reCAPTCHA invisible 报错,十有八九不是 sitekey 错了——而是 token 拿到手了,回调函数却没被触发,表单原地不动。没有可见组件可点,出错也没有明显反馈,脚本常常静默失败,日志里只剩一堆看不懂的 ERROR_*。
隐形 reCAPTCHA 去掉了复选框,却给自动化流程带来一整套新的失败模式,最常见的是这四类:
- 页面上根本没检测到隐形小部件
- token 注入后没有调用回调函数
- 提交了已经过期的 token
- 明明是隐形挑战,却用了 v2 标准的请求参数
国内团队测国际站点时还会多踩一个网络坑:reCAPTCHA 依赖 Google 的托管脚本,境内访问不稳定,容易误判成检测失败。国内站点更常用 GeeTest(极验)、网易易盾,reCAPTCHA 多见于面向海外用户的产品——先确认脚本能加载,再排查 sitekey、回调这些代码层问题。
本文按诊断优先级把每种报错拆开讲,每种都给出可直接照抄的修复代码。如果你还不清楚隐形 reCAPTCHA 的工作机制,建议先读一下reCAPTCHA Invisible 的工作原理以及解决方法。
报错速查表
先对照下表定位问题:
| 报错 | 原因 | 处理方式 |
|---|---|---|
ERROR_WRONG_GOOGLEKEY |
sitekey 错了,或用了别的域名下的 sitekey | 从隐形小部件的 div 或 grecaptcha.render() 调用里重新提取 sitekey |
ERROR_PAGEURL |
URL 不匹配——发的是父页面 URL,不是小部件 URL | 使用隐形小部件实际加载的确切 URL |
ERROR_CAPTCHA_UNSOLVABLE |
Google 判定该任务无法完成 | 换新代理和 cookie 重试;确认站点没切换到 v3 |
ERROR_BAD_TOKEN_OR_PAGEURL |
token 被目标站点拒绝 | 检查 pageurl 是否完全一致;通过回调注入,而非直接写隐藏字段 |
CAPCHA_NOT_READY |
任务还在处理中 | 每 5 秒轮询一次;隐形挑战一般需要 10-30 秒 |
ERROR_KEY_DOES_NOT_EXIST |
CaptchaAI 的 API Key 无效 | 到 captchaai.com/account 核对 Key |
| token 已拿到,但表单没反应 | token 注入后没调用回调函数 | 找到 data-callback 指定的函数,手动调用它 |
拿不准该看哪一节时,直接查"错误三"——回调没执行是最容易被漏掉、也最值得优先排查的一类。
错误一:页面上压根没检测到隐形 reCAPTCHA
- 没有可见复选框,脚本必须靠
data-size="invisible"主动识别 - 识别不到时,请求通常以表单报错或页面跳转的方式默默失败
如何检测隐形 reCAPTCHA
在 HTML 页面中查找这些模式:
<!-- Pattern 1: div with data-size="invisible" -->
<div class="g-recaptcha" data-sitekey="6LdKlZEU..."
data-size="invisible"
data-callback="onSubmit"></div>
<!-- Pattern 2: button with data-sitekey and invisible size -->
<button class="g-recaptcha"
data-sitekey="6LdKlZEU..."
data-callback="onSubmit"
data-action="submit">Submit</button>
<!-- Pattern 3: programmatic render with size: invisible -->
<script>
grecaptcha.render('submit-btn', {
sitekey: '6LdKlZEU...',
callback: onSubmit,
size: 'invisible'
});
</script>
检测脚本(Python):
import requests
from bs4 import BeautifulSoup
import re
def detect_invisible_recaptcha(url):
resp = requests.get(url)
soup = BeautifulSoup(resp.text, "html.parser")
# Check for data-size="invisible"
widget = soup.find("div", {"data-size": "invisible", "class": "g-recaptcha"})
if widget:
return {
"type": "invisible",
"sitekey": widget.get("data-sitekey"),
"callback": widget.get("data-callback")
}
# Check for programmatic render with invisible
scripts = soup.find_all("script")
for script in scripts:
if script.string and "size" in str(script.string) and "invisible" in str(script.string):
key_match = re.search(r"sitekey['\"]?\s*[:=]\s*['\"]([^'\"]+)", script.string)
if key_match:
return {
"type": "invisible-programmatic",
"sitekey": key_match.group(1),
"callback": "check grecaptcha.render() call"
}
return None
检测脚本(Node.js):
const axios = require("axios");
const cheerio = require("cheerio");
async function detectInvisibleRecaptcha(url) {
const { data } = await axios.get(url);
const $ = cheerio.load(data);
// Check for data-size="invisible"
const widget = $(".g-recaptcha[data-size='invisible']");
if (widget.length) {
return {
type: "invisible",
sitekey: widget.attr("data-sitekey"),
callback: widget.attr("data-callback"),
};
}
// Check script tags for programmatic invisible render
const scriptContent = $("script")
.map((_, el) => $(el).html())
.get()
.join("\n");
if (scriptContent.includes("invisible")) {
const keyMatch = scriptContent.match(/sitekey['"]?\s*[:=]\s*['"]([^'"]+)/);
if (keyMatch) {
return {
type: "invisible-programmatic",
sitekey: keyMatch[1],
callback: "check grecaptcha.render() call",
};
}
}
return null;
}
错误二:sitekey 用错了 — ERROR_WRONG_GOOGLEKEY
- 从同一站点别的页面上的 v2 复选框上抄了 sitekey
- 用了锚点 URL里那个 sitekey,但那是另一个 reCAPTCHA 版本的
- 页面上同时有好几个 reCAPTCHA 小部件,脚本抓到的不是隐形那一个
修复:只取隐形小部件专属的 sitekey
import requests
from bs4 import BeautifulSoup
def get_invisible_sitekey(url):
resp = requests.get(url)
soup = BeautifulSoup(resp.text, "html.parser")
# Priority 1: invisible widget
widget = soup.find(attrs={"data-size": "invisible", "class": "g-recaptcha"})
if widget:
return widget["data-sitekey"]
# Priority 2: any g-recaptcha div (may be invisible without data-size)
widget = soup.find(class_="g-recaptcha")
if widget and widget.get("data-sitekey"):
return widget["data-sitekey"]
return None
sitekey = get_invisible_sitekey("https://staging.example.com/qa-login")
print(f"Sitekey: {sitekey}")
错误三:token 拿到了,表单却毫无反应——回调没执行(开发者最容易踩的头号故障)
- v2 复选框: 把 token 写进
g-recaptcha-response就完事 - 隐形 reCAPTCHA: 还要额外调用一次 JavaScript 回调函数——没触发回调,表单不会往下走
回调这一套流程是怎么跑的
grecaptcha.execute()触发隐形挑战- 解出结果后,Google 会调用
data-callback里指定的那个函数 - 这个回调函数负责提交表单或者发起 API 调用
这也是本文把它排在诊断优先级最前面的原因——很多"莫名其妙"的报错,根源就在这里。
修复:找到回调函数,手动调用它
第一步 — 先确认回调函数叫什么名字:
# From HTML: data-callback="onSubmit"
# From JS: callback: onSubmit
# From grecaptcha.render: second argument with callback property
第二步 — 把 token 写入页面字段,并手动触发回调(Selenium):
from selenium import webdriver
import requests
import time
driver = webdriver.Chrome()
driver.get("https://example.com/form")
# Get sitekey
sitekey = driver.find_element("css selector", ".g-recaptcha").get_attribute("data-sitekey")
callback_name = driver.find_element("css selector", ".g-recaptcha").get_attribute("data-callback")
# Solve with CaptchaAI
task_id = requests.get("https://ocr.captchaai.com/in.php", params={
"key": "YOUR_API_KEY",
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": driver.current_url,
"invisible": 1
}).text.split("|")[1]
# Poll for result
token = None
for _ in range(60):
time.sleep(5)
resp = requests.get("https://ocr.captchaai.com/res.php", params={
"key": "YOUR_API_KEY",
"action": "get",
"id": task_id
}).text
if resp.startswith("OK|"):
token = resp.split("|")[1]
break
# Inject token into the response field
driver.execute_script(
f'document.getElementById("g-recaptcha-response").value = "{token}";'
)
# CRITICAL: Call the callback function
driver.execute_script(f'{callback_name}("{token}");')
第二步 — 把 token 写入页面字段,并手动触发回调(Puppeteer):
const puppeteer = require("puppeteer");
const axios = require("axios");
(async () => {
const browser = await puppeteer.launch({ headless: "new" });
const page = await browser.newPage();
await page.goto("https://example.com/form");
// Get sitekey and callback
const { sitekey, callback } = await page.evaluate(() => {
const el = document.querySelector(".g-recaptcha[data-size='invisible']");
return {
sitekey: el?.getAttribute("data-sitekey"),
callback: el?.getAttribute("data-callback"),
};
});
// Submit to CaptchaAI
const submitResp = await axios.get("https://ocr.captchaai.com/in.php", {
params: {
key: "YOUR_API_KEY",
method: "userrecaptcha",
googlekey: sitekey,
pageurl: page.url(),
invisible: 1,
},
});
const taskId = submitResp.data.split("|")[1];
// Poll for result
let token;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
const result = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: "YOUR_API_KEY", action: "get", id: taskId },
});
if (result.data.startsWith("OK|")) {
token = result.data.split("|")[1];
break;
}
}
// Inject token and fire callback
await page.evaluate(
(tok, cb) => {
document.getElementById("g-recaptcha-response").value = tok;
if (cb && typeof window[cb] === "function") {
window[cb](tok);
}
},
token,
callback,
);
await browser.close();
})();
错误四:请求里漏了 invisible=1 参数
- 必须带上: 请求里必须包含
invisible=1 - 少了会怎样: 求解器把任务当 v2 标准处理,要么报
ERROR_CAPTCHA_UNSOLVABLE,要么给出目标站点不认的 token
错误写法 vs 正确写法
# WRONG — missing invisible=1
params = {
"key": "YOUR_API_KEY",
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url
}
# CORRECT — includes invisible=1
params = {
"key": "YOUR_API_KEY",
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
"invisible": 1 # Required for invisible reCAPTCHA
}
response = requests.get("https://ocr.captchaai.com/in.php", params=params)
错误五:token 还没提交就已经过期了
隐形 reCAPTCHA 的 token 有效期是 120 秒,跟 v2 标准一样,但隐形流程往往在拿到 token 和真正提交之间多了几步额外处理,过期概率也就更高。
常见症状
- token 注入后,表单返回一个说不清原因的错误
- 服务器端
siteverify返回timeout-or-duplicate - token 本身是有效的,只是走到提交这一步花的时间太长了
修复:等真要提交了再去解
只在你确定马上就要提交的那一刻才发起求解请求:
import requests
import time
def solve_invisible_recaptcha(api_key, sitekey, page_url):
# Submit task
resp = requests.get("https://ocr.captchaai.com/in.php", params={
"key": api_key,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
"invisible": 1
})
if not resp.text.startswith("OK|"):
raise Exception(f"Submit failed: {resp.text}")
task_id = resp.text.split("|")[1]
# Poll for result
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": api_key,
"action": "get",
"id": task_id
})
if result.text.startswith("OK|"):
return result.text.split("|")[1]
if result.text != "CAPCHA_NOT_READY":
raise Exception(f"Solve failed: {result.text}")
raise Exception("Solve timed out after 5 minutes")
# Usage: solve JUST before you need to submit
# 1. Navigate to page and prepare form data first
# 2. THEN solve the captcha
# 3. Inject token and submit immediately
token = solve_invisible_recaptcha("YOUR_API_KEY", sitekey, page_url)
# Submit within 120 seconds of receiving the token
错误六:token 被拒 — ERROR_BAD_TOKEN_OR_PAGEURL
| 原因 | 怎么判断 | 处理方式 |
|---|---|---|
pageurl 不对 |
URL 和 sitekey 注册的域名对不上 | 用小部件实际加载时的确切 URL |
| token 用错域名 | 跨域复用了 token | 用正确域名下的 pageurl 重新求解 |
| token 用过了 | 同一个 token 提交了两次 | 每次提交都单独请求一个新解 |
| IP 对不上 | 你的 IP 和求解器出口 IP 不一致 | 加 proxy 参数,匹配会话 IP |
| 漏了隐形标记 | 按 v2 标准解的,但页面其实是隐形挑战 | 解题请求里补上 invisible=1 |
排查用的调试脚本
def debug_invisible_solve(api_key, sitekey, page_url, proxy=None):
"""Run a diagnostic solve with detailed logging."""
print(f"Sitekey: {sitekey}")
print(f"Page URL: {page_url}")
print(f"Proxy: {proxy or 'none'}")
params = {
"key": api_key,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
"invisible": 1
}
if proxy:
params["proxy"] = proxy
params["proxytype"] = "HTTP"
# Submit
resp = requests.get("https://ocr.captchaai.com/in.php", params=params)
print(f"Submit response: {resp.text}")
if not resp.text.startswith("OK|"):
return None
task_id = resp.text.split("|")[1]
print(f"Task ID: {task_id}")
# Poll with timing
start = time.time()
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": api_key, "action": "get", "id": task_id
})
elapsed = time.time() - start
print(f" [{elapsed:.0f}s] {result.text[:50]}")
if result.text.startswith("OK|"):
token = result.text.split("|")[1]
print(f"Token received after {elapsed:.0f}s")
print(f"Token length: {len(token)} characters")
print(f"Token starts with: {token[:30]}...")
return token
if result.text != "CAPCHA_NOT_READY":
print(f"FAILED: {result.text}")
return None
print("TIMEOUT after 5 minutes")
return None
Proxy 和实际请求 IP 明显不一致时,直接加
proxy参数最省事。
错误七:一个页面上塞了不止一个 reCAPTCHA 小部件
- 常见场景:登录表单挂可见 v2 复选框,后台风控触发点另挂隐形 reCAPTCHA
- 解错了小部件,token 有效但对不上真正需要的那个
修复:先定位到正确的小部件再解
from bs4 import BeautifulSoup
def find_all_recaptcha_widgets(html):
soup = BeautifulSoup(html, "html.parser")
widgets = []
for el in soup.find_all(class_="g-recaptcha"):
widgets.append({
"sitekey": el.get("data-sitekey"),
"size": el.get("data-size", "normal"),
"callback": el.get("data-callback"),
"tag": el.name,
"id": el.get("id")
})
return widgets
# Example output:
# [
# {"sitekey": "6LdA...", "size": "normal", "callback": None, "tag": "div", "id": "recaptcha-login"},
# {"sitekey": "6LdB...", "size": "invisible", "callback": "onRegister", "tag": "div", "id": "recaptcha-register"}
# ]
# Use the widget with size="invisible" for the invisible solve
生产环境可用:一套覆盖所有报错的完整求解器
- 自动重试,指数退避
- 区分配置错误(不重试)和临时错误(重试)
- 轮询、超时统一处理
import requests
import time
import logging
logger = logging.getLogger(__name__)
class InvisibleRecaptchaSolver:
def __init__(self, api_key, max_retries=3):
self.api_key = api_key
self.max_retries = max_retries
self.base_url = "https://ocr.captchaai.com"
def solve(self, sitekey, page_url, proxy=None):
"""Solve invisible reCAPTCHA with automatic retry on transient errors."""
for attempt in range(1, self.max_retries + 1):
try:
token = self._attempt_solve(sitekey, page_url, proxy)
if token:
return token
except Exception as e:
logger.warning(f"Attempt {attempt} failed: {e}")
if attempt < self.max_retries:
time.sleep(2 ** attempt)
raise Exception(f"Failed to solve after {self.max_retries} attempts")
def _attempt_solve(self, sitekey, page_url, proxy):
params = {
"key": self.api_key,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
"invisible": 1
}
if proxy:
params["proxy"] = proxy
params["proxytype"] = "HTTP"
# Submit task
resp = requests.get(f"{self.base_url}/in.php", params=params)
if "ERROR" in resp.text:
error = resp.text.strip()
if error in ("ERROR_WRONG_GOOGLEKEY", "ERROR_KEY_DOES_NOT_EXIST"):
raise Exception(f"Configuration error (do not retry): {error}")
if error == "ERROR_ZERO_BALANCE":
raise Exception("Account balance is zero — add funds")
raise Exception(f"Submit error: {error}")
if not resp.text.startswith("OK|"):
raise Exception(f"Unexpected submit response: {resp.text}")
task_id = resp.text.split("|")[1]
# Poll for result
for _ in range(60):
time.sleep(5)
result = requests.get(f"{self.base_url}/res.php", params={
"key": self.api_key,
"action": "get",
"id": task_id
})
if result.text.startswith("OK|"):
return result.text.split("|")[1]
if result.text == "CAPCHA_NOT_READY":
continue
if result.text == "ERROR_CAPTCHA_UNSOLVABLE":
logger.warning("Captcha unsolvable — will retry with new task")
return None
raise Exception(f"Poll error: {result.text}")
raise Exception("Solve timed out after 5 minutes")
# Usage
solver = InvisibleRecaptchaSolver("YOUR_API_KEY")
token = solver.solve(
sitekey="6LdKlZEU...",
page_url="https://staging.example.com/qa-login"
)
print(f"Token: {token[:50]}...")
const axios = require("axios");
class InvisibleRecaptchaSolver {
constructor(apiKey, maxRetries = 3) {
this.apiKey = apiKey;
this.maxRetries = maxRetries;
this.baseUrl = "https://ocr.captchaai.com";
}
async solve(sitekey, pageUrl, proxy) {
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const token = await this._attemptSolve(sitekey, pageUrl, proxy);
if (token) return token;
} catch (err) {
console.warn(`Attempt ${attempt} failed: ${err.message}`);
if (attempt < this.maxRetries) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
}
}
}
throw new Error(`Failed to solve after ${this.maxRetries} attempts`);
}
async _attemptSolve(sitekey, pageUrl, proxy) {
const params = {
key: this.apiKey,
method: "userrecaptcha",
googlekey: sitekey,
pageurl: pageUrl,
invisible: 1,
};
if (proxy) {
params.proxy = proxy;
params.proxytype = "HTTP";
}
// Submit task
const submitResp = await axios.get(`${this.baseUrl}/in.php`, { params });
if (submitResp.data.includes("ERROR")) {
const error = submitResp.data.trim();
if (["ERROR_WRONG_GOOGLEKEY", "ERROR_KEY_DOES_NOT_EXIST"].includes(error)) {
throw new Error(`Configuration error (do not retry): ${error}`);
}
throw new Error(`Submit error: ${error}`);
}
const taskId = submitResp.data.split("|")[1];
// Poll for result
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
const result = await axios.get(`${this.baseUrl}/res.php`, {
params: { key: this.apiKey, action: "get", id: taskId },
});
if (result.data.startsWith("OK|")) {
return result.data.split("|")[1];
}
if (result.data === "CAPCHA_NOT_READY") continue;
if (result.data === "ERROR_CAPTCHA_UNSOLVABLE") return null;
throw new Error(`Poll error: ${result.data}`);
}
throw new Error("Solve timed out after 5 minutes");
}
}
// Usage
const solver = new InvisibleRecaptchaSolver("YOUR_API_KEY");
solver.solve("6LdKlZEU...", "https://staging.example.com/qa-login").then((token) => {
console.log(`Token: ${token.substring(0, 50)}...`);
});
常见问题
reCAPTCHA invisible 是不是就等于“没有验证码”?
不是。判断逻辑(风险评分 + 行为分析)照样在跑,通不过一样会拦下请求。区别在于没有可点击的 UI 组件,脚本必须靠 data-size="invisible" 这类属性主动识别,不能靠“找到复选框点一下”这种简单逻辑。
请求参数和 reCAPTCHA v2 标准比,多了什么?
调用方式基本一样(method=userrecaptcha),但必须加上 invisible=1,否则容易拿到目标站点不认的 token。更关键的差异在拿到 token 之后:隐形 reCAPTCHA 几乎都要求写入页面字段后再手动调用一次回调函数,v2 标准通常只写隐藏字段就够了。
本地测试 token 是好的,一上线就报 ERROR_BAD_TOKEN_OR_PAGEURL,为什么?
最常见的原因是 IP 不一致。本地联调时求解器出口 IP 和本机 IP 可能接近;上线后服务器 IP 和求解器 IP 往往不同,目标站点风控校验会把 token 判定为无效。加一个匹配会话 IP 的 proxy 参数通常能解决。
页面上同时有 v2 复选框和 invisible 两个小部件,该解哪一个?
看要保护的是哪个动作。登录表单常用可见的 v2 复选框,后台风控触发点(提交按钮、AJAX 请求)常另外挂一个隐形 reCAPTCHA。用检测脚本列出页面上所有 g-recaptcha 元素,按 data-size 区分,只解和目标动作绑定的那一个。
ERROR_KEY_DOES_NOT_EXIST 一直报,可 API Key 看着是对的,是什么情况?
先到 captchaai.com/account 核对显示的 Key 和代码里写的是否完全一致(多一个空格都会触发这个错误),再确认没有把测试环境和正式环境的 Key 搞混。Key 没问题的话,检查账号余额是否为零。
排查这类报错的固定流程
| 步骤 | 检查什么 | 具体做法 |
|---|---|---|
| 1 | 确认真的是隐形,不是 v2 标准 | 在渲染调用里找 data-size="invisible" 或 size: 'invisible' |
| 2 | sitekey 取得对不对 | 专门和隐形小部件上的 data-sitekey 做比对,别抓错到别的 widget |
| 3 | API 请求里带没带 invisible=1 |
检查 in.php 的参数列表 |
| 4 | pageurl 是不是完全一致 |
用浏览器 DevTools 里看到的真实 URL,不要用跳转前/跳转后的中间地址 |
| 5 | 回调函数名找对了没 | 在 grecaptcha.render() 或 HTML 属性里找 data-callback |
| 6 | token 注入和回调调用都做了吗 | 这两步缺一不可——只写 token 不调用回调,表单不会动 |
| 7 | token 是不是还新鲜 | 必须在拿到后 120 秒内用掉 |
| 8 | IP 是否会影响判定 | 加上 proxy 和 proxytype 参数试一次 |
大多数情况下,问题出在步骤 2(sitekey 取错)和步骤 6(漏了回调调用)。
下一步阅读
- reCAPTCHA Invisible 的工作原理以及解决方法 — 隐形机制背景知识
- 如何使用 API 解决 reCAPTCHA v2 — 标准 v2 求解流程对照
- 常见 reCAPTCHA v2 解决错误和修复 — v2 标准报错模式
- CaptchaAI 错误代码:完整参考 — 完整错误码表
- 用 Python 解决 reCAPTCHA Invisible — Python 实战教程
- 常见的九宫格验证码错误和修复 — 另一类高频报错
- reCAPTCHA v2 与 Invisible 对比 — 两种模式怎么选