如何在 Netlify Functions 中识别验证码(Node.js SDK)

在 Netlify Functions 里做验证码识别,不能放在响应请求的那个同步函数里。Netlify 会在 60 秒时停掉这个函数,而且不允许你调高这个上限,可一次 reCAPTCHA 识别可能要花好几分钟。改为在后台函数里识别,它有 15 分钟;让它自己提交表单,再把结果记录到 Netlify Blobs 里,由另一个函数来汇报。把 CapSkip Node.js SDK 指向以 Server 模式运行的识别工具,因为在 Netlify 函数里,127.0.0.1 是 Netlify 的机器,不是你的。然后处理重试:抛出异常的后台函数会再运行一次,写得不小心的话,就会把表单提交两次。
你需要什么
- CapSkip 运行在一台你能控制的 Windows 机器上,处于 Server 模式,配有静态公网 IP,且识别工具的端口可以从公网访问。Server 模式位于 连接设置中,其余部分由第 1 步讲解。
- 一个 Netlify 站点,函数放在 netlify/functions 里,用 Node.js 22.12 或更高版本构建,这是 @netlify/blobs 的要求。Netlify 会用你构建时所用的 Node.js 版本来运行函数。所有基于额度的套餐都提供后台函数,免费套餐也包括在内;旧版套餐各有不同,请查看你自己的套餐。
- capskip 包 1.3.0 或更高版本,外加用来存任务记录的 @netlify/blobs,以及用来读取页面的 cheerio。在函数内部,Blobs 不需要任何配置:Netlify 会替你填好站点和 token。
- 带验证码的那个页面的 URL。示例识别的是 reCAPTCHA v2,同样的结构适用于 CapSkip 支持的每一种类型。
# npm install capskip @netlify/blobs cheerio npm install capskip @netlify/blobs cheerio
为什么同步函数做不到
Netlify 给每种函数都设定了固定的执行时间上限,列在 它的函数配置文档中。三者都无法更改:
| 函数类型 | 执行时间上限 | 在这套方案中的职责 |
|---|---|---|
| 同步 | 60 秒 | 汇报任务结果 |
| 定时 | 30 秒 | 按定时启动任务 |
| 后台 | 15 分钟 | 识别,然后提交表单 |
现在拿它和 Netlify Functions 里的一次验证码识别比一比。SDK 等待 reCAPTCHA 答案的时间最长为 recaptchaTimeout(默认 300 秒),而 CapSkip 自己允许一个任务等 250 秒来获得空闲线程,再花另外 250 秒去识别。一次在清闲的下午只要 20 秒的识别,在所有线程都忙或者代理很慢时,就要 90 秒。同步函数一到 60 秒,Netlify 就会结束它。CapSkip 并不知道这一点,所以它会把任务做到底,而答案永远没人来取。
context.waitUntil 看起来像是一条出路,因为它能让函数在响应发出之后继续运行。但它不是。Netlify 的文档说,函数仍然只能运行到它的执行时间上限为止,异步工作也包括在内,所以交给 waitUntil 的识别照样会在 60 秒时终止。流式响应同样受这 60 秒上限的约束。
后台函数会立即用 202 回应调用方,然后继续运行最多 15 分钟。代价就在这个 202 里:没有人会收到函数的返回值。而且 reCAPTCHA token 在签发大约两分钟后就会过期,所以它不能干等着调用方来取。后台函数必须自己用掉这个 token,也就是提交表单,并留下一条记录,说明发生了什么。
第 1 步:把 SDK 指向以 Server 模式运行的识别工具
在 Netlify 函数里,127.0.0.1 就是函数自己的沙箱。用默认值构造的客户端在那里什么也连不上,会抛出带 ECONNREFUSED 的 NetworkException。把 CapSkip 切换到 Server 模式,让它监听你的公网 IP;如果那台 Windows 机器在路由器后面,就把端口转发给它;再在 Windows Firewall 中放行这个端口。
接下来决定谁可以连接。默认情况下,Netlify 函数发起连接的地址会随着 Netlify 扩缩容而变化,所以防火墙规则没法把它们列出来。Netlify 的 Private Connectivity 能给函数一组固定的 IP 供你放行,但它是 Enterprise 套餐的附加功能。没有它的话,负责把关的是 CapSkip 的 API Key Validation 设置:把它打开,为这个站点添加一个 API 密钥,并作为 apiKey 传入。每个 SDK 请求都会携带这个密钥,并且和调用的其余部分一样走明文 HTTP,所以要给这个函数单独一个密钥,删掉它也不会影响其他任何东西。
把地址和密钥作为环境变量存到 Netlify 里:进入 Project configuration,再进入 Environment variables,作用域要包含 Functions。这里有两条 Netlify 规则很容易让人栽跟头。在 netlify.toml 里声明的变量根本到不了函数。而且每次部署都会保留构建时设定的值,所以新的 CAPSKIP_HOST 要等你重新部署后才会生效。
import { CapSkip } from "capskip";
// The SDK does not read these by itself, so pass them in.
if (!process.env.CAPSKIP_HOST) throw new Error("CAPSKIP_HOST is not set");
const solver = new CapSkip({
apiKey: process.env.CAPSKIP_API_KEY, // a key from API Key Validation
host: process.env.CAPSKIP_HOST, // your public IP, Server mode
port: Number(process.env.CAPSKIP_PORT ?? 8080),
});这个检查要保留。如果变量缺失,host 就是 undefined,SDK 会一声不吭地回退到默认的 127.0.0.1,让你直接回到 ECONNREFUSED。在完整示例中,这个检查放在识别部分的 try 块里,所以变量缺失会被写进任务记录,而不是在函数还没来得及写记录之前就让它停下。
第 2 步:在后台函数里识别并提交
只要在函数的 config 里把 background 设为 true,它就成了后台函数。这个函数会先检查一个共享密钥,因为它的 URL 是公开的,而每个发给它的请求都会让你的识别工具干活。然后它获取页面,读取 sitekey 并识别:
// netlify/functions/solve-signup.mjs
import { getStore } from "@netlify/blobs";
import * as cheerio from "cheerio";
// solver from Step 1; PAGE_URL is the page with the CAPTCHA.
export default async (req) => {
if (req.headers.get("x-job-secret") !== process.env.JOB_SECRET) return;
const { jobId, email } = await req.json();
const jobs = getStore({ name: "captcha-jobs", consistency: "strong" });
const page = await fetch(PAGE_URL);
const $ = cheerio.load(await page.text());
const result = await solver.recaptcha($(".g-recaptcha").attr("data-sitekey"), PAGE_URL);
// Then claim the job and post the form, below.
};
export const config = { path: "/api/solve-signup", method: "POST", background: true };reCAPTCHA 的答案在 result.code 里。要立即使用它:有效期只有两分钟,所以负责识别的那次运行,就得是负责提交的那次运行。
再来看重试。后台函数以错误结束时,Netlify 会在一分钟后再运行它一次;如果那次也失败,就在两分钟后再运行一次。在表单提交之前,重试正是你想要的:一个新的页面,一次新的识别。在表单提交之后,重试就意味着第二次注册。所以函数在提交之前,会先在 Blobs 里认领这个任务:
// Only one run can create this key, so only one run posts.
const claim = await jobs.setJSON(`${jobId}-posted`, { at: Date.now() }, { onlyIfNew: true });
if (!claim.modified) return;
const form = $("form").has(".g-recaptcha");
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", email);
body.set("g-recaptcha-response", result.code);
const cookie = page.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
method: "POST",
headers: { cookie, referer: PAGE_URL },
body,
});
await jobs.setJSON(jobId, { state: "done", status: res.status });有了 onlyIfNew,只有当这个键还不存在时写入才会成功,而 modified 会告诉你这次运行是否创建了它。两次运行争抢同一个任务时,不可能都拿到 true,所以只有其中一次会提交。函数在开始之前也会检查这个键,所以已完成任务的重新运行会直接返回,不会浪费一次识别。
提交本身会发送页面的 cookie 和表单自身的字段,包括隐藏的 CSRF token,大多数注册表单除了验证码之外,检查的就是这些。它还会把页面作为 Referer 发送,因为 fetch 不会发送 Referer,而有些框架会拒绝没有它的 HTTPS 表单提交。一旦认领成功,之后的任何失败都要记录到 Blobs 里,而不是抛出异常。重试会停在认领这一步,所以抛出异常换不来任何东西,而这条记录是你的状态端点唯一能展示错误的地方。下面的完整示例就是这样把两个阶段包起来的。
第 3 步:启动任务并读取结果
在任意服务端代码里向后台函数发一个 POST 请求,就能启动一个任务。任务 id 由调用方自己生成,因为 202 没有响应体,没法把 id 带回来:
const jobId = crypto.randomUUID();
const start = await fetch("https://YOUR_SITE.netlify.app/api/solve-signup", {
method: "POST",
headers: { "x-job-secret": process.env.JOB_SECRET },
body: JSON.stringify({ jobId, email: "YOUR_EMAIL" }),
});
console.log(start.status); // 202: accepted, not solved yet由一个小小的同步函数来汇报这条记录。它几毫秒就能跑完,远在 60 秒上限之内:
// netlify/functions/job-status.mjs
import { getStore } from "@netlify/blobs";
export default async (req, context) => {
const jobs = getStore({ name: "captcha-jobs", consistency: "strong" });
const job = await jobs.get(context.params.id, { type: "json" });
if (!job) return new Response("unknown or not started", { status: 404 });
return Response.json(job);
};
export const config = { path: "/api/jobs/:id", method: "GET" };每隔几秒轮询一次。任务在完成或某次尝试失败之前没有记录,所以 404 表示它的第一次尝试还在运行,或者因为共享密钥不匹配而根本没有启动。要像这里这样用强一致性读取。Blobs 默认是最终一致的:新记录会立刻出现,但一次更新可能要花最多 60 秒才能传到每个边缘节点,这段时间足以让一个其实已经完成的任务仍然显示为正在重试。
要按定时运行任务,就用定时函数,但要留意它的上限:30 秒,只有同步函数的一半。让它把工作交给后台函数,自己在远不到一秒的时间内结束:
// netlify/functions/nightly-signup.mjs
export default async () => {
const res = await fetch(`${process.env.URL}/api/solve-signup`, {
method: "POST",
headers: { "x-job-secret": process.env.JOB_SECRET },
body: JSON.stringify({ jobId: crypto.randomUUID(), email: "YOUR_EMAIL" }),
});
console.log("queued:", res.status); // 202 means accepted, not solved
};
export const config = { schedule: "@daily" };URL 是 Netlify 在运行时提供给函数的只读变量之一:你站点的主地址。定时函数只会在已发布的部署上触发,不会在 Deploy Previews 或分支部署上触发。
完整可运行示例
// npm install capskip @netlify/blobs cheerio
// netlify/functions/solve-signup.mjs
import { getStore } from "@netlify/blobs";
import * as cheerio from "cheerio";
import { CapSkip } from "capskip";
const PAGE_URL = "https://example.com/signup";
export default async (req) => {
// Anyone can POST to this URL, so check a shared secret first.
if (req.headers.get("x-job-secret") !== process.env.JOB_SECRET) return;
const { jobId, email } = await req.json();
const jobs = getStore({ name: "captcha-jobs", consistency: "strong" });
if (await jobs.get(`${jobId}-posted`)) return; // already posted once
// Phase 1: fetch and solve. Throwing here is safe: Netlify runs
// the function again after one minute, then two minutes later.
let page, $, result;
try {
if (!process.env.CAPSKIP_HOST) throw new Error("CAPSKIP_HOST is not set");
const solver = new CapSkip({
apiKey: process.env.CAPSKIP_API_KEY,
host: process.env.CAPSKIP_HOST,
port: Number(process.env.CAPSKIP_PORT ?? 8080),
});
page = await fetch(PAGE_URL);
$ = cheerio.load(await page.text());
result = await solver.recaptcha($(".g-recaptcha").attr("data-sitekey"), PAGE_URL);
} catch (err) {
const prev = await jobs.get(jobId, { type: "json" });
const attempt = (prev?.attempt ?? 0) + 1;
// The first run plus two retries: after the third, nothing reruns.
const state = attempt < 3 ? "retrying" : "failed";
await jobs.setJSON(jobId, { state, attempt, error: String(err) });
throw err;
}
// Phase 2: post the form once. Claim the job first, so a rerun
// that reaches this line finds the claim taken and stops.
const claim = await jobs.setJSON(`${jobId}-posted`, { at: Date.now() }, { onlyIfNew: true });
if (!claim.modified) return;
try {
const form = $("form").has(".g-recaptcha");
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", email);
body.set("g-recaptcha-response", result.code);
const cookie = page.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
method: "POST",
headers: { cookie, referer: PAGE_URL },
body,
});
await jobs.setJSON(jobId, { state: "done", status: res.status });
} catch (err) {
// No rethrow: a retry could not post again, so record it here.
await jobs.setJSON(jobId, { state: "failed", error: String(err) });
}
};
export const config = { path: "/api/solve-signup", method: "POST", background: true };在 Netlify UI 中设置 JOB_SECRET、CAPSKIP_HOST、CAPSKIP_API_KEY,如果改过端口,再设置 CAPSKIP_PORT,然后部署,并像第 3 步那样启动一个任务。reCAPTCHA 调用接受的每个选项,包括隐形版和 Enterprise,在这里都能原样使用; reCAPTCHA v2 识别页面 介绍了这一类型所需的一切。
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 请求在大约一分钟后失败,表单也没有提交 | 识别是在同步函数里运行的,或者放在 waitUntil 里,而 waitUntil 同样受那 60 秒上限的约束 | 把识别挪到后台函数里 |
| 任务记录里写着 CAPSKIP_HOST is not set;如果你去掉了那个检查,则会抛出带 ECONNREFUSED 127.0.0.1:8080 的 NetworkException | 已部署的函数里缺少 CAPSKIP_HOST,所以 SDK 会回退到回环地址 | 设置该变量,作用域要包含 Functions,然后重新部署 |
| 用 netlify dev 时正常,一部署就失败 | 在本地,函数运行在你自己的机器上,那里的回环地址和本地 IP 都能访问到识别工具 | 对已部署的站点,使用 Server 模式和你的公网 IP |
| 来自 netlify.toml 的变量在函数里是 undefined | 在 netlify.toml 里声明的变量永远到不了函数 | 改为在 Netlify UI、CLI 或 API 中设置 |
| 函数仍在使用旧的 CAPSKIP_HOST | 一次部署会保留它构建时设定的值 | 修改变量后重新部署 |
| 连接一直挂着,随后抛出带 ETIMEDOUT 的 NetworkException | 端口没有转发,或者被 Windows Firewall 丢弃 | 转发端口,在 Windows Firewall 中放行它,并从你的网络外部测试一下 |
| 抛出 ApiException,提示 ERROR_KEY_DOES_NOT_EXIST | API Key Validation 已开启,而这个密钥不在 CapSkip 的列表里;或者 CAPSKIP_API_KEY 未设置,SDK 发送的是它的默认值 | 在 CapSkip 中添加这个密钥,并设置该变量 |
| 表单被提交了两次 | 提交之后的一个错误触发了重试,而没有任何东西拦住第二次运行 | 像第 2 步那样,在提交之前用 onlyIfNew 认领任务 |
| 状态端点一直返回 404 | 共享密钥不匹配,所以函数在写入任何内容之前就返回了 | 在调用方和站点中设置相同的 JOB_SECRET |
| 站点拒绝了 token | token 签发已超过约两分钟,或者已经被用过 | 识别完立即提交,每次提交用一个 token |
常见问题
在 Netlify Functions 里识别验证码,能调高 60 秒的上限吗?
不能。Netlify 把同步、定时和后台函数的上限都列为固定值,而 waitUntil 和流式响应都跳不出那 60 秒。能用上的长上限,就是后台函数的 15 分钟,覆盖 SDK 的 300 秒等待还绰绰有余。
Netlify 函数能访问我家里或办公室电脑上的 CapSkip 吗?
可以,通过 Server 模式。CapSkip 监听你的公网 IP,路由器把端口转发到那台电脑,函数则通过它在本地也会用的同一套 HTTP API 连接。静态公网 IP 能让 CAPSKIP_HOST 在多次部署之间保持有效。没有 Private Connectivity,你就无法按地址放行 Netlify,所以由 API Key Validation 来把关。
Netlify 调用 CapSkip 时,CapSkip 会按次收费吗?
不会。Server 模式改变的是识别工具可以从哪里访问,而不是由谁来运行它:它仍然是你自己的 Windows 机器,也不会统计识别次数。Netlify 计量的是函数运行时间,所以一个为一次识别等了两分钟的后台函数,就会用掉两分钟的运行时间。
这和在 AWS Lambda 上运行有什么不同?
限制条件不同。在 API Gateway 后面的 Lambda 上,那堵墙是 29 秒的集成超时,而带 Elastic IP 的 NAT gateway 能让每次识别都从同一个固定的源地址发出,方便你的防火墙放行。在 Netlify 上,那堵墙是 60 秒,后台函数就是平台内置的绕行办法,而且除非用上 Enterprise 的附加功能,否则没有固定地址。Lambda 的配置方法见 AWS Lambda 验证码识别指南.
简短版结论
在 Netlify Functions 里做验证码任务,千万不要在同步函数里识别:它的 60 秒上限是固定的,waitUntil 也绕不过去。要在后台函数里识别,趁 token 还新鲜,在同一次运行里提交表单,并且先用一次 onlyIfNew 写入认领任务,这样 Netlify 的两次重试就永远不会重复提交。通过一个小小的同步函数,从 Blobs 中汇报结果。以 Server 模式连接 CapSkip,地址和密钥放在作用域为 Functions 的变量里,并打开 API Key Validation。
- Node 包能识别的其他所有验证码类型: Node.js 验证码识别页面.
Netlify 提供函数,而识别始终留在你自己拥有的 Windows 机器上。这正是运行你自己的 验证码识别工具的意义所在:平台的计费表只计分钟,识别次数则没有任何东西去计。
