mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee346a6a57 | ||
|
|
3e699f554c | ||
|
|
9eb90da012 | ||
|
|
6b8d8b6378 | ||
|
|
252e79b17d | ||
|
|
0ccdc71e47 | ||
|
|
74b1ff64db | ||
|
|
a9a0ba13ba | ||
|
|
b04ad6ec2a | ||
|
|
4459f66593 | ||
|
|
ce8b92ba4f | ||
|
|
4e1027847e | ||
|
|
f164c1c874 | ||
|
|
f5e242a160 | ||
|
|
935beef980 | ||
|
|
c6d3469e4c | ||
|
|
cb0b87873e | ||
|
|
2be8cdcc03 | ||
|
|
be9a98db67 | ||
|
|
9b004bbd85 | ||
|
|
5b2981c4c1 | ||
|
|
7afe59435e | ||
|
|
1cef71133d | ||
|
|
7a0937cc54 | ||
|
|
5b00ff0325 | ||
|
|
5dd44298ee | ||
|
|
54d8442f20 | ||
|
|
a01adbe26c | ||
|
|
06d77e7261 | ||
|
|
211bd93d3e | ||
|
|
1060772734 | ||
|
|
8eb2e4b905 | ||
|
|
216a7d6a6a | ||
|
|
02359f69c8 | ||
|
|
a0c7704c4b | ||
|
|
ccda93669e | ||
|
|
eb4efef329 | ||
|
|
25d34dcea3 | ||
|
|
c9e4f58353 | ||
|
|
c58b691f1c | ||
|
|
1b91a33e51 | ||
|
|
1bfd5ca036 | ||
|
|
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 | ||
|
|
724d49f65b | ||
|
|
ed79560e5f | ||
|
|
829e4b881f | ||
|
|
3880d30d0f | ||
|
|
9c533e4120 | ||
|
|
98c216f07e | ||
|
|
ee953709b0 | ||
|
|
a45fdc4d7e | ||
|
|
e411f24cf3 | ||
|
|
0a99a1458a | ||
|
|
f76dbdb044 | ||
|
|
976f5ae534 | ||
|
|
0719f750ef | ||
|
|
05fa1a052a | ||
|
|
25acff23b7 | ||
|
|
bd22e51bc2 | ||
|
|
ca5cce2222 | ||
|
|
de54e67f74 | ||
|
|
ef066fa091 | ||
|
|
5237065385 | ||
|
|
8e83b8c399 | ||
|
|
c44b04a953 | ||
|
|
51c3f464a5 | ||
|
|
ed0ecf0e48 | ||
|
|
46049a15d3 | ||
|
|
11b3bcb701 | ||
|
|
28de7bb147 | ||
|
|
0c64a32122 | ||
|
|
f9887943c0 | ||
|
|
55418add96 | ||
|
|
53c9eb581d | ||
|
|
d8960447a0 | ||
|
|
1ad2b8d3a9 | ||
|
|
3afe20cda2 | ||
|
|
3b256c413b | ||
|
|
a4b6caff47 | ||
|
|
fc10bdf13e | ||
|
|
4c2e06682b | ||
|
|
cb08a602b0 | ||
|
|
8eb666885f | ||
|
|
f417fe2530 | ||
|
|
c65939af14 | ||
|
|
2f1f592b3a | ||
|
|
0bbc170747 | ||
|
|
6506b5fe44 | ||
|
|
1082c810af | ||
|
|
67efadef26 | ||
|
|
59b9d71684 | ||
|
|
f480958ba7 | ||
|
|
8ffaf86abe | ||
|
|
8c76a68cb5 | ||
|
|
cee166c2d2 | ||
|
|
b07797f963 | ||
|
|
31c04d5bcc | ||
|
|
cc501d8ef6 | ||
|
|
8cecebf118 | ||
|
|
179531fd17 | ||
|
|
4d96db1448 | ||
|
|
4e809b9678 | ||
|
|
23ae521832 | ||
|
|
c2eb0ef21a | ||
|
|
2d7894ad68 | ||
|
|
efab732142 | ||
|
|
924f401265 | ||
|
|
6ea8391fac |
@@ -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
|
||||
@@ -0,0 +1,34 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
python:
|
||||
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: Install dependencies
|
||||
run: pip install -e ".[dev]" pytest pytest-asyncio
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v -m "not slow"
|
||||
|
||||
javascript:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install and build
|
||||
run: cd js && npm install && npm run build
|
||||
- name: Typecheck
|
||||
run: cd js && npm run typecheck
|
||||
- name: Run tests
|
||||
run: cd js && npm test
|
||||
@@ -0,0 +1,134 @@
|
||||
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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.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@cef221092ed1bacb1cc03d23a2d87d1d172e277b # 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm)
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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
|
||||
+27
@@ -37,6 +37,10 @@ htmlcov/
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# JavaScript / Node.js
|
||||
js/node_modules/
|
||||
js/dist/
|
||||
|
||||
# Distribution
|
||||
*.tar.gz
|
||||
*.whl
|
||||
@@ -45,3 +49,26 @@ AGENTS.md
|
||||
|
||||
# Private docs (launch posts, strategy)
|
||||
docs/
|
||||
|
||||
# Internal test infrastructure (Docker, VPS-specific)
|
||||
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
|
||||
|
||||
# Beads / Dolt files (added by bd init)
|
||||
.dolt/
|
||||
*.db
|
||||
.beads-credential-key
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# CloakBrowser Binary License
|
||||
|
||||
**Version 1.0 — February 2026**
|
||||
|
||||
Copyright (c) 2026 CloakHQ. All rights reserved.
|
||||
|
||||
This license applies to the compiled CloakBrowser Chromium binary ("Binary") distributed via GitHub Releases and cloakbrowser.dev. It does **not** apply to the wrapper source code in this repository, which is licensed under the [MIT License](LICENSE).
|
||||
|
||||
By downloading, installing, or using the Binary, you agree to be bound by the terms of this license.
|
||||
|
||||
## Intellectual Property
|
||||
|
||||
The Binary is built on Chromium, which is open-source software by The Chromium Authors under the BSD 3-Clause License, and incorporates components from the open-source ungoogled-chromium project. CloakHQ's build configuration, patches, and the Binary as a combined work are the proprietary property of CloakHQ. This license governs the Binary as distributed by CloakHQ — it does not restrict rights granted by upstream open-source licenses to their respective components.
|
||||
|
||||
## Grant of Use
|
||||
|
||||
You are granted a non-exclusive, non-transferable, royalty-free license to use the Binary for personal or commercial purposes. No fees are required.
|
||||
|
||||
## Restrictions
|
||||
|
||||
You may NOT:
|
||||
|
||||
1. **Redistribute** the Binary, in whole or in part, whether modified or unmodified
|
||||
2. **Resell, sublicense, or repackage** the Binary, or include it in any product or service distributed to third parties
|
||||
3. **Reverse engineer, decompile, or disassemble** the Binary, or attempt to derive source code from it, except to the extent permitted by applicable law
|
||||
4. **Modify** the Binary or create derivative works based on it
|
||||
5. **Remove or alter** any copyright notices, license files, or attribution included with the Binary
|
||||
|
||||
Normal use of the Binary with command-line flags, browser extensions, managed policies, custom profiles, or user data directories does not constitute modification or creation of derivative works.
|
||||
|
||||
## Cloud, Container & Integration Use
|
||||
|
||||
**Internal use** — You may store and run the unmodified Binary within internal infrastructure, including Docker images, VM templates, CI runners, container registries, and artifact repositories (e.g., Artifactory, Nexus), solely for your organization's internal operational purposes.
|
||||
|
||||
**Dependency listing** — Listing CloakBrowser as a dependency in your project or third-party framework (e.g., in `requirements.txt`, `package.json`, or documentation) is not redistribution, as end users download the Binary directly from official CloakHQ channels. No commercial license is required for this.
|
||||
|
||||
**Using CloakBrowser for your own business is free** — no license beyond this one is needed, regardless of company size or revenue.
|
||||
|
||||
**OEM/SaaS license required** — Bundling, embedding, or pre-installing the Binary into a product, hosted service, or cloud artifact distributed to third parties requires a separate OEM license. This includes running the Binary on your infrastructure to serve third-party customers (e.g., browser-as-a-service). Contact cloakhq@pm.me for OEM/SaaS licensing.
|
||||
|
||||
## Official Distribution
|
||||
|
||||
The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases (github.com/CloakHQ/CloakBrowser) and cloakbrowser.dev. Internal organizational mirrors permitted under the Cloud, Container & Integration Use section are not considered unauthorized sources.
|
||||
|
||||
## Trademark Notice
|
||||
|
||||
This license does not grant you any right to use the CloakHQ or CloakBrowser name, logo, or trademarks, except for nominative use reasonably necessary to refer to CloakHQ or CloakBrowser.
|
||||
|
||||
## Attribution
|
||||
|
||||
Attribution is appreciated but not required. If you'd like to credit CloakBrowser, a "Powered by CloakBrowser" notice with a link to https://github.com/CloakHQ/CloakBrowser in your documentation, README, or about page is welcome.
|
||||
|
||||
## Acceptable Use
|
||||
|
||||
You are solely responsible for how you use the Binary. You agree NOT to use the Binary for any activity that violates applicable laws or regulations in your jurisdiction. CloakHQ does not endorse, encourage, or support any illegal use.
|
||||
|
||||
Without limiting the above, the following uses are expressly prohibited:
|
||||
|
||||
- Unauthorized access to financial, banking, healthcare, or government authentication systems
|
||||
- Credential stuffing, brute-force login attempts, or automated account creation
|
||||
- Circumventing authentication on systems you do not own or have authorization to test
|
||||
- Any activity that constitutes fraud, identity theft, or unauthorized data collection
|
||||
|
||||
## Indemnification
|
||||
|
||||
You agree to indemnify and hold harmless CloakHQ and its contributors from any claims, damages, losses, liabilities, and expenses (including reasonable legal fees) arising from your unlawful use of the Binary or your violation of this license.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
THE BINARY IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE BINARY OR THE USE OR OTHER DEALINGS IN THE BINARY.
|
||||
|
||||
## Limitation of Liability
|
||||
|
||||
IN NO EVENT SHALL CLOAKHQ OR ITS CONTRIBUTORS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, BUSINESS OPPORTUNITIES, OR GOODWILL, ARISING OUT OF OR IN CONNECTION WITH THE USE OF THE BINARY, REGARDLESS OF THE THEORY OF LIABILITY. CLOAKHQ'S TOTAL AGGREGATE LIABILITY SHALL NOT EXCEED ONE HUNDRED US DOLLARS (US $100).
|
||||
|
||||
## Data Collection
|
||||
|
||||
CloakHQ does not intentionally include telemetry, analytics, or tracking mechanisms in the Binary. The Binary is built on ungoogled-chromium, which removes Google-specific services and telemetry. Any network activity may result from normal browser operation, Chromium subsystems, user configuration, extensions, or the web pages and services you access, and not from any telemetry or analytics service operated by CloakHQ.
|
||||
|
||||
## Updates
|
||||
|
||||
CloakHQ is under no obligation to provide updates, patches, new versions, or support for the Binary. Updates, when provided, are subject to the terms of this license.
|
||||
|
||||
## Termination
|
||||
|
||||
This license terminates automatically if you violate any of its terms. Upon termination, you must destroy all copies of the Binary in your possession. The Intellectual Property, Restrictions, Trademark Notice, Indemnification, Disclaimer, Governing Law, Reservation of Rights, Entire Agreement, No Waiver, Assignment, and Severability sections survive termination.
|
||||
|
||||
## Governing Law
|
||||
|
||||
This license is governed by the laws of the jurisdiction in which CloakHQ is established. Any disputes arising under this license shall be subject to the exclusive jurisdiction of the courts in that jurisdiction.
|
||||
|
||||
## Reservation of Rights
|
||||
|
||||
All rights not expressly granted under this license are reserved by CloakHQ.
|
||||
|
||||
## Entire Agreement
|
||||
|
||||
This license constitutes the entire agreement between you and CloakHQ regarding the Binary and supersedes any prior or contemporaneous understandings relating to the Binary.
|
||||
|
||||
## No Waiver
|
||||
|
||||
Failure by CloakHQ to enforce any provision of this license does not constitute a waiver of that provision or any other provision.
|
||||
|
||||
## Assignment
|
||||
|
||||
You may not assign or transfer this license or any rights under it without prior written consent from CloakHQ.
|
||||
|
||||
## Severability
|
||||
|
||||
If any provision of this license is held to be unenforceable or invalid, that provision shall be modified to the minimum extent necessary to make it enforceable, and all remaining provisions shall continue in full force and effect.
|
||||
|
||||
## Contact
|
||||
|
||||
For licensing inquiries, including redistribution or OEM licensing, contact cloakhq@pm.me.
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to CloakBrowser — wrapper and binary — are documented here.
|
||||
|
||||
Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromium patches.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.26] — 2026-04-28
|
||||
|
||||
- **[binary]** Windows x64 upgraded to Chromium 146.0.7680.177.4 — 57 source-level fingerprint patches (up from 33 on 145.0.7632.159.7), now matches Linux. Includes all binary improvements from 0.3.18–0.3.25: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3), WebRTC IP spoofing, proxy signal removal, CDP input stealth, storage quota normalization, WebAuthn/AAC/window position patches, WebGL and canvas consistency fixes, expanded GPU model database
|
||||
- **[wrapper]** Auto URL-encode SOCKS5 credentials containing special characters in string URLs (#157)
|
||||
- **[wrapper]** AWS Lambda integration example with cold-start hardening and handler-side retry orchestration (#177, thanks [@AlexTech314](https://github.com/AlexTech314))
|
||||
- **[docker]** Add emoji and extended font packages to resolve Kasada/Akamai canvas fingerprint blocks (#179)
|
||||
- **[docs]** Add Font Setup on Linux section to README (#179)
|
||||
- **[docs]** Add Deployment Integrations section to README (#177)
|
||||
- **[meta]** Bump GitHub Actions dependencies (#178)
|
||||
|
||||
## [0.3.25] — 2026-04-16
|
||||
|
||||
- **[wrapper]** Python: add `launch_context_async()` — async counterpart to `launch_context()`. Returns a BrowserContext with all kwargs forwarded to `browser.new_context()`, enabling `storage_state`, `permissions`, `extra_http_headers`, etc. without a persistent profile folder. Closes #141.
|
||||
- **[wrapper]** JS: `launchContext()` and `launchPersistentContext()` silently dropped unknown options (including `storageState`). New `contextOptions` escape hatch forwards arbitrary options to Playwright's `newContext()`.
|
||||
- **[wrapper]** Fix `humanConfig` TypeScript typing (#151).
|
||||
- **[binary]** New build 146.0.7680.177.3 for Linux x64 + arm64 — 57 source-level fingerprint patches (up from 49): WebAuthn capabilities, AAC audio encoder, and window position spoofing; WebGL and canvas format consistency fixes; SOCKS5 warm connection pool auth fix for credentialed proxies.
|
||||
- **[docs]** Add recommended anti-bot config and SOCKS5 tips to troubleshooting.
|
||||
|
||||
## [0.3.24] — 2026-04-10
|
||||
|
||||
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
|
||||
- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@evelaa123](https://github.com/evelaa123), #133)
|
||||
- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64
|
||||
- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5)
|
||||
- **[docs]** Clarify humanize requires wrapper import over CDP (#126)
|
||||
|
||||
## [0.3.23] — 2026-04-09
|
||||
|
||||
- **[wrapper]** Add full Puppeteer humanize support — human-like mouse, keyboard, and scroll behavior for `puppeteer-core` users (thanks [@evelaa123](https://github.com/evelaa123), #129)
|
||||
- **[wrapper]** Fix Playwright humanize gaps — `pressSequentially`, `tap`, `clear` on pages and frames now use human-like behavior (#129)
|
||||
- **[wrapper]** Expose humanize module for CDP-connected browsers — `import from 'cloakbrowser/human'` for manual patching of external Playwright instances (#126)
|
||||
- **[docker]** Fix `cloakserve` locale/timezone mismatch — CLI args now route through `build_args()` so the companion `--lang` flag is added automatically (#130)
|
||||
- **[meta]** Use Node 24 in CI publish workflow to work around broken npm in Node 22.22.2
|
||||
|
||||
## [0.3.22] — 2026-04-09
|
||||
|
||||
- **[binary]** Upgrade Linux x64 build to Chromium 146.0.7680.177.1 — 49 source-level C++ patches (up from 48), rebased from 145.0.7632.x
|
||||
|
||||
## [0.3.21] — 2026-04-07
|
||||
|
||||
- **[wrapper]** Remove dead `--disable-blink-features=AutomationControlled` flag -- binary patch 009 already handles `navigator.webdriver` at source level
|
||||
- **[wrapper]** Remove hardcoded GPU vendor/renderer flags -- binary auto-generates diverse, realistic GPU profiles from the fingerprint seed. Each seed gets a unique GPU instead of every user sharing the same one
|
||||
- **[wrapper]** Allow `viewport=None` to disable viewport emulation in both Python and JS wrappers (thanks [@kitiho](https://github.com/kitiho), #107)
|
||||
- **[wrapper]** Enable `geoip=True` in stealth test example to fix FingerprintJS detection
|
||||
- **[meta]** Remove npm self-upgrade step in CI -- Node 22 ships with compatible npm
|
||||
- **[docker]** Install `geoip2` in Docker image for GeoIP auto-detection support
|
||||
|
||||
## [0.3.20] — 2026-04-06
|
||||
|
||||
- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.9 — 48 source-level C++ patches (up from 42)
|
||||
- **[binary]** 6 new patches: WebRTC IP spoofing, proxy signal removal, network timing normalization, WebGL accuracy improvements
|
||||
- **[binary]** New `--fingerprint-webrtc-ip` flag — spoof WebRTC ICE candidate IPs to match your proxy exit IP
|
||||
- **[binary]** Proxy detection signals eliminated — timing, headers, and network metadata normalized when proxy is active
|
||||
- **[binary]** WebGL rendering accuracy improvements for headed mode
|
||||
- **[wrapper]** Auto-inject `--fingerprint-webrtc-ip` when `geoip=True` — uses resolved exit IP from GeoIP lookup
|
||||
- **[wrapper]** Rewrite `cloakserve` as CDP multiplexer with per-connection fingerprint seeds and connection tracking
|
||||
- **[wrapper]** Humanize keyboard improvements — better behavioral stealth for typing interactions (thanks [@evelaa123](https://github.com/evelaa123))
|
||||
- **[meta]** Bump GitHub Actions dependencies
|
||||
|
||||
## [0.3.19] — 2026-03-30
|
||||
|
||||
- **[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
|
||||
- **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints
|
||||
- **[wrapper]** Default Playwright backend switched from `patchright` to stock `playwright`. Patchright broke proxy auth and `add_init_script` (#27) and is redundant since the binary handles stealth at C++ level. Opt in with `launch(backend="patchright")` or `CLOAKBROWSER_BACKEND=patchright` env var. Install: `pip install cloakbrowser[patchright]`
|
||||
- **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium
|
||||
- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var
|
||||
|
||||
## [0.3.7] — 2026-03-05
|
||||
|
||||
- **[wrapper]** Unify timezone parameter: rename `timezone_id` to `timezone` in `launch_context()`, `launch_persistent_context()`, and `launch_persistent_context_async()` (Python). Old `timezone_id` still works with a deprecation warning. JS: deprecate `timezoneId` on `LaunchContextOptions` — use `timezone` (inherited from `LaunchOptions`)
|
||||
- **[wrapper]** Docker Hub image (`cloakhq/cloakbrowser`) — pre-built with Python + JS wrappers, Xvfb for headed mode, and `cloaktest` CLI shortcut. One-liner: `docker run --rm cloakhq/cloakbrowser cloaktest`
|
||||
- **[wrapper]** Add "Launching stealth browser..." feedback to all examples for better UX in Docker/CI
|
||||
- **[wrapper]** Comprehensive unit tests: 169 Python + 88 JS (up from 59 + 47)
|
||||
- **[docs]** Streamline READMEs for launch — reorder for conversion, collapse fingerprint flags, update Docker section
|
||||
|
||||
## [0.3.6] — 2026-03-04
|
||||
|
||||
- **[wrapper]** `proxy` parameter now accepts a Playwright proxy dict (`{server, bypass, username, password}`) in addition to URL strings — enables bypass lists and separate auth fields (PR #24). **TS note:** type changed from `string` to `string | object` — code that assumed `proxy` is always a string may need a `typeof` narrowing check
|
||||
|
||||
## [0.3.5] — 2026-03-04
|
||||
|
||||
- **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17)
|
||||
- **[wrapper]** Add `launchPersistentContext()` (JS/TS) — same feature for JavaScript with full type support
|
||||
- **[wrapper]** Fix Windows zip extraction failure when primary download server is down — file handle leak caused `ERROR_SHARING_VIOLATION` on fallback download (thanks [@evelaa123](https://github.com/evelaa123) — PR #23)
|
||||
|
||||
## [0.3.4] — 2026-03-04
|
||||
|
||||
Binary v14: auto-spoof restored with seed, wrapper simplified to match.
|
||||
|
||||
- **[binary]** Restore full auto-spoof when `--fingerprint=seed` is set — all randomized properties now derive from the seed consistently
|
||||
- **[binary]** Auto-inject random fingerprint seed at startup if none provided. Binary is stealthy with zero flags
|
||||
- **[binary]** 26 source-level C++ patches (up from 25)
|
||||
- **[wrapper]** Simplify default stealth args — remove flags the binary now auto-generates. Wrapper still sets platform profile on Linux and `--no-sandbox`
|
||||
- **[wrapper]** Fix timezone in `launch_context()` — use Playwright's per-context timezone instead of binary flag, fixing mismatch when creating new browser contexts with geoip
|
||||
- **[wrapper]** Clarify README platform detection behavior
|
||||
|
||||
## [0.3.3] — 2026-03-03
|
||||
|
||||
All platforms now run Chromium 145 v2 with 25 patches. Windows x64 added.
|
||||
|
||||
- **[binary]** Auto-spoof by default — binary is stealthy with zero flags. Random fingerprint seed auto-generated at startup, no wrapper or configuration required
|
||||
- **[binary]** Platform-aware auto-detection — GPU, screen dimensions, and User-Agent automatically match the real OS (macOS, Linux, Windows) without explicit flags
|
||||
- **[binary]** Expanded GPU model database for realistic per-session diversity
|
||||
- **[binary]** First macOS v145 builds (arm64 + x64) — 25 patches, up from 16 on v142
|
||||
- **[binary]** First Windows x64 v145 build — 25 patches
|
||||
- **[wrapper]** Add Windows x64 platform support — auto-download, binary path resolution, and platform detection
|
||||
- **[wrapper]** Upgrade macOS (arm64 + x64) from Chromium 142 to 145 — all platforms now ship the same 25-patch build
|
||||
- **[wrapper]** Add explicit Mac GPU flags (`Apple M3 Metal` renderer) to default stealth args for consistent WebGL fingerprints
|
||||
- **[wrapper]** Improve reCAPTCHA stealth test — wait for score element instead of blind sleep
|
||||
- **[wrapper]** JS: add `win32-x64` platform mapping, Windows binary path (`chrome.exe`)
|
||||
|
||||
## [0.3.1] — 2026-03-03
|
||||
|
||||
- **[wrapper]** Auto-check for wrapper updates on startup (PyPI/npm). Notifies users when a newer wrapper version is available. Runs once per process, respects `CLOAKBROWSER_AUTO_UPDATE=false`.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] — 2026-03-02
|
||||
|
||||
Chromium v145 upgrade. 25 fingerprint patches (up from 16). New download verification and fallback system. macOS v145 binary builds pending.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **[wrapper]** Python dependency changed from `playwright` to `patchright` (CDP stealth fork). Patchright is API-compatible, but if you import `playwright` directly elsewhere, add it as a separate dependency. Replace `from playwright.sync_api` with `from patchright.sync_api` (or keep using `cloakbrowser.launch()` which handles this automatically).
|
||||
- **[wrapper]** `launch_context()` / `launchContext()` now defaults viewport to 1920×947 (realistic maximized Chrome on 1080p Windows with 48px taskbar) instead of Playwright's default 1280×720. Pass `viewport={"width": 1280, "height": 720}` explicitly to restore old behavior.
|
||||
|
||||
### 2026-03-02
|
||||
|
||||
- **[binary]** Full stealth audit — multiple detection vectors eliminated, improved cross-API consistency
|
||||
- **[binary]** Platform-aware fingerprint defaults: screen dimensions, taskbar, and layout auto-adjust per spoofed platform
|
||||
- **[binary]** Stability and performance improvements across fingerprint patches
|
||||
- **[binary]** New optional flags: `--fingerprint-fonts-dir`, `--fingerprint-taskbar-height`
|
||||
- **[wrapper]** Sync wrapper with latest binary changes: updated flag names, viewport, and defaults
|
||||
- **[wrapper]** Per-platform Chromium versioning — Linux and macOS can track different binary versions independently
|
||||
- **[wrapper]** Improved SHA-256 checksum verification and version marker migration
|
||||
|
||||
### 2026-03-01
|
||||
|
||||
- **[wrapper]** Upgrade wrapper to Chromium v145.0.7632.109
|
||||
- **[wrapper]** Add GitHub Releases fallback when primary download mirror is unavailable
|
||||
- **[wrapper]** Add SHA-256 checksum verification for binary downloads
|
||||
- **[wrapper]** Wire timezone and locale params to Chromium binary flags
|
||||
- **[wrapper]** Add device memory to default stealth args
|
||||
- **[wrapper]** JS: add `colorScheme` support, guard download fallback against partial failures
|
||||
|
||||
### 2026-02-28
|
||||
|
||||
- **[binary]** Enforce strict flag discipline — patches only activate when explicitly configured via command-line flags
|
||||
- **[binary]** Improved fingerprint consistency across multiple browser APIs
|
||||
- **[binary]** 3 new fingerprint patches + bug fixes in existing patches
|
||||
- **[binary]** New command-line flag for device memory spoofing
|
||||
- **[infra]** Automated test matrix: 8 groups, 41+ tests across core stealth, fingerprint noise, bot detection, reCAPTCHA, TLS, Turnstile, residential proxy, and enterprise reCAPTCHA
|
||||
- **[infra]** Docker-based test runner with subprocess isolation per test group
|
||||
|
||||
### 2026-02-25
|
||||
|
||||
- **[binary]** Reduced automation markers visible to detection scripts
|
||||
- **[binary]** Added browser API support at build time
|
||||
- **[binary]** Improved screen property consistency
|
||||
|
||||
### 2026-02-24
|
||||
|
||||
- **[binary]** Comprehensive fingerprint audit and hardening pass
|
||||
- **[binary]** Fixed font rendering edge case on cross-platform spoofing
|
||||
- **[binary]** 4 new fingerprint patches
|
||||
|
||||
### 2026-02-22
|
||||
|
||||
- **[binary]** Start Chromium v145 build (v145.0.7632.109)
|
||||
- **[binary]** 24 fingerprint patches ported and adapted
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2] — 2026-03-01
|
||||
|
||||
### 2026-03-01
|
||||
|
||||
- **[wrapper]** Fix: replace `page.wait_for_timeout()` with `time.sleep()` to avoid timing leak
|
||||
- **[wrapper]** Add auto-detect timezone and locale from proxy IP via GeoIP lookup
|
||||
- **[binary]** CDP detection vector audit and hardening
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-02-27
|
||||
|
||||
macOS platform release. JavaScript/TypeScript wrapper. Self-hosted binary mirror.
|
||||
|
||||
### 2026-02-27
|
||||
|
||||
- **[wrapper]** Add macOS support: Apple Silicon (arm64) and Intel (x64) binary downloads
|
||||
- **[wrapper]** Add GPG-signed release workflow via GitHub Actions
|
||||
- **[wrapper]** Fix macOS binary download: preserve `.app` symlinks, remove quarantine xattrs
|
||||
- **[wrapper]** Add real bot detection assertions to stealth tests
|
||||
- **[wrapper]** Bump version to 0.2.0
|
||||
|
||||
### 2026-02-26
|
||||
|
||||
- **[wrapper]** Switch binary downloads to self-hosted mirror (`cloakbrowser.dev`) as GitHub backup
|
||||
- **[wrapper]** Set up GitLab mirror at `gitlab.com/CloakHQ/cloakbrowser`
|
||||
|
||||
### 2026-02-25
|
||||
|
||||
- **[wrapper]** Move binary releases from separate repo to wrapper repo
|
||||
- **[wrapper]** Add auto-update check on launch
|
||||
- **[infra]** Initial Docker test infrastructure + matrix test runner
|
||||
|
||||
### 2026-02-24
|
||||
|
||||
- **[wrapper]** Add JavaScript/TypeScript wrapper with Playwright + Puppeteer support (`npm install cloakbrowser`)
|
||||
- **[wrapper]** Fix proxy authentication credentials support in URL (closes #4)
|
||||
|
||||
---
|
||||
|
||||
## [0.1.4] — 2026-02-23
|
||||
|
||||
### 2026-02-23
|
||||
|
||||
- **[wrapper]** Stealth hardening: additional launch args and detection evasion improvements
|
||||
- **[wrapper]** Full test suite rewrite with real detection site assertions
|
||||
- **[wrapper]** Add Docker support with Dockerfile and compose config
|
||||
- **[wrapper]** Add headed mode documentation
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2026-02-22
|
||||
|
||||
Initial release. Chromium v142 with 16 fingerprint patches.
|
||||
|
||||
### 2026-02-22
|
||||
|
||||
- **[binary]** Chromium v142.0.7444.175 with 16 source-level fingerprint patches
|
||||
- **[binary]** Fix browser brand string to match Chrome 142 format
|
||||
- **[wrapper]** `launch()` and `launch_async()` — drop-in Playwright replacements
|
||||
- **[wrapper]** Auto-download binary from GitHub Releases, cached in `~/.cloakbrowser/`
|
||||
- **[wrapper]** Linux x64 platform support
|
||||
- **[wrapper]** Passes 14/14 bot detection tests
|
||||
- **[wrapper]** reCAPTCHA v3: 0.9 (server-verified), Cloudflare Turnstile: pass
|
||||
+37
-5
@@ -1,21 +1,53 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Playwright system deps
|
||||
# Chromium system deps + Node.js
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
|
||||
libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \
|
||||
libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
|
||||
libcairo2 libasound2 libx11-xcb1 \
|
||||
libcairo2 libasound2 libx11-xcb1 libfontconfig1 libx11-6 \
|
||||
libxcb1 libxext6 libxshmfence1 \
|
||||
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
|
||||
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
|
||||
fonts-noto-color-emoji fonts-unifont fonts-freefont-ttf \
|
||||
fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf \
|
||||
xvfb xdotool \
|
||||
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/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
# Python wrapper
|
||||
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
|
||||
COPY cloakbrowser/ cloakbrowser/
|
||||
RUN pip install --no-cache-dir ".[serve,geoip]"
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
# JS wrapper
|
||||
COPY js/ js/
|
||||
RUN cd js && npm install && npm run build
|
||||
|
||||
# Examples
|
||||
COPY examples/ examples/
|
||||
|
||||
# Pre-download stealth Chromium binary during build (not at runtime)
|
||||
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()"
|
||||
# Remove welcome marker so users see it on first container run
|
||||
RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
|
||||
&& rm -f ~/.cloakbrowser/.welcome_shown
|
||||
|
||||
# CLI shortcuts
|
||||
COPY bin/cloaktest /usr/local/bin/cloaktest
|
||||
COPY bin/cloakserve /usr/local/bin/cloakserve
|
||||
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve
|
||||
|
||||
EXPOSE 9222
|
||||
|
||||
# Xvfb entrypoint for headed mode support
|
||||
COPY bin/docker-entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENV DISPLAY=:99
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["python"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 cloakbrowser
|
||||
Copyright (c) 2026 CloakHQ
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
Executable
+669
@@ -0,0 +1,669 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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 # default, backward compat
|
||||
cloakserve --port=9222 # custom port
|
||||
|
||||
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 random
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import websockets
|
||||
from aiohttp import web
|
||||
|
||||
from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args, _normalize_socks_string_url
|
||||
from cloakbrowser.download import ensure_binary
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("cloakserve")
|
||||
|
||||
# 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",
|
||||
"--disable-extensions",
|
||||
"--disable-popup-blocking",
|
||||
"--disable-background-networking",
|
||||
"--metrics-recording-only",
|
||||
"--ignore-gpu-blocklist",
|
||||
]
|
||||
|
||||
BASE_CDP_PORT = 5100
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChromePool — manages multiple Chrome processes keyed by seed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChromePool:
|
||||
def __init__(
|
||||
self,
|
||||
binary: str,
|
||||
global_args: list[str],
|
||||
headless: bool,
|
||||
data_dir: str = "/tmp/cloakserve",
|
||||
default_seed: str | None = None,
|
||||
default_locale: str | None = None,
|
||||
default_timezone: str | None = None,
|
||||
):
|
||||
self._binary = binary
|
||||
self._global_args = global_args
|
||||
self._headless = headless
|
||||
self._data_dir = data_dir
|
||||
self._default_seed = default_seed
|
||||
self._default_locale = default_locale
|
||||
self._default_timezone = default_timezone
|
||||
self._processes: dict[str, ChromeProcess] = {}
|
||||
self._default: ChromeProcess | None = None
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
self._next_port = BASE_CDP_PORT
|
||||
# Connection refcounting for status reporting
|
||||
self._connections: dict[str, int] = {}
|
||||
|
||||
def _get_lock(self, seed: str) -> asyncio.Lock:
|
||||
if seed not in self._locks:
|
||||
self._locks[seed] = asyncio.Lock()
|
||||
return self._locks[seed]
|
||||
|
||||
def _allocate_port(self) -> int:
|
||||
"""Find a free port starting from _next_port."""
|
||||
for _ in range(100):
|
||||
port = self._next_port
|
||||
self._next_port += 1
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError("No free ports available for Chrome CDP")
|
||||
|
||||
def connect(self, seed_key: str) -> None:
|
||||
"""Increment connection refcount for a seed."""
|
||||
self._connections[seed_key] = self._connections.get(seed_key, 0) + 1
|
||||
|
||||
def disconnect(self, seed_key: str) -> None:
|
||||
"""Decrement connection refcount for a seed."""
|
||||
count = self._connections.get(seed_key, 0) - 1
|
||||
if count <= 0:
|
||||
self._connections.pop(seed_key, None)
|
||||
else:
|
||||
self._connections[seed_key] = count
|
||||
|
||||
async def get_or_launch(
|
||||
self,
|
||||
seed: str | None,
|
||||
extra_args: list[str] | None = None,
|
||||
timezone: str | None = None,
|
||||
locale: str | None = None,
|
||||
proxy: str | None = None,
|
||||
geoip: bool = False,
|
||||
) -> ChromeProcess:
|
||||
"""Get existing or launch new Chrome process for a seed."""
|
||||
# Apply CLI defaults when query params don't provide values
|
||||
if seed is None and self._default_seed:
|
||||
seed = self._default_seed
|
||||
if locale is None:
|
||||
locale = self._default_locale
|
||||
if timezone is None:
|
||||
timezone = self._default_timezone
|
||||
|
||||
# No seed = default shared process
|
||||
if seed is None:
|
||||
seed_key = "__default__"
|
||||
actual_seed = str(random.randint(10000, 99999))
|
||||
else:
|
||||
seed_key = seed
|
||||
actual_seed = seed
|
||||
|
||||
lock = self._get_lock(seed_key)
|
||||
async with lock:
|
||||
# Check if already running (including default fast-path)
|
||||
if seed_key in self._processes:
|
||||
proc = self._processes[seed_key]
|
||||
if proc.process.poll() is None:
|
||||
if any([extra_args, timezone, locale, proxy, geoip]):
|
||||
logger.warning(
|
||||
"Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — "
|
||||
"ignoring new params (first-launch wins)",
|
||||
seed_key, proc.cdp_port,
|
||||
proc.timezone, proc.locale, proc.proxy,
|
||||
)
|
||||
return proc
|
||||
# Dead — clean up
|
||||
await self._cleanup_process(seed_key)
|
||||
|
||||
# Resolve geoip if requested
|
||||
exit_ip = None
|
||||
if geoip and proxy:
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(True, proxy, timezone, locale)
|
||||
|
||||
# Build Chrome args via shared logic
|
||||
fp_extra = [f"--fingerprint={actual_seed}"]
|
||||
if extra_args:
|
||||
fp_extra.extend(extra_args)
|
||||
if proxy:
|
||||
fp_extra.append(f"--proxy-server={_normalize_socks_string_url(proxy)}")
|
||||
|
||||
# WebRTC IP spoofing: resolve auto, inject geoip exit IP
|
||||
fp_extra = _resolve_webrtc_args(fp_extra, proxy)
|
||||
if exit_ip and not any(a.startswith("--fingerprint-webrtc-ip") for a in (fp_extra or [])):
|
||||
fp_extra = list(fp_extra or [])
|
||||
fp_extra.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||
|
||||
chrome_args = build_args(
|
||||
stealth_args=True,
|
||||
extra_args=fp_extra,
|
||||
timezone=timezone,
|
||||
locale=locale,
|
||||
headless=self._headless,
|
||||
)
|
||||
|
||||
# Allocate port and user data dir
|
||||
port = self._allocate_port()
|
||||
user_data_dir = os.path.join(self._data_dir, seed_key)
|
||||
os.makedirs(user_data_dir, exist_ok=True)
|
||||
|
||||
full_args = (
|
||||
[self._binary]
|
||||
+ BASE_CHROME_ARGS
|
||||
+ chrome_args
|
||||
+ self._global_args
|
||||
+ [
|
||||
f"--remote-debugging-port={port}",
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
f"--user-data-dir={user_data_dir}",
|
||||
]
|
||||
)
|
||||
|
||||
logger.info("Launching Chrome (seed=%s, port=%d)", actual_seed, port)
|
||||
process = subprocess.Popen(
|
||||
full_args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# Wait for CDP to be ready
|
||||
if not await self._wait_for_cdp(port):
|
||||
process.kill()
|
||||
await asyncio.to_thread(process.wait, timeout=5)
|
||||
await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
|
||||
raise web.HTTPBadGateway(
|
||||
text=json.dumps({"error": "Chrome failed to start"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
cp = ChromeProcess(
|
||||
seed=actual_seed,
|
||||
process=process,
|
||||
cdp_port=port,
|
||||
user_data_dir=user_data_dir,
|
||||
timezone=timezone,
|
||||
locale=locale,
|
||||
proxy=proxy,
|
||||
)
|
||||
self._processes[seed_key] = cp
|
||||
|
||||
if seed is None:
|
||||
self._default = cp
|
||||
|
||||
logger.info("Chrome ready (seed=%s, port=%d, pid=%d)", actual_seed, port, process.pid)
|
||||
return cp
|
||||
|
||||
async def _cleanup_process(self, key: str) -> None:
|
||||
"""Terminate a Chrome process and clean up."""
|
||||
proc = self._processes.pop(key, None)
|
||||
if not proc:
|
||||
return
|
||||
if proc.process.poll() is None:
|
||||
proc.process.terminate()
|
||||
try:
|
||||
await asyncio.to_thread(proc.process.wait, timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.process.kill()
|
||||
# Clean up user data dir (can be slow for large profiles)
|
||||
await asyncio.to_thread(shutil.rmtree, proc.user_data_dir, True)
|
||||
if self._default is proc:
|
||||
self._default = None
|
||||
self._locks.pop(key, None)
|
||||
self._connections.pop(key, None)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Terminate all Chrome processes."""
|
||||
for key in list(self._processes.keys()):
|
||||
await self._cleanup_process(key)
|
||||
logger.info("All Chrome processes terminated")
|
||||
|
||||
@staticmethod
|
||||
async def _wait_for_cdp(port: int, timeout: float = 10.0) -> bool:
|
||||
"""Poll Chrome's /json/version until ready."""
|
||||
deadline = time.monotonic() + timeout
|
||||
delay = 0.1
|
||||
session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=1)
|
||||
)
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
async with session.get(
|
||||
f"http://127.0.0.1:{port}/json/version"
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 1.0)
|
||||
return False
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query param parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Params that need special handling (not simple --fingerprint-{name}= mapping)
|
||||
SPECIAL_PARAMS = {"fingerprint", "proxy", "geoip", "locale", "timezone"}
|
||||
|
||||
|
||||
def parse_connection_params(query_string: str) -> dict:
|
||||
"""Parse query params into connection config."""
|
||||
qs = parse_qs(query_string, keep_blank_values=False)
|
||||
|
||||
result: dict = {
|
||||
"seed": None,
|
||||
"timezone": None,
|
||||
"locale": None,
|
||||
"proxy": None,
|
||||
"geoip": False,
|
||||
"extra_args": [],
|
||||
}
|
||||
|
||||
for key, values in qs.items():
|
||||
val = values[0]
|
||||
if key == "fingerprint":
|
||||
result["seed"] = val
|
||||
elif key == "timezone":
|
||||
result["timezone"] = val
|
||||
elif key == "locale":
|
||||
result["locale"] = val
|
||||
elif key == "proxy":
|
||||
result["proxy"] = val
|
||||
elif key == "geoip":
|
||||
result["geoip"] = val.lower() in ("true", "1", "yes")
|
||||
elif key not in SPECIAL_PARAMS:
|
||||
# Generic fingerprint param: map to --fingerprint-{key}={val}
|
||||
result["extra_args"].append(f"--fingerprint-{key}={val}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ws_scheme(request: web.Request) -> str:
|
||||
"""Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'."""
|
||||
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||
return "wss" if proto == "https" else "ws"
|
||||
|
||||
|
||||
async def handle_root(request: web.Request) -> web.Response:
|
||||
"""Health check / process status."""
|
||||
pool: ChromePool = request.app["pool"]
|
||||
processes = {}
|
||||
for key, proc in pool._processes.items():
|
||||
if proc.process.poll() is None:
|
||||
processes[key] = {
|
||||
"pid": proc.process.pid,
|
||||
"port": proc.cdp_port,
|
||||
"seed": proc.seed,
|
||||
"connections": pool._connections.get(key, 0),
|
||||
"timezone": proc.timezone,
|
||||
"locale": proc.locale,
|
||||
"proxy": proc.proxy,
|
||||
}
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"active": len(processes),
|
||||
"processes": processes,
|
||||
})
|
||||
|
||||
|
||||
async def handle_json_version(request: web.Request) -> web.Response:
|
||||
"""Proxy /json/version with optional per-seed routing."""
|
||||
pool: ChromePool = request.app["pool"]
|
||||
params = parse_connection_params(request.query_string)
|
||||
|
||||
cp = await pool.get_or_launch(
|
||||
seed=params["seed"],
|
||||
extra_args=params["extra_args"] or None,
|
||||
timezone=params["timezone"],
|
||||
locale=params["locale"],
|
||||
proxy=params["proxy"],
|
||||
geoip=params["geoip"],
|
||||
)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"http://127.0.0.1:{cp.cdp_port}/json/version",
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
|
||||
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
||||
|
||||
# Rewrite webSocketDebuggerUrl to route through our multiplexer
|
||||
host = request.headers.get("Host", f"localhost:{request.app['port']}")
|
||||
seed_key = params["seed"]
|
||||
if seed_key:
|
||||
ws_path = f"fingerprint/{seed_key}/devtools/browser"
|
||||
else:
|
||||
ws_path = "devtools/browser"
|
||||
|
||||
# Extract the browser GUID from Chrome's original URL
|
||||
orig_ws = data.get("webSocketDebuggerUrl", "")
|
||||
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
|
||||
|
||||
scheme = _ws_scheme(request)
|
||||
data["webSocketDebuggerUrl"] = f"{scheme}://{host}/{ws_path}/{guid}"
|
||||
return web.json_response(data)
|
||||
|
||||
|
||||
async def handle_json_list(request: web.Request) -> web.Response:
|
||||
"""Proxy /json/list with per-seed routing. Rewrites all entries."""
|
||||
pool: ChromePool = request.app["pool"]
|
||||
params = parse_connection_params(request.query_string)
|
||||
|
||||
cp = await pool.get_or_launch(
|
||||
seed=params["seed"],
|
||||
extra_args=params["extra_args"] or None,
|
||||
timezone=params["timezone"],
|
||||
locale=params["locale"],
|
||||
proxy=params["proxy"],
|
||||
geoip=params["geoip"],
|
||||
)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"http://127.0.0.1:{cp.cdp_port}/json/list",
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc)
|
||||
return web.json_response({"error": "CDP endpoint unreachable"}, status=502)
|
||||
|
||||
host = request.headers.get("Host", f"localhost:{request.app['port']}")
|
||||
scheme = _ws_scheme(request)
|
||||
seed_key = params["seed"]
|
||||
|
||||
for entry in data:
|
||||
if "webSocketDebuggerUrl" in entry:
|
||||
ws_tail = entry["webSocketDebuggerUrl"].split("/devtools/")[-1]
|
||||
if seed_key:
|
||||
entry["webSocketDebuggerUrl"] = (
|
||||
f"{scheme}://{host}/fingerprint/{seed_key}/devtools/{ws_tail}"
|
||||
)
|
||||
else:
|
||||
entry["webSocketDebuggerUrl"] = f"{scheme}://{host}/devtools/{ws_tail}"
|
||||
|
||||
return web.json_response(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def proxy_cdp_websocket(
|
||||
client_ws: web.WebSocketResponse,
|
||||
target_url: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
"""Bidirectional WebSocket proxy between client and Chrome CDP."""
|
||||
try:
|
||||
async with websockets.connect(
|
||||
target_url, max_size=None, ping_interval=None, ping_timeout=None,
|
||||
) as cdp_ws:
|
||||
logger.info("%s: connected to %s", label, target_url)
|
||||
|
||||
async def client_to_cdp():
|
||||
try:
|
||||
async for msg in client_ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
await cdp_ws.send(msg.data)
|
||||
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||
await cdp_ws.send(msg.data)
|
||||
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("%s [c->cdp]: %s", label, exc)
|
||||
|
||||
async def cdp_to_client():
|
||||
try:
|
||||
async for msg in cdp_ws:
|
||||
if isinstance(msg, str):
|
||||
await client_ws.send_str(msg)
|
||||
else:
|
||||
await client_ws.send_bytes(msg)
|
||||
except Exception as exc:
|
||||
logger.debug("%s [cdp->c]: %s", label, exc)
|
||||
|
||||
c2d = asyncio.create_task(client_to_cdp(), name="c2d")
|
||||
d2c = asyncio.create_task(cdp_to_client(), name="d2c")
|
||||
done, pending = await asyncio.wait(
|
||||
[c2d, d2c], return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
logger.info("%s: disconnected", label)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("%s error: %s", label, exc)
|
||||
|
||||
|
||||
async def handle_ws_default(request: web.Request) -> web.WebSocketResponse:
|
||||
"""WebSocket proxy for default (no-seed) Chrome: /devtools/{type}/{guid}"""
|
||||
pool: ChromePool = request.app["pool"]
|
||||
path = request.match_info.get("path", "")
|
||||
|
||||
cp = await pool.get_or_launch(seed=None)
|
||||
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
|
||||
pool.connect("__default__")
|
||||
try:
|
||||
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
|
||||
await proxy_cdp_websocket(ws, target_url, f"CDP default [{path}]")
|
||||
finally:
|
||||
pool.disconnect("__default__")
|
||||
return ws
|
||||
|
||||
|
||||
async def handle_ws_seed(request: web.Request) -> web.WebSocketResponse:
|
||||
"""WebSocket proxy for seed-specific Chrome: /fingerprint/{seed}/devtools/{type}/{guid}"""
|
||||
pool: ChromePool = request.app["pool"]
|
||||
seed = request.match_info["seed"]
|
||||
path = request.match_info.get("path", "")
|
||||
|
||||
cp = await pool.get_or_launch(seed=seed)
|
||||
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
|
||||
pool.connect(seed)
|
||||
try:
|
||||
target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}"
|
||||
await proxy_cdp_websocket(ws, target_url, f"CDP seed={seed} [{path}]")
|
||||
finally:
|
||||
pool.disconnect(seed)
|
||||
return ws
|
||||
|
||||
|
||||
async def on_shutdown(app: web.Application) -> None:
|
||||
await app["pool"].shutdown()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI arg parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_data_dir() -> str:
|
||||
"""Smart default: Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
|
||||
if os.path.exists("/.dockerenv"):
|
||||
return "/tmp/cloakserve"
|
||||
return str(Path.home() / ".cloakbrowser" / "cloakserve")
|
||||
|
||||
|
||||
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
|
||||
"""Parse cloakserve-specific args, return (config, passthrough_args).
|
||||
|
||||
--fingerprint, --fingerprint-locale, and --fingerprint-timezone are
|
||||
extracted into config defaults so they route through build_args()
|
||||
(e.g. locale needs both --lang and --fingerprint-locale).
|
||||
Query-string params override these defaults per-connection.
|
||||
"""
|
||||
config: dict = {
|
||||
"port": 9222,
|
||||
"headless": True,
|
||||
"data_dir": None,
|
||||
"default_seed": None,
|
||||
"default_locale": None,
|
||||
"default_timezone": None,
|
||||
}
|
||||
passthrough = []
|
||||
# Flags consumed by cloakserve (not passed to Chrome)
|
||||
consumed_prefixes = (
|
||||
"--port=",
|
||||
"--data-dir=",
|
||||
"--remote-debugging-port=",
|
||||
"--remote-debugging-address=",
|
||||
)
|
||||
|
||||
for arg in argv:
|
||||
if arg.startswith("--port="):
|
||||
config["port"] = int(arg.split("=", 1)[1])
|
||||
elif arg.startswith("--data-dir="):
|
||||
config["data_dir"] = arg.split("=", 1)[1]
|
||||
elif arg == "--headless=false" or arg == "--headless=False":
|
||||
config["headless"] = False
|
||||
passthrough.append(arg)
|
||||
elif arg.startswith(consumed_prefixes):
|
||||
pass # Strip these silently
|
||||
# Route through build_args() so companion flags are set correctly
|
||||
elif arg.startswith("--fingerprint-locale="):
|
||||
config["default_locale"] = arg.split("=", 1)[1]
|
||||
elif arg.startswith("--fingerprint-timezone="):
|
||||
config["default_timezone"] = arg.split("=", 1)[1]
|
||||
elif arg.startswith("--fingerprint="):
|
||||
config["default_seed"] = arg.split("=", 1)[1]
|
||||
else:
|
||||
passthrough.append(arg)
|
||||
|
||||
if config["data_dir"] is None:
|
||||
config["data_dir"] = _default_data_dir()
|
||||
|
||||
return config, passthrough
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
binary = ensure_binary()
|
||||
config, global_args = parse_cli_args(sys.argv[1:])
|
||||
|
||||
pool = ChromePool(
|
||||
binary=binary,
|
||||
global_args=global_args,
|
||||
headless=config["headless"],
|
||||
data_dir=config["data_dir"],
|
||||
default_seed=config["default_seed"],
|
||||
default_locale=config["default_locale"],
|
||||
default_timezone=config["default_timezone"],
|
||||
)
|
||||
|
||||
app = web.Application()
|
||||
app["pool"] = pool
|
||||
app["port"] = config["port"]
|
||||
|
||||
# Routes
|
||||
app.router.add_get("/", handle_root)
|
||||
app.router.add_get("/json/version", handle_json_version)
|
||||
app.router.add_get("/json/version/", handle_json_version)
|
||||
app.router.add_get("/json/list", handle_json_list)
|
||||
app.router.add_get("/json/list/", handle_json_list)
|
||||
app.router.add_get("/json", handle_json_list)
|
||||
app.router.add_get("/json/", handle_json_list)
|
||||
|
||||
# WebSocket routes — seed-specific (must be before default to match first)
|
||||
app.router.add_get("/fingerprint/{seed}/devtools/{path:.+}", handle_ws_seed)
|
||||
# WebSocket routes — default (no seed)
|
||||
app.router.add_get("/devtools/{path:.+}", handle_ws_default)
|
||||
|
||||
app.on_shutdown.append(on_shutdown)
|
||||
|
||||
port = config["port"]
|
||||
logger.info("CloakBrowser CDP multiplexer starting on port %d", port)
|
||||
logger.info(
|
||||
"Connect: playwright.chromium.connect_over_cdp("
|
||||
"\"http://localhost:%d?fingerprint=<seed>\")",
|
||||
port,
|
||||
)
|
||||
|
||||
web.run_app(app, host="0.0.0.0", port=port, print=None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Run CloakBrowser stealth test suite
|
||||
exec python -u /app/examples/stealth_test.py --no-screenshots "$@"
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
|
||||
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
||||
sleep 1
|
||||
exec "$@"
|
||||
@@ -11,19 +11,41 @@ Usage:
|
||||
browser.close()
|
||||
"""
|
||||
|
||||
from .browser import launch, launch_async, launch_context
|
||||
from .config import CHROMIUM_VERSION, DEFAULT_STEALTH_ARGS
|
||||
from .download import binary_info, clear_cache, ensure_binary
|
||||
from .browser import launch, launch_async, launch_context, launch_context_async, 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",
|
||||
"launch_context",
|
||||
"launch_context_async",
|
||||
"launch_persistent_context",
|
||||
"launch_persistent_context_async",
|
||||
"ensure_binary",
|
||||
"clear_cache",
|
||||
"binary_info",
|
||||
"check_for_update",
|
||||
"CHROMIUM_VERSION",
|
||||
"DEFAULT_STEALTH_ARGS",
|
||||
"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.1.0"
|
||||
__version__ = "0.3.26"
|
||||
|
||||
+881
-40
File diff suppressed because it is too large
Load Diff
+149
-22
@@ -4,29 +4,69 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
from ._version import __version__
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chromium version shipped with this release
|
||||
# Chromium version shipped with this release.
|
||||
# Different platforms may ship different versions during transition periods.
|
||||
# CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
# Use get_chromium_version() for the current platform's actual version.
|
||||
# ---------------------------------------------------------------------------
|
||||
CHROMIUM_VERSION = "142.0.7444.175"
|
||||
CHROMIUM_VERSION = "146.0.7680.177.3"
|
||||
|
||||
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
|
||||
"linux-x64": "146.0.7680.177.3",
|
||||
"linux-arm64": "146.0.7680.177.3",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "146.0.7680.177.4",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_STEALTH_ARGS: list[str] = [
|
||||
"--no-sandbox",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
# Fingerprint overrides (activate compiled C++ patches)
|
||||
"--fingerprint=98765",
|
||||
"--fingerprint-platform=windows",
|
||||
"--fingerprint-hardware-concurrency=8",
|
||||
"--fingerprint-gpu-vendor=NVIDIA Corporation",
|
||||
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 4070",
|
||||
]
|
||||
def get_default_stealth_args() -> list[str]:
|
||||
"""Build stealth args with a random fingerprint seed per launch.
|
||||
|
||||
On macOS, skips platform/GPU spoofing — runs as a native Mac browser.
|
||||
Spoofing Windows on Mac creates detectable mismatches (fonts, GPU, etc.).
|
||||
"""
|
||||
seed = random.randint(10000, 99999)
|
||||
system = platform.system()
|
||||
|
||||
base = [
|
||||
"--no-sandbox",
|
||||
f"--fingerprint={seed}",
|
||||
]
|
||||
|
||||
if system == "Darwin":
|
||||
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||
return base + ["--fingerprint-platform=macos"]
|
||||
|
||||
# Linux/Windows: Windows fingerprint profile
|
||||
# Hardware concurrency, device memory, screen, window size, and GPU are
|
||||
# auto-generated by the binary from the seed (v14+).
|
||||
return base + ["--fingerprint-platform=windows"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default viewport — realistic maximized Chrome on 1080p Windows
|
||||
# screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
||||
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform detection
|
||||
@@ -36,8 +76,19 @@ SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = {
|
||||
("Linux", "aarch64"): "linux-arm64",
|
||||
("Darwin", "arm64"): "darwin-arm64",
|
||||
("Darwin", "x86_64"): "darwin-x64",
|
||||
("Windows", "AMD64"): "windows-x64",
|
||||
("Windows", "x86_64"): "windows-x64",
|
||||
}
|
||||
|
||||
# Platforms with pre-built binaries available for download (derived from version map).
|
||||
AVAILABLE_PLATFORMS: set[str] = set(PLATFORM_CHROMIUM_VERSIONS.keys())
|
||||
|
||||
|
||||
def get_chromium_version() -> str:
|
||||
"""Return the Chromium version for the current platform."""
|
||||
tag = get_platform_tag()
|
||||
return PLATFORM_CHROMIUM_VERSIONS.get(tag, CHROMIUM_VERSION)
|
||||
|
||||
|
||||
def get_platform_tag() -> str:
|
||||
"""Return the platform tag for binary download (e.g. 'linux-x64', 'darwin-arm64')."""
|
||||
@@ -67,37 +118,113 @@ def get_cache_dir() -> Path:
|
||||
return Path.home() / ".cloakbrowser"
|
||||
|
||||
|
||||
def get_binary_dir() -> Path:
|
||||
"""Return the directory for the current Chromium version binary."""
|
||||
return get_cache_dir() / f"chromium-{CHROMIUM_VERSION}"
|
||||
def get_binary_dir(version: str | None = None) -> Path:
|
||||
"""Return the directory for a Chromium version binary."""
|
||||
v = version or get_chromium_version()
|
||||
return get_cache_dir() / f"chromium-{v}"
|
||||
|
||||
|
||||
def get_binary_path() -> Path:
|
||||
def get_binary_path(version: str | None = None) -> Path:
|
||||
"""Return the expected path to the chrome executable."""
|
||||
platform_tag = get_platform_tag()
|
||||
binary_dir = get_binary_dir()
|
||||
binary_dir = get_binary_dir(version)
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
# macOS: Chromium.app bundle
|
||||
return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium"
|
||||
elif platform.system() == "Windows":
|
||||
return binary_dir / "chrome.exe"
|
||||
else:
|
||||
# Linux: flat binary
|
||||
return binary_dir / "chrome"
|
||||
|
||||
|
||||
def check_platform_available() -> None:
|
||||
"""Raise a clear error if no pre-built binary exists for this platform.
|
||||
|
||||
Skipped when CLOAKBROWSER_BINARY_PATH is set (user has their own build).
|
||||
"""
|
||||
if get_local_binary_override():
|
||||
return
|
||||
|
||||
tag = get_platform_tag() # raises if platform unsupported entirely
|
||||
if tag not in AVAILABLE_PLATFORMS:
|
||||
available = ", ".join(sorted(AVAILABLE_PLATFORMS))
|
||||
import sys
|
||||
sys.exit(
|
||||
f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n\n"
|
||||
f"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary."
|
||||
)
|
||||
|
||||
|
||||
def get_effective_version() -> str:
|
||||
"""Return the best available version: auto-updated if available, else platform default.
|
||||
|
||||
Reads a platform-scoped marker file from the cache directory.
|
||||
Returns the platform's hardcoded version if no update has been downloaded.
|
||||
"""
|
||||
base = get_chromium_version()
|
||||
# Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
cache = get_cache_dir()
|
||||
for name in (f"latest_version_{get_platform_tag()}", "latest_version"):
|
||||
marker = cache / name
|
||||
if marker.exists():
|
||||
try:
|
||||
version = marker.read_text().strip()
|
||||
if version and _version_newer(version, base):
|
||||
binary = get_binary_path(version)
|
||||
if binary.exists():
|
||||
return version
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return base
|
||||
|
||||
|
||||
def _version_tuple(v: str) -> tuple[int, ...]:
|
||||
"""Parse '145.0.7718.0' into (145, 0, 7718, 0) for comparison."""
|
||||
return tuple(int(x) for x in v.split("."))
|
||||
|
||||
|
||||
def _version_newer(a: str, b: str) -> bool:
|
||||
"""Return True if version a is strictly newer than version b."""
|
||||
return _version_tuple(a) > _version_tuple(b)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download URL
|
||||
# ---------------------------------------------------------------------------
|
||||
DOWNLOAD_BASE_URL = os.environ.get(
|
||||
"CLOAKBROWSER_DOWNLOAD_URL",
|
||||
"https://github.com/CloakHQ/chromium-stealth-builds/releases/download",
|
||||
"https://cloakbrowser.dev",
|
||||
)
|
||||
|
||||
GITHUB_API_URL = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases"
|
||||
|
||||
GITHUB_DOWNLOAD_BASE_URL = (
|
||||
"https://github.com/CloakHQ/cloakbrowser/releases/download"
|
||||
)
|
||||
|
||||
|
||||
def get_download_url() -> str:
|
||||
def get_archive_ext() -> str:
|
||||
"""Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise)."""
|
||||
return ".zip" if platform.system() == "Windows" else ".tar.gz"
|
||||
|
||||
|
||||
def get_archive_name(tag: str | None = None) -> str:
|
||||
"""Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz')."""
|
||||
t = tag or get_platform_tag()
|
||||
return f"cloakbrowser-{t}{get_archive_ext()}"
|
||||
|
||||
|
||||
def get_download_url(version: str | None = None) -> str:
|
||||
"""Return the full download URL for the current platform's binary archive."""
|
||||
tag = get_platform_tag()
|
||||
return f"{DOWNLOAD_BASE_URL}/v{CHROMIUM_VERSION}/cloakbrowser-{tag}.tar.gz"
|
||||
v = version or get_chromium_version()
|
||||
return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
def get_fallback_download_url(version: str | None = None) -> str:
|
||||
"""Return the GitHub Releases fallback URL for the binary archive."""
|
||||
v = version or get_chromium_version()
|
||||
return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+402
-34
@@ -6,20 +6,38 @@ Similar to how Playwright downloads its own bundled Chromium.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from ._version import __version__ as _wrapper_version
|
||||
from .config import (
|
||||
CHROMIUM_VERSION,
|
||||
DOWNLOAD_BASE_URL,
|
||||
GITHUB_API_URL,
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
_version_newer,
|
||||
check_platform_available,
|
||||
get_archive_ext,
|
||||
get_archive_name,
|
||||
get_binary_dir,
|
||||
get_binary_path,
|
||||
get_cache_dir,
|
||||
get_chromium_version,
|
||||
get_download_url,
|
||||
get_effective_version,
|
||||
get_fallback_download_url,
|
||||
get_local_binary_override,
|
||||
get_platform_tag,
|
||||
)
|
||||
@@ -27,7 +45,30 @@ from .config import (
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
# Timeout for download (large binary, allow 10 min)
|
||||
DOWNLOAD_TIMEOUT = 600.0
|
||||
DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
|
||||
|
||||
# Auto-update check interval (1 hour)
|
||||
UPDATE_CHECK_INTERVAL = 3600
|
||||
|
||||
|
||||
def _show_welcome() -> None:
|
||||
"""Show welcome message on first launch. Uses a marker file to show only once."""
|
||||
marker = get_cache_dir() / ".welcome_shown"
|
||||
if marker.exists():
|
||||
return
|
||||
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("")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_binary() -> str:
|
||||
@@ -48,20 +89,37 @@ def ensure_binary() -> str:
|
||||
logger.info("Using local binary override: %s", local_override)
|
||||
return str(path)
|
||||
|
||||
# Check if binary is already cached
|
||||
binary_path = get_binary_path()
|
||||
# Fail fast if no binary available for this platform
|
||||
check_platform_available()
|
||||
|
||||
# Check for auto-updated version first, then fall back to hardcoded
|
||||
effective = get_effective_version()
|
||||
binary_path = get_binary_path(effective)
|
||||
|
||||
if binary_path.exists() and _is_executable(binary_path):
|
||||
logger.debug("Binary found in cache: %s", binary_path)
|
||||
logger.debug("Binary found in cache: %s (version %s)", binary_path, effective)
|
||||
_show_welcome()
|
||||
_maybe_trigger_update_check()
|
||||
return str(binary_path)
|
||||
|
||||
# Download
|
||||
# Fall back to platform's hardcoded version if effective version binary doesn't exist
|
||||
platform_version = get_chromium_version()
|
||||
if effective != platform_version:
|
||||
fallback_path = get_binary_path()
|
||||
if fallback_path.exists() and _is_executable(fallback_path):
|
||||
logger.debug("Binary found in cache: %s", fallback_path)
|
||||
_maybe_trigger_update_check()
|
||||
return str(fallback_path)
|
||||
|
||||
# Download platform's hardcoded version
|
||||
logger.info(
|
||||
"Stealth Chromium %s not found. Downloading for %s...",
|
||||
CHROMIUM_VERSION,
|
||||
platform_version,
|
||||
get_platform_tag(),
|
||||
)
|
||||
_download_and_extract()
|
||||
|
||||
binary_path = get_binary_path()
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Download completed but binary not found at expected path: {binary_path}. "
|
||||
@@ -69,29 +127,123 @@ def ensure_binary() -> str:
|
||||
f"https://github.com/CloakHQ/cloakbrowser/issues"
|
||||
)
|
||||
|
||||
_maybe_trigger_update_check()
|
||||
return str(binary_path)
|
||||
|
||||
|
||||
def _download_and_extract() -> None:
|
||||
"""Download the binary archive and extract to cache directory."""
|
||||
url = get_download_url()
|
||||
binary_dir = get_binary_dir()
|
||||
def _download_and_extract(version: str | None = None) -> None:
|
||||
"""Download the binary archive and extract to cache directory.
|
||||
|
||||
Tries the primary server (cloakbrowser.dev) first, falls back to
|
||||
GitHub Releases if the primary is unreachable or returns an error.
|
||||
Verifies SHA-256 checksum before extraction when available.
|
||||
"""
|
||||
primary_url = get_download_url(version)
|
||||
fallback_url = get_fallback_download_url(version)
|
||||
binary_dir = get_binary_dir(version)
|
||||
binary_path = get_binary_path(version)
|
||||
|
||||
# Create cache dir
|
||||
binary_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download to temp file first (atomic — no partial downloads in cache)
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
||||
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
_download_file(url, tmp_path)
|
||||
_extract_archive(tmp_path, binary_dir)
|
||||
# Try primary, fall back to GitHub Releases (skip fallback if custom URL)
|
||||
try:
|
||||
_download_file(primary_url, tmp_path)
|
||||
except Exception as primary_err:
|
||||
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
||||
raise
|
||||
logger.warning(
|
||||
"Primary download failed (%s), trying GitHub Releases...",
|
||||
primary_err,
|
||||
)
|
||||
_download_file(fallback_url, tmp_path)
|
||||
|
||||
# Verify checksum before extraction
|
||||
if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() != "true":
|
||||
_verify_download_checksum(tmp_path, version)
|
||||
|
||||
_extract_archive(tmp_path, binary_dir, binary_path)
|
||||
_show_welcome()
|
||||
finally:
|
||||
# Clean up temp file
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
|
||||
"""Fetch SHA256SUMS and verify the downloaded file. Warn if unavailable, fail on mismatch."""
|
||||
checksums = _fetch_checksums(version)
|
||||
tarball_name = get_archive_name()
|
||||
|
||||
if checksums is None:
|
||||
logger.warning("SHA256SUMS not available for this release — skipping checksum verification")
|
||||
return
|
||||
|
||||
expected = checksums.get(tarball_name)
|
||||
if expected is None:
|
||||
logger.warning("SHA256SUMS found but no entry for %s — skipping verification", tarball_name)
|
||||
return
|
||||
|
||||
_verify_checksum(file_path, expected)
|
||||
|
||||
|
||||
def _fetch_checksums(version: str | None = None) -> dict[str, str] | None:
|
||||
"""Fetch SHA256SUMS file for a version. Returns {filename: hash} or None."""
|
||||
v = version or get_chromium_version()
|
||||
has_custom_url = os.environ.get("CLOAKBROWSER_DOWNLOAD_URL")
|
||||
|
||||
# Build URL list — respect custom URL contract (no GitHub fallback)
|
||||
urls = [f"{DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS"]
|
||||
if not has_custom_url:
|
||||
urls.append(f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS")
|
||||
|
||||
for url in urls:
|
||||
try:
|
||||
resp = httpx.get(url, follow_redirects=True, timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
return _parse_checksums(resp.text)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_checksums(text: str) -> dict[str, str]:
|
||||
"""Parse SHA256SUMS format: 'hash filename' per line."""
|
||||
result = {}
|
||||
for line in text.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
hash_val, filename = parts
|
||||
filename = filename.lstrip("*")
|
||||
result[filename] = hash_val.lower()
|
||||
return result
|
||||
|
||||
|
||||
def _verify_checksum(file_path: Path, expected_hash: str) -> None:
|
||||
"""Verify SHA-256 of a file. Raises RuntimeError on mismatch."""
|
||||
sha256 = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
sha256.update(chunk)
|
||||
actual = sha256.hexdigest().lower()
|
||||
if actual != expected_hash:
|
||||
raise RuntimeError(
|
||||
f"Checksum verification failed!\n"
|
||||
f" Expected: {expected_hash}\n"
|
||||
f" Got: {actual}\n"
|
||||
f" File may be corrupted or tampered with. "
|
||||
f"Please retry or report at https://github.com/CloakHQ/cloakbrowser/issues"
|
||||
)
|
||||
logger.info("Checksum verified: SHA-256 OK")
|
||||
|
||||
|
||||
def _download_file(url: str, dest: Path) -> None:
|
||||
"""Download a file with progress logging."""
|
||||
logger.info("Downloading from %s", url)
|
||||
@@ -123,8 +275,10 @@ def _download_file(url: str, dest: Path) -> None:
|
||||
logger.info("Download complete: %d MB", dest.stat().st_size // (1024 * 1024))
|
||||
|
||||
|
||||
def _extract_archive(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract tar.gz archive to destination directory."""
|
||||
def _extract_archive(
|
||||
archive_path: Path, dest_dir: Path, binary_path: Path | None = None
|
||||
) -> None:
|
||||
"""Extract tar.gz or zip archive to destination directory."""
|
||||
logger.info("Extracting to %s", dest_dir)
|
||||
|
||||
# Clean existing dir if partial download existed
|
||||
@@ -134,29 +288,59 @@ def _extract_archive(archive_path: Path, dest_dir: Path) -> None:
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if str(archive_path).endswith(".zip"):
|
||||
_extract_zip(archive_path, dest_dir)
|
||||
else:
|
||||
_extract_tar(archive_path, dest_dir)
|
||||
|
||||
# If extracted into a single subdirectory, flatten it
|
||||
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
|
||||
# But never flatten .app bundles — macOS needs the bundle structure intact
|
||||
_flatten_single_subdir(dest_dir)
|
||||
|
||||
# Make binary executable
|
||||
bp = binary_path or get_binary_path()
|
||||
if bp.exists():
|
||||
_make_executable(bp)
|
||||
|
||||
# macOS: remove quarantine/provenance xattrs to prevent Gatekeeper prompts
|
||||
if platform.system() == "Darwin":
|
||||
_remove_quarantine(dest_dir)
|
||||
|
||||
if bp.exists():
|
||||
logger.info("Binary ready: %s", bp)
|
||||
|
||||
|
||||
def _extract_tar(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract tar.gz archive with path traversal protection."""
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
# Security: prevent path traversal and symlink attacks
|
||||
safe_members = []
|
||||
for member in tar.getmembers():
|
||||
# Allow symlinks — macOS .app bundles require them (Framework layout)
|
||||
if member.issym() or member.islnk():
|
||||
logger.warning("Skipping symlink in archive: %s", member.name)
|
||||
continue
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {member.name}")
|
||||
link_target = member.linkname
|
||||
if os.path.isabs(link_target) or ".." in link_target.split("/"):
|
||||
logger.warning("Skipping suspicious symlink: %s -> %s", member.name, link_target)
|
||||
continue
|
||||
else:
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {member.name}")
|
||||
safe_members.append(member)
|
||||
|
||||
tar.extractall(dest_dir, members=safe_members)
|
||||
|
||||
# If tar extracted into a single subdirectory, flatten it
|
||||
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
|
||||
_flatten_single_subdir(dest_dir)
|
||||
|
||||
# Make binary executable
|
||||
binary_path = get_binary_path()
|
||||
if binary_path.exists():
|
||||
_make_executable(binary_path)
|
||||
logger.info("Binary ready: %s", binary_path)
|
||||
def _extract_zip(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract zip archive with path traversal protection."""
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
for info in zf.infolist():
|
||||
member_path = (dest_dir / info.filename).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {info.filename}")
|
||||
zf.extractall(dest_dir)
|
||||
|
||||
|
||||
def _flatten_single_subdir(dest_dir: Path) -> None:
|
||||
@@ -170,6 +354,10 @@ def _flatten_single_subdir(dest_dir: Path) -> None:
|
||||
entries = list(dest_dir.iterdir())
|
||||
if len(entries) == 1 and entries[0].is_dir():
|
||||
subdir = entries[0]
|
||||
# Never flatten .app bundles — macOS needs the bundle structure
|
||||
if subdir.name.endswith(".app"):
|
||||
logger.debug("Keeping .app bundle intact: %s", subdir.name)
|
||||
return
|
||||
logger.debug("Flattening single subdirectory: %s", subdir.name)
|
||||
for item in subdir.iterdir():
|
||||
shutil.move(str(item), str(dest_dir / item.name))
|
||||
@@ -182,11 +370,26 @@ def _is_executable(path: Path) -> bool:
|
||||
|
||||
|
||||
def _make_executable(path: Path) -> None:
|
||||
"""Make a file executable (chmod +x)."""
|
||||
"""Make a file executable (chmod +x). Skipped on Windows (no-op / AV lock risk)."""
|
||||
if platform.system() == "Windows":
|
||||
return
|
||||
current = path.stat().st_mode
|
||||
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
def _remove_quarantine(path: Path) -> None:
|
||||
"""Remove macOS quarantine/provenance xattrs so Gatekeeper doesn't block the binary."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["xattr", "-cr", str(path)],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
logger.debug("Removed quarantine attributes from %s", path)
|
||||
except Exception:
|
||||
logger.debug("Failed to remove quarantine attributes", exc_info=True)
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Remove all cached binaries. Forces re-download on next launch."""
|
||||
from .config import get_cache_dir
|
||||
@@ -200,12 +403,177 @@ def clear_cache() -> None:
|
||||
|
||||
def binary_info() -> dict:
|
||||
"""Return info about the current binary installation."""
|
||||
binary_path = get_binary_path()
|
||||
effective = get_effective_version()
|
||||
binary_path = get_binary_path(effective)
|
||||
return {
|
||||
"version": CHROMIUM_VERSION,
|
||||
"version": effective,
|
||||
"bundled_version": CHROMIUM_VERSION,
|
||||
"platform": get_platform_tag(),
|
||||
"binary_path": str(binary_path),
|
||||
"installed": binary_path.exists(),
|
||||
"cache_dir": str(get_binary_dir()),
|
||||
"download_url": get_download_url(),
|
||||
"cache_dir": str(get_binary_dir(effective)),
|
||||
"download_url": get_download_url(effective),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_for_update() -> str | None:
|
||||
"""Manually check for a newer Chromium version. Returns new version or None.
|
||||
|
||||
This is the public API for triggering an update check. Unlike the
|
||||
background check in ensure_binary(), this blocks until complete.
|
||||
"""
|
||||
latest = _get_latest_chromium_version()
|
||||
if latest is None:
|
||||
return None
|
||||
if not _version_newer(latest, get_chromium_version()):
|
||||
return None
|
||||
|
||||
binary_dir = get_binary_dir(latest)
|
||||
if binary_dir.exists():
|
||||
# Already downloaded
|
||||
_write_version_marker(latest)
|
||||
return latest
|
||||
|
||||
logger.info("Downloading Chromium %s...", latest)
|
||||
_download_and_extract(version=latest)
|
||||
_write_version_marker(latest)
|
||||
return latest
|
||||
|
||||
|
||||
def _should_check_for_update() -> bool:
|
||||
"""Check if auto-update is enabled and rate limit hasn't been hit."""
|
||||
if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false":
|
||||
return False
|
||||
if get_local_binary_override():
|
||||
return False
|
||||
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
||||
return False
|
||||
|
||||
check_file = get_cache_dir() / ".last_update_check"
|
||||
if check_file.exists():
|
||||
try:
|
||||
last_check = float(check_file.read_text().strip())
|
||||
if time.time() - last_check < UPDATE_CHECK_INTERVAL:
|
||||
return False
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _get_latest_chromium_version() -> str | None:
|
||||
"""Hit GitHub Releases API, return latest chromium-v* version for this platform.
|
||||
|
||||
Checks that the release has a binary asset for the current platform,
|
||||
so Linux-only releases won't be offered to macOS users.
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
GITHUB_API_URL, params={"per_page": 10}, timeout=10.0
|
||||
)
|
||||
resp.raise_for_status()
|
||||
platform_tarball = get_archive_name()
|
||||
for release in resp.json():
|
||||
tag = release.get("tag_name", "")
|
||||
if tag.startswith("chromium-v") and not release.get("draft"):
|
||||
asset_names = {a["name"] for a in release.get("assets", [])}
|
||||
if platform_tarball in asset_names:
|
||||
return tag.removeprefix("chromium-v")
|
||||
return None
|
||||
except Exception:
|
||||
logger.debug("Auto-update check failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _write_version_marker(version: str) -> None:
|
||||
"""Write the latest version marker for this platform to cache dir."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
marker = cache_dir / f"latest_version_{get_platform_tag()}"
|
||||
# Write to temp file then rename for atomicity
|
||||
tmp = marker.with_suffix(".tmp")
|
||||
tmp.write_text(version)
|
||||
tmp.rename(marker)
|
||||
|
||||
|
||||
_wrapper_update_checked = False
|
||||
|
||||
|
||||
def _check_wrapper_update() -> None:
|
||||
"""Check PyPI for a newer wrapper version. Runs once per process."""
|
||||
global _wrapper_update_checked
|
||||
if _wrapper_update_checked:
|
||||
return
|
||||
_wrapper_update_checked = True
|
||||
if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false":
|
||||
return
|
||||
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
||||
return
|
||||
try:
|
||||
resp = httpx.get(
|
||||
"https://pypi.org/pypi/cloakbrowser/json",
|
||||
timeout=5.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
latest = resp.json()["info"]["version"]
|
||||
if _version_newer(latest, _wrapper_version):
|
||||
logger.warning(
|
||||
"Update available: cloakbrowser %s → %s. "
|
||||
"Run: pip install --upgrade cloakbrowser",
|
||||
_wrapper_version,
|
||||
latest,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Wrapper update check failed", exc_info=True)
|
||||
|
||||
|
||||
def _check_and_download_update() -> None:
|
||||
"""Background task: check for newer binary, download if available."""
|
||||
try:
|
||||
# Record check timestamp first (rate limiting)
|
||||
check_file = get_cache_dir() / ".last_update_check"
|
||||
check_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
check_file.write_text(str(time.time()))
|
||||
|
||||
platform_version = get_chromium_version()
|
||||
latest = _get_latest_chromium_version()
|
||||
if latest is None:
|
||||
return
|
||||
if not _version_newer(latest, platform_version):
|
||||
return
|
||||
|
||||
# Already downloaded?
|
||||
if get_binary_dir(latest).exists():
|
||||
_write_version_marker(latest)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Newer Chromium available: %s (current: %s). Downloading in background...",
|
||||
latest,
|
||||
platform_version,
|
||||
)
|
||||
_download_and_extract(version=latest)
|
||||
_write_version_marker(latest)
|
||||
logger.info(
|
||||
"Background update complete: Chromium %s ready. Will use on next launch.",
|
||||
latest,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Background update failed", exc_info=True)
|
||||
|
||||
|
||||
def _maybe_trigger_update_check() -> None:
|
||||
"""Fire-and-forget update check in a daemon thread."""
|
||||
# Wrapper update: once per process, not rate-limited
|
||||
if not _wrapper_update_checked:
|
||||
t = threading.Thread(target=_check_wrapper_update, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Binary update: rate-limited to once per hour
|
||||
if not _should_check_for_update():
|
||||
return
|
||||
t = threading.Thread(target=_check_and_download_update, daemon=True)
|
||||
t.start()
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""GeoIP-based timezone and locale detection from proxy IP.
|
||||
|
||||
Optional feature — requires ``geoip2`` package::
|
||||
|
||||
pip install cloakbrowser[geoip]
|
||||
|
||||
Downloads GeoLite2-City.mmdb (~70 MB) on first use, caches in
|
||||
``~/.cloakbrowser/geoip/``. Background re-download after 30 days.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
# P3TERX mirror of MaxMind GeoLite2-City — no license key needed
|
||||
GEOIP_DB_URL = (
|
||||
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
|
||||
)
|
||||
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
|
||||
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days
|
||||
|
||||
# Country ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
|
||||
COUNTRY_LOCALE_MAP: dict[str, str] = {
|
||||
"US": "en-US", "GB": "en-GB", "AU": "en-AU", "CA": "en-CA", "NZ": "en-NZ",
|
||||
"IE": "en-IE", "ZA": "en-ZA", "SG": "en-SG",
|
||||
"DE": "de-DE", "AT": "de-AT", "CH": "de-CH",
|
||||
"FR": "fr-FR", "BE": "fr-BE",
|
||||
"ES": "es-ES", "MX": "es-MX", "AR": "es-AR", "CO": "es-CO", "CL": "es-CL",
|
||||
"BR": "pt-BR", "PT": "pt-PT",
|
||||
"IT": "it-IT", "NL": "nl-NL",
|
||||
"JP": "ja-JP", "KR": "ko-KR", "CN": "zh-CN", "TW": "zh-TW", "HK": "zh-HK",
|
||||
"RU": "ru-RU", "UA": "uk-UA", "PL": "pl-PL", "CZ": "cs-CZ", "RO": "ro-RO",
|
||||
"IL": "he-IL", "TR": "tr-TR", "SA": "ar-SA", "AE": "ar-AE", "EG": "ar-EG",
|
||||
"IN": "hi-IN", "ID": "id-ID", "PH": "en-PH",
|
||||
"TH": "th-TH", "VN": "vi-VN", "MY": "ms-MY",
|
||||
"SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK", "FI": "fi-FI",
|
||||
"GR": "el-GR", "HU": "hu-HU", "BG": "bg-BG",
|
||||
}
|
||||
|
||||
|
||||
def resolve_proxy_geo(proxy_url: str) -> tuple[str | None, str | None]:
|
||||
"""Resolve timezone and locale from a proxy's IP address.
|
||||
|
||||
Returns ``(timezone, locale)`` — either or both may be ``None`` on
|
||||
failure (missing dep, DB download error, lookup miss). Never raises.
|
||||
"""
|
||||
tz, locale, _ip = resolve_proxy_geo_with_ip(proxy_url)
|
||||
return tz, locale
|
||||
|
||||
|
||||
def resolve_proxy_geo_with_ip(
|
||||
proxy_url: str,
|
||||
) -> tuple[str | None, str | None, str | None]:
|
||||
"""Resolve timezone, locale, and exit IP from a proxy.
|
||||
|
||||
Returns ``(timezone, locale, exit_ip)``. The exit IP is a free bonus
|
||||
from the lookup — reused for WebRTC spoofing without an extra HTTP call.
|
||||
"""
|
||||
try:
|
||||
import geoip2.database # noqa: F811
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"geoip2 is required for geoip=True. Install it with:\n"
|
||||
" pip install cloakbrowser[geoip]"
|
||||
) from None
|
||||
|
||||
db_path = _ensure_geoip_db()
|
||||
if db_path is None:
|
||||
return None, None, None
|
||||
|
||||
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||
ip = _resolve_exit_ip(proxy_url)
|
||||
if ip is None:
|
||||
ip = _resolve_proxy_ip(proxy_url)
|
||||
if ip is None:
|
||||
return None, None, None
|
||||
|
||||
try:
|
||||
with geoip2.database.Reader(str(db_path)) as reader:
|
||||
resp = reader.city(ip)
|
||||
timezone = resp.location.time_zone
|
||||
country = resp.country.iso_code
|
||||
locale = COUNTRY_LOCALE_MAP.get(country) if country else None
|
||||
logger.debug(
|
||||
"GeoIP: %s → tz=%s, country=%s, locale=%s",
|
||||
ip, timezone, country, locale,
|
||||
)
|
||||
return timezone, locale, ip
|
||||
except Exception as exc:
|
||||
logger.warning("GeoIP lookup failed for %s: %s", ip, exc)
|
||||
return None, None, ip
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxy IP resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_proxy_ip(proxy_url: str) -> str | None:
|
||||
"""Extract proxy hostname from URL and resolve to an IP address."""
|
||||
try:
|
||||
hostname = urlparse(proxy_url).hostname
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
# Already a literal IP?
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, hostname)
|
||||
return hostname
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, hostname)
|
||||
return hostname
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# DNS resolve (returns first result, handles both v4/v6)
|
||||
results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
if results:
|
||||
ip = results[0][4][0]
|
||||
logger.debug("Resolved proxy %s → %s", hostname, ip)
|
||||
return ip
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to resolve proxy hostname: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _is_private_ip(ip: str) -> bool:
|
||||
"""Check if an IP address is private/internal (not routable on the internet)."""
|
||||
try:
|
||||
return ipaddress.ip_address(ip).is_private
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# IP echo services — fast, no auth, return just the IP
|
||||
_IP_ECHO_URLS = [
|
||||
"https://api.ipify.org",
|
||||
"https://checkip.amazonaws.com",
|
||||
"https://ifconfig.me/ip",
|
||||
]
|
||||
|
||||
|
||||
def _resolve_exit_ip(proxy_url: str) -> str | None:
|
||||
"""Discover the proxy's actual exit IP by connecting through it."""
|
||||
import httpx
|
||||
|
||||
for url in _IP_ECHO_URLS:
|
||||
try:
|
||||
resp = httpx.get(url, proxy=proxy_url, timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
ip = resp.text.strip()
|
||||
# Validate it looks like an IP
|
||||
ipaddress.ip_address(ip)
|
||||
logger.debug("Exit IP via %s: %s", url, ip)
|
||||
return ip
|
||||
except httpx.UnsupportedProtocol:
|
||||
logger.warning(
|
||||
"SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]"
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
continue
|
||||
logger.warning("Failed to discover exit IP through proxy")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GeoIP database management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_geoip_dir() -> Path:
|
||||
from .config import get_cache_dir
|
||||
|
||||
return get_cache_dir() / "geoip"
|
||||
|
||||
|
||||
def _ensure_geoip_db() -> Path | None:
|
||||
"""Return path to GeoLite2-City.mmdb, downloading on first use."""
|
||||
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
|
||||
|
||||
if db_path.exists():
|
||||
_maybe_trigger_update(db_path)
|
||||
return db_path
|
||||
|
||||
try:
|
||||
_download_geoip_db(db_path)
|
||||
return db_path
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to download GeoIP database: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _download_geoip_db(dest: Path) -> None:
|
||||
"""Atomic download of GeoLite2-City.mmdb via httpx."""
|
||||
import httpx
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Downloading GeoIP database (~70 MB) …")
|
||||
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(dir=dest.parent, suffix=".tmp")
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with httpx.stream(
|
||||
"GET", GEOIP_DB_URL, follow_redirects=True, timeout=300.0
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
total = int(resp.headers.get("content-length", 0))
|
||||
downloaded = 0
|
||||
last_pct = -1
|
||||
with open(tmp_fd, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=65_536):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total:
|
||||
pct = downloaded * 100 // total
|
||||
if pct >= last_pct + 10:
|
||||
last_pct = pct
|
||||
logger.info("GeoIP download: %d %%", pct)
|
||||
|
||||
tmp_path.rename(dest)
|
||||
logger.info("GeoIP database ready: %s", dest)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _maybe_trigger_update(db_path: Path) -> None:
|
||||
"""Re-download in background if DB is older than 30 days."""
|
||||
try:
|
||||
age = time.time() - db_path.stat().st_mtime
|
||||
if age < GEOIP_UPDATE_INTERVAL:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def _bg() -> None:
|
||||
try:
|
||||
_download_geoip_db(db_path)
|
||||
except Exception:
|
||||
logger.debug("Background GeoIP update failed", exc_info=True)
|
||||
|
||||
threading.Thread(target=_bg, daemon=True).start()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
"""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, TypedDict
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Range = Tuple[float, float]
|
||||
HumanPreset = Literal["default", "careful"]
|
||||
|
||||
|
||||
class HumanConfigOverrides(TypedDict, total=False):
|
||||
typing_delay: float
|
||||
typing_delay_spread: float
|
||||
typing_pause_chance: float
|
||||
typing_pause_range: Range
|
||||
shift_down_delay: Range
|
||||
shift_up_delay: Range
|
||||
key_hold: Range
|
||||
field_switch_delay: Range
|
||||
mistype_chance: float
|
||||
mistype_delay_notice: Range
|
||||
mistype_delay_correct: Range
|
||||
mouse_steps_divisor: float
|
||||
mouse_min_steps: int
|
||||
mouse_max_steps: int
|
||||
mouse_wobble_max: float
|
||||
mouse_overshoot_chance: float
|
||||
mouse_overshoot_px: Range
|
||||
mouse_burst_size: Range
|
||||
mouse_burst_pause: Range
|
||||
click_aim_delay_input: Range
|
||||
click_aim_delay_button: Range
|
||||
click_hold_input: Range
|
||||
click_hold_button: Range
|
||||
click_input_x_range: Range
|
||||
idle_drift_px: float
|
||||
idle_pause_range: Range
|
||||
scroll_delta_base: Range
|
||||
scroll_delta_variance: float
|
||||
scroll_pause_fast: Range
|
||||
scroll_pause_slow: Range
|
||||
scroll_accel_steps: Range
|
||||
scroll_decel_steps: Range
|
||||
scroll_overshoot_chance: float
|
||||
scroll_overshoot_px: Range
|
||||
scroll_settle_delay: Range
|
||||
scroll_target_zone: Range
|
||||
scroll_pre_move_delay: Range
|
||||
initial_cursor_x: Range
|
||||
initial_cursor_y: Range
|
||||
idle_between_actions: bool
|
||||
idle_between_duration: Range
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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: HumanConfigOverrides | None = None,
|
||||
) -> HumanConfig:
|
||||
"""Resolve a preset name + optional overrides into a full HumanConfig.
|
||||
|
||||
Args:
|
||||
preset: 'default' or 'careful'.
|
||||
overrides: Typed mapping of HumanConfig 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,189 @@
|
||||
"""cloakbrowser-human — Human-like keyboard input.
|
||||
|
||||
Stealth-aware: when a CDP session is provided, shift symbols are typed
|
||||
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||
Falls back to page.evaluate when no CDP session is available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Optional, 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',
|
||||
}
|
||||
|
||||
# CDP key code for each shift symbol's physical key.
|
||||
_SHIFT_SYMBOL_CODES: dict[str, str] = {
|
||||
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
|
||||
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
|
||||
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
|
||||
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
|
||||
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
|
||||
'?': 'Slash', '~': 'Backquote',
|
||||
}
|
||||
|
||||
# Windows virtual key codes for Input.dispatchKeyEvent.
|
||||
_SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
|
||||
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
|
||||
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
|
||||
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
|
||||
'~': 192,
|
||||
}
|
||||
|
||||
|
||||
def _get_nearby_key(ch: str) -> str:
|
||||
"""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,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type text with human-like per-character timing.
|
||||
|
||||
Args:
|
||||
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||
producing isTrusted=true events with no evaluate stack trace.
|
||||
If None, falls back to page.evaluate (detectable).
|
||||
"""
|
||||
for i, ch in enumerate(text):
|
||||
# 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, cdp_session)
|
||||
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,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type a shift symbol character.
|
||||
|
||||
Stealth path (cdp_session provided):
|
||||
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||
|
||||
Fallback path (no cdp_session):
|
||||
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||
Detectable via isTrusted=false and evaluate stack frame.
|
||||
"""
|
||||
if cdp_session is not None:
|
||||
# --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
code = _SHIFT_SYMBOL_CODES.get(ch, '')
|
||||
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
|
||||
cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyDown",
|
||||
"modifiers": 8, # Shift modifier flag
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
"text": ch,
|
||||
"unmodifiedText": ch,
|
||||
})
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
|
||||
cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyUp",
|
||||
"modifiers": 8,
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
})
|
||||
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
raw.up("Shift")
|
||||
else:
|
||||
# --- Fallback path: page.evaluate (detectable) ---
|
||||
raw.down("Shift")
|
||||
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,150 @@
|
||||
"""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``.
|
||||
|
||||
Stealth-aware: when a CDP session is provided, shift symbols are typed
|
||||
via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, async_sleep_ms
|
||||
from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key
|
||||
from .keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES
|
||||
|
||||
|
||||
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,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type text with human-like per-character timing (async).
|
||||
|
||||
Args:
|
||||
cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||
producing isTrusted=true events with no evaluate stack trace.
|
||||
If None, falls back to page.evaluate (detectable).
|
||||
"""
|
||||
for i, ch in enumerate(text):
|
||||
# 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, cdp_session)
|
||||
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,
|
||||
cdp_session: Any = None,
|
||||
) -> None:
|
||||
"""Type a shift symbol character (async).
|
||||
|
||||
Stealth path (cdp_session provided):
|
||||
Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||
|
||||
Fallback path (no cdp_session):
|
||||
Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||
Detectable via isTrusted=false and evaluate stack frame.
|
||||
"""
|
||||
if cdp_session is not None:
|
||||
# --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
code = _SHIFT_SYMBOL_CODES.get(ch, '')
|
||||
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||
|
||||
await raw.down("Shift")
|
||||
await async_sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
|
||||
await cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyDown",
|
||||
"modifiers": 8, # Shift modifier flag
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
"text": ch,
|
||||
"unmodifiedText": ch,
|
||||
})
|
||||
await async_sleep_ms(rand_range(cfg.key_hold))
|
||||
|
||||
await cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyUp",
|
||||
"modifiers": 8,
|
||||
"key": ch,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
})
|
||||
|
||||
await async_sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await raw.up("Shift")
|
||||
else:
|
||||
# --- Fallback path: page.evaluate (detectable) ---
|
||||
await raw.down("Shift")
|
||||
await 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
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
print("Launching stealth browser...", flush=True)
|
||||
browser = launch(headless=False)
|
||||
page = browser.new_page()
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Test against fingerprint-scan.com and CreepJS.
|
||||
|
||||
Tests the specific headless detection signals flagged by the community:
|
||||
- noTaskbar, noContentIndex, noContactsManager, noDownlinkMax
|
||||
- Bot risk score (fingerprint-scan.com)
|
||||
- Headless/stealth percentages (CreepJS)
|
||||
- Full CreepJS signal breakdown (likeHeadless, headless, stealth)
|
||||
|
||||
Usage:
|
||||
python examples/fingerprint_scan_test.py
|
||||
python examples/fingerprint_scan_test.py --proxy http://10.50.96.5:8888
|
||||
python examples/fingerprint_scan_test.py --headless
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
from cloakbrowser import launch_context
|
||||
|
||||
HEADLESS = "--headless" in sys.argv
|
||||
PROXY = None
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg == "--proxy" and i + 1 < len(sys.argv):
|
||||
PROXY = sys.argv[i + 1]
|
||||
|
||||
|
||||
def test_fingerprint_scan(page):
|
||||
"""fingerprint-scan.com — bot risk score + headless detection signals."""
|
||||
print("=== fingerprint-scan.com ===")
|
||||
page.goto("https://fingerprint-scan.com/", wait_until="domcontentloaded", timeout=30000)
|
||||
time.sleep(20) # Castle.js needs time to compute score
|
||||
|
||||
# Check bot risk score
|
||||
score = page.evaluate(
|
||||
'document.getElementById("fingerprintScore")?.textContent || "Score not rendered"'
|
||||
)
|
||||
print(f"Bot Risk Score: {score}")
|
||||
|
||||
# Check headless detection signals
|
||||
apis = page.evaluate("""() => ({
|
||||
noTaskbar: screen.height === screen.availHeight,
|
||||
taskbarSize: screen.height - screen.availHeight,
|
||||
noContentIndex: typeof window.ContentIndex === "undefined",
|
||||
noContactsManager: !("contacts" in navigator),
|
||||
noDownlinkMax: !("downlinkMax" in (navigator.connection || {})),
|
||||
downlinkMax: navigator.connection?.downlinkMax ?? null,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
webdriver: navigator.webdriver,
|
||||
isPlaywright: "__pwInitScripts" in window || "__playwright__binding__" in window,
|
||||
webgpu: typeof navigator.gpu !== "undefined" ? "available" : "NOT_AVAILABLE",
|
||||
scrollbarWidth: (() => { const d = document.createElement("div"); d.style.cssText = "overflow:scroll;width:100px;height:100px;position:absolute;top:-999px"; document.body.appendChild(d); const w = d.offsetWidth - d.clientWidth; d.remove(); return w; })()
|
||||
})""")
|
||||
|
||||
print("\nHeadless detection signals:")
|
||||
headless_fails = 0
|
||||
for k, v in apis.items():
|
||||
is_fail = k.startswith("no") and v is True
|
||||
if is_fail:
|
||||
headless_fails += 1
|
||||
flag = "FAIL" if is_fail else ""
|
||||
print(f" {k}: {v} {flag}")
|
||||
|
||||
# Extract bot test results from page
|
||||
bot_tests = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const tests = {};
|
||||
for (const key of ['WebDriver', 'Is Selenium Chrome', 'CDP Check', 'Is Playwright']) {
|
||||
const match = text.match(new RegExp(key + '\\\\s+(true|false)'));
|
||||
if (match) tests[key] = match[1];
|
||||
}
|
||||
return tests;
|
||||
}""")
|
||||
print("\nBot Detection Tests:")
|
||||
for k, v in bot_tests.items():
|
||||
status = "PASS" if v == "false" else "FAIL"
|
||||
print(f" {k}: {v} [{status}]")
|
||||
|
||||
page.screenshot(path="/results/fingerprint-scan.png", full_page=True)
|
||||
print("\nScreenshot: /results/fingerprint-scan.png")
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"headless_fails": headless_fails,
|
||||
"apis": apis,
|
||||
"bot_tests": bot_tests,
|
||||
}
|
||||
|
||||
|
||||
def test_creepjs(page):
|
||||
"""abrahamjuliot.github.io/creepjs — comprehensive fingerprint analysis."""
|
||||
print("\n=== CreepJS ===")
|
||||
page.goto(
|
||||
"https://abrahamjuliot.github.io/creepjs/", wait_until="domcontentloaded", timeout=30000
|
||||
)
|
||||
print("Waiting 30s for CreepJS analysis...")
|
||||
time.sleep(30)
|
||||
|
||||
# Extract % scores from page text (matches test-infra/matrix_tests/group3_bot_detection.py)
|
||||
scores = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const likeMatch = text.match(/(\\d+)%\\s*like headless/i);
|
||||
const headlessMatch = text.match(/(\\d+)%\\s*headless:/i);
|
||||
const stealthMatch = text.match(/(\\d+)%\\s*stealth:/i);
|
||||
return {
|
||||
likeHeadlessPct: likeMatch ? parseInt(likeMatch[1]) : null,
|
||||
headlessPct: headlessMatch ? parseInt(headlessMatch[1]) : null,
|
||||
stealthPct: stealthMatch ? parseInt(stealthMatch[1]) : null,
|
||||
};
|
||||
}""")
|
||||
|
||||
print(f"\nScores:")
|
||||
print(f" like-headless: {scores['likeHeadlessPct']}% (target: <=30%)")
|
||||
print(f" headless: {scores['headlessPct']}% (target: 0%)")
|
||||
print(f" stealth: {scores['stealthPct']}% (target: 0%)")
|
||||
|
||||
# Extract full signal breakdown from window.Fingerprint.headless (CreepJS internal object)
|
||||
signals = page.evaluate("""() => {
|
||||
try {
|
||||
const fp = window.Fingerprint;
|
||||
if (!fp || !fp.headless) return null;
|
||||
return {
|
||||
likeHeadless: fp.headless.likeHeadless || null,
|
||||
headless: fp.headless.headless || null,
|
||||
stealth: fp.headless.stealth || null,
|
||||
};
|
||||
} catch { return null; }
|
||||
}""")
|
||||
|
||||
if signals:
|
||||
if signals.get("likeHeadless"):
|
||||
print("\nlikeHeadless signals:")
|
||||
fails = 0
|
||||
for k, v in signals["likeHeadless"].items():
|
||||
is_fail = v is True
|
||||
if is_fail:
|
||||
fails += 1
|
||||
flag = " FAIL" if is_fail else ""
|
||||
print(f" {k}: {v}{flag}")
|
||||
print(f" ({fails} fails)")
|
||||
|
||||
if signals.get("headless"):
|
||||
print("\nheadless signals:")
|
||||
for k, v in signals["headless"].items():
|
||||
flag = " FAIL" if v is True else ""
|
||||
print(f" {k}: {v}{flag}")
|
||||
|
||||
if signals.get("stealth"):
|
||||
print("\nstealth signals:")
|
||||
for k, v in signals["stealth"].items():
|
||||
flag = " FAIL" if v is True else ""
|
||||
print(f" {k}: {v}{flag}")
|
||||
else:
|
||||
print("\n(window.Fingerprint.headless not available — signals not extracted)")
|
||||
|
||||
# Extract platform estimate
|
||||
platform = page.evaluate("""() => {
|
||||
try {
|
||||
const fp = window.Fingerprint;
|
||||
if (!fp || !fp.platformEstimate) return null;
|
||||
return fp.platformEstimate;
|
||||
} catch { return null; }
|
||||
}""")
|
||||
if platform:
|
||||
print(f"\nPlatform estimate: {platform}")
|
||||
|
||||
passed = (
|
||||
scores["headlessPct"] is not None
|
||||
and scores["headlessPct"] <= 30
|
||||
and scores["stealthPct"] is not None
|
||||
and scores["stealthPct"] <= 30
|
||||
)
|
||||
print(f"\nVerdict: {'PASS' if passed else 'FAIL'} (<=30% headless, <=30% stealth)")
|
||||
|
||||
page.screenshot(path="/results/creepjs.png", full_page=True)
|
||||
print("Screenshot: /results/creepjs.png")
|
||||
|
||||
return {**scores, "signals": signals, "platform": platform}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("CloakBrowser — Fingerprint & Headless Detection Tests")
|
||||
print("=" * 60)
|
||||
print(f"Mode: {'headless' if HEADLESS else 'headed'}")
|
||||
print(f"Proxy: {PROXY or 'none'}")
|
||||
print()
|
||||
|
||||
print("Launching stealth browser...", flush=True)
|
||||
context = launch_context(
|
||||
headless=HEADLESS,
|
||||
proxy=PROXY,
|
||||
args=[
|
||||
"--fingerprint-screen-width=1920",
|
||||
"--fingerprint-screen-height=1080",
|
||||
"--fingerprint-timezone=Asia/Jerusalem",
|
||||
],
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
try:
|
||||
fp_result = test_fingerprint_scan(page)
|
||||
creep_result = test_creepjs(page)
|
||||
finally:
|
||||
context.close()
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f"fingerprint-scan.com: {fp_result['score']}")
|
||||
print(f" Headless signal fails: {fp_result['headless_fails']}")
|
||||
like = creep_result["likeHeadlessPct"]
|
||||
headless = creep_result["headlessPct"]
|
||||
stealth = creep_result["stealthPct"]
|
||||
print(f"CreepJS: like-headless={like}%, headless={headless}%, stealth={stealth}%")
|
||||
|
||||
# Count CreepJS signal fails
|
||||
sigs = creep_result.get("signals")
|
||||
if sigs and sigs.get("likeHeadless"):
|
||||
fail_names = [k for k, v in sigs["likeHeadless"].items() if v is True]
|
||||
if fail_names:
|
||||
print(f" likeHeadless fails: {', '.join(fail_names)}")
|
||||
print("=" * 60)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
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,79 @@
|
||||
# CloakBrowser on AWS Lambda — derived from the official CloakHQ image.
|
||||
#
|
||||
# `FROM cloakhq/cloakbrowser:<tag>` is an official distribution channel under
|
||||
# the CloakBrowser Binary License — pulling it isn't redistribution. We just
|
||||
# layer Lambda glue on top: the Lambda Runtime Interface Client (awslambdaric),
|
||||
# the Lambda Runtime Interface Emulator (for local `docker run` testing), the
|
||||
# dual-mode entrypoint, and the handler module.
|
||||
#
|
||||
# This directory is self-contained — copy/clone it anywhere and build from
|
||||
# inside it. No files outside this directory are referenced.
|
||||
#
|
||||
# ─── Lambda invocation (default CMD) ──────────────────────────────────────────
|
||||
# # From inside this directory:
|
||||
# docker buildx build --platform linux/arm64 -t cloakbrowser-lambda:arm64 --load .
|
||||
#
|
||||
# # Or from a parent dir, pointing at this directory as the build context:
|
||||
# docker buildx build --platform linux/arm64 \
|
||||
# -f path/to/aws_lambda/Dockerfile -t cloakbrowser-lambda:arm64 --load \
|
||||
# path/to/aws_lambda
|
||||
#
|
||||
# docker run --rm -p 9000:8080 cloakbrowser-lambda:arm64
|
||||
# curl -XPOST http://localhost:9000/2015-03-31/functions/function/invocations \
|
||||
# -d '{"url":"https://example.com"}'
|
||||
#
|
||||
# ─── Same as the canonical CloakHQ image (CMD overridden) ─────────────────────
|
||||
# docker run --rm -it cloakbrowser-lambda:arm64 python # REPL
|
||||
# docker run --rm cloakbrowser-lambda:arm64 python examples/basic.py # examples
|
||||
# docker run --rm -p 9222:9222 cloakbrowser-lambda:arm64 cloakserve --port=9222 # CDP server
|
||||
# docker run --rm cloakbrowser-lambda:arm64 cloaktest # stealth tests
|
||||
# docker run --rm -it cloakbrowser-lambda:arm64 node # JS wrapper
|
||||
# docker run --rm -it cloakbrowser-lambda:arm64 bash # shell
|
||||
#
|
||||
# Pin a specific tag (e.g. cloakhq/cloakbrowser:0.3.25) for reproducible builds;
|
||||
# `latest` floats with CloakHQ's release cadence.
|
||||
|
||||
FROM cloakhq/cloakbrowser:latest
|
||||
|
||||
# ─── Lambda Runtime Interface Client ──────────────────────────────────────────
|
||||
RUN pip install --no-cache-dir awslambdaric
|
||||
|
||||
# ─── Lambda Runtime Interface Emulator (local `docker run` testing) ───────────
|
||||
# Bundled into the image so users can hit the standard local-invoke endpoint
|
||||
# without mounting the RIE separately. TARGETARCH is provided by buildx.
|
||||
ARG TARGETARCH
|
||||
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie-${TARGETARCH} \
|
||||
/usr/local/bin/aws-lambda-rie
|
||||
RUN chmod +x /usr/local/bin/aws-lambda-rie
|
||||
|
||||
# ─── Lambda glue ──────────────────────────────────────────────────────────────
|
||||
# Dual-mode entrypoint replaces the canonical bin/docker-entrypoint.sh: same
|
||||
# Xvfb startup, plus routing for `module.func` CMDs through awslambdaric.
|
||||
COPY lambda-entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Handler sits at /app (already on Python's import path in the canonical image,
|
||||
# WORKDIR=/app), imports cloakbrowser as a normal library.
|
||||
COPY lambda_handler.py /app/lambda_handler.py
|
||||
|
||||
# ─── Lambda non-root readability fix ──────────────────────────────────────────
|
||||
# The canonical image bakes the Chromium binary at /root/.cloakbrowser/ (root's
|
||||
# HOME at build time). Lambda runs the container as a non-root user that can't
|
||||
# read /root by default (mode 750). Make the whole binary tree world-readable
|
||||
# and traversable. Also restore the .welcome_shown marker the canonical image
|
||||
# rm's (Lambda's read-only runtime FS can't recreate it, so the welcome would
|
||||
# print to CloudWatch on every cold start otherwise).
|
||||
RUN touch /root/.cloakbrowser/.welcome_shown \
|
||||
&& chmod -R o+rX /root /root/.cloakbrowser
|
||||
|
||||
# ─── Lambda runtime env ───────────────────────────────────────────────────────
|
||||
# HOME=/tmp gives Chromium a writable scratch dir (Lambda only allows writes
|
||||
# under /tmp). CLOAKBROWSER_CACHE_DIR points at the baked binary location since
|
||||
# HOME=/tmp would otherwise make get_cache_dir() resolve to /tmp/.cloakbrowser
|
||||
# (empty). Auto-update is disabled because the runtime FS is read-only.
|
||||
ENV HOME=/tmp \
|
||||
CLOAKBROWSER_CACHE_DIR=/root/.cloakbrowser \
|
||||
CLOAKBROWSER_AUTO_UPDATE=false
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["lambda_handler.handler"]
|
||||
@@ -0,0 +1,181 @@
|
||||
# CloakBrowser on AWS Lambda
|
||||
|
||||
Run stealth Chromium one-shot scrapes inside an AWS Lambda function (container image package type). The image derives directly from the official CloakHQ Docker Hub image (`cloakhq/cloakbrowser`) and adds Lambda runtime support on top — Lambda is an additional invocation surface, not a replacement. Every other surface from the canonical image (`python`, `cloakserve`, `cloaktest`, `node`, `bash`, examples) keeps working.
|
||||
|
||||
This document covers what the image is, how to build and locally test it, and the event/response contract. **It does not prescribe a deployment method** — push the resulting image to ECR and create the Lambda function however you prefer (AWS CLI, CDK, Terraform, SAM, console, etc.). Configuration tips for whichever tool you use are at the bottom.
|
||||
|
||||
## Files in this directory
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `Dockerfile` | `FROM cloakhq/cloakbrowser` plus a thin Lambda layer. Self-contained — no files outside this directory are referenced. |
|
||||
| `lambda-entrypoint.sh` | Dual-mode entrypoint. Starts Xvfb, then routes `module.func` CMDs through `awslambdaric` (via the bundled `aws-lambda-rie` locally, or the AWS Runtime API in production), and execs everything else (`python`, `cloakserve`, `cloaktest`, `node`, `bash`) directly. |
|
||||
| `lambda_handler.py` | Default handler. Takes `{url, ...}`, returns `{title, url, html, screenshot_b64?}`. Always headed via Xvfb. |
|
||||
| `INSTRUCTIONS.md` | This file. |
|
||||
|
||||
The Lambda layer is ~30 lines on top of the official image — no apt list, no Node install, no JS-wrapper build, no Chromium download. The canonical CloakHQ image owns those.
|
||||
|
||||
This directory is **standalone**: copy or clone it anywhere (its own repo, a subdirectory of an existing project, a CI artifact bundle) and the build still works. It depends only on the upstream `cloakhq/cloakbrowser` image on Docker Hub and the `aws-lambda-rie` binary on GitHub Releases — both fetched at build time.
|
||||
|
||||
## Build
|
||||
|
||||
From inside this directory:
|
||||
|
||||
```bash
|
||||
docker buildx build --platform linux/arm64 -t cloakbrowser-lambda:arm64 --load .
|
||||
```
|
||||
|
||||
Or from anywhere, pointing at this directory as the build context:
|
||||
|
||||
```bash
|
||||
docker buildx build --platform linux/arm64 \
|
||||
-f path/to/aws_lambda/Dockerfile \
|
||||
-t cloakbrowser-lambda:arm64 --load \
|
||||
path/to/aws_lambda
|
||||
```
|
||||
|
||||
The build pulls `cloakhq/cloakbrowser:latest` from Docker Hub and adds the Lambda layer on top. Pin a specific tag (e.g. `cloakhq/cloakbrowser:0.3.25`) in the `FROM` line for reproducible builds; `latest` floats with the upstream release cadence.
|
||||
|
||||
For x86_64, switch `--platform linux/amd64` (slower on Apple Silicon under emulation).
|
||||
|
||||
## Local smoke test (no AWS account needed)
|
||||
|
||||
> **What's the RIE?** Lambda container images can't be run with a plain `docker run` — they expect to talk to AWS's Runtime API (the HTTP service Lambda exposes inside its sandbox to deliver events and collect responses). AWS publishes a small binary called the **Runtime Interface Emulator** that stands up a fake Runtime API on localhost so you can test the container exactly the way Lambda will invoke it, without deploying. We bake the RIE into the image, and the dual-mode entrypoint uses it automatically when `AWS_LAMBDA_RUNTIME_API` isn't set (i.e. you're not running in real Lambda).
|
||||
|
||||
The image bakes in `aws-lambda-rie`, so the standard Lambda local-invoke endpoint works without mounting anything:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 9000:8080 cloakbrowser-lambda:arm64
|
||||
|
||||
# In another shell:
|
||||
curl -sS -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" \
|
||||
-d '{"url":"https://example.com"}'
|
||||
```
|
||||
|
||||
Other invocation surfaces stay intact (these match the canonical CloakHQ image):
|
||||
|
||||
```bash
|
||||
docker run --rm -it cloakbrowser-lambda:arm64 python # REPL
|
||||
docker run --rm cloakbrowser-lambda:arm64 python examples/basic.py # examples
|
||||
docker run --rm -p 9222:9222 cloakbrowser-lambda:arm64 cloakserve --port=9222 # CDP server
|
||||
docker run --rm cloakbrowser-lambda:arm64 cloaktest # stealth tests
|
||||
docker run --rm -it cloakbrowser-lambda:arm64 node # JS wrapper
|
||||
```
|
||||
|
||||
## Event schema
|
||||
|
||||
Only `url` is required. Everything else is optional.
|
||||
|
||||
### Launch options (forwarded to `cloakbrowser.launch_context_async`)
|
||||
|
||||
| Field | Type | Default |
|
||||
|---|---|---|
|
||||
| `url` | str | required |
|
||||
| `proxy` | str / dict | none — `http://user:pass@host:port` or a Playwright proxy dict |
|
||||
| `humanize` | bool | `false` — enable human-like mouse / keyboard / scroll |
|
||||
| `human_preset` | str | `"default"` or `"careful"` |
|
||||
| `geoip` | bool | `false` — auto timezone+locale from proxy IP |
|
||||
| `timezone` | str | none — IANA tz, e.g. `"America/New_York"` |
|
||||
| `locale` | str | none — BCP-47, e.g. `"en-US"` |
|
||||
| `viewport` | `{width,height}` | `1920x947` (cloakbrowser default) |
|
||||
| `user_agent` | str | none |
|
||||
| `extra_args` | `list[str]` | `[]` — extra Chromium CLI flags |
|
||||
|
||||
### Navigation
|
||||
|
||||
| Field | Type | Default |
|
||||
|---|---|---|
|
||||
| `wait_until` | str | `"domcontentloaded"` — `load` / `domcontentloaded` / `networkidle` / `commit` |
|
||||
| `goto_timeout_ms` | int | `30000` |
|
||||
|
||||
### Post-navigation waits
|
||||
|
||||
`smart_wait` is the default when no other wait is specified. It polls `document.documentElement.outerHTML.length` and returns when the size hasn't changed for `dom_stable_ms`. Robust for at-scale scraping because it ignores network activity (analytics beacons, long-poll, websockets) that doesn't mutate the DOM — `wait_until: "networkidle"` is unreliable on modern SPAs for exactly this reason.
|
||||
|
||||
| Field | Type | Default |
|
||||
|---|---|---|
|
||||
| `smart_wait` | bool | `true` if no other wait is set |
|
||||
| `dom_stable_ms` | int | `1500` |
|
||||
| `max_settle_ms` | int | `15000` |
|
||||
| `wait_for_load_state` | str | none — `load` / `domcontentloaded` / `networkidle` |
|
||||
| `wait_for_load_state_timeout_ms` | int | `30000` |
|
||||
| `wait_for_selector` | str | none — CSS or XPath |
|
||||
| `wait_for_selector_state` | str | `"visible"` — also `attached` / `detached` / `hidden` |
|
||||
| `wait_for_selector_timeout_ms` | int | `30000` |
|
||||
| `wait_for_function` | str | none — JS expression returning truthy when ready |
|
||||
| `wait_for_function_timeout_ms` | int | `30000` |
|
||||
| `wait_ms` | int | none — fixed pause |
|
||||
|
||||
### Capture
|
||||
|
||||
| Field | Type | Default |
|
||||
|---|---|---|
|
||||
| `screenshot` | bool | `true` |
|
||||
| `full_page_screenshot` | bool | `false` |
|
||||
|
||||
### Retry orchestration
|
||||
|
||||
The handler retries transient navigation failures inline within the same Lambda invocation. Two layers, both built-in:
|
||||
|
||||
- **Launch retries** — 3 attempts with 0.3 s + 0.6 s backoff. Recovers Xvfb / Chromium spawn races at cold start. Fast and cheap; not configurable.
|
||||
- **Strategy retries** — default 1 attempt, configurable via the `retries` event field. Recovers specific post-launch error classes by relaunching with adjusted Chromium args / page-load budgets.
|
||||
|
||||
| Field | Type | Default |
|
||||
|---|---|---|
|
||||
| `retries` | int | `1` — number of strategy-retry attempts after the first failure. Set to `0` to disable retry entirely. |
|
||||
|
||||
Strategies (priority order — first match wins):
|
||||
|
||||
| Error pattern | Strategy applied |
|
||||
|---|---|
|
||||
| `ERR_CERT_*` (any cert error) | `extra_args: ["--ignore-certificate-errors"]`, `goto_timeout_ms: 60000` |
|
||||
| `Timeout … exceeded` | `goto_timeout_ms: 90000`, `max_settle_ms: 25000` |
|
||||
| `ERR_CONNECTION_TIMED_OUT` | same as `Timeout … exceeded` |
|
||||
|
||||
Errors that are **not retried** (no anonymous scraper can recover): `ERR_NAME_NOT_RESOLVED`, `ERR_SSL_PROTOCOL_ERROR`, `ERR_CONNECTION_REFUSED`, `ERR_HTTP_RESPONSE_CODE_FAILURE`. These bail immediately.
|
||||
|
||||
On final failure, the raised `RuntimeError`'s message includes a `retry_history` block listing every attempt (strategy applied + error seen). Successful invocations return the standard response shape unchanged — no surprise fields when retries didn't fire.
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "...",
|
||||
"url": "https://example.com/",
|
||||
"html": "<!DOCTYPE html>...",
|
||||
"screenshot_b64": "<base64 PNG>"
|
||||
}
|
||||
```
|
||||
|
||||
## Lambda-specific Chromium hardening (baked in, do not remove)
|
||||
|
||||
Two flags are forced on every launch by `lambda_handler.py`:
|
||||
|
||||
- `--disable-dev-shm-usage` — Lambda's `/dev/shm` is ~64 MB; Chromium's renderer crashes mid-paint without this.
|
||||
- `--no-zygote` — Lambda's restricted process model can't fork from Chromium's zygote process; without this the browser launches but child renderers fail to spawn and the first `page.new_page()` raises `TargetClosedError`.
|
||||
|
||||
## Function configuration recommendations
|
||||
|
||||
Whatever tool you use to create the Lambda function (CLI, CDK, Terraform, SAM, console), apply these settings:
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---|---|---|
|
||||
| Package type | Image | Required — this is a container image, not a zip. |
|
||||
| Architecture | `arm64` | Roughly 20% cheaper than x86_64. Native build on Apple Silicon. Match the architecture you built for. |
|
||||
| Memory | 3008 MB | Memory in Lambda is tied to vCPU. Below ~1769 MB Chromium starts noticeably slower. |
|
||||
| Timeout | 120–180 s | Single-attempt scrapes complete in 3–15 s warm; under retry, a `Timeout`-class first failure (30 s default) plus a longer-budget retry (90 s) plus cleanup can total ~120-130 s. 180 s leaves headroom; below 120 s the function will time out before the retry completes. Cold-start init adds 5-10 s on top. |
|
||||
| Ephemeral storage (`/tmp`) | 1024 MB | Chromium profile dirs and screenshots can fill the 512 MB default. |
|
||||
| Networking | Default (no VPC) | Binary is baked in, no network needed at cold start. Add VPC + NAT only if your proxy egress requires it. |
|
||||
| Execution role | `AWSLambdaBasicExecutionRole` | Just CloudWatch Logs. Add more permissions only if your handler needs them. |
|
||||
|
||||
## Cold start
|
||||
|
||||
First invocation in a new container takes ~80–90 s (image extraction, Chromium binary mmap, JS engine warmup, no DNS/TLS caches). Subsequent warm invocations on the same container are 3–15 s.
|
||||
|
||||
For latency-sensitive use cases: provision concurrency, schedule a CloudWatch/EventBridge warmer ping, or accept the cold tail.
|
||||
|
||||
If you see empty/missing dynamic content on cold-start invocations, raise `max_settle_ms` in the event payload (e.g. `25000`) — the default `15000` is tuned for warm runs.
|
||||
|
||||
## License
|
||||
|
||||
The patched Chromium binary inside the upstream `cloakhq/cloakbrowser` image is governed by the **CloakBrowser Binary License** (published at https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md). Internal organizational use (private ECR, your own scraping pipelines, your own business) is free. Exposing this Lambda as a paid API to third-party customers — i.e. browser-as-a-service — requires an OEM/SaaS license from CloakHQ (`cloakhq@pm.me`). Do not push the resulting image to a public registry; that would be redistribution and is prohibited.
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
# Dual-mode entrypoint for the CloakBrowser Lambda image.
|
||||
#
|
||||
# 1. Always start Xvfb on :99 (same as the canonical bin/docker-entrypoint.sh)
|
||||
# so headed Chromium works no matter how the container is invoked.
|
||||
# 2. Detect whether the CMD looks like a Lambda handler (a single
|
||||
# `module.func`-shaped argument). If yes, route through the Lambda runtime
|
||||
# client (using the bundled aws-lambda-rie locally, or talking to the real
|
||||
# Lambda Runtime API when AWS_LAMBDA_RUNTIME_API is set in production).
|
||||
# 3. Otherwise exec the CMD directly — preserving the canonical Dockerfile's
|
||||
# interaction surface (`python`, `cloakserve`, `cloaktest`, `node`, `bash`,
|
||||
# `python examples/basic.py`, etc.).
|
||||
set -e
|
||||
|
||||
mkdir -p /tmp/.X11-unix
|
||||
chmod 1777 /tmp/.X11-unix 2>/dev/null || true
|
||||
|
||||
# Clean any stale Xvfb state. If a previous Xvfb died and left its lock file
|
||||
# behind (we observed this in cold-start storms), a new Xvfb refuses to start
|
||||
# with "Server is already active for display 99". Removing both files makes
|
||||
# Xvfb start cleanly every time.
|
||||
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
|
||||
|
||||
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/tmp/Xvfb.log 2>&1 &
|
||||
|
||||
# Wait for the X11 socket to appear AND for Xvfb to be ready to serve. The
|
||||
# socket file appears at bind(), but listen() and the first accept() come
|
||||
# slightly later — under cold-start CPU contention this gap matters.
|
||||
i=0
|
||||
while [ ! -e /tmp/.X11-unix/X99 ] && [ "$i" -lt 200 ]; do
|
||||
i=$((i + 1))
|
||||
sleep 0.05
|
||||
done
|
||||
# Small buffer after the socket appears so Xvfb has a moment to call listen()
|
||||
# and start accepting clients. Cheap insurance against the bind/listen gap.
|
||||
sleep 0.2
|
||||
|
||||
# Lambda handler shape: exactly one arg, dotted identifier (no spaces, no slashes,
|
||||
# no leading dot). `python`, `cloakserve`, `cloaktest`, `bash`, `node` all fail
|
||||
# this test and pass through to plain exec.
|
||||
if [ $# -eq 1 ] && \
|
||||
echo "$1" | grep -qE '^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)+$'; then
|
||||
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
|
||||
# Local invocation via bundled RIE.
|
||||
exec /usr/local/bin/aws-lambda-rie /usr/local/bin/python -m awslambdaric "$@"
|
||||
else
|
||||
# Real Lambda — runtime API endpoint already provided by the platform.
|
||||
exec /usr/local/bin/python -m awslambdaric "$@"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,336 @@
|
||||
"""AWS Lambda handler for one-off stealth-browser invocations.
|
||||
|
||||
Always runs **headed** via the Xvfb display started by `lambda-entrypoint.sh`.
|
||||
|
||||
Event schema (all fields except `url` are optional):
|
||||
|
||||
Launch options (passed to cloakbrowser.launch_context_async):
|
||||
url str required, the page to scrape
|
||||
proxy str|dict http://user:pass@host:port or Playwright proxy dict
|
||||
humanize bool False — enable human-like mouse/keyboard/scroll
|
||||
human_preset str "default" | "careful"
|
||||
geoip bool False — auto timezone+locale from proxy IP
|
||||
timezone str IANA tz, e.g. "America/New_York"
|
||||
locale str BCP-47, e.g. "en-US"
|
||||
viewport {width,height} defaults to 1920x947 (cloakbrowser DEFAULT_VIEWPORT)
|
||||
user_agent str custom UA (rare — cloakbrowser sets one already)
|
||||
extra_args list[str] additional Chromium CLI flags
|
||||
|
||||
Navigation options (passed to page.goto):
|
||||
wait_until str "load"|"domcontentloaded"|"networkidle"|"commit"
|
||||
default "domcontentloaded"
|
||||
goto_timeout_ms int 30000
|
||||
|
||||
Post-navigation waits (run in this order if specified):
|
||||
smart_wait bool ON by default if no other wait is set.
|
||||
Polls document.outerHTML.length and bails when it
|
||||
hasn't changed for `dom_stable_ms`. Handles lazy
|
||||
hydration, async chunks, and lazy images, and is
|
||||
immune to analytics beacons / long-poll that keep
|
||||
the network busy without mutating the DOM.
|
||||
dom_stable_ms int 1500 — how long DOM must be quiet
|
||||
max_settle_ms int 15000 — hard cap on smart_wait
|
||||
wait_for_load_state str "load"|"domcontentloaded"|"networkidle"
|
||||
wait_for_load_state_timeout_ms int 30000
|
||||
wait_for_selector str CSS or XPath selector
|
||||
wait_for_selector_state str "attached"|"detached"|"visible"|"hidden", default "visible"
|
||||
wait_for_selector_timeout_ms int 30000
|
||||
wait_for_function str JS expression that returns truthy when ready
|
||||
wait_for_function_timeout_ms int 30000
|
||||
wait_ms int fixed pause in ms (page.wait_for_timeout)
|
||||
|
||||
Capture options:
|
||||
screenshot bool True
|
||||
full_page_screenshot bool False — capture entire scrollable page
|
||||
|
||||
Retry orchestration:
|
||||
retries int default 1. Number of retry attempts after the first
|
||||
failure. Set to 0 to disable retries entirely (the
|
||||
handler will fail fast on the first error).
|
||||
Retried errors:
|
||||
ERR_CERT_* -> retry with --ignore-certificate-errors
|
||||
Timeout exceeded -> retry with goto_timeout_ms=90000, max_settle_ms=25000
|
||||
ERR_CONNECTION_TIMED_OUT -> same as Timeout
|
||||
Not retried (unrecoverable): ERR_NAME_NOT_RESOLVED,
|
||||
ERR_SSL_PROTOCOL_ERROR, generic ERR_CONNECTION_REFUSED.
|
||||
On final failure, the error message includes a
|
||||
retry_history block with strategy + error per attempt.
|
||||
|
||||
Returns:
|
||||
{"title": ..., "url": ..., "html": ..., "screenshot_b64"?: ...}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cloakbrowser import launch_context_async
|
||||
|
||||
logger = logging.getLogger("cloakbrowser.lambda")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def _diag_snapshot() -> str:
|
||||
"""Capture Xvfb status, Xvfb log, X11 socket state, and env for error reports."""
|
||||
import os
|
||||
parts = []
|
||||
try:
|
||||
r = subprocess.run(["pgrep", "-fa", "Xvfb"], capture_output=True, text=True)
|
||||
parts.append(f"pgrep Xvfb: rc={r.returncode} stdout={r.stdout.strip()!r}")
|
||||
except Exception as e:
|
||||
parts.append(f"pgrep failed: {e}")
|
||||
try:
|
||||
r = subprocess.run(["ls", "-la", "/tmp/.X11-unix"], capture_output=True, text=True)
|
||||
parts.append(f"ls /tmp/.X11-unix:\n{r.stdout}{r.stderr}")
|
||||
except Exception as e:
|
||||
parts.append(f"ls /tmp/.X11-unix failed: {e}")
|
||||
try:
|
||||
log = Path("/tmp/Xvfb.log").read_text()
|
||||
parts.append(f"/tmp/Xvfb.log:\n{log}")
|
||||
except Exception as e:
|
||||
parts.append(f"Xvfb log unreadable: {e}")
|
||||
parts.append(f"env: DISPLAY={os.environ.get('DISPLAY')!r} HOME={os.environ.get('HOME')!r}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def handler(event: dict, context: Any) -> dict:
|
||||
return asyncio.run(_run(event))
|
||||
|
||||
|
||||
def _build_launch_kwargs(event: dict) -> dict:
|
||||
"""Translate the event dict into kwargs for launch_context_async.
|
||||
|
||||
Only includes keys explicitly set in the event so cloakbrowser's defaults
|
||||
(DEFAULT_VIEWPORT etc.) kick in when fields are absent — passing
|
||||
viewport=None would *disable* viewport emulation, which we don't want.
|
||||
"""
|
||||
kwargs: dict = {
|
||||
"headless": False, # always headed via Xvfb
|
||||
"args": [
|
||||
# Lambda /dev/shm is ~64 MB — Chromium crashes mid-render without this.
|
||||
"--disable-dev-shm-usage",
|
||||
# Lambda's restricted process model can't fork from Chromium's zygote
|
||||
# — without this, child renderer processes fail to spawn.
|
||||
"--no-zygote",
|
||||
*event.get("extra_args", []),
|
||||
],
|
||||
}
|
||||
for key in ("proxy", "humanize", "human_preset", "geoip",
|
||||
"timezone", "locale", "viewport", "user_agent"):
|
||||
if key in event:
|
||||
kwargs[key] = event[key]
|
||||
return kwargs
|
||||
|
||||
|
||||
async def _smart_wait(page, dom_stable_ms: int = 1500, max_settle_ms: int = 15000) -> None:
|
||||
"""Wait until the document HTML hasn't changed for `dom_stable_ms`.
|
||||
|
||||
Generic stopping condition for at-scale scraping when you can't tune
|
||||
selectors per site. More robust than `networkidle` because it ignores
|
||||
network activity that doesn't mutate the DOM (analytics beacons,
|
||||
long-poll, websockets, web vitals streams).
|
||||
"""
|
||||
js = f"""
|
||||
(() => {{
|
||||
if (!window.__cb_settle) {{
|
||||
window.__cb_settle = {{ len: -1, since: Date.now() }};
|
||||
}}
|
||||
const cur = document.documentElement.outerHTML.length;
|
||||
const s = window.__cb_settle;
|
||||
if (cur !== s.len) {{
|
||||
s.len = cur;
|
||||
s.since = Date.now();
|
||||
return false;
|
||||
}}
|
||||
return (Date.now() - s.since) >= {int(dom_stable_ms)};
|
||||
}})()
|
||||
"""
|
||||
try:
|
||||
await page.wait_for_function(js, timeout=max_settle_ms, polling=200)
|
||||
except Exception:
|
||||
# Hit max_settle_ms cap — return what we have rather than fail the whole invoke
|
||||
logger.warning("smart_wait hit max_settle_ms=%d cap", max_settle_ms)
|
||||
|
||||
|
||||
_EXPLICIT_WAIT_KEYS = (
|
||||
"wait_for_load_state", "wait_for_selector", "wait_for_function", "wait_ms",
|
||||
)
|
||||
|
||||
|
||||
async def _post_nav_waits(page, event: dict) -> None:
|
||||
"""Run waits in priority order. smart_wait is the default unless the
|
||||
caller asked for a more specific stopping condition."""
|
||||
explicit = any(k in event for k in _EXPLICIT_WAIT_KEYS)
|
||||
if event.get("smart_wait", not explicit):
|
||||
await _smart_wait(
|
||||
page,
|
||||
dom_stable_ms=event.get("dom_stable_ms", 1500),
|
||||
max_settle_ms=event.get("max_settle_ms", 15000),
|
||||
)
|
||||
if "wait_for_load_state" in event:
|
||||
await page.wait_for_load_state(
|
||||
event["wait_for_load_state"],
|
||||
timeout=event.get("wait_for_load_state_timeout_ms", 30000),
|
||||
)
|
||||
if "wait_for_selector" in event:
|
||||
await page.wait_for_selector(
|
||||
event["wait_for_selector"],
|
||||
state=event.get("wait_for_selector_state", "visible"),
|
||||
timeout=event.get("wait_for_selector_timeout_ms", 30000),
|
||||
)
|
||||
if "wait_for_function" in event:
|
||||
await page.wait_for_function(
|
||||
event["wait_for_function"],
|
||||
timeout=event.get("wait_for_function_timeout_ms", 30000),
|
||||
)
|
||||
if "wait_ms" in event:
|
||||
await page.wait_for_timeout(event["wait_ms"])
|
||||
|
||||
|
||||
async def _launch_with_retry(event: dict, attempts: int = 3, backoff_s: float = 0.3):
|
||||
"""Retry launch_context_async up to `attempts` times with linear backoff.
|
||||
|
||||
Lambda cold-start storms occasionally race Xvfb readiness or hit transient
|
||||
Chromium spawn failures — both surface as "Target page, context or browser
|
||||
has been closed" at launch. The failure is fast (~0.5s) so retries are
|
||||
cheap, and a retry on a now-warm container almost always succeeds.
|
||||
|
||||
Pairs with the lock-cleanup + socket-poll in lambda-entrypoint.sh: the
|
||||
entrypoint catches the common case at container init; this catches the
|
||||
residual race when the first invocation hits before Xvfb is fully ready.
|
||||
"""
|
||||
last_err: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return await launch_context_async(**_build_launch_kwargs(event))
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.warning("launch attempt %d/%d failed: %s",
|
||||
i + 1, attempts, str(e)[:200])
|
||||
if i + 1 < attempts:
|
||||
await asyncio.sleep(backoff_s * (i + 1)) # 0.3s, 0.6s
|
||||
raise last_err # type: ignore[misc]
|
||||
|
||||
|
||||
def _classify_error(err: Exception) -> dict | None:
|
||||
"""Map a Playwright error to a retry-strategy override dict, or None
|
||||
if the error is unrecoverable.
|
||||
|
||||
Match on str(e) because Playwright errors carry their codes inside the
|
||||
message (Error.__str__ includes ERR_CERT_AUTHORITY_INVALID etc.); there
|
||||
is no stable structured `.error_code` attribute to rely on.
|
||||
|
||||
Strategies (priority order — first match wins):
|
||||
ERR_CERT_* -> --ignore-certificate-errors + 60s goto budget
|
||||
Timeout exceeded -> 90s goto budget + 25s smart_wait cap
|
||||
ERR_CONNECTION_TIMED_OUT -> same as Timeout
|
||||
Returns None for unrecoverable site issues (DNS, SSL, refused, HTTP 4xx/5xx).
|
||||
"""
|
||||
msg = str(err)
|
||||
if "ERR_CERT" in msg:
|
||||
return {
|
||||
"extra_args": ["--ignore-certificate-errors"],
|
||||
"goto_timeout_ms": 60000,
|
||||
}
|
||||
if ("Timeout" in msg and "exceeded" in msg) or "ERR_CONNECTION_TIMED_OUT" in msg:
|
||||
return {
|
||||
"goto_timeout_ms": 90000,
|
||||
"max_settle_ms": 25000,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
async def _attempt_scrape(url: str, event: dict) -> dict:
|
||||
"""One self-contained scrape attempt: launch, navigate, wait, capture, close.
|
||||
|
||||
Extracted from `_run` so the retry loop can call it repeatedly with an
|
||||
overridden event dict. Each attempt relaunches the browser — uniform
|
||||
behavior across strategies (the cert-bypass strategy *requires* a relaunch
|
||||
because `--ignore-certificate-errors` is a Chromium CLI arg, not a per-
|
||||
context switch), and the ~3-5s relaunch cost is fine on the slow path.
|
||||
"""
|
||||
ctx = await _launch_with_retry(event)
|
||||
try:
|
||||
page = await ctx.new_page()
|
||||
await page.goto(
|
||||
url,
|
||||
wait_until=event.get("wait_until", "domcontentloaded"),
|
||||
timeout=event.get("goto_timeout_ms", 30000),
|
||||
)
|
||||
|
||||
await _post_nav_waits(page, event)
|
||||
|
||||
result: dict = {
|
||||
"title": await page.title(),
|
||||
"url": page.url,
|
||||
"html": await page.content(),
|
||||
}
|
||||
|
||||
if event.get("screenshot", True):
|
||||
png = await page.screenshot(
|
||||
full_page=event.get("full_page_screenshot", False),
|
||||
)
|
||||
result["screenshot_b64"] = base64.b64encode(png).decode()
|
||||
|
||||
return result
|
||||
finally:
|
||||
try:
|
||||
await ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _raise_with_history(err: Exception, history: list[dict]) -> None:
|
||||
"""Surface a final failure with a retry_history block embedded in the
|
||||
error message, so callers see what was tried before bailing."""
|
||||
diag = _diag_snapshot()
|
||||
if history:
|
||||
diag = "retry_history: " + json.dumps(history, default=str) + "\n\n" + diag
|
||||
logger.error("scrape failed (after %d retries): %s\nDIAG:\n%s",
|
||||
len(history), err, diag)
|
||||
raise RuntimeError(f"scrape failed: {err}\n--- DIAG ---\n{diag}") from err
|
||||
|
||||
|
||||
async def _run(event: dict) -> dict:
|
||||
"""Top-level scrape with strategy-based retry orchestration.
|
||||
|
||||
First attempt uses the event verbatim. If it fails with a classifiable
|
||||
error (cert / timeout), retry with that strategy's overrides merged into
|
||||
the event. `retries` bounds the number of strategy retries (default 1;
|
||||
set to 0 to disable retry entirely).
|
||||
"""
|
||||
url = event["url"]
|
||||
retries_left = max(0, int(event.get("retries", 1)))
|
||||
history: list[dict] = []
|
||||
current_event = event
|
||||
|
||||
while True:
|
||||
try:
|
||||
return await _attempt_scrape(url, current_event)
|
||||
except Exception as e:
|
||||
if retries_left <= 0:
|
||||
_raise_with_history(e, history)
|
||||
strategy = _classify_error(e)
|
||||
if strategy is None:
|
||||
_raise_with_history(e, history)
|
||||
history.append({
|
||||
"attempt": len(history) + 1,
|
||||
"error": str(e)[:300],
|
||||
"strategy": strategy,
|
||||
})
|
||||
logger.warning("attempt %d failed (%s); retrying with strategy=%s",
|
||||
len(history), str(e)[:120], strategy)
|
||||
merged_args = list(current_event.get("extra_args", [])) + list(strategy.get("extra_args", []))
|
||||
current_event = {**current_event, **strategy, "extra_args": merged_args}
|
||||
retries_left -= 1
|
||||
# No backoff: strategy overrides change goto budget directly;
|
||||
# the prior failure was either fast (cert reject) or already
|
||||
# waited its full timeout. Container is warm.
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Persistent context example: cookies and localStorage survive across sessions."""
|
||||
|
||||
from cloakbrowser import launch_persistent_context
|
||||
|
||||
PROFILE_DIR = "./my-profile"
|
||||
|
||||
# Session 1 — set some state
|
||||
print("=== Session 1: Setting state ===")
|
||||
print("Launching stealth browser...", flush=True)
|
||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com")
|
||||
page.evaluate("document.cookie = 'session=abc123; path=/; max-age=3600'")
|
||||
page.evaluate("localStorage.setItem('user', 'returning')")
|
||||
print(f"Cookie: {page.evaluate('document.cookie')}")
|
||||
ls_val = page.evaluate("localStorage.getItem('user')")
|
||||
print(f"localStorage: {ls_val}")
|
||||
ctx.close()
|
||||
|
||||
# Session 2 — state is restored
|
||||
print("\n=== Session 2: Verifying persistence ===")
|
||||
print("Launching stealth browser...", flush=True)
|
||||
ctx = launch_persistent_context(PROFILE_DIR, headless=False)
|
||||
page = ctx.new_page()
|
||||
page.goto("https://example.com")
|
||||
print(f"Cookie: {page.evaluate('document.cookie')}")
|
||||
ls_val = page.evaluate("localStorage.getItem('user')")
|
||||
print(f"localStorage: {ls_val}")
|
||||
ctx.close()
|
||||
|
||||
print("\nDone!")
|
||||
@@ -5,8 +5,11 @@ Expected: 0.9 (human-level) with cloakbrowser.
|
||||
Default Playwright typically scores 0.1-0.3.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
print("Launching stealth browser...", flush=True)
|
||||
browser = launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
@@ -18,7 +21,7 @@ page.wait_for_load_state("networkidle")
|
||||
button = page.query_selector("button")
|
||||
if button:
|
||||
button.click()
|
||||
page.wait_for_timeout(3000)
|
||||
time.sleep(3)
|
||||
|
||||
# Extract score from page
|
||||
content = page.content()
|
||||
|
||||
+292
-26
@@ -1,57 +1,323 @@
|
||||
"""Run stealth tests against major bot detection services.
|
||||
|
||||
Tests cloakbrowser against multiple detection sites and reports results.
|
||||
Tests cloakbrowser against multiple detection sites, extracts pass/fail
|
||||
verdicts via JS evaluation, and reports results with screenshots.
|
||||
|
||||
Usage:
|
||||
python examples/stealth_test.py
|
||||
python examples/stealth_test.py --headed # watch in real-time
|
||||
python examples/stealth_test.py --no-screenshots
|
||||
python examples/stealth_test.py --proxy http://10.50.96.5:8888
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
from cloakbrowser import launch
|
||||
|
||||
HEADED = "--headed" in sys.argv
|
||||
SCREENSHOTS = "--no-screenshots" not in sys.argv
|
||||
PROXY = None
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg == "--proxy" and i + 1 < len(sys.argv):
|
||||
PROXY = sys.argv[i + 1]
|
||||
|
||||
|
||||
def test_bot_sannysoft(page):
|
||||
"""bot.sannysoft.com — classic bot detection checks."""
|
||||
page.goto("https://bot.sannysoft.com", wait_until="networkidle", timeout=30000)
|
||||
time.sleep(3)
|
||||
|
||||
results = page.evaluate("""() => {
|
||||
const rows = document.querySelectorAll('table tr');
|
||||
const data = {};
|
||||
rows.forEach(r => {
|
||||
const cells = r.querySelectorAll('td');
|
||||
if (cells.length >= 2) {
|
||||
const key = cells[0].innerText.trim();
|
||||
const val = cells[1].innerText.trim();
|
||||
const cls = cells[1].className || '';
|
||||
data[key] = {value: val, passed: !cls.includes('failed')};
|
||||
}
|
||||
});
|
||||
return data;
|
||||
}""")
|
||||
|
||||
failed = [k for k, v in results.items() if not v["passed"]]
|
||||
total = len(results)
|
||||
passed = total - len(failed)
|
||||
return {"passed": passed, "total": total, "failed": failed}
|
||||
|
||||
|
||||
def test_bot_incolumitas(page):
|
||||
"""bot.incolumitas.com — comprehensive 30+ check bot detection."""
|
||||
page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000)
|
||||
|
||||
# Poll until test count stabilizes (site runs tests progressively)
|
||||
last_total = 0
|
||||
for _ in range(15):
|
||||
time.sleep(2)
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const okMatches = text.match(/"\\w+":\\s*"OK"/g) || [];
|
||||
const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || [];
|
||||
const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]);
|
||||
return {
|
||||
passed: okMatches.length,
|
||||
failed: failMatches.length,
|
||||
failedTests,
|
||||
total: okMatches.length + failMatches.length
|
||||
};
|
||||
}""")
|
||||
if results["total"] >= 30 and results["total"] == last_total:
|
||||
break
|
||||
last_total = results["total"]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def test_browserscan(page):
|
||||
"""browserscan.net/bot-detection — WebDriver, UA, CDP, Navigator checks."""
|
||||
page.goto("https://www.browserscan.net/bot-detection", wait_until="networkidle", timeout=30000)
|
||||
time.sleep(5)
|
||||
|
||||
results = page.evaluate("""() => {
|
||||
const items = document.querySelectorAll('[class*="result"], [class*="item"], [class*="check"]');
|
||||
let normal = 0, abnormal = 0;
|
||||
const text = document.body.innerText;
|
||||
// Count "Normal" vs "Abnormal" verdicts
|
||||
const normalMatches = text.match(/Normal/g);
|
||||
const abnormalMatches = text.match(/Abnormal/g);
|
||||
return {
|
||||
normal: normalMatches ? normalMatches.length : 0,
|
||||
abnormal: abnormalMatches ? abnormalMatches.length : 0,
|
||||
pageText: text.substring(0, 500)
|
||||
};
|
||||
}""")
|
||||
return results
|
||||
|
||||
|
||||
def test_deviceandbrowserinfo(page):
|
||||
"""deviceandbrowserinfo.com/are_you_a_bot — fingerprint + behavioral detection."""
|
||||
page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", wait_until="domcontentloaded", timeout=30000)
|
||||
time.sleep(8)
|
||||
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
// Site outputs JSON with "isBot": false and detail checks
|
||||
const botMatch = text.match(/"isBot":\\s*(true|false)/);
|
||||
const isBot = botMatch ? botMatch[1] === 'true' : null;
|
||||
const checks = {};
|
||||
const patterns = [
|
||||
'isBot', 'hasBotUserAgent', 'hasWebdriverTrue',
|
||||
'isHeadlessChrome', 'isAutomatedWithCDP', 'hasSuspiciousWeakSignals',
|
||||
'isPlaywright', 'hasInconsistentChromeObject'
|
||||
];
|
||||
patterns.forEach(p => {
|
||||
const match = text.match(new RegExp('"' + p + '":\\s*(true|false)'));
|
||||
if (match) checks[p] = match[1] === 'true';
|
||||
});
|
||||
return {isBot, checks};
|
||||
}""")
|
||||
return results
|
||||
|
||||
|
||||
def test_fingerprintjs(page):
|
||||
"""demo.fingerprint.com/web-scraping — industry-standard bot detection."""
|
||||
page.goto("https://demo.fingerprint.com/web-scraping", wait_until="domcontentloaded", timeout=30000)
|
||||
time.sleep(8)
|
||||
|
||||
# Click search to trigger bot detection — bots get blocked, humans see flights
|
||||
try:
|
||||
page.click("button:has-text('Search')", timeout=5000)
|
||||
time.sleep(5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
results = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
// Bots see error messages; humans see flight prices
|
||||
const hasFlights = text.includes('Price per adult') || text.includes('$');
|
||||
const isBlocked = text.includes('request was blocked') || text.includes('bot visit detected');
|
||||
return {passed: hasFlights && !isBlocked, isBlocked, hasFlights};
|
||||
}""")
|
||||
return results
|
||||
|
||||
|
||||
def test_recaptcha(page):
|
||||
"""recaptcha-demo.appspot.com — Google's official reCAPTCHA v3 score."""
|
||||
page.goto(
|
||||
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=30000,
|
||||
)
|
||||
# Wait for score to appear (polls up to 30s)
|
||||
for _ in range(15):
|
||||
time.sleep(2)
|
||||
score = page.evaluate("""() => {
|
||||
const text = document.body.innerText;
|
||||
const match = text.match(/"score":\\s*(\\d+\\.\\d+)/);
|
||||
return match ? parseFloat(match[1]) : null;
|
||||
}""")
|
||||
if score is not None:
|
||||
break
|
||||
|
||||
return {"score": score}
|
||||
|
||||
|
||||
TESTS = [
|
||||
{
|
||||
"name": "bot.sannysoft.com",
|
||||
"url": "https://bot.sannysoft.com",
|
||||
"runner": test_bot_sannysoft,
|
||||
"verdict": lambda r: f"{r['passed']}/{r['total']} passed"
|
||||
+ (f" (FAILED: {', '.join(r['failed'])})" if r["failed"] else " — ALL GREEN"),
|
||||
"pass": lambda r: len(r["failed"]) == 0,
|
||||
},
|
||||
{
|
||||
"name": "bot.incolumitas.com",
|
||||
"url": "https://bot.incolumitas.com",
|
||||
"check": "Bot detection analysis",
|
||||
"runner": test_bot_incolumitas,
|
||||
"verdict": lambda r: f"{r['passed']}/{r['total']} passed"
|
||||
+ (" — ALL GREEN" if r.get("failed", 0) == 0
|
||||
else f" (FAILED: {', '.join(r.get('failedTests', []))} — known false positives)"
|
||||
if set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}
|
||||
else f" (FAILED: {', '.join(r.get('failedTests', []))})"),
|
||||
"pass": lambda r: set(r.get("failedTests", [])) <= {"WEBDRIVER", "connectionRTT"}, # known false positives
|
||||
},
|
||||
{
|
||||
"name": "BrowserScan",
|
||||
"url": "https://www.browserscan.net/bot-detection",
|
||||
"check": "Bot detection status",
|
||||
"runner": test_browserscan,
|
||||
"verdict": lambda r: f"Normal: {r['normal']}, Abnormal: {r['abnormal']}",
|
||||
"pass": lambda r: r.get("abnormal", 1) == 0,
|
||||
},
|
||||
{
|
||||
"name": "deviceandbrowserinfo.com",
|
||||
"url": "https://deviceandbrowserinfo.com/are_you_a_bot",
|
||||
"check": "isBot flag",
|
||||
"runner": test_deviceandbrowserinfo,
|
||||
"verdict": lambda r: f"isBot: {r.get('isBot', 'unknown')}"
|
||||
+ (f" checks: {json.dumps(r.get('checks', {}))}" if r.get("checks") else ""),
|
||||
"pass": lambda r: not r.get("isBot", True),
|
||||
},
|
||||
{
|
||||
"name": "FingerprintJS",
|
||||
"url": "https://demo.fingerprint.com/web-scraping",
|
||||
"check": "Bot detection result",
|
||||
"runner": test_fingerprintjs,
|
||||
"verdict": lambda r: "PASSED (flights shown)" if r.get("passed") else "BLOCKED" if r.get("isBlocked") else "NO FLIGHTS",
|
||||
"pass": lambda r: r.get("passed", False),
|
||||
},
|
||||
{
|
||||
"name": "reCAPTCHA v3 (Google)",
|
||||
"url": "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
|
||||
"runner": test_recaptcha,
|
||||
"verdict": lambda r: f"Score: {r.get('score', 'N/A')}",
|
||||
"pass": lambda r: (r.get("score") or 0) >= 0.7,
|
||||
},
|
||||
]
|
||||
|
||||
browser = launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
print("=" * 60)
|
||||
print("CloakBrowser Stealth Test Suite")
|
||||
print("=" * 60)
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("CloakBrowser Stealth Test Suite")
|
||||
print("=" * 60)
|
||||
print(f"Mode: {'headed' if HEADED else 'headless'}")
|
||||
print(f"Screenshots: {'on' if SCREENSHOTS else 'off'}")
|
||||
print(f"Proxy: {PROXY or 'none'}")
|
||||
print()
|
||||
print("Launching stealth browser...", flush=True)
|
||||
|
||||
for test in TESTS:
|
||||
print(f"\n--- {test['name']} ---")
|
||||
print(f"URL: {test['url']}")
|
||||
browser = launch(headless=not HEADED, proxy=PROXY, geoip=True)
|
||||
page = browser.new_page()
|
||||
|
||||
# Show browser fingerprint details
|
||||
try:
|
||||
page.goto(test["url"], wait_until="networkidle", timeout=30000)
|
||||
page.wait_for_timeout(3000)
|
||||
import re
|
||||
info = page.evaluate("""async () => {
|
||||
const ua = navigator.userAgent;
|
||||
let fullVersion = null;
|
||||
try {
|
||||
const data = await navigator.userAgentData.getHighEntropyValues(['fullVersionList', 'platform', 'platformVersion']);
|
||||
const chrome = data.fullVersionList.find(b => b.brand === 'Chromium' || b.brand === 'Google Chrome');
|
||||
fullVersion = chrome ? chrome.version : null;
|
||||
} catch {}
|
||||
const gl = document.createElement('canvas').getContext('webgl');
|
||||
const dbg = gl ? gl.getExtension('WEBGL_debug_renderer_info') : null;
|
||||
return {
|
||||
ua,
|
||||
fullVersion,
|
||||
platform: navigator.platform,
|
||||
cores: navigator.hardwareConcurrency,
|
||||
gpu: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : 'N/A',
|
||||
gpuVendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : 'N/A',
|
||||
screen: screen.width + 'x' + screen.height,
|
||||
languages: navigator.languages.join(', '),
|
||||
};
|
||||
}""")
|
||||
# Condensed UA
|
||||
ua_short = re.sub(r'^Mozilla/5\.0 \(', '', info["ua"])
|
||||
ua_short = re.sub(r'\) AppleWebKit/[\d.]+ \(KHTML, like Gecko\) ', ' | ', ua_short)
|
||||
print(f"UA: {ua_short}", flush=True)
|
||||
print(f"Platform: {info['platform']} | Cores: {info['cores']} | Screen: {info['screen']}", flush=True)
|
||||
print(f"GPU: {info['gpuVendor']} — {info['gpu']}", flush=True)
|
||||
except Exception:
|
||||
print("Chrome: could not detect", flush=True)
|
||||
|
||||
# Screenshot each test
|
||||
filename = f"stealth_test_{test['name'].replace('.', '_').replace(' ', '_')}.png"
|
||||
page.screenshot(path=filename)
|
||||
print(f"Screenshot: {filename}")
|
||||
print(f"Title: {page.title()}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
# Show IP address
|
||||
try:
|
||||
page.goto("https://httpbin.org/ip", timeout=10000)
|
||||
ip = page.evaluate("JSON.parse(document.body.innerText).origin")
|
||||
print(f"IP: {ip}", flush=True)
|
||||
except Exception:
|
||||
print("IP: could not detect", flush=True)
|
||||
|
||||
browser.close()
|
||||
print(f"Running {len(TESTS)} tests (this takes ~2 minutes)...\n", flush=True)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Tests complete. Check screenshots for results.")
|
||||
print("=" * 60)
|
||||
results_summary = []
|
||||
|
||||
for test in TESTS:
|
||||
name = test["name"]
|
||||
print(f"--- {name} ---")
|
||||
print(f"URL: {test['url']}")
|
||||
|
||||
try:
|
||||
result = test["runner"](page)
|
||||
passed = test["pass"](result)
|
||||
verdict = test["verdict"](result)
|
||||
status = "PASS" if passed else "FAIL"
|
||||
results_summary.append((name, status, verdict))
|
||||
|
||||
print(f"Result: [{status}] {verdict}")
|
||||
|
||||
if SCREENSHOTS:
|
||||
filename = f"stealth_test_{name.replace('.', '_').replace(' ', '_').replace('/', '_')}.png"
|
||||
page.screenshot(path=filename)
|
||||
print(f"Screenshot: {filename}")
|
||||
|
||||
except Exception as e:
|
||||
results_summary.append((name, "ERROR", str(e)))
|
||||
print(f"Error: {e}")
|
||||
|
||||
print()
|
||||
|
||||
browser.close()
|
||||
|
||||
# Summary table
|
||||
print("=" * 60)
|
||||
print("RESULTS SUMMARY")
|
||||
print("=" * 60)
|
||||
for name, status, verdict in results_summary:
|
||||
icon = {"PASS": "+", "FAIL": "!", "ERROR": "x"}[status]
|
||||
print(f" [{icon}] {name}: {verdict}")
|
||||
|
||||
passed_count = sum(1 for _, s, _ in results_summary if s == "PASS")
|
||||
total = len(results_summary)
|
||||
print(f"\n {passed_count}/{total} tests passed")
|
||||
print("=" * 60)
|
||||
|
||||
return 0 if passed_count == total else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
+287
@@ -0,0 +1,287 @@
|
||||
<p align="center">
|
||||
<img src="https://i.imgur.com/cqkp6fG.png" width="500" alt="CloakBrowser">
|
||||
</p>
|
||||
|
||||
# CloakBrowser
|
||||
|
||||
[](https://www.npmjs.com/package/cloakbrowser)
|
||||
[](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE)
|
||||
|
||||
**Stealth Chromium that passes every bot detection test.**
|
||||
|
||||
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
|
||||
|
||||
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
|
||||
- **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** — tested with browser-use, Crawl4AI, Scrapling, Stagehand ([example](examples/stagehand.ts)), LangChain, Selenium, and more
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# With Playwright
|
||||
npm install cloakbrowser playwright-core
|
||||
|
||||
# With Puppeteer
|
||||
npm install cloakbrowser puppeteer-core
|
||||
```
|
||||
|
||||
On first launch, the stealth Chromium binary auto-downloads (~200MB, cached at `~/.cloakbrowser/`).
|
||||
|
||||
## Usage
|
||||
|
||||
### Playwright (default)
|
||||
|
||||
```javascript
|
||||
import { launch } from 'cloakbrowser';
|
||||
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://protected-site.com');
|
||||
console.log(await page.title());
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
### Puppeteer
|
||||
|
||||
> **Note:** Playwright is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect. This is a known Puppeteer limitation, not specific to CloakBrowser.
|
||||
|
||||
```javascript
|
||||
import { launch } from 'cloakbrowser/puppeteer';
|
||||
|
||||
const browser = await launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://protected-site.com');
|
||||
console.log(await page.title());
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```javascript
|
||||
import { launch, launchContext, launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
// With proxy (HTTP or SOCKS5)
|
||||
const browser = await launch({
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
const browser = await launch({
|
||||
proxy: 'socks5://user:pass@proxy:1080',
|
||||
});
|
||||
|
||||
// With proxy object (bypass, separate auth fields)
|
||||
const browser = await launch({
|
||||
proxy: { server: 'http://proxy:8080', bypass: '.google.com', username: 'user', password: 'pass' },
|
||||
});
|
||||
|
||||
// Headed mode (visible browser window)
|
||||
const browser = await launch({ headless: false });
|
||||
|
||||
// Extra Chrome args
|
||||
const browser = await launch({
|
||||
args: ['--fingerprint=12345'],
|
||||
});
|
||||
|
||||
// With timezone and locale
|
||||
const browser = await launch({
|
||||
timezone: 'America/New_York',
|
||||
locale: 'en-US',
|
||||
});
|
||||
|
||||
// Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib)
|
||||
const browser = await launch({
|
||||
proxy: 'http://proxy:8080',
|
||||
geoip: true,
|
||||
});
|
||||
|
||||
// Browser + context in one call (timezone/locale set via binary flags)
|
||||
const context = await launchContext({
|
||||
userAgent: 'Custom UA',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'en-US',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
|
||||
// Persistent profile — stay logged in, bypass incognito detection, load extensions
|
||||
const ctx = await launchPersistentContext({
|
||||
userDataDir: './chrome-profile',
|
||||
headless: false,
|
||||
proxy: 'http://user:pass@proxy:8080',
|
||||
});
|
||||
const page = ctx.pages()[0] || await ctx.newPage();
|
||||
await page.goto('https://example.com');
|
||||
await ctx.close(); // profile saved — reuse same path to restore state
|
||||
```
|
||||
|
||||
### Auto Timezone/Locale from Proxy IP
|
||||
|
||||
When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's location. Install `mmdb-lib` to enable auto-detection from an offline GeoIP database (~70 MB, downloaded on first use):
|
||||
|
||||
```bash
|
||||
npm install mmdb-lib
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Auto-detect — timezone and locale set from proxy's IP geolocation
|
||||
const browser = await launch({ proxy: 'http://proxy:8080', geoip: true });
|
||||
|
||||
// Works with launchContext too
|
||||
const context = await launchContext({ proxy: 'http://proxy:8080', geoip: true });
|
||||
|
||||
// Explicit values always win over auto-detection
|
||||
const browser = await launch({ proxy: 'http://proxy:8080', geoip: true, timezone: 'Europe/London' });
|
||||
```
|
||||
|
||||
> **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
|
||||
import { ensureBinary, clearCache, binaryInfo, checkForUpdate } from 'cloakbrowser';
|
||||
|
||||
// Pre-download binary (e.g., during Docker build)
|
||||
await ensureBinary();
|
||||
|
||||
// Check installation
|
||||
console.log(binaryInfo());
|
||||
|
||||
// Force re-download
|
||||
clearCache();
|
||||
|
||||
// Manually check for newer Chromium version
|
||||
const newVersion = await checkForUpdate();
|
||||
if (newVersion) console.log(`Updated to ${newVersion}`);
|
||||
```
|
||||
|
||||
## Test Results
|
||||
|
||||
| Detection Service | Stock Browser | CloakBrowser |
|
||||
|---|---|---|
|
||||
| **reCAPTCHA v3** | 0.1 (bot) | **0.9** (human) |
|
||||
| **Cloudflare Turnstile** | FAIL | **PASS** |
|
||||
| **FingerprintJS** | DETECTED | **PASS** |
|
||||
| **BrowserScan** | DETECTED | **NORMAL** (4/4) |
|
||||
| **bot.incolumitas.com** | 13 fails | **1 fail** |
|
||||
| `navigator.webdriver` | `true` | **`false`** |
|
||||
| CDP detection | Detected | **Not detected** |
|
||||
| TLS fingerprint | Mismatch | **Identical to Chrome** |
|
||||
| | | **Tested against 30+ detection sites** |
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary |
|
||||
| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory |
|
||||
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL |
|
||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
||||
|
||||
## Migrate From Playwright
|
||||
|
||||
```diff
|
||||
- import { chromium } from 'playwright';
|
||||
- const browser = await chromium.launch();
|
||||
+ import { launch } from 'cloakbrowser';
|
||||
+ const browser = await launch();
|
||||
|
||||
const page = await browser.newPage();
|
||||
// ... rest of your code works unchanged
|
||||
```
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Chromium | Patches | Status |
|
||||
|---|---|---|---|
|
||||
| Linux x86_64 | 145 | 48 | ✅ Latest |
|
||||
| Linux arm64 (RPi, Graviton) | 145 | 48 | ✅ Latest |
|
||||
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
|
||||
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
|
||||
| Windows x86_64 | 145 | 48 | ✅ Latest |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 20
|
||||
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Site detects incognito / private browsing mode**
|
||||
|
||||
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launchPersistentContext()` instead — it runs with a real user profile:
|
||||
|
||||
```javascript
|
||||
import { launchPersistentContext } from 'cloakbrowser';
|
||||
|
||||
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)**
|
||||
|
||||
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
|
||||
|
||||
```javascript
|
||||
// Bad — sends CDP commands, reCAPTCHA detects this
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Good — invisible to the browser
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
```
|
||||
|
||||
Other tips for maximizing reCAPTCHA scores:
|
||||
- **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
|
||||
- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA
|
||||
- **Use a fixed fingerprint seed** (`--fingerprint=12345`) for consistent device identity across sessions
|
||||
- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes:
|
||||
```javascript
|
||||
await page.type('#email', 'user@example.com', { delay: 50 });
|
||||
```
|
||||
- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic
|
||||
|
||||
**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:
|
||||
```bash
|
||||
# Linux
|
||||
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.159.7\chrome.exe
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- 🌐 [Website](https://cloakbrowser.dev)
|
||||
- 🐛 [Bug reports & feature requests](https://github.com/CloakHQ/CloakBrowser/issues)
|
||||
- 📦 [PyPI (Python package)](https://pypi.org/project/cloakbrowser/)
|
||||
- 📖 [Full documentation](https://github.com/CloakHQ/CloakBrowser#readme)
|
||||
- 📧 Contact: cloakhq@pm.me
|
||||
|
||||
## License
|
||||
|
||||
- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE).
|
||||
- **CloakBrowser binary** (compiled Chromium) — free to use, no redistribution. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md).
|
||||
|
||||
Use against financial, banking, healthcare, or government authentication systems without authorization is expressly prohibited.
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Basic CloakBrowser example using Playwright API.
|
||||
*
|
||||
* Usage:
|
||||
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/basic-playwright.ts
|
||||
*/
|
||||
|
||||
import { launch } from "../src/index.js";
|
||||
|
||||
const browser = await launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto("https://example.com");
|
||||
console.log(`Title: ${await page.title()}`);
|
||||
console.log(`URL: ${page.url()}`);
|
||||
|
||||
await browser.close();
|
||||
console.log("Done.");
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Basic CloakBrowser example using Puppeteer API.
|
||||
*
|
||||
* Usage:
|
||||
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/basic-puppeteer.ts
|
||||
*/
|
||||
|
||||
import { launch } from "../src/puppeteer.js";
|
||||
|
||||
const browser = await launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto("https://example.com");
|
||||
console.log(`Title: ${await page.title()}`);
|
||||
console.log(`URL: ${page.url()}`);
|
||||
|
||||
await browser.close();
|
||||
console.log("Done.");
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Persistent context example: cookies and localStorage survive across sessions.
|
||||
*
|
||||
* Usage:
|
||||
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/persistent-context.ts
|
||||
*/
|
||||
|
||||
import { launchPersistentContext } from "../src/index.js";
|
||||
|
||||
const PROFILE_DIR = "./my-profile";
|
||||
|
||||
// Session 1 — set some state
|
||||
console.log("=== Session 1: Setting state ===");
|
||||
let ctx = await launchPersistentContext({
|
||||
userDataDir: PROFILE_DIR,
|
||||
headless: false,
|
||||
});
|
||||
let page = ctx.pages()[0] || (await ctx.newPage());
|
||||
await page.goto("https://example.com");
|
||||
await page.evaluate(() => {
|
||||
document.cookie = "session=abc123; path=/; max-age=3600";
|
||||
localStorage.setItem("user", "returning");
|
||||
});
|
||||
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
|
||||
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
|
||||
await ctx.close();
|
||||
|
||||
// Session 2 — state is restored
|
||||
console.log("\n=== Session 2: Verifying persistence ===");
|
||||
ctx = await launchPersistentContext({
|
||||
userDataDir: PROFILE_DIR,
|
||||
headless: false,
|
||||
});
|
||||
page = ctx.pages()[0] || (await ctx.newPage());
|
||||
await page.goto("https://example.com");
|
||||
console.log(`Cookie: ${await page.evaluate(() => document.cookie)}`);
|
||||
console.log(`localStorage: ${await page.evaluate(() => localStorage.getItem("user"))}`);
|
||||
await ctx.close();
|
||||
|
||||
console.log("\nDone!");
|
||||
@@ -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();
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Full stealth test suite — validates CloakBrowser against live detection services.
|
||||
* Mirrors Python examples/stealth_test.py.
|
||||
*
|
||||
* Usage:
|
||||
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/stealth-test.ts
|
||||
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/stealth-test.ts --proxy http://10.50.96.5:8888
|
||||
*/
|
||||
|
||||
import { launch } from "../src/index.js";
|
||||
|
||||
const PROXY = process.argv.includes("--proxy")
|
||||
? process.argv[process.argv.indexOf("--proxy") + 1]
|
||||
: undefined;
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
status: "PASS" | "FAIL" | "ERROR";
|
||||
verdict: string;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
console.log("=".repeat(60));
|
||||
console.log("CloakBrowser JS — Stealth Test Suite");
|
||||
console.log("=".repeat(60));
|
||||
console.log(`Proxy: ${PROXY || "none"}\n`);
|
||||
|
||||
const browser = await launch({ headless: true, proxy: PROXY });
|
||||
const page = await browser.newPage();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: bot.sannysoft.com
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testSannysoft() {
|
||||
console.log("--- bot.sannysoft.com ---");
|
||||
await page.goto("https://bot.sannysoft.com", {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const rows = document.querySelectorAll("table tr");
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
const failed: string[] = [];
|
||||
rows.forEach((r) => {
|
||||
const cells = r.querySelectorAll("td");
|
||||
if (cells.length >= 2) {
|
||||
total++;
|
||||
const key = cells[0]!.innerText.trim();
|
||||
const cls = cells[1]!.className || "";
|
||||
if (cls.includes("failed")) {
|
||||
failed.push(key);
|
||||
} else {
|
||||
passed++;
|
||||
}
|
||||
}
|
||||
});
|
||||
return { passed, total, failed };
|
||||
});
|
||||
|
||||
const verdict =
|
||||
result.failed.length === 0
|
||||
? `${result.passed}/${result.total} — ALL GREEN`
|
||||
: `${result.passed}/${result.total} (FAILED: ${result.failed.join(", ")})`;
|
||||
const status = result.failed.length === 0 ? "PASS" : "FAIL";
|
||||
console.log(`Result: [${status}] ${verdict}\n`);
|
||||
results.push({ name: "bot.sannysoft.com", status, verdict });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: bot.incolumitas.com
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testIncolumitas() {
|
||||
console.log("--- bot.incolumitas.com ---");
|
||||
await page.goto("https://bot.incolumitas.com", {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(12000); // needs time for all detection tests
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const text = document.body.innerText;
|
||||
const okMatches = text.match(/"(\w+)":\s*"OK"/g) || [];
|
||||
const failMatches = text.match(/"(\w+)":\s*"FAIL"/g) || [];
|
||||
const failedTests = failMatches.map((m) => {
|
||||
const match = m.match(/"(\w+)"/);
|
||||
return match ? match[1] : m;
|
||||
});
|
||||
return {
|
||||
passed: okMatches.length,
|
||||
failed: failMatches.length,
|
||||
failedTests,
|
||||
total: okMatches.length + failMatches.length,
|
||||
};
|
||||
});
|
||||
|
||||
const verdict =
|
||||
result.failed === 0
|
||||
? `${result.passed}/${result.total} — ALL GREEN`
|
||||
: `${result.passed}/${result.total} (FAILED: ${result.failedTests.join(", ")})`;
|
||||
// WEBDRIVER false positive is expected
|
||||
const status = result.failed <= 1 ? "PASS" : "FAIL";
|
||||
console.log(`Result: [${status}] ${verdict}\n`);
|
||||
results.push({ name: "bot.incolumitas.com", status, verdict });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: BrowserScan
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testBrowserScan() {
|
||||
console.log("--- BrowserScan ---");
|
||||
await page.goto("https://www.browserscan.net/bot-detection", {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const text = document.body.innerText;
|
||||
const normalMatches = text.match(/Normal/g);
|
||||
const abnormalMatches = text.match(/Abnormal/g);
|
||||
return {
|
||||
normal: normalMatches ? normalMatches.length : 0,
|
||||
abnormal: abnormalMatches ? abnormalMatches.length : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const verdict = `Normal: ${result.normal}, Abnormal: ${result.abnormal}`;
|
||||
const status = result.abnormal === 0 ? "PASS" : "FAIL";
|
||||
console.log(`Result: [${status}] ${verdict}\n`);
|
||||
results.push({ name: "BrowserScan", status, verdict });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 4: deviceandbrowserinfo.com
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testDeviceAndBrowserInfo() {
|
||||
console.log("--- deviceandbrowserinfo.com ---");
|
||||
await page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const text = document.body.innerText;
|
||||
const botMatch = text.match(/"isBot":\s*(true|false)/);
|
||||
const isBot = botMatch ? botMatch[1] === "true" : null;
|
||||
const checks: Record<string, boolean> = {};
|
||||
const patterns = [
|
||||
"isBot",
|
||||
"hasBotUserAgent",
|
||||
"hasWebdriverTrue",
|
||||
"isHeadlessChrome",
|
||||
"isAutomatedWithCDP",
|
||||
"hasSuspiciousWeakSignals",
|
||||
"isPlaywright",
|
||||
"hasInconsistentChromeObject",
|
||||
];
|
||||
patterns.forEach((p) => {
|
||||
const match = text.match(new RegExp('"' + p + '":\\s*(true|false)'));
|
||||
if (match) checks[p] = match[1] === "true";
|
||||
});
|
||||
return { isBot, checks };
|
||||
});
|
||||
|
||||
const trueFlags = Object.entries(result.checks)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k);
|
||||
const verdict =
|
||||
`isBot: ${result.isBot}` +
|
||||
(trueFlags.length > 0 ? ` (flagged: ${trueFlags.join(", ")})` : " — all clear");
|
||||
const status = !result.isBot ? "PASS" : "FAIL";
|
||||
console.log(`Result: [${status}] ${verdict}\n`);
|
||||
results.push({ name: "deviceandbrowserinfo.com", status, verdict });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5: FingerprintJS
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testFingerprintJS() {
|
||||
console.log("--- FingerprintJS ---");
|
||||
await page.goto("https://demo.fingerprint.com/web-scraping", {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
try {
|
||||
await page.click("button:has-text('Search')", { timeout: 5000 });
|
||||
await page.waitForTimeout(5000);
|
||||
} catch {
|
||||
// Search button may not be present
|
||||
}
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const text = document.body.innerText;
|
||||
const hasFlights =
|
||||
text.includes("Price per adult") || text.includes("$");
|
||||
const isBlocked =
|
||||
text.includes("request was blocked") ||
|
||||
text.includes("bot visit detected");
|
||||
return { passed: hasFlights && !isBlocked, isBlocked, hasFlights };
|
||||
});
|
||||
|
||||
const verdict = result.passed
|
||||
? "PASSED (flights shown)"
|
||||
: result.isBlocked
|
||||
? "BLOCKED"
|
||||
: "NO FLIGHTS";
|
||||
const status = result.passed ? "PASS" : "FAIL";
|
||||
console.log(`Result: [${status}] ${verdict}\n`);
|
||||
results.push({ name: "FingerprintJS", status, verdict });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 6: reCAPTCHA v3
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testRecaptcha() {
|
||||
console.log("--- reCAPTCHA v3 (Google) ---");
|
||||
await page.goto(
|
||||
"https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php",
|
||||
{ waitUntil: "networkidle", timeout: 30000 }
|
||||
);
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const text = document.body.innerText;
|
||||
const scoreMatch = text.match(/"score":\s*(\d+\.\d+)/);
|
||||
return {
|
||||
score: scoreMatch ? parseFloat(scoreMatch[1]) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const verdict = `Score: ${result.score ?? "N/A"}`;
|
||||
const status = (result.score ?? 0) >= 0.7 ? "PASS" : "FAIL";
|
||||
console.log(`Result: [${status}] ${verdict}\n`);
|
||||
results.push({ name: "reCAPTCHA v3", status, verdict });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run all tests
|
||||
// ---------------------------------------------------------------------------
|
||||
const tests = [
|
||||
testSannysoft,
|
||||
testIncolumitas,
|
||||
testBrowserScan,
|
||||
testDeviceAndBrowserInfo,
|
||||
testFingerprintJS,
|
||||
testRecaptcha,
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
await test();
|
||||
} catch (err) {
|
||||
const name = test.name.replace("test", "");
|
||||
console.log(`Error: ${err}\n`);
|
||||
results.push({ name, status: "ERROR", verdict: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Summary
|
||||
console.log("=".repeat(60));
|
||||
console.log("RESULTS SUMMARY");
|
||||
console.log("=".repeat(60));
|
||||
for (const r of results) {
|
||||
const icon = { PASS: "+", FAIL: "!", ERROR: "x" }[r.status];
|
||||
console.log(` [${icon}] ${r.name}: ${r.verdict}`);
|
||||
}
|
||||
const passedCount = results.filter((r) => r.status === "PASS").length;
|
||||
console.log(`\n ${passedCount}/${results.length} tests passed`);
|
||||
console.log("=".repeat(60));
|
||||
|
||||
process.exit(passedCount === results.length ? 0 : 1);
|
||||
Generated
+2988
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.3.26",
|
||||
"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",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./puppeteer": {
|
||||
"types": "./dist/puppeteer.d.ts",
|
||||
"import": "./dist/puppeteer.js"
|
||||
},
|
||||
"./human": {
|
||||
"types": "./dist/human/index.d.ts",
|
||||
"import": "./dist/human/index.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"cloakbrowser": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"stealth",
|
||||
"browser",
|
||||
"chromium",
|
||||
"playwright",
|
||||
"puppeteer",
|
||||
"scraping",
|
||||
"web-scraping",
|
||||
"anti-detect",
|
||||
"antidetect",
|
||||
"undetected",
|
||||
"bot-detection",
|
||||
"fingerprint",
|
||||
"recaptcha",
|
||||
"cloudflare",
|
||||
"turnstile",
|
||||
"datadome",
|
||||
"captcha",
|
||||
"headless",
|
||||
"automation",
|
||||
"ai-agent"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CloakHQ/cloakbrowser",
|
||||
"directory": "js"
|
||||
},
|
||||
"homepage": "https://github.com/CloakHQ/cloakbrowser#javascript--nodejs",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mmdb-lib": ">=2.0.0",
|
||||
"playwright-core": ">=1.40.0",
|
||||
"puppeteer-core": ">=21.0.0",
|
||||
"socks-proxy-agent": ">=10.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright-core": {
|
||||
"optional": true
|
||||
},
|
||||
"puppeteer-core": {
|
||||
"optional": true
|
||||
},
|
||||
"mmdb-lib": {
|
||||
"optional": true
|
||||
},
|
||||
"socks-proxy-agent": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"tar": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"playwright-core": "^1.40.0",
|
||||
"puppeteer-core": "^21.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Shared argument builder for Playwright and Puppeteer wrappers.
|
||||
*/
|
||||
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { getDefaultStealthArgs } from "./config.js";
|
||||
|
||||
const DEBUG = /\bcloakbrowser\b/.test(process.env.DEBUG ?? "");
|
||||
|
||||
/**
|
||||
* Build deduplicated Chromium CLI args from stealth defaults + user overrides.
|
||||
*
|
||||
* Priority: stealth defaults < user args < dedicated params (timezone/locale).
|
||||
*/
|
||||
export function buildArgs(options: LaunchOptions): string[] {
|
||||
const seen = new Map<string, string>();
|
||||
|
||||
if (options.stealthArgs !== false) {
|
||||
for (const arg of getDefaultStealthArgs()) {
|
||||
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];
|
||||
if (seen.has(key)) {
|
||||
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${arg}`);
|
||||
}
|
||||
seen.set(key, arg);
|
||||
}
|
||||
}
|
||||
if (options.timezone) {
|
||||
const key = "--fingerprint-timezone";
|
||||
const flag = `${key}=${options.timezone}`;
|
||||
if (seen.has(key)) {
|
||||
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`);
|
||||
}
|
||||
seen.set(key, flag);
|
||||
}
|
||||
if (options.locale) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Stealth configuration and platform detection for cloakbrowser.
|
||||
* Mirrors Python cloakbrowser/config.py.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Read wrapper version from package.json (single source of truth)
|
||||
let WRAPPER_VERSION = "0.0.0";
|
||||
try {
|
||||
const _configDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const _pkgPath = path.resolve(_configDir, "..", "package.json");
|
||||
const _pkg = JSON.parse(fs.readFileSync(_pkgPath, "utf-8")) as { version: string };
|
||||
WRAPPER_VERSION = _pkg.version;
|
||||
} catch {
|
||||
// Fallback — package.json not found (bundled or unusual layout).
|
||||
// Wrapper update check will compare against 0.0.0 and always suggest updating.
|
||||
}
|
||||
export { WRAPPER_VERSION };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chromium version shipped with this release.
|
||||
// Different platforms may ship different versions during transition periods.
|
||||
// CHROMIUM_VERSION is the latest across all platforms (for display/reference).
|
||||
// Use getChromiumVersion() for the current platform's actual version.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHROMIUM_VERSION = "146.0.7680.177.3";
|
||||
|
||||
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
|
||||
"linux-x64": "146.0.7680.177.3",
|
||||
"linux-arm64": "146.0.7680.177.3",
|
||||
"darwin-arm64": "145.0.7632.109.2",
|
||||
"darwin-x64": "145.0.7632.109.2",
|
||||
"windows-x64": "146.0.7680.177.4",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform detection
|
||||
// ---------------------------------------------------------------------------
|
||||
const SUPPORTED_PLATFORMS: Record<string, string> = {
|
||||
"linux-x64": "linux-x64",
|
||||
"linux-arm64": "linux-arm64",
|
||||
"darwin-arm64": "darwin-arm64",
|
||||
"darwin-x64": "darwin-x64",
|
||||
"win32-x64": "windows-x64",
|
||||
};
|
||||
|
||||
// Platforms with pre-built binaries available for download (derived from version map).
|
||||
const AVAILABLE_PLATFORMS = new Set(Object.keys(PLATFORM_CHROMIUM_VERSIONS));
|
||||
|
||||
export function getChromiumVersion(): string {
|
||||
const tag = getPlatformTag();
|
||||
return PLATFORM_CHROMIUM_VERSIONS[tag] ?? CHROMIUM_VERSION;
|
||||
}
|
||||
|
||||
export function getPlatformTag(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
// Map Node.js platform/arch to our tag format
|
||||
let key: string;
|
||||
if (platform === "linux" && arch === "x64") key = "linux-x64";
|
||||
else if (platform === "linux" && arch === "arm64") key = "linux-arm64";
|
||||
else if (platform === "darwin" && arch === "arm64") key = "darwin-arm64";
|
||||
else if (platform === "darwin" && arch === "x64") key = "darwin-x64";
|
||||
else if (platform === "win32" && arch === "x64") key = "win32-x64";
|
||||
else {
|
||||
const supported = Object.values(SUPPORTED_PLATFORMS).join(", ");
|
||||
throw new Error(
|
||||
`Unsupported platform: ${platform} ${arch}. Supported: ${supported}`
|
||||
);
|
||||
}
|
||||
|
||||
return SUPPORTED_PLATFORMS[key]!;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary cache paths
|
||||
// ---------------------------------------------------------------------------
|
||||
export function getCacheDir(): string {
|
||||
const custom = process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
if (custom) return custom;
|
||||
return path.join(os.homedir(), ".cloakbrowser");
|
||||
}
|
||||
|
||||
export function getBinaryDir(version?: string): string {
|
||||
return path.join(getCacheDir(), `chromium-${version || getChromiumVersion()}`);
|
||||
}
|
||||
|
||||
export function getBinaryPath(version?: string): string {
|
||||
const binaryDir = getBinaryDir(version);
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(binaryDir, "Chromium.app", "Contents", "MacOS", "Chromium");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(binaryDir, "chrome.exe");
|
||||
}
|
||||
return path.join(binaryDir, "chrome");
|
||||
}
|
||||
|
||||
export function checkPlatformAvailable(): void {
|
||||
if (getLocalBinaryOverride()) return;
|
||||
|
||||
const tag = getPlatformTag(); // throws if unsupported entirely
|
||||
if (!AVAILABLE_PLATFORMS.has(tag)) {
|
||||
const available = [...AVAILABLE_PLATFORMS].sort().join(", ");
|
||||
throw new Error(
|
||||
`CloakBrowser — Pre-built binaries are currently only available for: ${available}.\n\n` +
|
||||
`To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download URL
|
||||
// ---------------------------------------------------------------------------
|
||||
export const DOWNLOAD_BASE_URL =
|
||||
process.env.CLOAKBROWSER_DOWNLOAD_URL ||
|
||||
"https://cloakbrowser.dev";
|
||||
|
||||
export const GITHUB_API_URL =
|
||||
"https://api.github.com/repos/CloakHQ/cloakbrowser/releases";
|
||||
|
||||
export const GITHUB_DOWNLOAD_BASE_URL =
|
||||
"https://github.com/CloakHQ/cloakbrowser/releases/download";
|
||||
|
||||
export function getArchiveExt(): string {
|
||||
return process.platform === "win32" ? ".zip" : ".tar.gz";
|
||||
}
|
||||
|
||||
export function getArchiveName(tag?: string): string {
|
||||
return `cloakbrowser-${tag || getPlatformTag()}${getArchiveExt()}`;
|
||||
}
|
||||
|
||||
export function getDownloadUrl(version?: string): string {
|
||||
const v = version || getChromiumVersion();
|
||||
return `${DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getFallbackDownloadUrl(version?: string): string {
|
||||
const v = version || getChromiumVersion();
|
||||
return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`;
|
||||
}
|
||||
|
||||
export function getEffectiveVersion(): string {
|
||||
const base = getChromiumVersion();
|
||||
const cacheDir = getCacheDir();
|
||||
// Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
for (const name of [`latest_version_${getPlatformTag()}`, "latest_version"]) {
|
||||
const marker = path.join(cacheDir, name);
|
||||
try {
|
||||
if (fs.existsSync(marker)) {
|
||||
const version = fs.readFileSync(marker, "utf-8").trim();
|
||||
if (version && versionNewer(version, base)) {
|
||||
const binary = getBinaryPath(version);
|
||||
if (fs.existsSync(binary)) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Marker unreadable — try next
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function parseVersion(v: string): number[] {
|
||||
return v.split(".").map(Number);
|
||||
}
|
||||
|
||||
export function versionNewer(a: string, b: string): boolean {
|
||||
const va = parseVersion(a);
|
||||
const vb = parseVersion(b);
|
||||
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
||||
if ((va[i] ?? 0) > (vb[i] ?? 0)) return true;
|
||||
if ((va[i] ?? 0) < (vb[i] ?? 0)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local binary override
|
||||
// ---------------------------------------------------------------------------
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default viewport — realistic maximized Chrome on 1080p Windows
|
||||
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
|
||||
|
||||
export function getDefaultStealthArgs(): string[] {
|
||||
const seed = Math.floor(Math.random() * 90000) + 10000; // 10000-99999
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const base = [
|
||||
"--no-sandbox",
|
||||
`--fingerprint=${seed}`,
|
||||
];
|
||||
|
||||
if (isMac) {
|
||||
// macOS: run as native Mac browser — GPU/UA match natively
|
||||
return [...base, "--fingerprint-platform=macos"];
|
||||
}
|
||||
|
||||
// Linux/Windows: spoof as Windows desktop
|
||||
// Hardware concurrency, device memory, screen, window size, and GPU are
|
||||
// auto-generated by the binary from the seed (v14+).
|
||||
return [...base, "--fingerprint-platform=windows"];
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Binary download and cache management for cloakbrowser.
|
||||
* Downloads the patched Chromium binary on first use, caches it locally.
|
||||
* Mirrors Python cloakbrowser/download.py.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { extract as tarExtract } from "tar";
|
||||
|
||||
import type { BinaryInfo } from "./types.js";
|
||||
import {
|
||||
DOWNLOAD_BASE_URL,
|
||||
GITHUB_API_URL,
|
||||
GITHUB_DOWNLOAD_BASE_URL,
|
||||
WRAPPER_VERSION,
|
||||
checkPlatformAvailable,
|
||||
getArchiveExt,
|
||||
getArchiveName,
|
||||
getBinaryDir,
|
||||
getBinaryPath,
|
||||
getCacheDir,
|
||||
getChromiumVersion,
|
||||
getDownloadUrl,
|
||||
getEffectiveVersion,
|
||||
getFallbackDownloadUrl,
|
||||
getLocalBinaryOverride,
|
||||
getPlatformTag,
|
||||
versionNewer,
|
||||
} from "./config.js";
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes
|
||||
const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ensure the stealth Chromium binary is available. Download if needed.
|
||||
* Returns the path to the chrome executable.
|
||||
*/
|
||||
export async function ensureBinary(): Promise<string> {
|
||||
// Check for local override
|
||||
const localOverride = getLocalBinaryOverride();
|
||||
if (localOverride) {
|
||||
if (!fs.existsSync(localOverride)) {
|
||||
throw new Error(
|
||||
`CLOAKBROWSER_BINARY_PATH set to '${localOverride}' but file does not exist`
|
||||
);
|
||||
}
|
||||
console.log(`[cloakbrowser] Using local binary override: ${localOverride}`);
|
||||
return localOverride;
|
||||
}
|
||||
|
||||
// Fail fast if no binary available for this platform
|
||||
checkPlatformAvailable();
|
||||
|
||||
// Check for auto-updated version first, then fall back to hardcoded
|
||||
const effective = getEffectiveVersion();
|
||||
const binaryPath = getBinaryPath(effective);
|
||||
|
||||
if (fs.existsSync(binaryPath) && isExecutable(binaryPath)) {
|
||||
showWelcome();
|
||||
maybeTriggerUpdateCheck();
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
// Fall back to platform's hardcoded version if effective version binary doesn't exist
|
||||
const platformVersion = getChromiumVersion();
|
||||
if (effective !== platformVersion) {
|
||||
const fallbackPath = getBinaryPath();
|
||||
if (fs.existsSync(fallbackPath) && isExecutable(fallbackPath)) {
|
||||
maybeTriggerUpdateCheck();
|
||||
return fallbackPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Download platform's hardcoded version
|
||||
console.log(
|
||||
`[cloakbrowser] Stealth Chromium ${platformVersion} not found. Downloading for ${getPlatformTag()}...`
|
||||
);
|
||||
await downloadAndExtract();
|
||||
|
||||
const downloadedPath = getBinaryPath();
|
||||
if (!fs.existsSync(downloadedPath)) {
|
||||
throw new Error(
|
||||
`Download completed but binary not found at expected path: ${downloadedPath}. ` +
|
||||
`This may indicate a packaging issue. Please report at ` +
|
||||
`https://github.com/CloakHQ/cloakbrowser/issues`
|
||||
);
|
||||
}
|
||||
|
||||
maybeTriggerUpdateCheck();
|
||||
return downloadedPath;
|
||||
}
|
||||
|
||||
/** Remove all cached binaries. Forces re-download on next launch. */
|
||||
export function clearCache(): void {
|
||||
const cacheDir = getCacheDir();
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
console.log(`[cloakbrowser] Cache cleared: ${cacheDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Return info about the current binary installation. */
|
||||
export function binaryInfo(): BinaryInfo {
|
||||
const effective = getEffectiveVersion();
|
||||
const binaryPath = getBinaryPath(effective);
|
||||
return {
|
||||
version: effective,
|
||||
platform: getPlatformTag(),
|
||||
binaryPath,
|
||||
installed: fs.existsSync(binaryPath),
|
||||
cacheDir: getBinaryDir(effective),
|
||||
downloadUrl: getDownloadUrl(effective),
|
||||
};
|
||||
}
|
||||
|
||||
/** Manually check for a newer Chromium version. Returns new version or null. */
|
||||
export async function checkForUpdate(): Promise<string | null> {
|
||||
const latest = await getLatestChromiumVersion();
|
||||
if (!latest || !versionNewer(latest, getChromiumVersion())) return null;
|
||||
|
||||
const binaryDir = getBinaryDir(latest);
|
||||
if (fs.existsSync(binaryDir)) {
|
||||
writeVersionMarker(latest);
|
||||
return latest;
|
||||
}
|
||||
|
||||
console.log(`[cloakbrowser] Downloading Chromium ${latest}...`);
|
||||
await downloadAndExtract(latest);
|
||||
writeVersionMarker(latest);
|
||||
return latest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Welcome message (shown once per install)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showWelcome(): void {
|
||||
const marker = path.join(getCacheDir(), ".welcome_shown");
|
||||
if (fs.existsSync(marker)) return;
|
||||
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, "");
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function downloadAndExtract(version?: string): Promise<void> {
|
||||
const primaryUrl = getDownloadUrl(version);
|
||||
const fallbackUrl = getFallbackDownloadUrl(version);
|
||||
const binaryDir = getBinaryDir(version);
|
||||
const binaryPath = getBinaryPath(version);
|
||||
|
||||
// Create cache dir
|
||||
fs.mkdirSync(path.dirname(binaryDir), { recursive: true });
|
||||
|
||||
// Download to temp file (atomic — no partial downloads in cache)
|
||||
const tmpPath = path.join(
|
||||
path.dirname(binaryDir),
|
||||
`_download_${Date.now()}${getArchiveExt()}`
|
||||
);
|
||||
|
||||
try {
|
||||
// Try primary server, fall back to GitHub Releases (skip fallback if custom URL)
|
||||
try {
|
||||
await downloadFile(primaryUrl, tmpPath);
|
||||
} catch (primaryErr) {
|
||||
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) {
|
||||
throw primaryErr;
|
||||
}
|
||||
console.warn(
|
||||
`[cloakbrowser] Primary download failed (${primaryErr instanceof Error ? primaryErr.message : primaryErr}), trying GitHub Releases...`
|
||||
);
|
||||
await downloadFile(fallbackUrl, tmpPath);
|
||||
}
|
||||
|
||||
// Verify checksum before extraction
|
||||
if (process.env.CLOAKBROWSER_SKIP_CHECKSUM?.toLowerCase() !== "true") {
|
||||
await verifyDownloadChecksum(tmpPath, version);
|
||||
}
|
||||
|
||||
await extractArchive(tmpPath, binaryDir, binaryPath);
|
||||
showWelcome();
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
fs.unlinkSync(tmpPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyDownloadChecksum(filePath: string, version?: string): Promise<void> {
|
||||
const checksums = await fetchChecksums(version);
|
||||
const tarballName = getArchiveName();
|
||||
|
||||
if (!checksums) {
|
||||
console.warn("[cloakbrowser] SHA256SUMS not available for this release — skipping checksum verification");
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = checksums.get(tarballName);
|
||||
if (!expected) {
|
||||
console.warn(`[cloakbrowser] SHA256SUMS found but no entry for ${tarballName} — skipping verification`);
|
||||
return;
|
||||
}
|
||||
|
||||
await verifyChecksum(filePath, expected);
|
||||
}
|
||||
|
||||
/** @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;
|
||||
|
||||
// Respect custom URL contract — no GitHub fallback when custom URL is set
|
||||
const urls = [`${DOWNLOAD_BASE_URL}/chromium-v${v}/SHA256SUMS`];
|
||||
if (!hasCustomUrl) {
|
||||
urls.push(`${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/SHA256SUMS`);
|
||||
}
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!resp.ok) continue;
|
||||
return parseChecksums(await resp.text());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export function parseChecksums(text: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const line of text.trim().split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i);
|
||||
if (match) {
|
||||
result.set(match[2]!, match[1]!.toLowerCase());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function verifyChecksum(filePath: string, expectedHash: string): Promise<void> {
|
||||
const hash = createHash("sha256");
|
||||
const stream = fs.createReadStream(filePath);
|
||||
for await (const chunk of stream) {
|
||||
hash.update(chunk);
|
||||
}
|
||||
const actual = hash.digest("hex").toLowerCase();
|
||||
if (actual !== expectedHash) {
|
||||
throw new Error(
|
||||
`Checksum verification failed!\n` +
|
||||
` Expected: ${expectedHash}\n` +
|
||||
` Got: ${actual}\n` +
|
||||
` File may be corrupted or tampered with. ` +
|
||||
`Please retry or report at https://github.com/CloakHQ/cloakbrowser/issues`
|
||||
);
|
||||
}
|
||||
console.log("[cloakbrowser] Checksum verified: SHA-256 OK");
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
console.log(`[cloakbrowser] Downloading from ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
||||
|
||||
// Create file stream early so we can ensure cleanup on error
|
||||
const fileStream = createWriteStream(dest);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: HTTP ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Download failed: empty response body");
|
||||
}
|
||||
|
||||
const total = Number(response.headers.get("content-length") || 0);
|
||||
let downloaded = 0;
|
||||
let lastLoggedPct = -1;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
|
||||
// Stream chunks to file with progress logging
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
fileStream.write(value);
|
||||
downloaded += value.length;
|
||||
|
||||
if (total > 0) {
|
||||
const pct = Math.floor((downloaded / total) * 100);
|
||||
if (pct >= lastLoggedPct + 10) {
|
||||
lastLoggedPct = pct;
|
||||
const dlMB = Math.floor(downloaded / (1024 * 1024));
|
||||
const totalMB = Math.floor(total / (1024 * 1024));
|
||||
console.log(
|
||||
`[cloakbrowser] Download progress: ${pct}% (${dlMB}/${totalMB} MB)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for file stream to fully close (not just finish)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fileStream.end();
|
||||
fileStream.on("close", () => resolve());
|
||||
fileStream.on("error", reject);
|
||||
});
|
||||
|
||||
const sizeMB = Math.floor(fs.statSync(dest).size / (1024 * 1024));
|
||||
console.log(`[cloakbrowser] Download complete: ${sizeMB} MB`);
|
||||
} catch (err) {
|
||||
// Ensure file stream is destroyed on error to release the handle
|
||||
if (!fileStream.destroyed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
fileStream.destroy();
|
||||
fileStream.on("close", () => resolve());
|
||||
// Safety timeout in case close never fires
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function extractArchive(
|
||||
archivePath: string,
|
||||
destDir: string,
|
||||
binaryPath?: string
|
||||
): Promise<void> {
|
||||
console.log(`[cloakbrowser] Extracting to ${destDir}`);
|
||||
|
||||
// Clean existing dir if partial download existed
|
||||
if (fs.existsSync(destDir)) {
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
if (archivePath.endsWith(".zip")) {
|
||||
await extractZip(archivePath, destDir);
|
||||
} else {
|
||||
await extractTar(archivePath, destDir);
|
||||
}
|
||||
|
||||
// Flatten single subdirectory if needed
|
||||
flattenSingleSubdir(destDir);
|
||||
|
||||
// Make binary executable (skip on Windows — no-op / AV lock risk)
|
||||
const bp = binaryPath || getBinaryPath();
|
||||
if (process.platform !== "win32" && fs.existsSync(bp)) {
|
||||
fs.chmodSync(bp, 0o755);
|
||||
}
|
||||
|
||||
// macOS: remove quarantine/provenance xattrs to prevent Gatekeeper prompts
|
||||
if (process.platform === "darwin") {
|
||||
removeQuarantine(destDir);
|
||||
}
|
||||
|
||||
if (fs.existsSync(bp)) {
|
||||
console.log(`[cloakbrowser] Binary ready: ${bp}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractTar(archivePath: string, destDir: string): Promise<void> {
|
||||
await tarExtract({
|
||||
file: archivePath,
|
||||
cwd: destDir,
|
||||
strip: 0,
|
||||
filter: (entryPath: string) => {
|
||||
if (path.isAbsolute(entryPath) || entryPath.includes("..")) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Skipping suspicious archive entry: ${entryPath}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
// Brief delay to ensure OS fully releases file handles (Windows)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// PowerShell 5.1's Expand-Archive uses .NET FileStream which can conflict
|
||||
// with recently-closed Node.js file handles. Use ZipFile API directly.
|
||||
execFileSync("powershell", [
|
||||
"-NoProfile", "-Command",
|
||||
`Add-Type -AssemblyName System.IO.Compression.FileSystem; ` +
|
||||
`[System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath}', '${destDir}')`,
|
||||
], { timeout: 120_000 });
|
||||
} else {
|
||||
execFileSync("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If extraction created a single subdirectory, move its contents up.
|
||||
* Many tarballs wrap files in a top-level directory.
|
||||
*/
|
||||
function flattenSingleSubdir(destDir: string): void {
|
||||
const entries = fs.readdirSync(destDir);
|
||||
if (entries.length === 1) {
|
||||
const subdir = path.join(destDir, entries[0]!);
|
||||
// Never flatten .app bundles — macOS needs the bundle structure
|
||||
if (entries[0]!.endsWith(".app")) return;
|
||||
if (fs.statSync(subdir).isDirectory()) {
|
||||
const children = fs.readdirSync(subdir);
|
||||
for (const child of children) {
|
||||
fs.renameSync(
|
||||
path.join(subdir, child),
|
||||
path.join(destDir, child)
|
||||
);
|
||||
}
|
||||
fs.rmdirSync(subdir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove macOS quarantine/provenance xattrs so Gatekeeper doesn't block the binary. */
|
||||
function removeQuarantine(dirPath: string): void {
|
||||
try {
|
||||
execFileSync("xattr", ["-cr", dirPath], { timeout: 30_000 });
|
||||
} catch {
|
||||
// Non-fatal — user can manually run: xattr -cr ~/.cloakbrowser/
|
||||
}
|
||||
}
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function shouldCheckForUpdate(): boolean {
|
||||
if (process.env.CLOAKBROWSER_AUTO_UPDATE?.toLowerCase() === "false")
|
||||
return false;
|
||||
if (getLocalBinaryOverride()) return false;
|
||||
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) return false;
|
||||
|
||||
const checkFile = path.join(getCacheDir(), ".last_update_check");
|
||||
try {
|
||||
const lastCheck = Number(fs.readFileSync(checkFile, "utf-8").trim());
|
||||
if (Date.now() - lastCheck < UPDATE_CHECK_INTERVAL_MS) return false;
|
||||
} catch {
|
||||
/* file doesn't exist or unreadable */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export async function getLatestChromiumVersion(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch(`${GITHUB_API_URL}?per_page=10`, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const releases = (await resp.json()) as Array<{
|
||||
tag_name: string;
|
||||
draft: boolean;
|
||||
assets: Array<{ name: string }>;
|
||||
}>;
|
||||
const platformTarball = getArchiveName();
|
||||
for (const release of releases) {
|
||||
if (release.tag_name.startsWith("chromium-v") && !release.draft) {
|
||||
const assetNames = new Set(
|
||||
(release.assets ?? []).map((a) => a.name)
|
||||
);
|
||||
if (assetNames.has(platformTarball)) {
|
||||
return release.tag_name.replace(/^chromium-v/, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeVersionMarker(version: string): void {
|
||||
const cacheDir = getCacheDir();
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const marker = path.join(cacheDir, `latest_version_${getPlatformTag()}`);
|
||||
const tmp = `${marker}.tmp`;
|
||||
fs.writeFileSync(tmp, version);
|
||||
fs.renameSync(tmp, marker);
|
||||
}
|
||||
|
||||
let wrapperUpdateChecked = false;
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export function resetWrapperUpdateChecked(): void {
|
||||
wrapperUpdateChecked = false;
|
||||
}
|
||||
|
||||
/** @internal Exported for testing only. */
|
||||
export async function checkWrapperUpdate(): Promise<void> {
|
||||
if (wrapperUpdateChecked) return;
|
||||
wrapperUpdateChecked = true;
|
||||
if (process.env.CLOAKBROWSER_AUTO_UPDATE?.toLowerCase() === "false") return;
|
||||
if (process.env.CLOAKBROWSER_DOWNLOAD_URL) return;
|
||||
try {
|
||||
const resp = await fetch("https://registry.npmjs.org/cloakbrowser/latest", {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = (await resp.json()) as { version: string };
|
||||
if (data.version && versionNewer(data.version, WRAPPER_VERSION)) {
|
||||
console.warn(
|
||||
`[cloakbrowser] Update available: ${WRAPPER_VERSION} → ${data.version}. ` +
|
||||
`Run: npm install cloakbrowser@latest`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — never block binary update check
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndDownloadUpdate(): Promise<void> {
|
||||
try {
|
||||
// Record check timestamp first (rate limiting)
|
||||
const cacheDir = getCacheDir();
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(cacheDir, ".last_update_check"),
|
||||
String(Date.now())
|
||||
);
|
||||
|
||||
const platformVersion = getChromiumVersion();
|
||||
const latest = await getLatestChromiumVersion();
|
||||
if (!latest || !versionNewer(latest, platformVersion)) return;
|
||||
|
||||
// Already downloaded?
|
||||
if (fs.existsSync(getBinaryDir(latest))) {
|
||||
writeVersionMarker(latest);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[cloakbrowser] Newer Chromium available: ${latest} (current: ${platformVersion}). Downloading in background...`
|
||||
);
|
||||
await downloadAndExtract(latest);
|
||||
writeVersionMarker(latest);
|
||||
console.log(
|
||||
`[cloakbrowser] Background update complete: Chromium ${latest} ready. Will use on next launch.`
|
||||
);
|
||||
} catch (err) {
|
||||
// Background update failed — don't disrupt the user
|
||||
if (process.env.DEBUG) {
|
||||
console.error("[cloakbrowser] Background update failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maybeTriggerUpdateCheck(): void {
|
||||
// Wrapper update: once per process, not rate-limited
|
||||
if (!wrapperUpdateChecked) {
|
||||
checkWrapperUpdate().catch(() => { });
|
||||
}
|
||||
|
||||
// Binary update: rate-limited to once per hour
|
||||
if (!shouldCheckForUpdate()) return;
|
||||
checkAndDownloadUpdate().catch(() => { });
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* GeoIP-based timezone and locale detection from proxy IP.
|
||||
*
|
||||
* Optional feature — requires `mmdb-lib` package:
|
||||
* npm install mmdb-lib
|
||||
*
|
||||
* Downloads GeoLite2-City.mmdb (~70 MB) on first use,
|
||||
* caches in `~/.cloakbrowser/geoip/`.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
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, isSocksProxy, reconstructSocksUrl, type ProxyDict } from "./proxy.js";
|
||||
|
||||
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed
|
||||
const GEOIP_DB_URL =
|
||||
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
|
||||
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
|
||||
const GEOIP_UPDATE_INTERVAL_MS = 30 * 86_400_000; // 30 days
|
||||
|
||||
/** Country ISO code → BCP 47 locale (covers ~90% of proxy traffic). */
|
||||
export const COUNTRY_LOCALE_MAP: Record<string, string> = {
|
||||
US: "en-US", GB: "en-GB", AU: "en-AU", CA: "en-CA", NZ: "en-NZ",
|
||||
IE: "en-IE", ZA: "en-ZA", SG: "en-SG",
|
||||
DE: "de-DE", AT: "de-AT", CH: "de-CH",
|
||||
FR: "fr-FR", BE: "fr-BE",
|
||||
ES: "es-ES", MX: "es-MX", AR: "es-AR", CO: "es-CO", CL: "es-CL",
|
||||
BR: "pt-BR", PT: "pt-PT",
|
||||
IT: "it-IT", NL: "nl-NL",
|
||||
JP: "ja-JP", KR: "ko-KR", CN: "zh-CN", TW: "zh-TW", HK: "zh-HK",
|
||||
RU: "ru-RU", UA: "uk-UA", PL: "pl-PL", CZ: "cs-CZ", RO: "ro-RO",
|
||||
IL: "he-IL", TR: "tr-TR", SA: "ar-SA", AE: "ar-AE", EG: "ar-EG",
|
||||
IN: "hi-IN", ID: "id-ID", PH: "en-PH",
|
||||
TH: "th-TH", VN: "vi-VN", MY: "ms-MY",
|
||||
SE: "sv-SE", NO: "nb-NO", DK: "da-DK", FI: "fi-FI",
|
||||
GR: "el-GR", HU: "hu-HU", BG: "bg-BG",
|
||||
};
|
||||
|
||||
export interface GeoResult {
|
||||
timezone: string | null;
|
||||
locale: string | null;
|
||||
exitIp: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve timezone and locale from a proxy's IP address.
|
||||
* Returns `{ timezone, locale }` — either may be null on failure.
|
||||
* Never throws.
|
||||
*/
|
||||
export async function resolveProxyGeo(
|
||||
proxyUrl: string
|
||||
): Promise<GeoResult> {
|
||||
let Reader: any;
|
||||
try {
|
||||
const mmdb = await import("mmdb-lib");
|
||||
Reader = mmdb.default?.Reader ?? mmdb.Reader;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"mmdb-lib is required for geoip: true. Install it with:\n npm install mmdb-lib"
|
||||
);
|
||||
}
|
||||
|
||||
const dbPath = await ensureGeoipDb();
|
||||
if (!dbPath) return { timezone: null, locale: null, exitIp: null };
|
||||
|
||||
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||
let ip = await resolveExitIp(proxyUrl);
|
||||
if (!ip) ip = await resolveProxyIp(proxyUrl);
|
||||
if (!ip) return { timezone: null, locale: null, exitIp: null };
|
||||
|
||||
try {
|
||||
const buf = fs.readFileSync(dbPath);
|
||||
const reader = new Reader(buf);
|
||||
const result = reader.get(ip) as any;
|
||||
const timezone: string | null = result?.location?.time_zone ?? null;
|
||||
const countryCode: string | null = result?.country?.iso_code ?? null;
|
||||
const locale =
|
||||
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
|
||||
return { timezone, locale, exitIp: ip };
|
||||
} catch {
|
||||
return { timezone: null, locale: null, exitIp: ip };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy IP resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export async function resolveProxyIp(
|
||||
proxyUrl: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const url = new URL(proxyUrl);
|
||||
const hostname = url.hostname;
|
||||
if (!hostname) return null;
|
||||
|
||||
// Already a literal IP?
|
||||
if (net.isIP(hostname)) return hostname;
|
||||
|
||||
// DNS resolve
|
||||
const { address } = await dns.lookup(hostname);
|
||||
return address;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
// Quick check for common private ranges
|
||||
if (ip.startsWith("10.") || ip.startsWith("127.") || ip === "::1") return true;
|
||||
if (ip.startsWith("172.")) {
|
||||
const second = parseInt(ip.split(".")[1], 10);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
if (ip.startsWith("192.168.")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const IP_ECHO_URLS = [
|
||||
"https://api.ipify.org",
|
||||
"https://checkip.amazonaws.com",
|
||||
"https://ifconfig.me/ip",
|
||||
];
|
||||
|
||||
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
||||
const isSocks = isSocksProxy(proxyUrl);
|
||||
|
||||
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
|
||||
if (isSocks) {
|
||||
let SocksProxyAgent: typeof import("socks-proxy-agent").SocksProxyAgent;
|
||||
try {
|
||||
({ SocksProxyAgent } = await import("socks-proxy-agent"));
|
||||
} catch {
|
||||
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
|
||||
return null;
|
||||
}
|
||||
const { default: https } = await import("node:https");
|
||||
const agent = new SocksProxyAgent(proxyUrl);
|
||||
|
||||
for (const echoUrl of IP_ECHO_URLS) {
|
||||
try {
|
||||
const ip = await new Promise<string | null>((resolve) => {
|
||||
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
res.on("end", () => {
|
||||
const ip = data.trim();
|
||||
resolve(net.isIP(ip) ? ip : null);
|
||||
});
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
req.on("timeout", () => { req.destroy(); resolve(null); });
|
||||
req.end();
|
||||
});
|
||||
if (ip) return ip;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use a CONNECT tunnel via http
|
||||
try {
|
||||
const { default: http } = await import("node:http");
|
||||
const { default: https } = await import("node:https");
|
||||
const proxyUrlObj = new URL(proxyUrl);
|
||||
|
||||
for (const echoUrl of IP_ECHO_URLS) {
|
||||
try {
|
||||
const ip = await new Promise<string | null>((resolve, reject) => {
|
||||
const targetUrl = new URL(echoUrl);
|
||||
const connectReq = http.request({
|
||||
host: proxyUrlObj.hostname,
|
||||
port: parseInt(proxyUrlObj.port || "80", 10),
|
||||
method: "CONNECT",
|
||||
path: `${targetUrl.hostname}:443`,
|
||||
headers: proxyUrlObj.username
|
||||
? {
|
||||
"Proxy-Authorization":
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
`${decodeURIComponent(proxyUrlObj.username)}:${decodeURIComponent(proxyUrlObj.password || "")}`
|
||||
).toString("base64"),
|
||||
}
|
||||
: {},
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
connectReq.on("connect", (_res, socket) => {
|
||||
const req = https.request(
|
||||
echoUrl,
|
||||
{ socket, timeout: 5_000 } as any,
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
res.on("end", () => {
|
||||
const ip = data.trim();
|
||||
resolve(net.isIP(ip) ? ip : null);
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", () => resolve(null));
|
||||
req.end();
|
||||
});
|
||||
|
||||
connectReq.on("error", () => resolve(null));
|
||||
connectReq.on("timeout", () => {
|
||||
connectReq.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
connectReq.end();
|
||||
});
|
||||
|
||||
if (ip) return ip;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback: couldn't import http modules
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GeoIP database management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getGeoipDir(): string {
|
||||
return path.join(getCacheDir(), "geoip");
|
||||
}
|
||||
|
||||
async function ensureGeoipDb(): Promise<string | null> {
|
||||
const dir = getGeoipDir();
|
||||
const dbPath = path.join(dir, GEOIP_DB_FILENAME);
|
||||
|
||||
if (fs.existsSync(dbPath)) {
|
||||
maybeTriggerUpdate(dbPath);
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadGeoipDb(dbPath);
|
||||
return dbPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadGeoipDb(dest: string): Promise<void> {
|
||||
const dir = path.dirname(dest);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log("[cloakbrowser] Downloading GeoIP database (~70 MB)…");
|
||||
|
||||
const tmpPath = `${dest}.tmp.${Date.now()}`;
|
||||
try {
|
||||
const response = await fetch(GEOIP_DB_URL, { redirect: "follow" });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const fileStream = createWriteStream(tmpPath);
|
||||
const reader = response.body.getReader();
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
fileStream.write(value);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fileStream.end(() => resolve());
|
||||
fileStream.on("error", reject);
|
||||
});
|
||||
|
||||
fs.renameSync(tmpPath, dest);
|
||||
console.log(`[cloakbrowser] GeoIP database ready: ${dest}`);
|
||||
} catch (err) {
|
||||
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeTriggerUpdate(dbPath: string): void {
|
||||
try {
|
||||
const age = Date.now() - fs.statSync(dbPath).mtimeMs;
|
||||
if (age < GEOIP_UPDATE_INTERVAL_MS) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget background update
|
||||
downloadGeoipDb(dbPath).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a usable proxy URL from LaunchOptions.proxy.
|
||||
* For SOCKS5 dicts with separate credentials, reconstructs the full URL
|
||||
* with inline credentials so SOCKS5 auth works.
|
||||
*/
|
||||
function extractProxyUrl(proxy: string | ProxyDict | undefined): string | null {
|
||||
if (!proxy) return null;
|
||||
if (typeof proxy === "string") return ensureProxyScheme(proxy);
|
||||
const p = proxy as ProxyDict;
|
||||
if (!p.server) return null;
|
||||
if (p.username && isSocksProxy(p)) {
|
||||
return reconstructSocksUrl(p);
|
||||
}
|
||||
return ensureProxyScheme(p.server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
|
||||
* Also returns exitIp as a free bonus (reused for WebRTC spoofing).
|
||||
*/
|
||||
export async function maybeResolveGeoip(
|
||||
options: LaunchOptions
|
||||
): Promise<{ timezone?: string; locale?: string; exitIp?: string }> {
|
||||
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
const proxyUrl = extractProxyUrl(options.proxy);
|
||||
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
|
||||
|
||||
// When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||
if (options.timezone && options.locale) {
|
||||
const exitIp = await resolveExitIp(proxyUrl) ?? undefined;
|
||||
return { timezone: options.timezone, locale: options.locale, exitIp };
|
||||
}
|
||||
|
||||
const { timezone: geoTz, locale: geoLocale, exitIp: geoExitIp } = await resolveProxyGeo(proxyUrl);
|
||||
const exitIp = geoExitIp ?? undefined;
|
||||
return {
|
||||
timezone: options.timezone ?? geoTz ?? undefined,
|
||||
locale: options.locale ?? geoLocale ?? undefined,
|
||||
exitIp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP.
|
||||
* Returns args unchanged if no ``auto`` value is present.
|
||||
*/
|
||||
export async function resolveWebrtcArgs(
|
||||
options: LaunchOptions
|
||||
): Promise<string[] | undefined> {
|
||||
const args = options.args;
|
||||
if (!args) return args;
|
||||
const idx = args.findIndex(a => a === "--fingerprint-webrtc-ip=auto");
|
||||
if (idx === -1) return args;
|
||||
|
||||
const proxyUrl = extractProxyUrl(options.proxy);
|
||||
if (!proxyUrl) {
|
||||
console.warn("[cloakbrowser] --fingerprint-webrtc-ip=auto requires a proxy; removing flag");
|
||||
const result = [...args];
|
||||
result.splice(idx, 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const ip = await resolveExitIp(proxyUrl);
|
||||
const result = [...args];
|
||||
if (ip) {
|
||||
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
|
||||
} else {
|
||||
console.warn("[cloakbrowser] Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
result.splice(idx, 1);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
console.warn("[cloakbrowser] Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto");
|
||||
const result = [...args];
|
||||
result.splice(idx, 1);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
/**
|
||||
* Human-like behavioral layer for cloakbrowser — Puppeteer edition.
|
||||
*
|
||||
* Mirrors Playwright humanize architecture, adapted for Puppeteer API.
|
||||
*
|
||||
* Patches ALL native Puppeteer interaction surfaces:
|
||||
*
|
||||
* PAGE-LEVEL:
|
||||
* click (with clickCount support for dblclick), hover, type,
|
||||
* select, focus, tap, goto
|
||||
*
|
||||
* MOUSE:
|
||||
* move, click (with clickCount support for dblclick), wheel,
|
||||
* dragAndDrop
|
||||
*
|
||||
* KEYBOARD:
|
||||
* type, down, up, press, sendCharacter
|
||||
*
|
||||
* FRAME-LEVEL:
|
||||
* click, hover, type, select, focus, tap
|
||||
* + $, $$, waitForSelector (return patched ElementHandles)
|
||||
*
|
||||
* ELEMENTHANDLE-LEVEL (Puppeteer-specific, no Playwright equivalent):
|
||||
* click (with clickCount), hover, type, press, tap, select,
|
||||
* focus, drop, dragAndDrop
|
||||
* + $, $$, waitForSelector (nested elements are also patched)
|
||||
*
|
||||
* BROWSER-LEVEL:
|
||||
* newPage, createBrowserContext / createIncognitoBrowserContext,
|
||||
* targetcreated event
|
||||
*
|
||||
* Stealth-aware:
|
||||
* - isInputElement / isSelectorFocused use CDP Isolated Worlds
|
||||
* - Shift symbol typing uses CDP Input.dispatchKeyEvent (isTrusted=true)
|
||||
* - ElementHandle isInput check uses CDP DOM.describeNode (no JS execution)
|
||||
* - Falls back to page.evaluate only when CDP session is unavailable
|
||||
*
|
||||
* Puppeteer-specific adaptations:
|
||||
* - page.createCDPSession() instead of context.newCDPSession(page)
|
||||
* - page.viewport() instead of page.viewportSize()
|
||||
* - page.$(selector) instead of page.locator(selector)
|
||||
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText
|
||||
* - mouse.wheel({deltaX, deltaY}) object form adapted to (dx, dy)
|
||||
* - page.select() instead of page.selectOption()
|
||||
* - ElementHandle prototype patching (Puppeteer-only)
|
||||
* - No page.dblclick() — Puppeteer uses click({clickCount:2})
|
||||
*/
|
||||
|
||||
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
|
||||
import type { HumanConfig } from '../human/config.js';
|
||||
import { resolveConfig, rand, randRange, sleep } from '../human/config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement, smoothWheel } from './scroll.js';
|
||||
|
||||
export type { HumanConfig } from '../human/config.js';
|
||||
export { resolveConfig } from '../human/config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
|
||||
export { humanType } from './keyboard.js';
|
||||
export { scrollToElement } from './scroll.js';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// CDP Isolated World — stealth DOM evaluation (Puppeteer version)
|
||||
// ============================================================================
|
||||
|
||||
class StealthEval {
|
||||
private cdp: CDPSession | null = null;
|
||||
private contextId: number | null = null;
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
private async ensureCdp(): Promise<CDPSession> {
|
||||
if (!this.cdp) {
|
||||
this.cdp = await this.page.createCDPSession();
|
||||
}
|
||||
return this.cdp;
|
||||
}
|
||||
|
||||
private async createWorld(): Promise<number> {
|
||||
const cdp = await this.ensureCdp();
|
||||
const tree = await cdp.send('Page.getFrameTree');
|
||||
const frameId = (tree as any).frameTree.frame.id;
|
||||
const result = await cdp.send('Page.createIsolatedWorld', {
|
||||
frameId,
|
||||
worldName: '',
|
||||
grantUniveralAccess: true,
|
||||
});
|
||||
const ctxId = (result as any).executionContextId;
|
||||
this.contextId = ctxId;
|
||||
return ctxId;
|
||||
}
|
||||
|
||||
async evaluate(expression: string): Promise<any> {
|
||||
if (this.contextId === null) {
|
||||
await this.createWorld();
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const cdp = await this.ensureCdp();
|
||||
const result = await cdp.send('Runtime.evaluate', {
|
||||
expression,
|
||||
contextId: this.contextId!,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
if ((result as any).exceptionDetails) {
|
||||
if (attempt === 0) {
|
||||
await this.createWorld();
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (result as any).result?.value;
|
||||
} catch {
|
||||
if (attempt === 0) {
|
||||
this.contextId = null;
|
||||
try { await this.createWorld(); } catch { return undefined; }
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.contextId = null;
|
||||
}
|
||||
|
||||
async getCdpSession(): Promise<CDPSession> {
|
||||
return this.ensureCdp();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Cursor state
|
||||
// ============================================================================
|
||||
|
||||
class CursorState {
|
||||
x = 0;
|
||||
y = 0;
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Stealth DOM queries
|
||||
// ============================================================================
|
||||
|
||||
async function isInputElement(
|
||||
stealth: StealthEval | null,
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<boolean> {
|
||||
if (stealth) {
|
||||
try {
|
||||
const escaped = JSON.stringify(selector);
|
||||
const result = await stealth.evaluate(`
|
||||
(() => {
|
||||
const el = document.querySelector(${escaped});
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
})()
|
||||
`);
|
||||
return !!result;
|
||||
} catch { /* fallthrough */ }
|
||||
}
|
||||
|
||||
return page.evaluate((sel: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
}, selector).catch(() => false);
|
||||
}
|
||||
|
||||
async function isSelectorFocused(
|
||||
stealth: StealthEval | null,
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<boolean> {
|
||||
if (stealth) {
|
||||
try {
|
||||
const escaped = JSON.stringify(selector);
|
||||
const result = await stealth.evaluate(`
|
||||
(() => {
|
||||
const el = document.querySelector(${escaped});
|
||||
return el === document.activeElement;
|
||||
})()
|
||||
`);
|
||||
return !!result;
|
||||
} catch { /* fallthrough */ }
|
||||
}
|
||||
|
||||
return page.evaluate((sel: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el === document.activeElement;
|
||||
}, selector).catch(() => false);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Stealth ElementHandle input check — uses CDP DOM.describeNode
|
||||
// instead of el.evaluate() to avoid main-world JS execution.
|
||||
// ============================================================================
|
||||
|
||||
async function isInputElementHandle(
|
||||
stealth: StealthEval | null,
|
||||
el: ElementHandle,
|
||||
): Promise<boolean> {
|
||||
if (stealth) {
|
||||
try {
|
||||
const cdp = await stealth.getCdpSession();
|
||||
const remoteObject = (el as any).remoteObject?.();
|
||||
if (remoteObject?.objectId) {
|
||||
const { node } = await cdp.send('DOM.describeNode', {
|
||||
objectId: remoteObject.objectId,
|
||||
}) as any;
|
||||
|
||||
const tag = (node?.nodeName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea') return true;
|
||||
|
||||
const attrs: string[] = node?.attributes || [];
|
||||
for (let i = 0; i < attrs.length; i += 2) {
|
||||
if (attrs[i] === 'contenteditable' && attrs[i + 1] === 'true') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch { /* fallthrough to el.evaluate */ }
|
||||
}
|
||||
|
||||
return el.evaluate((node: any) => {
|
||||
const tag = node.tagName?.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| node.getAttribute?.('contenteditable') === 'true';
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Page-level patching
|
||||
// ============================================================================
|
||||
|
||||
function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
const originals = {
|
||||
click: page.click.bind(page),
|
||||
hover: page.hover.bind(page),
|
||||
type: page.type.bind(page),
|
||||
select: page.select.bind(page),
|
||||
focus: page.focus.bind(page),
|
||||
goto: page.goto.bind(page),
|
||||
tap: page.tap.bind(page),
|
||||
|
||||
mouseMove: page.mouse.move.bind(page.mouse),
|
||||
mouseClick: page.mouse.click.bind(page.mouse),
|
||||
mouseDown: page.mouse.down.bind(page.mouse),
|
||||
mouseUp: page.mouse.up.bind(page.mouse),
|
||||
mouseWheel: (page.mouse as any).wheel?.bind(page.mouse),
|
||||
mouseDragAndDrop: (page.mouse as any).dragAndDrop?.bind(page.mouse),
|
||||
|
||||
keyboardType: page.keyboard.type.bind(page.keyboard),
|
||||
keyboardDown: page.keyboard.down.bind(page.keyboard) as (key: string) => Promise<void>,
|
||||
keyboardUp: page.keyboard.up.bind(page.keyboard) as (key: string) => Promise<void>,
|
||||
keyboardPress: page.keyboard.press.bind(page.keyboard),
|
||||
keyboardSendCharacter: page.keyboard.sendCharacter.bind(page.keyboard),
|
||||
};
|
||||
|
||||
(page as any)._original = originals;
|
||||
(page as any)._humanCfg = cfg;
|
||||
|
||||
const stealth = new StealthEval(page);
|
||||
(page as any)._stealth = stealth;
|
||||
|
||||
let cdpSession: CDPSession | null = null;
|
||||
const ensureCdp = async (): Promise<CDPSession | null> => {
|
||||
if (!cdpSession) {
|
||||
try { cdpSession = await stealth.getCdpSession(); } catch {}
|
||||
}
|
||||
return cdpSession;
|
||||
};
|
||||
|
||||
const raw: RawMouse = {
|
||||
move: originals.mouseMove,
|
||||
down: originals.mouseDown,
|
||||
up: originals.mouseUp,
|
||||
wheel: async (deltaX: number, deltaY: number) => {
|
||||
if (originals.mouseWheel) {
|
||||
await originals.mouseWheel({ deltaX, deltaY });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const rawKb: RawKeyboard = {
|
||||
down: originals.keyboardDown,
|
||||
up: originals.keyboardUp,
|
||||
type: originals.keyboardType,
|
||||
insertText: originals.keyboardSendCharacter,
|
||||
};
|
||||
|
||||
async function ensureCursorInit(): Promise<void> {
|
||||
if (!cursor.initialized) {
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
|
||||
await originals.mouseMove(cursor.x, cursor.y);
|
||||
cursor.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ==== goto ====
|
||||
const humanGoto = async (url: string, options?: any) => {
|
||||
const response = await originals.goto(url, options);
|
||||
stealth.invalidate();
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return response;
|
||||
};
|
||||
|
||||
// ==== click (with clickCount support for dblclick) ====
|
||||
const humanClickFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
|
||||
const clickCount = options?.clickCount ?? options?.count ?? 1;
|
||||
if (clickCount >= 2) {
|
||||
await humanClick(raw, isInput, cfg);
|
||||
await sleep(rand(40, 90));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
} else {
|
||||
await humanClick(raw, isInput, cfg);
|
||||
}
|
||||
};
|
||||
|
||||
// ==== hover ====
|
||||
const humanHoverFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const target = clickTarget(box, false, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
};
|
||||
|
||||
// ==== type ====
|
||||
const humanTypeFn = async (selector: string, text: string, options?: any) => {
|
||||
await sleep(randRange(cfg.field_switch_delay));
|
||||
await humanClickFn(selector);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
// ==== select ====
|
||||
const humanSelectFn = async (selector: string, ...values: string[]) => {
|
||||
await humanHoverFn(selector);
|
||||
await sleep(rand(100, 300));
|
||||
return originals.select(selector, ...values);
|
||||
};
|
||||
|
||||
// ==== focus ====
|
||||
const humanFocusFn = async (selector: string) => {
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
};
|
||||
|
||||
// ==== tap ====
|
||||
const humanTapFn = async (selector: string, options?: any) => {
|
||||
await humanClickFn(selector, options);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Assign page-level patches
|
||||
// ============================================================
|
||||
(page as any).goto = humanGoto;
|
||||
(page as any).click = humanClickFn;
|
||||
(page as any).hover = humanHoverFn;
|
||||
(page as any).type = humanTypeFn;
|
||||
(page as any).select = humanSelectFn;
|
||||
(page as any).focus = humanFocusFn;
|
||||
(page as any).tap = humanTapFn;
|
||||
|
||||
// ============================================================
|
||||
// Mouse patches
|
||||
// ============================================================
|
||||
page.mouse.move = async (x: number, y: number, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||
cursor.x = x;
|
||||
cursor.y = y;
|
||||
};
|
||||
|
||||
page.mouse.click = async (x: number, y: number, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||
cursor.x = x;
|
||||
cursor.y = y;
|
||||
|
||||
const clickCount = options?.clickCount ?? options?.count ?? 1;
|
||||
if (clickCount >= 2) {
|
||||
await humanClick(raw, false, cfg);
|
||||
await sleep(rand(40, 90));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
} else {
|
||||
await humanClick(raw, false, cfg);
|
||||
}
|
||||
};
|
||||
|
||||
if (originals.mouseWheel) {
|
||||
(page.mouse as any).wheel = async (options?: { deltaX?: number; deltaY?: number }) => {
|
||||
const dx = options?.deltaX ?? 0;
|
||||
const dy = options?.deltaY ?? 0;
|
||||
if (Math.abs(dy) > 0) {
|
||||
await smoothWheel(raw, dy, cfg, 'y');
|
||||
}
|
||||
if (Math.abs(dx) > 0) {
|
||||
await smoothWheel(raw, dx, cfg, 'x');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (originals.mouseDragAndDrop) {
|
||||
(page.mouse as any).dragAndDrop = async (
|
||||
start: { x: number; y: number },
|
||||
target: { x: number; y: number },
|
||||
options?: any,
|
||||
) => {
|
||||
await ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg);
|
||||
cursor.x = start.x;
|
||||
cursor.y = start.y;
|
||||
await sleep(rand(100, 200));
|
||||
await originals.mouseDown();
|
||||
await sleep(rand(80, 150));
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
await sleep(rand(80, 150));
|
||||
await originals.mouseUp();
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Keyboard patches
|
||||
// ============================================================
|
||||
page.keyboard.type = async (text: string, options?: any) => {
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
page.keyboard.press = async (key: any, options?: any) => {
|
||||
await sleep(rand(20, 60));
|
||||
await originals.keyboardDown(key as any);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await originals.keyboardUp(key as any);
|
||||
};
|
||||
|
||||
page.keyboard.down = async (key: any) => {
|
||||
await sleep(rand(10, 30));
|
||||
await originals.keyboardDown(key as any);
|
||||
};
|
||||
|
||||
page.keyboard.up = async (key: any) => {
|
||||
await sleep(rand(10, 30));
|
||||
await originals.keyboardUp(key as any);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Store helpers for frame/element patching
|
||||
// ============================================================
|
||||
(page as any)._humanCursor = cursor;
|
||||
(page as any)._humanRaw = raw;
|
||||
(page as any)._humanRawKb = rawKb;
|
||||
(page as any)._ensureCursorInit = ensureCursorInit;
|
||||
|
||||
// Initialize cursor
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]);
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]);
|
||||
originals.mouseMove(cursor.x, cursor.y).then(() => {
|
||||
cursor.initialized = true;
|
||||
}).catch(() => {});
|
||||
|
||||
// Patch frames
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
|
||||
// Patch ElementHandle selectors
|
||||
patchElementHandle(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// ElementHandle patching — PUPPETEER-SPECIFIC
|
||||
// ============================================================================
|
||||
|
||||
function patchElementHandle(
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: StealthEval,
|
||||
): void {
|
||||
const orig$ = page.$.bind(page);
|
||||
const orig$$ = page.$$.bind(page);
|
||||
const origWaitForSelector = page.waitForSelector.bind(page);
|
||||
|
||||
(page as any).$ = async (selector: string) => {
|
||||
const el = await orig$(selector);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
|
||||
(page as any).$$ = async (selector: string) => {
|
||||
const els = await orig$$(selector);
|
||||
for (const el of els) {
|
||||
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
return els;
|
||||
};
|
||||
|
||||
(page as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const el = await origWaitForSelector(selector, options);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
|
||||
function patchSingleElementHandle(
|
||||
el: ElementHandle,
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: StealthEval,
|
||||
): void {
|
||||
if ((el as any)._humanPatched) return;
|
||||
(el as any)._humanPatched = true;
|
||||
|
||||
const origElClick = el.click.bind(el);
|
||||
const origElHover = el.hover.bind(el);
|
||||
const origElType = el.type.bind(el);
|
||||
const origElPress = (el as any).press?.bind(el);
|
||||
const origElTap = (el as any).tap?.bind(el);
|
||||
const origElFocus = (el as any).focus?.bind(el);
|
||||
const origElDragAndDrop = (el as any).dragAndDrop?.bind(el);
|
||||
const origElSelect = (el as any).select?.bind(el);
|
||||
const origElDrop = (el as any).drop?.bind(el);
|
||||
|
||||
// --- Nested selectors ---
|
||||
const origEl$ = el.$.bind(el);
|
||||
const origEl$$ = el.$$.bind(el);
|
||||
const origElWaitForSelector = el.waitForSelector.bind(el);
|
||||
|
||||
(el as any).$ = async (selector: string) => {
|
||||
const child = await origEl$(selector);
|
||||
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return child;
|
||||
};
|
||||
|
||||
(el as any).$$ = async (selector: string) => {
|
||||
const children = await origEl$$(selector);
|
||||
for (const child of children) {
|
||||
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
(el as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const child = await origElWaitForSelector(selector, options);
|
||||
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return child;
|
||||
};
|
||||
|
||||
// --- Helper: get box and move cursor ---
|
||||
const moveToElement = async () => {
|
||||
await (page as any)._ensureCursorInit();
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
const isInp = await isInputElementHandle(stealth, el);
|
||||
const target = clickTarget(box, isInp, cfg);
|
||||
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
return { box, isInp };
|
||||
};
|
||||
|
||||
// --- el.click() ---
|
||||
(el as any).click = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElClick(options);
|
||||
|
||||
const clickCount = options?.clickCount ?? options?.count ?? 1;
|
||||
if (clickCount >= 2) {
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await sleep(rand(40, 90));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
} else {
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
}
|
||||
};
|
||||
|
||||
// --- el.hover() ---
|
||||
(el as any).hover = async () => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElHover();
|
||||
};
|
||||
|
||||
// --- el.type() ---
|
||||
(el as any).type = async (text: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElType(text, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await stealth.getCdpSession().catch(() => null);
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
// --- el.press() ---
|
||||
if (origElPress) {
|
||||
(el as any).press = async (key: string, options?: any) => {
|
||||
await sleep(rand(20, 60));
|
||||
await originals.keyboardDown(key as any);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await originals.keyboardUp(key as any);
|
||||
};
|
||||
}
|
||||
|
||||
// --- el.tap() ---
|
||||
if (origElTap) {
|
||||
(el as any).tap = async () => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElTap();
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
}
|
||||
|
||||
// --- el.focus() ---
|
||||
if (origElFocus) {
|
||||
(el as any).focus = async () => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElFocus();
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
}
|
||||
|
||||
// --- el.select() ---
|
||||
if (origElSelect) {
|
||||
(el as any).select = async (...values: string[]) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSelect(...values);
|
||||
await humanClick(raw, false, cfg);
|
||||
await sleep(rand(100, 300));
|
||||
return origElSelect(...values);
|
||||
};
|
||||
}
|
||||
|
||||
// --- el.drop() ---
|
||||
if (origElDrop) {
|
||||
(el as any).drop = async (draggable: ElementHandle, options?: any) => {
|
||||
const srcBox = await draggable.boundingBox();
|
||||
const tgtBox = await el.boundingBox();
|
||||
|
||||
if (srcBox && tgtBox) {
|
||||
const sx = srcBox.x + srcBox.width / 2;
|
||||
const sy = srcBox.y + srcBox.height / 2;
|
||||
const tx = tgtBox.x + tgtBox.width / 2;
|
||||
const ty = tgtBox.y + tgtBox.height / 2;
|
||||
|
||||
await (page as any)._ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
|
||||
cursor.x = sx;
|
||||
cursor.y = sy;
|
||||
await sleep(rand(100, 200));
|
||||
await originals.mouseDown();
|
||||
await sleep(rand(80, 150));
|
||||
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
|
||||
cursor.x = tx;
|
||||
cursor.y = ty;
|
||||
await sleep(rand(80, 150));
|
||||
await originals.mouseUp();
|
||||
} else {
|
||||
return origElDrop(draggable, options);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- el.dragAndDrop() ---
|
||||
if (origElDragAndDrop) {
|
||||
(el as any).dragAndDrop = async (targetEl: ElementHandle, options?: any) => {
|
||||
const srcBox = await el.boundingBox();
|
||||
const tgtBox = await targetEl.boundingBox();
|
||||
|
||||
if (srcBox && tgtBox) {
|
||||
const sx = srcBox.x + srcBox.width / 2;
|
||||
const sy = srcBox.y + srcBox.height / 2;
|
||||
const tx = tgtBox.x + tgtBox.width / 2;
|
||||
const ty = tgtBox.y + tgtBox.height / 2;
|
||||
|
||||
await (page as any)._ensureCursorInit();
|
||||
await humanMove(raw, cursor.x, cursor.y, sx, sy, cfg);
|
||||
cursor.x = sx;
|
||||
cursor.y = sy;
|
||||
await sleep(rand(100, 200));
|
||||
await originals.mouseDown();
|
||||
await sleep(rand(80, 150));
|
||||
await humanMove(raw, cursor.x, cursor.y, tx, ty, cfg);
|
||||
cursor.x = tx;
|
||||
cursor.y = ty;
|
||||
await sleep(rand(80, 150));
|
||||
await originals.mouseUp();
|
||||
} else {
|
||||
return origElDragAndDrop(targetEl, options);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Frame-level patching — native Puppeteer Frame methods only
|
||||
// Puppeteer Frame has: click, hover, type, select, focus, tap
|
||||
// ============================================================================
|
||||
|
||||
function patchFrames(
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: StealthEval,
|
||||
): void {
|
||||
for (const frame of iterFrames(page)) {
|
||||
patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
}
|
||||
|
||||
function patchSingleFrame(
|
||||
frame: Frame,
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: StealthEval,
|
||||
): void {
|
||||
if ((frame as any)._humanPatched) return;
|
||||
(frame as any)._humanPatched = true;
|
||||
|
||||
const origFrameSelect = frame.select.bind(frame);
|
||||
|
||||
(frame as any).click = async (selector: string, options?: any) => {
|
||||
await (page as any).click(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).hover = async (selector: string, options?: any) => {
|
||||
await (page as any).hover(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).type = async (selector: string, text: string, options?: any) => {
|
||||
await (page as any).type(selector, text, options);
|
||||
};
|
||||
|
||||
(frame as any).select = async (selector: string, ...values: string[]) => {
|
||||
await (page as any).hover(selector);
|
||||
await sleep(rand(100, 300));
|
||||
return origFrameSelect(selector, ...values);
|
||||
};
|
||||
|
||||
(frame as any).focus = async (selector: string) => {
|
||||
await (page as any).focus(selector);
|
||||
};
|
||||
|
||||
(frame as any).tap = async (selector: string, options?: any) => {
|
||||
await (page as any).click(selector, options);
|
||||
};
|
||||
|
||||
// Patch frame.$() to return patched ElementHandles
|
||||
const origFrame$ = frame.$.bind(frame);
|
||||
const origFrame$$ = frame.$$.bind(frame);
|
||||
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
|
||||
|
||||
(frame as any).$ = async (selector: string) => {
|
||||
const el = await origFrame$(selector);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
|
||||
(frame as any).$$ = async (selector: string) => {
|
||||
const els = await origFrame$$(selector);
|
||||
for (const el of els) {
|
||||
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
return els;
|
||||
};
|
||||
|
||||
(frame as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const el = await origFrameWaitForSelector(selector, options);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function* iterFrames(page: Page): Generator<Frame> {
|
||||
try {
|
||||
const mainFrame = page.mainFrame();
|
||||
yield mainFrame;
|
||||
for (const child of mainFrame.childFrames()) {
|
||||
yield child;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Browser-level patching
|
||||
// ============================================================================
|
||||
|
||||
export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
|
||||
browser.pages().then(pages => {
|
||||
for (const page of pages) {
|
||||
if (!(page as any)._original) {
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
const origNewPage = browser.newPage.bind(browser);
|
||||
(browser as any).newPage = async () => {
|
||||
const page = await origNewPage();
|
||||
if (!(page as any)._original) {
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
return page;
|
||||
};
|
||||
|
||||
// v21: createIncognitoBrowserContext
|
||||
// v22+: createBrowserContext (renamed in puppeteer/puppeteer#11834)
|
||||
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
|
||||
if (typeof (browser as any)[methodName] === 'function') {
|
||||
const origCreateContext = (browser as any)[methodName].bind(browser);
|
||||
(browser as any)[methodName] = async (options?: any) => {
|
||||
const context: BrowserContext = await origCreateContext(options);
|
||||
|
||||
const origCtxNewPage = context.newPage.bind(context);
|
||||
(context as any).newPage = async () => {
|
||||
const page = await origCtxNewPage();
|
||||
if (!(page as any)._original) {
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
return page;
|
||||
};
|
||||
|
||||
return context;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
browser.on('targetcreated', async (target: any) => {
|
||||
try {
|
||||
if (target.type() === 'page') {
|
||||
const page = await target.page();
|
||||
if (page && !(page as any)._original) {
|
||||
patchPage(page, cfg, new CursorState());
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
export { patchPage };
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* cloakbrowser-human — Human-like keyboard input.
|
||||
* Adapted for Puppeteer API.
|
||||
*
|
||||
* Changes from Playwright version:
|
||||
* - Uses puppeteer-core Page/CDPSession types
|
||||
* - keyboard.sendCharacter() mapped via RawKeyboard.insertText adapter
|
||||
* - CDPSession obtained via page.createCDPSession()
|
||||
*
|
||||
* Stealth-aware: shift symbols use CDP Input.dispatchKeyEvent (isTrusted=true).
|
||||
*/
|
||||
|
||||
import type { Page, CDPSession } from 'puppeteer-core';
|
||||
import { RawKeyboard } from '../human/mouse.js';
|
||||
import type { HumanConfig } from '../human/config.js';
|
||||
import { rand, randRange, sleep } from '../human/config.js';
|
||||
|
||||
const SHIFT_SYMBOLS = new Set([
|
||||
'@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
|
||||
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~',
|
||||
]);
|
||||
|
||||
const NEARBY_KEYS: Record<string, string> = {
|
||||
a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf',
|
||||
f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm',
|
||||
k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp',
|
||||
p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy',
|
||||
u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu',
|
||||
z: 'asx',
|
||||
'1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt',
|
||||
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
|
||||
};
|
||||
|
||||
const SHIFT_SYMBOL_CODES: Record<string, string> = {
|
||||
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
|
||||
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
|
||||
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
|
||||
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
|
||||
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
|
||||
'?': 'Slash', '~': 'Backquote',
|
||||
};
|
||||
|
||||
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
|
||||
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
|
||||
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
|
||||
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
|
||||
'~': 192,
|
||||
};
|
||||
|
||||
function isAscii(ch: string): boolean {
|
||||
const code = ch.codePointAt(0);
|
||||
return code !== undefined && code < 128;
|
||||
}
|
||||
|
||||
function getNearbyKey(ch: string): string {
|
||||
const lower = ch.toLowerCase();
|
||||
if (lower in NEARBY_KEYS) {
|
||||
const neighbors = NEARBY_KEYS[lower];
|
||||
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
|
||||
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
function isUpperCase(ch: string): boolean {
|
||||
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||
}
|
||||
|
||||
export async function humanType(
|
||||
page: Page,
|
||||
raw: RawKeyboard,
|
||||
text: string,
|
||||
cfg: HumanConfig,
|
||||
cdpSession?: CDPSession | null,
|
||||
): Promise<void> {
|
||||
const chars = [...text];
|
||||
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const ch = chars[i];
|
||||
|
||||
// Non-ASCII → sendCharacter via insertText adapter
|
||||
if (!isAscii(ch)) {
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.insertText(ch);
|
||||
if (i < chars.length - 1) await interCharDelay(cfg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mistype
|
||||
if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) {
|
||||
const wrong = getNearbyKey(ch);
|
||||
await typeNormalChar(raw, wrong, cfg);
|
||||
await sleep(randRange(cfg.mistype_delay_notice));
|
||||
await raw.down('Backspace');
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.up('Backspace');
|
||||
await sleep(randRange(cfg.mistype_delay_correct));
|
||||
}
|
||||
|
||||
if (isUpperCase(ch)) {
|
||||
await typeShiftedChar(raw, ch, cfg);
|
||||
} else if (SHIFT_SYMBOLS.has(ch)) {
|
||||
await typeShiftSymbol(page, raw, ch, cfg, cdpSession);
|
||||
} else {
|
||||
await typeNormalChar(raw, ch, cfg);
|
||||
}
|
||||
|
||||
if (i < chars.length - 1) await interCharDelay(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||
await raw.down(ch);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.up(ch);
|
||||
}
|
||||
|
||||
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
await raw.down(ch);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.up(ch);
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await raw.up('Shift');
|
||||
}
|
||||
|
||||
async function typeShiftSymbol(
|
||||
page: Page,
|
||||
raw: RawKeyboard,
|
||||
ch: string,
|
||||
cfg: HumanConfig,
|
||||
cdpSession?: CDPSession | null,
|
||||
): Promise<void> {
|
||||
if (cdpSession) {
|
||||
const code = SHIFT_SYMBOL_CODES[ch] || '';
|
||||
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
|
||||
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
|
||||
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
modifiers: 8,
|
||||
key: ch,
|
||||
code,
|
||||
windowsVirtualKeyCode: keyCode,
|
||||
text: ch,
|
||||
unmodifiedText: ch,
|
||||
});
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
|
||||
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
modifiers: 8,
|
||||
key: ch,
|
||||
code,
|
||||
windowsVirtualKeyCode: keyCode,
|
||||
});
|
||||
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await raw.up('Shift');
|
||||
} else {
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
await raw.insertText(ch);
|
||||
await page.evaluate((key: string) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}, ch);
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await raw.up('Shift');
|
||||
}
|
||||
}
|
||||
|
||||
async function interCharDelay(cfg: HumanConfig): Promise<void> {
|
||||
if (Math.random() < cfg.typing_pause_chance) {
|
||||
await sleep(randRange(cfg.typing_pause_range));
|
||||
} else {
|
||||
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
|
||||
await sleep(Math.max(10, delay));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* cloakbrowser-human — Human-like scrolling via mouse wheel events.
|
||||
* Adapted for Puppeteer API.
|
||||
*
|
||||
* Changes from Playwright version:
|
||||
* - page.viewport() instead of page.viewportSize()
|
||||
* - page.$(selector) + el.boundingBox() instead of page.locator().boundingBox()
|
||||
* - No timeout parameter on boundingBox()
|
||||
*/
|
||||
|
||||
import type { Page } from 'puppeteer-core';
|
||||
import type { HumanConfig } from '../human/config.js';
|
||||
import { rand, randRange, randIntRange, sleep } from '../human/config.js';
|
||||
import { RawMouse, humanMove } from '../human/mouse.js';
|
||||
|
||||
interface ElementBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isInViewport(
|
||||
bounds: ElementBounds,
|
||||
viewportHeight: number,
|
||||
cfg: HumanConfig,
|
||||
): boolean {
|
||||
const topEdge = bounds.y;
|
||||
const bottomEdge = bounds.y + bounds.height;
|
||||
const zoneTop = viewportHeight * cfg.scroll_target_zone[0];
|
||||
const zoneBottom = viewportHeight * cfg.scroll_target_zone[1];
|
||||
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
|
||||
}
|
||||
|
||||
export async function smoothWheel(
|
||||
raw: RawMouse,
|
||||
delta: number,
|
||||
cfg: HumanConfig,
|
||||
axis: 'x' | 'y' = 'y',
|
||||
): Promise<void> {
|
||||
const absD = Math.abs(delta);
|
||||
const sign = delta > 0 ? 1 : -1;
|
||||
let sent = 0;
|
||||
while (sent < absD) {
|
||||
const stepSize = rand(20, 40);
|
||||
const chunk = Math.min(stepSize, absD - sent);
|
||||
const d = Math.round(chunk) * sign;
|
||||
if (axis === 'x') {
|
||||
await raw.wheel(d, 0);
|
||||
} else {
|
||||
await raw.wheel(0, d);
|
||||
}
|
||||
sent += chunk;
|
||||
await sleep(rand(8, 20));
|
||||
}
|
||||
}
|
||||
|
||||
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
|
||||
try {
|
||||
const el = await page.$(selector);
|
||||
if (!el) return null;
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollToElement(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
const viewport = page.viewport();
|
||||
if (!viewport) throw new Error('Viewport size not available');
|
||||
|
||||
let box = await getElementBox(page, selector);
|
||||
if (!box) {
|
||||
await sleep(200);
|
||||
box = await getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element not found: ${selector}`);
|
||||
}
|
||||
|
||||
if (isInViewport(box, viewport.height, cfg)) {
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
// Move cursor into scroll area
|
||||
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
|
||||
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
|
||||
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
|
||||
cursorX = scrollAreaX;
|
||||
cursorY = scrollAreaY;
|
||||
await sleep(randRange(cfg.scroll_pre_move_delay));
|
||||
|
||||
// Calculate scroll distance
|
||||
const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]);
|
||||
const elementCenter = box.y + box.height / 2;
|
||||
const distanceToScroll = elementCenter - targetY;
|
||||
|
||||
const direction = distanceToScroll > 0 ? 1 : -1;
|
||||
const absDistance = Math.abs(distanceToScroll);
|
||||
const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
|
||||
const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta));
|
||||
const accelSteps = randIntRange(cfg.scroll_accel_steps);
|
||||
const decelSteps = randIntRange(cfg.scroll_decel_steps);
|
||||
|
||||
let scrolled = 0;
|
||||
|
||||
for (let i = 0; i < totalClicks; i++) {
|
||||
let delta: number;
|
||||
let pause: number;
|
||||
|
||||
if (i < accelSteps) {
|
||||
delta = rand(80, 100);
|
||||
pause = randRange(cfg.scroll_pause_slow);
|
||||
} else if (i >= totalClicks - decelSteps) {
|
||||
delta = rand(60, 90);
|
||||
pause = randRange(cfg.scroll_pause_slow);
|
||||
} else {
|
||||
delta = randRange(cfg.scroll_delta_base);
|
||||
pause = randRange(cfg.scroll_pause_fast);
|
||||
}
|
||||
|
||||
delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance;
|
||||
delta = Math.round(delta) * direction;
|
||||
|
||||
await smoothWheel(raw, delta, cfg);
|
||||
scrolled += Math.abs(delta);
|
||||
await sleep(pause);
|
||||
|
||||
if (i % 3 === 2 || i === totalClicks - 1) {
|
||||
box = await getElementBox(page, selector);
|
||||
if (box && isInViewport(box, viewport.height, cfg)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (scrolled >= absDistance * 1.1) break;
|
||||
}
|
||||
|
||||
// Optional overshoot + correction
|
||||
if (Math.random() < cfg.scroll_overshoot_chance) {
|
||||
const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction;
|
||||
await smoothWheel(raw, overshootPx, cfg);
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
|
||||
const corrections = randIntRange([1, 2]);
|
||||
for (let c = 0; c < corrections; c++) {
|
||||
const corrDelta = Math.round(rand(40, 80)) * -direction;
|
||||
await smoothWheel(raw, corrDelta, cfg);
|
||||
await sleep(rand(100, 250));
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
|
||||
box = await getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
|
||||
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
@@ -0,0 +1,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,366 @@
|
||||
/**
|
||||
* ElementHandle humanization for Playwright.
|
||||
*
|
||||
* Mirrors Puppeteer's ElementHandle patching architecture.
|
||||
* Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles,
|
||||
* and patches all interaction methods on each ElementHandle instance.
|
||||
*
|
||||
* Playwright ElementHandle methods patched:
|
||||
* click, dblclick, hover, type, fill, press, selectOption,
|
||||
* check, uncheck, setChecked, tap, focus
|
||||
* + $, $$, waitForSelector (nested elements are also patched)
|
||||
*
|
||||
* Stealth-aware:
|
||||
* - Uses CDP DOM.describeNode when available to check element type
|
||||
* (no main-world JS execution)
|
||||
* - Falls back to el.evaluate() only when CDP is unavailable
|
||||
*/
|
||||
|
||||
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
|
||||
import type { HumanConfig } from './config.js';
|
||||
import { rand, randRange, sleep } from './config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Stealth ElementHandle input check — uses CDP DOM.describeNode
|
||||
// ============================================================================
|
||||
|
||||
async function isInputElementHandle(
|
||||
stealth: any, // StealthEval from index.ts
|
||||
el: ElementHandle,
|
||||
): Promise<boolean> {
|
||||
// Try CDP DOM.describeNode first (no main-world JS execution)
|
||||
if (stealth) {
|
||||
try {
|
||||
const cdp: CDPSession = await stealth.getCdpSession();
|
||||
// Playwright exposes the JSHandle's internal preview via _objectId or similar
|
||||
// We need the remote object ID. Try to get it via internal API.
|
||||
const impl = (el as any)._impl ?? (el as any)._object ?? el;
|
||||
const guid = (impl as any)._guid;
|
||||
|
||||
// Use el.evaluate as a reliable fallback within stealth context
|
||||
// Playwright doesn't expose remoteObject directly like Puppeteer
|
||||
} catch { /* fallthrough */ }
|
||||
}
|
||||
|
||||
// Fallback: el.evaluate (works reliably in Playwright)
|
||||
try {
|
||||
return await el.evaluate((node: any) => {
|
||||
const tag = node.tagName?.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| node.getAttribute?.('contenteditable') === 'true';
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// CursorState type (matches index.ts)
|
||||
// ============================================================================
|
||||
|
||||
interface CursorState {
|
||||
x: number;
|
||||
y: number;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Patch a single Playwright ElementHandle
|
||||
// ============================================================================
|
||||
|
||||
export function patchSingleElementHandle(
|
||||
el: ElementHandle,
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: any,
|
||||
): void {
|
||||
if ((el as any)._humanPatched) return;
|
||||
(el as any)._humanPatched = true;
|
||||
|
||||
// Save originals
|
||||
const origElClick = el.click.bind(el);
|
||||
const origElDblclick = el.dblclick.bind(el);
|
||||
const origElHover = el.hover.bind(el);
|
||||
const origElType = el.type.bind(el);
|
||||
const origElFill = el.fill.bind(el);
|
||||
const origElPress = el.press.bind(el);
|
||||
const origElSelectOption = el.selectOption.bind(el);
|
||||
const origElCheck = el.check.bind(el);
|
||||
const origElUncheck = el.uncheck.bind(el);
|
||||
const origElSetChecked = (el as any).setChecked?.bind(el);
|
||||
const origElTap = el.tap.bind(el);
|
||||
const origElFocus = el.focus.bind(el);
|
||||
|
||||
// Nested selectors
|
||||
const origEl$ = el.$.bind(el);
|
||||
const origEl$$ = el.$$.bind(el);
|
||||
const origElWaitForSelector = el.waitForSelector.bind(el);
|
||||
|
||||
// --- Nested elements are also patched ---
|
||||
(el as any).$ = async (selector: string) => {
|
||||
const child = await origEl$(selector);
|
||||
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return child;
|
||||
};
|
||||
|
||||
(el as any).$$ = async (selector: string) => {
|
||||
const children = await origEl$$(selector);
|
||||
for (const child of children) {
|
||||
patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
(el as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const child = await origElWaitForSelector(selector, options);
|
||||
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return child;
|
||||
};
|
||||
|
||||
// --- Helper: get bounding box and move cursor to element ---
|
||||
const moveToElement = async () => {
|
||||
// Ensure cursor is initialized
|
||||
const ensureCursorInit = (page as any)._ensureCursorInit;
|
||||
if (ensureCursorInit) await ensureCursorInit();
|
||||
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
const isInp = await isInputElementHandle(stealth, el);
|
||||
const target = clickTarget(box, isInp, cfg);
|
||||
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
return { box, isInp };
|
||||
};
|
||||
|
||||
// --- el.click() ---
|
||||
(el as any).click = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElClick(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
// --- el.dblclick() ---
|
||||
(el as any).dblclick = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElDblclick(options);
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
};
|
||||
|
||||
// --- el.hover() ---
|
||||
(el as any).hover = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElHover(options);
|
||||
// Just move — no click
|
||||
};
|
||||
|
||||
// --- el.type() ---
|
||||
(el as any).type = async (text: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElType(text, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await sleep(rand(100, 250));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||
await humanType(page, rawKb, text, cfg, cdpSession);
|
||||
};
|
||||
|
||||
// --- el.fill() ---
|
||||
(el as any).fill = async (value: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElFill(value, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await sleep(rand(100, 250));
|
||||
// Clear existing content
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
await sleep(rand(50, 150));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||
await humanType(page, rawKb, value, cfg, cdpSession);
|
||||
};
|
||||
|
||||
// --- el.press() ---
|
||||
(el as any).press = async (key: string, options?: any) => {
|
||||
await sleep(rand(20, 60));
|
||||
await originals.keyboardDown(key);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await originals.keyboardUp(key);
|
||||
};
|
||||
|
||||
// --- el.selectOption() ---
|
||||
(el as any).selectOption = async (values: any, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSelectOption(values, options);
|
||||
await humanClick(raw, false, cfg);
|
||||
await sleep(rand(100, 300));
|
||||
return origElSelectOption(values, options);
|
||||
};
|
||||
|
||||
// --- el.check() ---
|
||||
(el as any).check = async (options?: any) => {
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (checked) return; // Already checked
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElCheck(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
// --- el.uncheck() ---
|
||||
(el as any).uncheck = async (options?: any) => {
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (!checked) return; // Already unchecked
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElUncheck(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
// --- el.setChecked() ---
|
||||
if (origElSetChecked) {
|
||||
(el as any).setChecked = async (checked: boolean, options?: any) => {
|
||||
try {
|
||||
const current = await el.isChecked();
|
||||
if (current === checked) return;
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSetChecked(checked, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
}
|
||||
|
||||
// --- el.tap() ---
|
||||
(el as any).tap = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElTap(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
// --- el.focus() ---
|
||||
// Move cursor humanly but use programmatic focus (no click side-effects).
|
||||
// Stock Playwright el.focus() never clicks — clicking would trigger onclick,
|
||||
// submit forms, navigate links, etc.
|
||||
(el as any).focus = async () => {
|
||||
await moveToElement(); // human-like Bézier cursor movement
|
||||
await origElFocus(); // programmatic focus, no click
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Page-level ElementHandle patching
|
||||
// ============================================================================
|
||||
|
||||
export function patchPageElementHandles(
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: any,
|
||||
): void {
|
||||
// Patch page.$() — only if the method exists
|
||||
if (typeof page.$ === 'function') {
|
||||
const orig$ = page.$.bind(page);
|
||||
(page as any).$ = async (selector: string) => {
|
||||
const el = await orig$(selector);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
|
||||
// Patch page.$$()
|
||||
if (typeof page.$$ === 'function') {
|
||||
const orig$$ = page.$$.bind(page);
|
||||
(page as any).$$ = async (selector: string) => {
|
||||
const els = await orig$$(selector);
|
||||
for (const el of els) {
|
||||
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
return els;
|
||||
};
|
||||
}
|
||||
|
||||
// Patch page.waitForSelector()
|
||||
if (typeof page.waitForSelector === 'function') {
|
||||
const origWaitForSelector = page.waitForSelector.bind(page);
|
||||
(page as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const el = await origWaitForSelector(selector, options);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Frame-level ElementHandle patching
|
||||
// ============================================================================
|
||||
|
||||
export function patchFrameElementHandles(
|
||||
frame: Frame,
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: any,
|
||||
): void {
|
||||
// Patch frame.$() — only if the method exists
|
||||
if (typeof frame.$ === 'function') {
|
||||
const origFrame$ = frame.$.bind(frame);
|
||||
(frame as any).$ = async (selector: string) => {
|
||||
const el = await origFrame$(selector);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
|
||||
// Patch frame.$$()
|
||||
if (typeof frame.$$ === 'function') {
|
||||
const origFrame$$ = frame.$$.bind(frame);
|
||||
(frame as any).$$ = async (selector: string) => {
|
||||
const els = await origFrame$$(selector);
|
||||
for (const el of els) {
|
||||
patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
return els;
|
||||
};
|
||||
}
|
||||
|
||||
// Patch frame.waitForSelector()
|
||||
if (typeof frame.waitForSelector === 'function') {
|
||||
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
|
||||
(frame as any).waitForSelector = async (selector: string, options?: any) => {
|
||||
const el = await origFrameWaitForSelector(selector, options);
|
||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return el;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Stealth-aware (fixes #110):
|
||||
* - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate
|
||||
* - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events
|
||||
* - Falls back to page.evaluate only when CDP session is unavailable
|
||||
*
|
||||
* Patches all interaction methods:
|
||||
* click, dblclick, hover, type, fill, check, uncheck, selectOption,
|
||||
* press, pressSequentially, tap, dragTo, clear + Frame-level equivalents.
|
||||
*
|
||||
* ELEMENTHANDLE-LEVEL:
|
||||
* click, dblclick, hover, type, fill, press, selectOption,
|
||||
* check, uncheck, setChecked, tap, focus
|
||||
* + $, $$, waitForSelector (nested elements are also patched)
|
||||
*
|
||||
* page.$(), page.$$(), page.waitForSelector() and Frame equivalents
|
||||
* return patched ElementHandles automatically.
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
|
||||
import { 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';
|
||||
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.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';
|
||||
export { patchSingleElementHandle } from './elementhandle.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// CDP Isolated World — stealth DOM evaluation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Manages a CDP isolated execution context for DOM reads.
|
||||
* Produces clean Error.stack traces (no 'eval at evaluate :302:')
|
||||
* and is invisible to querySelector monkey-patches in the main world.
|
||||
*
|
||||
* Context ID is invalidated on navigation and auto-recreated on next call.
|
||||
*/
|
||||
class StealthEval {
|
||||
private cdp: CDPSession | null = null;
|
||||
private contextId: number | null = null;
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
private async ensureCdp(): Promise<CDPSession> {
|
||||
if (!this.cdp) {
|
||||
this.cdp = await this.page.context().newCDPSession(this.page);
|
||||
}
|
||||
return this.cdp;
|
||||
}
|
||||
|
||||
private async createWorld(): Promise<number> {
|
||||
const cdp = await this.ensureCdp();
|
||||
const tree = await cdp.send('Page.getFrameTree');
|
||||
const frameId = tree.frameTree.frame.id;
|
||||
const result = await cdp.send('Page.createIsolatedWorld', {
|
||||
frameId,
|
||||
worldName: '',
|
||||
grantUniveralAccess: true,
|
||||
});
|
||||
const ctxId = result.executionContextId;
|
||||
this.contextId = ctxId;
|
||||
return ctxId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a JS expression in the isolated world.
|
||||
* Auto-recreates the world if the context was invalidated (navigation).
|
||||
* Returns the result value, or undefined on failure.
|
||||
*/
|
||||
async evaluate(expression: string): Promise<any> {
|
||||
if (this.contextId === null) {
|
||||
await this.createWorld();
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const cdp = await this.ensureCdp();
|
||||
const result = await cdp.send('Runtime.evaluate', {
|
||||
expression,
|
||||
contextId: this.contextId!,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
if (result.exceptionDetails) {
|
||||
// Context was likely invalidated by navigation
|
||||
if (attempt === 0) {
|
||||
await this.createWorld();
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.result?.value;
|
||||
} catch {
|
||||
if (attempt === 0) {
|
||||
this.contextId = null;
|
||||
try {
|
||||
await this.createWorld();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Mark context as stale — call after navigation. */
|
||||
invalidate(): void {
|
||||
this.contextId = null;
|
||||
}
|
||||
|
||||
/** Get the underlying CDP session (reused for Input.dispatchKeyEvent etc.). */
|
||||
async getCdpSession(): Promise<CDPSession> {
|
||||
return this.ensureCdp();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Cursor state
|
||||
// ============================================================================
|
||||
|
||||
class CursorState {
|
||||
x = 0;
|
||||
y = 0;
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Stealth DOM queries — isolated world with evaluate fallback
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if selector matches an input/textarea/contenteditable element.
|
||||
* Uses CDP Isolated World when available — invisible to main world.
|
||||
*/
|
||||
async function isInputElement(
|
||||
stealth: StealthEval | null,
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<boolean> {
|
||||
if (stealth) {
|
||||
try {
|
||||
const escaped = JSON.stringify(selector);
|
||||
const result = await stealth.evaluate(`
|
||||
(() => {
|
||||
const el = document.querySelector(${escaped});
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea'
|
||||
|| el.getAttribute('contenteditable') === 'true';
|
||||
})()
|
||||
`);
|
||||
return !!result;
|
||||
} catch {
|
||||
// Fall through to page.evaluate
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: page.evaluate (detectable — should only happen if CDP fails)
|
||||
return page.evaluate((sel: string) => {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the element matching selector is currently focused.
|
||||
* Uses CDP Isolated World when available — invisible to main world.
|
||||
*/
|
||||
async function isSelectorFocused(
|
||||
stealth: StealthEval | null,
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<boolean> {
|
||||
if (stealth) {
|
||||
try {
|
||||
const escaped = JSON.stringify(selector);
|
||||
const result = await stealth.evaluate(`
|
||||
(() => {
|
||||
const el = document.querySelector(${escaped});
|
||||
return el === document.activeElement;
|
||||
})()
|
||||
`);
|
||||
return !!result;
|
||||
} catch {
|
||||
// Fall through to page.evaluate
|
||||
}
|
||||
}
|
||||
|
||||
return page.evaluate((sel: string) => {
|
||||
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;
|
||||
|
||||
// --- Stealth infrastructure ---
|
||||
const stealth = new StealthEval(page);
|
||||
(page as any)._stealth = stealth;
|
||||
|
||||
// CDP session for shift symbol typing (lazy-initialized, reuses stealth's session)
|
||||
let cdpSession: CDPSession | null = null;
|
||||
const ensureCdp = async (): Promise<CDPSession | null> => {
|
||||
if (!cdpSession) {
|
||||
try {
|
||||
cdpSession = await stealth.getCdpSession();
|
||||
} catch {}
|
||||
}
|
||||
return cdpSession;
|
||||
};
|
||||
|
||||
const raw: RawMouse = {
|
||||
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 (invalidate isolated world on navigation) ---
|
||||
const humanGoto = async (url: string, options?: any) => {
|
||||
const response = await originals.goto(url, options);
|
||||
stealth.invalidate();
|
||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
return response;
|
||||
};
|
||||
|
||||
// --- click ---
|
||||
const humanClickFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
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(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
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));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
// --- 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));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, value, cfg, cdp);
|
||||
};
|
||||
|
||||
// --- clear ---
|
||||
const humanClearFn = async (selector: string, options?: any) => {
|
||||
if (!await isSelectorFocused(stealth, 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(stealth, 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(stealth, page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
}
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
// --- 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;
|
||||
(page as any).pressSequentially = humanPressSequentiallyFn;
|
||||
(page as any).tap = humanTapFn;
|
||||
(page as any).clear = humanClearFn;
|
||||
|
||||
// --- 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) => {
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
};
|
||||
|
||||
// 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, stealth);
|
||||
|
||||
// --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) ---
|
||||
patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Frame-level patching
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Patch Frame methods so Locator-based calls go through humanization.
|
||||
* All 13 methods patched: click, dblclick, hover, type, fill, check, uncheck,
|
||||
* selectOption, press, pressSequentially, tap, clear, dragAndDrop.
|
||||
*/
|
||||
function patchFrames(
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
cursor: CursorState,
|
||||
raw: RawMouse,
|
||||
rawKb: RawKeyboard,
|
||||
originals: any,
|
||||
stealth: StealthEval,
|
||||
): void {
|
||||
for (const frame of iterFrames(page)) {
|
||||
patchSingleFrame(frame, page, cfg, originals, stealth);
|
||||
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
|
||||
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||
}
|
||||
}
|
||||
|
||||
function patchSingleFrame(
|
||||
frame: Frame,
|
||||
page: Page,
|
||||
cfg: HumanConfig,
|
||||
originals: any,
|
||||
stealth: StealthEval,
|
||||
): 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).pressSequentially = async (selector: string, text: string, options?: any) => {
|
||||
await (page as any).pressSequentially(selector, text, options);
|
||||
};
|
||||
|
||||
(frame as any).tap = async (selector: string, options?: any) => {
|
||||
await (page as any).tap(selector, options);
|
||||
};
|
||||
|
||||
(frame as any).clear = async (selector: string, options?: any) => {
|
||||
if (!await isSelectorFocused(stealth, 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,214 @@
|
||||
/**
|
||||
* cloakbrowser-human — Human-like keyboard input.
|
||||
*
|
||||
* Stealth-aware: when a CDPSession is provided, shift symbols are typed
|
||||
* via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace).
|
||||
* Falls back to page.evaluate when no CDPSession is available.
|
||||
*/
|
||||
|
||||
import type { Page, CDPSession } from 'playwright-core';
|
||||
import { 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',
|
||||
};
|
||||
|
||||
/**
|
||||
* CDP key code for each shift symbol's physical key.
|
||||
* Used by Input.dispatchKeyEvent to produce isTrusted=true events.
|
||||
*/
|
||||
const SHIFT_SYMBOL_CODES: Record<string, string> = {
|
||||
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4',
|
||||
'%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8',
|
||||
'(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal',
|
||||
'{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash',
|
||||
':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period',
|
||||
'?': 'Slash', '~': 'Backquote',
|
||||
};
|
||||
|
||||
/**
|
||||
* Windows virtual key codes for shift symbols.
|
||||
* Input.dispatchKeyEvent uses these to match real keyboard behavior.
|
||||
*/
|
||||
const SHIFT_SYMBOL_KEYCODES: Record<string, number> = {
|
||||
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48,
|
||||
'_': 189, '+': 187, '{': 219, '}': 221, '|': 220,
|
||||
':': 186, '"': 222, '<': 188, '>': 190, '?': 191,
|
||||
'~': 192,
|
||||
};
|
||||
|
||||
function isAscii(ch: string): boolean {
|
||||
const code = ch.codePointAt(0);
|
||||
return code !== undefined && code < 128;
|
||||
}
|
||||
|
||||
function getNearbyKey(ch: string): string {
|
||||
const lower = ch.toLowerCase();
|
||||
if (lower in NEARBY_KEYS) {
|
||||
const neighbors = NEARBY_KEYS[lower];
|
||||
const wrong = neighbors[Math.floor(Math.random() * neighbors.length)];
|
||||
return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong;
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
function isUpperCase(ch: string): boolean {
|
||||
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type text with human-like per-character timing, mistype simulation,
|
||||
* and realistic shift handling.
|
||||
*
|
||||
* @param cdpSession - If provided, shift symbols use CDP Input.dispatchKeyEvent
|
||||
* producing isTrusted=true events with no evaluate stack trace.
|
||||
* If null/undefined, falls back to page.evaluate (detectable).
|
||||
*/
|
||||
export async function humanType(
|
||||
page: Page,
|
||||
raw: RawKeyboard,
|
||||
text: string,
|
||||
cfg: HumanConfig,
|
||||
cdpSession?: CDPSession | null,
|
||||
): 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, cdpSession);
|
||||
} else {
|
||||
await typeNormalChar(raw, ch, cfg);
|
||||
}
|
||||
|
||||
if (i < chars.length - 1) {
|
||||
await interCharDelay(cfg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||
await raw.down(ch);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.up(ch);
|
||||
}
|
||||
|
||||
async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
await raw.down(ch);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await raw.up(ch);
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await raw.up('Shift');
|
||||
}
|
||||
|
||||
/**
|
||||
* Type a shift symbol character.
|
||||
*
|
||||
* Stealth path (cdpSession provided):
|
||||
* Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack.
|
||||
*
|
||||
* Fallback path (no cdpSession):
|
||||
* Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent.
|
||||
* Detectable via isTrusted=false and evaluate stack frame.
|
||||
*/
|
||||
async function typeShiftSymbol(
|
||||
page: Page,
|
||||
raw: RawKeyboard,
|
||||
ch: string,
|
||||
cfg: HumanConfig,
|
||||
cdpSession?: CDPSession | null,
|
||||
): Promise<void> {
|
||||
if (cdpSession) {
|
||||
// --- Stealth path: CDP Input.dispatchKeyEvent ---
|
||||
const code = SHIFT_SYMBOL_CODES[ch] || '';
|
||||
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
|
||||
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
|
||||
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
modifiers: 8, // Shift modifier flag
|
||||
key: ch,
|
||||
code,
|
||||
windowsVirtualKeyCode: keyCode,
|
||||
text: ch,
|
||||
unmodifiedText: ch,
|
||||
});
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
|
||||
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
modifiers: 8,
|
||||
key: ch,
|
||||
code,
|
||||
windowsVirtualKeyCode: keyCode,
|
||||
});
|
||||
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await raw.up('Shift');
|
||||
} else {
|
||||
// --- Fallback path: page.evaluate (detectable) ---
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
await raw.insertText(ch);
|
||||
await page.evaluate((key: string) => {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}, ch);
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await raw.up('Shift');
|
||||
}
|
||||
}
|
||||
|
||||
async function interCharDelay(cfg: HumanConfig): Promise<void> {
|
||||
if (Math.random() < cfg.typing_pause_chance) {
|
||||
await sleep(randRange(cfg.typing_pause_range));
|
||||
} else {
|
||||
const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread;
|
||||
await sleep(Math.max(10, delay));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* CloakBrowser — Stealth Chromium for Node.js
|
||||
*
|
||||
* Default export uses Playwright. For Puppeteer, import from 'cloakbrowser/puppeteer'.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Playwright (default)
|
||||
* import { launch } from 'cloakbrowser';
|
||||
* const browser = await launch();
|
||||
*
|
||||
* // Puppeteer
|
||||
* import { launch } from 'cloakbrowser/puppeteer';
|
||||
* const browser = await launch();
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Launch functions (Playwright API)
|
||||
export { launch, launchContext, launchPersistentContext } from "./playwright.js";
|
||||
|
||||
// Binary management
|
||||
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
|
||||
|
||||
// Config
|
||||
export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
|
||||
|
||||
// Types
|
||||
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Playwright launch wrapper for cloakbrowser.
|
||||
* Mirrors Python cloakbrowser/browser.py.
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext, BrowserContextOptions } from "playwright-core";
|
||||
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
||||
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { resolveProxyConfig } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
|
||||
/** @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) {
|
||||
const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
|
||||
delete (merged as any).timezoneId;
|
||||
return merged;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `locale` and `timezoneId` from user-provided contextOptions — both route
|
||||
* through detectable CDP emulation. The wrapper's top-level `locale`/`timezone`
|
||||
* fields use binary flags instead (undetectable). Warn so users notice.
|
||||
*/
|
||||
function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserContextOptions> {
|
||||
if (!ctx) return {};
|
||||
const { locale, timezoneId, ...rest } = ctx;
|
||||
if (locale !== undefined) {
|
||||
console.warn(
|
||||
"[cloakbrowser] contextOptions.locale ignored — use top-level `locale` " +
|
||||
"instead (routes through binary flag, avoids detectable CDP emulation)."
|
||||
);
|
||||
}
|
||||
if (timezoneId !== undefined) {
|
||||
console.warn(
|
||||
"[cloakbrowser] contextOptions.timezoneId ignored — use top-level `timezone` " +
|
||||
"instead (routes through binary flag, avoids detectable CDP emulation)."
|
||||
);
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth Chromium browser via Playwright.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { launch } from 'cloakbrowser';
|
||||
* const browser = await launch();
|
||||
* const page = await browser.newPage();
|
||||
* await page.goto('https://bot.incolumitas.com');
|
||||
* console.log(await page.title());
|
||||
* await browser.close();
|
||||
* ```
|
||||
*/
|
||||
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
const { chromium } = await import("playwright-core");
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
...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 ?? 'default',
|
||||
options.humanConfig,
|
||||
);
|
||||
patchBrowser(browser, cfg);
|
||||
}
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||
* Closing the context also closes the browser.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { launchContext } from 'cloakbrowser';
|
||||
* const context = await launchContext({
|
||||
* userAgent: 'Mozilla/5.0...',
|
||||
* viewport: { width: 1920, height: 1080 },
|
||||
* });
|
||||
* const page = await context.newPage();
|
||||
* await page.goto('https://example.com');
|
||||
* await context.close(); // also closes browser
|
||||
* ```
|
||||
*/
|
||||
export async function launchContext(
|
||||
options: LaunchContextOptions = {}
|
||||
): Promise<BrowserContext> {
|
||||
options = resolveTimezone(options);
|
||||
// Resolve geoip BEFORE launch() to avoid double-resolution
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
let launchArgs = await resolveWebrtcArgs(options);
|
||||
// Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
|
||||
if (exitIp && !(launchArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
launchArgs = [...(launchArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||
// 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, args: launchArgs, geoip: false });
|
||||
|
||||
let context: BrowserContext;
|
||||
try {
|
||||
context = await browser.newContext({
|
||||
// contextOptions first — explicit wrapper fields below override it.
|
||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||
...filterStealthCtxOptions(options.contextOptions),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
await browser.close();
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Patch close() to also close the browser
|
||||
const origClose = context.close.bind(context);
|
||||
context.close = async () => {
|
||||
await origClose();
|
||||
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 ?? 'default',
|
||||
options.humanConfig,
|
||||
);
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth browser with a persistent user profile (non-incognito).
|
||||
* Uses Playwright's chromium.launchPersistentContext() under the hood.
|
||||
*
|
||||
* This avoids incognito detection by services like BrowserScan (-10% penalty)
|
||||
* and enables session persistence (cookies, localStorage) across launches.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { launchPersistentContext } from 'cloakbrowser';
|
||||
* const context = await launchPersistentContext({
|
||||
* userDataDir: './chrome-profile',
|
||||
* headless: false,
|
||||
* proxy: 'http://user:pass@host:port',
|
||||
* geoip: true,
|
||||
* });
|
||||
* const page = context.pages()[0] || await context.newPage();
|
||||
* await page.goto('https://example.com');
|
||||
* await context.close();
|
||||
* ```
|
||||
*/
|
||||
export async function launchPersistentContext(
|
||||
options: LaunchPersistentContextOptions
|
||||
): Promise<BrowserContext> {
|
||||
options = resolveTimezone(options);
|
||||
const { chromium } = await import("playwright-core");
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
let resolvedArgs = await resolveWebrtcArgs(options);
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
|
||||
|
||||
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
|
||||
// — 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: IGNORE_DEFAULT_ARGS,
|
||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||
// contextOptions before explicit wrapper fields so explicit wins.
|
||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||
...filterStealthCtxOptions(options.contextOptions),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
...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 ?? 'default',
|
||||
options.humanConfig,
|
||||
);
|
||||
patchContext(context, cfg);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @internal Exposed for unit tests only. */
|
||||
export { buildArgs as _buildArgsForTest } from "./args.js";
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
|
||||
*/
|
||||
|
||||
export interface ParsedProxy {
|
||||
server: string;
|
||||
username?: string;
|
||||
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,
|
||||
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
|
||||
*/
|
||||
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
|
||||
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };
|
||||
|
||||
/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
|
||||
export interface ProxyConfig {
|
||||
/** Playwright proxy option (for HTTP proxies). */
|
||||
proxyOption?: ParsedProxy;
|
||||
/** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
|
||||
proxyArgs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a proxy uses the SOCKS5 protocol.
|
||||
*/
|
||||
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
|
||||
if (!proxy) return false;
|
||||
const url = typeof proxy === "string" ? proxy : proxy.server;
|
||||
return /^socks5h?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SOCKS URL from already-percent-encoded credentials and a host suffix.
|
||||
*
|
||||
* `encPass === null` means no password (no colon in userinfo). Empty string
|
||||
* means present-but-empty (colon preserved).
|
||||
*/
|
||||
function assembleSocksUrl(
|
||||
scheme: string,
|
||||
encUser: string,
|
||||
encPass: string | null,
|
||||
hostAndRest: string,
|
||||
): string {
|
||||
let userinfo: string;
|
||||
if (encPass !== null) {
|
||||
userinfo = `${encUser}:${encPass}@`;
|
||||
} else if (encUser) {
|
||||
userinfo = `${encUser}@`;
|
||||
} else {
|
||||
userinfo = "";
|
||||
}
|
||||
return `${scheme}://${userinfo}${hostAndRest}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient percent-decode that handles malformed escapes gracefully, matching
|
||||
* Python's ``urllib.parse.unquote``: valid ``%XX`` sequences are decoded,
|
||||
* bare ``%`` not followed by two hex digits is left as a literal ``%``.
|
||||
*/
|
||||
function lenientDecodeURIComponent(s: string): string {
|
||||
return s.replace(/%([0-9A-Fa-f]{2})|%/g, (match, hex) =>
|
||||
hex ? String.fromCharCode(parseInt(hex, 16)) : "%",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
|
||||
*/
|
||||
export function reconstructSocksUrl(proxy: ProxyDict): string {
|
||||
const url = new URL(proxy.server);
|
||||
if (proxy.username) {
|
||||
url.username = encodeURIComponent(proxy.username);
|
||||
if (proxy.password) url.password = encodeURIComponent(proxy.password);
|
||||
}
|
||||
return url.href.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encode credentials in a SOCKS5 URL string so Chromium's parser doesn't
|
||||
* truncate them at special chars like '='. Idempotent: pre-encoded input stays
|
||||
* the same (decoded then re-encoded).
|
||||
*
|
||||
* Parsing is done manually rather than via `new URL` + setters, because WHATWG
|
||||
* URL's username/password setters re-encode `%` on assignment, causing
|
||||
* double-encoding when we round-trip decode-then-encode.
|
||||
*
|
||||
* On any unexpected failure, logs a warning and returns the original string
|
||||
* so Chromium's own error handling can surface the real problem.
|
||||
*/
|
||||
export function normalizeSocksStringUrl(urlStr: string): string {
|
||||
// Split userinfo from host at the LAST '@' (RFC 3986), so a raw '@' inside
|
||||
// a password like `socks5://user:p@ss@host:1080` parses correctly. Matches
|
||||
// Python urlparse's rpartition('@') behavior.
|
||||
const schemeMatch = urlStr.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
|
||||
if (!schemeMatch) return urlStr;
|
||||
const [, scheme, rest] = schemeMatch;
|
||||
const hostStart = rest.search(/[/?#]/);
|
||||
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
|
||||
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
|
||||
const atIdx = authority.lastIndexOf("@");
|
||||
if (atIdx === -1) return urlStr; // no creds
|
||||
const userinfo = authority.slice(0, atIdx);
|
||||
const hostPart = authority.slice(atIdx + 1);
|
||||
// Validate port (matches Python's urlparse().port ValueError guard).
|
||||
// Extract port after last ':' — but skip IPv6 brackets (e.g. [::1]:1080).
|
||||
const bracketEnd = hostPart.lastIndexOf("]");
|
||||
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
|
||||
if (portColonIdx !== -1) {
|
||||
const portStr = hostPart.slice(portColonIdx + 1);
|
||||
if (portStr && !/^\d+$/.test(portStr)) {
|
||||
console.warn(`[cloakbrowser] Malformed SOCKS5 proxy URL, passing through unchanged: invalid port`);
|
||||
return urlStr;
|
||||
}
|
||||
}
|
||||
const hostAndRest = hostPart + suffix;
|
||||
const colonIdx = userinfo.indexOf(":");
|
||||
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
|
||||
const hasPassword = colonIdx !== -1;
|
||||
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
|
||||
try {
|
||||
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
|
||||
const encPass = hasPassword
|
||||
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
|
||||
: null;
|
||||
return assembleSocksUrl(scheme, encUser, encPass, hostAndRest);
|
||||
} catch (e) {
|
||||
console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`);
|
||||
return urlStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve proxy into Playwright option and/or Chrome args.
|
||||
*
|
||||
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
|
||||
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
|
||||
*/
|
||||
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
|
||||
if (!proxy) return { proxyArgs: [] };
|
||||
|
||||
if (isSocksProxy(proxy)) {
|
||||
// SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
|
||||
if (typeof proxy === "string") {
|
||||
// Re-encode creds to work around Chromium parser truncating passwords
|
||||
// at '=' and other special chars (#157).
|
||||
return { proxyArgs: [`--proxy-server=${normalizeSocksStringUrl(proxy)}`] };
|
||||
}
|
||||
const socksUrl = reconstructSocksUrl(proxy);
|
||||
const args = [`--proxy-server=${socksUrl}`];
|
||||
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
|
||||
return { proxyArgs: args };
|
||||
}
|
||||
|
||||
// HTTP/HTTPS: use Playwright's proxy dict
|
||||
if (typeof proxy === "string") {
|
||||
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
|
||||
}
|
||||
return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
|
||||
}
|
||||
|
||||
export function parseProxyUrl(proxy: string): ParsedProxy {
|
||||
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(normalized);
|
||||
} catch {
|
||||
// Not a parseable URL (e.g. bare "host:port") — pass through as-is
|
||||
return { server: proxy };
|
||||
}
|
||||
|
||||
if (!url.username) {
|
||||
return { server: proxy };
|
||||
}
|
||||
|
||||
// Rebuild server URL without credentials
|
||||
const server = `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ""}`;
|
||||
|
||||
const result: ParsedProxy = {
|
||||
server,
|
||||
username: decodeURIComponent(url.username),
|
||||
};
|
||||
if (url.password) {
|
||||
result.password = decodeURIComponent(url.password);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Puppeteer launch wrapper for cloakbrowser.
|
||||
* NOW WITH HUMANIZE SUPPORT — humanize: true enables human-like
|
||||
* mouse curves, keyboard timing, and scroll patterns (same as Playwright).
|
||||
*/
|
||||
|
||||
import type { Browser } from "puppeteer-core";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
|
||||
/**
|
||||
* Launch stealth Chromium browser via Puppeteer.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { launch } from 'cloakbrowser/puppeteer';
|
||||
* * // With humanize — human-like mouse, keyboard, scroll
|
||||
* const browser = await launch({ humanize: true });
|
||||
* const page = await browser.newPage();
|
||||
* await page.goto('[https://example.com](https://example.com)');
|
||||
* await page.click('#login'); // Bézier curve mouse movement
|
||||
* await page.type('#email', 'user@example.com'); // Per-character timing
|
||||
* ```
|
||||
*/
|
||||
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
const { exitIp, ...resolved } = (await maybeResolveGeoip(options)) ?? {};
|
||||
let resolvedArgs = (await resolveWebrtcArgs(options)) ?? options.args;
|
||||
|
||||
if (exitIp && !(resolvedArgs ?? []).some(a => a.startsWith("--fingerprint-webrtc-ip"))) {
|
||||
resolvedArgs = [...(resolvedArgs ?? []), `--fingerprint-webrtc-ip=${exitIp}`];
|
||||
}
|
||||
const args = buildArgs({ ...options, ...resolved, args: resolvedArgs });
|
||||
|
||||
// Puppeteer handles proxy via CLI args, not a separate option.
|
||||
// SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
|
||||
// HTTP: Chrome does NOT support inline credentials — strip them and
|
||||
// use page.authenticate() for Proxy-Authorization headers instead.
|
||||
let proxyAuth: { username: string; password: string } | undefined;
|
||||
if (options.proxy) {
|
||||
if (isSocksProxy(options.proxy)) {
|
||||
// SOCKS5: pass full URL with credentials to Chrome directly
|
||||
const { proxyArgs } = resolveProxyConfig(options.proxy);
|
||||
args.push(...proxyArgs);
|
||||
} else if (typeof options.proxy === "string") {
|
||||
const { server, username, password } = parseProxyUrl(options.proxy);
|
||||
args.push(`--proxy-server=${server}`);
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
} else {
|
||||
const parsed = parseProxyUrl(options.proxy.server);
|
||||
args.push(`--proxy-server=${parsed.server}`);
|
||||
if (options.proxy.bypass) {
|
||||
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
|
||||
}
|
||||
const username = options.proxy.username ?? parsed.username;
|
||||
const password = options.proxy.password ?? parsed.password;
|
||||
if (username) {
|
||||
proxyAuth = { username, password: password ?? "" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await puppeteer.default.launch({
|
||||
executablePath: binaryPath,
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
...options.launchOptions,
|
||||
});
|
||||
|
||||
// Monkey-patch newPage() to auto-authenticate proxy credentials
|
||||
if (proxyAuth) {
|
||||
const origNewPage = browser.newPage.bind(browser);
|
||||
const auth = proxyAuth;
|
||||
browser.newPage = async (...pageArgs: Parameters<typeof origNewPage>) => {
|
||||
const page = await origNewPage(...pageArgs);
|
||||
await page.authenticate(auth);
|
||||
return page;
|
||||
};
|
||||
}
|
||||
|
||||
// Human-like behavioral patching — FULL coverage, same as Playwright.
|
||||
// This enables Bézier mouse movements, organic typing rhythms, and
|
||||
// natural scrolling to bypass advanced anti-bot detection.
|
||||
if (options.humanize) {
|
||||
const { patchBrowser } = await import('./human-puppeteer/index.js');
|
||||
const { resolveConfig } = await import('./human/config.js');
|
||||
const cfg = resolveConfig(
|
||||
options.humanPreset ?? 'default',
|
||||
options.humanConfig,
|
||||
);
|
||||
patchBrowser(browser, cfg);
|
||||
}
|
||||
|
||||
return browser;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Shared types for cloakbrowser launch wrappers.
|
||||
*/
|
||||
|
||||
import type { BrowserContextOptions } from "playwright-core";
|
||||
import type { HumanConfig, HumanPreset } from "./human/config.js";
|
||||
|
||||
export interface LaunchOptions {
|
||||
/** Run in headless mode (default: true). */
|
||||
headless?: boolean;
|
||||
/**
|
||||
* Proxy server — URL string or Playwright proxy object.
|
||||
* String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
|
||||
* Object: { server: "http://proxy:8080", bypass: ".google.com", ... }
|
||||
* — passed directly to Playwright.
|
||||
*/
|
||||
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
|
||||
/** Additional Chromium CLI arguments. */
|
||||
args?: string[];
|
||||
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
|
||||
stealthArgs?: boolean;
|
||||
/** IANA timezone, e.g. "America/New_York". Sets --fingerprint-timezone binary flag. */
|
||||
timezone?: string;
|
||||
/** BCP 47 locale, e.g. "en-US". Sets --lang binary flag. */
|
||||
locale?: string;
|
||||
/** Auto-detect timezone/locale from proxy IP (requires: npm install mmdb-lib). */
|
||||
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?: HumanPreset;
|
||||
/** Override individual human behavior parameters. */
|
||||
humanConfig?: Partial<HumanConfig>;
|
||||
}
|
||||
|
||||
export interface LaunchContextOptions extends LaunchOptions {
|
||||
/** Custom user agent string. */
|
||||
userAgent?: string;
|
||||
/** Viewport size. */
|
||||
viewport?: { width: number; height: number } | null;
|
||||
/** Browser locale, e.g. "en-US". */
|
||||
locale?: string;
|
||||
/** IANA timezone — alias for `timezone`. Either works. */
|
||||
timezoneId?: string;
|
||||
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */
|
||||
colorScheme?: "light" | "dark" | "no-preference";
|
||||
/**
|
||||
* Extra options forwarded directly to Playwright's `browser.newContext()` —
|
||||
* e.g. `storageState`, `permissions`, `geolocation`, `extraHTTPHeaders`,
|
||||
* `httpCredentials`. Use this for context-level options not surfaced as
|
||||
* top-level fields. `locale` and `timezoneId` are stripped here to avoid
|
||||
* detectable CDP emulation — use the top-level `locale` and `timezone`
|
||||
* wrapper fields instead (they route through undetectable binary flags).
|
||||
*/
|
||||
contextOptions?: BrowserContextOptions;
|
||||
}
|
||||
|
||||
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
||||
/** Path to user data directory for persistent profile. */
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
export interface BinaryInfo {
|
||||
version: string;
|
||||
platform: string;
|
||||
binaryPath: string;
|
||||
installed: boolean;
|
||||
cacheDir: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CHROMIUM_VERSION,
|
||||
getArchiveExt,
|
||||
getChromiumVersion,
|
||||
getDefaultStealthArgs,
|
||||
getCacheDir,
|
||||
getBinaryDir,
|
||||
getDownloadUrl,
|
||||
getFallbackDownloadUrl,
|
||||
} from "../src/config.js";
|
||||
import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js";
|
||||
|
||||
describe("config", () => {
|
||||
it("CHROMIUM_VERSION matches expected format", () => {
|
||||
expect(CHROMIUM_VERSION).toMatch(/^\d+\.\d+\.\d+\.\d+(\.\d+)?$/);
|
||||
});
|
||||
|
||||
it("getDefaultStealthArgs returns expected flags", () => {
|
||||
const args = getDefaultStealthArgs();
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
expect(args).toContain("--no-sandbox");
|
||||
|
||||
if (isMac) {
|
||||
expect(args).toContain("--fingerprint-platform=macos");
|
||||
} else {
|
||||
expect(args).toContain("--fingerprint-platform=windows");
|
||||
}
|
||||
|
||||
// GPU flags removed — binary auto-generates from seed + platform
|
||||
expect(args.some((a) => a.includes("fingerprint-gpu-vendor"))).toBe(false);
|
||||
expect(args.some((a) => a.includes("fingerprint-gpu-renderer"))).toBe(false);
|
||||
|
||||
// Should have a random fingerprint seed
|
||||
const fingerprintArg = args.find((a) => a.startsWith("--fingerprint="));
|
||||
expect(fingerprintArg).toBeDefined();
|
||||
const seed = Number(fingerprintArg!.split("=")[1]);
|
||||
expect(seed).toBeGreaterThanOrEqual(10000);
|
||||
expect(seed).toBeLessThanOrEqual(99999);
|
||||
});
|
||||
|
||||
it("getDefaultStealthArgs generates different seeds", () => {
|
||||
const seeds = new Set<string>();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const args = getDefaultStealthArgs();
|
||||
const fp = args.find((a) => a.startsWith("--fingerprint="))!;
|
||||
seeds.add(fp);
|
||||
}
|
||||
// With 90k possible seeds, 10 calls should produce at least 2 unique
|
||||
expect(seeds.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("getCacheDir returns ~/.cloakbrowser by default", () => {
|
||||
const dir = getCacheDir();
|
||||
expect(dir).toContain(".cloakbrowser");
|
||||
});
|
||||
|
||||
it("getBinaryDir includes platform version", () => {
|
||||
const dir = getBinaryDir();
|
||||
expect(dir).toContain(`chromium-${getChromiumVersion()}`);
|
||||
});
|
||||
|
||||
it("getDownloadUrl contains platform version and platform tag", () => {
|
||||
const url = getDownloadUrl();
|
||||
expect(url).toContain(getChromiumVersion());
|
||||
expect(url).toContain("cloakbrowser-");
|
||||
expect(url).toContain(".tar.gz");
|
||||
expect(url).toContain("cloakbrowser.dev");
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive helpers", () => {
|
||||
it("getArchiveExt returns correct extension for platform", () => {
|
||||
const ext = getArchiveExt();
|
||||
if (process.platform === "win32") {
|
||||
expect(ext).toBe(".zip");
|
||||
} else {
|
||||
expect(ext).toBe(".tar.gz");
|
||||
}
|
||||
});
|
||||
|
||||
it("getFallbackDownloadUrl uses GitHub Releases", () => {
|
||||
const url = getFallbackDownloadUrl("145.0.0.0");
|
||||
expect(url).toContain("github.com/CloakHQ/cloakbrowser/releases/download");
|
||||
expect(url).toContain("chromium-v145.0.0.0");
|
||||
});
|
||||
|
||||
it("getFallbackDownloadUrl uses default version", () => {
|
||||
const url = getFallbackDownloadUrl();
|
||||
expect(url).toContain(`chromium-v${getChromiumVersion()}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArgs timezone/locale", () => {
|
||||
it("injects --fingerprint-timezone when timezone is set", () => {
|
||||
const args = _buildArgsForTest({ timezone: "America/New_York" });
|
||||
expect(args).toContain("--fingerprint-timezone=America/New_York");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("does not inject flags when not set", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArgs deduplication", () => {
|
||||
it("user --fingerprint overrides default seed", () => {
|
||||
const args = _buildArgsForTest({ args: ["--fingerprint=99887"] });
|
||||
const fpArgs = args.filter(a => a.startsWith("--fingerprint="));
|
||||
expect(fpArgs).toHaveLength(1);
|
||||
expect(fpArgs[0]).toBe("--fingerprint=99887");
|
||||
});
|
||||
|
||||
it("user --fingerprint-platform overrides default", () => {
|
||||
const args = _buildArgsForTest({ args: ["--fingerprint-platform=linux"] });
|
||||
const platArgs = args.filter(a => a.startsWith("--fingerprint-platform="));
|
||||
expect(platArgs).toHaveLength(1);
|
||||
expect(platArgs[0]).toBe("--fingerprint-platform=linux");
|
||||
});
|
||||
|
||||
it("timezone param overrides user --fingerprint-timezone arg", () => {
|
||||
const args = _buildArgsForTest({
|
||||
args: ["--fingerprint-timezone=Europe/London"],
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
const tzArgs = args.filter(a => a.startsWith("--fingerprint-timezone="));
|
||||
expect(tzArgs).toHaveLength(1);
|
||||
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
|
||||
});
|
||||
|
||||
it("locale param overrides user --lang and --fingerprint-locale args", () => {
|
||||
const args = _buildArgsForTest({
|
||||
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", () => {
|
||||
const args = _buildArgsForTest({
|
||||
args: ["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de-DE",
|
||||
});
|
||||
const keys = args.map(a => a.split("=")[0]);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("non-value flags preserved without dedup issues", () => {
|
||||
const args = _buildArgsForTest({ args: ["--disable-gpu", "--no-zygote"] });
|
||||
expect(args).toContain("--disable-gpu");
|
||||
expect(args).toContain("--no-zygote");
|
||||
expect(args).toContain("--no-sandbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArgs webrtc IP", () => {
|
||||
it("passes --fingerprint-webrtc-ip from args", () => {
|
||||
const args = _buildArgsForTest({ args: ["--fingerprint-webrtc-ip=1.2.3.4"] });
|
||||
expect(args).toContain("--fingerprint-webrtc-ip=1.2.3.4");
|
||||
});
|
||||
|
||||
it("does not inject when not in args", () => {
|
||||
const args = _buildArgsForTest({});
|
||||
expect(args.some(a => a.startsWith("--fingerprint-webrtc-ip"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimezone alias", () => {
|
||||
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 = 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 = 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 = resolveTimezone(opts);
|
||||
expect(result).toBe(opts);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { COUNTRY_LOCALE_MAP, resolveProxyIp } from "../src/geoip.js";
|
||||
|
||||
describe("resolveProxyIp", () => {
|
||||
it("returns literal IPv4 from proxy URL", async () => {
|
||||
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
|
||||
});
|
||||
|
||||
it("handles proxy URL with credentials", async () => {
|
||||
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe(
|
||||
"10.50.96.5"
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves localhost", async () => {
|
||||
const ip = await resolveProxyIp("http://localhost:8888");
|
||||
expect(ip).toBeTruthy();
|
||||
expect(["127.0.0.1", "::1"]).toContain(ip);
|
||||
});
|
||||
|
||||
it("returns null for invalid URL", async () => {
|
||||
expect(await resolveProxyIp("not-a-url")).toBeNull();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("contains common countries", () => {
|
||||
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
|
||||
expect(COUNTRY_LOCALE_MAP[code]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("values are BCP 47 language-REGION format", () => {
|
||||
for (const [code, locale] of Object.entries(COUNTRY_LOCALE_MAP)) {
|
||||
const parts = locale.split("-");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0]).toMatch(/^[a-z]{2,3}$/);
|
||||
expect(parts[1]).toMatch(/^[A-Z]{2}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { binaryInfo } from "../src/download.js";
|
||||
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
|
||||
|
||||
describe("binaryInfo", () => {
|
||||
it("returns correct structure", () => {
|
||||
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = `/tmp/cloakbrowser-test-${Date.now()}`;
|
||||
try {
|
||||
const info = binaryInfo();
|
||||
|
||||
expect(info.version).toBe(getChromiumVersion());
|
||||
expect(info.platform).toMatch(/^(linux|darwin|windows)-(x64|arm64)$/);
|
||||
expect(info.binaryPath).toBeTruthy();
|
||||
expect(typeof info.installed).toBe("boolean");
|
||||
expect(info.cacheDir).toContain("cloakbrowser");
|
||||
} finally {
|
||||
if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig;
|
||||
else delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Integration tests require the binary — run with:
|
||||
// CLOAKBROWSER_BINARY_PATH=/path/to/chrome npm test
|
||||
describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)(
|
||||
"launch (integration)",
|
||||
() => {
|
||||
it("launches browser and checks stealth", async () => {
|
||||
const { launch } = await import("../src/playwright.js");
|
||||
|
||||
const browser = await launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto("about:blank");
|
||||
|
||||
const webdriver = await page.evaluate(() => navigator.webdriver);
|
||||
expect(webdriver).toBeFalsy();
|
||||
|
||||
const plugins = await page.evaluate(() => navigator.plugins.length);
|
||||
expect(plugins).toBeGreaterThan(0);
|
||||
|
||||
await browser.close();
|
||||
}, 30_000);
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// launchContext / launchPersistentContext unit tests (mock playwright-core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("launchContext (unit)", () => {
|
||||
let mockContext: any;
|
||||
let mockBrowser: any;
|
||||
let mockChromium: any;
|
||||
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
const origClose = vi.fn();
|
||||
mockContext = { close: origClose, _origClose: origClose };
|
||||
mockBrowser = {
|
||||
newContext: vi.fn().mockResolvedValue(mockContext),
|
||||
close: vi.fn(),
|
||||
};
|
||||
mockChromium = { launch: vi.fn().mockResolvedValue(mockBrowser) };
|
||||
|
||||
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (origEnv) {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it("applies DEFAULT_VIEWPORT when no viewport given", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext();
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("uses custom viewport when provided", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
const custom = { width: 1280, height: 720 };
|
||||
await launchContext({ viewport: custom });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.viewport).toEqual(custom);
|
||||
});
|
||||
|
||||
it("forwards userAgent to newContext", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ userAgent: "Custom/1.0" });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.userAgent).toBe("Custom/1.0");
|
||||
});
|
||||
|
||||
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 --fingerprint-timezone binary flag
|
||||
const launchArgs = mockChromium.launch.mock.calls[0][0];
|
||||
const hasTimezoneFlag = launchArgs.args.some((a: string) =>
|
||||
a.startsWith("--fingerprint-timezone=America/New_York")
|
||||
);
|
||||
expect(hasTimezoneFlag).toBe(true);
|
||||
|
||||
// NOT in newContext() — no CDP emulation
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.timezoneId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards colorScheme to newContext", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({ colorScheme: "dark" });
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.colorScheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("close() also closes browser", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
const ctx = await launchContext();
|
||||
|
||||
await ctx.close();
|
||||
// Original context close called
|
||||
expect(mockContext._origClose).toHaveBeenCalledOnce();
|
||||
// Browser also closed
|
||||
expect(mockBrowser.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("forwards contextOptions to newContext (storageState, etc.)", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({
|
||||
contextOptions: {
|
||||
storageState: "state.json",
|
||||
permissions: ["geolocation"],
|
||||
},
|
||||
});
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.storageState).toBe("state.json");
|
||||
expect(ctxArgs.permissions).toEqual(["geolocation"]);
|
||||
});
|
||||
|
||||
it("explicit top-level fields win over contextOptions on collision", async () => {
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({
|
||||
userAgent: "Explicit/1.0",
|
||||
viewport: { width: 1280, height: 720 },
|
||||
colorScheme: "dark",
|
||||
contextOptions: {
|
||||
userAgent: "ShouldBeOverridden/9.9",
|
||||
viewport: { width: 9999, height: 9999 },
|
||||
colorScheme: "light",
|
||||
},
|
||||
});
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
expect(ctxArgs.userAgent).toBe("Explicit/1.0");
|
||||
expect(ctxArgs.viewport).toEqual({ width: 1280, height: 720 });
|
||||
expect(ctxArgs.colorScheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("strips locale and timezoneId from contextOptions (stealth-sensitive)", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { launchContext } = await import("../src/playwright.js");
|
||||
await launchContext({
|
||||
contextOptions: {
|
||||
storageState: "state.json",
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
|
||||
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||
// Stealth-sensitive keys stripped — they would reintroduce detectable CDP emulation.
|
||||
expect(ctxArgs.locale).toBeUndefined();
|
||||
expect(ctxArgs.timezoneId).toBeUndefined();
|
||||
// Benign keys preserved
|
||||
expect(ctxArgs.storageState).toBe("state.json");
|
||||
// Warning was logged for both stripped keys
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchPersistentContext (unit)", () => {
|
||||
let mockContext: any;
|
||||
let mockChromium: any;
|
||||
const origEnv = process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/fake/chrome";
|
||||
mockContext = { close: vi.fn(), pages: vi.fn().mockReturnValue([]) };
|
||||
mockChromium = {
|
||||
launchPersistentContext: vi.fn().mockResolvedValue(mockContext),
|
||||
};
|
||||
|
||||
vi.doMock("playwright-core", () => ({ chromium: mockChromium }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (origEnv) {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = origEnv;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it("applies DEFAULT_VIEWPORT", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({ userDataDir: "/tmp/profile" });
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("passes timezone and locale via binary args, not CDP context", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
timezone: "Asia/Tokyo",
|
||||
locale: "ja-JP",
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
// 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 () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
proxy: "http://user:pass@proxy:8080",
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.proxy.server).toBe("http://proxy:8080");
|
||||
expect(args.proxy.username).toBe("user");
|
||||
expect(args.proxy.password).toBe("pass");
|
||||
});
|
||||
|
||||
it("forwards userAgent and colorScheme", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
userAgent: "Custom/1.0",
|
||||
colorScheme: "dark",
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.userAgent).toBe("Custom/1.0");
|
||||
expect(args.colorScheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("forwards contextOptions to launchPersistentContext", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
contextOptions: {
|
||||
permissions: ["geolocation"],
|
||||
extraHTTPHeaders: { "X-Custom": "1" },
|
||||
},
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.permissions).toEqual(["geolocation"]);
|
||||
expect(args.extraHTTPHeaders).toEqual({ "X-Custom": "1" });
|
||||
});
|
||||
|
||||
it("explicit top-level fields win over contextOptions in persistent context", async () => {
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
userAgent: "Explicit/1.0",
|
||||
viewport: { width: 1280, height: 720 },
|
||||
contextOptions: {
|
||||
userAgent: "ShouldBeOverridden/9.9",
|
||||
viewport: { width: 9999, height: 9999 },
|
||||
},
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.userAgent).toBe("Explicit/1.0");
|
||||
expect(args.viewport).toEqual({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
it("strips locale and timezoneId from contextOptions (persistent context)", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||
await launchPersistentContext({
|
||||
userDataDir: "/tmp/profile",
|
||||
contextOptions: {
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
|
||||
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||
expect(args.locale).toBeUndefined();
|
||||
expect(args.timezoneId).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
||||
import type { LaunchOptions } from "../src/types.js";
|
||||
|
||||
describe("parseProxyUrl", () => {
|
||||
it("passes through URL without credentials", () => {
|
||||
expect(parseProxyUrl("http://proxy:8080")).toEqual({
|
||||
server: "http://proxy:8080",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts credentials from URL", () => {
|
||||
expect(parseProxyUrl("http://user:pass@proxy:8080")).toEqual({
|
||||
server: "http://proxy:8080",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes URL-encoded special chars", () => {
|
||||
const result = parseProxyUrl("http://user:p%40ss%3Aword@proxy:8080");
|
||||
expect(result.password).toBe("p@ss:word");
|
||||
expect(result.username).toBe("user");
|
||||
expect(result.server).toBe("http://proxy:8080");
|
||||
});
|
||||
|
||||
it("handles socks5 protocol", () => {
|
||||
const result = parseProxyUrl("socks5://user:pass@proxy:1080");
|
||||
expect(result.server).toBe("socks5://proxy:1080");
|
||||
expect(result.username).toBe("user");
|
||||
expect(result.password).toBe("pass");
|
||||
});
|
||||
|
||||
it("handles URL without port", () => {
|
||||
const result = parseProxyUrl("http://user:pass@proxy");
|
||||
expect(result.server).toBe("http://proxy");
|
||||
expect(result.username).toBe("user");
|
||||
});
|
||||
|
||||
it("handles username only (no password)", () => {
|
||||
const result = parseProxyUrl("http://user@proxy:8080");
|
||||
expect(result.server).toBe("http://proxy:8080");
|
||||
expect(result.username).toBe("user");
|
||||
expect(result.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through unparseable string", () => {
|
||||
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy dict type", () => {
|
||||
it("accepts string proxy in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = { proxy: "http://proxy:8080" };
|
||||
expect(typeof opts.proxy).toBe("string");
|
||||
});
|
||||
|
||||
it("accepts dict proxy with bypass in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = {
|
||||
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
|
||||
};
|
||||
expect(typeof opts.proxy).toBe("object");
|
||||
if (typeof opts.proxy === "object") {
|
||||
expect(opts.proxy.server).toBe("http://proxy:8080");
|
||||
expect(opts.proxy.bypass).toBe(".google.com,localhost");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts dict proxy with auth and bypass in LaunchOptions", () => {
|
||||
const opts: LaunchOptions = {
|
||||
proxy: {
|
||||
server: "http://proxy:8080",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
bypass: ".example.com",
|
||||
},
|
||||
};
|
||||
if (typeof opts.proxy === "object") {
|
||||
expect(opts.proxy.username).toBe("user");
|
||||
expect(opts.proxy.password).toBe("pass");
|
||||
expect(opts.proxy.bypass).toBe(".example.com");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSocksProxy", () => {
|
||||
it("detects socks5 string", () => {
|
||||
expect(isSocksProxy("socks5://user:pass@host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects socks5h string", () => {
|
||||
expect(isSocksProxy("socks5h://host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("case insensitive", () => {
|
||||
expect(isSocksProxy("SOCKS5://host:1080")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http", () => {
|
||||
expect(isSocksProxy("http://host:8080")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects socks5 dict", () => {
|
||||
expect(isSocksProxy({ server: "socks5://host:1080" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects http dict", () => {
|
||||
expect(isSocksProxy({ server: "http://host:8080" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isSocksProxy(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProxyConfig", () => {
|
||||
it("returns empty for undefined", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(undefined);
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns playwright dict for http string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
|
||||
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns playwright dict for http dict", () => {
|
||||
const proxy = { server: "http://proxy:8080", bypass: ".example.com" };
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig(proxy);
|
||||
expect(proxyOption).toEqual(proxy);
|
||||
expect(proxyArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5 string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://user:pass@host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:1080"]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5 no auth", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5://host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
|
||||
});
|
||||
|
||||
it("returns chrome arg for socks5h string", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig("socks5h://user:pass@host:1080");
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5h://user:pass@host:1080"]);
|
||||
});
|
||||
|
||||
it("reconstructs URL from socks5 dict with auth", () => {
|
||||
const { proxyOption, proxyArgs } = resolveProxyConfig({
|
||||
server: "socks5://host:1080",
|
||||
username: "user",
|
||||
password: "p@ss",
|
||||
});
|
||||
expect(proxyOption).toBeUndefined();
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:p%40ss@host:1080"]);
|
||||
});
|
||||
|
||||
it("includes bypass for socks5 dict", () => {
|
||||
const { proxyArgs } = resolveProxyConfig({
|
||||
server: "socks5://host:1080",
|
||||
bypass: ".example.com",
|
||||
});
|
||||
expect(proxyArgs).toContain("--proxy-server=socks5://host:1080");
|
||||
expect(proxyArgs).toContain("--proxy-bypass-list=.example.com");
|
||||
});
|
||||
|
||||
// Chromium's --proxy-server parser truncates passwords at '=' (#157).
|
||||
// Wrapper must auto URL-encode before passing to Chrome.
|
||||
it("encodes '=' in socks5 string password", () => {
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://user:pass=123@host:1080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass%3D123@host:1080"]);
|
||||
});
|
||||
|
||||
it("encoding is idempotent for already-encoded socks5 string", () => {
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://user:pass%3D123@host:1080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass%3D123@host:1080"]);
|
||||
});
|
||||
|
||||
it("leaves socks5 string without creds unchanged", () => {
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://host:1080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://host:1080"]);
|
||||
});
|
||||
|
||||
it("encodes password even with empty username (password-only userinfo)", () => {
|
||||
// Regression: empty-username bypass would skip encoding, leaving the
|
||||
// Chromium truncation bug alive for this userinfo shape.
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://:pass=123@host:1080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://:pass%3D123@host:1080"]);
|
||||
});
|
||||
|
||||
it("handles literal '%' in password without throwing (malformed escape)", () => {
|
||||
// JS's decodeURIComponent throws on '%sure' (% not followed by 2 hex digits).
|
||||
// Must fall back to treating '%' as literal and percent-encoding it.
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://user:100%sure@host:1080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:100%25sure@host:1080"]);
|
||||
});
|
||||
|
||||
it("passes malformed SOCKS5 URLs through unchanged (no throw)", () => {
|
||||
// Broken IPv6 bracket — wrapper must not throw;
|
||||
// Chromium will surface its own error.
|
||||
const { proxyArgs: a1 } = resolveProxyConfig("socks5://user:pass@[::1");
|
||||
expect(a1).toEqual(["--proxy-server=socks5://user:pass@[::1"]);
|
||||
});
|
||||
|
||||
it("passes non-numeric port through unchanged", () => {
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://user:pass@host:abc");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass@host:abc"]);
|
||||
});
|
||||
|
||||
it("encodes special chars in IPv6 SOCKS5 string password", () => {
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://user:pass=eq@[::1]:1080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:pass%3Deq@[::1]:1080"]);
|
||||
});
|
||||
|
||||
// Regression #157: userinfo must be split at the LAST '@' (RFC 3986),
|
||||
// not the first, so raw '@' in a password parses correctly.
|
||||
it("encodes raw '@' in socks5 string password (last-@ split)", () => {
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://user:p@ss@host:1080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:p%40ss@host:1080"]);
|
||||
});
|
||||
|
||||
it("handles multiple raw '@' in password (splits at last)", () => {
|
||||
const { proxyArgs } = resolveProxyConfig("socks5://user:a@b@c@host:1080");
|
||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:a%40b%40c@host:1080"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
|
||||
// Mock puppeteer-core and download before importing the module under test
|
||||
vi.mock("puppeteer-core", () => ({
|
||||
default: {
|
||||
launch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../src/download.js", () => ({
|
||||
ensureBinary: vi.fn().mockResolvedValue("/fake/chrome"),
|
||||
}));
|
||||
|
||||
vi.mock("../src/geoip.js", () => ({
|
||||
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
|
||||
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
|
||||
resolveWebrtcArgs: vi.fn().mockImplementation((opts: any) => Promise.resolve(opts.args)),
|
||||
}));
|
||||
|
||||
describe("puppeteer launch", () => {
|
||||
let puppeteerMock: any;
|
||||
let mockBrowser: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
puppeteerMock = await import("puppeteer-core");
|
||||
mockBrowser = {
|
||||
newPage: vi.fn().mockResolvedValue({
|
||||
authenticate: vi.fn(),
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(puppeteerMock.default.launch).mockResolvedValue(mockBrowser);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls ensureBinary and launches with binary path", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch();
|
||||
|
||||
expect(puppeteerMock.default.launch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executablePath: "/fake/chrome",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes stealth args by default", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch();
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
|
||||
expect(callArgs.args).toContain("--no-sandbox");
|
||||
});
|
||||
|
||||
it("excludes stealth args when stealthArgs=false", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ stealthArgs: false });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
|
||||
});
|
||||
|
||||
it("adds --proxy-server for string proxy", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ proxy: "http://proxy:8080" });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=http://proxy:8080");
|
||||
});
|
||||
|
||||
it("adds --proxy-bypass-list for dict proxy with bypass", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({
|
||||
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=http://proxy:8080");
|
||||
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
|
||||
});
|
||||
|
||||
it("monkey-patches newPage for proxy auth", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
|
||||
|
||||
// newPage should auto-authenticate
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).toHaveBeenCalledWith({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("injects timezone and locale as binary flags", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ timezone: "Asia/Tokyo", locale: "ja-JP" });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--fingerprint-timezone=Asia/Tokyo");
|
||||
expect(callArgs.args).toContain("--lang=ja-JP");
|
||||
});
|
||||
|
||||
it("merges extra args", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ args: ["--disable-gpu", "--no-first-run"] });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--disable-gpu");
|
||||
expect(callArgs.args).toContain("--no-first-run");
|
||||
});
|
||||
|
||||
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({ proxy: "socks5://user:pass@proxy:1080" });
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=socks5://user:pass@proxy:1080");
|
||||
|
||||
// Should NOT set up page.authenticate for SOCKS5
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconstructs SOCKS5 dict with auth into --proxy-server URL", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({
|
||||
proxy: { server: "socks5://proxy:1080", username: "user", password: "p@ss" },
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(callArgs.args).toContain("--proxy-server=socks5://user:p%40ss@proxy:1080");
|
||||
|
||||
const page = await browser.newPage();
|
||||
expect(page.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import {
|
||||
CHROMIUM_VERSION,
|
||||
getChromiumVersion,
|
||||
getDownloadUrl,
|
||||
getEffectiveVersion,
|
||||
getPlatformTag,
|
||||
parseVersion,
|
||||
versionNewer,
|
||||
} from "../src/config.js";
|
||||
import {
|
||||
binaryInfo,
|
||||
checkForUpdate,
|
||||
checkWrapperUpdate,
|
||||
clearCache,
|
||||
ensureBinary,
|
||||
fetchChecksums,
|
||||
getLatestChromiumVersion,
|
||||
parseChecksums,
|
||||
resetWrapperUpdateChecked,
|
||||
} from "../src/download.js";
|
||||
|
||||
describe("version comparison", () => {
|
||||
it("parseVersion handles 4-part versions", () => {
|
||||
expect(parseVersion("145.0.7718.0")).toEqual([145, 0, 7718, 0]);
|
||||
expect(parseVersion("142.0.7444.175")).toEqual([142, 0, 7444, 175]);
|
||||
});
|
||||
|
||||
it("detects newer version", () => {
|
||||
expect(versionNewer("145.0.7718.0", "142.0.7444.175")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects older version", () => {
|
||||
expect(versionNewer("142.0.7444.175", "145.0.7718.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("same version is not newer", () => {
|
||||
expect(versionNewer("142.0.7444.175", "142.0.7444.175")).toBe(false);
|
||||
});
|
||||
|
||||
it("patch bump detected", () => {
|
||||
expect(versionNewer("142.0.7444.176", "142.0.7444.175")).toBe(true);
|
||||
});
|
||||
|
||||
it("major bump wins over minor", () => {
|
||||
expect(versionNewer("143.0.0.0", "142.9.9999.999")).toBe(true);
|
||||
});
|
||||
|
||||
it("parseVersion handles 5-part build numbers", () => {
|
||||
expect(parseVersion("145.0.7632.109.2")).toEqual([145, 0, 7632, 109, 2]);
|
||||
});
|
||||
|
||||
it("build bump detected", () => {
|
||||
expect(versionNewer("145.0.7632.109.3", "145.0.7632.109.2")).toBe(true);
|
||||
});
|
||||
|
||||
it("build suffix newer than no suffix", () => {
|
||||
expect(versionNewer("145.0.7632.109.2", "145.0.7632.109")).toBe(true);
|
||||
});
|
||||
|
||||
it("no suffix older than build suffix", () => {
|
||||
expect(versionNewer("145.0.7632.109", "145.0.7632.109.2")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("download URL", () => {
|
||||
it("uses chromium-v prefix and cloakbrowser repo", () => {
|
||||
const url = getDownloadUrl();
|
||||
expect(url).toContain("cloakbrowser.dev");
|
||||
expect(url).toContain(`chromium-v${getChromiumVersion()}`);
|
||||
expect(url.endsWith(".tar.gz")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts custom version", () => {
|
||||
const url = getDownloadUrl("145.0.7718.0");
|
||||
expect(url).toContain("chromium-v145.0.7718.0");
|
||||
});
|
||||
|
||||
it("does not reference old repo", () => {
|
||||
const url = getDownloadUrl();
|
||||
expect(url).not.toContain("chromium-stealth-builds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("latest version (platform-aware)", () => {
|
||||
const platformTarball = `cloakbrowser-${getPlatformTag()}.tar.gz`;
|
||||
|
||||
function makeAssets(platforms: string[]) {
|
||||
return platforms.map((p) => ({ name: `cloakbrowser-${p}.tar.gz` }));
|
||||
}
|
||||
|
||||
function mockFetch(releases: Array<Record<string, unknown>>) {
|
||||
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => releases,
|
||||
} as Response);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns version when release has platform asset", async () => {
|
||||
mockFetch([
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
|
||||
});
|
||||
|
||||
it("skips release without platform asset", async () => {
|
||||
const spy = mockFetch([
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64"]), // Linux only
|
||||
},
|
||||
{
|
||||
tag_name: "chromium-v142.0.7444.175",
|
||||
draft: false,
|
||||
assets: makeAssets(["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"]),
|
||||
},
|
||||
]);
|
||||
const result = await getLatestChromiumVersion();
|
||||
const tag = getPlatformTag();
|
||||
if (tag === "linux-x64") {
|
||||
expect(result).toBe("145.0.7718.0");
|
||||
} else {
|
||||
expect(result).toBe("142.0.7444.175");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when no release has platform asset", async () => {
|
||||
mockFetch([
|
||||
{
|
||||
tag_name: "chromium-v145.0.7718.0",
|
||||
draft: false,
|
||||
assets: [{ name: "cloakbrowser-freebsd-x64.tar.gz" }],
|
||||
},
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBeNull();
|
||||
});
|
||||
|
||||
it("skips draft releases", async () => {
|
||||
const all = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"];
|
||||
mockFetch([
|
||||
{ tag_name: "chromium-v999.0.0.0", draft: true, assets: makeAssets(all) },
|
||||
{ tag_name: "chromium-v145.0.7718.0", draft: false, assets: makeAssets(all) },
|
||||
]);
|
||||
expect(await getLatestChromiumVersion()).toBe("145.0.7718.0");
|
||||
});
|
||||
|
||||
it("returns null on network error", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
expect(await getLatestChromiumVersion()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapper update check", () => {
|
||||
beforeEach(() => {
|
||||
resetWrapperUpdateChecked();
|
||||
delete process.env.CLOAKBROWSER_AUTO_UPDATE;
|
||||
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.CLOAKBROWSER_AUTO_UPDATE;
|
||||
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
|
||||
});
|
||||
|
||||
it("warns when newer version available", async () => {
|
||||
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "99.0.0" }),
|
||||
} as Response);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Update available"));
|
||||
});
|
||||
|
||||
it("silent when current version", async () => {
|
||||
const { WRAPPER_VERSION } = await import("../src/config.js");
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: WRAPPER_VERSION }),
|
||||
} as Response);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disabled by CLOAKBROWSER_AUTO_UPDATE=false", async () => {
|
||||
process.env.CLOAKBROWSER_AUTO_UPDATE = "false";
|
||||
const spy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disabled by CLOAKBROWSER_DOWNLOAD_URL", async () => {
|
||||
process.env.CLOAKBROWSER_DOWNLOAD_URL = "https://mirror.example.com";
|
||||
const spy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("silent on network error", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs only once per process", async () => {
|
||||
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "0.0.1" }),
|
||||
} as Response);
|
||||
|
||||
await checkWrapperUpdate();
|
||||
await checkWrapperUpdate();
|
||||
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseChecksums", () => {
|
||||
// Valid 64-char hex strings for testing
|
||||
const HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
const HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
|
||||
|
||||
it("parses standard SHA256SUMS format", () => {
|
||||
const text = [
|
||||
`${HASH_A} cloakbrowser-linux-x64.tar.gz`,
|
||||
`${HASH_B} cloakbrowser-darwin-arm64.tar.gz`,
|
||||
].join("\n");
|
||||
const result = parseChecksums(text);
|
||||
expect(result.get("cloakbrowser-linux-x64.tar.gz")).toBe(HASH_A);
|
||||
expect(result.get("cloakbrowser-darwin-arm64.tar.gz")).toBe(HASH_B);
|
||||
});
|
||||
|
||||
it("handles binary-mode asterisk prefix", () => {
|
||||
const text = `${HASH_A} *cloakbrowser-linux-x64.tar.gz`;
|
||||
const result = parseChecksums(text);
|
||||
expect(result.has("cloakbrowser-linux-x64.tar.gz")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips empty lines", () => {
|
||||
const text = `\n\n${HASH_A} file.tar.gz\n\n`;
|
||||
expect(parseChecksums(text).size).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty map for empty input", () => {
|
||||
expect(parseChecksums("").size).toBe(0);
|
||||
expect(parseChecksums(" \n \n").size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = `/tmp/cloakbrowser-test-${Date.now()}`;
|
||||
try {
|
||||
expect(getEffectiveVersion()).toBe(getChromiumVersion());
|
||||
} finally {
|
||||
if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig;
|
||||
else delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureBinary", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLOAKBROWSER_BINARY_PATH;
|
||||
});
|
||||
|
||||
it("returns local override when set", async () => {
|
||||
// Use this test file as a "binary" that exists
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = __filename;
|
||||
const result = await ensureBinary();
|
||||
expect(result).toBe(__filename);
|
||||
});
|
||||
|
||||
it("throws when local override path missing", async () => {
|
||||
process.env.CLOAKBROWSER_BINARY_PATH = "/nonexistent/chrome";
|
||||
await expect(ensureBinary()).rejects.toThrow("does not exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCache", () => {
|
||||
it("does not throw when cache dir missing", () => {
|
||||
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = "/tmp/cloakbrowser-test-nonexistent";
|
||||
expect(() => clearCache()).not.toThrow();
|
||||
if (orig) {
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = orig;
|
||||
} else {
|
||||
delete process.env.CLOAKBROWSER_CACHE_DIR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkForUpdate", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when no newer version", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
} as Response);
|
||||
expect(await checkForUpdate()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on network error", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
expect(await checkForUpdate()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "node_modules", "tests", "examples"]
|
||||
}
|
||||
+24
-8
@@ -10,22 +10,29 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.9"
|
||||
authors = [
|
||||
{ name = "cloakbrowser" },
|
||||
{ name = "CloakHQ", email = "cloakhq@pm.me" },
|
||||
]
|
||||
keywords = [
|
||||
"stealth",
|
||||
"browser",
|
||||
"chromium",
|
||||
"playwright",
|
||||
"puppeteer",
|
||||
"scraping",
|
||||
"web-scraping",
|
||||
"anti-detect",
|
||||
"antidetect",
|
||||
"undetected",
|
||||
"bot-detection",
|
||||
"fingerprint",
|
||||
"recaptcha",
|
||||
"cloudflare",
|
||||
"turnstile",
|
||||
"bot-detection",
|
||||
"fingerprint",
|
||||
"web-scraping",
|
||||
"datadome",
|
||||
"captcha",
|
||||
"headless",
|
||||
"automation",
|
||||
"ai-agent",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -46,11 +53,20 @@ dependencies = [
|
||||
"httpx>=0.24",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
geoip = ["geoip2>=4.0", "socksio>=1.0"] # socksio: SOCKS5 transport for httpx
|
||||
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"
|
||||
Documentation = "https://github.com/CloakHQ/cloakbrowser#readme"
|
||||
Repository = "https://github.com/CloakHQ/cloakbrowser"
|
||||
Issues = "https://github.com/CloakHQ/cloakbrowser/issues"
|
||||
Homepage = "https://github.com/CloakHQ/CloakBrowser"
|
||||
Documentation = "https://github.com/CloakHQ/CloakBrowser#readme"
|
||||
Repository = "https://github.com/CloakHQ/CloakBrowser"
|
||||
Issues = "https://github.com/CloakHQ/CloakBrowser/issues"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "cloakbrowser/_version.py"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Shared test fixtures."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_backend_env(monkeypatch):
|
||||
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
|
||||
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Unit tests for backend resolution (_resolve_backend)."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.browser import _resolve_backend
|
||||
|
||||
|
||||
def test_resolve_backend_default():
|
||||
"""No param, no env var → 'playwright'."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert _resolve_backend(None) == "playwright"
|
||||
|
||||
|
||||
def test_resolve_backend_explicit_playwright():
|
||||
assert _resolve_backend("playwright") == "playwright"
|
||||
|
||||
|
||||
def test_resolve_backend_explicit_patchright():
|
||||
assert _resolve_backend("patchright") == "patchright"
|
||||
|
||||
|
||||
def test_resolve_backend_env_var():
|
||||
"""CLOAKBROWSER_BACKEND env var used when no param."""
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
|
||||
assert _resolve_backend(None) == "patchright"
|
||||
|
||||
|
||||
def test_resolve_backend_param_beats_env():
|
||||
"""Explicit param overrides env var."""
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
|
||||
assert _resolve_backend("playwright") == "playwright"
|
||||
|
||||
|
||||
def test_resolve_backend_invalid_raises():
|
||||
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
|
||||
_resolve_backend("bogus")
|
||||
|
||||
|
||||
def test_resolve_backend_invalid_env_raises():
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
|
||||
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
|
||||
_resolve_backend(None)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Unit tests for build_args timezone/locale injection and timezone alias."""
|
||||
|
||||
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")
|
||||
assert "--fingerprint-timezone=America/New_York" in args
|
||||
|
||||
|
||||
def test_locale_injected():
|
||||
"""--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")
|
||||
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")
|
||||
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/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")
|
||||
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
|
||||
|
||||
|
||||
# --- _resolve_timezone alias ---
|
||||
|
||||
|
||||
def test_resolve_timezone_id_alias():
|
||||
"""timezone_id in kwargs should be promoted to timezone."""
|
||||
kwargs = {"timezone_id": "Europe/Paris"}
|
||||
result = _resolve_timezone(None, kwargs)
|
||||
assert result == "Europe/Paris"
|
||||
assert "timezone_id" not in kwargs
|
||||
|
||||
|
||||
def test_resolve_timezone_wins_over_alias():
|
||||
"""Explicit timezone takes precedence; timezone_id is still popped."""
|
||||
kwargs = {"timezone_id": "Europe/Paris"}
|
||||
result = _resolve_timezone("UTC", kwargs)
|
||||
assert result == "UTC"
|
||||
assert "timezone_id" not in kwargs
|
||||
|
||||
|
||||
def test_resolve_no_alias():
|
||||
"""No-op when timezone_id is absent."""
|
||||
kwargs = {"other": "value"}
|
||||
result = _resolve_timezone("UTC", kwargs)
|
||||
assert result == "UTC"
|
||||
assert "other" in kwargs
|
||||
|
||||
|
||||
def test_resolve_both_none():
|
||||
"""Neither param set — returns None."""
|
||||
kwargs = {}
|
||||
result = _resolve_timezone(None, kwargs)
|
||||
assert result is None
|
||||
|
||||
|
||||
# --- Deduplication tests ---
|
||||
|
||||
|
||||
def test_user_fingerprint_overrides_default():
|
||||
"""User --fingerprint should override the random default seed."""
|
||||
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"
|
||||
|
||||
|
||||
def test_user_platform_overrides_default():
|
||||
"""User --fingerprint-platform should override the default."""
|
||||
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"
|
||||
|
||||
|
||||
def test_timezone_param_overrides_user_arg():
|
||||
"""Dedicated timezone param should override user arg."""
|
||||
args = build_args(
|
||||
stealth_args=True,
|
||||
extra_args=["--fingerprint-timezone=Europe/London"],
|
||||
timezone="America/New_York",
|
||||
)
|
||||
tz_args = [a for a in args if a.startswith("--fingerprint-timezone=")]
|
||||
assert len(tz_args) == 1
|
||||
assert tz_args[0] == "--fingerprint-timezone=America/New_York"
|
||||
|
||||
|
||||
def test_locale_param_overrides_user_arg():
|
||||
"""Dedicated locale param should override user --lang and --fingerprint-locale args."""
|
||||
args = build_args(
|
||||
stealth_args=True,
|
||||
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(
|
||||
stealth_args=True,
|
||||
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
|
||||
timezone="Europe/Berlin",
|
||||
locale="de-DE",
|
||||
)
|
||||
keys = [a.split("=", 1)[0] for a in args]
|
||||
assert len(keys) == len(set(keys)), f"Duplicate keys found: {keys}"
|
||||
|
||||
|
||||
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"])
|
||||
assert "--disable-gpu" in args
|
||||
assert "--no-zygote" in args
|
||||
assert "--no-sandbox" in args
|
||||
|
||||
|
||||
def test_override_logs_debug(caplog):
|
||||
"""Should log debug message when an override happens."""
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
|
||||
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)
|
||||
|
||||
|
||||
# --- WebRTC IP spoofing ---
|
||||
|
||||
|
||||
def test_webrtc_ip_passed_through_args():
|
||||
"""--fingerprint-webrtc-ip in args should pass through to output."""
|
||||
args = build_args(stealth_args=True, extra_args=["--fingerprint-webrtc-ip=1.2.3.4"])
|
||||
assert "--fingerprint-webrtc-ip=1.2.3.4" in args
|
||||
|
||||
|
||||
def test_webrtc_ip_not_present_by_default():
|
||||
"""No --fingerprint-webrtc-ip when not in args."""
|
||||
args = build_args(stealth_args=True, extra_args=None)
|
||||
assert not any(a.startswith("--fingerprint-webrtc-ip") for a in args)
|
||||
|
||||
|
||||
def test_resolve_webrtc_args_auto():
|
||||
"""--fingerprint-webrtc-ip=auto should be resolved to an IP."""
|
||||
from cloakbrowser.browser import _resolve_webrtc_args
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="5.6.7.8"):
|
||||
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=auto"], "http://proxy:8080")
|
||||
assert result == ["--fingerprint-webrtc-ip=5.6.7.8"]
|
||||
|
||||
|
||||
def test_resolve_webrtc_args_explicit_ip_unchanged():
|
||||
"""Explicit IP in args should not be touched."""
|
||||
from cloakbrowser.browser import _resolve_webrtc_args
|
||||
|
||||
result = _resolve_webrtc_args(["--fingerprint-webrtc-ip=9.9.9.9"], "http://proxy:8080")
|
||||
assert result == ["--fingerprint-webrtc-ip=9.9.9.9"]
|
||||
|
||||
|
||||
def test_resolve_webrtc_args_no_flag():
|
||||
"""No webrtc flag in args should return args unchanged."""
|
||||
from cloakbrowser.browser import _resolve_webrtc_args
|
||||
|
||||
result = _resolve_webrtc_args(["--no-sandbox"], "http://proxy:8080")
|
||||
assert result == ["--no-sandbox"]
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Unit tests for cloakserve — parse_connection_params, parse_cli_args, URL rewriting, connection tracking."""
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
aiohttp = pytest.importorskip("aiohttp", reason="cloakserve requires aiohttp (install with .[serve])")
|
||||
|
||||
# Load cloakserve as a module from bin/ (no .py extension).
|
||||
_bin_path = str(Path(__file__).resolve().parents[1] / "bin" / "cloakserve")
|
||||
_loader = importlib.machinery.SourceFileLoader("cloakserve", _bin_path)
|
||||
_spec = importlib.util.spec_from_file_location("cloakserve", _bin_path, loader=_loader)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["cloakserve"] = _mod
|
||||
_loader.exec_module(_mod)
|
||||
|
||||
parse_connection_params = _mod.parse_connection_params
|
||||
parse_cli_args = _mod.parse_cli_args
|
||||
ChromePool = _mod.ChromePool
|
||||
_default_data_dir = _mod._default_data_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_connection_params
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseConnectionParams:
|
||||
def test_empty_query(self):
|
||||
result = parse_connection_params("")
|
||||
assert result["seed"] is None
|
||||
assert result["extra_args"] == []
|
||||
|
||||
def test_fingerprint_seed(self):
|
||||
result = parse_connection_params("fingerprint=12345")
|
||||
assert result["seed"] == "12345"
|
||||
|
||||
def test_timezone_and_locale(self):
|
||||
result = parse_connection_params("fingerprint=1&timezone=Asia/Tokyo&locale=ja-JP")
|
||||
assert result["timezone"] == "Asia/Tokyo"
|
||||
assert result["locale"] == "ja-JP"
|
||||
|
||||
def test_proxy(self):
|
||||
result = parse_connection_params("proxy=http://proxy:8080")
|
||||
assert result["proxy"] == "http://proxy:8080"
|
||||
|
||||
def test_geoip_true_variants(self):
|
||||
for val in ("true", "1", "yes", "True", "YES"):
|
||||
result = parse_connection_params(f"geoip={val}")
|
||||
assert result["geoip"] is True, f"geoip={val} should be True"
|
||||
|
||||
def test_geoip_false(self):
|
||||
for val in ("false", "0", "no", "anything"):
|
||||
result = parse_connection_params(f"geoip={val}")
|
||||
assert result["geoip"] is False, f"geoip={val} should be False"
|
||||
|
||||
def test_generic_fingerprint_params(self):
|
||||
qs = "fingerprint=1&platform=windows&hardware-concurrency=8&gpu-vendor=NVIDIA"
|
||||
result = parse_connection_params(qs)
|
||||
assert "--fingerprint-platform=windows" in result["extra_args"]
|
||||
assert "--fingerprint-hardware-concurrency=8" in result["extra_args"]
|
||||
assert "--fingerprint-gpu-vendor=NVIDIA" in result["extra_args"]
|
||||
|
||||
def test_special_params_not_in_extra_args(self):
|
||||
qs = "fingerprint=1&timezone=UTC&locale=en-US&proxy=http://x:1&geoip=true"
|
||||
result = parse_connection_params(qs)
|
||||
assert result["extra_args"] == []
|
||||
|
||||
def test_multiple_values_takes_first(self):
|
||||
result = parse_connection_params("fingerprint=111&fingerprint=222")
|
||||
assert result["seed"] == "111"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_cli_args
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseCliArgs:
|
||||
def test_defaults(self):
|
||||
config, passthrough = parse_cli_args([])
|
||||
assert config["port"] == 9222
|
||||
assert config["headless"] is True
|
||||
assert config["data_dir"] is not None
|
||||
assert passthrough == []
|
||||
|
||||
def test_custom_port(self):
|
||||
config, _ = parse_cli_args(["--port=8080"])
|
||||
assert config["port"] == 8080
|
||||
|
||||
def test_headless_false(self):
|
||||
config, passthrough = parse_cli_args(["--headless=false"])
|
||||
assert config["headless"] is False
|
||||
# headless flag still passed through to Chrome
|
||||
assert "--headless=false" in passthrough
|
||||
|
||||
def test_strips_remote_debugging_flags(self):
|
||||
args = ["--remote-debugging-port=9999", "--remote-debugging-address=0.0.0.0", "--no-sandbox"]
|
||||
config, passthrough = parse_cli_args(args)
|
||||
assert passthrough == ["--no-sandbox"]
|
||||
|
||||
def test_passthrough_args(self):
|
||||
args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"]
|
||||
config, passthrough = parse_cli_args(args)
|
||||
# --fingerprint=999 is consumed into config["default_seed"], not passed through
|
||||
assert passthrough == ["--no-sandbox", "--disable-gpu"]
|
||||
assert config["default_seed"] == "999"
|
||||
|
||||
def test_port_not_in_passthrough(self):
|
||||
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
|
||||
assert "--port=9222" not in passthrough
|
||||
assert "--no-sandbox" in passthrough
|
||||
|
||||
def test_custom_data_dir(self):
|
||||
config, passthrough = parse_cli_args(["--data-dir=/custom/path", "--no-sandbox"])
|
||||
assert config["data_dir"] == "/custom/path"
|
||||
assert "--data-dir=/custom/path" not in passthrough
|
||||
|
||||
def test_data_dir_not_in_passthrough(self):
|
||||
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
|
||||
assert not any(a.startswith("--data-dir=") for a in passthrough)
|
||||
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_default_data_dir_docker(self, _mock):
|
||||
assert _default_data_dir() == "/tmp/cloakserve"
|
||||
|
||||
@patch("os.path.exists", return_value=False)
|
||||
def test_default_data_dir_bare_metal(self, _mock):
|
||||
result = _default_data_dir()
|
||||
assert result.endswith(".cloakbrowser/cloakserve")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL rewriting logic (pure string manipulation, extracted from handlers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestURLRewriting:
|
||||
"""Test the URL rewriting logic used by /json/version and /json/list."""
|
||||
|
||||
def _rewrite_version(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
|
||||
"""Replicate the URL rewrite logic from handle_json_version."""
|
||||
if seed:
|
||||
ws_path = f"fingerprint/{seed}/devtools/browser"
|
||||
else:
|
||||
ws_path = "devtools/browser"
|
||||
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
|
||||
return f"{scheme}://{host}/{ws_path}/{guid}"
|
||||
|
||||
def _rewrite_list_entry(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
|
||||
"""Replicate the URL rewrite logic from handle_json_list."""
|
||||
ws_tail = orig_ws.split("/devtools/")[-1]
|
||||
if seed:
|
||||
return f"{scheme}://{host}/fingerprint/{seed}/devtools/{ws_tail}"
|
||||
else:
|
||||
return f"{scheme}://{host}/devtools/{ws_tail}"
|
||||
|
||||
def test_version_rewrite_with_seed(self):
|
||||
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
|
||||
result = self._rewrite_version(orig, "container:9222", "12345")
|
||||
assert result == "ws://container:9222/fingerprint/12345/devtools/browser/abc-123"
|
||||
|
||||
def test_version_rewrite_no_seed(self):
|
||||
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
|
||||
result = self._rewrite_version(orig, "container:9222", None)
|
||||
assert result == "ws://container:9222/devtools/browser/abc-123"
|
||||
|
||||
def test_list_rewrite_page_with_seed(self):
|
||||
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
|
||||
result = self._rewrite_list_entry(orig, "host:9222", "99")
|
||||
assert result == "ws://host:9222/fingerprint/99/devtools/page/DEF-456"
|
||||
|
||||
def test_list_rewrite_page_no_seed(self):
|
||||
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
|
||||
result = self._rewrite_list_entry(orig, "host:9222", None)
|
||||
assert result == "ws://host:9222/devtools/page/DEF-456"
|
||||
|
||||
def test_list_rewrite_browser(self):
|
||||
orig = "ws://127.0.0.1:5100/devtools/browser/XYZ"
|
||||
result = self._rewrite_list_entry(orig, "host:9222", "seed1")
|
||||
assert result == "ws://host:9222/fingerprint/seed1/devtools/browser/XYZ"
|
||||
|
||||
def test_wss_scheme_version(self):
|
||||
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
|
||||
result = self._rewrite_version(orig, "host:443", "seed1", scheme="wss")
|
||||
assert result == "wss://host:443/fingerprint/seed1/devtools/browser/abc-123"
|
||||
|
||||
def test_wss_scheme_list(self):
|
||||
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
|
||||
result = self._rewrite_list_entry(orig, "host:443", "seed1", scheme="wss")
|
||||
assert result == "wss://host:443/fingerprint/seed1/devtools/page/DEF-456"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection refcounting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConnectionTracking:
|
||||
"""Test ChromePool.connect() / disconnect() without real Chrome."""
|
||||
|
||||
def _make_pool(self):
|
||||
return ChromePool(
|
||||
binary="/fake/chrome",
|
||||
global_args=[],
|
||||
headless=True,
|
||||
data_dir="/tmp/test-cloakserve",
|
||||
)
|
||||
|
||||
def test_connect_increments(self):
|
||||
pool = self._make_pool()
|
||||
pool.connect("seed1")
|
||||
assert pool._connections["seed1"] == 1
|
||||
pool.connect("seed1")
|
||||
assert pool._connections["seed1"] == 2
|
||||
|
||||
def test_disconnect_decrements(self):
|
||||
pool = self._make_pool()
|
||||
pool.connect("seed1")
|
||||
pool.connect("seed1")
|
||||
pool.disconnect("seed1")
|
||||
assert pool._connections["seed1"] == 1
|
||||
|
||||
def test_disconnect_to_zero_removes_key(self):
|
||||
pool = self._make_pool()
|
||||
pool.connect("seed1")
|
||||
pool.disconnect("seed1")
|
||||
assert "seed1" not in pool._connections
|
||||
|
||||
def test_disconnect_below_zero_safe(self):
|
||||
pool = self._make_pool()
|
||||
pool.disconnect("nonexistent")
|
||||
assert "nonexistent" not in pool._connections
|
||||
|
||||
def test_multiple_seeds_independent(self):
|
||||
pool = self._make_pool()
|
||||
pool.connect("a")
|
||||
pool.connect("b")
|
||||
pool.connect("a")
|
||||
pool.disconnect("a")
|
||||
assert pool._connections["a"] == 1
|
||||
assert pool._connections["b"] == 1
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Unit tests for config.py — platform detection, paths, stealth args."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.config import (
|
||||
get_archive_ext,
|
||||
get_archive_name,
|
||||
get_binary_path,
|
||||
get_cache_dir,
|
||||
get_chromium_version,
|
||||
get_default_stealth_args,
|
||||
get_fallback_download_url,
|
||||
get_platform_tag,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform-specific binary paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetBinaryPath:
|
||||
def test_linux(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
|
||||
path = get_binary_path("145.0.0.0")
|
||||
assert str(path).endswith("chromium-145.0.0.0/chrome")
|
||||
|
||||
def test_darwin(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
|
||||
path = get_binary_path("145.0.0.0")
|
||||
assert str(path).endswith("chromium-145.0.0.0/Chromium.app/Contents/MacOS/Chromium")
|
||||
|
||||
def test_windows(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Windows"):
|
||||
path = get_binary_path("145.0.0.0")
|
||||
assert str(path).endswith("chromium-145.0.0.0/chrome.exe")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Archive extension and name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArchive:
|
||||
def test_ext_windows(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Windows"):
|
||||
assert get_archive_ext() == ".zip"
|
||||
|
||||
def test_ext_unix(self):
|
||||
for system in ("Linux", "Darwin"):
|
||||
with patch("cloakbrowser.config.platform.system", return_value=system):
|
||||
assert get_archive_ext() == ".tar.gz"
|
||||
|
||||
def test_archive_name(self):
|
||||
tag = get_platform_tag()
|
||||
ext = get_archive_ext()
|
||||
assert get_archive_name() == f"cloakbrowser-{tag}{ext}"
|
||||
|
||||
def test_archive_name_custom_tag(self):
|
||||
name = get_archive_name("linux-x64")
|
||||
assert "cloakbrowser-linux-x64" in name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download URLs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFallbackUrl:
|
||||
def test_github_releases_format(self):
|
||||
url = get_fallback_download_url("145.0.0.0")
|
||||
assert "github.com/CloakHQ/cloakbrowser/releases/download" in url
|
||||
assert "chromium-v145.0.0.0" in url
|
||||
|
||||
def test_default_version(self):
|
||||
url = get_fallback_download_url()
|
||||
version = get_chromium_version()
|
||||
assert f"chromium-v{version}" in url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCacheDir:
|
||||
def test_default_path(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
# Remove override if set
|
||||
env = os.environ.copy()
|
||||
env.pop("CLOAKBROWSER_CACHE_DIR", None)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
path = get_cache_dir()
|
||||
assert str(path).endswith(".cloakbrowser")
|
||||
|
||||
def test_env_override(self, tmp_path):
|
||||
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
|
||||
assert get_cache_dir() == tmp_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform tag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlatformTag:
|
||||
def test_unsupported_raises(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="FreeBSD"):
|
||||
with patch("cloakbrowser.config.platform.machine", return_value="x86_64"):
|
||||
with pytest.raises(RuntimeError, match="Unsupported platform"):
|
||||
get_platform_tag()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stealth args
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStealthArgs:
|
||||
def test_seed_uniqueness(self):
|
||||
"""Two calls should produce different fingerprint seeds."""
|
||||
args1 = get_default_stealth_args()
|
||||
args2 = get_default_stealth_args()
|
||||
seed1 = [a for a in args1 if a.startswith("--fingerprint=")][0]
|
||||
seed2 = [a for a in args2 if a.startswith("--fingerprint=")][0]
|
||||
# Seeds are random 10000-99999 — extremely unlikely to collide
|
||||
assert seed1 != seed2
|
||||
|
||||
def test_macos_profile(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Darwin"):
|
||||
args = get_default_stealth_args()
|
||||
assert "--fingerprint-platform=macos" in args
|
||||
# GPU flags removed — binary auto-generates from seed + platform
|
||||
assert not any("fingerprint-gpu-vendor" in a for a in args)
|
||||
assert not any("fingerprint-gpu-renderer" in a for a in args)
|
||||
|
||||
def test_linux_windows_profile(self):
|
||||
with patch("cloakbrowser.config.platform.system", return_value="Linux"):
|
||||
args = get_default_stealth_args()
|
||||
assert "--fingerprint-platform=windows" in args
|
||||
# GPU flags removed — binary auto-generates from seed + platform
|
||||
assert not any("fingerprint-gpu-vendor" in a for a in args)
|
||||
assert not any("fingerprint-gpu-renderer" in a for a in args)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Unit tests for archive extraction — path traversal protection, flattening, permissions."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.download import (
|
||||
_extract_tar,
|
||||
_extract_zip,
|
||||
_flatten_single_subdir,
|
||||
_is_executable,
|
||||
_make_executable,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tar.gz extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_tar_gz(tmp_path, members: dict[str, bytes]) -> "Path":
|
||||
"""Create a tar.gz with given {name: content} members."""
|
||||
archive = tmp_path / "test.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
for name, content in members.items():
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(content)
|
||||
tar.addfile(info, io.BytesIO(content))
|
||||
return archive
|
||||
|
||||
|
||||
class TestExtractTar:
|
||||
def test_basic(self, tmp_path):
|
||||
archive = _create_tar_gz(tmp_path, {"chrome": b"binary", "lib/libfoo.so": b"lib"})
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
_extract_tar(archive, dest)
|
||||
assert (dest / "chrome").read_bytes() == b"binary"
|
||||
assert (dest / "lib" / "libfoo.so").read_bytes() == b"lib"
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path):
|
||||
archive = tmp_path / "evil.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
info = tarfile.TarInfo(name="../../../etc/passwd")
|
||||
info.size = 4
|
||||
tar.addfile(info, io.BytesIO(b"evil"))
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
with pytest.raises(RuntimeError, match="path traversal"):
|
||||
_extract_tar(archive, dest)
|
||||
|
||||
def test_suspicious_symlink_skipped(self, tmp_path):
|
||||
"""Symlinks with absolute targets are skipped (logged as warning)."""
|
||||
archive = tmp_path / "symlink.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
# Normal file
|
||||
info = tarfile.TarInfo(name="chrome")
|
||||
info.size = 6
|
||||
tar.addfile(info, io.BytesIO(b"binary"))
|
||||
# Suspicious symlink
|
||||
sym = tarfile.TarInfo(name="evil_link")
|
||||
sym.type = tarfile.SYMTYPE
|
||||
sym.linkname = "/etc/passwd"
|
||||
tar.addfile(sym)
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
_extract_tar(archive, dest)
|
||||
# Normal file extracted
|
||||
assert (dest / "chrome").exists()
|
||||
# Suspicious symlink was skipped
|
||||
assert not (dest / "evil_link").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# zip extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_zip(tmp_path, members: dict[str, bytes]) -> "Path":
|
||||
"""Create a zip with given {name: content} members."""
|
||||
archive = tmp_path / "test.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
for name, content in members.items():
|
||||
zf.writestr(name, content)
|
||||
return archive
|
||||
|
||||
|
||||
class TestExtractZip:
|
||||
def test_basic(self, tmp_path):
|
||||
archive = _create_zip(tmp_path, {"chrome.exe": b"binary", "lib/foo.dll": b"lib"})
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
_extract_zip(archive, dest)
|
||||
assert (dest / "chrome.exe").read_bytes() == b"binary"
|
||||
assert (dest / "lib" / "foo.dll").read_bytes() == b"lib"
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path):
|
||||
archive = tmp_path / "evil.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("../../../etc/passwd", "evil")
|
||||
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
with pytest.raises(RuntimeError, match="path traversal"):
|
||||
_extract_zip(archive, dest)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory flattening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFlatten:
|
||||
def test_single_subdir_flattened(self, tmp_path):
|
||||
"""Single subdir contents moved up."""
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
subdir = dest / "fingerprint-chromium-custom-v14"
|
||||
subdir.mkdir()
|
||||
(subdir / "chrome").write_bytes(b"binary")
|
||||
(subdir / "lib").mkdir()
|
||||
|
||||
_flatten_single_subdir(dest)
|
||||
|
||||
assert (dest / "chrome").read_bytes() == b"binary"
|
||||
assert (dest / "lib").is_dir()
|
||||
assert not subdir.exists()
|
||||
|
||||
def test_app_bundle_preserved(self, tmp_path):
|
||||
""".app directory NOT flattened (macOS bundle)."""
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
app = dest / "Chromium.app"
|
||||
app.mkdir()
|
||||
(app / "Contents").mkdir()
|
||||
(app / "Contents" / "MacOS").mkdir()
|
||||
(app / "Contents" / "MacOS" / "Chromium").write_bytes(b"binary")
|
||||
|
||||
_flatten_single_subdir(dest)
|
||||
|
||||
# .app bundle kept intact
|
||||
assert app.is_dir()
|
||||
assert (app / "Contents" / "MacOS" / "Chromium").exists()
|
||||
|
||||
def test_noop_multiple_entries(self, tmp_path):
|
||||
"""Multiple entries at top level — no flattening."""
|
||||
dest = tmp_path / "out"
|
||||
dest.mkdir()
|
||||
(dest / "chrome").write_bytes(b"binary")
|
||||
(dest / "lib").mkdir()
|
||||
|
||||
_flatten_single_subdir(dest)
|
||||
|
||||
# Nothing moved
|
||||
assert (dest / "chrome").exists()
|
||||
assert (dest / "lib").is_dir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPermissions:
|
||||
@pytest.mark.skipif(platform.system() == "Windows", reason="chmod not applicable on Windows")
|
||||
def test_make_executable(self, tmp_path):
|
||||
binary = tmp_path / "chrome"
|
||||
binary.write_bytes(b"binary")
|
||||
binary.chmod(0o644)
|
||||
assert not _is_executable(binary)
|
||||
|
||||
_make_executable(binary)
|
||||
assert _is_executable(binary)
|
||||
|
||||
def test_is_executable_true(self, tmp_path):
|
||||
binary = tmp_path / "chrome"
|
||||
binary.write_bytes(b"binary")
|
||||
binary.chmod(0o755)
|
||||
assert _is_executable(binary)
|
||||
|
||||
def test_is_executable_false(self, tmp_path):
|
||||
binary = tmp_path / "chrome"
|
||||
binary.write_bytes(b"binary")
|
||||
binary.chmod(0o644)
|
||||
assert not _is_executable(binary)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Unit tests for GeoIP-based timezone/locale detection."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cloakbrowser.browser import maybe_resolve_geoip
|
||||
from cloakbrowser.geoip import (
|
||||
COUNTRY_LOCALE_MAP,
|
||||
_is_private_ip,
|
||||
_resolve_proxy_ip,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_proxy_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_literal_ipv4():
|
||||
assert _resolve_proxy_ip("http://10.50.96.5:8888") == "10.50.96.5"
|
||||
|
||||
|
||||
def test_resolve_literal_ipv4_with_auth():
|
||||
assert _resolve_proxy_ip("http://user:pass@10.50.96.5:8888") == "10.50.96.5"
|
||||
|
||||
|
||||
def test_resolve_literal_ipv6():
|
||||
ip = _resolve_proxy_ip("http://[::1]:8888")
|
||||
assert ip == "::1"
|
||||
|
||||
|
||||
def test_resolve_hostname():
|
||||
"""DNS resolution of a known hostname should return an IP."""
|
||||
ip = _resolve_proxy_ip("http://localhost:8888")
|
||||
assert ip is not None
|
||||
assert ip in ("127.0.0.1", "::1")
|
||||
|
||||
|
||||
def test_resolve_invalid_url():
|
||||
assert _resolve_proxy_ip("not-a-url") is None
|
||||
|
||||
|
||||
def test_resolve_empty():
|
||||
assert _resolve_proxy_ip("") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COUNTRY_LOCALE_MAP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_locale_map_has_common_countries():
|
||||
for code in ("US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"):
|
||||
assert code in COUNTRY_LOCALE_MAP, f"Missing {code}"
|
||||
|
||||
|
||||
def test_locale_map_values_are_bcp47():
|
||||
"""All locales should be language-REGION format."""
|
||||
for code, locale in COUNTRY_LOCALE_MAP.items():
|
||||
parts = locale.split("-")
|
||||
assert len(parts) == 2, f"{code}: {locale} not language-REGION"
|
||||
assert parts[0].islower(), f"{code}: language part should be lowercase"
|
||||
assert parts[1].isupper(), f"{code}: region part should be uppercase"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_proxy_geo fallbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_geo_raises_when_geoip2_missing():
|
||||
"""Should raise ImportError with install instructions when geoip2 not installed."""
|
||||
with patch.dict("sys.modules", {"geoip2": None, "geoip2.database": None}):
|
||||
from importlib import reload
|
||||
import cloakbrowser.geoip as geoip_mod
|
||||
reload(geoip_mod)
|
||||
with pytest.raises(ImportError, match="pip install cloakbrowser"):
|
||||
geoip_mod.resolve_proxy_geo("http://10.50.96.5:8888")
|
||||
# Restore
|
||||
reload(geoip_mod)
|
||||
|
||||
|
||||
def test_resolve_geo_returns_none_when_db_missing():
|
||||
"""Should return (None, None) when DB file doesn't exist."""
|
||||
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": None})})()
|
||||
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
|
||||
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=None):
|
||||
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value=None):
|
||||
from cloakbrowser.geoip import resolve_proxy_geo
|
||||
assert resolve_proxy_geo("http://10.50.96.5:8888") == (None, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# maybe_resolve_geoip (browser.py helper)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_maybe_resolve_skips_when_geoip_false():
|
||||
tz, loc, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
|
||||
assert tz is None
|
||||
assert loc is None
|
||||
assert ip is None
|
||||
|
||||
|
||||
def test_maybe_resolve_skips_when_no_proxy():
|
||||
tz, loc, ip = maybe_resolve_geoip(True, None, None, None)
|
||||
assert tz is None
|
||||
assert loc is None
|
||||
assert ip is None
|
||||
|
||||
|
||||
def test_maybe_resolve_skips_when_both_explicit():
|
||||
"""Explicit values should still resolve exit IP for WebRTC."""
|
||||
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="1.2.3.4"):
|
||||
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
|
||||
assert tz == "Europe/Berlin"
|
||||
assert loc == "de-DE"
|
||||
assert ip == "1.2.3.4"
|
||||
|
||||
|
||||
def test_maybe_resolve_fills_missing_timezone():
|
||||
"""When only locale is explicit, geoip should fill timezone."""
|
||||
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
|
||||
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
|
||||
assert tz == "America/New_York"
|
||||
assert loc == "fr-FR" # Explicit wins
|
||||
|
||||
|
||||
def test_maybe_resolve_fills_missing_locale():
|
||||
"""When only timezone is explicit, geoip should fill locale."""
|
||||
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")):
|
||||
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
|
||||
assert tz == "Asia/Tokyo" # Explicit wins
|
||||
assert loc == "en-US"
|
||||
|
||||
|
||||
def test_maybe_resolve_fills_both():
|
||||
"""When neither is set, geoip should fill both."""
|
||||
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")):
|
||||
tz, loc, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
|
||||
assert tz == "Europe/Berlin"
|
||||
assert loc == "de-DE"
|
||||
assert ip == "5.6.7.8"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_private_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_private_ip_loopback():
|
||||
assert _is_private_ip("127.0.0.1") is True
|
||||
|
||||
|
||||
def test_private_ip_rfc1918():
|
||||
assert _is_private_ip("192.168.1.1") is True
|
||||
assert _is_private_ip("10.0.0.1") is True
|
||||
assert _is_private_ip("172.16.0.1") is True
|
||||
|
||||
|
||||
def test_private_ip_public():
|
||||
assert _is_private_ip("8.8.8.8") is False
|
||||
assert _is_private_ip("64.176.168.43") is False
|
||||
@@ -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,363 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
# ============================================================
|
||||
# SCENARIO 7: ElementHandle — query_selector interactions
|
||||
# ============================================================
|
||||
step("ElementHandle — query_selector click, type, fill, hover")
|
||||
page.goto('https://www.wikipedia.org', wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
inject(page)
|
||||
time.sleep(1)
|
||||
|
||||
print(" Watch: get element via query_selector, cursor moves smoothly")
|
||||
el = page.query_selector('#searchInput')
|
||||
assert el is not None, "query_selector returned None"
|
||||
assert getattr(el, '_human_patched', False), "ElementHandle not patched!"
|
||||
|
||||
t0 = time.time()
|
||||
el.click()
|
||||
eh_click_ms = int((time.time() - t0) * 1000)
|
||||
check("ElementHandle click", eh_click_ms > 100, f"{eh_click_ms} ms")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: ElementHandle type — characters appear one by one")
|
||||
t0 = time.time()
|
||||
el.type('ElementHandle typing')
|
||||
eh_type_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
check("ElementHandle type", val == 'ElementHandle typing' and eh_type_ms > 1500, f"{eh_type_ms} ms, value='{val}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: ElementHandle fill — clears then types")
|
||||
t0 = time.time()
|
||||
el.fill('Filled via EH')
|
||||
eh_fill_ms = int((time.time() - t0) * 1000)
|
||||
val = page.locator('#searchInput').input_value()
|
||||
check("ElementHandle fill", val == 'Filled via EH' and eh_fill_ms > 1000, f"{eh_fill_ms} ms, value='{val}'")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: ElementHandle hover — cursor moves without clicking")
|
||||
btn_el = page.query_selector('button[type="submit"]')
|
||||
t0 = time.time()
|
||||
btn_el.hover()
|
||||
eh_hover_ms = int((time.time() - t0) * 1000)
|
||||
check("ElementHandle hover", eh_hover_ms > 50, f"{eh_hover_ms} ms")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(" Watch: query_selector_all returns patched handles")
|
||||
page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded')
|
||||
time.sleep(2)
|
||||
inject(page)
|
||||
time.sleep(1)
|
||||
els = page.query_selector_all('input[type="checkbox"]')
|
||||
all_patched = all(getattr(e, '_human_patched', False) for e in els)
|
||||
check("query_selector_all all patched", all_patched and len(els) >= 2, f"{len(els)} elements, all_patched={all_patched}")
|
||||
|
||||
if els:
|
||||
print(" Watch: click checkbox via ElementHandle")
|
||||
t0 = time.time()
|
||||
els[0].click()
|
||||
cb_click_ms = int((time.time() - t0) * 1000)
|
||||
check("ElementHandle checkbox click", cb_click_ms > 100, f"{cb_click_ms} ms")
|
||||
time.sleep(1)
|
||||
|
||||
# ============================================================
|
||||
# SUMMARY
|
||||
# ============================================================
|
||||
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);
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user