Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d330c2ff4 |
@@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Guidance for OpenCode (and Claude Code) working in this repo.
|
Repo-wide guidance for coding agents.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
@@ -13,15 +13,18 @@ One backend, one `bookmarks` table: a `kind` column (`manga`|`novel`) splits the
|
|||||||
|
|
||||||
## Hard constraints (drive design — don't violate)
|
## Hard constraints (drive design — don't violate)
|
||||||
|
|
||||||
|
Nothing below is derivable from reading the code — it is why the code looks the
|
||||||
|
way it does, plus dated measurements against services we don't control.
|
||||||
|
|
||||||
Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines:
|
Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines:
|
||||||
- **Avoid `GM_*` unless needed.** Prefer page `localStorage` over `GM_setValue`/`GM_getValue`, on-page UI over `GM_registerMenuCommand`, plain `fetch()` over `GM_xmlhttpRequest` for cross-origin.
|
- **Avoid `GM_*` unless needed.** Prefer page `localStorage` over `GM_setValue`/`GM_getValue`, on-page UI over `GM_registerMenuCommand`, plain `fetch()` over `GM_xmlhttpRequest` for cross-origin.
|
||||||
- Cross-origin `fetch()` work **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block).
|
- Cross-origin `fetch()` work **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block).
|
||||||
- Every site is its **own origin with its own `localStorage`** — a shared remote store is the only way to unify bookmarks. Cloud sync required, not optional.
|
- Every site is its **own origin with its own `localStorage`** — a shared remote store is the only way to unify bookmarks. Cloud sync required, not optional.
|
||||||
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
|
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
|
||||||
- Cloudflare's block on manga sites is **per-zone configuration plus request fingerprint, not IP reputation — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s with 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 blocked; wasn't, at least this date. Treat "does curl work right now" as live, time-varying fact to re-check, not fixed property of machine — a Site can turn its protection on overnight, which is exactly what comix.to did on 2026-08-12. An earlier version of this line blamed "Cloudflare's bot scoring"; that was wrong. The 1-99 bot score is Enterprise Bot Management only and does not exist for a free-plan zone, and no per-IP request rate is documented as an input to challenge issuance — `docs/research/cloudflare-bot-scoring-and-poll-cadence.md`. Backend fetcher still needs graceful-degrade path for when challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test.
|
- Cloudflare's block on manga sites is **per-zone configuration plus request fingerprint, not IP reputation — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s with 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 blocked; wasn't, at least this date. Treat "does curl work right now" as live, time-varying fact to re-check, not fixed property of machine — a Site can turn its protection on overnight, which is exactly what comix.to did on 2026-08-12. An earlier version of this line blamed "Cloudflare's bot scoring"; that was wrong. The 1-99 bot score is Enterprise Bot Management only and does not exist for a free-plan zone, and no per-IP request rate is documented as an input to challenge issuance. Backend fetcher still needs graceful-degrade path for when challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test.
|
||||||
- **kagane.to, comix.to and novelfull.com are the exception to the above** — all three sit behind a Cloudflare JavaScript challenge no TLS fingerprint clears, so the backend polls them over CDP (`BROWSER_WS_URL`). When that's unset, kagane and comix are skipped entirely (a plain fetch would only retrieve a challenge page) while novelfull pages are still attempted over plain TLS — its challenge is a live time-varying fact and its cover bytes never need the browser. comix turned hostile on 2026-08-12 (#98): its cover host `static.comix.to` is gated too, so its cover bytes go through the browser as well, and its page is read as an in-tab `fetch()` of the series URL rather than a rendered DOM — comix is an SPA, and rendering costs ~65 requests for the same server-rendered HTML one fetch returns. The three other sites poll fine over plain TLS.
|
- **kagane.to, comix.to and novelfull.com are the exception to the above** — all three sit behind a Cloudflare JavaScript challenge no TLS fingerprint clears, so the backend polls them over CDP (`BROWSER_WS_URL`). When that's unset, kagane and comix are skipped entirely (a plain fetch would only retrieve a challenge page) while novelfull pages are still attempted over plain TLS — its challenge is a live time-varying fact and its cover bytes never need the browser. comix turned hostile on 2026-08-12: its cover host `static.comix.to` is gated too, so its cover bytes go through the browser as well, and its page is read as an in-tab `fetch()` of the series URL rather than a rendered DOM — comix is an SPA, and rendering costs ~65 requests for the same server-rendered HTML one fetch returns. The three other sites poll fine over plain TLS.
|
||||||
- **The CDP browser must look like a real browser, and stock headless images don't.** Measured 2026-08-08 against kagane.to, all from the same IP: `chromedp/headless-shell:stable` never cleared the challenge in 90s (`navigator.webdriver` true, empty plugin list, Chromium-branded client hints — suppressing `webdriver` alone changed nothing); `zenika/alpine-chrome` ships Chrome 124, refused outright; real Chrome with the default `--headless=new` UA never cleared, because the UA says `HeadlessChrome`; real Chrome with a stock UA **and** a non-UTC clock zone cleared in ~4s. Hence `chrome/` — a Debian image with `google-chrome-stable`, a version-derived UA, and `TZ`/`BROWSER_TZ`. Chrome reads the zone *name* through ICU from `/etc/localtime`'s symlink target, ignoring the file's contents, so mounting the host's `/etc/localtime` does **not** work; `/etc/timezone` is mounted instead.
|
- **The CDP browser must look like a real browser, and stock headless images don't.** Measured 2026-08-08 against kagane.to, all from the same IP: `chromedp/headless-shell:stable` never cleared the challenge in 90s (`navigator.webdriver` true, empty plugin list, Chromium-branded client hints — suppressing `webdriver` alone changed nothing); `zenika/alpine-chrome` ships Chrome 124, refused outright; real Chrome with the default `--headless=new` UA never cleared, because the UA says `HeadlessChrome`; real Chrome with a stock UA **and** a non-UTC clock zone cleared in ~4s. Hence `chrome/` — a Debian image with `google-chrome-stable`, a version-derived UA, and `TZ`/`BROWSER_TZ`. Chrome reads the zone *name* through ICU from `/etc/localtime`'s symlink target, ignoring the file's contents, so mounting the host's `/etc/localtime` does **not** work; `/etc/timezone` is mounted instead.
|
||||||
- **The browser is not in the API stack and must not be put back.** It's its own compose unit (`chrome/docker-compose.yml`) on a second machine, reached over the tailnet — it held 471 MiB on a 1974 MiB swapless VPS, and a residential egress avoids the cloud-hosting-IP signature Bot Fight Mode documentedly challenges (ADR-0006; not a better "score" — free-plan zones have no score). Consequences that constrain code: `BROWSER_WS_URL` must be a tailnet **IP** (a MagicDNS name 500s at `/json/version`, same trap as the old Docker service name); the CDP port binds to the tailnet address only, since CDP authenticates nothing and that host has a real LAN; and the browser is on-demand (ADR-0005), so an unreachable or asleep one must degrade exactly as an unset `BROWSER_WS_URL` — plain-TLS libraries unaffected, kagane/comix logged and skipped, stored covers still served. Never add `chromedp.NoModifyURL`: discovery per fetch is what makes a restarted Chrome invisible.
|
- **The browser is not in the API stack and must not be put back.** It's its own compose unit (`chrome/docker-compose.yml`) on a second machine, reached over the tailnet — it held 471 MiB on a 1974 MiB swapless VPS, and a residential egress avoids the cloud-hosting-IP signature Bot Fight Mode documentedly challenges (not a better "score" — free-plan zones have no score). Consequences that constrain code: `BROWSER_WS_URL` must be a tailnet **IP** (a MagicDNS name 500s at `/json/version`, same trap as the old Docker service name); the CDP port binds to the tailnet address only, since CDP authenticates nothing and that host has a real LAN; and the browser is on-demand, so an unreachable or asleep one must degrade exactly as an unset `BROWSER_WS_URL` — plain-TLS libraries unaffected, kagane/comix logged and skipped, stored covers still served. Never add `chromedp.NoModifyURL`: discovery per fetch is what makes a restarted Chrome invisible.
|
||||||
- **UTC is the tell, not a country mismatch.** A UTC clock is the datacenter default, and the challenge refuses it; any real zone clears. Measured 2026-08-08, identical container, one Indonesian egress IP: UTC never cleared in 60s (twice), while `Asia/Jakarta` **and** `America/New_York` both cleared in 4s. An earlier note here claimed the zone had to match the egress IP's country — that was wrong, inferred from the host clock (`Asia/Bangkok`) rather than the measured egress. A second earlier claim, that Cloudflare "scores" a UTC clock, was also wrong: the measurement is real but the mechanism is not documented anywhere — Cloudflare publishes no timezone signal, and free-plan zones carry no score at all. `BROWSER_TZ` therefore needs a plausible zone, not a geolocated one.
|
- **UTC is the tell, not a country mismatch.** A UTC clock is the datacenter default, and the challenge refuses it; any real zone clears. Measured 2026-08-08, identical container, one Indonesian egress IP: UTC never cleared in 60s (twice), while `Asia/Jakarta` **and** `America/New_York` both cleared in 4s. An earlier note here claimed the zone had to match the egress IP's country — that was wrong, inferred from the host clock (`Asia/Bangkok`) rather than the measured egress. A second earlier claim, that Cloudflare "scores" a UTC clock, was also wrong: the measurement is real but the mechanism is not documented anywhere — Cloudflare publishes no timezone signal, and free-plan zones carry no score at all. `BROWSER_TZ` therefore needs a plausible zone, not a geolocated one.
|
||||||
- **A challenged page needs the tab kept open.** The interstitial takes seconds to solve and only then writes clearance into the browser's shared cookie jar. Navigate-read-close never clears anything; `BrowserFetcher.run` holds one tab and re-reads until the payload arrives.
|
- **A challenged page needs the tab kept open.** The interstitial takes seconds to solve and only then writes clearance into the browser's shared cookie jar. Navigate-read-close never clears anything; `BrowserFetcher.run` holds one tab and re-reads until the payload arrives.
|
||||||
|
|
||||||
@@ -36,7 +39,7 @@ Two Violentmonkey userscripts (isolated world, per-site adapters, localStorage c
|
|||||||
on-demand Chrome, separate machine (chrome/)
|
on-demand Chrome, separate machine (chrome/)
|
||||||
```
|
```
|
||||||
|
|
||||||
Two deployable units on two machines: the API stack (`docker-compose.yml` + `docker-compose.prod.yml`, on the VPS) and the browser (`chrome/docker-compose.yml`, on the home machine). They share nothing but `BROWSER_WS_URL` and update independently. Backend-specific architecture (packages, endpoints, poller, config env vars) lives in `backend/AGENTS.md`. Userscript-specific structure (adapters, retry queue, UI, live URL shapes) lives in `userscript/AGENTS.md`. Deploy order `DEPLOY.md` (§7 for the browser), redeploy `REDEPLOY.md` (§8 for the browser).
|
Two deployable units on two machines: the API stack (`docker-compose.yml` + `docker-compose.prod.yml`, on the VPS) and the browser (`chrome/docker-compose.yml`, on the home machine). They share nothing but `BROWSER_WS_URL` and update independently. Backend-specific detail lives in `backend/AGENTS.md`, userscript-specific detail in `userscript/AGENTS.md`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -64,28 +67,29 @@ Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTI
|
|||||||
|
|
||||||
## Forge: Gitea, not GitHub
|
## Forge: Gitea, not GitHub
|
||||||
|
|
||||||
`origin` is self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` don't work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones:
|
`origin` is self-hosted Gitea instance (`gitea.violetcrown.my.id`, repo `sulthan/mangaBookmark`), so **`gh` don't work here — use `tea` (Gitea CLI) for anything past plain git.** `tea` infers the repo from `origin`; auth lives in `tea login`, not a `GH_TOKEN` env var. It prints rendered boxes rather than plain text, so pass `-o json` when parsing; a PR URL lands on the last line.
|
||||||
|
|
||||||
- Open PR: `tea pr create --head <branch> --base main --title "..." --description "..."`
|
- PRs: `tea pr create --head <branch> --base main --title "..." --description "..."`, `tea pr list`, `tea pr <n>`, `tea pr checkout <n>`.
|
||||||
- List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>`
|
- Issues: `tea issue create --title "..." --description "..."` (`--labels`, `--assignees` optional), `tea issue <n> --comments`, `tea issue list --state open|closed|all -o json`, `tea issue close <n>`.
|
||||||
- Issues: `tea issue create`, `tea issue list`
|
- Comments: `tea comment <n> "..."` — `tea issue close` takes no `--comment` flag.
|
||||||
- Auth lives in `tea login`, not `GH_TOKEN` env var.
|
- Labels: `tea issue edit <n> --add-labels "..."` / `--remove-labels "..."`. Gitea will **not** auto-create a label, so `tea labels create --name "..." --color "#rrggbb"` first.
|
||||||
|
- Triage vocabulary is `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`.
|
||||||
`tea` print output as rendered boxes rather than plain text; PR URL lands on last line.
|
- Gitea shares one index space across issues and PRs, so a bare `#42` may be either — try `tea pr 42`, fall back to `tea issue 42`.
|
||||||
|
|
||||||
## Design system
|
## Design system
|
||||||
|
|
||||||
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md`
|
Web UI + userscript panel follow **Cinder**. Tokens are the `:root` block in
|
||||||
— source of truth Claude Design project `BookmarkManager Web UI`
|
`backend/internal/web/static/style.css`; that file, `backend/internal/web/templates/*`,
|
||||||
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
|
and the userscript `TEMPLATE`/`CSS` are the only places it is expressed.
|
||||||
`backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript
|
Source of truth for the visual language is the Claude Design project
|
||||||
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
|
`BookmarkManager Web UI` (`969ac210-fe02-4c01-ae1b-9a271dcc779a`).
|
||||||
state (busy, error, destruction) may use `--ember`; destruction gets
|
|
||||||
`--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens
|
Core law: **ember means new chapter only** — no other state (busy, error,
|
||||||
only (never hardcode hex outside `:root`), both colour branches touched
|
destruction) may use `--ember`; destruction gets `--danger`. No
|
||||||
together. Any move that pulls series out of list (archive/finish/remove)
|
cards/corners/shadows, one `--measure: 760px` column, tokens only (never
|
||||||
must be confirm-gated via its own `.confirm-row`; only restore fires
|
hardcode hex outside `:root`), both colour branches touched together. Any move
|
||||||
instantly.
|
that pulls a series out of the list (archive/finish/remove) must be
|
||||||
|
confirm-gated via its own `.confirm-row`; only restore fires instantly.
|
||||||
|
|
||||||
## Security invariants
|
## Security invariants
|
||||||
|
|
||||||
@@ -127,13 +131,19 @@ Review gate: auth, CORS, session, crypto, and the fetch gate are security-critic
|
|||||||
## Comments
|
## Comments
|
||||||
|
|
||||||
Comment only if code alone can't carry info. Cost per read — must earn spot.
|
Comment only if code alone can't carry info. Cost per read — must earn spot.
|
||||||
|
Wrong comment worse than none: it misleads readers and measurably degrades
|
||||||
|
LLM performance on the file. Missing comment costs little. Bias to fewer.
|
||||||
|
|
||||||
Write for:
|
Docstring on public/exported surface — exception, near-always worth it.
|
||||||
- Why not what. Tradeoffs, non-obvious decisions.
|
Contract only: what it takes, returns, throws, mutates; units; pre/post
|
||||||
- Load-bearing detail looking incidental — say so if "simplify" breaks it.
|
conditions. Not a restatement of the body. Skip on private/obvious.
|
||||||
|
|
||||||
|
Inline — write for:
|
||||||
|
- Why not what. Tradeoffs, non-obvious decisions, rejected alternatives.
|
||||||
|
- heavy detail looking incidental — say so if "simplify" breaks it.
|
||||||
- Non-local consequence, invisible from function alone.
|
- Non-local consequence, invisible from function alone.
|
||||||
- Wire format / encoding / interface contract — save callers re-deriving.
|
- Wire format / encoding / ordering / invariant — save callers re-deriving.
|
||||||
- Gotcha/workaround, with ref if exists.
|
- Gotcha/workaround, with ref (issue, RFC, vendor bug) if exists.
|
||||||
- Domain/business rule not derivable from code.
|
- Domain/business rule not derivable from code.
|
||||||
|
|
||||||
Skip:
|
Skip:
|
||||||
@@ -142,24 +152,49 @@ Skip:
|
|||||||
- Banners, dividers, `// helpers`.
|
- Banners, dividers, `// helpers`.
|
||||||
- Change narration (`// fix bug`, `// as requested`, `// new impl`) — git's job.
|
- Change narration (`// fix bug`, `// as requested`, `// new impl`) — git's job.
|
||||||
- Commented-out code — delete.
|
- Commented-out code — delete.
|
||||||
- TODO without concrete action.
|
- TODO without concrete action + owner.
|
||||||
|
- Narrating the plan you just reasoned through. Plan in prose or in your head;
|
||||||
|
ship the code, not the transcript.
|
||||||
|
- Anything restating a name that could be fixed by renaming instead.
|
||||||
|
|
||||||
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.
|
Staleness filter: if the comment describes something likely to change
|
||||||
|
independently of this line, it will rot and start lying. Either anchor it to
|
||||||
|
something stable, assert it in a test, or leave it out.
|
||||||
|
|
||||||
Test: "competent reader get this from code in few sec?" Yes → skip. Needs detour through another file/spec/git-blame → write it.
|
Style: one dense comment over a function beats one per line inside. Tight; no
|
||||||
|
worked example unless the bug is subtle. On edit, update or delete stale
|
||||||
|
comments in the code you touch — silence beats a lie.
|
||||||
|
|
||||||
## Agent skills
|
Test: "competent reader get this from code in a few sec?" Yes → skip.
|
||||||
|
Needs detour through another file/spec/git-blame/external doc → write it.
|
||||||
|
|
||||||
`AGENTS.md` is the single source of truth for agent guidance; every `CLAUDE.md` in this repo is a symlink to the `AGENTS.md` beside it. Edit `AGENTS.md`.
|
## Writing an AGENTS.md
|
||||||
|
|
||||||
### Issue tracker
|
`AGENTS.md` is the single source of truth for agent guidance; every `CLAUDE.md`
|
||||||
|
in this repo is a symlink to the `AGENTS.md` beside it. Edit `AGENTS.md`.
|
||||||
|
|
||||||
Issues live as Gitea issues on `gitea.violetcrown.my.id` (`sulthan/mangaBookmark`), driven by the `tea` CLI — not `gh`. See `docs/agents/issue-tracker.md`.
|
**Cite code, never docs, issues, or plans.** A spec, ADR, plan file, or Gitea
|
||||||
|
issue records what was true when it was written and then goes stale silently;
|
||||||
|
an agent that follows the pointer reads a decision that may already have been
|
||||||
|
reversed. Code is the only source true at read time — cite a package, file,
|
||||||
|
symbol, env var, or route. The sole non-code exception is a sibling
|
||||||
|
`AGENTS.md`. If a doc holds a fact an agent needs, restate the fact here rather
|
||||||
|
than linking to it.
|
||||||
|
|
||||||
### Triage labels
|
**State a fact in prose only if the code cannot answer it.** Split by
|
||||||
|
derivability:
|
||||||
|
|
||||||
Default five-role vocabulary, label strings unchanged (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.
|
- *Structure* — packages, routes, env vars, columns, struct fields. Rots fast,
|
||||||
|
cheap to re-read. **Name the symbol, write nothing else.**
|
||||||
|
- *Mechanism* — what a function does, how a flow proceeds. **Name the symbol
|
||||||
|
plus at most one line of orientation.**
|
||||||
|
- *Rationale* — why it is this way, what a "simplify" would break, what was
|
||||||
|
tried and rejected. Not in the code and cannot be re-derived. **Write it out.**
|
||||||
|
- *Measurement* — an observation against something we don't control. **Write it
|
||||||
|
out with the date**; a dated fact is honest, an undated one pretends to be
|
||||||
|
permanent.
|
||||||
|
|
||||||
### Domain docs
|
Restating mechanism in prose is how these files rot: the code changes, the
|
||||||
|
paragraph doesn't, and the next agent trusts the paragraph. A pointer degrades
|
||||||
Single-context: one root `CONTEXT.md` plus `docs/adr/`, both created lazily. See `docs/agents/domain.md`.
|
more honestly — and every symbol you name must actually exist, since a dead
|
||||||
|
pointer is a bug, not a stale sentence.
|
||||||
|
|||||||
+264
-270
@@ -1,274 +1,268 @@
|
|||||||
Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system.
|
Scope: `backend/`.
|
||||||
|
|
||||||
- **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + Postgres over `jackc/pgx/v5` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`.
|
Each entry names the code that holds the truth — read that for *what it does*.
|
||||||
Single binary, split into packages under `backend/internal/`: `store`
|
The prose here is only what code cannot tell you: rationale, rejected
|
||||||
(Bookmark type, Postgres persistence, migration runner), `latest` (background
|
alternatives, dated measurements, and invariants a plausible refactor would
|
||||||
poller, site parsers, TLS fetcher), `session` (cookie signing, login
|
silently break.
|
||||||
rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON
|
|
||||||
bookmark handlers), `userscript` (userscript-serving handler), `web`
|
### Layout
|
||||||
(browser UI handler + `templates/` + `static/`, `go:embed`-ed).
|
|
||||||
`backend/main.go` is the composition root — the only place that wires
|
`backend/main.go` → `newRouter` is the composition root, the only place
|
||||||
packages together into `newRouter`. Root-level `*_test.go` hold
|
packages are wired. Packages under `backend/internal/`: `store`, `latest`,
|
||||||
integration tests that exercise the full router; unit tests for a
|
`session`, `httpmw`, `api`, `userscript`, `web`, `token`, `pgtest`. Root-level
|
||||||
package live beside it under `internal/`.
|
`*_test.go` exercise the full router; unit tests live beside their package.
|
||||||
- **Schema is migration-owned.** `internal/store/migrations/*.sql` is
|
|
||||||
`go:embed`-ed and applied on every start by `store.migrate`: one numbered
|
Not visible from any single file: stdlib `net/http` with no framework,
|
||||||
file per change, one transaction each, versions recorded in
|
Postgres over `jackc/pgx/v5`, `CGO_ENABLED=0` static binary into a distroless
|
||||||
`schema_migrations`. Files are **append-only** — editing an applied one
|
image, TLS terminated by the reverse proxy so the service listens plain `:8080`.
|
||||||
changes nothing on a database that already ran it. No column probing, no
|
|
||||||
data-fixup migrations: both were SQLite-era machinery and are gone.
|
### Schema — `internal/store/migrations/*.sql`, run by `store.migrate`
|
||||||
- **Tests need Docker.** `internal/pgtest` starts one `postgres:17-alpine`
|
|
||||||
container per test binary (`TestMain` -> `pgtest.Main`) and hands each test
|
- Migration files are **append-only**. Editing an applied one changes nothing
|
||||||
its own database (`pgtest.URL(t)`). A package whose tests touch the store
|
on a database that already recorded its version in `schema_migrations`, so
|
||||||
must have that `TestMain`.
|
the fix silently applies to new deployments only.
|
||||||
- **Reader-owned store, four tables.** `readers` is keyed by Discord user ID
|
- No column probing, no data-fixup migrations. Both were SQLite-era machinery
|
||||||
and carries the SHA-256 of the Reader's userscript credential plus a
|
and were removed deliberately — don't reintroduce either.
|
||||||
`token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates the **owner** row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Every other row is created by that Reader's own first login (`Store.EnsureReader`, idempotent on `discord_id`, and it never rewrites an existing row's hash). Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI.
|
|
||||||
`series` keyed `(site, series_id)`
|
### Tests need Docker — `internal/pgtest`
|
||||||
(`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the
|
|
||||||
shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`),
|
`pgtest.Main` from `TestMain` starts one `postgres:17-alpine` per test binary;
|
||||||
Latest Chapter, `latest_checked_at`, and the Sighting pair
|
`pgtest.URL` hands each test its own database. A package whose tests touch the
|
||||||
`latest_sighted_at`/`latest_raised_by` (issue #103) — and `bookmarks` holds only what
|
store must have that `TestMain` or it has no database at all.
|
||||||
differs between readers: progress, favourite, lifecycle bucket,
|
|
||||||
`updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no
|
### Reader-owned store — `internal/store`, `internal/token`
|
||||||
surrogate id; the wire `key` is derived as `site:series_id` on read — and
|
|
||||||
every store read/write is scoped to the reader it names. Auth resolves the
|
Four tables; shape is in the migrations, behaviour in `Store`'s methods.
|
||||||
acting Reader from the presented credential (`httpmw.Auth`) and nothing
|
|
||||||
else — there is no unauthenticated-by-Reader route and no global token; the
|
- **Credentials are derived, never stored.** `token.Token(TOKEN_KEY, discord_id, epoch)`
|
||||||
reader id travels in the request context. Sync **last-write-wins**; the wire format
|
is an HMAC; only its SHA-256 reaches `readers.token_sha256`. So install URLs
|
||||||
stays flat (ADR-0004). `Store.Upsert` decomposes one flat body across two
|
can be rebuilt after any restart, and a database leak yields nothing usable.
|
||||||
tables and enforces the ownership rule: client `title`/`series_url`/`cover`
|
- **The owner's epoch-0 hash is refreshed at startup only while the row has
|
||||||
are written only when the series row is new (ADR-0003).
|
never been rotated.** Drop that condition and a restart resurrects a
|
||||||
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
|
rotated-away credential.
|
||||||
- **Web UI:** same binary serve the browser UI on a second
|
- `Store.EnsureReader` never rewrites an existing row's hash — a returning
|
||||||
hostname — `GET /` (list, or login page when no session),
|
Reader's login must not invalidate their installed scripts.
|
||||||
`GET /auth/discord` + `GET /auth/discord/callback` (Discord OAuth,
|
- **Every read and write is scoped to the acting Reader**, resolved from the
|
||||||
ADR-0002), `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
presented credential by `httpmw.Auth` and carried in the request context.
|
||||||
under `/ui/*`. Templates + assets `go:embed`-ed under
|
There is no unauthenticated-by-Reader route and no global token.
|
||||||
`backend/internal/web/`, so `backend/Dockerfile` must copy the whole
|
- **`series` holds what readers share, `bookmarks` only what differs.** A
|
||||||
`internal/` tree, not just `*.go`. Sessions are rows in the `sessions`
|
bookmark key is `(reader_id, site, series_id)` with no surrogate id; the wire
|
||||||
table: the cookie carries only an opaque id, looked up (and expiry-
|
`key` is derived as `site:series_id` on read.
|
||||||
checked) on every request, and deleting the row revokes the session.
|
- `Store.Upsert` splits one flat body across both tables and enforces the
|
||||||
Guild membership *is* registration (issue #27): `discordCallback` gates on
|
ownership rule: client `title`/`series_url`/`cover` are written **only when
|
||||||
membership (and `DISCORD_REQUIRED_ROLE` when set) and then calls
|
the series row is new**, so one reader cannot retitle a shared series.
|
||||||
`Store.EnsureReader`, so a refusal creates nothing and a returning Reader
|
- Sync is last-write-wins and the wire format stays flat — clients depend on
|
||||||
reuses their row. The owner is the only Reader with administrative reach:
|
both; neither is an implementation detail to tidy up.
|
||||||
`POST /readers/{id}/revoke` (404 for anyone else) drops that Reader's
|
|
||||||
sessions, and the `readers` panel renders only on the owner's page.
|
### Web UI — `internal/web`
|
||||||
A Reader with no bookmarks at all sees `listView.Fresh`, whose empty state
|
|
||||||
offers both install links instead of describing a filter.
|
Routes, templates and assets are all in that package; `AdminPatterns()` and
|
||||||
UI mutations read-modify-write
|
`adminRoutes()` enumerate the privileged ones.
|
||||||
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
|
|
||||||
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
|
- **`backend/Dockerfile` must copy the whole `internal/` tree**, not just
|
||||||
**Design-tool caveat:** templates link `/static/style.css` root-absolutely
|
`*.go`: templates and static assets are `go:embed`-ed from
|
||||||
(correct — served from `/`), but impeccable detector resolves
|
`internal/web/`.
|
||||||
stylesheet href with `path.resolve(fileDir, href)`, drops directory
|
- **Guild membership *is* registration.** `discordCallback` gates on membership
|
||||||
on leading `/` and silently skip file. Relative href don't help
|
(plus `DISCORD_REQUIRED_ROLE` when set) and only then calls
|
||||||
either: template's directory isn't its served path. So
|
`Store.EnsureReader`, so a refusal creates nothing.
|
||||||
`detect.mjs backend/internal/web/templates` reports **false clean** —
|
- Sessions are rows, not signatures: the cookie carries an opaque id and
|
||||||
always pass `backend/internal/web/static` too. One finding there,
|
expiry is checked on lookup, which is what makes deleting the row an instant
|
||||||
`overused-font` on "Instrument Serif", deliberate identity choice, not debt.
|
revocation.
|
||||||
- **Every action that moves series out of list is confirm-gated.**
|
- UI mutations go through `Store.Get` + `Store.Upsert` so the `updated_at` rule
|
||||||
Archive, finish, remove each open own `.confirm-row` disclosure
|
below stays in exactly one place.
|
||||||
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
|
- `listView.Fresh` exists because a Reader with no bookmarks at all needs
|
||||||
`archive|finish|remove`); restore fire instantly since it's the reversal.
|
install links, not an empty-filter message.
|
||||||
Remove's row wear ember wash, two reversible ones wear `.calm` grey.
|
- **Design-tool caveat:** `detect.mjs backend/internal/web/templates` reports a
|
||||||
`--ember` stay reserved for new-chapter signal: busy bar and inline
|
**false clean**. Templates link `/static/style.css` root-absolutely (correct —
|
||||||
error use `--mute`.
|
it is served from `/`), but the detector resolves hrefs with
|
||||||
- **Latest-chapter poller:** one goroutine per Site (a Poll Lane, issue #100),
|
`path.resolve(fileDir, href)`, which drops the directory on a leading `/` and
|
||||||
each re-checking that Site's bookmarked series' newest published chapter from
|
skips the file silently; a relative href doesn't help either, since a
|
||||||
backend's own network access, so `latest_chapter` stays fresh when the user
|
template's directory isn't its served path. Always pass
|
||||||
isn't browsing. Second, parallel signal — the userscript keeps its own
|
`backend/internal/web/static` too. The one finding there, `overused-font` on
|
||||||
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` schedule, and its
|
"Instrument Serif", is a deliberate identity choice, not debt.
|
||||||
`reportLatestChapter` PUTs every read, unchanged numbers included, because an
|
|
||||||
unchanged read is exactly the Sighting worth deferring a Poll on (#103).
|
### Confirm gating — `internal/web/static/filter.js`, `toggleConfirmRow(key, kind)`
|
||||||
Two independent clocks: per-series rest (`series.latest_checked_at`,
|
|
||||||
enforced by `Store.DueForLatestCheck`'s WHERE clause — `now - Rest`) and
|
Every action that pulls a series out of the list (`archive|finish|remove`) opens
|
||||||
per-Lane gap (the Lane sleeping between fetches, `effectiveGap`). Both live
|
its own `.confirm-row`; restore fires instantly because it is the reversal.
|
||||||
in the Site registry (`internal/latest/sites.go`), not config: the five env
|
Remove wears the ember wash, the two reversible ones wear `.calm` grey.
|
||||||
knobs that used to size a shared pace are gone.
|
**`--ember` is reserved for the new-chapter signal** — the busy bar and inline
|
||||||
The poller walks **Series, not Bookmarks** — a series referenced by several
|
errors must use `--mute`, or the one colour that means "something to read"
|
||||||
bookmarks is fetched once per cycle, and the due queue orders
|
stops meaning it.
|
||||||
`reader_count DESC, latest_checked_at ASC` (ADR-0003). Series row stamped
|
|
||||||
*before* fetch so broken series wait out the rest instead of retrying
|
### Latest-chapter poller — `internal/latest`, Site registry in `sites.go`
|
||||||
every tick; found chapter written straight to the series row via
|
|
||||||
`Store.SetLatestChapter`, so a bookmark's `updated_at` — and the list
|
One goroutine per Site (a Poll Lane) re-checks that Site's bookmarked series
|
||||||
order — is never touched.
|
from the backend's own network position, so `latest_chapter` stays fresh while
|
||||||
**Sightings** (issue #103, ADR-0011) let a Reader's own page read defer a
|
nobody is browsing. The userscript's `reportLatestChapter` is a second,
|
||||||
Poll: `Store.RecordSighting` — called by the PUT handler *before* the Upsert,
|
parallel signal — it PUTs every read, unchanged numbers included, because an
|
||||||
because the raise test needs the row as it stands — stamps
|
unchanged read is exactly the Sighting worth deferring a Poll on.
|
||||||
`series.latest_sighted_at` and, when the report raises the stored number,
|
|
||||||
names its Reader in `series.latest_raised_by`. The due query's HAVING clause
|
- **Pace lives in the Site registry, not config.** Two clocks: per-series rest
|
||||||
is where deferral lives: a Series is skipped only while it has exactly one
|
(`series.latest_checked_at`, enforced in `Store.DueForLatestCheck`'s WHERE)
|
||||||
Bookmark, was sighted within one Rest, and is under the ceiling
|
and per-Lane gap (`effectiveGap`). The five env knobs that used to size one
|
||||||
(`sightingCeilingRests`, six of that Site's rests) since its last Poll. So a
|
shared pace are gone; don't add them back.
|
||||||
shared Series is never deferred, and no Series goes six hours unpolled
|
- **The poller walks Series, not Bookmarks** — a series several readers hold is
|
||||||
whatever arrives. `checkOne` judges the named Reader off the comparison it
|
fetched once per cycle, and the due queue orders `reader_count DESC,
|
||||||
already makes: a lower number is a contradiction (logged with the Reader and
|
latest_checked_at ASC` so the widely-read ones win contention.
|
||||||
both numbers), the same number an agreement, a higher number the Site
|
- **The series row is stamped *before* the fetch**, so a permanently broken
|
||||||
publishing and neither — that last one clears the attribution instead, since
|
series waits out its rest instead of being retried every tick.
|
||||||
the value the Poll then stores is its own and a later retraction is not the
|
- `Store.SetLatestChapter` is a single-column UPDATE, deliberately not a
|
||||||
Reader's fault. Three contradictions
|
read-modify-write of the bookmark: it therefore cannot revert read progress
|
||||||
(`store.SightingDisagreementLimit`) stop that Reader deferring — their
|
or move `updated_at`. The old stale-re-read race died with the Get+Upsert
|
||||||
reports still write the Latest Chapter — and twenty consecutive agreements
|
flow — don't restore one here.
|
||||||
(`store.SightingAgreementsToClear`) forgive them, as does the owner's
|
|
||||||
clear-marks control. Deferral is recomputed from live facts every round, so
|
**Sightings** (`Store.RecordSighting`, the due query's HAVING clause,
|
||||||
nothing needs invalidating when a Series gains a second Bookmark; the one
|
`latest.checkOne`) let a Reader's own page read defer a Poll.
|
||||||
input read earlier is the Reader's marks, checked when the Sighting is
|
|
||||||
recorded, so crossing the threshold or being cleared takes effect from that
|
- Recorded by the PUT handler **before** the Upsert, because the raise test
|
||||||
Reader's next Sighting and the standing already bought lasts out its rest.
|
needs the row as it stands.
|
||||||
Refusals and browser loss are Lane-local: two `errChallengeHeld` in one pass
|
- A Series is deferred only while it has exactly one Bookmark, was sighted
|
||||||
stop that Site for `refuseBackoff` (15m) while other Lanes continue; an
|
within one Rest, and is under `sightingCeilingRests` since its last Poll — so
|
||||||
`errBrowserInterrupted` (remote Chrome restart) sets a shared Poller flag
|
a shared Series is never deferred and nothing goes six hours unpolled
|
||||||
that makes the other browser Lanes skip their passes for the same 15m, so a
|
whatever arrives.
|
||||||
restarting Chrome doesn't stamp one Series per Lane per pass — after the
|
- A *higher* report clears the attribution rather than crediting it: the value
|
||||||
window the flag decays and they probe again. Browser Lanes wake Chrome only
|
the Poll then stores is its own, so a later retraction isn't the Reader's
|
||||||
when 5+ Series are due or one has waited 15m (ADR-0005 on-demand browser),
|
fault.
|
||||||
and cover work (both healing a stored source URL and filling a blank from
|
- `store.SightingDisagreementLimit` contradictions stop a Reader deferring —
|
||||||
the series page) runs in the background so a slow CDN can't consume a
|
their reports still write the Latest Chapter — and
|
||||||
Lane's gap.
|
`store.SightingAgreementsToClear` agreements forgive them, as does the
|
||||||
A refusal is only ever the challenge *page*: `isInterstitial` matches the
|
owner's clear-marks control.
|
||||||
orchestration path `/cdn-cgi/challenge-platform/h/`, never the bare prefix.
|
- Deferral is recomputed from live facts each round, so nothing needs
|
||||||
Cloudflare injects `/cdn-cgi/challenge-platform/scripts/jsd/main.js` into
|
invalidating when a Series gains a second Bookmark. The one input read
|
||||||
ordinary 200 pages once a zone turns JS detections on, which demonic did on
|
earlier is the Reader's marks, so crossing or clearing a threshold takes
|
||||||
2026-08-16 — the prefix match then read every real demonic page as a refusal
|
effect from their next Sighting and the standing already bought lasts out its
|
||||||
and parked that Lane in 15m backoff while plain TLS was returning the full
|
rest.
|
||||||
series page.
|
|
||||||
Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth
|
**Refusals and browser loss are Lane-local.** Two `errChallengeHeld` in a pass
|
||||||
against fingerprint-based blocking; any failure log and skip. kagane, comix
|
stop that Site for `refuseBackoff` while other Lanes continue. An
|
||||||
and novelfull sit behind Cloudflare JavaScript challenges the TLS client
|
`errBrowserInterrupted` (remote Chrome restarted) sets a shared Poller flag so
|
||||||
can't clear, so they are fetched over CDP via `BROWSER_WS_URL`; kagane and
|
the *other* browser Lanes skip their passes for the same window — otherwise a
|
||||||
comix are simply not polled when that's unset, while novelfull falls back to
|
restarting Chrome stamps one Series per Lane per pass, burning rests on
|
||||||
a plain-TLS attempt — its challenge is a live time-varying fact, and its
|
failures. The flag decays and they probe again.
|
||||||
cover bytes never need the browser. comix's browser read is an in-tab
|
|
||||||
`fetch()` of the Series URL, not a DOM render: it is an SPA, so rendering
|
- **`isInterstitial` matches the orchestration path
|
||||||
costs ~65 requests for the same server-rendered HTML one fetch returns
|
`/cdn-cgi/challenge-platform/h/`, never the bare prefix.** Cloudflare injects
|
||||||
(measured 2026-08-12, issue #98). See
|
`/cdn-cgi/challenge-platform/scripts/jsd/main.js` into ordinary 200 pages
|
||||||
`docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`.
|
once a zone turns JS detections on, which demonic did on 2026-08-16: the
|
||||||
The poller's series write is a single-column UPDATE
|
prefix match read every real demonic page as a refusal and parked the Lane in
|
||||||
(`Store.SetLatestChapter`), not a read-modify-write of the whole bookmark:
|
backoff while plain TLS was returning full series pages.
|
||||||
it cannot revert read progress or move `updated_at`, so the old
|
- Fetches use `bogdanfinn/tls-client` with a Chrome profile as defence in depth
|
||||||
stale-re-read race is gone with the Get+Upsert flow.
|
against fingerprint blocking; any failure logs and skips.
|
||||||
- **Covers are acquired at creation, then served from our own origin
|
- kagane, comix and novelfull sit behind Cloudflare JS challenges the TLS
|
||||||
(ADR-0007):** the first Bookmark of a Series fires `Store.OnSeriesCreated`,
|
client can't clear, so they go over CDP (`BROWSER_WS_URL`). kagane and comix
|
||||||
which `latest.Acquirer` turns into one series-page fetch yielding both the
|
are simply not polled when it's unset — a plain fetch would only retrieve a
|
||||||
Latest Chapter and the cover URL; the bytes then go through
|
challenge page — while novelfull still attempts plain TLS, because its
|
||||||
`latest.CoverBytesFetcher` into `Store.SetSeriesCover`. It runs in a
|
challenge is a live time-varying fact and its cover bytes never need a browser.
|
||||||
goroutine — the Reader's PUT must neither block on a Site nor fail with one
|
- **comix's browser read is an in-tab `fetch()` of the Series URL, not a DOM
|
||||||
— and every failure is logged and dropped, leaving the Bookmark intact. The
|
render.** It is an SPA: rendering cost ~65 requests for the same
|
||||||
wire's `cover` is the absolute `PUBLIC_BASE_URL + /covers/{sha256}` once
|
server-rendered HTML one fetch returns (measured 2026-08-12).
|
||||||
bytes exist and `""` before, never an address that 404s. `GET /covers/{addr}`
|
- Browser Lanes wake Chrome only when 5+ Series are due or one has waited 15m,
|
||||||
is public and uncredentialed: the userscript renders it on a Site's origin,
|
and cover work runs in the background so a slow CDN can't eat a Lane's gap.
|
||||||
where no cookie or token of ours travels. A client-sent `cover` is decoded
|
|
||||||
and discarded, permanently (ADR-0004 compatibility).
|
### Covers — `Store.OnSeriesCreated`, `latest.Acquirer`, `latest.CoverBytesFetcher`, `Store.SetSeriesCover`
|
||||||
Browser-backed Sites join the same pipeline (issue #62, extended to comix by
|
|
||||||
#98): kagane and comix pages *and* cover bytes go through the browser sidecar
|
Acquired once when the first Bookmark of a Series is created, then served from
|
||||||
(nothing falls back to a plain fetch, which would only retrieve a challenge
|
our own origin by the public `GET /covers/{addr}`.
|
||||||
page), while novelfull needs the browser only for its HTML — the cover URL
|
|
||||||
comes out of the browser-fetched page and the bytes go over plain TLS. With
|
- Acquisition runs in a goroutine: the Reader's PUT must neither block on a
|
||||||
no browser configured, kagane and comix Covers are simply absent; novelfull
|
Site nor fail with one. Every failure is logged and dropped, leaving the
|
||||||
still gets one — at creation and on the poll — when its page body happens to
|
Bookmark intact.
|
||||||
answer a plain request (the challenge is a live time-varying fact). comix
|
- The wire `cover` is the absolute `PUBLIC_BASE_URL + /covers/{sha256}` once
|
||||||
cover bytes must arrive by direct navigation, not an in-page fetch: its
|
bytes exist and `""` before — **never an address that 404s**. Absolute
|
||||||
Series page sets `cross-origin-embedder-policy: require-corp`, which fails a
|
because the userscript renders it on a Site's origin.
|
||||||
page-context fetch of `static.comix.to`. The old kagane-only
|
- `GET /covers/{addr}` is public and uncredentialed by design: no cookie or
|
||||||
serving path (`/img/kagane/{id}`, template rewrite, `CoverFetcher`) is gone
|
token of ours may travel to a Site's origin.
|
||||||
(issue #63): the one public route serves every Site.
|
- A client-sent `cover` is decoded and discarded, permanently — wire
|
||||||
- **`updated_at` drives list order, so moves only on real reading progress:** server apply its timestamp when row new or `last_chapter_num` changes, else keep stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**, clients must adopt that response rather than own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
|
compatibility, not an oversight.
|
||||||
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
|
- **One route serves all six Sites.** No proxy, no per-Site rewrite, no second
|
||||||
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
place that decides a renderable address: the wire `cover` is it. Templates
|
||||||
own tab — not in All, Updated, Favourites, or recent strip. Poller keeps
|
render `.Cover` and nothing else. The old kagane-only serving path
|
||||||
checking archived series and skip finished ones. `finished` settable
|
(`/img/kagane/{id}` plus a template rewrite) is gone; don't reintroduce a
|
||||||
only from web UI; `PUT /bookmarks/{key}` reject it with 400.
|
per-Site route because one Site's CDN misbehaves.
|
||||||
**Empty incoming status means "keep stored one"** — resolved on the
|
- The only Site names left in cover code are in `browserOnlyCoverURL`
|
||||||
`VALUES` side of `Store.Upsert`, not conflict clause, since
|
(`internal/latest`): kagane answers a plain fetch with a challenge *and*
|
||||||
`excluded.*` is post-evaluation row and default applied there would
|
|
||||||
wipe bucket on every PUT from client that predates column. See
|
|
||||||
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
|
||||||
- **Config via env:** `TOKEN_KEY` (derives every Reader's userscript credential;
|
|
||||||
required), `OWNER_DISCORD_ID` (seeds the owner Reader — the administrator and
|
|
||||||
the owner of every pre-registration bookmark; required),
|
|
||||||
`ALLOWED_ORIGINS` (comma list),
|
|
||||||
`DATABASE_URL` (Postgres connection URL, required — no default),
|
|
||||||
`COVER_DIR` (required filesystem volume for content-addressed Cover bytes),
|
|
||||||
`PUBLIC_BASE_URL` (required origin this deployment answers on, trailing
|
|
||||||
slash trimmed; every Cover URL on the wire is built from it, absolute
|
|
||||||
because the userscript renders on a Site's origin — ADR-0007),
|
|
||||||
`PORT` (default `8080`), `DISCORD_CLIENT_ID`/`_CLIENT_SECRET`/`_GUILD_ID`/
|
|
||||||
`_REDIRECT_URI` (required; Discord OAuth for the browser UI),
|
|
||||||
`DISCORD_REQUIRED_ROLE` (optional role gate, empty by default),
|
|
||||||
`DISCORD_API_BASE` (default `https://discord.com/api/v10`),
|
|
||||||
`LATEST_CHAPTER_POLL_ENABLED` (background latest-chapter poller kill
|
|
||||||
switch, default on). Pace is per Site in the registry (issue #100): every
|
|
||||||
Site rests an hour and gaps ten seconds, a Site with more eligible Series
|
|
||||||
than 360 tightens its own gap toward the 1s floor, and browser Lanes wake
|
|
||||||
Chrome only on demand (ADR-0005). The `_COOLDOWN`/`_BROWSER_COOLDOWN`/
|
|
||||||
`_INTERVAL`/`_BATCH`/`_STAGGER` knobs that used to size a shared pace are
|
|
||||||
gone. The 1h rest for browser Sites is safe on documented grounds: a
|
|
||||||
challenged page costs seconds of a serialized single-tab browser, free-plan
|
|
||||||
zones have no bot score and no published per-IP rate input, and
|
|
||||||
`cf_clearance` expires in 30 minutes so every cadence at or above 1h
|
|
||||||
re-solves anyway —
|
|
||||||
`docs/research/cloudflare-bot-scoring-and-poll-cadence.md`.
|
|
||||||
`USERSCRIPT_PATH` and `NOVEL_USERSCRIPT_PATH` (files served at
|
|
||||||
`/u/{token}/manga-bookmark.user.js` and `/u/{token}/novel-bookmark.user.js`,
|
|
||||||
defaults `/userscript/manga-bookmark.user.js` and
|
|
||||||
`/userscript/novel-bookmark.user.js`, both supplied by bindmount; the
|
|
||||||
`__API_TOKEN__` placeholder inside them is substituted with the requesting
|
|
||||||
Reader's credential at serve time).
|
|
||||||
`BROWSER_WS_URL` (CDP endpoint of the browser, which runs on a **separate
|
|
||||||
machine** and is reached over the tailnet — ADR-0006, `chrome/docker-compose.yml`.
|
|
||||||
Used by the poller for kagane, comix and novelfull page fetches and by the
|
|
||||||
cover pipeline for kagane's and comix's image bytes (the browser is the only
|
|
||||||
route that clears the challenge those two serve their covers behind); unset —
|
|
||||||
the default — disables browser polling and leaves kagane and comix Covers
|
|
||||||
blank until stored bytes
|
|
||||||
exist. Must be a tailnet IP, never a hostname: Chrome's DevTools handler 500s
|
|
||||||
`/json/version` for any Host that isn't an IP or `localhost`).
|
|
||||||
- **No per-Site cover path (issue #63):** every Cover — all six Sites — is
|
|
||||||
served by the one public `GET /covers/{addr}` route from content-addressed
|
|
||||||
bytes. There is no proxy, no per-Site rewrite, no second place that decides
|
|
||||||
a Cover's renderable address: the wire `cover` is it. The only place a Site
|
|
||||||
name still appears in cover code is the extraction module (`latest`), where
|
|
||||||
kagane's and comix's image URLs are claimed by `browserOnlyCoverURL` — kagane
|
|
||||||
answers a plain fetch with a challenge and
|
|
||||||
`cross-origin-resource-policy: same-origin`, and `static.comix.to` answers
|
`cross-origin-resource-policy: same-origin`, and `static.comix.to` answers
|
||||||
one with the same Cloudflare challenge its pages serve;
|
with the same challenge its pages serve. Every other Site's CDN answers plain
|
||||||
every other Site's CDN answers plain TLS. Templates render `.Cover` — the
|
TLS.
|
||||||
wire value — never anything else.
|
- **comix cover bytes must arrive by direct navigation, not an in-page fetch:**
|
||||||
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
|
its Series page sets `cross-origin-embedder-policy: require-corp`, which
|
||||||
(renders the bindmounted script with the acting Reader's derived credential
|
fails a page-context fetch of `static.comix.to`.
|
||||||
substituted in — the credential never appears in page markup, the address
|
- With no browser configured, kagane and comix Covers are simply absent;
|
||||||
bar, or a redirect; `?download=1` adds `Content-Disposition: attachment` for
|
novelfull still gets one whenever its page answers a plain request.
|
||||||
mobile Violentmonkey, which ignores a `.user.js` navigation) and
|
|
||||||
`POST /rotate-token` (atomic epoch bump + hash
|
### `updated_at` drives list order — `Store.Upsert`
|
||||||
rewrite; invalidates every installed copy, so the panel warns to reinstall
|
|
||||||
on all devices).
|
The server applies its own timestamp only when the row is new or
|
||||||
- **Owner-only admin page (`internal/web/admin.go`, issue #102):** `GET /admin`
|
`last_chapter_num` changes, else it keeps the stored value. **Favouriting a
|
||||||
carries the Reader roster (sessions, Sighting counters, `POST
|
series, or a newly published chapter arriving, must not reorder the list** —
|
||||||
/readers/{id}/revoke` and `POST /readers/{id}/clear-marks`) and Poll Lane
|
only real reading progress moves a row. Consequently `PUT` returns the row **as
|
||||||
status (`GET /ui/admin/lanes`, self-refreshing every 30s). Every route that
|
stored** and clients must adopt that response rather than their own payload.
|
||||||
reaches past the acting Reader is listed in `adminRoutes()` and wrapped in
|
|
||||||
`requireOwner` at registration — add a route there, not a check inside a
|
### Lifecycle buckets — `status` on each bookmark
|
||||||
handler; `web.AdminPatterns()` is what the gate test walks. A non-owner gets
|
|
||||||
404, never 403. Lane figures come from the running poller through the
|
`reading` | `archived` | `finished`, orthogonal to `favorite`. Archived and
|
||||||
`web.LaneReporter` seam (`latest.Poller.LaneStatus`), never from a table: a
|
finished appear only in their own tab, never in All, Updated, Favourites or the
|
||||||
nil reporter or a Lane that has not finished a pass renders "no data yet"
|
recent strip. The poller keeps checking archived series and skips finished ones.
|
||||||
rather than zeroes. `main.newRouter` takes the reporter as an interface and
|
|
||||||
converts a nil `*Poller` to a nil interface — a typed nil would make the page
|
- `finished` is settable only from the web UI; `PUT /bookmarks/{key}` rejects
|
||||||
claim a poller exists.
|
it with 400.
|
||||||
The one owner comparison left outside `requireOwner` is in `index`
|
- **An empty incoming status means "keep the stored one"**, and it is resolved
|
||||||
|
on the `VALUES` side of `Store.Upsert`, not in the conflict clause:
|
||||||
|
`excluded.*` is the post-evaluation row, so a default applied there would
|
||||||
|
wipe the bucket on every PUT from a client predating the column.
|
||||||
|
|
||||||
|
### Config — `Config` / `loadConfig` / `loadLatestPoll` in `backend/main.go`
|
||||||
|
|
||||||
|
That function is the complete list of env vars, their defaults, and which are
|
||||||
|
required. What it can't tell you:
|
||||||
|
|
||||||
|
- `PUBLIC_BASE_URL` must be an absolute origin because every Cover URL on the
|
||||||
|
wire is built from it and the userscript renders on a Site's origin.
|
||||||
|
- `BROWSER_WS_URL` **must be a tailnet IP, never a hostname** — Chrome's
|
||||||
|
DevTools handler 500s `/json/version` for any Host that isn't an IP or
|
||||||
|
`localhost`. Unset (the default) disables browser polling.
|
||||||
|
- `USERSCRIPT_PATH` / `NOVEL_USERSCRIPT_PATH` are bindmounted files; the
|
||||||
|
`__API_TOKEN__` placeholder inside them is substituted with the requesting
|
||||||
|
Reader's credential at serve time.
|
||||||
|
- Pace is per Site in the registry, not env. The
|
||||||
|
`_COOLDOWN`/`_BROWSER_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` knobs are
|
||||||
|
gone on purpose.
|
||||||
|
- The 1h rest for browser Sites is safe on documented grounds: a challenged
|
||||||
|
page costs seconds of a serialized single-tab browser, free-plan zones carry
|
||||||
|
no bot score and no published per-IP rate input, and `cf_clearance` expires
|
||||||
|
in 30 minutes, so every cadence at or above 1h re-solves anyway.
|
||||||
|
|
||||||
|
### Userscript install & rotation — `internal/userscript`, `internal/token`
|
||||||
|
|
||||||
|
Session-gated `GET /install/{manga,novel}-bookmark.user.js` renders the
|
||||||
|
bindmounted script with the acting Reader's derived credential substituted in,
|
||||||
|
so the credential never appears in page markup, the address bar, or a redirect.
|
||||||
|
`?download=1` adds `Content-Disposition: attachment` for mobile Violentmonkey,
|
||||||
|
which ignores a `.user.js` navigation. `POST /rotate-token` is an atomic epoch
|
||||||
|
bump plus hash rewrite and invalidates every installed copy — the panel must
|
||||||
|
keep warning to reinstall on all devices.
|
||||||
|
|
||||||
|
### Owner-only admin — `internal/web/admin.go`
|
||||||
|
|
||||||
|
- **Every route reaching past the acting Reader is listed in `adminRoutes()`
|
||||||
|
and wrapped in `requireOwner` at registration** — add it there, not as a
|
||||||
|
check inside a handler; `web.AdminPatterns()` is what the gate test walks. A
|
||||||
|
non-owner gets 404, never 403.
|
||||||
|
- The one owner comparison left outside the gate is in `index`
|
||||||
(`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an
|
(`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an
|
||||||
endpoint, so it is a rendering decision a registration-time wrapper cannot
|
endpoint, so it is a rendering decision a registration-time wrapper cannot
|
||||||
express — do not "unify" it into the gate.
|
express. Do not "unify" it into the gate.
|
||||||
A Lane pass that returns before computing its figures (refusal backoff,
|
- Lane figures come through the `web.LaneReporter` seam
|
||||||
sidecar down) carries the previous pass's due count and gap forward rather
|
(`latest.Poller.LaneStatus`), never a table. `main.newRouter` takes the
|
||||||
than recording zeroes; a Lane that has never reached a pace renders no gap at
|
reporter as an interface and converts a nil `*Poller` to a nil interface — a
|
||||||
all. `Checked` next to `Due` is what separates a stopped Lane from a quiet
|
typed nil would make the page claim a poller exists.
|
||||||
one, so neither figure may be dropped from the row.
|
- A pass that returns before computing figures (refusal backoff, sidecar down)
|
||||||
Due-without-Checked is *not* by itself a stall: a browser Lane under both
|
carries the previous pass's numbers forward rather than recording zeroes.
|
||||||
wake thresholds sets `LaneState.Asleep` at the on-demand gate and renders
|
- **`Checked` next to `Due` is what separates a stopped Lane from a quiet one**,
|
||||||
"browser asleep" instead of "not checking", and never counts toward
|
so neither may be dropped from the row.
|
||||||
`Attention`. That is the commonest healthy state for kagane, comix and
|
- Due-without-Checked is **not** by itself a stall: a browser Lane under both
|
||||||
novelfull — one due Series, nothing checked — so spending the stall mark on
|
wake thresholds sets `LaneState.Asleep` and renders "browser asleep", and
|
||||||
it would train the owner to ignore the mark that matters.
|
never counts toward `Attention`. That is the commonest healthy state for
|
||||||
|
kagane, comix and novelfull, so spending the stall mark on it would train the
|
||||||
|
owner to ignore the mark that matters.
|
||||||
|
|||||||
+94
-84
@@ -1,93 +1,103 @@
|
|||||||
Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system.
|
Scope: `userscript/`.
|
||||||
|
|
||||||
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
Each entry names the code that holds the truth. The prose is only what the code
|
||||||
|
cannot tell you: rationale, invariants a refactor would break, and dated
|
||||||
|
observations about sites we don't control.
|
||||||
|
|
||||||
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.
|
### Structure — single IIFE, `manga-bookmark.user.js`
|
||||||
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
|
|
||||||
failed mutation park in `localStorage` (`bmgr:manga:queue`) and replayed on
|
|
||||||
next navigation, reconnect, or `refresh()`. 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. `sendStatus` is **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. See
|
|
||||||
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
|
||||||
5. **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_BASE` sits in CONFIG
|
|
||||||
block next to `API_BASE`. FAB is `7 × 44` edge tab whose *hit* area
|
|
||||||
widened to `28 × 72` by invisible `#hit` child; `#fab` must keep
|
|
||||||
`touch-action: none` and must **not** regain `overflow: hidden`. Since
|
|
||||||
`touch-action` resolved at gesture start, strip can't be both
|
|
||||||
browser-scrolled and script-dragged, so `makeDraggable` splits by intent: swipe
|
|
||||||
from `#hit` scrolls via `window.scrollBy`, hold of `ARM_MS` arms
|
|
||||||
reposition drag, visible sliver drags with no hold. See
|
|
||||||
`docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`.
|
|
||||||
6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fire without reload. Demonic uses classic reloads (initial `document-idle` run suffice).
|
|
||||||
|
|
||||||
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust)
|
Six parts, in file order: site adapters, API client, progress logic, retry
|
||||||
|
queue, UI, SPA navigation.
|
||||||
|
|
||||||
- **asurascans.com**: series `/comics/<slug>` (slug carries trailing
|
**Site adapters** — one per host, `detect(location, document)` returning page
|
||||||
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
|
`type` + IDs.
|
||||||
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `seriesId` must strip
|
|
||||||
hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in userscript,
|
- Identify type and IDs from **URL regex**, which is the most stable surface a
|
||||||
`asuraBuildHash` in backend); URLs keep full slug — stale-hash
|
site exposes; take `title` from **`og:title`** (or the page heading where a
|
||||||
URLs 302 to current ones. Astro-rendered; chapter links present in raw
|
site ships no og: tags), never CSS classes.
|
||||||
server HTML.
|
- **No adapter reads a cover.** The backend acquires, stores and serves every
|
||||||
- **demonicscans.org**: series `/manga/<slug>` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title/<slug>/chapter/<n>/<page>` (older `chaptered.php?manga=<id>&chapter=<n>` form still exists as redirect, what series-page chapter-list anchors link through).
|
Cover from its own origin, the wire `cover` is already an address there, and
|
||||||
Encodings (incl. triple-encoded punctuation like `%25252D`) identical
|
`apiPut` strips any `cover` off an outgoing body.
|
||||||
on /manga/ and /title/ pages, so decode-once seriesIds match — verified
|
|
||||||
2026-07-28.
|
**Progress logic** — auto-upsert `last_chapter` only when
|
||||||
- **comix.to**: series `/title/<id>-<slug>`, chapter
|
`chapterNum >= stored last_chapter_num`; unparseable sets the current value.
|
||||||
`/title/<id>-<slug>/<uploadId>-chapter-<n>`. Only the leading `<id>` is
|
Re-reading an old chapter must not regress progress. A manual panel override
|
||||||
identity — the slug re-renders when a series is renamed (`comixSeriesId`).
|
forces any value.
|
||||||
An SPA that **never rewrites `og:title`**: the server-rendered head keeps
|
|
||||||
whatever document loaded first, so on a cold load `og:title` is the homepage's
|
**Retry queue** — every write goes through `pushBookmark`/`pushDelete`.
|
||||||
"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
|
- Entries are markers (`{key, op, sendStatus, attempts}`), **never payloads**:
|
||||||
update, so titles come from there, with the chapter page's `" · Ch.<n>"` tail
|
the body is read from cache at send time, so one entry per key gives ordering
|
||||||
stripped. It publishes no `og:image` either, which is one of the reasons cover
|
and coalescing for free.
|
||||||
|
- `sendStatus` is **sticky** — while an archive is pending, later writes to that
|
||||||
|
key keep carrying the bucket. Without it a successful in-between write
|
||||||
|
silently un-archives the series.
|
||||||
|
- `refresh()` drains before it fetches and overlays anything still pending, so
|
||||||
|
the list never flaps.
|
||||||
|
- 400 drops the entry, 401 aborts the pass and keeps the queue, transient
|
||||||
|
failures retry to a cap. Latest-chapter writes deliberately stay out of the
|
||||||
|
queue.
|
||||||
|
|
||||||
|
**UI** — rendered inside a **Shadow DOM** root to isolate it from site CSS,
|
||||||
|
which is critical on mobile.
|
||||||
|
|
||||||
|
- The FAB's *hit* area is widened by an invisible `#hit` child. `#fab` must keep
|
||||||
|
`touch-action: none` and must **not** regain `overflow: hidden`.
|
||||||
|
- `touch-action` is resolved at gesture start, so the strip cannot be both
|
||||||
|
browser-scrolled and script-dragged. `makeDraggable` therefore splits by
|
||||||
|
intent: a swipe from `#hit` scrolls via `window.scrollBy`, a hold of `ARM_MS`
|
||||||
|
arms a reposition drag, and the visible sliver drags with no hold.
|
||||||
|
|
||||||
|
**SPA navigation** — Asura is Astro and client-routes on comic/chapter pages, so
|
||||||
|
`history.pushState`/`replaceState` are patched and `popstate` listened to, and
|
||||||
|
`detect()` re-runs on URL change. Demonic uses classic reloads, where the
|
||||||
|
initial `document-idle` run suffices.
|
||||||
|
|
||||||
|
### Live URL shapes
|
||||||
|
|
||||||
|
Encoded in the adapters; the notes below are the parts a reader of the regex
|
||||||
|
would get wrong. **Verified 2026-07-26 unless dated otherwise — sites drift, so
|
||||||
|
re-check against a live page before trusting any of it.**
|
||||||
|
|
||||||
|
- **asurascans.com** — the series slug carries a site-wide build-hash suffix
|
||||||
|
(e.g. `-059befe1`) that **rotates on every redeploy**, so `seriesId` must
|
||||||
|
strip it (`stripBuildHash` here, `asuraBuildHash` in the backend) while URLs
|
||||||
|
keep the full slug — stale-hash URLs 302 to current ones.
|
||||||
|
- **demonicscans.org** — slugs may URL-encode punctuation, and the older
|
||||||
|
`chaptered.php?manga=<id>&chapter=<n>` form still exists as a redirect, which
|
||||||
|
is what series-page chapter-list anchors link through. Encodings (including
|
||||||
|
triple-encoded punctuation like `%25252D`) are identical on `/manga/` and
|
||||||
|
`/title/` pages, so decode-once seriesIds match (verified 2026-07-28).
|
||||||
|
- **comix.to** — only the leading `<id>` is identity; the slug re-renders when a
|
||||||
|
series is renamed (`comixSeriesId`). It is an SPA that **never rewrites
|
||||||
|
`og:title`**: the server-rendered head keeps whatever document loaded first,
|
||||||
|
so on a cold load `og:title` is the homepage's name and after an in-page hop
|
||||||
|
it is the *previous* series'. `document.title` is the one thing client routing
|
||||||
|
updates, hence titles come from there with the chapter page's `" · Ch.<n>"`
|
||||||
|
tail stripped. It publishes no `og:image` either, one of the reasons cover
|
||||||
acquisition moved to the backend.
|
acquisition moved to the backend.
|
||||||
- **kagane.to**: series `/series/<uuid>`, reader
|
- **kagane.to** — reader URLs carry no chapter number, so the number comes out
|
||||||
`/series/<uuid>/reader/<bookUuid>`. Reader URLs carry no chapter number, so
|
of `og:title`. Two shapes exist, `"<Series> - Chapter <n>[ - Episode <n>]"`
|
||||||
the number comes out of `og:title`. Two shapes exist: `"<Series> - Chapter
|
and `"<Series> - Volume <v> Chapter <n>"`; both must yield a bare series
|
||||||
<n>[ - Episode <n>]"` and, for volume-numbered series, `"<Series> - Volume <v>
|
title, or the volume tail lands in the bookmark's title. Its covers are
|
||||||
Chapter <n>"` with no episode name — both must yield a bare series title, or
|
challenge- and CORP-protected, so nothing outside kagane.to can load one —
|
||||||
the volume tail lands in the bookmark's title.
|
the panel renders the backend's cover address like every other Site.
|
||||||
Its covers are challenge- and CORP-protected, so nothing outside kagane.to can
|
- **novelfull.com** (novel script) — no `og:*` tags at all, so the title comes
|
||||||
load one directly; the panel renders the backend's own cover address like every
|
from `h3.title` (series) or `a.truyen-title` (chapter).
|
||||||
other Site. Behind a Cloudflare JS challenge, so the backend polls it
|
- **lightnovelworld.net** (novel script) — chapter paths are flat at the site
|
||||||
through the headless browser.
|
root and their slug is a **Chapter Slug, not an identity**: a Series may
|
||||||
- **novelfull.com** (novel script): series `/<slug>.html`, chapter
|
publish under several. The Series address is read off the page's
|
||||||
`/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from
|
|
||||||
`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/<slug>/`, chapter
|
|
||||||
`/<slug>-chapter-<n>/` — flat, at the site root. The chapter path's slug is a
|
|
||||||
Chapter Slug, not an identity: the Series address is read off the page's
|
|
||||||
`a[aria-label='All Chapter']` (fallback: the BreadcrumbList's second crumb),
|
`a[aria-label='All Chapter']` (fallback: the BreadcrumbList's second crumb),
|
||||||
and a Series may publish under several Chapter Slugs. A chapter page with no
|
and a chapter page with no pointer resolves to `other` so no Bookmark is
|
||||||
pointer resolves to `other`, so no Bookmark is offered. `h1.entry-title` is
|
offered. The client runs **no latest-chapter scan** for this Site —
|
||||||
the clean title on a series page and `<Title> Chapter <n>` on a chapter page.
|
`computeLatestChapter` yields null and `backgroundRefreshLatest` skips it
|
||||||
Its series page lists every chapter with an
|
before any fetch — because the backend Poll's one-hour cooldown dominates the
|
||||||
absolute href, so the backend polls it with the plain TLS client.
|
client's four-hour throttle, so a scan would add no freshness while having to
|
||||||
The client performs no latest-chapter scan for this Site: the Poll's
|
truncate at the page's wpdiscuz thread, a public write surface.
|
||||||
one-hour cooldown dominates the client's four-hour throttle, so a scan
|
|
||||||
would add no freshness, and the page's wpdiscuz thread is a public write
|
|
||||||
surface a scan would have to truncate at. `computeLatestChapter` yields
|
|
||||||
null here and `backgroundRefreshLatest` skips the Site before any fetch.
|
|
||||||
|
|
||||||
### Second script: `novel-bookmark.user.js`
|
### Second script — `novel-bookmark.user.js`
|
||||||
|
|
||||||
A copy of the manga script with two adapters, `LIBRARY = "novel"` and
|
A copy of the manga script with two adapters, `LIBRARY = "novel"` and
|
||||||
`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous
|
`STORE_PREFIX = "bmgr:novel:"`. No migration loop, because this script has no
|
||||||
installation to carry keys over from). Installed alongside the manga script;
|
previous installation to carry keys over from. Installed alongside the manga
|
||||||
both write to the same backend with the same `LIBRARY` column discriminating
|
script; both write to the same backend, discriminated by `LIBRARY`.
|
||||||
them.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user