/** * Captcha solving via 2captcha (https://2captcha.com, JSON API v2). * * Used for Cloudflare Turnstile — both the standalone widget (`data-sitekey`) and the managed * challenge page ("Just a moment…"), where the `turnstile.render` call must be intercepted to grab * `sitekey`, `action`, `cData` and `chlPageData`, and the returned token handed to the widget's * callback. (The solver also returns the User-Agent it used; changing the page's UA to match makes * Cloudflare reject the token — keep the browser's own UA.) */ export interface TurnstileParams { sitekey: string; action?: string | null; cData?: string | null; chlPageData?: string | null; /** Whether a JS callback was captured (challenge page / explicit render). */ hasCallback: boolean; } export interface TurnstileSolution { token: string; userAgent: string | null; taskId: string; ms: number; costUsd: number; } export interface CaptchaSolverOptions { apiKey: string; endpoint?: string; /** Overall budget for a solve (default 110 s). */ timeoutMs?: number; /** First poll delay / subsequent poll interval (2captcha recommends ~5 s then 3 s). */ firstPollMs?: number; pollMs?: number; /** Price per Turnstile solve, for cost accounting (2captcha lists ~$1.45 / 1000). */ pricePerSolveUsd?: number; fetch?: typeof globalThis.fetch; log?: { info: (m: string) => void; warn: (m: string) => void }; } export class CaptchaSolverError extends Error { constructor( readonly code: string, message: string, ) { super(message); this.name = "CaptchaSolverError"; } } interface TaskResult { errorId: number; errorCode?: string; errorDescription?: string; status?: "processing" | "ready"; solution?: { token?: string; userAgent?: string }; taskId?: number | string; balance?: number; } export class TwoCaptchaSolver { readonly provider = "2captcha" as const; private readonly opts: Required> & { log: NonNullable }; private stats = { requested: 0, solved: 0, failed: 0, spentUsd: 0, lastError: null as string | null }; constructor(opts: CaptchaSolverOptions) { if (!opts.apiKey) throw new Error("2captcha: apiKey is required"); this.opts = { apiKey: opts.apiKey, endpoint: (opts.endpoint ?? "https://api.2captcha.com").replace(/\/$/, ""), timeoutMs: opts.timeoutMs ?? 110_000, firstPollMs: opts.firstPollMs ?? 5000, pollMs: opts.pollMs ?? 3000, pricePerSolveUsd: opts.pricePerSolveUsd ?? 0.00145, fetch: opts.fetch ?? globalThis.fetch.bind(globalThis), log: opts.log ?? { info: (m) => console.log("[2captcha]", m), warn: (m) => console.warn("[2captcha]", m) }, }; } status() { return { provider: this.provider, ...this.stats }; } private async call(path: string, body: Record, timeoutMs = 20_000): Promise { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), timeoutMs); try { const res = await this.opts.fetch(`${this.opts.endpoint}${path}`, { method: "POST", headers: { "content-type": "application/json", "user-agent": "fetcha/0.2 (+https://www.fetcha.co)" }, body: JSON.stringify({ clientKey: this.opts.apiKey, ...body }), signal: ac.signal, }); const data = (await res.json().catch(() => ({}))) as TaskResult; if (!res.ok) throw new CaptchaSolverError("HTTP_ERROR", `2captcha HTTP ${res.status}`); return data; } finally { clearTimeout(t); } } async balance(): Promise { const r = await this.call("/getBalance", {}); if (r.errorId) throw new CaptchaSolverError(r.errorCode ?? "ERROR", r.errorDescription ?? "2captcha error"); return Number(r.balance ?? 0); } /** * Solve a Turnstile challenge. `deadline` (performance.now() based) bounds the wait so that the * caller's request timeout is honoured. */ async solveTurnstile(input: { websiteURL: string; params: TurnstileParams; deadline?: number }): Promise { const t0 = performance.now(); const hardDeadline = Math.min(t0 + this.opts.timeoutMs, input.deadline ?? Infinity); this.stats.requested++; const task: Record = { type: "TurnstileTaskProxyless", websiteURL: input.websiteURL, websiteKey: input.params.sitekey }; if (input.params.action) task.action = input.params.action; if (input.params.cData) task.data = input.params.cData; if (input.params.chlPageData) task.pagedata = input.params.chlPageData; let created: TaskResult; try { created = await this.call("/createTask", { task }); } catch (e) { this.fail((e as Error).message); throw e instanceof CaptchaSolverError ? e : new CaptchaSolverError("NETWORK", (e as Error).message); } if (created.errorId || !created.taskId) { const code = created.errorCode ?? "CREATE_FAILED"; this.fail(`${code}: ${created.errorDescription ?? ""}`); throw new CaptchaSolverError(code, created.errorDescription ?? "2captcha refused the task"); } const taskId = String(created.taskId); let wait = this.opts.firstPollMs; for (;;) { const remaining = hardDeadline - performance.now(); if (remaining <= 0) { this.fail("timeout"); throw new CaptchaSolverError("TIMEOUT", "2captcha did not solve the challenge in time"); } await new Promise((r) => setTimeout(r, Math.min(wait, remaining))); wait = this.opts.pollMs; let r: TaskResult; try { r = await this.call("/getTaskResult", { taskId: created.taskId }); } catch (e) { // transient network error: keep polling this.opts.log.warn(`poll error: ${(e as Error).message}`); continue; } if (r.errorId) { const code = r.errorCode ?? "SOLVE_FAILED"; this.fail(`${code}: ${r.errorDescription ?? ""}`); throw new CaptchaSolverError(code, r.errorDescription ?? "2captcha could not solve the challenge"); } if (r.status === "ready" && r.solution?.token) { const ms = Math.round(performance.now() - t0); this.stats.solved++; this.stats.spentUsd += this.opts.pricePerSolveUsd; this.opts.log.info(`turnstile solved in ${ms} ms (task ${taskId})`); return { token: r.solution.token, userAgent: r.solution.userAgent ?? null, taskId, ms, costUsd: this.opts.pricePerSolveUsd }; } } } private fail(msg: string) { this.stats.failed++; this.stats.lastError = msg.slice(0, 200); this.opts.log.warn(`solve failed: ${msg}`); } } /** * Init script: intercept `turnstile.render` (challenge pages and explicit widgets) to capture the * parameters 2captcha needs, and keep the callback so the token can be injected later. Stored on a * non-enumerable, obscurely named window property. */ export const TURNSTILE_HOOK_KEY = "__fx_ts_" + "9c1"; export function turnstileHookScript(): string { return `(() => { const KEY = ${JSON.stringify(TURNSTILE_HOOK_KEY)}; const store = { params: null, callback: null, rendered: 0, hooked: [] }; try { Object.defineProperty(window, KEY, { value: store, enumerable: false, configurable: true, writable: false }); } catch { window[KEY] = store; } const patchTs = (ts) => { if (!ts || typeof ts !== 'object' || ts.__fx) return false; const origRender = ts.render; const patched = function (container, params) { try { const p = params || {}; store.rendered++; store.params = { sitekey: p.sitekey || null, action: p.action || null, cData: p.cData || null, chlPageData: p.chlPageData || null, hasCallback: typeof p.callback === 'function' }; if (typeof p.callback === 'function') store.callback = p.callback; } catch {} // Challenge pages: do NOT render the real widget (its own attempt would consume the challenge // data we are about to solve out-of-band); return a fake widget id instead (2captcha's guidance). if (store.params && store.params.chlPageData) return 'fx-' + Math.random().toString(36).slice(2, 10); return typeof origRender === 'function' ? origRender.apply(this, arguments) : undefined; }; try { Object.defineProperty(patched, 'toString', { value: () => 'function render() { [native code] }' }); } catch {} try { ts.render = patched; Object.defineProperty(ts, '__fx', { value: true, enumerable: false, configurable: true }); } catch { return false; } return true; }; // 1) Challenge pages load api.js with ?onload=&render=explicit and call turnstile.render from that // callback. Wrapping the callback catches the turnstile object at the exact moment it exists. const hookOnload = (name) => { if (!name || store.hooked.includes(name)) return; store.hooked.push(name); let cur = window[name]; const wrap = (fn) => function () { try { patchTs(arguments[0] && typeof arguments[0].render === 'function' ? arguments[0] : window.turnstile); } catch {} return fn.apply(this, arguments); }; try { Object.defineProperty(window, name, { configurable: true, enumerable: true, get() { return cur; }, set(v) { cur = typeof v === 'function' ? wrap(v) : v; } }); if (typeof cur === 'function') cur = wrap(cur); } catch {} }; const onloadName = (src) => { const m = /[?&]onload=([A-Za-z0-9_$]+)/.exec(src || ''); return m ? m[1] : null; }; const scan = (root) => { try { for (const el of root.querySelectorAll('script[src*="turnstile"]')) hookOnload(onloadName(el.src)); } catch {} }; try { new MutationObserver((muts) => { for (const m of muts) for (const n of m.addedNodes) { if (n.nodeName === 'SCRIPT' && n.src && /turnstile/.test(n.src)) hookOnload(onloadName(n.src)); else if (n.querySelectorAll) scan(n); } }).observe(document, { childList: true, subtree: true }); } catch {} document.addEventListener('DOMContentLoaded', () => scan(document)); // 2) Standalone widgets / other pages: poll for window.turnstile (api.js sets it on load). let n = 0; const iv = setInterval(() => { if (patchTs(window.turnstile) || ++n > 1200) clearInterval(iv); }, 25); })();`; }