Compare commits

..
Author SHA1 Message Date
CloakHQ d63ddd4d43 feat(cloakserve): add connection tracking, configurable data dir, better status endpoint
- Move `import websockets` to top-level (guaranteed by [serve] extra)
- Add --data-dir flag with smart default (Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve)
- Store launch params (tz/locale/proxy) on ChromeProcess for conflict logging
- Enhance GET / to return per-process detail (pid, port, seed, connections, config)
- Add connection refcounting in WS handlers for status visibility
- Add first-launch-wins note to README
- Add tests for data-dir, Docker detection, and connection tracking
2026-04-05 22:41:33 +02:00
CloakHQandDurafen 03a0ad732f feat: rewrite cloakserve as CDP multiplexer with per-connection fingerprint seeds
Spawns a separate Chrome process per unique fingerprint seed, all behind
a single port (9222). Clients specify seeds and fingerprint params via
query string on the CDP URL:

  connect_over_cdp("http://host:9222?fingerprint=12345&timezone=Asia/Tokyo")

Supports all --fingerprint-* flags as query params, geoip=true for
auto timezone/locale from proxy IP, and proxy= for per-process proxies.

- Rewrite bin/cloakserve from 57-line wrapper to aiohttp CDP multiplexer
- Add ChromePool with per-seed process management and port allocation
- Bidirectional WebSocket proxy for CDP traffic
- URL rewriting for /json/version, /json/list, and WS paths
- Rename _build_args -> build_args, _maybe_resolve_geoip -> maybe_resolve_geoip
- Add aiohttp + websockets to serve optional deps
- Dockerfile installs .[serve] extras
- Add 20 unit tests for cloakserve (param parsing, CLI args, URL rewriting)
2026-04-05 22:30:18 +02:00
49 changed files with 361 additions and 10368 deletions
@@ -1,62 +0,0 @@
name: issue-knowledge-responder
description: First-responder bot that finds related docs and past issues for new GitHub issues
model: openai/gpt-4.1
modelParameters:
maxCompletionTokens: 1500
temperature: 0.3
messages:
- role: system
content: |
You are a first-responder bot for CloakBrowser, an open-source stealth Chromium wrapper.
Your ONLY job: find related resources from the provided context and point the user to them.
You are NOT a maintainer. You do NOT answer questions yourself.
Rules:
- NEVER answer the question yourself. Only point to existing resources.
- NEVER reveal internal patch details, CDP stealth methods, or bypass techniques.
- Keep it short. 5-10 lines max.
- Only include resources that are ACTUALLY relevant, not vaguely similar.
- If the issue is about financial, government, or healthcare targets, output RESPOND: NO
- Maximum 5 related issues. Prefer closed+resolved over open.
- role: user
content: |
## The new issue
{{issue_body}}
## Past issues found by search
{{past_issues}}
## Past issue details (body + comments from top matches)
{{issue_details}}
## README.md (excerpt)
{{readme}}
## js/README.md (excerpt)
{{js_readme}}
## CHANGELOG.md (recent)
{{changelog}}
## Your task
Based on the context above, decide:
**If you found related resources** (doc sections, past issues, or both), output EXACTLY this format:
RESPOND: YES
---
While you wait for the team to respond, I found some resources that might be related:
**From the docs:**
- [description of relevant section] (in `README.md` / `js/README.md`)
**Related issues:**
- #123 — [title] (closed/open) — [one-line summary of resolution or discussion]
Hope this helps! The team will follow up soon.
---
**If you found NOTHING relevant**, output EXACTLY:
RESPOND: NO
@@ -1,148 +0,0 @@
name: Issue Knowledge Responder
on:
issues:
types: [opened]
jobs:
respond:
if: github.event.issue.user.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
models: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Gather context
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
run: |
mkdir -p /tmp/ctx
# 1. Read the full issue
gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title,body,labels --jq '
"Title: " + .title + "\n\nBody:\n" + (.body // "No body") + "\n\nLabels: " + ([.labels[].name] | join(", "))
' > /tmp/ctx/issue_body.txt
# 2. Extract keywords from title + body
TITLE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq '.title')
BODY_TEXT=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq '.body // ""' | head -20)
STOPWORDS='^(the|a|an|is|in|on|at|to|for|of|with|and|or|not|bug|issue|error|problem|help|please|how|why|what|does|can|i|my|me|it|this|that|when|from|have|has|been|are|was|were|be|do|did|just|but|if|so|no|yes|all|any|some|like|get|got|use|using|used|try|tried|also|want|need|would|could|should|about|into|after|before|only|very|too|more|most|much|own|same|other|than|then|there|here|each|every|both|few|many|such|these|those|its|new|old|first|last|long|great|little|right|big|high|different|small|large|next|early|young|important|public|bad|same|able)$'
TITLE_KW=$(echo "$TITLE" | tr -cs 'a-zA-Z0-9' ' ' | tr ' ' '\n' | \
grep -v -i -E "$STOPWORDS" | head -5 | tr '\n' ' ')
BODY_KW=$(echo "$BODY_TEXT" | tr -cs 'a-zA-Z0-9' ' ' | tr ' ' '\n' | \
grep -v -i -E "$STOPWORDS" | sort | uniq -c | sort -rn | awk '{print $2}' | head -5 | tr '\n' ' ')
# 3. Search past issues — 5 strategies, deduplicated
# A: full title
gh issue list --repo "$REPO" --state all --search "$TITLE" --limit 10 \
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
2>/dev/null > /tmp/ctx/search_results.txt || true
# B: title keywords
gh issue list --repo "$REPO" --state all --search "$TITLE_KW" --limit 10 \
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
2>/dev/null >> /tmp/ctx/search_results.txt || true
# C: body keywords (most frequent terms from body)
if [ -n "$BODY_KW" ]; then
gh issue list --repo "$REPO" --state all --search "$BODY_KW" --limit 10 \
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
2>/dev/null >> /tmp/ctx/search_results.txt || true
fi
# D: search by labels if the new issue has any
LABELS=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json labels --jq '[.labels[].name] | join(",")')
if [ -n "$LABELS" ]; then
IFS=',' read -ra LABEL_ARR <<< "$LABELS"
for LBL in "${LABEL_ARR[@]}"; do
gh issue list --repo "$REPO" --state all --label "$LBL" --limit 10 \
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
2>/dev/null >> /tmp/ctx/search_results.txt || true
done
fi
# E: search for error messages / code snippets in body (first backtick block or quoted line)
ERROR_MSG=$(echo "$BODY_TEXT" | grep -oP '`[^`]{5,80}`' | head -1 | tr -d '`')
if [ -n "$ERROR_MSG" ]; then
gh issue list --repo "$REPO" --state all --search "$ERROR_MSG" --limit 5 \
--json number,title,state --jq '.[] | "#\(.number) — \(.title) (\(.state))"' \
2>/dev/null >> /tmp/ctx/search_results.txt || true
fi
# Deduplicate, exclude self, keep top 20
sort -u /tmp/ctx/search_results.txt | \
grep -v "^#${ISSUE_NUMBER} " | head -20 > /tmp/ctx/past_issues.txt
# 4. Read top 7 issue details (body + comments)
echo "" > /tmp/ctx/issue_details.txt
for NUM in $(grep -oP '#\K[0-9]+' /tmp/ctx/past_issues.txt | head -7); do
gh issue view "$NUM" --repo "$REPO" --json title,state,body,comments --jq '
"### #'"$NUM"' — " + .title + " (" + .state + ")\n" +
(.body // "")[:500] + "\n\nComments:\n" +
([.comments[:3][] | .body[:300]] | join("\n---\n"))
' >> /tmp/ctx/issue_details.txt 2>/dev/null || true
echo -e "\n---\n" >> /tmp/ctx/issue_details.txt
done
# 5. Read docs
head -200 README.md > /tmp/ctx/readme.txt 2>/dev/null || echo "No README" > /tmp/ctx/readme.txt
head -150 js/README.md > /tmp/ctx/js_readme.txt 2>/dev/null || echo "No JS README" > /tmp/ctx/js_readme.txt
head -100 CHANGELOG.md > /tmp/ctx/changelog.txt 2>/dev/null || echo "No CHANGELOG" > /tmp/ctx/changelog.txt
# Debug: show what we gathered
echo "=== Issue ===" && head -5 /tmp/ctx/issue_body.txt
echo "=== Past issues ===" && cat /tmp/ctx/past_issues.txt
echo "=== Keywords: $KEYWORDS ==="
- name: AI analysis
id: ai
uses: actions/ai-inference@v1
with:
prompt-file: .github/prompts/issue-responder.prompt.yml
file_input: |
issue_body: /tmp/ctx/issue_body.txt
past_issues: /tmp/ctx/past_issues.txt
issue_details: /tmp/ctx/issue_details.txt
readme: /tmp/ctx/readme.txt
js_readme: /tmp/ctx/js_readme.txt
changelog: /tmp/ctx/changelog.txt
- name: Parse and post
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RESPONSE=$(cat "${{ steps.ai.outputs.response-file }}")
echo "=== AI Response ==="
echo "$RESPONSE"
if echo "$RESPONSE" | grep -q "RESPOND: YES"; then
# Extract content between --- markers
COMMENT=$(echo "$RESPONSE" | sed -n '/^---$/,/^---$/p' | sed '1d;$d')
if [ -z "$COMMENT" ]; then
# Fallback: everything after "RESPOND: YES", strip --- lines
COMMENT=$(echo "$RESPONSE" | sed '1,/RESPOND: YES/d' | sed '/^---$/d')
fi
if [ -n "$COMMENT" ]; then
echo "$COMMENT" > /tmp/comment.txt
gh issue comment "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
--body-file /tmp/comment.txt
echo "✅ Comment posted"
else
echo "⚠️ RESPOND: YES but empty comment body, skipping"
fi
else
echo "️ No relevant resources found, staying silent"
fi
+3 -1
View File
@@ -83,8 +83,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm) node-version: 22
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Upgrade npm
run: npm install -g npm@latest
- name: Build - name: Build
run: cd js && npm ci && npm run build run: cd js && npm ci && npm run build
- name: Publish to npm - name: Publish to npm
-5
View File
@@ -67,8 +67,3 @@ debug
publish-docker.sh publish-docker.sh
captures captures
20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt 20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt
# Beads / Dolt files (added by bd init)
.dolt/
*.db
.beads-credential-key
-41
View File
@@ -6,47 +6,6 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
--- ---
## [0.3.24] — 2026-04-10
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@lilos](https://github.com/lilos), #133)
- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64
- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5)
- **[docs]** Clarify humanize requires wrapper import over CDP (#126)
## [0.3.23] — 2026-04-09
- **[wrapper]** Add full Puppeteer humanize support — human-like mouse, keyboard, and scroll behavior for `puppeteer-core` users (thanks [@evelaa123](https://github.com/evelaa123), #129)
- **[wrapper]** Fix Playwright humanize gaps — `pressSequentially`, `tap`, `clear` on pages and frames now use human-like behavior (#129)
- **[wrapper]** Expose humanize module for CDP-connected browsers — `import from 'cloakbrowser/human'` for manual patching of external Playwright instances (#126)
- **[docker]** Fix `cloakserve` locale/timezone mismatch — CLI args now route through `build_args()` so the companion `--lang` flag is added automatically (#130)
- **[meta]** Use Node 24 in CI publish workflow to work around broken npm in Node 22.22.2
## [0.3.22] — 2026-04-09
- **[binary]** Upgrade Linux x64 build to Chromium 146.0.7680.177.1 — 49 source-level C++ patches (up from 48), rebased from 145.0.7632.x
## [0.3.21] — 2026-04-07
- **[wrapper]** Remove dead `--disable-blink-features=AutomationControlled` flag -- binary patch 009 already handles `navigator.webdriver` at source level
- **[wrapper]** Remove hardcoded GPU vendor/renderer flags -- binary auto-generates diverse, realistic GPU profiles from the fingerprint seed. Each seed gets a unique GPU instead of every user sharing the same one
- **[wrapper]** Allow `viewport=None` to disable viewport emulation in both Python and JS wrappers (thanks [@kitiho](https://github.com/kitiho), #107)
- **[wrapper]** Enable `geoip=True` in stealth test example to fix FingerprintJS detection
- **[meta]** Remove npm self-upgrade step in CI -- Node 22 ships with compatible npm
- **[docker]** Install `geoip2` in Docker image for GeoIP auto-detection support
## [0.3.20] — 2026-04-06
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.9 — 48 source-level C++ patches (up from 42)
- **[binary]** 6 new patches: WebRTC IP spoofing, proxy signal removal, network timing normalization, WebGL accuracy improvements
- **[binary]** New `--fingerprint-webrtc-ip` flag — spoof WebRTC ICE candidate IPs to match your proxy exit IP
- **[binary]** Proxy detection signals eliminated — timing, headers, and network metadata normalized when proxy is active
- **[binary]** WebGL rendering accuracy improvements for headed mode
- **[wrapper]** Auto-inject `--fingerprint-webrtc-ip` when `geoip=True` — uses resolved exit IP from GeoIP lookup
- **[wrapper]** Rewrite `cloakserve` as CDP multiplexer with per-connection fingerprint seeds and connection tracking
- **[wrapper]** Humanize keyboard improvements — better behavioral stealth for typing interactions (thanks [@evelaa123](https://github.com/evelaa123))
- **[meta]** Bump GitHub Actions dependencies
## [0.3.19] — 2026-03-30 ## [0.3.19] — 2026-03-30
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.8 — 42 source-level C++ patches (up from 33) - **[binary]** Upgrade Linux x64 build to 145.0.7632.159.8 — 42 source-level C++ patches (up from 33)
+1 -1
View File
@@ -20,7 +20,7 @@ WORKDIR /app
# Python wrapper # Python wrapper
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./ COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
COPY cloakbrowser/ cloakbrowser/ COPY cloakbrowser/ cloakbrowser/
RUN pip install --no-cache-dir ".[serve,geoip]" RUN pip install --no-cache-dir ".[serve]"
# JS wrapper # JS wrapper
COPY js/ js/ COPY js/ js/
+34 -62
View File
@@ -40,7 +40,7 @@ Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong> Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
</p> </p>
- **49 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals, CDP input behavior - **42 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes - **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
- **0.9 reCAPTCHA v3 score** — human-level, server-verified - **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites - **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
@@ -128,16 +128,13 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
--- ---
## Latest: v0.3.24 (Chromium 146.0.7680.177.2) ## Latest: v0.3.19 (Chromium 145.0.7632.159.8)
- **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. - **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful`
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177 - **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 4 source-level patches covering pointer, keyboard, and mouse behavior
- **49 fingerprint patches** — Linux arm64 now matches Linux x64 on Chromium 146 - **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation
- **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) - **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed - **42 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
- **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds
- **Humanize CDP isolation** — keyboard events now use isolated worlds and trusted dispatch for better behavioral stealth
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required - **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale - **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection - **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
@@ -156,7 +153,7 @@ CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPT
## Test Results ## Test Results
All tests verified against live detection services. Last tested: Apr 2026 (Chromium 146). All tests verified against live detection services. Last tested: Mar 2026 (Chromium 145).
| Detection Service | Stock Playwright | CloakBrowser | Notes | | Detection Service | Stock Playwright | CloakBrowser | Notes |
|---|---|---|---| |---|---|---|---|
@@ -171,7 +168,7 @@ All tests verified against live detection services. Last tested: Apr 2026 (Chrom
| `navigator.webdriver` | `true` | **`false`** | Source-level patch | | `navigator.webdriver` | `true` | **`false`** | Source-level patch |
| `navigator.plugins.length` | 0 | **5** | Real plugin list | | `navigator.plugins.length` | 0 | **5** | Real plugin list |
| `window.chrome` | `undefined` | **`object`** | Present like real Chrome | | `window.chrome` | `undefined` | **`object`** | Present like real Chrome |
| UA string | `HeadlessChrome` | **`Chrome/146.0.0.0`** | No headless leak | | UA string | `HeadlessChrome` | **`Chrome/145.0.0.0`** | No headless leak |
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` | | CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match | | TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
| | | **Tested against 30+ detection sites** | | | | | **Tested against 30+ detection sites** | |
@@ -220,11 +217,11 @@ All tests verified against live detection services. Last tested: Apr 2026 (Chrom
CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary: CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary:
1. **You install**`pip install cloakbrowser` or `npm install cloakbrowser` 1. **You install**`pip install cloakbrowser` or `npm install cloakbrowser`
2. **First launch** → binary auto-downloads for your platform (Chromium 146) 2. **First launch** → binary auto-downloads for your platform (Chromium 145)
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args 3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn 4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 49 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, WebRTC, network timing, hardware reporting, automation signal removal, and CDP input behavior mimicking. The binary includes 42 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking.
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags. These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
@@ -243,9 +240,8 @@ browser = launch()
# Headed mode (see the browser window) # Headed mode (see the browser window)
browser = launch(headless=False) browser = launch(headless=False)
# With proxy (HTTP or SOCKS5) # With proxy
browser = launch(proxy="http://user:pass@proxy:8080") browser = launch(proxy="http://user:pass@proxy:8080")
browser = launch(proxy="socks5://user:pass@proxy:1080")
# With proxy dict (bypass, separate auth fields) # With proxy dict (bypass, separate auth fields)
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"}) browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
@@ -257,19 +253,11 @@ browser = launch(args=["--disable-gpu"])
browser = launch(timezone="America/New_York", locale="en-US") browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip]) # Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
# Also auto-injects --fingerprint-webrtc-ip to prevent WebRTC IP leaks (no extra cost)
# Note: makes HTTP calls through your proxy to resolve exit IP (ipify.org, checkip.amazonaws.com)
browser = launch(proxy="http://proxy:8080", geoip=True) browser = launch(proxy="http://proxy:8080", geoip=True)
# Explicit timezone/locale always win over auto-detection # Explicit timezone/locale always win over auto-detection
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London") browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
# WebRTC IP spoofing only (no geoip dep needed — resolves exit IP via HTTP call through proxy)
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=auto"])
# Explicit WebRTC IP (no network call)
browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=1.2.3.4"])
# Human-like mouse, keyboard, and scroll behavior # Human-like mouse, keyboard, and scroll behavior
browser = launch(humanize=True) browser = launch(humanize=True)
@@ -372,7 +360,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status # Check binary installation status
print(binary_info()) print(binary_info())
# {'version': '146.0.7680.177.2', 'platform': 'linux-x64', 'installed': True, ...} # {'version': '145.0.7632.159.2', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download # Force re-download
clear_cache() clear_cache()
@@ -454,7 +442,7 @@ clearCache();
## Human Behavior ## Human Behavior
Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls (`page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, Locator API) and Puppeteer calls (`page.click()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, ElementHandle API) are automatically replaced with human-like equivalents. No code changes needed. Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls `page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, and the full Locator API are automatically replaced with human-like equivalents. No code changes needed.
```python ```python
browser = launch(humanize=True) browser = launch(humanize=True)
@@ -465,14 +453,6 @@ page.locator("button[type=submit]").click() # Bézier curve, realistic aim
``` ```
```javascript ```javascript
// Playwright
import { launch } from 'cloakbrowser';
const browser = await launch({ humanize: true });
```
```javascript
// Puppeteer
import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch({ humanize: true }); const browser = await launch({ humanize: true });
``` ```
@@ -521,11 +501,9 @@ const browser = await launch({
Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call. Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call.
> **Note (Playwright):** Always use `page.click(selector)`, `page.type(selector, text)`, `page.hover(selector)`, or `page.locator(selector).*` — these go through the full humanize pipeline. Avoid `page.query_selector()` — `ElementHandle` objects bypass all patches, so mouse movement teleports, keyboard events fire without timing, and scroll has no human curve. > **Note:** Always use `page.click(selector)`, `page.type(selector, text)`, `page.hover(selector)`, or `page.locator(selector).*` — these go through the full humanize pipeline. Avoid `page.query_selector()` — `ElementHandle` objects bypass all patches, so mouse movement teleports, keyboard events fire without timing, and scroll has no human curve.
>
> **Note (Puppeteer):** Both selector-based methods (`page.click()`, `page.type()`) and ElementHandle methods (`el.click()`, `el.type()`) are fully humanized. `page.$()`, `page.$$()`, and `page.waitForSelector()` return patched handles automatically.
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright and Puppeteer API coverage. > Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage.
## Configuration ## Configuration
@@ -567,10 +545,14 @@ Every `launch()` call sets these automatically. The **wrapper** applies platform
|------|--------------|---------------|----------| |------|--------------|---------------|----------|
| `--fingerprint` | Random (1000099999) | Random (1000099999) | Master seed for canvas, WebGL, audio, fonts, client rects | | `--fingerprint` | Random (1000099999) | Random (1000099999) | Master seed for canvas, WebGL, audio, fonts, client rects |
| `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection | | `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection |
| `--fingerprint-gpu-vendor` | `NVIDIA Corporation` | `Google Inc. (Apple)` | WebGL `UNMASKED_VENDOR_WEBGL` |
| `--fingerprint-gpu-renderer` | `NVIDIA GeForce RTX 3070` | `ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)` | WebGL `UNMASKED_RENDERER_WEBGL` |
The binary auto-generates everything else from the seed: GPU, hardware concurrency, device memory, and screen dimensions. Each seed produces a unique, consistent fingerprint. Override with explicit flags if needed. The binary auto-generates hardware concurrency (8), device memory (8), and screen dimensions (1920x1080 on Windows/Linux, 1440x900 on macOS) from the seed. Override with explicit flags if needed.
> **Using the binary directly?** It works out of the box with zero flags -- the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value. > **Using the binary directly?** It works out of the box with zero flags the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value.
> **Production tip:** For better stealth at scale, pass your own GPU, screen, and hardware values instead of relying on defaults. Custom parameters make your sessions harder to cluster by anti-bot systems that look for uniform fingerprint profiles.
### Additional Flags ### Additional Flags
@@ -578,8 +560,6 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| Flag | Controls | | Flag | Controls |
|------|----------| |------|----------|
| `--fingerprint-gpu-vendor` | WebGL `UNMASKED_VENDOR_WEBGL` (auto-generated from seed + platform) |
| `--fingerprint-gpu-renderer` | WebGL `UNMASKED_RENDERER_WEBGL` (auto-generated from seed + platform) |
| `--fingerprint-hardware-concurrency` | `navigator.hardwareConcurrency` (auto-generated: `8`) | | `--fingerprint-hardware-concurrency` | `navigator.hardwareConcurrency` (auto-generated: `8`) |
| `--fingerprint-device-memory` | `navigator.deviceMemory` in GB (auto-generated: `8`) | | `--fingerprint-device-memory` | `navigator.deviceMemory` in GB (auto-generated: `8`) |
| `--fingerprint-screen-width` | Screen width (auto-generated: `1920` Win/Linux, `1440` macOS) | | `--fingerprint-screen-width` | Screen width (auto-generated: `1920` Win/Linux, `1440` macOS) |
@@ -593,7 +573,6 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set | | `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) | | `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory | | `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active | | `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements | | `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
@@ -609,9 +588,11 @@ browser = launch(args=["--fingerprint=42069"])
browser = launch(stealth_args=False, args=[ browser = launch(stealth_args=False, args=[
"--fingerprint=42069", "--fingerprint=42069",
"--fingerprint-platform=windows", "--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
]) ])
# Override GPU to look like a specific machine # Override GPU to look like a different machine
browser = launch(args=[ browser = launch(args=[
"--fingerprint-gpu-vendor=Intel Inc.", "--fingerprint-gpu-vendor=Intel Inc.",
"--fingerprint-gpu-renderer=Intel Iris OpenGL Engine", "--fingerprint-gpu-renderer=Intel Iris OpenGL Engine",
@@ -647,16 +628,8 @@ stealth_args = get_default_stealth_args() # all fingerprint flags
from cloakbrowser import launch_async from cloakbrowser import launch_async
browser = await launch_async(args=["--remote-debugging-port=9242"]) browser = await launch_async(args=["--remote-debugging-port=9242"])
# Connect your framework to http://127.0.0.1:9242 — all stealth flags are set # 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 | | Framework | Stars | Language | Example |
|-----------|-------|----------|---------| |-----------|-------|----------|---------|
| [browser-use](https://github.com/browser-use/browser-use) | 70K | Python | [`browser_use_example.py`](examples/integrations/browser_use_example.py) | | [browser-use](https://github.com/browser-use/browser-use) | 70K | Python | [`browser_use_example.py`](examples/integrations/browser_use_example.py) |
@@ -673,11 +646,11 @@ browser = await launch_async(args=["--remote-debugging-port=9242"])
| Platform | Chromium | Patches | Status | | Platform | Chromium | Patches | Status |
|---|---|---|---| |---|---|---|---|
| Linux x86_64 | 146 | 49 | ✅ Latest | | Linux x86_64 | 145 | 42 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 146 | 49 | ✅ Latest | | Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ | | macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
| macOS x86_64 (Intel) | 145 | 26 | ✅ | | macOS x86_64 (Intel) | 145 | 26 | ✅ |
| Windows x86_64 | 145 | 48 | ✅ | | Windows x86_64 | 145 | 33 | ✅ |
The wrapper auto-downloads the correct binary for your platform. The wrapper auto-downloads the correct binary for your platform.
@@ -938,9 +911,9 @@ export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
Install a specific wrapper version to downgrade both the wrapper and the binary it downloads: Install a specific wrapper version to downgrade both the wrapper and the binary it downloads:
```bash ```bash
pip install cloakbrowser==0.3.21 # Python pip install cloakbrowser==0.3.11 # Python
npm install cloakbrowser@0.3.21 # JavaScript npm install cloakbrowser@0.3.11 # JavaScript
docker pull cloakhq/cloakbrowser:0.3.21 # Docker docker pull cloakhq/cloakbrowser:0.3.11 # Docker
``` ```
Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch. Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch.
@@ -1030,13 +1003,13 @@ A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright
A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves. A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves.
**Q: Can I use my own proxy?** **Q: Can I use my own proxy?**
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. A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
## Roadmap ## Roadmap
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| Linux x64 — Chromium 146 (49 patches) | ✅ Released | | Linux x64 — Chromium 145 (42 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released | | macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 145 (33 patches) | ✅ Released | | Windows x64 — Chromium 145 (33 patches) | ✅ Released |
| JavaScript/Puppeteer + Playwright support | ✅ Released | | JavaScript/Puppeteer + Playwright support | ✅ Released |
@@ -1060,7 +1033,7 @@ All releases are signed for supply chain verification.
```bash ```bash
# Verify GPG signature (binary release tag) # Verify GPG signature (binary release tag)
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
git verify-tag chromium-v146.0.7680.177.2 git verify-tag chromium-v145.0.7632.159.8
# Verify GitHub binary attestation (Sigstore) # Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
@@ -1085,4 +1058,3 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix - [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts - [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
- [@kitiho](https://github.com/kitiho) — null viewport fix
+3 -43
View File
@@ -36,7 +36,7 @@ import aiohttp
import websockets import websockets
from aiohttp import web from aiohttp import web
from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args from cloakbrowser.browser import build_args, maybe_resolve_geoip
from cloakbrowser.download import ensure_binary from cloakbrowser.download import ensure_binary
logging.basicConfig( logging.basicConfig(
@@ -88,17 +88,11 @@ class ChromePool:
global_args: list[str], global_args: list[str],
headless: bool, headless: bool,
data_dir: str = "/tmp/cloakserve", data_dir: str = "/tmp/cloakserve",
default_seed: str | None = None,
default_locale: str | None = None,
default_timezone: str | None = None,
): ):
self._binary = binary self._binary = binary
self._global_args = global_args self._global_args = global_args
self._headless = headless self._headless = headless
self._data_dir = data_dir self._data_dir = data_dir
self._default_seed = default_seed
self._default_locale = default_locale
self._default_timezone = default_timezone
self._processes: dict[str, ChromeProcess] = {} self._processes: dict[str, ChromeProcess] = {}
self._default: ChromeProcess | None = None self._default: ChromeProcess | None = None
self._locks: dict[str, asyncio.Lock] = {} self._locks: dict[str, asyncio.Lock] = {}
@@ -146,14 +140,6 @@ class ChromePool:
geoip: bool = False, geoip: bool = False,
) -> ChromeProcess: ) -> ChromeProcess:
"""Get existing or launch new Chrome process for a seed.""" """Get existing or launch new Chrome process for a seed."""
# Apply CLI defaults when query params don't provide values
if seed is None and self._default_seed:
seed = self._default_seed
if locale is None:
locale = self._default_locale
if timezone is None:
timezone = self._default_timezone
# No seed = default shared process # No seed = default shared process
if seed is None: if seed is None:
seed_key = "__default__" seed_key = "__default__"
@@ -180,9 +166,8 @@ class ChromePool:
await self._cleanup_process(seed_key) await self._cleanup_process(seed_key)
# Resolve geoip if requested # Resolve geoip if requested
exit_ip = None
if geoip and proxy: if geoip and proxy:
timezone, locale, exit_ip = maybe_resolve_geoip(True, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(True, proxy, timezone, locale)
# Build Chrome args via shared logic # Build Chrome args via shared logic
fp_extra = [f"--fingerprint={actual_seed}"] fp_extra = [f"--fingerprint={actual_seed}"]
@@ -191,12 +176,6 @@ class ChromePool:
if proxy: if proxy:
fp_extra.append(f"--proxy-server={proxy}") fp_extra.append(f"--proxy-server={proxy}")
# WebRTC IP spoofing: resolve auto, inject geoip exit IP
fp_extra = _resolve_webrtc_args(fp_extra, proxy)
if exit_ip and not any(a.startswith("--fingerprint-webrtc-ip") for a in (fp_extra or [])):
fp_extra = list(fp_extra or [])
fp_extra.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args( chrome_args = build_args(
stealth_args=True, stealth_args=True,
extra_args=fp_extra, extra_args=fp_extra,
@@ -566,20 +545,11 @@ def _default_data_dir() -> str:
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]: def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
"""Parse cloakserve-specific args, return (config, passthrough_args). """Parse cloakserve-specific args, return (config, passthrough_args)."""
--fingerprint, --fingerprint-locale, and --fingerprint-timezone are
extracted into config defaults so they route through build_args()
(e.g. locale needs both --lang and --fingerprint-locale).
Query-string params override these defaults per-connection.
"""
config: dict = { config: dict = {
"port": 9222, "port": 9222,
"headless": True, "headless": True,
"data_dir": None, "data_dir": None,
"default_seed": None,
"default_locale": None,
"default_timezone": None,
} }
passthrough = [] passthrough = []
# Flags consumed by cloakserve (not passed to Chrome) # Flags consumed by cloakserve (not passed to Chrome)
@@ -600,13 +570,6 @@ def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
passthrough.append(arg) passthrough.append(arg)
elif arg.startswith(consumed_prefixes): elif arg.startswith(consumed_prefixes):
pass # Strip these silently pass # Strip these silently
# Route through build_args() so companion flags are set correctly
elif arg.startswith("--fingerprint-locale="):
config["default_locale"] = arg.split("=", 1)[1]
elif arg.startswith("--fingerprint-timezone="):
config["default_timezone"] = arg.split("=", 1)[1]
elif arg.startswith("--fingerprint="):
config["default_seed"] = arg.split("=", 1)[1]
else: else:
passthrough.append(arg) passthrough.append(arg)
@@ -629,9 +592,6 @@ def main() -> None:
global_args=global_args, global_args=global_args,
headless=config["headless"], headless=config["headless"],
data_dir=config["data_dir"], data_dir=config["data_dir"],
default_seed=config["default_seed"],
default_locale=config["default_locale"],
default_timezone=config["default_timezone"],
) )
app = web.Application() app = web.Application()
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.24" __version__ = "0.3.19"
+35 -200
View File
@@ -17,16 +17,13 @@ from __future__ import annotations
import logging import logging
import os import os
from typing import Any, Literal, TypedDict from typing import Any, Literal, TypedDict
from urllib.parse import quote, unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
from .download import ensure_binary from .download import ensure_binary
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)
_VIEWPORT_UNSET = object()
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None: def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Accept both timezone and timezone_id — either works, no warning.""" """Accept both timezone and timezone_id — either works, no warning."""
@@ -104,13 +101,8 @@ def launch(
sync_playwright = _import_sync_playwright(_resolve_backend(backend)) sync_playwright = _import_sync_playwright(_resolve_backend(backend))
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -120,7 +112,7 @@ def launch(
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS, ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs, **_build_proxy_kwargs(proxy),
**kwargs, **kwargs,
) )
@@ -194,13 +186,8 @@ async def launch_async( # noqa: C901
async_playwright = _import_async_playwright(_resolve_backend(backend)) async_playwright = _import_async_playwright(_resolve_backend(backend))
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -210,7 +197,7 @@ async def launch_async( # noqa: C901
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS, ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs, **_build_proxy_kwargs(proxy),
**kwargs, **kwargs,
) )
@@ -242,7 +229,7 @@ def launch_persistent_context(
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
user_agent: str | None = None, user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET, viewport: dict | None = None,
locale: str | None = None, locale: str | None = None,
timezone: str | None = None, timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None, color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -269,7 +256,6 @@ def launch_persistent_context(
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string. user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US". locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York'). timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
@@ -298,13 +284,8 @@ def launch_persistent_context(
timezone = _resolve_timezone(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug( logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)", "Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
@@ -317,12 +298,7 @@ def launch_persistent_context(
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
if viewport is _VIEWPORT_UNSET: context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -334,7 +310,7 @@ def launch_persistent_context(
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS, ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs, **_build_proxy_kwargs(proxy),
**context_kwargs, **context_kwargs,
) )
@@ -366,7 +342,7 @@ async def launch_persistent_context_async(
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
user_agent: str | None = None, user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET, viewport: dict | None = None,
locale: str | None = None, locale: str | None = None,
timezone: str | None = None, timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None, color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -392,7 +368,6 @@ async def launch_persistent_context_async(
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string. user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US". locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York'). timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
@@ -424,13 +399,8 @@ async def launch_persistent_context_async(
timezone = _resolve_timezone(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
args = _resolve_webrtc_args(args, proxy)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless)
logger.debug( logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)", "Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
@@ -443,12 +413,7 @@ async def launch_persistent_context_async(
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
if viewport is _VIEWPORT_UNSET: context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -460,7 +425,7 @@ async def launch_persistent_context_async(
headless=headless, headless=headless,
args=chrome_args, args=chrome_args,
ignore_default_args=IGNORE_DEFAULT_ARGS, ignore_default_args=IGNORE_DEFAULT_ARGS,
**proxy_kwargs, **_build_proxy_kwargs(proxy),
**context_kwargs, **context_kwargs,
) )
@@ -491,7 +456,7 @@ def launch_context(
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
user_agent: str | None = None, user_agent: str | None = None,
viewport: dict | None = _VIEWPORT_UNSET, viewport: dict | None = None,
locale: str | None = None, locale: str | None = None,
timezone: str | None = None, timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None, color_scheme: Literal["light", "dark", "no-preference"] | None = None,
@@ -514,7 +479,6 @@ def launch_context(
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string. user_agent: Custom user agent string.
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
Pass None to disable viewport emulation (use OS window size).
locale: Browser locale, e.g. "en-US". locale: Browser locale, e.g. "en-US".
timezone: IANA timezone (e.g. 'America/New_York'). timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
@@ -533,11 +497,7 @@ def launch_context(
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure # Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to binary flags # resolved values flow to binary flags
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
# Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
args = list(args or [])
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
# --fingerprint-timezone is process-wide (reads CommandLine in renderer), # --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# so it applies to ALL contexts, not just the default one. # so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation. # locale and timezone are set via binary flags only — no CDP emulation.
@@ -547,12 +507,7 @@ def launch_context(
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
if viewport is _VIEWPORT_UNSET: context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
context_kwargs["viewport"] = DEFAULT_VIEWPORT
elif viewport is None:
context_kwargs["no_viewport"] = True
else:
context_kwargs["viewport"] = viewport
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -635,117 +590,28 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
return proxy_url if "://" in proxy_url else f"http://{proxy_url}" return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _reconstruct_socks_url(proxy: ProxySettings) -> str:
"""Reconstruct a SOCKS5 URL with inline credentials from a Playwright proxy dict."""
server = proxy.get("server", "")
username = proxy.get("username", "")
password = proxy.get("password", "")
if not username:
return server
parsed = urlparse(server)
creds = quote(username, safe="")
if password:
creds += f":{quote(password, safe='')}"
host = parsed.hostname or ""
if ":" in host: # IPv6 literal — re-add brackets
host = f"[{host}]"
netloc = f"{creds}@{host}"
if parsed.port:
netloc += f":{parsed.port}"
return urlunparse((parsed.scheme, netloc, parsed.path, "", "", ""))
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
"""Extract and normalize proxy URL string from proxy param.
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
if isinstance(proxy, dict):
server = proxy.get("server", "")
if not server:
return None
if _is_socks_proxy(proxy):
return _reconstruct_socks_url(proxy)
return _ensure_proxy_scheme(server)
return _ensure_proxy_scheme(proxy)
def maybe_resolve_geoip( def maybe_resolve_geoip(
geoip: bool, geoip: bool,
proxy: str | ProxySettings | None, proxy: str | ProxySettings | None,
timezone: str | None, timezone: str | None,
locale: str | None, locale: str | None,
) -> tuple[str | None, str | None, str | None]: ) -> tuple[str | None, str | None]:
"""Auto-fill timezone/locale from proxy IP when geoip is enabled. """Auto-fill timezone/locale from proxy IP when geoip is enabled."""
if not geoip or not proxy or (timezone is not None and locale is not None):
return timezone, locale
Returns ``(timezone, locale, exit_ip)``. *exit_ip* is a free bonus from .geoip import resolve_proxy_geo
from the geoip lookup (no extra HTTP call) — used for WebRTC spoofing.
"""
if not geoip or not proxy:
return timezone, locale, None
from .geoip import resolve_proxy_geo_with_ip proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
proxy_url = _extract_proxy_url(proxy)
if not proxy_url: if not proxy_url:
return timezone, locale, None return timezone, locale
proxy_url = _ensure_proxy_scheme(proxy_url)
# When both tz/locale are explicit, still resolve exit IP for WebRTC geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
if timezone is not None and locale is not None:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
return timezone, locale, exit_ip
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
if timezone is None: if timezone is None:
timezone = geo_tz timezone = geo_tz
if locale is None: if locale is None:
locale = geo_locale locale = geo_locale
return timezone, locale, exit_ip return timezone, locale
def _resolve_webrtc_args(
args: list[str] | None,
proxy: str | ProxySettings | None,
) -> list[str] | None:
"""Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
Returns args unchanged if no ``auto`` value is present.
"""
if not args:
return args
idx = None
for i, a in enumerate(args):
if a == "--fingerprint-webrtc-ip=auto":
idx = i
break
if idx is None:
return args
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
logger.warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag")
args = list(args)
del args[idx]
return args
try:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
except Exception:
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args)
del args[idx]
return args
if exit_ip:
args = list(args)
args[idx] = f"--fingerprint-webrtc-ip={exit_ip}"
else:
logger.warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args)
del args[idx]
return args
def build_args( def build_args(
@@ -832,41 +698,10 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
return result return result
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool: def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
"""Check if the proxy uses SOCKS5 protocol.""" """Build proxy kwargs for Playwright launch."""
if proxy is None: if proxy is None:
return False return {}
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): if isinstance(proxy, dict):
url = _reconstruct_socks_url(proxy) return {"proxy": proxy}
extra_args = [f"--proxy-server={url}"] return {"proxy": _parse_proxy_url(proxy)}
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)}, []
+15 -6
View File
@@ -15,11 +15,11 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference). # CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version. # Use get_chromium_version() for the current platform's actual version.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CHROMIUM_VERSION = "146.0.7680.177.1" CHROMIUM_VERSION = "145.0.7632.159.8"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "146.0.7680.177.2", "linux-x64": "145.0.7632.159.8",
"linux-arm64": "146.0.7680.177.2", "linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.159.7", "windows-x64": "145.0.7632.159.7",
@@ -48,17 +48,26 @@ def get_default_stealth_args() -> list[str]:
base = [ base = [
"--no-sandbox", "--no-sandbox",
"--disable-blink-features=AutomationControlled",
f"--fingerprint={seed}", f"--fingerprint={seed}",
] ]
if system == "Darwin": if system == "Darwin":
# Tell the fingerprint patches we're on macOS so GPU/UA match natively # Tell the fingerprint patches we're on macOS so GPU/UA match natively
return base + ["--fingerprint-platform=macos"] return base + [
"--fingerprint-platform=macos",
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
]
# Linux/Windows: Windows fingerprint profile # Linux/Windows: Windows fingerprint profile
# Hardware concurrency, device memory, screen, window size, and GPU are # Hardware concurrency, device memory, screen, and window size are
# auto-generated by the binary from the seed (v14+). # auto-generated by the binary from the seed (v14+).
return base + ["--fingerprint-platform=windows"] return base + [
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+7 -24
View File
@@ -53,18 +53,6 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
Returns ``(timezone, locale)`` — either or both may be ``None`` on Returns ``(timezone, locale)`` — either or both may be ``None`` on
failure (missing dep, DB download error, lookup miss). Never raises. failure (missing dep, DB download error, lookup miss). Never raises.
""" """
tz, locale, _ip = resolve_proxy_geo_with_ip(proxy_url)
return tz, locale
def resolve_proxy_geo_with_ip(
proxy_url: str,
) -> tuple[str | None, str | None, str | None]:
"""Resolve timezone, locale, and exit IP from a proxy.
Returns ``(timezone, locale, exit_ip)``. The exit IP is a free bonus
from the lookup — reused for WebRTC spoofing without an extra HTTP call.
"""
try: try:
import geoip2.database # noqa: F811 import geoip2.database # noqa: F811
except ImportError: except ImportError:
@@ -75,14 +63,14 @@ def resolve_proxy_geo_with_ip(
db_path = _ensure_geoip_db() db_path = _ensure_geoip_db()
if db_path is None: if db_path is None:
return None, None, None return None, None
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit # Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
ip = _resolve_exit_ip(proxy_url) ip = _resolve_exit_ip(proxy_url)
if ip is None: if ip is None:
ip = _resolve_proxy_ip(proxy_url) ip = _resolve_proxy_ip(proxy_url)
if ip is None: if ip is None:
return None, None, None return None, None
try: try:
with geoip2.database.Reader(str(db_path)) as reader: with geoip2.database.Reader(str(db_path)) as reader:
@@ -94,10 +82,10 @@ def resolve_proxy_geo_with_ip(
"GeoIP: %s → tz=%s, country=%s, locale=%s", "GeoIP: %s → tz=%s, country=%s, locale=%s",
ip, timezone, country, locale, ip, timezone, country, locale,
) )
return timezone, locale, ip return timezone, locale
except Exception as exc: except Exception as exc:
logger.warning("GeoIP lookup failed for %s: %s", ip, exc) logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
return None, None, ip return None, None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -132,7 +120,7 @@ def _resolve_proxy_ip(proxy_url: str) -> str | None:
return ip return ip
return None return None
except Exception as exc: except Exception as exc:
logger.warning("Failed to resolve proxy hostname: %s", exc) logger.debug("Failed to resolve proxy hostname: %s", exc)
return None return None
@@ -165,14 +153,9 @@ def _resolve_exit_ip(proxy_url: str) -> str | None:
ipaddress.ip_address(ip) ipaddress.ip_address(ip)
logger.debug("Exit IP via %s: %s", url, ip) logger.debug("Exit IP via %s: %s", url, ip)
return ip return ip
except httpx.UnsupportedProtocol:
logger.warning(
"SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]"
)
return None
except Exception: except Exception:
continue continue
logger.warning("Failed to discover exit IP through proxy") logger.debug("Failed to discover exit IP through proxy")
return None return None
File diff suppressed because it is too large Load Diff
+5 -82
View File
@@ -1,14 +1,9 @@
"""cloakbrowser-human — Human-like keyboard input. """cloakbrowser-human — Human-like keyboard input."""
Stealth-aware: when a CDP session is provided, shift symbols are typed
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
Falls back to page.evaluate when no CDP session is available.
"""
from __future__ import annotations from __future__ import annotations
import random import random
from typing import Any, Optional, Protocol from typing import Any, Protocol
from .config import HumanConfig, rand, rand_range, sleep_ms from .config import HumanConfig, rand, rand_range, sleep_ms
@@ -33,25 +28,6 @@ NEARBY_KEYS = {
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p', '6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
} }
# CDP key code for each shift symbol's physical key.
_SHIFT_SYMBOL_CODES: dict[str, str] = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
}
# Windows virtual key codes for Input.dispatchKeyEvent.
_SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
}
def _get_nearby_key(ch: str) -> str: def _get_nearby_key(ch: str) -> str:
"""Return a random adjacent key for the given character.""" """Return a random adjacent key for the given character."""
@@ -63,17 +39,7 @@ def _get_nearby_key(ch: str) -> str:
return ch return ch
def human_type( def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type text with human-like per-character timing.
Args:
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
producing isTrusted=true events with no evaluate stack trace.
If None, falls back to page.evaluate (detectable).
"""
for i, ch in enumerate(text): for i, ch in enumerate(text):
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText # Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii(): if not ch.isascii():
@@ -96,7 +62,7 @@ def human_type(
if ch.isupper() and ch.isalpha(): if ch.isupper() and ch.isalpha():
_type_shifted_char(page, raw, ch, cfg) _type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS: elif ch in SHIFT_SYMBOLS:
_type_shift_symbol(page, raw, ch, cfg, cdp_session) _type_shift_symbol(page, raw, ch, cfg)
else: else:
_type_normal_char(raw, ch, cfg) _type_normal_char(raw, ch, cfg)
@@ -120,50 +86,7 @@ def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -
raw.up("Shift") raw.up("Shift")
def _type_shift_symbol( def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type a shift symbol character.
Stealth path (cdp_session provided):
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
Fallback path (no cdp_session):
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
Detectable via isTrusted=false and evaluate stack frame.
"""
if cdp_session is not None:
# --- Stealth path: CDP Input.dispatchKeyEvent ---
code = _SHIFT_SYMBOL_CODES.get(ch, '')
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay))
cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyDown",
"modifiers": 8, # Shift modifier flag
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
"text": ch,
"unmodifiedText": ch,
})
sleep_ms(rand_range(cfg.key_hold))
cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyUp",
"modifiers": 8,
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
})
sleep_ms(rand_range(cfg.shift_up_delay))
raw.up("Shift")
else:
# --- Fallback path: page.evaluate (detectable) ---
raw.down("Shift") raw.down("Shift")
sleep_ms(rand_range(cfg.shift_down_delay)) sleep_ms(rand_range(cfg.shift_down_delay))
raw.insert_text(ch) raw.insert_text(ch)
+4 -61
View File
@@ -2,19 +2,15 @@
Mirrors keyboard.py but uses ``await`` for all Playwright calls and Mirrors keyboard.py but uses ``await`` for all Playwright calls and
``async_sleep_ms`` instead of ``sleep_ms``. ``async_sleep_ms`` instead of ``sleep_ms``.
Stealth-aware: when a CDP session is provided, shift symbols are typed
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
""" """
from __future__ import annotations from __future__ import annotations
import random import random
from typing import Any, Optional, Protocol from typing import Any, Protocol
from .config import HumanConfig, rand, rand_range, async_sleep_ms from .config import HumanConfig, rand, rand_range, async_sleep_ms
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
from .keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES
class AsyncRawKeyboard(Protocol): class AsyncRawKeyboard(Protocol):
@@ -24,17 +20,7 @@ class AsyncRawKeyboard(Protocol):
async def insert_text(self, text: str) -> None: ... async def insert_text(self, text: str) -> None: ...
async def async_human_type( async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type text with human-like per-character timing (async).
Args:
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
producing isTrusted=true events with no evaluate stack trace.
If None, falls back to page.evaluate (detectable).
"""
for i, ch in enumerate(text): for i, ch in enumerate(text):
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText # Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if not ch.isascii(): if not ch.isascii():
@@ -57,7 +43,7 @@ async def async_human_type(
if ch.isupper() and ch.isalpha(): if ch.isupper() and ch.isalpha():
await _type_shifted_char(page, raw, ch, cfg) await _type_shifted_char(page, raw, ch, cfg)
elif ch in SHIFT_SYMBOLS: elif ch in SHIFT_SYMBOLS:
await _type_shift_symbol(page, raw, ch, cfg, cdp_session) await _type_shift_symbol(page, raw, ch, cfg)
else: else:
await _type_normal_char(raw, ch, cfg) await _type_normal_char(raw, ch, cfg)
@@ -81,50 +67,7 @@ async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: Hum
await raw.up("Shift") await raw.up("Shift")
async def _type_shift_symbol( async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig,
cdp_session: Any = None,
) -> None:
"""Type a shift symbol character (async).
Stealth path (cdp_session provided):
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
Fallback path (no cdp_session):
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
Detectable via isTrusted=false and evaluate stack frame.
"""
if cdp_session is not None:
# --- Stealth path: CDP Input.dispatchKeyEvent ---
code = _SHIFT_SYMBOL_CODES.get(ch, '')
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay))
await cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyDown",
"modifiers": 8, # Shift modifier flag
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
"text": ch,
"unmodifiedText": ch,
})
await async_sleep_ms(rand_range(cfg.key_hold))
await cdp_session.send("Input.dispatchKeyEvent", {
"type": "keyUp",
"modifiers": 8,
"key": ch,
"code": code,
"windowsVirtualKeyCode": key_code,
})
await async_sleep_ms(rand_range(cfg.shift_up_delay))
await raw.up("Shift")
else:
# --- Fallback path: page.evaluate (detectable) ---
await raw.down("Shift") await raw.down("Shift")
await async_sleep_ms(rand_range(cfg.shift_down_delay)) await async_sleep_ms(rand_range(cfg.shift_down_delay))
await raw.insert_text(ch) await raw.insert_text(ch)
+1 -1
View File
@@ -228,7 +228,7 @@ def main():
print() print()
print("Launching stealth browser...", flush=True) print("Launching stealth browser...", flush=True)
browser = launch(headless=not HEADED, proxy=PROXY, geoip=True) browser = launch(headless=not HEADED, proxy=PROXY)
page = browser.new_page() page = browser.new_page()
# Show browser fingerprint details # Show browser fingerprint details
+6 -9
View File
@@ -11,7 +11,7 @@
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.** Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals - **42 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **0.9 reCAPTCHA v3 score** — human-level, server-verified - **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites - **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config - **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
@@ -63,13 +63,10 @@ await browser.close();
```javascript ```javascript
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser'; import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
// With proxy (HTTP or SOCKS5) // With proxy
const browser = await launch({ const browser = await launch({
proxy: 'http://user:pass@proxy:8080', proxy: 'http://user:pass@proxy:8080',
}); });
const browser = await launch({
proxy: 'socks5://user:pass@proxy:1080',
});
// With proxy object (bypass, separate auth fields) // With proxy object (bypass, separate auth fields)
const browser = await launch({ const browser = await launch({
@@ -206,15 +203,15 @@ const page = await browser.newPage();
| Platform | Chromium | Patches | Status | | Platform | Chromium | Patches | Status |
|---|---|---|---| |---|---|---|---|
| Linux x86_64 | 145 | 48 | ✅ Latest | | Linux x86_64 | 145 | 33 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ Latest | | Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest | | macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest | | macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 48 | ✅ Latest | | Windows x86_64 | 145 | 33 | ✅ Latest |
## Requirements ## Requirements
- Node.js >= 20 - Node.js >= 18
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21 - One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
## Troubleshooting ## Troubleshooting
+9 -57
View File
@@ -1,36 +1,31 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.23", "version": "0.3.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.23", "version": "0.3.9",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tar": "^7.0.0" "tar": "^7.0.0"
}, },
"bin": {
"cloakbrowser": "dist/cli.js"
},
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2", "mmdb-lib": "^3.0.2",
"playwright-core": "^1.40.0", "playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0", "puppeteer-core": "^21.0.0",
"socks-proxy-agent": "^10.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vitest": "^1.0.0" "vitest": "^1.0.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=18.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"mmdb-lib": ">=2.0.0", "mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0", "playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0", "puppeteer-core": ">=21.0.0"
"socks-proxy-agent": ">=8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"mmdb-lib": { "mmdb-lib": {
@@ -41,9 +36,6 @@
}, },
"puppeteer-core": { "puppeteer-core": {
"optional": true "optional": true
},
"socks-proxy-agent": {
"optional": true
} }
} }
}, },
@@ -2011,21 +2003,6 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/pac-proxy-agent/node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/pac-resolver": { "node_modules/pac-resolver": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
@@ -2187,21 +2164,6 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/proxy-agent/node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2370,28 +2332,18 @@
} }
}, },
"node_modules/socks-proxy-agent": { "node_modules/socks-proxy-agent": {
"version": "10.0.0", "version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "9.0.0", "agent-base": "^7.1.2",
"debug": "^4.3.4", "debug": "^4.3.4",
"socks": "^2.8.3" "socks": "^2.8.3"
}, },
"engines": { "engines": {
"node": ">= 20" "node": ">= 14"
}
},
"node_modules/socks-proxy-agent/node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
} }
}, },
"node_modules/source-map": { "node_modules/source-map": {
+3 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.24", "version": "0.3.19",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.", "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -13,10 +13,6 @@
"./puppeteer": { "./puppeteer": {
"types": "./dist/puppeteer.d.ts", "types": "./dist/puppeteer.d.ts",
"import": "./dist/puppeteer.js" "import": "./dist/puppeteer.js"
},
"./human": {
"types": "./dist/human/index.d.ts",
"import": "./dist/human/index.js"
} }
}, },
"bin": { "bin": {
@@ -55,13 +51,12 @@
}, },
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs", "homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=18.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"mmdb-lib": ">=2.0.0", "mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0", "playwright-core": ">=1.40.0",
"puppeteer-core": ">=21.0.0", "puppeteer-core": ">=21.0.0"
"socks-proxy-agent": ">=10.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"playwright-core": { "playwright-core": {
@@ -72,9 +67,6 @@
}, },
"mmdb-lib": { "mmdb-lib": {
"optional": true "optional": true
},
"socks-proxy-agent": {
"optional": true
} }
}, },
"dependencies": { "dependencies": {
@@ -83,7 +75,6 @@
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2", "mmdb-lib": "^3.0.2",
"socks-proxy-agent": "^10.0.0",
"playwright-core": "^1.40.0", "playwright-core": "^1.40.0",
"puppeteer-core": "^21.0.0", "puppeteer-core": "^21.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
+17 -6
View File
@@ -27,11 +27,11 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference). // CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version. // Use getChromiumVersion() for the current platform's actual version.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "146.0.7680.177.1"; export const CHROMIUM_VERSION = "145.0.7632.159.8";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = { export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "146.0.7680.177.2", "linux-x64": "145.0.7632.159.8",
"linux-arm64": "146.0.7680.177.2", "linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.159.7", "windows-x64": "145.0.7632.159.7",
@@ -211,16 +211,27 @@ export function getDefaultStealthArgs(): string[] {
const base = [ const base = [
"--no-sandbox", "--no-sandbox",
"--disable-blink-features=AutomationControlled",
`--fingerprint=${seed}`, `--fingerprint=${seed}`,
]; ];
if (isMac) { if (isMac) {
// macOS: run as native Mac browser — GPU/UA match natively // macOS: run as native Mac browser — GPU/UA match natively
return [...base, "--fingerprint-platform=macos"]; return [
...base,
"--fingerprint-platform=macos",
"--fingerprint-gpu-vendor=Google Inc. (Apple)",
"--fingerprint-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)",
];
} }
// Linux/Windows: spoof as Windows desktop // Linux/Windows: spoof as Windows desktop
// Hardware concurrency, device memory, screen, window size, and GPU are // Hardware concurrency, device memory, screen, and window size are
// auto-generated by the binary from the seed (v14+). // auto-generated by the binary from the seed (v14+).
return [...base, "--fingerprint-platform=windows"]; return [
...base,
"--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
];
} }
+14 -111
View File
@@ -15,7 +15,7 @@ import dns from "node:dns/promises";
import net from "node:net"; import net from "node:net";
import { getCacheDir } from "./config.js"; import { getCacheDir } from "./config.js";
import type { LaunchOptions } from "./types.js"; import type { LaunchOptions } from "./types.js";
import { ensureProxyScheme, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js"; import { ensureProxyScheme } from "./proxy.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed // P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL = const GEOIP_DB_URL =
@@ -44,7 +44,6 @@ export const COUNTRY_LOCALE_MAP: Record<string, string> = {
export interface GeoResult { export interface GeoResult {
timezone: string | null; timezone: string | null;
locale: string | null; locale: string | null;
exitIp: string | null;
} }
/** /**
@@ -66,12 +65,12 @@ export async function resolveProxyGeo(
} }
const dbPath = await ensureGeoipDb(); const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: null, exitIp: null }; if (!dbPath) return { timezone: null, locale: null };
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit // Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
let ip = await resolveExitIp(proxyUrl); let ip = await resolveExitIp(proxyUrl);
if (!ip) ip = await resolveProxyIp(proxyUrl); if (!ip) ip = await resolveProxyIp(proxyUrl);
if (!ip) return { timezone: null, locale: null, exitIp: null }; if (!ip) return { timezone: null, locale: null };
try { try {
const buf = fs.readFileSync(dbPath); const buf = fs.readFileSync(dbPath);
@@ -81,9 +80,9 @@ export async function resolveProxyGeo(
const countryCode: string | null = result?.country?.iso_code ?? null; const countryCode: string | null = result?.country?.iso_code ?? null;
const locale = const locale =
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null; countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
return { timezone, locale, exitIp: ip }; return { timezone, locale };
} catch { } catch {
return { timezone: null, locale: null, exitIp: ip }; return { timezone: null, locale: null };
} }
} }
@@ -129,44 +128,9 @@ const IP_ECHO_URLS = [
]; ];
async function resolveExitIp(proxyUrl: string): Promise<string | null> { async function resolveExitIp(proxyUrl: string): Promise<string | null> {
const isSocks = isSocksProxy(proxyUrl); // 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
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent // through the proxy using Node's http module
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 { try {
const { default: http } = await import("node:http"); const { default: http } = await import("node:http");
const { default: https } = await import("node:https"); const { default: https } = await import("node:https");
@@ -299,83 +263,22 @@ function maybeTriggerUpdate(dbPath: string): void {
downloadGeoipDb(dbPath).catch(() => {}); downloadGeoipDb(dbPath).catch(() => {});
} }
/**
* Extract a usable proxy URL from LaunchOptions.proxy.
* For SOCKS5 dicts with separate credentials, reconstructs the full URL
* with inline credentials so SOCKS5 auth works.
*/
function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null {
if (!proxy) return null;
if (typeof proxy === "string") return ensureProxyScheme(proxy);
const p = proxy as ProxyDict;
if (!p.server) return null;
if (p.username && isSocksProxy(p)) {
return reconstructSocksUrl(p);
}
return ensureProxyScheme(p.server);
}
/** /**
* Auto-fill timezone/locale from proxy IP when geoip is enabled. * Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Also returns exitIp as a free bonus (reused for WebRTC spoofing). * Shared by the Playwright and Puppeteer wrappers.
*/ */
export async function maybeResolveGeoip( export async function maybeResolveGeoip(
options: LaunchOptions options: LaunchOptions
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> { ): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale }; if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const proxyUrl = extractProxyUrl(options.proxy); let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale }; if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
proxyUrl = ensureProxyScheme(proxyUrl);
// When both tz/locale are explicit, still resolve exit IP for WebRTC const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
if (options.timezone && options.locale) {
const exitIp = await resolveExitIp(proxyUrl) ?? undefined;
return { timezone: options.timezone, locale: options.locale, exitIp };
}
const { timezone: geoTz, locale: geoLocale, exitIp: geoExitIp } = await resolveProxyGeo(proxyUrl);
const exitIp = geoExitIp ?? undefined;
return { return {
timezone: options.timezone ?? geoTz ?? undefined, timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined, locale: options.locale ?? geoLocale ?? undefined,
exitIp,
}; };
} }
/**
* Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
* Returns args unchanged if no ``auto`` value is present.
*/
export async function resolveWebrtcArgs(
options: LaunchOptions
): Promise<string[] | undefined> {
const args = options.args;
if (!args) return args;
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
if (idx === -1) return args;
const proxyUrl = extractProxyUrl(options.proxy);
if (!proxyUrl) {
console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag");
const result = [...args];
result.splice(idx, 1);
return result;
}
try {
const ip = await resolveExitIp(proxyUrl);
const result = [...args];
if (ip) {
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
} else {
console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
result.splice(idx, 1);
}
return result;
} catch {
console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
const result = [...args];
result.splice(idx, 1);
return result;
}
}
-913
View File
@@ -1,913 +0,0 @@
/**
* Human-like behavioral layer for cloakbrowser — Puppeteer edition.
*
* Mirrors Playwright humanize architecture, adapted for Puppeteer API.
*
* Patches ALL native Puppeteer interaction surfaces:
*
* PAGE-LEVEL:
* click (with clickCount support for dblclick), hover, type,
* select, focus, tap, goto
*
* MOUSE:
* move, click (with clickCount support for dblclick), wheel,
* dragAndDrop
*
* KEYBOARD:
* type, down, up, press, sendCharacter
*
* FRAME-LEVEL:
* click, hover, type, select, focus, tap
* + $, $$, waitForSelector (return patched ElementHandles)
*
* ELEMENTHANDLE-LEVEL (Puppeteer-specific, no Playwright equivalent):
* click (with clickCount), hover, type, press, tap, select,
* focus, drop, dragAndDrop
* + $, $$, waitForSelector (nested elements are also patched)
*
* BROWSER-LEVEL:
* newPage, createBrowserContext / createIncognitoBrowserContext,
* targetcreated event
*
* Stealth-aware:
* - isInputElement / isSelectorFocused use CDP Isolated Worlds
* - Shift symbol typing uses CDP Input.dispatchKeyEvent (isTrusted=true)
* - ElementHandle isInput check uses CDP DOM.describeNode (no JS execution)
* - Falls back to page.evaluate only when CDP session is unavailable
*
* Puppeteer-specific adaptations:
* - page.createCDPSession() instead of context.newCDPSession(page)
* - page.viewport() instead of page.viewportSize()
* - page.$(selector) instead of page.locator(selector)
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText
* - mouse.wheel({deltaX, deltaY}) object form adapted to (dx, dy)
* - page.select() instead of page.selectOption()
* - ElementHandle prototype patching (Puppeteer-only)
* - No page.dblclick() — Puppeteer uses click({clickCount:2})
*/
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
import type { HumanConfig } from '../human/config.js';
import { resolveConfig, rand, randRange, sleep } from '../human/config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement, smoothWheel } from './scroll.js';
export type { HumanConfig } from '../human/config.js';
export { resolveConfig } from '../human/config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
// ============================================================================
// CDP Isolated World — stealth DOM evaluation (Puppeteer version)
// ============================================================================
class StealthEval {
private cdp: CDPSession | null = null;
private contextId: number | null = null;
private page: Page;
constructor(page: Page) {
this.page = page;
}
private async ensureCdp(): Promise<CDPSession> {
if (!this.cdp) {
this.cdp = await this.page.createCDPSession();
}
return this.cdp;
}
private async createWorld(): Promise<number> {
const cdp = await this.ensureCdp();
const tree = await cdp.send('Page.getFrameTree');
const frameId = (tree as any).frameTree.frame.id;
const result = await cdp.send('Page.createIsolatedWorld', {
frameId,
worldName: '',
grantUniveralAccess: true,
});
const ctxId = (result as any).executionContextId;
this.contextId = ctxId;
return ctxId;
}
async evaluate(expression: string): Promise<any> {
if (this.contextId === null) {
await this.createWorld();
}
for (let attempt = 0; attempt < 2; attempt++) {
try {
const cdp = await this.ensureCdp();
const result = await cdp.send('Runtime.evaluate', {
expression,
contextId: this.contextId!,
returnByValue: true,
});
if ((result as any).exceptionDetails) {
if (attempt === 0) {
await this.createWorld();
continue;
}
return undefined;
}
return (result as any).result?.value;
} catch {
if (attempt === 0) {
this.contextId = null;
try { await this.createWorld(); } catch { return undefined; }
continue;
}
return undefined;
}
}
return undefined;
}
invalidate(): void {
this.contextId = null;
}
async getCdpSession(): Promise<CDPSession> {
return this.ensureCdp();
}
}
// ============================================================================
// Cursor state
// ============================================================================
class CursorState {
x = 0;
y = 0;
initialized = false;
}
// ============================================================================
// Stealth DOM queries
// ============================================================================
async function isInputElement(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
})()
`);
return !!result;
} catch { /* fallthrough */ }
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}, selector).catch(() => false);
}
async function isSelectorFocused(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
return el === document.activeElement;
})()
`);
return !!result;
} catch { /* fallthrough */ }
}
return page.evaluate((sel: string) => {
const el = document.querySelector(sel);
return el === document.activeElement;
}, selector).catch(() => false);
}
// ============================================================================
// Stealth ElementHandle input check — uses CDP DOM.describeNode
// instead of el.evaluate() to avoid main-world JS execution.
// ============================================================================
async function isInputElementHandle(
stealth: StealthEval | null,
el: ElementHandle,
): Promise<boolean> {
if (stealth) {
try {
const cdp = await stealth.getCdpSession();
const remoteObject = (el as any).remoteObject?.();
if (remoteObject?.objectId) {
const { node } = await cdp.send('DOM.describeNode', {
objectId: remoteObject.objectId,
}) as any;
const tag = (node?.nodeName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea') return true;
const attrs: string[] = node?.attributes || [];
for (let i = 0; i < attrs.length; i += 2) {
if (attrs[i] === 'contenteditable' && attrs[i + 1] === 'true') {
return true;
}
}
return false;
}
} catch { /* fallthrough to el.evaluate */ }
}
return el.evaluate((node: any) => {
const tag = node.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| node.getAttribute?.('contenteditable') === 'true';
}).catch(() => false);
}
// ============================================================================
// Page-level patching
// ============================================================================
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const originals = {
click: page.click.bind(page),
hover: page.hover.bind(page),
type: page.type.bind(page),
select: page.select.bind(page),
focus: page.focus.bind(page),
goto: page.goto.bind(page),
tap: page.tap.bind(page),
mouseMove: page.mouse.move.bind(page.mouse),
mouseClick: page.mouse.click.bind(page.mouse),
mouseDown: page.mouse.down.bind(page.mouse),
mouseUp: page.mouse.up.bind(page.mouse),
mouseWheel: (page.mouse as any).wheel?.bind(page.mouse),
mouseDragAndDrop: (page.mouse as any).dragAndDrop?.bind(page.mouse),
keyboardType: page.keyboard.type.bind(page.keyboard),
keyboardDown: page.keyboard.down.bind(page.keyboard) as (key: string) => Promise<void>,
keyboardUp: page.keyboard.up.bind(page.keyboard) as (key: string) => Promise<void>,
keyboardPress: page.keyboard.press.bind(page.keyboard),
keyboardSendCharacter: page.keyboard.sendCharacter.bind(page.keyboard),
};
(page as any)._original = originals;
(page as any)._humanCfg = cfg;
const stealth = new StealthEval(page);
(page as any)._stealth = stealth;
let cdpSession: CDPSession | null = null;
const ensureCdp = async (): Promise<CDPSession | null> => {
if (!cdpSession) {
try { cdpSession = await stealth.getCdpSession(); } catch {}
}
return cdpSession;
};
const raw: RawMouse = {
move: originals.mouseMove,
down: originals.mouseDown,
up: originals.mouseUp,
wheel: async (deltaX: number, deltaY: number) => {
if (originals.mouseWheel) {
await originals.mouseWheel({ deltaX, deltaY });
}
},
};
const rawKb: RawKeyboard = {
down: originals.keyboardDown,
up: originals.keyboardUp,
type: originals.keyboardType,
insertText: originals.keyboardSendCharacter,
};
async function ensureCursorInit(): Promise<void> {
if (!cursor.initialized) {
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
await originals.mouseMove(cursor.x, cursor.y);
cursor.initialized = true;
}
}
// ==== goto ====
const humanGoto = async (url: string, options?: any) => {
const response = await originals.goto(url, options);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
return response;
};
// ==== click (with clickCount support for dblclick) ====
const humanClickFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, isInput, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, isInput, cfg);
}
};
// ==== hover ====
const humanHoverFn = async (selector: string, options?: any) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX;
cursor.y = cursorY;
const target = clickTarget(box, false, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
};
// ==== type ====
const humanTypeFn = async (selector: string, text: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
// ==== select ====
const humanSelectFn = async (selector: string, ...values: string[]) => {
await humanHoverFn(selector);
await sleep(rand(100, 300));
return originals.select(selector, ...values);
};
// ==== focus ====
const humanFocusFn = async (selector: string) => {
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
}
};
// ==== tap ====
const humanTapFn = async (selector: string, options?: any) => {
await humanClickFn(selector, options);
};
// ============================================================
// Assign page-level patches
// ============================================================
(page as any).goto = humanGoto;
(page as any).click = humanClickFn;
(page as any).hover = humanHoverFn;
(page as any).type = humanTypeFn;
(page as any).select = humanSelectFn;
(page as any).focus = humanFocusFn;
(page as any).tap = humanTapFn;
// ============================================================
// Mouse patches
// ============================================================
page.mouse.move = async (x: number, y: number, options?: any) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
};
page.mouse.click = async (x: number, y: number, options?: any) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, false, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, false, cfg);
}
};
if (originals.mouseWheel) {
(page.mouse as any).wheel = async (options?: { deltaX?: number; deltaY?: number }) => {
const dx = options?.deltaX ?? 0;
const dy = options?.deltaY ?? 0;
if (Math.abs(dy) > 0) {
await smoothWheel(raw, dy, cfg, 'y');
}
if (Math.abs(dx) > 0) {
await smoothWheel(raw, dx, cfg, 'x');
}
};
}
if (originals.mouseDragAndDrop) {
(page.mouse as any).dragAndDrop = async (
start: { x: number; y: number },
target: { x: number; y: number },
options?: any,
) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg);
cursor.x = start.x;
cursor.y = start.y;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x;
cursor.y = target.y;
await sleep(rand(80, 150));
await originals.mouseUp();
};
}
// ============================================================
// Keyboard patches
// ============================================================
page.keyboard.type = async (text: string, options?: any) => {
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
page.keyboard.press = async (key: any, options?: any) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key as any);
};
page.keyboard.down = async (key: any) => {
await sleep(rand(10, 30));
await originals.keyboardDown(key as any);
};
page.keyboard.up = async (key: any) => {
await sleep(rand(10, 30));
await originals.keyboardUp(key as any);
};
// ============================================================
// Store helpers for frame/element patching
// ============================================================
(page as any)._humanCursor = cursor;
(page as any)._humanRaw = raw;
(page as any)._humanRawKb = rawKb;
(page as any)._ensureCursorInit = ensureCursorInit;
// Initialize cursor
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
originals.mouseMove(cursor.x, cursor.y).then(() => {
cursor.initialized = true;
}).catch(() => {});
// Patch frames
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
// Patch ElementHandle selectors
patchElementHandle(page, cfg, cursor, raw, rawKb, originals, stealth);
}
// ============================================================================
// ElementHandle patching — PUPPETEER-SPECIFIC
// ============================================================================
function patchElementHandle(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
const orig$ = page.$.bind(page);
const orig$$ = page.$$.bind(page);
const origWaitForSelector = page.waitForSelector.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;
};
(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;
};
(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;
};
}
function patchSingleElementHandle(
el: ElementHandle,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((el as any)._humanPatched) return;
(el as any)._humanPatched = true;
const origElClick = el.click.bind(el);
const origElHover = el.hover.bind(el);
const origElType = el.type.bind(el);
const origElPress = (el as any).press?.bind(el);
const origElTap = (el as any).tap?.bind(el);
const origElFocus = (el as any).focus?.bind(el);
const origElDragAndDrop = (el as any).dragAndDrop?.bind(el);
const origElSelect = (el as any).select?.bind(el);
const origElDrop = (el as any).drop?.bind(el);
// --- Nested selectors ---
const origEl$ = el.$.bind(el);
const origEl$$ = el.$$.bind(el);
const origElWaitForSelector = el.waitForSelector.bind(el);
(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 box and move cursor ---
const moveToElement = async () => {
await (page as any)._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);
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, info.isInp, cfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, info.isInp, cfg);
}
};
// --- el.hover() ---
(el as any).hover = async () => {
const info = await moveToElement();
if (!info) return origElHover();
};
// --- 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));
const cdp = await stealth.getCdpSession().catch(() => null);
await humanType(page, rawKb, text, cfg, cdp);
};
// --- el.press() ---
if (origElPress) {
(el as any).press = async (key: string, options?: any) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
await originals.keyboardUp(key as any);
};
}
// --- el.tap() ---
if (origElTap) {
(el as any).tap = async () => {
const info = await moveToElement();
if (!info) return origElTap();
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.focus() ---
if (origElFocus) {
(el as any).focus = async () => {
const info = await moveToElement();
if (!info) return origElFocus();
await humanClick(raw, info.isInp, cfg);
};
}
// --- el.select() ---
if (origElSelect) {
(el as any).select = async (...values: string[]) => {
const info = await moveToElement();
if (!info) return origElSelect(...values);
await humanClick(raw, false, cfg);
await sleep(rand(100, 300));
return origElSelect(...values);
};
}
// --- el.drop() ---
if (origElDrop) {
(el as any).drop = async (draggable: ElementHandle, options?: any) => {
const srcBox = await draggable.boundingBox();
const tgtBox = await el.boundingBox();
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
const sy = srcBox.y + srcBox.height / 2;
const tx = tgtBox.x + tgtBox.width / 2;
const ty = tgtBox.y + tgtBox.height / 2;
await (page as any)._ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
cursor.x = sx;
cursor.y = sy;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
cursor.x = tx;
cursor.y = ty;
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origElDrop(draggable, options);
}
};
}
// --- el.dragAndDrop() ---
if (origElDragAndDrop) {
(el as any).dragAndDrop = async (targetEl: ElementHandle, options?: any) => {
const srcBox = await el.boundingBox();
const tgtBox = await targetEl.boundingBox();
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
const sy = srcBox.y + srcBox.height / 2;
const tx = tgtBox.x + tgtBox.width / 2;
const ty = tgtBox.y + tgtBox.height / 2;
await (page as any)._ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
cursor.x = sx;
cursor.y = sy;
await sleep(rand(100, 200));
await originals.mouseDown();
await sleep(rand(80, 150));
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
cursor.x = tx;
cursor.y = ty;
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origElDragAndDrop(targetEl, options);
}
};
}
}
// ============================================================================
// Frame-level patching — native Puppeteer Frame methods only
// Puppeteer Frame has: click, hover, type, select, focus, tap
// ============================================================================
function patchFrames(
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
}
}
function patchSingleFrame(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true;
const origFrameSelect = frame.select.bind(frame);
(frame as any).click = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
};
(frame as any).hover = async (selector: string, options?: any) => {
await (page as any).hover(selector, options);
};
(frame as any).type = async (selector: string, text: string, options?: any) => {
await (page as any).type(selector, text, options);
};
(frame as any).select = async (selector: string, ...values: string[]) => {
await (page as any).hover(selector);
await sleep(rand(100, 300));
return origFrameSelect(selector, ...values);
};
(frame as any).focus = async (selector: string) => {
await (page as any).focus(selector);
};
(frame as any).tap = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
};
// Patch frame.$() to return patched ElementHandles
const origFrame$ = frame.$.bind(frame);
const origFrame$$ = frame.$$.bind(frame);
const origFrameWaitForSelector = frame.waitForSelector.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;
};
(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;
};
(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;
};
}
function* iterFrames(page: Page): Generator<Frame> {
try {
const mainFrame = page.mainFrame();
yield mainFrame;
for (const child of mainFrame.childFrames()) {
yield child;
}
} catch {}
}
// ============================================================================
// Browser-level patching
// ============================================================================
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
browser.pages().then(pages => {
for (const page of pages) {
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
}
}).catch(() => {});
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async () => {
const page = await origNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
// v21: createIncognitoBrowserContext
// v22+: createBrowserContext (renamed in puppeteer/puppeteer#11834)
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
if (typeof (browser as any)[methodName] === 'function') {
const origCreateContext = (browser as any)[methodName].bind(browser);
(browser as any)[methodName] = async (options?: any) => {
const context: BrowserContext = await origCreateContext(options);
const origCtxNewPage = context.newPage.bind(context);
(context as any).newPage = async () => {
const page = await origCtxNewPage();
if (!(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
return page;
};
return context;
};
}
}
browser.on('targetcreated', async (target: any) => {
try {
if (target.type() === 'page') {
const page = await target.page();
if (page && !(page as any)._original) {
patchPage(page, cfg, new CursorState());
}
}
} catch {}
});
}
export { patchPage };
-187
View File
@@ -1,187 +0,0 @@
/**
* cloakbrowser-human — Human-like keyboard input.
* Adapted for Puppeteer API.
*
* Changes from Playwright version:
* - Uses puppeteer-core Page/CDPSession types
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText adapter
* - CDPSession obtained via page.createCDPSession()
*
* Stealth-aware: shift symbols use CDP Input.dispatchKeyEvent (isTrusted=true).
*/
import type { Page, CDPSession } from 'puppeteer-core';
import { RawKeyboard } from '../human/mouse.js';
import type { HumanConfig } from '../human/config.js';
import { rand, randRange, sleep } from '../human/config.js';
const SHIFT_SYMBOLS = new Set([
'@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~',
]);
const NEARBY_KEYS: Record<string, string> = {
a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf',
f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm',
k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp',
p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy',
u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu',
z: 'asx',
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
};
const SHIFT_SYMBOL_CODES: Record<string, string> = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
};
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
};
function isAscii(ch: string): boolean {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
}
function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) {
const neighbors = NEARBY_KEYS[lower];
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
}
return ch;
}
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
export async function humanType(
page: Page,
raw: RawKeyboard,
text: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
const chars = [...text];
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
// Non-ASCII → sendCharacter via insertText adapter
if (!isAscii(ch)) {
await sleep(randRange(cfg.key_hold));
await raw.insertText(ch);
if (i < chars.length - 1) await interCharDelay(cfg);
continue;
}
// Mistype
if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) {
const wrong = getNearbyKey(ch);
await typeNormalChar(raw, wrong, cfg);
await sleep(randRange(cfg.mistype_delay_notice));
await raw.down('Backspace');
await sleep(randRange(cfg.key_hold));
await raw.up('Backspace');
await sleep(randRange(cfg.mistype_delay_correct));
}
if (isUpperCase(ch)) {
await typeShiftedChar(raw, ch, cfg);
} else if (SHIFT_SYMBOLS.has(ch)) {
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
} else {
await typeNormalChar(raw, ch, cfg);
}
if (i < chars.length - 1) await interCharDelay(cfg);
}
}
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
}
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.down(ch);
await sleep(randRange(cfg.key_hold));
await raw.up(ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
async function typeShiftSymbol(
page: Page,
raw: RawKeyboard,
ch: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
if (cdpSession) {
const code = SHIFT_SYMBOL_CODES[ch] || '';
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyDown',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
text: ch,
unmodifiedText: ch,
});
await sleep(randRange(cfg.key_hold));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyUp',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
});
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
} else {
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch);
await page.evaluate((key: string) => {
const el = document.activeElement;
if (el) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}
}, ch);
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
}
}
async function interCharDelay(cfg: HumanConfig): Promise<void> {
if (Math.random() < cfg.typing_pause_chance) {
await sleep(randRange(cfg.typing_pause_range));
} else {
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
await sleep(Math.max(10, delay));
}
}
-166
View File
@@ -1,166 +0,0 @@
/**
* cloakbrowser-human — Human-like scrolling via mouse wheel events.
* Adapted for Puppeteer API.
*
* Changes from Playwright version:
* - page.viewport() instead of page.viewportSize()
* - page.$(selector) + el.boundingBox() instead of page.locator().boundingBox()
* - No timeout parameter on boundingBox()
*/
import type { Page } from 'puppeteer-core';
import type { HumanConfig } from '../human/config.js';
import { rand, randRange, randIntRange, sleep } from '../human/config.js';
import { RawMouse, humanMove } from '../human/mouse.js';
interface ElementBounds {
x: number;
y: number;
width: number;
height: number;
}
function isInViewport(
bounds: ElementBounds,
viewportHeight: number,
cfg: HumanConfig,
): boolean {
const topEdge = bounds.y;
const bottomEdge = bounds.y + bounds.height;
const zoneTop = viewportHeight * cfg.scroll_target_zone[0];
const zoneBottom = viewportHeight * cfg.scroll_target_zone[1];
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
}
export async function smoothWheel(
raw: RawMouse,
delta: number,
cfg: HumanConfig,
axis: 'x' | 'y' = 'y',
): Promise<void> {
const absD = Math.abs(delta);
const sign = delta > 0 ? 1 : -1;
let sent = 0;
while (sent < absD) {
const stepSize = rand(20, 40);
const chunk = Math.min(stepSize, absD - sent);
const d = Math.round(chunk) * sign;
if (axis === 'x') {
await raw.wheel(d, 0);
} else {
await raw.wheel(0, d);
}
sent += chunk;
await sleep(rand(8, 20));
}
}
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
try {
const el = await page.$(selector);
if (!el) return null;
const box = await el.boundingBox();
if (!box) return null;
return { x: box.x, y: box.y, width: box.width, height: box.height };
} catch {
return null;
}
}
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
const viewport = page.viewport();
if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector);
if (!box) {
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
}
// Move cursor into scroll area
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
cursorX = scrollAreaX;
cursorY = scrollAreaY;
await sleep(randRange(cfg.scroll_pre_move_delay));
// Calculate scroll distance
const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]);
const elementCenter = box.y + box.height / 2;
const distanceToScroll = elementCenter - targetY;
const direction = distanceToScroll > 0 ? 1 : -1;
const absDistance = Math.abs(distanceToScroll);
const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta));
const accelSteps = randIntRange(cfg.scroll_accel_steps);
const decelSteps = randIntRange(cfg.scroll_decel_steps);
let scrolled = 0;
for (let i = 0; i < totalClicks; i++) {
let delta: number;
let pause: number;
if (i < accelSteps) {
delta = rand(80, 100);
pause = randRange(cfg.scroll_pause_slow);
} else if (i >= totalClicks - decelSteps) {
delta = rand(60, 90);
pause = randRange(cfg.scroll_pause_slow);
} else {
delta = randRange(cfg.scroll_delta_base);
pause = randRange(cfg.scroll_pause_fast);
}
delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance;
delta = Math.round(delta) * direction;
await smoothWheel(raw, delta, cfg);
scrolled += Math.abs(delta);
await sleep(pause);
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector);
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
}
if (scrolled >= absDistance * 1.1) break;
}
// Optional overshoot + correction
if (Math.random() < cfg.scroll_overshoot_chance) {
const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction;
await smoothWheel(raw, overshootPx, cfg);
await sleep(randRange(cfg.scroll_settle_delay));
const corrections = randIntRange([1, 2]);
for (let c = 0; c < corrections; c++) {
const corrDelta = Math.round(rand(40, 80)) * -direction;
await smoothWheel(raw, corrDelta, cfg);
await sleep(rand(100, 250));
}
}
await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
return { box, cursorX, cursorY };
}
-366
View File
@@ -1,366 +0,0 @@
/**
* 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;
};
}
}
+21 -237
View File
@@ -4,182 +4,32 @@
* Activated via humanize: true in launch() / launchContext(). * Activated via humanize: true in launch() / launchContext().
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling. * Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
* *
* Stealth-aware (fixes #110):
* - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
* - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
* - Falls back to page.evaluate only when CDP session is unavailable
*
* Patches all interaction methods: * Patches all interaction methods:
* click, dblclick, hover, type, fill, check, uncheck, selectOption, * click, dblclick, hover, type, fill, check, uncheck, selectOption,
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents. * press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
*
* ELEMENTHANDLE-LEVEL:
* click, dblclick, hover, type, fill, press, selectOption,
* check, uncheck, setChecked, tap, focus
* + $, $$, waitForSelector (nested elements are also patched)
*
* page.$(), page.$$(), page.waitForSelector() and Frame equivalents
* return patched ElementHandles automatically.
*/ */
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core'; import type { Browser, BrowserContext, Page, Frame } from 'playwright-core';
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js'; import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js'; import { humanType } from './keyboard.js';
import { scrollToElement } from './scroll.js'; import { scrollToElement } from './scroll.js';
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
export { HumanConfig, resolveConfig } from './config.js'; export { HumanConfig, resolveConfig } from './config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
export { humanType } from './keyboard.js'; export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js'; export { scrollToElement } from './scroll.js';
export { patchSingleElementHandle } from './elementhandle.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) --- // --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
// ============================================================================
// CDP Isolated World — stealth DOM evaluation
// ============================================================================
/**
* Manages a CDP isolated execution context for DOM reads.
* Produces clean Error.stack traces (no 'eval at evaluate :302:')
* and is invisible to querySelector monkey-patches in the main world.
*
* Context ID is invalidated on navigation and auto-recreated on next call.
*/
class StealthEval {
private cdp: CDPSession | null = null;
private contextId: number | null = null;
private page: Page;
constructor(page: Page) {
this.page = page;
}
private async ensureCdp(): Promise<CDPSession> {
if (!this.cdp) {
this.cdp = await this.page.context().newCDPSession(this.page);
}
return this.cdp;
}
private async createWorld(): Promise<number> {
const cdp = await this.ensureCdp();
const tree = await cdp.send('Page.getFrameTree');
const frameId = tree.frameTree.frame.id;
const result = await cdp.send('Page.createIsolatedWorld', {
frameId,
worldName: '',
grantUniveralAccess: true,
});
const ctxId = result.executionContextId;
this.contextId = ctxId;
return ctxId;
}
/**
* Evaluate a JS expression in the isolated world.
* Auto-recreates the world if the context was invalidated (navigation).
* Returns the result value, or undefined on failure.
*/
async evaluate(expression: string): Promise<any> {
if (this.contextId === null) {
await this.createWorld();
}
for (let attempt = 0; attempt < 2; attempt++) {
try {
const cdp = await this.ensureCdp();
const result = await cdp.send('Runtime.evaluate', {
expression,
contextId: this.contextId!,
returnByValue: true,
});
if (result.exceptionDetails) {
// Context was likely invalidated by navigation
if (attempt === 0) {
await this.createWorld();
continue;
}
return undefined;
}
return result.result?.value;
} catch {
if (attempt === 0) {
this.contextId = null;
try {
await this.createWorld();
} catch {
return undefined;
}
continue;
}
return undefined;
}
}
return undefined;
}
/** Mark context as stale — call after navigation. */
invalidate(): void {
this.contextId = null;
}
/** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */
async getCdpSession(): Promise<CDPSession> {
return this.ensureCdp();
}
}
// ============================================================================
// Cursor state
// ============================================================================
class CursorState { class CursorState {
x = 0; x = 0;
y = 0; y = 0;
initialized = false; initialized = false;
} }
async function isInputElement(page: Page, selector: string): Promise<boolean> {
// ============================================================================
// Stealth DOM queries — isolated world with evaluate fallback
// ============================================================================
/**
* Check if selector matches an input/textarea/contenteditable element.
* Uses CDP Isolated World when available — invisible to main world.
*/
async function isInputElement(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
if (!el) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
// Fallback: page.evaluate (detectable — should only happen if CDP fails)
return page.evaluate((sel: string) => { return page.evaluate((sel: string) => {
const el = document.querySelector(sel); const el = document.querySelector(sel);
if (!el) return false; if (!el) return false;
@@ -189,37 +39,13 @@ async function isInputElement(
}, selector).catch(() => false); }, selector).catch(() => false);
} }
/** async function isSelectorFocused(page: Page, selector: string): Promise<boolean> {
* Check if the element matching selector is currently focused.
* Uses CDP Isolated World when available — invisible to main world.
*/
async function isSelectorFocused(
stealth: StealthEval | null,
page: Page,
selector: string,
): Promise<boolean> {
if (stealth) {
try {
const escaped = JSON.stringify(selector);
const result = await stealth.evaluate(`
(() => {
const el = document.querySelector(${escaped});
return el === document.activeElement;
})()
`);
return !!result;
} catch {
// Fall through to page.evaluate
}
}
return page.evaluate((sel: string) => { return page.evaluate((sel: string) => {
const el = document.querySelector(sel); const el = document.querySelector(sel);
return el === document.activeElement; return el === document.activeElement;
}, selector).catch(() => false); }, selector).catch(() => false);
} }
// ============================================================================ // ============================================================================
// Page-level patching // Page-level patching
// ============================================================================ // ============================================================================
@@ -256,21 +82,6 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page as any)._original = originals; (page as any)._original = originals;
(page as any)._humanCfg = cfg; (page as any)._humanCfg = cfg;
// --- Stealth infrastructure ---
const stealth = new StealthEval(page);
(page as any)._stealth = stealth;
// CDP session for shift symbol typing (lazy-initialized, reuses stealth's session)
let cdpSession: CDPSession | null = null;
const ensureCdp = async (): Promise<CDPSession | null> => {
if (!cdpSession) {
try {
cdpSession = await stealth.getCdpSession();
} catch {}
}
return cdpSession;
};
const raw: RawMouse = { const raw: RawMouse = {
move: originals.mouseMove, move: originals.mouseMove,
down: originals.mouseDown, down: originals.mouseDown,
@@ -294,11 +105,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
} }
} }
// --- goto (invalidate isolated world on navigation) --- // --- goto ---
const humanGoto = async (url: string, options?: any) => { const humanGoto = async (url: string, options?: any) => {
const response = await originals.goto(url, options); const response = await originals.goto(url, options);
stealth.invalidate(); // Patch any new frames after navigation
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); patchFrames(page, cfg, cursor, raw, rawKb, originals);
return response; return response;
}; };
@@ -311,7 +122,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg); const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX; cursor.x = cursorX;
cursor.y = cursorY; cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector); const isInput = await isInputElement(page, selector);
const target = clickTarget(box, isInput, cfg); const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg); await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x; cursor.x = target.x;
@@ -328,7 +139,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg); const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
cursor.x = cursorX; cursor.x = cursorX;
cursor.y = cursorY; cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector); const isInput = await isInputElement(page, selector);
const target = clickTarget(box, isInput, cfg); const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg); await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
cursor.x = target.x; cursor.x = target.x;
@@ -358,8 +169,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
await sleep(randRange(cfg.field_switch_delay)); await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector); await humanClickFn(selector);
await sleep(rand(100, 250)); await sleep(rand(100, 250));
const cdp = await ensureCdp(); await humanType(page, rawKb, text, cfg);
await humanType(page, rawKb, text, cfg, cdp);
}; };
// --- fill (clears existing content first) --- // --- fill (clears existing content first) ---
@@ -371,13 +181,12 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
await sleep(rand(30, 80)); await sleep(rand(30, 80));
await originals.keyboardPress('Backspace'); await originals.keyboardPress('Backspace');
await sleep(rand(50, 150)); await sleep(rand(50, 150));
const cdp = await ensureCdp(); await humanType(page, rawKb, value, cfg);
await humanType(page, rawKb, value, cfg, cdp);
}; };
// --- clear --- // --- clear ---
const humanClearFn = async (selector: string, options?: any) => { const humanClearFn = async (selector: string, options?: any) => {
if (!await isSelectorFocused(stealth, page, selector)) { if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector); await humanClickFn(selector);
} }
await sleep(rand(50, 150)); await sleep(rand(50, 150));
@@ -417,7 +226,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- press (checks focus first — avoids redundant mouse moves) --- // --- press (checks focus first — avoids redundant mouse moves) ---
const humanPressFn = async (selector: string, key: string, options?: any) => { const humanPressFn = async (selector: string, key: string, options?: any) => {
if (!await isSelectorFocused(stealth, page, selector)) { if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector); await humanClickFn(selector);
} }
await sleep(rand(50, 150)); await sleep(rand(50, 150));
@@ -426,12 +235,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- pressSequentially --- // --- pressSequentially ---
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => { const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
if (!await isSelectorFocused(stealth, page, selector)) { if (!await isSelectorFocused(page, selector)) {
await humanClickFn(selector); await humanClickFn(selector);
} }
await sleep(rand(100, 250)); await sleep(rand(100, 250));
const cdp = await ensureCdp(); await humanType(page, rawKb, text, cfg);
await humanType(page, rawKb, text, cfg, cdp);
}; };
// --- tap --- // --- tap ---
@@ -450,9 +258,6 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page as any).uncheck = humanUncheckFn; (page as any).uncheck = humanUncheckFn;
(page as any).selectOption = humanSelectOptionFn; (page as any).selectOption = humanSelectOptionFn;
(page as any).press = humanPressFn; (page as any).press = humanPressFn;
(page as any).pressSequentially = humanPressSequentiallyFn;
(page as any).tap = humanTapFn;
(page as any).clear = humanClearFn;
// --- mouse patches --- // --- mouse patches ---
page.mouse.move = async (x: number, y: number, options?: any) => { page.mouse.move = async (x: number, y: number, options?: any) => {
@@ -472,8 +277,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- keyboard patches --- // --- keyboard patches ---
page.keyboard.type = async (text: string, options?: any) => { page.keyboard.type = async (text: string, options?: any) => {
const cdp = await ensureCdp(); await humanType(page, rawKb, text, cfg);
await humanType(page, rawKb, text, cfg, cdp);
}; };
// Store helpers for frame patching // Store helpers for frame patching
@@ -497,10 +301,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}).catch(() => {}); }).catch(() => {});
// --- Patch Frame-level methods (for sub-frames) --- // --- Patch Frame-level methods (for sub-frames) ---
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); patchFrames(page, cfg, cursor, raw, rawKb, originals);
// --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) ---
patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth);
} }
@@ -510,8 +311,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
/** /**
* Patch Frame methods so Locator-based calls go through humanization. * Patch Frame methods so Locator-based calls go through humanization.
* All 13 methods patched: click, dblclick, hover, type, fill, check, uncheck, * All 11 methods patched: click, dblclick, hover, type, fill, check, uncheck,
* selectOption, press, pressSequentially, tap, clear, dragAndDrop. * selectOption, press, clear, dragAndDrop.
*/ */
function patchFrames( function patchFrames(
page: Page, page: Page,
@@ -520,22 +321,13 @@ function patchFrames(
raw: RawMouse, raw: RawMouse,
rawKb: RawKeyboard, rawKb: RawKeyboard,
originals: any, originals: any,
stealth: StealthEval,
): void { ): void {
for (const frame of iterFrames(page)) { for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, originals, stealth); patchSingleFrame(frame, page, cfg, originals);
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
} }
} }
function patchSingleFrame( function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
frame: Frame,
page: Page,
cfg: HumanConfig,
originals: any,
stealth: StealthEval,
): void {
if ((frame as any)._humanPatched) return; if ((frame as any)._humanPatched) return;
(frame as any)._humanPatched = true; (frame as any)._humanPatched = true;
@@ -581,16 +373,8 @@ function patchSingleFrame(
await (page as any).press(selector, key, options); await (page as any).press(selector, key, options);
}; };
(frame as any).pressSequentially = async (selector: string, text: string, options?: any) => {
await (page as any).pressSequentially(selector, text, options);
};
(frame as any).tap = async (selector: string, options?: any) => {
await (page as any).tap(selector, options);
};
(frame as any).clear = async (selector: string, options?: any) => { (frame as any).clear = async (selector: string, options?: any) => {
if (!await isSelectorFocused(stealth, page, selector)) { if (!await isSelectorFocused(page, selector)) {
await (page as any).click(selector); await (page as any).click(selector);
} }
await sleep(rand(50, 150)); await sleep(rand(50, 150));
+7 -93
View File
@@ -1,12 +1,8 @@
/** /**
* cloakbrowser-human — Human-like keyboard input. * cloakbrowser-human — Human-like keyboard input.
*
* Stealth-aware: when a CDPSession is provided, shift symbols are typed
* via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
* Falls back to page.evaluate when no CDPSession is available.
*/ */
import type { Page, CDPSession } from 'playwright-core'; import type { Page } from 'playwright-core';
import { RawKeyboard } from './mouse.js'; import { RawKeyboard } from './mouse.js';
import { HumanConfig, rand, randRange, sleep } from './config.js'; import { HumanConfig, rand, randRange, sleep } from './config.js';
@@ -26,31 +22,6 @@ const NEARBY_KEYS: Record<string, string> = {
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p', '6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
}; };
/**
* CDP key code for each shift symbol's physical key.
* Used by Input.dispatchKeyEvent to produce isTrusted=true events.
*/
const SHIFT_SYMBOL_CODES: Record<string, string> = {
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
'?': 'Slash', '~': 'Backquote',
};
/**
* Windows virtual key codes for shift symbols.
* Input.dispatchKeyEvent uses these to match real keyboard behavior.
*/
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
'~': 192,
};
function isAscii(ch: string): boolean { function isAscii(ch: string): boolean {
const code = ch.codePointAt(0); const code = ch.codePointAt(0);
return code !== undefined && code < 128; return code !== undefined && code < 128;
@@ -66,24 +37,11 @@ function getNearbyKey(ch: string): string {
return ch; return ch;
} }
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
}
/**
* Type text with human-like per-character timing, mistype simulation,
* and realistic shift handling.
*
* @param cdpSession - If provided, shift symbols use CDP Input.dispatchKeyEvent
* producing isTrusted=true events with no evaluate stack trace.
* If null/undefined, falls back to page.evaluate (detectable).
*/
export async function humanType( export async function humanType(
page: Page, page: Page,
raw: RawKeyboard, raw: RawKeyboard,
text: string, text: string,
cfg: HumanConfig, cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> { ): Promise<void> {
const chars = [...text]; // Handle emoji surrogate pairs correctly const chars = [...text]; // Handle emoji surrogate pairs correctly
@@ -114,7 +72,7 @@ export async function humanType(
if (isUpperCase(ch)) { if (isUpperCase(ch)) {
await typeShiftedChar(raw, ch, cfg); await typeShiftedChar(raw, ch, cfg);
} else if (SHIFT_SYMBOLS.has(ch)) { } else if (SHIFT_SYMBOLS.has(ch)) {
await typeShiftSymbol(page, raw, ch, cfg, cdpSession); await typeShiftSymbol(page, raw, ch, cfg);
} else { } else {
await typeNormalChar(raw, ch, cfg); await typeNormalChar(raw, ch, cfg);
} }
@@ -141,54 +99,7 @@ async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig):
await raw.up('Shift'); await raw.up('Shift');
} }
/** async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
* Type a shift symbol character.
*
* Stealth path (cdpSession provided):
* Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
*
* Fallback path (no cdpSession):
* Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
* Detectable via isTrusted=false and evaluate stack frame.
*/
async function typeShiftSymbol(
page: Page,
raw: RawKeyboard,
ch: string,
cfg: HumanConfig,
cdpSession?: CDPSession | null,
): Promise<void> {
if (cdpSession) {
// --- Stealth path: CDP Input.dispatchKeyEvent ---
const code = SHIFT_SYMBOL_CODES[ch] || '';
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyDown',
modifiers: 8, // Shift modifier flag
key: ch,
code,
windowsVirtualKeyCode: keyCode,
text: ch,
unmodifiedText: ch,
});
await sleep(randRange(cfg.key_hold));
await cdpSession.send('Input.dispatchKeyEvent', {
type: 'keyUp',
modifiers: 8,
key: ch,
code,
windowsVirtualKeyCode: keyCode,
});
await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift');
} else {
// --- Fallback path: page.evaluate (detectable) ---
await raw.down('Shift'); await raw.down('Shift');
await sleep(randRange(cfg.shift_down_delay)); await sleep(randRange(cfg.shift_down_delay));
await raw.insertText(ch); await raw.insertText(ch);
@@ -201,7 +112,10 @@ async function typeShiftSymbol(
}, ch); }, ch);
await sleep(randRange(cfg.shift_up_delay)); await sleep(randRange(cfg.shift_up_delay));
await raw.up('Shift'); await raw.up('Shift');
} }
function isUpperCase(ch: string): boolean {
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
} }
async function interCharDelay(cfg: HumanConfig): Promise<void> { async function interCharDelay(cfg: HumanConfig): Promise<void> {
+16 -27
View File
@@ -8,8 +8,8 @@ import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOption
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js"; import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js"; import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { resolveProxyConfig } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js"; import { maybeResolveGeoip } from "./geoip.js";
/** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */ /** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T { export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
@@ -38,20 +38,17 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { chromium } = await import("playwright-core"); const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = await maybeResolveGeoip(options); const resolved = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy); const args = buildArgs({ ...options, ...resolved });
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
const browser = await chromium.launch({ const browser = await chromium.launch({
executablePath: binaryPath, executablePath: binaryPath,
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}), ...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...options.launchOptions, ...options.launchOptions,
}); });
@@ -90,22 +87,17 @@ export async function launchContext(
): Promise<BrowserContext> { ): Promise<BrowserContext> {
options = resolveTimezone(options); options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution // Resolve geoip BEFORE launch() to avoid double-resolution
const { exitIp, ...resolved } = await maybeResolveGeoip(options); const resolved = await maybeResolveGeoip(options);
let launchArgs = await resolveWebrtcArgs(options);
// Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
if (exitIp && !(launchArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
launchArgs = [...(launchArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
// --fingerprint-timezone is process-wide (reads CommandLine in renderer), // --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// so it applies to ALL contexts, not just the default one. // so it applies to ALL contexts, not just the default one.
// locale and timezone are set via binary flags only — no CDP emulation. // locale and timezone are set via binary flags only — no CDP emulation.
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false }); const browser = await launch({ ...options, ...resolved, geoip: false });
let context: BrowserContext; let context: BrowserContext;
try { try {
context = await browser.newContext({ context = await browser.newContext({
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport, viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
}); });
} catch (err) { } catch (err) {
@@ -162,13 +154,8 @@ export async function launchPersistentContext(
const { chromium } = await import("playwright-core"); const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = await maybeResolveGeoip(options); const resolved = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy); const args = buildArgs({ ...options, ...resolved });
let resolvedArgs = await resolveWebrtcArgs(options);
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone) // locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation. // — NOT via Playwright context kwargs which use detectable CDP emulation.
@@ -177,9 +164,11 @@ export async function launchPersistentContext(
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS, ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}), ...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport, viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
...options.launchOptions, ...options.launchOptions,
}); });
-59
View File
@@ -23,65 +23,6 @@ export function ensureProxyScheme(proxyUrl: string): string {
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port, * Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http). * and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
*/ */
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
export interface ProxyConfig {
/** Playwright proxy option (for HTTP proxies). */
proxyOption?: ParsedProxy;
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
proxyArgs: string[];
}
/**
* Check if a proxy uses the SOCKS5 protocol.
*/
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
if (!proxy) return false;
const url = typeof proxy === "string" ? proxy : proxy.server;
return /^socks5h?:\/\//i.test(url);
}
/**
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
*/
export function reconstructSocksUrl(proxy: ProxyDict): string {
const url = new URL(proxy.server);
if (proxy.username) {
url.username = encodeURIComponent(proxy.username);
if (proxy.password) url.password = encodeURIComponent(proxy.password);
}
return url.href.replace(/\/$/, "");
}
/**
* Resolve proxy into Playwright option and/or Chrome args.
*
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
*/
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
if (isSocksProxy(proxy)) {
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${proxy}`] };
}
const socksUrl = reconstructSocksUrl(proxy);
const args = [`--proxy-server=${socksUrl}`];
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
return { proxyArgs: args };
}
// HTTP/HTTPS: use Playwright's proxy dict
if (typeof proxy === "string") {
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
}
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
}
export function parseProxyUrl(proxy: string): ParsedProxy { export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL; let url: URL;
// Bare format: "user:pass@host:port" — new URL() throws without a scheme. // Bare format: "user:pass@host:port" — new URL() throws without a scheme.
+20 -37
View File
@@ -1,7 +1,6 @@
/** /**
* Puppeteer launch wrapper for cloakbrowser. * Puppeteer launch wrapper for cloakbrowser.
* NOW WITH HUMANIZE SUPPORT — humanize: true enables human-like * Alternative to the Playwright wrapper for users who prefer Puppeteer.
* mouse curves, keyboard timing, and scroll patterns (same as Playwright).
*/ */
import type { Browser } from "puppeteer-core"; import type { Browser } from "puppeteer-core";
@@ -9,8 +8,8 @@ import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js"; import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js"; import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js"; import { maybeResolveGeoip } from "./geoip.js";
/** /**
* Launch stealth Chromium browser via Puppeteer. * Launch stealth Chromium browser via Puppeteer.
@@ -18,48 +17,40 @@ import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
* @example * @example
* ```ts * ```ts
* import { launch } from 'cloakbrowser/puppeteer'; * import { launch } from 'cloakbrowser/puppeteer';
* * // With humanize — human-like mouse, keyboard, scroll * const browser = await launch();
* const browser = await launch({ humanize: true });
* const page = await browser.newPage(); * const page = await browser.newPage();
* await page.goto('[https://example.com](https://example.com)'); * await page.goto('https://bot.incolumitas.com');
* await page.click('#login'); // Bézier curve mouse movement * console.log(await page.title());
* await page.type('#email', 'user@example.com'); // Per-character timing * await browser.close();
* ``` * ```
*/ */
export async function launch(options: LaunchOptions = {}): Promise<Browser> { export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const puppeteer = await import("puppeteer-core"); const puppeteer = await import("puppeteer-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {}; const resolved = await maybeResolveGeoip(options);
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args; const args = buildArgs({ ...options, ...resolved });
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
}
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
// Puppeteer handles proxy via CLI args, not a separate option. // Puppeteer handles proxy via CLI args, not a separate option.
// SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth). // Chromium's --proxy-server does NOT support inline credentials,
// HTTP: Chrome does NOT support inline credentials — strip them and // so we strip them and use page.authenticate() instead.
// use page.authenticate() for Proxy-Authorization headers instead.
let proxyAuth: { username: string; password: string } | undefined; let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) { if (options.proxy) {
if (isSocksProxy(options.proxy)) { if (typeof options.proxy === "string") {
// 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); const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`); args.push(`--proxy-server=${server}`);
if (username) { if (username) {
proxyAuth = { username, password: password ?? "" }; proxyAuth = { username, password: password ?? "" };
} }
} else { } 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); const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`); args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) { if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`); args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
} }
// Explicit username/password fields take precedence over inline creds
const username = options.proxy.username ?? parsed.username; const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password; const password = options.proxy.password ?? parsed.password;
if (username) { if (username) {
@@ -87,18 +78,10 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
}; };
} }
// Human-like behavioral patching — FULL coverage, same as Playwright.
// This enables Bézier mouse movements, organic typing rhythms, and
// natural scrolling to bypass advanced anti-bot detection.
if (options.humanize) {
const { patchBrowser } = await import('./human-puppeteer/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
(options.humanPreset as any) ?? 'default',
options.humanConfig as any,
);
patchBrowser(browser, cfg);
}
return browser; return browser;
} }
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -36,7 +36,7 @@ export interface LaunchContextOptions extends LaunchOptions {
/** Custom user agent string. */ /** Custom user agent string. */
userAgent?: string; userAgent?: string;
/** Viewport size. */ /** Viewport size. */
viewport?: { width: number; height: number } | null; viewport?: { width: number; height: number };
/** Browser locale, e.g. "en-US". */ /** Browser locale, e.g. "en-US". */
locale?: string; locale?: string;
/** IANA timezone — alias for `timezone`. Either works. */ /** IANA timezone — alias for `timezone`. Either works. */
+3 -16
View File
@@ -21,17 +21,16 @@ describe("config", () => {
const isMac = process.platform === "darwin"; const isMac = process.platform === "darwin";
expect(args).toContain("--no-sandbox"); expect(args).toContain("--no-sandbox");
expect(args).toContain("--disable-blink-features=AutomationControlled");
if (isMac) { if (isMac) {
expect(args).toContain("--fingerprint-platform=macos"); expect(args).toContain("--fingerprint-platform=macos");
// macOS: no hardware-concurrency or GPU spoofing (uses native values)
expect(args.some((a) => a.includes("hardware-concurrency"))).toBe(false);
} else { } else {
expect(args).toContain("--fingerprint-platform=windows"); expect(args).toContain("--fingerprint-platform=windows");
} }
// GPU flags removed — binary auto-generates from seed + platform
expect(args.some((a) => a.includes("fingerprint-gpu-vendor"))).toBe(false);
expect(args.some((a) => a.includes("fingerprint-gpu-renderer"))).toBe(false);
// Should have a random fingerprint seed // Should have a random fingerprint seed
const fingerprintArg = args.find((a) => a.startsWith("--fingerprint=")); const fingerprintArg = args.find((a) => a.startsWith("--fingerprint="));
expect(fingerprintArg).toBeDefined(); expect(fingerprintArg).toBeDefined();
@@ -183,18 +182,6 @@ describe("buildArgs deduplication", () => {
}); });
}); });
describe("buildArgs webrtc IP", () => {
it("passes --fingerprint-webrtc-ip from args", () => {
const args = _buildArgsForTest({ args: ["--fingerprint-webrtc-ip=1.2.3.4"] });
expect(args).toContain("--fingerprint-webrtc-ip=1.2.3.4");
});
it("does not inject when not in args", () => {
const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-webrtc-ip"))).toBe(false);
});
});
describe("resolveTimezone alias", () => { describe("resolveTimezone alias", () => {
it("resolves timezoneId to timezone", () => { it("resolves timezoneId to timezone", () => {
const result = resolveTimezone({ timezoneId: "Europe/Paris" }); const result = resolveTimezone({ timezoneId: "Europe/Paris" });
-427
View File
@@ -1,7 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js"; import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js";
import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js"; import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js";
import { patchPageElementHandles } from "../src/human/elementhandle.js";
// ========================================================================= // =========================================================================
// Config resolution // Config resolution
@@ -452,89 +451,6 @@ describe("module exports", () => {
}); });
}); });
// =========================================================================
// patchBrowser on CDP-connected browser (issue #126)
// =========================================================================
describe("patchBrowser CDP-connected workflow", () => {
it("patches existing pages on a browser with pre-existing contexts", async () => {
const { patchBrowser, resolveConfig } = await import("../src/human/index.js");
// Simulate a CDP-connected browser: it already has contexts and pages
const page = buildMockPage();
const context: any = {
pages: vi.fn(() => [page]),
on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}),
};
const browser: any = {
contexts: vi.fn(() => [context]),
newContext: vi.fn(async () => context),
newPage: vi.fn(async () => page),
};
const cfg = resolveConfig("default");
patchBrowser(browser, cfg);
// page should now have _original (proof it was patched)
expect((page as any)._original).toBeDefined();
expect((page as any)._original.click).toBeTypeOf("function");
expect((page as any)._original.fill).toBeTypeOf("function");
expect((page as any)._humanCfg).toBe(cfg);
});
it("patched click calls mouse.down (humanized path, not original)", async () => {
const { patchBrowser, resolveConfig } = await import("../src/human/index.js");
let downCalled = false;
const page = buildMockPage();
page.mouse.down = vi.fn(async () => { downCalled = true; });
const context: any = {
pages: vi.fn(() => [page]),
on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}),
};
const browser: any = {
contexts: vi.fn(() => [context]),
newContext: vi.fn(async () => context),
newPage: vi.fn(async () => page),
};
patchBrowser(browser, resolveConfig("default"));
// Click through the patched method — should go through humanize path
try { await (page as any).click("button"); } catch (_) {}
expect(downCalled).toBe(true);
}, 30000);
it("new contexts created after patchBrowser are also patched", async () => {
const { patchBrowser, resolveConfig } = await import("../src/human/index.js");
const newPage = buildMockPage();
const newContext: any = {
pages: vi.fn(() => [newPage]),
on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}),
};
const browser: any = {
contexts: vi.fn(() => []),
newContext: vi.fn(async () => newContext),
newPage: vi.fn(async () => newPage),
};
patchBrowser(browser, resolveConfig("default"));
// Create a new context via the patched newContext
const ctx = await browser.newContext();
// Pages in the new context should be patched
expect((newPage as any)._original).toBeDefined();
});
});
// ========================================================================= // =========================================================================
// Test helpers // Test helpers
// ========================================================================= // =========================================================================
@@ -690,349 +606,6 @@ describe("humanType non-ASCII", () => {
// =========================================================================
// ElementHandle patching (Playwright)
// =========================================================================
function buildMockElementHandle(overrides: Record<string, any> = {}): any {
const el: any = {
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
press: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
setChecked: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
focus: vi.fn(async () => {}),
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
evaluate: overrides.evaluate ?? vi.fn(async () => false),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
$: vi.fn(async () => null),
$$: vi.fn(async () => []),
waitForSelector: vi.fn(async () => null),
_humanPatched: false,
};
return el;
}
describe("patchSingleElementHandle", () => {
it("marks element as patched", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true };
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = {
keyboardPress: vi.fn(async () => {}),
keyboardDown: vi.fn(async () => {}),
keyboardUp: vi.fn(async () => {}),
};
const el = buildMockElementHandle();
const page = buildMockPage();
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
expect(el._humanPatched).toBe(true);
});
it("el.click calls mouse.move and mouse.down/up (humanized path)", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
let moveCount = 0;
let downCalled = false;
let upCalled = false;
const raw = {
move: vi.fn(async () => { moveCount++; }),
down: vi.fn(async () => { downCalled = true; }),
up: vi.fn(async () => { upCalled = true; }),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = {
keyboardPress: vi.fn(async () => {}),
keyboardDown: vi.fn(async () => {}),
keyboardUp: vi.fn(async () => {}),
};
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.click();
expect(moveCount).toBeGreaterThan(0);
expect(downCalled).toBe(true);
expect(upCalled).toBe(true);
}, 30000);
it("el.hover calls mouse.move but NOT down/up", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
let downCalled = false;
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => { downCalled = true; }),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.hover();
expect(raw.move).toHaveBeenCalled();
expect(downCalled).toBe(false);
}, 30000);
it("el.type triggers mouse move + click + keyboard events", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 });
const cursor = { x: 50, y: 50, initialized: true };
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.type("abc");
expect(raw.move).toHaveBeenCalled();
expect(raw.down).toHaveBeenCalled(); // click to focus
expect(rawKb.down).toHaveBeenCalled(); // keyboard typing
}, 30000);
it("el.fill calls selectAll + backspace + type", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 });
const cursor = { x: 50, y: 50, initialized: true };
const pressedKeys: string[] = [];
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const originals = {
keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }),
keyboardDown: vi.fn(async () => {}),
keyboardUp: vi.fn(async () => {}),
};
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.fill("newtext");
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
expect(pressedKeys).toContain(expected);
expect(pressedKeys).toContain("Backspace");
}, 30000);
it("no double patching", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle();
const page = buildMockPage();
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
const firstClick = el.click;
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
expect(el.click).toBe(firstClick);
});
it("nested $() returns patched child handle", async () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const child = buildMockElementHandle();
const el = buildMockElementHandle();
el.$ = vi.fn(async () => child);
const page = buildMockPage();
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
const result = await el.$("span");
expect(result._humanPatched).toBe(true);
});
});
describe("patchPageElementHandles", () => {
it("page.$() returns patched ElementHandle", async () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any).$ = vi.fn(async () => el);
(page as any).$$ = vi.fn(async () => [el]);
(page as any).waitForSelector = vi.fn(async () => el);
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
const result = await (page as any).$("#test");
expect(result._humanPatched).toBe(true);
});
it("page.$$() returns all patched handles", async () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el1 = buildMockElementHandle();
const el2 = buildMockElementHandle();
const page = buildMockPage();
(page as any).$ = vi.fn(async () => null);
(page as any).$$ = vi.fn(async () => [el1, el2]);
(page as any).waitForSelector = vi.fn(async () => null);
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
const results = await (page as any).$$("div");
expect(results[0]._humanPatched).toBe(true);
expect(results[1]._humanPatched).toBe(true);
});
it("page.waitForSelector() returns patched handle", async () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any).$ = vi.fn(async () => null);
(page as any).$$ = vi.fn(async () => []);
(page as any).waitForSelector = vi.fn(async () => el);
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
const result = await (page as any).waitForSelector("#test");
expect(result._humanPatched).toBe(true);
});
it("page.$() returns null when no element found (no crash)", async () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
const page = buildMockPage();
(page as any).$ = vi.fn(async () => null);
(page as any).$$ = vi.fn(async () => []);
(page as any).waitForSelector = vi.fn(async () => null);
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
const result = await (page as any).$("#nonexistent");
expect(result).toBeNull();
});
});
describe("patchPage integrates ElementHandle patching", () => {
it("patchPage patches page.$ automatically", async () => {
const { patchPage } = await import("../src/human/index.js");
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any).$ = vi.fn(async () => el);
(page as any).$$ = vi.fn(async () => []);
(page as any).waitForSelector = vi.fn(async () => null);
const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
const result = await (page as any).$("#test");
expect(result._humanPatched).toBe(true);
});
});
function buildMockFrame(): any { function buildMockFrame(): any {
return { return {
click: vi.fn(async () => {}), click: vi.fn(async () => {}),
+1 -90
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js"; import { parseProxyUrl } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js"; import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => { describe("parseProxyUrl", () => {
@@ -115,92 +115,3 @@ describe("bare proxy format (user:pass@host:port)", () => {
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" }); expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
}); });
}); });
describe("isSocksProxy", () => {
it("detects socks5 string", () => {
expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true);
});
it("detects socks5h string", () => {
expect(isSocksProxy("socks5h://host:1080")).toBe(true);
});
it("case insensitive", () => {
expect(isSocksProxy("SOCKS5://host:1080")).toBe(true);
});
it("rejects http", () => {
expect(isSocksProxy("http://host:8080")).toBe(false);
});
it("detects socks5 dict", () => {
expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true);
});
it("rejects http dict", () => {
expect(isSocksProxy({ server: "http://host:8080" })).toBe(false);
});
it("returns false for undefined", () => {
expect(isSocksProxy(undefined)).toBe(false);
});
});
describe("resolveProxyConfig", () => {
it("returns empty for undefined", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http dict", () => {
const proxy = { server: "http://proxy:8080", bypass: ".example.com" };
const { proxyOption, proxyArgs } = resolveProxyConfig(proxy);
expect(proxyOption).toEqual(proxy);
expect(proxyArgs).toEqual([]);
});
it("returns chrome arg for socks5 string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]);
});
it("returns chrome arg for socks5 no auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
});
it("returns chrome arg for socks5h string", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]);
});
it("reconstructs URL from socks5 dict with auth", () => {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
username: "user",
password: "p@ss",
});
expect(proxyOption).toBeUndefined();
expect(proxyArgs.length).toBe(1);
expect(proxyArgs[0]).toContain("--proxy-server=socks5://user:p%40ss@host:1080");
});
it("includes bypass for socks5 dict", () => {
const { proxyArgs } = resolveProxyConfig({
server: "socks5://host:1080",
bypass: ".example.com",
});
expect(proxyArgs).toContain("--proxy-server=socks5://host:1080");
expect(proxyArgs).toContain("--proxy-bypass-list=.example.com");
});
});
-27
View File
@@ -14,7 +14,6 @@ vi.mock("../src/download.js", () => ({
vi.mock("../src/geoip.js", () => ({ vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }), resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}), maybeResolveGeoip: vi.fn().mockResolvedValue({}),
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
})); }));
describe("puppeteer launch", () => { describe("puppeteer launch", () => {
@@ -22,7 +21,6 @@ describe("puppeteer launch", () => {
let mockBrowser: any; let mockBrowser: any;
beforeEach(async () => { beforeEach(async () => {
delete process.env.CLOAKBROWSER_BINARY_PATH;
puppeteerMock = await import("puppeteer-core"); puppeteerMock = await import("puppeteer-core");
mockBrowser = { mockBrowser = {
newPage: vi.fn().mockResolvedValue({ newPage: vi.fn().mockResolvedValue({
@@ -113,29 +111,4 @@ describe("puppeteer launch", () => {
expect(callArgs.args).toContain("--disable-gpu"); expect(callArgs.args).toContain("--disable-gpu");
expect(callArgs.args).toContain("--no-first-run"); expect(callArgs.args).toContain("--no-first-run");
}); });
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
// Should NOT set up page.authenticate for SOCKS5
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({
proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" },
});
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
});
}); });
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -54,7 +54,7 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"] patchright = ["patchright>=1.40"]
serve = ["aiohttp>=3.9", "websockets>=12.0"] serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"] dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
-41
View File
@@ -158,44 +158,3 @@ def test_override_logs_debug(caplog):
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"): with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
build_args(stealth_args=True, extra_args=["--fingerprint=99887"]) build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records) assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
# --- WebRTC IP spoofing ---
def test_webrtc_ip_passed_through_args():
"""--fingerprint-webrtc-ip in args should pass through to output."""
args = build_args(stealth_args=True, extra_args=["--fingerprint-webrtc-ip=1.2.3.4"])
assert "--fingerprint-webrtc-ip=1.2.3.4" in args
def test_webrtc_ip_not_present_by_default():
"""No --fingerprint-webrtc-ip when not in args."""
args = build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-webrtc-ip") for a in args)
def test_resolve_webrtc_args_auto():
"""--fingerprint-webrtc-ip=auto should be resolved to an IP."""
from cloakbrowser.browser import _resolve_webrtc_args
from unittest.mock import patch
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="5.6.7.8"):
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=auto"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=5.6.7.8"]
def test_resolve_webrtc_args_explicit_ip_unchanged():
"""Explicit IP in args should not be touched."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=9.9.9.9"], "http://proxy:8080")
assert result == ["--fingerprint-webrtc-ip=9.9.9.9"]
def test_resolve_webrtc_args_no_flag():
"""No webrtc flag in args should return args unchanged."""
from cloakbrowser.browser import _resolve_webrtc_args
result = _resolve_webrtc_args(["--no-sandbox"], "http://proxy:8080")
assert result == ["--no-sandbox"]
+2 -4
View File
@@ -105,10 +105,8 @@ class TestParseCliArgs:
def test_passthrough_args(self): def test_passthrough_args(self):
args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"] args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"]
config, passthrough = parse_cli_args(args) _, passthrough = parse_cli_args(args)
# --fingerprint=999 is consumed into config["default_seed"], not passed through assert passthrough == args
assert passthrough == ["--no-sandbox", "--disable-gpu"]
assert config["default_seed"] == "999"
def test_port_not_in_passthrough(self): def test_port_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"]) _, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
+2 -6
View File
@@ -133,14 +133,10 @@ class TestStealthArgs:
with patch("cloakbrowser.config.platform.system", return_value="Darwin"): with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
args = get_default_stealth_args() args = get_default_stealth_args()
assert "--fingerprint-platform=macos" in args assert "--fingerprint-platform=macos" in args
# GPU flags removed — binary auto-generates from seed + platform assert any("Apple" in a for a in args)
assert not any("fingerprint-gpu-vendor" in a for a in args)
assert not any("fingerprint-gpu-renderer" in a for a in args)
def test_linux_windows_profile(self): def test_linux_windows_profile(self):
with patch("cloakbrowser.config.platform.system", return_value="Linux"): with patch("cloakbrowser.config.platform.system", return_value="Linux"):
args = get_default_stealth_args() args = get_default_stealth_args()
assert "--fingerprint-platform=windows" in args assert "--fingerprint-platform=windows" in args
# GPU flags removed — binary auto-generates from seed + platform assert any("NVIDIA" in a for a in args)
assert not any("fingerprint-gpu-vendor" in a for a in args)
assert not any("fingerprint-gpu-renderer" in a for a in args)
+10 -15
View File
@@ -97,51 +97,46 @@ def test_resolve_geo_returns_none_when_db_missing():
def test_maybe_resolve_skips_when_geoip_false(): def test_maybe_resolve_skips_when_geoip_false():
tz, loc, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None) tz, loc = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None assert tz is None
assert loc is None assert loc is None
assert ip is None
def test_maybe_resolve_skips_when_no_proxy(): def test_maybe_resolve_skips_when_no_proxy():
tz, loc, ip = maybe_resolve_geoip(True, None, None, None) tz, loc = maybe_resolve_geoip(True, None, None, None)
assert tz is None assert tz is None
assert loc is None assert loc is None
assert ip is None
def test_maybe_resolve_skips_when_both_explicit(): def test_maybe_resolve_skips_when_both_explicit():
"""Explicit values should still resolve exit IP for WebRTC.""" """Explicit values should not trigger geoip resolution."""
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="1.2.3.4"): tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert loc == "de-DE" assert loc == "de-DE"
assert ip == "1.2.3.4"
def test_maybe_resolve_fills_missing_timezone(): def test_maybe_resolve_fills_missing_timezone():
"""When only locale is explicit, geoip should fill timezone.""" """When only locale is explicit, geoip should fill timezone."""
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")): with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR") tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
assert tz == "America/New_York" assert tz == "America/New_York"
assert loc == "fr-FR" # Explicit wins assert loc == "fr-FR" # Explicit wins
def test_maybe_resolve_fills_missing_locale(): def test_maybe_resolve_fills_missing_locale():
"""When only timezone is explicit, geoip should fill locale.""" """When only timezone is explicit, geoip should fill locale."""
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")): with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None) tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
assert tz == "Asia/Tokyo" # Explicit wins assert tz == "Asia/Tokyo" # Explicit wins
assert loc == "en-US" assert loc == "en-US"
def test_maybe_resolve_fills_both(): def test_maybe_resolve_fills_both():
"""When neither is set, geoip should fill both.""" """When neither is set, geoip should fill both."""
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")): with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")):
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None) tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert loc == "de-DE" assert loc == "de-DE"
assert ip == "5.6.7.8"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-61
View File
@@ -279,67 +279,6 @@ if __name__ == "__main__":
check("keyboard.type", kb_ms > 500, f"{kb_ms} ms") check("keyboard.type", kb_ms > 500, f"{kb_ms} ms")
time.sleep(1) time.sleep(1)
# ============================================================
# SCENARIO 7: ElementHandle — query_selector interactions
# ============================================================
step("ElementHandle — query_selector click, type, fill, hover")
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
print(" Watch: get element via query_selector, cursor moves smoothly")
el = page.query_selector('#searchInput')
assert el is not None, "query_selector returned None"
assert getattr(el, '_human_patched', False), "ElementHandle not patched!"
t0 = time.time()
el.click()
eh_click_ms = int((time.time() - t0) * 1000)
check("ElementHandle click", eh_click_ms > 100, f"{eh_click_ms} ms")
time.sleep(0.5)
print(" Watch: ElementHandle type — characters appear one by one")
t0 = time.time()
el.type('ElementHandle typing')
eh_type_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("ElementHandle type", val == 'ElementHandle typing' and eh_type_ms > 1500, f"{eh_type_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: ElementHandle fill — clears then types")
t0 = time.time()
el.fill('Filled via EH')
eh_fill_ms = int((time.time() - t0) * 1000)
val = page.locator('#searchInput').input_value()
check("ElementHandle fill", val == 'Filled via EH' and eh_fill_ms > 1000, f"{eh_fill_ms} ms, value='{val}'")
time.sleep(0.5)
print(" Watch: ElementHandle hover — cursor moves without clicking")
btn_el = page.query_selector('button[type="submit"]')
t0 = time.time()
btn_el.hover()
eh_hover_ms = int((time.time() - t0) * 1000)
check("ElementHandle hover", eh_hover_ms > 50, f"{eh_hover_ms} ms")
time.sleep(0.5)
print(" Watch: query_selector_all returns patched handles")
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
time.sleep(2)
inject(page)
time.sleep(1)
els = page.query_selector_all('input[type="checkbox"]')
all_patched = all(getattr(e, '_human_patched', False) for e in els)
check("query_selector_all all patched", all_patched and len(els) >= 2, f"{len(els)} elements, all_patched={all_patched}")
if els:
print(" Watch: click checkbox via ElementHandle")
t0 = time.time()
els[0].click()
cb_click_ms = int((time.time() - t0) * 1000)
check("ElementHandle checkbox click", cb_click_ms > 100, f"{cb_click_ms} ms")
time.sleep(1)
# ============================================================ # ============================================================
# SUMMARY # SUMMARY
# ============================================================ # ============================================================
+14 -622
View File
@@ -12,7 +12,7 @@ Can also run directly: python tests/test_humanize_unit.py
import math import math
import time import time
import sys import sys
import asyncio
import pytest import pytest
@@ -535,7 +535,7 @@ class TestNonAsciiKeyboardAsync:
class TestBrowserFill: class TestBrowserFill:
def test_fill_clears_existing(self): def test_fill_clears_existing(self):
from cloakbrowser import launch from cloakbrowser import launch
browser = launch(headless=False, humanize=True) browser = launch(headless=True, humanize=True)
page = browser.new_page() page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1) time.sleep(1)
@@ -550,7 +550,7 @@ class TestBrowserFill:
def test_fill_timing_humanized(self): def test_fill_timing_humanized(self):
from cloakbrowser import launch from cloakbrowser import launch
browser = launch(headless=False, humanize=True) browser = launch(headless=True, humanize=True)
page = browser.new_page() page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1) time.sleep(1)
@@ -562,7 +562,7 @@ class TestBrowserFill:
def test_clear_empties_field(self): def test_clear_empties_field(self):
from cloakbrowser import launch from cloakbrowser import launch
browser = launch(headless=False, humanize=True) browser = launch(headless=True, humanize=True)
page = browser.new_page() page = browser.new_page()
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
time.sleep(1) time.sleep(1)
@@ -579,7 +579,7 @@ class TestBrowserFill:
class TestBrowserPatching: class TestBrowserPatching:
def test_page_has_original(self): def test_page_has_original(self):
from cloakbrowser import launch from cloakbrowser import launch
browser = launch(headless=False, humanize=True) browser = launch(headless=True, humanize=True)
page = browser.new_page() page = browser.new_page()
assert hasattr(page, '_original') assert hasattr(page, '_original')
assert hasattr(page, '_human_cfg') assert hasattr(page, '_human_cfg')
@@ -587,7 +587,7 @@ class TestBrowserPatching:
def test_locator_methods_patched(self): def test_locator_methods_patched(self):
from cloakbrowser import launch from cloakbrowser import launch
browser = launch(headless=False, humanize=True) browser = launch(headless=True, humanize=True)
page = browser.new_page() page = browser.new_page()
from playwright.sync_api._generated import Locator from playwright.sync_api._generated import Locator
methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck', methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck',
@@ -608,7 +608,7 @@ class TestBrowserPatching:
def test_page_human_cfg_persists(self): def test_page_human_cfg_persists(self):
from cloakbrowser import launch from cloakbrowser import launch
browser = launch(headless=False, humanize=True) browser = launch(headless=True, humanize=True)
page = browser.new_page() page = browser.new_page()
assert page._human_cfg is not None assert page._human_cfg is not None
assert hasattr(page._human_cfg, 'idle_between_actions') assert hasattr(page._human_cfg, 'idle_between_actions')
@@ -618,7 +618,7 @@ class TestBrowserPatching:
@pytest.mark.slow @pytest.mark.slow
class TestBrowserBotDetection: class TestBrowserBotDetection:
PROXY = None PROXY = ''
def test_behavioral_checks_pass(self): def test_behavioral_checks_pass(self):
from cloakbrowser import launch from cloakbrowser import launch
@@ -644,7 +644,7 @@ class TestBrowserBotDetection:
def test_form_timing(self): def test_form_timing(self):
from cloakbrowser import launch from cloakbrowser import launch
browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True) browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True)
page = browser.new_page() page = browser.new_page()
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
wait_until='domcontentloaded') wait_until='domcontentloaded')
@@ -661,12 +661,13 @@ class TestBrowserBotDetection:
@pytest.mark.slow @pytest.mark.slow
class TestAsyncEndToEnd: class TestAsyncEndToEnd:
@pytest.mark.asyncio def test_async_launch_click_fill(self):
async def test_async_launch_click_fill(self):
"""launch_async(humanize=True) — async page.click and page.fill work end-to-end.""" """launch_async(humanize=True) — async page.click and page.fill work end-to-end."""
import asyncio
from cloakbrowser import launch_async from cloakbrowser import launch_async
browser = await launch_async(headless=False, humanize=True) async def _run():
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page() page = await browser.new_page()
assert hasattr(page, '_original'), "async page not patched" assert hasattr(page, '_original'), "async page not patched"
assert hasattr(page, '_human_cfg'), "async page missing _human_cfg" assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"
@@ -684,616 +685,7 @@ class TestAsyncEndToEnd:
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()
# ========================================================================= # =========================================================================
+1 -1
View File
@@ -114,7 +114,7 @@ def test_color_scheme(mock_launch, _mock_bin):
assert ctx_kwargs[1]["color_scheme"] == "dark" assert ctx_kwargs[1]["color_scheme"] == "dark"
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip): def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
+11 -11
View File
@@ -26,7 +26,7 @@ def _make_mock_pw_and_context():
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_args_built(_mock_geoip, _mock_bin): def test_persistent_context_args_built(_mock_geoip, _mock_bin):
"""Stealth args + extra args combined correctly.""" """Stealth args + extra args combined correctly."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -42,7 +42,7 @@ def test_persistent_context_args_built(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin): def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given.""" """DEFAULT_VIEWPORT applied when no viewport given."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -56,7 +56,7 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin): def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
"""Custom viewport overrides DEFAULT_VIEWPORT.""" """Custom viewport overrides DEFAULT_VIEWPORT."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -71,7 +71,7 @@ def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_user_agent(_mock_geoip, _mock_bin): def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
"""user_agent forwarded to launch_persistent_context().""" """user_agent forwarded to launch_persistent_context()."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -103,7 +103,7 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin): def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
"""color_scheme forwarded correctly.""" """color_scheme forwarded correctly."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -116,7 +116,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
assert call_kwargs["color_scheme"] == "dark" assert call_kwargs["color_scheme"] == "dark"
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip): def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale — flows to binary args, not CDP context.""" """geoip fills missing tz/locale — flows to binary args, not CDP context."""
@@ -150,7 +150,7 @@ def test_persistent_context_timezone_id_alias(_mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin): def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
"""context.close() also calls pw.stop().""" """context.close() also calls pw.stop()."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -166,7 +166,7 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin): def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
"""Proxy string parsed and passed.""" """Proxy string parsed and passed."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -182,7 +182,7 @@ def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin): def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
"""Proxy dict passed through.""" """Proxy dict passed through."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -213,7 +213,7 @@ def _make_mock_async_pw_and_context():
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin): async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
"""Async launch builds args correctly.""" """Async launch builds args correctly."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
@@ -229,7 +229,7 @@ async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin): async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
"""await context.close() calls await pw.stop().""" """await context.close() calls await pw.stop()."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
+27 -142
View File
@@ -2,12 +2,7 @@
from unittest.mock import patch from unittest.mock import patch
from cloakbrowser.browser import ( from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url
_is_socks_proxy,
_parse_proxy_url,
_resolve_proxy_config,
maybe_resolve_geoip,
)
class TestParseProxyUrl: class TestParseProxyUrl:
@@ -43,30 +38,23 @@ class TestParseProxyUrl:
class TestBuildProxyKwargs: class TestBuildProxyKwargs:
"""Tests for _resolve_proxy_config (formerly _build_proxy_kwargs) HTTP path."""
def test_none(self): def test_none(self):
kwargs, args = _resolve_proxy_config(None) assert _build_proxy_kwargs(None) == {}
assert kwargs == {}
assert args == []
def test_simple_proxy(self): def test_simple_proxy(self):
kwargs, args = _resolve_proxy_config("http://proxy:8080") result = _build_proxy_kwargs("http://proxy:8080")
assert kwargs == {"proxy": {"server": "http://proxy:8080"}} assert result == {"proxy": {"server": "http://proxy:8080"}}
assert args == []
def test_proxy_with_auth(self): def test_proxy_with_auth(self):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") result = _build_proxy_kwargs("http://user:pass@proxy:8080")
assert kwargs == { assert result == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"} "proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
} }
assert args == []
def test_proxy_dict_passthrough(self): def test_proxy_dict_passthrough(self):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"} proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
kwargs, args = _resolve_proxy_config(proxy_dict) result = _build_proxy_kwargs(proxy_dict)
assert kwargs == {"proxy": proxy_dict} assert result == {"proxy": proxy_dict}
assert args == []
def test_proxy_dict_with_auth(self): def test_proxy_dict_with_auth(self):
proxy_dict = { proxy_dict = {
@@ -75,82 +63,57 @@ class TestBuildProxyKwargs:
"password": "pass", "password": "pass",
"bypass": ".example.com", "bypass": ".example.com",
} }
kwargs, args = _resolve_proxy_config(proxy_dict) result = _build_proxy_kwargs(proxy_dict)
assert kwargs == {"proxy": proxy_dict} assert result == {"proxy": proxy_dict}
assert args == []
class TestMaybeResolveGeoip: class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_with_string_proxy(self, mock_geo): def test_geoip_with_string_proxy(self, mock_geo):
tz, locale, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None) tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080") mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York" assert tz == "America/New_York"
assert locale == "en-US" assert locale == "en-US"
assert ip == "1.2.3.4"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "5.6.7.8")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo): def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"} proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None) tz, locale = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080") mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "Europe/London" assert tz == "Europe/London"
assert locale == "en-GB" assert locale == "en-GB"
def test_geoip_disabled_skips_resolution(self): def test_geoip_disabled_skips_resolution(self):
tz, locale, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None) tz, locale = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None assert tz is None
assert locale is None assert locale is None
assert ip is None
def test_geoip_no_proxy_skips_resolution(self): def test_geoip_no_proxy_skips_resolution(self):
tz, locale, ip = maybe_resolve_geoip(True, None, None, None) tz, locale = maybe_resolve_geoip(True, None, None, None)
assert tz is None assert tz is None
assert locale is None assert locale is None
assert ip is None
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Asia/Tokyo", "ja-JP", "9.8.7.6")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
def test_geoip_preserves_explicit_timezone(self, mock_geo): def test_geoip_preserves_explicit_timezone(self, mock_geo):
tz, locale, _ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None) tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert locale == "ja-JP" assert locale == "ja-JP"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo): def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
# "user:pass@host:port" must be normalized to http:// before geoip lookup. # "user:pass@host:port" must be normalized to http:// before geoip lookup.
tz, locale, _ip = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None) tz, locale = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
mock_geo.assert_called_once_with("http://user:pass@proxy:8080") mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
assert tz == "America/New_York" assert tz == "America/New_York"
assert locale == "en-US" assert locale == "en-US"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo): def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
# "host:port" (no @ and no scheme) must also be normalized. # "host:port" (no @ and no scheme) must also be normalized.
tz, locale, _ip = maybe_resolve_geoip(True, "proxy:8080", None, None) tz, locale = maybe_resolve_geoip(True, "proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080") mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York" assert tz == "America/New_York"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
def test_geoip_socks5_dict_reconstructs_credentials(self, mock_geo):
proxy_dict = {"server": "socks5://proxy:1080", "username": "user", "password": "pass"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("socks5://user:pass@proxy:1080")
assert tz == "Europe/Berlin"
assert locale == "de-DE"
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
def test_geoip_socks5_dict_no_auth_uses_server(self, mock_geo):
proxy_dict = {"server": "socks5://proxy:1080"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("socks5://proxy:1080")
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "1.1.1.1"))
def test_geoip_http_dict_does_not_inline_creds(self, mock_geo):
# HTTP dict: credentials stay separate, only server URL passed
proxy_dict = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
class TestBareProxyFormat: class TestBareProxyFormat:
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme).""" """_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
@@ -183,86 +146,8 @@ class TestBareProxyFormat:
r = _parse_proxy_url("proxy:8080") r = _parse_proxy_url("proxy:8080")
assert r == {"server": "proxy:8080"} assert r == {"server": "proxy:8080"}
def test_resolve_proxy_config_bare(self): def test_build_proxy_kwargs_bare(self):
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080") r = _build_proxy_kwargs("user:pass@proxy:8080")
assert kwargs["proxy"]["username"] == "user" assert r["proxy"]["username"] == "user"
assert kwargs["proxy"]["password"] == "pass" assert r["proxy"]["password"] == "pass"
assert "user" not in kwargs["proxy"]["server"] assert "user" not in r["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
-168
View File
@@ -1,168 +0,0 @@
# tests/test_stealth_reproduction_110.py
"""
Exact reproduction of issue #110 detection vectors.
Proves all three leaks (isInputElement, isSelectorFocused, typeShiftSymbol)
are fixed with CDP isolated worlds.
"""
import asyncio
import pytest
@pytest.mark.slow
class TestIssue110Reproduction:
@pytest.mark.asyncio
async def test_exact_reproduction_from_issue(self):
"""Exact detection script from issue #110 — must produce zero detections."""
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
# === EXACT detection from issue #110 ===
await page.evaluate("""
() => {
window.__detections = {
evaluateQS: [],
untrustedKeydown: []
};
// Detection 1: querySelector from evaluate context
const origQS = document.querySelector.bind(document);
document.querySelector = function(sel) {
try { throw new Error(); } catch (e) {
if (e.stack.includes(':302:')) {
window.__detections.evaluateQS.push(sel);
}
}
return origQS(sel);
};
// Detection 2: untrusted keyboard events
document.addEventListener('keydown', (e) => {
if (!e.isTrusted) {
window.__detections.untrustedKeydown.push(e.key);
}
}, true);
}
""")
# === Trigger all three vectors from issue ===
# Vector 1: isInputElement — click triggers querySelector check
await page.click('#searchInput')
await asyncio.sleep(0.3)
# Vector 2+3: typeShiftSymbol — type text with shift symbols
await page.keyboard.type('Hello!@#$%^&*()')
await asyncio.sleep(0.5)
# === Verify: zero detections ===
detections = await page.evaluate('() => window.__detections')
qs_leaks = detections['evaluateQS']
untrusted = detections['untrustedKeydown']
print(f"\n{'='*60}")
print(f"Issue #110 Reproduction Results:")
print(f" querySelector from evaluate: {len(qs_leaks)} detections")
print(f" Untrusted keyboard events: {len(untrusted)} detections")
print(f"{'='*60}")
assert len(qs_leaks) == 0, (
f"LEAK: querySelector called from evaluate context: {qs_leaks}"
)
assert len(untrusted) == 0, (
f"LEAK: Untrusted keyboard events detected: {untrusted}"
)
await browser.close()
@pytest.mark.asyncio
async def test_all_21_shift_symbols_trusted(self):
"""Every single shift symbol must produce isTrusted=true."""
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
await page.evaluate("""
() => {
window.__keyResults = { trusted: [], untrusted: [] };
const input = document.querySelector('#searchInput');
input.addEventListener('keydown', (e) => {
const list = e.isTrusted ? 'trusted' : 'untrusted';
window.__keyResults[list].push(e.key);
}, true);
}
""")
await page.click('#searchInput')
await asyncio.sleep(0.3)
# Type ALL 21 shift symbols
all_shift = '!@#$%^&*()_+{}|:"<>?~'
await page.keyboard.type(all_shift)
await asyncio.sleep(1)
results = await page.evaluate('() => window.__keyResults')
print(f"\n{'='*60}")
print(f"All 21 Shift Symbols Test:")
print(f" Trusted: {results['trusted']}")
print(f" Untrusted: {results['untrusted']}")
print(f"{'='*60}")
# Every shift symbol must be trusted
for sym in all_shift:
assert sym in results['trusted'], f"'{sym}' NOT in trusted events"
assert sym not in results['untrusted'], f"'{sym}' IS in untrusted events"
await browser.close()
@pytest.mark.asyncio
async def test_clear_uses_isolated_world(self):
"""clear() calls isSelectorFocused — must not leak evaluate."""
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True)
page = await browser.new_page()
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
await asyncio.sleep(1)
await page.evaluate("""
() => {
window.__evalLeaks = [];
const origQS = document.querySelector.bind(document);
document.querySelector = function(sel) {
try { throw new Error(); } catch (e) {
if (e.stack.includes(':302:')) {
window.__evalLeaks.push(sel);
}
}
return origQS(sel);
};
}
""")
# fill → click + type (isInputElement + isSelectorFocused)
await page.locator('#searchInput').fill('some text')
await asyncio.sleep(0.3)
# clear → isSelectorFocused check
await page.locator('#searchInput').clear()
await asyncio.sleep(0.3)
leaks = await page.evaluate('() => window.__evalLeaks')
assert len(leaks) == 0, f"clear() leaked via evaluate: {leaks}"
val = await page.locator('#searchInput').input_value()
assert val == '', f"clear() didn't clear: '{val}'"
await browser.close()
File diff suppressed because it is too large Load Diff