feat(userscript): render Covers from the public route (#60)

Both userscripts stop scraping Covers and stop putting one on the wire.
A scraped address has no rendering path left now that the backend
acquires, stores and serves every Cover from its own origin (ADR-0007),
and keeping one would reintroduce third-party URLs into exactly the
place #47 came from.

- Adapters no longer read og:image / meta[name=image], and comix's
  img[alt] cover scan and novelfull's metaName helper are deleted.
- apiPut strips `cover` off every outgoing body, so a cached row's
  address (already ours) never travels back either. The server ignores
  the field regardless.
- Both card renderers swap in the existing `.cover.ph` placeholder when
  the image fails to load, so a failure looks designed rather than
  broken - the other half of what #47 reported.
- The deleted scraping's test cases go with it: the comix cover cases,
  the img[alt] and meta[name] stub branches, and the stale og:image
  fixtures. Export lists are unchanged; nothing cover-specific was
  exported.

The cover route itself is already public and uncredentialed, with the
immutable cache directive and 404-for-unknown covered by the tests that
landed with #59.
This commit is contained in:
2026-08-10 04:20:23 +07:00
parent 92eba07da7
commit cc0fa92a1a
6 changed files with 55 additions and 119 deletions
@@ -31,7 +31,7 @@ The test file installs four globals **before** requiring the userscript:
|---|---|---| |---|---|---|
| `localStorage` | `Map`-backed stub | `loadCache`, `loadQueue`, and the key-migration IIFE touch it at module scope | | `localStorage` | `Map`-backed stub | `loadCache`, `loadQueue`, and the key-migration IIFE touch it at module scope |
| `location` | `{href, hostname, pathname, origin}` | read during boot | | `location` | `{href, hostname, pathname, origin}` | read during boot |
| `document` | `querySelector` for `meta[property="…"]` only, plus a no-op `addEventListener` | adapters read `og:title`/`og:image` | | `document` | `querySelector` for `meta[property="…"]` only, plus a no-op `addEventListener` | adapters read `og:title` (covers are the backend's, never scraped) |
| `document.body` | **left `undefined`** | this is the whole trick | | `document.body` | **left `undefined`** | this is the whole trick |
`document.body === undefined` sends the userscript's boot block down its `else` `document.body === undefined` sends the userscript's boot block down its `else`
+11 -10
View File
@@ -2,7 +2,7 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A
### Userscript structure (single IIFE, `manga-bookmark.user.js`) ### Userscript structure (single IIFE, `manga-bookmark.user.js`)
1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes. 1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title` from **`og:title`** (or the page heading where a site ships no og: tags), not CSS classes. **No adapter reads a cover**: the backend acquires, stores and serves every Cover from its own origin (ADR-0007), the wire's `cover` is already an address on our origin, and `apiPut` strips any `cover` off an outgoing body.
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `bmgr:manga:cache` for instant render + offline fallback. 2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `bmgr:manga:cache` for instant render + offline fallback.
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. 3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so 4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so
@@ -52,26 +52,27 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A
"Comix — Read Comics online for free" and after an in-page hop it is the "Comix — Read Comics online for free" and after an in-page hop it is the
*previous* series' name. `document.title` is the one thing client routing does *previous* series' name. `document.title` is the one thing client routing does
update, so titles come from there, with the chapter page's `" · Ch.<n>"` tail update, so titles come from there, with the chapter page's `" · Ch.<n>"` tail
stripped. Covers likewise: `og:image` is absent, so the cover is the `img` stripped. It publishes no `og:image` either, which is one of the reasons cover
whose `alt` matches the cleaned title — verified live 2026-08-08. acquisition moved to the backend.
- **kagane.to**: series `/series/<uuid>`, reader - **kagane.to**: series `/series/<uuid>`, reader
`/series/<uuid>/reader/<bookUuid>`. Reader URLs carry no chapter number, so `/series/<uuid>/reader/<bookUuid>`. Reader URLs carry no chapter number, so
the number comes out of `og:title`. Two shapes exist: `"<Series> - Chapter the number comes out of `og:title`. Two shapes exist: `"<Series> - Chapter
<n>[ - Episode <n>]"` and, for volume-numbered series, `"<Series> - Volume <v> <n>[ - Episode <n>]"` and, for volume-numbered series, `"<Series> - Volume <v>
Chapter <n>"` with no episode name — both must yield a bare series title, or Chapter <n>"` with no episode name — both must yield a bare series title, or
the volume tail lands in the bookmark's title. Its covers are challenge- and the volume tail lands in the bookmark's title.
CORP-protected, so the web UI proxies them; the userscript still stores the Its covers are challenge- and CORP-protected, so nothing outside kagane.to can
raw `og:image`. Behind a Cloudflare JS challenge, so the backend polls it load one directly; the panel renders the backend's own cover address like every
other Site. Behind a Cloudflare JS challenge, so the backend polls it
through the headless browser. through the headless browser.
- **novelfull.com** (novel script): series `/<slug>.html`, chapter - **novelfull.com** (novel script): series `/<slug>.html`, chapter
`/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from `/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from
`h3.title` (series) or `a.truyen-title` (chapter), cover from `h3.title` (series) or `a.truyen-title` (chapter); the script reads no cover.
`meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint Behind a Cloudflare JS challenge no TLS fingerprint
clears, so the backend polls it through the headless browser. clears, so the backend polls it through the headless browser.
- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter - **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter
`/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean `/<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. Chapter title on a series page and `<Title> Chapter <n>` on a chapter page. Its series
pages carry no `og:image`. Its series page lists every chapter with an page lists every chapter with an
absolute href, so the backend polls it with the plain TLS client. absolute href, so the backend polls it with the plain TLS client.
### Second script: `novel-bookmark.user.js` ### Second script: `novel-bookmark.user.js`
+17 -25
View File
@@ -56,9 +56,10 @@
// ============================================================ // ============================================================
// Site adapters // Site adapters
// //
// Page type + IDs come from URL regex (most stable); title/cover come from // Page type + IDs come from URL regex (most stable); the title comes from
// og: meta tags. Verified live 2026-07-24 against asurascans.com and // og: meta tags. Covers are never read here: the backend acquires and serves
// demonicscans.org — see README "Adapter reference". // them itself (ADR-0007). Verified live 2026-07-24 against asurascans.com
// and demonicscans.org — see README "Adapter reference".
// ============================================================ // ============================================================
function meta(prop) { function meta(prop) {
@@ -128,7 +129,6 @@
site: this.site, site: this.site,
seriesId: stripBuildHash(m[1]), seriesId: stripBuildHash(m[1]),
title: cleanTitle(meta("og:title")), title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/comics/" + m[1], seriesUrl: loc.origin + "/comics/" + m[1],
chapterLabel: "Chapter " + m[2], chapterLabel: "Chapter " + m[2],
chapterNum: isNaN(num) ? null : num, chapterNum: isNaN(num) ? null : num,
@@ -143,7 +143,6 @@
site: this.site, site: this.site,
seriesId: stripBuildHash(m[1]), seriesId: stripBuildHash(m[1]),
title: cleanTitle(meta("og:title")), title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/comics/" + m[1], seriesUrl: loc.origin + "/comics/" + m[1],
chapterLabel: null, chapterLabel: null,
chapterNum: null, chapterNum: null,
@@ -192,7 +191,6 @@
site: this.site, site: this.site,
seriesId: decodeURIComponent(m[1]), seriesId: decodeURIComponent(m[1]),
title: cleanTitle(meta("og:title")), title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/manga/" + m[1], seriesUrl: loc.origin + "/manga/" + m[1],
chapterLabel: "Chapter " + m[2], chapterLabel: "Chapter " + m[2],
chapterNum: isNaN(num) ? null : num, chapterNum: isNaN(num) ? null : num,
@@ -207,7 +205,6 @@
site: this.site, site: this.site,
seriesId: decodeURIComponent(m[1]), seriesId: decodeURIComponent(m[1]),
title: cleanTitle(meta("og:title")), title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/manga/" + m[1], seriesUrl: loc.origin + "/manga/" + m[1],
chapterLabel: null, chapterLabel: null,
chapterNum: null, chapterNum: null,
@@ -259,7 +256,6 @@
site: this.site, site: this.site,
seriesId: comixSeriesId(m[1]), seriesId: comixSeriesId(m[1]),
title: pageTitle, title: pageTitle,
cover: coverFromPage(pageTitle),
seriesUrl: loc.origin + "/title/" + m[1], seriesUrl: loc.origin + "/title/" + m[1],
chapterLabel: "Chapter " + m[2], chapterLabel: "Chapter " + m[2],
chapterNum: isNaN(num) ? null : num, chapterNum: isNaN(num) ? null : num,
@@ -274,7 +270,6 @@
site: this.site, site: this.site,
seriesId: comixSeriesId(m[1]), seriesId: comixSeriesId(m[1]),
title: pageTitle, title: pageTitle,
cover: coverFromPage(pageTitle),
seriesUrl: loc.origin + "/title/" + m[1], seriesUrl: loc.origin + "/title/" + m[1],
chapterLabel: null, chapterLabel: null,
chapterNum: null, chapterNum: null,
@@ -289,17 +284,6 @@
return t.replace(/\s*·\s*Ch\.[\d.]+\s*$/i, "").trim(); return t.replace(/\s*·\s*Ch\.[\d.]+\s*$/i, "").trim();
} }
// comix serves no og:image, so this is the one adapter that has to read
// the DOM for a cover. Matching on alt rather than a class keeps it off
// the site's styling: the cover is the image whose alt is the title.
// Do not "simplify" this into meta("og:image") — that returns null.
function coverFromPage(title) {
if (!title || !document.querySelectorAll) return "";
for (const img of document.querySelectorAll("img[alt]")) {
if (img.getAttribute("alt") === title) return img.getAttribute("src") || "";
}
return "";
}
}, },
// Scoped to this series' own id prefix so a recommendation strip's links // Scoped to this series' own id prefix so a recommendation strip's links
// cannot win the maximum. seriesId is passed in because the anchors alone // cannot win the maximum. seriesId is passed in because the anchors alone
@@ -342,7 +326,6 @@
site: this.site, site: this.site,
seriesId: m[1], seriesId: m[1],
title: cleanTitle(meta("og:title")), title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/series/" + m[1], seriesUrl: loc.origin + "/series/" + m[1],
chapterLabel: num === null ? null : "Chapter " + num, chapterLabel: num === null ? null : "Chapter " + num,
chapterNum: num, chapterNum: num,
@@ -357,7 +340,6 @@
site: this.site, site: this.site,
seriesId: m[1], seriesId: m[1],
title: cleanTitle(meta("og:title")), title: cleanTitle(meta("og:title")),
cover: meta("og:image") || "",
seriesUrl: loc.origin + "/series/" + m[1], seriesUrl: loc.origin + "/series/" + m[1],
chapterLabel: null, chapterLabel: null,
chapterNum: null, chapterNum: null,
@@ -498,6 +480,10 @@
async function apiPut(key, obj, { sendStatus = false } = {}) { async function apiPut(key, obj, { sendStatus = false } = {}) {
const body = Object.assign({}, obj); const body = Object.assign({}, obj);
if (!sendStatus) delete body.status; if (!sendStatus) delete body.status;
// Covers belong to the backend, which acquires and serves them itself
// (ADR-0007) and ignores an incoming one; a third-party address must never
// go back on the wire.
delete body.cover;
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
method: "PUT", method: "PUT",
headers: authHeaders({ "Content-Type": "application/json" }), headers: authHeaders({ "Content-Type": "application/json" }),
@@ -855,7 +841,6 @@
series_id: p.seriesId, series_id: p.seriesId,
title: p.title || (existing && existing.title) || p.seriesId, title: p.title || (existing && existing.title) || p.seriesId,
series_url: p.seriesUrl || (existing && existing.series_url) || "", series_url: p.seriesUrl || (existing && existing.series_url) || "",
cover: p.cover || (existing && existing.cover) || "",
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "", last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
last_chapter_num: last_chapter_num:
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null, p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
@@ -878,7 +863,6 @@
series_id: p.seriesId, series_id: p.seriesId,
title: existing.title || p.title || p.seriesId, title: existing.title || p.title || p.seriesId,
series_url: existing.series_url || p.seriesUrl || "", series_url: existing.series_url || p.seriesUrl || "",
cover: existing.cover || p.cover || "",
last_chapter: p.chapterLabel || existing.last_chapter || "", last_chapter: p.chapterLabel || existing.last_chapter || "",
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num, last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
last_chapter_url: p.chapterUrl || "", last_chapter_url: p.chapterUrl || "",
@@ -1394,7 +1378,15 @@
return el("div", { class: "item" + heat }, [ return el("div", { class: "item" + heat }, [
el("a", { class: "go", href: cont }, [ el("a", { class: "go", href: cont }, [
b.cover b.cover
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" }) ? el("img", {
class: "cover",
src: b.cover,
loading: "lazy",
alt: "",
// A Cover that will not load shows the designed placeholder
// rather than the browser's broken-image glyph (#47).
onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" })),
})
: el("div", { class: "cover ph" }), : el("div", { class: "cover ph" }),
]), ]),
el("div", { class: "meta" }, [ el("div", { class: "meta" }, [
+16 -23
View File
@@ -33,16 +33,11 @@
// ============================================================ // ============================================================
// Site adapters // Site adapters
// //
// Page type + IDs come from URL regex (most stable); title/cover come from // Page type + IDs come from URL regex (most stable); the title comes from
// og: meta tags (with the novelfull name= meta as the exception). // og: meta tags or the page's own heading. Covers are never read here: the
// backend acquires and serves them itself (ADR-0007).
// ============================================================ // ============================================================
function meta(prop) {
const el = document.querySelector('meta[property="' + prop + '"]');
return el ? el.getAttribute("content") : null;
}
// Chapter lists are read from two places: the page we are standing on, and // Chapter lists are read from two places: the page we are standing on, and
// series pages fetched in the background. Both are reduced to {href, text} // series pages fetched in the background. Both are reduced to {href, text}
// pairs so each adapter needs only one rule for picking the latest chapter. // pairs so each adapter needs only one rule for picking the latest chapter.
@@ -66,12 +61,6 @@
return out; return out;
} }
// novelfull ships no og: tags at all — its cover lives on a name= meta.
function metaName(name) {
const el = document.querySelector('meta[name="' + name + '"]');
return el ? el.getAttribute("content") : null;
}
function escapeRe(s) { function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
@@ -108,7 +97,6 @@
// h3.title on a chapter page is the *chapter's* title; the breadcrumb // h3.title on a chapter page is the *chapter's* title; the breadcrumb
// link back to the series page carries the series name. // link back to the series page carries the series name.
title: back ? (back.textContent || "").trim() : "", title: back ? (back.textContent || "").trim() : "",
cover: metaName("image") || "",
seriesUrl: loc.origin + "/" + m[1] + ".html", seriesUrl: loc.origin + "/" + m[1] + ".html",
chapterLabel: "Chapter " + m[2], chapterLabel: "Chapter " + m[2],
chapterNum: isNaN(num) ? null : num, chapterNum: isNaN(num) ? null : num,
@@ -124,7 +112,6 @@
site: this.site, site: this.site,
seriesId: m[1], seriesId: m[1],
title: h3 ? (h3.textContent || "").trim() : "", title: h3 ? (h3.textContent || "").trim() : "",
cover: metaName("image") || "",
seriesUrl: loc.origin + "/" + m[1] + ".html", seriesUrl: loc.origin + "/" + m[1] + ".html",
chapterLabel: null, chapterLabel: null,
chapterNum: null, chapterNum: null,
@@ -157,9 +144,6 @@
seriesId: m[1], seriesId: m[1],
// The heading is "<Series> Chapter <n>"; drop the suffix. // The heading is "<Series> Chapter <n>"; drop the suffix.
title: heading.replace(/\s*Chapter\s+[0-9.]+\s*$/i, "").trim(), title: heading.replace(/\s*Chapter\s+[0-9.]+\s*$/i, "").trim(),
// Chapter pages carry no og:image. Empty is safe: every write merges
// against the cached row, which keeps the cover the series page gave.
cover: "",
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/", seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
chapterLabel: "Chapter " + m[2], chapterLabel: "Chapter " + m[2],
chapterNum: isNaN(num) ? null : num, chapterNum: isNaN(num) ? null : num,
@@ -175,7 +159,6 @@
site: this.site, site: this.site,
seriesId: m[1], seriesId: m[1],
title: h1 ? (h1.textContent || "").trim() : "", title: h1 ? (h1.textContent || "").trim() : "",
cover: meta("og:image") || "",
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/", seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
chapterLabel: null, chapterLabel: null,
chapterNum: null, chapterNum: null,
@@ -286,6 +269,10 @@
async function apiPut(key, obj, { sendStatus = false } = {}) { async function apiPut(key, obj, { sendStatus = false } = {}) {
const body = Object.assign({}, obj); const body = Object.assign({}, obj);
if (!sendStatus) delete body.status; if (!sendStatus) delete body.status;
// Covers belong to the backend, which acquires and serves them itself
// (ADR-0007) and ignores an incoming one; a third-party address must never
// go back on the wire.
delete body.cover;
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
method: "PUT", method: "PUT",
headers: authHeaders({ "Content-Type": "application/json" }), headers: authHeaders({ "Content-Type": "application/json" }),
@@ -613,7 +600,6 @@
series_id: p.seriesId, series_id: p.seriesId,
title: p.title || (existing && existing.title) || p.seriesId, title: p.title || (existing && existing.title) || p.seriesId,
series_url: p.seriesUrl || (existing && existing.series_url) || "", series_url: p.seriesUrl || (existing && existing.series_url) || "",
cover: p.cover || (existing && existing.cover) || "",
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "", last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
last_chapter_num: last_chapter_num:
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null, p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
@@ -636,7 +622,6 @@
series_id: p.seriesId, series_id: p.seriesId,
title: existing.title || p.title || p.seriesId, title: existing.title || p.title || p.seriesId,
series_url: existing.series_url || p.seriesUrl || "", series_url: existing.series_url || p.seriesUrl || "",
cover: existing.cover || p.cover || "",
last_chapter: p.chapterLabel || existing.last_chapter || "", last_chapter: p.chapterLabel || existing.last_chapter || "",
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num, last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
last_chapter_url: p.chapterUrl || "", last_chapter_url: p.chapterUrl || "",
@@ -1147,7 +1132,15 @@
return el("div", { class: "item" + heat }, [ return el("div", { class: "item" + heat }, [
el("a", { class: "go", href: cont }, [ el("a", { class: "go", href: cont }, [
b.cover b.cover
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" }) ? el("img", {
class: "cover",
src: b.cover,
loading: "lazy",
alt: "",
// A Cover that will not load shows the designed placeholder
// rather than the browser's broken-image glyph (#47).
onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" })),
})
: el("div", { class: "cover ph" }), : el("div", { class: "cover ph" }),
]), ]),
el("div", { class: "meta" }, [ el("div", { class: "meta" }, [
+8 -43
View File
@@ -34,9 +34,6 @@ let metaTags = {};
// document.title. comix's SPA rewrites this on client routing but never // document.title. comix's SPA rewrites this on client routing but never
// og:title, so the comix adapter reads it instead. Reassigned per test. // og:title, so the comix adapter reads it instead. Reassigned per test.
let docTitle = ""; let docTitle = "";
// img[alt] elements comix's coverFromPage() scans. Reassigned per test; each
// entry is {alt, src}.
let pageImages = [];
globalThis.document = { globalThis.document = {
querySelector(sel) { querySelector(sel) {
const m = sel.match(/^meta\[property="([^"]+)"\]$/); const m = sel.match(/^meta\[property="([^"]+)"\]$/);
@@ -44,11 +41,8 @@ globalThis.document = {
const v = metaTags[m[1]]; const v = metaTags[m[1]];
return v == null ? null : { getAttribute: () => v }; return v == null ? null : { getAttribute: () => v };
}, },
querySelectorAll(sel) { querySelectorAll() {
if (sel !== "img[alt]") return []; return [];
return pageImages.map((img) => ({
getAttribute: (attr) => img[attr] ?? null,
}));
}, },
addEventListener() {}, addEventListener() {},
get title() { get title() {
@@ -100,7 +94,7 @@ test("stripBuildHash ignores suffixes that are not exactly 8 hex chars", () => {
// ============================================================ // ============================================================
test("asura.detect reads a series page, stripping the hash from the id only", () => { test("asura.detect reads a series page, stripping the hash from the id only", () => {
metaTags = { "og:title": "Solo Leveling | Asura Scans", "og:image": "https://cdn.example/x.jpg" }; metaTags = { "og:title": "Solo Leveling | Asura Scans" };
const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1")); const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1"));
assert.equal(p.type, "series"); assert.equal(p.type, "series");
assert.equal(p.site, "asura"); assert.equal(p.site, "asura");
@@ -108,12 +102,11 @@ test("asura.detect reads a series page, stripping the hash from the id only", ()
// seriesUrl keeps the hash: navigation needs the current one (stale ones 302). // seriesUrl keeps the hash: navigation needs the current one (stale ones 302).
assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1"); assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1");
assert.equal(p.title, "Solo Leveling"); assert.equal(p.title, "Solo Leveling");
assert.equal(p.cover, "https://cdn.example/x.jpg");
assert.equal(p.chapterNum, null); assert.equal(p.chapterNum, null);
}); });
test("asura.detect reads a chapter page including a decimal number", () => { test("asura.detect reads a chapter page including a decimal number", () => {
metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans", "og:image": "" }; metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans" };
const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5"; const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5";
const p = asura.detect(loc(url)); const p = asura.detect(loc(url));
assert.equal(p.type, "chapter"); assert.equal(p.type, "chapter");
@@ -150,7 +143,7 @@ test("asura.latestChapterFromAnchors returns null when nothing matches", () => {
// ============================================================ // ============================================================
test("demonic.detect reads a series page", () => { test("demonic.detect reads a series page", () => {
metaTags = { "og:title": "The World After The Fall", "og:image": "https://cdn.example/y.jpg" }; metaTags = { "og:title": "The World After The Fall" };
const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall")); const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall"));
assert.equal(p.type, "series"); assert.equal(p.type, "series");
assert.equal(p.site, "demonic"); assert.equal(p.site, "demonic");
@@ -159,7 +152,7 @@ test("demonic.detect reads a series page", () => {
}); });
test("demonic.detect reads a chapter page and strips the suffix from the title", () => { test("demonic.detect reads a chapter page and strips the suffix from the title", () => {
metaTags = { "og:title": "The World After The Fall Chapter 3", "og:image": "" }; metaTags = { "og:title": "The World After The Fall Chapter 3" };
const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1")); const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1"));
assert.equal(p.type, "chapter"); assert.equal(p.type, "chapter");
assert.equal(p.chapterNum, 3); assert.equal(p.chapterNum, 3);
@@ -247,26 +240,6 @@ test("comix parses decimal chapter numbers", () => {
assert.equal(p.chapterNum, 80.5); assert.equal(p.chapterNum, 80.5);
}); });
test("comix.detect reads the cover from an img whose alt matches the cleaned title", () => {
metaTags = { "og:title": COMIX_STALE_HOME };
docTitle = "Dungeons and Crayons";
pageImages = [
{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" },
{ alt: "Dungeons and Crayons", src: "https://cdn.example/cover.jpg" },
];
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
assert.equal(p.cover, "https://cdn.example/cover.jpg");
});
test("comix.detect leaves cover empty when no img alt matches the title", () => {
metaTags = {};
docTitle = "Dungeons and Crayons";
pageImages = [{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" }];
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
assert.equal(p.cover, "");
pageImages = [];
});
test("comix ignores unrelated paths", () => { test("comix ignores unrelated paths", () => {
assert.equal(comix.detect(loc("https://comix.to/browse")).type, "other"); assert.equal(comix.detect(loc("https://comix.to/browse")).type, "other");
}); });
@@ -308,23 +281,18 @@ const KAGANE_SERIES = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b";
const KAGANE_BOOK = "019fa2e0-6dbd-73ca-b40b-fe06ab75eb0e"; const KAGANE_BOOK = "019fa2e0-6dbd-73ca-b40b-fe06ab75eb0e";
test("kagane detects a series page", () => { test("kagane detects a series page", () => {
metaTags = { metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" };
"og:title": "Infinite Decryption: The Strongest Level 0",
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
};
const p = kagane.detect(loc("https://kagane.to/series/" + KAGANE_SERIES)); const p = kagane.detect(loc("https://kagane.to/series/" + KAGANE_SERIES));
assert.equal(p.type, "series"); assert.equal(p.type, "series");
assert.equal(p.site, "kagane"); assert.equal(p.site, "kagane");
assert.equal(p.seriesId, KAGANE_SERIES); assert.equal(p.seriesId, KAGANE_SERIES);
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0"); assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
assert.equal(p.cover, "https://kagane.to/api/v2/image/abc/compressed");
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES); assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
}); });
test("kagane reads the chapter number out of og:title", () => { test("kagane reads the chapter number out of og:title", () => {
metaTags = { metaTags = {
"og:title": "Infinite Decryption: The Strongest Level 0 - Chapter 41 - Episode 41", "og:title": "Infinite Decryption: The Strongest Level 0 - Chapter 41 - Episode 41",
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
}; };
const p = kagane.detect( const p = kagane.detect(
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK) loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
@@ -341,10 +309,7 @@ test("kagane reads the chapter number out of og:title", () => {
// no episode name, because the book carries volume_no and an empty title. // no episode name, because the book carries volume_no and an empty title.
// Captured live 2026-08-08 from SP Baby. // Captured live 2026-08-08 from SP Baby.
test("kagane reads through a Volume-numbered chapter suffix", () => { test("kagane reads through a Volume-numbered chapter suffix", () => {
metaTags = { metaTags = { "og:title": "SP Baby - Volume 1 Chapter 1" };
"og:title": "SP Baby - Volume 1 Chapter 1",
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
};
const p = kagane.detect( const p = kagane.detect(
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK) loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
); );
+2 -17
View File
@@ -4,8 +4,7 @@ const test = require("node:test");
const assert = require("node:assert"); const assert = require("node:assert");
// ============================================================ // ============================================================
// Minimal browser stub. Same shape as logic.test.js, plus a meta[name=...] // Minimal browser stub. Same shape as logic.test.js.
// branch: novelfull ships no og: tags, so its cover comes from name="image".
// document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded // document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded
// that never fires and no network call is ever made. // that never fires and no network call is ever made.
// ============================================================ // ============================================================
@@ -20,20 +19,14 @@ globalThis.localStorage = {
globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" }; globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" };
let metaTags = {}; let metaTags = {};
let namedMetas = {};
let elements = {}; let elements = {};
globalThis.document = { globalThis.document = {
querySelector(sel) { querySelector(sel) {
let m = sel.match(/^meta\[property="([^"]+)"\]$/); const m = sel.match(/^meta\[property="([^"]+)"\]$/);
if (m) { if (m) {
const v = metaTags[m[1]]; const v = metaTags[m[1]];
return v == null ? null : { getAttribute: () => v }; return v == null ? null : { getAttribute: () => v };
} }
m = sel.match(/^meta\[name="([^"]+)"\]$/);
if (m) {
const v = namedMetas[m[1]];
return v == null ? null : { getAttribute: () => v };
}
const text = elements[sel]; const text = elements[sel];
return text == null ? null : { textContent: text }; return text == null ? null : { textContent: text };
}, },
@@ -58,7 +51,6 @@ function loc(href) {
function reset() { function reset() {
metaTags = {}; metaTags = {};
namedMetas = {};
elements = {}; elements = {};
} }
@@ -68,21 +60,18 @@ function reset() {
test("novelfull.detect reads a series page", () => { test("novelfull.detect reads a series page", () => {
reset(); reset();
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
elements = { "h3.title": "Reverend Insanity" }; elements = { "h3.title": "Reverend Insanity" };
const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html")); const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html"));
assert.equal(p.type, "series"); assert.equal(p.type, "series");
assert.equal(p.site, "novelfull"); assert.equal(p.site, "novelfull");
assert.equal(p.seriesId, "reverend-insanity"); assert.equal(p.seriesId, "reverend-insanity");
assert.equal(p.title, "Reverend Insanity"); assert.equal(p.title, "Reverend Insanity");
assert.equal(p.cover, "https://novelfull.com/uploads/thumbs/ri.jpg");
assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html"); assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html");
assert.equal(p.chapterNum, null); assert.equal(p.chapterNum, null);
}); });
test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => { test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => {
reset(); reset();
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
elements = { "a.truyen-title": "Reverend Insanity" }; elements = { "a.truyen-title": "Reverend Insanity" };
const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html"; const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html";
const p = novelfull.detect(loc(url)); const p = novelfull.detect(loc(url));
@@ -120,14 +109,12 @@ test("novelfull.latestChapterFromAnchors takes the max and ignores other series"
test("lightnovelworld.detect reads a series page", () => { test("lightnovelworld.detect reads a series page", () => {
reset(); reset();
metaTags = { "og:image": "https://lightnovelworld.net/wp-content/uploads/awe.webp" };
elements = { "h1.entry-title": "A Will Eternal" }; elements = { "h1.entry-title": "A Will Eternal" };
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/")); const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/"));
assert.equal(p.type, "series"); assert.equal(p.type, "series");
assert.equal(p.site, "lightnovelworld"); assert.equal(p.site, "lightnovelworld");
assert.equal(p.seriesId, "a-will-eternal"); assert.equal(p.seriesId, "a-will-eternal");
assert.equal(p.title, "A Will Eternal"); assert.equal(p.title, "A Will Eternal");
assert.equal(p.cover, "https://lightnovelworld.net/wp-content/uploads/awe.webp");
}); });
test("lightnovelworld.detect strips the chapter suffix off the heading", () => { test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
@@ -141,8 +128,6 @@ test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
assert.equal(p.chapterLabel, "Chapter 1298"); assert.equal(p.chapterLabel, "Chapter 1298");
assert.equal(p.title, "A Will Eternal"); assert.equal(p.title, "A Will Eternal");
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/"); assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
// Chapter pages have no cover; the merge in bookmarkCurrent keeps the stored one.
assert.equal(p.cover, "");
}); });
test("lightnovelworld.detect returns other for non-series paths", () => { test("lightnovelworld.detect returns other for non-series paths", () => {