Troubleshooting

常见的 reCAPTCHA 隐形错误和修复

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 回调函数——没触发回调,表单不会往下走

回调这一套流程是怎么跑的

  1. grecaptcha.execute() 触发隐形挑战
  2. 解出结果后,Google 会调用 data-callback 里指定的那个函数
  3. 这个回调函数负责提交表单或者发起 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 是否会影响判定 加上 proxyproxytype 参数试一次

大多数情况下,问题出在步骤 2(sitekey 取错)和步骤 6(漏了回调调用)。


下一步阅读

该文章已禁用评论。