Remove the client latest-chapter scan for lightnovelworld (#91) (#93)

Follows the spec published on #91: remove the client-side latest-chapter scan for lightnovelworld rather than porting #87's truncation into a second codebase.

- lightnovelworld.latestChapterFromAnchors deleted, not stubbed: absence is what the background-fetch guard keys off.
- computeLatestChapter tolerates an adapter with no scanner (yields null) and is exported as the test seam.
- backgroundRefreshLatest skips a scanner-less Site before the due filter: no Series page fetched, no freshness timestamp recorded, no batch slot consumed. The on-page path (maybeCaptureLatestOnSeriesPage) routes through the same null-tolerant computation.
- novelfull's scanner, the shared max-chapter helper and all four manga Sites untouched.
- userscript/AGENTS.md records the Poll-only contract for this Site and why.

All seven acceptance criteria from the spec met. node --check clean; novel suite 30/30 (the regression pin fails if a lnw scan is reintroduced, scoped or not); manga suite 35/35, manga userscript byte-for-byte unchanged.

Two-axis code review: no hard standard violations, spec-clean; one follow-up commit matching the sibling adapter guard from the manga script.

Reviewed-on: #93
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #93.
This commit is contained in:
2026-08-11 20:58:18 +07:00
committed by sulthan
parent e7e22a12a5
commit 672c16ffbf
8 changed files with 11560 additions and 11724 deletions
+5
View File
@@ -78,6 +78,11 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A
the clean title on a series page and `<Title> Chapter <n>` on a chapter page.
Its series page lists every chapter with an
absolute href, so the backend polls it with the plain TLS client.
The client performs no latest-chapter scan for this Site: the Poll's
one-hour cooldown dominates the client's four-hour throttle, so a scan
would add no freshness, and the page's wpdiscuz thread is a public write
surface a scan would have to truncate at. `computeLatestChapter` yields
null here and `backgroundRefreshLatest` skips the Site before any fetch.
### Second script: `novel-bookmark.user.js`
+12 -10
View File
@@ -218,12 +218,6 @@
}
return { type: "other" };
},
latestChapterFromAnchors(anchors, seriesId) {
return maxChapter(
anchors,
new RegExp("lightnovelworld\\.net/" + escapeRe(seriesId) + "-chapter-([0-9.]+)/")
);
},
};
const ADAPTERS = [novelfull, lightnovelworld];
@@ -342,10 +336,11 @@
// Highest chapter the site lists, or null when the markup yields nothing.
// seriesId is only consulted by adapters whose pages carry other series'
// chapter links; the rest ignore it.
// chapter links. An adapter without a scanner (lightnovelworld — see the
// AGENTS.md entry) yields null, not an error.
function computeLatestChapter(site, anchors, seriesId) {
const a = adapterFor(site);
return a ? a.latestChapterFromAnchors(anchors, seriesId) : null;
return a && a.latestChapterFromAnchors ? a.latestChapterFromAnchors(anchors, seriesId) : null;
}
function currentSite() {
@@ -861,6 +856,14 @@
const site = currentSite();
if (!site) return;
// A Site with no client scanner (lightnovelworld) is refreshed by the Poll
// on a cooldown shorter than the client throttle; fetching its pages here
// would be a megabyte-scale request whose result is discarded. Skipping
// before the due filter records no freshness timestamp and consumes no
// per-navigation batch slot.
const adapter = adapterFor(site);
if (!adapter || !adapter.latestChapterFromAnchors) return;
const checked = loadLastChecked();
const now = Date.now();
const due = state.list
@@ -871,7 +874,6 @@
.slice(0, LATEST_CHECK_BATCH);
if (due.length === 0) return;
const adapter = adapterFor(site);
for (const bm of due) {
// Recorded even when the fetch fails, so a broken series is retried on
// the next throttle window rather than on every single page load.
@@ -1750,7 +1752,7 @@
// Exposes pure logic only — see userscript/test/novel-logic.test.js.
// ============================================================
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, repairLnwStaleRow };
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, computeLatestChapter, repairLnwStaleRow };
}
// ============================================================
+27 -3
View File
@@ -45,6 +45,7 @@ globalThis.document = {
const {
novelfull,
lightnovelworld,
computeLatestChapter,
kindOf,
maxChapter,
repairLnwStaleRow,
@@ -314,17 +315,40 @@ test("lightnovelworld.detect returns other for non-series paths", () => {
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/az-lists/")).type, "other");
});
test("lightnovelworld.latestChapterFromAnchors takes the max and ignores other series", () => {
const best = lightnovelworld.latestChapterFromAnchors(
test("computeLatestChapter yields nothing for lightnovelworld, comment anchors included", () => {
// A realistic Series-page anchor set: current-slug chapters, a second
// Chapter Slug's chapters, and a wpdiscuz comment pasting a high-numbered
// chapter of another novel (the poisoning vector). The client never scans
// this Site — the Poll's cooldown dominates the client throttle and the
// comment thread is a public write surface — so this must fail the moment
// a scan is reintroduced, scoped or not.
const latest = computeLatestChapter(
"lightnovelworld",
[
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1/", text: "Chapter 1" },
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1317/", text: "Chapter 1317" },
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1298/", text: "Chapter 1298" },
// a divergent novel's second Chapter Slug
{ href: "https://lightnovelworld.net/my-longevity-simulation-chapter-400/", text: "Chapter 400" },
// a wpdiscuz comment anchor
{ href: "https://lightnovelworld.net/overgeared-chapter-9999/", text: "Chapter 9999" },
],
"a-will-eternal"
);
assert.deepEqual(best, { num: 1317, label: "Chapter 1317" });
assert.equal(latest, null);
});
test("computeLatestChapter still scans novelfull and ignores other series", () => {
const best = computeLatestChapter(
"novelfull",
[
{ href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" },
{ href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" },
{ href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" },
],
"reverend-insanity"
);
assert.deepEqual(best, { num: 2334, label: "Chapter 2334" });
});
test("latestChapterFromAnchors returns null when nothing matches", () => {