PHP ile cURL ve DOMXPath Kullanarak Friendly Captcha Nasıl Çözülür

solve friendly captcha in php - How to Solve Friendly Captcha in PHP With cURL and DOMXPath

PHP ile Friendly Captcha çözmek için sayfayı, çerez motoru açık tek bir cURL handle’ı ile çekin. frc-captcha widget’ını ve script etiketlerini DOMXPath ile okuyun, v1 mi v2 mi olduğuna da script’e bakarak karar verin. Ardından sitekey’i, sayfa URL’sini, sürümü ve script adresini capskip/capskip paketinin friendlyCaptcha metoduna verin. Dönen token’ı aynı handle üzerinden, formun gizli alanlarıyla birlikte, v2’de frc-captcha-response, v1’de frc-captcha-solution alanında gönderin. İki PHP alışkanlığı bu akışı bozar. Her istek için yeni bir curl_init oturum çerezini çöpe atar, bu yüzden CSRF korumalı bir form kusursuz bir token’ı bile reddeder. Ayrıca çağrı, çözüm bitene kadar bloklar; bu yüzden art arda elli form, çözümlerini birlikte göndermediğiniz sürece uzun sürer. CapSkip, Friendly Captcha desteğini 1.4.0 sürümünde ekledi; bu rehber de tüm süreci sizin tarafınızda hiçbir tarayıcı olmadan yürütüyor.

Neye ihtiyacınız var

  • Bir Windows makinesinde çalışan CapSkip 1.4.0 veya üzeri. Friendly Captcha desteği, CaptchaFox ve Capy Puzzle ile birlikte bu sürümle geldi.
  • curl ve dom eklentileriyle PHP 8.0 veya üzeri ve capskip/capskip paketinin 1.3.0 veya üzeri bir sürümü; friendlyCaptcha metodunu içeren ilk sürüm budur. Composer, ext-curl eksik olduğu için paketi reddederse php.ini dosyanızdaki extension=curl satırının başındaki yorum işaretini kaldırın.
  • Widget’ı gösteren sayfanın URL’si. Sitekey, script adresi ve alan adı bu sayfanın HTML’inden çıkar.
  • Çözücü için bir adres. Local modu 127.0.0.1 üzerinden yalnızca o cihaza yanıt verir; Server modu ise ağ adresinizi veya genel IP’nizi dinler, böylece başka bir makinedeki bir PHP uygulaması onu API üzerinden çağırabilir. İkisi de bağlantı ayarları bölümünde yer alır; ne zaman geçiş yapmanız gerektiğini 4. adım anlatıyor.
# composer require capskip/capskip
composer require capskip/capskip

Betikleri komut satırından çalıştırın. Kötü bir günde bir Friendly Captcha çözümü dakikalar sürebilir, bir web isteği ise nadiren o kadar uzun yaşar; nedenini 4. adım açıklıyor.

1. Adım: sayfayı tek bir cURL handle’ı ile çekin

PHP’nin cURL eklentisi siz istemedikçe hiçbir çerez saklamaz. Bir handle çerez deposu olmadan başlar ve her istek için açılan yeni bir handle tamamen boş başlar; dolayısıyla sayfanın ayarladığı oturum çerezi, siz formu gönderene kadar çoktan kaybolmuştur. CSRF token’ını bu oturuma bağlayan bir site de, CAPTCHA token’ı ne kadar iyi olursa olsun, gönderimi çoğu zaman bir 403 ile reddeder.

Sorunu tek bir seçenek çözer. CURLOPT_COOKIEFILE değerini boş bir dizeye ayarlamak, hiçbir dosya okumadan ya da yazmadan o handle için cURL’ün bellek içi çerez motorunu açar; tıpkı curl_setopt referansında anlatıldığı gibi. Handle’ı saklayın ve gönderim için yeniden kullanın; böylece sitenin ayarladığı her çerez, bir yönlendirmede ayarlananlar da dahil, gönderimle birlikte gider.

<?php
// composer require capskip/capskip
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

const PAGE_URL = 'https://example.com/signup';

// One handle for the whole run. An empty COOKIEFILE switches on the
// in-memory cookie engine, so the session survives until the post.
$http = curl_init();
curl_setopt_array($http, [
    CURLOPT_URL => PAGE_URL,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_COOKIEFILE => '',
    // PHP's cURL sends no User-Agent at all unless you set one.
    CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
]);
$html = curl_exec($http);
if ($html === false) {
    throw new RuntimeException('fetch failed: ' . curl_error($http));
}
// The address the page finally came from, after any redirects.
$pageUrl = curl_getinfo($http, CURLINFO_EFFECTIVE_URL);

// Without a meta charset, loadHTML reads UTF-8 as Latin-1.
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $html);
$xp = new DOMXPath($doc);

Oradaki üç satır, sizi sonradan hata ayıklamaktan kurtarır. PHP’nin cURL’ü, siz ayarlamadıkça hiçbir User-Agent başlığı göndermez ve bazı siteler bu başlığı olmayan bir isteği geri çevirir. CURLINFO_EFFECTIVE_URL, sayfanın en sonunda geldiği adresi verir; ilk adres yönlendirme yaptığında hem çözücü hem de Referer için doğru URL budur. Kodlama öneki ise sayfa hiçbir charset bildirmediğinde loadHTML’in sayfayı Latin-1 olarak okumasını engeller; aksi halde aksanlı bir gizli değer, geri dönüşte anlamsız karakterlere dönüşür.

2. Adım: widget’ı okuyun, v1 ya da v2’yi seçin ve friendlyCaptcha metodunu çağırın

Her iki sürüm de aynı öğeyi oluşturur: frc-captcha sınıfına ve bir data-sitekey özniteliğine sahip bir div. Bu yüzden widget tek başına protokol hakkında hiçbir şey söylemez. Bunu söyleyen script etiketidir. Bir v2 sitesi, dosyası site.min.js olan @friendlycaptcha/sdk paketini, bir v1 sitesi ise dosyası widget.module.min.js olan friendly-challenge paketini yükler. DOMXPath widget’ı, onu çevreleyen formu ve her script’i bulur:

$widget = $xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " frc-captcha ")]')->item(0);
if (!$widget instanceof DOMElement || $widget->getAttribute('data-sitekey') === '') {
    throw new RuntimeException('No frc-captcha widget in the HTML; it may be built by JavaScript.');
}
$form = $xp->query('ancestor::form[1]', $widget)->item(0);

// Every script except nomodule fallbacks, as full addresses.
$scripts = [];
foreach ($xp->query('//script[@src][not(@nomodule)]') as $tag) {
    $scripts[] = absoluteUrl($tag->getAttribute('src'), $pageUrl);
}

Kendi sunucusunda barındırılan widget’lar için tam adresler önemlidir. v2’de CapSkip, çözümü yürütmek için tam olarak bu script’i kendi tarayıcısında yükler ve /vendor/v2/site.min.js gibi yalın bir yol hiçbir zaman yüklenmez. PHP’nin parse_url fonksiyonu bir URL’yi parçalarına ayırır ama göreli bir URL’yi bir sayfaya göre çözümleyemez. Bu yüzden işte mutlak, protokole göreli, köke göreli ve nokta segmentli yolları, ayrıca # veya ?step=2 gibi aynı sayfayı gösteren form action değerlerini işleyen küçük bir adres çözümleyici:

// Resolve a script src against the page URL, as a browser would.
function absoluteUrl(string $src, string $base): string
{
    if (preg_match('~^[a-z][a-z0-9+.-]*:~i', $src)) {
        return $src;                                   // already absolute
    }
    if (str_starts_with($src, '#')) {
        return strtok($base, '#');                     // same page
    }
    if (str_starts_with($src, '?')) {
        return strtok($base, '?#') . $src;             // same path, new query
    }
    $b = parse_url($base);
    if (str_starts_with($src, '//')) {
        return $b['scheme'] . ':' . $src;              // protocol-relative
    }
    $origin = $b['scheme'] . '://' . $b['host'] . (isset($b['port']) ? ':' . $b['port'] : '');
    $path = str_starts_with($src, '/') ? $src : preg_replace('~[^/]*$~', '', $b['path'] ?? '/') . $src;
    $out = [];
    foreach (explode('/', $path) as $seg) {
        if ($seg === '..') {
            if (count($out) > 1) {
                array_pop($out);
            }
        } elseif ($seg !== '.') {
            $out[] = $seg;
        }
    }
    return $origin . implode('/', $out);
}

CapSkip sürümü sabit bir sırayla seçer ve ilk cevapta durur: önce sizin verdiğiniz sürüm, sonra module_script olarak verdiğiniz script adresi, en son da varsayılan v1. Tuzak bu varsayılandır. /assets/app.4f2a.js gibi bundle edilmiş bir script CapSkip’e hiçbir şey söylemez; bu yüzden CapSkip v1 çözer ve bir v2 sitesi her token’ı reddeder. Bunun yerine kararı, tahmin yürütmeyi reddedebileceğiniz kendi kodunuzda verin:

// Returns [version, script src or null], or throws if the page
// does not say which protocol it uses.
function friendlyVersion(array $scripts, DOMElement $widget): array
{
    // Package names are unambiguous, so check them first.
    foreach ($scripts as $src) {
        if (str_contains($src, '@friendlycaptcha/sdk')) return ['v2', $src];
        if (str_contains($src, 'friendly-challenge')) return ['v1', $src];
    }
    // Self-hosted builds usually keep the file name. Themes ship their
    // own site.min.js too, so only trust a path that says friendly.
    foreach ($scripts as $src) {
        if (!str_contains(strtolower($src), 'friendly')) continue;
        $file = basename((string) parse_url($src, PHP_URL_PATH));
        if (in_array($file, ['site.min.js', 'site.compat.min.js'], true)) return ['v2', $src];
        if (in_array($file, ['widget.module.min.js', 'widget.min.js', 'widget.polyfilled.min.js'], true)) return ['v1', $src];
    }
    // Last resort: v2 and v1 name their widget options differently.
    if ($widget->hasAttribute('data-api-endpoint') || $widget->hasAttribute('data-form-field-name')) return ['v2', null];
    if ($widget->hasAttribute('data-puzzle-endpoint') || $widget->hasAttribute('data-solution-field-name')) return ['v1', null];
    throw new RuntimeException('v1 or v2? Read the page and set it by hand.');
}

Ardından çağrıyı yapın. friendlyCaptcha metodu sitekey’i, sayfa URL’sini ve bir seçenekler dizisini alır ve herhangi bir şey göndermeden önce değeri null olan her seçeneği atar:

use CapSkip\CapSkip;

$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);
[$version, $script] = friendlyVersion($scripts, $widget);
// EU sites: data-api-endpoint="eu" (v2) or data-puzzle-endpoint (v1)
$endpoint = $widget->getAttribute('data-api-endpoint') ?: $widget->getAttribute('data-puzzle-endpoint');

$result = $solver->friendlyCaptcha($widget->getAttribute('data-sitekey'), $pageUrl, [
    'version' => $version,
    'module_script' => $script,
    'api_server' => $endpoint ?: null,
]);

echo substr($result['token'], 0, 40), PHP_EOL;   // v2 tokens start with AQQA.

Endpoint değerindeki ?: null ifadesine dikkat edin. DOMElement’in getAttribute metodu, sayfada bulunmayan bir öznitelik için asla null değil, boş bir dize döndürür; SDK ise yalnızca null değerleri atar. Bu ifade olmasaydı boş bir api_server istekle birlikte giderdi. Bu seçenek, Friendly Captcha’nın EU endpoint’ini kullanan siteler içindir. Global endpoint bir EU sitekey’i için de token üretir; dolayısıyla orada çözmek yalnızca sitenin kendi kontrolünde başarısız olur ve bu, yanlış sürümdeki sessiz hatanın aynısıdır. Sonucun token girdisi gönderilecek dizedir, code girdisi aynı dizeyi tutar, captchaId ise CapSkip’in bu iş için verdiği kimliktir. v1, v2, 1 veya 2 dışında bir sürüm, boş bir sitekey ya da metodun tanımadığı bir seçenek, makinenizden daha hiçbir istek çıkmadan ValidationException fırlatır. PHP 8.4 ayrıca querySelector desteği ve gerçek bir HTML5 ayrıştırıcısıyla gelen Dom\HTMLDocument sınıfını da sunar. Ancak bu sınıfın öğeleri DOMElement değildir ve getAttribute metodu eksik bir öznitelik için null döndürür; bu yüzden bu rehberdeki kodun onunla çalışabilmesi için değişiklik gerekir.

3. Adım: token’ı formun kendi alanlarıyla birlikte gönderin

Token alanı, çektiğiniz HTML’de yoktur, çünkü onu widget script’i tarayıcıda oluşturur. Bu yüzden alanı, sürümün ve widget’ın kullandığı adla kendiniz eklersiniz. Formun taşıdığı diğer her şey, gizli bir CSRF token’ı da dahil, formun kendisinden gelir; gönderim de 1. adımdaki handle üzerinden yapılır:

// The fields a browser would send: hidden ones, text, checked boxes.
$fields = [];
foreach ($xp->query('.//input[@name]', $form) as $input) {
    $type = strtolower($input->getAttribute('type'));
    if ($input->hasAttribute('disabled') || in_array($type, ['submit', 'button', 'image', 'file', 'reset'], true)) continue;
    $checkable = in_array($type, ['checkbox', 'radio'], true);
    if ($checkable && !$input->hasAttribute('checked')) continue;
    // A checked box with no value attribute submits "on".
    $fields[$input->getAttribute('name')] = $checkable && !$input->hasAttribute('value') ? 'on' : $input->getAttribute('value');
}
$fields['email'] = 'YOUR_EMAIL';

// A renamed field wins; otherwise the default for the version.
$field = $widget->getAttribute('data-form-field-name')
    ?: $widget->getAttribute('data-solution-field-name')
    ?: ($version === 'v2' ? 'frc-captcha-response' : 'frc-captcha-solution');
$fields[$field] = $result['token'];

curl_setopt_array($http, [
    CURLOPT_URL => absoluteUrl($form->getAttribute('action') ?: $pageUrl, $pageUrl),
    CURLOPT_POST => true,
    // A string, not the array: an array makes cURL send multipart.
    CURLOPT_POSTFIELDS => http_build_query($fields),
    // Browsers send the page as Referer; some servers refuse a post without it.
    CURLOPT_REFERER => $pageUrl,
]);
$response = curl_exec($http);
echo curl_getinfo($http, CURLINFO_RESPONSE_CODE), PHP_EOL;

Döngü, bir tarayıcının bu form için göndereceği alanları toplar; CSRF token’ı da siz adını anmadan sunucuya bu sayede geri döner. Gönder düğmesini, devre dışı alanları, seçim menülerini ve metin alanlarını atlar; site bunları kontrol ediyorsa düğmeyi, menüleri ve metin alanlarını adlarıyla ekleyin. CURLOPT_POSTFIELDS seçeneğine dizinin kendisini değil, http_build_query ile üretilmiş bir dizeyi verin: bir dizi verildiğinde cURL multipart/form-data gönderir, bir tarayıcı ise bu formu URL kodlamalı olarak gönderir. Sayfa URL’sini Referer olarak da gönderin. Aralarında Django’nun da bulunduğu bazı framework’ler, çerez ve token ne kadar doğru olursa olsun Referer veya Origin içermeyen bir HTTPS form gönderimini reddeder. Bir v2 token’ı yaklaşık altı kilobayttır, bu yüzden yeri gövdedir, asla bir sorgu dizesi değildir; bir v1 token’ı ise noktalarla ayrılmış dört parçadan oluşur ve birkaç yüz karakterdir. Token’a hiç dokunmadan olduğu gibi iletin.

İki durumda tarayıcınızın DevTools’una bakmanız gerekir. v1’de tek bir kısa çizgiye ayarlanmış data-solution-field-name, widget’ın hiçbir alan yazmadığı ve token’ı sitenin kendi script’inin başka bir yolla gönderdiği anlamına gelir. Bazı siteler de formu göndermek yerine JavaScript ile JSON gönderir. Her iki durumda da formu bir kez elle gönderin ve sayfanın gerçekte yaptığı isteği kopyalayın.

Her token tek bir gönderim için geçerlidir. Friendly Captcha’nın doğrulaması daha önce kullanılmış veya süresi dolmuş bir yanıtı reddeder, bu yüzden her form için yeniden çözün.

4. Adım: tek bir PHP sürecinden çok sayıda form ve çözücünün nerede çalıştığı

friendlyCaptcha, token gelene kadar bloklar. PHP’de burada bir event loop yoktur; paketteki AsyncCapSkip de aynı sınıfın başka bir adıdır ve dört SDK aynı şekilde okunsun diye tutulmuştur. Tek bir form için bu sorun değildir. Elli form için ise art arda yapılan çağrılar, her çözüm sürerken CapSkip’in on Friendly Captcha thread’inden dokuzunu boşta bırakır.

SDK’nın manuel iş akışı bunu tek bir süreçten çözer. send() bir işi gönderir ve kimliğini hemen döndürür; getResult() ise tek bir kimliği bir kez yoklar ve yanıt hazır olmadığı sürece NetworkException fırlatır. Aynı anda en fazla on işi işlemde tutun, çünkü CapSkip’in Friendly Captcha için varsayılan Max. Threads değeri budur ve bu sınırın ötesinde kuyruğa alınan bir görev, CapSkip onu başarısız saymadan önce boş bir thread için en fazla 250 saniye bekler:

use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\NetworkException;
use CapSkip\Exceptions\TimeoutException;

// Keep up to $limit solves in flight and call $done($i, $outcome) as
// each ends: $outcome is the token, or the exception that ended it.
function solveAll(\CapSkip\CapSkip $solver, array $jobs, callable $done, int $limit = 10, int $timeout = 300): void
{
    $running = [];                                      // index => [id, started]
    while ($jobs || $running) {
        while ($jobs && count($running) < $limit) {
            $i = array_key_first($jobs);
            $job = $jobs[$i];
            unset($jobs[$i]);
            try {
                $running[$i] = [$solver->send([
                    'method' => 'friendly_captcha',
                    'sitekey' => $job['sitekey'],
                    'url' => $job['url'],
                    'version' => $job['version'],
                    'module_script' => $job['script'] ?? null,
                    'api_server' => $job['endpoint'] ?? null,
                ]), time()];
            } catch (CapSkipError $e) {
                $done($i, $e);
            }
        }
        sleep(1);
        foreach ($running as $i => [$id, $started]) {
            try {
                $token = $solver->getResult($id, 1)['request'];
            } catch (NetworkException $e) {
                // Not ready yet. Give up only once the deadline passes.
                if (time() - $started < $timeout) continue;
                // An empty message means not ready; anything else is a connection error.
                $token = $e->getMessage() !== '' ? $e : new TimeoutException("no answer in {$timeout} s");
            } catch (ApiException $e) {
                $token = $e;                            // e.g. ERROR_CAPTCHA_UNSOLVABLE
            }
            unset($running[$i]);
            $done($i, $token);
        }
    }
}

// $jobs: one array per page from Step 2 (sitekey, url, version, script,
// endpoint), plus that page's cURL handle and fields for the post.
solveAll($solver, $jobs, function (int $i, $outcome) use ($jobs) {
    if ($outcome instanceof Throwable) {
        echo "form $i failed: ", $outcome->getMessage(), PHP_EOL;
        return;
    }
    // Post straight away, as in Step 3, on $jobs[$i]'s own handle.
});

Her sayfaya kendi cURL handle’ını verin ve onu işin içinde tutun, çünkü her formun CSRF token’ı onu çeken oturuma aittir. Callback içinden göndermek, her token’ın en yavaş çözümü beklemek yerine oluştuğu anda gitmesi demektir. NetworkException, yanıtın hazır olmadığı anlamına gelir, ama CapSkip’e ulaşılamadığı anlamına da gelir. Bu yüzden çözücü çalışmanın ortasında durursa henüz gönderilmemiş işler send() aşamasında Failed to connect hatasıyla, daha önce gönderilmiş işler ise ancak son süre dolduğunda başarısız olur. Her yanıt yalnızca bir kez okunabilir; sonrasında CapSkip boş bir gövde döndürür ve SDK bunu da hazır değil olarak bildirir, bu yüzden bir kimliği okunur okunmaz döngüden çıkarın.

Çözüm süreleri değişkendir. Friendly Captcha her istek için gereken iş miktarını kendisi belirler ve çok gördüğü adresler için bu miktarı artırır; CapSkip de her v2 widget’ını gerçek bir tarayıcıda çözer. friendlyCaptcha metodunun 120 saniyelik defaultTimeout yerine recaptchaTimeout süresi boyunca, yani varsayılan olarak 300 saniyeye kadar yoklama yapmasının ve yukarıdaki döngünün de aynı değeri kullanmasının nedeni budur. CapSkip’in de kendi saati vardır: Friendly Captcha ayarlarında bir çözüme, ERROR_CAPTCHA_UNSOLVABLE ile başarısız sayılmadan önce 120 saniyelik Row Timeout süresi tanınır. Diğer işler CapSkip’in on thread’ini zaten meşgul ediyorsa bir çağrı boş bir thread için de bekler; bu yüzden SDK, bu bekleme ile çözümün toplamından daha uzun dayanmalıdır: CapSkip’in varsayılan ayarlarında 370 saniyeye kadar. Yukarıdaki döngünün on işte durmasının nedeni budur; aynı nedenle, çözücüyü başka işlerle paylaşan bir betik de tek bir friendlyCaptcha çağrısı için SDK’nın bekleme süresini uzatmak üzere seçeneklerde timeout vermelidir. Yoklama aralığı da çağrı başına ayarlanabilir, ama yalnızca snake case yazımıyla, polling_interval olarak; camelCase pollingInterval constructor’a aittir ve çağrı başına seçenek olarak verildiğinde ValidationException fırlatır. Uzun bir çalışma boyunca çözümler yavaşlıyorsa olağan sebep tek bir adreste artan zorluktur. type ve uri anahtarları olan bir proxy dizisi verin ya da CapSkip’te bir proxy havuzu kurun.

Betiklerin komut satırından çalışmasının nedeni de bu beklemedir. PHP’nin max_execution_time değeri bir web isteği için varsayılan olarak 30 saniyedir, CLI için ise kapalıdır. Windows’ta bu süre beklemeyle geçen zamanı da sayar; bu yüzden bir sayfa isteğinin içindeki çözüm, hiçbir catch bloğunun yakalayamadığı bir fatal error ile biter. Linux’ta yalnızca CPU zamanını sayar ve bu kez isteği web sunucusunun kendi zaman aşımı keser. Her iki durumda da çözümü bir CLI betiğinde ya da bir kuyruk worker’ında yapın.

Örnekler 127.0.0.1 kullanıyor, çünkü PHP ile çözücü aynı makinede olduğunda doğru adres budur. Betiği başka herhangi bir yerde, örneğin bir VPS’te, paylaşımlı bir hostingde ya da Linux üzerindeki bir Laravel uygulamasında çalıştırırsanız loopback o makineyi gösterir. Bu durumda ilk çağrı "Failed to connect to 127.0.0.1 port 8080" mesajıyla NetworkException fırlatır. CapSkip’i Server moduna alın; o zaman ağ adresinizi veya genel IP’nizi dinler, böylece bunların hepsi ona aynı API üzerinden ulaşabilir. Rota internetten geçiyorsa statik bir genel IP kullanın. 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. Donanım yine sizin donanımınızdır ve çözüm başına ücretlendirme yine yoktur. İstemci ortam değişkenlerini kendiliğinden okumaz; bu yüzden, aşağıdaki tam örnekte olduğu gibi, CAPSKIP_HOST değerini getenv ile okuyup istemciye verin.

Tam çalışan örnek

İşte tüm süreç tek bir dosyada: yalnızca bir sayfa URL’sinden yola çıkarak PHP ile Friendly Captcha çözmenin en kısa eksiksiz yolu.

<?php
// composer require capskip/capskip
// solve-friendly.php: run with php solve-friendly.php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\ValidationException;

const PAGE_URL = 'https://example.com/signup';

function absoluteUrl(string $src, string $base): string
{
    if (preg_match('~^[a-z][a-z0-9+.-]*:~i', $src)) {
        return $src;
    }
    if (str_starts_with($src, '#')) {
        return strtok($base, '#');
    }
    if (str_starts_with($src, '?')) {
        return strtok($base, '?#') . $src;
    }
    $b = parse_url($base);
    if (str_starts_with($src, '//')) {
        return $b['scheme'] . ':' . $src;
    }
    $origin = $b['scheme'] . '://' . $b['host'] . (isset($b['port']) ? ':' . $b['port'] : '');
    $path = str_starts_with($src, '/') ? $src : preg_replace('~[^/]*$~', '', $b['path'] ?? '/') . $src;
    $out = [];
    foreach (explode('/', $path) as $seg) {
        if ($seg === '..') {
            if (count($out) > 1) {
                array_pop($out);
            }
        } elseif ($seg !== '.') {
            $out[] = $seg;
        }
    }
    return $origin . implode('/', $out);
}

function friendlyVersion(array $scripts, DOMElement $widget): array
{
    foreach ($scripts as $src) {
        if (str_contains($src, '@friendlycaptcha/sdk')) return ['v2', $src];
        if (str_contains($src, 'friendly-challenge')) return ['v1', $src];
    }
    foreach ($scripts as $src) {
        if (!str_contains(strtolower($src), 'friendly')) continue;
        $file = basename((string) parse_url($src, PHP_URL_PATH));
        if (in_array($file, ['site.min.js', 'site.compat.min.js'], true)) return ['v2', $src];
        if (in_array($file, ['widget.module.min.js', 'widget.min.js', 'widget.polyfilled.min.js'], true)) return ['v1', $src];
    }
    if ($widget->hasAttribute('data-api-endpoint') || $widget->hasAttribute('data-form-field-name')) return ['v2', null];
    if ($widget->hasAttribute('data-puzzle-endpoint') || $widget->hasAttribute('data-solution-field-name')) return ['v1', null];
    throw new RuntimeException('v1 or v2? Read the page and set it by hand.');
}

$solver = new CapSkip([
    'apiKey' => getenv('CAPSKIP_API_KEY') ?: 'capskip',
    'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
    'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
]);

$http = curl_init();
curl_setopt_array($http, [
    CURLOPT_URL => PAGE_URL,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_COOKIEFILE => '',
    CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
]);
$html = curl_exec($http);
if ($html === false) {
    exit('fetch failed: ' . curl_error($http) . PHP_EOL);
}
$pageUrl = curl_getinfo($http, CURLINFO_EFFECTIVE_URL);

libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $html);
$xp = new DOMXPath($doc);

$widget = $xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " frc-captcha ")]')->item(0);
if (!$widget instanceof DOMElement || $widget->getAttribute('data-sitekey') === '') {
    exit('No frc-captcha widget in the HTML; it may be built by JavaScript.' . PHP_EOL);
}
$form = $xp->query('ancestor::form[1]', $widget)->item(0);
if (!$form instanceof DOMElement) {
    exit('The widget is not inside a form; copy the request the page makes instead.' . PHP_EOL);
}
$scripts = [];
foreach ($xp->query('//script[@src][not(@nomodule)]') as $tag) {
    $scripts[] = absoluteUrl($tag->getAttribute('src'), $pageUrl);
}

try {
    [$version, $script] = friendlyVersion($scripts, $widget);
} catch (RuntimeException $e) {
    exit($e->getMessage() . PHP_EOL);
}
$endpoint = $widget->getAttribute('data-api-endpoint') ?: $widget->getAttribute('data-puzzle-endpoint');
try {
    $result = $solver->friendlyCaptcha($widget->getAttribute('data-sitekey'), $pageUrl, [
        'version' => $version,
        'module_script' => $script,
        'api_server' => $endpoint ?: null,
    ]);
} catch (ValidationException $e) {
    exit('not sent: ' . $e->getMessage() . PHP_EOL);
} catch (CapSkipError $e) {
    exit('solve failed: ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
}

$fields = [];
foreach ($xp->query('.//input[@name]', $form) as $input) {
    $type = strtolower($input->getAttribute('type'));
    if ($input->hasAttribute('disabled') || in_array($type, ['submit', 'button', 'image', 'file', 'reset'], true)) continue;
    $checkable = in_array($type, ['checkbox', 'radio'], true);
    if ($checkable && !$input->hasAttribute('checked')) continue;
    // A checked box with no value attribute submits "on".
    $fields[$input->getAttribute('name')] = $checkable && !$input->hasAttribute('value') ? 'on' : $input->getAttribute('value');
}
$fields['email'] = 'YOUR_EMAIL';
$field = $widget->getAttribute('data-form-field-name')
    ?: $widget->getAttribute('data-solution-field-name')
    ?: ($version === 'v2' ? 'frc-captcha-response' : 'frc-captcha-solution');
$fields[$field] = $result['token'];

// Post wherever the form's action points, on the same handle.
curl_setopt_array($http, [
    CURLOPT_URL => absoluteUrl($form->getAttribute('action') ?: $pageUrl, $pageUrl),
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($fields),
    CURLOPT_REFERER => $pageUrl,
]);
curl_exec($http);
echo curl_getinfo($http, CURLINFO_RESPONSE_CODE), ' ', $version, ' ', $field, PHP_EOL;

Sürüm bir kez belirlenir ve iki kez kullanılır: çözüm için ve alan adı için; böylece ikisi asla birbiriyle çelişemez. DOMXPath hiç widget bulamazsa sayfa genellikle onu JavaScript ile oluşturuyordur ve sitekey’i render edilmiş sayfadan ya da onu oluşturan script’ten almanız gerekir. Ham endpoint’in kabul ettiği tüm parametreler şurada: Friendly Captcha API referansı.

Sık görülen hatalar ve anlamları

GördüğünüzNedenDüzeltme
CapSkip’in döndürdüğü bir token ile 403 ya da bir CSRF hatasıGönderim, sayfanın çerezlerini taşımayan yeni bir handle üzerinden, gizli alanlar olmadan ya da bir Referer olmadan yapıldı1. adımdaki boş COOKIEFILE ayarlı handle üzerinden gönderin, alanları formdan oluşturun ve CURLOPT_REFERER değerini ayarlayın
Site, başka hiçbir hata vermeden token’ı reddediyorYanlış sürüm çözüldü, çoğu zaman varsayılan olarak v1Sürüme 2. adımdaki gibi kodda karar verin ve onu iletin
Widget’ında data-api-endpoint (v2) veya data-puzzle-endpoint (v1) olan bir sitede token reddediliyorToken global endpoint’ten geldi, site ise EU endpoint’ini kullanıyorÖzniteliğin değerini api_server olarak verin
Widget’ı kendisi barındıran bir sitede v2 çözümü başarısız oluyormodule_script göreli bir yoldu, bu yüzden widget hiç yüklenmedisrc değerini absoluteUrl ile sayfa URL’sine göre tam adrese çevirin
Eksik alan hatası ve multipart/form-data olarak giden bir istekCURLOPT_POSTFIELDS seçeneğine dizinin kendisi verildiURL kodlamalı bir dize olan http_build_query($fields) değerini verin
Aksanlı gizli değerler bozuk karakterlerle ulaşıyorloadHTML, meta charset içermeyen bir sayfayı Latin-1 olarak okudu1. adımdaki kodlama önekini koruyun
pollingInterval adını anan bir ValidationExceptionÇağrı başına seçeneğin yazımı polling_interval şeklindedirÇağrı başına snake case kullanın ya da pollingInterval değerini constructor’da ayarlayın
Her seferinde birkaç saniye içinde ERROR_CAPTCHA_UNSOLVABLE içeren bir ApiExceptionFriendly Captcha sitekey’i ya da sayfanın origin’ini reddediyor veya hesapta v2 ya da EU endpoint’i etkin değilSitekey’i, sayfa URL’sini ve api_server değerini kontrol edin; yeniden denemek işe yaramaz
İki dakika ya da daha uzun süre sonra ERROR_CAPTCHA_UNSOLVABLE içeren bir ApiExceptionBir çözüm CapSkip’in 120 saniyelik Row Timeout süresini aştı ya da boş bir thread için 250 saniyelik Wait Timeout süresinden fazla beklediAynı anda en fazla on çözümü işlemde tutun, proxy ekleyin ya da Friendly Captcha ayarlarında Row Timeout değerini yükseltin
300 saniye sonra TimeoutExceptionBir görevin boş bir thread için beklemesi ve ardından çözülmesi, SDK’nın yoklama süresinden uzun sürdüAynı anda en fazla on çözümü işlemde tutun ya da daha uzun bir timeout verin
Fatal error: Maximum execution time of 30 seconds exceeded hatası ve hiçbir catch bloğu çalışmadıÇözüm, PHP’nin 30 saniyelik sınırının Windows’ta bekleme süresini de saydığı bir web isteğinin içinde çalıştıÇözümü komut satırından ya da bir kuyruk worker’ından yapın
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip çalışmıyor, host ya da port yanlış veya PHP başka bir makinede çalışıyorCapSkip’i başlatın, ardından Local modunda mı yoksa Server modunda mı olması gerektiğini kontrol edin

FAQ

PHP ile Friendly Captcha çözmek için bir tarayıcı gerekir mi?

Hayır. Betiğinizin tek ihtiyacı HTML’dir, onu da cURL alır. Tarayıcı gerektiren iş CapSkip’in içinde gerçekleşir; CapSkip v2 widget’larını kendi tarayıcısında, v1 bulmacalarını ise doğrudan çözer. PHP kodunuz başka nedenlerle zaten bir tarayıcı yönetiyorsa aynı çağrı çalışır: sitekey’i ve script adresini çekilen HTML yerine canlı sayfadan okuyun.

Ham cURL yerine Guzzle kullanabilir miyim?

Evet. cookies seçeneği true olarak ayarlanmış tek bir istemci oluşturun; bu istemci, yönlendirmeler dahil yaptığı her istek için bir cookie jar tutar ve boş COOKIEFILE’ın işini görür. Alanları, onları URL kodlamalı olarak gönderen form_params ile gönderin ve sayfa URL’sini bir Referer başlığı olarak ekleyin. Guzzle, User-Agent başlığında kendini GuzzleHttp olarak tanıtır; site seçiciyse bir tarayıcı User-Agent’ı ayarlayın. CapSkip çağrıları hiç değişmez, çünkü SDK çözücüyle kendi başına konuşur.

Bunu bir Laravel kuyruk işinde çalıştırabilir miyim?

Evet; üstelik bir kuyruk worker’ı bunun için bir controller’dan daha iyi bir yerdir, çünkü worker komut satırından çalışır. Sayfayı çekmeyi, çözümü ve gönderimi tek bir işe koyun ki token hiçbir zaman oturumundan uzun yaşamasın. Bu durumda işin zaman aşımı yavaş bir çözümü de kapsamalıdır ve Windows’ta worker bu zaman aşımını hiç uygulayamaz. Laravel kuyruk rehberi işin zaman aşımını, retry_after değerini ve CapSkip ile Windows durumunu ele alıyor.

Bir VPS’teki PHP uygulaması, Windows bilgisayarımdaki çözücüyü kullanabilir mi?

Evet. Bağlantı ayarlarından CapSkip’i Server moduna alın; böylece loopback yerine bir ağ adresini dinler. Ardından uygulamanın çalıştığı yerde CAPSKIP_HOST değişkenini ayarlayın ve tam örnekte olduğu gibi istemciye verin. VPS, paylaşımlı hosting ya da konteyner fark etmez: hepsi aynı HTTP API üzerinden bağlanır. Rota internetten geçiyorsa statik bir genel IP kullanın ve porta ulaşmaması gereken adresler için bir Windows Firewall engelleme kuralı ekleyin. Çözücü sizin sahip olduğunuz donanımda kalır, dolayısıyla daha fazla çözüm çalıştırmak ödediğiniz tutarı asla değiştirmez.

Kısa özet

PHP ile Friendly Captcha çözmek için sayfayı boş bir CURLOPT_COOKIEFILE ayarlı tek bir cURL handle’ı üzerinden çekin, ardından frc-captcha öğesini ve script etiketlerini DOMXPath ile okuyun, her src değerini tam bir adrese çevirin. v1 mi v2 mi olduğuna paket ya da dosya adından veya widget’ın kendi özniteliklerinden karar verin; hiçbiri bir şey söylemiyorsa hata fırlatın. friendlyCaptcha metodunu sitekey, sayfa URL’si, bu sürüm ve script adresiyle çağırın; EU siteleri için api_server da ekleyin. Token’ı bir kez, aynı handle üzerinden URL kodlamalı olarak, formun kendi alanlarının yanında ve sürümün belirttiği alanda gönderin. Çok sayıda form için işleri send ile gönderin ve getResult ile yoklayın, her seferinde on tane.

Friendly Captcha zorluğun bedelini para olarak değil CPU zamanı olarak tahsil eder; bu yüzden yoğun bir gün size kendi makinenizdeki çözüm süresinden başka hiçbir şeye mal olmaz. Kontrol ettiğiniz donanımda çalışan bir sınırsız captcha çözücü ile tek sınır o makine ve ona verdiğiniz thread’ler olur.