Compare commits

...
13 Commits
Author SHA1 Message Date
CloakHQ fbf005bc19 release: v0.6.3 — humanize timing, mouse and scroll realism, GeoIP fixes 2026-10-14 18:50:55 +02:00
CloakHQ e156f89564 test(dotnet): expect normalized newlines in CRLF typing test 2026-10-14 18:32:39 +02:00
lilosandevelaa123 1966ff861d fix(humanize): human-like key timing, Fitts-law mouse, notch wheels; containing-block scroller (#593) (#595)
Typing (Python, JS Playwright/Puppeteer, .NET)
- Absolute-time Typist: log-normal hold (~100 ms), key-pair dependent
  inter-key gaps (space, same/other hand, repeated letter), rollover via
  queued releases, Shift often released before the letter, no key pressed
  twice, all held keys released on error. A lower-case press of a key is
  released before Shift + the same key goes down ("xX", "1!"), so no
  keydown with repeat=true.
- Defaults moved to Aalto 136M Keystrokes: typing_delay 150, key_hold
  (60, 175), mistype_chance 0.04; careful preset updated.

Mouse
- Fitts-law stroke duration (mouse_time_base / per_bit / spread),
  log-normal run-to-run spread, frame-rate events, time-warped
  minimum-jerk profile with an early velocity peak, gentle curvature and
  wobble, no zero-movement events, notch-free overshoot correction.

Scroll
- Whole wheel notches of the persona's platform (Win 100, mac 40,
  Linux 120; scroll_notch_px override) in short flicks, so deltaY agrees
  with CDP's one-notch wheelDeltaY; overshoot measured in notches.

- scroller() in the isolated-world helpers walks the containing-block
  chain: fixed boxes skip ancestors that do not establish a containing
  block for them, absolute boxes skip static ancestors. A fixed modal in
  a scrolling panel is clicked instead of timing out with "element is
  outside of the viewport". All three helper copies stay byte-identical.

Removed
- Settings with no effect after the change: mouse_steps_divisor,
  mouse_burst_size, mouse_burst_pause, scroll_delta_base,
  scroll_delta_variance (and the .NET properties). Python drops them from
  human_config with a FutureWarning that points at the caller; JS and the .NET override
  dictionary ignore them. The standalone .NET HumanScroll wheels in whole
  notches too. READMEs updated to the new defaults.

- .NET: a "\r\n" text element is inserted as text again (it threw
  "Unknown key" from KeyDownAsync).

Tests: timing/rollover/notch/zero-move tests; five #593 cases in Python,
JS and .NET; puppeteer/stealth tests updated to the new semantics.

Co-authored-by: evelaa123 <189992797+evelaa123@users.noreply.github.com>
2026-10-14 18:30:49 +02:00
CloakHQ 04c3c027c2 fix(humanize): select_option returns when the pick navigates the page (#597) 2026-10-13 21:35:37 +02:00
CloakHQ 2571290992 fix(geoip): fall back to en-US for unmapped countries (#591)
A country missing from the locale map no longer aborts the launch; it logs a warning and uses en-US.
2026-10-13 21:20:49 +02:00
Jasper 160f56da85 fix(geoip): map Seychelles (SC) to a locale (#596)
GeoLite2 classifies some datacenter proxy exits as SC; with no entry in
COUNTRY_LOCALE_MAP the lookup returned no locale and geoip=True failed.
Add SC -> en-SC to the Python, JS and .NET maps.

Fixes #591
2026-10-13 21:14:27 +02:00
CloakHQ 214a2beb69 docs: Linux Pro Stable 154.0.8037.57.2, --license-route flag
README stable versions updated; documents --license-route and that the
Windows font-metrics flag is on by default from 57.2.
2026-10-13 20:28:18 +02:00
CloakHQ 485f678ac0 docs: version pinning requires a paid Pro key
A free GitHub key ignores browser_version / CLOAKBROWSER_VERSION and
always gets the latest build. README examples now show the Pro pin only.
2026-10-13 06:59:22 +02:00
CloakHQ 89fb671af8 fix(async): run blocking launch lookups off the event loop
Python async launchers ran the binary/license check and the GeoIP and
WebRTC exit-IP lookups on the caller's event loop, freezing every other
coroutine for up to 20s per launch. Run them via asyncio.to_thread.

.NET: license validation and Pro version lookups inside async download
methods now run via Task.Run instead of holding the caller's thread.
2026-10-12 22:01:53 +02:00
CloakHQ 4a1b8f9c89 release: v0.6.2 — humanize fix for Playwright 1.64 2026-10-12 08:16:10 +02:00
dependabot[bot] c3e0f9131b chore(deps-dev): bump the javascript group in /js with 3 updates (#588)
Bumps the javascript group in /js with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [playwright-core](https://github.com/microsoft/playwright) and [puppeteer-core](https://github.com/puppeteer/puppeteer).


Updates `@types/node` from 26.6.3 to 26.6.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `playwright-core` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)

Updates `puppeteer-core` from 25.12.0 to 25.13.0
- [Release notes](https://github.com/puppeteer/puppeteer/releases)
- [Changelog](https://github.com/puppeteer/puppeteer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v25.12.0...puppeteer-core-v25.13.0)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.6.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: javascript
- dependency-name: playwright-core
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: javascript
- dependency-name: puppeteer-core
  dependency-version: 25.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: javascript
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 22:47:43 +02:00
dependabot[bot] 9a934873ad chore(deps): bump actions/setup-node (#589)
Bumps the actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/setup-node` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 22:47:40 +02:00
laplace-anon bd89505888 fix(humanize): pass frameVisible to InjectedScript state checks (#586) (#587)
Playwright 1.64 added a frameVisible argument to InjectedScript's
checkElementStates() and elementState(). Without it every element reads
as hidden, so every humanized element action times out with "element is
not visible". Pass true (the pre-1.64 behavior) in all three helper
copies; Playwright 1.63 and older ignore the extra argument.
2026-10-11 22:34:01 +02:00
59 changed files with 2738 additions and 1134 deletions

No files matched your search

+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+19 -20
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "0.6.1"
__version__ = "0.6.3"
+12 -8
View File
@@ -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.
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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."""
+88 -17
View File
@@ -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
View File
@@ -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:
+15 -2
View File
@@ -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
View File
@@ -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 |
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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";
}
+5 -4
View File
@@ -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);
+7
View File
@@ -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);
}
+55 -29
View File
@@ -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)
+74 -52
View File
@@ -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);
}
}
+264 -16
View File
@@ -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 */ }
}
}
}
+127 -48
View File
@@ -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;
+63 -86
View File
@@ -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);
+15 -2
View File
@@ -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
View File
@@ -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.
+40 -40
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+11 -8
View File
@@ -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));
};
}
+41 -134
View File
@@ -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();
}
}
+79 -73
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+15 -2
View File
@@ -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;
+34
View File
@@ -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("-");
+1 -1
View File
@@ -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");
+68
View File
@@ -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 () => {
-1
View File
@@ -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],
+28
View File
@@ -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 {
+15 -13
View File
@@ -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 () => {
+4 -2
View File
@@ -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],
};
+34
View File
@@ -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"))
+138 -1
View File
@@ -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):
+80
View File
@@ -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
# =========================================================================
+51
View File
@@ -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")