Troubleshooting

在同一站点上处理 reCAPTCHA v2 和 Cloudflare Turnstile

先说结论:同一站点既有 reCAPTCHA v2 又有 Cloudflare Turnstile 时,别把 method 写死。每次拿到 HTML 先判断控件类型,再决定提交 userrecaptcha 还是 turnstile

写死 method 的脚本失败得很隐蔽:任务照常返回 token,表单却不通过——token 落进了另一种控件的字段。

两种控件的识别特征对照

判断只看三样:容器 class、脚本地址、token 字段名。

提供商 HTML 标记 脚本地址 token 字段
reCAPTCHA v2 class="g-recaptcha" google.com/recaptcha/api.js g-recaptcha-response
Cloudflare Turnstile class="cf-turnstile" challenges.cloudflare.com/turnstile cf-turnstile-response
hCaptcha class="h-captcha" js.hcaptcha.com/1/api.js h-captcha-response

sitekey 都在 data-sitekey 里,只匹配这个属性必然认错:先看类名,再取 sitekey。列出 hCaptcha 是为了把它区分开——CaptchaAI 不支持 hCaptcha 和 FunCaptcha(Arkose Labs),检测到就跳过。

同一个站点为什么会挂两套验证码

场景 实际表现
页面不同,供应商不同 登录页 reCAPTCHA,结账页 Turnstile
供应商灰度对比 同一 URL 随机返回其中一种
迁移没做完 老页面 reCAPTCHA,新页面 Turnstile
加载失败回退 主控件加载不出来,前端降级
按地区下发 美国访客 reCAPTCHA,欧盟访客 Turnstile

一个双站点回归的例子

某跨境电商团队的两套 staging 环境:

  • 国内站登录页:极验滑块
  • 海外站登录页:reCAPTCHA v2
  • 海外站结账页:迁到 Cloudflare 后换成 Turnstile

原脚本按站点写死三条分支,前端一改就得改代码。换成动态检测后,返回什么类型就用什么 method。

成本上不必担心:CaptchaAI 按并发线程计费,套餐内识别次数不限,混用不额外花钱。顺序跑的 QA 脚本用 BASIC($15/月,5 线程)够,上百页面并行采集从 ADVANCE($90/月,50 线程)起步。

Python:检测类型并提交对应任务

职责拆成两半:detect_captcha_type() 认出提供商并返回字段名,solve_captcha() 按 method 组参数。

两者共用 in.phpres.php,只是 reCAPTCHA 传 googlekey、Turnstile 传 sitekey

import requests
import time
import re
from dataclasses import dataclass

API_KEY = "YOUR_API_KEY"
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"


@dataclass
class CaptchaInfo:
    provider: str     # "recaptcha" or "turnstile"
    method: str       # API method name
    sitekey: str
    pageurl: str
    response_field: str  # Form field name for the token


def detect_captcha_type(html, pageurl):
    """
    Detect which CAPTCHA provider is on the page.
    Returns CaptchaInfo or None.
    """
    # Check for Turnstile
    turnstile_match = re.search(
        r'class=["\'][^"\']*cf-turnstile[^"\']*["\'][^>]*data-sitekey=["\']([^"\']+)["\']',
        html,
    )
    if not turnstile_match:
        turnstile_match = re.search(
            r'data-sitekey=["\']([^"\']+)["\'][^>]*class=["\'][^"\']*cf-turnstile',
            html,
        )

    if turnstile_match:
        return CaptchaInfo(
            provider="turnstile",
            method="turnstile",
            sitekey=turnstile_match.group(1),
            pageurl=pageurl,
            response_field="cf-turnstile-response",
        )

    # Check for reCAPTCHA
    recaptcha_match = re.search(
        r'class=["\'][^"\']*g-recaptcha[^"\']*["\'][^>]*data-sitekey=["\']([^"\']+)["\']',
        html,
    )
    if not recaptcha_match:
        recaptcha_match = re.search(
            r'data-sitekey=["\']([^"\']+)["\'][^>]*class=["\'][^"\']*g-recaptcha',
            html,
        )

    # Also check for script-rendered reCAPTCHA
    if not recaptcha_match:
        recaptcha_match = re.search(
            r'grecaptcha\.render\([^,]+,\s*\{[^}]*["\']sitekey["\']\s*:\s*["\']([^"\']+)["\']',
            html,
        )

    if recaptcha_match:
        return CaptchaInfo(
            provider="recaptcha",
            method="userrecaptcha",
            sitekey=recaptcha_match.group(1),
            pageurl=pageurl,
            response_field="g-recaptcha-response",
        )

    return None


def solve_captcha(info):
    """Solve any detected CAPTCHA type via CaptchaAI."""
    params = {
        "key": API_KEY,
        "method": info.method,
        "json": 1,
    }

    if info.method == "userrecaptcha":
        params["googlekey"] = info.sitekey
        params["pageurl"] = info.pageurl
    elif info.method == "turnstile":
        params["sitekey"] = info.sitekey
        params["pageurl"] = info.pageurl

    resp = requests.post(SUBMIT_URL, data=params, timeout=30).json()
    if resp.get("status") != 1:
        raise RuntimeError(f"Submit failed: {resp.get('request')}")

    task_id = resp["request"]
    for _ in range(60):
        time.sleep(5)
        poll = requests.get(RESULT_URL, params={
            "key": API_KEY, "action": "get",
            "id": task_id, "json": 1,
        }, timeout=15).json()

        if poll.get("request") == "CAPCHA_NOT_READY":
            continue
        if poll.get("status") == 1:
            return poll["request"]
        raise RuntimeError(f"Solve failed: {poll.get('request')}")

    raise RuntimeError("Timeout")


def process_page(session, url):
    """Fetch page, detect CAPTCHA type, solve, and return form-ready data."""
    response = session.get(url)
    captcha_info = detect_captcha_type(response.text, url)

    if not captcha_info:
        print(f"No CAPTCHA detected on {url}")
        return None

    print(f"Detected {captcha_info.provider} on {url}")
    print(f"  Sitekey: {captcha_info.sitekey[:30]}...")

    token = solve_captcha(captcha_info)
    print(f"  Solved: {token[:30]}...")

    return {
        "provider": captcha_info.provider,
        "response_field": captcha_info.response_field,
        "token": token,
    }


# Usage: Handle multiple pages with different providers
session = requests.Session()

pages = [
    "https://staging.example.com/qa-login",      # Might have reCAPTCHA
    "https://example.com/checkout",   # Might have Turnstile
]

for url in pages:
    result = process_page(session, url)
    if result:
        form_data = {result["response_field"]: result["token"]}
        # Add other form fields...
        # session.post(url, data=form_data)

三处细节值得记一下:

  • 轮询每 5 秒一次,最多 60 次。
  • CAPCHA_NOT_READY 是排队中,不是错误。
  • token 要写进 response_field 指定的字段。

Node.js 里的动态检测写法

逻辑一致,换成 match()URLSearchParams。脚本渲染的 reCAPTCHA 没有类名标记,末尾留了兜底规则。

const API_KEY = "YOUR_API_KEY";
const SUBMIT_URL = "https://ocr.captchaai.com/in.php";
const RESULT_URL = "https://ocr.captchaai.com/res.php";

function detectCaptchaType(html, pageurl) {
  // Turnstile
  const turnstileMatch = html.match(/cf-turnstile[^>]*data-sitekey=["']([^"']+)["']/);
  if (turnstileMatch) {
    return { provider: "turnstile", method: "turnstile", sitekey: turnstileMatch[1], pageurl, field: "cf-turnstile-response" };
  }

  // reCAPTCHA
  const recaptchaMatch = html.match(/g-recaptcha[^>]*data-sitekey=["']([^"']+)["']/);
  if (recaptchaMatch) {
    return { provider: "recaptcha", method: "userrecaptcha", sitekey: recaptchaMatch[1], pageurl, field: "g-recaptcha-response" };
  }

  // Script-rendered reCAPTCHA
  const scriptMatch = html.match(/sitekey["']\s*:\s*["']([^"']+)["']/);
  if (scriptMatch) {
    return { provider: "recaptcha", method: "userrecaptcha", sitekey: scriptMatch[1], pageurl, field: "g-recaptcha-response" };
  }

  return null;
}

async function solveCaptcha(info) {
  const body = new URLSearchParams({ key: API_KEY, method: info.method, json: "1" });
  if (info.method === "userrecaptcha") { body.set("googlekey", info.sitekey); body.set("pageurl", info.pageurl); }
  else if (info.method === "turnstile") { body.set("sitekey", info.sitekey); body.set("pageurl", info.pageurl); }

  const resp = await (await fetch(SUBMIT_URL, { method: "POST", body })).json();
  if (resp.status !== 1) throw new Error(`Submit: ${resp.request}`);

  const taskId = resp.request;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const url = `${RESULT_URL}?key=${API_KEY}&action=get&id=${taskId}&json=1`;
    const poll = await (await fetch(url)).json();
    if (poll.request === "CAPCHA_NOT_READY") continue;
    if (poll.status === 1) return poll.request;
    throw new Error(`Solve: ${poll.request}`);
  }
  throw new Error("Timeout");
}

async function processPage(url) {
  const response = await fetch(url);
  const html = await response.text();
  const info = detectCaptchaType(html, url);

  if (!info) { console.log(`No CAPTCHA on ${url}`); return null; }
  console.log(`${info.provider} detected on ${url}`);

  const token = await solveCaptcha(info);
  return { provider: info.provider, field: info.field, token };
}

// Usage
const pages = ["https://staging.example.com/qa-login", "https://example.com/checkout"];
for (const url of pages) {
  const result = await processPage(url);
  if (result) {
    console.log(`Solved ${result.provider}: ${result.token.substring(0, 30)}...`);
  }
}

排查:五个最常见的翻车点

现象 根因 处理方式
类型判断错了 正则匹到别的元素 先匹配类名,别只看 data-sitekey
token 拿到但表单被拒 字段名用错 按类型填对应 response 字段
两次访问类型不同 灰度对比或按地区下发 每次请求重新检测
一页上匹到两种 有一个是隐藏占位 只识别可见的那个
脚本渲染的控件检测不到 源码没有类名标记 grecaptcha.render() 调用

常见问题

一页上出现两个 sitekey,该识别哪一个?

看可见性。通常只有一个控件参与提交,另一个是隐藏占位,只对可见且 data-sitekey 非空的那个提交。

检测到 hCaptcha 怎么办?

跳过并记日志:CaptchaAI 不支持 hCaptcha 和 FunCaptcha(Arkose Labs)。CaptchaFox、Friendly Captcha、Lemin 是测试版支持。

token 可以缓存下来下次用吗?

不建议。两种 token 都是一次性凭证,有效期以分钟计,识别完就该立即提交。

用 Selenium 或 Playwright 还需要写正则吗?

不需要,浏览器里用 querySelector 判断更可靠。正则主要用于纯 requests 链路。

相关文章

下一步

把检测函数抽成公共模块——注册获取 CaptchaAI API Key,先拿两个页面各跑一遍。

相关指南:

该文章已禁用评论。