Как решать капчу в воркерах Symfony Messenger (PHP)

symfony messenger captcha - How to Solve CAPTCHAs in Symfony Messenger Workers (PHP)

Чтобы решать капчу в Symfony Messenger, поставьте в очередь небольшое сообщение с адресом страницы и решайте капчу внутри его обработчика. Храните очередь в одном файле SQLite, чтобы воркеру на Windows не понадобились ни Redis, ни RabbitMQ. У такой схемы решения капчи в Symfony Messenger четыре ловушки, и в этом руководстве разобрана каждая. Исключение, которое выглядит созданным для повторов, игнорирует max_retries. У Messenger нет лимита времени для одного сообщения. Второй воркер перехватывает сообщение и решает капчу снова, если таймаут повторной доставки короче самого медленного решения. А без расширения pcntl, которого в PHP для Windows нет, Ctrl+C убивает воркер посреди решения.

Что понадобится

  • CapSkip, запущенный на ПК с Windows, с включённым решателем reCAPTCHA.
  • PHP 8.4.1 или новее и Composer. Symfony 8.1 требует именно эту версию PHP, а обработчик ниже использует HTML-парсер из PHP 8.4. Всё запускалось в Windows 11 с PHP 8.4.15, Symfony 8.1.8, doctrine/dbal 4.5.0 и capskip/capskip 1.3.0.
  • Три расширения PHP, включённые в php.ini: curl, pdo_sqlite и openssl. Воркер работал и с двумя первыми на обычной тестовой странице по http, но для загрузки страницы по https нужен openssl, как и для Composer, когда он скачивает пакеты.
  • Ни Redis, ни RabbitMQ, ни сервер базы данных не нужны. Транспорт Doctrine хранит очередь в файле SQLite.
  • Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы воркер на другой машине мог обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.

Замеры ниже получены на тестовой имитации CapSkip и тестовой странице входа на том же ПК, с настоящим PHP SDK и настоящими компонентами Symfony. Времена, которые относятся к SDK и к Messenger, например 120 секунд, верны и для вашего собственного сайта. Правила приёма и отклонения принадлежат только этому тестовому сайту.

Шаг 1: создаём проект

Прежде всего проверьте php -v, потому что каждая команда в этом руководстве использует PHP из вашего PATH. Если первым в PATH стоит более старый PHP (например, XAMPP поставляется с 8.0), composer create-project не останавливается. Он выводит сообщение о том, что для самого нового скелета проекта нужен PHP 8.4, и всё равно устанавливает старую версию: на PHP 8.0.30 это был Symfony 6.0. Прежде чем начинать, поставьте папку PHP 8.4 первой в PATH этого окна.

# PowerShell, with PHP 8.4 first on the PATH
composer create-project symfony/skeleton cs-worker
cd cs-worker
composer require symfony/messenger symfony/doctrine-messenger doctrine/doctrine-bundle symfony/http-client capskip/capskip --no-scripts
Remove-Item config\packages\doctrine.yaml, config\packages\messenger.yaml

Composer может спросить, включать ли конфигурацию Docker из рецептов. Ответьте n, потому что ответ по умолчанию добавляет compose.yaml для службы PostgreSQL, которую эта схема не использует. Важны ещё две детали. Рецепт Doctrine записывает doctrine.yaml, который требует пакет doctrine/orm, а этот проект его не использует, поэтому cache:clear падает с ошибкой о том, что нужен doctrine/orm. Флаг no-scripts у Composer пропускает этот шаг, а последняя строка удаляет два файла YAML, созданных рецептами. Шаг 2 заменяет их файлами PHP, которые Symfony так же загружает из config/packages.

Шаг 2: направляем транспорт на файл SQLite

Замените строки DATABASE_URL и MESSENGER_TRANSPORT_DSN, которые рецепт добавил в .env, на эти и добавьте остальные. Реальные значения, например пароль, храните в .env.local: скелет проекта Symfony исключает этот файл из Git.

MESSENGER_TRANSPORT_DSN=doctrine://default
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
CAPSKIP_HOST=127.0.0.1
CAPSKIP_PORT=8080
CAPSKIP_API_KEY=YOUR_API_KEY
SITE_PASSWORD=YOUR_PASSWORD

Путь к SQLite намеренно начинается с %kernel.project_dir%. Относительный путь вроде sqlite:///var/data.db завершился ошибкой unable to open database file, когда команда запускалась из C:\. При запуске из папки, в которой была собственная папка var, он тихо создал там вторую базу данных, и две команды видели две очереди. Обработчик читает переменные CapSkip через Autowire, потому что SDK сам их не читает. CAPSKIP_API_KEY важен, только если вы включаете в CapSkip проверку API-ключа.

<?php
// config/packages/doctrine.php (replaces the recipe's doctrine.yaml, which asks for doctrine/orm)
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return App::config([
    'doctrine' => [
        'dbal' => ['url' => '%env(resolve:DATABASE_URL)%'],
    ],
]);

Настройки транспорта идут во втором файле.

<?php
// config/packages/messenger.php (replaces the recipe's messenger.yaml)
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return App::config([
    'framework' => [
        'messenger' => [
            'failure_transport' => 'failed',
            'transports' => [
                'async' => [
                    'dsn' => '%env(MESSENGER_TRANSPORT_DSN)%',
                    // Longer than the slowest handler, or a second worker takes the message over.
                    'options' => ['redeliver_timeout' => 900],
                    'retry_strategy' => ['max_retries' => 3, 'delay' => 30000, 'multiplier' => 2, 'max_delay' => 300000],
                ],
                'failed' => 'doctrine://default?queue_name=failed',
            ],
            'routing' => ['App\Message\SolveLogin' => 'async'],
        ],
    ],
]);

Основную нагрузку несут три настройки. Стратегия повторов ждёт между попытками примерно 30, 60 и 120 секунд, что даёт CapSkip время прийти в себя. Messenger изменяет каждую задержку до 10 процентов в любую сторону, и в одном тесте в лог попали 29.4, 57.0 и 117.9 секунды. По умолчанию повторов три, первый через одну секунду. Транспорт для неудачных сообщений (failed transport) важнее, чем кажется: без него сообщение, использовавшее последний повтор, удаляется и теряется. А redeliver_timeout объясняется в отдельном разделе ниже.

Шаг 3: пишем сообщение

Сообщение несёт исходные данные для входа и ничего из того, что создаёт решатель. Токен reCAPTCHA действует две минуты и принимается только один раз, согласно документации Google. Тестовый сайт применяет это правило. Сообщение, которое несло токен, принималось, когда выполнялось сразу, и отклонялось, когда выполнялось спустя 130 секунд. Сообщение, которое несло только адрес, решало капчу заново и принималось. Поэтому обработчик загружает страницу, решает капчу и отправляет форму одно за другим.

<?php
// src/Message/SolveLogin.php
namespace App\Message;

final class SolveLogin
{
    public readonly string $jobId;

    public function __construct(
        public readonly string $siteUrl,
        public readonly string $username,
    ) {
        // Stays the same across retries: the message is serialized once and re-sent as it is.
        $this->jobId = bin2hex(random_bytes(8));
    }
}

Идентификатор задания создаётся один раз, при создании сообщения, и остаётся тем же при повторах, потому что Messenger снова отправляет то же сериализованное сообщение. На него опирается реестр ближе к концу руководства. Messenger хранит сообщение через PHP serialize, поэтому переименование этого класса, пока сообщения стоят в очереди, ломает эти строки.

Шаг 4: решаем, что повторять

CapSkip выбрасывает четыре типа исключений, и не все они заслуживают ещё одной попытки. ValidationException означает, что неверен сам вызов, и в следующий раз он упадёт точно так же. ApiException стоит повторять только для ERROR_CAPTCHA_UNSOLVABLE: так CapSkip сообщает, что это решение не удалось. NetworkException (CapSkip не запущен) и TimeoutException повторять стоит. Если вы изменили текст в настройке Error Replacement в CapSkip, проверка на ERROR_CAPTCHA_UNSOLVABLE его не увидит, и такие сбои повторяться не будут.

<?php
// src/CapSkipFailure.php
namespace App;

use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\ValidationException;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;

final class CapSkipFailure
{
    public static function toMessenger(CapSkipError $e): \Throwable
    {
        // A bad parameter or an API error other than "unsolvable" will fail the same way next time.
        $retry = !$e instanceof ValidationException
            && (!$e instanceof ApiException || str_contains($e->getMessage(), 'ERROR_CAPTCHA_UNSOLVABLE'));

        // A plain exception is retried by the retry strategy, which also decides the delay and the limit.
        return $retry
            ? new \RuntimeException($e->getMessage(), 0, $e)
            : new UnrecoverableMessageHandlingException($e->getMessage(), 0, $e);
    }
}

Обычное исключение повторяется по стратегии повторов, но не более max_retries раз. UnrecoverableMessageHandlingException пропускает повторы и сразу отправляет сообщение в транспорт для неудачных сообщений: в тесте была сделана одна попытка, и в лог записалось Removing from transport after 0 retries.

Не используйте здесь RecoverableMessageHandlingException, хотя его название на это намекает. В Symfony 8.1.8 он повторяет попытки независимо от значения max_retries, если не передать false пятым аргументом. При max_retries, равном 3, в одном тесте он сделал 7 попыток за 14 секунд, и после этого сообщение всё ещё стояло в очереди. В исходном коде 7.4 такого аргумента нет, и он повторяет всегда. Обычное исключение ведёт себя одинаково в обеих версиях.

Шаг 5: пишем обработчик

Обработчик загружает страницу входа через HttpClient из Symfony, читает токен CSRF и sitekey, запрашивает у CapSkip токен и отправляет форму. В этой схеме у HttpClient нет хранилища cookie, поэтому обработчик вручную копирует первый заголовок Set-Cookie в отправляемый запрос. В Django это cookie, который идёт вместе с токеном CSRF; если ваш сайт устанавливает несколько cookie, объедините их. Обработчик также отправляет Referer, потому что некоторые фреймворки, например Django по HTTPS, отклоняют отправку формы без него. Замените пути и имена полей на те, которые использует ваш сайт.

<?php
// src/MessageHandler/SolveLoginHandler.php
namespace App\MessageHandler;

use App\CapSkipFailure;
use App\Message\SolveLogin;
use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use Dom\HTMLDocument;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\HttpClientInterface;

#[AsMessageHandler]
final class SolveLoginHandler
{
    private CapSkip $solver;

    public function __construct(
        private HttpClientInterface $http,
        #[Autowire(env: 'CAPSKIP_HOST')] string $host,
        #[Autowire(env: 'int:CAPSKIP_PORT')] int $port,
        #[Autowire(env: 'CAPSKIP_API_KEY')] string $apiKey,
        #[Autowire(env: 'SITE_PASSWORD')] private string $password,
    ) {
        $this->solver = new CapSkip(['apiKey' => $apiKey, 'host' => $host, 'port' => $port]);
    }

    public function __invoke(SolveLogin $message): void
    {
        $url = $message->siteUrl . '/login';
        $page = $this->http->request('GET', $url);
        $cookie = explode(';', $page->getHeaders()['set-cookie'][0])[0];
        $doc = HTMLDocument::createFromString($page->getContent(), LIBXML_NOERROR);
        $csrf = $doc->querySelector('input[name=csrfmiddlewaretoken]')->getAttribute('value');
        $sitekey = $doc->querySelector('.g-recaptcha')->getAttribute('data-sitekey');

        try {
            $token = $this->solver->recaptcha($sitekey, $url)['code'];
        } catch (CapSkipError $e) {
            throw CapSkipFailure::toMessenger($e);
        }

        $reply = $this->http->request('POST', $url, [
            'headers' => ['Referer' => $url, 'Cookie' => $cookie],
            'body' => ['csrfmiddlewaretoken' => $csrf, 'username' => $message->username,
                'password' => $this->password, 'g-recaptcha-response' => $token],
        ]);
        if ($reply->getStatusCode() >= 400) {
            throw new \RuntimeException('login rejected: ' . $reply->getContent(false));
        }
        // Save the result here (your own code).
    }
}

Метод recaptcha возвращает массив, в элементе code которого лежит токен. Для других типов капчи вызовите соответствующий метод SDK, а остальное оставьте без изменений. В руководстве по решению reCAPTCHA v2 на PHP тот же вызов разобран вне очереди.

Обработчик считает сбоем статус 400 и выше. Сайту, который на неверный пароль отвечает статусом 200 и снова показывает форму входа, нужна проверка самой страницы, иначе реестр ниже отметит задание как выполненное. Отклонённый вход здесь выбрасывает обычное исключение, поэтому повторяется, как и остальные сбои, и неверный пароль стоит четырёх решений. Для любого ответа, о котором вы знаете, что он окончательный, выбрасывайте UnrecoverableMessageHandlingException.

Шаг 6: ставим сообщение в очередь и запускаем воркер

Небольшая консольная команда ставит одно сообщение в очередь. Консольные команды Symfony 8 могут быть обычными вызываемыми классами, как здесь.

<?php
// src/Command/SolveLoginCommand.php
namespace App\Command;

use App\Message\SolveLogin;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Messenger\MessageBusInterface;

#[AsCommand(name: 'app:solve-login', description: 'Queue one SolveLogin message')]
final class SolveLoginCommand
{
    public function __construct(private MessageBusInterface $bus)
    {
    }

    public function __invoke(#[Argument] string $siteUrl, #[Argument] string $username): int
    {
        $this->bus->dispatch(new SolveLogin($siteUrl, $username));

        return 0;
    }
}

Поставьте вход в очередь и запустите воркер. Транспорт создаёт свою таблицу при первом использовании. Замените https://example.com на собственную страницу с reCAPTCHA: с заглушкой загрузка страницы не удаётся, и задание уходит в цикл повторов.

php bin/console app:solve-login https://example.com YOUR_USERNAME
php bin/console messenger:consume async -vv --time-limit=3600 --memory-limit=128M

Без -vv воркер почти ничего не выводит по каждому сообщению, а в консольном логе Symfony нет временных меток, поэтому добавьте свои, если они нужны. С лимитом памяти проблем нет: воркер занимал около 12 МБ. Лимит времени требует предупреждения, о нём речь дальше.

У одного сообщения нет лимита времени

У Messenger нет таймаута для отдельного сообщения. Опция лимита времени воркера проверяется только между сообщениями, поэтому она не может прервать обработчик: при двух сообщениях в очереди, решении длиной 20 секунд и лимите времени 5 секунд воркер проработал 23.1 секунды, завершил первое сообщение и оставил второе в очереди. Настройка max_execution_time тоже не помогает. PHP в командной строке игнорирует значение из php.ini, а принудительная установка через параметр -d или set_time_limit привела лишь к фатальной ошибке уже после того, как заблокированный вызов вернулся, на 120.1 секунды.

Зависшее решение ограничивает сам SDK. PHP SDK даёт каждому HTTP-вызову лимит на подключение в 30 секунд и общий лимит в 120 секунд и не предлагает способа их изменить. CapSkip, который принял соединение и так и не ответил на отправку, стоил воркеру 120.0 секунды, после чего было выброшено исключение NetworkException. CapSkip, который принял задачу, а потом не отвечал на опросы результата, стоил 362.1 секунды. SDK считает каждый молчащий опрос признаком того, что результат ещё не готов, поэтому прошло три опроса по 120 секунд, прежде чем он заметил, что recaptchaTimeout в 300 секунд истёк, и выбросил TimeoutException.

Опрос результата, на который CapSkip перестаёт отвечать незадолго до истечения срока, может занять все свои 120 секунд уже после него. При recaptchaTimeout, равном 20, CapSkip, замолчавший через 14 секунд, завершил решение на 142.8 секунды. Каждый из этих случаев заканчивается исключением, которое повторяется по правилам из шага 4. Они задают число, от которого нужно планировать: с настройками по умолчанию обработчик может занять до 9 минут, то есть 120 секунд на отправку, 300 на опросы и 120 на последний опрос, плюс загрузка страницы и отправка формы.

Почему redeliver_timeout решает, придётся ли решать капчу дважды

Транспорт Doctrine помечает сообщение как доставленное и хранит его в таблице, пока обработчик не закончит. Если сообщение числится доставленным дольше, чем redeliver_timeout, любой воркер может взять его снова, независимо от того, работает ли ещё первый. По умолчанию это 3600 секунд.

В тесте с redeliver_timeout 5 секунд и решением, которое заняло 15, второй воркер получил то же сообщение через 5.6 секунды после первого. CapSkip решил капчу дважды, и сайт принял две отправки формы. Проверка одноразового токена на тестовом сайте этому не помешала, потому что каждый воркер решил собственный токен; она отклоняет только один и тот же токен, использованный дважды. Когда второй воркер закончил, его подтверждение (acknowledgement) не удалило ни одной строки и не вызвало ошибки. При таймауте 40 секунд тот же тест сделал одно решение и одну отправку.

Поэтому значение должно быть больше времени работы самого медленного обработчика. С описанными выше лимитами SDK обработчик может занять около 9 минут, поэтому файл messenger.php из шага 2 задаёт 900 секунд. Цена такого решения в том, что сообщение, удерживаемое упавшим воркером, ждёт столько же, прежде чем его подберёт другой воркер. Меньшее значение служит страховкой, а не исправлением. В тесте, где решение у первого воркера зависло, таймаут в 20 секунд позволил второму воркеру перехватить сообщение и довести его до конца. Зависший воркер затем упал на 120 секундах и сам запланировал повтор, поэтому задание было решено второй раз, и сайт принял две отправки формы.

Как остановить воркер, не потеряв решение

Обычно Messenger слушает SIGTERM и SIGINT через pcntl, поэтому Ctrl+C означает: закончить сообщение, затем остановиться. В PHP для Windows pcntl нет. Настоящее событие Ctrl+C убило воркер посреди решения за 0.01 секунды, с кодом выхода 0xC000013A и без строки в логе. Сообщение осталось в таблице с проставленным временем доставки, поэтому при таймауте по умолчанию ни один другой воркер не увидел бы его в течение часа.

При redeliver_timeout 10 секунд второй воркер получил сообщение примерно через 22 секунды после постановки в очередь и решил капчу заново, из-за чего первое решение пропало впустую.

Исправляет это слушатель событий, который при запуске воркера устанавливает консольный обработчик Windows.

<?php
// src/EventListener/WindowsGracefulStop.php
namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\Event\WorkerStartedEvent;

#[AsEventListener]
final class WindowsGracefulStop
{
    public function __invoke(WorkerStartedEvent $event): void
    {
        // Without pcntl, Ctrl+C kills the process at once. This turns it into "finish the message, then stop".
        if (function_exists('sapi_windows_set_ctrl_handler')) {
            $worker = $event->getWorker();
            sapi_windows_set_ctrl_handler(static fn () => $worker->stop());
        }
    }
}

С ним Ctrl+C записал в лог Stopping worker, воркер закончил решение и отправку формы, подтвердил сообщение и завершился с кодом 0 примерно через 15 секунд. Опция keepalive в Windows не помогает: ей нужен pcntl, и она завершается с сообщением Signals are not supported. Команда messenger:stop-workers между двумя процессами работает. Простаивающий воркер остановился за 0.3 секунды, а занятый сначала закончил своё сообщение и оставил следующее в очереди.

У слушателя есть цена. Ctrl+C больше не прерывает зависшее решение, потому что теперь он означает остановку после этого сообщения, а повторное нажатие не форсирует выход. В тесте четыре нажатия с интервалом в две секунды всё равно дождались завершения решения на 20 секунд. Если CapSkip замолчал, это могут быть несколько минут, поэтому завершите процесс через Task Manager, когда его нужно убрать немедленно.

Как поддерживать работу воркера

Воркер рассчитан на то, чтобы время от времени завершаться, потому что опции лимита времени и лимита памяти заставляют его намеренно останавливаться. Цикл в PowerShell каждый раз запускает нового воркера. Он запускает воркер через cmd, чтобы файл лога был обычным текстом: перенаправление нативной команды в Windows PowerShell 5.1 записывает UTF-16 и добавляет лишние сообщения об ошибках.

while ($true) {
    cmd /c "php bin/console messenger:consume async -vv --time-limit=3600 --memory-limit=128M >> var\worker.log 2>&1"
    Start-Sleep -Seconds 5
}

В тестах цикл запускал нового воркера и после выхода по лимиту времени, и после принудительного завершения процесса. Запуск цикла при входе в систему, например из Task Scheduler или как службы, здесь не проверялся.

Не решайте одно и то же задание дважды

Повторы выполняют весь обработчик заново. Если отправка формы прошла успешно, а один из следующих шагов выбрасывает исключение, например при сохранении результата, следующая попытка снова решит капчу и снова отправит форму. В тесте с шагом сохранения, который всегда падал после отправки, обработчик из шага 5 сделал 4 решения и 4 принятые отправки для одного сообщения.

Небольшой реестр это исправляет. Идентификатор задания из сообщения записывается в таблицу сразу после отправки формы, а повтор, который находит этот идентификатор, пропускает решение и выполняет только ту часть, которая упала.

<?php
// src/JobLedger.php  (SQLite syntax: INSERT OR IGNORE)
namespace App;

use Doctrine\DBAL\Connection;

final class JobLedger
{
    public function __construct(private Connection $db)
    {
        $this->db->executeStatement('CREATE TABLE IF NOT EXISTS job_done (job_id TEXT PRIMARY KEY, status INTEGER NOT NULL)');
    }

    public function status(string $jobId): ?int
    {
        $status = $this->db->fetchOne('SELECT status FROM job_done WHERE job_id = ?', [$jobId]);

        return $status === false ? null : (int) $status;
    }

    public function done(string $jobId, int $status): int
    {
        $this->db->executeStatement('INSERT OR IGNORE INTO job_done (job_id, status) VALUES (?, ?)', [$jobId, $status]);

        return $status;
    }
}

Вторая версия обработчика сначала обращается к реестру. Замените файл из шага 5 этим.

<?php
// src/MessageHandler/SolveLoginHandler.php (second version: runs the login once per job)
namespace App\MessageHandler;

use App\CapSkipFailure;
use App\JobLedger;
use App\Message\SolveLogin;
use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use Dom\HTMLDocument;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\HttpClientInterface;

#[AsMessageHandler]
final class SolveLoginHandler
{
    private CapSkip $solver;

    public function __construct(
        private HttpClientInterface $http,
        private JobLedger $ledger,
        #[Autowire(env: 'CAPSKIP_HOST')] string $host,
        #[Autowire(env: 'int:CAPSKIP_PORT')] int $port,
        #[Autowire(env: 'CAPSKIP_API_KEY')] string $apiKey,
        #[Autowire(env: 'SITE_PASSWORD')] private string $password,
    ) {
        $this->solver = new CapSkip(['apiKey' => $apiKey, 'host' => $host, 'port' => $port]);
    }

    public function __invoke(SolveLogin $message): void
    {
        // A retry after the POST went through skips the solve and the POST and repeats only the cheap part.
        $status = $this->ledger->status($message->jobId)
            ?? $this->ledger->done($message->jobId, $this->login($message));
        // Save the result here (your own code), using $status.
    }

    private function login(SolveLogin $message): int
    {
        $url = $message->siteUrl . '/login';
        $page = $this->http->request('GET', $url);
        $cookie = explode(';', $page->getHeaders()['set-cookie'][0])[0];
        $doc = HTMLDocument::createFromString($page->getContent(), LIBXML_NOERROR);
        $csrf = $doc->querySelector('input[name=csrfmiddlewaretoken]')->getAttribute('value');
        $sitekey = $doc->querySelector('.g-recaptcha')->getAttribute('data-sitekey');

        try {
            $token = $this->solver->recaptcha($sitekey, $url)['code'];
        } catch (CapSkipError $e) {
            throw CapSkipFailure::toMessenger($e);
        }

        $reply = $this->http->request('POST', $url, [
            'headers' => ['Referer' => $url, 'Cookie' => $cookie],
            'body' => ['csrfmiddlewaretoken' => $csrf, 'username' => $message->username,
                'password' => $this->password, 'g-recaptcha-response' => $token],
        ]);
        if ($reply->getStatusCode() >= 400) {
            throw new \RuntimeException('login rejected: ' . $reply->getContent(false));
        }

        return $reply->getStatusCode();
    }
}

С реестром тот же падающий шаг сохранения дал 1 решение и 1 отправку, а повторы шли сразу к сохранению. Реестр не защищает от того, что два воркера одновременно выполняют одно и то же задание. От этого защищает только достаточно большой redeliver_timeout. В таблице job_done появляется одна строка на задание, поэтому время от времени удаляйте старые строки.

Запуск CapSkip на другой машине

Воркеру и решателю не обязательно работать на одном ПК. Переключите CapSkip в режим Server в настройках подключения, и он будет слушать ваш сетевой адрес или публичный IP. Затем задайте этот адрес в CAPSKIP_HOST в .env или .env.local.

Если маршрут идёт через интернет, используйте статический публичный IP и включите проверку API-ключа, указав тот же ключ в CAPSKIP_API_KEY. При запуске режима Server CapSkip открывает свой порт для всех адресов, поэтому, чтобы ограничить доступ ожидаемыми машинами, добавьте в Windows Firewall правило, блокирующее все остальные. Решателем по-прежнему остаётся ваша собственная машина с Windows, и решения по-прежнему не тарифицируются.

Частые ошибки при решении капчи в Symfony Messenger

Что вы видитеПричинаИсправить
No transport supports Messenger DSN doctrine://defaultНет doctrine-bundle, хотя в ошибке упоминается doctrine-messengerДобавьте doctrine/doctrine-bundle командой composer require
An exception occurred in the driver: could not find driverpdo_sqlite выключен, или в .env всё ещё стоит DATABASE_URL для PostgreSQL из рецепта. Оба случая дают один и тот же текст ошибкиВключите расширение и замените URL
no such table: messenger_messagesВ DSN всё ещё стоит auto_setup=0 из рецепта, поэтому таблицу никто не создаётУберите auto_setup=0 или выполните messenger:setup-transports
unable to open database fileОтносительный путь к SQLiteНачните путь с %kernel.project_dir%
The doctrine/orm package is required when the doctrine.orm config is setdoctrine.yaml из рецепта всё ещё на местеУдалите его и используйте файл PHP из шага 2
You have requested a non-existent parameterdoctrine.yaml удалён, а doctrine.php его не заменилДобавьте файл doctrine.php
Invalid Messenger routing configuration: class not foundФайл класса сообщения отсутствует или в нём неверное пространство имёнСоздайте src/Message/SolveLogin.php, как в шаге 3
Unknown option found in DSN: [redeliver_timout]Опция написана с ошибкой; в сообщении перечислены допустимыеИсправьте написание
Removing from transport after 3 retries, а затем сообщение пропадаетТранспорт для неудачных сообщений не настроенЗадайте failure_transport, как в шаге 2
Повторов больше, чем max_retriesИспользуется RecoverableMessageHandlingExceptionВыбрасывайте обычное исключение, как в шаге 4
Воркер завершается по Ctrl+C без строки в логеВ Windows нет pcntlДобавьте слушатель с консольным обработчиком
Один и тот же вход отправлен дваждыredeliver_timeout меньше времени работы обработчика, или упал шаг после отправкиУвеличьте таймаут и добавьте реестр
Signals are not supportedОпции keepalive нужен pcntlВ Windows не включайте эту опцию

FAQ

Работает ли это на Symfony 7.4 LTS?

Блоки кода запускались на Symfony 8.1.8 и PHP 8.4.15. Для Symfony 7.4 нужен PHP 8.2 или новее, а обработчик использует HTML-парсер из PHP 8.4, поэтому на более старом PHP придётся подставить другой парсер. Схема повторов та же, а обычное исключение ведёт себя одинаково в обеих версиях. Отличается только RecoverableMessageHandlingException, как объясняет шаг 4. Поведение 7.4 известно из чтения его исходного кода, а не из запуска.

Что происходит с сообщением, которое провалило все повторы?

Если настроен транспорт для неудачных сообщений из шага 2, оно перемещается в ту же таблицу под именем очереди failed. Просмотрите его командой messenger:failed:show, отправьте снова командой messenger:failed:retry и удалите командой messenger:failed:remove. Команда retry выполняет обработчик в вашем терминале. Без транспорта для неудачных сообщений этих команд нет, а сообщение удаляется. Messenger записывает окончательный сбой в лог с уровнем critical и строкой Removing from transport after 3 retries.

Можно ли запускать больше одного воркера?

Да. Запустите messenger:consume во втором окне, и каждый воркер будет брать разные сообщения. В тестах два воркера работали с одним файлом SQLite без ошибок. Сверху действует собственный лимит CapSkip: его решатель reCAPTCHA по умолчанию выполняет 10 задач одновременно, а остальные ждут своей очереди.

Чем это отличается от очередей Laravel?

У queue:work в Laravel есть таймаут для отдельной задачи, но ему нужен pcntl. В руководстве по решению капчи в очередях Laravel объясняется, что в Windows из-за этого он молча игнорируется. У Messenger нет таймаута для отдельной задачи ни на одной платформе. Его риски: исключение для повторов, таймаут повторной доставки и отсутствие обработки сигналов.

Коротко

Воркеру Symfony Messenger для решения капчи нужно больше, чем обработчик. Передавайте в сообщении исходные данные, а не токен, и решайте капчу внутри обработчика. Выбрасывайте обычное исключение, чтобы повторить, и UnrecoverableMessageHandlingException, чтобы остановить, и добавьте транспорт для неудачных сообщений, чтобы ничто не пропадало. Задайте redeliver_timeout больше самого долгого обработчика, то есть примерно 9 минут при лимитах SDK, и добавьте консольный обработчик Windows, чтобы Ctrl+C позволял завершить решение. В документации Symfony Messenger описаны все использованные здесь параметры.

Очередь, которая разбирает несколько сотен входов в день, делает тысячи решений в месяц. Если пропускать их через распознавание капчи на вашей собственной машине с Windows, это число никогда не превращается в счёт за каждое решение.