Tracks read progress on comix.to and kagane.to alongside asura and demonic, in both the userscript and the backend. Implements `docs/superpowers/plans/2026-08-03-comix-kagane-support.md`. ## Userscript - `comix` adapter — `/title/<id>-<slug>`; only the id prefix is identity (the slug follows the title). No `og:image`, so the cover is matched by `alt`. - `kagane` adapter — reader URLs are uuids with no chapter number, so it comes out of `og:title`; anchor scanning is structurally impossible, replaced by `latestChapterFromApi` against kagane's same-origin JSON API. - `seriesId` threaded through `latestChapterFromAnchors` so comix can scope its scan to its own series and a recommendation strip cannot win the maximum. - `@match` for both hosts, panel chips, v1.6.0. ## Backend - `latestChapterFrom` cases: comix parses the SSR JSON state blob (`latestChapterUrl`, scoped to the series id); kagane parses API JSON (`chapter_no`). - Poller allowlist extended; `Poller.BrowserFetch` with `fetcherFor(site)` routes kagane to a browser fetcher. Nil means kagane is not polled at all — never a fallback to the TLS fetcher, which would only ever retrieve a challenge page. - `BrowserFetcher`: chromedp against a `headless-shell` sidecar. kagane sits behind a Cloudflare JS challenge that no TLS fingerprint clears, and the request is made inside the page rather than by replaying `cf_clearance`. - `BROWSER_WS_URL` wiring, sidecar in both compose files (no `ports:`, dedicated non-external network), Dockerfile on `golang:1.26-alpine` — chromedp requires go 1.26. - Web UI `--comix` / `--kagane` tokens in both colour branches. ## Notes for review - `series_url` is client-supplied and a headless browser is a strong SSRF primitive, so kagane's host is pinned twice: in `fetchableSeriesURL` and again in `kaganeAPIURL`. - Three chained defects found during verification made the browser path dead under Compose (sidecar flag collision, Chrome's Host-header DNS-rebinding check, the wrong chromedp option). Fixed; the compose comments record the wrong configurations too, so they don't get "simplified" back. - `ALLOWED_ORIGINS` now includes both new origins. Without it every write from comix/kagane silently fails CORS preflight, parks in the retry queue, and drops at the cap. ## Verification 221 backend tests, 32 userscript tests, static `CGO_ENABLED=0` build, both compose configs. Two gaps, both real: 1. The userscript on live pages via Violentmonkey needs a human browser profile — not run. Check: comix series page (title/cover, no chapter), comix chapter page (records the number; an *older* chapter must not regress it), comix SPA navigation without reload, kagane series page (og:image cover), kagane reader (number from `og:title`), both chips opening the right sites. 2. The kagane browser path has not completed end-to-end anywhere. Dial/navigate/fetch is confirmed, but Cloudflare 403'd headless-shell's Chrome on every attempt from the dev sandbox, and comix's poll-through-Docker was blocked by that environment's TLS interception. Both environment-dependent rather than branch defects — the first real deploy is the actual verification. Reviewed-on: #13 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
16 KiB
AGENTS.md
Guidance for OpenCode (and Claude Code) working in this repo.
Status
Active. Backend (backend/) and userscript (userscript/manga-bookmark.user.js) built. Plan plans/mangaBookmark.md = original spec, may drift; trust code + design docs in docs/superpowers/specs/ over plan.
What this is
Manga read-progress tracker for user reading on asurascans.com (current domain; asuracomic.net 301s here) and demonicscans.org from Bromite (mobile Chromium). Userscript injects on-page UI (floating button + slide-in panel), syncs progress to self-hosted Go backend so bookmarks unify across both sites and devices.
Hard constraints (drive design — do not violate)
Bromite uses Chromium's native userscript engine, not Tampermonkey:
- No
GM_*APIs anywhere. NoGM_setValue/GM_getValue(use pagelocalStorage), noGM_registerMenuCommand(inject on-page UI), noGM_xmlhttpRequestfor cross-origin (use plainfetch()). GM-free script also runs in desktop Tampermonkey/Violentmonkey for faster iteration. - Cross-origin
fetch()works only against CORS-enabled backend. Manga siteshttps://, so backend must be HTTPS (else mixed-content block). - Asura and Demonic = separate origins, separate
localStorage— shared remote store only way to unify bookmarks. Cloud sync required, not optional. - Userscript runs in isolated world, so embedded API token safe from site's JS.
- Cloudflare's block on manga sites is IP-reputation-based, not universal — not reliably reproducible. Verified 2026-07-26: plain
curlfrom both CGNAT dev machine and deployed VPS got clean 200s w/ real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier, untested assumption CGNAT dev IP would be blocked; wasn't, at least this date. Treat "does curl work now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare bot scoring can flip clean IP without notice. Any backend fetcher still needs graceful-degrade path for when challenged; adapters should be verified against live pages (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test.
Architecture
Bromite userscript (isolated world, per-site adapters, localStorage cache)
-- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume)
- Backend (
backend/): stdlibnet/http(handful of routes, no framework) +modernc.org/sqlite(pure Go,CGO_ENABLED=0-> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain:8080. Single binary, split into packages underbackend/internal/:store(Bookmark type, SQLite persistence, migrations),latest(background poller, site parsers, TLS fetcher),session(cookie signing, login rate limiter),httpmw(Auth/Gzip/CORS middleware),api(JSON bookmark handlers),userscript(userscript-serving handler),web(browser UI handler +templates/+static/,go:embed-ed).backend/main.gois the composition root — the only place that wires packages together intonewRouter. Root-level*_test.gohold integration tests that exercise the full router; unit tests for a package live beside it underinternal/. - Single-user store. One
bookmarkstable keyed<site>:<series_id>(asura|demonic). Sync last-write-wins. Schema + endpoint list in plan. - Endpoints:
GET /bookmarks,PUT /bookmarks/{key}(upsert; seeupdated_atrule below),DELETE /bookmarks/{key},GET /healthz(no auth). - Web UI: same binary serves password-gated browser UI on second
hostname —
GET /(list, or login page when no session),POST /login,POST /logout,GET /static/*, htmx fragment endpoints under/ui/*. Templates + assetsgo:embed-ed underbackend/internal/web/, sobackend/Dockerfilemust copy the wholeinternal/tree, not just*.go. Sessions = stateless HMAC cookies keyed offAPI_TOKEN;WEB_PASSWORDgates them, when empty web routes not registered at all. UI mutations read-modify-write throughStore.Get+Store.Upsertsoupdated_atrule stays one place. Seedocs/superpowers/specs/2026-07-25-web-ui-design.md. Design-tool caveat: templates link/static/style.cssroot-absolutely (correct — served from/), but impeccable detector resolves stylesheet href withpath.resolve(fileDir, href), drops directory on leading/and silently skips file. Relative hrefs don't help either: template's directory isn't its served path. Sodetect.mjs backend/internal/web/templatesreports false clean — always passbackend/internal/web/statictoo. One finding there,overused-fonton "Instrument Serif", deliberate identity choice, not debt. - Every action that moves series out of list is confirm-gated.
Archive, finish, remove each open own
.confirm-rowdisclosure (toggleConfirmRow(key, kind)infilter.js,kind∈archive|finish|remove); restore fires instantly since it's the reversal. Remove's row wears ember wash, two reversible ones wear.calmgrey.--emberstays reserved for new-chapter signal: busy bar and inline error use--mute. - Latest-chapter poller: ticker goroutine in same binary re-checks
each bookmarked series' newest published chapter from backend's own
network access, so
latest_chapterstays fresh when user not browsing. Second, parallel signal — userscript keeps ownmaybeCaptureLatestOnSeriesPage/backgroundRefreshLatestlogic unchanged. Two independent clocks: per-bookmark cooldown (latest_checked_atcolumn, enforced byStore.DueForLatestCheck's WHERE clause) and wake interval. Row stamped before fetch so broken series waits full cooldown instead of retrying every tick; writes go throughStore.Get+Store.Upsertso new chapter never reorders list. Fetches usebogdanfinn/tls-clientw/ Chrome profile as defence in depth against fingerprint-based blocking; any failure logs and skips. Seedocs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md. Poller'sStore.Get+Store.Upsertnot wrapped in transaction, so userscriptPUTcommitting between the two can be overwritten by poller's stale re-read — reverting read progress and, since stored value now differs, movingupdated_atand reordering list. Known, accepted limitation for single-user deployment, not bug to fix. updated_atdrives list order, moves only on real reading progress: server applies timestamp when row new orlast_chapter_numchanges, else keeps stored value — favouriting series or recording newly published chapter must not reorder list.PUTtherefore returns row as stored; clients must adopt that response over own payload. Seeplans/2026-07-25-bookmark-list-favorites-design.md§4.- Lifecycle buckets:
statuson each bookmark isreading|archived|finished, orthogonal tofavorite. Archived and finished appear only in own tab — not All, Updated, Favourites, or recent strip. Poller keeps checking archived series, skips finished ones.finishedsettable only from web UI;PUT /bookmarks/{key}rejects it w/ 400. Empty incoming status means "keep stored one" — resolved onVALUESside ofStore.Upsert, not conflict clause, sinceexcluded.*= post-evaluation row and default applied there'd wipe bucket on every PUT from client predating column. Seedocs/superpowers/specs/2026-07-27-status-buckets-design.md. - Config via env:
API_TOKEN,ALLOWED_ORIGINS(comma list),DB_PATH(default/data/bookmarks.db),PORT(default8080),WEB_PASSWORD(gates browser UI; unset disables it),LATEST_CHAPTER_POLL_ENABLED/_COOLDOWN/_INTERVAL/_BATCH/_STAGGER(background latest-chapter poller; defaults on,1h/10m/14/20s).USERSCRIPT_PATH(file served at/u/{token}/manga-bookmark.user.js, default/userscript/manga-bookmark.user.js, supplied by a bindmount).
Userscript structure (single IIFE, manga-bookmark.user.js)
- Site adapters — one per host,
detect(location, document)returns pagetype+ IDs. ID type/IDs from URL regex (most stable); pulltitle/coverfromog:title/og:imagemeta tags, not CSS classes. - API client —
apiGet/apiPut/apiDeletew/ bearer header;localStoragekeymangabm: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 goes through
pushBookmark/pushDelete, so failed mutation parked inlocalStorage(mangabm: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 gives ordering + coalescing for free.sendStatussticky: while archive pending, later writes to that key keep carrying bucket, stops successful in-between write from silently un-archiving series.refresh()drains before fetching, overlays anything still pending, so list never flaps. 400 drops entry, 401 aborts pass and keeps queue, 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) + 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 fires w/o reload. Demonic uses classic reloads (initialdocument-idlerun suffices).
Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
- asurascans.com: series
/comics/<slug>(slug carries a trailing site-wide build-hash suffix, e.g.-059befe1, that rotates on every redeploy), chapter/comics/<slug>/chapter/<n>.seriesIdmust strip the hash (/-[0-9a-f]{8}$/,stripBuildHashin the userscript,asuraBuildHashin the backend); URLs keep the 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) are identical on /manga/ and /title/ pages, so decode-once seriesIds match — verified 2026-07-28.
Commands
Backend (cd backend):
- Test all:
go test ./... - Single test:
go test -run TestName ./... - Build static binary:
CGO_ENABLED=0 go build
Local stack: docker compose up (named volume mounted at /data, restart: unless-stopped).
Smoke test: curl endpoints w/ Authorization: Bearer <token>; confirm OPTIONS preflight returns CORS headers and /healthz returns 200.
Forge: Gitea, not GitHub
origin = self-hosted Gitea instance (gitea.violetcrown.my.id), so gh doesn't work here — use tea (Gitea CLI) for anything past plain git. Common ones:
- Open PR:
tea pr create --head <branch> --base main --title "..." --description "..." - List / view / check out:
tea pr list,tea pr <n>,tea pr checkout <n> - Issues:
tea issue create,tea issue list - Auth lives in
tea login, notGH_TOKENenv var.
tea prints output as rendered boxes not plain text; PR URL lands on last line.
Design system
Web UI + userscript panel follow Cinder, rules in docs/design-system.md
— source of truth Claude Design project mangaBookmark Web UI
(969ac210-fe02-4c01-ae1b-9a271dcc779a). Read it before touching
backend/internal/web/static/style.css, backend/internal/web/templates/*, or userscript
TEMPLATE/CSS. Core law: ember means new chapter only — no other
state (busy, error, destruction) may use --ember; destruction gets
--danger. No cards/corners/shadows, one --measure: 760px column, tokens
only (never hardcode hex outside :root), both colour branches touched
together. Any move that pulls series out of list (archive/finish/remove)
must be confirm-gated via its own .confirm-row; only restore fires
instantly.
Security invariants
- Auth on
/bookmarks*: requireAuthorization: Bearer <API_TOKEN>, constant-time compare, 401 otherwise. - CORS: reflect
Originonly when inALLOWED_ORIGINS; allowGET,PUT,DELETE,OPTIONS+ headersAuthorization,Content-Type; answer preflightOPTIONSw/204.
Comments
Comment only if code alone can't carry info. Cost per read — must earn spot.
Write for:
- Why not what. Tradeoffs, non-obvious decisions.
- Load-bearing detail looking incidental — say so if "simplify" breaks it.
- Non-local consequence, invisible from function alone.
- Wire format / encoding / interface contract — save callers re-deriving.
- Gotcha/workaround, with ref if exists.
- Domain/business rule not derivable from code.
Skip:
- Restating code (no
// increment iabovei++). - Trivial getter/setter/pass-through.
- Banners, dividers,
// helpers. - Change narration (
// fix bug,// as requested,// new impl) — git's job. - Commented-out code — delete.
- TODO without concrete action.
Style: one dense comment over function beats one per line inside. Tight, no worked example unless bug subtle. Wrong comment worse than none — update/delete on change. Default fewer — sparse+high-signal beats comprehensive.
Test: "competent reader get this from code in few sec?" Yes → skip. Needs detour through another file/spec/git-blame → write it.
Relevant skills
multi-stage-dockerfile and docker-compose-orchestration for container work (referenced in plan).
golang-code-style, golang-error-handling, golang-performance, golang-testing for backend Go work.
graphify
Project has knowledge graph at graphify-out/ w/ god nodes, community structure, cross-file relationships.
Rules:
- For codebase questions, first run
graphify query "<question>"when graphify-out/graph.json exists. Usegraphify path "<A>" "<B>"for relationships,graphify explain "<concept>"for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. - If graphify-out/wiki/index.md exists, use for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain don't surface enough context.
- After modifying code, run
graphify update .to keep graph current (AST-only, no API cost).
OpenCode-specific
- Caveman mode active by default (
/home/tan/.config/opencode/AGENTS.md). Keep comms terse — drop articles, fluff, pleasantries. Code/commits/security written normal. .superpowers/and.agents/dirs hold skill definitions. Gitea atgitea.violetcrown.my.id.