mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d63ddd4d43 | ||
|
|
03a0ad732f | ||
|
|
c58b691f1c | ||
|
|
1b91a33e51 | ||
|
|
1bfd5ca036 | ||
|
|
f46f8e9364 | ||
|
|
592b3d5661 | ||
|
|
a0a8210e35 | ||
|
|
468964ff30 | ||
|
|
c1b93e634b | ||
|
|
5ccb4a32a5 | ||
|
|
49d80d3b57 | ||
|
|
2813b3dc4c | ||
|
|
6550f3ad6c | ||
|
|
132cafe13c | ||
|
|
6c94b9e985 | ||
|
|
fdc1ae0484 | ||
|
|
2ded0c1866 | ||
|
|
f91700c4a4 | ||
|
|
1380c86847 | ||
|
|
83e3b30117 | ||
|
|
5649620545 | ||
|
|
d2a42fc86b | ||
|
|
1af25d67bc | ||
|
|
1bef989404 | ||
|
|
0aa4ea56bd | ||
|
|
c0ba21faa1 | ||
|
|
b501d8f158 | ||
|
|
6007a6e511 | ||
|
|
96c55352e0 | ||
|
|
5d35fb9e4c | ||
|
|
c966e046e7 | ||
|
|
767eb16a82 | ||
|
|
04255cf412 | ||
|
|
1fb554e061 | ||
|
|
748013bf83 | ||
|
|
eeea366047 | ||
|
|
858c0d0e85 | ||
|
|
e615349f1e | ||
|
|
1c93951f23 | ||
|
|
7bf8836683 | ||
|
|
23a9c4d4bd | ||
|
|
c8e09656aa |
@@ -0,0 +1 @@
|
||||
ko_fi: cloakhq
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or detection issue
|
||||
labels: bug
|
||||
---
|
||||
|
||||
Description: <!-- What happened? What did you expect? -->
|
||||
|
||||
CloakBrowser version: <!-- pip show cloakbrowser / npm list cloakbrowser -->
|
||||
|
||||
Wrapper: <!-- Python or JavaScript -->
|
||||
|
||||
Environment: <!-- OS, Docker y/n, base image, architecture -->
|
||||
|
||||
Launch options:
|
||||
|
||||
|
||||
Tested with a different IP or proxy? <!-- Yes (same result) / Yes (works with different IP) / No -->
|
||||
|
||||
Works outside Docker / on host machine? <!-- Yes / No / Not using Docker -->
|
||||
|
||||
Steps to reproduce:
|
||||
|
||||
|
||||
Error output / screenshots:
|
||||
|
||||
Dockerfile (if applicable):
|
||||
|
||||
Additional notes:
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Attest Release Binary
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. chromium-v145.0.7632.159.2)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
attest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Sigstore OIDC
|
||||
attestations: write # GitHub attestation API
|
||||
contents: write # Download release assets
|
||||
steps:
|
||||
- name: Download release binaries
|
||||
run: gh release download ${{ github.event.inputs.tag }} --repo CloakHQ/cloakbrowser --pattern "cloakbrowser-*.tar.gz" --pattern "cloakbrowser-*.zip"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: |
|
||||
cloakbrowser-*.tar.gz
|
||||
cloakbrowser-*.zip
|
||||
@@ -10,8 +10,8 @@ jobs:
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
@@ -22,8 +22,8 @@ jobs:
|
||||
javascript:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install and build
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
job:
|
||||
description: 'Job to run (leave empty to run all)'
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- ''
|
||||
- publish-pypi
|
||||
- publish-npm
|
||||
- publish-docker
|
||||
|
||||
concurrency:
|
||||
group: publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Python tests
|
||||
run: |
|
||||
pip install -e ".[dev]" pytest pytest-asyncio
|
||||
pytest tests/ -v -m "not slow"
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: JavaScript tests
|
||||
run: cd js && npm ci && npm run build && npm test
|
||||
|
||||
validate-version:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Check tag matches package versions
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
PY=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
||||
JS=$(python -c 'import json; print(json.load(open("js/package.json"))["version"])')
|
||||
echo "Tag: $TAG | Python: $PY | npm: $JS"
|
||||
[ "$TAG" = "$PY" ] || { echo "ERROR: tag v$TAG != _version.py $PY"; exit 1; }
|
||||
[ "$TAG" = "$JS" ] || { echo "ERROR: tag v$TAG != package.json $JS"; exit 1; }
|
||||
|
||||
publish-pypi:
|
||||
needs: [test, validate-version]
|
||||
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Build
|
||||
run: |
|
||||
pip install build
|
||||
python -m build
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1
|
||||
|
||||
publish-npm:
|
||||
needs: [test, validate-version]
|
||||
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Upgrade npm
|
||||
run: npm install -g npm@latest
|
||||
- name: Build
|
||||
run: cd js && npm ci && npm run build
|
||||
- name: Publish to npm
|
||||
run: cd js && npm publish --provenance --access public
|
||||
|
||||
publish-docker:
|
||||
needs: [test, validate-version]
|
||||
if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Cosign keyless signing + attestations
|
||||
contents: read
|
||||
attestations: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Extract version
|
||||
run: |
|
||||
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PAT }}
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
cloakhq/cloakbrowser:${{ env.VERSION }}
|
||||
cloakhq/cloakbrowser:latest
|
||||
provenance: true
|
||||
sbom: true
|
||||
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
- name: Sign image
|
||||
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: index.docker.io/cloakhq/cloakbrowser
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Release Binary
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. chromium-v145.0.7718.0)'
|
||||
required: true
|
||||
title:
|
||||
description: 'Release title (e.g. Chromium v145 — Stealth Build)'
|
||||
required: true
|
||||
default: 'Stealth Chromium Build'
|
||||
patch_count:
|
||||
description: 'Number of fingerprint patches'
|
||||
required: true
|
||||
default: '16'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: "${{ github.event.inputs.title }}"
|
||||
body: |
|
||||
## Stealth Chromium Build
|
||||
|
||||
Pre-built Chromium with ${{ github.event.inputs.patch_count }} source-level fingerprint patches.
|
||||
|
||||
### Install
|
||||
```bash
|
||||
pip install cloakbrowser # Python
|
||||
npm install cloakbrowser # JavaScript
|
||||
# Binary auto-downloads on first launch
|
||||
```
|
||||
|
||||
> Binary integrity is verified automatically via SHA-256 checksums on download.
|
||||
>
|
||||
> Release signed with CloakHQ GPG key: `C60C0DDC9D0DE2DD`
|
||||
@@ -56,9 +56,14 @@ test-infra/
|
||||
# Website (deployed separately)
|
||||
site/
|
||||
|
||||
# Browser profile manager (deployed separately)
|
||||
manager/
|
||||
|
||||
# Release scripts
|
||||
publish.sh
|
||||
deploy.sh
|
||||
.env
|
||||
debug
|
||||
publish-docker.sh
|
||||
captures
|
||||
20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt
|
||||
|
||||
@@ -6,6 +6,86 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
---
|
||||
|
||||
## [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]** 9 new fingerprint patches covering additional browser APIs and cross-platform consistency
|
||||
- **[binary]** New `--fingerprint-noise` flag — disable noise injection while keeping deterministic fingerprint seed active
|
||||
- **[binary]** Improved fingerprint noise reliability and determinism across all patched APIs
|
||||
- **[binary]** Expanded platform-aware fingerprint spoofing for more realistic cross-platform profiles
|
||||
- **[binary]** Font rendering and detection accuracy improvements for Windows profiles
|
||||
- **[binary]** Removed experimental patches that caused compatibility issues with certain anti-bot systems
|
||||
- **[binary]** Docker/VNC environment compatibility improvements
|
||||
- **[wrapper]** Fix Playwright cleanup — `pw.stop()` now runs even if `browser.close()` raises or is cancelled (fixes #60, thanks [@dgtlmoon](https://github.com/dgtlmoon))
|
||||
- **[meta]** Pin GitHub Actions to commit SHAs, add Dependabot for automated dependency updates
|
||||
|
||||
## [0.3.18] — 2026-03-15
|
||||
|
||||
- **[wrapper]** Fix welcome banner printing to stdout — now writes to stderr so it won't corrupt JSON output in programmatic usage (fixes #59)
|
||||
- **[wrapper]** Fix `cloakserve` Docker WebGL by adding `--ignore-gpu-blocklist` flag
|
||||
- **[docs]** Add Crawlee integration example
|
||||
- **[meta]** Add GitHub issue template for bug reports
|
||||
|
||||
## [0.3.17] — 2026-03-15
|
||||
|
||||
- **[binary]** Windows x64 build upgraded to 145.0.7632.159.7 — 33 source-level C++ patches, matching Linux
|
||||
- **[wrapper]** Auto-inject GPU blocklist bypass for headed mode and Windows — fixes WebGL/WebGPU on software GPUs in Docker/VNC (fixes #56)
|
||||
- **[wrapper]** Add 8 framework integration examples (Scrapy, Crawlee, BrowserBase, etc.) and README integrations section
|
||||
|
||||
## [0.3.16] — 2026-03-14
|
||||
|
||||
- **[binary]** Linux arm64 build available — Raspberry Pi, AWS Graviton, Oracle Ampere now supported
|
||||
- **[wrapper]** Add donate link to first-launch welcome banner
|
||||
|
||||
## [0.3.15] — 2026-03-13
|
||||
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.7 — 33 source-level C++ patches
|
||||
- **[binary]** StorageBuckets API quota normalization — closes the last storage-based incognito detection vector
|
||||
- **[wrapper]** Fix non-ASCII character support in humanized typing — Cyrillic, CJK, and emoji now type correctly (thanks [@evelaa123](https://github.com/evelaa123))
|
||||
|
||||
## [0.3.14] — 2026-03-12
|
||||
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.6 — fix persistent context detection by FingerprintJS
|
||||
- **[binary]** Storage quota normalization for persistent context profiles
|
||||
- **[binary]** Fix outerHeight calculation for non-incognito contexts
|
||||
- **[wrapper]** Add CLI for binary management — `python -m cloakbrowser install` / `npx cloakbrowser install` with visible download progress (closes #43)
|
||||
|
||||
## [0.3.13] — 2026-03-10
|
||||
|
||||
- **[wrapper]** Suppress Playwright's `--enable-unsafe-swiftshader` default arg — eliminates SwiftShader software renderer detection signal, letting the binary's GPU spoofing work cleanly
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.5 — fix WebGPU adapter limits and features for NVIDIA profiles
|
||||
|
||||
## [0.3.12] — 2026-03-10
|
||||
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.4
|
||||
- **[binary]** Native locale spoofing — new C++ patch replaces detectable CDP-level locale emulation
|
||||
- **[binary]** WebGPU fingerprint hardening — spoof adapter features, limits, device ID, and subgroup sizes for cross-API consistency
|
||||
- **[binary]** Restore WebGPU blocklist bypass auto-injection (safe now with full adapter spoofing)
|
||||
- **[binary]** Fix WebGL renderer suffix — remove driver version string flagged by BrowserLeaks
|
||||
- **[wrapper]** Use binary flags for timezone/locale instead of CDP emulation — eliminates a detection vector
|
||||
- **[wrapper]** Support bare proxy format (`user:pass@host:port`) without scheme prefix
|
||||
- **[wrapper]** Use ANGLE-wrapped GPU strings in default stealth args for realistic WebGL fingerprint
|
||||
|
||||
## [0.3.11] — 2026-03-08
|
||||
|
||||
- **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123))
|
||||
- **[binary]** CDP input stealth — 4 new source-level C++ patches removing automation signals from input events
|
||||
- **[binary]** Support `--remote-debugging-address` flag for CDP bind address — eliminates the socat workaround in `cloakserve` Docker mode
|
||||
- **[wrapper]** `cloakserve` updated to use `--remote-debugging-address=0.0.0.0` directly — socat dependency removed from Docker image
|
||||
- **[binary]** GPU fingerprint accuracy improvements — renderer suffix strings now match real Chrome output across Windows and Linux profiles
|
||||
- **[binary]** GPU capability accuracy fix for NVIDIA profiles — spoofed values now reflect actual hardware limits
|
||||
- **[binary]** macOS GPU accuracy fix — GPU model database reference corrected for Apple Silicon profiles
|
||||
- **[binary]** Fix CDP input synthesis — a guard condition prevented the patch from activating; now fires correctly on all input events
|
||||
- **[binary]** Code quality hardening across patches — correctness and reliability fixes
|
||||
|
||||
## [0.3.10] — 2026-03-07
|
||||
|
||||
- **[binary]** Upgrade Linux build to 145.0.7632.159.2
|
||||
- **[binary]** Fix detection regression caused by unnecessary browser flag (fixes #16)
|
||||
- **[binary]** Fix fingerprint consistency in offline audio rendering
|
||||
- **[wrapper]** Add `cloakserve` CDP server mode for Docker — exposes Chrome DevTools Protocol on `0.0.0.0:9222` for external tool integration
|
||||
- **[wrapper]** Add wrapper regression tests: page.goto timing with stealth init (#9), add_init_script compatibility with proxy auth (#27)
|
||||
|
||||
## [0.3.9] — 2026-03-05
|
||||
|
||||
- **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
|
||||
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
|
||||
xvfb xdotool \
|
||||
curl ca-certificates socat \
|
||||
curl ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -20,7 +20,7 @@ WORKDIR /app
|
||||
# Python wrapper
|
||||
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
|
||||
COPY cloakbrowser/ cloakbrowser/
|
||||
RUN pip install --no-cache-dir .
|
||||
RUN pip install --no-cache-dir ".[serve]"
|
||||
|
||||
# JS wrapper
|
||||
COPY js/ js/
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
|
||||
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
|
||||
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
|
||||
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ko-fi.com/cloakhq"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi"></a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
@@ -35,7 +40,8 @@ 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>
|
||||
</p>
|
||||
|
||||
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
||||
- **42 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
|
||||
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
|
||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||
- **Auto-updating binary** — background update checks, always on the latest stealth build
|
||||
@@ -108,15 +114,29 @@ page.goto("https://example.com")
|
||||
|
||||
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
|
||||
|
||||
## Latest: v0.3.8 (Chromium 145.0.7632.159)
|
||||
## Browser Profile Manager
|
||||
|
||||
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
|
||||
- **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more)
|
||||
Self-hosted alternative to Multilogin, GoLogin, and AdsPower. Create browser profiles with unique fingerprints, proxies, and persistent sessions. Launch and interact with them in your browser via noVNC.
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 -v cloakprofiles:/data cloakhq/cloakbrowser-manager
|
||||
```
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **Launch**. Done.
|
||||
|
||||
→ **[CloakBrowser Manager](https://github.com/CloakHQ/CloakBrowser-Manager)** — free, open source (MIT)
|
||||
|
||||
---
|
||||
|
||||
## Latest: v0.3.19 (Chromium 145.0.7632.159.8)
|
||||
|
||||
- **`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`
|
||||
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 4 source-level patches covering pointer, keyboard, and mouse behavior
|
||||
- **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation
|
||||
- **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency
|
||||
- **42 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
|
||||
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
|
||||
- **Full stealth audit** — every patch reviewed for detection vectors, multiple fixes shipped
|
||||
- **CDP hardening** — audited and patched known automation detection vectors
|
||||
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
|
||||
- **Playwright + Puppeteer from one package** — `import from 'cloakbrowser'` or `import from 'cloakbrowser/puppeteer'`. Same binary, your choice of API
|
||||
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
|
||||
|
||||
See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
||||
@@ -127,7 +147,7 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
|
||||
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser.
|
||||
- **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface.
|
||||
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
|
||||
- **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path.
|
||||
- **Works with AI agents and automation frameworks** — drop-in stealth for browser-use, Crawl4AI, Scrapling, Stagehand, LangChain, Selenium, and more. See [integrations](#framework-integrations).
|
||||
|
||||
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
|
||||
|
||||
@@ -175,6 +195,11 @@ All tests verified against live detection services. Last tested: Mar 2026 (Chrom
|
||||
<br><em>FingerprintJS web-scraping demo — data served, not blocked</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://i.imgur.com/srCcFtK.png" width="600" alt="deviceandbrowserinfo.com — You are human!">
|
||||
<br><em>deviceandbrowserinfo.com behavioral bot detection — "You are human!" with humanize=True (24/24 signals passed)</em>
|
||||
</p>
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser |
|
||||
@@ -196,7 +221,7 @@ CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chrom
|
||||
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
|
||||
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
|
||||
|
||||
The binary includes 26 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, and automation signal removal.
|
||||
The binary includes 42 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking.
|
||||
|
||||
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
|
||||
|
||||
@@ -224,7 +249,7 @@ browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com",
|
||||
# With extra Chrome args
|
||||
browser = launch(args=["--disable-gpu"])
|
||||
|
||||
# With timezone and locale (sets both binary flags and Playwright context)
|
||||
# With timezone and locale (sets binary flags — no detectable CDP emulation)
|
||||
browser = launch(timezone="America/New_York", locale="en-US")
|
||||
|
||||
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
|
||||
@@ -233,6 +258,12 @@ browser = launch(proxy="http://proxy:8080", geoip=True)
|
||||
# Explicit timezone/locale always win over auto-detection
|
||||
browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London")
|
||||
|
||||
# Human-like mouse, keyboard, and scroll behavior
|
||||
browser = launch(humanize=True)
|
||||
|
||||
# With slower, more deliberate movements
|
||||
browser = launch(humanize=True, human_preset="careful")
|
||||
|
||||
# Without default stealth args (bring your own fingerprint flags)
|
||||
browser = launch(stealth_args=False, args=["--fingerprint=12345"])
|
||||
```
|
||||
@@ -275,7 +306,7 @@ context.close()
|
||||
|
||||
### `launch_persistent_context()`
|
||||
|
||||
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan.
|
||||
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
|
||||
|
||||
Use this when you need to:
|
||||
- **Stay logged in** across runs (cookies/sessions survive restarts)
|
||||
@@ -300,6 +331,28 @@ Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `vie
|
||||
|
||||
Async version: `launch_persistent_context_async()`.
|
||||
|
||||
**Storage quota and detection tradeoff:** By default, the binary normalizes storage quota to pass FingerprintJS, which blocks persistent contexts that report non-incognito quota values. This means detection services that penalize incognito mode (like BrowserScan's `notPrivate` check, -10 points) will still flag it. If your target site penalizes incognito but doesn't use FingerprintJS, set a higher quota to appear as a regular profile:
|
||||
|
||||
```python
|
||||
ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quota=5000"])
|
||||
```
|
||||
|
||||
| Quota setting | FingerprintJS | BrowserScan `notPrivate` |
|
||||
|---|---|---|
|
||||
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
|
||||
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
|
||||
|
||||
### CLI
|
||||
|
||||
Pre-download the binary or check installation status from the command line:
|
||||
|
||||
```bash
|
||||
python -m cloakbrowser install # Download binary with progress output
|
||||
python -m cloakbrowser info # Show version, path, platform
|
||||
python -m cloakbrowser update # Check for and download newer binary
|
||||
python -m cloakbrowser clear-cache # Remove cached binaries
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
```python
|
||||
@@ -307,7 +360,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
|
||||
|
||||
# Check binary installation status
|
||||
print(binary_info())
|
||||
# {'version': '145.0.7632.159', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
# {'version': '145.0.7632.159.2', 'platform': 'linux-x64', 'installed': True, ...}
|
||||
|
||||
# Force re-download
|
||||
clear_cache()
|
||||
@@ -335,6 +388,7 @@ const browser = await launch({
|
||||
args: ['--fingerprint=12345'],
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
humanize: true,
|
||||
});
|
||||
|
||||
// Convenience: browser + context in one call
|
||||
@@ -386,6 +440,71 @@ console.log(binaryInfo());
|
||||
clearCache();
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
```python
|
||||
browser = launch(humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto("https://example.com")
|
||||
page.locator("#email").fill("user@example.com") # per-character timing, thinking pauses
|
||||
page.locator("button[type=submit]").click() # Bézier curve, realistic aim point
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch({ humanize: true });
|
||||
```
|
||||
|
||||
**What changes:**
|
||||
|
||||
| Interaction | Default | With `humanize=True` |
|
||||
|---|---|---|
|
||||
| Mouse movement | Instant teleport | Bézier curve with easing and slight overshoot |
|
||||
| Clicks | Instant | Realistic aim point + hold duration |
|
||||
| Keyboard | Instant fill | Per-character timing, thinking pauses, occasional typos with self-correction |
|
||||
| Scroll | Jump | Accelerate → cruise → decelerate micro-steps |
|
||||
| `fill()` | Instant value set | Clears existing content, types character by character |
|
||||
|
||||
**Presets** — `default` (normal speed) or `careful` (slower, more deliberate, idle micro-movements between actions):
|
||||
|
||||
```python
|
||||
browser = launch(humanize=True, human_preset="careful")
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch({ humanize: true, humanPreset: 'careful' });
|
||||
```
|
||||
|
||||
**Custom config** — override any parameter:
|
||||
|
||||
```python
|
||||
browser = launch(humanize=True, human_config={
|
||||
"mistype_chance": 0.05, # 5% typo rate with self-correction
|
||||
"typing_delay": 100, # slower typing (ms per character)
|
||||
"idle_between_actions": True, # micro-movements between clicks
|
||||
"idle_between_duration": [0.3, 0.8], # idle duration range (seconds)
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch({
|
||||
humanize: true,
|
||||
humanConfig: {
|
||||
mistype_chance: 0.05,
|
||||
typing_delay: 100,
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [0.3, 0.8],
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env Variable | Default | Description |
|
||||
@@ -450,8 +569,11 @@ Supported by the binary but **not set by default** — pass via `args` to custom
|
||||
| `--fingerprint-platform-version` | Client Hints platform version |
|
||||
| `--fingerprint-location` | Geolocation coordinates |
|
||||
| `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) |
|
||||
| `--fingerprint-locale` | Locale (e.g. `en-US`) |
|
||||
| `--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-fonts-dir` | Path to cross-platform font directory |
|
||||
| `--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 |
|
||||
|
||||
> **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.
|
||||
@@ -491,14 +613,44 @@ browser = launch(args=[
|
||||
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load
|
||||
- [`stealth-test.ts`](js/examples/stealth-test.ts) — Run against 6 detection sites
|
||||
|
||||
### Framework Integrations
|
||||
|
||||
CloakBrowser works with any framework that uses Playwright or Chromium:
|
||||
|
||||
```python
|
||||
# Option 1: Framework launches our binary directly (Selenium, Stagehand, UC)
|
||||
from cloakbrowser.download import ensure_binary
|
||||
from cloakbrowser.config import get_default_stealth_args
|
||||
binary_path = ensure_binary() # auto-downloads if needed
|
||||
stealth_args = get_default_stealth_args() # all fingerprint flags
|
||||
|
||||
# Option 2: CloakBrowser launches first, framework connects via CDP (browser-use, Crawl4AI, Scrapling)
|
||||
from cloakbrowser import launch_async
|
||||
browser = await launch_async(args=["--remote-debugging-port=9242"])
|
||||
# Connect your framework to http://127.0.0.1:9242 — all stealth flags are set
|
||||
```
|
||||
|
||||
| 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) |
|
||||
| [Crawl4AI](https://github.com/unclecode/crawl4ai) | 58K | Python | [`crawl4ai_example.py`](examples/integrations/crawl4ai_example.py) |
|
||||
| [Crawlee](https://github.com/apify/crawlee-python) | 8.6K | Python | [`crawlee_example.py`](examples/integrations/crawlee_example.py) |
|
||||
| [Scrapling](https://github.com/D4Vinci/Scrapling) | 21K | Python | [`scrapling_example.py`](examples/integrations/scrapling_example.py) |
|
||||
| [Stagehand](https://github.com/browserbase/stagehand) | 21K | TypeScript | [`stagehand.ts`](js/examples/stagehand.ts) |
|
||||
| [LangChain](https://github.com/langchain-ai/langchain) | 100K+ | Python | [`langchain_loader.py`](examples/integrations/langchain_loader.py) |
|
||||
| [Selenium](https://github.com/SeleniumHQ/selenium) | — | Python | [`selenium_example.py`](examples/integrations/selenium_example.py) |
|
||||
| [undetected-chromedriver](https://github.com/ultrafunkamsterdam/undetected-chromedriver) | 12K | Python | [`undetected_chromedriver.py`](examples/integrations/undetected_chromedriver.py) |
|
||||
| [agent-browser](https://github.com/nichochar/agent-browser) | — | Shell | [`agent_browser.sh`](examples/integrations/agent_browser.sh) |
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 26 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 26 | ✅ Latest |
|
||||
| Linux x86_64 | 145 | 42 | ✅ Latest |
|
||||
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ |
|
||||
| Windows x86_64 | 145 | 33 | ✅ |
|
||||
|
||||
The wrapper auto-downloads the correct binary for your platform.
|
||||
|
||||
@@ -602,7 +754,28 @@ services:
|
||||
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:
|
||||
|
||||
@@ -628,6 +801,15 @@ COPY your_script.py /app/
|
||||
CMD ["python", "your_script.py"]
|
||||
```
|
||||
|
||||
**Building your own image from pip** — use `python -m cloakbrowser install` to download the binary during build with visible progress:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
RUN pip install cloakbrowser && python -m cloakbrowser install
|
||||
COPY your_script.py /app/
|
||||
CMD ["python", "/app/your_script.py"]
|
||||
```
|
||||
|
||||
**Building from source** — a [`Dockerfile`](Dockerfile) is also included if you prefer to build your own image:
|
||||
|
||||
```bash
|
||||
@@ -640,7 +822,9 @@ CloakBrowser works identically local, in Docker, and on VPS. No environment-spec
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Still getting blocked on aggressive sites (DataDome, Turnstile)?**
|
||||
---
|
||||
|
||||
### Still getting blocked on aggressive sites (DataDome, Turnstile)?
|
||||
|
||||
Some sites detect headless mode even with our C++ patches. Run in **headed mode** with a virtual display:
|
||||
|
||||
@@ -665,7 +849,9 @@ browser.close()
|
||||
|
||||
This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combined with a residential proxy, this passes even the most aggressive detection services. Datacenter IPs are often flagged by IP reputation regardless of browser fingerprint — a residential proxy makes the difference.
|
||||
|
||||
**Sites challenge fresh sessions but work after first visit**
|
||||
---
|
||||
|
||||
### Sites challenge fresh sessions but work after first visit
|
||||
|
||||
Some sites challenge first-time visitors with no cookies over HTTP/2. This affects all Chromium browsers, not just CloakBrowser. Use a persistent profile to warm up cookies once, then reuse across sessions:
|
||||
|
||||
@@ -699,7 +885,10 @@ ctx = await launchPersistentContext({ userDataDir: './profile' });
|
||||
|
||||
For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2.
|
||||
|
||||
**Something not working? Make sure you're on the latest version**
|
||||
---
|
||||
|
||||
### Something not working? Make sure you're on the latest version
|
||||
|
||||
Older versions may use outdated stealth args or download an older binary:
|
||||
```bash
|
||||
pip install -U cloakbrowser # Python
|
||||
@@ -707,64 +896,68 @@ npm install cloakbrowser@latest # JavaScript
|
||||
docker pull cloakhq/cloakbrowser:latest # Docker
|
||||
```
|
||||
|
||||
**Binary download fails / timeout**
|
||||
---
|
||||
|
||||
### Binary download fails / timeout
|
||||
|
||||
Set a custom download URL or use a local binary:
|
||||
```bash
|
||||
export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
|
||||
```
|
||||
|
||||
**New update broke something? Roll back to the previous version**
|
||||
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
|
||||
---
|
||||
|
||||
### New update broke something? Roll back to the previous version
|
||||
|
||||
Install a specific wrapper version to downgrade both the wrapper and the binary it downloads:
|
||||
```bash
|
||||
# Linux
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
|
||||
|
||||
# macOS
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
|
||||
|
||||
# Windows
|
||||
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
|
||||
pip install cloakbrowser==0.3.11 # Python
|
||||
npm install cloakbrowser@0.3.11 # JavaScript
|
||||
docker pull cloakhq/cloakbrowser:0.3.11 # Docker
|
||||
```
|
||||
Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch.
|
||||
|
||||
---
|
||||
|
||||
### macOS: "App is damaged" or Gatekeeper blocks launch
|
||||
|
||||
**macOS: "App is damaged" or Gatekeeper blocks launch**
|
||||
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
|
||||
```bash
|
||||
xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
|
||||
```
|
||||
|
||||
**"playwright install" vs CloakBrowser binary**
|
||||
---
|
||||
|
||||
### "playwright install" vs CloakBrowser binary
|
||||
|
||||
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
|
||||
```bash
|
||||
playwright install-deps chromium
|
||||
```
|
||||
|
||||
**macOS: Blocked on some sites that pass on Linux**
|
||||
---
|
||||
|
||||
### macOS: Blocked on some sites that pass on Linux
|
||||
|
||||
The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list).
|
||||
|
||||
**Site detects incognito / private browsing mode**
|
||||
---
|
||||
|
||||
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launch_persistent_context()` instead — it runs with a real user profile, so incognito detection passes:
|
||||
### Site detects incognito / private browsing mode
|
||||
|
||||
By default, `launch()` opens an incognito context. Some sites penalize this. Use `launch_persistent_context()` to get a real profile with cookie persistence:
|
||||
|
||||
```python
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
ctx = launch_persistent_context("./my-profile", headless=False)
|
||||
page = ctx.new_page()
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { launchPersistentContext } from 'cloakbrowser';
|
||||
If the site still flags incognito, raise the storage quota to appear as a regular browsing session. See the [storage quota tradeoff](#launch_persistent_context) for details on how this affects different detection services.
|
||||
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './my-profile',
|
||||
headless: false,
|
||||
});
|
||||
```
|
||||
---
|
||||
|
||||
This also gives you cookie and localStorage persistence across sessions.
|
||||
|
||||
**reCAPTCHA v3 scores are low (0.1–0.3)**
|
||||
### reCAPTCHA v3 scores are low (0.1–0.3)
|
||||
|
||||
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||
|
||||
@@ -786,7 +979,7 @@ await new Promise(r => setTimeout(r, 3000));
|
||||
```
|
||||
|
||||
Other tips for maximizing reCAPTCHA scores:
|
||||
- **Try the Patchright backend** — suppresses CDP automation signals that reCAPTCHA Enterprise detects. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it when you need the extra CDP stealth
|
||||
- **Try the Patchright backend** — suppresses additional CDP automation signals at the Playwright protocol layer. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it if you're still seeing low scores after trying the steps above
|
||||
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
|
||||
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
|
||||
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
|
||||
@@ -816,9 +1009,9 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Linux x64 — Chromium 145 (26 patches) | ✅ Released |
|
||||
| Linux x64 — Chromium 145 (42 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 |
|
||||
| Fingerprint rotation per session | ✅ Released |
|
||||
| Built-in proxy rotation | 📋 Planned |
|
||||
@@ -830,8 +1023,28 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
||||
- 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues)
|
||||
- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/)
|
||||
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
|
||||
- ☕ **Support** — [ko-fi.com/cloakhq](https://ko-fi.com/cloakhq)
|
||||
- 📧 **Contact** — cloakhq@pm.me
|
||||
|
||||
## Security
|
||||
|
||||
All releases are signed for supply chain verification.
|
||||
|
||||
```bash
|
||||
# Verify GPG signature (binary release tag)
|
||||
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
|
||||
git verify-tag chromium-v145.0.7632.159.8
|
||||
|
||||
# Verify GitHub binary attestation (Sigstore)
|
||||
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
|
||||
|
||||
# Verify Docker image signature (Cosign/Sigstore)
|
||||
cosign verify \
|
||||
--certificate-identity-regexp "https://github.com/CloakHQ/CloakBrowser/" \
|
||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||
cloakhq/cloakbrowser:latest
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
|
||||
@@ -840,3 +1053,8 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
|
||||
## Contributing
|
||||
|
||||
Issues and PRs welcome. If something isn't working, [open an issue](https://github.com/CloakHQ/CloakBrowser/issues) — we respond fast.
|
||||
|
||||
## Contributors
|
||||
|
||||
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
|
||||
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
|
||||
|
||||
+603
-43
@@ -1,33 +1,54 @@
|
||||
#!/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:
|
||||
cloakserve # headless on port 9222
|
||||
cloakserve --headless=false # headed (uses Xvfb in Docker)
|
||||
cloakserve --proxy-server=host:port # with proxy
|
||||
cloakserve # default, backward compat
|
||||
cloakserve --port=9222 # custom port
|
||||
|
||||
Connect from host:
|
||||
playwright.chromium.connect_over_cdp("http://localhost:9222")
|
||||
Client:
|
||||
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"
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import random
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
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
|
||||
from cloakbrowser.download import ensure_binary
|
||||
|
||||
EXPOSE_PORT = 9222 # External, 0.0.0.0 (user connects here)
|
||||
CHROME_PORT = 9223 # Internal, 127.0.0.1 (Chrome binds here)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("cloakserve")
|
||||
|
||||
binary = ensure_binary()
|
||||
|
||||
chrome_args = [
|
||||
binary,
|
||||
f"--remote-debugging-port={CHROME_PORT}",
|
||||
# "--remote-debugging-address=0.0.0.0", # TODO: enable after binary patch 032
|
||||
# Sane defaults for running Chrome directly (outside Playwright)
|
||||
# Args for running Chrome directly (outside Playwright).
|
||||
# Playwright normally adds its own version of these.
|
||||
BASE_CHROME_ARGS = [
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-dev-shm-usage",
|
||||
@@ -35,35 +56,574 @@ chrome_args = [
|
||||
"--disable-popup-blocking",
|
||||
"--disable-background-networking",
|
||||
"--metrics-recording-only",
|
||||
] + get_default_stealth_args() + sys.argv[1:]
|
||||
"--ignore-gpu-blocklist",
|
||||
]
|
||||
|
||||
# Launch Chrome
|
||||
chrome = subprocess.Popen(chrome_args)
|
||||
|
||||
# Wait for Chrome to start listening
|
||||
time.sleep(2)
|
||||
|
||||
# Forward 0.0.0.0:9222 -> 127.0.0.1:9223
|
||||
# Chrome hardcodes 127.0.0.1 for security; socat exposes it to Docker network.
|
||||
# TODO: Remove socat after patching binary to support --remote-debugging-address
|
||||
socat = subprocess.Popen([
|
||||
"socat",
|
||||
f"TCP-LISTEN:{EXPOSE_PORT},fork,reuseaddr,bind=0.0.0.0",
|
||||
f"TCP:127.0.0.1:{CHROME_PORT}",
|
||||
])
|
||||
|
||||
print(f"CloakBrowser CDP server ready on port {EXPOSE_PORT}", flush=True)
|
||||
BASE_CDP_PORT = 5100
|
||||
|
||||
|
||||
def cleanup(sig, frame):
|
||||
chrome.terminate()
|
||||
socat.terminate()
|
||||
sys.exit(0)
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChromeProcess — one running Chrome instance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Wait for Chrome to exit, then clean up socat
|
||||
chrome.wait()
|
||||
socat.terminate()
|
||||
class ChromePool:
|
||||
def __init__(
|
||||
self,
|
||||
binary: str,
|
||||
global_args: list[str],
|
||||
headless: bool,
|
||||
data_dir: str = "/tmp/cloakserve",
|
||||
):
|
||||
self._binary = binary
|
||||
self._global_args = global_args
|
||||
self._headless = headless
|
||||
self._data_dir = data_dir
|
||||
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."""
|
||||
# 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
|
||||
if geoip and proxy:
|
||||
timezone, locale = 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}")
|
||||
|
||||
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)."""
|
||||
config: dict = {
|
||||
"port": 9222,
|
||||
"headless": True,
|
||||
"data_dir": 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
|
||||
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"],
|
||||
)
|
||||
|
||||
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,11 +11,23 @@ Usage:
|
||||
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 .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||
from ._version import __version__
|
||||
|
||||
# Human-like behavioral layer (optional)
|
||||
def __getattr__(name):
|
||||
if name == "HumanConfig":
|
||||
from .human.config import HumanConfig
|
||||
globals()["HumanConfig"] = HumanConfig
|
||||
return HumanConfig
|
||||
if name == "resolve_human_config":
|
||||
from .human.config import resolve_config
|
||||
globals()["resolve_human_config"] = resolve_config
|
||||
return resolve_config
|
||||
raise AttributeError(f"module 'cloakbrowser' has no attribute {name}")
|
||||
|
||||
__all__ = [
|
||||
"launch",
|
||||
"launch_async",
|
||||
@@ -28,6 +40,11 @@ __all__ = [
|
||||
"check_for_update",
|
||||
"CHROMIUM_VERSION",
|
||||
"get_default_stealth_args",
|
||||
"build_args",
|
||||
"maybe_resolve_geoip",
|
||||
"ProxySettings",
|
||||
"HumanConfig",
|
||||
"resolve_human_config",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""CLI for cloakbrowser — download and manage the stealth Chromium binary.
|
||||
|
||||
Usage:
|
||||
python -m cloakbrowser install # Download binary (with progress)
|
||||
python -m cloakbrowser info # Show binary version, path, platform
|
||||
python -m cloakbrowser update # Check for and download newer binary
|
||||
python -m cloakbrowser clear-cache # Remove cached binaries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""Route cloakbrowser logger to stderr with clean output."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
stream=sys.stderr,
|
||||
force=True,
|
||||
)
|
||||
# Suppress noisy HTTP request logs from httpx
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> None:
|
||||
from .download import ensure_binary
|
||||
|
||||
path = ensure_binary()
|
||||
print(path)
|
||||
|
||||
|
||||
def cmd_info(args: argparse.Namespace) -> None:
|
||||
from .config import get_local_binary_override
|
||||
from .download import binary_info
|
||||
|
||||
info = binary_info()
|
||||
override = get_local_binary_override()
|
||||
|
||||
print(f"Version: {info['version']}")
|
||||
print(f"Platform: {info['platform']}")
|
||||
print(f"Binary: {info['binary_path']}")
|
||||
print(f"Installed: {info['installed']}")
|
||||
print(f"Cache: {info['cache_dir']}")
|
||||
if override:
|
||||
print(f"Override: {override} (CLOAKBROWSER_BINARY_PATH)")
|
||||
|
||||
|
||||
def cmd_update(args: argparse.Namespace) -> None:
|
||||
from .download import check_for_update
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
logger.info("Checking for updates...")
|
||||
new_version = check_for_update()
|
||||
if new_version:
|
||||
print(f"Updated to Chromium {new_version}")
|
||||
else:
|
||||
print("Already up to date.")
|
||||
|
||||
|
||||
def cmd_clear_cache(args: argparse.Namespace) -> None:
|
||||
from .config import get_cache_dir
|
||||
from .download import clear_cache
|
||||
|
||||
if not get_cache_dir().exists():
|
||||
print("No cache to clear.")
|
||||
return
|
||||
clear_cache()
|
||||
print("Cache cleared.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cloakbrowser",
|
||||
description="Manage the CloakBrowser stealth Chromium binary.",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("install", help="Download the Chromium binary")
|
||||
sub.add_parser("info", help="Show binary version, path, and platform")
|
||||
sub.add_parser("update", help="Check for and download a newer binary")
|
||||
sub.add_parser("clear-cache", help="Remove all cached binaries")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
_setup_logging()
|
||||
|
||||
commands = {
|
||||
"install": cmd_install,
|
||||
"info": cmd_info,
|
||||
"update": cmd_update,
|
||||
"clear-cache": cmd_clear_cache,
|
||||
}
|
||||
|
||||
try:
|
||||
commands[args.command](args)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.9"
|
||||
__version__ = "0.3.19"
|
||||
|
||||
+147
-59
@@ -16,20 +16,18 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from typing import Any, Literal, TypedDict
|
||||
from urllib.parse import unquote, urlparse, urlunparse
|
||||
|
||||
from .config import DEFAULT_VIEWPORT, get_default_stealth_args
|
||||
from .config import DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS, get_default_stealth_args
|
||||
from .download import ensure_binary
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
|
||||
def _migrate_timezone_id(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
|
||||
"""Pop deprecated timezone_id from kwargs, warn, return resolved timezone."""
|
||||
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
|
||||
"""Accept both timezone and timezone_id — either works, no warning."""
|
||||
if "timezone_id" in kwargs:
|
||||
warnings.warn("timezone_id is deprecated, use timezone instead", FutureWarning, stacklevel=3)
|
||||
if timezone is None:
|
||||
timezone = kwargs.pop("timezone_id")
|
||||
else:
|
||||
@@ -58,6 +56,9 @@ def launch(
|
||||
locale: str | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: str = "default",
|
||||
human_config: dict | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
||||
@@ -81,6 +82,9 @@ def launch(
|
||||
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
|
||||
but breaks proxy auth and add_init_script.
|
||||
Override globally with CLOAKBROWSER_BACKEND env var.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config dict to override preset values.
|
||||
**kwargs: Passed directly to playwright.chromium.launch().
|
||||
|
||||
Returns:
|
||||
@@ -97,8 +101,8 @@ def launch(
|
||||
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
|
||||
|
||||
@@ -107,7 +111,7 @@ def launch(
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**kwargs,
|
||||
)
|
||||
@@ -116,15 +120,24 @@ def launch(
|
||||
_original_close = browser.close
|
||||
|
||||
def _close_with_cleanup() -> None:
|
||||
_original_close()
|
||||
pw.stop()
|
||||
try:
|
||||
_original_close()
|
||||
finally:
|
||||
pw.stop()
|
||||
|
||||
browser.close = _close_with_cleanup
|
||||
|
||||
# Human-like behavioral patching
|
||||
if humanize:
|
||||
from .human import patch_browser
|
||||
from .human.config import resolve_config
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_browser(browser, cfg)
|
||||
|
||||
return browser
|
||||
|
||||
|
||||
async def launch_async(
|
||||
async def launch_async( # noqa: C901
|
||||
headless: bool = True,
|
||||
proxy: str | ProxySettings | None = None,
|
||||
args: list[str] | None = None,
|
||||
@@ -133,6 +146,9 @@ async def launch_async(
|
||||
locale: str | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: str = "default",
|
||||
human_config: dict | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch(). Returns a Playwright Browser object.
|
||||
@@ -146,6 +162,9 @@ async def launch_async(
|
||||
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config dict to override preset values.
|
||||
**kwargs: Passed directly to playwright.chromium.launch().
|
||||
|
||||
Returns:
|
||||
@@ -167,8 +186,8 @@ async def launch_async(
|
||||
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
|
||||
|
||||
@@ -177,7 +196,7 @@ async def launch_async(
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**kwargs,
|
||||
)
|
||||
@@ -186,11 +205,20 @@ async def launch_async(
|
||||
_original_close = browser.close
|
||||
|
||||
async def _close_with_cleanup() -> None:
|
||||
await _original_close()
|
||||
await pw.stop()
|
||||
try:
|
||||
await _original_close()
|
||||
finally:
|
||||
await pw.stop()
|
||||
|
||||
browser.close = _close_with_cleanup
|
||||
|
||||
# Human-like behavioral patching (async variant)
|
||||
if humanize:
|
||||
from .human import patch_browser_async
|
||||
from .human.config import resolve_config
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_browser_async(browser, cfg)
|
||||
|
||||
return browser
|
||||
|
||||
|
||||
@@ -207,6 +235,9 @@ def launch_persistent_context(
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: str = "default",
|
||||
human_config: dict | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||
@@ -232,6 +263,9 @@ def launch_persistent_context(
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
Requires ``pip install cloakbrowser[geoip]``.
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config dict to override preset values.
|
||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||
|
||||
Returns:
|
||||
@@ -247,11 +281,11 @@ def launch_persistent_context(
|
||||
"""
|
||||
sync_playwright = _import_sync_playwright(_resolve_backend(backend))
|
||||
|
||||
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
|
||||
@@ -259,14 +293,12 @@ def launch_persistent_context(
|
||||
user_data_dir,
|
||||
)
|
||||
|
||||
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||
# — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone:
|
||||
context_kwargs["timezone_id"] = timezone
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
@@ -277,7 +309,7 @@ def launch_persistent_context(
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**context_kwargs,
|
||||
)
|
||||
@@ -286,11 +318,20 @@ def launch_persistent_context(
|
||||
_original_close = context.close
|
||||
|
||||
def _close_with_cleanup() -> None:
|
||||
_original_close()
|
||||
pw.stop()
|
||||
try:
|
||||
_original_close()
|
||||
finally:
|
||||
pw.stop()
|
||||
|
||||
context.close = _close_with_cleanup
|
||||
|
||||
# Human-like behavioral patching
|
||||
if humanize:
|
||||
from .human import patch_context
|
||||
from .human.config import resolve_config
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_context(context, cfg)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -307,6 +348,9 @@ async def launch_persistent_context_async(
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: str = "default",
|
||||
human_config: dict | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch_persistent_context().
|
||||
@@ -329,6 +373,9 @@ async def launch_persistent_context_async(
|
||||
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config dict to override preset values.
|
||||
**kwargs: Passed directly to playwright.chromium.launch_persistent_context().
|
||||
|
||||
Returns:
|
||||
@@ -349,11 +396,11 @@ async def launch_persistent_context_async(
|
||||
"""
|
||||
async_playwright = _import_async_playwright(_resolve_backend(backend))
|
||||
|
||||
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale)
|
||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
|
||||
|
||||
logger.debug(
|
||||
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
|
||||
@@ -361,14 +408,12 @@ async def launch_persistent_context_async(
|
||||
user_data_dir,
|
||||
)
|
||||
|
||||
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||
# — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone:
|
||||
context_kwargs["timezone_id"] = timezone
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
@@ -379,7 +424,7 @@ async def launch_persistent_context_async(
|
||||
executable_path=binary_path,
|
||||
headless=headless,
|
||||
args=chrome_args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**_build_proxy_kwargs(proxy),
|
||||
**context_kwargs,
|
||||
)
|
||||
@@ -388,11 +433,20 @@ async def launch_persistent_context_async(
|
||||
_original_close = context.close
|
||||
|
||||
async def _close_with_cleanup() -> None:
|
||||
await _original_close()
|
||||
await pw.stop()
|
||||
try:
|
||||
await _original_close()
|
||||
finally:
|
||||
await pw.stop()
|
||||
|
||||
context.close = _close_with_cleanup
|
||||
|
||||
# Human-like behavioral patching (async variant)
|
||||
if humanize:
|
||||
from .human import patch_context_async
|
||||
from .human.config import resolve_config
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_context_async(context, cfg)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -408,6 +462,9 @@ def launch_context(
|
||||
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||
geoip: bool = False,
|
||||
backend: str | None = None,
|
||||
humanize: bool = False,
|
||||
human_preset: str = "default",
|
||||
human_config: dict | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||
@@ -428,30 +485,29 @@ def launch_context(
|
||||
Default: None (uses Chromium default, which is 'light').
|
||||
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||
human_config: Custom humanize config dict to override preset values.
|
||||
**kwargs: Passed to browser.new_context().
|
||||
|
||||
Returns:
|
||||
Playwright BrowserContext object.
|
||||
"""
|
||||
timezone = _migrate_timezone_id(timezone, kwargs)
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure
|
||||
# resolved values flow to both binary flags AND context params
|
||||
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
# Skip --fingerprint-timezone binary flag: it only applies to the default
|
||||
# context and interferes with Playwright's timezone_id on new contexts.
|
||||
# Timezone is set via browser.new_context(timezone_id=...) below instead.
|
||||
# resolved values flow to binary flags
|
||||
timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
# --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||
# so it applies to ALL contexts, not just the default one.
|
||||
# locale and timezone are set via binary flags only — no CDP emulation.
|
||||
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
||||
timezone=None, locale=locale, backend=backend)
|
||||
timezone=timezone, locale=locale, backend=backend)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
|
||||
if locale:
|
||||
context_kwargs["locale"] = locale
|
||||
if timezone:
|
||||
context_kwargs["timezone_id"] = timezone
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
@@ -466,11 +522,20 @@ def launch_context(
|
||||
_original_ctx_close = context.close
|
||||
|
||||
def _close_context_with_cleanup() -> None:
|
||||
_original_ctx_close()
|
||||
browser.close()
|
||||
try:
|
||||
_original_ctx_close()
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
context.close = _close_context_with_cleanup
|
||||
|
||||
# Human-like behavioral patching
|
||||
if humanize:
|
||||
from .human import patch_context
|
||||
from .human.config import resolve_config
|
||||
cfg = resolve_config(human_preset, human_config)
|
||||
patch_context(context, cfg)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@@ -520,7 +585,12 @@ def _import_async_playwright(backend: str):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _maybe_resolve_geoip(
|
||||
def _ensure_proxy_scheme(proxy_url: str) -> str:
|
||||
"""Prepend http:// to schemeless proxy URLs so parsers can extract hostname."""
|
||||
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
|
||||
|
||||
|
||||
def maybe_resolve_geoip(
|
||||
geoip: bool,
|
||||
proxy: str | ProxySettings | None,
|
||||
timezone: str | None,
|
||||
@@ -535,6 +605,7 @@ def _maybe_resolve_geoip(
|
||||
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
|
||||
if not proxy_url:
|
||||
return timezone, locale
|
||||
proxy_url = _ensure_proxy_scheme(proxy_url)
|
||||
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
|
||||
if timezone is None:
|
||||
timezone = geo_tz
|
||||
@@ -543,11 +614,12 @@ def _maybe_resolve_geoip(
|
||||
return timezone, locale
|
||||
|
||||
|
||||
def _build_args(
|
||||
def build_args(
|
||||
stealth_args: bool,
|
||||
extra_args: list[str] | None,
|
||||
timezone: str | None = None,
|
||||
locale: str | None = None,
|
||||
headless: bool = True,
|
||||
) -> list[str]:
|
||||
"""Combine stealth args with user-provided args and locale flags.
|
||||
|
||||
@@ -560,6 +632,16 @@ def _build_args(
|
||||
for arg in get_default_stealth_args():
|
||||
seen[arg.split("=", 1)[0]] = arg
|
||||
|
||||
# GPU blocklist bypass:
|
||||
# - Headed mode (all platforms): Chromium blocks WebGL on software GPUs
|
||||
# in Docker/Xvfb. Flag lets SwiftShader serve WebGL. See issue #56.
|
||||
# - Windows (all modes): Chromium's GPU blocklist blocks WebGPU for the
|
||||
# Microsoft Basic Render Driver. Dawn's adapter_blocklist bypass alone
|
||||
# isn't enough — need this flag too. Linux doesn't need it.
|
||||
import platform as _platform
|
||||
if not headless or _platform.system() == "Windows":
|
||||
seen["--ignore-gpu-blocklist"] = "--ignore-gpu-blocklist"
|
||||
|
||||
if extra_args:
|
||||
for arg in extra_args:
|
||||
key = arg.split("=", 1)[0]
|
||||
@@ -575,11 +657,11 @@ def _build_args(
|
||||
logger.debug("Arg override: %s -> %s", seen[key], flag)
|
||||
seen[key] = flag
|
||||
if locale:
|
||||
key = "--lang"
|
||||
flag = f"{key}={locale}"
|
||||
if key in seen:
|
||||
logger.debug("Arg override: %s -> %s", seen[key], flag)
|
||||
seen[key] = flag
|
||||
for key in ("--lang", "--fingerprint-locale"):
|
||||
flag = f"{key}={locale}"
|
||||
if key in seen:
|
||||
logger.debug("Arg override: %s -> %s", seen[key], flag)
|
||||
seen[key] = flag
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
@@ -588,12 +670,18 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
|
||||
"""Parse proxy URL, extracting credentials into separate Playwright fields.
|
||||
|
||||
Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"}
|
||||
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).
|
||||
"""
|
||||
parsed = urlparse(proxy)
|
||||
# Bare format: "user:pass@host:port" — urlparse needs a scheme to extract credentials.
|
||||
normalized = proxy
|
||||
if "@" in proxy and "://" not in proxy:
|
||||
normalized = f"http://{proxy}"
|
||||
|
||||
parsed = urlparse(normalized)
|
||||
|
||||
if not parsed.username:
|
||||
return {"server": proxy}
|
||||
return {"server": proxy} # no creds — return original unchanged
|
||||
|
||||
# Rebuild server URL without credentials
|
||||
netloc = parsed.hostname or ""
|
||||
|
||||
+14
-5
@@ -15,15 +15,24 @@ from ._version import __version__
|
||||
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
# Use get_chromium_version() for the current platform's actual version.
|
||||
# ---------------------------------------------------------------------------
|
||||
CHROMIUM_VERSION = "145.0.7632.159"
|
||||
CHROMIUM_VERSION = "145.0.7632.159.8"
|
||||
|
||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||
"linux-x64": "145.0.7632.159",
|
||||
"linux-x64": "145.0.7632.159.8",
|
||||
"linux-arm64": "145.0.7632.159.7",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.159.7",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Playwright default args to suppress — these leak automation signals.
|
||||
# --enable-automation: exposes navigator.webdriver = true
|
||||
# --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
|
||||
# producing a distinctive renderer string that no real user browser has
|
||||
# ---------------------------------------------------------------------------
|
||||
IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default stealth arguments passed to the patched Chromium binary.
|
||||
# These activate source-level fingerprint patches compiled into the binary.
|
||||
@@ -56,8 +65,8 @@ def get_default_stealth_args() -> list[str]:
|
||||
# auto-generated by the binary from the seed (v14+).
|
||||
return base + [
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
"--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
|
||||
"--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import platform
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -55,13 +56,14 @@ def _show_welcome() -> None:
|
||||
marker = get_cache_dir() / ".welcome_shown"
|
||||
if marker.exists():
|
||||
return
|
||||
print()
|
||||
print(" CloakBrowser — stealth Chromium for automation")
|
||||
print(" https://github.com/CloakHQ/CloakBrowser")
|
||||
print()
|
||||
print(" Issues? https://github.com/CloakHQ/CloakBrowser/issues")
|
||||
print(" Star us if CloakBrowser helps your project!")
|
||||
print()
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
|
||||
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.write(" Issues? https://github.com/CloakHQ/CloakBrowser/issues\n")
|
||||
sys.stderr.write(" Donate? https://ko-fi.com/cloakhq\n")
|
||||
sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
|
||||
sys.stderr.write("\n")
|
||||
try:
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
"""cloakbrowser-human — Configuration and presets.
|
||||
|
||||
All numeric parameters for human-like behavior are centralized here.
|
||||
Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Range = Tuple[float, float]
|
||||
HumanPreset = Literal["default", "careful"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class HumanConfig:
|
||||
"""All tunable parameters for human-like behavior."""
|
||||
|
||||
# Keyboard
|
||||
typing_delay: float = 70
|
||||
typing_delay_spread: float = 40
|
||||
typing_pause_chance: float = 0.1
|
||||
typing_pause_range: Range = (400, 1000)
|
||||
shift_down_delay: Range = (30, 70)
|
||||
shift_up_delay: Range = (20, 50)
|
||||
key_hold: Range = (15, 35)
|
||||
|
||||
# Mistype (typo simulation)
|
||||
mistype_chance: float = 0.02
|
||||
mistype_delay_notice: Range = (100, 300)
|
||||
mistype_delay_correct: Range = (50, 150)
|
||||
|
||||
field_switch_delay: Range = (800, 1500)
|
||||
|
||||
# Mouse — movement
|
||||
mouse_steps_divisor: float = 8
|
||||
mouse_min_steps: int = 25
|
||||
mouse_max_steps: int = 80
|
||||
mouse_wobble_max: float = 1.5
|
||||
mouse_overshoot_chance: float = 0.15
|
||||
mouse_overshoot_px: Range = (3, 6)
|
||||
mouse_burst_size: Range = (3, 5)
|
||||
mouse_burst_pause: Range = (8, 18)
|
||||
|
||||
# Mouse — clicks
|
||||
click_aim_delay_input: Range = (60, 140)
|
||||
click_aim_delay_button: Range = (80, 200)
|
||||
click_hold_input: Range = (40, 100)
|
||||
click_hold_button: Range = (60, 150)
|
||||
click_input_x_range: Range = (0.05, 0.30)
|
||||
|
||||
# Mouse — idle
|
||||
idle_drift_px: float = 3
|
||||
idle_pause_range: Range = (300, 1000)
|
||||
|
||||
# Scroll
|
||||
scroll_delta_base: Range = (80, 130)
|
||||
scroll_delta_variance: float = 0.2
|
||||
scroll_pause_fast: Range = (30, 80)
|
||||
scroll_pause_slow: Range = (80, 200)
|
||||
scroll_accel_steps: Range = (2, 3)
|
||||
scroll_decel_steps: Range = (2, 3)
|
||||
scroll_overshoot_chance: float = 0.1
|
||||
scroll_overshoot_px: Range = (50, 150)
|
||||
scroll_settle_delay: Range = (300, 600)
|
||||
scroll_target_zone: Range = (0.20, 0.80)
|
||||
scroll_pre_move_delay: Range = (100, 300)
|
||||
|
||||
# Initial cursor position (as if coming from the address bar area)
|
||||
initial_cursor_x: Range = (400, 700)
|
||||
initial_cursor_y: Range = (45, 60)
|
||||
|
||||
# Idle micro-movements between actions (opt-in, adds latency)
|
||||
idle_between_actions: bool = False
|
||||
idle_between_duration: Range = (0.3, 0.8)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _careful_config() -> HumanConfig:
|
||||
"""Careful preset — everything slower and more deliberate."""
|
||||
return HumanConfig(
|
||||
# Keyboard — slower typing
|
||||
typing_delay=100,
|
||||
typing_delay_spread=50,
|
||||
typing_pause_chance=0.15,
|
||||
typing_pause_range=(500, 1200),
|
||||
shift_down_delay=(40, 90),
|
||||
shift_up_delay=(30, 70),
|
||||
key_hold=(20, 45),
|
||||
field_switch_delay=(1000, 2000),
|
||||
# Mouse — slower, more precise
|
||||
mouse_overshoot_chance=0.10,
|
||||
mouse_burst_pause=(12, 25),
|
||||
# Mouse — clicks (longer aiming and holding)
|
||||
click_aim_delay_input=(80, 180),
|
||||
click_aim_delay_button=(120, 280),
|
||||
click_hold_input=(60, 140),
|
||||
click_hold_button=(80, 200),
|
||||
# Scroll — slower
|
||||
scroll_pause_fast=(100, 200),
|
||||
scroll_pause_slow=(250, 600),
|
||||
scroll_settle_delay=(400, 800),
|
||||
scroll_pre_move_delay=(150, 400),
|
||||
# Idle between actions enabled for careful preset
|
||||
idle_between_actions=True,
|
||||
idle_between_duration=(0.4, 1.0),
|
||||
)
|
||||
|
||||
|
||||
_PRESETS: dict[str, HumanConfig] = {
|
||||
"default": HumanConfig(),
|
||||
"careful": _careful_config(),
|
||||
}
|
||||
|
||||
|
||||
def resolve_config(
|
||||
preset: HumanPreset = "default",
|
||||
overrides: dict | None = None,
|
||||
) -> HumanConfig:
|
||||
"""Resolve a preset name + optional overrides into a full HumanConfig.
|
||||
|
||||
Args:
|
||||
preset: 'default' or 'careful'.
|
||||
overrides: Dict of field names to override values.
|
||||
|
||||
Returns:
|
||||
A new HumanConfig instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If preset is not a recognized name.
|
||||
"""
|
||||
if preset not in _PRESETS:
|
||||
raise ValueError(
|
||||
f"Unknown humanize preset {preset!r}. "
|
||||
f"Valid presets: {', '.join(sorted(_PRESETS.keys()))}"
|
||||
)
|
||||
base = _PRESETS[preset]
|
||||
if not overrides:
|
||||
return HumanConfig(**{k: getattr(base, k) for k in base.__dataclass_fields__})
|
||||
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
|
||||
merged.update(overrides)
|
||||
return HumanConfig(**merged)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utility functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def rand(lo: float, hi: float) -> float:
|
||||
"""Random float in [lo, hi]."""
|
||||
return random.uniform(lo, hi)
|
||||
|
||||
|
||||
def rand_int(lo: int, hi: int) -> int:
|
||||
"""Random integer in [lo, hi] inclusive."""
|
||||
return random.randint(lo, hi)
|
||||
|
||||
|
||||
def rand_range(r: Range) -> float:
|
||||
"""Random float from a (min, max) tuple."""
|
||||
return random.uniform(r[0], r[1])
|
||||
|
||||
|
||||
def rand_int_range(r: Range) -> int:
|
||||
"""Random integer from a (min, max) tuple, inclusive."""
|
||||
return random.randint(int(r[0]), int(r[1]))
|
||||
|
||||
|
||||
def sleep_ms(ms: float) -> None:
|
||||
"""Sleep for `ms` milliseconds."""
|
||||
if ms > 0:
|
||||
time.sleep(ms / 1000.0)
|
||||
|
||||
|
||||
async def async_sleep_ms(ms: float) -> None:
|
||||
"""Async sleep for `ms` milliseconds."""
|
||||
if ms > 0:
|
||||
import asyncio
|
||||
await asyncio.sleep(ms / 1000.0)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""cloakbrowser-human — Human-like keyboard input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, sleep_ms
|
||||
|
||||
|
||||
class RawKeyboard(Protocol):
|
||||
def down(self, key: str) -> None: ...
|
||||
def up(self, key: str) -> None: ...
|
||||
def type(self, text: str) -> None: ...
|
||||
def insert_text(self, text: str) -> None: ...
|
||||
|
||||
|
||||
SHIFT_SYMBOLS = frozenset('@#!$%^&*()_+{}|:"<>?~')
|
||||
|
||||
NEARBY_KEYS = {
|
||||
'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',
|
||||
}
|
||||
|
||||
|
||||
def _get_nearby_key(ch: str) -> str:
|
||||
"""Return a random adjacent key for the given character."""
|
||||
lower = ch.lower()
|
||||
if lower in NEARBY_KEYS:
|
||||
neighbors = NEARBY_KEYS[lower]
|
||||
wrong = random.choice(neighbors)
|
||||
return wrong.upper() if ch.isupper() else wrong
|
||||
return ch
|
||||
|
||||
|
||||
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
|
||||
for i, ch in enumerate(text):
|
||||
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||
if not ch.isascii():
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
raw.insert_text(ch)
|
||||
if i < len(text) - 1:
|
||||
_inter_char_delay(cfg)
|
||||
continue
|
||||
|
||||
# Mistype chance — only for ASCII alphanumeric
|
||||
if random.random() < cfg.mistype_chance and ch.isalnum():
|
||||
wrong = _get_nearby_key(ch)
|
||||
_type_normal_char(raw, wrong, cfg)
|
||||
sleep_ms(rand_range(cfg.mistype_delay_notice))
|
||||
raw.down("Backspace")
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
raw.up("Backspace")
|
||||
sleep_ms(rand_range(cfg.mistype_delay_correct))
|
||||
|
||||
if ch.isupper() and ch.isalpha():
|
||||
_type_shifted_char(page, raw, ch, cfg)
|
||||
elif ch in SHIFT_SYMBOLS:
|
||||
_type_shift_symbol(page, raw, ch, cfg)
|
||||
else:
|
||||
_type_normal_char(raw, ch, cfg)
|
||||
|
||||
if i < len(text) - 1:
|
||||
_inter_char_delay(cfg)
|
||||
|
||||
|
||||
def _type_normal_char(raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
raw.down(ch)
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
raw.up(ch)
|
||||
|
||||
|
||||
def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
raw.down(ch)
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
raw.up(ch)
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
raw.up("Shift")
|
||||
|
||||
|
||||
def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
raw.insert_text(ch)
|
||||
page.evaluate(
|
||||
"""(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}""",
|
||||
ch,
|
||||
)
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
raw.up("Shift")
|
||||
|
||||
|
||||
def _inter_char_delay(cfg: HumanConfig) -> None:
|
||||
if random.random() < cfg.typing_pause_chance:
|
||||
sleep_ms(rand_range(cfg.typing_pause_range))
|
||||
else:
|
||||
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
|
||||
sleep_ms(max(10, delay))
|
||||
@@ -0,0 +1,93 @@
|
||||
"""cloakbrowser-human — Async human-like keyboard input.
|
||||
|
||||
Mirrors keyboard.py but uses ``await`` for all Playwright calls and
|
||||
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, async_sleep_ms
|
||||
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
|
||||
|
||||
|
||||
class AsyncRawKeyboard(Protocol):
|
||||
async def down(self, key: str) -> None: ...
|
||||
async def up(self, key: str) -> None: ...
|
||||
async def type(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:
|
||||
for i, ch in enumerate(text):
|
||||
# Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||
if not ch.isascii():
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
await raw.insert_text(ch)
|
||||
if i < len(text) - 1:
|
||||
await _inter_char_delay(cfg)
|
||||
continue
|
||||
|
||||
# Mistype chance — only for ASCII alphanumeric
|
||||
if random.random() < cfg.mistype_chance and ch.isalnum():
|
||||
wrong = _get_nearby_key(ch)
|
||||
await _type_normal_char(raw, wrong, cfg)
|
||||
await async_sleep_ms(rand_range(cfg.mistype_delay_notice))
|
||||
await raw.down("Backspace")
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
await raw.up("Backspace")
|
||||
await async_sleep_ms(rand_range(cfg.mistype_delay_correct))
|
||||
|
||||
if ch.isupper() and ch.isalpha():
|
||||
await _type_shifted_char(page, raw, ch, cfg)
|
||||
elif ch in SHIFT_SYMBOLS:
|
||||
await _type_shift_symbol(page, raw, ch, cfg)
|
||||
else:
|
||||
await _type_normal_char(raw, ch, cfg)
|
||||
|
||||
if i < len(text) - 1:
|
||||
await _inter_char_delay(cfg)
|
||||
|
||||
|
||||
async def _type_normal_char(raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
await raw.down(ch)
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
await raw.up(ch)
|
||||
|
||||
|
||||
async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
await raw.down("Shift")
|
||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
await raw.down(ch)
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
await raw.up(ch)
|
||||
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await raw.up("Shift")
|
||||
|
||||
|
||||
async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
await raw.down("Shift")
|
||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
await raw.insert_text(ch)
|
||||
await page.evaluate(
|
||||
"""(key) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}""",
|
||||
ch,
|
||||
)
|
||||
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await raw.up("Shift")
|
||||
|
||||
|
||||
async def _inter_char_delay(cfg: HumanConfig) -> None:
|
||||
if random.random() < cfg.typing_pause_chance:
|
||||
await async_sleep_ms(rand_range(cfg.typing_pause_range))
|
||||
else:
|
||||
delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread
|
||||
await async_sleep_ms(max(10, delay))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""cloakbrowser-human — Human-like mouse movement and clicking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Protocol, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
|
||||
|
||||
|
||||
class RawMouse(Protocol):
|
||||
def move(self, x: float, y: float) -> None: ...
|
||||
def down(self) -> None: ...
|
||||
def up(self) -> None: ...
|
||||
def wheel(self, delta_x: float, delta_y: float) -> None: ...
|
||||
|
||||
|
||||
class Point:
|
||||
__slots__ = ("x", "y")
|
||||
def __init__(self, x: float, y: float):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
|
||||
def _ease_in_out(t: float) -> float:
|
||||
if t < 0.5:
|
||||
return 4 * t * t * t
|
||||
return 1 - pow(-2 * t + 2, 3) / 2
|
||||
|
||||
|
||||
def _bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point:
|
||||
u = 1 - t
|
||||
uu = u * u
|
||||
uuu = uu * u
|
||||
tt = t * t
|
||||
ttt = tt * t
|
||||
return Point(
|
||||
uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
|
||||
uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
|
||||
)
|
||||
|
||||
|
||||
def _random_control_points(start: Point, end: Point) -> Tuple[Point, Point]:
|
||||
dx = end.x - start.x
|
||||
dy = end.y - start.y
|
||||
dist = math.hypot(dx, dy) or 1
|
||||
px = -dy / dist
|
||||
py = dx / dist
|
||||
bias1 = rand(-0.3, 0.3) * dist
|
||||
bias2 = rand(-0.3, 0.3) * dist
|
||||
return (
|
||||
Point(start.x + dx * 0.25 + px * bias1, start.y + dy * 0.25 + py * bias1),
|
||||
Point(start.x + dx * 0.75 + px * bias2, start.y + dy * 0.75 + py * bias2),
|
||||
)
|
||||
|
||||
|
||||
def human_move(
|
||||
raw: RawMouse,
|
||||
start_x: float, start_y: float,
|
||||
end_x: float, end_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> None:
|
||||
dist = math.hypot(end_x - start_x, end_y - start_y)
|
||||
if dist < 1:
|
||||
return
|
||||
|
||||
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
|
||||
start = Point(start_x, start_y)
|
||||
end = Point(end_x, end_y)
|
||||
cp1, cp2 = _random_control_points(start, end)
|
||||
|
||||
burst_counter = 0
|
||||
burst_size = rand_int_range(cfg.mouse_burst_size)
|
||||
|
||||
for i in range(steps + 1):
|
||||
progress = i / steps
|
||||
eased_t = _ease_in_out(progress)
|
||||
pt = _bezier(start, cp1, cp2, end, eased_t)
|
||||
|
||||
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
|
||||
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
|
||||
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
|
||||
|
||||
raw.move(round(wx), round(wy))
|
||||
|
||||
burst_counter += 1
|
||||
if burst_counter >= burst_size and i < steps:
|
||||
sleep_ms(rand_range(cfg.mouse_burst_pause))
|
||||
burst_counter = 0
|
||||
|
||||
if random.random() < cfg.mouse_overshoot_chance:
|
||||
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
|
||||
angle = math.atan2(end_y - start_y, end_x - start_x)
|
||||
raw.move(round(end_x + math.cos(angle) * overshoot_dist),
|
||||
round(end_y + math.sin(angle) * overshoot_dist))
|
||||
sleep_ms(rand(30, 70))
|
||||
raw.move(round(end_x + (random.random() - 0.5) * 4),
|
||||
round(end_y + (random.random() - 0.5) * 4))
|
||||
|
||||
|
||||
def click_target(box: dict, is_input: bool, cfg: HumanConfig) -> Point:
|
||||
if is_input:
|
||||
x_frac = rand_range(cfg.click_input_x_range)
|
||||
y_frac = rand(0.30, 0.70)
|
||||
else:
|
||||
x_frac = rand(0.35, 0.65)
|
||||
y_frac = rand(0.35, 0.65)
|
||||
return Point(round(box["x"] + box["width"] * x_frac),
|
||||
round(box["y"] + box["height"] * y_frac))
|
||||
|
||||
|
||||
def human_click(raw: RawMouse, is_input: bool, cfg: HumanConfig) -> None:
|
||||
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
|
||||
sleep_ms(aim_delay)
|
||||
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
|
||||
raw.down()
|
||||
sleep_ms(hold_time)
|
||||
raw.up()
|
||||
|
||||
|
||||
def human_idle(raw: RawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
|
||||
import time as _time
|
||||
end_time = _time.monotonic() + seconds
|
||||
x, y = cx, cy
|
||||
while _time.monotonic() < end_time:
|
||||
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||
x += dx
|
||||
y += dy
|
||||
raw.move(round(x), round(y))
|
||||
sleep_ms(rand_range(cfg.idle_pause_range))
|
||||
@@ -0,0 +1,87 @@
|
||||
"""cloakbrowser-human — Async human-like mouse movement and clicking.
|
||||
|
||||
Mirrors mouse.py but uses ``await`` for all Playwright calls and
|
||||
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
|
||||
from .mouse import Point, _ease_in_out, _bezier, _random_control_points, click_target # noqa: reuse pure math
|
||||
|
||||
|
||||
class AsyncRawMouse(Protocol):
|
||||
async def move(self, x: float, y: float) -> None: ...
|
||||
async def down(self) -> None: ...
|
||||
async def up(self) -> None: ...
|
||||
async def wheel(self, delta_x: float, delta_y: float) -> None: ...
|
||||
|
||||
|
||||
async def async_human_move(
|
||||
raw: AsyncRawMouse,
|
||||
start_x: float, start_y: float,
|
||||
end_x: float, end_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> None:
|
||||
dist = math.hypot(end_x - start_x, end_y - start_y)
|
||||
if dist < 1:
|
||||
return
|
||||
|
||||
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
|
||||
start = Point(start_x, start_y)
|
||||
end = Point(end_x, end_y)
|
||||
cp1, cp2 = _random_control_points(start, end)
|
||||
|
||||
burst_counter = 0
|
||||
burst_size = rand_int_range(cfg.mouse_burst_size)
|
||||
|
||||
for i in range(steps + 1):
|
||||
progress = i / steps
|
||||
eased_t = _ease_in_out(progress)
|
||||
pt = _bezier(start, cp1, cp2, end, eased_t)
|
||||
|
||||
wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max
|
||||
wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp
|
||||
wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp
|
||||
|
||||
await raw.move(round(wx), round(wy))
|
||||
|
||||
burst_counter += 1
|
||||
if burst_counter >= burst_size and i < steps:
|
||||
await async_sleep_ms(rand_range(cfg.mouse_burst_pause))
|
||||
burst_counter = 0
|
||||
|
||||
if random.random() < cfg.mouse_overshoot_chance:
|
||||
overshoot_dist = rand_range(cfg.mouse_overshoot_px)
|
||||
angle = math.atan2(end_y - start_y, end_x - start_x)
|
||||
await raw.move(round(end_x + math.cos(angle) * overshoot_dist),
|
||||
round(end_y + math.sin(angle) * overshoot_dist))
|
||||
await async_sleep_ms(rand(30, 70))
|
||||
await raw.move(round(end_x + (random.random() - 0.5) * 4),
|
||||
round(end_y + (random.random() - 0.5) * 4))
|
||||
|
||||
|
||||
async def async_human_click(raw: AsyncRawMouse, is_input: bool, cfg: HumanConfig) -> None:
|
||||
aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button)
|
||||
await async_sleep_ms(aim_delay)
|
||||
hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button)
|
||||
await raw.down()
|
||||
await async_sleep_ms(hold_time)
|
||||
await raw.up()
|
||||
|
||||
|
||||
async def async_human_idle(raw: AsyncRawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None:
|
||||
import time as _time
|
||||
end_time = _time.monotonic() + seconds
|
||||
x, y = cx, cy
|
||||
while _time.monotonic() < end_time:
|
||||
dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||
dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px
|
||||
x += dx
|
||||
y += dy
|
||||
await raw.move(round(x), round(y))
|
||||
await async_sleep_ms(rand_range(cfg.idle_pause_range))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""cloakbrowser-human — Human-like scrolling via mouse wheel events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
|
||||
from .mouse import RawMouse, human_move
|
||||
|
||||
|
||||
def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> bool:
|
||||
top_edge = bounds["y"]
|
||||
bottom_edge = bounds["y"] + bounds["height"]
|
||||
zone_top = viewport_height * cfg.scroll_target_zone[0]
|
||||
zone_bottom = viewport_height * cfg.scroll_target_zone[1]
|
||||
return top_edge >= zone_top and bottom_edge <= zone_bottom
|
||||
|
||||
|
||||
def _get_element_box(page: Any, selector: str) -> Optional[dict]:
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return el.bounding_box(timeout=2000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
|
||||
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
|
||||
abs_d = abs(delta)
|
||||
sign = 1 if delta > 0 else -1
|
||||
sent = 0
|
||||
while sent < abs_d:
|
||||
step_size = rand(20, 40)
|
||||
chunk = min(step_size, abs_d - sent)
|
||||
raw.wheel(0, round(chunk) * sign)
|
||||
sent += chunk
|
||||
sleep_ms(rand(8, 20))
|
||||
|
||||
|
||||
def scroll_to_element(
|
||||
page: Any,
|
||||
raw: RawMouse,
|
||||
selector: str,
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
raise RuntimeError("Viewport size not available")
|
||||
|
||||
viewport_height = viewport["height"]
|
||||
viewport_width = viewport["width"]
|
||||
|
||||
box = _get_element_box(page, selector)
|
||||
if box is None:
|
||||
sleep_ms(200)
|
||||
box = _get_element_box(page, selector)
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element not found: {selector}")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
|
||||
# Move cursor into scroll area
|
||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
||||
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
|
||||
human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
|
||||
cursor_x = scroll_area_x
|
||||
cursor_y = scroll_area_y
|
||||
sleep_ms(rand_range(cfg.scroll_pre_move_delay))
|
||||
|
||||
# Calculate scroll distance
|
||||
target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1])
|
||||
element_center = box["y"] + box["height"] / 2
|
||||
distance_to_scroll = element_center - target_y
|
||||
|
||||
direction = 1 if distance_to_scroll > 0 else -1
|
||||
abs_distance = abs(distance_to_scroll)
|
||||
avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
|
||||
total_clicks = max(3, math.ceil(abs_distance / avg_delta))
|
||||
accel_steps = rand_int_range(cfg.scroll_accel_steps)
|
||||
decel_steps = rand_int_range(cfg.scroll_decel_steps)
|
||||
|
||||
# Scroll loop: accelerate → cruise → decelerate
|
||||
scrolled = 0
|
||||
for i in range(total_clicks):
|
||||
if i < accel_steps:
|
||||
delta = rand(80, 100)
|
||||
pause = rand_range(cfg.scroll_pause_slow)
|
||||
elif i >= total_clicks - decel_steps:
|
||||
delta = rand(60, 90)
|
||||
pause = rand_range(cfg.scroll_pause_slow)
|
||||
else:
|
||||
delta = rand_range(cfg.scroll_delta_base)
|
||||
pause = rand_range(cfg.scroll_pause_fast)
|
||||
|
||||
delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance
|
||||
delta = round(delta) * direction
|
||||
|
||||
_smooth_wheel(raw, delta, cfg)
|
||||
scrolled += abs(delta)
|
||||
sleep_ms(pause)
|
||||
|
||||
# Check visibility every 3 steps
|
||||
if i % 3 == 2 or i == total_clicks - 1:
|
||||
box = _get_element_box(page, selector)
|
||||
if box and _is_in_viewport(box, viewport_height, cfg):
|
||||
break
|
||||
if scrolled >= abs_distance * 1.1:
|
||||
break
|
||||
|
||||
# Optional overshoot + correction
|
||||
if random.random() < cfg.scroll_overshoot_chance:
|
||||
overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction
|
||||
_smooth_wheel(raw, overshoot_px, cfg)
|
||||
sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
corrections = rand_int_range((1, 2))
|
||||
for _ in range(corrections):
|
||||
corr_delta = round(rand(40, 80)) * -direction
|
||||
_smooth_wheel(raw, corr_delta, cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
|
||||
# Settle
|
||||
sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
|
||||
box = _get_element_box(page, selector)
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element lost after scrolling: {selector}")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
@@ -0,0 +1,129 @@
|
||||
"""cloakbrowser-human — Async human-like scrolling via mouse wheel events.
|
||||
|
||||
Mirrors scroll.py but uses ``await`` for all Playwright calls and
|
||||
``async_sleep_ms`` instead of ``sleep_ms``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
|
||||
from .mouse_async import AsyncRawMouse, async_human_move
|
||||
from .scroll import _is_in_viewport
|
||||
|
||||
|
||||
async def _get_element_box_async(page: Any, selector: str) -> Optional[dict]:
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return await el.bounding_box(timeout=2000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig) -> None:
|
||||
"""Send one logical scroll as a burst of small wheel events (like real inertia)."""
|
||||
abs_d = abs(delta)
|
||||
sign = 1 if delta > 0 else -1
|
||||
sent = 0
|
||||
while sent < abs_d:
|
||||
step_size = rand(20, 40)
|
||||
chunk = min(step_size, abs_d - sent)
|
||||
await raw.wheel(0, round(chunk) * sign)
|
||||
sent += chunk
|
||||
await async_sleep_ms(rand(8, 20))
|
||||
|
||||
|
||||
async def async_scroll_to_element(
|
||||
page: Any,
|
||||
raw: AsyncRawMouse,
|
||||
selector: str,
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
raise RuntimeError("Viewport size not available")
|
||||
|
||||
viewport_height = viewport["height"]
|
||||
viewport_width = viewport["width"]
|
||||
|
||||
box = await _get_element_box_async(page, selector)
|
||||
if box is None:
|
||||
await async_sleep_ms(200)
|
||||
box = await _get_element_box_async(page, selector)
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element not found: {selector}")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
|
||||
# Move cursor into scroll area
|
||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
||||
scroll_area_y = round(viewport_height * rand(0.3, 0.7))
|
||||
await async_human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg)
|
||||
cursor_x = scroll_area_x
|
||||
cursor_y = scroll_area_y
|
||||
await async_sleep_ms(rand_range(cfg.scroll_pre_move_delay))
|
||||
|
||||
# Calculate scroll distance
|
||||
target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1])
|
||||
element_center = box["y"] + box["height"] / 2
|
||||
distance_to_scroll = element_center - target_y
|
||||
|
||||
direction = 1 if distance_to_scroll > 0 else -1
|
||||
abs_distance = abs(distance_to_scroll)
|
||||
avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
|
||||
total_clicks = max(3, math.ceil(abs_distance / avg_delta))
|
||||
accel_steps = rand_int_range(cfg.scroll_accel_steps)
|
||||
decel_steps = rand_int_range(cfg.scroll_decel_steps)
|
||||
|
||||
# Scroll loop: accelerate → cruise → decelerate
|
||||
scrolled = 0
|
||||
for i in range(total_clicks):
|
||||
if i < accel_steps:
|
||||
delta = rand(80, 100)
|
||||
pause = rand_range(cfg.scroll_pause_slow)
|
||||
elif i >= total_clicks - decel_steps:
|
||||
delta = rand(60, 90)
|
||||
pause = rand_range(cfg.scroll_pause_slow)
|
||||
else:
|
||||
delta = rand_range(cfg.scroll_delta_base)
|
||||
pause = rand_range(cfg.scroll_pause_fast)
|
||||
|
||||
delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance
|
||||
delta = round(delta) * direction
|
||||
|
||||
await _async_smooth_wheel(raw, delta, cfg)
|
||||
scrolled += abs(delta)
|
||||
await async_sleep_ms(pause)
|
||||
|
||||
# Check visibility every 3 steps
|
||||
if i % 3 == 2 or i == total_clicks - 1:
|
||||
box = await _get_element_box_async(page, selector)
|
||||
if box and _is_in_viewport(box, viewport_height, cfg):
|
||||
break
|
||||
if scrolled >= abs_distance * 1.1:
|
||||
break
|
||||
|
||||
# Optional overshoot + correction
|
||||
if random.random() < cfg.scroll_overshoot_chance:
|
||||
overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction
|
||||
await _async_smooth_wheel(raw, overshoot_px, cfg)
|
||||
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
corrections = rand_int_range((1, 2))
|
||||
for _ in range(corrections):
|
||||
corr_delta = round(rand(40, 80)) * -direction
|
||||
await _async_smooth_wheel(raw, corr_delta, cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
|
||||
# Settle
|
||||
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
|
||||
box = await _get_element_box_async(page, selector)
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element lost after scrolling: {selector}")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# agent-browser + CloakBrowser: AI browser agent with stealth fingerprints.
|
||||
#
|
||||
# agent-browser is a Node.js CLI for browser automation with session management.
|
||||
# CloakBrowser provides the stealth Chromium binary.
|
||||
#
|
||||
# Requires: npm install -g agent-browser
|
||||
# pip install cloakbrowser (to auto-download the binary)
|
||||
#
|
||||
# Note: agent-browser launches Chrome itself via env vars — it can't connect
|
||||
# to an existing browser via CDP. So we pass the binary path and stealth args directly.
|
||||
|
||||
# Get CloakBrowser binary path (auto-downloads if needed)
|
||||
BINARY_PATH=$(python3 -c "from cloakbrowser.download import ensure_binary; print(ensure_binary())")
|
||||
|
||||
# Get stealth args from our wrapper (comma-separated for agent-browser)
|
||||
STEALTH_ARGS=$(python3 -c "from cloakbrowser.config import get_default_stealth_args; print(','.join(get_default_stealth_args()))")
|
||||
|
||||
# Point agent-browser at CloakBrowser
|
||||
export AGENT_BROWSER_EXECUTABLE_PATH="$BINARY_PATH"
|
||||
export AGENT_BROWSER_ARGS="$STEALTH_ARGS"
|
||||
|
||||
# Open a page
|
||||
agent-browser --session stealth-test open "https://example.com"
|
||||
|
||||
# Get page title
|
||||
agent-browser --session stealth-test eval "document.title"
|
||||
|
||||
# Check stealth
|
||||
agent-browser --session stealth-test eval "JSON.stringify({webdriver: navigator.webdriver, plugins: navigator.plugins.length, platform: navigator.platform})"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""browser-use + CloakBrowser: AI agent with stealth fingerprints.
|
||||
|
||||
browser-use handles AI agent logic, CloakBrowser handles bot detection.
|
||||
Your agent can now browse sites behind Cloudflare, reCAPTCHA, DataDome.
|
||||
|
||||
Requires: pip install browser-use cloakbrowser
|
||||
Set OPENAI_API_KEY (or swap for another LLM provider).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from browser_use import Agent, BrowserSession, ChatOpenAI
|
||||
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
|
||||
async def main():
|
||||
# Step 1: Launch CloakBrowser (handles binary, stealth args, fingerprints)
|
||||
cb_browser = await launch_async(
|
||||
headless=True,
|
||||
args=["--remote-debugging-port=9242", "--remote-debugging-address=127.0.0.1"],
|
||||
)
|
||||
|
||||
# Step 2: Connect browser-use to the stealth browser via CDP
|
||||
session = BrowserSession(cdp_url="http://127.0.0.1:9242")
|
||||
|
||||
# Step 3: Run your AI agent — it browses through CloakBrowser
|
||||
agent = Agent(
|
||||
task="Go to https://www.google.com and search for 'browser automation'",
|
||||
llm=ChatOpenAI(model="gpt-4o-mini"),
|
||||
browser_session=session,
|
||||
)
|
||||
|
||||
result = await agent.run()
|
||||
print(result)
|
||||
|
||||
await cb_browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Crawl4AI + CloakBrowser: LLM-ready web crawling with stealth fingerprints.
|
||||
|
||||
Crawl4AI handles extraction and markdown conversion,
|
||||
CloakBrowser handles bot detection.
|
||||
|
||||
Requires: pip install crawl4ai cloakbrowser
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
|
||||
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
|
||||
async def main():
|
||||
# Step 1: Launch CloakBrowser with remote debugging
|
||||
cb_browser = await launch_async(
|
||||
headless=True,
|
||||
args=["--remote-debugging-port=9243", "--remote-debugging-address=127.0.0.1"],
|
||||
)
|
||||
|
||||
# Step 2: Connect Crawl4AI to the stealth browser via CDP
|
||||
browser_config = BrowserConfig(browser_mode="cdp", cdp_url="http://127.0.0.1:9243")
|
||||
run_config = CrawlerRunConfig()
|
||||
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
result = await crawler.arun(
|
||||
"https://example.com",
|
||||
config=run_config,
|
||||
)
|
||||
print(f"Extracted {len(result.markdown)} chars of markdown")
|
||||
print(result.markdown[:500])
|
||||
|
||||
await cb_browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Crawlee + CloakBrowser: stealth web crawling with PlaywrightCrawler.
|
||||
|
||||
Uses a custom BrowserPlugin to swap Crawlee's default Chromium
|
||||
for CloakBrowser's patched binary with source-level fingerprint patches.
|
||||
|
||||
Requires: pip install cloakbrowser "crawlee[playwright]"
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from cloakbrowser.config import IGNORE_DEFAULT_ARGS, get_default_stealth_args
|
||||
from cloakbrowser.download import ensure_binary
|
||||
from typing_extensions import override
|
||||
|
||||
from crawlee.browsers import (
|
||||
BrowserPool,
|
||||
PlaywrightBrowserController,
|
||||
PlaywrightBrowserPlugin,
|
||||
)
|
||||
from crawlee.crawlers import PlaywrightCrawler, PlaywrightCrawlingContext
|
||||
|
||||
|
||||
class CloakBrowserPlugin(PlaywrightBrowserPlugin):
|
||||
"""Browser plugin that uses CloakBrowser's patched Chromium,
|
||||
but otherwise keeps the functionality of PlaywrightBrowserPlugin.
|
||||
"""
|
||||
|
||||
@override
|
||||
async def new_browser(self) -> PlaywrightBrowserController:
|
||||
if not self._playwright:
|
||||
raise RuntimeError('Playwright browser plugin is not initialized.')
|
||||
|
||||
binary_path = ensure_binary()
|
||||
stealth_args = get_default_stealth_args()
|
||||
|
||||
# Merge CloakBrowser stealth args with any user-provided launch options.
|
||||
launch_options = dict(self._browser_launch_options)
|
||||
launch_options.pop('executable_path', None)
|
||||
launch_options.pop('chromium_sandbox', None)
|
||||
existing_args = list(launch_options.pop('args', []))
|
||||
launch_options['args'] = [*existing_args, *stealth_args]
|
||||
|
||||
return PlaywrightBrowserController(
|
||||
browser=await self._playwright.chromium.launch(
|
||||
executable_path=binary_path,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
**launch_options,
|
||||
),
|
||||
max_open_pages_per_browser=1,
|
||||
# CloakBrowser handles fingerprints at the binary level.
|
||||
header_generator=None,
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
crawler = PlaywrightCrawler(
|
||||
max_requests_per_crawl=10,
|
||||
browser_pool=BrowserPool(plugins=[CloakBrowserPlugin()]),
|
||||
)
|
||||
|
||||
@crawler.router.default_handler
|
||||
async def request_handler(context: PlaywrightCrawlingContext) -> None:
|
||||
context.log.info(f'Processing {context.request.url} ...')
|
||||
title = await context.page.title()
|
||||
await context.push_data({'url': context.request.url, 'title': title})
|
||||
await context.enqueue_links()
|
||||
|
||||
await crawler.run(['https://example.com'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""LangChain + CloakBrowser: load web pages behind bot detection into LangChain Documents.
|
||||
|
||||
LangChain's PlaywrightURLLoader hardcodes chromium.launch() with no way to pass
|
||||
a custom binary. This example uses CloakBrowser directly as a stealth document loader
|
||||
that produces LangChain Document objects.
|
||||
|
||||
Requires: pip install langchain-core cloakbrowser
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from langchain_core.documents import Document
|
||||
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
|
||||
async def load_urls_stealth(urls: list[str], **launch_kwargs) -> list[Document]:
|
||||
"""Load URLs using CloakBrowser stealth browser, return LangChain Documents."""
|
||||
browser = await launch_async(headless=True, **launch_kwargs)
|
||||
page = await browser.new_page()
|
||||
docs = []
|
||||
|
||||
for url in urls:
|
||||
await page.goto(url, wait_until="domcontentloaded")
|
||||
text = await page.evaluate("document.body.innerText")
|
||||
title = await page.title()
|
||||
docs.append(Document(
|
||||
page_content=text,
|
||||
metadata={"source": url, "title": title},
|
||||
))
|
||||
|
||||
await browser.close()
|
||||
return docs
|
||||
|
||||
|
||||
async def main():
|
||||
urls = [
|
||||
"https://example.com",
|
||||
"https://httpbin.org/html",
|
||||
]
|
||||
|
||||
docs = await load_urls_stealth(urls)
|
||||
|
||||
for doc in docs:
|
||||
print(f"--- {doc.metadata['title']} ({doc.metadata['source']}) ---")
|
||||
print(doc.page_content[:300])
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Scrapling + CloakBrowser: adaptive web scraping with stealth fingerprints.
|
||||
|
||||
Scrapling handles parsing and element tracking,
|
||||
CloakBrowser handles bot detection.
|
||||
|
||||
Requires: pip install scrapling[all] cloakbrowser
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from urllib.request import urlopen
|
||||
|
||||
from scrapling.fetchers import StealthyFetcher
|
||||
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
|
||||
async def main():
|
||||
# Launch CloakBrowser with remote debugging
|
||||
cb_browser = await launch_async(
|
||||
headless=True,
|
||||
args=["--remote-debugging-port=9245", "--remote-debugging-address=127.0.0.1"],
|
||||
)
|
||||
|
||||
# Get the WebSocket URL from Chrome (Scrapling requires ws:// scheme)
|
||||
info = json.loads(urlopen("http://127.0.0.1:9245/json/version").read())
|
||||
ws_url = info["webSocketDebuggerUrl"]
|
||||
|
||||
# Connect Scrapling to the stealth browser via CDP
|
||||
page = await StealthyFetcher.async_fetch(
|
||||
"https://example.com",
|
||||
cdp_url=ws_url,
|
||||
)
|
||||
|
||||
print(f"Title: {page.css('title::text').get()}")
|
||||
print(f"Text: {page.css('p::text').getall()}")
|
||||
|
||||
await cb_browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Selenium + CloakBrowser: use stealth Chromium with Selenium WebDriver.
|
||||
|
||||
CloakBrowser provides the binary and stealth args.
|
||||
Selenium drives it via ChromeDriver.
|
||||
|
||||
Requires: pip install selenium cloakbrowser
|
||||
Note: ChromeDriver version must match Chromium 145.
|
||||
pip install chromedriver-autoinstaller or download manually.
|
||||
"""
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
from cloakbrowser.config import get_default_stealth_args
|
||||
from cloakbrowser.download import ensure_binary
|
||||
|
||||
binary_path = ensure_binary()
|
||||
stealth_args = get_default_stealth_args()
|
||||
|
||||
options = Options()
|
||||
options.binary_location = binary_path
|
||||
options.add_argument("--headless")
|
||||
for arg in stealth_args:
|
||||
options.add_argument(arg)
|
||||
|
||||
driver = webdriver.Chrome(options=options)
|
||||
|
||||
driver.get("https://example.com")
|
||||
print(f"Selenium + CloakBrowser: {driver.title}")
|
||||
|
||||
# Verify stealth
|
||||
result = driver.execute_script("""
|
||||
return {
|
||||
webdriver: navigator.webdriver,
|
||||
plugins: navigator.plugins.length,
|
||||
platform: navigator.platform,
|
||||
}
|
||||
""")
|
||||
print(f"Stealth checks: {result}")
|
||||
|
||||
driver.quit()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""undetected-chromedriver + CloakBrowser: double stealth layer.
|
||||
|
||||
undetected-chromedriver patches ChromeDriver detection signals,
|
||||
CloakBrowser patches the browser fingerprints at the C++ level.
|
||||
|
||||
Requires: pip install undetected-chromedriver cloakbrowser
|
||||
"""
|
||||
|
||||
import undetected_chromedriver as uc
|
||||
|
||||
from cloakbrowser.config import get_chromium_version, get_default_stealth_args
|
||||
from cloakbrowser.download import ensure_binary
|
||||
|
||||
binary_path = ensure_binary()
|
||||
stealth_args = get_default_stealth_args()
|
||||
chromium_major = int(get_chromium_version().split(".")[0])
|
||||
|
||||
options = uc.ChromeOptions()
|
||||
options.binary_location = binary_path
|
||||
options.add_argument("--headless")
|
||||
for arg in stealth_args:
|
||||
options.add_argument(arg)
|
||||
|
||||
driver = uc.Chrome(options=options, version_main=chromium_major)
|
||||
|
||||
driver.get("https://example.com")
|
||||
print(f"undetected-chromedriver + CloakBrowser: {driver.title}")
|
||||
|
||||
# Verify stealth
|
||||
result = driver.execute_script("""
|
||||
return {
|
||||
webdriver: navigator.webdriver,
|
||||
plugins: navigator.plugins.length,
|
||||
platform: navigator.platform,
|
||||
hardwareConcurrency: navigator.hardwareConcurrency,
|
||||
}
|
||||
""")
|
||||
print(f"Stealth checks: {result}")
|
||||
|
||||
driver.quit()
|
||||
+20
-8
@@ -11,12 +11,12 @@
|
||||
|
||||
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
|
||||
|
||||
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
||||
- **42 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
|
||||
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
|
||||
- **Free and open source** — no subscriptions, no usage limits
|
||||
- **Works with any framework** — also tested with Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser
|
||||
- **Works with any framework** — tested with browser-use, Crawl4AI, Scrapling, Stagehand ([example](examples/stagehand.ts)), LangChain, Selenium, and more
|
||||
|
||||
## Install
|
||||
|
||||
@@ -81,7 +81,7 @@ const browser = await launch({
|
||||
args: ['--fingerprint=12345'],
|
||||
});
|
||||
|
||||
// With timezone and locale (sets --fingerprint-timezone and --lang binary flags)
|
||||
// With timezone and locale
|
||||
const browser = await launch({
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
@@ -93,7 +93,7 @@ const browser = await launch({
|
||||
geoip: true,
|
||||
});
|
||||
|
||||
// Browser + context in one call (timezone/locale set both binary flags AND context)
|
||||
// Browser + context in one call (timezone/locale set via binary flags)
|
||||
const context = await launchContext({
|
||||
userAgent: 'Custom UA',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
@@ -133,6 +133,17 @@ const browser = await launch({ proxy: 'http://proxy:8080', geoip: true, timezone
|
||||
|
||||
> **Note:** For rotating residential proxies, the DNS-resolved IP may differ from the exit IP. Pass explicit `timezone`/`locale` in those cases.
|
||||
|
||||
### CLI
|
||||
|
||||
Pre-download the binary or check installation status from the command line:
|
||||
|
||||
```bash
|
||||
npx cloakbrowser install # Download binary with progress output
|
||||
npx cloakbrowser info # Show version, path, platform
|
||||
npx cloakbrowser update # Check for and download newer binary
|
||||
npx cloakbrowser clear-cache # Remove cached binaries
|
||||
```
|
||||
|
||||
### Utilities
|
||||
|
||||
```javascript
|
||||
@@ -192,10 +203,11 @@ const page = await browser.newPage();
|
||||
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 26 | ✅ Latest |
|
||||
| Linux x86_64 | 145 | 33 | ✅ Latest |
|
||||
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 33 | ✅ Latest |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -247,13 +259,13 @@ Other tips for maximizing reCAPTCHA scores:
|
||||
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
|
||||
```bash
|
||||
# Linux
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
|
||||
|
||||
# macOS
|
||||
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
|
||||
|
||||
# Windows
|
||||
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
|
||||
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.159.7\chrome.exe
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Stagehand + CloakBrowser: AI browser automation with stealth fingerprints.
|
||||
*
|
||||
* Stagehand handles AI-powered navigation and actions,
|
||||
* CloakBrowser handles bot detection.
|
||||
*
|
||||
* Requires: npm install @browserbasehq/stagehand cloakbrowser
|
||||
* Set OPENAI_API_KEY for the AI model.
|
||||
*
|
||||
* Usage:
|
||||
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/stagehand.ts
|
||||
*/
|
||||
|
||||
import { Stagehand } from "@browserbasehq/stagehand";
|
||||
import { ensureBinary } from "../src/download.js";
|
||||
import { getDefaultStealthArgs } from "../src/config.js";
|
||||
|
||||
const binaryPath = await ensureBinary();
|
||||
const stealthArgs = getDefaultStealthArgs();
|
||||
|
||||
const stagehand = new Stagehand({
|
||||
env: "LOCAL",
|
||||
localBrowserLaunchOptions: {
|
||||
executablePath: binaryPath,
|
||||
args: stealthArgs,
|
||||
headless: true,
|
||||
},
|
||||
});
|
||||
|
||||
await stagehand.init();
|
||||
|
||||
const page = stagehand.context.pages()[0];
|
||||
await page.goto("https://example.com");
|
||||
console.log(`Stagehand + CloakBrowser: ${await page.title()}`);
|
||||
|
||||
await stagehand.close();
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tar": "^7.0.0"
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.19",
|
||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -15,6 +15,9 @@
|
||||
"import": "./dist/puppeteer.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"cloakbrowser": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
+15
-5
@@ -20,6 +20,15 @@ export function buildArgs(options: LaunchOptions): string[] {
|
||||
seen.set(arg.split("=")[0], arg);
|
||||
}
|
||||
}
|
||||
// GPU blocklist bypass:
|
||||
// - Headed mode (all platforms): Chromium blocks WebGL on software GPUs
|
||||
// in Docker/Xvfb. Flag lets SwiftShader serve WebGL. See issue #56.
|
||||
// - Windows (all modes): Chromium's GPU blocklist blocks WebGPU for the
|
||||
// Microsoft Basic Render Driver. Dawn's adapter_blocklist bypass alone
|
||||
// isn't enough. Linux doesn't need it.
|
||||
if (options.headless === false || process.platform === "win32") {
|
||||
seen.set("--ignore-gpu-blocklist", "--ignore-gpu-blocklist");
|
||||
}
|
||||
if (options.args) {
|
||||
for (const arg of options.args) {
|
||||
const key = arg.split("=")[0];
|
||||
@@ -38,12 +47,13 @@ export function buildArgs(options: LaunchOptions): string[] {
|
||||
seen.set(key, flag);
|
||||
}
|
||||
if (options.locale) {
|
||||
const key = "--lang";
|
||||
const flag = `${key}=${options.locale}`;
|
||||
if (seen.has(key)) {
|
||||
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
|
||||
for (const k of ["--lang", "--fingerprint-locale"] as const) {
|
||||
const flag = `${k}=${options.locale}`;
|
||||
if (seen.has(k)) {
|
||||
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(k)} -> ${flag}`);
|
||||
}
|
||||
seen.set(k, flag);
|
||||
}
|
||||
seen.set(key, flag);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI for cloakbrowser — download and manage the stealth Chromium binary.
|
||||
*
|
||||
* Usage:
|
||||
* npx cloakbrowser install # Download binary (with progress)
|
||||
* npx cloakbrowser info # Show binary version, path, platform
|
||||
* npx cloakbrowser update # Check for and download newer binary
|
||||
* npx cloakbrowser clear-cache # Remove cached binaries
|
||||
*/
|
||||
|
||||
import { ensureBinary, binaryInfo, checkForUpdate, clearCache } from "./download.js";
|
||||
import { getLocalBinaryOverride, getCacheDir } from "./config.js";
|
||||
import fs from "node:fs";
|
||||
|
||||
const USAGE = `Usage: cloakbrowser <command>
|
||||
|
||||
Commands:
|
||||
install Download the Chromium binary
|
||||
info Show binary version, path, and platform
|
||||
update Check for and download a newer binary
|
||||
clear-cache Remove all cached binaries`;
|
||||
|
||||
async function cmdInstall(): Promise<void> {
|
||||
const binaryPath = await ensureBinary();
|
||||
console.log(binaryPath);
|
||||
}
|
||||
|
||||
function cmdInfo(): void {
|
||||
const info = binaryInfo();
|
||||
const override = getLocalBinaryOverride();
|
||||
|
||||
console.log(`Version: ${info.version}`);
|
||||
console.log(`Platform: ${info.platform}`);
|
||||
console.log(`Binary: ${info.binaryPath}`);
|
||||
console.log(`Installed: ${info.installed}`);
|
||||
console.log(`Cache: ${info.cacheDir}`);
|
||||
if (override) {
|
||||
console.log(`Override: ${override} (CLOAKBROWSER_BINARY_PATH)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdUpdate(): Promise<void> {
|
||||
console.error("Checking for updates...");
|
||||
const newVersion = await checkForUpdate();
|
||||
if (newVersion) {
|
||||
console.log(`Updated to Chromium ${newVersion}`);
|
||||
} else {
|
||||
console.log("Already up to date.");
|
||||
}
|
||||
}
|
||||
|
||||
function cmdClearCache(): void {
|
||||
const cacheDir = getCacheDir();
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
console.log("No cache to clear.");
|
||||
return;
|
||||
}
|
||||
clearCache();
|
||||
console.log("Cache cleared.");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const command = process.argv[2];
|
||||
|
||||
if (!command || command === "--help" || command === "-h") {
|
||||
console.log(USAGE);
|
||||
process.exit(command ? 0 : 2);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case "install":
|
||||
await cmdInstall();
|
||||
break;
|
||||
case "info":
|
||||
cmdInfo();
|
||||
break;
|
||||
case "update":
|
||||
await cmdUpdate();
|
||||
break;
|
||||
case "clear-cache":
|
||||
cmdClearCache();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${command}\n`);
|
||||
console.log(USAGE);
|
||||
process.exit(2);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+14
-5
@@ -27,13 +27,14 @@ export { WRAPPER_VERSION };
|
||||
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
// Use getChromiumVersion() for the current platform's actual version.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHROMIUM_VERSION = "145.0.7632.159";
|
||||
export const CHROMIUM_VERSION = "145.0.7632.159.8";
|
||||
|
||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||
"linux-x64": "145.0.7632.159",
|
||||
"linux-x64": "145.0.7632.159.8",
|
||||
"linux-arm64": "145.0.7632.159.7",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "145.0.7632.159.7",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -188,6 +189,14 @@ export function getLocalBinaryOverride(): string | undefined {
|
||||
return process.env.CLOAKBROWSER_BINARY_PATH || undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playwright default args to suppress — these leak automation signals.
|
||||
// --enable-automation: exposes navigator.webdriver = true
|
||||
// --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader,
|
||||
// producing a distinctive renderer string that no real user browser has
|
||||
// ---------------------------------------------------------------------------
|
||||
export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default stealth arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -222,7 +231,7 @@ export function getDefaultStealthArgs(): string[] {
|
||||
return [
|
||||
...base,
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070",
|
||||
"--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
|
||||
"--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
];
|
||||
}
|
||||
|
||||
+10
-8
@@ -146,13 +146,14 @@ export async function checkForUpdate(): Promise<string | null> {
|
||||
function showWelcome(): void {
|
||||
const marker = path.join(getCacheDir(), ".welcome_shown");
|
||||
if (fs.existsSync(marker)) return;
|
||||
console.log();
|
||||
console.log(" CloakBrowser — stealth Chromium for automation");
|
||||
console.log(" https://github.com/CloakHQ/CloakBrowser");
|
||||
console.log();
|
||||
console.log(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
|
||||
console.log(" Star us if CloakBrowser helps your project!");
|
||||
console.log();
|
||||
console.error();
|
||||
console.error(" CloakBrowser — stealth Chromium for automation");
|
||||
console.error(" https://github.com/CloakHQ/CloakBrowser");
|
||||
console.error();
|
||||
console.error(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
|
||||
console.error(" Donate? https://ko-fi.com/cloakhq");
|
||||
console.error(" Star us if CloakBrowser helps your project!");
|
||||
console.error();
|
||||
try {
|
||||
fs.mkdirSync(getCacheDir(), { recursive: true });
|
||||
fs.writeFileSync(marker, "");
|
||||
@@ -227,7 +228,8 @@ async function verifyDownloadChecksum(filePath: string, version?: string): Promi
|
||||
await verifyChecksum(filePath, expected);
|
||||
}
|
||||
|
||||
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
|
||||
/** @internal Exported for testing only. */
|
||||
export async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
|
||||
const v = version || getChromiumVersion();
|
||||
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import { createWriteStream } from "node:fs";
|
||||
import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
import { getCacheDir } from "./config.js";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { ensureProxyScheme } from "./proxy.js";
|
||||
|
||||
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
|
||||
const GEOIP_DB_URL =
|
||||
@@ -260,3 +262,23 @@ function maybeTriggerUpdate(dbPath: string): void {
|
||||
// Fire-and-forget background update
|
||||
downloadGeoipDb(dbPath).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
|
||||
* Shared by the Playwright and Puppeteer wrappers.
|
||||
*/
|
||||
export async function maybeResolveGeoip(
|
||||
options: LaunchOptions
|
||||
): Promise<{ timezone?: string; locale?: string }> {
|
||||
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;
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
proxyUrl = ensureProxyScheme(proxyUrl);
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
|
||||
return {
|
||||
timezone: options.timezone ?? geoTz ?? undefined,
|
||||
locale: options.locale ?? geoLocale ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* cloakbrowser-human — Configuration and presets.
|
||||
*
|
||||
* All numeric parameters for human-like behavior are centralized here.
|
||||
* Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HumanConfig {
|
||||
// Keyboard
|
||||
typing_delay: number;
|
||||
typing_delay_spread: number;
|
||||
typing_pause_chance: number;
|
||||
typing_pause_range: [number, number];
|
||||
shift_down_delay: [number, number];
|
||||
shift_up_delay: [number, number];
|
||||
key_hold: [number, number];
|
||||
field_switch_delay: [number, number];
|
||||
mistype_chance: number;
|
||||
mistype_delay_notice: [number, number];
|
||||
mistype_delay_correct: [number, number];
|
||||
|
||||
|
||||
// Mouse — movement
|
||||
mouse_steps_divisor: number;
|
||||
mouse_min_steps: number;
|
||||
mouse_max_steps: number;
|
||||
mouse_wobble_max: number;
|
||||
mouse_overshoot_chance: number;
|
||||
mouse_overshoot_px: [number, number];
|
||||
mouse_burst_size: [number, number];
|
||||
mouse_burst_pause: [number, number];
|
||||
|
||||
// Mouse — clicks
|
||||
click_aim_delay_input: [number, number];
|
||||
click_aim_delay_button: [number, number];
|
||||
click_hold_input: [number, number];
|
||||
click_hold_button: [number, number];
|
||||
click_input_x_range: [number, number];
|
||||
|
||||
// Mouse — idle
|
||||
idle_drift_px: number;
|
||||
idle_pause_range: [number, number];
|
||||
|
||||
// Scroll
|
||||
scroll_delta_base: [number, number];
|
||||
scroll_delta_variance: number;
|
||||
scroll_pause_fast: [number, number];
|
||||
scroll_pause_slow: [number, number];
|
||||
scroll_accel_steps: [number, number];
|
||||
scroll_decel_steps: [number, number];
|
||||
scroll_overshoot_chance: number;
|
||||
scroll_overshoot_px: [number, number];
|
||||
scroll_settle_delay: [number, number];
|
||||
scroll_target_zone: [number, number];
|
||||
scroll_pre_move_delay: [number, number];
|
||||
|
||||
// Initial cursor position
|
||||
initial_cursor_x: [number, number];
|
||||
initial_cursor_y: [number, number];
|
||||
|
||||
|
||||
// Idle micro-movements between actions (opt-in, adds latency)
|
||||
idle_between_actions: boolean;
|
||||
idle_between_duration: [number, number];
|
||||
}
|
||||
|
||||
export type HumanPreset = 'default' | 'careful';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default preset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_CONFIG: HumanConfig = {
|
||||
// Keyboard
|
||||
typing_delay: 70,
|
||||
typing_delay_spread: 40,
|
||||
typing_pause_chance: 0.1,
|
||||
typing_pause_range: [400, 1000],
|
||||
shift_down_delay: [30, 70],
|
||||
shift_up_delay: [20, 50],
|
||||
key_hold: [15, 35],
|
||||
field_switch_delay: [800, 1500],
|
||||
// Mistype (typo simulation)
|
||||
mistype_chance: 0.02,
|
||||
mistype_delay_notice: [100, 300],
|
||||
mistype_delay_correct: [50, 150],
|
||||
|
||||
// Mouse — movement
|
||||
mouse_steps_divisor: 8,
|
||||
mouse_min_steps: 25,
|
||||
mouse_max_steps: 80,
|
||||
mouse_wobble_max: 1.5,
|
||||
mouse_overshoot_chance: 0.15,
|
||||
mouse_overshoot_px: [3, 6],
|
||||
mouse_burst_size: [3, 5],
|
||||
mouse_burst_pause: [8, 18],
|
||||
|
||||
// Mouse — clicks
|
||||
click_aim_delay_input: [60, 140],
|
||||
click_aim_delay_button: [80, 200],
|
||||
click_hold_input: [40, 100],
|
||||
click_hold_button: [60, 150],
|
||||
click_input_x_range: [0.05, 0.30],
|
||||
|
||||
// Mouse — idle
|
||||
idle_drift_px: 3,
|
||||
idle_pause_range: [300, 1000],
|
||||
|
||||
// Scroll
|
||||
scroll_delta_base: [80, 130],
|
||||
scroll_delta_variance: 0.2,
|
||||
scroll_pause_fast: [30, 80],
|
||||
scroll_pause_slow: [80, 200],
|
||||
scroll_accel_steps: [2, 3],
|
||||
scroll_decel_steps: [2, 3],
|
||||
scroll_overshoot_chance: 0.1,
|
||||
scroll_overshoot_px: [50, 150],
|
||||
scroll_settle_delay: [300, 600],
|
||||
scroll_target_zone: [0.20, 0.80],
|
||||
scroll_pre_move_delay: [100, 300],
|
||||
|
||||
// Initial cursor position (as if coming from the address bar area)
|
||||
initial_cursor_x: [400, 700],
|
||||
initial_cursor_y: [45, 60],
|
||||
|
||||
// Idle micro-movements between actions (off by default)
|
||||
idle_between_actions: false,
|
||||
idle_between_duration: [0.3, 0.8],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Careful preset — everything slower and more deliberate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CAREFUL_CONFIG: HumanConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
|
||||
// Keyboard — slower typing
|
||||
typing_delay: 100,
|
||||
typing_delay_spread: 50,
|
||||
typing_pause_chance: 0.15,
|
||||
typing_pause_range: [500, 1200],
|
||||
shift_down_delay: [40, 90],
|
||||
shift_up_delay: [30, 70],
|
||||
key_hold: [20, 45],
|
||||
field_switch_delay: [1000, 2000],
|
||||
mistype_chance: 0.03,
|
||||
mistype_delay_notice: [150, 400],
|
||||
mistype_delay_correct: [80, 200],
|
||||
|
||||
// Mouse — slower, more precise
|
||||
mouse_overshoot_chance: 0.10,
|
||||
mouse_burst_pause: [12, 25],
|
||||
|
||||
// Mouse — clicks (longer aiming and holding)
|
||||
click_aim_delay_input: [80, 180],
|
||||
click_aim_delay_button: [120, 280],
|
||||
click_hold_input: [60, 140],
|
||||
click_hold_button: [80, 200],
|
||||
|
||||
// Scroll — slower
|
||||
scroll_pause_fast: [100, 200],
|
||||
scroll_pause_slow: [250, 600],
|
||||
scroll_settle_delay: [400, 800],
|
||||
scroll_pre_move_delay: [150, 400],
|
||||
|
||||
// Idle between actions enabled for careful preset
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [0.4, 1.0],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preset map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRESETS: Record<HumanPreset, HumanConfig> = {
|
||||
default: DEFAULT_CONFIG,
|
||||
careful: CAREFUL_CONFIG,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a preset name or partial config into a full HumanConfig.
|
||||
* If `preset` is a string, returns the corresponding built-in config.
|
||||
* Any keys in `overrides` replace the preset values.
|
||||
*/
|
||||
export function resolveConfig(
|
||||
preset: HumanPreset = 'default',
|
||||
overrides?: Partial<HumanConfig>,
|
||||
): HumanConfig {
|
||||
const base = PRESETS[preset];
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`Unknown humanize preset "${preset}". Valid presets: ${Object.keys(PRESETS).join(', ')}`
|
||||
);
|
||||
}
|
||||
if (!overrides) return { ...base };
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: random number in range
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Random float in [min, max]. */
|
||||
export function rand(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
/** Random integer in [min, max] (inclusive). */
|
||||
export function randInt(min: number, max: number): number {
|
||||
return Math.floor(rand(min, max + 1));
|
||||
}
|
||||
|
||||
/** Random value from a [min, max] tuple. */
|
||||
export function randRange(range: [number, number]): number {
|
||||
return rand(range[0], range[1]);
|
||||
}
|
||||
|
||||
/** Random integer from a [min, max] tuple. */
|
||||
export function randIntRange(range: [number, number]): number {
|
||||
return randInt(range[0], range[1]);
|
||||
}
|
||||
|
||||
/** Sleep for `ms` milliseconds. */
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* Human-like behavioral layer for cloakbrowser (JS/TS).
|
||||
*
|
||||
* Activated via humanize: true in launch() / launchContext().
|
||||
* Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling.
|
||||
*
|
||||
* Patches all interaction methods:
|
||||
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
|
||||
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext, Page, Frame } from 'playwright-core';
|
||||
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement } from './scroll.js';
|
||||
|
||||
export { HumanConfig, resolveConfig } from './config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
export { humanType } from './keyboard.js';
|
||||
export { scrollToElement } from './scroll.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
|
||||
class CursorState {
|
||||
x = 0;
|
||||
y = 0;
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
async function isInputElement(page: Page, selector: string): Promise<boolean> {
|
||||
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(page: Page, selector: string): Promise<boolean> {
|
||||
return page.evaluate((sel: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el === document.activeElement;
|
||||
}, selector).catch(() => false);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Page-level patching
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Replace page methods with human-like implementations.
|
||||
*/
|
||||
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
const originals = {
|
||||
click: page.click.bind(page),
|
||||
dblclick: page.dblclick.bind(page),
|
||||
hover: page.hover.bind(page),
|
||||
type: page.type.bind(page),
|
||||
fill: page.fill.bind(page),
|
||||
check: page.check.bind(page),
|
||||
uncheck: page.uncheck.bind(page),
|
||||
selectOption: page.selectOption.bind(page),
|
||||
press: page.press.bind(page),
|
||||
goto: page.goto.bind(page),
|
||||
isChecked: page.isChecked.bind(page),
|
||||
mouseMove: page.mouse.move.bind(page.mouse),
|
||||
mouseClick: page.mouse.click.bind(page.mouse),
|
||||
mouseDblclick: page.mouse.dblclick.bind(page.mouse),
|
||||
mouseWheel: page.mouse.wheel.bind(page.mouse),
|
||||
mouseDown: page.mouse.down.bind(page.mouse),
|
||||
mouseUp: page.mouse.up.bind(page.mouse),
|
||||
keyboardType: page.keyboard.type.bind(page.keyboard),
|
||||
keyboardDown: page.keyboard.down.bind(page.keyboard),
|
||||
keyboardUp: page.keyboard.up.bind(page.keyboard),
|
||||
keyboardPress: page.keyboard.press.bind(page.keyboard),
|
||||
keyboardInsertText: page.keyboard.insertText.bind(page.keyboard),
|
||||
};
|
||||
|
||||
(page as any)._original = originals;
|
||||
(page as any)._humanCfg = cfg;
|
||||
|
||||
const raw: RawMouse = {
|
||||
move: originals.mouseMove,
|
||||
down: originals.mouseDown,
|
||||
up: originals.mouseUp,
|
||||
wheel: originals.mouseWheel,
|
||||
};
|
||||
|
||||
const rawKb: RawKeyboard = {
|
||||
down: originals.keyboardDown,
|
||||
up: originals.keyboardUp,
|
||||
type: originals.keyboardType,
|
||||
insertText: originals.keyboardInsertText,
|
||||
};
|
||||
|
||||
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);
|
||||
// Patch any new frames after navigation
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
||||
return response;
|
||||
};
|
||||
|
||||
// --- click ---
|
||||
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(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;
|
||||
await humanClick(raw, isInput, cfg);
|
||||
};
|
||||
|
||||
// --- dblclick ---
|
||||
const humanDblclickFn = 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(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;
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
};
|
||||
|
||||
// --- 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));
|
||||
await humanType(page, rawKb, text, cfg);
|
||||
};
|
||||
|
||||
// --- fill (clears existing content first) ---
|
||||
const humanFillFn = async (selector: string, value: string, options?: any) => {
|
||||
await sleep(randRange(cfg.field_switch_delay));
|
||||
await humanClickFn(selector);
|
||||
await sleep(rand(100, 250));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
await sleep(rand(50, 150));
|
||||
await humanType(page, rawKb, value, cfg);
|
||||
};
|
||||
|
||||
// --- clear ---
|
||||
const humanClearFn = async (selector: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
};
|
||||
|
||||
// --- check ---
|
||||
const humanCheckFn = async (selector: string, options?: any) => {
|
||||
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 checked = await originals.isChecked(selector).catch(() => false);
|
||||
if (!checked) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
};
|
||||
|
||||
// --- uncheck ---
|
||||
const humanUncheckFn = async (selector: string, options?: any) => {
|
||||
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 checked = await originals.isChecked(selector).catch(() => true);
|
||||
if (checked) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
};
|
||||
|
||||
// --- selectOption ---
|
||||
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => {
|
||||
await humanHoverFn(selector);
|
||||
await sleep(rand(100, 300));
|
||||
return originals.selectOption(selector, values, options);
|
||||
};
|
||||
|
||||
// --- press (checks focus first — avoids redundant mouse moves) ---
|
||||
const humanPressFn = async (selector: string, key: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(key);
|
||||
};
|
||||
|
||||
// --- pressSequentially ---
|
||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(100, 250));
|
||||
await humanType(page, rawKb, text, cfg);
|
||||
};
|
||||
|
||||
// --- 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).dblclick = humanDblclickFn;
|
||||
(page as any).hover = humanHoverFn;
|
||||
(page as any).type = humanTypeFn;
|
||||
(page as any).fill = humanFillFn;
|
||||
(page as any).check = humanCheckFn;
|
||||
(page as any).uncheck = humanUncheckFn;
|
||||
(page as any).selectOption = humanSelectOptionFn;
|
||||
(page as any).press = humanPressFn;
|
||||
|
||||
// --- 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;
|
||||
await humanClick(raw, false, cfg);
|
||||
};
|
||||
|
||||
// --- keyboard patches ---
|
||||
page.keyboard.type = async (text: string, options?: any) => {
|
||||
await humanType(page, rawKb, text, cfg);
|
||||
};
|
||||
|
||||
// Store helpers for frame patching
|
||||
(page as any)._humanCursor = cursor;
|
||||
(page as any)._humanRaw = raw;
|
||||
(page as any)._humanRawKb = rawKb;
|
||||
(page as any)._humanOriginals = originals;
|
||||
(page as any)._humanClickFn = humanClickFn;
|
||||
(page as any)._humanHoverFn = humanHoverFn;
|
||||
(page as any)._humanClearFn = humanClearFn;
|
||||
(page as any)._humanPressFn = humanPressFn;
|
||||
(page as any)._humanPressSequentiallyFn = humanPressSequentiallyFn;
|
||||
(page as any)._humanTapFn = humanTapFn;
|
||||
(page as any)._ensureCursorInit = ensureCursorInit;
|
||||
|
||||
// Initialize cursor immediately so it doesn't visibly jump from (0,0)
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
|
||||
originals.mouseMove(cursor.x, cursor.y).then(() => {
|
||||
cursor.initialized = true;
|
||||
}).catch(() => {});
|
||||
|
||||
// --- Patch Frame-level methods (for sub-frames) ---
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Frame-level patching
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Patch Frame methods so Locator-based calls go through humanization.
|
||||
* All 11 methods patched: click, dblclick, hover, type, fill, check, uncheck,
|
||||
* selectOption, press, clear, dragAndDrop.
|
||||
*/
|
||||
function patchFrames(
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
): void {
|
||||
for (const frame of iterFrames(page)) {
|
||||
patchSingleFrame(frame, page, cfg, originals);
|
||||
}
|
||||
}
|
||||
|
||||
function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void {
|
||||
if ((frame as any)._humanPatched) return;
|
||||
(frame as any)._humanPatched = true;
|
||||
|
||||
// Save originals for methods that need fallback
|
||||
const origFrameSelectOption = frame.selectOption.bind(frame);
|
||||
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
|
||||
|
||||
(frame as any).click = async (selector: string, options?: any) => {
|
||||
await (page as any).click(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).dblclick = async (selector: string, options?: any) => {
|
||||
await (page as any).dblclick(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).fill = async (selector: string, value: string, options?: any) => {
|
||||
await (page as any).fill(selector, value, options);
|
||||
};
|
||||
|
||||
(frame as any).check = async (selector: string, options?: any) => {
|
||||
await (page as any).check(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).uncheck = async (selector: string, options?: any) => {
|
||||
await (page as any).uncheck(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).selectOption = async (selector: string, values: any, options?: any) => {
|
||||
await (page as any).hover(selector);
|
||||
await sleep(rand(100, 300));
|
||||
return origFrameSelectOption(selector, values, options);
|
||||
};
|
||||
|
||||
(frame as any).press = async (selector: string, key: string, options?: any) => {
|
||||
await (page as any).press(selector, key, options);
|
||||
};
|
||||
|
||||
(frame as any).clear = async (selector: string, options?: any) => {
|
||||
if (!await isSelectorFocused(page, selector)) {
|
||||
await (page as any).click(selector);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
};
|
||||
|
||||
(frame as any).dragAndDrop = async (source: string, target: string, options?: any) => {
|
||||
const srcBox = await frame.locator(source).boundingBox().catch(() => null);
|
||||
const tgtBox = await frame.locator(target).boundingBox().catch(() => null);
|
||||
|
||||
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.mouse.move(sx, sy);
|
||||
await sleep(rand(100, 200));
|
||||
await originals.mouseDown();
|
||||
await sleep(rand(80, 150));
|
||||
await page.mouse.move(tx, ty);
|
||||
await sleep(rand(80, 150));
|
||||
await originals.mouseUp();
|
||||
} else {
|
||||
return origFrameDragAndDrop(source, target, options);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function* iterFrames(page: Page): Generator<Frame> {
|
||||
try {
|
||||
const mainFrame = page.mainFrame();
|
||||
yield mainFrame;
|
||||
for (const child of mainFrame.childFrames()) {
|
||||
yield child;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Context-level patching
|
||||
// ============================================================================
|
||||
|
||||
function patchContext(context: BrowserContext, cfg: HumanConfig): void {
|
||||
const cursor = new CursorState();
|
||||
for (const page of context.pages()) {
|
||||
patchPage(page, cfg, cursor);
|
||||
}
|
||||
context.on('page', (page: Page) => {
|
||||
if (!(page as any)._original) {
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
});
|
||||
|
||||
const origNewPage = context.newPage.bind(context);
|
||||
(context as any).newPage = async () => {
|
||||
const page = await origNewPage();
|
||||
if (!(page as any)._original) {
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
return page;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Browser-level patching
|
||||
// ============================================================================
|
||||
|
||||
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
|
||||
for (const context of browser.contexts()) {
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
const origNewContext = browser.newContext.bind(browser);
|
||||
(browser as any).newContext = async (options?: any) => {
|
||||
const context = await origNewContext(options);
|
||||
patchContext(context, cfg);
|
||||
return context;
|
||||
};
|
||||
|
||||
const origNewPage = browser.newPage.bind(browser);
|
||||
(browser as any).newPage = async (options?: any) => {
|
||||
const page = await origNewPage(options);
|
||||
if (!(page as any)._original) {
|
||||
const ctx = page.context();
|
||||
if (!(ctx as any)._humanPatched) {
|
||||
patchContext(ctx, cfg);
|
||||
(ctx as any)._humanPatched = true;
|
||||
}
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
return page;
|
||||
};
|
||||
}
|
||||
|
||||
export { patchContext, patchPage };
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* cloakbrowser-human — Human-like keyboard input.
|
||||
*/
|
||||
|
||||
import type { Page } from 'playwright-core';
|
||||
import { RawKeyboard } from './mouse.js';
|
||||
import { HumanConfig, rand, randRange, sleep } from './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',
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function humanType(
|
||||
page: Page,
|
||||
raw: RawKeyboard,
|
||||
text: string,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void> {
|
||||
const chars = [...text]; // Handle emoji surrogate pairs correctly
|
||||
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const ch = chars[i];
|
||||
|
||||
// Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||
if (!isAscii(ch)) {
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.insertText(ch);
|
||||
if (i < chars.length - 1) {
|
||||
await interCharDelay(cfg);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mistype chance — only for ASCII alphanumeric
|
||||
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);
|
||||
} 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): Promise<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
function isUpperCase(ch: string): boolean {
|
||||
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||
}
|
||||
|
||||
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,193 @@
|
||||
/**
|
||||
* cloakbrowser-human — Human-like mouse movement and clicking.
|
||||
*/
|
||||
|
||||
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw interface — original Playwright methods, bypassing the wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RawMouse {
|
||||
move: (x: number, y: number) => Promise<void>;
|
||||
down: (options?: any) => Promise<void>;
|
||||
up: (options?: any) => Promise<void>;
|
||||
wheel: (deltaX: number, deltaY: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface RawKeyboard {
|
||||
down: (key: string) => Promise<void>;
|
||||
up: (key: string) => Promise<void>;
|
||||
type: (text: string) => Promise<void>;
|
||||
insertText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Easing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function easeInOut(t: number): number {
|
||||
return t < 0.5
|
||||
? 4 * t * t * t
|
||||
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bezier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
|
||||
const u = 1 - t;
|
||||
const uu = u * u;
|
||||
const uuu = uu * u;
|
||||
const tt = t * t;
|
||||
const ttt = tt * t;
|
||||
return {
|
||||
x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
|
||||
y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
function randomControlPoints(start: Point, end: Point): [Point, Point] {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const px = -dy / (dist || 1);
|
||||
const py = dx / (dist || 1);
|
||||
const bias1 = rand(-0.3, 0.3) * dist;
|
||||
const bias2 = rand(-0.3, 0.3) * dist;
|
||||
return [
|
||||
{ x: start.x + dx * 0.25 + px * bias1, y: start.y + dy * 0.25 + py * bias1 },
|
||||
{ x: start.x + dx * 0.75 + px * bias2, y: start.y + dy * 0.75 + py * bias2 },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Human mouse movement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function humanMove(
|
||||
raw: RawMouse,
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void> {
|
||||
const dist = Math.hypot(endX - startX, endY - startY);
|
||||
if (dist < 1) return;
|
||||
|
||||
const steps = Math.max(
|
||||
cfg.mouse_min_steps,
|
||||
Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)),
|
||||
);
|
||||
|
||||
const start: Point = { x: startX, y: startY };
|
||||
const end: Point = { x: endX, y: endY };
|
||||
const [cp1, cp2] = randomControlPoints(start, end);
|
||||
|
||||
let burstCounter = 0;
|
||||
const burstSize = randIntRange(cfg.mouse_burst_size);
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const progress = i / steps;
|
||||
const easedT = easeInOut(progress);
|
||||
const pt = bezier(start, cp1, cp2, end, easedT);
|
||||
|
||||
const wobbleAmp = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max;
|
||||
const wx = pt.x + (Math.random() - 0.5) * 2 * wobbleAmp;
|
||||
const wy = pt.y + (Math.random() - 0.5) * 2 * wobbleAmp;
|
||||
|
||||
await raw.move(Math.round(wx), Math.round(wy));
|
||||
|
||||
burstCounter++;
|
||||
if (burstCounter >= burstSize && i < steps) {
|
||||
await sleep(randRange(cfg.mouse_burst_pause));
|
||||
burstCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.random() < cfg.mouse_overshoot_chance) {
|
||||
const overshootDist = randRange(cfg.mouse_overshoot_px);
|
||||
const angle = Math.atan2(endY - startY, endX - startX);
|
||||
const ovX = Math.round(endX + Math.cos(angle) * overshootDist);
|
||||
const ovY = Math.round(endY + Math.sin(angle) * overshootDist);
|
||||
await raw.move(ovX, ovY);
|
||||
await sleep(rand(30, 70));
|
||||
const corrX = Math.round(endX + (Math.random() - 0.5) * 4);
|
||||
const corrY = Math.round(endY + (Math.random() - 0.5) * 4);
|
||||
await raw.move(corrX, corrY);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Human click
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function clickTarget(
|
||||
box: { x: number; y: number; width: number; height: number },
|
||||
isInput: boolean,
|
||||
cfg: HumanConfig,
|
||||
): Point {
|
||||
if (isInput) {
|
||||
const xFrac = randRange(cfg.click_input_x_range);
|
||||
const yFrac = rand(0.30, 0.70);
|
||||
return {
|
||||
x: Math.round(box.x + box.width * xFrac),
|
||||
y: Math.round(box.y + box.height * yFrac),
|
||||
};
|
||||
}
|
||||
const xFrac = rand(0.35, 0.65);
|
||||
const yFrac = rand(0.35, 0.65);
|
||||
return {
|
||||
x: Math.round(box.x + box.width * xFrac),
|
||||
y: Math.round(box.y + box.height * yFrac),
|
||||
};
|
||||
}
|
||||
|
||||
export async function humanClick(
|
||||
raw: RawMouse,
|
||||
isInput: boolean,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void> {
|
||||
const aimDelay = isInput
|
||||
? randRange(cfg.click_aim_delay_input)
|
||||
: randRange(cfg.click_aim_delay_button);
|
||||
await sleep(aimDelay);
|
||||
|
||||
const holdTime = isInput
|
||||
? randRange(cfg.click_hold_input)
|
||||
: randRange(cfg.click_hold_button);
|
||||
await raw.down();
|
||||
await sleep(holdTime);
|
||||
await raw.up();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Human idle / drift
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function humanIdle(
|
||||
raw: RawMouse,
|
||||
seconds: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<void> {
|
||||
const endTime = Date.now() + seconds * 1000;
|
||||
let x = cx;
|
||||
let y = cy;
|
||||
while (Date.now() < endTime) {
|
||||
const dx = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
|
||||
const dy = (Math.random() - 0.5) * 2 * cfg.idle_drift_px;
|
||||
x += dx;
|
||||
y += dy;
|
||||
await raw.move(Math.round(x), Math.round(y));
|
||||
await sleep(randRange(cfg.idle_pause_range));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* cloakbrowser-human — Human-like scrolling via mouse wheel events.
|
||||
*/
|
||||
|
||||
import type { Page } from 'playwright-core';
|
||||
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
|
||||
import { RawMouse, humanMove } from './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;
|
||||
}
|
||||
|
||||
async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): 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);
|
||||
await raw.wheel(0, Math.round(chunk) * sign);
|
||||
sent += chunk;
|
||||
await sleep(rand(8, 20));
|
||||
}
|
||||
}
|
||||
|
||||
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.viewportSize();
|
||||
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;
|
||||
|
||||
// Scroll loop: accelerate → cruise → decelerate
|
||||
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);
|
||||
|
||||
// Check visibility every 3 steps
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// Settle
|
||||
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 };
|
||||
}
|
||||
|
||||
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
|
||||
const el = page.locator(selector).first();
|
||||
try {
|
||||
const box = await el.boundingBox({ timeout: 2000 });
|
||||
return box;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+47
-32
@@ -5,15 +5,15 @@
|
||||
|
||||
import type { Browser, BrowserContext } from "playwright-core";
|
||||
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
||||
import { DEFAULT_VIEWPORT } from "./config.js";
|
||||
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
import { maybeResolveGeoip } from "./geoip.js";
|
||||
|
||||
/** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */
|
||||
export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
|
||||
/** @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 {
|
||||
if (options.timezoneId != null) {
|
||||
console.warn("[cloakbrowser] timezoneId is deprecated, use timezone instead");
|
||||
const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
|
||||
delete (merged as any).timezoneId;
|
||||
return merged;
|
||||
@@ -45,13 +45,24 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
// Human-like behavioral patching
|
||||
if (options.humanize) {
|
||||
const { patchBrowser } = await import('./human/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;
|
||||
}
|
||||
|
||||
@@ -74,21 +85,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
export async function launchContext(
|
||||
options: LaunchContextOptions = {}
|
||||
): Promise<BrowserContext> {
|
||||
options = migrateTimezoneId(options);
|
||||
options = resolveTimezone(options);
|
||||
// Resolve geoip BEFORE launch() to avoid double-resolution
|
||||
const resolved = await maybeResolveGeoip(options);
|
||||
// Skip --fingerprint-timezone binary flag: it only applies to the default
|
||||
// context and interferes with Playwright's timezoneId on new contexts.
|
||||
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
|
||||
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
|
||||
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||
// so it applies to ALL contexts, not just the default one.
|
||||
// locale and timezone are set via binary flags only — no CDP emulation.
|
||||
const browser = await launch({ ...options, ...resolved, geoip: false });
|
||||
|
||||
let context: BrowserContext;
|
||||
try {
|
||||
context = await browser.newContext({
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport ?? DEFAULT_VIEWPORT,
|
||||
...(resolved.locale ? { locale: resolved.locale } : {}),
|
||||
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -103,6 +112,17 @@ export async function launchContext(
|
||||
await browser.close();
|
||||
};
|
||||
|
||||
// Human-like behavioral patching
|
||||
if (options.humanize) {
|
||||
const { patchContext } = await import('./human/index.js');
|
||||
const { resolveConfig } = await import('./human/config.js');
|
||||
const cfg = resolveConfig(
|
||||
(options.humanPreset as any) ?? 'default',
|
||||
options.humanConfig as any,
|
||||
);
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -130,29 +150,40 @@ export async function launchContext(
|
||||
export async function launchPersistentContext(
|
||||
options: LaunchPersistentContextOptions
|
||||
): Promise<BrowserContext> {
|
||||
options = migrateTimezoneId(options);
|
||||
options = resolveTimezone(options);
|
||||
const { chromium } = await import("playwright-core");
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const resolved = await maybeResolveGeoip(options);
|
||||
const args = buildArgs({ ...options, ...resolved });
|
||||
|
||||
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||
// — NOT via Playwright context kwargs which use detectable CDP emulation.
|
||||
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(options.proxy
|
||||
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
|
||||
: {}),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport ?? DEFAULT_VIEWPORT,
|
||||
...(resolved.locale ? { locale: resolved.locale } : {}),
|
||||
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
// Human-like behavioral patching
|
||||
if (options.humanize) {
|
||||
const { patchContext } = await import('./human/index.js');
|
||||
const { resolveConfig } = await import('./human/config.js');
|
||||
const cfg = resolveConfig(
|
||||
(options.humanPreset as any) ?? 'default',
|
||||
options.humanConfig as any,
|
||||
);
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -160,21 +191,5 @@ export async function launchPersistentContext(
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function maybeResolveGeoip(
|
||||
options: LaunchOptions
|
||||
): Promise<{ timezone?: string; locale?: string }> {
|
||||
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
const { resolveProxyGeo } = await import("./geoip.js");
|
||||
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
|
||||
return {
|
||||
timezone: options.timezone ?? geoTz ?? undefined,
|
||||
locale: options.locale ?? geoLocale ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal Exposed for unit tests only. */
|
||||
export { buildArgs as _buildArgsForTest } from "./args.js";
|
||||
|
||||
+14
-2
@@ -8,16 +8,28 @@ export interface ParsedProxy {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend http:// to schemeless proxy URLs so parsers can extract hostname.
|
||||
* Used by geoip resolution which only needs a valid hostname, not auth fields.
|
||||
*/
|
||||
export function ensureProxyScheme(proxyUrl: string): string {
|
||||
return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a proxy URL, extracting credentials into separate fields.
|
||||
*
|
||||
* Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" }
|
||||
* 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).
|
||||
*/
|
||||
export function parseProxyUrl(proxy: string): ParsedProxy {
|
||||
let url: URL;
|
||||
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
|
||||
const normalized =
|
||||
proxy.includes("@") && !proxy.includes("://") ? `http://${proxy}` : proxy;
|
||||
try {
|
||||
url = new URL(proxy);
|
||||
url = new URL(normalized);
|
||||
} catch {
|
||||
// Not a parseable URL (e.g. bare "host:port") — pass through as-is
|
||||
return { server: proxy };
|
||||
|
||||
+3
-17
@@ -5,9 +5,11 @@
|
||||
|
||||
import type { Browser } from "puppeteer-core";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { parseProxyUrl } from "./proxy.js";
|
||||
import { maybeResolveGeoip } from "./geoip.js";
|
||||
|
||||
/**
|
||||
* Launch stealth Chromium browser via Puppeteer.
|
||||
@@ -61,7 +63,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: ["--enable-automation"],
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
@@ -83,19 +85,3 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function maybeResolveGeoip(
|
||||
options: LaunchOptions
|
||||
): Promise<{ timezone?: string; locale?: string }> {
|
||||
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
||||
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
const { resolveProxyGeo } = await import("./geoip.js");
|
||||
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
|
||||
return {
|
||||
timezone: options.timezone ?? geoTz ?? undefined,
|
||||
locale: options.locale ?? geoLocale ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -24,6 +24,12 @@ export interface LaunchOptions {
|
||||
geoip?: boolean;
|
||||
/** Raw options passed directly to playwright/puppeteer launch(). */
|
||||
launchOptions?: Record<string, unknown>;
|
||||
/** Enable human-like mouse, keyboard, and scroll behavior. */
|
||||
humanize?: boolean;
|
||||
/** Human behavior preset: 'default' or 'careful'. */
|
||||
humanPreset?: 'default' | 'careful';
|
||||
/** Override individual human behavior parameters. */
|
||||
humanConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LaunchContextOptions extends LaunchOptions {
|
||||
@@ -33,7 +39,7 @@ export interface LaunchContextOptions extends LaunchOptions {
|
||||
viewport?: { width: number; height: number };
|
||||
/** Browser locale, e.g. "en-US". */
|
||||
locale?: string;
|
||||
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */
|
||||
/** IANA timezone — alias for `timezone`. Either works. */
|
||||
timezoneId?: string;
|
||||
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
|
||||
colorScheme?: "light" | "dark" | "no-preference";
|
||||
|
||||
+17
-10
@@ -9,7 +9,7 @@ import {
|
||||
getDownloadUrl,
|
||||
getFallbackDownloadUrl,
|
||||
} from "../src/config.js";
|
||||
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js";
|
||||
import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js";
|
||||
|
||||
describe("config", () => {
|
||||
it("CHROMIUM_VERSION matches expected format", () => {
|
||||
@@ -97,21 +97,24 @@ describe("buildArgs timezone/locale", () => {
|
||||
expect(args).toContain("--fingerprint-timezone=America/New_York");
|
||||
});
|
||||
|
||||
it("injects --lang when locale is set", () => {
|
||||
it("injects --lang and --fingerprint-locale when locale is set", () => {
|
||||
const args = _buildArgsForTest({ locale: "en-US" });
|
||||
expect(args).toContain("--lang=en-US");
|
||||
expect(args).toContain("--fingerprint-locale=en-US");
|
||||
});
|
||||
|
||||
it("injects both when both are set", () => {
|
||||
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
|
||||
expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
|
||||
expect(args).toContain("--lang=de-DE");
|
||||
expect(args).toContain("--fingerprint-locale=de-DE");
|
||||
});
|
||||
|
||||
it("injects timezone/locale even when stealthArgs=false", () => {
|
||||
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
|
||||
expect(args).toContain("--fingerprint-timezone=America/New_York");
|
||||
expect(args).toContain("--lang=en-US");
|
||||
expect(args).toContain("--fingerprint-locale=en-US");
|
||||
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -119,6 +122,7 @@ describe("buildArgs timezone/locale", () => {
|
||||
const args = _buildArgsForTest({});
|
||||
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
|
||||
expect(args.some(a => a.startsWith("--lang="))).toBe(false);
|
||||
expect(args.some(a => a.startsWith("--fingerprint-locale="))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,14 +151,17 @@ describe("buildArgs deduplication", () => {
|
||||
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
|
||||
});
|
||||
|
||||
it("locale param overrides user --lang arg", () => {
|
||||
it("locale param overrides user --lang and --fingerprint-locale args", () => {
|
||||
const args = _buildArgsForTest({
|
||||
args: ["--lang=de-DE"],
|
||||
args: ["--lang=de-DE", "--fingerprint-locale=de-DE"],
|
||||
locale: "en-US",
|
||||
});
|
||||
const langArgs = args.filter(a => a.startsWith("--lang="));
|
||||
expect(langArgs).toHaveLength(1);
|
||||
expect(langArgs[0]).toBe("--lang=en-US");
|
||||
const localeArgs = args.filter(a => a.startsWith("--fingerprint-locale="));
|
||||
expect(localeArgs).toHaveLength(1);
|
||||
expect(localeArgs[0]).toBe("--fingerprint-locale=en-US");
|
||||
});
|
||||
|
||||
it("no duplicate flag keys in output", () => {
|
||||
@@ -175,29 +182,29 @@ describe("buildArgs deduplication", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateTimezoneId deprecation", () => {
|
||||
it("migrates timezoneId to timezone", () => {
|
||||
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" });
|
||||
describe("resolveTimezone alias", () => {
|
||||
it("resolves timezoneId to timezone", () => {
|
||||
const result = resolveTimezone({ timezoneId: "Europe/Paris" });
|
||||
expect(result.timezone).toBe("Europe/Paris");
|
||||
expect(result).not.toHaveProperty("timezoneId");
|
||||
});
|
||||
|
||||
it("preserves explicit timezone over timezoneId", () => {
|
||||
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" });
|
||||
const result = resolveTimezone({ timezone: "UTC", timezoneId: "Europe/Paris" });
|
||||
expect(result.timezone).toBe("UTC");
|
||||
expect(result).not.toHaveProperty("timezoneId");
|
||||
});
|
||||
|
||||
it("returns options unchanged when no timezoneId", () => {
|
||||
const opts = { timezone: "UTC" };
|
||||
const result = migrateTimezoneId(opts);
|
||||
const result = resolveTimezone(opts);
|
||||
expect(result).toBe(opts); // same reference, no copy
|
||||
expect(result.timezone).toBe("UTC");
|
||||
});
|
||||
|
||||
it("returns options unchanged when neither is set", () => {
|
||||
const opts = {};
|
||||
const result = migrateTimezoneId(opts);
|
||||
const result = resolveTimezone(opts);
|
||||
expect(result).toBe(opts);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,17 @@ describe("resolveProxyIp", () => {
|
||||
it("returns null for empty string", async () => {
|
||||
expect(await resolveProxyIp("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for schemeless proxy (shows why normalization is needed)", async () => {
|
||||
// no scheme — new URL() gives empty hostname for both bare formats
|
||||
expect(await resolveProxyIp("user:pass@10.50.96.5:8888")).toBeNull();
|
||||
expect(await resolveProxyIp("10.50.96.5:8888")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts IP after normalization (http:// prepended by maybeResolveGeoip)", async () => {
|
||||
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe("10.50.96.5");
|
||||
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("COUNTRY_LOCALE_MAP", () => {
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js";
|
||||
import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js";
|
||||
|
||||
// =========================================================================
|
||||
// Config resolution
|
||||
// =========================================================================
|
||||
describe("resolveConfig", () => {
|
||||
it("returns valid default config", () => {
|
||||
const cfg = resolveConfig("default");
|
||||
expect(cfg).toBeDefined();
|
||||
expect(cfg.mouse_min_steps).toBeGreaterThan(0);
|
||||
expect(cfg.mouse_max_steps).toBeGreaterThan(cfg.mouse_min_steps);
|
||||
expect(cfg.typing_delay).toBeGreaterThan(0);
|
||||
expect(cfg.initial_cursor_x).toHaveLength(2);
|
||||
expect(cfg.initial_cursor_y).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns valid careful config with slower typing", () => {
|
||||
const cfg = resolveConfig("careful");
|
||||
const def = resolveConfig("default");
|
||||
expect(cfg).toBeDefined();
|
||||
expect(cfg.typing_delay).toBeGreaterThanOrEqual(def.typing_delay);
|
||||
});
|
||||
|
||||
it("applies custom overrides", () => {
|
||||
const cfg = resolveConfig("default", { mouse_min_steps: 100, mouse_max_steps: 200 });
|
||||
expect(cfg.mouse_min_steps).toBe(100);
|
||||
expect(cfg.mouse_max_steps).toBe(200);
|
||||
});
|
||||
|
||||
it("preserves idle_between_actions override", () => {
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [50, 100],
|
||||
});
|
||||
expect(cfg.idle_between_actions).toBe(true);
|
||||
expect(cfg.idle_between_duration[0]).toBe(50);
|
||||
expect(cfg.idle_between_duration[1]).toBe(100);
|
||||
});
|
||||
|
||||
it("throws on unknown preset name", () => {
|
||||
expect(() => resolveConfig("nonexistent" as any)).toThrow(/Unknown humanize preset/);
|
||||
});
|
||||
|
||||
it("returns all required fields including mistype", () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const required = [
|
||||
"mouse_min_steps", "mouse_max_steps", "typing_delay",
|
||||
"initial_cursor_x", "initial_cursor_y", "idle_between_actions",
|
||||
"idle_between_duration", "field_switch_delay",
|
||||
"mistype_chance", "mistype_delay_notice", "mistype_delay_correct",
|
||||
];
|
||||
for (const f of required) {
|
||||
expect(cfg).toHaveProperty(f);
|
||||
}
|
||||
});
|
||||
|
||||
it("mistype_delay fields are [min, max] tuples", () => {
|
||||
const cfg = resolveConfig("default");
|
||||
expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true);
|
||||
expect(cfg.mistype_delay_notice).toHaveLength(2);
|
||||
expect(cfg.mistype_delay_notice[0]).toBeLessThanOrEqual(cfg.mistype_delay_notice[1]);
|
||||
expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true);
|
||||
expect(cfg.mistype_delay_correct).toHaveLength(2);
|
||||
expect(cfg.mistype_delay_correct[0]).toBeLessThanOrEqual(cfg.mistype_delay_correct[1]);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// rand / randRange / sleep
|
||||
// =========================================================================
|
||||
describe("rand helpers", () => {
|
||||
it("rand stays within bounds over many iterations", () => {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const v = rand(10, 20);
|
||||
expect(v).toBeGreaterThanOrEqual(10);
|
||||
expect(v).toBeLessThanOrEqual(20);
|
||||
}
|
||||
});
|
||||
|
||||
it("randRange stays within bounds", () => {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const v = randRange([5, 15]);
|
||||
expect(v).toBeGreaterThanOrEqual(5);
|
||||
expect(v).toBeLessThanOrEqual(15);
|
||||
}
|
||||
});
|
||||
|
||||
it("sleep pauses for approximately correct duration", async () => {
|
||||
const t0 = Date.now();
|
||||
await sleep(50);
|
||||
const elapsed = Date.now() - t0;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(40);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Bézier mouse movement (behavioral with vi.fn mocks)
|
||||
// =========================================================================
|
||||
describe("humanMove", () => {
|
||||
function makeFakeRaw() {
|
||||
const moves: Array<{ x: number; y: number }> = [];
|
||||
return {
|
||||
raw: {
|
||||
move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
},
|
||||
moves,
|
||||
};
|
||||
}
|
||||
|
||||
it("generates multiple intermediate points", async () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const { raw, moves } = makeFakeRaw();
|
||||
await humanMove(raw, 0, 0, 500, 300, cfg);
|
||||
expect(moves.length).toBeGreaterThanOrEqual(10);
|
||||
const last = moves[moves.length - 1];
|
||||
expect(Math.abs(last.x - 500)).toBeLessThan(10);
|
||||
expect(Math.abs(last.y - 300)).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it("raw.move called exactly once per step", async () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const { raw, moves } = makeFakeRaw();
|
||||
await humanMove(raw, 0, 0, 400, 400, cfg);
|
||||
expect(raw.move).toHaveBeenCalledTimes(moves.length);
|
||||
});
|
||||
|
||||
it("no single jump exceeds 50% of total distance", async () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const { raw, moves } = makeFakeRaw();
|
||||
await humanMove(raw, 0, 0, 400, 400, cfg);
|
||||
const totalDist = Math.sqrt(400 ** 2 + 400 ** 2);
|
||||
const maxJump = totalDist * 0.5;
|
||||
for (let i = 1; i < moves.length; i++) {
|
||||
const dx = moves[i].x - moves[i - 1].x;
|
||||
const dy = moves[i].y - moves[i - 1].y;
|
||||
expect(Math.sqrt(dx * dx + dy * dy)).toBeLessThan(maxJump);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces curved path (not a straight line)", async () => {
|
||||
const cfg = resolveConfig("default");
|
||||
let maxDev = 0;
|
||||
for (let trial = 0; trial < 10; trial++) {
|
||||
const { raw, moves } = makeFakeRaw();
|
||||
await humanMove(raw, 0, 0, 500, 0, cfg);
|
||||
const dev = Math.max(...moves.map(m => Math.abs(m.y)));
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
expect(maxDev).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it("handles very short distances", async () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const { raw, moves } = makeFakeRaw();
|
||||
await humanMove(raw, 100, 100, 103, 102, cfg);
|
||||
expect(moves.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("handles zero distance without crashing", async () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const { raw } = makeFakeRaw();
|
||||
await humanMove(raw, 200, 200, 200, 200, cfg);
|
||||
// Completes without error; may or may not call move (both valid)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// humanClick behavioral
|
||||
// =========================================================================
|
||||
describe("humanClick", () => {
|
||||
it("calls down then up in correct order", async () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const callOrder: string[] = [];
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => { callOrder.push("down"); }),
|
||||
up: vi.fn(async () => { callOrder.push("up"); }),
|
||||
wheel: vi.fn(async () => {}),
|
||||
};
|
||||
await humanClick(raw, false, cfg);
|
||||
expect(raw.down).toHaveBeenCalledTimes(1);
|
||||
expect(raw.up).toHaveBeenCalledTimes(1);
|
||||
expect(callOrder).toEqual(["down", "up"]);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// humanIdle behavioral
|
||||
// =========================================================================
|
||||
describe("humanIdle", () => {
|
||||
it("calls raw.move at least once during idle", async () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
};
|
||||
await humanIdle(raw, 10, 100, 100, cfg);
|
||||
expect(raw.move).toHaveBeenCalled();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// clickTarget
|
||||
// =========================================================================
|
||||
describe("clickTarget", () => {
|
||||
it("returns point within bounding box", () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const box = { x: 100, y: 200, width: 150, height: 40 };
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const t = clickTarget(box, false, cfg);
|
||||
expect(t.x).toBeGreaterThanOrEqual(100);
|
||||
expect(t.x).toBeLessThanOrEqual(250);
|
||||
expect(t.y).toBeGreaterThanOrEqual(200);
|
||||
expect(t.y).toBeLessThanOrEqual(240);
|
||||
}
|
||||
});
|
||||
|
||||
it("isInput=true biases click toward left side of box", () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const box = { x: 50, y: 50, width: 200, height: 30 };
|
||||
let sumX = 0;
|
||||
const N = 300;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t = clickTarget(box, true, cfg);
|
||||
expect(t.x).toBeGreaterThanOrEqual(50);
|
||||
expect(t.x).toBeLessThanOrEqual(250);
|
||||
sumX += t.x;
|
||||
}
|
||||
const avgX = sumX / N;
|
||||
expect(avgX).toBeLessThan(175);
|
||||
});
|
||||
|
||||
it("does not crash with 1x1 box", () => {
|
||||
const cfg = resolveConfig("default");
|
||||
const t = clickTarget({ x: 0, y: 0, width: 1, height: 1 }, false, cfg);
|
||||
expect(t.x).toBeGreaterThanOrEqual(0);
|
||||
expect(t.x).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// patchPage behavioral: fill uses platform SELECT_ALL
|
||||
// =========================================================================
|
||||
describe("patchPage fill", () => {
|
||||
it("fill calls keyboard.press with platform-correct select-all", async () => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
const pressedKeys: string[] = [];
|
||||
const page = buildMockPage({
|
||||
keyboardPress: async (key: string) => { pressedKeys.push(key); },
|
||||
evaluate: async () => false,
|
||||
});
|
||||
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).fill("input#name", "hello"); } catch (_) {}
|
||||
|
||||
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
||||
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
|
||||
if (pressedKeys.length > 0) {
|
||||
expect(pressedKeys).toContain(expected);
|
||||
expect(pressedKeys).not.toContain(wrong);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// patchPage behavioral: check/uncheck with idle_between_actions
|
||||
// =========================================================================
|
||||
describe("patchPage check/uncheck idle", () => {
|
||||
it("check with idle=true calls humanClickFn and does not crash on idle", async () => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
let downCalled = false;
|
||||
const page = buildMockPage({
|
||||
isChecked: async () => false,
|
||||
evaluate: async () => false,
|
||||
});
|
||||
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [1, 2],
|
||||
});
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).check("input#cb"); } catch (_) {}
|
||||
|
||||
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
|
||||
expect(downCalled).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("uncheck with idle=true calls humanClickFn and does not crash on idle", async () => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
let downCalled = false;
|
||||
const page = buildMockPage({
|
||||
isChecked: async () => true,
|
||||
evaluate: async () => false,
|
||||
});
|
||||
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [1, 2],
|
||||
});
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).uncheck("input#cb"); } catch (_) {}
|
||||
|
||||
expect(downCalled).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("config with idle=true is accepted by resolveConfig", () => {
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [5, 10],
|
||||
});
|
||||
expect(cfg.idle_between_actions).toBe(true);
|
||||
expect(cfg.idle_between_duration).toEqual([5, 10]);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// patchPage behavioral: press focus check
|
||||
// =========================================================================
|
||||
describe("patchPage press focus", () => {
|
||||
it("press clicks element when NOT focused (mouse.down called)", async () => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
let downCount = 0;
|
||||
const page = buildMockPage({
|
||||
evaluate: async () => false,
|
||||
});
|
||||
// Intercept mouse.down before patching so raw captures it
|
||||
page.mouse.down = vi.fn(async () => { downCount++; });
|
||||
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
|
||||
|
||||
expect(downCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("press skips click when element IS focused (no mouse.down)", async () => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
let downCount = 0;
|
||||
const page = buildMockPage({
|
||||
evaluate: async () => true,
|
||||
});
|
||||
page.mouse.down = vi.fn(async () => { downCount++; });
|
||||
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
|
||||
|
||||
expect(downCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// patchPage behavioral: frame patching
|
||||
// =========================================================================
|
||||
describe("patchPage frame patching", () => {
|
||||
it("patches child frames with _humanPatched flag", async () => {
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
const childFrame = buildMockFrame();
|
||||
const mainFrame = {
|
||||
...buildMockFrame(),
|
||||
childFrames: vi.fn(() => [childFrame]),
|
||||
};
|
||||
|
||||
const page = buildMockPage({ mainFrameReturn: mainFrame });
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
expect((childFrame as any)._humanPatched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Mistype config
|
||||
// =========================================================================
|
||||
describe("mistype config", () => {
|
||||
it("default config has valid mistype fields", () => {
|
||||
const cfg = resolveConfig("default");
|
||||
expect(typeof cfg.mistype_chance).toBe("number");
|
||||
expect(cfg.mistype_chance).toBeGreaterThanOrEqual(0);
|
||||
expect(cfg.mistype_chance).toBeLessThanOrEqual(1);
|
||||
// mistype_delay_notice and mistype_delay_correct are [min, max] tuples
|
||||
expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true);
|
||||
expect(cfg.mistype_delay_notice).toHaveLength(2);
|
||||
expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true);
|
||||
expect(cfg.mistype_delay_correct).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("mistype_chance can be overridden to 0 (disabled)", () => {
|
||||
const cfg = resolveConfig("default", { mistype_chance: 0 });
|
||||
expect(cfg.mistype_chance).toBe(0);
|
||||
});
|
||||
|
||||
it("mistype_chance can be overridden to higher value", () => {
|
||||
const cfg = resolveConfig("default", { mistype_chance: 0.15 });
|
||||
expect(cfg.mistype_chance).toBe(0.15);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Module exports
|
||||
// =========================================================================
|
||||
describe("module exports", () => {
|
||||
it("patchBrowser, patchContext, patchPage are all exported functions", async () => {
|
||||
const mod = await import("../src/human/index.js");
|
||||
expect(typeof mod.patchBrowser).toBe("function");
|
||||
expect(typeof mod.patchContext).toBe("function");
|
||||
expect(typeof mod.patchPage).toBe("function");
|
||||
});
|
||||
|
||||
it("humanMove, humanClick, clickTarget, humanIdle are exported", async () => {
|
||||
const mod = await import("../src/human/index.js");
|
||||
expect(typeof mod.humanMove).toBe("function");
|
||||
expect(typeof mod.humanClick).toBe("function");
|
||||
expect(typeof mod.clickTarget).toBe("function");
|
||||
expect(typeof mod.humanIdle).toBe("function");
|
||||
});
|
||||
|
||||
it("resolveConfig is re-exported from index", async () => {
|
||||
const mod = await import("../src/human/index.js");
|
||||
expect(typeof mod.resolveConfig).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Test helpers
|
||||
// =========================================================================
|
||||
|
||||
function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||
const mainFrameObj = overrides.mainFrameReturn ?? {
|
||||
childFrames: vi.fn(() => []),
|
||||
click: vi.fn(async () => {}),
|
||||
dblclick: vi.fn(async () => {}),
|
||||
hover: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
fill: vi.fn(async () => {}),
|
||||
check: vi.fn(async () => {}),
|
||||
uncheck: vi.fn(async () => {}),
|
||||
selectOption: vi.fn(async () => {}),
|
||||
press: vi.fn(async () => {}),
|
||||
clear: vi.fn(async () => {}),
|
||||
dragAndDrop: vi.fn(async () => {}),
|
||||
locator: vi.fn(() => ({
|
||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||
first: vi.fn(function(this: any) { return this; }),
|
||||
})),
|
||||
};
|
||||
|
||||
const makeLocator = () => {
|
||||
const loc: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
};
|
||||
loc.first = vi.fn(() => loc);
|
||||
return loc;
|
||||
};
|
||||
|
||||
const page: any = {
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||
addInitScript: vi.fn(async () => {}),
|
||||
mouse: {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
click: vi.fn(async () => {}),
|
||||
dblclick: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
},
|
||||
keyboard: {
|
||||
press: overrides.keyboardPress
|
||||
? vi.fn(overrides.keyboardPress)
|
||||
: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async () => {}),
|
||||
},
|
||||
click: vi.fn(async () => {}),
|
||||
dblclick: vi.fn(async () => {}),
|
||||
hover: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
fill: vi.fn(async () => {}),
|
||||
check: vi.fn(async () => {}),
|
||||
uncheck: vi.fn(async () => {}),
|
||||
selectOption: vi.fn(async () => {}),
|
||||
press: vi.fn(async () => {}),
|
||||
goto: vi.fn(async () => ({})),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
locator: vi.fn(() => makeLocator()),
|
||||
viewportSize: vi.fn(() => ({ width: 1280, height: 720 })),
|
||||
mainFrame: vi.fn(() => mainFrameObj),
|
||||
frames: vi.fn(() => []),
|
||||
context: vi.fn(() => ({
|
||||
pages: vi.fn(() => []),
|
||||
addInitScript: vi.fn(async () => {}),
|
||||
})),
|
||||
url: vi.fn(() => "about:blank"),
|
||||
waitForTimeout: vi.fn(async () => {}),
|
||||
};
|
||||
return page;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// humanType non-ASCII
|
||||
// =========================================================================
|
||||
describe("humanType non-ASCII", () => {
|
||||
function makeRawKeyboardMock() {
|
||||
const downKeys: string[] = [];
|
||||
const insertedChars: string[] = [];
|
||||
const raw = {
|
||||
down: vi.fn(async (k: string) => { downKeys.push(k); }),
|
||||
up: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
|
||||
};
|
||||
return { raw, downKeys, insertedChars };
|
||||
}
|
||||
|
||||
it("types Cyrillic via insertText, not down", async () => {
|
||||
const { humanType } = await import("../src/human/keyboard.js");
|
||||
const cfg = resolveConfig("default", { mistype_chance: 0 });
|
||||
const { raw, downKeys, insertedChars } = makeRawKeyboardMock();
|
||||
|
||||
await humanType({} as any, raw, "Привет", cfg);
|
||||
|
||||
expect(insertedChars.join("")).toBe("Привет");
|
||||
for (const k of downKeys) {
|
||||
expect(k.charCodeAt(0)).toBeLessThan(128);
|
||||
}
|
||||
});
|
||||
|
||||
it("types mixed ASCII + Cyrillic correctly", async () => {
|
||||
const { humanType } = await import("../src/human/keyboard.js");
|
||||
const cfg = resolveConfig("default", { mistype_chance: 0 });
|
||||
const { raw, downKeys, insertedChars } = makeRawKeyboardMock();
|
||||
|
||||
await humanType({} as any, raw, "Hi Мир", cfg);
|
||||
|
||||
expect(downKeys).toContain("H");
|
||||
expect(downKeys).toContain("i");
|
||||
expect(insertedChars.join("")).toContain("М");
|
||||
expect(insertedChars.join("")).toContain("и");
|
||||
expect(insertedChars.join("")).toContain("р");
|
||||
});
|
||||
|
||||
it("types CJK via insertText", async () => {
|
||||
const { humanType } = await import("../src/human/keyboard.js");
|
||||
const cfg = resolveConfig("default", { mistype_chance: 0 });
|
||||
const { raw, insertedChars } = makeRawKeyboardMock();
|
||||
|
||||
await humanType({} as any, raw, "你好", cfg);
|
||||
|
||||
expect(insertedChars.join("")).toBe("你好");
|
||||
});
|
||||
|
||||
it("types emoji via insertText", async () => {
|
||||
const { humanType } = await import("../src/human/keyboard.js");
|
||||
const cfg = resolveConfig("default", { mistype_chance: 0 });
|
||||
const { raw, insertedChars } = makeRawKeyboardMock();
|
||||
|
||||
await humanType({} as any, raw, "Hi 👋", cfg);
|
||||
|
||||
expect(insertedChars.join("")).toContain("👋");
|
||||
});
|
||||
|
||||
it("mistype only triggers for ASCII, not Cyrillic", async () => {
|
||||
const { humanType } = await import("../src/human/keyboard.js");
|
||||
const cfg = resolveConfig("default", { mistype_chance: 1.0 });
|
||||
const { raw, downKeys } = makeRawKeyboardMock();
|
||||
|
||||
await humanType({} as any, raw, "AБ", cfg);
|
||||
|
||||
expect(downKeys).toContain("Backspace");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
function buildMockFrame(): any {
|
||||
return {
|
||||
click: vi.fn(async () => {}),
|
||||
dblclick: vi.fn(async () => {}),
|
||||
hover: vi.fn(async () => {}),
|
||||
type: vi.fn(async () => {}),
|
||||
fill: vi.fn(async () => {}),
|
||||
check: vi.fn(async () => {}),
|
||||
uncheck: vi.fn(async () => {}),
|
||||
selectOption: vi.fn(async () => {}),
|
||||
press: vi.fn(async () => {}),
|
||||
clear: vi.fn(async () => {}),
|
||||
dragAndDrop: vi.fn(async () => {}),
|
||||
locator: vi.fn(() => ({
|
||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||
})),
|
||||
childFrames: vi.fn(() => []),
|
||||
};
|
||||
}
|
||||
+11
-10
@@ -96,20 +96,20 @@ describe("launchContext (unit)", () => {
|
||||
expect(ctxArgs.userAgent).toBe("Custom/1.0");
|
||||
});
|
||||
|
||||
it("passes timezone to context timezoneId, not to launch", async () => {
|
||||
it("passes timezone via binary flag, not CDP context", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ timezone: "America/New_York" });
|
||||
|
||||
// launch() called with timezone: undefined (skipped for binary flag)
|
||||
// launch() called with --fingerprint-timezone binary flag
|
||||
const launchArgs = mockChromium.launch.mock.calls[0][0];
|
||||
const hasTimezoneFlag = launchArgs.args.some((a: string) =>
|
||||
a.startsWith("--fingerprint-timezone=")
|
||||
a.startsWith("--fingerprint-timezone=America/New_York")
|
||||
);
|
||||
expect(hasTimezoneFlag).toBe(false);
|
||||
expect(hasTimezoneFlag).toBe(true);
|
||||
|
||||
// newContext() gets timezoneId
|
||||
// NOT in newContext() — no CDP emulation
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.timezoneId).toBe("America/New_York");
|
||||
expect(ctxArgs.timezoneId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards colorScheme to newContext", async () => {
|
||||
@@ -165,7 +165,7 @@ describe("launchPersistentContext (unit)", () => {
|
||||
expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("passes timezone and locale to context", async () => {
|
||||
it("passes timezone and locale via binary args, not CDP context", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
@@ -174,11 +174,12 @@ describe("launchPersistentContext (unit)", () => {
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.timezoneId).toBe("Asia/Tokyo");
|
||||
expect(args.locale).toBe("ja-JP");
|
||||
// Also in binary args
|
||||
// Binary args (native, undetectable)
|
||||
expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo");
|
||||
expect(args.args).toContain("--lang=ja-JP");
|
||||
// NOT in context kwargs (would trigger detectable CDP emulation)
|
||||
expect(args.timezoneId).toBeUndefined();
|
||||
expect(args.locale).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards proxy string", async () => {
|
||||
|
||||
@@ -82,3 +82,36 @@ describe("proxy dict type", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bare proxy format (user:pass@host:port)", () => {
|
||||
it("extracts credentials from bare format", () => {
|
||||
expect(parseProxyUrl("user:pass@proxy:8080")).toEqual({
|
||||
server: "http://proxy:8080",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("credentials not in server", () => {
|
||||
const r = parseProxyUrl("user:pass@proxy1.example.com:5610");
|
||||
expect(r.server).not.toContain("user");
|
||||
expect(r.server).not.toContain("pass");
|
||||
});
|
||||
|
||||
it("bare username only", () => {
|
||||
const r = parseProxyUrl("user@proxy:8080");
|
||||
expect(r.username).toBe("user");
|
||||
expect(r.password).toBeUndefined();
|
||||
expect(r.server).toBe("http://proxy:8080");
|
||||
});
|
||||
|
||||
it("bare no port", () => {
|
||||
const r = parseProxyUrl("user:pass@proxy.example.com");
|
||||
expect(r.username).toBe("user");
|
||||
expect(r.server).toBe("http://proxy.example.com");
|
||||
});
|
||||
|
||||
it("bare no credentials passes through unchanged", () => {
|
||||
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("../src/download.js", () => ({
|
||||
|
||||
vi.mock("../src/geoip.js", () => ({
|
||||
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
|
||||
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
describe("puppeteer launch", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
checkWrapperUpdate,
|
||||
clearCache,
|
||||
ensureBinary,
|
||||
fetchChecksums,
|
||||
getLatestChromiumVersion,
|
||||
parseChecksums,
|
||||
resetWrapperUpdateChecked,
|
||||
@@ -269,6 +270,54 @@ describe("parseChecksums", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("download fallback", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
|
||||
});
|
||||
|
||||
it("checksum fetch falls back to GitHub on primary 429", async () => {
|
||||
const HASH =
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
const checksumText = `${HASH} cloakbrowser-${getPlatformTag()}.tar.gz`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: (input as Request).url;
|
||||
if (url.includes("cloakbrowser.dev")) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
} as Response;
|
||||
}
|
||||
// GitHub fallback
|
||||
return { ok: true, text: async () => checksumText } as Response;
|
||||
});
|
||||
|
||||
const result = await fetchChecksums();
|
||||
expect(result).not.toBeNull();
|
||||
expect(
|
||||
result!.has(`cloakbrowser-${getPlatformTag()}.tar.gz`)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("checksum fetch returns null when both sources fail", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
} as Response);
|
||||
|
||||
const result = await fetchChecksums();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effective version", () => {
|
||||
it("returns platform version when no marker exists", () => {
|
||||
// Default behavior — no marker file in test environment
|
||||
|
||||
@@ -56,6 +56,11 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
geoip = ["geoip2>=4.0"]
|
||||
patchright = ["patchright>=1.40"]
|
||||
serve = ["aiohttp>=3.9", "websockets>=12.0"]
|
||||
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
|
||||
|
||||
[project.scripts]
|
||||
cloakbrowser = "cloakbrowser.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/CloakHQ/CloakBrowser"
|
||||
|
||||
+38
-44
@@ -1,97 +1,88 @@
|
||||
"""Unit tests for _build_args timezone/locale injection and deprecation compat."""
|
||||
"""Unit tests for build_args timezone/locale injection and timezone alias."""
|
||||
|
||||
import warnings
|
||||
|
||||
from cloakbrowser.browser import _build_args, _migrate_timezone_id
|
||||
from cloakbrowser.browser import build_args, _resolve_timezone
|
||||
|
||||
|
||||
def test_timezone_injected():
|
||||
"""--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
|
||||
|
||||
|
||||
def test_locale_injected():
|
||||
"""--lang flag should appear when locale is set."""
|
||||
args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
|
||||
"""--lang and --fingerprint-locale flags should appear when locale is set."""
|
||||
args = build_args(stealth_args=True, extra_args=None, locale="en-US")
|
||||
assert "--lang=en-US" in args
|
||||
assert "--fingerprint-locale=en-US" in args
|
||||
|
||||
|
||||
def test_both_injected():
|
||||
"""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 "--lang=de-DE" in args
|
||||
assert "--fingerprint-locale=de-DE" in args
|
||||
|
||||
|
||||
def test_timezone_independent_of_stealth_args():
|
||||
"""--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 "--lang=en-US" in args
|
||||
assert "--fingerprint-locale=en-US" in args
|
||||
# No stealth fingerprint args
|
||||
assert not any(a.startswith("--fingerprint=") for a in args)
|
||||
|
||||
|
||||
def test_no_flags_when_not_set():
|
||||
"""No timezone/lang flags when params are None."""
|
||||
args = _build_args(stealth_args=True, extra_args=None)
|
||||
"""No timezone/lang/fingerprint-locale flags when params are 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("--lang=") for a in args)
|
||||
assert not any(a.startswith("--fingerprint-locale=") for a in args)
|
||||
|
||||
|
||||
def test_extra_args_preserved():
|
||||
"""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 "--fingerprint-timezone=Asia/Tokyo" in args
|
||||
assert "--lang=ja-JP" in args
|
||||
assert "--fingerprint-locale=ja-JP" in args
|
||||
|
||||
|
||||
# --- _migrate_timezone_id deprecation compat ---
|
||||
# --- _resolve_timezone alias ---
|
||||
|
||||
|
||||
def test_migrate_old_param_only():
|
||||
def test_resolve_timezone_id_alias():
|
||||
"""timezone_id in kwargs should be promoted to timezone."""
|
||||
kwargs = {"timezone_id": "Europe/Paris"}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id(None, kwargs)
|
||||
result = _resolve_timezone(None, kwargs)
|
||||
assert result == "Europe/Paris"
|
||||
assert "timezone_id" not in kwargs
|
||||
assert len(w) == 1 and issubclass(w[0].category, FutureWarning)
|
||||
|
||||
|
||||
def test_migrate_new_param_wins():
|
||||
def test_resolve_timezone_wins_over_alias():
|
||||
"""Explicit timezone takes precedence; timezone_id is still popped."""
|
||||
kwargs = {"timezone_id": "Europe/Paris"}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id("UTC", kwargs)
|
||||
result = _resolve_timezone("UTC", kwargs)
|
||||
assert result == "UTC"
|
||||
assert "timezone_id" not in kwargs
|
||||
assert len(w) == 1
|
||||
|
||||
|
||||
def test_migrate_no_old_param():
|
||||
"""No warning when timezone_id is absent."""
|
||||
def test_resolve_no_alias():
|
||||
"""No-op when timezone_id is absent."""
|
||||
kwargs = {"other": "value"}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id("UTC", kwargs)
|
||||
result = _resolve_timezone("UTC", kwargs)
|
||||
assert result == "UTC"
|
||||
assert "other" in kwargs
|
||||
assert len(w) == 0
|
||||
|
||||
|
||||
def test_migrate_both_none():
|
||||
"""Neither param set — returns None, no warning."""
|
||||
def test_resolve_both_none():
|
||||
"""Neither param set — returns None."""
|
||||
kwargs = {}
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
result = _migrate_timezone_id(None, kwargs)
|
||||
result = _resolve_timezone(None, kwargs)
|
||||
assert result is None
|
||||
assert len(w) == 0
|
||||
|
||||
|
||||
# --- Deduplication tests ---
|
||||
@@ -99,7 +90,7 @@ def test_migrate_both_none():
|
||||
|
||||
def test_user_fingerprint_overrides_default():
|
||||
"""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=")]
|
||||
assert len(fingerprint_args) == 1
|
||||
assert fingerprint_args[0] == "--fingerprint=99887"
|
||||
@@ -107,7 +98,7 @@ def test_user_fingerprint_overrides_default():
|
||||
|
||||
def test_user_platform_overrides_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=")]
|
||||
assert len(platform_args) == 1
|
||||
assert platform_args[0] == "--fingerprint-platform=linux"
|
||||
@@ -115,7 +106,7 @@ def test_user_platform_overrides_default():
|
||||
|
||||
def test_timezone_param_overrides_user_arg():
|
||||
"""Dedicated timezone param should override user arg."""
|
||||
args = _build_args(
|
||||
args = build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--fingerprint-timezone=Europe/London"],
|
||||
timezone="America/New_York",
|
||||
@@ -126,20 +117,23 @@ def test_timezone_param_overrides_user_arg():
|
||||
|
||||
|
||||
def test_locale_param_overrides_user_arg():
|
||||
"""Dedicated locale param should override user --lang arg."""
|
||||
args = _build_args(
|
||||
"""Dedicated locale param should override user --lang and --fingerprint-locale args."""
|
||||
args = build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--lang=de-DE"],
|
||||
extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
|
||||
locale="en-US",
|
||||
)
|
||||
lang_args = [a for a in args if a.startswith("--lang=")]
|
||||
assert len(lang_args) == 1
|
||||
assert lang_args[0] == "--lang=en-US"
|
||||
locale_args = [a for a in args if a.startswith("--fingerprint-locale=")]
|
||||
assert len(locale_args) == 1
|
||||
assert locale_args[0] == "--fingerprint-locale=en-US"
|
||||
|
||||
|
||||
def test_no_duplicate_flags():
|
||||
"""No flag key should appear more than once in the output."""
|
||||
args = _build_args(
|
||||
args = build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
|
||||
timezone="Europe/Berlin",
|
||||
@@ -151,7 +145,7 @@ def test_no_duplicate_flags():
|
||||
|
||||
def test_non_value_flags_preserved():
|
||||
"""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 "--no-zygote" in args
|
||||
assert "--no-sandbox" in args
|
||||
@@ -162,5 +156,5 @@ def test_override_logs_debug(caplog):
|
||||
import logging
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""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"]
|
||||
_, passthrough = parse_cli_args(args)
|
||||
assert passthrough == args
|
||||
|
||||
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
|
||||
+8
-8
@@ -4,7 +4,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.browser import _maybe_resolve_geoip
|
||||
from cloakbrowser.browser import maybe_resolve_geoip
|
||||
from cloakbrowser.geoip import (
|
||||
COUNTRY_LOCALE_MAP,
|
||||
_is_private_ip,
|
||||
@@ -92,25 +92,25 @@ 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():
|
||||
tz, loc = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||
tz, loc = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||
assert tz is None
|
||||
assert loc is None
|
||||
|
||||
|
||||
def test_maybe_resolve_skips_when_no_proxy():
|
||||
tz, loc = _maybe_resolve_geoip(True, None, None, None)
|
||||
tz, loc = maybe_resolve_geoip(True, None, None, None)
|
||||
assert tz is None
|
||||
assert loc is None
|
||||
|
||||
|
||||
def test_maybe_resolve_skips_when_both_explicit():
|
||||
"""Explicit values should not trigger geoip resolution."""
|
||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
|
||||
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
|
||||
assert tz == "Europe/Berlin"
|
||||
assert loc == "de-DE"
|
||||
|
||||
@@ -118,7 +118,7 @@ def test_maybe_resolve_skips_when_both_explicit():
|
||||
def test_maybe_resolve_fills_missing_timezone():
|
||||
"""When only locale is explicit, geoip should fill timezone."""
|
||||
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
|
||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
|
||||
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
|
||||
assert tz == "America/New_York"
|
||||
assert loc == "fr-FR" # Explicit wins
|
||||
|
||||
@@ -126,7 +126,7 @@ def test_maybe_resolve_fills_missing_timezone():
|
||||
def test_maybe_resolve_fills_missing_locale():
|
||||
"""When only timezone is explicit, geoip should fill locale."""
|
||||
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
|
||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
|
||||
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
|
||||
assert tz == "Asia/Tokyo" # Explicit wins
|
||||
assert loc == "en-US"
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_maybe_resolve_fills_missing_locale():
|
||||
def test_maybe_resolve_fills_both():
|
||||
"""When neither is set, geoip should fill both."""
|
||||
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")):
|
||||
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
assert tz == "Europe/Berlin"
|
||||
assert loc == "de-DE"
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// test_human_visual.mjs
|
||||
/**
|
||||
* Visual + functional test for humanize (JS).
|
||||
* Red dot = cursor, yellow = mouse held.
|
||||
* Trail dots show the path taken.
|
||||
*/
|
||||
import { launch } from '../js/dist/index.js';
|
||||
|
||||
const CURSOR_JS = `
|
||||
(() => {
|
||||
if (document.getElementById('__hc')) return;
|
||||
const el = document.createElement('div');
|
||||
el.id = '__hc';
|
||||
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
|
||||
document.body.appendChild(el);
|
||||
|
||||
const trail = document.createElement('div');
|
||||
trail.id = '__hcTrail';
|
||||
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
|
||||
document.body.appendChild(trail);
|
||||
|
||||
let dotCount = 0;
|
||||
const maxDots = 500;
|
||||
|
||||
function updatePos(x, y) {
|
||||
el.style.display = 'block';
|
||||
el.style.left = (x - 9) + 'px';
|
||||
el.style.top = (y - 9) + 'px';
|
||||
if (dotCount < maxDots) {
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
|
||||
trail.appendChild(dot);
|
||||
dotCount++;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
|
||||
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
|
||||
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
|
||||
document.addEventListener('dragend', () => { el.style.background = 'red'; });
|
||||
})();
|
||||
`;
|
||||
|
||||
const results = [];
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
async function inject(page) {
|
||||
try { await page.evaluate(CURSOR_JS); } catch {}
|
||||
await delay(300);
|
||||
}
|
||||
|
||||
function step(name) {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(` STEP: ${name}`);
|
||||
console.log('='.repeat(60));
|
||||
}
|
||||
|
||||
function check(name, passed, detail = '') {
|
||||
const status = passed ? 'PASS' : 'FAIL';
|
||||
let msg = ` [${status}] ${name}`;
|
||||
if (detail) msg += ` — ${detail}`;
|
||||
console.log(msg);
|
||||
results.push({ name, status });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(70));
|
||||
console.log(' HUMAN-LIKE BEHAVIOR VISUAL TEST (JS)');
|
||||
console.log(' Watch the red dot — it should move smoothly like a real cursor');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const browser = await launch({
|
||||
headless: false,
|
||||
humanize: true,
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
// ============================================================
|
||||
// SCENARIO 1: Wikipedia search
|
||||
// ============================================================
|
||||
step('Wikipedia — navigate and search');
|
||||
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||
await delay(2000);
|
||||
await inject(page);
|
||||
await delay(1000);
|
||||
|
||||
console.log(' Watch: cursor moves to search box (Bezier curve)');
|
||||
let t0 = Date.now();
|
||||
await page.locator('#searchInput').click();
|
||||
let ms = Date.now() - t0;
|
||||
check('click on search input', ms > 200, `${ms} ms`);
|
||||
await delay(500);
|
||||
|
||||
console.log(' Watch: characters appear one by one');
|
||||
t0 = Date.now();
|
||||
await page.locator('#searchInput').fill('Python programming language');
|
||||
ms = Date.now() - t0;
|
||||
let val = await page.locator('#searchInput').inputValue();
|
||||
check('fill search box', val === 'Python programming language' && ms > 2000, `${ms} ms, value='${val}'`);
|
||||
await delay(500);
|
||||
|
||||
console.log(' Watch: double click selects word');
|
||||
t0 = Date.now();
|
||||
await page.locator('#searchInput').dblclick();
|
||||
ms = Date.now() - t0;
|
||||
let sel = await page.evaluate(() => window.getSelection().toString().trim());
|
||||
check('dblclick selects word', sel.length > 0 && ms > 200, `${ms} ms, selected='${sel}'`);
|
||||
await delay(500);
|
||||
|
||||
console.log(' Watch: old text replaced');
|
||||
t0 = Date.now();
|
||||
await page.locator('#searchInput').fill('Artificial intelligence');
|
||||
ms = Date.now() - t0;
|
||||
val = await page.locator('#searchInput').inputValue();
|
||||
check('fill replaces text', val === 'Artificial intelligence' && ms > 1500, `${ms} ms, value='${val}'`);
|
||||
await delay(500);
|
||||
|
||||
console.log(' Watch: cursor hovers button without clicking');
|
||||
t0 = Date.now();
|
||||
await page.locator('button[type="submit"]').hover();
|
||||
ms = Date.now() - t0;
|
||||
check('hover search button', ms > 100, `${ms} ms`);
|
||||
await delay(1000);
|
||||
|
||||
// ============================================================
|
||||
// SCENARIO 2: Checkboxes
|
||||
// ============================================================
|
||||
step('Checkboxes — check and uncheck');
|
||||
await page.goto('https://the-internet.herokuapp.com/checkboxes', { waitUntil: 'domcontentloaded' });
|
||||
await delay(2000);
|
||||
await inject(page);
|
||||
await delay(1000);
|
||||
|
||||
const cb1 = page.locator('input[type="checkbox"]').nth(0);
|
||||
const cb2 = page.locator('input[type="checkbox"]').nth(1);
|
||||
|
||||
if (await cb1.isChecked()) { await cb1.uncheck(); await delay(500); }
|
||||
|
||||
console.log(' Watch: cursor moves to checkbox, clicks');
|
||||
t0 = Date.now();
|
||||
await cb1.check();
|
||||
ms = Date.now() - t0;
|
||||
check('check checkbox 1', await cb1.isChecked() && ms > 200, `${ms} ms`);
|
||||
await delay(500);
|
||||
|
||||
if (!(await cb2.isChecked())) { await cb2.check(); await delay(500); }
|
||||
|
||||
t0 = Date.now();
|
||||
await cb2.uncheck();
|
||||
ms = Date.now() - t0;
|
||||
check('uncheck checkbox 2', !(await cb2.isChecked()) && ms > 200, `${ms} ms`);
|
||||
await delay(1000);
|
||||
|
||||
// ============================================================
|
||||
// SCENARIO 3: Dropdown
|
||||
// ============================================================
|
||||
step('Dropdown — select option');
|
||||
await page.goto('https://the-internet.herokuapp.com/dropdown', { waitUntil: 'domcontentloaded' });
|
||||
await delay(2000);
|
||||
await inject(page);
|
||||
await delay(1000);
|
||||
|
||||
console.log(' Watch: cursor hovers dropdown, option selected');
|
||||
t0 = Date.now();
|
||||
await page.locator('#dropdown').selectOption('2');
|
||||
ms = Date.now() - t0;
|
||||
val = await page.locator('#dropdown').inputValue();
|
||||
check('select option', val === '2' && ms > 100, `${ms} ms, value='${val}'`);
|
||||
await delay(1000);
|
||||
|
||||
// ============================================================
|
||||
// SCENARIO 4: Drag and Drop
|
||||
// ============================================================
|
||||
step('Drag and Drop');
|
||||
await page.goto('https://the-internet.herokuapp.com/drag_and_drop', { waitUntil: 'domcontentloaded' });
|
||||
await delay(2000);
|
||||
await inject(page);
|
||||
await delay(1000);
|
||||
|
||||
const beforeA = (await page.locator('#column-a header').textContent()).trim();
|
||||
console.log(` Before: A='${beforeA}'`);
|
||||
console.log(' Watch: cursor to A, yellow (held), moves to B, releases');
|
||||
|
||||
t0 = Date.now();
|
||||
await page.locator('#column-a').dragTo(page.locator('#column-b'));
|
||||
ms = Date.now() - t0;
|
||||
await delay(1000);
|
||||
|
||||
const afterA = (await page.locator('#column-a header').textContent()).trim();
|
||||
const swapped = beforeA !== afterA;
|
||||
check('drag A to B', swapped && ms > 300, `${ms} ms, swapped=${swapped}`);
|
||||
await delay(1000);
|
||||
|
||||
// ============================================================
|
||||
// SCENARIO 5: Text editing
|
||||
// ============================================================
|
||||
step('Text editing — type, press, clear');
|
||||
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||
await delay(2000);
|
||||
await inject(page);
|
||||
await delay(1000);
|
||||
|
||||
console.log(' Watch: types character by character');
|
||||
t0 = Date.now();
|
||||
await page.locator('#searchInput').type('Hello World');
|
||||
ms = Date.now() - t0;
|
||||
val = await page.locator('#searchInput').inputValue();
|
||||
check("type 'Hello World'", val === 'Hello World' && ms > 1000, `${ms} ms`);
|
||||
await delay(500);
|
||||
|
||||
console.log(' Watch: field cleared');
|
||||
t0 = Date.now();
|
||||
await page.locator('#searchInput').clear();
|
||||
ms = Date.now() - t0;
|
||||
val = await page.locator('#searchInput').inputValue();
|
||||
check('clear field', val === '' && ms > 100, `${ms} ms`);
|
||||
await delay(500);
|
||||
|
||||
console.log(' Watch: mouse moves in Bezier curve');
|
||||
t0 = Date.now();
|
||||
await page.mouse.move(600, 400);
|
||||
ms = Date.now() - t0;
|
||||
check('mouse.move', ms > 100, `${ms} ms`);
|
||||
await delay(500);
|
||||
|
||||
t0 = Date.now();
|
||||
await page.mouse.click(300, 300);
|
||||
ms = Date.now() - t0;
|
||||
check('mouse.click', ms > 100, `${ms} ms`);
|
||||
await delay(1000);
|
||||
|
||||
// ============================================================
|
||||
// SUMMARY
|
||||
// ============================================================
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log(' SUMMARY');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const passed = results.filter(r => r.status === 'PASS').length;
|
||||
const failed = results.filter(r => r.status === 'FAIL').length;
|
||||
|
||||
for (const r of results) {
|
||||
const icon = r.status === 'PASS' ? 'OK' : 'XX';
|
||||
console.log(` [${icon}] ${r.name}`);
|
||||
}
|
||||
|
||||
console.log(`\n ${passed}/${results.length} passed, ${failed} failed`);
|
||||
if (failed === 0) console.log(' *** ALL TESTS PASSED ***');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Visual + functional test for humanize.
|
||||
Red dot = cursor, yellow = mouse held.
|
||||
"""
|
||||
import pytest
|
||||
pytestmark = pytest.mark.slow
|
||||
|
||||
if __name__ == "__main__":
|
||||
from cloakbrowser import launch
|
||||
import time
|
||||
|
||||
CURSOR_JS = """
|
||||
() => {
|
||||
if (document.getElementById('__hc')) return;
|
||||
const el = document.createElement('div');
|
||||
el.id = '__hc';
|
||||
el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;';
|
||||
document.body.appendChild(el);
|
||||
|
||||
const trail = document.createElement('div');
|
||||
trail.id = '__hcTrail';
|
||||
trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;';
|
||||
document.body.appendChild(trail);
|
||||
|
||||
let dotCount = 0;
|
||||
const maxDots = 500;
|
||||
|
||||
function updatePos(x, y) {
|
||||
el.style.display = 'block';
|
||||
el.style.left = (x - 9) + 'px';
|
||||
el.style.top = (y - 9) + 'px';
|
||||
|
||||
if (dotCount < maxDots) {
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;';
|
||||
trail.appendChild(dot);
|
||||
dotCount++;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY));
|
||||
document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||
document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); });
|
||||
document.addEventListener('mousedown', () => { el.style.background = 'yellow'; });
|
||||
document.addEventListener('mouseup', () => { el.style.background = 'red'; });
|
||||
document.addEventListener('dragend', () => { el.style.background = 'red'; });
|
||||
}
|
||||
"""
|
||||
|
||||
def inject(page):
|
||||
try:
|
||||
page.evaluate(CURSOR_JS)
|
||||
except:
|
||||
pass
|
||||
time.sleep(0.3)
|
||||
|
||||
results = []
|
||||
|
||||
def step(name):
|
||||
print(f"\n{'='*60}")
|
||||
print(f" STEP: {name}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
def check(name, passed, detail=""):
|
||||
status = "PASS" if passed else "FAIL"
|
||||
msg = f" [{status}] {name}"
|
||||
if detail:
|
||||
msg += f" — {detail}"
|
||||
print(msg)
|
||||
results.append((name, status))
|
||||
|
||||
print("=" * 70)
|
||||
print(" HUMAN-LIKE BEHAVIOR VISUAL TEST")
|
||||
print(" Watch the red dot — it should move smoothly like a real cursor")
|
||||
print(" Yellow = mouse button held")
|
||||
print(" Red trail dots = path taken")
|
||||
print("=" * 70)
|
||||
|
||||
browser = launch(headless=False, humanize=True)
|
||||
page = browser.new_page()
|
||||
|
||||
# ============================================================
|
||||
# SCENARIO 1: Wikipedia search
|
||||
# ============================================================
|
||||
step("Wikipedia — navigate and search")
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
inject(page)
|
||||
time.sleep(1)
|
||||
|
||||
print(" Watch: cursor moves to search box (Bezier curve)")
|
||||
t0 = time.time()
|
||||
page.locator('#searchInput').click()
|
||||
click_ms = int((time.time() - t0) * 1000)
|
||||
check("click on search input", click_ms > 200, f"{click_ms} ms")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: characters appear one by one with varying speed")
|
||||
t0 = time.time()
|
||||
page.locator('#searchInput').fill('Python programming language')
|
||||
fill_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
check("fill search box", val == 'Python programming language' and fill_ms > 2000, f"{fill_ms} ms, value='{val}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: cursor moves to search box, double yellow flash, word selected")
|
||||
t0 = time.time()
|
||||
page.locator('#searchInput').dblclick()
|
||||
dbl_ms = int((time.time() - t0) * 1000)
|
||||
sel = page.evaluate('() => window.getSelection().toString().trim()')
|
||||
check("dblclick selects word", len(sel) > 0 and dbl_ms > 200, f"{dbl_ms} ms, selected='{sel}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: old text cleared, new text typed")
|
||||
t0 = time.time()
|
||||
page.locator('#searchInput').fill('Artificial intelligence')
|
||||
fill2_ms = int((time.time() - t0) * 1000)
|
||||
val2 = page.locator('#searchInput').input_value()
|
||||
check("fill replaces text", val2 == 'Artificial intelligence' and fill2_ms > 1500, f"{fill2_ms} ms, value='{val2}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: cursor moves to button without clicking")
|
||||
t0 = time.time()
|
||||
page.locator('button[type="submit"]').hover()
|
||||
hover_ms = int((time.time() - t0) * 1000)
|
||||
check("hover search button", hover_ms > 100, f"{hover_ms} ms")
|
||||
time.sleep(1)
|
||||
|
||||
# ============================================================
|
||||
# SCENARIO 2: Form interaction — checkboxes
|
||||
# ============================================================
|
||||
step("Checkboxes — check and uncheck")
|
||||
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
inject(page)
|
||||
time.sleep(1)
|
||||
|
||||
cb1 = page.locator('input[type="checkbox"]').nth(0)
|
||||
cb2 = page.locator('input[type="checkbox"]').nth(1)
|
||||
|
||||
print(" Watch: cursor moves to first checkbox, clicks")
|
||||
if cb1.is_checked():
|
||||
cb1.uncheck()
|
||||
time.sleep(0.5)
|
||||
|
||||
t0 = time.time()
|
||||
cb1.check()
|
||||
check_ms = int((time.time() - t0) * 1000)
|
||||
check("check checkbox 1", cb1.is_checked() and check_ms > 200, f"{check_ms} ms, checked={cb1.is_checked()}")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: cursor moves to second checkbox, clicks to uncheck")
|
||||
if not cb2.is_checked():
|
||||
cb2.check()
|
||||
time.sleep(0.5)
|
||||
|
||||
t0 = time.time()
|
||||
cb2.uncheck()
|
||||
uncheck_ms = int((time.time() - t0) * 1000)
|
||||
check("uncheck checkbox 2", not cb2.is_checked() and uncheck_ms > 200, f"{uncheck_ms} ms, checked={cb2.is_checked()}")
|
||||
time.sleep(1)
|
||||
|
||||
# ============================================================
|
||||
# SCENARIO 3: Dropdown
|
||||
# ============================================================
|
||||
step("Dropdown — select option")
|
||||
page.goto('https://the-internet.herokuapp.com/dropdown', wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
inject(page)
|
||||
time.sleep(1)
|
||||
|
||||
print(" Watch: cursor moves to dropdown, hovers, option selected")
|
||||
t0 = time.time()
|
||||
page.locator('#dropdown').select_option('1')
|
||||
sel_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#dropdown').input_value()
|
||||
check("select option 1", val == '1' and sel_ms > 100, f"{sel_ms} ms, value='{val}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
t0 = time.time()
|
||||
page.locator('#dropdown').select_option('2')
|
||||
sel2_ms = int((time.time() - t0) * 1000)
|
||||
val2 = page.locator('#dropdown').input_value()
|
||||
check("select option 2", val2 == '2' and sel2_ms > 100, f"{sel2_ms} ms, value='{val2}'")
|
||||
time.sleep(1)
|
||||
|
||||
# ============================================================
|
||||
# SCENARIO 4: Drag and drop
|
||||
# ============================================================
|
||||
step("Drag and Drop — move column A to B")
|
||||
page.goto('https://the-internet.herokuapp.com/drag_and_drop', wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
inject(page)
|
||||
time.sleep(1)
|
||||
|
||||
before_a = page.locator('#column-a header').text_content().strip()
|
||||
before_b = page.locator('#column-b header').text_content().strip()
|
||||
print(f" Before: A='{before_a}', B='{before_b}'")
|
||||
|
||||
print(" Watch: cursor moves to A, turns yellow (held), moves to B, releases")
|
||||
t0 = time.time()
|
||||
page.locator('#column-a').drag_to(page.locator('#column-b'))
|
||||
drag_ms = int((time.time() - t0) * 1000)
|
||||
time.sleep(1)
|
||||
|
||||
after_a = page.locator('#column-a header').text_content().strip()
|
||||
after_b = page.locator('#column-b header').text_content().strip()
|
||||
swapped = before_a != after_a
|
||||
print(f" After: A='{after_a}', B='{after_b}'")
|
||||
check("drag A to B", swapped and drag_ms > 300, f"{drag_ms} ms, swapped={swapped}")
|
||||
time.sleep(1)
|
||||
|
||||
# ============================================================
|
||||
# SCENARIO 5: Text editing
|
||||
# ============================================================
|
||||
step("Text editing — type, press keys, clear")
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
inject(page)
|
||||
time.sleep(1)
|
||||
|
||||
print(" Watch: cursor clicks input, types character by character")
|
||||
t0 = time.time()
|
||||
page.locator('#searchInput').type('Hello World')
|
||||
type_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
check("type 'Hello World'", val == 'Hello World' and type_ms > 1000, f"{type_ms} ms, value='{val}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: cursor clicks, presses single key")
|
||||
t0 = time.time()
|
||||
page.locator('#searchInput').press('End')
|
||||
page.locator('#searchInput').press('!')
|
||||
press_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
check("press '!' at end", '!' in val and press_ms > 100, f"{press_ms} ms, value='{val}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: field gets cleared (Ctrl+A, Backspace)")
|
||||
t0 = time.time()
|
||||
page.locator('#searchInput').clear()
|
||||
clear_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
check("clear field", val == '' and clear_ms > 100, f"{clear_ms} ms, value='{repr(val)}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: press_sequentially types each key individually")
|
||||
t0 = time.time()
|
||||
page.locator('#searchInput').press_sequentially('Sequential')
|
||||
pseq_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
check("press_sequentially", val == 'Sequential' and pseq_ms > 500, f"{pseq_ms} ms, value='{val}'")
|
||||
time.sleep(1)
|
||||
|
||||
# ============================================================
|
||||
# SCENARIO 6: Mouse precision
|
||||
# ============================================================
|
||||
step("Mouse precision — move to coordinates")
|
||||
print(" Watch: cursor moves in a Bezier curve to (600, 400)")
|
||||
t0 = time.time()
|
||||
page.mouse.move(600, 400)
|
||||
move_ms = int((time.time() - t0) * 1000)
|
||||
check("mouse.move to (600,400)", move_ms > 100, f"{move_ms} ms")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: cursor moves to (200, 200), clicks")
|
||||
t0 = time.time()
|
||||
page.mouse.click(200, 200)
|
||||
mclick_ms = int((time.time() - t0) * 1000)
|
||||
check("mouse.click at (200,200)", mclick_ms > 100, f"{mclick_ms} ms")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: keyboard types directly (no click needed)")
|
||||
page.locator('#searchInput').click()
|
||||
time.sleep(0.3)
|
||||
t0 = time.time()
|
||||
page.keyboard.type('Direct keyboard')
|
||||
kb_ms = int((time.time() - t0) * 1000)
|
||||
check("keyboard.type", kb_ms > 500, f"{kb_ms} ms")
|
||||
time.sleep(1)
|
||||
|
||||
# ============================================================
|
||||
# SUMMARY
|
||||
# ============================================================
|
||||
print("\n" + "=" * 70)
|
||||
print(" SUMMARY")
|
||||
print("=" * 70)
|
||||
passed = sum(1 for _, s in results if s == "PASS")
|
||||
failed = sum(1 for _, s in results if s == "FAIL")
|
||||
total = len(results)
|
||||
|
||||
for name, status in results:
|
||||
icon = "OK" if status == "PASS" else "XX"
|
||||
print(f" [{icon}] {name}")
|
||||
|
||||
print(f"\n {passed}/{total} passed, {failed} failed")
|
||||
if failed == 0:
|
||||
print(" *** ALL TESTS PASSED ***")
|
||||
print("=" * 70)
|
||||
|
||||
input("\nPress Enter to close browser...")
|
||||
browser.close()
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Unit + integration tests for the humanize layer (JS).
|
||||
* Covers: config resolution, Bézier math, fill clearing,
|
||||
* bot-detection form, and patching integrity.
|
||||
*
|
||||
* Run: node tests/test_humanize_unit.mjs
|
||||
*/
|
||||
import { launch } from '../js/dist/index.js';
|
||||
import { resolveConfig, rand, randRange, sleep } from '../js/dist/human/config.js';
|
||||
import { humanMove, clickTarget } from '../js/dist/human/mouse.js';
|
||||
|
||||
const PROXY = {
|
||||
|
||||
};
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
const results = [];
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` [PASS] ${name}`);
|
||||
results.push({ name, status: 'PASS' });
|
||||
} catch (e) {
|
||||
console.log(` [FAIL] ${name} — ${e.message || e}`);
|
||||
results.push({ name, status: 'FAIL' });
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 1. Config resolution
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' CONFIG RESOLUTION');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('default config resolves', async () => {
|
||||
const cfg = resolveConfig('default');
|
||||
if (!cfg) throw new Error('resolveConfig returned null');
|
||||
if (cfg.mouse_min_steps <= 0) throw new Error('mouse_min_steps should be > 0');
|
||||
if (cfg.mouse_max_steps <= cfg.mouse_min_steps) throw new Error('mouse_max_steps should be > min');
|
||||
if (cfg.typing_delay <= 0) throw new Error('typing_delay should be > 0');
|
||||
if (!Array.isArray(cfg.initial_cursor_x) || cfg.initial_cursor_x.length !== 2) throw new Error('initial_cursor_x invalid');
|
||||
if (!Array.isArray(cfg.initial_cursor_y) || cfg.initial_cursor_y.length !== 2) throw new Error('initial_cursor_y invalid');
|
||||
});
|
||||
|
||||
await test('careful config resolves', async () => {
|
||||
const cfg = resolveConfig('careful');
|
||||
const def = resolveConfig('default');
|
||||
if (!cfg) throw new Error('resolveConfig returned null');
|
||||
if (cfg.typing_delay < def.typing_delay) throw new Error('careful should have >= typing_delay');
|
||||
});
|
||||
|
||||
await test('custom config override', async () => {
|
||||
const cfg = resolveConfig('default', { mouse_min_steps: 100, mouse_max_steps: 200 });
|
||||
if (cfg.mouse_min_steps !== 100) throw new Error(`Override failed: ${cfg.mouse_min_steps}`);
|
||||
if (cfg.mouse_max_steps !== 200) throw new Error(`Override failed: ${cfg.mouse_max_steps}`);
|
||||
});
|
||||
|
||||
await test('rand within bounds', async () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const v = rand(10, 20);
|
||||
if (v < 10 || v > 20) throw new Error(`rand out of range: ${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('randRange within bounds', async () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const v = randRange([5, 15]);
|
||||
if (v < 5 || v > 15) throw new Error(`randRange out of range: ${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('sleep timing', async () => {
|
||||
const t0 = Date.now();
|
||||
await sleep(50);
|
||||
const elapsed = Date.now() - t0;
|
||||
if (elapsed < 40) throw new Error(`sleep too short: ${elapsed} ms`);
|
||||
if (elapsed > 200) throw new Error(`sleep too long: ${elapsed} ms`);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 2. Bézier math (via humanMove recording)
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' BÉZIER MATH (via mouse movement recording)');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('humanMove generates multiple points', async () => {
|
||||
const cfg = resolveConfig('default');
|
||||
const moves = [];
|
||||
const fakeRaw = {
|
||||
move: async (x, y) => moves.push({ x, y }),
|
||||
down: async () => {},
|
||||
up: async () => {},
|
||||
wheel: async () => {},
|
||||
};
|
||||
await humanMove(fakeRaw, 0, 0, 500, 300, cfg);
|
||||
if (moves.length < 10) throw new Error(`Expected >= 10 moves, got ${moves.length}`);
|
||||
const last = moves[moves.length - 1];
|
||||
if (Math.abs(last.x - 500) > 10) throw new Error(`Last x too far: ${last.x}`);
|
||||
if (Math.abs(last.y - 300) > 10) throw new Error(`Last y too far: ${last.y}`);
|
||||
});
|
||||
|
||||
await test('humanMove smoothness (no large jumps)', async () => {
|
||||
const cfg = resolveConfig('default');
|
||||
const moves = [];
|
||||
const fakeRaw = {
|
||||
move: async (x, y) => moves.push({ x, y }),
|
||||
down: async () => {},
|
||||
up: async () => {},
|
||||
wheel: async () => {},
|
||||
};
|
||||
await humanMove(fakeRaw, 0, 0, 400, 400, cfg);
|
||||
const totalDist = Math.sqrt(400 * 400 + 400 * 400);
|
||||
const maxJump = totalDist * 0.5;
|
||||
for (let i = 1; i < moves.length; i++) {
|
||||
const dx = moves[i].x - moves[i - 1].x;
|
||||
const dy = moves[i].y - moves[i - 1].y;
|
||||
const jump = Math.sqrt(dx * dx + dy * dy);
|
||||
if (jump > maxJump) throw new Error(`Jump too large at step ${i}: ${jump.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('humanMove not a straight line', async () => {
|
||||
const cfg = resolveConfig('default');
|
||||
let maxDev = 0;
|
||||
for (let trial = 0; trial < 5; trial++) {
|
||||
const moves = [];
|
||||
const fakeRaw = {
|
||||
move: async (x, y) => moves.push({ x, y }),
|
||||
down: async () => {},
|
||||
up: async () => {},
|
||||
wheel: async () => {},
|
||||
};
|
||||
await humanMove(fakeRaw, 0, 0, 500, 0, cfg);
|
||||
const dev = Math.max(...moves.map(m => Math.abs(m.y)));
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
if (maxDev < 0.5) throw new Error(`Curve too straight, max y deviation: ${maxDev.toFixed(2)}`);
|
||||
});
|
||||
|
||||
await test('clickTarget within bounding box', async () => {
|
||||
const cfg = resolveConfig('default');
|
||||
const box = { x: 100, y: 200, width: 150, height: 40 };
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const t = clickTarget(box, false, cfg);
|
||||
if (t.x < 100 || t.x > 250) throw new Error(`x out of box: ${t.x}`);
|
||||
if (t.y < 200 || t.y > 240) throw new Error(`y out of box: ${t.y}`);
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 3. Fill clearing (with real browser)
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' FILL CLEARING (browser)');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('fill() clears existing text', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||
await delay(1000);
|
||||
|
||||
await page.locator('#searchInput').type('initial text');
|
||||
await delay(500);
|
||||
const before = await page.locator('#searchInput').inputValue();
|
||||
if (before !== 'initial text') throw new Error(`Initial type failed: '${before}'`);
|
||||
|
||||
await page.locator('#searchInput').fill('replaced text');
|
||||
await delay(500);
|
||||
const after = await page.locator('#searchInput').inputValue();
|
||||
if (after !== 'replaced text') throw new Error(`Fill did not replace: '${after}'`);
|
||||
if (after.includes('initial')) throw new Error('Old text still present');
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
await test('fill() timing is humanized (>1s)', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||
await delay(1000);
|
||||
|
||||
const t0 = Date.now();
|
||||
await page.locator('#searchInput').fill('Human speed test');
|
||||
const elapsed = Date.now() - t0;
|
||||
if (elapsed < 1000) throw new Error(`fill() too fast: ${elapsed} ms`);
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
await test('clear() empties field', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||
await delay(1000);
|
||||
|
||||
await page.locator('#searchInput').fill('some text');
|
||||
await delay(500);
|
||||
await page.locator('#searchInput').clear();
|
||||
await delay(500);
|
||||
const val = await page.locator('#searchInput').inputValue();
|
||||
if (val !== '') throw new Error(`clear() did not empty: '${val}'`);
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 4. Bot detection form — deviceandbrowserinfo.com
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' BOT DETECTION FORM (deviceandbrowserinfo.com)');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('bot detection form — behavioral checks pass', async () => {
|
||||
const browser = await launch({ headless: false, humanize: true, proxy: PROXY });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' });
|
||||
await delay(3000);
|
||||
|
||||
await page.locator('#email').click();
|
||||
await delay(300);
|
||||
await page.locator('#email').fill('test@example.com');
|
||||
await delay(500);
|
||||
|
||||
await page.locator('#password').click();
|
||||
await delay(300);
|
||||
await page.locator('#password').fill('SecurePass!123');
|
||||
await delay(500);
|
||||
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await delay(5000);
|
||||
|
||||
const body = await page.locator('body').textContent();
|
||||
|
||||
const superHuman = body.includes('"superHumanSpeed": true');
|
||||
const suspicious = body.includes('"suspiciousClientSideBehavior": true');
|
||||
const cdpMouse = body.includes('"hasCDPMouseLeak": true');
|
||||
|
||||
console.log(` superHumanSpeed: ${superHuman}`);
|
||||
console.log(` suspiciousClientSideBehavior: ${suspicious}`);
|
||||
console.log(` hasCDPMouseLeak: ${cdpMouse}`);
|
||||
|
||||
if (superHuman) throw new Error('superHumanSpeed detected');
|
||||
if (suspicious) throw new Error('suspiciousClientSideBehavior detected');
|
||||
|
||||
if (body.includes('"isAutomatedWithCDP": true')) {
|
||||
console.log(' [INFO] isAutomatedWithCDP=true — stealth issue, not humanize');
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
await test('bot detection form timing (>3s)', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true, proxy: PROXY });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' });
|
||||
await delay(2000);
|
||||
|
||||
const t0 = Date.now();
|
||||
await page.locator('#email').fill('test@example.com');
|
||||
await page.locator('#password').fill('MyPassword!99');
|
||||
await page.locator('button[type="submit"]').click();
|
||||
const elapsed = Date.now() - t0;
|
||||
await delay(3000);
|
||||
|
||||
console.log(` Form fill + submit took: ${elapsed} ms`);
|
||||
if (elapsed < 3000) throw new Error(`Form filled too fast: ${elapsed} ms`);
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 5. Patching integrity
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' PATCHING INTEGRITY');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('page has _original after launch', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
const page = await browser.newPage();
|
||||
if (!page._original) throw new Error('page._original missing');
|
||||
if (!page._humanCfg) throw new Error('page._humanCfg missing');
|
||||
if (!page._humanCursor) throw new Error('page._humanCursor missing');
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
await test('page.click is humanized', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
const page = await browser.newPage();
|
||||
const clickStr = page.click.toString();
|
||||
if (!clickStr.includes('ensureCursorInit') && !clickStr.includes('humanClickFn') && !clickStr.includes('scrollToElement')) {
|
||||
throw new Error('page.click does not appear humanized');
|
||||
}
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
await test('non-humanized page works normally', async () => {
|
||||
const browser = await launch({ headless: true, humanize: false });
|
||||
const page = await browser.newPage();
|
||||
if (page._original) throw new Error('Non-humanized page should not have _original');
|
||||
|
||||
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||
await delay(1000);
|
||||
|
||||
const t0 = Date.now();
|
||||
await page.locator('#searchInput').fill('test');
|
||||
const elapsed = Date.now() - t0;
|
||||
if (elapsed > 500) throw new Error(`Non-humanized fill too slow: ${elapsed} ms`);
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 6. Focus check — press skips click when focused
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' FOCUS CHECK (press / pressSequentially)');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('press skips click when element already focused', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||
await delay(1000);
|
||||
|
||||
// Click input first to focus it
|
||||
await page.locator('#searchInput').click();
|
||||
await delay(300);
|
||||
|
||||
// Record mouse moves before pressing Enter
|
||||
const movesBefore = [];
|
||||
const origMove = page._humanOriginals.mouseMove;
|
||||
let moveCount = 0;
|
||||
page._humanOriginals.mouseMove = async (x, y, opts) => {
|
||||
moveCount++;
|
||||
return origMove(x, y, opts);
|
||||
};
|
||||
|
||||
// Press Enter — element is already focused, should NOT trigger mouse move
|
||||
const movesAtStart = moveCount;
|
||||
await page.locator('#searchInput').press('a');
|
||||
const movesUsed = moveCount - movesAtStart;
|
||||
|
||||
// Restore
|
||||
page._humanOriginals.mouseMove = origMove;
|
||||
|
||||
// If focus check works, should be 0 moves (just keyboard press)
|
||||
if (movesUsed > 0) {
|
||||
console.log(` [INFO] press() triggered ${movesUsed} mouse moves on focused element`);
|
||||
}
|
||||
// Lenient: allow some moves but not a full Bézier path (>10 would indicate a click)
|
||||
if (movesUsed > 10) {
|
||||
throw new Error(`press() moved mouse ${movesUsed} times on already-focused element — focus check broken`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 7. check/uncheck idle
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' CHECK/UNCHECK IDLE');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('check() respects idle_between_actions config', async () => {
|
||||
const cfg = resolveConfig('default', { idle_between_actions: true, idle_between_duration: [50, 100] });
|
||||
if (!cfg.idle_between_actions) throw new Error('idle_between_actions should be true');
|
||||
if (!cfg.idle_between_duration || cfg.idle_between_duration[0] !== 50) {
|
||||
throw new Error('idle_between_duration not set');
|
||||
}
|
||||
// Verify config is carried through to page
|
||||
const browser = await launch({ headless: true, humanize: true, humanize_config: { idle_between_actions: true } });
|
||||
const page = await browser.newPage();
|
||||
if (!page._humanCfg) throw new Error('page._humanCfg missing');
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 8. Frame patching completeness
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' FRAME PATCHING COMPLETENESS');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('frame has all methods patched', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
|
||||
await delay(1000);
|
||||
|
||||
const mainFrame = page.mainFrame();
|
||||
const expected = ['click', 'dblclick', 'hover', 'type', 'fill',
|
||||
'check', 'uncheck', 'selectOption', 'press',
|
||||
'clear', 'dragAndDrop'];
|
||||
const missing = [];
|
||||
for (const method of expected) {
|
||||
if (typeof mainFrame[method] !== 'function') {
|
||||
missing.push(method);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Frame missing patched methods: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
// Verify they are patched (not original Playwright bindings)
|
||||
if (!mainFrame._humanPatched) {
|
||||
throw new Error('mainFrame._humanPatched flag not set');
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 9. drag_to safety — page._original check
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' DRAG_TO SAFETY');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('page._humanCfg is accessible', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
const page = await browser.newPage();
|
||||
if (!page._humanCfg) throw new Error('page._humanCfg not set');
|
||||
if (!page._original) throw new Error('page._original not set');
|
||||
if (typeof page._original.mouseDown !== 'function') throw new Error('mouseDown not preserved');
|
||||
if (typeof page._original.mouseUp !== 'function') throw new Error('mouseUp not preserved');
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// 10. patchBrowser.newPage uses original context
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' PATCH BROWSER — newPage context');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await test('browser.newPage returns patched page', async () => {
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
const page = await browser.newPage();
|
||||
if (!page._original) throw new Error('page from browser.newPage() not patched');
|
||||
if (!page._humanCfg) throw new Error('page._humanCfg missing from browser.newPage()');
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// SUMMARY
|
||||
// =========================================================================
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log(' TEST SUMMARY');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const passed = results.filter(r => r.status === 'PASS').length;
|
||||
const failed = results.filter(r => r.status === 'FAIL').length;
|
||||
|
||||
for (const r of results) {
|
||||
const icon = r.status === 'PASS' ? 'OK' : 'XX';
|
||||
console.log(` [${icon}] ${r.name}`);
|
||||
}
|
||||
|
||||
console.log(`\n ${passed}/${results.length} passed, ${failed} failed`);
|
||||
if (failed === 0) console.log(' *** ALL JS TESTS PASSED ***');
|
||||
else console.log(` *** ${failed} TESTS FAILED ***`);
|
||||
console.log('='.repeat(70));
|
||||
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,696 @@
|
||||
"""
|
||||
Unit + integration tests for the humanize layer.
|
||||
|
||||
Fast unit tests (config, Bézier math, mocks) are proper test_ functions
|
||||
that pytest discovers automatically.
|
||||
|
||||
Browser-dependent tests are marked @pytest.mark.slow and skipped in CI
|
||||
unless explicitly requested (pytest -m slow).
|
||||
|
||||
Can also run directly: python tests/test_humanize_unit.py
|
||||
"""
|
||||
import math
|
||||
import time
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Helper: ensure Locator class is patched before mock tests
|
||||
# =========================================================================
|
||||
|
||||
def _ensure_locator_patched():
|
||||
import cloakbrowser.human as h
|
||||
h._locator_sync_patched = False
|
||||
h._patch_locator_class_sync()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Helper: fake RawMouse for Bézier tests
|
||||
# =========================================================================
|
||||
|
||||
class _FakeRawMouse:
|
||||
def __init__(self):
|
||||
self.moves = []
|
||||
def move(self, x, y, **kw):
|
||||
self.moves.append((x, y))
|
||||
def down(self, **kw):
|
||||
pass
|
||||
def up(self, **kw):
|
||||
pass
|
||||
def wheel(self, dx, dy):
|
||||
pass
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Config resolution
|
||||
# =========================================================================
|
||||
|
||||
class TestConfigResolution:
|
||||
def test_default_config_resolves(self):
|
||||
from cloakbrowser.human.config import resolve_config, HumanConfig
|
||||
cfg = resolve_config("default", None)
|
||||
assert isinstance(cfg, HumanConfig)
|
||||
assert cfg.mouse_min_steps > 0
|
||||
assert cfg.mouse_max_steps > cfg.mouse_min_steps
|
||||
assert len(cfg.initial_cursor_x) == 2
|
||||
assert len(cfg.initial_cursor_y) == 2
|
||||
assert cfg.typing_delay > 0
|
||||
|
||||
def test_careful_config_resolves(self):
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("careful", None)
|
||||
default_cfg = resolve_config("default", None)
|
||||
assert cfg.mouse_min_steps > 0
|
||||
assert cfg.typing_delay >= default_cfg.typing_delay
|
||||
|
||||
def test_custom_override(self):
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default", {"mouse_min_steps": 100, "mouse_max_steps": 200})
|
||||
assert cfg.mouse_min_steps == 100
|
||||
assert cfg.mouse_max_steps == 200
|
||||
|
||||
def test_invalid_preset_raises(self):
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
with pytest.raises(ValueError, match="Unknown humanize preset"):
|
||||
resolve_config("nonexistent", None)
|
||||
|
||||
def test_rand_within_bounds(self):
|
||||
from cloakbrowser.human.config import rand, rand_range
|
||||
for _ in range(200):
|
||||
v = rand(10, 20)
|
||||
assert 10 <= v <= 20
|
||||
for _ in range(200):
|
||||
v = rand_range([5, 15])
|
||||
assert 5 <= v <= 15
|
||||
|
||||
def test_sleep_ms_timing(self):
|
||||
from cloakbrowser.human.config import sleep_ms
|
||||
t0 = time.time()
|
||||
sleep_ms(50)
|
||||
elapsed = (time.time() - t0) * 1000
|
||||
assert elapsed >= 40
|
||||
assert elapsed < 200
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Bézier math
|
||||
# =========================================================================
|
||||
|
||||
class TestBezierMath:
|
||||
def test_generates_multiple_points(self):
|
||||
from cloakbrowser.human.mouse import human_move
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default", None)
|
||||
raw = _FakeRawMouse()
|
||||
human_move(raw, 0, 0, 500, 300, cfg)
|
||||
assert len(raw.moves) >= 10
|
||||
last_x, last_y = raw.moves[-1]
|
||||
assert abs(last_x - 500) < 10
|
||||
assert abs(last_y - 300) < 10
|
||||
|
||||
def test_smoothness_no_large_jumps(self):
|
||||
from cloakbrowser.human.mouse import human_move
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default", None)
|
||||
raw = _FakeRawMouse()
|
||||
human_move(raw, 0, 0, 400, 400, cfg)
|
||||
total_dist = math.sqrt(400**2 + 400**2)
|
||||
max_jump = total_dist * 0.5
|
||||
for i in range(1, len(raw.moves)):
|
||||
dx = raw.moves[i][0] - raw.moves[i-1][0]
|
||||
dy = raw.moves[i][1] - raw.moves[i-1][1]
|
||||
assert math.sqrt(dx*dx + dy*dy) < max_jump
|
||||
|
||||
def test_short_distance(self):
|
||||
from cloakbrowser.human.mouse import human_move
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default", None)
|
||||
raw = _FakeRawMouse()
|
||||
human_move(raw, 100, 100, 103, 102, cfg)
|
||||
assert len(raw.moves) >= 1
|
||||
|
||||
def test_not_straight_line(self):
|
||||
from cloakbrowser.human.mouse import human_move
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default", None)
|
||||
max_dev = 0
|
||||
for _ in range(5):
|
||||
raw = _FakeRawMouse()
|
||||
human_move(raw, 0, 0, 500, 0, cfg)
|
||||
dev = max(abs(y) for _, y in raw.moves)
|
||||
if dev > max_dev:
|
||||
max_dev = dev
|
||||
assert max_dev > 0.5
|
||||
|
||||
def test_click_target_within_box(self):
|
||||
from cloakbrowser.human.mouse import click_target
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default", None)
|
||||
box = {"x": 100, "y": 200, "width": 150, "height": 40}
|
||||
for _ in range(50):
|
||||
t = click_target(box, False, cfg)
|
||||
assert 100 <= t.x <= 250
|
||||
assert 200 <= t.y <= 240
|
||||
|
||||
def test_click_target_input_mode(self):
|
||||
from cloakbrowser.human.mouse import click_target
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default", None)
|
||||
box = {"x": 50, "y": 50, "width": 200, "height": 30}
|
||||
for _ in range(20):
|
||||
t = click_target(box, True, cfg)
|
||||
assert 50 <= t.x <= 250
|
||||
assert 50 <= t.y <= 80
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. Async compatibility
|
||||
# =========================================================================
|
||||
|
||||
class TestAsyncCompat:
|
||||
def test_async_modules_import(self):
|
||||
from cloakbrowser.human.mouse_async import AsyncRawMouse, async_human_move
|
||||
from cloakbrowser.human.keyboard_async import AsyncRawKeyboard, async_human_type
|
||||
from cloakbrowser.human.scroll_async import async_scroll_to_element
|
||||
from cloakbrowser.human import patch_page_async, patch_browser_async, patch_context_async
|
||||
assert callable(async_human_move)
|
||||
assert callable(async_human_type)
|
||||
assert callable(async_scroll_to_element)
|
||||
|
||||
def test_async_locator_patch(self):
|
||||
import cloakbrowser.human as h
|
||||
h._locator_async_patched = False
|
||||
h._patch_locator_class_async()
|
||||
assert h._locator_async_patched
|
||||
from playwright.async_api._generated import Locator as AsyncLocator
|
||||
assert 'humanized' in AsyncLocator.fill.__name__
|
||||
|
||||
def test_async_sleep_is_coroutine(self):
|
||||
from cloakbrowser.human.config import async_sleep_ms
|
||||
import asyncio
|
||||
assert asyncio.iscoroutinefunction(async_sleep_ms)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 4. Focus check — press / clear / pressSequentially
|
||||
# =========================================================================
|
||||
|
||||
class TestFocusCheck:
|
||||
def test_press_skips_click_when_focused(self):
|
||||
_ensure_locator_patched()
|
||||
from unittest.mock import MagicMock, patch as mock_patch
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page._human_cfg = MagicMock()
|
||||
page._human_cfg.idle_between_actions = False
|
||||
|
||||
with mock_patch("cloakbrowser.human._is_selector_focused", return_value=True):
|
||||
from playwright.sync_api._generated import Locator
|
||||
loc = MagicMock()
|
||||
loc.page = page
|
||||
loc._impl_obj = MagicMock()
|
||||
loc._impl_obj._selector = "#test"
|
||||
Locator.press(loc, "Enter")
|
||||
|
||||
page.click.assert_not_called()
|
||||
|
||||
def test_press_clicks_when_not_focused(self):
|
||||
_ensure_locator_patched()
|
||||
from unittest.mock import MagicMock, patch as mock_patch
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page._human_cfg = MagicMock()
|
||||
page._human_cfg.idle_between_actions = False
|
||||
|
||||
with mock_patch("cloakbrowser.human._is_selector_focused", return_value=False):
|
||||
from playwright.sync_api._generated import Locator
|
||||
loc = MagicMock()
|
||||
loc.page = page
|
||||
loc._impl_obj = MagicMock()
|
||||
loc._impl_obj._selector = "#test"
|
||||
Locator.press(loc, "Enter")
|
||||
|
||||
page.click.assert_called_with("#test")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 5. check/uncheck idle
|
||||
# =========================================================================
|
||||
|
||||
class TestCheckUncheckIdle:
|
||||
def test_check_calls_idle_when_enabled(self):
|
||||
_ensure_locator_patched()
|
||||
from unittest.mock import MagicMock, patch as mock_patch
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})
|
||||
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page._original.mouse_move = MagicMock()
|
||||
page._human_cfg = cfg
|
||||
|
||||
idle_called = {"n": 0}
|
||||
def fake_idle(*a, **kw):
|
||||
idle_called["n"] += 1
|
||||
|
||||
from playwright.sync_api._generated import Locator
|
||||
loc = MagicMock()
|
||||
loc.page = page
|
||||
loc._impl_obj = MagicMock()
|
||||
loc._impl_obj._selector = "#checkbox"
|
||||
loc.is_checked = MagicMock(return_value=False)
|
||||
|
||||
with mock_patch("cloakbrowser.human.human_idle", fake_idle):
|
||||
Locator.check(loc)
|
||||
|
||||
assert idle_called["n"] >= 1
|
||||
|
||||
def test_uncheck_calls_idle_when_enabled(self):
|
||||
_ensure_locator_patched()
|
||||
from unittest.mock import MagicMock, patch as mock_patch
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]})
|
||||
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page._original.mouse_move = MagicMock()
|
||||
page._human_cfg = cfg
|
||||
|
||||
idle_called = {"n": 0}
|
||||
def fake_idle(*a, **kw):
|
||||
idle_called["n"] += 1
|
||||
|
||||
from playwright.sync_api._generated import Locator
|
||||
loc = MagicMock()
|
||||
loc.page = page
|
||||
loc._impl_obj = MagicMock()
|
||||
loc._impl_obj._selector = "#checkbox"
|
||||
loc.is_checked = MagicMock(return_value=True)
|
||||
|
||||
with mock_patch("cloakbrowser.human.human_idle", fake_idle):
|
||||
Locator.uncheck(loc)
|
||||
|
||||
assert idle_called["n"] >= 1
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 6. Frame patching completeness
|
||||
# =========================================================================
|
||||
|
||||
class TestFramePatching:
|
||||
def test_all_11_methods_patched(self):
|
||||
from cloakbrowser.human import _patch_single_frame_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()
|
||||
frame = MagicMock()
|
||||
frame._human_patched = False
|
||||
|
||||
_patch_single_frame_sync(frame, page, cfg, cursor, MagicMock(), MagicMock(), page._original)
|
||||
|
||||
expected = ['click', 'dblclick', 'hover', 'type', 'fill',
|
||||
'check', 'uncheck', 'select_option', 'press',
|
||||
'clear', 'drag_and_drop']
|
||||
for method in expected:
|
||||
fn = getattr(frame, method)
|
||||
assert not isinstance(fn, MagicMock), f"frame.{method} was not patched"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 7. drag_to safety
|
||||
# =========================================================================
|
||||
|
||||
class TestDragToSafety:
|
||||
def test_handles_missing_original(self):
|
||||
_ensure_locator_patched()
|
||||
from playwright.sync_api._generated import Locator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
page = MagicMock()
|
||||
page._original = None
|
||||
|
||||
source_loc = MagicMock()
|
||||
source_loc.page = page
|
||||
source_loc._impl_obj = MagicMock()
|
||||
source_loc._impl_obj._selector = "#src"
|
||||
source_loc.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 50})
|
||||
|
||||
target_loc = MagicMock()
|
||||
target_loc.page = page
|
||||
target_loc._impl_obj = MagicMock()
|
||||
target_loc._impl_obj._selector = "#tgt"
|
||||
target_loc.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 50, "height": 50})
|
||||
|
||||
try:
|
||||
Locator.drag_to(source_loc, target_loc)
|
||||
except AttributeError:
|
||||
pytest.fail("drag_to crashed without page._original")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 8. Page config persistence
|
||||
# =========================================================================
|
||||
|
||||
class TestPageConfigPersistence:
|
||||
def test_resolve_config_has_all_fields(self):
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default")
|
||||
required = ["mouse_min_steps", "mouse_max_steps", "typing_delay",
|
||||
"initial_cursor_x", "initial_cursor_y", "idle_between_actions",
|
||||
"idle_between_duration", "field_switch_delay",
|
||||
"mistype_chance", "mistype_delay_notice", "mistype_delay_correct"]
|
||||
for field in required:
|
||||
assert hasattr(cfg, field), f"Config missing field: {field}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 9. Mistype config
|
||||
# =========================================================================
|
||||
|
||||
class TestMistypeConfig:
|
||||
def test_default_mistype_chance(self):
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
cfg = resolve_config("default")
|
||||
assert 0 < cfg.mistype_chance < 1
|
||||
assert len(cfg.mistype_delay_notice) == 2
|
||||
assert len(cfg.mistype_delay_correct) == 2
|
||||
|
||||
def test_careful_mistype_higher(self):
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
default = resolve_config("default")
|
||||
careful = resolve_config("careful")
|
||||
assert careful.mistype_chance >= default.mistype_chance
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 10. Select-all platform detection
|
||||
# =========================================================================
|
||||
|
||||
class TestSelectAllPlatform:
|
||||
def test_select_all_constant_exists(self):
|
||||
from cloakbrowser.human import _SELECT_ALL
|
||||
assert _SELECT_ALL in ("Meta+a", "Control+a")
|
||||
|
||||
def test_select_all_matches_platform(self):
|
||||
import sys
|
||||
from cloakbrowser.human import _SELECT_ALL
|
||||
if sys.platform == "darwin":
|
||||
assert _SELECT_ALL == "Meta+a"
|
||||
else:
|
||||
assert _SELECT_ALL == "Control+a"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 11. Non-ASCII keyboard input
|
||||
# =========================================================================
|
||||
|
||||
class TestNonAsciiKeyboard:
|
||||
def test_cyrillic_uses_insert_text(self):
|
||||
from cloakbrowser.human.keyboard import human_type
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {"mistype_chance": 0})
|
||||
page = MagicMock()
|
||||
raw = MagicMock()
|
||||
|
||||
down_keys = []
|
||||
inserted = []
|
||||
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
|
||||
raw.up = MagicMock()
|
||||
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
|
||||
|
||||
human_type(page, raw, "Привет", cfg)
|
||||
|
||||
assert "".join(inserted) == "Привет"
|
||||
for k in down_keys:
|
||||
assert ord(k[0]) < 128 or k in ("Shift", "Backspace")
|
||||
|
||||
def test_mixed_ascii_cyrillic(self):
|
||||
from cloakbrowser.human.keyboard import human_type
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {"mistype_chance": 0})
|
||||
page = MagicMock()
|
||||
raw = MagicMock()
|
||||
|
||||
down_keys = []
|
||||
inserted = []
|
||||
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
|
||||
raw.up = MagicMock()
|
||||
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
|
||||
|
||||
human_type(page, raw, "Hi Мир", cfg)
|
||||
|
||||
assert "H" in down_keys
|
||||
assert "i" in down_keys
|
||||
assert "М" in "".join(inserted)
|
||||
|
||||
def test_cjk_uses_insert_text(self):
|
||||
from cloakbrowser.human.keyboard import human_type
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {"mistype_chance": 0})
|
||||
page = MagicMock()
|
||||
raw = MagicMock()
|
||||
|
||||
inserted = []
|
||||
raw.down = MagicMock()
|
||||
raw.up = MagicMock()
|
||||
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
|
||||
|
||||
human_type(page, raw, "你好", cfg)
|
||||
|
||||
assert "".join(inserted) == "你好"
|
||||
|
||||
def test_mistype_only_ascii(self):
|
||||
from cloakbrowser.human.keyboard import human_type
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {"mistype_chance": 1.0})
|
||||
page = MagicMock()
|
||||
raw = MagicMock()
|
||||
|
||||
down_keys = []
|
||||
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
|
||||
raw.up = MagicMock()
|
||||
raw.insert_text = MagicMock()
|
||||
|
||||
human_type(page, raw, "AБ", cfg)
|
||||
|
||||
assert "Backspace" in down_keys
|
||||
|
||||
def test_no_error_on_cyrillic(self):
|
||||
from cloakbrowser.human.keyboard import human_type
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {"mistype_chance": 0})
|
||||
page = MagicMock()
|
||||
raw = MagicMock()
|
||||
raw.down = MagicMock()
|
||||
raw.up = MagicMock()
|
||||
raw.insert_text = MagicMock()
|
||||
|
||||
# Should not raise
|
||||
human_type(page, raw, "Тест кириллицы", cfg)
|
||||
|
||||
|
||||
class TestNonAsciiKeyboardAsync:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_cyrillic_uses_insert_text(self):
|
||||
from cloakbrowser.human.keyboard_async import async_human_type
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
cfg = resolve_config("default", {"mistype_chance": 0})
|
||||
page = MagicMock()
|
||||
raw = MagicMock()
|
||||
|
||||
inserted = []
|
||||
raw.down = AsyncMock()
|
||||
raw.up = AsyncMock()
|
||||
raw.insert_text = AsyncMock(side_effect=lambda t: inserted.append(t))
|
||||
|
||||
await async_human_type(page, raw, "Привет", cfg)
|
||||
|
||||
assert "".join(inserted) == "Привет"
|
||||
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# SLOW TESTS — require browser (skipped in CI unless pytest -m slow)
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestBrowserFill:
|
||||
def test_fill_clears_existing(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
page.locator('#searchInput').type('initial text')
|
||||
time.sleep(0.5)
|
||||
page.locator('#searchInput').fill('replaced text')
|
||||
time.sleep(0.5)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
assert val == 'replaced text'
|
||||
assert 'initial' not in val
|
||||
browser.close()
|
||||
|
||||
def test_fill_timing_humanized(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
t0 = time.time()
|
||||
page.locator('#searchInput').fill('Human speed test')
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
assert elapsed_ms > 1000
|
||||
browser.close()
|
||||
|
||||
def test_clear_empties_field(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(1)
|
||||
page.locator('#searchInput').fill('some text')
|
||||
time.sleep(0.5)
|
||||
page.locator('#searchInput').clear()
|
||||
time.sleep(0.5)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
assert val == ''
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestBrowserPatching:
|
||||
def test_page_has_original(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
page = browser.new_page()
|
||||
assert hasattr(page, '_original')
|
||||
assert hasattr(page, '_human_cfg')
|
||||
browser.close()
|
||||
|
||||
def test_locator_methods_patched(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
page = browser.new_page()
|
||||
from playwright.sync_api._generated import Locator
|
||||
methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck',
|
||||
'set_checked', 'select_option', 'press', 'press_sequentially',
|
||||
'tap', 'drag_to', 'clear']
|
||||
for method in methods:
|
||||
fn = getattr(Locator, method)
|
||||
assert 'humanized' in fn.__name__, f"{method} not patched"
|
||||
browser.close()
|
||||
|
||||
def test_non_humanized_page_normal(self):
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
assert not hasattr(page, '_original')
|
||||
browser.close()
|
||||
|
||||
def test_page_human_cfg_persists(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True)
|
||||
page = browser.new_page()
|
||||
assert page._human_cfg is not None
|
||||
assert hasattr(page._human_cfg, 'idle_between_actions')
|
||||
assert hasattr(page._human_cfg, 'mistype_chance')
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestBrowserBotDetection:
|
||||
PROXY = ''
|
||||
|
||||
def test_behavioral_checks_pass(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
|
||||
wait_until='domcontentloaded')
|
||||
time.sleep(3)
|
||||
page.locator('#email').click()
|
||||
time.sleep(0.3)
|
||||
page.locator('#email').fill('test@example.com')
|
||||
time.sleep(0.5)
|
||||
page.locator('#password').click()
|
||||
time.sleep(0.3)
|
||||
page.locator('#password').fill('SecurePass!123')
|
||||
time.sleep(0.5)
|
||||
page.locator('button[type="submit"]').click()
|
||||
time.sleep(5)
|
||||
body = page.locator('body').text_content()
|
||||
assert '"superHumanSpeed": true' not in body
|
||||
assert '"suspiciousClientSideBehavior": true' not in body
|
||||
browser.close()
|
||||
|
||||
def test_form_timing(self):
|
||||
from cloakbrowser import launch
|
||||
browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True)
|
||||
page = browser.new_page()
|
||||
page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions',
|
||||
wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
t0 = time.time()
|
||||
page.locator('#email').fill('test@example.com')
|
||||
page.locator('#password').fill('MyPassword!99')
|
||||
page.locator('button[type="submit"]').click()
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
time.sleep(3)
|
||||
assert elapsed_ms > 3000
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestAsyncEndToEnd:
|
||||
def test_async_launch_click_fill(self):
|
||||
"""launch_async(humanize=True) — async page.click and page.fill work end-to-end."""
|
||||
import asyncio
|
||||
from cloakbrowser import launch_async
|
||||
|
||||
async def _run():
|
||||
browser = await launch_async(headless=True, humanize=True)
|
||||
page = await browser.new_page()
|
||||
assert hasattr(page, '_original'), "async page not patched"
|
||||
assert hasattr(page, '_human_cfg'), "async page missing _human_cfg"
|
||||
|
||||
await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
await asyncio.sleep(1)
|
||||
|
||||
t0 = time.time()
|
||||
await page.locator('#searchInput').fill('async test')
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms"
|
||||
|
||||
val = await page.locator('#searchInput').input_value()
|
||||
assert val == 'async test', f"async fill wrong value: {val}"
|
||||
|
||||
await browser.close()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Direct runner (backwards compat)
|
||||
# =========================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"]))
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
|
||||
|
||||
import warnings
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
@@ -66,7 +65,7 @@ def test_user_agent(mock_launch, _mock_bin):
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_locale_forwarded(mock_launch, _mock_bin):
|
||||
"""locale flows to both launch() binary args AND new_context()."""
|
||||
"""locale flows to launch() for --lang binary flag, NOT to new_context() CDP."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
@@ -75,18 +74,18 @@ def test_locale_forwarded(mock_launch, _mock_bin):
|
||||
|
||||
# Locale in launch() call (for --lang binary flag)
|
||||
assert mock_launch.call_args[1]["locale"] == "de-DE"
|
||||
# Locale in new_context() call
|
||||
# NOT in new_context() — would trigger detectable CDP emulation
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["locale"] == "de-DE"
|
||||
assert "locale" not in ctx_kwargs[1]
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_timezone_via_context_not_binary(mock_launch, _mock_bin):
|
||||
"""timezone passed to new_context(timezone_id=...) but NOT to launch(timezone=...).
|
||||
def test_timezone_via_binary_not_cdp(mock_launch, _mock_bin):
|
||||
"""timezone passed to launch() for binary flag, NOT to new_context() CDP.
|
||||
|
||||
This is intentional: the --fingerprint-timezone binary flag only applies to the
|
||||
default context and would conflict with Playwright's timezone_id on new contexts.
|
||||
--fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||
so it applies to ALL contexts, not just the default one.
|
||||
"""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
@@ -94,11 +93,11 @@ def test_timezone_via_context_not_binary(mock_launch, _mock_bin):
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(timezone="America/New_York")
|
||||
|
||||
# timezone=None in launch() — binary flag skipped
|
||||
assert mock_launch.call_args[1]["timezone"] is None
|
||||
# timezone_id in new_context()
|
||||
# timezone in launch() — binary flag set
|
||||
assert mock_launch.call_args[1]["timezone"] == "America/New_York"
|
||||
# NOT in new_context() — no CDP emulation
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["timezone_id"] == "America/New_York"
|
||||
assert "timezone_id" not in ctx_kwargs[1]
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@@ -115,44 +114,41 @@ def test_color_scheme(mock_launch, _mock_bin):
|
||||
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"))
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
|
||||
"""geoip fills timezone+locale, both flow to correct places."""
|
||||
"""geoip fills timezone+locale, both flow to binary args only."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(proxy="http://proxy:8080", geoip=True)
|
||||
|
||||
# Locale goes to launch() for binary flag
|
||||
# Both go to launch() for binary flags
|
||||
assert mock_launch.call_args[1]["locale"] == "de-DE"
|
||||
# Timezone goes to context, not binary
|
||||
assert mock_launch.call_args[1]["timezone"] is None
|
||||
assert mock_launch.call_args[1]["timezone"] == "Europe/Berlin"
|
||||
# Neither in context — no CDP emulation
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["timezone_id"] == "Europe/Berlin"
|
||||
assert ctx_kwargs[1]["locale"] == "de-DE"
|
||||
assert "timezone_id" not in ctx_kwargs[1]
|
||||
assert "locale" not in ctx_kwargs[1]
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_timezone_id_deprecation(mock_launch, _mock_bin):
|
||||
"""timezone_id kwarg triggers FutureWarning, value migrated to timezone."""
|
||||
def test_timezone_id_alias(mock_launch, _mock_bin):
|
||||
"""timezone_id kwarg accepted as alias for timezone."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
launch_context(timezone_id="Europe/Paris")
|
||||
launch_context(timezone_id="Europe/Paris")
|
||||
|
||||
assert len(w) == 1
|
||||
assert issubclass(w[0].category, FutureWarning)
|
||||
assert "timezone_id" in str(w[0].message)
|
||||
# Migrated value flows to context
|
||||
# Resolved value flows to launch() for binary flag
|
||||
assert mock_launch.call_args[1]["timezone"] == "Europe/Paris"
|
||||
# NOT in context — no CDP emulation
|
||||
ctx_kwargs = browser.new_context.call_args
|
||||
assert ctx_kwargs[1]["timezone_id"] == "Europe/Paris"
|
||||
assert "timezone_id" not in ctx_kwargs[1]
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
All tests mock playwright to avoid needing a binary.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -27,7 +26,7 @@ def _make_mock_pw_and_context():
|
||||
|
||||
|
||||
@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))
|
||||
def test_persistent_context_args_built(_mock_geoip, _mock_bin):
|
||||
"""Stealth args + extra args combined correctly."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
@@ -43,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._maybe_resolve_geoip", return_value=(None, None))
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
|
||||
"""DEFAULT_VIEWPORT applied when no viewport given."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
@@ -57,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._maybe_resolve_geoip", return_value=(None, None))
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
||||
"""Custom viewport overrides DEFAULT_VIEWPORT."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
@@ -72,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._maybe_resolve_geoip", return_value=(None, None))
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
|
||||
"""user_agent forwarded to launch_persistent_context()."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
@@ -87,7 +86,7 @@ def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
def test_persistent_context_locale_and_timezone(_mock_bin):
|
||||
"""Both timezone and locale flow to context kwargs and binary args."""
|
||||
"""Timezone and locale flow to binary args only, NOT to CDP context kwargs."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
@@ -95,16 +94,16 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
|
||||
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
# Context kwargs
|
||||
assert call_kwargs["timezone_id"] == "Asia/Tokyo"
|
||||
assert call_kwargs["locale"] == "ja-JP"
|
||||
# Binary args
|
||||
# Binary args (native, undetectable)
|
||||
assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"]
|
||||
assert "--lang=ja-JP" in call_kwargs["args"]
|
||||
# NOT in context kwargs (would trigger detectable CDP emulation)
|
||||
assert "timezone_id" not in call_kwargs
|
||||
assert "locale" not in call_kwargs
|
||||
|
||||
|
||||
@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))
|
||||
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
|
||||
"""color_scheme forwarded correctly."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
@@ -117,10 +116,10 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
|
||||
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"))
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
def test_persistent_context_geoip(_mock_bin, _mock_geoip):
|
||||
"""geoip fills missing tz/locale."""
|
||||
"""geoip fills missing tz/locale — flows to binary args, not CDP context."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
@@ -128,29 +127,30 @@ def test_persistent_context_geoip(_mock_bin, _mock_geoip):
|
||||
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["timezone_id"] == "Europe/Berlin"
|
||||
assert call_kwargs["locale"] == "de-DE"
|
||||
# Binary args
|
||||
assert "--fingerprint-timezone=Europe/Berlin" in call_kwargs["args"]
|
||||
assert "--lang=de-DE" in call_kwargs["args"]
|
||||
# NOT in context kwargs
|
||||
assert "timezone_id" not in call_kwargs
|
||||
assert "locale" not in call_kwargs
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
def test_persistent_context_timezone_id_deprecation(_mock_bin):
|
||||
"""Old timezone_id kwarg migrated with warning."""
|
||||
def test_persistent_context_timezone_id_alias(_mock_bin):
|
||||
"""timezone_id kwarg accepted as alias for timezone."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
|
||||
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
|
||||
|
||||
assert len(w) == 1
|
||||
assert issubclass(w[0].category, FutureWarning)
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["timezone_id"] == "Europe/Paris"
|
||||
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
|
||||
assert "timezone_id" not in call_kwargs
|
||||
|
||||
|
||||
@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))
|
||||
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
|
||||
"""context.close() also calls pw.stop()."""
|
||||
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._maybe_resolve_geoip", return_value=(None, None))
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
|
||||
"""Proxy string parsed and passed."""
|
||||
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._maybe_resolve_geoip", return_value=(None, None))
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
|
||||
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
|
||||
"""Proxy dict passed through."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
@@ -213,7 +213,7 @@ def _make_mock_async_pw_and_context():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@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))
|
||||
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
|
||||
"""Async launch builds args correctly."""
|
||||
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
|
||||
@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))
|
||||
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
|
||||
"""await context.close() calls await pw.stop()."""
|
||||
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
||||
@@ -246,17 +246,14 @@ async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
async def test_persistent_context_async_timezone_id_deprecation(_mock_bin):
|
||||
"""Deprecated timezone_id kwarg migrated with warning in async path."""
|
||||
async def test_persistent_context_async_timezone_id_alias(_mock_bin):
|
||||
"""timezone_id kwarg accepted as alias in async path."""
|
||||
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
||||
|
||||
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context_async
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
|
||||
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
|
||||
|
||||
assert len(w) == 1
|
||||
assert issubclass(w[0].category, FutureWarning)
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs["timezone_id"] == "Europe/Paris"
|
||||
assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
|
||||
assert "timezone_id" not in call_kwargs
|
||||
|
||||
+59
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
|
||||
from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url
|
||||
|
||||
|
||||
class TestParseProxyUrl:
|
||||
@@ -70,7 +70,7 @@ class TestBuildProxyKwargs:
|
||||
class TestMaybeResolveGeoip:
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
||||
def test_geoip_with_string_proxy(self, mock_geo):
|
||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "America/New_York"
|
||||
assert locale == "en-US"
|
||||
@@ -78,23 +78,76 @@ class TestMaybeResolveGeoip:
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
|
||||
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
|
||||
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
|
||||
tz, locale = _maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
tz, locale = maybe_resolve_geoip(True, proxy_dict, None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "Europe/London"
|
||||
assert locale == "en-GB"
|
||||
|
||||
def test_geoip_disabled_skips_resolution(self):
|
||||
tz, locale = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||
tz, locale = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||
assert tz is None
|
||||
assert locale is None
|
||||
|
||||
def test_geoip_no_proxy_skips_resolution(self):
|
||||
tz, locale = _maybe_resolve_geoip(True, None, None, None)
|
||||
tz, locale = maybe_resolve_geoip(True, None, None, None)
|
||||
assert tz is None
|
||||
assert locale is None
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
|
||||
def test_geoip_preserves_explicit_timezone(self, mock_geo):
|
||||
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
|
||||
tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
|
||||
assert tz == "Europe/Berlin"
|
||||
assert locale == "ja-JP"
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
||||
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
|
||||
# "user:pass@host:port" must be normalized to http:// before geoip lookup.
|
||||
tz, locale = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
|
||||
mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
|
||||
assert tz == "America/New_York"
|
||||
assert locale == "en-US"
|
||||
|
||||
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
|
||||
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
|
||||
# "host:port" (no @ and no scheme) must also be normalized.
|
||||
tz, locale = maybe_resolve_geoip(True, "proxy:8080", None, None)
|
||||
mock_geo.assert_called_once_with("http://proxy:8080")
|
||||
assert tz == "America/New_York"
|
||||
|
||||
|
||||
class TestBareProxyFormat:
|
||||
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
|
||||
|
||||
def test_bare_with_credentials(self):
|
||||
r = _parse_proxy_url("user:pass@proxy:8080")
|
||||
assert r["username"] == "user"
|
||||
assert r["password"] == "pass"
|
||||
assert r["server"] == "http://proxy:8080"
|
||||
|
||||
def test_bare_credentials_not_in_server(self):
|
||||
r = _parse_proxy_url("user:pass@proxy1.example.com:5610")
|
||||
assert "user" not in r["server"]
|
||||
assert "pass" not in r["server"]
|
||||
|
||||
def test_bare_username_only(self):
|
||||
r = _parse_proxy_url("user@proxy:8080")
|
||||
assert r["username"] == "user"
|
||||
assert "password" not in r
|
||||
assert r["server"] == "http://proxy:8080"
|
||||
|
||||
def test_bare_no_port(self):
|
||||
r = _parse_proxy_url("user:pass@proxy.example.com")
|
||||
assert r["username"] == "user"
|
||||
assert r["password"] == "pass"
|
||||
assert r["server"] == "http://proxy.example.com"
|
||||
|
||||
def test_bare_no_credentials_passthrough(self):
|
||||
# "host:port" without @ — no scheme, no creds — pass through unchanged
|
||||
r = _parse_proxy_url("proxy:8080")
|
||||
assert r == {"server": "proxy:8080"}
|
||||
|
||||
def test_build_proxy_kwargs_bare(self):
|
||||
r = _build_proxy_kwargs("user:pass@proxy:8080")
|
||||
assert r["proxy"]["username"] == "user"
|
||||
assert r["proxy"]["password"] == "pass"
|
||||
assert "user" not in r["proxy"]["server"]
|
||||
|
||||
@@ -20,6 +20,8 @@ from cloakbrowser.config import (
|
||||
)
|
||||
from cloakbrowser.download import (
|
||||
_check_wrapper_update,
|
||||
_download_and_extract,
|
||||
_fetch_checksums,
|
||||
_get_latest_chromium_version,
|
||||
_parse_checksums,
|
||||
_should_check_for_update,
|
||||
@@ -474,3 +476,75 @@ class TestWriteVersionMarker:
|
||||
marker = tmp_path / f"latest_version_{get_platform_tag()}"
|
||||
assert marker.exists()
|
||||
assert marker.read_text() == "999.0.0.0"
|
||||
|
||||
|
||||
class TestDownloadFallback:
|
||||
"""Verify primary server (cloakbrowser.dev) → GitHub Releases fallback on HTTP errors."""
|
||||
|
||||
def test_binary_download_falls_back_on_http_error(self, tmp_path):
|
||||
"""HTTP error from primary triggers GitHub Releases fallback for binary download."""
|
||||
with patch.dict(os.environ, {
|
||||
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
||||
"CLOAKBROWSER_DOWNLOAD_URL": "",
|
||||
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
|
||||
}):
|
||||
urls_called = []
|
||||
|
||||
def mock_download_file(url, dest):
|
||||
urls_called.append(url)
|
||||
if "cloakbrowser.dev" in url:
|
||||
raise Exception("HTTP 429 Too Many Requests")
|
||||
# GitHub fallback succeeds
|
||||
dest.write_bytes(b"fake")
|
||||
|
||||
with patch("cloakbrowser.download._download_file", side_effect=mock_download_file), \
|
||||
patch("cloakbrowser.download._extract_archive"), \
|
||||
patch("cloakbrowser.download._show_welcome"):
|
||||
_download_and_extract()
|
||||
|
||||
assert len(urls_called) == 2
|
||||
assert "cloakbrowser.dev" in urls_called[0]
|
||||
assert "github.com" in urls_called[1]
|
||||
|
||||
def test_binary_download_no_fallback_with_custom_url(self, tmp_path):
|
||||
"""Custom CLOAKBROWSER_DOWNLOAD_URL disables GitHub fallback — error propagates."""
|
||||
with patch.dict(os.environ, {
|
||||
"CLOAKBROWSER_CACHE_DIR": str(tmp_path),
|
||||
"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com/releases",
|
||||
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
|
||||
}):
|
||||
with patch("cloakbrowser.download._download_file", side_effect=Exception("503")):
|
||||
with pytest.raises(Exception, match="503"):
|
||||
_download_and_extract()
|
||||
|
||||
def test_checksum_fetch_falls_back_on_http_error(self):
|
||||
"""HTTP error from primary checksum URL triggers GitHub fallback."""
|
||||
valid_checksums = (
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
" cloakbrowser-linux-x64.tar.gz\n"
|
||||
)
|
||||
|
||||
def mock_get(url, **kwargs):
|
||||
resp = MagicMock()
|
||||
if "cloakbrowser.dev" in url:
|
||||
resp.raise_for_status.side_effect = Exception("HTTP 429")
|
||||
return resp
|
||||
# GitHub URL succeeds
|
||||
resp.text = valid_checksums
|
||||
resp.raise_for_status = MagicMock()
|
||||
return resp
|
||||
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}):
|
||||
with patch("cloakbrowser.download.httpx.get", side_effect=mock_get):
|
||||
result = _fetch_checksums()
|
||||
|
||||
assert result is not None
|
||||
assert "cloakbrowser-linux-x64.tar.gz" in result
|
||||
|
||||
def test_checksum_fetch_returns_none_when_both_fail(self):
|
||||
"""Both primary and GitHub checksum URLs fail → returns None (skip verification)."""
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}):
|
||||
with patch("cloakbrowser.download.httpx.get", side_effect=Exception("network error")):
|
||||
result = _fetch_checksums()
|
||||
|
||||
assert result is None
|
||||
|
||||
Reference in New Issue
Block a user