如何在 Symfony Panther 中识别 reCAPTCHA(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 interactable | Panther 试图把 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 N | Chrome 自动更新了,驱动程序却没有 | 再运行一次 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 8080 | CapSkip 没有在运行,或者主机和端口不对 | 启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式 |
| 识别过程中出现 Maximum execution time exceeded | Panther 运行在一个 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 模式。
- PHP 包能处理的所有验证码类型: PHP 验证码识别工具页面.
- 复选框版和隐形版 reCAPTCHA v2 如何识别: reCAPTCHA v2 识别页面.
一个每页都会遇到小组件的 Panther 爬虫,只要负责 验证码绕过 的工具运行在你自己的 Windows 机器上,就不会额外多付一分钱。没有理由因为页面上有个挑战就跳过它。
