如何在 Crawlee 中用 Node.js SDK 处理验证码

Crawlee 没有验证码钩子,也不需要有。Crawlee 里的验证码是在你自己的 requestHandler内部识别的,就在你已经拿到浏览器页面的那次请求当中。有三件事让它成立:在花掉一次识别之前先检测小组件;调高处理器超时时间,因为默认值比一次 reCAPTCHA 识别还短;失败时抛出异常,让 Crawlee 通过自己的队列重试该请求,而不是靠你自己的循环。本指南以 PlaywrightCrawler 为例,把这三件事都演示一遍。
你需要什么
- Node.js 18 或更高版本,以及一个已经在抓取内容的 Crawlee 项目
- CapSkip 已运行且可访问。Local 模式监听 127.0.0.1 的 8080 端口,供同一台机器上的自动化程序使用;Server 模式则监听你的内网或公网 IP,让另一台机器、VPS 或容器宿主上的爬虫也能调用它。两种模式都在 连接设置
- 三个包一起安装
# One install for the crawler, the browser and the solver client. npm install crawlee playwright capskip # Crawlee drives a real browser, so fetch one. npx playwright install chromium
这里的示例是 CommonJS,也就是 CapSkip README 所记录的写法。Crawlee 3 同时提供两种构建产物,所以 ESM 项目可以改用 import 语句来引入爬虫。
识别放在哪里:requestHandler 内部
Scrapy 有下载器中间件,Selenium 有你自己写的那层封装。Crawlee 直接把 page 对象交给你,所以不需要再写一层拦截。你在同一个函数里检测挑战、识别它,然后继续往下走。
先检测。对每个页面都发起识别,会把资源浪费在根本没有出现挑战的页面上,还会掩盖一个有用的信号:你到底多久真的被拦一次。
// npm install crawlee playwright capskip
const { PlaywrightCrawler } = require('crawlee');
const { CapSkip } = require('capskip');
// Local mode. Point host at a server IP to share one solver.
const solver = new CapSkip({ host: '127.0.0.1', port: 8080 });
async function solveIfChallenged(page, url, log) {
const widget = page.locator('[data-sitekey]').first();
if ((await widget.count()) === 0) return false;
const sitekey = await widget.getAttribute('data-sitekey');
log.info(`Solving sitekey ${sitekey}`);
const result = await solver.recaptcha(sitekey, url);
return result.code; // the token
}data-sitekey 属性既出现在 reCAPTCHA v2 的小组件 div 上,也出现在 Turnstile 的 div 上,所以一个选择器就能同时覆盖两者。reCAPTCHA v3 没有可见的小组件,因此要改从 script URL 里读取这个 key。
注入 token,然后提交
识别会给你一个 token。页面仍然期待这个 token 出现在它自己的小组件本该填写的隐藏字段里,所以把它放进去,再像浏览器那样提交表单。
// The widget writes into a hidden textarea. Do the same.
await page.evaluate((token) => {
const field = document.getElementById('g-recaptcha-response');
field.value = token;
}, token);
// Then submit exactly as the page would, and wait for the result.
await Promise.all([
page.waitForNavigation(),
page.click('button[type=submit]'),
]);有些页面不提交表单,而是调用一个 JavaScript 回调。如果小组件 div 上带有 data-callback 属性,就用 token 去调用那个函数,而不要去点击任何东西,因为点击处理函数可能根本不会执行。
第一件事是调高 requestHandlerTimeoutSecs
这一条最容易让人栽跟头,而且看上去像是识别工具的问题,其实不是。
PlaywrightCrawler 给每个请求处理器分配的时间是 60 秒 ,这是默认值。reCAPTCHA v2 任务在最初的 15 到 20 秒里还没就绪,v3 需要 10 到 15 秒,而这还没算上加载页面、注入 token 以及等待跳转的时间。处理器会在识别进行到一半时被杀掉,Crawlee 记下一次超时,请求回到队列里从头再来一遍。
// npm install crawlee playwright capskip
const crawler = new PlaywrightCrawler({
// 60 is the default and it is shorter than a v2 solve plus a submit.
requestHandlerTimeoutSecs: 180,
// Three tries per URL, which is Crawlee's default and the right one.
maxRequestRetries: 3,
async requestHandler({ page, request, log }) {
// your handler
},
});180 秒是个合理的上限。它大约是一次正常识别耗时的十倍,并且刻意低于 SDK 为 reCAPTCHA 设定的 300 秒轮询上限,这样真正卡住的请求会由 Crawlee 放弃,而不是整整占用一个浏览器槽位五分钟。如果你更希望由识别客户端先放弃,就把 recaptchaTimeout 调到比处理器超时更小的值。
完整可运行示例
一个文件,一个爬虫,一条识别路径。把你自己的起始 URL 填进 run 调用里。
// npm install crawlee playwright capskip
const { PlaywrightCrawler, Dataset } = require('crawlee');
const { CapSkip } = require('capskip');
const solver = new CapSkip({ host: '127.0.0.1', port: 8080 });
const crawler = new PlaywrightCrawler({
requestHandlerTimeoutSecs: 180,
maxRequestRetries: 3,
async requestHandler({ page, request, log }) {
const widget = page.locator('[data-sitekey]').first();
if ((await widget.count()) > 0) {
const sitekey = await widget.getAttribute('data-sitekey');
const result = await solver.recaptcha(sitekey, request.loadedUrl);
await page.evaluate((token) => {
document.getElementById('g-recaptcha-response').value = token;
}, result.code);
await Promise.all([
page.waitForNavigation(),
page.click('button[type=submit]'),
]);
log.info(`Cleared the challenge on ${request.loadedUrl}`);
}
await Dataset.pushData({ url: request.loadedUrl, title: await page.title() });
},
});
await crawler.run(['https://example.com/page-with-recaptcha']);识别跑在你自己的机器上,所以上面这套重试预算除了墙上时钟时间之外不花任何成本。这就是它与计费服务的实际区别:在计费服务上,每个 URL 试三次就是一笔账。
让队列去重试,不要自己写循环
本能反应是把识别包进一个 for 循环里。不要这么做。Crawlee 已经有一套重试机制,它了解请求队列、会话池和代理配置,而你手写在处理器内部的循环对这三者都是不可见的。
改成抛出异常。处理器抛出异常后,请求会被送回队列,Crawlee 最多重试 maxRequestRetries 次,每次都用一个全新的浏览器上下文。
// npm install capskip
const { ApiException, NetworkException, TimeoutException } = require('capskip');
const crawler = new PlaywrightCrawler({
requestHandlerTimeoutSecs: 180,
// Runs between retries, while attempts remain.
errorHandler({ request, log }, error) {
log.warning(`Retry ${request.retryCount} for ${request.url}: ${error.message}`);
},
// Runs once, after the last attempt fails.
failedRequestHandler({ request, log }) {
log.error(`Gave up on ${request.url}`);
},
});抛出的是哪种异常,就说明该改什么。NetworkException 表示 CapSkip 不可达,先检查主机和端口,再去怪站点。TimeoutException 表示轮询窗口已经超时,页面提供的挑战多半比你以为的更难。ApiException 会带上返回的错误代码,这一种值得连同 URL 一起记录下来。
爬虫和识别工具跑在不同机器上
Crawlee 靠多开自己来扩容,而分散在多台机器上的爬虫集群不可能都去访问 127.0.0.1。答案是 Server 模式:CapSkip 不再监听回环地址,而是监听你的内网或公网 IP,每个 worker 都指向同一个地址。
// npm install capskip
const { CapSkip } = require('capskip');
// Same client, different address. Nothing else in the code changes.
const solver = new CapSkip({
host: process.env.CAPSKIP_HOST || '127.0.0.1',
port: Number(process.env.CAPSKIP_PORT || 8080),
});SDK 会自己从环境变量里读取 CAPSKIP_HOST 和 CAPSKIP_PORT,所以上面那个兜底写法只是为了防止容器启动时没有这两个变量。识别工具所在的机器建议配一个静态公网 IP,设置步骤见 连接设置。它依然是你自己的硬件,依然不计量收费,唯一变化的只是进程跑在哪里。
常见错误及其含义
| 症状 | 原因 | 修复 |
|---|---|---|
| requestHandler 在 60 秒后超时 | 默认的处理器超时时间比一次识别还短 | 把 requestHandlerTimeoutSecs 设为 180 |
| 识别成功了,页面仍然拦截 | token 填进去了,但表单从未被提交 | 检查是否有 data-callback 属性,有就调用它 |
ERROR_GOOGLEKEY | sitekey 属性为空,或者是从错误的元素上读来的 | 识别之前先把这个值打印出来;v3 的 key 在 script URL 里 |
ERROR_PAGEURL | 处理器传入的是相对 URL 或跳转之前的 URL | 改用 request.loadedUrl,也就是跳转之后的 URL |
| 每个请求都报 NetworkException | 爬虫访问不到识别工具 | Local 模式只监听回环地址;远程 worker 请改用 Server 模式 |
| 每个 URL 都重试三次,然后被丢弃 | 处理器在识别之前就抛出了异常 | 去看 errorHandler 的日志;第一次失败才是真正的原因 |
参数名和完整的错误代码列表见 API 文档.
常见问题
这套做法在 CheerioCrawler 上能用吗?
部分能用。CheerioCrawler 没有浏览器,所以既没有 page 对象,也没办法运行小组件自带的 JavaScript。你仍然可以从 HTML 里解析出 sitekey,识别它,再自己把 token 随表单体一起提交。这对纯粹的表单提交够用,对任何需要回调的场景就不够了。预计会遇到挑战时,请用 PlaywrightCrawler。
是不是应该改到 preNavigationHook 里去识别?
不应该。导航前钩子在页面加载之前运行,那时还没有东西可以检测。导航后钩子接近一些,但请求处理器才是你已经同时拿到页面、跳转后的 URL 和 logger 的地方。把识别留在那里,钩子留给 cookie 和请求头。
爬虫能跑在托管平台上,而识别工具留在家里吗?
可以,前提是 CapSkip 用 Server 模式。爬虫需要有一条能到达识别工具地址的路由,所以家庭宽带需要静态公网 IP 和开放端口,用 VPS 会更省事。两种情况下客户端代码完全一样:只是 host 的值不同。
一次抓取能同时压多少个识别任务?
Crawlee 会自动调节自己的并发,每个处理器各自 await 自己的识别,所以客户端这边没有队列需要配置。SDK 从 250 毫秒开始轮询,然后逐步退避到 pollingInterval 上限,即使同时有好几个任务在跑,识别得快结果依然回得快。你的 Crawlee 并发应该按目标站点能承受的程度来定,而不是按识别工具来定。
简短版结论
检测小组件,在请求处理器里识别,把处理器超时调到 180 秒,然后抛出异常让队列去重试。正因为识别工具是你自己在跑,重试三次才成为一个合理的默认值,而不是一笔成本账;这与在抓取的任何环节都使用本地 验证码绕过 的道理是一样的。 Node.js 集成指南 介绍了客户端的设置方法, Playwright 指南 讲的是 Crawlee 所继承的浏览器侧细节,而 网络爬虫验证码识别 则完整介绍了整个抓取过程中的会话处理。同样的模式在 Python 里的写法,请参见 Scrapy 中间件那篇文章.
