-
Notifications
You must be signed in to change notification settings - Fork 378
feat(english): add Nightjar Reads source plugin #2575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
RevDev909
wants to merge
3
commits into
lnreader:master
Choose a base branch
from
RevDev909:feat/nightjarreads-plugin
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+375
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,375 @@ | ||
| 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,"<escaped payload>"])</script> | ||
| * scripts. Decode every payload into one searchable text blob. | ||
| * The closing tag match tolerates an optional semicolon / whitespace | ||
| * (some Next.js versions emit `);</script>`), 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<string>(); | ||
| 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/<slug> 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 <h1>. | ||
| m = new RegExp( | ||
| '"\\$","h1",null,\\{"className":"text-3xl[^"]*","children":"' + | ||
| JSON_STR + | ||
| '"\\}', | ||
| ).exec(flight); | ||
| if (m) details.name = unescapeFlight(m[1]); | ||
|
|
||
| // Author: the <dd> right after the "Author" <dt>. | ||
| 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 = /<meta name="description" content="([\s\S]*?)"/.exec(html); | ||
| if (m) details.summary = decodeHtmlEntities(m[1]).replace(/\\n/g, '\n'); | ||
|
|
||
| // Chapter list: flat {number,title,publishedAt,words,tier} objects. | ||
| const chRe = new RegExp( | ||
| '"number":(\\d+),"title":"' + | ||
| JSON_STR + | ||
| '","publishedAt":"([^"]*)","words":(\\d+),"tier":"([^"]*)"', | ||
| 'g', | ||
| ); | ||
| const seenCh = new Set<number>(); | ||
| 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 = | ||
| '<p><strong>This chapter is locked on Nightjar Reads.</strong></p>' + | ||
| '<p>It is a premium chapter — unlock it on nightjarreads.com to read it here.</p>'; | ||
|
|
||
| function escapeHtml(s: string): string { | ||
| return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | ||
| } | ||
|
|
||
| /** | ||
| * Parse a /novel/<slug>/<n> 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 => '<p>' + escapeHtml(p) + '</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<string>(); | ||
| 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<Plugin.NovelItem[]> { | ||
| 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<Plugin.SourceNovel> { | ||
| 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<string> { | ||
| const html = await fetchText(this.site + chapterPath); | ||
| const content = parseChapterPage(extractFlightText(html)); | ||
| if (content === null) { | ||
| return ( | ||
| '<p><strong>Could not load this chapter.</strong></p>' + | ||
| '<p>It may be locked or temporarily unavailable on Nightjar Reads.</p>' | ||
| ); | ||
| } | ||
| return content; | ||
| } | ||
|
|
||
| async searchNovels( | ||
| searchTerm: string, | ||
| pageNo: number, | ||
| ): Promise<Plugin.NovelItem[]> { | ||
| 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(); | ||
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add captured-payload tests for search and chapter parsing. The live checker accepts an empty search result and reads only the first chapter, so it cannot catch changes that make the query marker, locked-chapter check, or body extraction show unrelated results or unreadable chapters.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!