mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Merge pull request #30 from evelaa123/feature/humanize
feat: add humanize option human-like mouse, keyboard, scroll behavio…
This commit is contained in:
@@ -16,6 +16,18 @@ from .config import CHROMIUM_VERSION, get_default_stealth_args
|
|||||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
|
|
||||||
|
# Human-like behavioral layer (optional)
|
||||||
|
def __getattr__(name):
|
||||||
|
if name == "HumanConfig":
|
||||||
|
from .human.config import HumanConfig
|
||||||
|
globals()["HumanConfig"] = HumanConfig
|
||||||
|
return HumanConfig
|
||||||
|
if name == "resolve_human_config":
|
||||||
|
from .human.config import resolve_config
|
||||||
|
globals()["resolve_human_config"] = resolve_config
|
||||||
|
return resolve_config
|
||||||
|
raise AttributeError(f"module 'cloakbrowser' has no attribute {name}")
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"launch",
|
"launch",
|
||||||
"launch_async",
|
"launch_async",
|
||||||
@@ -29,5 +41,8 @@ __all__ = [
|
|||||||
"CHROMIUM_VERSION",
|
"CHROMIUM_VERSION",
|
||||||
"get_default_stealth_args",
|
"get_default_stealth_args",
|
||||||
"ProxySettings",
|
"ProxySettings",
|
||||||
|
"HumanConfig",
|
||||||
|
"resolve_human_config",
|
||||||
"__version__",
|
"__version__",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+66
-1
@@ -58,6 +58,9 @@ def launch(
|
|||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
|
humanize: bool = False,
|
||||||
|
human_preset: str = "default",
|
||||||
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
||||||
@@ -81,6 +84,9 @@ def launch(
|
|||||||
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
|
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
|
||||||
but breaks proxy auth and add_init_script.
|
but breaks proxy auth and add_init_script.
|
||||||
Override globally with CLOAKBROWSER_BACKEND env var.
|
Override globally with CLOAKBROWSER_BACKEND env var.
|
||||||
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch().
|
**kwargs: Passed directly to playwright.chromium.launch().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -121,10 +127,17 @@ def launch(
|
|||||||
|
|
||||||
browser.close = _close_with_cleanup
|
browser.close = _close_with_cleanup
|
||||||
|
|
||||||
|
# Human-like behavioral patching
|
||||||
|
if humanize:
|
||||||
|
from .human import patch_browser
|
||||||
|
from .human.config import resolve_config
|
||||||
|
cfg = resolve_config(human_preset, human_config)
|
||||||
|
patch_browser(browser, cfg)
|
||||||
|
|
||||||
return browser
|
return browser
|
||||||
|
|
||||||
|
|
||||||
async def launch_async(
|
async def launch_async( # noqa: C901
|
||||||
headless: bool = True,
|
headless: bool = True,
|
||||||
proxy: str | ProxySettings | None = None,
|
proxy: str | ProxySettings | None = None,
|
||||||
args: list[str] | None = None,
|
args: list[str] | None = None,
|
||||||
@@ -133,6 +146,9 @@ async def launch_async(
|
|||||||
locale: str | None = None,
|
locale: str | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
|
humanize: bool = False,
|
||||||
|
human_preset: str = "default",
|
||||||
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Async version of launch(). Returns a Playwright Browser object.
|
"""Async version of launch(). Returns a Playwright Browser object.
|
||||||
@@ -146,6 +162,9 @@ async def launch_async(
|
|||||||
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
|
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch().
|
**kwargs: Passed directly to playwright.chromium.launch().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -191,6 +210,13 @@ async def launch_async(
|
|||||||
|
|
||||||
browser.close = _close_with_cleanup
|
browser.close = _close_with_cleanup
|
||||||
|
|
||||||
|
# Human-like behavioral patching (async variant)
|
||||||
|
if humanize:
|
||||||
|
from .human import patch_browser_async
|
||||||
|
from .human.config import resolve_config
|
||||||
|
cfg = resolve_config(human_preset, human_config)
|
||||||
|
patch_browser_async(browser, cfg)
|
||||||
|
|
||||||
return browser
|
return browser
|
||||||
|
|
||||||
|
|
||||||
@@ -207,6 +233,9 @@ def launch_persistent_context(
|
|||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
|
humanize: bool = False,
|
||||||
|
human_preset: str = "default",
|
||||||
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||||
@@ -232,6 +261,9 @@ def launch_persistent_context(
|
|||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
Requires ``pip install cloakbrowser[geoip]``.
|
Requires ``pip install cloakbrowser[geoip]``.
|
||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -291,6 +323,13 @@ def launch_persistent_context(
|
|||||||
|
|
||||||
context.close = _close_with_cleanup
|
context.close = _close_with_cleanup
|
||||||
|
|
||||||
|
# Human-like behavioral patching
|
||||||
|
if humanize:
|
||||||
|
from .human import patch_context
|
||||||
|
from .human.config import resolve_config
|
||||||
|
cfg = resolve_config(human_preset, human_config)
|
||||||
|
patch_context(context, cfg)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -307,6 +346,9 @@ async def launch_persistent_context_async(
|
|||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
|
humanize: bool = False,
|
||||||
|
human_preset: str = "default",
|
||||||
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Async version of launch_persistent_context().
|
"""Async version of launch_persistent_context().
|
||||||
@@ -329,6 +371,9 @@ async def launch_persistent_context_async(
|
|||||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -393,6 +438,13 @@ async def launch_persistent_context_async(
|
|||||||
|
|
||||||
context.close = _close_with_cleanup
|
context.close = _close_with_cleanup
|
||||||
|
|
||||||
|
# Human-like behavioral patching (async variant)
|
||||||
|
if humanize:
|
||||||
|
from .human import patch_context_async
|
||||||
|
from .human.config import resolve_config
|
||||||
|
cfg = resolve_config(human_preset, human_config)
|
||||||
|
patch_context_async(context, cfg)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -408,6 +460,9 @@ def launch_context(
|
|||||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
geoip: bool = False,
|
geoip: bool = False,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
|
humanize: bool = False,
|
||||||
|
human_preset: str = "default",
|
||||||
|
human_config: dict | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||||
@@ -428,6 +483,9 @@ def launch_context(
|
|||||||
Default: None (uses Chromium default, which is 'light').
|
Default: None (uses Chromium default, which is 'light').
|
||||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
|
human_config: Custom humanize config dict to override preset values.
|
||||||
**kwargs: Passed to browser.new_context().
|
**kwargs: Passed to browser.new_context().
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -471,6 +529,13 @@ def launch_context(
|
|||||||
|
|
||||||
context.close = _close_context_with_cleanup
|
context.close = _close_context_with_cleanup
|
||||||
|
|
||||||
|
# Human-like behavioral patching
|
||||||
|
if humanize:
|
||||||
|
from .human import patch_context
|
||||||
|
from .human.config import resolve_config
|
||||||
|
cfg = resolve_config(human_preset, human_config)
|
||||||
|
patch_context(context, cfg)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
|||||||
|
"""cloakbrowser-human — Configuration and presets.
|
||||||
|
|
||||||
|
All numeric parameters for human-like behavior are centralized here.
|
||||||
|
Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal, Tuple
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Type alias
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Range = Tuple[float, float]
|
||||||
|
HumanPreset = Literal["default", "careful"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration dataclass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HumanConfig:
|
||||||
|
"""All tunable parameters for human-like behavior."""
|
||||||
|
|
||||||
|
# Keyboard
|
||||||
|
typing_delay: float = 70
|
||||||
|
typing_delay_spread: float = 40
|
||||||
|
typing_pause_chance: float = 0.1
|
||||||
|
typing_pause_range: Range = (400, 1000)
|
||||||
|
shift_down_delay: Range = (30, 70)
|
||||||
|
shift_up_delay: Range = (20, 50)
|
||||||
|
key_hold: Range = (15, 35)
|
||||||
|
|
||||||
|
# Mistype (typo simulation)
|
||||||
|
mistype_chance: float = 0.02
|
||||||
|
mistype_delay_notice: Range = (100, 300)
|
||||||
|
mistype_delay_correct: Range = (50, 150)
|
||||||
|
|
||||||
|
field_switch_delay: Range = (800, 1500)
|
||||||
|
|
||||||
|
# Mouse — movement
|
||||||
|
mouse_steps_divisor: float = 8
|
||||||
|
mouse_min_steps: int = 25
|
||||||
|
mouse_max_steps: int = 80
|
||||||
|
mouse_wobble_max: float = 1.5
|
||||||
|
mouse_overshoot_chance: float = 0.15
|
||||||
|
mouse_overshoot_px: Range = (3, 6)
|
||||||
|
mouse_burst_size: Range = (3, 5)
|
||||||
|
mouse_burst_pause: Range = (8, 18)
|
||||||
|
|
||||||
|
# Mouse — clicks
|
||||||
|
click_aim_delay_input: Range = (60, 140)
|
||||||
|
click_aim_delay_button: Range = (80, 200)
|
||||||
|
click_hold_input: Range = (40, 100)
|
||||||
|
click_hold_button: Range = (60, 150)
|
||||||
|
click_input_x_range: Range = (0.05, 0.30)
|
||||||
|
|
||||||
|
# Mouse — idle
|
||||||
|
idle_drift_px: float = 3
|
||||||
|
idle_pause_range: Range = (300, 1000)
|
||||||
|
|
||||||
|
# Scroll
|
||||||
|
scroll_delta_base: Range = (80, 130)
|
||||||
|
scroll_delta_variance: float = 0.2
|
||||||
|
scroll_pause_fast: Range = (30, 80)
|
||||||
|
scroll_pause_slow: Range = (80, 200)
|
||||||
|
scroll_accel_steps: Range = (2, 3)
|
||||||
|
scroll_decel_steps: Range = (2, 3)
|
||||||
|
scroll_overshoot_chance: float = 0.1
|
||||||
|
scroll_overshoot_px: Range = (50, 150)
|
||||||
|
scroll_settle_delay: Range = (300, 600)
|
||||||
|
scroll_target_zone: Range = (0.20, 0.80)
|
||||||
|
scroll_pre_move_delay: Range = (100, 300)
|
||||||
|
|
||||||
|
# Initial cursor position (as if coming from the address bar area)
|
||||||
|
initial_cursor_x: Range = (400, 700)
|
||||||
|
initial_cursor_y: Range = (45, 60)
|
||||||
|
|
||||||
|
# Idle micro-movements between actions (opt-in, adds latency)
|
||||||
|
idle_between_actions: bool = False
|
||||||
|
idle_between_duration: Range = (0.3, 0.8)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Presets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _careful_config() -> HumanConfig:
|
||||||
|
"""Careful preset — everything slower and more deliberate."""
|
||||||
|
return HumanConfig(
|
||||||
|
# Keyboard — slower typing
|
||||||
|
typing_delay=100,
|
||||||
|
typing_delay_spread=50,
|
||||||
|
typing_pause_chance=0.15,
|
||||||
|
typing_pause_range=(500, 1200),
|
||||||
|
shift_down_delay=(40, 90),
|
||||||
|
shift_up_delay=(30, 70),
|
||||||
|
key_hold=(20, 45),
|
||||||
|
field_switch_delay=(1000, 2000),
|
||||||
|
# Mouse — slower, more precise
|
||||||
|
mouse_overshoot_chance=0.10,
|
||||||
|
mouse_burst_pause=(12, 25),
|
||||||
|
# Mouse — clicks (longer aiming and holding)
|
||||||
|
click_aim_delay_input=(80, 180),
|
||||||
|
click_aim_delay_button=(120, 280),
|
||||||
|
click_hold_input=(60, 140),
|
||||||
|
click_hold_button=(80, 200),
|
||||||
|
# Scroll — slower
|
||||||
|
scroll_pause_fast=(100, 200),
|
||||||
|
scroll_pause_slow=(250, 600),
|
||||||
|
scroll_settle_delay=(400, 800),
|
||||||
|
scroll_pre_move_delay=(150, 400),
|
||||||
|
# Idle between actions enabled for careful preset
|
||||||
|
idle_between_actions=True,
|
||||||
|
idle_between_duration=(0.4, 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_PRESETS: dict[str, HumanConfig] = {
|
||||||
|
"default": HumanConfig(),
|
||||||
|
"careful": _careful_config(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config(
|
||||||
|
preset: HumanPreset = "default",
|
||||||
|
overrides: dict | None = None,
|
||||||
|
) -> HumanConfig:
|
||||||
|
"""Resolve a preset name + optional overrides into a full HumanConfig.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: 'default' or 'careful'.
|
||||||
|
overrides: Dict of field names to override values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A new HumanConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If preset is not a recognized name.
|
||||||
|
"""
|
||||||
|
if preset not in _PRESETS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown humanize preset {preset!r}. "
|
||||||
|
f"Valid presets: {', '.join(sorted(_PRESETS.keys()))}"
|
||||||
|
)
|
||||||
|
base = _PRESETS[preset]
|
||||||
|
if not overrides:
|
||||||
|
return HumanConfig(**{k: getattr(base, k) for k in base.__dataclass_fields__})
|
||||||
|
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
|
||||||
|
merged.update(overrides)
|
||||||
|
return HumanConfig(**merged)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Utility functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def rand(lo: float, hi: float) -> float:
|
||||||
|
"""Random float in [lo, hi]."""
|
||||||
|
return random.uniform(lo, hi)
|
||||||
|
|
||||||
|
|
||||||
|
def rand_int(lo: int, hi: int) -> int:
|
||||||
|
"""Random integer in [lo, hi] inclusive."""
|
||||||
|
return random.randint(lo, hi)
|
||||||
|
|
||||||
|
|
||||||
|
def rand_range(r: Range) -> float:
|
||||||
|
"""Random float from a (min, max) tuple."""
|
||||||
|
return random.uniform(r[0], r[1])
|
||||||
|
|
||||||
|
|
||||||
|
def rand_int_range(r: Range) -> int:
|
||||||
|
"""Random integer from a (min, max) tuple, inclusive."""
|
||||||
|
return random.randint(int(r[0]), int(r[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def sleep_ms(ms: float) -> None:
|
||||||
|
"""Sleep for `ms` milliseconds."""
|
||||||
|
if ms > 0:
|
||||||
|
time.sleep(ms / 1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_sleep_ms(ms: float) -> None:
|
||||||
|
"""Async sleep for `ms` milliseconds."""
|
||||||
|
if ms > 0:
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(ms / 1000.0)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""cloakbrowser-human — Human-like keyboard input."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from .config import HumanConfig, rand, rand_range, sleep_ms
|
||||||
|
|
||||||
|
|
||||||
|
class RawKeyboard(Protocol):
|
||||||
|
def down(self, key: str) -> None: ...
|
||||||
|
def up(self, key: str) -> None: ...
|
||||||
|
def type(self, text: str) -> None: ...
|
||||||
|
def insert_text(self, text: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
SHIFT_SYMBOLS = frozenset('@#!$%^&*()_+{}|:"<>?~')
|
||||||
|
|
||||||
|
NEARBY_KEYS = {
|
||||||
|
'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'sfecx', 'e': 'wrsdf',
|
||||||
|
'f': 'dgrtcv', 'g': 'fhtyb', 'h': 'gjybn', 'i': 'ujko', 'j': 'hkunm',
|
||||||
|
'k': 'jloi', 'l': 'kop', 'm': 'njk', 'n': 'bhjm', 'o': 'iklp',
|
||||||
|
'p': 'ol', 'q': 'wa', 'r': 'edft', 's': 'awedxz', 't': 'rfgy',
|
||||||
|
'u': 'yhji', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu',
|
||||||
|
'z': 'asx',
|
||||||
|
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
|
||||||
|
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nearby_key(ch: str) -> str:
|
||||||
|
"""Return a random adjacent key for the given character."""
|
||||||
|
lower = ch.lower()
|
||||||
|
if lower in NEARBY_KEYS:
|
||||||
|
neighbors = NEARBY_KEYS[lower]
|
||||||
|
wrong = random.choice(neighbors)
|
||||||
|
return wrong.upper() if ch.isupper() else wrong
|
||||||
|
return ch
|
||||||
|
|
||||||
|
|
||||||
|
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
# Mistype chance — press wrong key, notice, backspace, then correct
|
||||||
|
if random.random() < cfg.mistype_chance and ch.isalnum():
|
||||||
|
wrong = _get_nearby_key(ch)
|
||||||
|
_type_normal_char(raw, wrong, cfg)
|
||||||
|
sleep_ms(rand_range(cfg.mistype_delay_notice))
|
||||||
|
raw.down("Backspace")
|
||||||
|
sleep_ms(rand_range(cfg.key_hold))
|
||||||
|
raw.up("Backspace")
|
||||||
|
sleep_ms(rand_range(cfg.mistype_delay_correct))
|
||||||
|
|
||||||
|
if ch.isupper() and ch.isalpha():
|
||||||
|
_type_shifted_char(page, raw, ch, cfg)
|
||||||
|
elif ch in SHIFT_SYMBOLS:
|
||||||
|
_type_shift_symbol(page, raw, ch, cfg)
|
||||||
|
else:
|
||||||
|
_type_normal_char(raw, ch, cfg)
|
||||||
|
|
||||||
|
if i < len(text) - 1:
|
||||||
|
_inter_char_delay(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _type_normal_char(raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||||
|
raw.down(ch)
|
||||||
|
sleep_ms(rand_range(cfg.key_hold))
|
||||||
|
raw.up(ch)
|
||||||
|
|
||||||
|
|
||||||
|
def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||||
|
raw.down("Shift")
|
||||||
|
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||||
|
raw.down(ch)
|
||||||
|
sleep_ms(rand_range(cfg.key_hold))
|
||||||
|
raw.up(ch)
|
||||||
|
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||||
|
raw.up("Shift")
|
||||||
|
|
||||||
|
|
||||||
|
def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||||
|
raw.down("Shift")
|
||||||
|
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||||
|
raw.insert_text(ch)
|
||||||
|
page.evaluate(
|
||||||
|
"""(key) => {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (el) {
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||||
|
}
|
||||||
|
}""",
|
||||||
|
ch,
|
||||||
|
)
|
||||||
|
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||||
|
raw.up("Shift")
|
||||||
|
|
||||||
|
|
||||||
|
def _inter_char_delay(cfg: HumanConfig) -> None:
|
||||||
|
if random.random() < cfg.typing_pause_chance:
|
||||||
|
sleep_ms(rand_range(cfg.typing_pause_range))
|
||||||
|
else:
|
||||||
|
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
|
||||||
|
sleep_ms(max(10, delay))
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""cloakbrowser-human — Async human-like keyboard input.
|
||||||
|
|
||||||
|
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
|
||||||
|
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from .config import HumanConfig, rand, rand_range, async_sleep_ms
|
||||||
|
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncRawKeyboard(Protocol):
|
||||||
|
async def down(self, key: str) -> None: ...
|
||||||
|
async def up(self, key: str) -> None: ...
|
||||||
|
async def type(self, text: str) -> None: ...
|
||||||
|
async def insert_text(self, text: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
# Mistype chance — press wrong key, notice, backspace, then correct
|
||||||
|
if random.random() < cfg.mistype_chance and ch.isalnum():
|
||||||
|
wrong = _get_nearby_key(ch)
|
||||||
|
await _type_normal_char(raw, wrong, cfg)
|
||||||
|
await async_sleep_ms(rand_range(cfg.mistype_delay_notice))
|
||||||
|
await raw.down("Backspace")
|
||||||
|
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||||
|
await raw.up("Backspace")
|
||||||
|
await async_sleep_ms(rand_range(cfg.mistype_delay_correct))
|
||||||
|
|
||||||
|
if ch.isupper() and ch.isalpha():
|
||||||
|
await _type_shifted_char(page, raw, ch, cfg)
|
||||||
|
elif ch in SHIFT_SYMBOLS:
|
||||||
|
await _type_shift_symbol(page, raw, ch, cfg)
|
||||||
|
else:
|
||||||
|
await _type_normal_char(raw, ch, cfg)
|
||||||
|
|
||||||
|
if i < len(text) - 1:
|
||||||
|
await _inter_char_delay(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
async def _type_normal_char(raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||||
|
await raw.down(ch)
|
||||||
|
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||||
|
await raw.up(ch)
|
||||||
|
|
||||||
|
|
||||||
|
async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||||
|
await raw.down("Shift")
|
||||||
|
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||||
|
await raw.down(ch)
|
||||||
|
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||||
|
await raw.up(ch)
|
||||||
|
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||||
|
await raw.up("Shift")
|
||||||
|
|
||||||
|
|
||||||
|
async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||||
|
await raw.down("Shift")
|
||||||
|
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||||
|
await raw.insert_text(ch)
|
||||||
|
await page.evaluate(
|
||||||
|
"""(key) => {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (el) {
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||||
|
}
|
||||||
|
}""",
|
||||||
|
ch,
|
||||||
|
)
|
||||||
|
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||||
|
await raw.up("Shift")
|
||||||
|
|
||||||
|
|
||||||
|
async def _inter_char_delay(cfg: HumanConfig) -> None:
|
||||||
|
if random.random() < cfg.typing_pause_chance:
|
||||||
|
await async_sleep_ms(rand_range(cfg.typing_pause_range))
|
||||||
|
else:
|
||||||
|
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
|
||||||
|
await async_sleep_ms(max(10, delay))
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""cloakbrowser-human — Human-like mouse movement and clicking."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from typing import Any, Protocol, Tuple
|
||||||
|
|
||||||
|
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
|
||||||
|
|
||||||
|
|
||||||
|
class RawMouse(Protocol):
|
||||||
|
def move(self, x: float, y: float) -> None: ...
|
||||||
|
def down(self) -> None: ...
|
||||||
|
def up(self) -> None: ...
|
||||||
|
def wheel(self, delta_x: float, delta_y: float) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Point:
|
||||||
|
__slots__ = ("x", "y")
|
||||||
|
def __init__(self, x: float, y: float):
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
|
||||||
|
|
||||||
|
def _ease_in_out(t: float) -> float:
|
||||||
|
if t < 0.5:
|
||||||
|
return 4 * t * t * t
|
||||||
|
return 1 - pow(-2 * t + 2, 3) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def _bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point:
|
||||||
|
u = 1 - t
|
||||||
|
uu = u * u
|
||||||
|
uuu = uu * u
|
||||||
|
tt = t * t
|
||||||
|
ttt = tt * t
|
||||||
|
return Point(
|
||||||
|
uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
|
||||||
|
uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _random_control_points(start: Point, end: Point) -> Tuple[Point, Point]:
|
||||||
|
dx = end.x - start.x
|
||||||
|
dy = end.y - start.y
|
||||||
|
dist = math.hypot(dx, dy) or 1
|
||||||
|
px = -dy / dist
|
||||||
|
py = dx / dist
|
||||||
|
bias1 = rand(-0.3, 0.3) * dist
|
||||||
|
bias2 = rand(-0.3, 0.3) * dist
|
||||||
|
return (
|
||||||
|
Point(start.x + dx * 0.25 + px * bias1, start.y + dy * 0.25 + py * bias1),
|
||||||
|
Point(start.x + dx * 0.75 + px * bias2, start.y + dy * 0.75 + py * bias2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def human_move(
|
||||||
|
raw: RawMouse,
|
||||||
|
start_x: float, start_y: float,
|
||||||
|
end_x: float, end_y: float,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
) -> None:
|
||||||
|
dist = math.hypot(end_x - start_x, end_y - start_y)
|
||||||
|
if dist < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
|
||||||
|
start = Point(start_x, start_y)
|
||||||
|
end = Point(end_x, end_y)
|
||||||
|
cp1, cp2 = _random_control_points(start, end)
|
||||||
|
|
||||||
|
burst_counter = 0
|
||||||
|
burst_size = rand_int_range(cfg.mouse_burst_size)
|
||||||
|
|
||||||
|
for i in range(steps + 1):
|
||||||
|
progress = i / steps
|
||||||
|
eased_t = _ease_in_out(progress)
|
||||||
|
pt = _bezier(start, cp1, cp2, end, eased_t)
|
||||||
|
|
||||||
|
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
|
||||||
|
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
|
||||||
|
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
|
||||||
|
|
||||||
|
raw.move(round(wx), round(wy))
|
||||||
|
|
||||||
|
burst_counter += 1
|
||||||
|
if burst_counter >= burst_size and i < steps:
|
||||||
|
sleep_ms(rand_range(cfg.mouse_burst_pause))
|
||||||
|
burst_counter = 0
|
||||||
|
|
||||||
|
if random.random() < cfg.mouse_overshoot_chance:
|
||||||
|
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
|
||||||
|
angle = math.atan2(end_y - start_y, end_x - start_x)
|
||||||
|
raw.move(round(end_x + math.cos(angle) * overshoot_dist),
|
||||||
|
round(end_y + math.sin(angle) * overshoot_dist))
|
||||||
|
sleep_ms(rand(30, 70))
|
||||||
|
raw.move(round(end_x + (random.random() - 0.5) * 4),
|
||||||
|
round(end_y + (random.random() - 0.5) * 4))
|
||||||
|
|
||||||
|
|
||||||
|
def click_target(box: dict, is_input: bool, cfg: HumanConfig) -> Point:
|
||||||
|
if is_input:
|
||||||
|
x_frac = rand_range(cfg.click_input_x_range)
|
||||||
|
y_frac = rand(0.30, 0.70)
|
||||||
|
else:
|
||||||
|
x_frac = rand(0.35, 0.65)
|
||||||
|
y_frac = rand(0.35, 0.65)
|
||||||
|
return Point(round(box["x"] + box["width"] * x_frac),
|
||||||
|
round(box["y"] + box["height"] * y_frac))
|
||||||
|
|
||||||
|
|
||||||
|
def human_click(raw: RawMouse, is_input: bool, cfg: HumanConfig) -> None:
|
||||||
|
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
|
||||||
|
sleep_ms(aim_delay)
|
||||||
|
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
|
||||||
|
raw.down()
|
||||||
|
sleep_ms(hold_time)
|
||||||
|
raw.up()
|
||||||
|
|
||||||
|
|
||||||
|
def human_idle(raw: RawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
|
||||||
|
import time as _time
|
||||||
|
end_time = _time.monotonic() + seconds
|
||||||
|
x, y = cx, cy
|
||||||
|
while _time.monotonic() < end_time:
|
||||||
|
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||||
|
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||||
|
x += dx
|
||||||
|
y += dy
|
||||||
|
raw.move(round(x), round(y))
|
||||||
|
sleep_ms(rand_range(cfg.idle_pause_range))
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""cloakbrowser-human — Async human-like mouse movement and clicking.
|
||||||
|
|
||||||
|
Mirrors mouse.py but uses ``await`` for all Playwright calls and
|
||||||
|
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
|
||||||
|
from .mouse import Point, _ease_in_out, _bezier, _random_control_points, click_target # noqa: reuse pure math
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncRawMouse(Protocol):
|
||||||
|
async def move(self, x: float, y: float) -> None: ...
|
||||||
|
async def down(self) -> None: ...
|
||||||
|
async def up(self) -> None: ...
|
||||||
|
async def wheel(self, delta_x: float, delta_y: float) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
async def async_human_move(
|
||||||
|
raw: AsyncRawMouse,
|
||||||
|
start_x: float, start_y: float,
|
||||||
|
end_x: float, end_y: float,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
) -> None:
|
||||||
|
dist = math.hypot(end_x - start_x, end_y - start_y)
|
||||||
|
if dist < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
|
||||||
|
start = Point(start_x, start_y)
|
||||||
|
end = Point(end_x, end_y)
|
||||||
|
cp1, cp2 = _random_control_points(start, end)
|
||||||
|
|
||||||
|
burst_counter = 0
|
||||||
|
burst_size = rand_int_range(cfg.mouse_burst_size)
|
||||||
|
|
||||||
|
for i in range(steps + 1):
|
||||||
|
progress = i / steps
|
||||||
|
eased_t = _ease_in_out(progress)
|
||||||
|
pt = _bezier(start, cp1, cp2, end, eased_t)
|
||||||
|
|
||||||
|
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
|
||||||
|
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
|
||||||
|
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
|
||||||
|
|
||||||
|
await raw.move(round(wx), round(wy))
|
||||||
|
|
||||||
|
burst_counter += 1
|
||||||
|
if burst_counter >= burst_size and i < steps:
|
||||||
|
await async_sleep_ms(rand_range(cfg.mouse_burst_pause))
|
||||||
|
burst_counter = 0
|
||||||
|
|
||||||
|
if random.random() < cfg.mouse_overshoot_chance:
|
||||||
|
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
|
||||||
|
angle = math.atan2(end_y - start_y, end_x - start_x)
|
||||||
|
await raw.move(round(end_x + math.cos(angle) * overshoot_dist),
|
||||||
|
round(end_y + math.sin(angle) * overshoot_dist))
|
||||||
|
await async_sleep_ms(rand(30, 70))
|
||||||
|
await raw.move(round(end_x + (random.random() - 0.5) * 4),
|
||||||
|
round(end_y + (random.random() - 0.5) * 4))
|
||||||
|
|
||||||
|
|
||||||
|
async def async_human_click(raw: AsyncRawMouse, is_input: bool, cfg: HumanConfig) -> None:
|
||||||
|
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
|
||||||
|
await async_sleep_ms(aim_delay)
|
||||||
|
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
|
||||||
|
await raw.down()
|
||||||
|
await async_sleep_ms(hold_time)
|
||||||
|
await raw.up()
|
||||||
|
|
||||||
|
|
||||||
|
async def async_human_idle(raw: AsyncRawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
|
||||||
|
import time as _time
|
||||||
|
end_time = _time.monotonic() + seconds
|
||||||
|
x, y = cx, cy
|
||||||
|
while _time.monotonic() < end_time:
|
||||||
|
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||||
|
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||||
|
x += dx
|
||||||
|
y += dy
|
||||||
|
await raw.move(round(x), round(y))
|
||||||
|
await async_sleep_ms(rand_range(cfg.idle_pause_range))
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""cloakbrowser-human — Async human-like scrolling via mouse wheel events.
|
||||||
|
|
||||||
|
Mirrors scroll.py but uses ``await`` for all Playwright calls and
|
||||||
|
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
|
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
|
||||||
|
from .mouse_async import AsyncRawMouse, async_human_move
|
||||||
|
from .scroll import _is_in_viewport
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_element_box_async(page: Any, selector: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
el = page.locator(selector).first
|
||||||
|
return await el.bounding_box(timeout=2000)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_smooth_wheel(raw: AsyncRawMouse, 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)
|
||||||
|
await raw.wheel(0, round(chunk) * sign)
|
||||||
|
sent += chunk
|
||||||
|
await async_sleep_ms(rand(8, 20))
|
||||||
|
|
||||||
|
|
||||||
|
async def async_scroll_to_element(
|
||||||
|
page: Any,
|
||||||
|
raw: AsyncRawMouse,
|
||||||
|
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 = await _get_element_box_async(page, selector)
|
||||||
|
if box is None:
|
||||||
|
await async_sleep_ms(200)
|
||||||
|
box = await _get_element_box_async(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))
|
||||||
|
await async_human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
|
||||||
|
cursor_x = scroll_area_x
|
||||||
|
cursor_y = scroll_area_y
|
||||||
|
await async_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
|
||||||
|
|
||||||
|
await _async_smooth_wheel(raw, delta, cfg)
|
||||||
|
scrolled += abs(delta)
|
||||||
|
await async_sleep_ms(pause)
|
||||||
|
|
||||||
|
# Check visibility every 3 steps
|
||||||
|
if i % 3 == 2 or i == total_clicks - 1:
|
||||||
|
box = await _get_element_box_async(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
|
||||||
|
await _async_smooth_wheel(raw, overshoot_px, cfg)
|
||||||
|
await async_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
|
||||||
|
await _async_smooth_wheel(raw, corr_delta, cfg)
|
||||||
|
await async_sleep_ms(rand(100, 250))
|
||||||
|
|
||||||
|
# Settle
|
||||||
|
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||||
|
|
||||||
|
box = await _get_element_box_async(page, selector)
|
||||||
|
if box is None:
|
||||||
|
raise RuntimeError(f"Element lost after scrolling: {selector}")
|
||||||
|
|
||||||
|
return box, cursor_x, cursor_y
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.2.0",
|
"version": "0.3.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.2.0",
|
"version": "0.3.9",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tar": "^7.0.0"
|
"tar": "^7.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* cloakbrowser-human — Configuration and presets.
|
||||||
|
*
|
||||||
|
* All numeric parameters for human-like behavior are centralized here.
|
||||||
|
* Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface HumanConfig {
|
||||||
|
// Keyboard
|
||||||
|
typing_delay: number;
|
||||||
|
typing_delay_spread: number;
|
||||||
|
typing_pause_chance: number;
|
||||||
|
typing_pause_range: [number, number];
|
||||||
|
shift_down_delay: [number, number];
|
||||||
|
shift_up_delay: [number, number];
|
||||||
|
key_hold: [number, number];
|
||||||
|
field_switch_delay: [number, number];
|
||||||
|
mistype_chance: number;
|
||||||
|
mistype_delay_notice: [number, number];
|
||||||
|
mistype_delay_correct: [number, number];
|
||||||
|
|
||||||
|
|
||||||
|
// Mouse — movement
|
||||||
|
mouse_steps_divisor: number;
|
||||||
|
mouse_min_steps: number;
|
||||||
|
mouse_max_steps: number;
|
||||||
|
mouse_wobble_max: number;
|
||||||
|
mouse_overshoot_chance: number;
|
||||||
|
mouse_overshoot_px: [number, number];
|
||||||
|
mouse_burst_size: [number, number];
|
||||||
|
mouse_burst_pause: [number, number];
|
||||||
|
|
||||||
|
// Mouse — clicks
|
||||||
|
click_aim_delay_input: [number, number];
|
||||||
|
click_aim_delay_button: [number, number];
|
||||||
|
click_hold_input: [number, number];
|
||||||
|
click_hold_button: [number, number];
|
||||||
|
click_input_x_range: [number, number];
|
||||||
|
|
||||||
|
// Mouse — idle
|
||||||
|
idle_drift_px: number;
|
||||||
|
idle_pause_range: [number, number];
|
||||||
|
|
||||||
|
// Scroll
|
||||||
|
scroll_delta_base: [number, number];
|
||||||
|
scroll_delta_variance: number;
|
||||||
|
scroll_pause_fast: [number, number];
|
||||||
|
scroll_pause_slow: [number, number];
|
||||||
|
scroll_accel_steps: [number, number];
|
||||||
|
scroll_decel_steps: [number, number];
|
||||||
|
scroll_overshoot_chance: number;
|
||||||
|
scroll_overshoot_px: [number, number];
|
||||||
|
scroll_settle_delay: [number, number];
|
||||||
|
scroll_target_zone: [number, number];
|
||||||
|
scroll_pre_move_delay: [number, number];
|
||||||
|
|
||||||
|
// Initial cursor position
|
||||||
|
initial_cursor_x: [number, number];
|
||||||
|
initial_cursor_y: [number, number];
|
||||||
|
|
||||||
|
|
||||||
|
// Idle micro-movements between actions (opt-in, adds latency)
|
||||||
|
idle_between_actions: boolean;
|
||||||
|
idle_between_duration: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HumanPreset = 'default' | 'careful';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default preset
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: HumanConfig = {
|
||||||
|
// Keyboard
|
||||||
|
typing_delay: 70,
|
||||||
|
typing_delay_spread: 40,
|
||||||
|
typing_pause_chance: 0.1,
|
||||||
|
typing_pause_range: [400, 1000],
|
||||||
|
shift_down_delay: [30, 70],
|
||||||
|
shift_up_delay: [20, 50],
|
||||||
|
key_hold: [15, 35],
|
||||||
|
field_switch_delay: [800, 1500],
|
||||||
|
// Mistype (typo simulation)
|
||||||
|
mistype_chance: 0.02,
|
||||||
|
mistype_delay_notice: [100, 300],
|
||||||
|
mistype_delay_correct: [50, 150],
|
||||||
|
|
||||||
|
// Mouse — movement
|
||||||
|
mouse_steps_divisor: 8,
|
||||||
|
mouse_min_steps: 25,
|
||||||
|
mouse_max_steps: 80,
|
||||||
|
mouse_wobble_max: 1.5,
|
||||||
|
mouse_overshoot_chance: 0.15,
|
||||||
|
mouse_overshoot_px: [3, 6],
|
||||||
|
mouse_burst_size: [3, 5],
|
||||||
|
mouse_burst_pause: [8, 18],
|
||||||
|
|
||||||
|
// Mouse — clicks
|
||||||
|
click_aim_delay_input: [60, 140],
|
||||||
|
click_aim_delay_button: [80, 200],
|
||||||
|
click_hold_input: [40, 100],
|
||||||
|
click_hold_button: [60, 150],
|
||||||
|
click_input_x_range: [0.05, 0.30],
|
||||||
|
|
||||||
|
// Mouse — idle
|
||||||
|
idle_drift_px: 3,
|
||||||
|
idle_pause_range: [300, 1000],
|
||||||
|
|
||||||
|
// Scroll
|
||||||
|
scroll_delta_base: [80, 130],
|
||||||
|
scroll_delta_variance: 0.2,
|
||||||
|
scroll_pause_fast: [30, 80],
|
||||||
|
scroll_pause_slow: [80, 200],
|
||||||
|
scroll_accel_steps: [2, 3],
|
||||||
|
scroll_decel_steps: [2, 3],
|
||||||
|
scroll_overshoot_chance: 0.1,
|
||||||
|
scroll_overshoot_px: [50, 150],
|
||||||
|
scroll_settle_delay: [300, 600],
|
||||||
|
scroll_target_zone: [0.20, 0.80],
|
||||||
|
scroll_pre_move_delay: [100, 300],
|
||||||
|
|
||||||
|
// Initial cursor position (as if coming from the address bar area)
|
||||||
|
initial_cursor_x: [400, 700],
|
||||||
|
initial_cursor_y: [45, 60],
|
||||||
|
|
||||||
|
// Idle micro-movements between actions (off by default)
|
||||||
|
idle_between_actions: false,
|
||||||
|
idle_between_duration: [0.3, 0.8],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Careful preset — everything slower and more deliberate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CAREFUL_CONFIG: HumanConfig = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
|
||||||
|
// Keyboard — slower typing
|
||||||
|
typing_delay: 100,
|
||||||
|
typing_delay_spread: 50,
|
||||||
|
typing_pause_chance: 0.15,
|
||||||
|
typing_pause_range: [500, 1200],
|
||||||
|
shift_down_delay: [40, 90],
|
||||||
|
shift_up_delay: [30, 70],
|
||||||
|
key_hold: [20, 45],
|
||||||
|
field_switch_delay: [1000, 2000],
|
||||||
|
mistype_chance: 0.03,
|
||||||
|
mistype_delay_notice: [150, 400],
|
||||||
|
mistype_delay_correct: [80, 200],
|
||||||
|
|
||||||
|
// Mouse — slower, more precise
|
||||||
|
mouse_overshoot_chance: 0.10,
|
||||||
|
mouse_burst_pause: [12, 25],
|
||||||
|
|
||||||
|
// Mouse — clicks (longer aiming and holding)
|
||||||
|
click_aim_delay_input: [80, 180],
|
||||||
|
click_aim_delay_button: [120, 280],
|
||||||
|
click_hold_input: [60, 140],
|
||||||
|
click_hold_button: [80, 200],
|
||||||
|
|
||||||
|
// Scroll — slower
|
||||||
|
scroll_pause_fast: [100, 200],
|
||||||
|
scroll_pause_slow: [250, 600],
|
||||||
|
scroll_settle_delay: [400, 800],
|
||||||
|
scroll_pre_move_delay: [150, 400],
|
||||||
|
|
||||||
|
// Idle between actions enabled for careful preset
|
||||||
|
idle_between_actions: true,
|
||||||
|
idle_between_duration: [0.4, 1.0],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Preset map
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PRESETS: Record<HumanPreset, HumanConfig> = {
|
||||||
|
default: DEFAULT_CONFIG,
|
||||||
|
careful: CAREFUL_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a preset name or partial config into a full HumanConfig.
|
||||||
|
* If `preset` is a string, returns the corresponding built-in config.
|
||||||
|
* Any keys in `overrides` replace the preset values.
|
||||||
|
*/
|
||||||
|
export function resolveConfig(
|
||||||
|
preset: HumanPreset = 'default',
|
||||||
|
overrides?: Partial<HumanConfig>,
|
||||||
|
): HumanConfig {
|
||||||
|
const base = PRESETS[preset];
|
||||||
|
if (!base) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown humanize preset "${preset}". Valid presets: ${Object.keys(PRESETS).join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!overrides) return { ...base };
|
||||||
|
return { ...base, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utility: random number in range
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Random float in [min, max]. */
|
||||||
|
export function rand(min: number, max: number): number {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Random integer in [min, max] (inclusive). */
|
||||||
|
export function randInt(min: number, max: number): number {
|
||||||
|
return Math.floor(rand(min, max + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Random value from a [min, max] tuple. */
|
||||||
|
export function randRange(range: [number, number]): number {
|
||||||
|
return rand(range[0], range[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Random integer from a [min, max] tuple. */
|
||||||
|
export function randIntRange(range: [number, number]): number {
|
||||||
|
return randInt(range[0], range[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sleep for `ms` milliseconds. */
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
/**
|
||||||
|
* Human-like behavioral layer for cloakbrowser (JS/TS).
|
||||||
|
*
|
||||||
|
* Activated via humanize: true in launch() / launchContext().
|
||||||
|
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
|
||||||
|
*
|
||||||
|
* Patches all interaction methods:
|
||||||
|
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
|
||||||
|
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Browser, BrowserContext, Page, Frame } from 'playwright-core';
|
||||||
|
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
|
||||||
|
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||||
|
import { humanType } from './keyboard.js';
|
||||||
|
import { scrollToElement } from './scroll.js';
|
||||||
|
|
||||||
|
export { HumanConfig, resolveConfig } from './config.js';
|
||||||
|
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||||
|
export { humanType } from './keyboard.js';
|
||||||
|
export { scrollToElement } from './scroll.js';
|
||||||
|
|
||||||
|
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
|
||||||
|
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||||
|
|
||||||
|
class CursorState {
|
||||||
|
x = 0;
|
||||||
|
y = 0;
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isInputElement(page: Page, selector: string): Promise<boolean> {
|
||||||
|
return page.evaluate((sel: string) => {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
if (!el) return false;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
return tag === 'input' || tag === 'textarea'
|
||||||
|
|| el.getAttribute('contenteditable') === 'true';
|
||||||
|
}, selector).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isSelectorFocused(page: Page, selector: string): Promise<boolean> {
|
||||||
|
return page.evaluate((sel: string) => {
|
||||||
|
const el = document.querySelector(sel);
|
||||||
|
return el === document.activeElement;
|
||||||
|
}, selector).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Page-level patching
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace page methods with human-like implementations.
|
||||||
|
*/
|
||||||
|
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||||
|
const originals = {
|
||||||
|
click: page.click.bind(page),
|
||||||
|
dblclick: page.dblclick.bind(page),
|
||||||
|
hover: page.hover.bind(page),
|
||||||
|
type: page.type.bind(page),
|
||||||
|
fill: page.fill.bind(page),
|
||||||
|
check: page.check.bind(page),
|
||||||
|
uncheck: page.uncheck.bind(page),
|
||||||
|
selectOption: page.selectOption.bind(page),
|
||||||
|
press: page.press.bind(page),
|
||||||
|
goto: page.goto.bind(page),
|
||||||
|
isChecked: page.isChecked.bind(page),
|
||||||
|
mouseMove: page.mouse.move.bind(page.mouse),
|
||||||
|
mouseClick: page.mouse.click.bind(page.mouse),
|
||||||
|
mouseDblclick: page.mouse.dblclick.bind(page.mouse),
|
||||||
|
mouseWheel: page.mouse.wheel.bind(page.mouse),
|
||||||
|
mouseDown: page.mouse.down.bind(page.mouse),
|
||||||
|
mouseUp: page.mouse.up.bind(page.mouse),
|
||||||
|
keyboardType: page.keyboard.type.bind(page.keyboard),
|
||||||
|
keyboardDown: page.keyboard.down.bind(page.keyboard),
|
||||||
|
keyboardUp: page.keyboard.up.bind(page.keyboard),
|
||||||
|
keyboardPress: page.keyboard.press.bind(page.keyboard),
|
||||||
|
keyboardInsertText: page.keyboard.insertText.bind(page.keyboard),
|
||||||
|
};
|
||||||
|
|
||||||
|
(page as any)._original = originals;
|
||||||
|
(page as any)._humanCfg = cfg;
|
||||||
|
|
||||||
|
const raw: RawMouse = {
|
||||||
|
move: originals.mouseMove,
|
||||||
|
down: originals.mouseDown,
|
||||||
|
up: originals.mouseUp,
|
||||||
|
wheel: originals.mouseWheel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawKb: RawKeyboard = {
|
||||||
|
down: originals.keyboardDown,
|
||||||
|
up: originals.keyboardUp,
|
||||||
|
type: originals.keyboardType,
|
||||||
|
insertText: originals.keyboardInsertText,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureCursorInit(): Promise<void> {
|
||||||
|
if (!cursor.initialized) {
|
||||||
|
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
|
||||||
|
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
|
||||||
|
await originals.mouseMove(cursor.x, cursor.y);
|
||||||
|
cursor.initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- goto ---
|
||||||
|
const humanGoto = async (url: string, options?: any) => {
|
||||||
|
const response = await originals.goto(url, options);
|
||||||
|
// Patch any new frames after navigation
|
||||||
|
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- click ---
|
||||||
|
const humanClickFn = async (selector: string, options?: any) => {
|
||||||
|
await ensureCursorInit();
|
||||||
|
if (cfg.idle_between_actions) {
|
||||||
|
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||||
|
}
|
||||||
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||||
|
cursor.x = cursorX;
|
||||||
|
cursor.y = cursorY;
|
||||||
|
const isInput = await isInputElement(page, selector);
|
||||||
|
const target = clickTarget(box, isInput, cfg);
|
||||||
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||||
|
cursor.x = target.x;
|
||||||
|
cursor.y = target.y;
|
||||||
|
await humanClick(raw, isInput, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- dblclick ---
|
||||||
|
const humanDblclickFn = async (selector: string, options?: any) => {
|
||||||
|
await ensureCursorInit();
|
||||||
|
if (cfg.idle_between_actions) {
|
||||||
|
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||||
|
}
|
||||||
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||||
|
cursor.x = cursorX;
|
||||||
|
cursor.y = cursorY;
|
||||||
|
const isInput = await isInputElement(page, selector);
|
||||||
|
const target = clickTarget(box, isInput, cfg);
|
||||||
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||||
|
cursor.x = target.x;
|
||||||
|
cursor.y = target.y;
|
||||||
|
await raw.down({ clickCount: 2 });
|
||||||
|
await sleep(rand(30, 60));
|
||||||
|
await raw.up({ clickCount: 2 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- hover ---
|
||||||
|
const humanHoverFn = async (selector: string, options?: any) => {
|
||||||
|
await ensureCursorInit();
|
||||||
|
if (cfg.idle_between_actions) {
|
||||||
|
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||||
|
}
|
||||||
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||||
|
cursor.x = cursorX;
|
||||||
|
cursor.y = cursorY;
|
||||||
|
const target = clickTarget(box, false, cfg);
|
||||||
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||||
|
cursor.x = target.x;
|
||||||
|
cursor.y = target.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- type ---
|
||||||
|
const humanTypeFn = async (selector: string, text: string, options?: any) => {
|
||||||
|
await sleep(randRange(cfg.field_switch_delay));
|
||||||
|
await humanClickFn(selector);
|
||||||
|
await sleep(rand(100, 250));
|
||||||
|
await humanType(page, rawKb, text, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- fill (clears existing content first) ---
|
||||||
|
const humanFillFn = async (selector: string, value: string, options?: any) => {
|
||||||
|
await sleep(randRange(cfg.field_switch_delay));
|
||||||
|
await humanClickFn(selector);
|
||||||
|
await sleep(rand(100, 250));
|
||||||
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
|
await sleep(rand(30, 80));
|
||||||
|
await originals.keyboardPress('Backspace');
|
||||||
|
await sleep(rand(50, 150));
|
||||||
|
await humanType(page, rawKb, value, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- clear ---
|
||||||
|
const humanClearFn = async (selector: string, options?: any) => {
|
||||||
|
if (!await isSelectorFocused(page, selector)) {
|
||||||
|
await humanClickFn(selector);
|
||||||
|
}
|
||||||
|
await sleep(rand(50, 150));
|
||||||
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
|
await sleep(rand(30, 80));
|
||||||
|
await originals.keyboardPress('Backspace');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- check ---
|
||||||
|
const humanCheckFn = async (selector: string, options?: any) => {
|
||||||
|
if (cfg.idle_between_actions) {
|
||||||
|
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||||
|
}
|
||||||
|
const checked = await originals.isChecked(selector).catch(() => false);
|
||||||
|
if (!checked) {
|
||||||
|
await humanClickFn(selector);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- uncheck ---
|
||||||
|
const humanUncheckFn = async (selector: string, options?: any) => {
|
||||||
|
if (cfg.idle_between_actions) {
|
||||||
|
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||||
|
}
|
||||||
|
const checked = await originals.isChecked(selector).catch(() => true);
|
||||||
|
if (checked) {
|
||||||
|
await humanClickFn(selector);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- selectOption ---
|
||||||
|
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => {
|
||||||
|
await humanHoverFn(selector);
|
||||||
|
await sleep(rand(100, 300));
|
||||||
|
return originals.selectOption(selector, values, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- press (checks focus first — avoids redundant mouse moves) ---
|
||||||
|
const humanPressFn = async (selector: string, key: string, options?: any) => {
|
||||||
|
if (!await isSelectorFocused(page, selector)) {
|
||||||
|
await humanClickFn(selector);
|
||||||
|
}
|
||||||
|
await sleep(rand(50, 150));
|
||||||
|
await originals.keyboardPress(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- pressSequentially ---
|
||||||
|
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
|
||||||
|
if (!await isSelectorFocused(page, selector)) {
|
||||||
|
await humanClickFn(selector);
|
||||||
|
}
|
||||||
|
await sleep(rand(100, 250));
|
||||||
|
await humanType(page, rawKb, text, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- tap ---
|
||||||
|
const humanTapFn = async (selector: string, options?: any) => {
|
||||||
|
await humanClickFn(selector, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign page-level patches
|
||||||
|
(page as any).goto = humanGoto;
|
||||||
|
(page as any).click = humanClickFn;
|
||||||
|
(page as any).dblclick = humanDblclickFn;
|
||||||
|
(page as any).hover = humanHoverFn;
|
||||||
|
(page as any).type = humanTypeFn;
|
||||||
|
(page as any).fill = humanFillFn;
|
||||||
|
(page as any).check = humanCheckFn;
|
||||||
|
(page as any).uncheck = humanUncheckFn;
|
||||||
|
(page as any).selectOption = humanSelectOptionFn;
|
||||||
|
(page as any).press = humanPressFn;
|
||||||
|
|
||||||
|
// --- mouse patches ---
|
||||||
|
page.mouse.move = async (x: number, y: number, options?: any) => {
|
||||||
|
await ensureCursorInit();
|
||||||
|
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||||
|
cursor.x = x;
|
||||||
|
cursor.y = y;
|
||||||
|
};
|
||||||
|
|
||||||
|
page.mouse.click = async (x: number, y: number, options?: any) => {
|
||||||
|
await ensureCursorInit();
|
||||||
|
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||||
|
cursor.x = x;
|
||||||
|
cursor.y = y;
|
||||||
|
await humanClick(raw, false, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- keyboard patches ---
|
||||||
|
page.keyboard.type = async (text: string, options?: any) => {
|
||||||
|
await humanType(page, rawKb, text, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store helpers for frame patching
|
||||||
|
(page as any)._humanCursor = cursor;
|
||||||
|
(page as any)._humanRaw = raw;
|
||||||
|
(page as any)._humanRawKb = rawKb;
|
||||||
|
(page as any)._humanOriginals = originals;
|
||||||
|
(page as any)._humanClickFn = humanClickFn;
|
||||||
|
(page as any)._humanHoverFn = humanHoverFn;
|
||||||
|
(page as any)._humanClearFn = humanClearFn;
|
||||||
|
(page as any)._humanPressFn = humanPressFn;
|
||||||
|
(page as any)._humanPressSequentiallyFn = humanPressSequentiallyFn;
|
||||||
|
(page as any)._humanTapFn = humanTapFn;
|
||||||
|
(page as any)._ensureCursorInit = ensureCursorInit;
|
||||||
|
|
||||||
|
// Initialize cursor immediately so it doesn't visibly jump from (0,0)
|
||||||
|
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
|
||||||
|
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
|
||||||
|
originals.mouseMove(cursor.x, cursor.y).then(() => {
|
||||||
|
cursor.initialized = true;
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// --- Patch Frame-level methods (for sub-frames) ---
|
||||||
|
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Frame-level patching
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch Frame methods so Locator-based calls go through humanization.
|
||||||
|
* All 11 methods patched: click, dblclick, hover, type, fill, check, uncheck,
|
||||||
|
* selectOption, press, clear, dragAndDrop.
|
||||||
|
*/
|
||||||
|
function patchFrames(
|
||||||
|
page: Page,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
cursor: CursorState,
|
||||||
|
raw: RawMouse,
|
||||||
|
rawKb: RawKeyboard,
|
||||||
|
originals: any,
|
||||||
|
): void {
|
||||||
|
for (const frame of iterFrames(page)) {
|
||||||
|
patchSingleFrame(frame, page, cfg, originals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
|
||||||
|
if ((frame as any)._humanPatched) return;
|
||||||
|
(frame as any)._humanPatched = true;
|
||||||
|
|
||||||
|
// Save originals for methods that need fallback
|
||||||
|
const origFrameSelectOption = frame.selectOption.bind(frame);
|
||||||
|
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
|
||||||
|
|
||||||
|
(frame as any).click = async (selector: string, options?: any) => {
|
||||||
|
await (page as any).click(selector, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).dblclick = async (selector: string, options?: any) => {
|
||||||
|
await (page as any).dblclick(selector, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).hover = async (selector: string, options?: any) => {
|
||||||
|
await (page as any).hover(selector, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).type = async (selector: string, text: string, options?: any) => {
|
||||||
|
await (page as any).type(selector, text, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).fill = async (selector: string, value: string, options?: any) => {
|
||||||
|
await (page as any).fill(selector, value, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).check = async (selector: string, options?: any) => {
|
||||||
|
await (page as any).check(selector, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).uncheck = async (selector: string, options?: any) => {
|
||||||
|
await (page as any).uncheck(selector, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).selectOption = async (selector: string, values: any, options?: any) => {
|
||||||
|
await (page as any).hover(selector);
|
||||||
|
await sleep(rand(100, 300));
|
||||||
|
return origFrameSelectOption(selector, values, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).press = async (selector: string, key: string, options?: any) => {
|
||||||
|
await (page as any).press(selector, key, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).clear = async (selector: string, options?: any) => {
|
||||||
|
if (!await isSelectorFocused(page, selector)) {
|
||||||
|
await (page as any).click(selector);
|
||||||
|
}
|
||||||
|
await sleep(rand(50, 150));
|
||||||
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
|
await sleep(rand(30, 80));
|
||||||
|
await originals.keyboardPress('Backspace');
|
||||||
|
};
|
||||||
|
|
||||||
|
(frame as any).dragAndDrop = async (source: string, target: string, options?: any) => {
|
||||||
|
const srcBox = await frame.locator(source).boundingBox().catch(() => null);
|
||||||
|
const tgtBox = await frame.locator(target).boundingBox().catch(() => null);
|
||||||
|
|
||||||
|
if (srcBox && tgtBox) {
|
||||||
|
const sx = srcBox.x + srcBox.width / 2;
|
||||||
|
const sy = srcBox.y + srcBox.height / 2;
|
||||||
|
const tx = tgtBox.x + tgtBox.width / 2;
|
||||||
|
const ty = tgtBox.y + tgtBox.height / 2;
|
||||||
|
|
||||||
|
await page.mouse.move(sx, sy);
|
||||||
|
await sleep(rand(100, 200));
|
||||||
|
await originals.mouseDown();
|
||||||
|
await sleep(rand(80, 150));
|
||||||
|
await page.mouse.move(tx, ty);
|
||||||
|
await sleep(rand(80, 150));
|
||||||
|
await originals.mouseUp();
|
||||||
|
} else {
|
||||||
|
return origFrameDragAndDrop(source, target, options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function* iterFrames(page: Page): Generator<Frame> {
|
||||||
|
try {
|
||||||
|
const mainFrame = page.mainFrame();
|
||||||
|
yield mainFrame;
|
||||||
|
for (const child of mainFrame.childFrames()) {
|
||||||
|
yield child;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context-level patching
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function patchContext(context: BrowserContext, cfg: HumanConfig): void {
|
||||||
|
const cursor = new CursorState();
|
||||||
|
for (const page of context.pages()) {
|
||||||
|
patchPage(page, cfg, cursor);
|
||||||
|
}
|
||||||
|
context.on('page', (page: Page) => {
|
||||||
|
if (!(page as any)._original) {
|
||||||
|
patchPage(page, cfg, new CursorState());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const origNewPage = context.newPage.bind(context);
|
||||||
|
(context as any).newPage = async () => {
|
||||||
|
const page = await origNewPage();
|
||||||
|
if (!(page as any)._original) {
|
||||||
|
patchPage(page, cfg, new CursorState());
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Browser-level patching
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
|
||||||
|
for (const context of browser.contexts()) {
|
||||||
|
patchContext(context, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const origNewContext = browser.newContext.bind(browser);
|
||||||
|
(browser as any).newContext = async (options?: any) => {
|
||||||
|
const context = await origNewContext(options);
|
||||||
|
patchContext(context, cfg);
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
const origNewPage = browser.newPage.bind(browser);
|
||||||
|
(browser as any).newPage = async (options?: any) => {
|
||||||
|
const page = await origNewPage(options);
|
||||||
|
if (!(page as any)._original) {
|
||||||
|
const ctx = page.context();
|
||||||
|
if (!(ctx as any)._humanPatched) {
|
||||||
|
patchContext(ctx, cfg);
|
||||||
|
(ctx as any)._humanPatched = true;
|
||||||
|
}
|
||||||
|
patchPage(page, cfg, new CursorState());
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { patchContext, patchPage };
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* cloakbrowser-human — Human-like keyboard input.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Page } from 'playwright-core';
|
||||||
|
import { RawKeyboard } from './mouse.js';
|
||||||
|
import { HumanConfig, rand, randRange, sleep } from './config.js';
|
||||||
|
|
||||||
|
const SHIFT_SYMBOLS = new Set([
|
||||||
|
'@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
|
||||||
|
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NEARBY_KEYS: Record<string, string> = {
|
||||||
|
a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf',
|
||||||
|
f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm',
|
||||||
|
k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp',
|
||||||
|
p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy',
|
||||||
|
u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu',
|
||||||
|
z: 'asx',
|
||||||
|
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
|
||||||
|
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getNearbyKey(ch: string): string {
|
||||||
|
const lower = ch.toLowerCase();
|
||||||
|
if (lower in NEARBY_KEYS) {
|
||||||
|
const neighbors = NEARBY_KEYS[lower];
|
||||||
|
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
|
||||||
|
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
|
||||||
|
}
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function humanType(
|
||||||
|
page: Page,
|
||||||
|
raw: RawKeyboard,
|
||||||
|
text: string,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
|
||||||
|
// Mistype chance — press wrong key, notice, backspace, then correct
|
||||||
|
if (Math.random() < cfg.mistype_chance && /[a-zA-Z0-9]/.test(ch)) {
|
||||||
|
const wrong = getNearbyKey(ch);
|
||||||
|
await typeNormalChar(raw, wrong, cfg);
|
||||||
|
await sleep(randRange(cfg.mistype_delay_notice));
|
||||||
|
await raw.down('Backspace');
|
||||||
|
await sleep(randRange(cfg.key_hold));
|
||||||
|
await raw.up('Backspace');
|
||||||
|
await sleep(randRange(cfg.mistype_delay_correct));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUpperCase(ch)) {
|
||||||
|
await typeShiftedChar(raw, ch, cfg);
|
||||||
|
} else if (SHIFT_SYMBOLS.has(ch)) {
|
||||||
|
await typeShiftSymbol(page, raw, ch, cfg);
|
||||||
|
} else {
|
||||||
|
await typeNormalChar(raw, ch, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < text.length - 1) {
|
||||||
|
await interCharDelay(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||||
|
await raw.down(ch);
|
||||||
|
await sleep(randRange(cfg.key_hold));
|
||||||
|
await raw.up(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||||
|
await raw.down('Shift');
|
||||||
|
await sleep(randRange(cfg.shift_down_delay));
|
||||||
|
await raw.down(ch);
|
||||||
|
await sleep(randRange(cfg.key_hold));
|
||||||
|
await raw.up(ch);
|
||||||
|
await sleep(randRange(cfg.shift_up_delay));
|
||||||
|
await raw.up('Shift');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||||
|
await raw.down('Shift');
|
||||||
|
await sleep(randRange(cfg.shift_down_delay));
|
||||||
|
await raw.insertText(ch);
|
||||||
|
await page.evaluate((key: string) => {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (el) {
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||||
|
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||||
|
}
|
||||||
|
}, ch);
|
||||||
|
await sleep(randRange(cfg.shift_up_delay));
|
||||||
|
await raw.up('Shift');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUpperCase(ch: string): boolean {
|
||||||
|
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function interCharDelay(cfg: HumanConfig): Promise<void> {
|
||||||
|
if (Math.random() < cfg.typing_pause_chance) {
|
||||||
|
await sleep(randRange(cfg.typing_pause_range));
|
||||||
|
} else {
|
||||||
|
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
|
||||||
|
await sleep(Math.max(10, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* cloakbrowser-human — Human-like mouse movement and clicking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Raw interface — original Playwright methods, bypassing the wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RawMouse {
|
||||||
|
move: (x: number, y: number) => Promise<void>;
|
||||||
|
down: (options?: any) => Promise<void>;
|
||||||
|
up: (options?: any) => Promise<void>;
|
||||||
|
wheel: (deltaX: number, deltaY: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawKeyboard {
|
||||||
|
down: (key: string) => Promise<void>;
|
||||||
|
up: (key: string) => Promise<void>;
|
||||||
|
type: (text: string) => Promise<void>;
|
||||||
|
insertText: (text: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Easing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function easeInOut(t: number): number {
|
||||||
|
return t < 0.5
|
||||||
|
? 4 * t * t * t
|
||||||
|
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bezier
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
|
||||||
|
const u = 1 - t;
|
||||||
|
const uu = u * u;
|
||||||
|
const uuu = uu * u;
|
||||||
|
const tt = t * t;
|
||||||
|
const ttt = tt * t;
|
||||||
|
return {
|
||||||
|
x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
|
||||||
|
y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomControlPoints(start: Point, end: Point): [Point, Point] {
|
||||||
|
const dx = end.x - start.x;
|
||||||
|
const dy = end.y - start.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const px = -dy / (dist || 1);
|
||||||
|
const py = dx / (dist || 1);
|
||||||
|
const bias1 = rand(-0.3, 0.3) * dist;
|
||||||
|
const bias2 = rand(-0.3, 0.3) * dist;
|
||||||
|
return [
|
||||||
|
{ x: start.x + dx * 0.25 + px * bias1, y: start.y + dy * 0.25 + py * bias1 },
|
||||||
|
{ x: start.x + dx * 0.75 + px * bias2, y: start.y + dy * 0.75 + py * bias2 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Human mouse movement
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function humanMove(
|
||||||
|
raw: RawMouse,
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
endX: number,
|
||||||
|
endY: number,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const dist = Math.hypot(endX - startX, endY - startY);
|
||||||
|
if (dist < 1) return;
|
||||||
|
|
||||||
|
const steps = Math.max(
|
||||||
|
cfg.mouse_min_steps,
|
||||||
|
Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const start: Point = { x: startX, y: startY };
|
||||||
|
const end: Point = { x: endX, y: endY };
|
||||||
|
const [cp1, cp2] = randomControlPoints(start, end);
|
||||||
|
|
||||||
|
let burstCounter = 0;
|
||||||
|
const burstSize = randIntRange(cfg.mouse_burst_size);
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const progress = i / steps;
|
||||||
|
const easedT = easeInOut(progress);
|
||||||
|
const pt = bezier(start, cp1, cp2, end, easedT);
|
||||||
|
|
||||||
|
const wobbleAmp = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max;
|
||||||
|
const wx = pt.x + (Math.random() - 0.5) * 2 * wobbleAmp;
|
||||||
|
const wy = pt.y + (Math.random() - 0.5) * 2 * wobbleAmp;
|
||||||
|
|
||||||
|
await raw.move(Math.round(wx), Math.round(wy));
|
||||||
|
|
||||||
|
burstCounter++;
|
||||||
|
if (burstCounter >= burstSize && i < steps) {
|
||||||
|
await sleep(randRange(cfg.mouse_burst_pause));
|
||||||
|
burstCounter = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.random() < cfg.mouse_overshoot_chance) {
|
||||||
|
const overshootDist = randRange(cfg.mouse_overshoot_px);
|
||||||
|
const angle = Math.atan2(endY - startY, endX - startX);
|
||||||
|
const ovX = Math.round(endX + Math.cos(angle) * overshootDist);
|
||||||
|
const ovY = Math.round(endY + Math.sin(angle) * overshootDist);
|
||||||
|
await raw.move(ovX, ovY);
|
||||||
|
await sleep(rand(30, 70));
|
||||||
|
const corrX = Math.round(endX + (Math.random() - 0.5) * 4);
|
||||||
|
const corrY = Math.round(endY + (Math.random() - 0.5) * 4);
|
||||||
|
await raw.move(corrX, corrY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Human click
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function clickTarget(
|
||||||
|
box: { x: number; y: number; width: number; height: number },
|
||||||
|
isInput: boolean,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
): Point {
|
||||||
|
if (isInput) {
|
||||||
|
const xFrac = randRange(cfg.click_input_x_range);
|
||||||
|
const yFrac = rand(0.30, 0.70);
|
||||||
|
return {
|
||||||
|
x: Math.round(box.x + box.width * xFrac),
|
||||||
|
y: Math.round(box.y + box.height * yFrac),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const xFrac = rand(0.35, 0.65);
|
||||||
|
const yFrac = rand(0.35, 0.65);
|
||||||
|
return {
|
||||||
|
x: Math.round(box.x + box.width * xFrac),
|
||||||
|
y: Math.round(box.y + box.height * yFrac),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function humanClick(
|
||||||
|
raw: RawMouse,
|
||||||
|
isInput: boolean,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const aimDelay = isInput
|
||||||
|
? randRange(cfg.click_aim_delay_input)
|
||||||
|
: randRange(cfg.click_aim_delay_button);
|
||||||
|
await sleep(aimDelay);
|
||||||
|
|
||||||
|
const holdTime = isInput
|
||||||
|
? randRange(cfg.click_hold_input)
|
||||||
|
: randRange(cfg.click_hold_button);
|
||||||
|
await raw.down();
|
||||||
|
await sleep(holdTime);
|
||||||
|
await raw.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Human idle / drift
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function humanIdle(
|
||||||
|
raw: RawMouse,
|
||||||
|
seconds: number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const endTime = Date.now() + seconds * 1000;
|
||||||
|
let x = cx;
|
||||||
|
let y = cy;
|
||||||
|
while (Date.now() < endTime) {
|
||||||
|
const dx = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
|
||||||
|
const dy = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
|
||||||
|
x += dx;
|
||||||
|
y += dy;
|
||||||
|
await raw.move(Math.round(x), Math.round(y));
|
||||||
|
await sleep(randRange(cfg.idle_pause_range));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* cloakbrowser-human — Human-like scrolling via mouse wheel events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Page } from 'playwright-core';
|
||||||
|
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
|
||||||
|
import { RawMouse, humanMove } from './mouse.js';
|
||||||
|
|
||||||
|
interface ElementBounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInViewport(
|
||||||
|
bounds: ElementBounds,
|
||||||
|
viewportHeight: number,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
): boolean {
|
||||||
|
const topEdge = bounds.y;
|
||||||
|
const bottomEdge = bounds.y + bounds.height;
|
||||||
|
const zoneTop = viewportHeight * cfg.scroll_target_zone[0];
|
||||||
|
const zoneBottom = viewportHeight * cfg.scroll_target_zone[1];
|
||||||
|
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Promise<void> {
|
||||||
|
const absD = Math.abs(delta);
|
||||||
|
const sign = delta > 0 ? 1 : -1;
|
||||||
|
let sent = 0;
|
||||||
|
while (sent < absD) {
|
||||||
|
const stepSize = rand(20, 40);
|
||||||
|
const chunk = Math.min(stepSize, absD - sent);
|
||||||
|
await raw.wheel(0, Math.round(chunk) * sign);
|
||||||
|
sent += chunk;
|
||||||
|
await sleep(rand(8, 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scrollToElement(
|
||||||
|
page: Page,
|
||||||
|
raw: RawMouse,
|
||||||
|
selector: string,
|
||||||
|
cursorX: number,
|
||||||
|
cursorY: number,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
if (!viewport) throw new Error('Viewport size not available');
|
||||||
|
|
||||||
|
let box = await getElementBox(page, selector);
|
||||||
|
if (!box) {
|
||||||
|
await sleep(200);
|
||||||
|
box = await getElementBox(page, selector);
|
||||||
|
if (!box) throw new Error(`Element not found: ${selector}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInViewport(box, viewport.height, cfg)) {
|
||||||
|
return { box, cursorX, cursorY };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move cursor into scroll area
|
||||||
|
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
|
||||||
|
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
|
||||||
|
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
|
||||||
|
cursorX = scrollAreaX;
|
||||||
|
cursorY = scrollAreaY;
|
||||||
|
await sleep(randRange(cfg.scroll_pre_move_delay));
|
||||||
|
|
||||||
|
// Calculate scroll distance
|
||||||
|
const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]);
|
||||||
|
const elementCenter = box.y + box.height / 2;
|
||||||
|
const distanceToScroll = elementCenter - targetY;
|
||||||
|
|
||||||
|
const direction = distanceToScroll > 0 ? 1 : -1;
|
||||||
|
const absDistance = Math.abs(distanceToScroll);
|
||||||
|
const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
|
||||||
|
const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta));
|
||||||
|
const accelSteps = randIntRange(cfg.scroll_accel_steps);
|
||||||
|
const decelSteps = randIntRange(cfg.scroll_decel_steps);
|
||||||
|
|
||||||
|
let scrolled = 0;
|
||||||
|
|
||||||
|
// Scroll loop: accelerate → cruise → decelerate
|
||||||
|
for (let i = 0; i < totalClicks; i++) {
|
||||||
|
let delta: number;
|
||||||
|
let pause: number;
|
||||||
|
|
||||||
|
if (i < accelSteps) {
|
||||||
|
delta = rand(80, 100);
|
||||||
|
pause = randRange(cfg.scroll_pause_slow);
|
||||||
|
} else if (i >= totalClicks - decelSteps) {
|
||||||
|
delta = rand(60, 90);
|
||||||
|
pause = randRange(cfg.scroll_pause_slow);
|
||||||
|
} else {
|
||||||
|
delta = randRange(cfg.scroll_delta_base);
|
||||||
|
pause = randRange(cfg.scroll_pause_fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance;
|
||||||
|
delta = Math.round(delta) * direction;
|
||||||
|
|
||||||
|
await smoothWheel(raw, delta, cfg);
|
||||||
|
scrolled += Math.abs(delta);
|
||||||
|
await sleep(pause);
|
||||||
|
|
||||||
|
// Check visibility every 3 steps
|
||||||
|
if (i % 3 === 2 || i === totalClicks - 1) {
|
||||||
|
box = await getElementBox(page, selector);
|
||||||
|
if (box && isInViewport(box, viewport.height, cfg)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrolled >= absDistance * 1.1) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional overshoot + correction
|
||||||
|
if (Math.random() < cfg.scroll_overshoot_chance) {
|
||||||
|
const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction;
|
||||||
|
await smoothWheel(raw, overshootPx, cfg);
|
||||||
|
await sleep(randRange(cfg.scroll_settle_delay));
|
||||||
|
|
||||||
|
const corrections = randIntRange([1, 2]);
|
||||||
|
for (let c = 0; c < corrections; c++) {
|
||||||
|
const corrDelta = Math.round(rand(40, 80)) * -direction;
|
||||||
|
await smoothWheel(raw, corrDelta, cfg);
|
||||||
|
await sleep(rand(100, 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle
|
||||||
|
await sleep(randRange(cfg.scroll_settle_delay));
|
||||||
|
|
||||||
|
box = await getElementBox(page, selector);
|
||||||
|
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
|
||||||
|
|
||||||
|
return { box, cursorX, cursorY };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
|
||||||
|
const el = page.locator(selector).first();
|
||||||
|
try {
|
||||||
|
const box = await el.boundingBox({ timeout: 2000 });
|
||||||
|
return box;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,17 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
...options.launchOptions,
|
...options.launchOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Human-like behavioral patching
|
||||||
|
if (options.humanize) {
|
||||||
|
const { patchBrowser } = await import('./human/index.js');
|
||||||
|
const { resolveConfig } = await import('./human/config.js');
|
||||||
|
const cfg = resolveConfig(
|
||||||
|
(options.humanPreset as any) ?? 'default',
|
||||||
|
options.humanConfig as any,
|
||||||
|
);
|
||||||
|
patchBrowser(browser, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
return browser;
|
return browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +114,17 @@ export async function launchContext(
|
|||||||
await browser.close();
|
await browser.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Human-like behavioral patching
|
||||||
|
if (options.humanize) {
|
||||||
|
const { patchContext } = await import('./human/index.js');
|
||||||
|
const { resolveConfig } = await import('./human/config.js');
|
||||||
|
const cfg = resolveConfig(
|
||||||
|
(options.humanPreset as any) ?? 'default',
|
||||||
|
options.humanConfig as any,
|
||||||
|
);
|
||||||
|
patchContext(context, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +175,17 @@ export async function launchPersistentContext(
|
|||||||
...options.launchOptions,
|
...options.launchOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Human-like behavioral patching
|
||||||
|
if (options.humanize) {
|
||||||
|
const { patchContext } = await import('./human/index.js');
|
||||||
|
const { resolveConfig } = await import('./human/config.js');
|
||||||
|
const cfg = resolveConfig(
|
||||||
|
(options.humanPreset as any) ?? 'default',
|
||||||
|
options.humanConfig as any,
|
||||||
|
);
|
||||||
|
patchContext(context, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export interface LaunchOptions {
|
|||||||
geoip?: boolean;
|
geoip?: boolean;
|
||||||
/** Raw options passed directly to playwright/puppeteer launch(). */
|
/** Raw options passed directly to playwright/puppeteer launch(). */
|
||||||
launchOptions?: Record<string, unknown>;
|
launchOptions?: Record<string, unknown>;
|
||||||
|
/** Enable human-like mouse, keyboard, and scroll behavior. */
|
||||||
|
humanize?: boolean;
|
||||||
|
/** Human behavior preset: 'default' or 'careful'. */
|
||||||
|
humanPreset?: 'default' | 'careful';
|
||||||
|
/** Override individual human behavior parameters. */
|
||||||
|
humanConfig?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaunchContextOptions extends LaunchOptions {
|
export interface LaunchContextOptions extends LaunchOptions {
|
||||||
|
|||||||
@@ -0,0 +1,552 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js";
|
||||||
|
import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js";
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Config resolution
|
||||||
|
// =========================================================================
|
||||||
|
describe("resolveConfig", () => {
|
||||||
|
it("returns valid default config", () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
expect(cfg).toBeDefined();
|
||||||
|
expect(cfg.mouse_min_steps).toBeGreaterThan(0);
|
||||||
|
expect(cfg.mouse_max_steps).toBeGreaterThan(cfg.mouse_min_steps);
|
||||||
|
expect(cfg.typing_delay).toBeGreaterThan(0);
|
||||||
|
expect(cfg.initial_cursor_x).toHaveLength(2);
|
||||||
|
expect(cfg.initial_cursor_y).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns valid careful config with slower typing", () => {
|
||||||
|
const cfg = resolveConfig("careful");
|
||||||
|
const def = resolveConfig("default");
|
||||||
|
expect(cfg).toBeDefined();
|
||||||
|
expect(cfg.typing_delay).toBeGreaterThanOrEqual(def.typing_delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom overrides", () => {
|
||||||
|
const cfg = resolveConfig("default", { mouse_min_steps: 100, mouse_max_steps: 200 });
|
||||||
|
expect(cfg.mouse_min_steps).toBe(100);
|
||||||
|
expect(cfg.mouse_max_steps).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves idle_between_actions override", () => {
|
||||||
|
const cfg = resolveConfig("default", {
|
||||||
|
idle_between_actions: true,
|
||||||
|
idle_between_duration: [50, 100],
|
||||||
|
});
|
||||||
|
expect(cfg.idle_between_actions).toBe(true);
|
||||||
|
expect(cfg.idle_between_duration[0]).toBe(50);
|
||||||
|
expect(cfg.idle_between_duration[1]).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on unknown preset name", () => {
|
||||||
|
expect(() => resolveConfig("nonexistent" as any)).toThrow(/Unknown humanize preset/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all required fields including mistype", () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const required = [
|
||||||
|
"mouse_min_steps", "mouse_max_steps", "typing_delay",
|
||||||
|
"initial_cursor_x", "initial_cursor_y", "idle_between_actions",
|
||||||
|
"idle_between_duration", "field_switch_delay",
|
||||||
|
"mistype_chance", "mistype_delay_notice", "mistype_delay_correct",
|
||||||
|
];
|
||||||
|
for (const f of required) {
|
||||||
|
expect(cfg).toHaveProperty(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mistype_delay fields are [min, max] tuples", () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true);
|
||||||
|
expect(cfg.mistype_delay_notice).toHaveLength(2);
|
||||||
|
expect(cfg.mistype_delay_notice[0]).toBeLessThanOrEqual(cfg.mistype_delay_notice[1]);
|
||||||
|
expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true);
|
||||||
|
expect(cfg.mistype_delay_correct).toHaveLength(2);
|
||||||
|
expect(cfg.mistype_delay_correct[0]).toBeLessThanOrEqual(cfg.mistype_delay_correct[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// rand / randRange / sleep
|
||||||
|
// =========================================================================
|
||||||
|
describe("rand helpers", () => {
|
||||||
|
it("rand stays within bounds over many iterations", () => {
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
const v = rand(10, 20);
|
||||||
|
expect(v).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(v).toBeLessThanOrEqual(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("randRange stays within bounds", () => {
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
const v = randRange([5, 15]);
|
||||||
|
expect(v).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(v).toBeLessThanOrEqual(15);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sleep pauses for approximately correct duration", async () => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
await sleep(50);
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(40);
|
||||||
|
expect(elapsed).toBeLessThan(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Bézier mouse movement (behavioral with vi.fn mocks)
|
||||||
|
// =========================================================================
|
||||||
|
describe("humanMove", () => {
|
||||||
|
function makeFakeRaw() {
|
||||||
|
const moves: Array<{ x: number; y: number }> = [];
|
||||||
|
return {
|
||||||
|
raw: {
|
||||||
|
move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }),
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
wheel: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
moves,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("generates multiple intermediate points", async () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const { raw, moves } = makeFakeRaw();
|
||||||
|
await humanMove(raw, 0, 0, 500, 300, cfg);
|
||||||
|
expect(moves.length).toBeGreaterThanOrEqual(10);
|
||||||
|
const last = moves[moves.length - 1];
|
||||||
|
expect(Math.abs(last.x - 500)).toBeLessThan(10);
|
||||||
|
expect(Math.abs(last.y - 300)).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raw.move called exactly once per step", async () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const { raw, moves } = makeFakeRaw();
|
||||||
|
await humanMove(raw, 0, 0, 400, 400, cfg);
|
||||||
|
expect(raw.move).toHaveBeenCalledTimes(moves.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no single jump exceeds 50% of total distance", async () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const { raw, moves } = makeFakeRaw();
|
||||||
|
await humanMove(raw, 0, 0, 400, 400, cfg);
|
||||||
|
const totalDist = Math.sqrt(400 ** 2 + 400 ** 2);
|
||||||
|
const maxJump = totalDist * 0.5;
|
||||||
|
for (let i = 1; i < moves.length; i++) {
|
||||||
|
const dx = moves[i].x - moves[i - 1].x;
|
||||||
|
const dy = moves[i].y - moves[i - 1].y;
|
||||||
|
expect(Math.sqrt(dx * dx + dy * dy)).toBeLessThan(maxJump);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces curved path (not a straight line)", async () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
let maxDev = 0;
|
||||||
|
for (let trial = 0; trial < 10; trial++) {
|
||||||
|
const { raw, moves } = makeFakeRaw();
|
||||||
|
await humanMove(raw, 0, 0, 500, 0, cfg);
|
||||||
|
const dev = Math.max(...moves.map(m => Math.abs(m.y)));
|
||||||
|
if (dev > maxDev) maxDev = dev;
|
||||||
|
}
|
||||||
|
expect(maxDev).toBeGreaterThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles very short distances", async () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const { raw, moves } = makeFakeRaw();
|
||||||
|
await humanMove(raw, 100, 100, 103, 102, cfg);
|
||||||
|
expect(moves.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles zero distance without crashing", async () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const { raw } = makeFakeRaw();
|
||||||
|
await humanMove(raw, 200, 200, 200, 200, cfg);
|
||||||
|
// Completes without error; may or may not call move (both valid)
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// humanClick behavioral
|
||||||
|
// =========================================================================
|
||||||
|
describe("humanClick", () => {
|
||||||
|
it("calls down then up in correct order", async () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const raw = {
|
||||||
|
move: vi.fn(async () => {}),
|
||||||
|
down: vi.fn(async () => { callOrder.push("down"); }),
|
||||||
|
up: vi.fn(async () => { callOrder.push("up"); }),
|
||||||
|
wheel: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
await humanClick(raw, false, cfg);
|
||||||
|
expect(raw.down).toHaveBeenCalledTimes(1);
|
||||||
|
expect(raw.up).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callOrder).toEqual(["down", "up"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// humanIdle behavioral
|
||||||
|
// =========================================================================
|
||||||
|
describe("humanIdle", () => {
|
||||||
|
it("calls raw.move at least once during idle", async () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const raw = {
|
||||||
|
move: vi.fn(async () => {}),
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
wheel: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
await humanIdle(raw, 10, 100, 100, cfg);
|
||||||
|
expect(raw.move).toHaveBeenCalled();
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// clickTarget
|
||||||
|
// =========================================================================
|
||||||
|
describe("clickTarget", () => {
|
||||||
|
it("returns point within bounding box", () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const box = { x: 100, y: 200, width: 150, height: 40 };
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const t = clickTarget(box, false, cfg);
|
||||||
|
expect(t.x).toBeGreaterThanOrEqual(100);
|
||||||
|
expect(t.x).toBeLessThanOrEqual(250);
|
||||||
|
expect(t.y).toBeGreaterThanOrEqual(200);
|
||||||
|
expect(t.y).toBeLessThanOrEqual(240);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isInput=true biases click toward left side of box", () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const box = { x: 50, y: 50, width: 200, height: 30 };
|
||||||
|
let sumX = 0;
|
||||||
|
const N = 300;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const t = clickTarget(box, true, cfg);
|
||||||
|
expect(t.x).toBeGreaterThanOrEqual(50);
|
||||||
|
expect(t.x).toBeLessThanOrEqual(250);
|
||||||
|
sumX += t.x;
|
||||||
|
}
|
||||||
|
const avgX = sumX / N;
|
||||||
|
expect(avgX).toBeLessThan(175);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not crash with 1x1 box", () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const t = clickTarget({ x: 0, y: 0, width: 1, height: 1 }, false, cfg);
|
||||||
|
expect(t.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(t.x).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// patchPage behavioral: fill uses platform SELECT_ALL
|
||||||
|
// =========================================================================
|
||||||
|
describe("patchPage fill", () => {
|
||||||
|
it("fill calls keyboard.press with platform-correct select-all", async () => {
|
||||||
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
|
|
||||||
|
const pressedKeys: string[] = [];
|
||||||
|
const page = buildMockPage({
|
||||||
|
keyboardPress: async (key: string) => { pressedKeys.push(key); },
|
||||||
|
evaluate: async () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
|
try { await (page as any).fill("input#name", "hello"); } catch (_) {}
|
||||||
|
|
||||||
|
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
||||||
|
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
|
||||||
|
if (pressedKeys.length > 0) {
|
||||||
|
expect(pressedKeys).toContain(expected);
|
||||||
|
expect(pressedKeys).not.toContain(wrong);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// patchPage behavioral: check/uncheck with idle_between_actions
|
||||||
|
// =========================================================================
|
||||||
|
describe("patchPage check/uncheck idle", () => {
|
||||||
|
it("check with idle=true calls humanClickFn and does not crash on idle", async () => {
|
||||||
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
|
|
||||||
|
let downCalled = false;
|
||||||
|
const page = buildMockPage({
|
||||||
|
isChecked: async () => false,
|
||||||
|
evaluate: async () => false,
|
||||||
|
});
|
||||||
|
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||||
|
|
||||||
|
const cfg = resolveConfig("default", {
|
||||||
|
idle_between_actions: true,
|
||||||
|
idle_between_duration: [1, 2],
|
||||||
|
});
|
||||||
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
|
try { await (page as any).check("input#cb"); } catch (_) {}
|
||||||
|
|
||||||
|
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
|
||||||
|
expect(downCalled).toBe(true);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("uncheck with idle=true calls humanClickFn and does not crash on idle", async () => {
|
||||||
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
|
|
||||||
|
let downCalled = false;
|
||||||
|
const page = buildMockPage({
|
||||||
|
isChecked: async () => true,
|
||||||
|
evaluate: async () => false,
|
||||||
|
});
|
||||||
|
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||||
|
|
||||||
|
const cfg = resolveConfig("default", {
|
||||||
|
idle_between_actions: true,
|
||||||
|
idle_between_duration: [1, 2],
|
||||||
|
});
|
||||||
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
|
try { await (page as any).uncheck("input#cb"); } catch (_) {}
|
||||||
|
|
||||||
|
expect(downCalled).toBe(true);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("config with idle=true is accepted by resolveConfig", () => {
|
||||||
|
const cfg = resolveConfig("default", {
|
||||||
|
idle_between_actions: true,
|
||||||
|
idle_between_duration: [5, 10],
|
||||||
|
});
|
||||||
|
expect(cfg.idle_between_actions).toBe(true);
|
||||||
|
expect(cfg.idle_between_duration).toEqual([5, 10]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// patchPage behavioral: press focus check
|
||||||
|
// =========================================================================
|
||||||
|
describe("patchPage press focus", () => {
|
||||||
|
it("press clicks element when NOT focused (mouse.down called)", async () => {
|
||||||
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
|
|
||||||
|
let downCount = 0;
|
||||||
|
const page = buildMockPage({
|
||||||
|
evaluate: async () => false,
|
||||||
|
});
|
||||||
|
// Intercept mouse.down before patching so raw captures it
|
||||||
|
page.mouse.down = vi.fn(async () => { downCount++; });
|
||||||
|
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
|
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
|
||||||
|
|
||||||
|
expect(downCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("press skips click when element IS focused (no mouse.down)", async () => {
|
||||||
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
|
|
||||||
|
let downCount = 0;
|
||||||
|
const page = buildMockPage({
|
||||||
|
evaluate: async () => true,
|
||||||
|
});
|
||||||
|
page.mouse.down = vi.fn(async () => { downCount++; });
|
||||||
|
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
|
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
|
||||||
|
|
||||||
|
expect(downCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// patchPage behavioral: frame patching
|
||||||
|
// =========================================================================
|
||||||
|
describe("patchPage frame patching", () => {
|
||||||
|
it("patches child frames with _humanPatched flag", async () => {
|
||||||
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
|
|
||||||
|
const childFrame = buildMockFrame();
|
||||||
|
const mainFrame = {
|
||||||
|
...buildMockFrame(),
|
||||||
|
childFrames: vi.fn(() => [childFrame]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const page = buildMockPage({ mainFrameReturn: mainFrame });
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
|
expect((childFrame as any)._humanPatched).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Mistype config
|
||||||
|
// =========================================================================
|
||||||
|
describe("mistype config", () => {
|
||||||
|
it("default config has valid mistype fields", () => {
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
expect(typeof cfg.mistype_chance).toBe("number");
|
||||||
|
expect(cfg.mistype_chance).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(cfg.mistype_chance).toBeLessThanOrEqual(1);
|
||||||
|
// mistype_delay_notice and mistype_delay_correct are [min, max] tuples
|
||||||
|
expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true);
|
||||||
|
expect(cfg.mistype_delay_notice).toHaveLength(2);
|
||||||
|
expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true);
|
||||||
|
expect(cfg.mistype_delay_correct).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mistype_chance can be overridden to 0 (disabled)", () => {
|
||||||
|
const cfg = resolveConfig("default", { mistype_chance: 0 });
|
||||||
|
expect(cfg.mistype_chance).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mistype_chance can be overridden to higher value", () => {
|
||||||
|
const cfg = resolveConfig("default", { mistype_chance: 0.15 });
|
||||||
|
expect(cfg.mistype_chance).toBe(0.15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Module exports
|
||||||
|
// =========================================================================
|
||||||
|
describe("module exports", () => {
|
||||||
|
it("patchBrowser, patchContext, patchPage are all exported functions", async () => {
|
||||||
|
const mod = await import("../src/human/index.js");
|
||||||
|
expect(typeof mod.patchBrowser).toBe("function");
|
||||||
|
expect(typeof mod.patchContext).toBe("function");
|
||||||
|
expect(typeof mod.patchPage).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("humanMove, humanClick, clickTarget, humanIdle are exported", async () => {
|
||||||
|
const mod = await import("../src/human/index.js");
|
||||||
|
expect(typeof mod.humanMove).toBe("function");
|
||||||
|
expect(typeof mod.humanClick).toBe("function");
|
||||||
|
expect(typeof mod.clickTarget).toBe("function");
|
||||||
|
expect(typeof mod.humanIdle).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveConfig is re-exported from index", async () => {
|
||||||
|
const mod = await import("../src/human/index.js");
|
||||||
|
expect(typeof mod.resolveConfig).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Test helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||||
|
const mainFrameObj = overrides.mainFrameReturn ?? {
|
||||||
|
childFrames: vi.fn(() => []),
|
||||||
|
click: vi.fn(async () => {}),
|
||||||
|
dblclick: vi.fn(async () => {}),
|
||||||
|
hover: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
fill: vi.fn(async () => {}),
|
||||||
|
check: vi.fn(async () => {}),
|
||||||
|
uncheck: vi.fn(async () => {}),
|
||||||
|
selectOption: vi.fn(async () => {}),
|
||||||
|
press: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
dragAndDrop: vi.fn(async () => {}),
|
||||||
|
locator: vi.fn(() => ({
|
||||||
|
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||||
|
first: vi.fn(function(this: any) { return this; }),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeLocator = () => {
|
||||||
|
const loc: any = {
|
||||||
|
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||||
|
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
||||||
|
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||||
|
};
|
||||||
|
loc.first = vi.fn(() => loc);
|
||||||
|
return loc;
|
||||||
|
};
|
||||||
|
|
||||||
|
const page: any = {
|
||||||
|
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||||
|
addInitScript: vi.fn(async () => {}),
|
||||||
|
mouse: {
|
||||||
|
move: vi.fn(async () => {}),
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
click: vi.fn(async () => {}),
|
||||||
|
dblclick: vi.fn(async () => {}),
|
||||||
|
wheel: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
keyboard: {
|
||||||
|
press: overrides.keyboardPress
|
||||||
|
? vi.fn(overrides.keyboardPress)
|
||||||
|
: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
insertText: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
click: vi.fn(async () => {}),
|
||||||
|
dblclick: vi.fn(async () => {}),
|
||||||
|
hover: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
fill: vi.fn(async () => {}),
|
||||||
|
check: vi.fn(async () => {}),
|
||||||
|
uncheck: vi.fn(async () => {}),
|
||||||
|
selectOption: vi.fn(async () => {}),
|
||||||
|
press: vi.fn(async () => {}),
|
||||||
|
goto: vi.fn(async () => ({})),
|
||||||
|
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||||
|
locator: vi.fn(() => makeLocator()),
|
||||||
|
viewportSize: vi.fn(() => ({ width: 1280, height: 720 })),
|
||||||
|
mainFrame: vi.fn(() => mainFrameObj),
|
||||||
|
frames: vi.fn(() => []),
|
||||||
|
context: vi.fn(() => ({
|
||||||
|
pages: vi.fn(() => []),
|
||||||
|
addInitScript: vi.fn(async () => {}),
|
||||||
|
})),
|
||||||
|
url: vi.fn(() => "about:blank"),
|
||||||
|
waitForTimeout: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function buildMockFrame(): any {
|
||||||
|
return {
|
||||||
|
click: vi.fn(async () => {}),
|
||||||
|
dblclick: vi.fn(async () => {}),
|
||||||
|
hover: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
fill: vi.fn(async () => {}),
|
||||||
|
check: vi.fn(async () => {}),
|
||||||
|
uncheck: vi.fn(async () => {}),
|
||||||
|
selectOption: vi.fn(async () => {}),
|
||||||
|
press: vi.fn(async () => {}),
|
||||||
|
clear: vi.fn(async () => {}),
|
||||||
|
dragAndDrop: vi.fn(async () => {}),
|
||||||
|
locator: vi.fn(() => ({
|
||||||
|
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||||
|
})),
|
||||||
|
childFrames: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// test_human_visual.mjs
|
||||||
|
/**
|
||||||
|
* Visual + functional test for humanize (JS).
|
||||||
|
* Red dot = cursor, yellow = mouse held.
|
||||||
|
* Trail dots show the path taken.
|
||||||
|
*/
|
||||||
|
import { launch } from '../js/dist/index.js';
|
||||||
|
|
||||||
|
const CURSOR_JS = `
|
||||||
|
(() => {
|
||||||
|
if (document.getElementById('__hc')) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.id = '__hc';
|
||||||
|
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
const trail = document.createElement('div');
|
||||||
|
trail.id = '__hcTrail';
|
||||||
|
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
|
||||||
|
document.body.appendChild(trail);
|
||||||
|
|
||||||
|
let dotCount = 0;
|
||||||
|
const maxDots = 500;
|
||||||
|
|
||||||
|
function updatePos(x, y) {
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.left = (x - 9) + 'px';
|
||||||
|
el.style.top = (y - 9) + 'px';
|
||||||
|
if (dotCount < maxDots) {
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
|
||||||
|
trail.appendChild(dot);
|
||||||
|
dotCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
|
||||||
|
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||||
|
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||||
|
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
|
||||||
|
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
|
||||||
|
document.addEventListener('dragend', () => { el.style.background = 'red'; });
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function inject(page) {
|
||||||
|
try { await page.evaluate(CURSOR_JS); } catch {}
|
||||||
|
await delay(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(name) {
|
||||||
|
console.log(`\n${'='.repeat(60)}`);
|
||||||
|
console.log(` STEP: ${name}`);
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(name, passed, detail = '') {
|
||||||
|
const status = passed ? 'PASS' : 'FAIL';
|
||||||
|
let msg = ` [${status}] ${name}`;
|
||||||
|
if (detail) msg += ` — ${detail}`;
|
||||||
|
console.log(msg);
|
||||||
|
results.push({ name, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
console.log(' HUMAN-LIKE BEHAVIOR VISUAL TEST (JS)');
|
||||||
|
console.log(' Watch the red dot — it should move smoothly like a real cursor');
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
|
const browser = await launch({
|
||||||
|
headless: false,
|
||||||
|
humanize: true,
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SCENARIO 1: Wikipedia search
|
||||||
|
// ============================================================
|
||||||
|
step('Wikipedia — navigate and search');
|
||||||
|
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(2000);
|
||||||
|
await inject(page);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
console.log(' Watch: cursor moves to search box (Bezier curve)');
|
||||||
|
let t0 = Date.now();
|
||||||
|
await page.locator('#searchInput').click();
|
||||||
|
let ms = Date.now() - t0;
|
||||||
|
check('click on search input', ms > 200, `${ms} ms`);
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
console.log(' Watch: characters appear one by one');
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.locator('#searchInput').fill('Python programming language');
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
let val = await page.locator('#searchInput').inputValue();
|
||||||
|
check('fill search box', val === 'Python programming language' && ms > 2000, `${ms} ms, value='${val}'`);
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
console.log(' Watch: double click selects word');
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.locator('#searchInput').dblclick();
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
let sel = await page.evaluate(() => window.getSelection().toString().trim());
|
||||||
|
check('dblclick selects word', sel.length > 0 && ms > 200, `${ms} ms, selected='${sel}'`);
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
console.log(' Watch: old text replaced');
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.locator('#searchInput').fill('Artificial intelligence');
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
val = await page.locator('#searchInput').inputValue();
|
||||||
|
check('fill replaces text', val === 'Artificial intelligence' && ms > 1500, `${ms} ms, value='${val}'`);
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
console.log(' Watch: cursor hovers button without clicking');
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.locator('button[type="submit"]').hover();
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
check('hover search button', ms > 100, `${ms} ms`);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SCENARIO 2: Checkboxes
|
||||||
|
// ============================================================
|
||||||
|
step('Checkboxes — check and uncheck');
|
||||||
|
await page.goto('https://the-internet.herokuapp.com/checkboxes', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(2000);
|
||||||
|
await inject(page);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
const cb1 = page.locator('input[type="checkbox"]').nth(0);
|
||||||
|
const cb2 = page.locator('input[type="checkbox"]').nth(1);
|
||||||
|
|
||||||
|
if (await cb1.isChecked()) { await cb1.uncheck(); await delay(500); }
|
||||||
|
|
||||||
|
console.log(' Watch: cursor moves to checkbox, clicks');
|
||||||
|
t0 = Date.now();
|
||||||
|
await cb1.check();
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
check('check checkbox 1', await cb1.isChecked() && ms > 200, `${ms} ms`);
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
if (!(await cb2.isChecked())) { await cb2.check(); await delay(500); }
|
||||||
|
|
||||||
|
t0 = Date.now();
|
||||||
|
await cb2.uncheck();
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
check('uncheck checkbox 2', !(await cb2.isChecked()) && ms > 200, `${ms} ms`);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SCENARIO 3: Dropdown
|
||||||
|
// ============================================================
|
||||||
|
step('Dropdown — select option');
|
||||||
|
await page.goto('https://the-internet.herokuapp.com/dropdown', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(2000);
|
||||||
|
await inject(page);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
console.log(' Watch: cursor hovers dropdown, option selected');
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.locator('#dropdown').selectOption('2');
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
val = await page.locator('#dropdown').inputValue();
|
||||||
|
check('select option', val === '2' && ms > 100, `${ms} ms, value='${val}'`);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SCENARIO 4: Drag and Drop
|
||||||
|
// ============================================================
|
||||||
|
step('Drag and Drop');
|
||||||
|
await page.goto('https://the-internet.herokuapp.com/drag_and_drop', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(2000);
|
||||||
|
await inject(page);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
const beforeA = (await page.locator('#column-a header').textContent()).trim();
|
||||||
|
console.log(` Before: A='${beforeA}'`);
|
||||||
|
console.log(' Watch: cursor to A, yellow (held), moves to B, releases');
|
||||||
|
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.locator('#column-a').dragTo(page.locator('#column-b'));
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
const afterA = (await page.locator('#column-a header').textContent()).trim();
|
||||||
|
const swapped = beforeA !== afterA;
|
||||||
|
check('drag A to B', swapped && ms > 300, `${ms} ms, swapped=${swapped}`);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SCENARIO 5: Text editing
|
||||||
|
// ============================================================
|
||||||
|
step('Text editing — type, press, clear');
|
||||||
|
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(2000);
|
||||||
|
await inject(page);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
console.log(' Watch: types character by character');
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.locator('#searchInput').type('Hello World');
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
val = await page.locator('#searchInput').inputValue();
|
||||||
|
check("type 'Hello World'", val === 'Hello World' && ms > 1000, `${ms} ms`);
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
console.log(' Watch: field cleared');
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.locator('#searchInput').clear();
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
val = await page.locator('#searchInput').inputValue();
|
||||||
|
check('clear field', val === '' && ms > 100, `${ms} ms`);
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
console.log(' Watch: mouse moves in Bezier curve');
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.mouse.move(600, 400);
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
check('mouse.move', ms > 100, `${ms} ms`);
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
t0 = Date.now();
|
||||||
|
await page.mouse.click(300, 300);
|
||||||
|
ms = Date.now() - t0;
|
||||||
|
check('mouse.click', ms > 100, `${ms} ms`);
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SUMMARY
|
||||||
|
// ============================================================
|
||||||
|
console.log('\n' + '='.repeat(70));
|
||||||
|
console.log(' SUMMARY');
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
|
const passed = results.filter(r => r.status === 'PASS').length;
|
||||||
|
const failed = results.filter(r => r.status === 'FAIL').length;
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const icon = r.status === 'PASS' ? 'OK' : 'XX';
|
||||||
|
console.log(` [${icon}] ${r.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n ${passed}/${results.length} passed, ${failed} failed`);
|
||||||
|
if (failed === 0) console.log(' *** ALL TESTS PASSED ***');
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
Visual + functional test for humanize.
|
||||||
|
Red dot = cursor, yellow = mouse held.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
pytestmark = pytest.mark.slow
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from cloakbrowser import launch
|
||||||
|
import time
|
||||||
|
|
||||||
|
CURSOR_JS = """
|
||||||
|
() => {
|
||||||
|
if (document.getElementById('__hc')) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.id = '__hc';
|
||||||
|
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
const trail = document.createElement('div');
|
||||||
|
trail.id = '__hcTrail';
|
||||||
|
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
|
||||||
|
document.body.appendChild(trail);
|
||||||
|
|
||||||
|
let dotCount = 0;
|
||||||
|
const maxDots = 500;
|
||||||
|
|
||||||
|
function updatePos(x, y) {
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.left = (x - 9) + 'px';
|
||||||
|
el.style.top = (y - 9) + 'px';
|
||||||
|
|
||||||
|
if (dotCount < maxDots) {
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
|
||||||
|
trail.appendChild(dot);
|
||||||
|
dotCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
|
||||||
|
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||||
|
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||||
|
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
|
||||||
|
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
|
||||||
|
document.addEventListener('dragend', () => { el.style.background = 'red'; });
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def inject(page):
|
||||||
|
try:
|
||||||
|
page.evaluate(CURSOR_JS)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def step(name):
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" STEP: {name}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
def check(name, passed, detail=""):
|
||||||
|
status = "PASS" if passed else "FAIL"
|
||||||
|
msg = f" [{status}] {name}"
|
||||||
|
if detail:
|
||||||
|
msg += f" — {detail}"
|
||||||
|
print(msg)
|
||||||
|
results.append((name, status))
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print(" HUMAN-LIKE BEHAVIOR VISUAL TEST")
|
||||||
|
print(" Watch the red dot — it should move smoothly like a real cursor")
|
||||||
|
print(" Yellow = mouse button held")
|
||||||
|
print(" Red trail dots = path taken")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
browser = launch(headless=False, humanize=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SCENARIO 1: Wikipedia search
|
||||||
|
# ============================================================
|
||||||
|
step("Wikipedia — navigate and search")
|
||||||
|
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||||
|
time.sleep(2)
|
||||||
|
inject(page)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print(" Watch: cursor moves to search box (Bezier curve)")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#searchInput').click()
|
||||||
|
click_ms = int((time.time() - t0) * 1000)
|
||||||
|
check("click on search input", click_ms > 200, f"{click_ms} ms")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: characters appear one by one with varying speed")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#searchInput').fill('Python programming language')
|
||||||
|
fill_ms = int((time.time() - t0) * 1000)
|
||||||
|
val = page.locator('#searchInput').input_value()
|
||||||
|
check("fill search box", val == 'Python programming language' and fill_ms > 2000, f"{fill_ms} ms, value='{val}'")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: cursor moves to search box, double yellow flash, word selected")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#searchInput').dblclick()
|
||||||
|
dbl_ms = int((time.time() - t0) * 1000)
|
||||||
|
sel = page.evaluate('() => window.getSelection().toString().trim()')
|
||||||
|
check("dblclick selects word", len(sel) > 0 and dbl_ms > 200, f"{dbl_ms} ms, selected='{sel}'")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: old text cleared, new text typed")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#searchInput').fill('Artificial intelligence')
|
||||||
|
fill2_ms = int((time.time() - t0) * 1000)
|
||||||
|
val2 = page.locator('#searchInput').input_value()
|
||||||
|
check("fill replaces text", val2 == 'Artificial intelligence' and fill2_ms > 1500, f"{fill2_ms} ms, value='{val2}'")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: cursor moves to button without clicking")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('button[type="submit"]').hover()
|
||||||
|
hover_ms = int((time.time() - t0) * 1000)
|
||||||
|
check("hover search button", hover_ms > 100, f"{hover_ms} ms")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SCENARIO 2: Form interaction — checkboxes
|
||||||
|
# ============================================================
|
||||||
|
step("Checkboxes — check and uncheck")
|
||||||
|
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
|
||||||
|
time.sleep(2)
|
||||||
|
inject(page)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
cb1 = page.locator('input[type="checkbox"]').nth(0)
|
||||||
|
cb2 = page.locator('input[type="checkbox"]').nth(1)
|
||||||
|
|
||||||
|
print(" Watch: cursor moves to first checkbox, clicks")
|
||||||
|
if cb1.is_checked():
|
||||||
|
cb1.uncheck()
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
cb1.check()
|
||||||
|
check_ms = int((time.time() - t0) * 1000)
|
||||||
|
check("check checkbox 1", cb1.is_checked() and check_ms > 200, f"{check_ms} ms, checked={cb1.is_checked()}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: cursor moves to second checkbox, clicks to uncheck")
|
||||||
|
if not cb2.is_checked():
|
||||||
|
cb2.check()
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
cb2.uncheck()
|
||||||
|
uncheck_ms = int((time.time() - t0) * 1000)
|
||||||
|
check("uncheck checkbox 2", not cb2.is_checked() and uncheck_ms > 200, f"{uncheck_ms} ms, checked={cb2.is_checked()}")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SCENARIO 3: Dropdown
|
||||||
|
# ============================================================
|
||||||
|
step("Dropdown — select option")
|
||||||
|
page.goto('https://the-internet.herokuapp.com/dropdown', wait_until='domcontentloaded')
|
||||||
|
time.sleep(2)
|
||||||
|
inject(page)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print(" Watch: cursor moves to dropdown, hovers, option selected")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#dropdown').select_option('1')
|
||||||
|
sel_ms = int((time.time() - t0) * 1000)
|
||||||
|
val = page.locator('#dropdown').input_value()
|
||||||
|
check("select option 1", val == '1' and sel_ms > 100, f"{sel_ms} ms, value='{val}'")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#dropdown').select_option('2')
|
||||||
|
sel2_ms = int((time.time() - t0) * 1000)
|
||||||
|
val2 = page.locator('#dropdown').input_value()
|
||||||
|
check("select option 2", val2 == '2' and sel2_ms > 100, f"{sel2_ms} ms, value='{val2}'")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SCENARIO 4: Drag and drop
|
||||||
|
# ============================================================
|
||||||
|
step("Drag and Drop — move column A to B")
|
||||||
|
page.goto('https://the-internet.herokuapp.com/drag_and_drop', wait_until='domcontentloaded')
|
||||||
|
time.sleep(2)
|
||||||
|
inject(page)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
before_a = page.locator('#column-a header').text_content().strip()
|
||||||
|
before_b = page.locator('#column-b header').text_content().strip()
|
||||||
|
print(f" Before: A='{before_a}', B='{before_b}'")
|
||||||
|
|
||||||
|
print(" Watch: cursor moves to A, turns yellow (held), moves to B, releases")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#column-a').drag_to(page.locator('#column-b'))
|
||||||
|
drag_ms = int((time.time() - t0) * 1000)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
after_a = page.locator('#column-a header').text_content().strip()
|
||||||
|
after_b = page.locator('#column-b header').text_content().strip()
|
||||||
|
swapped = before_a != after_a
|
||||||
|
print(f" After: A='{after_a}', B='{after_b}'")
|
||||||
|
check("drag A to B", swapped and drag_ms > 300, f"{drag_ms} ms, swapped={swapped}")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SCENARIO 5: Text editing
|
||||||
|
# ============================================================
|
||||||
|
step("Text editing — type, press keys, clear")
|
||||||
|
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||||
|
time.sleep(2)
|
||||||
|
inject(page)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print(" Watch: cursor clicks input, types character by character")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#searchInput').type('Hello World')
|
||||||
|
type_ms = int((time.time() - t0) * 1000)
|
||||||
|
val = page.locator('#searchInput').input_value()
|
||||||
|
check("type 'Hello World'", val == 'Hello World' and type_ms > 1000, f"{type_ms} ms, value='{val}'")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: cursor clicks, presses single key")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#searchInput').press('End')
|
||||||
|
page.locator('#searchInput').press('!')
|
||||||
|
press_ms = int((time.time() - t0) * 1000)
|
||||||
|
val = page.locator('#searchInput').input_value()
|
||||||
|
check("press '!' at end", '!' in val and press_ms > 100, f"{press_ms} ms, value='{val}'")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: field gets cleared (Ctrl+A, Backspace)")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#searchInput').clear()
|
||||||
|
clear_ms = int((time.time() - t0) * 1000)
|
||||||
|
val = page.locator('#searchInput').input_value()
|
||||||
|
check("clear field", val == '' and clear_ms > 100, f"{clear_ms} ms, value='{repr(val)}'")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: press_sequentially types each key individually")
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#searchInput').press_sequentially('Sequential')
|
||||||
|
pseq_ms = int((time.time() - t0) * 1000)
|
||||||
|
val = page.locator('#searchInput').input_value()
|
||||||
|
check("press_sequentially", val == 'Sequential' and pseq_ms > 500, f"{pseq_ms} ms, value='{val}'")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SCENARIO 6: Mouse precision
|
||||||
|
# ============================================================
|
||||||
|
step("Mouse precision — move to coordinates")
|
||||||
|
print(" Watch: cursor moves in a Bezier curve to (600, 400)")
|
||||||
|
t0 = time.time()
|
||||||
|
page.mouse.move(600, 400)
|
||||||
|
move_ms = int((time.time() - t0) * 1000)
|
||||||
|
check("mouse.move to (600,400)", move_ms > 100, f"{move_ms} ms")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: cursor moves to (200, 200), clicks")
|
||||||
|
t0 = time.time()
|
||||||
|
page.mouse.click(200, 200)
|
||||||
|
mclick_ms = int((time.time() - t0) * 1000)
|
||||||
|
check("mouse.click at (200,200)", mclick_ms > 100, f"{mclick_ms} ms")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(" Watch: keyboard types directly (no click needed)")
|
||||||
|
page.locator('#searchInput').click()
|
||||||
|
time.sleep(0.3)
|
||||||
|
t0 = time.time()
|
||||||
|
page.keyboard.type('Direct keyboard')
|
||||||
|
kb_ms = int((time.time() - t0) * 1000)
|
||||||
|
check("keyboard.type", kb_ms > 500, f"{kb_ms} ms")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SUMMARY
|
||||||
|
# ============================================================
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" SUMMARY")
|
||||||
|
print("=" * 70)
|
||||||
|
passed = sum(1 for _, s in results if s == "PASS")
|
||||||
|
failed = sum(1 for _, s in results if s == "FAIL")
|
||||||
|
total = len(results)
|
||||||
|
|
||||||
|
for name, status in results:
|
||||||
|
icon = "OK" if status == "PASS" else "XX"
|
||||||
|
print(f" [{icon}] {name}")
|
||||||
|
|
||||||
|
print(f"\n {passed}/{total} passed, {failed} failed")
|
||||||
|
if failed == 0:
|
||||||
|
print(" *** ALL TESTS PASSED ***")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
input("\nPress Enter to close browser...")
|
||||||
|
browser.close()
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
/**
|
||||||
|
* Unit + integration tests for the humanize layer (JS).
|
||||||
|
* Covers: config resolution, Bézier math, fill clearing,
|
||||||
|
* bot-detection form, and patching integrity.
|
||||||
|
*
|
||||||
|
* Run: node tests/test_humanize_unit.mjs
|
||||||
|
*/
|
||||||
|
import { launch } from '../js/dist/index.js';
|
||||||
|
import { resolveConfig, rand, randRange, sleep } from '../js/dist/human/config.js';
|
||||||
|
import { humanMove, clickTarget } from '../js/dist/human/mouse.js';
|
||||||
|
|
||||||
|
const PROXY = {
|
||||||
|
|
||||||
|
};
|
||||||
|
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
async function test(name, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(` [PASS] ${name}`);
|
||||||
|
results.push({ name, status: 'PASS' });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` [FAIL] ${name} — ${e.message || e}`);
|
||||||
|
results.push({ name, status: 'FAIL' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. Config resolution
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' CONFIG RESOLUTION');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('default config resolves', async () => {
|
||||||
|
const cfg = resolveConfig('default');
|
||||||
|
if (!cfg) throw new Error('resolveConfig returned null');
|
||||||
|
if (cfg.mouse_min_steps <= 0) throw new Error('mouse_min_steps should be > 0');
|
||||||
|
if (cfg.mouse_max_steps <= cfg.mouse_min_steps) throw new Error('mouse_max_steps should be > min');
|
||||||
|
if (cfg.typing_delay <= 0) throw new Error('typing_delay should be > 0');
|
||||||
|
if (!Array.isArray(cfg.initial_cursor_x) || cfg.initial_cursor_x.length !== 2) throw new Error('initial_cursor_x invalid');
|
||||||
|
if (!Array.isArray(cfg.initial_cursor_y) || cfg.initial_cursor_y.length !== 2) throw new Error('initial_cursor_y invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('careful config resolves', async () => {
|
||||||
|
const cfg = resolveConfig('careful');
|
||||||
|
const def = resolveConfig('default');
|
||||||
|
if (!cfg) throw new Error('resolveConfig returned null');
|
||||||
|
if (cfg.typing_delay < def.typing_delay) throw new Error('careful should have >= typing_delay');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('custom config override', async () => {
|
||||||
|
const cfg = resolveConfig('default', { mouse_min_steps: 100, mouse_max_steps: 200 });
|
||||||
|
if (cfg.mouse_min_steps !== 100) throw new Error(`Override failed: ${cfg.mouse_min_steps}`);
|
||||||
|
if (cfg.mouse_max_steps !== 200) throw new Error(`Override failed: ${cfg.mouse_max_steps}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('rand within bounds', async () => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const v = rand(10, 20);
|
||||||
|
if (v < 10 || v > 20) throw new Error(`rand out of range: ${v}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('randRange within bounds', async () => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const v = randRange([5, 15]);
|
||||||
|
if (v < 5 || v > 15) throw new Error(`randRange out of range: ${v}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('sleep timing', async () => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
await sleep(50);
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
if (elapsed < 40) throw new Error(`sleep too short: ${elapsed} ms`);
|
||||||
|
if (elapsed > 200) throw new Error(`sleep too long: ${elapsed} ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. Bézier math (via humanMove recording)
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' BÉZIER MATH (via mouse movement recording)');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('humanMove generates multiple points', async () => {
|
||||||
|
const cfg = resolveConfig('default');
|
||||||
|
const moves = [];
|
||||||
|
const fakeRaw = {
|
||||||
|
move: async (x, y) => moves.push({ x, y }),
|
||||||
|
down: async () => {},
|
||||||
|
up: async () => {},
|
||||||
|
wheel: async () => {},
|
||||||
|
};
|
||||||
|
await humanMove(fakeRaw, 0, 0, 500, 300, cfg);
|
||||||
|
if (moves.length < 10) throw new Error(`Expected >= 10 moves, got ${moves.length}`);
|
||||||
|
const last = moves[moves.length - 1];
|
||||||
|
if (Math.abs(last.x - 500) > 10) throw new Error(`Last x too far: ${last.x}`);
|
||||||
|
if (Math.abs(last.y - 300) > 10) throw new Error(`Last y too far: ${last.y}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('humanMove smoothness (no large jumps)', async () => {
|
||||||
|
const cfg = resolveConfig('default');
|
||||||
|
const moves = [];
|
||||||
|
const fakeRaw = {
|
||||||
|
move: async (x, y) => moves.push({ x, y }),
|
||||||
|
down: async () => {},
|
||||||
|
up: async () => {},
|
||||||
|
wheel: async () => {},
|
||||||
|
};
|
||||||
|
await humanMove(fakeRaw, 0, 0, 400, 400, cfg);
|
||||||
|
const totalDist = Math.sqrt(400 * 400 + 400 * 400);
|
||||||
|
const maxJump = totalDist * 0.5;
|
||||||
|
for (let i = 1; i < moves.length; i++) {
|
||||||
|
const dx = moves[i].x - moves[i - 1].x;
|
||||||
|
const dy = moves[i].y - moves[i - 1].y;
|
||||||
|
const jump = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (jump > maxJump) throw new Error(`Jump too large at step ${i}: ${jump.toFixed(1)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('humanMove not a straight line', async () => {
|
||||||
|
const cfg = resolveConfig('default');
|
||||||
|
let maxDev = 0;
|
||||||
|
for (let trial = 0; trial < 5; trial++) {
|
||||||
|
const moves = [];
|
||||||
|
const fakeRaw = {
|
||||||
|
move: async (x, y) => moves.push({ x, y }),
|
||||||
|
down: async () => {},
|
||||||
|
up: async () => {},
|
||||||
|
wheel: async () => {},
|
||||||
|
};
|
||||||
|
await humanMove(fakeRaw, 0, 0, 500, 0, cfg);
|
||||||
|
const dev = Math.max(...moves.map(m => Math.abs(m.y)));
|
||||||
|
if (dev > maxDev) maxDev = dev;
|
||||||
|
}
|
||||||
|
if (maxDev < 0.5) throw new Error(`Curve too straight, max y deviation: ${maxDev.toFixed(2)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('clickTarget within bounding box', async () => {
|
||||||
|
const cfg = resolveConfig('default');
|
||||||
|
const box = { x: 100, y: 200, width: 150, height: 40 };
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const t = clickTarget(box, false, cfg);
|
||||||
|
if (t.x < 100 || t.x > 250) throw new Error(`x out of box: ${t.x}`);
|
||||||
|
if (t.y < 200 || t.y > 240) throw new Error(`y out of box: ${t.y}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. Fill clearing (with real browser)
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' FILL CLEARING (browser)');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('fill() clears existing text', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
await page.locator('#searchInput').type('initial text');
|
||||||
|
await delay(500);
|
||||||
|
const before = await page.locator('#searchInput').inputValue();
|
||||||
|
if (before !== 'initial text') throw new Error(`Initial type failed: '${before}'`);
|
||||||
|
|
||||||
|
await page.locator('#searchInput').fill('replaced text');
|
||||||
|
await delay(500);
|
||||||
|
const after = await page.locator('#searchInput').inputValue();
|
||||||
|
if (after !== 'replaced text') throw new Error(`Fill did not replace: '${after}'`);
|
||||||
|
if (after.includes('initial')) throw new Error('Old text still present');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('fill() timing is humanized (>1s)', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
await page.locator('#searchInput').fill('Human speed test');
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
if (elapsed < 1000) throw new Error(`fill() too fast: ${elapsed} ms`);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('clear() empties field', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
await page.locator('#searchInput').fill('some text');
|
||||||
|
await delay(500);
|
||||||
|
await page.locator('#searchInput').clear();
|
||||||
|
await delay(500);
|
||||||
|
const val = await page.locator('#searchInput').inputValue();
|
||||||
|
if (val !== '') throw new Error(`clear() did not empty: '${val}'`);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4. Bot detection form — deviceandbrowserinfo.com
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' BOT DETECTION FORM (deviceandbrowserinfo.com)');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('bot detection form — behavioral checks pass', async () => {
|
||||||
|
const browser = await launch({ headless: false, humanize: true, proxy: PROXY });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(3000);
|
||||||
|
|
||||||
|
await page.locator('#email').click();
|
||||||
|
await delay(300);
|
||||||
|
await page.locator('#email').fill('test@example.com');
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
await page.locator('#password').click();
|
||||||
|
await delay(300);
|
||||||
|
await page.locator('#password').fill('SecurePass!123');
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
await delay(5000);
|
||||||
|
|
||||||
|
const body = await page.locator('body').textContent();
|
||||||
|
|
||||||
|
const superHuman = body.includes('"superHumanSpeed": true');
|
||||||
|
const suspicious = body.includes('"suspiciousClientSideBehavior": true');
|
||||||
|
const cdpMouse = body.includes('"hasCDPMouseLeak": true');
|
||||||
|
|
||||||
|
console.log(` superHumanSpeed: ${superHuman}`);
|
||||||
|
console.log(` suspiciousClientSideBehavior: ${suspicious}`);
|
||||||
|
console.log(` hasCDPMouseLeak: ${cdpMouse}`);
|
||||||
|
|
||||||
|
if (superHuman) throw new Error('superHumanSpeed detected');
|
||||||
|
if (suspicious) throw new Error('suspiciousClientSideBehavior detected');
|
||||||
|
|
||||||
|
if (body.includes('"isAutomatedWithCDP": true')) {
|
||||||
|
console.log(' [INFO] isAutomatedWithCDP=true — stealth issue, not humanize');
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('bot detection form timing (>3s)', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true, proxy: PROXY });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(2000);
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
await page.locator('#email').fill('test@example.com');
|
||||||
|
await page.locator('#password').fill('MyPassword!99');
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
await delay(3000);
|
||||||
|
|
||||||
|
console.log(` Form fill + submit took: ${elapsed} ms`);
|
||||||
|
if (elapsed < 3000) throw new Error(`Form filled too fast: ${elapsed} ms`);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 5. Patching integrity
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' PATCHING INTEGRITY');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('page has _original after launch', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
if (!page._original) throw new Error('page._original missing');
|
||||||
|
if (!page._humanCfg) throw new Error('page._humanCfg missing');
|
||||||
|
if (!page._humanCursor) throw new Error('page._humanCursor missing');
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('page.click is humanized', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const clickStr = page.click.toString();
|
||||||
|
if (!clickStr.includes('ensureCursorInit') && !clickStr.includes('humanClickFn') && !clickStr.includes('scrollToElement')) {
|
||||||
|
throw new Error('page.click does not appear humanized');
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('non-humanized page works normally', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: false });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
if (page._original) throw new Error('Non-humanized page should not have _original');
|
||||||
|
|
||||||
|
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
await page.locator('#searchInput').fill('test');
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
if (elapsed > 500) throw new Error(`Non-humanized fill too slow: ${elapsed} ms`);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 6. Focus check — press skips click when focused
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' FOCUS CHECK (press / pressSequentially)');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('press skips click when element already focused', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
// Click input first to focus it
|
||||||
|
await page.locator('#searchInput').click();
|
||||||
|
await delay(300);
|
||||||
|
|
||||||
|
// Record mouse moves before pressing Enter
|
||||||
|
const movesBefore = [];
|
||||||
|
const origMove = page._humanOriginals.mouseMove;
|
||||||
|
let moveCount = 0;
|
||||||
|
page._humanOriginals.mouseMove = async (x, y, opts) => {
|
||||||
|
moveCount++;
|
||||||
|
return origMove(x, y, opts);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Press Enter — element is already focused, should NOT trigger mouse move
|
||||||
|
const movesAtStart = moveCount;
|
||||||
|
await page.locator('#searchInput').press('a');
|
||||||
|
const movesUsed = moveCount - movesAtStart;
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
page._humanOriginals.mouseMove = origMove;
|
||||||
|
|
||||||
|
// If focus check works, should be 0 moves (just keyboard press)
|
||||||
|
if (movesUsed > 0) {
|
||||||
|
console.log(` [INFO] press() triggered ${movesUsed} mouse moves on focused element`);
|
||||||
|
}
|
||||||
|
// Lenient: allow some moves but not a full Bézier path (>10 would indicate a click)
|
||||||
|
if (movesUsed > 10) {
|
||||||
|
throw new Error(`press() moved mouse ${movesUsed} times on already-focused element — focus check broken`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 7. check/uncheck idle
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' CHECK/UNCHECK IDLE');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('check() respects idle_between_actions config', async () => {
|
||||||
|
const cfg = resolveConfig('default', { idle_between_actions: true, idle_between_duration: [50, 100] });
|
||||||
|
if (!cfg.idle_between_actions) throw new Error('idle_between_actions should be true');
|
||||||
|
if (!cfg.idle_between_duration || cfg.idle_between_duration[0] !== 50) {
|
||||||
|
throw new Error('idle_between_duration not set');
|
||||||
|
}
|
||||||
|
// Verify config is carried through to page
|
||||||
|
const browser = await launch({ headless: true, humanize: true, humanize_config: { idle_between_actions: true } });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
if (!page._humanCfg) throw new Error('page._humanCfg missing');
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 8. Frame patching completeness
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' FRAME PATCHING COMPLETENESS');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('frame has all methods patched', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
const mainFrame = page.mainFrame();
|
||||||
|
const expected = ['click', 'dblclick', 'hover', 'type', 'fill',
|
||||||
|
'check', 'uncheck', 'selectOption', 'press',
|
||||||
|
'clear', 'dragAndDrop'];
|
||||||
|
const missing = [];
|
||||||
|
for (const method of expected) {
|
||||||
|
if (typeof mainFrame[method] !== 'function') {
|
||||||
|
missing.push(method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Frame missing patched methods: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify they are patched (not original Playwright bindings)
|
||||||
|
if (!mainFrame._humanPatched) {
|
||||||
|
throw new Error('mainFrame._humanPatched flag not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 9. drag_to safety — page._original check
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' DRAG_TO SAFETY');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('page._humanCfg is accessible', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
if (!page._humanCfg) throw new Error('page._humanCfg not set');
|
||||||
|
if (!page._original) throw new Error('page._original not set');
|
||||||
|
if (typeof page._original.mouseDown !== 'function') throw new Error('mouseDown not preserved');
|
||||||
|
if (typeof page._original.mouseUp !== 'function') throw new Error('mouseUp not preserved');
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 10. patchBrowser.newPage uses original context
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log(' PATCH BROWSER — newPage context');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
await test('browser.newPage returns patched page', async () => {
|
||||||
|
const browser = await launch({ headless: true, humanize: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
if (!page._original) throw new Error('page from browser.newPage() not patched');
|
||||||
|
if (!page._humanCfg) throw new Error('page._humanCfg missing from browser.newPage()');
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SUMMARY
|
||||||
|
// =========================================================================
|
||||||
|
console.log('\n' + '='.repeat(70));
|
||||||
|
console.log(' TEST SUMMARY');
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
|
const passed = results.filter(r => r.status === 'PASS').length;
|
||||||
|
const failed = results.filter(r => r.status === 'FAIL').length;
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const icon = r.status === 'PASS' ? 'OK' : 'XX';
|
||||||
|
console.log(` [${icon}] ${r.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n ${passed}/${results.length} passed, ${failed} failed`);
|
||||||
|
if (failed === 0) console.log(' *** ALL JS TESTS PASSED ***');
|
||||||
|
else console.log(` *** ${failed} TESTS FAILED ***`);
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
"""
|
||||||
|
Unit + integration tests for the humanize layer.
|
||||||
|
|
||||||
|
Fast unit tests (config, Bézier math, mocks) are proper test_ functions
|
||||||
|
that pytest discovers automatically.
|
||||||
|
|
||||||
|
Browser-dependent tests are marked @pytest.mark.slow and skipped in CI
|
||||||
|
unless explicitly requested (pytest -m slow).
|
||||||
|
|
||||||
|
Can also run directly: python tests/test_humanize_unit.py
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Helper: ensure Locator class is patched before mock tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _ensure_locator_patched():
|
||||||
|
import cloakbrowser.human as h
|
||||||
|
h._locator_sync_patched = False
|
||||||
|
h._patch_locator_class_sync()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Helper: fake RawMouse for Bézier tests
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class _FakeRawMouse:
|
||||||
|
def __init__(self):
|
||||||
|
self.moves = []
|
||||||
|
def move(self, x, y, **kw):
|
||||||
|
self.moves.append((x, y))
|
||||||
|
def down(self, **kw):
|
||||||
|
pass
|
||||||
|
def up(self, **kw):
|
||||||
|
pass
|
||||||
|
def wheel(self, dx, dy):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 1. Config resolution
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestConfigResolution:
|
||||||
|
def test_default_config_resolves(self):
|
||||||
|
from cloakbrowser.human.config import resolve_config, HumanConfig
|
||||||
|
cfg = resolve_config("default", None)
|
||||||
|
assert isinstance(cfg, HumanConfig)
|
||||||
|
assert cfg.mouse_min_steps > 0
|
||||||
|
assert cfg.mouse_max_steps > cfg.mouse_min_steps
|
||||||
|
assert len(cfg.initial_cursor_x) == 2
|
||||||
|
assert len(cfg.initial_cursor_y) == 2
|
||||||
|
assert cfg.typing_delay > 0
|
||||||
|
|
||||||
|
def test_careful_config_resolves(self):
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("careful", None)
|
||||||
|
default_cfg = resolve_config("default", None)
|
||||||
|
assert cfg.mouse_min_steps > 0
|
||||||
|
assert cfg.typing_delay >= default_cfg.typing_delay
|
||||||
|
|
||||||
|
def test_custom_override(self):
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default", {"mouse_min_steps": 100, "mouse_max_steps": 200})
|
||||||
|
assert cfg.mouse_min_steps == 100
|
||||||
|
assert cfg.mouse_max_steps == 200
|
||||||
|
|
||||||
|
def test_invalid_preset_raises(self):
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
with pytest.raises(ValueError, match="Unknown humanize preset"):
|
||||||
|
resolve_config("nonexistent", None)
|
||||||
|
|
||||||
|
def test_rand_within_bounds(self):
|
||||||
|
from cloakbrowser.human.config import rand, rand_range
|
||||||
|
for _ in range(200):
|
||||||
|
v = rand(10, 20)
|
||||||
|
assert 10 <= v <= 20
|
||||||
|
for _ in range(200):
|
||||||
|
v = rand_range([5, 15])
|
||||||
|
assert 5 <= v <= 15
|
||||||
|
|
||||||
|
def test_sleep_ms_timing(self):
|
||||||
|
from cloakbrowser.human.config import sleep_ms
|
||||||
|
t0 = time.time()
|
||||||
|
sleep_ms(50)
|
||||||
|
elapsed = (time.time() - t0) * 1000
|
||||||
|
assert elapsed >= 40
|
||||||
|
assert elapsed < 200
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 2. Bézier math
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestBezierMath:
|
||||||
|
def test_generates_multiple_points(self):
|
||||||
|
from cloakbrowser.human.mouse import human_move
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default", None)
|
||||||
|
raw = _FakeRawMouse()
|
||||||
|
human_move(raw, 0, 0, 500, 300, cfg)
|
||||||
|
assert len(raw.moves) >= 10
|
||||||
|
last_x, last_y = raw.moves[-1]
|
||||||
|
assert abs(last_x - 500) < 10
|
||||||
|
assert abs(last_y - 300) < 10
|
||||||
|
|
||||||
|
def test_smoothness_no_large_jumps(self):
|
||||||
|
from cloakbrowser.human.mouse import human_move
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default", None)
|
||||||
|
raw = _FakeRawMouse()
|
||||||
|
human_move(raw, 0, 0, 400, 400, cfg)
|
||||||
|
total_dist = math.sqrt(400**2 + 400**2)
|
||||||
|
max_jump = total_dist * 0.5
|
||||||
|
for i in range(1, len(raw.moves)):
|
||||||
|
dx = raw.moves[i][0] - raw.moves[i-1][0]
|
||||||
|
dy = raw.moves[i][1] - raw.moves[i-1][1]
|
||||||
|
assert math.sqrt(dx*dx + dy*dy) < max_jump
|
||||||
|
|
||||||
|
def test_short_distance(self):
|
||||||
|
from cloakbrowser.human.mouse import human_move
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default", None)
|
||||||
|
raw = _FakeRawMouse()
|
||||||
|
human_move(raw, 100, 100, 103, 102, cfg)
|
||||||
|
assert len(raw.moves) >= 1
|
||||||
|
|
||||||
|
def test_not_straight_line(self):
|
||||||
|
from cloakbrowser.human.mouse import human_move
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default", None)
|
||||||
|
max_dev = 0
|
||||||
|
for _ in range(5):
|
||||||
|
raw = _FakeRawMouse()
|
||||||
|
human_move(raw, 0, 0, 500, 0, cfg)
|
||||||
|
dev = max(abs(y) for _, y in raw.moves)
|
||||||
|
if dev > max_dev:
|
||||||
|
max_dev = dev
|
||||||
|
assert max_dev > 0.5
|
||||||
|
|
||||||
|
def test_click_target_within_box(self):
|
||||||
|
from cloakbrowser.human.mouse import click_target
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default", None)
|
||||||
|
box = {"x": 100, "y": 200, "width": 150, "height": 40}
|
||||||
|
for _ in range(50):
|
||||||
|
t = click_target(box, False, cfg)
|
||||||
|
assert 100 <= t.x <= 250
|
||||||
|
assert 200 <= t.y <= 240
|
||||||
|
|
||||||
|
def test_click_target_input_mode(self):
|
||||||
|
from cloakbrowser.human.mouse import click_target
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default", None)
|
||||||
|
box = {"x": 50, "y": 50, "width": 200, "height": 30}
|
||||||
|
for _ in range(20):
|
||||||
|
t = click_target(box, True, cfg)
|
||||||
|
assert 50 <= t.x <= 250
|
||||||
|
assert 50 <= t.y <= 80
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 3. Async compatibility
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestAsyncCompat:
|
||||||
|
def test_async_modules_import(self):
|
||||||
|
from cloakbrowser.human.mouse_async import AsyncRawMouse, async_human_move
|
||||||
|
from cloakbrowser.human.keyboard_async import AsyncRawKeyboard, async_human_type
|
||||||
|
from cloakbrowser.human.scroll_async import async_scroll_to_element
|
||||||
|
from cloakbrowser.human import patch_page_async, patch_browser_async, patch_context_async
|
||||||
|
assert callable(async_human_move)
|
||||||
|
assert callable(async_human_type)
|
||||||
|
assert callable(async_scroll_to_element)
|
||||||
|
|
||||||
|
def test_async_locator_patch(self):
|
||||||
|
import cloakbrowser.human as h
|
||||||
|
h._locator_async_patched = False
|
||||||
|
h._patch_locator_class_async()
|
||||||
|
assert h._locator_async_patched
|
||||||
|
from playwright.async_api._generated import Locator as AsyncLocator
|
||||||
|
assert 'humanized' in AsyncLocator.fill.__name__
|
||||||
|
|
||||||
|
def test_async_sleep_is_coroutine(self):
|
||||||
|
from cloakbrowser.human.config import async_sleep_ms
|
||||||
|
import asyncio
|
||||||
|
assert asyncio.iscoroutinefunction(async_sleep_ms)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 4. Focus check — press / clear / pressSequentially
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestFocusCheck:
|
||||||
|
def test_press_skips_click_when_focused(self):
|
||||||
|
_ensure_locator_patched()
|
||||||
|
from unittest.mock import MagicMock, patch as mock_patch
|
||||||
|
page = MagicMock()
|
||||||
|
page._original = MagicMock()
|
||||||
|
page._human_cfg = MagicMock()
|
||||||
|
page._human_cfg.idle_between_actions = False
|
||||||
|
|
||||||
|
with mock_patch("cloakbrowser.human._is_selector_focused", return_value=True):
|
||||||
|
from playwright.sync_api._generated import Locator
|
||||||
|
loc = MagicMock()
|
||||||
|
loc.page = page
|
||||||
|
loc._impl_obj = MagicMock()
|
||||||
|
loc._impl_obj._selector = "#test"
|
||||||
|
Locator.press(loc, "Enter")
|
||||||
|
|
||||||
|
page.click.assert_not_called()
|
||||||
|
|
||||||
|
def test_press_clicks_when_not_focused(self):
|
||||||
|
_ensure_locator_patched()
|
||||||
|
from unittest.mock import MagicMock, patch as mock_patch
|
||||||
|
page = MagicMock()
|
||||||
|
page._original = MagicMock()
|
||||||
|
page._human_cfg = MagicMock()
|
||||||
|
page._human_cfg.idle_between_actions = False
|
||||||
|
|
||||||
|
with mock_patch("cloakbrowser.human._is_selector_focused", return_value=False):
|
||||||
|
from playwright.sync_api._generated import Locator
|
||||||
|
loc = MagicMock()
|
||||||
|
loc.page = page
|
||||||
|
loc._impl_obj = MagicMock()
|
||||||
|
loc._impl_obj._selector = "#test"
|
||||||
|
Locator.press(loc, "Enter")
|
||||||
|
|
||||||
|
page.click.assert_called_with("#test")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 5. check/uncheck idle
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestCheckUncheckIdle:
|
||||||
|
def test_check_calls_idle_when_enabled(self):
|
||||||
|
_ensure_locator_patched()
|
||||||
|
from unittest.mock import MagicMock, patch as mock_patch
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})
|
||||||
|
|
||||||
|
page = MagicMock()
|
||||||
|
page._original = MagicMock()
|
||||||
|
page._original.mouse_move = MagicMock()
|
||||||
|
page._human_cfg = cfg
|
||||||
|
|
||||||
|
idle_called = {"n": 0}
|
||||||
|
def fake_idle(*a, **kw):
|
||||||
|
idle_called["n"] += 1
|
||||||
|
|
||||||
|
from playwright.sync_api._generated import Locator
|
||||||
|
loc = MagicMock()
|
||||||
|
loc.page = page
|
||||||
|
loc._impl_obj = MagicMock()
|
||||||
|
loc._impl_obj._selector = "#checkbox"
|
||||||
|
loc.is_checked = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with mock_patch("cloakbrowser.human.human_idle", fake_idle):
|
||||||
|
Locator.check(loc)
|
||||||
|
|
||||||
|
assert idle_called["n"] >= 1
|
||||||
|
|
||||||
|
def test_uncheck_calls_idle_when_enabled(self):
|
||||||
|
_ensure_locator_patched()
|
||||||
|
from unittest.mock import MagicMock, patch as mock_patch
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})
|
||||||
|
|
||||||
|
page = MagicMock()
|
||||||
|
page._original = MagicMock()
|
||||||
|
page._original.mouse_move = MagicMock()
|
||||||
|
page._human_cfg = cfg
|
||||||
|
|
||||||
|
idle_called = {"n": 0}
|
||||||
|
def fake_idle(*a, **kw):
|
||||||
|
idle_called["n"] += 1
|
||||||
|
|
||||||
|
from playwright.sync_api._generated import Locator
|
||||||
|
loc = MagicMock()
|
||||||
|
loc.page = page
|
||||||
|
loc._impl_obj = MagicMock()
|
||||||
|
loc._impl_obj._selector = "#checkbox"
|
||||||
|
loc.is_checked = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with mock_patch("cloakbrowser.human.human_idle", fake_idle):
|
||||||
|
Locator.uncheck(loc)
|
||||||
|
|
||||||
|
assert idle_called["n"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 6. Frame patching completeness
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestFramePatching:
|
||||||
|
def test_all_11_methods_patched(self):
|
||||||
|
from cloakbrowser.human import _patch_single_frame_sync, _CursorState
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
cfg = resolve_config("default", None)
|
||||||
|
cursor = _CursorState()
|
||||||
|
page = MagicMock()
|
||||||
|
page._original = MagicMock()
|
||||||
|
frame = MagicMock()
|
||||||
|
frame._human_patched = False
|
||||||
|
|
||||||
|
_patch_single_frame_sync(frame, page, cfg, cursor, MagicMock(), MagicMock(), page._original)
|
||||||
|
|
||||||
|
expected = ['click', 'dblclick', 'hover', 'type', 'fill',
|
||||||
|
'check', 'uncheck', 'select_option', 'press',
|
||||||
|
'clear', 'drag_and_drop']
|
||||||
|
for method in expected:
|
||||||
|
fn = getattr(frame, method)
|
||||||
|
assert not isinstance(fn, MagicMock), f"frame.{method} was not patched"
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 7. drag_to safety
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestDragToSafety:
|
||||||
|
def test_handles_missing_original(self):
|
||||||
|
_ensure_locator_patched()
|
||||||
|
from playwright.sync_api._generated import Locator
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
page = MagicMock()
|
||||||
|
page._original = None
|
||||||
|
|
||||||
|
source_loc = MagicMock()
|
||||||
|
source_loc.page = page
|
||||||
|
source_loc._impl_obj = MagicMock()
|
||||||
|
source_loc._impl_obj._selector = "#src"
|
||||||
|
source_loc.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 50})
|
||||||
|
|
||||||
|
target_loc = MagicMock()
|
||||||
|
target_loc.page = page
|
||||||
|
target_loc._impl_obj = MagicMock()
|
||||||
|
target_loc._impl_obj._selector = "#tgt"
|
||||||
|
target_loc.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 50, "height": 50})
|
||||||
|
|
||||||
|
try:
|
||||||
|
Locator.drag_to(source_loc, target_loc)
|
||||||
|
except AttributeError:
|
||||||
|
pytest.fail("drag_to crashed without page._original")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 8. Page config persistence
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestPageConfigPersistence:
|
||||||
|
def test_resolve_config_has_all_fields(self):
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default")
|
||||||
|
required = ["mouse_min_steps", "mouse_max_steps", "typing_delay",
|
||||||
|
"initial_cursor_x", "initial_cursor_y", "idle_between_actions",
|
||||||
|
"idle_between_duration", "field_switch_delay",
|
||||||
|
"mistype_chance", "mistype_delay_notice", "mistype_delay_correct"]
|
||||||
|
for field in required:
|
||||||
|
assert hasattr(cfg, field), f"Config missing field: {field}"
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 9. Mistype config
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestMistypeConfig:
|
||||||
|
def test_default_mistype_chance(self):
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
cfg = resolve_config("default")
|
||||||
|
assert 0 < cfg.mistype_chance < 1
|
||||||
|
assert len(cfg.mistype_delay_notice) == 2
|
||||||
|
assert len(cfg.mistype_delay_correct) == 2
|
||||||
|
|
||||||
|
def test_careful_mistype_higher(self):
|
||||||
|
from cloakbrowser.human.config import resolve_config
|
||||||
|
default = resolve_config("default")
|
||||||
|
careful = resolve_config("careful")
|
||||||
|
assert careful.mistype_chance >= default.mistype_chance
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 10. Select-all platform detection
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestSelectAllPlatform:
|
||||||
|
def test_select_all_constant_exists(self):
|
||||||
|
from cloakbrowser.human import _SELECT_ALL
|
||||||
|
assert _SELECT_ALL in ("Meta+a", "Control+a")
|
||||||
|
|
||||||
|
def test_select_all_matches_platform(self):
|
||||||
|
import sys
|
||||||
|
from cloakbrowser.human import _SELECT_ALL
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
assert _SELECT_ALL == "Meta+a"
|
||||||
|
else:
|
||||||
|
assert _SELECT_ALL == "Control+a"
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# SLOW TESTS — require browser (skipped in CI unless pytest -m slow)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
class TestBrowserFill:
|
||||||
|
def test_fill_clears_existing(self):
|
||||||
|
from cloakbrowser import launch
|
||||||
|
browser = launch(headless=True, humanize=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||||
|
time.sleep(1)
|
||||||
|
page.locator('#searchInput').type('initial text')
|
||||||
|
time.sleep(0.5)
|
||||||
|
page.locator('#searchInput').fill('replaced text')
|
||||||
|
time.sleep(0.5)
|
||||||
|
val = page.locator('#searchInput').input_value()
|
||||||
|
assert val == 'replaced text'
|
||||||
|
assert 'initial' not in val
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_fill_timing_humanized(self):
|
||||||
|
from cloakbrowser import launch
|
||||||
|
browser = launch(headless=True, humanize=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||||
|
time.sleep(1)
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#searchInput').fill('Human speed test')
|
||||||
|
elapsed_ms = int((time.time() - t0) * 1000)
|
||||||
|
assert elapsed_ms > 1000
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_clear_empties_field(self):
|
||||||
|
from cloakbrowser import launch
|
||||||
|
browser = launch(headless=True, humanize=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||||
|
time.sleep(1)
|
||||||
|
page.locator('#searchInput').fill('some text')
|
||||||
|
time.sleep(0.5)
|
||||||
|
page.locator('#searchInput').clear()
|
||||||
|
time.sleep(0.5)
|
||||||
|
val = page.locator('#searchInput').input_value()
|
||||||
|
assert val == ''
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
class TestBrowserPatching:
|
||||||
|
def test_page_has_original(self):
|
||||||
|
from cloakbrowser import launch
|
||||||
|
browser = launch(headless=True, humanize=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
assert hasattr(page, '_original')
|
||||||
|
assert hasattr(page, '_human_cfg')
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_locator_methods_patched(self):
|
||||||
|
from cloakbrowser import launch
|
||||||
|
browser = launch(headless=True, humanize=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
from playwright.sync_api._generated import Locator
|
||||||
|
methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck',
|
||||||
|
'set_checked', 'select_option', 'press', 'press_sequentially',
|
||||||
|
'tap', 'drag_to', 'clear']
|
||||||
|
for method in methods:
|
||||||
|
fn = getattr(Locator, method)
|
||||||
|
assert 'humanized' in fn.__name__, f"{method} not patched"
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_non_humanized_page_normal(self):
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
assert not hasattr(page, '_original')
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_page_human_cfg_persists(self):
|
||||||
|
from cloakbrowser import launch
|
||||||
|
browser = launch(headless=True, humanize=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
assert page._human_cfg is not None
|
||||||
|
assert hasattr(page._human_cfg, 'idle_between_actions')
|
||||||
|
assert hasattr(page._human_cfg, 'mistype_chance')
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
class TestBrowserBotDetection:
|
||||||
|
PROXY = ''
|
||||||
|
|
||||||
|
def test_behavioral_checks_pass(self):
|
||||||
|
from cloakbrowser import launch
|
||||||
|
browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
|
||||||
|
wait_until='domcontentloaded')
|
||||||
|
time.sleep(3)
|
||||||
|
page.locator('#email').click()
|
||||||
|
time.sleep(0.3)
|
||||||
|
page.locator('#email').fill('test@example.com')
|
||||||
|
time.sleep(0.5)
|
||||||
|
page.locator('#password').click()
|
||||||
|
time.sleep(0.3)
|
||||||
|
page.locator('#password').fill('SecurePass!123')
|
||||||
|
time.sleep(0.5)
|
||||||
|
page.locator('button[type="submit"]').click()
|
||||||
|
time.sleep(5)
|
||||||
|
body = page.locator('body').text_content()
|
||||||
|
assert '"superHumanSpeed": true' not in body
|
||||||
|
assert '"suspiciousClientSideBehavior": true' not in body
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_form_timing(self):
|
||||||
|
from cloakbrowser import launch
|
||||||
|
browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
|
||||||
|
wait_until='domcontentloaded')
|
||||||
|
time.sleep(2)
|
||||||
|
t0 = time.time()
|
||||||
|
page.locator('#email').fill('test@example.com')
|
||||||
|
page.locator('#password').fill('MyPassword!99')
|
||||||
|
page.locator('button[type="submit"]').click()
|
||||||
|
elapsed_ms = int((time.time() - t0) * 1000)
|
||||||
|
time.sleep(3)
|
||||||
|
assert elapsed_ms > 3000
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
class TestAsyncEndToEnd:
|
||||||
|
def test_async_launch_click_fill(self):
|
||||||
|
"""launch_async(humanize=True) — async page.click and page.fill work end-to-end."""
|
||||||
|
import asyncio
|
||||||
|
from cloakbrowser import launch_async
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
browser = await launch_async(headless=True, humanize=True)
|
||||||
|
page = await browser.new_page()
|
||||||
|
assert hasattr(page, '_original'), "async page not patched"
|
||||||
|
assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"
|
||||||
|
|
||||||
|
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
await page.locator('#searchInput').fill('async test')
|
||||||
|
elapsed_ms = int((time.time() - t0) * 1000)
|
||||||
|
assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms"
|
||||||
|
|
||||||
|
val = await page.locator('#searchInput').input_value()
|
||||||
|
assert val == 'async test', f"async fill wrong value: {val}"
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Direct runner (backwards compat)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"]))
|
||||||
Reference in New Issue
Block a user