CaptchaAI 明明返回了 token,页面却始终不放行?这几乎是每个接入 Cloudflare Turnstile 的开发者都撞过的墙。好消息是:Turnstile 的失败很少是随机的,绝大多数都能归到三个阶段——提交阶段(你发给 API 的请求被拒)、轮询阶段(取结果时超时或报错)、验证阶段(API 返回了合法 token,但目标页面拒绝它)。
而真正拖垮 Turnstile 集成的,通常就三件事:
- pageurl 不精确 —— 在 Cloudflare 全屏验证页上尤其致命,上下文校验更严
- sitekey 取错了 —— 从错误的元素、或另一个组件实例上抓到的值
- token 走错了应用路径 —— 页面要的是
cf-turnstile-response、回调,或者两者都要
CaptchaAI 识别 Turnstile 通常在 10 秒内完成,成功率稳定。所以当集成失败时,问题几乎总是出在你发送的参数,或你如何把返回的 token 写回页面。把下面三个阶段逐一对照,就能快速定位到底卡在哪一环。
按症状定位:先看你卡在哪一步
不想从头读到尾,就先用这张表对号入座——找到最接近你现状的那一行,直接跳到对应章节:
| 你观察到的现象 | 卡在哪个阶段 | 跳到 |
|---|---|---|
提交任务时接口直接返回 ERROR_* |
提交阶段 | 「提交阶段错误码」 |
一直返回 CAPCHA_NOT_READY 或轮询报错 |
轮询阶段 | 「轮询阶段错误码」 |
| 拿到了 token,页面却不认 | 验证阶段 | 「验证阶段排查」 |
| 分不清是 Turnstile 还是全屏拦截 | 定位错方向 | 「先分清 Cloudflare 产品」 |
三个阶段互相独立:提交阶段的报错不会牵连轮询,验证阶段的失败也和 API 无关。先确认阶段,再查具体原因,能省掉一大半无用功。
第一步:先分清你面对的是哪个 Cloudflare 产品
排查前先确认一件事:你遇到的到底是页面内嵌的 Turnstile 组件,还是整页拦你的 Cloudflare 全屏验证(Cloudflare Challenge)?两者都是 Cloudflare 的产品,长得像,但用的 API method 不同、排查思路也完全不同——方向搞错,后面每一步都是白费。
| 判断信号 | Turnstile | Cloudflare 验证流程 |
|---|---|---|
| 你看到的 | 页面内嵌的组件(复选框或不可见) | 全屏 Cloudflare 验证界面 |
| CaptchaAI 返回什么 | 一个注入表单的 token | 一个 qa_session_cookie cookie |
| API method | turnstile |
cloudflare_challenge |
| 需要代理吗 | 可选 | 需要(必填) |
一眼分辨的方法:
- 页面主体内容照常显示、只在表单某处出现一个小控件 —— Turnstile
- 整页被一屏「正在验证你是否是真人」拦住、内容全部加载不出来 —— Cloudflare 全屏验证
如果你面对的是全屏 Cloudflare 验证(而非内嵌组件),要改用 Cloudflare 验证流程求解器,它返回 qa_session_cookie cookie,并且必须配代理。本文接下来只讲内嵌 Turnstile 组件的排查——确认是它,就继续往下。
Turnstile 为什么和别的验证码不一样
在逐条看错误码之前,先记住 Turnstile 区别于其他验证码类型的三个特点——很多排查思路都由它们而来。
1. pageurl 必须精确到路径
Turnstile 的 token 和页面上下文强绑定。在 Cloudflare 全屏验证页上,只要 URL 有一点出入(哪怕只是路径不同),token 就会被判为无效。
2. token 有两条应用路径
返回的 token 可以用两种方式写回页面,用错了会静默失败:
| 方式 | 适用场景 |
|---|---|
隐藏字段 —— 写入 cf-turnstile-response(有时还有 g-recaptcha-response) |
页面用的是带隐藏输入的标准表单 |
回调函数 —— 调用 turnstile.render() 或 data-callback 里定义的函数 |
页面用程序化校验,而不是表单提交 |
3. token 是一次性的
一个 Turnstile token 只能校验一次。如果自动化脚本不小心提交了两遍,或存在竞态条件,第二次一定失败。
顺带一提:Turnstile 由 Cloudflare 托管,不依赖 Google 脚本,因此在国内网络下比 reCAPTCHA 更容易正常加载——不少面向海外站点的采集或 QA 项目,接触到的第一个 Cloudflare 验证往往就是它。
提交阶段错误码(in.php)
向 https://ocr.captchaai.com/in.php 提交任务、请求还没被接受时,会遇到这些错误。大部分是账户或参数问题,一张表就能对号入座:
| 错误码 | 原因 | 处理方式 |
|---|---|---|
ERROR_WRONG_USER_KEY |
API Key 格式不对(正确长度为 32 个字符) | 到 captchaai.com/api.php 核对密钥 |
ERROR_KEY_DOES_NOT_EXIST |
密钥格式正确,但没关联到有效账户 | 打开控制台,确认账户已激活、密钥无误 |
ERROR_ZERO_BALANCE |
当前套餐没有空闲线程 | 等线程释放、降低并发,或升级套餐(见下方说明) |
| HTML 或 500 / 502 响应 | 服务端临时错误 | 等 5–10 秒再重试 |
关于 ERROR_ZERO_BALANCE 有个常见误会:CaptchaAI 按并发线程计费,这个报错指的是线程被占满,而不是账户余额为零——两者很容易混淆。账户明明还有钱却报这个错,八成是并发开太高,把线程占满了。
下面两个错误码需要单独展开,因为它们和参数格式直接相关。
ERROR_PAGEURL
缺少 pageurl 参数时报这个错。补上完整 URL——协议、域名、路径都要有:
pageurl=https://staging.example.com/qa-login
ERROR_BAD_PARAMETERS
必填参数缺失或格式错误时报这个错。Turnstile 的必填参数如下:
| 参数 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
key |
字符串 | 是 | 你的 CaptchaAI API Key |
method |
字符串 | 是 | 必须为 turnstile |
sitekey |
字符串 | 是 | Turnstile 组件的 sitekey |
pageurl |
字符串 | 是 | 完整页面 URL |
可选、但常用的参数:
| 参数 | 类型 | 说明 |
|---|---|---|
action |
字符串 | data-action 或 turnstile.render() 中 action 参数的值 |
proxy |
字符串 | 格式:login:password@IP:PORT |
proxytype |
字符串 | HTTP、HTTPS、SOCKS4、SOCKS5 |
逐一核对必填字段是否齐全、类型是否正确,通常就能消掉这个报错。关于 proxy 和 proxytype 什么时候要带上:
- 独立的内嵌 Turnstile 组件:一般不用,留空即可
- 组件挂在有额外风控的页面、或你需要固定出口 IP 做 QA 复现:按上表格式补上
如何正确提取 Turnstile sitekey
sitekey 是最常被填错的参数。按从易到难,有三种取值方式。
方式 1:data-sitekey 属性
最常见,直接从组件的 DOM 属性里读:
<div class="cf-turnstile" data-sitekey="0x4AAAAAAAB1example"></div>
方式 2:turnstile.render() 调用
如果页面用脚本渲染组件,sitekey 就在 render() 的参数里:
turnstile.render('#captcha-container', {
sitekey: '0x4AAAAAAAB1example',
callback: function(token) {
document.getElementById('cf-turnstile-response').value = token;
}
});
方式 3:拦截渲染调用(进阶)
如果 sitekey 是运行时动态注入的,可以在组件初始化前重新定义 turnstile.render,把参数截下来:
// Inject this before the Turnstile script loads
const originalRender = window.turnstile.render;
window.turnstile.render = function(container, params) {
console.log('Sitekey:', params.sitekey);
console.log('Action:', params.action);
return originalRender.call(this, container, params);
};
轮询阶段错误码(res.php)
轮询 https://ocr.captchaai.com/res.php 取结果时,会遇到这些返回值。除了 ERROR_EMPTY_ACTION(下面单独讲),其余都能照表处理:
| 返回值 | 含义 / 原因 | 处理方式 |
|---|---|---|
CAPCHA_NOT_READY |
不是错误,识别还在进行中(通常不到 10 秒) | 等 5 秒再轮询一次 |
ERROR_WRONG_ID_FORMAT |
验证码 ID 里混入了非数字字符 | 原样使用 in.php 返回的 ID,不要改动 |
ERROR_WRONG_CAPTCHA_ID |
ID 和任何已提交的任务都对不上 | 确认轮询的是提交响应里返回的那个 ID |
ERROR_CAPTCHA_UNSOLVABLE |
识别失败:可能 sitekey 取错,或页面配置暂不支持 | 核对 sitekey,刷新请求后重试 |
ERROR_INTERNAL_SERVER_ERROR |
服务端问题 | 等 10 秒再重试 |
CAPCHA_NOT_READY 是最常见的返回值,它根本不是报错——只是识别还没完成。别把它当异常抛出,按固定间隔继续轮询即可。
ERROR_EMPTY_ACTION
轮询请求里缺少 action 参数时报这个错。每次轮询都带上 action=get:
https://ocr.captchaai.com/res.php?key=YOUR_KEY&action=get&id=CAPTCHA_ID&json=1
提示: 轮询 Turnstile 结果时建议加上
json=1。带上它,接口返回结构化的{"status": 1, "request": "<token>"},脚本解析更省事;不加则返回OK|<token>这样的纯文本。两种都能用,挑你解析起来顺手的那种即可。
token 拿到了,页面却拒绝:验证阶段排查
这一类最难查,因为 API 明明成功返回了 token,目标页面却不认。它不会给你错误码,只会静默失败或跳回原页,所以要按下面四种情况逐一排除。
情况 1:token 写进了错误的字段
现象: 表单提交了,但页面报校验错误、或直接刷新。
Turnstile 页面可能在不同字段里等这个 token:
cf-turnstile-response—— Turnstile 主隐藏输入g-recaptcha-response—— 部分页面拿它作兜底
解决: 两个字段都检查一遍。浏览器自动化里可以同时写入:
# Selenium — inject into both fields for safety
driver.execute_script("""
var cfField = document.querySelector('[name="cf-turnstile-response"]');
var gField = document.querySelector('[name="g-recaptcha-response"]');
if (cfField) cfField.value = arguments[0];
if (gField) gField.value = arguments[0];
""", token)
情况 2:回调没被触发
现象: token 已经在字段里了,表单还是不让提交。
原因: 页面用的是回调函数,而不是(或者不只是)隐藏字段。回调往往还负责别的逻辑,比如激活提交按钮、或发起 AJAX 请求。
解决: 找到并手动调用回调:
// Check data-callback attribute
const callbackName = document.querySelector('.cf-turnstile').getAttribute('data-callback');
if (callbackName && window[callbackName]) {
window[callbackName](token);
}
// Or if it was passed in turnstile.render()
// You may need to intercept the render call to capture it
情况 3:pageurl 上下文对不上
现象: sitekey 正确、token 也是新解出来的,却仍被拒。
原因: API 请求里用的 pageurl 和页面真实上下文不一致。这在以下场景尤其常见:
- Cloudflare 全屏验证页 —— URL 里可能带着关键的查询参数或路径片段
- 单页应用(SPA) —— 地址栏看到的 URL,未必是加载 Turnstile 组件的那个 URL
解决: 用 DevTools 的 Network 面板找到 Turnstile 组件实际加载的 URL,拿它当 pageurl。
情况 4:token 被重复使用
现象: 第一次识别有效,之后就失败。
原因: Turnstile token 是一次性的。一旦被 Cloudflare 服务器校验过,就立即失效。
解决: 每次提交表单都重新请求一次识别,不要缓存或复用 token。
验证阶段这四种情况没有统一的错误码,只能靠现象反推,这里汇总成一张速查表:
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 表单提交后报校验错误或刷新 | token 写进了错误字段 | cf-turnstile-response 与 g-recaptcha-response 都写一遍 |
| token 已在字段里,仍无法提交 | 回调没被触发 | 读 data-callback,手动调用回调函数 |
| sitekey、token 都对,仍被拒 | pageurl 上下文对不上 | 用组件实际加载的 URL 当 pageurl |
| 首次有效、之后失败 | token 被复用 | 每次提交都请求新 token |
Python 完整示例
import time
import requests
API_KEY = "YOUR_CAPTCHAAI_API_KEY"
SITEKEY = "0x4AAAAAAAB1example"
PAGE_URL = "https://staging.example.com/qa-login"
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"
def solve_turnstile(api_key, sitekey, pageurl):
"""Submit a Turnstile challenge and return the solved token."""
# Submit
submit_resp = requests.post(
SUBMIT_URL,
data={
"key": api_key,
"method": "turnstile",
"sitekey": sitekey,
"pageurl": pageurl,
"json": 1,
},
timeout=30,
)
submit_resp.raise_for_status()
submit_data = submit_resp.json()
if submit_data.get("status") != 1:
raise RuntimeError(f"Submit failed: {submit_data}")
captcha_id = submit_data["request"]
print(f"Task created — captcha ID: {captcha_id}")
# Wait before first poll (Turnstile is fast — 10 seconds is usually enough)
time.sleep(10)
# Poll for result
for _ in range(60):
result_resp = requests.get(
RESULT_URL,
params={
"key": api_key,
"action": "get",
"id": captcha_id,
"json": 1,
},
timeout=30,
)
result_resp.raise_for_status()
result_data = result_resp.json()
if result_data.get("request") == "CAPCHA_NOT_READY":
time.sleep(5)
continue
if result_data.get("status") == 1:
return result_data["request"]
raise RuntimeError(f"Polling error: {result_data}")
raise TimeoutError("Turnstile solve timed out")
# Usage
token = solve_turnstile(API_KEY, SITEKEY, PAGE_URL)
print(f"Solved token: {token[:80]}...")
# Inject into cf-turnstile-response and/or g-recaptcha-response
# Then submit the form
Node.js 完整示例
const API_KEY = "YOUR_CAPTCHAAI_API_KEY";
const SITEKEY = "0x4AAAAAAAB1example";
const PAGE_URL = "https://staging.example.com/qa-login";
const SUBMIT_URL = "https://ocr.captchaai.com/in.php";
const RESULT_URL = "https://ocr.captchaai.com/res.php";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function solveTurnstile(apiKey, sitekey, pageurl) {
// Submit
const submitResp = await fetch(SUBMIT_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
key: apiKey,
method: "turnstile",
sitekey: sitekey,
pageurl: pageurl,
json: "1",
}),
});
const submitData = await submitResp.json();
if (submitData.status !== 1) {
throw new Error(`Submit failed: ${JSON.stringify(submitData)}`);
}
const captchaId = submitData.request;
console.log(`Task created — captcha ID: ${captchaId}`);
// Turnstile is fast — wait 10 seconds before first poll
await sleep(10_000);
// Poll for result
for (let i = 0; i < 60; i++) {
const resultResp = await fetch(
`${RESULT_URL}?${new URLSearchParams({
key: apiKey,
action: "get",
id: captchaId,
json: "1",
})}`
);
const resultData = await resultResp.json();
if (resultData.request === "CAPCHA_NOT_READY") {
await sleep(5_000);
continue;
}
if (resultData.status === 1) {
return resultData.request;
}
throw new Error(`Polling error: ${JSON.stringify(resultData)}`);
}
throw new Error("Turnstile solve timed out");
}
// Usage
solveTurnstile(API_KEY, SITEKEY, PAGE_URL)
.then((token) => {
console.log(`Solved token: ${token.slice(0, 80)}...`);
// Inject into cf-turnstile-response and/or g-recaptcha-response
})
.catch(console.error);
常见问题
Turnstile 的 token 有效期有多久?
Turnstile token 是一次性的,被校验一次即失效;即便没用过,也只有几分钟的时效窗口。所以拿到 token 后要尽快提交表单,别先缓存再慢慢用,更不要跨多次提交复用同一个 token。
sitekey 是动态加载的,怎么才能拿到?
在组件初始化前重新定义 window.turnstile.render,把传入的 params.sitekey 打印或截存下来(见上文“方式 3”)。这样即便 sitekey 由脚本在运行时注入,也能在真正渲染前捕获到。
轮询时到底要不要加 json=1?
建议加。带 json=1 时接口返回结构化 JSON,status 和 request 字段一目了然,脚本判断更稳;不加则是 OK|token 这样的纯文本,要自己按 | 切分。两种都能工作,但 JSON 更不容易解析出错。
CaptchaAI 识别一个 Turnstile 大概要多久?
通常不到 10 秒。所以示例代码里首次轮询前先等 10 秒,之后每 5 秒轮询一次,多数任务在头一两次轮询内就能拿到结果。如果长期卡在 CAPCHA_NOT_READY,多半是 sitekey 或 pageurl 有问题,而不是速度问题。
明明账户还有余额,为什么报 ERROR_ZERO_BALANCE?
这个报错针对的是线程,不是余额。CaptchaAI 按并发线程计费,每个套餐有固定线程数(例如 BASIC 是 $15/月、5 个线程)。当正在处理的任务占满了线程,新任务就会收到 ERROR_ZERO_BALANCE。等已有任务完成释放线程、降低并发,或升级到线程更多的套餐即可。
Turnstile 组件是隐形的,页面上根本看不到,还能识别吗?
能。不可见模式的 Turnstile 同样通过 data-sitekey 或 turnstile.render() 暴露 sitekey,取值和提交流程与复选框模式完全一致。区别只在于页面不显示交互控件,token 通常靠回调写回——所以遇到不可见组件时,优先按上文“情况 2”检查回调是否被触发。
轮询多少次没结果就该判定失败?
示例代码里最多轮询 60 次、每次间隔 5 秒,也就是约 5 分钟。正常任务远用不到这么久:首次等 10 秒后,绝大多数在头一两次轮询就返回 token。如果连续十几次仍是 CAPCHA_NOT_READY,与其继续干等,不如中止本次任务、核对 sitekey 与 pageurl 后重新提交,往往比把超时拉得更长更快解决问题。
修复清单
Turnstile 集成失败时,按这个顺序排查:
- 确认产品类型 —— 是内嵌 Turnstile 组件,还是全屏 Cloudflare 验证?method 用错,后面全错
- 核对 sitekey —— 从
data-sitekey或turnstile.render()提取,别从错误的元素上取 - 核对 pageurl —— 用精确到协议和路径的 URL,SPA 尤其要留意
- 确认 token 路径 —— 页面要的是
cf-turnstile-response、g-recaptcha-response,还是回调? - 加上
json=1—— 轮询 Turnstile 结果时用 JSON 响应 - 不复用 token —— 每次提交都请求一次新的识别
先从 CaptchaAI Turnstile 求解器 入手,对照 API 文档 核对参数;如果还想补一补组件的底层机制,可以读 Cloudflare Turnstile 的工作原理。