如何在 Hangfire 后台作业中识别验证码(.NET)

hangfire captcha - How to Solve CAPTCHAs in a Hangfire Background Job (.NET)

要在 Hangfire 后台作业中识别验证码,就在作业方法内部调用 CapSkip .NET 客户端,在同一个作业里提交 token,并把 Hangfire 的 CancellationToken 传给识别调用。这部分只要十行代码。真正要花功夫的是 Hangfire 的两个默认设置,它们适合普通作业,却不适合 Hangfire 验证码作业:一是对任何异常都自动重试十次,前后跨度约四个半小时;二是一个最多二十个 worker 的池,而异步识别从头到尾仍会占着其中一个。本指南涵盖作业本身、一条知道哪些失败值得重试的重试规则、一个按 CapSkip 容量设定大小的队列、停机处理,以及把 Hangfire 托管在与识别工具相同的 Windows 机器上。

你需要什么

  • 在一台 Windows 机器上运行的 CapSkip。Hangfire 可以跑在同一台机器上,也可以从另一台机器调用它。
  • Hangfire 1.8,存储不限。示例使用 SQL Server 存储,这是 Windows 上的常见选择;下文的重试过滤器需要 1.8.0 或更高版本。
  • CapSkip NuGet 包。每个识别方法都接受一个可选的 CancellationToken,正是它让停机时能干净利落地终止识别。
  • .NET 8 或更高版本。示例使用了主构造函数,这是随 C# 12 引入的特性。
  • 识别工具的地址。Local 模式只在 127.0.0.1 上响应,仅供本机使用;Server 模式监听你的网络地址或公网 IP,这样另一台机器上的 Hangfire 就能通过 API 调用它。两者都位于 连接设置.
# dotnet add package CapSkip
dotnet add package CapSkip
dotnet add package Hangfire.NetCore
dotnet add package Hangfire.SqlServer
dotnet add package Microsoft.Data.SqlClient

Hangfire.NetCore 会带来 Hangfire.Core,以及 AddHangfire 和 AddHangfireServer 这两个注册方法。如果是还需要仪表板的 Web 应用,就改为添加 Hangfire.AspNetCore。Hangfire.SqlServer 1.8 本身不带 SQL 客户端,所以清单里才有 Microsoft.Data.SqlClient。

第 1 步:在一个作业里完成识别和提交

把识别和表单提交放进同一个作业方法;本指南中的每个 Hangfire 验证码作业都建立在这条规则上。reCAPTCHA token 的有效期大约两分钟,所以提交不能排在队列里等其他工作,而用 ContinueJobWith 创建的延续作业恰恰就会这样。重试也是一样:每次运行都必须重新识别,绝不能接着使用之前某次尝试保存下来的 token 或 captcha id。

// dotnet add package CapSkip
using CapSkip;

public class SignupJob(CapSkipClient solver, HttpClient http)
{
    public async Task RunAsync(string pageUrl, string sitekey, CancellationToken ct)
    {
        // Solve and post together: the token lasts about 2 minutes.
        var result = await solver.RecaptchaAsync(sitekey, pageUrl, cancellationToken: ct);

        var form = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["g-recaptcha-response"] = result.Code,
        });
        // No ct here: once the submit starts, let it finish.
        var response = await http.PostAsync(pageUrl, form);
        response.EnsureSuccessStatusCode();   // a rejected post fails the job
    }
}

用普通值把它加入队列。Hangfire 会把参数序列化到存储里,所以要传字符串,绝不要传客户端对象,并传入 CancellationToken.None 作为占位符。Hangfire 会在作业运行前把它换成真正的 CancellationToken。

BackgroundJob.Enqueue<SignupJob>(job =>
    job.RunAsync("https://example.com/signup", "YOUR_SITEKEY", CancellationToken.None));

Hangfire 会从你的服务容器中构建 SignupJob,所以要像完整示例那样,把 CapSkip 客户端注册为单例,并给作业一个类型化的 HttpClient。这个客户端除了自身的设置之外不保存任何东西,所以一个实例可以安全地在所有 worker 之间共享。这里的表单字段只是示意;目标表单实际发送什么,你就提交什么。

第 2 步:替换默认的重试规则

Hangfire 会给每个作业套上一个自动重试过滤器。默认情况下,它对任何异常都重试十次,第 n 次重试前的延迟是 (n 减 1) 的四次方秒,再加 15 秒,再加一个随机量,从第一次失败到最后一次失败,加起来大约四个半小时。这对一台时好时坏的邮件服务器很合适。但它不适合验证码作业:有些失败值得再试一次,另一些则每次都会以完全相同的方式失败。

SDK 抛出的异常常见原因值得重试吗?
CapSkip.TimeoutException识别时间超过了 recaptchaTimeout(默认 300 秒),或者在 SDK 轮询期间 CapSkip 无法访问或发生了重启是
NetworkException作业提交任务时 CapSkip 无法访问是
带 ERROR_CAPTCHA_UNSOLVABLE 的 ApiException这次尝试失败了,或者在 CapSkip 内部超时;下一次未必如此是
带其他任何错误码的 ApiExceptionsitekey 或页面 URL 格式错误,或者 API 密钥被 CapSkip 拒绝否,每次都会以同样的方式失败
ValidationException你的代码传了该方法不接受的选项否,这是代码 bug

这张表有一个局限:CapSkip 只能检查 sitekey 的格式是否正确。一个格式正确、却被 Google 拒绝的 key,照样会显示为 ERROR_CAPTCHA_UNSOLVABLE,所以作业会先把重试次数用完,然后才失败。

Hangfire 1.8 给重试特性加上了 OnlyOn,用来把重试限制在你列出的异常类型上。ApiException 在上表中同时对应一个临时性的行和一个永久性的行,所以作业会把永久性的那种转换成一个不在列表上的异常类型:

[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 120, 600 },
    OnlyOn = new[] { typeof(CapSkip.TimeoutException),
                     typeof(NetworkException), typeof(ApiException) })]
public async Task RunAsync(string pageUrl, string sitekey, CancellationToken ct)
{
    SolveResult result;
    try
    {
        result = await solver.RecaptchaAsync(sitekey, pageUrl, cancellationToken: ct);
    }
    catch (ApiException ex) when (!ex.Message.Contains("ERROR_CAPTCHA_UNSOLVABLE"))
    {
        // Not on the OnlyOn list, so Hangfire fails the job at once.
        throw new InvalidOperationException($"CapSkip refused the task: {ex.Message}", ex);
    }
    // ...post the form as in Step 1.
}

Attempts 计的是重试次数,所以这里是运行一次,再加最多三次重试。用完尝试次数、或者抛出了列表之外异常的作业,会进入 Failed 状态并停在那里,供你检查。要把 CapSkip.TimeoutException 写全:在 .NET 6 或更高版本项目的隐式 using 下,System.TimeoutException 也在作用域内,只写短名称会编译不过。ApiException 的消息就是 CapSkip 的原始回复,所以按错误码匹配才行得通,错误码的完整列表见 API 参考文档.

第 3 步:给识别单独的队列和 worker 数量

一个 Hangfire 服务器运行 Environment.ProcessorCount 乘以 5 个 worker,上限为 20。把作业改成异步,并不能在识别等待期间释放 worker:Hangfire 在 worker 线程上运行每个作业,并在那里一直等到 Task 完成。所以同时进行二十个识别,就意味着二十个 worker 在识别期间一直被占着,应用里的其他所有作业,包括密码重置邮件,都得排在它们后面。

CapSkip 自己也有上限。在应用设置里,reCAPTCHA 的 Max. Threads 默认为 10,超出的任务会在 CapSkip 内部等待空闲线程。等待时间超过 reCAPTCHA 的 Wait Timeout(默认 250 秒)的任务,会以 ERROR_CAPTCHA_UNSOLVABLE 判为失败。所以要让每个 Hangfire 验证码作业都走一个专属队列,worker 数量与 CapSkip 的线程数完全相同:

// On the job method, next to [AutomaticRetry]:
[Queue("captcha")]

// In Program.cs: one server for solves, sized to CapSkip...
builder.Services.AddHangfireServer(o =>
{
    o.Queues = new[] { "captcha" };
    o.WorkerCount = 10;   // reCAPTCHA Max. Threads in CapSkip
});

// ...and the usual server for everything else.
builder.Services.AddHangfireServer(o => o.Queues = new[] { "default" });

现在,一次突发的五百个识别会在 Hangfire 存储里排队,你能看得见它们,而这个服务器同时交给 CapSkip 的识别永远不会超过十个。WorkerCount 按服务器计算,所以如果你在多个进程里运行 captcha 服务器,就要把 Max. Threads 分摊给它们。每次作业入队时都会重新应用这个特性,所以重试也会回到 captcha 队列。队列名只能包含小写字母、数字、下划线和短横线。如果你在 CapSkip 里调高了 Max. Threads,也要相应调高 WorkerCount。

第 4 步:停机处理,以及 Hangfire 运行在哪里

Hangfire 传入的 CancellationToken 会在两种情况下触发:一是服务器正在关闭,比如服务停止、部署或 IIS 回收;二是作业在仪表板里被删除或改变了状态,Hangfire 默认每五秒检查一次。把它交给 RecaptchaAsync,轮询就会立即停止。如果是关闭,Hangfire 随后会把作业放回它的队列,在重启后重新运行,并重新识别。如果是删除,它会丢弃这个作业。CapSkip 会自己把被放弃的任务做完,只是没有人来取结果。

这里有一个很小的坑。只有当作业以 OperationCanceledException 结束时,Hangfire 才会重新入队,而 SDK 在轮询期间抛出的正是这个异常。但如果 CancellationToken 在 SDK 还在提交任务时触发,也就是在它向 CapSkip 发出第一个请求的时候,SDK 会把这次取消包装成 NetworkException。于是 Hangfire 会把这次运行当作一次失败的尝试:按照第 2 步的规则,它会根据列表中的下一个延迟时间安排一次重试,并用掉三次中的一次。在 ApiException 过滤器之前加一个 catch 子句,就能把它重新变回取消:

catch (CapSkipError) when (ct.IsCancellationRequested)
{
    // A cancelled submit arrives as NetworkException;
    // rethrow as cancellation so Hangfire re-queues the job.
    throw new OperationCanceledException(ct);
}

如果进程直接崩溃,根本没有经过关闭流程,SQL Server 存储会在作业的不可见超时到期后,把它交给另一个 worker;在 Hangfire 1.8 中,这个超时默认是五分钟。无论哪种情况,Hangfire 对作业的保证都是至少运行一次,而不是恰好一次,这就是为什么示例会让已经开始的表单提交跑完,而不是取消它。

Hangfire 服务器托管在哪里同样重要。在 IIS 站点里,应用程序池默认空闲 20 分钟后就会停止,还会按计划回收,而池一停,Hangfire 服务器也就没了:定期识别不会触发,直到下一个 Web 请求把站点唤醒。要么把池的 Start Mode 设为 AlwaysRunning、Idle Time-out 设为 0,并在站点上开启 Preload Enabled(这需要安装 IIS 的 Application Initialization 功能),要么像完整示例那样,把服务器放在 Windows Service 里运行。在与 CapSkip 相同的 Windows 机器上,它只需要 Local 模式和 127.0.0.1 就够了。

要为两者之间的一个差异做好准备。服务在开机时启动,而 CapSkip 是一个桌面应用,在你登录 Windows 时才启动。无人值守的重启之后,在有人登录之前,每次提交都会以 NetworkException 失败,每个作业都会把重试次数耗在这段空窗期上。让那台机器保持登录状态,或者每次重启后都检查一下。

当 Hangfire 运行在别处时,比如第二台服务器、容器宿主机或 Azure App Service,就把 CapSkip 切换到 Server 模式,让它监听你的网络地址或公网 IP,再把客户端指向那里。如果链路要经过公网,请使用静态公网 IP,并为你预期的地址配置防火墙规则。硬件仍然是你自己的,用量也仍然不计量。客户端不会自己读取环境变量,所以要在启动代码里读取 CAPSKIP_HOST 并传给构造函数。

完整可运行示例

一个以 Windows Service 方式运行的 Worker Service,里面包含上面各步骤中的作业和一个定期计划。它需要的全部命令如下:

# dotnet new worker -n CaptchaWorker
dotnet new worker -n CaptchaWorker
cd CaptchaWorker
dotnet add package CapSkip
dotnet add package Hangfire.NetCore
dotnet add package Hangfire.SqlServer
dotnet add package Microsoft.Data.SqlClient
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting.WindowsServices
dotnet add package Microsoft.Extensions.Http
dotnet add package Newtonsoft.Json

其中有三行需要说明一下理由。Microsoft.Data.SqlClient 默认会加密连接,所以没有受信任证书的本地 SQL Server 需要在连接字符串里加上 TrustServerCertificate=true。Microsoft.Extensions.Hosting 这一行会把模板自带的 Hosting 引用提升到 Windows Services 包所要求的版本;少了它,还原(restore)会因包降级错误而失败。Newtonsoft.Json 这一行则让 Hangfire 的 JSON 依赖不再停留在一个被 NuGet 标记为有漏洞的旧版本上。

// dotnet add package CapSkip
using CapSkip;
using Hangfire;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService();

builder.Services.AddSingleton(new CapSkipClient(
    apiKey: Environment.GetEnvironmentVariable("CAPSKIP_API_KEY") ?? "capskip",
    host: Environment.GetEnvironmentVariable("CAPSKIP_HOST") ?? "127.0.0.1",
    port: 8080));
builder.Services.AddHttpClient<SignupJob>();

builder.Services.AddHangfire(cfg => cfg
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));

builder.Services.AddHangfireServer(o =>
{
    o.Queues = new[] { "captcha" };
    o.WorkerCount = 10;   // reCAPTCHA Max. Threads in CapSkip
});

var host = builder.Build();
host.Services.GetRequiredService<IRecurringJobManager>().AddOrUpdate<SignupJob>(
    "nightly-signup",
    job => job.RunAsync("https://example.com/signup", "YOUR_SITEKEY", CancellationToken.None),
    Cron.Daily());
host.Run();

public class SignupJob(CapSkipClient solver, HttpClient http)
{
    [Queue("captcha")]
    [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 120, 600 },
        OnlyOn = new[] { typeof(CapSkip.TimeoutException),
                         typeof(NetworkException), typeof(ApiException) })]
    public async Task RunAsync(string pageUrl, string sitekey, CancellationToken ct)
    {
        SolveResult result;
        try
        {
            result = await solver.RecaptchaAsync(sitekey, pageUrl, cancellationToken: ct);
        }
        catch (CapSkipError) when (ct.IsCancellationRequested)
        {
            throw new OperationCanceledException(ct);
        }
        catch (ApiException ex) when (!ex.Message.Contains("ERROR_CAPTCHA_UNSOLVABLE"))
        {
            throw new InvalidOperationException($"CapSkip refused the task: {ex.Message}", ex);
        }

        var form = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["g-recaptcha-response"] = result.Code,
        });
        var response = await http.PostAsync(pageUrl, form);
        response.EnsureSuccessStatusCode();
    }
}

这个服务只处理 captcha 队列。你的 Web 应用把作业加入同一个 SQL Server 存储,并保留自己的服务器来处理 default 队列;或者你也可以像第 3 步那样,在这里再加一个 AddHangfireServer 调用。一旦 Web 应用也要把这个作业入队,就把 SignupJob 从 Program.cs 移到两个项目都引用的类库里,因为 Web 应用入队时需要这个类型。项目需要在 appsettings.json 里有一个名为 Hangfire 的连接字符串。用 sc.exe create 安装这个服务,或者在 PowerShell 里用 New-Service,指向发布后的可执行文件。

把 RecaptchaAsync 换成 TurnstileAsync、FriendlyCaptchaAsync 或其他任何识别方法,作业的结构都保持不变。随类型变化的只有两件事:按 CapSkip 中该类型自己的 Max. Threads 设置来确定 WorkerCount,以及把 token 提交到该类型使用的字段。

常见错误及其含义

你所看到的原因修复
作业一直停在 Enqueued 状态,始终不开始方法上有 [Queue("captcha")],但没有服务器监听这个队列添加一个 Queues 中包含 captcha 的服务器
一个坏作业重试了好几个小时默认的重试过滤器:任何异常都尝试十次像第 2 步那样,使用带 OnlyOn 的 AutomaticRetry
刚部署完,就出现一次提示 The operation was canceled 的重试CancellationToken 在提交任务期间触发,SDK 对它做了包装加上第 4 步中处理取消的 catch 子句
每个作业都抛出 NetworkExceptionCapSkip 没有在运行,比如重启后没有人登录;或者 Hangfire 在另一台机器上,而 CapSkip 处于 Local 模式启动 CapSkip;如果 Hangfire 在另一台机器上,就把 CapSkip 切换到 Server 模式并设置 CAPSKIP_HOST
高负载下出现 ERROR_CAPTCHA_UNSOLVABLE 或 CapSkip.TimeoutException同时进行的识别多于 CapSkip 的线程数,导致任务等待超过 Wait Timeout让 captcha 服务器的 WorkerCount 与 Max. Threads 一致
作业成功了,但站点拒绝了提交token 在提交前已过期,或者表单还需要其他字段在一个作业里完成识别和提交,并照着 DevTools 里的真实表单原样复现
每晚的定期识别时有漏跑IIS 应用程序池处于空闲或正在回收设置 AlwaysRunning,或者把服务器托管在 Windows Service 里
编译失败,报 TimeoutException 有歧义CapSkip 和 System 都定义了这个名称把 CapSkip.TimeoutException 写全

常见问题

在识别验证码期间,异步作业会释放它的 Hangfire worker 吗?

不会。Hangfire 支持异步作业方法,但它会在启动该作业的 worker 线程上等待返回的 Task,所以在整个识别期间 worker 都处于占用状态。正因如此,对 Hangfire 验证码作业来说,其队列上的 worker 数量就是限制同时运行多少个识别的那个数字,它也因此应该与 CapSkip 的线程数一致。

我可以在一个作业里识别,再在延续作业里提交吗?

可以,但不应该。延续作业和其他作业一样要入队,所以它得排在所有已经在排队的作业后面,而 reCAPTCHA token 只在大约两分钟内有效。第二个作业一旦重试,还会把同一个已过期的 token 再提交一遍。把两者放在同一个作业里,就意味着每次尝试都会重新识别并立即提交。关于 token 能保持多久有效,更多内容见 reCAPTCHA token 过期指南.

Azure 上或 Linux 容器里的 Hangfire 能使用我 Windows 电脑上的识别工具吗?

可以。Hangfire 这一侧可以跑在任何能运行 .NET 的地方;只有 CapSkip 需要 Windows。在连接设置里把 CapSkip 切换到 Server 模式,在 Hangfire 运行的地方把 CAPSKIP_HOST 设为它的地址,然后传给客户端。在 Windows Firewall 中放行这个连接,如果链路要经过公网,请使用静态公网 IP。托管平台还有各自的限制,以其中一个平台为例,详见 Azure Functions 指南.

这和 Python 里的 Celery 相比如何?

最重要的那条规则在两者中是一样的:每次尝试都重新识别,并在同一个工作单元里提交。不同的是陷阱。Celery 的陷阱来自它的时间限制和确认设置,而 Hangfire 的陷阱来自它的默认重试设置、异步代码并不会释放的 worker,以及取消的报告方式。Python 这一侧的内容见 Celery 验证码指南.

简短版结论

在 Hangfire 验证码作业中,要在同一个方法里完成识别和提交,并把 Hangfire 的 CancellationToken 传给识别调用。用 AutomaticRetry 和 OnlyOn 替换默认的重试规则,并把永久性的 ApiException 转换成一个不在列表上的异常。给识别一个 worker 数量与 CapSkip 线程数一致的队列,把被取消的提交重新抛出为 OperationCanceledException,好让停机时作业能重新入队,并把服务器托管在不会闲置的地方:要么是 CapSkip 旁边的 Windows Service,要么借助 Server 模式放在任何其他地方。

关于重试,最后再说一点。因为识别工具运行在你自己的机器上,一次重试只花掉一个线程的几秒钟,而不是又一次计费的识别,所以一次 验证码绕过 就算失败了,再试一次的成本也很低。重试规则真正要防住的,是那些永远不会成功的作业,应该尽早叫停的正是它们。