如何在 Browser Use 中用自定义工具识别验证码

要在你自己的浏览器上让 Browser Use 识别验证码,就注册一个自定义工具:它从页面上读取小组件的 sitekey,向 CapSkip 请求 token,再把 token 写进表单;然后在系统消息里告诉智能体有这个工具。这样就覆盖了 Browser Use 在本地 Chromium 上处理验证码的全部环节,只差两个时限:两者默认都是 180 秒,需要调高,因为一次 reCAPTCHA 识别可能比其中任何一个都长。系统消息这一步不是可选的。Browser Use 的默认系统提示词告诉模型验证码会被自动识别,这在 Browser Use 的云端浏览器上成立,在本地 Chromium 上却不成立,所以不加这段话,智能体就会一直等待一次永远不会到来的识别。本指南一步步构建这个 Browser Use 验证码工具,所有内容都在 Browser Use 0.13.10 上核对过。
你需要什么
- 在 Windows 机器上运行的 CapSkip。下面的工具能处理 reCAPTCHA v2(包括隐形小组件和 Enterprise 小组件)以及 Cloudflare Turnstile 小组件。
- Python 3.11 或更高版本(Browser Use 要求如此),并安装 browser-use 和 capskip 包 1.3.0 或更高版本。Browser Use 会驱动它在本机上找到的 Chrome 或 Chromium。
- 你给智能体配的那个聊天模型的 API 密钥;如果用 Claude,就把它设为 ANTHROPIC_API_KEY。示例通过 ChatAnthropic 使用 Claude Opus 5.5,并打开了自适应思考(adaptive thinking),这个参数很关键:没有它,Browser Use 0.13.10 会强制指定模型的工具选择,而 Claude Opus 5.5 和 Sonnet 5.5 会拒绝这种做法,于是每个步骤都会失败。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用;在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的智能体就能通过 API 调用它。两种模式的设置位置都是 连接设置.
# Quoted, so cmd.exe does not read >= as a redirect pip install browser-use "capskip>=1.3.0"
为什么 Browser Use 在本地浏览器上会忽略验证码?
因为它被明确告知要这样做。Browser Use 在默认设置下使用的系统提示词里,浏览器规则中有这样一行:"CAPTCHAs are automatically solved by the browser"(验证码由浏览器自动识别),后面还跟着一条指令,让它不要尝试手动识别。这说的是 Browser Use 的云端浏览器,它们在自己的代理中识别验证码,并把这件事通知给库。库里有一个与之配套的 watchdog(看门狗)组件,由 BrowserProfile 上的 captcha_solver 设置开启(默认开启),在云端浏览器识别期间暂停智能体。它只监听那些云端浏览器发出的事件。
在你自己机器上的 Chromium 里,这样的事件永远不会到来。智能体看到小组件,相信了规则,于是等待、滚动页面,或者换一条路走,直到步数用完。一次运行以这种方式结束时,Browser Use 会建议你改用它的云端浏览器。如果你想让浏览器留在自己的硬件上,另一个办法就是给智能体一个真正能识别小组件的工具,并纠正它收到的那条规则。
第 1 步:注册 solve_captcha 工具
自定义工具是用 Tools 实例上的 tools.action 装饰器注册的异步函数。Browser Use 会按名称注入特殊参数:browser_session 给你当前活动的会话,page_url 给你当前页面的地址。这个工具需要两小段 JavaScript,一段用来查找小组件,一段用来填入 token。
FIND = """() => {
const w = document.querySelector(
'.g-recaptcha[data-sitekey], .cf-turnstile[data-sitekey]');
if (!w) return null;
return {
kind: w.classList.contains('cf-turnstile') ? 'turnstile' : 'recaptcha',
sitekey: w.dataset.sitekey,
invisible: w.dataset.size === 'invisible' || w.tagName !== 'DIV',
enterprise: !!document.querySelector(
'script[src*="recaptcha/enterprise.js"]'),
callback: w.dataset.callback || null,
};
}"""
FILL = """(kind, token, callback) => {
const name = kind === 'turnstile' ? 'cf-turnstile-response'
: 'g-recaptcha-response';
const fields = document.querySelectorAll(`[name="${name}"]`);
fields.forEach(f => { f.value = token; });
if (callback && typeof window[callback] === 'function') {
window[callback](token);
}
return fields.length;
}"""两段都写成箭头函数,因为 page 对象的 evaluate 方法不接受其他形式。它以 JSON 形式传递额外参数,而且总是返回字符串:对象以 JSON 编码后的形式返回,数字以数字字符的形式返回,null 则返回空字符串。FILL 把 token 写进小组件创建的响应字段;如果小组件声明了 data-callback,还会调用其中指定的函数,因为有些表单等待的是这个回调,而不是读取该字段。
FIND 还会读取识别所依赖的两项信息。绑定在按钮上的小组件,即使没有 data-size 属性也是隐形的,所以任何不是 div 的 g-recaptcha 元素都算作隐形。另外,加载了 Google 的 enterprise.js 脚本的页面会按 reCAPTCHA Enterprise 来识别,因为 Enterprise 小组件使用的标记和标准小组件相同。
@tools.action(
"Solve the reCAPTCHA v2 or Cloudflare Turnstile widget on the current "
"page. Call it after the other fields are filled, then submit the form "
"unless the page has already moved on."
)
async def solve_captcha(browser_session: BrowserSession, page_url: str):
page = await browser_session.must_get_current_page()
raw = await page.evaluate(FIND)
widget = json.loads(raw) if raw else None
if not widget:
return ActionResult(error="No reCAPTCHA or Turnstile widget found.")
try:
if widget["kind"] == "turnstile":
result = await solver.turnstile(widget["sitekey"], page_url)
else:
extra = {"invisible": 1} if widget["invisible"] else {}
result = await solver.recaptcha(
widget["sitekey"], page_url,
enterprise=int(widget["enterprise"]), **extra)
except CapSkipError as exc:
return ActionResult(error=f"CapSkip did not solve it: {exc!r}")
filled = await page.evaluate(
FILL, widget["kind"], result["code"], widget["callback"])
if filled == "0":
return ActionResult(error="Solved, but no response field to fill.")
return ActionResult(
extracted_content=f"Solved the {widget['kind']} CAPTCHA on {page_url} "
"and filled in the token. Submit the form now, "
"unless the page has already moved on.",
)描述字符串是模型在决定执行哪个动作时读到的内容,所以它不仅说明工具做什么,还说明什么时候该调用它。成功消息只放在 extracted_content 里。如果一个 ActionResult 同时设置了 long_term_memory,Browser Use 给模型看的是 memory,并丢掉 content,这样一来,像 "Submit the form now" 这样放在 content 里的指令就永远到不了模型那里。每次失败都会以一个带 error 的 ActionResult 返回,让模型能读到并作出反应,而不是抛出异常。
这里要用 AsyncCapSkip,而不是同步的 CapSkip 客户端。Browser Use 把浏览器连接和你的工具放在同一个事件循环上运行,一次阻塞式识别会在整个识别期间把它们全部冻住。在 Python 中,AsyncCapSkip 是真正的 asyncio 客户端,而不是一个别名。
第 2 步:告诉智能体有这个工具
把工具传给智能体,并在它的系统消息里加一段话。extend_system_message 会追加在默认提示词之后,所以它出现在它要纠正的那条规则后面。
EXTRA = (
"This browser does not solve CAPTCHAs on its own, whatever the rules "
"above say. When a page shows a reCAPTCHA or Turnstile widget, fill in "
"the other fields, call solve_captcha, then submit the form unless the "
"page has already moved on. Never click the CAPTCHA checkbox or "
"challenge yourself."
)
agent = Agent(
task="Sign up at https://example.com/signup with YOUR_EMAIL.",
llm=ChatAnthropic(model="claude-opus-5-5", thinking={"type": "adaptive"}),
tools=tools,
extend_system_message=EXTRA,
step_timeout=420,
)那段话里的顺序很重要。正如 reCAPTCHA token 过期指南 所述,这种 token 只在大约两分钟内有效,而模型的每个步骤都要花上几秒。先识别、再填写一张很长的表单,可能会让 token 在提交之前就过期,所以要让智能体最后再识别。模型可以在一个步骤里完成全部三件事:输入、识别和点击提交,而 Browser Use 会按顺序执行一个步骤中的各个动作。关于页面已经跳走的那个分句,是为隐形小组件准备的。它们的回调通常会自己提交表单,所以等 FILL 调用完回调,页面已经跳走了,再点一次提交只会失败。
ChatAnthropic 上的 thinking 参数不是摆设。Browser Use 通过一次工具调用向 Claude 索要下一个动作;在 0.13.10 版中,如果没有 thinking,它会强制指定这个工具选择。Claude Opus 5.5 和 Sonnet 5.5 会以 400 拒绝被强制的工具选择,所以智能体的每个步骤都会失败。打开自适应思考后,Browser Use 会让模型自己选择工具,请求就能通过。
第 3 步:调高两个 180 秒的时限
Browser Use 中的一次验证码识别,必须同时落在两个独立的计时器之内,而两者默认都是 180 秒。
- 单个动作的时限。 每个动作都包在一个超时里,超时值从 BROWSER_USE_ACTION_TIMEOUT_S 环境变量读取,智能体从不传入自己的值。这个变量只读取一次,就在 Browser Use 首次加载它的 tools 模块时,而任何对 Agent 或 Tools 的导入都会触发这次加载,所以在那之后再设置它不会有任何效果。
- 步骤超时。 Agent 的 step_timeout 覆盖整个步骤:准备页面状态、模型调用(Browser Use 自己会根据模型给它最多 90 秒),以及该步骤中的每个动作。
另一边,SDK 对 reCAPTCHA 最多轮询 300 秒(recaptchaTimeout),而 CapSkip 自己的 reCAPTCHA 设置允许一个任务最多等待 250 秒来获得线程,再用最多 250 秒来识别。大多数识别远远用不了这么久,但排队或者一个慢速代理可能让某次识别超过 180 秒。两个时限都保持默认时,步骤超时会先触发,因为步骤比动作先开始,智能体会报告该步骤在 180 秒后超时。如果只调高 step_timeout,动作时限就会接管:它会在轮询中途取消识别,并报告浏览器可能无响应,还会提到一个已断开的 CDP WebSocket。在这里,这条消息是误导性的。浏览器没有问题;只是识别耗时超过了这个时限。把两个时限都设得比 SDK 的更高。
import os
# Before anything imports Agent or Tools from browser_use.
os.environ.setdefault("BROWSER_USE_ACTION_TIMEOUT_S", "330")
from browser_use import Agent # noqa: E402
# Up to 300 s of SDK polling (a few more for the last poll), one
# model call (90 s for Claude) and the page state, with room to spare.
agent = Agent(task="...", llm=llm, tools=tools,
extend_system_message=EXTRA, step_timeout=420)一个超时的步骤还会算作智能体的一次连续失败,连续五次就会让智能体停止,所以时限设得太紧,代价不止是一次重试。
在服务器上运行 CapSkip
这个工具运行在你的 Python 进程里,和智能体在一起。关键在于这个进程在哪里运行,而不是浏览器在哪里:即使会话的 cdp_url 指向另一台机器上的 Chrome,识别请求仍然是从你的脚本发往 CapSkip。脚本和 CapSkip 在同一台 Windows PC 上时,用 127.0.0.1 就对了。当智能体被搬到 VPS、CI runner 或另一台机器上的定时任务里时,回环地址就指向了错误的机器,工具会把一个 NetworkException 作为错误返回。把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP,这样智能体就能通过同一套 API 调用它。如果链路要经过公网,请使用静态公网 IP,打开 API 密钥校验,并用一条 Windows Firewall 规则限制端口。识别工具始终留在你自己的 Windows 机器上,无论智能体遇到多少验证码,识别都不计量。
SDK 不会自己读取环境变量。像完整示例那样,在你的代码里读取 CAPSKIP_HOST、CAPSKIP_PORT 和 CAPSKIP_API_KEY,并把它们传给 AsyncCapSkip。
完整可运行示例
# pip install browser-use "capskip>=1.3.0"
import asyncio
import json
import os
# Browser Use reads this once, when its tools load, so set it first.
os.environ.setdefault("BROWSER_USE_ACTION_TIMEOUT_S", "330")
from browser_use import ActionResult, Agent, BrowserSession, ChatAnthropic, Tools
from capskip import AsyncCapSkip, CapSkipError
solver = AsyncCapSkip(
apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
port=int(os.getenv("CAPSKIP_PORT", "8080")),
)
tools = Tools()
FIND = """() => {
const w = document.querySelector(
'.g-recaptcha[data-sitekey], .cf-turnstile[data-sitekey]');
if (!w) return null;
return {
kind: w.classList.contains('cf-turnstile') ? 'turnstile' : 'recaptcha',
sitekey: w.dataset.sitekey,
invisible: w.dataset.size === 'invisible' || w.tagName !== 'DIV',
enterprise: !!document.querySelector(
'script[src*="recaptcha/enterprise.js"]'),
callback: w.dataset.callback || null,
};
}"""
FILL = """(kind, token, callback) => {
const name = kind === 'turnstile' ? 'cf-turnstile-response'
: 'g-recaptcha-response';
const fields = document.querySelectorAll(`[name="${name}"]`);
fields.forEach(f => { f.value = token; });
if (callback && typeof window[callback] === 'function') {
window[callback](token);
}
return fields.length;
}"""
@tools.action(
"Solve the reCAPTCHA v2 or Cloudflare Turnstile widget on the current "
"page. Call it after the other fields are filled, then submit the form "
"unless the page has already moved on."
)
async def solve_captcha(browser_session: BrowserSession, page_url: str):
page = await browser_session.must_get_current_page()
raw = await page.evaluate(FIND)
widget = json.loads(raw) if raw else None
if not widget:
return ActionResult(error="No reCAPTCHA or Turnstile widget found.")
try:
if widget["kind"] == "turnstile":
result = await solver.turnstile(widget["sitekey"], page_url)
else:
extra = {"invisible": 1} if widget["invisible"] else {}
result = await solver.recaptcha(
widget["sitekey"], page_url,
enterprise=int(widget["enterprise"]), **extra)
except CapSkipError as exc:
return ActionResult(error=f"CapSkip did not solve it: {exc!r}")
filled = await page.evaluate(
FILL, widget["kind"], result["code"], widget["callback"])
if filled == "0":
return ActionResult(error="Solved, but no response field to fill.")
return ActionResult(
extracted_content=f"Solved the {widget['kind']} CAPTCHA on {page_url} "
"and filled in the token. Submit the form now, "
"unless the page has already moved on.",
)
EXTRA = (
"This browser does not solve CAPTCHAs on its own, whatever the rules "
"above say. When a page shows a reCAPTCHA or Turnstile widget, fill in "
"the other fields, call solve_captcha, then submit the form unless the "
"page has already moved on. Never click the CAPTCHA checkbox or "
"challenge yourself."
)
async def main():
agent = Agent(
task="Sign up at https://example.com/signup with YOUR_EMAIL, "
"then report what the page says.",
# Adaptive thinking lets Browser Use leave the tool choice to Claude.
llm=ChatAnthropic(model="claude-opus-5-5", thinking={"type": "adaptive"}),
tools=tools,
extend_system_message=EXTRA,
step_timeout=420,
)
history = await agent.run(max_steps=25)
print(history.final_result())
asyncio.run(main())我们用测试页面把这个循环完整跑了一遍,覆盖复选框小组件、绑定在按钮上的隐形小组件、Enterprise 页面和 Turnstile 小组件,并用一个脚本化的替身代替模型,让每个步骤都可预测。结果是:提示词里在默认规则之后出现了那段额外的话,solve_captcha 出现在模型可选的动作之中,token 落进了表单,data-callback 函数被触发,工具的指令传到了模型,提交也被接受。我们还抓取了 ChatAnthropic 发出的请求,确认自适应思考会把工具选择留给模型。换成真实模型,唯一的区别是由模型来决定何时调用工具,而描述和那段额外的话正是为此准备的。这两个方法背后的原始接口,详见 API 参考文档.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 智能体在有验证码的页面上等待、滚动或者放弃,从不调用这个工具 | 默认系统提示词说验证码会被自动识别,而没有任何东西纠正它 | 传入 extend_system_message,内容用第 2 步中的那段话 |
| 每个步骤都以 400 失败,提示该模型不支持这种 tool_choice 类型 | 创建 ChatAnthropic 时没有传 thinking,所以 Browser Use 强制指定了工具选择 | 给 ChatAnthropic 传入 thinking={"type": "adaptive"} |
| 某个步骤在 180 秒后超时 | 一次慢速识别加上模型调用,耗时超过了 step_timeout | 给 Agent 传入 step_timeout=420,同时调高动作时限 |
| 报错:Action solve_captcha timed out after 180s. The browser may be unresponsive (dead CDP WebSocket). | step_timeout 已经调高,识别耗时超过了单个动作的时限;浏览器本身没有问题 | 在 Browser Use 加载之前,把 BROWSER_USE_ACTION_TIMEOUT_S 设为 300 以上 |
| 变量已经设置了,时限却仍然是 180 秒 | 它是在 Agent 或 Tools 已经被导入之后才设置的 | 把赋值语句移到入口脚本的最顶部,放在每一个会引入 browser_use 的 import 之前 |
| 识别验证码期间,整个智能体都卡住了 | 同步的 CapSkip 客户端阻塞了浏览器连接所在的事件循环 | 改用 AsyncCapSkip,并 await 它的方法 |
| 工具报告没有找到小组件 | 页面用脚本渲染小组件且没有 data-sitekey,或者把它放进了 iframe,又或者用的是另一种验证码 | 在 DevTools 里从小组件自己发出的请求中读取 sitekey,并为该页面扩展 FIND 和 FILL;两者都在顶层文档中运行,所以 iframe 里的表单需要单独查找 |
| 工具报错:Solved, but no response field to fill | Turnstile 小组件用 data-response-field-name 给它的字段改了名,或者用 data-response-field 把字段关掉了 | 在 FIND 中读取该属性,并把 token 写进它指定的字段 |
| token 已经填入,站点却仍然拒绝提交 | token 在提交之前就过期了,或者表单在等待它在脚本中注册的回调 | 让智能体在提交前一刻再识别;如果 data-callback 为空,就调用页面自己的回调 |
| 页面在按钮上使用的是 reCAPTCHA v3 | FIND 把它当成了 v2 | 调用 solver.recaptcha 时传入 version v3 和页面的 action |
| 工具返回的错误是 NetworkException | CapSkip 没有在运行,或者主机和端口与智能体的运行位置不符 | 启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式 |
常见问题
Browser Use 自己能识别验证码吗?
只有在它的云端浏览器上才会:识别在 Browser Use 自己的代理中进行,库只负责等待。开源库本身不带任何能在你自己运行的浏览器上识别验证码的功能,无论那是本地 Chromium,还是通过 CDP URL 连接的你自己的 Chrome。本指南针对的正是这种场景。
能不能改为给智能体接入 CapSkip 的 MCP 服务器?
可以。Browser Use 能把 MCP 服务器的工具加载到它的 Tools 实例中,而 CapSkip 的 MCP 服务器为每种支持的类型都提供了一个识别工具。不过这些工具返回的是 token,所以模型随后得自己写一段脚本,把 token 写进页面。这样模型要做两个决定,而不是一个,而且第二个决定还得匹配每个页面的标记。自定义工具则把页面的读取和写入都留在代码里。MCP 路线适合那些你无法往里加代码的智能体,而 MCP 验证码识别页面 介绍了具体的接入方法。
hCaptcha 或整页 Cloudflare 挑战怎么办?
CapSkip 不识别 hCaptcha,所以 FIND 只匹配 reCAPTCHA 和 Turnstile 的类名,从不匹配 h-captcha 小组件。整页的 Cloudflare 挑战和 Turnstile 小组件是两套不同的流程:正如 Cloudflare Turnstile 识别器页面 所述,它需要挑战的 cData 和 chlPageData 值,以及随 token 一起返回的 user agent,所以这个工具按现在的写法并不覆盖它。其他 CapSkip 类型,比如极验(GeeTest)或 Capy Puzzle,可以作为同一个工具里的更多分支加进去。
运行在云端的智能体能连到我的识别工具吗?
可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,然后在智能体运行的地方从 CAPSKIP_HOST 读取这个地址,并传给 AsyncCapSkip。VPS、容器宿主机、CI runner 和云平台上的定时任务,都通过同一套 HTTP API 连接。如果链路要经过公网,请使用静态公网 IP 并配上防火墙规则。识别工具始终运行在你自己的硬件上,所以即使智能体长时间运行、每个页面都遇到验证码,也不会多花一分钱。
简短版结论
在你自己的浏览器上让 Browser Use 处理验证码,归结起来是四处改动;当智能体离开识别工具所在的机器时,还要再加第五处。注册一个 solve_captcha 工具:用 page.evaluate 读取 sitekey,await AsyncCapSkip,再把 token 写回去;如果小组件有回调,就调用它。用 extend_system_message 纠正默认规则,让智能体在提交前一刻调用这个工具。给 ChatAnthropic 传入自适应思考,让 Claude 接受请求。在 Browser Use 加载之前调高 BROWSER_USE_ACTION_TIMEOUT_S,并调高 Agent 上的 step_timeout,两者都要高于 SDK 的 300 秒。另外,只要智能体不在识别工具所在的机器上运行,就把 CapSkip 切换到 Server 模式。
- Python 包能处理的所有验证码类型: Python 验证码识别页面.
- 复选框小组件和隐形小组件如何运作: reCAPTCHA v2 识别页面.
一个连续浏览好几个小时的智能体,可能每隔一个页面就会遇到一次挑战;而 验证码绕过 工具运行在你自己的 Windows 机器上,会逐一识别这些挑战,而且不按次计费。
