Read lnw Series identity from the chapter page pointer (#89)

The lightnovelworld chapter branch no longer derives the Series address by
string-manipulating the chapter path: on ~7% of novels the Chapter Slug
diverges from the Series slug and the derived address 404s on every Poll.
The identity now comes from the page's own a[aria-label='All Chapter']
pointer (breadcrumb's second crumb as fallback), validated as a
lightnovelworld.net /novel/<slug>/ address; no pointer resolves to
type: other. The Chapter Slug rides on the detected page object only
(chapterSlug; null on a series page) and is written to no store, for the
stale-row repair in #90.

Harness: document.querySelector now answers attribute selectors with an
element exposing getAttribute (attrEls table, cleared by reset()).
AGENTS.md records that the Series address is discovered, not derived.
This commit is contained in:
2026-08-11 11:48:46 +07:00
parent c400c91a80
commit a4da2e2f00
3 changed files with 182 additions and 5 deletions
+133
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]];
@@ -52,6 +57,7 @@ function loc(href) {
function reset() {
metaTags = {};
elements = {};
attrEls = {};
}
// ============================================================
@@ -115,11 +121,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 +143,126 @@ 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" };
attrEls = {
// Verbatim from the real page (research note §3): position 2 of the
// microdata BreadcrumbList is the Series.
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]': {
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");
});
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");