mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Bezier mouse curves, per-character typing with mistype simulation, smooth micro-step scrolling, idle micro-movements between actions. Supports both sync and async Playwright APIs. Patches page, frame, context, browser, and Locator class methods. Two presets: 'default' (normal speed) and 'careful' (slower, deliberate). Configurable via HumanConfig dataclass / interface with full override support. Bug fixes (from PR review): - fill()/clear(): platform-aware select-all (Meta+a on macOS, Control+a elsewhere) - sync Locator check()/uncheck(): wrap mouse_move in RawMouse-compatible object - resolve_config(): raise error on unknown preset name - Lazy-load human.config via __getattr__ in __init__.py - humanPreset typed as 'default' | 'careful' literal union - browser.newPage() patches implicit context Tests: Python 36/36, JS Vitest 34/34, visual Python 17/17, JS 13/13
133 lines
4.5 KiB
Python
133 lines
4.5 KiB
Python
"""cloakbrowser-human — Human-like scrolling via mouse wheel events."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import random
|
|
from typing import Any, Optional, Tuple
|
|
|
|
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
|
|
from .mouse import RawMouse, human_move
|
|
|
|
|
|
def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> bool:
|
|
top_edge = bounds["y"]
|
|
bottom_edge = bounds["y"] + bounds["height"]
|
|
zone_top = viewport_height * cfg.scroll_target_zone[0]
|
|
zone_bottom = viewport_height * cfg.scroll_target_zone[1]
|
|
return top_edge >= zone_top and bottom_edge <= zone_bottom
|
|
|
|
|
|
def _get_element_box(page: Any, selector: str) -> Optional[dict]:
|
|
try:
|
|
el = page.locator(selector).first
|
|
return el.bounding_box(timeout=2000)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
|
|
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
|
|
abs_d = abs(delta)
|
|
sign = 1 if delta > 0 else -1
|
|
sent = 0
|
|
while sent < abs_d:
|
|
step_size = rand(20, 40)
|
|
chunk = min(step_size, abs_d - sent)
|
|
raw.wheel(0, round(chunk) * sign)
|
|
sent += chunk
|
|
sleep_ms(rand(8, 20))
|
|
|
|
|
|
def scroll_to_element(
|
|
page: Any,
|
|
raw: RawMouse,
|
|
selector: str,
|
|
cursor_x: float, cursor_y: float,
|
|
cfg: HumanConfig,
|
|
) -> Tuple[dict, float, float]:
|
|
viewport = page.viewport_size
|
|
if not viewport:
|
|
raise RuntimeError("Viewport size not available")
|
|
|
|
viewport_height = viewport["height"]
|
|
viewport_width = viewport["width"]
|
|
|
|
box = _get_element_box(page, selector)
|
|
if box is None:
|
|
sleep_ms(200)
|
|
box = _get_element_box(page, selector)
|
|
if box is None:
|
|
raise RuntimeError(f"Element not found: {selector}")
|
|
|
|
if _is_in_viewport(box, viewport_height, cfg):
|
|
return box, cursor_x, cursor_y
|
|
|
|
# Move cursor into scroll area
|
|
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
|
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
|
|
human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
|
|
cursor_x = scroll_area_x
|
|
cursor_y = scroll_area_y
|
|
sleep_ms(rand_range(cfg.scroll_pre_move_delay))
|
|
|
|
# Calculate scroll distance
|
|
target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1])
|
|
element_center = box["y"] + box["height"] / 2
|
|
distance_to_scroll = element_center - target_y
|
|
|
|
direction = 1 if distance_to_scroll > 0 else -1
|
|
abs_distance = abs(distance_to_scroll)
|
|
avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
|
|
total_clicks = max(3, math.ceil(abs_distance / avg_delta))
|
|
accel_steps = rand_int_range(cfg.scroll_accel_steps)
|
|
decel_steps = rand_int_range(cfg.scroll_decel_steps)
|
|
|
|
# Scroll loop: accelerate → cruise → decelerate
|
|
scrolled = 0
|
|
for i in range(total_clicks):
|
|
if i < accel_steps:
|
|
delta = rand(80, 100)
|
|
pause = rand_range(cfg.scroll_pause_slow)
|
|
elif i >= total_clicks - decel_steps:
|
|
delta = rand(60, 90)
|
|
pause = rand_range(cfg.scroll_pause_slow)
|
|
else:
|
|
delta = rand_range(cfg.scroll_delta_base)
|
|
pause = rand_range(cfg.scroll_pause_fast)
|
|
|
|
delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance
|
|
delta = round(delta) * direction
|
|
|
|
_smooth_wheel(raw, delta, cfg)
|
|
scrolled += abs(delta)
|
|
sleep_ms(pause)
|
|
|
|
# Check visibility every 3 steps
|
|
if i % 3 == 2 or i == total_clicks - 1:
|
|
box = _get_element_box(page, selector)
|
|
if box and _is_in_viewport(box, viewport_height, cfg):
|
|
break
|
|
if scrolled >= abs_distance * 1.1:
|
|
break
|
|
|
|
# Optional overshoot + correction
|
|
if random.random() < cfg.scroll_overshoot_chance:
|
|
overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction
|
|
_smooth_wheel(raw, overshoot_px, cfg)
|
|
sleep_ms(rand_range(cfg.scroll_settle_delay))
|
|
corrections = rand_int_range((1, 2))
|
|
for _ in range(corrections):
|
|
corr_delta = round(rand(40, 80)) * -direction
|
|
_smooth_wheel(raw, corr_delta, cfg)
|
|
sleep_ms(rand(100, 250))
|
|
|
|
# Settle
|
|
sleep_ms(rand_range(cfg.scroll_settle_delay))
|
|
|
|
box = _get_element_box(page, selector)
|
|
if box is None:
|
|
raise RuntimeError(f"Element lost after scrolling: {selector}")
|
|
|
|
return box, cursor_x, cursor_y
|