Cara Memecahkan Friendly Captcha di PHP dengan cURL dan DOMXPath

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

Untuk memecahkan Friendly Captcha di PHP, ambil halamannya dengan satu handle cURL yang mesin cookie-nya sudah diaktifkan. Baca widget frc-captcha beserta tag script-nya dengan DOMXPath, lalu tentukan v1 atau v2 dari skrip itu. Kemudian berikan sitekey, URL halaman, versi, dan alamat skrip ke metode friendlyCaptcha dari paket capskip/capskip. Kirim token yang dikembalikannya pada handle yang sama, bersama field tersembunyi milik form, di frc-captcha-response pada v2 atau frc-captcha-solution pada v1. Dua kebiasaan PHP merusak proses ini. Membuat curl_init baru untuk setiap permintaan akan membuang cookie sesi, sehingga form yang dilindungi CSRF menolak token yang sempurna sekalipun. Selain itu, pemanggilannya memblokir sampai pemecahan selesai, sehingga lima puluh form berturut-turut memakan waktu lama kecuali Anda mengirimkan pemecahan semua form itu bersamaan. CapSkip menambahkan Friendly Captcha pada versi 1.4.0, dan panduan ini menjalankan seluruh prosesnya tanpa browser di sisi Anda.

Apa yang Anda butuhkan

  • CapSkip 1.4.0 atau lebih baru yang berjalan di komputer Windows. Rilis itu menambahkan Friendly Captcha, bersama CaptchaFox dan Capy Puzzle.
  • PHP 8.0 atau lebih baru dengan ekstensi curl dan dom, serta capskip/capskip versi 1.3.0 atau lebih baru, rilis pertama yang memiliki friendlyCaptcha. Jika Composer menolak paket itu karena ext-curl tidak ada, hapus tanda komentar pada extension=curl di php.ini Anda.
  • URL halaman yang menampilkan widget. Sitekey, alamat skrip, dan nama field semuanya diambil dari HTML halaman itu.
  • Sebuah alamat untuk pemecah CAPTCHA. Mode Local menjawab di 127.0.0.1 hanya untuk perangkat itu; mode Server mendengarkan di alamat jaringan atau IP publik Anda sehingga aplikasi PHP di mesin lain dapat memanggilnya melalui API. Keduanya ada di pengaturan koneksi, dan Langkah 4 membahas kapan harus beralih.
# composer require capskip/capskip
composer require capskip/capskip

Jalankan skripnya dari command line. Pemecahan Friendly Captcha bisa memakan waktu beberapa menit di hari yang buruk, dan permintaan web jarang bertahan selama itu; Langkah 4 menjelaskan alasannya.

Langkah 1: ambil halaman dengan satu handle cURL

Ekstensi cURL milik PHP tidak menyimpan cookie kecuali Anda memintanya. Sebuah handle dimulai tanpa penyimpanan cookie, dan handle baru untuk setiap permintaan dimulai tanpa apa pun sama sekali, sehingga cookie sesi yang disetel halaman sudah hilang saat Anda mengirim form. Situs yang mengikat token CSRF-nya ke sesi itu lalu menolak kiriman tersebut, sering kali dengan 403, sebagus apa pun token CAPTCHA-nya.

Solusinya cukup satu opsi. Menyetel CURLOPT_COOKIEFILE ke string kosong akan mengaktifkan mesin cookie in-memory milik cURL untuk handle itu, tanpa membaca atau menulis file apa pun, seperti yang dijelaskan referensi curl_setopt di manual PHP. Pertahankan handle itu dan gunakan lagi untuk mengirim form, maka setiap cookie yang disetel situs akan ikut terbawa, termasuk cookie yang disetel saat redirect.

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

Tiga baris di kode itu akan menghemat waktu debugging nanti. cURL milik PHP tidak mengirim header User-Agent kecuali Anda menyetelnya, dan sebagian situs menolak permintaan yang tidak memilikinya. CURLINFO_EFFECTIVE_URL memberikan alamat akhir tempat halaman itu berasal, yang merupakan URL yang tepat untuk pemecah CAPTCHA dan untuk Referer ketika alamat pertama melakukan redirect. Lalu prefix encoding mencegah loadHTML membaca halaman sebagai Latin-1 ketika halaman tidak mendeklarasikan charset, yang akan mengubah nilai tersembunyi yang beraksen menjadi karakter rusak saat dikirim kembali.

Langkah 2: baca widget, pilih v1 atau v2, lalu panggil friendlyCaptcha

Kedua versi merender elemen yang sama, yaitu sebuah div dengan class frc-captcha dan atribut data-sitekey, sehingga widget itu sendiri tidak memberi tahu apa pun tentang protokolnya. Tag script-lah yang memberi tahu. Situs v2 memuat paket @friendlycaptcha/sdk, yang file-nya adalah site.min.js, dan situs v1 memuat friendly-challenge, yang file-nya adalah widget.module.min.js. DOMXPath menemukan widget, form yang membungkusnya, dan setiap skrip:

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

Alamat lengkap penting untuk widget yang di-host sendiri. Pada v2, CapSkip memuat skrip persis itu di browser miliknya sendiri untuk menjalankan pemecahan, dan path mentah seperti /vendor/v2/site.min.js tidak akan pernah dimuat. Fungsi parse_url milik PHP memecah URL menjadi beberapa bagian tetapi tidak bisa me-resolve URL relatif terhadap sebuah halaman, jadi berikut resolver kecil yang menangani path absolut, protocol-relative, root-relative, dan dot-segment, ditambah action form di halaman yang sama seperti # atau ?step=2:

// 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 memilih versi dengan urutan tetap dan berhenti pada jawaban pertama: versi yang Anda berikan, lalu alamat skrip yang Anda berikan sebagai module_script, lalu default v1. Default itulah jebakannya. Skrip hasil bundling seperti /assets/app.4f2a.js tidak memberi tahu CapSkip apa pun, sehingga CapSkip memecahkannya sebagai v1 dan situs v2 menolak setiap token. Sebaiknya tentukan sendiri di kode Anda, tempat Anda bisa menolak untuk menebak:

// 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.');
}

Lalu lakukan pemanggilannya. Metode friendlyCaptcha menerima sitekey, URL halaman, dan sebuah array opsi, lalu membuang setiap opsi yang nilainya null sebelum mengirim apa pun:

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.

Perhatikan ?: null pada endpoint. Metode getAttribute milik DOMElement mengembalikan string kosong untuk atribut yang tidak dimiliki halaman, tidak pernah null, dan SDK hanya membuang nilai null, jadi tanpa itu api_server yang kosong ikut terkirim bersama permintaan. Opsi itu ditujukan untuk situs yang memakai endpoint EU milik Friendly Captcha. Endpoint global tetap menerbitkan token untuk sitekey EU, jadi memecahkannya di sana baru gagal saat pemeriksaan milik situs itu sendiri, kegagalan diam-diam yang sama seperti pada versi yang salah. Entri token pada hasil adalah string yang harus dikirim, entri code berisi string yang sama, dan captchaId adalah id CapSkip untuk job tersebut. Versi selain v1, v2, 1 atau 2, sitekey yang kosong, atau opsi yang tidak dikenal metode ini akan melempar ValidationException sebelum permintaan apa pun meninggalkan mesin Anda. PHP 8.4 juga menyertakan Dom\HTMLDocument, dengan querySelector dan parser HTML5 sungguhan. Namun, elemennya bukan DOMElement, dan getAttribute-nya mengembalikan null untuk atribut yang tidak ada, jadi kode di panduan ini perlu diubah sebelum bisa berjalan di sana.

Langkah 3: kirim token bersama field milik form itu sendiri

Field token tidak ada di HTML yang Anda ambil, karena skrip widget membuatnya di browser. Jadi Anda menambahkannya sendiri, dengan nama yang dipakai oleh versi dan widget. Semua hal lain yang dibawa form, termasuk token CSRF tersembunyi, berasal dari form itu sendiri, dan kiriman form keluar melalui handle dari Langkah 1:

// 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;

Loop itu mengumpulkan apa yang akan dikirim browser untuk form ini, dan begitulah token CSRF kembali ke server tanpa Anda perlu menyebut namanya. Loop itu melewatkan tombol submit, field yang dinonaktifkan, menu select, dan text area, jadi tambahkan tombol, menu, dan text area itu berdasarkan namanya jika situs memeriksanya. Berikan string dari http_build_query ke CURLOPT_POSTFIELDS, bukan array-nya langsung: jika diberi array, cURL mengirim multipart/form-data, sedangkan browser mengirim form ini dalam bentuk URL-encoded. Kirim juga URL halaman sebagai Referer. Sebagian framework, termasuk Django, menolak form post HTTPS yang tidak memiliki Referer maupun Origin, meskipun cookie dan tokennya sudah benar. Token v2 berukuran sekitar enam kilobyte, jadi tempatnya di body dan jangan pernah di query string, sedangkan token v1 terdiri dari empat bagian yang dipisahkan titik dan panjangnya beberapa ratus karakter. Teruskan apa adanya.

Dua kasus perlu diperiksa di DevTools browser Anda. Pada v1, data-solution-field-name yang diisi satu tanda hubung berarti widget sama sekali tidak menulis field, dan skrip milik situs itu sendiri mengirim token dengan cara lain. Sebagian situs juga mengirim JSON dari JavaScript alih-alih men-submit form. Untuk keduanya, kirim sekali secara manual lalu salin permintaan yang benar-benar dibuat halaman itu.

Setiap token hanya berlaku untuk satu pengiriman. Verifikasi Friendly Captcha menolak respons yang sudah pernah dipakai atau sudah kedaluwarsa, jadi pecahkan ulang untuk setiap form.

Langkah 4: banyak form dari satu proses PHP, dan di mana pemecah CAPTCHA berjalan

Metode friendlyCaptcha memblokir sampai token tiba. PHP tidak punya event loop di sini, dan AsyncCapSkip di paket ini adalah class yang sama dengan nama lain, yang dipertahankan agar keempat SDK terbaca seragam. Itu tidak masalah untuk satu form. Untuk lima puluh form, menjalankan pemanggilan satu per satu membuat sembilan dari sepuluh thread Friendly Captcha milik CapSkip menganggur selama setiap pemecahan berjalan.

Alur kerja manual milik SDK mengatasinya dari satu proses saja. send() mengirim job dan langsung mengembalikan id-nya, dan getResult() melakukan polling untuk satu id sebanyak satu kali, dengan melempar NetworkException selama jawabannya belum siap. Jaga agar paling banyak sepuluh job berjalan bersamaan, karena itulah nilai default Max. Threads CapSkip untuk Friendly Captcha, dan tugas yang mengantre melewati batas itu menunggu hingga 250 detik untuk mendapat thread kosong sebelum CapSkip menggagalkannya:

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

Beri setiap halaman handle cURL-nya sendiri dan simpan di dalam job, karena token CSRF setiap form terikat pada sesi yang mengambilnya. Mengirim form dari callback berarti setiap token langsung dikirim begitu tersedia, tanpa menunggu pemecahan yang paling lambat. NetworkException berarti belum siap, dan juga berarti CapSkip tidak bisa dijangkau. Jadi jika pemecah CAPTCHA berhenti di tengah proses, job yang belum dikirim gagal di send() dengan Failed to connect, sedangkan job yang sudah dikirim baru gagal setelah batas waktunya lewat. Setiap jawaban hanya bisa dibaca sekali; setelah itu CapSkip mengembalikan body kosong, yang juga dilaporkan SDK sebagai belum siap, jadi keluarkan id dari loop begitu jawabannya sudah dibaca.

Waktu pemecahan bervariasi. Friendly Captcha menetapkan jumlah kerja per permintaan dan menaikkannya untuk alamat yang sudah sering dilihatnya, dan CapSkip memecahkan setiap widget v2 di browser sungguhan. Itulah sebabnya friendlyCaptcha melakukan polling hingga recaptchaTimeout, 300 detik secara default, bukan defaultTimeout 120 detik, dan itu pula sebabnya loop di atas memakai angka yang sama. CapSkip juga punya penghitung waktunya sendiri: di pengaturan Friendly Captcha-nya, sebuah pemecahan mendapat Row Timeout 120 detik sebelum digagalkan dengan ERROR_CAPTCHA_UNSOLVABLE. Ketika job lain sudah memakai kesepuluh thread CapSkip, sebuah pemanggilan juga menunggu thread kosong, sehingga SDK harus bertahan lebih lama daripada waktu tunggu itu ditambah waktu pemecahan: hingga 370 detik dengan pengaturan default CapSkip. Itulah sebabnya loop di atas berhenti di angka sepuluh, dan mengapa skrip yang berbagi pemecah CAPTCHA dengan pekerjaan lain sebaiknya memberikan timeout di array opsi untuk memperpanjang waktu tunggu SDK untuk satu pemanggilan friendlyCaptcha. Interval polling juga bisa disetel per pemanggilan, tetapi hanya sebagai polling_interval dalam snake case; bentuk camelCase pollingInterval hanya untuk constructor, dan jika dipakai sebagai opsi per pemanggilan, ia melempar ValidationException. Jika pemecahan melambat selama proses yang panjang, tingkat kesulitan yang naik pada satu alamat biasanya menjadi penyebabnya. Berikan array proxy dengan key type dan uri, atau siapkan pool proxy di CapSkip.

Waktu tunggu itu juga menjadi alasan skrip dijalankan dari command line. Pengaturan max_execution_time milik PHP secara default bernilai 30 detik untuk permintaan web dan nonaktif untuk CLI. Di Windows, pengaturan ini ikut menghitung waktu yang dihabiskan untuk menunggu, sehingga pemecahan di dalam permintaan halaman berakhir dengan fatal error yang tidak tertangkap oleh blok catch mana pun. Di Linux, yang dihitung hanya waktu CPU, dan timeout milik web server itu sendirilah yang memutus permintaannya. Apa pun kasusnya, lakukan pemecahan di skrip CLI atau queue worker.

Contoh kode memakai 127.0.0.1 karena itu benar ketika PHP dan pemecah CAPTCHA berada di mesin yang sama. Jalankan skrip di tempat lain, seperti VPS, shared host, atau aplikasi Laravel di Linux, maka loopback menunjuk ke mesin itu sendiri. Pemanggilan pertama lalu melempar NetworkException dengan "Failed to connect to 127.0.0.1 port 8080". Alihkan CapSkip ke mode Server dan ia akan mendengarkan di alamat jaringan atau IP publik Anda, sehingga semua lingkungan itu dapat menjangkaunya melalui API yang sama. Gunakan IP publik statis jika rutenya melewati internet. CapSkip membuka port-nya untuk semua alamat saat mode Server dimulai, jadi untuk membatasinya hanya pada mesin yang Anda harapkan, tambahkan aturan blokir Windows Firewall untuk alamat lainnya. Perangkatnya tetap milik Anda sendiri dan tetap tanpa kuota. Klien tidak membaca variabel lingkungan dengan sendirinya, jadi baca CAPSKIP_HOST dengan getenv lalu teruskan ke klien, seperti yang dilakukan contoh lengkap di bawah.

Contoh lengkap yang berfungsi

Berikut seluruh prosesnya dalam satu file: cara lengkap paling singkat untuk memecahkan Friendly Captcha di PHP hanya dari sebuah URL halaman.

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

Versi ditentukan sekali dan dipakai dua kali, untuk pemecahan dan untuk nama field, sehingga keduanya tidak akan pernah bertentangan. Jika DOMXPath tidak menemukan widget, biasanya halaman membangunnya dari JavaScript, dan Anda perlu mengambil sitekey dari halaman yang sudah dirender atau dari skrip yang membuatnya. Setiap parameter yang diterima endpoint mentahnya tercantum di referensi API Friendly Captcha.

Kesalahan umum dan artinya

Apa yang Anda lihatPenyebabPerbaiki
403, atau error CSRF, dengan token yang dikembalikan CapSkipForm dikirim lewat handle baru tanpa cookie halaman, tanpa field tersembunyinya, atau tanpa RefererKirim lewat handle dari Langkah 1 dengan COOKIEFILE kosongnya, susun field dari form, dan setel CURLOPT_REFERER
Situs menolak token tanpa error lainVersi yang dipecahkan salah, sering kali v1 karena defaultTentukan versi di kode seperti di Langkah 2, lalu berikan
Ditolak di situs yang widget-nya memiliki data-api-endpoint (v2) atau data-puzzle-endpoint (v1)Token berasal dari endpoint global, padahal situs memakai endpoint EUBerikan nilai atribut itu sebagai api_server
Pemecahan v2 gagal di situs yang meng-host widget-nya sendirimodule_script berupa path relatif, sehingga widget tidak pernah dimuatUbah src menjadi alamat lengkap berdasarkan URL halaman dengan absoluteUrl
Error karena field hilang, dan permintaan terkirim sebagai multipart/form-dataCURLOPT_POSTFIELDS diberi arrayBerikan http_build_query($fields), yaitu string URL-encoded
Nilai tersembunyi yang beraksen tiba dalam keadaan rusakloadHTML membaca halaman tanpa meta charset sebagai Latin-1Pertahankan prefix encoding dari Langkah 1
ValidationException yang menyebut pollingIntervalOpsi per pemanggilan ditulis polling_intervalGunakan snake case per pemanggilan, atau setel pollingInterval di constructor
ApiException dengan ERROR_CAPTCHA_UNSOLVABLE dalam hitungan detik, setiap kaliFriendly Captcha menolak sitekey atau origin halaman, atau akunnya tidak mengaktifkan v2 atau endpoint EUPeriksa sitekey, URL halaman, dan api_server; mencoba ulang tidak akan membantu
ApiException dengan ERROR_CAPTCHA_UNSOLVABLE setelah dua menit atau lebihPemecahan melewati Row Timeout CapSkip sebesar 120 detik, atau menunggu thread kosong melewati Wait Timeout-nya sebesar 250 detikJaga agar paling banyak sepuluh berjalan bersamaan, tambahkan proxy, atau naikkan Row Timeout di pengaturan Friendly Captcha
TimeoutException setelah 300 detikTugas menunggu thread kosong, lalu dipecahkan lebih lama daripada waktu polling SDKJaga agar paling banyak sepuluh berjalan bersamaan, atau berikan timeout yang lebih panjang
Fatal error: Maximum execution time of 30 seconds exceeded, dan tidak ada blok catch yang berjalanPemecahan berjalan di dalam permintaan web, tempat batas 30 detik milik PHP ikut menghitung waktu tunggu di WindowsLakukan pemecahan dari command line atau queue worker
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip tidak berjalan, host atau port-nya salah, atau PHP berjalan di mesin lainJalankan CapSkip, lalu periksa apakah seharusnya memakai mode Local atau mode Server

FAQ

Apakah saya butuh browser untuk memecahkan Friendly Captcha di PHP?

Tidak. Skrip Anda hanya butuh HTML, yang diambil oleh cURL. Pekerjaan browser terjadi di dalam CapSkip, yang memecahkan widget v2 di browser miliknya sendiri dan puzzle v1 secara langsung. Jika kode PHP Anda sudah mengendalikan browser untuk keperluan lain, pemanggilan yang sama tetap berfungsi: baca sitekey dan alamat skrip dari halaman yang sedang terbuka, bukan dari HTML hasil pengambilan.

Bisakah saya memakai Guzzle sebagai pengganti cURL mentah?

Bisa. Buat satu klien dengan opsi cookies disetel ke true, maka klien itu menyimpan cookie jar untuk setiap permintaan yang dibuatnya, termasuk redirect, sehingga menggantikan peran COOKIEFILE kosong. Kirim field dengan form_params, yang mengirimkannya dalam bentuk URL-encoded, dan tambahkan URL halaman sebagai header Referer. Guzzle memperkenalkan dirinya sebagai GuzzleHttp di User-Agent-nya, jadi setel User-Agent browser jika situsnya ketat. Pemanggilan CapSkip sama sekali tidak berubah, karena SDK berkomunikasi dengan pemecah CAPTCHA secara mandiri.

Bisakah saya menjalankan ini di job queue Laravel?

Bisa, dan queue worker adalah tempat yang lebih baik untuk kode ini daripada controller, karena worker berjalan dari command line. Letakkan pengambilan halaman, pemecahan, dan pengiriman form dalam satu job agar token tidak pernah hidup lebih lama dari sesinya. Timeout job itu kemudian harus mencakup pemecahan yang lambat, dan di Windows worker sama sekali tidak bisa menegakkan timeout tersebut. Panduan queue Laravel membahas timeout job, retry_after, dan kasus Windows dengan CapSkip.

Bisakah aplikasi PHP di VPS memakai pemecah CAPTCHA di PC Windows saya?

Bisa. Alihkan CapSkip ke mode Server di pengaturan koneksi agar ia mendengarkan di alamat jaringan, bukan di loopback. Lalu setel CAPSKIP_HOST di tempat aplikasi berjalan dan teruskan ke klien, seperti pada contoh lengkap. VPS, shared host, dan container semuanya terhubung melalui API HTTP yang sama. Gunakan IP publik statis jika rutenya melewati internet, dan tambahkan aturan blokir Windows Firewall untuk alamat yang tidak boleh menjangkau port tersebut. Pemecah CAPTCHA tetap berada di perangkat milik Anda, jadi menjalankan lebih banyak pemecahan tidak pernah mengubah biaya yang Anda bayar.

Versi singkatnya

Untuk memecahkan Friendly Captcha di PHP, ambil halamannya dengan satu handle cURL yang memakai CURLOPT_COOKIEFILE kosong, lalu baca elemen frc-captcha dan tag script-nya dengan DOMXPath, sambil mengubah setiap src menjadi alamat lengkap. Tentukan v1 atau v2 dari nama paket atau nama file, atau dari atribut milik widget itu sendiri, dan lempar exception jika keduanya tidak memberi jawaban. Panggil friendlyCaptcha dengan sitekey, URL halaman, versi tersebut, dan alamat skrip, ditambah api_server untuk situs EU. Kirim token sekali saja, dalam bentuk URL-encoded pada handle yang sama, bersama field milik form itu sendiri dan pada field yang namanya ditentukan oleh versinya. Untuk banyak form, kirim job dengan send dan lakukan polling dengan getResult, sepuluh sekaligus.

Friendly Captcha menagih tingkat kesulitannya dalam waktu CPU, bukan uang, jadi hari yang sibuk hanya memakan waktu pemecahan di mesin Anda sendiri, tidak lebih. Dengan pemecah captcha tanpa batas di perangkat yang Anda kendalikan, satu-satunya batas adalah mesin itu serta thread yang Anda berikan kepadanya.