feature: действие typing — ввод как у человека, кириллица (#14)

fill остаётся мгновенным. typing подводит курсор по кривой, кликает в поле, стирает
старое значение и набирает текст посимвольно с паузами; символы вне раскладки
(кириллица) больше не роняют ввод. Значение typing маскируется в логах.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 20:58:09 +04:00
1 parent 49a2f3024a
commit 1075267d27
8 files changed
+74 -25

No files matched your search

+3 -1
View File
@@ -69,7 +69,9 @@ Cookie: session_id=<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` |
+41 -21
View File
@@ -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))
+7
View File
@@ -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)
+1 -1
View File
@@ -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)
+1
View File
@@ -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",),
+13
View File
@@ -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 # посимвольно, с паузами
+3
View File
@@ -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"},
+5 -2
View File
@@ -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", "<body>page"):
for secret in ("someuser", "TopSecret1", "KEYSECRET2", "CookieSecret3", "PassSecret4", "PassSecret5", "secret-cookie", "<body>page"):
assert secret not in log
# в Redis — только результат (там cookies и html страницы), но не секреты запроса