Как решить reCAPTCHA в Symfony Panther (PHP SDK)

Чтобы решить капчу в Symfony Panther, считайте sitekey со страницы, открытой в Panther, и передайте его вместе с текущим URL в метод recaptcha из PHP SDK CapSkip. Затем запишите токен в скрытую textarea g-recaptcha-response через executeScript и отправьте форму нажатием её собственной кнопки. Именно на записи код для Panther обычно и ломается. reCAPTCHA держит эту textarea в состоянии display:none, поэтому любой хелпер Panther, который мог бы её заполнить, и объект Form, и sendKeys, выбрасывает ElementNotInteractableException. Страницам, которые отправляют форму из колбэка reCAPTCHA, нужен ещё один шаг, потому что они игнорируют поле, пока не сработает колбэк. В этом руководстве разобраны настройка на Windows, решение, запись токена, оба способа отправки и ожидания, которые делают прогон надёжным.
Что понадобится
- CapSkip, запущенный на машине с Windows. В режиме Local он отвечает на 127.0.0.1 и только для этого устройства; в режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы Panther на другой машине мог обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.
- PHP 8.1 или новее с включёнными в php.ini расширениями curl и zip. Panther 2.4 требует 8.1, Composer не установит php-webdriver без zip, а сборки PHP для Windows поставляются с обоими расширениями в выключенном состоянии.
- Chrome и chromedriver той же основной версии. Утилита bdi ниже скачает подходящий.
- Страница с виджетом reCAPTCHA v2, с чекбоксом или невидимым.
# PHP 8.1+; bdi fetches the chromedriver that matches your Chrome composer require symfony/panther capskip/capskip composer require --dev dbrekelmans/bdi vendor\bin\bdi detect drivers
Последняя строка записана в форме для Windows; на Linux или macOS команда выглядит как vendor/bin/bdi. Она сохраняет chromedriver.exe в папку drivers внутри вашего проекта. Panther сам ищет драйвер в этой папке, но на Windows этот поиск оборачивается ловушкой. Panther находит файл, а затем не может запустить его по относительному пути. Ошибка гласит "Could not start chrome", а за ней следует сообщение cmd.exe о том, что команда не распознана. Передавайте полный путь к драйверу в createChromeClient, как это делает каждый пример здесь, или добавьте папку drivers в PATH.
Шаг 1: открываем страницу и читаем sitekey
Panther запускает headless Chrome через chromedriver и загружает страницу. Затем api.js от reCAPTCHA строит виджет и добавляет textarea с id g-recaptcha-response, поэтому ожидание этого элемента надёжно показывает, что виджет готов. Не ждите, пока он станет видимым: этого не происходит никогда, и waitForVisibility на нём падает по таймауту.
<?php
// composer require symfony/panther capskip/capskip
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\Panther\Client;
// The full path: on Windows, Panther finds drivers\chromedriver.exe on
// its own but then fails to start it from the relative path.
$client = Client::createChromeClient(__DIR__ . '/drivers/chromedriver.exe');
$client->request('GET', 'https://example.com/signup');
// api.js adds the hidden textarea once the widget has rendered.
$client->waitFor('#g-recaptcha-response', 20);
$crawler = $client->getCrawler();
$sitekey = $crawler->filter('[data-sitekey]')->attr('data-sitekey');Большинство страниц объявляют виджет через div с атрибутом data-sitekey. У страницы, которая строит его собственным скриптом через grecaptcha.render, такого атрибута нет, но ключ всё равно есть на странице, в адресе iframe виджета. На таких страницах используйте этот блок вместо последней строки шага 1:
// grecaptcha.render() pages have no data-sitekey; the key is the
// k= parameter of the widget's iframe.
$src = $crawler->filter('iframe[title="reCAPTCHA"]')->attr('src');
parse_str((string) parse_url($src, PHP_URL_QUERY), $query);
$sitekey = $query['k'] ?? '';Шаг 2: решаем капчу через PHP SDK
PHP SDK CapSkip нужны sitekey и URL страницы, на которой работает виджет. Берите URL из Panther, а не из своей переменной: после редиректа токен должен быть выпущен именно для той страницы, которая открыта в Panther.
use CapSkip\CapSkip; $solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]); // The page Panther has open, after any redirect, so the token is minted // for the URL the form really lives on. $result = $solver->recaptcha($sitekey, $client->getCurrentURL()); $token = $result['code']; // the result holds captchaId and code
Невидимый виджет встречается в одном из двух видов: div, у которого атрибут data-size равен invisible, или data-sitekey прямо на кнопке отправки. В обоих случаях передайте третьим аргументом массив опций, в котором invisible равен 1. Тестовое решение публичного тестового sitekey от Google через настоящий CapSkip заняло около 13 секунд и вернуло токен длиной 1678 символов.
PHP SDK синхронный, поэтому скрипт блокируется, пока не вернётся токен, и это нормально. Браузер Panther просто ждёт на странице, потому что таймауты php-webdriver применяются к каждой команде, а не к паузам между ними. Сессия, которая оставалась открытой во время решения длиной 103 секунды, после него всё так же нормально вставила токен и отправила форму. SDK опрашивает результат не дольше своего recaptchaTimeout, по умолчанию 300 секунд, а ключ timeout в массиве опций меняет это значение для одного вызова. А вот срок жизни токена действительно ограничен. Токен reCAPTCHA действует около двух минут после выдачи, поэтому записывайте и отправляйте его сразу.
Шаг 3: записываем токен через executeScript
Именно этот шаг решает, сработает ли прогон с капчей в Symfony Panther. Очевидный ход состоит в том, чтобы задать поле через объект Form в Panther, и он не срабатывает. Panther заполняет поля так, как это сделал бы пользователь: очищает их и печатает в них через WebDriver, а WebDriver отказывается печатать в элемент, который никто не видит. И присваивание поля в Form, и вызов setValues, и передача значения в submit, и вызов sendKeys на элементе заканчиваются одним и тем же ElementNotInteractableException. Вместо этого задайте значение прямо на странице:
// Throws ElementNotInteractableException: the textarea is display:none.
// $form['g-recaptcha-response'] = $token;
$client->executeScript(
'document.getElementById("g-recaptcha-response").value = arguments[0];',
[$token]
);executeScript передаёт каждый элемент PHP-массива в скрипт как arguments[0], arguments[1] и так далее, поэтому токен никогда не приходится экранировать внутрь строки JavaScript. Видимые поля, например адрес электронной почты, по-прежнему заполняются через обычный API форм Panther.
Шаг 4: отправляем форму нажатием настоящей кнопки
От способа отправки зависит, что увидит страница. Постройте форму от её кнопки, и Panther отправит её нажатием этой кнопки, то есть так же, как это делает посетитель:
// Built from its button, the form is sent by clicking that button, so the
// page's own submit handlers and HTML5 validation run as for a visitor.
$form = $client->getCrawler()->selectButton('Sign up')->form();
$form['email'] = 'YOUR_EMAIL';
$client->submit($form);
// Wait for something only the next page has; getCrawler() can lag.
$client->waitFor('#welcome', 15);
echo $client->getTitle(), PHP_EOL;Другие способы отправки кое-что пропускают, и это важно на страницах, чей собственный скрипт добавляет поле или проверяет форму при отправке:
| Как отправляется форма | Срабатывают обработчики submit на странице | Валидация HTML5 | Отправляются имя и значение кнопки |
|---|---|---|---|
| submit в Panther для формы, построенной от кнопки | Да | Да | Да |
| submit в Panther для формы, построенной от элемента form | Да | Нет | Нет |
| form.submit(), вызванный через executeScript | Нет | Нет | Нет |
У валидации есть и обратная сторона. Незаполненное обязательное поле или значение, которое не примет поле email, останавливает клик без какой-либо ошибки в PHP, и ничего не отправляется. Это одна из причин, по которой блок выше ждёт элемент, который есть только на следующей странице. Вторая причина в том, что краулер Panther ещё какое-то мгновение после отправки может описывать старую страницу, поэтому код, который сразу читает getCrawler(), может снова увидеть форму.
Шаг 5: страницы, которые отправляют форму из колбэка
На некоторых сайтах обычной отправки нет вовсе. У виджета есть атрибут data-callback, или страница передаёт колбэк в grecaptcha.render, и эта функция отправляет форму, как только reCAPTCHA передаст ей токен. Невидимые виджеты почти всегда работают именно так. На таких страницах токен в textarea ничего не делает, пока не сработает колбэк, поэтому вызовите его сами. Сначала запишите textarea, потому что колбэк обычно отправляет форму, а сервер читает токен из этого поля. Скрипт ниже делает и то и другое, а на странице с несколькими виджетами вызывает первый найденный колбэк:
// Writes the token into every reCAPTCHA textarea, then calls the widget's
// callback if it has one. ___grecaptcha_cfg is undocumented, so the walk
// looks for the object holding the sitekey instead of fixed key names.
const SUBMIT_TOKEN_JS = <<<'JS'
var token = arguments[0], found = null, seen = [];
document.querySelectorAll('textarea[name="g-recaptcha-response"]')
.forEach(function (t) { t.value = token; });
function walk(o, depth) {
if (found || !o || typeof o !== 'object' || depth > 6 || seen.indexOf(o) !== -1) return;
if (o instanceof Node || o === window) return;
seen.push(o);
if (Object.prototype.hasOwnProperty.call(o, 'sitekey') && o.callback) { found = o.callback; return; }
for (var k in o) {
if (Object.prototype.hasOwnProperty.call(o, k)) { try { walk(o[k], depth + 1); } catch (e) {} }
}
}
if (window.___grecaptcha_cfg) {
Object.keys(___grecaptcha_cfg.clients).forEach(function (id) { walk(___grecaptcha_cfg.clients[id], 0); });
}
if (typeof found === 'string') found = window[found];
if (typeof found !== 'function') return 'no callback';
found(token);
return 'callback called';
JS;
echo $client->executeScript(SUBMIT_TOKEN_JS, [$token]), PHP_EOL;reCAPTCHA хранит настройки каждого виджета в глобальном объекте ___grecaptcha_cfg. Атрибут data-callback попадает туда как имя функции, а колбэк, переданный в grecaptcha.render, как сама функция, поэтому скрипт обрабатывает оба случая. Объект не документирован, а имена его внутренних ключей минифицированы, поэтому скрипт ищет настройки, а не идёт по фиксированному пути. Если Google когда-нибудь изменит его структуру, скрипт сообщит, что колбэка нет, а на странице с data-callback вы всё равно сможете прочитать атрибут и вызвать функцию по этому имени. Единственная ошибка, которой нужно избегать, состоит в вызове колбэка без записи textarea: форма уйдёт с пустым g-recaptcha-response.
Полный рабочий пример
Вот весь прогон с капчей в Symfony Panther в одном скрипте. Он читает ключ со страницы любого из двух видов, решает капчу, записывает токен и либо даёт колбэку отправить форму, если колбэк есть, либо нажимает кнопку, если его нет.
<?php
// composer require symfony/panther capskip/capskip
// signup.php: run from the project folder with php signup.php
require __DIR__ . '/vendor/autoload.php';
use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\ValidationException;
use Symfony\Component\Panther\Client;
const PAGE_URL = 'https://example.com/signup';
// Writes the token into every reCAPTCHA textarea, then calls the widget's
// callback if it has one. ___grecaptcha_cfg is undocumented, so the walk
// looks for the object holding the sitekey instead of fixed key names.
const SUBMIT_TOKEN_JS = <<<'JS'
var token = arguments[0], found = null, seen = [];
document.querySelectorAll('textarea[name="g-recaptcha-response"]')
.forEach(function (t) { t.value = token; });
function walk(o, depth) {
if (found || !o || typeof o !== 'object' || depth > 6 || seen.indexOf(o) !== -1) return;
if (o instanceof Node || o === window) return;
seen.push(o);
if (Object.prototype.hasOwnProperty.call(o, 'sitekey') && o.callback) { found = o.callback; return; }
for (var k in o) {
if (Object.prototype.hasOwnProperty.call(o, k)) { try { walk(o[k], depth + 1); } catch (e) {} }
}
}
if (window.___grecaptcha_cfg) {
Object.keys(___grecaptcha_cfg.clients).forEach(function (id) { walk(___grecaptcha_cfg.clients[id], 0); });
}
if (typeof found === 'string') found = window[found];
if (typeof found !== 'function') return 'no callback';
found(token);
return 'callback called';
JS;
$solver = new CapSkip([
'apiKey' => getenv('CAPSKIP_API_KEY') ?: 'capskip',
'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
]);
// The full path: on Windows, Panther finds drivers\chromedriver.exe on
// its own but then fails to start it from the relative path.
$client = Client::createChromeClient(__DIR__ . '/drivers/chromedriver.exe');
try {
$client->request('GET', PAGE_URL);
// api.js adds the hidden textarea once the widget has rendered.
$client->waitFor('#g-recaptcha-response', 20);
$crawler = $client->getCrawler();
$box = $crawler->filter('[data-sitekey]');
if ($box->count() > 0) {
$sitekey = $box->attr('data-sitekey');
} else {
// grecaptcha.render() pages: the key is the k= parameter of the widget iframe.
$src = $crawler->filter('iframe[title="reCAPTCHA"]')->attr('src');
parse_str((string) parse_url($src, PHP_URL_QUERY), $query);
$sitekey = $query['k'] ?? '';
}
// Visible fields take typing as usual.
$form = $crawler->selectButton('Sign up')->form();
$form['email'] = 'YOUR_EMAIL';
$token = $solver->recaptcha($sitekey, $client->getCurrentURL())['code'];
if ($client->executeScript(SUBMIT_TOKEN_JS, [$token]) !== 'callback called') {
// No callback: click the real button, as a visitor would.
$client->submit($form);
}
// Wait for something only the next page has; getCrawler() can lag.
$client->waitFor('#welcome', 15);
echo 'Submitted: ', $client->getTitle(), PHP_EOL;
} catch (ValidationException $e) {
echo 'Not sent: ', $e->getMessage(), PHP_EOL;
} catch (CapSkipError $e) {
echo 'Solve failed: ', get_class($e), ': ', $e->getMessage(), PHP_EOL;
} finally {
$client->quit();
}Поменяйте надпись на кнопке, поле email и селектор, который вы ждёте, под свою страницу. Для невидимого виджета добавьте в вызов recaptcha массив опций из шага 2. На странице с колбэком и вообще без кнопки вся работа сводится к шагам 1, 2 и 5. Если waitFor сдаётся после клика, посмотрите на форму, прежде чем винить токен: незаполненное обязательное поле останавливает отправку вообще без ошибки. Подробнее о стороне SDK, включая опции invisible и Enterprise, читайте в руководстве, где решение reCAPTCHA v2 на PHP показано через вызов recaptcha без браузера.
Запуск Panther и CapSkip на разных машинах
Наборы тестов на Panther часто работают на раннере CI или на Linux-машине, а CapSkip представляет собой приложение для Windows. Это вполне рабочая схема. Переключите CapSkip в режим Server, и он начнёт слушать ваш сетевой адрес или публичный IP, так что Panther на любой машине сможет обращаться к нему через тот же API. Если маршрут идёт через интернет, используйте статический публичный IP, включите проверку API-ключа и ограничьте порт ожидаемыми адресами с помощью правила Windows Firewall. Решателем по-прежнему остаётся ваша собственная машина с Windows, и решения по-прежнему не тарифицируются.
PHP SDK сам не читает переменные окружения. Читайте CAPSKIP_HOST, CAPSKIP_PORT и CAPSKIP_API_KEY в своём коде и передавайте их в конструктор, как это делает полный пример, чтобы один и тот же скрипт работал и у вас на столе, и в CI. Если хост неверен или CapSkip не запущен, первый же вызов падает с NetworkException, в котором сказано, что подключиться не удалось. Это занимает около двух секунд, когда порт никто не слушает, и до 30 секунд (это таймаут подключения в SDK), когда соединение сбрасывает файрвол.
Частые ошибки и что они означают
| Что вы видите | Причина | Исправить |
|---|---|---|
| ElementNotInteractableException: element not interactable | Panther попытался напечатать токен в скрытую textarea g-recaptcha-response | Запишите значение через executeScript, как в шаге 3 |
| Could not start chrome и сообщение cmd.exe о том, что команда не распознана | Windows: Panther нашёл chromedriver в папке drivers, но не смог запустить его по относительному пути | Передайте полный путь к драйверу в createChromeClient или добавьте папку drivers в PATH |
| Could not start chrome и сообщение о том, что системе не удаётся найти указанный путь | По пути, переданному в createChromeClient, нет chromedriver.exe | Выполните bdi detect drivers в папке проекта или исправьте путь |
| SessionNotCreatedException: This version of ChromeDriver only supports Chrome version N | Chrome обновился сам, а драйвер нет | Выполните bdi detect drivers ещё раз |
| RuntimeException: The port 9515 is already in use. | Другой клиент Panther или скрипт уже занял порт chromedriver по умолчанию | Задайте каждому клиенту собственный порт в массиве опций createChromeClient |
| Токен записан, но ничего не происходит | Страница отправляет форму из колбэка reCAPTCHA | Запустите скрипт из шага 5: он записывает поле и вызывает колбэк |
| waitFor падает по таймауту после клика, и ничего не отправлено | Обязательное поле осталось пустым, или значение не прошло валидацию поля | Перед отправкой заполните каждое обязательное поле допустимым значением |
| NetworkException: Failed to connect to 127.0.0.1 port 8080 | CapSkip не запущен либо неверны хост и порт | Запустите CapSkip и проверьте, в каком режиме он должен работать: Local или Server |
| Ошибка Maximum execution time exceeded во время решения | Panther работает внутри веб-запроса, а на Windows этот лимит учитывает и время ожидания | Запускайте Panther из командной строки, где лимит отключён, или из воркера очереди |
FAQ
Усложняет ли headless Chrome прохождение капчи?
Не при таком подходе. Капча решается в собственном браузере CapSkip, а не в том Chrome, которым управляет Panther, поэтому браузеру Panther проходить её вообще не нужно. Panther лишь переносит готовый токен в форму. Чтобы наблюдать за прогоном, задайте PANTHER_NO_HEADLESS равным 1 в окружении до запуска PHP. Panther читает эту переменную из $_SERVER, поэтому вызов putenv() внутри скрипта ни на что не влияет.
Нужен ли решатель в тестах Panther для моего собственного приложения?
Обычно нет. Для приложения, которое вы контролируете, настройте тестовое окружение с опубликованными Google тестовыми ключами reCAPTCHA, которые всегда проходят проверку, так что в тестах вообще ничего не нужно решать. Решатель оправдывает себя, когда Panther управляет страницами, которые вы не контролируете, или staging-сайтом, который должен сохранить свои настоящие ключи.
Можно ли запустить несколько браузеров Panther одновременно?
Да, по одному на процесс или несколько в одном, если у каждого клиента свой порт chromedriver. Каждый клиент запускает собственный chromedriver на порту 9515, если вы не передадите другой порт в опциях createChromeClient, и второй клиент на том же порту падает на первом же запросе. PHP SDK синхронный, поэтому каждый процесс решает одну капчу за раз, а сам CapSkip по умолчанию выполняет 10 решений reCAPTCHA одновременно.
Работает ли тот же подход для Cloudflare Turnstile?
Схема та же. Turnstile кладёт свой токен в скрытый input с именем cf-turnstile-response. Panther не может заполнить и его: объект Form сообщает о недопустимом состоянии элемента, sendKeys сообщает, что элемент недоступен для взаимодействия, а executeScript его задаёт. Решайте капчу методом turnstile из SDK вместо recaptcha и читайте sitekey из атрибута data-sitekey у div cf-turnstile. Скрипт из шага 5 обрабатывает только колбэки reCAPTCHA.
Коротко
Для капчи в Symfony Panther передайте на Windows полный путь к драйверу в createChromeClient, загрузите страницу и дождитесь textarea g-recaptcha-response, затем прочитайте sitekey из data-sitekey или из iframe виджета. Решайте через PHP SDK с текущим URL из Panther. Записывайте токен через executeScript, никогда не через объект Form, затем нажимайте настоящую кнопку или вызывайте колбэк на страницах, где он есть. Дождитесь следующей страницы, прежде чем её читать, и переключите CapSkip в режим Server, когда Panther работает на другой машине.
- Все типы капчи, которые поддерживает PHP-пакет CapSkip, перечислены на странице сервиса распознавания капчи для PHP.
- Как решаются обе версии, с чекбоксом и невидимая, рассказано на странице сервиса распознавания reCAPTCHA v2.
Парсер на Panther, который встречает виджет на каждой странице, ничего не доплачивает, когда обход капчи работает на вашей собственной машине с Windows. Нет причин пропускать страницу только потому, что на ней стоит капча.
