From 20fff588ccb577ad43709fe730dbc3a2e6033a31 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 16 Aug 2026 21:17:29 +0700 Subject: [PATCH] fix: don't read Cloudflare's injected jsd script as a refusal (#109) Co-authored-by: Sulthan Zaki Co-committed-by: Sulthan Zaki --- backend/AGENTS.md | 7 +++++++ backend/internal/latest/browser.go | 13 ++++++++++--- backend/internal/latest/browser_test.go | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 23ecc92..9d35324 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -127,6 +127,13 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN 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, comix and novelfull sit behind Cloudflare JavaScript challenges the TLS client diff --git a/backend/internal/latest/browser.go b/backend/internal/latest/browser.go index d8c7a20..88a7254 100644 --- a/backend/internal/latest/browser.go +++ b/backend/internal/latest/browser.go @@ -250,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//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 diff --git a/backend/internal/latest/browser_test.go b/backend/internal/latest/browser_test.go index 34c20dc..4cabb75 100644 --- a/backend/internal/latest/browser_test.go +++ b/backend/internal/latest/browser_test.go @@ -117,6 +117,25 @@ func TestBrowserOnlyCoverURL(t *testing.T) { }) } } + +// 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 = `The Possessed Grappler + +Chapter 22` + 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)