CrewAI 验证码工具:识别 reCAPTCHA 和 Turnstile

crewai captcha - CrewAI CAPTCHA Tool: Solve reCAPTCHA and Turnstile

要构建 CrewAI 验证码工具,就继承 BaseTool,给它一个会拒绝错误输入的 schema,并在 _run里调用 CapSkip Python SDK。要让 token 不经过模型,请设置 result_as_answer,它会让 token 成为任务的答案。接下来,有三个 CrewAI 设置决定这个工具能否放心地一直运行:工具缓存(可能重放已失效的 token)、使用次数上限(按每次运行计数),以及重试路径(可能让失败的工具自行重跑)。

你需要什么

  • 在 Windows PC 上安装好的 CapSkip,并已开启 reCAPTCHA v2 和 Turnstile 识别。
  • Python 3.10 到 3.13。crewai 1.15.35 声明需要 3.10 或更高、低于 3.14,capskip 声明需要 3.10 或更高。在 Python 3.14.0 上,pip 把 crewai[anthropic] 解析成了旧的 crewai 0.11.2,而且只给了一条警告,所以下面的命令使用 py -3.13。如果它提示没有安装 3.13,请先从 python.org 获取。我运行的是 Windows 11,使用 crewai 1.15.35(2026 年 11 月 5 日 PyPI 上的最新版本)、capskip 1.6.0、anthropic 0.73.0 和 pydantic 2.12.5,Python 版本为 3.13.7 和 3.11.7,没有在 3.10 或 3.12 上运行过。
  • 一个 PowerShell 提示符,以及一个你自己拥有、带有 reCAPTCHA v2 复选框的页面。第 3 步的表单辅助函数期望有一个名为 _csrf 的隐藏字段和一个 email 字段,所以请把名称改成与你的表单一致。
  • CapSkip 的访问路径。Local 模式(127.0.0.1)只能从同一台 PC 访问。Server 模式则让 CapSkip 也接受来自其他机器的 API 调用。模式在 连接设置 中选择,该页面在安装指南里。
  • 一个 Anthropic API 密钥,只用于第 3 步的智能体运行。工具本身不需要它。

测试方式:针对我自己写的模拟 CapSkip 和模拟注册页面,配合真实的 CrewAI 和真实的 SDK。一个脚本化的本地服务器按 Anthropic Messages 格式作答,所以 CrewAI 真实的封装发出了真实的请求体,而我读到了它们。没有调用任何真实模型,模型 id anthropic/claude-opus-5-5 也从未到达真实的 API,所以这里没有任何内容能说明真实模型如何选择参数或读取错误。任务数是模拟服务器收到的 in.php 请求数。

在 PowerShell 里运行这段。它通过路径直接调用虚拟环境自己的 Python,所以你不需要激活它。

# pip install "crewai[anthropic]" capskip
mkdir crewai-captcha
cd crewai-captcha
py -3.13 -m venv .venv
.\.venv\Scripts\python -m pip install "crewai[anthropic]" capskip
.\.venv\Scripts\python -c "import crewai, capskip; print(crewai.__version__)"

在同一个窗口里设置你自己的值。 CAPTCHA_ALLOWED_HOSTS 对安全最重要:模型永远不会设置它,而空值会拒绝每一次调用。只写主机名时,还会允许 80 和 443 端口,而 host:port 只允许那个确切的端口。

# PowerShell: set these once per window, before you run any file
$env:CAPTCHA_ALLOWED_HOSTS = "your-site.example"    # the sites the tool may solve for
$env:PAGE_URL = "https://your-site.example/signup"  # your own page
$env:SITEKEY = "YOUR_SITEKEY"                       # its data-sitekey value
$env:ANTHROPIC_API_KEY = "YOUR_PROVIDER_KEY"        # only run_crew.py needs this

第 1 步:编写 CrewAI 验证码工具

把接下来的两个代码块保存为同一个文件: captcha_tool.py。第一个代码块放设置和参数 schema,CrewAI 就在这里检查模型发来的内容。 SolveArgs.kind 字段只接受两个值之一,sitekey 是一个简短的纯字符串,页面地址必须匹配你列出的某个主机。Python 包不会读取 CAPSKIP_HOST 或任何其他变量,所以文件要手动传入密钥、主机和端口。

# captcha_tool.py, part 1: settings, arguments and the host check
import os
from typing import Literal, Type
from urllib.parse import urlparse

from capskip import ApiException, CapSkip, CapSkipError
from capskip import NetworkException, TimeoutException
from crewai.tools import BaseTool
from crewai.tools.tool_failure import ToolFailure
from pydantic import BaseModel, Field, field_validator

solver = CapSkip(
    apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
    host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
    port=int(os.getenv("CAPSKIP_PORT", "8080")),
)
MINTED = set()  # every token this tool has returned
# Set by you, never by the model. Empty means every call is refused.
RAW_HOSTS = os.getenv("CAPTCHA_ALLOWED_HOSTS", "").lower().split(",")
CAPTCHA_ALLOWED_HOSTS = {h.strip() for h in RAW_HOSTS if h.strip()}
# A bare host also allows ports 80 and 443. Write host:port to allow one port.
ALLOWED_NETLOCS = CAPTCHA_ALLOWED_HOSTS | {
    h + port for h in CAPTCHA_ALLOWED_HOSTS if ":" not in h
    for port in (":80", ":443")}


class SolveArgs(BaseModel):
    kind: Literal["recaptcha_v2", "turnstile"] = Field(
        description="Which CAPTCHA the page shows")
    sitekey: str = Field(
        min_length=10, max_length=100, pattern=r"^[A-Za-z0-9_-]+$",
        description="The data-sitekey value from the page")
    page_url: str = Field(
        max_length=2048,
        description="Full http or https address of the page with the CAPTCHA")

    @field_validator("page_url")
    @classmethod
    def host_allowed(cls, value):
        # urlparse silently drops tabs and newlines, so only printable
        # ASCII without spaces may reach it
        if not (value.isascii() and value.isprintable()) or " " in value:
            raise ValueError("page_url is not a valid address")
        try:
            parts = urlparse(value)
        except ValueError:
            raise ValueError("page_url is not a valid address")
        if (parts.scheme not in ("http", "https")
                or parts.netloc.lower() not in ALLOWED_NETLOCS):
            raise ValueError("page_url is not a site this tool may solve for")
        return value

追加第二个代码块。其中有四个设置是本指南的重点。 result_as_answer 让 token 成为任务的答案。 cache_function 表示永不缓存。 max_usage_count 限制每次运行的调用次数。而失败会以 ToolFailure的形式返回,这是 CrewAI 的一个类,它把调用标记为失败,却不抛出异常。模型读取它的 message 和 code,而它的 ToolFailure.retryable 标志是留给你的一条备注。

# captcha_tool.py, part 2: the tool (appended to part 1)
def never_cache(_args, _result):
    return False  # a token is single use, so never replay one


def fail(message, code, retryable=False):
    return ToolFailure(message=message, code=code, retryable=retryable)


class SolveCaptchaTool(BaseTool):
    name: str = "solve_captcha"
    description: str = (
        "Solve a reCAPTCHA v2 or Turnstile on a page and return its token. "
        "The token is single use. A reCAPTCHA token expires within about "
        "two minutes and a Turnstile token within five.")
    args_schema: Type[BaseModel] = SolveArgs
    result_as_answer: bool = True
    cache_function: object = never_cache
    max_usage_count: int = 3

    def _run(self, kind: str, sitekey: str, page_url: str):
        call = solver.recaptcha if kind == "recaptcha_v2" else solver.turnstile
        try:
            token = call(sitekey, page_url)["code"]
            MINTED.add(token)
            return token
        except TimeoutException:
            return fail("CapSkip timed out. Try once more.", "timeout", True)
        except NetworkException:
            return fail("CapSkip did not answer correctly (not running, "
                        "or an HTTP error). Do not retry.",
                        "capskip_unreachable")
        except ApiException as exc:
            if "UNSOLVABLE" in str(exc):
                return fail("This CAPTCHA could not be solved. Try once more.",
                            "unsolvable", True)
            return fail("CapSkip refused the job. Do not retry.",
                        "capskip_rejected")
        except CapSkipError:
            return fail("The arguments were refused. Do not retry.",
                        "bad_arguments")

把下一个代码块保存为 try_tool.py。在项目文件夹里运行 .\.venv\Scripts\python try_tool.py。它手动调用该工具,不需要 Anthropic 密钥,并打印 token 的长度。Python SDK 不设置 HTTP 超时,所以一个接受了连接却始终不应答的 CapSkip,会让这个文件永远阻塞。第 3 步会加上一个 watchdog。

# try_tool.py: call the tool by hand, no model needed
import os

from captcha_tool import SolveCaptchaTool

tool = SolveCaptchaTool()
result = tool.run(
    kind="recaptcha_v2",
    sitekey=os.environ["SITEKEY"],
    page_url=os.environ["PAGE_URL"],
)
print("%d characters" % len(result) if isinstance(result, str) else result)

第二项检查是一个工具必须拒绝的调用。把它保存为 try_hostile.py。它打印的第一行会说明参数未通过校验,而 CapSkip 没有收到任何任务。

# try_hostile.py: a call the tool must refuse before CapSkip hears of it
import os

from captcha_tool import SolveCaptchaTool

try:
    SolveCaptchaTool().run(
        kind="recaptcha_v2",
        sitekey=os.environ["SITEKEY"],
        page_url="https://evil.example/signup",
    )
except ValueError as exc:
    print(str(exc).splitlines()[0])

第 2 步:模型看到什么,以及它能绕过什么

我通过 ANTHROPIC_BASE_URL 变量,让 CrewAI 的 Anthropic 提供方指向脚本化服务器,并记录了请求。请求里没有 tool choice、temperature 或 thinking 字段。工具带着 strict: true 和 kind 字段的枚举到达,但 sitekey 失去了长度限制和 pattern,页面地址失去了长度限制,因为 CrewAI 在 Anthropic 的 strict 模式下会去掉这些关键字。所以这些限制只存在于你的工具里:工具运行时,CrewAI 会按 args_schema 检查参数,这发生在使用计数和 SDK 之前。真实的 API 是否接受这个请求,我无法测试。

我把大约 100 组参数直接发给工具,来自两份独立的攻击清单,另外还有少数几组是通过智能体循环发送的。

参数结果任务数
另一个主机、形似的主机(如 127.0.0.1.evil.example)、地址里带用户名、反斜杠、IPv6 字面量Refused0
主机里夹着制表符或换行符、地址前后有空格、NUL 字符、不间断空格,或路径里带着请求头的换行Refused0
80 和 443 之外的任何端口,或空端口Refused0
file: 或 javascript: 地址、空地址、长达 5,000 个字符的地址Refused0
长度为 9、101 或 1,000,000 个字符的 sitekey,或带有换行和文本的 sitekeyRefused0
工具不提供的 kind,例如 hcaptchaRefused0
一个名为 proxy 的额外参数被丢弃,从不转发1
大写字母写成的允许主机Accepted1
什么也没设置,变量为 CAPTCHA_ALLOWED_HOSTSRefused0

只有不含空格的可打印 ASCII 才能到达解析器,因为 urlparse 在读取主机之前就会丢掉制表符和换行符,随后检查会匹配整个主机和端口。拒绝会以错误文本的形式返回给模型,对于 1,000,000 个字符的 sitekey,这段文本有 858 个字符。拒绝不会消耗 max_usage_count,一个不断发送错误地址的模型,在五次调用后被 CrewAI 的重复检查拦下,没有产生任何任务。在第 3 步的抛出策略下,拒绝则会直接结束这次运行。

第 3 步:在 crew 中运行 CrewAI 验证码工具并提交 token

智能体选择参数,其余的由你的脚本完成,所以模型永远不会持有 token。先看表单辅助函数 submit_form.py。它是普通的 Python,不是工具,是 LangChain 验证码工具指南中那个提交程序的变体。它不跟随重定向,并且会拒绝 action 与页面在协议、主机或端口上不一致的表单。

# submit_form.py: post the token to your own form, in plain Python
import re
from urllib.parse import urljoin, urlsplit

import requests

CSRF = r'name="_csrf" value="([^"]+)"'
ACTION = r'<form[^>]*action="([^"]*)"'


def same_origin(a, b):
    return urlsplit(a)[:2] == urlsplit(b)[:2]  # scheme and host:port


def post_form(page_url, token, email):
    http = requests.Session()
    # A redirect is not followed, so a page that bounces you gets no token
    page = http.get(page_url, timeout=15, allow_redirects=False)
    if page.status_code != 200:
        return "page_answered_%d" % page.status_code
    csrf = re.search(CSRF, page.text)
    action = re.search(ACTION, page.text)
    if not (csrf and action):
        return "form_not_recognised"
    target = urljoin(page_url, action.group(1))
    if not same_origin(target, page_url):
        return "form_posts_elsewhere"
    fields = {"_csrf": csrf.group(1), "email": email,
              "g-recaptcha-response": token}
    reply = http.post(target, data=fields, headers={"Referer": page_url},
                      timeout=15, allow_redirects=False)
    return "form_answered_%d" % reply.status_code

我拿它和一个天真的提交程序做了对比,后者会跟随重定向,并信任表单的 action。无论是 http 还是 https,只要重定向到另一个主机名,或者表单 action 指向另一个主机名,签发的 token 就会落到那个主机上,而这个辅助函数没有向那里发送任何内容。它还会拒绝这样的 https 页面:表单 action 是同一主机和端口上的 http;它也会拒绝同一主机内部的重定向,所以请把最终地址填进 PAGE_URL.

现在是 run_crew.py。抛出策略会让失败的工具调用停止运行,并以异常的形式传到你的代码里,在那里 ToolFailure.retryable 是可读的。脚本会重试一次,并且只接受由工具签发的 token。启动命令是 .\.venv\Scripts\python run_crew.py.

# run_crew.py: one agent, one tool, one task, and your own retry loop
import os

from crewai import Agent, Crew, LLM, Task
from crewai.tools import ToolExecutionFailedError
from crewai_core.paths import db_storage_path

from captcha_tool import MINTED, SolveCaptchaTool
from submit_form import post_form

PAGE_URL = os.environ["PAGE_URL"]
SITEKEY = os.environ["SITEKEY"]

agent = Agent(
    role="CAPTCHA solver",
    goal="Get one CAPTCHA token for the page you are given",
    backstory="You call solve_captcha once and answer with its token.",
    llm=LLM(model=os.getenv("CAPTCHA_MODEL", "anthropic/claude-opus-5-5")),
    tools=[SolveCaptchaTool()],
    max_iter=6,
)
task = Task(
    description="Solve the reCAPTCHA v2 on %s. Its sitekey is %s."
    % (PAGE_URL, SITEKEY),
    expected_output="The token and nothing else.",
    agent=agent,
)
# raise: a failed tool call stops the run and comes back to this script
crew = Crew(agents=[agent], tasks=[task], tool_failure_policy="raise")


def forget_outputs():
    # CrewAI keeps the last kickoff's answer, the token, in this file
    path = os.path.join(db_storage_path(), "latest_kickoff_task_outputs.db")
    if os.path.exists(path):
        os.remove(path)


def get_token(extra_tries=1):
    for attempt in range(extra_tries + 1):
        try:
            token = crew.kickoff().raw
            forget_outputs()
        except ToolExecutionFailedError as exc:
            failure = exc.record.failure
            print("attempt", attempt, "failed:", failure.code)
            if not failure.retryable:
                raise
            continue
        if token not in MINTED:  # the agent answered in words instead
            raise RuntimeError("no token in the answer")
        return token
    raise RuntimeError("no token after %d tries" % (extra_tries + 1))


if __name__ == "__main__":
    token = get_token()
    print("token:", len(token), "characters")
    print("form:", post_form(PAGE_URL, token, "[email protected]"))

在模拟服务器上,先遇到一个无法识别的验证码,再遇到一个正常的,结果打印了一次失败的尝试和一个 token,辅助函数返回了 200 应答:2 次模型请求和 2 个任务。一个用文字作答的模型,和一个编造了 token 的模型,都触发了 RuntimeError。每次失败的尝试,CrewAI 还会向 stdout 打印两条 Event pairing mismatch 警告,并向 stderr 打印一行 ERROR,其中点出工具名和失败代码。重试仍然成功。

watchdog 负责应对挂起。它在子进程里运行 crew,超过以秒为单位的上限就停止它,这个上限可以写在文件名后面传入。默认的 900 允许两次尝试,因为 SDK 每次最多轮询一个 reCAPTCHA 任务 300 秒。运行 .\.venv\Scripts\python watchdog.py 来代替直接运行 crew。

# watchdog.py: stop a crew that hangs on a CapSkip that never answers
import subprocess
import sys

limit = int(sys.argv[1]) if len(sys.argv) > 1 else 900
try:
    run = subprocess.run([sys.executable, "run_crew.py"], timeout=limit)
    sys.exit(run.returncode)
except subprocess.TimeoutExpired:
    sys.exit("run_crew.py was stopped after %d seconds" % limit)

对一个接受任务却从不应答的模拟 CapSkip,上限设为 15 时,运行在 15.1 秒后结束,消息说 run_crew.py 已被停止,退出码为 1。模拟服务器收到了 1 个任务,没有任何 Python 进程还握着这个连接。挂起是模拟服务器自己的规则,所以真实的 CapSkip 失败的方式可能不同。

关于 token 的两条警告。设置 verbose=True时,CrewAI 会把它打印到控制台。CrewAI 还会把最近一次 kickoff 的答案(包括 token)保存到 latest_kickoff_task_outputs.db 里,位于本地应用数据文件夹的 CrewAI 目录下。我在源码里没有找到停止这次写入的开关,所以脚本会在每次成功运行后删除该文件。发给模型的所有请求体里都没有出现 token。

CrewAI 如何处理失败、重试、cache_function 和 max_usage_count

各行使用 Anthropic 封装,对接脚本化服务器,但标有文本路径的两行除外:它们用的是进程内的替身模型,没有函数调用,所以 CrewAI 使用了它的 ReAct 文本格式。

场景模型请求数任务数
一个无法识别的验证码,工具返回 ToolFailure21
同上,但 SDK 异常逃出了工具 _run21
同样的异常逃出,文本路径23
同样的 ToolFailure,文本路径21
模型在每次失败后重复调用,上限为 393
同上,无上限65
抛出策略,一个无法识别的验证码11

在原生路径上,逃出的异常会以文本 Error executing tool 加上 SDK 消息的形式传给模型,之后不会有任何东西重新运行它。ToolFailure 只向模型展示你写的 message 和 code。在文本路径上,CrewAI 会自己重新运行抛出异常的工具,把一次失败变成三个任务,所以请返回失败。

max_usage_count 限制的是任务数,而不是模型请求数:上限为 3 时,模型发出了 9 次请求,模拟服务器看到了 3 个任务。计数器在每次运行时重新开始:同一个 crew 的五次 kickoff,在上限为 3 的情况下产生了五个任务。没有上限时,CrewAI 在第五次相同的调用之后停止了循环,并记录了一次重复调用失败。 ToolFailure.retryable 在这些运行中没有决定任何事,源码只是记录它,并把它传给追踪(tracing)。

缓存是最危险的一项。在 1.15.35 中,除非你传入 Crew(cache=True),否则 crew 会关闭它的工具缓存:用相同参数做两次 kickoff,即使使用 CrewAI 的默认缓存函数,也产生了 2 个任务。打开缓存并使用该默认值时,它们只产生了 1 个任务,并两次返回同一个 token,模拟站点把第二次提交当作已使用而拒绝。把 never_cache 放进工具后,打开缓存又产生了 2 个任务。ToolFailure 永远不会被缓存。参见 reCAPTCHA token 过期时间 了解真实 token 能用多久,CrewAI 的 工具文档 介绍了缓存函数和失败策略。

你可能遇到的错误

你所看到的原因修复
pip 装上了 crewai 0.11.2Python 3.14 超出了 crewai 1.15.35 支持的范围创建环境时使用 py -3.13
page_url is not a site this tool may solve for主机和端口不在 CAPTCHA_ALLOWED_HOSTS里,或者它没有设置设置 CAPTCHA_ALLOWED_HOSTS
attempt 0 failed: unsolvableCapSkip 放弃了这个验证码在你自己的代码里重试,就像 get_token 那样
failed: capskip_unreachable在 CAPSKIP_HOST处没有应答,或者 CapSkip 返回了 HTTP 错误启动 CapSkip,并确认主机和端口与它的设置一致
failed: capskip_rejectedCapSkip 拒绝了任务,例如 API 密钥错误检查密钥,并对照 API 文档 中的错误表
no token in the answer智能体用文字作答了不要提交。重新运行一次
站点把这个 token 当作重复而拒绝缓存重放了一个旧 token保留 never_cache ,放在工具里
脚本一直不结束CapSkip 接受了连接,却始终没有应答放在 watchdog 的监督下运行,即 watchdog.py

常见问题

crew 能调用运行在另一台 PC 上的 CapSkip 吗?

可以。在 CapSkip 里打开 Server 模式;如果 crew 运行在你的网络之外,就给那台 PC 一个静态公网 IP;然后在 crew 运行的地方设置 CAPSKIP_HOST, CAPSKIP_PORT, CAPSKIP_API_KEY 。流量是普通 HTTP,所以请使用专用网络或 VPN,并开启 API key validation。Server 模式启动时,CapSkip 会向所有地址开放它的端口,所以请为你不信任的地址添加 Windows Firewall 阻止规则。这台机器仍然是你的,识别也仍然不计量。我只试了这些变量,没有试远程机器或托管平台。

如果我的智能体是 Claude Code,还需要这个吗?

不需要。Claude Code 和其他 MCP 客户端可以从 CapSkip 的 MCP 服务器获得验证码识别,不需要任何工具代码, MCP 验证码识别页面 对此有详细介绍。

这对 Turnstile 也适用吗?

该工具接受 kind 为 turnstile,模拟服务器收到了一个带有 sitekey 的 Turnstile 任务。Cloudflare 说,一个 Turnstile token 的有效期是 300 秒,而且只能用一次。根据 CapSkip SDK 的说明,挑战页面需要额外的值,而这个工具不会发送它们;表单辅助函数提交的是 reCAPTCHA 字段,所以对 Turnstile 表单要改字段名。

简短版结论

一个好的 CrewAI 验证码工具,会让 schema 来负责拒绝,把失败作为返回值而不是抛出异常,为这个工具关闭缓存,并把重试留在你自己的代码里。以上每项测试中,这些防护都成立了。有了运行在你自己的 Windows PC 上的 验证码绕过 工具,每次尝试都不产生按次识别的费用,而 Python 验证码识别页面 列出了其他验证码类型的 SDK 调用。