From 5ed2cfbb3d1fbc2b32ebc7f2906a2e485c3f30de Mon Sep 17 00:00:00 2001 From: protokey Date: Wed, 14 Oct 2026 14:43:16 +0400 Subject: [PATCH] =?UTF-8?q?feature:=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B8=D0=B5=20captcha=20value=3Dimage=20=E2=80=94=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BF=D1=87=D0=B0-=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=202captcha=20(#10?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снимок элемента картинки на текущей странице (без повторной загрузки 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 --- client/README.md | 13 ++++- client/src/robotex/actions.py | 25 +++++++++- docs/API.md | 12 +++++ src/api/actions.py | 9 +++- src/api/schemas.py | 21 +++++++- src/captcha/__init__.py | 1 + src/captcha/base.py | 7 ++- src/captcha/hcaptcha.py | 2 +- src/captcha/image.py | 48 ++++++++++++++++++ src/captcha/recaptcha.py | 2 +- src/captcha/turnstile.py | 2 +- tests/browser/test_e2e.py | 63 ++++++++++++++++++++++++ tests/unit/test_actions.py | 92 +++++++++++++++++++++++++++++++++++ tests/unit/test_client.py | 3 ++ tests/unit/test_schemas.py | 10 ++++ 15 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 src/captcha/image.py diff --git a/client/README.md b/client/README.md index 6b161a3..895cb18 100644 --- a/client/README.md +++ b/client/README.md @@ -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] +``` diff --git a/client/src/robotex/actions.py b/client/src/robotex/actions.py index cb4b071..a4122f3 100644 --- a/client/src/robotex/actions.py +++ b/client/src/robotex/actions.py @@ -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).""" diff --git a/docs/API.md b/docs/API.md index f1fd766..7a9a985 100644 --- a/docs/API.md +++ b/docs/API.md @@ -82,8 +82,20 @@ Cookie: session_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`. Поля без значения не diff --git a/src/api/actions.py b/src/api/actions.py index 6191fe1..a66dcc7 100644 --- a/src/api/actions.py +++ b/src/api/actions.py @@ -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 diff --git a/src/api/schemas.py b/src/api/schemas.py index 5fbe38f..23e4ed9 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -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 diff --git a/src/captcha/__init__.py b/src/captcha/__init__.py index 5b890c5..66cb753 100644 --- a/src/captcha/__init__.py +++ b/src/captcha/__init__.py @@ -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 diff --git a/src/captcha/base.py b/src/captcha/base.py index 4704769..a062fef 100644 --- a/src/captcha/base.py +++ b/src/captcha/base.py @@ -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) diff --git a/src/captcha/hcaptcha.py b/src/captcha/hcaptcha.py index aacb5ba..5be8572 100644 --- a/src/captcha/hcaptcha.py +++ b/src/captcha/hcaptcha.py @@ -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() diff --git a/src/captcha/image.py b/src/captcha/image.py new file mode 100644 index 0000000..b82abc6 --- /dev/null +++ b/src/captcha/image.py @@ -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() + # тег может появиться раньше, чем загрузится картинка; другие элементы (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 diff --git a/src/captcha/recaptcha.py b/src/captcha/recaptcha.py index 1b9fd3a..8049462 100644 --- a/src/captcha/recaptcha.py +++ b/src/captcha/recaptcha.py @@ -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 diff --git a/src/captcha/turnstile.py b/src/captcha/turnstile.py index cb50a77..0d51a0d 100644 --- a/src/captcha/turnstile.py +++ b/src/captcha/turnstile.py @@ -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() diff --git a/tests/browser/test_e2e.py b/tests/browser/test_e2e.py index 94d97e3..5379813 100644 --- a/tests/browser/test_e2e.py +++ b/tests/browser/test_e2e.py @@ -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""" +
+""" + + +@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 diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py index 1c39b56..4855288 100644 --- a/tests/unit/test_actions.py +++ b/tests/unit/test_actions.py @@ -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 == [] diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 19d4a02..43d628e 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -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): diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index adc41fd..ea63d42 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -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):