feature: действие typing — ввод как у человека, кириллица (#14)
fill остаётся мгновенным. typing подводит курсор по кривой, кликает в поле, стирает старое значение и набирает текст посимвольно с паузами; символы вне раскладки (кириллица) больше не роняют ввод. Значение typing маскируется в логах. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49a2f3024a
commit
1075267d27
8 files changed
+74
-25
No files matched your search
+3
-1
@@ -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
@@ -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))
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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",),
|
||||
|
||||
@@ -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 # посимвольно, с паузами
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 страницы), но не секреты запроса
|
||||
|
||||
Reference in new issue
Block a user