Removes every explanatory comment added by this branch, inlines the browser
prefs rather than holding them in a module constant, folds _cloudflare_frame
into its only caller, and cuts the added docstrings to one line each.
No behaviour change: 13 unit tests pass, and removing the checked-box guard
still fails its test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
Cuts ~100 lines of commentary that restated the diff or recorded dead
investigation, and merges _press_point back into _press_checkbox now that
the checked guard is one condition rather than the extra return that
tripped the too-many-returns lint.
Corrects the COOP/COEP note, which claimed the pair changed no outcome.
Without those prefs the widget's iframe never appears in page.frames at
all: measured on ext.to, eight presses land with them and none without.
No behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
playwright-captcha's ClickSolver clicks the challenge's input element
directly. That input sits under a styled overlay, so Playwright reports a
successful click while `checked` never flips -- which is why the
interactive challenge has never been solved here. The solver also judged
its own click by waiting for networkidle, which returned 9ms later while
Cloudflare was still verifying, so it reported failure on challenges that
were about to pass.
Replace it with a poll loop that watches for the challenge markup to go
away and presses the widget's visible pixels whenever an unchecked box is
on offer. A box that is already checked is left alone: pressing over the
top of Cloudflare's verification restarts it, and ext.to and speed.cd sat
on "performing security verification" for a full 300s budget while being
pressed a dozen times.
Measured on a residential connection, driving the real /v1 handler:
nowsecure.nl passes in 3s, extratorrent.st in 116s and 1337x.to in 198s,
all three returning cf_clearance. extratorrent.st had never cleared
before, on any network or solver. ext.to and speed.cd still refuse -- the
press registers and the widget re-serves a fresh unchecked box -- so they
stay in the xfail list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDMac4vGGcBhoUB5V6bvFK
Follow-up to the earlier CI fix, after A/B-ing every change against main and
against this branch's original commit.
What measurably changed, and what did not:
- The solver's retry loop was unbounded (max_attempts = sys.maxsize). On a
challenge it cannot clear it retried ~1300 times per request and the caller
waited out the entire max_timeout for a 408 it was always going to get.
_solve_challenge now clicks, waits for the challenge markup to actually
disappear, and gives up when the budget does.
- That wait exists because the solver's own verdict is worthless here: it
judges its click with wait_for_load_state("networkidle"), which returned 9ms
after the click while Cloudflare was still showing "verifying you are
human", and then reported failure.
- The "is it still up?" check cannot use detect_cloudflare_challenge alone.
That matches any script under /cdn-cgi/challenge-platform/, and Cloudflare
serves its jsd bot-scoring beacon from the same path on cleared pages. Nor
can it use the widget iframe: a cleared nowsecure.nl carries two of those
with no challenge present. CHALLENGE_MARKERS matches the challenge
orchestrator script and the interstitial's own markup.
- test_tls_handshake_looks_like_firefox pins what this branch is actually for.
Measured through /v1 on the same host: main offers 52 cipher suites, this
branch 16, and real Firefox offers 16. route.fetch() was re-issuing
navigations through Playwright's HTTP client, and that is a fingerprint no
header spoofing hides. Unlike a Cloudflare verdict the count is
deterministic, so it is the one assertion here that cannot flake.
- Disabling COOP/COEP does let the solver reach and click the checkbox for the
first time (Cloudflare advances to "verifying you are human"), but it changed
no outcome across eight sites, and real Firefox ships those policies on.
Recorded in a comment rather than shipped.
test_bypass keeps a hard assertion against targets that clear from any network.
The four Cloudflare guards hardest move to xfail rather than skip: they still
run and still report, but Cloudflare's opinion of the runner's IP cannot turn
the build red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bypass tests were failing on CI with 408s after 78 minutes. Neither the
runner's speed nor this branch's TLS change was responsible.
On the sites that fail, Cloudflare serves its interactive checkbox challenge.
playwright-captcha locates the widget iframe inside the shadow root and then
calls ElementHandle.content_frame(), which this Firefox build refuses:
Protocol error (Page.describeNode): Permission denied to access property
"docShell" on cross-origin object
Its fallback -- matching page.frames by URL -- cannot help either, because the
challenge frame exposes an empty URL to the parent. Every attempt therefore
ends in CaptchaDetectionError: Cloudflare iframes not found.
MAX_ATTEMPTS was sys.maxsize, so that repeated until the request budget ran
out: 432 docShell errors and 1326 retry iterations in a single request on the
runner, and with max_timeout raised to 360 and --retries 3, a 1h18m job.
Three changes:
- max_attempts defaults to 5. An unreachable widget stays unreachable, so the
retries were not buying anything; the caller now hears about it in seconds.
- _solve_challenge translates the solver's own give-up exceptions into the 408
read_item already reports for timeouts. Without this, bounding max_attempts
would have turned the hang into an unhandled 500.
- The solver framework goes back to PLAYWRIGHT. PATCHRIGHT skips the
unlockShadowRoot init script and injects over CDP instead, which Firefox has
no session for ("CDP session is only available in Chromium"). Cloudflare
builds its widget in a closed shadow root, so on this branch the challenge
iframe was invisible even to page.locator: 1 -> 0 against the same sites on
the same runner.
test_bypass drops the max_timeout=360 override and skips again on 408.
Whether Cloudflare shows the interactive challenge depends on the visitor, so
the runner's luck should not decide whether a regression of ours is reported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
page.goto() returns None only for about:blank or a same-URL-different-hash
navigation, so page_request is always present for a real request and its
headers always carry the UA. The evaluate call was therefore unreachable
as a fallback and, once moved before navigation, silently became the
primary source instead.
Request headers are also the correct source: consumers replay them with
the clearance cookies, so the UA the server saw is the one to report.
This restores the ordering d3a828e established.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
page.evaluate runs eval() in the page's main world, which fails with 'call to eval() blocked by CSP' under any CSP that disallows unsafe-eval - HTTP headers (already stripped), meta tags (not strippable), or internal viewer documents (#394).
The navigation request already carries the UA the site actually saw, so take user_agent from page_request.request.headers and keep evaluate only as a best-effort fallback whose failure can no longer 500 the request.
Resolved conflicts in src/consts.py and src/endpoints.py:
- consts.py: take theirs (CHALLENGE_TITLES removed, browser_locale added,
CaptchaType import no longer needed — detection is now library-based)
- endpoints.py: merge both refactors — keep theirs' detect_cloudflare_challenge
+ page_html capture, reapply my helper extraction (setup_routes,
_navigate_and_solve, _solve_challenge, _wait_for_networkidle,
build_response_content, _fetch_pdf_content) on top
Cloudflare localizes its interstitial page title per visitor language
(e.g. Polish "Cierpliwości..." served by 1337x.to), so the hard-coded
["Just a moment..."] title check missed every non-English visitor:
Byparr returned the raw challenge page (HTTP 403, no cf_clearance
cookie, no "Challenge detected" log) and Prowlarr reported "Unable to
access 1337x.to, blocked by CloudFlare Protection." (issue #385, still
open on 3.0.1 after the compression fix).
Replace the title-based gate with the playwright-captcha library's own
language-independent DOM detection (detect_cloudflare_challenge), which
matches Cloudflare's challenge scripts directly:
- interstitial: script[src*="/cdn-cgi/challenge-platform/"]
- turnstile: input[name="cf-turnstile-response"],
script[src*="challenges.cloudflare.com/turnstile/v0"]
Both selectors match the live 1337x "Cierpliwości..." interstitial.
The navigation/detect/solve flow lives in _navigate_and_solve(); the
timeout-to-408 translation is inlined at the call site in read_item.
The now-unused title map is removed from src/consts.py.
Verified live (built image): "Challenge detected" now fires on 1337x
(0 -> 1 in logs) where the title check never fired; example.com negative
control returns 200 with no challenge path entered. End-to-end clearing
still depends on the requester's public IP (README caveat).
route.fulfill(response=...) re-serves the raw bytes fetched by
route.fetch(), so compressed (gzip/brotli/zstd) documents arrive
at the browser still compressed while the forwarded headers claim
otherwise - page.content() then returns garbled binary, breaking
indexers like uindex.org and 1337x.to (issue #385).
Fetch with accept-encoding: identity so the re-served body is plain
text, and drop content-encoding/content-length alongside the CSP
headers since they are stale after the rewrite.
A page whose network never goes idle (background analytics, websockets)
used to fail the whole request with a 408 once the networkidle wait
expired. Since the DOM is fully usable after domcontentloaded, treat a
networkidle timeout as non-fatal and return the loaded page instead.
Fatal timeouts during initial load or challenge solving still return 408.
Adds unit coverage for both paths using a fake page that fails
configured load-state waits.
The Firefox engine evaluates JS via eval(), which pages whose CSP
lacks 'unsafe-eval' block - every page.evaluate() then fails with
"call to eval() blocked by CSP". yggtorrent's search URL redirects to
a page with such a CSP, crashing the user-agent read and 500ing /v1.
Rewrite document responses without CSP headers via route.fetch +
fulfill. Juggler only routes the first request of a redirect chain,
so follow redirects inside the fetch and record the final URL
ourselves instead of relying on page.url.
- Catch both builtins.TimeoutError and playwright TimeoutError as 408
- Check challenge title before networkidle to avoid timeout on Cloudflare interstitial
- Add blockMedia and returnOnlyCookies request options
- Return raw PDF bytes as base64 with contentType application/pdf
- Skip tests on 408 timeouts; add PDF handling test