Как решать капчу в фоновой задаче Hangfire (.NET)

Чтобы решить капчу в фоновой задаче Hangfire, вызовите клиент CapSkip для .NET внутри метода задачи, отправьте токен в той же задаче и передайте в решение CancellationToken из Hangfire. На это уходит десять строк. Вся работа сосредоточена в двух настройках Hangfire по умолчанию, которые подходят обычным задачам, но не задаче Hangfire с капчей: десять автоматических повторов при любом исключении, растянутых примерно на четыре с половиной часа, и пул до двадцати воркеров, в котором асинхронное решение всё равно занимает воркер от начала до конца. В этом руководстве разобраны сама задача, правило повторов, которое знает, какие сбои стоит повторять, очередь, подогнанная под CapSkip, остановки сервера и размещение Hangfire на той же машине с Windows, что и решатель.
Что понадобится
- CapSkip, запущенный на машине с Windows. Hangfire может работать на той же машине или обращаться к ней с другой.
- Hangfire 1.8 с любым хранилищем. В примерах используется хранилище SQL Server, привычный выбор на Windows, а фильтру повторов ниже нужна версия 1.8.0 или новее.
- NuGet-пакет CapSkip. Каждый метод решения принимает необязательный 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. Веб-приложение, которому нужен ещё и дашборд, вместо него добавляет Hangfire.AspNetCore. Hangfire.SqlServer 1.8 поставляется без собственного SQL-клиента, поэтому в списке есть Microsoft.Data.SqlClient.
Шаг 1: решение и отправка в одной задаче
Поместите решение и отправку формы в один метод задачи; на этом правиле построена каждая задача Hangfire с капчей в этом руководстве. Токен reCAPTCHA живёт около двух минут, поэтому отправка не может ждать в очереди за другой работой, а именно так поступила бы задача-продолжение, созданная через ContinueJobWith. То же касается повтора: каждый запуск должен решать заново и никогда не продолжать с токена или 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 подставит настоящий токен отмены прямо перед запуском задачи.
BackgroundJob.Enqueue<SignupJob>(job =>
job.RunAsync("https://example.com/signup", "YOUR_SITEKEY", CancellationToken.None));Hangfire создаёт SignupJob из вашего контейнера сервисов, поэтому зарегистрируйте клиент CapSkip как singleton и дайте задаче типизированный HttpClient, как в полном примере. Клиент не хранит ничего, кроме своих настроек, поэтому один экземпляр можно безопасно разделять между всеми воркерами. Поля формы здесь приведены для иллюстрации; отправляйте то, что на самом деле отправляет целевая форма.
Шаг 2: заменяем правило повторов по умолчанию
Hangfire применяет фильтр автоматических повторов к каждой задаче. По умолчанию он повторяет задачу при любом исключении десять раз, а задержка перед повтором номер n составляет (n минус 1) в четвёртой степени секунд, плюс 15, плюс случайная добавка, что в сумме даёт примерно четыре с половиной часа между первым сбоем и последним. Для капризного почтового сервера это подходит. А вот задаче с капчей это не подходит: одни сбои там стоят ещё одной попытки, а другие каждый раз будут повторяться в точности.
| Что выбрасывает SDK | Обычная причина | Стоит ли повторять? |
|---|---|---|
| CapSkip.TimeoutException | Решение длилось дольше recaptchaTimeout (по умолчанию 300 секунд), или CapSkip был недоступен либо перезапускался, пока SDK опрашивал результат | Да |
| NetworkException | CapSkip был недоступен, когда задача отправляла задание | Да |
| ApiException с ERROR_CAPTCHA_UNSOLVABLE | Эта попытка не удалась или исчерпала время внутри CapSkip; следующая может пройти | Да |
| ApiException с любым другим кодом | Некорректный sitekey или URL страницы либо ключ API, который CapSkip отклоняет | Нет, каждый раз сбой будет одинаковым |
| ValidationException | Ваш код передал опцию, которую метод не принимает | Нет, это баг |
Одно ограничение этой таблицы: CapSkip может проверить только то, что sitekey правильно сформирован. Корректно сформированный ключ, который отклоняет Google, всё равно приходит как 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 полностью: при неявных директивах using в проекте на .NET 6 или новее в области видимости оказывается и System.TimeoutException, и короткое имя не скомпилируется. Сообщение ApiException содержит сырой ответ CapSkip, поэтому сопоставление по коду ошибки и работает, а полный список кодов приводит справочник API.
Шаг 3: отдельная очередь и число воркеров для решений
Сервер Hangfire запускает число воркеров, равное Environment.ProcessorCount, умноженному на 5, но не больше 20. Асинхронность задачи не освобождает воркер, пока решение ждёт: Hangfire выполняет каждую задачу в потоке своего воркера и ждёт там, пока не завершится Task. Поэтому двадцать решений в работе означают двадцать воркеров, занятых на всё время решения, а все остальные задачи приложения, включая письма для сброса пароля, ждут за ними.
У CapSkip есть собственный лимит. Настройка reCAPTCHA Max. Threads в приложении по умолчанию равна 10, а задания сверх этого числа ждут свободного потока внутри CapSkip. Задание, которое ждёт дольше reCAPTCHA Wait Timeout (по умолчанию 250 секунд), завершается с ошибкой ERROR_CAPTCHA_UNSOLVABLE. Поэтому дайте каждой задаче Hangfire с капчей собственную очередь, где воркеров ровно столько, сколько потоков у 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 считается на каждый сервер, поэтому если вы запускаете сервер для капчи в нескольких процессах, разделите Max. Threads между ними. Атрибут применяется заново при каждой постановке задачи в очередь, так что повторы тоже возвращаются в очередь captcha. Имена очередей допускают только строчные буквы, цифры, подчёркивания и дефисы. Если поднимете Max. Threads в CapSkip, поднимите и WorkerCount до того же значения.
Шаг 4: остановки и где работает Hangfire
CancellationToken, который передаёт Hangfire, срабатывает в двух случаях: сервер останавливается из-за остановки сервиса, деплоя или перезапуска пула IIS, либо задачу удалили или перевели в другое состояние в дашборде, что Hangfire по умолчанию проверяет каждые пять секунд. Если передать его в RecaptchaAsync, опрос прекращается сразу. При остановке Hangfire затем возвращает задачу в её очередь и после перезапуска выполняет её снова, со свежим решением. При удалении он задачу отбрасывает. CapSkip сам доводит брошенное задание до конца, и результат никто не забирает.
Есть одна тонкость. Hangfire возвращает задачу в очередь, только если она завершается с OperationCanceledException, и SDK выбрасывает именно его, пока опрашивает результат. Но если токен срабатывает, когда SDK ещё отправляет задание (это его первый запрос к CapSkip), SDK оборачивает отмену в NetworkException. Тогда Hangfire считает запуск неудачной попыткой: по правилу из шага 2 он планирует повтор со следующей задержкой из списка и расходует одну из трёх попыток. Один блок catch, поставленный перед фильтром ApiException, снова превращает это в отмену:
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 отдаёт задачу другому воркеру, как только истечёт её таймаут невидимости, по умолчанию пять минут в Hangfire 1.8. В любом случае Hangfire выполняет задачу как минимум один раз, а не ровно один раз, и поэтому пример позволяет уже начатой отправке формы завершиться, а не отменяет её.
Не менее важно, где размещён сервер Hangfire. Внутри сайта IIS пул приложений по умолчанию останавливается после 20 минут простоя и перезапускается по расписанию, а остановленный пул означает, что сервера Hangfire нет: решения по расписанию не запускаются, пока следующий веб-запрос не разбудит сайт. Либо задайте для пула Start Mode со значением AlwaysRunning и Idle Time-out со значением 0 и включите Preload Enabled на сайте, для чего должна быть установлена функция IIS Application Initialization, либо запустите сервер в Windows Service, как это сделано в полном примере. На той же машине с Windows, что и CapSkip, ему достаточно режима Local и 127.0.0.1.
Учтите одно различие между ними. Сервис стартует при загрузке системы, а CapSkip представляет собой настольное приложение, которое запускается, когда вы входите в Windows. После перезагрузки без присмотра каждая отправка падает с NetworkException, пока кто-нибудь не войдёт в систему, и каждая задача тратит свои повторы на этот промежуток. Держите на этой машине открытый сеанс или проверяйте её после каждой перезагрузки.
Если Hangfire работает в другом месте, например на втором сервере, на хосте контейнеров или в Azure App Service, переключите CapSkip в режим Server, чтобы он слушал ваш сетевой адрес или публичный IP, и направьте клиент туда. Если маршрут идёт через интернет, используйте статический публичный IP с правилом файрвола для ожидаемых адресов. Это по-прежнему ваше собственное железо, и по-прежнему без платы за каждое решение. Клиент сам не читает переменные окружения, поэтому читайте CAPSKIP_HOST в коде запуска и передавайте его в конструктор.
Полный рабочий пример
Worker-сервис, работающий как Windows 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; без неё восстановление пакетов завершается ошибкой понижения версии пакета. Newtonsoft.Json уводит JSON-зависимость Hangfire со старой версии, которую 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. Ваше веб-приложение ставит задачи в то же хранилище SQL Server и держит собственный сервер для очереди default, либо вы добавляете здесь второй вызов AddHangfireServer, как в шаге 3. Как только эту задачу начнёт ставить в очередь и веб-приложение, перенесите SignupJob из Program.cs в библиотеку классов, на которую ссылаются оба проекта, потому что веб-приложению нужен этот тип, чтобы ставить задачу в очередь. Проекту нужна строка подключения с именем Hangfire в appsettings.json. Установите сервис командой sc.exe create или через New-Service в PowerShell, указав опубликованный исполняемый файл.
Замените RecaptchaAsync на TurnstileAsync, FriendlyCaptchaAsync или любой другой метод решения, и форма задачи останется прежней. С типом капчи меняются две вещи: подгоните WorkerCount под собственную настройку Max. Threads этого типа в CapSkip и отправляйте токен в то поле, которое использует этот тип.
Частые ошибки и что они означают
| Что вы видите | Причина | Исправить |
|---|---|---|
| Задачи висят в Enqueued и не запускаются | У метода есть [Queue("captcha")], но ни один сервер не слушает эту очередь | Добавьте сервер, в Queues которого есть captcha |
| Одна плохая задача повторяется часами | Фильтр повторов по умолчанию: десять попыток при любом исключении | Используйте AutomaticRetry с OnlyOn, как в шаге 2 |
| Повтор с сообщением The operation was canceled сразу после деплоя | Токен сработал во время отправки задания, и SDK обернул отмену | Добавьте catch для отмены из шага 4 |
| NetworkException на каждой задаче | CapSkip не запущен, например после перезагрузки, когда никто не вошёл в систему, или Hangfire находится на другой машине, а CapSkip работает в режиме Local | Запустите CapSkip; если Hangfire на другой машине, переключите CapSkip в режим Server и задайте CAPSKIP_HOST |
| ERROR_CAPTCHA_UNSOLVABLE или CapSkip.TimeoutException под нагрузкой | Решений в работе больше, чем потоков у CapSkip, и задания ждут дольше Wait Timeout | Приведите WorkerCount сервера для captcha в соответствие с Max. Threads |
| Задача проходит успешно, но сайт отклоняет отправку | Токен истёк до отправки, или форме нужны другие поля | Решайте и отправляйте в одной задаче и воспроизведите настоящую форму, подсмотрев её в DevTools |
| Решения по расписанию пропускают ночи | Пул приложений IIS простаивал или перезапускался | Задайте AlwaysRunning или разместите сервер в Windows Service |
| Сборка падает на неоднозначном TimeoutException | Это имя определено и в CapSkip, и в System | Пишите CapSkip.TimeoutException полностью |
FAQ
Освобождает ли асинхронная задача свой воркер Hangfire, пока решается капча?
Нет. Hangfire поддерживает асинхронные методы задач, но ждёт возвращённый Task в том же потоке воркера, который его запустил, поэтому воркер занят всё время решения. Именно поэтому для задачи Hangfire с капчей число воркеров на её очереди и ограничивает, сколько решений выполняется одновременно, и поэтому оно должно совпадать с числом потоков CapSkip.
Можно ли решать в одной задаче, а отправлять в задаче-продолжении?
Можно, но не стоит. Задача-продолжение ставится в очередь, как любая другая задача, поэтому ждёт за всем, что уже стоит в очереди, а токен reCAPTCHA остаётся действительным около двух минут. Повтор второй задачи к тому же отправил бы тот же истёкший токен ещё раз. Если держать и то и другое в одной задаче, каждая попытка решает заново и сразу отправляет. Подробнее о том, сколько живут токены, рассказано в руководстве по сроку действия токена reCAPTCHA.
Может ли Hangfire в Azure или в Linux-контейнере использовать решатель на моём ПК с Windows?
Да. Сторона Hangfire может работать везде, где работает .NET; Windows требует только CapSkip. Переведите CapSkip в режим Server в настройках подключения, задайте в CAPSKIP_HOST его адрес везде, где работает Hangfire, и передайте его клиенту. Разрешите подключение в Windows Firewall и используйте статический публичный IP, если маршрут идёт через интернет. У хостинговых платформ есть и собственные ограничения, и те, что касаются одной из них, разбирает руководство по Azure Functions.
Чем это отличается от Celery в Python?
Самое важное правило в обоих случаях одинаковое: решайте заново при каждой попытке и отправляйте в той же единице работы. Ловушки разные. У Celery они возникают из-за лимитов времени и настроек подтверждения, а у Hangfire появляются из-за повторов по умолчанию, воркеров, которые асинхронный код не освобождает, и того, как сообщается об отмене. Сторона Python разобрана в руководстве по капче в Celery.
Коротко
В задаче Hangfire с капчей решайте и отправляйте в одном методе и передавайте в решение CancellationToken из Hangfire. Замените правило повторов по умолчанию на AutomaticRetry с OnlyOn и превращайте постоянные ApiException в исключение, которого нет в списке. Дайте решениям очередь, в которой число воркеров совпадает с числом потоков CapSkip, перебрасывайте отмену, пришедшую во время отправки задания, как OperationCanceledException, чтобы при остановке задачи возвращались в очередь, и размещайте сервер там, где он не может простаивать: в Windows Service рядом с CapSkip или в любом другом месте, если включён режим Server.
- Все типы капчи, которые решает пакет для .NET: страница решения капчи для C# и .NET.
- Как решается чекбокс reCAPTCHA v2, описано на странице сервиса распознавания reCAPTCHA v2.
И последнее о повторах. Решатель работает на вашей собственной машине, поэтому повтор стоит нескольких секунд работы потока, а не ещё одного оплачиваемого решения, и если обход капчи один раз не удался, его дёшево попробовать снова. На самом деле правило повторов защищает от задач, которые никогда не завершатся успешно, и именно их стоит останавливать пораньше.
