@achamm/veilbrowser 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/browser.d.ts +16 -0
- package/dist/browser.js +43 -0
- package/dist/cdp.d.ts +39 -0
- package/dist/cdp.js +128 -0
- package/dist/human.d.ts +33 -0
- package/dist/human.js +73 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/launcher.d.ts +67 -0
- package/dist/launcher.js +272 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +156 -0
- package/dist/page.d.ts +194 -0
- package/dist/page.js +527 -0
- package/dist/stealth.d.ts +28 -0
- package/dist/stealth.js +74 -0
- package/package.json +54 -0
- package/src/browser.ts +43 -0
- package/src/cdp.ts +133 -0
- package/src/human.ts +82 -0
- package/src/index.ts +6 -0
- package/src/launcher.ts +325 -0
- package/src/mcp.ts +155 -0
- package/src/page.ts +571 -0
- package/src/stealth.ts +103 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Armani Cunningham
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Veil
|
|
2
|
+
|
|
3
|
+
**A stealth automation runtime for AI agents. Drives real Chrome over raw CDP — no Playwright, no Puppeteer, no WebDriver, zero runtime dependencies.**
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
*Veil driving real Chrome: bot.sannysoft.com all-green, then straight through Cloudflare's JS challenge — no patches, no plugins.*
|
|
8
|
+
|
|
9
|
+
To Instagram, Google, Reddit, Cloudflare — Veil *is* Chrome. Same binary, same
|
|
10
|
+
TLS, same JS engine, same canvas/WebGL/font fingerprint a human's browser has. We
|
|
11
|
+
don't reimplement the browser (that's Chromium's 20-year, 1000-engineer job, and a
|
|
12
|
+
hand-rolled engine is *easier* to fingerprint, not harder). We replace the part that
|
|
13
|
+
gets you caught: **the automation layer.**
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Why not Playwright / Puppeteer?
|
|
18
|
+
|
|
19
|
+
They're powerful and great for scripted QA. For *agents on hostile sites* they have three structural problems:
|
|
20
|
+
|
|
21
|
+
| Problem | Playwright/Puppeteer | Veil |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| **Detectable** | `navigator.webdriver=true`, `--enable-automation`, the `Runtime.enable` CDP tell, `HeadlessChrome` UA | webdriver scrubbed, no automation switches, no `Runtime.enable`, UA + client-hints normalized |
|
|
24
|
+
| **Robotic input** | instant teleport clicks, fixed-cadence typing → behavioural detection | curved Bézier mouse paths, eased timing, human keystroke cadence |
|
|
25
|
+
| **Brittle for agents** | CSS/XPath selectors that break constantly | accessibility-tree snapshot → stable integer `ref`s; agents never write a selector |
|
|
26
|
+
|
|
27
|
+
Veil is **dependency-free** — Node 24 / Bun ship a global `WebSocket`, so the entire
|
|
28
|
+
CDP transport is ~120 lines we own. Nothing to patch, nothing to leak.
|
|
29
|
+
|
|
30
|
+
## How Veil compares (honest)
|
|
31
|
+
|
|
32
|
+
The "real Chrome over raw CDP" idea isn't new — Python's [`nodriver`](https://github.com/ultrafunkamsterdam/nodriver)
|
|
33
|
+
pioneered it, and [Camoufox](https://camoufox.com) (a C++-patched Firefox) scores even
|
|
34
|
+
better on pure stealth. Veil isn't claiming to out-stealth them. Its wedge is **where it
|
|
35
|
+
lives and how agents use it**:
|
|
36
|
+
|
|
37
|
+
- **TypeScript-native.** The JS/TS agent ecosystem (Vercel AI SDK, LangChain.js, MCP) has
|
|
38
|
+
no strong raw-CDP stealth driver — it's stuck on Playwright + stealth plugins, or
|
|
39
|
+
shelling out to Python `nodriver`. Veil is that missing piece.
|
|
40
|
+
- **MCP-native.** Ships an MCP server, so any agent gets stealth browsing as tools with
|
|
41
|
+
zero glue.
|
|
42
|
+
- **Agent-first, not scraper-first.** Accessibility-tree refs and human input are built for
|
|
43
|
+
an LLM driving the browser, not for a scraping script.
|
|
44
|
+
- **Does two things the others don't.** Drives the native **"Sign in with Google" (FedCM)**
|
|
45
|
+
account chooser over CDP — agents are otherwise walled out of Google-SSO apps entirely —
|
|
46
|
+
and **blocks visited sites from port-scanning your localhost/LAN** (on by default). Neither
|
|
47
|
+
`nodriver`, Camoufox, nor Playwright-stealth does either.
|
|
48
|
+
|
|
49
|
+
If you're in Python and just want raw stealth, use `nodriver` or Camoufox — they're great.
|
|
50
|
+
Veil is for **TypeScript agents and MCP hosts.**
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
**Prerequisites:** Chrome/Chromium on PATH (or `VEIL_CHROME=/path/to/chrome`), and Bun.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bun install
|
|
58
|
+
bun run examples/selftest.ts # launches real Chrome, runs the full chain
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**In your own project** — `bun add @achamm/veilbrowser` (or `npm install @achamm/veilbrowser`):
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { Browser } from "@achamm/veilbrowser";
|
|
65
|
+
|
|
66
|
+
const browser = await Browser.launch({ headless: false }); // headful = stealthiest
|
|
67
|
+
const page = await browser.newPage();
|
|
68
|
+
await page.goto("https://example.com");
|
|
69
|
+
|
|
70
|
+
// Accessibility-tree snapshot → stable integer refs (no selectors).
|
|
71
|
+
const snap = await page.snapshot();
|
|
72
|
+
console.log(snap.text);
|
|
73
|
+
// [1] textbox "Search"
|
|
74
|
+
// [2] button "Sign in"
|
|
75
|
+
|
|
76
|
+
await page.fill(1, "hello"); // act by ref — human typing, jittered timing
|
|
77
|
+
await page.click(2); // curved Bézier mouse path, real CDP input
|
|
78
|
+
const png = await page.screenshot(); // PNG buffer for a vision model
|
|
79
|
+
|
|
80
|
+
await browser.close();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## How the stealth works
|
|
84
|
+
|
|
85
|
+
1. **Launch (`launcher.ts`)** — a real Chrome with the flags a *normal* profile uses,
|
|
86
|
+
minus the automation switches Playwright adds. `--disable-blink-features=AutomationControlled`
|
|
87
|
+
flips `navigator.webdriver` to `false` at the engine level. Persistent `userDataDir`
|
|
88
|
+
so the profile looks *used* (history, cookies), not freshly minted.
|
|
89
|
+
2. **Transport (`cdp.ts`)** — raw WebSocket, flat session mode. **We never call
|
|
90
|
+
`Runtime.enable`** — that command is a primary CDP detection vector. `Runtime.evaluate`
|
|
91
|
+
works without it.
|
|
92
|
+
3. **Page patch (`stealth.ts`)** — injected via `addScriptToEvaluateOnNewDocument`
|
|
93
|
+
*before* any site code, on every frame: normalizes `webdriver`, `window.chrome`,
|
|
94
|
+
`plugins`, `languages`, `permissions.query`, WebGL vendor — and makes the patched
|
|
95
|
+
functions' `toString()` look native so the patch itself can't be detected. Kept
|
|
96
|
+
deliberately small; over-patching is its own fingerprint.
|
|
97
|
+
4. **UA / client hints (`page.ts`)** — strips the `HeadlessChrome` token from the UA
|
|
98
|
+
*and* the `Sec-CH-UA` brand headers.
|
|
99
|
+
5. **Human input (`human.ts`)** — seedable PRNG drives curved mouse paths and
|
|
100
|
+
jittered keystroke timing.
|
|
101
|
+
|
|
102
|
+
## Agent tooling (the other half of the product)
|
|
103
|
+
|
|
104
|
+
The selling point isn't only stealth — it's that agents drive it *well*:
|
|
105
|
+
|
|
106
|
+
- **`snapshot()`** returns the page as a flat numbered index from the accessibility
|
|
107
|
+
tree (the semantic layer screen readers use). The #1 cause of agent breakage —
|
|
108
|
+
guessed CSS/XPath selectors — is gone. The agent acts on a stable `ref`.
|
|
109
|
+
- **`screenshot()`** returns a PNG buffer, ready for vision grounding.
|
|
110
|
+
- **`click` / `fill` / `type`** drive real CDP input with human dynamics.
|
|
111
|
+
- **`waitFor(expr)`** replaces flaky fixed sleeps.
|
|
112
|
+
|
|
113
|
+
## Federated sign-in (FedCM)
|
|
114
|
+
|
|
115
|
+
"Sign in with Google" one-tap and the newer `navigator.credentials.get({identity})`
|
|
116
|
+
flows render their account chooser as **native browser UI** — the button is a
|
|
117
|
+
cross-origin IdP iframe and the chooser is browser chrome, so no synthetic click can
|
|
118
|
+
reach either. That's a wall for agents logging into Google-SSO apps. Veil drives it
|
|
119
|
+
over CDP's FedCM domain instead:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// Passive / one-tap (fires on load once you're signed in to the IdP):
|
|
123
|
+
await page.enableFedCm(); // autoSelectFirst — picks account 0 for you
|
|
124
|
+
await page.goto("https://app.example.com/");
|
|
125
|
+
await page.waitForFedCmDialog(); // chooser intercepted + auto-selected
|
|
126
|
+
await page.disableFedCm();
|
|
127
|
+
|
|
128
|
+
// Active "Sign in with Google" button, one call:
|
|
129
|
+
const account = await page.signInWithFedCm({ triggerRef: btn.ref });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`enableFedCm` also `resetCooldown`s (Chrome silently suppresses the dialog after
|
|
133
|
+
repeated dismissals) and binds account selection to the page's own CDP session — pick
|
|
134
|
+
the wrong target and the dialog, plus the page's `credentials.get()`, hangs forever.
|
|
135
|
+
Enable it **on demand**, right before the sign-in: turning it on globally hangs any
|
|
136
|
+
site that silently probes FedCM at load. End-to-end run against the canonical demo IdP:
|
|
137
|
+
`bun run examples/fedcm.ts`.
|
|
138
|
+
|
|
139
|
+
## No localhost / LAN leak (private-network block)
|
|
140
|
+
|
|
141
|
+
Fingerprinters (iphey, pixelscan, …) don't read your process list — they can't.
|
|
142
|
+
They **port-scan `127.0.0.1` from page JavaScript**, timing which local ports answer,
|
|
143
|
+
and map open ports to software: VNC on `:5900`, an antidetect API on `:3001`, a dev
|
|
144
|
+
server on `:3000`. That both fingerprints you *and* leaks your LAN to every site you
|
|
145
|
+
visit. Most stealth stacks (nodriver, Camoufox, Playwright-stealth) don't stop it.
|
|
146
|
+
|
|
147
|
+
Veil does, **on by default**. Every HTTP-family request (`fetch`/XHR/`EventSource`/
|
|
148
|
+
`<img>`/`<script>`) from a page to a loopback or private (`127.0.0.0/8`, `10/8`,
|
|
149
|
+
`172.16/12`, `192.168/16`, `::1`, `.localhost`) address is failed **uniformly and
|
|
150
|
+
instantly** — the same error whether the port is open or closed — so a scan can't tell
|
|
151
|
+
them apart and comes back empty. (That's the vector real detectors use; the `:3001`
|
|
152
|
+
antidetect API and `:5900` VNC probes are plain HTTP.)
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const browser = await Browser.launch(); // block is on
|
|
156
|
+
const browser = await Browser.launch({ blockPrivateNetwork: false }); // opt out
|
|
157
|
+
// per-page, toggle at runtime:
|
|
158
|
+
await page.blockPrivateNetwork();
|
|
159
|
+
await page.unblockPrivateNetwork();
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Still allowed: the agent's own top-level navigation to a private host
|
|
163
|
+
(`page.goto("http://localhost:3000")`), and a localhost page loading its own localhost
|
|
164
|
+
resources — only a **public page reaching a private host** is blocked. Known gaps (honest):
|
|
165
|
+
raw **WebSocket** to a private host isn't interceptable via CDP's Fetch domain, so those
|
|
166
|
+
fall back to Chrome's own Private Network Access (a timeout, not a uniform block); and
|
|
167
|
+
exotic IP encodings (decimal/hex) aren't matched — real-world scanners use the canonical
|
|
168
|
+
forms above.
|
|
169
|
+
|
|
170
|
+
## Detection scorecard (measured, Chrome 148)
|
|
171
|
+
|
|
172
|
+
Run it yourself: `bun run examples/detect.ts` (headless) or `VEIL_HEADFUL=1 bun run
|
|
173
|
+
examples/detect.ts` (headful — Veil auto-starts its own Xvfb, no wrapper needed).
|
|
174
|
+
|
|
175
|
+
Measured on an AMD Radeon (Renoir APU) host, real hardware GL via ANGLE/EGL:
|
|
176
|
+
|
|
177
|
+
| Detector | Mode | sannysoft | CreepJS "headless" | CreepJS "stealth" |
|
|
178
|
+
|---|---|---|---|---|
|
|
179
|
+
| **Veil — headful + auto-Xvfb + real GPU** | recommended | **57/57** | **0%** | **0%** |
|
|
180
|
+
| Veil — headless + real GPU | server/fast | **57/57** | 33% | 0% |
|
|
181
|
+
| _(earlier: SwiftShader + heavy stealth)_ | _superseded_ | 57/57 | 67% | 20% |
|
|
182
|
+
|
|
183
|
+
**Live targets** — the full [scrapingcourse.com](https://www.scrapingcourse.com/) challenge
|
|
184
|
+
suite, reproducible with `bun run examples/scrapingcourse.ts` (residential IP, headful):
|
|
185
|
+
|
|
186
|
+
| Target | Result |
|
|
187
|
+
|---|---|
|
|
188
|
+
| Antibot Challenge | **Bypassed** — "You bypassed the Antibot challenge" (~3s auto-solve) |
|
|
189
|
+
| Cloudflare JS Challenge | **Bypassed** — "You bypassed the Cloudflare challenge" |
|
|
190
|
+
| Cloudflare Antibot + login | **Bypassed** — cleared the wall, rendered the real login form |
|
|
191
|
+
| Cloudflare **Turnstile** + login | **Passed** — managed Turnstile issues its token to real Chrome (a 700+ char `cf-turnstile-response`); the form submits. We don't *solve* a captcha — the real browser *earns* the token |
|
|
192
|
+
| Demo suite — JS rendering, infinite scroll, pagination, table parsing, load-more, CSRF/login | all served clean |
|
|
193
|
+
| Reddit — incl. its JS challenge | served clean (challenge auto-solved) |
|
|
194
|
+
| Instagram — public profile | served clean |
|
|
195
|
+
|
|
196
|
+
**12/12 cleared** on a clean residential IP. Two honest caveats, not hidden:
|
|
197
|
+
Cloudflare's JS *interstitial* difficulty scales with **IP reputation** — hammer one IP
|
|
198
|
+
and it escalates (that's IP rep, not a browser tell; use proxies at volume). And what we
|
|
199
|
+
do **not** yet claim: an **interactive checkbox** reCAPTCHA/Turnstile that demands a human
|
|
200
|
+
click, enterprise DataDome/Kasada, and high-volume behavioural trust on logged-in sessions.
|
|
201
|
+
We test before we claim.
|
|
202
|
+
|
|
203
|
+
**What moved the needle (each verified by re-running the suite):**
|
|
204
|
+
1. **Real GPU, not SwiftShader.** `--use-gl=angle --use-angle=gl-egl` drives the actual
|
|
205
|
+
AMD GPU → an authentic, self-consistent WebGL fingerprint. No vendor spoof = no *lie*
|
|
206
|
+
for CreepJS's pixel-hash to catch.
|
|
207
|
+
2. **Headful on a server.** Veil manages its own Xvfb display, so "headful" needs no
|
|
208
|
+
desktop. Eliminates the headless render quirks + tiny-screen tell (33% → 0%).
|
|
209
|
+
3. **Slim, self-gating stealth.** The biggest surprise: the *stealth patches themselves*
|
|
210
|
+
were the "20% stealth" signal. A correctly-launched Chrome already reports
|
|
211
|
+
`webdriver === false` (the right human value — forcing `undefined` is *worse*), 5
|
|
212
|
+
plugins, a real `chrome` object. So each patch now fires only when the value is
|
|
213
|
+
genuinely anomalous; on healthy Chrome it's a no-op. Smaller surface = nothing to detect.
|
|
214
|
+
|
|
215
|
+
## Use from an AI agent (MCP)
|
|
216
|
+
|
|
217
|
+
Veil ships an MCP server (`src/mcp.ts`) — already wired into **persoje**
|
|
218
|
+
(`~/.config/persoje/mcp.json`), exposing 10 tools: `goto`, `snapshot`, `click`, `fill`,
|
|
219
|
+
`type`, `screenshot`, `eval`, `fedcm_enable`, `fedcm_signin`, `close`. Verified end-to-end
|
|
220
|
+
through persoje's own MCP client (discover → goto → snapshot). Any MCP host works:
|
|
221
|
+
|
|
222
|
+
```jsonc
|
|
223
|
+
{ "servers": { "veil": {
|
|
224
|
+
"command": "/home/armani/.bun/bin/bun",
|
|
225
|
+
"args": ["run", "/home/armani/projects/veil/src/mcp.ts"],
|
|
226
|
+
"env": {} // headful + auto-Xvfb + real GPU (0% CreepJS).
|
|
227
|
+
// Set VEIL_HEADLESS=1 for the faster server mode.
|
|
228
|
+
} } }
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Testing
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
bun run examples/selftest.ts # end-to-end: launch, stealth, snapshot, interact
|
|
235
|
+
bun run examples/detect.ts # bot-detection scorecard (bot.sannysoft.com, etc.)
|
|
236
|
+
bun test # unit tests (browser-launching ones skip under CI)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Unit tests cover:
|
|
240
|
+
- **PRNG (`human.test.ts`)**: xorshift32 determinism, range/int bounds, keystroke cadence, mouse timing
|
|
241
|
+
- **Snapshot refs (`snapshot.test.ts`)**: ref numbering (1-based, sequential, no gaps), AX-tree filtering
|
|
242
|
+
- **CDP framing (`cdp-messages.test.ts`)**: JSON-RPC structure, sessionId routing, command/response correlation
|
|
243
|
+
- **Private-network classifier (`private-host.test.ts`)**: loopback/RFC1918 vs public host detection (the block's decision)
|
|
244
|
+
- **Lifecycle (`lifecycle.test.ts`, local only)**: launch/close, process-group reaping, profile-lock refusal
|
|
245
|
+
|
|
246
|
+
## Status
|
|
247
|
+
|
|
248
|
+
**Working today** (verified against Chrome 148):
|
|
249
|
+
- Zero-dep CDP runtime (raw WebSocket, flat session mode)
|
|
250
|
+
- Stealth launch + page-script injection (`--disable-blink-features=AutomationControlled`)
|
|
251
|
+
- UA/client-hint scrub (no "HeadlessChrome" token)
|
|
252
|
+
- WebGL backend selection (hardware GPU via ANGLE/EGL, or SwiftShader + vendor masking)
|
|
253
|
+
- AX-tree snapshot → stable integer refs for agent-friendly interaction
|
|
254
|
+
- Human-like input: curved Bézier mouse paths, jittered keystroke timing, real CDP input
|
|
255
|
+
- PNG screenshots (vision-model ready)
|
|
256
|
+
- Headful-on-server via **auto-managed Xvfb** — "headful" with no desktop (best fingerprint scores)
|
|
257
|
+
- **FedCM sign-in** — drives the native "Sign in with Google" / `credentials.get()` account chooser over CDP (`examples/fedcm.ts`)
|
|
258
|
+
- **No localhost/LAN leak** — blocks visited sites from port-scanning your private network (on by default)
|
|
259
|
+
- Clean process lifecycle — detached process groups + a reaper, so Ctrl-C/crash never orphans Chrome
|
|
260
|
+
- MCP server (`src/mcp.ts`) — stdio JSON-RPC; persoje, Claude, any MCP host drives Veil natively
|
|
261
|
+
|
|
262
|
+
**Roadmap toward production:**
|
|
263
|
+
- [ ] Adversarial fingerprint suite — continuous scoring (CreepJS, sannysoft, Datadome demo)
|
|
264
|
+
- [ ] `Runtime.enable`-leak hardening via isolated worlds for all eval
|
|
265
|
+
- [ ] Profile + residential-proxy pools (the "Veil Cloud" layer)
|
|
266
|
+
- [ ] Vision-based element grounding fallback (sparse AX-trees, canvas apps)
|
|
267
|
+
- [ ] Response-body capture; session persistence & profile warm-up
|
|
268
|
+
- [ ] Per-tab concurrency (many tabs, one socket — transport already supports it)
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
MIT.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser: the top-level handle. Launches Chrome, opens the CDP socket, and
|
|
3
|
+
* hands out Page objects attached via flat sessions.
|
|
4
|
+
*/
|
|
5
|
+
import { type LaunchOptions } from "./launcher.js";
|
|
6
|
+
import { Page } from "./page.js";
|
|
7
|
+
export declare class Browser {
|
|
8
|
+
private cdp;
|
|
9
|
+
private launch;
|
|
10
|
+
private blockPrivate;
|
|
11
|
+
private constructor();
|
|
12
|
+
static launch(opts?: LaunchOptions): Promise<Browser>;
|
|
13
|
+
/** Open a fresh tab and return an initialised Page. */
|
|
14
|
+
newPage(): Promise<Page>;
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser: the top-level handle. Launches Chrome, opens the CDP socket, and
|
|
3
|
+
* hands out Page objects attached via flat sessions.
|
|
4
|
+
*/
|
|
5
|
+
import { launchChrome } from "./launcher.js";
|
|
6
|
+
import { CDP } from "./cdp.js";
|
|
7
|
+
import { Page } from "./page.js";
|
|
8
|
+
export class Browser {
|
|
9
|
+
cdp;
|
|
10
|
+
launch;
|
|
11
|
+
blockPrivate;
|
|
12
|
+
constructor(cdp, launch, blockPrivate) {
|
|
13
|
+
this.cdp = cdp;
|
|
14
|
+
this.launch = launch;
|
|
15
|
+
this.blockPrivate = blockPrivate;
|
|
16
|
+
}
|
|
17
|
+
static async launch(opts = {}) {
|
|
18
|
+
const launch = await launchChrome(opts);
|
|
19
|
+
const cdp = await CDP.connect(launch.webSocketDebuggerUrl);
|
|
20
|
+
// Default ON: a stealth browser shouldn't let visited sites port-scan your
|
|
21
|
+
// localhost/LAN. Opt out with { blockPrivateNetwork: false }.
|
|
22
|
+
return new Browser(cdp, launch, opts.blockPrivateNetwork ?? true);
|
|
23
|
+
}
|
|
24
|
+
/** Open a fresh tab and return an initialised Page. */
|
|
25
|
+
async newPage() {
|
|
26
|
+
const { targetId } = await this.cdp.send("Target.createTarget", { url: "about:blank" });
|
|
27
|
+
const { sessionId } = await this.cdp.send("Target.attachToTarget", {
|
|
28
|
+
targetId,
|
|
29
|
+
flatten: true,
|
|
30
|
+
});
|
|
31
|
+
const page = new Page(this.cdp, sessionId, targetId);
|
|
32
|
+
await page.init({ maskWebgl: this.launch.maskWebgl, blockPrivateNetwork: this.blockPrivate });
|
|
33
|
+
return page;
|
|
34
|
+
}
|
|
35
|
+
async close() {
|
|
36
|
+
try {
|
|
37
|
+
await this.cdp.send("Browser.close");
|
|
38
|
+
}
|
|
39
|
+
catch { }
|
|
40
|
+
this.cdp.close();
|
|
41
|
+
this.launch.kill();
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/cdp.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw Chrome DevTools Protocol client.
|
|
3
|
+
*
|
|
4
|
+
* No puppeteer, no playwright, no chrome-remote-interface. Just a WebSocket and
|
|
5
|
+
* the protocol. We use "flat" session mode (Target.attachToTarget {flatten:true})
|
|
6
|
+
* so every command/event is tagged with a sessionId and we can talk to the
|
|
7
|
+
* browser and any number of page targets over a single socket.
|
|
8
|
+
*
|
|
9
|
+
* Crucially: we never call `Runtime.enable`. That command is one of the loudest
|
|
10
|
+
* automation tells on the web (it creates a detectable execution-context binding
|
|
11
|
+
* and fires events sites listen for). `Runtime.evaluate` works fine without it.
|
|
12
|
+
*/
|
|
13
|
+
type EventHandler = (params: any) => void;
|
|
14
|
+
export declare class CDP {
|
|
15
|
+
private url;
|
|
16
|
+
private ws;
|
|
17
|
+
private nextId;
|
|
18
|
+
private pending;
|
|
19
|
+
private handlers;
|
|
20
|
+
private closed;
|
|
21
|
+
private constructor();
|
|
22
|
+
static connect(webSocketDebuggerUrl: string): Promise<CDP>;
|
|
23
|
+
private open;
|
|
24
|
+
private onMessage;
|
|
25
|
+
/** Send a CDP command. Optionally scoped to a session (page target). */
|
|
26
|
+
send<T = any>(method: string, params?: Record<string, any>, sessionId?: string): Promise<T>;
|
|
27
|
+
/** Subscribe to an event. Pass sessionId, or "*" to match any session. */
|
|
28
|
+
on(method: string, handler: EventHandler, sessionId?: string): () => void;
|
|
29
|
+
/** Resolve once an event fires (with optional predicate / timeout). */
|
|
30
|
+
once(method: string, opts?: {
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
predicate?: (p: any) => boolean;
|
|
33
|
+
timeout?: number;
|
|
34
|
+
}): Promise<any>;
|
|
35
|
+
/** Remove all handlers for a specific sessionId. Called on page close to prevent accumulation. */
|
|
36
|
+
clearHandlers(sessionId: string): void;
|
|
37
|
+
close(): void;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
package/dist/cdp.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw Chrome DevTools Protocol client.
|
|
3
|
+
*
|
|
4
|
+
* No puppeteer, no playwright, no chrome-remote-interface. Just a WebSocket and
|
|
5
|
+
* the protocol. We use "flat" session mode (Target.attachToTarget {flatten:true})
|
|
6
|
+
* so every command/event is tagged with a sessionId and we can talk to the
|
|
7
|
+
* browser and any number of page targets over a single socket.
|
|
8
|
+
*
|
|
9
|
+
* Crucially: we never call `Runtime.enable`. That command is one of the loudest
|
|
10
|
+
* automation tells on the web (it creates a detectable execution-context binding
|
|
11
|
+
* and fires events sites listen for). `Runtime.evaluate` works fine without it.
|
|
12
|
+
*/
|
|
13
|
+
export class CDP {
|
|
14
|
+
url;
|
|
15
|
+
ws;
|
|
16
|
+
nextId = 1;
|
|
17
|
+
pending = new Map();
|
|
18
|
+
// key: `${sessionId ?? ""}:${method}` -> set of handlers
|
|
19
|
+
handlers = new Map();
|
|
20
|
+
closed = false;
|
|
21
|
+
constructor(url) {
|
|
22
|
+
this.url = url;
|
|
23
|
+
}
|
|
24
|
+
static async connect(webSocketDebuggerUrl) {
|
|
25
|
+
const cdp = new CDP(webSocketDebuggerUrl);
|
|
26
|
+
await cdp.open();
|
|
27
|
+
return cdp;
|
|
28
|
+
}
|
|
29
|
+
open() {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
this.ws = new WebSocket(this.url);
|
|
32
|
+
this.ws.addEventListener("open", () => resolve());
|
|
33
|
+
this.ws.addEventListener("error", (e) => reject(new Error(`CDP socket error: ${e?.message ?? "unknown"}`)));
|
|
34
|
+
this.ws.addEventListener("close", () => {
|
|
35
|
+
this.closed = true;
|
|
36
|
+
for (const { reject } of this.pending.values())
|
|
37
|
+
reject(new Error("CDP connection closed"));
|
|
38
|
+
this.pending.clear();
|
|
39
|
+
});
|
|
40
|
+
this.ws.addEventListener("message", (ev) => this.onMessage(String(ev.data)));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
onMessage(data) {
|
|
44
|
+
let msg;
|
|
45
|
+
try {
|
|
46
|
+
msg = JSON.parse(data);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (typeof msg.id === "number") {
|
|
52
|
+
const p = this.pending.get(msg.id);
|
|
53
|
+
if (!p)
|
|
54
|
+
return;
|
|
55
|
+
this.pending.delete(msg.id);
|
|
56
|
+
if (msg.error)
|
|
57
|
+
p.reject(new Error(`${p.method}: ${msg.error.message} (${msg.error.code})`));
|
|
58
|
+
else
|
|
59
|
+
p.resolve(msg.result);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Event. Dispatch to handlers keyed with and without sessionId.
|
|
63
|
+
if (msg.method) {
|
|
64
|
+
const sid = msg.sessionId ?? "";
|
|
65
|
+
for (const key of [`${sid}:${msg.method}`, `*:${msg.method}`]) {
|
|
66
|
+
const set = this.handlers.get(key);
|
|
67
|
+
if (set)
|
|
68
|
+
for (const h of [...set])
|
|
69
|
+
h(msg.params ?? {});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Send a CDP command. Optionally scoped to a session (page target). */
|
|
74
|
+
send(method, params = {}, sessionId) {
|
|
75
|
+
if (this.closed)
|
|
76
|
+
return Promise.reject(new Error("CDP connection closed"));
|
|
77
|
+
const id = this.nextId++;
|
|
78
|
+
const payload = { id, method, params };
|
|
79
|
+
if (sessionId)
|
|
80
|
+
payload.sessionId = sessionId;
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
this.pending.set(id, { resolve, reject, method });
|
|
83
|
+
this.ws.send(JSON.stringify(payload));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Subscribe to an event. Pass sessionId, or "*" to match any session. */
|
|
87
|
+
on(method, handler, sessionId = "*") {
|
|
88
|
+
const key = `${sessionId}:${method}`;
|
|
89
|
+
let set = this.handlers.get(key);
|
|
90
|
+
if (!set)
|
|
91
|
+
this.handlers.set(key, (set = new Set()));
|
|
92
|
+
set.add(handler);
|
|
93
|
+
return () => set.delete(handler);
|
|
94
|
+
}
|
|
95
|
+
/** Resolve once an event fires (with optional predicate / timeout). */
|
|
96
|
+
once(method, opts = {}) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const off = this.on(method, (p) => {
|
|
99
|
+
if (opts.predicate && !opts.predicate(p))
|
|
100
|
+
return;
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
off();
|
|
103
|
+
resolve(p);
|
|
104
|
+
}, opts.sessionId ?? "*");
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
off();
|
|
107
|
+
reject(new Error(`Timed out waiting for ${method}`));
|
|
108
|
+
}, opts.timeout ?? 30000);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** Remove all handlers for a specific sessionId. Called on page close to prevent accumulation. */
|
|
112
|
+
clearHandlers(sessionId) {
|
|
113
|
+
// Remove all handlers keyed with this sessionId
|
|
114
|
+
const prefix = `${sessionId}:`;
|
|
115
|
+
for (const key of this.handlers.keys()) {
|
|
116
|
+
if (key.startsWith(prefix)) {
|
|
117
|
+
this.handlers.delete(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
close() {
|
|
122
|
+
this.closed = true;
|
|
123
|
+
try {
|
|
124
|
+
this.ws.close();
|
|
125
|
+
}
|
|
126
|
+
catch { }
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/human.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-like input timing.
|
|
3
|
+
*
|
|
4
|
+
* Robotic automation moves the mouse in straight teleports and types at a
|
|
5
|
+
* perfectly fixed cadence. Behavioural-fingerprinting scripts (Akamai, PerimeterX,
|
|
6
|
+
* Datadome) watch for exactly that. We move along a curved path with eased,
|
|
7
|
+
* jittered timing and vary keystroke intervals around human norms.
|
|
8
|
+
*
|
|
9
|
+
* Determinism note: we draw randomness from a seedable PRNG so runs can be made
|
|
10
|
+
* reproducible for tests, while still being non-uniform on the wire.
|
|
11
|
+
*/
|
|
12
|
+
export declare class Rng {
|
|
13
|
+
private s;
|
|
14
|
+
constructor(seed?: number);
|
|
15
|
+
next(): number;
|
|
16
|
+
range(min: number, max: number): number;
|
|
17
|
+
int(min: number, max: number): number;
|
|
18
|
+
}
|
|
19
|
+
export interface Point {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Sample a curved path from `from` to `to` using a quadratic Bézier whose control
|
|
25
|
+
* point is offset perpendicular to the travel direction — the gentle arc a real
|
|
26
|
+
* hand makes. Returns ~steps points with eased spacing (slow-fast-slow).
|
|
27
|
+
*/
|
|
28
|
+
export declare function mousePath(from: Point, to: Point, rng: Rng): Point[];
|
|
29
|
+
/** Per-step delay (ms) for mouse moves — short, slightly jittered. */
|
|
30
|
+
export declare function moveDelay(rng: Rng): number;
|
|
31
|
+
/** Per-character delay (ms) for typing — human burst-and-pause cadence. */
|
|
32
|
+
export declare function keyDelay(rng: Rng, char: string): number;
|
|
33
|
+
export declare const sleep: (ms: number) => Promise<unknown>;
|
package/dist/human.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-like input timing.
|
|
3
|
+
*
|
|
4
|
+
* Robotic automation moves the mouse in straight teleports and types at a
|
|
5
|
+
* perfectly fixed cadence. Behavioural-fingerprinting scripts (Akamai, PerimeterX,
|
|
6
|
+
* Datadome) watch for exactly that. We move along a curved path with eased,
|
|
7
|
+
* jittered timing and vary keystroke intervals around human norms.
|
|
8
|
+
*
|
|
9
|
+
* Determinism note: we draw randomness from a seedable PRNG so runs can be made
|
|
10
|
+
* reproducible for tests, while still being non-uniform on the wire.
|
|
11
|
+
*/
|
|
12
|
+
export class Rng {
|
|
13
|
+
s;
|
|
14
|
+
constructor(seed = (Date.now() ^ (process.pid << 16)) >>> 0) {
|
|
15
|
+
this.s = seed >>> 0 || 1;
|
|
16
|
+
}
|
|
17
|
+
next() {
|
|
18
|
+
// xorshift32
|
|
19
|
+
let x = this.s;
|
|
20
|
+
x ^= x << 13;
|
|
21
|
+
x ^= x >>> 17;
|
|
22
|
+
x ^= x << 5;
|
|
23
|
+
this.s = x >>> 0;
|
|
24
|
+
return this.s / 0xffffffff;
|
|
25
|
+
}
|
|
26
|
+
range(min, max) {
|
|
27
|
+
return min + this.next() * (max - min);
|
|
28
|
+
}
|
|
29
|
+
int(min, max) {
|
|
30
|
+
return Math.floor(this.range(min, max + 1));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sample a curved path from `from` to `to` using a quadratic Bézier whose control
|
|
35
|
+
* point is offset perpendicular to the travel direction — the gentle arc a real
|
|
36
|
+
* hand makes. Returns ~steps points with eased spacing (slow-fast-slow).
|
|
37
|
+
*/
|
|
38
|
+
export function mousePath(from, to, rng) {
|
|
39
|
+
const dx = to.x - from.x;
|
|
40
|
+
const dy = to.y - from.y;
|
|
41
|
+
const dist = Math.hypot(dx, dy);
|
|
42
|
+
const steps = Math.max(8, Math.min(40, Math.round(dist / 12)));
|
|
43
|
+
// Perpendicular control-point offset, scaled to distance, signed randomly.
|
|
44
|
+
const mag = Math.min(dist * 0.18, 60) * (rng.next() < 0.5 ? -1 : 1) * rng.range(0.4, 1);
|
|
45
|
+
const mx = (from.x + to.x) / 2 + (-dy / (dist || 1)) * mag;
|
|
46
|
+
const my = (from.y + to.y) / 2 + (dx / (dist || 1)) * mag;
|
|
47
|
+
const pts = [];
|
|
48
|
+
for (let i = 1; i <= steps; i++) {
|
|
49
|
+
let t = i / steps;
|
|
50
|
+
// ease-in-out so the cursor accelerates then settles
|
|
51
|
+
t = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
52
|
+
const u = 1 - t;
|
|
53
|
+
const x = u * u * from.x + 2 * u * t * mx + t * t * to.x;
|
|
54
|
+
const y = u * u * from.y + 2 * u * t * my + t * t * to.y;
|
|
55
|
+
// sub-pixel jitter
|
|
56
|
+
pts.push({ x: x + rng.range(-0.6, 0.6), y: y + rng.range(-0.6, 0.6) });
|
|
57
|
+
}
|
|
58
|
+
pts[pts.length - 1] = { x: to.x, y: to.y }; // land exactly on target
|
|
59
|
+
return pts;
|
|
60
|
+
}
|
|
61
|
+
/** Per-step delay (ms) for mouse moves — short, slightly jittered. */
|
|
62
|
+
export function moveDelay(rng) {
|
|
63
|
+
return rng.range(4, 12);
|
|
64
|
+
}
|
|
65
|
+
/** Per-character delay (ms) for typing — human burst-and-pause cadence. */
|
|
66
|
+
export function keyDelay(rng, char) {
|
|
67
|
+
if (char === " ")
|
|
68
|
+
return rng.range(60, 140);
|
|
69
|
+
if (/[.,!?]/.test(char))
|
|
70
|
+
return rng.range(120, 260); // micro-pause at punctuation
|
|
71
|
+
return rng.range(45, 130);
|
|
72
|
+
}
|
|
73
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Browser } from "./browser.js";
|
|
2
|
+
export { Page, isPrivateHost, type Snapshot, type Element, type FedCmAccount, type FedCmDialog } from "./page.js";
|
|
3
|
+
export { launchChrome, findChrome, type LaunchOptions } from "./launcher.js";
|
|
4
|
+
export { CDP } from "./cdp.js";
|
|
5
|
+
export { STEALTH_SOURCE } from "./stealth.js";
|
|
6
|
+
export { Rng, mousePath } from "./human.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Browser } from "./browser.js";
|
|
2
|
+
export { Page, isPrivateHost } from "./page.js";
|
|
3
|
+
export { launchChrome, findChrome } from "./launcher.js";
|
|
4
|
+
export { CDP } from "./cdp.js";
|
|
5
|
+
export { STEALTH_SOURCE } from "./stealth.js";
|
|
6
|
+
export { Rng, mousePath } from "./human.js";
|