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

在 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 Cloud | Server 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,这让默认值显得合理。真正值得调的是:哪些失败才配得到一次尝试。
| 哪种异常 | 含义 | 值得重试吗? |
|---|---|---|
| NetworkException | CapSkip 无法访问,或正在重启 | 值得。重试就是为这种情况准备的 |
| TimeoutException | 轮询超出了客户端自身的上限 | 也许可以重试一次,但很少值得试三次 |
| ApiException | API 返回了一个错误码 | 取决于错误码。通常不值得 |
| 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 API 文档.
- 勾选框挑战本身的说明,请见 reCAPTCHA v2 识别页面.
在选定并发上限之前值得掂量一下:CapSkip 作为一款 验证码识别工具 直接跑在你已经拥有的硬件上,所以你选的这个数字是容量决策,而不是预算决策。
