lightnovelworld Series identity is read from the chapter page (#80) (#92)

Implements spec #80 / ADR-0008 — Gitea issues #86, #87, #88, #89, #90, all closed.

A Reader bookmarks a novel on lightnovelworld and it never shows a New Chapter, because the Series identity was derived from the chapter address instead of read from the page. One Series can publish under several Chapter Slugs, so the derived key points at a slug that 404s.

- **#89** — the userscript's lnw adapter stops deriving `seriesUrl`/`seriesId` from the path. It reads the page's own pointer (`a[aria-label='All Chapter']`), falling back to the microdata breadcrumb's second crumb, and carries `chapterSlug` on the page object, stored nowhere.
- **#87** — the Poll's lnw chapter scan is unscoped (no stored-slug pattern can cover a Series' whole list) and truncated at the `wpd-threads` comment thread, the one region a visitor can write to. Marker absent means skip and log with the body length, never scan whole. Corrects the `maxBodyBytes` headroom comment to the measured 3.5x.
- **#86** — the scan fixture is now text trimmed from a real, wholly-fetched Series page instead of a hand-written cross-series anchor that no live page carries.
- **#90** — stale stored rows repair themselves on the next chapter visit: a pure transform over cache, queue and last-checked map, silent to the Reader, with progress, favourite and lifecycle bucket preserved when two rows merge.
- **#88** — an env-gated live canary (`SMOKE_LNW_SERIES_URL`) proving the marker still occurs exactly once and still follows the last chapter anchor, asserted against the production symbols themselves.

Verified on the merged branch: `go test ./...` green, `gofmt -l internal/latest/` silent, both userscripts `node --check` clean, 35/35 + 29/29 logic tests. Live canary green (marker once at byte 612,182 of 651,795). #90 verified on device with Playwright.

Open follow-up: **#91** — the userscript's client-side latest-chapter scan is still scoped to the derived slug.

Reviewed-on: #92
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #92.
This commit is contained in:
2026-08-11 18:21:50 +07:00
committed by sulthan
parent 90d8ab72ad
commit e7e22a12a5
13 changed files with 1731 additions and 551 deletions
+385
View File
@@ -20,8 +20,13 @@ globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin
let metaTags = {};
let elements = {};
// Attribute selectors (the lightnovelworld Series pointer) answer with an
// element exposing getAttribute, like the meta branch below.
let attrEls = {};
globalThis.document = {
querySelector(sel) {
const attr = attrEls[sel];
if (attr != null) return attr;
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
if (m) {
const v = metaTags[m[1]];
@@ -42,6 +47,7 @@ const {
lightnovelworld,
kindOf,
maxChapter,
repairLnwStaleRow,
} = require("../novel-bookmark.user.js");
function loc(href) {
@@ -52,6 +58,7 @@ function loc(href) {
function reset() {
metaTags = {};
elements = {};
attrEls = {};
}
// ============================================================
@@ -115,11 +122,18 @@ test("lightnovelworld.detect reads a series page", () => {
assert.equal(p.site, "lightnovelworld");
assert.equal(p.seriesId, "a-will-eternal");
assert.equal(p.title, "A Will Eternal");
// A series page's address *is* its identity; there is no Chapter Slug.
assert.equal(p.chapterSlug, null);
});
test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
reset();
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
attrEls = {
"a[aria-label='All Chapter']": {
getAttribute: () => "https://lightnovelworld.net/novel/a-will-eternal/",
},
};
const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/";
const p = lightnovelworld.detect(loc(url));
assert.equal(p.type, "chapter");
@@ -130,6 +144,170 @@ test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
});
test("lightnovelworld.detect reads the Series identity from the page pointer on a chapter page", () => {
reset();
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
attrEls = {
// Verbatim from the real page (research note §3): the All Chapter anchor
// carries the absolute Series address.
"a[aria-label='All Chapter']": {
getAttribute: () => "https://lightnovelworld.net/novel/a-will-eternal/",
},
};
const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/";
const p = lightnovelworld.detect(loc(url));
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, "a-will-eternal");
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
assert.equal(p.chapterSlug, "a-will-eternal");
assert.equal(p.chapterNum, 1298);
});
test("lightnovelworld.detect falls back to the breadcrumb when the pointer is absent", () => {
reset();
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
// Same divergent page, both pointers from the real markup (research note
// §3): breadcrumb position 2 must say what the All Chapter anchor says.
attrEls = {
"a[aria-label='All Chapter']": {
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
},
};
const viaPointer = lightnovelworld.detect(loc(url));
attrEls = {
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]': {
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
},
};
const viaBreadcrumb = lightnovelworld.detect(loc(url));
assert.equal(viaBreadcrumb.type, "chapter");
assert.equal(viaBreadcrumb.seriesId, viaPointer.seriesId);
assert.equal(viaBreadcrumb.seriesUrl, viaPointer.seriesUrl);
assert.equal(viaBreadcrumb.seriesId, "immortality-simulator");
assert.equal(viaBreadcrumb.chapterSlug, "my-longevity-simulation");
});
test("lightnovelworld.detect resolves a relative pointer against the page address", () => {
// Defensive: every measured page ships an absolute pointer href, but a
// theme change could go relative — the pointer is still this page's own
// link back to its Series.
reset();
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
attrEls = {
"a[aria-label='All Chapter']": { getAttribute: () => "/novel/a-will-eternal/" },
};
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/a-will-eternal-chapter-1298/"));
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, "a-will-eternal");
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
});
test("lightnovelworld.detect rejects a pointer that is not a /novel/ address on its own host", () => {
// Counterfactual pointers, exercising the criterion that a pointer "present
// but not parseable as /novel/<slug>/ on lightnovelworld.net" must not
// become a seriesUrl the backend is later asked to Poll: off-host, and
// on-host but the wrong path shape.
reset();
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
attrEls = {
"a[aria-label='All Chapter']": {
getAttribute: () => "https://evil.example/novel/immortality-simulator/",
},
};
assert.equal(lightnovelworld.detect(loc(url)).type, "other");
attrEls = {
"a[aria-label='All Chapter']": {
getAttribute: () => "https://lightnovelworld.net/fiction/immortality-simulator/",
},
};
assert.equal(lightnovelworld.detect(loc(url)).type, "other");
});
test("lightnovelworld.detect resolves to other when the page carries no pointer", () => {
reset();
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
const p = lightnovelworld.detect(
loc("https://lightnovelworld.net/my-longevity-simulation-chapter-1/")
);
assert.equal(p.type, "other");
});
test("lightnovelworld pins the divergent novel: the pointer's slug wins over the address's", () => {
// Regression pin for the derivation defect (research note §2/§3): the
// chapter address is built from "my-longevity-simulation" but the Series
// is published as "immortality-simulator". If the adapter ever derives the
// identity from the address again, this test goes red.
reset();
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
attrEls = {
"a[aria-label='All Chapter']": {
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
},
};
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
const p = lightnovelworld.detect(loc(url));
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, "immortality-simulator");
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/immortality-simulator/");
assert.equal(p.chapterSlug, "my-longevity-simulation");
assert.notEqual(p.chapterSlug, p.seriesId);
});
test("lightnovelworld.detect resolves a novel whose heading ends in a chapter number", () => {
// The split novel from research §4.4, chapter 200 (published under the
// current slug). Its heading ends "…Not Them All Chapter 200", which would
// false-match a selector that looks for the text "All Chapter".
reset();
elements = {
"h1.entry-title": "All Jobs and Classes I Just Wanted One Skill Not Them All Chapter 200",
};
attrEls = {
"a[aria-label='All Chapter']": {
getAttribute: () =>
"https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
},
};
const url =
"https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-200/";
const p = lightnovelworld.detect(loc(url));
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
assert.equal(p.title, "All Jobs and Classes I Just Wanted One Skill Not Them All");
assert.equal(p.chapterNum, 200);
assert.equal(p.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
});
test("lightnovelworld.detect resolves both Chapter Slugs of the split novel to one Series", () => {
// Research note §4.4: this Series serves chapters 1-99 under one Chapter
// Slug and 100-423 under another; both chapter addresses are live and both
// point back at the same Series. An old deep link must not get a different
// identity than a current one.
reset();
elements = { "h1.entry-title": "All Jobs and Classes I Just Wanted One Skill Not Chapter 1" };
attrEls = {
"a[aria-label='All Chapter']": {
getAttribute: () =>
"https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
},
};
const oldSlug = lightnovelworld.detect(
loc("https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-1/")
);
const currentSlug = lightnovelworld.detect(
loc(
"https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-404/"
)
);
assert.equal(oldSlug.type, "chapter");
assert.equal(oldSlug.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
assert.equal(oldSlug.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not");
assert.equal(currentSlug.type, "chapter");
assert.equal(currentSlug.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
assert.equal(currentSlug.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all");
});
test("lightnovelworld.detect returns other for non-series paths", () => {
reset();
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other");
@@ -154,6 +332,213 @@ test("latestChapterFromAnchors returns null when nothing matches", () => {
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
});
// ============================================================
// repairLnwStaleRow — the row migration (spec seam 3)
// ============================================================
// A row as bookmarkCurrent builds it, keyed under the invented Chapter Slug
// identity the old adapter derived from the chapter address.
function staleRow(over) {
return Object.assign(
{
key: "lightnovelworld:my-longevity-simulation",
site: "lightnovelworld",
kind: "novel",
series_id: "my-longevity-simulation",
title: "My Longevity Simulation",
series_url: "https://lightnovelworld.net/novel/my-longevity-simulation/",
last_chapter: "Chapter 7",
last_chapter_num: 7,
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-7/",
updated_at: 1000,
},
over
);
}
// The divergent novel from research §3: the address's slug is a Chapter Slug,
// the page pointer names the real Series.
function lnwPage(over) {
return Object.assign(
{
type: "chapter",
site: "lightnovelworld",
seriesId: "immortality-simulator",
chapterSlug: "my-longevity-simulation",
seriesUrl: "https://lightnovelworld.net/novel/immortality-simulator/",
},
over
);
}
test("repairLnwStaleRow rewrites the row, the queue entry and the last-checked map together", () => {
const list = [staleRow()];
const queue = [
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: false, attempts: 2 },
];
const lastChecked = { "lightnovelworld:my-longevity-simulation": 12345 };
const out = repairLnwStaleRow(list, queue, lastChecked, lnwPage());
assert.equal(out.list.length, 1);
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
assert.equal(out.list[0].series_id, "immortality-simulator");
assert.equal(out.list[0].series_url, "https://lightnovelworld.net/novel/immortality-simulator/");
assert.deepEqual(out.queue, [
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 2 },
]);
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 12345 });
});
test("repairLnwStaleRow keeps Progress, Favourite and Lifecycle bucket on the rewritten row", () => {
const stale = staleRow({
last_chapter: "Chapter 42",
last_chapter_num: 42,
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
favorite: true,
status: "archived",
updated_at: 777,
});
const out = repairLnwStaleRow([stale], [], {}, lnwPage());
const b = out.list[0];
assert.equal(b.last_chapter, "Chapter 42");
assert.equal(b.last_chapter_num, 42);
assert.equal(
b.last_chapter_url,
"https://lightnovelworld.net/my-longevity-simulation-chapter-42/"
);
assert.equal(b.favorite, true);
assert.equal(b.status, "archived");
assert.equal(b.updated_at, 777);
assert.equal(b.title, "My Longevity Simulation");
});
test("repairLnwStaleRow changes nothing when the Chapter Slug and Series slug agree", () => {
const list = [
staleRow({ key: "lightnovelworld:a-will-eternal", series_id: "a-will-eternal" }),
];
const queue = [{ key: "lightnovelworld:a-will-eternal", op: "put", sendStatus: true, attempts: 1 }];
const lastChecked = { "lightnovelworld:a-will-eternal": 99 };
const out = repairLnwStaleRow(
list,
queue,
lastChecked,
lnwPage({
chapterSlug: "a-will-eternal",
seriesId: "a-will-eternal",
seriesUrl: "https://lightnovelworld.net/novel/a-will-eternal/",
})
);
assert.equal(out.list, list); // same references, nothing rewritten
assert.equal(out.queue, queue);
assert.equal(out.lastChecked, lastChecked);
});
test("repairLnwStaleRow changes nothing when no row sits under the old key", () => {
const list = [
staleRow({
key: "lightnovelworld:immortality-simulator",
series_id: "immortality-simulator",
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
}),
];
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
const out = repairLnwStaleRow(list, [], lastChecked, lnwPage());
assert.equal(out.list, list);
assert.equal(out.queue.length, 0);
assert.equal(out.lastChecked, lastChecked);
});
test("repairLnwStaleRow ignores a page that carries no Chapter Slug", () => {
const list = [
staleRow({
key: "lightnovelworld:immortality-simulator",
series_id: "immortality-simulator",
}),
];
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
// A series page (chapterSlug null) and another site both stay untouched.
const series = repairLnwStaleRow(list, [], lastChecked, lnwPage({ chapterSlug: null }));
const other = repairLnwStaleRow(list, [], lastChecked, {
type: "chapter",
site: "novelfull",
seriesId: "x",
});
assert.equal(series.list, list);
assert.equal(other.list, list);
});
test("repairLnwStaleRow still moves the row and the last-checked map when the queue has no entry", () => {
const out = repairLnwStaleRow(
[staleRow()],
[],
{ "lightnovelworld:my-longevity-simulation": 5 },
lnwPage()
);
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
assert.deepEqual(out.queue, []);
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 5 });
});
test("repairLnwStaleRow carries a queued change to the repaired identity with its marker intact", () => {
const queue = [
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
];
const out = repairLnwStaleRow([staleRow()], queue, {}, lnwPage());
assert.deepEqual(out.queue, [
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 },
]);
});
test("repairLnwStaleRow merges a duplicate under the repaired key, keeping the farther-ahead progress", () => {
const canonical = staleRow({
key: "lightnovelworld:immortality-simulator",
series_id: "immortality-simulator",
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
last_chapter: "Chapter 5",
last_chapter_num: 5,
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-5/",
favorite: false,
});
const stale = staleRow({
last_chapter: "Chapter 42",
last_chapter_num: 42,
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
favorite: true,
status: "archived",
});
const queue = [
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 0 },
];
const out = repairLnwStaleRow([canonical, stale], queue, {}, lnwPage());
assert.equal(out.list.length, 1);
const merged = out.list[0];
assert.equal(merged.key, "lightnovelworld:immortality-simulator");
assert.equal(merged.last_chapter_num, 42); // the stale row is ahead — progress must not be lost
assert.equal(merged.favorite, true); // favourite survives from either row
assert.equal(merged.status, "archived"); // the stronger bucket survives
// One marker under the repaired key: sendStatus is sticky (the archive
// intent from the stale-key marker survives) and the worse attempts count
// wins — the queue's own coalescing rules.
assert.deepEqual(out.queue, [
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 },
]);
});
test("repairLnwStaleRow does not regress progress when the repaired-key row is ahead", () => {
const canonical = staleRow({
key: "lightnovelworld:immortality-simulator",
series_id: "immortality-simulator",
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
last_chapter: "Chapter 100",
last_chapter_num: 100,
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-100/",
});
const stale = staleRow({ last_chapter: "Chapter 42", last_chapter_num: 42 });
const out = repairLnwStaleRow([canonical, stale], [], {}, lnwPage());
assert.equal(out.list.length, 1);
assert.equal(out.list[0].last_chapter_num, 100);
});
// ============================================================
// kindOf
// ============================================================