Covers render in the userscript panel, from a public route (#60) (#69)

Closes #60.

Spec: #55. Originating bug: #47. Architecture: `docs/adr/0007-backend-hosts-cover-bytes.md`. Neither #47 nor #55 is closed from here.

## What this branch does

The panel now renders Covers from the deployment's own origin, and both userscripts stop having an opinion about where a Cover lives.

**The public route was already in place.** `GET /covers/{address}` landed with #59 (`92eba07`) and is registered on the bare mux, outside `httpmw.Auth` and outside the web UI's Discord session — `backend/main.go:210-214`, handler `backend/internal/api/handlers.go:142-158`. It reads no cookie and no header, answers `404` for an address that was never stored (and for a row whose file has gone missing — recorded-but-gone is not-found, never a fabricated body), refuses anything that is not `^[0-9a-f]{64}$` *before* the value becomes a path, and sets `Cache-Control: public, max-age=604800, immutable`. Those four properties are asserted by `backend/cover_test.go:231-278`. This branch re-verified them rather than re-implementing them; the only backend line it touches is a comment.

**Both userscripts lose cover scraping entirely.** Every adapter's `cover:` field is gone, along with the two helpers that fed them: the manga script's `coverFromPage()` (the `img[alt]` DOM scan comix needed, because comix publishes no `og:image`) and the novel script's `metaName()` plus the now-callerless module-level `meta()`. Nothing under `userscript/` reads `og:image`, `meta[name=image]`, or `img[alt]` any more.

**Nothing sends a cover either.** `delete body.cover` sits in `apiPut` — `manga-bookmark.user.js:486`, `novel-bookmark.user.js:275` — which is the single chokepoint every write passes through (`pushBookmark`, the retry-queue flush, `toggleFavorite`, `toggleArchive`). It operates on the `Object.assign` copy, so the in-memory row keeps the cover it renders with. This matters beyond tidiness: a Reader upgrading from an older copy has `localStorage` rows carrying third-party scraped URLs, and without the strip those would ride back up on the next write. The handler discards the field regardless (`handlers.go:53-59`) — it is permanently inert, not pending removal.

**Failed loads get the designed empty state, not the broken-image glyph.** `onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" }))` on the cover `<img>` in both card renderers (`manga:1380-1390`, `novel:1134-1144`). The replacement is byte-identical to the existing no-cover branch on the very next line, so it picks up the `.cover.ph` styling already in the panel CSS — no new tokens, no new rule. `el()` routes any `on*` prop through `addEventListener`, so this is a listener, not an inline attribute string, and the swap is a `createElement` + DOM call with no markup parsing anywhere near it. This is the half of #47 that was visible on kagane.

**The deleted scraping's tests went with it**: the two comix cover cases, the `pageImages` and `namedMetas` fixtures, the `img[alt]` and `meta[name=...]` stub branches, the now-dead `querySelectorAll` stub member, and every stale `og:image` fixture and `p.cover` assertion across both suites. The export lists needed no change and that was checked, not assumed — `coverFromPage` and `metaName` were module-private on `origin/main` and no cover symbol ever appeared in `module.exports`.

Docs that described the deleted behaviour were corrected in the same breath, because leaving them would instruct the next agent to put the scraping back: `userscript/AGENTS.md` (adapter contract + the per-site notes for comix, kagane and novelfull), the README's adapter reference, and the userscript testing skill's stub table.

## Verification

- `go test -count=1 ./...` — green across all nine packages (`backend` 29.8s, `latest`, `store`, `session`, `token`, `userscript`, `web`).
- `node --check` clean on both userscripts; `node --test` on both logic suites — 46 tests, 46 pass.
- `gofmt -l` clean; `go build ./...` clean.
- The `onerror` swap is DOM behaviour and deliberately has no coverage in the Node harness — that harness stubs a browser precisely so it never needs a DOM, and #60 says not to invent coverage for it. It was instead exercised for real: the `el()` helper and the exact render expression were loaded into a headless Chromium with a deliberately unloadable `src`, and the resulting DOM was `<div class="cover ph"></div>`. Ad hoc, not committed.
- **Not done, needs you:** the on-device criterion — a comix Series bookmarked mid-chapter showing its Cover in the panel. That needs a real install against the deployment and is the one box left unticked on #60.

## Reviewed

Both `/code-review` axes ran against `cc0fa92`. Spec found no missed requirement and no scope creep; standards found the diff clean on the four areas it scrutinised (the `delete body.cover` placement, the `onerror` handler's DOM safety, comment quality, dead-code removal). Their combined findings — the dead `querySelectorAll` stub, the stale README and skill text, and the handler comment whose premise this change invalidates — are fixed in `8b58019`.

## Out of scope, deliberately

The kagane-specific cover proxy still exists and still carries its session gate (#63 deletes it). The poll's blank-Cover fill (#61) and browser-backed Sites joining the pipeline (#62) are untouched.

Reviewed-on: #69
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #69.
This commit is contained in:
2026-08-10 08:52:57 +07:00
committed by sulthan
parent 92eba07da7
commit e2c054e7ce
8 changed files with 63 additions and 128 deletions
@@ -14,7 +14,7 @@ parsers, helpers. UI, network, and storage behaviour are verified on-device.
```bash ```bash
node --check userscript/manga-bookmark.user.js # parse check, silent on success node --check userscript/manga-bookmark.user.js # parse check, silent on success
node --test userscript/test/logic.test.js # 14 tests as of 2026-07-28 node --test userscript/test/logic.test.js # 35 tests as of 2026-08-10
``` ```
Run both before every commit that touches the userscript. Run both before every commit that touches the userscript.
@@ -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`
+4 -2
View File
@@ -261,8 +261,10 @@ an API.
## Adapter reference (verified live 2026-07-24) ## Adapter reference (verified live 2026-07-24)
The site adapters key everything off URL regex, with `title`/`cover` from The site adapters key everything off URL regex, with `title` from `og:title`
`og:title` / `og:image`. Confirmed against live pages via Playwright: (or the page's own heading where a site ships none). No adapter reads a cover:
the backend acquires, stores and serves every Cover from its own origin
(ADR-0007). Confirmed against live pages via Playwright:
| Site | Series URL | Chapter URL | `series_id` | | Site | Series URL | Chapter URL | `series_id` |
|------|-----------|-------------|-------------| |------|-----------|-------------|-------------|
+5 -5
View File
@@ -51,11 +51,11 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
return return
} }
// A body may carry a cover, and it is discarded here rather than // A body may carry a cover, and it is discarded here rather than
// rejected: every installed userscript still sends one, and ADR-0004's // rejected: an older installed userscript may still send one, and
// compatibility argument depends on those scripts continuing to work. The // ADR-0004's compatibility argument depends on those scripts continuing
// Cover is acquired server-side (ADR-0007), so the field is permanently // to work. The Cover is acquired server-side (ADR-0007), so the field is
// inert - not pending removal, and not a value any later code should // permanently inert - not pending removal, and not a value any later code
// start reading. // should start reading.
b.Cover = "" b.Cover = ""
// Path key is authoritative; derive site/series_id from it when the body // Path key is authoritative; derive site/series_id from it when the body
+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" }, [
+6 -44
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,12 +41,6 @@ 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) {
if (sel !== "img[alt]") return [];
return pageImages.map((img) => ({
getAttribute: (attr) => img[attr] ?? null,
}));
},
addEventListener() {}, addEventListener() {},
get title() { get title() {
return docTitle; return docTitle;
@@ -100,7 +91,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 +99,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 +140,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 +149,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 +237,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 +278,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 +306,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", () => {