diff --git a/userscript/novel-bookmark.user.js b/userscript/novel-bookmark.user.js index 07480f5..3d4884d 100644 --- a/userscript/novel-bookmark.user.js +++ b/userscript/novel-bookmark.user.js @@ -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 - // page markup, so validate before trusting: the host must be this Site's (a - // leading subdomain is allowed, as in `matches`) and the path the - // /novel// Series shape. Anything else is not a pointer. - function seriesIdFromLnwPointer(href) { + // page markup, so validate before trusting: resolve it against the page + // address, require the host above and the /novel// Series path. + // Anything else is not a pointer. + function seriesIdFromLnwPointer(href, base) { if (!href) return null; let u; try { - u = new URL(href); + u = new URL(href, base); } catch (e) { return null; } - if (!/(^|\.)lightnovelworld\.net$/.test(u.hostname)) return null; + if (!lnwHostRe.test(u.hostname)) return null; const m = u.pathname.match(/^\/novel\/([^/]+)\/?$/); return m ? m[1] : null; } const lightnovelworld = { site: "lightnovelworld", - matches: (loc) => /(^|\.)lightnovelworld\.net$/.test(loc.hostname), + matches: (loc) => lnwHostRe.test(loc.hostname), detect(loc) { const path = loc.pathname; // /-chapter-/ — flat, at the site root, not under /novel/. The @@ -166,7 +170,7 @@ // this replaced, not a safety net. const chapterSlug = m[1]; 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) { // Fallback: the microdata breadcrumb's second crumb is the Series. // Scoped to the BreadcrumbList because itemprop="item" is not @@ -174,7 +178,7 @@ const crumb = document.querySelector( '[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// // address on this host: not a page the script understands, so no diff --git a/userscript/test/novel-logic.test.js b/userscript/test/novel-logic.test.js index 86d5173..9b8ef5b 100644 --- a/userscript/test/novel-logic.test.js +++ b/userscript/test/novel-logic.test.js @@ -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", () => { 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 = { - // 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)); + 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, "immortality-simulator"); - assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/immortality-simulator/"); - assert.equal(p.chapterSlug, "my-longevity-simulation"); + 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// 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", () => {