API-Tutorials

Erstellen eines PHP Composer-Pakets für CaptchaAI

Spätestens im zweiten PHP-Projekt sollte der CaptchaAI-Zugriff nicht kopiert, sondern paketiert werden. Dieser Leitfaden baut Schritt für Schritt ein Composer-Paket your-vendor/captchaai mit einer Client-Klasse, typisierten Solver-Methoden, einer eigenen Fehlerhierarchie und Guzzle als HTTP-Schicht. Statt eines cURL-Blocks im Controller steht am Ende ein Aufruf wie $client->solveRecaptchaV2($sitekey, $pageurl) – versioniert, testbar und per composer require in jedem weiteren Projekt verfügbar.

Der typische Auslöser in DACH-Agenturen: drei Shopware-Shops, ein TYPO3-Projekt und eine Symfony-API teilen sich denselben CaptchaAI-Zugang, aber jedes Repository enthält eine leicht abweichende Kopie derselben vierzig Zeilen. Ändert sich ein Fehlercode oder das Polling-Intervall, muss jemand fünf Stellen anfassen. Ein Paket macht daraus eine Codebasis mit einer Versionsnummer.

Das fertige Paket soll:

  • Aufgaben an in.php übermitteln und das Ergebnis über res.php abfragen,
  • reCAPTCHA v2 und v3, Turnstile, GeeTest v3 und Bild-CAPTCHAs typisiert lösen,
  • fatale von wiederholbaren Fehlern trennen,
  • Guthaben abfragen und fehlerhafte Lösungen melden.

Verzeichnisstruktur des Pakets

Die Struktur folgt PSR-4: der gesamte Code liegt unter src/, die Fehlerklassen in einem eigenen Unterverzeichnis, in der Wurzel steht keine Logik.

captchaai-php/
├── src/
│   ├── CaptchaAI.php        # Main client class
│   ├── Exception/
│   │   ├── CaptchaAIException.php
│   │   ├── SubmitException.php
│   │   ├── SolveException.php
│   │   └── TimeoutException.php
│   └── Enum/
│       └── Method.php
├── composer.json
└── README.md

Die Aufteilung ist bewusst konservativ: CaptchaAI.php kennt HTTP und API-Parameter, die Klassen unter Exception/ nur Fehlercodes. Wer später auf einen anderen PSR-18-Client wechselt, tauscht genau eine Datei aus.

composer.json: Autoloading und Abhängigkeiten

Zwei Angaben entscheiden über die Wiederverwendbarkeit: der psr-4-Namespace und die minimale PHP-Version. php: ">=8.1" ist kein Selbstzweck – die Client-Klasse nutzt typisierte Eigenschaften und benannte Argumente, beides erspart im Alltag ganze Klassen von Übergabefehlern.

{
    "name": "your-vendor/captchaai",
    "description": "PHP client library for CaptchaAI API",
    "type": "library",
    "license": "MIT",
    "require": {
        "php": ">=8.1",
        "guzzlehttp/guzzle": "^7.0"
    },
    "autoload": {
        "psr-4": {
            "CaptchaAI\\": "src/"
        }
    }
}

guzzlehttp/guzzle bleibt die einzige Laufzeitabhängigkeit; alles Weitere gehört in require-dev. Intern genügt es, das Git-Repository unter repositories einzutragen – eine öffentliche Veröffentlichung ist erst nötig, wenn Dritte das Paket installieren sollen.

Fehlerklassen: fatal oder wiederholbar

Die wichtigste Designentscheidung steckt nicht im Solver, sondern in der Fehlerbehandlung. Der Aufrufer muss zwei Situationen sauber trennen: Fehler, bei denen ein erneuter Versuch sinnvoll ist, und Fehler, bei denen jeder weitere Aufruf nur Threads belegt. ERROR_WRONG_USER_KEY, ERROR_KEY_DOES_NOT_EXIST, ERROR_ZERO_BALANCE und ERROR_IP_NOT_ALLOWED gehören in die zweite Gruppe – dafür gibt es isFatal().

Darauf setzen drei Klassen auf: SubmitException für abgelehnte Übermittlungen, SolveException für ungelöste Aufgaben und TimeoutException, die zusätzlich die Task-ID mitführt. Genau diese ID brauchen Sie im Log, wenn Sie Wochen später einen Einzelfall nachvollziehen.

<?php
// src/Exception/CaptchaAIException.php
namespace CaptchaAI\Exception;

class CaptchaAIException extends \RuntimeException
{
    private ?string $errorCode;

    private const FATAL_CODES = [
        'ERROR_WRONG_USER_KEY',
        'ERROR_KEY_DOES_NOT_EXIST',
        'ERROR_ZERO_BALANCE',
        'ERROR_IP_NOT_ALLOWED',
    ];

    public function __construct(string $message, ?string $errorCode = null)
    {
        parent::__construct($message);
        $this->errorCode = $errorCode;
    }

    public function getErrorCode(): ?string
    {
        return $this->errorCode;
    }

    public function isFatal(): bool
    {
        return in_array($this->errorCode, self::FATAL_CODES, true);
    }
}
<?php
// src/Exception/SubmitException.php
namespace CaptchaAI\Exception;

class SubmitException extends CaptchaAIException
{
    public function __construct(string $code)
    {
        parent::__construct("Task submission failed: {$code}", $code);
    }
}
<?php
// src/Exception/SolveException.php
namespace CaptchaAI\Exception;

class SolveException extends CaptchaAIException
{
    public function __construct(string $code)
    {
        parent::__construct("Task solving failed: {$code}", $code);
    }
}
<?php
// src/Exception/TimeoutException.php
namespace CaptchaAI\Exception;

class TimeoutException extends CaptchaAIException
{
    private string $taskId;

    public function __construct(string $taskId, int $timeoutSeconds)
    {
        parent::__construct("Task {$taskId} timed out after {$timeoutSeconds}s");
        $this->taskId = $taskId;
    }

    public function getTaskId(): string
    {
        return $this->taskId;
    }
}

Die Client-Klasse: übermitteln, abfragen, Token zurückgeben

Der Kern besteht aus drei privaten Methoden: submit() schickt die Parameter an in.php und gibt die Task-ID zurück, poll() fragt res.php in festen Abständen ab, bis ein Token vorliegt, solve() verbindet beides. Alle öffentlichen Solver-Methoden sind dünne Hüllen um diese Kette – deshalb kostet ein zusätzlicher CAPTCHA-Typ fünf Zeilen und nicht fünfzig.

Zwei Konstruktor-Parameter verdienen Aufmerksamkeit: pollInterval und timeout. Ein zu kurzes Intervall erzeugt Anfragen ohne Erkenntnisgewinn, ein zu langes verschenkt Zeit; 5 Sekunden sind ein brauchbarer Startwert. Als Timeout setzen Sie den langsamsten tatsächlich genutzten Typ an: Bild-CAPTCHAs liegen unter 0,5 Sekunden, Turnstile unter 10 Sekunden, GeeTest v3 unter 12 Sekunden, reCAPTCHA v2 unter 60 Sekunden.

<?php
// src/CaptchaAI.php
namespace CaptchaAI;

use GuzzleHttp\Client as HttpClient;
use CaptchaAI\Exception\SubmitException;
use CaptchaAI\Exception\SolveException;
use CaptchaAI\Exception\TimeoutException;

class CaptchaAI
{
    private const SUBMIT_URL = 'https://ocr.captchaai.com/in.php';
    private const RESULT_URL = 'https://ocr.captchaai.com/res.php';

    private string $apiKey;
    private HttpClient $http;
    private int $pollInterval;
    private int $timeout;

    public function __construct(
        string $apiKey,
        int $pollInterval = 5,
        int $timeout = 180,
        ?HttpClient $httpClient = null
    ) {
        $this->apiKey = $apiKey;
        $this->pollInterval = $pollInterval;
        $this->timeout = $timeout;
        $this->http = $httpClient ?? new HttpClient(['timeout' => 30]);
    }

    // --- Core methods ---

    private function submit(array $params): string
    {
        $params['key'] = $this->apiKey;
        $params['json'] = 1;

        $response = $this->http->post(self::SUBMIT_URL, [
            'form_params' => $params,
        ]);

        $result = json_decode($response->getBody()->getContents(), true);

        if (($result['status'] ?? 0) !== 1) {
            throw new SubmitException($result['request'] ?? 'unknown');
        }

        return $result['request']; // task ID
    }

    private function poll(string $taskId): string
    {
        $startTime = time();

        while (time() - $startTime < $this->timeout) {
            sleep($this->pollInterval);

            $response = $this->http->get(self::RESULT_URL, [
                'query' => [
                    'key' => $this->apiKey,
                    'action' => 'get',
                    'id' => $taskId,
                    'json' => 1,
                ],
            ]);

            $result = json_decode($response->getBody()->getContents(), true);

            if (($result['request'] ?? '') === 'CAPCHA_NOT_READY') {
                continue;
            }

            if (($result['status'] ?? 0) === 1) {
                return $result['request'];
            }

            throw new SolveException($result['request'] ?? 'unknown');
        }

        throw new TimeoutException($taskId, $this->timeout);
    }

    private function solve(array $params): string
    {
        $taskId = $this->submit($params);
        return $this->poll($taskId);
    }

    // --- Solver methods ---

    /**

     * Solve reCAPTCHA v2
     */
    public function solveRecaptchaV2(
        string $sitekey,
        string $pageurl,
        bool $invisible = false,
        ?string $cookies = null
    ): string {
        $params = [
            'method' => 'userrecaptcha',
            'googlekey' => $sitekey,
            'pageurl' => $pageurl,
        ];
        if ($invisible) $params['invisible'] = 1;
        if ($cookies) $params['cookies'] = $cookies;

        return $this->solve($params);
    }

    /**

     * Solve reCAPTCHA v3
     */
    public function solveRecaptchaV3(
        string $sitekey,
        string $pageurl,
        string $action = 'verify',
    ): string {
        return $this->solve([
            'method' => 'userrecaptcha',
            'version' => 'v3',
            'googlekey' => $sitekey,
            'pageurl' => $pageurl,
            'action' => $action,
        ]);
    }

    /**

     * Solve Cloudflare Turnstile
     */
    public function solveTurnstile(
        string $sitekey,
        string $pageurl,
        ?string $action = null,
        ?string $cdata = null
    ): string {
        $params = [
            'method' => 'turnstile',
            'sitekey' => $sitekey,
            'pageurl' => $pageurl,
        ];
        if ($action) $params['action'] = $action;
        if ($cdata) $params['data'] = $cdata;

        return $this->solve($params);
    }

    /**

     * Solve hCaptcha
     */
    public function solveHCaptcha(string $sitekey, string $pageurl): string
    {
        return $this->solve([
            'method' => 'hcaptcha',
            'sitekey' => $sitekey,
            'pageurl' => $pageurl,
        ]);
    }

    /**

     * Solve image/text CAPTCHA from base64
     */
    public function solveImage(
        string $base64Image,
        bool $caseSensitive = false,
        ?int $minLength = null,
        ?int $maxLength = null
    ): string {
        $params = [
            'method' => 'base64',
            'body' => $base64Image,
        ];
        if ($caseSensitive) $params['regsense'] = 1;
        if ($minLength !== null) $params['min_len'] = $minLength;
        if ($maxLength !== null) $params['max_len'] = $maxLength;

        return $this->solve($params);
    }

    /**

     * Solve GeeTest v3
     */
    public function solveGeeTestV3(
        string $gt,
        string $challenge,
        string $pageurl
    ): string {
        return $this->solve([
            'method' => 'geetest',
            'gt' => $gt,
            'challenge' => $challenge,
            'pageurl' => $pageurl,
        ]);
    }

    // --- Utility methods ---

    /**

     * Get current account balance
     */
    public function getBalance(): float
    {
        $response = $this->http->get(self::RESULT_URL, [
            'query' => [
                'key' => $this->apiKey,
                'action' => 'getbalance',
                'json' => 1,
            ],
        ]);

        $result = json_decode($response->getBody()->getContents(), true);
        return (float) ($result['request'] ?? 0);
    }

    /**

     * Report a bad solution
     */
    public function reportBad(string $taskId): bool
    {
        $response = $this->http->get(self::RESULT_URL, [
            'query' => [
                'key' => $this->apiKey,
                'action' => 'reportbad',
                'id' => $taskId,
                'json' => 1,
            ],
        ]);

        $result = json_decode($response->getBody()->getContents(), true);
        return ($result['status'] ?? 0) === 1;
    }
}

Welche Solver-Methoden in die öffentliche API gehören

Nehmen Sie nur auf, was CaptchaAI auch löst: reCAPTCHA v2 inklusive Invisible, Callback und Enterprise, reCAPTCHA v3 und v3 Enterprise, Cloudflare Turnstile und Challenge, GeeTest v3, Bild- und Rasterbild-CAPTCHAs sowie BLS. CaptchaFox, Friendly Captcha und Lemin sind als Beta verfügbar und gehören auch so benannt. Für hCaptcha und FunCaptcha (Arkose Labs) gibt es bei CaptchaAI keinen Solver, GeeTest v4 ist nur als „bald verfügbar“ angekündigt – eine Hülle wie solveHCaptcha() zeigt oben lediglich das Muster für weitere Typen.

Das Paket im Projekt einsetzen

Nach composer dump-autoload genügen drei Zeilen: Autoloader einbinden, Client mit dem API-Schlüssel erzeugen, Methode aufrufen. Benannte Argumente machen den Aufruf selbsterklärend, und dank der Klassenhierarchie behandeln Sie eine TimeoutException anders als eine SubmitException mit fatalem Code.

<?php
require_once 'vendor/autoload.php';

use CaptchaAI\CaptchaAI;
use CaptchaAI\Exception\SubmitException;
use CaptchaAI\Exception\TimeoutException;

$client = new CaptchaAI(
    apiKey: 'YOUR_API_KEY',
    pollInterval: 5,
    timeout: 120
);

// Check balance
$balance = $client->getBalance();
echo "Balance: \${$balance}\n";

// Solve reCAPTCHA v2
try {
    $token = $client->solveRecaptchaV2(
        sitekey: '6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-',
        pageurl: 'https://example.com/login'
    );
    echo "Token: " . substr($token, 0, 40) . "...\n";
} catch (TimeoutException $e) {
    echo "Timed out: {$e->getMessage()}\n";
} catch (SubmitException $e) {
    if ($e->isFatal()) {
        echo "Fatal: {$e->getErrorCode()}\n";
        exit(1);
    }
    echo "Retryable: {$e->getErrorCode()}\n";
}

// Solve Turnstile
$turnstileToken = $client->solveTurnstile(
    sitekey: '0x4AAAAAAADnPIDROrmt1Wwj',
    pageurl: 'https://example.com/checkout'
);

// Solve image CAPTCHA
$imageBase64 = base64_encode(file_get_contents('captcha.png'));
$text = $client->solveImage($imageBase64, caseSensitive: true);
echo "Text: {$text}\n";

Threads, Parallelität und Plangröße

Das Paket selbst kennt keine Nebenläufigkeit: Es blockiert, bis ein Token vorliegt. Wie viele Aufgaben gleichzeitig laufen dürfen, entscheidet Ihr Tarif: CaptchaAI rechnet pro Thread ab, nicht pro Lösung – die Zahl der Lösungen pro Thread ist unbegrenzt. BASIC (15 $/Monat) bringt 5 Threads, STANDARD (30 $/Monat) 15, ADVANCE (90 $/Monat) 50. Alle Preise in US-Dollar.

Für echte Parallelität bieten sich Guzzle-Promises oder ein Worker-Prozess pro Auftrag an; ein einzelner CLI-Worker auf einem kleinen Hetzner- oder netcup-Server deckt die meisten Agentur-Workloads ab. Entscheidend ist, dass die Zahl gleichzeitiger Aufrufe zur Thread-Zahl Ihres Plans passt – sonst warten Anfragen, ohne dass es im Log auffällt.

In GitLab-CI-Pipelines, in deutschen Unternehmen mindestens so verbreitet wie GitHub Actions, hinterlegen Sie den API-Schlüssel als maskierte CI-Variable – in die composer.json gehört er nie. Läuft das Paket in einer Scraping-Pipeline, prüfen Sie Ihre Rechtsgrundlage: IP-Adressen gelten nach DSGVO als personenbezogene Daten.

Typische Fehlerbilder

Symptom Ursache Vorgehen
SubmitException: ERROR_WRONG_USER_KEY Schlüssel aus der falschen Umgebung geladen Schlüssel im Dashboard prüfen, per Umgebungsvariable injizieren
Häufige TimeoutException Timeout kürzer als die Lösungszeit des Typs timeout auf 180 Sekunden anheben
Class not found beim ersten Aufruf Autoloader nicht aktualisiert composer dump-autoload ausführen, psr-4-Namespace prüfen
json_decode liefert null Antwort ist kein JSON – Proxy, Fehlerseite, fehlendes json=1 Rohantwort loggen, Erreichbarkeit von ocr.captchaai.com prüfen
Token wird erzeugt, aber vom Formular abgelehnt sitekey, pageurl oder Session-Kontext passen nicht zusammen Parameter erneut erfassen, Token in derselben Sitzung eintragen, bevor es abläuft

Veröffentlichen und versionieren

Intern reicht ein privates Repository: Git-Tag setzen, composer require your-vendor/captchaai:^1.0, fertig. GitLab Package Registry oder Private Packagist ergänzen Zugriffskontrolle über bestehende Projektrollen. Öffentliche Pakete landen auf Packagist – dann gelten SemVer-Disziplin, README mit lauffähigem Beispiel und ein Test-Job als Mindeststandard. Halten Sie die Signaturen stabil: Umbenannte Methoden erzwingen einen Major-Sprung.

FAQ

Welche CAPTCHA-Typen deckt das Paket ab?

Alle von CaptchaAI unterstützten Familien: reCAPTCHA (v2, v3, Enterprise), Cloudflare (Turnstile, Challenge), GeeTest v3, Bild- und Rasterbild-CAPTCHAs sowie BLS, dazu CaptchaFox, Friendly Captcha und Lemin als Beta. Für hCaptcha, FunCaptcha und GeeTest v4 gibt es keinen Solver.

Wie viele Anfragen kann ich parallel laufen lassen?

So viele, wie Ihr Plan Threads hat – ein Thread entspricht einem laufenden CAPTCHA und wird nach der Lösung sofort wieder frei. Ein Limit pro Lösung gibt es nicht, die Thread-Zahl ist beim Dimensionieren also die einzige relevante Größe.

Was tue ich, wenn res.php dauerhaft CAPCHA_NOT_READY liefert?

Nichts – der Zustand ist normal und heißt nur, dass die Aufgabe noch läuft; poll() überspringt ihn und fragt nach dem Intervall erneut. Kritisch wird es erst, wenn der Timeout greift: Dann protokollieren Sie die Task-ID und prüfen sitekey und pageurl.

Lässt sich das Paket in Symfony, Laravel oder Shopware einbinden?

Ja. In Symfony registrieren Sie die Klasse als Service und übergeben den Schlüssel per Parameter, in Laravel als Singleton im Service-Provider. In Shopware-Projekten funktioniert der Weg über einen Plugin-Service genauso, weil das Paket außer Guzzle nichts voraussetzt.

Wie teste ich das Paket ohne echte API-Aufrufe?

Über den optionalen vierten Konstruktor-Parameter. Der Client akzeptiert einen vorbereiteten HTTP-Client, sodass Sie im Test einen Guzzle-MockHandler mit gespeicherten Antworten einsetzen. So prüfen Sie Fehlerpfade wie ERROR_ZERO_BALANCE oder ein Timeout, ohne Threads zu verbrauchen.


Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.