diff --git a/docs/API.md b/docs/API.md index 1c9be4f..8abfb37 100644 --- a/docs/API.md +++ b/docs/API.md @@ -69,7 +69,9 @@ Cookie: session_id= # необязательно, см. «Сесси | `wait_for` | `selector`, `timeout` (1–7200 с, по умолчанию 30) | ждёт появления элемента; при таймауте — ошибка действия | | `click` | `selector` | клик по элементу | | `submit` | `selector` | клик и ожидание навигации | -| `fill` | `selector`, `value` | вставка значения в поле | +| `fill` | `selector`, `value` | мгновенная вставка значения в поле | +| `typing` | `selector`, `value` | ввод как у человека: подвод курсора, клик, очистка поля, посимвольный набор с паузами 50–200 мс; медленнее `fill`, но не выдаёт автоматизацию | +| `inner_text` | `selector` | возвращает текст элемента (HTML без тегов) в `result` | | `mouse_move` | `selector` | наведение курсора на элемент; нет элемента — ошибка `selector not found` | | `delay` | `value` (секунды строкой, 0–600) | пауза | | `inner_html` | `selector` | возвращает HTML элемента в `result`; нет элемента — ошибка `selector not found` | diff --git a/src/antibot/humanize.py b/src/antibot/humanize.py index db06c5d..40b314c 100644 --- a/src/antibot/humanize.py +++ b/src/antibot/humanize.py @@ -124,28 +124,48 @@ async def wait_for_page_stability(page: Page, load_dom: bool, network_idle: bool await wait_for_networkidle(page) -async def typing(page: Page, locator: str, text: str): - element = page.locator(locator) +def _key_delay_ms(char: str) -> float: + """Пауза после символа: строчные латинские набираются быстрее всего, остальное — медленнее.""" + if char in string.ascii_lowercase: + delays = (50, 100) + elif char in string.ascii_uppercase or char in string.digits: + delays = (100, 150) + elif char in string.punctuation: + delays = (100, 200) + else: + # кириллица и прочие символы, пробел + delays = (60, 160) + return random.uniform(*delays) + + +async def typing(page: Page, locator: str, text: str) -> None: + """Ввод как у человека: курсор подводится к полю по кривой, клик в случайную точку поля, + старое значение стирается, текст набирается посимвольно с паузами.""" + element = page.locator(locator).first await element.scroll_into_view_if_needed() - assert await element.is_visible() - assert await element.is_enabled() - assert await element.is_editable() - await element.focus() - await element.click() + if not await element.is_visible(): + raise ValueError(f"element is not visible: {locator}") + if not await element.is_editable(): + raise ValueError(f"element is not editable: {locator}") + + box = await element.bounding_box() + if box is None: + raise ValueError(f"element has no box: {locator}") + await click_human( + page, + box["x"] + box["width"] * random.uniform(0.2, 0.8), + box["y"] + box["height"] * random.uniform(0.3, 0.7), + ) + + if await element.input_value(): + await page.keyboard.press("ControlOrMeta+A") + await page.wait_for_timeout(random.uniform(80, 200)) + await page.keyboard.press("Backspace") + await page.wait_for_timeout(random.uniform(100, 250)) for char in text: - await page.keyboard.press(char) - - if char in string.ascii_lowercase: - delays = (50, 100) - elif char in string.ascii_uppercase: - delays = (100, 150) - elif char in string.digits: - delays = (100, 150) - elif char in string.punctuation: - delays = (100, 200) - else: - raise SystemError("character not supported: {}".format(char)) - - await page.wait_for_timeout(random.uniform(*delays)) + # keyboard.type даёт keydown/keypress/keyup для клавиш раскладки и insertText для остальных + # символов (кириллица и т.п.); press() на них падал + await page.keyboard.type(char) + await page.wait_for_timeout(_key_delay_ms(char)) diff --git a/src/api/actions.py b/src/api/actions.py index e67993d..6191fe1 100644 --- a/src/api/actions.py +++ b/src/api/actions.py @@ -4,6 +4,7 @@ import time 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 @@ -32,6 +33,12 @@ async def fill(page: Page, act: ActionRequest, **kwargs) -> str | None: await page.fill(act.selector, act.value) +@register +async def typing(page: Page, act: ActionRequest, **kwargs) -> str | None: + """В отличие от fill — как человек: подвод мыши, клик, посимвольный ввод с паузами (#14).""" + await human_typing(page, act.selector, act.value) + + @register async def captcha(page: Page, act: ActionRequest, captcha_key: str, **kwargs) -> str | None: captcha_solver = load_captcha(captcha_key, act.value) diff --git a/src/api/logs.py b/src/api/logs.py index 752d2b7..184fed3 100644 --- a/src/api/logs.py +++ b/src/api/logs.py @@ -21,7 +21,7 @@ def safe_request_dump(payload: SolveRequest) -> str: if payload.cookies: data["cookies"] = [c.get("name") for c in payload.cookies] for act in data["actions"]: - if act["type"] == "fill" and "value" in act: + if act["type"] in ("fill", "typing") and "value" in act: act["value"] = "***" return json.dumps(data, indent=2, ensure_ascii=False) diff --git a/src/api/schemas.py b/src/api/schemas.py index c68a89b..5fbe38f 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -11,6 +11,7 @@ PROXY_SCHEMES = ("http", "https", "socks5") # тип действия -> обязательные поля (кроме type) _REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { "fill": ("selector", "value"), + "typing": ("selector", "value"), "captcha": ("selector", "value"), "delay": ("value",), "mouse_move": ("selector",), diff --git a/tests/browser/test_e2e.py b/tests/browser/test_e2e.py index b4b890c..2e31b26 100644 --- a/tests/browser/test_e2e.py +++ b/tests/browser/test_e2e.py @@ -170,3 +170,16 @@ def test_window_held_between_tasks(env): assert client.delete("/v2/sessions/current").json() == {"closed": True} assert env.pool.used == 0 + + +def test_typing_like_a_human(client): + """typing: клик по полю и посимвольный ввод, включая кириллицу; поле сначала очищается.""" + data = solve(client, url="https://httpbin.org/forms/post", actions=[ + {"type": "fill", "selector": "input[name=custname]", "value": "old value"}, + {"type": "typing", "selector": "input[name=custname]", "value": "Иван Smith-2"}, + {"type": "submit", "selector": "form button"}, + {"type": "inner_text", "selector": "body"}, + ]) + assert data["status"] == "ok", data + assert json.loads(data["actions"][-1]["result"])["form"]["custname"] == "Иван Smith-2" + assert data["actions"][1]["duration_ms"] > 12 * 50 # посимвольно, с паузами diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 96ef0bc..adc41fd 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -13,6 +13,7 @@ def solve(**kwargs) -> SolveRequest: ({"type": "wait_for"}, "requires: selector"), ({"type": "fill", "selector": "#q"}, "requires: value"), ({"type": "fill", "selector": "#q", "value": ""}, "requires: value"), + ({"type": "typing", "selector": "#q"}, "requires: value"), ({"type": "click"}, "requires: selector"), ({"type": "delay"}, "requires: value"), ({"type": "delay", "value": "abc"}, "integer"), @@ -29,6 +30,8 @@ def test_invalid_action(action, error): @pytest.mark.parametrize("action", [ {"type": "wait_for", "selector": "a", "timeout": 10}, {"type": "fill", "selector": "#q", "value": "x"}, + {"type": "typing", "selector": "#q", "value": "Привет"}, + {"type": "inner_text", "selector": "h1"}, {"type": "delay", "value": "0"}, {"type": "pass_challenge"}, {"type": "captcha", "selector": "#c", "value": "hcaptcha"}, diff --git a/tests/unit/test_v1.py b/tests/unit/test_v1.py index 7c2bd8b..c077e10 100644 --- a/tests/unit/test_v1.py +++ b/tests/unit/test_v1.py @@ -180,11 +180,14 @@ def test_secrets_not_logged_nor_stored(env, monkeypatch, caplog): post( env, proxy="http://someuser:TopSecret1@host:3128", captcha="KEYSECRET2", html=True, cookies=[{"name": "sid", "value": "CookieSecret3", "url": "https://example.com"}], - actions=[{"type": "fill", "selector": "#p", "value": "PassSecret4"}], + actions=[ + {"type": "fill", "selector": "#p", "value": "PassSecret4"}, + {"type": "typing", "selector": "#p", "value": "PassSecret5"}, + ], ) log = caplog.text assert "Incoming payload" in log and "Outgoing payload" in log - for secret in ("someuser", "TopSecret1", "KEYSECRET2", "CookieSecret3", "PassSecret4", "secret-cookie", "page"): + for secret in ("someuser", "TopSecret1", "KEYSECRET2", "CookieSecret3", "PassSecret4", "PassSecret5", "secret-cookie", "page"): assert secret not in log # в Redis — только результат (там cookies и html страницы), но не секреты запроса