Fix comix titles and covers, kagane volume chapters, and kagane cover rendering #37
@@ -20,6 +20,8 @@ Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free
|
|||||||
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
|
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
|
||||||
- Cloudflare's block on manga sites **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.
|
- 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`) and skips them entirely when that's unset. The four other sites poll fine over plain TLS.
|
- **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`) and skips them entirely when that's unset. The four other sites poll fine over plain TLS.
|
||||||
|
- **The CDP sidecar 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 clock zone matching the egress IP's country 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, so mounting the host's `/etc/localtime` does **not** work; `/etc/timezone` is mounted instead.
|
||||||
|
- **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
|
## Architecture
|
||||||
|
|
||||||
@@ -37,7 +39,12 @@ Backend (`cd backend`):
|
|||||||
- Single test: `go test -run TestName ./...`
|
- Single test: `go test -run TestName ./...`
|
||||||
- Build static binary: `CGO_ENABLED=0 go build`
|
- Build static binary: `CGO_ENABLED=0 go build`
|
||||||
|
|
||||||
Local stack: `docker compose up` (bookmark-api + postgres + headless-shell; `postgres-data` named volume, `restart: unless-stopped`).
|
Local stack: `docker compose up` (bookmark-api + postgres + headless-shell; `postgres-data` named volume, `restart: unless-stopped`). The `headless-shell` service keeps its name but now builds `chrome/` — real Google Chrome, for the reason in the hard constraints above.
|
||||||
|
|
||||||
|
Live CDP proof (needs a sidecar and network, skipped otherwise):
|
||||||
|
`SMOKE_BROWSER_WS_URL=ws://<host>:<port> go test -run TestSmokeKagane ./internal/latest`
|
||||||
|
— fetches a real kagane 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.
|
||||||
|
|
||||||
Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200.
|
Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Real Google Chrome for the latest-chapter poller and the kagane cover proxy.
|
||||||
|
#
|
||||||
|
# Not chromedp/headless-shell, which this replaces. headless-shell is a stripped
|
||||||
|
# Chrome build and Cloudflare's managed challenge on kagane.to never clears for
|
||||||
|
# it: measured 2026-08-08, 60s of a held-open tab still served the interstitial,
|
||||||
|
# while stock Chrome from the same IP cleared in ~4s. The tells are structural
|
||||||
|
# rather than a header — navigator.webdriver true, an empty plugin list, and
|
||||||
|
# Chromium- rather than Chrome-branded client hints. Overriding webdriver alone
|
||||||
|
# was tried and did not move it, so the browser build itself is the fix.
|
||||||
|
#
|
||||||
|
# zenika/alpine-chrome was also tried: its Chrome is 124 (2024), old enough that
|
||||||
|
# Cloudflare refuses it outright and old enough to break chromedp's CDP structs.
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
|
# Chrome is deliberately unpinned, against the usual rule. A pinned build goes
|
||||||
|
# stale, and a stale browser is exactly what Cloudflare turns away — the 124 in
|
||||||
|
# alpine-chrome is the worked example. Rebuild is the upgrade path.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates wget gnupg \
|
||||||
|
&& wget -qO- https://dl.google.com/linux/linux_signing_key.pub \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
|
||||||
|
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" \
|
||||||
|
> /etc/apt/sources.list.d/google-chrome.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends google-chrome-stable socat \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Unprivileged: Chrome refuses to run as root, and the CDP endpoint is a shell
|
||||||
|
# on whatever user owns it.
|
||||||
|
RUN useradd --create-home --shell /usr/sbin/nologin chrome
|
||||||
|
USER chrome
|
||||||
|
WORKDIR /home/chrome
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 9222
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Cloudflare scores a browser whose clock zone disagrees with its egress IP's
|
||||||
|
# country as a proxy, and kagane's challenge then never clears (measured
|
||||||
|
# 2026-08-08 from a Thai IP: identical container, UTC never cleared in 90s,
|
||||||
|
# Asia/Bangkok cleared in 4s). So the zone has to be right, and it has to be
|
||||||
|
# right the way Chrome reads it.
|
||||||
|
#
|
||||||
|
# TZ must carry the zone *name*. Chrome resolves the zone through ICU, which
|
||||||
|
# takes the name from /etc/localtime's symlink target and ignores the file's
|
||||||
|
# contents; bind-mounting the host's /etc/localtime therefore lands on the
|
||||||
|
# image's own symlink to Etc/UTC and leaves glibc reporting +07 while Chrome
|
||||||
|
# still reports UTC. /etc/timezone, mounted by docker-compose.yml, is the name.
|
||||||
|
[ -n "${TZ:-}" ] || TZ=$(cat /etc/timezone 2>/dev/null || echo UTC)
|
||||||
|
export TZ
|
||||||
|
|
||||||
|
# Chrome's own UA advertises "HeadlessChrome" under --headless=new, and that one
|
||||||
|
# token is the difference between kagane.to's challenge clearing in ~4s and
|
||||||
|
# never clearing at all (measured 2026-08-08, same host, same Chrome, only the
|
||||||
|
# UA changed). Overriding it does not touch the Sec-CH-UA client hints, which
|
||||||
|
# report the real version, so the version is read back out of the binary rather
|
||||||
|
# than hardcoded: a hardcoded one would drift out of step with the hints on the
|
||||||
|
# next Chrome update and become a fresh tell.
|
||||||
|
major=$(google-chrome-stable --version | sed -E 's/[^0-9]*([0-9]+)\..*/\1/')
|
||||||
|
ua="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
# Chrome binds its DevTools port to loopback and silently ignores
|
||||||
|
# --remote-debugging-address (verified 2026-08-08: Chrome 151 with
|
||||||
|
# --remote-debugging-address=0.0.0.0 still listened on 127.0.0.1 only), so the
|
||||||
|
# caller — another container — cannot reach it directly. socat fronting the
|
||||||
|
# loopback port is how chromedp/headless-shell solved the same problem and is
|
||||||
|
# why this image is a drop-in for it.
|
||||||
|
#
|
||||||
|
# Nothing publishes 9222; reachability is the `browser` network in
|
||||||
|
# docker-compose.yml, and an exposed CDP endpoint is remote code execution.
|
||||||
|
socat TCP-LISTEN:9222,fork,reuseaddr TCP:127.0.0.1:9223 &
|
||||||
|
|
||||||
|
# Chrome stays in the foreground so that its death takes the container down and
|
||||||
|
# compose's restart policy applies; a backgrounded browser behind a live socat
|
||||||
|
# would leave the sidecar looking healthy while answering nothing.
|
||||||
|
#
|
||||||
|
# No --enable-automation: it sets navigator.webdriver, the first thing a bot
|
||||||
|
# check reads.
|
||||||
|
#
|
||||||
|
# --no-sandbox because Chrome's zygote wants user namespaces, which Docker's
|
||||||
|
# default profile does not hand out; the alternative is --cap-add=SYS_ADMIN,
|
||||||
|
# which gives the container strictly more than it takes away. Containment here
|
||||||
|
# is the unprivileged user, the isolated network, and the fact that this
|
||||||
|
# browser only ever navigates to kagane.to and novelfull.com.
|
||||||
|
exec google-chrome-stable \
|
||||||
|
--headless=new \
|
||||||
|
--no-sandbox \
|
||||||
|
--remote-debugging-port=9223 \
|
||||||
|
--user-agent="$ua" \
|
||||||
|
--user-data-dir=/home/chrome/profile \
|
||||||
|
--no-first-run \
|
||||||
|
--no-default-browser-check \
|
||||||
|
--disable-gpu \
|
||||||
|
about:blank
|
||||||
+19
-12
@@ -95,8 +95,24 @@ services:
|
|||||||
- db
|
- db
|
||||||
|
|
||||||
headless-shell:
|
headless-shell:
|
||||||
image: chromedp/headless-shell:stable
|
# Real Google Chrome, not chromedp/headless-shell — see chrome/Dockerfile.
|
||||||
|
# The service name is kept so existing overrides and BROWSER_WS_URL stay put.
|
||||||
|
build: ./chrome
|
||||||
|
image: bookmarkmanager-chrome:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Cloudflare scores a browser whose clock zone disagrees with its egress
|
||||||
|
# IP's country as a proxy, and kagane's challenge then never clears
|
||||||
|
# (measured 2026-08-08: identical container, UTC never cleared in 90s,
|
||||||
|
# Asia/Bangkok cleared in 4s from a Thai IP). Unset falls back to the
|
||||||
|
# host's /etc/timezone below, which is right whenever the host clock is
|
||||||
|
# set to local time; set BROWSER_TZ when the host runs UTC somewhere that
|
||||||
|
# isn't, since it is the IP's country that has to match, not the clock's.
|
||||||
|
TZ: ${BROWSER_TZ:-}
|
||||||
|
volumes:
|
||||||
|
# The zone *name*, which is what Chrome's ICU needs — see chrome/entrypoint.sh.
|
||||||
|
# Absent on a non-Debian host, which the entrypoint handles by falling back to UTC.
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
# Chrome allocates shared memory per tab and dies on Docker's 64MB default.
|
# Chrome allocates shared memory per tab and dies on Docker's 64MB default.
|
||||||
shm_size: '1gb'
|
shm_size: '1gb'
|
||||||
# Reaps zombie renderer processes, which otherwise accumulate for the
|
# Reaps zombie renderer processes, which otherwise accumulate for the
|
||||||
@@ -104,17 +120,8 @@ services:
|
|||||||
init: true
|
init: true
|
||||||
# Deliberately no `ports:` — an exposed CDP endpoint is remote code
|
# Deliberately no `ports:` — an exposed CDP endpoint is remote code
|
||||||
# execution. Only bookmark-api, via the `browser` network below, may reach it.
|
# execution. Only bookmark-api, via the `browser` network below, may reach it.
|
||||||
# Don't pass --remote-debugging-address/--remote-debugging-port here: the
|
# No `command:` either: every flag this browser needs is in its entrypoint,
|
||||||
# image's own entrypoint (/headless-shell/run.sh) already starts Chrome on
|
# and the UA override there is load-bearing for the challenge.
|
||||||
# 127.0.0.1:9223 and fronts it with a socat proxy listening on 0.0.0.0:9222.
|
|
||||||
# Redeclaring the port flag here overrides Chrome's, so it binds 9222
|
|
||||||
# directly (IPv6 loopback only) instead of 9223 — collides with socat's own
|
|
||||||
# bind on 9222 and leaves nothing listening on 9223, so every external
|
|
||||||
# connection to headless-shell:9222 fails with EOF. Only pass flags the
|
|
||||||
# entrypoint doesn't already set.
|
|
||||||
command:
|
|
||||||
- --disable-gpu
|
|
||||||
- --no-sandbox
|
|
||||||
networks:
|
networks:
|
||||||
browser:
|
browser:
|
||||||
# Pinned so BROWSER_WS_URL can name an IP (required, see above) that
|
# Pinned so BROWSER_WS_URL can name an IP (required, see above) that
|
||||||
|
|||||||
Reference in New Issue
Block a user