API Tutorials

Création d'un package PHP Composer pour CaptchaAI

Un package Composer transforme l'API CaptchaAI en dépendance réutilisable : installée une fois, elle résout un défi CAPTCHA en une ligne, $client->solveRecaptchaV2($sitekey, $url), au lieu d'appels cURL et de parsing JSON recopiés dans chaque projet PHP. Ce guide le construit de bout en bout — arborescence PSR-4, composer.json, exceptions typées et client Guzzle — pour reCAPTCHA v2/v3, Turnstile, GeeTest v3 et l'OCR d'image.

Ce que le package expose

Une seule classe cliente, CaptchaAI, présente une méthode typée par type de défi. Le reste (envoi de la tâche, polling, gestion des erreurs) reste privé, ce qui garde la surface publique lisible :

  • solveRecaptchaV2() — reCAPTCHA v2, variante invisible comprise ;
  • solveRecaptchaV3() — reCAPTCHA v3, avec le paramètre action et un score ;
  • solveTurnstile() — Cloudflare Turnstile ;
  • solveGeeTestV3() — GeeTest v3 ;
  • solveImage() — CAPTCHA image/OCR à partir d'un base64.

hCaptcha et FunCaptcha ne sont pas pris en charge : n'exposez pas de méthode pour ces types, sinon le package promet ce que l'API ne fait pas.

Arborescence du package

Le package suit la convention PSR-4 : tout le code vit sous src/, mappé sur le namespace CaptchaAI\.

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

Le manifeste composer.json

Une seule dépendance, Guzzle, et l'autoloading PSR-4. PHP 8.1 minimum active les arguments nommés et les propriétés typées du code ci-dessous.

{
    "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/"
        }
    }
}

Trois entrées portent tout le manifeste :

  • require — PHP 8.1 au minimum, plus Guzzle 7 comme unique client HTTP ;
  • autoload.psr-4 — mappe le namespace CaptchaAI\ sur le dossier src/, sans fichier d'autoload à maintenir ;
  • type: library — indispensable pour une publication ultérieure sur Packagist.

Une hiérarchie d'exceptions typée

Toutes les erreurs API ne se valent pas : une clé invalide ou un solde vide sont fatals, un échec ponctuel ou un timeout méritent une nouvelle tentative. CaptchaAIException porte le code d'erreur et une méthode isFatal() qui tranche entre les deux.

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

SubmitException, SolveException et TimeoutException séparent l'envoi, la résolution et l'expiration ; la dernière conserve l'identifiant de tâche.

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

La classe cliente principale

Le cœur du package tient en trois méthodes privées : submit() envoie la tâche sur in.php, poll() interroge res.php jusqu'à obtenir le token, et solve() enchaîne les deux. pollInterval et timeout se règlent au constructeur selon le type de CAPTCHA.

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

Mise en pratique

L'usage tient en quelques lignes : vérifier le solde, résoudre un reCAPTCHA v2 en isolant les exceptions fatales des relançables, puis enchaîner Turnstile et OCR.

<?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";

En production : workers et conformité RGPD

Placez ce client dans un worker de file d'attente (Symfony Messenger, tâche cron) hébergé au plus près de vos utilisateurs — OVHcloud, Scaleway ou la région AWS eu-west-3 (Paris). Côté RGPD, ne journalisez jamais un token complet ni les cookies : tronquez-les, comme substr($token, 0, 40).

Publier le package sur Packagist

Pour un usage interne, gardez le package privé : référencez-le via une entrée repositories de type vcs dans le composer.json du projet qui le consomme, pointée sur votre dépôt Git. Pour une diffusion publique, suivez la chaîne habituelle :

  1. Taguez une version sémantique (git tag v1.0.0) et poussez le tag ;
  2. Créez un compte Packagist et soumettez l'URL du dépôt ;
  3. Ajoutez le webhook GitHub pour que Packagist se resynchronise à chaque tag ;
  4. Documentez l'installation (composer require your-vendor/captchaai) dans le README.

Versionnez la surface publique — les signatures des méthodes solve*() — et non les détails internes du polling, pour que vos montées de version restent prévisibles.

Dépannage

Le tableau ci-dessous couvre les erreurs les plus fréquentes à l'installation et au premier appel.

Problème Cause Correctif
SubmitException: ERROR_WRONG_USER_KEY Clé API invalide ou mal copiée Vérifiez la clé dans votre tableau de bord CaptchaAI
TimeoutException récurrent Délai d'expiration trop court pour le type de CAPTCHA Augmentez $timeout à 180 s ou plus
Class not found Autoloading PSR-4 non régénéré Lancez composer dump-autoload
Erreur de connexion Guzzle Réseau ou pare-feu bloquant Vérifiez que le serveur joint ocr.captchaai.com
json_decode renvoie null Réponse non-JSON (page d'erreur HTML, endpoint erroné) Journalisez la réponse brute avant de la décoder

FAQ

Quels types de CAPTCHA ce package couvre-t-il ?

Les méthodes fournies résolvent reCAPTCHA v2 (y compris invisible), reCAPTCHA v3, Cloudflare Turnstile, GeeTest v3 et les CAPTCHA image/OCR. hCaptcha et FunCaptcha ne sont pas pris en charge.

Comment injecter le client dans Symfony ou Laravel ?

Déclarez CaptchaAI::class comme service partagé : sous Symfony, dans services.yaml avec la clé API en argument ; sous Laravel, en singleton dans un service provider depuis config/services.php. Injectez-le ensuite par le constructeur.

Combien coûte la résolution des CAPTCHA ?

La facturation se fait par thread concurrent, pas au CAPTCHA résolu. Le plan BASIC ($15/mois, 5 threads) inclut un nombre illimité de résolutions par thread, et les paliers montent jusqu'à VIP-3 ($7,500/mois, 5 000 threads). Le débit dépend donc du nombre de threads, pas d'un quota.

Comment distinguer une erreur fatale d'une nouvelle tentative ?

CaptchaAIException::isFatal() renvoie true pour les codes irrécupérables comme ERROR_WRONG_USER_KEY ou ERROR_ZERO_BALANCE : arrêtez-vous. Sur les autres (échecs ponctuels, timeouts), relancez avec backoff exponentiel.

Articles connexes

Prochaines étapes

Prêt à empaqueter votre intégration ? Récupérez votre clé API CaptchaAI et publiez votre première bibliothèque Composer.

Guides associés :

Les commentaires sont désactivés pour cet article.