Symfony Messenger Worker’larında CAPTCHA Nasıl Çözülür (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üz | Neden | Düzeltme |
|---|---|---|
| No transport supports Messenger DSN doctrine://default | doctrine-bundle eksik, hata doctrine-messenger adını verse bile | doctrine/doctrine-bundle paketini composer require ile kurun |
| An exception occurred in the driver: could not find driver | pdo_sqlite kapalı ya da recipe’nin PostgreSQL DATABASE_URL değeri hâlâ .env içinde. İkisi de aynı hata metnini verir | Eklentiyi etkinleştirin ve URL’yi değiştirin |
| no such table: messenger_messages | DSN hâlâ recipe’nin auto_setup=0 ayarını içeriyor, bu yüzden tabloyu hiçbir şey oluşturmuyor | auto_setup=0 ayarını kaldırın ya da messenger:setup-transports komutunu çalıştırın |
| unable to open database file | Göreli bir SQLite yolu | Yolu %kernel.project_dir% ile başlatın |
| The doctrine/orm package is required when the doctrine.orm config is set | Recipe’nin doctrine.yaml dosyası hâlâ duruyor | Onu silin ve 2. adımdaki PHP dosyasını kullanın |
| You have requested a non-existent parameter | doctrine.yaml silindi ve yerine hiçbir doctrine.php gelmedi | doctrine.php dosyasını ekleyin |
| Invalid Messenger routing configuration: class not found | Mesaj sınıfı dosyası eksik ya da yanlış namespace’e sahip | src/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ı listeler | Yazımı düzeltin |
| Removing from transport after 3 retries, ardından mesaj kayboluyor | Hiçbir failed transport yapılandırılmamış | failure_transport ayarını 2. adımdaki gibi yapın |
| max_retries değerinden fazla yeniden deneme | RecoverableMessageHandlingException kullanılıyor | 4. adımdaki gibi sıradan bir istisna fırlatın |
| Worker, Ctrl+C ile hiçbir log satırı olmadan çıkıyor | Windows’ta pcntl yok | Konsol işleyicisini kuran dinleyiciyi ekleyin |
| Aynı giriş iki kez gönderildi | redeliver_timeout handler’dan kısa ya da gönderimden sonraki bir adım başarısız oldu | Zaman aşımını yükseltin ve iş defterini ekleyin |
| Signals are not supported | keepalive seçeneği pcntl gerektirir | Windows’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.
- PHP paketinin desteklediği tüm CAPTCHA türleri: PHP CAPTCHA çözücü sayfası.
- Onay kutulu reCAPTCHA’nın nasıl çalıştığı ve çözücünün neleri kapsadığı: reCAPTCHA v2 çözücü sayfası.
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.
