mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0caa14bf7b | ||
|
|
2a99081850 | ||
|
|
7fc577e5c6 | ||
|
|
12d02c3547 | ||
|
|
243c1385a0 | ||
|
|
0f3dc7201b | ||
|
|
34d2f78e87 | ||
|
|
41be4e0e30 | ||
|
|
58ccdb683c | ||
|
|
8028ddefef | ||
|
|
864cae2493 |
@@ -8,3 +8,21 @@ updates:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
python:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/js"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
javascript:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -106,14 +106,14 @@ jobs:
|
||||
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PAT }}
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -8,6 +8,15 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.31] — 2026-05-26
|
||||
|
||||
- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
|
||||
- **[wrapper]** JS: export `buildContextOptions` helper for custom context creation (thanks [@honor2030](https://github.com/honor2030), #262)
|
||||
- **[wrapper]** Humanize: fix iframe coordinate offset in pointer-events check (thanks [@eofreternal](https://github.com/eofreternal), #303)
|
||||
- **[wrapper]** Humanize: use shared deadline for timeout budget in frame and ElementHandle methods (#307)
|
||||
- **[docker]** Clean up stale Xvfb lock so container survives restarts (thanks [@sparanoid](https://github.com/sparanoid), #284)
|
||||
- **[meta]** Add pip and npm ecosystems to Dependabot, bump GitHub Actions (#309)
|
||||
|
||||
## [0.3.30] — 2026-05-21
|
||||
|
||||
- **[binary]** New build 146.0.7680.177.5 for Linux x64 + Windows x64 — 58 source-level fingerprint patches (up from 57)
|
||||
|
||||
@@ -59,7 +59,7 @@ from cloakbrowser import launch
|
||||
|
||||
browser = launch()
|
||||
page = browser.new_page()
|
||||
page.goto("https://protected-site.com") # no more blocks
|
||||
page.goto("https://example.com")
|
||||
browser.close()
|
||||
```
|
||||
|
||||
@@ -69,12 +69,34 @@ import { launch } from 'cloakbrowser';
|
||||
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://protected-site.com');
|
||||
await page.goto('https://example.com');
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer))
|
||||
|
||||
**For sites with anti-bot protection**, add a residential proxy and these flags:
|
||||
|
||||
```python
|
||||
browser = launch(
|
||||
proxy="http://user:pass@residential-proxy:port", # residential IP, not datacenter
|
||||
geoip=True, # match timezone + locale to proxy IP
|
||||
headless=False, # some sites detect headless even with C++ patches
|
||||
humanize=True, # human-like mouse, keyboard, scroll
|
||||
)
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch({
|
||||
proxy: 'http://user:pass@residential-proxy:port',
|
||||
geoip: true,
|
||||
headless: false,
|
||||
humanize: true,
|
||||
});
|
||||
```
|
||||
|
||||
See [Troubleshooting](#troubleshooting) for site-specific issues (FingerprintJS, Kasada, reCAPTCHA).
|
||||
|
||||
## Install
|
||||
|
||||
**Python:**
|
||||
@@ -128,7 +150,7 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
|
||||
|
||||
---
|
||||
|
||||
## Latest: v0.3.30 (Chromium 146.0.7680.177.5)
|
||||
## Latest: v0.3.31 (Chromium 146.0.7680.177.5)
|
||||
|
||||
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
|
||||
- **Windows native GPU passthrough** — real hardware values pass through directly instead of being spoofed, matching real browser behavior
|
||||
@@ -986,6 +1008,51 @@ If you're still blocked after this, check the font setup below.
|
||||
|
||||
---
|
||||
|
||||
### Detected by FingerprintJS?
|
||||
|
||||
FingerprintJS (`demo.fingerprint.com/playground`) checks multiple signals. Each detection has a specific cause:
|
||||
|
||||
| Detection | Cause | Fix |
|
||||
|-----------|-------|-----|
|
||||
| **`nodriver` / bad bot** | IP reputation or missing flags | Residential proxy + config below |
|
||||
| **Browser tampering** | Noise injection detected by ML | `--fingerprint-noise=false` |
|
||||
| **Virtual machine** | Screen dimensions don't match viewport | `--fingerprint-screen-width/height` matching viewport |
|
||||
| **Incognito** | Storage quota normalized to ~500MB | Expected tradeoff — see below |
|
||||
|
||||
Config that passes FPJS (verified on v0.3.30, Linux + Windows):
|
||||
|
||||
```python
|
||||
browser = launch(
|
||||
headless=False,
|
||||
proxy="http://user:pass@residential-proxy:port",
|
||||
geoip=True,
|
||||
args=[
|
||||
"--fingerprint-noise=false", # prevents tampering detection
|
||||
"--fingerprint-screen-width=1920", # match your viewport
|
||||
"--fingerprint-screen-height=1080",
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch({
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@residential-proxy:port',
|
||||
geoip: true,
|
||||
args: [
|
||||
'--fingerprint-noise=false',
|
||||
'--fingerprint-screen-width=1920',
|
||||
'--fingerprint-screen-height=1080',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
For persistent contexts (`launch_persistent_context` / `launchPersistentContext`), also add `--fingerprint-storage-quota=500` to the args.
|
||||
|
||||
**Storage quota tradeoff:** The binary normalizes storage quota to ~500MB to pass FPJS, but this makes the session look like incognito to other detection services (e.g. BrowserScan's `notPrivate` check, -10 points). Setting `--fingerprint-storage-quota=5000` passes incognito checks but may trigger FPJS. You can't satisfy both simultaneously — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
|
||||
|
||||
---
|
||||
|
||||
### Blocked on Kasada / Akamai sites despite correct config?
|
||||
|
||||
On minimal Linux environments, missing font packages cause canvas emoji rendering to produce hashes that anti-bot systems don't recognize. This is the most common cause of blocks on aggressive sites after proxy, geoip, and headed mode are already set up correctly.
|
||||
@@ -1202,7 +1269,7 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
|
||||
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
|
||||
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
|
||||
- [@kitiho](https://github.com/kitiho) — null viewport fix
|
||||
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types
|
||||
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types, iframe pointer-events fix
|
||||
- [@manaskarra](https://github.com/manaskarra) — iframe scope fix for humanized frame actions, GeoIP timeout guard
|
||||
- [@Youhai020616](https://github.com/Youhai020616) — SOCKS5 credential encoding logging
|
||||
- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration, cold-start hardening
|
||||
@@ -1212,4 +1279,5 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
|
||||
- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake
|
||||
- [@245678000000](https://github.com/245678000000) — package-lock sync
|
||||
- [@honor2030](https://github.com/honor2030) — cloakserve WebSocket origin guard, composable JS launch helpers
|
||||
- [@sparanoid](https://github.com/sparanoid) — Docker Xvfb lock cleanup
|
||||
- [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Clean up any stale Xvfb lock left behind by a previous container instance.
|
||||
# `/tmp` is not a tmpfs in this image, so on `docker restart` the previous
|
||||
# container's `/tmp/.X99-lock` survives, and Xvfb refuses to start with an
|
||||
# existing lock — leaving the container with no X server, every Chrome
|
||||
# launch dying with "Missing X server or $DISPLAY", and `cloakserve`
|
||||
# returning 502 forever. See CloakHQ/CloakBrowser#283.
|
||||
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
|
||||
|
||||
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
|
||||
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
||||
sleep 1
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.30"
|
||||
__version__ = "0.3.31"
|
||||
|
||||
+96
-7
@@ -774,7 +774,7 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
|
||||
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
|
||||
|
||||
|
||||
def _assemble_socks_url(
|
||||
def _assemble_proxy_url(
|
||||
scheme: str,
|
||||
host: str,
|
||||
port: int | None,
|
||||
@@ -785,7 +785,7 @@ def _assemble_socks_url(
|
||||
query: str = "",
|
||||
fragment: str = "",
|
||||
) -> str:
|
||||
"""Build a SOCKS URL from already-percent-encoded credentials and host parts.
|
||||
"""Build a proxy URL from already-percent-encoded credentials and host parts.
|
||||
|
||||
``enc_pass is None`` means no password (no colon in userinfo). Empty string
|
||||
means present-but-empty (colon preserved). This mirrors the distinction
|
||||
@@ -816,7 +816,7 @@ def _reconstruct_socks_url(proxy: ProxySettings) -> str:
|
||||
enc_user = quote(username, safe="")
|
||||
# Dict convention: empty/missing password → no colon.
|
||||
enc_pass = quote(password, safe="") if password else None
|
||||
return _assemble_socks_url(
|
||||
return _assemble_proxy_url(
|
||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||
enc_user, enc_pass, parsed.path,
|
||||
)
|
||||
@@ -856,7 +856,7 @@ def _normalize_socks_string_url(url: str) -> str:
|
||||
else:
|
||||
raw_pass = None
|
||||
enc_pass = None
|
||||
normalized = _assemble_socks_url(
|
||||
normalized = _assemble_proxy_url(
|
||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||
enc_user, enc_pass,
|
||||
parsed.path, parsed.params, parsed.query, parsed.fragment,
|
||||
@@ -1061,6 +1061,81 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def _has_credentials(proxy: str | ProxySettings) -> bool:
|
||||
"""Check if the proxy has inline or dict-level credentials."""
|
||||
if isinstance(proxy, dict):
|
||||
return bool(proxy.get("username"))
|
||||
return "@" in proxy
|
||||
|
||||
|
||||
def _reconstruct_http_url(proxy: ProxySettings) -> str:
|
||||
"""Reconstruct an HTTP(S) proxy 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(_ensure_proxy_scheme(server))
|
||||
enc_user = quote(username, safe="")
|
||||
enc_pass = quote(password, safe="") if password else None
|
||||
return _assemble_proxy_url(
|
||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||
enc_user, enc_pass, parsed.path,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_http_string_url(url: str) -> str:
|
||||
"""Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
|
||||
|
||||
Same pattern as ``_normalize_socks_string_url`` — decode then re-encode to
|
||||
ensure Chromium's proxy URL parser handles special chars correctly.
|
||||
"""
|
||||
normalized = url if "://" in url else f"http://{url}"
|
||||
try:
|
||||
parsed = urlparse(normalized)
|
||||
_ = parsed.port
|
||||
except ValueError as e:
|
||||
logger.warning("Malformed HTTP proxy URL, passing through unchanged: %s", e)
|
||||
return normalized
|
||||
if parsed.username is None and parsed.password is None:
|
||||
return normalized
|
||||
raw_user = parsed.username or ""
|
||||
enc_user = quote(unquote(raw_user), safe="") if raw_user else ""
|
||||
if parsed.password is not None:
|
||||
raw_pass = parsed.password
|
||||
enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else ""
|
||||
else:
|
||||
raw_pass = None
|
||||
enc_pass = None
|
||||
result = _assemble_proxy_url(
|
||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||
enc_user, enc_pass,
|
||||
parsed.path, parsed.params, parsed.query, parsed.fragment,
|
||||
)
|
||||
if enc_user != raw_user or enc_pass != raw_pass:
|
||||
logger.info(
|
||||
"Auto URL-encoded HTTP proxy credentials (special characters "
|
||||
"detected). Pre-encode the URL to suppress this notice."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
_HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5"
|
||||
_HTTP_PROXY_INLINE_AUTH_PLATFORMS = {"linux-x64", "windows-x64"}
|
||||
|
||||
|
||||
def _supports_http_proxy_inline_auth() -> bool:
|
||||
"""Check if the current platform's binary supports HTTP proxy inline credentials.
|
||||
|
||||
Requires both a supported platform AND a binary version with preemptive proxy auth.
|
||||
"""
|
||||
from .config import get_platform_tag, get_chromium_version, _version_tuple
|
||||
tag = get_platform_tag()
|
||||
if tag not in _HTTP_PROXY_INLINE_AUTH_PLATFORMS:
|
||||
return False
|
||||
return _version_tuple(get_chromium_version()) >= _version_tuple(_HTTP_PROXY_INLINE_AUTH_MIN_VERSION)
|
||||
|
||||
|
||||
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
|
||||
"""Check if the proxy uses SOCKS5 protocol."""
|
||||
if proxy is None:
|
||||
@@ -1074,8 +1149,9 @@ def _resolve_proxy_config(
|
||||
) -> 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.
|
||||
Proxies with credentials (SOCKS5 or HTTP/HTTPS) are passed via Chrome's
|
||||
--proxy-server flag with inline credentials, bypassing Playwright's CDP
|
||||
auth interceptor which breaks on some proxies and Google domains (#182).
|
||||
|
||||
Returns:
|
||||
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
|
||||
@@ -1096,7 +1172,20 @@ def _resolve_proxy_config(
|
||||
# passwords at '=' and other special chars (#157).
|
||||
return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"]
|
||||
|
||||
# HTTP/HTTPS: use Playwright's proxy dict as before
|
||||
# HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
|
||||
# CDP auth interceptor, pass directly to Chrome via --proxy-server with
|
||||
# inline creds. Chrome sends Proxy-Authorization preemptively, avoiding
|
||||
# the 407 round-trip that breaks on some proxies (#182).
|
||||
if _has_credentials(proxy) and _supports_http_proxy_inline_auth():
|
||||
if isinstance(proxy, dict):
|
||||
url = _reconstruct_http_url(proxy)
|
||||
extra_args = [f"--proxy-server={url}"]
|
||||
if proxy.get("bypass"):
|
||||
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
|
||||
return {}, extra_args
|
||||
return {}, [f"--proxy-server={_normalize_http_string_url(proxy)}"]
|
||||
|
||||
# HTTP/HTTPS without credentials: use Playwright's proxy dict
|
||||
if isinstance(proxy, dict):
|
||||
return {"proxy": proxy}, []
|
||||
return {"proxy": _parse_proxy_url(proxy)}, []
|
||||
|
||||
@@ -1257,13 +1257,16 @@ def _patch_single_element_handle_sync(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
|
||||
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_click(**kwargs)
|
||||
if not force:
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
|
||||
# --- el.dblclick() ---
|
||||
@@ -1271,13 +1274,16 @@ def _patch_single_element_handle_sync(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
|
||||
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_dblclick(**kwargs)
|
||||
if not force:
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
raw_mouse.down(click_count=2)
|
||||
sleep_ms(rand(30, 60))
|
||||
raw_mouse.up(click_count=2)
|
||||
@@ -1287,8 +1293,11 @@ def _patch_single_element_handle_sync(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force)
|
||||
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_hover(**kwargs)
|
||||
@@ -1298,13 +1307,16 @@ def _patch_single_element_handle_sync(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
|
||||
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_type(text, **kwargs)
|
||||
if not force:
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
|
||||
@@ -1314,13 +1326,16 @@ def _patch_single_element_handle_sync(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
|
||||
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_fill(value, **kwargs)
|
||||
if not force:
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
originals.keyboard_press(_SELECT_ALL)
|
||||
@@ -1367,8 +1382,11 @@ def _patch_single_element_handle_sync(
|
||||
def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force)
|
||||
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
|
||||
info = _move_to_element()
|
||||
if info is None:
|
||||
return _orig_select_option(value, **kwargs)
|
||||
@@ -1380,8 +1398,11 @@ def _patch_single_element_handle_sync(
|
||||
def _human_el_check(**kwargs: Any) -> None:
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
||||
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||
try:
|
||||
if el.is_checked():
|
||||
return
|
||||
@@ -1391,15 +1412,18 @@ def _patch_single_element_handle_sync(
|
||||
if info is None:
|
||||
return _orig_check(**kwargs)
|
||||
if not force:
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.uncheck() ---
|
||||
def _human_el_uncheck(**kwargs: Any) -> None:
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
||||
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||
try:
|
||||
if not el.is_checked():
|
||||
return
|
||||
@@ -1409,15 +1433,18 @@ def _patch_single_element_handle_sync(
|
||||
if info is None:
|
||||
return _orig_uncheck(**kwargs)
|
||||
if not force:
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.set_checked() ---
|
||||
def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
||||
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||
try:
|
||||
current = el.is_checked()
|
||||
if current == checked:
|
||||
@@ -1429,7 +1456,7 @@ def _patch_single_element_handle_sync(
|
||||
return _orig_set_checked(checked, **kwargs)
|
||||
if info:
|
||||
if not force:
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.tap() ---
|
||||
@@ -2158,13 +2185,16 @@ def _patch_single_element_handle_async(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_click(**kwargs)
|
||||
if not force:
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
|
||||
# --- el.dblclick() ---
|
||||
@@ -2172,13 +2202,16 @@ def _patch_single_element_handle_async(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_dblclick(**kwargs)
|
||||
if not force:
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
await raw_mouse.down(click_count=2)
|
||||
await async_sleep_ms(rand(30, 60))
|
||||
await raw_mouse.up(click_count=2)
|
||||
@@ -2188,8 +2221,11 @@ def _patch_single_element_handle_async(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force)
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_hover(**kwargs)
|
||||
@@ -2199,13 +2235,16 @@ def _patch_single_element_handle_async(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_type(text, **kwargs)
|
||||
if not force:
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
cdp = await _get_cdp()
|
||||
@@ -2216,13 +2255,16 @@ def _patch_single_element_handle_async(
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_fill(value, **kwargs)
|
||||
if not force:
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
await originals.keyboard_press(_SELECT_ALL)
|
||||
@@ -2269,8 +2311,11 @@ def _patch_single_element_handle_async(
|
||||
async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force)
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
|
||||
info = await _move_to_element()
|
||||
if info is None:
|
||||
return await _orig_select_option(value, **kwargs)
|
||||
@@ -2282,8 +2327,11 @@ def _patch_single_element_handle_async(
|
||||
async def _human_el_check(**kwargs: Any) -> None:
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||
try:
|
||||
if await el.is_checked():
|
||||
return
|
||||
@@ -2293,15 +2341,18 @@ def _patch_single_element_handle_async(
|
||||
if info is None:
|
||||
return await _orig_check(**kwargs)
|
||||
if not force:
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.uncheck() ---
|
||||
async def _human_el_uncheck(**kwargs: Any) -> None:
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||
try:
|
||||
if not await el.is_checked():
|
||||
return
|
||||
@@ -2311,15 +2362,18 @@ def _patch_single_element_handle_async(
|
||||
if info is None:
|
||||
return await _orig_uncheck(**kwargs)
|
||||
if not force:
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.set_checked() ---
|
||||
async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
|
||||
force = kwargs.get("force", False)
|
||||
timeout = kwargs.get("timeout", 30000)
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
def _remaining_ms():
|
||||
return max(0, (deadline - time.monotonic()) * 1000)
|
||||
if not force:
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
|
||||
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
|
||||
try:
|
||||
current = await el.is_checked()
|
||||
if current == checked:
|
||||
@@ -2331,7 +2385,7 @@ def _patch_single_element_handle_async(
|
||||
return await _orig_set_checked(checked, **kwargs)
|
||||
if info:
|
||||
if not force:
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
|
||||
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
|
||||
# --- el.tap() ---
|
||||
|
||||
@@ -196,8 +196,14 @@ def ensure_stable(
|
||||
# Pointer-events check (post-scroll, at actual click coordinates)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_POINTER_EVENTS_LOCATOR_JS = """(expected, coords) => {
|
||||
const target = document.elementFromPoint(coords.x, coords.y);
|
||||
# data.box is page-space (from bounding_box); rect is frame-local. Their delta
|
||||
# is the iframe offset, needed to map page-space click coords into the frame's
|
||||
# own viewport before elementFromPoint. For main-frame elements the offset is 0.
|
||||
_POINTER_EVENTS_LOCATOR_JS = """(expected, data) => {
|
||||
const rect = expected.getBoundingClientRect();
|
||||
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
@@ -205,8 +211,11 @@ _POINTER_EVENTS_LOCATOR_JS = """(expected, coords) => {
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}"""
|
||||
|
||||
_POINTER_EVENTS_HANDLE_JS = """(expected, coords) => {
|
||||
const target = document.elementFromPoint(coords.x, coords.y);
|
||||
_POINTER_EVENTS_HANDLE_JS = """(expected, data) => {
|
||||
const rect = expected.getBoundingClientRect();
|
||||
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
@@ -230,17 +239,19 @@ def check_pointer_events(
|
||||
"""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
coords = {"x": x, "y": y}
|
||||
|
||||
while True:
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
|
||||
box = loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
|
||||
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
|
||||
except Exception as exc:
|
||||
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
||||
result = None
|
||||
|
||||
if result and result.get("hit", False):
|
||||
# Proceed if the check confirms a hit, or if it could not be determined
|
||||
# (None) — failing closed would block legitimate clicks.
|
||||
if result is None or result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
@@ -322,15 +333,16 @@ def check_pointer_events_handle(
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
coords = {"x": x, "y": y}
|
||||
|
||||
while True:
|
||||
try:
|
||||
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
|
||||
box = el.bounding_box()
|
||||
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
|
||||
except Exception:
|
||||
result = None
|
||||
|
||||
if result and result.get("hit", False):
|
||||
# Proceed if the check confirms a hit, or if it could not be determined
|
||||
# (None) — failing closed would block legitimate clicks.
|
||||
if result is None or result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
@@ -140,17 +140,19 @@ async def async_check_pointer_events(
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
coords = {"x": x, "y": y}
|
||||
|
||||
while True:
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
|
||||
box = await loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
|
||||
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
|
||||
except Exception as exc:
|
||||
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
||||
result = None
|
||||
|
||||
if result and result.get("hit", False):
|
||||
# Proceed if the check confirms a hit, or if it could not be determined
|
||||
# (None) — failing closed would block legitimate clicks.
|
||||
if result is None or result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
@@ -227,15 +229,16 @@ async def async_check_pointer_events_handle(
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
coords = {"x": x, "y": y}
|
||||
|
||||
while True:
|
||||
try:
|
||||
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
|
||||
box = await el.bounding_box()
|
||||
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
|
||||
except Exception:
|
||||
result = None
|
||||
|
||||
if result and result.get("hit", False):
|
||||
# Proceed if the check confirms a hit, or if it could not be determined
|
||||
# (None) — failing closed would block legitimate clicks.
|
||||
if result is None or result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
+16
-3
@@ -11,7 +11,7 @@
|
||||
|
||||
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
|
||||
|
||||
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
|
||||
- **58 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
|
||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
|
||||
@@ -39,11 +39,24 @@ import { launch } from 'cloakbrowser';
|
||||
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://protected-site.com');
|
||||
await page.goto('https://example.com');
|
||||
console.log(await page.title());
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**For sites with anti-bot protection**, add a residential proxy and these flags:
|
||||
|
||||
```javascript
|
||||
const browser = await launch({
|
||||
proxy: 'http://user:pass@residential-proxy:port',
|
||||
geoip: true, // match timezone + locale to proxy IP
|
||||
headless: false, // some sites detect headless even with C++ patches
|
||||
humanize: true, // human-like mouse, keyboard, scroll
|
||||
});
|
||||
```
|
||||
|
||||
See the [main README](https://github.com/CloakHQ/CloakBrowser#troubleshooting) for site-specific troubleshooting (FingerprintJS, Kasada, reCAPTCHA).
|
||||
|
||||
### Puppeteer
|
||||
|
||||
> **Note:** Playwright is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect. This is a known Puppeteer limitation, not specific to CloakBrowser.
|
||||
@@ -53,7 +66,7 @@ import { launch } from 'cloakbrowser/puppeteer';
|
||||
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://protected-site.com');
|
||||
await page.goto('https://example.com');
|
||||
console.log(await page.title());
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
Generated
+211
-602
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.31",
|
||||
"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",
|
||||
@@ -81,12 +81,12 @@
|
||||
"tar": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"playwright-core": "1.60",
|
||||
"puppeteer-core": "^25.0.4",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"playwright-core": "^1.53.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -197,8 +197,11 @@ export async function ensureStable(
|
||||
// Pointer-events check (post-scroll, at actual click coordinates)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const POINTER_EVENTS_LOCATOR_JS = `(expected, coords) => {
|
||||
const target = document.elementFromPoint(coords.x, coords.y);
|
||||
const POINTER_EVENTS_LOCATOR_JS = `(expected, data) => {
|
||||
const rect = expected.getBoundingClientRect();
|
||||
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
@@ -206,8 +209,11 @@ const POINTER_EVENTS_LOCATOR_JS = `(expected, coords) => {
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}`;
|
||||
|
||||
const POINTER_EVENTS_HANDLE_JS = `(expected, coords) => {
|
||||
const target = document.elementFromPoint(coords.x, coords.y);
|
||||
const POINTER_EVENTS_HANDLE_JS = `(expected, data) => {
|
||||
const rect = expected.getBoundingClientRect();
|
||||
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
|
||||
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
|
||||
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
@@ -225,18 +231,18 @@ export async function checkPointerEvents(
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
const coords = { x, y };
|
||||
|
||||
while (true) {
|
||||
let result: any = null;
|
||||
try {
|
||||
const loc = pageOrFrame.locator(selector).first();
|
||||
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, coords);
|
||||
const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) });
|
||||
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box });
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result && result.hit) return;
|
||||
if (!result || result.hit) return;
|
||||
const covering = (result as any)?.covering ?? 'unknown';
|
||||
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
|
||||
|
||||
@@ -317,17 +323,16 @@ export async function checkPointerEventsHandle(
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
|
||||
const coords = { x, y };
|
||||
|
||||
while (true) {
|
||||
let result: any;
|
||||
try {
|
||||
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, coords);
|
||||
const box = await el.boundingBox();
|
||||
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, { x, y, box });
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result && result.hit) return;
|
||||
if (!result || result.hit) return;
|
||||
|
||||
const covering = (result as any)?.covering ?? 'unknown';
|
||||
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('<ElementHandle>', covering);
|
||||
|
||||
@@ -196,10 +196,12 @@ export function patchSingleElementHandle(
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElClick(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
};
|
||||
|
||||
@@ -216,10 +218,12 @@ export function patchSingleElementHandle(
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElDblclick(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
@@ -235,7 +239,9 @@ export function patchSingleElementHandle(
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, timeout, force);
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, remainingMs(), force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElHover(options);
|
||||
};
|
||||
@@ -248,10 +254,12 @@ export function patchSingleElementHandle(
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = (options as any)?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElType(text, options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
@@ -267,10 +275,12 @@ export function patchSingleElementHandle(
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElFill(value, options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
@@ -298,7 +308,9 @@ export function patchSingleElementHandle(
|
||||
}) => {
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, timeout, force);
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, remainingMs(), force);
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSelectOption(values, options);
|
||||
await humanClick(raw, false, cfg);
|
||||
@@ -316,14 +328,16 @@ export function patchSingleElementHandle(
|
||||
}) => {
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (checked) return;
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElCheck(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
@@ -337,14 +351,16 @@ export function patchSingleElementHandle(
|
||||
}) => {
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (!checked) return;
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElUncheck(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
@@ -359,14 +375,16 @@ export function patchSingleElementHandle(
|
||||
}) => {
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
|
||||
try {
|
||||
const current = await el.isChecked();
|
||||
if (current === checked) return;
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSetChecked(checked, options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
}
|
||||
|
||||
+29
-12
@@ -691,7 +691,12 @@ function patchSingleFrame(
|
||||
const origFrameTap = (frame as any).tap?.bind(frame);
|
||||
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
|
||||
|
||||
const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => {
|
||||
const moveToFrameSelector = async (
|
||||
selector: string,
|
||||
options: HumanActionOptions | undefined,
|
||||
inputBias: boolean,
|
||||
remainingMs: () => number,
|
||||
) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
@@ -699,9 +704,9 @@ function patchSingleFrame(
|
||||
|
||||
const locator = firstFrameLocator(frame, selector);
|
||||
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
|
||||
await locator.scrollIntoViewIfNeeded({ timeout: options?.timeout }).catch(() => undefined);
|
||||
await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined);
|
||||
}
|
||||
const box = await locator.boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
||||
const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null);
|
||||
if (!box) return null;
|
||||
|
||||
const isInput = inputBias || await isFrameInputElement(frame, selector);
|
||||
@@ -713,23 +718,32 @@ function patchSingleFrame(
|
||||
};
|
||||
|
||||
const frameClick = async (selector: string, options?: HumanActionOptions) => {
|
||||
const moved = await moveToFrameSelector(selector, options);
|
||||
if (!moved) return origFrameClick(selector, options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
|
||||
if (!moved) return origFrameClick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
|
||||
await humanClick(raw, moved.isInput, moved.callCfg);
|
||||
};
|
||||
|
||||
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
|
||||
|
||||
const frameHover = async (selector: string, options?: HumanActionOptions) => {
|
||||
const moved = await moveToFrameSelector(selector, options, false);
|
||||
if (!moved) return origFrameHover(selector, options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
|
||||
if (!moved) return origFrameHover(selector, { ...options, timeout: Math.max(1, remainingMs()) });
|
||||
};
|
||||
|
||||
(frame as any).click = frameClick;
|
||||
|
||||
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
|
||||
const moved = await moveToFrameSelector(selector, options);
|
||||
if (!moved) return origFrameDblclick(selector, options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
|
||||
if (!moved) return origFrameDblclick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
@@ -820,8 +834,11 @@ function patchSingleFrame(
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
||||
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(1, deadline - Date.now());
|
||||
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: remainingMs() }).catch(() => null);
|
||||
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: remainingMs() }).catch(() => null);
|
||||
|
||||
if (srcBox && tgtBox) {
|
||||
const sx = srcBox.x + srcBox.width / 2;
|
||||
@@ -837,7 +854,7 @@ function patchSingleFrame(
|
||||
await sleep(rand(80, 150));
|
||||
await originals.mouseUp();
|
||||
} else {
|
||||
return origFrameDragAndDrop(source, target, options);
|
||||
return origFrameDragAndDrop(source, target, { ...options, timeout: Math.max(1, remainingMs()) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
// Launch functions (Playwright API)
|
||||
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, humanizeBrowser } from "./playwright.js";
|
||||
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";
|
||||
|
||||
// Binary management
|
||||
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
|
||||
|
||||
+22
-14
@@ -44,6 +44,26 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserCo
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Playwright BrowserContext options for CloakBrowser without launching a browser
|
||||
* or creating a context.
|
||||
*
|
||||
* Useful when integrating CloakBrowser with an existing Playwright Browser while
|
||||
* keeping the wrapper's stealth-safe defaults for `newContext()`.
|
||||
*/
|
||||
export function buildContextOptions(
|
||||
options: LaunchContextOptions = {}
|
||||
): BrowserContextOptions {
|
||||
return {
|
||||
// contextOptions first — explicit wrapper fields below override it.
|
||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||
...filterStealthCtxOptions(options.contextOptions),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
} as BrowserContextOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Playwright launch options for CloakBrowser without starting Chromium.
|
||||
*
|
||||
@@ -144,14 +164,7 @@ export async function launchContext(
|
||||
|
||||
let context: BrowserContext;
|
||||
try {
|
||||
context = await browser.newContext({
|
||||
// contextOptions first — explicit wrapper fields below override it.
|
||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||
...filterStealthCtxOptions(options.contextOptions),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
});
|
||||
context = await browser.newContext(buildContextOptions(options));
|
||||
} catch (err) {
|
||||
await browser.close();
|
||||
throw err;
|
||||
@@ -222,12 +235,7 @@ export async function launchPersistentContext(
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
// contextOptions before explicit wrapper fields so explicit wins.
|
||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||
...filterStealthCtxOptions(options.contextOptions),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
...buildContextOptions(options),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
|
||||
+112
-3
@@ -2,6 +2,8 @@
|
||||
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
|
||||
*/
|
||||
|
||||
import { getChromiumVersion, getPlatformTag, parseVersion } from "./config.js";
|
||||
|
||||
export interface ParsedProxy {
|
||||
server: string;
|
||||
username?: string;
|
||||
@@ -155,11 +157,106 @@ export function normalizeSocksStringUrl(urlStr: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5";
|
||||
const HTTP_PROXY_INLINE_AUTH_PLATFORMS = new Set(["linux-x64", "windows-x64"]);
|
||||
|
||||
export function supportsHttpProxyInlineAuth(): boolean {
|
||||
try {
|
||||
const tag = getPlatformTag();
|
||||
if (!HTTP_PROXY_INLINE_AUTH_PLATFORMS.has(tag)) return false;
|
||||
const current = parseVersion(getChromiumVersion());
|
||||
const minimum = parseVersion(HTTP_PROXY_INLINE_AUTH_MIN_VERSION);
|
||||
for (let i = 0; i < Math.max(current.length, minimum.length); i++) {
|
||||
if ((current[i] ?? 0) > (minimum[i] ?? 0)) return true;
|
||||
if ((current[i] ?? 0) < (minimum[i] ?? 0)) return false;
|
||||
}
|
||||
return true; // equal = supported
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasCredentials(proxy: string | ProxyDict): boolean {
|
||||
if (typeof proxy === "string") return proxy.includes("@");
|
||||
return !!proxy.username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct an HTTP(S) proxy URL with inline credentials from a proxy dict.
|
||||
*/
|
||||
export function reconstructHttpUrl(proxy: ProxyDict): string {
|
||||
if (!proxy.username) return proxy.server;
|
||||
const url = new URL(ensureProxyScheme(proxy.server));
|
||||
url.username = encodeURIComponent(proxy.username);
|
||||
if (proxy.password) url.password = encodeURIComponent(proxy.password);
|
||||
return url.href.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
|
||||
* Same pattern as normalizeSocksStringUrl.
|
||||
*/
|
||||
export function normalizeHttpStringUrl(urlStr: string): string {
|
||||
const normalized = urlStr.includes("://") ? urlStr : `http://${urlStr}`;
|
||||
const schemeMatch = normalized.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
|
||||
if (!schemeMatch) return normalized;
|
||||
const [, scheme, rest] = schemeMatch;
|
||||
const hostStart = rest.search(/[/?#]/);
|
||||
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
|
||||
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
|
||||
const atIdx = authority.lastIndexOf("@");
|
||||
if (atIdx === -1) return normalized;
|
||||
const userinfo = authority.slice(0, atIdx);
|
||||
const hostPart = authority.slice(atIdx + 1);
|
||||
const bracketEnd = hostPart.lastIndexOf("]");
|
||||
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
|
||||
if (portColonIdx !== -1) {
|
||||
const portStr = hostPart.slice(portColonIdx + 1);
|
||||
if (portStr && !/^\d+$/.test(portStr)) {
|
||||
console.warn(`[cloakbrowser] Malformed HTTP proxy URL, passing through unchanged: invalid port`);
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
const hostAndRest = hostPart + suffix;
|
||||
const colonIdx = userinfo.indexOf(":");
|
||||
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
|
||||
const hasPassword = colonIdx !== -1;
|
||||
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
|
||||
try {
|
||||
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
|
||||
const encPass = hasPassword
|
||||
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
|
||||
: null;
|
||||
let userinfoPart: string;
|
||||
if (encPass !== null) {
|
||||
userinfoPart = `${encUser}:${encPass}@`;
|
||||
} else if (encUser) {
|
||||
userinfoPart = `${encUser}@`;
|
||||
} else {
|
||||
userinfoPart = "";
|
||||
}
|
||||
const result = `${scheme}://${userinfoPart}${hostAndRest}`;
|
||||
const credsChanged = encUser !== rawUserEnc
|
||||
|| (hasPassword ? encPass !== rawPassEnc : false);
|
||||
if (credsChanged) {
|
||||
console.info(
|
||||
"[cloakbrowser] Auto URL-encoded HTTP proxy credentials (special " +
|
||||
"characters detected). Pre-encode the URL to suppress this notice.",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.warn(`[cloakbrowser] Could not normalize HTTP proxy URL, passing through unchanged: ${(e as Error).message}`);
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Proxies with credentials (SOCKS5 or HTTP/HTTPS on supported platforms) are
|
||||
* passed via Chrome's --proxy-server flag with inline credentials, bypassing
|
||||
* Playwright's CDP auth interceptor which breaks on some proxies (#182).
|
||||
*/
|
||||
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
|
||||
if (!proxy) return { proxyArgs: [] };
|
||||
@@ -177,7 +274,19 @@ export function resolveProxyConfig(proxy: string | ProxyDict | undefined): Proxy
|
||||
return { proxyArgs: args };
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use Playwright's proxy dict
|
||||
// HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
|
||||
// CDP auth interceptor, use Chrome's preemptive Proxy-Authorization (#182).
|
||||
if (hasCredentials(proxy) && supportsHttpProxyInlineAuth()) {
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
|
||||
}
|
||||
const httpUrl = reconstructHttpUrl(proxy);
|
||||
const args = [`--proxy-server=${httpUrl}`];
|
||||
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
|
||||
return { proxyArgs: args };
|
||||
}
|
||||
|
||||
// HTTP/HTTPS without credentials (or unsupported platform): use Playwright's proxy dict
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
|
||||
}
|
||||
|
||||
+22
-5
@@ -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 { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
|
||||
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
|
||||
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
||||
@@ -26,9 +26,9 @@ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string
|
||||
|
||||
/**
|
||||
* Resolve proxy into Chrome CLI args and optional HTTP auth credentials.
|
||||
* 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.
|
||||
* SOCKS5: Chrome handles inline credentials natively (RFC 1929 auth).
|
||||
* HTTP on supported platforms: inline credentials via --proxy-server.
|
||||
* HTTP on unsupported platforms: strip credentials, use page.authenticate() fallback.
|
||||
*/
|
||||
function resolveProxy(options: LaunchOptions, args: string[]): { username: string; password: string } | undefined {
|
||||
if (!options.proxy) return undefined;
|
||||
@@ -39,6 +39,23 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// On supported platforms: pass full URL with inline creds to --proxy-server
|
||||
if (supportsHttpProxyInlineAuth()) {
|
||||
if (typeof options.proxy === "string") {
|
||||
args.push(`--proxy-server=${normalizeHttpStringUrl(options.proxy)}`);
|
||||
return undefined;
|
||||
}
|
||||
const url = options.proxy.username
|
||||
? reconstructHttpUrl(options.proxy)
|
||||
: options.proxy.server;
|
||||
args.push(`--proxy-server=${url}`);
|
||||
if (options.proxy.bypass) {
|
||||
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Unsupported platform: strip credentials, fall back to page.authenticate()
|
||||
if (typeof options.proxy === "string") {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
@@ -55,7 +72,7 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
|
||||
return username ? { username, password: password ?? "" } : undefined;
|
||||
}
|
||||
|
||||
/** Apply proxy auth monkey-patch and humanize behavioral patching. */
|
||||
/** Apply proxy auth fallback (unsupported platforms) and humanize patching. */
|
||||
async function applyPostLaunch(
|
||||
browser: Browser,
|
||||
options: LaunchOptions,
|
||||
|
||||
@@ -1479,3 +1479,123 @@ describe("el.scrollIntoViewIfNeeded humanization", () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Issue #307: frame.click timeout should not multiply
|
||||
// =========================================================================
|
||||
describe("frame.click timeout budget (#307)", () => {
|
||||
it("total wait time should not exceed the specified timeout", async () => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
const TIMEOUT_MS = 500;
|
||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// Build a frame where the element does NOT exist:
|
||||
// scrollIntoViewIfNeeded and boundingBox each wait until their
|
||||
// individual timeout before failing, and origFrameClick does the same.
|
||||
const frameLoc: any = {
|
||||
boundingBox: vi.fn(async (opts?: { timeout?: number }) => {
|
||||
await delay(opts?.timeout ?? 30000);
|
||||
return null;
|
||||
}),
|
||||
scrollIntoViewIfNeeded: vi.fn(async (opts?: { timeout?: number }) => {
|
||||
await delay(opts?.timeout ?? 30000);
|
||||
throw new Error("timeout");
|
||||
}),
|
||||
evaluate: vi.fn(async () => ({ hit: true })),
|
||||
isChecked: vi.fn(async () => false),
|
||||
};
|
||||
frameLoc.first = vi.fn(() => frameLoc);
|
||||
|
||||
const origClickFn = vi.fn(async (_sel: string, opts?: any) => {
|
||||
await delay(opts?.timeout ?? 30000);
|
||||
throw new Error("timeout");
|
||||
});
|
||||
|
||||
const childFrame: any = {
|
||||
click: origClickFn,
|
||||
dblclick: vi.fn(async () => {}),
|
||||
hover: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
fill: vi.fn(async () => {}),
|
||||
check: vi.fn(async () => {}),
|
||||
uncheck: vi.fn(async () => {}),
|
||||
selectOption: vi.fn(async () => {}),
|
||||
press: vi.fn(async () => {}),
|
||||
pressSequentially: vi.fn(async () => {}),
|
||||
tap: vi.fn(async () => {}),
|
||||
clear: vi.fn(async () => {}),
|
||||
dragAndDrop: vi.fn(async () => {}),
|
||||
locator: vi.fn(() => frameLoc),
|
||||
childFrames: vi.fn(() => []),
|
||||
};
|
||||
|
||||
const mainFrame = {
|
||||
...buildMockFrame(),
|
||||
childFrames: vi.fn(() => [childFrame]),
|
||||
};
|
||||
|
||||
const page = buildMockPage({ mainFrameReturn: mainFrame });
|
||||
const cfg = resolveConfig("default", {
|
||||
mouse_min_steps: 1,
|
||||
mouse_max_steps: 1,
|
||||
idle_between_actions: false,
|
||||
});
|
||||
const cursor = { x: 0, y: 0, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
await (childFrame as any).click("#does-not-exist", { timeout: TIMEOUT_MS });
|
||||
} catch {
|
||||
// expected — element doesn't exist
|
||||
}
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// With the bug, elapsed ≈ 3 * TIMEOUT_MS (scrollIntoView + boundingBox + origClick).
|
||||
// Fixed: elapsed should be ≈ 1 * TIMEOUT_MS (shared deadline).
|
||||
// Allow 1.8x as upper bound to account for test overhead but catch the 3x bug.
|
||||
expect(elapsed).toBeLessThan(TIMEOUT_MS * 1.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pointer-events check fail-open", () => {
|
||||
// When the check itself cannot run (evaluate / boundingBox throws -> result
|
||||
// null), proceed with the click instead of blocking it until the timeout.
|
||||
it("checkPointerEventsHandle returns promptly when evaluate throws", async () => {
|
||||
const { checkPointerEventsHandle } = await import("../src/human/actionability.js");
|
||||
const el = {
|
||||
boundingBox: vi.fn().mockRejectedValue(new Error("stale handle")),
|
||||
evaluate: vi.fn().mockRejectedValue(new Error("execution context destroyed")),
|
||||
};
|
||||
const start = Date.now();
|
||||
await checkPointerEventsHandle(el as any, 100, 100, 2000); // must not throw
|
||||
expect(Date.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("checkPointerEvents returns promptly when evaluate throws", async () => {
|
||||
const { checkPointerEvents } = await import("../src/human/actionability.js");
|
||||
const loc = {
|
||||
first: () => loc,
|
||||
boundingBox: vi.fn().mockRejectedValue(new Error("no element")),
|
||||
evaluate: vi.fn().mockRejectedValue(new Error("no element")),
|
||||
};
|
||||
const page = { locator: vi.fn().mockReturnValue(loc) };
|
||||
const start = Date.now();
|
||||
await checkPointerEvents(page as any, "#x", 100, 100, null, 2000); // must not throw
|
||||
expect(Date.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("checkPointerEventsHandle still throws when genuinely covered", async () => {
|
||||
const { checkPointerEventsHandle, ElementNotReceivingEventsError } =
|
||||
await import("../src/human/actionability.js");
|
||||
const el = {
|
||||
boundingBox: vi.fn().mockResolvedValue({ x: 0, y: 0, width: 10, height: 10 }),
|
||||
evaluate: vi.fn().mockResolvedValue({ hit: false, covering: "DIV" }),
|
||||
};
|
||||
await expect(checkPointerEventsHandle(el as any, 5, 5, 200)).rejects.toBeInstanceOf(
|
||||
ElementNotReceivingEventsError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+51
-1
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { binaryInfo } from "../src/download.js";
|
||||
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
||||
import * as config from "../src/config.js";
|
||||
|
||||
describe("binaryInfo", () => {
|
||||
it("returns correct structure", () => {
|
||||
@@ -39,14 +40,54 @@ describe("composable Playwright launch helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("exports buildLaunchOptions and humanizeBrowser from the package entrypoint", async () => {
|
||||
it("exports composable helpers from the package entrypoint", async () => {
|
||||
const entry = await import("../src/index.js");
|
||||
|
||||
expect(entry.buildLaunchOptions).toBeTypeOf("function");
|
||||
expect(entry.buildContextOptions).toBeTypeOf("function");
|
||||
expect(entry.humanizeBrowser).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("buildContextOptions returns Playwright context options without launching a browser", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { buildContextOptions } = await import("../src/index.js");
|
||||
|
||||
const options = buildContextOptions({
|
||||
userAgent: "Explicit/1.0",
|
||||
viewport: { width: 1280, height: 720 },
|
||||
colorScheme: "dark",
|
||||
contextOptions: {
|
||||
userAgent: "Context/9.9",
|
||||
viewport: { width: 9999, height: 9999 },
|
||||
colorScheme: "light",
|
||||
storageState: "state.json",
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
|
||||
expect(options).toMatchObject({
|
||||
userAgent: "Explicit/1.0",
|
||||
viewport: { width: 1280, height: 720 },
|
||||
colorScheme: "dark",
|
||||
storageState: "state.json",
|
||||
});
|
||||
expect(options.locale).toBeUndefined();
|
||||
expect(options.timezoneId).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("buildContextOptions applies DEFAULT_VIEWPORT by default and allows null viewport", async () => {
|
||||
const { buildContextOptions } = await import("../src/index.js");
|
||||
|
||||
expect(buildContextOptions().viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
|
||||
});
|
||||
|
||||
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
|
||||
const freshConfig = await import("../src/config.js");
|
||||
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||
try {
|
||||
const { buildLaunchOptions } = await import("../src/index.js");
|
||||
|
||||
const options = await buildLaunchOptions({
|
||||
@@ -66,6 +107,9 @@ describe("composable Playwright launch helpers", () => {
|
||||
password: "pass",
|
||||
});
|
||||
expect(options.timeout).toBe(1234);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("humanizeBrowser patches an existing browser only when requested", async () => {
|
||||
@@ -307,6 +351,9 @@ describe("launchPersistentContext (unit)", () => {
|
||||
});
|
||||
|
||||
it("forwards proxy string", async () => {
|
||||
const freshConfig = await import("../src/config.js");
|
||||
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||
try {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
@@ -317,6 +364,9 @@ describe("launchPersistentContext (unit)", () => {
|
||||
expect(args.proxy.server).toBe("http://proxy:8080");
|
||||
expect(args.proxy.username).toBe("user");
|
||||
expect(args.proxy.password).toBe("pass");
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards userAgent and colorScheme", async () => {
|
||||
|
||||
+100
-2
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig, reconstructHttpUrl, normalizeHttpStringUrl } from "../src/proxy.js";
|
||||
import * as config from "../src/config.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
describe("parseProxyUrl", () => {
|
||||
@@ -153,10 +154,15 @@ describe("resolveProxyConfig", () => {
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns playwright dict for http string", () => {
|
||||
it("returns playwright dict for http string on unsupported platform", () => {
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||
try {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
|
||||
expect(proxyArgs).toEqual([]);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns playwright dict for http dict", () => {
|
||||
@@ -321,4 +327,96 @@ describe("resolveProxyConfig", () => {
|
||||
debugSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// --- HTTP with credentials → --proxy-server (supported platform + version) ---
|
||||
|
||||
it("routes http string with creds through --proxy-server on linux-x64 v177.5", () => {
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||
try {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes http dict with creds through --proxy-server on linux-x64 v177.5", () => {
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||
try {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig({
|
||||
server: "http://proxy:8080",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes bypass for http dict with creds on windows-x64 v177.5", () => {
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("windows-x64");
|
||||
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||
try {
|
||||
const { proxyArgs } = resolveProxyConfig({
|
||||
server: "http://proxy:8080",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
bypass: ".google.com",
|
||||
});
|
||||
expect(proxyArgs).toContain("--proxy-server=http://user:pass@proxy:8080");
|
||||
expect(proxyArgs).toContain("--proxy-bypass-list=.google.com");
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("encodes special chars in http proxy password on supported platform v177.5", () => {
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||
try {
|
||||
const { proxyArgs } = resolveProxyConfig("http://user:pass=123@proxy:8080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass%3D123@proxy:8080"]);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back on linux-x64 with old version (pre-inline-auth)", () => {
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.3");
|
||||
try {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||
expect(proxyOption).toBeDefined();
|
||||
expect(proxyArgs).toEqual([]);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to playwright dict for http with creds on darwin-arm64", () => {
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||
try {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
|
||||
expect(proxyArgs).toEqual([]);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to playwright dict for http with creds on linux-arm64", () => {
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-arm64");
|
||||
try {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||
expect(proxyOption).toBeDefined();
|
||||
expect(proxyArgs).toEqual([]);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,16 +84,39 @@ describe("puppeteer launch", () => {
|
||||
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
|
||||
});
|
||||
|
||||
it("monkey-patches newPage for proxy auth", async () => {
|
||||
it("uses page.authenticate fallback for http proxy on unsupported platform", async () => {
|
||||
const config = await import("../src/config.js");
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||
try {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
|
||||
|
||||
// newPage should auto-authenticate
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).toHaveBeenCalledWith({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes inline creds via --proxy-server on supported platform (no page.authenticate)", async () => {
|
||||
const config = await import("../src/config.js");
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
|
||||
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
|
||||
try {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=http://user:pass@proxy:8080");
|
||||
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("injects timezone and locale as binary flags", async () => {
|
||||
@@ -189,7 +212,10 @@ describe("puppeteer launchPersistentContext", () => {
|
||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles proxy auth with persistent context", async () => {
|
||||
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
|
||||
const config = await import("../src/config.js");
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||
try {
|
||||
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
||||
const browser = await launchPersistentContext({
|
||||
userDataDir: "./my-profile",
|
||||
@@ -201,6 +227,9 @@ describe("puppeteer launchPersistentContext", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
|
||||
|
||||
+114
-2
@@ -708,7 +708,7 @@ class TestBrowserBotDetection:
|
||||
time.sleep(0.3)
|
||||
page.locator('#password').fill('SecurePass!123')
|
||||
time.sleep(0.5)
|
||||
page.locator('button[type="submit"]').click()
|
||||
page.locator('#loginForm button[type="submit"]').click()
|
||||
time.sleep(5)
|
||||
body = page.locator('body').text_content()
|
||||
assert '"superHumanSpeed": true' not in body
|
||||
@@ -725,7 +725,7 @@ class TestBrowserBotDetection:
|
||||
t0 = time.time()
|
||||
page.locator('#email').fill('test@example.com')
|
||||
page.locator('#password').fill('MyPassword!99')
|
||||
page.locator('button[type="submit"]').click()
|
||||
page.locator('#loginForm button[type="submit"]').click()
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
time.sleep(3)
|
||||
assert elapsed_ms > 3000
|
||||
@@ -1828,6 +1828,118 @@ class TestScrollIntoViewIfNeeded:
|
||||
assert cursor.x == 200 and cursor.y == 200
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Issue #307: frame/page click timeout should not multiply
|
||||
# =========================================================================
|
||||
|
||||
class TestTimeoutBudget307:
|
||||
"""Verify timeout budget is shared across sequential operations."""
|
||||
|
||||
def test_page_click_total_time_within_budget(self):
|
||||
"""page.click on a missing element should not exceed ~1x the timeout."""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
TIMEOUT_MS = 500
|
||||
cfg = resolve_config("default", {"idle_between_actions": False})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
|
||||
page = MagicMock()
|
||||
page.click = MagicMock()
|
||||
page.dblclick = MagicMock()
|
||||
page.hover = MagicMock()
|
||||
page.type = MagicMock()
|
||||
page.fill = MagicMock()
|
||||
page.goto = MagicMock()
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.evaluate = MagicMock(return_value={"hit": True})
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
page.query_selector = MagicMock(return_value=None)
|
||||
page.query_selector_all = MagicMock(return_value=[])
|
||||
page.wait_for_selector = MagicMock(return_value=None)
|
||||
page.main_frame = MagicMock()
|
||||
page.main_frame.child_frames = []
|
||||
|
||||
loc = MagicMock()
|
||||
loc.wait_for = MagicMock(side_effect=lambda **kw: time.sleep(kw.get("timeout", 30000) / 1000.0))
|
||||
loc.is_visible = MagicMock(return_value=False)
|
||||
loc.first = loc
|
||||
page.locator = MagicMock(return_value=loc)
|
||||
|
||||
h.patch_page(page, cfg, cursor)
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
page.click("#does-not-exist", timeout=TIMEOUT_MS)
|
||||
except Exception:
|
||||
pass
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
|
||||
assert elapsed_ms < TIMEOUT_MS * 1.8, (
|
||||
f"expected <{TIMEOUT_MS * 1.8}ms, got {elapsed_ms:.0f}ms"
|
||||
)
|
||||
|
||||
|
||||
class TestPointerEventsFailOpen:
|
||||
"""The pointer-events check must fail open: when it cannot run (evaluate /
|
||||
bounding_box throws -> result None), proceed with the click instead of
|
||||
blocking it until the timeout expires."""
|
||||
|
||||
def test_handle_failopen_returns_on_evaluate_error(self):
|
||||
from cloakbrowser.human.actionability import check_pointer_events_handle
|
||||
el = MagicMock()
|
||||
el.bounding_box = MagicMock(side_effect=Exception("stale handle"))
|
||||
el.evaluate = MagicMock(side_effect=Exception("execution context destroyed"))
|
||||
start = time.monotonic()
|
||||
check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000) # must not raise
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
|
||||
|
||||
def test_locator_failopen_returns_on_evaluate_error(self):
|
||||
from cloakbrowser.human.actionability import check_pointer_events
|
||||
page = MagicMock()
|
||||
loc = MagicMock()
|
||||
loc.first = loc
|
||||
loc.bounding_box = MagicMock(side_effect=Exception("no element"))
|
||||
loc.evaluate = MagicMock(side_effect=Exception("no element"))
|
||||
page.locator = MagicMock(return_value=loc)
|
||||
start = time.monotonic()
|
||||
check_pointer_events(page, "#x", 100, 100, timeout=2000) # must not raise
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
|
||||
|
||||
def test_handle_still_raises_when_covered(self):
|
||||
"""A genuine 'covered' result (not None) must still raise — fail-open
|
||||
only applies when the check could not be determined."""
|
||||
from cloakbrowser.human.actionability import (
|
||||
check_pointer_events_handle, ElementNotReceivingEventsError,
|
||||
)
|
||||
el = MagicMock()
|
||||
el.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 10, "height": 10})
|
||||
el.evaluate = MagicMock(return_value={"hit": False, "covering": "DIV"})
|
||||
with pytest.raises(ElementNotReceivingEventsError):
|
||||
check_pointer_events_handle(MagicMock(), el, 5, 5, timeout=200)
|
||||
|
||||
def test_async_handle_failopen_returns_on_evaluate_error(self):
|
||||
from cloakbrowser.human.actionability_async import async_check_pointer_events_handle
|
||||
from unittest.mock import AsyncMock
|
||||
el = MagicMock()
|
||||
el.bounding_box = AsyncMock(side_effect=Exception("stale handle"))
|
||||
el.evaluate = AsyncMock(side_effect=Exception("execution context destroyed"))
|
||||
start = time.monotonic()
|
||||
asyncio.run(async_check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000))
|
||||
elapsed_ms = (time.monotonic() - start) * 1000
|
||||
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Direct runner (backwards compat)
|
||||
# =========================================================================
|
||||
|
||||
@@ -165,10 +165,11 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
|
||||
pw.stop.assert_called_once()
|
||||
|
||||
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
|
||||
"""Proxy string parsed and passed."""
|
||||
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin, _mock_platform):
|
||||
"""Proxy string parsed and passed (unsupported platform → Playwright dict)."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
|
||||
+118
-14
@@ -55,12 +55,12 @@ class TestBuildProxyKwargs:
|
||||
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_with_auth(self):
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
def test_proxy_with_auth(self, *_):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert kwargs == {
|
||||
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
}
|
||||
assert args == []
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||
|
||||
def test_proxy_dict_passthrough(self):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
|
||||
@@ -68,7 +68,9 @@ class TestBuildProxyKwargs:
|
||||
assert kwargs == {"proxy": proxy_dict}
|
||||
assert args == []
|
||||
|
||||
def test_proxy_dict_with_auth(self):
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
def test_proxy_dict_with_auth(self, *_):
|
||||
proxy_dict = {
|
||||
"server": "http://proxy:8080",
|
||||
"username": "user",
|
||||
@@ -76,8 +78,11 @@ class TestBuildProxyKwargs:
|
||||
"bypass": ".example.com",
|
||||
}
|
||||
kwargs, args = _resolve_proxy_config(proxy_dict)
|
||||
assert kwargs == {"proxy": proxy_dict}
|
||||
assert args == []
|
||||
assert kwargs == {}
|
||||
assert args == [
|
||||
"--proxy-server=http://user:pass@proxy:8080",
|
||||
"--proxy-bypass-list=.example.com",
|
||||
]
|
||||
|
||||
|
||||
class TestMaybeResolveGeoip:
|
||||
@@ -183,11 +188,12 @@ class TestBareProxyFormat:
|
||||
r = _parse_proxy_url("proxy:8080")
|
||||
assert r == {"server": "proxy:8080"}
|
||||
|
||||
def test_resolve_proxy_config_bare(self):
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
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"]
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||
|
||||
|
||||
class TestIsSocksProxy:
|
||||
@@ -219,11 +225,17 @@ class TestResolveProxyConfig:
|
||||
assert kwargs == {}
|
||||
assert args == []
|
||||
|
||||
def test_http_string_returns_playwright_dict(self):
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
def test_http_string_with_creds_returns_chrome_arg(self, *_):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||
|
||||
def test_http_string_no_creds_returns_playwright_dict(self):
|
||||
kwargs, args = _resolve_proxy_config("http://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):
|
||||
@@ -374,3 +386,95 @@ class TestResolveProxyConfig:
|
||||
# Port 0 is an unusual but valid URL component; don't silently strip it.
|
||||
_, args = _resolve_proxy_config("socks5://user:pass=1@host:0")
|
||||
assert args[0] == "--proxy-server=socks5://user:pass%3D1@host:0"
|
||||
|
||||
# --- HTTP with credentials → --proxy-server (supported platforms + version) ---
|
||||
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
def test_http_string_with_creds_on_supported_platform(self, *_):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
def test_http_dict_with_creds_on_supported_platform(self, *_):
|
||||
proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
def test_http_dict_with_creds_and_bypass(self, *_):
|
||||
proxy = {
|
||||
"server": "http://proxy:8080",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"bypass": ".google.com",
|
||||
}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {}
|
||||
assert "--proxy-server=http://user:pass@proxy:8080" in args
|
||||
assert "--proxy-bypass-list=.google.com" in args
|
||||
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
def test_http_string_encodes_special_chars_in_password(self, *_):
|
||||
_, args = _resolve_proxy_config("http://user:pass=123@proxy:8080")
|
||||
assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"]
|
||||
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
def test_http_string_encoding_idempotent(self, *_):
|
||||
_, args = _resolve_proxy_config("http://user:pass%3D123@proxy:8080")
|
||||
assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"]
|
||||
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="windows-x64")
|
||||
def test_http_string_with_creds_on_windows(self, *_):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert kwargs == {}
|
||||
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
|
||||
|
||||
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.3")
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
|
||||
def test_http_with_creds_old_version_falls_back(self, *_):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert "proxy" in kwargs
|
||||
assert args == []
|
||||
|
||||
# --- HTTP with credentials on unsupported platform → fallback to Playwright ---
|
||||
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
|
||||
def test_http_string_with_creds_on_macos_falls_back(self, _mock):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert "proxy" in kwargs
|
||||
assert kwargs["proxy"]["username"] == "user"
|
||||
assert args == []
|
||||
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
|
||||
def test_http_dict_with_creds_on_macos_falls_back(self, _mock):
|
||||
proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {"proxy": proxy}
|
||||
assert args == []
|
||||
|
||||
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-arm64")
|
||||
def test_http_string_with_creds_on_linux_arm_falls_back(self, _mock):
|
||||
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||
assert "proxy" in kwargs
|
||||
assert args == []
|
||||
|
||||
# --- HTTP without credentials (all platforms) ---
|
||||
|
||||
def test_http_no_creds_returns_playwright_dict(self):
|
||||
kwargs, args = _resolve_proxy_config("http://proxy:8080")
|
||||
assert "proxy" in kwargs
|
||||
assert args == []
|
||||
|
||||
def test_http_dict_no_creds_returns_playwright_dict(self):
|
||||
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
|
||||
kwargs, args = _resolve_proxy_config(proxy)
|
||||
assert kwargs == {"proxy": proxy}
|
||||
assert args == []
|
||||
|
||||
Reference in New Issue
Block a user