From 18b29727148dda5a927245ee483b0fb4f7d89278 Mon Sep 17 00:00:00 2001 From: RevDev Date: Fri, 25 Sep 2026 16:26:05 +0300 Subject: [PATCH 1/3] feat(english): add Nightjar Reads source plugin --- plugins/english/nightjarreads.ts | 372 +++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 plugins/english/nightjarreads.ts diff --git a/plugins/english/nightjarreads.ts b/plugins/english/nightjarreads.ts new file mode 100644 index 000000000..dd7eed904 --- /dev/null +++ b/plugins/english/nightjarreads.ts @@ -0,0 +1,372 @@ +import { fetchText } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; + +type NovelCard = { + slug: string; + title: string; + author: string; + genres: string[]; + coverUrl: string; + chapterCount: number; + status: string; +}; + +type ChapterInfo = { + number: number; + title: string; + publishedAt: string; + tier: string; +}; + +type NovelDetails = { + name: string; + author: string; + genres: string[]; + status: string; + cover: string; + summary: string; + chapters: ChapterInfo[]; +}; + +/** Matches a JSON string body with escaped quotes, e.g. "a \"quoted\" title". */ +const JSON_STR = '((?:[^"\\\\]|\\\\.)*)'; + +/** Undo one extra level of escaping left by nested JSON-in-RSC payloads. */ +function unescapeFlight(s: string): string { + return s.replace(/\\"/g, '"').replace(/\\'/g, "'"); +} + +/** + * Next.js app-router pages embed their data in + * self.__next_f.push([1,""]) + * scripts. Decode every payload into one searchable text blob. + */ +function extractFlightText(html: string): string { + const re = /self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)<\/script>/g; + let out = ''; + let m: RegExpExecArray | null; + while ((m = re.exec(html)) !== null) { + try { + out += JSON.parse('"' + m[1] + '"'); + } catch { + /* skip malformed chunk */ + } + } + return out; +} + +const ENTRY_RE = new RegExp( + '"slug":"([a-z0-9-]+)","title":"' + + JSON_STR + + '","author":"' + + JSON_STR + + '","genres":(\\[[^\\]]*\\]),"glyph":"[^"]*","cover":\\[[^\\]]*\\],"coverUrl":"([^"]*)","chapters":(\\d+),"status":"([^"]*)"', + 'g', +); + +/** Parse every novel card object embedded in flight data (browse/search pages). */ +function parseNovelEntries(flight: string): NovelCard[] { + const out: NovelCard[] = []; + const seen = new Set(); + ENTRY_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ENTRY_RE.exec(flight)) !== null) { + if (seen.has(m[1])) continue; + seen.add(m[1]); + let genres: string[] = []; + try { + genres = JSON.parse(m[4]); + } catch { + /* keep empty */ + } + out.push({ + slug: m[1], + title: unescapeFlight(m[2]), + author: unescapeFlight(m[3]), + genres, + coverUrl: m[5], + chapterCount: parseInt(m[6], 10), + status: m[7], + }); + } + return out; +} + +/** Find the novel's own card in the page's embedded novel index (header data). */ +function findIndexEntry(flight: string, slug: string): NovelCard | null { + const re = new RegExp( + '"slug":"' + + slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + '","title":"' + + JSON_STR + + '","author":"' + + JSON_STR + + '","genres":(\\[[^\\]]*\\]),"glyph":"[^"]*","cover":\\[[^\\]]*\\],"coverUrl":"([^"]*)","chapters":(\\d+),"status":"([^"]*)"', + ); + const m = re.exec(flight); + if (!m) return null; + let genres: string[] = []; + try { + genres = JSON.parse(m[3]); + } catch { + /* keep empty */ + } + return { + slug, + title: unescapeFlight(m[1]), + author: unescapeFlight(m[2]), + genres, + coverUrl: m[4], + chapterCount: parseInt(m[5], 10), + status: m[6], + }; +} + +function decodeHtmlEntities(s: string): string { + return s + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +/** Parse a /novel/ page into its details + full chapter list. */ +function parseNovelPage( + html: string, + flight: string, + slug: string, +): NovelDetails { + const details: NovelDetails = { + name: slug, + author: '', + genres: [], + status: '', + cover: '', + summary: '', + chapters: [], + }; + + let m: RegExpExecArray | null; + + // Title from the rendered

. + m = new RegExp( + '"\\$","h1",null,\\{"className":"text-3xl[^"]*","children":"' + + JSON_STR + + '"\\}', + ).exec(flight); + if (m) details.name = unescapeFlight(m[1]); + + // Author: the
right after the "Author"
. + m = new RegExp( + '"children":"Author"\\}\\],\\["\\$","dd",null,\\{"className":"mt-0\\.5 text-sm font-medium","children":"' + + JSON_STR + + '"\\}', + ).exec(flight); + if (m) details.author = unescapeFlight(m[1]); + + // Genres: prefer the novel's own entry in the embedded novel index; + // fall back to the "#Tag" chips in the tag row under the header. + const indexEntry = findIndexEntry(flight, slug); + if (indexEntry && indexEntry.genres.length > 0) { + details.genres = indexEntry.genres; + } else { + const chipRow = flight.indexOf('mt-3 flex flex-wrap gap-1.5'); + if (chipRow >= 0) { + const chipRe = /"children":\["#","([^"]*)"\]/g; + chipRe.lastIndex = chipRow; + let cm: RegExpExecArray | null; + let count = 0; + while ( + (cm = chipRe.exec(flight)) !== null && + cm.index < chipRow + 6000 && + count < 40 + ) { + details.genres.push(cm[1]); + count++; + } + } + } + + // Fill any gaps from the index entry. + if (indexEntry) { + if (!details.author && indexEntry.author) + details.author = indexEntry.author; + if (!details.status && indexEntry.status) + details.status = indexEntry.status; + if (!details.cover && indexEntry.coverUrl) + details.cover = indexEntry.coverUrl; + if (details.name === slug && indexEntry.title) + details.name = indexEntry.title; + } + + // Status chip (Ongoing / Completed / ...). + m = + /"className":"chip"[\s\S]{0,150}?"children":"(Ongoing|Completed|Hiatus|Dropped|On Hiatus|Cancelled)"/.exec( + flight, + ); + if (m) details.status = m[1]; + + // Cover: first supabase-hosted cover image on the page is the novel's own. + m = /"src":"(https:\/\/[^\"]*?supabase[^\"]*?covers[^\"]*?)"/.exec(flight); + if (m) details.cover = m[1]; + + // Synopsis from the meta description tag. + m = /(); + let chm: RegExpExecArray | null; + while ((chm = chRe.exec(flight)) !== null) { + const num = parseInt(chm[1], 10); + if (seenCh.has(num)) continue; + seenCh.add(num); + details.chapters.push({ + number: num, + title: unescapeFlight(chm[2]), + publishedAt: chm[3], + tier: chm[5], + }); + } + details.chapters.sort((a, b) => a.number - b.number); + + return details; +} + +const LOCKED_MESSAGE = + '

This chapter is locked on Nightjar Reads.

' + + '

It is a premium chapter — unlock it on nightjarreads.com to read it here.

'; + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * Parse a /novel// chapter page. + * Returns the chapter HTML, or LOCKED_MESSAGE when the chapter is premium. + * Returns null when the page has no readable content at all. + */ +function parseChapterPage(flight: string): string | null { + if (/"locked":true/.test(flight)) return LOCKED_MESSAGE; + const m = /"body":\["([\s\S]*?)"\],"translatorNotes"/.exec(flight); + if (!m) return null; + let paras: string[]; + try { + paras = JSON.parse('["' + m[1] + '"]'); + } catch { + return null; + } + paras = paras + .map(p => p.trim()) + .filter(p => p.length > 0 && !/Advance chapters at Nightjar Reads/.test(p)); + if (paras.length === 0) return null; + return paras.map(p => '

' + escapeHtml(p) + '

').join('\n'); +} + +/** Parse a /search?q=... page: result slugs in order, looked up in the index. */ +function parseSearchResults(flight: string, query: string): NovelCard[] { + const marker = '"initial":"' + query + '"'; + const start = flight.indexOf(marker); + const section = start >= 0 ? flight.slice(start) : flight; + const hrefRe = /"href":"\/novel\/([a-z0-9-]+)"/g; + const order: string[] = []; + const seen = new Set(); + let hm: RegExpExecArray | null; + while ((hm = hrefRe.exec(section)) !== null) { + if (!seen.has(hm[1])) { + seen.add(hm[1]); + order.push(hm[1]); + } + } + const index = new Map(parseNovelEntries(flight).map(n => [n.slug, n])); + return order.map(slug => index.get(slug)).filter((n): n is NovelCard => !!n); +} + +class NightjarReads implements Plugin.PluginBase { + id = 'nightjarreads'; + name = 'Nightjar Reads'; + icon = 'src/en/nightjarreads/icon.png'; + site = 'https://nightjarreads.com'; + version = '1.0.0'; + + async popularNovels(pageNo: number): Promise { + if (pageNo > 1) return []; + const html = await fetchText(this.site + '/browse?sort=popular'); + return parseNovelEntries(extractFlightText(html)).map(n => ({ + name: n.title, + path: '/novel/' + n.slug, + cover: n.coverUrl, + })); + } + + async parseNovel(novelPath: string): Promise { + const slug = novelPath.split('/').filter(Boolean).pop() || ''; + const html = await fetchText(this.site + novelPath); + const d = parseNovelPage(html, extractFlightText(html), slug); + + let status: string = NovelStatus.Unknown; + if (d.status === 'Ongoing') status = NovelStatus.Ongoing; + else if (d.status === 'Completed') status = NovelStatus.Completed; + else if (d.status === 'On Hiatus' || d.status === 'Hiatus') + status = NovelStatus.OnHiatus; + else if (d.status === 'Cancelled' || d.status === 'Dropped') + status = NovelStatus.Cancelled; + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: d.name, + status, + }; + if (d.cover) novel.cover = d.cover; + if (d.author) novel.author = d.author; + if (d.genres.length) novel.genres = d.genres.join(', '); + if (d.summary) novel.summary = d.summary; + novel.chapters = d.chapters.map(c => ({ + name: 'Chapter ' + c.number + ': ' + c.title, + path: '/novel/' + slug + '/' + c.number, + releaseTime: c.publishedAt, + chapterNumber: c.number, + })); + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const html = await fetchText(this.site + chapterPath); + const content = parseChapterPage(extractFlightText(html)); + if (content === null) { + return ( + '

Could not load this chapter.

' + + '

It may be locked or temporarily unavailable on Nightjar Reads.

' + ); + } + return content; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo > 1) return []; + const html = await fetchText( + this.site + '/search?q=' + encodeURIComponent(searchTerm), + ); + const flight = extractFlightText(html); + return parseSearchResults(flight, searchTerm).map(n => ({ + name: n.title, + path: '/novel/' + n.slug, + cover: n.coverUrl, + })); + } + + resolveUrl = (path: string): string => this.site + path; +} + +export default new NightjarReads(); From f912fe1fed44399cd42ead10e97ff905ddc1fd57 Mon Sep 17 00:00:00 2001 From: RevDev Date: Fri, 25 Sep 2026 16:27:16 +0300 Subject: [PATCH 2/3] Add Nightjar Reads plugin icon --- public/static/src/en/nightjarreads/icon.png | Bin 0 -> 4281 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/static/src/en/nightjarreads/icon.png diff --git a/public/static/src/en/nightjarreads/icon.png b/public/static/src/en/nightjarreads/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6927c111abe866e02bf6f64f2911f6e55e410e74 GIT binary patch literal 4281 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 Date: Fri, 25 Sep 2026 16:50:37 +0300 Subject: [PATCH 3/3] Harden flight chunk regex (review feedback) --- plugins/english/nightjarreads.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/english/nightjarreads.ts b/plugins/english/nightjarreads.ts index dd7eed904..d8451796d 100644 --- a/plugins/english/nightjarreads.ts +++ b/plugins/english/nightjarreads.ts @@ -41,9 +41,12 @@ function unescapeFlight(s: string): string { * Next.js app-router pages embed their data in * self.__next_f.push([1,""]) * scripts. Decode every payload into one searchable text blob. + * The closing tag match tolerates an optional semicolon / whitespace + * (some Next.js versions emit `);`), so chunks are never + * silently skipped due to formatting. */ function extractFlightText(html: string): string { - const re = /self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)<\/script>/g; + const re = /self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)\s*;?\s*<\/script>/g; let out = ''; let m: RegExpExecArray | null; while ((m = re.exec(html)) !== null) { @@ -209,7 +212,7 @@ function parseNovelPage( if (m) details.status = m[1]; // Cover: first supabase-hosted cover image on the page is the novel's own. - m = /"src":"(https:\/\/[^\"]*?supabase[^\"]*?covers[^\"]*?)"/.exec(flight); + m = /"src":"(https:\/\/[^"]*?supabase[^"]*?covers[^"]*?)"/.exec(flight); if (m) details.cover = m[1]; // Synopsis from the meta description tag.