So lösen Sie Captchas in Symfony-Messenger-Workern (PHP)

Um Captchas in Symfony Messenger zu lösen, dispatchen Sie eine kleine Nachricht, die die Seitenadresse enthält, und lösen innerhalb ihres Handlers. Halten Sie die Queue in einer einzigen SQLite-Datei, sodass ein Windows-Worker weder Redis noch RabbitMQ braucht. Dieses Symfony-Messenger-Captcha-Setup hat vier Fallen, und dieser Leitfaden behandelt jede davon. Die Exception, die wie für Wiederholungsversuche gemacht aussieht, ignoriert max_retries. Messenger hat kein Zeitlimit für eine einzelne Nachricht. Ein zweiter Worker übernimmt die Nachricht und löst erneut, wenn das Redelivery-Timeout kürzer ist als der langsamste Lösevorgang. Und ohne die Erweiterung pcntl, die PHP unter Windows nicht hat, beendet Ctrl+C den Worker mitten in einem Lösevorgang.
Was Sie brauchen
- CapSkip auf einem Windows-PC, mit eingeschaltetem reCAPTCHA-Löser.
- PHP 8.4.1 oder neuer und Composer. Symfony 8.1 verlangt diese PHP-Version, und der Handler unten verwendet den HTML-Parser von PHP 8.4. Alles wurde unter Windows 11 mit PHP 8.4.15, Symfony 8.1.8, doctrine/dbal 4.5.0 und capskip/capskip 1.3.0 ausgeführt.
- Drei PHP-Erweiterungen, die in der php.ini eingeschaltet sind: curl, pdo_sqlite und openssl. Der Worker lief gegen eine einfache http-Testseite auch nur mit den ersten beiden, aber zum Abrufen einer https-Seite wird openssl gebraucht, ebenso von Composer beim Herunterladen von Paketen.
- Kein Redis, kein RabbitMQ und kein Datenbankserver. Der Doctrine-Transport hält die Queue in einer SQLite-Datei.
- Eine Adresse für den Solver. Im Local-Modus antwortet CapSkip auf 127.0.0.1 nur für dieses Gerät. Im Server-Modus lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP, damit ein Worker auf einem anderen Rechner es über die API aufrufen kann. Eingestellt werden beide Modi unter Verbindungseinstellungen.
Die Messungen unten stammen von einem Mock-CapSkip und einer Mock-Login-Seite (nachgebaute Testattrappen) auf demselben PC, verwendet mit dem echten PHP-SDK und den echten Symfony-Komponenten. Die Zeiten, die zum SDK und zu Messenger gehören, etwa 120 Sekunden, gelten auch für Ihre eigene Website. Die Regeln der Mock-Website zum Annehmen und Ablehnen sind deren eigene.
Schritt 1: Das Projekt anlegen
Prüfen Sie zuerst php -v, denn jeder Befehl in diesem Leitfaden verwendet das PHP aus Ihrem PATH. Steht ein älteres PHP davor (XAMPP liefert zum Beispiel 8.0 mit), bricht composer create-project nicht ab. Es gibt einen Hinweis aus, dass das neueste Skeleton PHP 8.4 braucht, und installiert trotzdem ein altes: Unter PHP 8.0.30 war das Symfony 6.0. Setzen Sie den PHP-8.4-Ordner im PATH dieses Fensters an die erste Stelle, bevor Sie beginnen.
# 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 fragt eventuell, ob die Docker-Konfiguration aus den Recipes eingebunden werden soll. Antworten Sie mit n, denn die Vorgabe fügt eine compose.yaml für einen PostgreSQL-Dienst hinzu, den dieses Setup nicht verwendet. Zwei weitere Details sind wichtig. Das Doctrine-Recipe schreibt eine doctrine.yaml, die das Paket doctrine/orm verlangt, das dieses Projekt nicht nutzt, sodass cache:clear mit einem Fehler scheitert, der besagt, dass doctrine/orm erforderlich ist. Das Flag no-scripts von Composer überspringt diesen Schritt, und die letzte Zeile löscht die beiden YAML-Dateien, die die Recipes geschrieben haben. Schritt 2 ersetzt sie durch PHP-Dateien, die Symfony genauso aus config/packages lädt.
Schritt 2: Den Transport auf eine SQLite-Datei richten
Ersetzen Sie die Zeilen DATABASE_URL und MESSENGER_TRANSPORT_DSN, die das Recipe in .env eingefügt hat, durch diese, und fügen Sie den Rest hinzu. Legen Sie echte Werte wie das Passwort in .env.local ab, einer Datei, die das Symfony-Skeleton aus Git heraushält.
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
Der SQLite-Pfad beginnt mit Absicht bei %kernel.project_dir%. Ein relativer Pfad wie sqlite:///var/data.db scheiterte mit unable to open database file, wenn der Befehl aus C:\ lief. Aus einem Ordner gestartet, der ein eigenes var-Verzeichnis hatte, legte er dort stillschweigend eine zweite Datenbank an, sodass zwei Befehle zwei Queues sahen. Der Handler liest die CapSkip-Variablen über Autowire, weil das SDK sie nicht von sich aus liest. CAPSKIP_API_KEY ist nur wichtig, wenn Sie in CapSkip die Validierung des API-Schlüssels einschalten.
<?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)%'],
],
]);Die Transport-Einstellungen kommen in die zweite Datei.
<?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'],
],
],
]);Drei Einstellungen tragen die Hauptlast. Die Retry-Strategie wartet zwischen den Versuchen etwa 30, 60 und 120 Sekunden, was CapSkip Zeit gibt, wieder hochzukommen. Messenger variiert jede Wartezeit um bis zu 10 Prozent in beide Richtungen, und ein Test protokollierte 29,4, 57,0 und 117,9 Sekunden. Die Standardwerte wiederholen dreimal, beginnend mit einem Abstand von einer Sekunde. Der Failed-Transport ist wichtiger, als es aussieht: Ohne ihn wird eine Nachricht, die ihren letzten Retry verbraucht hat, gelöscht und geht verloren. Und redeliver_timeout wird weiter unten in einem eigenen Abschnitt erklärt.
Schritt 3: Die Nachricht schreiben
Die Nachricht enthält die Eingaben für den Login und nichts, was der Solver erzeugt. Ein reCAPTCHA-Token ist zwei Minuten gültig und funktioniert nur einmal, laut der Dokumentation von Google. Die Mock-Website erzwingt diese Regel. Eine Nachricht, die einen Token enthielt, wurde angenommen, als sie sofort lief, und abgelehnt, als sie 130 Sekunden später lief. Eine Nachricht, die nur die Adresse enthielt, wurde erneut gelöst und angenommen. Deshalb ruft der Handler die Seite ab, löst und sendet direkt nacheinander ab.
<?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));
}
}Die Job-ID wird einmal erzeugt, wenn die Nachricht gebaut wird, und bleibt über Wiederholungen hinweg gleich, weil Messenger dieselbe serialisierte Nachricht erneut sendet. Das Ledger gegen Ende dieses Leitfadens verlässt sich darauf. Messenger speichert die Nachricht mit PHP serialize, daher macht das Umbenennen dieser Klasse, solange Nachrichten in der Queue liegen, diese Zeilen unbrauchbar.
Schritt 4: Festlegen, was wiederholt wird
CapSkip löst vier Exception-Typen aus, und nicht alle verdienen einen weiteren Versuch. Eine ValidationException bedeutet, dass der Aufruf selbst falsch ist und beim nächsten Mal genauso scheitern wird. Eine ApiException ist nur bei ERROR_CAPTCHA_UNSOLVABLE einen neuen Versuch wert, womit CapSkip meldet, dass dieser Lösevorgang nicht geklappt hat. Eine NetworkException (CapSkip läuft nicht) und eine TimeoutException sind einen neuen Versuch wert. Wenn Sie in CapSkip den Text von Error Replacement geändert haben, kann der Abgleich auf ERROR_CAPTCHA_UNSOLVABLE ihn nicht erkennen, und diese Fehlschläge werden nicht wiederholt.
<?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);
}
}Eine gewöhnliche Exception wird von der Retry-Strategie wiederholt, bis zu max_retries. UnrecoverableMessageHandlingException überspringt die Wiederholungen und geht direkt in den Failed-Transport: Im Test machte sie einen Versuch und loggte Removing from transport after 0 retries.
Greifen Sie hier nicht zu RecoverableMessageHandlingException, auch wenn der Name es nahelegt. In Symfony 8.1.8 wiederholt sie unabhängig davon, was max_retries sagt, außer Sie übergeben false als fünftes Argument. Mit max_retries auf 3 machte sie in einem Test 7 Versuche in 14 Sekunden, und die Nachricht stand danach immer noch in der Queue. Im Quellcode von Version 7.4 hat sie kein solches Argument und wiederholt immer. Eine gewöhnliche Exception verhält sich bei beiden gleich.
Schritt 5: Den Handler schreiben
Der Handler ruft die Login-Seite mit dem HttpClient von Symfony ab, liest den CSRF-Token und den sitekey, fordert bei CapSkip einen Token an und sendet das Formular ab. In diesem Setup hat HttpClient kein Cookie-Jar, daher kopiert der Handler den ersten Set-Cookie-Header von Hand in den POST. Bei Django ist das das Cookie, das zum CSRF-Token gehört; fügen Sie mehrere zusammen, wenn Ihre Website weitere setzt. Außerdem sendet er den Referer, weil manche Frameworks, etwa Django über HTTPS, einen Formular-POST ohne ihn ablehnen. Ersetzen Sie die Pfade und Feldnamen durch die Ihrer Website.
<?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).
}
}Die Methode recaptcha gibt ein Array zurück, dessen Eintrag code der Token ist. Für andere Captcha-Typen rufen Sie die passende SDK-Methode auf und behalten den Rest bei. Der Leitfaden zum Lösen von reCAPTCHA v2 in PHP behandelt denselben Aufruf außerhalb einer Queue.
Der Handler behandelt Status 400 und höher als Fehlschlag. Eine Website, die ein falsches Passwort mit Status 200 und erneut dem Login-Formular beantwortet, braucht eine Prüfung der Seite selbst, sonst verbucht das Ledger weiter unten den Job als erledigt. Ein abgelehnter Login wirft hier eine gewöhnliche Exception, wird also wie der Rest wiederholt, und ein falsches Passwort kostet vier Lösevorgänge. Werfen Sie UnrecoverableMessageHandlingException bei jeder Antwort, von der Sie wissen, dass sie endgültig ist.
Schritt 6: Eine Nachricht dispatchen und den Worker starten
Ein kleiner Konsolenbefehl stellt eine Nachricht in die Queue. Konsolenbefehle in Symfony 8 können einfache invokable Klassen sein, wie hier.
<?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;
}
}Stellen Sie einen Login in die Queue und starten Sie den Worker. Der Transport legt seine Tabelle bei der ersten Verwendung an. Ersetzen Sie https://example.com durch eine eigene Seite, die ein reCAPTCHA enthält: Mit dem Platzhalter scheitert der Abruf der Seite, und der Job landet in der Retry-Schleife.
php bin/console app:solve-login https://example.com YOUR_USERNAME php bin/console messenger:consume async -vv --time-limit=3600 --memory-limit=128M
Ohne -vv gibt der Worker pro Nachricht fast nichts aus, und das Konsolen-Log von Symfony hat keine Zeitstempel, ergänzen Sie also eigene, wenn Sie sie brauchen. Das Speicherlimit ist unproblematisch: Der Worker lag bei etwa 12 MB. Beim Zeitlimit ist eine Warnung nötig, die als Nächstes folgt.
Für eine einzelne Nachricht gibt es kein Zeitlimit
Messenger hat kein Timeout pro Nachricht. Die Zeitlimit-Option des Workers wird nur zwischen zwei Nachrichten geprüft und kann einen Handler daher nicht vorzeitig abbrechen: Bei zwei Nachrichten in der Queue, einem Lösevorgang von 20 Sekunden und einem Zeitlimit von 5 Sekunden lebte der Worker 23,1 Sekunden, schloss die erste Nachricht ab und ließ die zweite in der Queue. Auch das Setzen von max_execution_time hilft nicht. Das PHP auf der Kommandozeile ignoriert den Wert in der php.ini, und ihn mit der Option -d oder mit set_time_limit zu erzwingen, erzeugte nur einen fatalen Fehler, nachdem der blockierte Aufruf bereits zurückgekehrt war, nach 120,1 Sekunden.
Was einen festgefahrenen Lösevorgang begrenzt, ist das SDK selbst. Das PHP-SDK gibt jedem HTTP-Aufruf ein Verbindungslimit von 30 Sekunden und ein Gesamtlimit von 120 Sekunden und bietet keine Option, sie zu ändern. Ein CapSkip, das die Verbindung annahm, auf das Einreichen aber nie antwortete, kostete den Worker 120,0 Sekunden und löste dann eine NetworkException aus. Eines, das die Aufgabe annahm und dann auf die Ergebnisabfragen nie antwortete, kostete 362,1 Sekunden. Das SDK wertet jede stumme Abfrage als noch nicht bereit, daher waren drei Abfragen von je 120 Sekunden vergangen, bevor es bemerkte, dass recaptchaTimeout, 300 Sekunden, abgelaufen war, und eine TimeoutException auslöste.
Eine Abfrage, die kurz vor der Frist verstummt, kann ihre vollen 120 Sekunden darüber hinaus laufen. Mit recaptchaTimeout auf 20 beendete ein CapSkip, das nach 14 Sekunden verstummte, den Lösevorgang nach 142,8 Sekunden. Jeder dieser Fälle endet in einer Exception, die die Zuordnung aus Schritt 4 wiederholt. Sie bestimmen die Zahl, nach der Sie planen sollten: Mit den Standardeinstellungen kann ein Handler bis zu etwa 9 Minuten brauchen, nämlich 120 Sekunden für das Einreichen, 300 für das Abfragen und 120 für die letzte Abfrage, plus Seitenabruf und POST.
Warum redeliver_timeout darüber entscheidet, ob Sie zweimal lösen
Der Doctrine-Transport markiert eine Nachricht als zugestellt (delivered) und behält sie in der Tabelle, bis der Handler fertig ist. Ist eine Nachricht länger als redeliver_timeout zugestellt, darf jeder Worker sie erneut übernehmen, unabhängig davon, ob der erste noch arbeitet. Der Standardwert sind 3600 Sekunden.
In einem Test mit redeliver_timeout auf 5 Sekunden und einem Lösevorgang, der 15 dauerte, erhielt ein zweiter Worker dieselbe Nachricht 5,6 Sekunden nach dem ersten. CapSkip löste zweimal, und die Website akzeptierte zwei POSTs. Die Einmal-Prüfung des Tokens auf der Testseite hielt das nicht auf, weil jeder Worker seinen eigenen Token gelöst hatte; sie lehnt nur einen Token ab, der zweimal verwendet wird. Als der zweite Worker fertig war, löschte seine Bestätigung keine Zeilen und löste keinen Fehler aus. Mit dem Timeout auf 40 Sekunden machte derselbe Test einen Lösevorgang und einen POST.
Der Wert muss also länger sein als Ihr langsamster Handler. Mit den SDK-Limits von oben kann ein Handler etwa 9 Minuten brauchen, deshalb setzt die Datei messenger.php in Schritt 2 900 Sekunden. Der Preis ist, dass eine Nachricht, die ein abgestürzter Worker hält, so lange wartet, bis ein anderer Worker sie aufnimmt. Ein niedrigerer Wert ist ein Sicherheitsnetz, keine Lösung. In einem Test, bei dem der Lösevorgang des ersten Workers hing, ließ ein Timeout von 20 Sekunden einen zweiten Worker übernehmen und fertigstellen. Der hängende Worker scheiterte dann nach 120 Sekunden und plante seinen eigenen Retry ein, sodass der Job ein zweites Mal gelöst wurde und die Website zwei POSTs akzeptierte.
Den Worker stoppen, ohne den Lösevorgang zu verlieren
Messenger lauscht normalerweise über pcntl auf SIGTERM und SIGINT, sodass Ctrl+C bedeutet: die Nachricht abschließen, dann stoppen. PHP unter Windows hat kein pcntl. Ein echtes Ctrl+C-Ereignis beendete den Worker mitten in einem Lösevorgang innerhalb von 0,01 Sekunden, mit dem Exit-Code 0xC000013A und ohne Logzeile. Die Nachricht blieb mit gesetzter Zustellzeit in der Tabelle, sodass kein anderer Worker sie mit dem Standard-Timeout eine Stunde lang sehen würde.
Mit redeliver_timeout auf 10 Sekunden erhielt ein zweiter Worker die Nachricht etwa 22 Sekunden nach dem Einreihen und löste sie erneut, wodurch der erste Lösevorgang umsonst war.
Die Lösung ist ein Listener, der beim Start des Workers den Windows-Konsolen-Handler installiert.
<?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());
}
}
}Damit loggte Ctrl+C Stopping worker, der Worker schloss den Lösevorgang und den POST ab, bestätigte die Nachricht und beendete sich etwa 15 Sekunden später mit Code 0. Die Option keepalive hilft unter Windows nicht: Sie braucht pcntl und bricht mit Signals are not supported ab. Der Befehl messenger:stop-workers funktioniert dagegen zwischen zwei Prozessen. Ein Worker im Leerlauf stoppte in 0,3 Sekunden, und ein beschäftigter schloss zuerst seine Nachricht ab und ließ die nächste in der Queue.
Der Listener hat einen Preis. Ctrl+C bricht einen festgefahrenen Lösevorgang nicht mehr ab, denn es bedeutet jetzt: nach dieser Nachricht stoppen, und ein erneutes Drücken erzwingt das Beenden nicht. In einem Test warteten vier Tastendrücke im Abstand von zwei Sekunden trotzdem, bis ein Lösevorgang von 20 Sekunden fertig war. Wenn CapSkip verstummt ist, können das mehrere Minuten sein. Beenden Sie den Prozess deshalb im Task Manager, wenn er sofort weg sein soll.
Den Worker am Laufen halten
Ein Worker soll ab und zu enden, denn die Optionen für Zeitlimit und Speicherlimit lassen ihn absichtlich stoppen. Eine Schleife in PowerShell startet jedes Mal einen neuen. Sie führt den Worker über cmd aus, damit die Logdatei reiner Text ist; die Umleitung eines nativen Befehls in Windows PowerShell 5.1 schreibt UTF-16 und fügt überflüssige Fehlerzeilen hinzu.
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
}Im Test startete die Schleife nach einem Beenden wegen des Zeitlimits und nach dem Abbruch des Prozesses einen neuen Worker. Das Starten der Schleife bei der Anmeldung, zum Beispiel über Task Scheduler oder als Dienst, wurde hier nicht getestet.
Denselben Job nicht zweimal lösen
Retries wiederholen den gesamten Handler. Gelingt der Formular-POST und wirft ein späterer Schritt eine Exception, zum Beispiel beim Speichern des Ergebnisses, wird beim nächsten Versuch erneut gelöst und gesendet. In einem Test mit einem Speicherschritt, der nach dem POST immer fehlschlug, machte der Handler aus Schritt 5 für eine Nachricht 4 Lösevorgänge und 4 akzeptierte POSTs.
Ein kleines Ledger behebt das. Die Job-ID aus der Nachricht kommt direkt nach dem POST in eine Tabelle, und ein Retry, der die ID findet, überspringt den Lösevorgang und führt nur den Teil aus, der fehlgeschlagen ist.
<?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;
}
}Die zweite Version des Handlers fragt zuerst das Ledger ab. Ersetzen Sie die Datei aus Schritt 5 durch diese.
<?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();
}
}Mit dem Ledger machte derselbe fehlschlagende Speicherschritt 1 Lösevorgang und 1 POST, und die Retries gingen direkt zum Speichern. Das Ledger schützt nicht davor, dass zwei Worker denselben Job gleichzeitig ausführen. Das leistet nur ein ausreichend langes redeliver_timeout. Die Tabelle job_done bekommt pro Job eine Zeile, löschen Sie also hin und wieder alte Zeilen.
CapSkip auf einem anderen Rechner betreiben
Der Worker und der Solver müssen sich keinen PC teilen. Schalten Sie CapSkip in den Verbindungseinstellungen in den Server-Modus, dann lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP. Setzen Sie anschließend CAPSKIP_HOST in .env oder .env.local auf diese Adresse.
Verwenden Sie eine statische öffentliche IP, wenn der Weg über das Internet führt, und aktivieren Sie die Validierung des API-Schlüssels, mit demselben Schlüssel in CAPSKIP_API_KEY. CapSkip öffnet seinen Port beim Start des Server-Modus für jede Adresse. Um den Zugriff auf die Rechner zu beschränken, die Sie erwarten, fügen Sie in der Windows Firewall eine Blockierregel für alle anderen hinzu. Der Solver bleibt Ihr eigener Windows-Rechner, und abgerechnet wird pro Lösung weiterhin nichts.
Häufige Captcha-Fehler mit Symfony Messenger
| Was Sie sehen | Ursache | Beheben |
|---|---|---|
| No transport supports Messenger DSN doctrine://default | doctrine-bundle fehlt, obwohl der Fehler doctrine-messenger nennt | Installieren Sie doctrine/doctrine-bundle mit composer require |
| An exception occurred in the driver: could not find driver | pdo_sqlite ist ausgeschaltet, oder die PostgreSQL-DATABASE_URL aus dem Recipe steht noch in .env. Beides ergibt denselben Fehlertext | Aktivieren Sie die Erweiterung und ersetzen Sie die URL |
| no such table: messenger_messages | Der DSN enthält noch das auto_setup=0 aus dem Recipe, daher legt nichts die Tabelle an | Entfernen Sie auto_setup=0 oder führen Sie messenger:setup-transports aus |
| unable to open database file | Ein relativer SQLite-Pfad | Lassen Sie den Pfad bei %kernel.project_dir% beginnen |
| The doctrine/orm package is required when the doctrine.orm config is set | Die doctrine.yaml aus dem Recipe ist noch vorhanden | Löschen Sie sie und verwenden Sie die PHP-Datei aus Schritt 2 |
| You have requested a non-existent parameter | doctrine.yaml wurde gelöscht, und keine doctrine.php hat sie ersetzt | Fügen Sie die Datei doctrine.php hinzu |
| Invalid Messenger routing configuration: class not found | Die Datei der Nachrichtenklasse fehlt oder hat den falschen Namespace | Legen Sie src/Message/SolveLogin.php wie in Schritt 3 an |
| Unknown option found in DSN: [redeliver_timout] | Eine falsch geschriebene Option; die Meldung listet die gültigen auf | Korrigieren Sie die Schreibweise |
| Removing from transport after 3 retries, danach ist die Nachricht weg | Es ist kein Failed-Transport konfiguriert | Setzen Sie failure_transport wie in Schritt 2 |
| Mehr Wiederholungen als max_retries | RecoverableMessageHandlingException wird verwendet | Werfen Sie eine gewöhnliche Exception, wie in Schritt 4 |
| Der Worker endet bei Ctrl+C ohne Logzeile | Unter Windows gibt es kein pcntl | Fügen Sie den Listener für den Konsolen-Handler hinzu |
| Derselbe Login wurde zweimal abgesendet | redeliver_timeout ist kürzer als der Handler, oder ein Schritt nach dem POST ist fehlgeschlagen | Erhöhen Sie das Timeout und fügen Sie das Ledger hinzu |
| Signals are not supported | Die Option keepalive braucht pcntl | Lassen Sie diese Option unter Windows aus |
FAQ
Funktioniert das mit Symfony 7.4 LTS?
Die Blöcke wurden mit Symfony 8.1.8 und PHP 8.4.15 ausgeführt. Symfony 7.4 braucht PHP 8.2 oder neuer, und der Handler verwendet den HTML-Parser von PHP 8.4, sodass Sie unter älterem PHP einen anderen Parser einsetzen müssten. Das Retry-Design ist dasselbe, und eine gewöhnliche Exception verhält sich in beiden Versionen gleich. Nur RecoverableMessageHandlingException unterscheidet sich, wie Schritt 4 erklärt. Das Verhalten von 7.4 stammt aus dem Lesen des Quellcodes, nicht aus dem Ausführen.
Was passiert mit einer Nachricht, die bei jedem Retry scheitert?
Mit dem Failed-Transport aus Schritt 2 wandert sie in dieselbe Tabelle, unter dem Queue-Namen failed. Lesen Sie sie mit messenger:failed:show, senden Sie sie mit messenger:failed:retry erneut und verwerfen Sie sie mit messenger:failed:remove. Der Retry-Befehl führt den Handler in Ihrem Terminal aus. Ohne Failed-Transport gibt es diese Befehle nicht, und die Nachricht wird gelöscht. Messenger loggt den endgültigen Fehlschlag als critical, mit der Zeile Removing from transport after 3 retries.
Kann ich mehr als einen Worker betreiben?
Ja. Starten Sie messenger:consume in einem zweiten Fenster, dann übernimmt jeder Worker andere Nachrichten. Zwei Worker liefen in den Tests ohne Fehler gegen dieselbe SQLite-Datei. Zusätzlich gilt das eigene Limit von CapSkip: Sein reCAPTCHA-Solver führt standardmäßig 10 Aufgaben gleichzeitig aus, und die übrigen warten, bis sie an der Reihe sind.
Wie unterscheidet sich das von Laravel-Queues?
queue:work von Laravel hat ein Timeout pro Job, das aber pcntl braucht und deshalb unter Windows stillschweigend ignoriert wird, wie der Leitfaden zum Lösen von Captchas in Laravel-Queues erklärt. Messenger hat auf keiner Plattform ein Timeout pro Job. Seine Risiken sind die Retry-Exception, das Redelivery-Timeout und die fehlende Signalbehandlung.
Die Kurzfassung
Ein Symfony-Messenger-Captcha-Worker braucht mehr als einen Handler. Senden Sie die Eingaben, nicht den Token, und lösen Sie innerhalb des Handlers. Werfen Sie eine gewöhnliche Exception zum Wiederholen und UnrecoverableMessageHandlingException zum Stoppen, und fügen Sie einen Failed-Transport hinzu, damit nichts verschwindet. Setzen Sie redeliver_timeout über die Dauer des längsten Handlers, die mit den Limits des SDK bei etwa 9 Minuten liegt, und fügen Sie den Windows-Konsolen-Handler hinzu, damit Ctrl+C den Lösevorgang abschließt. Die Dokumentation zu Symfony Messenger beschreibt jede hier verwendete Option.
- Alle Captcha-Typen, die das PHP-Paket verarbeitet: die PHP-Captcha-Solver-Seite.
- Wie das Checkbox-reCAPTCHA funktioniert und was der Solver abdeckt: die reCAPTCHA-v2-Solver-Seite.
Eine Queue, die einige hundert Logins pro Tag abarbeitet, kommt auf Tausende Lösungen im Monat. Wenn Sie sie über einen Captcha-Löser auf Ihrem eigenen Windows-Rechner laufen lassen, wird aus dieser Zahl nie eine Rechnung pro Lösung.
