Compare commits

..

7 Commits

Author SHA1 Message Date
sulthan 81631ef08f fix: don't read Cloudflare's injected jsd script as a refusal
demonicscans.org turned JS detections on, so Cloudflare began injecting
/cdn-cgi/challenge-platform/scripts/jsd/main.js into ordinary 200 pages.
isInterstitial matched the bare /cdn-cgi/challenge-platform/ prefix, so
every real demonic series page became errChallengeHeld: two per pass
parked the Lane in the 15m refusal backoff while plain TLS was in fact
returning the full page (200, 133KB, 22 chapter anchors, verified live
2026-08-16).

Match the challenge orchestration path /cdn-cgi/challenge-platform/h/
instead — served only by the interstitial itself, stable across its
wording and locale.
2026-08-16 21:17:10 +07:00
sulthan ba679223b2 Sightings: a Reader report defers a Poll of a solitary Series (#103) (#108)
Closes #103.

A userscript PUT already carries the Latest Chapter the Reader's own browser read off the Series page. It may now stand in for a Poll, under one restriction and one ceiling:

- **Solitary Series only** — a Series two Readers share is Polled on schedule however recently it was sighted, so one Reader's mistake can never reach another's list.
- **One rest of standing**, and a **six-rest ceiling** (`sightingCeilingRests`, counted in the Site's own Rest): however many Sightings arrive, an unpolled Series is Polled.

Both live in the due query's HAVING clause (`Store.DueForLatestCheck`) — the same place the schedule has always been decided, so no timer and no second code path can disagree with it. No new query per scheduler round.

Judgement costs no extra request. `Poller.checkOne` already compares what the Site publishes against what is stored: a lower number contradicts the Sighting (Reader and both numbers logged), the same number confirms it, a higher number is the Site publishing and clears the attribution instead. Three contradictions stop that Reader deferring — their reports still write the Latest Chapter — and twenty consecutive confirmations forgive them, as does the owner's clear-marks control from #102.

One client change was required: both userscripts skipped the PUT when the number had not moved, so the case the whole mechanism exists for — visiting a Series with nothing new — never reached the backend. `reportLatestChapter` sends it, skipping only the local write and the re-render. A numberless PUT (favourite toggle, progress from a chapter page) is no Sighting and defers nothing.

Schema: migration `0011_series_sightings.sql` adds `series.latest_sighted_at` and `series.latest_raised_by`. Trust model, thresholds, and rejected alternatives with their citations: `docs/adr/0011-sighting-deferral-trust-model.md`.

Reviewed on both axes (spec against #103, standards against the repo's rules); the blocker — attribution surviving a Poll that overtook the report — is fixed and has a test that fails without the fix.

Verification: `go test ./...` green (needs Docker), `node --test userscript/test/*.test.js` 66 pass.
Reviewed-on: #108
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-16 20:10:59 +07:00
sulthan 1e6f1e985d Owner-only admin page: Reader roster plus Poll Lane status (#102) (#107)
Closes #102.

The only operational surface was /healthz and a fold-out roster inside the owner's own reading page. This adds /admin: an owner-only page carrying the Reader roster and one row per Poll Lane.

- **Poller seam.** `latest.Poller` records each Lane's last pass (`Site`, `Due`, `Checked`, `LastRun`, `Gap`, `Clamped`, `Browser`) and answers `LaneStatus()`; the page reads that snapshot, never a table. A pass that returns before computing its figures (refusal backoff, sidecar down) carries the previous pass's figures forward rather than recording zeroes, and a Lane that has never reached a pace renders no gap at all. Refusal and sidecar reachability are derived at snapshot time.
- **Owner gate at registration.** Every route reaching past the acting Reader lives in `adminRoutes()` and is wrapped in `requireOwner` when it is registered, so a missing gate is visible in the route list rather than hidden in a handler. `web.AdminPatterns()` is what the gate test walks, so a new route cannot be added without being tested. A non-owner gets 404, never 403.
- **Nil poller is a first-class state.** `main.newRouter` takes the reporter as an interface and converts a nil `*Poller` to a nil interface; no poller and no completed pass both render "No data yet" with the reason spelled out, rather than confident zeroes.
- **Roster moved** off the reading page onto /admin, with the Sighting counters and a confirm-gated `Clear marks` control. #103 fills those counters, so on delivery they read zero for everyone - deliberate ordering.
- **One accent, `--patina`** (verdigris, both colour branches): the far side of the wheel from ember's crimson and clear of the archive blue. Ember still means new chapter only; revocation still wears --danger.

Verification: `go vet ./...` and `go test ./...` green (Docker-backed); admin page screenshotted at 1100px and 390px in both colour schemes. Reviewed on both axes (spec, standards); findings on the accent hue, zero-figure honesty and three tests that could not fail are fixed in 58014eb.
Reviewed-on: #107
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-16 15:02:13 +07:00
sulthan 3303a55b20 feat: one Poll Lane per Site, replacing the shared pace (#100) (#106)
Closes #100.

Each Site runs its own Poll Lane: an independent goroutine with its own rest
and pace from the registry (`backend/internal/latest/sites.go`), replacing the
shared cooldown/interval/stagger/batch configuration. Rest (1h, all six Sites
including the browser trio) is enforced by the due query's WHERE clause; the
Lane sleeps its effective gap between fetches — the registry 10s, or
rest/eligible when a Site holds enough Series, floored at 1s with a
Site-naming warning when the floor engages.

Lane-local failure handling:
- Two challenge-held results stop that Site's Lane for 15m; the probes keep
  their stamp, untried Series stay due.
- A lost browser sets a shared Poller flag: the other browser Lanes skip
  their passes for the same 15m (no stamp-per-pass-per-Lane on a dead tab),
  then decay and probe again.
- Browser wake gate preserved (5 due, or one waiting 15m, ADR-0005); one tab
  shared by the three browser Sites; "browser lane behind by X" logged every
  pass.
- Cover work (healing a stored source URL and filling a blank from the series
  page) runs in the background so a slow CDN cannot consume a Lane's gap.

Removed: `LATEST_CHAPTER_POLL_{COOLDOWN,BROWSER_COOLDOWN,INTERVAL,BATCH,STAGGER}`
and the 6h browser rest. Only `LATEST_CHAPTER_POLL_ENABLED` remains; DEPLOY.md
documents the exact `.env` edit. ADR-0010 records the decisions.

Reviewed-on: #106
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-16 13:16:19 +07:00
sulthan ddbd57070d Poll comix.to through the browser sidecar (#98) (#105)
Closes #98.

comix.to began answering plain-TLS fetches with a Cloudflare JavaScript
challenge on 2026-08-12, so every poll got a 403 interstitial. Its cover host
`static.comix.to` is gated the same way. comix therefore joins kagane and
novelfull as a browser-backed Site.

## What changed

- **Registry** (`internal/latest/sites.go`): comix gains a `Browser` entry —
  `comixRead`, `Done: body != "" && !isInterstitial(body)`, `Fallback: false`.
  Skip-when-no-browser falls out of the existing routing; no site-string compare
  was added anywhere.
- **Read shape** (`internal/latest/browser.go`): an in-tab `fetch()` of the
  Series URL, not a DOM render. comix is an SPA — rendering it costs ~65
  requests for the same server-rendered HTML one fetch returns (24.5 KB,
  ~480 ms measured). `comixSeriesPageURL` pins scheme + host + `/title/<slug>`
  and rebuilds the address, so a client-supplied `series_url` cannot aim the
  browser anywhere else.
- **Cover bytes**: `comixImageURLRe` pins `https://static.comix.to/<path>.<ext>`;
  `BrowserFetcher.Image` now gates on `browserOnlyCoverURL` rather than a
  kagane-only regex, so both Sites' image URLs route through the one path.
  Bytes come from direct navigation, not a page-context fetch — comix's Series
  page sets `cross-origin-embedder-policy: require-corp`, which fails one.
- **Parsers and stored Series identity: untouched.** The in-tab body is the same
  server-rendered HTML the existing fixtures were cut from.

## Verification

- `go test ./...` green (needs Docker).
- New seam tests: comix routes to the browser when one is configured, and is
  not fetched at all when none is (`TestComixUsesBrowserFetcher`,
  `TestComixSkippedWhenNoBrowserFetcher`); URL-pin and cover-gate table tests.
- Live proof against the real browser unit, `TestSmokeComix` (env-gated):
  page 24793 bytes in one in-tab fetch, chapter 53, cover accepted by the pin,
  26862 bytes of `image/jpg` retrieved.
- Two-axis review run; findings were stale comments on `BrowserFetcher`, `Get`
  and the `Fallback` field, fixed in f000cc7.

Docs updated: root `AGENTS.md` (constraint + smoke command, including the note
that this dev machine's ISP DNS-hijacks `comix.to`), `backend/AGENTS.md`
(poller, cover pipeline, `BROWSER_WS_URL`), `REDEPLOY.md` §8 degrade note.

Reviewed-on: #105
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-16 12:13:59 +07:00
sulthan 3f53c79cf4 docs: add Poll Lane and Sighting to the shared vocabulary (#104)
Two new glossary terms in `CONTEXT.md`, settled in a design session, plus the graphify refresh.

- **Poll Lane** — one Site's own stream of Polls, carrying the pace at which that Site is willing to be asked. No Lane can slow, block or borrow from another's; a Reader never has one.
- **Sighting** — what a Reader's browser happened to see of a Series's Latest Chapter. Reports the same fact as a Poll, carries none of its authority.
- **Latest Chapter** amended: it no longer claims to be discovered without the reader present, since a Sighting establishes it between Polls.

No code. The work these terms describe is specified in #98 (comix), #101 (Poll Lanes), #102 (admin page) and #103 (Sightings).

Reviewed-on: #104
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-16 11:42:16 +07:00
sulthan 17ee0bd3f8 docs: correct the bot-score claims behind the browser poller (#99)
Docs only. No code changes - `git diff origin/main --stat` touches five Markdown files and adds one research note.

## What was wrong

Several docs explained Cloudflare challenges as a "bot score" that our request rate could worsen. That mechanism does not exist on these sites.

Researched live on 2026-08-12 against Cloudflare's own documentation and blog plus RFC 9309 - 22 primary pages, every claim carrying a source URL and read date, seven areas explicitly marked `Not publicly documented`. The note is `docs/research/cloudflare-bot-scoring-and-poll-cadence.md`.

- The 1-99 bot score is **Enterprise Bot Management only**. A free-plan zone has no score at all; it gets Bot Fight Mode, which matches *signatures* (headless browsers, cloud-hosting IPs).
- **No per-IP request rate is documented as an input to challenge issuance.** Volume is policed by Rate Limiting Rules, a separate opt-in product: one rule, IP-only counting, 10-second windows on Free. Published DDoS thresholds are ~1,000 errors/sec.
- **`cf_clearance` defaults to 30 minutes**, so every cadence at or above 1 hour re-solves the challenge anyway. Cadence changes how many ~4s solves happen per day and nothing else.
- The documented risk is **fingerprint quality**, which this repo already solved (real Chrome, stock UA, non-UTC clock).

## What changed

| File | Correction |
|---|---|
| `AGENTS.md` | The block is per-zone configuration plus request fingerprint, not IP reputation. comix.to turning its gate on 2026-08-12 is the worked example. Residential egress avoids the cloud-hosting-IP *signature* rather than earning a better score. The UTC measurement stands; its mechanism is now marked undocumented. |
| `backend/AGENTS.md` | Says why `_BROWSER_COOLDOWN` is longer: cost, not safety. |
| `docs/adr/0003` | Dated correction - the sites do not "bot-score" the VPS IP. Decision stands on its sweep-depth argument. |
| `docs/adr/0006` | Dated correction - no score to be better at. Decision stands on VPS memory. |
| `DEPLOY.md` | A red kagane smoke run means the Site's settings or this Chrome's fingerprint moved, not "Cloudflare's scoring". |

ADRs got dated `Corrected 2026-08-12:` paragraphs rather than silent rewrites - the record of what was decided stays intact, only the wrong mechanism is retracted.

## Deliberately not in this PR

- **The 6h browser cooldown is unchanged.** I had lowered it to 1h and reverted that; cadence is a behaviour change and belongs with the comix work in #98, not in a docs correction.
- **Two code comments still carry the myth**: `backend/main.go:83-84` ("a hammer against sites that are already bot-scoring us"). Left alone to keep this diff docs-only.

Related: #98.
Reviewed-on: #99
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-12 09:32:07 +07:00
51 changed files with 12216 additions and 4374 deletions
+5 -18
View File
@@ -73,26 +73,13 @@ DISCORD_REDIRECT_URI=
# The backend re-checks each bookmarked series' newest published chapter on its
# own schedule, so latest_chapter stays fresh even when you never open the manga
# sites. This runs in parallel with the userscript's own in-browser check.
# Set to 0 to turn it off entirely.
# Set to 0 to turn it off entirely. Pace is per Site (one Poll Lane per Site,
# issue #100) and lives in the backend registry, not here — there is nothing
# else to configure.
# LATEST_CHAPTER_POLL_ENABLED=1
#
# Two independent clocks. COOLDOWN is how long a plain-TLS series rests between
# checks; BROWSER_COOLDOWN is the longer rest for kagane and novelfull. INTERVAL
# is how often the poller wakes up and looks for series past their cooldowns.
# Shortening INTERVAL cannot shorten either cooldown.
LATEST_CHAPTER_POLL_COOLDOWN=1h # plain-TLS per series, floor 15m
LATEST_CHAPTER_POLL_BROWSER_COOLDOWN=6h # browser-backed per series, floor 15m
LATEST_CHAPTER_POLL_INTERVAL=10m # how often to wake
LATEST_CHAPTER_POLL_BATCH=14 # series per wake
LATEST_CHAPTER_POLL_STAGGER=20s # delay between fetches in a batch
#
# Uses a ticker, not an immediate first run: the first poll happens one
# INTERVAL after startup, not at startup. A container restarting more often
# than INTERVAL never polls.
#
# BATCH x (COOLDOWN / INTERVAL) series hold the cooldown cadence — 84 with these
# defaults. Beyond that the cadence stretches uniformly rather than breaking;
# raise BATCH or lower INTERVAL. Keep BATCH x STAGGER under INTERVAL.
# Every Site rests an hour between checks and gaps ten seconds between fetches;
# a Site with many Series tightens its own gap. See backend/internal/latest/sites.go.
# CDP endpoint of the browser, used for the two sites behind a Cloudflare
# JavaScript challenge (kagane, novelfull) and by the web UI's kagane cover
+15 -7
View File
@@ -18,11 +18,11 @@ Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free
- 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.
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
- Cloudflare's block on manga sites **IP-reputation-based, not universal — 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 — Cloudflare's bot scoring can flip previously-clean IP without notice. 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 and novelfull.com are the exception to the above** — both 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 is 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. The four other sites poll fine over plain TLS.
- 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.
- **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.
- **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 scores better with Cloudflare anyway (ADR-0006). 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/novelfull 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, so Cloudflare scores it as one; 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. `BROWSER_TZ` therefore needs a plausible zone, not a geolocated one.
- **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.
- **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.
## Architecture
@@ -45,12 +45,20 @@ Backend (`cd backend`):
- Single test: `go test -run TestName ./...`
- Build static binary: `CGO_ENABLED=0 go build`
Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and novelfull. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP).
Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and comix. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP).
Live CDP proof (needs that browser and network, skipped otherwise):
`SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run TestSmokeKagane ./internal/latest`
— fetches a real kagane cover and chapter list. A red run means the challenge is
`SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run 'TestSmokeKagane|TestSmokeComix' ./internal/latest`
— fetches a real kagane and comix cover and chapter list. A red run means the challenge is
not clearing from this IP, which is a live fact to re-check, not necessarily a defect.
A red `TestSmokeComix` reporting `ERR_CERT_COMMON_NAME_INVALID` is not the
challenge: it means the resolver the browser container uses hijacks `comix.to`.
Observed 2026-08-16 on one Indonesian ISP, which CNAMEs it to a block page
(`aduankonten.id`). Check with `docker exec <browser> getent hosts comix.to`,
and if it is hijacked, run the container with
`--add-host comix.to:<ip> --add-host static.comix.to:<ip>` from a DoH lookup
(`curl -H 'accept: application/dns-json' 'https://1.1.1.1/dns-query?name=comix.to&type=A'`).
Machine-local, so don't put those hosts in `chrome/docker-compose.yml`.
Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200.
+21 -7
View File
@@ -56,12 +56,12 @@ is real activity, so only Progress reorders the list.
_Avoid_: position, bookmark (the noun is taken), last read
**Latest Chapter**:
The highest-numbered chapter a Site has published for a Series, discovered without the
reader present. The number is what ranks it, never a date and never the Site's own
"newest chapter" banner — where a Site disagrees with itself, its list of chapters is
the record and its summary of that list is not. Distinct from Progress in every way
that matters: it is a fact about the Site, not about the reader, and it must never
reorder the list.
The highest-numbered chapter a Site has published for a Series. The number is what
ranks it, never a date and never the Site's own "newest chapter" banner — where a Site
disagrees with itself, its list of chapters is the record and its summary of that list
is not. Established by a Poll and, between Polls, by a Sighting. Distinct from Progress
in every way that matters: it is a fact about the Site, not about the reader, and it
must never reorder the list.
_Avoid_: newest, current chapter, update
**Poll**:
@@ -70,9 +70,23 @@ Reader present. Performed once per Series no matter how many Readers bookmarked
a Poll is work done on behalf of the Series, never on behalf of a Reader.
_Avoid_: scrape, refresh, check, sync
**Poll Lane**:
One Site's own stream of Polls, carrying the pace at which that Site is willing to be
asked. Every Site has exactly one and no Lane can slow, block or borrow from another's;
a Reader never has one and never influences one.
_Avoid_: worker, queue, scheduler, batch, wave
**Sighting**:
What a Reader's browser happened to see of a Series's Latest Chapter while that Reader
was on the page. It reports the same fact as a Poll but carries none of its authority:
a Poll always overrules it, and only a Sighting on a Series no Reader else holds may
defer one. A Sighting a later Poll contradicts downwards is a false Sighting, and
enough of those cost the Reader the right to defer at all.
_Avoid_: client report, user poll, observation, claim
**Acquisition**:
The single read of a Series page made the moment the Series first exists, giving it
both its Latest Chapter and its Cover without waiting out the Poll queue. Distinct
both its Latest Chapter and its Cover without waiting for the Lane's pace. Distinct
from a Poll in the two ways that matter: a Reader is present — it is triggered by
their first Bookmark of that Series — and it is the only read that establishes a
Cover rather than refreshing facts. It happens once in a Series's life; every later
+13 -3
View File
@@ -93,6 +93,14 @@ the backend dials but not the password the database expects, and `bookmark-api`
crash-loops on `password authentication failed`. Set it before §2 and leave it
alone.
An `.env` written before issue #100 carries the old poll-pace names
(`LATEST_CHAPTER_POLL_COOLDOWN`, `_BROWSER_COOLDOWN`, `_INTERVAL`, `_BATCH`,
`_STAGGER`). All five are dead configuration now — the pace lives in the Site
registry (`backend/internal/latest/sites.go`), so **delete those lines** and
keep only the kill switch `LATEST_CHAPTER_POLL_ENABLED`. Leaving them behind
is harmless (nothing reads them) but silently misleads the next person who
edits the file.
> Match `TRAEFIK_ENTRYPOINT` / `TRAEFIK_CERTRESOLVER` to your Traefik's actual
> names (check your Traefik static config — common alternatives: `https`,
> `myresolver`, `cloudflare`). Wrong names = no certificate issued.
@@ -275,7 +283,8 @@ copy immediately — reinstall on all devices, or they silently stop syncing.
Kagane and novelfull sit behind a Cloudflare JavaScript challenge no TLS
fingerprint clears, so the poller reaches them through a real Chrome over CDP.
That browser does **not** run on the VPS: it held 471 MiB of a 1974 MiB box
with no swap, and it scores better from a residential IP anyway (ADR-0006). It
with no swap, and a residential IP avoids the cloud-hosting-IP signature Bot
Fight Mode challenges anyway (ADR-0006). It
is its own compose unit, deployed and updated independently of everything
above.
@@ -454,7 +463,8 @@ SMOKE_BROWSER_WS_URL=ws://100.x.y.z:9222 go test -run TestSmokeKagane ./internal
```
A red run means "not clearing from this address right now", which is a live
fact to re-check before it is a defect — Cloudflare's scoring moves. Then, from
fact to re-check before it is a defect — a Site's Cloudflare settings, and the
fingerprint this Chrome presents after an update, both move. Then, from
the web UI, open a bookmarked kagane series and confirm the cover renders. Once
a cover is stored it is served from Postgres forever after, so the browser being
asleep, unreachable, or mid-power-outage costs chapter freshness and nothing
@@ -503,7 +513,7 @@ picks up a restarted Chrome's new debugger UUID by itself.
| kagane rows never get a `latest_chapter`; log says `browser fetcher disabled` or nothing at all | `BROWSER_WS_URL` unset. Expected before §7 is done. |
| kagane polls all fail; log shows a 500 from `/json/version` | `BROWSER_WS_URL` names a MagicDNS hostname (or any name). Chrome's DevTools handler only accepts an IP or `localhost` — use the tailnet IP. |
| kagane polls fail with a connection error | Home machine off, off the tailnet, or the unit is down. `tailscale ping <machine>`, then `docker compose ps` in its `chrome/`. Costs freshness only; stored covers keep serving. |
| kagane cover is a placeholder for a newly bookmarked series | Its cover has never been fetched and the browser is unreachable. It fills in on the next successful poll of that series (up to `LATEST_CHAPTER_POLL_BROWSER_COOLDOWN`, default 6h). |
| kagane cover is a placeholder for a newly bookmarked series | Its cover has never been fetched and the browser is unreachable. It fills in on the next successful poll of that series. |
| `compose` in `chrome/` errors `set BROWSER_BIND_ADDR to this machine's tailnet IP` | No `chrome/.env`, or the variable is empty. Deliberate — it has no default so an unset value cannot publish CDP to the LAN. |
| browser container restarts, or is OOM-killed | `docker inspect bookmark-browser --format '{{.RestartCount}} {{.State.OOMKilled}}'`. The 512 MiB cap is sized against a measured 645 MiB untuned peak; a real breach is a Chrome regression worth reading `docker logs` for, not a number to raise reflexively. |
+1 -6
View File
@@ -53,12 +53,7 @@ covers are stored, so the library renders in full with the browser switched off.
| `DISCORD_API_BASE` | `https://discord.com/api/v10` | Test seam — tests point it at a local stub so the real token exchange runs. |
| `USERSCRIPT_PATH` | `/userscript/manga-bookmark.user.js` | Bindmounted file served at `/u/{token}/manga-bookmark.user.js`. |
| `NOVEL_USERSCRIPT_PATH` | `/userscript/novel-bookmark.user.js` | Same, for the novel library. |
| `LATEST_CHAPTER_POLL_ENABLED` | `1` | `0` turns the poller off entirely. |
| `LATEST_CHAPTER_POLL_COOLDOWN` | `1h` | Rest between checks of one plain-TLS series; floor `15m`. |
| `LATEST_CHAPTER_POLL_BROWSER_COOLDOWN` | `6h` | Rest between checks of one browser-backed series; floor `15m`. |
| `LATEST_CHAPTER_POLL_INTERVAL` | `10m` | How often the poller wakes. Cannot shorten either cooldown. |
| `LATEST_CHAPTER_POLL_BATCH` | `14` | Series per wake. Keep `BATCH × STAGGER` under `INTERVAL`. |
| `LATEST_CHAPTER_POLL_STAGGER` | `20s` | Delay between fetches in a batch — this is the outbound request rate. |
| `LATEST_CHAPTER_POLL_ENABLED` | `1` | `0` turns the poller off entirely. Pace is per Site in the registry — one Poll Lane per Site, each with its own rest and gap (issue #100) — so no other knobs exist. |
Compose reads a few more from the same `.env` that the backend never sees:
`POSTGRES_PASSWORD` (required — `DATABASE_URL` is built from it, and Postgres
+2 -2
View File
@@ -436,8 +436,8 @@ free -m # the Gitea runner should still have its headroom
Nothing here needs doing during an API redeploy. The API stack does not
`depends_on` the browser, and an unreachable one degrades exactly as an unset
`BROWSER_WS_URL`: plain-TLS libraries unaffected, kagane and novelfull logged
and skipped, stored covers still served.
`BROWSER_WS_URL`: plain-TLS libraries unaffected, kagane and comix logged
and skipped, novelfull attempted over plain TLS, stored covers still served.
---
+118 -36
View File
@@ -27,7 +27,8 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`series` keyed `(site, series_id)`
(`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the
shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`),
Latest Chapter, `latest_checked_at` — and `bookmarks` holds only what
Latest Chapter, `latest_checked_at`, and the Sighting pair
`latest_sighted_at`/`latest_raised_by` (issue #103) — and `bookmarks` holds only what
differs between readers: progress, favourite, lifecycle bucket,
`updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no
surrogate id; the wire `key` is derived as `site:series_id` on read — and
@@ -74,27 +75,75 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
Remove's row wear ember wash, two reversible ones wear `.calm` grey.
`--ember` stay reserved for new-chapter signal: busy bar and inline
error use `--mute`.
- **Latest-chapter poller:** ticker goroutine in same binary re-check
each bookmarked series' newest published chapter from backend's own
network access, so `latest_chapter` stay fresh when user not
browsing. Second, parallel signal — userscript keep own
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged.
Two independent clocks: per-series cooldown (`series.latest_checked_at`,
enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval.
- **Latest-chapter poller:** one goroutine per Site (a Poll Lane, issue #100),
each re-checking that Site's bookmarked series' newest published chapter from
backend's own network access, so `latest_chapter` stays fresh when the user
isn't browsing. Second, parallel signal — the userscript keeps its own
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` schedule, and its
`reportLatestChapter` PUTs every read, unchanged numbers included, because an
unchanged read is exactly the Sighting worth deferring a Poll on (#103).
Two independent clocks: per-series rest (`series.latest_checked_at`,
enforced by `Store.DueForLatestCheck`'s WHERE clause — `now - Rest`) and
per-Lane gap (the Lane sleeping between fetches, `effectiveGap`). Both live
in the Site registry (`internal/latest/sites.go`), not config: the five env
knobs that used to size a shared pace are gone.
The poller walks **Series, not Bookmarks** — a series referenced by several
bookmarks is fetched once per cycle, and the due queue orders
`reader_count DESC, latest_checked_at ASC` (ADR-0003). Series row stamped
*before* fetch so broken series wait out full cooldown instead of retrying
*before* fetch so broken series wait out the rest instead of retrying
every tick; found chapter written straight to the series row via
`Store.SetLatestChapter`, so a bookmark's `updated_at` — and the list
order — is never touched.
**Sightings** (issue #103, ADR-0011) let a Reader's own page read defer a
Poll: `Store.RecordSighting` — called by the PUT handler *before* the Upsert,
because the raise test needs the row as it stands — stamps
`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
is where deferral lives: a Series is skipped only while it has exactly one
Bookmark, was sighted within one Rest, and is under the ceiling
(`sightingCeilingRests`, six of that Site's rests) since its last Poll. So a
shared Series is never deferred, and no Series goes six hours unpolled
whatever arrives. `checkOne` judges the named Reader off the comparison it
already makes: a lower number is a contradiction (logged with the Reader and
both numbers), the same number an agreement, a higher number the Site
publishing and neither — that last one clears the attribution instead, since
the value the Poll then stores is its own and a later retraction is not the
Reader's fault. Three contradictions
(`store.SightingDisagreementLimit`) stop that Reader deferring — their
reports still write the Latest Chapter — and twenty consecutive agreements
(`store.SightingAgreementsToClear`) forgive them, as does the owner's
clear-marks control. Deferral is recomputed from live facts every round, so
nothing needs invalidating when a Series gains a second Bookmark; the one
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
Reader's next Sighting and the standing already bought lasts out its rest.
Refusals and browser loss are Lane-local: two `errChallengeHeld` in one pass
stop that Site for `refuseBackoff` (15m) while other Lanes continue; an
`errBrowserInterrupted` (remote Chrome restart) sets a shared Poller flag
that makes the other browser Lanes skip their passes for the same 15m, so a
restarting Chrome doesn't stamp one Series per Lane per pass — after the
window the flag decays and they probe again. Browser Lanes wake Chrome only
when 5+ Series are due or one has waited 15m (ADR-0005 on-demand browser),
and cover work (both healing a stored source URL and filling a blank from
the series page) runs in the background so a slow CDN can't consume a
Lane's gap.
A refusal is only ever the challenge *page*: `isInterstitial` matches the
orchestration path `/cdn-cgi/challenge-platform/h/`, never the bare prefix.
Cloudflare injects `/cdn-cgi/challenge-platform/scripts/jsd/main.js` into
ordinary 200 pages once a zone turns JS detections on, which demonic did on
2026-08-16 — the prefix match then read every real demonic page as a refusal
and parked that Lane in 15m backoff while plain TLS was returning the full
series page.
Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth
against fingerprint-based blocking; any failure log and skip. kagane and
novelfull sit behind Cloudflare JavaScript challenges the TLS client can't
clear, so they are fetched over CDP via `BROWSER_WS_URL`; kagane is simply
not polled when that's unset, while novelfull falls back to a plain-TLS
attempt — its challenge is a live time-varying fact, and its cover bytes
never need the browser. See
against fingerprint-based blocking; any failure log and skip. kagane, comix
and novelfull sit behind Cloudflare JavaScript challenges the TLS client
can't clear, so they are fetched over CDP via `BROWSER_WS_URL`; kagane and
comix are simply not polled when that's unset, while novelfull falls back to
a plain-TLS attempt — its challenge is a live time-varying fact, and its
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
costs ~65 requests for the same server-rendered HTML one fetch returns
(measured 2026-08-12, issue #98). See
`docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`.
The poller's series write is a single-column UPDATE
(`Store.SetLatestChapter`), not a read-modify-write of the whole bookmark:
@@ -112,14 +161,17 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
is public and uncredentialed: the userscript renders it on a Site's origin,
where no cookie or token of ours travels. A client-sent `cover` is decoded
and discarded, permanently (ADR-0004 compatibility).
Browser-backed Sites join the same pipeline (issue #62): kagane pages *and*
cover bytes go through the browser sidecar (nothing falls back to a plain
fetch, which would only retrieve a challenge 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 no browser
configured, kagane Covers are simply absent; novelfull still gets one — at
creation and on the poll — when its page body happens to answer a plain
request (the challenge is a live time-varying fact). The old kagane-only
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
(nothing falls back to a plain fetch, which would only retrieve a challenge
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
no browser configured, kagane and comix Covers are simply absent; novelfull
still gets one — at creation and on the poll — when its page body happens to
answer a plain request (the challenge is a live time-varying fact). comix
cover bytes must arrive by direct navigation, not an in-page fetch: its
Series page sets `cross-origin-embedder-policy: require-corp`, which fails a
page-context fetch of `static.comix.to`. The old kagane-only
serving path (`/img/kagane/{id}`, template rewrite, `CoverFetcher`) is gone
(issue #63): the one public route serves every Site.
- **`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.
@@ -146,10 +198,18 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`_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`/`_COOLDOWN`/`_BROWSER_COOLDOWN`/`_INTERVAL`/
`_BATCH`/`_STAGGER` (background latest-chapter poller; defaults on,
`1h` plain-TLS cooldown, `6h` browser cooldown, `10m`/`14`/`20s`; both
cooldowns have a `15m` floor).
`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
@@ -158,10 +218,11 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
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 and novelfull page fetches and by the cover
pipeline for kagane's image bytes (the browser is the only route that clears
the challenge kagane serves its covers behind); unset — the default —
disables browser polling and leaves kagane Covers blank until stored bytes
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
@@ -169,8 +230,10 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
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 image URLs are claimed by `browserOnlyCoverURL` — they answer a
plain fetch with a challenge and `cross-origin-resource-policy: same-origin`;
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
one with the same Cloudflare challenge its pages serve;
every other Site's CDN answers plain TLS. Templates render `.Cover` — the
wire value — never anything else.
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
@@ -181,6 +244,25 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`POST /rotate-token` (atomic epoch bump + hash
rewrite; invalidates every installed copy, so the panel warns to reinstall
on all devices).
Owner-only `POST /readers/{id}/revoke` (drops one Reader's session rows and
re-renders the `readers` panel; 404 for any non-owner) is the only route that
reaches across Readers.
- **Owner-only admin page (`internal/web/admin.go`, issue #102):** `GET /admin`
carries the Reader roster (sessions, Sighting counters, `POST
/readers/{id}/revoke` and `POST /readers/{id}/clear-marks`) and Poll Lane
status (`GET /ui/admin/lanes`, self-refreshing every 30s). Every route that
reaches past the acting Reader is listed in `adminRoutes()` and wrapped in
`requireOwner` at registration — add a route there, not a check inside a
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
`web.LaneReporter` seam (`latest.Poller.LaneStatus`), never from a table: a
nil reporter or a Lane that has not finished a pass renders "no data yet"
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
claim a poller exists.
The one owner comparison left outside `requireOwner` is in `index`
(`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an
endpoint, so it is a rendering decision a registration-time wrapper cannot
express — do not "unify" it into the gate.
A Lane pass that returns before computing its figures (refusal backoff,
sidecar down) carries the previous pass's due count and gap forward rather
than recording zeroes; a Lane that has never reached a pace renders no gap at
all. `Checked` next to `Due` is what separates a stopped Lane from a quiet
one, so neither figure may be dropped from the row.
+44 -4
View File
@@ -48,7 +48,7 @@ func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
func newTestServer(t *testing.T) http.Handler {
t.Helper()
return newRouter(newTestStore(t), testConfig())
return newRouter(newTestStore(t), testConfig(), nil)
}
func newTestStore(t *testing.T) *store.Store {
@@ -599,7 +599,7 @@ func TestLoadConfigDiscord(t *testing.T) {
// cooldown and the poller would re-fetch that series on every single tick.
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
s := newTestStore(t)
srv := newRouter(s, testConfig())
srv := newRouter(s, testConfig(), nil)
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
@@ -621,6 +621,46 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
}
}
// Sighting deferral (issue #103) only reaches production through the PUT
// handler: the store and poller can be right and the feature still dead if the
// handler never records the report. Asserted where a client can see it - the
// series stops being due the moment the PUT lands.
func TestPutRecordsASighting(t *testing.T) {
s := newTestStore(t)
srv := newRouter(s, testConfig(), nil)
now := time.Now().UnixMilli()
hour := time.Hour.Milliseconds()
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", now-2*hour)
due, err := s.DueForLatestCheck("asura", now-hour, now-6*hour)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 {
t.Fatalf("due before the PUT = %d series, want 1", len(due))
}
body := `{"key":"asura:x","site":"asura","series_id":"x",
"series_url":"https://asurascans.com/comics/x",
"last_chapter":"Chapter 5","last_chapter_num":5,
"latest_chapter":"Chapter 9","latest_chapter_num":9}`
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, auth(req))
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
due, err = s.DueForLatestCheck("asura", now-hour, now-6*hour)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 0 {
t.Fatalf("due after the PUT = %d series, want 0: the handler recorded no Sighting", len(due))
}
}
// The userscript route is registered outside the web UI's Discord auth, so it
// must keep working whatever the web config — see internal/userscript for the
// handler's own behaviour. The credential in the path is the owner's derived
@@ -637,7 +677,7 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil)
newRouter(s, cfg).ServeHTTP(rr, req)
newRouter(s, cfg, nil).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
@@ -659,7 +699,7 @@ func TestNovelUserscriptServed(t *testing.T) {
cfg := testConfig()
cfg.NovelUserscriptPath = novelPath
srv := newRouter(s, cfg)
srv := newRouter(s, cfg, nil)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
+1 -1
View File
@@ -98,7 +98,7 @@ func TestPublicCoverNeverEchoesNonImage(t *testing.T) {
if _, err := db.Exec(`UPDATE covers SET content_type = 'text/html' WHERE address = $1`, address); err != nil {
t.Fatalf("poison row: %v", err)
}
rr := getCover(t, newRouter(st, testConfig()), "/covers/"+address, nil)
rr := getCover(t, newRouter(st, testConfig(), nil), "/covers/"+address, nil)
if rr.Code == http.StatusOK {
t.Fatalf("status = 200, want a refusal for a non-image row (body %q)", rr.Body.String())
}
+12 -1
View File
@@ -99,7 +99,18 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
// reading progress actually moved. Any client value is ignored.
b.UpdatedAt = time.Now().UnixMilli()
stored, err := h.Store.Upsert(httpmw.ReaderID(r), b)
// A userscript PUT is a Sighting: the Reader's browser was on the Series
// page and read its Latest Chapter (issue #103). Recorded before the
// Upsert, which is what makes the raise comparison possible, and never
// from the web UI's own read-modify-write — a Reader toggling a favourite
// has not looked at the Site and must not postpone a Poll. A failure here
// costs a deferral, not the write, so it is logged and dropped.
readerID := httpmw.ReaderID(r)
if err := h.Store.RecordSighting(readerID, b.Site, b.SeriesID, b.LatestChapterNum, b.UpdatedAt); err != nil {
log.Printf("record sighting: %v", err)
}
stored, err := h.Store.Upsert(readerID, b)
if err != nil {
log.Printf("upsert: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
+1 -1
View File
@@ -120,7 +120,7 @@ func (a *Acquirer) acquire(ctx context.Context, sr store.Series) {
// Stamped after success — the reverse of the poller, which stamps before
// the fetch: the Reader is here, watching the Series they just created, so
// a failed acquisition must leave the row due for a fast retry rather than
// consuming the cooldown. The stamp happens even when the page read
// consuming the rest. The stamp happens even when the page read
// succeeded but produced no facts to persist.
if err := a.Store.MarkLatestChecked(sr.Site, sr.SeriesID, time.Now().UnixMilli()); err != nil {
log.Printf("acquire %q: mark checked: %v", sr.Key(), err)
+72 -30
View File
@@ -18,18 +18,22 @@ import (
// challengeTimeout bounds one navigate-and-solve. A Cloudflare managed
// challenge clears in a few seconds when it clears at all; anything longer is a
// challenge that is not going to pass, and the caller's cooldown was already
// challenge that is not going to pass, and the caller's rest was already
// stamped before this ran.
const challengeTimeout = 45 * time.Second
var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`)
// comixSeriesPathRe matches the one path shape comixRead will open: a Series
// page, "/title/<id>-<slug>". Verified live 2026-08-12.
var comixSeriesPathRe = regexp.MustCompile(`^/title/[^/?#]+/?$`)
// BrowserFetcher retrieves pages through a remote headless Chrome over the
// DevTools Protocol.
//
// It exists for one reason: kagane.to and novelfull.com sit behind a
// Cloudflare JavaScript challenge. Verified 2026-08-03 (kagane) and 2026-08-05
// (novelfull) from the deployment host, plain HTTP and bogdanfinn/tls-client
// It exists for one reason: kagane.to, novelfull.com and comix.to sit behind a
// Cloudflare JavaScript challenge. Verified 2026-08-03 (kagane), 2026-08-05
// (novelfull) and 2026-08-12 (comix), plain HTTP and bogdanfinn/tls-client
// with a Chrome_133 profile both get 403 with cf-mitigated: challenge on every
// path, including the API, robots.txt and images. Clearing it requires
// executing the challenge script, which only a real browser does.
@@ -40,10 +44,11 @@ var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`)
// sync that break silently and separately. The browser's own cookie jar
// persists across polls, so the challenge is solved once every few hours.
//
// The two sites differ in how the chapter list is read: kagane serves it from
// a JSON API that must be called from inside the page (so the request carries
// the clearance cookie), while novelfull renders it into the HTML so the
// cleared DOM is the payload.
// The three sites differ in what a cleared tab is asked for: kagane fetches a
// JSON API from inside the page (the list exists nowhere else), comix fetches
// its own Series URL from inside the page (the served HTML carries the facts,
// and rendering the SPA costs ~65 requests instead of one), and novelfull
// renders its list into the HTML so the cleared DOM is the payload.
type BrowserFetcher struct {
allocCtx context.Context
cancel context.CancelFunc
@@ -87,10 +92,9 @@ func (f *BrowserFetcher) Close() {
}
// Get navigates to seriesURL, lets any challenge resolve, then reads the
// payload the Site's registry entry describes — kagane's chapter-list API from
// inside the page so the request carries the clearance cookie, novelfull's
// served HTML. The returned body is whatever the Site's chapter list lives in,
// which is what the entry's LatestChapter parse expects.
// payload the Site's registry entry describes (the shapes are listed on
// BrowserFetcher). The returned body is whatever the Site's chapter list lives
// in, which is what the entry's LatestChapter parse expects.
func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int, error) {
var body string
// Sorted order (browserBackedSites sorts) makes dispatch deterministic:
@@ -141,29 +145,47 @@ func novelfullRead(seriesURL string, out *string) (chromedp.Action, bool) {
return chromedp.OuterHTML("html", out, chromedp.ByQuery), true
}
// comixRead fetches the Series page from inside the cleared tab. comix is an
// SPA: rendering the page costs ~65 requests, while one same-origin fetch of
// the same address returns the server-rendered HTML — 24.5 KB, ~480 ms,
// carrying both parser anchors (measured 2026-08-12, issue #98). So this is
// kaganeRead's shape, not novelfullRead's, even though the payload is HTML.
// Refusing any other address is the per-Site half of the SSRF gate.
func comixRead(seriesURL string, out *string) (chromedp.Action, bool) {
pageURL, ok := comixSeriesPageURL(seriesURL)
if !ok {
return nil, false
}
return chromedp.Evaluate(
`fetch(`+jsString(pageURL)+`).then(r => r.ok ? r.text() : "")`,
out, awaitPromise), true
}
// Image retrieves one cover's bytes through the browser sidecar, and its
// content type.
//
// It exists because kagane serves covers behind the same challenge as its
// pages *and* with `cross-origin-resource-policy: same-origin`, so the bytes
// are only reachable from inside a browser that already holds the clearance
// cookie (verified 2026-08-08). Acquisition through the sidecar is the only
// route.
// It exists because kagane and comix serve covers behind the same challenge as
// their pages — kagane additionally with
// `cross-origin-resource-policy: same-origin` — so the bytes are only
// reachable from inside a browser that already holds the clearance cookie
// (verified 2026-08-08 for kagane, 2026-08-12 for comix). Acquisition through
// the sidecar is the only route.
//
// The image URL is navigated to rather than fetched from some other kagane
// page: the challenge only runs on a top-level navigation, and once it clears
// The image URL is navigated to rather than fetched from another page of the
// Site: the challenge only runs on a top-level navigation, and once it clears
// the document *is* the image, so a same-origin fetch of location.href reads
// it straight back out of the cache.
// it straight back out of the cache. For comix the navigation is also the only
// route that works at all — its Series page sets
// `cross-origin-embedder-policy: require-corp`, which fails a page-context
// fetch of the cover host.
//
// The challenge is not solved by the first read: WaitReady("body") is satisfied
// by the interstitial too. run holds the tab open until the in-page fetch
// succeeds, which is what gives the challenge script the seconds it needs.
func (f *BrowserFetcher) Image(ctx context.Context, imageURL string) ([]byte, string, error) {
m := kaganeImageURLRe.FindStringSubmatch(imageURL)
if m == nil {
if !browserOnlyCoverURL(imageURL) {
return nil, "", fmt.Errorf("not a browser-fetchable cover url: %q", imageURL)
}
imageID := m[1]
var dataURL string
err := f.run(ctx, imageURL,
chromedp.Evaluate(`fetch(location.href).then(r => r.ok
@@ -175,23 +197,23 @@ func (f *BrowserFetcher) Image(ctx context.Context, imageURL string) ([]byte, st
: "")`, &dataURL, awaitPromise),
func() bool { return dataURL != "" })
if err != nil {
return nil, "", fmt.Errorf("browser image %s: %w", imageID, err)
return nil, "", fmt.Errorf("browser image %s: %w", imageURL, err)
}
// "data:image/webp;base64,<payload>".
head, payload, ok := strings.Cut(dataURL, ";base64,")
if !ok {
return nil, "", fmt.Errorf("browser image %s: not a data url", imageID)
return nil, "", fmt.Errorf("browser image %s: not a data url", imageURL)
}
raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return nil, "", fmt.Errorf("browser image %s: %w", imageID, err)
return nil, "", fmt.Errorf("browser image %s: %w", imageURL, err)
}
return raw, strings.TrimPrefix(head, "data:"), nil
}
// errChallengeHeld reports that the budget ran out with the interstitial still
// up. Distinct from a transport failure: it means "this site said no", which
// the poller answers with a 403 and its ordinary cooldown.
// the poller answers with a refusal backoff for that Site's Lane (issue #100).
var errChallengeHeld = errors.New("challenge held")
// errBrowserInterrupted distinguishes a remote Chrome restart from the
@@ -228,11 +250,18 @@ func browserConnectionLost(ctx context.Context) bool {
const challengePollInterval = 2 * time.Second
// isInterstitial reports whether html is Cloudflare's challenge page rather
// than the site's own. Matched on the challenge runtime's script path, which is
// stable across the interstitial's wording and locale — the visible "Just a
// than the site's own. Matched on the challenge orchestration path
// (/cdn-cgi/challenge-platform/h/<b|g|x>/orchestrate/...), which is stable
// across the interstitial's wording and locale — the visible "Just a
// moment..." title is neither.
//
// The bare "/cdn-cgi/challenge-platform/" prefix is NOT enough: Cloudflare
// injects /cdn-cgi/challenge-platform/scripts/jsd/main.js into ordinary 200
// pages when JS detections are on, so matching the prefix declared every real
// demonic page a refusal and parked that Lane in 15m backoff (observed
// 2026-08-16, demonic turned detections on).
func isInterstitial(html string) bool {
return strings.Contains(html, "/cdn-cgi/challenge-platform/")
return strings.Contains(html, "/cdn-cgi/challenge-platform/h/")
}
// run navigates to target and re-reads until done reports an answer, bounded by
@@ -323,6 +352,19 @@ func novelfullSeriesURL(seriesURL string) bool {
strings.HasSuffix(u.Path, ".html")
}
// comixSeriesPageURL returns the address comixRead fetches inside the tab: the
// Series page itself, rebuilt from the pinned host and path so nothing else
// travels. Host-pinned here for the same reason kagane's is — series_url is
// client-supplied and a headless browser is a strong SSRF primitive.
func comixSeriesPageURL(seriesURL string) (string, bool) {
u, err := url.Parse(seriesURL)
if err != nil || u.Scheme != "https" || u.Hostname() != "comix.to" ||
!comixSeriesPathRe.MatchString(u.Path) {
return "", false
}
return "https://comix.to" + u.Path, true
}
// awaitPromise makes Evaluate resolve the promise rather than returning a
// serialised Promise object.
func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
+75
View File
@@ -61,6 +61,81 @@ func TestNovelfullSeriesURL(t *testing.T) {
})
}
}
func TestComixSeriesPageURL(t *testing.T) {
const series = "https://comix.to/title/n8we-dungeons-and-crayons"
cases := []struct {
name string
url string
want string
}{
{"series page", series, series},
{"trailing slash kept", series + "/", series + "/"},
// Query and fragment are dropped: only the pinned path travels.
{"query dropped", series + "?tab=chapters", series},
{"foreign host", "https://evil.example/title/x", ""},
{"lookalike host", "https://comix.to.evil.example/title/x", ""},
{"not https", "http://comix.to/title/x", ""},
{"not a series path", "https://comix.to/search", ""},
{"chapter page", series + "/11139891-chapter-80", ""},
{"garbage", "://nope", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := comixSeriesPageURL(tc.url)
if ok != (tc.want != "") || got != tc.want {
t.Fatalf("comixSeriesPageURL(%q) = %q, %v; want %q", tc.url, got, ok, tc.want)
}
})
}
}
// The browser is an SSRF primitive and a cover address can originate in a
// client-supplied PUT body, so this gate decides what it may navigate to.
func TestBrowserOnlyCoverURL(t *testing.T) {
cases := []struct {
url string
want bool
}{
{"https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg", true},
{"https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed", true},
// Every other Site's CDN answers plain TLS.
{"https://gg.asuracomic.net/covers/x.webp", false},
{"http://static.comix.to/039d/x.jpg", false},
{"https://static.comix.to.evil.example/039d/x.jpg", false},
{"https://evil.example/static.comix.to/x.jpg", false},
{"https://static.comix.to/039d/x.jpg?next=http://169.254.169.254/", false},
{"https://static.comix.to/039d/x.svg", false},
{"https://static.comix.to/../etc/passwd.jpg", false},
{"https://static.comix.to/", false},
}
for _, tc := range cases {
t.Run(tc.url, func(t *testing.T) {
if got := browserOnlyCoverURL(tc.url); got != tc.want {
t.Fatalf("browserOnlyCoverURL(%q) = %v, want %v", tc.url, got, tc.want)
}
})
}
}
// The jsd script is injected into ordinary 200 pages when a zone turns JS
// detections on; only the orchestration path means the page itself is the
// challenge. Conflating the two parked the demonic Lane in refusal backoff
// while every fetch was in fact the real series page (observed 2026-08-16).
func TestIsInterstitial(t *testing.T) {
if !isInterstitial(challengeFixture) {
t.Fatal("challenge page not detected as interstitial")
}
const jsdInjected = `<html><head><title>The Possessed Grappler</title>
<script src="/cdn-cgi/challenge-platform/scripts/jsd/main.js"></script></head>
<body><a href="/chaptered.php?manga=13721&chapter=22">Chapter 22</a></body></html>`
if isInterstitial(jsdInjected) {
t.Fatal("real page carrying the injected jsd script misread as interstitial")
}
if got, ok := demonicLatestChapter("", jsdInjected); !ok || got.Label != "Chapter 22" {
t.Fatalf("demonicLatestChapter = %+v, ok = %v, want Chapter 22", got, ok)
}
}
func TestClassifyBrowserInterruption(t *testing.T) {
if err := classifyBrowserError(context.Background(), true, context.Canceled); !errors.Is(err, errBrowserInterrupted) {
t.Fatalf("classifyBrowserError(context.Canceled) = %v, want browser interruption", err)
+2 -1
View File
@@ -25,7 +25,8 @@ type CoverBytesFetcher interface {
// fetchCoverBytes routes a cover's byte retrieval by URL shape, not by Site
// name: the browser fetcher's module claims the addresses only it can fetch
// (kagane's image route answers a plain fetch with a challenge and
// `cross-origin-resource-policy: same-origin`), and everything else goes over
// `cross-origin-resource-policy: same-origin`, static.comix.to answers one with
// the same challenge its pages serve), and everything else goes over
// plain TLS. Missing fetchers degrade to an error the caller logs, never a
// fallback onto a path that cannot succeed. One routing rule for the poll and
// the acquirer, so the two cannot drift apart.
+345 -77
View File
@@ -5,6 +5,8 @@ import (
"errors"
"log"
"net/url"
"sort"
"sync"
"time"
"bookmarkmanager/backend/internal/store"
@@ -17,8 +19,8 @@ type Fetcher interface {
}
// BrowserCoverFetcher retrieves one cover's bytes through the browser-backed
// path — the only route that clears the challenge kagane's image URLs answer
// a plain fetch with. Satisfied by BrowserFetcher.
// path — the only route that clears the challenge kagane's and comix's image
// URLs answer a plain fetch with. Satisfied by BrowserFetcher.
type BrowserCoverFetcher interface {
Image(ctx context.Context, imageURL string) (body []byte, contentType string, err error)
}
@@ -28,15 +30,11 @@ type BrowserCoverFetcher interface {
// in parallel and report the same observable fact, so whichever writes last wins
// and neither needs to know about the other.
//
// Two clocks, deliberately independent:
//
// - Interval is how often this goroutine wakes up and looks.
// - Cooldowns are how long a series rests since its own last check. Browser-
// backed sites use the longer BrowserCooldown.
//
// Cooldowns are enforced by the WHERE clause in DueForLatestCheck rather than
// by any timer. Shortening Interval therefore cannot shorten anyone's cooldown;
// it only makes the poller wake up and find nothing due more often.
// Every Site gets its own Poll Lane: one independent stream of Polls with its
// own pace, running concurrently with every other Site's (issue #100). Rest
// time and gap live in the Site registry, not here — see sites.go. Rest is
// enforced by the WHERE clause in DueForLatestCheck rather than by any timer;
// the gap is enforced by the Lane sleeping between fetches.
type Poller struct {
Store *store.Store
Fetch Fetcher
@@ -50,11 +48,23 @@ type Poller struct {
// same failure-isolated prefetch path.
CoverBytesFetch CoverBytesFetcher
Now func() time.Time // injected so tests can freeze it
Cooldown time.Duration
BrowserCooldown time.Duration
Interval time.Duration
Stagger time.Duration
Batch int
// refuseUntil gates a Site's Lane after it refused twice in one run: no
// Series of that Site is attempted again before this time (issue #100).
// browserDownAt is when a browser Lane last lost the sidecar; the other
// browser Lanes skip their passes for the next refuseBackoff, so a
// restarting Chrome does not stamp one Series per pass per Lane (story 20).
mu sync.Mutex
refuseUntil map[string]time.Time
browserDownAt time.Time
// laneStates is the owner's page snapshot of each Lane's last pass
// (issue #102), keyed by Site. Guarded by mu; a Site appears only after
// its first pass, so a restart renders "no data yet" rather than zeroes.
laneStates map[string]LaneState
// coverWG tracks in-flight cover work. Covers heal in the background so a
// slow cover host cannot delay the next Series-page Poll; tests join it
// before asserting on cover fetches.
coverWG sync.WaitGroup
}
// fillBlankCover gives a Series its Cover when it has none. The blank state is
@@ -76,7 +86,14 @@ func (p *Poller) fillBlankCover(ctx context.Context, sr store.Series, cover stri
if cover == "" {
return
}
p.storeCover(ctx, sr, cover)
// Like healCover, the fill runs in the background: a large import of
// blanks would otherwise pay one og:image fetch per Series against the
// Lane's gap (issue #100, story 12).
p.coverWG.Add(1)
go func() {
defer p.coverWG.Done()
p.storeCover(ctx, sr, cover)
}()
}
// prefetchCover heals Series that already carry a third-party source URL but
@@ -119,9 +136,9 @@ func (p *Poller) storeCover(ctx context.Context, sr store.Series, sourceURL stri
// fetcherFor returns the fetcher a site's page needs, or nil when the site
// cannot be fetched at all right now. A Site whose registry entry carries a
// Browser read — kagane and novelfull, both behind a Cloudflare JavaScript
// challenge no TLS fingerprint clears — prefers the browser; when it is
// absent, the entry's Fallback decides whether plain TLS may take over. One
// Browser read — kagane, comix and novelfull, all behind a Cloudflare
// JavaScript challenge no TLS fingerprint clears — prefers the browser; when it
// is absent, the entry's Fallback decides whether plain TLS may take over. One
// routing rule for the poll and the acquirer, so the two cannot drift apart.
func fetcherFor(site string, browser, tls Fetcher) Fetcher {
s, known := sites[site]
@@ -143,72 +160,259 @@ func fetcherFor(site string, browser, tls Fetcher) Fetcher {
return nil
}
// Run polls until ctx is cancelled.
//
// runOnce is called synchronously, so a batch that overruns the tick delays the
// next one instead of stacking a second batch on top of it. That is the intended
// failure mode for a misconfigured batch x stagger: a slower cadence, never
// concurrent fetch storms.
// Run polls until ctx is cancelled: one goroutine per Site Lane, each pacing
// itself by the Site's effective gap. Lanes share nothing but the store and
// the browser fetcher's single tab (BrowserFetcher serializes itself), so one
// hostile Site burns only its own budget.
// laneNames returns every registry Site in the deterministic order both Run
// and runOnce iterate: sorted, so lane behaviour and its tests agree on who
// runs first.
func laneNames() []string {
names := make([]string, 0, len(sites))
for name := range sites {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (p *Poller) Run(ctx context.Context) {
log.Printf("latest-chapter poller: interval=%s cooldown=%s browser-cooldown=%s batch=%d stagger=%s",
p.Interval, p.Cooldown, p.BrowserCooldown, p.Batch, p.Stagger)
t := time.NewTicker(p.Interval)
defer t.Stop()
names := laneNames()
log.Printf("latest-chapter poller: %d lanes, rest=%s gap=%s", len(names), defaultRest, defaultGap)
for _, name := range names {
go p.lane(ctx, name)
}
<-ctx.Done()
log.Println("latest-chapter poller: stopped")
}
// lane is one Site's Poll Lane: one pass, then sleep the pace the pass
// reported, then another pass, until ctx is cancelled. The sleep is the whole
// pace discipline — a pass that fetched nothing still reports its gap so the
// Lane wakes often enough to notice Series as they become due. The pass shares
// the Poller's browser-down state, so a sidecar loss is noticed once and the
// other browser Lanes skip passes until the backoff window decays.
func (p *Poller) lane(ctx context.Context, name string) {
for {
pace := p.runLanePass(ctx, name, true)
if ctx.Err() != nil {
return
}
select {
case <-ctx.Done():
log.Println("latest-chapter poller: stopped")
return
case <-t.C:
p.runOnce(ctx)
case <-time.After(pace):
}
}
}
// runOnce processes one batch of due series.
// runOnce processes one round: one pass of every Lane, back to back, no real
// time passing. This is the deterministic entry point the test suite drives a
// round at a time. The production Run loop does the same work paced by its own
// sleeps; pacing is the only difference.
func (p *Poller) runOnce(ctx context.Context) {
for _, name := range laneNames() {
p.runLanePass(ctx, name, false)
}
}
// runLanePass processes one pass of one Site's Lane: select the due Series,
// pace through them, and report how long the Lane should wait before its next
// pass. paced spaces consecutive fetches by the Site's effective gap — the
// production Lane's rate limit; the deterministic test entry runs back to back.
func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.Duration {
now := p.Now()
cutoff := now.Add(-p.Cooldown).UnixMilli()
browserCutoff := now.Add(-p.BrowserCooldown).UnixMilli()
due, err := p.Store.DueForLatestCheck(cutoff, browserCutoff, browserBackedSites(), p.Batch)
if err != nil {
log.Printf("latest poll: due query: %v", err)
return
// Snapshot this pass for the owner's page (issue #102). Recorded on every
// return path, with the figures filled in where the pass computes them.
st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)}
defer func() { p.recordLaneState(st) }()
if until := p.refusalBackoff(name); now.Before(until) {
// Cooling down after a refusal: do not attempt this Site at all.
return until.Sub(now)
}
if isBrowserSite(name) {
if downFor, down := p.browserDownFor(now); down && downFor < refuseBackoff {
// A sibling browser Lane lost the sidecar within the backoff
// window: skip this pass, so a restarting Chrome does not stamp
// this Site's Series one pass at a time. After refuseBackoff the
// flag decays and the Lane probes again (issue #100, story 20).
log.Printf("latest poll %s: browser lane skipping pass (sidecar down %s ago)", name, downFor)
return refuseBackoff - downFor
}
}
s := sites[name]
f := fetcherFor(name, p.BrowserFetch, p.Fetch)
if f == nil {
// No fetcher at all right now (browser absent, no fallback): every
// Series stays unstamped and due, so a browser that appears after a
// restart finds its full queue waiting (issue #100).
st.Gap = defaultGap
return defaultGap
}
checked := 0
due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(),
now.Add(-sightingCeilingRests*s.Rest).UnixMilli())
if err != nil {
log.Printf("latest poll %s: due query: %v", name, err)
st.Gap = defaultGap
return defaultGap
}
st.Due = len(due)
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
// browser): waking it for a single Poll would cost a challenge solve
// per request. The Lane still paces at the default gap, which is what
// the owner's page must show rather than a zero.
st.Gap = defaultGap
return defaultGap
}
if s.Browser != nil {
// Browser Lanes share one tab, so their combined ceiling is about 360
// Polls an hour. When they cannot keep up, the wait past the rest time
// grows — log by how much, every pass, so the decision to give them
// more pages is made from a measurement rather than a guess.
if behind := maxSeriesWait(due, now, s.Rest) - s.Rest; behind > 0 {
log.Printf("latest poll %s: browser lane behind by %s (browser Sites cannot keep up with the hour)", name, behind)
}
}
eligible, err := p.Store.EligibleSeriesCount(name)
if err != nil {
log.Printf("latest poll %s: eligible count: %v", name, err)
st.Gap = defaultGap
return defaultGap
}
gap, clamped := effectiveGap(s, eligible)
st.Gap, st.Clamped = gap, clamped
if clamped {
log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible)
}
if eligible == 0 {
// Nothing to poll for the foreseeable future; sleep a full rest instead
// of re-querying every gap.
return s.Rest
}
refusals := 0
for i, sr := range due {
if ctx.Err() != nil {
break
}
// Staggered rather than fired together: a burst of simultaneous requests
// from one server IP is the traffic shape most likely to move that IP's
// bot score. This is the server-side analogue of the userscript's "one
// series per navigation ... indistinguishable from browsing" (L455-456).
stopped := false
if i > 0 && p.Stagger > 0 {
select {
case <-ctx.Done():
stopped = true
case <-time.After(p.Stagger):
}
}
if stopped {
if refusals >= 2 {
// This Site refused twice in a row: the remaining Series are left
// unstamped and due, and the Lane waits refuseBackoff before
// trying it again.
break
}
p.checkOne(ctx, sr)
checked++
if paced && i > 0 {
select {
case <-ctx.Done():
break
case <-time.After(gap):
}
if ctx.Err() != nil {
break
}
}
if err := p.checkOne(ctx, sr); err != nil {
switch {
case errors.Is(err, errChallengeHeld):
refusals++
case errors.Is(err, errBrowserInterrupted):
p.setBrowserDown(now)
log.Printf("latest poll %s: browser unreachable, browser lanes skipping passes for %s", name, refuseBackoff)
return gap
default:
refusals = 0
}
} else {
refusals = 0
}
st.Checked++
}
// due vs checked is how you tell which constraint is binding: ticks that
// report due=0 mean the cooldown is the limit, ticks that report due==batch
// every time mean throughput is.
log.Printf("latest poll: due=%d checked=%d", len(due), checked)
if st.Checked > 0 {
log.Printf("latest poll %s: due=%d checked=%d", name, len(due), st.Checked)
}
if refusals >= 2 {
p.setRefusalBackoff(name, now.Add(refuseBackoff))
log.Printf("latest poll %s: refused twice this run, waiting %s", name, refuseBackoff)
return refuseBackoff
}
return gap
}
func (p *Poller) refusalBackoff(name string) time.Time {
p.mu.Lock()
defer p.mu.Unlock()
return p.refuseUntil[name]
}
func (p *Poller) setRefusalBackoff(name string, until time.Time) {
p.mu.Lock()
defer p.mu.Unlock()
if p.refuseUntil == nil {
p.refuseUntil = make(map[string]time.Time)
}
p.refuseUntil[name] = until
}
// setBrowserDown records when a browser Lane lost the sidecar. It is Poller
// state rather than pass state so the other browser Lanes see it too.
func (p *Poller) setBrowserDown(now time.Time) {
p.mu.Lock()
p.browserDownAt = now
p.mu.Unlock()
}
// browserDownFor reports how long the sidecar has been down and that it is
// down at all — the zero time means never down, which must not read as a
// zero-duration loss. The window decays: once refuseBackoff passes without a
// fresh loss, Lanes probe again.
func (p *Poller) browserDownFor(now time.Time) (time.Duration, bool) {
p.mu.Lock()
defer p.mu.Unlock()
if p.browserDownAt.IsZero() {
return 0, false
}
return now.Sub(p.browserDownAt), true
}
// isBrowserSite reports whether the registry routes this Site's page through
// the browser sidecar.
func isBrowserSite(name string) bool {
return sites[name].Browser != nil
}
// browserWakeDue reports whether a browser Lane may start a run: five or more
// of its Series are due, or any one of them has been due for browserWakeAge.
// Below both thresholds the Lane leaves Chrome asleep — Series Polled together
// become due together, so the group naturally stays clustered, and the age
// rule exists to stop a Series that drifted out of the group from starving.
func browserWakeDue(due []store.Series, now time.Time, rest time.Duration) bool {
if len(due) >= browserWakeCount {
return true
}
return maxSeriesWait(due, now, rest) >= browserWakeAge
}
// maxSeriesWait returns how long the most-overdue of the due Series has been
// waiting past its due moment (0 when due is empty).
func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.Duration {
var oldest time.Duration
for _, sr := range due {
if w := now.Sub(time.UnixMilli(sr.LatestCheckedAt).Add(rest)); w > oldest {
oldest = w
}
}
return oldest
}
// checkOne re-checks one series. Every failure path here is "log and move on":
// the poller is a best-effort enhancement, and no single bad series may stall a
// batch or take down the process.
func (p *Poller) checkOne(ctx context.Context, sr store.Series) {
// Lane or take down the process. The returned error is the page read's
// classified outcome so the Lane can tell a refusal from a loss of the
// browser; non-classified failures still return nil-equivalent behaviour.
func (p *Poller) checkOne(ctx context.Context, sr store.Series) error {
defer func() {
if r := recover(); r != nil {
log.Printf("latest poll %q: recovered from panic: %v", sr.Key(), r)
@@ -216,52 +420,59 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) {
}()
// Stamped before the fetch, not after, so an error, a timeout, or a shutdown
// mid-request still consumes the cooldown. Otherwise a renamed or deleted
// series would be retried on every single tick forever. The userscript
// stamps in the same order and for the same reason (L471-473).
// mid-request still consumes the rest. Otherwise a renamed or deleted
// series would be retried on every single pass forever. The userscript
// stamps in the same order and for the same reason (L471-473). A Series
// never reaches checkOne without a fetcher — runLanePass skips those — so
// the stamp means "attempted", and an untried Series stays due.
if err := p.Store.MarkLatestChecked(sr.Site, sr.SeriesID, p.Now().UnixMilli()); err != nil {
log.Printf("latest poll %q: mark checked: %v", sr.Key(), err)
return
return nil
}
facts, err := readSeriesPage(ctx, sr.Site, sr.SeriesURL, p.BrowserFetch, p.Fetch)
if err != nil {
switch {
case errors.Is(err, errNotFetchable):
// The cooldown above is already consumed, so a row that never
// passes the gate is retried at cooldown pace rather than
// The rest above is already consumed, so a row that never
// passes the gate is retried at rest pace rather than
// hot-looping.
log.Printf("latest poll %q: not fetchable: site=%q url=%q", sr.Key(), sr.Site, sr.SeriesURL)
return
return err
case errors.Is(err, errNoFetcher):
log.Printf("latest poll %q: no fetcher for site %q", sr.Key(), sr.Site)
return
return err
}
// A legacy cover heals independently of the page read: its source may
// answer — a CDN — while the origin does not, so a fetch failure does
// not skip the heal, matching the order the shared read replaced.
p.prefetchCover(ctx, sr)
p.healCover(ctx, sr)
log.Printf("latest poll %q: %v", sr.Key(), err)
return
return err
}
// A legacy cover source is healed independently of the page read.
p.prefetchCover(ctx, sr)
p.healCover(ctx, sr)
// Cover fill is independent of the chapter signal: a page that lost its
// chapter list may keep its og:image, and a blank Series heals either way.
p.fillBlankCover(ctx, sr, facts.Cover)
if !facts.HasLatest {
// Most likely a challenge page or a layout change. Either way the row is
// already stamped, so this waits out a cooldown instead of hot-looping.
// already stamped, so this waits out a rest instead of hot-looping.
log.Printf("latest poll %q: no chapter links in %d bytes", sr.Key(), facts.BodyLen)
return
return nil
}
// The Poll is the oracle for whatever Sighting last raised this Series
// (issue #103), and the judgement is free: the comparison below already
// exists, and no extra request is made to reach it.
p.judgeSighting(sr, facts.Latest.Num)
// Equality, not >, mirroring the userscript (L427): a site that retracts a
// chapter should correct the stored number downward. The comparison is
// against the due-query snapshot; a concurrent write in between only costs
// one redundant UPDATE of the same absolute value, never a wrong one.
if sr.LatestChapterNum != nil && *sr.LatestChapterNum == facts.Latest.Num {
return
return nil
}
// Series-level write: the row is shared, so one update refreshes every
@@ -270,9 +481,66 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) {
// the list.
if err := p.Store.SetLatestChapter(sr.Site, sr.SeriesID, facts.Latest.Label, facts.Latest.Num); err != nil {
log.Printf("latest poll %q: set latest chapter: %v", sr.Key(), err)
return
return nil
}
log.Printf("latest poll %q: latest is now %s", sr.Key(), facts.Latest.Label)
return nil
}
// judgeSighting settles the Sighting the Series' stored Latest Chapter is owed
// to, if any, against what the Site actually publishes. The asymmetry is the
// whole of the detection rule and is what keeps it free of false alarms: a Poll
// finding a *lower* number than stored means the Reader who raised it reported
// a chapter that does not exist, while a Poll finding a higher one is only the
// Site publishing since and means nothing about the report. Equality confirms
// the report, which is how an honest Reader earns back a mark.
//
// A Series with no attribution — the stored value is a Poll's own, or a
// previous Poll already judged the report — is nobody's to answer for.
func (p *Poller) judgeSighting(sr store.Series, found float64) {
if sr.LatestRaisedBy == nil || sr.LatestChapterNum == nil {
return
}
stored := *sr.LatestChapterNum
if found > stored {
// The report is neither confirmed nor contradicted, but it is answered:
// the value about to be stored is the Poll's own, so leaving the
// attribution would credit this Reader with the next Poll's agreement
// and blame them if the Site later retracts.
if err := p.Store.ClearSightingAttribution(sr.Site, sr.SeriesID, *sr.LatestRaisedBy); err != nil {
log.Printf("latest poll %q: clear sighting attribution: %v", sr.Key(), err)
}
return
}
if found < stored {
// Logged with both numbers and the Reader, because that is what tells a
// broken Site adapter (which marks every Reader of that Site at once)
// from one Reader deliberately lying.
log.Printf("latest poll %q: sighting contradicted: reader %d raised it to %v, site publishes %v",
sr.Key(), *sr.LatestRaisedBy, stored, found)
}
if err := p.Store.RecordSightingOutcome(sr.Site, sr.SeriesID, *sr.LatestRaisedBy, found == stored); err != nil {
log.Printf("latest poll %q: record sighting outcome: %v", sr.Key(), err)
}
}
// healCover runs prefetchCover in the background. Cover bytes come from a
// different host — often a CDN — and heal once in a Series's life, so they
// must not consume a Lane's gap: a large import with many blanks would
// otherwise make every Latest Chapter go stale behind a slow image host
// (issue #100).
func (p *Poller) healCover(ctx context.Context, sr store.Series) {
p.coverWG.Add(1)
go func() {
defer p.coverWG.Done()
p.prefetchCover(ctx, sr)
}()
}
// waitCovers blocks until every in-flight cover heal finishes. Tests call it
// after a round before asserting on cover fetches.
func (p *Poller) waitCovers() {
p.coverWG.Wait()
}
// fetchableSeriesURL reports whether site is a Site the registry knows and
+567 -108
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
@@ -156,19 +157,16 @@ func (f *fakeBytesCoverFetcher) callCount() int {
return len(f.calls)
}
// newTestPoller wires a poller with a frozen clock and no stagger, so tests run
// instantly and deterministically.
// newTestPoller wires a poller with a frozen clock. Rest and gap come from the
// Site registry, so tests seed checked_at relative to the one-hour rest; the
// round entry point (runOnce) runs every Lane back to back with no real
// pacing, so tests stay instant and deterministic.
func newTestPoller(t *testing.T, s *store.Store, f Fetcher, at time.Time) *Poller {
t.Helper()
return &Poller{
Store: s,
Fetch: f,
Now: func() time.Time { return at },
Cooldown: time.Hour,
BrowserCooldown: 6 * time.Hour,
Interval: 10 * time.Minute,
Stagger: 0,
Batch: 14,
Store: s,
Fetch: f,
Now: func() time.Time { return at },
}
}
@@ -208,9 +206,10 @@ func TestRunOncePrefetchesPublicCover(t *testing.T) {
covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
p := &Poller{
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture, status: 200}, CoverBytesFetch: covers,
Now: func() time.Time { return time.UnixMilli(5_000_000) }, Cooldown: time.Hour, Batch: 10,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
p.waitCovers()
if got := covers.callCount(); got != 1 {
t.Fatalf("cover fetch calls = %d, want 1", got)
@@ -241,9 +240,10 @@ func TestRunOnceDoesNotStoreNonImagePublicCover(t *testing.T) {
p := &Poller{
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture, status: 200},
CoverBytesFetch: &fakeBytesCoverFetcher{body: []byte("challenge"), contentType: "text/html"},
Now: func() time.Time { return time.UnixMilli(5_000_000) }, Cooldown: time.Hour, Batch: 10,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
p.waitCovers()
if _, _, found, err := s.GetCover(coverURL); err != nil || found {
t.Fatalf("non-image cover = found %v, err %v; want missing", found, err)
@@ -350,23 +350,6 @@ func TestRunOnceMarksCheckedOnFailure(t *testing.T) {
}
}
func TestRunOnceRespectsBatchLimit(t *testing.T) {
s, _ := newTestStore(t)
for i := 0; i < 20; i++ {
key := "asura:s" + string(rune('a'+i))
seedForCheck(t, s, key, "https://asurascans.com/comics/"+key, 0)
}
f := &fakeFetcher{body: "", status: 200}
p := newTestPoller(t, s, f, time.UnixMilli(5_000_000))
p.Batch = 5
p.runOnce(context.Background())
if got := f.callCount(); got != 5 {
t.Fatalf("fetched %d series, want 5 (batch limit)", got)
}
}
// The point of the split (ADR-0003): a series referenced by several bookmarks
// is fetched once per due cycle, not once per bookmark. Two bookmarks share a
// series when two readers track it (issue #22).
@@ -409,8 +392,8 @@ func TestRunOnceFetchesSharedSeriesOnce(t *testing.T) {
}
}
// One unreachable series must not abandon the rest of the batch.
func TestRunOnceOneBadSeriesDoesNotStallBatch(t *testing.T) {
// One unreachable series must not abandon the rest of the Lane.
func TestRunOnceOneBadSeriesDoesNotStallLane(t *testing.T) {
s, _ := newTestStore(t)
keys := []string{"asura:a", "asura:b", "asura:c", "asura:d", "asura:e"}
for _, k := range keys {
@@ -436,7 +419,9 @@ func TestRunOnceOneBadSeriesDoesNotStallBatch(t *testing.T) {
}
}
func TestRunLogsCooldowns(t *testing.T) {
// Pace now lives in the registry, not config: Run logs the lane defaults so a
// deployment can see what the poller is doing without reading the source.
func TestRunLogsLaneDefaults(t *testing.T) {
var logs strings.Builder
previous := log.Writer()
log.SetOutput(&logs)
@@ -444,21 +429,19 @@ func TestRunLogsCooldowns(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
(&Poller{
Cooldown: time.Hour,
BrowserCooldown: 6 * time.Hour,
Interval: time.Hour,
}).Run(ctx)
(&Poller{Now: func() time.Time { return time.Now() }}).Run(ctx)
if got := logs.String(); !strings.Contains(got, "cooldown=1h") ||
!strings.Contains(got, "browser-cooldown=6h") {
t.Fatalf("startup log = %q, want both cooldowns", got)
got := logs.String()
for _, want := range []string{"6 lanes", "rest=1h0m0s", "gap=10s"} {
if !strings.Contains(got, want) {
t.Fatalf("startup log = %q, want %q", got, want)
}
}
}
// The cooldown is enforced by the due query, so a second immediate pass must do
// nothing at all — this is what makes the tick interval independent of it.
func TestRunOnceHonoursCooldownAcrossPasses(t *testing.T) {
// The rest is enforced by the due query, so a second immediate pass must do
// nothing at all — this is what makes the Lane's sleep independent of it.
func TestRunOnceHonoursRestAcrossPasses(t *testing.T) {
s, _ := newTestStore(t)
const url = "https://asurascans.com/comics/x"
seedForCheck(t, s, "asura:x", url, 0)
@@ -471,56 +454,18 @@ func TestRunOnceHonoursCooldownAcrossPasses(t *testing.T) {
if got := f.callCount(); got != 1 {
t.Fatalf("first pass fetched %d, want 1", got)
}
// Same instant, and again 59 minutes later: both inside the 1h cooldown.
// Same instant, and again 59 minutes later: both inside the 1h rest.
p.runOnce(context.Background())
p.Now = func() time.Time { return now.Add(59 * time.Minute) }
p.runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("fetched %d times inside the cooldown, want 1", got)
t.Fatalf("fetched %d times inside the rest, want 1", got)
}
// Past the cooldown, it is due again.
// Past the rest, it is due again.
p.Now = func() time.Time { return now.Add(61 * time.Minute) }
p.runOnce(context.Background())
if got := f.callCount(); got != 2 {
t.Fatalf("fetched %d times after the cooldown, want 2", got)
}
}
func TestRunOnceUsesBrowserCooldown(t *testing.T) {
s, _ := newTestStore(t)
const browserKey = "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
seedForCheck(t, s, "asura:plain", "https://asurascans.com/comics/plain", 0)
seedForCheck(t, s, browserKey, "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b", 0)
hour := time.Hour
now := time.Unix(2*int64(hour/time.Second), 0)
tls := &fakeFetcher{status: 200}
browser := &fakeFetcher{status: 200}
p := &Poller{
Store: s,
Fetch: tls,
BrowserFetch: browser,
Now: func() time.Time { return now },
Cooldown: hour,
BrowserCooldown: 6 * hour,
Batch: 10,
}
p.runOnce(context.Background())
if got := tls.callCount(); got != 1 {
t.Fatalf("plain-TLS fetches after 2h = %d, want 1", got)
}
if got := browser.callCount(); got != 0 {
t.Fatalf("browser fetches after 2h = %d, want 0", got)
}
now = time.Unix(7*int64(hour/time.Second), 0)
p.runOnce(context.Background())
if got := tls.callCount(); got != 2 {
t.Fatalf("plain-TLS fetches after 7h = %d, want 2", got)
}
if got := browser.callCount(); got != 1 {
t.Fatalf("browser fetches after 7h = %d, want 1", got)
t.Fatalf("fetched %d times after the rest, want 2", got)
}
}
@@ -665,17 +610,18 @@ func TestKaganeSkippedWhenNoBrowserFetcher(t *testing.T) {
f := &fakeFetcher{body: kaganeAPIFixture, status: 200}
p := &Poller{
Store: s,
Fetch: f,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
Cooldown: time.Hour, BrowserCooldown: time.Hour,
Interval: time.Hour, Batch: 10,
Store: s,
Fetch: f,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
if len(f.calls) != 0 {
t.Errorf("TLS fetcher was called for kagane: %v", f.calls)
}
if got := readLatestCheckedAt(t, s, "kagane:019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"); got != 0 {
t.Errorf("latest_checked_at = %d, want 0 (untried stays due until a browser appears)", got)
}
}
// novelfull without a browser is not skipped outright: its challenge is a
@@ -699,11 +645,10 @@ func TestNovelfullUsesTLSWhenNoBrowserFetcher(t *testing.T) {
covers := &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"}
p := &Poller{
Store: s, Fetch: tlsF, CoverBytesFetch: covers,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
Cooldown: time.Hour, BrowserCooldown: time.Hour,
Interval: time.Hour, Batch: 10,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
p.waitCovers()
if len(tlsF.calls) != 1 {
t.Fatalf("TLS fetcher calls = %d, want 1", len(tlsF.calls))
@@ -741,9 +686,7 @@ func TestKaganeUsesBrowserFetcher(t *testing.T) {
browserF := &fakeFetcher{body: kaganeAPIFixture, status: 200}
p := &Poller{
Store: s, Fetch: tlsF, BrowserFetch: browserF,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
Cooldown: time.Hour, BrowserCooldown: time.Hour,
Interval: time.Hour, Batch: 10,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
@@ -762,6 +705,86 @@ func TestKaganeUsesBrowserFetcher(t *testing.T) {
}
}
// comix joined kagane behind the challenge on 2026-08-12 (#98): its page goes
// to the browser, its Cover bytes go through the browser's image route because
// static.comix.to is gated the same way, and the TLS fetcher is never asked
// for either.
func TestComixUsesBrowserFetcher(t *testing.T) {
s, dbURL := newTestStore(t)
const (
key = "comix:n8we-dungeons-and-crayons"
seriesID = "n8we-dungeons-and-crayons"
seriesURL = "https://comix.to/title/n8we-dungeons-and-crayons"
coverURL = "https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg"
)
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
Key: key, Site: "comix", SeriesID: seriesID, SeriesURL: seriesURL,
UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
seedCoverSource(t, dbURL, "comix", seriesID, coverURL)
tlsF := &fakeFetcher{body: "", status: 200}
browserF := &fakeFetcher{body: comixSeriesFixture, status: 200}
covers := &fakeCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"}
tlsCovers := &fakeBytesCoverFetcher{body: []byte("tls-bytes"), contentType: "image/jpeg"}
p := &Poller{
Store: s, Fetch: tlsF, BrowserFetch: browserF,
CoverFetch: covers, CoverBytesFetch: tlsCovers,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
p.waitCovers()
if len(tlsF.calls) != 0 {
t.Errorf("TLS fetcher was called for comix: %v", tlsF.calls)
}
if len(browserF.calls) != 1 {
t.Fatalf("browser fetcher calls = %v, want 1", browserF.calls)
}
if got := tlsCovers.callCount(); got != 0 {
t.Errorf("TLS cover fetches = %d, want 0: static.comix.to answers a challenge", got)
}
if got := covers.callCount(); got != 1 {
t.Fatalf("browser cover fetches = %d, want 1", got)
}
got, found, err := s.Get(s.OwnerID(), key)
if err != nil || !found {
t.Fatalf("Get: %v found=%v", err, found)
}
if got.LatestChapterNum == nil || *got.LatestChapterNum != 80 {
t.Errorf("LatestChapterNum = %v, want 80", got.LatestChapterNum)
}
}
// Without a browser, comix is skipped outright rather than handed to plain
// TLS: a plain fetch retrieves only a challenge page (measured 2026-08-12).
func TestComixSkippedWhenNoBrowserFetcher(t *testing.T) {
s, _ := newTestStore(t)
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
Key: "comix:n8we-dungeons-and-crayons", Site: "comix",
SeriesID: "n8we-dungeons-and-crayons",
SeriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
f := &fakeFetcher{body: comixSeriesFixture, status: 200}
p := &Poller{
Store: s, Fetch: f,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
if len(f.calls) != 0 {
t.Errorf("TLS fetcher was called for comix: %v", f.calls)
}
if got := readLatestCheckedAt(t, s, "comix:n8we-dungeons-and-crayons"); got != 0 {
t.Errorf("latest_checked_at = %d, want 0 (untried stays due until a browser appears)", got)
}
}
func TestRunOncePrefetchesKaganeCover(t *testing.T) {
s, dbURL := newTestStore(t)
const (
@@ -780,10 +803,10 @@ func TestRunOncePrefetchesKaganeCover(t *testing.T) {
p := &Poller{
Store: s, Fetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200}, CoverFetch: covers,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
Cooldown: time.Hour, BrowserCooldown: time.Hour, Batch: 10,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
p.waitCovers()
body, contentType, ok, err := s.CoverByAddress(store.CoverAddress(coverURL))
if err != nil || !ok {
@@ -818,11 +841,13 @@ func TestRunOnceDoesNotRefetchKaganeCover(t *testing.T) {
covers := &fakeCoverFetcher{body: []byte("cover-bytes"), contentType: "image/webp"}
p := &Poller{
Store: s, BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200}, CoverFetch: covers,
Now: func() time.Time { return at }, Cooldown: time.Hour, BrowserCooldown: time.Hour, Batch: 10,
Now: func() time.Time { return at },
}
p.runOnce(context.Background())
p.waitCovers()
at = at.Add(2 * time.Hour)
p.runOnce(context.Background())
p.waitCovers()
if got := covers.callCount(); got != 1 {
t.Fatalf("cover fetch calls = %d, want 1 after two due cycles", got)
@@ -847,9 +872,10 @@ func TestRunOnceCoverFailureDoesNotBlockChapter(t *testing.T) {
p := &Poller{
Store: s, BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
CoverFetch: &fakeCoverFetcher{err: errors.New("browser unavailable")},
Now: func() time.Time { return now }, Cooldown: time.Hour, BrowserCooldown: time.Hour, Batch: 10,
Now: func() time.Time { return now },
}
p.runOnce(context.Background())
p.waitCovers()
got, found, err := s.Get(s.OwnerID(), key)
if err != nil || !found {
@@ -880,9 +906,10 @@ func TestRunOnceRejectsInvalidKaganeCover(t *testing.T) {
p := &Poller{
Store: s, BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
CoverFetch: &fakeCoverFetcher{body: []byte("not an image"), contentType: "text/html"},
Now: func() time.Time { return time.UnixMilli(5_000_000) }, Cooldown: time.Hour, BrowserCooldown: time.Hour, Batch: 10,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
p.waitCovers()
if _, _, found, err := s.CoverByAddress(store.CoverAddress(coverURL)); err != nil || found {
t.Fatalf("invalid cover persisted = %v, err %v; want missing", found, err)
@@ -905,9 +932,10 @@ func TestRunOnceWithoutCoverFetcherStillPollsKagane(t *testing.T) {
seedCoverSource(t, dbURL, "kagane", seriesID, coverURL)
p := &Poller{
Store: s, BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
Now: func() time.Time { return time.UnixMilli(5_000_000) }, Cooldown: time.Hour, BrowserCooldown: time.Hour, Batch: 10,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
p.waitCovers()
if _, _, found, err := s.CoverByAddress(store.CoverAddress(coverURL)); err != nil || found {
t.Fatalf("cover after nil CoverFetch = found %v, err %v; want missing", found, err)
@@ -930,9 +958,10 @@ func TestRunOnceRoutesNonKaganeCoverToPublicFetcher(t *testing.T) {
p := &Poller{
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture, status: 200}, CoverFetch: browserCovers,
CoverBytesFetch: publicCovers,
Now: func() time.Time { return time.UnixMilli(5_000_000) }, Cooldown: time.Hour, BrowserCooldown: time.Hour, Batch: 10,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
p.waitCovers()
if got := publicCovers.callCount(); got != 1 {
t.Fatalf("public cover fetch calls = %d, want 1", got)
@@ -1048,10 +1077,10 @@ func TestRunOnceFillsBlankCoverFromSeriesPage(t *testing.T) {
p := &Poller{
Store: s, Fetch: page, BrowserFetch: page,
CoverBytesFetch: public, CoverFetch: browser,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
Cooldown: time.Hour, BrowserCooldown: time.Hour, Batch: 10,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
p.runOnce(context.Background())
p.waitCovers()
got := readBookmark(t, s, tc.key)
wantWire := testCoverBaseURL + "/covers/" + store.CoverAddress(tc.wantCover)
@@ -1101,7 +1130,7 @@ func TestRunOnceDoesNotReplaceExistingCover(t *testing.T) {
p := &Poller{
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
CoverBytesFetch: public,
Now: func() time.Time { return at }, Cooldown: time.Hour, Batch: 10,
Now: func() time.Time { return at },
}
p.runOnce(context.Background())
at = at.Add(2 * time.Hour)
@@ -1136,9 +1165,10 @@ func TestRunOnceRetriesFailedBlankCoverOnNextPoll(t *testing.T) {
p := &Poller{
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
CoverBytesFetch: public,
Now: func() time.Time { return at }, Cooldown: time.Hour, Batch: 10,
Now: func() time.Time { return at },
}
p.runOnce(context.Background())
p.waitCovers()
if got := readBookmark(t, s, key); got.Cover != "" {
t.Fatalf("Cover after failed fetch = %q, want blank", got.Cover)
}
@@ -1151,6 +1181,7 @@ func TestRunOnceRetriesFailedBlankCoverOnNextPoll(t *testing.T) {
public.contentType = "image/jpeg"
at = at.Add(2 * time.Hour)
p.runOnce(context.Background())
p.waitCovers()
if got := public.callCount(); got != 2 {
t.Fatalf("cover fetch calls after retry = %d, want 2", got)
@@ -1184,9 +1215,10 @@ func TestRunOnceBlankCoverFailureDoesNotBlockChapter(t *testing.T) {
p := &Poller{
Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200},
CoverBytesFetch: &fakeBytesCoverFetcher{err: errors.New("cdn down")},
Now: func() time.Time { return now }, Cooldown: time.Hour, Batch: 10,
Now: func() time.Time { return now },
}
p.runOnce(context.Background())
p.waitCovers()
got := readBookmark(t, s, key)
if got.LatestChapterNum == nil || *got.LatestChapterNum != 181 {
@@ -1209,3 +1241,430 @@ const kaganeAPIFixtureWithCover = `
{"book_id":"b","title":"Episode 41","chapter_no":"41","sort_no":41},
{"book_id":"c","title":"Episode 40.5","chapter_no":"40.5","sort_no":40}]}
`
func TestEffectiveGap(t *testing.T) {
asura := sites["asura"]
tests := []struct {
eligible int
want time.Duration
clamped bool
}{
{0, 10 * time.Second, false},
{5, 10 * time.Second, false},
{360, 10 * time.Second, false},
{720, 5 * time.Second, false},
{3600, time.Second, false},
{4000, time.Second, true},
}
for _, tt := range tests {
got, clamped := effectiveGap(asura, tt.eligible)
if got != tt.want || clamped != tt.clamped {
t.Errorf("effectiveGap(asura, %d) = (%s, %v), want (%s, %v)",
tt.eligible, got, clamped, tt.want, tt.clamped)
}
}
}
// The due query orders by sharedness first, then age: a series two readers
// track is polled before a single-reader series that has waited far longer
// (ADR-0003, issue #100).
func TestRunOnceOrdersBySharednessThenAge(t *testing.T) {
s, url := newTestStore(t)
other, err := store.Open(url, store.Owner{DiscordID: "second-reader", TokenHash: sha256.Sum256([]byte("second-token-hash"))}, t.TempDir(), testCoverBaseURL)
if err != nil {
t.Fatalf("Open second reader: %v", err)
}
t.Cleanup(func() { other.Close() })
now := time.UnixMilli(5_000_000)
// popular: two readers, due for 2 minutes. loner: one reader, due for 23
// minutes. Popularity must win — the loner waited far longer.
const (
popularKey = "asura:popular"
lonerKey = "asura:loner"
popularURL = "https://asurascans.com/comics/popular"
lonerURL = "https://asurascans.com/comics/loner"
)
seedForCheck(t, s, popularKey, popularURL, now.Add(-62*time.Minute).UnixMilli())
seedForCheck(t, s, lonerKey, lonerURL, 0)
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
Key: popularKey, Site: "asura", SeriesID: "popular", UpdatedAt: 2000,
}); err != nil {
t.Fatalf("seed second reader: %v", err)
}
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
newTestPoller(t, s, f, now).runOnce(context.Background())
if len(f.calls) != 2 {
t.Fatalf("fetches = %d, want 2", len(f.calls))
}
if f.calls[0] != popularURL {
t.Fatalf("first fetch = %q, want the shared series %q", f.calls[0], popularURL)
}
}
// Two refusals in one pass stop the Lane: the remaining Series stay unstamped
// and due, and the Lane backs off for refuseBackoff before trying the Site
// again. One hostile Site burns only its own Lane's budget (issue #100).
func TestRunOnceSiteRefusalSkipsRestOfLaneAndBacksOff(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(5_000_000)
const n = 4
for i := 0; i < n; i++ {
key := fmt.Sprintf("kagane:s%d", i)
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0)
}
browser := &fakeFetcher{status: 403}
tls := &fakeFetcher{status: 200}
// An asura Series sits on its own Lane: it must still be polled while
// kagane's Lane burns its budget on refusals (story 4).
seedForCheck(t, s, "asura:still-polls", "https://asurascans.com/series/still-polls", 0)
p := &Poller{
Store: s, Fetch: tls, BrowserFetch: browser,
Now: func() time.Time { return now },
}
p.runOnce(context.Background())
if got := browser.callCount(); got != 2 {
t.Fatalf("browser fetches after first pass = %d, want 2 (refused twice)", got)
}
if got := tls.callCount(); got != 1 {
t.Fatalf("asura fetches after first pass = %d, want 1 (its Lane is independent)", got)
}
stamped := 0
for i := 0; i < n; i++ {
if readLatestCheckedAt(t, s, fmt.Sprintf("kagane:s%d", i)) == now.UnixMilli() {
stamped++
}
}
if stamped != 2 {
t.Fatalf("stamped series = %d, want 2; the remaining two stay due", stamped)
}
// Inside the backoff window nothing is attempted.
p.Now = func() time.Time { return now.Add(14 * time.Minute) }
p.runOnce(context.Background())
if got := browser.callCount(); got != 2 {
t.Fatalf("browser fetches inside backoff = %d, want still 2", got)
}
// Past the backoff the Lane resumes and the two untried Series are polled.
p.Now = func() time.Time { return now.Add(16 * time.Minute) }
p.runOnce(context.Background())
if got := browser.callCount(); got != 4 {
t.Fatalf("browser fetches after backoff = %d, want 4", got)
}
for i := 0; i < n; i++ {
if got := readLatestCheckedAt(t, s, fmt.Sprintf("kagane:s%d", i)); got == 0 {
t.Fatalf("kagane:s%d still untried after backoff", i)
}
}
}
// Below five due Series with none waiting long, a browser Lane leaves Chrome
// asleep; five due, or one waiting browserWakeAge, wakes it (ADR-0005).
func TestBrowserLaneWakeThresholds(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(5_000_000)
// Freshly due: checked two minutes before the rest elapses, so the wait
// is far below browserWakeAge.
seed := func(i int) {
key := fmt.Sprintf("kagane:w%d", i)
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], now.Add(-62*time.Minute).UnixMilli())
}
for i := 0; i < 3; i++ {
seed(i)
}
browser := &fakeFetcher{body: kaganeAPIFixture, status: 200}
p := &Poller{
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
Now: func() time.Time { return now },
}
p.runOnce(context.Background())
if got := browser.callCount(); got != 0 {
t.Fatalf("browser fetches with 3 freshly-due series = %d, want 0 (Chrome stays asleep)", got)
}
// 5 due crosses the count threshold.
for i := 3; i < 5; i++ {
seed(i)
}
p.runOnce(context.Background())
if got := browser.callCount(); got != 5 {
t.Fatalf("browser fetches with 5 due series = %d, want 5", got)
}
// A single long-neglected series wakes the browser by age alone.
seedForCheck(t, s, "kagane:ancient", "https://kagane.to/series/ancient", 0)
p.runOnce(context.Background())
if got := browser.callCount(); got != 6 {
t.Fatalf("browser fetches with one ancient series = %d, want 6", got)
}
}
// When one browser Lane loses the sidecar, the round's remaining browser
// Lanes are skipped: every fetch would fail anyway, and their Series must not
// burn their stamps on a dead Chrome (issue #100).
func TestRunOnceUnreachableBrowserStopsBrowserLanes(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(5_000_000)
seedForCheck(t, s, "comix:c", "https://comix.to/title/c", 0)
seedForCheck(t, s, "kagane:k", "https://kagane.to/series/k", 0)
seedForCheck(t, s, "novelfull:n", "https://novelfull.com/n.html", 0)
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
browser := &fakeFetcher{status: 200, err: interrupted}
p := &Poller{
Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser,
Now: func() time.Time { return now },
}
p.runOnce(context.Background())
// Sorted lane order: comix, kagane, novelfull. Only comix attempted.
if got := browser.callCount(); got != 1 {
t.Fatalf("browser fetches = %d, want 1 (only the first browser lane)", got)
}
if got := readLatestCheckedAt(t, s, "comix:c"); got != now.UnixMilli() {
t.Fatalf("comix stamp = %d, want %d", got, now.UnixMilli())
}
for _, key := range []string{"kagane:k", "novelfull:n"} {
if got := readLatestCheckedAt(t, s, key); got != 0 {
t.Fatalf("%s stamp = %d, want 0 (untried)", key, got)
}
}
// The shared flag decays after refuseBackoff: the next round probes
// again. comix's Series is resting (stamped last round), so the probe
// falls to kagane — the only Lane with something due — and its fresh
// loss re-gates the Lanes behind it.
p.Now = func() time.Time { return now.Add(16 * time.Minute) }
p.runOnce(context.Background())
if got := browser.callCount(); got != 2 {
t.Fatalf("browser fetches after backoff decay = %d, want 2 (kagane probes again)", got)
}
if got := readLatestCheckedAt(t, s, "comix:c"); got != now.UnixMilli() {
t.Fatalf("comix stamp after decay = %d, want %d (resting, untouched)", got, now.UnixMilli())
}
if got := readLatestCheckedAt(t, s, "kagane:k"); got != now.Add(16*time.Minute).UnixMilli() {
t.Fatalf("kagane stamp after decay = %d, want %d (the probe)", got, now.Add(16*time.Minute).UnixMilli())
}
if got := readLatestCheckedAt(t, s, "novelfull:n"); got != 0 {
t.Fatalf("novelfull stamp after decay = %d, want 0 (gated by kagane's fresh loss)", got)
}
}
// A browser Lane that cannot keep up logs the backlog every pass, so the
// decision to give browser Sites more pages is measured, not guessed.
func TestRunOnceLogsBrowserLaneBehind(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(5_000_000)
// Checked three rests ago: two rests' worth of wait past the due moment.
seedForCheck(t, s, "kagane:old", "https://kagane.to/series/old", now.Add(-3*time.Hour).UnixMilli())
var logs strings.Builder
prev := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(prev) })
p := &Poller{
Store: s, Fetch: &fakeFetcher{status: 200},
BrowserFetch: &fakeFetcher{body: kaganeAPIFixture, status: 200},
Now: func() time.Time { return now },
}
p.runOnce(context.Background())
if got := logs.String(); !strings.Contains(got, "latest poll kagane: browser lane behind by 1h0m0s") {
t.Fatalf("behind log = %q, want it to name kagane and the backlog", got)
}
}
// gatedCoverFetcher blocks every Fetch on a gate, so a test can hold a cover
// heal in flight and prove a Lane does not wait for it.
type gatedCoverFetcher struct {
inner *fakeBytesCoverFetcher
gate chan struct{}
started chan struct{}
once sync.Once
}
func (g *gatedCoverFetcher) Fetch(ctx context.Context, sourceURL string) ([]byte, string, error) {
g.once.Do(func() { g.started <- struct{}{} })
<-g.gate
return g.inner.Fetch(ctx, sourceURL)
}
// Cover heals run in the background: a heal stuck on a slow CDN must not
// delay the Lane's next Series-page Poll, or a large import with many blanks
// would make every Latest Chapter go stale (issue #100).
func TestRunOnceCoverFetchDoesNotDelayNextPoll(t *testing.T) {
s, dbURL := newTestStore(t)
// Two Series whose legacy cover sources still need healing.
for i := 0; i < 2; i++ {
key := fmt.Sprintf("asura:cover-%d", i)
seriesID := fmt.Sprintf("cover-%d", i)
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
Key: key, Site: "asura", SeriesID: seriesID,
SeriesURL: "https://asurascans.com/comics/" + seriesID, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
seedCoverSource(t, dbURL, "asura", seriesID, fmt.Sprintf("https://cdn.example/covers/%d.jpg", i))
}
pages := &fakeFetcher{body: asuraSeriesFixture, status: 200}
gated := &gatedCoverFetcher{
inner: &fakeBytesCoverFetcher{body: []byte("cover-bytes"), contentType: "image/jpeg"},
gate: make(chan struct{}),
started: make(chan struct{}, 1),
}
p := &Poller{
Store: s, Fetch: pages, CoverBytesFetch: gated,
Now: func() time.Time { return time.UnixMilli(5_000_000) },
}
done := make(chan struct{})
go func() {
p.runOnce(context.Background())
close(done)
}()
<-gated.started // the first cover heal is now stuck on its CDN
select {
case <-done:
// The Lane finished its page fetches without waiting for the cover.
case <-time.After(5 * time.Second):
t.Fatal("runOnce blocked on an in-flight cover fetch")
}
close(gated.gate)
p.waitCovers()
if got := pages.callCount(); got != 2 {
t.Fatalf("series page fetches = %d, want 2", got)
}
if got := gated.inner.callCount(); got != 2 {
t.Fatalf("cover fetches = %d, want 2", got)
}
}
// At 3601 eligible Series the effective gap falls below the one-second floor;
// the clamp warning must name the Site so the operator knows which Lane is
// outrunning its plan.
func TestRunOnceClampWarningNamesTheSite(t *testing.T) {
s, dbURL := newTestStore(t)
db, err := sql.Open("pgx", dbURL)
if err != nil {
t.Fatalf("open %s: %v", dbURL, err)
}
defer db.Close()
if _, err := db.Exec(`INSERT INTO series (site, series_id, series_url, latest_checked_at)
SELECT 'asura', 'bulk-' || g, 'https://asurascans.com/comics/bulk-' || g, 0
FROM generate_series(1, 3601) AS g`); err != nil {
t.Fatalf("bulk seed series: %v", err)
}
if _, err := db.Exec(`INSERT INTO bookmarks (reader_id, site, series_id, updated_at)
SELECT (SELECT id FROM readers ORDER BY id LIMIT 1), 'asura', 'bulk-' || g, 1000
FROM generate_series(1, 3601) AS g`); err != nil {
t.Fatalf("bulk seed bookmarks: %v", err)
}
var logs strings.Builder
prev := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(prev) })
newTestPoller(t, s, &fakeFetcher{body: "<html></html>", status: 200}, time.UnixMilli(5_000_000)).
runOnce(context.Background())
if got := logs.String(); !strings.Contains(got, "latest poll asura: gap clamped to 1s floor (eligible series=3601)") {
t.Fatalf("clamp warning = %q, want it to name asura and 3601", got)
}
}
// The owner's admin page (issue #102) reads Lane state out of the poller.
// Before any pass the snapshot is empty — a restart must render "no data
// yet", not zeroes — and each pass records what it saw: the frozen clock,
// the due count and the pace, with refusal backoff derived at snapshot time.
func TestLaneStatus(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(5_000_000)
p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
if st := p.LaneStatus(); len(st.Lanes) != 0 {
t.Fatalf("lanes before any pass = %d, want 0 (nothing has run)", len(st.Lanes))
} else if st.BrowserConfigured || st.BrowserReachable {
t.Fatalf("browser before any pass = configured=%v reachable=%v, want false without a browser fetcher", st.BrowserConfigured, st.BrowserReachable)
}
seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0)
// Two refusals put kagane's Lane into backoff; asura sits on its own Lane.
browser := &fakeFetcher{status: 403}
p.BrowserFetch = browser
for i := range 2 {
key := fmt.Sprintf("kagane:s%d", i)
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0)
}
p.runOnce(context.Background())
st := p.LaneStatus()
var asura, kagane LaneState
for _, lane := range st.Lanes {
switch lane.Site {
case "asura":
asura = lane
case "kagane":
kagane = lane
}
}
if st.Lanes[0].Site != "asura" {
t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site)
}
if asura.Site == "" {
t.Fatalf("asura missing from snapshot: %+v", st.Lanes)
}
if !asura.LastRun.Equal(now) {
t.Fatalf("asura LastRun = %s, want the frozen clock %s", asura.LastRun, now)
}
if asura.Due != 1 {
t.Fatalf("asura Due = %d, want 1", asura.Due)
}
if asura.Gap == 0 {
t.Fatal("asura Gap = 0, want the Lane's pace")
}
if asura.Browser || asura.Refusing {
t.Fatalf("asura = %+v, want a TLS Lane that is not refusing", asura)
}
if !kagane.Browser || !kagane.Refusing {
t.Fatalf("kagane = %+v, want a browser Lane in refusal backoff", kagane)
}
if !st.BrowserConfigured || !st.BrowserReachable {
t.Fatalf("browser after round = configured=%v reachable=%v, want true/true (sidecar never lost)", st.BrowserConfigured, st.BrowserReachable)
}
if asura.Checked != 1 {
t.Fatalf("asura Checked = %d, want the one Series it read", asura.Checked)
}
// A pass that declines to look (kagane is now in backoff) must not restate
// the figures it never gathered as zeroes: the last real pass's due count
// and pace stand until a pass replaces them.
before := kagane
if before.Due == 0 || before.Gap == 0 {
t.Fatalf("kagane after its refusing pass = %+v, want the figures that pass gathered", before)
}
p.runOnce(context.Background())
for _, lane := range p.LaneStatus().Lanes {
if lane.Site != "kagane" {
continue
}
if lane.Due != before.Due || lane.Gap != before.Gap {
t.Fatalf("kagane after a skipped pass = due %d gap %s, want the previous pass's %d / %s",
lane.Due, lane.Gap, before.Due, before.Gap)
}
}
// A lost sidecar reads as unreachable for the same window the Lanes skip.
p.setBrowserDown(now)
if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable {
t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable)
}
}
+16 -1
View File
@@ -9,7 +9,7 @@ import (
// seriesRead carries the two facts the poll and the acquirer both extract
// from a series page. Persistence, stamps and scheduling stay with the
// callers, so the policies that keep the two flows distinct (stamp order,
// cooldowns) are not swallowed by the module.
// rests) are not swallowed by the module.
type seriesRead struct {
Latest latestChapter
HasLatest bool
@@ -22,6 +22,9 @@ type seriesRead struct {
// errNotFetchable and errNoFetcher separate the gate and the route from fetch
// failures so each caller keeps its own distinct log line for all three.
// errChallengeHeld (browser.go) is the outcome of a Site that answered with
// its interstitial — status 403 (cf-mitigated) or a challenge page body — and
// is how a Lane tells a refusal from an ordinary failure (issue #100).
var (
errNotFetchable = errors.New("series url not fetchable")
errNoFetcher = errors.New("no fetcher for site")
@@ -49,9 +52,21 @@ func readSeriesPage(ctx context.Context, site, seriesURL string, browser, tls Fe
if err != nil {
return seriesRead{}, fmt.Errorf("fetch %s: %w", seriesURL, err)
}
if status == 403 {
// Cloudflare's challenge response for these Sites (cf-mitigated). The
// browser fetcher returns exactly this on a held interstitial, and a
// plain-TLS 403 means the same: the Site is refusing.
return seriesRead{}, fmt.Errorf("%w: fetch %s: status %d", errChallengeHeld, seriesURL, status)
}
if status != 200 {
return seriesRead{}, fmt.Errorf("fetch %s: status %d", seriesURL, status)
}
if isInterstitial(body) {
// A 200 that is the challenge page, not the payload: the TLS route can
// receive this where the browser would have kept re-reading. Same
// refusal as the 403.
return seriesRead{}, fmt.Errorf("%w: fetch %s: interstitial body", errChallengeHeld, seriesURL)
}
latest, hasLatest := latestChapterFrom(site, seriesURL, body)
cover, hasCover := coverFrom(site, seriesURL, body)
return seriesRead{Latest: latest, HasLatest: hasLatest, Cover: cover, HasCover: hasCover, BodyLen: len(body)}, nil
+494
View File
@@ -0,0 +1,494 @@
package latest
import (
"context"
"crypto/sha256"
"fmt"
"log"
"strings"
"testing"
"time"
"bookmarkmanager/backend/internal/store"
)
// Sightings (issue #103) are specified at the Poller seam, with the store as
// the way in: a Sighting is seeded the way handlers.Put performs one, a round
// is run against the injected fetcher and a frozen clock, and the assertions
// are the two observable facts — whether the Series was fetched, and what the
// stored Latest Chapter is afterwards. Nothing here asserts counter arithmetic
// through an internal call or reads how a deferral is represented in a row.
const (
sightingSlug = "chronicles-of-the-demon-faction-f886a8af"
sightingKey = "asura:" + sightingSlug
sightingURL = "https://asurascans.com/comics/" + sightingSlug
)
// sightingFixtureLatest is the newest chapter asuraSeriesFixture publishes.
const sightingFixtureLatest = 181.0
// sight performs one Sighting exactly as the JSON API does (handlers.Put):
// RecordSighting against the row as stored, then the Upsert that stores the
// reported value. The order is load-bearing — the raise comparison has nothing
// to compare against once the Upsert has landed — and the bookmark's own fields
// are carried over untouched, which is what a userscript PUT does when it
// echoes back the row it cached.
func sight(t *testing.T, s *store.Store, readerID int64, key string, num float64, at time.Time) {
t.Helper()
site, seriesID, ok := strings.Cut(key, ":")
if !ok {
t.Fatalf("key %q: no ':' separator", key)
}
b, found, err := s.Get(readerID, key)
if err != nil || !found {
t.Fatalf("sight %q: get: %v found=%v", key, err, found)
}
if err := s.RecordSighting(readerID, site, seriesID, &num, at.UnixMilli()); err != nil {
t.Fatalf("sight %q: %v", key, err)
}
b.LatestChapter = fmt.Sprintf("Chapter %v", num)
b.LatestChapterNum = &num
b.UpdatedAt = at.UnixMilli()
if _, err := s.Upsert(readerID, b); err != nil {
t.Fatalf("sight %q: upsert: %v", key, err)
}
}
// secondReader is another Reader on the same database. The owner seed is the
// only reader-creation path in this package, so a second Open as a different
// owner is how a test gets one (as TestRunOnceFetchesSharedSeriesOnce does).
func secondReader(t *testing.T, dbURL string) *store.Store {
t.Helper()
other, err := store.Open(dbURL,
store.Owner{DiscordID: "second-reader", TokenHash: sha256.Sum256([]byte("second-token-hash"))},
t.TempDir(), testCoverBaseURL)
if err != nil {
t.Fatalf("Open second reader: %v", err)
}
t.Cleanup(func() { other.Close() })
return other
}
func readLatestNum(t *testing.T, s *store.Store, readerID int64, key string) float64 {
t.Helper()
b, ok, err := s.Get(readerID, key)
if err != nil || !ok {
t.Fatalf("Get %q: %v ok=%v", key, err, ok)
}
if b.LatestChapterNum == nil {
t.Fatalf("%q has no latest chapter", key)
}
return *b.LatestChapterNum
}
// A Series only one Reader bookmarks is the case where being wrong can hurt
// nobody but the Reader who reported it, so their Sighting stands in for the
// Poll and the round leaves the Series alone.
func TestSightingOnSolitarySeriesDefersPoll(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-10*time.Minute))
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
newTestPoller(t, s, f, now).runOnce(context.Background())
if got := f.callCount(); got != 0 {
t.Fatalf("fetched %d times after a Sighting on a solitary Series, want 0", got)
}
}
// On a shared Series the Sighting still writes the Latest Chapter for everyone,
// but the Poll happens on schedule anyway — which is what corrects a wrong
// value within the hour instead of letting it persist.
func TestSightingOnSharedSeriesDoesNotDeferPoll(t *testing.T) {
s, dbURL := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
other := secondReader(t, dbURL)
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, UpdatedAt: 2000,
}); err != nil {
t.Fatalf("seed second reader: %v", err)
}
sight(t, s, s.OwnerID(), sightingKey, 200, now.Add(-10*time.Minute))
// The Sighting updated the shared row immediately, before any Poll.
if got := readLatestNum(t, s, s.OwnerID(), sightingKey); got != 200 {
t.Fatalf("latest after the Sighting = %v, want 200", got)
}
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
newTestPoller(t, s, f, now).runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("fetched %d times after a Sighting on a shared Series, want 1", got)
}
if got := readLatestNum(t, s, s.OwnerID(), sightingKey); got != sightingFixtureLatest {
t.Fatalf("latest after the Poll = %v, want the Site's own %v", got, sightingFixtureLatest)
}
}
// Reporting a chapter is not reading one: a Sighting may move the Latest
// Chapter and nothing else. Both the solitary and the shared case, because the
// deferral branch must not be where this guarantee lives.
func TestSightingLeavesProgressAndOrderingUntouched(t *testing.T) {
for _, shared := range []bool{false, true} {
name := "solitary"
if shared {
name = "shared"
}
t.Run(name, func(t *testing.T) {
s, dbURL := newTestStore(t)
read := 5.0
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, SeriesURL: sightingURL,
LastChapter: "Chapter 5", LastChapterNum: read,
LastChapterURL: sightingURL + "/chapter/5", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
if shared {
other := secondReader(t, dbURL)
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, UpdatedAt: 2000,
}); err != nil {
t.Fatalf("seed second reader: %v", err)
}
}
sight(t, s, s.OwnerID(), sightingKey, 200, time.UnixMilli(9_000_000))
b, ok, err := s.Get(s.OwnerID(), sightingKey)
if err != nil || !ok {
t.Fatalf("Get: %v ok=%v", err, ok)
}
if b.LatestChapterNum == nil || *b.LatestChapterNum != 200 {
t.Fatalf("LatestChapterNum = %v, want 200", b.LatestChapterNum)
}
if b.LastChapterNum != read {
t.Fatalf("LastChapterNum = %v, want %v: a Sighting is not Progress", b.LastChapterNum, read)
}
if b.UpdatedAt != 1000 {
t.Fatalf("updated_at moved to %d: a Sighting must not reorder the list", b.UpdatedAt)
}
})
}
}
// The ceiling is what makes trusting a client report safe: however recently a
// Series was sighted, one that has not been Polled in six hours is Polled.
func TestSightingCeilingForcesPoll(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-7*time.Hour).UnixMilli())
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-time.Minute))
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
newTestPoller(t, s, f, now).runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("fetched %d times past the %s ceiling, want 1", got, sightingCeilingRests*defaultRest)
}
}
// Deferral is decided from live facts every round, so a Series that gains a
// second Bookmark stops deferring at once — and one that loses it defers again.
func TestDeferralFollowsTheBookmarkCount(t *testing.T) {
s, dbURL := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-10*time.Minute))
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
p := newTestPoller(t, s, f, now)
p.runOnce(context.Background())
if got := f.callCount(); got != 0 {
t.Fatalf("solitary Series fetched %d times, want 0", got)
}
other := secondReader(t, dbURL)
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, UpdatedAt: 2000,
}); err != nil {
t.Fatalf("seed second reader: %v", err)
}
p.runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("shared Series fetched %d times, want 1", got)
}
// The Poll above consumed the rest, so move past it before asking again.
if err := other.Delete(other.OwnerID(), sightingKey); err != nil {
t.Fatalf("delete second bookmark: %v", err)
}
later := now.Add(2 * time.Hour)
p.Now = func() time.Time { return later }
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, later.Add(-time.Minute))
p.runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("Series fetched %d times after returning to one Bookmark, want 1", got)
}
}
// A Series nobody reports any more returns to the normal schedule on its own:
// the Sighting's standing lasts one rest, not forever.
func TestDeferralExpiresWithoutFurtherSightings(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-10*time.Minute))
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
p := newTestPoller(t, s, f, now)
p.runOnce(context.Background())
if got := f.callCount(); got != 0 {
t.Fatalf("fetched %d times while the Sighting stood, want 0", got)
}
p.Now = func() time.Time { return now.Add(90 * time.Minute) }
p.runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("fetched %d times once the Sighting aged out, want 1", got)
}
}
// demonicFixture publishes one chapter in demonicscans' live page shape, so a
// test can make a Site publish an arbitrary number rather than the one the
// captured fixture froze.
func demonicFixture(num float64) string {
return fmt.Sprintf(
`<a href="/chaptered.php?manga=11799&chapter=%v" class="chplinks" title="Catastrophic Necromancer %v">Chapter %v</a>`,
num, num, num)
}
const (
demonicKey = "demonic:Catastrophic-Necromancer"
demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer"
)
// contradictOnce reports a chapter that does not exist and then runs the round
// that catches it, returning when that round ran so a caller can chain the
// next one. The wait is one rest and a minute: a Sighting stands in for exactly
// one rest, so that is the first moment this solitary Series is Polled again.
func contradictOnce(t *testing.T, s *store.Store, p *Poller, sightAt time.Time, real float64) time.Time {
t.Helper()
sight(t, s, s.OwnerID(), demonicKey, real+500, sightAt)
at := sightAt.Add(defaultRest + time.Minute)
p.Now = func() time.Time { return at }
p.runOnce(context.Background())
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != real {
t.Fatalf("latest after the Poll = %v, want the Site's own %v", got, real)
}
return at
}
func seedDemonic(t *testing.T, s *store.Store, checkedAt int64) {
t.Helper()
seedForCheck(t, s, demonicKey, demonicURL, checkedAt)
}
// A Poll finding a lower number than stored means the Sighting that raised it
// was false. The Reader is named — not the Series flagged — and both numbers are
// logged, because that is what tells a broken adapter from a deliberate lie.
func TestPollContradictingASightingNamesTheReaderAndBothNumbers(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
var logs strings.Builder
prev := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(prev) })
f := &fakeFetcher{body: demonicFixture(296), status: 200}
p := newTestPoller(t, s, f, now)
contradictOnce(t, s, p, now, 296)
got := logs.String()
for _, want := range []string{
fmt.Sprintf("reader %d", s.OwnerID()), "796", "296", demonicKey,
} {
if !strings.Contains(got, want) {
t.Fatalf("contradiction log = %q, want it to name %q", got, want)
}
}
}
// Three contradictions cost the Reader the right to defer. Nothing here writes
// a counter: the marks are earned through Polls, which is the only way
// production produces them.
func TestThreeContradictionsStopDeferral(t *testing.T) {
s, _ := newTestStore(t)
start := time.UnixMilli(20 * time.Hour.Milliseconds())
seedDemonic(t, s, start.Add(-2*time.Hour).UnixMilli())
f := &fakeFetcher{body: demonicFixture(296), status: 200}
p := newTestPoller(t, s, f, start)
at := start
for range store.SightingDisagreementLimit {
at = contradictOnce(t, s, p, at.Add(time.Minute), 296)
}
fetchesSoFar := f.callCount()
// The marked Reader sights the same solitary Series again. It still writes
// the Latest Chapter — the penalty removes a privilege, it does not silence
// anyone — but the Poll is no longer postponed: the round below runs while a
// trusted Reader's Sighting would still be standing, and fetches anyway.
sight(t, s, s.OwnerID(), demonicKey, 900, at.Add(31*time.Minute))
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != 900 {
t.Fatalf("latest after a marked Reader's Sighting = %v, want 900", got)
}
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
p.runOnce(context.Background())
if got := f.callCount(); got != fetchesSoFar+1 {
t.Fatalf("marked Reader's Sighting still deferred the Poll (fetches %d, want %d)",
got, fetchesSoFar+1)
}
}
// The owner's remedy for a mark a broken Site adapter produced restores the
// privilege without a wait and without SQL.
func TestClearingMarksRestoresDeferral(t *testing.T) {
s, _ := newTestStore(t)
start := time.UnixMilli(20 * time.Hour.Milliseconds())
seedDemonic(t, s, start.Add(-2*time.Hour).UnixMilli())
f := &fakeFetcher{body: demonicFixture(296), status: 200}
p := newTestPoller(t, s, f, start)
at := start
for range store.SightingDisagreementLimit {
at = contradictOnce(t, s, p, at.Add(time.Minute), 296)
}
if err := s.ClearReaderMarks(s.OwnerID()); err != nil {
t.Fatalf("ClearReaderMarks: %v", err)
}
fetchesSoFar := f.callCount()
sight(t, s, s.OwnerID(), demonicKey, 900, at.Add(31*time.Minute))
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
p.runOnce(context.Background())
if got := f.callCount(); got != fetchesSoFar {
t.Fatalf("fetched %d times after the marks were cleared, want %d: deferral must resume",
got, fetchesSoFar)
}
}
// Recovery is automatic but expensive: twenty Polls that each confirm a
// Sighting of this Reader's clear the marks. Each round needs a new chapter,
// because only a report that raises the stored number is attributed and so only
// that one can be confirmed.
func TestTwentyAgreementsClearTheMarks(t *testing.T) {
s, _ := newTestStore(t)
start := time.UnixMilli(20 * time.Hour.Milliseconds())
seedDemonic(t, s, start.Add(-2*time.Hour).UnixMilli())
f := &fakeFetcher{body: demonicFixture(296), status: 200}
p := newTestPoller(t, s, f, start)
at := start
for range store.SightingDisagreementLimit {
at = contradictOnce(t, s, p, at.Add(time.Minute), 296)
}
chapter := 296.0
for range store.SightingAgreementsToClear {
chapter++
sight(t, s, s.OwnerID(), demonicKey, chapter, at.Add(time.Minute))
f.body = demonicFixture(chapter) // the Site publishes what was reported
at = at.Add(defaultRest + time.Minute)
p.Now = func() time.Time { return at }
p.runOnce(context.Background())
}
fetchesSoFar := f.callCount()
chapter++
sight(t, s, s.OwnerID(), demonicKey, chapter, at.Add(31*time.Minute))
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
p.runOnce(context.Background())
if got := f.callCount(); got != fetchesSoFar {
t.Fatalf("fetched %d times after %d confirmations, want %d: the marks must be forgiven",
got, store.SightingAgreementsToClear, fetchesSoFar)
}
}
// A Poll finding a higher number is the Site publishing since the Sighting and
// means nothing about the Reader — no mark, and no credit either.
func TestPollFindingHigherNumberIsNotAContradiction(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
// Reported truthfully, then the Site published one more.
sight(t, s, s.OwnerID(), demonicKey, 295, now.Add(-10*time.Minute))
f := &fakeFetcher{body: demonicFixture(296), status: 200}
p := newTestPoller(t, s, f, now)
// One rest on, the Sighting has lapsed and the Poll happens.
p.Now = func() time.Time { return now.Add(7 * time.Hour) }
p.runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("fetched %d times past the ceiling, want 1", got)
}
// Unmarked, so a fresh Sighting still defers.
at := now.Add(9 * time.Hour)
sight(t, s, s.OwnerID(), demonicKey, 296, at.Add(-time.Minute))
p.Now = func() time.Time { return at }
p.runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("a Reader whose report the Site overtook lost the right to defer (fetches %d, want 1)", got)
}
}
// A Poll that overtakes a Sighting takes ownership of the row: the value stored
// afterwards is the Poll's own, so a later retraction is not the Reader's fault
// and must not be charged to them.
func TestAttributionDoesNotSurviveAPollThatOvertookIt(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
sight(t, s, s.OwnerID(), demonicKey, 295, now.Add(-10*time.Minute))
f := &fakeFetcher{body: demonicFixture(296), status: 200}
p := newTestPoller(t, s, f, now)
at := now.Add(defaultRest + time.Minute)
p.Now = func() time.Time { return at }
p.runOnce(context.Background())
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != 296 {
t.Fatalf("latest after the Poll = %v, want the Site's own 296", got)
}
var logs strings.Builder
prev := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(prev) })
f.body = demonicFixture(290) // the Site retracts what only the Poll wrote
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
p.runOnce(context.Background())
if strings.Contains(logs.String(), "sighting contradicted") {
t.Fatalf("a retraction of the Poll's own value was charged to a Reader: %s", logs.String())
}
}
// A PUT with no Latest Chapter in it — a favourite toggle, progress written
// from a chapter page — is nobody looking at the Series page, so it buys no
// deferral. Otherwise a client could suppress a Series' Polls while reporting
// nothing, and with nothing reported there would be nothing to judge.
func TestPutWithoutALatestChapterDoesNotDefer(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
// The handler's own call, with the field the client omitted.
if err := s.RecordSighting(s.OwnerID(), "demonic", "Catastrophic-Necromancer",
nil, now.Add(-time.Minute).UnixMilli()); err != nil {
t.Fatalf("RecordSighting: %v", err)
}
f := &fakeFetcher{body: demonicFixture(296), status: 200}
p := newTestPoller(t, s, f, now)
p.runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("fetched %d times after a PUT carrying no chapter, want 1", got)
}
}
+101 -11
View File
@@ -9,6 +9,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"github.com/chromedp/chromedp"
)
@@ -32,6 +33,11 @@ type site struct {
LatestChapter func(seriesURL, body string) (latestChapter, bool)
// Cover finds the Cover address in a fetched body.
Cover func(seriesURL, body string) (string, bool)
// Rest is how long a Series of this Site rests between Polls.
Rest time.Duration
// Gap is the Lane's strictest pace: at least one second must pass between
// two consecutive Series-page Polls of this Site (issue #100).
Gap time.Duration
// Browser reads this Site's payload from a cleared browser tab; nil
// means the page is fetched over plain TLS.
Browser *browserRead
@@ -47,9 +53,9 @@ type browserRead struct {
// Done reports whether the payload arrived.
Done func(body string) bool
// Fallback allows the plain-TLS fetcher when no browser is configured.
// False skips the Site instead. kagane is false — a plain fetch would
// only ever retrieve a challenge page — and novelfull is true, because
// its challenge is a live time-varying fact (AGENTS.md).
// False skips the Site instead. kagane and comix are false — a plain fetch
// would only ever retrieve a challenge page — and novelfull is true,
// because its challenge is a live time-varying fact (AGENTS.md).
Fallback bool
}
@@ -248,14 +254,25 @@ var comixInitialDataRe = regexp.MustCompile(`(?is)<script\b[^>]*\bid\s*=\s*["']i
// supplied, and a headless browser is a strong SSRF primitive.
var kaganeImageURLRe = regexp.MustCompile(`^https://kagane\.to/api/v2/image/([0-9a-f-]{36})/compressed$`)
// comixImageURLRe matches comix's cover host and path shape. Pinned in full
// (scheme, host, path characters, image extension) for the same reason
// kaganeImageURLRe is: the address reaches a headless browser, and it can
// originate in a client-supplied PUT body. No dot is allowed inside the path,
// so no traversal or second extension can hide in it. Shape from a live page,
// 2026-08-10: /039d/i/1/34/6a6742bf15736@280.jpg.
var comixImageURLRe = regexp.MustCompile(`^https://static\.comix\.to/[A-Za-z0-9@/_-]+\.(?:jpg|jpeg|png|webp)$`)
// browserOnlyCoverURL reports whether the browser sidecar is the only fetcher
// for cover bytes at imageURL. kagane's image route answers a plain fetch with
// a challenge and `cross-origin-resource-policy: same-origin`, so a TLS fetch
// would only ever retrieve a challenge page and must not be attempted
// (ADR-0007). This is the byte-fetch router's per-Site knowledge; it lives in
// the extraction module, which owns kagane's URL shapes.
// a challenge and `cross-origin-resource-policy: same-origin`, and
// static.comix.to answers one with the same Cloudflare challenge its pages
// serve (measured 2026-08-12, issue #98), so a TLS fetch would only ever
// retrieve a challenge page and must not be attempted (ADR-0007). This is the
// byte-fetch router's per-Site knowledge; it lives in the extraction module,
// which owns those URL shapes.
func browserOnlyCoverURL(imageURL string) bool {
return kaganeImageURLRe.MatchString(imageURL)
return kaganeImageURLRe.MatchString(imageURL) ||
comixImageURLRe.MatchString(imageURL)
}
// kagane's browser-fetched series response publishes cover image IDs under
@@ -369,6 +386,58 @@ func publishedCoverURL(value string) string {
return strings.ReplaceAll(value, " ", "%20")
}
// Poll Lane constants (issue #100). The per-Site structure is deliberately
// uniform at first — every Site rests an hour and gaps ten seconds — but it
// exists so a single Site can be slowed if it turns hostile, and the numbers
// stay in the registry so the structure has a place to differ.
const (
// defaultRest is how long every Series rests between Polls.
defaultRest = time.Hour
// defaultGap is the strictest pace of every Lane unless the eligible
// Series count forces it tighter.
defaultGap = 10 * time.Second
// minGap floors the effective gap. One request per second is already an
// order of magnitude past the strictest rate rule a free-plan Site can
// express (docs/research/cloudflare-bot-scoring-and-poll-cadence.md);
// below it the Lane is outrunning its own plan and says so loudly.
minGap = time.Second
// refuseBackoff is how long a Lane waits after its Site refused twice in
// one run before attempting it again.
refuseBackoff = 15 * time.Minute
// browserWakeCount and browserWakeAge gate a browser Lane's run: five or
// more due Series, or any one of them waiting this long, or Chrome stays
// asleep (ADR-0005 on-demand browser).
browserWakeCount = 5
browserWakeAge = 15 * time.Minute
// sightingCeilingRests caps Sighting deferral (issue #103): however many
// Sightings arrive, a Series unpolled for this many of its Site's rests is
// Polled. It is what makes a client report safe to trust — a wrong Latest
// Chapter dies within the ceiling deterministically, rather than in
// expectation the way a randomised audit would have it. Six, so a Series a
// Reader visits constantly still gets one authoritative check per working
// day-part.
sightingCeilingRests = 6
)
// effectiveGap is a Site's pace: the registry gap, or one rest divided by the
// eligible Series count when that is smaller, never below one second. The
// denominator follows defaultRest rather than a literal hour so a Site whose
// rest is ever changed keeps its per-Series pace in step. The second return is
// true when the one-second floor engaged (and the Lane logs a warning naming
// the Site, every round it does).
func effectiveGap(s site, eligible int) (time.Duration, bool) {
gap := s.Gap
if eligible > 0 {
if perSeries := defaultRest / time.Duration(eligible); perSeries < gap {
gap = perSeries
}
}
if gap < minGap {
return minGap, true
}
return gap, false
}
// sites is the registry: one entry per Site, keyed by the stored site string.
// Adding a Site means adding an entry here and nowhere else — the dispatch
// functions above and the poller's route list are lookups into this map. An
@@ -379,21 +448,38 @@ var sites = map[string]site{
Host: "asurascans.com",
LatestChapter: asuraLatestChapter,
Cover: ogImageCover,
Rest: defaultRest,
Gap: defaultGap,
},
"demonic": {
Host: "demonicscans.org",
LatestChapter: demonicLatestChapter,
Cover: ogImageCover,
Rest: defaultRest,
Gap: defaultGap,
},
"comix": {
Host: "comix.to",
LatestChapter: comixLatestChapter,
Cover: comixCoverEntry,
Rest: defaultRest,
Gap: defaultGap,
Browser: &browserRead{
Read: comixRead,
// The interstitial is served in place of the page, so "arrived"
// has to exclude it explicitly, as novelfull's does.
Done: func(body string) bool { return body != "" && !isInterstitial(body) },
// Never falls back: a plain fetch of a comix page or cover
// retrieves only a challenge page (measured 2026-08-12).
Fallback: false,
},
},
"kagane": {
Host: "kagane.to",
LatestChapter: kaganeLatestChapter,
Cover: kaganeCoverEntry,
Rest: defaultRest,
Gap: defaultGap,
Browser: &browserRead{
Read: kaganeRead,
Done: func(body string) bool { return body != "" },
@@ -406,6 +492,8 @@ var sites = map[string]site{
Host: "novelfull.com",
LatestChapter: novelfullLatestChapter,
Cover: novelfullCoverEntry,
Rest: defaultRest,
Gap: defaultGap,
Browser: &browserRead{
Read: novelfullRead,
// The interstitial has a DOM too, so "the payload arrived" has to
@@ -418,13 +506,15 @@ var sites = map[string]site{
Host: "lightnovelworld.net",
LatestChapter: lnwLatestChapter,
Cover: ogImageCover,
Rest: defaultRest,
Gap: defaultGap,
},
}
// browserBackedSites is derived from the registry: the Sites whose pages are
// read through the browser sidecar, which are also the ones granted the longer
// cooldown. Sorted so callers that range it (the due query, the browser
// fetcher's dispatch) see a stable order instead of map-iteration noise.
// read through the browser sidecar. Sorted so callers that range it (the
// browser fetcher's dispatch) see a stable order instead of map-iteration
// noise.
func browserBackedSites() []string {
out := make([]string, 0, len(sites))
for name, s := range sites {
+7
View File
@@ -41,6 +41,13 @@ const challengeFixture = `<!DOCTYPE html><html><head><title>Just a moment...</ti
// https://comix.to/title/n8we-dungeons-and-crayons fetched 2026-08-03. comix is
// an SPA: the page ships a JSON state blob rather than a list of chapter
// anchors, and latestChapterUrl is where the newest chapter actually lives.
//
// Still the right fixture after comix moved behind the challenge (#98): the
// browser read is an in-tab fetch of the Series URL, so the body a poll parses
// is this same server-rendered HTML, not a rendered DOM. Confirmed against a
// live cleared tab 2026-08-16 (TestSmokeComix): the in-tab fetch returned
// 24793 bytes of server-rendered HTML that these same parses read a chapter
// and a cover out of.
const comixSeriesFixture = `
{"firstChapterUrl":"/title/n8we-dungeons-and-crayons/5038739-chapter-1","latestChapterUrl":"/title/n8we-dungeons-and-crayons/11139891-chapter-80"},
{""manga","recommended","n8we",1]":{"items":[{"latestChapterUrl":"/title/qqwrm-full-time-awakening/99999999-chapter-999"}]}
@@ -0,0 +1,72 @@
package latest
import (
"context"
"os"
"testing"
"time"
"bookmarkmanager/backend/internal/store"
)
// TestSmokeComix answers "is comix's challenge clearing from this browser right
// now" — a live, time-varying fact, so a red run is something to re-check
// before it is a defect. Needs the real browser unit with outbound network:
//
// cd chrome && BROWSER_BIND_ADDR=127.0.0.1 docker compose up -d --build
// SMOKE_BROWSER_WS_URL=ws://127.0.0.1:9222 go test -run TestSmokeComix ./internal/latest
//
// It walks the whole read: the in-tab page fetch, both parses, and the Cover
// bytes by direct navigation to static.comix.to. The Cover address comes out of
// the page rather than being pinned in the test, because a stored one rots.
func TestSmokeComix(t *testing.T) {
ws := os.Getenv("SMOKE_BROWSER_WS_URL")
if ws == "" {
t.Skip("SMOKE_BROWSER_WS_URL unset")
}
const seriesURL = "https://comix.to/title/m12d-classmate"
f, err := NewBrowserFetcher(ws)
if err != nil {
t.Fatalf("NewBrowserFetcher: %v", err)
}
defer f.Close()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
body, status, err := f.Get(ctx, seriesURL)
if err != nil {
t.Fatalf("Get: %v", err)
}
t.Logf("status=%d bytes=%d", status, len(body))
if status != 200 {
t.Fatalf("status = %d, want 200 — the sidecar is not clearing the challenge", status)
}
chapter, ok := latestChapterFrom("comix", seriesURL, body)
if !ok {
t.Fatalf("no latest chapter in %d bytes — page shape changed", len(body))
}
t.Logf("latest chapter: %v %q", chapter.Num, chapter.Label)
cover, ok := coverFrom("comix", seriesURL, body)
if !ok {
t.Fatalf("no cover address in %d bytes — page shape changed", len(body))
}
t.Logf("cover: %s", cover)
if !browserOnlyCoverURL(cover) {
t.Fatalf("cover %q is not claimed by the browser gate: the pin and the live URL shape disagree", cover)
}
bytes, contentType, err := f.Image(ctx, cover)
if err != nil {
t.Fatalf("Image: %v", err)
}
if len(bytes) < 1000 {
t.Fatalf("cover is %d bytes, want a real image", len(bytes))
}
t.Logf("fetched %d bytes of %s", len(bytes), contentType)
if _, ok := store.CoverContentType(contentType); !ok {
t.Fatalf("content type %q is not storable", contentType)
}
}
+74
View File
@@ -0,0 +1,74 @@
package latest
import "time"
// LaneState is the administrative page's view of one Poll Lane (issue #102):
// what the Lane's last pass saw. Due, Gap and Checked are filled in as the
// pass computes them; a pass that returned before reaching a figure (refusal
// backoff, sidecar down) carries the previous pass's figures forward rather
// than overwriting them with zeroes the page would state as fact.
type LaneState struct {
Site string
Due int
LastRun time.Time
Gap time.Duration
// Checked is how many Series this pass actually read. A Lane with Series
// due and nothing checked has stopped working; one with nothing due is
// merely quiet, and the page must not draw the two the same (story 13).
Checked int
Clamped bool
Refusing bool
Browser bool
}
// Status is the owner's page snapshot of the whole poller (issue #102).
type Status struct {
Lanes []LaneState
BrowserConfigured bool
BrowserReachable bool
}
// LaneStatus returns a copy of the poller's Lane state for the owner's page.
// Only Sites that have completed a pass appear — a restart therefore renders
// "no data yet" instead of confident zeroes — in the same order Run iterates.
// Refusing is derived at snapshot time from the refusal backoff, not stored,
// so a Lane that cooled down between passes reports false without a new pass.
// BrowserReachable mirrors the Lanes' own gate: the sidecar is down only
// within the refuseBackoff window since its last loss.
func (p *Poller) LaneStatus() Status {
p.mu.Lock()
defer p.mu.Unlock()
lanes := make([]LaneState, 0, len(p.laneStates))
now := p.Now()
for _, name := range laneNames() {
st, ok := p.laneStates[name]
if !ok {
continue
}
st.Refusing = now.Before(p.refuseUntil[name])
lanes = append(lanes, st)
}
configured := p.BrowserFetch != nil
reachable := configured
if reachable && !p.browserDownAt.IsZero() && now.Sub(p.browserDownAt) < refuseBackoff {
reachable = false
}
return Status{Lanes: lanes, BrowserConfigured: configured, BrowserReachable: reachable}
}
// recordLaneState stores one Lane's last pass for LaneStatus. Called deferred
// from runLanePass so every return path records, even a pass that refused.
// A pass that never reached the pace (Gap zero) keeps the last pass's figures:
// the Lane's due count and gap did not become zero because this pass declined
// to look, and the row's own marks say why it declined.
func (p *Poller) recordLaneState(st LaneState) {
p.mu.Lock()
defer p.mu.Unlock()
if p.laneStates == nil {
p.laneStates = make(map[string]LaneState)
}
if prev, ok := p.laneStates[st.Site]; ok && st.Gap == 0 {
st.Due, st.Gap, st.Clamped, st.Checked = prev.Due, prev.Gap, prev.Clamped, prev.Checked
}
p.laneStates[st.Site] = st
}
@@ -0,0 +1,6 @@
-- The owner's administrative page (issue #102) renders these counters and
-- offers a control to clear them, deliberately shipped before the Sighting
-- feature (issue #103) that fills them, so a false mark never needs SQL
-- against production. Zero counters mean a trusted Reader.
ALTER TABLE readers ADD COLUMN sighting_agreements integer NOT NULL DEFAULT 0;
ALTER TABLE readers ADD COLUMN sighting_disagreements integer NOT NULL DEFAULT 0;
@@ -0,0 +1,10 @@
-- Sighting deferral (issue #103). latest_sighted_at is when a Reader's report
-- last stood in for a Poll; it is separate from latest_checked_at because the
-- six-hour ceiling has to know when the Series was last really fetched, and a
-- Sighting writing the Poll's own column would erase that.
-- latest_raised_by is attribution: whoever last raised this Series' Latest
-- Chapter by Sighting, so a Poll that contradicts the value downwards names a
-- Reader rather than flagging a row. Cleared by the Poll that judges it, NULL
-- whenever the stored value is the Poll's own.
ALTER TABLE series ADD COLUMN latest_sighted_at bigint NOT NULL DEFAULT 0;
ALTER TABLE series ADD COLUMN latest_raised_by bigint REFERENCES readers(id) ON DELETE SET NULL;
+211 -26
View File
@@ -16,7 +16,6 @@ import (
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgtype"
_ "github.com/jackc/pgx/v5/stdlib"
)
@@ -39,7 +38,7 @@ type Bookmark struct {
// origin once the bytes exist, and "" until they do — never a third-party
// address and never an address that 404s (ADR-0007). A client may still
// send this field and it is discarded on the way in; see Upsert.
Cover string `json:"cover"`
Cover string `json:"cover"`
LastChapter string `json:"last_chapter"`
LastChapterNum float64 `json:"last_chapter_num"`
LastChapterURL string `json:"last_chapter_url"`
@@ -80,6 +79,11 @@ type Series struct {
LatestChapter string
LatestChapterNum *float64 // nil until first captured
LatestCheckedAt int64 // unix ms; see MarkLatestChecked
// LatestRaisedBy is the Reader whose Sighting last raised LatestChapter,
// and nil when the stored value is a Poll's own finding. It is what lets a
// Poll that contradicts the value downwards name a Reader instead of
// merely flagging the row (issue #103); the Poll that judges it clears it.
LatestRaisedBy *int64
// readerCount is the number of bookmarks referencing this series, filled
// only by the due-queue query that orders on it.
@@ -196,7 +200,7 @@ const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_add
// due query. latest_checked_at lives only on series — see MarkLatestChecked
// for why it stays off every client-visible write.
const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at`
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_raised_by`
// Owner is the person running the service: the first Reader, seeded at startup
// so a fresh deployment has a library before anyone logs in. The seed makes
@@ -316,25 +320,42 @@ func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, e
return id, nil
}
// SightingDisagreementLimit is the number of contradictions that stop that
// Reader's Sightings from deferring a Poll (issue #103). The counters exist
// before the mechanism that moves them, so the admin page (issue #102) can
// clear a false mark without waiting for the Sighting feature.
const SightingDisagreementLimit = 3
// Blocked reports whether this Reader's Sighting marks have reached the
// disagreement limit, which stops their Sightings from deferring a Poll.
func (r ReaderSummary) Blocked() bool {
return r.Disagreements >= SightingDisagreementLimit
}
// ReaderSummary is one Reader as the owner's administration panel sees them:
// who they are and how many live sessions they hold. No credential material,
// hashed or otherwise, is exposed.
// who they are, how many live sessions they hold, and their Sighting marks.
// No credential material, hashed or otherwise, is exposed.
type ReaderSummary struct {
ID int64
DiscordID string
// Sessions counts unexpired session rows — what the owner revokes.
Sessions int
// Agreements and Disagreements are the Sighting counters (issue #102);
// zero means a trusted Reader.
Agreements int
Disagreements int
}
// Readers lists every Reader with their live session count, oldest first, so
// the owner row (always the oldest) heads the list.
// Readers lists every Reader with their live session count and Sighting
// marks, oldest first, so the owner row (always the oldest) heads the list.
func (s *Store) Readers() ([]ReaderSummary, error) {
rows, err := s.db.Query(`
SELECT r.id, r.discord_id,
r.sighting_agreements, r.sighting_disagreements,
count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions
FROM readers r
LEFT JOIN sessions sess ON sess.reader_id = r.id
GROUP BY r.id, r.discord_id
GROUP BY r.id, r.discord_id, r.sighting_agreements, r.sighting_disagreements
ORDER BY r.id`)
if err != nil {
return nil, fmt.Errorf("query readers: %w", err)
@@ -344,7 +365,7 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
out := []ReaderSummary{}
for rows.Next() {
var r ReaderSummary
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil {
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Agreements, &r.Disagreements, &r.Sessions); err != nil {
return nil, fmt.Errorf("scan reader: %w", err)
}
out = append(out, r)
@@ -352,6 +373,18 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
return out, rows.Err()
}
// ClearReaderMarks zeroes a Reader's Sighting counters. It is the owner's
// remedy for a mark produced by a broken Site adapter rather than a dishonest
// Reader: it restores a privilege, it is not destruction.
func (s *Store) ClearReaderMarks(readerID int64) error {
if _, err := s.db.Exec(`
UPDATE readers SET sighting_agreements = 0, sighting_disagreements = 0
WHERE id = $1`, readerID); err != nil {
return fmt.Errorf("clear reader marks for reader %d: %w", readerID, err)
}
return nil
}
// readersMigration is the version that creates the readers table. The owner
// seed runs between two migrate passes, so that the run-once migration which
// attaches existing bookmarks (0004) finds the owner row.
@@ -556,16 +589,17 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
}
// scanSeries reads one row in seriesColumns order, plus the due query's
// reader_count column. latest_chapter_num is NULL until the first capture,
// same as on the bookmark read path.
// reader_count column. latest_chapter_num and latest_raised_by are both
// nullable, same as latest_chapter_num on the bookmark read path.
func scanSeries(scan func(...any) error) (Series, error) {
var (
sr Series
latestChapterNum sql.NullFloat64
latestRaisedBy sql.NullInt64
)
if err := scan(
&sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress,
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt,
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy,
&sr.readerCount,
); err != nil {
return Series{}, err
@@ -573,6 +607,9 @@ func scanSeries(scan func(...any) error) (Series, error) {
if latestChapterNum.Valid {
sr.LatestChapterNum = &latestChapterNum.Float64
}
if latestRaisedBy.Valid {
sr.LatestRaisedBy = &latestRaisedBy.Int64
}
return sr, nil
}
@@ -897,10 +934,13 @@ func (s *Store) Delete(readerID int64, key string) error {
return nil
}
// DueForLatestCheck returns series whose server-side latest-chapter check has
// aged past the appropriate cutoff, ordered by how many bookmarks reference
// them (descending) then least-recently-checked first, at most limit of them.
// Browser-backed sites use browserCutoffMs; every other site uses cutoffMs.
// DueForLatestCheck returns one Site's series whose server-side
// latest-chapter check has aged past cutoffMs, ordered by how many bookmarks
// reference them (descending) then least-recently-checked first. One Site per
// query, because each Poll Lane asks for its own list: the query carries one
// Site and one cut-off instead of parallel lists (issue #100). There is no
// limit — the Lane's own gap paces the fetches, and the batch size that used
// to cap this query is gone with the shared pace.
//
// The reader_count ordering is the point of the split (ADR-0003): a series
// shared by several readers is fetched once per due cycle, and the popular
@@ -916,20 +956,33 @@ func (s *Store) Delete(readerID int64, key string) error {
// burns requests. Archived bookmarks still count — knowing what a shelved
// series is up to is the whole reason for archiving instead of deleting.
// A series with no bookmarks at all never appears: the join excludes it.
func (s *Store) DueForLatestCheck(cutoffMs, browserCutoffMs int64, browserSites []string, limit int) ([]Series, error) {
//
// ceilingMs is the Sighting deferral ceiling (issue #103): a Series whose last
// real Poll is older than it appears however recently it was sighted. That is
// what bounds the whole mechanism — a wrong Latest Chapter dies within the
// ceiling deterministically rather than in expectation. Deferral itself is
// decided here, from two facts the query already computes, so a Lane gains no
// query per round: a Sighting younger than cutoffMs holds the Series back, but
// only while COUNT(*) is 1. A Series a second Reader bookmarks is Polled on
// schedule, so a wrong value the whole guild can see is corrected by a check
// that was never postponed; on a solitary Series the only person a wrong value
// reaches is the Reader who reported it. Whether the reporting Reader is
// allowed to defer at all was settled when the Sighting was recorded — see
// RecordSighting.
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
rows, err := s.db.Query(`SELECT `+seriesColumns+`, COUNT(*) AS reader_count
FROM series s
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
WHERE s.series_url <> ''
AND s.latest_checked_at <= CASE
WHEN s.site = ANY($3::text[]) THEN $2::bigint
ELSE $1::bigint
END
WHERE s.site = $1
AND s.series_url <> ''
AND s.latest_checked_at <= $2::bigint
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
ORDER BY reader_count DESC, s.latest_checked_at ASC
LIMIT $4`, cutoffMs, browserCutoffMs, pgtype.FlatArray[string](browserSites), limit)
AND (COUNT(*) > 1
OR s.latest_sighted_at <= $2::bigint
OR s.latest_checked_at <= $3::bigint)
ORDER BY reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs)
if err != nil {
return nil, fmt.Errorf("query due series: %w", err)
}
@@ -946,6 +999,30 @@ func (s *Store) DueForLatestCheck(cutoffMs, browserCutoffMs int64, browserSites
return out, rows.Err()
}
// EligibleSeriesCount returns how many of a Site's Series still have at least
// one bookmark outside the finished bucket. It is the denominator of the
// Lane's pace (issue #100): the effective gap is the smaller of the registry
// gap and one hour divided by this count, so Series that will never be Polled
// do not make the Lane faster than it needs to be, and counting every eligible
// Series rather than only those currently due keeps the pace steady — the
// single worst moment to be fastest is startup, when everything is due at
// once.
func (s *Store) EligibleSeriesCount(site string) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM (
SELECT 1
FROM series s
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
WHERE s.site = $1
GROUP BY s.site, s.series_id
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
) e`, site).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count eligible series %s: %w", site, err)
}
return n, nil
}
// MarkLatestChecked records that the server looked at a series at ts, whatever
// the look turned up. Marking a missing series is not an error: the row may
// have been orphaned while a fetch was in flight.
@@ -954,7 +1031,7 @@ func (s *Store) DueForLatestCheck(cutoffMs, browserCutoffMs int64, browserSites
// out of the client-visible read path on purpose. PUT /bookmarks/{key} decodes
// a whole Bookmark from the client and Upsert writes every series column it
// knows about, so a userscript PUT — which has no idea this field exists —
// would write a zero and reset the cooldown, making the poller re-fetch that
// would write a zero and reset the rest, making the poller re-fetch that
// series every tick for as long as the user kept reading it.
func (s *Store) MarkLatestChecked(site, seriesID string, ts int64) error {
if _, err := s.db.Exec(
@@ -966,7 +1043,7 @@ func (s *Store) MarkLatestChecked(site, seriesID string, ts int64) error {
}
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
// tests outside this package (the poller's own tests assert on cooldown
// tests outside this package (the poller's own tests assert on rest
// bookkeeping) — see MarkLatestChecked for why the field stays off the
// client-visible row.
func (s *Store) LatestCheckedAt(site, seriesID string) (int64, error) {
@@ -992,3 +1069,111 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
}
return nil
}
// RecordSighting notes that a Reader's browser reported this Series' Latest
// Chapter, which is the half of a Sighting the client body cannot express
// (issue #103). It must be called *before* the Upsert that stores the reported
// value: the raise test compares against what is still on the row, and after
// the Upsert there is nothing left to compare with. A Series that does not
// exist yet — the first Bookmark of it — is not a Sighting at all: nothing has
// ever been Polled, so there is nothing to defer and nobody to attribute.
//
// Two independent effects, hence the two CASE arms. The deferral stamp is only
// written for a Reader below the disagreement limit, so a marked Reader's
// reports keep updating the Latest Chapter but stop postponing anything, and
// clearing their marks restores the privilege on their next Sighting. The
// attribution is written whenever the report raises the stored number,
// including for a marked Reader — their Sightings are still judged, which is
// how they earn the privilege back.
//
// num is the reported chapter number. A PUT that carries none — a favourite
// toggle, or progress written from a chapter page — is no Sighting at all:
// nobody read the Series page, so there is nothing to stand in for a Poll and
// nothing that could later be judged.
func (s *Store) RecordSighting(readerID int64, site, seriesID string, num *float64, ts int64) error {
if num == nil {
return nil
}
if _, err := s.db.Exec(`
UPDATE series SET
latest_sighted_at = CASE
WHEN (SELECT sighting_disagreements FROM readers WHERE id = $3) < $6
THEN $4::bigint ELSE latest_sighted_at END,
latest_raised_by = CASE
WHEN latest_chapter_num IS NULL OR $5::double precision > latest_chapter_num
THEN $3::bigint ELSE latest_raised_by END
WHERE site = $1 AND series_id = $2`,
site, seriesID, readerID, ts, *num, SightingDisagreementLimit); err != nil {
return fmt.Errorf("record sighting %s:%s: %w", site, seriesID, err)
}
return nil
}
// SightingAgreementsToClear is how many Polls must confirm a Reader's
// Sightings in a row before their disagreements are forgiven. An agreement is
// only recorded when a Poll later confirms a Sighting, so this is twenty Polls
// of Series that Reader bookmarks — hours to days, not twenty page views. That
// is the intended price: recovery is automatic but cannot be outwaited, and a
// disagreement resets the run to zero, so credit cannot be banked in advance.
const SightingAgreementsToClear = 20
// RecordSightingOutcome settles what a Poll decided about the Reader whose
// Sighting last raised this Series' Latest Chapter, and clears the attribution
// in the same transaction so one Sighting is judged exactly once. agreed is
// the Poll confirming the stored value; its opposite is the Poll finding a
// lower number, which means the raise was false.
//
// A Poll finding a *higher* number is neither — the Site published — and takes
// ClearSightingAttribution instead.
func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agreed bool) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin sighting outcome %s:%s: %w", site, seriesID, err)
}
defer tx.Rollback()
// The run length is what "consecutive" means: a disagreement zeroes the
// agreements, and completing a run zeroes both, so the next run starts
// from nothing rather than forgiving every later disagreement instantly.
q := `UPDATE readers SET sighting_disagreements = sighting_disagreements + 1,
sighting_agreements = 0
WHERE id = $1`
args := []any{readerID}
if agreed {
q = `UPDATE readers SET
sighting_agreements = CASE WHEN sighting_agreements + 1 >= $2 THEN 0
ELSE sighting_agreements + 1 END,
sighting_disagreements = CASE WHEN sighting_agreements + 1 >= $2 THEN 0
ELSE sighting_disagreements END
WHERE id = $1`
args = append(args, SightingAgreementsToClear)
}
if _, err := tx.Exec(q, args...); err != nil {
return fmt.Errorf("record sighting outcome for reader %d: %w", readerID, err)
}
if _, err := tx.Exec(clearAttributionSQL, site, seriesID, readerID); err != nil {
return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit sighting outcome %s:%s: %w", site, seriesID, err)
}
return nil
}
// ClearSightingAttribution answers a Sighting without judging it: the Poll
// found a higher number, so the value about to be stored is its own and this
// Reader is no longer answerable for the row. Without it the next Poll's
// agreement would be credited to a Reader who did not earn it.
func (s *Store) ClearSightingAttribution(site, seriesID string, readerID int64) error {
if _, err := s.db.Exec(clearAttributionSQL, site, seriesID, readerID); err != nil {
return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err)
}
return nil
}
// clearAttributionSQL drops the attribution only while it still names the
// Reader being judged: a Sighting landing between the due query's snapshot and
// this write is a fresh, unjudged one and must not be erased by the previous
// one's verdict.
const clearAttributionSQL = `UPDATE series SET latest_raised_by = NULL
WHERE site = $1 AND series_id = $2 AND latest_raised_by = $3`
+131 -12
View File
@@ -277,6 +277,10 @@ func seedForCheck(t *testing.T, s *Store, key, seriesURL string, checkedAt int64
}
}
// noCeiling is a Sighting deferral ceiling no Series can reach, for the tests
// that predate the ceiling and are about rest, ordering or buckets instead.
const noCeiling = int64(-1)
func TestDueForLatestCheck(t *testing.T) {
const hour = int64(3600_000)
now := 10 * hour
@@ -298,7 +302,7 @@ func TestDueForLatestCheck(t *testing.T) {
s := newTestStore(t)
seedForCheck(t, s, "asura:x", tt.seriesURL, tt.checkedAt)
due, err := s.DueForLatestCheck(now-hour, now-hour, nil, 10)
due, err := s.DueForLatestCheck("asura", now-hour, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
@@ -309,22 +313,25 @@ func TestDueForLatestCheck(t *testing.T) {
}
}
func TestDueForLatestCheckOldestFirstAndLimited(t *testing.T) {
func TestDueForLatestCheckOldestFirstAndScopedToSite(t *testing.T) {
s := newTestStore(t)
// Insert newest-checked first so a correct ORDER BY has to reverse it.
seedForCheck(t, s, "asura:c", "https://asurascans.com/comics/c", 300)
seedForCheck(t, s, "asura:b", "https://asurascans.com/comics/b", 200)
seedForCheck(t, s, "asura:a", "https://asurascans.com/comics/a", 100)
// A second Site's due series must not appear in asura's list: each Lane
// asks for one Site, and no Lane may see another's queue.
seedForCheck(t, s, "demonic:z", "https://demonicscans.org/manga/z", 0)
due, err := s.DueForLatestCheck(1000, 1000, nil, 2)
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 2 {
t.Fatalf("got %d rows, want 2 (limit)", len(due))
if len(due) != 3 {
t.Fatalf("got %d rows, want 3 (all of asura's, none of demonic's)", len(due))
}
if due[0].Key() != "asura:a" || due[1].Key() != "asura:b" {
t.Fatalf("got %q,%q; want asura:a,asura:b (oldest first)", due[0].Key(), due[1].Key())
if due[0].Key() != "asura:a" || due[1].Key() != "asura:b" || due[2].Key() != "asura:c" {
t.Fatalf("got %q,%q,%q; want asura:a,asura:b,asura:c (oldest first)", due[0].Key(), due[1].Key(), due[2].Key())
}
}
@@ -496,7 +503,7 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
}
}
due, err := store.DueForLatestCheck(time.Now().UnixMilli(), time.Now().UnixMilli(), nil, 10)
due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
@@ -512,6 +519,38 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
}
}
// The gap's denominator counts every Series the Lane will ever Poll: a
// finished Series must not make the Lane faster than it needs to be, and
// another Site's Series must not leak into this Site's count.
func TestEligibleSeriesCount(t *testing.T) {
store := newTestStore(t)
seedForCheck(t, store, "asura:reading", "https://asurascans.com/comics/reading", 0)
seedForCheck(t, store, "asura:archived", "https://asurascans.com/comics/archived", 0)
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:finished", Site: "asura", SeriesID: "finished",
SeriesURL: "https://asurascans.com/comics/finished",
Status: StatusFinished, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed finished: %v", err)
}
seedForCheck(t, store, "demonic:z", "https://demonicscans.org/manga/z", 0)
n, err := store.EligibleSeriesCount("asura")
if err != nil {
t.Fatalf("EligibleSeriesCount: %v", err)
}
if n != 2 {
t.Fatalf("eligible = %d, want 2 (finished excluded, demonic excluded)", n)
}
n, err = store.EligibleSeriesCount("demonic")
if err != nil {
t.Fatalf("EligibleSeriesCount(demonic): %v", err)
}
if n != 1 {
t.Fatalf("eligible(demonic) = %d, want 1", n)
}
}
func TestDisplayChapter(t *testing.T) {
cases := []struct {
name string
@@ -970,7 +1009,7 @@ func TestDueForLatestCheckOrdersByReaderCountThenAge(t *testing.T) {
seedSecondReader(t, s, "asura:pop:2", "asura", "pop", 1001)
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 100)
due, err := s.DueForLatestCheck(1000, 1000, nil, 10)
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
@@ -996,7 +1035,7 @@ func TestDueForLatestCheckExcludesOrphanSeries(t *testing.T) {
t.Fatalf("seed orphan series: %v", err)
}
due, err := s.DueForLatestCheck(1000, 1000, nil, 10)
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
@@ -1299,6 +1338,86 @@ func TestReadersAndSessionRevocation(t *testing.T) {
}
}
// The Sighting counters ship before the mechanism that fills them (issue
// #102 before #103), so the admin page depends on their default: a fresh
// Reader reads back trusted. Marking one directly proves Readers() reports
// the counters and Blocked() flips at the limit, and that ClearReaderMarks —
// the owner's remedy for a false mark — zeroes them again.
func TestReaderSightingMarks(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
readers, err := s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
if len(readers) != 2 {
t.Fatalf("readers = %d, want the owner and the second Reader", len(readers))
}
for _, r := range readers {
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
t.Fatalf("fresh reader %d has marks: %+v", r.ID, r)
}
}
// The counters' default is the trusted state; writing them directly is
// the only way to exercise the read path until issue #103 moves them.
if _, err := s.db.Exec(`
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
WHERE id = $1`, other); err != nil {
t.Fatalf("mark reader: %v", err)
}
readers, err = s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
var marked *ReaderSummary
for i := range readers {
if readers[i].ID == other {
marked = &readers[i]
}
}
if marked == nil || marked.Agreements != 5 || marked.Disagreements != 2 {
t.Fatalf("marked reader = %+v, want agreements 5, disagreements 2", marked)
}
if marked.Blocked() {
t.Fatalf("reader with 2 disagreements is blocked; limit is %d", SightingDisagreementLimit)
}
if _, err := s.db.Exec(`
UPDATE readers SET sighting_disagreements = 3 WHERE id = $1`, other); err != nil {
t.Fatalf("block reader: %v", err)
}
readers, err = s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
for _, r := range readers {
if r.ID == other && !r.Blocked() {
t.Fatalf("reader at the disagreement limit is not blocked: %+v", r)
}
if r.ID == s.OwnerID() && r.Blocked() {
t.Fatalf("untouched owner became blocked: %+v", r)
}
}
if err := s.ClearReaderMarks(other); err != nil {
t.Fatalf("ClearReaderMarks: %v", err)
}
if err := s.ClearReaderMarks(other + 9999); err != nil {
t.Fatalf("ClearReaderMarks(unknown id): %v", err)
}
readers, err = s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
for _, r := range readers {
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
t.Fatalf("reader %d not cleared: %+v", r.ID, r)
}
}
}
// Two Readers on one Series: one series row, two independent progresses. The
// second Reader starts at zero however far the first has read, and the shared
// row is still due exactly once.
@@ -1334,7 +1453,7 @@ func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
t.Fatalf("series rows = %d, want 1 shared row for two bookmarks", series)
}
due, err := s.DueForLatestCheck(time.Now().UnixMilli(), time.Now().UnixMilli(), nil, 10)
due, err := s.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
@@ -1350,7 +1469,7 @@ func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
if b, ok, err := s.Get(s.OwnerID(), "asura:solo"); err != nil || !ok || b.LastChapterNum != 200 {
t.Fatalf("owner's bookmark after the other's delete = %+v ok=%v err=%v, want it intact", b, ok, err)
}
due, err = s.DueForLatestCheck(time.Now().UnixMilli(), time.Now().UnixMilli(), nil, 10)
due, err = s.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck after delete: %v", err)
}
+251
View File
@@ -0,0 +1,251 @@
package web
import (
"log"
"net/http"
"strconv"
"time"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/store"
)
// LaneReporter is the administrative page's whole window onto the running
// poller: one snapshot of Poll Lane state, copied out of memory on request.
// The Poller satisfies it in production and a fake with fixed values satisfies
// it in tests, so the page's tests need neither a poller nor a Site.
type LaneReporter interface {
LaneStatus() latest.Status
}
// adminView is what the administrative page and the roster fragment receive.
type adminView struct {
Readers []store.ReaderSummary
// OwnerID travels with the roster so it can tell the owner's own row from
// the Readers they may act on.
OwnerID int64
Lanes lanesView
}
// lanesView is the Lane status block: one row per Site that has run, plus the
// browser fact, which is shared by the three browser Sites rather than held
// once per Site.
type lanesView struct {
Rows []laneRow
// PollerOff means no poller is running at all (disabled by config, or its
// client could not be built). The browser line must not answer "not
// configured" then: the sidecar is not the reason nothing is polled.
PollerOff bool
BrowserConfigured bool
BrowserReachable bool
}
// laneRow is one Lane formatted for reading rather than for arithmetic: the
// template renders strings and flags, and every judgement about what they mean
// is made here.
type laneRow struct {
Site string
Due int
Ran string
// Checked is how many Series the last pass read. Due without Checked is a
// Lane that has stopped working; the two figures side by side are what
// separate that from a Lane with nothing to do.
Checked int
// Gap is empty when no pass has reached the pace yet, so the row omits the
// figure instead of stating a zero.
Gap string
Clamped bool
Refusing bool
// BrowserLost marks a Lane whose pages can only be read through the
// sidecar while the sidecar is unreachable — including the case where none
// is configured, which stops those Series just as completely.
BrowserLost bool
// Stalled marks a Lane with Series waiting that its last pass did not read
// — the difference between a stopped Lane and a quiet one (story 13).
Stalled bool
// Attention is the one flag the template colours on, so an unhealthy Lane
// is found at a glance rather than read for.
Attention bool
}
// adminRoute pairs a route pattern with its handler so the route list and the
// gate cannot drift apart.
type adminRoute struct {
pattern string
handler http.HandlerFunc
}
// adminRoutes is every route that reaches past the acting Reader. Register
// wraps each one in requireOwner, so a new administrative route is gated by
// being listed here rather than by remembering to write a check inside it.
func (h *Handler) adminRoutes() []adminRoute {
return []adminRoute{
{"GET /admin", h.admin},
{"GET /ui/admin/lanes", h.uiLanes},
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
}
}
// AdminPatterns names every administrative route, so one test can prove the
// owner gate covers all of them rather than one test per route. The receiver is
// nil because only the patterns are read; the bound handlers are never called.
func AdminPatterns() []string {
routes := (*Handler)(nil).adminRoutes()
out := make([]string, 0, len(routes))
for _, rt := range routes {
out = append(out, rt.pattern)
}
return out
}
// requireOwner is the owner test, in one place, layered on the session gate: no
// session is still 401, and a signed-in Reader who is not the owner gets 404
// rather than 403 — a refusal that confirms the address exists is a refusal
// that helps whoever is probing for it.
func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc {
return h.requireSession(func(w http.ResponseWriter, r *http.Request) {
if readerOf(r) != h.store.OwnerID() {
http.NotFound(w, r)
return
}
next(w, r)
})
}
// admin renders the owner's page: the Reader roster and Poll Lane status.
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
readers, err := h.store.Readers()
if err != nil {
log.Printf("admin: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.render(w, http.StatusOK, "admin", adminView{
Readers: readers,
OwnerID: h.store.OwnerID(),
Lanes: h.lanesView(),
})
}
// uiLanes answers the status block's own refresh. Only the block refreshes on a
// timer; the roster re-renders after an action, as it always has.
func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "lanes", h.lanesView())
}
// lanesView copies the poller's snapshot into display form. A nil reporter (no
// poller running) and a poller no Lane has reported to yet are the same thing
// to the page: no data, which it must say rather than draw as confident zeroes
// — an empty page a few seconds after a restart must not read as a stopped one.
func (h *Handler) lanesView() lanesView {
if h.lanes == nil {
return lanesView{PollerOff: true}
}
snap := h.lanes.LaneStatus()
v := lanesView{
Rows: make([]laneRow, 0, len(snap.Lanes)),
BrowserConfigured: snap.BrowserConfigured,
BrowserReachable: snap.BrowserReachable,
}
now := time.Now()
for _, l := range snap.Lanes {
lost := l.Browser && !snap.BrowserReachable
// Series waiting and none read is the shape of a Lane that has stopped
// working, as distinct from one that is quiet for want of work.
stalled := l.Due > 0 && l.Checked == 0
gap := ""
if l.Gap > 0 {
gap = l.Gap.Truncate(time.Second).String()
}
v.Rows = append(v.Rows, laneRow{
Site: l.Site,
Due: l.Due,
Ran: since(now, l.LastRun),
Checked: l.Checked,
Gap: gap,
Clamped: l.Clamped,
Refusing: l.Refusing,
BrowserLost: lost,
Stalled: stalled,
Attention: l.Clamped || l.Refusing || lost || stalled,
})
}
return v
}
// since formats how long ago a Lane last ran, at second resolution: the block
// refreshes every thirty seconds, so anything finer is noise the owner would
// have to ignore.
func since(now, then time.Time) string {
d := now.Sub(then).Truncate(time.Second)
if d < time.Second {
return "just now"
}
return d.String() + " ago"
}
// revokeReaderSessions logs one Reader out of every browser they are signed in
// on. The owner gate is the route's, not this handler's.
func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
target, ok := readerPathID(w, r)
if !ok {
return
}
// The owner is not one of the Readers this endpoint reaches: revoking
// themselves would sign out the browser making the request, which is what
// logout is for. The roster hides the button; this refuses the hand-rolled
// POST behind it.
if target == h.store.OwnerID() {
http.NotFound(w, r)
return
}
if err := h.store.DeleteReaderSessions(target); err != nil {
log.Printf("revoke sessions: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderRoster(w, "revoke sessions")
}
// clearReaderMarks zeroes one Reader's Sighting counters. The guard those
// counters feed has one known false positive — a Site changing its page shape
// makes a correct adapter read a wrong high number and marks every honest
// Reader of that Site at once (issue #103) — and this is its remedy. It
// restores a privilege rather than destroying anything, so the control is
// confirmed but never wears the destruction accent.
func (h *Handler) clearReaderMarks(w http.ResponseWriter, r *http.Request) {
target, ok := readerPathID(w, r)
if !ok {
return
}
if err := h.store.ClearReaderMarks(target); err != nil {
log.Printf("clear marks: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderRoster(w, "clear marks")
}
// readerPathID reads the Reader a route names, answering the request itself
// when there is nobody to act on.
func readerPathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "bad reader id", http.StatusBadRequest)
return 0, false
}
return id, true
}
// renderRoster answers an action with the whole roster, so the counts and marks
// it shows cannot describe the state before the tap.
func (h *Handler) renderRoster(w http.ResponseWriter, what string) {
readers, err := h.store.Readers()
if err != nil {
log.Printf("%s: %v", what, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.render(w, http.StatusOK, "readers", adminView{Readers: readers, OwnerID: h.store.OwnerID()})
}
+53 -4
View File
@@ -87,6 +87,11 @@
--moss: #7fae86; /* finished */
--clay: #b5906f; /* set chapter */
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
/* A Lane needing attention: the admin page's only accent. Verdigris — cool,
the far side of the wheel from ember's crimson, and clear of the archive
blue. Neither ember (new chapter) nor danger (destruction) may say
"system unhealthy". */
--patina: #5fb3a6;
/* Desktop cell borders for the two coloured action states. */
--play-hot-line: #3a1d18;
@@ -146,6 +151,7 @@
--moss: #3d6c46;
--clay: #7c5533;
--trash: #8c6558;
--patina: #1f6f66;
--play-hot-line: #f0cfc6;
--fav-line: #e3d3a4;
@@ -290,9 +296,21 @@ button { cursor: pointer; }
letter-spacing: .04em;
}
/* ---- reader roster (owner only): same hairline panel, one row per Reader ---- */
.readerlist { margin: 0; padding: 0; list-style: none; }
.readerlist li {
/* ---- admin page: two sections on the same measured sheet, no cards ----
The reading page is a list of series; this is a list of facts. Both are
sheets of hairline-separated rows, so the roster keeps the shape it had as
a fold-out and the Lane block copies it. */
.readers, .lanes { margin: 0 20px; padding: 12px 0 16px; border-bottom: 1px solid var(--rule); }
.readers h2, .lanes h2 {
margin: 0;
padding: 8px 0;
font: 500 10px/1 var(--font-mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--mute-2);
}
.readerlist, .lanelist { margin: 0; padding: 0; list-style: none; }
.readerlist li, .lanelist li {
display: flex;
align-items: center;
flex-wrap: wrap;
@@ -300,7 +318,8 @@ button { cursor: pointer; }
min-height: 44px;
border-top: 1px solid var(--rule);
}
.readerlist form { margin: 0 0 0 auto; }
.reader-actions { display: flex; gap: 18px; margin-left: auto; }
.readerlist form { margin: 0; }
.reader-id {
font: 500 13px/1.4 var(--font-mono);
letter-spacing: .04em;
@@ -312,6 +331,30 @@ button { cursor: pointer; }
text-transform: uppercase;
color: var(--mute);
}
.reader-sightings, .lane-fact {
font: 500 10px/1 var(--font-mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--mute-2);
}
/* Two states the owner is meant to find rather than read for: a Reader whose
reports no longer defer a Poll, and a Lane that is not keeping its promise.
Both wear --patina — never ember, which means one thing, and never danger,
which is destruction. */
.reader-blocked, .lane-mark {
font: 500 10px/1 var(--font-mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--patina);
}
.lane-site {
font: 400 19px/1.2 var(--font-display);
color: var(--paper-dim);
}
/* The whole row leans patina when the Lane needs attention, so the scan is one
pass down the left edge rather than a read of every mark. */
.lanelist li.attention .lane-site { color: var(--patina); }
.lane-browser { padding: 12px 0 0; }
/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for
the new-chapter signal. */
.ghost.danger { color: var(--danger); }
@@ -600,6 +643,9 @@ button { cursor: pointer; }
box-shadow: inset 0 -2px 0 var(--ember);
}
.topbar form { margin-left: 18px; }
/* The admin page's topbar has no switch to fill the middle, so its back link
keeps company with Log out at the right edge instead of floating centre. */
.topbar .back { margin-left: auto; }
/* At phone width brand + switch + Log out do not fit on one line, so the
switch takes its own row under the wordmark rather than pushing Log out
off-screen. */
@@ -609,6 +655,9 @@ button { cursor: pointer; }
.libswitch { order: 3; margin-left: 0; }
.libswitch a { flex: 1; text-align: center; padding: 8px 14px; }
.topbar form { margin-left: 12px; }
/* The admin page has no switch to take the second row, so its brand claims
the first outright and the back link keeps Log out company below. */
.topbar:has(.back) .brand { flex: 1 1 100%; }
}
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
+39
View File
@@ -0,0 +1,39 @@
{{/* The owner's administrative page: everything that reaches past one Reader,
at its own address so it can be bookmarked rather than hunted for inside
the reading page. Owner-only at route registration (requireOwner), which
is why nothing in here re-tests who is asking. */}}
{{define "admin"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>BookmarkManager — Admin</title>
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css">
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
<div class="sheet">
<header class="topbar">
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
{{/* Back to the library, no switch: this page belongs to neither library,
and the ember-lit switch says which library you are reading. */}}
<a class="ghost back" href="/">Library</a>
<form method="post" action="/logout">
<button type="submit" class="ghost">Log out</button>
</form>
</header>
{{/* The live region wraps the swapped block rather than being it: the
refresh replaces the section wholesale, and a region recreated on every
update is never announced. */}}
<div aria-live="polite">{{template "lanes" .Lanes}}</div>
{{template "readers" .}}
</div>
</body>
</html>
{{end}}
+4 -2
View File
@@ -28,6 +28,10 @@
<a href="/?lib=novel&amp;tab=all" class="{{if eq .Lib "novel"}}active{{end}}"
{{if eq .Lib "novel"}}aria-current="page"{{end}}>Novels</a>
</nav>
{{/* The owner's only difference on this page: a link out to the
administrative one. It sits beside Log out rather than in the library
switch — that switch says which library, not which page. */}}
{{if .Owner}}<a class="ghost" href="/admin">Admin</a>{{end}}
<form method="post" action="/logout">
<button type="submit" class="ghost">Log out</button>
</form>
@@ -76,8 +80,6 @@
{{template "setup" .}}
{{if .Owner}}{{template "readers" .}}{{end}}
{{template "keyrow" .}}
{{template "recent" .}}
+41
View File
@@ -0,0 +1,41 @@
{{/* Poll Lane status: one row per Site, refreshing itself so a run can be
watched rather than sampled by reloading. The refresh is one attribute on
the fragment root and the endpoint answers with this same fragment, so the
swap replaces the element that asked for it.
Every figure here is read out of the running poller, never out of a table:
a Site absent from Rows has not completed a pass since the last restart,
which the empty state must say — zeroes would read as a stopped Lane. */}}
{{define "lanes"}}
<section class="lanes" id="lanes"
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML">
<h2>Poll Lanes</h2>
{{if .Rows}}
<ul class="lanelist">
{{range .Rows}}
<li{{if .Attention}} class="attention"{{end}}>
<span class="lane-site">{{.Site}}</span>
<span class="lane-fact">{{.Due}} due</span>
<span class="lane-fact">{{.Checked}} checked</span>
<span class="lane-fact">ran {{.Ran}}</span>
{{if .Gap}}<span class="lane-fact">gap {{.Gap}}</span>{{end}}
{{if .Clamped}}<span class="lane-mark">gap at floor</span>{{end}}
{{if .Refusing}}<span class="lane-mark">refusing</span>{{end}}
{{if .BrowserLost}}<span class="lane-mark">no browser</span>{{end}}
{{if .Stalled}}<span class="lane-mark">not checking</span>{{end}}
</li>
{{end}}
</ul>
{{else}}
<p class="setup-copy">No data yet — no Lane has completed a pass since the
backend started.</p>
{{end}}
<p class="setup-copy lane-browser">
{{if .PollerOff}}Polling is switched off in this deployment: no Lane runs,
and Latest Chapter comes from the userscripts alone.
{{else}}Browser sidecar:
{{if not .BrowserConfigured}}not configured — comix, kagane and novelfull
pages are not fetched through it{{else if .BrowserReachable}}reachable
{{else}}unreachable{{end}}.{{end}}</p>
</section>
{{end}}
+36 -17
View File
@@ -1,29 +1,48 @@
{{/* The owner's Reader roster. Rendered only for the owner (listView.Owner),
and re-rendered whole as the response to a revocation so the session
counts cannot describe the state before the tap. Revocation is
confirm-gated: it signs someone out of every device at once. */}}
{{/* The Reader roster, on the owner's administrative page. Re-rendered whole
as the response to an action so the counts and marks it shows cannot
describe the state before the tap. Both actions are confirm-gated: one
signs a Reader out of every device at once, the other wipes a record.
Owner-only at route registration, so nothing here re-tests who is asking. */}}
{{define "readers"}}
<details class="setup" id="readers">
<summary>Readers</summary>
<section class="readers" id="readers">
<h2>Readers</h2>
<p class="setup-copy">Everyone who has signed in through Discord. Revoking
signs a Reader out of every device; their library and bookmarks are
untouched, and they can sign in again.</p>
untouched, and they can sign in again. The Sighting counters record how
often a later Poll confirmed or contradicted what that Reader's browser
reported; enough contradictions stop their reports deferring a Poll, and
clearing the marks gives that back.</p>
<ul class="readerlist">
{{range .Readers}}
<li>
<span class="reader-id">{{.DiscordID}}</span>
<span class="reader-sessions">{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}</span>
{{/* The owner's own row never offers Revoke: it is the one row where the
button would sign the tapping browser out, and the endpoint refuses
it anyway. Logout is the deliberate way to do that. */}}
{{if and .Sessions (ne .ID $.OwnerID)}}
<form hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
hx-confirm="Revoking signs this Reader out on every device immediately. Revoke?">
<button type="submit" class="ghost danger">Revoke sessions</button>
</form>
{{end}}
<span class="reader-sightings">{{.Agreements}} confirmed / {{.Disagreements}} contradicted</span>
{{/* Blocked is spelled out rather than left to be worked out from two
numbers and a threshold. */}}
{{if .Blocked}}<span class="reader-blocked">deferral blocked</span>{{end}}
<span class="reader-actions">
{{/* Clearing restores a privilege, so it is a plain ghost button —
the destruction accent belongs to revocation alone. It is offered
on every row, including one reading zero: the remedy must be
findable before the counters climb, not after. */}}
<form hx-post="/readers/{{.ID}}/clear-marks" hx-target="#readers" hx-swap="outerHTML"
hx-confirm="Clearing wipes this Reader's whole Sighting record, confirmations included. Clear?">
<button type="submit" class="ghost">Clear marks</button>
</form>
{{/* The owner's own row never offers Revoke: it is the one row where the
button would sign the tapping browser out, and the endpoint refuses
it anyway. Logout is the deliberate way to do that. */}}
{{if and .Sessions (ne .ID $.OwnerID)}}
<form hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
hx-confirm="Revoking signs this Reader out on every device immediately. Revoke?">
<button type="submit" class="ghost danger">Revoke sessions</button>
</form>
{{end}}
</span>
</li>
{{end}}
</ul>
</details>
</section>
{{end}}
+18 -56
View File
@@ -50,6 +50,9 @@ type Handler struct {
// httpClient is the plain stdlib client that talks to Discord. It is not
// an injected interface: tests point APIBase at a stub server instead.
httpClient *http.Client
// lanes is the Poll Lane snapshot source the administrative page reads.
// Nil is a running deployment with no poller, not a bug.
lanes LaneReporter
}
// listView is what every list-rendering template receives.
@@ -77,14 +80,9 @@ type listView struct {
// It is not "newly registered": a Reader who deletes their last bookmark is
// in the same position and needs the same links.
EmptyLibrary bool
// Owner marks the acting Reader as the deployment's owner, which unlocks
// the Readers panel. Nothing else in the UI differs.
// Owner marks the acting Reader as the deployment's owner, which offers
// the link to the administrative page. Nothing else in the UI differs.
Owner bool
// Readers is the owner's roster, populated only for the owner's own page
// render and the revocation fragment. OwnerID travels with it so the roster
// can tell the owner's own row apart from the Readers they may revoke.
Readers []store.ReaderSummary
OwnerID int64
}
// PageURL and ListURL are the two link shapes every tab needs. Building them
@@ -111,7 +109,10 @@ type loginView struct {
// New parses every template up front so a broken one kills the process at
// startup rather than the first request that touches it.
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
//
// lanes is the administrative page's window onto the running Poller; nil means
// nothing is polling, which the page reports rather than hides.
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, lanes LaneReporter) (*Handler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
@@ -126,6 +127,7 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove
states: newOAuthStates(),
limiter: session.NewLoginLimiter(),
httpClient: &http.Client{Timeout: discordTimeout},
lanes: lanes,
}, nil
}
@@ -149,9 +151,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /install/manga-bookmark.user.js", h.requireSession(h.installUserscript("manga-bookmark.user.js")))
mux.HandleFunc("GET /install/novel-bookmark.user.js", h.requireSession(h.installUserscript("novel-bookmark.user.js")))
mux.HandleFunc("POST /rotate-token", h.requireSession(h.rotateToken))
// Owner-only: the one place the UI crosses the Reader boundary.
mux.HandleFunc("POST /readers/{id}/revoke", h.requireSession(h.revokeReaderSessions))
// Owner-only: every route that reaches past the acting Reader is gated in
// one place, so a missing gate is visible in the route list.
for _, rt := range h.adminRoutes() {
mux.HandleFunc(rt.pattern, h.requireOwner(rt.handler))
}
}
// staticHandler serves the embedded assets. An hour, not longer: assets are
@@ -240,14 +244,9 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if readerID == h.store.OwnerID() {
view.Owner, view.OwnerID = true, readerID
if view.Readers, err = h.store.Readers(); err != nil {
log.Printf("index readers: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
// The owner's page differs only by the link to the administrative page:
// the roster lives there now, so the page read every day is only reading.
view.Owner = readerID == h.store.OwnerID()
h.render(w, http.StatusOK, "app", view)
}
@@ -618,40 +617,3 @@ func (h *Handler) rotateToken(w http.ResponseWriter, r *http.Request) {
view := listView{Lib: store.KindManga, Rotated: true}
h.render(w, http.StatusOK, "setup", view)
}
// revokeReaderSessions logs one Reader out of every browser they are signed
// in on. Owner-only: it reaches across the Reader boundary every other handler
// respects, so the guard is a comparison against the seeded owner rather than
// a role a Reader could acquire. A non-owner gets 404 — the panel does not
// exist for them, so neither should the endpoint.
func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
if readerOf(r) != h.store.OwnerID() {
http.NotFound(w, r)
return
}
target, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "bad reader id", http.StatusBadRequest)
return
}
// The owner is not one of the Readers this endpoint reaches: revoking
// themselves would sign out the browser making the request, which is what
// logout is for. The roster hides the button; this refuses the hand-rolled
// POST behind it.
if target == h.store.OwnerID() {
http.NotFound(w, r)
return
}
if err := h.store.DeleteReaderSessions(target); err != nil {
log.Printf("revoke sessions: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
readers, err := h.store.Readers()
if err != nil {
log.Printf("revoke sessions: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.render(w, http.StatusOK, "readers", listView{Owner: true, Readers: readers, OwnerID: h.store.OwnerID()})
}
+31 -95
View File
@@ -7,7 +7,6 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
@@ -61,30 +60,15 @@ type Config struct {
// LatestPoll configures the background latest-chapter poller.
//
// Sizing: batch x (cooldown / interval) is how many series hold a true cooldown
// cadence — 14 x (1h / 10m) = 84 with these defaults, which covers this
// deployment. Past that nothing breaks; the effective cadence stretches to
// N x interval / batch and the oldest-checked-first ordering keeps it uniform.
// Only the kill switch lives here. Pace is per Site — rest time and gap are
// registry properties (internal/latest/sites.go, issue #100), because each
// Lane has to be able to differ from the others. The five environment
// settings that used to size a shared pace (cooldown, browser cooldown,
// interval, stagger, batch) are gone with it: no deployed .env may carry them.
type LatestPoll struct {
Enabled bool
Cooldown time.Duration
BrowserCooldown time.Duration
Interval time.Duration
Stagger time.Duration
Batch int
Enabled bool
}
const (
defaultPollCooldown = time.Hour
defaultBrowserPollCooldown = 6 * time.Hour
defaultPollInterval = 10 * time.Minute
defaultPollStagger = 20 * time.Second
defaultPollBatch = 14
// minPollCooldown keeps a typo from turning a polite background check into
// a hammer against sites that are already bot-scoring us.
minPollCooldown = 15 * time.Minute
)
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
@@ -107,66 +91,11 @@ func envBool(key string, def bool) bool {
}
}
// envDuration reads a duration env var. An unparseable or non-positive value
// falls back to def and logs rather than failing startup: the poller is an
// enhancement, and a typo in one of its knobs must not stop bookmark sync.
func envDuration(key string, def time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return def
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
log.Printf("config: %s=%q is not a positive duration, using %s", key, raw, def)
return def
}
return d
}
// envInt reads a positive integer env var, with the same fallback policy.
func envInt(key string, def int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
log.Printf("config: %s=%q is not a positive integer, using %d", key, raw, def)
return def
}
return n
}
func clampPollCooldown(name string, d time.Duration) time.Duration {
if d < minPollCooldown {
log.Printf("config: %s %s is below the %s floor, clamping", name, d, minPollCooldown)
return minPollCooldown
}
return d
}
// loadLatestPoll reads the poller's settings, clamping anything that would make
// it antisocial.
// loadLatestPoll reads the poller's settings. The pace knobs that used to be
// clamped here are registry properties now (issue #100), so there is nothing
// left to clamp.
func loadLatestPoll() LatestPoll {
p := LatestPoll{
Enabled: envBool("LATEST_CHAPTER_POLL_ENABLED", true),
Cooldown: envDuration("LATEST_CHAPTER_POLL_COOLDOWN", defaultPollCooldown),
BrowserCooldown: envDuration("LATEST_CHAPTER_POLL_BROWSER_COOLDOWN", defaultBrowserPollCooldown),
Interval: envDuration("LATEST_CHAPTER_POLL_INTERVAL", defaultPollInterval),
Stagger: envDuration("LATEST_CHAPTER_POLL_STAGGER", defaultPollStagger),
Batch: envInt("LATEST_CHAPTER_POLL_BATCH", defaultPollBatch),
}
p.Cooldown = clampPollCooldown("cooldown", p.Cooldown)
p.BrowserCooldown = clampPollCooldown("browser cooldown", p.BrowserCooldown)
// batch x stagger has to fit inside one tick or a batch is still running
// when the next one is due. Run() serialises them, so this degrades to a
// slower cadence rather than to overlapping fetches — worth a warning, not
// a failure.
if span := time.Duration(p.Batch) * p.Stagger; span > p.Interval {
log.Printf("config: batch(%d) x stagger(%s) = %s exceeds interval %s; batches will overrun their tick",
p.Batch, p.Stagger, span, p.Interval)
}
return p
return LatestPoll{Enabled: envBool("LATEST_CHAPTER_POLL_ENABLED", true)}
}
func loadConfig() Config {
@@ -200,7 +129,10 @@ func loadConfig() Config {
// newRouter wires routes and middleware. CORS is the outermost layer so
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
// /healthz is public.
func newRouter(s *store.Store, cfg Config) http.Handler {
//
// lanes may be nil — polling disabled, or its client could not be built. The
// admin page reports that rather than pretending Lanes exist.
func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler {
mux := http.NewServeMux()
h := &api.Handler{Store: s}
mux.HandleFunc("GET /healthz", api.Healthz)
@@ -231,7 +163,7 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
// The browser UI is always registered; signing in is Discord OAuth, so
// there is no password to forget and no gate to leave unset.
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
cfg.UserscriptPath, cfg.NovelUserscriptPath)
cfg.UserscriptPath, cfg.NovelUserscriptPath, lanes)
if err != nil {
log.Fatalf("web handler: %v", err)
}
@@ -346,11 +278,17 @@ func main() {
}
s.OnSeriesCreated = acq.Acquire
}
startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
// A nil *Poller must not become a non-nil interface holding a nil pointer:
// the admin page tests the reporter for nil to decide whether anything is
// polling at all.
var lanes web.LaneReporter
if poller := startLatestPoller(pollCtx, s, cfg.LatestPoll, browser); poller != nil {
lanes = poller
}
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: newRouter(s, cfg),
Handler: newRouter(s, cfg, lanes),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -377,7 +315,8 @@ func main() {
}
}
// newLatestPoller wires the configured cooldowns and fetchers into the poller.
// newLatestPoller wires the fetcher seams into the poller. Pace is registry
// property, not config (issue #100), so there are no knobs to pass through.
func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetcher) *latest.Poller {
var covers latest.BrowserCoverFetcher
if f, ok := browser.(latest.BrowserCoverFetcher); ok {
@@ -390,27 +329,23 @@ func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetch
CoverFetch: covers,
CoverBytesFetch: latest.NewCoverFetcher(),
Now: time.Now,
Cooldown: cfg.Cooldown,
BrowserCooldown: cfg.BrowserCooldown,
Interval: cfg.Interval,
Stagger: cfg.Stagger,
Batch: cfg.Batch,
}
}
// startLatestPoller launches the background poller unless it is disabled or its
// HTTP client cannot be built. Any problem here is logged and skipped: this
// feature going missing degrades the service to userscript-only latest-chapter
// tracking, which is exactly how it behaved before.
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) {
// tracking, which is exactly how it behaved before. It returns the running
// Poller, or nil when there is none — the admin page's Lane status reads it.
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) *latest.Poller {
if !cfg.Enabled {
log.Println("latest-chapter poller: disabled by config")
return
return nil
}
f, err := latest.NewTLSFetcher()
if err != nil {
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
return
return nil
}
// Nil browser: sites behind a JavaScript challenge are simply not polled,
// and their latest_chapter comes from the userscript alone — which is how
@@ -418,4 +353,5 @@ func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, brow
p := newLatestPoller(s, cfg, f, browser)
go p.Run(ctx)
return p
}
+21 -117
View File
@@ -9,31 +9,15 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/store"
)
func TestLoadLatestPollDefaults(t *testing.T) {
for _, k := range []string{
"LATEST_CHAPTER_POLL_ENABLED", "LATEST_CHAPTER_POLL_COOLDOWN",
"LATEST_CHAPTER_POLL_BROWSER_COOLDOWN", "LATEST_CHAPTER_POLL_INTERVAL",
"LATEST_CHAPTER_POLL_STAGGER", "LATEST_CHAPTER_POLL_BATCH",
} {
t.Setenv(k, "")
}
got := loadLatestPoll()
want := LatestPoll{
Enabled: true,
Cooldown: time.Hour,
BrowserCooldown: 6 * time.Hour,
Interval: 10 * time.Minute,
Stagger: 20 * time.Second,
Batch: 14,
}
if got != want {
t.Fatalf("loadLatestPoll() = %+v, want %+v", got, want)
t.Setenv("LATEST_CHAPTER_POLL_ENABLED", "")
if got := loadLatestPoll(); got != (LatestPoll{Enabled: true}) {
t.Fatalf("loadLatestPoll() = %+v, want %+v", got, LatestPoll{Enabled: true})
}
}
@@ -63,105 +47,25 @@ func TestLoadLatestPollEnabledParsing(t *testing.T) {
}
}
func TestLoadLatestPollClampsAndFallsBack(t *testing.T) {
tests := []struct {
name string
env map[string]string
wantFrom func(LatestPoll) any
want any
}{
{
name: "cooldown below the floor is clamped up",
env: map[string]string{"LATEST_CHAPTER_POLL_COOLDOWN": "1m"},
wantFrom: func(p LatestPoll) any { return p.Cooldown },
want: 15 * time.Minute,
},
{
name: "cooldown at the floor is kept",
env: map[string]string{"LATEST_CHAPTER_POLL_COOLDOWN": "15m"},
wantFrom: func(p LatestPoll) any { return p.Cooldown },
want: 15 * time.Minute,
},
{
name: "browser cooldown below the floor is clamped up",
env: map[string]string{"LATEST_CHAPTER_POLL_BROWSER_COOLDOWN": "1m"},
wantFrom: func(p LatestPoll) any { return p.BrowserCooldown },
want: 15 * time.Minute,
},
{
name: "browser cooldown at the floor is kept",
env: map[string]string{"LATEST_CHAPTER_POLL_BROWSER_COOLDOWN": "15m"},
wantFrom: func(p LatestPoll) any { return p.BrowserCooldown },
want: 15 * time.Minute,
},
{
name: "browser cooldown override is honoured",
env: map[string]string{"LATEST_CHAPTER_POLL_BROWSER_COOLDOWN": "8h"},
wantFrom: func(p LatestPoll) any { return p.BrowserCooldown },
want: 8 * time.Hour,
},
{
name: "browser cooldown unparseable value falls back",
env: map[string]string{"LATEST_CHAPTER_POLL_BROWSER_COOLDOWN": "six hours"},
wantFrom: func(p LatestPoll) any { return p.BrowserCooldown },
want: 6 * time.Hour,
},
{
name: "a valid override is honoured",
env: map[string]string{"LATEST_CHAPTER_POLL_INTERVAL": "5m"},
wantFrom: func(p LatestPoll) any { return p.Interval },
want: 5 * time.Minute,
},
{
name: "an unparseable duration falls back",
env: map[string]string{"LATEST_CHAPTER_POLL_INTERVAL": "ten minutes"},
wantFrom: func(p LatestPoll) any { return p.Interval },
want: 10 * time.Minute,
},
{
name: "a zero duration falls back",
env: map[string]string{"LATEST_CHAPTER_POLL_STAGGER": "0s"},
wantFrom: func(p LatestPoll) any { return p.Stagger },
want: 20 * time.Second,
},
{
name: "a valid batch is honoured",
env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "30"},
wantFrom: func(p LatestPoll) any { return p.Batch },
want: 30,
},
{
name: "a negative batch falls back",
env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "-5"},
wantFrom: func(p LatestPoll) any { return p.Batch },
want: 14,
},
{
name: "a non-numeric batch falls back",
env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "lots"},
wantFrom: func(p LatestPoll) any { return p.Batch },
want: 14,
},
// newLatestPoller wires the fetcher seams; pace lives in the registry, so
// nothing here sizes a cooldown any more.
func TestNewLatestPollerWiresFetchers(t *testing.T) {
tls := &latest.TLSFetcher{}
p := newLatestPoller(nil, LatestPoll{Enabled: true}, tls, nil)
if p.Fetch != tls {
t.Fatalf("Fetch not wired")
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for k, v := range tt.env {
t.Setenv(k, v)
}
if got := tt.wantFrom(loadLatestPoll()); got != tt.want {
t.Fatalf("got %v, want %v", got, tt.want)
}
})
if p.BrowserFetch != nil {
t.Fatalf("BrowserFetch = %v, want nil for a browser-less deployment", p.BrowserFetch)
}
}
func TestNewLatestPollerWiresCooldowns(t *testing.T) {
p := newLatestPoller(nil, LatestPoll{
Cooldown: time.Hour,
BrowserCooldown: 6 * time.Hour,
}, nil, nil)
if p.Cooldown != time.Hour || p.BrowserCooldown != 6*time.Hour {
t.Fatalf("poller cooldowns = %s/%s, want 1h/6h", p.Cooldown, p.BrowserCooldown)
if p.CoverFetch != nil {
t.Fatalf("CoverFetch = %v, want nil when the browser is absent", p.CoverFetch)
}
if p.CoverBytesFetch == nil {
t.Fatalf("CoverBytesFetch = nil, want the TLS cover fetcher")
}
if p.Now == nil {
t.Fatalf("Now = nil, want the live clock")
}
}
+3 -3
View File
@@ -49,7 +49,7 @@ func withBody(req *http.Request, body string) *http.Request {
// A refused credential is refused however plausible it looks: only a hash the
// readers table holds authenticates anything.
func TestUnknownCredentialRejected(t *testing.T) {
srv := newRouter(newTestStore(t), testConfig())
srv := newRouter(newTestStore(t), testConfig(), nil)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered")))
@@ -69,7 +69,7 @@ func TestUnknownCredentialRejected(t *testing.T) {
func TestPerReaderIsolation(t *testing.T) {
s := newTestStore(t)
registerReader(t, s, "other-reader")
srv := newRouter(s, testConfig())
srv := newRouter(s, testConfig(), nil)
ownerKey := "asura:solo"
putBookmark(t, srv, ownerKey, store.Bookmark{
@@ -267,7 +267,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
}
cfg := testConfig()
cfg.UserscriptPath = path
srv := newRouter(s, cfg)
srv := newRouter(s, cfg, nil)
oldCred := ownerCredential()
rr := httptest.NewRecorder()
+246 -16
View File
@@ -1,6 +1,7 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
@@ -15,6 +16,7 @@ import (
"testing"
"time"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/web"
@@ -26,11 +28,17 @@ import (
const testOwnerID = "owner-snowflake"
// newWebTestServer returns the full router plus the store behind it, so tests
// can seed rows and assert on what the handlers wrote back.
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
// can seed rows and assert on what the handlers wrote back. An optional lane
// reporter stands in for the running poller; omitted means none is running,
// which is what every test that is not about the admin page wants.
func newWebTestServer(t *testing.T, cfg Config, lanes ...web.LaneReporter) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
return newRouter(st, cfg), st
var reporter web.LaneReporter
if len(lanes) > 0 {
reporter = lanes[0]
}
return newRouter(st, cfg, reporter), st
}
// sessionCookie mints a live session row for the owner and returns the cookie
@@ -141,12 +149,12 @@ func discordConfig(stubURL string) web.DiscordConfig {
// oauthWebTestServer returns the full router, its store, and a Discord stub
// wired as the configured API — the starting point for sign-in tests.
func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
func oauthWebTestServer(t *testing.T, lanes ...web.LaneReporter) (http.Handler, *store.Store, *discordStub) {
t.Helper()
stub, srv := newDiscordStub(t)
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
router, st := newWebTestServer(t, cfg)
router, st := newWebTestServer(t, cfg, lanes...)
return router, st, stub
}
@@ -410,7 +418,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = tc.require
st := newTestStore(t)
router := newRouter(st, cfg)
router := newRouter(st, cfg, nil)
rr := completeSignIn(t, router, startSignIn(t, router))
if rr.Code != http.StatusForbidden {
@@ -626,24 +634,52 @@ func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
}
}
// The owner's own page carries the roster; nobody else's does.
func TestOwnerSeesReadersPanel(t *testing.T) {
// fakeLanes is the admin page's poller stand-in: one fixed snapshot, so the
// page's tests need neither a poller nor a Site.
type fakeLanes struct{ status latest.Status }
func (f fakeLanes) LaneStatus() latest.Status { return f.status }
// The roster moved off the reading page onto its own address: the owner gets a
// link, everyone else gets nothing, and the page itself lists every Reader with
// the counters and the two controls.
func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
signInCookie(t, router)
theirCookie := signInCookie(t, router)
ownerCookie := sessionCookie(t, st)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, st))
req.AddCookie(ownerCookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `id="readers"`) {
t.Fatal("the owner's page lacks the Readers panel")
if strings.Contains(body, `id="readers"`) {
t.Error("the reading page still carries the roster; it belongs on /admin")
}
if !strings.Contains(body, testOwnerID) {
t.Fatalf("the roster does not list the registered Reader:\n%s", body)
if !strings.Contains(body, `href="/admin"`) {
t.Error("the owner's reading page offers no link to the admin page")
}
if !strings.Contains(body, "Revoke sessions") {
t.Fatal("the roster offers no revocation control for a signed-in Reader")
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(theirCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if strings.Contains(rr.Body.String(), `href="/admin"`) {
t.Error("a non-owner was offered the admin link")
}
req = httptest.NewRequest(http.MethodGet, "/admin", nil)
req.AddCookie(ownerCookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin status = %d, want 200", rr.Code)
}
body = rr.Body.String()
for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} {
if !strings.Contains(body, want) {
t.Errorf("admin page lacks %q:\n%s", want, body)
}
}
// Exactly one revocable row: the other Reader's. The owner's own row carries
// the same session count and no button.
@@ -652,6 +688,200 @@ func TestOwnerSeesReadersPanel(t *testing.T) {
}
}
// Every administrative route is gated the same way, so the test walks the list
// the router registers rather than naming routes by hand: no session is 401,
// a signed-in non-owner is 404, and the address is not confirmed to either.
func TestAdminRoutesAreOwnerOnly(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
theirCookie := signInCookie(t, router)
ownerCookie := sessionCookie(t, st)
target := strconv.FormatInt(st.OwnerID(), 10)
patterns := web.AdminPatterns()
if len(patterns) == 0 {
t.Fatal("no administrative routes to test")
}
for _, pattern := range patterns {
method, path, ok := strings.Cut(pattern, " ")
if !ok {
t.Fatalf("route pattern %q has no method", pattern)
}
path = strings.Replace(path, "{id}", target, 1)
for _, tc := range []struct {
name string
cookie *http.Cookie
want int
}{
{"no session", nil, http.StatusUnauthorized},
{"non-owner", theirCookie, http.StatusNotFound},
} {
req := httptest.NewRequest(method, path, nil)
if tc.cookie != nil {
req.AddCookie(tc.cookie)
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != tc.want {
t.Errorf("%s %s as %s: status = %d, want %d", method, path, tc.name, rr.Code, tc.want)
}
}
req := httptest.NewRequest(method, path, nil)
req.AddCookie(ownerCookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code == http.StatusUnauthorized {
t.Errorf("%s %s as the owner: status = 401, the gate rejects the owner", method, path)
}
}
}
// The Lane block reports what the poller says, and marks the Lanes that need
// attention — a clamped gap, a refusal, a Site whose pages can only be read
// through a sidecar that is not there, and a Lane with Series waiting that its
// last pass did not read.
func TestAdminPageShowsLaneStatus(t *testing.T) {
lanes := fakeLanes{latest.Status{
Lanes: []latest.LaneState{
{Site: "asura", Due: 12, Checked: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second},
{Site: "kagane", Due: 3, Checked: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true},
{Site: "demonic", Due: 400, Checked: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true},
{Site: "comix", Due: 7, LastRun: time.Now(), Gap: time.Minute, Browser: true},
},
BrowserConfigured: true,
BrowserReachable: true,
}}
router, st, _ := oauthWebTestServer(t, lanes)
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{"asura", "kagane", "12 due", "12 checked", "gap 40s", "ran 1m30s ago", "gap at floor", "not checking", "reachable"} {
if !strings.Contains(body, want) {
t.Errorf("lane status lacks %q:\n%s", want, body)
}
}
// Nothing is refusing and the sidecar is up, so neither mark may appear:
// a mark the owner cannot act on is worse than none.
for _, unwanted := range []string{"refusing", "no browser"} {
if strings.Contains(body, unwanted) {
t.Errorf("lane status marks %q on a healthy run:\n%s", unwanted, body)
}
}
}
// A Lane whose pass never reached a figure must not have that figure drawn as
// a zero: a refusing Lane still reports the due count and gap its last real
// pass saw, and a Lane that has never reached one omits it entirely.
func TestLaneStatusOmitsUnknownGap(t *testing.T) {
lanes := fakeLanes{latest.Status{
Lanes: []latest.LaneState{{Site: "comix", LastRun: time.Now(), Refusing: true, Browser: true}},
BrowserConfigured: true,
BrowserReachable: true,
}}
router, st, _ := oauthWebTestServer(t, lanes)
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
if strings.Contains(body, "gap 0s") {
t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body)
}
if !strings.Contains(body, "refusing") {
t.Errorf("a refusing Lane is not marked as such:\n%s", body)
}
}
// No poller and a poller that has not finished a pass both render "no data
// yet" rather than zeroes that read as a stopped backend — but they are not
// the same fact, so the page must not blame the sidecar when nothing polls.
func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
for _, tc := range []struct {
name string
lanes []web.LaneReporter
want, unwant string
}{
{"no poller", nil, "Polling is switched off", "not configured"},
{"poller, no pass yet", []web.LaneReporter{fakeLanes{}}, "not configured", "Polling is switched off"},
} {
t.Run(tc.name, func(t *testing.T) {
router, st, _ := oauthWebTestServer(t, tc.lanes...)
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, "No data yet") {
t.Errorf("admin page with no Lane data does not say so:\n%s", body)
}
if !strings.Contains(body, tc.want) {
t.Errorf("admin page lacks %q:\n%s", tc.want, body)
}
if strings.Contains(body, tc.unwant) {
t.Errorf("admin page states %q, which is not what is wrong:\n%s", tc.unwant, body)
}
})
}
}
// A Reader past the disagreement threshold is rendered as blocked, and
// clearing their marks both zeroes the counters and lifts the block in the
// roster the response carries back.
func TestOwnerClearsReaderMarks(t *testing.T) {
st, dsn := newTestStoreURL(t)
router := newRouter(st, testConfig(), nil)
cookie := sessionCookie(t, st)
// The counters are filled by issue #103; until it lands the only way to
// stand a marked Reader up is to write the columns directly.
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
if _, err := db.Exec(`UPDATE readers SET sighting_agreements = 4, sighting_disagreements = 3 WHERE id = $1`, st.OwnerID()); err != nil {
t.Fatalf("mark reader: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, "4 confirmed / 3 contradicted") {
t.Errorf("roster does not report the Reader's marks:\n%s", body)
}
if !strings.Contains(body, "deferral blocked") {
t.Errorf("a Reader at the threshold is not rendered as blocked:\n%s", body)
}
req = httptest.NewRequest(http.MethodPost,
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/clear-marks", nil)
req.AddCookie(cookie)
rr = httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("clear marks: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
body = rr.Body.String()
if !strings.Contains(body, `id="readers"`) {
t.Fatalf("clear marks did not re-render the roster:\n%s", body)
}
if !strings.Contains(body, "0 confirmed / 0 contradicted") {
t.Errorf("roster does not report the cleared counters:\n%s", body)
}
if strings.Contains(body, "deferral blocked") {
t.Errorf("a cleared Reader is still marked blocked:\n%s", body)
}
}
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.tokenStatus = http.StatusInternalServerError
+4 -7
View File
@@ -58,14 +58,11 @@ services:
# Second script from the same bindmount; the novel library is a separate
# Violentmonkey install.
NOVEL_USERSCRIPT_PATH: ${NOVEL_USERSCRIPT_PATH:-/userscript/novel-bookmark.user.js}
# Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the kill
# switch; it only takes effect because these are listed here.
# Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the
# kill switch; it only takes effect because it is listed here. Pace is
# per Site in the registry (one Poll Lane per Site, issue #100) — the
# cooldown/interval/stagger/batch knobs are gone with the shared pace.
LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1}
LATEST_CHAPTER_POLL_COOLDOWN: ${LATEST_CHAPTER_POLL_COOLDOWN:-1h}
LATEST_CHAPTER_POLL_BROWSER_COOLDOWN: ${LATEST_CHAPTER_POLL_BROWSER_COOLDOWN:-6h}
LATEST_CHAPTER_POLL_INTERVAL: ${LATEST_CHAPTER_POLL_INTERVAL:-10m}
LATEST_CHAPTER_POLL_BATCH: ${LATEST_CHAPTER_POLL_BATCH:-14}
LATEST_CHAPTER_POLL_STAGGER: ${LATEST_CHAPTER_POLL_STAGGER:-20s}
# CDP endpoint for sites behind a JavaScript challenge (kagane,
# novelfull). The browser is not part of this stack — it runs on the home
# machine as its own unit (chrome/docker-compose.yml) and is reached over
@@ -19,8 +19,15 @@ and because the Series row now knows how many Readers hold it, the poll queue is
absorbs the shortfall. That ordering is only expressible because the split happened.
Raising throughput instead was rejected: sweeping 400 Series hourly needs the stagger
cut from 20s to ~9s, doubling request rate against sites that already bot-score the
single VPS IP.
cut from 20s to ~9s, doubling request rate against sites already fronted by Cloudflare
from the single VPS IP.
Corrected 2026-08-12: the original wording said those sites "bot-score" the VPS IP.
They do not — the 1-99 bot score is Enterprise Bot Management only, and no per-IP
request rate is documented as an input to challenge issuance
(`docs/research/cloudflare-bot-scoring-and-poll-cadence.md`). The decision stands on
its first argument, sweep depth versus the 1-hour cooldown; the rate-limit fear was
never evidenced.
## Only the Poll writes Series fields
+9 -2
View File
@@ -23,8 +23,15 @@ requests: the poller's due query joins bookmarks, production held four kagane
series and no bookmarks on any of them, and with no kagane bookmark the web UI
never rendered a kagane cover either.
The home machine has 5.9 GiB of swap and a residential egress, which Cloudflare
scores better than a datacenter IP. Both machines were already on the tailnet.
The home machine has 5.9 GiB of swap and a residential egress, which avoids the
cloud-hosting-IP signature Cloudflare's Bot Fight Mode documentedly challenges. Both
machines were already on the tailnet.
Corrected 2026-08-12: the original wording said Cloudflare "scores" a residential
egress better than a datacenter IP. There is no score on a free-plan zone; what is
documented is signature matching, and hosting-provider IP space is one of the
signatures (`docs/research/cloudflare-bot-scoring-and-poll-cadence.md`). Memory was
the load-bearing reason regardless.
This move is only safe because covers are persisted (ADR-0005's sibling work,
issue #43/#45) and the browser is on-demand (ADR-0005). Without stored covers a
+91
View File
@@ -0,0 +1,91 @@
# ADR-0010: Poll Lanes — one independent Poll stream per Site
Date: 2026-08-16
Status: accepted
## Decision
Replace the single shared polling pace with one **Poll Lane** per Site: an
independent goroutine that polls only that Site's Series, paced by that Site's
registry entry. Pace moves out of config and into the Site registry
(`internal/latest/sites.go`): every entry carries a `Rest` (how long a Series
rests between Polls) and a `Gap` (how long the Lane waits between fetches).
Rest is enforced by the due query's WHERE clause (`latest_checked_at <= now -
Rest`), never by a timer — the same mechanism that enforced the old cooldown.
The Lane enforces its own gap by sleeping between fetches. `effectiveGap` is
the registry gap, or one hour divided by the Site's eligible Series count when
that is smaller, never below one second.
The five environment settings that used to size the shared pace —
`LATEST_CHAPTER_POLL_COOLDOWN`, `_BROWSER_COOLDOWN`, `_INTERVAL`, `_BATCH`,
`_STAGGER` — are deleted. Only the kill switch `LATEST_CHAPTER_POLL_ENABLED`
remains. No deployed `.env` may carry the deleted knobs.
## Why
The shared pace capped the whole backend at roughly 180 Polls an hour (one
20-second stagger across one queue). ~60 Series today, scaling to hundreds or
thousands, would stretch the hour beyond what the New Chapter signal can
tolerate. Worse, the queue mixed Sites with very different costs: kagane and
comix pay seconds of a serialized single-tab Chrome per Poll (a challenged
page, ADR-0005), and one hostile Site burning its challenge timeout made every
other Site's Series wait — "one hostile Site can eat most of an hour".
Lanes fix both at once:
- **Throughput scales per Site.** The six Lanes fetch concurrently; a Lane's
own gap paces it. The browser Lanes' combined ceiling stays about 360 Polls
an hour (one tab), and when they cannot keep up the wait past Rest grows and
is logged every pass — the "behind by X" measurement, so the decision to
give browser Sites more pages is made from data.
- **Hostility is contained.** A refusal (two challenge-held reads in one
pass) stops only that Site's Lane for `refuseBackoff` (15m); the rest of
that Lane's Series stay unstamped and due. A lost browser gates the other
browser Lanes' passes for the same window — the flag is shared Poller
state, so the loss is noticed once instead of once per Lane per pass, and
decays after 15m so the Lanes probe again. One Site can no longer tax the
others.
## Tradeoffs and rejections
- **Per-Site env knobs** (e.g. `KAGANE_POLL_GAP`) rejected: the registry is
the single place pace lives, testable and reviewable; config knobs would
recreate the shared-pace sprawl with six times the surface. All six entries
are deliberately uniform at first — rest an hour, gap ten seconds — so the
structure exists to differ without inventing numbers for Sites that have
not earned them.
- **Dynamic gap** (`rest / eligible`) is the one knob that stays automatic:
a Site with more Series than one per ten seconds would otherwise back up
behind its own gap, and the per-Series share of the hour is the natural
pace. The ten-second default is not arbitrary: one request per ten seconds
is the strictest rate rule a free-plan Site can even express (per-zone
rate limiting, as documented in
`docs/research/cloudflare-bot-scoring-and-poll-cadence.md`), so the
default pace is exactly what the most restrictive Site would demand of us.
The computed gap never goes below one second and logs loudly when the
floor engages.
- **Timer-based pacing** rejected: the old ticker made the poller's rate a
function of wall clock rather than of what was due. The due-query cutoff is
the only rate authority; the Lane sleep just prevents hammering.
- **Batch size** (the old `_BATCH` cap) is gone with the shared pace: a Lane
processes everything due, paced by its gap. There is no global queue left
to bound.
## Constraints preserved
- Stamp-before-fetch ("attempted" semantics): an untried Series stays due, so
a browser that appears after a restart finds its full queue waiting.
- Browser wake gate (ADR-0005): a browser Lane leaves Chrome asleep below
five due Series and 15 minutes of wait, per Lane.
- The browser is not in the API stack (ADR-0006): an unreachable browser
degrades a Lane exactly as an unset `BROWSER_WS_URL` — browser-only Sites
skipped, plain-TLS unaffected, stored covers still served.
- Cover heals moved to background goroutines (joined by the test suite via
`waitCovers`) so a slow cover CDN cannot consume a Lane's gap.
Supersedes the pace mechanics of ADR-0003's "raise throughput instead" note
(the stagger cut it rejected is what the per-Lane gap replaces) and the
6-hour browser cooldown introduced with the browser-backed Sites; the
1-hour browser rest was already cleared as safe by
`docs/research/cloudflare-bot-scoring-and-poll-cadence.md`.
@@ -0,0 +1,139 @@
# ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them
Date: 2026-08-16
Status: accepted
## Decision
A **Sighting** is the Latest Chapter the Reader's own browser read off the
Series page and PUT to the backend. It is now allowed to stand in for a Poll,
under one restriction and one ceiling:
- **Solitary Series only.** A Sighting defers the Poll of a Series exactly one
Bookmark points at. A Series two Readers share is Polled on schedule no matter
how recently it was sighted.
- **One rest of standing.** A Sighting postpones Polls for one Rest
(`defaultRest`, an hour), not forever: a Series nobody visits again returns to
the normal schedule by itself.
- **Six-rest ceiling.** `sightingCeilingRests = 6`, counted in the Site's own
Rest — six hours everywhere today. However many Sightings arrive, a Series
unpolled that long is Polled.
Both live in the due query's HAVING clause (`store.DueForLatestCheck`), beside
the Rest cutoff — the same place the schedule has always been decided, so no
timer and no second code path can disagree with it.
Attribution and judgement:
- `Store.RecordSighting` runs *before* the Upsert that stores the reported
value, because the raise test needs the row as it stands. A report that raises
the stored Latest Chapter names its Reader in `series.latest_raised_by`.
- The Poll is the oracle. `Poller.checkOne` already compares what the Site
publishes against what is stored, so judgement costs no extra request: a lower
number contradicts the Sighting (`sighting_disagreements + 1`, both numbers and
the Reader logged), the same number confirms it (`sighting_agreements + 1`), a
**higher** number is the Site publishing and means nothing either way — but it
does clear the attribution (`Store.ClearSightingAttribution`), because the
value stored afterwards is the Poll's own and nobody must answer for it.
- At `SightingDisagreementLimit` (3) that Reader's Sightings stop deferring
anything. They still write the Latest Chapter — the penalty removes a
privilege, it does not silence anyone.
- `SightingAgreementsToClear` (20) consecutive confirmations forgive the
disagreements. A disagreement resets the run to zero.
- The owner clears marks from the administration page (issue #102, shipped
first precisely so a false mark has a remedy the day the mechanism lands).
One client change was required, and only one. Both userscripts stopped short of
PUTting a read whose number had not moved (`applyLatestChapterIfChanged`), so
the case this whole mechanism exists for — visiting a Series with nothing new —
never reached the backend. `reportLatestChapter` now sends it, skipping only the
local write and the re-render. A numberless PUT (favourite toggle, progress from
a chapter page) is not a Sighting and defers nothing: nobody read the Series
page, so there would be nothing to judge later.
## Why
Most of the backend's work was redundant. The userscript reads the Latest
Chapter on every Series page visit; minutes later the Poll Lane fetches the same
page for the same number. Deferring on a report converts a visit into a Poll
saved, which is Lane capacity handed back to Series nobody is
reading.
The restriction is the whole safety argument, and it is about **blast radius**,
not about trust arithmetic:
- On a solitary Series, a wrong report can only mislead the Reader who made it.
There is nobody else's ember to falsify.
- On a shared Series it could mislead someone else, so a report never postpones
anything there.
The ceiling bounds the damage in time: a false value dies within six hours
whatever happens, because the Poll that finds it is guaranteed. That is also
what makes lying pointless — the six-hour audit is certain, not sampled, so a
determined attacker buys at most three ceilings' worth of a wrong number on
their own Series and then loses deferral entirely.
The cost of recovery is deliberate. An agreement is only recorded when a later
Poll confirms a Sighting, so twenty agreements are twenty Polls of Series that
Reader bookmarks — hours to days of real time, not twenty page views. Waiting is
therefore not a strategy, and credit cannot be banked in advance.
## Tradeoffs and rejections
- **Trusting a Sighting on a shared Series** rejected: it is the only case where
one Reader's mistake reaches another Reader's list, and no amount of
reputation makes that recoverable within the six-hour window.
- **Cross-Reader agreement, voting, weighting, consensus scoring** rejected on
evidence: every truth-discovery method estimates source reliability by
comparing sources on the same object, and the standard survey states outright
that an object provided by very few sources cannot have its confidence
evaluated — Li, Gao, Meng, Li, Su, Zhao, Fan, Han, *A Survey on Truth
Discovery*, SIGMOD Record 45(1), 2016 (arXiv:1505.02463), §"Challenges" on
sparse sources. With the two Readers this backend actually has, a
disagreement is a coin flip. The Poll is an authoritative oracle, so it is
the only judge.
- **A randomised audit** (Poll a fraction of deferred Series) rejected in favour
of the fixed ceiling. Sampling an oracle against untrusted reports is the
gold-question technique from crowdsourcing quality control — Le, Edmonds,
Hester, Biewald, *Ensuring quality in crowdsourced search relevance
evaluation: the effects of training question distribution*, SIGIR 2010
Workshop on Crowdsourcing for Search Evaluation, which inserts known answers
sporadically and adjusts each worker's trust from them. The ceiling is the
same idea made deterministic: sampling prices an attack in expectation, a
guaranteed six-hour audit prices it as a certainty, which is what makes the
solitary-Series rule defensible in one sentence.
- **A trust *ratio*** (agreements over judgements, as that same gold-question
scheme uses) rejected for two thresholds: a ratio lets an attacker bank
credit first and spend it on lies later, and it needs the owner watching a
score to act. Three-and-twenty is a threshold both ways — a disagreement
resets the run to zero, so credit cannot be pre-bought, and recovery happens
without the owner in the loop.
- **Blocking a marked Reader's writes** rejected: the Latest Chapter they report
is still the best available value, and their Sightings must keep being judged
or they could never earn the privilege back.
- **Per-Series flagging** rejected in favour of per-Reader marks: a Series is
not the thing that can be wrong. Naming the Reader and logging both numbers is
also what distinguishes a broken Site adapter (every Reader of that Site
contradicted at once) from one bad actor.
- **Timers or a background reputation job** rejected: deferral is recomputed
from live facts every round — Bookmark count and sighting timestamp — so a
Series that gains a second Bookmark stops deferring at once, with nothing to
invalidate. The Reader's marks are the one input read earlier, when the
Sighting is recorded rather than when the round runs: a Reader who crosses
the threshold, or has their marks cleared, changes behaviour from their next
Sighting on, and the standing they already bought lasts out its rest. That is
bounded by one rest and costs one subselect instead of joining `readers` into
the due query on every round.
## Constraints preserved
- A Sighting is not Progress: it may move the Latest Chapter and nothing else.
`updated_at` never moves, so a report cannot reorder the list (ADR-0004).
- The Latest Chapter is a Series-level fact (ADR-0003): a Sighting writes the
shared row, so every Reader of a shared Series sees it immediately — deferral
is the only thing the solitary rule withholds.
- Ember means new chapter only (`docs/design-system.md`): a marked Reader
renders no differently in their own list, and nothing about the trust model
reaches the Series list's colour.
- The Poll remains authoritative. Where a Sighting and a Poll disagree, the
Poll's value is what gets stored.
+17 -4
View File
@@ -10,7 +10,7 @@ Implemented in:
| Surface | Files |
| --- | --- |
| Web UI (login, list, card, empty, errors) | `backend/internal/web/static/style.css`, `backend/internal/web/templates/{app,card,list,login,chrome,icons}.html`, `backend/internal/web/static/filter.js` |
| Web UI (login, list, card, empty, errors, admin) | `backend/internal/web/static/style.css`, `backend/internal/web/templates/{app,admin,lanes,readers,card,list,login,chrome,icons}.html`, `backend/internal/web/static/filter.js` |
| Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE |
## 1. The one idea
@@ -73,6 +73,7 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| `--moss` | `#7fae86` | `#3d6c46` | finished accent |
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
| `--patina` | `#5fb3a6` | `#1f6f66` | admin page only — a Poll Lane needing attention, a Reader whose reports are blocked |
| `--play-hot-line` | `#3a1d18` | `#f0cfc6` | desktop cell border, play when `.is-new` |
| `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on |
| `--asura` | `#7d93a5` | `#4f6b80` | site tag |
@@ -81,9 +82,13 @@ Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the
| `--kagane` | `#9a8aa5` | `#6f5f7d` | site tag |
| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
`--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately:
one accent per action, so a press says which lane it belongs to, with none of
them competing with ember. Dark is the default (`color-scheme: dark light`);
`--slate`/`--moss`/`--clay`/`--brass`/`--patina` are held at the same weight
deliberately: one accent per meaning, so a press says which lane it belongs to,
with none of them competing with ember. `--patina` is the admin page's only
colour — a cool verdigris, the far side of the wheel from ember's crimson and
clear of the archive blue: system health is neither a new chapter nor
destruction, so it borrows neither `--ember` nor `--danger`.
Dark is the default (`color-scheme: dark light`);
light is a `@media (prefers-color-scheme: light)` override of the same names.
**Any new colour must be added in both branches** — light is not a filter over
dark, the hues are re-tuned.
@@ -140,6 +145,14 @@ Recurring specs (copy these rather than inventing sizes):
main#list article.card … | .empty
```
The owner's admin page (`admin.html`) is the same sheet with two sections in
place of the list — `.lanes` (Poll Lane rows) and `.readers` (the roster) —
and no library switch: it belongs to neither library, so its topbar carries a
plain `.ghost.back` link home. Both sections are eyebrow + hairline-separated
rows, the shape the roster already had as a fold-out. `.lanes` refreshes itself
every 30s via `hx-get="/ui/admin/lanes"` with `hx-swap="outerHTML"`; the roster
re-renders only in answer to an action.
**Brand mark**: an inline `<svg class="mark">` (`viewBox="0 0 200 172"`),
defined once in `chrome.html`'s `mark` template and reused by `app.html` and
`login.html` so it takes the page's `--ink`/`currentColor`/`--ember` rather
@@ -0,0 +1,243 @@
# Cloudflare bot scoring and poll cadence — what is actually documented
Research note for the browser-backed poller cadence decision (kagane.to, novelfull.com, comix.to). All pages were fetched live from **developers.cloudflare.com / blog.cloudflare.com on 2026-08-12**. Primary sources only: Cloudflare's own documentation, Cloudflare blog posts, and RFCs/standards where noted. Where Cloudflare does not publicly document something, this note says **`Not publicly documented`** instead of guessing. Repo-measured facts from the existing poller work are reused without re-derivation and marked as such.
The site configuration of the three challenged sites (which plan, which bot product, Challenge Passage setting, whether Precursor is enabled) is **not observable from outside** — Cloudflare does not expose a zone's security configuration to anonymous clients. Anything in this note that depends on those unknowns is flagged `[INFERENCE]`.
---
## Short answer
**No — polling once per hour per series, from one residential IP through one real Chrome holding a valid `cf_clearance`, carries no documented challenge risk beyond polling every six hours.** Challenge issuance on Free/Pro-grade protection (Bot Fight Mode, WAF rules) is signature- and fingerprint-driven (headless browsers, cloud-hosted IPs, browser signals); the only rate-aware detector — the per-request bot score — exists solely on Enterprise Bot Management, and free-plan Rate Limiting Rules count per-IP over 10-second windows, which 20–60 requests/hour cannot trip. Both cadences re-solve the challenge every visit anyway: `cf_clearance` expires after **30 minutes by default** (site-configurable), so a 1-hour gap always finds it expired. The documented lever that matters — already verified in this repo — is **fingerprint quality**: real Chrome + real timezone clears in ~4 s; headless variants never do. Residual, undocumented risk is site-specific: Challenge Passage, Precursor (behavior-bound re-challenge), and custom WAF rules are zone settings not observable from outside.
---
## Summary answer table
| Question | Answer | Section |
|---|---|---|
| What is a bot score? | Integer 1–99 = Cloudflare's certainty a request is automated; **Enterprise Bot Management only**; everyone else gets coarse "bot groupings" (Pro+ analytics) or nothing. | §1 |
| Is request frequency documented as a bot-score input? | Partially: ML inputs are "headers, session characteristics, and browser signals"; the `__cf_bm` cookie "measures a single user's request pattern". **No numeric rate threshold is documented.** Volume policing is Rate Limiting, a separate product. | §1, §5 |
| What can a free-plan site deploy? | Bot Fight Mode only: challenges *signatures* (headless browsers, cloud-hosting IPs) with a computational challenge; JavaScript Detections forced on; no scores, no analytics, cannot be skipped/customized. | §2 |
| Does the free tier score continuously? | **No.** No score exists on Free at all — granular scores need Enterprise Bot Management, groupings need Pro+. BFM just challenges signature matches. | §2 |
| What does `cf-mitigated: challenge` mean? | The response was a Cloudflare Challenge Page (any type); `challenge` is the only value; body is always `text/html`. | §3 |
| How is a successful solve remembered? | `cf_clearance` cookie, issued with `SameSite=None; Secure; Partitioned`; suppresses challenges while valid. | §3, §4 |
| `cf_clearance` lifetime? | **30 minutes by default**, configurable by the site via Challenge Passage (15–45 min recommended); +skew minutes; +1 h for XHR. | §4 |
| Is `cf_clearance` bound to IP / device? | Documented: "securely tied to the specific visitor and device it was issued to"; the *solve request* must come from the same IP that received the challenge (different IP → invalid solve → challenge loop). Replay from another machine/IP is therefore **not** valid. | §4 |
| What invalidates clearance early? | Precursor (if enabled): suspicious session → clearance reduced/invalidated, re-challenge even before expiry. Zone-level toggle; unobservable from outside. | §4 |
| Does polling more often raise challenge risk? | **No documented mechanism at 20–60 req/hour.** Free-plan rate limiting is 10 s/IP-only; DDoS thresholds are ~1,000 errors/sec. Scores (the only rate-aware thing) are Enterprise-only. | §5 |
| Is there a documented "legitimate poller" path? | Yes, but it requires **self-identification** (Web Bot Auth signature or published IP list + stable UA) via the verified-bots application — not anonymity. robots.txt is voluntary; nothing exempts anonymous scrapers. | §6 |
---
## 1. What a bot score is and what feeds it
### 1.1 The score itself
Cloudflare documents the bot score as "a score from _1_ to _99_ that indicates how likely that request came from a bot" — 1 = quite certain automated, 99 = quite certain human. Source: [Bot scores — Cloudflare docs](https://developers.cloudflare.com/bots/concepts/bot-score/), read 2026-08-12.
Two access tiers, both gated:
- **Granular 1–99 scores are only available to Enterprise customers who purchased Bot Management.** "All other customers can only access this information through bot groupings in Bot Analytics" (categories: `Not computed` = 0, `Automated` = 1, `Likely automated` = 2–29, `Likely human` = 30–99, `Verified bot`). Bot groupings themselves require "a Pro plan or higher". Source: [Bot scores — Cloudflare docs](https://developers.cloudflare.com/bots/concepts/bot-score/), read 2026-08-12.
- A score of 0 means "Bot Management did not evaluate the request" (redirected, handled by another feature) — "does not indicate the request is safe or human". Same source.
So: **on a Free-plan site there is no bot score at all, for anyone.** `[INFERENCE]` the three challenged sites are almost certainly not Enterprise Bot Management customers, but this is not externally verifiable.
### 1.2 The detection engines (Enterprise Bot Management)
Cloudflare documents four engines, all stated to apply to Enterprise Bot Management (the bot-score page: "The following detection engines only apply to Enterprise Bot Management"). Sources: [Bot scores — Cloudflare docs](https://developers.cloudflare.com/bots/concepts/bot-score/) and [Bot detection engines — Cloudflare docs](https://developers.cloudflare.com/bots/concepts/bot-detection-engines/), both read 2026-08-12.
| Engine | Documented behavior | Score it produces |
|---|---|---|
| **Heuristics** | "Processes all requests"; pattern matching against "a growing database of malicious fingerprints". | 1 for high-confidence deterministic detections; occasionally 29 "where Cloudflare has identified automated traffic and is still assessing traffic overlap" |
| **Machine learning** | Supervised model, trained on "billions of daily requests". Input variables: "headers, session characteristics, and browser signals". Output: "predicted probability that a client is human (such as the probability of successfully solving a Challenge)". | Most scores 2–99 |
| **Anomaly detection** | Unsupervised; learns a per-domain baseline, flags outlier requests; **deprecated, not onboarding new customers**. | 1 |
| **JavaScript detections** | "Identifies headless browsers and other automation tools" via "a lightweight, invisible JavaScript injection"; runs client-side; "blocks, challenges, or passes requests to other engines". Enabled by default (but optional) in Bot Management. | Pass/fail (`cf.bot_management.js_detection.passed`), not a score |
Crucially, the ML engine's documented inputs are *headers, session characteristics, and browser signals* — **no rate or per-IP volume parameter is listed.** The only place request patterns appear is the `__cf_bm` cookie note: "Cloudflare uses the `__cf_bm` cookie to smooth out the bot score and reduce false positives… The Bot Management cookie measures a single user's request pattern and applies it to the machine learning data to generate a reliable bot score for all of that user's requests." ([Bot scores — Cloudflare docs](https://developers.cloudflare.com/bots/concepts/bot-score/), read 2026-08-12). So frequency is *a* signal inside Enterprise Bot Management via the session cookie — **but no numeric threshold, window, or per-IP rate is published anywhere.** `Not publicly documented`: any specific requests-per-hour / requests-per-IP value that raises or lowers a bot score.
### 1.3 Rate limiting is a separate product
Volume enforcement is not part of bot scoring at all. Rate Limiting Rules are a distinct WAF product with their own evaluation phase (`http_ratelimit`, running after custom rules and before SBFM). Sources: [Rate limiting rules — Cloudflare docs](https://developers.cloudflare.com/waf/rate-limiting-rules/) and [Security features interoperability — Cloudflare docs](https://developers.cloudflare.com/waf/feature-interoperability/), read 2026-08-12. See §5 for what the Free plan's version of that product can actually do.
---
## 2. The free-plan reality
### 2.1 What each plan gets
Cloudflare's plan table ([Plans — Cloudflare docs](https://developers.cloudflare.com/bots/plans/), read 2026-08-12):
| Plan | Bot product | Documented detections | Action | Control |
|---|---|---|---|---|
| **Free** | **Bot Fight Mode** (BFM) | "Simple bots from cloud hosting providers and headless browsers" | "Cloudflare issues a computationally expensive challenge" | Applied to all traffic across the domain; no exceptions possible |
| **Pro / Business / Enterprise (no BM)** | **Super Bot Fight Mode** (SBFM) | Configurable actions per bot category (Definitely automated / Likely automated / Verified bots) | Challenge or block | Runs on Ruleset Engine; **can** be skipped via custom rules |
| **Enterprise + Bot Management** | Bot Management | "Simple and sophisticated bots, headless browsers, and domain-specific anomalies" | Customer-chosen (block, challenges) | Per-path / per-IP rules; access to bot score, JA3/JA4, bot tags, detection IDs |
Sources: [Plans — Free](https://developers.cloudflare.com/bots/plans/free/), [Plans — Bot Management for Enterprise](https://developers.cloudflare.com/bots/plans/bm-subscription/), [Bot Fight Mode — Cloudflare docs](https://developers.cloudflare.com/bots/get-started/bot-fight-mode/), [Super Bot Fight Mode — Cloudflare docs](https://developers.cloudflare.com/bots/get-started/super-bot-fight-mode/) — all read 2026-08-12.
### 2.2 Bot Fight Mode specifics (the Free-plan product)
- Identifies "traffic matching patterns of known bots" and "issues computationally expensive challenges that force the requesting client to perform CPU-intensive calculations". ([Bot Fight Mode — Cloudflare docs](https://developers.cloudflare.com/bots/get-started/bot-fight-mode/), read 2026-08-12.)
- It "does not run on the Ruleset Engine — it operates in a separate evaluation pipeline where _Skip_, _Bypass_, and _Allow_ actions have no effect"; **you cannot bypass or skip BFM** with custom rules or Page Rules. ([Bot Fight Mode — Cloudflare docs](https://developers.cloudflare.com/bots/get-started/bot-fight-mode/) and [Security features interoperability — Cloudflare docs](https://developers.cloudflare.com/waf/feature-interoperability/), read 2026-08-12.)
- **JavaScript Detections is automatically enabled for BFM customers and cannot be disabled.** ([Bot Fight Mode — Cloudflare docs](https://developers.cloudflare.com/bots/get-started/bot-fight-mode/), read 2026-08-12.) This is the documented hook that explains the repo's `headless-shell` / `HeadlessChrome` failures: JSD "identifies headless browsers" ([JavaScript detections — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/), read 2026-08-12).
- False positives on *legitimate automated* traffic are acknowledged as expected behavior: "false positives can occur where legitimate human or automated traffic is incorrectly challenged or blocked", and the only remedies are disabling BFM or upgrading to Bot Management. ([Handle False Positives — Cloudflare docs](https://developers.cloudflare.com/bots/troubleshooting/false-positives/), read 2026-08-12.)
### 2.3 Does the free tier "score" continuously?
**No.** The Free plan exposes no score, no bot analytics (groupings need Pro+), and no per-request decision data. BFM is a static on/off toggle that challenges signature matches; there is no continuous per-request score on Free. ([Plans — Free](https://developers.cloudflare.com/bots/plans/free/) and [Bot scores — Cloudflare docs](https://developers.cloudflare.com/bots/concepts/bot-score/), read 2026-08-12.) JSD *runs* on every HTML request even on Free (forced by BFM), but the only documented way to act on its result — the `cf.bot_management.js_detection.passed` field — is gated behind an Enterprise Bot Management subscription ("Prerequisites: You must have an Enterprise Bot Management subscription"). ([JavaScript detections — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/), read 2026-08-12.) On Free, JSD output feeds BFM's internal challenge decision; `Not publicly documented` exactly how.
`[INFERENCE]` The observed behavior on the three sites (real Chrome + real timezone passes in ~4 s; headless variants never do) is consistent with either BFM or a WAF custom rule using a challenge action — Cloudflare does not expose which product a site runs, and the failure signature (HeadlessChrome UA / headless-shell never passing) matches JSD's documented headless-browser detection either way.
---
## 3. Managed Challenge / JS challenge mechanics and `cf-mitigated`
### 3.1 What the observed response is
A `403` with `cf-mitigated: challenge`, `server: cloudflare`, and a "Just a moment…" body is a **Cloudflare Challenge Page**. Cloudflare documents: "the Challenge Page response (regardless of the Challenge Page type) will have the `cf-mitigated` header present and set to `challenge`… `challenge` is the only valid value. The header is set for all Challenge Page types", and "the content-type of a challenge will be `text/html`". ([Detect a Challenge Page response — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/detect-response/), read 2026-08-12.)
### 3.2 What a Challenge Page does
"An interstitial Challenge Page… acts as a gate between the visitor and your website… The Challenge Page intercepts the visitor… by holding the request and evaluating the browser environment for automated signals, and serving a challenge. The visitor cannot reach their destination without passing the challenge." ([Interstitial Challenge Pages — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/), read 2026-08-12.)
Three variants, in increasing severity ([Interstitial Challenge Pages — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/), read 2026-08-12):
- **Non-Interactive**: Cloudflare judges automation from browser signals gathered by injected JS; the page needs no human interaction, typically < 5 s of JS processing.
- **Managed Challenge**: "Cloudflare dynamically chooses the appropriate type of challenge… based on the characteristics of a request from the signals indicated by their browser. Most human visitors are automatically verified and the Challenge Page will display **Successful**. However, if Cloudflare detects non-human attributes… they may be required to interact." Cloudflare's stated recommendation for WAF rules.
- **Interactive**: requires explicit human interaction (CAPTCHA-style). Cloudflare's "End the CAPTCHA era" position is that Managed Challenges should make this rare.
Cloudflare's own framing matches the repo's ~4 s real-Chrome solve: a normal browser passes with no interaction (Managed Challenge auto-verify or Non-Interactive JS processing).
### 3.3 Which product issues which challenge
Documented mapping ([How Challenges work — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/concepts/how-challenges-work/), read 2026-08-12):
| Trigger | Challenge type |
|---|---|
| WAF custom rules, **rate limiting rules**, IP Access rules | Interstitial Challenge Page |
| Bot Management | JavaScript Detections (invisible, per-request) |
| **Bot Fight Mode / Super Bot Fight Mode** | Interstitial Challenge Page |
| Under Attack Mode | Managed Challenge |
### 3.4 How a successful solve is remembered
Solving issues the **`cf_clearance`** cookie: "Clearance Cookie stores the proof of challenge passed. It is used to no longer issue a challenge if present. It is required to reach an origin server." ([Cloudflare Cookies — Cloudflare docs](https://developers.cloudflare.com/fundamentals/reference/policies-compliances/cloudflare-cookies/), read 2026-08-12.) "When that visitor tries to access other parts of your website, Cloudflare evaluates the cookie before presenting another challenge. If the cookie is still valid, no challenges will be shown." ([Challenge Passage — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/challenge-passage/), read 2026-08-12.)
`cf_clearance` is set with `SameSite=None; Secure; Partitioned`; because of the `Partitioned` (CHIPS) attribute, "a clearance obtained in one top-level context is not reused in a different top-level context" — so a clearance from kagane.to does not carry to comix.to even on the same browser. ([SameSite cookie interaction — Cloudflare docs](https://developers.cloudflare.com/waf/troubleshooting/samesite-cookie-interaction/), read 2026-08-12.)
---
## 4. `cf_clearance` — lifetime, binding, invalidation (the key question)
### 4.1 Lifetime
- **Default: 30 minutes.** "By default, the `cf_clearance` cookie has a lifetime of 30 minutes. Cloudflare recommends a setting between 15 and 45 minutes." The site owner can change it via the **Challenge Passage** setting. ([Challenge Passage — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/challenge-passage/), read 2026-08-12; also [SameSite cookie interaction — Cloudflare docs](https://developers.cloudflare.com/waf/troubleshooting/samesite-cookie-interaction/), read 2026-08-12.)
- Validation grace: "a few extra minutes are included to account for clock skew. For XmlHTTP requests, an extra hour is added to the validation time." ([Challenge Passage — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/challenge-pages/challenge-passage/), read 2026-08-12.)
- "The Challenge Passage does not apply to rate limiting rules." Same source.
- `Not publicly documented`: whether the three target sites have changed the Challenge Passage from the 30-minute default, and whether there is any maximum value Cloudflare enforces.
**Consequence for cadence:** with the default 30-minute TTL, *any* poll cadence ≥ 1 hour finds the cookie expired and re-solves the challenge on every visit. A 1-hour and a 6-hour cadence therefore differ only in *how many times per day* the browser re-solves (~4× for the same series), not in whether a re-solve happens. This repo already measured the re-solve cost: ~4 s with real Chrome + real timezone. `[INFERENCE]` a site could raise the Challenge Passage to hours/days, which would make a 1-hour cadence *cheaper* (cookie still valid, no re-solve) — but that setting is unobservable and unlikely to be long on free manga sites.
### 4.2 What it is bound to — can clearance be replayed from another IP?
Documented statements, both from Cloudflare's own docs:
1. **Device/visitor binding:** "The cookie is securely tied to the specific visitor and device it was issued to, preventing reuse across machines." ([Clearance — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/), read 2026-08-12.)
2. **IP binding of the solve:** under Challenge limitations, Cloudflare lists "Client software where the solve request of a Managed Challenge comes from a different IP than the original IP a Challenge request was issued to. For example, if you receive the Challenge from one IP and solve it using another IP, the solve is not valid and you may encounter a Challenge loop." ([How Challenges work — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/concepts/how-challenges-work/), read 2026-08-12.)
3. **Top-level-site binding** via CHIPS partitioning: clearance is not reused across embedding contexts. ([SameSite cookie interaction — Cloudflare docs](https://developers.cloudflare.com/waf/troubleshooting/samesite-cookie-interaction/), read 2026-08-12.)
So the repo's belief is **verified by primary sources**: a `cf_clearance` obtained on one machine cannot be replayed from a different IP — the solve is IP-bound and the cookie is device-bound. `Not publicly documented`: whether the cookie value is also cryptographically bound to the User-Agent or TLS/JA3 fingerprint. The only UA-adjacent documented statement is the reverse direction: challenge *solving* breaks when a browser extension modifies the User-Agent or Canvas/WebGL APIs ("Cloudflare Challenges cannot support… Browser extensions that modify the browser's User-Agent value or Web APIs such as Canvas and WebGL") — i.e., tampering with browser signals is documented to *fail* challenges ([How Challenges work — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/concepts/how-challenges-work/), read 2026-08-12).
### 4.3 Two-tier clearance, and the behavior-bound invalidation (Precursor)
`cf_clearance` now carries two kinds of clearance ([Clearance — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/), read 2026-08-12):
- **Challenge clearance** — granted by solving a challenge; level-gated (Interactive > Managed > Non-Interactive; higher clears bypass lower challenges); "remains valid for the duration configured by the customer (Challenge Passage), **unless Precursor determines the session is suspicious**".
- **Precursor clearance** — "continuously updated based on session behavior"; an ongoing client-side process that periodically reassesses behavior. "If Precursor determines that a session is suspicious: the visitor's effective Challenge clearance may be **reduced or invalidated**; the visitor may be **re-challenged, even if the cookie has not expired**."
Precursor is documented as "client-side, session-based verification that continuously evaluates visitor behavior to identify automation… to detect automation that appears legitimate in individual requests but exhibits non-human patterns across a session", writing session state back into `cf_clearance`. It is a **zone-level toggle** (Security → Settings → Precursor; modes: Minimize Friction default, Maximize Security recommended), and "Precursor supersedes JavaScript Detections (JSD)". ([Precursor — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/precursor/), read 2026-08-12.)
**Implication:** the only documented mechanism by which *behavior over time* (as opposed to a single request's fingerprint) can revoke a valid clearance is Precursor — and it is opt-in per zone. `[INFERENCE]` It is unlikely to be enabled on free manga sites, but this is not observable from outside. With Precursor off (the default posture `[INFERENCE]`), a valid `cf_clearance` is honored purely on TTL + device/IP binding.
---
## 5. Does polling more often raise challenge risk?
### 5.1 Is per-IP request rate an input to challenge issuance? (documented answer: no such lever on non-Enterprise protection)
- **Bot scores** (the only per-request automated-detection output) are Enterprise-Bot-Management-only (§1.1); the ML engine's documented inputs are headers/session/browser signals, with request *pattern* entering only via `__cf_bm` — and no numeric rate is published (§1.2). `Not publicly documented`: any requests-per-hour value that changes a bot score or challenge probability.
- **BFM/SBFM** match "patterns of known bots" — signatures, not volumes ([Bot Fight Mode — Cloudflare docs](https://developers.cloudflare.com/bots/get-started/bot-fight-mode/), read 2026-08-12).
- **Rate limiting** is the product that polices volume, and it is opt-in per zone with explicit per-plan constraints (§5.2). Cloudflare's only documented link between "one valid clearance + high volume" is a *recommendation to site owners*: "Cloudflare recommends that customers add a rate limiting rule based on the `cf_clearance` cookie value. This helps ensure that a single, valid cookie cannot be abused by one machine to send an excessive volume of requests." ([Clearance — Cloudflare docs](https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/), read 2026-08-12.) Note: counting by cookie value is only available on Enterprise (see table below); a Free/Pro site cannot even build that rule.
### 5.2 What Rate Limiting Rules would do to 20–60 requests/hour
Rate limiting rules are opt-in; nothing runs them unless the site creates a rule. The documented per-plan capabilities ([Rate limiting rules — Cloudflare docs](https://developers.cloudflare.com/waf/rate-limiting-rules/), read 2026-08-12):
| Capability | Free | Pro | Business |
|---|---|---|---|
| Number of rules | **1** | 2 | 5 |
| Counting characteristics | **IP only** | IP only | IP, IP w/ NAT |
| Counting period | **10 s only** | ≤ 1 min | ≤ 10 min |
| Mitigation timeout | **10 s** | ≤ 1 h | ≤ 1 day |
| Fields in expression | **Path, Verified Bot** | + Host, URI, Full URI, Query | + Method, Source IP, User Agent |
Even the most aggressive free-plan rule (1 request per 10 s = 360/hour) is 6–18× above our 20–60/hour volume; the counting window is 10 s, so a per-hour burst is invisible to it. At 1 request per minute worst-case, **20–60 requests/hour from one residential IP cannot trip any rate limiting rule the Free plan can express.** Also documented: rate limiting is approximate, not precise — "there may be a delay of up to a few seconds between detecting a request and updating rate counters… excess requests could still reach the origin", and counters are per-data-center. ([Rate limiting rules — Cloudflare docs](https://developers.cloudflare.com/waf/rate-limiting-rules/), read 2026-08-12.) `[INFERENCE]` a site could also challenge on rate via WAF custom rules, but that requires Pro+ (custom rules are not on Free) and its own configuration.
### 5.3 DDoS protection (always on, all plans)
HTTP DDoS Attack Protection "is always enabled" and can only be tuned, not disabled. The only *published numeric* thresholds are error-rate-based: origin-error floods mitigate at the default "High" sensitivity of **1,000 errors per second** (Pro+ also requires 5× normal origin traffic). Per-IP volumetric thresholds are adaptive and `Not publicly documented` in the managed ruleset docs. 20–60 requests/hour is ~9 orders of magnitude below the published figure. ([HTTP DDoS Attack Protection — Cloudflare docs](https://developers.cloudflare.com/ddos-protection/managed-rulesets/http/), read 2026-08-12.)
### 5.4 Execution order (which product fires first)
Documented phase order: `ddos_l7` → custom rules → `http_ratelimit` (rate limiting) → managed rules → `http_request_sbfm` (SBFM); BFM runs outside this pipeline and cannot be skipped; a terminating action (block/challenge) stops later phases. ([Security features interoperability — Cloudflare docs](https://developers.cloudflare.com/waf/feature-interoperability/), read 2026-08-12.) Practical reading: on the three sites, the challenge we see could come from any of these stages; none of them documents a volume input at our scale (§5.1–5.3).
---
## 6. The documented legitimate side
### 6.1 Verified bots — the only "treated well" path, and it requires self-identification
Cloudflare documents a Verified bot as one meeting two bars ([Verified bots — Cloudflare docs](https://developers.cloudflare.com/bots/concepts/bot/verified-bots/), read 2026-08-12):
1. **Honest self-identification** — "through a cryptographic Web Bot Auth signature, a published IP list with a stable user-agent, or reverse DNS".
2. **Non-abusive behavior** — "it obeys `robots.txt` and crawl directives, **maintains reasonable request rates**, and has not been observed evading website owner preferences or attacking sites".
Relevant verified-bot *behavior classes* exist for exactly this kind of client: "**Feed Fetching** — RSS readers, podcast aggregators, and news feed bots" and "**Monitoring & Operations** — Uptime monitoring, webhooks, and health checks". Becoming verified requires an application via the dashboard and validation via Web Bot Auth or IP validation; breach of the policy (e.g. "An AI Crawler that does not respect the crawl-delay directive") removes the bot from the allowlist. ([Verified bots — Cloudflare docs](https://developers.cloudflare.com/bots/concepts/bot/verified-bots/), read 2026-08-12.)
"Historically, Verified bots have been excluded in default bot configurations across all plans" (same source) — i.e., verified bots are *default-allowed* under SBFM/Bot Management. **But** this path is the opposite of what a scraper wants: it requires the poller to publicly identify itself (stable, published IPs or cryptographic signatures) and to have its identity vetted by Cloudflare — and the *site* still decides via verified-bot policy whether to allow the category. There is **no documented mechanism for an anonymous low-volume automated client to be treated well.** `[INFERENCE]` a manga-site scraper would never qualify (it would be classified as Data Collection / scraping behavior, which is not a default-allowed class).
### 6.2 robots.txt and crawl control
- `robots.txt` **compliance is voluntary** — "The file expresses your preferences, but it does not prevent crawlers from accessing your content at a technical level." Enforcement requires Cloudflare's AI Crawl Control. ([robots.txt setting — Cloudflare docs](https://developers.cloudflare.com/bots/additional-configurations/managed-robots-txt/), read 2026-08-12.)
- The managed `robots.txt` feature (all plans) is aimed at AI crawlers; it prepends `Disallow` rules for AI bots and a Content Signals Policy. It does not create any allowance for generic scrapers. (Same source.)
- RFC-side: the robots exclusion standard is an unauthenticated convention; nothing in it grants access rights. ([RFC 9309 "Robots Exclusion Protocol"](https://www.rfc-editor.org/rfc/rfc9309.html) — read 2026-08-12.) The standard defines crawl-delay etc. as voluntary directives; Cloudflare's docs are the operative statement for CF-protected sites.
### 6.3 The documented takeaway for "slowing down vs. fingerprint quality"
Cloudflare's own documentation repeatedly points at **browser/device signals** as the decision input on non-Enterprise protection (JSD detecting headless browsers; Managed Challenge choosing based on "signals indicated by their browser"; challenges failing when UA/Canvas/WebGL are modified — §3.2, §4.2), and at **identity** (verified bots) as the only legitimacy signal for automation (§6.1). Request *rate* appears only as: (a) an unnamed component of Enterprise-ML "session characteristics", (b) a voluntary verified-bot behavioral bar, and (c) the separate, opt-in, Free-plan-impotent Rate Limiting product (§5). Nothing documented says "slow down and you'll be challenged less" for a free-plan site — **fingerprint quality is the lever that the documentation actually describes**, which matches this repo's measurements (real Chrome + real timezone passes; every headless variant fails regardless of rate).
---
## 7. Implications for cadence design
### Documented facts (with sources above)
1. **1-hour vs 6-hour cadence is not a documented risk lever.** Challenge issuance on Free/Pro-grade protection is signature-based; the rate-aware scoring only exists on Enterprise Bot Management; free-plan rate limiting cannot express a limit our volume could trip (§1, §2, §5).
2. **Every poll ≥ 1 hour re-solves the challenge anyway.** `cf_clearance` defaults to 30 minutes; Challenge Passage is site-configurable and unobservable. The re-solve cost is what this repo measured (~4 s, real Chrome + real timezone) (§4.1, repo measurements).
3. **The documented failure modes are fingerprint, not rate:** headless browsers (JSD), cloud-hosting IPs (BFM heuristics), modified UA/Canvas/WebGL (challenge solve failure) (§2.2, §3, §4.2).
4. **Clearance is not portable:** device-bound + solve-IP-bound + CHIPS-partitioned; replaying a cookie from another IP is documented invalid (§4.2).
5. **Anonymity has no documented "good citizen" path:** the only legitimate-automation route (verified bots) requires self-identification and site-side allowance (§6).
6. **The one behavior-bound revocation mechanism (Precursor) is opt-in per zone**, not a default documented behavior (§4.3).
### Inferences (not documented)
- `[INFERENCE]` The three sites run Free/Pro-grade protection (BFM, SBFM, or WAF challenge rules), not Enterprise Bot Management; therefore no continuous per-request bot score exists for our traffic.
- `[INFERENCE]` The sites have not changed Challenge Passage to hours/days (free manga sites default to the 30-minute default); if they had, hourly polling would get *cheaper* (valid cookie, no re-solve).
- `[INFERENCE]` Precursor is not enabled on these sites; if it were, hourly re-visits from an automated Chrome could accumulate session-behavior signals and trigger re-challenge even with a valid cookie — the only documented scenario in which polling *frequency* (via session behavior) could matter.
- `[INFERENCE]` 6-hour cooldowns buy nothing documented beyond raw request-count reduction (fewer challenge solves per day, less origin load); the risk profile at 1 request/hour/series is not documented to differ from 6 request/hour/series.
- `[INFERENCE]` If the owner wants belt-and-braces, the engineering levers that match the documentation are: keep the real-Chrome fingerprint (no UA spoofing, no headless-shell, real timezone — already done), keep a persistent user-data profile so `cf_clearance`/`__cf_bm` persist across visits, and treat any change of exit IP (e.g. home connection rebooting to a new IP) as a guaranteed re-solve, since clearance does not travel with the IP.
### Bottom line
Moving browser-backed sites from 6-hour to 1-hour per-series cooldown is **not contradicted by any documented Cloudflare mechanism** at 20–60 requests/hour from one residential IP through one real Chrome. The documented risk is carried by fingerprint quality (already solved in this repo) and by unobservable site configuration (Challenge Passage, Precursor, possible custom WAF rules). The residual, non-documented risk is that these sites sit behind Cloudflare's *proprietary* detection, and Cloudflare publishes neither its per-IP thresholds nor the ML feature set — so "no documented lever" is not "no lever".
+11 -23
View File
@@ -15,20 +15,23 @@
"13": "Web UI Handlers",
"14": "Go Error Handling",
"15": "CDP Browser Client",
"16": "Cloudflare bot scoring and poll cadence — what is actually documented",
"17": "Go Code Style Guide",
"18": "Agent Skills",
"19": "Store",
"20": "I/O Performance Patterns",
"21": "CPU Optimization",
"22": "Caching Patterns",
"23": "Browser Entrypoint",
"24": "Memory Allocation & GC",
"25": "Cover Fetcher Tests",
"26": "readSeries",
"26": "Open",
"27": "Find Skills Guide",
"28": "Allocation Patterns",
"29": "Observability & Alerting",
"31": "Memory Layout",
"32": "Repo Hard Constraints",
"30": "AGENTS.md",
"31": "Store",
"32": "Open",
"33": "Go Testing Guide",
"34": "Session Store",
"35": "Web UI Filter Logic",
@@ -37,6 +40,8 @@
"38": "novel-logic.test.js",
"39": "UI Critique 2026-07-26A",
"40": "UI Critique 2026-07-26B",
"41": "Handler",
"42": "pgtest.go",
"43": "Issue Tracker & Triage",
"44": "Ticket Workflow",
"45": "Go Perf Alert Rules",
@@ -119,6 +124,7 @@
"122": "Identity comes from Discord OAuth; we store no passwords and send no email",
"123": "The wire format stays flat and deliberately does not mirror the schema",
"124": "ADR-0005: On-demand browser sidecar",
"125": "sessions_test.go",
"126": "Bookmark Manager",
"127": "triage-labels.md",
"128": "Cross-Ticket Contract",
@@ -146,22 +152,12 @@
"150": "Reviewer Subagent (opencode)",
"151": "Finding Severity Rubric",
"152": "Spec Compliance Review",
"154": "ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them",
"156": "ResponseWriter",
"161": "Why Use samber/oops",
"162": "singleflight Cache Stampede Prevention",
"163": "Struct Field Alignment",
"164": "testing/synctest Deterministic Goroutine Testing",
"165": "API Package (Bookmark JSON Handlers)",
"166": "Cover Acquisition & Serving Pipeline",
"167": "Backend AGENTS.md Guidance",
"168": "HTTP Middleware (Auth/Gzip/CORS)",
"169": "Latest Package (Site Parsers & Poller)",
"170": "Latest-Chapter Poller",
"171": "Main Composition Root",
"172": "Migration-Owned Schema",
"173": "Session Package (Cookie Signing & Rate Limit)",
"174": "updated_at List-Order Rule",
"175": "Userscript Package (Serving Handler)",
"176": "AGENTS.md",
"177": "Backend CLAUDE.md Guidance",
"178": "Graphify Knowledge Graph (graphify-out/)",
"179": "CLAUDE.md (Symlink to AGENTS.md)",
@@ -212,10 +208,6 @@
"245": "wayfinder map/ticket mechanism",
"246": "Triage labels: canonical roles to tracker labels",
"247": "Canonical triage role labels (needs-triage ... wontfix)",
"248": "Cinder (BookmarkManager Web UI design system)",
"249": "Heat is typographic: ember reserved for unread chapters",
"250": "Design tokens (dark + light branches, no hardcoded hex)",
"251": "Three type roles: display serif / mono small-caps / sans",
"252": "a[aria-label='All Chapter'] priority pointer",
"253": "Research: lightnovelworld chapter slug vs series slug",
"254": "Gitea issue #77 (chapter vs series slug)",
@@ -226,10 +218,6 @@
"259": "Dark-First Design Constraint",
"260": "Discord Guild Membership",
"261": "Reader Isolation Invariant",
"268": "Browser Unit Redeploy",
"269": "pg_dump Hot Backup",
"270": "Redeploy Runbook",
"271": "Rollback Strategy",
"273": "AGENTS.md",
"279": "Userscript CLAUDE guidance"
}
+96 -82
View File
@@ -1,16 +1,16 @@
# Graph Report - mangaBookmark (2026-08-12)
# Graph Report - mangaBookmark (2026-08-16)
## Corpus Check
- 110 files · ~267,791 words
- 119 files · ~290,953 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1621 nodes · 3242 edges · 233 communities (63 shown, 170 thin omitted)
- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 312 edges (avg confidence: 0.77)
- 1736 nodes · 3597 edges · 221 communities (70 shown, 151 thin omitted)
- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 361 edges (avg confidence: 0.78)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `8ae98816`
- Built from commit: `56afb9f2`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -31,20 +31,22 @@
- [[_COMMUNITY_Web UI Handlers|Web UI Handlers]]
- [[_COMMUNITY_Go Error Handling|Go Error Handling]]
- [[_COMMUNITY_CDP Browser Client|CDP Browser Client]]
- [[_COMMUNITY_Cloudflare bot scoring and poll cadence — what is actually documented|Cloudflare bot scoring and poll cadence — what is actually documented]]
- [[_COMMUNITY_Go Code Style Guide|Go Code Style Guide]]
- [[_COMMUNITY_Agent Skills|Agent Skills]]
- [[_COMMUNITY_Store|Store]]
- [[_COMMUNITY_IO Performance Patterns|I/O Performance Patterns]]
- [[_COMMUNITY_CPU Optimization|CPU Optimization]]
- [[_COMMUNITY_Caching Patterns|Caching Patterns]]
- [[_COMMUNITY_Browser Entrypoint|Browser Entrypoint]]
- [[_COMMUNITY_Memory Allocation & GC|Memory Allocation & GC]]
- [[_COMMUNITY_Cover Fetcher Tests|Cover Fetcher Tests]]
- [[_COMMUNITY_readSeries|readSeries]]
- [[_COMMUNITY_Open|Open]]
- [[_COMMUNITY_Find Skills Guide|Find Skills Guide]]
- [[_COMMUNITY_Allocation Patterns|Allocation Patterns]]
- [[_COMMUNITY_Observability & Alerting|Observability & Alerting]]
- [[_COMMUNITY_Memory Layout|Memory Layout]]
- [[_COMMUNITY_Repo Hard Constraints|Repo Hard Constraints]]
- [[_COMMUNITY_AGENTS|AGENTS.md]]
- [[_COMMUNITY_Store|Store]]
- [[_COMMUNITY_Go Testing Guide|Go Testing Guide]]
- [[_COMMUNITY_Session Store|Session Store]]
- [[_COMMUNITY_Web UI Filter Logic|Web UI Filter Logic]]
@@ -53,6 +55,8 @@
- [[_COMMUNITY_novel-logic.test.js|novel-logic.test.js]]
- [[_COMMUNITY_UI Critique 2026-07-26A|UI Critique 2026-07-26A]]
- [[_COMMUNITY_UI Critique 2026-07-26B|UI Critique 2026-07-26B]]
- [[_COMMUNITY_Handler|Handler]]
- [[_COMMUNITY_pgtest.go|pgtest.go]]
- [[_COMMUNITY_Issue Tracker & Triage|Issue Tracker & Triage]]
- [[_COMMUNITY_Ticket Workflow|Ticket Workflow]]
- [[_COMMUNITY_Go Perf Alert Rules|Go Perf Alert Rules]]
@@ -135,6 +139,7 @@
- [[_COMMUNITY_Identity comes from Discord OAuth; we store no passwords and send no email|Identity comes from Discord OAuth; we store no passwords and send no email]]
- [[_COMMUNITY_The wire format stays flat and deliberately does not mirror the schema|The wire format stays flat and deliberately does not mirror the schema]]
- [[_COMMUNITY_ADR-0005 On-demand browser sidecar|ADR-0005: On-demand browser sidecar]]
- [[_COMMUNITY_sessions_test.go|sessions_test.go]]
- [[_COMMUNITY_Bookmark Manager|Bookmark Manager]]
- [[_COMMUNITY_triage-labels|triage-labels.md]]
- [[_COMMUNITY_Cross-Ticket Contract|Cross-Ticket Contract]]
@@ -162,21 +167,12 @@
- [[_COMMUNITY_Reviewer Subagent (opencode)|Reviewer Subagent (opencode)]]
- [[_COMMUNITY_Finding Severity Rubric|Finding Severity Rubric]]
- [[_COMMUNITY_Spec Compliance Review|Spec Compliance Review]]
- [[_COMMUNITY_ADR-0011 Sightings — a Reader report defers a Poll where being wrong hurts only them|ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them]]
- [[_COMMUNITY_ResponseWriter|ResponseWriter]]
- [[_COMMUNITY_Why Use samberoops|Why Use samber/oops]]
- [[_COMMUNITY_singleflight Cache Stampede Prevention|singleflight Cache Stampede Prevention]]
- [[_COMMUNITY_Struct Field Alignment|Struct Field Alignment]]
- [[_COMMUNITY_testingsynctest Deterministic Goroutine Testing|testing/synctest Deterministic Goroutine Testing]]
- [[_COMMUNITY_API Package (Bookmark JSON Handlers)|API Package (Bookmark JSON Handlers)]]
- [[_COMMUNITY_Cover Acquisition & Serving Pipeline|Cover Acquisition & Serving Pipeline]]
- [[_COMMUNITY_Backend AGENTS.md Guidance|Backend AGENTS.md Guidance]]
- [[_COMMUNITY_HTTP Middleware (AuthGzipCORS)|HTTP Middleware (Auth/Gzip/CORS)]]
- [[_COMMUNITY_Latest Package (Site Parsers & Poller)|Latest Package (Site Parsers & Poller)]]
- [[_COMMUNITY_Latest-Chapter Poller|Latest-Chapter Poller]]
- [[_COMMUNITY_Main Composition Root|Main Composition Root]]
- [[_COMMUNITY_Migration-Owned Schema|Migration-Owned Schema]]
- [[_COMMUNITY_Session Package (Cookie Signing & Rate Limit)|Session Package (Cookie Signing & Rate Limit)]]
- [[_COMMUNITY_updated_at List-Order Rule|updated_at List-Order Rule]]
- [[_COMMUNITY_Userscript Package (Serving Handler)|Userscript Package (Serving Handler)]]
- [[_COMMUNITY_Backend CLAUDE.md Guidance|Backend CLAUDE.md Guidance]]
- [[_COMMUNITY_Graphify Knowledge Graph (graphify-out)|Graphify Knowledge Graph (graphify-out/)]]
- [[_COMMUNITY_CLAUDE.md (Symlink to AGENTS.md)|CLAUDE.md (Symlink to AGENTS.md)]]
@@ -227,10 +223,6 @@
- [[_COMMUNITY_wayfinder mapticket mechanism|wayfinder map/ticket mechanism]]
- [[_COMMUNITY_Triage labels canonical roles to tracker labels|Triage labels: canonical roles to tracker labels]]
- [[_COMMUNITY_Canonical triage role labels (needs-triage ... wontfix)|Canonical triage role labels (needs-triage ... wontfix)]]
- [[_COMMUNITY_Cinder (BookmarkManager Web UI design system)|Cinder (BookmarkManager Web UI design system)]]
- [[_COMMUNITY_Heat is typographic ember reserved for unread chapters|Heat is typographic: ember reserved for unread chapters]]
- [[_COMMUNITY_Design tokens (dark + light branches, no hardcoded hex)|Design tokens (dark + light branches, no hardcoded hex)]]
- [[_COMMUNITY_Three type roles display serif mono small-caps sans|Three type roles: display serif / mono small-caps / sans]]
- [[_COMMUNITY_aaria-label='All Chapter' priority pointer|a[aria-label='All Chapter'] priority pointer]]
- [[_COMMUNITY_Research lightnovelworld chapter slug vs series slug|Research: lightnovelworld chapter slug vs series slug]]
- [[_COMMUNITY_Gitea issue 77 (chapter vs series slug)|Gitea issue #77 (chapter vs series slug)]]
@@ -241,28 +233,22 @@
- [[_COMMUNITY_Dark-First Design Constraint|Dark-First Design Constraint]]
- [[_COMMUNITY_Discord Guild Membership|Discord Guild Membership]]
- [[_COMMUNITY_Reader Isolation Invariant|Reader Isolation Invariant]]
- [[_COMMUNITY_Browser Unit Redeploy|Browser Unit Redeploy]]
- [[_COMMUNITY_pg_dump Hot Backup|pg_dump Hot Backup]]
- [[_COMMUNITY_Redeploy Runbook|Redeploy Runbook]]
- [[_COMMUNITY_Rollback Strategy|Rollback Strategy]]
- [[_COMMUNITY_AGENTS|AGENTS.md]]
- [[_COMMUNITY_Userscript CLAUDE guidance|Userscript CLAUDE guidance]]
## God Nodes (most connected - your core abstractions)
1. `testConfig()` - 53 edges
2. `newWebTestServer()` - 49 edges
3. `newTestStore()` - 42 edges
4. `newTestStore()` - 41 edges
1. `newTestStore()` - 62 edges
2. `testConfig()` - 55 edges
3. `newWebTestServer()` - 49 edges
4. `newTestStore()` - 44 edges
5. `e()` - 33 edges
6. `Handler` - 29 edges
7. `ne()` - 28 edges
8. `De()` - 28 edges
9. `Open()` - 27 edges
10. `se()` - 27 edges
6. `Store` - 31 edges
7. `Open()` - 31 edges
8. `newTestPoller()` - 30 edges
9. `Handler` - 29 edges
10. `ne()` - 28 edges
## Surprising Connections (you probably didn't know these)
- `Browser Sidecar Service` --semantically_similar_to--> `Browser Sidecar (BROWSER_WS_URL)` [INFERRED] [semantically similar]
chrome/docker-compose.yml → backend/AGENTS.md
- `el()` --indirect_call--> `c()` [INFERRED]
userscript/manga-bookmark.user.js → backend/internal/web/static/htmx.min.js
- `el()` --indirect_call--> `c()` [INFERRED]
@@ -271,6 +257,8 @@
userscript/manga-bookmark.user.js → backend/internal/web/static/htmx.min.js
- `el()` --indirect_call--> `k()` [INFERRED]
userscript/manga-bookmark.user.js → backend/internal/web/static/htmx.min.js
- `el()` --indirect_call--> `k()` [INFERRED]
userscript/novel-bookmark.user.js → backend/internal/web/static/htmx.min.js
## Import Cycles
- None detected.
@@ -278,48 +266,46 @@
## Hyperedges (group relationships)
- **Batch Ticket Implementation Pipeline** — _claude_skills_implement_tickets_skill_implement_tickets, _omp_agents_ticket_implementer_ticket_implementer, _omp_agents_ticket_implementer_cr_spec, _omp_agents_ticket_implementer_cr_standards [INFERRED 0.85]
- **Subagent-Driven Development Pipeline** — _opencode_agent_implementer_implementer, _opencode_agent_reviewer_reviewer, _opencode_agent_implementer_subagent_driven_development [INFERRED 0.85]
- **Go HTML Template Family** — backend_internal_web_templates_app_doc, backend_internal_web_templates_card_doc, backend_internal_web_templates_list_doc, backend_internal_web_templates_chrome_doc, backend_internal_web_templates_login_doc, backend_internal_web_templates_setup_doc, backend_internal_web_templates_readers_doc, backend_internal_web_templates_icons_doc [INFERRED 0.95]
- **htmx Fragment Swap Flow** — backend_internal_web_templates_app_doc, backend_internal_web_templates_card_doc, backend_internal_web_templates_chrome_doc, backend_internal_web_templates_setup_doc, backend_internal_web_templates_readers_doc [INFERRED 0.95]
- **Backend owns the truth (single-writer ownership of shared facts)** — docs_adr_0003_series_shared_and_poll_owned_poll_owned_writes, docs_adr_0004_wire_format_does_not_mirror_the_schema_flat_wire_contract, docs_adr_0007_backend_hosts_cover_bytes_server_side_covers [INFERRED 0.85]
- **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85]
- **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85]
## Communities (233 total, 170 thin omitted)
## Communities (221 total, 151 thin omitted)
### Community 0 - "HTMX Library Internals"
Cohesion: 0.08
Nodes (101): A(), ae(), an(), at(), B(), be(), bn(), bt() (+93 more)
### Community 1 - "Cover Fetch Test Helpers"
Cohesion: 0.10
Nodes (84): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecorder, T, TestListRendersAcquiredCover() (+76 more)
Cohesion: 0.09
Nodes (89): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecorder, T, TestListRendersAcquiredCover() (+81 more)
### Community 2 - "Manga Userscript Adapters"
Cohesion: 0.06
Nodes (76): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+68 more)
Nodes (77): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), armDwell() (+69 more)
### Community 3 - "Novel Userscript Adapters"
Cohesion: 0.06
Nodes (79): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+71 more)
Nodes (78): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLnwStaleRowRepair() (+70 more)
### Community 4 - "Series Acquisition Tests"
Cohesion: 0.10
Nodes (67): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+59 more)
Cohesion: 0.07
Nodes (103): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+95 more)
### Community 5 - "Bookmarks API Tests"
Cohesion: 0.08
Nodes (67): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+59 more)
Cohesion: 0.07
Nodes (71): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+63 more)
### Community 7 - "Cover & Acquire Internals"
Cohesion: 0.09
Nodes (31): Addr, Context, Store, defaultCoverResolver(), fetchCoverBytes(), Client, Context, NewCoverFetcher() (+23 more)
Cohesion: 0.06
Nodes (43): Addr, fakeLanes, Context, Store, WaitGroup, isInterstitial(), defaultCoverResolver(), fetchCoverBytes() (+35 more)
### Community 8 - "System Architecture Concepts"
Cohesion: 0.10
Nodes (26): Confirm-Gated Destructive Actions, Discord OAuth & Guild-Membership Gate, Lifecycle Buckets (reading/archived/finished), Reader-Owned Store, HMAC-Derived Reader Credentials, Web Package (Browser UI + Templates), app.html — App Shell Template, Manga/Novel Library Switch (+18 more)
Cohesion: 0.15
Nodes (15): Confirm Row (Archive/Finish/Remove), card.html — Series Card Template, htmx /ui/* Mutation Endpoints, chrome.html — Out-of-Band Regions, Action Key, Brand Mark SVG, Continue Reading Strip, icons.html — Icon Sprite Template (+7 more)
### Community 9 - "Session Middleware"
Cohesion: 0.07
Cohesion: 0.08
Nodes (35): ClearCookie(), ClientIP(), Duration, Mutex, Request, ResponseWriter, Time, isHTTPS() (+27 more)
### Community 10 - "Go Test Helpers"
@@ -327,29 +313,33 @@ Cohesion: 0.05
Nodes (39): Test Helpers, Test Timeout, Basic Handler Test, HTTP Handler Testing, Query Parameters and Headers, Docker Compose Fixture, Integration Testing, SQL Schema Fixture (+31 more)
### Community 11 - "Store Tests"
Cohesion: 0.07
Nodes (79): M, TestMain(), M, TestMain(), M, Main(), start(), URL() (+71 more)
Cohesion: 0.14
Nodes (45): Store, T, newTestStore(), readLatestCheckedAt(), readSeries(), secondReader(), seedForCheck(), seedSecondReader() (+37 more)
### Community 12 - "Bookmarks API Handler"
Cohesion: 0.08
Nodes (33): Handler, Request, ResponseWriter, Store, Healthz(), writeJSON(), Auth(), compressible() (+25 more)
### Community 13 - "Web UI Handlers"
Cohesion: 0.06
Nodes (24): coverRelativePath(), coverSourceAddress(), displayChapter(), Store, currentLib(), currentTab(), filterBookmarks(), Client (+16 more)
Nodes (32): Handler, Request, ResponseWriter, Store, Healthz(), writeJSON(), Auth(), compressible() (+24 more)
### Community 14 - "Go Error Handling"
Cohesion: 0.06
Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more)
### Community 15 - "CDP Browser Client"
Cohesion: 0.08
Nodes (31): awaitPromise(), browserConnectionLost(), classifyBrowserError(), Action, Context, Mutex, jsString(), kaganeAPIURL() (+23 more)
Cohesion: 0.20
Nodes (17): applyMigration(), migrate(), Open(), refreshOwnerToken(), seedOwner(), TestCoverIsContentAddressedOnFilesystem(), TestCoverPersistsAcrossReopen(), TestMigration0002BackfillsExistingBookmarks() (+9 more)
### Community 16 - "Cloudflare bot scoring and poll cadence — what is actually documented"
Cohesion: 0.06
Nodes (33): 1.1 The score itself, 1.2 The detection engines (Enterprise Bot Management), 1.3 Rate limiting is a separate product, 1. What a bot score is and what feeds it, 2.1 What each plan gets, 2.2 Bot Fight Mode specifics (the Free-plan product), 2.3 Does the free tier "score" continuously?, 2. The free-plan reality (+25 more)
### Community 17 - "Go Code Style Guide"
Cohesion: 0.08
Nodes (23): Code Style Details, Extract Complex Conditions, Value vs Pointer Arguments, Code Organization Within Files, Complex Conditions & Init Scope, Composite Literals, Control Flow, Cross-References (+15 more)
### Community 19 - "Store"
Cohesion: 0.33
Nodes (5): ADR-0010: Poll Lanes — one independent Poll stream per Site, Constraints preserved, Decision, Tradeoffs and rejections, Why
### Community 20 - "I/O Performance Patterns"
Cohesion: 0.11
Nodes (18): Avoid io.ReadAll for large payloads, Batch Operations, Buffered I/O, Cgo Overhead, Channel: batch processing from a stream, Concurrent Multi-Stage Pipelines, Connection pooling, Database: batch inserts over row-by-row (+10 more)
@@ -374,9 +364,9 @@ Nodes (15): Allocation Rate Reduction, Ballast pattern (pre-Go 1.19), Garbage Co
Cohesion: 0.33
Nodes (12): coverResponse(), Request, T, TestCoverFetcherCanonicalisesJpgAlias(), TestCoverFetcherFetchesPublicHTTPSImage(), TestCoverFetcherRefusesUnsafeDestinationsBeforeRequest(), TestCoverFetcherRejectsNonImage(), TestCoverFetcherRejectsOversizedBody() (+4 more)
### Community 26 - "readSeries"
Cohesion: 0.13
Nodes (28): asuraLatestChapter(), browserOnlyCoverURL(), comixCoverEntry(), comixCoverURL(), comixLatestChapter(), comixSeriesID(), coverFrom(), demonicLatestChapter() (+20 more)
### Community 26 - "Open"
Cohesion: 0.05
Nodes (61): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+53 more)
### Community 27 - "Find Skills Guide"
Cohesion: 0.14
@@ -390,10 +380,14 @@ Nodes (14): Allocation Patterns, Backing Array Leaks, Direct indexing vs append,
Cohesion: 0.22
Nodes (9): Alerting rules (examples), CPU saturation, GC pressure, Goroutine leaks, Grafana Dashboards, Memory leaks, Prometheus Metrics for Go, PromQL Queries for Performance Diagnosis (+1 more)
### Community 31 - "Memory Layout"
### Community 30 - "AGENTS.md"
Cohesion: 0.40
Nodes (5): Map of pointers for large, frequently updated structs, Memory Layout, Pointer receivers for large structs, Struct field alignment, Zero-size field at end of struct
### Community 31 - "Store"
Cohesion: 0.09
Nodes (5): coverRelativePath(), coverSourceAddress(), Store, scanSeries(), ReaderSummary
### Community 33 - "Go Testing Guide"
Cohesion: 0.20
Nodes (10): CI Regression Detection, Common Mistakes, Core Philosophy, Cross-References, Decision Tree: Where Is Time Spent?, Deep Dives, Go Performance Optimization, Iterative Optimization Methodology (+2 more)
@@ -422,6 +416,14 @@ Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations,
Cohesion: 0.29
Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider
### Community 41 - "Handler"
Cohesion: 0.14
Nodes (18): currentLib(), currentTab(), filterBookmarks(), Client, HandlerFunc, Request, ResponseWriter, Store (+10 more)
### Community 42 - "pgtest.go"
Cohesion: 0.18
Nodes (12): M, TestMain(), M, TestMain(), M, Main(), start(), URL() (+4 more)
### Community 45 - "Go Perf Alert Rules"
Cohesion: 0.50
Nodes (4): Prometheus Alerting Rules (Go Performance), GoroutineLeak Alert, HighGCPauseTime Alert, MemoryNearLimit Alert
@@ -443,8 +445,8 @@ Cohesion: 1.00
Nodes (3): Mirrored Double Bookmark Mark, Ember Flame Accent, BookmarkManager Logo
### Community 103 - "bookmark-api Service"
Cohesion: 0.24
Nodes (10): Backend Go Service (stdlib net/http), Browser Sidecar (BROWSER_WS_URL), Browser Sidecar Service, CDP Endpoint (Tailnet-Bound :9222), Persistent Chrome Profile Volume, chrome/docker-compose.yml — Browser Deployable Unit, bookmark-api Prod Override, CDP Never on Shared Proxy Network (+2 more)
Cohesion: 0.32
Nodes (8): Browser Sidecar Service, CDP Endpoint (Tailnet-Bound :9222), Persistent Chrome Profile Volume, chrome/docker-compose.yml — Browser Deployable Unit, bookmark-api Prod Override, CDP Never on Shared Proxy Network, docker-compose.prod.yml — Production Override, Traefik Reverse Proxy Labels
### Community 104 - "AGENTS.md"
Cohesion: 0.12
@@ -530,29 +532,41 @@ Nodes (3): Consequence, The wire format stays flat and deliberately does not mir
Cohesion: 0.50
Nodes (3): ADR-0005: On-demand browser sidecar, Constraints, Decision
### Community 125 - "sessions_test.go"
Cohesion: 0.48
Nodes (6): T, TestCreateAndGetSession(), TestDeleteSessionIsPerReader(), TestDeleteSessionRevokes(), TestExpiredSessionIsGone(), TestGetSessionUnknownID()
### Community 154 - "ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them"
Cohesion: 0.33
Nodes (5): ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them, Constraints preserved, Decision, Tradeoffs and rejections, Why
### Community 156 - "ResponseWriter"
Cohesion: 0.18
Nodes (13): AdminPatterns(), HandlerFunc, Request, ResponseWriter, Time, Handler, readerPathID(), since() (+5 more)
### Community 273 - "AGENTS.md"
Cohesion: 0.50
Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust), Second script: `novel-bookmark.user.js`, Userscript structure (single IIFE, `manga-bookmark.user.js`)
## Knowledge Gaps
- **508 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+503 more)
- **524 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+519 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **170 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **151 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Session Middleware`, `Store Tests`, `Web UI Handlers`?**
_High betweenness centrality (0.052) - this node is a cross-community bridge._
- **Why does `Open()` connect `Store Tests` to `Cover Fetch Test Helpers`, `Web UI Handlers`, `Series Acquisition Tests`, `Bookmarks API Tests`?**
_High betweenness centrality (0.034) - this node is a cross-community bridge._
- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`?**
_High betweenness centrality (0.027) - this node is a cross-community bridge._
- **Are the 47 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?**
_`testConfig()` has 47 INFERRED edges - model-reasoned connections that need verification._
- **Why does `Open()` connect `CDP Browser Client` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `pgtest.go`, `Store Tests`, `Store`?**
_High betweenness centrality (0.060) - this node is a cross-community bridge._
- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Handler`, `Session Middleware`, `CDP Browser Client`, `ResponseWriter`?**
_High betweenness centrality (0.050) - this node is a cross-community bridge._
- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`, `ResponseWriter`?**
_High betweenness centrality (0.030) - this node is a cross-community bridge._
- **Are the 25 inferred relationships involving `newTestStore()` (e.g. with `TestAcquireDoesNotBlockTheWrite()` and `TestAcquireFailureLeavesTheBookmarkIntact()`) actually correct?**
_`newTestStore()` has 25 INFERRED edges - model-reasoned connections that need verification._
- **Are the 48 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?**
_`testConfig()` has 48 INFERRED edges - model-reasoned connections that need verification._
- **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?**
_`newWebTestServer()` has 8 INFERRED edges - model-reasoned connections that need verification._
- **Are the 6 inferred relationships involving `newTestStore()` (e.g. with `TestCreateAndGetSession()` and `TestDeleteSessionIsPerReader()`) actually correct?**
_`newTestStore()` has 6 INFERRED edges - model-reasoned connections that need verification._
- **What connects `bookmarkmanager/backend`, `ctxKey`, `loginView` to the rest of the system?**
_548 weakly-connected nodes found - possible documentation gaps or missing edges._
_556 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+8234 -3463
View File
File diff suppressed because it is too large Load Diff
+151 -96
View File
@@ -25,9 +25,9 @@
"semantic_hash": "dac242903b0e98c3e4395159d609e08e"
},
"backend/main.go": {
"mtime": 1786363889.5678573,
"ast_hash": "6e98a3ae91aaa132df251e43c4dfca6d",
"semantic_hash": "6e98a3ae91aaa132df251e43c4dfca6d"
"mtime": 1786863963.3450089,
"ast_hash": "d5a50b03438d7c120079d40cc578462b",
"semantic_hash": ""
},
"skills-lock.json": {
"mtime": 1784884678.6842625,
@@ -35,8 +35,8 @@
"semantic_hash": "4a94ac85bad6bce330d085bcc0ae3ffd"
},
"userscript/manga-bookmark.user.js": {
"mtime": 1786488438.9080842,
"ast_hash": "1f8bcddd3632d709f058a8401af8f127",
"mtime": 1786872888.3482232,
"ast_hash": "4e79684942234b26f4a1fb03fccd6d35",
"semantic_hash": ""
},
".agents/skills/find-skills/SKILL.md": {
@@ -140,18 +140,18 @@
"semantic_hash": "3a08979e4603aae5c32a58d5b6c39765"
},
"CLAUDE.md": {
"mtime": 1786488493.5922732,
"ast_hash": "7362a25f37333a2a6a55a5aeccf9b0cb",
"mtime": 1786857336.6414917,
"ast_hash": "c79e49f912d7852f9832565a5f9a1c39",
"semantic_hash": ""
},
"DEPLOY.md": {
"mtime": 1786488464.5532806,
"ast_hash": "2b7b537aa1c0954400c19acc4b634029",
"mtime": 1786861012.026504,
"ast_hash": "b7c2f813aded562ee9291c9baed795ad",
"semantic_hash": ""
},
"README.md": {
"mtime": 1786488487.8305292,
"ast_hash": "9d6be8aa8a2946c23ad48d8f2864b5ca",
"mtime": 1786861012.026504,
"ast_hash": "81af12a0a2d43e791efd030b5f4d6cbc",
"semantic_hash": ""
},
"docker-compose.prod.yml": {
@@ -160,8 +160,8 @@
"semantic_hash": "0751998a532297b8ac507a01ec48dc31"
},
"docker-compose.yml": {
"mtime": 1786488450.3508112,
"ast_hash": "124fd581bf0a662ff15012abfdb40a92",
"mtime": 1786861012.026504,
"ast_hash": "d3b53a8f42a8e0acb4fc4306ea42c093",
"semantic_hash": ""
},
".claude/settings.json": {
@@ -170,14 +170,14 @@
"semantic_hash": "e51077b6a7f1f67afc748f1a32a1557d"
},
"backend/web_test.go": {
"mtime": 1786216141.700644,
"ast_hash": "8f1b093b59eb1ed81bc7fc0c22495c50",
"semantic_hash": "8f1b093b59eb1ed81bc7fc0c22495c50"
"mtime": 1786865248.7717106,
"ast_hash": "7c298e39c63e4502cf6272d1e206e9ef",
"semantic_hash": ""
},
"backend/main_test.go": {
"mtime": 1786363889.5678573,
"ast_hash": "8a165955cf28ad47481fec5ea7afb3d6",
"semantic_hash": "8a165955cf28ad47481fec5ea7afb3d6"
"mtime": 1786863966.7091317,
"ast_hash": "0a5d4dbdc770b40c329ccccc899b59f4",
"semantic_hash": ""
},
".claude/settings.local.json": {
"mtime": 1785697645.350201,
@@ -200,12 +200,12 @@
"semantic_hash": "e69a8340a371579ca3ea689660f7d7bd"
},
"AGENTS.md": {
"mtime": 1786488493.5922732,
"ast_hash": "7362a25f37333a2a6a55a5aeccf9b0cb",
"mtime": 1786857336.6414917,
"ast_hash": "c79e49f912d7852f9832565a5f9a1c39",
"semantic_hash": ""
},
"userscript/test/logic.test.js": {
"mtime": 1786488563.581765,
"mtime": 1786499529.779988,
"ast_hash": "80d512bfe6fa8b847f3ba6169c321a74",
"semantic_hash": ""
},
@@ -215,29 +215,29 @@
"semantic_hash": "8f3c0132eb4787a2c8736eb99f7689af"
},
"REDEPLOY.md": {
"mtime": 1786363889.552731,
"ast_hash": "d0baf08b95e7b5986234a9f36759c12e",
"semantic_hash": "d0baf08b95e7b5986234a9f36759c12e"
"mtime": 1786857336.6414917,
"ast_hash": "e5e910f0244a040e2ccdc669b17eb4db",
"semantic_hash": ""
},
"docs/design-system.md": {
"mtime": 1786022513.9623306,
"ast_hash": "421cd7e57f02d4b467f120ca6ddd7b6a",
"semantic_hash": "421cd7e57f02d4b467f120ca6ddd7b6a"
"mtime": 1786865139.9022946,
"ast_hash": "3bd39932997c8e245508874194db4e49",
"semantic_hash": ""
},
"backend/api_test.go": {
"mtime": 1786488480.637822,
"ast_hash": "8e4b9293bc2e45ee3f42027315594fd5",
"mtime": 1786868080.2317674,
"ast_hash": "b64372df5429fb14e48b37b3bab10402",
"semantic_hash": ""
},
"backend/cover_test.go": {
"mtime": 1786363889.552731,
"ast_hash": "c7e313d6c92eb28e6d370e5e89035984",
"semantic_hash": "c7e313d6c92eb28e6d370e5e89035984"
"mtime": 1786863966.7084978,
"ast_hash": "fd6b3f6ef46bd17b0241d104b6e5bdf6",
"semantic_hash": ""
},
"backend/internal/api/handlers.go": {
"mtime": 1786363889.552731,
"ast_hash": "59e6b8767ab19839bb8f82891a7e4616",
"semantic_hash": "59e6b8767ab19839bb8f82891a7e4616"
"mtime": 1786867745.788687,
"ast_hash": "ae4f3b961450f06a06e0632eb273a5b2",
"semantic_hash": ""
},
"backend/internal/httpmw/middleware.go": {
"mtime": 1786216141.672644,
@@ -245,38 +245,38 @@
"semantic_hash": "385b36f58488b7e6d93eb6d6034e9ee3"
},
"backend/internal/latest/browser.go": {
"mtime": 1786469354.923288,
"ast_hash": "ed129a7f00601ea90c877ff29fa21220",
"mtime": 1786861012.026504,
"ast_hash": "75f34a974568d122681939dd297944a2",
"semantic_hash": ""
},
"backend/internal/latest/browser_test.go": {
"mtime": 1786262323.6964688,
"ast_hash": "e900f92971486f47d7ef76e9a95217fe",
"semantic_hash": "e900f92971486f47d7ef76e9a95217fe"
"mtime": 1786857336.6414917,
"ast_hash": "800fa6aa471ada054a3c48943ad17ab7",
"semantic_hash": ""
},
"backend/internal/latest/fetch.go": {
"mtime": 1786446006.9219902,
"mtime": 1786499529.7688599,
"ast_hash": "3e20ad86aa46783e9aa95c2b746551ee",
"semantic_hash": ""
},
"backend/internal/latest/poller.go": {
"mtime": 1786469641.8342345,
"ast_hash": "44fef6074ac2eaffc8233f46aad5236b",
"mtime": 1786872774.641225,
"ast_hash": "5e55e2ac5cb7d347807754f92425d29b",
"semantic_hash": ""
},
"backend/internal/latest/poller_test.go": {
"mtime": 1786469644.9306462,
"ast_hash": "64bc838c822f1bf33bbf9e291215454b",
"mtime": 1786865291.3758123,
"ast_hash": "1da669ac748a8d1a0289e557392fa3fd",
"semantic_hash": ""
},
"backend/internal/latest/sites.go": {
"mtime": 1786469348.5394242,
"ast_hash": "b744cc685363317a526cc3bebceea39e",
"mtime": 1786868771.0663204,
"ast_hash": "5786000dd0dfd5b4b3bd2b87b1fc100b",
"semantic_hash": ""
},
"backend/internal/latest/sites_test.go": {
"mtime": 1786446006.9219902,
"ast_hash": "eabca9014a306e3c71d238b0ae499f61",
"mtime": 1786857336.6414917,
"ast_hash": "0db2028a6073f342fee61ad15c2e5f0f",
"semantic_hash": ""
},
"backend/internal/latest/smoke_image_test.go": {
@@ -340,14 +340,14 @@
"semantic_hash": "0b6764a0ee20f5cb7748eecd31a1d220"
},
"backend/internal/store/store.go": {
"mtime": 1786363889.5602942,
"ast_hash": "54367a8ab043983e2491b2eb2650961c",
"semantic_hash": "54367a8ab043983e2491b2eb2650961c"
"mtime": 1786868763.646761,
"ast_hash": "34f55617b7409f03f02e925bec92fac0",
"semantic_hash": ""
},
"backend/internal/store/store_test.go": {
"mtime": 1786363889.5602942,
"ast_hash": "dc823fd77bcce2268114e31d759b20a5",
"semantic_hash": "dc823fd77bcce2268114e31d759b20a5"
"mtime": 1786867781.301985,
"ast_hash": "16f69d97eb63bcec7409294466642411",
"semantic_hash": ""
},
"backend/internal/userscript/userscript.go": {
"mtime": 1786216141.692644,
@@ -380,14 +380,14 @@
"semantic_hash": "19a573773be4ca22570ca2f8543120c5"
},
"backend/internal/web/web.go": {
"mtime": 1786363889.5678573,
"ast_hash": "8308949c658d3a08ce1c3cdbea6a907c",
"semantic_hash": "8308949c658d3a08ce1c3cdbea6a907c"
"mtime": 1786863744.1453943,
"ast_hash": "b051f2de214c4b253ee09fbbdb8ebb60",
"semantic_hash": ""
},
"backend/reader_credential_test.go": {
"mtime": 1786216141.700644,
"ast_hash": "2a751ea6d9c06635aa3179db0aef1b2b",
"semantic_hash": "2a751ea6d9c06635aa3179db0aef1b2b"
"mtime": 1786863966.7092986,
"ast_hash": "0c93de387af595901c43b6bdb3bed8cc",
"semantic_hash": ""
},
"chrome/entrypoint.sh": {
"mtime": 1786363889.5678573,
@@ -395,12 +395,12 @@
"semantic_hash": "8008a187690764436540fab47ba0cfcc"
},
"userscript/novel-bookmark.user.js": {
"mtime": 1786454823.798836,
"ast_hash": "834effb0821f8d6c9f57f6554a5db462",
"mtime": 1786872902.809149,
"ast_hash": "36c81462919719f0e053ca5d633a243a",
"semantic_hash": ""
},
"userscript/test/novel-logic.test.js": {
"mtime": 1786454566.070801,
"mtime": 1786499529.779988,
"ast_hash": "b25a7377af210dd0aff8284501fd0252",
"semantic_hash": ""
},
@@ -415,8 +415,8 @@
"semantic_hash": "e44a2f6f624db044e19508bc5ab05592"
},
"CONTEXT.md": {
"mtime": 1786466512.6719532,
"ast_hash": "544b7d93f1d5cb9d347cb0b92fc3a709",
"mtime": 1786861012.022683,
"ast_hash": "4aafbce0b046e6e34734fb414df818ce",
"semantic_hash": ""
},
"CUTOVER.md": {
@@ -425,19 +425,19 @@
"semantic_hash": "6c6f3e4c4c2f57867894280bce728c50"
},
"backend/AGENTS.md": {
"mtime": 1786363889.552731,
"ast_hash": "6356ee58447f299e5fa7aa83486bbbe0",
"semantic_hash": "6356ee58447f299e5fa7aa83486bbbe0"
"mtime": 1786872951.3127444,
"ast_hash": "68bc86af9593adad1e37fa05188fe63e",
"semantic_hash": ""
},
"backend/CLAUDE.md": {
"mtime": 1786363889.552731,
"ast_hash": "6356ee58447f299e5fa7aa83486bbbe0",
"semantic_hash": "6356ee58447f299e5fa7aa83486bbbe0"
"mtime": 1786872951.3127444,
"ast_hash": "68bc86af9593adad1e37fa05188fe63e",
"semantic_hash": ""
},
"backend/internal/web/templates/app.html": {
"mtime": 1786216141.692644,
"ast_hash": "3965e20e204afb71ba2a3aa86cb7c61c",
"semantic_hash": "3965e20e204afb71ba2a3aa86cb7c61c"
"mtime": 1786864445.1584811,
"ast_hash": "99d3e1139042d0eb61627155dddb3843",
"semantic_hash": ""
},
"backend/internal/web/templates/card.html": {
"mtime": 1786363889.5678573,
@@ -465,9 +465,9 @@
"semantic_hash": "bcc3101498a66cf8b79f9d97c6c9cd6b"
},
"backend/internal/web/templates/readers.html": {
"mtime": 1786216141.696644,
"ast_hash": "c5034e76bd20a705d2799cb5ecb328f0",
"semantic_hash": "c5034e76bd20a705d2799cb5ecb328f0"
"mtime": 1786864296.770521,
"ast_hash": "2b1cbb24d6185e48561966db1af04ba4",
"semantic_hash": ""
},
"backend/internal/web/templates/setup.html": {
"mtime": 1786216141.696644,
@@ -485,9 +485,9 @@
"semantic_hash": "852a04d86659385085da6ffc8b489933"
},
"docs/adr/0003-series-shared-and-poll-owned.md": {
"mtime": 1786216141.7071996,
"ast_hash": "58bb4d24e20f6f7adc1a8b9cf7967c3f",
"semantic_hash": "58bb4d24e20f6f7adc1a8b9cf7967c3f"
"mtime": 1786501942.7367291,
"ast_hash": "6138b113340693e0cc667d1ecbb75f72",
"semantic_hash": ""
},
"docs/adr/0004-wire-format-does-not-mirror-the-schema.md": {
"mtime": 1786216141.7071996,
@@ -510,12 +510,12 @@
"semantic_hash": "69114d07ed792d6bb1d13758ba5435e1"
},
"userscript/AGENTS.md": {
"mtime": 1786454605.6727684,
"mtime": 1786499529.7762787,
"ast_hash": "e276ffe9a6e7b55fd3235466a1995c22",
"semantic_hash": ""
},
"userscript/CLAUDE.md": {
"mtime": 1786454605.6727684,
"mtime": 1786499529.7762787,
"ast_hash": "e276ffe9a6e7b55fd3235466a1995c22",
"semantic_hash": ""
},
@@ -545,9 +545,9 @@
"semantic_hash": "5605599395a3f085904e78a2bfec1e58"
},
"docs/adr/0006-browser-on-the-home-machine.md": {
"mtime": 1786292465.8305523,
"ast_hash": "dfd6bbc045d23315f2942ee8d98eb7db",
"semantic_hash": "dfd6bbc045d23315f2942ee8d98eb7db"
"mtime": 1786501942.7367291,
"ast_hash": "bfcaf39b6c7e96610a7caa00eeb36533",
"semantic_hash": ""
},
"docs/adr/0007-backend-hosts-cover-bytes.md": {
"mtime": 1786292465.8305523,
@@ -560,9 +560,9 @@
"semantic_hash": "46cf7822d4f667e3cab36b547abe5e97"
},
"backend/internal/latest/cover.go": {
"mtime": 1786363889.5565126,
"ast_hash": "e6749cfe3cd7c2e71d4392dde84f55f9",
"semantic_hash": "e6749cfe3cd7c2e71d4392dde84f55f9"
"mtime": 1786857336.6414917,
"ast_hash": "d5f2248c3d11de74bf5a3977651b17c2",
"semantic_hash": ""
},
"backend/internal/latest/cover_fetch_test.go": {
"mtime": 1786363889.5565126,
@@ -570,8 +570,8 @@
"semantic_hash": "60d9eb7c59a3751baf4f31c7655217e7"
},
"backend/internal/latest/acquire.go": {
"mtime": 1786468950.9432797,
"ast_hash": "6c1ad34bbe9f5b49d0fd1eae9093f55d",
"mtime": 1786861012.026504,
"ast_hash": "d605e3c94a62fc7787efbc139696b9d2",
"semantic_hash": ""
},
"backend/internal/latest/acquire_test.go": {
@@ -605,18 +605,73 @@
"semantic_hash": "74a4e538875f0a7e8ca3d5dc48c0bb53"
},
"backend/internal/latest/smoke_lnw_test.go": {
"mtime": 1786446006.9219902,
"mtime": 1786499529.7725692,
"ast_hash": "2d65da8a081759172918fdf159760f45",
"semantic_hash": ""
},
"docs/adr/0009-a-site-answers-questions-its-own-way.md": {
"mtime": 1786469367.6875648,
"mtime": 1786499529.7725692,
"ast_hash": "8039012a5b6de2359ff1a47079f51b66",
"semantic_hash": ""
},
"backend/internal/latest/read.go": {
"mtime": 1786469329.7445614,
"ast_hash": "3cf29046ddaef39fafb1df70b9f9ae8c",
"mtime": 1786861012.026504,
"ast_hash": "9f039cc3ad74f803d7621f7ef4157bf2",
"semantic_hash": ""
},
"docs/research/cloudflare-bot-scoring-and-poll-cadence.md": {
"mtime": 1786501942.7367291,
"ast_hash": "1aa17575ab20f2f36583602999a6a60f",
"semantic_hash": ""
},
"backend/internal/latest/smoke_comix_test.go": {
"mtime": 1786857336.6414917,
"ast_hash": "111fdbb75fc68ac2ab1013bc916063cf",
"semantic_hash": ""
},
"docs/adr/0010-poll-lanes-per-site-pace.md": {
"mtime": 1786861012.026504,
"ast_hash": "dc19d75f034ca920d93b9c71e6ca28e6",
"semantic_hash": ""
},
"backend/internal/latest/status.go": {
"mtime": 1786865033.5648637,
"ast_hash": "8141514efa5a7a66e77b9a62d4982d52",
"semantic_hash": ""
},
"backend/internal/store/migrations/0010_reader_sightings.sql": {
"mtime": 1786863695.6957178,
"ast_hash": "a72c08c63fad530df3c793d16edfa385",
"semantic_hash": ""
},
"backend/internal/web/admin.go": {
"mtime": 1786865087.473256,
"ast_hash": "b1c0f23a102a9bc7f84adf306d743f56",
"semantic_hash": ""
},
"backend/internal/web/templates/admin.html": {
"mtime": 1786865108.4095602,
"ast_hash": "f04ea1cb01369d53053eac489e796faa",
"semantic_hash": ""
},
"backend/internal/web/templates/lanes.html": {
"mtime": 1786865113.5362902,
"ast_hash": "8b9f9c7a56e0ec3bf950aa70acd95e92",
"semantic_hash": ""
},
"backend/internal/latest/sighting_test.go": {
"mtime": 1786868815.9760568,
"ast_hash": "3b1372bbeeb538b73f86f57da210b4cc",
"semantic_hash": ""
},
"backend/internal/store/migrations/0011_series_sightings.sql": {
"mtime": 1786867605.3728056,
"ast_hash": "d0ade928a6e951eb179e4d0255cca526",
"semantic_hash": ""
},
"docs/adr/0011-sighting-deferral-trust-model.md": {
"mtime": 1786872970.473592,
"ast_hash": "0b6ca704f62185c9d629320f2d8475b5",
"semantic_hash": ""
}
}
+24 -14
View File
@@ -905,27 +905,37 @@
}
}
// Records the newest chapter a site has published. Silent: this fires from
// Reports the newest chapter a site has published. Silent: this fires from
// page visits and background checks the user did not ask for, and it never
// reorders the list — updated_at is a candidate the server discards unless
// reading progress moved.
async function applyLatestChapterIfChanged(existing, latest) {
//
// An unchanged number is still sent. It is the read that lets the backend
// skip its own poll of this series (a Sighting, issue #103), so the common
// case — visiting a series with nothing new — is exactly the one worth
// reporting. Only the local write and the re-render are skipped.
async function reportLatestChapter(existing, latest) {
if (!existing || !latest) return;
if (existing.latest_chapter_num === latest.num) return;
const bm = Object.assign({}, existing, {
latest_chapter: latest.label,
latest_chapter_num: latest.num,
updated_at: Date.now(),
});
upsertLocal(bm);
render();
const changed = existing.latest_chapter_num !== latest.num;
let bm = existing;
if (changed) {
bm = Object.assign({}, existing, {
latest_chapter: latest.label,
latest_chapter_num: latest.num,
updated_at: Date.now(),
});
upsertLocal(bm);
render();
}
// A queued write owns this row; the drain sends latest_chapter
// with it, carrying the correct bucket.
if (queueGet(bm.key)) return;
try {
const saved = await apiPut(bm.key, bm);
upsertLocal(saved);
render();
if (changed) {
upsertLocal(saved);
render();
}
} catch (e) {
/* offline — the local cache still shows it, retried on a later visit */
}
@@ -937,7 +947,7 @@
if (p.type !== "series") return;
const existing = state.byKey[keyOf(p)];
if (!existing) return;
applyLatestChapterIfChanged(
reportLatestChapter(
existing,
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
);
@@ -977,7 +987,7 @@
const html = await res.text();
latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
}
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
await reportLatestChapter(state.byKey[bm.key] || bm, latest);
} catch (e) {
/* offline or blocked — try again after the throttle window */
}
+24 -14
View File
@@ -809,27 +809,37 @@
}
}
// Records the newest chapter a site has published. Silent: this fires from
// Reports the newest chapter a site has published. Silent: this fires from
// page visits and background checks the user did not ask for, and it never
// reorders the list — updated_at is a candidate the server discards unless
// reading progress moved.
async function applyLatestChapterIfChanged(existing, latest) {
//
// An unchanged number is still sent. It is the read that lets the backend
// skip its own poll of this series (a Sighting, issue #103), so the common
// case — visiting a series with nothing new — is exactly the one worth
// reporting. Only the local write and the re-render are skipped.
async function reportLatestChapter(existing, latest) {
if (!existing || !latest) return;
if (existing.latest_chapter_num === latest.num) return;
const bm = Object.assign({}, existing, {
latest_chapter: latest.label,
latest_chapter_num: latest.num,
updated_at: Date.now(),
});
upsertLocal(bm);
render();
const changed = existing.latest_chapter_num !== latest.num;
let bm = existing;
if (changed) {
bm = Object.assign({}, existing, {
latest_chapter: latest.label,
latest_chapter_num: latest.num,
updated_at: Date.now(),
});
upsertLocal(bm);
render();
}
// A queued write owns this row; the drain sends latest_chapter
// with it, carrying the correct bucket.
if (queueGet(bm.key)) return;
try {
const saved = await apiPut(bm.key, bm);
upsertLocal(saved);
render();
if (changed) {
upsertLocal(saved);
render();
}
} catch (e) {
/* offline — the local cache still shows it, retried on a later visit */
}
@@ -841,7 +851,7 @@
if (p.type !== "series") return;
const existing = state.byKey[keyOf(p)];
if (!existing) return;
applyLatestChapterIfChanged(
reportLatestChapter(
existing,
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
);
@@ -883,7 +893,7 @@
if (!res.ok) continue;
const html = await res.text();
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
await reportLatestChapter(state.byKey[bm.key] || bm, latest);
} catch (e) {
/* offline or blocked — try again after the throttle window */
}