{"id":26201,"date":"2026-10-02T12:15:34","date_gmt":"2026-10-02T12:15:34","guid":{"rendered":"https:\/\/capskip.com\/?p=26201"},"modified":"2026-10-02T12:15:34","modified_gmt":"2026-10-02T12:15:34","slug":"symfony-panther-captcha","status":"publish","type":"post","link":"https:\/\/capskip.com\/zh\/symfony-panther-captcha\/","title":{"rendered":"\u5982\u4f55\u5728 Symfony Panther \u4e2d\u8bc6\u522b reCAPTCHA\uff08PHP SDK\uff09"},"content":{"rendered":"<p>To solve a Symfony Panther CAPTCHA, read the sitekey from the page Panther has open and pass it with the current URL to the recaptcha method of the CapSkip PHP SDK. Then write the token into the hidden g-recaptcha-response textarea with executeScript and submit by clicking the form&#8217;s own button. The write is where Panther code usually breaks. reCAPTCHA keeps that textarea at display:none, so every Panther helper that would fill it, the Form object and sendKeys alike, throws ElementNotInteractableException. Pages that submit from a reCAPTCHA callback need one more step, because they ignore the field until the callback runs. This guide covers the setup on Windows, the solve, the write, both kinds of submit and the waits that keep the run reliable.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">What you need<\/h2>\n<ul>\n<li>CapSkip running on a Windows machine. In Local mode it answers on 127.0.0.1 for that device only; in Server mode it listens on your network address or public IP, so Panther on another machine can call it over the API. Both are set under <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">connection settings<\/a>.<\/li>\n<li>PHP 8.1 or later, with the curl and zip extensions switched on in php.ini. Panther 2.4 needs 8.1, Composer will not install php-webdriver without zip, and the Windows builds of PHP ship both extensions switched off.<\/li>\n<li>Chrome, and a chromedriver with the same major version. The bdi tool below downloads the right one.<\/li>\n<li>A page with a reCAPTCHA v2 widget, checkbox or invisible.<\/li>\n<\/ul>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># PHP 8.1+; bdi fetches the chromedriver that matches your Chrome\ncomposer require symfony\/panther capskip\/capskip\ncomposer require --dev dbrekelmans\/bdi\nvendor\\bin\\bdi detect drivers<\/pre>\n<\/div>\n<p>The last line is the Windows form of the command; on Linux or macOS it is vendor\/bin\/bdi. It saves chromedriver.exe in a drivers folder inside your project. Panther looks in that folder on its own, but on Windows the lookup is a trap. It finds the file, then fails to start it from the relative path. The error reads &quot;Could not start chrome&quot;, followed by a cmd.exe message that a command is not recognized. Pass the driver&#8217;s full path to createChromeClient, as every sample here does, or put the drivers folder on PATH.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 1: open the page and read the sitekey<\/h2>\n<p>Panther starts a headless Chrome through chromedriver and loads the page. reCAPTCHA&#8217;s api.js then builds the widget and adds a textarea with the id g-recaptcha-response, so waiting for that element is the reliable sign the widget is ready. Do not wait for it to become visible: it never does, and waitForVisibility on it times out.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"php\" class=\"EnlighterJSRAW\">&lt;?php\n\/\/ composer require symfony\/panther capskip\/capskip\nrequire __DIR__ . '\/vendor\/autoload.php';\n\nuse Symfony\\Component\\Panther\\Client;\n\n\/\/ The full path: on Windows, Panther finds drivers\\chromedriver.exe on\n\/\/ its own but then fails to start it from the relative path.\n$client = Client::createChromeClient(__DIR__ . '\/drivers\/chromedriver.exe');\n$client-&gt;request('GET', 'https:\/\/example.com\/signup');\n\n\/\/ api.js adds the hidden textarea once the widget has rendered.\n$client-&gt;waitFor('#g-recaptcha-response', 20);\n$crawler = $client-&gt;getCrawler();\n$sitekey = $crawler-&gt;filter('[data-sitekey]')-&gt;attr('data-sitekey');<\/pre>\n<\/div>\n<p>Most pages declare the widget with a div that carries a data-sitekey attribute. A page that builds it from its own script with grecaptcha.render has no such attribute, but the key is still on the page, in the address of the widget&#8217;s iframe. On those pages, use this block in place of the last line of Step 1:<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"php\" class=\"EnlighterJSRAW\">\/\/ grecaptcha.render() pages have no data-sitekey; the key is the\n\/\/ k= parameter of the widget's iframe.\n$src = $crawler-&gt;filter('iframe[title=&quot;reCAPTCHA&quot;]')-&gt;attr('src');\nparse_str((string) parse_url($src, PHP_URL_QUERY), $query);\n$sitekey = $query['k'] ?? '';<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 2: solve it with the PHP SDK<\/h2>\n<p>The CapSkip PHP SDK needs the sitekey and the URL of the page the widget runs on. Take the URL from Panther rather than from your own variable: after a redirect, the page Panther has open is the one the token has to be minted for.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"php\" class=\"EnlighterJSRAW\">use CapSkip\\CapSkip;\n\n$solver = new CapSkip(['host' =&gt; '127.0.0.1', 'port' =&gt; 8080]);\n\n\/\/ The page Panther has open, after any redirect, so the token is minted\n\/\/ for the URL the form really lives on.\n$result = $solver-&gt;recaptcha($sitekey, $client-&gt;getCurrentURL());\n$token = $result['code'];   \/\/ the result holds captchaId and code<\/pre>\n<\/div>\n<p>An invisible widget shows up in one of two ways: a div with a data-size attribute set to invisible, or a data-sitekey on the submit button itself. For either, pass an options array with invisible set to 1 as the third argument. A test solve of Google&#8217;s public test sitekey through a real CapSkip took about 13 seconds and returned a token of 1,678 characters.<\/p>\n<p>The PHP SDK is synchronous, so the script blocks until the token comes back, and that is fine. Panther&#8217;s browser simply waits on the page, because php-webdriver&#8217;s timeouts apply to each command, not to the gaps between them. A session kept open through a 103-second solve still injected and submitted normally afterwards. The SDK polls for up to its recaptchaTimeout, 300 seconds by default, and a timeout key in the options array changes that for one call. What does run out is the token. A reCAPTCHA token is good for <a href=\"https:\/\/capskip.com\/recaptcha-token-expiration\/\">about two minutes after it is issued<\/a>, so write and submit it straight away.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 3: write the token with executeScript<\/h2>\n<p>This is the step that decides whether a Symfony Panther CAPTCHA run works. The obvious move is to set the field through Panther&#8217;s Form object, and it fails. Panther fills fields the way a user would, by clearing them and typing into them through WebDriver, and WebDriver refuses to type into an element nobody can see. Setting the field on the Form, calling setValues, passing it to submit or calling sendKeys on the element all end in the same ElementNotInteractableException. Set the value in the page instead:<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"php\" class=\"EnlighterJSRAW\">\/\/ Throws ElementNotInteractableException: the textarea is display:none.\n\/\/ $form['g-recaptcha-response'] = $token;\n\n$client-&gt;executeScript(\n    'document.getElementById(&quot;g-recaptcha-response&quot;).value = arguments[0];',\n    [$token]\n);<\/pre>\n<\/div>\n<p>executeScript passes each item of the PHP array to the script as arguments[0], arguments[1] and so on, so the token never has to be escaped into the JavaScript string. Visible fields such as an email address still take Panther&#8217;s normal form API.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 4: submit by clicking the real button<\/h2>\n<p>How you submit changes what the page sees. Build the form from its button and Panther submits it by clicking that button, which is what a visitor does:<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"php\" class=\"EnlighterJSRAW\">\/\/ Built from its button, the form is sent by clicking that button, so the\n\/\/ page's own submit handlers and HTML5 validation run as for a visitor.\n$form = $client-&gt;getCrawler()-&gt;selectButton('Sign up')-&gt;form();\n$form['email'] = 'YOUR_EMAIL';\n$client-&gt;submit($form);\n\n\/\/ Wait for something only the next page has; getCrawler() can lag.\n$client-&gt;waitFor('#welcome', 15);\necho $client-&gt;getTitle(), PHP_EOL;<\/pre>\n<\/div>\n<p>The other ways to submit leave things out, which matters on pages whose own script adds a field or checks the form on submit:<\/p>\n<table>\n<thead>\n<tr>\n<th>How the form is sent<\/th>\n<th>Page&#8217;s submit handlers run<\/th>\n<th>HTML5 validation<\/th>\n<th>Button name and value posted<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Panther submit on a form built from its button<\/td>\n<td>Yes<\/td>\n<td>Yes<\/td>\n<td>Yes<\/td>\n<\/tr>\n<tr>\n<td>Panther submit on a form built from the form element<\/td>\n<td>Yes<\/td>\n<td>No<\/td>\n<td>No<\/td>\n<\/tr>\n<tr>\n<td>form.submit() run through executeScript<\/td>\n<td>No<\/td>\n<td>No<\/td>\n<td>No<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Validation cuts both ways. A required field left empty, or a value an email field will not accept, stops the click without any error in PHP, and nothing is posted. That is one reason the block above waits for an element that only the next page has. The other is that Panther&#8217;s crawler can still describe the old page for a moment after a submit, so code that reads getCrawler() straight away may see the form again.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 5: pages that submit from a callback<\/h2>\n<p>Some sites have no ordinary submit at all. The widget carries a data-callback attribute, or the page passes a callback to grecaptcha.render, and that function submits the form once reCAPTCHA hands it a token. Invisible widgets almost always work this way. On those pages, a token in the textarea does nothing until the callback runs, so call it yourself. Write the textarea first, because the callback usually posts the form and the server reads the token from that field. The script below does both, and on a page with several widgets it calls the first callback it finds:<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"php\" class=\"EnlighterJSRAW\">\/\/ Writes the token into every reCAPTCHA textarea, then calls the widget's\n\/\/ callback if it has one. ___grecaptcha_cfg is undocumented, so the walk\n\/\/ looks for the object holding the sitekey instead of fixed key names.\nconst SUBMIT_TOKEN_JS = &lt;&lt;&lt;'JS'\nvar token = arguments[0], found = null, seen = [];\ndocument.querySelectorAll('textarea[name=&quot;g-recaptcha-response&quot;]')\n  .forEach(function (t) { t.value = token; });\nfunction walk(o, depth) {\n  if (found || !o || typeof o !== 'object' || depth &gt; 6 || seen.indexOf(o) !== -1) return;\n  if (o instanceof Node || o === window) return;\n  seen.push(o);\n  if (Object.prototype.hasOwnProperty.call(o, 'sitekey') &amp;&amp; o.callback) { found = o.callback; return; }\n  for (var k in o) {\n    if (Object.prototype.hasOwnProperty.call(o, k)) { try { walk(o[k], depth + 1); } catch (e) {} }\n  }\n}\nif (window.___grecaptcha_cfg) {\n  Object.keys(___grecaptcha_cfg.clients).forEach(function (id) { walk(___grecaptcha_cfg.clients[id], 0); });\n}\nif (typeof found === 'string') found = window[found];\nif (typeof found !== 'function') return 'no callback';\nfound(token);\nreturn 'callback called';\nJS;\n\necho $client-&gt;executeScript(SUBMIT_TOKEN_JS, [$token]), PHP_EOL;<\/pre>\n<\/div>\n<p>reCAPTCHA keeps each widget&#8217;s settings in a global object called ___grecaptcha_cfg. A data-callback attribute ends up there as the function&#8217;s name, and a callback passed to grecaptcha.render ends up there as the function itself, so the script handles both. The object is undocumented and its inner key names are minified, which is why the script searches for the settings rather than following a fixed path. If Google ever restructures it, the script returns no callback, and on a data-callback page you can still read the attribute and call the function by that name. Calling the callback without writing the textarea is the one mistake to avoid: the form goes out with an empty g-recaptcha-response.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Full working example<\/h2>\n<p>Here is the whole Symfony Panther CAPTCHA run in one script. It reads the key from either kind of page, solves, writes the token, and lets the callback submit when there is one or clicks the button when there is not.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"php\" class=\"EnlighterJSRAW\">&lt;?php\n\/\/ composer require symfony\/panther capskip\/capskip\n\/\/ signup.php: run from the project folder with php signup.php\nrequire __DIR__ . '\/vendor\/autoload.php';\n\nuse CapSkip\\CapSkip;\nuse CapSkip\\Exceptions\\CapSkipError;\nuse CapSkip\\Exceptions\\ValidationException;\nuse Symfony\\Component\\Panther\\Client;\n\nconst PAGE_URL = 'https:\/\/example.com\/signup';\n\n\/\/ Writes the token into every reCAPTCHA textarea, then calls the widget's\n\/\/ callback if it has one. ___grecaptcha_cfg is undocumented, so the walk\n\/\/ looks for the object holding the sitekey instead of fixed key names.\nconst SUBMIT_TOKEN_JS = &lt;&lt;&lt;'JS'\nvar token = arguments[0], found = null, seen = [];\ndocument.querySelectorAll('textarea[name=&quot;g-recaptcha-response&quot;]')\n  .forEach(function (t) { t.value = token; });\nfunction walk(o, depth) {\n  if (found || !o || typeof o !== 'object' || depth &gt; 6 || seen.indexOf(o) !== -1) return;\n  if (o instanceof Node || o === window) return;\n  seen.push(o);\n  if (Object.prototype.hasOwnProperty.call(o, 'sitekey') &amp;&amp; o.callback) { found = o.callback; return; }\n  for (var k in o) {\n    if (Object.prototype.hasOwnProperty.call(o, k)) { try { walk(o[k], depth + 1); } catch (e) {} }\n  }\n}\nif (window.___grecaptcha_cfg) {\n  Object.keys(___grecaptcha_cfg.clients).forEach(function (id) { walk(___grecaptcha_cfg.clients[id], 0); });\n}\nif (typeof found === 'string') found = window[found];\nif (typeof found !== 'function') return 'no callback';\nfound(token);\nreturn 'callback called';\nJS;\n\n$solver = new CapSkip([\n    'apiKey' =&gt; getenv('CAPSKIP_API_KEY') ?: 'capskip',\n    'host' =&gt; getenv('CAPSKIP_HOST') ?: '127.0.0.1',\n    'port' =&gt; (int) (getenv('CAPSKIP_PORT') ?: 8080),\n]);\n\/\/ The full path: on Windows, Panther finds drivers\\chromedriver.exe on\n\/\/ its own but then fails to start it from the relative path.\n$client = Client::createChromeClient(__DIR__ . '\/drivers\/chromedriver.exe');\n\ntry {\n    $client-&gt;request('GET', PAGE_URL);\n    \/\/ api.js adds the hidden textarea once the widget has rendered.\n    $client-&gt;waitFor('#g-recaptcha-response', 20);\n    $crawler = $client-&gt;getCrawler();\n\n    $box = $crawler-&gt;filter('[data-sitekey]');\n    if ($box-&gt;count() &gt; 0) {\n        $sitekey = $box-&gt;attr('data-sitekey');\n    } else {\n        \/\/ grecaptcha.render() pages: the key is the k= parameter of the widget iframe.\n        $src = $crawler-&gt;filter('iframe[title=&quot;reCAPTCHA&quot;]')-&gt;attr('src');\n        parse_str((string) parse_url($src, PHP_URL_QUERY), $query);\n        $sitekey = $query['k'] ?? '';\n    }\n\n    \/\/ Visible fields take typing as usual.\n    $form = $crawler-&gt;selectButton('Sign up')-&gt;form();\n    $form['email'] = 'YOUR_EMAIL';\n\n    $token = $solver-&gt;recaptcha($sitekey, $client-&gt;getCurrentURL())['code'];\n\n    if ($client-&gt;executeScript(SUBMIT_TOKEN_JS, [$token]) !== 'callback called') {\n        \/\/ No callback: click the real button, as a visitor would.\n        $client-&gt;submit($form);\n    }\n    \/\/ Wait for something only the next page has; getCrawler() can lag.\n    $client-&gt;waitFor('#welcome', 15);\n    echo 'Submitted: ', $client-&gt;getTitle(), PHP_EOL;\n} catch (ValidationException $e) {\n    echo 'Not sent: ', $e-&gt;getMessage(), PHP_EOL;\n} catch (CapSkipError $e) {\n    echo 'Solve failed: ', get_class($e), ': ', $e-&gt;getMessage(), PHP_EOL;\n} finally {\n    $client-&gt;quit();\n}<\/pre>\n<\/div>\n<p>Change the button label, the email field and the selector you wait for to match your page. For an invisible widget, add the options array from Step 2 to the recaptcha call. On a callback page with no button at all, Steps 1, 2 and 5 are the whole job. If waitFor gives up after the click, look at the form before blaming the token: a required field left empty stops the submit with no error at all. For more on the SDK side, including the invisible and Enterprise options, the guide to <a href=\"https:\/\/capskip.com\/recaptcha-v2-php\/\">solving reCAPTCHA v2 in PHP<\/a> covers the recaptcha call without a browser.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Running Panther and CapSkip on different machines<\/h2>\n<p>Panther suites often run on a CI runner or a Linux box, while CapSkip is a Windows application. That works fine. Switch CapSkip to Server mode and it listens on your network address or public IP, so Panther on any machine can call it over the same API. Use a static public IP when the route crosses the internet, turn on API key validation, and limit the port to the addresses you expect with a Windows Firewall rule. The solver is still your own Windows machine, and solves are still unmetered.<\/p>\n<p>The PHP SDK does not read environment variables by itself. Read CAPSKIP_HOST, CAPSKIP_PORT and CAPSKIP_API_KEY in your code and pass them to the constructor, as the full example does, so the same script runs at your desk and in CI. When the host is wrong or CapSkip is not running, the first call fails with a NetworkException that says it failed to connect. That takes about two seconds when nothing listens on the port, and up to 30 seconds, the SDK&#8217;s connect timeout, when a firewall drops the connection.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Common errors and what they mean<\/h2>\n<table>\n<thead>\n<tr>\n<th>What you see<\/th>\n<th>Cause<\/th>\n<th>Fix<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>ElementNotInteractableException: element not interactable<\/td>\n<td>Panther tried to type the token into the hidden g-recaptcha-response textarea<\/td>\n<td>Write the value with executeScript, as in Step 3<\/td>\n<\/tr>\n<tr>\n<td>Could not start chrome, with a cmd.exe message that a command is not recognized<\/td>\n<td>Windows: Panther found chromedriver in the drivers folder and could not start it from the relative path<\/td>\n<td>Pass the driver&#8217;s full path to createChromeClient, or add the drivers folder to PATH<\/td>\n<\/tr>\n<tr>\n<td>Could not start chrome, with a message that the system cannot find the path<\/td>\n<td>There is no chromedriver.exe at the path passed to createChromeClient<\/td>\n<td>Run bdi detect drivers in the project folder, or correct the path<\/td>\n<\/tr>\n<tr>\n<td>SessionNotCreatedException: This version of ChromeDriver only supports Chrome version N<\/td>\n<td>Chrome updated itself and the driver did not<\/td>\n<td>Run bdi detect drivers again<\/td>\n<\/tr>\n<tr>\n<td>RuntimeException: The port 9515 is already in use.<\/td>\n<td>Another Panther client or script already holds chromedriver&#8217;s default port<\/td>\n<td>Give each client its own port in the options array of createChromeClient<\/td>\n<\/tr>\n<tr>\n<td>The token is written and nothing happens<\/td>\n<td>The page submits from a reCAPTCHA callback<\/td>\n<td>Run the Step 5 script, which writes the field and calls the callback<\/td>\n<\/tr>\n<tr>\n<td>waitFor times out after the click and nothing was posted<\/td>\n<td>A required field was empty, or a value failed the field&#8217;s validation<\/td>\n<td>Fill every required field with a valid value before you submit<\/td>\n<\/tr>\n<tr>\n<td>NetworkException: Failed to connect to 127.0.0.1 port 8080<\/td>\n<td>CapSkip is not running, or the host and port are wrong<\/td>\n<td>Start CapSkip, then check whether it should be in Local mode or Server mode<\/td>\n<\/tr>\n<tr>\n<td>Maximum execution time exceeded during a solve<\/td>\n<td>Panther runs inside a web request, and on Windows that limit counts time spent waiting<\/td>\n<td>Run Panther from the command line, where the limit is off, or from a queue worker<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">FAQ<\/h2>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Does headless Chrome make the CAPTCHA harder to pass?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Not for this approach. The challenge is solved in CapSkip&#8217;s own browser, not in the Chrome that Panther drives, so Panther&#8217;s browser never has to pass it. Panther only carries the finished token into the form. To watch the run, set PANTHER_NO_HEADLESS to 1 in the environment before you start PHP. Panther reads it from $_SERVER, so a putenv() call inside the script has no effect.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Should my own app&#8217;s Panther tests use a solver?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Usually not. For an app you control, configure the test environment with Google&#8217;s published <a href=\"https:\/\/developers.google.com\/recaptcha\/docs\/faq\" rel=\"nofollow noopener\" target=\"_blank\">reCAPTCHA test keys<\/a>, which always pass verification, so the tests need no solving at all. A solver earns its place when Panther drives pages you do not control, or a staging site that has to keep its real keys.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can I run several Panther browsers at once?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes, one per process or several in one, as long as each client gets its own chromedriver port. Every client starts its own chromedriver on port 9515 unless you pass a different port in the options of createChromeClient, and the second one on the same port fails on its first request. The PHP SDK is synchronous, so each process solves one CAPTCHA at a time, and CapSkip itself runs 10 reCAPTCHA solves at once by default.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Does the same approach work for Cloudflare Turnstile?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">The shape is the same. Turnstile puts its token in a hidden input named cf-turnstile-response. Panther cannot fill it either: the Form object reports an invalid element state, sendKeys reports that the element is not interactable, and executeScript sets it. Solve it with the SDK&#8217;s turnstile method instead of recaptcha, and read the sitekey from the data-sitekey attribute of the cf-turnstile div. The Step 5 script handles reCAPTCHA callbacks only.<\/p>\n<\/details>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The short version<\/h2>\n<p>For a Symfony Panther CAPTCHA, pass the driver&#8217;s full path to createChromeClient on Windows, load the page and wait for the g-recaptcha-response textarea, then read the sitekey from data-sitekey or the widget iframe. Solve with the PHP SDK using Panther&#8217;s current URL. Write the token with executeScript, never with the Form object, then click the real button, or call the callback on pages that have one. Wait for the next page before reading it, and switch CapSkip to Server mode when Panther runs elsewhere.<\/p>\n<ul>\n<li>Every CAPTCHA type the PHP package handles: <a href=\"https:\/\/capskip.com\/php-captcha-solver\/\">the PHP CAPTCHA solver page<\/a>.<\/li>\n<li>How checkbox and invisible reCAPTCHA v2 are solved: <a href=\"https:\/\/capskip.com\/recaptcha-v2-solver\/\">the reCAPTCHA v2 solver page<\/a>.<\/li>\n<\/ul>\n<p>A Panther scraper that meets a widget on every page pays nothing extra when the <a href=\"https:\/\/capskip.com\/\">captcha bypass<\/a> runs on your own Windows machine. There is no reason to skip a page because a challenge sits on it.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Panther \u65e0\u6cd5\u628a\u5185\u5bb9\u8f93\u5165 reCAPTCHA \u9690\u85cf\u7684 g-recaptcha-response \u5b57\u6bb5\uff0c\u800c\u4e14\u5728 Windows \u4e0a\uff0c\u5b83\u81ea\u5df1\u627e\u5230\u7684 chromedriver \u4e5f\u542f\u52a8\u4e0d\u4e86\u3002\u672c\u6587\u7ed9\u51fa\u5b8c\u6574\u7684 PHP \u4ee3\u7801\uff1a\u7528 CapSkip \u8bc6\u522b\u5c0f\u7ec4\u4ef6\uff0c\u7528 executeScript \u5199\u5165 token \u5e76\u63d0\u4ea4\uff0c\u5e26\u56de\u8c03\u7684\u9875\u9762\u4e5f\u4e00\u5e76\u6db5\u76d6\u3002<\/p>","protected":false},"author":1,"featured_media":26200,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Symfony Panther CAPTCHA: Solve reCAPTCHA in PHP | CapSkip","rank_math_description":"A symfony panther captcha step fails on sendKeys because the token field is hidden. Solve it with CapSkip, write it with executeScript, then submit.","rank_math_focus_keyword":"symfony panther captcha","footnotes":""},"categories":[70],"tags":[],"class_list":["post-26201","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-captcha"],"_links":{"self":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/26201","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/comments?post=26201"}],"version-history":[{"count":2,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/26201\/revisions"}],"predecessor-version":[{"id":26205,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/26201\/revisions\/26205"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media\/26200"}],"wp:attachment":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media?parent=26201"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/categories?post=26201"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/tags?post=26201"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}