Address review findings on the lnw pointer read (#89)
- Share one lnw host regex between matches and the pointer validator so the two cannot drift apart. - Resolve the pointer href against the page address before validating, so a relative pointer still yields its Series instead of silently resolving to type other. - Pin pointer/breadcrumb agreement on the divergent page and pin that a pointer off-host or not /novel/<slug>/ resolves to other.
This commit is contained in:
@@ -132,26 +132,30 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The host shape this adapter owns. One definition so the pointer validator
|
||||||
|
// and `matches` cannot drift apart (a leading subdomain is allowed).
|
||||||
|
const lnwHostRe = /(^|\.)lightnovelworld\.net$/;
|
||||||
|
|
||||||
// A chapter page's pointer is its own link back to its Series. The href is
|
// A chapter page's pointer is its own link back to its Series. The href is
|
||||||
// page markup, so validate before trusting: the host must be this Site's (a
|
// page markup, so validate before trusting: resolve it against the page
|
||||||
// leading subdomain is allowed, as in `matches`) and the path the
|
// address, require the host above and the /novel/<slug>/ Series path.
|
||||||
// /novel/<slug>/ Series shape. Anything else is not a pointer.
|
// Anything else is not a pointer.
|
||||||
function seriesIdFromLnwPointer(href) {
|
function seriesIdFromLnwPointer(href, base) {
|
||||||
if (!href) return null;
|
if (!href) return null;
|
||||||
let u;
|
let u;
|
||||||
try {
|
try {
|
||||||
u = new URL(href);
|
u = new URL(href, base);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!/(^|\.)lightnovelworld\.net$/.test(u.hostname)) return null;
|
if (!lnwHostRe.test(u.hostname)) return null;
|
||||||
const m = u.pathname.match(/^\/novel\/([^/]+)\/?$/);
|
const m = u.pathname.match(/^\/novel\/([^/]+)\/?$/);
|
||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lightnovelworld = {
|
const lightnovelworld = {
|
||||||
site: "lightnovelworld",
|
site: "lightnovelworld",
|
||||||
matches: (loc) => /(^|\.)lightnovelworld\.net$/.test(loc.hostname),
|
matches: (loc) => lnwHostRe.test(loc.hostname),
|
||||||
detect(loc) {
|
detect(loc) {
|
||||||
const path = loc.pathname;
|
const path = loc.pathname;
|
||||||
// /<slug>-chapter-<n>/ — flat, at the site root, not under /novel/. The
|
// /<slug>-chapter-<n>/ — flat, at the site root, not under /novel/. The
|
||||||
@@ -166,7 +170,7 @@
|
|||||||
// this replaced, not a safety net.
|
// this replaced, not a safety net.
|
||||||
const chapterSlug = m[1];
|
const chapterSlug = m[1];
|
||||||
const pointer = document.querySelector("a[aria-label='All Chapter']");
|
const pointer = document.querySelector("a[aria-label='All Chapter']");
|
||||||
let seriesId = pointer ? seriesIdFromLnwPointer(pointer.getAttribute("href")) : null;
|
let seriesId = pointer ? seriesIdFromLnwPointer(pointer.getAttribute("href"), loc.href) : null;
|
||||||
if (!seriesId) {
|
if (!seriesId) {
|
||||||
// Fallback: the microdata breadcrumb's second crumb is the Series.
|
// Fallback: the microdata breadcrumb's second crumb is the Series.
|
||||||
// Scoped to the BreadcrumbList because itemprop="item" is not
|
// Scoped to the BreadcrumbList because itemprop="item" is not
|
||||||
@@ -174,7 +178,7 @@
|
|||||||
const crumb = document.querySelector(
|
const crumb = document.querySelector(
|
||||||
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]'
|
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]'
|
||||||
);
|
);
|
||||||
seriesId = crumb ? seriesIdFromLnwPointer(crumb.getAttribute("href")) : null;
|
seriesId = crumb ? seriesIdFromLnwPointer(crumb.getAttribute("href"), loc.href) : null;
|
||||||
}
|
}
|
||||||
// Neither pointer present, nor either pointing at a /novel/<slug>/
|
// Neither pointer present, nor either pointing at a /novel/<slug>/
|
||||||
// address on this host: not a page the script understands, so no
|
// address on this host: not a page the script understands, so no
|
||||||
|
|||||||
@@ -165,19 +165,63 @@ test("lightnovelworld.detect reads the Series identity from the page pointer on
|
|||||||
test("lightnovelworld.detect falls back to the breadcrumb when the pointer is absent", () => {
|
test("lightnovelworld.detect falls back to the breadcrumb when the pointer is absent", () => {
|
||||||
reset();
|
reset();
|
||||||
elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" };
|
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 = {
|
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/"]': {
|
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]': {
|
||||||
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
|
getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/";
|
const viaBreadcrumb = lightnovelworld.detect(loc(url));
|
||||||
const p = 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.type, "chapter");
|
||||||
assert.equal(p.seriesId, "immortality-simulator");
|
assert.equal(p.seriesId, "a-will-eternal");
|
||||||
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/immortality-simulator/");
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
||||||
assert.equal(p.chapterSlug, "my-longevity-simulation");
|
});
|
||||||
|
|
||||||
|
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", () => {
|
test("lightnovelworld.detect resolves to other when the page carries no pointer", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user