Если тестовый запрос к CaptchaAI падает с ERROR_WRONG_CAPTCHA_ID уже после того, как вы прождали ответ несколько секунд, причина почти всегда не в API, а в параметре, который вообще не стоило отправлять по сети: пустой sitekey, URL без схемы, base64-строка короче ожидаемой длины. Pydantic ловит такие ошибки на уровне кода, до HTTP-вызова — вместо загадочного кода ошибки от API вы получаете понятное исключение ValidationError с указанием, какое именно поле не прошло проверку.
Зачем добавлять Pydantic в CaptchaAI-клиент на Python
Без валидации ошибка в параметре обнаруживается только после round-trip к API — вы теряете время на ожидание ответа, чтобы получить код ошибки вместо результата. Pydantic переносит проверку на уровень кода, до сети:
| Без Pydantic | С Pydantic |
|---|---|
| Пустой sitekey замечен только через несколько секунд, когда API уже ответил ошибкой. | ValidationError — сразу, до сетевого вызова |
Разбор ответа через dict["key"] — риск KeyError на непредвиденном поле. |
Типизированная модель со значениями по умолчанию и валидацией |
| Нет автодополнения IDE для параметров запроса. | Полные подсказки типов по каждому полю |
| Ошибка в base64-строке или URL всплывает только в логе API. | Ошибка возвращается в момент создания объекта запроса |
Ключевая экономия — не в микросекундах самой валидации, а в отменённых сетевых вызовах: каждый пропущенный round-trip к in.php — это несколько секунд ожидания, которые Pydantic позволяет не тратить.
Модели данных для типов CAPTCHA
Каждому поддерживаемому типу CAPTCHA — reCAPTCHA v2/v3, Cloudflare Turnstile, image/OCR — соответствует своя Pydantic-модель с полями и ограничениями, которые CaptchaAI ожидает на in.php. Метод to_params() конвертирует модель в словарь параметров запроса, а SubmitResponse/PollResponse разбирают ответ res.php в типизированный объект.
# models.py
from pydantic import BaseModel, Field, field_validator, HttpUrl
from enum import Enum
from typing import Optional
class CaptchaMethod(str, Enum):
RECAPTCHA_V2 = "userrecaptcha"
RECAPTCHA_V3 = "userrecaptcha" # Differentiated by version field
TURNSTILE = "turnstile"
HCAPTCHA = "hcaptcha"
IMAGE = "base64"
GEETEST = "geetest"
class RecaptchaV2Request(BaseModel):
"""Parameters for solving reCAPTCHA v2."""
sitekey: str = Field(min_length=20, max_length=100, description="Site's reCAPTCHA sitekey")
pageurl: HttpUrl = Field(description="URL where CAPTCHA appears")
invisible: bool = False
cookies: Optional[str] = None
@field_validator("sitekey")
@classmethod
def validate_sitekey(cls, v: str) -> str:
if v.strip() != v:
raise ValueError("Sitekey must not have leading/trailing whitespace")
return v
def to_params(self) -> dict:
params = {
"method": "userrecaptcha",
"googlekey": self.sitekey,
"pageurl": str(self.pageurl),
}
if self.invisible:
params["invisible"] = "1"
if self.cookies:
params["cookies"] = self.cookies
return params
class RecaptchaV3Request(BaseModel):
"""Parameters for solving reCAPTCHA v3."""
sitekey: str = Field(min_length=20, max_length=100)
pageurl: HttpUrl
action: str = Field(default="verify", min_length=1, max_length=100)
def to_params(self) -> dict:
return {
"method": "userrecaptcha",
"version": "v3",
"googlekey": self.sitekey,
"pageurl": str(self.pageurl),
"action": self.action,
}
class TurnstileRequest(BaseModel):
"""Parameters for solving Cloudflare Turnstile."""
sitekey: str = Field(min_length=10, max_length=100)
pageurl: HttpUrl
action: Optional[str] = None
cdata: Optional[str] = None
def to_params(self) -> dict:
params = {
"method": "turnstile",
"sitekey": self.sitekey,
"pageurl": str(self.pageurl),
}
if self.action:
params["action"] = self.action
if self.cdata:
params["data"] = self.cdata
return params
class ImageRequest(BaseModel):
"""Parameters for solving image/text CAPTCHA."""
base64_image: str = Field(min_length=100, description="Base64-encoded image")
case_sensitive: bool = False
min_length: Optional[int] = Field(default=None, ge=1, le=50)
max_length: Optional[int] = Field(default=None, ge=1, le=50)
@field_validator("base64_image")
@classmethod
def validate_base64(cls, v: str) -> str:
# Strip data URI prefix if present
if v.startswith("data:"):
parts = v.split(",", 1)
if len(parts) == 2:
return parts[1]
return v
def to_params(self) -> dict:
params = {
"method": "base64",
"body": self.base64_image,
}
if self.case_sensitive:
params["regsense"] = "1"
if self.min_length is not None:
params["min_len"] = str(self.min_length)
if self.max_length is not None:
params["max_len"] = str(self.max_length)
return params
class SubmitResponse(BaseModel):
"""Parsed API submit response."""
status: int
request: str
@property
def success(self) -> bool:
return self.status == 1
@property
def task_id(self) -> str:
if not self.success:
raise ValueError(f"No task ID — submission failed: {self.request}")
return self.request
class PollResponse(BaseModel):
"""Parsed API poll response."""
status: int
request: str
@property
def ready(self) -> bool:
return self.request != "CAPCHA_NOT_READY"
@property
def success(self) -> bool:
return self.status == 1
@property
def token(self) -> str:
if not self.success:
raise ValueError(f"No token — solve failed: {self.request}")
return self.request
class SolveResult(BaseModel):
"""Result of a successful solve."""
token: str
task_id: str
solve_time: float = Field(description="Solve time in seconds")
Валидатор validate_sitekey в RecaptchaV2Request отсеивает пробелы по краям строки — распространённая причина ERROR_WRONG_CAPTCHA_ID, когда sitekey скопирован из HTML вместе с переносом строки. Похожим образом validate_base64 в ImageRequest сам убирает префикс data:...;base64,, если вы передали изображение как есть из атрибута src.
Класс клиента CaptchaAI
Клиент отвечает за отправку задачи на in.php, опрос res.php с интервалом poll_interval и разбор обоих ответов через модели SubmitResponse/PollResponse. Исключение CaptchaAIError оборачивает код ошибки, который вернул сам API, а ValidationError — исключение Pydantic, которое наступает раньше, ещё при создании объекта запроса.
# client.py
import time
import requests
from pydantic import ValidationError
from models import (
RecaptchaV2Request,
RecaptchaV3Request,
TurnstileRequest,
ImageRequest,
SubmitResponse,
PollResponse,
SolveResult,
)
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"
class CaptchaAIError(Exception):
def __init__(self, code: str, message: str = ""):
self.code = code
super().__init__(f"{code}: {message}" if message else code)
class CaptchaAI:
def __init__(self, api_key: str, poll_interval: int = 5, timeout: int = 180):
if not api_key or len(api_key) < 10:
raise ValueError("Invalid API key")
self.api_key = api_key
self.poll_interval = poll_interval
self.timeout = timeout
def _submit(self, params: dict) -> str:
params["key"] = self.api_key
params["json"] = 1
resp = requests.post(SUBMIT_URL, data=params, timeout=30)
result = SubmitResponse.model_validate(resp.json())
if not result.success:
raise CaptchaAIError(result.request, "Submit failed")
return result.task_id
def _poll(self, task_id: str) -> str:
start = time.monotonic()
while time.monotonic() - start < self.timeout:
time.sleep(self.poll_interval)
resp = requests.get(RESULT_URL, params={
"key": self.api_key,
"action": "get",
"id": task_id,
"json": 1,
}, timeout=15)
result = PollResponse.model_validate(resp.json())
if not result.ready:
continue
if result.success:
return result.token
raise CaptchaAIError(result.request, "Solve failed")
raise CaptchaAIError("TIMEOUT", f"Task {task_id} timed out after {self.timeout}s")
def _solve(self, params: dict) -> SolveResult:
start = time.monotonic()
task_id = self._submit(params)
token = self._poll(task_id)
elapsed = time.monotonic() - start
return SolveResult(
token=token,
task_id=task_id,
solve_time=round(elapsed, 1),
)
def solve_recaptcha_v2(self, sitekey: str, pageurl: str, **kwargs) -> SolveResult:
"""Solve reCAPTCHA v2 with validated parameters."""
req = RecaptchaV2Request(sitekey=sitekey, pageurl=pageurl, **kwargs)
return self._solve(req.to_params())
def solve_recaptcha_v3(self, sitekey: str, pageurl: str, **kwargs) -> SolveResult:
"""Solve reCAPTCHA v3 with validated parameters."""
req = RecaptchaV3Request(sitekey=sitekey, pageurl=pageurl, **kwargs)
return self._solve(req.to_params())
def solve_turnstile(self, sitekey: str, pageurl: str, **kwargs) -> SolveResult:
"""Solve Cloudflare Turnstile with validated parameters."""
req = TurnstileRequest(sitekey=sitekey, pageurl=pageurl, **kwargs)
return self._solve(req.to_params())
def solve_image(self, base64_image: str, **kwargs) -> SolveResult:
"""Solve image/text CAPTCHA with validated parameters."""
req = ImageRequest(base64_image=base64_image, **kwargs)
return self._solve(req.to_params())
def get_balance(self) -> float:
"""Get current account balance."""
resp = requests.get(RESULT_URL, params={
"key": self.api_key,
"action": "getbalance",
"json": 1,
}, timeout=10)
result = SubmitResponse.model_validate(resp.json())
return float(result.request)
Обратите внимание: _solve() — единая точка входа для всех типов CAPTCHA, поэтому логика опроса и обработки ошибок написана один раз, а не дублируется в каждом solve_*-методе.
Пример использования клиента
Ниже — типичный сценарий: успешное решение reCAPTCHA v2, отклонённый Pydantic пустой sitekey и ошибка API при решении Turnstile.
from pydantic import ValidationError
from client import CaptchaAI, CaptchaAIError
client = CaptchaAI("YOUR_API_KEY", timeout=120)
# Valid request — passes validation, calls API
result = client.solve_recaptcha_v2(
sitekey="6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
pageurl="https://staging.example.com/qa-login",
)
print(f"Token: {result.token[:40]}...")
print(f"Solved in {result.solve_time}s")
# Invalid sitekey — caught immediately, no API call
try:
client.solve_recaptcha_v2(sitekey="", pageurl="https://example.com")
except ValidationError as e:
print(e)
# sitekey: String should have at least 20 characters
# Invalid score — caught before API call
try:
client.solve_recaptcha_v3(
sitekey="6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
pageurl="https://example.com",
)
except ValidationError as e:
print(e)
# API error — caught during request
try:
result = client.solve_turnstile(
sitekey="0x4AAAAAAADnPIDROrmt1Wwj",
pageurl="https://example.com",
)
except CaptchaAIError as e:
print(f"API error: {e.code}")
Установите зависимости перед первым запуском:
pip install pydantic requests
Пример из практики: пакетная проверка перед парсингом маркетплейса
Команды, которые парсят карточки товаров или объявления — маркетплейсы, агрегаторы бронирования, сервисы мониторинга цен — обычно запускают десятки воркеров параллельно. Если один воркер отправит пустой pageurl из-за бага в конфиге, без валидации это заметят только через минуту, когда истечёт таймаут res.php. С Pydantic такой воркер падает с ValidationError в момент старта, до того как займёт поток в тарифном плане.
Это особенно ощутимо на потоковом тарифе: план BASIC ($15/мес, 5 потоков) даёт всего 5 одновременных задач, и один воркер, зависший на невалидном запросе, — это пятая часть доступной параллельности впустую. На ADVANCE ($90/мес, 50 потоков) относительные потери ниже, но абсолютные секунды решения, которые уходят на пустые попытки, растут вместе с объёмом. Валидация на входе — простой способ не платить за них.
Если среди собираемых данных встречаются персональные данные пользователей — телефоны, адреса, история заказов, — добавьте отдельную проверку правомерности сбора данных на этапе перед отправкой в CaptchaAI (152-ФЗ «О персональных данных» для юрлиц в РФ, GDPR-style due diligence для трансграничных проектов). Это отдельная проверка от валидации параметров CAPTCHA, но обе стоит выполнять до сетевого вызова, а не после.
Типичные ошибки валидации и их причины
Большинство ValidationError в этом клиенте сводятся к пяти причинам:
| Ошибка | Причина | Что сделать |
|---|---|---|
ValidationError на sitekey, который выглядит нормально |
sitekey короче 20 символов (10 для Turnstile) | Проверьте длину sitekey; при необходимости скорректируйте min_length под конкретный сайт |
ValidationError на pageurl |
В URL нет схемы (https://) |
Добавьте https:// перед адресом страницы |
| Ошибка валидации base64-изображения | Строка короче 100 символов или содержит префикс data: |
Валидатор сам отрезает data:...;base64, — убедитесь, что после него остаётся настоящий base64 |
CaptchaAIError: ERROR_ZERO_BALANCE |
На балансе аккаунта недостаточно средств | Пополните баланс в панели управления CaptchaAI |
ImportError при импорте field_validator |
Установлен Pydantic v1 | Обновитесь до Pydantic v2: pip install 'pydantic>=2.0' |
Частые вопросы
Замедляет ли Pydantic решение CAPTCHA?
Нет — валидация занимает микросекунды, а решение CAPTCHA — секунды. Отменённые сетевые вызовы благодаря ранней проверке параметров экономят больше времени, чем тратит сама валидация.
Как подключить модели к асинхронному клиенту на httpx?
Замените requests на httpx.AsyncClient, а методы _submit, _poll и solve_* сделайте async def. Модели Pydantic не меняются — они проверяют параметры синхронно, до того как выполнится асинхронный HTTP-вызов.
Почему ValidationError не попадает в except CaptchaAIError?
Это два разных исключения на разных этапах. ValidationError — из Pydantic, возникает при создании объекта запроса (RecaptchaV2Request(...)), ещё до сети. CaptchaAIError — из клиента, оборачивает код ошибки, который вернул сам in.php/res.php. В коде, вызывающем solve_*, стоит ловить оба исключения отдельно, как показано в разделе «Пример использования клиента».
Как заранее поймать нехватку баланса, не дожидаясь ERROR_ZERO_BALANCE?
Вызовите client.get_balance() перед батчем задач и сравните результат с ожидаемым расходом — например, объёмом потоков на вашем тарифе. Параметров у этого запроса нет, поэтому Pydantic-модель запроса тут не нужна, но ответ всё равно разбирается через SubmitResponse, так что структура ответа остаётся типизированной.