如何在不会挂掉的 .NET Worker Service 中识别验证码

worker service captcha - How to Solve CAPTCHAs in a .NET Worker Service That Stays Up

要在 .NET Worker Service 中识别验证码,就把一个 CapSkipClient 注册为单例,在 ExecuteAsync 里带上停止令牌(stoppingToken)调用 RecaptchaAsync,并在循环内部捕获 CapSkip 的异常。最后这一点正是 Worker 模板没有写的,而它比看上去重要得多。从 .NET 6 开始,BackgroundService 中一个未处理的异常就会让整个主机停止。随后进程以退出码 0 退出,所以 Windows Service 的恢复选项永远不会重启它。开机时 CapSkip 还没打开,由此抛出的一个 NetworkException 就足以造成这种后果。这套 Worker Service 验证码识别方案每 15 分钟登录一次受 reCAPTCHA 保护的站点,能一直等到尚未启动的识别工具就绪,不到一秒就能停止,并作为 Windows Service 运行在 CapSkip 旁边。

你需要什么

  • 在 Windows 机器上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。
  • 已安装 .NET 8 SDK 或更高版本。下面的代码已在 .NET 8 和 .NET 10 上运行过。
  • NuGet 上的 CapSkip 包,以及 Microsoft.Extensions.Hosting.WindowsServices,下面的 Program.cs 用它来作为 Windows Service 运行。
  • 安装服务的那台机器上的管理员权限,而且只有安装这一步需要。
  • 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的 worker 就能通过 API 调用它。两种模式的设置位置都是 连接设置.
dotnet new worker -n PortalWorker
cd PortalWorker
dotnet add package CapSkip
# Update Hosting before the next line: the template pins an older version,
# and the Windows Services package fails to restore against it (NU1605).
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting.WindowsServices

最后两行的顺序很重要。Worker 模板锁定的是随你的 SDK 一起发布的 Microsoft.Extensions.Hosting 版本,而当前的 Windows Services 包需要更新的版本。如果先添加 Windows Services 包,还原(restore)就会以 NU1605 失败,这是一个包降级错误。先更新 Hosting 包就能避免它,模板里的其他内容都不会变。

第 1 步:在 Program.cs 中注册一个客户端

CapSkipClient 不保存任何按请求区分的状态,所有请求都通过包内部的一个静态 HttpClient 发送,所以一个实例就能服务整个进程。从配置中构建它一次,并把它注册为单例。

// Program.cs
using CapSkip;
using PortalWorker;

var builder = Host.CreateApplicationBuilder(args);

// A Windows Service when the service manager starts it, an ordinary
// console app when you run it yourself.
builder.Services.AddWindowsService(options => options.ServiceName = "PortalWorker");

// One client for the whole process. The SDK reads no environment
// variables, but the host's configuration does, so CAPSKIP_HOST can come
// from the environment or from appsettings.json.
var config = builder.Configuration;
builder.Services.AddSingleton(new CapSkipClient(
    apiKey: config["CAPSKIP_API_KEY"] ?? "capskip",
    host: config["CAPSKIP_HOST"] ?? "127.0.0.1",
    port: int.Parse(config["CAPSKIP_PORT"] ?? "8080")));
builder.Services.AddSingleton<PortalLogin>();
builder.Services.AddHostedService<Worker>();

builder.Build().Run();

当你自己启动程序时,AddWindowsService 什么也不做,所以同一个构建产物在测试时可以在控制台里运行,之后又能作为服务运行。CapSkip SDK 自己不读取任何环境变量,但 Host.CreateApplicationBuilder 背后的配置对象会读取所有环境变量,所以 CAPSKIP_HOST 可以来自环境变量。它也可以作为 appsettings.json 里的一个键,对服务来说这个位置更方便,因为服务的环境变量不好编辑。Windows 启动服务时,服务的工作目录是 System32。这时主机会改用程序自己所在的文件夹作为内容根目录,所以它读取的是发布在 exe 旁边的 appsettings.json。

第 2 步:识别验证码的作业

作业本身就是普通代码。它获取登录页面,读取 sitekey 和隐藏字段,向 CapSkip 请求一个 token,然后提交表单。把它放在单独的类里,这样后台程序只需决定它何时运行,以及失败意味着什么。

// PortalLogin.cs
using System.Net;
using System.Text.RegularExpressions;
using CapSkip;

namespace PortalWorker;

public sealed class PortalLogin(CapSkipClient solver, ILogger<PortalLogin> logger)
{
    const string PageUrl = "https://example.com/login";

    public async Task RunAsync(CancellationToken ct)
    {
        // A new HttpClient per run keeps its own cookies, so one run's
        // session never leaks into the next.
        using var http = new HttpClient();
        var html = await http.GetStringAsync(PageUrl, ct);
        var sitekey = Regex.Match(html, "data-sitekey=\"([^\"]+)\"").Groups[1].Value;

        // The form's hidden fields, such as a CSRF token.
        var fields = new Dictionary<string, string>();
        foreach (Match input in Regex.Matches(html, "<input[^>]*type=\"hidden\"[^>]*>"))
        {
            var name = Regex.Match(input.Value, "name=\"([^\"]+)\"").Groups[1].Value;
            var value = Regex.Match(input.Value, "value=\"([^\"]*)\"").Groups[1].Value;
            fields[name] = WebUtility.HtmlDecode(value);
        }

        // Pass the token on, so stopping the service cancels the solve.
        var result = await solver.RecaptchaAsync(sitekey, PageUrl, cancellationToken: ct);

        fields["username"] = "YOUR_USERNAME";
        fields["password"] = "YOUR_PASSWORD";
        fields["g-recaptcha-response"] = result.Code;
        using var post = new HttpRequestMessage(HttpMethod.Post, PageUrl)
        {
            Content = new FormUrlEncodedContent(fields),
        };
        // HttpClient sends no Referer; strict servers refuse a post without one.
        post.Headers.Referrer = new Uri(PageUrl);
        using var response = await http.SendAsync(post, ct);
        if (response.IsSuccessStatusCode)
            logger.LogInformation("Login posted: {Status}", (int)response.StatusCode);
        else
            logger.LogWarning("Login rejected: {Status}", (int)response.StatusCode);
    }
}

有三个细节容易被忽略。每次运行新建一个 HttpClient,cookie 就只在这次运行中保留,所以表单提交回去时,会话 cookie 和隐藏字段里的 CSRF token 仍然对得上。HttpClient 不发送 Referer 请求头,而 Django 这类框架会拒绝没有 Referer 的 HTTPS 表单提交,所以作业会自己设置它。另外,停止令牌会传给 RecaptchaAsync,正是这一点让服务能在识别进行到一半时停止,而不必等它完成。

要在提交之前立刻识别。reCAPTCHA token 在签发两分钟后就会过期,所以一个先识别、过一阵再提交的后台程序,发出去的是一个已失效的 token。这里的页面解析有意写得很简单。隐形小组件、Enterprise key 和其他变体,详见 用 C# 识别 reCAPTCHA v2 的指南.

第 3 步:识别失败也不会挂掉的循环

Worker Service 验证码识别的 bug,大多出在这个文件里。模板里的循环根本没有 try 块,用来记录时间没问题,但对任何要和另一个程序通信的代码来说都是致命的。

// Worker.cs
using CapSkip;

namespace PortalWorker;

public sealed class Worker(PortalLogin job, ILogger<Worker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                var wait = TimeSpan.FromMinutes(15);
                try
                {
                    await job.RunAsync(stoppingToken);
                }
                catch (NetworkException ex) when (!stoppingToken.IsCancellationRequested)
                {
                    // CapSkip is not running yet, as after a reboot. Try again soon.
                    logger.LogWarning("CapSkip unreachable: {Message}", ex.Message);
                    wait = TimeSpan.FromSeconds(30);
                }
                catch (CapSkipError ex) when (!stoppingToken.IsCancellationRequested)
                {
                    logger.LogWarning("Solve failed: {Type}: {Message}", ex.GetType().Name, ex.Message);
                }
                catch (Exception ex) when ((ex is HttpRequestException or TaskCanceledException)
                                           && !stoppingToken.IsCancellationRequested)
                {
                    // The site failed, or HttpClient gave up on it after 100 s.
                    logger.LogWarning("The site did not answer: {Message}", ex.Message);
                }

                await Task.Delay(wait, stoppingToken);
            }
        }
        catch (Exception) when (stoppingToken.IsCancellationRequested)
        {
            // Stopping. A cancelled poll ends in TaskCanceledException and a
            // cancelled submit in NetworkException; neither is a failure.
        }
        catch (Exception ex)
        {
            logger.LogCritical(ex, "Worker failed");
            // Exit non-zero, so the service's recovery options restart it.
            // A host that stops on its own exits with code 0.
            Environment.Exit(1);
        }
    }
}

每个 catch 回答一个问题。NetworkException 表示 CapSkip 没有响应,通常是因为它还没运行,所以后台程序会在 30 秒后重试,而不是等 15 分钟。其他任何 CapSkipError 都是识别失败,比如带有 CapSkip 错误码的 ApiException,所以后台程序记录日志,然后等待下一次运行。在没有发生停止的情况下出现的 HttpRequestException 或 TaskCanceledException,都来自站点。这里的 TaskCanceledException 表示 HttpClient 在默认的 100 秒后放弃了。每一次失败都以 Warning 级别记录,被拒绝的登录也不例外,原因会在讲服务的那一节里解释。

按停止令牌过滤,能让一次停止不被当成失败。这样,外层 catch 负责处理停止本身,最后一个 catch 则处理所有没人预料到的情况,比如解析代码里的 bug。它记录异常,并以退出码 1 结束进程,正是这一个细节让 Windows 能够重启服务。

为什么一个异常就会让整个主机停止

主机启动 ExecuteAsync,并监视它返回的 Task。从 .NET 6 开始,如果这个 Task 失败,主机就会应用 HostOptions.BackgroundServiceExceptionBehavior,其默认值是 StopHost。拿模板里的循环来试,再关掉 CapSkip。在 .NET 10 上运行时,日志会先记录 BackgroundService failed,下面是那个 NetworkException,接着是一条 critical 级别的日志,说主机因为某个 BackgroundService 抛出异常而正在停止。然后它就关闭了。一次被拒绝的连接,后台程序就没了。

对服务来说,真正要命的是退出码。主机是正常停止的,所以进程以退出码 0 退出,我们在 .NET 8 和 .NET 10 上测得的结果都一样。Windows 只在服务失败时才应用它的恢复选项,而正常退出不算失败,所以服务就一直停在那里。Microsoft 官方那篇关于 以 Windows Service 方式运行 BackgroundService 的指南,正是出于这个原因,建议用非零退出码调用 Environment.Exit,上面的后台程序就在它最后一个 catch 里这么做了。另一个设置 Ignore 更糟:主机继续运行,里面却是一个已经死掉的后台程序,成了一个看起来健康、实则什么也不做的服务。

在识别中途干净地停止

当 Windows 停止服务,或者你在控制台里按下 Ctrl+C 时,主机会取消停止令牌,并等待 ExecuteAsync 结束。它最多等待 HostOptions.ShutdownTimeout(默认 30 秒),然后无论如何都会停止。一次 reCAPTCHA 识别可能远比这更久,因为 SDK 最多会轮询 300 秒。在我们的测试中,如果不传令牌,在一次 60 秒的识别进行到第 5 秒时请求停止,要花 30 秒:主机把 ShutdownTimeout 整个等完,然后放弃了这次识别。把令牌传下去之后,同样的停止只用了大约 20 毫秒。

取消会以两种形式到达你这里,取决于它发生在哪个阶段。在 SDK 轮询期间,它会以 TaskCanceledException 结束,这个异常来自两次轮询之间的延迟。如果它发生在 SDK 正在提交任务时,也就是它向 CapSkip 发出第一个请求的时候,SDK 会把它包装成 NetworkException,这是一个 CapSkipError,而不是 OperationCanceledException。如果没有过滤,第二种情况看起来就像一次崩溃。我们在提交中途停止了一个后台程序,结果在一次普通的关闭中,主机记录了 BackgroundService failed,下面是 The operation was canceled。这就是为什么上面的每个 catch 都先检查停止令牌。

第 4 步:作为 Windows Service 运行

把后台程序发布到一个文件夹,再向服务管理器注册这个 exe。除了 dotnet publish 之外,下面的每条命令都需要在以管理员身份运行的 PowerShell 中执行。

# In an elevated PowerShell. Paths are examples.
dotnet publish -c Release -r win-x64 -o C:\Services\PortalWorker
sc.exe create PortalWorker binpath= "C:\Services\PortalWorker\PortalWorker.exe" start= delayed-auto
sc.exe failure PortalWorker reset= 86400 actions= restart/60000/restart/60000/restart/60000
sc.exe start PortalWorker

failure 那一行正是与 Environment.Exit(1) 配套的。它告诉 Windows 在服务失败一分钟后重启它。之后每次失败,Windows 都会重复最后一个操作,所以服务会被一次次重新拉起,而失败计数会在一天没有失败之后清零。延迟自动启动会让服务在其他自动启动的服务之后不久才启动,而不是在开机一开始就启动。CapSkip 本身是一个桌面应用,所以重启之后,要等有人登录 Windows 它才会运行,而服务可能早在那之前就启动了。后台程序会自己处理这种情况:它每 30 秒记录一次 CapSkip unreachable,直到识别工具响应为止。每次重试还会重新加载一次登录页面,在 Windows 停在登录界面期间,一小时大约 120 次,所以如果站点会察觉到,就把这 30 秒调长一些。服务和桌面应用运行在不同的 Windows 会话中,但 127.0.0.1 可以跨会话连接,所以在它们之间用 Local 模式就行。

作为服务运行时,主机会把日志发送到 Windows Event Log 的 Application log 中,而默认只有 Warning 及以上级别的条目才会写进去。这就是后台程序把失败记录为警告的原因:Information 级别的日志永远不会出现在 Event Viewer 里。

一次运行处理多个账号

要在每次定时运行时登录多个账号,就在作业内部用 Parallel.ForEachAsync 循环,而不是启动更多后台程序。把 MaxDegreeOfParallelism 设为 10,因为 CapSkip 默认同时识别 10 个 reCAPTCHA(即它 reCAPTCHA 设置中的 Max. Threads),其余的会排队。通过 ParallelOptions 的 CancellationToken 传入停止令牌,并把每次迭代收到的令牌继续传给 RecaptchaAsync,这样一次停止就能同时取消所有识别。在循环体内部捕获每个账号的失败,使用和后台程序相同的过滤条件。从某一个账号逃逸出来的异常,会取消这次运行中其他所有账号的识别。CapSkipClient 单例可以安全地在这些调用之间共享。像上面的作业那样,给每个账号各自的 HttpClient,这样任意两个会话都不会共用 cookie。

把识别工具跑在另一台机器上

只要后台程序和 CapSkip 在同一台 Windows 电脑上,127.0.0.1 就是对的。后台程序也可能运行在别处,比如另一台 Windows 服务器、一台虚拟机或一台容器宿主机。这时就把 CapSkip 切换到 Server 模式,让它监听你的网络地址或公网 IP,并在 appsettings.json 或环境变量中把 CAPSKIP_HOST 设为这个地址。如果链路要经过公网,请使用静态公网 IP,并打开 API 密钥校验。Server 模式启动时,CapSkip 会向所有地址开放端口,所以要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。

Worker Service 验证码识别的常见错误

你所看到的原因修复
添加 Windows Services 包时出现 NU1605,即检测到包降级模板锁定的 Microsoft.Extensions.Hosting 版本比 Windows Services 包需要的更旧先添加 Microsoft.Extensions.Hosting,再添加 Windows Services 包
先是 BackgroundService failed,接着是一条关于 StopHost 的 critical 日志,然后后台程序就没了有异常逃出了 ExecuteAsync;默认的 StopHost 行为停止了主机在循环内部捕获 CapSkipError 和站点引发的异常
服务在出错后停止,Windows 却始终没有重启它被 StopHost 停止的主机以退出码 0 退出,在 Windows 看来这不算失败用 Environment.Exit(1) 结束意外失败,并用 sc.exe failure 设置恢复选项
正常停止时出现 BackgroundService failed,附带 The operation was canceled停止发生在任务正被提交给 CapSkip 的时候,SDK 会把它包装成 NetworkException让每个 catch 都按停止令牌过滤
停止服务要花 30 秒识别不响应取消,所以主机要把 ShutdownTimeout 等完把停止令牌传给 RecaptchaAsync 和每一个 HTTP 调用
每次重启后,日志里都出现 CapSkip unreachable服务在有人登录并打开 CapSkip 之前就启动了无需修复:后台程序会每 30 秒重试一次,直到 CapSkip 响应
NetworkException,提示目标计算机积极拒绝了连接CapSkip 没有在运行,或者主机或端口不对启动 CapSkip,并对照它的连接设置检查 CAPSKIP_HOST 和 CAPSKIP_PORT
站点拒绝了 token,或者又返回了登录页面token 在提交前已过期,或者提交时漏掉了 cookie、某个隐藏字段或 Referer在提交之前立刻识别,并用获取页面的那个 HttpClient 提交
Event Viewer 里看不到后台程序的任何日志Event Log 默认只接收 Warning 及以上级别以 Warning 级别记录失败,或者在 appsettings.json 中把 EventLog 的 LogLevel 设为 Information
抛出带有 ERROR_KEY_DOES_NOT_EXIST 或 ERROR_WRONG_USER_KEY 的 ApiExceptionCapSkip 打开了 API 密钥校验,而后台程序发送的是默认密钥,或者根本没有发送把 CAPSKIP_API_KEY 设为 CapSkip 中配置好的密钥

常见问题

是不是应该改为把 BackgroundServiceExceptionBehavior 设为 Ignore?

不应该。设为 Ignore 后,主机会把异常作为错误记录一次,然后继续运行,但 ExecuteAsync 再也不会被调用,所以进程虽然还在运行,里面却什么也没有。Windows 显示服务正在运行,可在有人察觉之前,不会发生任何登录。在循环中捕获预料之中的失败,其余的就让它以非零退出码结束进程。

ASP.NET Core 中的托管服务也是这样吗?

是的。ASP.NET Core 应用运行在同一个 Generic Host 上。所以 Web 应用中的 BackgroundService 有同样的 StopHost 默认值和同样的 30 秒关闭超时,一个未处理的 CapSkip 异常会把整个网站一起拖垮。上面的循环在那里原样可用,但要注意,它的 Environment.Exit(1) 也会结束整个 Web 应用。

什么时候 Hangfire 比后台程序更合适?

当识别是由其他代码排入队列的作业,而不是定时器上的单个任务时。Hangfire 会存储每个作业,按它自己的计划重试,并在仪表板中显示历史记录,而这些在普通的后台程序里都得你自己来做。它的重试过滤器在处理 CapSkip 的异常时需要单独留意,具体见 在 Hangfire 作业中识别验证码的指南.

后台程序能和 CapSkip 运行在不同的机器上吗?

可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,并把 CAPSKIP_HOST 设为这个地址。这样,后台程序就能从任何可以访问该端口的机器上,通过同一套 HTTP API 调用它;对于不该访问的地址,则用一条 Windows Firewall 阻止规则挡住。识别工具始终运行在你自己的硬件上,因此识别次数的计算方式没有任何变化。

简短版结论

Worker Service 验证码识别作业需要三样模板不会给你的东西。在循环内部捕获 CapSkipError 和站点引发的异常,因为只要有一个异常逃逸出去,主机就会停止,并以退出码 0 退出。把停止令牌传给 RecaptchaAsync,并让每个 catch 都按它过滤,这样停止只需片刻而不是 30 秒,也永远不会被记录成崩溃。还要用 Environment.Exit(1) 结束真正意料之外的失败,好让服务的恢复选项能够重启它。CapSkip 还没启动时每 30 秒重试一次,一旦后台程序离开识别工具所在的机器,就切换到 Server 模式。

一个每 15 分钟运行一次、连续运行几个月的后台程序,会累积数千次识别。这时,在你自己的 Windows 机器上运行的 验证码绕过 工具就显出价值了:识别次数永远不会出现在任何账单上。