Compare commits

..
14 Commits
Author SHA1 Message Date
CloakHQ db0b5f1946 release: v0.3.28 — cloakserve path traversal fix, GeoIP timeout guard, humanize iframe scope 2026-05-11 21:43:14 +02:00
CloakHQ babef04e07 fix(cloakserve): sanitize fingerprint seed to prevent path traversal (#217)
Validate seed format with strict regex, add path containment check
before rmtree, and bind to 127.0.0.1 by default on bare metal.
2026-05-11 21:36:09 +02:00
CloakHQ f8026a7b39 chore: clean up GeoIP timeout follow-up (#213)
Remove dead null checks, document CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS
env var, credit contributor.

Fix review findings:
- Use timeout-bounded resolve_proxy_exit_ip in _resolve_webrtc_args
- Add missing timeout handler on tunneled HTTPS request in JS
- Reject nan/inf in Python timeout parsing (parity with JS)
- Recompute deadline after CONNECT succeeds in JS proxy tunnel
2026-05-11 21:15:36 +02:00
manaskarraandGitHub 71f57d00d1 fix: bound GeoIP resolution so launch cannot hang (#213)
* Fix geoip resolution timeout

* fix: keep GeoIP timeout inside resolution path
2026-05-11 20:55:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e9735392e8 chore(deps): bump sigstore/cosign-installer in the actions group (#214)
Bumps the actions group with 1 update: [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer).


Updates `sigstore/cosign-installer` from 4.1.1 to 4.1.2
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003...6f9f17788090df1f26f669e9d70d6ae9567deba6)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 20:55:41 +02:00
CloakHQ 0d41a4f023 refactor(js): extract HumanActionOptions type, fix frame check/uncheck error handling, align SOCKS5 log level
- Extract HumanActionOptions type alias to replace ~40 inline copies
- Frame check/uncheck: let isChecked errors propagate instead of silently clicking non-checkbox elements
- SOCKS5 credential log: console.debug → console.info (parity with Python logger.info)
- Add contributors to README
2026-05-11 00:23:31 +02:00
EternalandCloakHQ 80d9f7c14e feat(js): add types to humanized method options (#205)
Replace `any` with proper TypeScript types on all humanized method options
(Playwright, Puppeteer, ElementHandle, Frame). Support flat per-call config
overrides alongside existing `human_config` style. Internalize `humanIdle`
duration computation with backward-compatible overloads.

Co-authored-by: Eternal <chasezou09@gmail.com>
2026-05-10 23:50:01 +02:00
114b3c826b fix(js): preserve iframe scope in humanized frame actions (#201)
fix(js): preserve iframe scope in humanized frame actions

Frame actions (click, type, fill, etc.) now resolve selectors through frame.locator() instead of delegating to page.* methods, fixing iframe-scoped interactions.

Closes #184

Co-authored-by: manaskarra <manas.karra@gmail.com>
2026-05-10 18:13:29 +02:00
YouhaiandGitHub c07c2b6b4a fix(proxy): log when SOCKS5 credential auto-encoding rewrites URL (#157) (#209)
* fix(proxy): log when SOCKS5 credential auto-encoding rewrites URL (#157)

Auto URL-encoding of SOCKS5 credentials (added in v0.3.26 to fix Chromium's
'=' truncation bug) currently happens silently. Users debugging connectivity
have no way to know the wrapper rewrote their proxy URL — the original #157
thread took 8 round-trips to surface this exact ambiguity.

Emit a log when re-encoding actually changes the URL: INFO on Python's
'cloakbrowser' logger, console.debug in JavaScript. Stays silent on
already-encoded inputs and credential-less URLs to avoid false-positive
noise. Credentials are not included in the log message.

Tests: 3 new cases per language (Python caplog, JS vi.spyOn console.debug)
covering trigger / silent-when-encoded / silent-when-no-creds.

* fix(proxy): gate log on credential change, not full URL diff

Per Copilot review on #209: urlparse cosmetically lowercases scheme and
hostname, so comparing the full reconstructed URL to the input would emit
"Auto URL-encoded SOCKS5..." even for inputs like
`socks5://USER:pass@HOST.com:1080` where no credential encoding happened.

Compare raw vs encoded user/password substrings instead. Mirror the same
condition in JS for parity (JS's manual parser preserves case today, but the
credential-level compare is more robust against future changes).

Adds one regression test per language.
2026-05-10 18:09:46 +02:00
CloakHQ 13b1b98b68 fix(js): bump playwright-core minimum to >=1.53.0 (#200)
playwright-core <=1.52.0 injects __pwInitScripts into window, which
deviceandbrowserinfo.com detects as isPlaywright:true. Fixed in 1.53.0.
2026-05-07 17:31:07 +02:00
CloakHQ 0d6ce76b1d release: v0.3.27 — per-call human_config, humanize timeout fix, scrollIntoViewIfNeeded 2026-05-06 18:32:41 +02:00
CloakHQ f01902025a fix: align humanize timeout default with Playwright's 30s auto-retry (#172)
The humanize layer hardcoded timeout=2000ms for element lookups, causing
locator.click() and page.click() to fail instantly instead of retrying
for 30s like standard Playwright. Aligned all defaults to 30000ms across
Python sync/async, JS Playwright, and JS Puppeteer paths. Bumped the
outer retry sleep from 200ms to 500ms for DOM mutation settle time.
2026-05-01 20:48:06 +02:00
CloakHQ 2df8c7e2d1 fix(js): correct issue references #137#172 in humanize comments 2026-04-28 20:37:09 +02:00
lilosandGitHub 661b873dad feat: per-call human_config, timeout forwarding, humanized scrollIntoViewIfNeeded (#183) 2026-04-28 20:34:17 +02:00
31 changed files with 2578 additions and 521 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ jobs:
cloakhq/cloakbrowser:latest cloakhq/cloakbrowser:latest
provenance: true provenance: true
sbom: true sbom: true
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign image - name: Sign image
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }} run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
- name: Attest build provenance - name: Attest build provenance
+17
View File
@@ -8,6 +8,23 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [Unreleased] ## [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 ## [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.180.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 - **[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.180.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
+4 -1
View File
@@ -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_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks | | `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_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 ## 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 - [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts - [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
- [@kitiho](https://github.com/kitiho) — null viewport fix - [@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 - [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration
+31 -6
View File
@@ -22,6 +22,7 @@ import json
import logging import logging
import os import os
import random import random
import re
import shutil import shutil
import socket import socket
import subprocess import subprocess
@@ -61,6 +62,9 @@ BASE_CHROME_ARGS = [
BASE_CDP_PORT = 5100 BASE_CDP_PORT = 5100
SAFE_SEED_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
RESERVED_SEEDS = {"__default__"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ChromeProcess — one running Chrome instance # ChromeProcess — one running Chrome instance
@@ -111,6 +115,14 @@ class ChromePool:
self._locks[seed] = asyncio.Lock() self._locks[seed] = asyncio.Lock()
return self._locks[seed] 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: def _allocate_port(self) -> int:
"""Find a free port starting from _next_port.""" """Find a free port starting from _next_port."""
for _ in range(100): for _ in range(100):
@@ -159,6 +171,11 @@ class ChromePool:
seed_key = "__default__" seed_key = "__default__"
actual_seed = str(random.randint(10000, 99999)) actual_seed = str(random.randint(10000, 99999))
else: 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 seed_key = seed
actual_seed = seed actual_seed = seed
@@ -232,7 +249,7 @@ class ChromePool:
if not await self._wait_for_cdp(port): if not await self._wait_for_cdp(port):
process.kill() process.kill()
await asyncio.to_thread(process.wait, timeout=5) 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( raise web.HTTPBadGateway(
text=json.dumps({"error": "Chrome failed to start"}), text=json.dumps({"error": "Chrome failed to start"}),
content_type="application/json", content_type="application/json",
@@ -266,8 +283,7 @@ class ChromePool:
await asyncio.to_thread(proc.process.wait, timeout=5) await asyncio.to_thread(proc.process.wait, timeout=5)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
proc.process.kill() proc.process.kill()
# Clean up user data dir (can be slow for large profiles) await asyncio.to_thread(self._safe_rmtree, proc.user_data_dir)
await asyncio.to_thread(shutil.rmtree, proc.user_data_dir, True)
if self._default is proc: if self._default is proc:
self._default = None self._default = None
self._locks.pop(key, None) self._locks.pop(key, None)
@@ -559,8 +575,8 @@ async def on_shutdown(app: web.Application) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _default_data_dir() -> str: def _default_data_dir() -> str:
"""Smart default: Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve.""" """Smart default: container → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
if os.path.exists("/.dockerenv"): if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return "/tmp/cloakserve" return "/tmp/cloakserve"
return str(Path.home() / ".cloakbrowser" / "cloakserve") return str(Path.home() / ".cloakbrowser" / "cloakserve")
@@ -624,6 +640,13 @@ def main() -> None:
binary = ensure_binary() binary = ensure_binary()
config, global_args = parse_cli_args(sys.argv[1:]) 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( pool = ChromePool(
binary=binary, binary=binary,
global_args=global_args, global_args=global_args,
@@ -662,7 +685,9 @@ def main() -> None:
port, 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__": if __name__ == "__main__":
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.26" __version__ = "0.3.28"
+24 -8
View File
@@ -25,6 +25,7 @@ from .human.config import HumanConfigOverrides, HumanPreset
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation) # Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)
_VIEWPORT_UNSET = object() _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 truncate them at special chars like '='. Idempotent: pre-encoded input stays
the same (decoded then re-encoded). 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 On unparseable input (invalid port, broken IPv6 literal, etc.) logs a
warning and returns the original string — preserves pre-fix pass-through warning and returns the original string — preserves pre-fix pass-through
behavior so Chromium's own error handling kicks in. 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. # urlparse returns None for absent components, "" for present-but-empty.
if parsed.username is None and parsed.password is None: if parsed.username is None and parsed.password is None:
return url 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 # Preserve the colon separator when password component is present, even if
# empty, so `user:@host` stays `user:@host`. # empty, so `user:@host` stays `user:@host`.
if parsed.password is not None: 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: else:
raw_pass = None
enc_pass = None enc_pass = None
return _assemble_socks_url( normalized = _assemble_socks_url(
parsed.scheme, parsed.hostname or "", parsed.port, parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass, enc_user, enc_pass,
parsed.path, parsed.params, parsed.query, parsed.fragment, 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: def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
@@ -874,7 +891,7 @@ def maybe_resolve_geoip(
if not geoip or not proxy: if not geoip or not proxy:
return timezone, locale, None 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) proxy_url = _extract_proxy_url(proxy)
if not proxy_url: if not proxy_url:
@@ -882,8 +899,7 @@ def maybe_resolve_geoip(
# When both tz/locale are explicit, still resolve exit IP for WebRTC # When both tz/locale are explicit, still resolve exit IP for WebRTC
if timezone is not None and locale is not None: if timezone is not None and locale is not None:
from .geoip import _resolve_exit_ip exit_ip = resolve_proxy_exit_ip(proxy_url)
exit_ip = _resolve_exit_ip(proxy_url)
return timezone, locale, exit_ip return timezone, locale, exit_ip
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url) geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
@@ -918,8 +934,8 @@ def _resolve_webrtc_args(
del args[idx] del args[idx]
return args return args
try: try:
from .geoip import _resolve_exit_ip from .geoip import resolve_proxy_exit_ip
exit_ip = _resolve_exit_ip(proxy_url) exit_ip = resolve_proxy_exit_ip(proxy_url)
except Exception: except Exception:
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto") logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args) args = list(args)
-1
View File
@@ -60,7 +60,6 @@ def _show_welcome() -> None:
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n") sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n") sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
sys.stderr.write("\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(" Donate? https://ko-fi.com/cloakhq\n")
sys.stderr.write(" Star us if CloakBrowser helps your project!\n") sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
sys.stderr.write("\n") sys.stderr.write("\n")
+65 -5
View File
@@ -12,6 +12,8 @@ from __future__ import annotations
import ipaddress import ipaddress
import logging import logging
import math
import os
import socket import socket
import tempfile import tempfile
import threading import threading
@@ -27,6 +29,8 @@ GEOIP_DB_URL = (
) )
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb" GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days 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 ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
COUNTRY_LOCALE_MAP: dict[str, str] = { COUNTRY_LOCALE_MAP: dict[str, str] = {
@@ -77,11 +81,16 @@ def resolve_proxy_geo_with_ip(
if db_path is None: if db_path is None:
return None, None, 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 # Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
ip = _resolve_exit_ip(proxy_url) ip = _resolve_exit_ip(proxy_url, timeout=_remaining_seconds(deadline))
if ip is None: if ip is None and not _deadline_expired(deadline):
ip = _resolve_proxy_ip(proxy_url) 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 return None, None, None
try: 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.""" """Discover the proxy's actual exit IP by connecting through it."""
import httpx import httpx
deadline = _deadline_from_timeout(timeout or 0)
for url in _IP_ECHO_URLS: for url in _IP_ECHO_URLS:
try: 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() resp.raise_for_status()
ip = resp.text.strip() ip = resp.text.strip()
# Validate it looks like an IP # Validate it looks like an IP
+304 -95
View File
@@ -18,23 +18,23 @@ import logging
import sys import sys
from typing import Any, Optional 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 .config import rand, rand_range, sleep_ms, async_sleep_ms
from .mouse import RawMouse, human_move, human_click, click_target, human_idle from .mouse import RawMouse, human_move, human_click, click_target, human_idle
from .keyboard import RawKeyboard, human_type 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 .mouse_async import AsyncRawMouse, async_human_move, async_human_click, async_human_idle
from .keyboard_async import AsyncRawKeyboard, async_human_type 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" _SELECT_ALL = "Meta+a" if sys.platform == "darwin" else "Control+a"
__all__ = [ __all__ = [
"patch_browser", "patch_context", "patch_page", "patch_browser", "patch_context", "patch_page",
"patch_browser_async", "patch_context_async", "patch_page_async", "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_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") logger = logging.getLogger("cloakbrowser.human")
@@ -356,6 +356,7 @@ def _patch_locator_class_sync():
_orig_tap = Locator.tap _orig_tap = Locator.tap
_orig_drag_to = Locator.drag_to _orig_drag_to = Locator.drag_to
_orig_clear = Locator.clear _orig_clear = Locator.clear
_orig_scroll_into_view = getattr(Locator, 'scroll_into_view_if_needed', None)
def _get_selector(self): def _get_selector(self):
return self._impl_obj._selector return self._impl_obj._selector
@@ -366,36 +367,76 @@ def _patch_locator_class_sync():
def _get_cfg(self): def _get_cfg(self):
return getattr(self.page, '_human_cfg', None) 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): def _humanized_fill(self, value, **kwargs):
if _is_humanized(self): if _is_humanized(self):
self.page.fill(_get_selector(self), value) self.page.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
else: else:
_orig_fill(self, value, **kwargs) _orig_fill(self, value, **kwargs)
def _humanized_click(self, **kwargs): def _humanized_click(self, **kwargs):
if _is_humanized(self): if _is_humanized(self):
self.page.click(_get_selector(self)) self.page.click(_get_selector(self), **_forward_kwargs(kwargs))
else: else:
_orig_click(self, **kwargs) _orig_click(self, **kwargs)
def _humanized_type(self, text, **kwargs): def _humanized_type(self, text, **kwargs):
if _is_humanized(self): if _is_humanized(self):
self.page.type(_get_selector(self), text) self.page.type(_get_selector(self), text, **_forward_kwargs(kwargs))
else: else:
_orig_type(self, text, **kwargs) _orig_type(self, text, **kwargs)
def _humanized_dblclick(self, **kwargs): def _humanized_dblclick(self, **kwargs):
if _is_humanized(self): if _is_humanized(self):
self.page.dblclick(_get_selector(self)) self.page.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
else: else:
_orig_dblclick(self, **kwargs) _orig_dblclick(self, **kwargs)
def _humanized_hover(self, **kwargs): def _humanized_hover(self, **kwargs):
if _is_humanized(self): if _is_humanized(self):
self.page.hover(_get_selector(self)) self.page.hover(_get_selector(self), **_forward_kwargs(kwargs))
else: else:
_orig_hover(self, **kwargs) _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): def _humanized_check(self, **kwargs):
if _is_humanized(self): if _is_humanized(self):
cfg = _get_cfg(self) cfg = _get_cfg(self)
@@ -512,6 +553,8 @@ def _patch_locator_class_sync():
Locator.tap = _humanized_tap Locator.tap = _humanized_tap
Locator.drag_to = _humanized_drag_to Locator.drag_to = _humanized_drag_to
Locator.clear = _humanized_clear 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_tap = AsyncLocator.tap
_orig_drag_to = AsyncLocator.drag_to _orig_drag_to = AsyncLocator.drag_to
_orig_clear = AsyncLocator.clear _orig_clear = AsyncLocator.clear
_orig_scroll_into_view = getattr(AsyncLocator, 'scroll_into_view_if_needed', None)
def _get_selector(self): def _get_selector(self):
return self._impl_obj._selector return self._impl_obj._selector
@@ -554,36 +598,74 @@ def _patch_locator_class_async():
def _get_cfg(self): def _get_cfg(self):
return getattr(self.page, '_human_cfg', None) 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): async def _humanized_fill(self, value, **kwargs):
if _is_humanized(self): if _is_humanized(self):
await self.page.fill(_get_selector(self), value) await self.page.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
else: else:
await _orig_fill(self, value, **kwargs) await _orig_fill(self, value, **kwargs)
async def _humanized_click(self, **kwargs): async def _humanized_click(self, **kwargs):
if _is_humanized(self): if _is_humanized(self):
await self.page.click(_get_selector(self)) await self.page.click(_get_selector(self), **_forward_kwargs(kwargs))
else: else:
await _orig_click(self, **kwargs) await _orig_click(self, **kwargs)
async def _humanized_type(self, text, **kwargs): async def _humanized_type(self, text, **kwargs):
if _is_humanized(self): if _is_humanized(self):
await self.page.type(_get_selector(self), text) await self.page.type(_get_selector(self), text, **_forward_kwargs(kwargs))
else: else:
await _orig_type(self, text, **kwargs) await _orig_type(self, text, **kwargs)
async def _humanized_dblclick(self, **kwargs): async def _humanized_dblclick(self, **kwargs):
if _is_humanized(self): if _is_humanized(self):
await self.page.dblclick(_get_selector(self)) await self.page.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
else: else:
await _orig_dblclick(self, **kwargs) await _orig_dblclick(self, **kwargs)
async def _humanized_hover(self, **kwargs): async def _humanized_hover(self, **kwargs):
if _is_humanized(self): if _is_humanized(self):
await self.page.hover(_get_selector(self)) await self.page.hover(_get_selector(self), **_forward_kwargs(kwargs))
else: else:
await _orig_hover(self, **kwargs) 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): async def _humanized_check(self, **kwargs):
if _is_humanized(self): if _is_humanized(self):
cfg = _get_cfg(self) cfg = _get_cfg(self)
@@ -708,6 +790,8 @@ def _patch_locator_class_async():
AsyncLocator.tap = _humanized_tap AsyncLocator.tap = _humanized_tap
AsyncLocator.drag_to = _humanized_drag_to AsyncLocator.drag_to = _humanized_drag_to
AsyncLocator.clear = _humanized_clear 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._original = originals
page._human_cfg = cfg page._human_cfg = cfg
page._human_cursor = cursor
# --- Stealth infrastructure --- # --- Stealth infrastructure ---
try: try:
@@ -764,6 +849,8 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
"insert_text": originals.keyboard_insert_text, "insert_text": originals.keyboard_insert_text,
})() })()
page._human_raw_mouse = raw_mouse
def _ensure_cursor_init() -> None: def _ensure_cursor_init() -> None:
if not cursor.initialized: if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]) 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: def _human_click(selector: str, **kwargs: Any) -> None:
_ensure_cursor_init() _ensure_cursor_init()
if cfg.idle_between_actions: call_cfg = merge_config(cfg, kwargs.get("human_config"))
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg) 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( 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.x = cx
cursor.y = cy cursor.y = cy
is_input = _is_input_element(page, selector) is_input = _is_input_element(page, selector)
target = click_target(box, is_input, cfg) target = click_target(box, is_input, 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.x = target.x
cursor.y = target.y 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: def _human_dblclick(selector: str, **kwargs: Any) -> None:
_ensure_cursor_init() _ensure_cursor_init()
if cfg.idle_between_actions: call_cfg = merge_config(cfg, kwargs.get("human_config"))
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg) 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( 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.x = cx
cursor.y = cy cursor.y = cy
is_input = _is_input_element(page, selector) is_input = _is_input_element(page, selector)
target = click_target(box, is_input, cfg) target = click_target(box, is_input, 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.x = target.x
cursor.y = target.y cursor.y = target.y
raw_mouse.down(click_count=2) 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: def _human_hover(selector: str, **kwargs: Any) -> None:
_ensure_cursor_init() _ensure_cursor_init()
if cfg.idle_between_actions: call_cfg = merge_config(cfg, kwargs.get("human_config"))
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg) 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( 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.x = cx
cursor.y = cy cursor.y = cy
target = click_target(box, False, cfg) target = click_target(box, False, 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.x = target.x
cursor.y = target.y cursor.y = target.y
def _human_type(selector: str, text: str, **kwargs: Any) -> None: def _human_type(selector: str, text: str, **kwargs: Any) -> None:
sleep_ms(rand_range(cfg.field_switch_delay)) call_cfg = merge_config(cfg, kwargs.get("human_config"))
_human_click(selector) 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)) 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: def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
sleep_ms(rand_range(cfg.field_switch_delay)) call_cfg = merge_config(cfg, kwargs.get("human_config"))
_human_click(selector) sleep_ms(rand_range(call_cfg.field_switch_delay))
_human_click(selector, **kwargs)
sleep_ms(rand(100, 250)) sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL) originals.keyboard_press(_SELECT_ALL)
sleep_ms(rand(30, 80)) sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace") originals.keyboard_press("Backspace")
sleep_ms(rand(50, 150)) 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: def _human_check(selector: str, **kwargs: Any) -> None:
try: try:
@@ -958,6 +1055,7 @@ def _patch_single_element_handle_sync(
_orig_set_checked = getattr(el, 'set_checked', None) _orig_set_checked = getattr(el, 'set_checked', None)
_orig_tap = el.tap _orig_tap = el.tap
_orig_focus = el.focus _orig_focus = el.focus
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
# Nested selectors # Nested selectors
_orig_qs = el.query_selector _orig_qs = el.query_selector
@@ -992,39 +1090,57 @@ def _patch_single_element_handle_sync(
el.query_selector_all = _patched_qsa el.query_selector_all = _patched_qsa
el.wait_for_selector = _patched_wfs el.wait_for_selector = _patched_wfs
# Helper: move cursor to element # Helper: move cursor to element. Accepts optional ``call_cfg`` so per-call
def _move_to_element(): # ``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: if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]) cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]) cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
originals.mouse_move(cursor.x, cursor.y) originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True 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() box = el.bounding_box()
if not box: if not box:
return None return None
is_inp = _is_input_element_handle_sync(el) 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: if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg) 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.x = target.x
cursor.y = target.y cursor.y = target.y
return {'box': box, 'is_inp': is_inp} return {'box': box, 'is_inp': is_inp}
# --- el.click() --- # --- el.click() ---
def _human_el_click(**kwargs: Any) -> None: 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: if info is None:
return _orig_click(**kwargs) return _orig_click(**kwargs)
human_click(raw_mouse, info['is_inp'], cfg) human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() --- # --- el.dblclick() ---
def _human_el_dblclick(**kwargs: Any) -> None: 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: if info is None:
return _orig_dblclick(**kwargs) return _orig_dblclick(**kwargs)
raw_mouse.down(click_count=2) raw_mouse.down(click_count=2)
@@ -1033,32 +1149,62 @@ def _patch_single_element_handle_sync(
# --- el.hover() --- # --- el.hover() ---
def _human_el_hover(**kwargs: Any) -> None: 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: if info is None:
return _orig_hover(**kwargs) return _orig_hover(**kwargs)
# Just move, no click # Just move, no click
# --- el.type() --- # --- el.type() ---
def _human_el_type(text: str, **kwargs: Any) -> None: 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: if info is None:
return _orig_type(text, **kwargs) 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)) 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() --- # --- el.fill() ---
def _human_el_fill(value: str, **kwargs: Any) -> None: 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: if info is None:
return _orig_fill(value, **kwargs) 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)) sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL) originals.keyboard_press(_SELECT_ALL)
sleep_ms(rand(30, 80)) sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace") originals.keyboard_press("Backspace")
sleep_ms(rand(50, 150)) 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() --- # --- el.press() ---
def _human_el_press(key: str, **kwargs: Any) -> None: 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.set_checked = _human_el_set_checked
el.tap = _human_el_tap el.tap = _human_el_tap
el.focus = _human_el_focus 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( 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._original = originals
page._human_cfg = cfg page._human_cfg = cfg
page._human_cursor = cursor
# --- Stealth infrastructure (lazy-initialized, async) --- # --- Stealth infrastructure (lazy-initialized, async) ---
stealth = _AsyncIsolatedWorld(page) stealth = _AsyncIsolatedWorld(page)
@@ -1454,6 +1603,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
"insert_text": originals.keyboard_insert_text, "insert_text": originals.keyboard_insert_text,
})() })()
page._human_raw_mouse = raw_mouse
async def _ensure_cursor_init() -> None: async def _ensure_cursor_init() -> None:
if not cursor.initialized: if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]) 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: async def _human_click(selector: str, **kwargs: Any) -> None:
await _ensure_cursor_init() await _ensure_cursor_init()
if cfg.idle_between_actions: call_cfg = merge_config(cfg, kwargs.get("human_config"))
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg) 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( 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.x = cx
cursor.y = cy cursor.y = cy
is_input = await _async_is_input_element(page, selector) is_input = await _async_is_input_element(page, selector)
target = click_target(box, is_input, cfg) target = click_target(box, is_input, 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.x = target.x
cursor.y = target.y 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: async def _human_dblclick(selector: str, **kwargs: Any) -> None:
await _ensure_cursor_init() await _ensure_cursor_init()
if cfg.idle_between_actions: call_cfg = merge_config(cfg, kwargs.get("human_config"))
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg) 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( 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.x = cx
cursor.y = cy cursor.y = cy
is_input = await _async_is_input_element(page, selector) is_input = await _async_is_input_element(page, selector)
target = click_target(box, is_input, cfg) target = click_target(box, is_input, 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.x = target.x
cursor.y = target.y cursor.y = target.y
await raw_mouse.down(click_count=2) 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: async def _human_hover(selector: str, **kwargs: Any) -> None:
await _ensure_cursor_init() await _ensure_cursor_init()
if cfg.idle_between_actions: call_cfg = merge_config(cfg, kwargs.get("human_config"))
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg) 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( 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.x = cx
cursor.y = cy cursor.y = cy
target = click_target(box, False, cfg) target = click_target(box, False, 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.x = target.x
cursor.y = target.y cursor.y = target.y
async def _human_type(selector: str, text: str, **kwargs: Any) -> None: async def _human_type(selector: str, text: str, **kwargs: Any) -> None:
await async_sleep_ms(rand_range(cfg.field_switch_delay)) call_cfg = merge_config(cfg, kwargs.get("human_config"))
await _human_click(selector) await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
await _human_click(selector, **kwargs)
await async_sleep_ms(rand(100, 250)) await async_sleep_ms(rand(100, 250))
cdp = await _ensure_cdp() 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: async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
await async_sleep_ms(rand_range(cfg.field_switch_delay)) call_cfg = merge_config(cfg, kwargs.get("human_config"))
await _human_click(selector) await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
await _human_click(selector, **kwargs)
await async_sleep_ms(rand(100, 250)) await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL) await originals.keyboard_press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80)) await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace") await originals.keyboard_press("Backspace")
await async_sleep_ms(rand(50, 150)) await async_sleep_ms(rand(50, 150))
cdp = await _ensure_cdp() 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: async def _human_check(selector: str, **kwargs: Any) -> None:
try: try:
@@ -1637,6 +1796,7 @@ def _patch_single_element_handle_async(
_orig_set_checked = getattr(el, 'set_checked', None) _orig_set_checked = getattr(el, 'set_checked', None)
_orig_tap = el.tap _orig_tap = el.tap
_orig_focus = el.focus _orig_focus = el.focus
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
# Nested selectors # Nested selectors
_orig_qs = el.query_selector _orig_qs = el.query_selector
@@ -1671,25 +1831,41 @@ def _patch_single_element_handle_async(
el.query_selector_all = _patched_qsa el.query_selector_all = _patched_qsa
el.wait_for_selector = _patched_wfs el.wait_for_selector = _patched_wfs
# Helper: move cursor to element (async) # Helper: move cursor to element (async). Accepts optional ``call_cfg`` so
async def _move_to_element(): # 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: if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]) cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]) cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
await originals.mouse_move(cursor.x, cursor.y) await originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True 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() box = await el.bounding_box()
if not box: if not box:
return None return None
is_inp = await _async_is_input_element_handle(el) 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: if call_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) 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.x = target.x
cursor.y = target.y cursor.y = target.y
return {'box': box, 'is_inp': is_inp} return {'box': box, 'is_inp': is_inp}
@@ -1704,14 +1880,16 @@ def _patch_single_element_handle_async(
# --- el.click() --- # --- el.click() ---
async def _human_el_click(**kwargs: Any) -> None: 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: if info is None:
return await _orig_click(**kwargs) 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() --- # --- el.dblclick() ---
async def _human_el_dblclick(**kwargs: Any) -> None: 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: if info is None:
return await _orig_dblclick(**kwargs) return await _orig_dblclick(**kwargs)
await raw_mouse.down(click_count=2) await raw_mouse.down(click_count=2)
@@ -1720,33 +1898,62 @@ def _patch_single_element_handle_async(
# --- el.hover() --- # --- el.hover() ---
async def _human_el_hover(**kwargs: Any) -> None: 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: if info is None:
return await _orig_hover(**kwargs) return await _orig_hover(**kwargs)
# --- el.type() --- # --- el.type() ---
async def _human_el_type(text: str, **kwargs: Any) -> None: 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: if info is None:
return await _orig_type(text, **kwargs) 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)) await async_sleep_ms(rand(100, 250))
cdp = await _get_cdp() 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() --- # --- el.fill() ---
async def _human_el_fill(value: str, **kwargs: Any) -> None: 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: if info is None:
return await _orig_fill(value, **kwargs) 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 async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL) await originals.keyboard_press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80)) await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace") await originals.keyboard_press("Backspace")
await async_sleep_ms(rand(50, 150)) await async_sleep_ms(rand(50, 150))
cdp = await _get_cdp() 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() --- # --- el.press() ---
async def _human_el_press(key: str, **kwargs: Any) -> None: 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.set_checked = _human_el_set_checked
el.tap = _human_el_tap el.tap = _human_el_tap
el.focus = _human_el_focus 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( def _patch_page_element_handles_async(
+19
View File
@@ -201,6 +201,25 @@ def resolve_config(
return HumanConfig(**merged) 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 # Utility functions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+44 -13
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import math import math
import random 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 .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
from .mouse import RawMouse, human_move 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 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: try:
el = page.locator(selector).first el = page.locator(selector).first
return el.bounding_box(timeout=2000) return el.bounding_box(timeout=timeout)
except Exception: except Exception:
return None return None
@@ -39,13 +44,21 @@ def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
sleep_ms(rand(8, 20)) sleep_ms(rand(8, 20))
def scroll_to_element( def human_scroll_into_view(
page: Any, page: Any,
raw: RawMouse, raw: RawMouse,
selector: str, get_box: Callable[[], Optional[dict]],
cursor_x: float, cursor_y: float, cursor_x: float, cursor_y: float,
cfg: HumanConfig, cfg: HumanConfig,
) -> Tuple[dict, float, float]: ) -> 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 viewport = page.viewport_size
if not viewport: if not viewport:
raise RuntimeError("Viewport size not available") raise RuntimeError("Viewport size not available")
@@ -53,12 +66,9 @@ def scroll_to_element(
viewport_height = viewport["height"] viewport_height = viewport["height"]
viewport_width = viewport["width"] viewport_width = viewport["width"]
box = _get_element_box(page, selector) box = get_box()
if box is None: if box is None:
sleep_ms(200) raise RuntimeError("Element not found while scrolling into view")
box = _get_element_box(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
if _is_in_viewport(box, viewport_height, cfg): if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y return box, cursor_x, cursor_y
@@ -105,7 +115,7 @@ def scroll_to_element(
# Check visibility every 3 steps # Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1: 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): if box and _is_in_viewport(box, viewport_height, cfg):
break break
if scrolled >= abs_distance * 1.1: if scrolled >= abs_distance * 1.1:
@@ -125,8 +135,29 @@ def scroll_to_element(
# Settle # Settle
sleep_ms(rand_range(cfg.scroll_settle_delay)) sleep_ms(rand_range(cfg.scroll_settle_delay))
box = _get_element_box(page, selector) box = get_box()
if box is None: 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 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,
)
+43 -13
View File
@@ -8,17 +8,22 @@ from __future__ import annotations
import math import math
import random 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 .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
from .mouse_async import AsyncRawMouse, async_human_move from .mouse_async import AsyncRawMouse, async_human_move
from .scroll import _is_in_viewport 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: try:
el = page.locator(selector).first el = page.locator(selector).first
return await el.bounding_box(timeout=2000) return await el.bounding_box(timeout=timeout)
except Exception: except Exception:
return None return None
@@ -36,13 +41,20 @@ async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig)
await async_sleep_ms(rand(8, 20)) await async_sleep_ms(rand(8, 20))
async def async_scroll_to_element( async def async_human_scroll_into_view(
page: Any, page: Any,
raw: AsyncRawMouse, raw: AsyncRawMouse,
selector: str, get_box: Callable[[], Awaitable[Optional[dict]]],
cursor_x: float, cursor_y: float, cursor_x: float, cursor_y: float,
cfg: HumanConfig, cfg: HumanConfig,
) -> Tuple[dict, float, float]: ) -> 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 viewport = page.viewport_size
if not viewport: if not viewport:
raise RuntimeError("Viewport size not available") raise RuntimeError("Viewport size not available")
@@ -50,12 +62,9 @@ async def async_scroll_to_element(
viewport_height = viewport["height"] viewport_height = viewport["height"]
viewport_width = viewport["width"] viewport_width = viewport["width"]
box = await _get_element_box_async(page, selector) box = await get_box()
if box is None: if box is None:
await async_sleep_ms(200) raise RuntimeError("Element not found while scrolling into view")
box = await _get_element_box_async(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
if _is_in_viewport(box, viewport_height, cfg): if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y return box, cursor_x, cursor_y
@@ -102,7 +111,7 @@ async def async_scroll_to_element(
# Check visibility every 3 steps # Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1: 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): if box and _is_in_viewport(box, viewport_height, cfg):
break break
if scrolled >= abs_distance * 1.1: if scrolled >= abs_distance * 1.1:
@@ -122,8 +131,29 @@ async def async_scroll_to_element(
# Settle # Settle
await async_sleep_ms(rand_range(cfg.scroll_settle_delay)) 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: 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 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
View File
@@ -215,7 +215,7 @@ const page = await browser.newPage();
## Requirements ## Requirements
- Node.js >= 20 - Node.js >= 20
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21 - One of: `playwright-core` >= 1.53 or `puppeteer-core` >= 21
## Troubleshooting ## Troubleshooting
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "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.", "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -59,7 +59,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"mmdb-lib": ">=2.0.0", "mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0", "playwright-core": ">=1.53.0",
"puppeteer-core": ">=21.0.0", "puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=10.0.0" "socks-proxy-agent": ">=10.0.0"
}, },
@@ -84,7 +84,7 @@
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2", "mmdb-lib": "^3.0.2",
"socks-proxy-agent": "^10.0.0", "socks-proxy-agent": "^10.0.0",
"playwright-core": "^1.40.0", "playwright-core": "^1.53.0",
"puppeteer-core": "^21.0.0", "puppeteer-core": "^21.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vitest": "^1.0.0" "vitest": "^1.0.0"
+53 -10
View File
@@ -22,6 +22,7 @@ const GEOIP_DB_URL =
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"; "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"; const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
const GEOIP_UPDATE_INTERVAL_MS = 30 * 86_400_000; // 30 days 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). */ /** Country ISO code → BCP 47 locale (covers ~90% of proxy traffic). */
export const COUNTRY_LOCALE_MAP: Record<string, string> = { export const COUNTRY_LOCALE_MAP: Record<string, string> = {
@@ -68,10 +69,18 @@ export async function resolveProxyGeo(
const dbPath = await ensureGeoipDb(); const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: null, exitIp: null }; 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 // Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
let ip = await resolveExitIp(proxyUrl); let ip = await resolveExitIp(proxyUrl, remainingMs(deadline));
if (!ip) ip = await resolveProxyIp(proxyUrl); if (!ip && !deadlineExpired(deadline)) ip = await resolveProxyIp(proxyUrl);
if (!ip) return { timezone: null, locale: null, exitIp: null }; 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 { try {
const buf = fs.readFileSync(dbPath); 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 // Proxy IP resolution
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -128,7 +161,8 @@ const IP_ECHO_URLS = [
"https://ifconfig.me/ip", "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); const isSocks = isSocksProxy(proxyUrl);
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent // 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); const agent = new SocksProxyAgent(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) { for (const echoUrl of IP_ECHO_URLS) {
const remaining = remainingMs(deadline);
if (remaining !== undefined && remaining <= 0) return null;
try { try {
const ip = await new Promise<string | null>((resolve) => { 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 = ""; let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString())); res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => { res.on("end", () => {
@@ -173,6 +209,8 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
const proxyUrlObj = new URL(proxyUrl); const proxyUrlObj = new URL(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) { for (const echoUrl of IP_ECHO_URLS) {
const remaining = remainingMs(deadline);
if (remaining !== undefined && remaining <= 0) return null;
try { try {
const ip = await new Promise<string | null>((resolve, reject) => { const ip = await new Promise<string | null>((resolve, reject) => {
const targetUrl = new URL(echoUrl); const targetUrl = new URL(echoUrl);
@@ -190,13 +228,14 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
).toString("base64"), ).toString("base64"),
} }
: {}, : {},
timeout: 10_000, timeout: Math.min(10_000, remaining ?? 10_000),
}); });
connectReq.on("connect", (_res, socket) => { connectReq.on("connect", (_res, socket) => {
const innerRemaining = remainingMs(deadline);
const req = https.request( const req = https.request(
echoUrl, echoUrl,
{ socket, timeout: 5_000 } as any, { socket, timeout: Math.min(5_000, innerRemaining ?? 5_000) } as any,
(res) => { (res) => {
let data = ""; let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString())); 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("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
req.end(); req.end();
}); });
@@ -261,7 +301,9 @@ async function downloadGeoipDb(dest: string): Promise<void> {
const tmpPath = `${dest}.tmp.${Date.now()}`; const tmpPath = `${dest}.tmp.${Date.now()}`;
try { try {
const response = await fetch(GEOIP_DB_URL, { redirect: "follow" }); const response = await fetch(GEOIP_DB_URL, {
redirect: "follow",
});
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`); 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 // When both tz/locale are explicit, still resolve exit IP for WebRTC
if (options.timezone && options.locale) { 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 }; return { timezone: options.timezone, locale: options.locale, exitIp };
} }
@@ -363,7 +406,7 @@ export async function resolveWebrtcArgs(
} }
try { try {
const ip = await resolveExitIp(proxyUrl); const ip = await resolveExitIp(proxyUrl, getGeoipTimeoutMs());
const result = [...args]; const result = [...args];
if (ip) { if (ip) {
result[idx] = `--fingerprint-webrtc-ip=${ip}`; result[idx] = `--fingerprint-webrtc-ip=${ip}`;
+141 -55
View File
@@ -47,17 +47,17 @@
*/ */
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core'; import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
import type { HumanConfig } from '../human/config.js'; import type { HumanConfig, HumanActionOptions } from '../human/config.js';
import { resolveConfig, rand, randRange, sleep } 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 { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
import { humanType } from './keyboard.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 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 { humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
export { humanType } from './keyboard.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 ==== // ==== 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); const response = await originals.goto(url, options);
stealth.invalidate(); stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); 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) ==== // ==== 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(); await ensureCursorInit();
if (cfg.idle_between_actions) { const callCfg = mergeConfig(cfg, options?.human_config ?? options);
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);
} }
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.x = cursorX;
cursor.y = cursorY; cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector); const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg); const target = clickTarget(box, isInput, 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.x = target.x;
cursor.y = target.y; cursor.y = target.y;
const clickCount = options?.clickCount ?? options?.count ?? 1; const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) { if (clickCount >= 2) {
await humanClick(raw, isInput, cfg); await humanClick(raw, isInput, callCfg);
await sleep(rand(40, 90)); await sleep(rand(40, 90));
await raw.down({ clickCount: 2 }); await raw.down({ clickCount: 2 });
await sleep(rand(30, 60)); await sleep(rand(30, 60));
await raw.up({ clickCount: 2 }); await raw.up({ clickCount: 2 });
} else { } else {
await humanClick(raw, isInput, cfg); await humanClick(raw, isInput, callCfg);
} }
}; };
// ==== hover ==== // ==== hover ====
const humanHoverFn = async (selector: string, options?: any) => { const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit(); await ensureCursorInit();
if (cfg.idle_between_actions) { const callCfg = mergeConfig(cfg, options?.human_config ?? options);
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);
} }
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.x = cursorX;
cursor.y = cursorY; cursor.y = cursorY;
const target = clickTarget(box, false, cfg); const target = clickTarget(box, false, 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.x = target.x;
cursor.y = target.y; cursor.y = target.y;
}; };
// ==== type ==== // ==== type ====
const humanTypeFn = async (selector: string, text: string, options?: any) => { const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions & {
await sleep(randRange(cfg.field_switch_delay)); delay?: number;
await humanClickFn(selector); }) => {
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 sleep(rand(100, 250));
const cdp = await ensureCdp(); const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp); await humanType(page, rawKb, text, callCfg, cdp);
}; };
// ==== select ==== // ==== select ====
@@ -392,7 +406,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}; };
// ==== tap ==== // ==== tap ====
const humanTapFn = async (selector: string, options?: any) => { const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
await humanClickFn(selector, options); await humanClickFn(selector, options);
}; };
@@ -410,14 +424,19 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// ============================================================ // ============================================================
// Mouse patches // 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 ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg); await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x; cursor.x = x;
cursor.y = y; 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 ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg); await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x; cursor.x = x;
@@ -452,7 +471,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page.mouse as any).dragAndDrop = async ( (page.mouse as any).dragAndDrop = async (
start: { x: number; y: number }, start: { x: number; y: number },
target: { x: number; y: number }, target: { x: number; y: number },
options?: any, options?: { delay?: number },
) => { ) => {
await ensureCursorInit(); await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg); 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 // Keyboard patches
// ============================================================ // ============================================================
page.keyboard.type = async (text: string, options?: any) => { page.keyboard.type = async (text: string, options?: { delay?: number }) => {
const cdp = await ensureCdp(); const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp); 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 sleep(rand(20, 60));
await originals.keyboardDown(key as any); await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold)); await sleep(randRange(cfg.key_hold));
@@ -548,7 +567,11 @@ function patchElementHandle(
return els; 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); const el = await origWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el; return el;
@@ -577,6 +600,9 @@ function patchSingleElementHandle(
const origElDragAndDrop = (el as any).dragAndDrop?.bind(el); const origElDragAndDrop = (el as any).dragAndDrop?.bind(el);
const origElSelect = (el as any).select?.bind(el); const origElSelect = (el as any).select?.bind(el);
const origElDrop = (el as any).drop?.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 --- // --- Nested selectors ---
const origEl$ = el.$.bind(el); const origEl$ = el.$.bind(el);
@@ -597,45 +623,69 @@ function patchSingleElementHandle(
return children; 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); const child = await origElWaitForSelector(selector, options);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child; return child;
}; };
// --- Helper: get box and move cursor --- // --- Helper: get box and move cursor. Accepts a per-call ``callCfg``
const moveToElement = async () => { // 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(); 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(); const box = await el.boundingBox();
if (!box) return null; if (!box) return null;
const isInp = await isInputElementHandle(stealth, el); const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, cfg); const target = clickTarget(box, isInp, callCfg);
if (cfg.idle_between_actions) { if (callCfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg); 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.x = target.x;
cursor.y = target.y; cursor.y = target.y;
return { box, isInp }; return { box, isInp };
}; };
// --- el.click() --- // --- el.click() ---
(el as any).click = async (options?: any) => { (el as any).click = async (options?: HumanActionOptions & {
const info = await moveToElement(); 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); if (!info) return origElClick(options);
const clickCount = options?.clickCount ?? options?.count ?? 1; const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) { if (clickCount >= 2) {
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, callCfg);
await sleep(rand(40, 90)); await sleep(rand(40, 90));
await raw.down({ clickCount: 2 }); await raw.down({ clickCount: 2 });
await sleep(rand(30, 60)); await sleep(rand(30, 60));
await raw.up({ clickCount: 2 }); await raw.up({ clickCount: 2 });
} else { } else {
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, callCfg);
} }
}; };
@@ -646,18 +696,43 @@ function patchSingleElementHandle(
}; };
// --- el.type() --- // --- el.type() ---
(el as any).type = async (text: string, options?: any) => { (el as any).type = async (text: string, options?: HumanActionOptions & { delay?: number }) => {
const info = await moveToElement(); const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElType(text, options); if (!info) return origElType(text, options);
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250)); await sleep(rand(100, 250));
const cdp = await stealth.getCdpSession().catch(() => null); 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() --- // --- el.press() ---
if (origElPress) { 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 sleep(rand(20, 60));
await originals.keyboardDown(key as any); await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold)); await sleep(randRange(cfg.key_hold));
@@ -696,7 +771,7 @@ function patchSingleElementHandle(
// --- el.drop() --- // --- el.drop() ---
if (origElDrop) { 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 srcBox = await draggable.boundingBox();
const tgtBox = await el.boundingBox(); const tgtBox = await el.boundingBox();
@@ -726,7 +801,7 @@ function patchSingleElementHandle(
// --- el.dragAndDrop() --- // --- el.dragAndDrop() ---
if (origElDragAndDrop) { 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 srcBox = await el.boundingBox();
const tgtBox = await targetEl.boundingBox(); const tgtBox = await targetEl.boundingBox();
@@ -790,15 +865,22 @@ function patchSingleFrame(
const origFrameSelect = frame.select.bind(frame); 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); 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); 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); await (page as any).type(selector, text, options);
}; };
@@ -812,7 +894,7 @@ function patchSingleFrame(
await (page as any).focus(selector); 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); await (page as any).click(selector, options);
}; };
@@ -835,7 +917,11 @@ function patchSingleFrame(
return els; 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); const el = await origFrameWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el; return el;
@@ -881,7 +967,7 @@ export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) { for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
if (typeof (browser as any)[methodName] === 'function') { if (typeof (browser as any)[methodName] === 'function') {
const origCreateContext = (browser as any)[methodName].bind(browser); 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 context: BrowserContext = await origCreateContext(options);
const origCtxNewPage = context.newPage.bind(context); const origCtxNewPage = context.newPage.bind(context);
+55 -18
View File
@@ -5,7 +5,7 @@
* Changes from Playwright version: * Changes from Playwright version:
* - page.viewport() instead of page.viewportSize() * - page.viewport() instead of page.viewportSize()
* - page.$(selector) + el.boundingBox() instead of page.locator().boundingBox() * - 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'; import type { Page } from 'puppeteer-core';
@@ -55,22 +55,40 @@ export async function smoothWheel(
} }
} }
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | 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 { try {
const el = await page.$(selector); const el = await page.$(selector);
if (!el) return null; if (el) {
const box = await el.boundingBox(); const box = await el.boundingBox();
if (!box) return null; if (box) return { x: box.x, y: box.y, width: box.width, height: box.height };
return { x: box.x, y: box.y, width: box.width, height: box.height }; }
} catch { } catch { /* keep polling */ }
return null;
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, page: Page,
raw: RawMouse, raw: RawMouse,
selector: string, getBox: () => Promise<ElementBounds | null>,
cursorX: number, cursorX: number,
cursorY: number, cursorY: number,
cfg: HumanConfig, cfg: HumanConfig,
@@ -78,12 +96,8 @@ export async function scrollToElement(
const viewport = page.viewport(); const viewport = page.viewport();
if (!viewport) throw new Error('Viewport size not available'); if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector); let box = await getBox();
if (!box) { if (!box) throw new Error('Element not found while scrolling into view');
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
if (isInViewport(box, viewport.height, cfg)) { if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY }; return { box, cursorX, cursorY };
@@ -134,7 +148,7 @@ export async function scrollToElement(
await sleep(pause); await sleep(pause);
if (i % 3 === 2 || i === totalClicks - 1) { if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector); box = await getBox();
if (box && isInViewport(box, viewport.height, cfg)) { if (box && isInViewport(box, viewport.height, cfg)) {
break; break;
} }
@@ -159,8 +173,31 @@ export async function scrollToElement(
await sleep(randRange(cfg.scroll_settle_delay)); await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector); box = await getBox();
if (!box) throw new Error(`Element lost after scrolling: ${selector}`); if (!box) throw new Error('Element lost after scrolling into view');
return { box, cursorX, cursorY }; 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,
);
}
+21
View File
@@ -70,6 +70,11 @@ export interface HumanConfig {
export type HumanPreset = 'default' | 'careful'; export type HumanPreset = 'default' | 'careful';
export type HumanActionOptions = Partial<HumanConfig> & {
timeout?: number;
human_config?: Partial<HumanConfig>;
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Default preset // Default preset
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -201,6 +206,22 @@ export function resolveConfig(
return { ...base, ...overrides }; 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 // Utility: random number in range
+155 -34
View File
@@ -17,10 +17,11 @@
*/ */
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core'; import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
import type { HumanConfig } from './config.js'; import type { HumanConfig, HumanActionOptions } from './config.js';
import { rand, randRange, sleep } from './config.js'; import { rand, randRange, sleep, mergeConfig } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js'; import { humanType } from './keyboard.js';
import { humanScrollIntoView } from './scroll.js';
// --- Platform-aware select-all shortcut --- // --- Platform-aware select-all shortcut ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; 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 origElSetChecked = (el as any).setChecked?.bind(el);
const origElTap = el.tap.bind(el); const origElTap = el.tap.bind(el);
const origElFocus = el.focus.bind(el); const origElFocus = el.focus.bind(el);
const origElScrollIntoViewIfNeeded = (el as any).scrollIntoViewIfNeeded?.bind(el);
// Nested selectors // Nested selectors
const origEl$ = el.$.bind(el); const origEl$ = el.$.bind(el);
@@ -123,44 +125,88 @@ export function patchSingleElementHandle(
return children; return children;
}; };
(el as any).waitForSelector = async (selector: string, options?: any) => { (el as any).waitForSelector = async (selector: string, options?: {
const child = await origElWaitForSelector(selector, 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); if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child; return child;
}; };
// --- Helper: get bounding box and move cursor to element --- // --- 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 // Ensure cursor is initialized
const ensureCursorInit = (page as any)._ensureCursorInit; const ensureCursorInit = (page as any)._ensureCursorInit;
if (ensureCursorInit) await 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(); const box = await el.boundingBox();
if (!box) return null; if (!box) return null;
const isInp = await isInputElementHandle(stealth, el); const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, cfg); const target = clickTarget(box, isInp, callCfg);
if (cfg.idle_between_actions) { if (callCfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg); 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.x = target.x;
cursor.y = target.y; cursor.y = target.y;
return { box, isInp }; return { box, isInp };
}; };
// --- el.click() --- // --- el.click() ---
(el as any).click = async (options?: any) => { (el as any).click = async (options?: HumanActionOptions & {
const info = await moveToElement(); 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); if (!info) return origElClick(options);
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, callCfg);
}; };
// --- el.dblclick() --- // --- el.dblclick() ---
(el as any).dblclick = async (options?: any) => { (el as any).dblclick = async (options?: HumanActionOptions & {
const info = await moveToElement(); 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); if (!info) return origElDblclick(options);
await raw.down({ clickCount: 2 }); await raw.down({ clickCount: 2 });
await sleep(rand(30, 60)); await sleep(rand(30, 60));
@@ -168,28 +214,42 @@ export function patchSingleElementHandle(
}; };
// --- el.hover() --- // --- el.hover() ---
(el as any).hover = async (options?: any) => { (el as any).hover = async (options?: HumanActionOptions & {
const info = await moveToElement(); 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); if (!info) return origElHover(options);
// Just move — no click // Just move — no click
}; };
// --- el.type() --- // --- el.type() ---
(el as any).type = async (text: string, options?: any) => { (el as any).type = async (text: string, options?: HumanActionOptions & {
const info = await moveToElement(); delay?: number;
noWaitAfter?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElType(text, options); if (!info) return origElType(text, options);
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250)); await sleep(rand(100, 250));
let cdpSession: CDPSession | null = null; let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {} try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, text, cfg, cdpSession); await humanType(page, rawKb, text, callCfg, cdpSession);
}; };
// --- el.fill() --- // --- el.fill() ---
(el as any).fill = async (value: string, options?: any) => { (el as any).fill = async (value: string, options?: HumanActionOptions & {
const info = await moveToElement(); force?: boolean;
noWaitAfter?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElFill(value, options); if (!info) return origElFill(value, options);
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250)); await sleep(rand(100, 250));
// Clear existing content // Clear existing content
await originals.keyboardPress(SELECT_ALL); await originals.keyboardPress(SELECT_ALL);
@@ -198,11 +258,11 @@ export function patchSingleElementHandle(
await sleep(rand(50, 150)); await sleep(rand(50, 150));
let cdpSession: CDPSession | null = null; let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {} try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, value, cfg, cdpSession); await humanType(page, rawKb, value, callCfg, cdpSession);
}; };
// --- el.press() --- // --- 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 sleep(rand(20, 60));
await originals.keyboardDown(key); await originals.keyboardDown(key);
await sleep(randRange(cfg.key_hold)); await sleep(randRange(cfg.key_hold));
@@ -210,7 +270,11 @@ export function patchSingleElementHandle(
}; };
// --- el.selectOption() --- // --- 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(); const info = await moveToElement();
if (!info) return origElSelectOption(values, options); if (!info) return origElSelectOption(values, options);
await humanClick(raw, false, cfg); await humanClick(raw, false, cfg);
@@ -219,7 +283,13 @@ export function patchSingleElementHandle(
}; };
// --- el.check() --- // --- 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 { try {
const checked = await el.isChecked(); const checked = await el.isChecked();
if (checked) return; // Already checked if (checked) return; // Already checked
@@ -230,7 +300,13 @@ export function patchSingleElementHandle(
}; };
// --- el.uncheck() --- // --- 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 { try {
const checked = await el.isChecked(); const checked = await el.isChecked();
if (!checked) return; // Already unchecked if (!checked) return; // Already unchecked
@@ -242,7 +318,13 @@ export function patchSingleElementHandle(
// --- el.setChecked() --- // --- el.setChecked() ---
if (origElSetChecked) { 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 { try {
const current = await el.isChecked(); const current = await el.isChecked();
if (current === checked) return; if (current === checked) return;
@@ -254,7 +336,14 @@ export function patchSingleElementHandle(
} }
// --- el.tap() --- // --- 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(); const info = await moveToElement();
if (!info) return origElTap(options); if (!info) return origElTap(options);
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, cfg);
@@ -268,6 +357,30 @@ export function patchSingleElementHandle(
await moveToElement(); // human-like Bézier cursor movement await moveToElement(); // human-like Bézier cursor movement
await origElFocus(); // programmatic focus, no click 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() // Patch page.waitForSelector()
if (typeof page.waitForSelector === 'function') { if (typeof page.waitForSelector === 'function') {
const origWaitForSelector = page.waitForSelector.bind(page); const origWaitForSelector = page.waitForSelector.bind(page);
(page as any).waitForSelector = async (selector: string, options?: any) => { (page as any).waitForSelector = async (selector: string, options?: {
const el = await origWaitForSelector(selector, 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); if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el; return el;
}; };
@@ -357,8 +474,12 @@ export function patchFrameElementHandles(
// Patch frame.waitForSelector() // Patch frame.waitForSelector()
if (typeof frame.waitForSelector === 'function') { if (typeof frame.waitForSelector === 'function') {
const origFrameWaitForSelector = frame.waitForSelector.bind(frame); const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
(frame as any).waitForSelector = async (selector: string, options?: any) => { (frame as any).waitForSelector = async (selector: string, options?: {
const el = await origFrameWaitForSelector(selector, 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); if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el; return el;
}; };
+206 -84
View File
@@ -23,16 +23,16 @@
*/ */
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core'; 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 { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js'; import { humanType } from './keyboard.js';
import { scrollToElement } from './scroll.js'; import { scrollToElement, humanScrollIntoView } from './scroll.js';
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.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 { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
export { humanType } from './keyboard.js'; export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js'; export { scrollToElement, humanScrollIntoView } from './scroll.js';
export { patchSingleElementHandle } from './elementhandle.js'; export { patchSingleElementHandle } from './elementhandle.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) --- // --- 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) --- // --- 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); const response = await originals.goto(url, options);
stealth.invalidate(); stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
@@ -303,34 +307,37 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}; };
// --- click --- // --- click ---
const humanClickFn = async (selector: string, options?: any) => { const humanClickFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit(); await ensureCursorInit();
if (cfg.idle_between_actions) { const callCfg = mergeConfig(cfg, options?.human_config ?? options);
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);
} }
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.x = cursorX;
cursor.y = cursorY; cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector); const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg); const target = clickTarget(box, isInput, 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.x = target.x;
cursor.y = target.y; cursor.y = target.y;
await humanClick(raw, isInput, cfg); await humanClick(raw, isInput, callCfg);
}; };
// --- dblclick --- // --- dblclick ---
const humanDblclickFn = async (selector: string, options?: any) => { const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit(); await ensureCursorInit();
if (cfg.idle_between_actions) { const callCfg = mergeConfig(cfg, options?.human_config ?? options);
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);
} }
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.x = cursorX;
cursor.y = cursorY; cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector); const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg); const target = clickTarget(box, isInput, 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.x = target.x;
cursor.y = target.y; cursor.y = target.y;
await raw.down({ clickCount: 2 }); await raw.down({ clickCount: 2 });
@@ -339,46 +346,49 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}; };
// --- hover --- // --- hover ---
const humanHoverFn = async (selector: string, options?: any) => { const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit(); await ensureCursorInit();
if (cfg.idle_between_actions) { const callCfg = mergeConfig(cfg, options?.human_config ?? options);
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);
} }
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.x = cursorX;
cursor.y = cursorY; cursor.y = cursorY;
const target = clickTarget(box, false, cfg); const target = clickTarget(box, false, 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.x = target.x;
cursor.y = target.y; cursor.y = target.y;
}; };
// --- type --- // --- type ---
const humanTypeFn = async (selector: string, text: string, options?: any) => { const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
await sleep(randRange(cfg.field_switch_delay)); const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await humanClickFn(selector); await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, options);
await sleep(rand(100, 250)); await sleep(rand(100, 250));
const cdp = await ensureCdp(); const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp); await humanType(page, rawKb, text, callCfg, cdp);
}; };
// --- fill (clears existing content first) --- // --- fill (clears existing content first) ---
const humanFillFn = async (selector: string, value: string, options?: any) => { const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
await sleep(randRange(cfg.field_switch_delay)); const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await humanClickFn(selector); await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, options);
await sleep(rand(100, 250)); await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL); await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80)); await sleep(rand(30, 80));
await originals.keyboardPress('Backspace'); await originals.keyboardPress('Backspace');
await sleep(rand(50, 150)); await sleep(rand(50, 150));
const cdp = await ensureCdp(); const cdp = await ensureCdp();
await humanType(page, rawKb, value, cfg, cdp); await humanType(page, rawKb, value, callCfg, cdp);
}; };
// --- clear --- // --- clear ---
const humanClearFn = async (selector: string, options?: any) => { const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
if (!await isSelectorFocused(stealth, page, selector)) { if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector); await humanClickFn(selector, options);
} }
await sleep(rand(50, 150)); await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL); await originals.keyboardPress(SELECT_ALL);
@@ -387,55 +397,58 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}; };
// --- check --- // --- check ---
const humanCheckFn = async (selector: string, options?: any) => { const humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
if (cfg.idle_between_actions) { const callCfg = mergeConfig(cfg, options?.human_config ?? options);
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);
} }
const checked = await originals.isChecked(selector).catch(() => false); const checked = await originals.isChecked(selector).catch(() => false);
if (!checked) { if (!checked) {
await humanClickFn(selector); await humanClickFn(selector, options);
} }
}; };
// --- uncheck --- // --- uncheck ---
const humanUncheckFn = async (selector: string, options?: any) => { const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
if (cfg.idle_between_actions) { const callCfg = mergeConfig(cfg, options?.human_config ?? options);
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);
} }
const checked = await originals.isChecked(selector).catch(() => true); const checked = await originals.isChecked(selector).catch(() => true);
if (checked) { if (checked) {
await humanClickFn(selector); await humanClickFn(selector, options);
} }
}; };
// --- selectOption --- // --- selectOption ---
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => { const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
await humanHoverFn(selector); await humanHoverFn(selector, options);
await sleep(rand(100, 300)); await sleep(rand(100, 300));
return originals.selectOption(selector, values, options); return originals.selectOption(selector, values, options);
}; };
// --- press (checks focus first — avoids redundant mouse moves) --- // --- 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)) { if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector); await humanClickFn(selector, options);
} }
await sleep(rand(50, 150)); await sleep(rand(50, 150));
await originals.keyboardPress(key); await originals.keyboardPress(key);
}; };
// --- pressSequentially --- // --- 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)) { if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector); await humanClickFn(selector, options);
} }
await sleep(rand(100, 250)); await sleep(rand(100, 250));
const cdp = await ensureCdp(); const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp); await humanType(page, rawKb, text, callCfg, cdp);
}; };
// --- tap --- // --- tap ---
const humanTapFn = async (selector: string, options?: any) => { const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
await humanClickFn(selector, options); await humanClickFn(selector, options);
}; };
@@ -455,14 +468,20 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page as any).clear = humanClearFn; (page as any).clear = humanClearFn;
// --- mouse patches --- // --- 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 ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg); await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x; cursor.x = x;
cursor.y = y; 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 ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg); await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x; cursor.x = x;
@@ -471,7 +490,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}; };
// --- keyboard patches --- // --- keyboard patches ---
page.keyboard.type = async (text: string, options?: any) => { page.keyboard.type = async (text: string, options?: { delay?: number }) => {
const cdp = await ensureCdp(); const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp); await humanType(page, rawKb, text, cfg, cdp);
}; };
@@ -523,16 +542,37 @@ function patchFrames(
stealth: StealthEval, stealth: StealthEval,
): void { ): void {
for (const frame of iterFrames(page)) { 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) // Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth); 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( function patchSingleFrame(
frame: Frame, frame: Frame,
page: Page, page: Page,
cfg: HumanConfig, cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any, originals: any,
stealth: StealthEval, stealth: StealthEval,
): void { ): void {
@@ -540,58 +580,132 @@ function patchSingleFrame(
(frame as any)._humanPatched = true; (frame as any)._humanPatched = true;
// Save originals for methods that need fallback // 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 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); const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
(frame as any).click = async (selector: string, options?: any) => { const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => {
await (page as any).click(selector, options); 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) => { const frameClick = async (selector: string, options?: HumanActionOptions) => {
await (page as any).dblclick(selector, options); 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) => { const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
await (page as any).hover(selector, options);
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) => { (frame as any).click = frameClick;
await (page as any).type(selector, text, options);
(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) => { (frame as any).hover = frameHover;
await (page as any).fill(selector, value, options);
(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) => { (frame as any).fill = async (selector: string, value: string, options?: HumanActionOptions) => {
await (page as any).check(selector, options); 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) => { (frame as any).check = async (selector: string, options?: HumanActionOptions) => {
await (page as any).uncheck(selector, options); 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) => { (frame as any).uncheck = async (selector: string, options?: HumanActionOptions) => {
await (page as any).hover(selector); 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)); await sleep(rand(100, 300));
return origFrameSelectOption(selector, values, options); return origFrameSelectOption(selector, values, options);
}; };
(frame as any).press = async (selector: string, key: string, options?: any) => { (frame as any).press = async (selector: string, key: string, options?: HumanActionOptions) => {
await (page as any).press(selector, key, options); 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) => { (frame as any).pressSequentially = async (selector: string, text: string, options?: HumanActionOptions) => {
await (page as any).pressSequentially(selector, text, options); 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) => { (frame as any).tap = async (selector: string, options?: HumanActionOptions) => {
await (page as any).tap(selector, options); await frameClick(selector, options).catch(() => origFrameTap?.(selector, options));
}; };
(frame as any).clear = async (selector: string, options?: any) => { (frame as any).clear = async (selector: string, options?: HumanActionOptions) => {
if (!await isSelectorFocused(stealth, page, selector)) { if (!await isFrameSelectorFocused(frame, selector)) {
await (page as any).click(selector); await frameClick(selector, options);
} }
await sleep(rand(50, 150)); await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL); await originals.keyboardPress(SELECT_ALL);
@@ -599,9 +713,17 @@ function patchSingleFrame(
await originals.keyboardPress('Backspace'); await originals.keyboardPress('Backspace');
}; };
(frame as any).dragAndDrop = async (source: string, target: string, options?: any) => { (frame as any).dragAndDrop = async (source: string, target: string, options?: {
const srcBox = await frame.locator(source).boundingBox().catch(() => null); force?: boolean;
const tgtBox = await frame.locator(target).boundingBox().catch(() => null); 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) { if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2; 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); 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); const context = await origNewContext(options);
patchContext(context, cfg); patchContext(context, cfg);
return context; return context;
}; };
const origNewPage = browser.newPage.bind(browser); 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); const page = await origNewPage(options);
if (!(page as any)._original) { if (!(page as any)._original) {
const ctx = page.context(); const ctx = page.context();
+21 -1
View File
@@ -172,13 +172,33 @@ export async function humanClick(
// Human idle / drift // 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, raw: RawMouse,
seconds: number, seconds: number,
cx: number, cx: number,
cy: number, cy: number,
cfg: HumanConfig, cfg: HumanConfig,
): Promise<void>;
export async function humanIdle(
raw: RawMouse,
secondsOrCx: number,
cxOrCy: number,
cyOrCfg: number | HumanConfig,
maybeCfg?: HumanConfig,
): Promise<void> { ): 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; const endTime = Date.now() + seconds * 1000;
let x = cx; let x = cx;
let y = cy; let y = cy;
+43 -13
View File
@@ -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, page: Page,
raw: RawMouse, raw: RawMouse,
selector: string, getBox: () => Promise<ElementBounds | null>,
cursorX: number, cursorX: number,
cursorY: number, cursorY: number,
cfg: HumanConfig, cfg: HumanConfig,
@@ -49,12 +56,8 @@ export async function scrollToElement(
const viewport = page.viewportSize(); const viewport = page.viewportSize();
if (!viewport) throw new Error('Viewport size not available'); if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector); let box = await getBox();
if (!box) { if (!box) throw new Error('Element not found while scrolling into view');
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
if (isInViewport(box, viewport.height, cfg)) { if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY }; return { box, cursorX, cursorY };
@@ -107,7 +110,7 @@ export async function scrollToElement(
// Check visibility every 3 steps // Check visibility every 3 steps
if (i % 3 === 2 || i === totalClicks - 1) { if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector); box = await getBox();
if (box && isInViewport(box, viewport.height, cfg)) { if (box && isInViewport(box, viewport.height, cfg)) {
break; break;
} }
@@ -133,16 +136,43 @@ export async function scrollToElement(
// Settle // Settle
await sleep(randRange(cfg.scroll_settle_delay)); await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector); box = await getBox();
if (!box) throw new Error(`Element lost after scrolling: ${selector}`); if (!box) throw new Error('Element lost after scrolling into view');
return { box, cursorX, cursorY }; 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(); const el = page.locator(selector).first();
try { try {
const box = await el.boundingBox({ timeout: 2000 }); const box = await el.boundingBox({ timeout });
return box; return box;
} catch { } catch {
return null; return null;
+13 -1
View File
@@ -136,7 +136,19 @@ export function normalizeSocksStringUrl(urlStr: string): string {
const encPass = hasPassword const encPass = hasPassword
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "") ? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
: null; : 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) { } catch (e) {
console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`); console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`);
return urlStr; return urlStr;
+59 -2
View File
@@ -1,5 +1,17 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, afterEach, vi } from "vitest";
import { COUNTRY_LOCALE_MAP, resolveProxyIp } from "../src/geoip.js"; 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", () => { describe("resolveProxyIp", () => {
it("returns literal IPv4 from proxy URL", async () => { 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", () => { describe("COUNTRY_LOCALE_MAP", () => {
it("contains common countries", () => { it("contains common countries", () => {
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) { for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
+545 -151
View File
@@ -106,9 +106,9 @@ describe("humanMove", () => {
return { return {
raw: { raw: {
move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }), move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }),
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
wheel: vi.fn(async () => {}), wheel: vi.fn(async () => { }),
}, },
moves, moves,
}; };
@@ -180,10 +180,10 @@ describe("humanClick", () => {
const cfg = resolveConfig("default"); const cfg = resolveConfig("default");
const callOrder: string[] = []; const callOrder: string[] = [];
const raw = { const raw = {
move: vi.fn(async () => {}), move: vi.fn(async () => { }),
down: vi.fn(async () => { callOrder.push("down"); }), down: vi.fn(async () => { callOrder.push("down"); }),
up: vi.fn(async () => { callOrder.push("up"); }), up: vi.fn(async () => { callOrder.push("up"); }),
wheel: vi.fn(async () => {}), wheel: vi.fn(async () => { }),
}; };
await humanClick(raw, false, cfg); await humanClick(raw, false, cfg);
expect(raw.down).toHaveBeenCalledTimes(1); expect(raw.down).toHaveBeenCalledTimes(1);
@@ -199,12 +199,12 @@ describe("humanIdle", () => {
it("calls raw.move at least once during idle", async () => { it("calls raw.move at least once during idle", async () => {
const cfg = resolveConfig("default"); const cfg = resolveConfig("default");
const raw = { const raw = {
move: vi.fn(async () => {}), move: vi.fn(async () => { }),
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
wheel: vi.fn(async () => {}), wheel: vi.fn(async () => { }),
}; };
await humanIdle(raw, 10, 100, 100, cfg); await humanIdle(raw, 100, 100, cfg);
expect(raw.move).toHaveBeenCalled(); expect(raw.move).toHaveBeenCalled();
}, 15000); }, 15000);
}); });
@@ -265,7 +265,7 @@ describe("patchPage fill", () => {
const cursor = { x: 0, y: 0, initialized: false }; const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any); 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 expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+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 }; const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any); 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 // humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
expect(downCalled).toBe(true); expect(downCalled).toBe(true);
@@ -321,7 +321,7 @@ describe("patchPage check/uncheck idle", () => {
const cursor = { x: 100, y: 100, initialized: true }; const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any); 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); expect(downCalled).toBe(true);
}, 30000); }, 30000);
@@ -354,7 +354,7 @@ describe("patchPage press focus", () => {
const cursor = { x: 50, y: 50, initialized: true }; const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any); 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); expect(downCount).toBeGreaterThan(0);
}); });
@@ -372,7 +372,7 @@ describe("patchPage press focus", () => {
const cursor = { x: 50, y: 50, initialized: true }; const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any); 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); expect(downCount).toBe(0);
}); });
@@ -398,6 +398,69 @@ describe("patchPage frame patching", () => {
expect((childFrame as any)._humanPatched).toBe(true); 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]), pages: vi.fn(() => [page]),
on: vi.fn(), on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()), newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}), addInitScript: vi.fn(async () => { }),
}; };
const browser: any = { const browser: any = {
contexts: vi.fn(() => [context]), contexts: vi.fn(() => [context]),
@@ -494,7 +557,7 @@ describe("patchBrowser CDP-connected workflow", () => {
pages: vi.fn(() => [page]), pages: vi.fn(() => [page]),
on: vi.fn(), on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()), newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}), addInitScript: vi.fn(async () => { }),
}; };
const browser: any = { const browser: any = {
contexts: vi.fn(() => [context]), contexts: vi.fn(() => [context]),
@@ -505,7 +568,7 @@ describe("patchBrowser CDP-connected workflow", () => {
patchBrowser(browser, resolveConfig("default")); patchBrowser(browser, resolveConfig("default"));
// Click through the patched method — should go through humanize path // 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); expect(downCalled).toBe(true);
}, 30000); }, 30000);
@@ -518,7 +581,7 @@ describe("patchBrowser CDP-connected workflow", () => {
pages: vi.fn(() => [newPage]), pages: vi.fn(() => [newPage]),
on: vi.fn(), on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()), newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}), addInitScript: vi.fn(async () => { }),
}; };
const browser: any = { const browser: any = {
contexts: vi.fn(() => []), contexts: vi.fn(() => []),
@@ -542,27 +605,27 @@ describe("patchBrowser CDP-connected workflow", () => {
function buildMockPage(overrides: Record<string, any> = {}): any { function buildMockPage(overrides: Record<string, any> = {}): any {
const mainFrameObj = overrides.mainFrameReturn ?? { const mainFrameObj = overrides.mainFrameReturn ?? {
childFrames: vi.fn(() => []), childFrames: vi.fn(() => []),
click: vi.fn(async () => {}), click: vi.fn(async () => { }),
dblclick: vi.fn(async () => {}), dblclick: vi.fn(async () => { }),
hover: vi.fn(async () => {}), hover: vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
fill: vi.fn(async () => {}), fill: vi.fn(async () => { }),
check: vi.fn(async () => {}), check: vi.fn(async () => { }),
uncheck: vi.fn(async () => {}), uncheck: vi.fn(async () => { }),
selectOption: vi.fn(async () => {}), selectOption: vi.fn(async () => { }),
press: vi.fn(async () => {}), press: vi.fn(async () => { }),
clear: vi.fn(async () => {}), clear: vi.fn(async () => { }),
dragAndDrop: vi.fn(async () => {}), dragAndDrop: vi.fn(async () => { }),
locator: vi.fn(() => ({ locator: vi.fn(() => ({
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })), 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 makeLocator = () => {
const loc: any = { const loc: any = {
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })), 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), isChecked: overrides.isChecked ?? vi.fn(async () => false),
}; };
loc.first = vi.fn(() => loc); loc.first = vi.fn(() => loc);
@@ -571,33 +634,33 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
const page: any = { const page: any = {
evaluate: overrides.evaluate ?? vi.fn(async () => false), evaluate: overrides.evaluate ?? vi.fn(async () => false),
addInitScript: vi.fn(async () => {}), addInitScript: vi.fn(async () => { }),
mouse: { mouse: {
move: vi.fn(async () => {}), move: vi.fn(async () => { }),
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
click: vi.fn(async () => {}), click: vi.fn(async () => { }),
dblclick: vi.fn(async () => {}), dblclick: vi.fn(async () => { }),
wheel: vi.fn(async () => {}), wheel: vi.fn(async () => { }),
}, },
keyboard: { keyboard: {
press: overrides.keyboardPress press: overrides.keyboardPress
? vi.fn(overrides.keyboardPress) ? vi.fn(overrides.keyboardPress)
: vi.fn(async () => {}), : vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
insertText: vi.fn(async () => {}), insertText: vi.fn(async () => { }),
}, },
click: vi.fn(async () => {}), click: vi.fn(async () => { }),
dblclick: vi.fn(async () => {}), dblclick: vi.fn(async () => { }),
hover: vi.fn(async () => {}), hover: vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
fill: vi.fn(async () => {}), fill: vi.fn(async () => { }),
check: vi.fn(async () => {}), check: vi.fn(async () => { }),
uncheck: vi.fn(async () => {}), uncheck: vi.fn(async () => { }),
selectOption: vi.fn(async () => {}), selectOption: vi.fn(async () => { }),
press: vi.fn(async () => {}), press: vi.fn(async () => { }),
goto: vi.fn(async () => ({})), goto: vi.fn(async () => ({})),
isChecked: overrides.isChecked ?? vi.fn(async () => false), isChecked: overrides.isChecked ?? vi.fn(async () => false),
locator: vi.fn(() => makeLocator()), locator: vi.fn(() => makeLocator()),
@@ -606,10 +669,10 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
frames: vi.fn(() => []), frames: vi.fn(() => []),
context: vi.fn(() => ({ context: vi.fn(() => ({
pages: vi.fn(() => []), pages: vi.fn(() => []),
addInitScript: vi.fn(async () => {}), addInitScript: vi.fn(async () => { }),
})), })),
url: vi.fn(() => "about:blank"), url: vi.fn(() => "about:blank"),
waitForTimeout: vi.fn(async () => {}), waitForTimeout: vi.fn(async () => { }),
}; };
return page; return page;
} }
@@ -623,8 +686,8 @@ describe("humanType non-ASCII", () => {
const insertedChars: string[] = []; const insertedChars: string[] = [];
const raw = { const raw = {
down: vi.fn(async (k: string) => { downKeys.push(k); }), down: vi.fn(async (k: string) => { downKeys.push(k); }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }), insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
}; };
return { raw, downKeys, insertedChars }; return { raw, downKeys, insertedChars };
@@ -696,18 +759,18 @@ describe("humanType non-ASCII", () => {
function buildMockElementHandle(overrides: Record<string, any> = {}): any { function buildMockElementHandle(overrides: Record<string, any> = {}): any {
const el: any = { const el: any = {
click: vi.fn(async () => {}), click: vi.fn(async () => { }),
dblclick: vi.fn(async () => {}), dblclick: vi.fn(async () => { }),
hover: vi.fn(async () => {}), hover: vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
fill: vi.fn(async () => {}), fill: vi.fn(async () => { }),
press: vi.fn(async () => {}), press: vi.fn(async () => { }),
selectOption: vi.fn(async () => {}), selectOption: vi.fn(async () => { }),
check: vi.fn(async () => {}), check: vi.fn(async () => { }),
uncheck: vi.fn(async () => {}), uncheck: vi.fn(async () => { }),
setChecked: vi.fn(async () => {}), setChecked: vi.fn(async () => { }),
tap: vi.fn(async () => {}), tap: vi.fn(async () => { }),
focus: vi.fn(async () => {}), focus: vi.fn(async () => { }),
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })), boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
evaluate: overrides.evaluate ?? vi.fn(async () => false), evaluate: overrides.evaluate ?? vi.fn(async () => false),
isChecked: overrides.isChecked ?? vi.fn(async () => false), isChecked: overrides.isChecked ?? vi.fn(async () => false),
@@ -725,21 +788,21 @@ describe("patchSingleElementHandle", () => {
const cfg = resolveConfig("default"); const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true }; const cursor = { x: 100, y: 100, initialized: true };
const raw = { const raw = {
move: vi.fn(async () => {}), move: vi.fn(async () => { }),
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
wheel: vi.fn(async () => {}), wheel: vi.fn(async () => { }),
}; };
const rawKb = { const rawKb = {
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
insertText: vi.fn(async () => {}), insertText: vi.fn(async () => { }),
}; };
const originals = { const originals = {
keyboardPress: vi.fn(async () => {}), keyboardPress: vi.fn(async () => { }),
keyboardDown: vi.fn(async () => {}), keyboardDown: vi.fn(async () => { }),
keyboardUp: vi.fn(async () => {}), keyboardUp: vi.fn(async () => { }),
}; };
const el = buildMockElementHandle(); const el = buildMockElementHandle();
@@ -762,23 +825,23 @@ describe("patchSingleElementHandle", () => {
move: vi.fn(async () => { moveCount++; }), move: vi.fn(async () => { moveCount++; }),
down: vi.fn(async () => { downCalled = true; }), down: vi.fn(async () => { downCalled = true; }),
up: vi.fn(async () => { upCalled = true; }), up: vi.fn(async () => { upCalled = true; }),
wheel: vi.fn(async () => {}), wheel: vi.fn(async () => { }),
}; };
const rawKb = { const rawKb = {
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
insertText: vi.fn(async () => {}), insertText: vi.fn(async () => { }),
}; };
const originals = { const originals = {
keyboardPress: vi.fn(async () => {}), keyboardPress: vi.fn(async () => { }),
keyboardDown: vi.fn(async () => {}), keyboardDown: vi.fn(async () => { }),
keyboardUp: vi.fn(async () => {}), keyboardUp: vi.fn(async () => { }),
}; };
const el = buildMockElementHandle(); const el = buildMockElementHandle();
const page = buildMockPage(); 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); patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
@@ -796,22 +859,22 @@ describe("patchSingleElementHandle", () => {
let downCalled = false; let downCalled = false;
const raw = { const raw = {
move: vi.fn(async () => {}), move: vi.fn(async () => { }),
down: vi.fn(async () => { downCalled = true; }), down: vi.fn(async () => { downCalled = true; }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
wheel: vi.fn(async () => {}), wheel: vi.fn(async () => { }),
}; };
const rawKb = { const rawKb = {
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
insertText: 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 el = buildMockElementHandle();
const page = buildMockPage(); 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); 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 cursor = { x: 50, y: 50, initialized: true };
const raw = { const raw = {
move: vi.fn(async () => {}), move: vi.fn(async () => { }),
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
wheel: vi.fn(async () => {}), wheel: vi.fn(async () => { }),
}; };
const rawKb = { const rawKb = {
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
insertText: 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 el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true
const page = buildMockPage(); 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); patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
@@ -860,26 +923,26 @@ describe("patchSingleElementHandle", () => {
const pressedKeys: string[] = []; const pressedKeys: string[] = [];
const raw = { const raw = {
move: vi.fn(async () => {}), move: vi.fn(async () => { }),
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
wheel: vi.fn(async () => {}), wheel: vi.fn(async () => { }),
}; };
const rawKb = { const rawKb = {
down: vi.fn(async () => {}), down: vi.fn(async () => { }),
up: vi.fn(async () => {}), up: vi.fn(async () => { }),
type: vi.fn(async () => {}), type: vi.fn(async () => { }),
insertText: vi.fn(async () => {}), insertText: vi.fn(async () => { }),
}; };
const originals = { const originals = {
keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }), keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }),
keyboardDown: vi.fn(async () => {}), keyboardDown: vi.fn(async () => { }),
keyboardUp: vi.fn(async () => {}), keyboardUp: vi.fn(async () => { }),
}; };
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
const page = buildMockPage(); 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); 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 { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default"); const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false }; 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 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 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 originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
const el = buildMockElementHandle(); const el = buildMockElementHandle();
const page = buildMockPage(); const page = buildMockPage();
@@ -913,9 +976,9 @@ describe("patchSingleElementHandle", () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default"); const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false }; 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 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 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 originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
const child = buildMockElementHandle(); const child = buildMockElementHandle();
const el = buildMockElementHandle(); const el = buildMockElementHandle();
@@ -935,9 +998,9 @@ describe("patchPageElementHandles", () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default"); const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false }; 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 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 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 originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
const el = buildMockElementHandle(); const el = buildMockElementHandle();
const page = buildMockPage(); const page = buildMockPage();
@@ -955,9 +1018,9 @@ describe("patchPageElementHandles", () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default"); const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false }; 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 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 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 originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
const el1 = buildMockElementHandle(); const el1 = buildMockElementHandle();
const el2 = buildMockElementHandle(); const el2 = buildMockElementHandle();
@@ -977,9 +1040,9 @@ describe("patchPageElementHandles", () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default"); const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false }; 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 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 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 originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
const el = buildMockElementHandle(); const el = buildMockElementHandle();
const page = buildMockPage(); const page = buildMockPage();
@@ -997,9 +1060,9 @@ describe("patchPageElementHandles", () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default"); const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false }; 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 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 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 originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
const page = buildMockPage(); const page = buildMockPage();
(page as any).$ = vi.fn(async () => null); (page as any).$ = vi.fn(async () => null);
@@ -1034,21 +1097,352 @@ describe("patchPage integrates ElementHandle patching", () => {
function buildMockFrame(): any { function buildMockFrame(): any {
return { const locator: any = {
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 })), 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 () => { }),
pressSequentially: vi.fn(async () => { }),
tap: vi.fn(async () => { }),
clear: vi.fn(async () => { }),
dragAndDrop: vi.fn(async () => { }),
locator: vi.fn(() => locator),
childFrames: vi.fn(() => []), 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
View File
@@ -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 { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js"; import type { LaunchOptions } from "../src/types.js";
@@ -262,4 +262,63 @@ describe("resolveProxyConfig", () => {
const { proxyArgs } = resolveProxyConfig("socks5://user:a@b@c@host:1080"); const { proxyArgs } = resolveProxyConfig("socks5://user:a@b@c@host:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:a%40b%40c@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();
}
});
}); });
+48
View File
@@ -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) // patchPage stealth infrastructure (Puppeteer)
// ========================================================================= // =========================================================================
@@ -1671,11 +1717,13 @@ describe("Puppeteer: isInputElement stealth integration via patchPage", () => {
}), }),
}); });
const mockEl = buildMockElementHandle();
const page = buildMockPage({ const page = buildMockPage({
evaluate: vi.fn(async (...args: any[]) => { evaluate: vi.fn(async (...args: any[]) => {
evaluateCalls.push(args); evaluateCalls.push(args);
return false; return false;
}), }),
$: vi.fn(async () => mockEl),
}); });
page.createCDPSession = vi.fn(async () => mockCdp); page.createCDPSession = vi.fn(async () => mockCdp);
+94
View File
@@ -22,6 +22,8 @@ parse_connection_params = _mod.parse_connection_params
parse_cli_args = _mod.parse_cli_args parse_cli_args = _mod.parse_cli_args
ChromePool = _mod.ChromePool ChromePool = _mod.ChromePool
_default_data_dir = _mod._default_data_dir _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") pool.disconnect("a")
assert pool._connections["a"] == 1 assert pool._connections["a"] == 1
assert pool._connections["b"] == 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"
+15
View File
@@ -1,6 +1,7 @@
"""Unit tests for GeoIP-based timezone/locale detection.""" """Unit tests for GeoIP-based timezone/locale detection."""
from unittest.mock import patch from unittest.mock import patch
import time
import pytest import pytest
@@ -144,6 +145,20 @@ def test_maybe_resolve_fills_both():
assert ip == "5.6.7.8" 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 # _is_private_ip
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+453
View File
@@ -1296,6 +1296,459 @@ class TestAsyncElementHandle:
await browser.close() 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) # Direct runner (backwards compat)
# ========================================================================= # =========================================================================
+36
View File
@@ -284,6 +284,42 @@ class TestResolveProxyConfig:
_, args = _resolve_proxy_config("socks5://user:pass%3D123@host:1080") _, args = _resolve_proxy_config("socks5://user:pass%3D123@host:1080")
assert args == ["--proxy-server=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): def test_socks5_string_no_creds_unchanged(self):
_, args = _resolve_proxy_config("socks5://host:1080") _, args = _resolve_proxy_config("socks5://host:1080")
assert args == ["--proxy-server=socks5://host:1080"] assert args == ["--proxy-server=socks5://host:1080"]