Use Cases

Web 表单中 API 端点测试的 CAPTCHA 解决方案

能不能跳过浏览器,直接测试受验证码保护的接口?可以——用 API 换到验证码 token,塞进请求体直接 POST 给后端,不用打开页面、不用等渲染。

做后端校验、压测、CI/CD 集成测试时,这条路径比浏览器自动化快得多。


验证码接口测试适用场景

场景 你要验证什么
后端校验逻辑 服务端是不是真的在检查 token,而不是收到就放行
压测 批量请求下,受保护接口在真实并发中的表现
CI/CD 集成测试 表单提交接口纳入流水线,每次构建自动跑一遍
错误响应测试 token 过期或无效时,接口能不能返回正确的错误信息

开始之前要确认的 3 件事

  1. 从目标页面的网络请求或 HTML 源码里找到真实的 sitekeypageurl——两个参数错一个,token 都通不过校验
  2. 确认 CaptchaAI 账户里有可用余额,API Key 有效
  3. 先手动跑一次 TokenProvider,确认能稳定拿到 token,再接入自动化测试套件

接口测试整体流程

┌──────────┐     ┌────────────┐     ┌──────────────┐     ┌──────────────┐
│ Solve    │────▶│ Build      │────▶│ POST to      │────▶│ Validate     │
│ CAPTCHA  │     │ Request    │     │ Endpoint     │     │ Response     │
│ (API)    │     │ Payload    │     │              │     │              │
└──────────┘     └────────────┘     └──────────────┘     └──────────────┘

对应到图里的四步:

  1. 用 API 换到 token(Solve)
  2. 把 token 塞进 payload(Build Request)
  3. 提交给目标接口(POST)
  4. 校验响应是否符合预期(Validate)

多数接口测试根本用不到浏览器。


API 接口测试代码实现

验证码 Token 获取器

TokenProviderin.php / res.php 的提交与轮询封装成两个方法——reCAPTCHA、Turnstile 各一个:

import time
import requests

class TokenProvider:
    BASE = "https://ocr.captchaai.com"

    def __init__(self, api_key):
        self.api_key = api_key

    def get_recaptcha_token(self, sitekey, pageurl, version="v2"):
        params = {
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": pageurl,
        }
        if version == "v3":
            params["version"] = "v3"
            params["action"] = "submit"
        return self._solve(params, initial_wait=15 if version == "v3" else 10)

    def get_turnstile_token(self, sitekey, pageurl):
        return self._solve({
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": pageurl,
        })

    def _solve(self, params, initial_wait=10):
        params["key"] = self.api_key
        params["json"] = 1
        resp = requests.post(f"{self.BASE}/in.php", data=params).json()
        if resp["status"] != 1:
            raise Exception(resp["request"])
        task_id = resp["request"]
        time.sleep(initial_wait)
        for _ in range(60):
            result = requests.get(
                f"{self.BASE}/res.php",
                params={"key": self.api_key, "action": "get", "id": task_id, "json": 1},
            ).json()
            if result["request"] == "CAPCHA_NOT_READY":
                time.sleep(5)
                continue
            if result["status"] == 1:
                return result["request"]
            raise Exception(result["request"])
        raise TimeoutError("Timed out")

接口测试器

拿到 token 后交给 EndpointTester:组装 payload、发起请求、比对响应,并内置两个反向用例,确认接口会拒绝无效或缺失的 token:

import json
import time

class EndpointTester:
    def __init__(self, api_key):
        self.token_provider = TokenProvider(api_key)
        self.session = requests.Session()
        self.results = []

    def test_endpoint(self, config):
        """
        config: {
            "name": "test name",
            "url": "endpoint URL",
            "method": "POST",
            "captcha_type": "recaptcha_v2" | "recaptcha_v3" | "turnstile",
            "sitekey": "...",
            "pageurl": "...",
            "captcha_field": "g-recaptcha-response",
            "payload": { ... form data ... },
            "expected_status": 200,
            "expected_contains": "success",
        }
        """
        start = time.time()
        result = {"name": config["name"], "passed": False}

        try:
            # Get CAPTCHA token
            captcha_type = config.get("captcha_type", "recaptcha_v2")
            if captcha_type == "recaptcha_v2":
                token = self.token_provider.get_recaptcha_token(
                    config["sitekey"], config["pageurl"]
                )
            elif captcha_type == "recaptcha_v3":
                token = self.token_provider.get_recaptcha_token(
                    config["sitekey"], config["pageurl"], version="v3"
                )
            elif captcha_type == "turnstile":
                token = self.token_provider.get_turnstile_token(
                    config["sitekey"], config["pageurl"]
                )
            else:
                raise ValueError(f"Unknown captcha type: {captcha_type}")

            # Build payload
            payload = {**config.get("payload", {})}
            captcha_field = config.get("captcha_field", "g-recaptcha-response")
            payload[captcha_field] = token

            # Submit request
            method = config.get("method", "POST").upper()
            headers = config.get("headers", {})

            if config.get("json_body"):
                resp = self.session.request(
                    method, config["url"], json=payload, headers=headers
                )
            else:
                resp = self.session.request(
                    method, config["url"], data=payload, headers=headers
                )

            # Validate response
            result["status_code"] = resp.status_code
            result["response_length"] = len(resp.text)
            result["elapsed"] = round(time.time() - start, 2)

            # Check expected status
            expected_status = config.get("expected_status", 200)
            if resp.status_code != expected_status:
                result["error"] = f"Expected {expected_status}, got {resp.status_code}"
                self.results.append(result)
                return result

            # Check expected content
            expected = config.get("expected_contains")
            if expected and expected.lower() not in resp.text.lower():
                result["error"] = f"Response missing: '{expected}'"
                self.results.append(result)
                return result

            result["passed"] = True

        except Exception as e:
            result["error"] = str(e)
            result["elapsed"] = round(time.time() - start, 2)

        self.results.append(result)
        return result

    def test_invalid_token(self, config):
        """Test that endpoint rejects invalid CAPTCHA tokens."""
        invalid_config = {**config}
        invalid_config["name"] = f"{config['name']} (invalid token)"

        # Override with fake token
        payload = {**config.get("payload", {})}
        captcha_field = config.get("captcha_field", "g-recaptcha-response")
        payload[captcha_field] = "INVALID_TOKEN_12345"

        start = time.time()
        result = {"name": invalid_config["name"], "passed": False}

        try:
            resp = self.session.post(config["url"], data=payload)
            result["status_code"] = resp.status_code
            result["elapsed"] = round(time.time() - start, 2)

            # Should reject — 4xx or error message
            if resp.status_code >= 400 or "error" in resp.text.lower() or "invalid" in resp.text.lower():
                result["passed"] = True
            else:
                result["error"] = "Endpoint accepted invalid CAPTCHA token"

        except Exception as e:
            result["error"] = str(e)
            result["elapsed"] = round(time.time() - start, 2)

        self.results.append(result)
        return result

    def test_missing_token(self, config):
        """Test that endpoint rejects missing CAPTCHA token."""
        start = time.time()
        result = {"name": f"{config['name']} (missing token)", "passed": False}

        try:
            payload = config.get("payload", {})
            resp = self.session.post(config["url"], data=payload)
            result["status_code"] = resp.status_code
            result["elapsed"] = round(time.time() - start, 2)

            if resp.status_code >= 400 or "captcha" in resp.text.lower():
                result["passed"] = True
            else:
                result["error"] = "Endpoint accepted request without CAPTCHA"

        except Exception as e:
            result["error"] = str(e)
            result["elapsed"] = round(time.time() - start, 2)

        self.results.append(result)
        return result

    def run_suite(self, configs):
        """Run a full test suite against multiple endpoints."""
        for config in configs:
            self.test_endpoint(config)
            self.test_invalid_token(config)
            self.test_missing_token(config)
        return self.report()

    def report(self):
        passed = sum(1 for r in self.results if r["passed"])
        total = len(self.results)
        lines = [f"Endpoint Tests: {passed}/{total} passed", "=" * 50]
        for r in self.results:
            status = "PASS" if r["passed"] else "FAIL"
            elapsed = r.get("elapsed", "?")
            lines.append(f"  [{status}] {r['name']} ({elapsed}s)")
            if r.get("error"):
                lines.append(f"         Error: {r['error']}")
        return "\n".join(lines)

国内 CI 环境的小提醒

国内网络下跑 CI 时,reCAPTCHA 依赖的 Google 脚本经常连不稳定,浏览器方案容易超时;用 API 换 token 可以跳过这一层,流水线更稳。依赖安装可走镜像:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple requests


接口测试调用示例

联系表单走 reCAPTCHA v2,订阅表单走 Turnstile:

tester = EndpointTester("YOUR_API_KEY")

configs = [
    {
        "name": "Contact form submission",
        "url": "https://example.com/api/contact",
        "captcha_type": "recaptcha_v2",
        "sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
        "pageurl": "https://example.com/contact",
        "captcha_field": "g-recaptcha-response",
        "payload": {
            "name": "Test User",
            "email": "[email protected]",
            "message": "Automated test message",
        },
        "expected_status": 200,
        "expected_contains": "success",
    },
    {
        "name": "Newsletter signup",
        "url": "https://example.com/api/subscribe",
        "captcha_type": "turnstile",
        "sitekey": "0x4AAAA...",
        "pageurl": "https://example.com/newsletter",
        "captcha_field": "cf-turnstile-response",
        "payload": {
            "email": "[email protected]",
        },
        "expected_status": 200,
    },
]

report = tester.run_suite(configs)
print(report)

示例里的 sitekeypageurl 都是占位值,跑之前换成你自己接口对应的参数。

输出:

Endpoint Tests: 5/6 passed
==================================================
  [PASS] Contact form submission (18.5s)
  [PASS] Contact form submission (invalid token) (0.3s)
  [PASS] Contact form submission (missing token) (0.2s)
  [PASS] Newsletter signup (14.2s)
  [FAIL] Newsletter signup (invalid token) (0.3s)
         Error: Endpoint accepted invalid CAPTCHA token
  [PASS] Newsletter signup (missing token) (0.2s)

故障排查

下面是接口测试中最容易踩的四个坑,对应症状和处理方式:

现象 原因 处理方式
有效 token 被拒绝 solve 完到提交隔太久,token 过期了 缩短 solve 和提交之间的间隔
无效 token 也被放行 后端根本没做验证码校验 记为安全问题,提交 bug
所有请求都是 403 缺 CSRF token 或没带 session cookie 补上 session cookie 或 CSRF 请求头
JSON 接口拒收表单数据 Content-Type 传错了 在 config 里设 json_body: True

上面是通用排查思路,具体日志字段和错误文案以你自己后端框架的实际输出为准。


常见问题

不解真实验证码,能测接口吗?

能测一部分。无效、缺失 token 这两类用例不需要真的解验证码——直接不带 token 或塞个假值提交即可。要测“合法提交能不能走通”,就得用 CaptchaAI 拿一个真实 token。

reCAPTCHA v2、v3 和 Turnstile 要分开写测试吗?

不用。TokenProvider 已按类型分方法,EndpointTestercaptcha_type 字段路由,配置里指定类型和字段名(g-recaptcha-responsecf-turnstile-response)即可。

这套测试放进 CI/CD,会不会拖慢流水线?

耗时主要在 solve 阶段,v3 比 v2/Turnstile 稍慢。无效、缺失 token 用例几乎不占时间,可把“合法 token”用例单拆一个 job,跟快速用例并行跑。

怎么验证接口的限流(rate limit)生效了?

在请求间加延迟,逐步提高频率,记录接口从哪个频率开始返回 429。这跟验证码校验是两回事——压测限流不需要每次都带真实 token,用无效或缺失 token 的请求就能测。

可以直接对生产环境跑这套测试吗?

不建议直接跑全部用例,尤其是压测和高频请求。先在 staging 环境验证脚本逻辑——token 获取、payload 组装是否正确——确认没问题,再视频率控制情况决定要不要在生产环境小范围验证。


相关指南

顺手打通表单提交和文档查阅的场景。

工作流程之外,还可以看看自动化表单提交怎么处理验证码,或者把CaptchaAI API 速查手册收藏起来备用。


把每一个受验证码保护的接口都测到——了解 CaptchaAI

该文章已禁用评论。