Как решать капчу в .NET Worker Service, который не падает

Чтобы решать капчу в .NET Worker Service, зарегистрируйте один CapSkipClient как singleton, вызывайте RecaptchaAsync из ExecuteAsync с токеном остановки и перехватывайте исключения CapSkip внутри цикла. Именно этого последнего пункта нет в шаблоне worker, а важен он больше, чем кажется. Начиная с .NET 6 необработанное исключение в BackgroundService останавливает весь хост. Затем процесс завершается с кодом 0, поэтому параметры восстановления Windows Service его так и не перезапускают. Хватит одного NetworkException при загрузке системы, выброшенного потому, что CapSkip ещё не был открыт. Описанная здесь схема решения капчи в Worker Service каждые 15 минут входит на сайт, защищённый reCAPTCHA, пережидает решатель, который ещё не запустился, останавливается за доли секунды и работает как Windows Service рядом с CapSkip.
Что понадобится
- CapSkip, запущенный на машине с Windows, с включённым решателем reCAPTCHA.
- SDK .NET 8 или новее. Код ниже запускался на .NET 8 и .NET 10.
- Пакет CapSkip из NuGet, а также Microsoft.Extensions.Hosting.WindowsServices, который Program.cs ниже использует для запуска как Windows Service.
- Права администратора на машине, где вы устанавливаете сервис, причём только для этого шага.
- Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы воркер на другой машине мог обращаться к нему через 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 фиксирует версию Microsoft.Extensions.Hosting, которая поставлялась с вашим SDK, а текущему пакету Windows Services нужна более новая. Если добавить пакет Windows Services первым, восстановление пакетов падает с NU1605, ошибкой понижения версии пакета. Если сначала обновить пакет Hosting, этого не происходит, а больше в шаблоне ничего не меняется.
Шаг 1: регистрируем один клиент в Program.cs
CapSkipClient не хранит состояния отдельных запросов и отправляет всё через один статический HttpClient внутри пакета, поэтому один экземпляр может обслуживать весь процесс. Создайте его один раз из конфигурации и зарегистрируйте как singleton.
// 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 ничего не делает, когда вы запускаете программу сами, поэтому одна и та же сборка работает в консоли, пока вы её тестируете, а потом как сервис. SDK CapSkip сам не читает никаких переменных окружения, но объект конфигурации, который стоит за Host.CreateApplicationBuilder, читает все переменные окружения, поэтому CAPSKIP_HOST работает из окружения. Он работает и как ключ в appsettings.json, а для сервиса это место удобнее, потому что окружение сервиса редактировать хлопотно. Когда Windows запускает сервис, его рабочей папкой становится System32. Тогда хост использует в качестве корня контента собственную папку программы, поэтому читает appsettings.json, опубликованный рядом с exe.
Шаг 2: задача, которая решает капчу
Сама задача представляет собой обычный код. Она загружает страницу входа, читает sitekey и скрытые поля, запрашивает у CapSkip токен и отправляет форму. Держите её в отдельном классе, чтобы воркер решал только то, когда она запускается и что означает сбой.
// 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 на каждый запуск хранит cookies только этого запуска, поэтому сессионный cookie и CSRF-токен в скрытых полях всё ещё совпадают, когда форма уходит обратно. HttpClient не отправляет заголовок Referer, а фреймворки вроде Django отклоняют отправку формы по HTTPS без него, поэтому задача задаёт его сама. И токен остановки передаётся в RecaptchaAsync, а именно это позволяет сервису остановиться посреди решения, а не ждать его окончания.
Решайте капчу непосредственно перед отправкой. Токен reCAPTCHA истекает через две минуты после выдачи, поэтому воркер, который решает заранее, а отправляет позже, шлёт уже мёртвый токен. Разбор страницы здесь намеренно минимален. Невидимые виджеты, ключи Enterprise и другие варианты разобраны в руководстве по решению reCAPTCHA v2 на C#.
Шаг 3: цикл, который переживает неудачное решение
Большинство ошибок с капчей в Worker Service живёт именно в этом файле. В цикле шаблона вообще нет блока 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 означает неудачное решение, например ApiException с кодом ошибки от CapSkip, поэтому воркер записывает его в лог и ждёт следующего запуска. HttpRequestException или TaskCanceledException, которые приходят, когда никакой остановки не происходит, исходят от сайта. TaskCanceledException здесь означает, что HttpClient сдался по истечении своих стандартных 100 секунд. Каждый сбой, включая отклонённый вход, пишется в лог с уровнем Warning, и причину объясняет раздел о сервисе.
Фильтр по токену остановки не даёт остановке выглядеть как сбой. Затем внешний catch обрабатывает саму остановку, а последний catch обрабатывает всё, чего никто не ожидал, например баг в разборе страницы. Он записывает исключение в лог и завершает процесс с кодом выхода 1, и именно эта деталь позволяет Windows перезапустить сервис.
Почему одно исключение останавливает весь хост
Хост запускает ExecuteAsync и следит за возвращённым Task. Начиная с .NET 6, если этот Task завершается с ошибкой, хост применяет HostOptions.BackgroundServiceExceptionBehavior, значение которого по умолчанию равно StopHost. Возьмите цикл из шаблона и закройте CapSkip. Тогда запуск на .NET 10 пишет в лог BackgroundService failed с NetworkException под этой строкой, а следом критическую строку о том, что хост останавливается, потому что BackgroundService выбросил исключение. Затем он завершает работу. Одно отклонённое соединение, и воркера больше нет.
Для сервиса всё портит именно код выхода. Хост останавливается штатно, поэтому процесс завершается с кодом 0, и мы проверили это как на .NET 8, так и на .NET 10. Windows применяет параметры восстановления сервиса только при его сбое, а штатный выход сбоем не считается, поэтому сервис просто остаётся остановленным. Собственное руководство Microsoft по запуску BackgroundService как Windows Service именно поэтому рекомендует вызывать Environment.Exit с ненулевым кодом, и воркер выше делает это в своём последнем catch. Другое значение, Ignore, ещё хуже: хост продолжает работать с мёртвым воркером внутри, и получается сервис, который выглядит здоровым и ничего не делает.
Чистая остановка посреди решения
Когда Windows останавливает сервис или вы нажимаете Ctrl+C в консоли, хост отменяет токен остановки и ждёт, пока ExecuteAsync завершится. Он ждёт не дольше HostOptions.ShutdownTimeout (по умолчанию 30 секунд), а потом всё равно останавливается. Решение reCAPTCHA может длиться гораздо дольше, потому что SDK опрашивает результат до 300 секунд. Без токена остановка, запрошенная через 5 секунд после начала 60-секундного решения, в нашем тесте заняла 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, а сервис может стартовать задолго до этого. Воркер справляется с этим сам: он пишет в лог CapSkip unreachable каждые 30 секунд, пока решатель не ответит. Каждая повторная попытка к тому же заново загружает страницу входа, примерно 120 раз в час, пока Windows ждёт на экране входа, так что увеличьте эти 30 секунд, если сайт может это заметить. Сервис и настольное приложение работают в разных сеансах Windows, но подключение к 127.0.0.1 проходит между сеансами, так что режим Local работает и в этом случае.
В роли сервиса хост отправляет логи в Windows Event Log, а именно в Application log, и по умолчанию туда попадают только записи уровня Warning и выше. Поэтому воркер и пишет свои сбои как предупреждения: строка уровня Information никогда не появилась бы в Event Viewer.
Несколько аккаунтов за один запуск
Чтобы на каждом запуске по расписанию входить в несколько аккаунтов, запускайте цикл внутри задачи через Parallel.ForEachAsync, а не добавляйте воркеров. Задайте MaxDegreeOfParallelism равным 10, потому что по умолчанию CapSkip решает 10 reCAPTCHA одновременно (Max. Threads в его настройках reCAPTCHA), а остальные ставит в очередь. Передайте токен остановки в CancellationToken у ParallelOptions, а токен, который получает каждая итерация, передавайте дальше в RecaptchaAsync, чтобы остановка разом отменяла все решения. Перехватывайте сбои каждого аккаунта внутри тела цикла с теми же фильтрами, что и в воркере. Исключение, которое вырвется из обработки одного аккаунта, отменит решения всех остальных в этом запуске. Синглтон CapSkipClient можно безопасно разделять между этими вызовами. Дайте каждому аккаунту собственный HttpClient, как это делает задача выше, чтобы никакие две сессии не делили один cookie.
Запуск решателя на другой машине
Адрес 127.0.0.1 подходит, пока воркер и CapSkip работают на одном ПК с Windows. Воркер может работать и в другом месте: на другом Windows-сервере, на виртуальной машине или на хосте контейнеров. Тогда переключите CapSkip в режим Server, чтобы он слушал ваш сетевой адрес или публичный IP, и задайте этот адрес в CAPSKIP_HOST в appsettings.json или в окружении. Если маршрут идёт через интернет, используйте статический публичный IP и включите проверку API-ключа. При запуске режима Server CapSkip открывает свой порт для всех адресов, поэтому, чтобы доступ к нему был только у ожидаемых машин, добавьте в Windows Firewall правило, блокирующее все остальные. Решателем по-прежнему остаётся ваша собственная машина с Windows, и решения по-прежнему не тарифицируются.
Частые ошибки при решении капчи в Worker Service
| Что вы видите | Причина | Исправить |
|---|---|---|
| NU1605 (обнаружено понижение версии пакета) при добавлении пакета Windows Services | Шаблон фиксирует более старую версию Microsoft.Extensions.Hosting, чем нужна пакету Windows Services | Сначала добавьте Microsoft.Extensions.Hosting, затем пакет Windows Services |
| BackgroundService failed, затем критическая строка про StopHost, и воркера больше нет | Исключение вырвалось из 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 с его настройками подключения |
| Сайт отклоняет токен или снова отвечает страницей входа | Токен истёк до отправки, или в отправке не хватало cookie, скрытого поля или Referer | Решайте прямо перед отправкой, а отправляйте через тот же HttpClient, который загрузил страницу |
| В Event Viewer нет ничего от воркера | По умолчанию Event Log принимает только Warning и выше | Пишите сбои в лог с уровнем Warning или задайте для EventLog LogLevel со значением Information в appsettings.json |
| ApiException с ERROR_KEY_DOES_NOT_EXIST или ERROR_WRONG_USER_KEY | В CapSkip включена проверка API-ключа, а воркер отправил ключ по умолчанию или не отправил никакого | Задайте в CAPSKIP_API_KEY ключ, настроенный в CapSkip |
FAQ
Не лучше ли задать BackgroundServiceExceptionBehavior значение Ignore?
Нет. С Ignore хост один раз записывает исключение в лог как ошибку и работает дальше, однако ExecuteAsync больше никогда не вызывается, так что процесс живёт, но внутри у него пусто. Windows показывает сервис как работающий, и никаких входов не происходит, пока кто-нибудь этого не заметит. Перехватывайте ожидаемые сбои в цикле, а всё остальное пусть завершает процесс с ненулевым кодом выхода.
Относится ли то же самое к фоновому сервису (hosted service) в ASP.NET Core?
Да. Приложение ASP.NET Core работает на том же Generic Host. Поэтому у BackgroundService в веб-приложении то же значение StopHost по умолчанию и тот же 30-секундный таймаут остановки, а необработанное исключение CapSkip роняет вместе с собой весь сайт. Цикл выше работает там без изменений, но учтите, что его Environment.Exit(1) тоже завершает всё веб-приложение.
Когда Hangfire подходит лучше, чем воркер?
Когда решения представляют собой задачи, которые ставит в очередь другой код, а не одну задачу по таймеру. Hangfire хранит каждую задачу, повторяет её по собственному расписанию и показывает историю в дашборде, а с простым воркером всё это остаётся на вас. Его фильтр повторов требует отдельного внимания к исключениям CapSkip, как объясняется в руководстве по решению капчи в задачах Hangfire.
Может ли воркер работать не на той машине, где запущен CapSkip?
Да. Переведите CapSkip в режим Server в настройках подключения, чтобы он слушал сетевой адрес, а не локальную петлю, и задайте этот адрес в CAPSKIP_HOST. Тогда воркер обращается к нему через тот же HTTP API с любой машины, которой доступен порт, а для адресов, у которых не должно быть доступа, действует блокирующее правило Windows Firewall. Решатель остаётся на вашем собственном железе, поэтому в том, как считаются решения, ничего не меняется.
Коротко
Задаче с капчей в Worker Service нужны три вещи, которых не даёт шаблон. Перехватывайте CapSkipError и исключения сайта внутри цикла, потому что одно вырвавшееся исключение останавливает хост, и процесс завершается с кодом 0. Передавайте токен остановки в RecaptchaAsync и фильтруйте по нему каждый catch, чтобы остановка занимала мгновение, а не 30 секунд, и никогда не попадала в лог как падение. И завершайте по-настоящему неожиданные сбои через Environment.Exit(1), чтобы параметры восстановления сервиса могли его перезапустить. Повторяйте попытку каждые 30 секунд, пока CapSkip не запущен, и переходите в режим Server, как только воркер покидает машину решателя.
- Все типы капчи, которые поддерживает пакет для .NET, перечислены на странице сервиса распознавания капчи для C#.
- Как работает reCAPTCHA с чекбоксом и что покрывает решатель, описано на странице сервиса распознавания reCAPTCHA v2.
Воркер, который месяцами запускается каждые 15 минут, набирает тысячи решений. Именно здесь и окупается обход капчи на вашей собственной машине с Windows: число решений никогда не появляется в счёте.
