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:
@@ -70,9 +70,13 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A
|
||||
Behind a Cloudflare JS challenge no TLS fingerprint
|
||||
clears, so the backend polls it through the headless browser.
|
||||
- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter
|
||||
`/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean
|
||||
title on a series page and `<Title> Chapter <n>` on a chapter page. Its series
|
||||
page lists every chapter with an
|
||||
`/<slug>-chapter-<n>/` — flat, at the site root. The chapter path's slug is a
|
||||
Chapter Slug, not an identity: the Series address is read off the page's
|
||||
`a[aria-label='All Chapter']` (fallback: the BreadcrumbList's second crumb),
|
||||
and a Series may publish under several Chapter Slugs. A chapter page with no
|
||||
pointer resolves to `other`, so no Bookmark is offered. `h1.entry-title` is
|
||||
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.
|
||||
|
||||
### Second script: `novel-bookmark.user.js`
|
||||
|
||||
@@ -132,9 +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: resolve it against the page
|
||||
// address, require the host above and the /novel/<slug>/ Series path.
|
||||
// Anything else is not a pointer.
|
||||
function seriesIdFromLnwPointer(href, base) {
|
||||
if (!href) return null;
|
||||
let u;
|
||||
try {
|
||||
u = new URL(href, base);
|
||||
} catch (e) {
|
||||
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;
|
||||
// /<slug>-chapter-<n>/ — flat, at the site root, not under /novel/. The
|
||||
@@ -142,16 +163,38 @@
|
||||
// words still resolves to the right series.
|
||||
let m = path.match(/^\/(.+)-chapter-([0-9]+(?:\.[0-9]+)?)\/?$/);
|
||||
if (m) {
|
||||
// The address is not the identity on this Site: the slug in the path
|
||||
// is a Chapter Slug, which can differ from the Series slug and is
|
||||
// never computable from it. The Series address is read from the
|
||||
// page's own pointer — a silent fallback to derivation is the defect
|
||||
// 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"), loc.href) : null;
|
||||
if (!seriesId) {
|
||||
// Fallback: the microdata breadcrumb's second crumb is the Series.
|
||||
// Scoped to the BreadcrumbList because itemprop="item" is not
|
||||
// unique to it (the header nav uses microdata too).
|
||||
const crumb = document.querySelector(
|
||||
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]'
|
||||
);
|
||||
seriesId = crumb ? seriesIdFromLnwPointer(crumb.getAttribute("href"), loc.href) : null;
|
||||
}
|
||||
// Neither pointer present, nor either pointing at a /novel/<slug>/
|
||||
// address on this host: not a page the script understands, so no
|
||||
// Bookmark under an invented identity.
|
||||
if (!seriesId) return { type: "other" };
|
||||
const num = parseFloat(m[2]);
|
||||
const h1 = document.querySelector("h1.entry-title");
|
||||
const heading = h1 ? h1.textContent || "" : "";
|
||||
return {
|
||||
type: "chapter",
|
||||
site: this.site,
|
||||
seriesId: m[1],
|
||||
seriesId: seriesId,
|
||||
chapterSlug: chapterSlug,
|
||||
// The heading is "<Series> Chapter <n>"; drop the suffix.
|
||||
title: heading.replace(/\s*Chapter\s+[0-9.]+\s*$/i, "").trim(),
|
||||
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
||||
seriesUrl: "https://lightnovelworld.net/novel/" + seriesId + "/",
|
||||
chapterLabel: "Chapter " + m[2],
|
||||
chapterNum: isNaN(num) ? null : num,
|
||||
chapterUrl: loc.href,
|
||||
@@ -165,6 +208,7 @@
|
||||
type: "series",
|
||||
site: this.site,
|
||||
seriesId: m[1],
|
||||
chapterSlug: null,
|
||||
title: h1 ? (h1.textContent || "").trim() : "",
|
||||
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
||||
chapterLabel: null,
|
||||
@@ -200,6 +244,102 @@
|
||||
return ADAPTERS.find((a) => a.site === site) || null;
|
||||
}
|
||||
|
||||
// A stale lightnovelworld row is one keyed under the Chapter Slug its
|
||||
// address was built from, while the page's pointer names a different
|
||||
// Series. All three storage sites must move together: the cache row alone
|
||||
// leaves a queued write replaying under a key whose row no longer exists
|
||||
// (that write is silently lost), and a last-checked timestamp left behind
|
||||
// re-fetches the repaired row on the next visit. A repair must never cost a
|
||||
// Reader the chapter they were tracking, so when a row already holds the
|
||||
// repaired key the two rows merge and the farther-ahead progress wins.
|
||||
// Pure: no storage access, no module state, no clock.
|
||||
function repairLnwStaleRow(list, queue, lastChecked, page) {
|
||||
if (
|
||||
!page ||
|
||||
page.site !== "lightnovelworld" ||
|
||||
!page.chapterSlug ||
|
||||
!page.seriesId ||
|
||||
page.chapterSlug === page.seriesId
|
||||
) {
|
||||
return { list, queue, lastChecked };
|
||||
}
|
||||
const oldKey = "lightnovelworld:" + page.chapterSlug;
|
||||
const newKey = "lightnovelworld:" + page.seriesId;
|
||||
const stale = list.find((b) => b.key === oldKey);
|
||||
if (!stale) return { list, queue, lastChecked };
|
||||
|
||||
let listOut;
|
||||
const existing = list.find((b) => b.key === newKey);
|
||||
if (existing) {
|
||||
// Duplicate case (spec user story 2): one row must survive, and it is
|
||||
// the one already under the repaired key — with the stale row's
|
||||
// progress carried across when it is ahead, favourite OR'd, and the
|
||||
// stronger lifecycle bucket kept (finished > archived > reading, so a
|
||||
// merge can never silently un-archive or un-finish a row).
|
||||
const merged = Object.assign({}, existing);
|
||||
if (
|
||||
existing.last_chapter_num == null ||
|
||||
(stale.last_chapter_num != null && stale.last_chapter_num > existing.last_chapter_num)
|
||||
) {
|
||||
merged.last_chapter = stale.last_chapter;
|
||||
merged.last_chapter_num = stale.last_chapter_num;
|
||||
merged.last_chapter_url = stale.last_chapter_url;
|
||||
}
|
||||
merged.favorite = !!(existing.favorite || stale.favorite);
|
||||
const rank = (s) => ({ finished: 2, archived: 1, reading: 0 }[s || "reading"] || 0);
|
||||
merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status;
|
||||
merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0);
|
||||
listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b));
|
||||
} else {
|
||||
listOut = list.map((b) =>
|
||||
b.key === oldKey
|
||||
? Object.assign({}, b, {
|
||||
key: newKey,
|
||||
series_id: page.seriesId,
|
||||
series_url: page.seriesUrl,
|
||||
})
|
||||
: b
|
||||
);
|
||||
}
|
||||
|
||||
let queueOut = queue;
|
||||
const oldEntry = queue.find((e) => e.key === oldKey);
|
||||
if (oldEntry) {
|
||||
const survivor = queue.find((x) => x.key === newKey);
|
||||
if (!survivor) {
|
||||
queueOut = queue.map((e) => (e.key === oldKey ? Object.assign({}, e, { key: newKey }) : e));
|
||||
} else {
|
||||
// Both keys hold a marker and one row survives, so the two collapse
|
||||
// into one entry under the repaired key (the queue's one-entry-per-key
|
||||
// invariant). sendStatus is sticky — an archive intent from either
|
||||
// marker survives, the queue's own rule — and the worse attempts
|
||||
// count wins. The stale-key marker's op is dropped: the row it
|
||||
// described is retired by the merge itself.
|
||||
queueOut = queue
|
||||
.filter((e) => e.key !== oldKey && e.key !== newKey)
|
||||
.concat([
|
||||
{
|
||||
key: newKey,
|
||||
op: survivor.op,
|
||||
sendStatus: survivor.sendStatus || oldEntry.sendStatus,
|
||||
attempts: Math.max(survivor.attempts || 0, oldEntry.attempts || 0),
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
let lastCheckedOut = lastChecked;
|
||||
if (oldKey in lastChecked) {
|
||||
lastCheckedOut = Object.assign({}, lastChecked);
|
||||
// Max, not overwrite: a timestamp already under the repaired key must
|
||||
// not be rolled back to an older one.
|
||||
lastCheckedOut[newKey] = Math.max(lastCheckedOut[newKey] || 0, lastCheckedOut[oldKey]);
|
||||
delete lastCheckedOut[oldKey];
|
||||
}
|
||||
|
||||
return { list: listOut, queue: queueOut, lastChecked: lastCheckedOut };
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1199,7 +1339,8 @@
|
||||
// Refresh + navigation
|
||||
// ============================================================
|
||||
|
||||
async function refresh() {
|
||||
async function refresh(awaitFirst) {
|
||||
if (awaitFirst) await awaitFirst; // repair sync lands before we adopt the server's view of it
|
||||
await drain(); // push what we owe before adopting the server's view of it
|
||||
loading = true;
|
||||
render();
|
||||
@@ -1214,9 +1355,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Runs the repair on every navigation, silently: rewrites a stale
|
||||
// lightnovelworld row to the page's discovered identity, persists all three
|
||||
// sites, and syncs through the queue-backed path so an offline repair parks
|
||||
// and replays later. No toast — the Reader never asked for this. Returns
|
||||
// the sync promise (or null when nothing changed) so init can hold the
|
||||
// server view until the repair has landed. When the transform returns its
|
||||
// inputs unchanged there is nothing to do.
|
||||
function applyLnwStaleRowRepair() {
|
||||
const lastChecked = loadLastChecked();
|
||||
const out = repairLnwStaleRow(state.list, queue, lastChecked, state.page);
|
||||
if (out.list === state.list && out.queue === queue && out.lastChecked === lastChecked) {
|
||||
return null;
|
||||
}
|
||||
state.list = out.list;
|
||||
reindex(); // byKey answers under the old key until rebuilt
|
||||
saveCache(state.list);
|
||||
queue.splice(0, queue.length, ...out.queue); // closures hold this array instance
|
||||
saveQueue(queue);
|
||||
saveLastChecked(out.lastChecked);
|
||||
// Queue-backed sync, outcome swallowed: the repaired row is PUT (with its
|
||||
// bucket when archived — the only status a userscript write may send) and
|
||||
// the old server bookmark is deleted so no duplicate survives on the
|
||||
// wire. The delete parks under the retired key while offline — the
|
||||
// teardown of the old identity, not a new record of the Chapter Slug.
|
||||
const key = keyOf(state.page);
|
||||
return Promise.all([
|
||||
pushBookmark(key, statusOf(state.byKey[key]) === "archived"),
|
||||
pushDelete("lightnovelworld:" + state.page.chapterSlug),
|
||||
]).catch(() => {});
|
||||
}
|
||||
|
||||
let lastUrl = location.href;
|
||||
function onNavigate() {
|
||||
state.page = detect();
|
||||
applyLnwStaleRowRepair();
|
||||
render();
|
||||
maybeAutoUpdate();
|
||||
maybeCaptureLatestOnSeriesPage();
|
||||
@@ -1329,13 +1502,14 @@
|
||||
function init() {
|
||||
buildUI();
|
||||
state.page = detect();
|
||||
const repair = applyLnwStaleRowRepair();
|
||||
render();
|
||||
installNavWatcher();
|
||||
installLongPress();
|
||||
window.addEventListener("online", drain); // signal returned while the page stayed open
|
||||
// Sync first: both auto-record and the latest-chapter checks below need to
|
||||
// know which series are bookmarked and how fresh they are.
|
||||
refresh().then(() => {
|
||||
refresh(repair).then(() => {
|
||||
maybeAutoUpdate();
|
||||
maybeCaptureLatestOnSeriesPage();
|
||||
backgroundRefreshLatest();
|
||||
@@ -1576,7 +1750,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 };
|
||||
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, repairLnwStaleRow };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user