Symfony Messenger Worker’larında CAPTCHA Nasıl Çözülür (PHP)

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

Symfony Messenger’da CAPTCHA çözmek için sayfa adresini taşıyan küçük bir mesajı kuyruğa dispatch edin ve çözümü handler’ın içinde yapın. Kuyruğu tek bir SQLite dosyasında tutun; böylece bir Windows worker’ı için Redis ya da RabbitMQ gerekmez. Bu Symfony Messenger CAPTCHA kurulumunun dört tuzağı var ve bu rehber her birini ele alıyor. Yeniden deneme için yapılmış gibi görünen istisna max_retries değerini yok sayar. Messenger’da tek bir mesaj için zaman sınırı yoktur. Yeniden teslim zaman aşımı en yavaş çözümden kısaysa ikinci bir worker mesajı devralır ve yeniden çözer. Ayrıca Windows’taki PHP’de bulunmayan pcntl eklentisi olmadan Ctrl+C, worker’ı bir çözümün ortasında öldürür.

Neye ihtiyacınız var

  • reCAPTCHA çözücüsü açık hâlde bir Windows bilgisayarında çalışan CapSkip.
  • PHP 8.4.1 veya üzeri ve Composer. Symfony 8.1 bu PHP sürümünü gerektirir ve aşağıdaki handler PHP 8.4’ün HTML ayrıştırıcısını kullanır. Her şey Windows 11 üzerinde PHP 8.4.15, Symfony 8.1.8, doctrine/dbal 4.5.0 ve capskip/capskip 1.3.0 ile çalıştırıldı.
  • php.ini içinde etkinleştirilmiş üç PHP eklentisi: curl, pdo_sqlite ve openssl. Worker, düz bir http test sayfasına karşı yalnızca ilk ikisiyle çalıştı, ancak bir https sayfasını çekmek openssl gerektirir; Composer da paketleri indirirken openssl gerektirir.
  • Redis, RabbitMQ ya da veritabanı sunucusu gerekmez. Doctrine transport’u kuyruğu bir SQLite dosyasında tutar.
  • Çözücü için bir adres. Local modunda CapSkip 127.0.0.1 üzerinden yalnızca o cihaza yanıt verir. Server modunda ise ağ adresinizi veya genel IP’nizi dinler, böylece başka bir makinedeki bir worker onu API üzerinden çağırabilir. İkisi de şurada ayarlanır: bağlantı ayarları.

Aşağıdaki ölçümler, aynı bilgisayardaki sahte (mock) bir CapSkip ve sahte bir giriş sayfasından gelir; bunlar gerçek PHP SDK’sı ve gerçek Symfony bileşenleriyle kullanıldı. SDK’ya ve Messenger’a ait süreler, örneğin 120 saniye, kendi siteniz için de geçerlidir. Sahte sitenin kabul ve ret kuralları ise o sitenin kendi kurallarıdır.

1. Adım: projeyi oluşturun

Her şeyden önce php -v komutunu kontrol edin, çünkü bu rehberdeki her komut PATH içindeki PHP’yi kullanır. PATH içinde daha eski bir PHP önce geliyorsa (örneğin XAMPP 8.0 ile gelir), composer create-project durmaz. En yeni skeleton’ın PHP 8.4 gerektirdiğini söyleyen bir not yazdırır ve yine de eski bir sürümü kurar: PHP 8.0.30 üzerinde bu Symfony 6.0 oldu. Başlamadan önce bu pencerenin PATH değişkeninde PHP 8.4 klasörünü en başa koyun.

# 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, recipe’lerdeki Docker yapılandırmasının eklenip eklenmeyeceğini sorabilir. n yanıtını verin, çünkü varsayılan seçenek bu kurulumun kullanmadığı bir PostgreSQL servisi için bir compose.yaml ekler. İki ayrıntı daha önemlidir. Doctrine recipe’si, bu projenin kullanmadığı doctrine/orm paketini isteyen bir doctrine.yaml yazar; bu yüzden cache:clear, doctrine/orm gerektiğini söyleyen bir hatayla başarısız olur. Composer’ın no-scripts bayrağı bu adımı atlar ve son satır, recipe’lerin yazdığı iki YAML dosyasını siler. 2. adım bunların yerine PHP dosyaları koyar; Symfony bunları config/packages klasöründen aynı şekilde yükler.

2. Adım: transport’u bir SQLite dosyasına yönlendirin

Recipe’nin .env dosyasına eklediği DATABASE_URL ve MESSENGER_TRANSPORT_DSN satırlarını bunlarla değiştirin ve geri kalanını ekleyin. Parola gibi gerçek değerleri, Symfony skeleton’ının Git’e eklemediği .env.local dosyasına koyun.

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 yolu bilerek %kernel.project_dir% ile başlar. sqlite:///var/data.db gibi göreli bir yol, komut C:\ konumundan çalıştırıldığında unable to open database file hatasıyla başarısız oldu. Kendi var dizinine sahip bir klasörden çalıştırıldığında ise orada sessizce ikinci bir veritabanı oluşturdu; böylece iki komut iki farklı kuyruk gördü. Handler, CapSkip değişkenlerini Autowire ile okur, çünkü SDK onları kendiliğinden okumaz. CAPSKIP_API_KEY yalnızca CapSkip’te API anahtarı doğrulamasını açtıysanız önemlidir.

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

Transport ayarları ikinci dosyaya gider.

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

Asıl yükü üç ayar taşır. Yeniden deneme stratejisi, denemeler arasında yaklaşık 30, 60 ve 120 saniye bekler; bu, CapSkip’e geri gelmesi için zaman tanır. Messenger her gecikmeyi iki yönde de en fazla yüzde 10 oranında değiştirir ve bir testte 29.4, 57.0 ve 117.9 saniye loglandı. Varsayılan ayarlar üç kez yeniden dener ve ilk aralık bir saniyedir. Başarısız mesajlar için kullanılan failed transport göründüğünden daha önemlidir: o olmadan son yeniden denemesini kullanmış bir mesaj silinir ve kaybolur. redeliver_timeout ise aşağıda kendi bölümünde açıklanıyor.

3. Adım: mesajı yazın

Mesaj, giriş için gereken girdileri taşır; çözücünün ürettiği hiçbir şeyi taşımaz. Bir reCAPTCHA token’ı iki dakika geçerlidir ve yalnızca bir kez işe yarar, Google’ın dokümantasyonuna göre. Sahte site bu kuralı uygular. Token taşıyan bir mesaj hemen çalıştığında kabul edildi, 130 saniye sonra çalıştığında reddedildi. Yalnızca adresi taşıyan bir mesaj yeniden çözdü ve kabul edildi. Bu yüzden handler sayfayı çeker, çözer ve art arda gönderir.

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

İş kimliği (job id), mesaj oluşturulduğunda bir kez üretilir ve Messenger aynı serileştirilmiş mesajı yeniden gönderdiği için yeniden denemeler boyunca aynı kalır. Bu rehberin sonuna doğru yer alan iş defteri (ledger) buna dayanır. Messenger mesajı PHP serialize ile saklar; bu yüzden mesajlar kuyruktayken bu sınıfı yeniden adlandırmak o satırları bozar.

4. Adım: neyin yeniden deneneceğine karar verin

CapSkip dört istisna türü fırlatır ve hepsi yeni bir denemeyi hak etmez. ValidationException, çağrının kendisinin hatalı olduğu ve bir sonraki sefer de aynı şekilde başarısız olacağı anlamına gelir. ApiException yalnızca ERROR_CAPTCHA_UNSOLVABLE için yeniden denemeye değer; CapSkip, bu çözümün işe yaramadığını böyle söyler. NetworkException (CapSkip çalışmıyor) ve TimeoutException yeniden denemeye değerdir. CapSkip’te Error Replacement metnini değiştirdiyseniz, ERROR_CAPTCHA_UNSOLVABLE eşleşmesi onu göremez ve bu hatalar yeniden denenmez.

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

Sıradan bir PHP istisnası, yeniden deneme stratejisi tarafından max_retries değerine kadar yeniden denenir. UnrecoverableMessageHandlingException yeniden denemeleri atlar ve doğrudan failed transport’a gider: testte tek bir deneme yaptı ve Removing from transport after 0 retries satırını logladı.

Adı bunu düşündürse de burada RecoverableMessageHandlingException kullanmayın. Symfony 8.1.8’de, beşinci argüman olarak false vermediğiniz sürece max_retries ne derse desin yeniden dener. max_retries 3 iken bir testte 14 saniyede 7 deneme yaptı ve mesaj sonrasında hâlâ kuyruktaydı. 7.4 kaynak kodunda böyle bir argüman yoktur ve her zaman yeniden dener. Sıradan bir istisna ikisinde de aynı davranır.

5. Adım: handler’ı yazın

Handler, giriş sayfasını Symfony’nin HttpClient’ı ile çeker, CSRF token’ını ve sitekey’i okur, CapSkip’ten bir token ister ve formu gönderir. Bu kurulumda HttpClient’ın cookie jar’ı yoktur, bu yüzden handler ilk Set-Cookie başlığını gönderime elle kopyalar. Django’da bu, CSRF token’ıyla birlikte gelen çerezdir; siteniz daha fazlasını ayarlıyorsa birkaçını birleştirin. Ayrıca Referer da gönderir, çünkü HTTPS üzerinden çalışan Django gibi bazı framework’ler, Referer olmayan bir form gönderimini reddeder. Yolları ve alan adlarını kendi sitenizin kullandıklarıyla değiştirin.

<?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 metodu, code öğesi token olan bir dizi döndürür. Diğer CAPTCHA türleri için eşleşen SDK metodunu çağırın ve geri kalanını olduğu gibi bırakın. PHP ile reCAPTCHA v2 çözme rehberi aynı çağrıyı bir kuyruğun dışında ele alır.

Handler, 400 ve üzeri durum kodlarını başarısızlık sayar. Yanlış parolaya 200 durum koduyla ve yine giriş formuyla yanıt veren bir site için sayfanın kendisinde bir kontrol gerekir; aksi hâlde aşağıdaki iş defteri işi tamamlanmış olarak kaydeder. Reddedilen bir giriş burada sıradan bir istisna fırlatır, bu yüzden diğerleri gibi yeniden denenir ve yanlış bir parola dört çözüme mal olur. Nihai olduğunu bildiğiniz her yanıt için UnrecoverableMessageHandlingException fırlatın.

6. Adım: bir mesajı dispatch edin ve worker’ı başlatın

Küçük bir konsol komutu kuyruğa bir mesaj koyar. Symfony 8 konsol komutları, burada olduğu gibi sade, çağrılabilir (invokable) sınıflar olabilir.

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

Bir girişi kuyruğa ekleyin ve worker’ı başlatın. Transport tablosunu ilk kullanımda oluşturur. https://example.com yerine kendinize ait, reCAPTCHA içeren bir sayfa yazın: yer tutucuyla sayfa çekme başarısız olur ve iş yeniden deneme döngüsüne girer.

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

Worker, -vv olmadan mesaj başına neredeyse hiçbir şey yazdırmaz ve Symfony’nin konsol logunda zaman damgaları yoktur; gerekirse kendi zaman damgalarınızı ekleyin. Bellek sınırı güvenlidir: worker yaklaşık 12 MB civarında kaldı. Zaman sınırı ise bir uyarı gerektirir, bunu sıradaki bölüm ele alıyor.

Tek bir mesaj için zaman sınırı yoktur

Messenger’da mesaj başına zaman aşımı yoktur. Worker’ın zaman sınırı seçeneği yalnızca mesajlar arasında kontrol edilir, bu yüzden bir handler’ı yarıda kesemez: kuyrukta iki mesaj, 20 saniyelik bir çözüm ve 5 saniyelik bir zaman sınırıyla worker 23.1 saniye çalıştı, ilk mesajı bitirdi ve ikincisini kuyrukta bıraktı. max_execution_time ayarlamak da işe yaramaz. Komut satırındaki PHP, php.ini içindeki değeri yok sayar ve değeri -d seçeneği ya da set_time_limit ile zorlamak, engellenen çağrı 120.1 saniyede zaten döndükten sonra yalnızca ölümcül bir hata üretti.

Takılı kalan bir çözümü sınırlayan şey SDK’nın kendisidir. PHP SDK’sı her HTTP çağrısına 30 saniyelik bir bağlantı sınırı ve 120 saniyelik bir toplam sınır verir ve bunları değiştirmek için bir seçenek sunmaz. Bağlantıyı kabul edip görev gönderimine hiç yanıt vermeyen bir CapSkip, worker’a 120.0 saniyeye mal oldu ve ardından bir NetworkException fırlattı. Görevi alıp sonra sonuç yoklamalarına hiç yanıt vermeyen bir CapSkip ise 362.1 saniyeye mal oldu. SDK her sessiz yoklamayı henüz hazır değil olarak değerlendirir; bu yüzden 300 saniyelik recaptchaTimeout süresinin dolduğunu fark edip bir TimeoutException fırlatmadan önce, her biri 120 saniyelik üç yoklama geçmişti.

Son tarihten hemen önce sessizleşen bir yoklama, bu tarihin ardından tam 120 saniye daha sürebilir. recaptchaTimeout 20 iken, 14 saniye sonra sessizleşen bir CapSkip çözümü 142.8 saniyede sonlandırdı. Bunların her biri, 4. adımdaki eşlemenin yeniden denediği bir istisnayla sona erer. Bunlar planlamanız gereken sayıyı belirler: varsayılan ayarlarla bir handler yaklaşık 9 dakikaya kadar sürebilir; bu, görev gönderimi için 120 saniye, yoklama için 300 ve son yoklama için 120 saniye artı sayfa çekme ve form gönderimidir.

redeliver_timeout neden iki kez çözüp çözmeyeceğinizi belirler

Doctrine transport’u bir mesajı teslim edilmiş olarak işaretler ve handler bitene kadar onu tabloda tutar. Bir mesaj redeliver_timeout süresinden daha uzun süredir teslim edilmiş durumdaysa, ilk worker hâlâ çalışıyor olsa da olmasa da herhangi bir worker onu yeniden alabilir. Varsayılan değer 3600 saniyedir.

redeliver_timeout 5 saniye ve çözüm 15 saniye sürerken yapılan bir testte, ikinci bir worker aynı mesajı birincisinden 5.6 saniye sonra aldı. CapSkip iki kez çözdü ve site iki gönderimi kabul etti. Test sitesinin tek kullanımlık token kontrolü bunu engellemedi, çünkü her worker kendi token’ını çözmüştü; kontrol yalnızca iki kez kullanılan tek bir token’ı reddeder. İkinci worker bitirdiğinde onayı hiçbir satırı silmedi ve hata da vermedi. Zaman aşımı 40 saniye iken aynı test tek bir çözüm ve tek bir gönderim yaptı.

Bu yüzden değer, en yavaş handler’ınızdan uzun olmalıdır. Yukarıdaki SDK sınırlarıyla bir handler yaklaşık 9 dakika sürebilir, bu nedenle 2. adımdaki messenger.php dosyası 900 saniye ayarlar. Bunun bedeli, çöken bir worker’ın tuttuğu bir mesajın, başka bir worker onu alana kadar o kadar beklemesidir. Daha düşük bir değer bir güvenlik ağıdır, çözüm değil. İlk worker’ın çözümünün takıldığı bir testte, 20 saniyelik bir zaman aşımı ikinci bir worker’ın mesajı devralıp bitirmesine izin verdi. Takılan worker ise 120 saniyede başarısız oldu ve kendi yeniden denemesini zamanladı; böylece iş ikinci kez çözüldü ve site iki gönderimi kabul etti.

Çözümü kaybetmeden worker’ı durdurun

Messenger normalde SIGTERM ve SIGINT sinyallerini pcntl üzerinden dinler; bu yüzden Ctrl+C, mesajı bitir, sonra dur anlamına gelir. Windows’taki PHP’de pcntl yoktur. Gerçek bir Ctrl+C olayı, worker’ı 0.01 saniye içinde bir çözümün ortasında öldürdü; çıkış kodu 0xC000013A idi ve hiçbir log satırı yoktu. Mesaj, teslim zamanı ayarlı hâlde tabloda kaldı; bu yüzden varsayılan zaman aşımıyla başka hiçbir worker onu bir saat boyunca görmezdi.

redeliver_timeout 10 saniye iken ikinci bir worker mesajı, kuyruğa alındıktan yaklaşık 22 saniye sonra aldı ve yeniden çözdü; bu da ilk çözümü boşa harcamış oldu.

Çözüm, worker başladığında Windows konsol işleyicisini kuran bir olay dinleyicisidir.

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

Dinleyici varken Ctrl+C, Stopping worker satırını logladı; worker çözümü ve gönderimi bitirdi, mesajı onayladı ve yaklaşık 15 saniye sonra 0 koduyla çıktı. keepalive seçeneği Windows’ta işe yaramaz: pcntl gerektirir ve Signals are not supported iletisiyle durur. messenger:stop-workers komutu ise iki süreç arasında çalışır. Boşta bekleyen bir worker 0.3 saniyede durdu, meşgul olan ise önce mesajını bitirdi ve sonraki mesajı kuyrukta bıraktı.

Dinleyicinin bir maliyeti var. Ctrl+C artık takılı kalmış bir çözümü iptal etmez, çünkü artık bu mesajdan sonra dur anlamına gelir ve tekrar basmak çıkışı zorlamaz. Bir testte, iki saniye arayla dört kez basmak yine de 20 saniyelik bir çözümün bitmesini bekledi. CapSkip sessizleştiyse bu birkaç dakika sürebilir; bu yüzden süreci hemen kapatmanız gerektiğinde Task Manager’dan sonlandırın.

Worker’ı çalışır durumda tutun

Bir worker’ın ara sıra çıkması amaçlanmıştır, çünkü zaman sınırı ve bellek sınırı seçenekleri onu bilerek durdurur. PowerShell’de bir döngü her seferinde yenisini başlatır. Log dosyası düz metin olsun diye worker’ı cmd üzerinden çalıştırır; Windows PowerShell 5.1’de yerel bir komutu yönlendirmek UTF-16 yazar ve hata gürültüsü ekler.

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
}

Testte döngü, zaman sınırı nedeniyle çıkıştan sonra ve süreç öldürüldükten sonra yeni bir worker başlattı. Döngüyü oturum açılırken, örneğin Task Scheduler’dan ya da bir servis olarak başlatmak burada test edilmedi.

Aynı işi iki kez çözmeyin

Yeniden denemeler handler’ın tamamını tekrarlar. Form gönderimi başarılı olursa ve sonraki bir adım, örneğin sonucu kaydederken, istisna fırlatırsa, bir sonraki deneme yeniden çözer ve yeniden gönderir. Gönderimden sonra her zaman başarısız olan bir kaydetme adımıyla yapılan bir testte, 5. adımdaki handler tek bir mesaj için 4 çözüm ve 4 kabul edilen gönderim yaptı.

Küçük bir iş defteri bunu çözer. Mesajdaki iş kimliği, gönderimden hemen sonra bir tabloya yazılır; kimliği bulan bir yeniden deneme çözümü atlar ve yalnızca başarısız olan kısmı çalıştırır.

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

Handler’ın ikinci sürümü önce iş defterine sorar. 5. adımdaki dosyayı bununla değiştirin.

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

İş defteriyle aynı başarısız kaydetme adımı 1 çözüm ve 1 gönderim yaptı; yeniden denemeler doğrudan kaydetme adımına gitti. İş defteri, iki worker’ın aynı işi aynı anda çalıştırmasına karşı koruma sağlamaz. Bunu yalnızca yeterince uzun bir redeliver_timeout sağlar. job_done tablosu iş başına bir satır alır, bu yüzden eski satırları ara sıra silin.

CapSkip’i başka bir makinede çalıştırmak

Worker ile çözücünün aynı bilgisayarı paylaşması gerekmez. Bağlantı ayarlarından CapSkip’i Server moduna alın; ağ adresinizi veya genel IP’nizi dinlemeye başlar. Ardından .env ya da .env.local içindeki CAPSKIP_HOST değerini bu adrese ayarlayın.

Rota internetten geçiyorsa statik bir genel IP kullanın ve API anahtarı doğrulamasını açıp aynı anahtarı CAPSKIP_API_KEY içinde de verin. CapSkip, Server modu başladığında portunu her adrese açar; bu yüzden erişimi beklediğiniz makinelerle sınırlamak için diğerlerine yönelik bir Windows Firewall engelleme kuralı ekleyin. Çözücü hâlâ sizin kendi Windows makinenizdir ve çözüm başına ücretlendirme hâlâ yoktur.

Sık görülen Symfony Messenger CAPTCHA hataları

GördüğünüzNedenDüzeltme
No transport supports Messenger DSN doctrine://defaultdoctrine-bundle eksik, hata doctrine-messenger adını verse biledoctrine/doctrine-bundle paketini composer require ile kurun
An exception occurred in the driver: could not find driverpdo_sqlite kapalı ya da recipe’nin PostgreSQL DATABASE_URL değeri hâlâ .env içinde. İkisi de aynı hata metnini verirEklentiyi etkinleştirin ve URL’yi değiştirin
no such table: messenger_messagesDSN hâlâ recipe’nin auto_setup=0 ayarını içeriyor, bu yüzden tabloyu hiçbir şey oluşturmuyorauto_setup=0 ayarını kaldırın ya da messenger:setup-transports komutunu çalıştırın
unable to open database fileGöreli bir SQLite yoluYolu %kernel.project_dir% ile başlatın
The doctrine/orm package is required when the doctrine.orm config is setRecipe’nin doctrine.yaml dosyası hâlâ duruyorOnu silin ve 2. adımdaki PHP dosyasını kullanın
You have requested a non-existent parameterdoctrine.yaml silindi ve yerine hiçbir doctrine.php gelmedidoctrine.php dosyasını ekleyin
Invalid Messenger routing configuration: class not foundMesaj sınıfı dosyası eksik ya da yanlış namespace’e sahipsrc/Message/SolveLogin.php dosyasını 3. adımdaki gibi oluşturun
Unknown option found in DSN: [redeliver_timout]Yanlış yazılmış bir seçenek; mesaj geçerli olanları listelerYazımı düzeltin
Removing from transport after 3 retries, ardından mesaj kayboluyorHiçbir failed transport yapılandırılmamışfailure_transport ayarını 2. adımdaki gibi yapın
max_retries değerinden fazla yeniden denemeRecoverableMessageHandlingException kullanılıyor4. adımdaki gibi sıradan bir istisna fırlatın
Worker, Ctrl+C ile hiçbir log satırı olmadan çıkıyorWindows’ta pcntl yokKonsol işleyicisini kuran dinleyiciyi ekleyin
Aynı giriş iki kez gönderildiredeliver_timeout handler’dan kısa ya da gönderimden sonraki bir adım başarısız olduZaman aşımını yükseltin ve iş defterini ekleyin
Signals are not supportedkeepalive seçeneği pcntl gerektirirWindows’ta bu seçeneği kapalı bırakın

FAQ

Bu, Symfony 7.4 LTS’te çalışır mı?

Bloklar Symfony 8.1.8 ve PHP 8.4.15 üzerinde çalıştırıldı. Symfony 7.4, PHP 8.2 veya üzerini gerektirir ve handler PHP 8.4’ün HTML ayrıştırıcısını kullanır; bu yüzden daha eski bir PHP’de başka bir ayrıştırıcıya geçmeniz gerekir. Yeniden deneme tasarımı aynıdır ve sıradan bir istisna her iki sürümde de aynı davranır. Yalnızca RecoverableMessageHandlingException farklıdır, 4. adımın açıkladığı gibi. 7.4 davranışı, çalıştırılarak değil, kaynak kodu okunarak çıkarılmıştır.

Her yeniden denemede başarısız olan bir mesaja ne olur?

2. adımdaki failed transport ile mesaj, aynı tabloya failed kuyruk adı altında taşınır. Onu messenger:failed:show ile okuyun, messenger:failed:retry ile yeniden gönderin ve messenger:failed:remove ile silin. retry komutu handler’ı terminalinizde çalıştırır. Bir failed transport olmadan bu komutlar yoktur ve mesaj silinir. Messenger son hatayı critical seviyesinde, Removing from transport after 3 retries satırıyla loglar.

Birden fazla worker çalıştırabilir miyim?

Evet. messenger:consume komutunu ikinci bir pencerede başlatın; her worker farklı mesajlar alır. Testlerde iki worker aynı SQLite dosyası üzerinde hatasız çalıştı. Bunun üstüne CapSkip’in kendi sınırı da uygulanır: reCAPTCHA çözücüsü varsayılan olarak aynı anda 10 görev çalıştırır, geri kalanlar sırasını bekler.

Bu, Laravel kuyruklarından nasıl farklıdır?

Laravel’in queue:work komutunun iş başına bir zaman aşımı vardır, ancak bu pcntl gerektirir; bu yüzden Windows’ta sessizce yok sayılır. Bunu Laravel kuyruklarında CAPTCHA çözme rehberi açıklar. Messenger’ın hiçbir platformda iş başına zaman aşımı yoktur. Riskleri şunlardır: yeniden deneme istisnası, yeniden teslim zaman aşımı ve eksik sinyal işleme.

Kısa özet

Bir Symfony Messenger CAPTCHA worker’ı, bir handler’dan fazlasına ihtiyaç duyar. Token’ı değil girdileri gönderin ve çözümü handler’ın içinde yapın. Yeniden denemek için sıradan bir istisna, durdurmak için UnrecoverableMessageHandlingException fırlatın ve hiçbir şey kaybolmasın diye bir failed transport ekleyin. redeliver_timeout değerini en uzun handler’ın üzerine ayarlayın (SDK’nın sınırlarıyla bu yaklaşık 9 dakikadır) ve Ctrl+C çözümü bitirsin diye Windows konsol işleyicisini ekleyin. Symfony Messenger dokümantasyonu burada kullanılan her seçeneği kapsar.

Günde birkaç yüz girişi temizleyen bir kuyruk, ayda binlerce çözüm yapar. Bunları kendi Windows makinenizde çalışan bir captcha çözücü üzerinden yürütmek, bu sayının asla çözüm başına bir faturaya dönüşmemesi demektir.