每接一个新项目就要重写一遍 cURL 请求、轮询逻辑和错误处理——不少 PHP 团队接入 CaptchaAI 时都踩过这个坑。更省事的做法是封装成一个 Composer 包,团队内其他项目 composer require 一次即可复用。
封装好之后,调用方能拿到什么:
- 一行代码识别验证码:
$client->solveRecaptchaV2($sitekey, $url) - 按失败原因分类的异常类,捕获时不用解析错误字符串
- 可替换的 HTTP 客户端接口,方便写单元测试
下面用最小实现,带你把这个包从目录结构搭到可用的客户端类。
包目录结构
captchaai-php/
├── src/
│ ├── CaptchaAI.php # Main client class
│ ├── Exception/
│ │ ├── CaptchaAIException.php
│ │ ├── SubmitException.php
│ │ ├── SolveException.php
│ │ └── TimeoutException.php
│ └── Enum/
│ └── Method.php
├── composer.json
└── README.md
Composer 配置文件
composer.json 声明依赖和自动加载规则,PHP 版本锁定在 8.1 及以上,才能使用下文的具名参数(named arguments):
{
"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/"
}
}
}
生产环境建议把
composer.lock提交到仓库,锁定 Guzzle 的具体版本,避免 CI 与本地环境不一致。
国内网络环境下 composer install 拉取包偶尔会慢,可以临时切到国内镜像,例如 composer config -g repos.packagist composer https://mirrors.aliyun.com/composer/,装完依赖后再按需切回官方源。
异常类设计
为不同失败场景各自设计一个异常类,方便按类型捕获处理,而不是解析错误字符串——提交失败、识别失败、超时分别对应下面三个子类:
<?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;
}
}
核心客户端类
主类只做两件事:提交任务、轮询结果。五个求解方法都走同一套 submit() → poll() 流程,区别只在 method 参数和额外字段。先看对照表:
五个求解方法速查
| 方法 | 对应验证码类型 | 关键参数 |
|---|---|---|
solveRecaptchaV2() |
reCAPTCHA v2 | sitekey、pageurl |
solveRecaptchaV3() |
reCAPTCHA v3 | sitekey、pageurl、action |
solveTurnstile() |
Cloudflare Turnstile | sitekey、pageurl |
solveImage() |
图片/文字验证码 | base64Image |
solveGeeTestV3() |
GeeTest v3 | gt、challenge、pageurl |
<?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;
}
}
完整调用示例
依次演示查询余额、识别 reCAPTCHA v2(含超时与失败分支)、识别 Turnstile 与图片验证码:
<?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://staging.example.com/qa-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";
如果团队同时维护多个需要验证码识别的系统,封装成私有 Composer 包比到处复制粘贴同一段 HTTP 调用好维护得多,升级只需要改一处。
常见问题
Guzzle 和原生 cURL 该怎么选?
如果项目已经引入 Guzzle,直接复用它的连接池、中间件和 PSR-7 接口能省不少代码;小项目用原生 cURL 也没问题。客户端类只依赖 PSR-18 接口,随时可换成 Symfony HttpClient,不锁死在 Guzzle 上。
GeeTest v4 现在能用这个包识别吗?
暂时不能。CaptchaAI 目前支持 GeeTest v3(对应 solveGeeTestV3),v4 状态是即将支持,别在生产代码里假设它可用;hCaptcha、FunCaptcha 同样不在支持列表里。
并发量大的时候,这个客户端够用吗?
客户端本身没有并发限制,瓶颈是账号的线程数——比如 ADVANCE 计划 $90/月、50 线程。业务层用队列同时发起多个 submit(),不超过线程上限即可并行处理。
怎么把客户端接入 Laravel 项目?
在 AppServiceProvider::register() 里把 CaptchaAI::class 注册成单例,构造参数从 config/services.php 读取;控制器或队列任务里用构造函数注入即可,不需要手动 new 实例。
怎么给这个包写单元测试?
把构造函数里的 ?HttpClient $httpClient = null 换成 Guzzle 的 MockHandler,就能不请求真实 API 测试各条分支——这正是留这个参数的目的:
- 用
MockHandler组装一个假的 Guzzle Client - 入队预设的 JSON 响应,模拟提交成功、
CAPCHA_NOT_READY、最终成功等序列 - 把假 Client 通过
$httpClient参数注入,断言各分支的返回值
常见故障排查
| 问题 | 原因 | 处理方式 |
|---|---|---|
SubmitException: ERROR_WRONG_USER_KEY |
API 密钥无效 | 从控制台检查密钥 |
TimeoutException 频繁 |
超时时间太短 | 将 $timeout 增加到 180+ |
Class not found |
自动加载器未配置 | 运行 composer dump-autoload |
| Guzzle 连接错误 | 网络问题或防火墙拦截 | 检查服务器是否能访问 ocr.captchaai.com |
json_decode 返回 null |
响应正文无效 | 检查 API URL;记录原始响应用于调试 |
| 国内服务器访问偶发超时 | 跨境网络延迟波动 | 适当调大 $timeout,捕获 TimeoutException 后重试而不是缩短轮询间隔 |
相关阅读
下一步
现在就动手:获取你的 CaptchaAI API Key,把上面的类文件跑起来,搭建属于你自己的 Composer 库。
相关指南: