So lösen Sie reCAPTCHA in Symfony Panther (PHP SDK)

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

Um ein Symfony-Panther-Captcha zu lösen, lesen Sie den sitekey von der Seite, die Panther geöffnet hat, und übergeben ihn zusammen mit der aktuellen URL an die Methode recaptcha des CapSkip PHP SDK. Schreiben Sie den Token dann mit executeScript in die versteckte Textarea g-recaptcha-response und senden Sie ab, indem Sie auf den eigenen Button des Formulars klicken. Am Schreiben scheitert Panther-Code meistens. reCAPTCHA hält diese Textarea auf display:none, daher wirft jede Hilfsfunktion von Panther, die sie füllen würde (das Form-Objekt genauso wie sendKeys), eine ElementNotInteractableException. Seiten, die über einen reCAPTCHA-Callback absenden, brauchen einen weiteren Schritt, denn sie ignorieren das Feld, bis der Callback läuft. Dieser Leitfaden behandelt die Einrichtung unter Windows, das Lösen, das Schreiben, beide Arten des Absendens und die Wartebedingungen, die den Durchlauf zuverlässig machen.

Was Sie brauchen

  • CapSkip auf einem Windows-Rechner. Im Local-Modus antwortet es auf 127.0.0.1 nur für dieses Gerät; im Server-Modus lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP, damit Panther es von einem anderen Rechner aus über die API aufrufen kann. Eingestellt werden beide Modi unter Verbindungseinstellungen.
  • PHP 8.1 oder neuer, mit aktivierten Erweiterungen curl und zip in der php.ini. Panther 2.4 braucht 8.1, Composer installiert php-webdriver nicht ohne zip, und die Windows-Builds von PHP liefern beide Erweiterungen deaktiviert aus.
  • Chrome und ein chromedriver mit derselben Hauptversion. Das Tool bdi unten lädt den passenden herunter.
  • Eine Seite mit einem reCAPTCHA-v2-Widget, als Checkbox oder Invisible.
# 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

Die letzte Zeile ist die Windows-Form des Befehls; unter Linux oder macOS lautet er vendor/bin/bdi. Er speichert chromedriver.exe in einem Ordner drivers in Ihrem Projekt. Panther sucht von selbst in diesem Ordner, aber unter Windows ist diese Suche eine Falle. Panther findet die Datei und kann sie dann über den relativen Pfad nicht starten. Der Fehler lautet "Could not start chrome", gefolgt von einer Meldung von cmd.exe, dass ein Befehl nicht erkannt wird. Übergeben Sie den vollständigen Pfad des Drivers an createChromeClient, wie es jedes Beispiel hier tut, oder nehmen Sie den Ordner drivers in den PATH auf.

Schritt 1: Die Seite öffnen und den sitekey auslesen

Panther startet über chromedriver ein Headless-Chrome und lädt die Seite. Das api.js von reCAPTCHA baut dann das Widget auf und fügt eine Textarea mit der id g-recaptcha-response hinzu. Auf dieses Element zu warten, ist also das zuverlässige Zeichen dafür, dass das Widget bereit ist. Warten Sie nicht darauf, dass es sichtbar wird: Das passiert nie, und waitForVisibility auf dieses Element läuft in ein Timeout.

<?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');

Die meisten Seiten deklarieren das Widget mit einem div, das ein Attribut data-sitekey trägt. Eine Seite, die es mit grecaptcha.render aus ihrem eigenen Skript aufbaut, hat kein solches Attribut, aber der Schlüssel steht trotzdem auf der Seite, und zwar in der Adresse des Widget-iframes. Verwenden Sie auf solchen Seiten diesen Block anstelle der letzten Zeile aus Schritt 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'] ?? '';

Schritt 2: Mit dem PHP SDK lösen

Das CapSkip PHP SDK braucht den sitekey und die URL der Seite, auf der das Widget läuft. Nehmen Sie die URL von Panther und nicht aus Ihrer eigenen Variable: Nach einer Weiterleitung ist die Seite, die Panther geöffnet hat, genau die, für die der Token ausgestellt werden muss.

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

Ein Invisible-Widget gibt es in zwei Formen: ein div mit einem Attribut data-size mit dem Wert invisible oder ein Attribut data-sitekey am Absende-Button selbst. Übergeben Sie in beiden Fällen als drittes Argument ein Options-Array, in dem invisible auf 1 gesetzt ist. Ein Testlauf mit dem öffentlichen Test-sitekey von Google über ein echtes CapSkip dauerte etwa 13 Sekunden und lieferte einen Token mit 1.678 Zeichen.

Das PHP SDK ist synchron, das Skript blockiert also, bis der Token zurückkommt, und das ist in Ordnung. Der Browser von Panther wartet einfach auf der Seite, denn die Timeouts von php-webdriver gelten für jeden einzelnen Befehl, nicht für die Pausen dazwischen. Eine Sitzung, die über einen Lösevorgang von 103 Sekunden offen gehalten wurde, hat den Token danach trotzdem ganz normal eingefügt und das Formular abgesendet. Das SDK pollt bis zu seinem recaptchaTimeout, standardmäßig 300 Sekunden, und ein Schlüssel timeout im Options-Array ändert das für einen einzelnen Aufruf. Was dagegen abläuft, ist der Token. Ein reCAPTCHA-Token ist nach seiner Ausstellung etwa zwei Minuten gültig, schreiben Sie ihn also sofort ins Feld und senden Sie ab.

Schritt 3: Den Token mit executeScript schreiben

Dieser Schritt entscheidet darüber, ob ein Captcha-Durchlauf in Symfony Panther funktioniert. Naheliegend ist es, das Feld über das Form-Objekt von Panther zu setzen, und genau das scheitert. Panther füllt Felder so, wie ein Nutzer es tun würde: Es leert sie und tippt über WebDriver hinein, und WebDriver weigert sich, in ein Element zu tippen, das niemand sehen kann. Ob Sie das Feld auf dem Form setzen, setValues aufrufen, es an submit übergeben oder sendKeys auf dem Element aufrufen, alles endet in derselben ElementNotInteractableException. Setzen Sie den Wert stattdessen in der Seite:

// Throws ElementNotInteractableException: the textarea is display:none.
// $form['g-recaptcha-response'] = $token;

$client->executeScript(
    'document.getElementById("g-recaptcha-response").value = arguments[0];',
    [$token]
);

executeScript übergibt jedes Element des PHP-Arrays als arguments[0], arguments[1] und so weiter an das Skript, der Token muss also nie maskiert in den JavaScript-String eingebaut werden. Sichtbare Felder wie eine E-Mail-Adresse füllen Sie weiterhin über die normale Formular-API von Panther.

Schritt 4: Mit einem Klick auf den echten Button absenden

Wie Sie absenden, verändert, was die Seite sieht. Bauen Sie das Formular aus seinem Button auf, dann sendet Panther es mit einem Klick auf diesen Button ab, genau wie ein Besucher:

// 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;

Die anderen Wege des Absendens lassen etwas aus, und das zählt auf Seiten, deren eigenes Skript beim Absenden ein Feld hinzufügt oder das Formular prüft:

Wie das Formular gesendet wirdSubmit-Handler der Seite laufenHTML5-ValidierungName und Wert des Buttons werden gesendet
Panther-submit auf einem aus seinem Button gebauten FormularJaJaJa
Panther-submit auf einem aus dem form-Element gebauten FormularJaNeinNein
form.submit(), ausgeführt über executeScriptNeinNeinNein

Die Validierung ist ein zweischneidiges Schwert. Ein leer gelassenes Pflichtfeld oder ein Wert, den ein E-Mail-Feld nicht akzeptiert, stoppt den Klick ohne jeden Fehler in PHP, und es wird nichts gesendet. Das ist ein Grund, warum der Block oben auf ein Element wartet, das erst die nächste Seite hat. Der andere: Der Crawler von Panther kann nach dem Absenden noch einen Moment lang die alte Seite beschreiben, sodass Code, der sofort getCrawler() aufruft, womöglich wieder das Formular sieht.

Schritt 5: Seiten, die über einen Callback absenden

Manche Websites haben überhaupt kein normales Absenden. Das Widget trägt ein Attribut data-callback, oder die Seite übergibt einen Callback an grecaptcha.render, und diese Funktion sendet das Formular ab, sobald reCAPTCHA ihr einen Token übergibt. Invisible-Widgets funktionieren fast immer so. Auf diesen Seiten bewirkt ein Token in der Textarea nichts, bis der Callback läuft, rufen Sie ihn also selbst auf. Schreiben Sie zuerst die Textarea, denn der Callback sendet meist das Formular ab, und der Server liest den Token aus diesem Feld. Das Skript unten erledigt beides und ruft auf einer Seite mit mehreren Widgets den ersten Callback auf, den es findet:

// 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 speichert die Einstellungen jedes Widgets in einem globalen Objekt namens ___grecaptcha_cfg. Ein Attribut data-callback landet dort als Name der Funktion, ein an grecaptcha.render übergebener Callback als die Funktion selbst, und das Skript deckt beide Fälle ab. Das Objekt ist nicht dokumentiert, und seine inneren Schlüsselnamen sind minifiziert. Deshalb sucht das Skript nach den Einstellungen, statt einem festen Pfad zu folgen. Sollte Google es einmal umbauen, gibt das Skript keinen Callback zurück, und auf einer Seite mit data-callback können Sie dann immer noch das Attribut lesen und die Funktion unter diesem Namen aufrufen. Den Callback aufzurufen, ohne vorher die Textarea zu schreiben, ist der eine Fehler, den Sie vermeiden müssen: Das Formular geht dann mit einem leeren g-recaptcha-response hinaus.

Vollständiges lauffähiges Beispiel

Hier ist der gesamte Captcha-Durchlauf in Symfony Panther in einem Skript. Es liest den Schlüssel von beiden Arten von Seiten aus, löst, schreibt den Token und lässt den Callback absenden, wenn es einen gibt, oder klickt auf den Button, wenn nicht.

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

Passen Sie die Beschriftung des Buttons, das E-Mail-Feld und den Selektor, auf den Sie warten, an Ihre Seite an. Fügen Sie bei einem Invisible-Widget das Options-Array aus Schritt 2 zum Aufruf von recaptcha hinzu. Auf einer Callback-Seite ganz ohne Button sind die Schritte 1, 2 und 5 schon die ganze Arbeit. Gibt waitFor nach dem Klick auf, sehen Sie sich das Formular an, bevor Sie dem Token die Schuld geben: Ein leer gelassenes Pflichtfeld stoppt das Absenden ganz ohne Fehlermeldung. Mehr zur SDK-Seite, einschließlich der Optionen invisible und Enterprise, finden Sie im Leitfaden dazu, wie Sie reCAPTCHA v2 in PHP lösen können. Er behandelt den Aufruf von recaptcha ohne Browser.

Panther und CapSkip auf verschiedenen Rechnern betreiben

Panther-Testsuites laufen oft auf einem CI-Runner oder einem Linux-Rechner, während CapSkip eine Windows-Anwendung ist. Das funktioniert problemlos. Schalten Sie CapSkip in den Server-Modus, dann lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP, sodass Panther es von jedem beliebigen Rechner aus über dieselbe API aufrufen kann. Verwenden Sie eine statische öffentliche IP, wenn der Weg über das Internet führt, aktivieren Sie die Validierung des API-Schlüssels und beschränken Sie den Port mit einer Regel in der Windows Firewall auf die Adressen, die Sie erwarten. Der Solver bleibt Ihr eigener Windows-Rechner, und abgerechnet wird pro Lösung weiterhin nichts.

Das PHP SDK liest Umgebungsvariablen nicht von selbst. Lesen Sie CAPSKIP_HOST, CAPSKIP_PORT und CAPSKIP_API_KEY in Ihrem Code aus und übergeben Sie die Werte an den Konstruktor, wie es das vollständige Beispiel tut. So läuft dasselbe Skript an Ihrem Schreibtisch und in der CI. Ist der Host falsch oder läuft CapSkip nicht, scheitert der erste Aufruf mit einer NetworkException, die meldet, dass keine Verbindung hergestellt werden konnte. Das dauert etwa zwei Sekunden, wenn auf dem Port nichts lauscht, und bis zu 30 Sekunden (das Verbindungs-Timeout des SDK), wenn eine Firewall die Verbindung verwirft.

Häufige Fehler und was sie bedeuten

Was Sie sehenUrsacheBeheben
ElementNotInteractableException: element not interactablePanther hat versucht, den Token in die versteckte Textarea g-recaptcha-response zu tippenSchreiben Sie den Wert mit executeScript, wie in Schritt 3
Could not start chrome, mit einer Meldung von cmd.exe, dass ein Befehl nicht erkannt wirdWindows: Panther hat chromedriver im Ordner drivers gefunden und konnte ihn über den relativen Pfad nicht startenÜbergeben Sie den vollständigen Pfad des Drivers an createChromeClient, oder nehmen Sie den Ordner drivers in den PATH auf
Could not start chrome, mit einer Meldung, dass das System den Pfad nicht finden kannUnter dem an createChromeClient übergebenen Pfad liegt keine chromedriver.exeFühren Sie bdi detect drivers im Projektordner aus, oder korrigieren Sie den Pfad
SessionNotCreatedException: This version of ChromeDriver only supports Chrome version NChrome hat sich selbst aktualisiert, der Driver aber nichtFühren Sie bdi detect drivers erneut aus
RuntimeException: The port 9515 is already in use.Ein anderer Panther-Client oder ein anderes Skript belegt bereits den Standardport von chromedriverGeben Sie jedem Client im Options-Array von createChromeClient einen eigenen Port
Der Token ist geschrieben, und nichts passiertDie Seite sendet über einen reCAPTCHA-Callback abFühren Sie das Skript aus Schritt 5 aus, das das Feld schreibt und den Callback aufruft
waitFor läuft nach dem Klick in ein Timeout, und es wurde nichts gesendetEin Pflichtfeld war leer, oder ein Wert hat die Validierung des Feldes nicht bestandenFüllen Sie vor dem Absenden jedes Pflichtfeld mit einem gültigen Wert
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip läuft nicht, oder Host und Port sind falschStarten Sie CapSkip und prüfen Sie dann, ob es im Local-Modus oder im Server-Modus laufen soll
Maximum execution time exceeded während eines LösevorgangsPanther läuft innerhalb einer Web-Anfrage, und unter Windows zählt dieses Limit auch die Wartezeit mitFühren Sie Panther über die Kommandozeile aus, wo das Limit abgeschaltet ist, oder aus einem Queue-Worker

FAQ

Erschwert Headless-Chrome das Bestehen des Captchas?

Bei diesem Ansatz nicht. Die Challenge wird im eigenen Browser von CapSkip gelöst, nicht in dem Chrome, das Panther steuert, der Browser von Panther muss sie also nie bestehen. Panther trägt nur den fertigen Token in das Formular ein. Um den Durchlauf zu beobachten, setzen Sie PANTHER_NO_HEADLESS in der Umgebung auf 1, bevor Sie PHP starten. Panther liest den Wert aus $_SERVER, ein Aufruf von putenv() im Skript hat daher keine Wirkung.

Sollten die Panther-Tests meiner eigenen App einen Solver verwenden?

Meistens nicht. Für eine App, die Sie selbst kontrollieren, konfigurieren Sie die Testumgebung mit den von Google veröffentlichten reCAPTCHA-Testschlüsseln, die die Verifizierung immer bestehen, sodass die Tests überhaupt nichts lösen müssen. Ein Solver lohnt sich, wenn Panther Seiten steuert, die Sie nicht kontrollieren, oder eine Staging-Website, die ihre echten Schlüssel behalten muss.

Kann ich mehrere Panther-Browser gleichzeitig betreiben?

Ja, einen pro Prozess oder mehrere in einem, solange jeder Client seinen eigenen chromedriver-Port bekommt. Jeder Client startet seinen eigenen chromedriver auf Port 9515, sofern Sie in den Optionen von createChromeClient keinen anderen Port übergeben, und der zweite auf demselben Port scheitert bei seiner ersten Anfrage. Das PHP SDK ist synchron, jeder Prozess löst also ein Captcha nach dem anderen, und CapSkip selbst führt standardmäßig 10 reCAPTCHA-Lösevorgänge gleichzeitig aus.

Funktioniert derselbe Ansatz auch für Cloudflare Turnstile?

Das Muster ist dasselbe. Turnstile legt seinen Token in ein verstecktes Input namens cf-turnstile-response. Auch dieses Feld kann Panther nicht füllen: Das Form-Objekt meldet einen ungültigen Elementzustand, sendKeys meldet, dass das Element nicht interaktiv ist, und executeScript setzt den Wert. Lösen Sie es mit der Methode turnstile des SDK statt mit recaptcha, und lesen Sie den sitekey aus dem Attribut data-sitekey des div mit der Klasse cf-turnstile. Das Skript aus Schritt 5 behandelt nur reCAPTCHA-Callbacks.

Die Kurzfassung

Bei einem Symfony-Panther-Captcha übergeben Sie unter Windows den vollständigen Pfad des Drivers an createChromeClient, laden die Seite, warten auf die Textarea g-recaptcha-response und lesen dann den sitekey aus data-sitekey oder aus dem iframe des Widgets. Lösen Sie mit dem PHP SDK und der aktuellen URL von Panther. Schreiben Sie den Token mit executeScript, nie mit dem Form-Objekt, und klicken Sie dann auf den echten Button oder rufen Sie auf Seiten, die einen haben, den Callback auf. Warten Sie auf die nächste Seite, bevor Sie sie auslesen, und schalten Sie CapSkip in den Server-Modus, wenn Panther anderswo läuft.

Ein Panther-Scraper, der auf jeder Seite auf ein Widget trifft, zahlt nichts extra, wenn die Captcha-Umgehung auf Ihrem eigenen Windows-Rechner läuft. Es gibt keinen Grund, eine Seite zu überspringen, nur weil eine Challenge darauf sitzt.