mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-10-18 23:07:13 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf005bc19 | ||
|
|
e156f89564 | ||
|
|
1966ff861d | ||
|
|
04c3c027c2 | ||
|
|
2571290992 | ||
|
|
160f56da85 | ||
|
|
214a2beb69 | ||
|
|
485f678ac0 | ||
|
|
89fb671af8 | ||
|
|
4a1b8f9c89 | ||
|
|
c3e0f9131b | ||
|
|
9a934873ad | ||
|
|
bd89505888 |
No files matched your search
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install and build
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install and build
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
run: |
|
||||
pip install -e ".[dev]" pytest pytest-asyncio
|
||||
pytest tests/ -v -m "not slow"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: JavaScript tests
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.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'
|
||||
|
||||
+15
-1
@@ -6,7 +6,21 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
## [0.6.3] — 2026-10-14
|
||||
|
||||
- **[wrapper]** `geoip=True` no longer fails the launch when the exit IP's country has no locale in the map. It logs a warning and uses `en-US`. Python, JavaScript, and .NET (#591)
|
||||
- **[wrapper]** GeoIP maps Seychelles (`SC`) to the `en-SC` locale. Python, JavaScript, and .NET (#596)
|
||||
- **[wrapper]** Async launches no longer freeze the caller's event loop. `launch_async()`, `launch_context_async()` and `launch_persistent_context_async()` ran the binary/license check and the GeoIP and WebRTC exit-IP lookups directly on the event loop, so every other coroutine (a server's health endpoint, WebSockets, other browsers) stalled for up to 20s per launch behind a slow proxy. They now run in a worker thread. .NET: the license check and Pro version lookup inside async launches no longer hold the caller's thread (e.g. a UI thread). Python and .NET; JavaScript was not affected.
|
||||
- **[wrapper]** Fix humanized `select_option()` raising `element is not attached to the DOM` when the pick navigates the page (e.g. a dropdown that submits its form on change). The option was selected and the page navigated, but the check after the pick ran on the new page. It now returns the chosen values, as Playwright does. A pick that lands on the wrong option without navigating still raises. Python, JavaScript Playwright, and .NET (#597)
|
||||
- **[wrapper]** Humanized clicks, hovers and taps now reach a `position: fixed` element (e.g. a modal) whose DOM parent is a scrolling panel, and a `position: absolute` element that escapes a static scroller. They timed out with `element is outside of the viewport` because the scroll container was looked up through DOM parents instead of the element's containing blocks. Elements that the scroller really clips are still scrolled into view as before. Python, JavaScript, and .NET (#593, #595)
|
||||
- **[wrapper]** Humanized typing now matches measured human keystroke timing (Aalto 136M Keystrokes dataset): keys are held about 100 ms (was 15-35 ms); the next key often goes down before the previous one is released (rollover, about 20% of pairs); Shift is often let go before the letter key. The gap between keys depends on the keys: after a space, the same hand, the other hand, or a repeated letter. The default typing speed is about 150 ms between keys (was 70 ms). The typo rate is 4% (was 2%; 5% on `careful`). Overall typing speed stays about the same: the gaps are longer, but keys overlap. A key is never pressed again while it is still held (including the same letter in lower then upper case, as in `xX`), and every held key is released if the action fails. In .NET, a `\r\n` line break in typed text is still inserted as text, as before. `typing_delay`, `key_hold`, `shift_down_delay` and `shift_up_delay` keep their meaning. Python, JavaScript Playwright and Puppeteer, and .NET (#595)
|
||||
- **[wrapper]** Humanized mouse moves now follow Fitts' law: a move takes longer the farther it goes, so short moves are a little slower and long moves faster than before (new `mouse_time_base`, `mouse_time_per_bit`, `mouse_time_spread`), with human run-to-run variation. Speed peaks in the first third of the move, as in recorded human strokes, not in the middle. Events are spaced at display frame rate. A move never reports zero movement, and an occasional overshoot past the target is corrected with a short second stroke. Python, JavaScript Playwright and Puppeteer, and .NET (#595)
|
||||
- **[wrapper]** Humanized scrolling now sends whole mouse-wheel notches for the persona's platform (100 px on Windows, 40 px on macOS, 120 px on Linux), in short flicks, so a page's `deltaY` agrees with `wheelDeltaY` the way a real wheel's does. Previously it sent 20-40 px chunks with a one-notch `wheelDeltaY`, which no real wheel produces. Override the notch with the new `scroll_notch_px` setting (0 = auto). The standalone .NET `HumanScroll` helper also wheels in whole notches. **Behaviour change** (JavaScript Puppeteer): `page.mouse.wheel({ deltaY })` now scrolls a whole number of notches, at least one, so `wheel({ deltaY: 10 })` scrolls a full notch (100 px on a Windows persona). Python, JavaScript Playwright and Puppeteer, and .NET (#595)
|
||||
- **[wrapper]** **Removed** the humanize settings `mouse_steps_divisor`, `mouse_burst_size`, `mouse_burst_pause`, `scroll_delta_base` and `scroll_delta_variance` (.NET `MouseStepsDivisor`, `MouseBurstSize`, `MouseBurstPause`, `ScrollDeltaBase`, `ScrollDeltaVariance`). Mouse moves are now timed by `mouse_time_*` and scrolling by `scroll_notch_px`, so these had no effect. A `human_config` that still passes them keeps working: Python drops them with a `FutureWarning` (shown by default and pointing at your own code), and JavaScript and the .NET override dictionary ignore them. .NET code that sets the properties directly no longer compiles.
|
||||
|
||||
## [0.6.2] — 2026-10-12
|
||||
|
||||
- **[wrapper]** Humanized actions (`click`, `hover`, `fill`, `type`, `check` and the rest) work again on Playwright 1.64. They timed out with `element is not visible` even on visible elements, because Playwright 1.64 changed how it checks visibility. Older Playwright versions are unaffected. Python, JavaScript, and .NET (thanks [@laplace-anon](https://github.com/laplace-anon), #586, #587)
|
||||
|
||||
## [0.6.1] — 2026-10-11
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Same API, same code — just swap the import. <strong>3 lines of code, 30 second
|
||||
- **87 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals, CDP input behavior
|
||||
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
|
||||
- **Pro: 0.9 reCAPTCHA v3 score** — human-level, server-verified
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 50+ detection sites
|
||||
- **Auto-downloads the right binary** — free or Pro based on your license
|
||||
- **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config
|
||||
- **Latest binary, free to try** — [sign in with GitHub](https://cloakbrowser.dev/free), point the newest build at your hardest target, scale to thousands of sessions on Pro
|
||||
@@ -150,9 +150,9 @@ page.goto("https://example.com")
|
||||
|
||||
---
|
||||
|
||||
## Latest: v0.6.1 — 87 source-level stealth patches (Chromium 154.0.8037.57.1)
|
||||
## Latest: v0.6.3 — 87 source-level stealth patches (Chromium 154.0.8037.57.2)
|
||||
|
||||
- **CloakBrowser Pro Stable** — Chromium `154.0.8037.57.1` on Linux x64, Linux ARM64, Windows x64, and macOS (Apple Silicon and Intel). Set a `license_key` (`licenseKey` in JS) or the `CLOAKBROWSER_LICENSE_KEY` env var and the wrapper fetches the latest Stable build for your platform automatically. See [CloakBrowser Pro](#cloakbrowser-pro)
|
||||
- **CloakBrowser Pro Stable** — Chromium `154.0.8037.57.2` on Linux x64 and Linux ARM64; `154.0.8037.57.1` on Windows x64 and macOS (Apple Silicon and Intel). Set a `license_key` (`licenseKey` in JS) or the `CLOAKBROWSER_LICENSE_KEY` env var and the wrapper fetches the latest Stable build for your platform automatically. See [CloakBrowser Pro](#cloakbrowser-pro)
|
||||
- **.NET 8 / C# client** — CloakBrowser now ships as a NuGet package (`CloakBrowser`), mirroring the Python and JS wrappers.
|
||||
- **Chromium 154 upgrade** — rebased the full patch set onto Chromium 154 on every platform; macOS moves up from Chromium 151
|
||||
- **87 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome profiles
|
||||
@@ -235,7 +235,7 @@ All tests verified against live detection services. Results below are for the la
|
||||
| UA string | `HeadlessChrome` | **`Chrome/151.0.0.0`** | No headless leak |
|
||||
| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` |
|
||||
| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match |
|
||||
| | | **Tested against 30+ detection sites** | |
|
||||
| | | **Tested against 50+ detection sites** | |
|
||||
|
||||
### Proof
|
||||
|
||||
@@ -648,7 +648,7 @@ const browser = await launch({ humanize: true, humanPreset: 'careful' });
|
||||
```python
|
||||
browser = launch(humanize=True, human_config={
|
||||
"mistype_chance": 0.05, # 5% typo rate with self-correction
|
||||
"typing_delay": 100, # slower typing (ms per character)
|
||||
"typing_delay": 200, # slower typing (ms between keys)
|
||||
"idle_between_actions": True, # micro-movements between clicks
|
||||
"idle_between_duration": [0.3, 0.8], # idle duration range (seconds)
|
||||
})
|
||||
@@ -659,7 +659,7 @@ const browser = await launch({
|
||||
humanize: true,
|
||||
humanConfig: {
|
||||
mistype_chance: 0.05,
|
||||
typing_delay: 100,
|
||||
typing_delay: 200,
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [0.3, 0.8],
|
||||
}
|
||||
@@ -689,7 +689,7 @@ For an arbitrary expression, `page._stealth_world.evaluate("…")` evaluates it
|
||||
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) |
|
||||
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
|
||||
| `CLOAKBROWSER_FETCH_WIDEVINE` | `0` | Docker only: set to `1` to auto-fetch the Widevine CDM on container start (Linux x86-64 only). See [Widevine / DRM](#widevine--drm) |
|
||||
| `CLOAKBROWSER_VERSION` | — | Pin to an exact Chromium version for rollback (e.g. `154.0.8037.57.1`). Works with Free and Pro binaries |
|
||||
| `CLOAKBROWSER_VERSION` | — | Pin to an exact Chromium version for rollback (e.g. `154.0.8037.57.1`). Paid Pro key only |
|
||||
| `CLOAKBROWSER_RELEASE_CHANNEL` | `stable` | Set to `preview` to opt into the Preview binary channel |
|
||||
|
||||
## Fingerprint Management
|
||||
@@ -724,7 +724,7 @@ Every `launch()` call sets these automatically. The **wrapper** applies platform
|
||||
|
||||
| Flag | Linux/Windows Default | macOS Default | Controls |
|
||||
|------|--------------|---------------|----------|
|
||||
| `--fingerprint` | Random (10000–99999) | Random (10000–99999) | Master seed for canvas, WebGL, audio, fonts, client rects |
|
||||
| `--fingerprint` | Random (10000–99999) | Random (10000–99999) | Master seed for canvas, WebGL, audio, fonts |
|
||||
| `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection |
|
||||
|
||||
The binary auto-generates everything else from the seed: GPU, hardware concurrency, device memory, and screen dimensions. Each seed produces a unique, consistent fingerprint. Override with explicit flags if needed.
|
||||
@@ -750,13 +750,14 @@ Supported by the binary but **not set by default** — pass via `args` to custom
|
||||
| `--fingerprint-locale` | Locale (e.g. `en-US`) |
|
||||
| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set |
|
||||
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
|
||||
| `--fingerprint-windows-font-metrics` | **Chromium 148+ binary only** (no-op on earlier builds). Align font metrics with the Windows platform when spoofing Windows on Linux — used in the [FingerprintJS config](#detected-by-fingerprintjs). Requires Windows fonts installed (see [Font Setup on Linux](#font-setup-on-linux)); no effect without them |
|
||||
| `--fingerprint-windows-font-metrics` | **Chromium 148+ binary only** (no-op on earlier builds). Align font metrics with the Windows platform when spoofing Windows on Linux — used in the [FingerprintJS config](#detected-by-fingerprintjs). Requires Windows fonts installed (see [Font Setup on Linux](#font-setup-on-linux)); no effect without them. On `154.0.8037.57.2` and newer this is on by default when the Windows fonts are installed, so the flag is no longer needed |
|
||||
| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` |
|
||||
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
|
||||
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio) while keeping the deterministic fingerprint seed active |
|
||||
| `--fingerprint=off` | **Chromium 148+ binary only.** Pass-through debug mode — turns spoofing off and presents the machine's **real native fingerprint** (keeps only the baseline any Chrome needs). The binary strips the injected seed *and* `--fingerprint-platform`, so there's no mixed OS profile. Most useful on a genuine Windows machine to check whether an issue is our spoofing or the environment. Accepts `off`/`false`/`0`/`disable`/`disabled`. For a *pure* pass-through don't combine it with `geoip=True` / explicit timezone / locale — those stay applied. |
|
||||
| `--fingerprint-allow-3p-cookies` | **Chromium 148+ binary only.** Re-enable third-party cookies for embedded flows that need them (reCAPTCHA v3, SSO, some payment challenges). Off by default; turn on only where a login/payment/embedded challenge loads but never finishes. |
|
||||
| `--fingerprint-sapi-voices=false` | **Chromium 150+ binary only.** Opt out of the Windows speech-voice tables when spoofing Windows. On by default (the voice set matches a real Chrome install); turn off only if a target reacts badly to the Windows voice list. |
|
||||
| `--license-through-proxy` | **Chromium 148+ binary only (all platforms).** Route the Pro license/session calls through your `--proxy-server` instead of direct to cloakbrowser.dev. Off by default (these calls go direct, so they never spend proxy bandwidth or touch your scraping session). |
|
||||
| `--license-route=auto\|direct\|proxy` | **Chromium 154.0.8037.57.2+ binary only.** How the Pro license and session calls leave the browser. `auto` (default): direct to cloakbrowser.dev first, and one retry through your `--proxy-server` if direct can't reach it. `direct`: never through the proxy. `proxy`: only through the proxy (needs `--proxy-server`). |
|
||||
| `--license-through-proxy` | **Chromium 148+ binary only (all platforms).** Route the Pro license/session calls through your `--proxy-server` instead of direct to cloakbrowser.dev. Off by default (these calls go direct, so they never spend proxy bandwidth or touch your scraping session). On `154.0.8037.57.2` and newer, use `--license-route=proxy`; this older spelling still works. |
|
||||
| `--fingerprint-transparent-proxy` | **Chromium 151+ binary only.** A stronger way of connecting through your `--proxy-server`, for sites and providers that block every proxy you try. Off by default; enable it alongside `--proxy-server`. |
|
||||
| `--fingerprint-portable-cookies` | **Chromium 151+ binary only.** Encrypt cookies with a machine-independent key so a copied profile keeps its logins across machines. Turn it on from the first launch — it changes how cookies are written, so a cookie is only portable if this was set when it was saved. |
|
||||
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
|
||||
@@ -1037,7 +1038,7 @@ services:
|
||||
**Per-connection fingerprint seeds** — run multiple browser identities from a single container. Each unique seed spawns a separate Chrome process with its own fingerprint:
|
||||
|
||||
```python
|
||||
# Each seed gets unique canvas noise, client rects, and other browser signals
|
||||
# Each seed gets unique canvas noise and other browser signals
|
||||
b1 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=11111")
|
||||
b2 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=22222")
|
||||
|
||||
@@ -1296,23 +1297,18 @@ Preview availability is platform-specific. Preview always selects the newest bui
|
||||
|
||||
Two ways to go back to a working version:
|
||||
|
||||
**Pin the binary** (keep current wrapper, just use an older Chromium) — works for Free and Pro:
|
||||
**Pin the binary** (keep current wrapper, just use an older Chromium) — paid Pro key only. A free GitHub key cannot pin: it ignores the pin and always gets the latest build.
|
||||
|
||||
```python
|
||||
# Free — pin a public release
|
||||
browser = launch(browser_version="146.0.7680.177.5")
|
||||
|
||||
# Pro — pin a specific Pro version
|
||||
browser = launch(license_key="cb_xxxxxxxx", browser_version="154.0.8037.57.1")
|
||||
```
|
||||
|
||||
```bash
|
||||
export CLOAKBROWSER_VERSION=146.0.7680.177.5 # env var for all launches
|
||||
export CLOAKBROWSER_VERSION=154.0.8037.57.1 # env var for all launches
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Free — pin a public release
|
||||
const browser = await launch({ browserVersion: '146.0.7680.177.5' });
|
||||
const browser = await launch({ licenseKey: 'cb_xxxxxxxx', browserVersion: '154.0.8037.57.1' });
|
||||
```
|
||||
|
||||
The pin is never sticky — unpinned launches always use the latest available version.
|
||||
@@ -1487,6 +1483,9 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
|
||||
- [@Shub3am](https://github.com/Shub3am) — horizontal scroll-into-view for humanized clicks, safe concurrent first-run downloads
|
||||
- [@dstosch](https://github.com/dstosch) — cloakserve idle cleanup for browsers launched over HTTP
|
||||
- [@bercedev](https://github.com/bercedev) — humanize support for compiled (Nuitka) Playwright builds
|
||||
- [@laplace-anon](https://github.com/laplace-anon) — humanize fix for Playwright 1.64
|
||||
- [@theaafofficial](https://github.com/theaafofficial) — non-blocking async launches (GeoIP, WebRTC and license lookups off the event loop)
|
||||
- [@jipeng6036-del](https://github.com/jipeng6036-del) — Seychelles GeoIP locale
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.6.1"
|
||||
__version__ = "0.6.3"
|
||||
+12
-8
@@ -14,6 +14,7 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
@@ -507,10 +508,12 @@ async def launch_async( # noqa: C901
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version, release_channel=release_channel)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
|
||||
# Blocking network calls (license/download, exit-IP lookups) run in a thread so
|
||||
# they don't freeze the caller's event loop.
|
||||
binary_path = await asyncio.to_thread(ensure_binary, license_key=license_key, browser_version=browser_version, release_channel=release_channel)
|
||||
timezone, locale, exit_ip = await asyncio.to_thread(maybe_resolve_geoip, geoip, proxy, timezone, locale, args)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
args = await asyncio.to_thread(_resolve_webrtc_args, args, proxy)
|
||||
args = _append_webrtc_exit_ip(args, exit_ip)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and not _suppress_maximize)
|
||||
_maybe_warn_windows_fonts(chrome_args)
|
||||
@@ -819,10 +822,11 @@ async def launch_persistent_context_async(
|
||||
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary(license_key=license_key, browser_version=browser_version, release_channel=release_channel)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
|
||||
# Blocking network calls run in a thread (see launch_async).
|
||||
binary_path = await asyncio.to_thread(ensure_binary, license_key=license_key, browser_version=browser_version, release_channel=release_channel)
|
||||
timezone, locale, exit_ip = await asyncio.to_thread(maybe_resolve_geoip, geoip, proxy, timezone, locale, args)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key, release_channel)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
args = await asyncio.to_thread(_resolve_webrtc_args, args, proxy)
|
||||
args = _append_webrtc_exit_ip(args, exit_ip)
|
||||
args = persistent_seed_args(user_data_dir, stealth_args, args)
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version, release_channel) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs)
|
||||
@@ -1104,8 +1108,8 @@ async def launch_context_async(
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
|
||||
# resolved values flow to binary flags
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale, args)
|
||||
# resolved values flow to binary flags. Threaded: the lookup is blocking network I/O.
|
||||
timezone, locale, exit_ip = await asyncio.to_thread(maybe_resolve_geoip, geoip, proxy, timezone, locale, args)
|
||||
args = _append_webrtc_exit_ip(args, exit_ip)
|
||||
# --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||
# so it applies to ALL contexts, not just the default one.
|
||||
|
||||
@@ -74,6 +74,7 @@ COUNTRY_LOCALE_MAP: dict[str, str] = {
|
||||
"CM": "fr-CM", "AO": "pt-AO", "MZ": "pt-MZ", "ZM": "en-ZM", "ZW": "en-ZW",
|
||||
"HN": "es-HN", "NI": "es-NI", "SV": "es-SV", "PA": "es-PA", "JM": "en-JM",
|
||||
"TT": "en-TT", "PR": "es-PR",
|
||||
"SC": "en-SC",
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +140,10 @@ def resolve_proxy_geo_with_ip(
|
||||
timezone = resp.location.time_zone
|
||||
country = resp.country.iso_code
|
||||
locale = COUNTRY_LOCALE_MAP.get(country) if country else None
|
||||
if locale is None:
|
||||
# Map gap, not a lookup failure — don't abort the launch over it
|
||||
logger.warning("GeoIP: no locale for country %s (IP %s), using en-US", country, ip)
|
||||
locale = "en-US"
|
||||
logger.debug(
|
||||
"GeoIP: %s → tz=%s, country=%s, locale=%s",
|
||||
ip, timezone, country, locale,
|
||||
|
||||
+120
-37
@@ -7,8 +7,11 @@ Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Tuple, TypedDict
|
||||
|
||||
@@ -32,14 +35,14 @@ class HumanConfigOverrides(TypedDict, total=False):
|
||||
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
|
||||
mouse_time_base: float
|
||||
mouse_time_per_bit: float
|
||||
mouse_time_spread: float
|
||||
click_aim_delay_input: Range
|
||||
click_aim_delay_button: Range
|
||||
click_hold_input: Range
|
||||
@@ -47,8 +50,7 @@ class HumanConfigOverrides(TypedDict, total=False):
|
||||
click_input_x_range: Range
|
||||
idle_drift_px: float
|
||||
idle_pause_range: Range
|
||||
scroll_delta_base: Range
|
||||
scroll_delta_variance: float
|
||||
scroll_notch_px: float
|
||||
scroll_pause_fast: Range
|
||||
scroll_pause_slow: Range
|
||||
scroll_accel_steps: Range
|
||||
@@ -72,31 +74,49 @@ class HumanConfigOverrides(TypedDict, total=False):
|
||||
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
|
||||
# Keyboard. Defaults follow the 136M Keystrokes study (Dhakal et al.,
|
||||
# CHI 2018): keys are held ~100 ms, ~150 ms pass between key presses, and
|
||||
# the next key is often pressed before the previous one is released.
|
||||
#
|
||||
# typing_delay: median time from one key press to the next (ms); the gap
|
||||
# is log-normal and depends on the letters (other hand: faster, same
|
||||
# finger: slower, after a space: slower).
|
||||
typing_delay: float = 150
|
||||
# How much the gap varies (ms); 0 = always exactly typing_delay.
|
||||
typing_delay_spread: float = 70
|
||||
# Chance of a thinking pause before a word (a tenth of it inside a word).
|
||||
typing_pause_chance: float = 0.15
|
||||
typing_pause_range: Range = (300, 1000)
|
||||
# Shift press -> letter press (ms, log-normal: ~5th..95th percentile).
|
||||
shift_down_delay: Range = (60, 220)
|
||||
# Shift release relative to the letter release (ms). Negative = Shift goes
|
||||
# up first, which is what people usually do.
|
||||
shift_up_delay: Range = (-60, 30)
|
||||
# How long a key is held down (ms, log-normal: ~5th..95th percentile).
|
||||
key_hold: Range = (60, 175)
|
||||
|
||||
# Mistype (typo simulation): chance per letter / digit.
|
||||
mistype_chance: float = 0.04
|
||||
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 — movement. A move lasts (Fitts' law, log-normal spread)
|
||||
# median = mouse_time_base + mouse_time_per_bit * log2(1 + distance / 8) ms;
|
||||
# the speed peaks early (a fast main stroke, then a slow approach) and
|
||||
# pointer events go out at ~60 Hz.
|
||||
mouse_time_base: float = 160
|
||||
mouse_time_per_bit: float = 75
|
||||
mouse_time_spread: float = 0.3
|
||||
mouse_min_steps: int = 2 # fewest pointer events per stroke
|
||||
mouse_max_steps: int = 200 # most pointer events per stroke
|
||||
mouse_wobble_max: float = 1.5 # smooth sideways drift (px)
|
||||
# Chance that the main stroke lands a little off and a short corrective
|
||||
# stroke finishes the move (the miss is a few % of the distance plus
|
||||
# mouse_overshoot_px).
|
||||
mouse_overshoot_chance: float = 0.35
|
||||
mouse_overshoot_px: Range = (2, 6)
|
||||
|
||||
# Mouse — clicks
|
||||
click_aim_delay_input: Range = (60, 140)
|
||||
@@ -109,9 +129,12 @@ class HumanConfig:
|
||||
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. The wheel turns in whole notches: one wheel event per notch of
|
||||
# scroll_notch_px CSS pixels, so deltaY and wheelDeltaY agree the way they
|
||||
# do for a real mouse wheel. 0 = what Chrome uses on the persona's
|
||||
# platform (100 on Windows, 40 on macOS, 120 on Linux).
|
||||
scroll_notch_px: float = 0
|
||||
# Pause between notches inside one flick / at the start and end of it.
|
||||
scroll_pause_fast: Range = (30, 80)
|
||||
scroll_pause_slow: Range = (80, 200)
|
||||
scroll_accel_steps: Range = (2, 3)
|
||||
@@ -139,17 +162,20 @@ 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_delay=210,
|
||||
typing_delay_spread=100,
|
||||
typing_pause_chance=0.25,
|
||||
typing_pause_range=(500, 1200),
|
||||
shift_down_delay=(40, 90),
|
||||
shift_up_delay=(30, 70),
|
||||
key_hold=(20, 45),
|
||||
shift_down_delay=(80, 260),
|
||||
shift_up_delay=(-50, 40),
|
||||
key_hold=(70, 190),
|
||||
field_switch_delay=(1000, 2000),
|
||||
mistype_chance=0.05,
|
||||
# Mouse — slower, more precise
|
||||
mouse_overshoot_chance=0.10,
|
||||
mouse_burst_pause=(12, 25),
|
||||
mouse_time_base=220,
|
||||
mouse_time_per_bit=95,
|
||||
mouse_overshoot_chance=0.45,
|
||||
mouse_overshoot_px=(2, 5),
|
||||
# Mouse — clicks (longer aiming and holding)
|
||||
click_aim_delay_input=(80, 180),
|
||||
click_aim_delay_button=(120, 280),
|
||||
@@ -166,6 +192,35 @@ def _careful_config() -> HumanConfig:
|
||||
)
|
||||
|
||||
|
||||
# Settings removed when moves switched to Fitts-law timing and scrolling to
|
||||
# whole wheel notches. Old configs that still pass them keep loading: the key
|
||||
# is dropped with a warning instead of failing in HumanConfig(**...).
|
||||
_REMOVED_KEYS = frozenset({
|
||||
"mouse_steps_divisor", "mouse_burst_size", "mouse_burst_pause",
|
||||
"scroll_delta_base", "scroll_delta_variance",
|
||||
})
|
||||
|
||||
|
||||
def _drop_removed(overrides: dict) -> dict:
|
||||
removed = sorted(k for k in overrides if k in _REMOVED_KEYS)
|
||||
if not removed:
|
||||
return overrides
|
||||
# Point the warning at the caller's own code (launch(), resolve_config()
|
||||
# and the wrappers sit at different depths inside this package).
|
||||
pkg = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
# stacklevel 1 is this function; climb until the first frame outside it.
|
||||
level, frame = 1, sys._getframe(0)
|
||||
while frame is not None and os.path.abspath(frame.f_code.co_filename).startswith(pkg + os.sep):
|
||||
level, frame = level + 1, frame.f_back
|
||||
# FutureWarning, not DeprecationWarning: Python hides the latter outside __main__.
|
||||
warnings.warn(
|
||||
f"human_config: {', '.join(removed)} no longer exist and are ignored "
|
||||
"(mouse moves use mouse_time_*, scrolling uses scroll_notch_px)",
|
||||
FutureWarning, stacklevel=level,
|
||||
)
|
||||
return {k: v for k, v in overrides.items() if k not in _REMOVED_KEYS}
|
||||
|
||||
|
||||
_PRESETS: dict[str, HumanConfig] = {
|
||||
"default": HumanConfig(),
|
||||
"careful": _careful_config(),
|
||||
@@ -197,7 +252,7 @@ def resolve_config(
|
||||
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)
|
||||
merged.update(_drop_removed(dict(overrides)))
|
||||
return HumanConfig(**merged)
|
||||
|
||||
|
||||
@@ -244,6 +299,34 @@ def rand_int_range(r: Range) -> int:
|
||||
return random.randint(int(r[0]), int(r[1]))
|
||||
|
||||
|
||||
_Z95 = 1.6449 # standard normal 95th percentile
|
||||
|
||||
|
||||
def rand_log(r: Range) -> float:
|
||||
"""Log-normal sample with ``r`` as its ~5th / 95th percentiles, clipped to
|
||||
[low / 2, high * 2]. Human timings (key holds, gaps) are right-skewed like
|
||||
this, not uniform. Falls back to uniform when a bound is not positive."""
|
||||
lo, hi = float(r[0]), float(r[1])
|
||||
if hi < lo:
|
||||
lo, hi = hi, lo
|
||||
if lo <= 0:
|
||||
return random.uniform(lo, hi)
|
||||
if hi == lo:
|
||||
return lo
|
||||
mu = (math.log(lo) + math.log(hi)) / 2
|
||||
sigma = (math.log(hi) - math.log(lo)) / (2 * _Z95)
|
||||
return min(hi * 2, max(lo / 2, math.exp(random.gauss(mu, sigma))))
|
||||
|
||||
|
||||
def rand_lognormal(median: float, sigma: float) -> float:
|
||||
"""Log-normal sample with the given median and log-space sigma."""
|
||||
if median <= 0:
|
||||
return 0.0
|
||||
if sigma <= 0:
|
||||
return float(median)
|
||||
return median * math.exp(random.gauss(0.0, sigma))
|
||||
|
||||
|
||||
def sleep_ms(ms: float) -> None:
|
||||
"""Sleep for `ms` milliseconds."""
|
||||
if ms > 0:
|
||||
|
||||
+268
-131
@@ -29,9 +29,9 @@ from playwright._impl._errors import Error, TimeoutError
|
||||
from playwright._impl._js_handle import JSHandle
|
||||
|
||||
from . import fields
|
||||
from .config import HumanConfig, merge_config, rand, rand_int_range, rand_range
|
||||
from .keyboard import NEARBY_KEYS, SHIFT_SYMBOLS, _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES
|
||||
from .mouse import Point, _bezier, _ease_in_out, _random_control_points, click_target
|
||||
from .config import HumanConfig, merge_config, rand, rand_int_range, rand_log, rand_range
|
||||
from .keyboard import NEARBY_KEYS, SHIFT_SYMBOLS, _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES, key_gap, key_hold, physical_key
|
||||
from .mouse import click_target, plan_move
|
||||
from .world import HELPERS, StaleElement, Worlds, intersect
|
||||
|
||||
_ENTER_FRAME_SPLIT = re.compile(r"\s*>>\s*internal:control=enter-frame\s*>>\s*")
|
||||
@@ -127,6 +127,148 @@ def _has_handle(v: Any) -> bool:
|
||||
return isinstance(getattr(v, "_impl_obj", v), JSHandle)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typing timeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Typist:
|
||||
"""Presses keys on a timeline the way a person does: each press follows
|
||||
the previous *press* after a letter-dependent gap, and each key is held
|
||||
for its own time, so the next key often goes down before the previous one
|
||||
comes up (rollover). Shift goes down before the letter and is usually let
|
||||
go just before the letter key. Releases are queued and sent when due."""
|
||||
|
||||
def __init__(self, human: "Human", cfg: HumanConfig, delay: Optional[float] = None) -> None:
|
||||
self.h, self.cfg, self.delay = human, cfg, delay
|
||||
self.prev = ""
|
||||
self.last_press: Optional[float] = None
|
||||
self.ups: List[Tuple[float, str, Optional[dict]]] = [] # (due, key, cdp params)
|
||||
self.shift_up_due: Optional[float] = None
|
||||
self.shift_down = False
|
||||
|
||||
async def _shift(self, down: bool) -> None:
|
||||
self.shift_down = down
|
||||
await (self.h.keyboard.down if down else self.h.keyboard.up)("Shift")
|
||||
|
||||
async def _wait(self, until: float) -> None:
|
||||
await sleep_ms((until - time.monotonic()) * 1000)
|
||||
|
||||
async def _flush(self, until: float) -> None:
|
||||
"""Send every release due before ``until`` (in time order)."""
|
||||
while True:
|
||||
due = [(t, k, p) for t, k, p in self.ups if t <= until]
|
||||
if self.shift_up_due is not None and self.shift_up_due <= until:
|
||||
due.append((self.shift_up_due, "Shift", None))
|
||||
if not due:
|
||||
return
|
||||
t, k, p = min(due, key=lambda e: e[0])
|
||||
await self._wait(t)
|
||||
if k == "Shift" and p is None and self.shift_up_due is not None and t == self.shift_up_due:
|
||||
self.shift_up_due = None
|
||||
await self._shift(False)
|
||||
else:
|
||||
self.ups.remove((t, k, p))
|
||||
await self._up(k, p)
|
||||
|
||||
async def _up(self, k: str, p: Optional[dict]) -> None:
|
||||
if p is not None:
|
||||
await (await self.h.worlds.session()).send("Input.dispatchKeyEvent", p)
|
||||
elif len(k) == 1 and k.isupper() and not self.shift_down:
|
||||
# Shift already came up: the OS reports the bare key ("a").
|
||||
await self.h.keyboard.up(k.lower())
|
||||
else:
|
||||
await self.h.keyboard.up(k)
|
||||
|
||||
async def key(self, ch: str) -> None:
|
||||
cfg = self.cfg
|
||||
now = time.monotonic()
|
||||
if self.last_press is None:
|
||||
at = now
|
||||
elif self.delay is not None:
|
||||
at = self.last_press + self.delay / 1000
|
||||
else:
|
||||
at = self.last_press + key_gap(cfg, self.prev, ch) / 1000
|
||||
shifted = (len(ch) == 1 and ch.isascii() and ch.isalpha() and ch.isupper()) or ch in SHIFT_SYMBOLS
|
||||
text_only = len(ch) == 1 and not ch.isascii()
|
||||
# A key cannot go down twice: let the earlier press of the same
|
||||
# physical key ("x" before "X", "1" before "!") come up first.
|
||||
key = physical_key(ch)
|
||||
held = [t for t, k, _ in self.ups if physical_key(k) == key]
|
||||
if held:
|
||||
at = max(at, min(held) + 0.005)
|
||||
if shifted:
|
||||
if self.shift_up_due is not None:
|
||||
self.shift_up_due = None # still down from the previous capital
|
||||
await self._flush(at)
|
||||
else:
|
||||
lead = rand_log(cfg.shift_down_delay) / 1000
|
||||
# Shift goes down after the previous key comes up.
|
||||
await self._flush(at - lead)
|
||||
await self._wait(at - lead)
|
||||
await self._shift(True)
|
||||
# Keys due to come up while Shift is going down (e.g. the "x"
|
||||
# of "xX") are released before the capital is pressed.
|
||||
await self._flush(at)
|
||||
else:
|
||||
await self._flush(at)
|
||||
if self.shift_up_due is not None and ch != "Backspace":
|
||||
# A lower-case key while Shift is still down: let it go first.
|
||||
self.shift_up_due = None
|
||||
await self._shift(False)
|
||||
await self._wait(at)
|
||||
self.last_press = max(at, time.monotonic())
|
||||
hold = (self.delay if self.delay is not None and self.delay < key_hold(cfg) else key_hold(cfg)) / 1000
|
||||
up_at = self.last_press + hold
|
||||
if text_only:
|
||||
# Non-ASCII text has no key: it arrives as one input, like an IME.
|
||||
await self.h.keyboard.insert_text(ch)
|
||||
elif ch in SHIFT_SYMBOLS:
|
||||
code = _SHIFT_SYMBOL_CODES.get(ch, "")
|
||||
vk = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||
await (await self.h.worlds.session()).send("Input.dispatchKeyEvent", {
|
||||
"type": "keyDown", "modifiers": 8, "key": ch, "code": code,
|
||||
"windowsVirtualKeyCode": vk, "text": ch, "unmodifiedText": ch,
|
||||
})
|
||||
self.ups.append((up_at, ch, {"type": "keyUp", "modifiers": 8, "key": ch, "code": code,
|
||||
"windowsVirtualKeyCode": vk}))
|
||||
else:
|
||||
await self.h.keyboard.down(ch)
|
||||
self.ups.append((up_at, ch, None))
|
||||
if shifted:
|
||||
# Usually let go of Shift around the letter's release, most often
|
||||
# just before it.
|
||||
self.shift_up_due = max(self.last_press + 0.01, up_at + rand_range(cfg.shift_up_delay) / 1000)
|
||||
if ch in SHIFT_SYMBOLS:
|
||||
# The CDP keyUp of a shift symbol carries the Shift modifier.
|
||||
self.shift_up_due = max(self.shift_up_due, up_at + 0.005)
|
||||
self.prev = ch
|
||||
|
||||
async def finish(self) -> None:
|
||||
"""Release everything still held, on schedule."""
|
||||
await self._flush(float("inf"))
|
||||
|
||||
async def rest(self) -> None:
|
||||
"""Hands off the keys (before a pause): release, and start the next
|
||||
key without a gap carried over from the last one."""
|
||||
await self.finish()
|
||||
self.last_press = None
|
||||
|
||||
async def release_all(self) -> None:
|
||||
"""Error path: never leave a key stuck down."""
|
||||
ups, self.ups = self.ups, []
|
||||
for _, k, p in ups:
|
||||
try:
|
||||
await self._up(k, p)
|
||||
except Exception:
|
||||
pass
|
||||
self.shift_up_due = None
|
||||
if self.shift_down:
|
||||
try:
|
||||
await self._shift(False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Human
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -183,42 +325,24 @@ class Human:
|
||||
async def ensure_cursor(self, cfg: Optional[HumanConfig] = None) -> None:
|
||||
if not self.cursor.initialized:
|
||||
cfg = cfg or self.cfg
|
||||
self.cursor.x = rand(*cfg.initial_cursor_x)
|
||||
self.cursor.y = rand(*cfg.initial_cursor_y)
|
||||
self.cursor.x = round(rand(*cfg.initial_cursor_x))
|
||||
self.cursor.y = round(rand(*cfg.initial_cursor_y))
|
||||
await self.mouse.move(self.cursor.x, self.cursor.y)
|
||||
self.cursor.initialized = True
|
||||
|
||||
async def move_to(self, x: float, y: float, cfg: HumanConfig) -> None:
|
||||
"""Bezier move from the current cursor position to (x, y)."""
|
||||
"""Move from the current cursor position to (x, y): an early-peaking
|
||||
main stroke timed by Fitts' law, sometimes a short corrective stroke,
|
||||
one pointer event per frame and none without movement."""
|
||||
await self.ensure_cursor(cfg)
|
||||
sx, sy = self.cursor.x, self.cursor.y
|
||||
dist = math.hypot(x - sx, y - sy)
|
||||
if dist >= 1:
|
||||
steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor)))
|
||||
start, end = Point(sx, sy), Point(x, y)
|
||||
cp1, cp2 = _random_control_points(start, end)
|
||||
burst, burst_size = 0, rand_int_range(cfg.mouse_burst_size)
|
||||
for i in range(1, steps + 1):
|
||||
progress = i / steps
|
||||
pt = _bezier(start, cp1, cp2, end, _ease_in_out(progress))
|
||||
wobble = math.sin(math.pi * progress) * cfg.mouse_wobble_max
|
||||
if i == steps:
|
||||
await self.mouse.move(x, y) # exact end point (position= offsets)
|
||||
continue
|
||||
else:
|
||||
wx = pt.x + (random.random() - 0.5) * 2 * wobble
|
||||
wy = pt.y + (random.random() - 0.5) * 2 * wobble
|
||||
await self.mouse.move(round(wx), round(wy))
|
||||
burst += 1
|
||||
if burst >= burst_size and i < steps:
|
||||
await sleep_ms(rand_range(cfg.mouse_burst_pause))
|
||||
burst = 0
|
||||
if random.random() < cfg.mouse_overshoot_chance:
|
||||
angle = math.atan2(y - sy, x - sx)
|
||||
over = rand_range(cfg.mouse_overshoot_px)
|
||||
await self.mouse.move(round(x + math.cos(angle) * over), round(y + math.sin(angle) * over))
|
||||
await sleep_ms(rand(30, 70))
|
||||
await self.mouse.move(x, y)
|
||||
clock = time.monotonic()
|
||||
for px, py, dt in plan_move(sx, sy, x, y, cfg):
|
||||
# Keep to the planned schedule: the CDP round trip of each move
|
||||
# comes out of the next wait instead of adding to it.
|
||||
clock += dt / 1000
|
||||
await sleep_ms((clock - time.monotonic()) * 1000)
|
||||
await self.mouse.move(px, py)
|
||||
self.cursor.x, self.cursor.y = x, y
|
||||
|
||||
async def idle(self, cfg: HumanConfig) -> None:
|
||||
@@ -416,11 +540,20 @@ class Human:
|
||||
vp = await self.worlds.viewport()
|
||||
stalled = rounds = 0
|
||||
overshot = False
|
||||
last = (0.0, 0.0)
|
||||
notch = await self.notch_px(cfg)
|
||||
while stalled < 2 and rounds < 40 and not deadline.expired():
|
||||
dx, dy = await self._needed_scroll(r, box, vp, cfg)
|
||||
if abs(dx) < 1 and abs(dy) < 1:
|
||||
break
|
||||
rounds += 1
|
||||
# Whole notches can jump over a target whose slack is smaller than
|
||||
# one notch (a short scroll box): after such a reversal, finish
|
||||
# with one wheel event of the exact remainder (a free-spinning or
|
||||
# high-resolution wheel sends those) instead of swinging forever.
|
||||
fine_y = dy * last[1] < 0 and abs(dy) < notch
|
||||
fine_x = dx * last[0] < 0 and abs(dx) < notch
|
||||
last = (dx, dy)
|
||||
area = box["clip"] if box["clip"]["width"] > 4 and box["clip"]["height"] > 4 else \
|
||||
{"x": 0.0, "y": 0.0, "width": float(vp["width"]), "height": float(vp["height"])}
|
||||
if not self._inside(area, self.cursor):
|
||||
@@ -429,12 +562,18 @@ class Human:
|
||||
await sleep_ms(rand_range(cfg.scroll_pre_move_delay))
|
||||
before = (box["x"], box["y"])
|
||||
if abs(dy) >= 1:
|
||||
await self._wheel_burst(0, dy, cfg, deadline)
|
||||
if not overshot:
|
||||
overshot = True
|
||||
await self._overshoot(dy, cfg, deadline)
|
||||
if fine_y:
|
||||
await self.mouse.wheel(0, round(dy))
|
||||
else:
|
||||
await self._wheel_burst(0, dy, cfg, deadline)
|
||||
if not overshot:
|
||||
overshot = True
|
||||
await self._overshoot(dy, cfg, deadline)
|
||||
if abs(dx) >= 1:
|
||||
await self._wheel_burst(dx, 0, cfg, deadline)
|
||||
if fine_x:
|
||||
await self.mouse.wheel(round(dx), 0)
|
||||
else:
|
||||
await self._wheel_burst(dx, 0, cfg, deadline)
|
||||
await sleep_ms(min(rand_range(cfg.scroll_settle_delay), deadline.remaining()))
|
||||
box = await self.element_box(r)
|
||||
moved = abs(box["x"] - before[0]) + abs(box["y"] - before[1])
|
||||
@@ -485,43 +624,61 @@ class Human:
|
||||
if random.random() >= cfg.scroll_overshoot_chance or deadline.expired():
|
||||
return
|
||||
sign = 1 if dy > 0 else -1
|
||||
await self._wheel_burst(0, round(rand_range(cfg.scroll_overshoot_px)) * sign, cfg, deadline)
|
||||
notch = await self.notch_px(cfg)
|
||||
over = max(1, int(round(rand_range(cfg.scroll_overshoot_px) / notch)))
|
||||
await self._wheel_burst(0, over * notch * sign, cfg, deadline)
|
||||
await sleep_ms(min(rand_range(cfg.scroll_settle_delay), deadline.remaining()))
|
||||
for _ in range(rand_int_range((1, 2))):
|
||||
if deadline.expired():
|
||||
break
|
||||
await self._wheel_burst(0, round(rand(40, 80)) * -sign, cfg, deadline)
|
||||
# Turn back in one or two flicks.
|
||||
first = rand_int_range((1, over)) if over > 1 and random.random() < 0.5 else over
|
||||
for n in (first, over - first):
|
||||
if n <= 0 or deadline.expired():
|
||||
continue
|
||||
await self._wheel_burst(0, n * notch * -sign, cfg, deadline)
|
||||
await sleep_ms(min(rand(100, 250), deadline.remaining()))
|
||||
|
||||
async def notch_px(self, cfg: HumanConfig) -> float:
|
||||
"""CSS pixels one wheel notch scrolls for the persona's platform:
|
||||
Chrome scrolls 100 px per notch on Windows (3 lines of 33.3 px), 40 on
|
||||
macOS and 120 on Linux, and reports ``wheelDelta`` 120 per notch. CDP
|
||||
always reports one notch per wheel event, so whole-notch events are
|
||||
the only ones whose ``deltaY`` and ``wheelDeltaY`` agree."""
|
||||
if cfg.scroll_notch_px and cfg.scroll_notch_px > 0:
|
||||
return float(cfg.scroll_notch_px)
|
||||
plat = (await self.platform()).lower()
|
||||
if plat.startswith("mac"):
|
||||
return 40.0
|
||||
if plat.startswith("win"):
|
||||
return 100.0
|
||||
return 120.0
|
||||
|
||||
async def _wheel_burst(self, dx: float, dy: float, cfg: HumanConfig,
|
||||
deadline: Optional[_Deadline] = None) -> None:
|
||||
"""One logical scroll: accelerate -> cruise -> decelerate in wheel ticks."""
|
||||
"""One logical scroll: flicks of a few wheel notches each, quicker in
|
||||
the middle of a flick, with short pauses between flicks. Every event
|
||||
is exactly one notch, so the page scrolls by the nearest whole number
|
||||
of notches (the caller re-measures and scrolls again when needed)."""
|
||||
total = abs(dy) if dy else abs(dx)
|
||||
sign = 1 if (dy or dx) > 0 else -1
|
||||
notch = await self.notch_px(cfg)
|
||||
notches = max(1, int(round(total / notch)))
|
||||
accel, decel = rand_int_range(cfg.scroll_accel_steps), rand_int_range(cfg.scroll_decel_steps)
|
||||
avg = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2
|
||||
ticks = max(1, math.ceil(total / avg))
|
||||
sent = 0.0
|
||||
for i in range(ticks):
|
||||
if sent >= total or (deadline is not None and deadline.expired()):
|
||||
flick = rand_int_range((2, 5))
|
||||
for i in range(notches):
|
||||
if deadline is not None and deadline.expired():
|
||||
break
|
||||
if i < accel or i >= ticks - decel:
|
||||
pause = rand_range(cfg.scroll_pause_slow)
|
||||
step = notch * sign
|
||||
await self.mouse.wheel(step if dx else 0, step if dy else 0)
|
||||
if i == notches - 1:
|
||||
break
|
||||
flick -= 1
|
||||
if flick <= 0:
|
||||
# A finger lifts and turns the wheel again.
|
||||
flick = rand_int_range((2, 5))
|
||||
await sleep_ms(rand_range(cfg.scroll_pause_slow) + rand(40, 120))
|
||||
elif i < accel or i >= notches - decel:
|
||||
await sleep_ms(rand_range(cfg.scroll_pause_slow))
|
||||
else:
|
||||
pause = rand_range(cfg.scroll_pause_fast)
|
||||
tick = min(total - sent, rand_range(cfg.scroll_delta_base) *
|
||||
(1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance))
|
||||
# Split each tick into small wheel events like real inertia.
|
||||
left = tick
|
||||
while left > 0.5:
|
||||
chunk = min(left, rand(20, 40))
|
||||
step = round(chunk) * sign
|
||||
if step:
|
||||
await self.mouse.wheel(step if dx else 0, step if dy else 0)
|
||||
left -= chunk
|
||||
await sleep_ms(rand(8, 20))
|
||||
sent += tick
|
||||
await sleep_ms(pause)
|
||||
await sleep_ms(rand_range(cfg.scroll_pause_fast))
|
||||
|
||||
# -- pointer ------------------------------------------------------------
|
||||
|
||||
@@ -708,9 +865,9 @@ class Human:
|
||||
info = await self.worlds.call(r.frame, "info", r.id)
|
||||
if info["tag"] == "input":
|
||||
if not await self.worlds.call(r.frame, "caretAtEnd", r.id):
|
||||
await self.keyboard.press("End")
|
||||
await self.press_key("End")
|
||||
else:
|
||||
await self.keyboard.press("Control+End")
|
||||
await self.press_key("Control+End")
|
||||
await sleep_ms(rand(20, 60))
|
||||
|
||||
async def press(self, target: Target, key: str, opts: dict, api: str = "press") -> None:
|
||||
@@ -718,7 +875,7 @@ class Human:
|
||||
deadline = self.deadline(target, opts)
|
||||
await self.focus_element(target, opts, api, cfg, deadline, require_focus=False)
|
||||
await sleep_ms(rand(50, 150))
|
||||
await self.keyboard.press(await self._keys(key), delay=opts.get("delay"))
|
||||
await self.press_key(await self._keys(key), delay=opts.get("delay"))
|
||||
|
||||
async def _keys(self, key: str) -> str:
|
||||
if "ControlOrMeta" in key:
|
||||
@@ -765,16 +922,16 @@ class Human:
|
||||
await sleep_ms(rand(100, 250))
|
||||
info = await self.worlds.call(r.frame, "info", r.id)
|
||||
if info["value"]:
|
||||
await self.keyboard.press(await self.select_all_key())
|
||||
await self.press_key(await self.select_all_key())
|
||||
await sleep_ms(rand(30, 80))
|
||||
await self.keyboard.press("Backspace")
|
||||
await self.press_key("Backspace")
|
||||
await sleep_ms(rand(50, 150))
|
||||
info = await self.worlds.call(r.frame, "info", r.id)
|
||||
if info["value"]:
|
||||
# Some widgets ignore select-all; clear what is left by keys.
|
||||
await self.caret_to_end(r)
|
||||
for _ in range(len(info["value"])):
|
||||
await self.keyboard.press("Backspace")
|
||||
await self.press_key("Backspace")
|
||||
await sleep_ms(rand(15, 40))
|
||||
if value:
|
||||
await self.type_text(value, cfg, r.frame, info, None)
|
||||
@@ -829,7 +986,7 @@ class Human:
|
||||
# person scrolling through the names.
|
||||
diff = int(parts["month"]) - int(current["month"])
|
||||
for _ in range(abs(diff)):
|
||||
await self.keyboard.press("ArrowUp" if diff > 0 else "ArrowDown")
|
||||
await self.press_key("ArrowUp" if diff > 0 else "ArrowDown")
|
||||
await sleep_ms(rand(60, 140))
|
||||
elif kind == "ampm":
|
||||
await self._type_char("P" if parts["hour24"] >= 12 else "A", cfg)
|
||||
@@ -841,10 +998,9 @@ class Human:
|
||||
# The editor forgets a partial entry after ~1 s without
|
||||
# a key; people type a number in one go, so keep the
|
||||
# gap inside a segment short (pauses go between parts).
|
||||
await sleep_ms(min(450.0, max(40.0, cfg.typing_delay +
|
||||
(random.random() - 0.5) * 2 * cfg.typing_delay_spread)))
|
||||
await sleep_ms(min(450.0, max(40.0, key_gap(cfg, ch, digits[j + 1]) - key_hold(cfg))))
|
||||
if i < len(segs) - 1 and not fields.segment_auto_advances(kind, parts, input_type):
|
||||
await self.keyboard.press("ArrowRight")
|
||||
await self.press_key("ArrowRight")
|
||||
await sleep_ms(rand(60, 160))
|
||||
got = await self.worlds.call(r.frame, "value", r.id)
|
||||
if got != value:
|
||||
@@ -873,7 +1029,7 @@ class Human:
|
||||
break
|
||||
up = cur < want
|
||||
key = ("ArrowRight" if up else "ArrowLeft") if not info["rtl"] else ("ArrowLeft" if up else "ArrowRight")
|
||||
await self.keyboard.press(key)
|
||||
await self.press_key(key)
|
||||
await sleep_ms(rand(40, 110))
|
||||
if float(await self.worlds.call(r.frame, "value", r.id)) == cur:
|
||||
break # cannot move further (min/max reached)
|
||||
@@ -923,7 +1079,12 @@ class Human:
|
||||
cfg, deadline)
|
||||
else:
|
||||
await self._select_listbox(target, r, p, opts, api, cfg, deadline)
|
||||
state = await self.worlds.call(r.frame, "selectState", r.id)
|
||||
try:
|
||||
state = await self.worlds.call(r.frame, "selectState", r.id)
|
||||
except StaleElement:
|
||||
# The pick navigated the page (a form submitted on change): nothing
|
||||
# left to read back, so report what was chosen, as Playwright does.
|
||||
return p["values"]
|
||||
if sorted(state["selected"]) != sorted(p["targets"]):
|
||||
raise Error(f"{api}: Error: selection ended as {state['values']} instead of {p['values']}")
|
||||
return state["values"]
|
||||
@@ -964,7 +1125,7 @@ class Human:
|
||||
typed = False
|
||||
for k in keys:
|
||||
if deadline.expired():
|
||||
await self.keyboard.press("Escape")
|
||||
await self.press_key("Escape")
|
||||
raise TimeoutError(f"{api}: Timeout {deadline.timeout:.0f}ms exceeded.\n"
|
||||
f"Call log:\n - choosing an option of {target.description}")
|
||||
if len(k) == 1:
|
||||
@@ -973,10 +1134,10 @@ class Human:
|
||||
self._typeahead_until = time.monotonic() + 1.1
|
||||
await sleep_ms(rand(60, 160))
|
||||
else:
|
||||
await self.keyboard.press(k)
|
||||
await self.press_key(k)
|
||||
await sleep_ms(rand(90, 200))
|
||||
await sleep_ms(rand(100, 250))
|
||||
await self.keyboard.press("Enter")
|
||||
await self.press_key("Enter")
|
||||
if typed:
|
||||
self._typeahead_until = time.monotonic() + 1.1
|
||||
await sleep_ms(rand(80, 160))
|
||||
@@ -989,7 +1150,7 @@ class Human:
|
||||
return
|
||||
session = await self.worlds.session()
|
||||
await session.send("Input.dispatchKeyEvent", {"type": "keyDown", "key": ch, "text": ch, "unmodifiedText": ch})
|
||||
await sleep_ms(rand_range(cfg.key_hold))
|
||||
await sleep_ms(key_hold(cfg))
|
||||
await session.send("Input.dispatchKeyEvent", {"type": "keyUp", "key": ch})
|
||||
|
||||
async def _select_listbox(self, target: Target, r: Resolved, p: dict, opts: dict, api: str,
|
||||
@@ -1071,35 +1232,35 @@ class Human:
|
||||
"""Type ``text`` key by key into the focused element of ``frame``."""
|
||||
tag = info.get("tag") if info else None
|
||||
mistypes = cfg.mistype_chance > 0 and tag is not None and fields.allows_mistype(tag, info.get("type"))
|
||||
for i, ch in enumerate(text):
|
||||
if not ch.isascii():
|
||||
await sleep_ms(rand_range(cfg.key_hold))
|
||||
await self.keyboard.insert_text(ch)
|
||||
else:
|
||||
if mistypes and ch.isalnum() and random.random() < cfg.mistype_chance:
|
||||
await self._typo(ch, cfg, frame)
|
||||
await self._type_char(ch, cfg)
|
||||
if i < len(text) - 1:
|
||||
await self._between_keys(cfg, delay)
|
||||
typist = _Typist(self, cfg, delay)
|
||||
try:
|
||||
for ch in text:
|
||||
if mistypes and ch.isascii() and ch.isalnum() and random.random() < cfg.mistype_chance:
|
||||
await self._typo(typist, ch, cfg, frame)
|
||||
await typist.key(ch)
|
||||
await typist.finish()
|
||||
finally:
|
||||
await typist.release_all()
|
||||
|
||||
async def _typo(self, ch: str, cfg: HumanConfig, frame: Any) -> None:
|
||||
async def _typo(self, typist: "_Typist", ch: str, cfg: HumanConfig, frame: Any) -> None:
|
||||
lower = ch.lower()
|
||||
if lower not in NEARBY_KEYS:
|
||||
return
|
||||
wrong = random.choice(NEARBY_KEYS[lower])
|
||||
if ch.isupper():
|
||||
wrong = wrong.upper()
|
||||
await typist.finish()
|
||||
before = await self._active_value(frame)
|
||||
await self._type_char(wrong, cfg)
|
||||
await typist.key(wrong)
|
||||
await typist.rest()
|
||||
await sleep_ms(rand_range(cfg.mistype_delay_notice))
|
||||
after = await self._active_value(frame)
|
||||
if before is not None and after == before:
|
||||
# The page rejected the wrong key (mask / filter): nothing to undo.
|
||||
await sleep_ms(rand_range(cfg.mistype_delay_correct))
|
||||
return
|
||||
await self.keyboard.down("Backspace")
|
||||
await sleep_ms(rand_range(cfg.key_hold))
|
||||
await self.keyboard.up("Backspace")
|
||||
await typist.key("Backspace")
|
||||
await typist.rest()
|
||||
await sleep_ms(rand_range(cfg.mistype_delay_correct))
|
||||
|
||||
async def _active_value(self, frame: Any) -> Optional[str]:
|
||||
@@ -1109,42 +1270,18 @@ class Human:
|
||||
return None
|
||||
|
||||
async def _type_char(self, ch: str, cfg: HumanConfig) -> None:
|
||||
if ch.isalpha() and ch.isupper():
|
||||
await self.keyboard.down("Shift")
|
||||
await sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
await self.keyboard.down(ch)
|
||||
await sleep_ms(rand_range(cfg.key_hold))
|
||||
await self.keyboard.up(ch)
|
||||
await sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await self.keyboard.up("Shift")
|
||||
elif ch in SHIFT_SYMBOLS:
|
||||
session = await self.worlds.session()
|
||||
code = _SHIFT_SYMBOL_CODES.get(ch, "")
|
||||
vk = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||
await self.keyboard.down("Shift")
|
||||
await sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
await session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyDown", "modifiers": 8, "key": ch, "code": code,
|
||||
"windowsVirtualKeyCode": vk, "text": ch, "unmodifiedText": ch,
|
||||
})
|
||||
await sleep_ms(rand_range(cfg.key_hold))
|
||||
await session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyUp", "modifiers": 8, "key": ch, "code": code, "windowsVirtualKeyCode": vk,
|
||||
})
|
||||
await sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
await self.keyboard.up("Shift")
|
||||
else:
|
||||
await self.keyboard.down(ch)
|
||||
await sleep_ms(rand_range(cfg.key_hold))
|
||||
await self.keyboard.up(ch)
|
||||
"""One key on its own (date editors, type-ahead)."""
|
||||
typist = _Typist(self, cfg)
|
||||
try:
|
||||
await typist.key(ch)
|
||||
await typist.finish()
|
||||
finally:
|
||||
await typist.release_all()
|
||||
|
||||
async def _between_keys(self, cfg: HumanConfig, delay: Optional[float]) -> None:
|
||||
if delay is not None:
|
||||
await sleep_ms(delay)
|
||||
elif random.random() < cfg.typing_pause_chance:
|
||||
await sleep_ms(rand_range(cfg.typing_pause_range))
|
||||
else:
|
||||
await sleep_ms(max(10, cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread))
|
||||
async def press_key(self, key: str, cfg: Optional[HumanConfig] = None, delay: Optional[float] = None) -> None:
|
||||
"""``keyboard.press`` with a human key-down time (Playwright's own
|
||||
press releases the key in the same instant)."""
|
||||
await self.keyboard.press(key, delay=delay if delay is not None else key_hold(cfg or self.cfg))
|
||||
|
||||
async def keyboard_type(self, text: str, delay: Optional[float] = None) -> None:
|
||||
"""``page.keyboard.type``: no target; mistypes only where safe."""
|
||||
|
||||
@@ -7,10 +7,11 @@ Falls back to page.evaluate when no CDP session is available.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, sleep_ms
|
||||
from .config import HumanConfig, rand, rand_log, rand_lognormal, rand_range, sleep_ms
|
||||
|
||||
|
||||
class RawKeyboard(Protocol):
|
||||
@@ -45,6 +46,23 @@ _SHIFT_SYMBOL_CODES: dict[str, str] = {
|
||||
'?': 'Slash', '~': 'Backquote',
|
||||
}
|
||||
|
||||
# Physical key of each unshifted digit / punctuation character, so a shift
|
||||
# symbol and its base character ("1" and "!") count as the same key.
|
||||
_BASE_KEY_CODES: dict[str, str] = {
|
||||
'1': 'Digit1', '2': 'Digit2', '3': 'Digit3', '4': 'Digit4', '5': 'Digit5',
|
||||
'6': 'Digit6', '7': 'Digit7', '8': 'Digit8', '9': 'Digit9', '0': 'Digit0',
|
||||
'-': 'Minus', '=': 'Equal', '[': 'BracketLeft', ']': 'BracketRight',
|
||||
'\\': 'Backslash', ';': 'Semicolon', "'": 'Quote', ',': 'Comma',
|
||||
'.': 'Period', '/': 'Slash', '`': 'Backquote',
|
||||
}
|
||||
|
||||
|
||||
def physical_key(ch: str) -> str:
|
||||
"""The physical key that types ``ch``: "x" and "X" are one key, so are
|
||||
"1" and "!". Used so a key never goes down while it is still held."""
|
||||
return _SHIFT_SYMBOL_CODES.get(ch) or _BASE_KEY_CODES.get(ch) or ch.lower()
|
||||
|
||||
|
||||
# Windows virtual key codes for Input.dispatchKeyEvent.
|
||||
_SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
|
||||
'!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||
@@ -55,6 +73,63 @@ _SHIFT_SYMBOL_KEYCODES: dict[str, int] = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key timing (shared by the humanize engine)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Numbers come from the 136M Keystrokes data (Dhakal et al., CHI 2018):
|
||||
# letters typed with the other hand follow ~20% faster than letters of the
|
||||
# same hand, a repeated letter (same finger) is slower, and the first letter
|
||||
# of a word (after a space) is the slowest. People commonly press the next
|
||||
# key before letting go of the previous one (rollover).
|
||||
|
||||
_LEFT_HAND = frozenset("qwertasdfgzxcvb`12345~!@#$%")
|
||||
|
||||
|
||||
def _hand(ch: str) -> Optional[str]:
|
||||
c = ch.lower()
|
||||
if not c or not c.isascii() or c.isspace():
|
||||
return None
|
||||
return "L" if c in _LEFT_HAND else "R"
|
||||
|
||||
|
||||
def gap_factor(prev: str, ch: str) -> float:
|
||||
"""How much slower / faster than average the press of ``ch`` follows
|
||||
``prev`` (the previous typed character)."""
|
||||
if not prev:
|
||||
return 1.0
|
||||
if prev == " ":
|
||||
return 1.3
|
||||
if ch == " ":
|
||||
return 0.9
|
||||
if prev.lower() == ch.lower():
|
||||
return 1.05
|
||||
hp, hc = _hand(prev), _hand(ch)
|
||||
if hp and hc:
|
||||
return 0.85 if hp != hc else 1.1
|
||||
return 1.0
|
||||
|
||||
|
||||
def key_gap(cfg: HumanConfig, prev: str, ch: str) -> float:
|
||||
"""Milliseconds from the press of ``prev`` to the press of ``ch``
|
||||
(log-normal around ``typing_delay``, plus occasional thinking pauses
|
||||
before words)."""
|
||||
median = max(1.0, cfg.typing_delay * gap_factor(prev, ch))
|
||||
spread = max(0.0, cfg.typing_delay_spread)
|
||||
sigma = math.log1p(spread / cfg.typing_delay) if cfg.typing_delay > 0 else 0.0
|
||||
gap = rand_lognormal(median, sigma)
|
||||
word_start = prev == " " or prev == ""
|
||||
chance = cfg.typing_pause_chance * (1.0 if word_start else 0.1)
|
||||
if chance > 0 and random.random() < chance:
|
||||
gap += rand_range(cfg.typing_pause_range)
|
||||
return max(15.0, gap)
|
||||
|
||||
|
||||
def key_hold(cfg: HumanConfig) -> float:
|
||||
"""How long one key stays down (ms)."""
|
||||
return max(1.0, rand_log(cfg.key_hold))
|
||||
|
||||
|
||||
def _get_nearby_key(ch: str) -> str:
|
||||
"""Return a random adjacent key for the given character."""
|
||||
lower = ch.lower()
|
||||
@@ -79,7 +154,7 @@ def human_type(
|
||||
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))
|
||||
sleep_ms(key_hold(cfg))
|
||||
raw.insert_text(ch)
|
||||
if i < len(text) - 1:
|
||||
_inter_char_delay(cfg)
|
||||
@@ -91,7 +166,7 @@ def human_type(
|
||||
_type_normal_char(raw, wrong, cfg)
|
||||
sleep_ms(rand_range(cfg.mistype_delay_notice))
|
||||
raw.down("Backspace")
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
sleep_ms(key_hold(cfg))
|
||||
raw.up("Backspace")
|
||||
sleep_ms(rand_range(cfg.mistype_delay_correct))
|
||||
|
||||
@@ -108,17 +183,17 @@ def human_type(
|
||||
|
||||
def _type_normal_char(raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None:
|
||||
raw.down(ch)
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
sleep_ms(key_hold(cfg))
|
||||
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))
|
||||
sleep_ms(rand_log(cfg.shift_down_delay))
|
||||
raw.down(ch)
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
sleep_ms(key_hold(cfg))
|
||||
raw.up(ch)
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
sleep_ms(max(0.0, rand_range(cfg.shift_up_delay)))
|
||||
raw.up("Shift")
|
||||
|
||||
|
||||
@@ -141,7 +216,7 @@ def _type_shift_symbol(
|
||||
key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0)
|
||||
|
||||
raw.down("Shift")
|
||||
sleep_ms(rand_range(cfg.shift_down_delay))
|
||||
sleep_ms(rand_log(cfg.shift_down_delay))
|
||||
|
||||
cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyDown",
|
||||
@@ -152,7 +227,7 @@ def _type_shift_symbol(
|
||||
"text": ch,
|
||||
"unmodifiedText": ch,
|
||||
})
|
||||
sleep_ms(rand_range(cfg.key_hold))
|
||||
sleep_ms(key_hold(cfg))
|
||||
|
||||
cdp_session.send("Input.dispatchKeyEvent", {
|
||||
"type": "keyUp",
|
||||
@@ -162,12 +237,12 @@ def _type_shift_symbol(
|
||||
"windowsVirtualKeyCode": key_code,
|
||||
})
|
||||
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
sleep_ms(max(0.0, 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))
|
||||
sleep_ms(rand_log(cfg.shift_down_delay))
|
||||
raw.insert_text(ch)
|
||||
page.evaluate(
|
||||
"""(key) => {
|
||||
@@ -179,13 +254,9 @@ def _type_shift_symbol(
|
||||
}""",
|
||||
ch,
|
||||
)
|
||||
sleep_ms(rand_range(cfg.shift_up_delay))
|
||||
sleep_ms(max(0.0, 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))
|
||||
sleep_ms(max(10.0, key_gap(cfg, "a", "b") - rand_log(cfg.key_hold)))
|
||||
+116
-41
@@ -4,9 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Protocol, Tuple
|
||||
from typing import Any, Callable, List, Optional, Protocol, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
|
||||
from .config import HumanConfig, rand, rand_lognormal, rand_range, rand_int_range, sleep_ms
|
||||
|
||||
|
||||
class RawMouse(Protocol):
|
||||
@@ -41,62 +41,137 @@ def _bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point:
|
||||
)
|
||||
|
||||
|
||||
def _random_control_points(start: Point, end: Point) -> Tuple[Point, Point]:
|
||||
def _random_control_points(start: Point, end: Point, curvature: float = 0.3) -> 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
|
||||
# Both control points bend the same way more often than not: a hand
|
||||
# sweeps an arc, it rarely draws an S.
|
||||
bias1 = rand(-curvature, curvature) * dist
|
||||
bias2 = bias1 * rand(0.2, 1.2) if random.random() < 0.8 else rand(-curvature, curvature) * 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),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stroke planning (shared by the humanize engine and human_move)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A pointing movement is a fast main stroke whose speed peaks early, then a
|
||||
# slow approach to the target, often finished by a short corrective stroke
|
||||
# (Meyer's optimised sub-movements; Plamondon's log-normal strokes). How long
|
||||
# it takes grows with log2 of the distance (Fitts' law) and varies a lot from
|
||||
# one move to the next. A mouse reports its position at a steady rate and
|
||||
# only when it actually moved.
|
||||
|
||||
FRAME_MS = 1000 / 60 # pointer events arrive about once per display frame
|
||||
_PEAK = (0.22, 0.38) # where the speed peaks, as a fraction of the stroke time
|
||||
|
||||
|
||||
def _progress_curve(peak: float) -> "Callable[[float], float]":
|
||||
"""Smooth 0->1 distance curve whose speed peaks at ``peak`` of the time
|
||||
(a time-warped minimum-jerk profile: fast early, long slow tail)."""
|
||||
k = math.log(0.5) / math.log(peak) # u = t**k puts the mid point at peak
|
||||
|
||||
def f(t: float) -> float:
|
||||
u = t ** k
|
||||
return u * u * u * (10 - 15 * u + 6 * u * u)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
def stroke_duration(dist: float, cfg: HumanConfig) -> float:
|
||||
"""Milliseconds for a ``dist`` px stroke (Fitts' law, log-normal spread)."""
|
||||
median = cfg.mouse_time_base + cfg.mouse_time_per_bit * math.log2(1 + dist / 8)
|
||||
return max(FRAME_MS * 2, rand_lognormal(median, cfg.mouse_time_spread))
|
||||
|
||||
|
||||
def plan_stroke(sx: float, sy: float, ex: float, ey: float, cfg: HumanConfig,
|
||||
duration: Optional[float] = None) -> List[Tuple[float, float, float]]:
|
||||
"""Points of one stroke as ``(x, y, ms_since_previous)``; the last point is
|
||||
exactly ``(ex, ey)``. Consecutive points always differ (a real mouse does
|
||||
not report a move without movement)."""
|
||||
dist = math.hypot(ex - sx, ey - sy)
|
||||
if dist < 0.5:
|
||||
return []
|
||||
total = duration if duration is not None else stroke_duration(dist, cfg)
|
||||
n = int(max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(total / FRAME_MS))))
|
||||
n = max(1, n)
|
||||
start, end = Point(sx, sy), Point(ex, ey)
|
||||
cp1, cp2 = _random_control_points(start, end, 0.12)
|
||||
curve = _progress_curve(rand(*_PEAK))
|
||||
# Low-frequency sideways drift (a hand is not a ruler), zero at both ends.
|
||||
ux, uy = (ex - sx) / dist, (ey - sy) / dist
|
||||
amp = min(cfg.mouse_wobble_max, dist * 0.02) * rand(-1, 1)
|
||||
waves = rand(0.5, 1.5)
|
||||
phase = rand(0, math.pi)
|
||||
out: List[Tuple[float, float, float]] = []
|
||||
lx, ly = sx, sy
|
||||
t_prev = 0.0
|
||||
for i in range(1, n + 1):
|
||||
# Frame timing jitters a little; the final event lands on the target.
|
||||
t = min(1.0, (i + (rand(-0.2, 0.2) if i < n else 0)) / n)
|
||||
if i == n:
|
||||
x, y = ex, ey
|
||||
if abs(x - lx) < 1 and abs(y - ly) < 1:
|
||||
if not out:
|
||||
break # already there (sub-pixel move): nothing to report
|
||||
# The last frame already got there: land exactly on it instead.
|
||||
_, _, pdt = out.pop()
|
||||
out.append((x, y, pdt + (t - t_prev) * total))
|
||||
break
|
||||
else:
|
||||
p = _bezier(start, cp1, cp2, end, curve(t))
|
||||
w = amp * math.sin(math.pi * t) * math.sin(phase + 2 * math.pi * waves * t)
|
||||
x, y = round(p.x - uy * w), round(p.y + ux * w)
|
||||
if abs(x - lx) < 1 and abs(y - ly) < 1:
|
||||
continue
|
||||
dt = (t - t_prev) * total
|
||||
t_prev = t
|
||||
out.append((x, y, dt))
|
||||
lx, ly = x, y
|
||||
return out
|
||||
|
||||
|
||||
def plan_move(sx: float, sy: float, ex: float, ey: float,
|
||||
cfg: HumanConfig) -> List[Tuple[float, float, float]]:
|
||||
"""A whole move: main stroke, and sometimes a miss near the target plus a
|
||||
short corrective stroke onto it."""
|
||||
dist = math.hypot(ex - sx, ey - sy)
|
||||
if dist < 0.5:
|
||||
return []
|
||||
if dist > 40 and random.random() < cfg.mouse_overshoot_chance:
|
||||
# Land a little past (or short of) the target, slightly off-line.
|
||||
ang = math.atan2(ey - sy, ex - sx) + rand(-0.25, 0.25)
|
||||
miss = dist * rand(0.01, 0.05) + rand_range(cfg.mouse_overshoot_px)
|
||||
if random.random() < 0.3:
|
||||
miss = -miss # undershoot
|
||||
mx, my = round(ex + math.cos(ang) * miss), round(ey + math.sin(ang) * miss)
|
||||
pts = plan_stroke(sx, sy, mx, my, cfg)
|
||||
corr = math.hypot(ex - mx, ey - my)
|
||||
pause = rand(40, 140)
|
||||
tail = plan_stroke(mx, my, ex, ey, cfg,
|
||||
duration=max(FRAME_MS * 2, rand_lognormal(90 + 25 * math.log2(1 + corr), 0.3)))
|
||||
if tail:
|
||||
x, y, dt = tail[0]
|
||||
tail[0] = (x, y, dt + pause)
|
||||
return pts + tail
|
||||
return plan_stroke(sx, sy, ex, ey, cfg)
|
||||
|
||||
|
||||
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))
|
||||
for x, y, dt in plan_move(start_x, start_y, end_x, end_y, cfg):
|
||||
sleep_ms(dt)
|
||||
raw.move(x, y)
|
||||
|
||||
|
||||
def click_target(box: dict, is_input: bool, cfg: HumanConfig) -> Point:
|
||||
|
||||
@@ -150,14 +150,14 @@ const H = {
|
||||
const e = get(id);
|
||||
try {
|
||||
if (I.checkElementStates) {
|
||||
const r = await I.checkElementStates(e, states);
|
||||
const r = await I.checkElementStates(e, states, true);
|
||||
if (r === undefined) return null;
|
||||
if (r === 'error:notconnected') return { missing: 'attached' };
|
||||
return { missing: r.missingState };
|
||||
}
|
||||
for (const s of states) {
|
||||
if (s === 'stable') continue;
|
||||
const r = I.elementState(e, s);
|
||||
const r = I.elementState(e, s, true);
|
||||
if (r === 'error:notconnected' || r.received === 'error:notconnected') return { missing: 'attached' };
|
||||
if (!(r.matches !== undefined ? r.matches : r)) return { missing: s };
|
||||
}
|
||||
@@ -184,11 +184,24 @@ const H = {
|
||||
bl: parseFloat(st.borderLeftWidth) || 0, bt: parseFloat(st.borderTopWidth) || 0 };
|
||||
},
|
||||
scroller(id) {
|
||||
// Walk the containing-block chain, not plain DOM ancestors: overflow only
|
||||
// clips descendants whose containing block lies inside the scroller. A
|
||||
// fixed box escapes every ancestor that does not establish a containing
|
||||
// block for it; an absolute box escapes static ancestors (#593).
|
||||
const e = get(id);
|
||||
const none = (v) => !v || v === 'none';
|
||||
const fixedCB = (s) => !none(s.transform) || !none(s.translate) || !none(s.rotate) || !none(s.scale) ||
|
||||
!none(s.perspective) || !none(s.filter) || !none(s.backdropFilter) ||
|
||||
/layout|paint|strict|content/.test(s.contain || '') ||
|
||||
/transform|perspective|filter/.test(s.willChange || '') || /size/.test(s.containerType || '');
|
||||
const b = e.getBoundingClientRect();
|
||||
const top = document.scrollingElement || document.documentElement;
|
||||
let pos = getComputedStyle(e).position;
|
||||
for (let p = parentOrHost(e); p && p !== top && p !== document.body; p = parentOrHost(p)) {
|
||||
const s = getComputedStyle(p);
|
||||
if (pos === 'fixed' && !fixedCB(s)) continue;
|
||||
if (pos === 'absolute' && s.position === 'static' && !fixedCB(s)) continue;
|
||||
pos = s.position;
|
||||
const sy = /(auto|scroll|overlay)/.test(s.overflowY) && p.scrollHeight > p.clientHeight;
|
||||
const sx = /(auto|scroll|overlay)/.test(s.overflowX) && p.scrollWidth > p.clientWidth;
|
||||
if (!sy && !sx) continue;
|
||||
|
||||
+24
-29
@@ -362,7 +362,7 @@ var page = await ctx.NewPageAsync();
|
||||
| `NoViewport` | `bool` | `false` | disable viewport emulation (track the real window) |
|
||||
| `ColorScheme` | `string` | - | `light` / `dark` |
|
||||
| `LicenseKey` | `string` | `null` | CloakBrowser Pro key (or `CLOAKBROWSER_LICENSE_KEY` env / `~/.cloakbrowser/license.key`) |
|
||||
| `BrowserVersion` | `string` | `null` | Pin an exact Chromium version (e.g. `"154.0.8037.57.1"`). Also reads from `CLOAKBROWSER_VERSION` env var. Works with Free and Pro. |
|
||||
| `BrowserVersion` | `string` | `null` | Pin an exact Chromium version (e.g. `"154.0.8037.57.1"`). Also reads from `CLOAKBROWSER_VERSION` env var. Paid Pro key only. |
|
||||
| `ReleaseChannel` | `string` | `null` (`stable`) | Set to `"preview"` to opt into the Pro Preview binary channel. Also reads from `CLOAKBROWSER_RELEASE_CHANNEL`. |
|
||||
|
||||
### CloakBrowser Pro
|
||||
@@ -408,16 +408,9 @@ Or set `CLOAKBROWSER_RELEASE_CHANNEL=preview` for all launches and CLI commands.
|
||||
|
||||
### Rolling back to a previous binary version
|
||||
|
||||
Pin an exact Chromium version without downgrading the wrapper — works for Free and Pro:
|
||||
Pin an exact Chromium version without downgrading the wrapper — paid Pro key only. A free GitHub key cannot pin: it ignores the pin and always gets the latest build.
|
||||
|
||||
```csharp
|
||||
// Free — pin a public release
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
BrowserVersion = "146.0.7680.177.5",
|
||||
});
|
||||
|
||||
// Pro — pin a specific Pro version
|
||||
await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions
|
||||
{
|
||||
LicenseKey = "cb_xxxxxxxx",
|
||||
@@ -439,28 +432,28 @@ keys. Ranges are `(min, max)` and may be passed as a `(double,double)` tuple or
|
||||
|
||||
| Group | Field (PascalCase) | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| Keyboard | `TypingDelay` | `70` | base inter-key delay (ms) |
|
||||
| | `TypingDelaySpread` | `40` | ± jitter around `TypingDelay` |
|
||||
| | `TypingPauseChance` | `0.1` | chance of a longer "thinking" pause |
|
||||
| | `TypingPauseRange` | `(400, 1000)` | thinking-pause duration (ms) |
|
||||
| | `ShiftDownDelay` / `ShiftUpDelay` | `(30,70)` / `(20,50)` | shift key timing |
|
||||
| | `KeyHold` | `(15, 35)` | key-down hold time (ms) |
|
||||
| Mistype | `MistypeChance` | `0.02` | chance of a fat-finger typo (ASCII alnum only) |
|
||||
| Keyboard | `TypingDelay` | `150` | median gap between key presses (ms), adjusted per key pair |
|
||||
| | `TypingDelaySpread` | `70` | spread of the gap around `TypingDelay` |
|
||||
| | `TypingPauseChance` | `0.15` | chance of a longer "thinking" pause before a word |
|
||||
| | `TypingPauseRange` | `(300, 1000)` | thinking-pause duration (ms) |
|
||||
| | `ShiftDownDelay` / `ShiftUpDelay` | `(60,220)` / `(-60,30)` | Shift down before the letter / Shift up relative to the letter's release (negative = Shift first) |
|
||||
| | `KeyHold` | `(60, 175)` | key-down hold time (ms, p5-p95 of a log-normal) |
|
||||
| Mistype | `MistypeChance` | `0.04` | chance of a fat-finger typo (ASCII alnum only) |
|
||||
| | `MistypeDelayNotice` | `(100, 300)` | pause before noticing the typo |
|
||||
| | `MistypeDelayCorrect` | `(50, 150)` | pause after correcting |
|
||||
| | `FieldSwitchDelay` | `(800, 1500)` | delay when moving between fields |
|
||||
| Mouse - move | `MouseStepsDivisor` | `8` | distance ÷ divisor = step count |
|
||||
| | `MouseMinSteps` / `MouseMaxSteps` | `25` / `80` | clamp on step count |
|
||||
| Mouse - move | `MouseTimeBase` / `MouseTimePerBit` | `160` / `75` | Fitts-law move time: base + per_bit × log2(1 + distance / 8) (ms) |
|
||||
| | `MouseTimeSpread` | `0.3` | log-normal spread of the move time |
|
||||
| | `MouseMinSteps` / `MouseMaxSteps` | `2` / `200` | clamp on pointer events per stroke (one per display frame) |
|
||||
| | `MouseWobbleMax` | `1.5` | perpendicular wobble amplitude (px) |
|
||||
| | `MouseOvershootChance` | `0.15` | chance of overshoot + correction |
|
||||
| | `MouseOvershootPx` | `(3, 6)` | overshoot distance |
|
||||
| | `MouseBurstSize` / `MouseBurstPause` | `(3,5)` / `(8,18)` | move-burst grouping + pause |
|
||||
| | `MouseOvershootChance` | `0.35` | chance the stroke lands slightly off and a short second stroke corrects it |
|
||||
| | `MouseOvershootPx` | `(2, 6)` | extra miss distance on top of a few % of the move |
|
||||
| Mouse - click | `ClickAimDelayInput` / `...Button` | `(60,140)` / `(80,200)` | aim delay before pressing |
|
||||
| | `ClickHoldInput` / `...Button` | `(40,100)` / `(60,150)` | mouse-down hold time |
|
||||
| | `ClickInputXRange` | `(0.05, 0.30)` | left-biased X target inside inputs |
|
||||
| Mouse - idle | `IdleDriftPx` | `3` | idle drift amplitude |
|
||||
| | `IdlePauseRange` | `(300, 1000)` | idle pause between drifts |
|
||||
| Scroll | `ScrollDeltaBase` | `(80, 130)` | wheel delta per microstep |
|
||||
| Scroll | `ScrollNotchPx` | `0` | CSS px per wheel notch; 0 = the persona's platform (Windows 100, macOS 40, Linux 120) |
|
||||
| | `ScrollOvershootChance` | `0.1` | chance to overshoot then settle |
|
||||
| | `ScrollSettleDelay` | `(300, 600)` | pause after reaching the target |
|
||||
| | `ScrollTargetZone` | `(0.20, 0.80)` | viewport band the element lands in |
|
||||
@@ -491,15 +484,17 @@ overrides return a clone, and non-overridden fields are preserved.
|
||||
`HumanMouse.HumanMoveAsync` traces a **cubic Bezier curve** between the current
|
||||
cursor position and a randomized aim point inside the target's bounding box:
|
||||
|
||||
1. Step count scales with distance (`dist / MouseStepsDivisor`, clamped to
|
||||
`[MouseMinSteps, MouseMaxSteps]`).
|
||||
1. Move time follows Fitts' law (`MouseTimeBase` + `MouseTimePerBit` ×
|
||||
log2(1 + distance / 8), log-normal spread `MouseTimeSpread`); one pointer
|
||||
event per display frame, clamped to `[MouseMinSteps, MouseMaxSteps]`.
|
||||
2. Two control points are biased **perpendicular** to the straight path, so the
|
||||
curve is never a straight line.
|
||||
3. Each step adds **sinusoidal wobble** (max amplitude `MouseWobbleMax`).
|
||||
4. Motion is **eased** (cubic ease-in-out) and grouped into bursts with short
|
||||
pauses (`MouseBurstSize` / `MouseBurstPause`).
|
||||
5. With probability `MouseOvershootChance`, the cursor overshoots the target and
|
||||
corrects back - exactly like a real hand.
|
||||
4. Speed follows a minimum-jerk profile that peaks early (about the first
|
||||
third of the move), as in recorded human strokes; no event reports zero
|
||||
movement.
|
||||
5. With probability `MouseOvershootChance`, the stroke lands slightly off and a
|
||||
short corrective stroke finishes the move.
|
||||
|
||||
Click points are computed by `HumanMouse.ClickTarget`: inputs get a left-biased X
|
||||
and wide Y band; buttons get a centered cluster.
|
||||
@@ -628,7 +623,7 @@ Same set as the Python wrapper:
|
||||
| `CLOAKBROWSER_DOWNLOAD_URL` | Override the download URL |
|
||||
| `CLOAKBROWSER_AUTO_UPDATE` | Enable/disable background auto-update |
|
||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | Skip SHA-256 verification |
|
||||
| `CLOAKBROWSER_VERSION` | Pin to an exact Chromium version for rollback (e.g. `154.0.8037.57.1`). Works with Free and Pro binaries |
|
||||
| `CLOAKBROWSER_VERSION` | Pin to an exact Chromium version for rollback (e.g. `154.0.8037.57.1`). Paid Pro key only |
|
||||
| `CLOAKBROWSER_RELEASE_CHANNEL` | Set to `preview` to opt into the Pro Preview binary channel (default: `stable`) |
|
||||
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | GeoIP HTTP timeout |
|
||||
| `CLOAKBROWSER_WIDEVINE_CDM` / `CLOAKBROWSER_WIDEVINE` | Widevine seeding control |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<!-- NuGet packaging metadata -->
|
||||
<PackageId>CloakBrowser</PackageId>
|
||||
<Version>0.6.1</Version>
|
||||
<Version>0.6.3</Version>
|
||||
<Authors>CloakHQ</Authors>
|
||||
<Description>Stealth Chromium that passes every bot detection test. Drop-in Playwright (.NET) replacement with source-level fingerprint patches.</Description>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace CloakBrowser;
|
||||
public static class CloakVersion
|
||||
{
|
||||
/// <summary>The CloakBrowser .NET wrapper version.</summary>
|
||||
public const string Version = "0.6.1";
|
||||
public const string Version = "0.6.3";
|
||||
}
|
||||
@@ -235,7 +235,8 @@ public static class Download
|
||||
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
var info = License.ValidateLicense(key);
|
||||
// Sync HTTP on a cache miss: run it off the caller's thread (e.g. a UI thread).
|
||||
var info = await Task.Run(() => License.ValidateLicense(key)).ConfigureAwait(false);
|
||||
if (info != null && info.Valid)
|
||||
{
|
||||
// Free tier always gets the latest build. Drop any version pin: the
|
||||
@@ -454,7 +455,7 @@ public static class Download
|
||||
|
||||
// GetProLatestVersion() is rate-limited to one network call per hour and returns a
|
||||
// cached string in between, so this foreground check stays cheap steady-state.
|
||||
var latest = License.GetProLatestVersion(releaseChannel);
|
||||
var latest = await Task.Run(() => License.GetProLatestVersion(releaseChannel)).ConfigureAwait(false);
|
||||
|
||||
// Tell the user when they asked for Preview but the server has no preview build
|
||||
// for this platform, so the Stable build is used instead. The lookup is a cache
|
||||
@@ -462,7 +463,7 @@ public static class Download
|
||||
// network on the once-an-hour refresh.
|
||||
if (Config.NormalizeReleaseChannel(releaseChannel) == "preview")
|
||||
{
|
||||
var release = License.GetProLatestRelease(releaseChannel);
|
||||
var release = await Task.Run(() => License.GetProLatestRelease(releaseChannel)).ConfigureAwait(false);
|
||||
if (release is { Fallback: true })
|
||||
WarnPreviewFallback();
|
||||
}
|
||||
@@ -1246,7 +1247,7 @@ public static class Download
|
||||
public static async Task<string?> CheckForProUpdateAsync(
|
||||
string licenseKey, CancellationToken ct = default, string? releaseChannel = null)
|
||||
{
|
||||
var latest = License.GetProLatestVersion(releaseChannel);
|
||||
var latest = await Task.Run(() => License.GetProLatestVersion(releaseChannel)).ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(latest)) return null;
|
||||
|
||||
var effective = Config.GetEffectiveVersion(pro: true, releaseChannel: releaseChannel);
|
||||
|
||||
@@ -74,6 +74,7 @@ public static class GeoIp
|
||||
["CM"] = "fr-CM", ["AO"] = "pt-AO", ["MZ"] = "pt-MZ", ["ZM"] = "en-ZM", ["ZW"] = "en-ZW",
|
||||
["HN"] = "es-HN", ["NI"] = "es-NI", ["SV"] = "es-SV", ["PA"] = "es-PA", ["JM"] = "en-JM",
|
||||
["TT"] = "en-TT", ["PR"] = "es-PR",
|
||||
["SC"] = "en-SC",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -130,6 +131,12 @@ public static class GeoIp
|
||||
var timezone = resp.Location?.TimeZone;
|
||||
var country = resp.Country?.IsoCode;
|
||||
string? locale = country != null && CountryLocaleMap.TryGetValue(country, out var l) ? l : null;
|
||||
if (locale == null)
|
||||
{
|
||||
// Map gap, not a lookup failure — don't abort the launch over it
|
||||
CloakLog.Warning("GeoIP: no locale for country {0} (IP {1}), using en-US", country, ip);
|
||||
locale = "en-US";
|
||||
}
|
||||
CloakLog.Debug("GeoIP: {0} -> tz={1}, country={2}, locale={3}", ip, timezone, country, locale);
|
||||
return (timezone, locale, ip);
|
||||
}
|
||||
|
||||
@@ -21,31 +21,52 @@ public enum HumanPreset
|
||||
/// </summary>
|
||||
public sealed class HumanConfig
|
||||
{
|
||||
// Keyboard
|
||||
public double TypingDelay { get; set; } = 70;
|
||||
public double TypingDelaySpread { get; set; } = 40;
|
||||
public double TypingPauseChance { get; set; } = 0.1;
|
||||
public Range TypingPauseRange { get; set; } = (400, 1000);
|
||||
public Range ShiftDownDelay { get; set; } = (30, 70);
|
||||
public Range ShiftUpDelay { get; set; } = (20, 50);
|
||||
public Range KeyHold { get; set; } = (15, 35);
|
||||
// Keyboard. Defaults follow the 136M Keystrokes study (Dhakal et al.,
|
||||
// CHI 2018): keys are held ~100 ms, ~150 ms pass between key presses, and
|
||||
// the next key is often pressed before the previous one is released.
|
||||
//
|
||||
// TypingDelay: median time from one key press to the next (ms); the gap is
|
||||
// log-normal and depends on the letters (other hand: faster, same finger:
|
||||
// slower, after a space: slower).
|
||||
public double TypingDelay { get; set; } = 150;
|
||||
/// <summary>How much the gap varies (ms); 0 = always exactly <see cref="TypingDelay"/>.</summary>
|
||||
public double TypingDelaySpread { get; set; } = 70;
|
||||
/// <summary>Chance of a thinking pause before a word (a tenth of it inside a word).</summary>
|
||||
public double TypingPauseChance { get; set; } = 0.15;
|
||||
public Range TypingPauseRange { get; set; } = (300, 1000);
|
||||
/// <summary>Shift press -> letter press (ms, log-normal: ~5th..95th percentile).</summary>
|
||||
public Range ShiftDownDelay { get; set; } = (60, 220);
|
||||
/// <summary>Shift release relative to the letter release (ms). Negative = Shift goes up
|
||||
/// first, which is what people usually do.</summary>
|
||||
public Range ShiftUpDelay { get; set; } = (-60, 30);
|
||||
/// <summary>How long a key is held down (ms, log-normal: ~5th..95th percentile).</summary>
|
||||
public Range KeyHold { get; set; } = (60, 175);
|
||||
|
||||
// Mistype (typo simulation)
|
||||
public double MistypeChance { get; set; } = 0.02;
|
||||
// Mistype (typo simulation): chance per letter / digit.
|
||||
public double MistypeChance { get; set; } = 0.04;
|
||||
public Range MistypeDelayNotice { get; set; } = (100, 300);
|
||||
public Range MistypeDelayCorrect { get; set; } = (50, 150);
|
||||
|
||||
public Range FieldSwitchDelay { get; set; } = (800, 1500);
|
||||
|
||||
// Mouse - movement
|
||||
public double MouseStepsDivisor { get; set; } = 8;
|
||||
public int MouseMinSteps { get; set; } = 25;
|
||||
public int MouseMaxSteps { get; set; } = 80;
|
||||
// Mouse - movement. A move lasts (Fitts' law, log-normal spread)
|
||||
// median = MouseTimeBase + MouseTimePerBit * log2(1 + distance / 8) ms;
|
||||
// the speed peaks early (a fast main stroke, then a slow approach) and
|
||||
// pointer events go out at ~60 Hz.
|
||||
public double MouseTimeBase { get; set; } = 160;
|
||||
public double MouseTimePerBit { get; set; } = 75;
|
||||
public double MouseTimeSpread { get; set; } = 0.3;
|
||||
/// <summary>Fewest pointer events per stroke.</summary>
|
||||
public int MouseMinSteps { get; set; } = 2;
|
||||
/// <summary>Most pointer events per stroke.</summary>
|
||||
public int MouseMaxSteps { get; set; } = 200;
|
||||
/// <summary>Smooth sideways drift (px).</summary>
|
||||
public double MouseWobbleMax { get; set; } = 1.5;
|
||||
public double MouseOvershootChance { get; set; } = 0.15;
|
||||
public Range MouseOvershootPx { get; set; } = (3, 6);
|
||||
public Range MouseBurstSize { get; set; } = (3, 5);
|
||||
public Range MouseBurstPause { get; set; } = (8, 18);
|
||||
/// <summary>Chance that the main stroke lands a little off and a short corrective
|
||||
/// stroke finishes the move (the miss is a few % of the distance plus
|
||||
/// <see cref="MouseOvershootPx"/>).</summary>
|
||||
public double MouseOvershootChance { get; set; } = 0.35;
|
||||
public Range MouseOvershootPx { get; set; } = (2, 6);
|
||||
|
||||
// Mouse - clicks
|
||||
public Range ClickAimDelayInput { get; set; } = (60, 140);
|
||||
@@ -58,9 +79,11 @@ public sealed class HumanConfig
|
||||
public double IdleDriftPx { get; set; } = 3;
|
||||
public Range IdlePauseRange { get; set; } = (300, 1000);
|
||||
|
||||
// Scroll
|
||||
public Range ScrollDeltaBase { get; set; } = (80, 130);
|
||||
public double ScrollDeltaVariance { get; set; } = 0.2;
|
||||
// Scroll. The wheel turns in whole notches: one wheel event per notch of
|
||||
// ScrollNotchPx CSS pixels, so deltaY and wheelDeltaY agree the way they
|
||||
// do for a real mouse wheel. 0 = what Chrome uses on the persona's
|
||||
// platform (100 on Windows, 40 on macOS, 120 on Linux).
|
||||
public double ScrollNotchPx { get; set; } = 0;
|
||||
public Range ScrollPauseFast { get; set; } = (30, 80);
|
||||
public Range ScrollPauseSlow { get; set; } = (80, 200);
|
||||
public Range ScrollAccelSteps { get; set; } = (2, 3);
|
||||
@@ -89,17 +112,20 @@ public static class HumanConfigFactory
|
||||
private static HumanConfig CarefulConfig() => new()
|
||||
{
|
||||
// Keyboard - slower typing
|
||||
TypingDelay = 100,
|
||||
TypingDelaySpread = 50,
|
||||
TypingPauseChance = 0.15,
|
||||
TypingDelay = 210,
|
||||
TypingDelaySpread = 100,
|
||||
TypingPauseChance = 0.25,
|
||||
TypingPauseRange = (500, 1200),
|
||||
ShiftDownDelay = (40, 90),
|
||||
ShiftUpDelay = (30, 70),
|
||||
KeyHold = (20, 45),
|
||||
ShiftDownDelay = (80, 260),
|
||||
ShiftUpDelay = (-50, 40),
|
||||
KeyHold = (70, 190),
|
||||
FieldSwitchDelay = (1000, 2000),
|
||||
MistypeChance = 0.05,
|
||||
// Mouse - slower, more precise
|
||||
MouseOvershootChance = 0.10,
|
||||
MouseBurstPause = (12, 25),
|
||||
MouseTimeBase = 220,
|
||||
MouseTimePerBit = 95,
|
||||
MouseOvershootChance = 0.45,
|
||||
MouseOvershootPx = (2, 5),
|
||||
// Mouse - clicks (longer aiming and holding)
|
||||
ClickAimDelayInput = (80, 180),
|
||||
ClickAimDelayButton = (120, 280),
|
||||
|
||||
@@ -73,11 +73,11 @@ internal sealed partial class HumanEngine
|
||||
if (S(info, "tag") == "input")
|
||||
{
|
||||
if (await World.CallAsync(r.Frame, "caretAtEnd", r.Id).ConfigureAwait(false) is not { ValueKind: JsonValueKind.True })
|
||||
await _raw.KeyPressAsync("End").ConfigureAwait(false);
|
||||
await PressKeyAsync("End").ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _raw.KeyPressAsync("Control+End").ConfigureAwait(false);
|
||||
await PressKeyAsync("Control+End").ConfigureAwait(false);
|
||||
}
|
||||
await Sleep(R(20, 60)).ConfigureAwait(false);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ internal sealed partial class HumanEngine
|
||||
var d = DeadlineFor(t, o);
|
||||
await FocusElementAsync(t, o, api, d, requireFocus: false).ConfigureAwait(false);
|
||||
await Sleep(R(50, 150)).ConfigureAwait(false);
|
||||
await _raw.KeyPressAsync(await KeysAsync(key).ConfigureAwait(false), o.Delay).ConfigureAwait(false);
|
||||
await PressKeyAsync(await KeysAsync(key).ConfigureAwait(false), null, o.Delay).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task TypeAsync(Target t, string text, ActOpts o, string api)
|
||||
@@ -129,9 +129,9 @@ internal sealed partial class HumanEngine
|
||||
info = await InfoAsync(r).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(S(info, "value")))
|
||||
{
|
||||
await _raw.KeyPressAsync(await SelectAllKeyAsync().ConfigureAwait(false)).ConfigureAwait(false);
|
||||
await PressKeyAsync(await SelectAllKeyAsync().ConfigureAwait(false)).ConfigureAwait(false);
|
||||
await Sleep(R(30, 80)).ConfigureAwait(false);
|
||||
await _raw.KeyPressAsync("Backspace").ConfigureAwait(false);
|
||||
await PressKeyAsync("Backspace").ConfigureAwait(false);
|
||||
await Sleep(R(50, 150)).ConfigureAwait(false);
|
||||
info = await InfoAsync(r).ConfigureAwait(false);
|
||||
var left = S(info, "value");
|
||||
@@ -141,7 +141,7 @@ internal sealed partial class HumanEngine
|
||||
await CaretToEndAsync(r).ConfigureAwait(false);
|
||||
for (int i = 0; i < left.Length; i++)
|
||||
{
|
||||
await _raw.KeyPressAsync("Backspace").ConfigureAwait(false);
|
||||
await PressKeyAsync("Backspace").ConfigureAwait(false);
|
||||
await Sleep(R(15, 40)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ internal sealed partial class HumanEngine
|
||||
int diff = int.Parse(parts["month"]) - int.Parse(current["month"]);
|
||||
for (int k = 0; k < Math.Abs(diff); k++)
|
||||
{
|
||||
await _raw.KeyPressAsync(diff > 0 ? "ArrowUp" : "ArrowDown").ConfigureAwait(false);
|
||||
await PressKeyAsync(diff > 0 ? "ArrowUp" : "ArrowDown").ConfigureAwait(false);
|
||||
await Sleep(R(60, 140)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -211,11 +211,11 @@ internal sealed partial class HumanEngine
|
||||
await TypeCharAsync(digits[j], cfg).ConfigureAwait(false);
|
||||
// The editor forgets a partial entry after ~1 s without a key.
|
||||
if (j < digits.Length - 1)
|
||||
await Sleep(Math.Min(450, Math.Max(40, cfg.TypingDelay + (HumanRandom.NextDouble() - 0.5) * 2 * cfg.TypingDelaySpread))).ConfigureAwait(false);
|
||||
await Sleep(Math.Min(450, Math.Max(40, KeyTiming.KeyGap(cfg, digits[j].ToString(), digits[j + 1].ToString()) - KeyTiming.KeyHold(cfg)))).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
if (i < segs.Count - 1 && !Fields.SegmentAutoAdvances(kind, parts, inputType))
|
||||
await _raw.KeyPressAsync("ArrowRight").ConfigureAwait(false);
|
||||
await PressKeyAsync("ArrowRight").ConfigureAwait(false);
|
||||
await Sleep(R(60, 160)).ConfigureAwait(false);
|
||||
}
|
||||
var got = await ValueAsync(r).ConfigureAwait(false);
|
||||
@@ -241,7 +241,7 @@ internal sealed partial class HumanEngine
|
||||
double cur = await Read().ConfigureAwait(false);
|
||||
if (Math.Abs(cur - want) < 1e-9 || (step > 0 && Math.Abs(cur - want) < step / 2)) break;
|
||||
bool up = cur < want;
|
||||
await _raw.KeyPressAsync(!rtl ? (up ? "ArrowRight" : "ArrowLeft") : (up ? "ArrowLeft" : "ArrowRight")).ConfigureAwait(false);
|
||||
await PressKeyAsync(!rtl ? (up ? "ArrowRight" : "ArrowLeft") : (up ? "ArrowLeft" : "ArrowRight")).ConfigureAwait(false);
|
||||
await Sleep(R(40, 110)).ConfigureAwait(false);
|
||||
if (await Read().ConfigureAwait(false) == cur) break; // min/max reached
|
||||
}
|
||||
@@ -274,7 +274,17 @@ internal sealed partial class HumanEngine
|
||||
await SelectDropdownAsync(t, r, targets.Count > 0 ? targets[0] : null, plan, o, api, d).ConfigureAwait(false);
|
||||
else
|
||||
await SelectListboxAsync(t, r, targets, o, api, cfg, d).ConfigureAwait(false);
|
||||
var state = (await World.CallAsync(r.Frame, "selectState", r.Id).ConfigureAwait(false))!.Value;
|
||||
JsonElement state;
|
||||
try
|
||||
{
|
||||
state = (await World.CallAsync(r.Frame, "selectState", r.Id).ConfigureAwait(false))!.Value;
|
||||
}
|
||||
catch (StaleElementException)
|
||||
{
|
||||
// The pick navigated the page (a form submitted on change): nothing
|
||||
// left to read back, so report what was chosen, as Playwright does.
|
||||
return values;
|
||||
}
|
||||
var selected = state.GetProperty("selected").EnumerateArray().Select(x => x.GetInt32()).OrderBy(x => x).ToList();
|
||||
var got = state.GetProperty("values").EnumerateArray().Select(x => x.GetString()!).ToList();
|
||||
if (!selected.SequenceEqual(targets.OrderBy(x => x)))
|
||||
@@ -329,7 +339,7 @@ internal sealed partial class HumanEngine
|
||||
{
|
||||
if (d.Expired)
|
||||
{
|
||||
await _raw.KeyPressAsync("Escape").ConfigureAwait(false);
|
||||
await PressKeyAsync("Escape").ConfigureAwait(false);
|
||||
throw new System.TimeoutException($"{api}: Timeout {Math.Round(d.Timeout)}ms exceeded.\n"
|
||||
+ $"Call log:\n - choosing an option of {t.Description}");
|
||||
}
|
||||
@@ -342,12 +352,12 @@ internal sealed partial class HumanEngine
|
||||
}
|
||||
else
|
||||
{
|
||||
await _raw.KeyPressAsync(k).ConfigureAwait(false);
|
||||
await PressKeyAsync(k).ConfigureAwait(false);
|
||||
await Sleep(R(90, 200)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
await Sleep(R(100, 250)).ConfigureAwait(false);
|
||||
await _raw.KeyPressAsync("Enter").ConfigureAwait(false);
|
||||
await PressKeyAsync("Enter").ConfigureAwait(false);
|
||||
if (typed) _typeAheadUntil = DateTime.UtcNow.AddMilliseconds(1100);
|
||||
await Sleep(R(80, 160)).ConfigureAwait(false);
|
||||
}
|
||||
@@ -369,7 +379,7 @@ internal sealed partial class HumanEngine
|
||||
{
|
||||
["type"] = "keyDown", ["key"] = ch.ToString(), ["text"] = ch.ToString(), ["unmodifiedText"] = ch.ToString(),
|
||||
}).ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await Sleep(KeyTiming.KeyHold(cfg)).ConfigureAwait(false);
|
||||
await session.SendAsync("Input.dispatchKeyEvent", new() { ["type"] = "keyUp", ["key"] = ch.ToString() }).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -461,34 +471,41 @@ internal sealed partial class HumanEngine
|
||||
string? tag = info is JsonElement i ? S(i, "tag") : null;
|
||||
bool mistypes = cfg.MistypeChance > 0 && tag != null && Fields.AllowsMistype(tag, S(info!.Value, "type"));
|
||||
var elements = System.Globalization.StringInfo.GetTextElementEnumerator(text);
|
||||
var chars = new List<string>();
|
||||
while (elements.MoveNext()) chars.Add((string)elements.Current);
|
||||
for (int k = 0; k < chars.Count; k++)
|
||||
var typist = NewTypist(cfg, delay);
|
||||
try
|
||||
{
|
||||
var ch = chars[k];
|
||||
if (!IsAscii(ch) || ch.Length != 1)
|
||||
while (elements.MoveNext())
|
||||
{
|
||||
await Sleep(RR(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await _raw.InsertTextAsync(ch).ConfigureAwait(false);
|
||||
var ch = (string)elements.Current;
|
||||
if (ch.Length == 1 && IsAscii(ch) && char.IsLetterOrDigit(ch[0]) && mistypes
|
||||
&& HumanRandom.NextDouble() < cfg.MistypeChance)
|
||||
await TypoAsync(typist, ch[0], cfg, frame).ConfigureAwait(false);
|
||||
await typist.KeyAsync(ch).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
char c = ch[0];
|
||||
if (mistypes && char.IsLetterOrDigit(c) && HumanRandom.NextDouble() < cfg.MistypeChance)
|
||||
await TypoAsync(c, cfg, frame).ConfigureAwait(false);
|
||||
await TypeCharAsync(c, cfg).ConfigureAwait(false);
|
||||
}
|
||||
if (k < chars.Count - 1) await BetweenKeysAsync(cfg, delay).ConfigureAwait(false);
|
||||
await typist.FinishAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await typist.ReleaseAllAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TypoAsync(char ch, HumanConfig cfg, IFrame frame)
|
||||
private Typist NewTypist(HumanConfig cfg, double? delay = null) => new(
|
||||
k => _raw.KeyDownAsync(k),
|
||||
k => _raw.KeyUpAsync(k),
|
||||
t => _raw.InsertTextAsync(t),
|
||||
async p => await (await World.SessionAsync().ConfigureAwait(false)).SendAsync("Input.dispatchKeyEvent", p).ConfigureAwait(false),
|
||||
cfg, delay);
|
||||
|
||||
private async Task TypoAsync(Typist typist, char ch, HumanConfig cfg, IFrame frame)
|
||||
{
|
||||
if (!HumanKeyboard.NearbyKeys.TryGetValue(char.ToLowerInvariant(ch), out var near) || near.Length == 0) return;
|
||||
char wrong = HumanRandom.Choice(near);
|
||||
if (IsUpper(ch)) wrong = char.ToUpperInvariant(wrong);
|
||||
await typist.FinishAsync().ConfigureAwait(false);
|
||||
var before = await ActiveValueAsync(frame).ConfigureAwait(false);
|
||||
await TypeCharAsync(wrong, cfg).ConfigureAwait(false);
|
||||
await typist.KeyAsync(wrong.ToString()).ConfigureAwait(false);
|
||||
await typist.RestAsync().ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.MistypeDelayNotice)).ConfigureAwait(false);
|
||||
var after = await ActiveValueAsync(frame).ConfigureAwait(false);
|
||||
if (before != null && after == before)
|
||||
@@ -497,9 +514,8 @@ internal sealed partial class HumanEngine
|
||||
await Sleep(RR(cfg.MistypeDelayCorrect)).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
await _raw.KeyDownAsync("Backspace").ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await _raw.KeyUpAsync("Backspace").ConfigureAwait(false);
|
||||
await typist.KeyAsync("Backspace").ConfigureAwait(false);
|
||||
await typist.RestAsync().ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.MistypeDelayCorrect)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -509,52 +525,25 @@ internal sealed partial class HumanEngine
|
||||
catch (PlaywrightException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>One key on its own (date editors, type-ahead).</summary>
|
||||
public async Task TypeCharAsync(char ch, HumanConfig cfg)
|
||||
{
|
||||
var key = ch.ToString();
|
||||
if (IsUpper(ch))
|
||||
var typist = NewTypist(cfg);
|
||||
try
|
||||
{
|
||||
await _raw.KeyDownAsync("Shift").ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await _raw.KeyDownAsync(key).ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await _raw.KeyUpAsync(key).ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await _raw.KeyUpAsync("Shift").ConfigureAwait(false);
|
||||
await typist.KeyAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await typist.FinishAsync().ConfigureAwait(false);
|
||||
}
|
||||
else if (HumanKeyboard.ShiftSymbols.Contains(ch))
|
||||
finally
|
||||
{
|
||||
var session = await World.SessionAsync().ConfigureAwait(false);
|
||||
var (code, vk) = HumanKeyboard.ShiftSymbolKey(ch);
|
||||
await _raw.KeyDownAsync("Shift").ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await session.SendAsync("Input.dispatchKeyEvent", new()
|
||||
{
|
||||
["type"] = "keyDown", ["modifiers"] = 8, ["key"] = key, ["code"] = code,
|
||||
["windowsVirtualKeyCode"] = vk, ["text"] = key, ["unmodifiedText"] = key,
|
||||
}).ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await session.SendAsync("Input.dispatchKeyEvent", new()
|
||||
{
|
||||
["type"] = "keyUp", ["modifiers"] = 8, ["key"] = key, ["code"] = code, ["windowsVirtualKeyCode"] = vk,
|
||||
}).ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await _raw.KeyUpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _raw.KeyDownAsync(key).ConfigureAwait(false);
|
||||
await Sleep(RR(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await _raw.KeyUpAsync(key).ConfigureAwait(false);
|
||||
await typist.ReleaseAllAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static Task BetweenKeysAsync(HumanConfig cfg, float? delay)
|
||||
{
|
||||
if (delay != null) return Sleep(delay.Value);
|
||||
if (HumanRandom.NextDouble() < cfg.TypingPauseChance) return Sleep(RR(cfg.TypingPauseRange));
|
||||
return Sleep(Math.Max(10, cfg.TypingDelay + (HumanRandom.NextDouble() - 0.5) * 2 * cfg.TypingDelaySpread));
|
||||
}
|
||||
/// <summary><c>keyboard.press</c> with a human key-down time (Playwright's own press
|
||||
/// releases the key in the same instant).</summary>
|
||||
public Task PressKeyAsync(string key, HumanConfig? cfg = null, float? delay = null) =>
|
||||
_raw.KeyPressAsync(key, delay ?? (float)KeyTiming.KeyHold(cfg ?? Config));
|
||||
|
||||
/// <summary><c>page.Keyboard.TypeAsync</c>: no target; mistypes only where safe.</summary>
|
||||
public async Task KeyboardTypeAsync(string text, float? delay = null)
|
||||
|
||||
@@ -273,40 +273,27 @@ internal sealed partial class HumanEngine
|
||||
{
|
||||
if (Cursor.Initialized) return;
|
||||
var c = cfg ?? Config;
|
||||
Cursor.X = RR(c.InitialCursorX);
|
||||
Cursor.Y = RR(c.InitialCursorY);
|
||||
Cursor.X = Math.Round(RR(c.InitialCursorX));
|
||||
Cursor.Y = Math.Round(RR(c.InitialCursorY));
|
||||
await _raw.MoveAsync(Cursor.X, Cursor.Y).ConfigureAwait(false);
|
||||
Cursor.Initialized = true;
|
||||
}
|
||||
|
||||
/// <summary>Bezier move from the current cursor position to (x, y), ending exactly there.</summary>
|
||||
/// <summary>Move from the current cursor position to (x, y), ending exactly there: an
|
||||
/// early-peaking main stroke timed by Fitts' law, sometimes a short corrective stroke, one
|
||||
/// pointer event per frame and none without movement.</summary>
|
||||
public async Task MoveToAsync(double x, double y, HumanConfig cfg)
|
||||
{
|
||||
await EnsureCursorAsync(cfg).ConfigureAwait(false);
|
||||
double sx = Cursor.X, sy = Cursor.Y;
|
||||
double dist = Math.Sqrt((x - sx) * (x - sx) + (y - sy) * (y - sy));
|
||||
if (dist >= 1)
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
double clock = 0;
|
||||
foreach (var (px, py, dt) in HumanMouse.PlanMove(Cursor.X, Cursor.Y, x, y, cfg))
|
||||
{
|
||||
int steps = (int)Math.Max(cfg.MouseMinSteps, Math.Min(cfg.MouseMaxSteps, Math.Round(dist / cfg.MouseStepsDivisor)));
|
||||
var (cp1, cp2) = HumanMouse.RandomControlPoints(new Point(sx, sy), new Point(x, y));
|
||||
int burst = 0, burstSize = HumanRandom.RandIntRange(cfg.MouseBurstSize);
|
||||
for (int i = 1; i <= steps; i++)
|
||||
{
|
||||
double progress = (double)i / steps;
|
||||
if (i == steps) { await _raw.MoveAsync(x, y).ConfigureAwait(false); continue; }
|
||||
var pt = HumanMouse.Bezier(new Point(sx, sy), cp1, cp2, new Point(x, y), HumanMouse.EaseInOut(progress));
|
||||
double wobble = Math.Sin(Math.PI * progress) * cfg.MouseWobbleMax;
|
||||
await _raw.MoveAsync(Math.Round(pt.X + (HumanRandom.NextDouble() - 0.5) * 2 * wobble),
|
||||
Math.Round(pt.Y + (HumanRandom.NextDouble() - 0.5) * 2 * wobble)).ConfigureAwait(false);
|
||||
if (++burst >= burstSize) { await Sleep(RR(cfg.MouseBurstPause)).ConfigureAwait(false); burst = 0; }
|
||||
}
|
||||
if (HumanRandom.NextDouble() < cfg.MouseOvershootChance)
|
||||
{
|
||||
double angle = Math.Atan2(y - sy, x - sx), over = RR(cfg.MouseOvershootPx);
|
||||
await _raw.MoveAsync(Math.Round(x + Math.Cos(angle) * over), Math.Round(y + Math.Sin(angle) * over)).ConfigureAwait(false);
|
||||
await Sleep(R(30, 70)).ConfigureAwait(false);
|
||||
await _raw.MoveAsync(x, y).ConfigureAwait(false);
|
||||
}
|
||||
// Keep to the planned schedule: the CDP round trip of each move
|
||||
// comes out of the next wait instead of adding to it.
|
||||
clock += dt;
|
||||
await Sleep(clock - sw.Elapsed.TotalMilliseconds).ConfigureAwait(false);
|
||||
await _raw.MoveAsync(px, py).ConfigureAwait(false);
|
||||
}
|
||||
Cursor.X = x; Cursor.Y = y;
|
||||
}
|
||||
@@ -534,11 +521,20 @@ internal sealed partial class HumanEngine
|
||||
var (vw, vh) = await World.ViewportAsync().ConfigureAwait(false);
|
||||
int stalled = 0, rounds = 0;
|
||||
bool overshot = false;
|
||||
double lastDx = 0, lastDy = 0;
|
||||
double notch = await NotchPxAsync(cfg).ConfigureAwait(false);
|
||||
while (stalled < 2 && rounds < 40 && !d.Expired)
|
||||
{
|
||||
var (dx, dy) = await NeededScrollAsync(r, box, vw, vh, cfg).ConfigureAwait(false);
|
||||
if (Math.Abs(dx) < 1 && Math.Abs(dy) < 1) break;
|
||||
rounds++;
|
||||
// Whole notches can jump over a target whose slack is smaller than
|
||||
// one notch (a short scroll box): after such a reversal, finish with
|
||||
// one wheel event of the exact remainder (a free-spinning or
|
||||
// high-resolution wheel sends those) instead of swinging forever.
|
||||
bool fineY = dy * lastDy < 0 && Math.Abs(dy) < notch;
|
||||
bool fineX = dx * lastDx < 0 && Math.Abs(dx) < notch;
|
||||
lastDx = dx; lastDy = dy;
|
||||
var area = box.Clip.Width > 4 && box.Clip.Height > 4 ? box.Clip : new Rect(0, 0, vw, vh);
|
||||
if (!area.Contains(Cursor.X, Cursor.Y))
|
||||
{
|
||||
@@ -548,14 +544,22 @@ internal sealed partial class HumanEngine
|
||||
double bx = box.X, by = box.Y;
|
||||
if (Math.Abs(dy) >= 1)
|
||||
{
|
||||
await WheelBurstAsync(0, dy, cfg, d).ConfigureAwait(false);
|
||||
if (!overshot)
|
||||
if (fineY) await _raw.WheelAsync(0, Math.Round(dy)).ConfigureAwait(false);
|
||||
else
|
||||
{
|
||||
overshot = true;
|
||||
await OvershootAsync(dy, cfg, d).ConfigureAwait(false);
|
||||
await WheelBurstAsync(0, dy, cfg, d).ConfigureAwait(false);
|
||||
if (!overshot)
|
||||
{
|
||||
overshot = true;
|
||||
await OvershootAsync(dy, cfg, d).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Math.Abs(dx) >= 1) await WheelBurstAsync(dx, 0, cfg, d).ConfigureAwait(false);
|
||||
if (Math.Abs(dx) >= 1)
|
||||
{
|
||||
if (fineX) await _raw.WheelAsync(Math.Round(dx), 0).ConfigureAwait(false);
|
||||
else await WheelBurstAsync(dx, 0, cfg, d).ConfigureAwait(false);
|
||||
}
|
||||
await Sleep(Math.Min(RR(cfg.ScrollSettleDelay), d.Remaining)).ConfigureAwait(false);
|
||||
box = await ElementBoxAsync(r).ConfigureAwait(false);
|
||||
stalled = Math.Abs(box.X - bx) + Math.Abs(box.Y - by) < 1 ? stalled + 1 : 0;
|
||||
@@ -612,41 +616,59 @@ internal sealed partial class HumanEngine
|
||||
{
|
||||
if (HumanRandom.NextDouble() >= cfg.ScrollOvershootChance || d.Expired) return;
|
||||
int sign = dy > 0 ? 1 : -1;
|
||||
await WheelBurstAsync(0, Math.Round(RR(cfg.ScrollOvershootPx)) * sign, cfg, d).ConfigureAwait(false);
|
||||
double notch = await NotchPxAsync(cfg).ConfigureAwait(false);
|
||||
int over = Math.Max(1, (int)Math.Round(RR(cfg.ScrollOvershootPx) / notch));
|
||||
await WheelBurstAsync(0, over * notch * sign, cfg, d).ConfigureAwait(false);
|
||||
await Sleep(Math.Min(RR(cfg.ScrollSettleDelay), d.Remaining)).ConfigureAwait(false);
|
||||
int corrections = HumanRandom.RandInt(1, 2);
|
||||
for (int i = 0; i < corrections && !d.Expired; i++)
|
||||
// Turn back in one or two flicks.
|
||||
int first = over > 1 && HumanRandom.NextDouble() < 0.5 ? HumanRandom.RandInt(1, over) : over;
|
||||
foreach (int n in new[] { first, over - first })
|
||||
{
|
||||
await WheelBurstAsync(0, Math.Round(R(40, 80)) * -sign, cfg, d).ConfigureAwait(false);
|
||||
if (n <= 0 || d.Expired) continue;
|
||||
await WheelBurstAsync(0, n * notch * -sign, cfg, d).ConfigureAwait(false);
|
||||
await Sleep(Math.Min(R(100, 250), d.Remaining)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One logical scroll: accelerate -> cruise -> decelerate in wheel ticks.</summary>
|
||||
/// <summary>CSS pixels one wheel notch scrolls for the persona's platform: Chrome scrolls
|
||||
/// 100 px per notch on Windows (3 lines of 33.3 px), 40 on macOS and 120 on Linux, and
|
||||
/// reports <c>wheelDelta</c> 120 per notch. CDP always reports one notch per wheel event,
|
||||
/// so whole-notch events are the only ones whose <c>deltaY</c> and <c>wheelDeltaY</c> agree.</summary>
|
||||
public async Task<double> NotchPxAsync(HumanConfig cfg)
|
||||
{
|
||||
if (cfg.ScrollNotchPx > 0) return cfg.ScrollNotchPx;
|
||||
var plat = (await PlatformAsync().ConfigureAwait(false)).ToLowerInvariant();
|
||||
if (plat.StartsWith("mac", StringComparison.Ordinal)) return 40;
|
||||
if (plat.StartsWith("win", StringComparison.Ordinal)) return 100;
|
||||
return 120;
|
||||
}
|
||||
|
||||
/// <summary>One logical scroll: flicks of a few wheel notches each, quicker in the middle
|
||||
/// of a flick, with short pauses between flicks. Every event is exactly one notch, so the
|
||||
/// page scrolls by the nearest whole number of notches (the caller re-measures and scrolls
|
||||
/// again when needed).</summary>
|
||||
public async Task WheelBurstAsync(double dx, double dy, HumanConfig cfg, Deadline? d = null)
|
||||
{
|
||||
double total = dy != 0 ? Math.Abs(dy) : Math.Abs(dx);
|
||||
int sign = (dy != 0 ? dy : dx) > 0 ? 1 : -1;
|
||||
double notch = await NotchPxAsync(cfg).ConfigureAwait(false);
|
||||
int notches = Math.Max(1, (int)Math.Round(total / notch));
|
||||
int accel = HumanRandom.RandIntRange(cfg.ScrollAccelSteps), decel = HumanRandom.RandIntRange(cfg.ScrollDecelSteps);
|
||||
double avg = (cfg.ScrollDeltaBase.Min + cfg.ScrollDeltaBase.Max) / 2;
|
||||
int ticks = Math.Max(1, (int)Math.Ceiling(total / avg));
|
||||
double sent = 0;
|
||||
for (int i = 0; i < ticks; i++)
|
||||
int flick = HumanRandom.RandInt(2, 5);
|
||||
for (int i = 0; i < notches; i++)
|
||||
{
|
||||
if (sent >= total || (d?.Expired ?? false)) break;
|
||||
double pause = i < accel || i >= ticks - decel ? RR(cfg.ScrollPauseSlow) : RR(cfg.ScrollPauseFast);
|
||||
double tick = Math.Min(total - sent, RR(cfg.ScrollDeltaBase) * (1 + (HumanRandom.NextDouble() - 0.5) * 2 * cfg.ScrollDeltaVariance));
|
||||
double left = tick;
|
||||
while (left > 0.5)
|
||||
if (d?.Expired ?? false) break;
|
||||
double step = notch * sign;
|
||||
await _raw.WheelAsync(dx != 0 ? step : 0, dy != 0 ? step : 0).ConfigureAwait(false);
|
||||
if (i == notches - 1) break;
|
||||
if (--flick <= 0)
|
||||
{
|
||||
double chunk = Math.Min(left, R(20, 40));
|
||||
double step = Math.Round(chunk) * sign;
|
||||
if (step != 0) await _raw.WheelAsync(dx != 0 ? step : 0, dy != 0 ? step : 0).ConfigureAwait(false);
|
||||
left -= chunk;
|
||||
await Sleep(R(8, 20)).ConfigureAwait(false);
|
||||
// A finger lifts and turns the wheel again.
|
||||
flick = HumanRandom.RandInt(2, 5);
|
||||
await Sleep(RR(cfg.ScrollPauseSlow) + R(40, 120)).ConfigureAwait(false);
|
||||
}
|
||||
sent += tick;
|
||||
await Sleep(pause).ConfigureAwait(false);
|
||||
else if (i < accel || i >= notches - decel) await Sleep(RR(cfg.ScrollPauseSlow)).ConfigureAwait(false);
|
||||
else await Sleep(RR(cfg.ScrollPauseFast)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,29 @@ public static class HumanKeyboard
|
||||
['~'] = 192,
|
||||
};
|
||||
|
||||
/// <summary>Physical key of each unshifted digit / punctuation character, so a shift
|
||||
/// symbol and its base character ("1" and "!") count as the same key.</summary>
|
||||
private static readonly IReadOnlyDictionary<char, string> BaseKeyCodes = new Dictionary<char, string>
|
||||
{
|
||||
['1'] = "Digit1", ['2'] = "Digit2", ['3'] = "Digit3", ['4'] = "Digit4", ['5'] = "Digit5",
|
||||
['6'] = "Digit6", ['7'] = "Digit7", ['8'] = "Digit8", ['9'] = "Digit9", ['0'] = "Digit0",
|
||||
['-'] = "Minus", ['='] = "Equal", ['['] = "BracketLeft", [']'] = "BracketRight",
|
||||
['\\'] = "Backslash", [';'] = "Semicolon", ['\''] = "Quote", [','] = "Comma",
|
||||
['.'] = "Period", ['/'] = "Slash", ['`'] = "Backquote",
|
||||
};
|
||||
|
||||
/// <summary>The physical key that types <paramref name="ch"/>: "x" and "X" are one key,
|
||||
/// so are "1" and "!". Used so a key never goes down while it is still held.</summary>
|
||||
internal static string PhysicalKey(string ch)
|
||||
{
|
||||
if (ch.Length == 1)
|
||||
{
|
||||
if (ShiftSymbolCodes.TryGetValue(ch[0], out var s)) return s;
|
||||
if (BaseKeyCodes.TryGetValue(ch[0], out var b)) return b;
|
||||
}
|
||||
return ch.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>CDP <c>code</c> and Windows virtual key code of a shift symbol's physical key.</summary>
|
||||
internal static (string Code, int KeyCode) ShiftSymbolKey(char ch) =>
|
||||
(ShiftSymbolCodes.TryGetValue(ch, out var c) ? c : "", ShiftSymbolKeyCodes.TryGetValue(ch, out var k) ? k : 0);
|
||||
@@ -133,7 +156,7 @@ public static class HumanKeyboard
|
||||
// Non-ASCII characters (Cyrillic, CJK, emoji) - use insertText.
|
||||
if (!IsAscii(ch))
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(KeyTiming.KeyHold(cfg)).ConfigureAwait(false);
|
||||
await raw.InsertTextAsync(ch.ToString()).ConfigureAwait(false);
|
||||
if (i < text.Length - 1)
|
||||
await InterCharDelayAsync(cfg).ConfigureAwait(false);
|
||||
@@ -147,7 +170,7 @@ public static class HumanKeyboard
|
||||
await TypeNormalCharAsync(raw, wrong, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.MistypeDelayNotice)).ConfigureAwait(false);
|
||||
await raw.DownAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(KeyTiming.KeyHold(cfg)).ConfigureAwait(false);
|
||||
await raw.UpAsync("Backspace").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.MistypeDelayCorrect)).ConfigureAwait(false);
|
||||
}
|
||||
@@ -167,18 +190,18 @@ public static class HumanKeyboard
|
||||
private static async Task TypeNormalCharAsync(IRawKeyboard raw, char ch, HumanConfig cfg)
|
||||
{
|
||||
await raw.DownAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(KeyTiming.KeyHold(cfg)).ConfigureAwait(false);
|
||||
await raw.UpAsync(ch.ToString()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task TypeShiftedCharAsync(IRawKeyboard raw, char ch, HumanConfig cfg)
|
||||
{
|
||||
await raw.DownAsync("Shift").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandLog(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await raw.DownAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(KeyTiming.KeyHold(cfg)).ConfigureAwait(false);
|
||||
await raw.UpAsync(ch.ToString()).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(Math.Max(0, HumanRandom.RandRange(cfg.ShiftUpDelay))).ConfigureAwait(false);
|
||||
await raw.UpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -197,7 +220,7 @@ public static class HumanKeyboard
|
||||
string s = ch.ToString();
|
||||
|
||||
await raw.DownAsync("Shift").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandLog(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
|
||||
await cdpSession.SendAsync("Input.dispatchKeyEvent", new JsonObject
|
||||
{
|
||||
@@ -209,7 +232,7 @@ public static class HumanKeyboard
|
||||
["text"] = s,
|
||||
["unmodifiedText"] = s,
|
||||
}).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.KeyHold)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(KeyTiming.KeyHold(cfg)).ConfigureAwait(false);
|
||||
|
||||
await cdpSession.SendAsync("Input.dispatchKeyEvent", new JsonObject
|
||||
{
|
||||
@@ -220,14 +243,14 @@ public static class HumanKeyboard
|
||||
["windowsVirtualKeyCode"] = keyCode,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(Math.Max(0, HumanRandom.RandRange(cfg.ShiftUpDelay))).ConfigureAwait(false);
|
||||
await raw.UpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// --- Fallback path: page.evaluate (detectable) ---
|
||||
await raw.DownAsync("Shift").ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandLog(cfg.ShiftDownDelay)).ConfigureAwait(false);
|
||||
await raw.InsertTextAsync(ch.ToString()).ConfigureAwait(false);
|
||||
if (evaluator != null)
|
||||
{
|
||||
@@ -241,21 +264,246 @@ public static class HumanKeyboard
|
||||
}",
|
||||
ch.ToString()).ConfigureAwait(false);
|
||||
}
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ShiftUpDelay)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(Math.Max(0, HumanRandom.RandRange(cfg.ShiftUpDelay))).ConfigureAwait(false);
|
||||
await raw.UpAsync("Shift").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InterCharDelayAsync(HumanConfig cfg)
|
||||
private static Task InterCharDelayAsync(HumanConfig cfg) =>
|
||||
HumanRandom.SleepMsAsync(Math.Max(10, KeyTiming.KeyGap(cfg, "a", "b") - HumanRandom.RandLog(cfg.KeyHold)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key timing shared by the humanize engine. Numbers come from the 136M Keystrokes data
|
||||
/// (Dhakal et al., CHI 2018): letters typed with the other hand follow ~20% faster than
|
||||
/// letters of the same hand, a repeated letter (same finger) is slower, and the first letter
|
||||
/// of a word (after a space) is the slowest. People commonly press the next key before
|
||||
/// letting go of the previous one (rollover).
|
||||
/// </summary>
|
||||
internal static class KeyTiming
|
||||
{
|
||||
private static readonly HashSet<char> LeftHand = new("qwertasdfgzxcvb`12345~!@#$%");
|
||||
|
||||
private static char? Hand(string ch)
|
||||
{
|
||||
if (HumanRandom.NextDouble() < cfg.TypingPauseChance)
|
||||
if (ch.Length != 1) return null;
|
||||
char c = char.ToLowerInvariant(ch[0]);
|
||||
if (c > 0x7F || char.IsWhiteSpace(c)) return null;
|
||||
return LeftHand.Contains(c) ? 'L' : 'R';
|
||||
}
|
||||
|
||||
/// <summary>How much slower / faster than average the press of <paramref name="ch"/> follows <paramref name="prev"/>.</summary>
|
||||
public static double GapFactor(string prev, string ch)
|
||||
{
|
||||
if (prev.Length == 0) return 1;
|
||||
if (prev == " ") return 1.3;
|
||||
if (ch == " ") return 0.9;
|
||||
if (string.Equals(prev, ch, StringComparison.OrdinalIgnoreCase)) return 1.05;
|
||||
var hp = Hand(prev);
|
||||
var hc = Hand(ch);
|
||||
if (hp != null && hc != null) return hp != hc ? 0.85 : 1.1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>Milliseconds from the press of <paramref name="prev"/> to the press of
|
||||
/// <paramref name="ch"/> (log-normal around TypingDelay, plus occasional thinking pauses
|
||||
/// before words).</summary>
|
||||
public static double KeyGap(HumanConfig cfg, string prev, string ch)
|
||||
{
|
||||
double median = Math.Max(1, cfg.TypingDelay * GapFactor(prev, ch));
|
||||
double spread = Math.Max(0, cfg.TypingDelaySpread);
|
||||
double sigma = cfg.TypingDelay > 0 ? Math.Log(1 + spread / cfg.TypingDelay) : 0;
|
||||
double gap = HumanRandom.RandLognormal(median, sigma);
|
||||
bool wordStart = prev == " " || prev.Length == 0;
|
||||
double chance = cfg.TypingPauseChance * (wordStart ? 1 : 0.1);
|
||||
if (chance > 0 && HumanRandom.NextDouble() < chance) gap += HumanRandom.RandRange(cfg.TypingPauseRange);
|
||||
return Math.Max(15, gap);
|
||||
}
|
||||
|
||||
/// <summary>How long one key stays down (ms).</summary>
|
||||
public static double KeyHold(HumanConfig cfg) => Math.Max(1, HumanRandom.RandLog(cfg.KeyHold));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presses keys on a timeline the way a person does: each press follows the previous
|
||||
/// <em>press</em> after a letter-dependent gap, and each key is held for its own time, so the
|
||||
/// next key often goes down before the previous one comes up (rollover). Shift goes down
|
||||
/// before the letter and is usually let go just before the letter key. Releases are queued
|
||||
/// and sent when due.
|
||||
/// </summary>
|
||||
internal sealed class Typist
|
||||
{
|
||||
private sealed record KeyUp(double Due, string Key, Dictionary<string, object>? Cdp);
|
||||
|
||||
private readonly Func<string, Task> _down;
|
||||
private readonly Func<string, Task> _up;
|
||||
private readonly Func<string, Task> _insertText;
|
||||
private readonly Func<Dictionary<string, object>, Task> _cdp;
|
||||
private readonly HumanConfig _cfg;
|
||||
private readonly double? _delay;
|
||||
private readonly System.Diagnostics.Stopwatch _clock = System.Diagnostics.Stopwatch.StartNew();
|
||||
private readonly List<KeyUp> _ups = new();
|
||||
private string _prev = "";
|
||||
private double? _lastPress;
|
||||
private double? _shiftUpDue;
|
||||
private bool _shiftDown;
|
||||
|
||||
public Typist(Func<string, Task> down, Func<string, Task> up, Func<string, Task> insertText,
|
||||
Func<Dictionary<string, object>, Task> cdp, HumanConfig cfg, double? delay = null)
|
||||
{
|
||||
_down = down; _up = up; _insertText = insertText; _cdp = cdp; _cfg = cfg; _delay = delay;
|
||||
}
|
||||
|
||||
private double Now => _clock.Elapsed.TotalMilliseconds;
|
||||
|
||||
private Task WaitAsync(double until) => HumanRandom.SleepMsAsync(until - Now);
|
||||
|
||||
private async Task ShiftAsync(bool down)
|
||||
{
|
||||
_shiftDown = down;
|
||||
await (down ? _down("Shift") : _up("Shift")).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpAsync(KeyUp u)
|
||||
{
|
||||
if (u.Cdp != null) await _cdp(u.Cdp).ConfigureAwait(false);
|
||||
else if (u.Key.Length == 1 && u.Key[0] is >= 'A' and <= 'Z' && !_shiftDown)
|
||||
await _up(u.Key.ToLowerInvariant()).ConfigureAwait(false); // Shift already came up: the OS reports the bare key
|
||||
else await _up(u.Key).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Send every release due before <paramref name="until"/> (in time order).</summary>
|
||||
private async Task FlushAsync(double until)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.TypingPauseRange)).ConfigureAwait(false);
|
||||
KeyUp? next = null;
|
||||
foreach (var u in _ups)
|
||||
if (u.Due <= until && (next == null || u.Due < next.Due)) next = u;
|
||||
bool shiftFirst = _shiftUpDue is double sd && sd <= until && (next == null || sd <= next.Due);
|
||||
if (shiftFirst)
|
||||
{
|
||||
await WaitAsync(_shiftUpDue!.Value).ConfigureAwait(false);
|
||||
_shiftUpDue = null;
|
||||
await ShiftAsync(false).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
if (next == null) return;
|
||||
await WaitAsync(next.Due).ConfigureAwait(false);
|
||||
_ups.Remove(next);
|
||||
await UpAsync(next).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task KeyAsync(string ch)
|
||||
{
|
||||
var cfg = _cfg;
|
||||
double at = _lastPress == null ? Now
|
||||
: _delay != null ? _lastPress.Value + _delay.Value
|
||||
: _lastPress.Value + KeyTiming.KeyGap(cfg, _prev, ch);
|
||||
bool single = ch.Length == 1;
|
||||
bool isSymbol = single && HumanKeyboard.ShiftSymbols.Contains(ch[0]);
|
||||
bool shifted = single && (ch[0] is >= 'A' and <= 'Z' || isSymbol);
|
||||
// Text without a key (non-ASCII, emoji / combined graphemes, a "\r\n" pair) is
|
||||
// inserted as one input; multi-letter ASCII names ("Backspace") are real keys.
|
||||
bool textOnly = single ? ch[0] > 0x7F : ch.Any(c => c > 0x7F || char.IsControl(c));
|
||||
// A key cannot go down twice: let the earlier press of the same physical key
|
||||
// ("x" before "X", "1" before "!") come up first.
|
||||
string key = HumanKeyboard.PhysicalKey(ch);
|
||||
var same = _ups.Where(u => HumanKeyboard.PhysicalKey(u.Key) == key).ToList();
|
||||
if (same.Count > 0) at = Math.Max(at, same.Min(u => u.Due) + 5);
|
||||
if (shifted)
|
||||
{
|
||||
if (_shiftUpDue != null)
|
||||
{
|
||||
_shiftUpDue = null; // still down from the previous capital
|
||||
await FlushAsync(at).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
double lead = HumanRandom.RandLog(cfg.ShiftDownDelay);
|
||||
// Shift goes down after the previous key comes up.
|
||||
await FlushAsync(at - lead).ConfigureAwait(false);
|
||||
await WaitAsync(at - lead).ConfigureAwait(false);
|
||||
await ShiftAsync(true).ConfigureAwait(false);
|
||||
// Keys due to come up while Shift is going down (e.g. the "x" of "xX")
|
||||
// are released before the capital is pressed.
|
||||
await FlushAsync(at).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
double delay = cfg.TypingDelay + (HumanRandom.NextDouble() - 0.5) * 2 * cfg.TypingDelaySpread;
|
||||
await HumanRandom.SleepMsAsync(Math.Max(10, delay)).ConfigureAwait(false);
|
||||
await FlushAsync(at).ConfigureAwait(false);
|
||||
if (_shiftUpDue != null && ch != "Backspace")
|
||||
{
|
||||
// A lower-case key while Shift is still down: let it go first.
|
||||
_shiftUpDue = null;
|
||||
await ShiftAsync(false).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
await WaitAsync(at).ConfigureAwait(false);
|
||||
double pressed = Math.Max(at, Now);
|
||||
_lastPress = pressed;
|
||||
double hold0 = KeyTiming.KeyHold(cfg);
|
||||
double hold = _delay != null && _delay.Value < hold0 ? _delay.Value : hold0;
|
||||
double upAt = pressed + hold;
|
||||
if (textOnly)
|
||||
{
|
||||
// Non-ASCII text has no key: it arrives as one input, like an IME.
|
||||
await _insertText(ch).ConfigureAwait(false);
|
||||
}
|
||||
else if (isSymbol)
|
||||
{
|
||||
var (code, vk) = HumanKeyboard.ShiftSymbolKey(ch[0]);
|
||||
await _cdp(new()
|
||||
{
|
||||
["type"] = "keyDown", ["modifiers"] = 8, ["key"] = ch, ["code"] = code,
|
||||
["windowsVirtualKeyCode"] = vk, ["text"] = ch, ["unmodifiedText"] = ch,
|
||||
}).ConfigureAwait(false);
|
||||
_ups.Add(new KeyUp(upAt, ch, new()
|
||||
{
|
||||
["type"] = "keyUp", ["modifiers"] = 8, ["key"] = ch, ["code"] = code, ["windowsVirtualKeyCode"] = vk,
|
||||
}));
|
||||
}
|
||||
else
|
||||
{
|
||||
await _down(ch).ConfigureAwait(false);
|
||||
_ups.Add(new KeyUp(upAt, ch, null));
|
||||
}
|
||||
if (shifted)
|
||||
{
|
||||
// Usually let go of Shift around the letter's release, most often just before it.
|
||||
_shiftUpDue = Math.Max(pressed + 10, upAt + HumanRandom.RandRange(cfg.ShiftUpDelay));
|
||||
// The CDP keyUp of a shift symbol carries the Shift modifier.
|
||||
if (isSymbol) _shiftUpDue = Math.Max(_shiftUpDue.Value, upAt + 5);
|
||||
}
|
||||
_prev = ch;
|
||||
}
|
||||
|
||||
/// <summary>Release everything still held, on schedule.</summary>
|
||||
public Task FinishAsync() => FlushAsync(double.PositiveInfinity);
|
||||
|
||||
/// <summary>Hands off the keys (before a pause): release, and start the next key without
|
||||
/// a gap carried over from the last one.</summary>
|
||||
public async Task RestAsync()
|
||||
{
|
||||
await FinishAsync().ConfigureAwait(false);
|
||||
_lastPress = null;
|
||||
}
|
||||
|
||||
/// <summary>Error path: never leave a key stuck down.</summary>
|
||||
public async Task ReleaseAllAsync()
|
||||
{
|
||||
var ups = _ups.ToList();
|
||||
_ups.Clear();
|
||||
foreach (var u in ups)
|
||||
{
|
||||
try { await UpAsync(u).ConfigureAwait(false); } catch { /* best effort */ }
|
||||
}
|
||||
_shiftUpDue = null;
|
||||
if (_shiftDown)
|
||||
{
|
||||
try { await ShiftAsync(false).ConfigureAwait(false); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public static class HumanMouse
|
||||
}
|
||||
|
||||
/// <summary>Generate two random control points biased perpendicular to the path.</summary>
|
||||
internal static (Point, Point) RandomControlPoints(Point start, Point end)
|
||||
internal static (Point, Point) RandomControlPoints(Point start, Point end, double curvature = 0.3)
|
||||
{
|
||||
double dx = end.X - start.X;
|
||||
double dy = end.Y - start.Y;
|
||||
@@ -54,16 +54,132 @@ public static class HumanMouse
|
||||
if (dist == 0) dist = 1;
|
||||
double px = -dy / dist;
|
||||
double py = dx / dist;
|
||||
double bias1 = HumanRandom.Rand(-0.3, 0.3) * dist;
|
||||
double bias2 = HumanRandom.Rand(-0.3, 0.3) * dist;
|
||||
// Both control points bend the same way more often than not: a hand
|
||||
// sweeps an arc, it rarely draws an S.
|
||||
double bias1 = HumanRandom.Rand(-curvature, curvature) * dist;
|
||||
double bias2 = HumanRandom.NextDouble() < 0.8
|
||||
? bias1 * HumanRandom.Rand(0.2, 1.2)
|
||||
: HumanRandom.Rand(-curvature, curvature) * dist;
|
||||
return (
|
||||
new Point(start.X + dx * 0.25 + px * bias1, start.Y + dy * 0.25 + py * bias1),
|
||||
new Point(start.X + dx * 0.75 + px * bias2, start.Y + dy * 0.75 + py * bias2));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stroke planning (shared by the humanize engine and HumanMoveAsync)
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// A pointing movement is a fast main stroke whose speed peaks early, then
|
||||
// a slow approach to the target, often finished by a short corrective
|
||||
// stroke (Meyer's optimised sub-movements; Plamondon's log-normal
|
||||
// strokes). How long it takes grows with log2 of the distance (Fitts' law)
|
||||
// and varies a lot from one move to the next. A mouse reports its position
|
||||
// at a steady rate and only when it actually moved.
|
||||
|
||||
/// <summary>Pointer events arrive about once per display frame.</summary>
|
||||
internal const double FrameMs = 1000.0 / 60;
|
||||
private const double PeakLo = 0.22, PeakHi = 0.38; // where the speed peaks (fraction of the stroke time)
|
||||
|
||||
/// <summary>Smooth 0->1 distance curve whose speed peaks at <paramref name="peak"/> of the
|
||||
/// time (a time-warped minimum-jerk profile: fast early, long slow tail).</summary>
|
||||
private static Func<double, double> ProgressCurve(double peak)
|
||||
{
|
||||
double k = Math.Log(0.5) / Math.Log(peak); // u = t**k puts the mid point at peak
|
||||
return t =>
|
||||
{
|
||||
double u = Math.Pow(t, k);
|
||||
return u * u * u * (10 - 15 * u + 6 * u * u);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Milliseconds for a <paramref name="dist"/> px stroke (Fitts' law, log-normal spread).</summary>
|
||||
internal static double StrokeDuration(double dist, HumanConfig cfg)
|
||||
{
|
||||
double median = cfg.MouseTimeBase + cfg.MouseTimePerBit * Math.Log2(1 + dist / 8);
|
||||
return Math.Max(FrameMs * 2, HumanRandom.RandLognormal(median, cfg.MouseTimeSpread));
|
||||
}
|
||||
|
||||
/// <summary>Points of one stroke as (x, y, ms since the previous one); the last point is
|
||||
/// exactly (ex, ey). Consecutive points always differ by a pixel or more (a mouse does
|
||||
/// not report a move without movement).</summary>
|
||||
internal static List<(double X, double Y, double Dt)> PlanStroke(
|
||||
double sx, double sy, double ex, double ey, HumanConfig cfg, double? duration = null)
|
||||
{
|
||||
var output = new List<(double X, double Y, double Dt)>();
|
||||
double dist = Math.Sqrt((ex - sx) * (ex - sx) + (ey - sy) * (ey - sy));
|
||||
if (dist < 0.5) return output;
|
||||
double total = duration ?? StrokeDuration(dist, cfg);
|
||||
int n = Math.Max(1, (int)Math.Max(cfg.MouseMinSteps, Math.Min(cfg.MouseMaxSteps, Math.Round(total / FrameMs))));
|
||||
var start = new Point(sx, sy);
|
||||
var end = new Point(ex, ey);
|
||||
var (cp1, cp2) = RandomControlPoints(start, end, 0.12);
|
||||
var curve = ProgressCurve(HumanRandom.Rand(PeakLo, PeakHi));
|
||||
// Low-frequency sideways drift (a hand is not a ruler), zero at both ends.
|
||||
double ux = (ex - sx) / dist, uy = (ey - sy) / dist;
|
||||
double amp = Math.Min(cfg.MouseWobbleMax, dist * 0.02) * HumanRandom.Rand(-1, 1);
|
||||
double waves = HumanRandom.Rand(0.5, 1.5), phase = HumanRandom.Rand(0, Math.PI);
|
||||
double lx = sx, ly = sy, tPrev = 0;
|
||||
for (int i = 1; i <= n; i++)
|
||||
{
|
||||
// Frame timing jitters a little; the final event lands on the target.
|
||||
double t = Math.Min(1, (i + (i < n ? HumanRandom.Rand(-0.2, 0.2) : 0)) / n);
|
||||
double x, y;
|
||||
if (i == n)
|
||||
{
|
||||
x = ex; y = ey;
|
||||
if (Math.Abs(x - lx) < 1 && Math.Abs(y - ly) < 1)
|
||||
{
|
||||
if (output.Count == 0) break; // already there (sub-pixel move): nothing to report
|
||||
// The last frame already got there: land exactly on it instead.
|
||||
var last = output[^1];
|
||||
output[^1] = (x, y, last.Dt + (t - tPrev) * total);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var p = Bezier(start, cp1, cp2, end, curve(t));
|
||||
double w = amp * Math.Sin(Math.PI * t) * Math.Sin(phase + 2 * Math.PI * waves * t);
|
||||
x = Math.Round(p.X - uy * w);
|
||||
y = Math.Round(p.Y + ux * w);
|
||||
if (Math.Abs(x - lx) < 1 && Math.Abs(y - ly) < 1) continue;
|
||||
}
|
||||
output.Add((x, y, (t - tPrev) * total));
|
||||
tPrev = t; lx = x; ly = y;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>A whole move: main stroke, and sometimes a miss near the target plus a short
|
||||
/// corrective stroke onto it.</summary>
|
||||
internal static List<(double X, double Y, double Dt)> PlanMove(
|
||||
double sx, double sy, double ex, double ey, HumanConfig cfg)
|
||||
{
|
||||
double dist = Math.Sqrt((ex - sx) * (ex - sx) + (ey - sy) * (ey - sy));
|
||||
if (dist < 0.5) return new();
|
||||
if (dist > 40 && HumanRandom.NextDouble() < cfg.MouseOvershootChance)
|
||||
{
|
||||
// Land a little past (or short of) the target, slightly off-line.
|
||||
double ang = Math.Atan2(ey - sy, ex - sx) + HumanRandom.Rand(-0.25, 0.25);
|
||||
double miss = dist * HumanRandom.Rand(0.01, 0.05) + HumanRandom.RandRange(cfg.MouseOvershootPx);
|
||||
if (HumanRandom.NextDouble() < 0.3) miss = -miss; // undershoot
|
||||
double mx = Math.Round(ex + Math.Cos(ang) * miss), my = Math.Round(ey + Math.Sin(ang) * miss);
|
||||
var pts = PlanStroke(sx, sy, mx, my, cfg);
|
||||
double corr = Math.Sqrt((ex - mx) * (ex - mx) + (ey - my) * (ey - my));
|
||||
double pause = HumanRandom.Rand(40, 140);
|
||||
var tail = PlanStroke(mx, my, ex, ey, cfg,
|
||||
Math.Max(FrameMs * 2, HumanRandom.RandLognormal(90 + 25 * Math.Log2(1 + corr), 0.3)));
|
||||
if (tail.Count > 0) tail[0] = (tail[0].X, tail[0].Y, tail[0].Dt + pause);
|
||||
pts.AddRange(tail);
|
||||
return pts;
|
||||
}
|
||||
return PlanStroke(sx, sy, ex, ey, cfg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move the cursor from (startX, startY) to (endX, endY) along a human-like
|
||||
/// Bezier curve with wobble, burst pauses, and an optional overshoot.
|
||||
/// Move the cursor from (startX, startY) to (endX, endY) the way a hand does:
|
||||
/// an early-peaking main stroke timed by Fitts' law, sometimes a short
|
||||
/// corrective stroke, one pointer event per frame.
|
||||
/// </summary>
|
||||
public static async Task HumanMoveAsync(
|
||||
IRawMouse raw,
|
||||
@@ -71,50 +187,13 @@ public static class HumanMouse
|
||||
double endX, double endY,
|
||||
HumanConfig cfg)
|
||||
{
|
||||
double dist = Math.Sqrt((endX - startX) * (endX - startX) + (endY - startY) * (endY - startY));
|
||||
if (dist < 1)
|
||||
return;
|
||||
|
||||
int steps = (int)Math.Max(cfg.MouseMinSteps,
|
||||
Math.Min(cfg.MouseMaxSteps, Math.Round(dist / cfg.MouseStepsDivisor)));
|
||||
var start = new Point(startX, startY);
|
||||
var end = new Point(endX, endY);
|
||||
var (cp1, cp2) = RandomControlPoints(start, end);
|
||||
|
||||
int burstCounter = 0;
|
||||
int burstSize = HumanRandom.RandIntRange(cfg.MouseBurstSize);
|
||||
|
||||
for (int i = 0; i <= steps; i++)
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
double clock = 0;
|
||||
foreach (var (x, y, dt) in PlanMove(startX, startY, endX, endY, cfg))
|
||||
{
|
||||
double progress = (double)i / steps;
|
||||
double easedT = EaseInOut(progress);
|
||||
var pt = Bezier(start, cp1, cp2, end, easedT);
|
||||
|
||||
double wobbleAmp = Math.Sin(Math.PI * progress) * cfg.MouseWobbleMax;
|
||||
double wx = pt.X + (HumanRandom.NextDouble() - 0.5) * 2 * wobbleAmp;
|
||||
double wy = pt.Y + (HumanRandom.NextDouble() - 0.5) * 2 * wobbleAmp;
|
||||
|
||||
await raw.MoveAsync(Math.Round(wx), Math.Round(wy)).ConfigureAwait(false);
|
||||
|
||||
burstCounter++;
|
||||
if (burstCounter >= burstSize && i < steps)
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.MouseBurstPause)).ConfigureAwait(false);
|
||||
burstCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (HumanRandom.NextDouble() < cfg.MouseOvershootChance)
|
||||
{
|
||||
double overshootDist = HumanRandom.RandRange(cfg.MouseOvershootPx);
|
||||
double angle = Math.Atan2(endY - startY, endX - startX);
|
||||
await raw.MoveAsync(
|
||||
Math.Round(endX + Math.Cos(angle) * overshootDist),
|
||||
Math.Round(endY + Math.Sin(angle) * overshootDist)).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 70)).ConfigureAwait(false);
|
||||
await raw.MoveAsync(
|
||||
Math.Round(endX + (HumanRandom.NextDouble() - 0.5) * 4),
|
||||
Math.Round(endY + (HumanRandom.NextDouble() - 0.5) * 4)).ConfigureAwait(false);
|
||||
clock += dt;
|
||||
await HumanRandom.SleepMsAsync(clock - sw.Elapsed.TotalMilliseconds).ConfigureAwait(false);
|
||||
await raw.MoveAsync(x, y).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,40 @@ public static class HumanRandom
|
||||
/// <summary>Random integer from a <see cref="Range"/> (min, max), inclusive.</summary>
|
||||
public static int RandIntRange(Range r) => RandInt((int)r.Min, (int)r.Max);
|
||||
|
||||
private const double Z95 = 1.6449; // standard normal 95th percentile
|
||||
|
||||
/// <summary>Standard normal sample (Box-Muller).</summary>
|
||||
public static double Gauss()
|
||||
{
|
||||
double u = 0;
|
||||
while (u == 0) u = Rng.NextDouble();
|
||||
return Math.Sqrt(-2 * Math.Log(u)) * Math.Cos(2 * Math.PI * Rng.NextDouble());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log-normal sample with <paramref name="r"/> as its ~5th / 95th percentiles, clipped
|
||||
/// to [low / 2, high * 2]. Human timings (key holds, gaps) are right-skewed like this,
|
||||
/// not uniform. Falls back to uniform when a bound is not positive.
|
||||
/// </summary>
|
||||
public static double RandLog(Range r)
|
||||
{
|
||||
double lo = r.Min, hi = r.Max;
|
||||
if (hi < lo) (lo, hi) = (hi, lo);
|
||||
if (lo <= 0) return Rand(lo, hi);
|
||||
if (hi == lo) return lo;
|
||||
double mu = (Math.Log(lo) + Math.Log(hi)) / 2;
|
||||
double sigma = (Math.Log(hi) - Math.Log(lo)) / (2 * Z95);
|
||||
return Math.Min(hi * 2, Math.Max(lo / 2, Math.Exp(mu + sigma * Gauss())));
|
||||
}
|
||||
|
||||
/// <summary>Log-normal sample with the given median and log-space sigma.</summary>
|
||||
public static double RandLognormal(double median, double sigma)
|
||||
{
|
||||
if (median <= 0) return 0;
|
||||
if (sigma <= 0) return median;
|
||||
return median * Math.Exp(sigma * Gauss());
|
||||
}
|
||||
|
||||
/// <summary>Return <c>true</c> with the given probability in [0, 1].</summary>
|
||||
public static bool Chance(double probability) => Rng.NextDouble() < probability;
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ public readonly record struct ScrollResult(BoundingBox Box, double CursorX, doub
|
||||
|
||||
/// <summary>
|
||||
/// Human-like scrolling via mouse wheel events.
|
||||
/// Direct port of Python <c>cloakbrowser/human/scroll.py</c>.
|
||||
/// Standalone helper (humanized actions use the engine); mirrors the JS Puppeteer scroller.
|
||||
/// </summary>
|
||||
public static class HumanScroll
|
||||
{
|
||||
@@ -56,44 +56,54 @@ public static class HumanScroll
|
||||
return bounds.X >= 0 && bounds.X + bounds.Width <= viewportWidth;
|
||||
}
|
||||
|
||||
/// <summary>Send one logical scroll as a burst of small wheel events (like real inertia).</summary>
|
||||
private static async Task SmoothWheelAsync(IRawMouse raw, int delta, HumanConfig cfg, bool horizontal = false)
|
||||
/// <summary>CSS pixels per wheel notch: <see cref="HumanConfig.ScrollNotchPx"/> when set,
|
||||
/// else 100 (Chrome on Windows; this helper has no persona to ask).</summary>
|
||||
private static double NotchPx(HumanConfig cfg) => cfg.ScrollNotchPx > 0 ? cfg.ScrollNotchPx : 100;
|
||||
|
||||
/// <summary>
|
||||
/// Turn the wheel by about <paramref name="delta"/> px in whole notches: flicks of a
|
||||
/// few notches, quicker inside a flick, with a short pause between flicks. CDP reports
|
||||
/// every wheel event as one notch (<c>wheelDeltaY</c> 120), so whole-notch events are
|
||||
/// the only ones whose <c>deltaY</c> agrees. Returns the distance actually wheeled.
|
||||
/// </summary>
|
||||
private static async Task<double> WheelNotchesAsync(IRawMouse raw, double delta, HumanConfig cfg, bool horizontal = false)
|
||||
{
|
||||
double absD = Math.Abs(delta);
|
||||
double notch = NotchPx(cfg);
|
||||
int sign = delta > 0 ? 1 : -1;
|
||||
double sent = 0;
|
||||
while (sent < absD)
|
||||
int notches = (int)Math.Round(Math.Abs(delta) / notch);
|
||||
int flick = HumanRandom.RandIntRange((2, 5));
|
||||
for (int i = 0; i < notches; i++)
|
||||
{
|
||||
double stepSize = HumanRandom.Rand(20, 40);
|
||||
double chunk = Math.Min(stepSize, absD - sent);
|
||||
// Fold a sub-pixel tail into this step: on its own it would round to a
|
||||
// (0, 0) wheel event, which no real wheel sends.
|
||||
if (absD - sent - chunk < 0.5)
|
||||
chunk = absD - sent;
|
||||
double step = Math.Round(chunk) * sign;
|
||||
if (horizontal)
|
||||
await raw.WheelAsync(step, 0).ConfigureAwait(false);
|
||||
double d = notch * sign;
|
||||
if (horizontal) await raw.WheelAsync(d, 0).ConfigureAwait(false);
|
||||
else await raw.WheelAsync(0, d).ConfigureAwait(false);
|
||||
if (i == notches - 1) break;
|
||||
if (--flick <= 0)
|
||||
{
|
||||
flick = HumanRandom.RandIntRange((2, 5));
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollPauseSlow) + HumanRandom.Rand(40, 120)).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
await raw.WheelAsync(0, step).ConfigureAwait(false);
|
||||
sent += chunk;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(8, 20)).ConfigureAwait(false);
|
||||
{
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollPauseFast)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
return notches * notch * sign;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public smooth-wheel helper for the transparent <c>IMouse.WheelAsync</c> override:
|
||||
/// breaks a (deltaX, deltaY) wheel request into the same small-burst-with-inertia
|
||||
/// pattern used for scroll-into-view, so direct wheel calls look human too.
|
||||
/// Public smooth-wheel helper for a direct wheel request: each non-zero axis is
|
||||
/// turned by a whole number of notches (at least one), like a person would. When
|
||||
/// both deltas are zero a single no-op wheel event is sent, matching Playwright's
|
||||
/// <c>IMouse.WheelAsync(0, 0)</c>.
|
||||
/// </summary>
|
||||
public static async Task SmoothWheelAsync(IRawMouse raw, double deltaX, double deltaY, HumanConfig cfg)
|
||||
{
|
||||
// X axis is sent in one go (horizontal scroll is rarely incremental), Y is
|
||||
// chunked for inertia. When both are zero, send a single no-op wheel event so
|
||||
// semantics match Playwright's IMouse.WheelAsync(0, 0).
|
||||
double notch = NotchPx(cfg);
|
||||
if (deltaY != 0)
|
||||
await SmoothWheelAsync(raw, (int)Math.Round(deltaY), cfg).ConfigureAwait(false);
|
||||
await WheelNotchesAsync(raw, Math.Sign(deltaY) * Math.Max(notch, Math.Abs(deltaY)), cfg).ConfigureAwait(false);
|
||||
if (deltaX != 0)
|
||||
await raw.WheelAsync(Math.Round(deltaX), 0).ConfigureAwait(false);
|
||||
await WheelNotchesAsync(raw, Math.Sign(deltaX) * Math.Max(notch, Math.Abs(deltaX)), cfg, horizontal: true).ConfigureAwait(false);
|
||||
if (deltaX == 0 && deltaY == 0)
|
||||
await raw.WheelAsync(0, 0).ConfigureAwait(false);
|
||||
}
|
||||
@@ -166,69 +176,36 @@ public static class HumanScroll
|
||||
cursorY = scrollAreaY;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollPreMoveDelay)).ConfigureAwait(false);
|
||||
|
||||
// Calculate scroll distance.
|
||||
// Wheel in whole notches, re-measuring between flicks, until the element
|
||||
// sits in the target zone (or the page stops moving).
|
||||
double notch = NotchPx(cfg);
|
||||
double targetY = viewportHeight * HumanRandom.Rand(cfg.ScrollTargetZone.Min, cfg.ScrollTargetZone.Max);
|
||||
double elementCenter = box.Value.Y + box.Value.Height / 2;
|
||||
double distanceToScroll = elementCenter - targetY;
|
||||
|
||||
int direction = distanceToScroll > 0 ? 1 : -1;
|
||||
double absDistance = Math.Abs(distanceToScroll);
|
||||
double avgDelta = (cfg.ScrollDeltaBase.Min + cfg.ScrollDeltaBase.Max) / 2;
|
||||
int totalClicks = Math.Max(3, (int)Math.Ceiling(absDistance / avgDelta));
|
||||
int accelSteps = HumanRandom.RandIntRange(cfg.ScrollAccelSteps);
|
||||
int decelSteps = HumanRandom.RandIntRange(cfg.ScrollDecelSteps);
|
||||
|
||||
// Scroll loop: accelerate -> cruise -> decelerate.
|
||||
double scrolled = 0;
|
||||
for (int i = 0; i < totalClicks; i++)
|
||||
bool overshot = false;
|
||||
int stalled = 0;
|
||||
for (int round = 0; round < 40 && stalled < 2; round++)
|
||||
{
|
||||
double delta, pause;
|
||||
if (i < accelSteps)
|
||||
double distance = box.Value.Y + box.Value.Height / 2 - targetY;
|
||||
if (Math.Abs(distance) < notch / 2 || IsInViewport(box.Value, viewportHeight, cfg)) break;
|
||||
int direction = distance > 0 ? 1 : -1;
|
||||
int flick = Math.Min((int)Math.Round(Math.Abs(distance) / notch), HumanRandom.RandIntRange((2, 5)));
|
||||
double beforeY = box.Value.Y;
|
||||
await WheelNotchesAsync(raw, flick * notch * direction, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollPauseSlow)).ConfigureAwait(false);
|
||||
var nb = await getBox().ConfigureAwait(false);
|
||||
if (nb == null) break;
|
||||
box = nb;
|
||||
stalled = Math.Abs(box.Value.Y - beforeY) < 1 ? stalled + 1 : 0;
|
||||
if (!overshot && HumanRandom.NextDouble() < cfg.ScrollOvershootChance
|
||||
&& Math.Abs(box.Value.Y + box.Value.Height / 2 - targetY) < notch * 2)
|
||||
{
|
||||
delta = HumanRandom.Rand(80, 100);
|
||||
pause = HumanRandom.RandRange(cfg.ScrollPauseSlow);
|
||||
}
|
||||
else if (i >= totalClicks - decelSteps)
|
||||
{
|
||||
delta = HumanRandom.Rand(60, 90);
|
||||
pause = HumanRandom.RandRange(cfg.ScrollPauseSlow);
|
||||
}
|
||||
else
|
||||
{
|
||||
delta = HumanRandom.RandRange(cfg.ScrollDeltaBase);
|
||||
pause = HumanRandom.RandRange(cfg.ScrollPauseFast);
|
||||
}
|
||||
|
||||
delta *= 1 + (HumanRandom.NextDouble() - 0.5) * 2 * cfg.ScrollDeltaVariance;
|
||||
int deltaInt = (int)(Math.Round(delta) * direction);
|
||||
|
||||
await SmoothWheelAsync(raw, deltaInt, cfg).ConfigureAwait(false);
|
||||
scrolled += Math.Abs(deltaInt);
|
||||
await HumanRandom.SleepMsAsync(pause).ConfigureAwait(false);
|
||||
|
||||
// Check visibility every 3 steps.
|
||||
if (i % 3 == 2 || i == totalClicks - 1)
|
||||
{
|
||||
box = await getBox().ConfigureAwait(false);
|
||||
if (box != null && IsInViewport(box.Value, viewportHeight, cfg))
|
||||
break;
|
||||
}
|
||||
if (scrolled >= absDistance * 1.1)
|
||||
break;
|
||||
}
|
||||
|
||||
// Optional overshoot + correction.
|
||||
if (HumanRandom.NextDouble() < cfg.ScrollOvershootChance)
|
||||
{
|
||||
int overshootPx = (int)(Math.Round(HumanRandom.RandRange(cfg.ScrollOvershootPx)) * direction);
|
||||
await SmoothWheelAsync(raw, overshootPx, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollSettleDelay)).ConfigureAwait(false);
|
||||
int corrections = HumanRandom.RandIntRange((1, 2));
|
||||
for (int c = 0; c < corrections; c++)
|
||||
{
|
||||
int corrDelta = (int)(Math.Round(HumanRandom.Rand(40, 80)) * -direction);
|
||||
await SmoothWheelAsync(raw, corrDelta, cfg).ConfigureAwait(false);
|
||||
// Optional overshoot + correction, in whole notches.
|
||||
overshot = true;
|
||||
int over = Math.Max(1, (int)Math.Round(HumanRandom.RandRange(cfg.ScrollOvershootPx) / notch));
|
||||
await WheelNotchesAsync(raw, over * notch * direction, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollSettleDelay)).ConfigureAwait(false);
|
||||
await WheelNotchesAsync(raw, -over * notch * direction, cfg).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 250)).ConfigureAwait(false);
|
||||
box = await getBox().ConfigureAwait(false) ?? box;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +253,7 @@ public static class HumanScroll
|
||||
cursorY = scrollAreaY;
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollPreMoveDelay)).ConfigureAwait(false);
|
||||
|
||||
await SmoothWheelAsync(raw, (int)Math.Round(distanceToScroll), cfg, horizontal: true).ConfigureAwait(false);
|
||||
await WheelNotchesAsync(raw, distanceToScroll, cfg, horizontal: true).ConfigureAwait(false);
|
||||
await HumanRandom.SleepMsAsync(HumanRandom.RandRange(cfg.ScrollSettleDelay)).ConfigureAwait(false);
|
||||
|
||||
var finalBox = await getBox().ConfigureAwait(false);
|
||||
|
||||
@@ -127,14 +127,14 @@ const H = {
|
||||
const e = get(id);
|
||||
try {
|
||||
if (I.checkElementStates) {
|
||||
const r = await I.checkElementStates(e, states);
|
||||
const r = await I.checkElementStates(e, states, true);
|
||||
if (r === undefined) return null;
|
||||
if (r === 'error:notconnected') return { missing: 'attached' };
|
||||
return { missing: r.missingState };
|
||||
}
|
||||
for (const s of states) {
|
||||
if (s === 'stable') continue;
|
||||
const r = I.elementState(e, s);
|
||||
const r = I.elementState(e, s, true);
|
||||
if (r === 'error:notconnected' || r.received === 'error:notconnected') return { missing: 'attached' };
|
||||
if (!(r.matches !== undefined ? r.matches : r)) return { missing: s };
|
||||
}
|
||||
@@ -161,11 +161,24 @@ const H = {
|
||||
bl: parseFloat(st.borderLeftWidth) || 0, bt: parseFloat(st.borderTopWidth) || 0 };
|
||||
},
|
||||
scroller(id) {
|
||||
// Walk the containing-block chain, not plain DOM ancestors: overflow only
|
||||
// clips descendants whose containing block lies inside the scroller. A
|
||||
// fixed box escapes every ancestor that does not establish a containing
|
||||
// block for it; an absolute box escapes static ancestors (#593).
|
||||
const e = get(id);
|
||||
const none = (v) => !v || v === 'none';
|
||||
const fixedCB = (s) => !none(s.transform) || !none(s.translate) || !none(s.rotate) || !none(s.scale) ||
|
||||
!none(s.perspective) || !none(s.filter) || !none(s.backdropFilter) ||
|
||||
/layout|paint|strict|content/.test(s.contain || '') ||
|
||||
/transform|perspective|filter/.test(s.willChange || '') || /size/.test(s.containerType || '');
|
||||
const b = e.getBoundingClientRect();
|
||||
const top = document.scrollingElement || document.documentElement;
|
||||
let pos = getComputedStyle(e).position;
|
||||
for (let p = parentOrHost(e); p && p !== top && p !== document.body; p = parentOrHost(p)) {
|
||||
const s = getComputedStyle(p);
|
||||
if (pos === 'fixed' && !fixedCB(s)) continue;
|
||||
if (pos === 'absolute' && s.position === 'static' && !fixedCB(s)) continue;
|
||||
pos = s.position;
|
||||
const sy = /(auto|scroll|overlay)/.test(s.overflowY) && p.scrollHeight > p.clientHeight;
|
||||
const sx = /(auto|scroll|overlay)/.test(s.overflowX) && p.scrollWidth > p.clientWidth;
|
||||
if (!sy && !sx) continue;
|
||||
|
||||
@@ -18,7 +18,6 @@ public class BezierMathTests
|
||||
private static HumanConfig Cfg() => new()
|
||||
{
|
||||
MouseOvershootChance = 0, // deterministic so the end-point assertion is stable
|
||||
MouseBurstPause = (0, 0),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -19,7 +19,6 @@ public class SelectorParityBrowserTests
|
||||
{
|
||||
["mouse_min_steps"] = 3,
|
||||
["mouse_max_steps"] = 3,
|
||||
["mouse_burst_pause"] = new[] { 0.0, 0.0 },
|
||||
["idle_between_actions"] = false,
|
||||
["scroll_settle_delay"] = new[] { 0.0, 0.0 },
|
||||
},
|
||||
|
||||
@@ -9,8 +9,9 @@ public class HumanConfigTests
|
||||
public void DefaultPreset_HasExpectedDefaults()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Default);
|
||||
Assert.Equal(70, cfg.TypingDelay);
|
||||
Assert.Equal((15, 35), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
Assert.Equal(150, cfg.TypingDelay);
|
||||
Assert.Equal((60, 175), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
Assert.Equal(0, cfg.ScrollNotchPx);
|
||||
Assert.False(cfg.IdleBetweenActions);
|
||||
}
|
||||
|
||||
@@ -18,9 +19,9 @@ public class HumanConfigTests
|
||||
public void CarefulPreset_IsSlower()
|
||||
{
|
||||
var cfg = HumanConfigFactory.Resolve(HumanPreset.Careful);
|
||||
Assert.Equal(100, cfg.TypingDelay);
|
||||
Assert.Equal(210, cfg.TypingDelay);
|
||||
Assert.True(cfg.IdleBetweenActions);
|
||||
Assert.Equal((20, 45), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
Assert.Equal((70, 190), (cfg.KeyHold.Min, cfg.KeyHold.Max));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,7 +73,7 @@ public class HumanConfigTests
|
||||
{
|
||||
["does_not_exist"] = 5,
|
||||
});
|
||||
Assert.Equal(70, cfg.TypingDelay); // unchanged
|
||||
Assert.Equal(150, cfg.TypingDelay); // unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -93,7 +94,7 @@ public class HumanConfigTests
|
||||
{
|
||||
var baseCfg = new HumanConfig();
|
||||
var merged = baseCfg.With(new Dictionary<string, object> { ["typing_delay"] = 999.0 });
|
||||
Assert.Equal(70, baseCfg.TypingDelay);
|
||||
Assert.Equal(150, baseCfg.TypingDelay);
|
||||
Assert.Equal(999, merged.TypingDelay);
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,44 @@ public class HumanEngineBrowserTests : IClassFixture<EngineFixture>, IAsyncLifet
|
||||
Assert.Equal("[\"ArrowUp\",\"a\",\"b\"]", await p.EvaluateAsync<string>("JSON.stringify(window.__keys)"));
|
||||
}
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Same_key_lower_then_upper_case_never_sends_a_repeat_keydown()
|
||||
{
|
||||
// "xX": the capital's key must not go down while the lower-case press of the
|
||||
// same physical key is still held (keydown with repeat = true). Same for "1!".
|
||||
var p = await OpenWith(new()
|
||||
{
|
||||
["key_hold"] = new[] { 60.0, 175.0 }, ["typing_delay"] = 150.0, ["typing_delay_spread"] = 70.0,
|
||||
["typing_pause_chance"] = 0.0, ["shift_down_delay"] = new[] { 60.0, 220.0 },
|
||||
["shift_up_delay"] = new[] { -60.0, 30.0 },
|
||||
});
|
||||
await p.EvaluateAsync("() => { window.__kd = []; for (const t of ['keydown', 'keyup'])"
|
||||
+ " document.addEventListener(t, e => __kd.push([t, e.code, e.repeat]), true); }");
|
||||
const string text = "xX aA zZ 1! qQ";
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await p.FillAsync("#name", text);
|
||||
Assert.Equal(text, await Value(p, "#name"));
|
||||
}
|
||||
var repeats = await p.EvaluateAsync<int>("__kd.filter(e => e[2]).length");
|
||||
Assert.Equal(0, repeats);
|
||||
}
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Text_with_crlf_types_without_throwing()
|
||||
{
|
||||
// StringInfo returns "\r\n" as one text element; it has no key of its own and
|
||||
// is inserted as text, as before, instead of being sent to KeyDownAsync (which
|
||||
// Playwright rejects with "Unknown key"). Same value as on main.
|
||||
var p = await Open();
|
||||
await p.GotoAsync("data:text/html," + Uri.EscapeDataString("<textarea id=\"ta\"></textarea>"));
|
||||
// A textarea's value normalizes "\r\n" to "\n".
|
||||
await p.FillAsync("#ta", "ab\r\ncd");
|
||||
Assert.Equal("ab\ncd", await p.EvaluateAsync<string>("document.getElementById('ta').value"));
|
||||
await p.Locator("#ta").PressSequentiallyAsync("\r\nef");
|
||||
Assert.Equal("ab\ncd\nef", await p.EvaluateAsync<string>("document.getElementById('ta').value"));
|
||||
}
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Mistypes_are_corrected_skip_masked_fields_and_use_shift()
|
||||
{
|
||||
@@ -315,6 +353,34 @@ public class HumanEngineBrowserTests : IClassFixture<EngineFixture>, IAsyncLifet
|
||||
Assert.Equal(new[] { "chk" }, (await Events(p, "click")).Select(e => e.GetProperty("target").GetString()));
|
||||
}
|
||||
|
||||
// #593: overflow clips only boxes whose containing block lies inside the scroller.
|
||||
private const string CbCss = "<!doctype html><style>body { margin: 0 } header { height: 215px }"
|
||||
+ ".content { height: 300px; overflow: auto } .tall { height: 2000px } button { width: 44px; height: 44px }"
|
||||
+ "</style><header>header</header>";
|
||||
|
||||
[BrowserTheory]
|
||||
[InlineData("<div class=\"content\"><div class=\"tall\"><div style=\"position: fixed; inset: 0\">"
|
||||
+ "<button id=\"t\" style=\"position: absolute; top: 146px; right: 36px\">X</button></div></div></div>", false)]
|
||||
[InlineData("<div style=\"position: relative\"><div class=\"content\"><div class=\"tall\">"
|
||||
+ "<button id=\"t\" style=\"position: absolute; top: 320px; left: 10px\">X</button></div></div></div>", false)]
|
||||
[InlineData("<div class=\"content\"><div class=\"tall\"><div style=\"height: 1500px\"></div>"
|
||||
+ "<button id=\"t\">X</button></div></div>", true)]
|
||||
[InlineData("<div class=\"content\" style=\"position: relative\"><div class=\"tall\">"
|
||||
+ "<button id=\"t\" style=\"position: absolute; top: 1500px; left: 10px\">X</button></div></div>", true)]
|
||||
[InlineData("<div class=\"content\" style=\"transform: translateZ(0)\"><div class=\"tall\">"
|
||||
+ "<button id=\"t\" style=\"position: fixed; top: 1500px; left: 10px\">X</button></div></div>", true)]
|
||||
public async Task Scroller_follows_the_containing_block_chain(string body, bool clipped)
|
||||
{
|
||||
var p = await Open();
|
||||
await p.SetViewportSizeAsync(390, 844);
|
||||
await p.GotoAsync("data:text/html," + Uri.EscapeDataString(CbCss + body));
|
||||
await p.EvaluateAsync("() => { window.__clicks = 0; document.getElementById('t').addEventListener('click', () => window.__clicks++); }");
|
||||
await p.ClickAsync("#t", new() { Timeout = 5000 });
|
||||
Assert.Equal(1, await p.EvaluateAsync<int>("window.__clicks"));
|
||||
var scrolled = await p.EvaluateAsync<double>("document.querySelector('.content').scrollTop");
|
||||
Assert.Equal(clipped, scrolled > 0);
|
||||
}
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Per_call_human_config_through_HumanPage()
|
||||
{
|
||||
@@ -385,6 +451,19 @@ public class HumanEngineBrowserTests : IClassFixture<EngineFixture>, IAsyncLifet
|
||||
Assert.Equal(new[] { "c" }, await p.SelectOptionAsync("#emoji", "c"));
|
||||
}
|
||||
|
||||
[BrowserFact]
|
||||
public async Task SelectOption_that_navigates_the_page_still_returns()
|
||||
{
|
||||
// #597: a dropdown that submits its form on change.
|
||||
var p = await Open();
|
||||
await p.EvaluateAsync(@"() => { const f = document.createElement('form'); f.method = 'get';
|
||||
const s = document.createElement('select'); s.id = 'nav'; s.name = 'city';
|
||||
for (const [l, v] of [['Amsterdam', 'a'], ['Berlin', 'b']]) s.add(new Option(l, v));
|
||||
s.onchange = () => f.submit(); f.append(s); document.body.prepend(f); }");
|
||||
Assert.Equal(new[] { "b" }, await p.SelectOptionAsync("#nav", "b"));
|
||||
await p.WaitForURLAsync(new System.Text.RegularExpressions.Regex("city=b"));
|
||||
}
|
||||
|
||||
// --- frames / handles --------------------------------------------------
|
||||
|
||||
[BrowserFact]
|
||||
|
||||
@@ -19,7 +19,6 @@ public sealed class SiteServer
|
||||
["shift_up_delay"] = new[] { 5.0, 5.0 },
|
||||
["mouse_min_steps"] = 4,
|
||||
["mouse_max_steps"] = 4,
|
||||
["mouse_burst_pause"] = new[] { 0.0, 0.0 },
|
||||
["mouse_overshoot_chance"] = 0.0,
|
||||
["click_aim_delay_input"] = new[] { 5.0, 5.0 },
|
||||
["click_aim_delay_button"] = new[] { 5.0, 5.0 },
|
||||
|
||||
@@ -232,6 +232,29 @@ public class LicenseTests : IDisposable
|
||||
Assert.Contains("could not be validated", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureBinaryAsync_does_not_block_the_caller_during_license_validation()
|
||||
{
|
||||
// Validation is a sync HTTP call; run inline it would hold the caller's thread
|
||||
// (e.g. a UI thread) for the whole request.
|
||||
using var gate = new ManualResetEventSlim();
|
||||
License.ValidateLicenseOverride = _ =>
|
||||
{
|
||||
gate.Wait(TimeSpan.FromSeconds(2));
|
||||
return new LicenseInfo(false, "solo", null);
|
||||
};
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var task = Download.EnsureBinaryAsync("cb_slow");
|
||||
var returnedAfter = sw.Elapsed;
|
||||
var completedOnReturn = task.IsCompleted;
|
||||
gate.Set();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => task);
|
||||
Assert.False(completedOnReturn);
|
||||
Assert.True(returnedAfter < TimeSpan.FromSeconds(1), $"caller blocked {returnedAfter}");
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// GetProLatestVersion - rate limiting + marker
|
||||
// =======================================================================
|
||||
|
||||
@@ -27,6 +27,7 @@ public class GeoIpTests
|
||||
// Extended-coverage entries (parity with Python/JS COUNTRY_LOCALE_MAP).
|
||||
Assert.Equal("sl-SI", GeoIp.CountryLocaleMap["SI"]);
|
||||
Assert.Equal("es-PE", GeoIp.CountryLocaleMap["PE"]);
|
||||
Assert.Equal("en-SC", GeoIp.CountryLocaleMap["SC"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CloakBrowser.Human;
|
||||
using Xunit;
|
||||
@@ -192,23 +193,24 @@ public class ScrollFallbackTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Smooth_wheel_never_sends_a_zero_step()
|
||||
public async Task Smooth_wheel_sends_whole_notches_only()
|
||||
{
|
||||
// A smooth wheel is split into 20-40px chunks and the last one is the
|
||||
// remainder. A remainder under 0.5px used to round to a (0, 0) wheel event,
|
||||
// which made Box_past_right_edge_scrolls_x_axis_only flaky (~1 run in 60).
|
||||
// A 21-40px delta is one or two chunks and leaves such a remainder a few
|
||||
// percent of the time, so 300 calls hit it with near certainty.
|
||||
var mouse = new CountingMouse();
|
||||
double total = 0;
|
||||
for (int i = 0; i < 300; i++)
|
||||
// CDP reports every wheel event as one notch (wheelDeltaY 120), so each
|
||||
// event's pixel delta must be one whole notch - never a chunk or (0, 0).
|
||||
foreach (double notch in new[] { 40.0, 100.0, 120.0 })
|
||||
{
|
||||
int delta = 21 + i % 20;
|
||||
total += delta;
|
||||
await HumanScroll.SmoothWheelAsync(mouse, 0, delta, FastConfig());
|
||||
var cfg = FastConfig();
|
||||
cfg.ScrollNotchPx = notch;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var mouse = new CountingMouse();
|
||||
double delta = (i % 2 == 1 ? -1 : 1) * (notch + 37 * i);
|
||||
await HumanScroll.SmoothWheelAsync(mouse, 0, delta, cfg);
|
||||
Assert.NotEmpty(mouse.Wheels);
|
||||
Assert.All(mouse.Wheels, w => { Assert.Equal(0, w.Dx); Assert.Equal(notch, Math.Abs(w.Dy)); });
|
||||
Assert.True(Math.Abs(mouse.Wheels.Sum(w => w.Dy) - delta) <= notch / 2);
|
||||
}
|
||||
}
|
||||
Assert.DoesNotContain(mouse.Wheels, w => w.Dx == 0 && w.Dy == 0);
|
||||
Assert.Equal(total, mouse.Wheels.Sum(w => w.Dy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -17,7 +17,6 @@ public class LocatorWrapperTests
|
||||
IdleBetweenActions = false,
|
||||
MouseMinSteps = 2,
|
||||
MouseMaxSteps = 3,
|
||||
MouseBurstPause = (0, 0),
|
||||
MouseOvershootChance = 0,
|
||||
ClickAimDelayButton = (0, 0),
|
||||
ClickHoldButton = (0, 0),
|
||||
|
||||
@@ -18,8 +18,6 @@ public class MouseKeyboardWrapperTests
|
||||
IdleBetweenActions = false,
|
||||
MouseMinSteps = 2,
|
||||
MouseMaxSteps = 3,
|
||||
MouseBurstSize = (5, 5),
|
||||
MouseBurstPause = (0, 0),
|
||||
MouseOvershootChance = 0, // deterministic: no overshoot
|
||||
ClickAimDelayButton = (0, 0),
|
||||
ClickHoldButton = (0, 0),
|
||||
|
||||
@@ -19,7 +19,6 @@ public class PageWrapperTests
|
||||
IdleBetweenActions = false,
|
||||
MouseMinSteps = 2,
|
||||
MouseMaxSteps = 3,
|
||||
MouseBurstPause = (0, 0),
|
||||
MouseOvershootChance = 0,
|
||||
ClickAimDelayButton = (0, 0),
|
||||
ClickHoldButton = (0, 0),
|
||||
|
||||
+8
-8
@@ -13,7 +13,7 @@ Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the
|
||||
|
||||
- **87 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
|
||||
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 50+ 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
|
||||
@@ -107,8 +107,8 @@ const browser = await launch({
|
||||
locale: 'en-US',
|
||||
});
|
||||
|
||||
// Pin an exact Chromium version (Free or Pro)
|
||||
const browser = await launch({ browserVersion: '146.0.7680.177.5' });
|
||||
// Pin an exact Chromium version (paid Pro key only)
|
||||
const browser = await launch({ licenseKey: 'cb_xxxxxxxx', browserVersion: '154.0.8037.57.1' });
|
||||
|
||||
// Opt into the Pro Preview binary channel
|
||||
const browser = await launch({
|
||||
@@ -208,7 +208,7 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
||||
| `navigator.webdriver` | `true` | **`false`** |
|
||||
| CDP detection | Detected | **Not detected** |
|
||||
| TLS fingerprint | Mismatch | **Identical to Chrome** |
|
||||
| | | **Tested against 30+ detection sites** |
|
||||
| | | **Tested against 50+ detection sites** |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -221,7 +221,7 @@ if (newVersion) console.log(`Updated to ${newVersion}`);
|
||||
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
|
||||
| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary) |
|
||||
| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts |
|
||||
| `CLOAKBROWSER_VERSION` | — | Pin to an exact Chromium version for rollback (e.g. `146.0.7680.177.5`). Works with Free and Pro binaries |
|
||||
| `CLOAKBROWSER_VERSION` | — | Pin to an exact Chromium version for rollback (e.g. `154.0.8037.57.1`). Paid Pro key only |
|
||||
| `CLOAKBROWSER_RELEASE_CHANNEL` | `stable` | Set to `preview` to opt into the Pro Preview binary channel |
|
||||
|
||||
### Widevine / DRM
|
||||
@@ -347,14 +347,14 @@ Or set `CLOAKBROWSER_RELEASE_CHANNEL=preview` for all launches and CLI commands.
|
||||
|
||||
**New update broke something? Roll back to the previous version**
|
||||
|
||||
Pin to an exact binary version (keep current wrapper, use older Chromium):
|
||||
Pin to an exact binary version (keep current wrapper, use older Chromium). Paid Pro key only. A free GitHub key cannot pin: it ignores the pin and always gets the latest build.
|
||||
|
||||
```bash
|
||||
export CLOAKBROWSER_VERSION=146.0.7680.177.5 # env var for all launches
|
||||
export CLOAKBROWSER_VERSION=154.0.8037.57.1 # env var for all launches
|
||||
```
|
||||
|
||||
```javascript
|
||||
const browser = await launch({ browserVersion: '146.0.7680.177.5' });
|
||||
const browser = await launch({ licenseKey: 'cb_xxxxxxxx', browserVersion: '154.0.8037.57.1' });
|
||||
```
|
||||
|
||||
The pin is never sticky — unpinned launches always use the latest available version.
|
||||
|
||||
Generated
+40
-40
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tar": "^7.0.0"
|
||||
@@ -17,7 +17,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.0.1",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"playwright-core": "1.63",
|
||||
"playwright-core": "1.64",
|
||||
"puppeteer-core": "^25.0.4",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
@@ -99,13 +99,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.3.tgz",
|
||||
"integrity": "sha512-2Bt3m6dDAJqmZehn0wiXSYyQmuhyiHHZ7FjvrVaS1W2RoFGV6eCK71UvKYkNJjkwC1Q9nobjhvTMhLQztFVINA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.4.tgz",
|
||||
"integrity": "sha512-okJ6yQMc0SBexOKxMi966omwAV1uAPwgF/YMZdb04gZQPXVjWHl9bmXjZYtIcsN1+PN3Bz4iFfGK3g+c6lZJ0Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.8.4",
|
||||
"modern-tar": "^0.8.5",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -449,9 +449,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz",
|
||||
"integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==",
|
||||
"version": "26.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
|
||||
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -837,9 +837,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
|
||||
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -892,14 +892,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
||||
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
|
||||
"version": "157.0.8090-0",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
|
||||
"integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||
@@ -971,9 +971,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1687809",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1687809.tgz",
|
||||
"integrity": "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g==",
|
||||
"version": "0.0.1696802",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1696802.tgz",
|
||||
"integrity": "sha512-FO5/SENfvc2K8wRnWQflKcwuoTH4dBkXCWTQ9ebIWtuX8JM5+ZvCotaEmkfh21pO7TvIKGTM3hG4xFiq9WTwmg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
@@ -1495,9 +1495,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1538,17 +1538,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.12.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.12.0.tgz",
|
||||
"integrity": "sha512-z6LQUt5SH7jwGtGA+nMtJuEjZIsqPiwQ+EFHCYuVDQrJpvifNmmGuyDvTzAKPyFzihxgHc5f3aMzu2kn8Ps37A==",
|
||||
"version": "25.13.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.13.0.tgz",
|
||||
"integrity": "sha512-cyBS0OEx3e9NS53B5F6+UazXtR7f9Tw0cR1/FbPW59/hV6JPZbkIiMnxK2543IkFp3rsGlLn1iPDLF77TJsJsA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.2.3",
|
||||
"chromium-bidi": "17.0.2",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.3",
|
||||
"@puppeteer/browsers": "3.2.4",
|
||||
"chromium-bidi": "157.0.8090-0",
|
||||
"devtools-protocol": "0.0.1696802",
|
||||
"typed-query-selector": "^2.12.3",
|
||||
"webdriver-bidi-protocol": "1.0.1",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1746,9 +1746,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"version": "2.12.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
|
||||
"integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1957,9 +1957,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
|
||||
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
|
||||
"integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
@@ -2082,9 +2082,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloakbrowser",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.3",
|
||||
"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",
|
||||
@@ -83,7 +83,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.0.1",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"playwright-core": "1.63",
|
||||
"playwright-core": "1.64",
|
||||
"puppeteer-core": "^25.0.4",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
|
||||
+7
-1
@@ -63,6 +63,7 @@ export const COUNTRY_LOCALE_MAP: Record<string, string> = {
|
||||
CM: "fr-CM", AO: "pt-AO", MZ: "pt-MZ", ZM: "en-ZM", ZW: "en-ZW",
|
||||
HN: "es-HN", NI: "es-NI", SV: "es-SV", PA: "es-PA", JM: "en-JM",
|
||||
TT: "en-TT", PR: "es-PR",
|
||||
SC: "en-SC",
|
||||
};
|
||||
|
||||
export interface GeoResult {
|
||||
@@ -124,8 +125,13 @@ export async function resolveProxyGeo(
|
||||
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 =
|
||||
let locale =
|
||||
countryCode ? (COUNTRY_LOCALE_MAP[countryCode] ?? null) : null;
|
||||
if (locale === null) {
|
||||
// Map gap, not a lookup failure — don't abort the launch over it
|
||||
console.warn(`[cloakbrowser] GeoIP: no locale for country ${countryCode} (IP ${ip}), using en-US`);
|
||||
locale = "en-US";
|
||||
}
|
||||
return { timezone, locale, exitIp: ip };
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -50,9 +50,9 @@ import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext }
|
||||
import type { HumanConfig, HumanActionOptions } from '../human/config.js';
|
||||
import { mergeConfig, rand, randRange, sleep } from '../human/config.js';
|
||||
import { type RawMouse, type RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
|
||||
import { pressWithDelay } from '../human/keyboard.js';
|
||||
import { pressWithDelay, keyHold } from '../human/keyboard.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement, humanScrollIntoView, smoothWheel } from './scroll.js';
|
||||
import { scrollToElement, humanScrollIntoView, smoothWheel, pageNotchPx } from './scroll.js';
|
||||
|
||||
export type { HumanConfig } from '../human/config.js';
|
||||
export { resolveConfig, mergeConfig } from '../human/config.js';
|
||||
@@ -332,8 +332,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
|
||||
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]);
|
||||
cursor.x = Math.round(rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]));
|
||||
cursor.y = Math.round(rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]));
|
||||
await originals.mouseMove(cursor.x, cursor.y);
|
||||
cursor.initialized = true;
|
||||
}
|
||||
@@ -461,11 +461,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
(page.mouse as any).wheel = async (options?: { deltaX?: number; deltaY?: number }) => {
|
||||
const dx = options?.deltaX ?? 0;
|
||||
const dy = options?.deltaY ?? 0;
|
||||
// page.mouse.wheel asks for a distance; turn the wheel by the nearest
|
||||
// whole number of notches (at least one), like a person would.
|
||||
const notch = await pageNotchPx(page, cfg);
|
||||
if (Math.abs(dy) > 0) {
|
||||
await smoothWheel(raw, dy, cfg, 'y');
|
||||
await smoothWheel(raw, Math.sign(dy) * Math.max(notch, Math.abs(dy)), cfg, 'y', notch);
|
||||
}
|
||||
if (Math.abs(dx) > 0) {
|
||||
await smoothWheel(raw, dx, cfg, 'x');
|
||||
await smoothWheel(raw, Math.sign(dx) * Math.max(notch, Math.abs(dx)), cfg, 'x', notch);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -501,7 +504,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
|
||||
page.keyboard.press = async (key: any, options?: { delay?: number }) => {
|
||||
await sleep(rand(20, 60));
|
||||
await pressWithDelay(originals.keyboardPress, key, options, randRange(cfg.key_hold));
|
||||
await pressWithDelay(originals.keyboardPress, key, options, keyHold(cfg));
|
||||
};
|
||||
|
||||
page.keyboard.down = async (key: any) => {
|
||||
@@ -726,7 +729,7 @@ function patchSingleElementHandle(
|
||||
if (origElPress) {
|
||||
(el as any).press = async (key: string, options?: { delay?: number }) => {
|
||||
await sleep(rand(20, 60));
|
||||
await pressWithDelay(originals.keyboardPress, key, options, randRange(cfg.key_hold));
|
||||
await pressWithDelay(originals.keyboardPress, key, options, keyHold(cfg));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,9 @@
|
||||
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';
|
||||
import { randRange, sleep } from '../human/config.js';
|
||||
import { Typist, type TypistKeys } from '../human/keyboard.js';
|
||||
|
||||
const SHIFT_SYMBOLS = new Set([
|
||||
'@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
|
||||
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~',
|
||||
]);
|
||||
|
||||
const NEARBY_KEYS: Record<string, string> = {
|
||||
a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf',
|
||||
@@ -33,22 +30,7 @@ const NEARBY_KEYS: Record<string, string> = {
|
||||
'6': '57', '7': '68', '8': '79', '9': '80', '0': '9',
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -65,9 +47,6 @@ function getNearbyKey(ch: string): string {
|
||||
return ch;
|
||||
}
|
||||
|
||||
function isUpperCase(ch: string): boolean {
|
||||
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||
}
|
||||
|
||||
export async function humanType(
|
||||
page: Page,
|
||||
@@ -76,117 +55,45 @@ export async function humanType(
|
||||
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);
|
||||
// A typo next to an uppercase letter is typed with Shift held, like the
|
||||
// letter itself (a bare keydown of "A" reports shiftKey=false).
|
||||
if (isUpperCase(wrong)) await typeShiftedChar(raw, wrong, cfg);
|
||||
else 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 }));
|
||||
// Same key timeline as the Playwright engine: log-normal holds, rollover,
|
||||
// Shift usually released before the letter.
|
||||
const keys: TypistKeys = {
|
||||
down: (k) => raw.down(k),
|
||||
up: (k) => raw.up(k),
|
||||
insertText: (t) => raw.insertText(t),
|
||||
cdp: async (params) => {
|
||||
if (cdpSession) {
|
||||
await cdpSession.send('Input.dispatchKeyEvent', params);
|
||||
} else if (params.type === 'keyDown') {
|
||||
// No CDP session: insert the symbol and fire synthetic key events
|
||||
// (detectable via isTrusted=false).
|
||||
await raw.insertText(params.key);
|
||||
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 }));
|
||||
}
|
||||
}, params.key);
|
||||
}
|
||||
}, 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));
|
||||
},
|
||||
};
|
||||
const typist = new Typist(keys, cfg);
|
||||
try {
|
||||
for (const ch of [...text]) {
|
||||
// Mistype
|
||||
if (isAscii(ch) && /^[a-zA-Z0-9]$/.test(ch) && Math.random() < cfg.mistype_chance) {
|
||||
await typist.key(getNearbyKey(ch));
|
||||
await typist.rest();
|
||||
await sleep(randRange(cfg.mistype_delay_notice));
|
||||
await typist.key('Backspace');
|
||||
await typist.rest();
|
||||
await sleep(randRange(cfg.mistype_delay_correct));
|
||||
}
|
||||
await typist.key(ch);
|
||||
}
|
||||
await typist.finish();
|
||||
} finally {
|
||||
await typist.releaseAll();
|
||||
}
|
||||
}
|
||||
@@ -36,30 +36,63 @@ function isInViewportX(bounds: ElementBounds, viewportWidth: number): boolean {
|
||||
return bounds.x >= 0 && bounds.x + bounds.width <= viewportWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS pixels one wheel notch scrolls on `platform` (navigator.platform):
|
||||
* Chrome scrolls 100 px per notch on Windows (3 lines of 33.3 px), 40 on
|
||||
* macOS and 120 on Linux, and reports `wheelDelta` 120 per notch. CDP always
|
||||
* reports one notch per wheel event, so whole-notch events are the only ones
|
||||
* whose `deltaY` and `wheelDeltaY` agree.
|
||||
*/
|
||||
export function notchPxFor(platform: string, cfg: HumanConfig): number {
|
||||
if (cfg.scroll_notch_px && cfg.scroll_notch_px > 0) return cfg.scroll_notch_px;
|
||||
const p = (platform || '').toLowerCase();
|
||||
if (p.startsWith('mac')) return 40;
|
||||
if (p.startsWith('win')) return 100;
|
||||
return 120;
|
||||
}
|
||||
|
||||
/** Notch size of `page`'s persona (cached per page). */
|
||||
const notchCache = new WeakMap<object, string>();
|
||||
export async function pageNotchPx(page: Page, cfg: HumanConfig): Promise<number> {
|
||||
let plat = notchCache.get(page);
|
||||
if (plat === undefined) {
|
||||
try {
|
||||
const stealth = (page as any)._stealth;
|
||||
plat = String(stealth ? await stealth.evaluate('navigator.platform') : await page.evaluate(() => navigator.platform)) || '';
|
||||
} catch { plat = ''; }
|
||||
notchCache.set(page, plat);
|
||||
}
|
||||
return notchPxFor(plat, cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the wheel by about `delta` px in whole notches of `notch` px: flicks
|
||||
* of a few notches, quicker inside a flick, with a short pause between
|
||||
* flicks. Returns the distance actually wheeled (a whole number of notches).
|
||||
*/
|
||||
export async function smoothWheel(
|
||||
raw: RawMouse,
|
||||
delta: number,
|
||||
cfg: HumanConfig,
|
||||
axis: 'x' | 'y' = 'y',
|
||||
): Promise<void> {
|
||||
const absD = Math.abs(delta);
|
||||
notch = 100,
|
||||
): Promise<number> {
|
||||
const sign = delta > 0 ? 1 : -1;
|
||||
let sent = 0;
|
||||
while (sent < absD) {
|
||||
const stepSize = rand(20, 40);
|
||||
let chunk = Math.min(stepSize, absD - sent);
|
||||
// Fold a sub-pixel tail into this step: on its own it would round to a
|
||||
// (0, 0) wheel event, which no real wheel sends.
|
||||
if (absD - sent - chunk < 0.5) chunk = absD - sent;
|
||||
const d = Math.round(chunk) * sign;
|
||||
if (axis === 'x') {
|
||||
await raw.wheel(d, 0);
|
||||
const notches = Math.round(Math.abs(delta) / notch);
|
||||
let flick = randIntRange([2, 5]);
|
||||
for (let i = 0; i < notches; i++) {
|
||||
const d = notch * sign;
|
||||
if (axis === 'x') await raw.wheel(d, 0);
|
||||
else await raw.wheel(0, d);
|
||||
if (i === notches - 1) break;
|
||||
if (--flick <= 0) {
|
||||
flick = randIntRange([2, 5]);
|
||||
await sleep(randRange(cfg.scroll_pause_slow) + rand(40, 120));
|
||||
} else {
|
||||
await raw.wheel(0, d);
|
||||
await sleep(randRange(cfg.scroll_pause_fast));
|
||||
}
|
||||
sent += chunk;
|
||||
await sleep(rand(8, 20));
|
||||
}
|
||||
return notches * notch * sign;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,8 +144,9 @@ export async function humanScrollIntoView(
|
||||
}
|
||||
if (!viewport || !viewport.height) throw new Error('Viewport size not available');
|
||||
|
||||
const yPass = await scrollYIntoView(raw, getBox, viewport, cursorX, cursorY, cfg);
|
||||
return scrollXIntoView(raw, getBox, yPass.box, viewport, yPass.cursorX, yPass.cursorY, cfg);
|
||||
const notch = await pageNotchPx(page, cfg);
|
||||
const yPass = await scrollYIntoView(raw, getBox, viewport, cursorX, cursorY, cfg, notch);
|
||||
return scrollXIntoView(raw, getBox, yPass.box, viewport, yPass.cursorX, yPass.cursorY, cfg, notch);
|
||||
}
|
||||
|
||||
/** Vertical pass: bring the box into ``scroll_target_zone``. */
|
||||
@@ -123,6 +157,7 @@ async function scrollYIntoView(
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
notch: number,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
let box = await getBox();
|
||||
if (!box) throw new Error('Element not found while scrolling into view');
|
||||
@@ -139,63 +174,33 @@ async function scrollYIntoView(
|
||||
cursorY = scrollAreaY;
|
||||
await sleep(randRange(cfg.scroll_pre_move_delay));
|
||||
|
||||
// Calculate scroll distance
|
||||
// Wheel in whole notches, re-measuring between flicks, until the element
|
||||
// sits in the target zone (or the page stops moving).
|
||||
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 getBox();
|
||||
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);
|
||||
let overshot = false;
|
||||
let stalled = 0;
|
||||
for (let round = 0; round < 40 && stalled < 2; round++) {
|
||||
const distance = box.y + box.height / 2 - targetY;
|
||||
if (Math.abs(distance) < notch / 2 || isInViewport(box, viewport.height, cfg)) break;
|
||||
const direction = distance > 0 ? 1 : -1;
|
||||
const flick = Math.min(Math.round(Math.abs(distance) / notch), randIntRange([2, 5]));
|
||||
const beforeY = box.y;
|
||||
await smoothWheel(raw, flick * notch * direction, cfg, 'y', notch);
|
||||
await sleep(randRange(cfg.scroll_pause_slow));
|
||||
const nb = await getBox();
|
||||
if (!nb) break;
|
||||
box = nb;
|
||||
stalled = Math.abs(box.y - beforeY) < 1 ? stalled + 1 : 0;
|
||||
if (!overshot && Math.random() < cfg.scroll_overshoot_chance &&
|
||||
Math.abs(box.y + box.height / 2 - targetY) < notch * 2) {
|
||||
// Optional overshoot + correction, in whole notches.
|
||||
overshot = true;
|
||||
const over = Math.max(1, Math.round(randRange(cfg.scroll_overshoot_px) / notch));
|
||||
await smoothWheel(raw, over * notch * direction, cfg, 'y', notch);
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
await smoothWheel(raw, -over * notch * direction, cfg, 'y', notch);
|
||||
await sleep(rand(100, 250));
|
||||
box = (await getBox()) ?? box;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +226,7 @@ async function scrollXIntoView(
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
notch: number,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
if (isInViewportX(box, viewport.width)) {
|
||||
return { box, cursorX, cursorY };
|
||||
@@ -239,7 +245,7 @@ async function scrollXIntoView(
|
||||
cursorY = scrollAreaY;
|
||||
await sleep(randRange(cfg.scroll_pre_move_delay));
|
||||
|
||||
await smoothWheel(raw, Math.round(distanceToScroll), cfg, 'x');
|
||||
await smoothWheel(raw, distanceToScroll, cfg, 'x', notch);
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
|
||||
const finalBox = await getBox();
|
||||
|
||||
+89
-36
@@ -25,14 +25,14 @@ export interface HumanConfig {
|
||||
|
||||
|
||||
// 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_time_base: number;
|
||||
mouse_time_per_bit: number;
|
||||
mouse_time_spread: number;
|
||||
|
||||
// Mouse — clicks
|
||||
click_aim_delay_input: [number, number];
|
||||
@@ -46,8 +46,7 @@ export interface HumanConfig {
|
||||
idle_pause_range: [number, number];
|
||||
|
||||
// Scroll
|
||||
scroll_delta_base: [number, number];
|
||||
scroll_delta_variance: number;
|
||||
scroll_notch_px: number;
|
||||
scroll_pause_fast: [number, number];
|
||||
scroll_pause_slow: [number, number];
|
||||
scroll_accel_steps: [number, number];
|
||||
@@ -82,29 +81,47 @@ export type HumanActionOptions = Partial<HumanConfig> & {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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],
|
||||
// Keyboard. Defaults follow the 136M Keystrokes study (Dhakal et al.,
|
||||
// CHI 2018): keys are held ~100 ms, ~150 ms pass between key presses, and
|
||||
// the next key is often pressed before the previous one is released.
|
||||
//
|
||||
// typing_delay: median time from one key press to the next (ms); the gap is
|
||||
// log-normal and depends on the letters (other hand: faster, same finger:
|
||||
// slower, after a space: slower).
|
||||
typing_delay: 150,
|
||||
// How much the gap varies (ms); 0 = always exactly typing_delay.
|
||||
typing_delay_spread: 70,
|
||||
// Chance of a thinking pause before a word (a tenth of it inside a word).
|
||||
typing_pause_chance: 0.15,
|
||||
typing_pause_range: [300, 1000],
|
||||
// Shift press -> letter press (ms, log-normal: ~5th..95th percentile).
|
||||
shift_down_delay: [60, 220],
|
||||
// Shift release relative to the letter release (ms). Negative = Shift goes
|
||||
// up first, which is what people usually do.
|
||||
shift_up_delay: [-60, 30],
|
||||
// How long a key is held down (ms, log-normal: ~5th..95th percentile).
|
||||
key_hold: [60, 175],
|
||||
field_switch_delay: [800, 1500],
|
||||
// Mistype (typo simulation)
|
||||
mistype_chance: 0.02,
|
||||
// Mistype (typo simulation): chance per letter / digit.
|
||||
mistype_chance: 0.04,
|
||||
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 — movement. A move lasts (Fitts' law, log-normal spread)
|
||||
// median = mouse_time_base + mouse_time_per_bit * log2(1 + distance / 8) ms;
|
||||
// the speed peaks early (a fast main stroke, then a slow approach) and
|
||||
// pointer events go out at ~60 Hz.
|
||||
mouse_time_base: 160,
|
||||
mouse_time_per_bit: 75,
|
||||
mouse_time_spread: 0.3,
|
||||
mouse_min_steps: 2, // fewest pointer events per stroke
|
||||
mouse_max_steps: 200, // most pointer events per stroke
|
||||
mouse_wobble_max: 1.5, // smooth sideways drift (px)
|
||||
// Chance that the main stroke lands a little off and a short corrective
|
||||
// stroke finishes the move (the miss is a few % of the distance plus
|
||||
// mouse_overshoot_px).
|
||||
mouse_overshoot_chance: 0.35,
|
||||
mouse_overshoot_px: [2, 6],
|
||||
|
||||
// Mouse — clicks
|
||||
click_aim_delay_input: [60, 140],
|
||||
@@ -117,9 +134,12 @@ const DEFAULT_CONFIG: HumanConfig = {
|
||||
idle_drift_px: 3,
|
||||
idle_pause_range: [300, 1000],
|
||||
|
||||
// Scroll
|
||||
scroll_delta_base: [80, 130],
|
||||
scroll_delta_variance: 0.2,
|
||||
// Scroll. The wheel turns in whole notches: one wheel event per notch of
|
||||
// scroll_notch_px CSS pixels, so deltaY and wheelDeltaY agree the way they
|
||||
// do for a real mouse wheel. 0 = what Chrome uses on the persona's platform
|
||||
// (100 on Windows, 40 on macOS, 120 on Linux).
|
||||
scroll_notch_px: 0,
|
||||
// Pause between notches inside one flick / at the start and end of it.
|
||||
scroll_pause_fast: [30, 80],
|
||||
scroll_pause_slow: [80, 200],
|
||||
scroll_accel_steps: [2, 3],
|
||||
@@ -147,21 +167,23 @@ const CAREFUL_CONFIG: HumanConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
|
||||
// Keyboard — slower typing
|
||||
typing_delay: 100,
|
||||
typing_delay_spread: 50,
|
||||
typing_pause_chance: 0.15,
|
||||
typing_delay: 210,
|
||||
typing_delay_spread: 100,
|
||||
typing_pause_chance: 0.25,
|
||||
typing_pause_range: [500, 1200],
|
||||
shift_down_delay: [40, 90],
|
||||
shift_up_delay: [30, 70],
|
||||
key_hold: [20, 45],
|
||||
shift_down_delay: [80, 260],
|
||||
shift_up_delay: [-50, 40],
|
||||
key_hold: [70, 190],
|
||||
field_switch_delay: [1000, 2000],
|
||||
mistype_chance: 0.03,
|
||||
mistype_chance: 0.05,
|
||||
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_time_base: 220,
|
||||
mouse_time_per_bit: 95,
|
||||
mouse_overshoot_chance: 0.45,
|
||||
mouse_overshoot_px: [2, 5],
|
||||
|
||||
// Mouse — clicks (longer aiming and holding)
|
||||
click_aim_delay_input: [80, 180],
|
||||
@@ -249,6 +271,37 @@ export function randIntRange(range: [number, number]): number {
|
||||
return randInt(range[0], range[1]);
|
||||
}
|
||||
|
||||
const Z95 = 1.6449; // standard normal 95th percentile
|
||||
|
||||
/** Standard normal sample (Box-Muller). */
|
||||
export function gauss(): number {
|
||||
let u = 0;
|
||||
while (u === 0) u = Math.random();
|
||||
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * Math.random());
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-normal sample with `range` as its ~5th / 95th percentiles, clipped to
|
||||
* [low / 2, high * 2]. Human timings (key holds, gaps) are right-skewed like
|
||||
* this, not uniform. Falls back to uniform when a bound is not positive.
|
||||
*/
|
||||
export function randLog(range: [number, number]): number {
|
||||
let lo = range[0], hi = range[1];
|
||||
if (hi < lo) [lo, hi] = [hi, lo];
|
||||
if (lo <= 0) return rand(lo, hi);
|
||||
if (hi === lo) return lo;
|
||||
const mu = (Math.log(lo) + Math.log(hi)) / 2;
|
||||
const sigma = (Math.log(hi) - Math.log(lo)) / (2 * Z95);
|
||||
return Math.min(hi * 2, Math.max(lo / 2, Math.exp(mu + sigma * gauss())));
|
||||
}
|
||||
|
||||
/** Log-normal sample with the given median and log-space sigma. */
|
||||
export function randLognormal(median: number, sigma: number): number {
|
||||
if (median <= 0) return 0;
|
||||
if (sigma <= 0) return median;
|
||||
return median * Math.exp(sigma * gauss());
|
||||
}
|
||||
|
||||
/** Sleep for `ms` milliseconds. */
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
+135
-117
@@ -16,9 +16,9 @@
|
||||
|
||||
import type { Frame, Page, ElementHandle, CDPSession } from 'playwright-core';
|
||||
import { errors } from 'playwright-core';
|
||||
import { type HumanConfig, mergeConfig, rand, randRange, randIntRange, sleep } from './config.js';
|
||||
import { NEARBY_KEYS, SHIFT_SYMBOLS, SHIFT_SYMBOL_CODES, SHIFT_SYMBOL_KEYCODES } from './keyboard.js';
|
||||
import { bezier, easeInOut, randomControlPoints, clickTarget } from './mouse.js';
|
||||
import { type HumanConfig, mergeConfig, rand, randLog, randRange, randIntRange, sleep } from './config.js';
|
||||
import { NEARBY_KEYS, keyGap, keyHold, Typist, type TypistKeys } from './keyboard.js';
|
||||
import { clickTarget, planMove } from './mouse.js';
|
||||
import * as fields from './fields.js';
|
||||
import { HELPERS, StaleElement, Worlds, intersect, type Rect } from './world.js';
|
||||
|
||||
@@ -180,41 +180,25 @@ export class Human {
|
||||
async ensureCursor(cfg?: HumanConfig): Promise<void> {
|
||||
if (!this.cursor.initialized) {
|
||||
const c = cfg ?? this.cfg;
|
||||
this.cursor.x = rand(c.initial_cursor_x[0], c.initial_cursor_x[1]);
|
||||
this.cursor.y = rand(c.initial_cursor_y[0], c.initial_cursor_y[1]);
|
||||
this.cursor.x = Math.round(rand(c.initial_cursor_x[0], c.initial_cursor_x[1]));
|
||||
this.cursor.y = Math.round(rand(c.initial_cursor_y[0], c.initial_cursor_y[1]));
|
||||
await this.raw.move(this.cursor.x, this.cursor.y);
|
||||
this.cursor.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bezier move from the current cursor position to (x, y). */
|
||||
/** Move from the current cursor position to (x, y): an early-peaking main
|
||||
* stroke timed by Fitts' law, sometimes a short corrective stroke, one
|
||||
* pointer event per frame and none without movement. */
|
||||
async moveTo(x: number, y: number, cfg: HumanConfig): Promise<void> {
|
||||
await this.ensureCursor(cfg);
|
||||
const sx = this.cursor.x, sy = this.cursor.y;
|
||||
const dist = Math.hypot(x - sx, y - sy);
|
||||
if (dist >= 1) {
|
||||
const steps = Math.max(cfg.mouse_min_steps, Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)));
|
||||
const start = { x: sx, y: sy }, end = { x, y };
|
||||
const [cp1, cp2] = randomControlPoints(start, end);
|
||||
let burst = 0;
|
||||
const burstSize = randIntRange(cfg.mouse_burst_size);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const progress = i / steps;
|
||||
if (i === steps) { await this.raw.move(x, y); continue; } // exact end point
|
||||
const pt = bezier(start, cp1, cp2, end, easeInOut(progress));
|
||||
const wobble = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max;
|
||||
await this.raw.move(Math.round(pt.x + (Math.random() - 0.5) * 2 * wobble),
|
||||
Math.round(pt.y + (Math.random() - 0.5) * 2 * wobble));
|
||||
burst++;
|
||||
if (burst >= burstSize) { await sleepMs(randRange(cfg.mouse_burst_pause)); burst = 0; }
|
||||
}
|
||||
if (Math.random() < cfg.mouse_overshoot_chance) {
|
||||
const angle = Math.atan2(y - sy, x - sx);
|
||||
const over = randRange(cfg.mouse_overshoot_px);
|
||||
await this.raw.move(Math.round(x + Math.cos(angle) * over), Math.round(y + Math.sin(angle) * over));
|
||||
await sleepMs(rand(30, 70));
|
||||
await this.raw.move(x, y);
|
||||
}
|
||||
let clock = performance.now();
|
||||
for (const [px, py, dt] of planMove(this.cursor.x, this.cursor.y, x, y, cfg)) {
|
||||
// Keep to the planned schedule: the CDP round trip of each move comes
|
||||
// out of the next wait instead of adding to it.
|
||||
clock += dt;
|
||||
await sleepMs(clock - performance.now());
|
||||
await this.raw.move(px, py);
|
||||
}
|
||||
this.cursor.x = x; this.cursor.y = y;
|
||||
}
|
||||
@@ -396,10 +380,19 @@ export class Human {
|
||||
let box = await this.elementBox(r);
|
||||
const vp = await this.worlds.viewport();
|
||||
let stalled = 0, rounds = 0, overshot = false;
|
||||
let last: [number, number] = [0, 0];
|
||||
const notch = await this.notchPx(cfg);
|
||||
while (stalled < 2 && rounds < 40 && !deadline.expired()) {
|
||||
const [dx, dy] = await this.neededScroll(r, box, vp, cfg);
|
||||
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) break;
|
||||
rounds++;
|
||||
// Whole notches can jump over a target whose slack is smaller than one
|
||||
// notch (a short scroll box): after such a reversal, finish with one
|
||||
// wheel event of the exact remainder (a free-spinning or high-resolution
|
||||
// wheel sends those) instead of swinging forever.
|
||||
const fineY = dy * last[1] < 0 && Math.abs(dy) < notch;
|
||||
const fineX = dx * last[0] < 0 && Math.abs(dx) < notch;
|
||||
last = [dx, dy];
|
||||
const area = box.clip.width > 4 && box.clip.height > 4 ? box.clip : { x: 0, y: 0, width: vp.width, height: vp.height };
|
||||
if (!this.inside(area)) {
|
||||
await this.moveTo(area.x + area.width * rand(0.3, 0.7), area.y + area.height * rand(0.3, 0.7), cfg);
|
||||
@@ -407,13 +400,19 @@ export class Human {
|
||||
}
|
||||
const before = [box.x, box.y];
|
||||
if (Math.abs(dy) >= 1) {
|
||||
await this.wheelBurst(0, dy, cfg, deadline);
|
||||
if (!overshot) {
|
||||
overshot = true;
|
||||
await this.overshoot(dy, cfg, deadline);
|
||||
if (fineY) await this.raw.wheel(0, Math.round(dy));
|
||||
else {
|
||||
await this.wheelBurst(0, dy, cfg, deadline);
|
||||
if (!overshot) {
|
||||
overshot = true;
|
||||
await this.overshoot(dy, cfg, deadline);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Math.abs(dx) >= 1) await this.wheelBurst(dx, 0, cfg, deadline);
|
||||
if (Math.abs(dx) >= 1) {
|
||||
if (fineX) await this.raw.wheel(Math.round(dx), 0);
|
||||
else await this.wheelBurst(dx, 0, cfg, deadline);
|
||||
}
|
||||
await sleepMs(Math.min(randRange(cfg.scroll_settle_delay), deadline.remaining()));
|
||||
box = await this.elementBox(r);
|
||||
const moved = Math.abs(box.x - before[0]) + Math.abs(box.y - before[1]);
|
||||
@@ -468,38 +467,57 @@ export class Human {
|
||||
private async overshoot(dy: number, cfg: HumanConfig, deadline: Deadline): Promise<void> {
|
||||
if (Math.random() >= cfg.scroll_overshoot_chance || deadline.expired()) return;
|
||||
const sign = dy > 0 ? 1 : -1;
|
||||
await this.wheelBurst(0, Math.round(randRange(cfg.scroll_overshoot_px)) * sign, cfg, deadline);
|
||||
const notch = await this.notchPx(cfg);
|
||||
const over = Math.max(1, Math.round(randRange(cfg.scroll_overshoot_px) / notch));
|
||||
await this.wheelBurst(0, over * notch * sign, cfg, deadline);
|
||||
await sleepMs(Math.min(randRange(cfg.scroll_settle_delay), deadline.remaining()));
|
||||
const corrections = randIntRange([1, 2]);
|
||||
for (let i = 0; i < corrections && !deadline.expired(); i++) {
|
||||
await this.wheelBurst(0, Math.round(rand(40, 80)) * -sign, cfg, deadline);
|
||||
// Turn back in one or two flicks.
|
||||
const first = over > 1 && Math.random() < 0.5 ? randIntRange([1, over]) : over;
|
||||
for (const n of [first, over - first]) {
|
||||
if (n <= 0 || deadline.expired()) continue;
|
||||
await this.wheelBurst(0, n * notch * -sign, cfg, deadline);
|
||||
await sleepMs(Math.min(rand(100, 250), deadline.remaining()));
|
||||
}
|
||||
}
|
||||
|
||||
/** One logical scroll: accelerate -> cruise -> decelerate in wheel ticks. */
|
||||
/** CSS pixels one wheel notch scrolls for the persona's platform: Chrome
|
||||
* scrolls 100 px per notch on Windows (3 lines of 33.3 px), 40 on macOS and
|
||||
* 120 on Linux, and reports `wheelDelta` 120 per notch. CDP always reports
|
||||
* one notch per wheel event, so whole-notch events are the only ones whose
|
||||
* `deltaY` and `wheelDeltaY` agree. */
|
||||
async notchPx(cfg: HumanConfig): Promise<number> {
|
||||
if (cfg.scroll_notch_px && cfg.scroll_notch_px > 0) return cfg.scroll_notch_px;
|
||||
const plat = (await this.platform()).toLowerCase();
|
||||
if (plat.startsWith('mac')) return 40;
|
||||
if (plat.startsWith('win')) return 100;
|
||||
return 120;
|
||||
}
|
||||
|
||||
/** One logical scroll: flicks of a few wheel notches each, quicker in the
|
||||
* middle of a flick, with short pauses between flicks. Every event is
|
||||
* exactly one notch, so the page scrolls by the nearest whole number of
|
||||
* notches (the caller re-measures and scrolls again when needed). */
|
||||
async wheelBurst(dx: number, dy: number, cfg: HumanConfig, deadline?: Deadline): Promise<void> {
|
||||
const total = dy ? Math.abs(dy) : Math.abs(dx);
|
||||
const sign = (dy || dx) > 0 ? 1 : -1;
|
||||
const notch = await this.notchPx(cfg);
|
||||
const notches = Math.max(1, Math.round(total / notch));
|
||||
const accel = randIntRange(cfg.scroll_accel_steps), decel = randIntRange(cfg.scroll_decel_steps);
|
||||
const avg = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2;
|
||||
const ticks = Math.max(1, Math.ceil(total / avg));
|
||||
let sent = 0;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
if (sent >= total || deadline?.expired()) break;
|
||||
const pause = (i < accel || i >= ticks - decel) ? randRange(cfg.scroll_pause_slow) : randRange(cfg.scroll_pause_fast);
|
||||
const tick = Math.min(total - sent, randRange(cfg.scroll_delta_base) * (1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance));
|
||||
// Split each tick into small wheel events like real inertia.
|
||||
let left = tick;
|
||||
while (left > 0.5) {
|
||||
const chunk = Math.min(left, rand(20, 40));
|
||||
const step = Math.round(chunk) * sign;
|
||||
if (step) await this.raw.wheel(dx ? step : 0, dy ? step : 0);
|
||||
left -= chunk;
|
||||
await sleepMs(rand(8, 20));
|
||||
let flick = randIntRange([2, 5]);
|
||||
for (let i = 0; i < notches; i++) {
|
||||
if (deadline?.expired()) break;
|
||||
const step = notch * sign;
|
||||
await this.raw.wheel(dx ? step : 0, dy ? step : 0);
|
||||
if (i === notches - 1) break;
|
||||
if (--flick <= 0) {
|
||||
// A finger lifts and turns the wheel again.
|
||||
flick = randIntRange([2, 5]);
|
||||
await sleepMs(randRange(cfg.scroll_pause_slow) + rand(40, 120));
|
||||
} else if (i < accel || i >= notches - decel) {
|
||||
await sleepMs(randRange(cfg.scroll_pause_slow));
|
||||
} else {
|
||||
await sleepMs(randRange(cfg.scroll_pause_fast));
|
||||
}
|
||||
sent += tick;
|
||||
await sleepMs(pause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,9 +693,9 @@ export class Human {
|
||||
async caretToEnd(r: Resolved): Promise<void> {
|
||||
const info = await this.worlds.call(r.frame, 'info', r.id);
|
||||
if (info.tag === 'input') {
|
||||
if (!(await this.worlds.call(r.frame, 'caretAtEnd', r.id))) await this.raw.keyPress('End');
|
||||
if (!(await this.worlds.call(r.frame, 'caretAtEnd', r.id))) await this.pressKey('End');
|
||||
} else {
|
||||
await this.raw.keyPress('Control+End');
|
||||
await this.pressKey('Control+End');
|
||||
}
|
||||
await sleepMs(rand(20, 60));
|
||||
}
|
||||
@@ -692,7 +710,7 @@ export class Human {
|
||||
await this.focusElement(target, opts, api, cfg, deadline, false);
|
||||
await sleepMs(rand(50, 150));
|
||||
const k = await this.keys(key);
|
||||
await this.raw.keyPress(k, opts.delay !== undefined ? { delay: opts.delay } : undefined);
|
||||
await this.pressKey(k, undefined, opts.delay);
|
||||
}
|
||||
|
||||
async type(target: Target, text: string, opts: Opts, api = 'type'): Promise<void> {
|
||||
@@ -732,16 +750,16 @@ export class Human {
|
||||
await sleepMs(rand(100, 250));
|
||||
info = await this.worlds.call(r.frame, 'info', r.id);
|
||||
if (info.value) {
|
||||
await this.raw.keyPress(await this.selectAllKey());
|
||||
await this.pressKey(await this.selectAllKey());
|
||||
await sleepMs(rand(30, 80));
|
||||
await this.raw.keyPress('Backspace');
|
||||
await this.pressKey('Backspace');
|
||||
await sleepMs(rand(50, 150));
|
||||
info = await this.worlds.call(r.frame, 'info', r.id);
|
||||
if (info.value) {
|
||||
// Some widgets ignore select-all; clear what is left by keys.
|
||||
await this.caretToEnd(r);
|
||||
for (let i = 0; i < info.value.length; i++) {
|
||||
await this.raw.keyPress('Backspace');
|
||||
await this.pressKey('Backspace');
|
||||
await sleepMs(rand(15, 40));
|
||||
}
|
||||
}
|
||||
@@ -792,7 +810,7 @@ export class Human {
|
||||
// matching is unreliable, so step with the arrow keys.
|
||||
const diff = parseInt(parts.month, 10) - parseInt(current.month, 10);
|
||||
for (let k = 0; k < Math.abs(diff); k++) {
|
||||
await this.raw.keyPress(diff > 0 ? 'ArrowUp' : 'ArrowDown');
|
||||
await this.pressKey(diff > 0 ? 'ArrowUp' : 'ArrowDown');
|
||||
await sleepMs(rand(60, 140));
|
||||
}
|
||||
} else if (kind === 'ampm') {
|
||||
@@ -804,11 +822,11 @@ export class Human {
|
||||
if (j < digits.length - 1) {
|
||||
// The editor forgets a partial entry after ~1 s without a key;
|
||||
// keep the gap inside a segment short.
|
||||
await sleepMs(Math.min(450, Math.max(40, cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread)));
|
||||
await sleepMs(Math.min(450, Math.max(40, keyGap(cfg, digits[j], digits[j + 1]) - keyHold(cfg))));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (i < segs.length - 1 && !fields.segmentAutoAdvances(kind, parts, inputType)) await this.raw.keyPress('ArrowRight');
|
||||
if (i < segs.length - 1 && !fields.segmentAutoAdvances(kind, parts, inputType)) await this.pressKey('ArrowRight');
|
||||
await sleepMs(rand(60, 160));
|
||||
}
|
||||
const got = await this.worlds.call(r.frame, 'value', r.id);
|
||||
@@ -833,7 +851,7 @@ export class Human {
|
||||
if (Math.abs(cur - want) < 1e-9 || (info.step && Math.abs(cur - want) < info.step / 2)) break;
|
||||
const up = cur < want;
|
||||
const key = !info.rtl ? (up ? 'ArrowRight' : 'ArrowLeft') : (up ? 'ArrowLeft' : 'ArrowRight');
|
||||
await this.raw.keyPress(key);
|
||||
await this.pressKey(key);
|
||||
await sleepMs(rand(40, 110));
|
||||
if ((await read()) === cur) break; // cannot move further (min/max reached)
|
||||
}
|
||||
@@ -872,7 +890,15 @@ export class Human {
|
||||
});
|
||||
if (!p.listbox) await this.selectDropdown(target, r, p.targets.length ? p.targets[0] : null, p, opts, api, deadline);
|
||||
else await this.selectListbox(target, r, p, opts, api, cfg, deadline);
|
||||
const state = await this.worlds.call(r.frame, 'selectState', r.id);
|
||||
let state;
|
||||
try {
|
||||
state = await this.worlds.call(r.frame, 'selectState', r.id);
|
||||
} catch (e) {
|
||||
// The pick navigated the page (a form submitted on change): nothing
|
||||
// left to read back, so report what was chosen, as Playwright does.
|
||||
if (e instanceof StaleElement) return p.values;
|
||||
throw e;
|
||||
}
|
||||
const sorted = (a: number[]) => JSON.stringify([...a].sort((x, y) => x - y));
|
||||
if (sorted(state.selected) !== sorted(p.targets)) {
|
||||
throw err(`${api}: Error: selection ended as ${JSON.stringify(state.values)} instead of ${JSON.stringify(p.values)}`);
|
||||
@@ -912,7 +938,7 @@ export class Human {
|
||||
let typed = false;
|
||||
for (const k of keys) {
|
||||
if (deadline.expired()) {
|
||||
await this.raw.keyPress('Escape');
|
||||
await this.pressKey('Escape');
|
||||
throw new errors.TimeoutError(`${api}: Timeout ${Math.round(deadline.timeout)}ms exceeded.\n`
|
||||
+ `Call log:\n - choosing an option of ${target.description}`);
|
||||
}
|
||||
@@ -922,12 +948,12 @@ export class Human {
|
||||
this.typeAheadUntil = Date.now() + 1100;
|
||||
await sleepMs(rand(60, 160));
|
||||
} else {
|
||||
await this.raw.keyPress(k);
|
||||
await this.pressKey(k);
|
||||
await sleepMs(rand(90, 200));
|
||||
}
|
||||
}
|
||||
await sleepMs(rand(100, 250));
|
||||
await this.raw.keyPress('Enter');
|
||||
await this.pressKey('Enter');
|
||||
if (typed) this.typeAheadUntil = Date.now() + 1100;
|
||||
await sleepMs(rand(80, 160));
|
||||
}
|
||||
@@ -941,7 +967,7 @@ export class Human {
|
||||
}
|
||||
const session: CDPSession = await this.worlds.session();
|
||||
await session.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch, unmodifiedText: ch });
|
||||
await sleepMs(randRange(cfg.key_hold));
|
||||
await sleepMs(keyHold(cfg));
|
||||
await session.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch });
|
||||
}
|
||||
|
||||
@@ -1027,28 +1053,28 @@ export class Human {
|
||||
async typeText(text: string, cfg: HumanConfig, frame: Frame, info: any, delay: number | undefined): Promise<void> {
|
||||
const tag = info?.tag ?? null;
|
||||
const mistypes = cfg.mistype_chance > 0 && tag !== null && fields.allowsMistype(tag, info?.type ?? null);
|
||||
const chars = [...text];
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const ch = chars[i];
|
||||
if (!isAscii(ch)) {
|
||||
await sleepMs(randRange(cfg.key_hold));
|
||||
await this.raw.insertText(ch);
|
||||
} else {
|
||||
if (mistypes && isAsciiAlnum(ch) && Math.random() < cfg.mistype_chance) await this.typo(ch, cfg, frame);
|
||||
await this.typeChar(ch, cfg);
|
||||
const typist = new Typist(this.typistKeys(), cfg, delay);
|
||||
try {
|
||||
for (const ch of [...text]) {
|
||||
if (mistypes && isAsciiAlnum(ch) && Math.random() < cfg.mistype_chance) await this.typo(typist, ch, cfg, frame);
|
||||
await typist.key(ch);
|
||||
}
|
||||
if (i < chars.length - 1) await this.betweenKeys(cfg, delay);
|
||||
await typist.finish();
|
||||
} finally {
|
||||
await typist.releaseAll();
|
||||
}
|
||||
}
|
||||
|
||||
private async typo(ch: string, cfg: HumanConfig, frame: Frame): Promise<void> {
|
||||
private async typo(typist: Typist, ch: string, cfg: HumanConfig, frame: Frame): Promise<void> {
|
||||
const lower = ch.toLowerCase();
|
||||
const near = NEARBY_KEYS[lower];
|
||||
if (!near) return;
|
||||
let wrong = near[Math.floor(Math.random() * near.length)];
|
||||
if (isUpper(ch)) wrong = wrong.toUpperCase();
|
||||
await typist.finish();
|
||||
const before = await this.activeValue(frame);
|
||||
await this.typeChar(wrong, cfg);
|
||||
await typist.key(wrong);
|
||||
await typist.rest();
|
||||
await sleepMs(randRange(cfg.mistype_delay_notice));
|
||||
const after = await this.activeValue(frame);
|
||||
if (before !== null && after === before) {
|
||||
@@ -1056,9 +1082,8 @@ export class Human {
|
||||
await sleepMs(randRange(cfg.mistype_delay_correct));
|
||||
return;
|
||||
}
|
||||
await this.raw.keyDown('Backspace');
|
||||
await sleepMs(randRange(cfg.key_hold));
|
||||
await this.raw.keyUp('Backspace');
|
||||
await typist.key('Backspace');
|
||||
await typist.rest();
|
||||
await sleepMs(randRange(cfg.mistype_delay_correct));
|
||||
}
|
||||
|
||||
@@ -1066,39 +1091,32 @@ export class Human {
|
||||
try { return (await this.worlds.call(frame, 'activeValue'))?.value ?? null; } catch { return null; }
|
||||
}
|
||||
|
||||
/** One key on its own (date editors, type-ahead). */
|
||||
async typeChar(ch: string, cfg: HumanConfig): Promise<void> {
|
||||
if (isUpper(ch)) {
|
||||
await this.raw.keyDown('Shift');
|
||||
await sleepMs(randRange(cfg.shift_down_delay));
|
||||
await this.raw.keyDown(ch);
|
||||
await sleepMs(randRange(cfg.key_hold));
|
||||
await this.raw.keyUp(ch);
|
||||
await sleepMs(randRange(cfg.shift_up_delay));
|
||||
await this.raw.keyUp('Shift');
|
||||
} else if (SHIFT_SYMBOLS.has(ch)) {
|
||||
const session: CDPSession = await this.worlds.session();
|
||||
const code = SHIFT_SYMBOL_CODES[ch] || '';
|
||||
const vk = SHIFT_SYMBOL_KEYCODES[ch] || 0;
|
||||
await this.raw.keyDown('Shift');
|
||||
await sleepMs(randRange(cfg.shift_down_delay));
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown', modifiers: 8, key: ch, code, windowsVirtualKeyCode: vk, text: ch, unmodifiedText: ch,
|
||||
});
|
||||
await sleepMs(randRange(cfg.key_hold));
|
||||
await session.send('Input.dispatchKeyEvent', { type: 'keyUp', modifiers: 8, key: ch, code, windowsVirtualKeyCode: vk });
|
||||
await sleepMs(randRange(cfg.shift_up_delay));
|
||||
await this.raw.keyUp('Shift');
|
||||
} else {
|
||||
await this.raw.keyDown(ch);
|
||||
await sleepMs(randRange(cfg.key_hold));
|
||||
await this.raw.keyUp(ch);
|
||||
const typist = new Typist(this.typistKeys(), cfg);
|
||||
try {
|
||||
await typist.key(ch);
|
||||
await typist.finish();
|
||||
} finally {
|
||||
await typist.releaseAll();
|
||||
}
|
||||
}
|
||||
|
||||
private async betweenKeys(cfg: HumanConfig, delay: number | undefined): Promise<void> {
|
||||
if (delay !== undefined && delay !== null) await sleepMs(delay);
|
||||
else if (Math.random() < cfg.typing_pause_chance) await sleepMs(randRange(cfg.typing_pause_range));
|
||||
else await sleepMs(Math.max(10, cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread));
|
||||
/** Key primitives for a Typist: the page's raw keyboard, and CDP key
|
||||
* events (shift symbols) through the isolated world's session. */
|
||||
typistKeys(): TypistKeys {
|
||||
return {
|
||||
down: (k: string) => this.raw.keyDown(k),
|
||||
up: (k: string) => this.raw.keyUp(k),
|
||||
insertText: (t: string) => this.raw.insertText(t),
|
||||
cdp: async (params: any) => { await (await this.worlds.session()).send('Input.dispatchKeyEvent', params); },
|
||||
};
|
||||
}
|
||||
|
||||
/** `keyboard.press` with a human key-down time (Playwright's own press
|
||||
* releases the key in the same instant). */
|
||||
async pressKey(key: string, cfg?: HumanConfig, delay?: number): Promise<void> {
|
||||
await this.raw.keyPress(key, { delay: delay ?? keyHold(cfg ?? this.cfg) });
|
||||
}
|
||||
|
||||
/** `page.keyboard.type`: no target; mistypes only where safe. */
|
||||
|
||||
+227
-18
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { Page, CDPSession } from 'playwright-core';
|
||||
import type { RawKeyboard } from './mouse.js';
|
||||
import { type HumanConfig, randRange, sleep } from './config.js';
|
||||
import { type HumanConfig, randLog, randLognormal, randRange, sleep } from './config.js';
|
||||
|
||||
export async function pressWithDelay<Key>(
|
||||
press: (key: Key, options?: { delay?: number }) => Promise<void>,
|
||||
@@ -55,6 +55,22 @@ export const SHIFT_SYMBOL_CODES: Record<string, string> = {
|
||||
'?': 'Slash', '~': 'Backquote',
|
||||
};
|
||||
|
||||
/** Physical key of each unshifted digit / punctuation character, so a shift
|
||||
* symbol and its base character ("1" and "!") count as the same key. */
|
||||
const BASE_KEY_CODES: Record<string, string> = {
|
||||
'1': 'Digit1', '2': 'Digit2', '3': 'Digit3', '4': 'Digit4', '5': 'Digit5',
|
||||
'6': 'Digit6', '7': 'Digit7', '8': 'Digit8', '9': 'Digit9', '0': 'Digit0',
|
||||
'-': 'Minus', '=': 'Equal', '[': 'BracketLeft', ']': 'BracketRight',
|
||||
'\\': 'Backslash', ';': 'Semicolon', "'": 'Quote', ',': 'Comma',
|
||||
'.': 'Period', '/': 'Slash', '`': 'Backquote',
|
||||
};
|
||||
|
||||
/** The physical key that types `ch`: "x" and "X" are one key, so are "1" and
|
||||
* "!". Used so a key never goes down while it is still held. */
|
||||
export function physicalKey(ch: string): string {
|
||||
return SHIFT_SYMBOL_CODES[ch] || BASE_KEY_CODES[ch] || ch.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows virtual key codes for shift symbols.
|
||||
* Input.dispatchKeyEvent uses these to match real keyboard behavior.
|
||||
@@ -82,6 +98,204 @@ function getNearbyKey(ch: string): string {
|
||||
return ch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key timing (shared by the humanize engine)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Numbers come from the 136M Keystrokes data (Dhakal et al., CHI 2018):
|
||||
// letters typed with the other hand follow ~20% faster than letters of the
|
||||
// same hand, a repeated letter (same finger) is slower, and the first letter
|
||||
// of a word (after a space) is the slowest. People commonly press the next
|
||||
// key before letting go of the previous one (rollover).
|
||||
|
||||
const LEFT_HAND = new Set('qwertasdfgzxcvb`12345~!@#$%');
|
||||
|
||||
function hand(ch: string): 'L' | 'R' | null {
|
||||
const c = ch.toLowerCase();
|
||||
if (!c || c.length !== 1 || c.charCodeAt(0) > 127 || /\s/.test(c)) return null;
|
||||
return LEFT_HAND.has(c) ? 'L' : 'R';
|
||||
}
|
||||
|
||||
/** How much slower / faster than average the press of `ch` follows `prev`. */
|
||||
export function gapFactor(prev: string, ch: string): number {
|
||||
if (!prev) return 1;
|
||||
if (prev === ' ') return 1.3;
|
||||
if (ch === ' ') return 0.9;
|
||||
if (prev.toLowerCase() === ch.toLowerCase()) return 1.05;
|
||||
const hp = hand(prev), hc = hand(ch);
|
||||
if (hp && hc) return hp !== hc ? 0.85 : 1.1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Milliseconds from the press of `prev` to the press of `ch` (log-normal
|
||||
* around `typing_delay`, plus occasional thinking pauses before words).
|
||||
*/
|
||||
export function keyGap(cfg: HumanConfig, prev: string, ch: string): number {
|
||||
const median = Math.max(1, cfg.typing_delay * gapFactor(prev, ch));
|
||||
const spread = Math.max(0, cfg.typing_delay_spread);
|
||||
const sigma = cfg.typing_delay > 0 ? Math.log1p(spread / cfg.typing_delay) : 0;
|
||||
let gap = randLognormal(median, sigma);
|
||||
const wordStart = prev === ' ' || prev === '';
|
||||
const chance = cfg.typing_pause_chance * (wordStart ? 1 : 0.1);
|
||||
if (chance > 0 && Math.random() < chance) gap += randRange(cfg.typing_pause_range);
|
||||
return Math.max(15, gap);
|
||||
}
|
||||
|
||||
/** How long one key stays down (ms). */
|
||||
export function keyHold(cfg: HumanConfig): number {
|
||||
return Math.max(1, randLog(cfg.key_hold));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typing timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type KeyUp = { due: number; key: string; cdp: any | null };
|
||||
|
||||
/** Key primitives a Typist drives. `cdp` sends one Input.dispatchKeyEvent. */
|
||||
export interface TypistKeys {
|
||||
down(key: string): Promise<void>;
|
||||
up(key: string): Promise<void>;
|
||||
insertText(text: string): Promise<void>;
|
||||
cdp(params: any): Promise<void>;
|
||||
}
|
||||
|
||||
const sleepMs = (ms: number) => (ms > 0 ? sleep(ms) : Promise.resolve());
|
||||
const isUpper = (ch: string) => /^[A-Z]$/.test(ch);
|
||||
|
||||
/**
|
||||
* Presses keys on a timeline the way a person does: each press follows the
|
||||
* previous *press* after a letter-dependent gap, and each key is held for its
|
||||
* own time, so the next key often goes down before the previous one comes up
|
||||
* (rollover). Shift goes down before the letter and is usually let go just
|
||||
* before the letter key. Releases are queued and sent when due.
|
||||
*/
|
||||
export class Typist {
|
||||
private prev = '';
|
||||
private lastPress: number | null = null;
|
||||
private ups: KeyUp[] = [];
|
||||
private shiftUpDue: number | null = null;
|
||||
private shiftDown = false;
|
||||
|
||||
constructor(private keys: TypistKeys, private cfg: HumanConfig, private delay?: number) {}
|
||||
|
||||
private async wait(until: number): Promise<void> { await sleepMs(until - performance.now()); }
|
||||
|
||||
private async shift(down: boolean): Promise<void> {
|
||||
this.shiftDown = down;
|
||||
await (down ? this.keys.down('Shift') : this.keys.up('Shift'));
|
||||
}
|
||||
|
||||
private async up(u: KeyUp): Promise<void> {
|
||||
if (u.cdp) await this.keys.cdp(u.cdp);
|
||||
else if (u.key.length === 1 && isUpper(u.key) && !this.shiftDown) await this.keys.up(u.key.toLowerCase());
|
||||
else await this.keys.up(u.key);
|
||||
}
|
||||
|
||||
/** Send every release due before `until` (in time order). */
|
||||
private async flush(until: number): Promise<void> {
|
||||
for (;;) {
|
||||
let next: KeyUp | null = null;
|
||||
for (const u of this.ups) if (u.due <= until && (!next || u.due < next.due)) next = u;
|
||||
const shiftFirst = this.shiftUpDue !== null && this.shiftUpDue <= until && (!next || this.shiftUpDue <= next.due);
|
||||
if (shiftFirst) {
|
||||
await this.wait(this.shiftUpDue!);
|
||||
this.shiftUpDue = null;
|
||||
await this.shift(false);
|
||||
continue;
|
||||
}
|
||||
if (!next) return;
|
||||
await this.wait(next.due);
|
||||
this.ups.splice(this.ups.indexOf(next), 1);
|
||||
await this.up(next);
|
||||
}
|
||||
}
|
||||
|
||||
async key(ch: string): Promise<void> {
|
||||
const cfg = this.cfg;
|
||||
let at: number;
|
||||
if (this.lastPress === null) at = performance.now();
|
||||
else if (this.delay !== undefined && this.delay !== null) at = this.lastPress + this.delay;
|
||||
else at = this.lastPress + keyGap(cfg, this.prev, ch);
|
||||
const shifted = isUpper(ch) || SHIFT_SYMBOLS.has(ch);
|
||||
const textOnly = !isAscii(ch);
|
||||
// A key cannot go down twice: let the earlier press of the same physical
|
||||
// key ("x" before "X", "1" before "!") come up first.
|
||||
const key = physicalKey(ch);
|
||||
const same = this.ups.filter(u => physicalKey(u.key) === key);
|
||||
if (same.length) at = Math.max(at, Math.min(...same.map(u => u.due)) + 5);
|
||||
if (shifted) {
|
||||
if (this.shiftUpDue !== null) {
|
||||
this.shiftUpDue = null; // still down from the previous capital
|
||||
await this.flush(at);
|
||||
} else {
|
||||
const lead = randLog(cfg.shift_down_delay);
|
||||
// Shift goes down after the previous key comes up.
|
||||
await this.flush(at - lead);
|
||||
await this.wait(at - lead);
|
||||
await this.shift(true);
|
||||
// Keys due to come up while Shift is going down (e.g. the "x" of
|
||||
// "xX") are released before the capital is pressed.
|
||||
await this.flush(at);
|
||||
}
|
||||
} else {
|
||||
await this.flush(at);
|
||||
if (this.shiftUpDue !== null && ch !== 'Backspace') {
|
||||
// A lower-case key while Shift is still down: let it go first.
|
||||
this.shiftUpDue = null;
|
||||
await this.shift(false);
|
||||
}
|
||||
}
|
||||
await this.wait(at);
|
||||
this.lastPress = Math.max(at, performance.now());
|
||||
const hold0 = keyHold(cfg);
|
||||
const hold = this.delay !== undefined && this.delay !== null && this.delay < hold0 ? this.delay : hold0;
|
||||
const upAt = this.lastPress + hold;
|
||||
if (textOnly) {
|
||||
// Non-ASCII text has no key: it arrives as one input, like an IME.
|
||||
await this.keys.insertText(ch);
|
||||
} else if (SHIFT_SYMBOLS.has(ch)) {
|
||||
const code = SHIFT_SYMBOL_CODES[ch] || '';
|
||||
const vk = SHIFT_SYMBOL_KEYCODES[ch] || 0;
|
||||
await this.keys.cdp({
|
||||
type: 'keyDown', modifiers: 8, key: ch, code, windowsVirtualKeyCode: vk, text: ch, unmodifiedText: ch,
|
||||
});
|
||||
this.ups.push({ due: upAt, key: ch, cdp: { type: 'keyUp', modifiers: 8, key: ch, code, windowsVirtualKeyCode: vk } });
|
||||
} else {
|
||||
await this.keys.down(ch);
|
||||
this.ups.push({ due: upAt, key: ch, cdp: null });
|
||||
}
|
||||
if (shifted) {
|
||||
// Usually let go of Shift around the letter's release, most often just
|
||||
// before it.
|
||||
this.shiftUpDue = Math.max(this.lastPress + 10, upAt + randRange(cfg.shift_up_delay));
|
||||
// The CDP keyUp of a shift symbol carries the Shift modifier.
|
||||
if (SHIFT_SYMBOLS.has(ch)) this.shiftUpDue = Math.max(this.shiftUpDue, upAt + 5);
|
||||
}
|
||||
this.prev = ch;
|
||||
}
|
||||
|
||||
/** Release everything still held, on schedule. */
|
||||
async finish(): Promise<void> { await this.flush(Infinity); }
|
||||
|
||||
/** Hands off the keys (before a pause): release, and start the next key
|
||||
* without a gap carried over from the last one. */
|
||||
async rest(): Promise<void> {
|
||||
await this.finish();
|
||||
this.lastPress = null;
|
||||
}
|
||||
|
||||
/** Error path: never leave a key stuck down. */
|
||||
async releaseAll(): Promise<void> {
|
||||
const ups = this.ups;
|
||||
this.ups = [];
|
||||
for (const u of ups) { try { await this.up(u); } catch { /* best effort */ } }
|
||||
this.shiftUpDue = null;
|
||||
if (this.shiftDown) { try { await this.shift(false); } catch { /* best effort */ } }
|
||||
}
|
||||
}
|
||||
|
||||
function isUpperCase(ch: string): boolean {
|
||||
return ch.length === 1 && ch >= 'A' && ch <= 'Z';
|
||||
}
|
||||
@@ -108,7 +322,7 @@ export async function humanType(
|
||||
|
||||
// Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
|
||||
if (!isAscii(ch)) {
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await sleep(keyHold(cfg));
|
||||
await raw.insertText(ch);
|
||||
if (i < chars.length - 1) {
|
||||
await interCharDelay(cfg);
|
||||
@@ -122,7 +336,7 @@ export async function humanType(
|
||||
await typeNormalChar(raw, wrong, cfg);
|
||||
await sleep(randRange(cfg.mistype_delay_notice));
|
||||
await raw.down('Backspace');
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await sleep(keyHold(cfg));
|
||||
await raw.up('Backspace');
|
||||
await sleep(randRange(cfg.mistype_delay_correct));
|
||||
}
|
||||
@@ -143,17 +357,17 @@ export async function humanType(
|
||||
|
||||
async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise<void> {
|
||||
await raw.down(ch);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await sleep(keyHold(cfg));
|
||||
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 sleep(randLog(cfg.shift_down_delay));
|
||||
await raw.down(ch);
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await sleep(keyHold(cfg));
|
||||
await raw.up(ch);
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await sleep(Math.max(0, randRange(cfg.shift_up_delay)));
|
||||
await raw.up('Shift');
|
||||
}
|
||||
|
||||
@@ -180,7 +394,7 @@ async function typeShiftSymbol(
|
||||
const keyCode = SHIFT_SYMBOL_KEYCODES[ch] || 0;
|
||||
|
||||
await raw.down('Shift');
|
||||
await sleep(randRange(cfg.shift_down_delay));
|
||||
await sleep(randLog(cfg.shift_down_delay));
|
||||
|
||||
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
@@ -191,7 +405,7 @@ async function typeShiftSymbol(
|
||||
text: ch,
|
||||
unmodifiedText: ch,
|
||||
});
|
||||
await sleep(randRange(cfg.key_hold));
|
||||
await sleep(keyHold(cfg));
|
||||
|
||||
await cdpSession.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
@@ -201,12 +415,12 @@ async function typeShiftSymbol(
|
||||
windowsVirtualKeyCode: keyCode,
|
||||
});
|
||||
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await sleep(Math.max(0, 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 sleep(randLog(cfg.shift_down_delay));
|
||||
await raw.insertText(ch);
|
||||
await page.evaluate((key: string) => {
|
||||
const el = document.activeElement;
|
||||
@@ -215,16 +429,11 @@ async function typeShiftSymbol(
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
|
||||
}
|
||||
}, ch);
|
||||
await sleep(randRange(cfg.shift_up_delay));
|
||||
await sleep(Math.max(0, 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));
|
||||
}
|
||||
await sleep(Math.max(10, keyGap(cfg, 'a', 'b') - randLog(cfg.key_hold)));
|
||||
}
|
||||
+110
-47
@@ -2,7 +2,7 @@
|
||||
* cloakbrowser-human — Human-like mouse movement and clicking.
|
||||
*/
|
||||
|
||||
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
|
||||
import { HumanConfig, rand, randLognormal, randRange, sleep } from './config.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw interface — original Playwright methods, bypassing the wrapper
|
||||
@@ -53,20 +53,120 @@ export function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): P
|
||||
};
|
||||
}
|
||||
|
||||
export function randomControlPoints(start: Point, end: Point): [Point, Point] {
|
||||
export function randomControlPoints(start: Point, end: Point, curvature = 0.3): [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;
|
||||
// Both control points bend the same way more often than not: a hand sweeps
|
||||
// an arc, it rarely draws an S.
|
||||
const bias1 = rand(-curvature, curvature) * dist;
|
||||
const bias2 = Math.random() < 0.8 ? bias1 * rand(0.2, 1.2) : rand(-curvature, curvature) * 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 },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stroke planning (shared by the humanize engine and humanMove)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A pointing movement is a fast main stroke whose speed peaks early, then a
|
||||
// slow approach to the target, often finished by a short corrective stroke
|
||||
// (Meyer's optimised sub-movements; Plamondon's log-normal strokes). How long
|
||||
// it takes grows with log2 of the distance (Fitts' law) and varies a lot from
|
||||
// one move to the next. A mouse reports its position at a steady rate and
|
||||
// only when it actually moved.
|
||||
|
||||
/** One planned pointer event: position and ms since the previous one. */
|
||||
export type MovePoint = [x: number, y: number, dt: number];
|
||||
|
||||
export const FRAME_MS = 1000 / 60; // pointer events arrive about once per display frame
|
||||
const PEAK: [number, number] = [0.22, 0.38]; // where the speed peaks (fraction of the stroke time)
|
||||
|
||||
/** Smooth 0->1 distance curve whose speed peaks at `peak` of the time
|
||||
* (a time-warped minimum-jerk profile: fast early, long slow tail). */
|
||||
function progressCurve(peak: number): (t: number) => number {
|
||||
const k = Math.log(0.5) / Math.log(peak); // u = t**k puts the mid point at peak
|
||||
return (t: number) => {
|
||||
const u = Math.pow(t, k);
|
||||
return u * u * u * (10 - 15 * u + 6 * u * u);
|
||||
};
|
||||
}
|
||||
|
||||
/** Milliseconds for a `dist` px stroke (Fitts' law, log-normal spread). */
|
||||
export function strokeDuration(dist: number, cfg: HumanConfig): number {
|
||||
const median = cfg.mouse_time_base + cfg.mouse_time_per_bit * Math.log2(1 + dist / 8);
|
||||
return Math.max(FRAME_MS * 2, randLognormal(median, cfg.mouse_time_spread));
|
||||
}
|
||||
|
||||
/** Points of one stroke; the last point is exactly (ex, ey). Consecutive
|
||||
* points always differ by a pixel or more (a mouse does not report a move
|
||||
* without movement). */
|
||||
export function planStroke(sx: number, sy: number, ex: number, ey: number, cfg: HumanConfig,
|
||||
duration?: number): MovePoint[] {
|
||||
const dist = Math.hypot(ex - sx, ey - sy);
|
||||
if (dist < 0.5) return [];
|
||||
const total = duration ?? strokeDuration(dist, cfg);
|
||||
const n = Math.max(1, Math.trunc(Math.max(cfg.mouse_min_steps, Math.min(cfg.mouse_max_steps, Math.round(total / FRAME_MS)))));
|
||||
const start = { x: sx, y: sy }, end = { x: ex, y: ey };
|
||||
const [cp1, cp2] = randomControlPoints(start, end, 0.12);
|
||||
const curve = progressCurve(rand(PEAK[0], PEAK[1]));
|
||||
// Low-frequency sideways drift (a hand is not a ruler), zero at both ends.
|
||||
const ux = (ex - sx) / dist, uy = (ey - sy) / dist;
|
||||
const amp = Math.min(cfg.mouse_wobble_max, dist * 0.02) * rand(-1, 1);
|
||||
const waves = rand(0.5, 1.5), phase = rand(0, Math.PI);
|
||||
const out: MovePoint[] = [];
|
||||
let lx = sx, ly = sy, tPrev = 0;
|
||||
for (let i = 1; i <= n; i++) {
|
||||
// Frame timing jitters a little; the final event lands on the target.
|
||||
const t = Math.min(1, (i + (i < n ? rand(-0.2, 0.2) : 0)) / n);
|
||||
let x: number, y: number;
|
||||
if (i === n) {
|
||||
x = ex; y = ey;
|
||||
if (Math.abs(x - lx) < 1 && Math.abs(y - ly) < 1) {
|
||||
if (!out.length) break; // already there (sub-pixel move): nothing to report
|
||||
// The last frame already got there: land exactly on it instead.
|
||||
const [, , pdt] = out.pop()!;
|
||||
out.push([x, y, pdt + (t - tPrev) * total]);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const p = bezier(start, cp1, cp2, end, curve(t));
|
||||
const w = amp * Math.sin(Math.PI * t) * Math.sin(phase + 2 * Math.PI * waves * t);
|
||||
x = Math.round(p.x - uy * w); y = Math.round(p.y + ux * w);
|
||||
if (Math.abs(x - lx) < 1 && Math.abs(y - ly) < 1) continue;
|
||||
}
|
||||
out.push([x, y, (t - tPrev) * total]);
|
||||
tPrev = t; lx = x; ly = y;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A whole move: main stroke, and sometimes a miss near the target plus a
|
||||
* short corrective stroke onto it. */
|
||||
export function planMove(sx: number, sy: number, ex: number, ey: number, cfg: HumanConfig): MovePoint[] {
|
||||
const dist = Math.hypot(ex - sx, ey - sy);
|
||||
if (dist < 0.5) return [];
|
||||
if (dist > 40 && Math.random() < cfg.mouse_overshoot_chance) {
|
||||
// Land a little past (or short of) the target, slightly off-line.
|
||||
const ang = Math.atan2(ey - sy, ex - sx) + rand(-0.25, 0.25);
|
||||
let miss = dist * rand(0.01, 0.05) + randRange(cfg.mouse_overshoot_px);
|
||||
if (Math.random() < 0.3) miss = -miss; // undershoot
|
||||
const mx = Math.round(ex + Math.cos(ang) * miss), my = Math.round(ey + Math.sin(ang) * miss);
|
||||
const pts = planStroke(sx, sy, mx, my, cfg);
|
||||
const corr = Math.hypot(ex - mx, ey - my);
|
||||
const pause = rand(40, 140);
|
||||
const tail = planStroke(mx, my, ex, ey, cfg,
|
||||
Math.max(FRAME_MS * 2, randLognormal(90 + 25 * Math.log2(1 + corr), 0.3)));
|
||||
if (tail.length) tail[0] = [tail[0][0], tail[0][1], tail[0][2] + pause];
|
||||
return pts.concat(tail);
|
||||
}
|
||||
return planStroke(sx, sy, ex, ey, cfg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Human mouse movement
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,49 +179,12 @@ export async function humanMove(
|
||||
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);
|
||||
let clock = Date.now();
|
||||
for (const [x, y, dt] of planMove(startX, startY, endX, endY, cfg)) {
|
||||
clock += dt;
|
||||
const wait = clock - Date.now();
|
||||
if (wait > 0) await sleep(wait);
|
||||
await raw.move(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,14 +123,14 @@ const H = {
|
||||
const e = get(id);
|
||||
try {
|
||||
if (I.checkElementStates) {
|
||||
const r = await I.checkElementStates(e, states);
|
||||
const r = await I.checkElementStates(e, states, true);
|
||||
if (r === undefined) return null;
|
||||
if (r === 'error:notconnected') return { missing: 'attached' };
|
||||
return { missing: r.missingState };
|
||||
}
|
||||
for (const s of states) {
|
||||
if (s === 'stable') continue;
|
||||
const r = I.elementState(e, s);
|
||||
const r = I.elementState(e, s, true);
|
||||
if (r === 'error:notconnected' || r.received === 'error:notconnected') return { missing: 'attached' };
|
||||
if (!(r.matches !== undefined ? r.matches : r)) return { missing: s };
|
||||
}
|
||||
@@ -157,11 +157,24 @@ const H = {
|
||||
bl: parseFloat(st.borderLeftWidth) || 0, bt: parseFloat(st.borderTopWidth) || 0 };
|
||||
},
|
||||
scroller(id) {
|
||||
// Walk the containing-block chain, not plain DOM ancestors: overflow only
|
||||
// clips descendants whose containing block lies inside the scroller. A
|
||||
// fixed box escapes every ancestor that does not establish a containing
|
||||
// block for it; an absolute box escapes static ancestors (#593).
|
||||
const e = get(id);
|
||||
const none = (v) => !v || v === 'none';
|
||||
const fixedCB = (s) => !none(s.transform) || !none(s.translate) || !none(s.rotate) || !none(s.scale) ||
|
||||
!none(s.perspective) || !none(s.filter) || !none(s.backdropFilter) ||
|
||||
/layout|paint|strict|content/.test(s.contain || '') ||
|
||||
/transform|perspective|filter/.test(s.willChange || '') || /size/.test(s.containerType || '');
|
||||
const b = e.getBoundingClientRect();
|
||||
const top = document.scrollingElement || document.documentElement;
|
||||
let pos = getComputedStyle(e).position;
|
||||
for (let p = parentOrHost(e); p && p !== top && p !== document.body; p = parentOrHost(p)) {
|
||||
const s = getComputedStyle(p);
|
||||
if (pos === 'fixed' && !fixedCB(s)) continue;
|
||||
if (pos === 'absolute' && s.position === 'static' && !fixedCB(s)) continue;
|
||||
pos = s.position;
|
||||
const sy = /(auto|scroll|overlay)/.test(s.overflowY) && p.scrollHeight > p.clientHeight;
|
||||
const sx = /(auto|scroll|overlay)/.test(s.overflowX) && p.scrollWidth > p.clientWidth;
|
||||
if (!sy && !sx) continue;
|
||||
|
||||
@@ -181,6 +181,36 @@ describe("maybeResolveGeoip", () => {
|
||||
await expect(resolveProxyGeo(null)).rejects.toThrow("GeoIP lookup failed");
|
||||
});
|
||||
|
||||
it("falls back to en-US for a country missing from the locale map (#591)", async () => {
|
||||
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-unmapped-"));
|
||||
tempDirs.push(cacheDir);
|
||||
const geoipDir = path.join(cacheDir, "geoip");
|
||||
fs.mkdirSync(geoipDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(geoipDir, "GeoLite2-City.mmdb"), "stub");
|
||||
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
|
||||
|
||||
class Reader {
|
||||
get() {
|
||||
return { location: { time_zone: "Indian/Mahe" }, country: { iso_code: "XX" } };
|
||||
}
|
||||
}
|
||||
vi.doMock("mmdb-lib", () => ({ default: { Reader }, Reader }));
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => "5.6.7.8",
|
||||
} as Response);
|
||||
|
||||
try {
|
||||
await expect(resolveProxyGeo(null)).resolves.toEqual({
|
||||
timezone: "Indian/Mahe",
|
||||
locale: "en-US",
|
||||
exitIp: "5.6.7.8",
|
||||
});
|
||||
} finally {
|
||||
vi.doUnmock("mmdb-lib");
|
||||
}
|
||||
});
|
||||
|
||||
it("downloads the database once under concurrent first-use launches (#458)", async () => {
|
||||
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-concurrent-"));
|
||||
tempDirs.push(cacheDir);
|
||||
@@ -311,6 +341,10 @@ describe("COUNTRY_LOCALE_MAP", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps Seychelles (issue #591)", () => {
|
||||
expect(COUNTRY_LOCALE_MAP["SC"]).toBe("en-SC");
|
||||
});
|
||||
|
||||
it("values are BCP 47 language-REGION format", () => {
|
||||
for (const [code, locale] of Object.entries(COUNTRY_LOCALE_MAP)) {
|
||||
const parts = locale.split("-");
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("humanMove", () => {
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
expect(maxDev).toBeGreaterThan(0.5);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("handles very short distances", async () => {
|
||||
const cfg = resolveConfig("default");
|
||||
|
||||
@@ -380,6 +380,31 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)('humanize engine (real br
|
||||
expect(await p.evaluate(() => (window as any).__keys)).toEqual(['ArrowUp', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('same key lower then upper case never sends a repeat keydown ("xX", "1!")', async () => {
|
||||
const p = await open();
|
||||
await p.evaluate(() => {
|
||||
(window as any).__kd = [];
|
||||
for (const t of ['keydown', 'keyup'])
|
||||
document.addEventListener(t, (e: any) => (window as any).__kd.push([t, e.code, e.repeat]), true);
|
||||
});
|
||||
const text = 'xX aA zZ 1! qQ';
|
||||
const human_config = {
|
||||
key_hold: [60, 175], typing_delay: 150, typing_delay_spread: 70, typing_pause_chance: 0,
|
||||
shift_down_delay: [60, 220], shift_up_delay: [-60, 30],
|
||||
};
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await p.fill('#name', text, { human_config } as any);
|
||||
expect(await value(p, '#name')).toBe(text);
|
||||
}
|
||||
const log: [string, string, boolean][] = await p.evaluate(() => (window as any).__kd);
|
||||
expect(log.filter(e => e[2])).toEqual([]);
|
||||
const held = new Set<string>();
|
||||
for (const [t, code] of log) {
|
||||
if (t === 'keydown') { expect(held.has(code)).toBe(false); held.add(code); }
|
||||
else held.delete(code);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('mistypes are corrected, skip masked fields, uppercase uses Shift', async () => {
|
||||
const p = await open();
|
||||
const typo = { human_config: { mistype_chance: 1 } } as any;
|
||||
@@ -407,6 +432,37 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)('humanize engine (real br
|
||||
expect((await events(p, 'wheel')).every((e) => e.dy > 0)).toBe(true);
|
||||
});
|
||||
|
||||
// #593: overflow clips only boxes whose containing block is inside the scroller.
|
||||
const CB_CSS = '<!doctype html><style>body { margin: 0 } header { height: 215px }' +
|
||||
'.content { height: 300px; overflow: auto } .tall { height: 2000px } button { width: 44px; height: 44px }' +
|
||||
'</style><header>header</header>';
|
||||
const CB_CASES: [string, string, boolean][] = [
|
||||
['fixed modal inside a scroller', '<div class="content"><div class="tall">' +
|
||||
'<div style="position: fixed; inset: 0; background: rgba(0,0,0,.4)">' +
|
||||
'<button id="t" style="position: absolute; top: 146px; right: 36px">X</button></div></div></div>', false],
|
||||
['absolute child escaping a static scroller', '<div style="position: relative"><div class="content"><div class="tall">' +
|
||||
'<button id="t" style="position: absolute; top: 320px; left: 10px">X</button></div></div></div>', false],
|
||||
['normal child below the fold', '<div class="content"><div class="tall">' +
|
||||
'<div style="height: 1500px"></div><button id="t">X</button></div></div>', true],
|
||||
['absolute child of a positioned scroller', '<div class="content" style="position: relative"><div class="tall">' +
|
||||
'<button id="t" style="position: absolute; top: 1500px; left: 10px">X</button></div></div>', true],
|
||||
['fixed child of a transformed scroller', '<div class="content" style="transform: translateZ(0)"><div class="tall">' +
|
||||
'<button id="t" style="position: fixed; top: 1500px; left: 10px">X</button></div></div>', true],
|
||||
];
|
||||
for (const [name, body, clipped] of CB_CASES) {
|
||||
it(`scroller follows the containing block: ${name} (#593)`, async () => {
|
||||
const p = await open();
|
||||
await p.setViewportSize({ width: 390, height: 844 });
|
||||
await p.goto('data:text/html,' + encodeURIComponent(CB_CSS + body));
|
||||
await p.evaluate(() => { (window as any).__clicks = 0;
|
||||
document.getElementById('t')!.addEventListener('click', () => (window as any).__clicks++); });
|
||||
await p.click('#t', { timeout: 5000 });
|
||||
expect(await p.evaluate(() => (window as any).__clicks)).toBe(1);
|
||||
const scrolled = await p.evaluate(() => document.querySelector('.content')!.scrollTop);
|
||||
expect(scrolled > 0).toBe(clipped);
|
||||
});
|
||||
}
|
||||
|
||||
it('per-call human_config and flat config keys', async () => {
|
||||
const p = await open();
|
||||
const hold = async (o: any) => {
|
||||
@@ -470,6 +526,18 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)('humanize engine (real br
|
||||
}
|
||||
});
|
||||
|
||||
it('selectOption that navigates the page still returns (#597)', async () => {
|
||||
const p = await open();
|
||||
await p.evaluate(() => {
|
||||
const f = document.createElement('form'); f.method = 'get';
|
||||
const s = document.createElement('select'); s.id = 'nav'; s.name = 'city';
|
||||
for (const [l, v] of [['Amsterdam', 'a'], ['Berlin', 'b']]) s.add(new Option(l, v));
|
||||
s.onchange = () => f.submit(); f.append(s); document.body.prepend(f);
|
||||
});
|
||||
expect(await p.selectOption('#nav', 'b')).toEqual(['b']);
|
||||
await p.waitForURL('**city=b');
|
||||
});
|
||||
|
||||
// --- frames and handles ---------------------------------------------------
|
||||
|
||||
it('frame actions', async () => {
|
||||
|
||||
@@ -21,7 +21,6 @@ export const FAST = {
|
||||
shift_up_delay: [5, 5] as [number, number],
|
||||
mouse_min_steps: 4,
|
||||
mouse_max_steps: 4,
|
||||
mouse_burst_pause: [0, 0] as [number, number],
|
||||
mouse_overshoot_chance: 0,
|
||||
click_aim_delay_input: [5, 5] as [number, number],
|
||||
click_aim_delay_button: [5, 5] as [number, number],
|
||||
|
||||
@@ -78,6 +78,34 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)('humanize: Puppeteer (rea
|
||||
expect((await events(f, 'mousemove')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('same key lower then upper case never sends a repeat keydown ("xX", "1!")', async () => {
|
||||
const b = await launch({
|
||||
headless: true, humanize: true,
|
||||
humanConfig: {
|
||||
...FAST, key_hold: [60, 175], typing_delay: 150, typing_delay_spread: 70, typing_pause_chance: 0,
|
||||
shift_down_delay: [60, 220], shift_up_delay: [-60, 30],
|
||||
} as any,
|
||||
});
|
||||
try {
|
||||
const page = await open(b);
|
||||
await page.evaluate(() => {
|
||||
(window as any).__kd = [];
|
||||
for (const t of ['keydown', 'keyup'])
|
||||
document.addEventListener(t, (e: any) => (window as any).__kd.push([t, e.code, e.repeat]), true);
|
||||
});
|
||||
const text = 'xX aA zZ 1! qQ';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.evaluate(() => { (document.querySelector('#name') as HTMLInputElement).value = ''; });
|
||||
await page.type('#name', text);
|
||||
expect(await value(page, '#name')).toBe(text);
|
||||
}
|
||||
const log: [string, string, boolean][] = await page.evaluate(() => (window as any).__kd);
|
||||
expect(log.filter(e => e[2])).toEqual([]);
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it('uppercase typos are typed with Shift', async () => {
|
||||
const b = await launch({ headless: true, humanize: true, humanConfig: { ...FAST, mistype_chance: 1 } as any });
|
||||
try {
|
||||
|
||||
@@ -545,7 +545,7 @@ describe("Puppeteer: page.type accepts per-call human config override", () => {
|
||||
idle_between_actions: false,
|
||||
field_switch_delay: [0, 1],
|
||||
});
|
||||
expect(cfg.typing_delay).toBe(70);
|
||||
expect(cfg.typing_delay).toBe(150);
|
||||
|
||||
let captured: any = null;
|
||||
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
|
||||
@@ -571,7 +571,7 @@ describe("Puppeteer: page.type accepts per-call human config override", () => {
|
||||
|
||||
expect(captured.typing_delay).toBe(30);
|
||||
expect(captured.mistype_chance).toBe(0);
|
||||
expect(cfg.typing_delay).toBe(70);
|
||||
expect(cfg.typing_delay).toBe(150);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
@@ -915,21 +915,23 @@ describe("Puppeteer: mouse.wheel() smooth scroll", () => {
|
||||
expect(wheelCalls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("smoothWheel never sends a zero step (sub-pixel tail folded in)", async () => {
|
||||
it("smoothWheel sends whole notches only, never a zero step", async () => {
|
||||
const { smoothWheel } = await import("../src/human-puppeteer/scroll.js");
|
||||
const calls: [number, number][] = [];
|
||||
const raw: any = { wheel: async (dx: number, dy: number) => { calls.push([dx, dy]); } };
|
||||
const cfg = resolveConfig("default");
|
||||
let total = 0;
|
||||
// A 21-40px delta is one or two chunks; a remainder under 0.5px used to
|
||||
// round to a (0, 0) wheel event a few percent of the time.
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const delta = 21 + (i % 20);
|
||||
total += delta;
|
||||
await smoothWheel(raw, delta, cfg);
|
||||
const cfg = resolveConfig("default", { scroll_pause_fast: [0, 0], scroll_pause_slow: [0, 0] });
|
||||
// CDP reports every wheel event as one notch (wheelDeltaY 120), so the
|
||||
// pixel delta of each event must be one platform notch, never a chunk.
|
||||
for (const notch of [40, 100, 120]) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
calls.length = 0;
|
||||
const delta = (i % 2 ? -1 : 1) * (notch + 37 * i);
|
||||
const done = await smoothWheel(raw, delta, cfg, "y", notch);
|
||||
expect(calls.every(([dx, dy]) => dx === 0 && Math.abs(dy) === notch)).toBe(true);
|
||||
expect(calls.reduce((a, [, dy]) => a + dy, 0)).toBe(done);
|
||||
expect(Math.abs(done - delta)).toBeLessThanOrEqual(notch / 2);
|
||||
}
|
||||
}
|
||||
expect(calls.some(([dx, dy]) => dx === 0 && dy === 0)).toBe(false);
|
||||
expect(calls.reduce((a, [, dy]) => a + dy, 0)).toBe(total);
|
||||
}, 30_000);
|
||||
|
||||
it("mouse.wheel({deltaX: 200}) smooths horizontal scroll too", async () => {
|
||||
|
||||
@@ -168,7 +168,10 @@ describe("SHIFT_SYMBOL maps completeness", () => {
|
||||
it("every shift symbol has a code and keycode entry", async () => {
|
||||
// We access these via dynamic import to get internal constants
|
||||
// Since they're not exported directly, we test via humanType behavior
|
||||
const cfg = resolveConfig("default", { mistype_chance: 0 });
|
||||
const cfg = resolveConfig("default", {
|
||||
mistype_chance: 0, typing_delay: 1, typing_delay_spread: 0, typing_pause_chance: 0,
|
||||
key_hold: [1, 2], shift_down_delay: [1, 2], shift_up_delay: [0, 1],
|
||||
});
|
||||
const SHIFT_SYMBOLS = ['@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
|
||||
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~'];
|
||||
|
||||
@@ -713,7 +716,6 @@ describeIfSlow("stealth browser: selector parity and target identity", () => {
|
||||
const fastHumanConfig = {
|
||||
mouse_min_steps: 3,
|
||||
mouse_max_steps: 3,
|
||||
mouse_burst_pause: [0, 0] as [number, number],
|
||||
idle_between_actions: false,
|
||||
scroll_settle_delay: [0, 0] as [number, number],
|
||||
};
|
||||
|
||||
@@ -59,6 +59,23 @@ def test_locale_map_has_common_countries():
|
||||
assert code in COUNTRY_LOCALE_MAP, f"Missing {code}"
|
||||
|
||||
|
||||
def test_resolve_geo_seychelles_exit_gets_locale():
|
||||
"""GeoLite2 places some datacenter exits in Seychelles (issue #591)."""
|
||||
resp = MagicMock()
|
||||
resp.location.time_zone = "Indian/Mahe"
|
||||
resp.country.iso_code = "SC"
|
||||
reader = MagicMock()
|
||||
reader.return_value.__enter__.return_value.city.return_value = resp
|
||||
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": reader})})()
|
||||
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
|
||||
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=object()):
|
||||
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="178.20.213.254"):
|
||||
from cloakbrowser.geoip import resolve_proxy_geo_with_ip
|
||||
assert resolve_proxy_geo_with_ip("http://10.50.96.5:8888") == (
|
||||
"Indian/Mahe", "en-SC", "178.20.213.254",
|
||||
)
|
||||
|
||||
|
||||
def test_locale_map_values_are_bcp47():
|
||||
"""All locales should be language-REGION format."""
|
||||
for code, locale in COUNTRY_LOCALE_MAP.items():
|
||||
@@ -111,6 +128,23 @@ def test_resolve_geo_raises_when_db_missing():
|
||||
resolve_proxy_geo_with_ip("http://10.50.96.5:8888")
|
||||
|
||||
|
||||
def test_resolve_geo_unmapped_country_falls_back_to_en_us():
|
||||
"""A country missing from COUNTRY_LOCALE_MAP must not abort the launch (#591)."""
|
||||
resp = MagicMock()
|
||||
resp.location.time_zone = "Indian/Mahe"
|
||||
resp.country.iso_code = "XX"
|
||||
reader = MagicMock()
|
||||
reader.return_value.__enter__.return_value.city.return_value = resp
|
||||
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": reader})})()
|
||||
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
|
||||
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=object()):
|
||||
with patch("cloakbrowser.geoip._resolve_exit_ip", return_value="9.8.7.6"):
|
||||
from cloakbrowser.geoip import resolve_proxy_geo_with_ip
|
||||
assert resolve_proxy_geo_with_ip("http://10.50.96.5:8888") == (
|
||||
"Indian/Mahe", "en-US", "9.8.7.6",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_geo_raises_when_lookup_fails():
|
||||
"""A corrupt or unreadable database must abort requested GeoIP resolution."""
|
||||
reader = MagicMock(side_effect=ValueError("corrupt database"))
|
||||
|
||||
@@ -14,6 +14,7 @@ import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -32,7 +33,6 @@ FAST: dict[str, Any] = {
|
||||
"shift_up_delay": (5, 5),
|
||||
"mouse_min_steps": 4,
|
||||
"mouse_max_steps": 4,
|
||||
"mouse_burst_pause": (0, 0),
|
||||
"mouse_overshoot_chance": 0.0,
|
||||
"click_aim_delay_input": (5, 5),
|
||||
"click_aim_delay_button": (5, 5),
|
||||
@@ -609,6 +609,133 @@ def test_scroll_overshoot_wheels_back(page):
|
||||
assert all(e["dy"] > 0 for e in events(page, "wheel"))
|
||||
|
||||
|
||||
def test_wheel_events_are_whole_notches(page):
|
||||
"""CDP reports every wheel event as one notch (wheelDeltaY = 120), so the
|
||||
page-visible deltaY must be a whole notch of the persona's platform too;
|
||||
a 20-40 px chunk with wheelDeltaY 120 is something no wheel produces."""
|
||||
page.set_viewport_size({"width": 800, "height": 300})
|
||||
page.evaluate("""() => { window.__wh = []; document.addEventListener('wheel',
|
||||
e => __wh.push([e.deltaY, e.wheelDeltaY]), { capture: true, passive: true }); }""")
|
||||
page.click("#chk", human_config={"scroll_notch_px": 0})
|
||||
wheels = page.evaluate("__wh")
|
||||
assert wheels
|
||||
plat = page.evaluate("navigator.platform").lower()
|
||||
notch = 40 if plat.startswith("mac") else 100 if plat.startswith("win") else 120
|
||||
assert {abs(dy) for dy, _ in wheels} == {notch}, wheels
|
||||
assert all(dy * wdy < 0 for dy, wdy in wheels)
|
||||
|
||||
|
||||
def test_typing_uses_human_key_timing(browser, site):
|
||||
"""Default timings: keys held ~100 ms, the next key often down before the
|
||||
previous one is up, Shift usually let go first; never a key down twice."""
|
||||
p = browser.new_page()
|
||||
p.goto(site.url + "index.html")
|
||||
try:
|
||||
text = "The Quick brown fox jumps over the lazy dog"
|
||||
p.fill("#name", text, human_config={"key_hold": (60, 175), "typing_delay": 150,
|
||||
"typing_delay_spread": 70, "typing_pause_chance": 0,
|
||||
"shift_down_delay": (60, 220), "shift_up_delay": (-60, 30)})
|
||||
assert value(p, "#name") == text
|
||||
down: dict = {}
|
||||
holds, rolled, pairs, last = [], 0, 0, None
|
||||
for e in events(p, "keydown", "keyup"):
|
||||
if e["t"] == "keydown":
|
||||
assert e["code"] not in down, e
|
||||
down[e["code"]] = e["ts"]
|
||||
if last is not None and e["key"] != "Shift":
|
||||
pairs += 1
|
||||
rolled += last in down
|
||||
if e["key"] != "Shift":
|
||||
last = e["code"]
|
||||
else:
|
||||
holds.append(e["ts"] - down.pop(e["code"]))
|
||||
assert not down
|
||||
holds.sort()
|
||||
assert 70 <= holds[len(holds) // 2] <= 140, holds
|
||||
assert rolled > 0, "no rollover at all"
|
||||
finally:
|
||||
p.close()
|
||||
|
||||
|
||||
|
||||
def test_same_key_lower_then_upper_never_repeats(browser, site):
|
||||
""""xX": the capital's key must not go down while the lower-case press of
|
||||
the same physical key is still held (a keydown with repeat=true). Same for
|
||||
a digit and its shift symbol ("1!")."""
|
||||
p = browser.new_page()
|
||||
p.goto(site.url + "index.html")
|
||||
try:
|
||||
p.evaluate("""() => { window.__kd = []; for (const t of ['keydown', 'keyup'])
|
||||
document.addEventListener(t, e => __kd.push([t, e.code, e.repeat]), true); }""")
|
||||
text = "xX aA zZ 1! qQ"
|
||||
cfg = {"key_hold": (60, 175), "typing_delay": 150, "typing_delay_spread": 70,
|
||||
"typing_pause_chance": 0, "shift_down_delay": (60, 220), "shift_up_delay": (-60, 30)}
|
||||
for _ in range(3):
|
||||
p.fill("#name", text, human_config=cfg)
|
||||
assert value(p, "#name") == text
|
||||
log = p.evaluate("__kd")
|
||||
assert not [e for e in log if e[2]], [e for e in log if e[2]]
|
||||
held = set()
|
||||
for t, code, _ in log:
|
||||
if t == "keydown":
|
||||
assert code not in held, (code, log)
|
||||
held.add(code)
|
||||
else:
|
||||
held.discard(code)
|
||||
finally:
|
||||
p.close()
|
||||
|
||||
def test_moves_never_report_zero_movement(page):
|
||||
page.evaluate("""() => { window.__mm = []; document.addEventListener('mousemove',
|
||||
e => __mm.push([e.movementX, e.movementY]), true); }""")
|
||||
page.hover("#btn")
|
||||
page.hover("#chk")
|
||||
mm = page.evaluate("__mm")
|
||||
assert mm and not [m for m in mm[1:] if m == [0, 0]], mm
|
||||
|
||||
|
||||
# --- containing-block-aware scroller (#593) ----------------------------------
|
||||
|
||||
_CB_CSS = """<!doctype html><style>
|
||||
body { margin: 0 } header { height: 215px }
|
||||
.content { height: 300px; overflow: auto } .tall { height: 2000px }
|
||||
button { width: 44px; height: 44px }
|
||||
</style><header>header</header>"""
|
||||
|
||||
_CB_CASES = {
|
||||
# renders on screen, outside the scroller's scrollport: no scrolling
|
||||
"fixed_modal": ("""<div class="content"><div class="tall">
|
||||
<div style="position: fixed; inset: 0; background: rgba(0,0,0,.4)">
|
||||
<button id="t" style="position: absolute; top: 146px; right: 36px">X</button></div></div></div>""", False),
|
||||
"absolute_escapes_static_scroller": ("""<div style="position: relative">
|
||||
<div class="content"><div class="tall">
|
||||
<button id="t" style="position: absolute; top: 320px; left: 10px">X</button></div></div></div>""", False),
|
||||
# still clipped by the scroller: must be wheeled into view first
|
||||
"normal_child_below_fold": ("""<div class="content"><div class="tall">
|
||||
<div style="height: 1500px"></div><button id="t">X</button></div></div>""", True),
|
||||
"absolute_in_positioned_scroller": ("""<div class="content" style="position: relative"><div class="tall">
|
||||
<button id="t" style="position: absolute; top: 1500px; left: 10px">X</button></div></div>""", True),
|
||||
"fixed_in_transformed_scroller": ("""<div class="content" style="transform: translateZ(0)"><div class="tall">
|
||||
<button id="t" style="position: fixed; top: 1500px; left: 10px">X</button></div></div>""", True),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(_CB_CASES))
|
||||
def test_scroller_follows_the_containing_block_chain(page, name):
|
||||
"""overflow clips only boxes whose containing block sits inside the
|
||||
scroller; a fixed modal that is a DOM child of a scrolling panel is on
|
||||
screen and must be clicked, not retried as 'outside of the viewport'."""
|
||||
body, clipped = _CB_CASES[name]
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.goto("data:text/html," + urllib.parse.quote(_CB_CSS + body))
|
||||
page.evaluate("""() => { window.__clicks = 0;
|
||||
document.getElementById('t').addEventListener('click', () => __clicks++); }""")
|
||||
page.click("#t", timeout=5000)
|
||||
assert page.evaluate("__clicks") == 1
|
||||
scrolled = page.evaluate("document.querySelector('.content').scrollTop")
|
||||
assert (scrolled > 0) == clipped, scrolled
|
||||
|
||||
|
||||
def test_per_call_human_config(page):
|
||||
reset(page)
|
||||
page.click("#btn", human_config={"click_hold_button": (300, 300)})
|
||||
@@ -724,6 +851,16 @@ def test_select_option_on_emoji_labels(page):
|
||||
assert page.select_option("#emoji", "c") == ["c"]
|
||||
|
||||
|
||||
def test_select_option_that_navigates_the_page(page):
|
||||
"""#597: a dropdown that submits its form on change; the pick still returns."""
|
||||
page.evaluate("""() => { const f = document.createElement('form'); f.method = 'get';
|
||||
const s = document.createElement('select'); s.id = 'nav'; s.name = 'city';
|
||||
for (const [l, v] of [['Amsterdam', 'a'], ['Berlin', 'b']]) s.add(new Option(l, v));
|
||||
s.onchange = () => f.submit(); f.append(s); document.body.prepend(f); }""")
|
||||
assert page.select_option("#nav", "b") == ["b"]
|
||||
page.wait_for_url("**city=b")
|
||||
|
||||
|
||||
# --- frames and handles ------------------------------------------------------
|
||||
|
||||
def test_frame_actions(page):
|
||||
|
||||
@@ -180,6 +180,86 @@ class TestBezierMath:
|
||||
assert 50 <= t.y <= 80
|
||||
|
||||
|
||||
class TestHumanTiming:
|
||||
"""Distributions the engine draws from, checked against human data
|
||||
(136M Keystrokes, Dhakal et al. CHI 2018; Fitts' law; SapiMouse)."""
|
||||
|
||||
def test_removed_settings_are_dropped_with_a_warning(self):
|
||||
import pytest
|
||||
from cloakbrowser.human.config import HumanConfig, resolve_config
|
||||
for k in ("mouse_steps_divisor", "mouse_burst_size", "mouse_burst_pause",
|
||||
"scroll_delta_base", "scroll_delta_variance"):
|
||||
assert k not in HumanConfig.__dataclass_fields__
|
||||
with pytest.warns(FutureWarning, match="mouse_steps_divisor") as rec:
|
||||
cfg = resolve_config("default", {"mouse_steps_divisor": 4, "typing_delay": 90})
|
||||
assert cfg.typing_delay == 90
|
||||
# Shown by default (not a DeprecationWarning) and points at the caller.
|
||||
assert rec[0].filename == __file__
|
||||
|
||||
def test_key_hold_is_human_length(self):
|
||||
import statistics
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from cloakbrowser.human.keyboard import key_hold
|
||||
cfg = resolve_config("default", None)
|
||||
holds = [key_hold(cfg) for _ in range(5000)]
|
||||
# People hold a key ~100 ms; under 40 ms is under 1% of human presses.
|
||||
assert 85 <= statistics.median(holds) <= 120
|
||||
assert sum(h < 40 for h in holds) / len(holds) < 0.01
|
||||
|
||||
def test_key_gap_depends_on_the_letters(self):
|
||||
import statistics
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from cloakbrowser.human.keyboard import key_gap
|
||||
cfg = resolve_config("default", {"typing_pause_chance": 0})
|
||||
med = lambda a, b: statistics.median(key_gap(cfg, a, b) for _ in range(3000))
|
||||
other_hand, same_hand, after_space = med("a", "j"), med("a", "s"), med(" ", "t")
|
||||
assert other_hand < same_hand < after_space
|
||||
|
||||
def test_mouse_never_reports_a_move_without_movement(self):
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from cloakbrowser.human.mouse import plan_move
|
||||
cfg = resolve_config("default", None)
|
||||
for d in (2, 30, 300, 1500):
|
||||
for _ in range(100):
|
||||
pts = plan_move(10, 10, 10 + d, 10 + d / 2, cfg)
|
||||
prev = (10, 10)
|
||||
for x, y, dt in pts:
|
||||
assert abs(x - prev[0]) >= 1 or abs(y - prev[1]) >= 1
|
||||
assert dt >= 0
|
||||
prev = (x, y)
|
||||
assert pts[-1][:2] == (10 + d, 10 + d / 2)
|
||||
|
||||
def test_mouse_duration_follows_fitts_law_with_spread(self):
|
||||
import statistics
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from cloakbrowser.human.mouse import plan_stroke
|
||||
cfg = resolve_config("default", None)
|
||||
def times(d):
|
||||
return [sum(p[2] for p in plan_stroke(0, 0, d, 0, cfg)) for _ in range(400)]
|
||||
short, long = times(100), times(1200)
|
||||
assert statistics.median(short) < statistics.median(long) < statistics.median(short) * 2
|
||||
# Same distance, very different durations (human CV is ~0.5).
|
||||
assert statistics.pstdev(long) / statistics.mean(long) > 0.2
|
||||
|
||||
def test_mouse_speed_peaks_early(self):
|
||||
import math, statistics
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from cloakbrowser.human.mouse import plan_stroke
|
||||
cfg = resolve_config("default", None)
|
||||
peaks = []
|
||||
for _ in range(200):
|
||||
pts = plan_stroke(0, 0, 800, 0, cfg)
|
||||
t, prev, best, at = 0.0, (0, 0), 0.0, 0.0
|
||||
for x, y, dt in pts:
|
||||
t += dt
|
||||
v = math.hypot(x - prev[0], y - prev[1]) / max(dt, 1e-6)
|
||||
if v > best:
|
||||
best, at = v, t
|
||||
prev = (x, y)
|
||||
peaks.append(at / t)
|
||||
assert statistics.median(peaks) < 0.4 # people: ~0.3; a symmetric ease: 0.5
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. Async compatibility
|
||||
# =========================================================================
|
||||
|
||||
@@ -284,6 +284,57 @@ async def test_persistent_context_async_timezone_id_alias(_mock_bin):
|
||||
assert "timezone_id" not in call_kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("launcher", ["launch_async", "launch_context_async", "launch_persistent_context_async"])
|
||||
async def test_async_launchers_do_blocking_io_off_the_event_loop(launcher):
|
||||
"""Binary/license, GeoIP and WebRTC lookups are blocking network calls; on the loop
|
||||
they freeze every other coroutine (e.g. a server's health endpoint) for up to 20s."""
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
loop_thread = threading.get_ident()
|
||||
threads = []
|
||||
|
||||
def slow(result):
|
||||
def call(*_a, **_kw):
|
||||
threads.append(threading.get_ident())
|
||||
time.sleep(0.2)
|
||||
return result(*_a) if callable(result) else result
|
||||
return call
|
||||
|
||||
ticks = 0
|
||||
|
||||
async def heartbeat():
|
||||
nonlocal ticks
|
||||
while True:
|
||||
await asyncio.sleep(0.02)
|
||||
ticks += 1
|
||||
|
||||
pw_cm, pw, context = _make_mock_async_pw_and_context()
|
||||
pw.chromium.launch.return_value = AsyncMock()
|
||||
|
||||
with patch("playwright.async_api.async_playwright", return_value=pw_cm), \
|
||||
patch("cloakbrowser.browser.ensure_binary", slow("/fake/chrome")), \
|
||||
patch("cloakbrowser.browser.maybe_resolve_geoip", slow(("Europe/Berlin", "de-DE", "5.6.7.8"))), \
|
||||
patch("cloakbrowser.browser._resolve_webrtc_args", slow(lambda args, _proxy: args)):
|
||||
import cloakbrowser.browser as browser
|
||||
beat = asyncio.create_task(heartbeat())
|
||||
try:
|
||||
if launcher == "launch_persistent_context_async":
|
||||
await browser.launch_persistent_context_async("/tmp/profile", geoip=True)
|
||||
else:
|
||||
await getattr(browser, launcher)(geoip=True)
|
||||
finally:
|
||||
beat.cancel()
|
||||
|
||||
# 3 calls; 4 via launch_context_async (its inner launch_async re-calls geoip as a no-op)
|
||||
assert len(threads) >= 3 and loop_thread not in threads
|
||||
assert ticks >= 10 # ~30 over 0.6s at 20ms; a blocked loop gets ~0
|
||||
launch = pw.chromium.launch_persistent_context if launcher == "launch_persistent_context_async" else pw.chromium.launch
|
||||
assert "--fingerprint-timezone=Europe/Berlin" in launch.call_args[1]["args"]
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||
@patch("cloakbrowser.browser.seed_widevine_hint")
|
||||
|
||||
Reference in new issue
Block a user