mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d3469e4c | ||
|
|
cb0b87873e | ||
|
|
2be8cdcc03 | ||
|
|
be9a98db67 | ||
|
|
9b004bbd85 |
@@ -6,6 +6,14 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
---
|
||||
|
||||
## [0.3.24] — 2026-04-10
|
||||
|
||||
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
|
||||
- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@lilos](https://github.com/lilos), #133)
|
||||
- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64
|
||||
- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5)
|
||||
- **[docs]** Clarify humanize requires wrapper import over CDP (#126)
|
||||
|
||||
## [0.3.23] — 2026-04-09
|
||||
|
||||
- **[wrapper]** Add full Puppeteer humanize support — human-like mouse, keyboard, and scroll behavior for `puppeteer-core` users (thanks [@evelaa123](https://github.com/evelaa123), #129)
|
||||
|
||||
@@ -128,10 +128,11 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
|
||||
|
||||
---
|
||||
|
||||
## Latest: v0.3.22 (Chromium 146.0.7680.177.1)
|
||||
## Latest: v0.3.24 (Chromium 146.0.7680.177.2)
|
||||
|
||||
- **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE.
|
||||
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177
|
||||
- **49 fingerprint patches** (Linux x64) — 1 new patch, all existing patches carried forward
|
||||
- **49 fingerprint patches** — Linux arm64 now matches Linux x64 on Chromium 146
|
||||
- **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call)
|
||||
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
|
||||
- **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds
|
||||
@@ -242,8 +243,9 @@ browser = launch()
|
||||
# Headed mode (see the browser window)
|
||||
browser = launch(headless=False)
|
||||
|
||||
# With proxy
|
||||
# With proxy (HTTP or SOCKS5)
|
||||
browser = launch(proxy="http://user:pass@proxy:8080")
|
||||
browser = launch(proxy="socks5://user:pass@proxy:1080")
|
||||
|
||||
# With proxy dict (bypass, separate auth fields)
|
||||
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
|
||||
@@ -370,7 +372,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
|
||||
|
||||
# Check binary installation status
|
||||
print(binary_info())
|
||||
# {'version': '146.0.7680.177.1', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
# {'version': '146.0.7680.177.2', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
|
||||
# Force re-download
|
||||
clear_cache()
|
||||
@@ -645,8 +647,16 @@ stealth_args = get_default_stealth_args() # all fingerprint flags
|
||||
from cloakbrowser import launch_async
|
||||
browser = await launch_async(args=["--remote-debugging-port=9242"])
|
||||
# Connect your framework to http://127.0.0.1:9242 — all stealth flags are set
|
||||
# Note: humanize requires the wrapper (see below)
|
||||
```
|
||||
|
||||
> **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. If you connect to CloakBrowser via CDP from a separate script, import the patching functions to add humanization:
|
||||
>
|
||||
> ```js
|
||||
> import { patchBrowser, resolveConfig } from 'cloakbrowser/human';
|
||||
> patchBrowser(browser, resolveConfig('default'));
|
||||
> ```
|
||||
|
||||
| Framework | Stars | Language | Example |
|
||||
|-----------|-------|----------|---------|
|
||||
| [browser-use](https://github.com/browser-use/browser-use) | 70K | Python | [`browser_use_example.py`](examples/integrations/browser_use_example.py) |
|
||||
@@ -664,7 +674,7 @@ browser = await launch_async(args=["--remote-debugging-port=9242"])
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 146 | 49 | ✅ Latest |
|
||||
| Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ |
|
||||
| Linux arm64 (RPi, Graviton) | 146 | 49 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
|
||||
| Windows x86_64 | 145 | 48 | ✅ |
|
||||
@@ -1020,7 +1030,7 @@ A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright
|
||||
A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves.
|
||||
|
||||
**Q: Can I use my own proxy?**
|
||||
A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
||||
A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@host:port"` to `launch()`. Both HTTP and SOCKS5 proxies are supported natively.
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -1050,7 +1060,7 @@ All releases are signed for supply chain verification.
|
||||
```bash
|
||||
# Verify GPG signature (binary release tag)
|
||||
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
|
||||
git verify-tag chromium-v146.0.7680.177.1
|
||||
git verify-tag chromium-v146.0.7680.177.2
|
||||
|
||||
# Verify GitHub binary attestation (Sigstore)
|
||||
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.23"
|
||||
__version__ = "0.3.24"
|
||||
|
||||
+85
-21
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Literal, TypedDict
|
||||
from urllib.parse import unquote, urlparse, urlunparse
|
||||
from urllib.parse import quote, unquote, urlparse, urlunparse
|
||||
|
||||
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
|
||||
from .download import ensure_binary
|
||||
@@ -105,11 +105,12 @@ def launch(
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
|
||||
|
||||
@@ -119,7 +120,7 @@ def launch(
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**proxy_kwargs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -194,11 +195,12 @@ async def launch_async( # noqa: C901
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
|
||||
|
||||
@@ -208,7 +210,7 @@ async def launch_async( # noqa: C901
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**proxy_kwargs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -297,11 +299,12 @@ def launch_persistent_context(
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
|
||||
@@ -331,7 +334,7 @@ def launch_persistent_context(
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**proxy_kwargs,
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
@@ -422,11 +425,12 @@ async def launch_persistent_context_async(
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||
args = list(args or [])
|
||||
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
|
||||
@@ -456,7 +460,7 @@ async def launch_persistent_context_async(
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**proxy_kwargs,
|
||||
**context_kwargs,
|
||||
)
|
||||
|
||||
@@ -631,14 +635,42 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
|
||||
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
|
||||
|
||||
|
||||
def _reconstruct_socks_url(proxy: ProxySettings) -> str:
|
||||
"""Reconstruct a SOCKS5 URL with inline credentials from a Playwright proxy dict."""
|
||||
server = proxy.get("server", "")
|
||||
username = proxy.get("username", "")
|
||||
password = proxy.get("password", "")
|
||||
if not username:
|
||||
return server
|
||||
parsed = urlparse(server)
|
||||
creds = quote(username, safe="")
|
||||
if password:
|
||||
creds += f":{quote(password, safe='')}"
|
||||
host = parsed.hostname or ""
|
||||
if ":" in host: # IPv6 literal — re-add brackets
|
||||
host = f"[{host}]"
|
||||
netloc = f"{creds}@{host}"
|
||||
if parsed.port:
|
||||
netloc += f":{parsed.port}"
|
||||
return urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
|
||||
|
||||
|
||||
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
|
||||
"""Extract and normalize proxy URL string from proxy param."""
|
||||
"""Extract and normalize proxy URL string from proxy param.
|
||||
|
||||
For SOCKS5 dicts with separate username/password fields, reconstructs
|
||||
the full URL with inline credentials so SOCKS5 auth works.
|
||||
"""
|
||||
if proxy is None:
|
||||
return None
|
||||
raw = proxy.get("server") if isinstance(proxy, dict) else proxy
|
||||
if not raw:
|
||||
return None
|
||||
return _ensure_proxy_scheme(raw)
|
||||
if isinstance(proxy, dict):
|
||||
server = proxy.get("server", "")
|
||||
if not server:
|
||||
return None
|
||||
if _is_socks_proxy(proxy):
|
||||
return _reconstruct_socks_url(proxy)
|
||||
return _ensure_proxy_scheme(server)
|
||||
return _ensure_proxy_scheme(proxy)
|
||||
|
||||
|
||||
def maybe_resolve_geoip(
|
||||
@@ -694,7 +726,7 @@ def _resolve_webrtc_args(
|
||||
return args
|
||||
proxy_url = _extract_proxy_url(proxy)
|
||||
if not proxy_url:
|
||||
logger.debug("--fingerprint-webrtc-ip=auto but no proxy set — removing flag")
|
||||
logger.warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag")
|
||||
args = list(args)
|
||||
del args[idx]
|
||||
return args
|
||||
@@ -702,7 +734,7 @@ def _resolve_webrtc_args(
|
||||
from .geoip import _resolve_exit_ip
|
||||
exit_ip = _resolve_exit_ip(proxy_url)
|
||||
except Exception:
|
||||
logger.debug("WebRTC IP resolution failed — removing flag")
|
||||
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
|
||||
args = list(args)
|
||||
del args[idx]
|
||||
return args
|
||||
@@ -710,6 +742,7 @@ def _resolve_webrtc_args(
|
||||
args = list(args)
|
||||
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
|
||||
else:
|
||||
logger.warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
|
||||
args = list(args)
|
||||
del args[idx]
|
||||
return args
|
||||
@@ -799,10 +832,41 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
|
||||
"""Build proxy kwargs for Playwright launch."""
|
||||
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
|
||||
"""Check if the proxy uses SOCKS5 protocol."""
|
||||
if proxy is None:
|
||||
return {}
|
||||
return False
|
||||
url = proxy.get("server", "") if isinstance(proxy, dict) else proxy
|
||||
return url.lower().startswith(("socks5://", "socks5h://"))
|
||||
|
||||
|
||||
def _resolve_proxy_config(
|
||||
proxy: str | ProxySettings | None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Resolve proxy into Playwright kwargs and Chrome args.
|
||||
|
||||
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
||||
so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
||||
|
||||
Returns:
|
||||
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
|
||||
"""
|
||||
if proxy is None:
|
||||
return {}, []
|
||||
|
||||
if _is_socks_proxy(proxy):
|
||||
# SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
||||
# Chrome handles SOCKS5 auth natively from the URL.
|
||||
if isinstance(proxy, dict):
|
||||
url = _reconstruct_socks_url(proxy)
|
||||
extra_args = [f"--proxy-server={url}"]
|
||||
if proxy.get("bypass"):
|
||||
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
|
||||
return {}, extra_args
|
||||
# String URL — pass as-is (Chrome handles user:pass@ in the URL)
|
||||
return {}, [f"--proxy-server={proxy}"]
|
||||
|
||||
# HTTP/HTTPS: use Playwright's proxy dict as before
|
||||
if isinstance(proxy, dict):
|
||||
return {"proxy": proxy}
|
||||
return {"proxy": _parse_proxy_url(proxy)}
|
||||
return {"proxy": proxy}, []
|
||||
return {"proxy": _parse_proxy_url(proxy)}, []
|
||||
|
||||
@@ -18,8 +18,8 @@ from ._version import __version__
|
||||
CHROMIUM_VERSION = "146.0.7680.177.1"
|
||||
|
||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||
"linux-x64": "146.0.7680.177.1",
|
||||
"linux-arm64": "145.0.7632.159.7",
|
||||
"linux-x64": "146.0.7680.177.2",
|
||||
"linux-arm64": "146.0.7680.177.2",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.159.7",
|
||||
|
||||
@@ -96,7 +96,7 @@ def resolve_proxy_geo_with_ip(
|
||||
)
|
||||
return timezone, locale, ip
|
||||
except Exception as exc:
|
||||
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
|
||||
logger.warning("GeoIP lookup failed for %s: %s", ip, exc)
|
||||
return None, None, ip
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ def _resolve_proxy_ip(proxy_url: str) -> str | None:
|
||||
return ip
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to resolve proxy hostname: %s", exc)
|
||||
logger.warning("Failed to resolve proxy hostname: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
@@ -165,9 +165,14 @@ def _resolve_exit_ip(proxy_url: str) -> str | None:
|
||||
ipaddress.ip_address(ip)
|
||||
logger.debug("Exit IP via %s: %s", url, ip)
|
||||
return ip
|
||||
except httpx.UnsupportedProtocol:
|
||||
logger.warning(
|
||||
"SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]"
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
continue
|
||||
logger.debug("Failed to discover exit IP through proxy")
|
||||
logger.warning("Failed to discover exit IP through proxy")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -900,6 +900,9 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
# --- Patch Frame-level methods (for sub-frames) ---
|
||||
_patch_frames_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals)
|
||||
|
||||
# --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) ---
|
||||
_patch_page_element_handles_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session)
|
||||
|
||||
# Initialize cursor immediately so it doesn't visibly jump from (0,0)
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
|
||||
@@ -913,6 +916,273 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
_patch_locator_class_sync()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SYNC ElementHandle patching
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _is_input_element_handle_sync(el: Any) -> bool:
|
||||
"""Check if an ElementHandle is an input/textarea/contenteditable (sync)."""
|
||||
try:
|
||||
return el.evaluate(
|
||||
"""(node) => {
|
||||
const tag = node.tagName ? node.tagName.toLowerCase() : '';
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| node.getAttribute && node.getAttribute('contenteditable') === 'true';
|
||||
}"""
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _patch_single_element_handle_sync(
|
||||
el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
|
||||
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
|
||||
stealth: Any, cdp_session: Any,
|
||||
) -> None:
|
||||
"""Patch all interaction methods on a sync Playwright ElementHandle."""
|
||||
if getattr(el, '_human_patched', False):
|
||||
return
|
||||
el._human_patched = True
|
||||
|
||||
# Save originals
|
||||
_orig_click = el.click
|
||||
_orig_dblclick = el.dblclick
|
||||
_orig_hover = el.hover
|
||||
_orig_type = el.type
|
||||
_orig_fill = el.fill
|
||||
_orig_press = el.press
|
||||
_orig_select_option = el.select_option
|
||||
_orig_check = el.check
|
||||
_orig_uncheck = el.uncheck
|
||||
_orig_set_checked = getattr(el, 'set_checked', None)
|
||||
_orig_tap = el.tap
|
||||
_orig_focus = el.focus
|
||||
|
||||
# Nested selectors
|
||||
_orig_qs = el.query_selector
|
||||
_orig_qsa = el.query_selector_all
|
||||
_orig_wfs = el.wait_for_selector
|
||||
|
||||
def _patched_qs(selector: str, **kwargs: Any) -> Any:
|
||||
child = _orig_qs(selector, **kwargs)
|
||||
if child is not None:
|
||||
_patch_single_element_handle_sync(
|
||||
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
|
||||
)
|
||||
return child
|
||||
|
||||
def _patched_qsa(selector: str, **kwargs: Any) -> Any:
|
||||
children = _orig_qsa(selector, **kwargs)
|
||||
for child in children:
|
||||
_patch_single_element_handle_sync(
|
||||
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
|
||||
)
|
||||
return children
|
||||
|
||||
def _patched_wfs(selector: str, **kwargs: Any) -> Any:
|
||||
child = _orig_wfs(selector, **kwargs)
|
||||
if child is not None:
|
||||
_patch_single_element_handle_sync(
|
||||
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
|
||||
)
|
||||
return child
|
||||
|
||||
el.query_selector = _patched_qs
|
||||
el.query_selector_all = _patched_qsa
|
||||
el.wait_for_selector = _patched_wfs
|
||||
|
||||
# Helper: move cursor to element
|
||||
def _move_to_element():
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
|
||||
originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
|
||||
box = el.bounding_box()
|
||||
if not box:
|
||||
return None
|
||||
|
||||
is_inp = _is_input_element_handle_sync(el)
|
||||
target = click_target(box, is_inp, cfg)
|
||||
|
||||
if cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
return {'box': box, 'is_inp': is_inp}
|
||||
|
||||
# --- el.click() ---
|
||||
def _human_el_click(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_click(**kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.dblclick() ---
|
||||
def _human_el_dblclick(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_dblclick(**kwargs)
|
||||
raw_mouse.down(click_count=2)
|
||||
sleep_ms(rand(30, 60))
|
||||
raw_mouse.up(click_count=2)
|
||||
|
||||
# --- el.hover() ---
|
||||
def _human_el_hover(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_hover(**kwargs)
|
||||
# Just move, no click
|
||||
|
||||
# --- el.type() ---
|
||||
def _human_el_type(text: str, **kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_type(text, **kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
|
||||
|
||||
# --- el.fill() ---
|
||||
def _human_el_fill(value: str, **kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_fill(value, **kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
originals.keyboard_press(_SELECT_ALL)
|
||||
sleep_ms(rand(30, 80))
|
||||
originals.keyboard_press("Backspace")
|
||||
sleep_ms(rand(50, 150))
|
||||
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
|
||||
|
||||
# --- el.press() ---
|
||||
def _human_el_press(key: str, **kwargs: Any) -> None:
|
||||
sleep_ms(rand(20, 60))
|
||||
originals.keyboard_down(key)
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
originals.keyboard_up(key)
|
||||
|
||||
# --- el.select_option() ---
|
||||
def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_select_option(value, **kwargs)
|
||||
human_click(raw_mouse, False, cfg)
|
||||
sleep_ms(rand(100, 300))
|
||||
return _orig_select_option(value, **kwargs)
|
||||
|
||||
# --- el.check() ---
|
||||
def _human_el_check(**kwargs: Any) -> None:
|
||||
try:
|
||||
if el.is_checked():
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_check(**kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.uncheck() ---
|
||||
def _human_el_uncheck(**kwargs: Any) -> None:
|
||||
try:
|
||||
if not el.is_checked():
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_uncheck(**kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.set_checked() ---
|
||||
def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
|
||||
try:
|
||||
current = el.is_checked()
|
||||
if current == checked:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
info = _move_to_element()
|
||||
if info is None and _orig_set_checked:
|
||||
return _orig_set_checked(checked, **kwargs)
|
||||
if info:
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.tap() ---
|
||||
def _human_el_tap(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_tap(**kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.focus() ---
|
||||
# FIX: move cursor humanly but use programmatic focus (no click side-effects).
|
||||
# Stock Playwright el.focus() never clicks — it just calls element.focus() in JS.
|
||||
# Clicking would trigger onclick, submit forms, navigate links, etc.
|
||||
def _human_el_focus() -> None:
|
||||
_move_to_element() # human-like cursor movement (Bézier)
|
||||
_orig_focus() # programmatic focus, no click side-effects
|
||||
|
||||
el.click = _human_el_click
|
||||
el.dblclick = _human_el_dblclick
|
||||
el.hover = _human_el_hover
|
||||
el.type = _human_el_type
|
||||
el.fill = _human_el_fill
|
||||
el.press = _human_el_press
|
||||
el.select_option = _human_el_select_option
|
||||
el.check = _human_el_check
|
||||
el.uncheck = _human_el_uncheck
|
||||
if _orig_set_checked is not None:
|
||||
el.set_checked = _human_el_set_checked
|
||||
el.tap = _human_el_tap
|
||||
el.focus = _human_el_focus
|
||||
|
||||
|
||||
def _patch_page_element_handles_sync(
|
||||
page: Any, cfg: HumanConfig, cursor: _CursorState,
|
||||
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
|
||||
stealth: Any, cdp_session: Any,
|
||||
) -> None:
|
||||
"""Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (sync)."""
|
||||
_orig_qs = page.query_selector
|
||||
_orig_qsa = page.query_selector_all
|
||||
_orig_wfs = page.wait_for_selector
|
||||
|
||||
def _patched_qs(selector: str, **kwargs: Any) -> Any:
|
||||
el = _orig_qs(selector, **kwargs)
|
||||
if el is not None:
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
|
||||
)
|
||||
return el
|
||||
|
||||
def _patched_qsa(selector: str, **kwargs: Any) -> Any:
|
||||
els = _orig_qsa(selector, **kwargs)
|
||||
for el in els:
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
|
||||
)
|
||||
return els
|
||||
|
||||
def _patched_wfs(selector: str, **kwargs: Any) -> Any:
|
||||
el = _orig_wfs(selector, **kwargs)
|
||||
if el is not None:
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
|
||||
)
|
||||
return el
|
||||
|
||||
page.query_selector = _patched_qs
|
||||
page.query_selector_all = _patched_qsa
|
||||
page.wait_for_selector = _patched_wfs
|
||||
|
||||
|
||||
def _patch_frames_sync(
|
||||
page: Any, cfg: HumanConfig, cursor: _CursorState,
|
||||
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
|
||||
@@ -1023,6 +1293,58 @@ def _patch_single_frame_sync(
|
||||
frame.clear = _frame_clear
|
||||
frame.drag_and_drop = _frame_drag_and_drop
|
||||
|
||||
# --- Patch frame-level ElementHandle selectors ---
|
||||
stealth_world = getattr(page, '_stealth_world', None)
|
||||
cdp_session = None
|
||||
if stealth_world is not None:
|
||||
try:
|
||||
cdp_session = stealth_world.get_cdp_session()
|
||||
except Exception:
|
||||
pass
|
||||
_patch_frame_element_handles_sync(
|
||||
frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session
|
||||
)
|
||||
|
||||
|
||||
def _patch_frame_element_handles_sync(
|
||||
frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
|
||||
raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any,
|
||||
stealth: Any, cdp_session: Any,
|
||||
) -> None:
|
||||
"""Patch frame.query_selector, query_selector_all, wait_for_selector (sync)."""
|
||||
_orig_qs = frame.query_selector
|
||||
_orig_qsa = frame.query_selector_all
|
||||
_orig_wfs = frame.wait_for_selector
|
||||
|
||||
def _patched_qs(selector: str, **kwargs: Any) -> Any:
|
||||
el = _orig_qs(selector, **kwargs)
|
||||
if el is not None:
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
|
||||
)
|
||||
return el
|
||||
|
||||
def _patched_qsa(selector: str, **kwargs: Any) -> Any:
|
||||
els = _orig_qsa(selector, **kwargs)
|
||||
for el in els:
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
|
||||
)
|
||||
return els
|
||||
|
||||
def _patched_wfs(selector: str, **kwargs: Any) -> Any:
|
||||
el = _orig_wfs(selector, **kwargs)
|
||||
if el is not None:
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session
|
||||
)
|
||||
return el
|
||||
|
||||
frame.query_selector = _patched_qs
|
||||
frame.query_selector_all = _patched_qsa
|
||||
frame.wait_for_selector = _patched_wfs
|
||||
|
||||
|
||||
|
||||
def _iter_frames(page: Any):
|
||||
try:
|
||||
@@ -1108,6 +1430,7 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
stealth = _AsyncIsolatedWorld(page)
|
||||
page._stealth_world = stealth
|
||||
cdp_session_holder: list[Any] = [None] # mutable container for closure
|
||||
page._cdp_session_holder = cdp_session_holder # expose for frame-level patching
|
||||
|
||||
async def _ensure_cdp() -> Any:
|
||||
if cdp_session_holder[0] is None:
|
||||
@@ -1265,10 +1588,289 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
# --- Patch Frame-level methods (for sub-frames) ---
|
||||
_patch_frames_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals)
|
||||
|
||||
# --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) ---
|
||||
_patch_page_element_handles_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder)
|
||||
|
||||
# --- Patch async Locator class (class-level, runs once) ---
|
||||
_patch_locator_class_async()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ASYNC ElementHandle patching
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def _async_is_input_element_handle(el: Any) -> bool:
|
||||
"""Check if an ElementHandle is an input/textarea/contenteditable (async)."""
|
||||
try:
|
||||
return await el.evaluate(
|
||||
"""(node) => {
|
||||
const tag = node.tagName ? node.tagName.toLowerCase() : '';
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| node.getAttribute && node.getAttribute('contenteditable') === 'true';
|
||||
}"""
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _patch_single_element_handle_async(
|
||||
el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
|
||||
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
|
||||
stealth: Any, cdp_session_holder: Any,
|
||||
) -> None:
|
||||
"""Patch all interaction methods on an async Playwright ElementHandle."""
|
||||
if getattr(el, '_human_patched', False):
|
||||
return
|
||||
el._human_patched = True
|
||||
|
||||
# Save originals
|
||||
_orig_click = el.click
|
||||
_orig_dblclick = el.dblclick
|
||||
_orig_hover = el.hover
|
||||
_orig_type = el.type
|
||||
_orig_fill = el.fill
|
||||
_orig_press = el.press
|
||||
_orig_select_option = el.select_option
|
||||
_orig_check = el.check
|
||||
_orig_uncheck = el.uncheck
|
||||
_orig_set_checked = getattr(el, 'set_checked', None)
|
||||
_orig_tap = el.tap
|
||||
_orig_focus = el.focus
|
||||
|
||||
# Nested selectors
|
||||
_orig_qs = el.query_selector
|
||||
_orig_qsa = el.query_selector_all
|
||||
_orig_wfs = el.wait_for_selector
|
||||
|
||||
async def _patched_qs(selector: str, **kwargs: Any) -> Any:
|
||||
child = await _orig_qs(selector, **kwargs)
|
||||
if child is not None:
|
||||
_patch_single_element_handle_async(
|
||||
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
|
||||
)
|
||||
return child
|
||||
|
||||
async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
|
||||
children = await _orig_qsa(selector, **kwargs)
|
||||
for child in children:
|
||||
_patch_single_element_handle_async(
|
||||
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
|
||||
)
|
||||
return children
|
||||
|
||||
async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
|
||||
child = await _orig_wfs(selector, **kwargs)
|
||||
if child is not None:
|
||||
_patch_single_element_handle_async(
|
||||
child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
|
||||
)
|
||||
return child
|
||||
|
||||
el.query_selector = _patched_qs
|
||||
el.query_selector_all = _patched_qsa
|
||||
el.wait_for_selector = _patched_wfs
|
||||
|
||||
# Helper: move cursor to element (async)
|
||||
async def _move_to_element():
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
|
||||
await originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
|
||||
box = await el.bounding_box()
|
||||
if not box:
|
||||
return None
|
||||
|
||||
is_inp = await _async_is_input_element_handle(el)
|
||||
target = click_target(box, is_inp, cfg)
|
||||
|
||||
if cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
return {'box': box, 'is_inp': is_inp}
|
||||
|
||||
async def _get_cdp():
|
||||
if cdp_session_holder[0] is None:
|
||||
try:
|
||||
cdp_session_holder[0] = await stealth.get_cdp_session()
|
||||
except Exception:
|
||||
pass
|
||||
return cdp_session_holder[0]
|
||||
|
||||
# --- el.click() ---
|
||||
async def _human_el_click(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_click(**kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.dblclick() ---
|
||||
async def _human_el_dblclick(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_dblclick(**kwargs)
|
||||
await raw_mouse.down(click_count=2)
|
||||
await async_sleep_ms(rand(30, 60))
|
||||
await raw_mouse.up(click_count=2)
|
||||
|
||||
# --- el.hover() ---
|
||||
async def _human_el_hover(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_hover(**kwargs)
|
||||
|
||||
# --- el.type() ---
|
||||
async def _human_el_type(text: str, **kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_type(text, **kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
cdp = await _get_cdp()
|
||||
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
|
||||
|
||||
# --- el.fill() ---
|
||||
async def _human_el_fill(value: str, **kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_fill(value, **kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
await originals.keyboard_press(_SELECT_ALL)
|
||||
await async_sleep_ms(rand(30, 80))
|
||||
await originals.keyboard_press("Backspace")
|
||||
await async_sleep_ms(rand(50, 150))
|
||||
cdp = await _get_cdp()
|
||||
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
|
||||
|
||||
# --- el.press() ---
|
||||
async def _human_el_press(key: str, **kwargs: Any) -> None:
|
||||
await async_sleep_ms(rand(20, 60))
|
||||
await originals.keyboard_down(key)
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
await originals.keyboard_up(key)
|
||||
|
||||
# --- el.select_option() ---
|
||||
async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_select_option(value, **kwargs)
|
||||
await async_human_click(raw_mouse, False, cfg)
|
||||
await async_sleep_ms(rand(100, 300))
|
||||
return await _orig_select_option(value, **kwargs)
|
||||
|
||||
# --- el.check() ---
|
||||
async def _human_el_check(**kwargs: Any) -> None:
|
||||
try:
|
||||
if await el.is_checked():
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_check(**kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.uncheck() ---
|
||||
async def _human_el_uncheck(**kwargs: Any) -> None:
|
||||
try:
|
||||
if not await el.is_checked():
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_uncheck(**kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.set_checked() ---
|
||||
async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
|
||||
try:
|
||||
current = await el.is_checked()
|
||||
if current == checked:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
info = await _move_to_element()
|
||||
if info is None and _orig_set_checked:
|
||||
return await _orig_set_checked(checked, **kwargs)
|
||||
if info:
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.tap() ---
|
||||
async def _human_el_tap(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_tap(**kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.focus() ---
|
||||
# FIX: move cursor humanly but use programmatic focus (no click side-effects).
|
||||
# Stock Playwright el.focus() never clicks — it just calls element.focus() in JS.
|
||||
# Clicking would trigger onclick, submit forms, navigate links, etc.
|
||||
async def _human_el_focus() -> None:
|
||||
await _move_to_element() # human-like cursor movement (Bézier)
|
||||
await _orig_focus() # programmatic focus, no click side-effects
|
||||
|
||||
el.click = _human_el_click
|
||||
el.dblclick = _human_el_dblclick
|
||||
el.hover = _human_el_hover
|
||||
el.type = _human_el_type
|
||||
el.fill = _human_el_fill
|
||||
el.press = _human_el_press
|
||||
el.select_option = _human_el_select_option
|
||||
el.check = _human_el_check
|
||||
el.uncheck = _human_el_uncheck
|
||||
if _orig_set_checked is not None:
|
||||
el.set_checked = _human_el_set_checked
|
||||
el.tap = _human_el_tap
|
||||
el.focus = _human_el_focus
|
||||
|
||||
|
||||
def _patch_page_element_handles_async(
|
||||
page: Any, cfg: HumanConfig, cursor: _CursorState,
|
||||
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
|
||||
stealth: Any, cdp_session_holder: Any,
|
||||
) -> None:
|
||||
"""Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (async)."""
|
||||
_orig_qs = page.query_selector
|
||||
_orig_qsa = page.query_selector_all
|
||||
_orig_wfs = page.wait_for_selector
|
||||
|
||||
async def _patched_qs(selector: str, **kwargs: Any) -> Any:
|
||||
el = await _orig_qs(selector, **kwargs)
|
||||
if el is not None:
|
||||
_patch_single_element_handle_async(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
|
||||
)
|
||||
return el
|
||||
|
||||
async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
|
||||
els = await _orig_qsa(selector, **kwargs)
|
||||
for el in els:
|
||||
_patch_single_element_handle_async(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
|
||||
)
|
||||
return els
|
||||
|
||||
async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
|
||||
el = await _orig_wfs(selector, **kwargs)
|
||||
if el is not None:
|
||||
_patch_single_element_handle_async(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
|
||||
)
|
||||
return el
|
||||
|
||||
page.query_selector = _patched_qs
|
||||
page.query_selector_all = _patched_qsa
|
||||
page.wait_for_selector = _patched_wfs
|
||||
|
||||
|
||||
def _patch_frames_async(
|
||||
page: Any, cfg: HumanConfig, cursor: _CursorState,
|
||||
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
|
||||
@@ -1379,6 +1981,53 @@ def _patch_single_frame_async(
|
||||
frame.clear = _frame_clear
|
||||
frame.drag_and_drop = _frame_drag_and_drop
|
||||
|
||||
# --- Patch frame-level ElementHandle selectors (async) ---
|
||||
stealth_world = getattr(page, '_stealth_world', None)
|
||||
cdp_session_holder = getattr(page, '_cdp_session_holder', [None])
|
||||
_patch_frame_element_handles_async(
|
||||
frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session_holder
|
||||
)
|
||||
|
||||
|
||||
def _patch_frame_element_handles_async(
|
||||
frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState,
|
||||
raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any,
|
||||
stealth: Any, cdp_session_holder: Any,
|
||||
) -> None:
|
||||
"""Patch frame.query_selector, query_selector_all, wait_for_selector (async)."""
|
||||
_orig_qs = frame.query_selector
|
||||
_orig_qsa = frame.query_selector_all
|
||||
_orig_wfs = frame.wait_for_selector
|
||||
|
||||
async def _patched_qs(selector: str, **kwargs: Any) -> Any:
|
||||
el = await _orig_qs(selector, **kwargs)
|
||||
if el is not None:
|
||||
_patch_single_element_handle_async(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
|
||||
)
|
||||
return el
|
||||
|
||||
async def _patched_qsa(selector: str, **kwargs: Any) -> Any:
|
||||
els = await _orig_qsa(selector, **kwargs)
|
||||
for el in els:
|
||||
_patch_single_element_handle_async(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
|
||||
)
|
||||
return els
|
||||
|
||||
async def _patched_wfs(selector: str, **kwargs: Any) -> Any:
|
||||
el = await _orig_wfs(selector, **kwargs)
|
||||
if el is not None:
|
||||
_patch_single_element_handle_async(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder
|
||||
)
|
||||
return el
|
||||
|
||||
frame.query_selector = _patched_qs
|
||||
frame.query_selector_all = _patched_qsa
|
||||
frame.wait_for_selector = _patched_wfs
|
||||
|
||||
|
||||
|
||||
def patch_context_async(context: Any, cfg: HumanConfig) -> None:
|
||||
cursor = _CursorState()
|
||||
|
||||
+5
-2
@@ -63,10 +63,13 @@ await browser.close();
|
||||
```javascript
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// With proxy
|
||||
// With proxy (HTTP or SOCKS5)
|
||||
const browser = await launch({
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
const browser = await launch({
|
||||
proxy: 'socks5://user:pass@proxy:1080',
|
||||
});
|
||||
|
||||
// With proxy object (bypass, separate auth fields)
|
||||
const browser = await launch({
|
||||
@@ -211,7 +214,7 @@ const page = await browser.newPage();
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 18
|
||||
- Node.js >= 20
|
||||
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Generated
+57
-9
@@ -1,31 +1,36 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tar": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"cloakbrowser": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"playwright-core": "^1.40.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mmdb-lib": ">=2.0.0",
|
||||
"playwright-core": ">=1.40.0",
|
||||
"puppeteer-core": ">=21.0.0"
|
||||
"puppeteer-core": ">=21.0.0",
|
||||
"socks-proxy-agent": ">=8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mmdb-lib": {
|
||||
@@ -36,6 +41,9 @@
|
||||
},
|
||||
"puppeteer-core": {
|
||||
"optional": true
|
||||
},
|
||||
"socks-proxy-agent": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2003,6 +2011,21 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-proxy-agent/node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-resolver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
|
||||
@@ -2164,6 +2187,21 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-agent/node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -2332,18 +2370,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz",
|
||||
"integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"agent-base": "9.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent/node_modules/agent-base": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
|
||||
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.23",
|
||||
"version": "0.3.24",
|
||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -55,12 +55,13 @@
|
||||
},
|
||||
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mmdb-lib": ">=2.0.0",
|
||||
"playwright-core": ">=1.40.0",
|
||||
"puppeteer-core": ">=21.0.0"
|
||||
"puppeteer-core": ">=21.0.0",
|
||||
"socks-proxy-agent": ">=10.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright-core": {
|
||||
@@ -71,6 +72,9 @@
|
||||
},
|
||||
"mmdb-lib": {
|
||||
"optional": true
|
||||
},
|
||||
"socks-proxy-agent": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -79,6 +83,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"playwright-core": "^1.40.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ export { WRAPPER_VERSION };
|
||||
export const CHROMIUM_VERSION = "146.0.7680.177.1";
|
||||
|
||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||
"linux-x64": "146.0.7680.177.1",
|
||||
"linux-arm64": "145.0.7632.159.7",
|
||||
"linux-x64": "146.0.7680.177.2",
|
||||
"linux-arm64": "146.0.7680.177.2",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.159.7",
|
||||
|
||||
+60
-8
@@ -15,7 +15,7 @@ import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
import { getCacheDir } from "./config.js";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { ensureProxyScheme } from "./proxy.js";
|
||||
import { ensureProxyScheme, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js";
|
||||
|
||||
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
|
||||
const GEOIP_DB_URL =
|
||||
@@ -129,9 +129,44 @@ const IP_ECHO_URLS = [
|
||||
];
|
||||
|
||||
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http
|
||||
// For simplicity, use a direct HTTP request to a plain-text IP echo service
|
||||
// through the proxy using Node's http module
|
||||
const isSocks = isSocksProxy(proxyUrl);
|
||||
|
||||
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
|
||||
if (isSocks) {
|
||||
let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent;
|
||||
try {
|
||||
({ SocksProxyAgent } = await import("socks-proxy-agent"));
|
||||
} catch {
|
||||
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
|
||||
return null;
|
||||
}
|
||||
const { default: https } = await import("node:https");
|
||||
const agent = new SocksProxyAgent(proxyUrl);
|
||||
|
||||
for (const echoUrl of IP_ECHO_URLS) {
|
||||
try {
|
||||
const ip = await new Promise<string | null>((resolve) => {
|
||||
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
res.on("end", () => {
|
||||
const ip = data.trim();
|
||||
resolve(net.isIP(ip) ? ip : null);
|
||||
});
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
req.on("timeout", () => { req.destroy(); resolve(null); });
|
||||
req.end();
|
||||
});
|
||||
if (ip) return ip;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use a CONNECT tunnel via http
|
||||
try {
|
||||
const { default: http } = await import("node:http");
|
||||
const { default: https } = await import("node:https");
|
||||
@@ -264,6 +299,22 @@ function maybeTriggerUpdate(dbPath: string): void {
|
||||
downloadGeoipDb(dbPath).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a usable proxy URL from LaunchOptions.proxy.
|
||||
* For SOCKS5 dicts with separate credentials, reconstructs the full URL
|
||||
* with inline credentials so SOCKS5 auth works.
|
||||
*/
|
||||
function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null {
|
||||
if (!proxy) return null;
|
||||
if (typeof proxy === "string") return ensureProxyScheme(proxy);
|
||||
const p = proxy as ProxyDict;
|
||||
if (!p.server) return null;
|
||||
if (p.username && isSocksProxy(p)) {
|
||||
return reconstructSocksUrl(p);
|
||||
}
|
||||
return ensureProxyScheme(p.server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
|
||||
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
|
||||
@@ -273,9 +324,8 @@ export async function maybeResolveGeoip(
|
||||
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
|
||||
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||
const proxyUrl = extractProxyUrl(options.proxy);
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
proxyUrl = ensureProxyScheme(proxyUrl);
|
||||
|
||||
// When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||
if (options.timezone && options.locale) {
|
||||
@@ -304,13 +354,13 @@ export async function resolveWebrtcArgs(
|
||||
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
|
||||
if (idx === -1) return args;
|
||||
|
||||
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy?.server;
|
||||
const proxyUrl = extractProxyUrl(options.proxy);
|
||||
if (!proxyUrl) {
|
||||
console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag");
|
||||
const result = [...args];
|
||||
result.splice(idx, 1);
|
||||
return result;
|
||||
}
|
||||
proxyUrl = ensureProxyScheme(proxyUrl);
|
||||
|
||||
try {
|
||||
const ip = await resolveExitIp(proxyUrl);
|
||||
@@ -318,10 +368,12 @@ export async function resolveWebrtcArgs(
|
||||
if (ip) {
|
||||
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
|
||||
} else {
|
||||
console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
result.splice(idx, 1);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
const result = [...args];
|
||||
result.splice(idx, 1);
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* ElementHandle humanization for Playwright.
|
||||
*
|
||||
* Mirrors Puppeteer's ElementHandle patching architecture.
|
||||
* Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles,
|
||||
* and patches all interaction methods on each ElementHandle instance.
|
||||
*
|
||||
* Playwright ElementHandle methods patched:
|
||||
* click, dblclick, hover, type, fill, press, selectOption,
|
||||
* check, uncheck, setChecked, tap, focus
|
||||
* + $, $$, waitForSelector (nested elements are also patched)
|
||||
*
|
||||
* Stealth-aware:
|
||||
* - Uses CDP DOM.describeNode when available to check element type
|
||||
* (no main-world JS execution)
|
||||
* - Falls back to el.evaluate() only when CDP is unavailable
|
||||
*/
|
||||
|
||||
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
|
||||
import type { HumanConfig } from './config.js';
|
||||
import { rand, randRange, sleep } from './config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Stealth ElementHandle input check — uses CDP DOM.describeNode
|
||||
// ============================================================================
|
||||
|
||||
async function isInputElementHandle(
|
||||
stealth: any, // StealthEval from index.ts
|
||||
el: ElementHandle,
|
||||
): Promise<boolean> {
|
||||
// Try CDP DOM.describeNode first (no main-world JS execution)
|
||||
if (stealth) {
|
||||
try {
|
||||
const cdp: CDPSession = await stealth.getCdpSession();
|
||||
// Playwright exposes the JSHandle's internal preview via _objectId or similar
|
||||
// We need the remote object ID. Try to get it via internal API.
|
||||
const impl = (el as any)._impl ?? (el as any)._object ?? el;
|
||||
const guid = (impl as any)._guid;
|
||||
|
||||
// Use el.evaluate as a reliable fallback within stealth context
|
||||
// Playwright doesn't expose remoteObject directly like Puppeteer
|
||||
} catch { /* fallthrough */ }
|
||||
}
|
||||
|
||||
// Fallback: el.evaluate (works reliably in Playwright)
|
||||
try {
|
||||
return await el.evaluate((node: any) => {
|
||||
const tag = node.tagName?.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| node.getAttribute?.('contenteditable') === 'true';
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// CursorState type (matches index.ts)
|
||||
// ============================================================================
|
||||
|
||||
interface CursorState {
|
||||
x: number;
|
||||
y: number;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Patch a single Playwright ElementHandle
|
||||
// ============================================================================
|
||||
|
||||
export function patchSingleElementHandle(
|
||||
el: ElementHandle,
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: any,
|
||||
): void {
|
||||
if ((el as any)._humanPatched) return;
|
||||
(el as any)._humanPatched = true;
|
||||
|
||||
// Save originals
|
||||
const origElClick = el.click.bind(el);
|
||||
const origElDblclick = el.dblclick.bind(el);
|
||||
const origElHover = el.hover.bind(el);
|
||||
const origElType = el.type.bind(el);
|
||||
const origElFill = el.fill.bind(el);
|
||||
const origElPress = el.press.bind(el);
|
||||
const origElSelectOption = el.selectOption.bind(el);
|
||||
const origElCheck = el.check.bind(el);
|
||||
const origElUncheck = el.uncheck.bind(el);
|
||||
const origElSetChecked = (el as any).setChecked?.bind(el);
|
||||
const origElTap = el.tap.bind(el);
|
||||
const origElFocus = el.focus.bind(el);
|
||||
|
||||
// Nested selectors
|
||||
const origEl$ = el.$.bind(el);
|
||||
const origEl$$ = el.$$.bind(el);
|
||||
const origElWaitForSelector = el.waitForSelector.bind(el);
|
||||
|
||||
// --- Nested elements are also patched ---
|
||||
(el as any).$ = async (selector: string) => {
|
||||
const child = await origEl$(selector);
|
||||
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return child;
|
||||
};
|
||||
|
||||
(el as any).$$ = async (selector: string) => {
|
||||
const children = await origEl$$(selector);
|
||||
for (const child of children) {
|
||||
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
(el as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const child = await origElWaitForSelector(selector, options);
|
||||
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return child;
|
||||
};
|
||||
|
||||
// --- Helper: get bounding box and move cursor to element ---
|
||||
const moveToElement = async () => {
|
||||
// Ensure cursor is initialized
|
||||
const ensureCursorInit = (page as any)._ensureCursorInit;
|
||||
if (ensureCursorInit) await ensureCursorInit();
|
||||
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
const isInp = await isInputElementHandle(stealth, el);
|
||||
const target = clickTarget(box, isInp, cfg);
|
||||
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
return { box, isInp };
|
||||
};
|
||||
|
||||
// --- el.click() ---
|
||||
(el as any).click = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElClick(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
// --- el.dblclick() ---
|
||||
(el as any).dblclick = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElDblclick(options);
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
};
|
||||
|
||||
// --- el.hover() ---
|
||||
(el as any).hover = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElHover(options);
|
||||
// Just move — no click
|
||||
};
|
||||
|
||||
// --- el.type() ---
|
||||
(el as any).type = async (text: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElType(text, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await sleep(rand(100, 250));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||
await humanType(page, rawKb, text, cfg, cdpSession);
|
||||
};
|
||||
|
||||
// --- el.fill() ---
|
||||
(el as any).fill = async (value: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElFill(value, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await sleep(rand(100, 250));
|
||||
// Clear existing content
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
await sleep(rand(50, 150));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||
await humanType(page, rawKb, value, cfg, cdpSession);
|
||||
};
|
||||
|
||||
// --- el.press() ---
|
||||
(el as any).press = async (key: string, options?: any) => {
|
||||
await sleep(rand(20, 60));
|
||||
await originals.keyboardDown(key);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await originals.keyboardUp(key);
|
||||
};
|
||||
|
||||
// --- el.selectOption() ---
|
||||
(el as any).selectOption = async (values: any, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSelectOption(values, options);
|
||||
await humanClick(raw, false, cfg);
|
||||
await sleep(rand(100, 300));
|
||||
return origElSelectOption(values, options);
|
||||
};
|
||||
|
||||
// --- el.check() ---
|
||||
(el as any).check = async (options?: any) => {
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (checked) return; // Already checked
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElCheck(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
// --- el.uncheck() ---
|
||||
(el as any).uncheck = async (options?: any) => {
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (!checked) return; // Already unchecked
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElUncheck(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
// --- el.setChecked() ---
|
||||
if (origElSetChecked) {
|
||||
(el as any).setChecked = async (checked: boolean, options?: any) => {
|
||||
try {
|
||||
const current = await el.isChecked();
|
||||
if (current === checked) return;
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSetChecked(checked, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
}
|
||||
|
||||
// --- el.tap() ---
|
||||
(el as any).tap = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElTap(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
// --- el.focus() ---
|
||||
// Move cursor humanly but use programmatic focus (no click side-effects).
|
||||
// Stock Playwright el.focus() never clicks — clicking would trigger onclick,
|
||||
// submit forms, navigate links, etc.
|
||||
(el as any).focus = async () => {
|
||||
await moveToElement(); // human-like Bézier cursor movement
|
||||
await origElFocus(); // programmatic focus, no click
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Page-level ElementHandle patching
|
||||
// ============================================================================
|
||||
|
||||
export function patchPageElementHandles(
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: any,
|
||||
): void {
|
||||
// Patch page.$() — only if the method exists
|
||||
if (typeof page.$ === 'function') {
|
||||
const orig$ = page.$.bind(page);
|
||||
(page as any).$ = async (selector: string) => {
|
||||
const el = await orig$(selector);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
|
||||
// Patch page.$$()
|
||||
if (typeof page.$$ === 'function') {
|
||||
const orig$$ = page.$$.bind(page);
|
||||
(page as any).$$ = async (selector: string) => {
|
||||
const els = await orig$$(selector);
|
||||
for (const el of els) {
|
||||
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
return els;
|
||||
};
|
||||
}
|
||||
|
||||
// Patch page.waitForSelector()
|
||||
if (typeof page.waitForSelector === 'function') {
|
||||
const origWaitForSelector = page.waitForSelector.bind(page);
|
||||
(page as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const el = await origWaitForSelector(selector, options);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Frame-level ElementHandle patching
|
||||
// ============================================================================
|
||||
|
||||
export function patchFrameElementHandles(
|
||||
frame: Frame,
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: any,
|
||||
): void {
|
||||
// Patch frame.$() — only if the method exists
|
||||
if (typeof frame.$ === 'function') {
|
||||
const origFrame$ = frame.$.bind(frame);
|
||||
(frame as any).$ = async (selector: string) => {
|
||||
const el = await origFrame$(selector);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
|
||||
// Patch frame.$$()
|
||||
if (typeof frame.$$ === 'function') {
|
||||
const origFrame$$ = frame.$$.bind(frame);
|
||||
(frame as any).$$ = async (selector: string) => {
|
||||
const els = await origFrame$$(selector);
|
||||
for (const el of els) {
|
||||
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
return els;
|
||||
};
|
||||
}
|
||||
|
||||
// Patch frame.waitForSelector()
|
||||
if (typeof frame.waitForSelector === 'function') {
|
||||
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
|
||||
(frame as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const el = await origFrameWaitForSelector(selector, options);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,14 @@
|
||||
* Patches all interaction methods:
|
||||
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
|
||||
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
|
||||
*
|
||||
* ELEMENTHANDLE-LEVEL:
|
||||
* click, dblclick, hover, type, fill, press, selectOption,
|
||||
* check, uncheck, setChecked, tap, focus
|
||||
* + $, $$, waitForSelector (nested elements are also patched)
|
||||
*
|
||||
* page.$(), page.$$(), page.waitForSelector() and Frame equivalents
|
||||
* return patched ElementHandles automatically.
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
|
||||
@@ -19,11 +27,13 @@ import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js'
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement } from './scroll.js';
|
||||
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
|
||||
|
||||
export { HumanConfig, resolveConfig } from './config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
export { humanType } from './keyboard.js';
|
||||
export { scrollToElement } from './scroll.js';
|
||||
export { patchSingleElementHandle } from './elementhandle.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
@@ -488,6 +498,9 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
|
||||
// --- Patch Frame-level methods (for sub-frames) ---
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
|
||||
// --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) ---
|
||||
patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
|
||||
|
||||
@@ -511,6 +524,8 @@ function patchFrames(
|
||||
): void {
|
||||
for (const frame of iterFrames(page)) {
|
||||
patchSingleFrame(frame, page, cfg, originals, stealth);
|
||||
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
|
||||
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOption
|
||||
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
import { resolveProxyConfig } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
|
||||
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
|
||||
@@ -39,20 +39,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
@@ -164,11 +163,12 @@ export async function launchPersistentContext(
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
|
||||
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||
@@ -177,9 +177,7 @@ export async function launchPersistentContext(
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
|
||||
@@ -23,6 +23,65 @@ export function ensureProxyScheme(proxyUrl: string): string {
|
||||
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
|
||||
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
|
||||
*/
|
||||
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
|
||||
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
|
||||
|
||||
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
|
||||
export interface ProxyConfig {
|
||||
/** Playwright proxy option (for HTTP proxies). */
|
||||
proxyOption?: ParsedProxy;
|
||||
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
|
||||
proxyArgs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a proxy uses the SOCKS5 protocol.
|
||||
*/
|
||||
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
|
||||
if (!proxy) return false;
|
||||
const url = typeof proxy === "string" ? proxy : proxy.server;
|
||||
return /^socks5h?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
|
||||
*/
|
||||
export function reconstructSocksUrl(proxy: ProxyDict): string {
|
||||
const url = new URL(proxy.server);
|
||||
if (proxy.username) {
|
||||
url.username = encodeURIComponent(proxy.username);
|
||||
if (proxy.password) url.password = encodeURIComponent(proxy.password);
|
||||
}
|
||||
return url.href.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve proxy into Playwright option and/or Chrome args.
|
||||
*
|
||||
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
||||
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
||||
*/
|
||||
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
|
||||
if (!proxy) return { proxyArgs: [] };
|
||||
|
||||
if (isSocksProxy(proxy)) {
|
||||
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyArgs: [`--proxy-server=${proxy}`] };
|
||||
}
|
||||
const socksUrl = reconstructSocksUrl(proxy);
|
||||
const args = [`--proxy-server=${socksUrl}`];
|
||||
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
|
||||
return { proxyArgs: args };
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use Playwright's proxy dict
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
|
||||
}
|
||||
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
|
||||
}
|
||||
|
||||
export function parseProxyUrl(proxy: string): ParsedProxy {
|
||||
let url: URL;
|
||||
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
|
||||
|
||||
+9
-7
@@ -9,7 +9,7 @@ import type { LaunchOptions } from "./types.js";
|
||||
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
|
||||
/**
|
||||
@@ -39,25 +39,27 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
|
||||
// Puppeteer handles proxy via CLI args, not a separate option.
|
||||
// Chromium's --proxy-server does NOT support inline credentials,
|
||||
// so we strip them and use page.authenticate() instead.
|
||||
// SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
|
||||
// HTTP: Chrome does NOT support inline credentials — strip them and
|
||||
// use page.authenticate() for Proxy-Authorization headers instead.
|
||||
let proxyAuth: { username: string; password: string } | undefined;
|
||||
if (options.proxy) {
|
||||
if (typeof options.proxy === "string") {
|
||||
if (isSocksProxy(options.proxy)) {
|
||||
// SOCKS5: pass full URL with credentials to Chrome directly
|
||||
const { proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
args.push(...proxyArgs);
|
||||
} else if (typeof options.proxy === "string") {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
} else {
|
||||
// Strip any inline credentials from the server URL — Chromium's
|
||||
// --proxy-server doesn't support them; use page.authenticate() instead.
|
||||
const parsed = parseProxyUrl(options.proxy.server);
|
||||
args.push(`--proxy-server=${parsed.server}`);
|
||||
if (options.proxy.bypass) {
|
||||
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
|
||||
}
|
||||
// Explicit username/password fields take precedence over inline creds
|
||||
const username = options.proxy.username ?? parsed.username;
|
||||
const password = options.proxy.password ?? parsed.password;
|
||||
if (username) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js";
|
||||
import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js";
|
||||
import { patchPageElementHandles } from "../src/human/elementhandle.js";
|
||||
|
||||
// =========================================================================
|
||||
// Config resolution
|
||||
@@ -689,6 +690,349 @@ describe("humanType non-ASCII", () => {
|
||||
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// ElementHandle patching (Playwright)
|
||||
// =========================================================================
|
||||
|
||||
function buildMockElementHandle(overrides: Record<string, any> = {}): any {
|
||||
const el: any = {
|
||||
click: vi.fn(async () => {}),
|
||||
dblclick: vi.fn(async () => {}),
|
||||
hover: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
fill: vi.fn(async () => {}),
|
||||
press: vi.fn(async () => {}),
|
||||
selectOption: vi.fn(async () => {}),
|
||||
check: vi.fn(async () => {}),
|
||||
uncheck: vi.fn(async () => {}),
|
||||
setChecked: vi.fn(async () => {}),
|
||||
tap: vi.fn(async () => {}),
|
||||
focus: vi.fn(async () => {}),
|
||||
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
$: vi.fn(async () => null),
|
||||
$$: vi.fn(async () => []),
|
||||
waitForSelector: vi.fn(async () => null),
|
||||
_humanPatched: false,
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("patchSingleElementHandle", () => {
|
||||
it("marks element as patched", async () => {
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
};
|
||||
const rawKb = {
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async () => {}),
|
||||
};
|
||||
const originals = {
|
||||
keyboardPress: vi.fn(async () => {}),
|
||||
keyboardDown: vi.fn(async () => {}),
|
||||
keyboardUp: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
expect(el._humanPatched).toBe(true);
|
||||
});
|
||||
|
||||
it("el.click calls mouse.move and mouse.down/up (humanized path)", async () => {
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
|
||||
let moveCount = 0;
|
||||
let downCalled = false;
|
||||
let upCalled = false;
|
||||
const raw = {
|
||||
move: vi.fn(async () => { moveCount++; }),
|
||||
down: vi.fn(async () => { downCalled = true; }),
|
||||
up: vi.fn(async () => { upCalled = true; }),
|
||||
wheel: vi.fn(async () => {}),
|
||||
};
|
||||
const rawKb = {
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async () => {}),
|
||||
};
|
||||
const originals = {
|
||||
keyboardPress: vi.fn(async () => {}),
|
||||
keyboardDown: vi.fn(async () => {}),
|
||||
keyboardUp: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
await el.click();
|
||||
|
||||
expect(moveCount).toBeGreaterThan(0);
|
||||
expect(downCalled).toBe(true);
|
||||
expect(upCalled).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("el.hover calls mouse.move but NOT down/up", async () => {
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
|
||||
let downCalled = false;
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => { downCalled = true; }),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
};
|
||||
const rawKb = {
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async () => {}),
|
||||
};
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
await el.hover();
|
||||
|
||||
expect(raw.move).toHaveBeenCalled();
|
||||
expect(downCalled).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("el.type triggers mouse move + click + keyboard events", async () => {
|
||||
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 };
|
||||
|
||||
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) }); // isInput = 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");
|
||||
|
||||
expect(raw.move).toHaveBeenCalled();
|
||||
expect(raw.down).toHaveBeenCalled(); // click to focus
|
||||
expect(rawKb.down).toHaveBeenCalled(); // keyboard typing
|
||||
}, 30000);
|
||||
|
||||
it("el.fill calls selectAll + backspace + type", async () => {
|
||||
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 };
|
||||
|
||||
const pressedKeys: string[] = [];
|
||||
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 (key: string) => { pressedKeys.push(key); }),
|
||||
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.fill("newtext");
|
||||
|
||||
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
||||
expect(pressedKeys).toContain(expected);
|
||||
expect(pressedKeys).toContain("Backspace");
|
||||
}, 30000);
|
||||
|
||||
it("no double patching", async () => {
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
const firstClick = el.click;
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
expect(el.click).toBe(firstClick);
|
||||
});
|
||||
|
||||
it("nested $() returns patched child handle", async () => {
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const child = buildMockElementHandle();
|
||||
const el = buildMockElementHandle();
|
||||
el.$ = vi.fn(async () => child);
|
||||
|
||||
const page = buildMockPage();
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
const result = await el.$("span");
|
||||
expect(result._humanPatched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchPageElementHandles", () => {
|
||||
it("page.$() returns patched ElementHandle", async () => {
|
||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
(page as any).$ = vi.fn(async () => el);
|
||||
(page as any).$$ = vi.fn(async () => [el]);
|
||||
(page as any).waitForSelector = vi.fn(async () => el);
|
||||
|
||||
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
const result = await (page as any).$("#test");
|
||||
expect(result._humanPatched).toBe(true);
|
||||
});
|
||||
|
||||
it("page.$$() returns all patched handles", async () => {
|
||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const el1 = buildMockElementHandle();
|
||||
const el2 = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
(page as any).$ = vi.fn(async () => null);
|
||||
(page as any).$$ = vi.fn(async () => [el1, el2]);
|
||||
(page as any).waitForSelector = vi.fn(async () => null);
|
||||
|
||||
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
const results = await (page as any).$$("div");
|
||||
expect(results[0]._humanPatched).toBe(true);
|
||||
expect(results[1]._humanPatched).toBe(true);
|
||||
});
|
||||
|
||||
it("page.waitForSelector() returns patched handle", async () => {
|
||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
(page as any).$ = vi.fn(async () => null);
|
||||
(page as any).$$ = vi.fn(async () => []);
|
||||
(page as any).waitForSelector = vi.fn(async () => el);
|
||||
|
||||
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
const result = await (page as any).waitForSelector("#test");
|
||||
expect(result._humanPatched).toBe(true);
|
||||
});
|
||||
|
||||
it("page.$() returns null when no element found (no crash)", async () => {
|
||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const page = buildMockPage();
|
||||
(page as any).$ = vi.fn(async () => null);
|
||||
(page as any).$$ = vi.fn(async () => []);
|
||||
(page as any).waitForSelector = vi.fn(async () => null);
|
||||
|
||||
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
const result = await (page as any).$("#nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchPage integrates ElementHandle patching", () => {
|
||||
it("patchPage patches page.$ automatically", async () => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
const page = buildMockPage();
|
||||
(page as any).$ = vi.fn(async () => el);
|
||||
(page as any).$$ = vi.fn(async () => []);
|
||||
(page as any).waitForSelector = vi.fn(async () => null);
|
||||
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
const result = await (page as any).$("#test");
|
||||
expect(result._humanPatched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function buildMockFrame(): any {
|
||||
return {
|
||||
click: vi.fn(async () => {}),
|
||||
|
||||
+90
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseProxyUrl } from "../src/proxy.js";
|
||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
describe("parseProxyUrl", () => {
|
||||
@@ -115,3 +115,92 @@ describe("bare proxy format (user:pass@host:port)", () => {
|
||||
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSocksProxy", () => {
|
||||
it("detects socks5 string", () => {
|
||||
expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects socks5h string", () => {
|
||||
expect(isSocksProxy("socks5h://host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("case insensitive", () => {
|
||||
expect(isSocksProxy("SOCKS5://host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http", () => {
|
||||
expect(isSocksProxy("http://host:8080")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects socks5 dict", () => {
|
||||
expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http dict", () => {
|
||||
expect(isSocksProxy({ server: "http://host:8080" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isSocksProxy(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProxyConfig", () => {
|
||||
it("returns empty for undefined", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns playwright dict for http string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns playwright dict for http dict", () => {
|
||||
const proxy = { server: "http://proxy:8080", bypass: ".example.com" };
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(proxy);
|
||||
expect(proxyOption).toEqual(proxy);
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5 string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5 no auth", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5h string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]);
|
||||
});
|
||||
|
||||
it("reconstructs URL from socks5 dict with auth", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig({
|
||||
server: "socks5://host:1080",
|
||||
username: "user",
|
||||
password: "p@ss",
|
||||
});
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs.length).toBe(1);
|
||||
expect(proxyArgs[0]).toContain("--proxy-server=socks5://user:p%40ss@host:1080");
|
||||
});
|
||||
|
||||
it("includes bypass for socks5 dict", () => {
|
||||
const { proxyArgs } = resolveProxyConfig({
|
||||
server: "socks5://host:1080",
|
||||
bypass: ".example.com",
|
||||
});
|
||||
expect(proxyArgs).toContain("--proxy-server=socks5://host:1080");
|
||||
expect(proxyArgs).toContain("--proxy-bypass-list=.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,4 +113,29 @@ describe("puppeteer launch", () => {
|
||||
expect(callArgs.args).toContain("--disable-gpu");
|
||||
expect(callArgs.args).toContain("--no-first-run");
|
||||
});
|
||||
|
||||
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
|
||||
|
||||
// Should NOT set up page.authenticate for SOCKS5
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({
|
||||
proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" },
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080");
|
||||
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
geoip = ["geoip2>=4.0"]
|
||||
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
|
||||
patchright = ["patchright>=1.40"]
|
||||
serve = ["aiohttp>=3.9", "websockets>=12.0"]
|
||||
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
|
||||
|
||||
@@ -105,8 +105,10 @@ class TestParseCliArgs:
|
||||
|
||||
def test_passthrough_args(self):
|
||||
args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"]
|
||||
_, passthrough = parse_cli_args(args)
|
||||
assert passthrough == args
|
||||
config, passthrough = parse_cli_args(args)
|
||||
# --fingerprint=999 is consumed into config["default_seed"], not passed through
|
||||
assert passthrough == ["--no-sandbox", "--disable-gpu"]
|
||||
assert config["default_seed"] == "999"
|
||||
|
||||
def test_port_not_in_passthrough(self):
|
||||
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
|
||||
|
||||
@@ -279,6 +279,67 @@ if __name__ == "__main__":
|
||||
check("keyboard.type", kb_ms > 500, f"{kb_ms} ms")
|
||||
time.sleep(1)
|
||||
|
||||
# ============================================================
|
||||
# SCENARIO 7: ElementHandle — query_selector interactions
|
||||
# ============================================================
|
||||
step("ElementHandle — query_selector click, type, fill, hover")
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
inject(page)
|
||||
time.sleep(1)
|
||||
|
||||
print(" Watch: get element via query_selector, cursor moves smoothly")
|
||||
el = page.query_selector('#searchInput')
|
||||
assert el is not None, "query_selector returned None"
|
||||
assert getattr(el, '_human_patched', False), "ElementHandle not patched!"
|
||||
|
||||
t0 = time.time()
|
||||
el.click()
|
||||
eh_click_ms = int((time.time() - t0) * 1000)
|
||||
check("ElementHandle click", eh_click_ms > 100, f"{eh_click_ms} ms")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: ElementHandle type — characters appear one by one")
|
||||
t0 = time.time()
|
||||
el.type('ElementHandle typing')
|
||||
eh_type_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
check("ElementHandle type", val == 'ElementHandle typing' and eh_type_ms > 1500, f"{eh_type_ms} ms, value='{val}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: ElementHandle fill — clears then types")
|
||||
t0 = time.time()
|
||||
el.fill('Filled via EH')
|
||||
eh_fill_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
check("ElementHandle fill", val == 'Filled via EH' and eh_fill_ms > 1000, f"{eh_fill_ms} ms, value='{val}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: ElementHandle hover — cursor moves without clicking")
|
||||
btn_el = page.query_selector('button[type="submit"]')
|
||||
t0 = time.time()
|
||||
btn_el.hover()
|
||||
eh_hover_ms = int((time.time() - t0) * 1000)
|
||||
check("ElementHandle hover", eh_hover_ms > 50, f"{eh_hover_ms} ms")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: query_selector_all returns patched handles")
|
||||
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
inject(page)
|
||||
time.sleep(1)
|
||||
els = page.query_selector_all('input[type="checkbox"]')
|
||||
all_patched = all(getattr(e, '_human_patched', False) for e in els)
|
||||
check("query_selector_all all patched", all_patched and len(els) >= 2, f"{len(els)} elements, all_patched={all_patched}")
|
||||
|
||||
if els:
|
||||
print(" Watch: click checkbox via ElementHandle")
|
||||
t0 = time.time()
|
||||
els[0].click()
|
||||
cb_click_ms = int((time.time() - t0) * 1000)
|
||||
check("ElementHandle checkbox click", cb_click_ms > 100, f"{cb_click_ms} ms")
|
||||
time.sleep(1)
|
||||
|
||||
# ============================================================
|
||||
# SUMMARY
|
||||
# ============================================================
|
||||
|
||||
+634
-26
@@ -12,7 +12,7 @@ Can also run directly: python tests/test_humanize_unit.py
|
||||
import math
|
||||
import time
|
||||
import sys
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -535,7 +535,7 @@ class TestNonAsciiKeyboardAsync:
|
||||
class TestBrowserFill:
|
||||
def test_fill_clears_existing(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
@@ -550,7 +550,7 @@ class TestBrowserFill:
|
||||
|
||||
def test_fill_timing_humanized(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
@@ -562,7 +562,7 @@ class TestBrowserFill:
|
||||
|
||||
def test_clear_empties_field(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
@@ -579,7 +579,7 @@ class TestBrowserFill:
|
||||
class TestBrowserPatching:
|
||||
def test_page_has_original(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
assert hasattr(page, '_original')
|
||||
assert hasattr(page, '_human_cfg')
|
||||
@@ -587,7 +587,7 @@ class TestBrowserPatching:
|
||||
|
||||
def test_locator_methods_patched(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
from playwright.sync_api._generated import Locator
|
||||
methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck',
|
||||
@@ -608,7 +608,7 @@ class TestBrowserPatching:
|
||||
|
||||
def test_page_human_cfg_persists(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
assert page._human_cfg is not None
|
||||
assert hasattr(page._human_cfg, 'idle_between_actions')
|
||||
@@ -618,7 +618,7 @@ class TestBrowserPatching:
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestBrowserBotDetection:
|
||||
PROXY = ''
|
||||
PROXY = None
|
||||
|
||||
def test_behavioral_checks_pass(self):
|
||||
from cloakbrowser import launch
|
||||
@@ -644,7 +644,7 @@ class TestBrowserBotDetection:
|
||||
|
||||
def test_form_timing(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True)
|
||||
browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
|
||||
wait_until='domcontentloaded')
|
||||
@@ -661,31 +661,639 @@ class TestBrowserBotDetection:
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestAsyncEndToEnd:
|
||||
def test_async_launch_click_fill(self):
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_launch_click_fill(self):
|
||||
"""launch_async(humanize=True) — async page.click and page.fill work end-to-end."""
|
||||
import asyncio
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
browser = await launch_async(headless=False, humanize=True)
|
||||
page = await browser.new_page()
|
||||
assert hasattr(page, '_original'), "async page not patched"
|
||||
assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"
|
||||
|
||||
async def _run():
|
||||
browser = await launch_async(headless=True, humanize=True)
|
||||
page = await browser.new_page()
|
||||
assert hasattr(page, '_original'), "async page not patched"
|
||||
assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"
|
||||
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
await asyncio.sleep(1)
|
||||
t0 = time.time()
|
||||
await page.locator('#searchInput').fill('async test')
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms"
|
||||
|
||||
t0 = time.time()
|
||||
await page.locator('#searchInput').fill('async test')
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms"
|
||||
val = await page.locator('#searchInput').input_value()
|
||||
assert val == 'async test', f"async fill wrong value: {val}"
|
||||
|
||||
val = await page.locator('#searchInput').input_value()
|
||||
assert val == 'async test', f"async fill wrong value: {val}"
|
||||
await browser.close()
|
||||
|
||||
await browser.close()
|
||||
|
||||
asyncio.run(_run())
|
||||
# =========================================================================
|
||||
# 12. ElementHandle patching — SYNC
|
||||
# =========================================================================
|
||||
|
||||
class TestElementHandlePatchingSync:
|
||||
"""Test that ElementHandle objects returned by query_selector etc. are humanized."""
|
||||
|
||||
def test_patch_single_element_handle_marks_patched(self):
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=True) # is_input
|
||||
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)
|
||||
|
||||
raw_mouse = MagicMock()
|
||||
raw_keyboard = MagicMock()
|
||||
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None
|
||||
)
|
||||
|
||||
assert el._human_patched is True
|
||||
|
||||
def test_element_handle_click_calls_human_move(self):
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {"idle_between_actions": False})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
page = MagicMock()
|
||||
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=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)
|
||||
|
||||
raw_mouse = MagicMock()
|
||||
raw_mouse.move = MagicMock()
|
||||
raw_mouse.down = MagicMock()
|
||||
raw_mouse.up = MagicMock()
|
||||
raw_mouse.wheel = MagicMock()
|
||||
raw_keyboard = MagicMock()
|
||||
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None
|
||||
)
|
||||
|
||||
# Call the patched click
|
||||
el.click()
|
||||
|
||||
# Should call raw_mouse.move (Bezier path) and then down/up
|
||||
assert raw_mouse.move.called
|
||||
assert raw_mouse.down.called
|
||||
assert raw_mouse.up.called
|
||||
|
||||
def test_element_handle_hover_moves_cursor_without_click(self):
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {"idle_between_actions": False})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 50
|
||||
cursor.y = 50
|
||||
page = MagicMock()
|
||||
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=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)
|
||||
|
||||
raw_mouse = MagicMock()
|
||||
raw_mouse.move = MagicMock()
|
||||
raw_mouse.down = MagicMock()
|
||||
raw_mouse.up = MagicMock()
|
||||
raw_mouse.wheel = MagicMock()
|
||||
raw_keyboard = MagicMock()
|
||||
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None
|
||||
)
|
||||
|
||||
el.hover()
|
||||
|
||||
# Move should be called, but NOT down/up (hover, not click)
|
||||
assert raw_mouse.move.called
|
||||
assert not raw_mouse.down.called
|
||||
|
||||
def test_element_handle_type_calls_human_type(self):
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 50
|
||||
cursor.y = 50
|
||||
page = MagicMock()
|
||||
originals = MagicMock()
|
||||
page._original = originals
|
||||
|
||||
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) # is input
|
||||
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)
|
||||
|
||||
raw_mouse = MagicMock()
|
||||
raw_mouse.move = MagicMock()
|
||||
raw_mouse.down = MagicMock()
|
||||
raw_mouse.up = MagicMock()
|
||||
raw_mouse.wheel = MagicMock()
|
||||
raw_keyboard = MagicMock()
|
||||
raw_keyboard.down = MagicMock()
|
||||
raw_keyboard.up = MagicMock()
|
||||
raw_keyboard.insert_text = MagicMock()
|
||||
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None
|
||||
)
|
||||
|
||||
el.type("hello")
|
||||
|
||||
# Mouse moved + clicked (to focus), then keyboard used
|
||||
assert raw_mouse.move.called
|
||||
assert raw_mouse.down.called # click to focus the input
|
||||
# Keyboard events should have fired (down/up for ASCII chars)
|
||||
assert raw_keyboard.down.called or raw_keyboard.insert_text.called
|
||||
|
||||
def test_element_handle_fill_clears_and_types(self):
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 50
|
||||
cursor.y = 50
|
||||
page = MagicMock()
|
||||
originals = MagicMock()
|
||||
page._original = originals
|
||||
|
||||
pressed_keys = []
|
||||
originals.keyboard_press = MagicMock(side_effect=lambda k: pressed_keys.append(k))
|
||||
|
||||
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)
|
||||
|
||||
raw_mouse = MagicMock()
|
||||
raw_mouse.move = MagicMock()
|
||||
raw_mouse.down = MagicMock()
|
||||
raw_mouse.up = MagicMock()
|
||||
raw_mouse.wheel = MagicMock()
|
||||
raw_keyboard = MagicMock()
|
||||
raw_keyboard.down = MagicMock()
|
||||
raw_keyboard.up = MagicMock()
|
||||
raw_keyboard.insert_text = MagicMock()
|
||||
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None
|
||||
)
|
||||
|
||||
el.fill("replaced")
|
||||
|
||||
# Should have pressed Select-All and Backspace to clear
|
||||
import sys
|
||||
expected_select = "Meta+a" if sys.platform == "darwin" else "Control+a"
|
||||
assert expected_select in pressed_keys
|
||||
assert "Backspace" in pressed_keys
|
||||
|
||||
def test_element_handle_no_double_patching(self):
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
el.wait_for_selector = MagicMock(return_value=None)
|
||||
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
|
||||
)
|
||||
|
||||
# Save patched click
|
||||
first_click = el.click
|
||||
|
||||
# Try to patch again
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
|
||||
)
|
||||
|
||||
# Should be the same — no double wrap
|
||||
assert el.click is first_click
|
||||
|
||||
def test_nested_query_selector_returns_patched_handle(self):
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
|
||||
child = MagicMock()
|
||||
child._human_patched = False
|
||||
child.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30})
|
||||
child.evaluate = MagicMock(return_value=False)
|
||||
child.is_checked = MagicMock(return_value=False)
|
||||
child.query_selector = MagicMock(return_value=None)
|
||||
child.query_selector_all = MagicMock(return_value=[])
|
||||
child.wait_for_selector = MagicMock(return_value=None)
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=child)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
el.wait_for_selector = MagicMock(return_value=None)
|
||||
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
|
||||
)
|
||||
|
||||
result = el.query_selector("span")
|
||||
assert result._human_patched is True
|
||||
|
||||
def test_page_query_selector_patched(self):
|
||||
from cloakbrowser.human import _patch_page_element_handles_sync, _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "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)
|
||||
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page.query_selector = MagicMock(return_value=el)
|
||||
page.query_selector_all = MagicMock(return_value=[el])
|
||||
page.wait_for_selector = MagicMock(return_value=el)
|
||||
|
||||
_patch_page_element_handles_sync(
|
||||
page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
|
||||
)
|
||||
|
||||
result = page.query_selector("#test")
|
||||
assert result._human_patched is True
|
||||
|
||||
def test_page_query_selector_all_patches_all(self):
|
||||
from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
|
||||
def make_el():
|
||||
e = MagicMock()
|
||||
e._human_patched = False
|
||||
e.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30})
|
||||
e.evaluate = MagicMock(return_value=False)
|
||||
e.is_checked = MagicMock(return_value=False)
|
||||
e.query_selector = MagicMock(return_value=None)
|
||||
e.query_selector_all = MagicMock(return_value=[])
|
||||
e.wait_for_selector = MagicMock(return_value=None)
|
||||
return e
|
||||
|
||||
el1, el2, el3 = make_el(), make_el(), make_el()
|
||||
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page.query_selector = MagicMock(return_value=None)
|
||||
page.query_selector_all = MagicMock(return_value=[el1, el2, el3])
|
||||
page.wait_for_selector = MagicMock(return_value=None)
|
||||
|
||||
_patch_page_element_handles_sync(
|
||||
page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
|
||||
)
|
||||
|
||||
results = page.query_selector_all("div")
|
||||
for r in results:
|
||||
assert r._human_patched is True
|
||||
|
||||
def test_wait_for_selector_patched(self):
|
||||
from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "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)
|
||||
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page.query_selector = MagicMock(return_value=None)
|
||||
page.query_selector_all = MagicMock(return_value=[])
|
||||
page.wait_for_selector = MagicMock(return_value=el)
|
||||
|
||||
_patch_page_element_handles_sync(
|
||||
page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
|
||||
)
|
||||
|
||||
result = page.wait_for_selector("#test")
|
||||
assert result._human_patched is True
|
||||
|
||||
def test_element_handle_all_methods_patched(self):
|
||||
"""Verify all expected interaction methods are replaced."""
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "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)
|
||||
el.set_checked = MagicMock() # ensure it exists
|
||||
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None
|
||||
)
|
||||
|
||||
expected_methods = ['click', 'dblclick', 'hover', 'type', 'fill', 'press',
|
||||
'select_option', 'check', 'uncheck', 'set_checked',
|
||||
'tap', 'focus', 'query_selector', 'query_selector_all',
|
||||
'wait_for_selector']
|
||||
for method in expected_methods:
|
||||
fn = getattr(el, method)
|
||||
assert not isinstance(fn, MagicMock), f"el.{method} was not patched"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 13. ElementHandle patching — ASYNC
|
||||
# =========================================================================
|
||||
|
||||
class TestElementHandlePatchingAsync:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_element_handle_click(self):
|
||||
from cloakbrowser.human import _patch_single_element_handle_async, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
cfg = resolve_config("default", {"idle_between_actions": False})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
|
||||
page = MagicMock()
|
||||
originals = MagicMock()
|
||||
originals.mouse_move = AsyncMock()
|
||||
page._original = originals
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = AsyncMock(return_value=False)
|
||||
el.is_checked = AsyncMock(return_value=False)
|
||||
el.query_selector = AsyncMock(return_value=None)
|
||||
el.query_selector_all = AsyncMock(return_value=[])
|
||||
el.wait_for_selector = AsyncMock(return_value=None)
|
||||
|
||||
raw_mouse = MagicMock()
|
||||
raw_mouse.move = AsyncMock()
|
||||
raw_mouse.down = AsyncMock()
|
||||
raw_mouse.up = AsyncMock()
|
||||
raw_mouse.wheel = AsyncMock()
|
||||
raw_keyboard = MagicMock()
|
||||
raw_keyboard.down = AsyncMock()
|
||||
raw_keyboard.up = AsyncMock()
|
||||
raw_keyboard.insert_text = AsyncMock()
|
||||
|
||||
stealth = MagicMock()
|
||||
stealth.get_cdp_session = AsyncMock(return_value=None)
|
||||
|
||||
_patch_single_element_handle_async(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, [None]
|
||||
)
|
||||
|
||||
await el.click()
|
||||
|
||||
assert raw_mouse.move.called
|
||||
assert raw_mouse.down.called
|
||||
assert raw_mouse.up.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_page_query_selector_patched(self):
|
||||
from cloakbrowser.human import _patch_page_element_handles_async, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = AsyncMock(return_value=False)
|
||||
el.is_checked = AsyncMock(return_value=False)
|
||||
el.query_selector = AsyncMock(return_value=None)
|
||||
el.query_selector_all = AsyncMock(return_value=[])
|
||||
el.wait_for_selector = AsyncMock(return_value=None)
|
||||
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page.query_selector = AsyncMock(return_value=el)
|
||||
page.query_selector_all = AsyncMock(return_value=[el])
|
||||
page.wait_for_selector = AsyncMock(return_value=el)
|
||||
|
||||
stealth = MagicMock()
|
||||
stealth.get_cdp_session = AsyncMock(return_value=None)
|
||||
|
||||
_patch_page_element_handles_async(
|
||||
page, cfg, cursor, MagicMock(), MagicMock(), page._original, stealth, [None]
|
||||
)
|
||||
|
||||
result = await page.query_selector("#test")
|
||||
assert result._human_patched is True
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 14. SLOW: Browser ElementHandle end-to-end
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestBrowserElementHandle:
|
||||
def test_query_selector_click_humanized(self):
|
||||
"""page.query_selector() returns a patched handle — el.click() uses human curves."""
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
|
||||
el = page.query_selector('#searchInput')
|
||||
assert el is not None
|
||||
assert getattr(el, '_human_patched', False), "ElementHandle not patched"
|
||||
|
||||
t0 = time.time()
|
||||
el.click()
|
||||
click_ms = int((time.time() - t0) * 1000)
|
||||
assert click_ms > 100, f"ElementHandle click too fast: {click_ms}ms (not humanized)"
|
||||
browser.close()
|
||||
|
||||
def test_query_selector_type_humanized(self):
|
||||
"""el.type() should type character-by-character with human timing."""
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
|
||||
el = page.query_selector('#searchInput')
|
||||
assert el is not None
|
||||
|
||||
t0 = time.time()
|
||||
el.type('ElementHandle test')
|
||||
type_ms = int((time.time() - t0) * 1000)
|
||||
assert type_ms > 1000, f"ElementHandle type too fast: {type_ms}ms"
|
||||
|
||||
val = page.locator('#searchInput').input_value()
|
||||
assert val == 'ElementHandle test'
|
||||
browser.close()
|
||||
|
||||
def test_query_selector_fill_humanized(self):
|
||||
"""el.fill() should clear + type with human timing."""
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
|
||||
el = page.query_selector('#searchInput')
|
||||
el.type('initial')
|
||||
time.sleep(0.3)
|
||||
|
||||
t0 = time.time()
|
||||
el.fill('replaced')
|
||||
fill_ms = int((time.time() - t0) * 1000)
|
||||
assert fill_ms > 500, f"ElementHandle fill too fast: {fill_ms}ms"
|
||||
|
||||
val = page.locator('#searchInput').input_value()
|
||||
assert val == 'replaced'
|
||||
browser.close()
|
||||
|
||||
def test_query_selector_all_returns_patched(self):
|
||||
"""page.query_selector_all() returns all handles patched."""
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
|
||||
els = page.query_selector_all('input[type="checkbox"]')
|
||||
assert len(els) >= 2
|
||||
for el in els:
|
||||
assert getattr(el, '_human_patched', False), "ElementHandle not patched"
|
||||
browser.close()
|
||||
|
||||
def test_query_selector_hover_humanized(self):
|
||||
"""el.hover() should move cursor with human Bezier curve."""
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
|
||||
el = page.query_selector('#searchInput')
|
||||
t0 = time.time()
|
||||
el.hover()
|
||||
hover_ms = int((time.time() - t0) * 1000)
|
||||
assert hover_ms > 50, f"ElementHandle hover too fast: {hover_ms}ms"
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestAsyncElementHandle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_query_selector_click(self):
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
browser = await launch_async(headless=False, humanize=True)
|
||||
page = await browser.new_page()
|
||||
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
await asyncio.sleep(1)
|
||||
|
||||
el = await page.query_selector('#searchInput')
|
||||
assert el is not None
|
||||
assert getattr(el, '_human_patched', False), "Async ElementHandle not patched"
|
||||
|
||||
t0 = time.time()
|
||||
await el.click()
|
||||
click_ms = int((time.time() - t0) * 1000)
|
||||
assert click_ms > 100, f"Async ElementHandle click too fast: {click_ms}ms"
|
||||
|
||||
await browser.close()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
||||
+127
-15
@@ -2,7 +2,12 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url
|
||||
from cloakbrowser.browser import (
|
||||
_is_socks_proxy,
|
||||
_parse_proxy_url,
|
||||
_resolve_proxy_config,
|
||||
maybe_resolve_geoip,
|
||||
)
|
||||
|
||||
|
||||
class TestParseProxyUrl:
|
||||
@@ -38,23 +43,30 @@ class TestParseProxyUrl:
|
||||
|
||||
|
||||
class TestBuildProxyKwargs:
|
||||
"""Tests for _resolve_proxy_config (formerly _build_proxy_kwargs) HTTP path."""
|
||||
|
||||
def test_none(self):
|
||||
assert _build_proxy_kwargs(None) == {}
|
||||
kwargs, args = _resolve_proxy_config(None)
|
||||
assert kwargs == {}
|
||||
assert args == []
|
||||
|
||||
def test_simple_proxy(self):
|
||||
result = _build_proxy_kwargs("http://proxy:8080")
|
||||
assert result == {"proxy": {"server": "http://proxy:8080"}}
|
||||
kwargs, args = _resolve_proxy_config("http://proxy:8080")
|
||||
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_with_auth(self):
|
||||
result = _build_proxy_kwargs("http://user:pass@proxy:8080")
|
||||
assert result == {
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert kwargs == {
|
||||
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_dict_passthrough(self):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
kwargs, args = _resolve_proxy_config(proxy_dict)
|
||||
assert kwargs == {"proxy": proxy_dict}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_dict_with_auth(self):
|
||||
proxy_dict = {
|
||||
@@ -63,8 +75,9 @@ class TestBuildProxyKwargs:
|
||||
"password": "pass",
|
||||
"bypass": ".example.com",
|
||||
}
|
||||
result = _build_proxy_kwargs(proxy_dict)
|
||||
assert result == {"proxy": proxy_dict}
|
||||
kwargs, args = _resolve_proxy_config(proxy_dict)
|
||||
assert kwargs == {"proxy": proxy_dict}
|
||||
assert args == []
|
||||
|
||||
|
||||
class TestMaybeResolveGeoip:
|
||||
@@ -117,6 +130,27 @@ class TestMaybeResolveGeoip:
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "America/New_York"
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
|
||||
def test_geoip_socks5_dict_reconstructs_credentials(self, mock_geo):
|
||||
proxy_dict = {"server": "socks5://proxy:1080", "username": "user", "password": "pass"}
|
||||
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("socks5://user:pass@proxy:1080")
|
||||
assert tz == "Europe/Berlin"
|
||||
assert locale == "de-DE"
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
|
||||
def test_geoip_socks5_dict_no_auth_uses_server(self, mock_geo):
|
||||
proxy_dict = {"server": "socks5://proxy:1080"}
|
||||
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("socks5://proxy:1080")
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "1.1.1.1"))
|
||||
def test_geoip_http_dict_does_not_inline_creds(self, mock_geo):
|
||||
# HTTP dict: credentials stay separate, only server URL passed
|
||||
proxy_dict = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
|
||||
|
||||
class TestBareProxyFormat:
|
||||
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
|
||||
@@ -149,8 +183,86 @@ class TestBareProxyFormat:
|
||||
r = _parse_proxy_url("proxy:8080")
|
||||
assert r == {"server": "proxy:8080"}
|
||||
|
||||
def test_build_proxy_kwargs_bare(self):
|
||||
r = _build_proxy_kwargs("user:pass@proxy:8080")
|
||||
assert r["proxy"]["username"] == "user"
|
||||
assert r["proxy"]["password"] == "pass"
|
||||
assert "user" not in r["proxy"]["server"]
|
||||
def test_resolve_proxy_config_bare(self):
|
||||
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
|
||||
assert kwargs["proxy"]["username"] == "user"
|
||||
assert kwargs["proxy"]["password"] == "pass"
|
||||
assert "user" not in kwargs["proxy"]["server"]
|
||||
|
||||
|
||||
class TestIsSocksProxy:
|
||||
def test_socks5_string(self):
|
||||
assert _is_socks_proxy("socks5://user:pass@host:1080") is True
|
||||
|
||||
def test_socks5h_string(self):
|
||||
assert _is_socks_proxy("socks5h://host:1080") is True
|
||||
|
||||
def test_socks5_uppercase(self):
|
||||
assert _is_socks_proxy("SOCKS5://host:1080") is True
|
||||
|
||||
def test_http_string(self):
|
||||
assert _is_socks_proxy("http://host:8080") is False
|
||||
|
||||
def test_dict_socks5(self):
|
||||
assert _is_socks_proxy({"server": "socks5://host:1080"}) is True
|
||||
|
||||
def test_dict_http(self):
|
||||
assert _is_socks_proxy({"server": "http://host:8080"}) is False
|
||||
|
||||
def test_none(self):
|
||||
assert _is_socks_proxy(None) is False
|
||||
|
||||
|
||||
class TestResolveProxyConfig:
|
||||
def test_none(self):
|
||||
kwargs, args = _resolve_proxy_config(None)
|
||||
assert kwargs == {}
|
||||
assert args == []
|
||||
|
||||
def test_http_string_returns_playwright_dict(self):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert "proxy" in kwargs
|
||||
assert kwargs["proxy"]["server"] == "http://proxy:8080"
|
||||
assert kwargs["proxy"]["username"] == "user"
|
||||
assert args == []
|
||||
|
||||
def test_http_dict_passthrough(self):
|
||||
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {"proxy": proxy}
|
||||
assert args == []
|
||||
|
||||
def test_socks5_string_returns_chrome_arg(self):
|
||||
kwargs, args = _resolve_proxy_config("socks5://user:pass@host:1080")
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=socks5://user:pass@host:1080"]
|
||||
|
||||
def test_socks5_no_auth_returns_chrome_arg(self):
|
||||
kwargs, args = _resolve_proxy_config("socks5://host:1080")
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=socks5://host:1080"]
|
||||
|
||||
def test_socks5h_returns_chrome_arg(self):
|
||||
kwargs, args = _resolve_proxy_config("socks5h://user:pass@host:1080")
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=socks5h://user:pass@host:1080"]
|
||||
|
||||
def test_socks5_dict_reconstructs_url(self):
|
||||
proxy = {"server": "socks5://host:1080", "username": "user", "password": "p@ss"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {}
|
||||
assert len(args) == 1
|
||||
assert args[0].startswith("--proxy-server=socks5://user:p%40ss@host:1080")
|
||||
|
||||
def test_socks5_dict_ipv6_preserves_brackets(self):
|
||||
proxy = {"server": "socks5://[::1]:1080", "username": "user", "password": "pass"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {}
|
||||
assert "[::1]" in args[0]
|
||||
|
||||
def test_socks5_dict_with_bypass(self):
|
||||
proxy = {"server": "socks5://host:1080", "bypass": ".example.com"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {}
|
||||
assert "--proxy-server=socks5://host:1080" in args
|
||||
assert "--proxy-bypass-list=.example.com" in args
|
||||
|
||||
Reference in New Issue
Block a user