Skip to content

feat(spanish): add novels2 source plugin - #2687

Open
RibatTRW wants to merge 4 commits into
lnreader:masterfrom
RibatTRW:fm/lnreader-novels2-120
Open

RibatTRW wants to merge 4 commits into
lnreader:masterfrom
RibatTRW:fm/lnreader-novels2-120

Conversation

@RibatTRW

@RibatTRW RibatTRW commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Screenshots

These come from this repo's plugin playground (npm run dev:start), run headless. They are not from the LNReader app: the Android toolchain is not installed on the machine that made this change, so the plugin has not been tested in the app.

1. Popular listing (Novels2 source, live results). Covers that show "Book cover not available" use the default cover.

Plugin playground: Novels2 popular listing

2. Search for "escamas".

Plugin playground: Novels2 search

3. Novel detail with its full chapter list (12 chapters).

Plugin playground: Novels2 novel detail and chapter list

4. Longest checked novel: all 253 chapters are listed, not just the first 20.

Plugin playground: Novels2 longest novel, all 253 chapters

5. Chapter text (readable Spanish).

Plugin playground: Novels2 chapter text

Fixes #120
Closes #120

This PR was written by an AI agent (Claude Haiku 5.5). No human has reviewed it yet.


Intent

Work on #120 and include screenshots of the working fix in the PR.

Issue 120, "[Source Request] NovelS2" (opened four years ago): the requester linked a novels2.com page and asked for the site as a source. The link they gave was a Vietnamese /truyen/... path that no longer exists - the site has changed since - but the site itself is live today: https://www.novels2.com/ returns 200 with a real listing, and a chapter page (for example a /novel/<slug>/parte-3-capitulo-2-... URL) returns 200 with about 11.6k characters of readable Spanish text. No plugin and no generated source covers novels2.com today, so the request is still open. The site's content is Spanish.

Make sure the fix's pull request shows a photo of the plugin working correctly inside the plugin playground, and run that screenshot process headless if possible.

What Changed

  • Adds a standalone Spanish source plugin plugins/spanish/novels2.ts (id novels2, site https://www.novels2.com) with popular listing (/?page=N) and search (/search?keyword=...), both parsed from the same novel-card markup and using the *_failed.png placeholder fallback to defaultCover.
  • parseNovel reads the title, cover, author, synopsis, genres, and status (ongoing/completed/unknown). The full chapter list is extracted from the Next.js RSC payload embedded in the novel page, since the rendered markup shows only the first 20 chapters. Chapters are sorted by their site number and carry a release time.
  • parseChapter returns the paragraphs inside <article> as HTML and throws if no chapter text is found. Adds the 96x96 icon at public/static/src/es/novels2/icon.png.

Risk Assessment

✅ Low: The change adds one new standalone plugin with a 96x96 icon and no edits to shared code; I traced the chapter regex and the two-pass RSC unescape by hand and found no wrong-value path, but live selectors and the chapter URL shape (slug plus id) are unverified because I did not run the live check, and the applied two-pass decode differs from the recorded single-pass decision, which I judged more correct for the escaping the regex captures.

Testing

Ran the baseline live check, a live harness against the real site covering listing, pagination, search, novel metadata, the full ordered chapter list (including a 288-chapter novel beyond the 20 in markup), chapter text, and three adversarial RSC cases, and drove the real playground headlessly through Popular, Search, Parse Novel, and Parse Chapter with screenshots. All live scenarios passed. The resolveUrl check was run in-process and was not driven live, so it is marked untested. The adversarial cases were run through a fetch wrapper that mutates the real site response, so they are live against the real plugin code but use a transport-level fixture. Screenshots are in the evidence directory.

  • Live validation: ✅ go - 12 of 13 scenarios driven live against the product
Scenario Result Live Evidence
Popular listing: user browses the latest novels and gets real cards with names, paths, and covers ✅ pass live playground-novels2-popular.png; harness-output.txt 'listing page 1'
Popular pagination: page 2 returns different novels than page 1 ✅ pass live harness-output.txt 'pagination returns different novels on page 2'
Search: searching 'Escamas de Sangre' returns the matching novel in the playground and the harness ✅ pass live playground-novels2-search.png; harness-output.txt 'search finds the requested novel'
Parse Novel: metadata (title, author, status, genres, summary, cover, total/first/last chapters) renders in the playground ✅ pass live playground-novels2-parse-novel.png; harness-output.txt 'novel detail metadata'
Parse Novel chapter list: 12 chapters in ascending order with unique paths and release times ✅ pass live playground-novels2-parse-novel.png; harness-output.txt 'chapter list ordered ascending'
Full RSC chapter list: a novel with more than 20 chapters returns all of them, ascending (288 chapters) ✅ pass live harness-output.txt 'novel with >20 chapters returns the full RSC list'
Parse Chapter: a chapter URL returns readable Spanish text (about 8k characters) ✅ pass live playground-novels2-parse-chapter.png; harness-output.txt 'chapter body is readable text'
resolveUrl joins the site origin with a novel path ⏸️ untested no resolveUrl is a pure string helper. It was exercised only in-process against the bundled plugin (harness-output.txt 'resolveUrl joins site and path'), not against the live product, so this run does no…
Adversarial: RSC key shape changes (createdAt renamed) while chapter data is present; parse must throw, not return an empty list ✅ pass live harness-output.txt 'renamed createdAt key ... throws' ('Could not parse chapter list for /novel/escamas-de-sangre-espaol-162166')
Adversarial: a novel payload with no chapter data returns an empty list without throwing ✅ pass live harness-output.txt 'novel payload with no chapter data returns empty list'
Adversarial: a chapter title containing a literal backslash-u sequence decodes to the original text ✅ pass live harness-output.txt 'literal backslash-u in title decodes to the original text' (name 'A\u0041')
Adversarial: escaped quotes in a chapter title decode correctly ✅ pass live harness-output.txt 'escaped quotes in title decode correctly' (name 'Di "hola"')
Plugin icon: Novels2 appears in the playground sidebar with its icon ✅ pass live playground-novels2-parse-novel.png (sidebar shows the Novels2 entry with its icon)
  • Evidence: Playground: Popular listing (Novels2) (local file: ~/.no-mistakes/evidence/01M4ZV5DN5QDD0HDGP3AJ5FNWF/playground-novels2-popular.png)
  • Evidence: Playground: Search results for Escamas de Sangre (local file: ~/.no-mistakes/evidence/01M4ZV5DN5QDD0HDGP3AJ5FNWF/playground-novels2-search.png)
  • Evidence: Playground: Parse Novel with full 12-chapter list (local file: ~/.no-mistakes/evidence/01M4ZV5DN5QDD0HDGP3AJ5FNWF/playground-novels2-parse-novel.png)
  • Evidence: Playground: Parse Chapter readable Spanish text (local file: ~/.no-mistakes/evidence/01M4ZV5DN5QDD0HDGP3AJ5FNWF/playground-novels2-parse-chapter.png)
Evidence: Live harness output
PASS | live: listing page 1 returns novel cards | {"count":25,"first":{"name":"Escamas de Sangre [Español]","path":"/novel/escamas-de-sangre-espaol-162166","cover":"https://novels2.com/obs/novels2/cover/01-escamas-de-sangre-espaol.jpg"}}
PASS | live: listing pagination returns different novels on page 2 | {"page2Count":25,"page2First":"/novel/outmatched-survival-among-the-powerful-163429"}
PASS | live: search finds the requested novel | {"results":["Escamas de Sangre [Español]"]}
PASS | live: novel detail metadata | {"name":"Escamas de Sangre [Español]","author":"Falkor Noctis","status":"Ongoing","genres":"Fantasy, Action, Adventure, Mystery","summaryChars":307,"cover":"https://novels2.com/obs/novels2/cover/01-escamas-de-sangre-espaol.jpg"}
PASS | live: chapter list ordered ascending with unique paths | {"count":12,"first":{"name":"Huele a gas","path":"/novel/escamas-de-sangre-espaol-162166/huele-a-gas-4300077","releaseTime":"2026-10-03T01:38:06.013Z","chapterNumber":1},"last":{"name":"Parte 3: Capítulo 2: danza de 4 alas","path":"/novel/escamas-de-sangre-espaol-162166/parte-3-captulo-2-danza-de-4-alas-4351119","releaseTime":"2026-10-15T02:41:54.300Z","chapterNumber":12}}
PASS | live: novel with >20 chapters returns the full RSC list (beyond markup 20) | {"path":"/novel/the-will-to-live-is-my-strongest-skill-vol-14-complete-118145","count":288,"firstNumber":1,"lastNumber":286,"releaseTime":"2026-01-02T01:39:25.634Z"}
PASS | live: chapter body is readable text | {"chars":7658,"sample":"Ha caído la noche, a pesar de ello el calor del recién comenzado el verano golpe"}
PASS | live: resolveUrl joins site and path | undefined
PASS | adversarial: renamed createdAt key with chapter data present throws | {"threw":"Could not parse chapter list for /novel/escamas-de-sangre-espaol-162166"}
PASS | adversarial: novel payload with no chapter data returns empty list (no throw) | {"error":null,"chapters":0}
PASS | adversarial: literal backslash-u in title decodes to the original text | {"name":"A\\u0041"}
PASS | adversarial: escaped quotes in title decode correctly | {"name":"Di \"hola\""}

SUMMARY: 12/12 passed
- Outcome: 🔧 3 issues found → auto-fixed ✅ across 2 runs (47m24s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

🔧 **Test** - 3 issues found → auto-fixed ✅
  • ⚠️ plugins/spanish/novels2.ts:147 - Zero-chapter guard keys on the literal createdAt key, so when the RSC chapter shape changes (for example createdAt renamed) and the regex matches nothing, parseNovel returns an empty chapter list with no error. Recorded decision silent-empty-chapters requires a throw when chapter data is present but unmatched.
  • ℹ️ plugins/spanish/novels2.ts:147 - The site data itself has two duplicate chapterNumber values on a 288-chapter novel (13 and 74). The plugin keeps the site order, so this is not a plugin defect.
  • 🚨 live validation verdict: no-go (9 of 9 scenarios were driven live against the product); failed: Adversarial: RSC chapter payload with a renamed createdAt key and chapter data present must throw a clear parse error, not return an empty list
  • Live validation: ❌ no-go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Popular listing in the playground shows Novels2 novels with covers, and falls back to the default cover when the site has none ✅ pass live 01-novels2-popular.png (Escamas de Sangre, Blood Bound, Dexter4Life, and others; placeholder cards show BOOK COVER NOT AVAILABLE)
Search for Escamas de Sangre in the playground returns the matching novel card ✅ pass live 02-novels2-search.png; live check searchNovels PASS (1 result)
Parse Novel in the playground shows title, author, status, metadata and the full chapter count (288, beyond the 20 rendered in markup) ✅ pass live 03-novels2-parse-novel.png (Total Chapters 288); live driver counted 288 chapters from parseNovel
Chapter list is returned in site order with ascending chapter numbers and clean titles ✅ pass live Live driver on the 288-chapter novel: first chapter number 1, last 286, two duplicate numbers (13, 74) that come from the site data, not the plugin
Opening a chapter returns readable Spanish paragraph HTML ✅ pass live Live check parseChapter PASS 8127 chars; live driver parseChapter on the first chapter returned 6100 chars
Adversarial: a chapter title containing a literal backslash-u sequence decodes exactly, not as an escape ✅ pass live Fixture run of the real plugin code: a title serialized with four backslashes before u0041 decodes to the literal text backslash u0041
Adversarial: RSC chapter payload with a renamed createdAt key and chapter data present must throw a clear parse error, not return an empty list ❌ fail live Fixture run: parseNovel on a page whose chapter keys are renamed createdOn and updatedOn returned OK with chapters=0 and no error
Adversarial: RSC chapter payload with reordered keys throws a clear parse error ✅ pass live Fixture run: parseNovel threw Could not parse chapter list for /novel/reordered
A novel with genuinely no chapter data returns an empty chapter list without throwing ✅ pass live Fixture run: parseNovel returned chapters=0 with no error
  • npm ci in the worktree (dependencies)
  • npm run check:plugin -- plugins/spanish/novels2.ts (live against novels2.com: popularNovels PASS 25 novels, searchNovels PASS, parseNovel PASS 12 chapters, parseChapter PASS 8127 chars)
  • Live driver (plugin bundled with esbuild, run against https://www.novels2.com): popularNovels page 1, searchNovels Escamas, parseNovel on The Will to Live Is My Strongest Skill (288 chapters, not capped at 20), ordering and duplicate-number check, parseChapter on chapter 1 (6100 chars)
  • Fixture-driven adversarial run of the real plugin code against a local stand-in server on 127.0.0.1:8765 built from a live novel page: literal backslash-u title, renamed createdAt key, reordered keys, and a page with no chapter data
  • Playground run: vite on 127.0.0.2:3000 from this worktree, headless Chromium driven over CDP with real mouse and keyboard events, host-resolver mapping localhost:3000 to the loopback alias. Selected Novels2, Popular Fetch, Search Escamas de Sangre, Parse Novel on the 288-chapter novel path
  • Removed generated multi-source outputs with npm run clean:multisrc; stopped my dev server, fixture server and Chromium; worktree has no tracked changes

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 12 of 13 scenarios driven live against the product
Scenario Result Live Evidence
Popular listing: user browses the latest novels and gets real cards with names, paths, and covers ✅ pass live playground-novels2-popular.png; harness-output.txt 'listing page 1'
Popular pagination: page 2 returns different novels than page 1 ✅ pass live harness-output.txt 'pagination returns different novels on page 2'
Search: searching 'Escamas de Sangre' returns the matching novel in the playground and the harness ✅ pass live playground-novels2-search.png; harness-output.txt 'search finds the requested novel'
Parse Novel: metadata (title, author, status, genres, summary, cover, total/first/last chapters) renders in the playground ✅ pass live playground-novels2-parse-novel.png; harness-output.txt 'novel detail metadata'
Parse Novel chapter list: 12 chapters in ascending order with unique paths and release times ✅ pass live playground-novels2-parse-novel.png; harness-output.txt 'chapter list ordered ascending'
Full RSC chapter list: a novel with more than 20 chapters returns all of them, ascending (288 chapters) ✅ pass live harness-output.txt 'novel with >20 chapters returns the full RSC list'
Parse Chapter: a chapter URL returns readable Spanish text (about 8k characters) ✅ pass live playground-novels2-parse-chapter.png; harness-output.txt 'chapter body is readable text'
resolveUrl joins the site origin with a novel path ⏸️ untested no resolveUrl is a pure string helper. It was exercised only in-process against the bundled plugin (harness-output.txt 'resolveUrl joins site and path'), not against the live product, so this run does no…
Adversarial: RSC key shape changes (createdAt renamed) while chapter data is present; parse must throw, not return an empty list ✅ pass live harness-output.txt 'renamed createdAt key ... throws' ('Could not parse chapter list for /novel/escamas-de-sangre-espaol-162166')
Adversarial: a novel payload with no chapter data returns an empty list without throwing ✅ pass live harness-output.txt 'novel payload with no chapter data returns empty list'
Adversarial: a chapter title containing a literal backslash-u sequence decodes to the original text ✅ pass live harness-output.txt 'literal backslash-u in title decodes to the original text' (name 'A\u0041')
Adversarial: escaped quotes in a chapter title decode correctly ✅ pass live harness-output.txt 'escaped quotes in title decode correctly' (name 'Di "hola"')
Plugin icon: Novels2 appears in the playground sidebar with its icon ✅ pass live playground-novels2-parse-novel.png (sidebar shows the Novels2 entry with its icon)
  • Baseline: npm run check:plugin -- plugins/spanish/novels2.ts (live site) — popularNovels, searchNovels, parseNovel, parseChapter all PASS
  • Harness ~/.no-mistakes/evidence/01M4ZV5DN5QDD0HDGP3AJ5FNWF/novels2-live-harness.cjs: esbuild-bundles the real plugin and drives it against https://www.novels2.com (12/12 pass, output in harness-output.txt)
  • Harness adversarial scenarios: fetch wrapper returns the real novel page with createdAt renamed (expects 'Could not parse chapter list' throw), with chapter data stripped (expects empty list, no throw), and with injected chapters whose titles contain a literal backslash-u and escaped quotes (expects exact decoded names)
  • Playground driven live: npm run dev:start on localhost:3000, headless system Chromium over the DevTools protocol via cdp.cjs. Selected Novels2, Popular → Fetch, Search 'Escamas de Sangre', Parse Novel with /novel/escamas-de-sangre-espaol-162166, Parse Chapter with /novel/escamas-de-sangre-espaol-162166/huele-a-gas-4300077. Screenshots captured for each tab.
  • chrome-devtools-axi bridge could not attach to a browser in this sandbox (BRIDGE_NOT_READY), so the screenshots come from the system Chromium driven directly
  • Cleanup: stopped Chromium and Vite, removed the temp browser profile, ran npm run clean:multisrc; worktree has no tracked changes
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Cover URL fix (EPUB export)

Follow-up commit a69380a fixes resolveUrl, which prepended the site to every path. An absolute cover URL came back as https://www.novels2.comhttps://novels2.com/..., so exported EPUB books lost their covers. resolveUrl now returns absolute http(s) URLs unchanged and still joins relative paths to the site. This was reported by the Greptile review bot (P2); the fix was validated again through the no-mistakes pipeline, and npm run check:plugin passed.

Adds a standalone Spanish source plugin for novels2.com (issue lnreader#120):
browse the latest listing, open a novel, read its full chapter list,
read chapter text, and search.

- The novel page only renders the first 20 chapters in its markup. The
  full list is serialized in the Next.js RSC payload, so it is parsed
  from the raw HTML (253 chapters on the longest checked novel).
- Covers the site marks as failed (`*_failed.png`) fall back to the
  default cover; relative cover paths are resolved against the site.
- No existing multisrc generator matches the site, so this is standalone.
@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] Adds a new novel source plugin for a Spanish website.

The PR appears safe to merge, with a non-blocking cover issue in EPUB export.

Findings

  1. P2 Exported books lose covers ▶

Summary

Adds the Spanish Novels2 source and its icon.

  • Novels2 listings and searches show matching novel cards.
  • Novels2 novel pages show story details and the full chapter list.
  • Opening a Novels2 chapter returns its Spanish paragraphs as HTML.

Reviews (1) · Last reviewed commit: "no-mistakes(test): Throw on unparsed RSC..." · Reviewed by Greptile

Comment thread plugins/spanish/novels2.ts Outdated
return this.parseNovelCards(html);
}

resolveUrl = (path: string): string => this.site + path;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Exported books lose covers

resolveUrl prepends the site even when given an absolute cover URL. EPUB export calls resolveUrl(coverUrl, true), and toCover already returns an absolute URL. This produces a URL such as https://www.novels2.comhttps://novels2.com/obs/..., so exported books lose their covers. Return absolute URLs unchanged before joining relative paths.

Suggested change
resolveUrl = (path: string): string => this.site + path;
resolveUrl = (path: string): string =>
/^https?:\/\//.test(path) ? path : this.site + path;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a69380a: resolveUrl now returns absolute http(s) URLs unchanged and only joins relative paths to the site, so cover URLs in EPUB export no longer get a doubled host. Verified with a reproduction before and after the change and with check:plugin.

resolveUrl prepended the site to every path, so an absolute cover URL
became https://www.novels2.comhttps://novels2.com/... and exported EPUB
books lost their covers. Absolute http(s) URLs are now returned as-is;
relative paths are still joined to the site.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NovelS2

1 participant