如何在 Crawl4AI 中识别验证码而不丢失会话

Crawl4AI 自己没有验证码识别功能,所以 Crawl4AI 的验证码流程是由你串起来的三次调用:在一个命名会话中加载页面,用外部识别工具识别 sitekey,然后回到同一个标签页填入 token 并提交。让人栽跟头的是 Crawl4AI 执行各步骤的顺序。从 0.8.5 版开始,js_code 在 wait_for 之后才运行,所以如果一次抓取在 js_code 里提交表单、再用 wait_for 等待下一个页面,它就会一直卡在那里,直到超时。提交应该放在 js_code_before_wait 里。本指南先用 reCAPTCHA v2 走一遍会话流程,然后介绍一个钩子,用于你无法控制每次调用的深度爬取。
你需要什么
- Python 3.10 或更高版本,以及 Crawl4AI 0.9 或更高版本。本文的所有内容都读自 0.9.4 的源码,并在该版本上实际运行过;第 3 步中的执行顺序从 0.8.5 起就是如此。
- CapSkip Python 包,它提供 AsyncCapSkip:一个真正异步的客户端,正好契合 Crawl4AI 本来就在用的事件循环。
- 带验证码的页面 URL,以及一个选择器,用来匹配只有表单提交成功后才会出现的元素。
- 在 Windows 机器上运行的 CapSkip。爬虫跑在同一台机器上时,Local 模式在 127.0.0.1 上响应;不在同一台机器上时,Server 模式监听你的网络地址或公网 IP。两种模式的说明见 连接设置.
# pip install crawl4ai pip install -U crawl4ai capskip # Downloads the browser Crawl4AI drives, once per machine. crawl4ai-setup
第 1 步:在会话中加载页面并读取 sitekey
给第一次调用传一个 session_id。这样调用返回后,标签页会保持打开,cookie 和小组件都完好无损,之后识别出的 token 就能落到当初请求它的那个页面里。不传的话,Crawl4AI 一拿到 HTML 就会关闭页面。
# pip install crawl4ai
import re
from crawl4ai import CrawlerRunConfig
PAGE_URL = "https://example.com/signup"
SESSION = "signup"
# The widget element, in any attribute order, with or without other classes.
WIDGET = r'<[^>]*class="(?:[^"]*\s)?g-recaptcha(?:\s[^"]*)?"[^>]*>'
async def read_sitekey(crawler):
# session_id keeps this tab open for the next arun call.
config = CrawlerRunConfig(session_id=SESSION)
first = await crawler.arun(PAGE_URL, config=config)
# Do not stop on first.success: a CAPTCHA page can be
# flagged as blocked while its HTML is complete.
widget = re.search(WIDGET, first.html)
match = widget and re.search(r'data-sitekey="([^"]+)"', widget.group(0))
return match.group(1) if match else None那条注释不是随便写的。Crawl4AI 0.9 会对每个结果执行反机器人检查,凡是被它判定为拦截页的结果,都会被标记为失败:success 返回 False,error_message 以 Blocked by anti-bot protection 开头。任何 403 或 503 的 HTML 响应都算,429 也算。对于其他错误状态码,小于 10 KB 的页面只要其小组件的 class 属性恰好是 g-recaptcha,也算。渲染后的 HTML 仍然在 first.html 里,里面仍然有你需要的 sitekey,所以在断定抓取失败之前先读一下它。
html 字段是渲染后的页面,所以由脚本构建的小组件也在里面。如果元素上没有 key,就去小组件 iframe 的 src 里找 k= 参数;如果小组件渲染得晚,就在第一次调用里加一个等待该 iframe 的 wait_for。
第 2 步:用 AsyncCapSkip 识别
Crawl4AI 从上到下都是 asyncio,所以要用异步客户端。它是真正的协程,而不是对阻塞调用的包装,这意味着一次耗时二十秒的识别,不会冻结同一事件循环上的其他所有抓取。
# pip install capskip
from capskip import AsyncCapSkip
solver = AsyncCapSkip(host="127.0.0.1", port=8080)
async def solve(sitekey):
# Invisible v2 takes invisible=1, Enterprise takes enterprise=1.
result = await solver.recaptcha(sitekey=sitekey, url=PAGE_URL)
return result["code"] # the g-recaptcha-response value这一步运行时,Crawl4AI 里没有任何东西在等待,因为识别发生在两次调用之间,而不是某一次调用之内。标签页就那么放着。真正有时限的是 token:reCAPTCHA token 签发后大约两分钟内有效,所以识别完要直接去提交。详见 reCAPTCHA token 的有效期能持续多久.
第 3 步:在同一个标签页中注入并提交
第二次调用复用这个会话并设置 js_only,让 Crawl4AI 在已有的页面里运行 JavaScript,而不是重新加载 URL。重新加载要多花一次页面加载,而这次加载可能又要从头面对挑战;它还会重置第一次加载时准备好的一切,比如一张填了一半的表单。
import json
async def submit(crawler, token):
# Crawl4AI wraps this in an async function, so statements work.
inject = (
"document.getElementById('g-recaptcha-response').value = "
f"{json.dumps(token)};"
"document.querySelector('form').submit();"
)
config = CrawlerRunConfig(
session_id=SESSION,
js_only=True, # same tab, no reload
js_code_before_wait=inject, # runs BEFORE wait_for
wait_for="css:.signup-complete",
)
return await crawler.arun(PAGE_URL, config=config)脚本之所以要放在 js_code_before_wait 里,原因如下。从 0.8.5 开始,以及在每个 0.9 版本中,一次抓取会先运行 js_code_before_wait,然后是 wait_for,最后才针对已完成的页面运行 js_code。所以如果你把提交放在 js_code 里,wait_for 会在任何东西提交之前就开始寻找下一个页面,并一直找到 page_timeout 耗尽(默认 60 秒),这时调用以 Wait condition failed 失败,表单始终没有发出。在同一次调用中用 js_code 提交、用 wait_for 等待的示例,包括 Crawl4AI 官方文档里的一个,在 0.9.4 上恰恰都会碰到这个问题。
用 json.dumps 构造字符串,而不是把 token 粘贴在引号之间,因为 JSON 字符串字面量同时也是合法的 JavaScript 字符串字面量,转义也算在内。你不必自己等待页面跳转:wait_for 会跨越跳转持续轮询,并在下一个页面上找到 .signup-complete。Crawl4AI 不会做的,是在脚本失败时抛出异常。语法错误会留下一行日志,但运行时错误(比如元素 ID 拼错了)会被悄无声息地吞掉,连日志都没有,只会表现为 wait_for 超时。所以在怪罪识别工具之前,先在真实页面的 DevTools 控制台里测试这两条语句。
有些站点从不读取 textarea。它们向小组件注册一个回调,并从回调里提交,所以要把那两条语句换成对 data-callback 属性中所指定函数的调用,并把 token 传进去。底层仍然是普通的 reCAPTCHA v2,具体说明见 reCAPTCHA v2 识别工具页面.
第 4 步:用钩子在深度爬取中识别
当每次 arun 调用都由你掌控时,会话流程可以处理 Crawl4AI 的验证码。深度爬取或 arun_many 批量任务做不到这一点,所以要改为把识别挂到 after_goto 钩子上。它在导航完成之后、wait_for 之前,在 Playwright 页面上运行,爬虫导航到的每个 URL 都会运行它(js_only 调用会跳过它),所以没有小组件的页面会直接通过。
from capskip import CapSkipError
async def after_goto(page, context, url, response, **kwargs):
holder = await page.query_selector("div.g-recaptcha[data-sitekey]")
if holder is None:
return page # no widget, nothing to do
sitekey = await holder.get_attribute("data-sitekey")
try:
result = await solver.recaptcha(sitekey=sitekey, url=page.url)
except CapSkipError:
return page # keep the page HTML if the solve fails
async with page.expect_navigation():
await page.evaluate(
"t => { document.getElementById('g-recaptcha-response').value = t;"
" document.querySelector('form').submit(); }", result["code"])
return page
crawler.crawler_strategy.set_hook("after_goto", after_goto)关于这条路线,有几件事需要知道。钩子在抓取过程中运行,所以识别耗时会算到那个页面上;多个页面同时遇到小组件时,各自等待自己的识别,AsyncCapSkip 会并行处理它们。钩子对整个爬虫全局生效,每个页面都会运行它,所以检查要尽量轻量。另外,try 块很重要:逃出钩子的异常会让该 URL 的整个抓取失败,而且完全不返回 HTML,所以要是没有它,一次 CapSkip 故障就会把深度爬取中每个受保护的页面都清空。还有一个坑:Crawl4AI 保留的是第一个响应的状态码。以 403 返回的挑战,即使钩子已经识别了它、result.html 也已是挑战背后的页面,结果仍然是 success 为 False 并带有 Blocked by anti-bot protection。对这些 URL,要检查 html 中有没有你要的内容,而不是相信 success。Crawl4AI 在 自家的钩子页面上记录了每个钩子及其参数。同一个钩子适用于任何由 Playwright 驱动的爬虫,更全面的介绍见 Playwright 验证码识别页面.
爬虫在别处时,CapSkip 跑在哪里
抓取规模一大,Crawl4AI 往往就会跑到 Linux 服务器或容器里,而 CapSkip 是一个 Windows 应用,所以两者经常位于不同的机器上。Server 模式就是为此而设的。它让 CapSkip 监听你的网络地址或公网 IP,而不是 127.0.0.1,这样爬虫在任何能路由到它的地方,都能通过同一套 HTTP API 调用它。如果这条链路要经过公网,请使用静态公网 IP,打开 API 密钥校验,并用一条 Windows Firewall 规则把端口限制为只对爬虫的地址开放。它仍然是你自己拥有的机器,在上面识别也仍然不计量。
SDK 不会自己读取环境变量。像完整示例那样,在你自己的代码里读取 CAPSKIP_HOST、CAPSKIP_PORT 和 CAPSKIP_API_KEY 并传进去。
有一个 Crawl4AI 的细节需要提前考虑:从 0.9.0 开始,它的 Docker 服务器在通过网络收到 session_id、js_code 和 js_code_before_wait 时,会以 HTTP 400 拒绝,而且不再接受钩子代码。本指南中的两条路线都无法通过 REST API 实现,所以请在你自己的 Python 进程里以库的方式运行。
完整可运行示例
# pip install crawl4ai capskip
import asyncio
import json
import os
import re
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
from capskip import AsyncCapSkip
PAGE_URL = "https://example.com/signup"
SESSION = "signup"
WIDGET = r'<[^>]*class="(?:[^"]*\s)?g-recaptcha(?:\s[^"]*)?"[^>]*>'
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")),
)
async def main():
async with AsyncWebCrawler() as crawler:
first = await crawler.arun(
PAGE_URL, config=CrawlerRunConfig(session_id=SESSION))
widget = re.search(WIDGET, first.html)
match = widget and re.search(r'data-sitekey="([^"]+)"', widget.group(0))
if not match:
raise RuntimeError(f"no sitekey found: {first.error_message}")
result = await solver.recaptcha(sitekey=match.group(1), url=PAGE_URL)
inject = (
"document.getElementById('g-recaptcha-response').value = "
f"{json.dumps(result['code'])};"
"document.querySelector('form').submit();"
)
done = await crawler.arun(PAGE_URL, config=CrawlerRunConfig(
session_id=SESSION,
js_only=True,
js_code_before_wait=inject,
wait_for="css:.signup-complete",
wait_for_timeout=30000,
))
print(done.success) # True once the next page has loaded
asyncio.run(main())wait_for_timeout 给提交后的等待单独设了一个时限,所以一次没有下文的提交会在 30 秒后失败,而不是等满 page_timeout 的 60 秒。把 .signup-complete 换成任何只在表单之后的页面上才存在的元素。Python 生态的其余部分,包括 Selenium 和普通 HTTP 客户端,见 Python 验证码识别页面.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 大约 60 秒后报 Wait condition failed,表单始终没有提交 | 提交放在了 js_code 里,而从 0.8.5 起它在 wait_for 之后才运行 | 把脚本移到 js_code_before_wait |
| first.success 为 False,并带有 Blocked by anti-bot protection | Crawl4AI 的拦截检查把验证码页面当成了拦截页 | 照样从 first.html 读取 sitekey;页面是完整的 |
| 第二次调用时 wait_for 超时,html 是一个空白页面 | session_id 不一致,所以 Crawl4AI 打开了一个新的空标签页 | 两次调用使用同一个 session_id |
| token 已在 textarea 里,站点却说验证码未通过 | 站点通过 data-callback 函数提交,或者 token 已过期 | 用 token 调用该回调,并在识别后两分钟内提交 |
| 没有任何报错,然后等待超时 | 注入脚本在页面内抛出了异常,而 Crawl4AI 会悄无声息地吞掉它 | 先在 DevTools 控制台里运行脚本,并检查它用到的 ID |
| Crawl4AI Docker 服务器返回 HTTP 400 | 服务器拒绝通过网络传来的 session_id 和脚本字段 | 在你自己的 Python 进程里运行这个库 |
| 识别时抛出 NetworkException | CapSkip 没有在运行,或者主机和端口指向了错误的机器 | 启动 CapSkip;爬虫在别的机器上时,使用 Server 模式 |
| 识别时抛出 TimeoutException | 识别耗时超过了 recaptchaTimeout | 在构造函数里把它调到默认的 300 秒以上 |
常见问题
Crawl4AI 自己能识别验证码吗?
不能。它能检测到验证码,也就是说它的反机器人检查会把验证码页面标记为一次被拦截的抓取;页面被拦截时,它还能通过一组代理重试。magic 和 simulate_user 选项会加入反机器人系统所关注的鼠标移动和滚动,这能降低出现挑战的可能。但这些都无法在小组件出现在页面上之后识别它,而这正是外部识别工具要填补的空白。关于这一空白,另见 面向网络爬虫的验证码识别页面.
同样的流程适用于 Cloudflare Turnstile 吗?
对于小组件,适用。用 sitekey 和页面 URL 调用 solver.turnstile,然后把 token 写进名为 cf-turnstile-response 的隐藏 input,而不是 reCAPTCHA 的 textarea。整页挑战则是另一回事,因为它们的 token 必须和 CapSkip 返回的 user agent 一起使用,所以提交 token 的浏览器必须呈现同一个 user agent。这种情况详见 Cloudflare Turnstile 识别器页面.
我的爬虫跑在容器里,CapSkip 该装在哪?
装在一台你自己控制的 Windows 机器上,并打开 Server 模式。之后容器就像访问任何其他内部服务一样,通过 API 访问它,所以爬虫和识别工具不需要共用操作系统。把这台 Windows 机器的地址作为 CAPSKIP_HOST 传入,打开 API 密钥校验,再给爬虫发一个它专用的密钥,这样就能单独吊销它。
两次调用之间,会话能保持打开多久?
远比一次识别需要的时间长。在 0.9.4 的源码中,浏览器管理器会清理闲置了 30 分钟的会话,而关闭爬虫会关闭所有会话。实际起作用的时限是 token 的有效期,大约两分钟,所以识别和提交应该紧挨着进行。
简短版结论
用一段话概括整个 Crawl4AI 验证码流程:带上 session_id 加载页面,并从 HTML 中读取 sitekey,即使 Crawl4AI 把这次抓取判定为被拦截也照读不误。用 AsyncCapSkip 识别它。用 js_only=True 回到同一个标签页,在 js_code_before_wait 里填入 token 并提交,再用 wait_for 加上它自己的超时等待下一个页面。深度爬取时,在 after_goto 钩子里做同样的事。爬虫运行在另一台机器上时,把客户端指向 Server 模式的地址。
最后还有一点,它会改变你规划抓取规模的方式。由于这套 验证码绕过 方案运行在你已经拥有的机器上,每个页面都遇到小组件的抓取,和只遇到一次的抓取花费一样,所以没有理由仅仅因为某个 URL 挡在挑战后面就跳过它。
