feature: действие captcha value=image — капча-картинка через 2captcha (#109)

Снимок элемента картинки на текущей странице (без повторной загрузки src) уходит
в 2captcha как обычная капча (method=base64), ответ набирается в поле target
как у typing и возвращается в result действия. Необязательные подсказки:
min_len, max_len, numeric, lang, case_sensitive. Клиент: Action.captcha_image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-14 16:17:25 +04:00
1 parent 502b6e1003
commit 5ed2cfbb3d
15 files changed
+301 -9

No files matched your search

+7 -2
View File
@@ -7,7 +7,7 @@ from patchright.async_api import Page
from antibot.humanize import typing as human_typing
from antibot.orchestrator import pass_challenges
from captcha import load_captcha
from api.schemas import ActionRequest
from api.schemas import ActionRequest, CAPTCHA_IMAGE_HINTS
_actions: dict = {}
@@ -42,7 +42,12 @@ async def typing(page: Page, act: ActionRequest, **kwargs) -> str | None:
@register
async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None:
captcha_solver = load_captcha(captcha_key, act.value)
await captcha_solver.solve(act.selector, page)
if act.value != "image":
await captcha_solver.solve(act.selector, page)
return None
# у картинки распознанный текст короткий и нужен клиенту — возвращаем в result (#109)
hints = act.model_dump(include=set(CAPTCHA_IMAGE_HINTS), exclude_none=True)
return await captcha_solver.solve(act.selector, page, target=act.target, **hints)
@register
+20 -1
View File
@@ -3,7 +3,9 @@ from urllib.parse import urlsplit
from pydantic import BaseModel, Field, field_validator, model_validator
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha")
CAPTCHA_TYPES = ("cloudflare", "recaptcha", "hcaptcha", "image")
# подсказки 2captcha для капчи-картинки (#109): разрешены только с captcha value=image
CAPTCHA_IMAGE_HINTS = ("min_len", "max_len", "numeric", "lang", "case_sensitive")
# максимум browser_idle_timeout в минутах: открытое окно держит слот браузера (#78)
MAX_BROWSER_IDLE_TIMEOUT = 120
PROXY_SCHEMES = ("http", "https", "socks5")
@@ -36,6 +38,14 @@ class ActionRequest(BaseModel):
selector: str | None = None
value: str | None = None
timeout: int | None = None
# captcha value=image: поле, куда вводится распознанный текст, и подсказки для 2captcha
target: str | None = None
min_len: int | None = Field(None, ge=1, le=20)
max_len: int | None = Field(None, ge=1, le=20)
# 1 — только цифры, 2 — только буквы, 3 — цифры или буквы, 4 — и цифры, и буквы
numeric: int | None = Field(None, ge=0, le=4)
lang: str | None = None
case_sensitive: bool | None = None
@model_validator(mode="after")
def check_fields(self):
@@ -60,6 +70,15 @@ class ActionRequest(BaseModel):
raise ValueError("delay value must be between 0 and 600 seconds")
elif self.type == "captcha" and self.value not in CAPTCHA_TYPES:
raise ValueError(f"captcha value must be one of: {', '.join(CAPTCHA_TYPES)}")
is_image = self.type == "captcha" and self.value == "image"
if is_image and not self.target:
raise ValueError("captcha 'image' requires: target")
extra = [f for f in ("target", *CAPTCHA_IMAGE_HINTS) if getattr(self, f) is not None]
if extra and not is_image:
raise ValueError(f"{', '.join(extra)}: only for action 'captcha' with value 'image'")
if self.min_len and self.max_len and self.min_len > self.max_len:
raise ValueError("min_len must not exceed max_len")
return self
+1
View File
@@ -1,4 +1,5 @@
from .hcaptcha import SolverHCaptcha
from .recaptcha import SolverReCaptcha
from .turnstile import SolverTurnstile
from .image import SolverImage
from .base import load_captcha
+6 -1
View File
@@ -14,7 +14,8 @@ class Captcha:
def get_balance(self) -> float:
return self._solver.balance()
async def solve(self, selector: str, page: Page) -> str:
async def solve(self, selector: str, page: Page, **options) -> str:
"""options — поля действия, нужные конкретному типу (например, target у image)."""
raise NotImplementedError()
async def _run(self, fun, /, *args, **kwargs):
@@ -29,6 +30,10 @@ class Captcha:
resp = await self._run(self._solver.recaptcha, site_key, url)
return resp
async def image(self, body: str, **params):
resp = await self._run(self._solver.solve, method="base64", body=body, **params)
return resp
async def turnstile(self, url, site_key):
# TODO: not finished!
resp = await self._run(self._solver.turnstile, site_key, url)
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
class SolverHCaptcha(Captcha):
ctype = "hcaptcha"
async def solve(self, selector, page: Page):
async def solve(self, selector, page: Page, **options):
# проверка что элемент с капчей есть
div_container = page.locator(selector)
await div_container.wait_for()
+48
View File
@@ -0,0 +1,48 @@
"""
Капча-картинка: изображение с текстом и поле ввода (#109).
Картинку генерирует сайт в сессии браузера, и повторная загрузка по src меняет код,
поэтому снимаем скриншот самого элемента на текущей странице и отправляем его
в 2captcha как обычную капчу (method=base64). Ответ вводится в поле по-человечески.
"""
import base64
import logging
from antibot.humanize import typing as human_typing
from .base import Captcha, Page
logger = logging.getLogger(__name__)
# имя поля запроса -> параметр 2captcha
_HINTS = {
"min_len": "minLen",
"max_len": "maxLen",
"numeric": "numeric",
"lang": "lang",
"case_sensitive": "caseSensitive",
}
class SolverImage(Captcha):
ctype = "image"
async def solve(self, selector, page: Page, *, target: str, **hints):
image = page.locator(selector).first
await image.wait_for()
# тег <img> может появиться раньше, чем загрузится картинка; другие элементы (canvas, div с фоном) снимаем как есть
await image.evaluate(
"el => el.tagName !== 'IMG' || el.complete || new Promise(r => {"
"el.addEventListener('load', r, {once: true}); el.addEventListener('error', r, {once: true});"
"})"
)
body = base64.b64encode(await image.screenshot(type="png")).decode()
params = {
_HINTS[k]: int(v) if isinstance(v, bool) else v
for k, v in hints.items() if k in _HINTS and v is not None
}
text = await self.image(body, **params)
logger.debug("image captcha solved: %r", text)
await human_typing(page, target, text)
return text
+1 -1
View File
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
class SolverReCaptcha(Captcha):
ctype = "recaptcha"
async def solve(self, selector, page: Page):
async def solve(self, selector, page: Page, **options):
"""
#recaptcha-token
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
class SolverTurnstile(Captcha):
ctype = "cloudflare"
async def solve(self, selector, page: Page):
async def solve(self, selector, page: Page, **options):
container = page.locator(selector)
await container.wait_for()