""" человекоподобные движения мыши (безье+ускорение), тайминги кликов Раньше это частично закрывал Camoufox (форк Firefox с движением курсора по кривой при humanize=True) — после перехода на голый Playwright + Chromium это ушло: обычный page.click()/locator.click() телепортирует курсор мгновенно. Функции ниже — единственный источник человекоподобности сейчас. """ import math import random import string from typing import Optional from patchright.async_api import TimeoutError from patchright.async_api import Page # последняя известная позиция курсора на страницу — Playwright не даёт её прочитать из браузера, # поэтому считаем сами, чтобы следующее движение начиналось не из (0, 0), а оттуда, где реально # остановились в прошлый раз _last_mouse_pos: dict[int, tuple[float, float]] = {} def _ease_in_out(t: float) -> float: """smoothstep: медленный старт, разгон к середине, торможение к цели — как у реальной руки.""" return t * t * (3 - 2 * t) def _cubic_bezier(p0, c1, c2, p1, t: float) -> tuple[float, float]: mt = 1 - t x = mt ** 3 * p0[0] + 3 * mt ** 2 * t * c1[0] + 3 * mt * t ** 2 * c2[0] + t ** 3 * p1[0] y = mt ** 3 * p0[1] + 3 * mt ** 2 * t * c1[1] + 3 * mt * t ** 2 * c2[1] + t ** 3 * p1[1] return x, y def _control_points(x0, y0, x1, y1): """Два контрольных узла, смещённых перпендикулярно линии движения — даёт дугу вместо прямой, как у настоящего движения руки/трекпада, а не у синтетического teleport'а.""" dx, dy = x1 - x0, y1 - y0 dist = math.hypot(dx, dy) or 1.0 nx, ny = -dy / dist, dx / dist curve = random.uniform(0.08, 0.28) * dist * random.choice((-1, 1)) t1 = random.uniform(0.2, 0.4) t2 = random.uniform(0.6, 0.8) c1 = (x0 + dx * t1 + nx * curve, y0 + dy * t1 + ny * curve) c2 = (x0 + dx * t2 + nx * curve * random.uniform(0.5, 1.0), y0 + dy * t2 + ny * curve * random.uniform(0.5, 1.0)) return c1, c2 def _random_start_position(page: Page) -> tuple[float, float]: vp = page.viewport_size or {"width": 1280, "height": 720} return ( random.uniform(vp["width"] * 0.3, vp["width"] * 0.7), random.uniform(vp["height"] * 0.3, vp["height"] * 0.7), ) async def move_mouse_human( page: Page, x: float, y: float, *, steps: Optional[int] = None, min_duration: float = 0.25, max_duration: float = 0.9, ) -> None: """Двигает курсор к (x, y) по кривой Безье с ease-in-out таймингом и лёгким дрожанием на промежуточных точках, вместо page.mouse.move(x, y), которое телепортирует курсор мгновенно. Начальная точка — последняя известная позиция курсора на этой странице (или случайная точка у центра вьюпорта, если это первое движение в сессии).""" start = _last_mouse_pos.get(id(page)) or _random_start_position(page) c1, c2 = _control_points(start[0], start[1], x, y) dist = math.hypot(x - start[0], y - start[1]) if steps is None: steps = max(8, min(60, int(dist / 8))) duration = random.uniform(min_duration, max_duration) step_delay_ms = (duration / steps) * 1000 for i in range(1, steps + 1): t = _ease_in_out(i / steps) px, py = _cubic_bezier(start, c1, c2, (x, y), t) if i < steps: px += random.uniform(-1.5, 1.5) py += random.uniform(-1.5, 1.5) await page.mouse.move(px, py) await page.wait_for_timeout(step_delay_ms * random.uniform(0.7, 1.3)) _last_mouse_pos[id(page)] = (x, y) async def click_human( page: Page, x: float, y: float, *, button: str = "left", **move_kwargs, ) -> None: """move_mouse_human + клик с человеческой задержкой между наведением и down/up, вместо мгновенного page.mouse.click(x, y).""" await move_mouse_human(page, x, y, **move_kwargs) await page.wait_for_timeout(random.uniform(50, 180)) await page.mouse.down(button=button) await page.wait_for_timeout(random.uniform(40, 99)) await page.mouse.up(button=button) async def wait_for_networkidle(page: Page, timeout: Optional[int] = None): """Wait for the page to become idle (no network activity) even if there are never-ending requests.""" try: await page.wait_for_load_state("networkidle", timeout=timeout) except TimeoutError: pass async def wait_for_page_stability(page: Page, load_dom: bool, network_idle: bool): await page.wait_for_load_state(state="load") if load_dom: await page.wait_for_load_state(state="domcontentloaded") if network_idle: await wait_for_networkidle(page) 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() 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: # keyboard.type даёт keydown/keypress/keyup для клавиш раскладки и insertText для остальных # символов (кириллица и т.п.); press() на них падал await page.keyboard.type(char) await page.wait_for_timeout(_key_delay_ms(char))