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

+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):