如何在 Apify Actor 里识别验证码(Node.js)

apify captcha - How to Solve CAPTCHA Inside an Apify Actor (Node.js)

Apify 里的验证码环节还是老三步,外加一步是这个平台特有的。读出 sitekey,识别它,把 token 随表单提交回去。多出来的那一步是决定识别工具放在哪,因为 Actor 不是跑在你的笔记本上。它跑在 Apify 基础设施上的一个容器里,所以容器里的回环地址只属于那个容器,别的谁都不是。把这一个决定做对,剩下的也就二十行代码。

你需要什么

  • Node.js 18 或更高版本、Apify CLI,以及一个 Apify 账号。
  • Actor 里已安装 apify 和 capskip 两个包。
  • CapSkip 以 Server 模式运行在一台 Actor 能访问到的机器上,配一个固定公网 IP,并打开密钥校验。你在自己机器上开发时,Local 模式依然可用。两种模式都写在 连接设置.
# Scaffold an Actor, then add the solver client.
apify create captcha-actor -t getting_started_node
cd captcha-actor
npm install capskip

这里 Server 模式不是可选项

这一点最容易把人绊倒,所以放在最前面。Actor 在 Apify 的 worker 上执行。当你的 Actor 代码去连 127.0.0.1:8080 时,它是在跟自己的容器说话,而那个容器上并没有跑识别工具,于是这次调用带着一个看起来像识别工具崩了的连接错误失败了。什么都没崩。只是这个地址本地指向的机器不对。

CapSkip 有两种连接模式,正是为了这种情况。Local 模式绑定到 127.0.0.1,只回应该设备本身。Server 模式绑定到你的内网或公网 IP,这样另一台机器、一台 VPS,或者像 Apify 这样的托管平台,都能通过 API 调用同一个识别工具。固定公网 IP 能让这个地址在多次运行之间保持稳定。

有件事直说比较好,因为总有人问:Server 模式并不会把 CapSkip 变成一个按量计费的云服务。它还是你自己的机器,也依然无限量。变的只是它监听在哪个网络接口上。一旦它监听在网络地址上,就把密钥校验打开,并给这个 Actor 发一个专属密钥,这样吊销它不会打扰到别的东西。

第 1 步:把识别工具的地址声明为加密输入项

别把 host 写死在代码里。Apify 的输入 schema 支持加密字段,识别工具的地址和它的密钥正该放在这儿,而且这样一来这些值是每次运行时设置的,而不是被烤进一次构建里。加密对 textfield、textarea 和 hidden 这几种编辑器都有效。

{
  "title": "CAPTCHA actor input",
  "type": "object",
  "schemaVersion": 1,
  "properties": {
    "targetUrl": {
      "title": "Target URL",
      "type": "string",
      "editor": "textfield"
    },
    "solverHost": {
      "title": "Solver host",
      "type": "string",
      "editor": "textfield",
      "isSecret": true
    },
    "solverKey": {
      "title": "Solver API key",
      "type": "string",
      "editor": "textfield",
      "isSecret": true
    }
  },
  "required": ["targetUrl", "solverHost"]
}

那个文件放在 .actor 文件夹里,跟 actor.json 挨着,这些值会通过 input 对象进到你的代码里。

第 2 步:在运行时选择 host

你想要的是一个在两边都能用的 Actor:本地跑的时候跟 127.0.0.1 说话,部署之后跟你的服务器说话。SDK 正好为此提供了一个布尔值。代码在 Apify 平台上执行时 Actor.isAtHome() 返回 true,不在平台上时返回 false。

// npm install apify capskip
import { Actor } from 'apify';
import { CapSkip } from 'capskip';

await Actor.init();
const input = await Actor.getInput();

// Local run talks to the loopback address. A platform run
// talks to the server address that came in as a secret.
const solver = new CapSkip({
  host: Actor.isAtHome() ? input.solverHost : '127.0.0.1',
  port: 8080,
  apiKey: input.solverKey,
});

Actor 里别的地方都不用知道这个区别。同一条代码路径把两种情况都处理了,部署失败也不再意味着要去改常量。

第 3 步:读出 sitekey,识别它,提交 token

sitekey 就在宿主文档上,是一个 data-sitekey 属性,所以一次普通的 fetch 加一个正则就能拿到,不用启动浏览器。这在一个付费平台上很重要:不带浏览器的 Actor 需要的内存少得多,而 Apify 是按内存乘以时间计费的。下面的 Actor.fail() 会直接结束这次运行,而不是返回,所以它后面的代码可以认定匹配已经成功。

// Fetch the form page and lift the sitekey out of it.
const html = await (await fetch(input.targetUrl)).text();
const match = html.match(/data-sitekey="([^"]+)"/);

if (!match) {
  await Actor.fail('No sitekey on the page. Did the widget render?');
}

const result = await solver.recaptcha(match[1], input.targetUrl);
const token = result.code;   // the g-recaptcha-response value

然后把 token 放进站点期待的那个字段里,提交表单。对 reCAPTCHA v2 来说,隐藏的那个 textarea 名叫 g-recaptcha-response,大多数表单也用同一个名字把它提交上去。如果页面是先把 token 交给一个回调,那就去看看这个回调实际提交的是什么。

// The token travels as an ordinary form field.
const body = new URLSearchParams({
  email: '[email protected]',
  'g-recaptcha-response': token,
});

const posted = await fetch(input.targetUrl, { method: 'POST', body });
await Actor.pushData({ url: input.targetUrl, status: posted.status });

Turnstile 和极验(GeeTest)在同一个客户端上各有自己的方法,两者的调用形态也一样。Turnstile 还会返回一个 user agent,在挑战页面上必须连同 token 一起发出去。完整的参数列表见 CapSkip API 文档.

完整可运行示例

整个 Actor,也就是 src/main.js。Actor 是一个带顶层 await 的 ES module,所以没有包裹函数,而结尾的 Actor.exit() 负责把 dataset 刷出去并干净地结束这次运行。

// npm install apify capskip
import { Actor } from 'apify';
import { CapSkip, NetworkException, TimeoutException } from 'capskip';

await Actor.init();

const input = await Actor.getInput();
const solver = new CapSkip({
  host: Actor.isAtHome() ? input.solverHost : '127.0.0.1',
  port: 8080,
  apiKey: input.solverKey,
});

try {
  const html = await (await fetch(input.targetUrl)).text();
  const match = html.match(/data-sitekey="([^"]+)"/);
  if (!match) throw new Error('No sitekey found on the page.');

  const result = await solver.recaptcha(match[1], input.targetUrl);

  const body = new URLSearchParams({
    'g-recaptcha-response': result.code,
  });
  const posted = await fetch(input.targetUrl, { method: 'POST', body });

  await Actor.pushData({ url: input.targetUrl, status: posted.status });
} catch (err) {
  if (err instanceof NetworkException) {
    await Actor.fail('Cannot reach the solver. Check Server mode and the host.');
  }
  if (err instanceof TimeoutException) {
    await Actor.fail('The solve outlasted recaptchaTimeout.');
  }
  throw err;
}

await Actor.exit();

把两类跟连接有关的异常分开捕获,这六行代码值得写。Actor.fail() 就是带上退出码 1 和一条消息的 Actor.exit(),所以这次运行会以 FAILED 结束,日志里还留下一句话,告诉你是配置的哪一半出了问题。不这么写的话,识别工具根本访问不到,和识别确实读不出来,产生的是同一次红色运行。

部署之前先在本地跑一遍

先在你自己机器上跑这个 Actor,识别工具用 Local 模式。isAtHome() 在那里返回 false,所以代码会自己去找 127.0.0.1,你什么都不用改,而且能在加上那一跳网络之前,先证明 sitekey 的读取和表单提交是通的。

# Reads INPUT from storage/key_value_stores/default.
apify run

这一步过了,就把 CapSkip 切到 Server 模式,记下它现在监听的地址,把那个地址填进平台上的 solverHost 输入项。变的只有一个字符串。

常见错误及其含义

你所看到的原因修复
平台上抛 NetworkException,本地却从来不抛Actor 调了 127.0.0.1,连到的是它自己的容器把识别工具切到 Server 模式,并把它的地址传进去
ERROR_WRONG_USER_KEY密钥校验开着,而 Actor 发过去的密钥不对把密钥设成加密输入项,并从 input 里读它
ERROR_GOOGLEKEY正则什么都没匹配到,发出去的是一个空 key在花掉一次识别之前先检查匹配结果
运行状态 TIMED-OUT这次运行的超时时间短于 fetch 加识别加提交的总耗时在 Actor 的默认运行选项里把超时调大
TimeoutException识别耗时超过了 recaptchaTimeout把它调到默认的 300 秒以上
表单拒绝了一个识别本身没问题的 tokentoken 在识别完成到提交之间过期了在提交前一刻才识别,不要在运行一开始就识别

常见问题

Apify Actor 真的能访问到我自己机器上的识别工具吗?

能,用的就是它访问任何内部服务时用的那套 API。Server 模式让 CapSkip 监听在你的内网或公网 IP 上,而不是回环地址,于是 Actor 就像调用任何其他 HTTP 端点那样调它。建议配一个固定公网 IP,好让地址不会在多次运行之间变来变去;在开放端口之前,密钥校验应该先打开。

在 Apify 上的 Crawlee 爬虫里也能这么做吗?

能,而且那三步一模一样。区别在于它们放在哪:读 sitekey 和注入 token 放进请求处理器里,而识别工具的客户端在处理器外面只创建一次,让每个请求共用同一个实例。爬虫这边的具体细节,包括为什么不该给一次识别再套一层你自己的重试循环,都写在 Crawlee 验证码教程.

我该通过 Apify Proxy 来识别吗?

只有当站点会给完成识别的那个 IP 打分时才需要。reCAPTCHA、Turnstile 和极验都支持代理,而它的意义在于 token 会被拿去和请求它的那个地址核对。把代理传在识别调用上,而不是让整个识别工具都走它,这样在你需要的时候,抓取和识别可以走不同的出口。其中的取舍,见 验证码代理轮换.

Actor 该配多少内存?

比你以为的要少,只要你不启动浏览器。上面那个 Actor 抓 HTML、等一次网络调用、提交一个表单,所以它大部分时间都闲着,根本不需要 Chromium 那种量级的占用。只有当页面不跑 JavaScript 就不肯交出 sitekey 时,才动用浏览器。不管哪种情况,识别本身都发生在识别工具那台机器上,不过在等待期间,你的 Actor 仍然在跑。

简短版结论

把识别工具放到 Server 模式,把它的地址和密钥存成加密输入项,让 Actor.isAtHome() 在那个地址和 127.0.0.1 之间做选择。然后读出 sitekey,识别它,再把 token 作为 g-recaptcha-response 提交上去。想了解 Node 这边更完整的图景,包括 Puppeteer 和 Playwright,见 Node.js 验证码识别页面。同一个问题在爬取那一侧的内容,见 网络爬虫页面.

在把 Actor 的规模拉大之前,有一个后果值得说清楚。因为这套 验证码绕过 方案跑在你本来就有的硬件上,所以从十次运行涨到一千次,动的是你的 Apify 账单,识别这边的账单还在原地。