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

+12 -1
View File
@@ -100,5 +100,16 @@ async def main():
## Действия
`Action.fill`, `typing`, `click`, `submit`, `mouse_move`, `delay(seconds)`, `wait_for(selector, timeout=None)`,
`inner_html`, `inner_text`, `captcha(selector, kind)`, `pass_challenge()`. Вместо `Action` можно передать dict в формате
`inner_html`, `inner_text`, `captcha(selector, kind)`, `captcha_image(selector, target, **hints)`, `pass_challenge()`. Вместо `Action` можно передать dict в формате
API: `{"type": "click", "selector": "#a"}`.
Капча-картинка («код с картинки»): снимок элемента уходит в 2captcha, ответ набирается в поле и возвращается
в `result`. Подсказки: `min_len`, `max_len`, `numeric` (1 — цифры, 2 — буквы), `lang`, `case_sensitive`.
```python
res = s.run(actions=[
Action.captcha_image("#popup-signup img.captcha", '#popup-signup input[name="captcha"]', min_len=4),
Action.submit("#popup-signup button[type=submit]"),
], captcha="<ключ 2captcha>")
code = res.results[0]
```
+24 -1
View File
@@ -11,6 +11,13 @@ class Action:
selector: str | None = None
value: str | None = None
timeout: int | None = None
# только для captcha_image
target: str | None = None
min_len: int | None = None
max_len: int | None = None
numeric: int | None = None
lang: str | None = None
case_sensitive: bool | None = None
def to_dict(self) -> dict[str, Any]:
return {k: v for k, v in vars(self).items() if v is not None}
@@ -60,9 +67,25 @@ class Action:
def captcha(cls, selector: str, kind: str) -> "Action":
"""Решить капчу через 2captcha: kind — cloudflare, recaptcha или hcaptcha; нужен параметр captcha (ключ)."""
if kind not in CAPTCHA_TYPES:
raise ValueError(f"captcha kind must be one of: {', '.join(CAPTCHA_TYPES)}")
raise ValueError(f"captcha kind must be one of: {', '.join(CAPTCHA_TYPES)} (image: use captcha_image)")
return cls("captcha", selector, kind)
@classmethod
def captcha_image(
cls, selector: str, target: str, *,
min_len: int | None = None, max_len: int | None = None, numeric: int | None = None,
lang: str | None = None, case_sensitive: bool | None = None,
) -> "Action":
"""Капча-картинка: снимок элемента selector уходит в 2captcha, ответ набирается в поле target
и возвращается в result; нужен параметр captcha (ключ).
Подсказки 2captcha: min_len/max_len — 1–20 символов; numeric — 1 только цифры, 2 только буквы,
3 цифры или буквы, 4 и цифры, и буквы; lang — код языка; case_sensitive — учитывать регистр."""
return cls(
"captcha", selector, "image", target=target, min_len=min_len, max_len=max_len,
numeric=numeric, lang=lang, case_sensitive=case_sensitive,
)
@classmethod
def pass_challenge(cls) -> "Action":
"""Пройти антибот на текущей странице (например, после submit)."""
+12
View File
@@ -82,8 +82,20 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
| `delay` | `value` (секунды строкой, 0–600) | пауза |
| `inner_html` | `selector` | возвращает HTML элемента в `result`; нет элемента — ошибка `selector not found` |
| `captcha` | `selector`, `value` (тип: `cloudflare`, `recaptcha`, `hcaptcha`) | решить капчу внутри страницы через 2captcha, нужен `captcha` в запросе |
| `captcha` (`value: "image"`) | `selector` (картинка), `target` (поле ввода); подсказки необязательны: `min_len`, `max_len` (1–20), `numeric`, `lang`, `case_sensitive` | капча-картинка «код с картинки»: скриншот элемента `selector` на текущей странице (без повторной загрузки, код не меняется) уходит в 2captcha, ответ набирается в `target` как у `typing` и возвращается в `result`. Не решена или таймаут 2captcha — ошибка действия. `numeric`: `1` — только цифры, `2` — только буквы, `3` — цифры или буквы, `4` — и цифры, и буквы |
| `pass_challenge` | `selector` (локатор успеха, необязательно) | повторно пройти антибот, например после перехода на другую страницу; если не пройден — ошибка действия |
Капча-картинка в форме регистрации — решить, затем отправить форму. Если сайт отклонил код и показал новую картинку,
повторите задачу v2 в открытом окне сессии (`browser_idle_timeout`): страница не перезагружается.
```json
{"captcha": "<ключ 2captcha>", "browser_idle_timeout": 5, "actions": [
{"type": "captcha", "selector": "#popup-signup img.captcha", "value": "image",
"target": "#popup-signup input[name=\"captcha\"]", "min_len": 4, "max_len": 6},
{"type": "submit", "selector": "#popup-signup button[type=submit]"}
]}
```
## Ответ
Формат один для всех случаев: `status` — `"ok"` или `"err"`, при ошибке — `code` и `error`. Поля без значения не
+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()
+63
View File
@@ -221,3 +221,66 @@ def test_typing_like_a_human(client):
assert data["status"] == "ok", data
assert json.loads(data["actions"][-1]["result"])["form"]["custname"] == "Иван Smith-2"
assert data["actions"][1]["duration_ms"] > 12 * 50 # посимвольно, с паузами
# капча-картинка (#109): картинка рисуется на canvas при загрузке, поэтому каждый новый запрос src дал бы другой код
CAPTCHA_PAGE = b"""<!doctype html><body>
<form id="signup"><img class="captcha"><input name="captcha" autocomplete="off"><div id="sent"></div></form>
<script>
const c = document.createElement('canvas'); c.width = 120; c.height = 40;
const x = c.getContext('2d'); x.fillStyle = '#fff'; x.fillRect(0, 0, 120, 40);
x.fillStyle = '#000'; x.font = '24px monospace'; x.fillText('k' + Math.random().toString(36).slice(2, 6), 8, 28);
document.querySelector('img.captcha').src = c.toDataURL();
document.querySelector('input').addEventListener('input', e => document.getElementById('sent').textContent = e.target.value);
</script></body>"""
@pytest.fixture
def captcha_site():
"""Страница с капчей-картинкой на локальном HTTP-сервере."""
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(CAPTCHA_PAGE)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}/signup"
server.shutdown()
def test_captcha_image(client, captcha_site, monkeypatch):
"""Снимок элемента (PNG размером с картинку) уходит в 2captcha, ответ набирается в поле."""
import base64
import struct
from twocaptcha import TwoCaptcha
sent = {}
def fake_solve(self, **kwargs):
sent.update(kwargs)
return {"captchaId": "1", "code": "k42z"}
monkeypatch.setattr(TwoCaptcha, "solve", fake_solve)
data = solve(client, url=captcha_site, captcha="KEY", actions=[
{"type": "captcha", "selector": "img.captcha", "value": "image", "target": "input[name=captcha]", "max_len": 6},
{"type": "inner_text", "selector": "#sent"},
])
assert data["status"] == "ok", data
assert [a.get("result") for a in data["actions"]] == ["k42z", "k42z"]
png = base64.b64decode(sent["body"])
assert png.startswith(b"\x89PNG")
width, height = struct.unpack(">II", png[16:24])
assert (width, height) == (120, 40)
assert sent["method"] == "base64" and sent["maxLen"] == 6
+92
View File
@@ -1,4 +1,6 @@
"""api.actions.execute на поддельной странице: порядок, остановка на ошибке, антибот, длительность."""
import base64
import pytest
from antibot.orchestrator import ChallengeOutcome
@@ -113,3 +115,93 @@ async def test_pass_challenge_action(challenge):
result = await actions.execute(FakePage(), acts({"type": "pass_challenge"}), antibot_name="cloudflare")
assert result[0]["ok"] and result[0]["result"] == "passed"
assert challenge["calls"] == 2
class FakeImage:
"""Locator картинки капчи: скриншот элемента — заранее заданные байты."""
def __init__(self, png):
self.png = png
self.waited = False
@property
def first(self):
return self
async def wait_for(self):
self.waited = True
async def evaluate(self, script):
return True
async def screenshot(self, type):
return self.png
class ImagePage(FakePage):
def __init__(self, image):
super().__init__()
self.image = image
self.locators = []
def locator(self, selector):
self.locators.append(selector)
return self.image
async def test_captcha_image(challenge, monkeypatch):
"""Снимок элемента уходит в 2captcha base64 с подсказками, ответ набирается в target и попадает в result."""
from captcha import image as image_mod
from twocaptcha import TwoCaptcha
sent, typed = {}, {}
def fake_solve(self, **kwargs):
sent.update(kwargs)
return {"captchaId": "1", "code": "x7Kp2"}
async def fake_typing(page, locator, text):
typed[locator] = text
monkeypatch.setattr(TwoCaptcha, "solve", fake_solve)
monkeypatch.setattr(image_mod, "human_typing", fake_typing)
page = ImagePage(FakeImage(b"\x89PNG-captcha"))
result = await actions.execute(page, acts(
{"type": "captcha", "selector": "#popup-signup img.captcha", "value": "image",
"target": "#popup-signup input[name=\"captcha\"]", "min_len": 5, "numeric": 4, "case_sensitive": True},
), captcha_key="KEY")
assert [(r["ok"], r.get("result")) for r in result] == [(True, "x7Kp2")]
assert page.locators == ["#popup-signup img.captcha"]
assert page.image.waited
assert sent == {
"method": "base64", "body": base64.b64encode(b"\x89PNG-captcha").decode(),
"minLen": 5, "numeric": 4, "caseSensitive": 1,
}
assert typed == {"#popup-signup input[name=\"captcha\"]": "x7Kp2"}
async def test_captcha_image_unsolved(challenge, monkeypatch):
"""2captcha не решила — действие падает, в target ничего не вводится."""
from captcha import image as image_mod
from twocaptcha import TwoCaptcha
from twocaptcha.solver import ApiException
def fake_solve(self, **kwargs):
raise ApiException("ERROR_CAPTCHA_UNSOLVABLE")
typed = []
async def fake_typing(page, locator, text):
typed.append(text)
monkeypatch.setattr(TwoCaptcha, "solve", fake_solve)
monkeypatch.setattr(image_mod, "human_typing", fake_typing)
result = await actions.execute(ImagePage(FakeImage(b"png")), acts(
{"type": "captcha", "selector": "img", "value": "image", "target": "#in"},
), captcha_key="KEY")
assert [(r["ok"], r["result"]) for r in result] == [(False, "ERROR_CAPTCHA_UNSOLVABLE")]
assert typed == []
+3
View File
@@ -38,6 +38,9 @@ def test_action_builders():
assert Action.pass_challenge().to_dict() == {"type": "pass_challenge"}
with pytest.raises(ValueError):
Action.captcha("#c", "funcaptcha")
assert Action.captcha_image("img", "#in", numeric=1, case_sensitive=False).to_dict() == {
"type": "captcha", "selector": "img", "value": "image", "target": "#in", "numeric": 1, "case_sensitive": False,
}
def test_settings_from_env(monkeypatch):
+10
View File
@@ -21,6 +21,13 @@ def solve(**kwargs) -> SolveRequest:
({"type": "wait_for", "selector": "a", "timeout": 0}, "between 1 and 7200"),
({"type": "wait_for", "selector": "a", "timeout": 7201}, "between 1 and 7200"),
({"type": "captcha", "selector": "#c", "value": "datadome"}, "captcha value must be one of"),
({"type": "captcha", "selector": "img", "value": "image"}, "requires: target"),
({"type": "captcha", "selector": "#c", "value": "hcaptcha", "target": "#in"}, "only for action 'captcha'"),
({"type": "typing", "selector": "#q", "value": "x", "numeric": 1}, "only for action 'captcha'"),
({"type": "captcha", "selector": "img", "value": "image", "target": "#in", "numeric": 5}, "numeric"),
({"type": "captcha", "selector": "img", "value": "image", "target": "#in", "max_len": 21}, "max_len"),
({"type": "captcha", "selector": "img", "value": "image", "target": "#in", "min_len": 6, "max_len": 4},
"min_len must not exceed max_len"),
])
def test_invalid_action(action, error):
with pytest.raises(ValidationError, match=error):
@@ -35,6 +42,9 @@ def test_invalid_action(action, error):
{"type": "delay", "value": "0"},
{"type": "pass_challenge"},
{"type": "captcha", "selector": "#c", "value": "hcaptcha"},
{"type": "captcha", "selector": "img", "value": "image", "target": "#in"},
{"type": "captcha", "selector": "img", "value": "image", "target": "#in",
"min_len": 4, "max_len": 6, "numeric": 1, "lang": "en", "case_sensitive": True},
{"type": "inner_html", "selector": "h1"},
])
def test_valid_action(action):