Снимок элемента картинки на текущей странице (без повторной загрузки 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>
208 lines
6.6 KiB
Python
208 lines
6.6 KiB
Python
"""api.actions.execute на поддельной странице: порядок, остановка на ошибке, антибот, длительность."""
|
|
import base64
|
|
|
|
import pytest
|
|
|
|
from antibot.orchestrator import ChallengeOutcome
|
|
from antibot.base import Stage
|
|
from api import actions
|
|
from api.schemas import ActionRequest
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
class FakeElement:
|
|
def __init__(self, html):
|
|
self.html = html
|
|
self.hovered = False
|
|
|
|
async def inner_html(self):
|
|
return self.html
|
|
|
|
async def hover(self, **kwargs):
|
|
self.hovered = True
|
|
|
|
|
|
class FakePage:
|
|
def __init__(self, elements=None):
|
|
self.elements = elements or {}
|
|
self.filled = {}
|
|
|
|
async def query_selector(self, selector):
|
|
return self.elements.get(selector)
|
|
|
|
async def wait_for_selector(self, selector, timeout):
|
|
if selector not in self.elements:
|
|
raise TimeoutError(f"Timeout {timeout}ms exceeded")
|
|
|
|
async def fill(self, selector, value):
|
|
self.filled[selector] = value
|
|
|
|
|
|
@pytest.fixture
|
|
def challenge(monkeypatch):
|
|
"""Подмена прохождения антибота; outcome можно поменять в тесте."""
|
|
state = {"outcome": ChallengeOutcome(True, reason="antibot not requested"), "calls": 0}
|
|
|
|
async def fake(*args, **kwargs):
|
|
state["calls"] += 1
|
|
return state["outcome"]
|
|
|
|
monkeypatch.setattr(actions, "pass_challenges", fake)
|
|
return state
|
|
|
|
|
|
def acts(*items):
|
|
return [ActionRequest(**a) for a in items]
|
|
|
|
|
|
async def test_actions_run_in_order(challenge):
|
|
page = FakePage({"h1": FakeElement("Title"), "a": FakeElement("link")})
|
|
result = await actions.execute(page, acts(
|
|
{"type": "wait_for", "selector": "h1", "timeout": 1},
|
|
{"type": "fill", "selector": "#q", "value": "text"},
|
|
{"type": "mouse_move", "selector": "a"},
|
|
{"type": "inner_html", "selector": "h1"},
|
|
))
|
|
assert [(r["type"], r["ok"], r.get("result")) for r in result] == [
|
|
("wait_for", True, None), ("fill", True, None), ("mouse_move", True, None), ("inner_html", True, "Title"),
|
|
]
|
|
assert all(r["duration_ms"] >= 0 for r in result)
|
|
assert page.filled == {"#q": "text"}
|
|
assert page.elements["a"].hovered
|
|
|
|
|
|
async def test_stops_on_first_failure(challenge):
|
|
page = FakePage({"h1": FakeElement("Title")})
|
|
result = await actions.execute(page, acts(
|
|
{"type": "inner_html", "selector": "h1"},
|
|
{"type": "inner_html", "selector": "#missing"},
|
|
{"type": "inner_html", "selector": "h1"},
|
|
))
|
|
assert [r["ok"] for r in result] == [True, False]
|
|
assert result[1]["result"] == "selector not found: #missing"
|
|
|
|
|
|
@pytest.mark.parametrize("action", [
|
|
{"type": "mouse_move", "selector": "#missing"},
|
|
{"type": "inner_html", "selector": "#missing"},
|
|
])
|
|
async def test_selector_not_found(challenge, action):
|
|
result = await actions.execute(FakePage(), acts(action))
|
|
assert result == [{"type": action["type"], "ok": False, "result": "selector not found: #missing", "duration_ms": result[0]["duration_ms"]}]
|
|
|
|
|
|
async def test_wait_for_timeout_is_failure(challenge):
|
|
result = await actions.execute(FakePage(), acts({"type": "wait_for", "selector": "#x", "timeout": 2}))
|
|
assert result[0]["ok"] is False
|
|
assert "2000ms" in result[0]["result"]
|
|
|
|
|
|
async def test_empty_actions(challenge):
|
|
assert await actions.execute(FakePage(), []) == []
|
|
|
|
|
|
async def test_challenge_failed_skips_actions(challenge):
|
|
challenge["outcome"] = ChallengeOutcome(False, Stage.CHECKBOX, "cloudflare", "timeout")
|
|
page = FakePage()
|
|
with pytest.raises(actions.ChallengeFailed, match=r"cloudflare not passed: timeout \(stage: checkbox\)"):
|
|
await actions.execute(page, acts({"type": "fill", "selector": "#q", "value": "x"}), antibot_name="cloudflare")
|
|
assert page.filled == {}
|
|
|
|
|
|
async def test_pass_challenge_action(challenge):
|
|
challenge["outcome"] = ChallengeOutcome(True, reason="passed")
|
|
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 == []
|