9.1 KiB
Manga Bookmark — Userscript + Self-Hosted Sync Backend
Context
The user reads manga on asurascans.com (now serves from asuracomic.net) and demonicscans.org, on a mobile browser (Bromite). They want to bookmark a series and auto-record the latest chapter they've read, with a UI reachable while those sites are open on the phone. Progress must sync across devices, so it lives in a backend on the user's own server.
Hard constraints (drive the whole design)
Bromite uses Chromium's native userscript engine — not Tampermonkey. Per Bromite's wiki / Chromium docs:
- No
GM_setValue/GM_getValue→ persistence must use pagelocalStorage. - No
GM_registerMenuCommand→ UI must be injected on-page (floating button + panel). GM_xmlhttpRequestis same-origin only → cross-origin calls use plainfetch(), which works only against a CORS-enabled backend.- Userscripts run in an isolated world → the manga site's JS cannot read our embedded API token (safe to embed).
asurascans.comanddemonicscans.orgare separate origins with separatelocalStorage→ the only way to unify bookmarks across both sites is a shared remote store. Cloud sync is therefore required, not a nice-to-have.- The manga sites are
https://, so the backend must be HTTPS (mixed-content block otherwise). User already runs a reverse proxy + domain, so a subdomain (e.g.manga-api.<domain>) fronts the service.
Decisions locked with user
- Backend: self-hosted, Go (resource-friendly), Docker Compose, behind existing reverse proxy (TLS handled there).
- Scope: track read progress only — no "new chapters available" detection (YAGNI for v1).
- Record last-read: auto on chapter open + manual override in the panel.
- UI: floating button + slide-in panel.
Architecture
Bromite (mobile)
userscript (isolated world, per-site adapters)
localStorage cache <--> fetch() over HTTPS
|
reverse proxy (TLS, CORS origin)
|
Go service (net/http) -> SQLite file (volume)
Two deliverables in this repo:
mangaBookmark/
backend/
main.go # server bootstrap, config from env, router
handlers.go # GET/PUT/DELETE /bookmarks, /healthz
store.go # SQLite open + queries (modernc.org/sqlite, CGO_ENABLED=0)
middleware.go # bearer-token auth + CORS/preflight
store_test.go # handler + store tests (httptest + temp sqlite)
go.mod
Dockerfile # multi-stage: golang:alpine build -> scratch/distroless
docker-compose.yml # service + named volume for the sqlite file
userscript/
manga-bookmark.user.js
README.md # deploy steps + Bromite install steps + config
Backend (Go)
Stack: stdlib net/http (no framework needed for 3 routes) + modernc.org/sqlite (pure Go → static binary, scratch image). Rust/axum + rusqlite is a drop-in alternative if preferred later.
Data model — one table, key unique across both sites:
CREATE TABLE IF NOT EXISTS bookmarks (
key TEXT PRIMARY KEY, -- "<site>:<series_id>"
site TEXT NOT NULL, -- "asura" | "demonic"
series_id TEXT NOT NULL,
title TEXT,
series_url TEXT,
cover TEXT,
last_chapter TEXT, -- label as shown, e.g. "Chapter 123"
last_chapter_num REAL, -- parsed for max() comparison
last_chapter_url TEXT,
updated_at INTEGER NOT NULL -- unix ms
);
Endpoints (JSON):
GET /bookmarks→ array of all bookmarks (single-user store).PUT /bookmarks/{key}→ upsert one series (body = bookmark object). Server setsupdated_at.DELETE /bookmarks/{key}→ remove one.GET /healthz→200 ok(no auth).
Middleware:
- Auth: require
Authorization: Bearer <API_TOKEN>(env) on/bookmarks*; 401 otherwise. Constant-time compare. - CORS: reflect
Originwhen it's inALLOWED_ORIGINS(env, comma list:https://asuracomic.net,https://asurascans.com,https://demonicscans.org). Allow methodsGET,PUT,DELETE,OPTIONS, headersAuthorization,Content-Type. Answer preflightOPTIONSwith204.
Config via env: API_TOKEN, ALLOWED_ORIGINS, DB_PATH (default /data/bookmarks.db), PORT (default 8080).
Dockerfile: multi-stage — golang:1.23-alpine build with CGO_ENABLED=0 go build, final stage gcr.io/distroless/static (or scratch) copying the binary; /data volume for the SQLite file. (See multi-stage-dockerfile skill.)
docker-compose.yml: one service, named volume mounted at /data, env vars, restart: unless-stopped. Attach to the existing reverse-proxy network (or expose a local port the proxy targets) — the proxy terminates TLS and routes manga-api.<domain> → service :8080. (See docker-compose-orchestration skill.)
Userscript (manga-bookmark.user.js)
Single Bromite-compatible file. No GM_* calls anywhere (so it also runs in desktop Tampermonkey/Violentmonkey for faster iteration). Wrapped in an IIFE.
Metadata header: @match https://asuracomic.net/*, https://asurascans.com/*, https://demonicscans.org/*; @run-at document-idle; @name, @version.
Config block (top of file, user fills in):
const API_BASE = "https://manga-api.<domain>";
const API_TOKEN = "<paste token>";
Modules inside the IIFE:
-
Site adapters — one per host, each exposing
detect(location, document)→{ type: 'series'|'chapter'|'other', site, seriesId, title, cover, seriesUrl, chapterLabel, chapterNum, chapterUrl }.- Identify page type + IDs from URL regex (most stable); pull
title/coverfromog:title/og:imagemeta tags (present and stable on both sites, avoids brittle CSS classes). - Asura: series
…/series/<slug>-<id>, chapter…/series/<slug>-<id>/chapter/<n>→seriesId = <slug>-<id>,chapterNum = n. - Demonic: series
…/manga/<slug>, chapter reader…/title/<id>/<chapterId>(exact reader path to be confirmed against live DOM). - URL patterns + selectors get verified against live pages during implementation (Cloudflare blocks server-side fetch; confirm via Playwright MCP or on-device devtools before finalizing).
- Identify page type + IDs from URL regex (most stable); pull
-
API client —
apiGet(),apiPut(key, obj),apiDelete(key)viafetchwith the bearer header.localStoragekeymangabm:cacheholds the last-known bookmark list for instant render + offline fallback. -
Progress logic — on a chapter page of a bookmarked series, auto-upsert
last_chapterwhenchapterNum >= stored last_chapter_num(so re-reading old chapters doesn't regress progress; unparseable → set current). Manual override in the panel forces any value. Bookmarking a new series is available from both series and chapter pages. -
Floating UI — rendered inside a Shadow DOM root (isolates from site CSS; important on mobile). Fixed circular button bottom-right (respects safe-area insets, high
z-index); tap toggles a slide-in panel:- Header: context of current page — "+ Bookmark this" if unbookmarked, else current progress + "Update to this chapter".
- List: bookmarks sorted by
updated_atdesc — title, last chapter, Continue link (→last_chapter_urlorseries_url), edit-chapter input, remove. - Toasts for sync success/failure.
-
SPA navigation — Asura is a Next.js client-routed app (no full reload on chapter change). Patch
history.pushState/replaceState+ listen forpopstate, re-rundetect()on URL change so auto-update fires without reload. Demonic (classic reloads) works via the initialdocument-idlerun.
Sync strategy: last-write-wins (single user). On load: apiGet() → render → cache. On mutation: optimistic cache+UI update, then PUT/DELETE; on failure show a toast, keep local, retry on next load.
Verification
Backend
go test ./...— auth (401 without/with bad token), CORS preflight headers + origin reflection, upsert→get→delete round-trip against a temp SQLite file.- Local smoke:
docker compose up, thencurlGET/PUT/DELETEwithAuthorizationheader; confirmOPTIONSpreflight returns the CORS headers. - Deployed: hit
https://manga-api.<domain>/healthz; confirm valid TLS (no mixed-content) and preflight from a real site origin.
Userscript
- Confirm adapter URL regex +
og:extraction on live Asura + Demonic pages (Playwright MCP or on-device devtools) before finalizing selectors. - Desktop dry-run in Tampermonkey/Violentmonkey (same file, no GM APIs): bookmark a series, open a chapter, verify panel updates and
curl GET /bookmarkson the server reflects it. - On Bromite: install per README, repeat the flow on both sites; verify auto-update on chapter open, manual override, Continue link, and cross-site unified list (bookmark on Asura shows when panel opened on Demonic).
Open items to confirm during build: exact Demonic reader URL path + chapter-number source; Asura's live series/chapter path (post-asuracomic.net migration); Bromite's current userscript-install steps (documented in README, verified on-device).