cloakbrowser
Advanced tools
| /** | ||
| * ElementHandle humanization for Playwright. | ||
| * | ||
| * Mirrors Puppeteer's ElementHandle patching architecture. | ||
| * Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles, | ||
| * and patches all interaction methods on each ElementHandle instance. | ||
| * | ||
| * Playwright ElementHandle methods patched: | ||
| * click, dblclick, hover, type, fill, press, selectOption, | ||
| * check, uncheck, setChecked, tap, focus | ||
| * + $, $$, waitForSelector (nested elements are also patched) | ||
| * | ||
| * Stealth-aware: | ||
| * - Uses CDP DOM.describeNode when available to check element type | ||
| * (no main-world JS execution) | ||
| * - Falls back to el.evaluate() only when CDP is unavailable | ||
| */ | ||
| import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core'; | ||
| import type { HumanConfig } from './config.js'; | ||
| import { rand, randRange, sleep } from './config.js'; | ||
| import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; | ||
| import { humanType } from './keyboard.js'; | ||
| // --- Platform-aware select-all shortcut --- | ||
| const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; | ||
| // ============================================================================ | ||
| // Stealth ElementHandle input check — uses CDP DOM.describeNode | ||
| // ============================================================================ | ||
| async function isInputElementHandle( | ||
| stealth: any, // StealthEval from index.ts | ||
| el: ElementHandle, | ||
| ): Promise<boolean> { | ||
| // Try CDP DOM.describeNode first (no main-world JS execution) | ||
| if (stealth) { | ||
| try { | ||
| const cdp: CDPSession = await stealth.getCdpSession(); | ||
| // Playwright exposes the JSHandle's internal preview via _objectId or similar | ||
| // We need the remote object ID. Try to get it via internal API. | ||
| const impl = (el as any)._impl ?? (el as any)._object ?? el; | ||
| const guid = (impl as any)._guid; | ||
| // Use el.evaluate as a reliable fallback within stealth context | ||
| // Playwright doesn't expose remoteObject directly like Puppeteer | ||
| } catch { /* fallthrough */ } | ||
| } | ||
| // Fallback: el.evaluate (works reliably in Playwright) | ||
| try { | ||
| return await el.evaluate((node: any) => { | ||
| const tag = node.tagName?.toLowerCase(); | ||
| return tag === 'input' || tag === 'textarea' | ||
| || node.getAttribute?.('contenteditable') === 'true'; | ||
| }); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| // ============================================================================ | ||
| // CursorState type (matches index.ts) | ||
| // ============================================================================ | ||
| interface CursorState { | ||
| x: number; | ||
| y: number; | ||
| initialized: boolean; | ||
| } | ||
| // ============================================================================ | ||
| // Patch a single Playwright ElementHandle | ||
| // ============================================================================ | ||
| export function patchSingleElementHandle( | ||
| el: ElementHandle, | ||
| page: Page, | ||
| cfg: HumanConfig, | ||
| cursor: CursorState, | ||
| raw: RawMouse, | ||
| rawKb: RawKeyboard, | ||
| originals: any, | ||
| stealth: any, | ||
| ): void { | ||
| if ((el as any)._humanPatched) return; | ||
| (el as any)._humanPatched = true; | ||
| // Save originals | ||
| const origElClick = el.click.bind(el); | ||
| const origElDblclick = el.dblclick.bind(el); | ||
| const origElHover = el.hover.bind(el); | ||
| const origElType = el.type.bind(el); | ||
| const origElFill = el.fill.bind(el); | ||
| const origElPress = el.press.bind(el); | ||
| const origElSelectOption = el.selectOption.bind(el); | ||
| const origElCheck = el.check.bind(el); | ||
| const origElUncheck = el.uncheck.bind(el); | ||
| const origElSetChecked = (el as any).setChecked?.bind(el); | ||
| const origElTap = el.tap.bind(el); | ||
| const origElFocus = el.focus.bind(el); | ||
| // Nested selectors | ||
| const origEl$ = el.$.bind(el); | ||
| const origEl$$ = el.$$.bind(el); | ||
| const origElWaitForSelector = el.waitForSelector.bind(el); | ||
| // --- Nested elements are also patched --- | ||
| (el as any).$ = async (selector: string) => { | ||
| const child = await origEl$(selector); | ||
| if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| return child; | ||
| }; | ||
| (el as any).$$ = async (selector: string) => { | ||
| const children = await origEl$$(selector); | ||
| for (const child of children) { | ||
| patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| } | ||
| return children; | ||
| }; | ||
| (el as any).waitForSelector = async (selector: string, options?: any) => { | ||
| const child = await origElWaitForSelector(selector, options); | ||
| if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| return child; | ||
| }; | ||
| // --- Helper: get bounding box and move cursor to element --- | ||
| const moveToElement = async () => { | ||
| // Ensure cursor is initialized | ||
| const ensureCursorInit = (page as any)._ensureCursorInit; | ||
| if (ensureCursorInit) await ensureCursorInit(); | ||
| const box = await el.boundingBox(); | ||
| if (!box) return null; | ||
| const isInp = await isInputElementHandle(stealth, el); | ||
| const target = clickTarget(box, isInp, cfg); | ||
| if (cfg.idle_between_actions) { | ||
| await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg); | ||
| } | ||
| await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg); | ||
| cursor.x = target.x; | ||
| cursor.y = target.y; | ||
| return { box, isInp }; | ||
| }; | ||
| // --- el.click() --- | ||
| (el as any).click = async (options?: any) => { | ||
| const info = await moveToElement(); | ||
| if (!info) return origElClick(options); | ||
| await humanClick(raw, info.isInp, cfg); | ||
| }; | ||
| // --- el.dblclick() --- | ||
| (el as any).dblclick = async (options?: any) => { | ||
| const info = await moveToElement(); | ||
| if (!info) return origElDblclick(options); | ||
| await raw.down({ clickCount: 2 }); | ||
| await sleep(rand(30, 60)); | ||
| await raw.up({ clickCount: 2 }); | ||
| }; | ||
| // --- el.hover() --- | ||
| (el as any).hover = async (options?: any) => { | ||
| const info = await moveToElement(); | ||
| if (!info) return origElHover(options); | ||
| // Just move — no click | ||
| }; | ||
| // --- el.type() --- | ||
| (el as any).type = async (text: string, options?: any) => { | ||
| const info = await moveToElement(); | ||
| if (!info) return origElType(text, options); | ||
| await humanClick(raw, info.isInp, cfg); | ||
| await sleep(rand(100, 250)); | ||
| let cdpSession: CDPSession | null = null; | ||
| try { cdpSession = await stealth?.getCdpSession(); } catch {} | ||
| await humanType(page, rawKb, text, cfg, cdpSession); | ||
| }; | ||
| // --- el.fill() --- | ||
| (el as any).fill = async (value: string, options?: any) => { | ||
| const info = await moveToElement(); | ||
| if (!info) return origElFill(value, options); | ||
| await humanClick(raw, info.isInp, cfg); | ||
| await sleep(rand(100, 250)); | ||
| // Clear existing content | ||
| await originals.keyboardPress(SELECT_ALL); | ||
| await sleep(rand(30, 80)); | ||
| await originals.keyboardPress('Backspace'); | ||
| await sleep(rand(50, 150)); | ||
| let cdpSession: CDPSession | null = null; | ||
| try { cdpSession = await stealth?.getCdpSession(); } catch {} | ||
| await humanType(page, rawKb, value, cfg, cdpSession); | ||
| }; | ||
| // --- el.press() --- | ||
| (el as any).press = async (key: string, options?: any) => { | ||
| await sleep(rand(20, 60)); | ||
| await originals.keyboardDown(key); | ||
| await sleep(randRange(cfg.key_hold)); | ||
| await originals.keyboardUp(key); | ||
| }; | ||
| // --- el.selectOption() --- | ||
| (el as any).selectOption = async (values: any, options?: any) => { | ||
| const info = await moveToElement(); | ||
| if (!info) return origElSelectOption(values, options); | ||
| await humanClick(raw, false, cfg); | ||
| await sleep(rand(100, 300)); | ||
| return origElSelectOption(values, options); | ||
| }; | ||
| // --- el.check() --- | ||
| (el as any).check = async (options?: any) => { | ||
| try { | ||
| const checked = await el.isChecked(); | ||
| if (checked) return; // Already checked | ||
| } catch {} | ||
| const info = await moveToElement(); | ||
| if (!info) return origElCheck(options); | ||
| await humanClick(raw, info.isInp, cfg); | ||
| }; | ||
| // --- el.uncheck() --- | ||
| (el as any).uncheck = async (options?: any) => { | ||
| try { | ||
| const checked = await el.isChecked(); | ||
| if (!checked) return; // Already unchecked | ||
| } catch {} | ||
| const info = await moveToElement(); | ||
| if (!info) return origElUncheck(options); | ||
| await humanClick(raw, info.isInp, cfg); | ||
| }; | ||
| // --- el.setChecked() --- | ||
| if (origElSetChecked) { | ||
| (el as any).setChecked = async (checked: boolean, options?: any) => { | ||
| try { | ||
| const current = await el.isChecked(); | ||
| if (current === checked) return; | ||
| } catch {} | ||
| const info = await moveToElement(); | ||
| if (!info) return origElSetChecked(checked, options); | ||
| await humanClick(raw, info.isInp, cfg); | ||
| }; | ||
| } | ||
| // --- el.tap() --- | ||
| (el as any).tap = async (options?: any) => { | ||
| const info = await moveToElement(); | ||
| if (!info) return origElTap(options); | ||
| await humanClick(raw, info.isInp, cfg); | ||
| }; | ||
| // --- el.focus() --- | ||
| // Move cursor humanly but use programmatic focus (no click side-effects). | ||
| // Stock Playwright el.focus() never clicks — clicking would trigger onclick, | ||
| // submit forms, navigate links, etc. | ||
| (el as any).focus = async () => { | ||
| await moveToElement(); // human-like Bézier cursor movement | ||
| await origElFocus(); // programmatic focus, no click | ||
| }; | ||
| } | ||
| // ============================================================================ | ||
| // Page-level ElementHandle patching | ||
| // ============================================================================ | ||
| export function patchPageElementHandles( | ||
| page: Page, | ||
| cfg: HumanConfig, | ||
| cursor: CursorState, | ||
| raw: RawMouse, | ||
| rawKb: RawKeyboard, | ||
| originals: any, | ||
| stealth: any, | ||
| ): void { | ||
| // Patch page.$() — only if the method exists | ||
| if (typeof page.$ === 'function') { | ||
| const orig$ = page.$.bind(page); | ||
| (page as any).$ = async (selector: string) => { | ||
| const el = await orig$(selector); | ||
| if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| return el; | ||
| }; | ||
| } | ||
| // Patch page.$$() | ||
| if (typeof page.$$ === 'function') { | ||
| const orig$$ = page.$$.bind(page); | ||
| (page as any).$$ = async (selector: string) => { | ||
| const els = await orig$$(selector); | ||
| for (const el of els) { | ||
| patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| } | ||
| return els; | ||
| }; | ||
| } | ||
| // Patch page.waitForSelector() | ||
| if (typeof page.waitForSelector === 'function') { | ||
| const origWaitForSelector = page.waitForSelector.bind(page); | ||
| (page as any).waitForSelector = async (selector: string, options?: any) => { | ||
| const el = await origWaitForSelector(selector, options); | ||
| if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| return el; | ||
| }; | ||
| } | ||
| } | ||
| // ============================================================================ | ||
| // Frame-level ElementHandle patching | ||
| // ============================================================================ | ||
| export function patchFrameElementHandles( | ||
| frame: Frame, | ||
| page: Page, | ||
| cfg: HumanConfig, | ||
| cursor: CursorState, | ||
| raw: RawMouse, | ||
| rawKb: RawKeyboard, | ||
| originals: any, | ||
| stealth: any, | ||
| ): void { | ||
| // Patch frame.$() — only if the method exists | ||
| if (typeof frame.$ === 'function') { | ||
| const origFrame$ = frame.$.bind(frame); | ||
| (frame as any).$ = async (selector: string) => { | ||
| const el = await origFrame$(selector); | ||
| if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| return el; | ||
| }; | ||
| } | ||
| // Patch frame.$$() | ||
| if (typeof frame.$$ === 'function') { | ||
| const origFrame$$ = frame.$$.bind(frame); | ||
| (frame as any).$$ = async (selector: string) => { | ||
| const els = await origFrame$$(selector); | ||
| for (const el of els) { | ||
| patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| } | ||
| return els; | ||
| }; | ||
| } | ||
| // Patch frame.waitForSelector() | ||
| if (typeof frame.waitForSelector === 'function') { | ||
| const origFrameWaitForSelector = frame.waitForSelector.bind(frame); | ||
| (frame as any).waitForSelector = async (selector: string, options?: any) => { | ||
| const el = await origFrameWaitForSelector(selector, options); | ||
| if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| return el; | ||
| }; | ||
| } | ||
| } |
+8
-0
@@ -9,2 +9,10 @@ # Changelog | ||
| ## [0.3.24] — 2026-04-10 | ||
| - **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS. | ||
| - **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@lilos](https://github.com/lilos), #133) | ||
| - **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64 | ||
| - **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5) | ||
| - **[docs]** Clarify humanize requires wrapper import over CDP (#126) | ||
| ## [0.3.23] — 2026-04-09 | ||
@@ -11,0 +19,0 @@ |
@@ -1,1 +0,1 @@ | ||
| __version__ = "0.3.23" | ||
| __version__ = "0.3.24" |
+85
-21
@@ -20,3 +20,3 @@ """Core browser launch functions for cloakbrowser. | ||
| from typing import Any, Literal, TypedDict | ||
| from urllib.parse import unquote, urlparse, urlunparse | ||
| from urllib.parse import quote, unquote, urlparse, urlunparse | ||
@@ -109,2 +109,3 @@ from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args | ||
| timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) | ||
| proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) | ||
| args = _resolve_webrtc_args(args, proxy) | ||
@@ -114,3 +115,3 @@ if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)): | ||
| args.append(f"--fingerprint-webrtc-ip={exit_ip}") | ||
| chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless) | ||
| chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless) | ||
@@ -125,3 +126,3 @@ logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args)) | ||
| ignore_default_args=IGNORE_DEFAULT_ARGS, | ||
| **_build_proxy_kwargs(proxy), | ||
| **proxy_kwargs, | ||
| **kwargs, | ||
@@ -201,2 +202,3 @@ ) | ||
| timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) | ||
| proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) | ||
| args = _resolve_webrtc_args(args, proxy) | ||
@@ -206,3 +208,3 @@ if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)): | ||
| args.append(f"--fingerprint-webrtc-ip={exit_ip}") | ||
| chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless) | ||
| chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless) | ||
@@ -217,3 +219,3 @@ logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args)) | ||
| ignore_default_args=IGNORE_DEFAULT_ARGS, | ||
| **_build_proxy_kwargs(proxy), | ||
| **proxy_kwargs, | ||
| **kwargs, | ||
@@ -307,2 +309,3 @@ ) | ||
| timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) | ||
| proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) | ||
| args = _resolve_webrtc_args(args, proxy) | ||
@@ -312,3 +315,3 @@ if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)): | ||
| args.append(f"--fingerprint-webrtc-ip={exit_ip}") | ||
| chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless) | ||
| chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless) | ||
@@ -343,3 +346,3 @@ logger.debug( | ||
| ignore_default_args=IGNORE_DEFAULT_ARGS, | ||
| **_build_proxy_kwargs(proxy), | ||
| **proxy_kwargs, | ||
| **context_kwargs, | ||
@@ -435,2 +438,3 @@ ) | ||
| timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) | ||
| proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) | ||
| args = _resolve_webrtc_args(args, proxy) | ||
@@ -440,3 +444,3 @@ if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)): | ||
| args.append(f"--fingerprint-webrtc-ip={exit_ip}") | ||
| chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless) | ||
| chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless) | ||
@@ -471,3 +475,3 @@ logger.debug( | ||
| ignore_default_args=IGNORE_DEFAULT_ARGS, | ||
| **_build_proxy_kwargs(proxy), | ||
| **proxy_kwargs, | ||
| **context_kwargs, | ||
@@ -647,10 +651,38 @@ ) | ||
| def _reconstruct_socks_url(proxy: ProxySettings) -> str: | ||
| """Reconstruct a SOCKS5 URL with inline credentials from a Playwright proxy dict.""" | ||
| server = proxy.get("server", "") | ||
| username = proxy.get("username", "") | ||
| password = proxy.get("password", "") | ||
| if not username: | ||
| return server | ||
| parsed = urlparse(server) | ||
| creds = quote(username, safe="") | ||
| if password: | ||
| creds += f":{quote(password, safe='')}" | ||
| host = parsed.hostname or "" | ||
| if ":" in host: # IPv6 literal — re-add brackets | ||
| host = f"[{host}]" | ||
| netloc = f"{creds}@{host}" | ||
| if parsed.port: | ||
| netloc += f":{parsed.port}" | ||
| return urlunparse((parsed.scheme, netloc, parsed.path, "", "", "")) | ||
| def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None: | ||
| """Extract and normalize proxy URL string from proxy param.""" | ||
| """Extract and normalize proxy URL string from proxy param. | ||
| For SOCKS5 dicts with separate username/password fields, reconstructs | ||
| the full URL with inline credentials so SOCKS5 auth works. | ||
| """ | ||
| if proxy is None: | ||
| return None | ||
| raw = proxy.get("server") if isinstance(proxy, dict) else proxy | ||
| if not raw: | ||
| return None | ||
| return _ensure_proxy_scheme(raw) | ||
| if isinstance(proxy, dict): | ||
| server = proxy.get("server", "") | ||
| if not server: | ||
| return None | ||
| if _is_socks_proxy(proxy): | ||
| return _reconstruct_socks_url(proxy) | ||
| return _ensure_proxy_scheme(server) | ||
| return _ensure_proxy_scheme(proxy) | ||
@@ -711,3 +743,3 @@ | ||
| if not proxy_url: | ||
| logger.debug("--fingerprint-webrtc-ip=auto but no proxy set — removing flag") | ||
| logger.warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag") | ||
| args = list(args) | ||
@@ -720,3 +752,3 @@ del args[idx] | ||
| except Exception: | ||
| logger.debug("WebRTC IP resolution failed — removing flag") | ||
| logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto") | ||
| args = list(args) | ||
@@ -729,2 +761,3 @@ del args[idx] | ||
| else: | ||
| logger.warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto") | ||
| args = list(args) | ||
@@ -819,8 +852,39 @@ del args[idx] | ||
| def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]: | ||
| """Build proxy kwargs for Playwright launch.""" | ||
| def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool: | ||
| """Check if the proxy uses SOCKS5 protocol.""" | ||
| if proxy is None: | ||
| return {} | ||
| return False | ||
| url = proxy.get("server", "") if isinstance(proxy, dict) else proxy | ||
| return url.lower().startswith(("socks5://", "socks5h://")) | ||
| def _resolve_proxy_config( | ||
| proxy: str | ProxySettings | None, | ||
| ) -> tuple[dict[str, Any], list[str]]: | ||
| """Resolve proxy into Playwright kwargs and Chrome args. | ||
| Playwright rejects SOCKS5 proxies with credentials in its proxy dict, | ||
| so SOCKS5 is passed via --proxy-server Chrome arg instead. | ||
| Returns: | ||
| (proxy_kwargs, extra_chrome_args) — one or both will be empty. | ||
| """ | ||
| if proxy is None: | ||
| return {}, [] | ||
| if _is_socks_proxy(proxy): | ||
| # SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server. | ||
| # Chrome handles SOCKS5 auth natively from the URL. | ||
| if isinstance(proxy, dict): | ||
| url = _reconstruct_socks_url(proxy) | ||
| extra_args = [f"--proxy-server={url}"] | ||
| if proxy.get("bypass"): | ||
| extra_args.append(f"--proxy-bypass-list={proxy['bypass']}") | ||
| return {}, extra_args | ||
| # String URL — pass as-is (Chrome handles user:pass@ in the URL) | ||
| return {}, [f"--proxy-server={proxy}"] | ||
| # HTTP/HTTPS: use Playwright's proxy dict as before | ||
| if isinstance(proxy, dict): | ||
| return {"proxy": proxy} | ||
| return {"proxy": _parse_proxy_url(proxy)} | ||
| return {"proxy": proxy}, [] | ||
| return {"proxy": _parse_proxy_url(proxy)}, [] |
@@ -21,4 +21,4 @@ """Stealth configuration and platform detection for cloakbrowser.""" | ||
| PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { | ||
| "linux-x64": "146.0.7680.177.1", | ||
| "linux-arm64": "145.0.7632.159.7", | ||
| "linux-x64": "146.0.7680.177.2", | ||
| "linux-arm64": "146.0.7680.177.2", | ||
| "darwin-arm64": "145.0.7632.109.2", | ||
@@ -25,0 +25,0 @@ "darwin-x64": "145.0.7632.109.2", |
@@ -99,3 +99,3 @@ """GeoIP-based timezone and locale detection from proxy IP. | ||
| except Exception as exc: | ||
| logger.debug("GeoIP lookup failed for %s: %s", ip, exc) | ||
| logger.warning("GeoIP lookup failed for %s: %s", ip, exc) | ||
| return None, None, ip | ||
@@ -136,3 +136,3 @@ | ||
| except Exception as exc: | ||
| logger.debug("Failed to resolve proxy hostname: %s", exc) | ||
| logger.warning("Failed to resolve proxy hostname: %s", exc) | ||
| return None | ||
@@ -170,5 +170,10 @@ | ||
| return ip | ||
| except httpx.UnsupportedProtocol: | ||
| logger.warning( | ||
| "SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]" | ||
| ) | ||
| return None | ||
| except Exception: | ||
| continue | ||
| logger.debug("Failed to discover exit IP through proxy") | ||
| logger.warning("Failed to discover exit IP through proxy") | ||
| return None | ||
@@ -175,0 +180,0 @@ |
+8
-3
| { | ||
| "name": "cloakbrowser", | ||
| "version": "0.3.23", | ||
| "version": "0.3.24", | ||
| "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.", | ||
@@ -58,3 +58,3 @@ "type": "module", | ||
| "engines": { | ||
| "node": ">=18.0.0" | ||
| "node": ">=20.0.0" | ||
| }, | ||
@@ -64,3 +64,4 @@ "peerDependencies": { | ||
| "playwright-core": ">=1.40.0", | ||
| "puppeteer-core": ">=21.0.0" | ||
| "puppeteer-core": ">=21.0.0", | ||
| "socks-proxy-agent": ">=10.0.0" | ||
| }, | ||
@@ -76,2 +77,5 @@ "peerDependenciesMeta": { | ||
| "optional": true | ||
| }, | ||
| "socks-proxy-agent": { | ||
| "optional": true | ||
| } | ||
@@ -85,2 +89,3 @@ }, | ||
| "mmdb-lib": "^3.0.2", | ||
| "socks-proxy-agent": "^10.0.0", | ||
| "playwright-core": "^1.40.0", | ||
@@ -87,0 +92,0 @@ "puppeteer-core": "^21.0.0", |
+5
-2
@@ -66,6 +66,9 @@ <p align="center"> | ||
| // With proxy | ||
| // With proxy (HTTP or SOCKS5) | ||
| const browser = await launch({ | ||
| proxy: 'http://user:pass@proxy:8080', | ||
| }); | ||
| const browser = await launch({ | ||
| proxy: 'socks5://user:pass@proxy:1080', | ||
| }); | ||
@@ -215,3 +218,3 @@ // With proxy object (bypass, separate auth fields) | ||
| - Node.js >= 18 | ||
| - Node.js >= 20 | ||
| - One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21 | ||
@@ -218,0 +221,0 @@ |
+2
-2
@@ -33,4 +33,4 @@ /** | ||
| export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = { | ||
| "linux-x64": "146.0.7680.177.1", | ||
| "linux-arm64": "145.0.7632.159.7", | ||
| "linux-x64": "146.0.7680.177.2", | ||
| "linux-arm64": "146.0.7680.177.2", | ||
| "darwin-arm64": "145.0.7632.109.2", | ||
@@ -37,0 +37,0 @@ "darwin-x64": "145.0.7632.109.2", |
+60
-8
@@ -18,3 +18,3 @@ /** | ||
| import type { LaunchOptions } from "./types.js"; | ||
| import { ensureProxyScheme } from "./proxy.js"; | ||
| import { ensureProxyScheme, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js"; | ||
@@ -133,5 +133,40 @@ // P3TERX mirror of MaxMind GeoLite2-City — no license key needed | ||
| async function resolveExitIp(proxyUrl: string): Promise<string | null> { | ||
| // Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http | ||
| // For simplicity, use a direct HTTP request to a plain-text IP echo service | ||
| // through the proxy using Node's http module | ||
| const isSocks = isSocksProxy(proxyUrl); | ||
| // SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent | ||
| if (isSocks) { | ||
| let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent; | ||
| try { | ||
| ({ SocksProxyAgent } = await import("socks-proxy-agent")); | ||
| } catch { | ||
| console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent"); | ||
| return null; | ||
| } | ||
| const { default: https } = await import("node:https"); | ||
| const agent = new SocksProxyAgent(proxyUrl); | ||
| for (const echoUrl of IP_ECHO_URLS) { | ||
| try { | ||
| const ip = await new Promise<string | null>((resolve) => { | ||
| const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => { | ||
| let data = ""; | ||
| res.on("data", (chunk: Buffer) => (data += chunk.toString())); | ||
| res.on("end", () => { | ||
| const ip = data.trim(); | ||
| resolve(net.isIP(ip) ? ip : null); | ||
| }); | ||
| }); | ||
| req.on("error", () => resolve(null)); | ||
| req.on("timeout", () => { req.destroy(); resolve(null); }); | ||
| req.end(); | ||
| }); | ||
| if (ip) return ip; | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| // HTTP/HTTPS: use a CONNECT tunnel via http | ||
| try { | ||
@@ -270,2 +305,18 @@ const { default: http } = await import("node:http"); | ||
| /** | ||
| * Extract a usable proxy URL from LaunchOptions.proxy. | ||
| * For SOCKS5 dicts with separate credentials, reconstructs the full URL | ||
| * with inline credentials so SOCKS5 auth works. | ||
| */ | ||
| function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null { | ||
| if (!proxy) return null; | ||
| if (typeof proxy === "string") return ensureProxyScheme(proxy); | ||
| const p = proxy as ProxyDict; | ||
| if (!p.server) return null; | ||
| if (p.username && isSocksProxy(p)) { | ||
| return reconstructSocksUrl(p); | ||
| } | ||
| return ensureProxyScheme(p.server); | ||
| } | ||
| /** | ||
| * Auto-fill timezone/locale from proxy IP when geoip is enabled. | ||
@@ -279,5 +330,4 @@ * Also returns exitIp as a free bonus (reused for WebRTC spoofing). | ||
| let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server; | ||
| const proxyUrl = extractProxyUrl(options.proxy); | ||
| if (!proxyUrl) return { timezone: options.timezone, locale: options.locale }; | ||
| proxyUrl = ensureProxyScheme(proxyUrl); | ||
@@ -311,4 +361,5 @@ // When both tz/locale are explicit, still resolve exit IP for WebRTC | ||
| let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy?.server; | ||
| const proxyUrl = extractProxyUrl(options.proxy); | ||
| if (!proxyUrl) { | ||
| console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag"); | ||
| const result = [...args]; | ||
@@ -318,3 +369,2 @@ result.splice(idx, 1); | ||
| } | ||
| proxyUrl = ensureProxyScheme(proxyUrl); | ||
@@ -327,2 +377,3 @@ try { | ||
| } else { | ||
| console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto"); | ||
| result.splice(idx, 1); | ||
@@ -332,2 +383,3 @@ } | ||
| } catch { | ||
| console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto"); | ||
| const result = [...args]; | ||
@@ -334,0 +386,0 @@ result.splice(idx, 1); |
@@ -15,2 +15,10 @@ /** | ||
| * press, pressSequentially, tap, dragTo, clear + Frame-level equivalents. | ||
| * | ||
| * ELEMENTHANDLE-LEVEL: | ||
| * click, dblclick, hover, type, fill, press, selectOption, | ||
| * check, uncheck, setChecked, tap, focus | ||
| * + $, $$, waitForSelector (nested elements are also patched) | ||
| * | ||
| * page.$(), page.$$(), page.waitForSelector() and Frame equivalents | ||
| * return patched ElementHandles automatically. | ||
| */ | ||
@@ -23,2 +31,3 @@ | ||
| import { scrollToElement } from './scroll.js'; | ||
| import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js'; | ||
@@ -29,2 +38,3 @@ export { HumanConfig, resolveConfig } from './config.js'; | ||
| export { scrollToElement } from './scroll.js'; | ||
| export { patchSingleElementHandle } from './elementhandle.js'; | ||
@@ -494,2 +504,5 @@ // --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) --- | ||
| patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| // --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) --- | ||
| patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| } | ||
@@ -518,2 +531,4 @@ | ||
| patchSingleFrame(frame, page, cfg, originals, stealth); | ||
| // Patch frame-level ElementHandle selectors ($, $$, waitForSelector) | ||
| patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth); | ||
| } | ||
@@ -520,0 +535,0 @@ } |
@@ -11,3 +11,3 @@ /** | ||
| import { ensureBinary } from "./download.js"; | ||
| import { parseProxyUrl } from "./proxy.js"; | ||
| import { resolveProxyConfig } from "./proxy.js"; | ||
| import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js"; | ||
@@ -43,2 +43,3 @@ | ||
| const { exitIp, ...resolved } = await maybeResolveGeoip(options); | ||
| const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy); | ||
| let resolvedArgs = await resolveWebrtcArgs(options); | ||
@@ -48,3 +49,3 @@ if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) { | ||
| } | ||
| const args = buildArgs({ ...options, ...resolved, args: resolvedArgs }); | ||
| const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] }); | ||
@@ -56,5 +57,3 @@ const browser = await chromium.launch({ | ||
| ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, | ||
| ...(options.proxy | ||
| ? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy } | ||
| : {}), | ||
| ...(proxyOption ? { proxy: proxyOption } : {}), | ||
| ...options.launchOptions, | ||
@@ -171,2 +170,3 @@ }); | ||
| const { exitIp, ...resolved } = await maybeResolveGeoip(options); | ||
| const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy); | ||
| let resolvedArgs = await resolveWebrtcArgs(options); | ||
@@ -176,3 +176,3 @@ if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) { | ||
| } | ||
| const args = buildArgs({ ...options, ...resolved, args: resolvedArgs }); | ||
| const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] }); | ||
@@ -186,5 +186,3 @@ // locale and timezone are set via binary flags (--lang, --fingerprint-timezone) | ||
| ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, | ||
| ...(options.proxy | ||
| ? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy } | ||
| : {}), | ||
| ...(proxyOption ? { proxy: proxyOption } : {}), | ||
| ...(options.userAgent ? { userAgent: options.userAgent } : {}), | ||
@@ -191,0 +189,0 @@ viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport, |
+59
-0
@@ -26,2 +26,61 @@ /** | ||
| */ | ||
| /** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */ | ||
| export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string }; | ||
| /** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */ | ||
| export interface ProxyConfig { | ||
| /** Playwright proxy option (for HTTP proxies). */ | ||
| proxyOption?: ParsedProxy; | ||
| /** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */ | ||
| proxyArgs: string[]; | ||
| } | ||
| /** | ||
| * Check if a proxy uses the SOCKS5 protocol. | ||
| */ | ||
| export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean { | ||
| if (!proxy) return false; | ||
| const url = typeof proxy === "string" ? proxy : proxy.server; | ||
| return /^socks5h?:\/\//i.test(url); | ||
| } | ||
| /** | ||
| * Reconstruct a SOCKS5 URL with inline credentials from a proxy dict. | ||
| */ | ||
| export function reconstructSocksUrl(proxy: ProxyDict): string { | ||
| const url = new URL(proxy.server); | ||
| if (proxy.username) { | ||
| url.username = encodeURIComponent(proxy.username); | ||
| if (proxy.password) url.password = encodeURIComponent(proxy.password); | ||
| } | ||
| return url.href.replace(/\/$/, ""); | ||
| } | ||
| /** | ||
| * Resolve proxy into Playwright option and/or Chrome args. | ||
| * | ||
| * Playwright rejects SOCKS5 proxies with credentials in its proxy dict, | ||
| * so SOCKS5 is passed via --proxy-server Chrome arg instead. | ||
| */ | ||
| export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig { | ||
| if (!proxy) return { proxyArgs: [] }; | ||
| if (isSocksProxy(proxy)) { | ||
| // SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server. | ||
| if (typeof proxy === "string") { | ||
| return { proxyArgs: [`--proxy-server=${proxy}`] }; | ||
| } | ||
| const socksUrl = reconstructSocksUrl(proxy); | ||
| const args = [`--proxy-server=${socksUrl}`]; | ||
| if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`); | ||
| return { proxyArgs: args }; | ||
| } | ||
| // HTTP/HTTPS: use Playwright's proxy dict | ||
| if (typeof proxy === "string") { | ||
| return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] }; | ||
| } | ||
| return { proxyOption: proxy as ParsedProxy, proxyArgs: [] }; | ||
| } | ||
| export function parseProxyUrl(proxy: string): ParsedProxy { | ||
@@ -28,0 +87,0 @@ let url: URL; |
@@ -12,3 +12,3 @@ /** | ||
| import { ensureBinary } from "./download.js"; | ||
| import { parseProxyUrl } from "./proxy.js"; | ||
| import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js"; | ||
| import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js"; | ||
@@ -43,7 +43,12 @@ | ||
| // Puppeteer handles proxy via CLI args, not a separate option. | ||
| // Chromium's --proxy-server does NOT support inline credentials, | ||
| // so we strip them and use page.authenticate() instead. | ||
| // SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth). | ||
| // HTTP: Chrome does NOT support inline credentials — strip them and | ||
| // use page.authenticate() for Proxy-Authorization headers instead. | ||
| let proxyAuth: { username: string; password: string } | undefined; | ||
| if (options.proxy) { | ||
| if (typeof options.proxy === "string") { | ||
| if (isSocksProxy(options.proxy)) { | ||
| // SOCKS5: pass full URL with credentials to Chrome directly | ||
| const { proxyArgs } = resolveProxyConfig(options.proxy); | ||
| args.push(...proxyArgs); | ||
| } else if (typeof options.proxy === "string") { | ||
| const { server, username, password } = parseProxyUrl(options.proxy); | ||
@@ -55,4 +60,2 @@ args.push(`--proxy-server=${server}`); | ||
| } else { | ||
| // Strip any inline credentials from the server URL — Chromium's | ||
| // --proxy-server doesn't support them; use page.authenticate() instead. | ||
| const parsed = parseProxyUrl(options.proxy.server); | ||
@@ -63,3 +66,2 @@ args.push(`--proxy-server=${parsed.server}`); | ||
| } | ||
| // Explicit username/password fields take precedence over inline creds | ||
| const username = options.proxy.username ?? parsed.username; | ||
@@ -66,0 +68,0 @@ const password = options.proxy.password ?? parsed.password; |
| import { describe, it, expect, vi } from "vitest"; | ||
| import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js"; | ||
| import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js"; | ||
| import { patchPageElementHandles } from "../src/human/elementhandle.js"; | ||
@@ -692,2 +693,345 @@ // ========================================================================= | ||
| // ========================================================================= | ||
| // ElementHandle patching (Playwright) | ||
| // ========================================================================= | ||
| function buildMockElementHandle(overrides: Record<string, any> = {}): any { | ||
| const el: any = { | ||
| click: vi.fn(async () => {}), | ||
| dblclick: vi.fn(async () => {}), | ||
| hover: vi.fn(async () => {}), | ||
| type: vi.fn(async () => {}), | ||
| fill: vi.fn(async () => {}), | ||
| press: vi.fn(async () => {}), | ||
| selectOption: vi.fn(async () => {}), | ||
| check: vi.fn(async () => {}), | ||
| uncheck: vi.fn(async () => {}), | ||
| setChecked: vi.fn(async () => {}), | ||
| tap: vi.fn(async () => {}), | ||
| focus: vi.fn(async () => {}), | ||
| boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })), | ||
| evaluate: overrides.evaluate ?? vi.fn(async () => false), | ||
| isChecked: overrides.isChecked ?? vi.fn(async () => false), | ||
| $: vi.fn(async () => null), | ||
| $$: vi.fn(async () => []), | ||
| waitForSelector: vi.fn(async () => null), | ||
| _humanPatched: false, | ||
| }; | ||
| return el; | ||
| } | ||
| describe("patchSingleElementHandle", () => { | ||
| it("marks element as patched", async () => { | ||
| const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default"); | ||
| const cursor = { x: 100, y: 100, initialized: true }; | ||
| const raw = { | ||
| move: vi.fn(async () => {}), | ||
| down: vi.fn(async () => {}), | ||
| up: vi.fn(async () => {}), | ||
| wheel: vi.fn(async () => {}), | ||
| }; | ||
| const rawKb = { | ||
| down: vi.fn(async () => {}), | ||
| up: vi.fn(async () => {}), | ||
| type: vi.fn(async () => {}), | ||
| insertText: vi.fn(async () => {}), | ||
| }; | ||
| const originals = { | ||
| keyboardPress: vi.fn(async () => {}), | ||
| keyboardDown: vi.fn(async () => {}), | ||
| keyboardUp: vi.fn(async () => {}), | ||
| }; | ||
| const el = buildMockElementHandle(); | ||
| const page = buildMockPage(); | ||
| patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| expect(el._humanPatched).toBe(true); | ||
| }); | ||
| it("el.click calls mouse.move and mouse.down/up (humanized path)", async () => { | ||
| const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default", { idle_between_actions: false }); | ||
| const cursor = { x: 50, y: 50, initialized: true }; | ||
| let moveCount = 0; | ||
| let downCalled = false; | ||
| let upCalled = false; | ||
| const raw = { | ||
| move: vi.fn(async () => { moveCount++; }), | ||
| down: vi.fn(async () => { downCalled = true; }), | ||
| up: vi.fn(async () => { upCalled = true; }), | ||
| wheel: vi.fn(async () => {}), | ||
| }; | ||
| const rawKb = { | ||
| down: vi.fn(async () => {}), | ||
| up: vi.fn(async () => {}), | ||
| type: vi.fn(async () => {}), | ||
| insertText: vi.fn(async () => {}), | ||
| }; | ||
| const originals = { | ||
| keyboardPress: vi.fn(async () => {}), | ||
| keyboardDown: vi.fn(async () => {}), | ||
| keyboardUp: vi.fn(async () => {}), | ||
| }; | ||
| const el = buildMockElementHandle(); | ||
| const page = buildMockPage(); | ||
| (page as any)._ensureCursorInit = vi.fn(async () => {}); | ||
| patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| await el.click(); | ||
| expect(moveCount).toBeGreaterThan(0); | ||
| expect(downCalled).toBe(true); | ||
| expect(upCalled).toBe(true); | ||
| }, 30000); | ||
| it("el.hover calls mouse.move but NOT down/up", async () => { | ||
| const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default", { idle_between_actions: false }); | ||
| const cursor = { x: 50, y: 50, initialized: true }; | ||
| let downCalled = false; | ||
| const raw = { | ||
| move: vi.fn(async () => {}), | ||
| down: vi.fn(async () => { downCalled = true; }), | ||
| up: vi.fn(async () => {}), | ||
| wheel: vi.fn(async () => {}), | ||
| }; | ||
| const rawKb = { | ||
| down: vi.fn(async () => {}), | ||
| up: vi.fn(async () => {}), | ||
| type: vi.fn(async () => {}), | ||
| insertText: vi.fn(async () => {}), | ||
| }; | ||
| const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) }; | ||
| const el = buildMockElementHandle(); | ||
| const page = buildMockPage(); | ||
| (page as any)._ensureCursorInit = vi.fn(async () => {}); | ||
| patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| await el.hover(); | ||
| expect(raw.move).toHaveBeenCalled(); | ||
| expect(downCalled).toBe(false); | ||
| }, 30000); | ||
| it("el.type triggers mouse move + click + keyboard events", async () => { | ||
| const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 }); | ||
| const cursor = { x: 50, y: 50, initialized: true }; | ||
| const raw = { | ||
| move: vi.fn(async () => {}), | ||
| down: vi.fn(async () => {}), | ||
| up: vi.fn(async () => {}), | ||
| wheel: vi.fn(async () => {}), | ||
| }; | ||
| const rawKb = { | ||
| down: vi.fn(async () => {}), | ||
| up: vi.fn(async () => {}), | ||
| type: vi.fn(async () => {}), | ||
| insertText: vi.fn(async () => {}), | ||
| }; | ||
| const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) }; | ||
| const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true | ||
| const page = buildMockPage(); | ||
| (page as any)._ensureCursorInit = vi.fn(async () => {}); | ||
| patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| await el.type("abc"); | ||
| expect(raw.move).toHaveBeenCalled(); | ||
| expect(raw.down).toHaveBeenCalled(); // click to focus | ||
| expect(rawKb.down).toHaveBeenCalled(); // keyboard typing | ||
| }, 30000); | ||
| it("el.fill calls selectAll + backspace + type", async () => { | ||
| const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 }); | ||
| const cursor = { x: 50, y: 50, initialized: true }; | ||
| const pressedKeys: string[] = []; | ||
| const raw = { | ||
| move: vi.fn(async () => {}), | ||
| down: vi.fn(async () => {}), | ||
| up: vi.fn(async () => {}), | ||
| wheel: vi.fn(async () => {}), | ||
| }; | ||
| const rawKb = { | ||
| down: vi.fn(async () => {}), | ||
| up: vi.fn(async () => {}), | ||
| type: vi.fn(async () => {}), | ||
| insertText: vi.fn(async () => {}), | ||
| }; | ||
| const originals = { | ||
| keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }), | ||
| keyboardDown: vi.fn(async () => {}), | ||
| keyboardUp: vi.fn(async () => {}), | ||
| }; | ||
| const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); | ||
| const page = buildMockPage(); | ||
| (page as any)._ensureCursorInit = vi.fn(async () => {}); | ||
| patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| await el.fill("newtext"); | ||
| const expected = process.platform === "darwin" ? "Meta+a" : "Control+a"; | ||
| expect(pressedKeys).toContain(expected); | ||
| expect(pressedKeys).toContain("Backspace"); | ||
| }, 30000); | ||
| it("no double patching", async () => { | ||
| const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default"); | ||
| const cursor = { x: 0, y: 0, initialized: false }; | ||
| const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) }; | ||
| const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) }; | ||
| const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) }; | ||
| const el = buildMockElementHandle(); | ||
| const page = buildMockPage(); | ||
| patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| const firstClick = el.click; | ||
| patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| expect(el.click).toBe(firstClick); | ||
| }); | ||
| it("nested $() returns patched child handle", async () => { | ||
| const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default"); | ||
| const cursor = { x: 0, y: 0, initialized: false }; | ||
| const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) }; | ||
| const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) }; | ||
| const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) }; | ||
| const child = buildMockElementHandle(); | ||
| const el = buildMockElementHandle(); | ||
| el.$ = vi.fn(async () => child); | ||
| const page = buildMockPage(); | ||
| patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| const result = await el.$("span"); | ||
| expect(result._humanPatched).toBe(true); | ||
| }); | ||
| }); | ||
| describe("patchPageElementHandles", () => { | ||
| it("page.$() returns patched ElementHandle", async () => { | ||
| const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default"); | ||
| const cursor = { x: 0, y: 0, initialized: false }; | ||
| const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) }; | ||
| const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) }; | ||
| const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) }; | ||
| const el = buildMockElementHandle(); | ||
| const page = buildMockPage(); | ||
| (page as any).$ = vi.fn(async () => el); | ||
| (page as any).$$ = vi.fn(async () => [el]); | ||
| (page as any).waitForSelector = vi.fn(async () => el); | ||
| patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| const result = await (page as any).$("#test"); | ||
| expect(result._humanPatched).toBe(true); | ||
| }); | ||
| it("page.$$() returns all patched handles", async () => { | ||
| const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default"); | ||
| const cursor = { x: 0, y: 0, initialized: false }; | ||
| const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) }; | ||
| const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) }; | ||
| const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) }; | ||
| const el1 = buildMockElementHandle(); | ||
| const el2 = buildMockElementHandle(); | ||
| const page = buildMockPage(); | ||
| (page as any).$ = vi.fn(async () => null); | ||
| (page as any).$$ = vi.fn(async () => [el1, el2]); | ||
| (page as any).waitForSelector = vi.fn(async () => null); | ||
| patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| const results = await (page as any).$$("div"); | ||
| expect(results[0]._humanPatched).toBe(true); | ||
| expect(results[1]._humanPatched).toBe(true); | ||
| }); | ||
| it("page.waitForSelector() returns patched handle", async () => { | ||
| const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default"); | ||
| const cursor = { x: 0, y: 0, initialized: false }; | ||
| const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) }; | ||
| const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) }; | ||
| const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) }; | ||
| const el = buildMockElementHandle(); | ||
| const page = buildMockPage(); | ||
| (page as any).$ = vi.fn(async () => null); | ||
| (page as any).$$ = vi.fn(async () => []); | ||
| (page as any).waitForSelector = vi.fn(async () => el); | ||
| patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| const result = await (page as any).waitForSelector("#test"); | ||
| expect(result._humanPatched).toBe(true); | ||
| }); | ||
| it("page.$() returns null when no element found (no crash)", async () => { | ||
| const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); | ||
| const cfg = resolveConfig("default"); | ||
| const cursor = { x: 0, y: 0, initialized: false }; | ||
| const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) }; | ||
| const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) }; | ||
| const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) }; | ||
| const page = buildMockPage(); | ||
| (page as any).$ = vi.fn(async () => null); | ||
| (page as any).$$ = vi.fn(async () => []); | ||
| (page as any).waitForSelector = vi.fn(async () => null); | ||
| patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null); | ||
| const result = await (page as any).$("#nonexistent"); | ||
| expect(result).toBeNull(); | ||
| }); | ||
| }); | ||
| describe("patchPage integrates ElementHandle patching", () => { | ||
| it("patchPage patches page.$ automatically", async () => { | ||
| const { patchPage } = await import("../src/human/index.js"); | ||
| const el = buildMockElementHandle(); | ||
| const page = buildMockPage(); | ||
| (page as any).$ = vi.fn(async () => el); | ||
| (page as any).$$ = vi.fn(async () => []); | ||
| (page as any).waitForSelector = vi.fn(async () => null); | ||
| const cfg = resolveConfig("default"); | ||
| const cursor = { x: 100, y: 100, initialized: true }; | ||
| patchPage(page as any, cfg, cursor as any); | ||
| const result = await (page as any).$("#test"); | ||
| expect(result._humanPatched).toBe(true); | ||
| }); | ||
| }); | ||
| function buildMockFrame(): any { | ||
@@ -694,0 +1038,0 @@ return { |
| import { describe, it, expect } from "vitest"; | ||
| import { parseProxyUrl } from "../src/proxy.js"; | ||
| import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js"; | ||
| import type { LaunchOptions } from "../src/types.js"; | ||
@@ -118,1 +118,90 @@ | ||
| }); | ||
| describe("isSocksProxy", () => { | ||
| it("detects socks5 string", () => { | ||
| expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true); | ||
| }); | ||
| it("detects socks5h string", () => { | ||
| expect(isSocksProxy("socks5h://host:1080")).toBe(true); | ||
| }); | ||
| it("case insensitive", () => { | ||
| expect(isSocksProxy("SOCKS5://host:1080")).toBe(true); | ||
| }); | ||
| it("rejects http", () => { | ||
| expect(isSocksProxy("http://host:8080")).toBe(false); | ||
| }); | ||
| it("detects socks5 dict", () => { | ||
| expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true); | ||
| }); | ||
| it("rejects http dict", () => { | ||
| expect(isSocksProxy({ server: "http://host:8080" })).toBe(false); | ||
| }); | ||
| it("returns false for undefined", () => { | ||
| expect(isSocksProxy(undefined)).toBe(false); | ||
| }); | ||
| }); | ||
| describe("resolveProxyConfig", () => { | ||
| it("returns empty for undefined", () => { | ||
| const { proxyOption, proxyArgs } = resolveProxyConfig(undefined); | ||
| expect(proxyOption).toBeUndefined(); | ||
| expect(proxyArgs).toEqual([]); | ||
| }); | ||
| it("returns playwright dict for http string", () => { | ||
| const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080"); | ||
| expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" }); | ||
| expect(proxyArgs).toEqual([]); | ||
| }); | ||
| it("returns playwright dict for http dict", () => { | ||
| const proxy = { server: "http://proxy:8080", bypass: ".example.com" }; | ||
| const { proxyOption, proxyArgs } = resolveProxyConfig(proxy); | ||
| expect(proxyOption).toEqual(proxy); | ||
| expect(proxyArgs).toEqual([]); | ||
| }); | ||
| it("returns chrome arg for socks5 string", () => { | ||
| const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080"); | ||
| expect(proxyOption).toBeUndefined(); | ||
| expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]); | ||
| }); | ||
| it("returns chrome arg for socks5 no auth", () => { | ||
| const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080"); | ||
| expect(proxyOption).toBeUndefined(); | ||
| expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]); | ||
| }); | ||
| it("returns chrome arg for socks5h string", () => { | ||
| const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080"); | ||
| expect(proxyOption).toBeUndefined(); | ||
| expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]); | ||
| }); | ||
| it("reconstructs URL from socks5 dict with auth", () => { | ||
| const { proxyOption, proxyArgs } = resolveProxyConfig({ | ||
| server: "socks5://host:1080", | ||
| username: "user", | ||
| password: "p@ss", | ||
| }); | ||
| expect(proxyOption).toBeUndefined(); | ||
| expect(proxyArgs.length).toBe(1); | ||
| expect(proxyArgs[0]).toContain("--proxy-server=socks5://user:p%40ss@host:1080"); | ||
| }); | ||
| it("includes bypass for socks5 dict", () => { | ||
| const { proxyArgs } = resolveProxyConfig({ | ||
| server: "socks5://host:1080", | ||
| bypass: ".example.com", | ||
| }); | ||
| expect(proxyArgs).toContain("--proxy-server=socks5://host:1080"); | ||
| expect(proxyArgs).toContain("--proxy-bypass-list=.example.com"); | ||
| }); | ||
| }); |
@@ -116,2 +116,27 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; | ||
| }); | ||
| it("keeps SOCKS5 credentials in --proxy-server URL", async () => { | ||
| const { launch } = await import("../src/puppeteer.js"); | ||
| const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" }); | ||
| const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0]; | ||
| expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080"); | ||
| // Should NOT set up page.authenticate for SOCKS5 | ||
| const page = await browser.newPage(); | ||
| expect(page.authenticate).not.toHaveBeenCalled(); | ||
| }); | ||
| it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => { | ||
| const { launch } = await import("../src/puppeteer.js"); | ||
| const browser = await launch({ | ||
| proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" }, | ||
| }); | ||
| const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0]; | ||
| expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080"); | ||
| const page = await browser.newPage(); | ||
| expect(page.authenticate).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
+19
-8
| Metadata-Version: 2.4 | ||
| Name: cloakbrowser | ||
| Version: 0.3.23 | ||
| Version: 0.3.24 | ||
| Summary: Stealth Chromium that passes every bot detection test. Drop-in Playwright replacement with source-level fingerprint patches. | ||
@@ -33,2 +33,3 @@ Project-URL: Homepage, https://github.com/CloakHQ/CloakBrowser | ||
| Requires-Dist: geoip2>=4.0; extra == 'geoip' | ||
| Requires-Dist: socksio>=1.0; extra == 'geoip' | ||
| Provides-Extra: patchright | ||
@@ -171,6 +172,7 @@ Requires-Dist: patchright>=1.40; extra == 'patchright' | ||
| ## Latest: v0.3.22 (Chromium 146.0.7680.177.1) | ||
| ## Latest: v0.3.24 (Chromium 146.0.7680.177.2) | ||
| - **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE. | ||
| - **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177 | ||
| - **49 fingerprint patches** (Linux x64) — 1 new patch, all existing patches carried forward | ||
| - **49 fingerprint patches** — Linux arm64 now matches Linux x64 on Chromium 146 | ||
| - **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call) | ||
@@ -286,4 +288,5 @@ - **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed | ||
| # With proxy | ||
| # With proxy (HTTP or SOCKS5) | ||
| browser = launch(proxy="http://user:pass@proxy:8080") | ||
| browser = launch(proxy="socks5://user:pass@proxy:1080") | ||
@@ -415,3 +418,3 @@ # With proxy dict (bypass, separate auth fields) | ||
| print(binary_info()) | ||
| # {'version': '146.0.7680.177.1', 'platform': 'linux-x64', 'installed': True, ...} | ||
| # {'version': '146.0.7680.177.2', 'platform': 'linux-x64', 'installed': True, ...} | ||
@@ -691,4 +694,12 @@ # Force re-download | ||
| # Connect your framework to http://127.0.0.1:9242 — all stealth flags are set | ||
| # Note: humanize requires the wrapper (see below) | ||
| ``` | ||
| > **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. If you connect to CloakBrowser via CDP from a separate script, import the patching functions to add humanization: | ||
| > | ||
| > ```js | ||
| > import { patchBrowser, resolveConfig } from 'cloakbrowser/human'; | ||
| > patchBrowser(browser, resolveConfig('default')); | ||
| > ``` | ||
| | Framework | Stars | Language | Example | | ||
@@ -711,3 +722,3 @@ |-----------|-------|----------|---------| | ||
| | Linux x86_64 | 146 | 49 | ✅ Latest | | ||
| | Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ | | ||
| | Linux arm64 (RPi, Graviton) | 146 | 49 | ✅ Latest | | ||
| | macOS arm64 (Apple Silicon) | 145 | 26 | ✅ | | ||
@@ -1068,3 +1079,3 @@ | macOS x86_64 (Intel) | 145 | 26 | ✅ | | ||
| **Q: Can I use my own proxy?** | ||
| A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`. | ||
| A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@host:port"` to `launch()`. Both HTTP and SOCKS5 proxies are supported natively. | ||
@@ -1099,3 +1110,3 @@ ## Roadmap | ||
| gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD | ||
| git verify-tag chromium-v146.0.7680.177.1 | ||
| git verify-tag chromium-v146.0.7680.177.2 | ||
@@ -1102,0 +1113,0 @@ # Verify GitHub binary attestation (Sigstore) |
+1
-1
@@ -57,3 +57,3 @@ [build-system] | ||
| [project.optional-dependencies] | ||
| geoip = ["geoip2>=4.0"] | ||
| geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx | ||
| patchright = ["patchright>=1.40"] | ||
@@ -60,0 +60,0 @@ serve = ["aiohttp>=3.9", "websockets>=12.0"] |
+17
-7
@@ -131,6 +131,7 @@ <p align="center"> | ||
| ## Latest: v0.3.22 (Chromium 146.0.7680.177.1) | ||
| ## Latest: v0.3.24 (Chromium 146.0.7680.177.2) | ||
| - **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE. | ||
| - **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177 | ||
| - **49 fingerprint patches** (Linux x64) — 1 new patch, all existing patches carried forward | ||
| - **49 fingerprint patches** — Linux arm64 now matches Linux x64 on Chromium 146 | ||
| - **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call) | ||
@@ -246,4 +247,5 @@ - **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed | ||
| # With proxy | ||
| # With proxy (HTTP or SOCKS5) | ||
| browser = launch(proxy="http://user:pass@proxy:8080") | ||
| browser = launch(proxy="socks5://user:pass@proxy:1080") | ||
@@ -375,3 +377,3 @@ # With proxy dict (bypass, separate auth fields) | ||
| print(binary_info()) | ||
| # {'version': '146.0.7680.177.1', 'platform': 'linux-x64', 'installed': True, ...} | ||
| # {'version': '146.0.7680.177.2', 'platform': 'linux-x64', 'installed': True, ...} | ||
@@ -651,4 +653,12 @@ # Force re-download | ||
| # Connect your framework to http://127.0.0.1:9242 — all stealth flags are set | ||
| # Note: humanize requires the wrapper (see below) | ||
| ``` | ||
| > **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. If you connect to CloakBrowser via CDP from a separate script, import the patching functions to add humanization: | ||
| > | ||
| > ```js | ||
| > import { patchBrowser, resolveConfig } from 'cloakbrowser/human'; | ||
| > patchBrowser(browser, resolveConfig('default')); | ||
| > ``` | ||
| | Framework | Stars | Language | Example | | ||
@@ -671,3 +681,3 @@ |-----------|-------|----------|---------| | ||
| | Linux x86_64 | 146 | 49 | ✅ Latest | | ||
| | Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ | | ||
| | Linux arm64 (RPi, Graviton) | 146 | 49 | ✅ Latest | | ||
| | macOS arm64 (Apple Silicon) | 145 | 26 | ✅ | | ||
@@ -1028,3 +1038,3 @@ | macOS x86_64 (Intel) | 145 | 26 | ✅ | | ||
| **Q: Can I use my own proxy?** | ||
| A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`. | ||
| A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@host:port"` to `launch()`. Both HTTP and SOCKS5 proxies are supported natively. | ||
@@ -1059,3 +1069,3 @@ ## Roadmap | ||
| gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD | ||
| git verify-tag chromium-v146.0.7680.177.1 | ||
| git verify-tag chromium-v146.0.7680.177.2 | ||
@@ -1062,0 +1072,0 @@ # Verify GitHub binary attestation (Sigstore) |
@@ -108,4 +108,6 @@ """Unit tests for cloakserve — parse_connection_params, parse_cli_args, URL rewriting, connection tracking.""" | ||
| args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"] | ||
| _, passthrough = parse_cli_args(args) | ||
| assert passthrough == args | ||
| config, passthrough = parse_cli_args(args) | ||
| # --fingerprint=999 is consumed into config["default_seed"], not passed through | ||
| assert passthrough == ["--no-sandbox", "--disable-gpu"] | ||
| assert config["default_seed"] == "999" | ||
@@ -112,0 +114,0 @@ def test_port_not_in_passthrough(self): |
@@ -283,2 +283,63 @@ """ | ||
| # ============================================================ | ||
| # SCENARIO 7: ElementHandle — query_selector interactions | ||
| # ============================================================ | ||
| step("ElementHandle — query_selector click, type, fill, hover") | ||
| page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| time.sleep(2) | ||
| inject(page) | ||
| time.sleep(1) | ||
| print(" Watch: get element via query_selector, cursor moves smoothly") | ||
| el = page.query_selector('#searchInput') | ||
| assert el is not None, "query_selector returned None" | ||
| assert getattr(el, '_human_patched', False), "ElementHandle not patched!" | ||
| t0 = time.time() | ||
| el.click() | ||
| eh_click_ms = int((time.time() - t0) * 1000) | ||
| check("ElementHandle click", eh_click_ms > 100, f"{eh_click_ms} ms") | ||
| time.sleep(0.5) | ||
| print(" Watch: ElementHandle type — characters appear one by one") | ||
| t0 = time.time() | ||
| el.type('ElementHandle typing') | ||
| eh_type_ms = int((time.time() - t0) * 1000) | ||
| val = page.locator('#searchInput').input_value() | ||
| check("ElementHandle type", val == 'ElementHandle typing' and eh_type_ms > 1500, f"{eh_type_ms} ms, value='{val}'") | ||
| time.sleep(0.5) | ||
| print(" Watch: ElementHandle fill — clears then types") | ||
| t0 = time.time() | ||
| el.fill('Filled via EH') | ||
| eh_fill_ms = int((time.time() - t0) * 1000) | ||
| val = page.locator('#searchInput').input_value() | ||
| check("ElementHandle fill", val == 'Filled via EH' and eh_fill_ms > 1000, f"{eh_fill_ms} ms, value='{val}'") | ||
| time.sleep(0.5) | ||
| print(" Watch: ElementHandle hover — cursor moves without clicking") | ||
| btn_el = page.query_selector('button[type="submit"]') | ||
| t0 = time.time() | ||
| btn_el.hover() | ||
| eh_hover_ms = int((time.time() - t0) * 1000) | ||
| check("ElementHandle hover", eh_hover_ms > 50, f"{eh_hover_ms} ms") | ||
| time.sleep(0.5) | ||
| print(" Watch: query_selector_all returns patched handles") | ||
| page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded') | ||
| time.sleep(2) | ||
| inject(page) | ||
| time.sleep(1) | ||
| els = page.query_selector_all('input[type="checkbox"]') | ||
| all_patched = all(getattr(e, '_human_patched', False) for e in els) | ||
| check("query_selector_all all patched", all_patched and len(els) >= 2, f"{len(els)} elements, all_patched={all_patched}") | ||
| if els: | ||
| print(" Watch: click checkbox via ElementHandle") | ||
| t0 = time.time() | ||
| els[0].click() | ||
| cb_click_ms = int((time.time() - t0) * 1000) | ||
| check("ElementHandle checkbox click", cb_click_ms > 100, f"{cb_click_ms} ms") | ||
| time.sleep(1) | ||
| # ============================================================ | ||
| # SUMMARY | ||
@@ -285,0 +346,0 @@ # ============================================================ |
+634
-26
@@ -15,3 +15,3 @@ """ | ||
| import sys | ||
| import asyncio | ||
| import pytest | ||
@@ -539,3 +539,3 @@ | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=True, humanize=True) | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
@@ -555,3 +555,3 @@ page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=True, humanize=True) | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
@@ -568,3 +568,3 @@ page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=True, humanize=True) | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
@@ -586,3 +586,3 @@ page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=True, humanize=True) | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
@@ -595,3 +595,3 @@ assert hasattr(page, '_original') | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=True, humanize=True) | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
@@ -617,3 +617,3 @@ from playwright.sync_api._generated import Locator | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=True, humanize=True) | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
@@ -628,3 +628,3 @@ assert page._human_cfg is not None | ||
| class TestBrowserBotDetection: | ||
| PROXY = '' | ||
| PROXY = None | ||
@@ -655,3 +655,3 @@ def test_behavioral_checks_pass(self): | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True) | ||
| browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True) | ||
| page = browser.new_page() | ||
@@ -673,30 +673,638 @@ page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', | ||
| class TestAsyncEndToEnd: | ||
| def test_async_launch_click_fill(self): | ||
| @pytest.mark.asyncio | ||
| async def test_async_launch_click_fill(self): | ||
| """launch_async(humanize=True) — async page.click and page.fill work end-to-end.""" | ||
| import asyncio | ||
| from cloakbrowser import launch_async | ||
| browser = await launch_async(headless=False, humanize=True) | ||
| page = await browser.new_page() | ||
| assert hasattr(page, '_original'), "async page not patched" | ||
| assert hasattr(page, '_human_cfg'), "async page missing _human_cfg" | ||
| async def _run(): | ||
| browser = await launch_async(headless=True, humanize=True) | ||
| page = await browser.new_page() | ||
| assert hasattr(page, '_original'), "async page not patched" | ||
| assert hasattr(page, '_human_cfg'), "async page missing _human_cfg" | ||
| await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| await asyncio.sleep(1) | ||
| await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| await asyncio.sleep(1) | ||
| t0 = time.time() | ||
| await page.locator('#searchInput').fill('async test') | ||
| elapsed_ms = int((time.time() - t0) * 1000) | ||
| assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms" | ||
| t0 = time.time() | ||
| await page.locator('#searchInput').fill('async test') | ||
| elapsed_ms = int((time.time() - t0) * 1000) | ||
| assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms" | ||
| val = await page.locator('#searchInput').input_value() | ||
| assert val == 'async test', f"async fill wrong value: {val}" | ||
| val = await page.locator('#searchInput').input_value() | ||
| assert val == 'async test', f"async fill wrong value: {val}" | ||
| await browser.close() | ||
| await browser.close() | ||
| asyncio.run(_run()) | ||
| # ========================================================================= | ||
| # 12. ElementHandle patching — SYNC | ||
| # ========================================================================= | ||
| class TestElementHandlePatchingSync: | ||
| """Test that ElementHandle objects returned by query_selector etc. are humanized.""" | ||
| def test_patch_single_element_handle_marks_patched(self): | ||
| from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", None) | ||
| cursor = _CursorState() | ||
| cursor.initialized = True | ||
| cursor.x = 100 | ||
| cursor.y = 100 | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) | ||
| el.evaluate = MagicMock(return_value=True) # is_input | ||
| el.is_checked = MagicMock(return_value=False) | ||
| el.query_selector = MagicMock(return_value=None) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| raw_mouse = MagicMock() | ||
| raw_keyboard = MagicMock() | ||
| _patch_single_element_handle_sync( | ||
| el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None | ||
| ) | ||
| assert el._human_patched is True | ||
| def test_element_handle_click_calls_human_move(self): | ||
| from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", {"idle_between_actions": False}) | ||
| cursor = _CursorState() | ||
| cursor.initialized = True | ||
| cursor.x = 100 | ||
| cursor.y = 100 | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) | ||
| el.evaluate = MagicMock(return_value=False) | ||
| el.is_checked = MagicMock(return_value=False) | ||
| el.query_selector = MagicMock(return_value=None) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| raw_mouse = MagicMock() | ||
| raw_mouse.move = MagicMock() | ||
| raw_mouse.down = MagicMock() | ||
| raw_mouse.up = MagicMock() | ||
| raw_mouse.wheel = MagicMock() | ||
| raw_keyboard = MagicMock() | ||
| _patch_single_element_handle_sync( | ||
| el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None | ||
| ) | ||
| # Call the patched click | ||
| el.click() | ||
| # Should call raw_mouse.move (Bezier path) and then down/up | ||
| assert raw_mouse.move.called | ||
| assert raw_mouse.down.called | ||
| assert raw_mouse.up.called | ||
| def test_element_handle_hover_moves_cursor_without_click(self): | ||
| from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", {"idle_between_actions": False}) | ||
| cursor = _CursorState() | ||
| cursor.initialized = True | ||
| cursor.x = 50 | ||
| cursor.y = 50 | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) | ||
| el.evaluate = MagicMock(return_value=False) | ||
| el.is_checked = MagicMock(return_value=False) | ||
| el.query_selector = MagicMock(return_value=None) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| raw_mouse = MagicMock() | ||
| raw_mouse.move = MagicMock() | ||
| raw_mouse.down = MagicMock() | ||
| raw_mouse.up = MagicMock() | ||
| raw_mouse.wheel = MagicMock() | ||
| raw_keyboard = MagicMock() | ||
| _patch_single_element_handle_sync( | ||
| el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None | ||
| ) | ||
| el.hover() | ||
| # Move should be called, but NOT down/up (hover, not click) | ||
| assert raw_mouse.move.called | ||
| assert not raw_mouse.down.called | ||
| def test_element_handle_type_calls_human_type(self): | ||
| from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0}) | ||
| cursor = _CursorState() | ||
| cursor.initialized = True | ||
| cursor.x = 50 | ||
| cursor.y = 50 | ||
| page = MagicMock() | ||
| originals = MagicMock() | ||
| page._original = originals | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) | ||
| el.evaluate = MagicMock(return_value=True) # is input | ||
| el.is_checked = MagicMock(return_value=False) | ||
| el.query_selector = MagicMock(return_value=None) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| raw_mouse = MagicMock() | ||
| raw_mouse.move = MagicMock() | ||
| raw_mouse.down = MagicMock() | ||
| raw_mouse.up = MagicMock() | ||
| raw_mouse.wheel = MagicMock() | ||
| raw_keyboard = MagicMock() | ||
| raw_keyboard.down = MagicMock() | ||
| raw_keyboard.up = MagicMock() | ||
| raw_keyboard.insert_text = MagicMock() | ||
| _patch_single_element_handle_sync( | ||
| el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None | ||
| ) | ||
| el.type("hello") | ||
| # Mouse moved + clicked (to focus), then keyboard used | ||
| assert raw_mouse.move.called | ||
| assert raw_mouse.down.called # click to focus the input | ||
| # Keyboard events should have fired (down/up for ASCII chars) | ||
| assert raw_keyboard.down.called or raw_keyboard.insert_text.called | ||
| def test_element_handle_fill_clears_and_types(self): | ||
| from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock, call | ||
| cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0}) | ||
| cursor = _CursorState() | ||
| cursor.initialized = True | ||
| cursor.x = 50 | ||
| cursor.y = 50 | ||
| page = MagicMock() | ||
| originals = MagicMock() | ||
| page._original = originals | ||
| pressed_keys = [] | ||
| originals.keyboard_press = MagicMock(side_effect=lambda k: pressed_keys.append(k)) | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) | ||
| el.evaluate = MagicMock(return_value=True) | ||
| el.is_checked = MagicMock(return_value=False) | ||
| el.query_selector = MagicMock(return_value=None) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| raw_mouse = MagicMock() | ||
| raw_mouse.move = MagicMock() | ||
| raw_mouse.down = MagicMock() | ||
| raw_mouse.up = MagicMock() | ||
| raw_mouse.wheel = MagicMock() | ||
| raw_keyboard = MagicMock() | ||
| raw_keyboard.down = MagicMock() | ||
| raw_keyboard.up = MagicMock() | ||
| raw_keyboard.insert_text = MagicMock() | ||
| _patch_single_element_handle_sync( | ||
| el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None | ||
| ) | ||
| el.fill("replaced") | ||
| # Should have pressed Select-All and Backspace to clear | ||
| import sys | ||
| expected_select = "Meta+a" if sys.platform == "darwin" else "Control+a" | ||
| assert expected_select in pressed_keys | ||
| assert "Backspace" in pressed_keys | ||
| def test_element_handle_no_double_patching(self): | ||
| from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", None) | ||
| cursor = _CursorState() | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.query_selector = MagicMock(return_value=None) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| _patch_single_element_handle_sync( | ||
| el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None | ||
| ) | ||
| # Save patched click | ||
| first_click = el.click | ||
| # Try to patch again | ||
| _patch_single_element_handle_sync( | ||
| el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None | ||
| ) | ||
| # Should be the same — no double wrap | ||
| assert el.click is first_click | ||
| def test_nested_query_selector_returns_patched_handle(self): | ||
| from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", None) | ||
| cursor = _CursorState() | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| child = MagicMock() | ||
| child._human_patched = False | ||
| child.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30}) | ||
| child.evaluate = MagicMock(return_value=False) | ||
| child.is_checked = MagicMock(return_value=False) | ||
| child.query_selector = MagicMock(return_value=None) | ||
| child.query_selector_all = MagicMock(return_value=[]) | ||
| child.wait_for_selector = MagicMock(return_value=None) | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) | ||
| el.evaluate = MagicMock(return_value=False) | ||
| el.is_checked = MagicMock(return_value=False) | ||
| el.query_selector = MagicMock(return_value=child) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| _patch_single_element_handle_sync( | ||
| el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None | ||
| ) | ||
| result = el.query_selector("span") | ||
| assert result._human_patched is True | ||
| def test_page_query_selector_patched(self): | ||
| from cloakbrowser.human import _patch_page_element_handles_sync, _patch_single_element_handle_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", None) | ||
| cursor = _CursorState() | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) | ||
| el.evaluate = MagicMock(return_value=False) | ||
| el.is_checked = MagicMock(return_value=False) | ||
| el.query_selector = MagicMock(return_value=None) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| page.query_selector = MagicMock(return_value=el) | ||
| page.query_selector_all = MagicMock(return_value=[el]) | ||
| page.wait_for_selector = MagicMock(return_value=el) | ||
| _patch_page_element_handles_sync( | ||
| page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None | ||
| ) | ||
| result = page.query_selector("#test") | ||
| assert result._human_patched is True | ||
| def test_page_query_selector_all_patches_all(self): | ||
| from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", None) | ||
| cursor = _CursorState() | ||
| def make_el(): | ||
| e = MagicMock() | ||
| e._human_patched = False | ||
| e.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30}) | ||
| e.evaluate = MagicMock(return_value=False) | ||
| e.is_checked = MagicMock(return_value=False) | ||
| e.query_selector = MagicMock(return_value=None) | ||
| e.query_selector_all = MagicMock(return_value=[]) | ||
| e.wait_for_selector = MagicMock(return_value=None) | ||
| return e | ||
| el1, el2, el3 = make_el(), make_el(), make_el() | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| page.query_selector = MagicMock(return_value=None) | ||
| page.query_selector_all = MagicMock(return_value=[el1, el2, el3]) | ||
| page.wait_for_selector = MagicMock(return_value=None) | ||
| _patch_page_element_handles_sync( | ||
| page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None | ||
| ) | ||
| results = page.query_selector_all("div") | ||
| for r in results: | ||
| assert r._human_patched is True | ||
| def test_wait_for_selector_patched(self): | ||
| from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", None) | ||
| cursor = _CursorState() | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) | ||
| el.evaluate = MagicMock(return_value=False) | ||
| el.is_checked = MagicMock(return_value=False) | ||
| el.query_selector = MagicMock(return_value=None) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| page.query_selector = MagicMock(return_value=None) | ||
| page.query_selector_all = MagicMock(return_value=[]) | ||
| page.wait_for_selector = MagicMock(return_value=el) | ||
| _patch_page_element_handles_sync( | ||
| page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None | ||
| ) | ||
| result = page.wait_for_selector("#test") | ||
| assert result._human_patched is True | ||
| def test_element_handle_all_methods_patched(self): | ||
| """Verify all expected interaction methods are replaced.""" | ||
| from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock | ||
| cfg = resolve_config("default", None) | ||
| cursor = _CursorState() | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) | ||
| el.evaluate = MagicMock(return_value=False) | ||
| el.is_checked = MagicMock(return_value=False) | ||
| el.query_selector = MagicMock(return_value=None) | ||
| el.query_selector_all = MagicMock(return_value=[]) | ||
| el.wait_for_selector = MagicMock(return_value=None) | ||
| el.set_checked = MagicMock() # ensure it exists | ||
| _patch_single_element_handle_sync( | ||
| el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None | ||
| ) | ||
| expected_methods = ['click', 'dblclick', 'hover', 'type', 'fill', 'press', | ||
| 'select_option', 'check', 'uncheck', 'set_checked', | ||
| 'tap', 'focus', 'query_selector', 'query_selector_all', | ||
| 'wait_for_selector'] | ||
| for method in expected_methods: | ||
| fn = getattr(el, method) | ||
| assert not isinstance(fn, MagicMock), f"el.{method} was not patched" | ||
| # ========================================================================= | ||
| # 13. ElementHandle patching — ASYNC | ||
| # ========================================================================= | ||
| class TestElementHandlePatchingAsync: | ||
| @pytest.mark.asyncio | ||
| async def test_async_element_handle_click(self): | ||
| from cloakbrowser.human import _patch_single_element_handle_async, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock, AsyncMock | ||
| cfg = resolve_config("default", {"idle_between_actions": False}) | ||
| cursor = _CursorState() | ||
| cursor.initialized = True | ||
| cursor.x = 100 | ||
| cursor.y = 100 | ||
| page = MagicMock() | ||
| originals = MagicMock() | ||
| originals.mouse_move = AsyncMock() | ||
| page._original = originals | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) | ||
| el.evaluate = AsyncMock(return_value=False) | ||
| el.is_checked = AsyncMock(return_value=False) | ||
| el.query_selector = AsyncMock(return_value=None) | ||
| el.query_selector_all = AsyncMock(return_value=[]) | ||
| el.wait_for_selector = AsyncMock(return_value=None) | ||
| raw_mouse = MagicMock() | ||
| raw_mouse.move = AsyncMock() | ||
| raw_mouse.down = AsyncMock() | ||
| raw_mouse.up = AsyncMock() | ||
| raw_mouse.wheel = AsyncMock() | ||
| raw_keyboard = MagicMock() | ||
| raw_keyboard.down = AsyncMock() | ||
| raw_keyboard.up = AsyncMock() | ||
| raw_keyboard.insert_text = AsyncMock() | ||
| stealth = MagicMock() | ||
| stealth.get_cdp_session = AsyncMock(return_value=None) | ||
| _patch_single_element_handle_async( | ||
| el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, [None] | ||
| ) | ||
| await el.click() | ||
| assert raw_mouse.move.called | ||
| assert raw_mouse.down.called | ||
| assert raw_mouse.up.called | ||
| @pytest.mark.asyncio | ||
| async def test_async_page_query_selector_patched(self): | ||
| from cloakbrowser.human import _patch_page_element_handles_async, _CursorState | ||
| from cloakbrowser.human.config import resolve_config | ||
| from unittest.mock import MagicMock, AsyncMock | ||
| cfg = resolve_config("default", None) | ||
| cursor = _CursorState() | ||
| el = MagicMock() | ||
| el._human_patched = False | ||
| el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) | ||
| el.evaluate = AsyncMock(return_value=False) | ||
| el.is_checked = AsyncMock(return_value=False) | ||
| el.query_selector = AsyncMock(return_value=None) | ||
| el.query_selector_all = AsyncMock(return_value=[]) | ||
| el.wait_for_selector = AsyncMock(return_value=None) | ||
| page = MagicMock() | ||
| page._original = MagicMock() | ||
| page.query_selector = AsyncMock(return_value=el) | ||
| page.query_selector_all = AsyncMock(return_value=[el]) | ||
| page.wait_for_selector = AsyncMock(return_value=el) | ||
| stealth = MagicMock() | ||
| stealth.get_cdp_session = AsyncMock(return_value=None) | ||
| _patch_page_element_handles_async( | ||
| page, cfg, cursor, MagicMock(), MagicMock(), page._original, stealth, [None] | ||
| ) | ||
| result = await page.query_selector("#test") | ||
| assert result._human_patched is True | ||
| # ========================================================================= | ||
| # 14. SLOW: Browser ElementHandle end-to-end | ||
| # ========================================================================= | ||
| @pytest.mark.slow | ||
| class TestBrowserElementHandle: | ||
| def test_query_selector_click_humanized(self): | ||
| """page.query_selector() returns a patched handle — el.click() uses human curves.""" | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
| page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| time.sleep(1) | ||
| el = page.query_selector('#searchInput') | ||
| assert el is not None | ||
| assert getattr(el, '_human_patched', False), "ElementHandle not patched" | ||
| t0 = time.time() | ||
| el.click() | ||
| click_ms = int((time.time() - t0) * 1000) | ||
| assert click_ms > 100, f"ElementHandle click too fast: {click_ms}ms (not humanized)" | ||
| browser.close() | ||
| def test_query_selector_type_humanized(self): | ||
| """el.type() should type character-by-character with human timing.""" | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
| page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| time.sleep(1) | ||
| el = page.query_selector('#searchInput') | ||
| assert el is not None | ||
| t0 = time.time() | ||
| el.type('ElementHandle test') | ||
| type_ms = int((time.time() - t0) * 1000) | ||
| assert type_ms > 1000, f"ElementHandle type too fast: {type_ms}ms" | ||
| val = page.locator('#searchInput').input_value() | ||
| assert val == 'ElementHandle test' | ||
| browser.close() | ||
| def test_query_selector_fill_humanized(self): | ||
| """el.fill() should clear + type with human timing.""" | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
| page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| time.sleep(1) | ||
| el = page.query_selector('#searchInput') | ||
| el.type('initial') | ||
| time.sleep(0.3) | ||
| t0 = time.time() | ||
| el.fill('replaced') | ||
| fill_ms = int((time.time() - t0) * 1000) | ||
| assert fill_ms > 500, f"ElementHandle fill too fast: {fill_ms}ms" | ||
| val = page.locator('#searchInput').input_value() | ||
| assert val == 'replaced' | ||
| browser.close() | ||
| def test_query_selector_all_returns_patched(self): | ||
| """page.query_selector_all() returns all handles patched.""" | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
| page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded') | ||
| time.sleep(1) | ||
| els = page.query_selector_all('input[type="checkbox"]') | ||
| assert len(els) >= 2 | ||
| for el in els: | ||
| assert getattr(el, '_human_patched', False), "ElementHandle not patched" | ||
| browser.close() | ||
| def test_query_selector_hover_humanized(self): | ||
| """el.hover() should move cursor with human Bezier curve.""" | ||
| from cloakbrowser import launch | ||
| browser = launch(headless=False, humanize=True) | ||
| page = browser.new_page() | ||
| page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| time.sleep(1) | ||
| el = page.query_selector('#searchInput') | ||
| t0 = time.time() | ||
| el.hover() | ||
| hover_ms = int((time.time() - t0) * 1000) | ||
| assert hover_ms > 50, f"ElementHandle hover too fast: {hover_ms}ms" | ||
| browser.close() | ||
| @pytest.mark.slow | ||
| class TestAsyncElementHandle: | ||
| @pytest.mark.asyncio | ||
| async def test_async_query_selector_click(self): | ||
| from cloakbrowser import launch_async | ||
| browser = await launch_async(headless=False, humanize=True) | ||
| page = await browser.new_page() | ||
| await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') | ||
| await asyncio.sleep(1) | ||
| el = await page.query_selector('#searchInput') | ||
| assert el is not None | ||
| assert getattr(el, '_human_patched', False), "Async ElementHandle not patched" | ||
| t0 = time.time() | ||
| await el.click() | ||
| click_ms = int((time.time() - t0) * 1000) | ||
| assert click_ms > 100, f"Async ElementHandle click too fast: {click_ms}ms" | ||
| await browser.close() | ||
| # ========================================================================= | ||
| # Direct runner (backwards compat) | ||
@@ -703,0 +1311,0 @@ # ========================================================================= |
+127
-15
@@ -5,3 +5,8 @@ """Tests for proxy URL parsing and credential extraction.""" | ||
| from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url | ||
| from cloakbrowser.browser import ( | ||
| _is_socks_proxy, | ||
| _parse_proxy_url, | ||
| _resolve_proxy_config, | ||
| maybe_resolve_geoip, | ||
| ) | ||
@@ -42,19 +47,26 @@ | ||
| class TestBuildProxyKwargs: | ||
| """Tests for _resolve_proxy_config (formerly _build_proxy_kwargs) HTTP path.""" | ||
| def test_none(self): | ||
| assert _build_proxy_kwargs(None) == {} | ||
| kwargs, args = _resolve_proxy_config(None) | ||
| assert kwargs == {} | ||
| assert args == [] | ||
| def test_simple_proxy(self): | ||
| result = _build_proxy_kwargs("http://proxy:8080") | ||
| assert result == {"proxy": {"server": "http://proxy:8080"}} | ||
| kwargs, args = _resolve_proxy_config("http://proxy:8080") | ||
| assert kwargs == {"proxy": {"server": "http://proxy:8080"}} | ||
| assert args == [] | ||
| def test_proxy_with_auth(self): | ||
| result = _build_proxy_kwargs("http://user:pass@proxy:8080") | ||
| assert result == { | ||
| kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") | ||
| assert kwargs == { | ||
| "proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"} | ||
| } | ||
| assert args == [] | ||
| def test_proxy_dict_passthrough(self): | ||
| proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"} | ||
| result = _build_proxy_kwargs(proxy_dict) | ||
| assert result == {"proxy": proxy_dict} | ||
| kwargs, args = _resolve_proxy_config(proxy_dict) | ||
| assert kwargs == {"proxy": proxy_dict} | ||
| assert args == [] | ||
@@ -68,4 +80,5 @@ def test_proxy_dict_with_auth(self): | ||
| } | ||
| result = _build_proxy_kwargs(proxy_dict) | ||
| assert result == {"proxy": proxy_dict} | ||
| kwargs, args = _resolve_proxy_config(proxy_dict) | ||
| assert kwargs == {"proxy": proxy_dict} | ||
| assert args == [] | ||
@@ -123,3 +136,24 @@ | ||
| @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")) | ||
| def test_geoip_socks5_dict_reconstructs_credentials(self, mock_geo): | ||
| proxy_dict = {"server": "socks5://proxy:1080", "username": "user", "password": "pass"} | ||
| tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None) | ||
| mock_geo.assert_called_once_with("socks5://user:pass@proxy:1080") | ||
| assert tz == "Europe/Berlin" | ||
| assert locale == "de-DE" | ||
| @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")) | ||
| def test_geoip_socks5_dict_no_auth_uses_server(self, mock_geo): | ||
| proxy_dict = {"server": "socks5://proxy:1080"} | ||
| tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None) | ||
| mock_geo.assert_called_once_with("socks5://proxy:1080") | ||
| @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "1.1.1.1")) | ||
| def test_geoip_http_dict_does_not_inline_creds(self, mock_geo): | ||
| # HTTP dict: credentials stay separate, only server URL passed | ||
| proxy_dict = {"server": "http://proxy:8080", "username": "user", "password": "pass"} | ||
| tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None) | ||
| mock_geo.assert_called_once_with("http://proxy:8080") | ||
| class TestBareProxyFormat: | ||
@@ -156,6 +190,84 @@ """_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme).""" | ||
| def test_build_proxy_kwargs_bare(self): | ||
| r = _build_proxy_kwargs("user:pass@proxy:8080") | ||
| assert r["proxy"]["username"] == "user" | ||
| assert r["proxy"]["password"] == "pass" | ||
| assert "user" not in r["proxy"]["server"] | ||
| def test_resolve_proxy_config_bare(self): | ||
| kwargs, args = _resolve_proxy_config("user:pass@proxy:8080") | ||
| assert kwargs["proxy"]["username"] == "user" | ||
| assert kwargs["proxy"]["password"] == "pass" | ||
| assert "user" not in kwargs["proxy"]["server"] | ||
| class TestIsSocksProxy: | ||
| def test_socks5_string(self): | ||
| assert _is_socks_proxy("socks5://user:pass@host:1080") is True | ||
| def test_socks5h_string(self): | ||
| assert _is_socks_proxy("socks5h://host:1080") is True | ||
| def test_socks5_uppercase(self): | ||
| assert _is_socks_proxy("SOCKS5://host:1080") is True | ||
| def test_http_string(self): | ||
| assert _is_socks_proxy("http://host:8080") is False | ||
| def test_dict_socks5(self): | ||
| assert _is_socks_proxy({"server": "socks5://host:1080"}) is True | ||
| def test_dict_http(self): | ||
| assert _is_socks_proxy({"server": "http://host:8080"}) is False | ||
| def test_none(self): | ||
| assert _is_socks_proxy(None) is False | ||
| class TestResolveProxyConfig: | ||
| def test_none(self): | ||
| kwargs, args = _resolve_proxy_config(None) | ||
| assert kwargs == {} | ||
| assert args == [] | ||
| def test_http_string_returns_playwright_dict(self): | ||
| kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") | ||
| assert "proxy" in kwargs | ||
| assert kwargs["proxy"]["server"] == "http://proxy:8080" | ||
| assert kwargs["proxy"]["username"] == "user" | ||
| assert args == [] | ||
| def test_http_dict_passthrough(self): | ||
| proxy = {"server": "http://proxy:8080", "bypass": ".example.com"} | ||
| kwargs, args = _resolve_proxy_config(proxy) | ||
| assert kwargs == {"proxy": proxy} | ||
| assert args == [] | ||
| def test_socks5_string_returns_chrome_arg(self): | ||
| kwargs, args = _resolve_proxy_config("socks5://user:pass@host:1080") | ||
| assert kwargs == {} | ||
| assert args == ["--proxy-server=socks5://user:pass@host:1080"] | ||
| def test_socks5_no_auth_returns_chrome_arg(self): | ||
| kwargs, args = _resolve_proxy_config("socks5://host:1080") | ||
| assert kwargs == {} | ||
| assert args == ["--proxy-server=socks5://host:1080"] | ||
| def test_socks5h_returns_chrome_arg(self): | ||
| kwargs, args = _resolve_proxy_config("socks5h://user:pass@host:1080") | ||
| assert kwargs == {} | ||
| assert args == ["--proxy-server=socks5h://user:pass@host:1080"] | ||
| def test_socks5_dict_reconstructs_url(self): | ||
| proxy = {"server": "socks5://host:1080", "username": "user", "password": "p@ss"} | ||
| kwargs, args = _resolve_proxy_config(proxy) | ||
| assert kwargs == {} | ||
| assert len(args) == 1 | ||
| assert args[0].startswith("--proxy-server=socks5://user:p%40ss@host:1080") | ||
| def test_socks5_dict_ipv6_preserves_brackets(self): | ||
| proxy = {"server": "socks5://[::1]:1080", "username": "user", "password": "pass"} | ||
| kwargs, args = _resolve_proxy_config(proxy) | ||
| assert kwargs == {} | ||
| assert "[::1]" in args[0] | ||
| def test_socks5_dict_with_bypass(self): | ||
| proxy = {"server": "socks5://host:1080", "bypass": ".example.com"} | ||
| kwargs, args = _resolve_proxy_config(proxy) | ||
| assert kwargs == {} | ||
| assert "--proxy-server=socks5://host:1080" in args | ||
| assert "--proxy-bypass-list=.example.com" in args |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
6203513
1.62%110
0.92%22683
10.3%