So lösen Sie Friendly Captcha in PHP mit cURL und DOMXPath

solve friendly captcha in php - How to Solve Friendly Captcha in PHP With cURL and DOMXPath

Um Friendly Captcha in PHP zu lösen, rufen Sie die Seite mit einem einzigen cURL-Handle ab, bei dem die Cookie-Engine eingeschaltet ist. Lesen Sie das Widget frc-captcha und seine Script-Tags mit DOMXPath aus und bestimmen Sie anhand des Skripts v1 oder v2. Übergeben Sie dann sitekey, Seiten-URL, Version und Skriptadresse an die Methode friendlyCaptcha des Pakets capskip/capskip. Senden Sie den zurückgegebenen Token über dasselbe Handle ab, neben den versteckten Feldern des Formulars, bei v2 in frc-captcha-response und bei v1 in frc-captcha-solution. Zwei PHP-Gewohnheiten machen das zunichte. Ein neues curl_init für jede Anfrage wirft das Session-Cookie weg, daher lehnt ein CSRF-geschütztes Formular selbst einen einwandfreien Token ab. Und der Aufruf blockiert, bis das Lösen beendet ist, sodass fünfzig Formulare nacheinander lange dauern, wenn Sie ihre Lösevorgänge nicht gemeinsam einreichen. CapSkip hat Friendly Captcha in 1.4.0 hinzugefügt, und dieser Leitfaden erledigt den ganzen Ablauf ohne Browser auf Ihrer Seite.

Was Sie brauchen

  • CapSkip 1.4.0 oder neuer auf einem Windows-Rechner. Mit diesem Release kam Friendly Captcha dazu, zusammen mit CaptchaFox und Capy Puzzle.
  • PHP 8.0 oder neuer mit den Erweiterungen curl und dom sowie Version 1.3.0 oder neuer von capskip/capskip, das erste Release mit friendlyCaptcha. Wenn Composer das Paket ablehnt, weil ext-curl fehlt, entfernen Sie in Ihrer php.ini das Kommentarzeichen vor extension=curl.
  • Die URL der Seite, die das Widget anzeigt. Der sitekey, die Skriptadresse und der Feldname stammen alle aus dem HTML dieser Seite.
  • Eine Adresse für den Solver. Der Local-Modus antwortet auf 127.0.0.1 nur für dieses Gerät; der Server-Modus lauscht auf Ihrer Netzwerkadresse oder öffentlichen IP, damit eine PHP-App auf einem anderen Rechner ihn über die API aufrufen kann. Beide finden Sie unter Verbindungseinstellungen, und Schritt 4 erklärt, wann Sie wechseln sollten.
# composer require capskip/capskip
composer require capskip/capskip

Führen Sie die Skripte über die Kommandozeile aus. Ein Friendly-Captcha-Lösevorgang kann an einem schlechten Tag Minuten dauern, und eine Webanfrage läuft selten so lange; Schritt 4 erklärt, warum.

Schritt 1: Die Seite mit einem einzigen cURL-Handle abrufen

Die cURL-Erweiterung von PHP speichert keine Cookies, solange Sie es nicht ausdrücklich verlangen. Ein Handle beginnt ohne Cookie-Speicher, und ein frisches Handle pro Anfrage beginnt mit gar nichts, das Session-Cookie, das die Seite setzt, ist also weg, wenn Sie das Formular senden. Eine Website, die ihren CSRF-Token an diese Session bindet, lehnt das Absenden dann ab, oft mit einem 403, egal wie gut der Captcha-Token ist.

Abhilfe schafft eine einzige Option. Setzen Sie CURLOPT_COOKIEFILE auf einen leeren String, schaltet das für dieses Handle die speicherinterne Cookie-Engine von cURL ein, ohne eine Datei zu lesen oder zu schreiben, wie es die Referenz zu curl_setopt beschreibt. Behalten Sie das Handle und verwenden Sie es für das Absenden wieder, dann kommt jedes Cookie mit, das die Website gesetzt hat, auch eines, das bei einer Weiterleitung gesetzt wurde.

<?php
// composer require capskip/capskip
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

const PAGE_URL = 'https://example.com/signup';

// One handle for the whole run. An empty COOKIEFILE switches on the
// in-memory cookie engine, so the session survives until the post.
$http = curl_init();
curl_setopt_array($http, [
    CURLOPT_URL => PAGE_URL,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_COOKIEFILE => '',
    // PHP's cURL sends no User-Agent at all unless you set one.
    CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
]);
$html = curl_exec($http);
if ($html === false) {
    throw new RuntimeException('fetch failed: ' . curl_error($http));
}
// The address the page finally came from, after any redirects.
$pageUrl = curl_getinfo($http, CURLINFO_EFFECTIVE_URL);

// Without a meta charset, loadHTML reads UTF-8 as Latin-1.
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $html);
$xp = new DOMXPath($doc);

Drei Zeilen darin ersparen Ihnen später das Debuggen. Die cURL-Erweiterung von PHP sendet keinen User-Agent-Header, solange Sie keinen setzen, und manche Websites weisen eine Anfrage ohne diesen Header ab. CURLINFO_EFFECTIVE_URL liefert die Adresse, von der die Seite letztlich kam, und das ist die richtige URL für den Solver und für den Referer, wenn die erste URL weiterleitet. Und das Encoding-Präfix verhindert, dass loadHTML die Seite als Latin-1 liest, wenn sie keinen Zeichensatz angibt, was einen versteckten Wert mit Akzenten auf dem Rückweg in Zeichensalat verwandeln würde.

Schritt 2: Das Widget auslesen, v1 oder v2 wählen und friendlyCaptcha aufrufen

Beide Versionen rendern dasselbe Element, ein div mit der Klasse frc-captcha und einem Attribut data-sitekey, das Widget allein verrät also nichts über das Protokoll. Das Script-Tag schon. Eine v2-Website lädt das Paket @friendlycaptcha/sdk mit der Datei site.min.js, eine v1-Website lädt friendly-challenge mit der Datei widget.module.min.js. DOMXPath findet das Widget, das Formular darum herum und jedes Skript:

$widget = $xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " frc-captcha ")]')->item(0);
if (!$widget instanceof DOMElement || $widget->getAttribute('data-sitekey') === '') {
    throw new RuntimeException('No frc-captcha widget in the HTML; it may be built by JavaScript.');
}
$form = $xp->query('ancestor::form[1]', $widget)->item(0);

// Every script except nomodule fallbacks, as full addresses.
$scripts = [];
foreach ($xp->query('//script[@src][not(@nomodule)]') as $tag) {
    $scripts[] = absoluteUrl($tag->getAttribute('src'), $pageUrl);
}

Vollständige Adressen sind bei selbst gehosteten Widgets wichtig. Bei v2 lädt CapSkip genau dieses Skript in seinem eigenen Browser, um das Lösen auszuführen, und ein nackter Pfad wie /vendor/v2/site.min.js lädt nie. Die Funktion parse_url von PHP zerlegt eine URL in ihre Teile, kann eine relative URL aber nicht gegen eine Seite auflösen. Hier ist deshalb ein kleiner Resolver, der absolute, protokollrelative und wurzelrelative Pfade sowie Pfade mit Punktsegmenten verarbeitet, dazu Formular-Actions auf dieselbe Seite wie # oder ?step=2:

// Resolve a script src against the page URL, as a browser would.
function absoluteUrl(string $src, string $base): string
{
    if (preg_match('~^[a-z][a-z0-9+.-]*:~i', $src)) {
        return $src;                                   // already absolute
    }
    if (str_starts_with($src, '#')) {
        return strtok($base, '#');                     // same page
    }
    if (str_starts_with($src, '?')) {
        return strtok($base, '?#') . $src;             // same path, new query
    }
    $b = parse_url($base);
    if (str_starts_with($src, '//')) {
        return $b['scheme'] . ':' . $src;              // protocol-relative
    }
    $origin = $b['scheme'] . '://' . $b['host'] . (isset($b['port']) ? ':' . $b['port'] : '');
    $path = str_starts_with($src, '/') ? $src : preg_replace('~[^/]*$~', '', $b['path'] ?? '/') . $src;
    $out = [];
    foreach (explode('/', $path) as $seg) {
        if ($seg === '..') {
            if (count($out) > 1) {
                array_pop($out);
            }
        } elseif ($seg !== '.') {
            $out[] = $seg;
        }
    }
    return $origin . implode('/', $out);
}

CapSkip wählt die Version in einer festen Reihenfolge und hört bei der ersten Antwort auf: zuerst die Version, die Sie übergeben, dann die Skriptadresse, die Sie als module_script übergeben, dann der Standardwert v1. Der Standardwert ist die Falle. Ein gebündeltes Skript wie /assets/app.4f2a.js verrät CapSkip nichts, also löst es v1, und eine v2-Website lehnt jeden Token ab. Entscheiden Sie stattdessen in Ihrem eigenen Code, wo Sie sich weigern können zu raten:

// Returns [version, script src or null], or throws if the page
// does not say which protocol it uses.
function friendlyVersion(array $scripts, DOMElement $widget): array
{
    // Package names are unambiguous, so check them first.
    foreach ($scripts as $src) {
        if (str_contains($src, '@friendlycaptcha/sdk')) return ['v2', $src];
        if (str_contains($src, 'friendly-challenge')) return ['v1', $src];
    }
    // Self-hosted builds usually keep the file name. Themes ship their
    // own site.min.js too, so only trust a path that says friendly.
    foreach ($scripts as $src) {
        if (!str_contains(strtolower($src), 'friendly')) continue;
        $file = basename((string) parse_url($src, PHP_URL_PATH));
        if (in_array($file, ['site.min.js', 'site.compat.min.js'], true)) return ['v2', $src];
        if (in_array($file, ['widget.module.min.js', 'widget.min.js', 'widget.polyfilled.min.js'], true)) return ['v1', $src];
    }
    // Last resort: v2 and v1 name their widget options differently.
    if ($widget->hasAttribute('data-api-endpoint') || $widget->hasAttribute('data-form-field-name')) return ['v2', null];
    if ($widget->hasAttribute('data-puzzle-endpoint') || $widget->hasAttribute('data-solution-field-name')) return ['v1', null];
    throw new RuntimeException('v1 or v2? Read the page and set it by hand.');
}

Dann folgt der Aufruf. Die Methode friendlyCaptcha nimmt den sitekey, die Seiten-URL und ein Options-Array entgegen und verwirft jede Option mit dem Wert null, bevor sie etwas sendet:

use CapSkip\CapSkip;

$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);
[$version, $script] = friendlyVersion($scripts, $widget);
// EU sites: data-api-endpoint="eu" (v2) or data-puzzle-endpoint (v1)
$endpoint = $widget->getAttribute('data-api-endpoint') ?: $widget->getAttribute('data-puzzle-endpoint');

$result = $solver->friendlyCaptcha($widget->getAttribute('data-sitekey'), $pageUrl, [
    'version' => $version,
    'module_script' => $script,
    'api_server' => $endpoint ?: null,
]);

echo substr($result['token'], 0, 40), PHP_EOL;   // v2 tokens start with AQQA.

Beachten Sie das ?: null beim Endpunkt. getAttribute von DOMElement gibt für ein Attribut, das die Seite nicht hat, einen leeren String zurück, niemals null, und das SDK verwirft nur null-Werte, ohne diesen Ausdruck geht also ein leeres api_server mit der Anfrage hinaus. Diese Option ist für Websites am EU-Endpunkt von Friendly Captcha gedacht. Der globale Endpunkt stellt auch für einen EU-sitekey einen Token aus, ein Lösen dort scheitert also erst bei der Prüfung durch die Website selbst, dasselbe stille Scheitern wie bei der falschen Version. Der Eintrag token im Ergebnis ist die Zeichenfolge zum Absenden, der Eintrag code enthält dieselbe Zeichenfolge, und captchaId ist die ID von CapSkip für den Auftrag. Eine Version außer v1, v2, 1 oder 2, ein leerer sitekey oder eine Option, die die Methode nicht kennt, löst eine ValidationException aus, bevor überhaupt eine Anfrage Ihren Rechner verlässt. PHP 8.4 bringt außerdem Dom\HTMLDocument mit, samt querySelector und einem echten HTML5-Parser. Dessen Elemente sind allerdings keine DOMElement-Objekte, und sein getAttribute gibt für ein fehlendes Attribut null zurück, der Code dieses Leitfadens muss also angepasst werden, bevor er damit läuft.

Schritt 3: Den Token mit den eigenen Feldern des Formulars senden

Das Token-Feld steht nicht in dem HTML, das Sie abgerufen haben, denn das Widget-Skript erzeugt es erst im Browser. Sie fügen es also selbst hinzu, unter dem Namen, den Version und Widget verwenden. Alles andere, was das Formular mitbringt, einschließlich eines versteckten CSRF-Tokens, kommt aus dem Formular selbst, und das Absenden läuft über das Handle aus Schritt 1:

// The fields a browser would send: hidden ones, text, checked boxes.
$fields = [];
foreach ($xp->query('.//input[@name]', $form) as $input) {
    $type = strtolower($input->getAttribute('type'));
    if ($input->hasAttribute('disabled') || in_array($type, ['submit', 'button', 'image', 'file', 'reset'], true)) continue;
    $checkable = in_array($type, ['checkbox', 'radio'], true);
    if ($checkable && !$input->hasAttribute('checked')) continue;
    // A checked box with no value attribute submits "on".
    $fields[$input->getAttribute('name')] = $checkable && !$input->hasAttribute('value') ? 'on' : $input->getAttribute('value');
}
$fields['email'] = 'YOUR_EMAIL';

// A renamed field wins; otherwise the default for the version.
$field = $widget->getAttribute('data-form-field-name')
    ?: $widget->getAttribute('data-solution-field-name')
    ?: ($version === 'v2' ? 'frc-captcha-response' : 'frc-captcha-solution');
$fields[$field] = $result['token'];

curl_setopt_array($http, [
    CURLOPT_URL => absoluteUrl($form->getAttribute('action') ?: $pageUrl, $pageUrl),
    CURLOPT_POST => true,
    // A string, not the array: an array makes cURL send multipart.
    CURLOPT_POSTFIELDS => http_build_query($fields),
    // Browsers send the page as Referer; some servers refuse a post without it.
    CURLOPT_REFERER => $pageUrl,
]);
$response = curl_exec($http);
echo curl_getinfo($http, CURLINFO_RESPONSE_CODE), PHP_EOL;

Die Schleife sammelt, was ein Browser für dieses Formular senden würde, und so gelangt der CSRF-Token zurück zum Server, ohne dass Sie ihn benennen müssen. Sie überspringt den Submit-Button, deaktivierte Felder, Auswahlmenüs und Textbereiche. Prüft die Website diese, fügen Sie Button, Menüs und Textbereiche also über ihren Namen hinzu. Übergeben Sie CURLOPT_POSTFIELDS einen String aus http_build_query statt des Arrays selbst: Bekommt cURL ein Array, sendet es multipart/form-data, während ein Browser dieses Formular URL-kodiert sendet. Senden Sie die Seiten-URL außerdem als Referer. Manche Frameworks, darunter Django, lehnen einen HTTPS-Formular-POST ohne Referer oder Origin ab, egal wie korrekt Cookie und Token sind. Ein v2-Token ist etwa sechs Kilobyte groß, er gehört also in den Body und niemals in einen Query-String, während ein v1-Token aus vier durch Punkte getrennten Teilen und einigen hundert Zeichen besteht. Reichen Sie ihn unverändert weiter.

Zwei Fälle erfordern einen Blick in die DevTools Ihres Browsers. Ist bei v1 data-solution-field-name auf einen einzelnen Bindestrich gesetzt, schreibt das Widget überhaupt kein Feld, und das eigene Skript der Website sendet den Token auf anderem Weg. Manche Websites senden außerdem JSON per JavaScript, statt das Formular abzusenden. In beiden Fällen senden Sie einmal von Hand ab und übernehmen die Anfrage, die die Seite tatsächlich stellt.

Jeder Token gilt für genau ein Absenden. Die Verifizierung von Friendly Captcha lehnt eine Antwort ab, die bereits verwendet wurde oder abgelaufen ist, lösen Sie also für jedes Formular neu.

Schritt 4: Viele Formulare aus einem PHP-Prozess und wo der Solver läuft

Die Methode friendlyCaptcha blockiert, bis der Token eintrifft. PHP hat hier keine Event-Loop, und AsyncCapSkip ist im Paket dieselbe Klasse unter anderem Namen, beibehalten, damit sich die vier SDKs gleich lesen. Für ein Formular ist das in Ordnung. Bei fünfzig lässt ein Aufruf nach dem anderen neun der zehn Friendly-Captcha-Threads von CapSkip untätig, während jeder Lösevorgang läuft.

Der manuelle Workflow des SDK behebt das aus einem einzigen Prozess heraus. send() reicht einen Auftrag ein und gibt sofort dessen ID zurück, und getResult() fragt eine ID genau einmal ab und wirft eine NetworkException, solange die Antwort nicht bereit ist. Halten Sie höchstens zehn Aufträge gleichzeitig in Arbeit, denn das ist der Standardwert von Max. Threads für Friendly Captcha in CapSkip, und eine Aufgabe, die darüber hinaus in die Warteschlange kommt, wartet bis zu 250 Sekunden auf einen freien Thread, bevor CapSkip sie als gescheitert abbricht:

use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\NetworkException;
use CapSkip\Exceptions\TimeoutException;

// Keep up to $limit solves in flight and call $done($i, $outcome) as
// each ends: $outcome is the token, or the exception that ended it.
function solveAll(\CapSkip\CapSkip $solver, array $jobs, callable $done, int $limit = 10, int $timeout = 300): void
{
    $running = [];                                      // index => [id, started]
    while ($jobs || $running) {
        while ($jobs && count($running) < $limit) {
            $i = array_key_first($jobs);
            $job = $jobs[$i];
            unset($jobs[$i]);
            try {
                $running[$i] = [$solver->send([
                    'method' => 'friendly_captcha',
                    'sitekey' => $job['sitekey'],
                    'url' => $job['url'],
                    'version' => $job['version'],
                    'module_script' => $job['script'] ?? null,
                    'api_server' => $job['endpoint'] ?? null,
                ]), time()];
            } catch (CapSkipError $e) {
                $done($i, $e);
            }
        }
        sleep(1);
        foreach ($running as $i => [$id, $started]) {
            try {
                $token = $solver->getResult($id, 1)['request'];
            } catch (NetworkException $e) {
                // Not ready yet. Give up only once the deadline passes.
                if (time() - $started < $timeout) continue;
                // An empty message means not ready; anything else is a connection error.
                $token = $e->getMessage() !== '' ? $e : new TimeoutException("no answer in {$timeout} s");
            } catch (ApiException $e) {
                $token = $e;                            // e.g. ERROR_CAPTCHA_UNSOLVABLE
            }
            unset($running[$i]);
            $done($i, $token);
        }
    }
}

// $jobs: one array per page from Step 2 (sitekey, url, version, script,
// endpoint), plus that page's cURL handle and fields for the post.
solveAll($solver, $jobs, function (int $i, $outcome) use ($jobs) {
    if ($outcome instanceof Throwable) {
        echo "form $i failed: ", $outcome->getMessage(), PHP_EOL;
        return;
    }
    // Post straight away, as in Step 3, on $jobs[$i]'s own handle.
});

Geben Sie jeder Seite ihr eigenes cURL-Handle und bewahren Sie es im Auftrag auf, denn der CSRF-Token jedes Formulars gehört zu der Session, in der es abgerufen wurde. Wenn Sie aus dem Callback heraus absenden, geht jeder Token in dem Moment hinaus, in dem er existiert, statt auf den langsamsten Lösevorgang zu warten. Eine NetworkException bedeutet, dass die Antwort noch nicht bereit ist, sie bedeutet aber auch, dass CapSkip nicht erreichbar war. Stoppt der Solver also mitten im Lauf, scheitern noch nicht gesendete Aufträge bei send() mit Failed to connect, und bereits gesendete Aufträge scheitern erst, wenn die Frist abgelaufen ist. Jede Antwort lässt sich nur einmal lesen; danach gibt CapSkip einen leeren Body zurück, den das SDK ebenfalls als nicht bereit meldet, entfernen Sie eine ID also aus der Schleife, sobald sie gelesen wurde.

Die Lösezeiten schwanken. Friendly Captcha legt die Arbeit pro Anfrage fest und erhöht sie für Adressen, die es schon oft gesehen hat, und CapSkip löst jedes v2-Widget in einem echten Browser. Deshalb pollt friendlyCaptcha bis zum recaptchaTimeout, standardmäßig 300 Sekunden, statt nur bis zum defaultTimeout von 120 Sekunden, und deshalb verwendet die Schleife oben denselben Wert. CapSkip hat außerdem eine eigene Uhr: In seinen Friendly-Captcha-Einstellungen bekommt ein Lösevorgang 120 Sekunden Row Timeout, bevor er mit ERROR_CAPTCHA_UNSOLVABLE als gescheitert gilt. Belegen andere Aufträge bereits die zehn Threads von CapSkip, wartet ein Aufruf zusätzlich auf einen freien Thread, das SDK muss also diese Wartezeit plus das Lösen überdauern: mit den Standardwerten von CapSkip bis zu 370 Sekunden. Deshalb hält die Schleife oben höchstens zehn gleichzeitig in Arbeit, und deshalb sollte ein Skript, das sich den Solver mit anderer Arbeit teilt, timeout in den Optionen übergeben, um die Wartezeit des SDK für einen einzelnen Aufruf von friendlyCaptcha zu verlängern. Das Polling-Intervall lässt sich ebenfalls pro Aufruf setzen, aber nur als polling_interval in Snake Case; das pollingInterval in camelCase gehört zum Konstruktor und löst als Option pro Aufruf eine ValidationException aus. Wenn das Lösen über einen langen Durchlauf hinweg langsamer wird, ist meist die steigende Schwierigkeit für eine Adresse die Ursache. Übergeben Sie ein Proxy-Array mit den Schlüsseln type und uri, oder richten Sie in CapSkip einen Proxy-Pool ein.

Dieses Warten ist auch der Grund, warum die Skripte über die Kommandozeile laufen. Die max_execution_time von PHP beträgt für eine Webanfrage standardmäßig 30 Sekunden und ist für die CLI abgeschaltet. Unter Windows zählt sie auch die Wartezeit, ein Lösevorgang innerhalb einer Seitenanfrage endet dort also in einem Fatal Error, den kein catch-Block sieht. Unter Linux zählt sie nur CPU-Zeit, und stattdessen bricht das eigene Timeout des Webservers die Anfrage ab. So oder so: Lösen Sie in einem CLI-Skript oder einem Queue-Worker.

Die Beispiele verwenden 127.0.0.1, weil das richtig ist, wenn PHP und der Solver auf demselben Rechner laufen. Läuft das Skript irgendwo anders, etwa auf einem VPS, auf einem Shared Hosting oder als Laravel-App unter Linux, zeigt Loopback auf genau diesen Rechner. Der erste Aufruf wirft dann eine NetworkException mit "Failed to connect to 127.0.0.1 port 8080". Schalten Sie CapSkip in den Server-Modus, dann lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP, sodass jede dieser Umgebungen es über dieselbe API erreichen kann. Verwenden Sie eine statische öffentliche IP, wenn der Weg über das Internet führt. CapSkip öffnet seinen Port beim Start des Server-Modus für jede Adresse. Um ihn auf die Rechner zu beschränken, die Sie erwarten, fügen Sie in der Windows Firewall eine Blockierregel für alle anderen hinzu. Es bleibt Ihre eigene Hardware, und es bleibt ohne Abrechnung pro Lösung. Der Client liest Umgebungsvariablen nicht von selbst, lesen Sie CAPSKIP_HOST also mit getenv aus und übergeben Sie den Wert, wie es das vollständige Beispiel unten tut.

Vollständiges lauffähiges Beispiel

Hier ist der ganze Ablauf in einer Datei: der kürzeste vollständige Weg, Friendly Captcha in PHP zu lösen, mit nichts als einer Seiten-URL als Ausgangspunkt.

<?php
// composer require capskip/capskip
// solve-friendly.php: run with php solve-friendly.php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\ValidationException;

const PAGE_URL = 'https://example.com/signup';

function absoluteUrl(string $src, string $base): string
{
    if (preg_match('~^[a-z][a-z0-9+.-]*:~i', $src)) {
        return $src;
    }
    if (str_starts_with($src, '#')) {
        return strtok($base, '#');
    }
    if (str_starts_with($src, '?')) {
        return strtok($base, '?#') . $src;
    }
    $b = parse_url($base);
    if (str_starts_with($src, '//')) {
        return $b['scheme'] . ':' . $src;
    }
    $origin = $b['scheme'] . '://' . $b['host'] . (isset($b['port']) ? ':' . $b['port'] : '');
    $path = str_starts_with($src, '/') ? $src : preg_replace('~[^/]*$~', '', $b['path'] ?? '/') . $src;
    $out = [];
    foreach (explode('/', $path) as $seg) {
        if ($seg === '..') {
            if (count($out) > 1) {
                array_pop($out);
            }
        } elseif ($seg !== '.') {
            $out[] = $seg;
        }
    }
    return $origin . implode('/', $out);
}

function friendlyVersion(array $scripts, DOMElement $widget): array
{
    foreach ($scripts as $src) {
        if (str_contains($src, '@friendlycaptcha/sdk')) return ['v2', $src];
        if (str_contains($src, 'friendly-challenge')) return ['v1', $src];
    }
    foreach ($scripts as $src) {
        if (!str_contains(strtolower($src), 'friendly')) continue;
        $file = basename((string) parse_url($src, PHP_URL_PATH));
        if (in_array($file, ['site.min.js', 'site.compat.min.js'], true)) return ['v2', $src];
        if (in_array($file, ['widget.module.min.js', 'widget.min.js', 'widget.polyfilled.min.js'], true)) return ['v1', $src];
    }
    if ($widget->hasAttribute('data-api-endpoint') || $widget->hasAttribute('data-form-field-name')) return ['v2', null];
    if ($widget->hasAttribute('data-puzzle-endpoint') || $widget->hasAttribute('data-solution-field-name')) return ['v1', null];
    throw new RuntimeException('v1 or v2? Read the page and set it by hand.');
}

$solver = new CapSkip([
    'apiKey' => getenv('CAPSKIP_API_KEY') ?: 'capskip',
    'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
    'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
]);

$http = curl_init();
curl_setopt_array($http, [
    CURLOPT_URL => PAGE_URL,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_COOKIEFILE => '',
    CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
]);
$html = curl_exec($http);
if ($html === false) {
    exit('fetch failed: ' . curl_error($http) . PHP_EOL);
}
$pageUrl = curl_getinfo($http, CURLINFO_EFFECTIVE_URL);

libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $html);
$xp = new DOMXPath($doc);

$widget = $xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " frc-captcha ")]')->item(0);
if (!$widget instanceof DOMElement || $widget->getAttribute('data-sitekey') === '') {
    exit('No frc-captcha widget in the HTML; it may be built by JavaScript.' . PHP_EOL);
}
$form = $xp->query('ancestor::form[1]', $widget)->item(0);
if (!$form instanceof DOMElement) {
    exit('The widget is not inside a form; copy the request the page makes instead.' . PHP_EOL);
}
$scripts = [];
foreach ($xp->query('//script[@src][not(@nomodule)]') as $tag) {
    $scripts[] = absoluteUrl($tag->getAttribute('src'), $pageUrl);
}

try {
    [$version, $script] = friendlyVersion($scripts, $widget);
} catch (RuntimeException $e) {
    exit($e->getMessage() . PHP_EOL);
}
$endpoint = $widget->getAttribute('data-api-endpoint') ?: $widget->getAttribute('data-puzzle-endpoint');
try {
    $result = $solver->friendlyCaptcha($widget->getAttribute('data-sitekey'), $pageUrl, [
        'version' => $version,
        'module_script' => $script,
        'api_server' => $endpoint ?: null,
    ]);
} catch (ValidationException $e) {
    exit('not sent: ' . $e->getMessage() . PHP_EOL);
} catch (CapSkipError $e) {
    exit('solve failed: ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
}

$fields = [];
foreach ($xp->query('.//input[@name]', $form) as $input) {
    $type = strtolower($input->getAttribute('type'));
    if ($input->hasAttribute('disabled') || in_array($type, ['submit', 'button', 'image', 'file', 'reset'], true)) continue;
    $checkable = in_array($type, ['checkbox', 'radio'], true);
    if ($checkable && !$input->hasAttribute('checked')) continue;
    // A checked box with no value attribute submits "on".
    $fields[$input->getAttribute('name')] = $checkable && !$input->hasAttribute('value') ? 'on' : $input->getAttribute('value');
}
$fields['email'] = 'YOUR_EMAIL';
$field = $widget->getAttribute('data-form-field-name')
    ?: $widget->getAttribute('data-solution-field-name')
    ?: ($version === 'v2' ? 'frc-captcha-response' : 'frc-captcha-solution');
$fields[$field] = $result['token'];

// Post wherever the form's action points, on the same handle.
curl_setopt_array($http, [
    CURLOPT_URL => absoluteUrl($form->getAttribute('action') ?: $pageUrl, $pageUrl),
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($fields),
    CURLOPT_REFERER => $pageUrl,
]);
curl_exec($http);
echo curl_getinfo($http, CURLINFO_RESPONSE_CODE), ' ', $version, ' ', $field, PHP_EOL;

Die Version wird einmal bestimmt und zweimal verwendet, für das Lösen und für den Feldnamen, sodass beides nie auseinanderlaufen kann. Findet DOMXPath kein Widget, erzeugt die Seite es meist per JavaScript, und Sie brauchen den sitekey aus der gerenderten Seite oder aus dem Skript, das es erzeugt. Alle Parameter, die der rohe Endpunkt annimmt, finden Sie in der API-Referenz zu Friendly Captcha.

Häufige Fehler und was sie bedeuten

Was Sie sehenUrsacheBeheben
403 oder ein CSRF-Fehler, mit einem Token, den CapSkip zurückgegeben hatDas Absenden lief über ein neues Handle ohne die Cookies der Seite, ohne ihre versteckten Felder oder ohne RefererSenden Sie über das Handle aus Schritt 1 mit seinem leeren COOKIEFILE ab, bauen Sie die Felder aus dem Formular auf und setzen Sie CURLOPT_REFERER
Die Website lehnt einen Token ohne weitere Fehlermeldung abDie falsche Version wurde gelöst, oft v1 als StandardwertBestimmen Sie die Version wie in Schritt 2 im Code und übergeben Sie sie
Abgelehnt auf einer Website, deren Widget data-api-endpoint (v2) oder data-puzzle-endpoint (v1) hatDer Token kam vom globalen Endpunkt, und die Website nutzt den EU-EndpunktÜbergeben Sie den Wert des Attributs als api_server
Ein v2-Lösevorgang scheitert auf einer Website, die das Widget selbst hostetmodule_script war ein relativer Pfad, daher wurde das Widget nie geladenLösen Sie src mit absoluteUrl gegen die Seiten-URL auf
Ein Fehler wegen eines fehlenden Felds, und die Anfrage ging als multipart/form-data hinausCURLOPT_POSTFIELDS hat das Array bekommenÜbergeben Sie http_build_query($fields), einen URL-kodierten String
Versteckte Werte mit Akzenten kommen verstümmelt anloadHTML hat eine Seite ohne meta charset als Latin-1 gelesenBehalten Sie das Encoding-Präfix aus Schritt 1 bei
ValidationException, die pollingInterval nenntDie Option pro Aufruf heißt polling_intervalVerwenden Sie pro Aufruf Snake Case oder setzen Sie pollingInterval im Konstruktor
ApiException mit ERROR_CAPTCHA_UNSOLVABLE innerhalb von Sekunden, jedes MalFriendly Captcha lehnt den sitekey oder den Origin der Seite ab, oder im Konto ist weder v2 noch der EU-Endpunkt aktiviertPrüfen Sie sitekey, Seiten-URL und api_server; ein erneuter Versuch hilft nicht
ApiException mit ERROR_CAPTCHA_UNSOLVABLE nach zwei Minuten oder mehrEin Lösevorgang lief länger als das Row Timeout von CapSkip mit 120 Sekunden oder wartete länger als das Wait Timeout von 250 Sekunden auf einen freien ThreadHalten Sie höchstens zehn gleichzeitig in Arbeit, fügen Sie Proxys hinzu oder erhöhen Sie Row Timeout in den Friendly-Captcha-Einstellungen
TimeoutException nach 300 SekundenEine Aufgabe wartete auf einen freien Thread, und der Lösevorgang lief danach länger, als das SDK polltHalten Sie höchstens zehn gleichzeitig in Arbeit oder übergeben Sie ein längeres timeout
Fatal error: Maximum execution time of 30 seconds exceeded, und kein catch-Block liefDer Lösevorgang lief innerhalb einer Webanfrage, wo das 30-Sekunden-Limit von PHP unter Windows auch die Wartezeit zähltLösen Sie über die Kommandozeile oder in einem Queue-Worker
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip läuft nicht, Host oder Port sind falsch, oder PHP läuft auf einem anderen RechnerStarten Sie CapSkip und prüfen Sie dann, ob der Local-Modus oder der Server-Modus passt

FAQ

Brauche ich einen Browser, um Friendly Captcha in PHP zu lösen?

Nein. Ihr Skript braucht nur das HTML, und das holt cURL. Die Browserarbeit findet in CapSkip statt, das v2-Widgets in seinem eigenen Browser und v1-Rätsel direkt löst. Wenn Ihr PHP-Code aus anderen Gründen ohnehin einen Browser steuert, funktioniert derselbe Aufruf: Lesen Sie sitekey und Skriptadresse dann aus der Live-Seite statt aus dem abgerufenen HTML aus.

Kann ich Guzzle statt reinem cURL verwenden?

Ja. Erzeugen Sie einen einzigen Client mit der Option cookies auf true, dann verwendet er für alle Anfragen, die er stellt, einen gemeinsamen Cookie-Speicher, Weiterleitungen eingeschlossen, und übernimmt damit die Aufgabe des leeren COOKIEFILE. Senden Sie die Felder mit form_params, das sie URL-kodiert sendet, und fügen Sie die Seiten-URL als Referer-Header hinzu. Guzzle gibt sich in seinem User-Agent als GuzzleHttp zu erkennen, setzen Sie also den eines Browsers, wenn die Website wählerisch ist. Die Aufrufe von CapSkip ändern sich überhaupt nicht, denn das SDK spricht selbstständig mit dem Solver.

Kann ich das in einem Laravel-Queue-Job ausführen?

Ja, und ein Queue-Worker ist dafür ein besserer Ort als ein Controller, denn der Worker läuft über die Kommandozeile. Packen Sie Abruf, Lösen und Absenden in einen einzigen Job, damit der Token seine Session nie überlebt. Das Timeout des Jobs muss dann einen langsamen Lösevorgang abdecken, und unter Windows kann der Worker dieses Timeout überhaupt nicht durchsetzen. Der Leitfaden zur Laravel-Queue erklärt das Job-Timeout, retry_after und den Windows-Fall mit CapSkip.

Kann eine PHP-App auf einem VPS den Solver auf meinem Windows-PC nutzen?

Ja. Schalten Sie CapSkip in den Verbindungseinstellungen in den Server-Modus, damit es auf einer Netzwerkadresse statt auf Loopback lauscht. Setzen Sie dann CAPSKIP_HOST dort, wo die App läuft, und übergeben Sie den Wert an den Client, wie es das vollständige Beispiel tut. Ein VPS, ein Shared Hosting und ein Container verbinden sich alle über dieselbe HTTP-API. Verwenden Sie eine statische öffentliche IP, wenn der Weg über das Internet führt, und fügen Sie in der Windows Firewall eine Blockierregel für Adressen hinzu, die den Port nicht erreichen sollen. Der Solver bleibt auf Hardware, die Ihnen gehört, mehr Lösevorgänge ändern also nie etwas an dem, was Sie zahlen.

Die Kurzfassung

Um Friendly Captcha in PHP zu lösen, rufen Sie die Seite über ein einziges cURL-Handle mit leerem CURLOPT_COOKIEFILE ab und lesen dann das Element frc-captcha und seine Script-Tags mit DOMXPath aus, wobei Sie jedes src zu einer vollständigen Adresse auflösen. Bestimmen Sie v1 oder v2 anhand des Paket- oder Dateinamens oder der eigenen Attribute des Widgets, und werfen Sie eine Exception, wenn keines davon etwas verrät. Rufen Sie friendlyCaptcha mit dem sitekey, der Seiten-URL, dieser Version und der Skriptadresse auf, bei EU-Websites zusätzlich mit api_server. Senden Sie den Token einmal ab, URL-kodiert über dasselbe Handle, neben den eigenen Feldern des Formulars und in dem Feld, das die Version vorgibt. Bei vielen Formularen reichen Sie mit send ein und fragen mit getResult ab, jeweils zehn auf einmal.

Friendly Captcha verlangt für seine Schwierigkeit CPU-Zeit und kein Geld, ein arbeitsreicher Tag kostet Sie also Lösezeit auf Ihrem eigenen Rechner und sonst nichts. Wenn ein unbegrenzter Captcha-Löser auf Hardware läuft, die Sie kontrollieren, sind dieser Rechner und die Threads, die Sie ihm geben, die einzigen Grenzen.