From 18b29727148dda5a927245ee483b0fb4f7d89278 Mon Sep 17 00:00:00 2001 From: RevDev Date: Fri, 25 Sep 2026 16:26:05 +0300 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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. From 3df62e2c79228955ba8becdb6fe6bcca4ab4b6f4 Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 16:53:20 +0300 Subject: [PATCH 04/21] test(english): add captured-payload tests for nightjarreads Add captured-payload tests for the Nightjar Reads plugin to ensure proper functionality and regression coverage. --- .../english/__tests__/nightjarreads.test.mjs | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 plugins/english/__tests__/nightjarreads.test.mjs diff --git a/plugins/english/__tests__/nightjarreads.test.mjs b/plugins/english/__tests__/nightjarreads.test.mjs new file mode 100644 index 000000000..db900fd7f --- /dev/null +++ b/plugins/english/__tests__/nightjarreads.test.mjs @@ -0,0 +1,124 @@ +// Captured-payload tests for the Nightjar Reads plugin. +// +// These tests replay HTML captured from nightjarreads.com (see fixtures/) +// through the real plugin code — including extractFlightText(), whose +// flight-chunk regex was hardened to tolerate `;`/whitespace before +// `` — so parser regressions are caught without hitting the live +// site. The repo's `check:plugin` live check accepts an empty search result +// and reads only the first chapter, so it cannot cover these cases. +// +// The plugin is bundled with esbuild at test time; `@libs/*` imports are +// aliased to the shims in ./shims, where `@libs/fetch` serves the captured +// fixtures instead of the network. +// +// Run: npm install && node --test plugins/english/__tests__/nightjarreads.test.mjs +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const here = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +let esbuild; +try { + esbuild = require('esbuild'); +} catch { + throw new Error( + 'esbuild is required to bundle the plugin for tests: run `npm install` in the repo root first.', + ); +} + +let plugin; +before(async () => { + const dir = mkdtempSync(join(tmpdir(), 'nightjarreads-test-')); + const outFile = join(dir, 'plugin.bundle.mjs'); + await esbuild.build({ + entryPoints: [join(here, '..', 'nightjarreads.ts')], + bundle: true, + format: 'esm', + platform: 'node', + outfile: outFile, + logLevel: 'error', + // The fetch shim is bundled, so import.meta.url no longer points at the + // test dir at runtime; inject it as a compile-time constant instead. + define: { 'process.env.NIGHTJARREADS_TEST_DIR': JSON.stringify(here) }, + alias: { + '@libs/fetch': join(here, 'shims', 'fetch.mjs'), + '@libs/novelStatus': join(here, 'shims', 'novelStatus.mjs'), + '@/types/plugin': join(here, 'shims', 'types.mjs'), + }, + }); + plugin = (await import(outFile)).default; + assert.ok(plugin, 'plugin default export missing'); +}); + +describe('search (captured /search payload)', () => { + it('finds novels for a real query', async () => { + const results = await plugin.searchNovels('god of guns', 1); + // The captured page also carries a suggestion index before the query + // marker; exactly one result proves the marker sliced the right section. + assert.equal(results.length, 1); + const gog = results.find(r => r.path === '/novel/god-of-guns'); + assert.ok( + gog, + 'expected /novel/god-of-guns in ' + + JSON.stringify(results.map(r => r.path)), + ); + assert.equal(gog.name, 'God of Guns'); + assert.ok(gog.cover.startsWith('https://'), 'cover should be an https URL'); + }); + + it('returns nothing for a nonsense query (query marker must not leak results)', async () => { + const results = await plugin.searchNovels('zzzzqqqnotreal', 1); + assert.deepEqual(results, []); + }); + + // The flight-chunk regex must not depend on the closing tag's formatting: + // same captured payload, only the emitter's `` style changes. + for (const variant of ['semicolon', 'whitespace']) { + it(`extracts flight chunks when scripts end with "${variant}" formatting`, async () => { + process.env.NIGHTJARREADS_FIXTURE_VARIANT = variant; + try { + const results = await plugin.searchNovels('god of guns', 1); + assert.ok( + results.some(r => r.path === '/novel/god-of-guns'), + 'chunks were silently skipped with ' + variant + ' formatting', + ); + } finally { + delete process.env.NIGHTJARREADS_FIXTURE_VARIANT; + } + }); + } +}); + +describe('chapter (captured /novel// payloads)', () => { + it('parses a free chapter into HTML paragraphs', async () => { + const html = await plugin.parseChapter('/novel/god-of-guns/88'); + assert.ok( + html.length > 1000, + 'expected substantial content, got ' + html.length + ' chars', + ); + assert.ok( + html.startsWith('

'), + 'expected

HTML, got: ' + html.slice(0, 60), + ); + assert.ok( + !html.includes('Advance chapters at Nightjar Reads'), + 'promo footer must be filtered out', + ); + }); + + it('shows the locked notice for a locked premium chapter', async () => { + const html = await plugin.parseChapter( + '/novel/trenches-guns-and-magic/608', + ); + assert.ok( + html.includes('This chapter is locked'), + 'expected the locked-chapter notice, got: ' + html.slice(0, 120), + ); + }); +}); From c8c3f1e2a9254c4fab49236990353408820ff05c Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 16:53:53 +0300 Subject: [PATCH 05/21] test(english): add captured-payload tests for nightjarreads --- plugins/english/__tests__/shims/fetch.mjs | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 plugins/english/__tests__/shims/fetch.mjs diff --git a/plugins/english/__tests__/shims/fetch.mjs b/plugins/english/__tests__/shims/fetch.mjs new file mode 100644 index 000000000..1c3a1c16f --- /dev/null +++ b/plugins/english/__tests__/shims/fetch.mjs @@ -0,0 +1,41 @@ +// Test shim for `@libs/fetch`: serves captured fixture pages instead of the +// network. Used only by plugins/english/__tests__/nightjarreads.test.mjs. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// NOTE: import.meta.url points at the esbuild bundle, not this file, so the +// fixtures directory is injected at bundle time (see the test's esbuild +// `define`). +const fixturesDir = join(process.env.NIGHTJARREADS_TEST_DIR, 'fixtures'); +const read = f => readFileSync(join(fixturesDir, f), 'utf8'); + +// Same captured payload, reformatted the way other Next.js emitters close +// their flight scripts — exercises the hardened extractFlightText regex. +function withVariant(html) { + const v = process.env.NIGHTJARREADS_FIXTURE_VARIANT; + if (v === 'semicolon') + return html.replaceAll('"])', '"])'); + if (v === 'whitespace') + return html.replaceAll('"])', '"])\n '); + return html; +} + +export async function fetchText(url) { + const u = new URL(url); + if (u.pathname === '/search') { + const q = u.searchParams.get('q') || ''; + return withVariant( + read( + q === 'god of guns' ? 'search-god-of-guns.html' : 'search-empty.html', + ), + ); + } + if (u.pathname === '/novel/god-of-guns/88') return read('chapter-free.html'); + if (u.pathname === '/novel/trenches-guns-and-magic/608') + return read('chapter-locked.html'); + throw new Error('[test shim] no fixture for ' + url); +} + +export async function fetchApi() { + throw new Error('[test shim] fetchApi is not used by this plugin'); +} From 9a9b8d642c40dcb1876014e97ea5410bde501760 Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 16:54:15 +0300 Subject: [PATCH 06/21] test(english): add captured-payload tests for nightjarreads --- plugins/english/__tests__/shims/novelStatus.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 plugins/english/__tests__/shims/novelStatus.mjs diff --git a/plugins/english/__tests__/shims/novelStatus.mjs b/plugins/english/__tests__/shims/novelStatus.mjs new file mode 100644 index 000000000..0a5c452b5 --- /dev/null +++ b/plugins/english/__tests__/shims/novelStatus.mjs @@ -0,0 +1,11 @@ +// Test shim for `@libs/novelStatus`. Used only by +// plugins/english/__tests__/nightjarreads.test.mjs. +export const NovelStatus = { + Unknown: 'Unknown', + Ongoing: 'Ongoing', + Completed: 'Completed', + Licensed: 'Licensed', + PublishingFinished: 'Publishing Finished', + Cancelled: 'Cancelled', + OnHiatus: 'On Hiatus', +}; From 1ba050f6e556e832bd01f5c5652cec3c7d8f4b2c Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 16:54:37 +0300 Subject: [PATCH 07/21] test(english): add captured-payload tests for nightjarreads --- plugins/english/__tests__/shims/types.mjs | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 plugins/english/__tests__/shims/types.mjs diff --git a/plugins/english/__tests__/shims/types.mjs b/plugins/english/__tests__/shims/types.mjs new file mode 100644 index 000000000..1ebcc7844 --- /dev/null +++ b/plugins/english/__tests__/shims/types.mjs @@ -0,0 +1,4 @@ +// Test shim for `@/types/plugin`: the import is type-only and erased at +// build time; this module only needs to exist so the bundler can resolve it. +// Used only by plugins/english/__tests__/nightjarreads.test.mjs. +export default {}; From 21be71686635dcef54a7548bdb2f9d221ae8dee6 Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 16:56:33 +0300 Subject: [PATCH 08/21] test(english): add captured-payload tests for nightjarreads --- plugins/english/__tests__/fixtures/search-god-of-guns.html | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 plugins/english/__tests__/fixtures/search-god-of-guns.html diff --git a/plugins/english/__tests__/fixtures/search-god-of-guns.html b/plugins/english/__tests__/fixtures/search-god-of-guns.html new file mode 100644 index 000000000..a6b06a53c --- /dev/null +++ b/plugins/english/__tests__/fixtures/search-god-of-guns.html @@ -0,0 +1,4 @@ + + + + From 3ae7be5c8f36c9dea1a2979aaf0596fce64a3442 Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 16:57:06 +0300 Subject: [PATCH 09/21] test(english): add captured-payload tests for nightjarreads --- plugins/english/__tests__/fixtures/search-empty.html | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 plugins/english/__tests__/fixtures/search-empty.html diff --git a/plugins/english/__tests__/fixtures/search-empty.html b/plugins/english/__tests__/fixtures/search-empty.html new file mode 100644 index 000000000..b45129f47 --- /dev/null +++ b/plugins/english/__tests__/fixtures/search-empty.html @@ -0,0 +1,3 @@ + + + From 942d74ea35e693e70af74dc034b9ef1176046d71 Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 16:57:41 +0300 Subject: [PATCH 10/21] test(english): add captured-payload tests for nightjarreads --- plugins/english/__tests__/fixtures/chapter-free.html | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 plugins/english/__tests__/fixtures/chapter-free.html diff --git a/plugins/english/__tests__/fixtures/chapter-free.html b/plugins/english/__tests__/fixtures/chapter-free.html new file mode 100644 index 000000000..fe83be77b --- /dev/null +++ b/plugins/english/__tests__/fixtures/chapter-free.html @@ -0,0 +1,3 @@ + + + From b12f9d104f99bd707c1996a94117636858a9ffc2 Mon Sep 17 00:00:00 2001 From: RevDev Date: Sat, 26 Sep 2026 16:58:13 +0300 Subject: [PATCH 11/21] test(english): add captured-payload tests for nightjarreads --- plugins/english/__tests__/fixtures/chapter-locked.html | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 plugins/english/__tests__/fixtures/chapter-locked.html diff --git a/plugins/english/__tests__/fixtures/chapter-locked.html b/plugins/english/__tests__/fixtures/chapter-locked.html new file mode 100644 index 000000000..13fe6ac33 --- /dev/null +++ b/plugins/english/__tests__/fixtures/chapter-locked.html @@ -0,0 +1,3 @@ + + + From 61c7b2d157da957c3f8aedbddc5ae61f17ab0621 Mon Sep 17 00:00:00 2001 From: RevDev Date: Mon, 28 Sep 2026 11:48:37 +0300 Subject: [PATCH 12/21] Remove captured-payload tests per maintainer request --- .../english/__tests__/nightjarreads.test.mjs | 124 ------------------ 1 file changed, 124 deletions(-) delete mode 100644 plugins/english/__tests__/nightjarreads.test.mjs diff --git a/plugins/english/__tests__/nightjarreads.test.mjs b/plugins/english/__tests__/nightjarreads.test.mjs deleted file mode 100644 index db900fd7f..000000000 --- a/plugins/english/__tests__/nightjarreads.test.mjs +++ /dev/null @@ -1,124 +0,0 @@ -// Captured-payload tests for the Nightjar Reads plugin. -// -// These tests replay HTML captured from nightjarreads.com (see fixtures/) -// through the real plugin code — including extractFlightText(), whose -// flight-chunk regex was hardened to tolerate `;`/whitespace before -// `` — so parser regressions are caught without hitting the live -// site. The repo's `check:plugin` live check accepts an empty search result -// and reads only the first chapter, so it cannot cover these cases. -// -// The plugin is bundled with esbuild at test time; `@libs/*` imports are -// aliased to the shims in ./shims, where `@libs/fetch` serves the captured -// fixtures instead of the network. -// -// Run: npm install && node --test plugins/english/__tests__/nightjarreads.test.mjs -import { describe, it, before } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; - -const here = dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); - -let esbuild; -try { - esbuild = require('esbuild'); -} catch { - throw new Error( - 'esbuild is required to bundle the plugin for tests: run `npm install` in the repo root first.', - ); -} - -let plugin; -before(async () => { - const dir = mkdtempSync(join(tmpdir(), 'nightjarreads-test-')); - const outFile = join(dir, 'plugin.bundle.mjs'); - await esbuild.build({ - entryPoints: [join(here, '..', 'nightjarreads.ts')], - bundle: true, - format: 'esm', - platform: 'node', - outfile: outFile, - logLevel: 'error', - // The fetch shim is bundled, so import.meta.url no longer points at the - // test dir at runtime; inject it as a compile-time constant instead. - define: { 'process.env.NIGHTJARREADS_TEST_DIR': JSON.stringify(here) }, - alias: { - '@libs/fetch': join(here, 'shims', 'fetch.mjs'), - '@libs/novelStatus': join(here, 'shims', 'novelStatus.mjs'), - '@/types/plugin': join(here, 'shims', 'types.mjs'), - }, - }); - plugin = (await import(outFile)).default; - assert.ok(plugin, 'plugin default export missing'); -}); - -describe('search (captured /search payload)', () => { - it('finds novels for a real query', async () => { - const results = await plugin.searchNovels('god of guns', 1); - // The captured page also carries a suggestion index before the query - // marker; exactly one result proves the marker sliced the right section. - assert.equal(results.length, 1); - const gog = results.find(r => r.path === '/novel/god-of-guns'); - assert.ok( - gog, - 'expected /novel/god-of-guns in ' + - JSON.stringify(results.map(r => r.path)), - ); - assert.equal(gog.name, 'God of Guns'); - assert.ok(gog.cover.startsWith('https://'), 'cover should be an https URL'); - }); - - it('returns nothing for a nonsense query (query marker must not leak results)', async () => { - const results = await plugin.searchNovels('zzzzqqqnotreal', 1); - assert.deepEqual(results, []); - }); - - // The flight-chunk regex must not depend on the closing tag's formatting: - // same captured payload, only the emitter's `` style changes. - for (const variant of ['semicolon', 'whitespace']) { - it(`extracts flight chunks when scripts end with "${variant}" formatting`, async () => { - process.env.NIGHTJARREADS_FIXTURE_VARIANT = variant; - try { - const results = await plugin.searchNovels('god of guns', 1); - assert.ok( - results.some(r => r.path === '/novel/god-of-guns'), - 'chunks were silently skipped with ' + variant + ' formatting', - ); - } finally { - delete process.env.NIGHTJARREADS_FIXTURE_VARIANT; - } - }); - } -}); - -describe('chapter (captured /novel// payloads)', () => { - it('parses a free chapter into HTML paragraphs', async () => { - const html = await plugin.parseChapter('/novel/god-of-guns/88'); - assert.ok( - html.length > 1000, - 'expected substantial content, got ' + html.length + ' chars', - ); - assert.ok( - html.startsWith('

'), - 'expected

HTML, got: ' + html.slice(0, 60), - ); - assert.ok( - !html.includes('Advance chapters at Nightjar Reads'), - 'promo footer must be filtered out', - ); - }); - - it('shows the locked notice for a locked premium chapter', async () => { - const html = await plugin.parseChapter( - '/novel/trenches-guns-and-magic/608', - ); - assert.ok( - html.includes('This chapter is locked'), - 'expected the locked-chapter notice, got: ' + html.slice(0, 120), - ); - }); -}); From 23073b2529bd717be05f422e33f156943d4bb2e1 Mon Sep 17 00:00:00 2001 From: RevDev Date: Mon, 28 Sep 2026 11:48:56 +0300 Subject: [PATCH 13/21] Remove captured-payload tests per maintainer request --- plugins/english/__tests__/fixtures/chapter-free.html | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 plugins/english/__tests__/fixtures/chapter-free.html diff --git a/plugins/english/__tests__/fixtures/chapter-free.html b/plugins/english/__tests__/fixtures/chapter-free.html deleted file mode 100644 index fe83be77b..000000000 --- a/plugins/english/__tests__/fixtures/chapter-free.html +++ /dev/null @@ -1,3 +0,0 @@ - - - From d2b97d5a7c68f3c4c8a49afe2282d1951463fce2 Mon Sep 17 00:00:00 2001 From: RevDev Date: Mon, 28 Sep 2026 11:49:12 +0300 Subject: [PATCH 14/21] Remove captured-payload tests per maintainer request --- plugins/english/__tests__/fixtures/chapter-locked.html | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 plugins/english/__tests__/fixtures/chapter-locked.html diff --git a/plugins/english/__tests__/fixtures/chapter-locked.html b/plugins/english/__tests__/fixtures/chapter-locked.html deleted file mode 100644 index 13fe6ac33..000000000 --- a/plugins/english/__tests__/fixtures/chapter-locked.html +++ /dev/null @@ -1,3 +0,0 @@ - - - From b8e970e7303514bca1181529b930ec976409cfe2 Mon Sep 17 00:00:00 2001 From: RevDev Date: Mon, 28 Sep 2026 11:49:29 +0300 Subject: [PATCH 15/21] Remove captured-payload tests per maintainer request --- plugins/english/__tests__/fixtures/search-empty.html | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 plugins/english/__tests__/fixtures/search-empty.html diff --git a/plugins/english/__tests__/fixtures/search-empty.html b/plugins/english/__tests__/fixtures/search-empty.html deleted file mode 100644 index b45129f47..000000000 --- a/plugins/english/__tests__/fixtures/search-empty.html +++ /dev/null @@ -1,3 +0,0 @@ - - - From c12ffdffc4bb974c9b0bda8be7995738ca8b3bac Mon Sep 17 00:00:00 2001 From: RevDev Date: Mon, 28 Sep 2026 11:49:45 +0300 Subject: [PATCH 16/21] Remove captured-payload tests per maintainer request --- plugins/english/__tests__/fixtures/search-god-of-guns.html | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 plugins/english/__tests__/fixtures/search-god-of-guns.html diff --git a/plugins/english/__tests__/fixtures/search-god-of-guns.html b/plugins/english/__tests__/fixtures/search-god-of-guns.html deleted file mode 100644 index a6b06a53c..000000000 --- a/plugins/english/__tests__/fixtures/search-god-of-guns.html +++ /dev/null @@ -1,4 +0,0 @@ - - - - From cbde2144caa96ff77d457ca436ae90f575a11061 Mon Sep 17 00:00:00 2001 From: RevDev Date: Mon, 28 Sep 2026 11:50:01 +0300 Subject: [PATCH 17/21] Remove captured-payload tests per maintainer request --- plugins/english/__tests__/shims/fetch.mjs | 41 ----------------------- 1 file changed, 41 deletions(-) delete mode 100644 plugins/english/__tests__/shims/fetch.mjs diff --git a/plugins/english/__tests__/shims/fetch.mjs b/plugins/english/__tests__/shims/fetch.mjs deleted file mode 100644 index 1c3a1c16f..000000000 --- a/plugins/english/__tests__/shims/fetch.mjs +++ /dev/null @@ -1,41 +0,0 @@ -// Test shim for `@libs/fetch`: serves captured fixture pages instead of the -// network. Used only by plugins/english/__tests__/nightjarreads.test.mjs. -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -// NOTE: import.meta.url points at the esbuild bundle, not this file, so the -// fixtures directory is injected at bundle time (see the test's esbuild -// `define`). -const fixturesDir = join(process.env.NIGHTJARREADS_TEST_DIR, 'fixtures'); -const read = f => readFileSync(join(fixturesDir, f), 'utf8'); - -// Same captured payload, reformatted the way other Next.js emitters close -// their flight scripts — exercises the hardened extractFlightText regex. -function withVariant(html) { - const v = process.env.NIGHTJARREADS_FIXTURE_VARIANT; - if (v === 'semicolon') - return html.replaceAll('"])', '"])'); - if (v === 'whitespace') - return html.replaceAll('"])', '"])\n '); - return html; -} - -export async function fetchText(url) { - const u = new URL(url); - if (u.pathname === '/search') { - const q = u.searchParams.get('q') || ''; - return withVariant( - read( - q === 'god of guns' ? 'search-god-of-guns.html' : 'search-empty.html', - ), - ); - } - if (u.pathname === '/novel/god-of-guns/88') return read('chapter-free.html'); - if (u.pathname === '/novel/trenches-guns-and-magic/608') - return read('chapter-locked.html'); - throw new Error('[test shim] no fixture for ' + url); -} - -export async function fetchApi() { - throw new Error('[test shim] fetchApi is not used by this plugin'); -} From 5f249b541b63229f6ca1dc21f479f34c5b50b736 Mon Sep 17 00:00:00 2001 From: RevDev Date: Mon, 28 Sep 2026 11:50:17 +0300 Subject: [PATCH 18/21] Remove captured-payload tests per maintainer request --- plugins/english/__tests__/shims/novelStatus.mjs | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 plugins/english/__tests__/shims/novelStatus.mjs diff --git a/plugins/english/__tests__/shims/novelStatus.mjs b/plugins/english/__tests__/shims/novelStatus.mjs deleted file mode 100644 index 0a5c452b5..000000000 --- a/plugins/english/__tests__/shims/novelStatus.mjs +++ /dev/null @@ -1,11 +0,0 @@ -// Test shim for `@libs/novelStatus`. Used only by -// plugins/english/__tests__/nightjarreads.test.mjs. -export const NovelStatus = { - Unknown: 'Unknown', - Ongoing: 'Ongoing', - Completed: 'Completed', - Licensed: 'Licensed', - PublishingFinished: 'Publishing Finished', - Cancelled: 'Cancelled', - OnHiatus: 'On Hiatus', -}; From d9e235e97ac629ede64f287d4d31abc9b46f4c74 Mon Sep 17 00:00:00 2001 From: RevDev Date: Mon, 28 Sep 2026 11:50:36 +0300 Subject: [PATCH 19/21] Remove captured-payload tests per maintainer request --- plugins/english/__tests__/shims/types.mjs | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 plugins/english/__tests__/shims/types.mjs diff --git a/plugins/english/__tests__/shims/types.mjs b/plugins/english/__tests__/shims/types.mjs deleted file mode 100644 index 1ebcc7844..000000000 --- a/plugins/english/__tests__/shims/types.mjs +++ /dev/null @@ -1,4 +0,0 @@ -// Test shim for `@/types/plugin`: the import is type-only and erased at -// build time; this module only needs to exist so the bundler can resolve it. -// Used only by plugins/english/__tests__/nightjarreads.test.mjs. -export default {}; From 987bef1c39b87fd02c448c6bb5c272c8ac8f73f7 Mon Sep 17 00:00:00 2001 From: RevDev Date: Thu, 1 Oct 2026 13:31:11 +0300 Subject: [PATCH 20/21] Remove nightjarreads.ts ahead of audited replacement --- plugins/english/nightjarreads.ts | 375 ------------------------------- 1 file changed, 375 deletions(-) delete mode 100644 plugins/english/nightjarreads.ts diff --git a/plugins/english/nightjarreads.ts b/plugins/english/nightjarreads.ts deleted file mode 100644 index d8451796d..000000000 --- a/plugins/english/nightjarreads.ts +++ /dev/null @@ -1,375 +0,0 @@ -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. - * 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]*?)"\]\)\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 97b559fbad71a74cc6870c5cbed420401bd1ea58 Mon Sep 17 00:00:00 2001 From: RevDev Date: Thu, 1 Oct 2026 13:31:36 +0300 Subject: [PATCH 21/21] fix(english): harden Nightjar Reads parsing, bound search and inputs --- plugins/english/nightjarreads.ts | 490 +++++++++++++++++++++++++++++++ 1 file changed, 490 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..25c6c54f1 --- /dev/null +++ b/plugins/english/nightjarreads.ts @@ -0,0 +1,490 @@ +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 = '((?:[^"\\\\]|\\\\.)*)'; + +/** + * Upper bound on a page this plugin will parse. Site pages are orders + * of magnitude smaller; anything bigger is a broken or hostile + * response, and regex-scanning / JSON-parsing it would bill the + * reader's phone, not the site. Enforced before any parsing happens. + */ +const MAX_PAGE_CHARS = 10000000; + +/** + * Decode one flight string field. The field is a JSON string body, so + * decode it as JSON — that handles every escape (\", \\, \n, \uXXXX) + * exactly the way the site encoded it. A hand-rolled unescaper only + * ever covers the escapes its author remembered. If the body is not + * valid JSON (site changed shape), fall back to the two common escapes + * rather than losing the field entirely. + */ +function unescapeFlight(s: string): string { + try { + return JSON.parse('"' + s + '"'); + } catch { + 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. + * 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 { + if (html.length > MAX_PAGE_CHARS) + throw new Error("Page too large to parse safely"); + 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) { + try { + out += JSON.parse('"' + m[1] + '"'); + } catch { + /* skip malformed chunk */ + } + } + return out; +} + +/** + * The novel-card pattern, built once and shared: parseNovelEntries + * captures the slug (pass a capture group), findIndexEntry looks up one + * known slug (pass the escaped literal — no group, so field numbering + * shifts by one; both callers index accordingly). The card shape must + * only ever be edited here: two hand-written copies of this pattern + * are how the parsers drift apart. + */ +function entryPattern(slugPattern: string): string { + return ( + '"slug":"' + + slugPattern + + '","title":"' + + JSON_STR + + '","author":"' + + JSON_STR + + '","genres":(\\[[^\\]]*\\]),"glyph":"[^"]*","cover":\\[[^\\]]*\\],"coverUrl":"([^"]*)","chapters":(\\d+),"status":"([^"]*)"' + ); +} + +const ENTRY_RE = new RegExp(entryPattern("([a-z0-9-]+)"), "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( + entryPattern(slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + ); + 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], + }; +} + +const HTML_ENTITY_CHARS: Record = { + amp: "&", + quot: '"', + "#39": "'", + apos: "'", + lt: "<", + gt: ">", +}; + +function decodeHtmlEntities(s: string): string { + // Single pass, one regex: a chain of sequential .replace() calls decodes + // twice, turning a literal "&lt;" in the source into "<". The + // alternation also bounds the map lookup to exactly these keys. + return s.replace( + /&(amp|quot|#39|apos|lt|gt);/g, + (m, e: string) => HTML_ENTITY_CHARS[e] ?? m, + ); +} + +/** Title from the rendered

(in the flight data). */ +const TITLE_RE = new RegExp( + '"\\$","h1",null,\\{"className":"text-3xl[^"]*","children":"' + + JSON_STR + + '"\\}', +); + +/** Author: the
right after the "Author"
. */ +const AUTHOR_RE = new RegExp( + '"children":"Author"\\}\\],\\["\\$","dd",null,\\{"className":"mt-0\\.5 text-sm font-medium","children":"' + + JSON_STR + + '"\\}', +); + +/** Chapter list entries: flat {number,title,publishedAt,words,tier} objects. */ +const CHAPTER_RE = new RegExp( + '"number":(\\d+(?:\\.\\d+)?),"title":"' + + JSON_STR + + '","publishedAt":"([^"]*)","words":(\\d+),"tier":"([^"]*)"', + "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 = TITLE_RE.exec(flight); + if (m) details.name = unescapeFlight(m[1]); + + // Author: the
right after the "Author"
. + m = AUTHOR_RE.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 = /(); + CHAPTER_RE.lastIndex = 0; + let chm: RegExpExecArray | null; + while ((chm = CHAPTER_RE.exec(flight)) !== null) { + const num = parseFloat(chm[1]); + 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, ">"); +} + +/** The chapter body: a JSON string array followed by translatorNotes. */ +const BODY_RE = /"body":\[\"([\s\S]*?)\"\],\"translatorNotes\"/; + +/** + * True when THIS chapter's record says the chapter is locked. The flag + * is looked up only where the site puts it: in the record fields right + * AFTER the chapter's "body" value — never across the whole page, and + * never inside the body text itself. An unscoped test over the entire + * blob would let any unrelated record containing "locked":true + * (another novel's data, a recommendation payload) mislabel a free + * chapter as locked. (A locked chapter's body is null, so for that + * case the window starts at the "body" key itself.) + */ +function isLockedChapter( + flight: string, + bodyMatch: RegExpExecArray | null, +): boolean { + const from = bodyMatch + ? bodyMatch.index + bodyMatch[0].length + : flight.indexOf('"body":'); + if (from < 0) return false; + return /"locked":true/.test(flight.slice(from, from + 400)); +} + +/** + * 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 { + const m = BODY_RE.exec(flight); + if (isLockedChapter(flight, m)) return LOCKED_MESSAGE; + if (!m) return null; + let parsedBody: unknown; + try { + parsedBody = JSON.parse('["' + m[1] + '"]'); + } catch { + return null; + } + if (!Array.isArray(parsedBody)) return null; + // Body members are strings by contract, but the array comes from the + // page: one non-string member used to throw out of the .map below + // (p.trim is not a function) and crash the chapter load, instead of + // returning null as this function documents. Non-strings are dropped. + const paras = parsedBody + .filter((p): p is string => typeof p === "string") + .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"); +} + +/** + * How far past the search marker the results section can run. On real + * pages the result links sit within a few hundred characters of the + * marker; the bound exists so links from unrelated later sections + * (recommendations, rails) can never be collected as results. + */ +const SEARCH_SECTION_MAX_CHARS = 200000; + +/** + * Parse a /search?q=... page: result slugs in order, looked up in the index. + * + * The marker is built from the query as the site stores it — JSON-escaped + * inside the flight payload. Searching for the RAW query meant any + * query containing a quote never matched its marker (the site stores + * \"), and the fallback then scanned the whole page, returning whatever + * links it found in whatever order. The scan is also bounded to the + * results section: from the marker to the next section's marker or + * SEARCH_SECTION_MAX_CHARS, whichever comes first. + */ +function parseSearchResults(flight: string, query: string): NovelCard[] { + const marker = + '"initial":"' + query.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; + const start = flight.indexOf(marker); + let section = flight; + if (start >= 0) { + let end = flight.indexOf('"initial":"', start + marker.length); + if (end < 0 || end > start + SEARCH_SECTION_MAX_CHARS) + end = Math.min(flight.length, start + SEARCH_SECTION_MAX_CHARS); + section = flight.slice(start, end); + } + // The page itself states how many results it found, right after the + // marker ("children":[N," ","result"," for ...). When that count is + // readable, take exactly N links — a recommendation rail further down + // the page can no longer leak in, however close it sits. When it + // isn't (site changed the wording), the bounded scan above is the + // fallback. + let limit = Infinity; + const countM = /\[(\d+)," ","result/.exec(section.slice(0, 2000)); + if (countM) limit = parseInt(countM[1], 10); + 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 && order.length < limit) { + 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();