SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
10.0 KB · 234 lines typescript
Raw Blame History
1/**2 * Captcha solving via 2captcha (https://2captcha.com, JSON API v2).3 *4 * Used for Cloudflare Turnstile — both the standalone widget (`data-sitekey`) and the managed5 * challenge page ("Just a moment…"), where the `turnstile.render` call must be intercepted to grab6 * `sitekey`, `action`, `cData` and `chlPageData`, and the returned token handed to the widget's7 * callback. (The solver also returns the User-Agent it used; changing the page's UA to match makes8 * Cloudflare reject the token — keep the browser's own UA.)9 */10export interface TurnstileParams {11  sitekey: string;12  action?: string | null;13  cData?: string | null;14  chlPageData?: string | null;15  /** Whether a JS callback was captured (challenge page / explicit render). */16  hasCallback: boolean;17}1819export interface TurnstileSolution {20  token: string;21  userAgent: string | null;22  taskId: string;23  ms: number;24  costUsd: number;25}2627export interface CaptchaSolverOptions {28  apiKey: string;29  endpoint?: string;30  /** Overall budget for a solve (default 110 s). */31  timeoutMs?: number;32  /** First poll delay / subsequent poll interval (2captcha recommends ~5 s then 3 s). */33  firstPollMs?: number;34  pollMs?: number;35  /** Price per Turnstile solve, for cost accounting (2captcha lists ~$1.45 / 1000). */36  pricePerSolveUsd?: number;37  fetch?: typeof globalThis.fetch;38  log?: { info: (m: string) => void; warn: (m: string) => void };39}4041export class CaptchaSolverError extends Error {42  constructor(43    readonly code: string,44    message: string,45  ) {46    super(message);47    this.name = "CaptchaSolverError";48  }49}5051interface TaskResult {52  errorId: number;53  errorCode?: string;54  errorDescription?: string;55  status?: "processing" | "ready";56  solution?: { token?: string; userAgent?: string };57  taskId?: number | string;58  balance?: number;59}6061export class TwoCaptchaSolver {62  readonly provider = "2captcha" as const;63  private readonly opts: Required<Omit<CaptchaSolverOptions, "log">> & { log: NonNullable<CaptchaSolverOptions["log"]> };64  private stats = { requested: 0, solved: 0, failed: 0, spentUsd: 0, lastError: null as string | null };6566  constructor(opts: CaptchaSolverOptions) {67    if (!opts.apiKey) throw new Error("2captcha: apiKey is required");68    this.opts = {69      apiKey: opts.apiKey,70      endpoint: (opts.endpoint ?? "https://api.2captcha.com").replace(/\/$/, ""),71      timeoutMs: opts.timeoutMs ?? 110_000,72      firstPollMs: opts.firstPollMs ?? 5000,73      pollMs: opts.pollMs ?? 3000,74      pricePerSolveUsd: opts.pricePerSolveUsd ?? 0.00145,75      fetch: opts.fetch ?? globalThis.fetch.bind(globalThis),76      log: opts.log ?? { info: (m) => console.log("[2captcha]", m), warn: (m) => console.warn("[2captcha]", m) },77    };78  }7980  status() {81    return { provider: this.provider, ...this.stats };82  }8384  private async call(path: string, body: Record<string, unknown>, timeoutMs = 20_000): Promise<TaskResult> {85    const ac = new AbortController();86    const t = setTimeout(() => ac.abort(), timeoutMs);87    try {88      const res = await this.opts.fetch(`${this.opts.endpoint}${path}`, {89        method: "POST",90        headers: { "content-type": "application/json", "user-agent": "fetcha/0.2 (+https://www.fetcha.co)" },91        body: JSON.stringify({ clientKey: this.opts.apiKey, ...body }),92        signal: ac.signal,93      });94      const data = (await res.json().catch(() => ({}))) as TaskResult;95      if (!res.ok) throw new CaptchaSolverError("HTTP_ERROR", `2captcha HTTP ${res.status}`);96      return data;97    } finally {98      clearTimeout(t);99    }100  }101102  async balance(): Promise<number> {103    const r = await this.call("/getBalance", {});104    if (r.errorId) throw new CaptchaSolverError(r.errorCode ?? "ERROR", r.errorDescription ?? "2captcha error");105    return Number(r.balance ?? 0);106  }107108  /**109   * Solve a Turnstile challenge. `deadline` (performance.now() based) bounds the wait so that the110   * caller's request timeout is honoured.111   */112  async solveTurnstile(input: { websiteURL: string; params: TurnstileParams; deadline?: number }): Promise<TurnstileSolution> {113    const t0 = performance.now();114    const hardDeadline = Math.min(t0 + this.opts.timeoutMs, input.deadline ?? Infinity);115    this.stats.requested++;116    const task: Record<string, unknown> = { type: "TurnstileTaskProxyless", websiteURL: input.websiteURL, websiteKey: input.params.sitekey };117    if (input.params.action) task.action = input.params.action;118    if (input.params.cData) task.data = input.params.cData;119    if (input.params.chlPageData) task.pagedata = input.params.chlPageData;120    let created: TaskResult;121    try {122      created = await this.call("/createTask", { task });123    } catch (e) {124      this.fail((e as Error).message);125      throw e instanceof CaptchaSolverError ? e : new CaptchaSolverError("NETWORK", (e as Error).message);126    }127    if (created.errorId || !created.taskId) {128      const code = created.errorCode ?? "CREATE_FAILED";129      this.fail(`${code}: ${created.errorDescription ?? ""}`);130      throw new CaptchaSolverError(code, created.errorDescription ?? "2captcha refused the task");131    }132    const taskId = String(created.taskId);133    let wait = this.opts.firstPollMs;134    for (;;) {135      const remaining = hardDeadline - performance.now();136      if (remaining <= 0) {137        this.fail("timeout");138        throw new CaptchaSolverError("TIMEOUT", "2captcha did not solve the challenge in time");139      }140      await new Promise((r) => setTimeout(r, Math.min(wait, remaining)));141      wait = this.opts.pollMs;142      let r: TaskResult;143      try {144        r = await this.call("/getTaskResult", { taskId: created.taskId });145      } catch (e) {146        // transient network error: keep polling147        this.opts.log.warn(`poll error: ${(e as Error).message}`);148        continue;149      }150      if (r.errorId) {151        const code = r.errorCode ?? "SOLVE_FAILED";152        this.fail(`${code}: ${r.errorDescription ?? ""}`);153        throw new CaptchaSolverError(code, r.errorDescription ?? "2captcha could not solve the challenge");154      }155      if (r.status === "ready" && r.solution?.token) {156        const ms = Math.round(performance.now() - t0);157        this.stats.solved++;158        this.stats.spentUsd += this.opts.pricePerSolveUsd;159        this.opts.log.info(`turnstile solved in ${ms} ms (task ${taskId})`);160        return { token: r.solution.token, userAgent: r.solution.userAgent ?? null, taskId, ms, costUsd: this.opts.pricePerSolveUsd };161      }162    }163  }164165  private fail(msg: string) {166    this.stats.failed++;167    this.stats.lastError = msg.slice(0, 200);168    this.opts.log.warn(`solve failed: ${msg}`);169  }170}171172/**173 * Init script: intercept `turnstile.render` (challenge pages and explicit widgets) to capture the174 * parameters 2captcha needs, and keep the callback so the token can be injected later. Stored on a175 * non-enumerable, obscurely named window property.176 */177export const TURNSTILE_HOOK_KEY = "__fx_ts_" + "9c1";178179export function turnstileHookScript(): string {180  return `(() => {181  const KEY = ${JSON.stringify(TURNSTILE_HOOK_KEY)};182  const store = { params: null, callback: null, rendered: 0, hooked: [] };183  try { Object.defineProperty(window, KEY, { value: store, enumerable: false, configurable: true, writable: false }); } catch { window[KEY] = store; }184  const patchTs = (ts) => {185    if (!ts || typeof ts !== 'object' || ts.__fx) return false;186    const origRender = ts.render;187    const patched = function (container, params) {188      try {189        const p = params || {};190        store.rendered++;191        store.params = { sitekey: p.sitekey || null, action: p.action || null, cData: p.cData || null, chlPageData: p.chlPageData || null, hasCallback: typeof p.callback === 'function' };192        if (typeof p.callback === 'function') store.callback = p.callback;193      } catch {}194      // Challenge pages: do NOT render the real widget (its own attempt would consume the challenge195      // data we are about to solve out-of-band); return a fake widget id instead (2captcha's guidance).196      if (store.params && store.params.chlPageData) return 'fx-' + Math.random().toString(36).slice(2, 10);197      return typeof origRender === 'function' ? origRender.apply(this, arguments) : undefined;198    };199    try { Object.defineProperty(patched, 'toString', { value: () => 'function render() { [native code] }' }); } catch {}200    try { ts.render = patched; Object.defineProperty(ts, '__fx', { value: true, enumerable: false, configurable: true }); } catch { return false; }201    return true;202  };203  // 1) Challenge pages load api.js with ?onload=<name>&render=explicit and call turnstile.render from that204  //    callback. Wrapping the callback catches the turnstile object at the exact moment it exists.205  const hookOnload = (name) => {206    if (!name || store.hooked.includes(name)) return;207    store.hooked.push(name);208    let cur = window[name];209    const wrap = (fn) => function () {210      try { patchTs(arguments[0] && typeof arguments[0].render === 'function' ? arguments[0] : window.turnstile); } catch {}211      return fn.apply(this, arguments);212    };213    try {214      Object.defineProperty(window, name, { configurable: true, enumerable: true, get() { return cur; }, set(v) { cur = typeof v === 'function' ? wrap(v) : v; } });215      if (typeof cur === 'function') cur = wrap(cur);216    } catch {}217  };218  const onloadName = (src) => { const m = /[?&]onload=([A-Za-z0-9_$]+)/.exec(src || ''); return m ? m[1] : null; };219  const scan = (root) => { try { for (const el of root.querySelectorAll('script[src*="turnstile"]')) hookOnload(onloadName(el.src)); } catch {} };220  try {221    new MutationObserver((muts) => {222      for (const m of muts) for (const n of m.addedNodes) {223        if (n.nodeName === 'SCRIPT' && n.src && /turnstile/.test(n.src)) hookOnload(onloadName(n.src));224        else if (n.querySelectorAll) scan(n);225      }226    }).observe(document, { childList: true, subtree: true });227  } catch {}228  document.addEventListener('DOMContentLoaded', () => scan(document));229  // 2) Standalone widgets / other pages: poll for window.turnstile (api.js sets it on load).230  let n = 0;231  const iv = setInterval(() => { if (patchTs(window.turnstile) || ++n > 1200) clearInterval(iv); }, 25);232})();`;233}234