如何在 Trigger.dev 后台任务中识别验证码

trigger.dev captcha - How to Solve CAPTCHAs in a Trigger.dev Background Task

在 Trigger.dev 里做验证码识别,第一次部署就会失败,原因和代码本身毫无关系。Trigger.dev 不会调用你的应用。它把你的任务构建成 Docker 镜像,跑在它自己的机器上,所以任务内部的 127.0.0.1 指的是那个容器,而不是你的识别工具所在的机器。服务器模式一个设置就能解决。第二件要弄对的事是 maxDuration,因为轮询识别工具的时间会全额计入这个预算。

你需要什么

  • 一个装好 SDK 的 Trigger.dev 项目,根目录下有 trigger.config.ts。
  • 在一台 Windows 机器上运行的 CapSkip,并把 Node 客户端加进同一个项目,这样它才会进入部署出来的镜像里。
  • sitekey 和页面 URL 通过任务的 payload 传入,而不是硬编码,这样一个任务就能服务所有表单。
  • 服务器模式,以及一个能被访问到的识别工具地址。在 Trigger.dev Cloud 上这不是可选项,原因见第 1 步。
# npm install capskip
npm install @trigger.dev/sdk capskip

第 1 步:任务究竟跑在哪里,以及这需要哪种模式

大多数平台教程都可以把这一点留到最后再讲。这一篇不行,因为它决定了其他一切能不能成立。Trigger.dev 官方对部署的描述很直白:代码被打包成 Docker 镜像并部署到你的 Trigger.dev 实例,每一次运行都在他们托管的隔离环境里执行。你的任务并不跑在你的编辑器所在的地方。

所以 run 函数内部的回环地址解析到的是任务容器。那里面没有任何东西在监听 8080 端口,于是每一次尝试都会因为连接被拒绝而抛出 NetworkException。

连接模式有两种。本地模式绑定 127.0.0.1,只响应本机,当你的自动化程序和识别工具在同一台机器上时,这是正确的选择。服务器模式绑定你的内网地址或公网 IP,这样另一台机器、一台 VPS 或某个托管平台就能通过 API 访问到同一台 Windows 机器。服务器模式只改变识别工具监听哪个地址。它依然是你自己的硬件,也依然不按次计费。两种模式都位于 连接设置.

你以哪种方式运行 Trigger.dev用哪种连接模式
dev CLI,跑在 CapSkip 所在的机器上Local 模式。127.0.0.1 在这里确实是对的
自托管,跑在你自己的内网里Server mode,填识别工具的内网地址
Trigger.dev CloudServer mode,配一个固定公网 IP 加一条防火墙规则

第三种情况值得准备一个固定公网 IP,这样地址就不会在部署运行期间变来变去。把地址放进环境变量而不是源码里,因为 dev CLI 和部署后的任务需要的值并不相同。

// npm install capskip
import { CapSkip } from "capskip";

// 127.0.0.1 while the dev CLI runs it on your machine,
// the solver's reachable address once it is deployed.
export const solver = new CapSkip({
  host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
  port: Number(process.env.CAPSKIP_PORT ?? 8080),
  recaptchaTimeout: 120,
});

第 2 步:maxDuration 必须覆盖识别的耗时

Trigger.dev 以秒为单位用 maxDuration 来衡量一次运行,文档规定的最小值是五。被排除在外的项目也写得很明确:花在 wait.for、triggerAndWait 和 batchTriggerAndWait 上的时间不计入。被 await 的 HTTP 请求不在这个名单里,所以客户端轮询识别工具的每一秒都会被全额计入。

这一点很重要,因为识别的大部分时间都花在等待上。一次 reCAPTCHA v2 识别通常要十五到四十五秒,队列繁忙时还会更久。设置 maxDuration 时要把识别的耗时算进预算,而不是只算任务的其余部分。

// trigger.config.ts sets the project-wide floor.
import { defineConfig } from "@trigger.dev/sdk";

export default defineConfig({
  project: "proj_YOUR_PROJECT_REF",
  maxDuration: 60,
});

// A solving task overrides it. 60s is not enough on its own:
// the solve alone can use most of that budget.
export const solveAndSubmit = task({
  id: "solve-and-submit",
  maxDuration: 300,
  run: async (payload) => { /* ... */ },
});

客户端自身的上限要设在它之下,这样客户端会先放弃,并抛出一个你能读懂的错误。Node 客户端对 reCAPTCHA、Turnstile 和极验(GeeTest)默认是 300 秒,对图片验证码是 120 秒。在 maxDuration 为 300 的前提下,把 reCAPTCHA 的客户端超时下调到 120,就给任务拿到 token 之后要做的事留出了余地。

有一个坑值得单独点出来。因为 wait.for 不计入 maxDuration,它看上去像是一种免费的暂停方式。但对 token 来说它并不免费。一个 reCAPTCHA token 的有效期大约是两分钟的真实时间,而平台的计时和 token 的计时是两套时钟。识别和使用要放在同一段代码里,另外先读一遍 reCAPTCHA token 能维持多久 这篇说明,再围绕它去设计流程。

第 3 步:重试,以及哪些错误值得重试

任务默认重试三次,采用指数退避,可以通过 factor、minTimeoutInMs、maxTimeoutInMs 和 randomize 配置。CLI 生成的配置在 DEV 环境里关闭了重试,这就是为什么一个在生产环境会重试的任务,在你本机上看起来像是立刻就失败了。

重试的任务会把整个 run 函数重新跑一遍,所以会重新识别一次。不存在继承下来的过期 token,这让默认值显得合理。真正值得调的是:哪些失败才配得到一次尝试。

哪种异常含义值得重试吗?
NetworkExceptionCapSkip 无法访问,或正在重启值得。重试就是为这种情况准备的
TimeoutException轮询超出了客户端自身的上限也许可以重试一次,但很少值得试三次
ApiExceptionAPI 返回了一个错误码取决于错误码。通常不值得
ValidationException参数错了,再试一次还是错不重试。抛出 AbortTaskRunError

AbortTaskRunError 会让这次尝试失败并禁用重试,格式错误的请求就该这么处理。一个错误的 sitekey 不会到第三次就变成正确的,而三次尝试每次九十秒,等于花四分半钟来证明这件事。

import { task, AbortTaskRunError } from "@trigger.dev/sdk";
import { ValidationException } from "capskip";

try {
  const { code } = await solver.recaptcha(sitekey, pageUrl);
  return await postForm(pageUrl, code);
} catch (err) {
  // Wrong parameters will be wrong on all three attempts.
  if (err instanceof ValidationException) {
    throw new AbortTaskRunError(err.message);
  }
  throw err;   // everything else takes the normal backoff
}

第 4 步:完整的任务

上面所有内容合在一个文件里。客户端在模块作用域里构造,所以每个容器只构造一次,而不是每次运行都构造一次,而且它不携带任何单次运行的状态。

// npm install capskip
import { task, AbortTaskRunError } from "@trigger.dev/sdk";
import { CapSkip, ValidationException } from "capskip";

const solver = new CapSkip({
  host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
  port: 8080,
  recaptchaTimeout: 120,
});

export const submitSignup = task({
  id: "submit-signup",
  maxDuration: 300,
  retry: { maxAttempts: 3, minTimeoutInMs: 2000 },
  queue: { concurrencyLimit: 10 },
  run: async (payload, { ctx }) => {
    const { sitekey, pageUrl, email } = payload;

    try {
      // Solve and submit together. The token is short lived.
      const { code } = await solver.recaptcha(sitekey, pageUrl);
      const res = await postSignup(pageUrl, email, code);
      return { status: res.status, runId: ctx.run.id };
    } catch (err) {
      if (err instanceof ValidationException) {
        throw new AbortTaskRunError(err.message);
      }
      throw err;
    }
  },
});

这个调用针对的是 reCAPTCHA v2。其他类型的写法完全一样:把 invisible 或 enterprise 设为 1,或者把 version 设为 v3 并带上一个 action,又或者改为调用 turnstile 或 geetest。完整的接口请见 Node.js 验证码识别页面.

第 5 步:并发,以及真正的上限在哪里

queue 选项限制一个任务同时执行多少次运行。如果用的是按次计费的识别服务,这个数字其实是一道花钱的闸门,大家也正是因为这个才把它设得很低。在这里它是关于一台机器的容量问题,所以应该按识别工具和目标站点能承受的量来设,而不是按你的预算来设。

真正限制它的只有两件事:跑 CapSkip 的那台 Windows 机器,以及你提交的目标站点在开始限流之前能接受多快的请求。通常第二条更紧。不会有请求排在余额后面等着,也不会到月底突然全都失败。

export const submitSignup = task({
  id: "submit-signup",
  // Sized for the solver machine and the target site,
  // not for a credit balance.
  queue: { concurrencyLimit: 10 },
  maxDuration: 300,
  run: async (payload) => { /* ... */ },
});

常见错误及其含义

你所看到的原因修复
用 dev CLI 一切正常,部署之后出现 NetworkException部署后的任务是一个容器,所以回环地址指的就是这个容器用服务器模式,并为部署环境设置 CAPSKIP_HOST
运行在识别进行到一半时被中止maxDuration 比识别实际需要的时间还短在任务上调高它,设得比客户端自身的超时更大
任务在 DEV 里立刻失败,在生产环境却会重试生成的配置在 DEV 里关闭了重试这是预期行为。请在部署环境里测试重试逻辑
三次尝试,报同样的错,白白耗掉几分钟参数错误被当成了临时性故障遇到 ValidationException 时抛出 AbortTaskRunError
经过一次 wait.for 之后 token 被拒绝等待对 maxDuration 是免费的,对 token 不是在等待之后再识别,紧接着就提交
ApiException 里带着 ERROR_WRONG_USER_KEY部署环境里没有设置 CAPSKIP_API_KEY在 Trigger.dev 的环境变量里设置它,然后重新部署
手写轮询时返回 CAPCHA_NOT_READY结果还没完成就被读取了交给客户端轮询,它会自己退避

最后那个响应就是这么拼的,少掉的那个字母不是我们这边的笔误,因为 API 返回的确实就是这个写法。完整解释见 一篇关于 CAPCHA_NOT_READY 响应的完整说明.

常见问题

Trigger.dev Cloud 上的任务能访问到我桌上那台机器里的识别工具吗?

可以,用服务器模式。任务跑在 Trigger.dev 托管的容器里,所以那里的回环地址指的是这个容器。在连接设置里把 CapSkip 绑定到你的公网 IP,在它前面加一条只放行预期地址的防火墙规则,再到 Trigger.dev 的环境变量里设置 CAPSKIP_HOST。建议使用固定公网 IP,这样地址不会在你不知情的时候变掉。

自托管 Trigger.dev 会改变这些吗?

变的是地址,不是模型。自托管的运行同样是在实例上的容器里执行你的代码,而不是调用你的应用,所以回环地址仍然是容器。区别在于实例通常就在你自己的内网里,所以服务器模式可以用 LAN 地址而不是公网地址,也不需要任何一条防火墙规则面向公网。

识别该不该单独做成一个任务,供其他任务调用?

通常不该。拆开意味着 token 要跨越任务边界,在父任务恢复期间一直躺在 payload 里,而这是最快用上一个已经过期的 token 的办法。把识别和消费 token 的那段逻辑放在同一个 run 函数里,并且返回结果而不是凭证。只有当它返回的东西根本不是 token 时,单独做一个任务才说得通。

这和在 Inngest 里做有什么不同?

部署模型正好相反,这也改变了全部答案。Inngest 通过 HTTP 调用你的应用,所以你的代码跑在你自己部署的地方,连接模式取决于你自己的托管方式。Trigger.dev 在它自己的机器上跑你的代码,所以在云端方案里,服务器模式是已经替你定好的。Inngest 版本,包括为什么在那里识别必须放在一个 step 里,见 Inngest 指南.

简短版结论

让 CapSkip 以服务器模式运行,并在 Trigger.dev 环境里设置 CAPSKIP_HOST,因为部署后的任务是一个容器,那里的回环地址就是这个容器。给任务一个能覆盖识别耗时的 maxDuration,因为轮询 HTTP 端点并不属于被排除的那几种等待。客户端的超时要设在它之下。默认的三次重试可以保留,但遇到参数错误要抛出 AbortTaskRunError。识别和提交放在同一个 run 函数里,绝不要跨越一次等待或一道任务边界。

在选定并发上限之前值得掂量一下:CapSkip 作为一款 验证码识别工具 直接跑在你已经拥有的硬件上,所以你选的这个数字是容量决策,而不是预算决策。