mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db0b5f1946 | ||
|
|
babef04e07 | ||
|
|
f8026a7b39 | ||
|
|
71f57d00d1 | ||
|
|
e9735392e8 | ||
|
|
0d41a4f023 | ||
|
|
80d9f7c14e | ||
|
|
114b3c826b | ||
|
|
c07c2b6b4a | ||
|
|
13b1b98b68 | ||
|
|
0d6ce76b1d | ||
|
|
f01902025a | ||
|
|
2df8c7e2d1 | ||
|
|
661b873dad |
@@ -123,7 +123,7 @@ jobs:
|
||||
cloakhq/cloakbrowser:latest
|
||||
provenance: true
|
||||
sbom: true
|
||||
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
- name: Sign image
|
||||
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
|
||||
- name: Attest build provenance
|
||||
|
||||
@@ -8,6 +8,23 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.28] — 2026-05-11
|
||||
|
||||
- **[wrapper]** **Security**: `cloakserve` — sanitize fingerprint seed to prevent path traversal, bind to `127.0.0.1` on bare metal, detect Podman containers (#217)
|
||||
- **[wrapper]** Fix GeoIP resolution hanging indefinitely — bounded with 10s timeout so `launch()` cannot stall (thanks [@manaskarra](https://github.com/manaskarra), #213)
|
||||
- **[wrapper]** JS: preserve iframe scope in humanized frame actions — `check()`, `uncheck()`, `selectOption()` now execute in the correct frame (thanks [@manaskarra](https://github.com/manaskarra), #201)
|
||||
- **[wrapper]** JS: add TypeScript types to humanized method options — `HumanActionOptions` type for `human_config` and `timeout` overrides (thanks [@eofreternal](https://github.com/eofreternal), #205)
|
||||
- **[wrapper]** Log when SOCKS5 credential auto-encoding rewrites a proxy URL (thanks [@Youhai020616](https://github.com/Youhai020616), #209)
|
||||
- **[wrapper]** JS: bump `playwright-core` peer dependency minimum to >=1.53.0 (#200)
|
||||
- **[meta]** Bump sigstore/cosign-installer in CI (#214)
|
||||
|
||||
## [0.3.27] — 2026-05-06
|
||||
|
||||
- **[wrapper]** Per-call `human_config` override — pass `human_config={...}` to individual humanized methods to override global HumanConfig on a per-action basis (#183)
|
||||
- **[wrapper]** Humanized `scrollIntoViewIfNeeded` — auto-scrolls with human-like behavior when `humanize=True` (#183)
|
||||
- **[wrapper]** Forward `timeout` parameter through humanized Playwright methods (#183)
|
||||
- **[wrapper]** Fix humanize timeout default to align with Playwright's 30s auto-retry instead of custom 2s (#172)
|
||||
|
||||
## [0.3.26] — 2026-04-28
|
||||
|
||||
- **[binary]** Windows x64 upgraded to Chromium 146.0.7680.177.4 — 57 source-level fingerprint patches (up from 33 on 145.0.7632.159.7), now matches Linux. Includes all binary improvements from 0.3.18–0.3.25: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3), WebRTC IP spoofing, proxy signal removal, CDP input stealth, storage quota normalization, WebAuthn/AAC/window position patches, WebGL and canvas consistency fixes, expanded GPU model database
|
||||
|
||||
@@ -570,6 +570,7 @@ Access the original un-patched Playwright page at `page._original` if you need r
|
||||
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
|
||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
||||
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
|
||||
|
||||
## Fingerprint Management
|
||||
|
||||
@@ -1192,5 +1193,7 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
|
||||
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
|
||||
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
|
||||
- [@kitiho](https://github.com/kitiho) — null viewport fix
|
||||
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix
|
||||
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types
|
||||
- [@manaskarra](https://github.com/manaskarra) — iframe scope fix for humanized frame actions, GeoIP timeout guard
|
||||
- [@Youhai020616](https://github.com/Youhai020616) — SOCKS5 credential encoding logging
|
||||
- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration
|
||||
|
||||
+31
-6
@@ -22,6 +22,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -61,6 +62,9 @@ BASE_CHROME_ARGS = [
|
||||
|
||||
BASE_CDP_PORT = 5100
|
||||
|
||||
SAFE_SEED_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
RESERVED_SEEDS = {"__default__"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChromeProcess — one running Chrome instance
|
||||
@@ -111,6 +115,14 @@ class ChromePool:
|
||||
self._locks[seed] = asyncio.Lock()
|
||||
return self._locks[seed]
|
||||
|
||||
def _safe_rmtree(self, path: str) -> None:
|
||||
resolved = Path(path).resolve()
|
||||
data_resolved = Path(self._data_dir).resolve()
|
||||
if resolved == data_resolved or not resolved.is_relative_to(data_resolved):
|
||||
logger.error("Refusing to delete path outside data_dir: %s", resolved)
|
||||
return
|
||||
shutil.rmtree(path, True)
|
||||
|
||||
def _allocate_port(self) -> int:
|
||||
"""Find a free port starting from _next_port."""
|
||||
for _ in range(100):
|
||||
@@ -159,6 +171,11 @@ class ChromePool:
|
||||
seed_key = "__default__"
|
||||
actual_seed = str(random.randint(10000, 99999))
|
||||
else:
|
||||
if not SAFE_SEED_RE.match(seed) or seed in RESERVED_SEEDS:
|
||||
raise web.HTTPBadRequest(
|
||||
text=json.dumps({"error": "Invalid fingerprint seed"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
seed_key = seed
|
||||
actual_seed = seed
|
||||
|
||||
@@ -232,7 +249,7 @@ class ChromePool:
|
||||
if not await self._wait_for_cdp(port):
|
||||
process.kill()
|
||||
await asyncio.to_thread(process.wait, timeout=5)
|
||||
await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
|
||||
await asyncio.to_thread(self._safe_rmtree, user_data_dir)
|
||||
raise web.HTTPBadGateway(
|
||||
text=json.dumps({"error": "Chrome failed to start"}),
|
||||
content_type="application/json",
|
||||
@@ -266,8 +283,7 @@ class ChromePool:
|
||||
await asyncio.to_thread(proc.process.wait, timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.process.kill()
|
||||
# Clean up user data dir (can be slow for large profiles)
|
||||
await asyncio.to_thread(shutil.rmtree, proc.user_data_dir, True)
|
||||
await asyncio.to_thread(self._safe_rmtree, proc.user_data_dir)
|
||||
if self._default is proc:
|
||||
self._default = None
|
||||
self._locks.pop(key, None)
|
||||
@@ -559,8 +575,8 @@ async def on_shutdown(app: web.Application) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_data_dir() -> str:
|
||||
"""Smart default: Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
|
||||
if os.path.exists("/.dockerenv"):
|
||||
"""Smart default: container → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
|
||||
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
|
||||
return "/tmp/cloakserve"
|
||||
return str(Path.home() / ".cloakbrowser" / "cloakserve")
|
||||
|
||||
@@ -624,6 +640,13 @@ def main() -> None:
|
||||
binary = ensure_binary()
|
||||
config, global_args = parse_cli_args(sys.argv[1:])
|
||||
|
||||
if config["default_seed"] and (
|
||||
not SAFE_SEED_RE.match(config["default_seed"])
|
||||
or config["default_seed"] in RESERVED_SEEDS
|
||||
):
|
||||
logger.error("Invalid --fingerprint seed: %s", config["default_seed"])
|
||||
sys.exit(1)
|
||||
|
||||
pool = ChromePool(
|
||||
binary=binary,
|
||||
global_args=global_args,
|
||||
@@ -662,7 +685,9 @@ def main() -> None:
|
||||
port,
|
||||
)
|
||||
|
||||
web.run_app(app, host="0.0.0.0", port=port, print=None)
|
||||
in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
|
||||
host = "0.0.0.0" if in_container else "127.0.0.1"
|
||||
web.run_app(app, host=host, port=port, print=None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.26"
|
||||
__version__ = "0.3.28"
|
||||
|
||||
+24
-8
@@ -25,6 +25,7 @@ from .human.config import HumanConfigOverrides, HumanPreset
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
|
||||
# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)
|
||||
_VIEWPORT_UNSET = object()
|
||||
|
||||
@@ -813,6 +814,10 @@ def _normalize_socks_string_url(url: str) -> str:
|
||||
truncate them at special chars like '='. Idempotent: pre-encoded input stays
|
||||
the same (decoded then re-encoded).
|
||||
|
||||
Emits an INFO log when re-encoding actually changes the URL, so users who
|
||||
previously hit silent SOCKS5 fallback (#157) can see what the wrapper did.
|
||||
Silent on already-encoded inputs (no false-positive noise).
|
||||
|
||||
On unparseable input (invalid port, broken IPv6 literal, etc.) logs a
|
||||
warning and returns the original string — preserves pre-fix pass-through
|
||||
behavior so Chromium's own error handling kicks in.
|
||||
@@ -828,18 +833,30 @@ def _normalize_socks_string_url(url: str) -> str:
|
||||
# urlparse returns None for absent components, "" for present-but-empty.
|
||||
if parsed.username is None and parsed.password is None:
|
||||
return url
|
||||
enc_user = quote(unquote(parsed.username), safe="") if parsed.username else ""
|
||||
raw_user = parsed.username or ""
|
||||
enc_user = quote(unquote(raw_user), safe="") if raw_user else ""
|
||||
# Preserve the colon separator when password component is present, even if
|
||||
# empty, so `user:@host` stays `user:@host`.
|
||||
if parsed.password is not None:
|
||||
enc_pass = quote(unquote(parsed.password), safe="") if parsed.password else ""
|
||||
raw_pass = parsed.password
|
||||
enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else ""
|
||||
else:
|
||||
raw_pass = None
|
||||
enc_pass = None
|
||||
return _assemble_socks_url(
|
||||
normalized = _assemble_socks_url(
|
||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||
enc_user, enc_pass,
|
||||
parsed.path, parsed.params, parsed.query, parsed.fragment,
|
||||
)
|
||||
# Compare credentials, not the full URL: urlparse cosmetically lowercases
|
||||
# scheme and hostname, so a full-string compare would falsely fire on
|
||||
# `socks5://USER:pass@HOST.com:1080` even when no encoding work happened.
|
||||
if enc_user != raw_user or enc_pass != raw_pass:
|
||||
logger.info(
|
||||
"Auto URL-encoded SOCKS5 proxy credentials (special characters "
|
||||
"detected). Pre-encode the URL to suppress this notice."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
|
||||
@@ -874,7 +891,7 @@ def maybe_resolve_geoip(
|
||||
if not geoip or not proxy:
|
||||
return timezone, locale, None
|
||||
|
||||
from .geoip import resolve_proxy_geo_with_ip
|
||||
from .geoip import resolve_proxy_exit_ip, resolve_proxy_geo_with_ip
|
||||
|
||||
proxy_url = _extract_proxy_url(proxy)
|
||||
if not proxy_url:
|
||||
@@ -882,8 +899,7 @@ def maybe_resolve_geoip(
|
||||
|
||||
# When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||
if timezone is not None and locale is not None:
|
||||
from .geoip import _resolve_exit_ip
|
||||
exit_ip = _resolve_exit_ip(proxy_url)
|
||||
exit_ip = resolve_proxy_exit_ip(proxy_url)
|
||||
return timezone, locale, exit_ip
|
||||
|
||||
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
|
||||
@@ -918,8 +934,8 @@ def _resolve_webrtc_args(
|
||||
del args[idx]
|
||||
return args
|
||||
try:
|
||||
from .geoip import _resolve_exit_ip
|
||||
exit_ip = _resolve_exit_ip(proxy_url)
|
||||
from .geoip import resolve_proxy_exit_ip
|
||||
exit_ip = resolve_proxy_exit_ip(proxy_url)
|
||||
except Exception:
|
||||
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
|
||||
args = list(args)
|
||||
|
||||
@@ -60,7 +60,6 @@ def _show_welcome() -> None:
|
||||
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
|
||||
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.write(" Issues? https://github.com/CloakHQ/CloakBrowser/issues\n")
|
||||
sys.stderr.write(" Donate? https://ko-fi.com/cloakhq\n")
|
||||
sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
|
||||
sys.stderr.write("\n")
|
||||
|
||||
+65
-5
@@ -12,6 +12,8 @@ from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -27,6 +29,8 @@ GEOIP_DB_URL = (
|
||||
)
|
||||
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
|
||||
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days
|
||||
DEFAULT_GEOIP_TIMEOUT_SECONDS = 5.0
|
||||
GEOIP_TIMEOUT_ENV = "CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS"
|
||||
|
||||
# Country ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
|
||||
COUNTRY_LOCALE_MAP: dict[str, str] = {
|
||||
@@ -77,11 +81,16 @@ def resolve_proxy_geo_with_ip(
|
||||
if db_path is None:
|
||||
return None, None, None
|
||||
|
||||
timeout = _get_geoip_timeout_seconds()
|
||||
deadline = _deadline_from_timeout(timeout)
|
||||
|
||||
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||
ip = _resolve_exit_ip(proxy_url)
|
||||
if ip is None:
|
||||
ip = _resolve_exit_ip(proxy_url, timeout=_remaining_seconds(deadline))
|
||||
if ip is None and not _deadline_expired(deadline):
|
||||
ip = _resolve_proxy_ip(proxy_url)
|
||||
if ip is None:
|
||||
if ip is None or _deadline_expired(deadline):
|
||||
if deadline is not None and _deadline_expired(deadline):
|
||||
logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout)
|
||||
return None, None, None
|
||||
|
||||
try:
|
||||
@@ -152,13 +161,64 @@ _IP_ECHO_URLS = [
|
||||
]
|
||||
|
||||
|
||||
def _resolve_exit_ip(proxy_url: str) -> str | None:
|
||||
def _get_geoip_timeout_seconds() -> float:
|
||||
raw = os.getenv(GEOIP_TIMEOUT_ENV)
|
||||
if not raw:
|
||||
return DEFAULT_GEOIP_TIMEOUT_SECONDS
|
||||
try:
|
||||
timeout = float(raw)
|
||||
except ValueError:
|
||||
timeout = float("nan")
|
||||
if not math.isfinite(timeout):
|
||||
logger.warning(
|
||||
"Invalid %s=%r; using %.1fs",
|
||||
GEOIP_TIMEOUT_ENV,
|
||||
raw,
|
||||
DEFAULT_GEOIP_TIMEOUT_SECONDS,
|
||||
)
|
||||
return DEFAULT_GEOIP_TIMEOUT_SECONDS
|
||||
return max(timeout, 0.0)
|
||||
|
||||
|
||||
def _deadline_from_timeout(timeout: float) -> float | None:
|
||||
if timeout <= 0:
|
||||
return None
|
||||
return time.monotonic() + timeout
|
||||
|
||||
|
||||
def _remaining_seconds(deadline: float | None) -> float | None:
|
||||
if deadline is None:
|
||||
return None
|
||||
return max(deadline - time.monotonic(), 0.0)
|
||||
|
||||
|
||||
def _deadline_expired(deadline: float | None) -> bool:
|
||||
return deadline is not None and time.monotonic() >= deadline
|
||||
|
||||
|
||||
def resolve_proxy_exit_ip(proxy_url: str) -> str | None:
|
||||
"""Resolve only the proxy exit IP, bounded by the GeoIP timeout."""
|
||||
timeout = _get_geoip_timeout_seconds()
|
||||
deadline = _deadline_from_timeout(timeout)
|
||||
ip = _resolve_exit_ip(proxy_url, timeout=timeout)
|
||||
if ip is None and _deadline_expired(deadline):
|
||||
logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout)
|
||||
return ip
|
||||
|
||||
|
||||
def _resolve_exit_ip(proxy_url: str, timeout: float | None = None) -> str | None:
|
||||
"""Discover the proxy's actual exit IP by connecting through it."""
|
||||
import httpx
|
||||
|
||||
deadline = _deadline_from_timeout(timeout or 0)
|
||||
|
||||
for url in _IP_ECHO_URLS:
|
||||
try:
|
||||
resp = httpx.get(url, proxy=proxy_url, timeout=10.0)
|
||||
remaining = _remaining_seconds(deadline)
|
||||
if remaining is not None and remaining <= 0:
|
||||
return None
|
||||
request_timeout = min(10.0, remaining) if remaining is not None else 10.0
|
||||
resp = httpx.get(url, proxy=proxy_url, timeout=request_timeout)
|
||||
resp.raise_for_status()
|
||||
ip = resp.text.strip()
|
||||
# Validate it looks like an IP
|
||||
|
||||
+304
-95
@@ -18,23 +18,23 @@ import logging
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from .config import HumanConfig, HumanPreset, resolve_config
|
||||
from .config import HumanConfig, HumanPreset, resolve_config, merge_config
|
||||
from .config import rand, rand_range, sleep_ms, async_sleep_ms
|
||||
from .mouse import RawMouse, human_move, human_click, click_target, human_idle
|
||||
from .keyboard import RawKeyboard, human_type
|
||||
from .scroll import scroll_to_element
|
||||
from .scroll import scroll_to_element, human_scroll_into_view
|
||||
from .mouse_async import AsyncRawMouse, async_human_move, async_human_click, async_human_idle
|
||||
from .keyboard_async import AsyncRawKeyboard, async_human_type
|
||||
from .scroll_async import async_scroll_to_element
|
||||
from .scroll_async import async_scroll_to_element, async_human_scroll_into_view
|
||||
|
||||
_SELECT_ALL = "Meta+a" if sys.platform == "darwin" else "Control+a"
|
||||
|
||||
__all__ = [
|
||||
"patch_browser", "patch_context", "patch_page",
|
||||
"patch_browser_async", "patch_context_async", "patch_page_async",
|
||||
"HumanConfig", "resolve_config",
|
||||
"HumanConfig", "resolve_config", "merge_config",
|
||||
"human_move", "human_click", "click_target", "human_idle",
|
||||
"human_type", "scroll_to_element",
|
||||
"human_type", "scroll_to_element", "human_scroll_into_view",
|
||||
]
|
||||
|
||||
logger = logging.getLogger("cloakbrowser.human")
|
||||
@@ -356,6 +356,7 @@ def _patch_locator_class_sync():
|
||||
_orig_tap = Locator.tap
|
||||
_orig_drag_to = Locator.drag_to
|
||||
_orig_clear = Locator.clear
|
||||
_orig_scroll_into_view = getattr(Locator, 'scroll_into_view_if_needed', None)
|
||||
|
||||
def _get_selector(self):
|
||||
return self._impl_obj._selector
|
||||
@@ -366,36 +367,76 @@ def _patch_locator_class_sync():
|
||||
def _get_cfg(self):
|
||||
return getattr(self.page, '_human_cfg', None)
|
||||
|
||||
# Forward only options the page-level humanized methods understand
|
||||
# (timeout, human_config). Other Locator-specific kwargs (force, trial,
|
||||
# noWaitAfter, ...) are silently dropped — the humanized path doesn't
|
||||
# consult them.
|
||||
def _forward_kwargs(kwargs):
|
||||
out = {}
|
||||
if "timeout" in kwargs:
|
||||
out["timeout"] = kwargs["timeout"]
|
||||
if "human_config" in kwargs:
|
||||
out["human_config"] = kwargs["human_config"]
|
||||
return out
|
||||
|
||||
def _humanized_fill(self, value, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.fill(_get_selector(self), value)
|
||||
self.page.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_fill(self, value, **kwargs)
|
||||
|
||||
def _humanized_click(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.click(_get_selector(self))
|
||||
self.page.click(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_click(self, **kwargs)
|
||||
|
||||
def _humanized_type(self, text, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.type(_get_selector(self), text)
|
||||
self.page.type(_get_selector(self), text, **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_type(self, text, **kwargs)
|
||||
|
||||
def _humanized_dblclick(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.dblclick(_get_selector(self))
|
||||
self.page.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_dblclick(self, **kwargs)
|
||||
|
||||
def _humanized_hover(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.hover(_get_selector(self))
|
||||
self.page.hover(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_hover(self, **kwargs)
|
||||
|
||||
def _humanized_scroll_into_view_if_needed(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
page = self.page
|
||||
cfg = _get_cfg(self)
|
||||
cursor = getattr(page, '_human_cursor', None)
|
||||
raw = getattr(page, '_human_raw_mouse', None)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
|
||||
if call_cfg is None or cursor is None or raw is None:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
return _orig_scroll_into_view(self, **native_kwargs)
|
||||
return
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
try:
|
||||
_, nx, ny = human_scroll_into_view(
|
||||
page, raw,
|
||||
lambda: self.bounding_box(timeout=timeout),
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
_orig_scroll_into_view(self, **native_kwargs)
|
||||
elif _orig_scroll_into_view is not None:
|
||||
_orig_scroll_into_view(self, **kwargs)
|
||||
|
||||
def _humanized_check(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
cfg = _get_cfg(self)
|
||||
@@ -512,6 +553,8 @@ def _patch_locator_class_sync():
|
||||
Locator.tap = _humanized_tap
|
||||
Locator.drag_to = _humanized_drag_to
|
||||
Locator.clear = _humanized_clear
|
||||
if _orig_scroll_into_view is not None:
|
||||
Locator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -544,6 +587,7 @@ def _patch_locator_class_async():
|
||||
_orig_tap = AsyncLocator.tap
|
||||
_orig_drag_to = AsyncLocator.drag_to
|
||||
_orig_clear = AsyncLocator.clear
|
||||
_orig_scroll_into_view = getattr(AsyncLocator, 'scroll_into_view_if_needed', None)
|
||||
|
||||
def _get_selector(self):
|
||||
return self._impl_obj._selector
|
||||
@@ -554,36 +598,74 @@ def _patch_locator_class_async():
|
||||
def _get_cfg(self):
|
||||
return getattr(self.page, '_human_cfg', None)
|
||||
|
||||
def _forward_kwargs(kwargs):
|
||||
out = {}
|
||||
if "timeout" in kwargs:
|
||||
out["timeout"] = kwargs["timeout"]
|
||||
if "human_config" in kwargs:
|
||||
out["human_config"] = kwargs["human_config"]
|
||||
return out
|
||||
|
||||
async def _humanized_fill(self, value, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.fill(_get_selector(self), value)
|
||||
await self.page.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_fill(self, value, **kwargs)
|
||||
|
||||
async def _humanized_click(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.click(_get_selector(self))
|
||||
await self.page.click(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_click(self, **kwargs)
|
||||
|
||||
async def _humanized_type(self, text, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.type(_get_selector(self), text)
|
||||
await self.page.type(_get_selector(self), text, **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_type(self, text, **kwargs)
|
||||
|
||||
async def _humanized_dblclick(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.dblclick(_get_selector(self))
|
||||
await self.page.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_dblclick(self, **kwargs)
|
||||
|
||||
async def _humanized_hover(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.hover(_get_selector(self))
|
||||
await self.page.hover(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_hover(self, **kwargs)
|
||||
|
||||
async def _humanized_scroll_into_view_if_needed(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
page = self.page
|
||||
cfg = _get_cfg(self)
|
||||
cursor = getattr(page, '_human_cursor', None)
|
||||
raw = getattr(page, '_human_raw_mouse', None)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
|
||||
if call_cfg is None or cursor is None or raw is None:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
await _orig_scroll_into_view(self, **native_kwargs)
|
||||
return
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
|
||||
async def _get_box():
|
||||
return await self.bounding_box(timeout=timeout)
|
||||
try:
|
||||
_, nx, ny = await async_human_scroll_into_view(
|
||||
page, raw, _get_box,
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
await _orig_scroll_into_view(self, **native_kwargs)
|
||||
elif _orig_scroll_into_view is not None:
|
||||
await _orig_scroll_into_view(self, **kwargs)
|
||||
|
||||
async def _humanized_check(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
cfg = _get_cfg(self)
|
||||
@@ -708,6 +790,8 @@ def _patch_locator_class_async():
|
||||
AsyncLocator.tap = _humanized_tap
|
||||
AsyncLocator.drag_to = _humanized_drag_to
|
||||
AsyncLocator.clear = _humanized_clear
|
||||
if _orig_scroll_into_view is not None:
|
||||
AsyncLocator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -738,6 +822,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
page._original = originals
|
||||
page._human_cfg = cfg
|
||||
page._human_cursor = cursor
|
||||
|
||||
# --- Stealth infrastructure ---
|
||||
try:
|
||||
@@ -764,6 +849,8 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
"insert_text": originals.keyboard_insert_text,
|
||||
})()
|
||||
|
||||
page._human_raw_mouse = raw_mouse
|
||||
|
||||
def _ensure_cursor_init() -> None:
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
|
||||
@@ -780,32 +867,36 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
def _human_click(selector: str, **kwargs: Any) -> None:
|
||||
_ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
if call_cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
is_input = _is_input_element(page, selector)
|
||||
target = click_target(box, is_input, cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, is_input, call_cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
human_click(raw_mouse, is_input, cfg)
|
||||
human_click(raw_mouse, is_input, call_cfg)
|
||||
|
||||
def _human_dblclick(selector: str, **kwargs: Any) -> None:
|
||||
_ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
if call_cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
is_input = _is_input_element(page, selector)
|
||||
target = click_target(box, is_input, cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, is_input, call_cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
raw_mouse.down(click_count=2)
|
||||
@@ -814,33 +905,39 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
def _human_hover(selector: str, **kwargs: Any) -> None:
|
||||
_ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
if call_cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
target = click_target(box, False, cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, False, call_cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
|
||||
def _human_type(selector: str, text: str, **kwargs: Any) -> None:
|
||||
sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
_human_click(selector)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
sleep_ms(rand_range(call_cfg.field_switch_delay))
|
||||
# Forward kwargs so timeout / human_config also propagate to the click
|
||||
# that focuses the field.
|
||||
_human_click(selector, **kwargs)
|
||||
sleep_ms(rand(100, 250))
|
||||
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
|
||||
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
|
||||
|
||||
def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
||||
sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
_human_click(selector)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
sleep_ms(rand_range(call_cfg.field_switch_delay))
|
||||
_human_click(selector, **kwargs)
|
||||
sleep_ms(rand(100, 250))
|
||||
originals.keyboard_press(_SELECT_ALL)
|
||||
sleep_ms(rand(30, 80))
|
||||
originals.keyboard_press("Backspace")
|
||||
sleep_ms(rand(50, 150))
|
||||
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
|
||||
human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
|
||||
|
||||
def _human_check(selector: str, **kwargs: Any) -> None:
|
||||
try:
|
||||
@@ -958,6 +1055,7 @@ def _patch_single_element_handle_sync(
|
||||
_orig_set_checked = getattr(el, 'set_checked', None)
|
||||
_orig_tap = el.tap
|
||||
_orig_focus = el.focus
|
||||
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
|
||||
|
||||
# Nested selectors
|
||||
_orig_qs = el.query_selector
|
||||
@@ -992,39 +1090,57 @@ def _patch_single_element_handle_sync(
|
||||
el.query_selector_all = _patched_qsa
|
||||
el.wait_for_selector = _patched_wfs
|
||||
|
||||
# Helper: move cursor to element
|
||||
def _move_to_element():
|
||||
# Helper: move cursor to element. Accepts optional ``call_cfg`` so per-call
|
||||
# ``human_config`` overrides on type/fill carry through to mouse timing.
|
||||
# Also scrolls into view first so off-screen elements don't silently fall
|
||||
# back to the unpatched native method (#129, #172 follow-up).
|
||||
def _move_to_element(call_cfg: HumanConfig = cfg):
|
||||
if not 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])
|
||||
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
|
||||
originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
|
||||
# Scroll into view first — best-effort. If the element can't be located
|
||||
# we fall through to bounding_box() below which returns None and lets
|
||||
# the caller fall back to the original Playwright method.
|
||||
try:
|
||||
_, nx, ny = human_scroll_into_view(
|
||||
page, raw_mouse, lambda: el.bounding_box(),
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
box = el.bounding_box()
|
||||
if not box:
|
||||
return None
|
||||
|
||||
is_inp = _is_input_element_handle_sync(el)
|
||||
target = click_target(box, is_inp, cfg)
|
||||
target = click_target(box, is_inp, call_cfg)
|
||||
|
||||
if cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
if call_cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
return {'box': box, 'is_inp': is_inp}
|
||||
|
||||
# --- el.click() ---
|
||||
def _human_el_click(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_click(**kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
|
||||
# --- el.dblclick() ---
|
||||
def _human_el_dblclick(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_dblclick(**kwargs)
|
||||
raw_mouse.down(click_count=2)
|
||||
@@ -1033,32 +1149,62 @@ def _patch_single_element_handle_sync(
|
||||
|
||||
# --- el.hover() ---
|
||||
def _human_el_hover(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_hover(**kwargs)
|
||||
# Just move, no click
|
||||
|
||||
# --- el.type() ---
|
||||
def _human_el_type(text: str, **kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_type(text, **kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
|
||||
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
|
||||
|
||||
# --- el.fill() ---
|
||||
def _human_el_fill(value: str, **kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_fill(value, **kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
originals.keyboard_press(_SELECT_ALL)
|
||||
sleep_ms(rand(30, 80))
|
||||
originals.keyboard_press("Backspace")
|
||||
sleep_ms(rand(50, 150))
|
||||
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
|
||||
human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
|
||||
|
||||
# --- el.scroll_into_view_if_needed() ---
|
||||
# Playwright's native version snaps the page — a strong bot signal.
|
||||
# Replace with the same accelerate → cruise → decelerate → overshoot wheel
|
||||
# sequence used by page.click(). Falls back to the native method if the
|
||||
# element is detached or scrolling fails.
|
||||
def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
|
||||
try:
|
||||
originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_, nx, ny = human_scroll_into_view(
|
||||
page, raw_mouse, lambda: el.bounding_box(),
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
_orig_scroll_into_view(**native_kwargs)
|
||||
|
||||
# --- el.press() ---
|
||||
def _human_el_press(key: str, **kwargs: Any) -> None:
|
||||
@@ -1142,6 +1288,8 @@ def _patch_single_element_handle_sync(
|
||||
el.set_checked = _human_el_set_checked
|
||||
el.tap = _human_el_tap
|
||||
el.focus = _human_el_focus
|
||||
if _orig_scroll_into_view is not None:
|
||||
el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed
|
||||
|
||||
|
||||
def _patch_page_element_handles_sync(
|
||||
@@ -1425,6 +1573,7 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
page._original = originals
|
||||
page._human_cfg = cfg
|
||||
page._human_cursor = cursor
|
||||
|
||||
# --- Stealth infrastructure (lazy-initialized, async) ---
|
||||
stealth = _AsyncIsolatedWorld(page)
|
||||
@@ -1454,6 +1603,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
"insert_text": originals.keyboard_insert_text,
|
||||
})()
|
||||
|
||||
page._human_raw_mouse = raw_mouse
|
||||
|
||||
async def _ensure_cursor_init() -> None:
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
|
||||
@@ -1469,32 +1620,36 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
async def _human_click(selector: str, **kwargs: Any) -> None:
|
||||
await _ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
if call_cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = await async_scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
is_input = await _async_is_input_element(page, selector)
|
||||
target = click_target(box, is_input, cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, is_input, call_cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
await async_human_click(raw_mouse, is_input, cfg)
|
||||
await async_human_click(raw_mouse, is_input, call_cfg)
|
||||
|
||||
async def _human_dblclick(selector: str, **kwargs: Any) -> None:
|
||||
await _ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
if call_cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = await async_scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
is_input = await _async_is_input_element(page, selector)
|
||||
target = click_target(box, is_input, cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, is_input, call_cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
await raw_mouse.down(click_count=2)
|
||||
@@ -1503,35 +1658,39 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
async def _human_hover(selector: str, **kwargs: Any) -> None:
|
||||
await _ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
if call_cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = await async_scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
target = click_target(box, False, cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, False, call_cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
|
||||
async def _human_type(selector: str, text: str, **kwargs: Any) -> None:
|
||||
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
await _human_click(selector)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
|
||||
await _human_click(selector, **kwargs)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
cdp = await _ensure_cdp()
|
||||
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
|
||||
await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
|
||||
|
||||
async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
||||
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
await _human_click(selector)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
|
||||
await _human_click(selector, **kwargs)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
await originals.keyboard_press(_SELECT_ALL)
|
||||
await async_sleep_ms(rand(30, 80))
|
||||
await originals.keyboard_press("Backspace")
|
||||
await async_sleep_ms(rand(50, 150))
|
||||
cdp = await _ensure_cdp()
|
||||
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
|
||||
await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
|
||||
|
||||
async def _human_check(selector: str, **kwargs: Any) -> None:
|
||||
try:
|
||||
@@ -1637,6 +1796,7 @@ def _patch_single_element_handle_async(
|
||||
_orig_set_checked = getattr(el, 'set_checked', None)
|
||||
_orig_tap = el.tap
|
||||
_orig_focus = el.focus
|
||||
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
|
||||
|
||||
# Nested selectors
|
||||
_orig_qs = el.query_selector
|
||||
@@ -1671,25 +1831,41 @@ def _patch_single_element_handle_async(
|
||||
el.query_selector_all = _patched_qsa
|
||||
el.wait_for_selector = _patched_wfs
|
||||
|
||||
# Helper: move cursor to element (async)
|
||||
async def _move_to_element():
|
||||
# Helper: move cursor to element (async). Accepts optional ``call_cfg`` so
|
||||
# per-call ``human_config`` overrides on type/fill carry through to mouse
|
||||
# timing. Also scrolls into view first so off-screen elements work
|
||||
# (#129, #172 follow-up).
|
||||
async def _move_to_element(call_cfg: HumanConfig = cfg):
|
||||
if not 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])
|
||||
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
|
||||
await originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
|
||||
# Scroll into view first — best-effort.
|
||||
async def _get_box():
|
||||
return await el.bounding_box()
|
||||
try:
|
||||
_, nx, ny = await async_human_scroll_into_view(
|
||||
page, raw_mouse, _get_box,
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
box = await el.bounding_box()
|
||||
if not box:
|
||||
return None
|
||||
|
||||
is_inp = await _async_is_input_element_handle(el)
|
||||
target = click_target(box, is_inp, cfg)
|
||||
target = click_target(box, is_inp, call_cfg)
|
||||
|
||||
if cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
if call_cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
return {'box': box, 'is_inp': is_inp}
|
||||
@@ -1704,14 +1880,16 @@ def _patch_single_element_handle_async(
|
||||
|
||||
# --- el.click() ---
|
||||
async def _human_el_click(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_click(**kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
|
||||
# --- el.dblclick() ---
|
||||
async def _human_el_dblclick(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_dblclick(**kwargs)
|
||||
await raw_mouse.down(click_count=2)
|
||||
@@ -1720,33 +1898,62 @@ def _patch_single_element_handle_async(
|
||||
|
||||
# --- el.hover() ---
|
||||
async def _human_el_hover(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_hover(**kwargs)
|
||||
|
||||
# --- el.type() ---
|
||||
async def _human_el_type(text: str, **kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_type(text, **kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
cdp = await _get_cdp()
|
||||
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
|
||||
await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
|
||||
|
||||
# --- el.fill() ---
|
||||
async def _human_el_fill(value: str, **kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_fill(value, **kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
await originals.keyboard_press(_SELECT_ALL)
|
||||
await async_sleep_ms(rand(30, 80))
|
||||
await originals.keyboard_press("Backspace")
|
||||
await async_sleep_ms(rand(50, 150))
|
||||
cdp = await _get_cdp()
|
||||
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
|
||||
await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
|
||||
|
||||
# --- el.scroll_into_view_if_needed() ---
|
||||
async def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
|
||||
try:
|
||||
await originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _get_box():
|
||||
return await el.bounding_box()
|
||||
try:
|
||||
_, nx, ny = await async_human_scroll_into_view(
|
||||
page, raw_mouse, _get_box,
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
await _orig_scroll_into_view(**native_kwargs)
|
||||
|
||||
# --- el.press() ---
|
||||
async def _human_el_press(key: str, **kwargs: Any) -> None:
|
||||
@@ -1830,6 +2037,8 @@ def _patch_single_element_handle_async(
|
||||
el.set_checked = _human_el_set_checked
|
||||
el.tap = _human_el_tap
|
||||
el.focus = _human_el_focus
|
||||
if _orig_scroll_into_view is not None:
|
||||
el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed
|
||||
|
||||
|
||||
def _patch_page_element_handles_async(
|
||||
|
||||
@@ -201,6 +201,25 @@ def resolve_config(
|
||||
return HumanConfig(**merged)
|
||||
|
||||
|
||||
def merge_config(base: HumanConfig, overrides: dict | None) -> HumanConfig:
|
||||
"""Merge ``overrides`` (a dict of HumanConfig field names → values) on top of
|
||||
``base``. Returns a new HumanConfig — ``base`` is never mutated.
|
||||
|
||||
Used by per-call overrides like ``page.type(sel, text, human_config={...})``
|
||||
so the same page can use different timings for different inputs without
|
||||
re-patching.
|
||||
|
||||
Unknown keys are ignored silently to keep this forgiving for callers.
|
||||
"""
|
||||
if not overrides:
|
||||
return base
|
||||
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
|
||||
for k, v in overrides.items():
|
||||
if k in base.__dataclass_fields__:
|
||||
merged[k] = v
|
||||
return HumanConfig(**merged)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utility functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
|
||||
from .mouse import RawMouse, human_move
|
||||
@@ -18,10 +18,15 @@ def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> boo
|
||||
return top_edge >= zone_top and bottom_edge <= zone_bottom
|
||||
|
||||
|
||||
def _get_element_box(page: Any, selector: str) -> Optional[dict]:
|
||||
def _get_element_box(page: Any, selector: str, timeout: float = 30000) -> Optional[dict]:
|
||||
"""Locate ``selector`` and return its bounding box.
|
||||
|
||||
The ``timeout`` is forwarded to Playwright's ``boundingBox(timeout=...)``
|
||||
so callers can extend it for slow-loading elements (#172).
|
||||
"""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return el.bounding_box(timeout=2000)
|
||||
return el.bounding_box(timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -39,13 +44,21 @@ def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
|
||||
sleep_ms(rand(8, 20))
|
||||
|
||||
|
||||
def scroll_to_element(
|
||||
def human_scroll_into_view(
|
||||
page: Any,
|
||||
raw: RawMouse,
|
||||
selector: str,
|
||||
get_box: Callable[[], Optional[dict]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
"""Humanized scrolling that uses an arbitrary ``get_box`` callable
|
||||
instead of a CSS selector.
|
||||
|
||||
Used both by ``scroll_to_element`` (selector-based) and by
|
||||
``ElementHandle.scroll_into_view_if_needed`` / ``Locator.scroll_into_view_if_needed``
|
||||
(handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot
|
||||
behavior runs everywhere.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
raise RuntimeError("Viewport size not available")
|
||||
@@ -53,12 +66,9 @@ def scroll_to_element(
|
||||
viewport_height = viewport["height"]
|
||||
viewport_width = viewport["width"]
|
||||
|
||||
box = _get_element_box(page, selector)
|
||||
box = get_box()
|
||||
if box is None:
|
||||
sleep_ms(200)
|
||||
box = _get_element_box(page, selector)
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element not found: {selector}")
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
@@ -105,7 +115,7 @@ def scroll_to_element(
|
||||
|
||||
# Check visibility every 3 steps
|
||||
if i % 3 == 2 or i == total_clicks - 1:
|
||||
box = _get_element_box(page, selector)
|
||||
box = get_box()
|
||||
if box and _is_in_viewport(box, viewport_height, cfg):
|
||||
break
|
||||
if scrolled >= abs_distance * 1.1:
|
||||
@@ -125,8 +135,29 @@ def scroll_to_element(
|
||||
# Settle
|
||||
sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
|
||||
box = _get_element_box(page, selector)
|
||||
box = get_box()
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element lost after scrolling: {selector}")
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
|
||||
|
||||
def scroll_to_element(
|
||||
page: Any,
|
||||
raw: RawMouse,
|
||||
selector: str,
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 30000,
|
||||
) -> Tuple[dict, float, float]:
|
||||
"""Selector-based humanized scroll.
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default matches Playwright's 30000ms when not specified.
|
||||
"""
|
||||
return human_scroll_into_view(
|
||||
page, raw,
|
||||
lambda: _get_element_box(page, selector, timeout),
|
||||
cursor_x, cursor_y, cfg,
|
||||
)
|
||||
|
||||
@@ -8,17 +8,22 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, Awaitable, Callable, 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]:
|
||||
async def _get_element_box_async(
|
||||
page: Any, selector: str, timeout: float = 30000,
|
||||
) -> Optional[dict]:
|
||||
"""Async variant. ``timeout`` is forwarded to Playwright's
|
||||
``boundingBox(timeout=...)`` so callers can extend it for slow-loading
|
||||
elements (#172)."""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return await el.bounding_box(timeout=2000)
|
||||
return await el.bounding_box(timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -36,13 +41,20 @@ async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig)
|
||||
await async_sleep_ms(rand(8, 20))
|
||||
|
||||
|
||||
async def async_scroll_to_element(
|
||||
async def async_human_scroll_into_view(
|
||||
page: Any,
|
||||
raw: AsyncRawMouse,
|
||||
selector: str,
|
||||
get_box: Callable[[], Awaitable[Optional[dict]]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
"""Humanized scrolling using an arbitrary async ``get_box`` callable.
|
||||
|
||||
Used by both ``async_scroll_to_element`` (selector-based) and the
|
||||
ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all
|
||||
scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate
|
||||
\u2192 overshoot behavior.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
raise RuntimeError("Viewport size not available")
|
||||
@@ -50,12 +62,9 @@ async def async_scroll_to_element(
|
||||
viewport_height = viewport["height"]
|
||||
viewport_width = viewport["width"]
|
||||
|
||||
box = await _get_element_box_async(page, selector)
|
||||
box = await get_box()
|
||||
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}")
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
@@ -102,7 +111,7 @@ async def async_scroll_to_element(
|
||||
|
||||
# Check visibility every 3 steps
|
||||
if i % 3 == 2 or i == total_clicks - 1:
|
||||
box = await _get_element_box_async(page, selector)
|
||||
box = await get_box()
|
||||
if box and _is_in_viewport(box, viewport_height, cfg):
|
||||
break
|
||||
if scrolled >= abs_distance * 1.1:
|
||||
@@ -122,8 +131,29 @@ async def async_scroll_to_element(
|
||||
# Settle
|
||||
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
|
||||
box = await _get_element_box_async(page, selector)
|
||||
box = await get_box()
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element lost after scrolling: {selector}")
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
|
||||
|
||||
async def async_scroll_to_element(
|
||||
page: Any,
|
||||
raw: AsyncRawMouse,
|
||||
selector: str,
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 30000,
|
||||
) -> Tuple[dict, float, float]:
|
||||
"""Selector-based humanized scroll (async).
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default matches Playwright's 30000ms when not specified.
|
||||
"""
|
||||
async def _get():
|
||||
return await _get_element_box_async(page, selector, timeout)
|
||||
return await async_human_scroll_into_view(
|
||||
page, raw, _get, cursor_x, cursor_y, cfg,
|
||||
)
|
||||
|
||||
+1
-1
@@ -215,7 +215,7 @@ const page = await browser.newPage();
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 20
|
||||
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
|
||||
- One of: `playwright-core` >= 1.53 or `puppeteer-core` >= 21
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.28",
|
||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mmdb-lib": ">=2.0.0",
|
||||
"playwright-core": ">=1.40.0",
|
||||
"playwright-core": ">=1.53.0",
|
||||
"puppeteer-core": ">=21.0.0",
|
||||
"socks-proxy-agent": ">=10.0.0"
|
||||
},
|
||||
@@ -84,7 +84,7 @@
|
||||
"@types/node": "^20.10.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"playwright-core": "^1.40.0",
|
||||
"playwright-core": "^1.53.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^1.0.0"
|
||||
|
||||
+53
-10
@@ -22,6 +22,7 @@ const GEOIP_DB_URL =
|
||||
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
|
||||
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
|
||||
const GEOIP_UPDATE_INTERVAL_MS = 30 * 86_400_000; // 30 days
|
||||
const DEFAULT_GEOIP_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Country ISO code → BCP 47 locale (covers ~90% of proxy traffic). */
|
||||
export const COUNTRY_LOCALE_MAP: Record<string, string> = {
|
||||
@@ -68,10 +69,18 @@ export async function resolveProxyGeo(
|
||||
const dbPath = await ensureGeoipDb();
|
||||
if (!dbPath) return { timezone: null, locale: null, exitIp: null };
|
||||
|
||||
const timeoutMs = getGeoipTimeoutMs();
|
||||
const deadline = deadlineFromTimeout(timeoutMs);
|
||||
|
||||
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||
let ip = await resolveExitIp(proxyUrl);
|
||||
if (!ip) ip = await resolveProxyIp(proxyUrl);
|
||||
if (!ip) return { timezone: null, locale: null, exitIp: null };
|
||||
let ip = await resolveExitIp(proxyUrl, remainingMs(deadline));
|
||||
if (!ip && !deadlineExpired(deadline)) ip = await resolveProxyIp(proxyUrl);
|
||||
if (!ip || deadlineExpired(deadline)) {
|
||||
if (deadlineExpired(deadline)) {
|
||||
console.warn(`[cloakbrowser] GeoIP resolution timed out after ${timeoutMs}ms; continuing without GeoIP`);
|
||||
}
|
||||
return { timezone: null, locale: null, exitIp: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = fs.readFileSync(dbPath);
|
||||
@@ -87,6 +96,30 @@ export async function resolveProxyGeo(
|
||||
}
|
||||
}
|
||||
|
||||
function getGeoipTimeoutMs(): number {
|
||||
const raw = process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS;
|
||||
if (!raw) return DEFAULT_GEOIP_TIMEOUT_MS;
|
||||
const timeoutSeconds = Number(raw);
|
||||
if (!Number.isFinite(timeoutSeconds)) {
|
||||
console.warn(`[cloakbrowser] Invalid CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS=${raw}; using ${DEFAULT_GEOIP_TIMEOUT_MS / 1000}s`);
|
||||
return DEFAULT_GEOIP_TIMEOUT_MS;
|
||||
}
|
||||
return Math.max(timeoutSeconds, 0) * 1000;
|
||||
}
|
||||
|
||||
function deadlineFromTimeout(timeoutMs: number): number | null {
|
||||
return timeoutMs > 0 ? performance.now() + timeoutMs : null;
|
||||
}
|
||||
|
||||
function remainingMs(deadline: number | null): number | undefined {
|
||||
if (deadline === null) return undefined;
|
||||
return Math.max(deadline - performance.now(), 0);
|
||||
}
|
||||
|
||||
function deadlineExpired(deadline: number | null): boolean {
|
||||
return deadline !== null && performance.now() >= deadline;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy IP resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -128,7 +161,8 @@ const IP_ECHO_URLS = [
|
||||
"https://ifconfig.me/ip",
|
||||
];
|
||||
|
||||
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||
async function resolveExitIp(proxyUrl: string, timeoutMs?: number): Promise<string | null> {
|
||||
const deadline = timeoutMs && timeoutMs > 0 ? performance.now() + timeoutMs : null;
|
||||
const isSocks = isSocksProxy(proxyUrl);
|
||||
|
||||
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
|
||||
@@ -144,9 +178,11 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||
const agent = new SocksProxyAgent(proxyUrl);
|
||||
|
||||
for (const echoUrl of IP_ECHO_URLS) {
|
||||
const remaining = remainingMs(deadline);
|
||||
if (remaining !== undefined && remaining <= 0) return null;
|
||||
try {
|
||||
const ip = await new Promise<string | null>((resolve) => {
|
||||
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
|
||||
const req = https.request(echoUrl, { agent, timeout: Math.min(10_000, remaining ?? 10_000) }, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
res.on("end", () => {
|
||||
@@ -173,6 +209,8 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||
const proxyUrlObj = new URL(proxyUrl);
|
||||
|
||||
for (const echoUrl of IP_ECHO_URLS) {
|
||||
const remaining = remainingMs(deadline);
|
||||
if (remaining !== undefined && remaining <= 0) return null;
|
||||
try {
|
||||
const ip = await new Promise<string | null>((resolve, reject) => {
|
||||
const targetUrl = new URL(echoUrl);
|
||||
@@ -190,13 +228,14 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||
).toString("base64"),
|
||||
}
|
||||
: {},
|
||||
timeout: 10_000,
|
||||
timeout: Math.min(10_000, remaining ?? 10_000),
|
||||
});
|
||||
|
||||
connectReq.on("connect", (_res, socket) => {
|
||||
const innerRemaining = remainingMs(deadline);
|
||||
const req = https.request(
|
||||
echoUrl,
|
||||
{ socket, timeout: 5_000 } as any,
|
||||
{ socket, timeout: Math.min(5_000, innerRemaining ?? 5_000) } as any,
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
@@ -207,6 +246,7 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||
}
|
||||
);
|
||||
req.on("error", () => resolve(null));
|
||||
req.on("timeout", () => { req.destroy(); resolve(null); });
|
||||
req.end();
|
||||
});
|
||||
|
||||
@@ -261,7 +301,9 @@ async function downloadGeoipDb(dest: string): Promise<void> {
|
||||
|
||||
const tmpPath = `${dest}.tmp.${Date.now()}`;
|
||||
try {
|
||||
const response = await fetch(GEOIP_DB_URL, { redirect: "follow" });
|
||||
const response = await fetch(GEOIP_DB_URL, {
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
@@ -329,7 +371,8 @@ export async function maybeResolveGeoip(
|
||||
|
||||
// When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||
if (options.timezone && options.locale) {
|
||||
const exitIp = await resolveExitIp(proxyUrl) ?? undefined;
|
||||
const timeoutMs = getGeoipTimeoutMs();
|
||||
const exitIp = await resolveExitIp(proxyUrl, timeoutMs) ?? undefined;
|
||||
return { timezone: options.timezone, locale: options.locale, exitIp };
|
||||
}
|
||||
|
||||
@@ -363,7 +406,7 @@ export async function resolveWebrtcArgs(
|
||||
}
|
||||
|
||||
try {
|
||||
const ip = await resolveExitIp(proxyUrl);
|
||||
const ip = await resolveExitIp(proxyUrl, getGeoipTimeoutMs());
|
||||
const result = [...args];
|
||||
if (ip) {
|
||||
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
|
||||
|
||||
+141
-55
@@ -47,17 +47,17 @@
|
||||
*/
|
||||
|
||||
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
|
||||
import type { HumanConfig } from '../human/config.js';
|
||||
import { resolveConfig, rand, randRange, sleep } from '../human/config.js';
|
||||
import type { HumanConfig, HumanActionOptions } from '../human/config.js';
|
||||
import { resolveConfig, mergeConfig, rand, randRange, sleep } from '../human/config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement, smoothWheel } from './scroll.js';
|
||||
import { scrollToElement, humanScrollIntoView, smoothWheel } from './scroll.js';
|
||||
|
||||
export type { HumanConfig } from '../human/config.js';
|
||||
export { resolveConfig } from '../human/config.js';
|
||||
export { resolveConfig, mergeConfig } from '../human/config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
|
||||
export { humanType } from './keyboard.js';
|
||||
export { scrollToElement } from './scroll.js';
|
||||
export { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
@@ -319,7 +319,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
}
|
||||
|
||||
// ==== goto ====
|
||||
const humanGoto = async (url: string, options?: any) => {
|
||||
const humanGoto = async (url: string, options?: {
|
||||
referer?: string;
|
||||
timeout?: number;
|
||||
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
|
||||
}) => {
|
||||
const response = await originals.goto(url, options);
|
||||
stealth.invalidate();
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
@@ -327,54 +331,64 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// ==== click (with clickCount support for dblclick) ====
|
||||
const humanClickFn = async (selector: string, options?: any) => {
|
||||
const humanClickFn = async (selector: string, options?: HumanActionOptions & {
|
||||
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
|
||||
clickCount?: number;
|
||||
count?: number;
|
||||
delay?: number;
|
||||
}) => {
|
||||
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 callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
|
||||
const clickCount = options?.clickCount ?? options?.count ?? 1;
|
||||
if (clickCount >= 2) {
|
||||
await humanClick(raw, isInput, cfg);
|
||||
await humanClick(raw, isInput, callCfg);
|
||||
await sleep(rand(40, 90));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
} else {
|
||||
await humanClick(raw, isInput, cfg);
|
||||
await humanClick(raw, isInput, callCfg);
|
||||
}
|
||||
};
|
||||
|
||||
// ==== hover ====
|
||||
const humanHoverFn = async (selector: string, options?: any) => {
|
||||
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
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 callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const target = clickTarget(box, false, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, false, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
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);
|
||||
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions & {
|
||||
delay?: number;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
};
|
||||
|
||||
// ==== select ====
|
||||
@@ -392,7 +406,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// ==== tap ====
|
||||
const humanTapFn = async (selector: string, options?: any) => {
|
||||
const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
await humanClickFn(selector, options);
|
||||
};
|
||||
|
||||
@@ -410,14 +424,19 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// ============================================================
|
||||
// Mouse patches
|
||||
// ============================================================
|
||||
page.mouse.move = async (x: number, y: number, options?: any) => {
|
||||
page.mouse.move = async (x: number, y: number, options?: { steps?: number }) => {
|
||||
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) => {
|
||||
page.mouse.click = async (x: number, y: number, options?: {
|
||||
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
|
||||
clickCount?: number;
|
||||
count?: number;
|
||||
delay?: number;
|
||||
}) => {
|
||||
await ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||
cursor.x = x;
|
||||
@@ -452,7 +471,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
(page.mouse as any).dragAndDrop = async (
|
||||
start: { x: number; y: number },
|
||||
target: { x: number; y: number },
|
||||
options?: any,
|
||||
options?: { delay?: number },
|
||||
) => {
|
||||
await ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg);
|
||||
@@ -472,12 +491,12 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// ============================================================
|
||||
// Keyboard patches
|
||||
// ============================================================
|
||||
page.keyboard.type = async (text: string, options?: any) => {
|
||||
page.keyboard.type = async (text: string, options?: { delay?: number }) => {
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
page.keyboard.press = async (key: any, options?: any) => {
|
||||
page.keyboard.press = async (key: any, options?: { delay?: number }) => {
|
||||
await sleep(rand(20, 60));
|
||||
await originals.keyboardDown(key as any);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
@@ -548,7 +567,11 @@ function patchElementHandle(
|
||||
return els;
|
||||
};
|
||||
|
||||
(page as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
(page as any).waitForSelector = async (selector: string, options?: {
|
||||
hidden?: boolean;
|
||||
timeout?: number;
|
||||
visible?: boolean;
|
||||
}) => {
|
||||
const el = await origWaitForSelector(selector, options);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
@@ -577,6 +600,9 @@ function patchSingleElementHandle(
|
||||
const origElDragAndDrop = (el as any).dragAndDrop?.bind(el);
|
||||
const origElSelect = (el as any).select?.bind(el);
|
||||
const origElDrop = (el as any).drop?.bind(el);
|
||||
// Puppeteer v22+ adds ElementHandle.scrollIntoView(); earlier versions
|
||||
// expose it implicitly via evaluate(node => node.scrollIntoView()).
|
||||
const origElScrollIntoView = (el as any).scrollIntoView?.bind(el);
|
||||
|
||||
// --- Nested selectors ---
|
||||
const origEl$ = el.$.bind(el);
|
||||
@@ -597,45 +623,69 @@ function patchSingleElementHandle(
|
||||
return children;
|
||||
};
|
||||
|
||||
(el as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
(el as any).waitForSelector = async (selector: string, options?: {
|
||||
hidden?: boolean;
|
||||
timeout?: number;
|
||||
visible?: boolean;
|
||||
}) => {
|
||||
const child = await origElWaitForSelector(selector, options);
|
||||
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return child;
|
||||
};
|
||||
|
||||
// --- Helper: get box and move cursor ---
|
||||
const moveToElement = async () => {
|
||||
// --- Helper: get box and move cursor. Accepts a per-call ``callCfg``
|
||||
// so type/fill overrides like ``el.type(text, { typing_delay: 30 })``
|
||||
// carry through to mouse timing for that single call. Also scrolls into
|
||||
// view first so off-screen elements work (#129, #172 follow-up).
|
||||
const moveToElement = async (callCfg: HumanConfig = cfg) => {
|
||||
await (page as any)._ensureCursorInit();
|
||||
|
||||
try {
|
||||
const { cursorX, cursorY } = await humanScrollIntoView(
|
||||
page, raw,
|
||||
() => el.boundingBox().then(b => b ?? null),
|
||||
cursor.x, cursor.y, callCfg,
|
||||
);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
} catch { /* let boundingBox() decide */ }
|
||||
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
const isInp = await isInputElementHandle(stealth, el);
|
||||
const target = clickTarget(box, isInp, cfg);
|
||||
const target = clickTarget(box, isInp, callCfg);
|
||||
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
return { box, isInp };
|
||||
};
|
||||
|
||||
// --- el.click() ---
|
||||
(el as any).click = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
(el as any).click = async (options?: HumanActionOptions & {
|
||||
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
|
||||
clickCount?: number;
|
||||
count?: number;
|
||||
delay?: number;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElClick(options);
|
||||
|
||||
const clickCount = options?.clickCount ?? options?.count ?? 1;
|
||||
if (clickCount >= 2) {
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(40, 90));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
} else {
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -646,18 +696,43 @@ function patchSingleElementHandle(
|
||||
};
|
||||
|
||||
// --- el.type() ---
|
||||
(el as any).type = async (text: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
(el as any).type = async (text: string, options?: HumanActionOptions & { delay?: number }) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElType(text, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await stealth.getCdpSession().catch(() => null);
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
};
|
||||
|
||||
// --- el.scrollIntoView() ---
|
||||
// Puppeteer-only equivalent of Playwright's scrollIntoViewIfNeeded.
|
||||
// Replaces the native snap-scroll (a strong bot signal) with the same
|
||||
// accelerate → cruise → decelerate → overshoot wheel sequence used by
|
||||
// page.click(). Only patched when the underlying ElementHandle exposes
|
||||
// ``scrollIntoView`` (Puppeteer v22+).
|
||||
if (origElScrollIntoView) {
|
||||
(el as any).scrollIntoView = async (options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
await (page as any)._ensureCursorInit();
|
||||
try {
|
||||
const { cursorX, cursorY } = await humanScrollIntoView(
|
||||
page, raw,
|
||||
() => el.boundingBox().then(b => b ?? null),
|
||||
cursor.x, cursor.y, callCfg,
|
||||
);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
} catch {
|
||||
return origElScrollIntoView(options);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- el.press() ---
|
||||
if (origElPress) {
|
||||
(el as any).press = async (key: string, options?: any) => {
|
||||
(el as any).press = async (key: string, options?: { delay?: number }) => {
|
||||
await sleep(rand(20, 60));
|
||||
await originals.keyboardDown(key as any);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
@@ -696,7 +771,7 @@ function patchSingleElementHandle(
|
||||
|
||||
// --- el.drop() ---
|
||||
if (origElDrop) {
|
||||
(el as any).drop = async (draggable: ElementHandle, options?: any) => {
|
||||
(el as any).drop = async (draggable: ElementHandle, options?: { delay?: number }) => {
|
||||
const srcBox = await draggable.boundingBox();
|
||||
const tgtBox = await el.boundingBox();
|
||||
|
||||
@@ -726,7 +801,7 @@ function patchSingleElementHandle(
|
||||
|
||||
// --- el.dragAndDrop() ---
|
||||
if (origElDragAndDrop) {
|
||||
(el as any).dragAndDrop = async (targetEl: ElementHandle, options?: any) => {
|
||||
(el as any).dragAndDrop = async (targetEl: ElementHandle, options?: { delay?: number }) => {
|
||||
const srcBox = await el.boundingBox();
|
||||
const tgtBox = await targetEl.boundingBox();
|
||||
|
||||
@@ -790,15 +865,22 @@ function patchSingleFrame(
|
||||
|
||||
const origFrameSelect = frame.select.bind(frame);
|
||||
|
||||
(frame as any).click = async (selector: string, options?: any) => {
|
||||
(frame as any).click = async (selector: string, options?: HumanActionOptions & {
|
||||
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
|
||||
clickCount?: number;
|
||||
count?: number;
|
||||
delay?: number;
|
||||
}) => {
|
||||
await (page as any).click(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).hover = async (selector: string, options?: any) => {
|
||||
(frame as any).hover = async (selector: string, options?: HumanActionOptions) => {
|
||||
await (page as any).hover(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).type = async (selector: string, text: string, options?: any) => {
|
||||
(frame as any).type = async (selector: string, text: string, options?: HumanActionOptions & {
|
||||
delay?: number;
|
||||
}) => {
|
||||
await (page as any).type(selector, text, options);
|
||||
};
|
||||
|
||||
@@ -812,7 +894,7 @@ function patchSingleFrame(
|
||||
await (page as any).focus(selector);
|
||||
};
|
||||
|
||||
(frame as any).tap = async (selector: string, options?: any) => {
|
||||
(frame as any).tap = async (selector: string, options?: HumanActionOptions) => {
|
||||
await (page as any).click(selector, options);
|
||||
};
|
||||
|
||||
@@ -835,7 +917,11 @@ function patchSingleFrame(
|
||||
return els;
|
||||
};
|
||||
|
||||
(frame as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
(frame as any).waitForSelector = async (selector: string, options?: {
|
||||
hidden?: boolean;
|
||||
timeout?: number;
|
||||
visible?: boolean;
|
||||
}) => {
|
||||
const el = await origFrameWaitForSelector(selector, options);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
@@ -881,7 +967,7 @@ export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
|
||||
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
|
||||
if (typeof (browser as any)[methodName] === 'function') {
|
||||
const origCreateContext = (browser as any)[methodName].bind(browser);
|
||||
(browser as any)[methodName] = async (options?: any) => {
|
||||
(browser as any)[methodName] = async (options?: Parameters<typeof origCreateContext>[0]) => {
|
||||
const context: BrowserContext = await origCreateContext(options);
|
||||
|
||||
const origCtxNewPage = context.newPage.bind(context);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Changes from Playwright version:
|
||||
* - page.viewport() instead of page.viewportSize()
|
||||
* - page.$(selector) + el.boundingBox() instead of page.locator().boundingBox()
|
||||
* - No timeout parameter on boundingBox()
|
||||
* - boundingBox() has no timeout param — we poll page.$() up to ``timeout`` ms
|
||||
*/
|
||||
|
||||
import type { Page } from 'puppeteer-core';
|
||||
@@ -55,22 +55,40 @@ export async function smoothWheel(
|
||||
}
|
||||
}
|
||||
|
||||
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
|
||||
try {
|
||||
const el = await page.$(selector);
|
||||
if (!el) return null;
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
} catch {
|
||||
return null;
|
||||
/**
|
||||
* Poll ``page.$(selector)`` for up to ``timeout`` ms, returning the element's
|
||||
* bounding box when found. ``timeout`` defaults to 30000ms when not specified.
|
||||
*/
|
||||
async function getElementBox(
|
||||
page: Page,
|
||||
selector: string,
|
||||
timeout: number = 30000,
|
||||
): Promise<ElementBounds | null> {
|
||||
const start = Date.now();
|
||||
const pollInterval = 100;
|
||||
while (true) {
|
||||
try {
|
||||
const el = await page.$(selector);
|
||||
if (el) {
|
||||
const box = await el.boundingBox();
|
||||
if (box) return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
|
||||
if (Date.now() - start >= timeout) return null;
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollToElement(
|
||||
/**
|
||||
* Humanized scrolling that takes an arbitrary ``getBox`` callable.
|
||||
* Used by both ``scrollToElement`` (selector-based) and the ElementHandle
|
||||
* ``scrollIntoView`` patch.
|
||||
*/
|
||||
export async function humanScrollIntoView(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
getBox: () => Promise<ElementBounds | null>,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
@@ -78,12 +96,8 @@ export async function scrollToElement(
|
||||
const viewport = page.viewport();
|
||||
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}`);
|
||||
}
|
||||
let box = await getBox();
|
||||
if (!box) throw new Error('Element not found while scrolling into view');
|
||||
|
||||
if (isInViewport(box, viewport.height, cfg)) {
|
||||
return { box, cursorX, cursorY };
|
||||
@@ -134,7 +148,7 @@ export async function scrollToElement(
|
||||
await sleep(pause);
|
||||
|
||||
if (i % 3 === 2 || i === totalClicks - 1) {
|
||||
box = await getElementBox(page, selector);
|
||||
box = await getBox();
|
||||
if (box && isInViewport(box, viewport.height, cfg)) {
|
||||
break;
|
||||
}
|
||||
@@ -159,8 +173,31 @@ export async function scrollToElement(
|
||||
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
|
||||
box = await getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
|
||||
box = await getBox();
|
||||
if (!box) throw new Error('Element lost after scrolling into view');
|
||||
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector-based humanized scroll (Puppeteer).
|
||||
*
|
||||
* ``timeout`` controls how long we poll ``page.$(selector)`` before giving up,
|
||||
* so callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
|
||||
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
|
||||
*/
|
||||
export async function scrollToElement(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
timeout?: number,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
return humanScrollIntoView(
|
||||
page, raw,
|
||||
() => getElementBox(page, selector, timeout),
|
||||
cursorX, cursorY, cfg,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ export interface HumanConfig {
|
||||
|
||||
export type HumanPreset = 'default' | 'careful';
|
||||
|
||||
export type HumanActionOptions = Partial<HumanConfig> & {
|
||||
timeout?: number;
|
||||
human_config?: Partial<HumanConfig>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default preset
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -201,6 +206,22 @@ export function resolveConfig(
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a partial overrides object on top of an existing HumanConfig.
|
||||
* Returns a new object — the original ``cfg`` is never mutated.
|
||||
*
|
||||
* Used by per-call overrides such as ``page.type(sel, text, { human_config: { typing_delay: 30 } })``
|
||||
* so the same patched page can type different fields at different speeds
|
||||
* without re-patching.
|
||||
*/
|
||||
export function mergeConfig(
|
||||
cfg: HumanConfig,
|
||||
overrides?: Partial<HumanConfig> | null,
|
||||
): HumanConfig {
|
||||
if (!overrides) return cfg;
|
||||
return { ...cfg, ...overrides };
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: random number in range
|
||||
|
||||
+155
-34
@@ -17,10 +17,11 @@
|
||||
*/
|
||||
|
||||
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
|
||||
import type { HumanConfig } from './config.js';
|
||||
import { rand, randRange, sleep } from './config.js';
|
||||
import type { HumanConfig, HumanActionOptions } from './config.js';
|
||||
import { rand, randRange, sleep, mergeConfig } from './config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { humanScrollIntoView } from './scroll.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
@@ -102,6 +103,7 @@ export function patchSingleElementHandle(
|
||||
const origElSetChecked = (el as any).setChecked?.bind(el);
|
||||
const origElTap = el.tap.bind(el);
|
||||
const origElFocus = el.focus.bind(el);
|
||||
const origElScrollIntoViewIfNeeded = (el as any).scrollIntoViewIfNeeded?.bind(el);
|
||||
|
||||
// Nested selectors
|
||||
const origEl$ = el.$.bind(el);
|
||||
@@ -123,44 +125,88 @@ export function patchSingleElementHandle(
|
||||
return children;
|
||||
};
|
||||
|
||||
(el as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const child = await origElWaitForSelector(selector, options);
|
||||
(el as any).waitForSelector = async (selector: string, options?: {
|
||||
state?: 'attached' | 'detached' | 'visible' | 'hidden';
|
||||
strict?: boolean;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
const child = await origElWaitForSelector(selector, options ?? {});
|
||||
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return child;
|
||||
};
|
||||
|
||||
// --- Helper: get bounding box and move cursor to element ---
|
||||
const moveToElement = async () => {
|
||||
// Accepts a per-call ``callCfg`` so type/fill overrides like
|
||||
// ``el.type(text, { human_config: { typing_delay: 30 } })`` or
|
||||
// ``el.type(text, { typing_delay: 30 })`` carry through to mouse movement
|
||||
// & idle timing for that single call.
|
||||
// Also scrolls the element into view first so off-screen elements work
|
||||
// (#129, #172 follow-up): otherwise boundingBox() returns null and we'd
|
||||
// silently fall back to the unpatched native method.
|
||||
const moveToElement = async (callCfg: HumanConfig = cfg) => {
|
||||
// Ensure cursor is initialized
|
||||
const ensureCursorInit = (page as any)._ensureCursorInit;
|
||||
if (ensureCursorInit) await ensureCursorInit();
|
||||
|
||||
// Scroll into view first so boundingBox() returns coordinates even when
|
||||
// the element starts below the fold. Best-effort — if humanScrollIntoView
|
||||
// throws (e.g. detached element), we let boundingBox() decide whether to
|
||||
// proceed or fall back to the original method.
|
||||
try {
|
||||
const { cursorX, cursorY } = await humanScrollIntoView(
|
||||
page, raw,
|
||||
() => el.boundingBox(),
|
||||
cursor.x, cursor.y, callCfg,
|
||||
);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
} catch { /* let boundingBox() decide */ }
|
||||
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
const isInp = await isInputElementHandle(stealth, el);
|
||||
const target = clickTarget(box, isInp, cfg);
|
||||
const target = clickTarget(box, isInp, callCfg);
|
||||
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
return { box, isInp };
|
||||
};
|
||||
|
||||
// --- el.click() ---
|
||||
(el as any).click = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
(el as any).click = async (options?: HumanActionOptions & {
|
||||
button?: 'left' | 'right' | 'middle';
|
||||
clickCount?: number;
|
||||
delay?: number;
|
||||
force?: boolean;
|
||||
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
|
||||
noWaitAfter?: boolean;
|
||||
position?: { x: number; y: number };
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElClick(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
};
|
||||
|
||||
// --- el.dblclick() ---
|
||||
(el as any).dblclick = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
(el as any).dblclick = async (options?: HumanActionOptions & {
|
||||
button?: 'left' | 'right' | 'middle';
|
||||
delay?: number;
|
||||
force?: boolean;
|
||||
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
|
||||
noWaitAfter?: boolean;
|
||||
position?: { x: number; y: number };
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElDblclick(options);
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
@@ -168,28 +214,42 @@ export function patchSingleElementHandle(
|
||||
};
|
||||
|
||||
// --- el.hover() ---
|
||||
(el as any).hover = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
(el as any).hover = async (options?: HumanActionOptions & {
|
||||
force?: boolean;
|
||||
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
|
||||
position?: { x: number; y: number };
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElHover(options);
|
||||
// Just move — no click
|
||||
};
|
||||
|
||||
// --- el.type() ---
|
||||
(el as any).type = async (text: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
(el as any).type = async (text: string, options?: HumanActionOptions & {
|
||||
delay?: number;
|
||||
noWaitAfter?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElType(text, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||
await humanType(page, rawKb, text, cfg, cdpSession);
|
||||
await humanType(page, rawKb, text, callCfg, cdpSession);
|
||||
};
|
||||
|
||||
// --- el.fill() ---
|
||||
(el as any).fill = async (value: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
(el as any).fill = async (value: string, options?: HumanActionOptions & {
|
||||
force?: boolean;
|
||||
noWaitAfter?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElFill(value, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
// Clear existing content
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
@@ -198,11 +258,11 @@ export function patchSingleElementHandle(
|
||||
await sleep(rand(50, 150));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||
await humanType(page, rawKb, value, cfg, cdpSession);
|
||||
await humanType(page, rawKb, value, callCfg, cdpSession);
|
||||
};
|
||||
|
||||
// --- el.press() ---
|
||||
(el as any).press = async (key: string, options?: any) => {
|
||||
(el as any).press = async (key: string, options?: { delay?: number; noWaitAfter?: boolean; timeout?: number }) => {
|
||||
await sleep(rand(20, 60));
|
||||
await originals.keyboardDown(key);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
@@ -210,7 +270,11 @@ export function patchSingleElementHandle(
|
||||
};
|
||||
|
||||
// --- el.selectOption() ---
|
||||
(el as any).selectOption = async (values: any, options?: any) => {
|
||||
(el as any).selectOption = async (values: any, options?: {
|
||||
force?: boolean;
|
||||
noWaitAfter?: boolean;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSelectOption(values, options);
|
||||
await humanClick(raw, false, cfg);
|
||||
@@ -219,7 +283,13 @@ export function patchSingleElementHandle(
|
||||
};
|
||||
|
||||
// --- el.check() ---
|
||||
(el as any).check = async (options?: any) => {
|
||||
(el as any).check = async (options?: {
|
||||
force?: boolean;
|
||||
noWaitAfter?: boolean;
|
||||
position?: { x: number; y: number };
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (checked) return; // Already checked
|
||||
@@ -230,7 +300,13 @@ export function patchSingleElementHandle(
|
||||
};
|
||||
|
||||
// --- el.uncheck() ---
|
||||
(el as any).uncheck = async (options?: any) => {
|
||||
(el as any).uncheck = async (options?: {
|
||||
force?: boolean;
|
||||
noWaitAfter?: boolean;
|
||||
position?: { x: number; y: number };
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (!checked) return; // Already unchecked
|
||||
@@ -242,7 +318,13 @@ export function patchSingleElementHandle(
|
||||
|
||||
// --- el.setChecked() ---
|
||||
if (origElSetChecked) {
|
||||
(el as any).setChecked = async (checked: boolean, options?: any) => {
|
||||
(el as any).setChecked = async (checked: boolean, options?: {
|
||||
force?: boolean;
|
||||
noWaitAfter?: boolean;
|
||||
position?: { x: number; y: number };
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const current = await el.isChecked();
|
||||
if (current === checked) return;
|
||||
@@ -254,7 +336,14 @@ export function patchSingleElementHandle(
|
||||
}
|
||||
|
||||
// --- el.tap() ---
|
||||
(el as any).tap = async (options?: any) => {
|
||||
(el as any).tap = async (options?: {
|
||||
force?: boolean;
|
||||
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
|
||||
noWaitAfter?: boolean;
|
||||
position?: { x: number; y: number };
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElTap(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
@@ -268,6 +357,30 @@ export function patchSingleElementHandle(
|
||||
await moveToElement(); // human-like Bézier cursor movement
|
||||
await origElFocus(); // programmatic focus, no click
|
||||
};
|
||||
|
||||
// --- el.scrollIntoViewIfNeeded() ---
|
||||
// Playwright's native version snaps the page — a strong bot signal.
|
||||
// Replace with the same accelerate → cruise → decelerate → overshoot
|
||||
// wheel sequence used by page.click() etc. Falls back to the native
|
||||
// method if the element is detached or scrolling fails.
|
||||
if (origElScrollIntoViewIfNeeded) {
|
||||
(el as any).scrollIntoViewIfNeeded = async (options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const ensureCursorInit = (page as any)._ensureCursorInit;
|
||||
if (ensureCursorInit) await ensureCursorInit();
|
||||
try {
|
||||
const { cursorX, cursorY } = await humanScrollIntoView(
|
||||
page, raw,
|
||||
() => el.boundingBox(),
|
||||
cursor.x, cursor.y, callCfg,
|
||||
);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
} catch {
|
||||
return origElScrollIntoViewIfNeeded(options);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -309,8 +422,12 @@ export function patchPageElementHandles(
|
||||
// Patch page.waitForSelector()
|
||||
if (typeof page.waitForSelector === 'function') {
|
||||
const origWaitForSelector = page.waitForSelector.bind(page);
|
||||
(page as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const el = await origWaitForSelector(selector, options);
|
||||
(page as any).waitForSelector = async (selector: string, options?: {
|
||||
state?: 'attached' | 'detached' | 'visible' | 'hidden';
|
||||
strict?: boolean;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
const el = await origWaitForSelector(selector, options ?? {});
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
@@ -357,8 +474,12 @@ export function patchFrameElementHandles(
|
||||
// Patch frame.waitForSelector()
|
||||
if (typeof frame.waitForSelector === 'function') {
|
||||
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
|
||||
(frame as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const el = await origFrameWaitForSelector(selector, options);
|
||||
(frame as any).waitForSelector = async (selector: string, options?: {
|
||||
state?: 'attached' | 'detached' | 'visible' | 'hidden';
|
||||
strict?: boolean;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
const el = await origFrameWaitForSelector(selector, options ?? {});
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
|
||||
+206
-84
@@ -23,16 +23,16 @@
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
|
||||
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
|
||||
import { HumanConfig, HumanActionOptions, resolveConfig, mergeConfig, 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';
|
||||
import { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
|
||||
|
||||
export { HumanConfig, resolveConfig } from './config.js';
|
||||
export { HumanConfig, resolveConfig, mergeConfig } from './config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
export { humanType } from './keyboard.js';
|
||||
export { scrollToElement } from './scroll.js';
|
||||
export { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||
export { patchSingleElementHandle } from './elementhandle.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
|
||||
@@ -295,7 +295,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
}
|
||||
|
||||
// --- goto (invalidate isolated world on navigation) ---
|
||||
const humanGoto = async (url: string, options?: any) => {
|
||||
const humanGoto = async (url: string, options?: {
|
||||
referer?: string;
|
||||
timeout?: number;
|
||||
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
|
||||
}) => {
|
||||
const response = await originals.goto(url, options);
|
||||
stealth.invalidate();
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
@@ -303,34 +307,37 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// --- click ---
|
||||
const humanClickFn = async (selector: string, options?: any) => {
|
||||
const humanClickFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
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 callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
await humanClick(raw, isInput, cfg);
|
||||
await humanClick(raw, isInput, callCfg);
|
||||
};
|
||||
|
||||
// --- dblclick ---
|
||||
const humanDblclickFn = async (selector: string, options?: any) => {
|
||||
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
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 callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
await raw.down({ clickCount: 2 });
|
||||
@@ -339,46 +346,49 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// --- hover ---
|
||||
const humanHoverFn = async (selector: string, options?: any) => {
|
||||
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
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 callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const target = clickTarget(box, false, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, false, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
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);
|
||||
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
};
|
||||
|
||||
// --- fill (clears existing content first) ---
|
||||
const humanFillFn = async (selector: string, value: string, options?: any) => {
|
||||
await sleep(randRange(cfg.field_switch_delay));
|
||||
await humanClickFn(selector);
|
||||
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await sleep(rand(100, 250));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
await sleep(rand(50, 150));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, value, cfg, cdp);
|
||||
await humanType(page, rawKb, value, callCfg, cdp);
|
||||
};
|
||||
|
||||
// --- clear ---
|
||||
const humanClearFn = async (selector: string, options?: any) => {
|
||||
const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
await humanClickFn(selector, options);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
@@ -387,55 +397,58 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// --- 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 humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const checked = await originals.isChecked(selector).catch(() => false);
|
||||
if (!checked) {
|
||||
await humanClickFn(selector);
|
||||
await humanClickFn(selector, options);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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 humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const checked = await originals.isChecked(selector).catch(() => true);
|
||||
if (checked) {
|
||||
await humanClickFn(selector);
|
||||
await humanClickFn(selector, options);
|
||||
}
|
||||
};
|
||||
|
||||
// --- selectOption ---
|
||||
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => {
|
||||
await humanHoverFn(selector);
|
||||
const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
|
||||
await humanHoverFn(selector, options);
|
||||
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) => {
|
||||
const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => {
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
await humanClickFn(selector, options);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(key);
|
||||
};
|
||||
|
||||
// --- pressSequentially ---
|
||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
|
||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
await humanClickFn(selector, options);
|
||||
}
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
};
|
||||
|
||||
// --- tap ---
|
||||
const humanTapFn = async (selector: string, options?: any) => {
|
||||
const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
await humanClickFn(selector, options);
|
||||
};
|
||||
|
||||
@@ -455,14 +468,20 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
(page as any).clear = humanClearFn;
|
||||
|
||||
// --- mouse patches ---
|
||||
page.mouse.move = async (x: number, y: number, options?: any) => {
|
||||
page.mouse.move = async (x: number, y: number, options?: {
|
||||
steps?: number;
|
||||
}) => {
|
||||
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) => {
|
||||
page.mouse.click = async (x: number, y: number, options?: {
|
||||
button?: 'left' | 'right' | 'middle';
|
||||
clickCount?: number;
|
||||
delay?: number;
|
||||
}) => {
|
||||
await ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||
cursor.x = x;
|
||||
@@ -471,7 +490,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// --- keyboard patches ---
|
||||
page.keyboard.type = async (text: string, options?: any) => {
|
||||
page.keyboard.type = async (text: string, options?: { delay?: number }) => {
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
@@ -523,16 +542,37 @@ function patchFrames(
|
||||
stealth: StealthEval,
|
||||
): void {
|
||||
for (const frame of iterFrames(page)) {
|
||||
patchSingleFrame(frame, page, cfg, originals, stealth);
|
||||
patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
|
||||
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
}
|
||||
|
||||
function firstFrameLocator(frame: Frame, selector: string): any {
|
||||
const locator = frame.locator(selector) as any;
|
||||
return typeof locator.first === 'function' ? locator.first() : locator;
|
||||
}
|
||||
|
||||
async function isFrameInputElement(frame: Frame, selector: string): Promise<boolean> {
|
||||
return firstFrameLocator(frame, selector).evaluate((el: Element) => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
async function isFrameSelectorFocused(frame: Frame, selector: string): Promise<boolean> {
|
||||
return firstFrameLocator(frame, selector).evaluate((el: Element) => el === document.activeElement)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
function patchSingleFrame(
|
||||
frame: Frame,
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: StealthEval,
|
||||
): void {
|
||||
@@ -540,58 +580,132 @@ function patchSingleFrame(
|
||||
(frame as any)._humanPatched = true;
|
||||
|
||||
// Save originals for methods that need fallback
|
||||
const origFrameClick = frame.click.bind(frame);
|
||||
const origFrameDblclick = frame.dblclick.bind(frame);
|
||||
const origFrameHover = frame.hover.bind(frame);
|
||||
const origFrameType = frame.type.bind(frame);
|
||||
const origFrameFill = frame.fill.bind(frame);
|
||||
const origFrameCheck = frame.check.bind(frame);
|
||||
const origFrameUncheck = frame.uncheck.bind(frame);
|
||||
const origFrameSelectOption = frame.selectOption.bind(frame);
|
||||
const origFramePress = frame.press.bind(frame);
|
||||
const origFramePressSequentially = (frame as any).pressSequentially?.bind(frame);
|
||||
const origFrameTap = (frame as any).tap?.bind(frame);
|
||||
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
|
||||
|
||||
(frame as any).click = async (selector: string, options?: any) => {
|
||||
await (page as any).click(selector, options);
|
||||
const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
|
||||
const locator = firstFrameLocator(frame, selector);
|
||||
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
|
||||
await locator.scrollIntoViewIfNeeded({ timeout: options?.timeout }).catch(() => undefined);
|
||||
}
|
||||
const box = await locator.boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
||||
if (!box) return null;
|
||||
|
||||
const isInput = inputBias || await isFrameInputElement(frame, selector);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
return { callCfg, isInput };
|
||||
};
|
||||
|
||||
(frame as any).dblclick = async (selector: string, options?: any) => {
|
||||
await (page as any).dblclick(selector, options);
|
||||
const frameClick = async (selector: string, options?: HumanActionOptions) => {
|
||||
const moved = await moveToFrameSelector(selector, options);
|
||||
if (!moved) return origFrameClick(selector, options);
|
||||
await humanClick(raw, moved.isInput, moved.callCfg);
|
||||
};
|
||||
|
||||
(frame as any).hover = async (selector: string, options?: any) => {
|
||||
await (page as any).hover(selector, options);
|
||||
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
|
||||
|
||||
const frameHover = async (selector: string, options?: HumanActionOptions) => {
|
||||
const moved = await moveToFrameSelector(selector, options, false);
|
||||
if (!moved) return origFrameHover(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).type = async (selector: string, text: string, options?: any) => {
|
||||
await (page as any).type(selector, text, options);
|
||||
(frame as any).click = frameClick;
|
||||
|
||||
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
|
||||
const moved = await moveToFrameSelector(selector, options);
|
||||
if (!moved) return origFrameDblclick(selector, options);
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
};
|
||||
|
||||
(frame as any).fill = async (selector: string, value: string, options?: any) => {
|
||||
await (page as any).fill(selector, value, options);
|
||||
(frame as any).hover = frameHover;
|
||||
|
||||
(frame as any).type = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await frameClick(selector, options);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await getFrameCdp();
|
||||
await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFrameType(selector, text, options));
|
||||
};
|
||||
|
||||
(frame as any).check = async (selector: string, options?: any) => {
|
||||
await (page as any).check(selector, options);
|
||||
(frame as any).fill = async (selector: string, value: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await frameClick(selector, options);
|
||||
await sleep(rand(100, 250));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
await sleep(rand(50, 150));
|
||||
const cdp = await getFrameCdp();
|
||||
await humanType(page, rawKb, value, callCfg, cdp).catch(() => origFrameFill(selector, value, options));
|
||||
};
|
||||
|
||||
(frame as any).uncheck = async (selector: string, options?: any) => {
|
||||
await (page as any).uncheck(selector, options);
|
||||
(frame as any).check = async (selector: string, options?: HumanActionOptions) => {
|
||||
const locator = firstFrameLocator(frame, selector);
|
||||
if (typeof locator.isChecked !== 'function') return origFrameCheck(selector, options);
|
||||
const checked = await locator.isChecked();
|
||||
if (!checked) await frameClick(selector, options).catch(() => origFrameCheck(selector, options));
|
||||
};
|
||||
|
||||
(frame as any).selectOption = async (selector: string, values: any, options?: any) => {
|
||||
await (page as any).hover(selector);
|
||||
(frame as any).uncheck = async (selector: string, options?: HumanActionOptions) => {
|
||||
const locator = firstFrameLocator(frame, selector);
|
||||
if (typeof locator.isChecked !== 'function') return origFrameUncheck(selector, options);
|
||||
const checked = await locator.isChecked();
|
||||
if (checked) await frameClick(selector, options).catch(() => origFrameUncheck(selector, options));
|
||||
};
|
||||
|
||||
(frame as any).selectOption = async (selector: string, values: any, options?: HumanActionOptions) => {
|
||||
await frameHover(selector, options);
|
||||
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).press = async (selector: string, key: string, options?: HumanActionOptions) => {
|
||||
if (!await isFrameSelectorFocused(frame, selector)) {
|
||||
await frameClick(selector, options);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(key);
|
||||
};
|
||||
|
||||
(frame as any).pressSequentially = async (selector: string, text: string, options?: any) => {
|
||||
await (page as any).pressSequentially(selector, text, options);
|
||||
(frame as any).pressSequentially = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (!await isFrameSelectorFocused(frame, selector)) {
|
||||
await frameClick(selector, options);
|
||||
}
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await getFrameCdp();
|
||||
await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFramePressSequentially?.(selector, text, options));
|
||||
};
|
||||
|
||||
(frame as any).tap = async (selector: string, options?: any) => {
|
||||
await (page as any).tap(selector, options);
|
||||
(frame as any).tap = async (selector: string, options?: HumanActionOptions) => {
|
||||
await frameClick(selector, options).catch(() => origFrameTap?.(selector, options));
|
||||
};
|
||||
|
||||
(frame as any).clear = async (selector: string, options?: any) => {
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await (page as any).click(selector);
|
||||
(frame as any).clear = async (selector: string, options?: HumanActionOptions) => {
|
||||
if (!await isFrameSelectorFocused(frame, selector)) {
|
||||
await frameClick(selector, options);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
@@ -599,9 +713,17 @@ function patchSingleFrame(
|
||||
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);
|
||||
(frame as any).dragAndDrop = async (source: string, target: string, options?: {
|
||||
force?: boolean;
|
||||
noWaitAfter?: boolean;
|
||||
sourcePosition?: { x: number; y: number };
|
||||
strict?: boolean;
|
||||
targetPosition?: { x: number; y: number };
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
||||
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
||||
|
||||
if (srcBox && tgtBox) {
|
||||
const sx = srcBox.x + srcBox.width / 2;
|
||||
@@ -670,14 +792,14 @@ export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
|
||||
}
|
||||
|
||||
const origNewContext = browser.newContext.bind(browser);
|
||||
(browser as any).newContext = async (options?: any) => {
|
||||
(browser as any).newContext = async (options?: Parameters<typeof origNewContext>[0]) => {
|
||||
const context = await origNewContext(options);
|
||||
patchContext(context, cfg);
|
||||
return context;
|
||||
};
|
||||
|
||||
const origNewPage = browser.newPage.bind(browser);
|
||||
(browser as any).newPage = async (options?: any) => {
|
||||
(browser as any).newPage = async (options?: Parameters<typeof origNewPage>[0]) => {
|
||||
const page = await origNewPage(options);
|
||||
if (!(page as any)._original) {
|
||||
const ctx = page.context();
|
||||
|
||||
+21
-1
@@ -172,13 +172,33 @@ export async function humanClick(
|
||||
// Human idle / drift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function humanIdle(
|
||||
export function humanIdle(
|
||||
raw: RawMouse,
|
||||
cx: number,
|
||||
cy: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void>;
|
||||
export function humanIdle(
|
||||
raw: RawMouse,
|
||||
seconds: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void>;
|
||||
export async function humanIdle(
|
||||
raw: RawMouse,
|
||||
secondsOrCx: number,
|
||||
cxOrCy: number,
|
||||
cyOrCfg: number | HumanConfig,
|
||||
maybeCfg?: HumanConfig,
|
||||
): Promise<void> {
|
||||
const hasExplicitSeconds = maybeCfg !== undefined;
|
||||
const seconds = hasExplicitSeconds
|
||||
? secondsOrCx
|
||||
: rand((cyOrCfg as HumanConfig).idle_between_duration[0], (cyOrCfg as HumanConfig).idle_between_duration[1]);
|
||||
const cx = hasExplicitSeconds ? cxOrCy : secondsOrCx;
|
||||
const cy = hasExplicitSeconds ? (cyOrCfg as number) : cxOrCy;
|
||||
const cfg = hasExplicitSeconds ? maybeCfg! : (cyOrCfg as HumanConfig);
|
||||
const endTime = Date.now() + seconds * 1000;
|
||||
let x = cx;
|
||||
let y = cy;
|
||||
|
||||
+43
-13
@@ -38,10 +38,17 @@ async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Prom
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollToElement(
|
||||
/**
|
||||
* Humanized scrolling that takes an arbitrary ``getBox`` callable.
|
||||
*
|
||||
* Used by both ``scrollToElement`` (selector-based) and the ElementHandle
|
||||
* ``scrollIntoViewIfNeeded`` patch so the same accelerate → cruise →
|
||||
* decelerate → overshoot behavior runs everywhere.
|
||||
*/
|
||||
export async function humanScrollIntoView(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
getBox: () => Promise<ElementBounds | null>,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
@@ -49,12 +56,8 @@ export async function scrollToElement(
|
||||
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}`);
|
||||
}
|
||||
let box = await getBox();
|
||||
if (!box) throw new Error('Element not found while scrolling into view');
|
||||
|
||||
if (isInViewport(box, viewport.height, cfg)) {
|
||||
return { box, cursorX, cursorY };
|
||||
@@ -107,7 +110,7 @@ export async function scrollToElement(
|
||||
|
||||
// Check visibility every 3 steps
|
||||
if (i % 3 === 2 || i === totalClicks - 1) {
|
||||
box = await getElementBox(page, selector);
|
||||
box = await getBox();
|
||||
if (box && isInViewport(box, viewport.height, cfg)) {
|
||||
break;
|
||||
}
|
||||
@@ -133,16 +136,43 @@ export async function scrollToElement(
|
||||
// Settle
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
|
||||
box = await getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
|
||||
box = await getBox();
|
||||
if (!box) throw new Error('Element lost after scrolling into view');
|
||||
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
|
||||
/**
|
||||
* Selector-based humanized scroll.
|
||||
*
|
||||
* ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so
|
||||
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
|
||||
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
|
||||
*/
|
||||
export async function scrollToElement(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
timeout?: number,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
return humanScrollIntoView(
|
||||
page, raw,
|
||||
() => getElementBox(page, selector, timeout),
|
||||
cursorX, cursorY, cfg,
|
||||
);
|
||||
}
|
||||
|
||||
async function getElementBox(
|
||||
page: Page,
|
||||
selector: string,
|
||||
timeout: number = 30000,
|
||||
): Promise<ElementBounds | null> {
|
||||
const el = page.locator(selector).first();
|
||||
try {
|
||||
const box = await el.boundingBox({ timeout: 2000 });
|
||||
const box = await el.boundingBox({ timeout });
|
||||
return box;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+13
-1
@@ -136,7 +136,19 @@ export function normalizeSocksStringUrl(urlStr: string): string {
|
||||
const encPass = hasPassword
|
||||
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
|
||||
: null;
|
||||
return assembleSocksUrl(scheme, encUser, encPass, hostAndRest);
|
||||
const normalized = assembleSocksUrl(scheme, encUser, encPass, hostAndRest);
|
||||
// Compare credentials, not the full URL: keeps the log condition focused
|
||||
// on real encoding work, not cosmetic differences (parity with the Python
|
||||
// implementation, which has to skip urlparse's hostname lowercasing).
|
||||
const credsChanged = encUser !== rawUserEnc
|
||||
|| (hasPassword ? encPass !== rawPassEnc : false);
|
||||
if (credsChanged) {
|
||||
console.info(
|
||||
"[cloakbrowser] Auto URL-encoded SOCKS5 proxy credentials (special " +
|
||||
"characters detected). Pre-encode the URL to suppress this notice.",
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
} catch (e) {
|
||||
console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`);
|
||||
return urlStr;
|
||||
|
||||
+59
-2
@@ -1,5 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { COUNTRY_LOCALE_MAP, resolveProxyIp } from "../src/geoip.js";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { COUNTRY_LOCALE_MAP, maybeResolveGeoip, resolveProxyGeo, resolveProxyIp } from "../src/geoip.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS;
|
||||
delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("resolveProxyIp", () => {
|
||||
it("returns literal IPv4 from proxy URL", async () => {
|
||||
@@ -38,6 +50,51 @@ describe("resolveProxyIp", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeResolveGeoip", () => {
|
||||
it("does not apply the GeoIP resolution timeout to first-use database download", async () => {
|
||||
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-download-"));
|
||||
tempDirs.push(cacheDir);
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
|
||||
process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS = "0.001";
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await resolveProxyGeo("http://203.0.113.10:8080");
|
||||
|
||||
expect(result).toEqual({ timezone: null, locale: null, exitIp: null });
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
expect(fetchSpy.mock.calls[0][1]).toEqual({ redirect: "follow" });
|
||||
});
|
||||
|
||||
it("returns quickly when GeoIP resolution times out", async () => {
|
||||
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-timeout-"));
|
||||
tempDirs.push(cacheDir);
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
|
||||
process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS = "0.025";
|
||||
|
||||
const start = performance.now();
|
||||
const result = await maybeResolveGeoip({
|
||||
geoip: true,
|
||||
proxy: "http://203.0.113.10:8080",
|
||||
timezone: "Europe/Paris",
|
||||
locale: "fr-FR",
|
||||
});
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
expect(result).toEqual({ timezone: "Europe/Paris", locale: "fr-FR", exitIp: undefined });
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("COUNTRY_LOCALE_MAP", () => {
|
||||
it("contains common countries", () => {
|
||||
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
|
||||
|
||||
+545
-151
@@ -106,9 +106,9 @@ describe("humanMove", () => {
|
||||
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 () => {}),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
},
|
||||
moves,
|
||||
};
|
||||
@@ -180,10 +180,10 @@ describe("humanClick", () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const callOrder: string[] = [];
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { callOrder.push("down"); }),
|
||||
up: vi.fn(async () => { callOrder.push("up"); }),
|
||||
wheel: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
await humanClick(raw, false, cfg);
|
||||
expect(raw.down).toHaveBeenCalledTimes(1);
|
||||
@@ -199,12 +199,12 @@ 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 () => {}),
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
await humanIdle(raw, 10, 100, 100, cfg);
|
||||
await humanIdle(raw, 100, 100, cfg);
|
||||
expect(raw.move).toHaveBeenCalled();
|
||||
}, 15000);
|
||||
});
|
||||
@@ -265,7 +265,7 @@ describe("patchPage fill", () => {
|
||||
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 (_) {}
|
||||
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";
|
||||
@@ -298,7 +298,7 @@ describe("patchPage check/uncheck idle", () => {
|
||||
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 (_) {}
|
||||
try { await (page as any).check("input#cb"); } catch (_) { }
|
||||
|
||||
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
|
||||
expect(downCalled).toBe(true);
|
||||
@@ -321,7 +321,7 @@ describe("patchPage check/uncheck idle", () => {
|
||||
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 (_) {}
|
||||
try { await (page as any).uncheck("input#cb"); } catch (_) { }
|
||||
|
||||
expect(downCalled).toBe(true);
|
||||
}, 30000);
|
||||
@@ -354,7 +354,7 @@ describe("patchPage press focus", () => {
|
||||
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 (_) {}
|
||||
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
|
||||
|
||||
expect(downCount).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -372,7 +372,7 @@ describe("patchPage press focus", () => {
|
||||
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 (_) {}
|
||||
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
|
||||
|
||||
expect(downCount).toBe(0);
|
||||
});
|
||||
@@ -398,6 +398,69 @@ describe("patchPage frame patching", () => {
|
||||
|
||||
expect((childFrame as any)._humanPatched).toBe(true);
|
||||
});
|
||||
|
||||
it("uses frame.locator for frame.click instead of page.click", 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 originalPageClick = page.click;
|
||||
const cfg = resolveConfig("default", { mouse_min_steps: 1, mouse_max_steps: 1 });
|
||||
const cursor = { x: 0, y: 0, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (childFrame as any).click("button.submit", { timeout: 1234 });
|
||||
|
||||
expect(childFrame.locator).toHaveBeenCalledWith("button.submit");
|
||||
expect(originalPageClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["type", async (frame: any) => frame.type("input.email", "@")],
|
||||
["fill", async (frame: any) => frame.fill("input.email", "@")],
|
||||
["pressSequentially", async (frame: any) => frame.pressSequentially("input.email", "@")],
|
||||
])("passes the page CDP session to frame.%s", async (_name, runFrameAction) => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
const cdpSend = vi.fn(async () => ({}));
|
||||
const childFrame = buildMockFrame();
|
||||
const mainFrame = {
|
||||
...buildMockFrame(),
|
||||
childFrames: vi.fn(() => [childFrame]),
|
||||
};
|
||||
const page = buildMockPage({ mainFrameReturn: mainFrame });
|
||||
page.context = vi.fn(() => ({
|
||||
pages: vi.fn(() => []),
|
||||
addInitScript: vi.fn(async () => {}),
|
||||
newCDPSession: vi.fn(async () => ({ send: cdpSend })),
|
||||
}));
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
field_switch_delay: [0, 0],
|
||||
key_hold: [0, 0],
|
||||
shift_down_delay: [0, 0],
|
||||
shift_up_delay: [0, 0],
|
||||
typing_delay: 0,
|
||||
typing_delay_spread: 0,
|
||||
typing_pause_chance: 0,
|
||||
mistype_chance: 0,
|
||||
mouse_min_steps: 1,
|
||||
mouse_max_steps: 1,
|
||||
idle_between_actions: false,
|
||||
});
|
||||
const cursor = { x: 0, y: 0, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await runFrameAction(childFrame);
|
||||
|
||||
const dispatches = cdpSend.mock.calls.filter(([method]) => method === "Input.dispatchKeyEvent");
|
||||
expect(dispatches).toHaveLength(2);
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
@@ -465,7 +528,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
||||
pages: vi.fn(() => [page]),
|
||||
on: vi.fn(),
|
||||
newPage: vi.fn(async () => buildMockPage()),
|
||||
addInitScript: vi.fn(async () => {}),
|
||||
addInitScript: vi.fn(async () => { }),
|
||||
};
|
||||
const browser: any = {
|
||||
contexts: vi.fn(() => [context]),
|
||||
@@ -494,7 +557,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
||||
pages: vi.fn(() => [page]),
|
||||
on: vi.fn(),
|
||||
newPage: vi.fn(async () => buildMockPage()),
|
||||
addInitScript: vi.fn(async () => {}),
|
||||
addInitScript: vi.fn(async () => { }),
|
||||
};
|
||||
const browser: any = {
|
||||
contexts: vi.fn(() => [context]),
|
||||
@@ -505,7 +568,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
||||
patchBrowser(browser, resolveConfig("default"));
|
||||
|
||||
// Click through the patched method — should go through humanize path
|
||||
try { await (page as any).click("button"); } catch (_) {}
|
||||
try { await (page as any).click("button"); } catch (_) { }
|
||||
|
||||
expect(downCalled).toBe(true);
|
||||
}, 30000);
|
||||
@@ -518,7 +581,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
||||
pages: vi.fn(() => [newPage]),
|
||||
on: vi.fn(),
|
||||
newPage: vi.fn(async () => buildMockPage()),
|
||||
addInitScript: vi.fn(async () => {}),
|
||||
addInitScript: vi.fn(async () => { }),
|
||||
};
|
||||
const browser: any = {
|
||||
contexts: vi.fn(() => []),
|
||||
@@ -542,27 +605,27 @@ describe("patchBrowser CDP-connected workflow", () => {
|
||||
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 () => {}),
|
||||
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; }),
|
||||
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 () => {}),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => { }),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
};
|
||||
loc.first = vi.fn(() => loc);
|
||||
@@ -571,33 +634,33 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||
|
||||
const page: any = {
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||
addInitScript: vi.fn(async () => {}),
|
||||
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 () => {}),
|
||||
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 () => {}),
|
||||
: 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 () => {}),
|
||||
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()),
|
||||
@@ -606,10 +669,10 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||
frames: vi.fn(() => []),
|
||||
context: vi.fn(() => ({
|
||||
pages: vi.fn(() => []),
|
||||
addInitScript: vi.fn(async () => {}),
|
||||
addInitScript: vi.fn(async () => { }),
|
||||
})),
|
||||
url: vi.fn(() => "about:blank"),
|
||||
waitForTimeout: vi.fn(async () => {}),
|
||||
waitForTimeout: vi.fn(async () => { }),
|
||||
};
|
||||
return page;
|
||||
}
|
||||
@@ -623,8 +686,8 @@ describe("humanType non-ASCII", () => {
|
||||
const insertedChars: string[] = [];
|
||||
const raw = {
|
||||
down: vi.fn(async (k: string) => { downKeys.push(k); }),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => { }),
|
||||
type: vi.fn(async () => { }),
|
||||
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
|
||||
};
|
||||
return { raw, downKeys, insertedChars };
|
||||
@@ -696,18 +759,18 @@ describe("humanType non-ASCII", () => {
|
||||
|
||||
function buildMockElementHandle(overrides: Record<string, any> = {}): any {
|
||||
const el: any = {
|
||||
click: vi.fn(async () => {}),
|
||||
dblclick: vi.fn(async () => {}),
|
||||
hover: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
fill: vi.fn(async () => {}),
|
||||
press: vi.fn(async () => {}),
|
||||
selectOption: vi.fn(async () => {}),
|
||||
check: vi.fn(async () => {}),
|
||||
uncheck: vi.fn(async () => {}),
|
||||
setChecked: vi.fn(async () => {}),
|
||||
tap: vi.fn(async () => {}),
|
||||
focus: vi.fn(async () => {}),
|
||||
click: vi.fn(async () => { }),
|
||||
dblclick: vi.fn(async () => { }),
|
||||
hover: vi.fn(async () => { }),
|
||||
type: vi.fn(async () => { }),
|
||||
fill: vi.fn(async () => { }),
|
||||
press: vi.fn(async () => { }),
|
||||
selectOption: vi.fn(async () => { }),
|
||||
check: vi.fn(async () => { }),
|
||||
uncheck: vi.fn(async () => { }),
|
||||
setChecked: vi.fn(async () => { }),
|
||||
tap: vi.fn(async () => { }),
|
||||
focus: vi.fn(async () => { }),
|
||||
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
@@ -725,21 +788,21 @@ describe("patchSingleElementHandle", () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
const rawKb = {
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
type: vi.fn(async () => { }),
|
||||
insertText: vi.fn(async () => { }),
|
||||
};
|
||||
const originals = {
|
||||
keyboardPress: vi.fn(async () => {}),
|
||||
keyboardDown: vi.fn(async () => {}),
|
||||
keyboardUp: vi.fn(async () => {}),
|
||||
keyboardPress: vi.fn(async () => { }),
|
||||
keyboardDown: vi.fn(async () => { }),
|
||||
keyboardUp: vi.fn(async () => { }),
|
||||
};
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
@@ -762,23 +825,23 @@ describe("patchSingleElementHandle", () => {
|
||||
move: vi.fn(async () => { moveCount++; }),
|
||||
down: vi.fn(async () => { downCalled = true; }),
|
||||
up: vi.fn(async () => { upCalled = true; }),
|
||||
wheel: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
const rawKb = {
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
type: vi.fn(async () => { }),
|
||||
insertText: vi.fn(async () => { }),
|
||||
};
|
||||
const originals = {
|
||||
keyboardPress: vi.fn(async () => {}),
|
||||
keyboardDown: vi.fn(async () => {}),
|
||||
keyboardUp: vi.fn(async () => {}),
|
||||
keyboardPress: vi.fn(async () => { }),
|
||||
keyboardDown: vi.fn(async () => { }),
|
||||
keyboardUp: vi.fn(async () => { }),
|
||||
};
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
@@ -796,22 +859,22 @@ describe("patchSingleElementHandle", () => {
|
||||
|
||||
let downCalled = false;
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { downCalled = true; }),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
const rawKb = {
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
type: vi.fn(async () => { }),
|
||||
insertText: vi.fn(async () => { }),
|
||||
};
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
@@ -827,22 +890,22 @@ describe("patchSingleElementHandle", () => {
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
const rawKb = {
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
type: vi.fn(async () => { }),
|
||||
insertText: vi.fn(async () => { }),
|
||||
};
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
@@ -860,26 +923,26 @@ describe("patchSingleElementHandle", () => {
|
||||
|
||||
const pressedKeys: string[] = [];
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
const rawKb = {
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
type: vi.fn(async () => { }),
|
||||
insertText: vi.fn(async () => { }),
|
||||
};
|
||||
const originals = {
|
||||
keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }),
|
||||
keyboardDown: vi.fn(async () => {}),
|
||||
keyboardUp: vi.fn(async () => {}),
|
||||
keyboardDown: vi.fn(async () => { }),
|
||||
keyboardUp: vi.fn(async () => { }),
|
||||
};
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
@@ -894,9 +957,9 @@ describe("patchSingleElementHandle", () => {
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: vi.fn(async () => { }) };
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
@@ -913,9 +976,9 @@ describe("patchSingleElementHandle", () => {
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: vi.fn(async () => { }) };
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const child = buildMockElementHandle();
|
||||
const el = buildMockElementHandle();
|
||||
@@ -935,9 +998,9 @@ describe("patchPageElementHandles", () => {
|
||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: vi.fn(async () => { }) };
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
@@ -955,9 +1018,9 @@ describe("patchPageElementHandles", () => {
|
||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: vi.fn(async () => { }) };
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el1 = buildMockElementHandle();
|
||||
const el2 = buildMockElementHandle();
|
||||
@@ -977,9 +1040,9 @@ describe("patchPageElementHandles", () => {
|
||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: vi.fn(async () => { }) };
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
@@ -997,9 +1060,9 @@ describe("patchPageElementHandles", () => {
|
||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: vi.fn(async () => { }) };
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const page = buildMockPage();
|
||||
(page as any).$ = vi.fn(async () => null);
|
||||
@@ -1034,21 +1097,352 @@ describe("patchPage integrates ElementHandle patching", () => {
|
||||
|
||||
|
||||
function buildMockFrame(): any {
|
||||
const locator: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
||||
evaluate: vi.fn(async () => false),
|
||||
isChecked: vi.fn(async () => false),
|
||||
};
|
||||
locator.first = vi.fn(() => locator);
|
||||
|
||||
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 })),
|
||||
})),
|
||||
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 () => { }),
|
||||
pressSequentially: vi.fn(async () => { }),
|
||||
tap: vi.fn(async () => { }),
|
||||
clear: vi.fn(async () => { }),
|
||||
dragAndDrop: vi.fn(async () => { }),
|
||||
locator: vi.fn(() => locator),
|
||||
childFrames: vi.fn(() => []),
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// mergeConfig
|
||||
// =========================================================================
|
||||
describe("mergeConfig", () => {
|
||||
it("returns base unchanged when overrides is undefined/null", async () => {
|
||||
const { mergeConfig, resolveConfig: rc } = await import("../src/human/config.js");
|
||||
const base = rc("default");
|
||||
expect(mergeConfig(base, undefined)).toBe(base);
|
||||
expect(mergeConfig(base, null)).toBe(base);
|
||||
});
|
||||
|
||||
it("creates a new object — base is never mutated", async () => {
|
||||
const { mergeConfig, resolveConfig: rc } = await import("../src/human/config.js");
|
||||
const base = rc("default");
|
||||
const before = base.typing_delay;
|
||||
const merged = mergeConfig(base, { typing_delay: 30 });
|
||||
expect(merged.typing_delay).toBe(30);
|
||||
expect(base.typing_delay).toBe(before);
|
||||
expect(merged).not.toBe(base);
|
||||
});
|
||||
|
||||
it("preserves non-overridden fields", async () => {
|
||||
const { mergeConfig, resolveConfig: rc } = await import("../src/human/config.js");
|
||||
const base = rc("default");
|
||||
const merged = mergeConfig(base, { typing_delay: 30 });
|
||||
expect(merged.mouse_min_steps).toBe(base.mouse_min_steps);
|
||||
expect(merged.mistype_chance).toBe(base.mistype_chance);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Per-call timeout forwarding (issue #137)
|
||||
// =========================================================================
|
||||
describe("page.click(selector, { timeout }) forwards timeout to scroll", () => {
|
||||
it("scrollToElement passes timeout to locator.boundingBox()", async () => {
|
||||
const { scrollToElement } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default");
|
||||
|
||||
const boundingBox = vi.fn(async () => ({ x: 100, y: 200, width: 50, height: 30 }));
|
||||
const page: any = {
|
||||
viewportSize: () => ({ width: 1280, height: 720 }),
|
||||
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
|
||||
};
|
||||
const raw = {
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
|
||||
await scrollToElement(page, raw, "#x", 0, 0, cfg, 5000);
|
||||
expect(boundingBox).toHaveBeenCalledWith({ timeout: 5000 });
|
||||
});
|
||||
|
||||
it("default timeout matches Playwright's 30000ms when not specified", async () => {
|
||||
const { scrollToElement } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default");
|
||||
|
||||
const boundingBox = vi.fn(async () => ({ x: 100, y: 200, width: 50, height: 30 }));
|
||||
const page: any = {
|
||||
viewportSize: () => ({ width: 1280, height: 720 }),
|
||||
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
|
||||
};
|
||||
const raw = {
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
|
||||
await scrollToElement(page, raw, "#x", 0, 0, cfg);
|
||||
expect(boundingBox).toHaveBeenCalledWith({ timeout: 30000 });
|
||||
});
|
||||
|
||||
it("page.click({ timeout }) reaches scrollToElement", async () => {
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||
|
||||
let captured = -1;
|
||||
const spy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy, _cfg, timeout?: number) => {
|
||||
captured = timeout ?? -1;
|
||||
return { box: { x: 100, y: 100, width: 50, height: 30 }, cursorX: cx, cursorY: cy };
|
||||
},
|
||||
);
|
||||
|
||||
const page = buildMockPage();
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
await (page as any).click("#slow", { timeout: 5000 });
|
||||
|
||||
expect(captured).toBe(5000);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Per-call human config override
|
||||
// =========================================================================
|
||||
describe("page.type / page.fill accept per-call human config override", () => {
|
||||
it("page.type forwards nested human_config to humanType", async () => {
|
||||
const keyboardMod = await import("../src/human/keyboard.js");
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
// Make field_switch_delay tiny so the test runs fast
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: false,
|
||||
field_switch_delay: [0, 1],
|
||||
});
|
||||
expect(cfg.typing_delay).toBe(70); // baseline
|
||||
|
||||
let captured: any = null;
|
||||
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
|
||||
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
|
||||
);
|
||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy) => ({
|
||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
||||
cursorX: cx, cursorY: cy,
|
||||
}),
|
||||
);
|
||||
|
||||
const page = buildMockPage();
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (page as any).type("#email", "hi", {
|
||||
human_config: { typing_delay: 30, mistype_chance: 0 },
|
||||
});
|
||||
|
||||
expect(captured.typing_delay).toBe(30);
|
||||
expect(captured.mistype_chance).toBe(0);
|
||||
// Global cfg untouched
|
||||
expect(cfg.typing_delay).toBe(70);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
}, 30000);
|
||||
|
||||
it("page.fill forwards flat config to humanType", async () => {
|
||||
const keyboardMod = await import("../src/human/keyboard.js");
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: false,
|
||||
field_switch_delay: [0, 1],
|
||||
});
|
||||
|
||||
let captured: any = null;
|
||||
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
|
||||
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
|
||||
);
|
||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy) => ({
|
||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
||||
cursorX: cx, cursorY: cy,
|
||||
}),
|
||||
);
|
||||
|
||||
const page = buildMockPage();
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (page as any).fill("#password", "secret", {
|
||||
typing_delay: 150,
|
||||
});
|
||||
|
||||
expect(captured.typing_delay).toBe(150);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
}, 30000);
|
||||
|
||||
it("el.type forwards human_config to humanType", async () => {
|
||||
const keyboardMod = await import("../src/human/keyboard.js");
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 });
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
|
||||
let captured: any = null;
|
||||
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
|
||||
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
|
||||
);
|
||||
|
||||
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: vi.fn(async () => { }) };
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
await el.type("abc", { human_config: { typing_delay: 25 } });
|
||||
expect(captured.typing_delay).toBe(25);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// scrollIntoViewIfNeeded humanization
|
||||
// =========================================================================
|
||||
describe("humanScrollIntoView", () => {
|
||||
it("skips wheel events when element is already in viewport", async () => {
|
||||
const { humanScrollIntoView } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default");
|
||||
|
||||
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
|
||||
const raw = {
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
// Box centered in viewport — squarely in scroll_target_zone
|
||||
const inViewBox = { x: 200, y: 300, width: 50, height: 30 };
|
||||
const result = await humanScrollIntoView(page, raw, async () => inViewBox, 0, 0, cfg);
|
||||
|
||||
expect(result.box).toEqual(inViewBox);
|
||||
expect(raw.wheel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires wheel events when element is below the fold", async () => {
|
||||
const { humanScrollIntoView } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default", {
|
||||
scroll_overshoot_chance: 0,
|
||||
scroll_pre_move_delay: [0, 1],
|
||||
scroll_pause_fast: [0, 1],
|
||||
scroll_pause_slow: [0, 1],
|
||||
scroll_settle_delay: [0, 1],
|
||||
});
|
||||
|
||||
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
|
||||
const raw = {
|
||||
move: vi.fn(async () => { }),
|
||||
down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }),
|
||||
wheel: vi.fn(async () => { }),
|
||||
};
|
||||
|
||||
const boxes = [
|
||||
{ x: 200, y: 2000, width: 50, height: 30 }, // far below
|
||||
{ x: 200, y: 1500, width: 50, height: 30 },
|
||||
{ x: 200, y: 1000, width: 50, height: 30 },
|
||||
{ x: 200, y: 400, width: 50, height: 30 }, // in view
|
||||
{ x: 200, y: 400, width: 50, height: 30 },
|
||||
{ x: 200, y: 400, width: 50, height: 30 },
|
||||
];
|
||||
let i = 0;
|
||||
const getBox = async () => boxes[Math.min(i++, boxes.length - 1)];
|
||||
|
||||
await humanScrollIntoView(page, raw, getBox, 0, 0, cfg);
|
||||
expect(raw.wheel).toHaveBeenCalled();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe("el.scrollIntoViewIfNeeded humanization", () => {
|
||||
it("calls humanScrollIntoView instead of native snap-scroll", async () => {
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
|
||||
let called = 0;
|
||||
const spy = vi.spyOn(scrollMod, "humanScrollIntoView").mockImplementation(
|
||||
async (_p, _raw, _gb, cx, cy) => {
|
||||
called++;
|
||||
return { box: { x: 200, y: 200, width: 50, height: 30 }, cursorX: cx, cursorY: cy };
|
||||
},
|
||||
);
|
||||
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: vi.fn(async () => { }) };
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
el.scrollIntoViewIfNeeded = vi.fn(async () => { });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
await el.scrollIntoViewIfNeeded();
|
||||
|
||||
expect(called).toBeGreaterThan(0);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to native scrollIntoViewIfNeeded if humanized helper throws", async () => {
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
|
||||
const spy = vi.spyOn(scrollMod, "humanScrollIntoView").mockImplementation(
|
||||
async () => { throw new Error("detached"); },
|
||||
);
|
||||
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: vi.fn(async () => { }) };
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const nativeFallback = vi.fn(async () => { });
|
||||
const el = buildMockElementHandle();
|
||||
el.scrollIntoViewIfNeeded = nativeFallback;
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
await el.scrollIntoViewIfNeeded();
|
||||
|
||||
expect(nativeFallback).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+60
-1
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
@@ -262,4 +262,63 @@ describe("resolveProxyConfig", () => {
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://user:a@b@c@host:1080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:a%40b%40c@host:1080"]);
|
||||
});
|
||||
|
||||
// Visibility for #157: when wrapper actually rewrites the URL, surface an
|
||||
// info log so users debugging silent SOCKS5 fallback can see what happened.
|
||||
it("logs info message when SOCKS5 credentials get re-encoded", () => {
|
||||
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
try {
|
||||
resolveProxyConfig("socks5://user:pass=123@host:1080");
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Auto URL-encoded SOCKS5"),
|
||||
);
|
||||
// Credentials must not leak into the log.
|
||||
const calls = debugSpy.mock.calls.flat().join(" ");
|
||||
expect(calls).not.toContain("pass=123");
|
||||
expect(calls).not.toContain("pass%3D123");
|
||||
} finally {
|
||||
debugSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("stays silent when SOCKS5 URL is already encoded (no log spam)", () => {
|
||||
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
try {
|
||||
resolveProxyConfig("socks5://user:pass%3D123@host:1080");
|
||||
const reencodedCalls = debugSpy.mock.calls
|
||||
.flat()
|
||||
.filter((arg) => typeof arg === "string" && arg.includes("Auto URL-encoded SOCKS5"));
|
||||
expect(reencodedCalls).toHaveLength(0);
|
||||
} finally {
|
||||
debugSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("stays silent when SOCKS5 URL has no credentials", () => {
|
||||
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
try {
|
||||
resolveProxyConfig("socks5://host:1080");
|
||||
const reencodedCalls = debugSpy.mock.calls
|
||||
.flat()
|
||||
.filter((arg) => typeof arg === "string" && arg.includes("Auto URL-encoded SOCKS5"));
|
||||
expect(reencodedCalls).toHaveLength(0);
|
||||
} finally {
|
||||
debugSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("stays silent when only host case differs (no credential rewrite)", () => {
|
||||
// Parity with Python: log condition must track credential changes, not
|
||||
// cosmetic URL-string differences (regression for Copilot's PR #209 review).
|
||||
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
try {
|
||||
resolveProxyConfig("socks5://USER:pass@HOST.com:1080");
|
||||
const reencodedCalls = debugSpy.mock.calls
|
||||
.flat()
|
||||
.filter((arg) => typeof arg === "string" && arg.includes("Auto URL-encoded SOCKS5"));
|
||||
expect(reencodedCalls).toHaveLength(0);
|
||||
} finally {
|
||||
debugSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -532,6 +532,52 @@ describe("Puppeteer: non-ASCII text avoids CDP shift path", () => {
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Per-call human config override (Puppeteer page-level)
|
||||
// =========================================================================
|
||||
describe("Puppeteer: page.type accepts per-call human config override", () => {
|
||||
it("page.type forwards merged config to humanType", async () => {
|
||||
const keyboardMod = await import("../src/human-puppeteer/keyboard.js");
|
||||
const scrollMod = await import("../src/human-puppeteer/scroll.js");
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: false,
|
||||
field_switch_delay: [0, 1],
|
||||
});
|
||||
expect(cfg.typing_delay).toBe(70);
|
||||
|
||||
let captured: any = null;
|
||||
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
|
||||
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
|
||||
);
|
||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy) => ({
|
||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
||||
cursorX: cx,
|
||||
cursorY: cy,
|
||||
}),
|
||||
);
|
||||
|
||||
const { patchPage } = await import("../src/human-puppeteer/index.js");
|
||||
const page = buildMockPage();
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (page as any).type("#email", "hi", {
|
||||
typing_delay: 30,
|
||||
mistype_chance: 0,
|
||||
});
|
||||
|
||||
expect(captured.typing_delay).toBe(30);
|
||||
expect(captured.mistype_chance).toBe(0);
|
||||
expect(cfg.typing_delay).toBe(70);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// patchPage stealth infrastructure (Puppeteer)
|
||||
// =========================================================================
|
||||
@@ -1671,11 +1717,13 @@ describe("Puppeteer: isInputElement stealth integration via patchPage", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const mockEl = buildMockElementHandle();
|
||||
const page = buildMockPage({
|
||||
evaluate: vi.fn(async (...args: any[]) => {
|
||||
evaluateCalls.push(args);
|
||||
return false;
|
||||
}),
|
||||
$: vi.fn(async () => mockEl),
|
||||
});
|
||||
page.createCDPSession = vi.fn(async () => mockCdp);
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ parse_connection_params = _mod.parse_connection_params
|
||||
parse_cli_args = _mod.parse_cli_args
|
||||
ChromePool = _mod.ChromePool
|
||||
_default_data_dir = _mod._default_data_dir
|
||||
SAFE_SEED_RE = _mod.SAFE_SEED_RE
|
||||
RESERVED_SEEDS = _mod.RESERVED_SEEDS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -244,3 +246,95 @@ class TestConnectionTracking:
|
||||
pool.disconnect("a")
|
||||
assert pool._connections["a"] == 1
|
||||
assert pool._connections["b"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed validation (CVE fix — path traversal via fingerprint param)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSeedValidation:
|
||||
"""Verify SAFE_SEED_RE rejects path traversal and reserved names."""
|
||||
|
||||
@pytest.mark.parametrize("seed", [
|
||||
"../foo", "../../etc", "/etc/passwd", "..", ".", "foo/bar",
|
||||
"foo\\bar", "\x00evil", "", "a" * 129,
|
||||
])
|
||||
def test_malicious_seeds_rejected(self, seed):
|
||||
assert not SAFE_SEED_RE.match(seed)
|
||||
|
||||
@pytest.mark.parametrize("seed", [
|
||||
"__default__",
|
||||
])
|
||||
def test_reserved_seeds_rejected(self, seed):
|
||||
assert seed in RESERVED_SEEDS
|
||||
|
||||
@pytest.mark.parametrize("seed", [
|
||||
"12345", "my-seed_01", "ABC", "a" * 128, "0", "test-seed",
|
||||
])
|
||||
def test_valid_seeds_accepted(self, seed):
|
||||
assert SAFE_SEED_RE.match(seed)
|
||||
assert seed not in RESERVED_SEEDS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path containment (_safe_rmtree)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSafeRmtree:
|
||||
"""Verify _safe_rmtree refuses to delete outside data_dir."""
|
||||
|
||||
def _make_pool(self, data_dir: str):
|
||||
return ChromePool(
|
||||
binary="/fake/chrome",
|
||||
global_args=[],
|
||||
headless=True,
|
||||
data_dir=data_dir,
|
||||
)
|
||||
|
||||
def test_refuses_path_outside_data_dir(self, tmp_path):
|
||||
data_dir = tmp_path / "profiles"
|
||||
data_dir.mkdir()
|
||||
victim = tmp_path / "victim"
|
||||
victim.mkdir()
|
||||
(victim / "sentinel").touch()
|
||||
|
||||
pool = self._make_pool(str(data_dir))
|
||||
pool._safe_rmtree(str(victim))
|
||||
|
||||
assert victim.exists(), "Directory outside data_dir must not be deleted"
|
||||
|
||||
def test_refuses_data_dir_itself(self, tmp_path):
|
||||
data_dir = tmp_path / "profiles"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "sentinel").touch()
|
||||
|
||||
pool = self._make_pool(str(data_dir))
|
||||
pool._safe_rmtree(str(data_dir))
|
||||
|
||||
assert data_dir.exists(), "data_dir itself must not be deleted"
|
||||
|
||||
def test_deletes_valid_subdirectory(self, tmp_path):
|
||||
data_dir = tmp_path / "profiles"
|
||||
data_dir.mkdir()
|
||||
subdir = data_dir / "seed-12345"
|
||||
subdir.mkdir()
|
||||
(subdir / "data").touch()
|
||||
|
||||
pool = self._make_pool(str(data_dir))
|
||||
pool._safe_rmtree(str(subdir))
|
||||
|
||||
assert not subdir.exists(), "Valid subdirectory should be deleted"
|
||||
|
||||
def test_refuses_traversal_path(self, tmp_path):
|
||||
data_dir = tmp_path / "profiles"
|
||||
data_dir.mkdir()
|
||||
victim = tmp_path / "victim"
|
||||
victim.mkdir()
|
||||
|
||||
traversal = str(data_dir / ".." / "victim")
|
||||
pool = self._make_pool(str(data_dir))
|
||||
pool._safe_rmtree(traversal)
|
||||
|
||||
assert victim.exists(), "Traversal path must not be deleted"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for GeoIP-based timezone/locale detection."""
|
||||
|
||||
from unittest.mock import patch
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -144,6 +145,20 @@ def test_maybe_resolve_fills_both():
|
||||
assert ip == "5.6.7.8"
|
||||
|
||||
|
||||
def test_maybe_resolve_geoip_timeout_returns_existing_values(monkeypatch):
|
||||
"""A stalled proxy lookup should not block launch indefinitely."""
|
||||
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": None})})()
|
||||
monkeypatch.setenv("CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS", "0.05")
|
||||
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
|
||||
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=object()):
|
||||
start = time.monotonic()
|
||||
tz, loc, ip = maybe_resolve_geoip(True, "http://203.0.113.10:8080", None, "fr-FR")
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert (tz, loc, ip) == (None, "fr-FR", None)
|
||||
assert elapsed < 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_private_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1296,6 +1296,459 @@ class TestAsyncElementHandle:
|
||||
await browser.close()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 15. Per-call timeout forwarding (issue #137)
|
||||
# =========================================================================
|
||||
|
||||
class TestPerCallTimeoutForwarding:
|
||||
"""page.click('#x', timeout=5000) must forward 5000 to bounding_box(),
|
||||
not silently use the hardcoded 2000ms in scroll."""
|
||||
|
||||
def test_get_element_box_default_timeout(self):
|
||||
"""Default timeout matches Playwright's 30000ms."""
|
||||
from cloakbrowser.human.scroll import _get_element_box
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
page = MagicMock()
|
||||
loc = MagicMock()
|
||||
loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1})
|
||||
page.locator = MagicMock(return_value=MagicMock(first=loc))
|
||||
|
||||
_get_element_box(page, "#x")
|
||||
loc.bounding_box.assert_called_once_with(timeout=30000)
|
||||
|
||||
def test_get_element_box_custom_timeout(self):
|
||||
"""Caller can pass a custom timeout that overrides the default."""
|
||||
from cloakbrowser.human.scroll import _get_element_box
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
page = MagicMock()
|
||||
loc = MagicMock()
|
||||
loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1})
|
||||
page.locator = MagicMock(return_value=MagicMock(first=loc))
|
||||
|
||||
_get_element_box(page, "#x", timeout=5000)
|
||||
loc.bounding_box.assert_called_once_with(timeout=5000)
|
||||
|
||||
def test_scroll_to_element_forwards_timeout(self):
|
||||
"""scroll_to_element passes timeout through to bounding_box()."""
|
||||
from cloakbrowser.human.scroll import scroll_to_element
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
loc = MagicMock()
|
||||
# Already in viewport so we don't actually scroll — just verify
|
||||
# the timeout was forwarded on the first bounding_box() call.
|
||||
loc.bounding_box = MagicMock(return_value={"x": 100, "y": 200, "width": 50, "height": 30})
|
||||
page.locator = MagicMock(return_value=MagicMock(first=loc))
|
||||
|
||||
raw = MagicMock()
|
||||
scroll_to_element(page, raw, "#x", 0, 0, cfg, timeout=7500)
|
||||
loc.bounding_box.assert_called_with(timeout=7500)
|
||||
|
||||
def test_page_click_forwards_timeout_kwarg(self):
|
||||
"""page.click(selector, timeout=...) reaches scroll_to_element.
|
||||
|
||||
Patches scroll_to_element module-side via monkey-patching the
|
||||
cloakbrowser.human module attribute used by patch_page.
|
||||
"""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", {"idle_between_actions": False})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
|
||||
# Build a minimal page mock
|
||||
page = MagicMock()
|
||||
page.click = MagicMock()
|
||||
page.dblclick = MagicMock()
|
||||
page.hover = MagicMock()
|
||||
page.type = MagicMock()
|
||||
page.fill = MagicMock()
|
||||
page.goto = MagicMock()
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
page.query_selector = MagicMock(return_value=None)
|
||||
page.query_selector_all = MagicMock(return_value=[])
|
||||
page.wait_for_selector = MagicMock(return_value=None)
|
||||
page.main_frame = MagicMock()
|
||||
page.main_frame.child_frames = []
|
||||
|
||||
captured = {}
|
||||
def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000):
|
||||
captured["timeout"] = timeout
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy)
|
||||
|
||||
with patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.click("#slow-button", timeout=5000)
|
||||
|
||||
assert captured.get("timeout") == 5000, f"expected 5000, got {captured}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 16. Per-call human_config override (typing speed customization)
|
||||
# =========================================================================
|
||||
|
||||
class TestPerCallHumanConfigOverride:
|
||||
"""page.type('#email', text, human_config={'typing_delay': 30}) lets users
|
||||
override typing speed (and any other HumanConfig field) on a per-call
|
||||
basis without re-patching the page."""
|
||||
|
||||
def test_merge_config_creates_new_instance(self):
|
||||
from cloakbrowser.human.config import resolve_config, merge_config
|
||||
|
||||
base = resolve_config("default", None)
|
||||
merged = merge_config(base, {"typing_delay": 30})
|
||||
|
||||
assert merged.typing_delay == 30
|
||||
assert base.typing_delay != 30 # not mutated
|
||||
# Non-overridden fields are preserved
|
||||
assert merged.mouse_min_steps == base.mouse_min_steps
|
||||
|
||||
def test_merge_config_none_returns_base(self):
|
||||
from cloakbrowser.human.config import resolve_config, merge_config
|
||||
|
||||
base = resolve_config("default", None)
|
||||
merged = merge_config(base, None)
|
||||
assert merged is base
|
||||
|
||||
def test_merge_config_ignores_unknown_keys(self):
|
||||
from cloakbrowser.human.config import resolve_config, merge_config
|
||||
|
||||
base = resolve_config("default", None)
|
||||
# ``not_a_real_field`` is silently dropped — callers shouldn't crash
|
||||
# if they pass typos or future field names.
|
||||
merged = merge_config(base, {"typing_delay": 30, "not_a_real_field": 99})
|
||||
assert merged.typing_delay == 30
|
||||
|
||||
def test_page_type_uses_per_call_typing_delay(self):
|
||||
"""page.type(..., human_config={'typing_delay': 30}) reaches human_type
|
||||
with cfg.typing_delay == 30 even when patch was done with default 70."""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", {
|
||||
"idle_between_actions": False,
|
||||
"field_switch_delay": (0, 1),
|
||||
})
|
||||
assert cfg.typing_delay == 70 # baseline
|
||||
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
|
||||
page = MagicMock()
|
||||
page.click = MagicMock()
|
||||
page.dblclick = MagicMock()
|
||||
page.hover = MagicMock()
|
||||
page.type = MagicMock()
|
||||
page.fill = MagicMock()
|
||||
page.goto = MagicMock()
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
page.query_selector = MagicMock(return_value=None)
|
||||
page.query_selector_all = MagicMock(return_value=[])
|
||||
page.wait_for_selector = MagicMock(return_value=None)
|
||||
page.main_frame = MagicMock()
|
||||
page.main_frame.child_frames = []
|
||||
|
||||
captured = {}
|
||||
def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
|
||||
captured["typing_delay"] = cfg_arg.typing_delay
|
||||
captured["mistype_chance"] = cfg_arg.mistype_chance
|
||||
|
||||
def fake_scroll(*args, **kwargs):
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type), \
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.type(
|
||||
"#email", "hi",
|
||||
human_config={"typing_delay": 30, "mistype_chance": 0},
|
||||
)
|
||||
|
||||
assert captured["typing_delay"] == 30
|
||||
assert captured["mistype_chance"] == 0
|
||||
# Global cfg untouched — per-call override doesn't leak
|
||||
assert cfg.typing_delay == 70
|
||||
|
||||
def test_page_fill_uses_per_call_typing_delay(self):
|
||||
"""Same as type, but for fill (which also clears the field first)."""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", {
|
||||
"idle_between_actions": False,
|
||||
"field_switch_delay": (0, 1),
|
||||
})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
page.query_selector = MagicMock(return_value=None)
|
||||
page.query_selector_all = MagicMock(return_value=[])
|
||||
page.wait_for_selector = MagicMock(return_value=None)
|
||||
page.main_frame = MagicMock()
|
||||
page.main_frame.child_frames = []
|
||||
|
||||
captured = {}
|
||||
def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
|
||||
captured["typing_delay"] = cfg_arg.typing_delay
|
||||
|
||||
def fake_scroll(*args, **kwargs):
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type), \
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.fill("#password", "secret", human_config={"typing_delay": 150})
|
||||
|
||||
assert captured["typing_delay"] == 150
|
||||
|
||||
def test_element_handle_type_uses_per_call_human_config(self):
|
||||
"""el.type(text, human_config={...}) merges per-call overrides on the
|
||||
ElementHandle path (which doesn't go through page.type)."""
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
import cloakbrowser.human as h
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", {
|
||||
"idle_between_actions": False,
|
||||
"field_switch_delay": (0, 1),
|
||||
})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 50
|
||||
cursor.y = 50
|
||||
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page._original = MagicMock()
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(
|
||||
return_value={"x": 200, "y": 200, "width": 100, "height": 30}
|
||||
)
|
||||
el.evaluate = MagicMock(return_value=True)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
el.wait_for_selector = MagicMock(return_value=None)
|
||||
el.scroll_into_view_if_needed = MagicMock()
|
||||
|
||||
raw_mouse = MagicMock()
|
||||
raw_keyboard = MagicMock()
|
||||
|
||||
captured = {}
|
||||
def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
|
||||
captured["typing_delay"] = cfg_arg.typing_delay
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type):
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard,
|
||||
page._original, None, None,
|
||||
)
|
||||
el.type("abc", human_config={"typing_delay": 25})
|
||||
|
||||
assert captured["typing_delay"] == 25
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 17. scroll_into_view_if_needed humanization
|
||||
# =========================================================================
|
||||
|
||||
class TestScrollIntoViewIfNeeded:
|
||||
"""scroll_into_view_if_needed should run through the same
|
||||
accelerate → cruise → decelerate → overshoot wheel sequence as page.click—
|
||||
not Playwright's instant-snap default."""
|
||||
|
||||
def test_human_scroll_into_view_skips_when_in_viewport(self):
|
||||
"""Already-visible elements: no wheel events, just return."""
|
||||
from cloakbrowser.human.scroll import human_scroll_into_view
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
raw = MagicMock()
|
||||
# Box is dead-center of viewport — squarely in scroll_target_zone
|
||||
in_view_box = {"x": 200, "y": 300, "width": 50, "height": 30}
|
||||
|
||||
box, cx, cy = human_scroll_into_view(
|
||||
page, raw, lambda: in_view_box, 0, 0, cfg,
|
||||
)
|
||||
assert box == in_view_box
|
||||
assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events"
|
||||
|
||||
def test_human_scroll_into_view_scrolls_when_below_fold(self):
|
||||
"""Below-fold elements: wheel events fire, eventually box becomes visible."""
|
||||
from cloakbrowser.human.scroll import human_scroll_into_view
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {
|
||||
"scroll_overshoot_chance": 0, # deterministic
|
||||
"scroll_pre_move_delay": (0, 1),
|
||||
"scroll_pause_fast": (0, 1),
|
||||
"scroll_pause_slow": (0, 1),
|
||||
"scroll_settle_delay": (0, 1),
|
||||
})
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
raw = MagicMock()
|
||||
|
||||
# First box is far below the fold; subsequent boxes "come into view"
|
||||
# so the loop terminates after a few wheel bursts.
|
||||
boxes = [
|
||||
{"x": 200, "y": 2000, "width": 50, "height": 30},
|
||||
{"x": 200, "y": 1500, "width": 50, "height": 30},
|
||||
{"x": 200, "y": 1000, "width": 50, "height": 30},
|
||||
{"x": 200, "y": 400, "width": 50, "height": 30}, # in viewport
|
||||
{"x": 200, "y": 400, "width": 50, "height": 30},
|
||||
{"x": 200, "y": 400, "width": 50, "height": 30},
|
||||
]
|
||||
idx = {"i": 0}
|
||||
def get_box():
|
||||
i = min(idx["i"], len(boxes) - 1)
|
||||
idx["i"] += 1
|
||||
return boxes[i]
|
||||
|
||||
human_scroll_into_view(page, raw, get_box, 0, 0, cfg)
|
||||
assert raw.wheel.called, "Below-fold scroll should produce wheel events"
|
||||
|
||||
def test_element_handle_scroll_into_view_if_needed_humanized(self):
|
||||
"""el.scroll_into_view_if_needed() routes through human_scroll_into_view."""
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
import cloakbrowser.human as h
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 50
|
||||
cursor.y = 50
|
||||
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page._original = MagicMock()
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(
|
||||
return_value={"x": 200, "y": 200, "width": 50, "height": 30}
|
||||
)
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
el.wait_for_selector = MagicMock(return_value=None)
|
||||
# Make sure the original method exists so the patch is wired up
|
||||
el.scroll_into_view_if_needed = MagicMock()
|
||||
|
||||
called = {"count": 0}
|
||||
def fake(*args, **kwargs):
|
||||
called["count"] += 1
|
||||
return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100)
|
||||
|
||||
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, MagicMock(), MagicMock(),
|
||||
page._original, None, None,
|
||||
)
|
||||
# Patched method should now invoke our humanized helper
|
||||
el.scroll_into_view_if_needed()
|
||||
|
||||
assert called["count"] >= 1, "humanized scroll helper was never called"
|
||||
|
||||
def test_locator_scroll_into_view_if_needed_humanized(self):
|
||||
"""Locator.scroll_into_view_if_needed() also goes through humanized scroll."""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Patch Locator class fresh
|
||||
_ensure_locator_patched()
|
||||
|
||||
from playwright.sync_api._generated import Locator
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
cursor.x = 50
|
||||
cursor.y = 50
|
||||
cursor.initialized = True
|
||||
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page._human_cfg = cfg
|
||||
page._human_cursor = cursor
|
||||
page._human_raw_mouse = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
|
||||
# Build a Locator-like object satisfying the patched method
|
||||
loc = MagicMock(spec=Locator)
|
||||
loc.page = page
|
||||
impl_obj = MagicMock()
|
||||
impl_obj._selector = "#x"
|
||||
loc._impl_obj = impl_obj
|
||||
loc.bounding_box = MagicMock(
|
||||
return_value={"x": 100, "y": 100, "width": 50, "height": 30}
|
||||
)
|
||||
|
||||
called = {"count": 0, "cfg": None}
|
||||
def fake(*args, **kwargs):
|
||||
called["count"] += 1
|
||||
# cfg is the 6th positional arg (page, raw, get_box, cx, cy, cfg)
|
||||
called["cfg"] = args[5] if len(args) >= 6 else kwargs.get("cfg")
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200)
|
||||
|
||||
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||
Locator.scroll_into_view_if_needed(
|
||||
loc, human_config={"scroll_overshoot_chance": 0.5},
|
||||
)
|
||||
|
||||
assert called["count"] == 1
|
||||
# Per-call override merged into the cfg passed downstream
|
||||
assert called["cfg"].scroll_overshoot_chance == 0.5
|
||||
# Cursor was updated from the helper's return value
|
||||
assert cursor.x == 200 and cursor.y == 200
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Direct runner (backwards compat)
|
||||
# =========================================================================
|
||||
|
||||
@@ -284,6 +284,42 @@ class TestResolveProxyConfig:
|
||||
_, args = _resolve_proxy_config("socks5://user:pass%3D123@host:1080")
|
||||
assert args == ["--proxy-server=socks5://user:pass%3D123@host:1080"]
|
||||
|
||||
def test_socks5_string_logs_info_when_reencoding(self, caplog):
|
||||
# When wrapper actually rewrites the URL (e.g. unencoded '=' in pwd),
|
||||
# surface an INFO log so users debugging SOCKS5 connectivity (#157)
|
||||
# can see what the wrapper did instead of being silently surprised.
|
||||
import logging
|
||||
with caplog.at_level(logging.INFO, logger="cloakbrowser"):
|
||||
_resolve_proxy_config("socks5://user:pass=123@host:1080")
|
||||
assert any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records)
|
||||
# Credentials must not leak into the log.
|
||||
for r in caplog.records:
|
||||
assert "pass=123" not in r.message
|
||||
assert "pass%3D123" not in r.message
|
||||
|
||||
def test_socks5_string_silent_when_already_encoded(self, caplog):
|
||||
# Idempotent path: pre-encoded URL produces no log noise.
|
||||
import logging
|
||||
with caplog.at_level(logging.INFO, logger="cloakbrowser"):
|
||||
_resolve_proxy_config("socks5://user:pass%3D123@host:1080")
|
||||
assert not any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records)
|
||||
|
||||
def test_socks5_string_silent_when_no_credentials(self, caplog):
|
||||
# No userinfo at all → no encoding work → no log.
|
||||
import logging
|
||||
with caplog.at_level(logging.INFO, logger="cloakbrowser"):
|
||||
_resolve_proxy_config("socks5://host:1080")
|
||||
assert not any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records)
|
||||
|
||||
def test_socks5_string_silent_when_only_cosmetic_change(self, caplog):
|
||||
# urlparse lowercases scheme and hostname, but credentials are
|
||||
# untouched. The log must NOT fire for these cosmetic-only rewrites
|
||||
# (regression for Copilot's review on PR #209).
|
||||
import logging
|
||||
with caplog.at_level(logging.INFO, logger="cloakbrowser"):
|
||||
_resolve_proxy_config("socks5://USER:pass@HOST.com:1080")
|
||||
assert not any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records)
|
||||
|
||||
def test_socks5_string_no_creds_unchanged(self):
|
||||
_, args = _resolve_proxy_config("socks5://host:1080")
|
||||
assert args == ["--proxy-server=socks5://host:1080"]
|
||||
|
||||
Reference in New Issue
Block a user