From cc0fa92a1a105fecae952ea184aa0f9cf7a78464 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 10 Aug 2026 04:20:23 +0700 Subject: [PATCH 1/2] 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. --- .../skills/testing-the-userscript/SKILL.md | 2 +- userscript/AGENTS.md | 21 ++++---- userscript/manga-bookmark.user.js | 42 +++++++-------- userscript/novel-bookmark.user.js | 39 ++++++-------- userscript/test/logic.test.js | 51 +++---------------- userscript/test/novel-logic.test.js | 19 +------ 6 files changed, 55 insertions(+), 119 deletions(-) diff --git a/.claude/skills/testing-the-userscript/SKILL.md b/.claude/skills/testing-the-userscript/SKILL.md index e61c484..150e9a2 100644 --- a/.claude/skills/testing-the-userscript/SKILL.md +++ b/.claude/skills/testing-the-userscript/SKILL.md @@ -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 | | `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 === undefined` sends the userscript's boot block down its `else` diff --git a/userscript/AGENTS.md b/userscript/AGENTS.md index 2a0b198..9966c62 100644 --- a/userscript/AGENTS.md +++ b/userscript/AGENTS.md @@ -2,7 +2,7 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A ### 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. 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 @@ -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 *previous* series' name. `document.title` is the one thing client routing does update, so titles come from there, with the chapter page's `" · Ch."` tail - stripped. Covers likewise: `og:image` is absent, so the cover is the `img` - whose `alt` matches the cleaned title — verified live 2026-08-08. + stripped. It publishes no `og:image` either, which is one of the reasons cover + acquisition moved to the backend. - **kagane.to**: series `/series/`, reader `/series//reader/`. Reader URLs carry no chapter number, so the number comes out of `og:title`. Two shapes exist: `" - Chapter [ - Episode ]"` and, for volume-numbered series, `" - Volume Chapter "` 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 - CORP-protected, so the web UI proxies them; the userscript still stores the - raw `og:image`. Behind a Cloudflare JS challenge, so the backend polls it + the volume tail lands in the bookmark's title. + Its covers are challenge- and CORP-protected, so nothing outside kagane.to can + 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. - **novelfull.com** (novel script): series `/.html`, chapter `//chapter-[-].html`. No `og:*` tags at all — title from - `h3.title` (series) or `a.truyen-title` (chapter), cover from - `meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint + `h3.title` (series) or `a.truyen-title` (chapter); the script reads no cover. + Behind a Cloudflare JS challenge no TLS fingerprint clears, so the backend polls it through the headless browser. - **lightnovelworld.net** (novel script): series `/novel//`, chapter `/-chapter-/` — flat, at the site root. `h1.entry-title` is the clean - title on a series page and ` Chapter <n>` on a chapter page. Chapter - pages carry no `og:image`. Its series page lists every chapter with an + 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` diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index c7e0a26..4643fd9 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -56,9 +56,10 @@ // ============================================================ // Site adapters // - // Page type + IDs come from URL regex (most stable); title/cover come from - // og: meta tags. Verified live 2026-07-24 against asurascans.com and - // demonicscans.org — see README "Adapter reference". + // Page type + IDs come from URL regex (most stable); the title comes from + // og: meta tags. Covers are never read here: the backend acquires and serves + // them itself (ADR-0007). Verified live 2026-07-24 against asurascans.com + // and demonicscans.org — see README "Adapter reference". // ============================================================ function meta(prop) { @@ -128,7 +129,6 @@ site: this.site, seriesId: stripBuildHash(m[1]), title: cleanTitle(meta("og:title")), - cover: meta("og:image") || "", seriesUrl: loc.origin + "/comics/" + m[1], chapterLabel: "Chapter " + m[2], chapterNum: isNaN(num) ? null : num, @@ -143,7 +143,6 @@ site: this.site, seriesId: stripBuildHash(m[1]), title: cleanTitle(meta("og:title")), - cover: meta("og:image") || "", seriesUrl: loc.origin + "/comics/" + m[1], chapterLabel: null, chapterNum: null, @@ -192,7 +191,6 @@ site: this.site, seriesId: decodeURIComponent(m[1]), title: cleanTitle(meta("og:title")), - cover: meta("og:image") || "", seriesUrl: loc.origin + "/manga/" + m[1], chapterLabel: "Chapter " + m[2], chapterNum: isNaN(num) ? null : num, @@ -207,7 +205,6 @@ site: this.site, seriesId: decodeURIComponent(m[1]), title: cleanTitle(meta("og:title")), - cover: meta("og:image") || "", seriesUrl: loc.origin + "/manga/" + m[1], chapterLabel: null, chapterNum: null, @@ -259,7 +256,6 @@ site: this.site, seriesId: comixSeriesId(m[1]), title: pageTitle, - cover: coverFromPage(pageTitle), seriesUrl: loc.origin + "/title/" + m[1], chapterLabel: "Chapter " + m[2], chapterNum: isNaN(num) ? null : num, @@ -274,7 +270,6 @@ site: this.site, seriesId: comixSeriesId(m[1]), title: pageTitle, - cover: coverFromPage(pageTitle), seriesUrl: loc.origin + "/title/" + m[1], chapterLabel: null, chapterNum: null, @@ -289,17 +284,6 @@ 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 // cannot win the maximum. seriesId is passed in because the anchors alone @@ -342,7 +326,6 @@ site: this.site, seriesId: m[1], title: cleanTitle(meta("og:title")), - cover: meta("og:image") || "", seriesUrl: loc.origin + "/series/" + m[1], chapterLabel: num === null ? null : "Chapter " + num, chapterNum: num, @@ -357,7 +340,6 @@ site: this.site, seriesId: m[1], title: cleanTitle(meta("og:title")), - cover: meta("og:image") || "", seriesUrl: loc.origin + "/series/" + m[1], chapterLabel: null, chapterNum: null, @@ -498,6 +480,10 @@ async function apiPut(key, obj, { sendStatus = false } = {}) { const body = Object.assign({}, obj); 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), { method: "PUT", headers: authHeaders({ "Content-Type": "application/json" }), @@ -855,7 +841,6 @@ series_id: p.seriesId, title: p.title || (existing && existing.title) || p.seriesId, series_url: p.seriesUrl || (existing && existing.series_url) || "", - cover: p.cover || (existing && existing.cover) || "", last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "", last_chapter_num: p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null, @@ -878,7 +863,6 @@ series_id: p.seriesId, title: existing.title || p.title || p.seriesId, series_url: existing.series_url || p.seriesUrl || "", - cover: existing.cover || p.cover || "", last_chapter: p.chapterLabel || existing.last_chapter || "", last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num, last_chapter_url: p.chapterUrl || "", @@ -1394,7 +1378,15 @@ return el("div", { class: "item" + heat }, [ el("a", { class: "go", href: cont }, [ 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: "meta" }, [ diff --git a/userscript/novel-bookmark.user.js b/userscript/novel-bookmark.user.js index 93efcdd..7a89952 100644 --- a/userscript/novel-bookmark.user.js +++ b/userscript/novel-bookmark.user.js @@ -33,16 +33,11 @@ // ============================================================ // Site adapters // - // Page type + IDs come from URL regex (most stable); title/cover come from - // og: meta tags (with the novelfull name= meta as the exception). + // Page type + IDs come from URL regex (most stable); the title comes from + // 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 // 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. @@ -66,12 +61,6 @@ 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) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -108,7 +97,6 @@ // h3.title on a chapter page is the *chapter's* title; the breadcrumb // link back to the series page carries the series name. title: back ? (back.textContent || "").trim() : "", - cover: metaName("image") || "", seriesUrl: loc.origin + "/" + m[1] + ".html", chapterLabel: "Chapter " + m[2], chapterNum: isNaN(num) ? null : num, @@ -124,7 +112,6 @@ site: this.site, seriesId: m[1], title: h3 ? (h3.textContent || "").trim() : "", - cover: metaName("image") || "", seriesUrl: loc.origin + "/" + m[1] + ".html", chapterLabel: null, chapterNum: null, @@ -157,9 +144,6 @@ seriesId: m[1], // The heading is "<Series> Chapter <n>"; drop the suffix. 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] + "/", chapterLabel: "Chapter " + m[2], chapterNum: isNaN(num) ? null : num, @@ -175,7 +159,6 @@ site: this.site, seriesId: m[1], title: h1 ? (h1.textContent || "").trim() : "", - cover: meta("og:image") || "", seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/", chapterLabel: null, chapterNum: null, @@ -286,6 +269,10 @@ async function apiPut(key, obj, { sendStatus = false } = {}) { const body = Object.assign({}, obj); 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), { method: "PUT", headers: authHeaders({ "Content-Type": "application/json" }), @@ -613,7 +600,6 @@ series_id: p.seriesId, title: p.title || (existing && existing.title) || p.seriesId, series_url: p.seriesUrl || (existing && existing.series_url) || "", - cover: p.cover || (existing && existing.cover) || "", last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "", last_chapter_num: p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null, @@ -636,7 +622,6 @@ series_id: p.seriesId, title: existing.title || p.title || p.seriesId, series_url: existing.series_url || p.seriesUrl || "", - cover: existing.cover || p.cover || "", last_chapter: p.chapterLabel || existing.last_chapter || "", last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num, last_chapter_url: p.chapterUrl || "", @@ -1147,7 +1132,15 @@ return el("div", { class: "item" + heat }, [ el("a", { class: "go", href: cont }, [ 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: "meta" }, [ diff --git a/userscript/test/logic.test.js b/userscript/test/logic.test.js index fdeff51..589d912 100644 --- a/userscript/test/logic.test.js +++ b/userscript/test/logic.test.js @@ -34,9 +34,6 @@ let metaTags = {}; // document.title. comix's SPA rewrites this on client routing but never // og:title, so the comix adapter reads it instead. Reassigned per test. let docTitle = ""; -// img[alt] elements comix's coverFromPage() scans. Reassigned per test; each -// entry is {alt, src}. -let pageImages = []; globalThis.document = { querySelector(sel) { const m = sel.match(/^meta\[property="([^"]+)"\]$/); @@ -44,11 +41,8 @@ globalThis.document = { const v = metaTags[m[1]]; return v == null ? null : { getAttribute: () => v }; }, - querySelectorAll(sel) { - if (sel !== "img[alt]") return []; - return pageImages.map((img) => ({ - getAttribute: (attr) => img[attr] ?? null, - })); + querySelectorAll() { + return []; }, addEventListener() {}, 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", () => { - 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")); assert.equal(p.type, "series"); 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). assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1"); assert.equal(p.title, "Solo Leveling"); - assert.equal(p.cover, "https://cdn.example/x.jpg"); assert.equal(p.chapterNum, null); }); 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 p = asura.detect(loc(url)); assert.equal(p.type, "chapter"); @@ -150,7 +143,7 @@ test("asura.latestChapterFromAnchors returns null when nothing matches", () => { // ============================================================ 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")); assert.equal(p.type, "series"); 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", () => { - 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")); assert.equal(p.type, "chapter"); assert.equal(p.chapterNum, 3); @@ -247,26 +240,6 @@ test("comix parses decimal chapter numbers", () => { 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", () => { 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"; test("kagane detects a series page", () => { - metaTags = { - "og:title": "Infinite Decryption: The Strongest Level 0", - "og:image": "https://kagane.to/api/v2/image/abc/compressed", - }; + metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" }; const p = kagane.detect(loc("https://kagane.to/series/" + KAGANE_SERIES)); assert.equal(p.type, "series"); assert.equal(p.site, "kagane"); assert.equal(p.seriesId, KAGANE_SERIES); 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); }); test("kagane reads the chapter number out of og:title", () => { metaTags = { "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( 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. // Captured live 2026-08-08 from SP Baby. test("kagane reads through a Volume-numbered chapter suffix", () => { - metaTags = { - "og:title": "SP Baby - Volume 1 Chapter 1", - "og:image": "https://kagane.to/api/v2/image/abc/compressed", - }; + metaTags = { "og:title": "SP Baby - Volume 1 Chapter 1" }; const p = kagane.detect( loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK) ); diff --git a/userscript/test/novel-logic.test.js b/userscript/test/novel-logic.test.js index 7293764..62e689e 100644 --- a/userscript/test/novel-logic.test.js +++ b/userscript/test/novel-logic.test.js @@ -4,8 +4,7 @@ const test = require("node:test"); const assert = require("node:assert"); // ============================================================ -// Minimal browser stub. Same shape as logic.test.js, plus a meta[name=...] -// branch: novelfull ships no og: tags, so its cover comes from name="image". +// Minimal browser stub. Same shape as logic.test.js. // document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded // that never fires and no network call is ever made. // ============================================================ @@ -20,20 +19,14 @@ globalThis.localStorage = { globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" }; let metaTags = {}; -let namedMetas = {}; let elements = {}; globalThis.document = { querySelector(sel) { - let m = sel.match(/^meta\[property="([^"]+)"\]$/); + const m = sel.match(/^meta\[property="([^"]+)"\]$/); if (m) { const v = metaTags[m[1]]; 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]; return text == null ? null : { textContent: text }; }, @@ -58,7 +51,6 @@ function loc(href) { function reset() { metaTags = {}; - namedMetas = {}; elements = {}; } @@ -68,21 +60,18 @@ function reset() { test("novelfull.detect reads a series page", () => { reset(); - namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" }; elements = { "h3.title": "Reverend Insanity" }; const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html")); assert.equal(p.type, "series"); assert.equal(p.site, "novelfull"); assert.equal(p.seriesId, "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.chapterNum, null); }); test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => { reset(); - namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" }; elements = { "a.truyen-title": "Reverend Insanity" }; const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html"; 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", () => { reset(); - metaTags = { "og:image": "https://lightnovelworld.net/wp-content/uploads/awe.webp" }; elements = { "h1.entry-title": "A Will Eternal" }; const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/")); assert.equal(p.type, "series"); assert.equal(p.site, "lightnovelworld"); assert.equal(p.seriesId, "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", () => { @@ -141,8 +128,6 @@ test("lightnovelworld.detect strips the chapter suffix off the heading", () => { assert.equal(p.chapterLabel, "Chapter 1298"); assert.equal(p.title, "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", () => { -- 2.52.0 From 8b58019e8c59a0617c6bbd06b8451c493d61a9b3 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki <sultankiki05@gmail.com> Date: Mon, 10 Aug 2026 04:27:22 +0700 Subject: [PATCH 2/2] docs: drop the last references to cover scraping (#60) Review follow-ups. The README's adapter reference and the userscript testing skill still described a scrape that no longer exists, and the manga test stub kept a querySelectorAll whose only caller was the deleted coverFromPage. The handler comment's premise ("every installed userscript still sends one") stops being true the moment a Reader reinstalls, so it now says older copies may. --- .claude/skills/testing-the-userscript/SKILL.md | 2 +- README.md | 6 ++++-- backend/internal/api/handlers.go | 10 +++++----- userscript/test/logic.test.js | 3 --- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.claude/skills/testing-the-userscript/SKILL.md b/.claude/skills/testing-the-userscript/SKILL.md index 150e9a2..1267d5f 100644 --- a/.claude/skills/testing-the-userscript/SKILL.md +++ b/.claude/skills/testing-the-userscript/SKILL.md @@ -14,7 +14,7 @@ parsers, helpers. UI, network, and storage behaviour are verified on-device. ```bash 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. diff --git a/README.md b/README.md index 5a293a4..adcdee1 100644 --- a/README.md +++ b/README.md @@ -261,8 +261,10 @@ an API. ## Adapter reference (verified live 2026-07-24) -The site adapters key everything off URL regex, with `title`/`cover` from -`og:title` / `og:image`. Confirmed against live pages via Playwright: +The site adapters key everything off URL regex, with `title` from `og:title` +(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` | |------|-----------|-------------|-------------| diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index c48b489..dba1eeb 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -51,11 +51,11 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) { return } // A body may carry a cover, and it is discarded here rather than - // rejected: every installed userscript still sends one, and ADR-0004's - // compatibility argument depends on those scripts continuing to work. The - // Cover is acquired server-side (ADR-0007), so the field is permanently - // inert - not pending removal, and not a value any later code should - // start reading. + // rejected: an older installed userscript may still send one, and + // ADR-0004's compatibility argument depends on those scripts continuing + // to work. The Cover is acquired server-side (ADR-0007), so the field is + // permanently inert - not pending removal, and not a value any later code + // should start reading. b.Cover = "" // Path key is authoritative; derive site/series_id from it when the body diff --git a/userscript/test/logic.test.js b/userscript/test/logic.test.js index 589d912..f095de3 100644 --- a/userscript/test/logic.test.js +++ b/userscript/test/logic.test.js @@ -41,9 +41,6 @@ globalThis.document = { const v = metaTags[m[1]]; return v == null ? null : { getAttribute: () => v }; }, - querySelectorAll() { - return []; - }, addEventListener() {}, get title() { return docTitle; -- 2.52.0