/** * @fetcha/browser — managed headless Chromium for rendered fetches. * * One Chromium process (full Chromium in "new headless" mode, not the headless shell, driven by * Patchright — Playwright patched against the classic CDP leaks) hosts many isolated contexts; every * render gets its own context with the upstream proxy credentials of the chosen route, a consistent * fingerprint (UA, viewport, locale, timezone, platform) and stealth patches. Cloudflare/DataDome * style JavaScript challenges are given time to complete (and a best-effort Turnstile click) before * the DOM is captured. Set FETCHA_BROWSER_HEADLESS=0 on a node with a graphical session to run a * real (off-screen) window, which defeats more headless-detection heuristics. */ import { FetchaError, isBlockedHostname, isIP, isBlockedIP, looksBlocked, type HttpMethod } from "@fetcha/core"; import type { FingerprintProfile, ProxyEndpoint } from "@fetcha/providers"; import { acceptLanguage } from "@fetcha/providers"; import type { Browser, BrowserContext, Page, Response as PwResponse } from "patchright"; import { stealthScript, timezoneFor, webglFor } from "./stealth"; import { TURNSTILE_HOOK_KEY, TwoCaptchaSolver, turnstileHookScript, type TurnstileParams, type TurnstileSolution } from "./captcha"; export * from "./captcha"; export interface RenderRequest { url: string; method: HttpMethod; body?: string | Buffer; proxy: ProxyEndpoint | null; profile: FingerprintProfile; locale?: string | null; country?: string | null; headers?: Record; cookies?: Array<{ name: string; value: string; domain: string; path: string; secure?: boolean; expires?: number | null }>; referer?: string | null; timeoutMs: number; waitUntil: "load" | "domcontentloaded" | "networkidle"; waitFor?: string | null; waitMs?: number | null; javascript: boolean; blockResources: boolean; screenshot: boolean; maxResponseBytes: number; /** Use the configured captcha solver (2captcha) on Turnstile challenges (default true). */ solveCaptcha?: boolean; /** Called for every main-frame navigation to a new URL (redirects). Throw to abort. */ onRedirect?: (nextUrl: string) => Promise; } export interface CaptchaOutcome { provider: "2captcha"; /** Token obtained and the page passed afterwards. */ solved: boolean; ms: number; costUsd: number; } export interface RenderResult { status: number; headers: Record; /** Serialised DOM (UTF-8 HTML). */ body: Buffer; finalUrl: string; redirects: number; bytesIn: number; bytesOut: number; timing: { navigation_ms: number; challenge_ms: number; settle_ms: number; capture_ms: number; total_ms: number }; cookies: Array<{ name: string; value: string; domain: string; path: string; secure: boolean; expires: number | null }>; screenshot?: Buffer; /** True when a challenge page was detected and later replaced by real content. */ challengeSolved: boolean; /** Block verdict on the captured DOM (after the challenge wait). */ block: ReturnType; /** Captcha solver involvement, when a token was purchased. */ captcha: CaptchaOutcome | null; } export interface BrowserPoolOptions { /** Max concurrent renders across the process. */ maxConcurrency?: number; /** How long a render may wait for a slot before failing with BROWSER_UNAVAILABLE. */ queueTimeoutMs?: number; /** Close the browser after this idle time. */ idleCloseMs?: number; /** Playwright channel: "chromium" (full build, new headless) or "chrome". */ channel?: string; enabled?: boolean; /** Maximum time to give a JS challenge to resolve. */ challengeWaitMs?: number; /** Run a real (off-screen) window instead of headless mode. Requires a graphical session. */ headless?: boolean; /** 2captcha API key (default: env TWOCAPTCHA_API_KEY). Empty = no solver. */ captchaApiKey?: string | null; log?: { info: (m: string) => void; warn: (m: string) => void }; } export interface BrowserStatus { enabled: boolean; launched: boolean; running: number; capacity: number; queue: number; renders: number; failures: number; challengesSolved: number; lastError: string | null; version: string | null; headless: boolean; channel: string; captchaSolver: { provider: string; requested: number; solved: number; failed: number; spentUsd: number; lastError: string | null } | null; } /** Assets that anti-bot vendors use to verify a real browser (trace pixels, widget assets): never block them. */ const ANTIBOT_ASSET_RE = /\/cdn-cgi\/|challenges\.cloudflare\.com|captcha-delivery\.com|datadome|perimeterx|px-cdn|px-cloud|kasada|kpsdk|imperva|incapsula|_Incapsula_Resource|hcaptcha\.com|recaptcha|gstatic\.com\/recaptcha|arkoselabs|funcaptcha|awswaf|distil|shape-security|vercel\.com\/_vercel|\/_vercel\/|akamaihd|akam\/|\/akam\//i; const CHALLENGE_SELECTORS = ["#challenge-running", "#challenge-form", "#challenge-stage", ".cf-turnstile", "iframe[src*='challenges.cloudflare.com']", "#px-captcha", "#datadome", "iframe[src*='captcha-delivery.com']", "#sec-cpt-if", "form#challenge", "#cmsg", "[data-testid='challenge']"]; export class BrowserPool { private browser: Browser | null = null; private launching: Promise | null = null; /** User agent reported by the real Chromium build (with "HeadlessChrome" normalised to "Chrome"). */ private nativeUa: string | null = null; private readonly solver: TwoCaptchaSolver | null; private running = 0; private queue: Array<() => void> = []; private idleTimer: NodeJS.Timeout | null = null; private stats = { renders: 0, failures: 0, challengesSolved: 0, lastError: null as string | null }; private readonly opts: Required> & { log: NonNullable }; constructor(opts: BrowserPoolOptions = {}) { this.opts = { maxConcurrency: opts.maxConcurrency ?? Number(process.env.FETCHA_BROWSER_CONCURRENCY ?? 6), queueTimeoutMs: opts.queueTimeoutMs ?? 25_000, idleCloseMs: opts.idleCloseMs ?? 10 * 60_000, channel: opts.channel ?? process.env.FETCHA_BROWSER_CHANNEL ?? "chromium", enabled: opts.enabled ?? process.env.FETCHA_BROWSER_ENABLED !== "0", challengeWaitMs: opts.challengeWaitMs ?? 18_000, headless: opts.headless ?? process.env.FETCHA_BROWSER_HEADLESS !== "0", captchaApiKey: opts.captchaApiKey === undefined ? process.env.TWOCAPTCHA_API_KEY?.trim() || null : opts.captchaApiKey, log: opts.log ?? { info: (m) => console.log("[browser]", m), warn: (m) => console.warn("[browser]", m) }, }; this.solver = this.opts.captchaApiKey ? new TwoCaptchaSolver({ apiKey: this.opts.captchaApiKey, log: { info: (m) => this.opts.log.info(`2captcha: ${m}`), warn: (m) => this.opts.log.warn(`2captcha: ${m}`) } }) : null; } get enabled(): boolean { return this.opts.enabled; } get captchaSolver(): TwoCaptchaSolver | null { return this.solver; } status(): BrowserStatus { return { enabled: this.opts.enabled, launched: Boolean(this.browser?.isConnected()), running: this.running, capacity: this.opts.maxConcurrency, queue: this.queue.length, renders: this.stats.renders, failures: this.stats.failures, challengesSolved: this.stats.challengesSolved, lastError: this.stats.lastError, version: this.browser?.version() ?? null, headless: this.opts.headless, channel: this.opts.channel, captchaSolver: this.solver ? this.solver.status() : null, }; } private async getBrowser(): Promise { if (this.browser?.isConnected()) return this.browser; if (this.launching) return this.launching; this.launching = (async () => { // Patchright = Playwright patched against CDP leaks (Runtime.enable, command-line flags, console). const { chromium } = await import("patchright"); const args = [ "--no-first-run", "--no-default-browser-check", "--disable-dev-shm-usage", "--disable-background-timer-throttling", "--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows", "--disable-features=IsolateOrigins,site-per-process,Translate,MediaRouter,OptimizationHints", "--disable-infobars", "--no-service-autorun", "--password-store=basic", "--use-mock-keychain", "--export-tagged-pdf", "--force-color-profile=srgb", "--window-size=1920,1080", ...(this.opts.headless ? [] : ["--window-position=-32000,-32000"]), ]; const launch = async (channel: string | undefined) => chromium.launch({ headless: this.opts.headless, channel, args, proxy: { server: "per-context" }, timeout: 60_000, }); let b: Browser; try { b = await launch(this.opts.channel); } catch (e) { this.opts.log.warn(`launch with channel=${this.opts.channel} failed (${(e as Error).message.split("\n")[0]}), retrying default build`); b = await launch(undefined); } b.on("disconnected", () => { this.opts.log.warn("browser disconnected"); if (this.browser === b) this.browser = null; }); this.browser = b; // Client hints (Sec-CH-UA*) come from the real build and cannot be spoofed coherently, so the UA // we present is the native one minus the headless marker — never a made-up version. try { const probe = await b.newContext({ proxy: { server: "http://127.0.0.1:1", bypass: "*" } }); const pg = await probe.newPage(); this.nativeUa = (await pg.evaluate(() => navigator.userAgent)).replace(/HeadlessChrome/g, "Chrome"); await probe.close(); } catch { this.nativeUa = null; } this.opts.log.info(`chromium ${b.version()} launched (channel=${this.opts.channel}, headless=${this.opts.headless}, concurrency=${this.opts.maxConcurrency})`); return b; })(); try { return await this.launching; } finally { this.launching = null; } } private touchIdle() { if (this.idleTimer) clearTimeout(this.idleTimer); this.idleTimer = setTimeout(() => { if (this.running === 0 && this.browser) { this.opts.log.info("closing idle browser"); this.browser.close().catch(() => {}); this.browser = null; } }, this.opts.idleCloseMs); this.idleTimer.unref?.(); } private async acquire(): Promise<() => void> { if (this.running < this.opts.maxConcurrency) { this.running++; return () => this.release(); } return new Promise<() => void>((resolve, reject) => { const timer = setTimeout(() => { this.queue = this.queue.filter((f) => f !== wake); reject(new FetchaError("BROWSER_UNAVAILABLE", "The managed browser is busy. Retry in a few seconds.")); }, this.opts.queueTimeoutMs); const wake = () => { clearTimeout(timer); this.running++; resolve(() => this.release()); }; this.queue.push(wake); }); } private release() { this.running = Math.max(0, this.running - 1); const next = this.queue.shift(); if (next) next(); else this.touchIdle(); } async close(): Promise { if (this.idleTimer) clearTimeout(this.idleTimer); await this.browser?.close().catch(() => {}); this.browser = null; } async render(req: RenderRequest): Promise { if (!this.opts.enabled) throw new FetchaError("BROWSER_UNAVAILABLE"); const release = await this.acquire(); const started = performance.now(); const deadline = started + req.timeoutMs; let context: BrowserContext | null = null; try { const browser = await this.getBrowser(); const profile = req.profile; const languages = acceptLanguage(req.locale, req.country) .split(",") .map((s) => s.split(";")[0]!.trim()) .filter(Boolean); const isMobile = profile.device === "mobile"; const extraHeaders: Record = {}; let customUa: string | null = null; for (const [k, v] of Object.entries(req.headers ?? {})) { const lk = k.toLowerCase(); if (lk === "user-agent") { customUa = v; continue; } if (["cookie", "host", "content-length", "accept-encoding", "connection"].includes(lk)) continue; extraHeaders[lk] = v; } const userAgent = customUa ?? (isMobile ? profile.userAgent : this.nativeUa ?? profile.userAgent); if (!extraHeaders["accept-language"]) extraHeaders["accept-language"] = acceptLanguage(req.locale, req.country); context = await browser.newContext({ // The browser is launched with per-context proxying; a context without an upstream proxy // must still provide one, so we point at an unreachable local proxy and bypass every host (= direct). proxy: req.proxy ? { server: `http://${req.proxy.host}:${req.proxy.port}`, username: req.proxy.username, password: req.proxy.password } : { server: "http://127.0.0.1:1", bypass: "*" }, userAgent, viewport: profile.viewport, deviceScaleFactor: isMobile ? 3 : profile.tls === "safari" ? 2 : 1, isMobile, hasTouch: isMobile || profile.device === "tablet", locale: languages[0] ?? "en-US", timezoneId: timezoneFor(req.country), javaScriptEnabled: req.javascript, extraHTTPHeaders: extraHeaders, ignoreHTTPSErrors: false, colorScheme: "light", serviceWorkers: "block", acceptDownloads: false, }); const webgl = webglFor(customUa || isMobile ? profile.platform : /Windows/.test(userAgent) ? "Win32" : /Linux/.test(userAgent) ? "Linux x86_64" : "MacIntel", profile.tls); const platform = customUa || isMobile ? profile.platform : /Windows/.test(userAgent) ? "Win32" : /Linux/.test(userAgent) ? "Linux x86_64" : "MacIntel"; await context.addInitScript(stealthScript({ platform, languages, hardwareConcurrency: isMobile ? 8 : 12, deviceMemory: 8, vendor: webgl.vendor, renderer: webgl.renderer, mobile: isMobile })); if (this.solver && req.solveCaptcha !== false) await context.addInitScript(turnstileHookScript()); if (req.cookies?.length) { await context .addCookies( req.cookies.map((c) => ({ name: c.name, value: c.value, domain: c.domain.startsWith(".") ? c.domain : c.domain, path: c.path || "/", secure: Boolean(c.secure), httpOnly: false, expires: c.expires ? Math.floor(c.expires / 1000) : -1, sameSite: "Lax" as const, })), ) .catch(() => {}); } let bytesIn = 0; let bytesOut = 0; const pendingSizes: Promise[] = []; const initialUrl = new URL(req.url); let redirects = 0; let policyError: FetchaError | null = null; let firstNav = true; // Resource blocking is lifted as soon as a challenge is detected: anti-bot scripts verify that // images/fonts actually load (measured: solved tokens were rejected with blocking on). let blockResources = req.blockResources; const page = await context.newPage(); page.setDefaultTimeout(Math.min(req.timeoutMs, 60_000)); await page.route("**/*", async (route) => { const r = route.request(); const isMain = r.isNavigationRequest() && r.frame() === page.mainFrame(); let target: URL; try { target = new URL(r.url()); } catch { return route.abort("blockedbyclient"); } // SSRF: never let the browser touch private hosts (even via subresources / redirects). const host = target.hostname.replace(/^\[|\]$/g, ""); if (isBlockedHostname(host) || (isIP(host) && isBlockedIP(host)) || !/^https?:$/.test(target.protocol)) { if (isMain) policyError = new FetchaError("URL_NOT_ALLOWED", "The page redirected to a local, private or internal host."); return route.abort("blockedbyclient"); } if (isMain) { if (firstNav) { firstNav = false; if (req.method !== "GET") { const headers = { ...r.headers() }; if (req.body !== undefined && !headers["content-type"]) headers["content-type"] = typeof req.body === "string" ? "application/json" : "application/octet-stream"; return route.fallback({ method: req.method, postData: req.body, headers }); } } else if (r.url() !== initialUrl.toString()) { redirects++; if (req.onRedirect) { try { await req.onRedirect(r.url()); } catch (e) { policyError = e instanceof FetchaError ? e : new FetchaError("URL_NOT_ALLOWED"); return route.abort("blockedbyclient"); } } } // fallback() (not continue()) lets Patchright's context-level route inject the init scripts. return route.fallback(); } if (blockResources && !ANTIBOT_ASSET_RE.test(r.url())) { const type = r.resourceType(); if (type === "image" || type === "media" || type === "font" || type === "manifest" || type === "texttrack") return route.abort("blockedbyclient"); } return route.fallback(); }); page.on("requestfinished", (r) => { pendingSizes.push( r .sizes() .then((s) => { bytesOut += s.requestBodySize + s.requestHeadersSize; bytesIn += s.responseBodySize + s.responseHeadersSize; }) .catch(() => {}), ); }); page.on("dialog", (d) => d.dismiss().catch(() => {})); const nav: { last: PwResponse | null } = { last: null }; // holder: assigned from an event callback page.on("response", (r) => { try { if (r.request().isNavigationRequest() && r.frame() === page.mainFrame() && r.status() !== 304) nav.last = r; } catch { /* ignore */ } }); // Navigate const tNav0 = performance.now(); let response: PwResponse | null = null; try { response = await page.goto(req.url, { waitUntil: req.waitUntil, timeout: Math.max(1000, Math.min(req.timeoutMs - 1500, deadline - performance.now())), referer: req.referer ?? undefined }); } catch (e) { if (policyError) throw policyError; const msg = (e as Error).message ?? String(e); if (/Timeout/i.test(msg)) { // Partial content may still be useful: if the document exists, capture what we have. if (!(await page.content().catch(() => "")).includes(" ""); let verdict = looksBlocked(status, html, headers); if (verdict.blocked && verdict.challenge !== false && req.javascript) { blockResources = false; const challengeStatus = status; const solverAllowed = Boolean(this.solver) && req.solveCaptcha !== false; const loopStart = performance.now(); let until = loopStart + Math.max(0, Math.min(this.opts.challengeWaitMs, deadline - performance.now() - 1500)); let clickedTurnstile = false; let solveState: { started: boolean; done: boolean; sol: TurnstileSolution | null } = { started: false, done: false, sol: null }; let injected = false; while (performance.now() < until) { await page.waitForTimeout(500).catch(() => {}); // Natural resolution: the challenge script redirects / reloads the document. Fast reloads are // missed by waitForNavigation, so the main-frame response listener is the source of truth. await page.waitForNavigation({ timeout: 700, waitUntil: "domcontentloaded" }).catch(() => null); const navResp = nav.last && nav.last !== response ? nav.last : null; if (navResp) { response = navResp; status = navResp.status(); headers = lower(navResp.headers()); } html = await page.content().catch(() => ""); // Interstitial markers only: the real page of a protected site also references // challenges.cloudflare.com (beacon), so vendor script URLs must not count here. const stillChallenge = /cf_chl_opt|__cf_chl_|id="challenge-(running|stage|form|error-text)"|\s*Just a moment|id="px-captcha"|geo\.captcha-delivery\.com|id="datadome"|Verifying you are human|Performing security verification/i.test(html.slice(0, 60_000)); // Headers of the interstitial (cf-mitigated…) must not veto a document that has moved on. const probeHeaders = stillChallenge ? headers : Object.fromEntries(Object.entries(headers).filter(([k]) => k !== "cf-mitigated" && k !== "x-amzn-waf-action" && k !== "x-datadome")); const v = looksBlocked(navResp ? status : 200, html, probeHeaders); if (!v.blocked && !stillChallenge && html.length > 800) { challengeSolved = true; if (!navResp) { status = 200; // the document was replaced in place headers = probeHeaders; } break; } // Captcha solver: once the widget parameters are visible, buy a token (in the background) and keep waiting. if (solverAllowed && !solveState.started && performance.now() - loopStart > 1200 && deadline - performance.now() > 15_000) { const params = await this.readTurnstileParams(page); if (params?.sitekey) { solveState.started = true; const websiteURL = page.url(); this.solver!.solveTurnstile({ websiteURL, params, deadline: deadline - 4000 }) .then((sol) => (solveState = { started: true, done: true, sol })) .catch(() => (solveState = { started: true, done: true, sol: null })); until = deadline - 2500; // wait for the solver instead of the short challenge budget } } if (solveState.done && !injected) { injected = true; const sol = solveState.sol; if (!sol) { until = Math.min(until, performance.now() + 2500); } else { captcha = { provider: "2captcha", solved: false, ms: sol.ms, costUsd: sol.costUsd }; // Do NOT switch the page's User-Agent to the solver's: a mid-page UA change is exactly what // Cloudflare rejects (measured 2026-09-08: 0/2 with the CDP override, 2/2 without). const how = await this.injectTurnstileToken(page, sol.token); this.opts.log.info(`turnstile token injected via ${how} on ${new URL(page.url()).hostname} (solver ua ${sol.userAgent ? (sol.userAgent === userAgent ? "same" : "differs, kept ours") : "none"})`); until = Math.min(deadline - 1500, performance.now() + 15_000); } } // Best-effort click on a Turnstile / hCaptcha checkbox when no solver is available. if (!solverAllowed && !clickedTurnstile) clickedTurnstile = await this.tryClickChallenge(page); } verdict = looksBlocked(status, html, headers); if (challengeSolved) { this.stats.challengesSolved++; headers = { ...headers, "x-fetcha-challenge": `solved:${challengeStatus}${captcha ? ":2captcha" : ""}` }; verdict = { blocked: false }; if (captcha) captcha.solved = true; } } const challengeMs = Math.round(performance.now() - tCh0); // Settle: wait for selector / extra time; light human-like interaction. const tSettle0 = performance.now(); if (req.waitFor && !verdict.blocked) { const remaining = deadline - performance.now() - 800; if (remaining > 200) { try { await page.waitForSelector(req.waitFor, { timeout: Math.min(remaining, 45_000), state: "attached" }); } catch { throw new FetchaError("BROWSER_TIMEOUT", `The selector "${req.waitFor}" did not appear in time.`, { details: { wait_for: req.waitFor } }); } } } if (req.javascript && !verdict.blocked) { try { const vp = profile.viewport; await page.mouse.move(vp.width * (0.3 + Math.random() * 0.4), vp.height * (0.3 + Math.random() * 0.3), { steps: 5 }); await page.mouse.wheel(0, 200 + Math.round(Math.random() * 400)); } catch { /* ignore */ } } if (req.waitMs) await page.waitForTimeout(Math.min(req.waitMs, Math.max(0, deadline - performance.now() - 500))); else if (req.javascript && req.waitUntil !== "networkidle") await page.waitForLoadState("networkidle", { timeout: Math.min(2500, Math.max(0, deadline - performance.now() - 500)) }).catch(() => {}); const settleMs = Math.round(performance.now() - tSettle0); // Capture const tCap0 = performance.now(); html = await page.content().catch(() => html); const finalUrl = page.url(); const body = Buffer.from(html, "utf8"); if (body.length > req.maxResponseBytes) throw new FetchaError("RESPONSE_TOO_LARGE"); let screenshot: Buffer | undefined; if (req.screenshot) screenshot = await page.screenshot({ type: "png", fullPage: false, timeout: 15_000 }).catch(() => undefined); const cookies = (await context.cookies().catch(() => [])).map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, secure: c.secure, expires: c.expires && c.expires > 0 ? Math.round(c.expires * 1000) : null })); await Promise.race([Promise.allSettled(pendingSizes), new Promise((r) => setTimeout(r, 500))]); const captureMs = Math.round(performance.now() - tCap0); const totalMs = Math.round(performance.now() - started); const finalVerdict = challengeSolved ? { blocked: false } : looksBlocked(status, html, headers); this.stats.renders++; const outHeaders: Record<string, string> = { ...headers }; delete outHeaders["content-encoding"]; delete outHeaders["content-length"]; if (!outHeaders["content-type"]) outHeaders["content-type"] = "text/html; charset=utf-8"; return { status, headers: outHeaders, body, finalUrl, redirects, bytesIn: bytesIn || body.length, bytesOut, timing: { navigation_ms: navigationMs, challenge_ms: challengeMs, settle_ms: settleMs, capture_ms: captureMs, total_ms: totalMs }, cookies, screenshot, challengeSolved, block: finalVerdict, captcha, }; } catch (e) { this.stats.failures++; this.stats.lastError = (e as Error).message?.slice(0, 300) ?? String(e); throw e; } finally { await context?.close().catch(() => {}); release(); } } /** Read the intercepted `turnstile.render` parameters, or fall back to a widget's data-* attributes. */ private async readTurnstileParams(page: Page): Promise<TurnstileParams | null> { try { return await page.evaluate((key) => { const w = window as unknown as Record<string, { params?: TurnstileParams | null; callback?: unknown } | undefined>; const store = w[key]; if (store?.params?.sitekey) return { ...store.params, hasCallback: typeof store.callback === "function" } as TurnstileParams; const el = document.querySelector(".cf-turnstile[data-sitekey], [data-sitekey][data-callback], #cf-chl-widget-container [data-sitekey]") as HTMLElement | null; if (el?.dataset.sitekey) return { sitekey: el.dataset.sitekey, action: el.dataset.action ?? null, cData: el.dataset.cdata ?? null, chlPageData: null, hasCallback: false } as TurnstileParams; return null; }, TURNSTILE_HOOK_KEY, undefined, false); // Patchright: 4th arg = isolatedContext → false = page's main world } catch { return null; } } /** Hand the solved token to the widget's callback (challenge pages) or to the form field (standalone widgets). */ private async injectTurnstileToken(page: Page, token: string): Promise<string> { try { return await page.evaluate( ({ key, token }) => { const w = window as unknown as Record<string, { callback?: (t: string) => void } | undefined>; const store = w[key]; if (store && typeof store.callback === "function") { store.callback(token); return "callback"; } const input = document.querySelector('input[name="cf-turnstile-response"]') as HTMLInputElement | null; if (input) { input.value = token; const form = input.closest("form"); const cbName = (document.querySelector(".cf-turnstile[data-callback]") as HTMLElement | null)?.dataset.callback; const cb = cbName ? (window as unknown as Record<string, unknown>)[cbName] : null; if (typeof cb === "function") (cb as (t: string) => void)(token); else if (form) (form as HTMLFormElement & { requestSubmit?: () => void }).requestSubmit ? (form as HTMLFormElement).requestSubmit() : form.submit(); return "form"; } return "none"; }, { key: TURNSTILE_HOOK_KEY, token }, undefined, false, // main world: the callback captured by the init script lives there ); } catch (e) { return `error:${(e as Error).message.split("\n")[0]}`; } } private async tryClickChallenge(page: Page): Promise<boolean> { try { for (const frame of page.frames()) { const u = frame.url(); if (!/challenges\.cloudflare\.com|hcaptcha\.com|captcha-delivery\.com/.test(u)) continue; const el = await frame.frameElement().catch(() => null); const box = el ? await el.boundingBox().catch(() => null) : null; if (box && box.width > 20 && box.height > 20) { await page.mouse.move(box.x + 28 + Math.random() * 6, box.y + box.height / 2 + (Math.random() * 4 - 2), { steps: 8 }); await page.waitForTimeout(150 + Math.random() * 200); await page.mouse.click(box.x + 30, box.y + box.height / 2, { delay: 40 + Math.random() * 60 }); return true; } } const cb = page.locator("#challenge-stage input[type=checkbox], .ctp-checkbox-label, label.ctp-checkbox-label").first(); if (await cb.isVisible({ timeout: 200 }).catch(() => false)) { await cb.click({ timeout: 1500 }).catch(() => {}); return true; } } catch { /* ignore */ } return false; } } function lower(h: Record<string, string>): Record<string, string> { const out: Record<string, string> = {}; for (const [k, v] of Object.entries(h)) out[k.toLowerCase()] = v; return out; } let _pool: BrowserPool | null = null; export function getBrowserPool(opts?: BrowserPoolOptions): BrowserPool { if (!_pool) _pool = new BrowserPool(opts); return _pool; } export async function closeBrowserPool(): Promise<void> { await _pool?.close(); _pool = null; }