先说结论:同一站点既有 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.php 与 res.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,先拿两个页面各跑一遍。
相关指南: