143 lines
9.1 KiB
Markdown
143 lines
9.1 KiB
Markdown
# 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 page `localStorage`.
|
|
- **No `GM_registerMenuCommand`** → UI must be injected on-page (floating button + panel).
|
|
- **`GM_xmlhttpRequest` is same-origin only** → cross-origin calls use plain `fetch()`, 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.com` and `demonicscans.org` are **separate origins** with **separate `localStorage`** → 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:
|
|
```sql
|
|
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 sets `updated_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 `Origin` when it's in `ALLOWED_ORIGINS` (env, comma list: `https://asuracomic.net,https://asurascans.com,https://demonicscans.org`). Allow methods `GET,PUT,DELETE,OPTIONS`, headers `Authorization,Content-Type`. Answer preflight `OPTIONS` with `204`.
|
|
|
|
**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):**
|
|
```js
|
|
const API_BASE = "https://manga-api.<domain>";
|
|
const API_TOKEN = "<paste token>";
|
|
```
|
|
|
|
**Modules inside the IIFE:**
|
|
|
|
1. **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`/`cover` from **`og:title` / `og:image` meta 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).
|
|
|
|
2. **API client** — `apiGet()`, `apiPut(key, obj)`, `apiDelete(key)` via `fetch` with the bearer header. `localStorage` key `mangabm:cache` holds the last-known bookmark list for instant render + offline fallback.
|
|
|
|
3. **Progress logic** — on a chapter page of a **bookmarked** series, auto-upsert `last_chapter` when `chapterNum >= 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.
|
|
|
|
4. **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_at` desc — title, last chapter, **Continue** link (→ `last_chapter_url` or `series_url`), edit-chapter input, remove.
|
|
- Toasts for sync success/failure.
|
|
|
|
5. **SPA navigation** — Asura is a Next.js client-routed app (no full reload on chapter change). Patch `history.pushState`/`replaceState` + listen for `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic (classic reloads) works via the initial `document-idle` run.
|
|
|
|
**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`, then `curl` `GET/PUT/DELETE` with `Authorization` header; confirm `OPTIONS` preflight 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 /bookmarks` on 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).
|