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

azure functions captcha - How to Solve CAPTCHAs in an Azure Function (C# Isolated)

在 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 的实例,不是你的机器。

在你确定 batchSize 之前,有一点值得掂量: 验证码绕过 ,用 CapSkip 的话它跑在你已经拥有的机器上,所以并发识别的上限取决于那台机器能扛多少,而不是取决于这个月的账单允许多少。