如何在 Symfony Panther 中识别 reCAPTCHA(PHP SDK)

symfony panther captcha - How to Solve reCAPTCHA in Symfony Panther (PHP SDK)

要识别 Symfony Panther 验证码,就从 Panther 当前打开的页面读取 sitekey,把它和当前 URL 一起传给 CapSkip PHP SDK 的 recaptcha 方法。然后用 executeScript 把 token 写入隐藏的 g-recaptcha-response textarea,再点击表单自己的按钮提交。Panther 代码通常就栽在写入这一步。reCAPTCHA 让这个 textarea 保持 display:none,所以凡是会去填写它的 Panther 辅助方法,不论是 Form 对象还是 sendKeys,都会抛出 ElementNotInteractableException。通过 reCAPTCHA 回调提交的页面还需要多一步,因为在回调运行之前,它们根本不看这个字段。本指南涵盖 Windows 上的环境搭建、识别、写入、两种提交方式,以及让运行保持稳定的等待。

你需要什么

  • 在 Windows 机器上运行的 CapSkip。在 Local 模式下,它只在 127.0.0.1 上响应,仅供本机使用;在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的 Panther 就能通过 API 调用它。两种模式的设置位置都是 连接设置.
  • PHP 8.1 或更高版本,并在 php.ini 中启用 curl 和 zip 扩展。Panther 2.4 需要 8.1;没有 zip,Composer 就不会安装 php-webdriver;而 Windows 版的 PHP 默认把这两个扩展都关着。
  • 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 上,这个查找机制是个陷阱:它能找到文件,却无法通过相对路径启动它。报错是 "Could not start chrome",后面跟着一条 cmd.exe 消息,说某个命令无法识别。像本文所有示例那样,把驱动程序的完整路径传给 createChromeClient,或者把 drivers 文件夹加到 PATH 中。

第 1 步:打开页面并读取 sitekey

Panther 通过 chromedriver 启动一个无头 Chrome 并加载页面。随后 reCAPTCHA 的 api.js 会构建小组件,并添加一个 id 为 g-recaptcha-response 的 textarea,所以等待这个元素出现,就是判断小组件已就绪的可靠信号。不要等它变为可见:它永远不会可见,对它调用 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');

大多数页面用一个带 data-sitekey 属性的 div 来声明小组件。如果页面用自己的脚本通过 grecaptcha.render 构建小组件,就没有这个属性,但 key 仍然在页面上,就在小组件 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 识别

CapSkip PHP SDK 需要 sitekey,以及小组件所在页面的 URL。URL 要从 Panther 获取,而不是用你自己的变量:发生重定向之后,Panther 当前打开的页面,才是 token 必须为之签发的那个页面。

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

隐形小组件有两种出现形式:一个 data-size 属性值为 invisible 的 div,或者直接写在提交按钮上的 data-sitekey。无论哪种,都把一个 invisible 设为 1 的选项数组作为第三个参数传入。用一台真实的 CapSkip 对 Google 公开的测试 sitekey 做了一次测试识别,耗时约 13 秒,返回的 token 长 1,678 个字符。

PHP SDK 是同步的,所以脚本会一直阻塞到 token 返回,这没有问题。Panther 的浏览器只是停在页面上等待,因为 php-webdriver 的超时只作用于每条命令,而不作用于命令之间的间隔。一个在 103 秒的识别期间一直保持打开的会话,之后照样正常完成了注入和提交。SDK 最多按 recaptchaTimeout 轮询(默认 300 秒),在选项数组里加一个 timeout 键,就能为单次调用改变这个时长。真正会过期的是 token。reCAPTCHA token 的有效期是 签发后约两分钟,所以要立刻写入并提交。

第 3 步:用 executeScript 写入 token

Symfony Panther 验证码流程能不能跑通,就取决于这一步。最直接的做法是通过 Panther 的 Form 对象设置这个字段,但这行不通。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] 等传给脚本,所以 token 永远不必转义后拼进 JavaScript 字符串。可见的字段(比如电子邮箱地址)仍然使用 Panther 常规的表单 API。

第 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 校验发送按钮的 name 和 value
对从按钮构建的表单调用 Panther submit是是是
对从 form 元素构建的表单调用 Panther submit是否否
通过 executeScript 运行 form.submit()否否否

校验是把双刃剑。必填字段留空,或者某个值不被电子邮箱字段接受,都会让这次点击停下:PHP 里不会出现任何错误,也不会提交任何内容。这是上面那段代码要等待一个只有下一页才有的元素的原因之一。另一个原因是,提交之后的片刻里,Panther 的 crawler 可能仍然描述着旧页面,所以立刻读取 getCrawler() 的代码可能又会看到那个表单。

第 5 步:通过回调提交的页面

有些站点根本没有普通的提交。小组件带有 data-callback 属性,或者页面把一个回调传给 grecaptcha.render,等 reCAPTCHA 把 token 交给这个函数后,由它来提交表单。隐形小组件几乎总是这样工作的。在这类页面上,回调运行之前,textarea 里的 token 不起任何作用,所以要你自己调用它。先写入 textarea,因为回调通常会提交表单,而服务器是从这个字段读取 token 的。下面的脚本两件事都做;在有多个小组件的页面上,它会调用找到的第一个回调:

// 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 验证码流程。它能从两种页面读取 key,完成识别,写入 token;有回调时让回调提交,没有回调时就点击按钮。

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

按你的页面修改按钮文字、邮箱字段和要等待的选择器。对于隐形小组件,在 recaptcha 调用里加上第 2 步的选项数组。在完全没有按钮的回调页面上,第 1、2、5 步就是全部工作。如果点击之后 waitFor 放弃了等待,先检查表单,再怀疑 token:必填字段留空会让提交停下,而且不报任何错误。想了解 SDK 这一侧的更多内容,包括 invisible 和 Enterprise 选项,请看关于 在 PHP 中识别 reCAPTCHA v2 的指南,它介绍了不借助浏览器的 recaptcha 调用。

在不同机器上运行 Panther 和 CapSkip

Panther 测试套件常常运行在 CI runner 或 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 interactablePanther 试图把 token 输入隐藏的 g-recaptcha-response textarea像第 3 步那样,用 executeScript 写入这个值
报错 Could not start chrome,并附带一条 cmd.exe 消息,说某个命令无法识别Windows 上:Panther 在 drivers 文件夹中找到了 chromedriver,却无法通过相对路径启动它把驱动程序的完整路径传给 createChromeClient,或者把 drivers 文件夹加到 PATH 中
报错 Could not start chrome,并附带一条消息,说系统找不到指定的路径传给 createChromeClient 的路径下没有 chromedriver.exe在项目文件夹中运行 bdi detect drivers,或者修正路径
SessionNotCreatedException: This version of ChromeDriver only supports Chrome version NChrome 自动更新了,驱动程序却没有再运行一次 bdi detect drivers
RuntimeException: The port 9515 is already in use.另一个 Panther 客户端或脚本已经占用了 chromedriver 的默认端口在 createChromeClient 的选项数组中给每个客户端分配各自的端口
token 已写入,却什么都没发生页面是通过 reCAPTCHA 回调提交的运行第 5 步的脚本,它会写入字段并调用回调
点击后 waitFor 超时,什么都没有提交某个必填字段为空,或者某个值没有通过该字段的校验提交之前,用有效的值填好每个必填字段
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip 没有在运行,或者主机和端口不对启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式
识别过程中出现 Maximum execution time exceededPanther 运行在一个 Web 请求内部,而在 Windows 上,这个时限会把等待的时间也计算在内从命令行运行 Panther(命令行下没有这个时限),或者在队列 worker 中运行

常见问题

无头 Chrome 会让验证码更难通过吗?

对这种做法来说不会。挑战是在 CapSkip 自己的浏览器里识别的,而不是在 Panther 驱动的 Chrome 里,所以 Panther 的浏览器从来不需要通过它。Panther 只负责把识别好的 token 带进表单。如果你想观看运行过程,就在启动 PHP 之前,在环境变量中把 PANTHER_NO_HEADLESS 设为 1。Panther 是从 $_SERVER 读取它的,所以在脚本内部调用 putenv() 不会生效。

我自己应用的 Panther 测试应该使用识别工具吗?

通常不该。对于你能控制的应用,在测试环境里配置 Google 公开的 reCAPTCHA 测试密钥,它们总能通过验证,所以测试根本不需要识别。只有当 Panther 驱动的是你控制不了的页面,或者是必须保留真实密钥的预发布站点时,识别工具才派得上用场。

我可以同时运行多个 Panther 浏览器吗?

可以,每个进程一个,或者一个进程里多个都行,只要每个客户端都有自己的 chromedriver 端口。除非你在 createChromeClient 的选项中传入别的端口,否则每个客户端都会在 9515 端口上启动自己的 chromedriver,而同一端口上的第二个客户端会在它的第一个请求时失败。PHP SDK 是同步的,所以每个进程一次只识别一个验证码;CapSkip 本身默认同时运行 10 个 reCAPTCHA 识别。

同样的做法适用于 Cloudflare Turnstile 吗?

整体思路一样。Turnstile 把它的 token 放在一个名为 cf-turnstile-response 的隐藏 input 里。Panther 同样无法填写它:Form 对象会报告元素状态无效,sendKeys 会报告元素不可交互,而 executeScript 能设置它。用 SDK 的 turnstile 方法代替 recaptcha 来识别,并从 cf-turnstile div 的 data-sitekey 属性读取 sitekey。第 5 步的脚本只处理 reCAPTCHA 回调。

简短版结论

处理 Symfony Panther 验证码时,在 Windows 上要把驱动程序的完整路径传给 createChromeClient;加载页面并等待 g-recaptcha-response textarea 出现,然后从 data-sitekey 或小组件的 iframe 读取 sitekey。用 PHP SDK 识别,URL 用 Panther 的当前 URL。用 executeScript 写入 token,永远不要用 Form 对象,然后点击真实的按钮;在有回调的页面上则调用回调。先等下一页加载出来再读取它;当 Panther 在别的机器上运行时,把 CapSkip 切换到 Server 模式。

一个每页都会遇到小组件的 Panther 爬虫,只要负责 验证码绕过 的工具运行在你自己的 Windows 机器上,就不会额外多付一分钱。没有理由因为页面上有个挑战就跳过它。