Cara Memecahkan CAPTCHA di Worker Symfony Messenger (PHP)

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

Untuk memecahkan CAPTCHA di Symfony Messenger, dispatch pesan kecil yang berisi alamat halaman, lalu pecahkan CAPTCHA di dalam handler-nya. Simpan queue dalam satu file SQLite, sehingga worker Windows tidak membutuhkan Redis atau RabbitMQ. Penyiapan symfony messenger captcha ini memiliki empat jebakan, dan panduan ini membahas masing-masing. Exception yang tampak dibuat untuk percobaan ulang justru mengabaikan max_retries. Messenger tidak memiliki batas waktu untuk satu pesan. Worker kedua mengambil alih pesan dan memecahkan lagi jika redelivery timeout lebih pendek daripada pemecahan paling lambat. Dan tanpa ekstensi pcntl, yang tidak dimiliki PHP di Windows, Ctrl+C mematikan worker di tengah pemecahan.

Apa yang Anda butuhkan

  • CapSkip yang berjalan di PC Windows, dengan solver reCAPTCHA diaktifkan.
  • PHP 8.4.1 atau lebih baru dan Composer. Symfony 8.1 membutuhkan versi PHP tersebut, dan handler di bawah memakai parser HTML milik PHP 8.4. Semuanya dijalankan di Windows 11 dengan PHP 8.4.15, Symfony 8.1.8, doctrine/dbal 4.5.0, dan capskip/capskip 1.3.0.
  • Tiga ekstensi PHP yang diaktifkan di php.ini: curl, pdo_sqlite, dan openssl. Worker berjalan hanya dengan dua yang pertama terhadap halaman uji http biasa, tetapi mengambil halaman https membutuhkan openssl, begitu juga Composer saat mengunduh paket.
  • Tanpa Redis, RabbitMQ, atau server database. Transport Doctrine menyimpan queue dalam sebuah file SQLite.
  • Sebuah alamat untuk pemecah CAPTCHA. Dalam mode Local, CapSkip menjawab di 127.0.0.1 hanya untuk perangkat itu. Dalam mode Server, CapSkip mendengarkan di alamat jaringan atau IP publik Anda, sehingga worker di mesin lain dapat memanggilnya melalui API. Keduanya diatur di pengaturan koneksi.

Pengukuran di bawah berasal dari CapSkip tiruan (mock) dan halaman login tiruan di PC yang sama, yang dipakai bersama SDK PHP yang asli dan komponen Symfony yang asli. Waktu yang berasal dari SDK dan dari Messenger, seperti 120 detik, berlaku juga untuk situs Anda sendiri. Aturan menerima dan menolak di situs tiruan adalah aturan situs tiruan itu sendiri.

Langkah 1: buat proyek

Periksa php -v sebelum hal lain, karena setiap perintah di panduan ini memakai PHP yang ada di PATH Anda. Jika ada PHP yang lebih lama di urutan pertama (XAMPP menyertakan PHP 8.0, misalnya), composer create-project tidak berhenti. Perintah itu mencetak catatan bahwa skeleton terbaru membutuhkan PHP 8.4, tetapi tetap menginstal versi lama: di PHP 8.0.30 hasilnya Symfony 6.0. Letakkan folder PHP 8.4 di urutan pertama pada PATH jendela ini sebelum Anda memulai.

# 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 mungkin menanyakan apakah konfigurasi Docker dari recipe akan disertakan. Jawab n, karena pilihan default menambahkan compose.yaml untuk layanan PostgreSQL yang tidak dipakai penyiapan ini. Ada dua detail lain yang penting. Recipe Doctrine menulis doctrine.yaml yang meminta paket doctrine/orm, yang tidak dipakai proyek ini, sehingga cache:clear gagal dengan error yang menyatakan bahwa doctrine/orm diperlukan. Flag no-scripts milik Composer melewati langkah itu, dan baris terakhir menghapus dua file YAML yang ditulis recipe. Langkah 2 menggantinya dengan file PHP, yang dimuat Symfony dari config/packages dengan cara yang sama.

Langkah 2: arahkan transport ke file SQLite

Ganti baris DATABASE_URL dan MESSENGER_TRANSPORT_DSN yang ditambahkan recipe ke .env dengan baris berikut, lalu tambahkan sisanya. Letakkan nilai asli seperti kata sandi di .env.local, yang dijaga skeleton Symfony agar tidak masuk ke 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

Path SQLite sengaja dimulai dari %kernel.project_dir%. Path relatif seperti sqlite:///var/data.db gagal dengan unable to open database file saat perintah dijalankan dari C:\. Saat dijalankan dari folder yang punya direktori var sendiri, perintah itu diam-diam membuat database kedua di sana, sehingga dua perintah melihat dua queue. Handler membaca variabel CapSkip melalui Autowire, karena SDK tidak membacanya dengan sendirinya. CAPSKIP_API_KEY hanya berpengaruh jika Anda mengaktifkan validasi API key di CapSkip.

<?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)%'],
    ],
]);

Pengaturan transport ada di file kedua.

<?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'],
        ],
    ],
]);

Tiga pengaturan menjadi penopang utama. Strategi retry menunggu sekitar 30, 60, dan 120 detik di antara percobaan, yang memberi CapSkip waktu untuk kembali. Messenger memvariasikan setiap jeda hingga 10 persen ke kedua arah, dan satu pengujian mencatat 29.4, 57.0, dan 117.9 detik. Nilai default mencoba ulang tiga kali, dimulai dengan jeda satu detik. Failed transport lebih penting daripada kelihatannya: tanpa failed transport, pesan yang sudah memakai percobaan ulang terakhirnya dihapus dan hilang. Dan redeliver_timeout dijelaskan di bagian tersendiri di bawah.

Langkah 3: tulis pesannya

Pesan hanya membawa input untuk login dan tidak membawa apa pun yang dihasilkan pemecah CAPTCHA. Token reCAPTCHA berlaku selama dua menit dan hanya bisa dipakai sekali, menurut dokumentasi Google. Situs tiruan itu menerapkan aturan tersebut. Pesan yang membawa token diterima saat dijalankan seketika dan ditolak saat dijalankan 130 detik kemudian. Pesan yang hanya membawa alamat memecahkan CAPTCHA lagi dan diterima. Jadi handler mengambil halaman, memecahkan CAPTCHA, dan mengirim form secara berurutan tanpa jeda.

<?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));
    }
}

ID job dibuat sekali, saat pesan dibuat, dan tetap sama di setiap percobaan ulang karena Messenger mengirim ulang pesan hasil serialisasi yang sama. Ledger di dekat akhir panduan ini bergantung padanya. Messenger menyimpan pesan dengan serialize milik PHP, sehingga mengganti nama class ini saat pesan masih berada di queue akan merusak baris tersebut.

Langkah 4: tentukan apa yang dicoba ulang

CapSkip memunculkan empat tipe exception, dan tidak semuanya layak mendapat percobaan lagi. ValidationException berarti pemanggilannya sendiri salah dan akan gagal dengan cara yang sama lain kali. ApiException layak dicoba ulang hanya untuk ERROR_CAPTCHA_UNSOLVABLE, yaitu cara CapSkip menyatakan bahwa pemecahan ini tidak berhasil. NetworkException (CapSkip tidak berjalan) dan TimeoutException layak dicoba ulang. Jika Anda mengubah teks Error Replacement di CapSkip, pencocokan pada ERROR_CAPTCHA_UNSOLVABLE tidak bisa melihatnya, dan kegagalan itu tidak dicoba ulang.

<?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);
    }
}

Exception biasa dicoba ulang oleh strategi retry, hingga max_retries. UnrecoverableMessageHandlingException melewati percobaan ulang dan langsung masuk ke failed transport: dalam pengujian, pesan hanya dicoba satu kali dan mencatat Removing from transport after 0 retries.

Jangan memakai RecoverableMessageHandlingException di sini, meskipun namanya mengisyaratkannya. Di Symfony 8.1.8, exception ini mencoba ulang terlepas dari nilai max_retries kecuali Anda memberikan false sebagai argumen kelimanya. Dengan max_retries bernilai 3, exception ini melakukan 7 percobaan dalam 14 detik pada satu pengujian, dan pesan masih berada di queue sesudahnya. Di source 7.4, exception ini tidak memiliki argumen seperti itu dan selalu mencoba ulang. Exception biasa berperilaku sama di kedua versi.

Langkah 5: tulis handler-nya

Handler mengambil halaman login dengan HttpClient milik Symfony, membaca token CSRF dan sitekey, meminta token ke CapSkip, lalu mengirim form. Pada penyiapan ini HttpClient tidak memiliki cookie jar, sehingga handler menyalin header Set-Cookie pertama ke dalam pengiriman secara manual. Di Django, itu adalah cookie yang berpasangan dengan token CSRF; gabungkan beberapa cookie jika situs Anda menyetel lebih banyak. Handler juga mengirim Referer, karena beberapa framework, seperti Django lewat HTTPS, menolak form post tanpa Referer. Ganti path dan nama field dengan yang dipakai situs Anda.

<?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).
    }
}

Metode recaptcha mengembalikan array yang entri code-nya adalah token. Untuk tipe CAPTCHA lain, panggil metode SDK yang sesuai dan pertahankan sisanya. Lihat panduan memecahkan reCAPTCHA v2 di PHP untuk pemanggilan yang sama di luar queue.

Handler menganggap status 400 ke atas sebagai kegagalan. Situs yang menjawab kata sandi salah dengan status 200 dan form login lagi memerlukan pengecekan pada halaman itu sendiri, atau ledger di bawah akan mencatat job sebagai selesai. Login yang ditolak melempar exception biasa di sini, sehingga dicoba ulang seperti yang lain, dan kata sandi yang salah memakan empat pemecahan. Lempar UnrecoverableMessageHandlingException untuk respons apa pun yang Anda tahu sudah final.

Langkah 6: dispatch pesan dan jalankan worker

Sebuah console command kecil memasukkan satu pesan ke queue. Console command Symfony 8 bisa berupa class invokable biasa, seperti di sini.

<?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;
    }
}

Masukkan satu login ke queue dan jalankan worker. Transport membuat tabelnya saat pertama kali dipakai. Ganti https://example.com dengan halaman Anda sendiri yang memuat reCAPTCHA: dengan placeholder itu, pengambilan halaman gagal, dan job masuk ke loop percobaan ulang.

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

Tanpa -vv, worker hampir tidak mencetak apa pun per pesan, dan log konsol milik Symfony tidak memiliki timestamp, jadi tambahkan sendiri jika Anda membutuhkannya. Batas memori aman: worker berada di sekitar 12 MB. Batas waktu perlu diberi peringatan, yang dibahas berikutnya.

Tidak ada batas waktu untuk satu pesan

Messenger tidak memiliki timeout per pesan. Opsi batas waktu milik worker hanya diperiksa di antara pesan, sehingga tidak bisa memotong handler di tengah jalan: dengan dua pesan di queue, pemecahan 20 detik, dan batas waktu 5 detik, worker berjalan selama 23.1 detik, menyelesaikan pesan pertama, dan meninggalkan pesan kedua di queue. Menyetel max_execution_time juga tidak membantu. PHP baris perintah mengabaikan nilai di php.ini, dan memaksanya dengan opsi -d atau set_time_limit hanya menghasilkan fatal error setelah pemanggilan yang tertahan sudah kembali, pada 120.1 detik.

Yang membatasi pemecahan yang macet adalah SDK itu sendiri. SDK PHP memberi setiap pemanggilan HTTP batas koneksi 30 detik dan batas total 120 detik, dan tidak menyediakan opsi untuk mengubahnya. CapSkip yang menerima koneksi tetapi tidak pernah menjawab pengiriman tugas menghabiskan waktu worker selama 120.0 detik, lalu memunculkan NetworkException. CapSkip yang menerima tugas lalu tidak pernah menjawab polling hasil memakan 362.1 detik. SDK menganggap setiap polling yang diam sebagai belum siap, sehingga tiga polling masing-masing 120 detik sudah berlalu sebelum SDK menyadari bahwa recaptchaTimeout, 300 detik, sudah habis, lalu memunculkan TimeoutException.

Polling yang berhenti menjawab tepat sebelum tenggat bisa berjalan penuh 120 detik melewati tenggat itu. Dengan recaptchaTimeout 20, CapSkip yang berhenti menjawab setelah 14 detik mengakhiri pemecahan pada 142.8 detik. Setiap kasus ini berakhir dengan exception yang dicoba ulang oleh pemetaan dari Langkah 4. Semua itu menentukan angka yang dijadikan patokan: dengan pengaturan default, sebuah handler bisa memakan waktu hingga sekitar 9 menit, yaitu 120 detik untuk pengiriman tugas, 300 untuk polling, dan 120 untuk polling terakhir, ditambah pengambilan halaman dan pengiriman form.

Mengapa redeliver_timeout menentukan apakah Anda memecahkan dua kali

Transport Doctrine menandai pesan sebagai delivered dan menyimpannya di tabel sampai handler selesai. Jika sebuah pesan sudah berstatus delivered lebih lama daripada redeliver_timeout, worker mana pun dapat mengambilnya lagi, baik worker pertama masih bekerja maupun tidak. Nilai default-nya 3600 detik.

Dalam pengujian dengan redeliver_timeout 5 detik dan pemecahan yang memakan 15 detik, worker kedua menerima pesan yang sama 5.6 detik setelah worker pertama. CapSkip memecahkan dua kali dan situs menerima dua kiriman form. Pengecekan token sekali pakai di situs uji tidak menghentikannya, karena setiap worker memecahkan tokennya sendiri; pengecekan itu hanya menolak satu token yang dipakai dua kali. Saat worker kedua selesai, acknowledgement-nya tidak menghapus satu baris pun dan tidak memunculkan error. Dengan timeout 40 detik, pengujian yang sama menghasilkan satu pemecahan dan satu kiriman form.

Jadi nilainya harus lebih panjang daripada handler Anda yang paling lambat. Dengan batas SDK di atas, sebuah handler bisa memakan waktu sekitar 9 menit, sehingga file messenger.php di Langkah 2 menyetel 900 detik. Harganya adalah pesan yang ditahan oleh worker yang crash menunggu selama itu sebelum worker lain mengambilnya. Nilai yang lebih rendah adalah jaring pengaman, bukan perbaikan. Dalam pengujian saat pemecahan worker pertama macet, timeout 20 detik membuat worker kedua mengambil alih dan menyelesaikannya. Worker yang macet itu kemudian gagal pada 120 detik dan menjadwalkan percobaan ulangnya sendiri, sehingga job dipecahkan untuk kedua kalinya dan situs menerima dua kiriman form.

Hentikan worker tanpa kehilangan pemecahan

Messenger biasanya mendengarkan SIGTERM dan SIGINT melalui pcntl, sehingga Ctrl+C berarti selesaikan pesan, lalu berhenti. PHP di Windows tidak memiliki pcntl. Event Ctrl+C yang sungguhan mematikan worker di tengah pemecahan dalam 0.01 detik, dengan kode keluar 0xC000013A dan tanpa baris log. Pesan tetap berada di tabel dengan waktu delivered yang sudah terisi, sehingga dengan timeout default tidak ada worker lain yang akan melihatnya selama satu jam.

Dengan redeliver_timeout 10 detik, worker kedua menerima pesan itu sekitar 22 detik setelah dimasukkan ke queue dan memecahkannya lagi, sehingga pemecahan pertama terbuang sia-sia.

Solusinya adalah listener yang memasang console handler Windows saat worker dimulai.

<?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());
        }
    }
}

Dengan listener itu, Ctrl+C mencatat baris Stopping worker, worker menyelesaikan pemecahan dan pengiriman form, meng-acknowledge pesan, lalu keluar dengan kode 0 sekitar 15 detik kemudian. Opsi keepalive tidak membantu di Windows: opsi itu membutuhkan pcntl dan berhenti dengan Signals are not supported. Perintah messenger:stop-workers memang berfungsi di antara dua proses. Worker yang menganggur berhenti dalam 0.3 detik, dan worker yang sibuk menyelesaikan pesannya lebih dulu dan membiarkan pesan berikutnya tetap di queue.

Listener ini ada harganya. Ctrl+C tidak lagi membatalkan pemecahan yang macet, karena sekarang artinya berhenti setelah pesan ini, dan menekannya lagi tidak memaksa keluar. Dalam satu pengujian, empat kali penekanan dengan jeda dua detik tetap menunggu pemecahan 20 detik selesai. Jika CapSkip sudah berhenti menjawab, itu bisa berarti beberapa menit, jadi akhiri proses dari Task Manager saat Anda membutuhkannya berhenti seketika.

Jaga worker tetap berjalan

Worker memang dirancang untuk sesekali keluar, karena opsi batas waktu dan batas memori membuatnya berhenti dengan sengaja. Loop di PowerShell memulai worker baru setiap kali. Loop itu menjalankan worker melalui cmd agar file log berupa teks biasa; mengarahkan output perintah native di Windows PowerShell 5.1 menulis UTF-16 dan menambahkan output error yang tidak perlu.

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
}

Dalam pengujian, loop memulai worker baru setelah worker keluar karena batas waktu dan setelah prosesnya dimatikan. Memulai loop saat masuk ke akun, misalnya dari Task Scheduler atau sebagai service, tidak diuji di sini.

Jangan memecahkan job yang sama dua kali

Percobaan ulang mengulang seluruh handler. Jika form post berhasil dan langkah berikutnya melempar exception, misalnya saat Anda menyimpan hasil, percobaan berikutnya memecahkan dan mengirim lagi. Dalam pengujian dengan langkah penyimpanan yang selalu gagal setelah pengiriman, handler dari Langkah 5 menghasilkan 4 pemecahan dan 4 kiriman yang diterima untuk satu pesan.

Sebuah ledger kecil memperbaikinya. ID job dari pesan dimasukkan ke sebuah tabel tepat setelah pengiriman, dan percobaan ulang yang menemukan ID itu melewati pemecahan dan hanya menjalankan bagian yang gagal.

<?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;
    }
}

Versi kedua handler bertanya ke ledger lebih dulu. Ganti file dari Langkah 5 dengan file ini.

<?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();
    }
}

Dengan ledger, langkah penyimpanan yang gagal itu menghasilkan 1 pemecahan dan 1 pengiriman, dan percobaan ulang langsung menuju penyimpanan. Ledger tidak melindungi dari dua worker yang menjalankan job yang sama pada waktu yang sama. Hanya redeliver_timeout yang cukup panjang yang bisa melakukannya. Tabel job_done mendapat satu baris per job, jadi hapus baris lama sesekali.

Menjalankan CapSkip di mesin lain

Worker dan pemecah CAPTCHA tidak harus berada di PC yang sama. Alihkan CapSkip ke mode Server di pengaturan koneksi, dan CapSkip akan mendengarkan di alamat jaringan atau IP publik Anda. Lalu setel CAPSKIP_HOST di .env atau .env.local ke alamat itu.

Gunakan IP publik statis jika rutenya melewati internet, dan aktifkan validasi API key, dengan key yang sama di CAPSKIP_API_KEY. CapSkip membuka port-nya untuk semua alamat saat mode Server dimulai, jadi untuk membatasi akses hanya pada mesin yang Anda harapkan, tambahkan aturan blokir Windows Firewall untuk alamat lainnya. Pemecah CAPTCHA tetap berupa mesin Windows milik Anda sendiri, dan pemecahannya tetap tanpa kuota.

Kesalahan umum CAPTCHA Symfony Messenger

Apa yang Anda lihatPenyebabPerbaiki
No transport supports Messenger DSN doctrine://defaultdoctrine-bundle tidak ada, meskipun error menyebut doctrine-messengerTambahkan doctrine/doctrine-bundle dengan composer require
An exception occurred in the driver: could not find driverpdo_sqlite nonaktif, atau DATABASE_URL PostgreSQL dari recipe masih ada di .env. Keduanya menghasilkan teks error yang samaAktifkan ekstensi dan ganti URL-nya
no such table: messenger_messagesDSN masih memiliki auto_setup=0 dari recipe, sehingga tidak ada yang membuat tabelnyaHapus auto_setup=0, atau jalankan messenger:setup-transports
unable to open database filePath SQLite relatifMulai path dari %kernel.project_dir%
The doctrine/orm package is required when the doctrine.orm config is setdoctrine.yaml dari recipe masih adaHapus file itu dan pakai file PHP dari Langkah 2
You have requested a non-existent parameterdoctrine.yaml sudah dihapus dan tidak ada doctrine.php yang menggantikannyaTambahkan file doctrine.php
Invalid Messenger routing configuration: class not foundFile class pesan hilang atau namespace-nya salahBuat src/Message/SolveLogin.php seperti di Langkah 3
Unknown option found in DSN: [redeliver_timout]Opsi yang salah eja; pesan error mencantumkan opsi yang validPerbaiki ejaannya
Removing from transport after 3 retries, lalu pesan hilangTidak ada failed transport yang dikonfigurasiSetel failure_transport seperti di Langkah 2
Percobaan ulang lebih banyak daripada max_retriesRecoverableMessageHandlingException dipakaiLempar exception biasa, seperti di Langkah 4
Worker keluar saat Ctrl+C tanpa baris logTidak ada pcntl di WindowsTambahkan listener console handler
Login yang sama dikirim dua kaliredeliver_timeout lebih pendek daripada handler, atau langkah setelah pengiriman gagalNaikkan timeout dan tambahkan ledger
Signals are not supportedOpsi keepalive membutuhkan pcntlBiarkan opsi itu nonaktif di Windows

FAQ

Apakah ini berfungsi di Symfony 7.4 LTS?

Blok kode dijalankan di Symfony 8.1.8 dan PHP 8.4.15. Symfony 7.4 membutuhkan PHP 8.2 atau lebih baru, dan handler memakai parser HTML milik PHP 8.4, jadi di PHP yang lebih lama Anda perlu menggantinya dengan parser lain. Desain retry-nya sama, dan exception biasa berperilaku sama di kedua versi. Hanya RecoverableMessageHandlingException yang berbeda, seperti dijelaskan di Langkah 4. Perilaku di 7.4 berasal dari membaca source-nya, bukan dari menjalankannya.

Apa yang terjadi pada pesan yang gagal di setiap percobaan ulang?

Dengan failed transport dari Langkah 2, pesan itu pindah ke tabel yang sama dengan nama queue failed. Baca dengan messenger:failed:show, kirim lagi dengan messenger:failed:retry, dan buang dengan messenger:failed:remove. Perintah retry menjalankan handler di terminal Anda. Tanpa failed transport, perintah-perintah itu tidak ada, dan pesan dihapus. Messenger mencatat kegagalan akhir sebagai critical, dengan baris Removing from transport after 3 retries.

Bisakah saya menjalankan lebih dari satu worker?

Bisa. Jalankan messenger:consume di jendela kedua dan setiap worker mengambil pesan yang berbeda. Dalam pengujian, dua worker berjalan pada file SQLite yang sama tanpa error. Batas milik CapSkip sendiri berlaku di atasnya: solver reCAPTCHA-nya menjalankan 10 tugas sekaligus secara default, dan sisanya menunggu giliran.

Apa bedanya dengan queue Laravel?

queue:work milik Laravel memiliki timeout per job, tetapi membutuhkan pcntl, sehingga di Windows timeout itu diabaikan secara diam-diam. Lihat panduan memecahkan CAPTCHA di queue Laravel untuk penjelasannya. Messenger tidak memiliki timeout per job di platform mana pun. Risikonya ada pada exception retry, redelivery timeout, dan tidak adanya penanganan sinyal.

Versi singkatnya

Worker CAPTCHA Symfony Messenger membutuhkan lebih dari sekadar handler. Kirim input, bukan token, dan pecahkan CAPTCHA di dalam handler. Lempar exception biasa untuk mencoba ulang dan UnrecoverableMessageHandlingException untuk berhenti, dan tambahkan failed transport agar tidak ada yang hilang. Setel redeliver_timeout di atas handler terlama, yaitu sekitar 9 menit dengan batas SDK, dan tambahkan console handler Windows agar Ctrl+C menyelesaikan pemecahan. Lihat dokumentasi Symfony Messenger untuk semua opsi yang dipakai di sini.

Queue yang menyelesaikan beberapa ratus login per hari menghasilkan ribuan pemecahan per bulan. Jika semuanya dijalankan melalui sebuah pemecah captcha di mesin Windows Anda sendiri, angka itu tidak pernah berubah menjadi tagihan per pemecahan.