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:
1 parent
502b6e1003
commit
5ed2cfbb3d
15 files changed
+301
-9
No files matched your search
+7
-2
@@ -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
@@ -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,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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user