如何在 Azure Function 中识别验证码(C# 独立工作进程)

在 Azure Functions 里做验证码识别会出问题,而原因是你的代码永远不会告诉你的。无论你把超时配成多少,一个 HTTP 触发的函数都只有 230 秒来响应一个请求,因为这个限制来自平台前面的负载均衡器。CapSkip 客户端里 reCAPTCHA 的轮询超时默认是 300 秒。所以一次慢的识别是被 Azure 掐断的,不是被你的函数掐断的,而日志里只会看到一个请求就这么结束了。修法是干脆不要在 HTTP 请求里做识别。另一件要弄对的事是回环地址,因为函数应用跑在 Azure 的机器上,不是跑在你的机器上。
你需要什么
- 一个 .NET 独立工作进程的函数应用。进程内模型的支持将于 2026 年 11 月 10 日结束,所以应该基于独立工作进程模型来构建。
- CapSkip 运行在一台 Windows 机器上,处于服务器模式,地址是函数应用能访问到的。
- 一个存储账户,因为下面这套做法会把识别挪到一个队列触发的函数里。
- sitekey 和页面 URL 通过队列消息传进来,而不是写死在代码里,这样一个函数就能服务所有表单。
# dotnet add package CapSkip dotnet add package CapSkip dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues
第 1 步:函数应用里的回环地址指的就是这个函数应用
这件事值得最先定下来,因为它决定了其余部分能不能跑通。你的函数运行在 Azure 分配的实例上,所以函数内部的 127.0.0.1 指的是那个实例。那里的 8080 端口上没有任何东西在监听,于是故障表现为部署后第一次识别就抛出 NetworkException,而同样的代码在本地工具链下完全正常。
连接模式有两种。本地模式绑定 127.0.0.1,只响应本机,当你的自动化程序和识别工具在同一台机器上时,这是正确的选择。服务器模式绑定你的内网地址或公网 IP,这样另一台机器、一台 VPS 或某个托管平台就能通过 API 访问到同一台 Windows 机器。服务器模式只改变识别工具监听哪个地址。它依然是你自己的硬件,也依然不按次计费。两种模式都位于 连接设置.
| 你以什么方式运行函数 | 用哪种连接模式 |
|---|---|
| 本地工具链,在 CapSkip 所在的机器上 | Local 模式。127.0.0.1 在这里确实是对的 |
| 已部署,识别工具位于 Azure 可以路由到的网络里 | 服务器模式,使用那个内网地址 |
| 已部署,通过公网访问识别工具 | Server mode,配一个固定公网 IP 加一条防火墙规则 |
当识别工具所在的网络是 Azure 可以路由到的网络时,有两个 Azure 功能值得了解。用于出站流量的虚拟网络集成在 Flex Consumption、Premium 和 Dedicated 计划上可用,在老的 Consumption 计划上则完全不可用。混合连接(Hybrid Connections)是专门为访问留在你自己网络里的服务而设计的,在 Premium 和 Dedicated 计划上、并且应用运行于 Windows 时可用。无论走哪条路,识别工具都留在你自己的硬件上,变的只是路由。
把地址放进应用程序设置而不是源码里,因为本地工具链和部署后的应用需要的值不一样。客户端不会自己读取任何环境变量,所以要在你的代码里读取 CAPSKIP_HOST 并传进去,就像下面的函数那样。
// dotnet add package CapSkip
using CapSkip;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
// One client for the app. The host is an application
// setting, so local and deployed can differ.
builder.Services.AddSingleton(new CapSkipClient(
host: Environment.GetEnvironmentVariable("CAPSKIP_HOST") ?? "127.0.0.1",
port: 8080));
builder.Build().Run();第 2 步:230 秒这堵墙,以及为什么它不是你设的那个超时
这一段会白白浪费掉你一个下午,因为你在门户里能看到的每一个数字,都比真正掐断请求的那个数字要大。
Microsoft 的文档写得很明白:无论函数应用的超时设置是多少,一个 HTTP 触发的函数响应一个请求最多只能用 230 秒,而这个限制之所以存在,是因为 Azure 负载均衡器上的默认空闲超时。你没法通过 host.json、通过应用程序设置,或者通过换一个计划来把它调高。
现在把客户端自己的数字放到旁边对比一下。reCAPTCHA、Turnstile 和 GeeTest 的轮询超时默认是 300 秒,图片验证码的超时默认是 120 秒。所以一次图片识别能宽裕地待在这堵墙里面,而一次 reCAPTCHA 识别却被允许比这堵墙多跑 70 秒。大多数识别远在这两个数字之前就完成了,这正是它能顺利上线到生产、然后在慢的那条尾巴上翻车的原因。
| Limit | 值 | 你能改它吗? |
|---|---|---|
| HTTP 响应,任何计划 | 230 秒 | 否 |
| 客户端 reCAPTCHA 轮询超时 | 300 秒 | 可以,在构造函数上改 |
| 客户端图片验证码轮询超时 | 120 秒 | 可以,在构造函数上改 |
把 reCAPTCHA 的轮询超时降到 230 秒以下无论如何都值得做,因为先放弃的客户端会抛出一个你可以记录下来的 CapSkip.TimeoutException,而不是留下一个凭空消失的请求。不过这不是真正的修法。真正的修法就是 Azure 文档给出的那个:使用 Durable Functions 的异步模式,或者把实际工作推迟掉并立即返回一个响应。落到实处就是:HTTP 触发器接下这个任务,写一条消息,然后马上返回。
[Function(nameof(EnqueueSolve))]
[QueueOutput("captcha-jobs")]
public SolveRequest EnqueueSolve(
[HttpTrigger(AuthorizationLevel.Function, "post")] SolveRequest req)
{
// Returns in milliseconds. The solve happens on the
// queue-triggered function, off the HTTP request.
return req;
}第 3 步:函数应用的超时是另一个独立的限制
一旦识别离开了 HTTP 请求,真正有意义的超时就是 host.json 里的那个,而它随计划不同而不同。除了老的 Consumption 计划,其他地方的默认值都很宽裕,而那个计划正是一次慢的 reCAPTCHA 识别真的可能不够用的地方。
| 托管计划 | 默认超时 | 最长超时 |
|---|---|---|
| Flex Consumption 计划 | 30 分钟 | 没有强制上限 |
| Premium 计划 | 30 分钟 | 没有强制上限 |
| Dedicated 计划 | 30 分钟 | 没有强制上限,前提是开启 Always On |
| Consumption 计划,旧版 | 5 分钟 | 10 分钟 |
五分钟正好是 300 秒,所以老的那个计划在默认值下根本盖不住一次 reCAPTCHA 超时:函数恰好在客户端本来要放弃的那一刻挂掉。如果你还在用那个计划,就把它调高,并让客户端自己的超时保持在你设定的值以下。
{
"version": "2.0",
"functionTimeout": "00:10:00"
}第 4 步:一条队列消息会被重新识别多少次
把识别挪到队列上能换来余量,同时它也带来了自己的一套重跑行为,放着不管会实实在在地浪费掉不少时间。
当一个队列触发的函数失败时,Azure Functions 会针对那条消息最多运行这个函数五次,这五次里包含第一次尝试。如果五次全都失败,运行时会把这条消息写进一个以原队列名加 poison 后缀命名的队列。如果失败的原因是那种永远不会成功的问题,比如 sitekey 根本不属于这个页面,那就是一个验证码花掉五次识别。
情况比看上去还要紧。host.json 里的 visibilityTimeout 默认是零,意思是失败的消息会立刻重新出现,所以那五次尝试可能在几秒之内一次接一次地跑完。把它设成一个能让临时性故障有时间恢复的值,并在函数里读取出队次数,这样对处在最后一次尝试上的消息就可以做不同的处理。
另一半是并发。默认情况下触发器一次取 16 条消息,然后只要处理中的数量降到 8,就马上再取 16 条。那 8 条在新一批开始时还在跑,所以单个实例上同一个函数可以同时跑 24 次识别。当应用横向扩展时,这个数字还要乘以实例数。在按次计费的服务上,你会为了保住余额而给它封顶。在这里它是一个关于单台 Windows 机器容量的问题,但每个实例 24 个并发识别,仍然是一个值得你主动做出、而不是被动继承的决定。
下面的示例刻意压在这两个默认值之下:三次尝试而不是五次,一批八条而不是十六条。
{
"version": "2.0",
"extensions": {
"queues": {
"batchSize": 8,
"newBatchThreshold": 4,
"visibilityTimeout": "00:00:30",
"maxDequeueCount": 3
}
}
}完整可运行示例
队列触发的那一半,识别和提交在同一次调用里完成。
// dotnet add package CapSkip
using CapSkip;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
public class SolveCaptcha(CapSkipClient solver, ILogger<SolveCaptcha> log)
{
[Function(nameof(SolveCaptcha))]
public async Task Run([QueueTrigger("captcha-jobs")] SolveRequest job)
{
try
{
// Solve and submit together. The token is short lived.
var result = await solver.RecaptchaAsync(job.Sitekey, job.PageUrl);
await SubmitFormAsync(job.PageUrl, result.Code);
}
catch (CapSkip.ValidationException ex)
{
// A bad sitekey fails identically on all five tries.
log.LogError("Not retryable: {Message}", ex.Message);
}
}
}那次调用是 reCAPTCHA v2。其他类型的形态完全一样:传一个 options 字典,把 invisible 或 enterprise 设为 1,或者把 version 设为 v3 并带上一个 action,又或者改为调用 TurnstileAsync 或 GeetestAsync。完整的接口列表见 C# 验证码识别页面.
挑战页面上的 Turnstile 是唯一一个值得知道的例外,因为它需要从页面里多取两个值,另外还需要识别工具当时用的 user agent。那一种有 一篇专门的指南.
把参数错误吞掉而不是重新抛出,是有意为之。抛出异常正是启动那五次尝试循环的东西,而对于一个和页面对不上的 sitekey,重试什么也做不了。
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| HTTP 请求在大约四分钟时结束,没有任何错误 | 是 230 秒的负载均衡器限制,不是你设的超时 | 立即返回,把识别放到队列触发器里做 |
| 本地工具链下正常,一部署就报 NetworkException | 函数应用里的回环地址指的是 Azure 的那个实例 | 用服务器模式,并在应用程序设置里设置 CAPSKIP_HOST |
| 五次一模一样的失败,然后 poison 队列里多了一条消息 | 一个不可重试的错误被抛到了函数外面 | 改为捕获 CapSkip.ValidationException 并把它记录下来 |
| 五次尝试在不到一分钟里就烧完了 | 队列的 visibilityTimeout 默认是零 | 设置一个 visibilityTimeout,让重试之间拉开间隔 |
| 编译失败,报 TimeoutException 有歧义 | CapSkip 和 System 都定义了这个短名称 | 写出完整限定名,或者捕获基类 CapSkipError |
| ApiException 里带着 ERROR_WRONG_USER_KEY | 部署后的应用里没有设置 CAPSKIP_API_KEY | 把它加到应用程序设置里,然后重启应用 |
| 手写轮询时返回 CAPCHA_NOT_READY | 结果还没完成就被读取了 | 交给客户端轮询,它会自己退避 |
最后那个响应就是这么拼的,少掉的那个字母不是我们这边的笔误,因为 API 返回的确实就是这个写法。完整解释见 一篇关于 CAPCHA_NOT_READY 响应的完整说明.
常见问题
Azure 上的函数应用能访问到我自己网络里的识别工具吗?
可以。在连接设置里把 CapSkip 切换到服务器模式,让它监听一个网络地址而不是回环地址,然后在应用程序设置里把 CAPSKIP_HOST 指向它。如果要走私有路由,虚拟网络集成在 Flex Consumption、Premium 和 Dedicated 计划上可用,混合连接则在 Premium 和 Dedicated 计划上、并且应用运行于 Windows 时可用。如果你改走公网,就用一个静态公网 IP,再加一条只允许你预期的那些地址的防火墙规则。以上任何一种做法里,识别工具本身都不会离开你自己的硬件。
为什么我 HTTP 触发的识别在大约四分钟时就死掉了?
因为一个 HTTP 触发的函数完成响应的上限就是 230 秒,而它来自负载均衡器,不是来自 Functions。没有任何计划、host.json 的值或应用程序设置能把它调高。如果你想在同一个请求里拿到结果,就得让识别在这个窗口内留有余量地完成,也就是把客户端 reCAPTCHA 的轮询超时从默认的 300 调低,并接受慢的识别会失败。更好的答案是把这份工作交给一个队列触发的函数,然后马上返回。
这件事我需要用 Durable Functions 吗?
只有在调用方必须轮询结果时才需要。Durable Functions 给了你带内置状态接口的异步 HTTP 模式,当有浏览器或合作方系统在等这个结果时,它是值得的。如果识别只是你自己流水线里的一步,存储队列更简单,而且同样能让你绕开 230 秒的限制。不管走哪条路,都要把识别和消费 token 的代码放在同一次调用里,因为 token 过期很快,而编排边界正是最容易输掉这场比赛的地方。
为什么我的 catch 块编译不过?
因为客户端定义了 TimeoutException 和 ValidationException,而这两个短名称在 System 里同样存在,并且一个函数文件几乎总是把两个命名空间都引入了作用域。请完整写出 CapSkip.TimeoutException 和 CapSkip.ValidationException,或者捕获基类 CapSkipError 再在里面分支处理。另外两个,NetworkException 和 ApiException,没有冲突,可以直接用短名称捕获。
简短版结论
不要在 HTTP 触发器里做识别。不管 functionTimeout 写的是多少,请求都会在 230 秒被负载均衡器掐断,而客户端 reCAPTCHA 的超时默认比这还长。接下任务,写一条队列消息,立即返回,然后在队列触发的函数里做识别。把队列重试的间隔拉开,并捕获参数错误,否则一个写错的 sitekey 就会在一个重试根本解决不了的问题上白白烧掉五次尝试。把 CapSkip 切换到服务器模式,并把它的地址放进应用程序设置,因为函数应用内部的回环地址指的是 Azure 的实例,不是你的机器。
- 客户端背后的原始端点,文档见 CapSkip API 文档.
- 勾选框挑战本身的说明,请见 reCAPTCHA v2 识别页面.
在你确定 batchSize 之前,有一点值得掂量: 验证码绕过 ,用 CapSkip 的话它跑在你已经拥有的机器上,所以并发识别的上限取决于那台机器能扛多少,而不是取决于这个月的账单允许多少。
