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)`, `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"}`. 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 selector: str | None = None
value: str | None = None value: str | None = None
timeout: int | 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]: def to_dict(self) -> dict[str, Any]:
return {k: v for k, v in vars(self).items() if v is not None} 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": def captcha(cls, selector: str, kind: str) -> "Action":
"""Решить капчу через 2captcha: kind — cloudflare, recaptcha или hcaptcha; нужен параметр captcha (ключ).""" """Решить капчу через 2captcha: kind — cloudflare, recaptcha или hcaptcha; нужен параметр captcha (ключ)."""
if kind not in CAPTCHA_TYPES: 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) 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 @classmethod
def pass_challenge(cls) -> "Action": def pass_challenge(cls) -> "Action":
"""Пройти антибот на текущей странице (например, после submit).""" """Пройти антибот на текущей странице (например, после submit)."""
+12
View File
@@ -82,8 +82,20 @@ Cookie: session_id=<id> # необязательно, см. «Сесси
| `delay` | `value` (секунды строкой, 0–600) | пауза | | `delay` | `value` (секунды строкой, 0–600) | пауза |
| `inner_html` | `selector` | возвращает HTML элемента в `result`; нет элемента — ошибка `selector not found` | | `inner_html` | `selector` | возвращает HTML элемента в `result`; нет элемента — ошибка `selector not found` |
| `captcha` | `selector`, `value` (тип: `cloudflare`, `recaptcha`, `hcaptcha`) | решить капчу внутри страницы через 2captcha, нужен `captcha` в запросе | | `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` (локатор успеха, необязательно) | повторно пройти антибот, например после перехода на другую страницу; если не пройден — ошибка действия | | `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`. Поля без значения не Формат один для всех случаев: `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.humanize import typing as human_typing
from antibot.orchestrator import pass_challenges from antibot.orchestrator import pass_challenges
from captcha import load_captcha from captcha import load_captcha
from api.schemas import ActionRequest from api.schemas import ActionRequest, CAPTCHA_IMAGE_HINTS
_actions: dict = {} _actions: dict = {}
@@ -42,7 +42,12 @@ async def typing(page: Page, act: ActionRequest, **kwargs) -> str | None:
@register @register
async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None: async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None:
captcha_solver = load_captcha(captcha_key, act.value) 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 @register
+20 -1
View File
@@ -3,7 +3,9 @@ from urllib.parse import urlsplit
from pydantic import BaseModel, Field, field_validator, model_validator 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) # максимум browser_idle_timeout в минутах: открытое окно держит слот браузера (#78)
MAX_BROWSER_IDLE_TIMEOUT = 120 MAX_BROWSER_IDLE_TIMEOUT = 120
PROXY_SCHEMES = ("http", "https", "socks5") PROXY_SCHEMES = ("http", "https", "socks5")
@@ -36,6 +38,14 @@ class ActionRequest(BaseModel):
selector: str | None = None selector: str | None = None
value: str | None = None value: str | None = None
timeout: int | 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") @model_validator(mode="after")
def check_fields(self): def check_fields(self):
@@ -60,6 +70,15 @@ class ActionRequest(BaseModel):
raise ValueError("delay value must be between 0 and 600 seconds") raise ValueError("delay value must be between 0 and 600 seconds")
elif self.type == "captcha" and self.value not in CAPTCHA_TYPES: elif self.type == "captcha" and self.value not in CAPTCHA_TYPES:
raise ValueError(f"captcha value must be one of: {', '.join(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 return self
+1
View File
@@ -1,4 +1,5 @@
from .hcaptcha import SolverHCaptcha from .hcaptcha import SolverHCaptcha
from .recaptcha import SolverReCaptcha from .recaptcha import SolverReCaptcha
from .turnstile import SolverTurnstile from .turnstile import SolverTurnstile
from .image import SolverImage
from .base import load_captcha from .base import load_captcha
+6 -1
View File
@@ -14,7 +14,8 @@ class Captcha:
def get_balance(self) -> float: def get_balance(self) -> float:
return self._solver.balance() 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() raise NotImplementedError()
async def _run(self, fun, /, *args, **kwargs): async def _run(self, fun, /, *args, **kwargs):
@@ -29,6 +30,10 @@ class Captcha:
resp = await self._run(self._solver.recaptcha, site_key, url) resp = await self._run(self._solver.recaptcha, site_key, url)
return resp 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): async def turnstile(self, url, site_key):
# TODO: not finished! # TODO: not finished!
resp = await self._run(self._solver.turnstile, site_key, url) 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): class SolverHCaptcha(Captcha):
ctype = "hcaptcha" ctype = "hcaptcha"
async def solve(self, selector, page: Page): async def solve(self, selector, page: Page, **options):
# проверка что элемент с капчей есть # проверка что элемент с капчей есть
div_container = page.locator(selector) div_container = page.locator(selector)
await div_container.wait_for() 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): class SolverReCaptcha(Captcha):
ctype = "recaptcha" ctype = "recaptcha"
async def solve(self, selector, page: Page): async def solve(self, selector, page: Page, **options):
""" """
#recaptcha-token #recaptcha-token
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
class SolverTurnstile(Captcha): class SolverTurnstile(Captcha):
ctype = "cloudflare" ctype = "cloudflare"
async def solve(self, selector, page: Page): async def solve(self, selector, page: Page, **options):
container = page.locator(selector) container = page.locator(selector)
await container.wait_for() await container.wait_for()
+63
View File
@@ -221,3 +221,66 @@ def test_typing_like_a_human(client):
assert data["status"] == "ok", data assert data["status"] == "ok", data
assert json.loads(data["actions"][-1]["result"])["form"]["custname"] == "Иван Smith-2" assert json.loads(data["actions"][-1]["result"])["form"]["custname"] == "Иван Smith-2"
assert data["actions"][1]["duration_ms"] > 12 * 50 # посимвольно, с паузами 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 на поддельной странице: порядок, остановка на ошибке, антибот, длительность.""" """api.actions.execute на поддельной странице: порядок, остановка на ошибке, антибот, длительность."""
import base64
import pytest import pytest
from antibot.orchestrator import ChallengeOutcome 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") result = await actions.execute(FakePage(), acts({"type": "pass_challenge"}), antibot_name="cloudflare")
assert result[0]["ok"] and result[0]["result"] == "passed" assert result[0]["ok"] and result[0]["result"] == "passed"
assert challenge["calls"] == 2 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"} assert Action.pass_challenge().to_dict() == {"type": "pass_challenge"}
with pytest.raises(ValueError): with pytest.raises(ValueError):
Action.captcha("#c", "funcaptcha") 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): 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": 0}, "between 1 and 7200"),
({"type": "wait_for", "selector": "a", "timeout": 7201}, "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": "#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): def test_invalid_action(action, error):
with pytest.raises(ValidationError, match=error): with pytest.raises(ValidationError, match=error):
@@ -35,6 +42,9 @@ def test_invalid_action(action, error):
{"type": "delay", "value": "0"}, {"type": "delay", "value": "0"},
{"type": "pass_challenge"}, {"type": "pass_challenge"},
{"type": "captcha", "selector": "#c", "value": "hcaptcha"}, {"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"}, {"type": "inner_html", "selector": "h1"},
]) ])
def test_valid_action(action): def test_valid_action(action):