Как решить Friendly Captcha на PHP с помощью cURL и DOMXPath

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

Чтобы решить Friendly Captcha на PHP, загрузите страницу через один дескриптор cURL с включённым механизмом cookies. Прочитайте виджет frc-captcha и его теги script с помощью DOMXPath и определите по скрипту v1 или v2. Затем передайте sitekey, URL страницы, версию и адрес скрипта в метод friendlyCaptcha пакета capskip/capskip. Отправьте возвращённый им токен через тот же дескриптор рядом со скрытыми полями формы: в frc-captcha-response для v2 или в frc-captcha-solution для v1. Две типичные для PHP привычки это ломают. Новый curl_init для каждого запроса выбрасывает сессионную cookie, поэтому форма с защитой от CSRF отклоняет даже идеальный токен. А вызов блокируется до конца решения, поэтому пятьдесят форм подряд занимают много времени, если не запускать их решения вместе. CapSkip добавил Friendly Captcha в версии 1.4.0, и в этом руководстве весь процесс проходит без браузера на вашей стороне.

Что понадобится

  • CapSkip 1.4.0 или новее на машине с Windows. Именно в этом выпуске появилась Friendly Captcha, а вместе с ней CaptchaFox и Capy Puzzle.
  • PHP 8.0 или новее с расширениями curl и dom и пакет capskip/capskip версии 1.3.0 или новее: это первый выпуск, в котором есть friendlyCaptcha. Если Composer отказывается ставить пакет из-за отсутствия ext-curl, раскомментируйте extension=curl в своём php.ini.
  • URL страницы, на которой показан виджет. Sitekey, адрес скрипта и имя поля берутся из HTML этой страницы.
  • Адрес решателя. Режим Local отвечает на 127.0.0.1 и только для этого устройства; режим Server слушает ваш сетевой адрес или публичный IP, чтобы PHP-приложение на другой машине могло обращаться к нему через API. Оба режима задаются в разделе Настройки подключения, а когда переключаться, объясняет шаг 4.
# composer require capskip/capskip
composer require capskip/capskip

Запускайте скрипты из командной строки. В неудачный день решение Friendly Captcha может занять несколько минут, а веб-запрос редко живёт так долго; причину объясняет шаг 4.

Шаг 1: загружаем страницу через один дескриптор cURL

Расширение cURL в PHP не хранит cookies, пока вы его об этом не попросите. Дескриптор создаётся без хранилища cookies, а новый дескриптор на каждый запрос вообще начинает с чистого листа, поэтому сессионная cookie, которую устанавливает страница, пропадает к тому моменту, когда вы отправляете форму. Сайт, который привязывает свой CSRF-токен к этой сессии, отклоняет такую отправку, часто с 403, каким бы хорошим ни был токен капчи.

Решение сводится к одной опции. Если присвоить CURLOPT_COOKIEFILE пустую строку, для этого дескриптора включится механизм cookies cURL, работающий в памяти, без чтения и записи каких-либо файлов, как справочник по curl_setopt и описывает. Сохраните дескриптор и используйте его повторно для отправки, и с ней уйдут все cookies, которые установил сайт, включая cookie, установленную при редиректе.

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

Три строки в этом коде сэкономят время на отладке. cURL в PHP не отправляет заголовок User-Agent, пока вы его не зададите, а некоторые сайты отклоняют запрос без него. CURLINFO_EFFECTIVE_URL даёт адрес, с которого в итоге пришла страница, и именно этот URL нужен решателю и заголовку Referer, если первый адрес перенаправляет. А префикс кодировки не даёт loadHTML читать страницу как Latin-1, если в ней не объявлена кодировка, иначе скрытое значение с диакритическими знаками на обратном пути превратилось бы в мусор.

Шаг 2: читаем виджет, выбираем v1 или v2 и вызываем friendlyCaptcha

Обе версии выводят один и тот же элемент: div с классом frc-captcha и атрибутом data-sitekey, поэтому сам виджет ничего не говорит о протоколе. Зато об этом говорит тег script. Сайт на v2 загружает пакет @friendlycaptcha/sdk, файл которого называется site.min.js, а сайт на v1 загружает friendly-challenge, файл которого называется widget.module.min.js. DOMXPath находит виджет, форму вокруг него и все скрипты:

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

Полные адреса важны для виджетов, которые сайт хостит у себя. Для v2 CapSkip загружает именно этот скрипт в своём браузере, чтобы выполнить решение, а голый путь вроде /vendor/v2/site.min.js не загрузится никогда. parse_url в PHP разбивает URL на части, но не умеет разрешать относительный адрес относительно страницы, поэтому вот небольшой резолвер, который справляется с абсолютными путями, путями относительно протокола и относительно корня, путями с точечными сегментами, а также с действиями формы на той же странице, например # или ?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 выбирает версию в фиксированном порядке и останавливается на первом ответе: сначала версия, которую вы передали, затем адрес скрипта, переданный как module_script, затем значение по умолчанию v1. Ловушка кроется именно в значении по умолчанию. Собранный скрипт вроде /assets/app.4f2a.js ничего не говорит CapSkip, поэтому он решает v1, и сайт на v2 отклоняет каждый токен. Вместо этого определяйте версию в своём коде, где можно отказаться гадать:

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

Затем сделайте вызов. friendlyCaptcha принимает sitekey, URL страницы и массив опций и отбрасывает любую опцию со значением null ещё до отправки:

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.

Обратите внимание на ?: null у эндпоинта. getAttribute у DOMElement возвращает для атрибута, которого на странице нет, пустую строку и никогда не возвращает null, а SDK отбрасывает только null, поэтому без этого оператора пустой api_server уйдёт вместе с запросом. Эта опция нужна для сайтов на эндпоинте EU сервиса Friendly Captcha. Глобальный эндпоинт всё равно выдаёт токен для sitekey из EU, поэтому решение там проваливается только на собственной проверке сайта: это тот же тихий сбой, что и с неверной версией. Элемент token в результате содержит строку для отправки, элемент code хранит ту же строку, а captchaId служит идентификатором задания в CapSkip. Версия, отличная от v1, v2, 1 или 2, пустой sitekey или опция, которую метод не знает, вызывают ValidationException ещё до того, как запрос покинет вашу машину. В PHP 8.4 также появился Dom\HTMLDocument с querySelector и настоящим парсером HTML5. Однако его элементы не являются DOMElement, а его getAttribute возвращает null для отсутствующего атрибута, поэтому код из этого руководства придётся изменить, прежде чем он заработает с этим классом.

Шаг 3: отправляем токен вместе с собственными полями формы

Поля для токена нет в HTML, который вы загрузили, потому что его создаёт скрипт виджета в браузере. Поэтому добавьте его сами, под тем именем, которое используют версия и виджет. Всё остальное, что несёт форма, включая скрытый CSRF-токен, берётся из самой формы, а запрос уходит через дескриптор из шага 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;

Цикл собирает то, что браузер отправил бы для этой формы, и именно так CSRF-токен возвращается на сервер, хотя вы его нигде не называете. Он пропускает кнопку отправки, отключённые поля, выпадающие списки и текстовые области, поэтому если сайт их проверяет, добавьте кнопку, списки и текстовые области по имени. Передавайте в CURLOPT_POSTFIELDS строку из http_build_query, а не сам массив: получив массив, cURL отправляет multipart/form-data, а браузер отправляет эту форму в URL-кодировке. Передавайте URL страницы и как Referer. Некоторые фреймворки, в том числе Django, отклоняют отправку формы по HTTPS без Referer или Origin, какими бы правильными ни были cookie и токен. Токен v2 весит примерно шесть килобайт, поэтому ему место в теле, а не в строке запроса, а токен v1 состоит из четырёх частей, разделённых точками, и занимает несколько сотен символов. Передавайте его без изменений.

В двух случаях придётся заглянуть в DevTools вашего браузера. В v1 атрибут data-solution-field-name, равный одному дефису, означает, что виджет вообще не пишет поле, а собственный скрипт сайта отправляет токен другим способом. Кроме того, некоторые сайты шлют JSON из JavaScript вместо обычной отправки формы. В обоих случаях отправьте форму один раз вручную и скопируйте запрос, который на самом деле делает страница.

Каждый токен годится для одной отправки. Проверка Friendly Captcha отклоняет ответ, который уже был использован или истёк, поэтому решайте заново для каждой формы.

Шаг 4: много форм из одного процесса PHP и где работает решатель

friendlyCaptcha блокирует выполнение, пока не придёт токен. Цикла событий в PHP здесь нет, а AsyncCapSkip в этом пакете представляет собой тот же класс под другим именем, оставленный для того, чтобы все четыре SDK выглядели одинаково. Для одной формы это нормально. Но для пятидесяти вызовы один за другим оставляют девять из десяти потоков Friendly Captcha в CapSkip без дела, пока идёт каждое решение.

Ручная схема работы в SDK решает эту проблему в рамках одного процесса. send() отправляет задание и сразу возвращает его идентификатор, а getResult() однократно опрашивает один идентификатор и выбрасывает NetworkException, пока ответ не готов. Держите в работе не больше десяти заданий, потому что именно таково значение Max. Threads для Friendly Captcha в CapSkip по умолчанию, а задание, поставленное в очередь сверх него, ждёт свободного потока до 250 секунд, прежде чем CapSkip признает его неудачным:

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

Дайте каждой странице собственный дескриптор cURL и храните его в задании, потому что CSRF-токен каждой формы принадлежит той сессии, которая её загрузила. Если отправлять форму из callback-функции, каждый токен уходит в тот момент, когда он появился, а не ждёт самого медленного решения. NetworkException означает, что ответ не готов, но также означает, что до CapSkip не удалось достучаться. Поэтому если решатель остановится посреди прогона, ещё не отправленные задания упадут на send() с Failed to connect, а уже отправленные упадут только после того, как пройдёт крайний срок. Каждый ответ можно прочитать один раз; после этого CapSkip возвращает пустое тело, которое SDK тоже считает признаком неготовности, поэтому убирайте идентификатор из цикла сразу после того, как прочитали ответ.

Время решения бывает разным. Friendly Captcha задаёт объём работы для каждого запроса и повышает его для адресов, которые уже видела много раз, а CapSkip решает каждый виджет v2 в настоящем браузере. Поэтому friendlyCaptcha опрашивает результат до recaptchaTimeout, по умолчанию 300 секунд, а не до defaultTimeout в 120 секунд, и по той же причине цикл выше использует это же значение. У CapSkip есть и собственные часы: в его настройках Friendly Captcha решению отводится 120 секунд Row Timeout, после чего оно признаётся неудачным с ERROR_CAPTCHA_UNSOLVABLE. Когда все десять потоков CapSkip уже заняты другими заданиями, вызов ещё и ждёт, пока освободится один из них, поэтому SDK должен пережить это ожидание плюс само решение: при настройках CapSkip по умолчанию до 370 секунд. Именно поэтому цикл выше ограничивается десятью заданиями, а скрипту, который делит решатель с другой работой, стоит передавать timeout в опциях, чтобы продлить ожидание SDK для одного вызова friendlyCaptcha. Интервал опроса тоже можно задать для отдельного вызова, но только как polling_interval в snake case; вариант pollingInterval в camelCase относится к конструктору, а как опция отдельного вызова приводит к ValidationException. Если на долгом прогоне решения замедляются, обычно причина кроется в растущей сложности для одного адреса. Передайте массив прокси с ключами type и uri или настройте пул прокси в CapSkip.

Именно из-за этого ожидания скрипты и запускаются из командной строки. max_execution_time в PHP по умолчанию равен 30 секундам для веб-запроса и отключён для CLI. В Windows он учитывает и время ожидания, поэтому решение внутри запроса страницы заканчивается фатальной ошибкой, которую не видит ни один блок catch. В Linux он учитывает только процессорное время, и запрос вместо этого обрывает собственный таймаут веб-сервера. В любом случае решайте капчу в CLI-скрипте или в воркере очереди.

В примерах стоит 127.0.0.1, потому что это верно, пока PHP и решатель делят одну машину. Запустите скрипт где-то ещё, например на VPS, на виртуальном хостинге или в приложении Laravel на Linux, и локальная петля будет указывать на саму эту машину. Тогда первый же вызов выбросит NetworkException с "Failed to connect to 127.0.0.1 port 8080". Переключите CapSkip в режим Server, и он будет слушать ваш сетевой адрес или публичный IP, так что любой из этих вариантов сможет обратиться к нему через тот же API. Если маршрут идёт через интернет, используйте статический публичный IP. При запуске режима Server CapSkip открывает свой порт для всех адресов, поэтому, чтобы доступ был только у ожидаемых машин, добавьте для остальных блокирующее правило Windows Firewall. Это по-прежнему ваше собственное железо, и по-прежнему без платы за каждое решение. Клиент сам не читает переменные окружения, поэтому читайте CAPSKIP_HOST через getenv и передавайте его клиенту, как это делает полный пример ниже.

Полный рабочий пример

Вот весь процесс в одном файле: самый короткий полный способ решить Friendly Captcha на PHP, имея на входе только URL страницы.

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

Версия определяется один раз и используется дважды: для решения и для имени поля, поэтому они никогда не разойдутся. Если DOMXPath не находит виджет, обычно это значит, что страница строит его из JavaScript, и sitekey нужно брать с отрендеренной страницы или из скрипта, который его создаёт. Все параметры, которые принимает сырой эндпоинт, описаны в справочнике API Friendly Captcha.

Частые ошибки и что они означают

Что вы видитеПричинаИсправить
403 или ошибка CSRF при токене, который вернул CapSkipЗапрос ушёл через новый дескриптор без cookies страницы, без её скрытых полей или без RefererОтправьте через дескриптор из шага 1 с пустым COOKIEFILE, соберите поля из формы и задайте CURLOPT_REFERER
Сайт отклоняет токен без какой-либо другой ошибкиРешена не та версия, часто v1 по умолчаниюОпределите версию в коде, как в шаге 2, и передайте её
Отклоняется на сайте, где у виджета есть data-api-endpoint (v2) или data-puzzle-endpoint (v1)Токен пришёл с глобального эндпоинта, а сайт использует эндпоинт EUПередайте значение атрибута как api_server
Решение v2 не проходит на сайте, который хостит виджет у себяmodule_script был относительным путём, поэтому виджет так и не загрузилсяРазрешите src относительно URL страницы через absoluteUrl
Ошибка об отсутствующем поле, а запрос ушёл как multipart/form-dataВ CURLOPT_POSTFIELDS передан массивПередайте http_build_query($fields), строку в URL-кодировке
Скрытые значения с диакритикой приходят искажённымиloadHTML прочитал страницу без meta charset как Latin-1Сохраните префикс кодировки из шага 1
ValidationException с упоминанием pollingIntervalОпция для отдельного вызова пишется как polling_intervalИспользуйте snake case в вызове или задайте pollingInterval в конструкторе
ApiException с ERROR_CAPTCHA_UNSOLVABLE через несколько секунд, каждый разFriendly Captcha отклоняет sitekey или origin страницы, либо в аккаунте не включены v2 или эндпоинт EUПроверьте sitekey, URL страницы и api_server; повторная попытка не поможет
ApiException с ERROR_CAPTCHA_UNSOLVABLE через две минуты или позжеРешение вышло за Row Timeout в CapSkip (120 секунд) или ждало свободного потока дольше Wait Timeout (250 секунд)Держите в работе не больше десяти заданий, добавьте прокси или поднимите Row Timeout в настройках Friendly Captcha
TimeoutException через 300 секундЗадание ждало свободного потока, а затем решалось, и вместе это длилось дольше, чем SDK опрашивает результатДержите в работе не больше десяти заданий или передайте более длинный timeout
Fatal error: Maximum execution time of 30 seconds exceeded, и ни один блок catch не сработалРешение выполнялось внутри веб-запроса, где 30-секундный лимит PHP в Windows учитывает и время ожиданияРешайте из командной строки или в воркере очереди
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip не запущен, неверны хост или порт, либо PHP работает на другой машинеЗапустите CapSkip, затем проверьте, какой режим нужен: Local или Server

FAQ

Нужен ли браузер, чтобы решить Friendly Captcha на PHP?

Нет. Вашему скрипту нужен только HTML, а его получает cURL. Работа с браузером происходит внутри CapSkip: виджеты v2 он решает в своём браузере, а головоломки v1 напрямую. Если ваш PHP-код и так управляет браузером по другим причинам, тот же вызов тоже работает: просто читайте sitekey и адрес скрипта с живой страницы, а не из загруженного HTML.

Можно ли использовать Guzzle вместо голого cURL?

Да. Создайте один клиент с опцией cookies, равной true, и он будет вести хранилище cookies для всех своих запросов, включая редиректы, а это ровно та работа, которую выполняет пустой COOKIEFILE. Отправляйте поля через form_params, который передаёт их в URL-кодировке, и добавьте URL страницы в заголовок Referer. В User-Agent Guzzle представляется как GuzzleHttp, поэтому задайте User-Agent браузера, если сайт привередлив. Вызовы CapSkip не меняются вовсе, потому что SDK общается с решателем самостоятельно.

Можно ли запускать это в задании очереди Laravel?

Да, и воркер очереди подходит для этого лучше контроллера, потому что воркер работает из командной строки. Поместите загрузку страницы, решение и отправку в одно задание, чтобы токен никогда не пережил свою сессию. Тогда таймаут задания должен покрывать медленное решение, а в Windows воркер вообще не может обеспечить соблюдение этого таймаута. Руководство по очередям Laravel описывает таймаут задания, retry_after и особенности Windows при работе с CapSkip.

Может ли PHP-приложение на VPS использовать решатель на моём ПК с Windows?

Да. Переведите CapSkip в режим Server в настройках подключения, чтобы он слушал сетевой адрес, а не локальную петлю. Затем задайте CAPSKIP_HOST там, где работает приложение, и передайте его клиенту, как это делает полный пример. VPS, виртуальный хостинг и контейнер подключаются через один и тот же HTTP API. Если маршрут идёт через интернет, используйте статический публичный IP и добавьте блокирующее правило Windows Firewall для адресов, которые не должны достучаться до порта. Решатель остаётся на вашем собственном железе, поэтому рост числа решений никак не меняет ваши расходы.

Коротко

Чтобы решить Friendly Captcha на PHP, загрузите страницу через один дескриптор cURL с пустым CURLOPT_COOKIEFILE, затем прочитайте элемент frc-captcha и его теги script с помощью DOMXPath, превратив каждый src в полный адрес. Определите v1 или v2 по имени пакета или файла либо по собственным атрибутам виджета, а если ни то ни другое не подсказывает, выбросьте исключение. Вызовите friendlyCaptcha с sitekey, URL страницы, этой версией и адресом скрипта, а для сайтов из EU добавьте api_server. Отправьте токен один раз, в URL-кодировке через тот же дескриптор, рядом с собственными полями формы и в поле, имя которого задаёт версия. Если форм много, отправляйте задания через send и опрашивайте через getResult, по десять за раз.

Friendly Captcha берёт плату за сложность процессорным временем, а не деньгами, поэтому загруженный день обходится лишь временем решения на вашей собственной машине. Когда безлимитный сервис распознавания капчи работает на железе, которым вы управляете, ограничивать вас будут только эта машина и потоки, которые вы ей выделите.