mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c35afec09a | ||
|
|
c6d3469e4c | ||
|
|
cb0b87873e | ||
|
|
2be8cdcc03 | ||
|
|
be9a98db67 | ||
|
|
9b004bbd85 | ||
|
|
5b2981c4c1 | ||
|
|
7afe59435e | ||
|
|
1cef71133d | ||
|
|
7a0937cc54 | ||
|
|
5b00ff0325 | ||
|
|
5dd44298ee | ||
|
|
54d8442f20 | ||
|
|
a01adbe26c | ||
|
|
06d77e7261 | ||
|
|
211bd93d3e | ||
|
|
1060772734 | ||
|
|
8eb2e4b905 | ||
|
|
216a7d6a6a | ||
|
|
02359f69c8 | ||
|
|
a0c7704c4b | ||
|
|
ccda93669e | ||
|
|
eb4efef329 | ||
|
|
25d34dcea3 | ||
|
|
c9e4f58353 | ||
|
|
c58b691f1c | ||
|
|
1b91a33e51 | ||
|
|
1bfd5ca036 |
@@ -4,3 +4,7 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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
|
||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
python:
|
python:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
javascript:
|
javascript:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- 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: 20
|
node-version: 20
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
|
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -80,13 +80,11 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
|
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- 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: 22
|
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
|
||||||
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
|
||||||
@@ -102,14 +100,14 @@ jobs:
|
|||||||
attestations: write
|
attestations: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Extract version
|
- name: Extract version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_PAT }}
|
password: ${{ secrets.DOCKER_PAT }}
|
||||||
|
|||||||
@@ -65,3 +65,10 @@ deploy.sh
|
|||||||
.env
|
.env
|
||||||
debug
|
debug
|
||||||
publish-docker.sh
|
publish-docker.sh
|
||||||
|
captures
|
||||||
|
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
|
||||||
|
|||||||
@@ -6,6 +6,47 @@ 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
@@ -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 .
|
RUN pip install --no-cache-dir ".[serve,geoip]"
|
||||||
|
|
||||||
# JS wrapper
|
# JS wrapper
|
||||||
COPY js/ js/
|
COPY js/ js/
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
- **33 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
|
- **49 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, 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,13 +128,16 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Latest: v0.3.17 (Chromium 145.0.7632.159.7)
|
## Latest: v0.3.24 (Chromium 146.0.7680.177.2)
|
||||||
|
|
||||||
- **`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`
|
- **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.
|
||||||
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior
|
- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177
|
||||||
- **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation
|
- **49 fingerprint patches** — Linux arm64 now matches Linux x64 on Chromium 146
|
||||||
- **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency
|
- **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)
|
||||||
- **33 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
|
- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed
|
||||||
|
- **`cloakserve` CDP multiplexer** — rewritten as a multi-connection CDP proxy with per-connection fingerprint seeds
|
||||||
|
- **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
|
||||||
@@ -153,7 +156,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: Mar 2026 (Chromium 145).
|
All tests verified against live detection services. Last tested: Apr 2026 (Chromium 146).
|
||||||
|
|
||||||
| Detection Service | Stock Playwright | CloakBrowser | Notes |
|
| Detection Service | Stock Playwright | CloakBrowser | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -168,7 +171,7 @@ All tests verified against live detection services. Last tested: Mar 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/145.0.0.0`** | No headless leak |
|
| UA string | `HeadlessChrome` | **`Chrome/146.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** | |
|
||||||
@@ -217,11 +220,11 @@ All tests verified against live detection services. Last tested: Mar 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 145)
|
2. **First launch** → binary auto-downloads for your platform (Chromium 146)
|
||||||
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 33 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking.
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -240,8 +243,9 @@ browser = launch()
|
|||||||
# Headed mode (see the browser window)
|
# Headed mode (see the browser window)
|
||||||
browser = launch(headless=False)
|
browser = launch(headless=False)
|
||||||
|
|
||||||
# With proxy
|
# With proxy (HTTP or SOCKS5)
|
||||||
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"})
|
||||||
@@ -253,11 +257,19 @@ 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)
|
||||||
|
|
||||||
@@ -360,7 +372,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': '145.0.7632.159.2', 'platform': 'linux-x64', 'installed': True, ...}
|
# {'version': '146.0.7680.177.2', 'platform': 'linux-x64', 'installed': True, ...}
|
||||||
|
|
||||||
# Force re-download
|
# Force re-download
|
||||||
clear_cache()
|
clear_cache()
|
||||||
@@ -442,7 +454,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.*`, and the full Locator 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.*`, 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.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
browser = launch(humanize=True)
|
browser = launch(humanize=True)
|
||||||
@@ -453,6 +465,14 @@ 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 });
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -501,9 +521,11 @@ 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:** 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 (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 (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 API coverage.
|
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright and Puppeteer API coverage.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -545,14 +567,10 @@ Every `launch()` call sets these automatically. The **wrapper** applies platform
|
|||||||
|------|--------------|---------------|----------|
|
|------|--------------|---------------|----------|
|
||||||
| `--fingerprint` | Random (10000–99999) | Random (10000–99999) | Master seed for canvas, WebGL, audio, fonts, client rects |
|
| `--fingerprint` | Random (10000–99999) | Random (10000–99999) | 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 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.
|
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.
|
||||||
|
|
||||||
> **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
|
||||||
|
|
||||||
@@ -560,6 +578,8 @@ 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) |
|
||||||
@@ -573,6 +593,8 @@ 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 |
|
||||||
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
|
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
|
||||||
|
|
||||||
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
|
> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production.
|
||||||
@@ -587,11 +609,9 @@ 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 different machine
|
# Override GPU to look like a specific 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",
|
||||||
@@ -627,8 +647,16 @@ 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) |
|
||||||
@@ -645,11 +673,11 @@ browser = await launch_async(args=["--remote-debugging-port=9242"])
|
|||||||
|
|
||||||
| Platform | Chromium | Patches | Status |
|
| Platform | Chromium | Patches | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Linux x86_64 | 145 | 33 | ✅ Latest |
|
| Linux x86_64 | 146 | 49 | ✅ Latest |
|
||||||
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ Latest |
|
| Linux arm64 (RPi, Graviton) | 146 | 49 | ✅ Latest |
|
||||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
|
||||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
|
||||||
| Windows x86_64 | 145 | 33 | ✅ Latest |
|
| Windows x86_64 | 145 | 48 | ✅ |
|
||||||
|
|
||||||
The wrapper auto-downloads the correct binary for your platform.
|
The wrapper auto-downloads the correct binary for your platform.
|
||||||
|
|
||||||
@@ -753,7 +781,28 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
```
|
```
|
||||||
|
|
||||||
Run multiple instances with different fingerprint seeds on different ports — each gets unique canvas noise, client rects, and other browser signals. Pass `--fingerprint=<seed>` in the command (e.g., `cloakserve --fingerprint=12345`).
|
**Per-connection fingerprint seeds** — run multiple browser identities from a single container. Each unique seed spawns a separate Chrome process with its own fingerprint:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Each seed gets unique canvas noise, client rects, and other browser signals
|
||||||
|
b1 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=11111")
|
||||||
|
b2 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=22222")
|
||||||
|
|
||||||
|
# Full identity control via query params
|
||||||
|
b3 = pw.chromium.connect_over_cdp(
|
||||||
|
"http://localhost:9222?fingerprint=33333"
|
||||||
|
"&timezone=Asia/Tokyo&locale=ja-JP&platform=macos"
|
||||||
|
"&hardware-concurrency=4&device-memory=8"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auto-detect timezone/locale from proxy exit IP
|
||||||
|
b4 = pw.chromium.connect_over_cdp(
|
||||||
|
"http://localhost:9222?fingerprint=44444"
|
||||||
|
"&proxy=http://proxy:8080&geoip=true"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported query params: `fingerprint`, `timezone`, `locale`, `platform`, `platform-version`, `brand`, `brand-version`, `gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`, `screen-width`, `screen-height`, `proxy`, `geoip`. Same seed reuses the same process (first connection's params win). No seed = shared default process (backward compatible). Check active processes at `GET /` (returns JSON with PIDs, ports, and connection counts).
|
||||||
|
|
||||||
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
|
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
|
||||||
|
|
||||||
@@ -889,9 +938,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.11 # Python
|
pip install cloakbrowser==0.3.21 # Python
|
||||||
npm install cloakbrowser@0.3.11 # JavaScript
|
npm install cloakbrowser@0.3.21 # JavaScript
|
||||||
docker pull cloakhq/cloakbrowser:0.3.11 # Docker
|
docker pull cloakhq/cloakbrowser:0.3.21 # 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.
|
||||||
|
|
||||||
@@ -981,15 +1030,15 @@ 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"` to `launch()`.
|
A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@host:port"` to `launch()`. Both HTTP and SOCKS5 proxies are supported natively.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Linux x64 — Chromium 145 (26 patches) | ✅ Released |
|
| Linux x64 — Chromium 146 (49 patches) | ✅ Released |
|
||||||
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
|
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||||
| Windows x64 — Chromium 145 (26 patches) | ✅ Released |
|
| Windows x64 — Chromium 145 (33 patches) | ✅ Released |
|
||||||
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
| JavaScript/Puppeteer + Playwright support | ✅ Released |
|
||||||
| Fingerprint rotation per session | ✅ Released |
|
| Fingerprint rotation per session | ✅ Released |
|
||||||
| Built-in proxy rotation | 📋 Planned |
|
| Built-in proxy rotation | 📋 Planned |
|
||||||
@@ -1011,7 +1060,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-v145.0.7632.159.7
|
git verify-tag chromium-v146.0.7680.177.2
|
||||||
|
|
||||||
# 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
|
||||||
@@ -1036,3 +1085,4 @@ 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
|
||||||
|
|||||||
+643
-30
@@ -1,31 +1,54 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Launch stealth Chromium as a CDP server for remote connections.
|
"""CDP multiplexer — per-connection fingerprint seeds for stealth Chromium.
|
||||||
|
|
||||||
|
Spawns a separate Chrome process per unique fingerprint seed, routing CDP
|
||||||
|
connections through a single port. Each seed gets its own browser identity.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
cloakserve # headless on port 9222
|
cloakserve # default, backward compat
|
||||||
cloakserve --headless=false # headed (uses Xvfb in Docker)
|
cloakserve --port=9222 # custom port
|
||||||
cloakserve --proxy-server=host:port # with proxy
|
|
||||||
|
|
||||||
Connect from host:
|
Client:
|
||||||
playwright.chromium.connect_over_cdp("http://localhost:9222")
|
browser = pw.chromium.connect_over_cdp("http://host:9222?fingerprint=12345")
|
||||||
|
browser = pw.chromium.connect_over_cdp(
|
||||||
|
"http://host:9222?fingerprint=12345&timezone=America/New_York&locale=en-US"
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
import signal
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
from cloakbrowser.config import get_default_stealth_args
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import websockets
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args
|
||||||
from cloakbrowser.download import ensure_binary
|
from cloakbrowser.download import ensure_binary
|
||||||
|
|
||||||
PORT = 9222
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("cloakserve")
|
||||||
|
|
||||||
binary = ensure_binary()
|
# Args for running Chrome directly (outside Playwright).
|
||||||
|
# Playwright normally adds its own version of these.
|
||||||
chrome_args = [
|
BASE_CHROME_ARGS = [
|
||||||
binary,
|
|
||||||
f"--remote-debugging-port={PORT}",
|
|
||||||
"--remote-debugging-address=0.0.0.0",
|
|
||||||
# Sane defaults for running Chrome directly (outside Playwright)
|
|
||||||
"--no-first-run",
|
"--no-first-run",
|
||||||
"--no-default-browser-check",
|
"--no-default-browser-check",
|
||||||
"--disable-dev-shm-usage",
|
"--disable-dev-shm-usage",
|
||||||
@@ -33,24 +56,614 @@ chrome_args = [
|
|||||||
"--disable-popup-blocking",
|
"--disable-popup-blocking",
|
||||||
"--disable-background-networking",
|
"--disable-background-networking",
|
||||||
"--metrics-recording-only",
|
"--metrics-recording-only",
|
||||||
# GPU blocklist bypass: Chromium blocks WebGL on software GPUs in
|
|
||||||
# Docker/Xvfb. Without this, WebGL vendor/renderer spoofing fails. #58
|
|
||||||
"--ignore-gpu-blocklist",
|
"--ignore-gpu-blocklist",
|
||||||
] + get_default_stealth_args() + sys.argv[1:]
|
]
|
||||||
|
|
||||||
chrome = subprocess.Popen(chrome_args)
|
BASE_CDP_PORT = 5100
|
||||||
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
print(f"CloakBrowser CDP server ready on port {PORT}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup(sig, frame):
|
# ---------------------------------------------------------------------------
|
||||||
chrome.terminate()
|
# ChromeProcess — one running Chrome instance
|
||||||
sys.exit(0)
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChromeProcess:
|
||||||
|
seed: str
|
||||||
|
process: subprocess.Popen
|
||||||
|
cdp_port: int
|
||||||
|
user_data_dir: str
|
||||||
|
timezone: str | None = None
|
||||||
|
locale: str | None = None
|
||||||
|
proxy: str | None = None
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, cleanup)
|
# ---------------------------------------------------------------------------
|
||||||
signal.signal(signal.SIGINT, cleanup)
|
# ChromePool — manages multiple Chrome processes keyed by seed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
chrome.wait()
|
class ChromePool:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
binary: str,
|
||||||
|
global_args: list[str],
|
||||||
|
headless: bool,
|
||||||
|
data_dir: str = "/tmp/cloakserve",
|
||||||
|
default_seed: str | None = None,
|
||||||
|
default_locale: str | None = None,
|
||||||
|
default_timezone: str | None = None,
|
||||||
|
):
|
||||||
|
self._binary = binary
|
||||||
|
self._global_args = global_args
|
||||||
|
self._headless = headless
|
||||||
|
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._default: ChromeProcess | None = None
|
||||||
|
self._locks: dict[str, asyncio.Lock] = {}
|
||||||
|
self._next_port = BASE_CDP_PORT
|
||||||
|
# Connection refcounting for status reporting
|
||||||
|
self._connections: dict[str, int] = {}
|
||||||
|
|
||||||
|
def _get_lock(self, seed: str) -> asyncio.Lock:
|
||||||
|
if seed not in self._locks:
|
||||||
|
self._locks[seed] = asyncio.Lock()
|
||||||
|
return self._locks[seed]
|
||||||
|
|
||||||
|
def _allocate_port(self) -> int:
|
||||||
|
"""Find a free port starting from _next_port."""
|
||||||
|
for _ in range(100):
|
||||||
|
port = self._next_port
|
||||||
|
self._next_port += 1
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("127.0.0.1", port))
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError("No free ports available for Chrome CDP")
|
||||||
|
|
||||||
|
def connect(self, seed_key: str) -> None:
|
||||||
|
"""Increment connection refcount for a seed."""
|
||||||
|
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
|
||||||
|
|
||||||
|
def disconnect(self, seed_key: str) -> None:
|
||||||
|
"""Decrement connection refcount for a seed."""
|
||||||
|
count = self._connections.get(seed_key, 0) - 1
|
||||||
|
if count <= 0:
|
||||||
|
self._connections.pop(seed_key, None)
|
||||||
|
else:
|
||||||
|
self._connections[seed_key] = count
|
||||||
|
|
||||||
|
async def get_or_launch(
|
||||||
|
self,
|
||||||
|
seed: str | None,
|
||||||
|
extra_args: list[str] | None = None,
|
||||||
|
timezone: str | None = None,
|
||||||
|
locale: str | None = None,
|
||||||
|
proxy: str | None = None,
|
||||||
|
geoip: bool = False,
|
||||||
|
) -> ChromeProcess:
|
||||||
|
"""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
|
||||||
|
if seed is None:
|
||||||
|
seed_key = "__default__"
|
||||||
|
actual_seed = str(random.randint(10000, 99999))
|
||||||
|
else:
|
||||||
|
seed_key = seed
|
||||||
|
actual_seed = seed
|
||||||
|
|
||||||
|
lock = self._get_lock(seed_key)
|
||||||
|
async with lock:
|
||||||
|
# Check if already running (including default fast-path)
|
||||||
|
if seed_key in self._processes:
|
||||||
|
proc = self._processes[seed_key]
|
||||||
|
if proc.process.poll() is None:
|
||||||
|
if any([extra_args, timezone, locale, proxy, geoip]):
|
||||||
|
logger.warning(
|
||||||
|
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
|
||||||
|
"ignoring new params (first-launch wins)",
|
||||||
|
seed_key, proc.cdp_port,
|
||||||
|
proc.timezone, proc.locale, proc.proxy,
|
||||||
|
)
|
||||||
|
return proc
|
||||||
|
# Dead — clean up
|
||||||
|
await self._cleanup_process(seed_key)
|
||||||
|
|
||||||
|
# Resolve geoip if requested
|
||||||
|
exit_ip = None
|
||||||
|
if geoip and proxy:
|
||||||
|
timezone, locale, exit_ip = maybe_resolve_geoip(True, proxy, timezone, locale)
|
||||||
|
|
||||||
|
# Build Chrome args via shared logic
|
||||||
|
fp_extra = [f"--fingerprint={actual_seed}"]
|
||||||
|
if extra_args:
|
||||||
|
fp_extra.extend(extra_args)
|
||||||
|
if 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(
|
||||||
|
stealth_args=True,
|
||||||
|
extra_args=fp_extra,
|
||||||
|
timezone=timezone,
|
||||||
|
locale=locale,
|
||||||
|
headless=self._headless,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allocate port and user data dir
|
||||||
|
port = self._allocate_port()
|
||||||
|
user_data_dir = os.path.join(self._data_dir, seed_key)
|
||||||
|
os.makedirs(user_data_dir, exist_ok=True)
|
||||||
|
|
||||||
|
full_args = (
|
||||||
|
[self._binary]
|
||||||
|
+ BASE_CHROME_ARGS
|
||||||
|
+ chrome_args
|
||||||
|
+ self._global_args
|
||||||
|
+ [
|
||||||
|
f"--remote-debugging-port={port}",
|
||||||
|
"--remote-debugging-address=127.0.0.1",
|
||||||
|
f"--user-data-dir={user_data_dir}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Launching Chrome (seed=%s, port=%d)", actual_seed, port)
|
||||||
|
process = subprocess.Popen(
|
||||||
|
full_args,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for CDP to be ready
|
||||||
|
if not await self._wait_for_cdp(port):
|
||||||
|
process.kill()
|
||||||
|
await asyncio.to_thread(process.wait, timeout=5)
|
||||||
|
await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
|
||||||
|
raise web.HTTPBadGateway(
|
||||||
|
text=json.dumps({"error": "Chrome failed to start"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
cp = ChromeProcess(
|
||||||
|
seed=actual_seed,
|
||||||
|
process=process,
|
||||||
|
cdp_port=port,
|
||||||
|
user_data_dir=user_data_dir,
|
||||||
|
timezone=timezone,
|
||||||
|
locale=locale,
|
||||||
|
proxy=proxy,
|
||||||
|
)
|
||||||
|
self._processes[seed_key] = cp
|
||||||
|
|
||||||
|
if seed is None:
|
||||||
|
self._default = cp
|
||||||
|
|
||||||
|
logger.info("Chrome ready (seed=%s, port=%d, pid=%d)", actual_seed, port, process.pid)
|
||||||
|
return cp
|
||||||
|
|
||||||
|
async def _cleanup_process(self, key: str) -> None:
|
||||||
|
"""Terminate a Chrome process and clean up."""
|
||||||
|
proc = self._processes.pop(key, None)
|
||||||
|
if not proc:
|
||||||
|
return
|
||||||
|
if proc.process.poll() is None:
|
||||||
|
proc.process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(proc.process.wait, timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.process.kill()
|
||||||
|
# Clean up user data dir (can be slow for large profiles)
|
||||||
|
await asyncio.to_thread(shutil.rmtree, proc.user_data_dir, True)
|
||||||
|
if self._default is proc:
|
||||||
|
self._default = None
|
||||||
|
self._locks.pop(key, None)
|
||||||
|
self._connections.pop(key, None)
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
"""Terminate all Chrome processes."""
|
||||||
|
for key in list(self._processes.keys()):
|
||||||
|
await self._cleanup_process(key)
|
||||||
|
logger.info("All Chrome processes terminated")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _wait_for_cdp(port: int, timeout: float = 10.0) -> bool:
|
||||||
|
"""Poll Chrome's /json/version until ready."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
delay = 0.1
|
||||||
|
session = aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(total=1)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
async with session.get(
|
||||||
|
f"http://127.0.0.1:{port}/json/version"
|
||||||
|
) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay = min(delay * 2, 1.0)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Query param parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Params that need special handling (not simple --fingerprint-{name}= mapping)
|
||||||
|
SPECIAL_PARAMS = {"fingerprint", "proxy", "geoip", "locale", "timezone"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_connection_params(query_string: str) -> dict:
|
||||||
|
"""Parse query params into connection config."""
|
||||||
|
qs = parse_qs(query_string, keep_blank_values=False)
|
||||||
|
|
||||||
|
result: dict = {
|
||||||
|
"seed": None,
|
||||||
|
"timezone": None,
|
||||||
|
"locale": None,
|
||||||
|
"proxy": None,
|
||||||
|
"geoip": False,
|
||||||
|
"extra_args": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, values in qs.items():
|
||||||
|
val = values[0]
|
||||||
|
if key == "fingerprint":
|
||||||
|
result["seed"] = val
|
||||||
|
elif key == "timezone":
|
||||||
|
result["timezone"] = val
|
||||||
|
elif key == "locale":
|
||||||
|
result["locale"] = val
|
||||||
|
elif key == "proxy":
|
||||||
|
result["proxy"] = val
|
||||||
|
elif key == "geoip":
|
||||||
|
result["geoip"] = val.lower() in ("true", "1", "yes")
|
||||||
|
elif key not in SPECIAL_PARAMS:
|
||||||
|
# Generic fingerprint param: map to --fingerprint-{key}={val}
|
||||||
|
result["extra_args"].append(f"--fingerprint-{key}={val}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ws_scheme(request: web.Request) -> str:
|
||||||
|
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
|
||||||
|
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||||
|
return "wss" if proto == "https" else "ws"
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_root(request: web.Request) -> web.Response:
|
||||||
|
"""Health check / process status."""
|
||||||
|
pool: ChromePool = request.app["pool"]
|
||||||
|
processes = {}
|
||||||
|
for key, proc in pool._processes.items():
|
||||||
|
if proc.process.poll() is None:
|
||||||
|
processes[key] = {
|
||||||
|
"pid": proc.process.pid,
|
||||||
|
"port": proc.cdp_port,
|
||||||
|
"seed": proc.seed,
|
||||||
|
"connections": pool._connections.get(key, 0),
|
||||||
|
"timezone": proc.timezone,
|
||||||
|
"locale": proc.locale,
|
||||||
|
"proxy": proc.proxy,
|
||||||
|
}
|
||||||
|
return web.json_response({
|
||||||
|
"status": "ok",
|
||||||
|
"active": len(processes),
|
||||||
|
"processes": processes,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_json_version(request: web.Request) -> web.Response:
|
||||||
|
"""Proxy /json/version with optional per-seed routing."""
|
||||||
|
pool: ChromePool = request.app["pool"]
|
||||||
|
params = parse_connection_params(request.query_string)
|
||||||
|
|
||||||
|
cp = await pool.get_or_launch(
|
||||||
|
seed=params["seed"],
|
||||||
|
extra_args=params["extra_args"] or None,
|
||||||
|
timezone=params["timezone"],
|
||||||
|
locale=params["locale"],
|
||||||
|
proxy=params["proxy"],
|
||||||
|
geoip=params["geoip"],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"http://127.0.0.1:{cp.cdp_port}/json/version",
|
||||||
|
timeout=aiohttp.ClientTimeout(total=5),
|
||||||
|
) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
|
||||||
|
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
||||||
|
|
||||||
|
# Rewrite webSocketDebuggerUrl to route through our multiplexer
|
||||||
|
host = request.headers.get("Host", f"localhost:{request.app['port']}")
|
||||||
|
seed_key = params["seed"]
|
||||||
|
if seed_key:
|
||||||
|
ws_path = f"fingerprint/{seed_key}/devtools/browser"
|
||||||
|
else:
|
||||||
|
ws_path = "devtools/browser"
|
||||||
|
|
||||||
|
# Extract the browser GUID from Chrome's original URL
|
||||||
|
orig_ws = data.get("webSocketDebuggerUrl", "")
|
||||||
|
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
|
||||||
|
|
||||||
|
scheme = _ws_scheme(request)
|
||||||
|
data["webSocketDebuggerUrl"] = f"{scheme}://{host}/{ws_path}/{guid}"
|
||||||
|
return web.json_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_json_list(request: web.Request) -> web.Response:
|
||||||
|
"""Proxy /json/list with per-seed routing. Rewrites all entries."""
|
||||||
|
pool: ChromePool = request.app["pool"]
|
||||||
|
params = parse_connection_params(request.query_string)
|
||||||
|
|
||||||
|
cp = await pool.get_or_launch(
|
||||||
|
seed=params["seed"],
|
||||||
|
extra_args=params["extra_args"] or None,
|
||||||
|
timezone=params["timezone"],
|
||||||
|
locale=params["locale"],
|
||||||
|
proxy=params["proxy"],
|
||||||
|
geoip=params["geoip"],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"http://127.0.0.1:{cp.cdp_port}/json/list",
|
||||||
|
timeout=aiohttp.ClientTimeout(total=5),
|
||||||
|
) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
|
||||||
|
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
||||||
|
|
||||||
|
host = request.headers.get("Host", f"localhost:{request.app['port']}")
|
||||||
|
scheme = _ws_scheme(request)
|
||||||
|
seed_key = params["seed"]
|
||||||
|
|
||||||
|
for entry in data:
|
||||||
|
if "webSocketDebuggerUrl" in entry:
|
||||||
|
ws_tail = entry["webSocketDebuggerUrl"].split("/devtools/")[-1]
|
||||||
|
if seed_key:
|
||||||
|
entry["webSocketDebuggerUrl"] = (
|
||||||
|
f"{scheme}://{host}/fingerprint/{seed_key}/devtools/{ws_tail}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entry["webSocketDebuggerUrl"] = f"{scheme}://{host}/devtools/{ws_tail}"
|
||||||
|
|
||||||
|
return web.json_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WebSocket proxy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def proxy_cdp_websocket(
|
||||||
|
client_ws: web.WebSocketResponse,
|
||||||
|
target_url: str,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
"""Bidirectional WebSocket proxy between client and Chrome CDP."""
|
||||||
|
try:
|
||||||
|
async with websockets.connect(
|
||||||
|
target_url, max_size=None, ping_interval=None, ping_timeout=None,
|
||||||
|
) as cdp_ws:
|
||||||
|
logger.info("%s: connected to %s", label, target_url)
|
||||||
|
|
||||||
|
async def client_to_cdp():
|
||||||
|
try:
|
||||||
|
async for msg in client_ws:
|
||||||
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
await cdp_ws.send(msg.data)
|
||||||
|
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||||
|
await cdp_ws.send(msg.data)
|
||||||
|
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("%s [c->cdp]: %s", label, exc)
|
||||||
|
|
||||||
|
async def cdp_to_client():
|
||||||
|
try:
|
||||||
|
async for msg in cdp_ws:
|
||||||
|
if isinstance(msg, str):
|
||||||
|
await client_ws.send_str(msg)
|
||||||
|
else:
|
||||||
|
await client_ws.send_bytes(msg)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("%s [cdp->c]: %s", label, exc)
|
||||||
|
|
||||||
|
c2d = asyncio.create_task(client_to_cdp(), name="c2d")
|
||||||
|
d2c = asyncio.create_task(cdp_to_client(), name="d2c")
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[c2d, d2c], return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
logger.info("%s: disconnected", label)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("%s error: %s", label, exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_ws_default(request: web.Request) -> web.WebSocketResponse:
|
||||||
|
"""WebSocket proxy for default (no-seed) Chrome: /devtools/{type}/{guid}"""
|
||||||
|
pool: ChromePool = request.app["pool"]
|
||||||
|
path = request.match_info.get("path", "")
|
||||||
|
|
||||||
|
cp = await pool.get_or_launch(seed=None)
|
||||||
|
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
pool.connect("__default__")
|
||||||
|
try:
|
||||||
|
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
|
||||||
|
await proxy_cdp_websocket(ws, target_url, f"CDP default [{path}]")
|
||||||
|
finally:
|
||||||
|
pool.disconnect("__default__")
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_ws_seed(request: web.Request) -> web.WebSocketResponse:
|
||||||
|
"""WebSocket proxy for seed-specific Chrome: /fingerprint/{seed}/devtools/{type}/{guid}"""
|
||||||
|
pool: ChromePool = request.app["pool"]
|
||||||
|
seed = request.match_info["seed"]
|
||||||
|
path = request.match_info.get("path", "")
|
||||||
|
|
||||||
|
cp = await pool.get_or_launch(seed=seed)
|
||||||
|
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
pool.connect(seed)
|
||||||
|
try:
|
||||||
|
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
|
||||||
|
await proxy_cdp_websocket(ws, target_url, f"CDP seed={seed} [{path}]")
|
||||||
|
finally:
|
||||||
|
pool.disconnect(seed)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
async def on_shutdown(app: web.Application) -> None:
|
||||||
|
await app["pool"].shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI arg parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _default_data_dir() -> str:
|
||||||
|
"""Smart default: Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
|
||||||
|
if os.path.exists("/.dockerenv"):
|
||||||
|
return "/tmp/cloakserve"
|
||||||
|
return str(Path.home() / ".cloakbrowser" / "cloakserve")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
|
||||||
|
"""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 = {
|
||||||
|
"port": 9222,
|
||||||
|
"headless": True,
|
||||||
|
"data_dir": None,
|
||||||
|
"default_seed": None,
|
||||||
|
"default_locale": None,
|
||||||
|
"default_timezone": None,
|
||||||
|
}
|
||||||
|
passthrough = []
|
||||||
|
# Flags consumed by cloakserve (not passed to Chrome)
|
||||||
|
consumed_prefixes = (
|
||||||
|
"--port=",
|
||||||
|
"--data-dir=",
|
||||||
|
"--remote-debugging-port=",
|
||||||
|
"--remote-debugging-address=",
|
||||||
|
)
|
||||||
|
|
||||||
|
for arg in argv:
|
||||||
|
if arg.startswith("--port="):
|
||||||
|
config["port"] = int(arg.split("=", 1)[1])
|
||||||
|
elif arg.startswith("--data-dir="):
|
||||||
|
config["data_dir"] = arg.split("=", 1)[1]
|
||||||
|
elif arg == "--headless=false" or arg == "--headless=False":
|
||||||
|
config["headless"] = False
|
||||||
|
passthrough.append(arg)
|
||||||
|
elif arg.startswith(consumed_prefixes):
|
||||||
|
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:
|
||||||
|
passthrough.append(arg)
|
||||||
|
|
||||||
|
if config["data_dir"] is None:
|
||||||
|
config["data_dir"] = _default_data_dir()
|
||||||
|
|
||||||
|
return config, passthrough
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
binary = ensure_binary()
|
||||||
|
config, global_args = parse_cli_args(sys.argv[1:])
|
||||||
|
|
||||||
|
pool = ChromePool(
|
||||||
|
binary=binary,
|
||||||
|
global_args=global_args,
|
||||||
|
headless=config["headless"],
|
||||||
|
data_dir=config["data_dir"],
|
||||||
|
default_seed=config["default_seed"],
|
||||||
|
default_locale=config["default_locale"],
|
||||||
|
default_timezone=config["default_timezone"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app["pool"] = pool
|
||||||
|
app["port"] = config["port"]
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
app.router.add_get("/", handle_root)
|
||||||
|
app.router.add_get("/json/version", handle_json_version)
|
||||||
|
app.router.add_get("/json/version/", handle_json_version)
|
||||||
|
app.router.add_get("/json/list", handle_json_list)
|
||||||
|
app.router.add_get("/json/list/", handle_json_list)
|
||||||
|
app.router.add_get("/json", handle_json_list)
|
||||||
|
app.router.add_get("/json/", handle_json_list)
|
||||||
|
|
||||||
|
# WebSocket routes — seed-specific (must be before default to match first)
|
||||||
|
app.router.add_get("/fingerprint/{seed}/devtools/{path:.+}", handle_ws_seed)
|
||||||
|
# WebSocket routes — default (no seed)
|
||||||
|
app.router.add_get("/devtools/{path:.+}", handle_ws_default)
|
||||||
|
|
||||||
|
app.on_shutdown.append(on_shutdown)
|
||||||
|
|
||||||
|
port = config["port"]
|
||||||
|
logger.info("CloakBrowser CDP multiplexer starting on port %d", port)
|
||||||
|
logger.info(
|
||||||
|
"Connect: playwright.chromium.connect_over_cdp("
|
||||||
|
"\"http://localhost:%d?fingerprint=<seed>\")",
|
||||||
|
port,
|
||||||
|
)
|
||||||
|
|
||||||
|
web.run_app(app, host="0.0.0.0", port=port, print=None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Usage:
|
|||||||
browser.close()
|
browser.close()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings
|
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
|
||||||
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
||||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
@@ -40,6 +40,8 @@ __all__ = [
|
|||||||
"check_for_update",
|
"check_for_update",
|
||||||
"CHROMIUM_VERSION",
|
"CHROMIUM_VERSION",
|
||||||
"get_default_stealth_args",
|
"get_default_stealth_args",
|
||||||
|
"build_args",
|
||||||
|
"maybe_resolve_geoip",
|
||||||
"ProxySettings",
|
"ProxySettings",
|
||||||
"HumanConfig",
|
"HumanConfig",
|
||||||
"resolve_human_config",
|
"resolve_human_config",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.19"
|
__version__ = "0.3.24"
|
||||||
|
|||||||
+202
-37
@@ -17,13 +17,16 @@ 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 unquote, urlparse, urlunparse
|
from urllib.parse import quote, 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."""
|
||||||
@@ -101,8 +104,13 @@ 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 = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||||
|
args = _resolve_webrtc_args(args, proxy)
|
||||||
|
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||||
|
args = list(args or [])
|
||||||
|
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||||
|
chrome_args = build_args(stealth_args, (args 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))
|
||||||
|
|
||||||
@@ -112,7 +120,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,
|
||||||
**_build_proxy_kwargs(proxy),
|
**proxy_kwargs,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,8 +194,13 @@ 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 = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||||
|
args = _resolve_webrtc_args(args, proxy)
|
||||||
|
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||||
|
args = list(args or [])
|
||||||
|
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||||
|
chrome_args = build_args(stealth_args, (args 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))
|
||||||
|
|
||||||
@@ -197,7 +210,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,
|
||||||
**_build_proxy_kwargs(proxy),
|
**proxy_kwargs,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -229,7 +242,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 = None,
|
viewport: dict | None = _VIEWPORT_UNSET,
|
||||||
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,
|
||||||
@@ -256,6 +269,7 @@ 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'.
|
||||||
@@ -284,8 +298,13 @@ def launch_persistent_context(
|
|||||||
timezone = _resolve_timezone(timezone, kwargs)
|
timezone = _resolve_timezone(timezone, kwargs)
|
||||||
|
|
||||||
binary_path = ensure_binary()
|
binary_path = ensure_binary()
|
||||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||||
|
args = _resolve_webrtc_args(args, proxy)
|
||||||
|
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||||
|
args = list(args or [])
|
||||||
|
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||||
|
chrome_args = build_args(stealth_args, (args 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)",
|
||||||
@@ -298,7 +317,12 @@ 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
|
||||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
if viewport is _VIEWPORT_UNSET:
|
||||||
|
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)
|
||||||
@@ -310,7 +334,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,
|
||||||
**_build_proxy_kwargs(proxy),
|
**proxy_kwargs,
|
||||||
**context_kwargs,
|
**context_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -342,7 +366,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 = None,
|
viewport: dict | None = _VIEWPORT_UNSET,
|
||||||
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,
|
||||||
@@ -368,6 +392,7 @@ 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'.
|
||||||
@@ -399,8 +424,13 @@ 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 = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||||
|
args = _resolve_webrtc_args(args, proxy)
|
||||||
|
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||||
|
args = list(args or [])
|
||||||
|
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||||
|
chrome_args = build_args(stealth_args, (args 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)",
|
||||||
@@ -413,7 +443,12 @@ 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
|
||||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
if viewport is _VIEWPORT_UNSET:
|
||||||
|
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)
|
||||||
@@ -425,7 +460,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,
|
||||||
**_build_proxy_kwargs(proxy),
|
**proxy_kwargs,
|
||||||
**context_kwargs,
|
**context_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -456,7 +491,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 = None,
|
viewport: dict | None = _VIEWPORT_UNSET,
|
||||||
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,
|
||||||
@@ -479,6 +514,7 @@ 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'.
|
||||||
@@ -497,7 +533,11 @@ 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 = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
timezone, locale, exit_ip = 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.
|
||||||
@@ -507,7 +547,12 @@ 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
|
||||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
if viewport is _VIEWPORT_UNSET:
|
||||||
|
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)
|
||||||
@@ -590,31 +635,120 @@ 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 _maybe_resolve_geoip(
|
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(
|
||||||
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]:
|
) -> tuple[str | None, 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
|
|
||||||
|
|
||||||
from .geoip import resolve_proxy_geo
|
Returns ``(timezone, locale, exit_ip)``. *exit_ip* is a free bonus
|
||||||
|
from the geoip lookup (no extra HTTP call) — used for WebRTC spoofing.
|
||||||
|
"""
|
||||||
|
if not geoip or not proxy:
|
||||||
|
return timezone, locale, None
|
||||||
|
|
||||||
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
|
from .geoip import resolve_proxy_geo_with_ip
|
||||||
|
|
||||||
|
proxy_url = _extract_proxy_url(proxy)
|
||||||
if not proxy_url:
|
if not proxy_url:
|
||||||
return timezone, locale
|
return timezone, locale, None
|
||||||
proxy_url = _ensure_proxy_scheme(proxy_url)
|
|
||||||
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
|
# When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||||
|
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
|
return timezone, locale, exit_ip
|
||||||
|
|
||||||
|
|
||||||
def _build_args(
|
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(
|
||||||
stealth_args: bool,
|
stealth_args: bool,
|
||||||
extra_args: list[str] | None,
|
extra_args: list[str] | None,
|
||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
@@ -698,10 +832,41 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
|
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
|
||||||
"""Build proxy kwargs for Playwright launch."""
|
"""Check if the proxy uses SOCKS5 protocol."""
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
return {}
|
return False
|
||||||
|
url = proxy.get("server", "") if isinstance(proxy, dict) else proxy
|
||||||
|
return url.lower().startswith(("socks5://", "socks5h://"))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_proxy_config(
|
||||||
|
proxy: str | ProxySettings | None,
|
||||||
|
) -> tuple[dict[str, Any], list[str]]:
|
||||||
|
"""Resolve proxy into Playwright kwargs and Chrome args.
|
||||||
|
|
||||||
|
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
||||||
|
so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(proxy_kwargs, extra_chrome_args) — one or both will be empty.
|
||||||
|
"""
|
||||||
|
if proxy is None:
|
||||||
|
return {}, []
|
||||||
|
|
||||||
|
if _is_socks_proxy(proxy):
|
||||||
|
# SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
||||||
|
# Chrome handles SOCKS5 auth natively from the URL.
|
||||||
if isinstance(proxy, dict):
|
if isinstance(proxy, dict):
|
||||||
return {"proxy": proxy}
|
url = _reconstruct_socks_url(proxy)
|
||||||
return {"proxy": _parse_proxy_url(proxy)}
|
extra_args = [f"--proxy-server={url}"]
|
||||||
|
if proxy.get("bypass"):
|
||||||
|
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
|
||||||
|
return {}, extra_args
|
||||||
|
# String URL — pass as-is (Chrome handles user:pass@ in the URL)
|
||||||
|
return {}, [f"--proxy-server={proxy}"]
|
||||||
|
|
||||||
|
# HTTP/HTTPS: use Playwright's proxy dict as before
|
||||||
|
if isinstance(proxy, dict):
|
||||||
|
return {"proxy": proxy}, []
|
||||||
|
return {"proxy": _parse_proxy_url(proxy)}, []
|
||||||
|
|||||||
+6
-15
@@ -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 = "145.0.7632.159.8"
|
CHROMIUM_VERSION = "146.0.7680.177.1"
|
||||||
|
|
||||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||||
"linux-x64": "145.0.7632.159.8",
|
"linux-x64": "146.0.7680.177.2",
|
||||||
"linux-arm64": "145.0.7632.159.7",
|
"linux-arm64": "146.0.7680.177.2",
|
||||||
"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,26 +48,17 @@ 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 + [
|
return base + ["--fingerprint-platform=macos"]
|
||||||
"--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, and window size are
|
# Hardware concurrency, device memory, screen, window size, and GPU are
|
||||||
# auto-generated by the binary from the seed (v14+).
|
# auto-generated by the binary from the seed (v14+).
|
||||||
return base + [
|
return base + ["--fingerprint-platform=windows"]
|
||||||
"--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)",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+24
-7
@@ -53,6 +53,18 @@ 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:
|
||||||
@@ -63,14 +75,14 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
|
|||||||
|
|
||||||
db_path = _ensure_geoip_db()
|
db_path = _ensure_geoip_db()
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
return None, None
|
return None, 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
|
return None, None, None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with geoip2.database.Reader(str(db_path)) as reader:
|
with geoip2.database.Reader(str(db_path)) as reader:
|
||||||
@@ -82,10 +94,10 @@ def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
|
|||||||
"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
|
return timezone, locale, ip
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("GeoIP lookup failed for %s: %s", ip, exc)
|
logger.warning("GeoIP lookup failed for %s: %s", ip, exc)
|
||||||
return None, None
|
return None, None, ip
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -120,7 +132,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.debug("Failed to resolve proxy hostname: %s", exc)
|
logger.warning("Failed to resolve proxy hostname: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -153,9 +165,14 @@ 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.debug("Failed to discover exit IP through proxy")
|
logger.warning("Failed to discover exit IP through proxy")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+923
-11
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,14 @@
|
|||||||
"""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, Protocol
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
from .config import HumanConfig, rand, rand_range, sleep_ms
|
from .config import HumanConfig, rand, rand_range, sleep_ms
|
||||||
|
|
||||||
@@ -28,6 +33,25 @@ 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."""
|
||||||
@@ -39,7 +63,17 @@ def _get_nearby_key(ch: str) -> str:
|
|||||||
return ch
|
return ch
|
||||||
|
|
||||||
|
|
||||||
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
|
def human_type(
|
||||||
|
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():
|
||||||
@@ -62,7 +96,7 @@ def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None
|
|||||||
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)
|
_type_shift_symbol(page, raw, ch, cfg, cdp_session)
|
||||||
else:
|
else:
|
||||||
_type_normal_char(raw, ch, cfg)
|
_type_normal_char(raw, ch, cfg)
|
||||||
|
|
||||||
@@ -86,7 +120,50 @@ def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -
|
|||||||
raw.up("Shift")
|
raw.up("Shift")
|
||||||
|
|
||||||
|
|
||||||
def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
def _type_shift_symbol(
|
||||||
|
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)
|
||||||
|
|||||||
@@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
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, Protocol
|
from typing import Any, Optional, 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):
|
||||||
@@ -20,7 +24,17 @@ class AsyncRawKeyboard(Protocol):
|
|||||||
async def insert_text(self, text: str) -> None: ...
|
async def insert_text(self, text: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
|
async def async_human_type(
|
||||||
|
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():
|
||||||
@@ -43,7 +57,7 @@ async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: Hum
|
|||||||
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)
|
await _type_shift_symbol(page, raw, ch, cfg, cdp_session)
|
||||||
else:
|
else:
|
||||||
await _type_normal_char(raw, ch, cfg)
|
await _type_normal_char(raw, ch, cfg)
|
||||||
|
|
||||||
@@ -67,7 +81,50 @@ 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(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
async def _type_shift_symbol(
|
||||||
|
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)
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
browser-use handles AI agent logic, CloakBrowser handles bot detection.
|
browser-use handles AI agent logic, CloakBrowser handles bot detection.
|
||||||
Your agent can now browse sites behind Cloudflare, reCAPTCHA, DataDome.
|
Your agent can now browse sites behind Cloudflare, reCAPTCHA, DataDome.
|
||||||
|
|
||||||
Requires: pip install browser-use cloakbrowser langchain-openai
|
Requires: pip install browser-use cloakbrowser
|
||||||
Set OPENAI_API_KEY (or swap for another LLM provider).
|
Set OPENAI_API_KEY (or swap for another LLM provider).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from browser_use import Agent, Browser, BrowserConfig
|
from browser_use import Agent, BrowserSession, ChatOpenAI
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
|
|
||||||
from cloakbrowser import launch_async
|
from cloakbrowser import launch_async
|
||||||
|
|
||||||
@@ -23,14 +22,13 @@ async def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Connect browser-use to the stealth browser via CDP
|
# Step 2: Connect browser-use to the stealth browser via CDP
|
||||||
config = BrowserConfig(cdp_url="http://127.0.0.1:9242")
|
session = BrowserSession(cdp_url="http://127.0.0.1:9242")
|
||||||
browser = Browser(config=config)
|
|
||||||
|
|
||||||
# Step 3: Run your AI agent — it browses through CloakBrowser
|
# Step 3: Run your AI agent — it browses through CloakBrowser
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
task="Go to https://www.google.com and search for 'browser automation'",
|
task="Go to https://www.google.com and search for 'browser automation'",
|
||||||
llm=ChatOpenAI(model="gpt-4o-mini"),
|
llm=ChatOpenAI(model="gpt-4o-mini"),
|
||||||
browser=browser,
|
browser_session=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await agent.run()
|
result = await agent.run()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Connect Crawl4AI to the stealth browser via CDP
|
# Step 2: Connect Crawl4AI to the stealth browser via CDP
|
||||||
browser_config = BrowserConfig(cdp_url="http://127.0.0.1:9243")
|
browser_config = BrowserConfig(browser_mode="cdp", cdp_url="http://127.0.0.1:9243")
|
||||||
run_config = CrawlerRunConfig()
|
run_config = CrawlerRunConfig()
|
||||||
|
|
||||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||||
|
|||||||
@@ -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)
|
browser = launch(headless=not HEADED, proxy=PROXY, geoip=True)
|
||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
|
|
||||||
# Show browser fingerprint details
|
# Show browser fingerprint details
|
||||||
|
|||||||
+9
-6
@@ -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.**
|
||||||
|
|
||||||
- **33 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, 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,10 +63,13 @@ await browser.close();
|
|||||||
```javascript
|
```javascript
|
||||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||||
|
|
||||||
// With proxy
|
// With proxy (HTTP or SOCKS5)
|
||||||
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({
|
||||||
@@ -203,15 +206,15 @@ const page = await browser.newPage();
|
|||||||
|
|
||||||
| Platform | Chromium | Patches | Status |
|
| Platform | Chromium | Patches | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Linux x86_64 | 145 | 33 | ✅ Latest |
|
| Linux x86_64 | 145 | 48 | ✅ Latest |
|
||||||
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ Latest |
|
| Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ 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 | 33 | ✅ Latest |
|
| Windows x86_64 | 145 | 48 | ✅ Latest |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node.js >= 18
|
- Node.js >= 20
|
||||||
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
|
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
Generated
+57
-9
@@ -1,31 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.9",
|
"version": "0.3.23",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.9",
|
"version": "0.3.23",
|
||||||
"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": ">=18.0.0"
|
"node": ">=20.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": {
|
||||||
@@ -36,6 +41,9 @@
|
|||||||
},
|
},
|
||||||
"puppeteer-core": {
|
"puppeteer-core": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"socks-proxy-agent": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2003,6 +2011,21 @@
|
|||||||
"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",
|
||||||
@@ -2164,6 +2187,21 @@
|
|||||||
"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",
|
||||||
@@ -2332,18 +2370,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socks-proxy-agent": {
|
"node_modules/socks-proxy-agent": {
|
||||||
"version": "8.0.5",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz",
|
||||||
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
|
"integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"agent-base": "^7.1.2",
|
"agent-base": "9.0.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"socks": "^2.8.3"
|
"socks": "^2.8.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socks-proxy-agent/node_modules/agent-base": {
|
||||||
|
"version": "9.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
|
|||||||
+12
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.19",
|
"version": "0.3.24",
|
||||||
"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,6 +13,10 @@
|
|||||||
"./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": {
|
||||||
@@ -51,12 +55,13 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
|
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=20.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": {
|
||||||
@@ -67,6 +72,9 @@
|
|||||||
},
|
},
|
||||||
"mmdb-lib": {
|
"mmdb-lib": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"socks-proxy-agent": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -75,6 +83,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
+6
-17
@@ -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 = "145.0.7632.159.8";
|
export const CHROMIUM_VERSION = "146.0.7680.177.1";
|
||||||
|
|
||||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||||
"linux-x64": "145.0.7632.159.8",
|
"linux-x64": "146.0.7680.177.2",
|
||||||
"linux-arm64": "145.0.7632.159.7",
|
"linux-arm64": "146.0.7680.177.2",
|
||||||
"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,27 +211,16 @@ 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 [
|
return [...base, "--fingerprint-platform=macos"];
|
||||||
...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, and window size are
|
// Hardware concurrency, device memory, screen, window size, and GPU are
|
||||||
// auto-generated by the binary from the seed (v14+).
|
// auto-generated by the binary from the seed (v14+).
|
||||||
return [
|
return [...base, "--fingerprint-platform=windows"];
|
||||||
...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)",
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-14
@@ -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 } from "./proxy.js";
|
import { ensureProxyScheme, isSocksProxy, reconstructSocksUrl, type ProxyDict } 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,6 +44,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,12 +66,12 @@ export async function resolveProxyGeo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = await ensureGeoipDb();
|
const dbPath = await ensureGeoipDb();
|
||||||
if (!dbPath) return { timezone: null, locale: null };
|
if (!dbPath) return { timezone: null, locale: null, exitIp: 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 };
|
if (!ip) return { timezone: null, locale: null, exitIp: null };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buf = fs.readFileSync(dbPath);
|
const buf = fs.readFileSync(dbPath);
|
||||||
@@ -80,9 +81,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 };
|
return { timezone, locale, exitIp: ip };
|
||||||
} catch {
|
} catch {
|
||||||
return { timezone: null, locale: null };
|
return { timezone: null, locale: null, exitIp: ip };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +129,44 @@ const IP_ECHO_URLS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||||
// Node.js fetch doesn't support proxy natively — use a CONNECT tunnel via http
|
const isSocks = isSocksProxy(proxyUrl);
|
||||||
// For simplicity, use a direct HTTP request to a plain-text IP echo service
|
|
||||||
// through the proxy using Node's http module
|
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
|
||||||
|
if (isSocks) {
|
||||||
|
let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent;
|
||||||
|
try {
|
||||||
|
({ SocksProxyAgent } = await import("socks-proxy-agent"));
|
||||||
|
} catch {
|
||||||
|
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { default: https } = await import("node:https");
|
||||||
|
const agent = new SocksProxyAgent(proxyUrl);
|
||||||
|
|
||||||
|
for (const echoUrl of IP_ECHO_URLS) {
|
||||||
|
try {
|
||||||
|
const ip = await new Promise<string | null>((resolve) => {
|
||||||
|
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||||
|
res.on("end", () => {
|
||||||
|
const ip = data.trim();
|
||||||
|
resolve(net.isIP(ip) ? ip : null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("error", () => resolve(null));
|
||||||
|
req.on("timeout", () => { req.destroy(); resolve(null); });
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
if (ip) return ip;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP/HTTPS: use a CONNECT tunnel via http
|
||||||
try {
|
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");
|
||||||
@@ -263,22 +299,83 @@ 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.
|
||||||
* Shared by the Playwright and Puppeteer wrappers.
|
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
|
||||||
*/
|
*/
|
||||||
export async function maybeResolveGeoip(
|
export async function maybeResolveGeoip(
|
||||||
options: LaunchOptions
|
options: LaunchOptions
|
||||||
): Promise<{ timezone?: string; locale?: string }> {
|
): Promise<{ timezone?: string; locale?: string; exitIp?: 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 };
|
|
||||||
|
|
||||||
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
const proxyUrl = extractProxyUrl(options.proxy);
|
||||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||||
proxyUrl = ensureProxyScheme(proxyUrl);
|
|
||||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
|
// When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,913 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* ElementHandle humanization for Playwright.
|
||||||
|
*
|
||||||
|
* Mirrors Puppeteer's ElementHandle patching architecture.
|
||||||
|
* Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles,
|
||||||
|
* and patches all interaction methods on each ElementHandle instance.
|
||||||
|
*
|
||||||
|
* Playwright ElementHandle methods patched:
|
||||||
|
* click, dblclick, hover, type, fill, press, selectOption,
|
||||||
|
* check, uncheck, setChecked, tap, focus
|
||||||
|
* + $, $$, waitForSelector (nested elements are also patched)
|
||||||
|
*
|
||||||
|
* Stealth-aware:
|
||||||
|
* - Uses CDP DOM.describeNode when available to check element type
|
||||||
|
* (no main-world JS execution)
|
||||||
|
* - Falls back to el.evaluate() only when CDP is unavailable
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
|
||||||
|
import type { HumanConfig } from './config.js';
|
||||||
|
import { rand, randRange, sleep } from './config.js';
|
||||||
|
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||||
|
import { humanType } from './keyboard.js';
|
||||||
|
|
||||||
|
// --- Platform-aware select-all shortcut ---
|
||||||
|
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stealth ElementHandle input check — uses CDP DOM.describeNode
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function isInputElementHandle(
|
||||||
|
stealth: any, // StealthEval from index.ts
|
||||||
|
el: ElementHandle,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Try CDP DOM.describeNode first (no main-world JS execution)
|
||||||
|
if (stealth) {
|
||||||
|
try {
|
||||||
|
const cdp: CDPSession = await stealth.getCdpSession();
|
||||||
|
// Playwright exposes the JSHandle's internal preview via _objectId or similar
|
||||||
|
// We need the remote object ID. Try to get it via internal API.
|
||||||
|
const impl = (el as any)._impl ?? (el as any)._object ?? el;
|
||||||
|
const guid = (impl as any)._guid;
|
||||||
|
|
||||||
|
// Use el.evaluate as a reliable fallback within stealth context
|
||||||
|
// Playwright doesn't expose remoteObject directly like Puppeteer
|
||||||
|
} catch { /* fallthrough */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: el.evaluate (works reliably in Playwright)
|
||||||
|
try {
|
||||||
|
return await el.evaluate((node: any) => {
|
||||||
|
const tag = node.tagName?.toLowerCase();
|
||||||
|
return tag === 'input' || tag === 'textarea'
|
||||||
|
|| node.getAttribute?.('contenteditable') === 'true';
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CursorState type (matches index.ts)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CursorState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
initialized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Patch a single Playwright ElementHandle
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function patchSingleElementHandle(
|
||||||
|
el: ElementHandle,
|
||||||
|
page: Page,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
cursor: CursorState,
|
||||||
|
raw: RawMouse,
|
||||||
|
rawKb: RawKeyboard,
|
||||||
|
originals: any,
|
||||||
|
stealth: any,
|
||||||
|
): void {
|
||||||
|
if ((el as any)._humanPatched) return;
|
||||||
|
(el as any)._humanPatched = true;
|
||||||
|
|
||||||
|
// Save originals
|
||||||
|
const origElClick = el.click.bind(el);
|
||||||
|
const origElDblclick = el.dblclick.bind(el);
|
||||||
|
const origElHover = el.hover.bind(el);
|
||||||
|
const origElType = el.type.bind(el);
|
||||||
|
const origElFill = el.fill.bind(el);
|
||||||
|
const origElPress = el.press.bind(el);
|
||||||
|
const origElSelectOption = el.selectOption.bind(el);
|
||||||
|
const origElCheck = el.check.bind(el);
|
||||||
|
const origElUncheck = el.uncheck.bind(el);
|
||||||
|
const origElSetChecked = (el as any).setChecked?.bind(el);
|
||||||
|
const origElTap = el.tap.bind(el);
|
||||||
|
const origElFocus = el.focus.bind(el);
|
||||||
|
|
||||||
|
// Nested selectors
|
||||||
|
const origEl$ = el.$.bind(el);
|
||||||
|
const origEl$$ = el.$$.bind(el);
|
||||||
|
const origElWaitForSelector = el.waitForSelector.bind(el);
|
||||||
|
|
||||||
|
// --- Nested elements are also patched ---
|
||||||
|
(el as any).$ = async (selector: string) => {
|
||||||
|
const child = await origEl$(selector);
|
||||||
|
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
|
||||||
|
(el as any).$$ = async (selector: string) => {
|
||||||
|
const children = await origEl$$(selector);
|
||||||
|
for (const child of children) {
|
||||||
|
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
(el as any).waitForSelector = async (selector: string, options?: any) => {
|
||||||
|
const child = await origElWaitForSelector(selector, options);
|
||||||
|
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Helper: get bounding box and move cursor to element ---
|
||||||
|
const moveToElement = async () => {
|
||||||
|
// Ensure cursor is initialized
|
||||||
|
const ensureCursorInit = (page as any)._ensureCursorInit;
|
||||||
|
if (ensureCursorInit) await ensureCursorInit();
|
||||||
|
|
||||||
|
const box = await el.boundingBox();
|
||||||
|
if (!box) return null;
|
||||||
|
|
||||||
|
const isInp = await isInputElementHandle(stealth, el);
|
||||||
|
const target = clickTarget(box, isInp, cfg);
|
||||||
|
|
||||||
|
if (cfg.idle_between_actions) {
|
||||||
|
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||||
|
cursor.x = target.x;
|
||||||
|
cursor.y = target.y;
|
||||||
|
return { box, isInp };
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.click() ---
|
||||||
|
(el as any).click = async (options?: any) => {
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElClick(options);
|
||||||
|
await humanClick(raw, info.isInp, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.dblclick() ---
|
||||||
|
(el as any).dblclick = async (options?: any) => {
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElDblclick(options);
|
||||||
|
await raw.down({ clickCount: 2 });
|
||||||
|
await sleep(rand(30, 60));
|
||||||
|
await raw.up({ clickCount: 2 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.hover() ---
|
||||||
|
(el as any).hover = async (options?: any) => {
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElHover(options);
|
||||||
|
// Just move — no click
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.type() ---
|
||||||
|
(el as any).type = async (text: string, options?: any) => {
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElType(text, options);
|
||||||
|
await humanClick(raw, info.isInp, cfg);
|
||||||
|
await sleep(rand(100, 250));
|
||||||
|
let cdpSession: CDPSession | null = null;
|
||||||
|
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||||
|
await humanType(page, rawKb, text, cfg, cdpSession);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.fill() ---
|
||||||
|
(el as any).fill = async (value: string, options?: any) => {
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElFill(value, options);
|
||||||
|
await humanClick(raw, info.isInp, cfg);
|
||||||
|
await sleep(rand(100, 250));
|
||||||
|
// Clear existing content
|
||||||
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
|
await sleep(rand(30, 80));
|
||||||
|
await originals.keyboardPress('Backspace');
|
||||||
|
await sleep(rand(50, 150));
|
||||||
|
let cdpSession: CDPSession | null = null;
|
||||||
|
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||||
|
await humanType(page, rawKb, value, cfg, cdpSession);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.press() ---
|
||||||
|
(el as any).press = async (key: string, options?: any) => {
|
||||||
|
await sleep(rand(20, 60));
|
||||||
|
await originals.keyboardDown(key);
|
||||||
|
await sleep(randRange(cfg.key_hold));
|
||||||
|
await originals.keyboardUp(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.selectOption() ---
|
||||||
|
(el as any).selectOption = async (values: any, options?: any) => {
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElSelectOption(values, options);
|
||||||
|
await humanClick(raw, false, cfg);
|
||||||
|
await sleep(rand(100, 300));
|
||||||
|
return origElSelectOption(values, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.check() ---
|
||||||
|
(el as any).check = async (options?: any) => {
|
||||||
|
try {
|
||||||
|
const checked = await el.isChecked();
|
||||||
|
if (checked) return; // Already checked
|
||||||
|
} catch {}
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElCheck(options);
|
||||||
|
await humanClick(raw, info.isInp, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.uncheck() ---
|
||||||
|
(el as any).uncheck = async (options?: any) => {
|
||||||
|
try {
|
||||||
|
const checked = await el.isChecked();
|
||||||
|
if (!checked) return; // Already unchecked
|
||||||
|
} catch {}
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElUncheck(options);
|
||||||
|
await humanClick(raw, info.isInp, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.setChecked() ---
|
||||||
|
if (origElSetChecked) {
|
||||||
|
(el as any).setChecked = async (checked: boolean, options?: any) => {
|
||||||
|
try {
|
||||||
|
const current = await el.isChecked();
|
||||||
|
if (current === checked) return;
|
||||||
|
} catch {}
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElSetChecked(checked, options);
|
||||||
|
await humanClick(raw, info.isInp, cfg);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- el.tap() ---
|
||||||
|
(el as any).tap = async (options?: any) => {
|
||||||
|
const info = await moveToElement();
|
||||||
|
if (!info) return origElTap(options);
|
||||||
|
await humanClick(raw, info.isInp, cfg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- el.focus() ---
|
||||||
|
// Move cursor humanly but use programmatic focus (no click side-effects).
|
||||||
|
// Stock Playwright el.focus() never clicks — clicking would trigger onclick,
|
||||||
|
// submit forms, navigate links, etc.
|
||||||
|
(el as any).focus = async () => {
|
||||||
|
await moveToElement(); // human-like Bézier cursor movement
|
||||||
|
await origElFocus(); // programmatic focus, no click
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Page-level ElementHandle patching
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function patchPageElementHandles(
|
||||||
|
page: Page,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
cursor: CursorState,
|
||||||
|
raw: RawMouse,
|
||||||
|
rawKb: RawKeyboard,
|
||||||
|
originals: any,
|
||||||
|
stealth: any,
|
||||||
|
): void {
|
||||||
|
// Patch page.$() — only if the method exists
|
||||||
|
if (typeof page.$ === 'function') {
|
||||||
|
const orig$ = page.$.bind(page);
|
||||||
|
(page as any).$ = async (selector: string) => {
|
||||||
|
const el = await orig$(selector);
|
||||||
|
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch page.$$()
|
||||||
|
if (typeof page.$$ === 'function') {
|
||||||
|
const orig$$ = page.$$.bind(page);
|
||||||
|
(page as any).$$ = async (selector: string) => {
|
||||||
|
const els = await orig$$(selector);
|
||||||
|
for (const el of els) {
|
||||||
|
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
}
|
||||||
|
return els;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch page.waitForSelector()
|
||||||
|
if (typeof page.waitForSelector === 'function') {
|
||||||
|
const origWaitForSelector = page.waitForSelector.bind(page);
|
||||||
|
(page as any).waitForSelector = async (selector: string, options?: any) => {
|
||||||
|
const el = await origWaitForSelector(selector, options);
|
||||||
|
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Frame-level ElementHandle patching
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function patchFrameElementHandles(
|
||||||
|
frame: Frame,
|
||||||
|
page: Page,
|
||||||
|
cfg: HumanConfig,
|
||||||
|
cursor: CursorState,
|
||||||
|
raw: RawMouse,
|
||||||
|
rawKb: RawKeyboard,
|
||||||
|
originals: any,
|
||||||
|
stealth: any,
|
||||||
|
): void {
|
||||||
|
// Patch frame.$() — only if the method exists
|
||||||
|
if (typeof frame.$ === 'function') {
|
||||||
|
const origFrame$ = frame.$.bind(frame);
|
||||||
|
(frame as any).$ = async (selector: string) => {
|
||||||
|
const el = await origFrame$(selector);
|
||||||
|
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch frame.$$()
|
||||||
|
if (typeof frame.$$ === 'function') {
|
||||||
|
const origFrame$$ = frame.$$.bind(frame);
|
||||||
|
(frame as any).$$ = async (selector: string) => {
|
||||||
|
const els = await origFrame$$(selector);
|
||||||
|
for (const el of els) {
|
||||||
|
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
}
|
||||||
|
return els;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch frame.waitForSelector()
|
||||||
|
if (typeof frame.waitForSelector === 'function') {
|
||||||
|
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
|
||||||
|
(frame as any).waitForSelector = async (selector: string, options?: any) => {
|
||||||
|
const el = await origFrameWaitForSelector(selector, options);
|
||||||
|
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+237
-21
@@ -4,32 +4,182 @@
|
|||||||
* 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 } from 'playwright-core';
|
import type { Browser, BrowserContext, Page, Frame, CDPSession } 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;
|
||||||
@@ -39,13 +189,37 @@ async function isInputElement(page: Page, selector: string): Promise<boolean> {
|
|||||||
}, 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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -82,6 +256,21 @@ 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,
|
||||||
@@ -105,11 +294,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- goto ---
|
// --- goto (invalidate isolated world on navigation) ---
|
||||||
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);
|
||||||
// Patch any new frames after navigation
|
stealth.invalidate();
|
||||||
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,7 +311,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(page, selector);
|
const isInput = await isInputElement(stealth, 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;
|
||||||
@@ -139,7 +328,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(page, selector);
|
const isInput = await isInputElement(stealth, 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;
|
||||||
@@ -169,7 +358,8 @@ 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));
|
||||||
await humanType(page, rawKb, text, cfg);
|
const cdp = await ensureCdp();
|
||||||
|
await humanType(page, rawKb, text, cfg, cdp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- fill (clears existing content first) ---
|
// --- fill (clears existing content first) ---
|
||||||
@@ -181,12 +371,13 @@ 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));
|
||||||
await humanType(page, rawKb, value, cfg);
|
const cdp = await ensureCdp();
|
||||||
|
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(page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
@@ -226,7 +417,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(page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
@@ -235,11 +426,12 @@ 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(page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector);
|
||||||
}
|
}
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
await humanType(page, rawKb, text, cfg);
|
const cdp = await ensureCdp();
|
||||||
|
await humanType(page, rawKb, text, cfg, cdp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- tap ---
|
// --- tap ---
|
||||||
@@ -258,6 +450,9 @@ 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) => {
|
||||||
@@ -277,7 +472,8 @@ 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) => {
|
||||||
await humanType(page, rawKb, text, cfg);
|
const cdp = await ensureCdp();
|
||||||
|
await humanType(page, rawKb, text, cfg, cdp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store helpers for frame patching
|
// Store helpers for frame patching
|
||||||
@@ -301,7 +497,10 @@ 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);
|
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
|
|
||||||
|
// --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) ---
|
||||||
|
patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -311,8 +510,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 11 methods patched: click, dblclick, hover, type, fill, check, uncheck,
|
* All 13 methods patched: click, dblclick, hover, type, fill, check, uncheck,
|
||||||
* selectOption, press, clear, dragAndDrop.
|
* selectOption, press, pressSequentially, tap, clear, dragAndDrop.
|
||||||
*/
|
*/
|
||||||
function patchFrames(
|
function patchFrames(
|
||||||
page: Page,
|
page: Page,
|
||||||
@@ -321,13 +520,22 @@ 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);
|
patchSingleFrame(frame, page, cfg, originals, stealth);
|
||||||
|
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
|
||||||
|
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
|
function patchSingleFrame(
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -373,8 +581,16 @@ function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals:
|
|||||||
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(page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await (page as any).click(selector);
|
await (page as any).click(selector);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* 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 } from 'playwright-core';
|
import type { Page, CDPSession } 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';
|
||||||
|
|
||||||
@@ -22,6 +26,31 @@ 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;
|
||||||
@@ -37,11 +66,24 @@ 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
|
||||||
|
|
||||||
@@ -72,7 +114,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);
|
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
|
||||||
} else {
|
} else {
|
||||||
await typeNormalChar(raw, ch, cfg);
|
await typeNormalChar(raw, ch, cfg);
|
||||||
}
|
}
|
||||||
@@ -99,7 +141,54 @@ 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);
|
||||||
@@ -112,10 +201,7 @@ async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: Hu
|
|||||||
}, 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> {
|
||||||
|
|||||||
+27
-16
@@ -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 { parseProxyUrl } from "./proxy.js";
|
import { resolveProxyConfig } from "./proxy.js";
|
||||||
import { maybeResolveGeoip } from "./geoip.js";
|
import { maybeResolveGeoip, resolveWebrtcArgs } 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,17 +38,20 @@ 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 resolved = await maybeResolveGeoip(options);
|
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||||
const args = buildArgs({ ...options, ...resolved });
|
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||||
|
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||||
|
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||||
|
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||||
|
}
|
||||||
|
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...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,
|
||||||
...(options.proxy
|
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
|
||||||
: {}),
|
|
||||||
...options.launchOptions,
|
...options.launchOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,17 +90,22 @@ 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 resolved = await maybeResolveGeoip(options);
|
const { exitIp, ...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, geoip: false });
|
const browser = await launch({ ...options, ...resolved, args: launchArgs, 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 ?? DEFAULT_VIEWPORT,
|
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -154,8 +162,13 @@ 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 resolved = await maybeResolveGeoip(options);
|
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||||
const args = buildArgs({ ...options, ...resolved });
|
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||||
|
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||||
|
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||||
|
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||||
|
}
|
||||||
|
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...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.
|
||||||
@@ -164,11 +177,9 @@ export async function launchPersistentContext(
|
|||||||
headless: options.headless ?? true,
|
headless: options.headless ?? true,
|
||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||||
...(options.proxy
|
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
|
||||||
: {}),
|
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
viewport: options.viewport ?? DEFAULT_VIEWPORT,
|
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||||
...options.launchOptions,
|
...options.launchOptions,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,65 @@ 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.
|
||||||
|
|||||||
+37
-20
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Puppeteer launch wrapper for cloakbrowser.
|
* Puppeteer launch wrapper for cloakbrowser.
|
||||||
* Alternative to the Playwright wrapper for users who prefer Puppeteer.
|
* NOW WITH HUMANIZE SUPPORT — humanize: true enables human-like
|
||||||
|
* mouse curves, keyboard timing, and scroll patterns (same as Playwright).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Browser } from "puppeteer-core";
|
import type { Browser } from "puppeteer-core";
|
||||||
@@ -8,8 +9,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 { parseProxyUrl } from "./proxy.js";
|
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
|
||||||
import { maybeResolveGeoip } from "./geoip.js";
|
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch stealth Chromium browser via Puppeteer.
|
* Launch stealth Chromium browser via Puppeteer.
|
||||||
@@ -17,40 +18,48 @@ import { maybeResolveGeoip } from "./geoip.js";
|
|||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* import { launch } from 'cloakbrowser/puppeteer';
|
* import { launch } from 'cloakbrowser/puppeteer';
|
||||||
* const browser = await launch();
|
* * // With humanize — human-like mouse, keyboard, scroll
|
||||||
|
* const browser = await launch({ humanize: true });
|
||||||
* const page = await browser.newPage();
|
* const page = await browser.newPage();
|
||||||
* await page.goto('https://bot.incolumitas.com');
|
* await page.goto('[https://example.com](https://example.com)');
|
||||||
* console.log(await page.title());
|
* await page.click('#login'); // Bézier curve mouse movement
|
||||||
* await browser.close();
|
* await page.type('#email', 'user@example.com'); // Per-character timing
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
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 resolved = await maybeResolveGeoip(options);
|
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
|
||||||
const args = buildArgs({ ...options, ...resolved });
|
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
|
||||||
|
|
||||||
|
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.
|
||||||
// Chromium's --proxy-server does NOT support inline credentials,
|
// SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
|
||||||
// so we strip them and use page.authenticate() instead.
|
// HTTP: Chrome does NOT support inline credentials — strip them and
|
||||||
|
// use page.authenticate() for Proxy-Authorization headers instead.
|
||||||
let proxyAuth: { username: string; password: string } | undefined;
|
let proxyAuth: { username: string; password: string } | undefined;
|
||||||
if (options.proxy) {
|
if (options.proxy) {
|
||||||
if (typeof options.proxy === "string") {
|
if (isSocksProxy(options.proxy)) {
|
||||||
|
// SOCKS5: pass full URL with credentials to Chrome directly
|
||||||
|
const { proxyArgs } = resolveProxyConfig(options.proxy);
|
||||||
|
args.push(...proxyArgs);
|
||||||
|
} else if (typeof options.proxy === "string") {
|
||||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
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) {
|
||||||
@@ -78,10 +87,18 @@ 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
@@ -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 };
|
viewport?: { width: number; height: number } | null;
|
||||||
/** 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. */
|
||||||
|
|||||||
+16
-3
@@ -21,16 +21,17 @@ 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();
|
||||||
@@ -182,6 +183,18 @@ 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" });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -451,6 +452,89 @@ 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
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -606,6 +690,349 @@ describe("humanType non-ASCII", () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// ElementHandle patching (Playwright)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
function buildMockElementHandle(overrides: Record<string, any> = {}): any {
|
||||||
|
const el: any = {
|
||||||
|
click: vi.fn(async () => {}),
|
||||||
|
dblclick: vi.fn(async () => {}),
|
||||||
|
hover: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
fill: vi.fn(async () => {}),
|
||||||
|
press: vi.fn(async () => {}),
|
||||||
|
selectOption: vi.fn(async () => {}),
|
||||||
|
check: vi.fn(async () => {}),
|
||||||
|
uncheck: vi.fn(async () => {}),
|
||||||
|
setChecked: vi.fn(async () => {}),
|
||||||
|
tap: vi.fn(async () => {}),
|
||||||
|
focus: vi.fn(async () => {}),
|
||||||
|
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||||
|
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||||
|
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||||
|
$: vi.fn(async () => null),
|
||||||
|
$$: vi.fn(async () => []),
|
||||||
|
waitForSelector: vi.fn(async () => null),
|
||||||
|
_humanPatched: false,
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("patchSingleElementHandle", () => {
|
||||||
|
it("marks element as patched", async () => {
|
||||||
|
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
|
const raw = {
|
||||||
|
move: vi.fn(async () => {}),
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
wheel: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const rawKb = {
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
insertText: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const originals = {
|
||||||
|
keyboardPress: vi.fn(async () => {}),
|
||||||
|
keyboardDown: vi.fn(async () => {}),
|
||||||
|
keyboardUp: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = buildMockElementHandle();
|
||||||
|
const page = buildMockPage();
|
||||||
|
|
||||||
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
expect(el._humanPatched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("el.click calls mouse.move and mouse.down/up (humanized path)", async () => {
|
||||||
|
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||||
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
|
|
||||||
|
let moveCount = 0;
|
||||||
|
let downCalled = false;
|
||||||
|
let upCalled = false;
|
||||||
|
const raw = {
|
||||||
|
move: vi.fn(async () => { moveCount++; }),
|
||||||
|
down: vi.fn(async () => { downCalled = true; }),
|
||||||
|
up: vi.fn(async () => { upCalled = true; }),
|
||||||
|
wheel: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const rawKb = {
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
insertText: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const originals = {
|
||||||
|
keyboardPress: vi.fn(async () => {}),
|
||||||
|
keyboardDown: vi.fn(async () => {}),
|
||||||
|
keyboardUp: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = buildMockElementHandle();
|
||||||
|
const page = buildMockPage();
|
||||||
|
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||||
|
|
||||||
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
await el.click();
|
||||||
|
|
||||||
|
expect(moveCount).toBeGreaterThan(0);
|
||||||
|
expect(downCalled).toBe(true);
|
||||||
|
expect(upCalled).toBe(true);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("el.hover calls mouse.move but NOT down/up", async () => {
|
||||||
|
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||||
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
|
|
||||||
|
let downCalled = false;
|
||||||
|
const raw = {
|
||||||
|
move: vi.fn(async () => {}),
|
||||||
|
down: vi.fn(async () => { downCalled = true; }),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
wheel: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const rawKb = {
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
insertText: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||||
|
|
||||||
|
const el = buildMockElementHandle();
|
||||||
|
const page = buildMockPage();
|
||||||
|
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||||
|
|
||||||
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
await el.hover();
|
||||||
|
|
||||||
|
expect(raw.move).toHaveBeenCalled();
|
||||||
|
expect(downCalled).toBe(false);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("el.type triggers mouse move + click + keyboard events", async () => {
|
||||||
|
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 });
|
||||||
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
|
|
||||||
|
const raw = {
|
||||||
|
move: vi.fn(async () => {}),
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
wheel: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const rawKb = {
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
insertText: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||||
|
|
||||||
|
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true
|
||||||
|
const page = buildMockPage();
|
||||||
|
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||||
|
|
||||||
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
await el.type("abc");
|
||||||
|
|
||||||
|
expect(raw.move).toHaveBeenCalled();
|
||||||
|
expect(raw.down).toHaveBeenCalled(); // click to focus
|
||||||
|
expect(rawKb.down).toHaveBeenCalled(); // keyboard typing
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("el.fill calls selectAll + backspace + type", async () => {
|
||||||
|
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 });
|
||||||
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
|
|
||||||
|
const pressedKeys: string[] = [];
|
||||||
|
const raw = {
|
||||||
|
move: vi.fn(async () => {}),
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
wheel: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const rawKb = {
|
||||||
|
down: vi.fn(async () => {}),
|
||||||
|
up: vi.fn(async () => {}),
|
||||||
|
type: vi.fn(async () => {}),
|
||||||
|
insertText: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const originals = {
|
||||||
|
keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }),
|
||||||
|
keyboardDown: vi.fn(async () => {}),
|
||||||
|
keyboardUp: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||||
|
const page = buildMockPage();
|
||||||
|
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||||
|
|
||||||
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
await el.fill("newtext");
|
||||||
|
|
||||||
|
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
||||||
|
expect(pressedKeys).toContain(expected);
|
||||||
|
expect(pressedKeys).toContain("Backspace");
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("no double patching", async () => {
|
||||||
|
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
|
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||||
|
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||||
|
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||||
|
|
||||||
|
const el = buildMockElementHandle();
|
||||||
|
const page = buildMockPage();
|
||||||
|
|
||||||
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
const firstClick = el.click;
|
||||||
|
|
||||||
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
expect(el.click).toBe(firstClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nested $() returns patched child handle", async () => {
|
||||||
|
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
|
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||||
|
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||||
|
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||||
|
|
||||||
|
const child = buildMockElementHandle();
|
||||||
|
const el = buildMockElementHandle();
|
||||||
|
el.$ = vi.fn(async () => child);
|
||||||
|
|
||||||
|
const page = buildMockPage();
|
||||||
|
|
||||||
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
const result = await el.$("span");
|
||||||
|
expect(result._humanPatched).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("patchPageElementHandles", () => {
|
||||||
|
it("page.$() returns patched ElementHandle", async () => {
|
||||||
|
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
|
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||||
|
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||||
|
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||||
|
|
||||||
|
const el = buildMockElementHandle();
|
||||||
|
const page = buildMockPage();
|
||||||
|
(page as any).$ = vi.fn(async () => el);
|
||||||
|
(page as any).$$ = vi.fn(async () => [el]);
|
||||||
|
(page as any).waitForSelector = vi.fn(async () => el);
|
||||||
|
|
||||||
|
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
const result = await (page as any).$("#test");
|
||||||
|
expect(result._humanPatched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("page.$$() returns all patched handles", async () => {
|
||||||
|
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
|
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||||
|
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||||
|
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||||
|
|
||||||
|
const el1 = buildMockElementHandle();
|
||||||
|
const el2 = buildMockElementHandle();
|
||||||
|
const page = buildMockPage();
|
||||||
|
(page as any).$ = vi.fn(async () => null);
|
||||||
|
(page as any).$$ = vi.fn(async () => [el1, el2]);
|
||||||
|
(page as any).waitForSelector = vi.fn(async () => null);
|
||||||
|
|
||||||
|
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
const results = await (page as any).$$("div");
|
||||||
|
expect(results[0]._humanPatched).toBe(true);
|
||||||
|
expect(results[1]._humanPatched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("page.waitForSelector() returns patched handle", async () => {
|
||||||
|
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
|
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||||
|
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||||
|
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||||
|
|
||||||
|
const el = buildMockElementHandle();
|
||||||
|
const page = buildMockPage();
|
||||||
|
(page as any).$ = vi.fn(async () => null);
|
||||||
|
(page as any).$$ = vi.fn(async () => []);
|
||||||
|
(page as any).waitForSelector = vi.fn(async () => el);
|
||||||
|
|
||||||
|
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
const result = await (page as any).waitForSelector("#test");
|
||||||
|
expect(result._humanPatched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("page.$() returns null when no element found (no crash)", async () => {
|
||||||
|
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
|
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||||
|
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||||
|
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||||
|
|
||||||
|
const page = buildMockPage();
|
||||||
|
(page as any).$ = vi.fn(async () => null);
|
||||||
|
(page as any).$$ = vi.fn(async () => []);
|
||||||
|
(page as any).waitForSelector = vi.fn(async () => null);
|
||||||
|
|
||||||
|
patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
|
const result = await (page as any).$("#nonexistent");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("patchPage integrates ElementHandle patching", () => {
|
||||||
|
it("patchPage patches page.$ automatically", async () => {
|
||||||
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
|
|
||||||
|
const el = buildMockElementHandle();
|
||||||
|
const page = buildMockPage();
|
||||||
|
(page as any).$ = vi.fn(async () => el);
|
||||||
|
(page as any).$$ = vi.fn(async () => []);
|
||||||
|
(page as any).waitForSelector = vi.fn(async () => null);
|
||||||
|
|
||||||
|
const cfg = resolveConfig("default");
|
||||||
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
|
const result = await (page as any).$("#test");
|
||||||
|
expect(result._humanPatched).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
function buildMockFrame(): any {
|
function buildMockFrame(): any {
|
||||||
return {
|
return {
|
||||||
click: vi.fn(async () => {}),
|
click: vi.fn(async () => {}),
|
||||||
|
|||||||
+90
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { parseProxyUrl } from "../src/proxy.js";
|
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
||||||
import type { LaunchOptions } from "../src/types.js";
|
import type { LaunchOptions } from "../src/types.js";
|
||||||
|
|
||||||
describe("parseProxyUrl", () => {
|
describe("parseProxyUrl", () => {
|
||||||
@@ -115,3 +115,92 @@ 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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", () => {
|
||||||
@@ -21,6 +22,7 @@ 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({
|
||||||
@@ -111,4 +113,29 @@ 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
+2
-1
@@ -54,8 +54,9 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
geoip = ["geoip2>=4.0"]
|
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
|
||||||
patchright = ["patchright>=1.40"]
|
patchright = ["patchright>=1.40"]
|
||||||
|
serve = ["aiohttp>=3.9", "websockets>=12.0"]
|
||||||
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
|
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
+56
-15
@@ -1,24 +1,24 @@
|
|||||||
"""Unit tests for _build_args timezone/locale injection and timezone alias."""
|
"""Unit tests for build_args timezone/locale injection and timezone alias."""
|
||||||
|
|
||||||
from cloakbrowser.browser import _build_args, _resolve_timezone
|
from cloakbrowser.browser import build_args, _resolve_timezone
|
||||||
|
|
||||||
|
|
||||||
def test_timezone_injected():
|
def test_timezone_injected():
|
||||||
"""--fingerprint-timezone flag should appear when timezone is set."""
|
"""--fingerprint-timezone flag should appear when timezone is set."""
|
||||||
args = _build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
|
args = build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
|
||||||
assert "--fingerprint-timezone=America/New_York" in args
|
assert "--fingerprint-timezone=America/New_York" in args
|
||||||
|
|
||||||
|
|
||||||
def test_locale_injected():
|
def test_locale_injected():
|
||||||
"""--lang and --fingerprint-locale flags should appear when locale is set."""
|
"""--lang and --fingerprint-locale flags should appear when locale is set."""
|
||||||
args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
|
args = build_args(stealth_args=True, extra_args=None, locale="en-US")
|
||||||
assert "--lang=en-US" in args
|
assert "--lang=en-US" in args
|
||||||
assert "--fingerprint-locale=en-US" in args
|
assert "--fingerprint-locale=en-US" in args
|
||||||
|
|
||||||
|
|
||||||
def test_both_injected():
|
def test_both_injected():
|
||||||
"""Both flags should appear when both are set."""
|
"""Both flags should appear when both are set."""
|
||||||
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
|
args = build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
|
||||||
assert "--fingerprint-timezone=Europe/Berlin" in args
|
assert "--fingerprint-timezone=Europe/Berlin" in args
|
||||||
assert "--lang=de-DE" in args
|
assert "--lang=de-DE" in args
|
||||||
assert "--fingerprint-locale=de-DE" in args
|
assert "--fingerprint-locale=de-DE" in args
|
||||||
@@ -26,7 +26,7 @@ def test_both_injected():
|
|||||||
|
|
||||||
def test_timezone_independent_of_stealth_args():
|
def test_timezone_independent_of_stealth_args():
|
||||||
"""--fingerprint-timezone should be injected even when stealth_args=False."""
|
"""--fingerprint-timezone should be injected even when stealth_args=False."""
|
||||||
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
|
args = build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
|
||||||
assert "--fingerprint-timezone=America/New_York" in args
|
assert "--fingerprint-timezone=America/New_York" in args
|
||||||
assert "--lang=en-US" in args
|
assert "--lang=en-US" in args
|
||||||
assert "--fingerprint-locale=en-US" in args
|
assert "--fingerprint-locale=en-US" in args
|
||||||
@@ -36,7 +36,7 @@ def test_timezone_independent_of_stealth_args():
|
|||||||
|
|
||||||
def test_no_flags_when_not_set():
|
def test_no_flags_when_not_set():
|
||||||
"""No timezone/lang/fingerprint-locale flags when params are None."""
|
"""No timezone/lang/fingerprint-locale flags when params are None."""
|
||||||
args = _build_args(stealth_args=True, extra_args=None)
|
args = build_args(stealth_args=True, extra_args=None)
|
||||||
assert not any(a.startswith("--fingerprint-timezone=") for a in args)
|
assert not any(a.startswith("--fingerprint-timezone=") for a in args)
|
||||||
assert not any(a.startswith("--lang=") for a in args)
|
assert not any(a.startswith("--lang=") for a in args)
|
||||||
assert not any(a.startswith("--fingerprint-locale=") for a in args)
|
assert not any(a.startswith("--fingerprint-locale=") for a in args)
|
||||||
@@ -44,7 +44,7 @@ def test_no_flags_when_not_set():
|
|||||||
|
|
||||||
def test_extra_args_preserved():
|
def test_extra_args_preserved():
|
||||||
"""Extra args should still be included alongside timezone/locale."""
|
"""Extra args should still be included alongside timezone/locale."""
|
||||||
args = _build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
|
args = build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
|
||||||
assert "--disable-gpu" in args
|
assert "--disable-gpu" in args
|
||||||
assert "--fingerprint-timezone=Asia/Tokyo" in args
|
assert "--fingerprint-timezone=Asia/Tokyo" in args
|
||||||
assert "--lang=ja-JP" in args
|
assert "--lang=ja-JP" in args
|
||||||
@@ -90,7 +90,7 @@ def test_resolve_both_none():
|
|||||||
|
|
||||||
def test_user_fingerprint_overrides_default():
|
def test_user_fingerprint_overrides_default():
|
||||||
"""User --fingerprint should override the random default seed."""
|
"""User --fingerprint should override the random default seed."""
|
||||||
args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
|
args = build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
|
||||||
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
|
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
|
||||||
assert len(fingerprint_args) == 1
|
assert len(fingerprint_args) == 1
|
||||||
assert fingerprint_args[0] == "--fingerprint=99887"
|
assert fingerprint_args[0] == "--fingerprint=99887"
|
||||||
@@ -98,7 +98,7 @@ def test_user_fingerprint_overrides_default():
|
|||||||
|
|
||||||
def test_user_platform_overrides_default():
|
def test_user_platform_overrides_default():
|
||||||
"""User --fingerprint-platform should override the default."""
|
"""User --fingerprint-platform should override the default."""
|
||||||
args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
|
args = build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
|
||||||
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
|
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
|
||||||
assert len(platform_args) == 1
|
assert len(platform_args) == 1
|
||||||
assert platform_args[0] == "--fingerprint-platform=linux"
|
assert platform_args[0] == "--fingerprint-platform=linux"
|
||||||
@@ -106,7 +106,7 @@ def test_user_platform_overrides_default():
|
|||||||
|
|
||||||
def test_timezone_param_overrides_user_arg():
|
def test_timezone_param_overrides_user_arg():
|
||||||
"""Dedicated timezone param should override user arg."""
|
"""Dedicated timezone param should override user arg."""
|
||||||
args = _build_args(
|
args = build_args(
|
||||||
stealth_args=True,
|
stealth_args=True,
|
||||||
extra_args=["--fingerprint-timezone=Europe/London"],
|
extra_args=["--fingerprint-timezone=Europe/London"],
|
||||||
timezone="America/New_York",
|
timezone="America/New_York",
|
||||||
@@ -118,7 +118,7 @@ def test_timezone_param_overrides_user_arg():
|
|||||||
|
|
||||||
def test_locale_param_overrides_user_arg():
|
def test_locale_param_overrides_user_arg():
|
||||||
"""Dedicated locale param should override user --lang and --fingerprint-locale args."""
|
"""Dedicated locale param should override user --lang and --fingerprint-locale args."""
|
||||||
args = _build_args(
|
args = build_args(
|
||||||
stealth_args=True,
|
stealth_args=True,
|
||||||
extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
|
extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
|
||||||
locale="en-US",
|
locale="en-US",
|
||||||
@@ -133,7 +133,7 @@ def test_locale_param_overrides_user_arg():
|
|||||||
|
|
||||||
def test_no_duplicate_flags():
|
def test_no_duplicate_flags():
|
||||||
"""No flag key should appear more than once in the output."""
|
"""No flag key should appear more than once in the output."""
|
||||||
args = _build_args(
|
args = build_args(
|
||||||
stealth_args=True,
|
stealth_args=True,
|
||||||
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
|
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
|
||||||
timezone="Europe/Berlin",
|
timezone="Europe/Berlin",
|
||||||
@@ -145,7 +145,7 @@ def test_no_duplicate_flags():
|
|||||||
|
|
||||||
def test_non_value_flags_preserved():
|
def test_non_value_flags_preserved():
|
||||||
"""Flags without = should be preserved without dedup issues."""
|
"""Flags without = should be preserved without dedup issues."""
|
||||||
args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
|
args = build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
|
||||||
assert "--disable-gpu" in args
|
assert "--disable-gpu" in args
|
||||||
assert "--no-zygote" in args
|
assert "--no-zygote" in args
|
||||||
assert "--no-sandbox" in args
|
assert "--no-sandbox" in args
|
||||||
@@ -156,5 +156,46 @@ def test_override_logs_debug(caplog):
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
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"]
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""Unit tests for cloakserve — parse_connection_params, parse_cli_args, URL rewriting, connection tracking."""
|
||||||
|
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
aiohttp = pytest.importorskip("aiohttp", reason="cloakserve requires aiohttp (install with .[serve])")
|
||||||
|
|
||||||
|
# Load cloakserve as a module from bin/ (no .py extension).
|
||||||
|
_bin_path = str(Path(__file__).resolve().parents[1] / "bin" / "cloakserve")
|
||||||
|
_loader = importlib.machinery.SourceFileLoader("cloakserve", _bin_path)
|
||||||
|
_spec = importlib.util.spec_from_file_location("cloakserve", _bin_path, loader=_loader)
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["cloakserve"] = _mod
|
||||||
|
_loader.exec_module(_mod)
|
||||||
|
|
||||||
|
parse_connection_params = _mod.parse_connection_params
|
||||||
|
parse_cli_args = _mod.parse_cli_args
|
||||||
|
ChromePool = _mod.ChromePool
|
||||||
|
_default_data_dir = _mod._default_data_dir
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_connection_params
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseConnectionParams:
|
||||||
|
def test_empty_query(self):
|
||||||
|
result = parse_connection_params("")
|
||||||
|
assert result["seed"] is None
|
||||||
|
assert result["extra_args"] == []
|
||||||
|
|
||||||
|
def test_fingerprint_seed(self):
|
||||||
|
result = parse_connection_params("fingerprint=12345")
|
||||||
|
assert result["seed"] == "12345"
|
||||||
|
|
||||||
|
def test_timezone_and_locale(self):
|
||||||
|
result = parse_connection_params("fingerprint=1&timezone=Asia/Tokyo&locale=ja-JP")
|
||||||
|
assert result["timezone"] == "Asia/Tokyo"
|
||||||
|
assert result["locale"] == "ja-JP"
|
||||||
|
|
||||||
|
def test_proxy(self):
|
||||||
|
result = parse_connection_params("proxy=http://proxy:8080")
|
||||||
|
assert result["proxy"] == "http://proxy:8080"
|
||||||
|
|
||||||
|
def test_geoip_true_variants(self):
|
||||||
|
for val in ("true", "1", "yes", "True", "YES"):
|
||||||
|
result = parse_connection_params(f"geoip={val}")
|
||||||
|
assert result["geoip"] is True, f"geoip={val} should be True"
|
||||||
|
|
||||||
|
def test_geoip_false(self):
|
||||||
|
for val in ("false", "0", "no", "anything"):
|
||||||
|
result = parse_connection_params(f"geoip={val}")
|
||||||
|
assert result["geoip"] is False, f"geoip={val} should be False"
|
||||||
|
|
||||||
|
def test_generic_fingerprint_params(self):
|
||||||
|
qs = "fingerprint=1&platform=windows&hardware-concurrency=8&gpu-vendor=NVIDIA"
|
||||||
|
result = parse_connection_params(qs)
|
||||||
|
assert "--fingerprint-platform=windows" in result["extra_args"]
|
||||||
|
assert "--fingerprint-hardware-concurrency=8" in result["extra_args"]
|
||||||
|
assert "--fingerprint-gpu-vendor=NVIDIA" in result["extra_args"]
|
||||||
|
|
||||||
|
def test_special_params_not_in_extra_args(self):
|
||||||
|
qs = "fingerprint=1&timezone=UTC&locale=en-US&proxy=http://x:1&geoip=true"
|
||||||
|
result = parse_connection_params(qs)
|
||||||
|
assert result["extra_args"] == []
|
||||||
|
|
||||||
|
def test_multiple_values_takes_first(self):
|
||||||
|
result = parse_connection_params("fingerprint=111&fingerprint=222")
|
||||||
|
assert result["seed"] == "111"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_cli_args
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCliArgs:
|
||||||
|
def test_defaults(self):
|
||||||
|
config, passthrough = parse_cli_args([])
|
||||||
|
assert config["port"] == 9222
|
||||||
|
assert config["headless"] is True
|
||||||
|
assert config["data_dir"] is not None
|
||||||
|
assert passthrough == []
|
||||||
|
|
||||||
|
def test_custom_port(self):
|
||||||
|
config, _ = parse_cli_args(["--port=8080"])
|
||||||
|
assert config["port"] == 8080
|
||||||
|
|
||||||
|
def test_headless_false(self):
|
||||||
|
config, passthrough = parse_cli_args(["--headless=false"])
|
||||||
|
assert config["headless"] is False
|
||||||
|
# headless flag still passed through to Chrome
|
||||||
|
assert "--headless=false" in passthrough
|
||||||
|
|
||||||
|
def test_strips_remote_debugging_flags(self):
|
||||||
|
args = ["--remote-debugging-port=9999", "--remote-debugging-address=0.0.0.0", "--no-sandbox"]
|
||||||
|
config, passthrough = parse_cli_args(args)
|
||||||
|
assert passthrough == ["--no-sandbox"]
|
||||||
|
|
||||||
|
def test_passthrough_args(self):
|
||||||
|
args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"]
|
||||||
|
config, passthrough = parse_cli_args(args)
|
||||||
|
# --fingerprint=999 is consumed into config["default_seed"], not passed through
|
||||||
|
assert passthrough == ["--no-sandbox", "--disable-gpu"]
|
||||||
|
assert config["default_seed"] == "999"
|
||||||
|
|
||||||
|
def test_port_not_in_passthrough(self):
|
||||||
|
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
|
||||||
|
assert "--port=9222" not in passthrough
|
||||||
|
assert "--no-sandbox" in passthrough
|
||||||
|
|
||||||
|
def test_custom_data_dir(self):
|
||||||
|
config, passthrough = parse_cli_args(["--data-dir=/custom/path", "--no-sandbox"])
|
||||||
|
assert config["data_dir"] == "/custom/path"
|
||||||
|
assert "--data-dir=/custom/path" not in passthrough
|
||||||
|
|
||||||
|
def test_data_dir_not_in_passthrough(self):
|
||||||
|
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
|
||||||
|
assert not any(a.startswith("--data-dir=") for a in passthrough)
|
||||||
|
|
||||||
|
@patch("os.path.exists", return_value=True)
|
||||||
|
def test_default_data_dir_docker(self, _mock):
|
||||||
|
assert _default_data_dir() == "/tmp/cloakserve"
|
||||||
|
|
||||||
|
@patch("os.path.exists", return_value=False)
|
||||||
|
def test_default_data_dir_bare_metal(self, _mock):
|
||||||
|
result = _default_data_dir()
|
||||||
|
assert result.endswith(".cloakbrowser/cloakserve")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# URL rewriting logic (pure string manipulation, extracted from handlers)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestURLRewriting:
|
||||||
|
"""Test the URL rewriting logic used by /json/version and /json/list."""
|
||||||
|
|
||||||
|
def _rewrite_version(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
|
||||||
|
"""Replicate the URL rewrite logic from handle_json_version."""
|
||||||
|
if seed:
|
||||||
|
ws_path = f"fingerprint/{seed}/devtools/browser"
|
||||||
|
else:
|
||||||
|
ws_path = "devtools/browser"
|
||||||
|
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
|
||||||
|
return f"{scheme}://{host}/{ws_path}/{guid}"
|
||||||
|
|
||||||
|
def _rewrite_list_entry(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
|
||||||
|
"""Replicate the URL rewrite logic from handle_json_list."""
|
||||||
|
ws_tail = orig_ws.split("/devtools/")[-1]
|
||||||
|
if seed:
|
||||||
|
return f"{scheme}://{host}/fingerprint/{seed}/devtools/{ws_tail}"
|
||||||
|
else:
|
||||||
|
return f"{scheme}://{host}/devtools/{ws_tail}"
|
||||||
|
|
||||||
|
def test_version_rewrite_with_seed(self):
|
||||||
|
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
|
||||||
|
result = self._rewrite_version(orig, "container:9222", "12345")
|
||||||
|
assert result == "ws://container:9222/fingerprint/12345/devtools/browser/abc-123"
|
||||||
|
|
||||||
|
def test_version_rewrite_no_seed(self):
|
||||||
|
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
|
||||||
|
result = self._rewrite_version(orig, "container:9222", None)
|
||||||
|
assert result == "ws://container:9222/devtools/browser/abc-123"
|
||||||
|
|
||||||
|
def test_list_rewrite_page_with_seed(self):
|
||||||
|
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
|
||||||
|
result = self._rewrite_list_entry(orig, "host:9222", "99")
|
||||||
|
assert result == "ws://host:9222/fingerprint/99/devtools/page/DEF-456"
|
||||||
|
|
||||||
|
def test_list_rewrite_page_no_seed(self):
|
||||||
|
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
|
||||||
|
result = self._rewrite_list_entry(orig, "host:9222", None)
|
||||||
|
assert result == "ws://host:9222/devtools/page/DEF-456"
|
||||||
|
|
||||||
|
def test_list_rewrite_browser(self):
|
||||||
|
orig = "ws://127.0.0.1:5100/devtools/browser/XYZ"
|
||||||
|
result = self._rewrite_list_entry(orig, "host:9222", "seed1")
|
||||||
|
assert result == "ws://host:9222/fingerprint/seed1/devtools/browser/XYZ"
|
||||||
|
|
||||||
|
def test_wss_scheme_version(self):
|
||||||
|
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
|
||||||
|
result = self._rewrite_version(orig, "host:443", "seed1", scheme="wss")
|
||||||
|
assert result == "wss://host:443/fingerprint/seed1/devtools/browser/abc-123"
|
||||||
|
|
||||||
|
def test_wss_scheme_list(self):
|
||||||
|
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
|
||||||
|
result = self._rewrite_list_entry(orig, "host:443", "seed1", scheme="wss")
|
||||||
|
assert result == "wss://host:443/fingerprint/seed1/devtools/page/DEF-456"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Connection refcounting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectionTracking:
|
||||||
|
"""Test ChromePool.connect() / disconnect() without real Chrome."""
|
||||||
|
|
||||||
|
def _make_pool(self):
|
||||||
|
return ChromePool(
|
||||||
|
binary="/fake/chrome",
|
||||||
|
global_args=[],
|
||||||
|
headless=True,
|
||||||
|
data_dir="/tmp/test-cloakserve",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_connect_increments(self):
|
||||||
|
pool = self._make_pool()
|
||||||
|
pool.connect("seed1")
|
||||||
|
assert pool._connections["seed1"] == 1
|
||||||
|
pool.connect("seed1")
|
||||||
|
assert pool._connections["seed1"] == 2
|
||||||
|
|
||||||
|
def test_disconnect_decrements(self):
|
||||||
|
pool = self._make_pool()
|
||||||
|
pool.connect("seed1")
|
||||||
|
pool.connect("seed1")
|
||||||
|
pool.disconnect("seed1")
|
||||||
|
assert pool._connections["seed1"] == 1
|
||||||
|
|
||||||
|
def test_disconnect_to_zero_removes_key(self):
|
||||||
|
pool = self._make_pool()
|
||||||
|
pool.connect("seed1")
|
||||||
|
pool.disconnect("seed1")
|
||||||
|
assert "seed1" not in pool._connections
|
||||||
|
|
||||||
|
def test_disconnect_below_zero_safe(self):
|
||||||
|
pool = self._make_pool()
|
||||||
|
pool.disconnect("nonexistent")
|
||||||
|
assert "nonexistent" not in pool._connections
|
||||||
|
|
||||||
|
def test_multiple_seeds_independent(self):
|
||||||
|
pool = self._make_pool()
|
||||||
|
pool.connect("a")
|
||||||
|
pool.connect("b")
|
||||||
|
pool.connect("a")
|
||||||
|
pool.disconnect("a")
|
||||||
|
assert pool._connections["a"] == 1
|
||||||
|
assert pool._connections["b"] == 1
|
||||||
@@ -133,10 +133,14 @@ 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
|
||||||
assert any("Apple" in a for a in args)
|
# GPU flags removed — binary auto-generates from seed + platform
|
||||||
|
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
|
||||||
assert any("NVIDIA" in a for a in args)
|
# GPU flags removed — binary auto-generates from seed + platform
|
||||||
|
assert not any("fingerprint-gpu-vendor" in a for a in args)
|
||||||
|
assert not any("fingerprint-gpu-renderer" in a for a in args)
|
||||||
|
|||||||
+17
-12
@@ -4,7 +4,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cloakbrowser.browser import _maybe_resolve_geoip
|
from cloakbrowser.browser import maybe_resolve_geoip
|
||||||
from cloakbrowser.geoip import (
|
from cloakbrowser.geoip import (
|
||||||
COUNTRY_LOCALE_MAP,
|
COUNTRY_LOCALE_MAP,
|
||||||
_is_private_ip,
|
_is_private_ip,
|
||||||
@@ -92,51 +92,56 @@ def test_resolve_geo_returns_none_when_db_missing():
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _maybe_resolve_geoip (browser.py helper)
|
# maybe_resolve_geoip (browser.py helper)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_maybe_resolve_skips_when_geoip_false():
|
def test_maybe_resolve_skips_when_geoip_false():
|
||||||
tz, loc = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
tz, loc, ip = 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 = _maybe_resolve_geoip(True, None, None, None)
|
tz, loc, ip = 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 not trigger geoip resolution."""
|
"""Explicit values should still resolve exit IP for WebRTC."""
|
||||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
|
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="1.2.3.4"):
|
||||||
|
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", return_value=("America/New_York", "en-US")):
|
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
|
||||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
|
tz, loc, ip = 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", return_value=("America/New_York", "en-US")):
|
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
|
||||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
|
tz, loc, ip = 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", return_value=("Europe/Berlin", "de-DE")):
|
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")):
|
||||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
tz, loc, ip = 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"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -279,6 +279,67 @@ 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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
+622
-14
@@ -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=True, humanize=True)
|
browser = launch(headless=False, 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=True, humanize=True)
|
browser = launch(headless=False, 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=True, humanize=True)
|
browser = launch(headless=False, 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=True, humanize=True)
|
browser = launch(headless=False, 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=True, humanize=True)
|
browser = launch(headless=False, 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=True, humanize=True)
|
browser = launch(headless=False, 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 = ''
|
PROXY = None
|
||||||
|
|
||||||
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=True, humanize=True, proxy=self.PROXY, geoip=True)
|
browser = launch(headless=False, 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,13 +661,12 @@ class TestBrowserBotDetection:
|
|||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
class TestAsyncEndToEnd:
|
class TestAsyncEndToEnd:
|
||||||
def test_async_launch_click_fill(self):
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_launch_click_fill(self):
|
||||||
"""launch_async(humanize=True) — async page.click and page.fill work end-to-end."""
|
"""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
|
||||||
|
|
||||||
async def _run():
|
browser = await launch_async(headless=False, humanize=True)
|
||||||
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"
|
||||||
@@ -685,7 +684,616 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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"))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
|
||||||
@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):
|
||||||
|
|||||||
@@ -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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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"))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8"))
|
||||||
@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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, 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()
|
||||||
|
|||||||
+142
-27
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
|
from cloakbrowser.browser import (
|
||||||
|
_is_socks_proxy,
|
||||||
|
_parse_proxy_url,
|
||||||
|
_resolve_proxy_config,
|
||||||
|
maybe_resolve_geoip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestParseProxyUrl:
|
class TestParseProxyUrl:
|
||||||
@@ -38,23 +43,30 @@ 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):
|
||||||
assert _build_proxy_kwargs(None) == {}
|
kwargs, args = _resolve_proxy_config(None)
|
||||||
|
assert kwargs == {}
|
||||||
|
assert args == []
|
||||||
|
|
||||||
def test_simple_proxy(self):
|
def test_simple_proxy(self):
|
||||||
result = _build_proxy_kwargs("http://proxy:8080")
|
kwargs, args = _resolve_proxy_config("http://proxy:8080")
|
||||||
assert result == {"proxy": {"server": "http://proxy:8080"}}
|
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
|
||||||
|
assert args == []
|
||||||
|
|
||||||
def test_proxy_with_auth(self):
|
def test_proxy_with_auth(self):
|
||||||
result = _build_proxy_kwargs("http://user:pass@proxy:8080")
|
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||||
assert result == {
|
assert kwargs == {
|
||||||
"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"}
|
||||||
result = _build_proxy_kwargs(proxy_dict)
|
kwargs, args = _resolve_proxy_config(proxy_dict)
|
||||||
assert result == {"proxy": proxy_dict}
|
assert kwargs == {"proxy": proxy_dict}
|
||||||
|
assert args == []
|
||||||
|
|
||||||
def test_proxy_dict_with_auth(self):
|
def test_proxy_dict_with_auth(self):
|
||||||
proxy_dict = {
|
proxy_dict = {
|
||||||
@@ -63,57 +75,82 @@ class TestBuildProxyKwargs:
|
|||||||
"password": "pass",
|
"password": "pass",
|
||||||
"bypass": ".example.com",
|
"bypass": ".example.com",
|
||||||
}
|
}
|
||||||
result = _build_proxy_kwargs(proxy_dict)
|
kwargs, args = _resolve_proxy_config(proxy_dict)
|
||||||
assert result == {"proxy": proxy_dict}
|
assert kwargs == {"proxy": proxy_dict}
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
|
||||||
class TestMaybeResolveGeoip:
|
class TestMaybeResolveGeoip:
|
||||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
|
||||||
def test_geoip_with_string_proxy(self, mock_geo):
|
def test_geoip_with_string_proxy(self, mock_geo):
|
||||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
tz, locale, ip = 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", return_value=("Europe/London", "en-GB"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "5.6.7.8"))
|
||||||
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 = _maybe_resolve_geoip(True, proxy_dict, None, None)
|
tz, locale, ip = 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 = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
tz, locale, ip = 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 = _maybe_resolve_geoip(True, None, None, None)
|
tz, locale, ip = 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", return_value=("Asia/Tokyo", "ja-JP"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Asia/Tokyo", "ja-JP", "9.8.7.6"))
|
||||||
def test_geoip_preserves_explicit_timezone(self, mock_geo):
|
def test_geoip_preserves_explicit_timezone(self, mock_geo):
|
||||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
|
tz, locale, _ip = 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", return_value=("America/New_York", "en-US"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
|
||||||
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 = _maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
|
tz, locale, _ip = 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", return_value=("America/New_York", "en-US"))
|
@patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4"))
|
||||||
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 = _maybe_resolve_geoip(True, "proxy:8080", None, None)
|
tz, locale, _ip = 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)."""
|
||||||
@@ -146,8 +183,86 @@ 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_build_proxy_kwargs_bare(self):
|
def test_resolve_proxy_config_bare(self):
|
||||||
r = _build_proxy_kwargs("user:pass@proxy:8080")
|
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
|
||||||
assert r["proxy"]["username"] == "user"
|
assert kwargs["proxy"]["username"] == "user"
|
||||||
assert r["proxy"]["password"] == "pass"
|
assert kwargs["proxy"]["password"] == "pass"
|
||||||
assert "user" not in r["proxy"]["server"]
|
assert "user" not in kwargs["proxy"]["server"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsSocksProxy:
|
||||||
|
def test_socks5_string(self):
|
||||||
|
assert _is_socks_proxy("socks5://user:pass@host:1080") is True
|
||||||
|
|
||||||
|
def test_socks5h_string(self):
|
||||||
|
assert _is_socks_proxy("socks5h://host:1080") is True
|
||||||
|
|
||||||
|
def test_socks5_uppercase(self):
|
||||||
|
assert _is_socks_proxy("SOCKS5://host:1080") is True
|
||||||
|
|
||||||
|
def test_http_string(self):
|
||||||
|
assert _is_socks_proxy("http://host:8080") is False
|
||||||
|
|
||||||
|
def test_dict_socks5(self):
|
||||||
|
assert _is_socks_proxy({"server": "socks5://host:1080"}) is True
|
||||||
|
|
||||||
|
def test_dict_http(self):
|
||||||
|
assert _is_socks_proxy({"server": "http://host:8080"}) is False
|
||||||
|
|
||||||
|
def test_none(self):
|
||||||
|
assert _is_socks_proxy(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProxyConfig:
|
||||||
|
def test_none(self):
|
||||||
|
kwargs, args = _resolve_proxy_config(None)
|
||||||
|
assert kwargs == {}
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
def test_http_string_returns_playwright_dict(self):
|
||||||
|
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
|
||||||
|
assert "proxy" in kwargs
|
||||||
|
assert kwargs["proxy"]["server"] == "http://proxy:8080"
|
||||||
|
assert kwargs["proxy"]["username"] == "user"
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
def test_http_dict_passthrough(self):
|
||||||
|
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
|
||||||
|
kwargs, args = _resolve_proxy_config(proxy)
|
||||||
|
assert kwargs == {"proxy": proxy}
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
def test_socks5_string_returns_chrome_arg(self):
|
||||||
|
kwargs, args = _resolve_proxy_config("socks5://user:pass@host:1080")
|
||||||
|
assert kwargs == {}
|
||||||
|
assert args == ["--proxy-server=socks5://user:pass@host:1080"]
|
||||||
|
|
||||||
|
def test_socks5_no_auth_returns_chrome_arg(self):
|
||||||
|
kwargs, args = _resolve_proxy_config("socks5://host:1080")
|
||||||
|
assert kwargs == {}
|
||||||
|
assert args == ["--proxy-server=socks5://host:1080"]
|
||||||
|
|
||||||
|
def test_socks5h_returns_chrome_arg(self):
|
||||||
|
kwargs, args = _resolve_proxy_config("socks5h://user:pass@host:1080")
|
||||||
|
assert kwargs == {}
|
||||||
|
assert args == ["--proxy-server=socks5h://user:pass@host:1080"]
|
||||||
|
|
||||||
|
def test_socks5_dict_reconstructs_url(self):
|
||||||
|
proxy = {"server": "socks5://host:1080", "username": "user", "password": "p@ss"}
|
||||||
|
kwargs, args = _resolve_proxy_config(proxy)
|
||||||
|
assert kwargs == {}
|
||||||
|
assert len(args) == 1
|
||||||
|
assert args[0].startswith("--proxy-server=socks5://user:p%40ss@host:1080")
|
||||||
|
|
||||||
|
def test_socks5_dict_ipv6_preserves_brackets(self):
|
||||||
|
proxy = {"server": "socks5://[::1]:1080", "username": "user", "password": "pass"}
|
||||||
|
kwargs, args = _resolve_proxy_config(proxy)
|
||||||
|
assert kwargs == {}
|
||||||
|
assert "[::1]" in args[0]
|
||||||
|
|
||||||
|
def test_socks5_dict_with_bypass(self):
|
||||||
|
proxy = {"server": "socks5://host:1080", "bypass": ".example.com"}
|
||||||
|
kwargs, args = _resolve_proxy_config(proxy)
|
||||||
|
assert kwargs == {}
|
||||||
|
assert "--proxy-server=socks5://host:1080" in args
|
||||||
|
assert "--proxy-bypass-list=.example.com" in args
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# 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
Reference in New Issue
Block a user