Как решить CaptchaFox на PHP с Guzzle и хранилищем cookie

solve captchafox in php - How to Solve CaptchaFox in PHP With Guzzle and a Cookie Jar

Чтобы решить CaptchaFox на PHP, загрузите страницу клиентом Guzzle, который хранит cookies, и прочитайте sitekey из виджета. Передайте его вместе с URL страницы в метод captchafox пакета capskip/capskip. Затем отправьте токен в поле cf-captcha-response, указав страницу в Referer и передав user agent, который CapSkip вернул вместе с токеном. Настройки Guzzle по умолчанию мешают вам на каждом из этих шагов. Он не хранит cookies, пока вы об этом не попросите, не отправляет Referer и сообщает каждому серверу, что его user agent называется GuzzleHttp/8. При ответе 403 он выбрасывает исключение, а не возвращает ответ. CapSkip добавил CaptchaFox в версии 1.4.0. В этом руководстве разобраны загрузка страницы, вызов, отправка и повторы на Guzzle 7 и 8.

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

  • CapSkip 1.4.0 или новее на машине с Windows. Именно в этом выпуске появился CaptchaFox, а вместе с ним Friendly Captcha и Capy Puzzle.
  • PHP 8.4 или новее: он нужен для Dom\HTMLDocument и его метода querySelector. На PHP от 8.0 до 8.3 вместо этого разбирайте страницу с помощью DOMDocument и DOMXPath, как показано в руководстве по решению Friendly Captcha на PHP.
  • Пакет capskip/capskip версии 1.3.0 или новее (первый выпуск, в котором есть метод captchafox) и Guzzle 7 или 8. Каждый пример здесь работает без изменений на Guzzle 7.15 и 8.2.
  • URL страницы, на которой показан виджет. Sitekey, поля формы и сборка виджета берутся именно с этой страницы.
  • Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы приложение на PHP на другой машине могло обращаться к нему через API. Оба режима задаются в разделе Настройки подключения, а когда переключаться, объясняет шаг 4.
# PHP 8.4 or later, then the SDK and Guzzle 7 or 8
composer require capskip/capskip guzzlehttp/guzzle

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

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

Клиент Guzzle не хранит cookies, если не создать его с опцией cookies, равной true. С ней клиент держит одно хранилище cookie для всех запросов, которые он отправляет, включая cookie, установленный при редиректе. Поэтому сессионный cookie, на котором держится форма с защитой CSRF, доживает до отправки. Создайте клиент один раз и используйте его для обоих запросов:

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

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

use GuzzleHttp\Client;
use GuzzleHttp\TransferStats;

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

// cookies: one jar for every request, redirects included.
// http_errors: a 403 comes back as a response, not an exception.
$http = new Client(['cookies' => true, 'http_errors' => false]);

$pageUrl = PAGE_URL;
$page = $http->get(PAGE_URL, [
    // Runs for every hop, so it ends on the address the page came from.
    'on_stats' => function (TransferStats $stats) use (&$pageUrl) {
        $pageUrl = (string) $stats->getEffectiveUri();
    },
]);
// With http_errors off, a blocked or missing page arrives as a response.
if ($page->getStatusCode() !== 200) {
    throw new RuntimeException("GET $pageUrl returned " . $page->getStatusCode());
}
$html = (string) $page->getBody();

// PHP 8.4's HTML5 parser; it reads UTF-8 without a meta charset.
$doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);

Основную работу здесь делают две опции и один callback. Когда http_errors равен false, ответ 403 возвращается как ответ, который можно прочитать, а не как ClientException. Именно поэтому код сам проверяет статус: иначе страница блокировки или 404 разбиралась бы так, будто это форма. on_stats вызывается один раз для каждого запроса, который отправляет Guzzle, включая шаги редиректа, поэтому после вызова он содержит адрес, с которого в итоге пришла страница. Почему этот адрес так важен для CaptchaFox, объясняет шаг 2. Dom\HTMLDocument представляет собой парсер HTML5, добавленный в PHP 8.4. В отличие от loadHTML в DOMDocument, он читает страницу без meta charset как UTF-8, поэтому скрытое поле с текстом с диакритическими знаками возвращается на сервер в целости.

Sitekey публичен, одинаков для всех посетителей и по соглашению начинается с sk_. Большинство страниц указывают его на контейнере виджета: это div с классом captchafox и атрибутом data-sitekey. Страница, которая строит виджет собственным скриптом, вместо этого передаёт ключ в вызов captchafox.render. Некоторые платформы встраивают пакет с s.uicdn.com/mampkg/ вместо стандартного виджета с cdn.captchafox.com. Такие сайты ждут токен, который начинается с MAM_, и на их страницах ключ может находиться прямо в адресе этого скрипта.

// The container, matched on the whole class name.
$widget = $doc->querySelector('.captchafox');
// MAM pages load this package and may carry the key in its src.
$mamSrc = $doc->querySelector('script[src*="mampkg/@mamdev/core.frontend.libs.captchafox"]')?->getAttribute('src');

$sitekey = $widget?->getAttribute('data-sitekey');
if ($sitekey === null && $mamSrc !== null) {
    parse_str((string) parse_url($mamSrc, PHP_URL_QUERY), $query);
    $sitekey = $query['key'] ?? null;
}
if ($sitekey === null && preg_match('/captchafox\.render\([\s\S]{0,300}?["\']?sitekey["\']?\s*:\s*["\']([^"\']+)/', $html, $m)) {
    $sitekey = $m[1];
}
$form = $widget?->closest('form') ?? $doc->querySelector('form');
if ($sitekey === null || $form === null) {
    throw new RuntimeException('No sitekey or form in the HTML; find them in DevTools.');
}
echo $sitekey, $mamSrc !== null ? ' (MAM page)' : '', PHP_EOL;

querySelector сопоставляет имена классов целиком, поэтому обёртка с классом вроде captchafox-wrapper случайно не подхватится. Проверка MAM сверяет путь к пакету captchafox, а не хост, потому что тот же CDN раздаёт и посторонние пакеты, в том числе виджет чата. У элементов Dom\HTMLDocument метод getAttribute возвращает null для отсутствующего атрибута, и именно это позволяет выстроить запасные варианты цепочкой. Ключа внутри собранного бандла скрипта в HTML нет вообще. В этом случае откройте DevTools: ключом будет сегмент пути после /captcha/ в запросе к api.captchafox.com, а на страницах с MAM в запросе к mam-api.captchafox.com. Если контейнера нет, код берёт первую форму на странице, поэтому когда на странице есть поле поиска или вторая форма, выбирайте форму регистрации по её id.

Шаг 2: вызов captchafox

Чтобы решить CaptchaFox на PHP, нужен один метод. Он принимает sitekey, URL страницы и массив опций, а для стандартного виджета ему хватает первых двух:

use CapSkip\CapSkip;

// Copied from the script tag on MAM pages.
const MAM_PACKAGE = 'https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/';

$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);

$result = $solver->captchafox($sitekey, $pageUrl, [
    // null is dropped, so the standard widget sends nothing extra.
    'api_server' => $mamSrc !== null ? MAM_PACKAGE : null,
]);

echo substr($result['token'], 0, 24), PHP_EOL;     // goes in cf-captcha-response
echo $result['userAgent'] ?? '(none)', PHP_EOL;    // the browser that minted it

Массив результата содержит токен под ключом token и ещё раз под ключом code, id задания в CapSkip под ключом captchaId, а также userAgent, если решение его сообщило. Этот user agent принадлежит браузеру, в котором решал CapSkip, а не чему-либо отправленному вами, и от него зависит шаг 3. Метод отбрасывает любую опцию со значением null ещё до отправки, поэтому строка с api_server может выполняться при каждом вызове.

Передавайте страницу, на которой виджет действительно работает. CaptchaFox регистрирует каждый ключ на список разрешённых доменов и проверяет хост, прежде чем что-либо выдать, поэтому URL страницы на другом домене отклоняется при каждой попытке. Именно поэтому шаг 1 отслеживает адрес после редиректов. Ссылка, которая перенаправляет на хост с www или на локализованную страницу регистрации, ведёт не туда, где находится виджет.

Опции, которые принимает метод

api_server выбирает сборку виджета, а apiServer работает как его псевдоним. Массив proxy с ключами type и uri направляет решение через ваш прокси. useragent принимается, но намеренно не применяется, потому что CapSkip решает в настоящем браузере с собственным набором признаков. Опция timeout в секундах заменяет recaptchaTimeout клиента для этого одного вызова. Потолок интервала опроса тоже можно задать на один вызов, но только как polling_interval в snake case. pollingInterval в camelCase относится к конструктору, и как опция отдельного вызова он выбрасывает ValidationException. То же исключение вызывают пустой sitekey или URL, опция, которую метод не знает, и тип прокси, отличный от HTTP, HTTPS, SOCKS5 или SOCKS5H, причём всё это ещё до того, как запрос покинет вашу машину.

Шаг 3: отправляем токен под тем user agent, с которым он выпущен

Когда вы решаете CaptchaFox на PHP, именно этот шаг определяет, примет ли сайт токен. Guzzle отправляет GuzzleHttp/8 в качестве user agent в каждом запросе, для которого вы его не задали, а на предыдущей мажорной версии отправляет GuzzleHttp/7. Это не тот браузер, который создал токен, а сайт, который ждёт токен от выпустившего его браузера, такую отправку отклоняет. Скопируйте возвращённое значение в запрос, который несёт токен:

use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;

// The fields a browser would send for this form, CSRF token included.
$fields = [];
foreach ($form->querySelectorAll('input[name]:not([disabled])') as $input) {
    $type = strtolower($input->getAttribute('type') ?? 'text');
    if (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')] = $input->getAttribute('value') ?? ($checkable ? 'on' : '');
}
$fields['email'] = 'YOUR_EMAIL';
$fields['cf-captcha-response'] = $result['token'];

// Browsers send the page as Referer; strict servers want one.
$headers = ['Referer' => $pageUrl];
// Without this, Guzzle announces itself as GuzzleHttp/8 (or /7).
if (isset($result['userAgent'])) {
    $headers['User-Agent'] = $result['userAgent'];
}

// Resolve the form's action against the page, as a browser would.
$action = UriResolver::resolve(new Uri($pageUrl), new Uri(trim($form->getAttribute('action') ?? '')));
$response = $http->post($action, ['form_params' => $fields, 'headers' => $headers]);
echo $response->getStatusCode(), PHP_EOL;

Цикл собирает то, что браузер отправил бы для этой формы: скрытый CSRF-токен, текстовые поля и отмеченные чекбоксы, без отключённых полей и кнопок. Выпадающие списки и многострочные текстовые поля он пропускает, поэтому если они есть в форме, добавьте их по имени, а если сервер проверяет имя кнопки отправки, добавьте и его. form_params отправляет поля в кодировке URL, так же как браузер отправляет эту форму. UriResolver входит в пакет PSR-7 от Guzzle и разрешает action формы относительно страницы так же, как это делает браузер, включая пустой action, action из одной лишь строки запроса и сегменты с точками.

Собственного Referer Guzzle тоже не отправляет. Некоторые фреймворки, в том числе Django, отклоняют отправку формы по HTTPS, в которой нет ни заголовка Referer, ни заголовка Origin, какими бы правильными ни были cookie и токен. Кроме того, Guzzle 8 сразу отклоняет значение заголовка null, выбрасывая InvalidArgumentException, поэтому user agent добавляется, только если решение его сообщило.

Ещё два правила следуют из того, как CaptchaFox проверяет токен на стороне сайта. Каждый токен проходит проверку один раз и только в течение короткого времени, поэтому решайте, когда уже готовы, и отправляйте сразу. И считайте токен непрозрачным: никогда не обрезайте и не перекодируйте его. Некоторые сайты отправляют его в теле JSON из собственного скрипта, поэтому один раз отправьте форму вручную с открытыми DevTools и повторите то, что отправляет страница. В документации CaptchaFox по началу работы показаны разметка виджета и проверка на стороне сервера.

Шаг 4: повторы, таймауты и где работает решатель

Какое задание выдаст CaptchaFox, выбираете не вы. Чаще всего при решении не выпадает вообще ничего, а если появляется слайдер-пазл, CapSkip обычно проходит и его. Задания с выбором изображений и аудиозадания редки и не решаются: они за считаные секунды возвращаются в виде ApiException, в сообщении которого есть ERROR_CAPTCHA_UNSOLVABLE. При новой попытке обычно выпадает что-то другое, поэтому повтор имеет смысл:

use CapSkip\Exceptions\ApiException;

function solveCaptchaFox(\CapSkip\CapSkip $solver, string $sitekey, string $url, array $options = [], int $attempts = 3): array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $solver->captchafox($sitekey, $url, $options);
        } catch (ApiException $e) {
            // Select, audio and unreadable slide draws come back unsolvable;
            // redraw. Anything else, such as a wrong API key, is final.
            if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt === $attempts) {
                throw $e;
            }
        }
    }
}

Сообщение исключения содержит сырой ответ CapSkip, поэтому проверка читает код ошибки прямо из него. Ключ, который сайт отклоняет, например на чужом домене, тоже выглядит как нерешаемый и уходит в повтор. Однако он падает за считаные секунды при каждой попытке: по этому признаку его и можно узнать. Другой путь даёт параметр Retries в строке CaptchaFox в настройках CapSkip. Он принимает значения от 0 до 3 и по умолчанию равен 0. Тогда CapSkip сам повторяет попытку при неподдерживаемых заданиях, при попытках, которым не хватило времени, и при ошибках браузера, но никогда не повторяет отклонённый ключ.

captchafox опрашивает результат с таймаутом клиента recaptchaTimeout, по умолчанию 300 секунд, потому что это настоящая браузерная сессия. У CapSkip есть и собственный таймер: задание может ждать одного из 10 потоков CaptchaFox (Max. Threads) до 250 секунд (Wait Timeout), а на одну попытку отводится 150 секунд (Row Timeout). Если поток свободен, неудачная попытка доходит до вас в виде ApiException задолго до того, как истекут 300 секунд SDK. После долгой очереди SDK может первым упереться в свой лимит и выбросить TimeoutException, пока CapSkip ещё работает. Поэтому передавайте более длинный timeout, когда ставите в очередь много решений или поднимаете Retries, ведь каждый повтор может добавить ещё один Row Timeout.

Для множества регистраций держите в работе не больше десяти решений, как задано в Max. Threads, и давайте каждому запуску собственный клиент Guzzle, чтобы хранилища cookie никогда не смешивались. captchafox блокирует выполнение до получения токена, поэтому один процесс PHP запускает решения параллельно через методы SDK send и getResult. Такой цикл есть в шаге 4 руководства по Friendly Captcha. Для CaptchaFox отправляйте задание с method, равным captchafox, и сохраняйте поле userAgent из массива, который возвращает getResult, а не только его поле request, потому что шаг 3 отправляет форму с обоими.

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

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

Вот весь процесс в одном файле. signUp загружает страницу собственным клиентом, читает ключ, решает капчу с повторами и отправляет форму с её cookies, Referer и тем user agent, с которым выпущен токен.

<?php
// composer require capskip/capskip guzzlehttp/guzzle
// signup.php: run with php signup.php (PHP 8.4 or later)
declare(strict_types=1);

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

use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\TimeoutException;
use CapSkip\Exceptions\ValidationException;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\TransferStats;

const PAGE_URL = 'https://example.com/signup';
// Copied from the script tag on MAM pages.
const MAM_PACKAGE = 'https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/';

function solveCaptchaFox(CapSkip $solver, string $sitekey, string $url, array $options = [], int $attempts = 3): array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $solver->captchafox($sitekey, $url, $options);
        } catch (ApiException $e) {
            // Select, audio and unreadable slide draws come back unsolvable.
            if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt === $attempts) {
                throw $e;
            }
            echo "attempt $attempt: ", $e->getMessage(), PHP_EOL;
        }
    }
}

function signUp(CapSkip $solver, string $url, string $email): int
{
    // A new client per run, so its cookie jar holds this session only.
    $http = new Client(['cookies' => true, 'http_errors' => false]);
    $pageUrl = $url;
    $page = $http->get($url, [
        'on_stats' => function (TransferStats $stats) use (&$pageUrl) {
            $pageUrl = (string) $stats->getEffectiveUri();
        },
    ]);
    if ($page->getStatusCode() !== 200) {
        throw new RuntimeException("GET $pageUrl returned " . $page->getStatusCode());
    }
    $html = (string) $page->getBody();
    $doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);

    $widget = $doc->querySelector('.captchafox');
    $mamSrc = $doc->querySelector('script[src*="mampkg/@mamdev/core.frontend.libs.captchafox"]')?->getAttribute('src');
    $sitekey = $widget?->getAttribute('data-sitekey');
    if ($sitekey === null && $mamSrc !== null) {
        parse_str((string) parse_url($mamSrc, PHP_URL_QUERY), $query);
        $sitekey = $query['key'] ?? null;
    }
    if ($sitekey === null && preg_match('/captchafox\.render\([\s\S]{0,300}?["\']?sitekey["\']?\s*:\s*["\']([^"\']+)/', $html, $m)) {
        $sitekey = $m[1];
    }
    $form = $widget?->closest('form') ?? $doc->querySelector('form');
    if ($sitekey === null || $form === null) {
        throw new RuntimeException('No sitekey or form in the HTML; find them in DevTools.');
    }

    $result = solveCaptchaFox($solver, $sitekey, $pageUrl, [
        'api_server' => $mamSrc !== null ? MAM_PACKAGE : null,
    ]);

    $fields = [];
    foreach ($form->querySelectorAll('input[name]:not([disabled])') as $input) {
        $type = strtolower($input->getAttribute('type') ?? 'text');
        if (in_array($type, ['submit', 'button', 'image', 'file', 'reset'], true)) continue;
        $checkable = in_array($type, ['checkbox', 'radio'], true);
        if ($checkable && !$input->hasAttribute('checked')) continue;
        $fields[$input->getAttribute('name')] = $input->getAttribute('value') ?? ($checkable ? 'on' : '');
    }
    $fields['email'] = $email;
    $fields['cf-captcha-response'] = $result['token'];

    $headers = ['Referer' => $pageUrl];
    if (isset($result['userAgent'])) {
        $headers['User-Agent'] = $result['userAgent'];
    }
    $action = UriResolver::resolve(new Uri($pageUrl), new Uri(trim($form->getAttribute('action') ?? '')));

    return $http->post($action, ['form_params' => $fields, 'headers' => $headers])->getStatusCode();
}

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

try {
    $status = signUp($solver, PAGE_URL, 'YOUR_EMAIL');
    echo $status, PHP_EOL;
    // A 4xx from the site means the post was refused.
    exit($status < 400 ? 0 : 1);
} catch (ValidationException $e) {
    fwrite(STDERR, 'not sent: ' . $e->getMessage() . PHP_EOL);
    exit(1);
} catch (TimeoutException $e) {
    fwrite(STDERR, 'gave up after recaptchaTimeout' . PHP_EOL);
    exit(1);
} catch (CapSkipError | RuntimeException $e) {
    // Guzzle's transfer errors are RuntimeExceptions too.
    fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
    exit(1);
}

Если каждый запуск проваливается за считаные секунды, первым делом проверьте URL страницы, потому что ключ, использованный вне зарегистрированных для него доменов, каждый раз отказывает одинаково. Если проверка sitekey выбрасывает исключение, значит, страница строит виджет из бандла скриптов, и ключ находится на вкладке Network, как описано в шаге 1. Сырой эндпоинт, который стоит за этим методом, описан в разделе CaptchaFox справочника API.

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

Что вы видитеПричинаИсправить
Сайт отклоняет токен, который CapSkip вернул как решённыйОтправка ушла от имени GuzzleHttp/8 или GuzzleHttp/7, а не от имени браузера, который выпустил токенОтправьте userAgent из результата в заголовке User-Agent этого запроса
Отклоняется, хотя user agent совпадаетСтраница загружает пакет MAM, а решение использовало стандартный виджет, или наоборотПрочитайте тег script и задайте api_server в соответствии с ним
Ответ 403 на отправку, хотя токен и user agent верныКлиент создан без опции cookies, равной true, или отправку выполнил второй клиент, поэтому поле CSRF больше не совпадает; либо в запросе не было RefererОтправляйте клиентом из шага 1 и передайте URL страницы как Referer
GuzzleHttp\Exception\ClientException при отправкеhttp_errors по умолчанию включён, поэтому любой ответ 4xx выбрасывает исключениеСоздайте клиент с http_errors, равным false, и читайте код статуса
InvalidArgumentException с упоминанием headers.User-AgentGuzzle 8 отклоняет значение заголовка null, а решение не сообщило user agentОставьте проверку isset; если сайт отклоняет собственное имя Guzzle, отправьте сами user agent актуального десктопного браузера
ApiException за считаные секунды, каждый раз, для одного ключаURL страницы находится вне доменов, для которых зарегистрирован ключ, часто потому, что был отправлен адрес до редиректа, или неверен сам ключПередавайте адрес, который записал on_stats, и перепроверьте ключ
Время от времени ApiException с ERROR_CAPTCHA_UNSOLVABLECaptchaFox выдал задание с выбором изображений или аудиозадание либо слайдер, который CapSkip не смог прочитатьПовторите попытку в коде или поднимите Retries в настройках CaptchaFox в CapSkip
RuntimeException с сообщением, что GET-запрос страницы вернул 403Сайт отказал в загрузке страницы, часто из-за собственного user agent GuzzleЗадайте клиенту user agent актуального десктопного браузера для загрузки страницы; отправка всё равно несёт тот, с которым выпущен токен
ValidationException ещё до какой-либо отправкиПустой sitekey или URL, опция, которую метод не принимает, например pollingInterval в отдельном вызове, или тип прокси, который CapSkip не поддерживаетПроверьте, что sitekey найден, и исправьте или уберите опцию, названную в сообщении
TimeoutException через пять минутОжидание свободного потока CaptchaFox и само решение вместе заняли больше 300 секунд SDK, или CapSkip остановился посреди решенияДержите в работе не больше десяти решений или передайте для этого вызова более длинный timeout
Class "Dom\HTMLDocument" not foundВерсия PHP ниже 8.4Обновите PHP или разбирайте страницу с помощью DOMDocument и DOMXPath
Fatal error: Cannot use GuzzleHttp\Exception\NetworkException as NetworkExceptionВ Guzzle 8 появился собственный класс NetworkException, и оба класса импортированы под одним коротким именемИмпортируйте один из них под псевдонимом, например GuzzleNetworkException
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip не запущен, неверны хост или порт, либо PHP работает на другой машинеЗапустите CapSkip и проверьте, в каком режиме он должен работать: Local или Server

FAQ

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

Нет. Вашему скрипту нужен только HTML страницы, а его загружает Guzzle. Вся браузерная работа происходит внутри CapSkip: он управляет настоящим виджетом в собственном браузере и возвращает токен вместе с user agent этого браузера. Ваш код на PHP никогда не выполняет JavaScript, поэтому ему нужно лишь правильно передать сессию и заголовки.

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

Да. Используйте один дескриптор cURL с CURLOPT_COOKIEFILE, равным пустой строке (это включает встроенный механизм cookie в памяти), и отправляйте форму через тот же дескриптор. Чистый cURL вообще не отправляет заголовок User-Agent, если вы его не задали, поэтому перед отправкой задайте CURLOPT_USERAGENT значение userAgent из результата, а CURLOPT_REFERER адрес страницы. В руководстве по Friendly Captcha для PHP весь этот процесс собран на cURL и DOMXPath.

Можно ли вместо этого отправить токен из Symfony Panther?

Да, если браузер, который его отправляет, работает под тем user agent, который вернул CapSkip. Сначала решите капчу, затем запустите Chrome из Panther с аргументом user agent, равным этому значению, загрузите форму, запишите токен в поле cf-captcha-response и отправьте. Если браузер больше ни для чего в вашем скрипте не нужен, путь через Guzzle легче. В руководстве по Symfony Panther показано, как записать токен в страницу из PHP.

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

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

Коротко

Чтобы решить CaptchaFox на PHP, загрузите страницу одним клиентом Guzzle, созданным с включёнными cookies и выключенным http_errors, и запишите итоговый адрес через on_stats. Считайте sitekey с контейнера captchafox, из вызова render или из скрипта MAM и отметьте, какую сборку загружает страница. Вызовите captchafox с sitekey и этим адресом, добавляя api_server только для страниц с MAM. Отправьте токен в cf-captcha-response через form_params, со страницей в Referer и userAgent из результата в заголовке User-Agent, один раз и сразу же. Повторяйте попытку, если выпало нерешаемое задание, держите в работе не больше решений, чем Max. Threads (по умолчанию 10), и переходите в режим Server, как только скрипт покидает машину решателя.

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