e2c054e7ce
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>
6.3 KiB
6.3 KiB
Guidance for OpenCode (and Claude Code) working under userscript/. See root AGENTS.md for the project-wide architecture diagram, hard constraints, and design system.
Userscript structure (single IIFE, manga-bookmark.user.js)
- Site adapters — one per host,
detect(location, document)return pagetype+ IDs. Identify type/IDs from URL regex (most stable); pulltitlefromog: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'scoveris already an address on our origin, andapiPutstrips anycoveroff an outgoing body. - API client —
apiGet/apiPut/apiDeletewith bearer header;localStoragekeybmgr:manga:cachefor instant render + offline fallback. - Progress logic — auto-upsert
last_chapteronly whenchapterNum >= stored last_chapter_num(re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. - Retry queue — every write go through
pushBookmark/pushDelete, so failed mutation park inlocalStorage(bmgr:manga:queue) and replayed on next navigation, reconnect, orrefresh(). Entries are markers ({key, op, sendStatus, attempts}), never payloads — body read from cache at send time, so one entry per key give ordering and coalescing for free.sendStatusis sticky: while archive pending, later writes to that key keep carrying bucket, which stop successful in-between write from silently un-archiving series.refresh()drains before it fetches and overlays anything still pending, so list never flaps. 400 drops entry, 401 abort pass and keep queue, and transient failures retry to cap of 10. Latest-chapter writes deliberately stay out of queue. Seedocs/superpowers/specs/2026-07-27-offline-retry-queue-design.md. - UI — rendered inside Shadow DOM root to isolate from site CSS
(critical on mobile). Three tabs (All / Favourites / Archived) and row of
link chips to web UI and both manga sites;
WEB_BASEsits in CONFIG block next toAPI_BASE. FAB is7 × 44edge tab whose hit area widened to28 × 72by invisible#hitchild;#fabmust keeptouch-action: noneand must not regainoverflow: hidden. Sincetouch-actionresolved at gesture start, strip can't be both browser-scrolled and script-dragged, somakeDraggablesplits by intent: swipe from#hitscrolls viawindow.scrollBy, hold ofARM_MSarms reposition drag, visible sliver drags with no hold. Seedocs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md. - SPA navigation — Asura is Astro, client-routed on comic/chapter pages: patch
history.pushState/replaceState+ listenpopstate, re-rundetect()on URL change so auto-update fire without reload. Demonic uses classic reloads (initialdocument-idlerun suffice).
Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust)
- asurascans.com: series
/comics/<slug>(slug carries trailing site-wide build-hash suffix, e.g.-059befe1, that rotates on every redeploy), chapter/comics/<slug>/chapter/<n>.seriesIdmust strip hash (/-[0-9a-f]{8}$/,stripBuildHashin userscript,asuraBuildHashin backend); URLs keep full slug — stale-hash URLs 302 to current ones. Astro-rendered; chapter links present in raw server HTML. - demonicscans.org: series
/manga/<slug>(slug may URL-encode punctuation, e.g.%2527for'), chapter/title/<slug>/chapter/<n>/<page>(olderchaptered.php?manga=<id>&chapter=<n>form still exists as redirect, what series-page chapter-list anchors link through). Encodings (incl. triple-encoded punctuation like%25252D) identical on /manga/ and /title/ pages, so decode-once seriesIds match — verified 2026-07-28. - comix.to: series
/title/<id>-<slug>, chapter/title/<id>-<slug>/<uploadId>-chapter-<n>. Only the leading<id>is identity — the slug re-renders when a series is renamed (comixSeriesId). An SPA that never rewritesog:title: the server-rendered head keeps whatever document loaded first, so on a cold loadog:titleis the homepage's "Comix — Read Comics online for free" and after an in-page hop it is the previous series' name.document.titleis the one thing client routing does update, so titles come from there, with the chapter page's" · Ch.<n>"tail stripped. It publishes noog:imageeither, which is one of the reasons cover acquisition moved to the backend. - kagane.to: series
/series/<uuid>, reader/series/<uuid>/reader/<bookUuid>. Reader URLs carry no chapter number, so the number comes out ofog:title. Two shapes exist:"<Series> - Chapter <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 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
/<slug>.html, chapter/<slug>/chapter-<n>[-<title-slug>].html. Noog:*tags at all — title fromh3.title(series) ora.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/<slug>/, chapter/<slug>-chapter-<n>/— flat, at the site root.h1.entry-titleis the clean title on a series page and<Title> Chapter <n>on a chapter page. Its series page lists every chapter with an absolute href, so the backend polls it with the plain TLS client.
Second script: novel-bookmark.user.js
A copy of the manga script with two adapters, LIBRARY = "novel" and
STORE_PREFIX = "bmgr:novel:". No migration loop (this script has no previous
installation to carry keys over from). Installed alongside the manga script;
both write to the same backend with the same LIBRARY column discriminating
them.