diff --git a/packages/tiers/src/solvers/turnstile.ts b/packages/tiers/src/solvers/turnstile.ts new file mode 100644 index 0000000..6a104d5 --- /dev/null +++ b/packages/tiers/src/solvers/turnstile.ts @@ -0,0 +1,148 @@ +// Cloudflare Turnstile solver — handles both: +// +// 1. Interstitial mode: CF serves a full-page Turnstile challenge BEFORE +// letting the user through. The challengeWait.ts loop handles this by +// clicking the iframe widget while polling for cf_clearance. +// +// 2. Embedded mode: The page itself contains a
widget. +// This is used by sites as a form protection (not as a page gate). +// Solving it generates a turnstile token in the hidden input, which the +// page's JS can then use to allow form submission or reveal content. +// +// Non-interactive mode: When Cloudflare determines the browser has a good +// risk score (Camoufox Firefox, residential IP, no automation signals), Turnstile +// auto-solves without any click — a spinner briefly appears then turns into +// a green checkmark. + +import type { Frame, Page } from "patchright" + +// Turnstile iframes can come from multiple CF origin paths depending on region and mode. +const CF_ORIGINS = ["challenges.cloudflare.com", "cloudflare.com/cdn-cgi", "cdn-cgi/challenge-platform"] + +export async function solveTurnstile(page: Page, timeoutMs = 25_000): Promise { + const deadline = Date.now() + timeoutMs + + // With Camoufox (Firefox + geoip), Turnstile often auto-solves immediately. + // The token lands in the hidden input before the iframe even appears. + const existingToken = await page + .locator('input[name="cf-turnstile-response"]') + .inputValue() + .catch(() => "") + if (existingToken) { + console.log("[turnstile] auto-solved, token:", `${existingToken.slice(0, 20)}...`) + return true + } + + const allFrames = page.frames() + console.log(`[turnstile] ${allFrames.length} frames on page:`, allFrames.map((f) => f.url().slice(0, 80)).join(" | ")) + + console.log("[turnstile] waiting for challenge iframe or auto-solve token...") + + // Give managed-mode Turnstile time to auto-verify before attempting click. + // On good IPs + Camoufox Firefox fingerprint, CF resolves within 3-8s passively. + await new Promise((r) => setTimeout(r, 5000)) + + let clickAttempts = 0 + while (Date.now() < deadline) { + // Check all possible token locations (hidden input set by turnstile.js) + const token = await page + .evaluate(() => { + const el = document.querySelector('[name="cf-turnstile-response"]') + return el?.value ?? "" + }) + .catch(() => "") + if (token) { + console.log("[turnstile] solved, token:", `${token.slice(0, 30)}...`) + return true + } + + // Find the CF challenge frame and attempt click + const frames = page.frames() + const cfFrame = frames.find(isChallengeFrame) + if (cfFrame) { + // Try frame-internal click — may succeed if Fission doesn't isolate this frame + const clickedInternal = await clickInFrameCheckbox(cfFrame) + if (clickedInternal) { + console.log("[turnstile] clicked via frame locator (attempt", ++clickAttempts, ")") + } else { + // Firefox Fission isolates CF iframe in a separate process — frame DOM is inaccessible. + // frame.frameElement() gives us the