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:
1 parent
502b6e1003
commit
5ed2cfbb3d
15 files changed
+301
-9
No files matched your search
+12
-1
@@ -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]
|
||||
```
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user