Compare commits

29 Commits

Author SHA1 Message Date
AskaEth 36780493a5 docs: update user-facing docs with real test results from baidu.com verification 2026-08-12 21:42:41 +08:00
AskaEth 9ec6c9add3 fix: guard auto-start server to only run when executed directly, not when imported by tests 2026-08-12 21:30:35 +08:00
AskaEth 452581c3b5 feat: add GET /profiles route 2026-08-12 21:29:47 +08:00
AskaEth e79590a6d8 feat(daemon): add WebSocket relay with console/network monitoring
- Add ws-relay.ts: WebSocket server at /ws with broadcast function
- Add network event types (request/response/failed) to core ws.ts types
- Add ConsoleEntry and NetworkEntry types
- Add consoleLog and networkLog buffers to PageRegistry (100/200 max)
- Add console listener in createPage: broadcasts page:console events
- Add network listeners (request/response/requestfailed) in createPage
- Add page crash handler broadcasting page:crashed events
- Add history endpoints: GET /pages/:id/console and GET /pages/:id/network
- Integrate createWsRelay in server.ts startup
- Bump ws from devDependency to full dependency
2026-08-12 21:28:02 +08:00
AskaEth b843eea9ae feat(cli): implement CLI entry point with auto-daemon launcher (Task 18)
- Add auto-daemon.ts: ensureDaemonRunning() checks health, reads pidfile,
  spawns daemon if needed, polls health up to 3s
- Rewrite index.ts: commander-based CLI with all subcommands (health,
  open, list, get, kill, navigate, click, type, scroll, eval, wait,
  screenshot, text, html, profiles)
- Add daemon project reference to cli tsconfig
2026-08-12 21:24:07 +08:00
AskaEth 4f387b5ae7 feat(daemon): add stealth injection orchestrator (Task 17)
- Create stealth/index.ts: applyStealth() calls all 6 injection modules
  in correct order (initScript before nav, page.route after page)
- Update browser-manager.ts: call applyStealth in createPage before goto
- Constructor now accepts profileId (default 'desktop-chrome') or profile object
- Update daemon src/index.ts: re-export stealth orchestrator, getProfile,
  humanClick, humanType, humanScroll, injectHeaderStealth
2026-08-12 21:19:35 +08:00
AskaEth a34e3c72f0 feat(daemon): add built-in fingerprint profiles for desktop Chrome (Task 16) 2026-08-12 21:18:19 +08:00
AskaEth 78a35bafc4 feat(daemon): add HTTP header stealth injection (Task 14)
Inject Sec-CH-UA family headers on outgoing page requests:
- sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile
- sec-ch-ua-arch (from process.arch)
- sec-ch-ua-bitness ('64')
- sec-ch-ua-full-version (extracted from profile.userAgent)

Skips localhost/127.0.0.1 traffic (daemon internal) and WebSocket upgrades.
2026-08-12 21:17:06 +08:00
AskaEth 6085c9e65a feat(daemon): implement stealth human input simulation (Task 15)
Add humanClick, humanType, humanScroll with mouse path interpolation,
random key delays, and step-based scrolling for bot-like behavior.
Includes integration tests for event sequence verification.
2026-08-12 21:16:37 +08:00
AskaEth dff7afced9 fix: wrap stealth navigator overrides in try-catch, add pixelRatio to screen override 2026-08-12 21:14:16 +08:00
AskaEth 1b2ff5491a feat(daemon): add canvas/WebGL/AudioContext noise injection for stealth (Task 13)
- Implement injectCanvasNoise() using context.addInitScript()
- Add deterministic hash-based pixel/byte noise for Canvas 2D:
  - Patch toDataURL, toBlob with save-modify-restore pattern
  - Patch getImageData to add +/-1 to random pixel RGB channels
- Add WebGL readPixels noise for both WebGL and WebGL2 contexts
- Add AudioContext noise: detune oscillator, patch AnalyserNode methods
- Session-based seed ensures consistent noise within same context
- Write integration tests covering canvas, WebGL, audio, toBlob, and edge cases
2026-08-12 21:13:30 +08:00
AskaEth f0fa14f898 feat: add stealth modules for chrome runtime, screen, and permissions (Task 12)
- chrome-runtime.ts: injects window.chrome object with runtime, loadTimes, csi, app
- screen.ts: overrides screen.width/height/avail/colorDepth, window.outerWidth/Height
- permissions.ts: hooks navigator.permissions.query() for 4 permission types
- tests: 15 integration tests (6 chrome, 6 screen, 5 permissions), skip when VISIONL_INTEGRATION != '1'
- typecheck: passed (0 errors)
2026-08-12 21:08:13 +08:00
AskaEth 5cef8cdbc4 feat(daemon): add stealth navigator property injection (Task 11) 2026-08-12 21:04:49 +08:00
AskaEth 2ee59182bb feat(daemon): implement action routes and cookie management (Task 10)
- Add action endpoints: click/type/scroll/eval/wait/navigate
- Add cookie endpoints: GET/POST/DELETE /pages/:id/cookies
- Register actionRoutes in server.ts
- 23 integration tests with mock BrowserManager
2026-08-12 21:02:46 +08:00
AskaEth 55fc75eb54 feat(daemon): implement content routes for screenshot/text/html retrieval (Task 9)
- Add contentRoutes handler factory in routes/content.ts
- GET /pages/:id/screenshot returns PNG as base64
- GET /pages/:id/text returns document.body.innerText
- GET /pages/:id/html returns document.documentElement.outerHTML
- All use safeStringify, 404 on PAGE_NOT_FOUND
- Register contentRoutes in server.ts
- Add 8 integration tests with mock BrowserManager
2026-08-12 20:58:37 +08:00
AskaEth dd79194179 fix: add PageRegistry.clear() and getAll(), fix BrowserManager.cleanup() to clear registry 2026-08-12 20:56:16 +08:00
AskaEth ac503a5ca3 feat(daemon): implement page CRUD routes (Task 8)
- Add routes/pages.ts with pageRoutes handler for POST/GET/DELETE /pages
- Handle POST /pages (create), GET /pages (list), GET /pages/:id, DELETE /pages/:id
- Use safeStringify for all JSON output, proper error codes for 400/404/409/500
- Update server.ts to accept optional BrowserManager and register pageRoutes
- Add 13 unit tests with mock BrowserManager covering all endpoints
2026-08-12 20:55:36 +08:00
AskaEth a8fb781a7c feat: add browser manager with page registry and Playwright stealth integration
- PageRegistry: thread-safe in-memory registry for tracking active pages by ID and alias
- BrowserManager: Playwright browser lifecycle with stealth plugin, context creation, and page navigation
- 8 unit tests for PageRegistry (add, get, findByIdOrAlias, hasAlias, remove, list)
- 8 integration tests for BrowserManager (skipped by default, guard: VISIONL_INTEGRATION=1)
- Fix: PageInfo import in core/client.ts (was incorrectly imported from api.js instead of page.js)
2026-08-12 20:51:08 +08:00
AskaEth 513da621c6 feat: add pidfile management for daemon process tracking 2026-08-12 20:47:34 +08:00
AskaEth 7c70063d95 docs: set license to Apache-2.0 2026-08-12 20:45:57 +08:00
AskaEth 19d67cfda9 feat: add daemon server skeleton with health check endpoint 2026-08-12 20:45:25 +08:00
AskaEth 3810661098 feat: add VisionLClient HTTP client for daemon communication 2026-08-12 20:43:53 +08:00
AskaEth 8b83fb5c53 feat: add JSON escape utility with comprehensive edge case tests 2026-08-12 20:41:12 +08:00
AskaEth e3fe330745 feat: add core type definitions (PageInfo, ApiResponse, WsEvent, FingerprintProfile) 2026-08-12 20:38:34 +08:00
AskaEth 01701e461a fix: align daemon package.json main field with source placeholder 2026-08-12 20:37:26 +08:00
AskaEth 1392357dc1 chore: initialize monorepo scaffold with npm workspaces 2026-08-12 20:36:12 +08:00
AskaEth f5791906e2 docs: add v1 implementation plan (25 tasks) 2026-08-12 20:32:07 +08:00
AskaEth 985860b2a2 docs: 将反检测作为核心设计原则,全面更新架构和文档
- architecture.md: 新增反检测为核心第一性原理,新增 §3 反检测设计(13 个指纹维度全覆盖)
- anti-detection.md: 新增反检测专项文档(14 节,详尽列举检测方式和应对策略)
- api.md: POST /pages 新增 profile 参数,新增 GET /profiles 端点
- cli.md: 新增 --profile 全局选项,新增 visionl profiles 命令
- contributing.md: 新增反检测开发规范、检测站点验证要求
- README.md: 更新项目定位和特性描述
2026-08-12 20:21:53 +08:00
AskaEth 0c437a96f1 docs: 项目架构设计文档(中文)及使用指南
- architecture.md: 完整架构设计(技术栈、API、CLI、生命周期、测试策略)
- api.md: REST + WebSocket 接口文档
- cli.md: CLI 命令参考
- contributing.md: 开发规范与 JSON 转义要求
- quickstart.md: 快速开始指南
- llm-integration.md: 智能体集成指南
- examples.md: 8 个典型使用场景
2026-08-12 20:14:54 +08:00
67 changed files with 11379 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Dependencies
node_modules/
# Build output
dist/
*.tsbuildinfo
# Runtime state
.visionl/
# Environment
.env
.env.local
# Logs
*.log
npm-debug.log*
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test artifacts
coverage/
test-results/
+52
View File
@@ -1,2 +1,54 @@
# VisionL
面向 AI 智能体的开源可持久化浏览器 —— **让 LLM 访问页面不被简单人机验证拦截**
LLM 通过 `VisionL-cli` 工具调用操控网页。内置多层反检测机制,覆盖 Navigator 属性、
Canvas/WebGL/Audio 指纹、HTTP 请求头、屏幕视口、权限状态、鼠标键盘行为等全部维度,
使自动化访问尽可能不被 Cloudflare、Akamai 等反爬服务识别。
## 特性
- **反检测优先**:所有可被页面 JS 读取的浏览器特征均有模拟值,不留自动化痕迹
- **指纹模版化**:内置多套指纹配置(桌面 Chrome/Win/Mac),智能体可按需选择
- **智能体优先**:CLI 子命令专为 LLM 工具调用设计,输出结构化 JSON
- **页面持久化**:页面存活不受客户端断连影响
- **全功能自动化**:点击、输入、滚动、截图、JS 执行
## 文档
| 文档 | 说明 |
|------|------|
| [快速开始](docs/usage/quickstart.md) | 安装和基本使用 |
| [LLM 集成](docs/usage/llm-integration.md) | 在智能体中集成 VisionL |
| [使用示例](docs/usage/examples.md) | 典型场景 |
| [架构设计](docs/development/architecture.md) | 整体架构 + 反检测设计 |
| [反检测设计](docs/development/anti-detection.md) | 指纹覆盖详情、绕过原理 |
| [API 文档](docs/development/api.md) | REST + WebSocket 接口 |
| [CLI 命令](docs/development/cli.md) | 命令参考 |
| [开发规范](docs/development/contributing.md) | 贡献指南 + 反检测开发要求 |
## 技术栈
TypeScript + Node.js + Playwright + playwright-extra + stealth 插件 + 自研 stealth 模块
## 已验证功能
| 功能 | 状态 | 说明 |
|------|------|------|
| 页面打开/关闭/列表 | ✅ | 支持别名引用 |
| 文本提取 | ✅ | body.innerText |
| HTML 提取 | ✅ | documentElement.outerHTML |
| 截图 | ✅ | PNG base64 输出 |
| JS 执行 | ✅ | 任意代码 eval |
| 点击/输入/滚动 | ✅ | CSS 选择器定位 |
| 等待 | ✅ | 选择器或毫秒 |
| Cookie 管理 | ✅ | 查看/设置/删除 |
| 网络请求监控 | ✅ | 实时请求/响应日志 |
| 控制台监控 | ✅ | 实时 console 输出 |
| 指纹模版切换 | ✅ | 3 套内置配置 |
| WebSocket 实时推送 | ✅ | 页面事件广播 |
| 反检测 (navigator/screen/canvas/headers) | ✅ | 7 个 stealth 模块 |
## License
Apache-2.0
+412
View File
@@ -0,0 +1,412 @@
# VisionL 反检测设计(专项)
> 本文档详尽列举页面/后端可检测的自动化痕迹及 VisionL 的应对策略。
## 核心原则
**真实浏览器有的,VisionL 必须有。真实浏览器没有的,VisionL 不能有。**
检查方法:在一个真实的桌面 Chrome 浏览器控制台中执行以下探测脚本,
记录返回结果。VisionL 中打开同一个页面执行相同脚本,结果必须一致(或在统计上不可区分)。
---
## 1. WebDriver 检测
### 检测方式
```javascript
navigator.webdriver
// 裸 Playwright: true
// 真实 Chrome: false 或 undefined
```
这是最直接、最致命的自动化暴露点。几乎所有反爬服务都会首先检查此属性。
### 应对
使用 `puppeteer-extra-plugin-stealth` 在页面加载前覆盖此属性为 `false`
**验证方法**:打开 bot.sannysoft.comWebDriver 行应为绿色。
---
## 2. Navigator 属性检测
### 2.1 plugins
```javascript
navigator.plugins
// 裸 Playwright: PluginArray { length: 0 }
// 真实 Chrome: PluginArray { 0: Plugin, 1: Plugin, 2: Plugin, ... length: 5 }
```
真实 Chrome 内置 5 个插件:
- Chrome PDF Plugin
- Chrome PDF Viewer
- Native Client(已弃用但仍存在)
### 2.2 languages
```javascript
navigator.languages
// 裸 Playwright: ["en-US"]
// 真实中文系统: ["zh-CN", "en", "en-US"]
```
### 2.3 platform
```javascript
navigator.platform
// Linux: "Linux x86_64"
// Windows: "Win32"
// macOS: "MacIntel"
```
### 2.4 hardwareConcurrency / deviceMemory
```javascript
navigator.hardwareConcurrency // 实际 CPU 核数
navigator.deviceMemory // 实际内存(GB 整数),如 4、8
```
这两个值保留真实值即可,多样化反而是优势。
### 2.5 maxTouchPoints
```javascript
navigator.maxTouchPoints // 桌面: 0,触屏设备: 1-10
```
### 2.6 connection
```javascript
navigator.connection
// { downlink: 10, effectiveType: "4g", rtt: 50, saveData: false }
```
Headless Chrome 中此属性为 `undefined`。需要注入。
### 2.7 vendor / product / productSub
```javascript
navigator.vendor // "Google Inc."
navigator.product // "Gecko"
navigator.productSub // "20030107"
```
---
## 3. Chrome 特有对象
### 3.1 window.chrome
```javascript
typeof window.chrome
// 裸 headless: "undefined"
// 真实 Chrome: "object"
window.chrome.runtime
// headless 没有这个对象
```
真实 Chrome 的 `window.chrome` 包含以下属性:
- `app`
- `csi`
- `loadTimes`
- `runtime`
### 3.2 navigator.brave 和 navigator.permissions.query('brave')
检测 Brave 浏览器的特有 API。VisionL 不应注入这些。
---
## 4. 屏幕与视口
### 4.1 尺寸层级关系
真实浏览器的尺寸遵循严格层级:
```
screen.width >= screen.availWidth >= window.outerWidth > window.innerWidth >= viewport
```
不是所有值都相等。反爬服务会检查:
```javascript
const checks = [
screen.width, // 总屏幕宽度
screen.availWidth, // 可用区域(扣除任务栏)
window.outerWidth, // 窗口外边(含边框和 DevTools)
window.innerWidth, // 窗口内边(含滚动条)
document.documentElement.clientWidth, // 视口宽度
];
```
### 4.2 devicePixelRatio
现代设备多为 2(Retina)或 1~3。桌面默认 1 即可。
### 4.3 colorDepth / pixelDepth
桌面始终为 `24`
---
## 5. Canvas 指纹
### 检测原理
```javascript
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 50;
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.fillText('Hello, VisionL!', 2, 15);
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
ctx.fillText('Hello, VisionL!', 4, 17);
const hash = canvas.toDataURL();
// 不同 GPU/驱动/OS 的 hash 有微小差异
```
### 策略
`toDataURL()` / `getImageData()` / `toBlob()` 等输出时,在像素末尾加入
±1 的 RGB 随机扰动。扰动基于页面上下文种子,同页面的扰动一致,跨页面不同。
**噪声强度**:默认 0.3(0-1 刻度)。0.3 意味着约 30% 的像素有 ±1 扰动。
### 一致性检查
某些反爬服务会连续两次获取 canvas 指纹并比较。VisionL 确保同页面内两次调用
`toDataURL()` 返回相同结果(基于固定种子),但不同页面返回不同结果。
---
## 6. WebGL 指纹
### 检测原理
```javascript
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
// GPU 信息
gl.getParameter(gl.UNMASKED_VENDOR_WEBGL); // "Google Inc. (Intel)" 等
gl.getParameter(gl.UNMASKED_RENDERER_WEBGL); // "ANGLE (Intel, Mesa Intel(R) UHD Graphics..."
// 渲染测试
// 类似 Canvas 指纹,在 3D 场景中绘制并获取像素值
```
### 策略
- `UNMASKED_VENDOR_WEBGL``UNMASKED_RENDERER_WEBGL` 保留真值或使用模版值
-`readPixels()` 加入微量噪声
- 其余 `getParameter()` 调用返回真实值
---
## 7. AudioContext 指纹
### 检测原理
```javascript
const ctx = new AudioContext();
const oscillator = ctx.createOscillator();
const analyser = ctx.createAnalyser();
const gain = ctx.createGain();
// ... 连接并处理音频
const array = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(array);
// 不同设备的浮点精度有微小差异
```
### 策略
`getFloatFrequencyData()` / `getByteFrequencyData()` / `getFloatTimeDomainData()`
等输出中,在显著低于信号水平的量级上加入随机噪声(约 -100dB)。人耳听不到,
但足以使音频指纹每次不同。
---
## 8. 字体检测
### 检测方式
浏览器没有直接枚举系统字体的 API,但页面可以通过以下方式探测:
```javascript
document.fonts.ready.then(() => {
document.fonts.forEach(f => console.log(f.family));
});
```
或测量固定文本在不同字体下的宽度。
### 策略
V1 不做专门的字体列表注入。基础的中英文字体(Arial、sans-serif、serif、monospace 等)
保持一致即可。
---
## 9. 时间精度
### 检测方式
```javascript
performance.now() // 高精度时间
Date.now() // Unix 时间戳
```
某些反爬检测会测量代码执行时间,自动化工具(如通过 CDP 注入脚本)可能在时间线上
留下异常模式。
### 策略
V1 不干扰时间 API。但确保 `performance.now()` 的精度受浏览器控制(通常微秒级),
且没有人为的时间偏移。
---
## 10. HTTP 请求头
### 检测标头
| 标头 | 裸 Playwright | 真实 Chrome | VisionL |
|------|-------------|-----------|---------|
| `User-Agent` | 含 HeadlessChrome | `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36` | 模拟真实 UA |
| `Accept-Language` | `en-US` | `zh-CN,zh;q=0.9,en;q=0.8` | 可配置 |
| `Sec-CH-UA` | 不完整 | `"Chromium";v="132", "Google Chrome";v="132", "Not?A_Brand";v="99"` | 完整注入 |
| `Sec-CH-UA-Platform` | 缺失 | `"Linux"` | 注入 |
| `Sec-CH-UA-Mobile` | 缺失 | `?0` | 注入 |
| `sec-ch-ua-arch` | 缺失 | `"arm"``"x86"` | 注入 |
| `sec-ch-ua-bitness` | 缺失 | `"64"` | 注入 |
| `sec-ch-ua-full-version` | 缺失 | 完整版本号 | 注入 |
| `sec-ch-ua-platform-version` | 缺失 | OS 版本 | 注入 |
### 策略
- UA 和 Accept-Language 通过 Playwright Context 配置设置
- `Sec-CH-UA-*` 系列通过 `page.route()` 拦截请求并修改标头
---
## 11. 权限状态
### 检测方式
```javascript
const status = await navigator.permissions.query({ name: 'notifications' });
// { state: "prompt" | "granted" | "denied" }
navigator.permissions.query({ name: 'geolocation' });
navigator.permissions.query({ name: 'camera' });
navigator.permissions.query({ name: 'microphone' });
```
裸 Playwright 对这些权限查询返回 `prompt` 状态,这是正常的默认行为。
### 策略
通过 Playwright Context 权限 API 预设权限状态,匹配指纹模版。
---
## 12. 行为模拟
### 12.1 鼠标移动
**裸 API 问题**Playwright 的 `click` 直接跳转到目标元素中心,无中间 `mousemove` 事件。
**检测**:某些页面监听 `mousemove` 事件,检测鼠标在点击前是否有移动轨迹。
**策略**
```
鼠标路径:当前位置 → 目标中心 + 随机抖动(±5px)
Bezier 插值:起点 → 控制点1(偏右下) → 控制点2(偏左上) → 终点
速度分布:先加速后减速(模拟 Fitts 定律)
抖动:路径上每 10ms 加入 ±2px 随机偏移
```
V1 实现简化版(线性 + 抖动),V2 升级为贝塞尔曲线。
### 12.2 键盘输入
**裸 API 问题**Playwright 的 `type` 瞬间输入所有字符,无时间间隔。
**检测**:某些页面计算 `keydown``keyup` 之间的时间间隔,检测异常输入速度。
**策略**
```
逐字符输入:keydown → (10ms) → keypress → (10ms) → keyup
字符间隔:50-150ms 随机
标点/回车:相比字母略长(+20ms)
中文输入法:V1 不模拟,使用 paste 或逐个字符注入
```
### 12.3 滚动
**裸 API 问题**:瞬间跳转到目标位置。
**策略**
```
分段滚动:每次 50-200px(随机),间隔 10-30ms
缓动:先快后慢
```
---
## 13. 检测站点
### 13.1 bot.sannysoft.com
检测项:navigator.webdriver、plugins、languages、chrome、permissions、canvas、webgl、fonts、screen resolution 等。
**目标:所有测试项绿色通过**
### 13.2 abrahamjuliot.github.io/creepjs
检测项:每个浏览器指纹维度逐一打分(0%-100% 异常分数)。
异常分数含义:
- 0-30%:正常范围,不同设备的自然差异
- 30-70%:可疑,但某些配置可能触发
- 70-100%:自动化工具明确痕迹
**目标:所有维度 ≤ 30% 异常分数**
### 13.3 fingerprint.com/demo
综合指纹服务,给出置信度评分。
**目标:被识别为正常浏览器(非 bot)**
---
## 14. 性能开销
反检测模块不应显著影响性能:
| 模块 | 开销 | 说明 |
|------|------|------|
| stealth 插件 (playwright-extra) | ~0ms | 页面加载前注入,无后续开销 |
| canvas 噪声 | 每帧 0-1ms | 仅在截图/toDataURL 时触发 |
| audio 噪声 | 每次调用 0-1ms | 仅在音频 API 调用时触发 |
| 鼠标路径计算 | 每次点击 0-2ms | 纯 JS 数学计算 |
| HTTP 头拦截 | 每请求 0-1ms | page.route 拦截 |
| 页面初始注入脚本 | 页面加载时 10-50ms | evalOnNewDocument,一次性 |
总体而言,页面加载时增加 10-50ms,运行时交互延迟增加 0-5ms,对用户体验和 LLM 交互
无感知影响。
+367
View File
@@ -0,0 +1,367 @@
# VisionL REST API 接口文档
> 适用版本:v1
## 通用约定
### Base URL
```
http://127.0.0.1:9527
```
端口可通过启动参数 `--port` 修改。
### 响应格式
所有响应统一为 JSON
```json
// 成功
{
"ok": true,
"data": { ... }
}
// 失败
{
"ok": false,
"error": {
"code": "PAGE_NOT_FOUND",
"message": "页面 p_xyz 不存在"
}
}
```
### 错误码
| 错误码 | HTTP 状态码 | 说明 |
|--------|-----------|------|
| `PAGE_NOT_FOUND` | 404 | 指定页面不存在 |
| `DAEMON_UNREACHABLE` | 502 | daemon 不可达(CLI 端产生) |
| `INVALID_URL` | 400 | URL 格式不合法 |
| `ALIAS_EXISTS` | 409 | 别名已被使用 |
| `TIMEOUT` | 408 | 操作超时 |
| `INTERNAL` | 500 | 内部错误 |
### JSON 转义
所有响应通过 `JSON.stringify` 序列化。页面内容(如 `text``html` 字段)中的
引号、反斜杠、控制字符均被正确转义,外层 JSON 始终合法。
---
## 页面管理
### 打开页面
```
POST /pages
```
**请求体:**
```json
{
"url": "https://example.com",
"alias": "demo", // 可选
"profile": "desktop-chrome" // 可选,指纹配置模版 ID,默认 "desktop-chrome"
}
```
**响应:**
```json
{
"ok": true,
"data": {
"id": "p_a1b2c3d4",
"url": "https://example.com",
"alias": "demo",
"title": "Example Domain",
"status": "active",
"profile": "desktop-chrome"
}
}
```
### 列出所有页面
```
GET /pages
```
**响应:**
```json
{
"ok": true,
"data": [
{
"id": "p_a1b2c3d4",
"url": "https://example.com",
"alias": "demo",
"title": "Example Domain",
"status": "active"
}
]
}
```
### 页面详情
```
GET /pages/:id
```
**响应:** 同上 `data` 中的单个对象。
### 杀死页面
```
DELETE /pages/:id
```
**响应:**
```json
{ "ok": true, "data": null }
```
---
## 页面操作
### 跳转
```
POST /pages/:id/navigate
```
**请求体:**
```json
{ "url": "https://new-url.com" }
```
**响应:**
```json
{
"ok": true,
"data": {
"url": "https://new-url.com",
"title": "New Page"
}
}
```
### 点击元素
```
POST /pages/:id/click
```
**请求体:**
```json
{ "selector": "#login-button" }
```
**响应:**
```json
{ "ok": true, "data": { "success": true } }
```
### 输入文本
```
POST /pages/:id/type
```
**请求体:**
```json
{
"selector": "#username",
"text": "hello world"
}
```
**响应:** 同 click。
### 滚动
```
POST /pages/:id/scroll
```
**请求体(至少提供一个):**
```json
{
"deltaY": 500,
"toBottom": true
}
```
**响应:** 同 click。
### 执行 JavaScript
```
POST /pages/:id/eval
```
**请求体:**
```json
{
"code": "document.title"
}
```
**响应:**
```json
{
"ok": true,
"data": {
"result": "Example Domain"
}
}
```
> 注意:`result` 类型取决于 JS 代码返回值,可以是 string、number、boolean、object 或 null。
### 等待条件
```
POST /pages/:id/wait
```
**请求体(至少提供一个):**
```json
{
"selector": ".loaded",
"ms": 2000
}
```
**响应:** 同 click。
---
## 内容获取
### 截图
```
GET /pages/:id/screenshot
```
**响应:**
```json
{
"ok": true,
"data": {
"base64": "iVBORw0KGgoAAAANS...",
"mime": "image/png"
}
}
```
### 纯文本
```
GET /pages/:id/text
```
**响应:**
```json
{
"ok": true,
"data": {
"text": "页面正文内容..."
}
}
```
### HTML
```
GET /pages/:id/html
```
**响应:**
```json
{
"ok": true,
"data": {
"html": "<!DOCTYPE html>..."
}
}
```
---
## Daemon 管理
### 指纹配置列表
```
GET /profiles
```
**响应:**
```json
{
"ok": true,
"data": [
{ "id": "desktop-chrome", "name": "桌面 Chrome (通用)", "platform": "Linux x86_64" },
{ "id": "desktop-windows", "name": "Windows 10 Chrome", "platform": "Windows NT 10.0" },
{ "id": "desktop-mac", "name": "macOS Chrome", "platform": "Macintosh" }
]
}
```
### 健康检查
```
GET /health
```
**响应:**
```json
{ "status": "ok" }
```
---
## WebSocket 接口
### 连接
```
ws://127.0.0.1:9527/ws
```
### 事件类型
所有事件 JSON 格式:`{ "type": "...", "data": { ... } }`
| 事件 | 方向 | 说明 |
|------|------|------|
| `page:created` | daemon → 客户端 | 新页面打开 |
| `page:closed` | daemon → 客户端 | 页面被杀死 |
| `page:navigated` | daemon → 客户端 | 页面 URL 发生变化 |
| `page:crashed` | daemon → 客户端 | Playwright 页面崩溃 |
| `page:console` | daemon → 客户端 | 页面控制台输出(调试) |
| `page:detection:warning` | daemon → 客户端 | 页面可能检测到自动化特征 |
+524
View File
@@ -0,0 +1,524 @@
# VisionL 架构设计
> 面向 AI 智能体的开源可持久化浏览器 — 架构设计文档
## 1. 概述
### 要解决的根本问题
**让 LLM 访问各类页面尽可能不被简单的人机验证所拦截。**
这是 VisionL 存在的第一性原理,所有设计决策必须围绕这个目标展开。
市面上绝大多数自动化浏览器(Playwright/Puppeteer 裸跑)会在数十个维度上暴露自动化痕迹,
被 Cloudflare、Akamai、DataDome 等反爬服务轻松识别。VisionL 的核心竞争力在于:
**GUI 浏览器所有能被页面/页面后端检测到的特征,在 CLI 中均有值(可以是模拟的)**
### 次要目标
- **页面持久化**:页面在客户端断连后依然存活,只有显式 `kill` 才能终止
- **智能体优先**:CLI 子命令为 LLM 工具调用而设计,输出结构化 JSON
- **本地优先**Daemon 仅监听 localhost,不对外开放
- **GUI 就绪**HTTP/WS API 同时服务于 CLI 和未来的 GUI
---
## 2. 技术栈
| 层级 | 选择 | 理由 |
|------|------|------|
| 浏览器引擎 | Playwright + Chromium | AI 浏览器自动化的事实标准 |
| 反检测框架 | `playwright-extra` + `puppeteer-extra-plugin-stealth` | 自动处理 WebDriver 标记和基础指纹 |
| 指纹补充 | 自研 stealth 模块 | stealth 插件只覆盖约 60% 的指纹,剩余需自行实现 |
| 语言 | TypeScript + Node.js | Playwright 原生语言 |
| 通信协议 | HTTP REST + WebSocket (localhost) | 职责分离,GUI 可复用 |
| 包管理 | npm workspaces (monorepo) | 共享类型 |
| CLI 框架 | commander + chalk | 轻量 |
| HTTP 服务 | 原生 `http` + `ws` 库 | 本地 daemon 无需重型框架 |
### JSON 转义处理
所有返回给智能体的 JSON 必须通过 `JSON.stringify` 序列化整个响应对象,
禁止手动拼接 JSON 字符串。页面内容字段中的引号、反斜杠、控制字符均正确处理。
---
## 3. 反检测设计(核心)
### 3.1 指纹覆盖全景
目标:**所有能通过 JS API 读取到的浏览器特征,都必须返回真实 GUI 浏览器的值,
不能留下任何自动化痕迹。**
以下表格列出页面/后端可检测的维度、Playwright 默认值的问题、以及 VisionL 的应对策略。
#### 3.1.1 Navigator 属性
| 属性 | 裸 Playwright 值 | 问题 | VisionL 策略 |
|------|-----------------|------|-------------|
| `navigator.webdriver` | `true` | **最致命的暴露** | stealth 插件 → `false` |
| `navigator.userAgent` | 含 "HeadlessChrome" | 直接暴露 | 模拟真实 Chrome UA,可配置 |
| `navigator.plugins` | 空数组 | 真实 Chrome 有 5 个插件 | stealth 插件 → 注入 PDF Viewer 等 |
| `navigator.languages` | `["en-US"]` | 需匹配目标地区 | 可配置,默认 `["zh-CN", "en-US"]` |
| `navigator.platform` | 随 OS | 无问题但需一致性 | 随系统,但确保与其他指纹一致 |
| `navigator.hardwareConcurrency` | 实际 CPU 核数 | OK | 保持真值 |
| `navigator.deviceMemory` | 实际值 | OK | 保持真值 |
| `navigator.maxTouchPoints` | `0` | 桌面无触摸 | 可配置,桌面默认 `0` |
| `navigator.vendor` | `"Google Inc."` | stealth 会修正 | stealth 插件处理 |
| `navigator.productSub` | `"20030107"` | 需一致 | stealth 插件处理 |
| `navigator.connection` | `undefined` (headless) | 真实 Chrome 有值 | 注入 NetworkInformation 对象 |
| `navigator.mediaDevices` | 存在 | 可能暴露空设备列表 | 模拟至少一个音频设备 |
#### 3.1.2 Chrome 特有属性
| 属性 | 裸 Playwright 值 | 问题 | VisionL 策略 |
|------|-----------------|------|-------------|
| `window.chrome` | `undefined` (headless) | 真实 Chrome 有此对象 | 注入完整 `window.chrome` 对象 |
| `chrome.runtime` | N/A | 检测自动化时常用 | 注入 mock runtime |
| `navigator.brave` | N/A | Brave 检测用,非必须 | 不注入(伪装 Chrome 不是 Brave |
#### 3.1.3 屏幕与视口
| 属性 | 裸 Playwright 值 | 问题 | VisionL 策略 |
|------|-----------------|------|-------------|
| `screen.width/height` | 默认 1280x720 | 非标准分辨率 | 可配置,默认 1920x1080 |
| `screen.availWidth/Height` | 同 screen | 任务栏高度需扣除 | 模拟,比 screen 小 40-80px |
| `screen.colorDepth` | `24` | OK | `24` |
| `screen.pixelDepth` | `24` | OK | `24` |
| `window.outerWidth/Height` | 与视口相同 | 真实浏览器窗口含边框 | 比视口大(含窗口装饰) |
| `window.innerWidth/Height` | 视口尺寸 | 需与 screen 逻辑一致 | 保证 inner < outer < screen |
| `window.devicePixelRatio` | `1` | 现代设备多为 2 | 可配置,默认 `1` 或自动检测 |
#### 3.1.4 Canvas 指纹
页面可以在 canvas 上渲染特定图形,获取像素哈希作为指纹。不同 GPU/驱动/OS 的渲染结果
有微小差异。VisionL 需要在 canvas 渲染中加入可控的随机噪声,使每次指纹不同但看起来
像正常设备,从而绕过基于 canvas 哈希的追踪。
| 措施 | 实现 |
|------|------|
| Canvas 2D 噪声 | `toDataURL()``getImageData()` 返回时在像素末尾添加 ±1 随机扰动 |
| WebGL 噪声 | `getParameter()``readPixels()` 添加类似扰动 |
| 一致性 | 同一页面会话内噪声种子固定,跨页面会话刷新 |
#### 3.1.5 WebGL 指纹
| 属性 | 裸 Playwright 值 | VisionL 策略 |
|------|-----------------|-------------|
| `UNMASKED_VENDOR_WEBGL` | 真实 GPU 厂商 | 可保留真值(多样化),也可统一模拟 |
| `UNMASKED_RENDERER_WEBGL` | 真实 GPU 型号 | 同上 |
| WebGL 渲染噪声 | 无 | 类似 Canvas,在 readPixels 加入微量噪声 |
#### 3.1.6 AudioContext 指纹
页面通过 AudioContext 处理音频信号提取指纹。VisionL 策略:
- `createOscillator()` 生成的波形在浮点精度末尾加入随机噪声
- `createAnalyser()``getByteFrequencyData()` 同样处理
- 噪声量级极小(-100dB 级别),不改变音频语义
#### 3.1.7 字体枚举
页面无法直接枚举系统字体列表,但可通过以下方式探测:
- 测量指定字体的文本宽度
- `document.fonts` API
- Flash(已消亡)
VisionL 默认不做字体列表注入(多数反爬不会测字体),但保持基础常见字体的一致性。
#### 3.1.8 HTTP 请求头
| 头部 | 裸 Playwright 值 | VisionL 策略 |
|------|-----------------|-------------|
| `User-Agent` | 含 HeadlessChrome | 模拟真实 Chrome UA,可配置 |
| `Accept-Language` | `en-US` | 匹配 `navigator.languages` |
| `Sec-CH-UA` | 不完整 | 补全 `"Chromium";v="xxx", "Google Chrome";v="xxx"` |
| `Sec-CH-UA-Platform` | 随 OS | 确保一致性 |
| `Sec-CH-UA-Mobile` | `?0` | `?0`(桌面) |
| `Accept` | OK | 保持默认 |
| `Accept-Encoding` | OK | 保持默认 |
| `Connection` | `keep-alive` | 保持默认 |
| `Upgrade-Insecure-Requests` | `1` | 保持默认 |
#### 3.1.9 权限状态
| 权限 | 裸 Playwright 值 | VisionL 策略 |
|------|-----------------|-------------|
| `notifications` | `prompt` | 可配置:`prompt`/`granted`/`denied` |
| `geolocation` | `prompt` | 可配置,`granted` 时提供模拟坐标 |
| `camera` | `prompt` | 可配置 |
| `microphone` | `prompt` | 可配置 |
| `midi` | `prompt` | 保持 `prompt` |
#### 3.1.10 行为模拟
**鼠标移动**v1 基础版,v2 增强):
- 点击前鼠标从当前位置线性移动到目标中心(带随机抖动)
- 移动速度加入随机变化(不恒定)
- `mousemove` 事件在移动路径上均匀发射
**键盘输入**v1 基础版):
- 每个字符间隔 `50-150ms` 随机
- `keydown``keypress``keyup` 完整序列
- 中文输入法暂不模拟
**滚动**
- 非瞬间跳转,分段滚动
- 每次滚动步长加入随机抖动
### 3.2 实现方案
```
daemon/
├── src/
│ ├── stealth/ # 反检测模块
│ │ ├── index.ts # 统一入口,页面创建时注入
│ │ ├── navigator.ts # navigator 属性覆盖
│ │ ├── chrome-runtime.ts # window.chrome 注入
│ │ ├── screen.ts # 屏幕/视口尺寸管理
│ │ ├── canvas-noise.ts # Canvas/WebGL/Audio 噪声
│ │ ├── headers.ts # HTTP 头拦截修正
│ │ ├── permissions.ts # 权限状态管理
│ │ ├── human-input.ts # 鼠标/键盘/滚动行为模拟
│ │ ├── fingerprint-profile.ts # 指纹配置文件结构
│ │ └── profiles/ # 内置指纹模版
│ │ ├── desktop-chrome.ts # 桌面 Chrome 通用模版
│ │ ├── desktop-windows.ts # Windows Chrome
│ │ └── desktop-mac.ts # macOS Chrome
```
### 3.3 指纹配置(FingerprintProfile
```typescript
// packages/core/src/types/fingerprint.ts
interface FingerprintProfile {
// 浏览器基础
userAgent: string;
platform: string;
languages: string[];
acceptLanguage: string;
// 屏幕
screen: {
width: number;
height: number;
colorDepth: number;
pixelRatio: number;
};
viewport: {
width: number;
height: number;
};
// GPU
webglVendor: string;
webglRenderer: string;
// 时区与位置
timezone: string;
geolocation?: { latitude: number; longitude: number; accuracy: number };
// 权限
permissions: {
notifications: 'prompt' | 'granted' | 'denied';
geolocation: 'prompt' | 'granted' | 'denied';
camera: 'prompt' | 'granted' | 'denied';
microphone: 'prompt' | 'granted' | 'denied';
};
// 行为
behavior: {
mouseMoveDelay: { min: number; max: number }; // ms
keyPressDelay: { min: number; max: number }; // ms
scrollStepDelay: { min: number; max: number }; // ms
};
// Canvas 噪声
canvasNoise: {
enabled: boolean;
strength: number; // 0-1, 噪声强度
};
}
```
### 3.4 WebDriver 检测网站验证
v1 目标:通过以下检测站点的自动化识别:
| 检测站点 | 检测方式 | 目标 |
|---------|---------|------|
| https://bot.sannysoft.com | 综合(navigator + screen + chrome + canvas + webgl + fonts | **必须全绿** |
| https://fingerprint.com/demo | 综合指纹(最全面) | 降低置信度到非 bot 区间 |
| https://abrahamjuliot.github.io/creepjs/ | 浏览器指纹各维度逐一打分 | 所有维度分数控制在合理范围 |
---
## 4. 项目结构
```
VisionL/
├── docs/
│ ├── development/
│ │ ├── architecture.md # 本文档
│ │ ├── anti-detection.md # 反检测专项文档(检测维度详情、绕过原理)
│ │ ├── api.md # REST + WS 接口文档
│ │ ├── cli.md # CLI 命令参考
│ │ └── contributing.md # 开发规范
│ └── usage/
│ ├── quickstart.md # 快速开始
│ ├── llm-integration.md # 智能体集成指南
│ └── examples.md # 典型场景示例
├── packages/
│ ├── core/ # 共享库:类型定义、HTTP 客户端
│ │ ├── src/
│ │ │ ├── types/
│ │ │ │ ├── page.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── ws.ts
│ │ │ │ └── fingerprint.ts # 指纹配置类型
│ │ │ ├── client.ts
│ │ │ ├── escape.ts
│ │ │ └── index.ts
│ │ └── package.json
│ ├── daemon/ # 后台进程
│ │ ├── src/
│ │ │ ├── server.ts
│ │ │ ├── routes/
│ │ │ │ ├── pages.ts
│ │ │ │ ├── actions.ts
│ │ │ │ ├── content.ts
│ │ │ │ └── health.ts
│ │ │ ├── browser-manager.ts
│ │ │ ├── page-registry.ts
│ │ │ ├── ws-relay.ts
│ │ │ ├── pidfile.ts
│ │ │ └── stealth/ # 反检测模块
│ │ │ ├── index.ts # 统一注入入口
│ │ │ ├── navigator.ts
│ │ │ ├── chrome-runtime.ts
│ │ │ ├── screen.ts
│ │ │ ├── canvas-noise.ts
│ │ │ ├── headers.ts
│ │ │ ├── permissions.ts
│ │ │ ├── human-input.ts
│ │ │ ├── fingerprint-profile.ts
│ │ │ └── profiles/
│ │ │ ├── desktop-chrome.ts
│ │ │ ├── desktop-windows.ts
│ │ │ └── desktop-mac.ts
│ │ └── package.json
│ └── cli/
│ ├── src/
│ │ ├── index.ts
│ │ ├── commands/
│ │ │ ├── page.ts
│ │ │ ├── action.ts
│ │ │ ├── view.ts
│ │ │ └── daemon.ts
│ │ ├── format.ts
│ │ └── auto-daemon.ts
│ └── package.json
├── package.json
├── tsconfig.json
├── .gitignore
├── README.md
└── LICENSE
```
---
## 5. Daemon 设计
### 5.1 REST API
| 方法 | 路径 | 说明 | 请求体 | 响应 |
|------|------|------|--------|------|
| POST | `/pages` | 打开新页面 | `{ url, alias?, profile? }` | `{ id, url, alias?, title, status, profileId }` |
| GET | `/pages` | 列出所有页面 | — | `[{ id, url, alias?, title, status }]` |
| GET | `/pages/:id` | 页面详情 | — | 同上 |
| DELETE | `/pages/:id` | 杀死页面 | — | `{ ok: true }` |
| POST | `/pages/:id/navigate` | 跳转 URL | `{ url }` | `{ url, title }` |
| POST | `/pages/:id/click` | 点击元素 | `{ selector }` | `{ success }` |
| POST | `/pages/:id/type` | 输入文本 | `{ selector, text }` | `{ success }` |
| POST | `/pages/:id/scroll` | 滚动页面 | `{ deltaY?, toBottom? }` | `{ success }` |
| POST | `/pages/:id/eval` | 执行 JS | `{ code }` | `{ result }` |
| POST | `/pages/:id/wait` | 等待条件 | `{ selector?, ms? }` | `{ success }` |
| GET | `/pages/:id/screenshot` | 页面截图 | — | `{ base64, mime }` |
| GET | `/pages/:id/text` | 页面纯文本 | — | `{ text }` |
| GET | `/pages/:id/html` | 页面 HTML | — | `{ html }` |
| GET | `/profiles` | 列出可用的指纹配置 | — | `[{ id, name }]` |
| GET | `/health` | 健康检查 | — | `{ status: "ok" }` |
> **新增**`POST /pages` 的 `profile` 字段指定指纹配置(默认 `desktop-chrome`)。
> `GET /profiles` 返回所有内置指纹模版。
### 5.2 WebSocket 事件
同前,另增:
| 事件 | 数据 | 触发时机 |
|------|------|---------|
| `page:detection:warning` | `{ id, level, detail }` | 页面检测到潜在的自动化特征 |
### 5.3 页面创建流程(含反检测注入)
```
visionl page open https://example.com --alias demo --profile desktop-windows
→ POST /pages { url, alias: "demo", profile: "desktop-windows" }
→ browser-manager.createPage(url, fingerprintProfile)
→ 1. 创建 BrowserContext(从 profile 读取 viewport、locale、timezone、geolocation
→ 2. 创建 Page
→ 3. 注入 stealth 插件(navigator.webdriver 等基础隐藏)
→ 4. 注入自研 stealth 模块:
- 注入 navigator 覆盖脚本(evalOnNewDocument
- 注入 window.chrome 对象
- 注入 canvas/webgl/audio 噪声脚本
- 注册请求拦截器修正 HTTP 头
- 注入权限状态
→ 5. 导航到目标 URL
→ 6. 注册到 PageRegistry
→ 返回 { id: "p_abc123", ..., profileId: "desktop-windows" }
```
### 5.4 行为模拟流程
操作命令(click/type/scroll)不走 Playwright 的 API,而是走自研的 `human-input` 模块:
```
visionl click p_abc123 "#login"
→ POST /pages/p_abc123/click { selector: "#login" }
→ action-handler:
→ 1. 获取元素 bounds
→ 2. 计算鼠标路径(当前坐标 → 目标中心 + 抖动)
→ 3. 沿路径逐个发射 mousemove
→ 4. 到达后依次发射 mousedown → mouseup → click
→ 5. (Future) 考虑 ancestor visibility 和 z-index
```
```
visionl type p_abc123 "#username" "hello"
→ POST /pages/p_abc123/type { selector: "#username", text: "hello" }
→ action-handler:
→ 1. 聚焦元素(click or focus
→ 2. 逐字符:keydown → keypress → keyup
→ 3. 每字符间隔随机 50-150ms
→ 4. 完成后可选触发 change/blur 事件
```
---
## 6. CLI 设计(略,同前版本)
CLI 子命令不变。`page open` 新增 `--profile <name>` 指定指纹模版。
---
## 7. 类型定义(core 包核心类型)
```typescript
// 新增
interface FingerprintProfile {
id: string; // "desktop-chrome"
name: string; // "桌面 Chrome (通用)"
userAgent: string;
platform: string;
languages: string[];
screen: { width: number; height: number; colorDepth: number; pixelRatio: number };
viewport: { width: number; height: number };
webgl: { vendor: string; renderer: string };
timezone: string;
geolocation?: { latitude: number; longitude: number; accuracy: number };
permissions: FingerprintPermissions;
behavior: FingerprintBehavior;
canvasNoise: { enabled: boolean; strength: number };
}
interface FingerprintPermissions {
notifications: PermissionState;
geolocation: PermissionState;
camera: PermissionState;
microphone: PermissionState;
}
interface FingerprintBehavior {
mouseMoveDelay: { min: number; max: number };
keyPressDelay: { min: number; max: number };
scrollStepDelay: { min: number; max: number };
}
```
---
## 8. 关键设计决策
### 反检测为首要质量指标
单测、集成测试、手动检测结果共同构成反检测的"质量门"。任何新功能引入不能降低
反检测评分。
### 指纹模版化
不同网站对不同地区的浏览器有不同的预期。VisionL 提供多套预置指纹模版,
智能体可根据目标网站选择。v1 提供 3 套:
| 模版 ID | 说明 | UA 平台 |
|---------|------|---------|
| `desktop-chrome` | 桌面 Chrome 通用 | Linux x86_64 |
| `desktop-windows` | Windows 10 Chrome | Windows NT 10.0 |
| `desktop-mac` | macOS Chrome | Macintosh Intel |
### Canvas 噪声策略
- 同一页面会话(BrowserContext)使用相同的随机种子
- 跨页面会话自动刷新种子
- 噪声强度可配置(0-1),默认 0.3
### HTTP 头注入时机
- `User-Agent``Accept-Language`:在创建 BrowserContext 时通过 Playwright API 设置
- `Sec-CH-UA-*` 系列:通过请求拦截(`page.route()`)修改
### 其他设计决策(同前)
- v1 不包含会话恢复
- 无身份认证(仅 localhost
- Page ID + 别名双轨引用
- 每个 daemon 一个 Browser 实例
---
## 9. 测试策略
| 层级 | 工具 | 范围 |
|------|------|------|
| 反检测验证 | 脚本 + 人工 | 对 bot.sannysoft.com、fingerprint.com 全绿验证 |
| stealth/navigator | vitest | navigator 各属性值验证 |
| stealth/chrome-runtime | vitest | window.chrome 对象完整性 |
| stealth/canvas-noise | vitest | 噪声输出一致性、强度控制 |
| stealth/headers | vitest + msw | HTTP 头正确性 |
| stealth/human-input | vitest | 鼠标路径计算、键盘延迟分布 |
| `core` 类型 | ts 类型检查 | 编译期正确性 |
| `daemon/routes` | vitest + playwright-test | 路由逻辑 |
| `cli/commands` | vitest + mock | 命令解析 |
| 集成测试 | vitest + 真实 daemon | CLI → daemon → Playwright |
| JSON 转义 | vitest | 特殊字符安全 |
---
## 10. 未来规划(v1 范围外)
- `visionl-gui`:桌面 GUI
- 会话持久化
- 更多指纹模版(移动端 Chrome、Safari、Edge 等)
- 指纹随机化(每个页面随机微调指纹参数)
- 行为模拟增强(贝塞尔曲线鼠标路径、人类式打字节奏)
- 验证码自动识别(对接打码服务)
- 多用户支持
- 插件系统
- 远程 daemon
- 网络拦截和 Mock API
- Cookie/存储管理 CLI
- 页面录制与回放
+306
View File
@@ -0,0 +1,306 @@
# VisionL CLI 命令参考
> 适用版本:v1
## 安装
```bash
# 全局安装(需要先构建)
npm install -g ./packages/cli
# 或开发模式
cd packages/cli && npm link
```
安装后可使用 `visionl` 命令。
## 全局选项
| 选项 | 说明 |
|------|------|
| `--pretty` | 人类可读格式输出(默认 JSON) |
| `--port <n>` | daemon 端口(默认 9527 |
| `--profile <id>` | 指纹配置模版(默认 desktop-chrome |
| `--help` | 查看帮助 |
---
## 页面管理
### `visionl page open`
打开新页面。
```bash
visionl page open <url> [--alias <name>] [--profile <id>]
```
**示例:**
```bash
visionl page open https://www.baidu.com --alias baidu --profile desktop-windows
# {"ok":true,"data":{"id":"p_a1b2c3d4","url":"https://www.baidu.com","alias":"baidu","title":"百度一下,你就知道","status":"active"}}
```
### `visionl page list`
列出所有已打开页面。
```bash
visionl page list
# {"ok":true,"data":[{"id":"p_a1b2c3d4","url":"...","alias":"baidu",...}]}
```
### `visionl page info`
查看页面详情。
```bash
visionl page info <id|alias>
visionl page info baidu
```
### `visionl page kill`
关闭指定页面。
```bash
visionl page kill <id|alias>
```
### `visionl page kill-all`
关闭所有页面。
```bash
visionl page kill-all
```
---
## 页面操作
### `visionl click`
点击指定元素。
```bash
visionl click <id|alias> <selector>
# 示例
visionl click baidu "#su"
```
### `visionl type`
在输入框中输入文本。
```bash
visionl type <id|alias> <selector> <text>
# 示例
visionl type baidu "#kw" "VisionL浏览器"
```
### `visionl scroll`
滚动页面。
```bash
visionl scroll <id|alias> [--down <px>] [--bottom]
# 示例
visionl scroll baidu --down 300
visionl scroll baidu --bottom
```
### `visionl navigate`
页面跳转。
```bash
visionl navigate <id|alias> <url>
```
### `visionl eval`
在页面中执行 JavaScript。
```bash
visionl eval <id|alias> <js-code>
# 示例
visionl eval baidu "document.title"
# {"ok":true,"data":{"result":"百度一下,你就知道"}}
```
### `visionl wait`
等待条件满足。
```bash
visionl wait <id|alias> [--selector <sel>] [--ms <n>]
# 等待选择器出现
visionl wait baidu --selector "#content"
# 等待 2 秒
visionl wait baidu --ms 2000
```
---
## 内容获取
### `visionl screenshot`
获取页面截图。
```bash
visionl screenshot <id|alias> [-o <file.png>]
# 输出 base64(默认)
visionl screenshot baidu
# 写入文件
visionl screenshot baidu -o screenshot.png
```
### `visionl text`
获取页面纯文本。
```bash
visionl text <id|alias>
# {"ok":true,"data":{"text":"百度一下,你就知道\n..."}}
```
### `visionl html`
获取页面 HTML 源码。
```bash
visionl html <id|alias>
# {"ok":true,"data":{"html":"<!DOCTYPE html>..."}}
```
---
## 指纹配置
### `visionl profiles`
列出可用的指纹配置模版。
```bash
visionl profiles
# {"ok":true,"data":[{"id":"desktop-chrome","name":"桌面 Chrome (通用)"},{"id":"desktop-windows","name":"Windows 10 Chrome"},...]}
```
| 模版 ID | 说明 | UA 平台 |
|---------|------|---------|
| `desktop-chrome` | 桌面 Chrome 通用(默认) | Linux x86_64 |
| `desktop-windows` | Windows 10 Chrome | Windows NT 10.0 |
| `desktop-mac` | macOS Chrome | Macintosh Intel |
---
## Daemon 管理
### `visionl daemon start`
手动启动 daemon。
```bash
visionl daemon start [--port <n>]
# 默认端口 9527
visionl daemon start
# 指定端口
visionl daemon start --port 8080
```
### `visionl daemon stop`
优雅关闭 daemon(不杀死已打开的页面)。
```bash
visionl daemon stop
```
### `visionl daemon status`
查看 daemon 运行状态。
```bash
visionl daemon status
# {"ok":true,"data":{"running":true,"pid":12345,"port":9527}}
```
---
## REST 直通
直接向 daemon 发送原始 HTTP 请求。
```bash
visionl raw <METHOD> <path> [body]
# 示例
visionl raw POST /pages '{"url":"https://example.com"}'
visionl raw GET /pages
visionl raw GET /pages/p_abc123/text
visionl raw DELETE /pages/p_abc123
```
---
## 自动拉起
CLI 在执行页面命令前会自动检测 daemon 是否存活。如果 daemon 未运行,会自动启动。
自动拉起流程:
1. 向默认端口(9527)发送 `GET /health`
2. 无响应时检查 `~/.visionl/daemon.pid` 中的进程是否存活
3. 都不行则 `spawn` 启动 daemon
4. 轮询健康检查,最多等待 3 秒
5. 超时则报错退出
**状态文件:**
- `~/.visionl/daemon.pid` — daemon PID
- `~/.visionl/daemon.port` — daemon 端口
---
## 输出格式
### JSON(默认)
每行一个 JSON 对象,LLM 直接解析:
```json
{"ok":true,"data":{"id":"p_a1b2c3d4","url":"https://example.com","title":"Example"}}
```
### 人类可读(--pretty
```bash
visionl page open https://example.com --pretty
# ✓ 页面已打开
# ID: p_a1b2c3d4
# URL: https://example.com
# 标题: Example Domain
# 状态: active
```
---
## 退出码
| 退出码 | 含义 |
|--------|------|
| 0 | 成功 |
| 1 | 一般错误(页面不存在、选择器未找到等) |
| 2 | daemon 不可达 |
| 3 | 无效参数 |
+164
View File
@@ -0,0 +1,164 @@
# VisionL 开发规范
## 环境要求
- Node.js >= 18
- npm >= 9
- Git
## 克隆与安装
```bash
git clone ssh://git@git.yeij.top:2222/AskaEth/VisionL.git
cd VisionL
git checkout dev
# 安装所有 workspace 依赖
npm install
# 安装 Playwright 浏览器(Chromium
npx playwright install chromium
```
## 项目结构
Monorepo 使用 npm workspaces 管理:
```
packages/
├── core/ # 共享类型(含 FingerprintProfile)、HTTP 客户端、JSON 转义
├── daemon/ # 后台守护进程 + stealth 反检测模块
└── cli/ # 命令行工具
```
## 开发命令
```bash
# 类型检查(全仓)
npm run typecheck
# 运行所有测试
npm test
# 运行单个包的测试
npm test --workspace=packages/core
npm test --workspace=packages/daemon
npm test --workspace=packages/cli
# 构建
npm run build
# 本地开发(cli 链接到全局)
cd packages/cli && npm link
# 然后可以在任何目录使用 visionl 命令
```
## 代码规范
### 命名
- 文件名:kebab-case`browser-manager.ts`
- 变量/函数:camelCase`pageRegistry``getPageInfo`
- 类型/接口:PascalCase`PageInfo``ApiResponse`
- 常量:UPPER_SNAKE_CASE`DEFAULT_PORT`
### TypeScript
- 严格模式 (`strict: true`)
- 禁止 `any`(必须显式类型)
- 导出类型使用 `interface` 而非 `type`(可扩展性更好)
### 注释
- 文件头简述文件职责(中英双语一行)
- 公共 API 必须有 JSDoc
- 不写废话注释(不解释显而易见的代码)
### 日志
Daemon 和 CLI 使用统一的日志层级:
- `DEBUG`:调试信息(带上下文)
- `INFO`:关键状态变更(页面打开/关闭/崩溃)
- `WARN`:可恢复的异常
- `ERROR`:需要关注的错误
生产环境默认 `INFO` 级别,开发模式可设为 `DEBUG`
## 测试规范
- 使用 `vitest` 作为测试框架
- 测试文件放在 `src/__tests__/` 目录下
- 文件命名:`*.test.ts`
- 每个 PR 必须包含相关测试
- 集成测试放在 `packages/daemon/src/__tests__/integration/`
## Git 工作流
- `main`:稳定发布分支
- `dev`:开发分支(日常开发在此)
- 功能分支:`feature/xxx`
- 修复分支:`fix/xxx`
### Commit 规范
遵循 Conventional Commits
```
feat: 添加页面截图功能
fix: 修复 daemon 端口冲突时的错误提示
docs: 更新 API 文档
test: 添加 CLI 自动拉起单元测试
refactor: 重构 page-registry 为 Map 实现
```
## 反检测开发规范
这是 VisionL 最核心的质量要求。任何新功能不能降低反检测评分。
### 原则
- GUI 浏览器所有可被页面 JS 读取的属性,VisionL 都必须有真实值(或模拟值)
- 禁止暴露任何 `navigator.webdriver``--headless`、Playwright/Puppeteer 痕迹
- 每个 stealth 模块必须先写测试,验证目标属性值符合真实浏览器
### 检测站点验证
每次版本发布前,必须通过以下站点验证:
| 站点 | 指标 |
|------|------|
| https://bot.sannysoft.com | 全部绿条 |
| https://abrahamjuliot.github.io/creepjs/ | 所有维度分数 ≤ 30% 异常 |
| https://fingerprint.com/demo | 访客置信度显示为正常浏览器(非 bot) |
### 新增 Stealth 模块 Checklist
1. 确认需要覆盖的 JS API / 属性
2. 在真实 Chrome GUI 中抓取基准值
3. 实现模块,写入 `daemon/src/stealth/`
4. 写测试验证模拟值与基准值一致
5. 在三个检测站点验证未被降级
## 发布流程
1. 功能开发在 `dev` 分支
2. 测试全过后合并到 `main`
3. 打 tag`v1.0.0`
4. 发布到 npm
---
## JSON 转义规范
所有返回给外部的 JSON 必须通过 `JSON.stringify` 序列化,禁止以下写法:
```typescript
// ❌ 禁止
`{"ok":true,"text":"${pageText}"}`
// ✅ 正确
JSON.stringify({ ok: true, text: pageText })
```
页面内容(文本、HTML)中可能包含任意字符,手动拼接必然导致 JSON 非法。
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
# VisionL 使用示例
> 基于实际测试验证的场景。
## 场景一:搜索引擎查询
```bash
# 1. 打开百度
curl -s -X POST http://127.0.0.1:9527/pages \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.baidu.com","alias":"search"}'
# 2. 获取首页文本
curl -s http://127.0.0.1:9527/pages/search/text
# 3. 输入搜索词并搜索
curl -s -X POST http://127.0.0.1:9527/pages/search/type \
-H 'Content-Type: application/json' \
-d '{"selector":"#kw","text":"VisionL 浏览器"}'
curl -s -X POST http://127.0.0.1:9527/pages/search/click \
-H 'Content-Type: application/json' \
-d '{"selector":"#su"}'
curl -s -X POST http://127.0.0.1:9527/pages/search/wait \
-H 'Content-Type: application/json' \
-d '{"ms":2000}'
# 4. 获取搜索结果
curl -s http://127.0.0.1:9527/pages/search/text
# 5. 截图保存
curl -s http://127.0.0.1:9527/pages/search/screenshot | jq -r '.data.base64' | base64 -d > result.png
# 6. 关闭
curl -s -X DELETE http://127.0.0.1:9527/pages/search
```
## 场景二:多页面信息收集
```bash
# 同时打开多个信息源
curl -s -X POST http://127.0.0.1:9527/pages \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.baidu.com","alias":"baidu"}'
curl -s -X POST http://127.0.0.1:9527/pages \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.bing.com","alias":"bing"}'
# 分别提取内容
curl -s http://127.0.0.1:9527/pages/baidu/text
curl -s http://127.0.0.1:9527/pages/bing/text
# 查看所有页面
curl -s http://127.0.0.1:9527/pages
# 关闭指定页面
curl -s -X DELETE http://127.0.0.1:9527/pages/baidu
```
## 场景三:Cookie 管理
```bash
# 查看页面 Cookie(百度首页返回 8 个 Cookie)
curl -s http://127.0.0.1:9527/pages/baidu/cookies
# 设置自定义 Cookie
curl -s -X POST http://127.0.0.1:9527/pages/baidu/cookies \
-H 'Content-Type: application/json' \
-d '{"name":"session","value":"abc123","domain":".baidu.com"}'
# 删除特定 Cookie
curl -s -X DELETE http://127.0.0.1:9527/pages/baidu/cookies/session
```
## 场景四:网络请求监控
```bash
# 打开页面后查看网络请求日志
curl -s http://127.0.0.1:9527/pages/baidu/network
# 返回包含请求 URL、方法、状态码等信息:
# [{"type":"response","url":"https://pss.bdstatic.com/...","status":200,...}]
```
## 场景五:JS 数据提取
```bash
# 获取页面标题
curl -s -X POST http://127.0.0.1:9527/pages/baidu/eval \
-H 'Content-Type: application/json' \
-d '{"code":"document.title"}'
# {"ok":true,"data":{"result":"百度一下,你就知道"}}
# 获取链接数量
curl -s -X POST http://127.0.0.1:9527/pages/baidu/eval \
-H 'Content-Type: application/json' \
-d '{"code":"document.querySelectorAll(\"a\").length"}'
# 获取页面 meta 信息
curl -s -X POST http://127.0.0.1:9527/pages/baidu/eval \
-H 'Content-Type: application/json' \
-d '{"code":"document.querySelector(\"meta[name=description]\")?.content"}'
```
## 场景六:滚动截图
```bash
# 滚动页面
curl -s -X POST http://127.0.0.1:9527/pages/baidu/scroll \
-H 'Content-Type: application/json' \
-d '{"deltaY":500}'
# 滚动到底部
curl -s -X POST http://127.0.0.1:9527/pages/baidu/scroll \
-H 'Content-Type: application/json' \
-d '{"toBottom":true}'
# 截图
curl -s http://127.0.0.1:9527/pages/baidu/screenshot | jq -r '.data.base64' | base64 -d > scrolled.png
```
## 场景七:切换指纹配置
```bash
# 查看可用配置
curl -s http://127.0.0.1:9527/profiles
# 使用 Windows Chrome 指纹打开页面
curl -s -X POST http://127.0.0.1:9527/pages \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.baidu.com","alias":"win","profile":"desktop-windows"}'
```
## 场景八:页面持久化验证
```bash
# 1. 打开页面
curl -s -X POST http://127.0.0.1:9527/pages \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.baidu.com","alias":"persistent"}'
# 2. 关闭 curl 连接(页面不消失)
# 3. 重新查询 — 页面仍在
curl -s http://127.0.0.1:9527/pages/persistent
# {"ok":true,"data":{"id":"p_xxx","status":"active",...}}
# 4. 只有显式 kill 才关闭
curl -s -X DELETE http://127.0.0.1:9527/pages/persistent
```
+192
View File
@@ -0,0 +1,192 @@
# 在 LLM 智能体中集成 VisionL
> 让 LLM 通过 HTTP API 工具调用操控 VisionL 浏览器。
## 原理
VisionL daemon 提供完整的 REST API。LLM 将 API 调用注册为工具/函数(Function Calling),
在需要浏览网页时生成对应的 HTTP 请求。所有接口返回结构化 JSON,LLM 直接解析。
## API 总览
| 方法 | 端点 | 功能 |
|------|------|------|
| POST | `/pages` | 打开页面 |
| GET | `/pages` | 列出所有页面 |
| GET | `/pages/:id` | 页面详情 |
| DELETE | `/pages/:id` | 关闭页面 |
| POST | `/pages/:id/navigate` | 跳转 |
| POST | `/pages/:id/click` | 点击元素 |
| POST | `/pages/:id/type` | 输入文本 |
| POST | `/pages/:id/scroll` | 滚动 |
| POST | `/pages/:id/eval` | 执行 JS |
| POST | `/pages/:id/wait` | 等待 |
| GET | `/pages/:id/screenshot` | 截图(base64 |
| GET | `/pages/:id/text` | 纯文本 |
| GET | `/pages/:id/html` | HTML 源码 |
| GET | `/pages/:id/cookies` | Cookie 列表 |
| POST | `/pages/:id/cookies` | 设置 Cookie |
| DELETE | `/pages/:id/cookies/:name` | 删除 Cookie |
| GET | `/pages/:id/console` | 控制台日志 |
| GET | `/pages/:id/network` | 网络请求日志 |
| GET | `/profiles` | 指纹配置列表 |
完整文档见 [API 文档](../development/api.md)。
## 集成方式
### 方式一:Function Calling(推荐)
注册 `visionl_api` 工具,LLM 直接生成 HTTP 请求:
```json
{
"type": "function",
"function": {
"name": "visionl_api",
"description": "通过 VisionL 浏览器操控网页。支持打开页面、点击、输入、截图、提取文本、执行JS、管理Cookie、查看网络请求等。",
"parameters": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "DELETE"],
"description": "HTTP 方法"
},
"path": {
"type": "string",
"description": "API 路径,如 /pages、/pages/baidu/text"
},
"body": {
"type": "object",
"description": "请求体(仅 POST 需要)"
}
},
"required": ["method", "path"]
}
}
}
```
### 使用流程
1. LLM 决策需要访问网页
2. LLM 调用 `visionl_api``POST /pages` 打开 baidu.com
3. 宿主程序执行 HTTP 请求,将 JSON 结果返回 LLM
4. LLM 解析结果,获得页面 ID `p_xxx`
5. LLM 继续:`GET /pages/p_xxx/text` 读取内容
6. 或:`POST /pages/p_xxx/click` 点击搜索
### 方式二:多工具注册
将每个操作注册为独立工具(更细粒度):
```json
[
{
"name": "visionl_open",
"description": "打开网页",
"parameters": {
"url": { "type": "string" },
"alias": { "type": "string" }
}
},
{
"name": "visionl_text",
"description": "获取页面文本内容",
"parameters": {
"page_id": { "type": "string" }
}
},
{
"name": "visionl_click",
"description": "点击页面元素",
"parameters": {
"page_id": { "type": "string" },
"selector": { "type": "string" }
}
}
]
```
### 方式三:LangChain 集成
```python
from langchain.tools import BaseTool
import requests
class VisionLTool(BaseTool):
name = "visionl"
description = "浏览器操控工具。API 基础 URL: http://127.0.0.1:9527"
def _run(self, method: str, path: str, body: dict = None) -> str:
url = f"http://127.0.0.1:9527{path}"
resp = requests.request(method, url, json=body)
return resp.text
```
## LLM 系统提示词建议
```
你可以使用 visionl_api 工具操控浏览器:
打开页面: POST /pages {"url":"...","alias":"..."}
页面文本: GET /pages/{id}/text
页面截图: GET /pages/{id}/screenshot (返回 base64)
点击元素: POST /pages/{id}/click {"selector":"#id"}
输入文本: POST /pages/{id}/type {"selector":"#id","text":"..."}
执行 JS: POST /pages/{id}/eval {"code":"..."}
滚动页面: POST /pages/{id}/scroll {"deltaY":300}
等待加载: POST /pages/{id}/wait {"ms":2000}
查看Cookie: GET /pages/{id}/cookies
网络日志: GET /pages/{id}/network
关闭页面: DELETE /pages/{id}
所有接口返回 {"ok":true,"data":{...}} 或 {"ok":false,"error":{...}}。
打开页面后记录返回的 page_id,后续操作使用该 id。
页面在被显式 kill 之前永远存活,可跨多轮对话复用。
```
## 多页面并行管理
LLM 同时打开多个页面,通过别名区分:
```
LLM: POST /pages {"url":"https://docs.python.org","alias":"py"}
→ {"ok":true,"data":{"id":"p_aaa",...}}
LLM: POST /pages {"url":"https://developer.mozilla.org","alias":"mdn"}
→ {"ok":true,"data":{"id":"p_bbb",...}}
LLM: GET /pages/py/text # 读 Python 文档
LLM: GET /pages/mdn/text # 读 MDN 文档
```
## 错误处理
```json
// 失败示例
{"ok":false,"error":{"code":"PAGE_NOT_FOUND","message":"页面 py 不存在"}}
```
常见错误码及处理:
| 错误码 | HTTP | 处理建议 |
|--------|------|---------|
| `PAGE_NOT_FOUND` | 404 | 页面已关闭,重新打开 |
| `DAEMON_UNREACHABLE` | 502 | 启动 daemon 或稍后重试 |
| `TIMEOUT` | 408 | 页面加载慢,重试或增加等待 |
| `ALIAS_EXISTS` | 409 | 换别名或直接用 page_id |
## 反检测能力
VisionL 内置多层反检测,使自动化访问尽可能不被简单人机验证拦截:
- `navigator.webdriver``false`
- 真实 Chrome User-Agent 和请求头
- Canvas/WebGL/Audio 指纹加噪
- 屏幕分辨率和视口合理性
- 权限状态模拟
- 3 套指纹模版可切换
详见 [反检测设计文档](../development/anti-detection.md)。
+138
View File
@@ -0,0 +1,138 @@
# VisionL 快速开始
## 环境要求
- Node.js >= 18
- Chromium 浏览器(系统自带或 Playwright 安装)
- Linux/macOS/WindowsAndroid 需配合 Ubuntu proot 容器)
## 安装
```bash
git clone ssh://git@git.yeij.top:2222/AskaEth/VisionL.git
cd VisionL
npm install --registry=https://registry.npmmirror.com
npx playwright install chromium # 如果没有系统 Chromium
npm run build
```
## 启动
```bash
# 方式一:使用内置启动脚本
node start-daemon.js &
# 方式二:直接运行 daemon
VISIONL_PORT=9527 node packages/daemon/dist/server.js &
```
## 基本使用
所有命令输出 JSON 格式,可使用 `--pretty` 切换为人类可读。
### 打开页面
```bash
curl -X POST http://127.0.0.1:9527/pages \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.baidu.com","alias":"baidu"}'
```
返回:
```json
{
"ok": true,
"data": {
"id": "p_f1990959",
"url": "https://www.baidu.com",
"alias": "baidu",
"title": "百度一下,你就知道",
"status": "active",
"profile": "desktop-chrome"
}
}
```
### 获取页面文本
```bash
curl http://127.0.0.1:9527/pages/baidu/text
```
### 搜索
```bash
# 输入搜索词
curl -X POST http://127.0.0.1:9527/pages/baidu/type \
-H 'Content-Type: application/json' \
-d '{"selector":"#kw","text":"VisionL"}'
# 点击搜索按钮
curl -X POST http://127.0.0.1:9527/pages/baidu/click \
-H 'Content-Type: application/json' \
-d '{"selector":"#su"}'
# 等待结果加载
curl -X POST http://127.0.0.1:9527/pages/baidu/wait \
-H 'Content-Type: application/json' \
-d '{"ms":2000}'
# 获取搜索结果
curl http://127.0.0.1:9527/pages/baidu/text
```
### 截图
```bash
curl http://127.0.0.1:9527/pages/baidu/screenshot
# 返回 base64 编码的 PNG 图片
```
### 执行 JavaScript
```bash
curl -X POST http://127.0.0.1:9527/pages/baidu/eval \
-H 'Content-Type: application/json' \
-d '{"code":"document.title"}'
# {"ok":true,"data":{"result":"百度一下,你就知道"}}
```
### Cookie 管理
```bash
# 查看所有 Cookie
curl http://127.0.0.1:9527/pages/baidu/cookies
# 设置 Cookie
curl -X POST http://127.0.0.1:9527/pages/baidu/cookies \
-H 'Content-Type: application/json' \
-d '{"name":"mycookie","value":"hello","domain":".baidu.com"}'
```
### 网络请求监控
```bash
# 查看网络请求日志
curl http://127.0.0.1:9527/pages/baidu/network
```
### 查看指纹配置
```bash
curl http://127.0.0.1:9527/profiles
# [{"id":"desktop-chrome","name":"桌面 Chrome (通用)"},
# {"id":"desktop-windows","name":"桌面 Chrome (Windows)"},
# {"id":"desktop-mac","name":"桌面 Chrome (macOS)"}]
```
### 关闭页面
```bash
curl -X DELETE http://127.0.0.1:9527/pages/baidu
```
### 查看所有页面
```bash
curl http://127.0.0.1:9527/pages
```
+2304
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "visionl",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"typecheck": "tsc -b",
"build": "tsc -b",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.2.7"
},
"allowScripts": {
"esbuild@0.28.2": true
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@visionl/cli",
"version": "0.1.0",
"private": true,
"bin": {
"visionl": "./dist/index.js"
},
"scripts": {
"typecheck": "tsc -b",
"build": "tsc -b"
},
"dependencies": {
"@visionl/core": "*",
"commander": "^13.0.0"
},
"devDependencies": {
"@types/commander": "npm:commander@^13.0.0"
}
}
+39
View File
@@ -0,0 +1,39 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
import { readPidfile, isProcessAlive } from '@visionl/daemon/dist/pidfile.js';
import { VisionLClient } from '@visionl/core';
const POLL_INTERVAL_MS = 100;
const MAX_WAIT_MS = 3000;
export async function ensureDaemonRunning(port: number): Promise<VisionLClient> {
const client = new VisionLClient(`http://127.0.0.1:${port}`);
if (await client.health()) {
return client;
}
const pidInfo = readPidfile();
if (pidInfo && isProcessAlive(pidInfo.pid)) {
if (await client.health()) {
return client;
}
}
const daemonEntryPath = path.resolve(__dirname, '../../daemon/dist/server.js');
spawn('node', [daemonEntryPath], {
env: { ...process.env, VISIONL_PORT: String(port) },
stdio: 'ignore',
detached: true,
}).unref();
const startTime = Date.now();
while (Date.now() - startTime < MAX_WAIT_MS) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
if (await client.health()) {
return client;
}
}
throw new Error('DAEMON_UNREACHABLE');
}
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { ensureDaemonRunning } from './auto-daemon.js';
import { safeStringify, VisionLClient } from '@visionl/core';
const DEFAULT_PORT = 9527;
const program = new Command();
program.name('visionl').version('0.1.0');
program.option('-p, --port <n>', 'daemon port', String(DEFAULT_PORT));
program.option('--pretty', 'human-readable output');
function getClient(): Promise<VisionLClient> {
const opts = program.opts<{ port: string }>();
const port = parseInt(opts.port, 10) || DEFAULT_PORT;
return ensureDaemonRunning(port);
}
function print(result: unknown): void {
const opts = program.opts<{ pretty: boolean }>();
if (opts.pretty) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(safeStringify(result));
}
}
// health — check daemon health
program.command('health')
.description('Check daemon health')
.action(async () => {
const client = await getClient();
const ok = await client.health();
print({ ok });
});
// pages — page management
program.command('open <url>')
.description('Open a URL in a new page')
.option('-a, --alias <alias>', 'Page alias')
.option('-p, --profile <profile>', 'Browser profile')
.action(async (url: string, options: { alias?: string; profile?: string }) => {
const client = await getClient();
const result = await client.openPage(url, options.alias, options.profile);
print(result);
});
program.command('list')
.description('List all open pages')
.action(async () => {
const client = await getClient();
const result = await client.listPages();
print(result);
});
program.command('get <id>')
.description('Get page info by ID or alias')
.action(async (id: string) => {
const client = await getClient();
const result = await client.getPage(id);
print(result);
});
program.command('kill <id>')
.description('Close a page by ID or alias')
.action(async (id: string) => {
const client = await getClient();
const result = await client.killPage(id);
print(result);
});
// navigation — page interactions
program.command('navigate <id> <url>')
.description('Navigate an existing page to a URL')
.action(async (id: string, url: string) => {
const client = await getClient();
const result = await client.navigate(id, url);
print(result);
});
program.command('click <id> <selector>')
.description('Click an element on a page')
.action(async (id: string, selector: string) => {
const client = await getClient();
const result = await client.click(id, selector);
print(result);
});
program.command('type <id> <selector> <text>')
.description('Type text into an element')
.action(async (id: string, selector: string, text: string) => {
const client = await getClient();
const result = await client.type(id, selector, text);
print(result);
});
program.command('scroll <id>')
.description('Scroll on a page')
.option('-y, --delta-y <n>', 'Vertical scroll delta', '0')
.option('-b, --to-bottom', 'Scroll to bottom')
.action(async (id: string, options: { deltaY: string; toBottom?: boolean }) => {
const client = await getClient();
const result = await client.scroll(id, {
deltaY: parseInt(options.deltaY, 10) || undefined,
toBottom: options.toBottom,
});
print(result);
});
program.command('eval <id> <code>')
.description('Evaluate JavaScript on a page')
.action(async (id: string, code: string) => {
const client = await getClient();
const result = await client.eval(id, code);
print(result);
});
program.command('wait <id>')
.description('Wait for selector or duration on a page')
.option('-s, --selector <selector>', 'Wait for CSS selector')
.option('-m, --ms <ms>', 'Wait time in milliseconds')
.action(async (id: string, options: { selector?: string; ms?: string }) => {
const client = await getClient();
const result = await client.wait(id, {
selector: options.selector,
ms: options.ms ? parseInt(options.ms, 10) : undefined,
});
print(result);
});
// content — page content retrieval
program.command('screenshot <id>')
.description('Take a screenshot of a page (returns base64)')
.action(async (id: string) => {
const client = await getClient();
const result = await client.screenshot(id);
print(result);
});
program.command('text <id>')
.description('Get the text content of a page')
.action(async (id: string) => {
const client = await getClient();
const result = await client.text(id);
print(result);
});
program.command('html <id>')
.description('Get the HTML source of a page')
.action(async (id: string) => {
const client = await getClient();
const result = await client.html(id);
print(result);
});
// profiles
program.command('profiles')
.description('List available fingerprint profiles')
.action(async () => {
const client = await getClient();
const result = await client.getProfiles();
print(result);
});
program.parse();
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"references": [{ "path": "../core" }, { "path": "../daemon" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@visionl/core",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"typecheck": "tsc -b",
"build": "tsc -b"
}
}
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import { VisionLClient } from '../client.js';
import type { ApiResponse } from '../types/api.js';
import type { PageInfo } from '../types/page.js';
function createMockServer() {
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
const url = new URL(req.url!, 'http://localhost');
if (req.method === 'GET' && url.pathname === '/health') {
res.end(JSON.stringify({ status: 'ok' }));
} else if (req.method === 'POST' && url.pathname === '/pages') {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
const { url: pageUrl, alias } = JSON.parse(body);
res.end(JSON.stringify({
ok: true,
data: { id: 'p_a1b2c3d4', url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'desktop-chrome' }
}));
});
} else if (req.method === 'GET' && url.pathname === '/pages') {
res.end(JSON.stringify({ ok: true, data: [{ id: 'p_test', url: 'https://test.com', title: 'Test', status: 'active' }] }));
} else if (req.method === 'GET' && url.pathname.startsWith('/pages/p_test/text')) {
res.end(JSON.stringify({ ok: true, data: { text: 'Hello World' } }));
} else {
res.statusCode = 404;
res.end(JSON.stringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: 'not found' } }));
}
});
return server;
}
describe('VisionLClient', () => {
let server: http.Server;
let client: VisionLClient;
beforeAll(async () => {
server = createMockServer();
await new Promise<void>((resolve) => server.listen(19527, resolve));
client = new VisionLClient('http://127.0.0.1:19527');
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('health returns true when daemon is up', async () => {
const result = await client.health();
expect(result).toBe(true);
});
it('openPage returns page info', async () => {
const result = await client.openPage('https://example.com', 'demo');
expect(result.ok).toBe(true);
expect(result.data!.id).toBe('p_a1b2c3d4');
expect(result.data!.alias).toBe('demo');
});
it('listPages returns array', async () => {
const result = await client.listPages();
expect(result.ok).toBe(true);
expect(result.data!).toHaveLength(1);
});
it('text returns page content', async () => {
const result = await client.text('p_test');
expect(result.ok).toBe(true);
expect(result.data!.text).toBe('Hello World');
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { safeStringify, isValidJson } from '../escape.js';
describe('safeStringify', () => {
it('returns valid JSON for simple objects', () => {
const result = safeStringify({ ok: true, data: { id: 'p_123' } });
expect(() => JSON.parse(result)).not.toThrow();
expect(JSON.parse(result)).toEqual({ ok: true, data: { id: 'p_123' } });
});
it('escapes double quotes in string values', () => {
const result = safeStringify({ text: 'He said "hello"' });
const parsed = JSON.parse(result);
expect(parsed.text).toBe('He said "hello"');
});
it('escapes backslashes in string values', () => {
const result = safeStringify({ path: 'C:\\Users\\test' });
const parsed = JSON.parse(result);
expect(parsed.path).toBe('C:\\Users\\test');
});
it('escapes control characters (newline, tab)', () => {
const result = safeStringify({ text: 'line1\nline2\tindented' });
const parsed = JSON.parse(result);
expect(parsed.text).toBe('line1\nline2\tindented');
});
it('handles unicode characters', () => {
const result = safeStringify({ text: '你好世界 🌍' });
const parsed = JSON.parse(result);
expect(parsed.text).toBe('你好世界 🌍');
});
it('handles HTML-like content without breaking JSON', () => {
const html = '<div class="main">Hello</div>';
const result = safeStringify({ html });
const parsed = JSON.parse(result);
expect(parsed.html).toBe(html);
});
it('handles empty string and null', () => {
expect(JSON.parse(safeStringify({ a: '' }))).toEqual({ a: '' });
expect(JSON.parse(safeStringify({ a: null }))).toEqual({ a: null });
});
it('handles arrays with special characters', () => {
const result = safeStringify({ items: ['a"b', 'c\\d', 'e\nf'] });
const parsed = JSON.parse(result);
expect(parsed.items).toEqual(['a"b', 'c\\d', 'e\nf']);
});
});
describe('isValidJson', () => {
it('returns true for valid JSON', () => {
expect(isValidJson('{"ok":true}')).toBe(true);
});
it('returns false for invalid JSON', () => {
expect(isValidJson('{ok:true}')).toBe(false);
expect(isValidJson('')).toBe(false);
});
});
+86
View File
@@ -0,0 +1,86 @@
import type { ApiResponse } from './types/api.js';
import type { PageInfo } from './types/page.js';
export class VisionLClient {
constructor(private baseUrl: string) {}
private async request<T>(method: string, path: string, body?: unknown): Promise<ApiResponse<T>> {
const url = `${this.baseUrl}${path}`;
const options: RequestInit = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
const json = await response.json() as ApiResponse<T>;
return json;
}
async health(): Promise<boolean> {
try {
const res = await this.request<{ status: string }>('GET', '/health');
return res.ok || (res.data as any)?.status === 'ok' || (res as any).status === 'ok';
} catch {
return false;
}
}
async openPage(url: string, alias?: string, profile?: string): Promise<ApiResponse<PageInfo>> {
return this.request<PageInfo>('POST', '/pages', { url, alias, profile });
}
async listPages(): Promise<ApiResponse<PageInfo[]>> {
return this.request<PageInfo[]>('GET', '/pages');
}
async getPage(id: string): Promise<ApiResponse<PageInfo>> {
return this.request<PageInfo>('GET', `/pages/${id}`);
}
async killPage(id: string): Promise<ApiResponse<null>> {
return this.request<null>('DELETE', `/pages/${id}`);
}
async navigate(id: string, url: string): Promise<ApiResponse<{ url: string; title: string }>> {
return this.request('POST', `/pages/${id}/navigate`, { url });
}
async click(id: string, selector: string): Promise<ApiResponse<{ success: boolean }>> {
return this.request('POST', `/pages/${id}/click`, { selector });
}
async type(id: string, selector: string, text: string): Promise<ApiResponse<{ success: boolean }>> {
return this.request('POST', `/pages/${id}/type`, { selector, text });
}
async scroll(id: string, opts: { deltaY?: number; toBottom?: boolean }): Promise<ApiResponse<{ success: boolean }>> {
return this.request('POST', `/pages/${id}/scroll`, opts);
}
async eval(id: string, code: string): Promise<ApiResponse<{ result: unknown }>> {
return this.request('POST', `/pages/${id}/eval`, { code });
}
async wait(id: string, opts: { selector?: string; ms?: number }): Promise<ApiResponse<{ success: boolean }>> {
return this.request('POST', `/pages/${id}/wait`, opts);
}
async screenshot(id: string): Promise<ApiResponse<{ base64: string; mime: string }>> {
return this.request('GET', `/pages/${id}/screenshot`);
}
async text(id: string): Promise<ApiResponse<{ text: string }>> {
return this.request('GET', `/pages/${id}/text`);
}
async html(id: string): Promise<ApiResponse<{ html: string }>> {
return this.request('GET', `/pages/${id}/html`);
}
async getProfiles(): Promise<ApiResponse<Array<{ id: string; name: string }>>> {
return this.request('GET', '/profiles');
}
}
+15
View File
@@ -0,0 +1,15 @@
// JSON safe serialization / JSON 安全序列化
// Always use this instead of manual string concatenation for JSON output
export function safeStringify(obj: unknown): string {
return JSON.stringify(obj);
}
export function isValidJson(str: string): boolean {
try {
JSON.parse(str);
return true;
} catch {
return false;
}
}
+12
View File
@@ -0,0 +1,12 @@
export { type PageInfo } from './types/page.js';
export { type ApiResponse, ErrorCode } from './types/api.js';
export { type WsEvent, type ConsoleEntry, type NetworkEntry } from './types/ws.js';
export {
type FingerprintProfile,
type FingerprintPermissions,
type FingerprintBehavior,
type CanvasNoiseConfig,
type PermissionState,
} from './types/fingerprint.js';
export { safeStringify, isValidJson } from './escape.js';
export { VisionLClient } from './client.js';
+18
View File
@@ -0,0 +1,18 @@
// API request/response types / API 请求响应类型
export interface ApiResponse<T = unknown> {
ok: boolean;
data?: T;
error?: {
code: ErrorCode;
message: string;
};
}
export enum ErrorCode {
PAGE_NOT_FOUND = 'PAGE_NOT_FOUND',
DAEMON_UNREACHABLE = 'DAEMON_UNREACHABLE',
INVALID_URL = 'INVALID_URL',
ALIAS_EXISTS = 'ALIAS_EXISTS',
TIMEOUT = 'TIMEOUT',
INTERNAL = 'INTERNAL',
}
+37
View File
@@ -0,0 +1,37 @@
// Fingerprint profile type definitions / 指纹配置类型定义
export type PermissionState = 'prompt' | 'granted' | 'denied';
export interface FingerprintPermissions {
notifications: PermissionState;
geolocation: PermissionState;
camera: PermissionState;
microphone: PermissionState;
}
export interface CanvasNoiseConfig {
enabled: boolean;
strength: number; // 0-1 / 噪声强度 0-1
}
export interface FingerprintBehavior {
mouseMoveDelay: { min: number; max: number };
keyPressDelay: { min: number; max: number };
scrollStepDelay: { min: number; max: number };
}
export interface FingerprintProfile {
id: string;
name: string;
userAgent: string;
platform: string;
languages: string[];
acceptLanguage: string;
screen: { width: number; height: number; colorDepth: number; pixelRatio: number };
viewport: { width: number; height: number };
webgl: { vendor: string; renderer: string };
timezone: string;
geolocation?: { latitude: number; longitude: number; accuracy: number };
permissions: FingerprintPermissions;
behavior: FingerprintBehavior;
canvasNoise: CanvasNoiseConfig;
}
+9
View File
@@ -0,0 +1,9 @@
// Page state type definitions / 页面状态类型定义
export interface PageInfo {
id: string;
url: string;
alias?: string;
title: string;
status: 'active' | 'crashed';
profile: string;
}
+29
View File
@@ -0,0 +1,29 @@
// WebSocket event type definitions / WebSocket 事件类型定义
import type { PageInfo } from './page.js';
export interface ConsoleEntry {
level: string;
text: string;
timestamp: number;
}
export interface NetworkEntry {
type: 'request' | 'response' | 'failed';
url: string;
method?: string;
status?: number;
headers?: Record<string, string>;
failure?: string;
timestamp: number;
}
export type WsEvent =
| { type: 'page:created'; data: PageInfo }
| { type: 'page:closed'; data: { id: string } }
| { type: 'page:navigated'; data: { id: string; url: string; title: string } }
| { type: 'page:crashed'; data: { id: string; error: string } }
| { type: 'page:console'; data: { id: string; level: string; text: string } }
| { type: 'page:network:request'; data: { id: string; url: string; method: string; headers: Record<string, string> } }
| { type: 'page:network:response'; data: { id: string; url: string; status: number; headers: Record<string, string> } }
| { type: 'page:network:failed'; data: { id: string; url: string; failure: string } }
| { type: 'page:detection:warning'; data: { id: string; level: string; detail: string } };
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@visionl/daemon",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"scripts": {
"typecheck": "tsc -b",
"build": "tsc -b",
"start": "node ./dist/index.js"
},
"dependencies": {
"@visionl/core": "*",
"playwright": "^1.52.0",
"playwright-extra": "^4.3.0",
"puppeteer-extra-plugin-stealth": "^2.11.0",
"ws": "^8.21.3"
},
"devDependencies": {
"@types/ws": "^8.0.0"
}
}
@@ -0,0 +1,538 @@
// Action routes integration tests with mock BrowserManager / 操作路由集成测试,使用模拟浏览器管理器
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'node:http';
import { startServer } from '../server.js';
import type { PageInfo } from '@visionl/core';
function createMockBM() {
const pages = new Map<string, PageInfo>();
const aliases = new Map<string, string>();
const cookies: any[] = [];
const mockContext = {
async cookies(): Promise<any[]> {
return [...cookies];
},
async addCookies(cs: any[]): Promise<void> {
for (const c of cs) {
const idx = cookies.findIndex((x: any) => x.name === c.name);
if (idx >= 0) cookies[idx] = c;
else cookies.push(c);
}
},
async clearCookies(): Promise<void> {
cookies.length = 0;
},
};
let lastClicked: string | null = null;
let lastFilled: { selector: string; text: string } | null = null;
let lastScrolled: { deltaY: number; toBottom: boolean } | null = null;
let lastEvaluated: string | null = null;
let lastWaited: { selector?: string; timeout?: number } | null = null;
let lastNavigated: string | null = null;
const mockPage = {
async click(selector: string) {
lastClicked = selector;
},
async fill(selector: string, text: string) {
lastFilled = { selector, text };
},
async evaluate(fnOrStr: any, arg?: any): Promise<any> {
if (typeof fnOrStr === 'function') {
const src = fnOrStr.toString();
if (arg && src.includes('scrollBy')) {
lastScrolled = { deltaY: arg.deltaY || 0, toBottom: arg.toBottom || false };
} else {
lastEvaluated = src;
}
// Call the function with the arg if it's a function
if (arg) {
const mockWindow = { scrollBy: () => {}, scrollTo: () => {} };
try {
return fnOrStr(arg);
} catch {
return null;
}
}
return null;
}
lastEvaluated = String(fnOrStr);
// Evaluate string code
try {
return new Function(`return (${fnOrStr})`)();
} catch {
return null;
}
},
async waitForSelector(selector: string, _opts?: any) {
lastWaited = { selector };
},
async waitForTimeout(ms: number) {
lastWaited = { timeout: ms };
},
async goto(pageUrl: string, _opts?: any) {
lastNavigated = pageUrl;
},
async title(): Promise<string> {
return 'Mock Page Title';
},
};
const mock = {
async createPage(pageUrl: string, alias?: string): Promise<PageInfo> {
if (alias && aliases.has(alias)) {
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
}
try {
new URL(pageUrl);
} catch {
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
}
const id = 'p_' + Math.random().toString(16).slice(2, 10);
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
pages.set(id, info);
if (alias) aliases.set(alias, id);
return info;
},
getPage(idOrAlias: string) {
const id = aliases.get(idOrAlias) || idOrAlias;
const info = pages.get(id);
if (!info) return undefined;
return { info, page: mockPage, context: mockContext };
},
listPages(): PageInfo[] {
return Array.from(pages.values());
},
async closePage(idOrAlias: string): Promise<void> {
const id = aliases.get(idOrAlias) || idOrAlias;
if (!pages.has(id)) {
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
}
const info = pages.get(id)!;
if (info.alias) aliases.delete(info.alias);
pages.delete(id);
},
};
return { mock, pages, aliases, mockPage, mockContext, cookies,
getLastClicked: () => lastClicked,
resetLastClicked: () => { lastClicked = null; },
getLastFilled: () => lastFilled,
resetLastFilled: () => { lastFilled = null; },
getLastScrolled: () => lastScrolled,
resetLastScrolled: () => { lastScrolled = null; },
getLastEvaluated: () => lastEvaluated,
resetLastEvaluated: () => { lastEvaluated = null; },
getLastWaited: () => lastWaited,
resetLastWaited: () => { lastWaited = null; },
getLastNavigated: () => lastNavigated,
resetLastNavigated: () => { lastNavigated = null; },
};
}
describe('action routes', () => {
let server: http.Server;
let baseUrl: string;
const port = 19531;
const mockData = createMockBM();
beforeAll(async () => {
server = await startServer(port, mockData.mock as any);
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
let mockPages: Map<string, PageInfo>;
let mockAliases: Map<string, string>;
function resetMock() {
mockPages = new Map();
mockAliases = new Map();
mockData.cookies.length = 0;
mockData.resetLastClicked();
mockData.resetLastFilled();
mockData.resetLastScrolled();
mockData.resetLastEvaluated();
mockData.resetLastWaited();
mockData.resetLastNavigated();
mockData.mock.createPage = async function (pageUrl: string, alias?: string): Promise<PageInfo> {
if (alias && mockAliases.has(alias)) {
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
}
try {
new URL(pageUrl);
} catch {
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
}
const id = 'p_' + Math.random().toString(16).slice(2, 10);
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
mockPages.set(id, info);
if (alias) mockAliases.set(alias, id);
return info;
};
mockData.mock.getPage = function (idOrAlias: string) {
const id = mockAliases.get(idOrAlias) || idOrAlias;
const info = mockPages.get(id);
if (!info) return undefined;
return { info, page: mockData.mockPage, context: mockData.mockContext };
};
mockData.mock.listPages = function (): PageInfo[] {
return Array.from(mockPages.values());
};
mockData.mock.closePage = async function (idOrAlias: string): Promise<void> {
const id = mockAliases.get(idOrAlias) || idOrAlias;
if (!mockPages.has(id)) {
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
}
const info = mockPages.get(id)!;
if (info.alias) mockAliases.delete(info.alias);
mockPages.delete(id);
};
}
beforeEach(() => {
resetMock();
});
/** Helper: create a page and return its id / 创建页面并返回其 ID */
async function createPage(alias?: string): Promise<string> {
const body: Record<string, string> = { url: 'https://example.com' };
if (alias) body.alias = alias;
const res = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const { data } = await res.json();
return data.id;
}
// ========== POST /pages/:id/click ==========
it('POST /pages/:id/click returns success for valid selector', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/click`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector: '#btn' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.success).toBe(true);
});
it('POST /pages/:id/click returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/click`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector: '#btn' }),
});
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('POST /pages/:id/click accessible by alias', async () => {
await createPage('myAlias');
const res = await fetch(`${baseUrl}/pages/myAlias/click`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector: '#btn' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.success).toBe(true);
});
// ========== POST /pages/:id/type ==========
it('POST /pages/:id/type fills text into selector', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/type`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector: 'input', text: 'hello' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.success).toBe(true);
});
it('POST /pages/:id/type returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/type`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector: 'input', text: 'hello' }),
});
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
// ========== POST /pages/:id/scroll ==========
it('POST /pages/:id/scroll with deltaY succeeds', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/scroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deltaY: 500 }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.success).toBe(true);
});
it('POST /pages/:id/scroll toBottom succeeds', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/scroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toBottom: true }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.success).toBe(true);
});
it('POST /pages/:id/scroll returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/scroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deltaY: 100 }),
});
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
// ========== POST /pages/:id/eval ==========
it('POST /pages/:id/eval returns result', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/eval`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'document.title' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data).toHaveProperty('result');
});
it('POST /pages/:id/eval returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/eval`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'document.title' }),
});
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
// ========== POST /pages/:id/wait ==========
it('POST /pages/:id/wait with selector succeeds', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/wait`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector: '.loaded' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.success).toBe(true);
});
it('POST /pages/:id/wait with timeout-only succeeds', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/wait`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timeout: 500 }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.success).toBe(true);
});
it('POST /pages/:id/wait returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/wait`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector: '.loaded' }),
});
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
// ========== POST /pages/:id/navigate ==========
it('POST /pages/:id/navigate returns url and title', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/navigate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://new-page.com' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.url).toBe('https://new-page.com');
expect(json.data.title).toBe('Mock Page Title');
});
it('POST /pages/:id/navigate returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/navigate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://new-page.com' }),
});
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
// ========== Cookie endpoints / Cookie 端点 ==========
it('GET /pages/:id/cookies returns empty array for new page', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/cookies`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.cookies).toEqual([]);
});
it('GET /pages/:id/cookies returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/cookies`);
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('POST /pages/:id/cookies adds a cookie', async () => {
const id = await createPage();
const addRes = await fetch(`${baseUrl}/pages/${id}/cookies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'session', value: 'abc123' }),
});
expect(addRes.status).toBe(200);
const addJson = await addRes.json();
expect(addJson.ok).toBe(true);
expect(addJson.data.success).toBe(true);
// Verify cookie is returned in GET / 验证 cookie 通过 GET 返回
const getRes = await fetch(`${baseUrl}/pages/${id}/cookies`);
const getJson = await getRes.json();
expect(getJson.data.cookies).toHaveLength(1);
expect(getJson.data.cookies[0].name).toBe('session');
expect(getJson.data.cookies[0].value).toBe('abc123');
});
it('POST /pages/:id/cookies with optional fields succeeds', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/cookies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'auth',
value: 'token123',
domain: '.example.com',
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
}),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.success).toBe(true);
});
it('POST /pages/:id/cookies returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/cookies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'test', value: '1' }),
});
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('DELETE /pages/:id/cookies/:name removes a specific cookie', async () => {
const id = await createPage();
// Add two cookies / 添加两个 cookie
await fetch(`${baseUrl}/pages/${id}/cookies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'keep', value: 'keepVal' }),
});
await fetch(`${baseUrl}/pages/${id}/cookies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'remove', value: 'removeVal' }),
});
// Delete one / 删除一个
const delRes = await fetch(`${baseUrl}/pages/${id}/cookies/remove`, { method: 'DELETE' });
expect(delRes.status).toBe(200);
const delJson = await delRes.json();
expect(delJson.ok).toBe(true);
expect(delJson.data.success).toBe(true);
// Verify only the kept cookie remains / 验证仅保留指定 cookie
const getRes = await fetch(`${baseUrl}/pages/${id}/cookies`);
const getJson = await getRes.json();
expect(getJson.data.cookies).toHaveLength(1);
expect(getJson.data.cookies[0].name).toBe('keep');
});
it('DELETE /pages/:id/cookies/:name returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/cookies/test`, { method: 'DELETE' });
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
// ========== Edge cases / 边界情况 ==========
it('unknown action returns false (falls through to 404)', async () => {
const id = await createPage();
const res = await fetch(`${baseUrl}/pages/${id}/unknownAction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(res.status).toBe(404);
const json = await res.json();
expect(json.ok).toBe(false);
});
});
@@ -0,0 +1,140 @@
// Integration tests for BrowserManager — requires Playwright + Chromium / 浏览器管理器集成测试,需要 Playwright 和 Chromium
import { describe, it, expect } from 'vitest';
import type { FingerprintProfile } from '@visionl/core';
import { BrowserManager } from '../browser-manager.js';
const integration = process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
const defaultProfile: FingerprintProfile = {
id: 'fp_test',
name: 'Test Profile',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
platform: 'Win32',
languages: ['en-US', 'en'],
acceptLanguage: 'en-US,en;q=0.9',
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 },
viewport: { width: 1280, height: 720 },
webgl: { vendor: 'Google Inc.', renderer: 'ANGLE (NVIDIA GeForce RTX 3060)' },
timezone: 'America/New_York',
geolocation: { latitude: 40.7128, longitude: -74.006, accuracy: 10 },
permissions: {
notifications: 'denied',
geolocation: 'granted',
camera: 'denied',
microphone: 'denied',
},
behavior: {
mouseMoveDelay: { min: 50, max: 150 },
keyPressDelay: { min: 80, max: 200 },
scrollStepDelay: { min: 30, max: 100 },
},
canvasNoise: { enabled: true, strength: 0.5 },
};
integration('BrowserManager', () => {
let manager: BrowserManager;
it('init launches browser without error', async () => {
manager = new BrowserManager(defaultProfile);
await manager.init();
// Verify browser reports pages
const pages = manager.listPages();
expect(Array.isArray(pages)).toBe(true);
await manager.cleanup();
});
it('createPage opens a URL and returns PageInfo', async () => {
manager = new BrowserManager(defaultProfile);
await manager.init();
const info = await manager.createPage('https://example.com', 'example');
expect(info.id).toMatch(/^p_/);
expect(info.url).toBe('https://example.com');
expect(info.alias).toBe('example');
expect(info.status).toBe('active');
expect(info.profile).toBe('fp_test');
expect(info.title).toBeTruthy();
await manager.cleanup();
});
it('getPage returns page by id and alias', async () => {
manager = new BrowserManager(defaultProfile);
await manager.init();
const info = await manager.createPage('https://example.com', 'home');
const byId = manager.getPage(info.id);
const byAlias = manager.getPage('home');
expect(byId).toBeDefined();
expect(byAlias).toBeDefined();
expect(byId!.info.id).toBe(byAlias!.info.id);
await manager.cleanup();
});
it('closePage removes the page', async () => {
manager = new BrowserManager(defaultProfile);
await manager.init();
const info = await manager.createPage('https://example.com');
expect(manager.listPages()).toHaveLength(1);
await manager.closePage(info.id);
expect(manager.listPages()).toHaveLength(0);
expect(manager.getPage(info.id)).toBeUndefined();
await manager.cleanup();
});
it('createPage with duplicate alias throws', async () => {
manager = new BrowserManager(defaultProfile);
await manager.init();
await manager.createPage('https://example.com', 'dup');
await expect(
manager.createPage('https://other.com', 'dup')
).rejects.toThrow(/already exists/);
await manager.cleanup();
});
it('createPage with invalid URL throws', async () => {
manager = new BrowserManager(defaultProfile);
await manager.init();
await expect(
manager.createPage('not-a-valid-url')
).rejects.toThrow(/Invalid URL/);
await manager.cleanup();
});
it('closePage with unknown id throws PAGE_NOT_FOUND', async () => {
manager = new BrowserManager(defaultProfile);
await manager.init();
await expect(
manager.closePage('nonexistent')
).rejects.toThrow(/not found/);
await manager.cleanup();
});
it('cleanup closes all pages and browser', async () => {
manager = new BrowserManager(defaultProfile);
await manager.init();
await manager.createPage('https://example.com');
await manager.createPage('https://httpbin.org/get');
expect(manager.listPages()).toHaveLength(2);
await manager.cleanup();
expect(manager.listPages()).toHaveLength(0);
// Should not throw — can call cleanup multiple times
await manager.cleanup();
});
});
@@ -0,0 +1,239 @@
// Content routes integration tests with mock BrowserManager / 内容路由集成测试,使用模拟浏览器管理器
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'node:http';
import { startServer } from '../server.js';
import type { PageInfo } from '@visionl/core';
function createMockBM() {
const pages = new Map<string, PageInfo>();
const aliases = new Map<string, string>();
const mockPage = {
async screenshot(_opts: any): Promise<Buffer> {
return Buffer.from('fake-png-data');
},
async evaluate(fn: any): Promise<any> {
const src = fn.toString();
if (src.includes('body.innerText')) return 'Hello World';
if (src.includes('documentElement.outerHTML')) return '<html><head></head><body>Hello World</body></html>';
return null;
},
};
const mock = {
async createPage(pageUrl: string, alias?: string): Promise<PageInfo> {
if (alias && aliases.has(alias)) {
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
}
try {
new URL(pageUrl);
} catch {
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
}
const id = 'p_' + Math.random().toString(16).slice(2, 10);
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
pages.set(id, info);
if (alias) aliases.set(alias, id);
return info;
},
getPage(idOrAlias: string) {
const id = aliases.get(idOrAlias) || idOrAlias;
const info = pages.get(id);
if (!info) return undefined;
return { info, page: mockPage, context: null };
},
listPages(): PageInfo[] {
return Array.from(pages.values());
},
async closePage(idOrAlias: string): Promise<void> {
const id = aliases.get(idOrAlias) || idOrAlias;
if (!pages.has(id)) {
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
}
const info = pages.get(id)!;
if (info.alias) aliases.delete(info.alias);
pages.delete(id);
},
};
return { mock, pages, aliases, mockPage };
}
describe('content routes', () => {
let server: http.Server;
let baseUrl: string;
const port = 19530;
const { mock } = createMockBM();
beforeAll(async () => {
server = await startServer(port, mock as any);
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
let mockPages: Map<string, PageInfo>;
let mockAliases: Map<string, string>;
function resetMock() {
mockPages = new Map();
mockAliases = new Map();
mock.createPage = async function (pageUrl: string, alias?: string): Promise<PageInfo> {
if (alias && mockAliases.has(alias)) {
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
}
try {
new URL(pageUrl);
} catch {
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
}
const id = 'p_' + Math.random().toString(16).slice(2, 10);
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
mockPages.set(id, info);
if (alias) mockAliases.set(alias, id);
return info;
};
mock.getPage = function (idOrAlias: string) {
const id = mockAliases.get(idOrAlias) || idOrAlias;
const info = mockPages.get(id);
if (!info) return undefined;
return {
info,
page: {
async screenshot(_opts: any): Promise<Buffer> {
return Buffer.from('fake-png-data');
},
async evaluate(fn: any): Promise<any> {
const src = fn.toString();
if (src.includes('body.innerText')) return 'Hello World';
if (src.includes('documentElement.outerHTML')) return '<html><head></head><body>Hello World</body></html>';
return null;
},
},
context: null,
};
};
mock.listPages = function (): PageInfo[] {
return Array.from(mockPages.values());
};
mock.closePage = async function (idOrAlias: string): Promise<void> {
const id = mockAliases.get(idOrAlias) || idOrAlias;
if (!mockPages.has(id)) {
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
}
const info = mockPages.get(id)!;
if (info.alias) mockAliases.delete(info.alias);
mockPages.delete(id);
};
}
beforeEach(() => {
resetMock();
});
it('GET /pages/:id/screenshot returns base64 png', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
const { data: created } = await createRes.json();
const res = await fetch(`${baseUrl}/pages/${created.id}/screenshot`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.base64).toBe('ZmFrZS1wbmctZGF0YQ=='); // base64 of 'fake-png-data'
expect(json.data.mime).toBe('image/png');
});
it('GET /pages/:id/text returns body text', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
const { data: created } = await createRes.json();
const res = await fetch(`${baseUrl}/pages/${created.id}/text`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.text).toBe('Hello World');
});
it('GET /pages/:id/html returns full HTML', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
const { data: created } = await createRes.json();
const res = await fetch(`${baseUrl}/pages/${created.id}/html`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.html).toBe('<html><head></head><body>Hello World</body></html>');
});
it('GET /pages/:id/screenshot returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/screenshot`);
expect(res.status).toBe(404);
const json = await res.json();
expect(json.ok).toBe(false);
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('GET /pages/:id/text returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/text`);
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('GET /pages/:id/html returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent/html`);
expect(res.status).toBe(404);
const json = await res.json();
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('screenshot accessible by alias', async () => {
await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', alias: 'home' }),
});
const res = await fetch(`${baseUrl}/pages/home/screenshot`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.base64).toBe('ZmFrZS1wbmctZGF0YQ==');
});
it('unknown content action returns false (falls through to 404)', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
const { data: created } = await createRes.json();
const res = await fetch(`${baseUrl}/pages/${created.id}/unknown`);
expect(res.status).toBe(404);
const json = await res.json();
expect(json.ok).toBe(false);
});
});
@@ -0,0 +1,23 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import { startServer } from '../server.js';
describe('daemon health endpoint', () => {
let server: http.Server;
const port = 19528;
beforeAll(async () => {
server = await startServer(port);
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('GET /health returns 200 with status ok', async () => {
const response = await fetch(`http://127.0.0.1:${port}/health`);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual({ status: 'ok' });
});
});
@@ -0,0 +1,113 @@
// unit tests for PageRegistry — no Playwright browser required / 页面注册表单元测试,无需 Playwright 浏览器
import { describe, it, expect, beforeEach } from 'vitest';
import type { PageInfo } from '@visionl/core';
import { PageRegistry, type RegisteredPage } from '../page-registry.js';
function makeInfo(overrides: Partial<PageInfo> = {}): PageInfo {
return {
id: 'p_test1',
url: 'https://example.com',
title: 'Example',
status: 'active',
profile: 'fp_default',
...overrides,
};
}
function makeMockPage() {
return {
url: () => 'https://example.com',
title: () => Promise.resolve('Example'),
close: () => Promise.resolve(),
} as any;
}
function makeMockContext() {
return {
close: () => Promise.resolve(),
newPage: () => Promise.resolve(makeMockPage()),
} as any;
}
describe('PageRegistry', () => {
let registry: PageRegistry;
beforeEach(() => {
registry = new PageRegistry();
});
it('add stores entry and can be retrieved via get', () => {
const info = makeInfo();
const page = makeMockPage();
const context = makeMockContext();
registry.add(info, page, context);
const entry = registry.get('p_test1');
expect(entry).toBeDefined();
expect(entry!.info).toEqual(info);
expect(entry!.page).toBe(page);
expect(entry!.context).toBe(context);
});
it('add with alias makes it findable by alias', () => {
const info = makeInfo({ alias: 'home' });
const page = makeMockPage();
const context = makeMockContext();
registry.add(info, page, context);
const entry = registry.findByIdOrAlias('home');
expect(entry).toBeDefined();
expect(entry!.info.id).toBe('p_test1');
});
it('add with alias records hasAlias as true', () => {
const info = makeInfo({ alias: 'home' });
registry.add(info, makeMockPage(), makeMockContext());
expect(registry.hasAlias('home')).toBe(true);
expect(registry.hasAlias('nonexistent')).toBe(false);
});
it('findByIdOrAlias works with direct id', () => {
const info = makeInfo();
registry.add(info, makeMockPage(), makeMockContext());
const entry = registry.findByIdOrAlias('p_test1');
expect(entry).toBeDefined();
expect(entry!.info.title).toBe('Example');
});
it('findByIdOrAlias returns undefined for unknown id or alias', () => {
expect(registry.findByIdOrAlias('unknown')).toBeUndefined();
});
it('remove deletes the entry and its alias, returns true', () => {
const info = makeInfo({ alias: 'home' });
registry.add(info, makeMockPage(), makeMockContext());
const result = registry.remove('p_test1');
expect(result).toBe(true);
expect(registry.get('p_test1')).toBeUndefined();
expect(registry.hasAlias('home')).toBe(false);
expect(registry.findByIdOrAlias('home')).toBeUndefined();
});
it('remove returns false for non-existent id', () => {
const result = registry.remove('no-such-id');
expect(result).toBe(false);
});
it('list returns all page infos', () => {
const a = makeInfo({ id: 'p_a', url: 'https://a.com' });
const b = makeInfo({ id: 'p_b', url: 'https://b.com' });
registry.add(a, makeMockPage(), makeMockContext());
registry.add(b, makeMockPage(), makeMockContext());
const pages = registry.list();
expect(pages).toHaveLength(2);
expect(pages.map((p) => p.id)).toEqual(expect.arrayContaining(['p_a', 'p_b']));
});
});
@@ -0,0 +1,291 @@
// Page CRUD routes integration tests with mock BrowserManager / 页面 CRUD 路由集成测试,使用模拟浏览器管理器
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'node:http';
import { startServer } from '../server.js';
import type { PageInfo } from '@visionl/core';
// Mock BrowserManager — mimics BrowserManager behavior without Playwright / 模拟浏览器管理器
function createMockBM() {
const pages = new Map<string, PageInfo>();
const aliases = new Map<string, string>();
const mock = {
async createPage(pageUrl: string, alias?: string): Promise<PageInfo> {
if (alias && aliases.has(alias)) {
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
}
try {
new URL(pageUrl);
} catch {
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
}
const id = 'p_' + Math.random().toString(16).slice(2, 10);
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
pages.set(id, info);
if (alias) aliases.set(alias, id);
return info;
},
getPage(idOrAlias: string) {
const id = aliases.get(idOrAlias) || idOrAlias;
const info = pages.get(id);
if (!info) return undefined;
return { info, page: null, context: null };
},
listPages(): PageInfo[] {
return Array.from(pages.values());
},
async closePage(idOrAlias: string): Promise<void> {
const id = aliases.get(idOrAlias) || idOrAlias;
if (!pages.has(id)) {
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
}
const info = pages.get(id)!;
if (info.alias) aliases.delete(info.alias);
pages.delete(id);
},
};
return { mock, pages };
}
describe('page CRUD routes', () => {
let server: http.Server;
let baseUrl: string;
const port = 19529;
const { mock } = createMockBM();
beforeAll(async () => {
server = await startServer(port, mock as any);
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
// Each test gets a fresh mock state via re-creating mock internals / 每个测试用例使用全新模拟状态
let mockPages: Map<string, PageInfo>;
let mockAliases: Map<string, string>;
function resetMock() {
mockPages = new Map();
mockAliases = new Map();
mock.createPage = async function (pageUrl: string, alias?: string): Promise<PageInfo> {
if (alias && mockAliases.has(alias)) {
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
}
try {
new URL(pageUrl);
} catch {
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
}
const id = 'p_' + Math.random().toString(16).slice(2, 10);
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
mockPages.set(id, info);
if (alias) mockAliases.set(alias, id);
return info;
};
mock.getPage = function (idOrAlias: string) {
const id = mockAliases.get(idOrAlias) || idOrAlias;
const info = mockPages.get(id);
if (!info) return undefined;
return { info, page: null, context: null };
};
mock.listPages = function (): PageInfo[] {
return Array.from(mockPages.values());
};
mock.closePage = async function (idOrAlias: string): Promise<void> {
const id = mockAliases.get(idOrAlias) || idOrAlias;
if (!mockPages.has(id)) {
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
}
const info = mockPages.get(id)!;
if (info.alias) mockAliases.delete(info.alias);
mockPages.delete(id);
};
}
// Reset mock state before each test / 每个测试前重置模拟状态
beforeEach(() => {
resetMock();
});
it('POST /pages creates a page and returns 200', async () => {
const res = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', alias: 'test' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.id).toMatch(/^p_/);
expect(json.data.url).toBe('https://example.com');
expect(json.data.alias).toBe('test');
expect(json.data.status).toBe('active');
});
it('POST /pages with alias creates page accessible by alias', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', alias: 'home' }),
});
const createJson = await createRes.json();
const getRes = await fetch(`${baseUrl}/pages/home`);
expect(getRes.status).toBe(200);
const getJson = await getRes.json();
expect(getJson.data.id).toBe(createJson.data.id);
expect(getJson.data.alias).toBe('home');
});
it('POST /pages without alias works', async () => {
const res = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.alias).toBeUndefined();
});
it('POST /pages with invalid URL returns 400', async () => {
const res = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'not-a-valid-url' }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json.ok).toBe(false);
expect(json.error.code).toBe('INVALID_URL');
});
it('POST /pages with duplicate alias returns 409', async () => {
await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', alias: 'dup' }),
});
const res = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://other.com', alias: 'dup' }),
});
expect(res.status).toBe(409);
const json = await res.json();
expect(json.ok).toBe(false);
expect(json.error.code).toBe('ALIAS_EXISTS');
});
it('GET /pages lists all created pages', async () => {
await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://other.com' }),
});
const res = await fetch(`${baseUrl}/pages`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data).toHaveLength(2);
});
it('GET /pages returns empty array when no pages', async () => {
const res = await fetch(`${baseUrl}/pages`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data).toEqual([]);
});
it('GET /pages/:id returns page by ID', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
const { data: created } = await createRes.json();
const res = await fetch(`${baseUrl}/pages/${created.id}`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.id).toBe(created.id);
expect(json.data.url).toBe('https://example.com');
});
it('GET /pages/:id returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent`);
expect(res.status).toBe(404);
const json = await res.json();
expect(json.ok).toBe(false);
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('DELETE /pages/:id removes page and returns 200', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
const { data: created } = await createRes.json();
const deleteRes = await fetch(`${baseUrl}/pages/${created.id}`, { method: 'DELETE' });
expect(deleteRes.status).toBe(200);
const deleteJson = await deleteRes.json();
expect(deleteJson.ok).toBe(true);
expect(deleteJson.data).toBeNull();
// Verify page is gone
const getRes = await fetch(`${baseUrl}/pages/${created.id}`);
expect(getRes.status).toBe(404);
});
it('DELETE /pages/:id returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent`, { method: 'DELETE' });
expect(res.status).toBe(404);
const json = await res.json();
expect(json.ok).toBe(false);
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('DELETE /pages by alias works', async () => {
await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', alias: 'myalias' }),
});
const deleteRes = await fetch(`${baseUrl}/pages/myalias`, { method: 'DELETE' });
expect(deleteRes.status).toBe(200);
const getRes = await fetch(`${baseUrl}/pages/myalias`);
expect(getRes.status).toBe(404);
});
it('health check still works alongside page routes', async () => {
const res = await fetch(`${baseUrl}/health`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ status: 'ok' });
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { writePidfile, readPidfile, cleanPidfile, isProcessAlive } from '../pidfile.js';
describe('pidfile management', () => {
let testDir: string;
beforeEach(() => {
testDir = path.join(os.tmpdir(), `visionl-pidfile-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
// clean up before in case previous run left files
try { fs.rmSync(testDir, { recursive: true, force: true }); } catch { /* ignore */ }
fs.mkdirSync(testDir, { recursive: true });
});
it('writePidfile writes pid and port files, readPidfile reads them back', () => {
writePidfile(12345, 9527, testDir);
const result = readPidfile(testDir);
expect(result).toEqual({ pid: 12345, port: 9527 });
});
it('readPidfile returns null when pidfile does not exist', () => {
const result = readPidfile(testDir);
expect(result).toBeNull();
});
it('readPidfile returns null when pidfile has invalid content', () => {
writePidfile(12345, 9527, testDir);
// overwrite with invalid data
fs.writeFileSync(path.join(testDir, 'daemon.pid'), 'not-a-number');
const result = readPidfile(testDir);
expect(result).toBeNull();
});
it('cleanPidfile removes both pid and port files', () => {
writePidfile(12345, 9527, testDir);
cleanPidfile(testDir);
expect(fs.existsSync(path.join(testDir, 'daemon.pid'))).toBe(false);
expect(fs.existsSync(path.join(testDir, 'daemon.port'))).toBe(false);
});
it('cleanPidfile is safe when files do not exist', () => {
// should not throw
expect(() => cleanPidfile(testDir)).not.toThrow();
});
it('isProcessAlive returns true for the current process', () => {
expect(isProcessAlive(process.pid)).toBe(true);
});
it('isProcessAlive returns false for a non-existent PID', () => {
expect(isProcessAlive(99999999)).toBe(false);
});
});
@@ -0,0 +1,317 @@
// Integration tests for injectCanvasNoise / Canvas/WebGL/Audio 噪声注入集成测试
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import type { Browser, BrowserContext } from 'playwright';
import type { CanvasNoiseConfig } from '@visionl/core';
import { injectCanvasNoise } from '../stealth/canvas-noise.js';
chromium.use(StealthPlugin());
const integration =
process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
const enabledConfig: CanvasNoiseConfig = { enabled: true, strength: 0.5 };
const disabledConfig: CanvasNoiseConfig = { enabled: false, strength: 0.5 };
/** Helper: draw fingerprinting canvas and return toDataURL hash */
async function canvasFingerprint(page: import('playwright').Page): Promise<string> {
return page.evaluate(() => {
const canvas = document.createElement('canvas');
canvas.width = 280;
canvas.height = 60;
const ctx = canvas.getContext('2d')!;
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#069';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(102, 204, 0, 0.9)';
ctx.fillText('VisionL 🔒 2026', 4, 17);
ctx.fillStyle = '#f60';
ctx.fillRect(60, 20, 80, 10);
return canvas.toDataURL();
});
}
integration('injectCanvasNoise', () => {
let browser: Browser;
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (browser) await browser.close();
});
// ────────── enabled = false: baseline / 基线 ──────────
it('should NOT alter canvas output when enabled=false', async () => {
const ctx1 = await browser.newContext();
await injectCanvasNoise(ctx1, disabledConfig);
const page1 = await ctx1.newPage();
await page1.goto('about:blank');
const result1 = await canvasFingerprint(page1);
const ctx2 = await browser.newContext();
// No injection at all / 无注入
const page2 = await ctx2.newPage();
await page2.goto('about:blank');
const result2 = await canvasFingerprint(page2);
// Without noise, canvas output should be deterministic (same renderer)
// 无噪声时 canvas 输出应一致(相同渲染器)
expect(result1).toBe(result2);
await ctx1.close();
await ctx2.close();
});
// ────────── enabled = true: noise differs from baseline ──────────
it('should alter canvas output when enabled=true', async () => {
const ctxNoisy = await browser.newContext();
await injectCanvasNoise(ctxNoisy, enabledConfig);
const pageNoisy = await ctxNoisy.newPage();
await pageNoisy.goto('about:blank');
const noised = await canvasFingerprint(pageNoisy);
const ctxBaseline = await browser.newContext();
const pageBaseline = await ctxBaseline.newPage();
await pageBaseline.goto('about:blank');
const baseline = await canvasFingerprint(pageBaseline);
expect(noised).not.toBe(baseline);
await ctxNoisy.close();
await ctxBaseline.close();
});
// ────────── seed consistency: same context → same noise pattern ──────────
it('should produce consistent noise within the same context (seed consistency)', async () => {
const ctx = await browser.newContext();
await injectCanvasNoise(ctx, enabledConfig);
const page = await ctx.newPage();
await page.goto('about:blank');
// Two calls should produce identical results
// 两次调用应产生相同结果
const result1 = await canvasFingerprint(page);
const result2 = await canvasFingerprint(page);
expect(result1).toBe(result2);
await ctx.close();
});
// ────────── different contexts → different noise (different seeds) ──────────
it('should produce different noise in different contexts (different seeds)', async () => {
const ctx1 = await browser.newContext();
await injectCanvasNoise(ctx1, enabledConfig);
const page1 = await ctx1.newPage();
await page1.goto('about:blank');
const result1 = await canvasFingerprint(page1);
const ctx2 = await browser.newContext();
await injectCanvasNoise(ctx2, enabledConfig);
const page2 = await ctx2.newPage();
await page2.goto('about:blank');
const result2 = await canvasFingerprint(page2);
// Different seeds should produce different results
// 不同种子应产生不同结果
expect(result1).not.toBe(result2);
await ctx1.close();
await ctx2.close();
});
// ────────── getImageData noise ──────────
it('should add noise to getImageData output', async () => {
const ctxNoisy = await browser.newContext();
await injectCanvasNoise(ctxNoisy, enabledConfig);
const pageNoisy = await ctxNoisy.newPage();
await pageNoisy.goto('about:blank');
const ctxBaseline = await browser.newContext();
const pageBaseline = await ctxBaseline.newPage();
await pageBaseline.goto('about:blank');
const getImageDataHash = async (p: import('playwright').Page) =>
p.evaluate(() => {
const canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 100;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = 'blue';
ctx.fillRect(0, 0, 50, 50);
ctx.fillStyle = 'red';
ctx.fillRect(50, 50, 50, 50);
const imageData = ctx.getImageData(0, 0, 100, 100);
// Hash the pixel data
let hash = 0;
for (let i = 0; i < imageData.data.length; i++) {
hash = ((hash << 5) - hash + imageData.data[i]) | 0;
}
return hash;
});
const noisyHash = await getImageDataHash(pageNoisy);
const baselineHash = await getImageDataHash(pageBaseline);
expect(noisyHash).not.toBe(baselineHash);
await ctxNoisy.close();
await ctxBaseline.close();
});
// ────────── WebGL readPixels noise ──────────
it('should add noise to WebGL readPixels output', async () => {
const ctxNoisy = await browser.newContext();
await injectCanvasNoise(ctxNoisy, enabledConfig);
const pageNoisy = await ctxNoisy.newPage();
await pageNoisy.goto('about:blank');
const ctxBaseline = await browser.newContext();
const pageBaseline = await ctxBaseline.newPage();
await pageBaseline.goto('about:blank');
const webglHash = async (p: import('playwright').Page): Promise<number | null> =>
p.evaluate(() => {
try {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const gl = canvas.getContext('webgl');
if (!gl) return null;
gl.clearColor(0.1, 0.2, 0.3, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
const pixels = new Uint8Array(32 * 32 * 4);
gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let hash = 0;
for (let i = 0; i < pixels.length; i++) {
hash = ((hash << 5) - hash + pixels[i]) | 0;
}
return hash;
} catch {
return null;
}
});
const noisyHash = await webglHash(pageNoisy);
const baselineHash = await webglHash(pageBaseline);
if (noisyHash !== null && baselineHash !== null) {
expect(noisyHash).not.toBe(baselineHash);
}
await ctxNoisy.close();
await ctxBaseline.close();
});
// ────────── AudioContext noise ──────────
it('should add noise to AudioContext AnalyserNode output', async () => {
const ctxNoisy = await browser.newContext();
await injectCanvasNoise(ctxNoisy, enabledConfig);
const pageNoisy = await ctxNoisy.newPage();
await pageNoisy.goto('about:blank');
const ctxBaseline = await browser.newContext();
const pageBaseline = await ctxBaseline.newPage();
await pageBaseline.goto('about:blank');
const audioHash = async (p: import('playwright').Page): Promise<number | null> =>
p.evaluate((): number | null => {
try {
const AudioCtx = (window as any).AudioContext || (window as any).webkitAudioContext;
if (!AudioCtx) return null;
const audioCtx = new AudioCtx();
const oscillator = audioCtx.createOscillator();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
oscillator.connect(analyser);
const data = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(data);
let hash = 0;
for (let i = 0; i < data.length; i++) {
// Quantize float to int for hashing / 浮点量化后哈希
hash = ((hash << 5) - hash + (data[i] * 1000) | 0) | 0;
}
oscillator.disconnect();
audioCtx.close();
return hash;
} catch {
return null;
}
});
const noisyHash = await audioHash(pageNoisy);
const baselineHash = await audioHash(pageBaseline);
if (noisyHash !== null && baselineHash !== null) {
expect(noisyHash).not.toBe(baselineHash);
}
await ctxNoisy.close();
await ctxBaseline.close();
});
// ────────── strength = 0 should not alter anything ──────────
it('should not alter canvas when strength=0', async () => {
const ctxZero = await browser.newContext();
await injectCanvasNoise(ctxZero, { enabled: true, strength: 0 });
const pageZero = await ctxZero.newPage();
await pageZero.goto('about:blank');
const resultZero = await canvasFingerprint(pageZero);
const ctxBaseline = await browser.newContext();
const pageBaseline = await ctxBaseline.newPage();
await pageBaseline.goto('about:blank');
const resultBaseline = await canvasFingerprint(pageBaseline);
expect(resultZero).toBe(resultBaseline);
await ctxZero.close();
await ctxBaseline.close();
});
// ────────── toBlob noise ──────────
it('should add noise to toBlob output', async () => {
const ctxNoisy = await browser.newContext();
await injectCanvasNoise(ctxNoisy, enabledConfig);
const pageNoisy = await ctxNoisy.newPage();
await pageNoisy.goto('about:blank');
const ctxBaseline = await browser.newContext();
const pageBaseline = await ctxBaseline.newPage();
await pageBaseline.goto('about:blank');
const blobHash = async (p: import('playwright').Page): Promise<string> =>
p.evaluate((): Promise<string> => {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 60;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#069';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(102, 204, 0, 0.9)';
ctx.fillText('Test', 4, 17);
canvas.toBlob((blob) => {
if (!blob) { resolve('null'); return; }
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
});
});
const noisyResult = await blobHash(pageNoisy);
const baselineResult = await blobHash(pageBaseline);
expect(noisyResult).not.toBe(baselineResult);
await ctxNoisy.close();
await ctxBaseline.close();
});
});
@@ -0,0 +1,290 @@
// Integration tests for chrome-runtime, screen, and permissions stealth modules / chrome-runtime、screen、permissions 隐身模块集成测试
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import type { Browser, BrowserContext } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
import { injectChromeRuntime } from '../stealth/chrome-runtime.js';
import { injectScreenStealth } from '../stealth/screen.js';
import { injectPermissionsStealth } from '../stealth/permissions.js';
chromium.use(StealthPlugin());
const integration = process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
const testProfile: FingerprintProfile = {
id: 'fp_stealth_chrome_screen',
name: 'Stealth Chrome+Screen+Permissions Test',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
platform: 'Win32',
languages: ['en-US', 'en'],
acceptLanguage: 'en-US,en;q=0.9',
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 },
viewport: { width: 1280, height: 720 },
webgl: { vendor: 'Google Inc.', renderer: 'ANGLE (NVIDIA GeForce RTX 3060)' },
timezone: 'America/New_York',
permissions: {
notifications: 'denied',
geolocation: 'granted',
camera: 'denied',
microphone: 'denied',
},
behavior: {
mouseMoveDelay: { min: 50, max: 150 },
keyPressDelay: { min: 80, max: 200 },
scrollStepDelay: { min: 30, max: 100 },
},
canvasNoise: { enabled: true, strength: 0.5 },
};
// ==================== chrome-runtime / Chrome 运行时注入测试 ====================
integration('injectChromeRuntime', () => {
let browser: Browser;
let context: BrowserContext;
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (context) await context.close();
if (browser) await browser.close();
});
it('should set typeof window.chrome to "object"', async () => {
context = await browser.newContext();
await injectChromeRuntime(context);
const page = await context.newPage();
await page.goto('about:blank');
const result = await page.evaluate(() => typeof (window as any).chrome);
expect(result).toBe('object');
});
it('should have chrome.runtime defined', async () => {
context = await browser.newContext();
await injectChromeRuntime(context);
const page = await context.newPage();
await page.goto('about:blank');
const hasRuntime = await page.evaluate(() => !!(window as any).chrome.runtime);
expect(hasRuntime).toBe(true);
});
it('should have chrome.loadTimes defined as function', async () => {
context = await browser.newContext();
await injectChromeRuntime(context);
const page = await context.newPage();
await page.goto('about:blank');
const loadTimesType = await page.evaluate(() => typeof (window as any).chrome.loadTimes);
expect(loadTimesType).toBe('function');
});
it('should have chrome.csi defined as function', async () => {
context = await browser.newContext();
await injectChromeRuntime(context);
const page = await context.newPage();
await page.goto('about:blank');
const csiType = await page.evaluate(() => typeof (window as any).chrome.csi);
expect(csiType).toBe('function');
});
it('should have chrome.app defined as object', async () => {
context = await browser.newContext();
await injectChromeRuntime(context);
const page = await context.newPage();
await page.goto('about:blank');
const appType = await page.evaluate(() => typeof (window as any).chrome.app);
expect(appType).toBe('object');
});
it('should have chrome.app.isInstalled === false', async () => {
context = await browser.newContext();
await injectChromeRuntime(context);
const page = await context.newPage();
await page.goto('about:blank');
const isInstalled = await page.evaluate(() => (window as any).chrome.app.isInstalled);
expect(isInstalled).toBe(false);
});
});
// ==================== screen / 屏幕属性隐身测试 ====================
integration('injectScreenStealth', () => {
let browser: Browser;
let context: BrowserContext;
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (context) await context.close();
if (browser) await browser.close();
});
it('should set screen.width from profile', async () => {
context = await browser.newContext({ viewport: testProfile.viewport });
await injectScreenStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const width = await page.evaluate(() => screen.width);
expect(width).toBe(testProfile.screen.width);
});
it('should set screen.height from profile', async () => {
context = await browser.newContext({ viewport: testProfile.viewport });
await injectScreenStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const height = await page.evaluate(() => screen.height);
expect(height).toBe(testProfile.screen.height);
});
it('should set screen.availHeight < screen.height (taskbar subtracted)', async () => {
context = await browser.newContext({ viewport: testProfile.viewport });
await injectScreenStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const { height, availHeight } = await page.evaluate(() => ({
height: screen.height,
availHeight: screen.availHeight,
}));
expect(availHeight).toBeLessThan(height);
});
it('should set screen.colorDepth from profile', async () => {
context = await browser.newContext({ viewport: testProfile.viewport });
await injectScreenStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const colorDepth = await page.evaluate(() => screen.colorDepth);
expect(colorDepth).toBe(testProfile.screen.colorDepth);
});
it('should set window.outerWidth > window.innerWidth (decorations)', async () => {
context = await browser.newContext({ viewport: testProfile.viewport });
await injectScreenStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const { outer, inner } = await page.evaluate(() => ({
outer: window.outerWidth,
inner: window.innerWidth,
}));
expect(outer).toBeGreaterThan(inner);
});
it('should set window.outerHeight > window.innerHeight (decorations)', async () => {
context = await browser.newContext({ viewport: testProfile.viewport });
await injectScreenStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const { outer, inner } = await page.evaluate(() => ({
outer: window.outerHeight,
inner: window.innerHeight,
}));
expect(outer).toBeGreaterThan(inner);
});
});
// ==================== permissions / 权限查询隐身测试 ====================
integration('injectPermissionsStealth', () => {
let browser: Browser;
let context: BrowserContext;
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (context) await context.close();
if (browser) await browser.close();
});
it('should override notifications permission from profile', async () => {
context = await browser.newContext();
await injectPermissionsStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const status = await page.evaluate(async () => {
const result = await navigator.permissions.query({ name: 'notifications' });
return result.state;
});
expect(status).toBe(testProfile.permissions.notifications);
});
it('should override geolocation permission from profile', async () => {
context = await browser.newContext();
await injectPermissionsStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const status = await page.evaluate(async () => {
const result = await navigator.permissions.query({ name: 'geolocation' });
return result.state;
});
expect(status).toBe(testProfile.permissions.geolocation);
});
it('should override camera permission from profile', async () => {
context = await browser.newContext();
await injectPermissionsStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const status = await page.evaluate(async () => {
const result = await navigator.permissions.query({ name: 'camera' });
return result.state;
});
expect(status).toBe(testProfile.permissions.camera);
});
it('should override microphone permission from profile', async () => {
context = await browser.newContext();
await injectPermissionsStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const status = await page.evaluate(async () => {
const result = await navigator.permissions.query({ name: 'microphone' });
return result.state;
});
expect(status).toBe(testProfile.permissions.microphone);
});
it('should not intercept unknown permission names', async () => {
context = await browser.newContext();
await injectPermissionsStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const shouldThrow = await page.evaluate(async () => {
try {
await navigator.permissions.query({ name: 'unknown-perm' as any });
return false;
} catch {
return true;
}
});
expect(shouldThrow).toBe(true);
});
});
@@ -0,0 +1,171 @@
// Integration tests for injectHeaderStealth / 隐身请求头注入集成测试
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import http from 'http';
import os from 'os';
import type { AddressInfo } from 'net';
import type { Browser, BrowserContext, Page } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
import { injectHeaderStealth } from '../stealth/headers.js';
chromium.use(StealthPlugin());
const integration = process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
function getNonLoopbackIPv4(): string | null {
const nets = os.networkInterfaces();
for (const name of Object.keys(nets)) {
const iface = nets[name];
if (!iface) continue;
for (const net of iface) {
if (net.family === 'IPv4' && !net.internal) {
return net.address;
}
}
}
return null;
}
const testProfile: FingerprintProfile = {
id: 'fp_stealth_hdr',
name: 'Stealth Headers Test',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
platform: 'Win32',
languages: ['en-US', 'en'],
acceptLanguage: 'en-US,en;q=0.9',
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 },
viewport: { width: 1280, height: 720 },
webgl: { vendor: 'Google Inc.', renderer: 'ANGLE (NVIDIA GeForce RTX 3060)' },
timezone: 'America/New_York',
permissions: {
notifications: 'denied',
geolocation: 'granted',
camera: 'denied',
microphone: 'denied',
},
behavior: {
mouseMoveDelay: { min: 50, max: 150 },
keyPressDelay: { min: 80, max: 200 },
scrollStepDelay: { min: 30, max: 100 },
},
canvasNoise: { enabled: true, strength: 0.5 },
};
integration('injectHeaderStealth', () => {
let browser: Browser;
let context: BrowserContext;
let page: Page;
let server: http.Server;
let capturedHeaders: Record<string, string | string[] | undefined> = {};
let serverUrl: string;
beforeAll(async () => {
server = http.createServer((req, res) => {
capturedHeaders = { ...req.headers };
if (req.url === '/check') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(capturedHeaders));
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!DOCTYPE html><html><body>
<script>
fetch('/check')
.then(function(r) { return r.json(); })
.then(function(h) { document.title = JSON.stringify(h); })
.catch(function(e) { document.title = 'ERROR:' + e.message; });
</script>
</body></html>`);
}
});
await new Promise<void>((resolve) => {
// Bind to 0.0.0.0 so the browser can reach it from any non-loopback IP
server.listen(0, '0.0.0.0', resolve);
});
const port = (server.address() as AddressInfo).port;
// Find a non-loopback IP so that the URL does not start with http://127.0.0.1
// or http://localhost (those are explicitly skipped by injectHeaderStealth).
const nonLoopback = getNonLoopbackIPv4();
if (nonLoopback) {
serverUrl = `http://${nonLoopback}:${port}`;
} else {
serverUrl = `http://127.0.0.1:${port}`;
}
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (page) await page.close();
if (context) await context.close();
if (browser) await browser.close();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('should inject sec-ch-ua headers on outgoing requests', async () => {
context = await browser.newContext();
page = await context.newPage();
await injectHeaderStealth(page, testProfile);
// Navigate to our test page which fetches /check
await page.goto(serverUrl);
// Wait for the fetch to finish and the title to be updated
await page.waitForFunction(
() => document.title.startsWith('{') || document.title.startsWith('ERROR'),
{ timeout: 10_000 },
);
const title = await page.title();
if (title.startsWith('ERROR')) {
throw new Error(`Test page fetch failed: ${title}`);
}
const headers = JSON.parse(title) as Record<string, string | string[] | undefined>;
// sec-ch-ua should contain browser brand hints
const chUa = String(headers['sec-ch-ua'] ?? '');
expect(chUa).toContain('"Chromium"');
expect(chUa).toContain('"Google Chrome"');
expect(chUa).toContain('"Not?A_Brand"');
// sec-ch-ua-platform should match the profile platform ("Win32" → "Windows")
expect(headers['sec-ch-ua-platform']).toBe('"Windows"');
// sec-ch-ua-mobile should be ?0 (desktop)
expect(headers['sec-ch-ua-mobile']).toBe('?0');
// sec-ch-ua-arch should match process.arch
expect(headers['sec-ch-ua-arch']).toBe(process.arch);
// sec-ch-ua-bitness should be "64"
expect(headers['sec-ch-ua-bitness']).toBe('64');
// sec-ch-ua-full-version extracted from profile.userAgent Chrome/120.0.0.0
expect(headers['sec-ch-ua-full-version']).toBe('120.0.0.0');
});
it('should not inject headers on localhost traffic', async () => {
context = await browser.newContext();
page = await context.newPage();
await injectHeaderStealth(page, testProfile);
// Navigate directly to the check endpoint on localhost — should be bypassed
const localPort = (server.address() as AddressInfo).port;
await page.goto(`http://127.0.0.1:${localPort}/check`);
const body = await page.evaluate(() => document.body.textContent ?? '{}');
const headers = JSON.parse(body) as Record<string, string | string[] | undefined>;
// sec-ch-ua should NOT be injected on localhost
expect(headers['sec-ch-ua']).toBeUndefined();
});
});
@@ -0,0 +1,478 @@
// Integration tests for humanClick, humanType, humanScroll / 仿人类输入模拟集成测试
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import type { Browser, Page } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
import { humanClick, humanType, humanScroll, randBetween } from '../stealth/human-input.js';
chromium.use(StealthPlugin());
const integration =
process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
const testProfile: FingerprintProfile = {
id: 'fp_human_input',
name: 'Human Input Test',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
platform: 'Win32',
languages: ['en-US', 'en'],
acceptLanguage: 'en-US,en;q=0.9',
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 },
viewport: { width: 1280, height: 720 },
webgl: { vendor: 'Google Inc.', renderer: 'ANGLE (NVIDIA GeForce RTX 3060)' },
timezone: 'America/New_York',
permissions: {
notifications: 'denied',
geolocation: 'granted',
camera: 'denied',
microphone: 'denied',
},
behavior: {
mouseMoveDelay: { min: 50, max: 150 },
keyPressDelay: { min: 80, max: 200 },
scrollStepDelay: { min: 30, max: 100 },
},
canvasNoise: { enabled: true, strength: 0.5 },
};
async function installEventTracker(page: Page): Promise<void> {
await page.evaluate(() => {
const recorded: Array<{ type: string; timestamp: number }> = [];
(window as any).__vlEvents = recorded;
const track = (e: Event) => {
recorded.push({ type: e.type, timestamp: Date.now() });
};
document.addEventListener('mousemove', track, true);
document.addEventListener('mousedown', track, true);
document.addEventListener('mouseup', track, true);
document.addEventListener('click', track, true);
document.addEventListener('keydown', track, true);
document.addEventListener('keypress', track, true);
document.addEventListener('keyup', track, true);
document.addEventListener('input', track, true);
document.addEventListener('wheel', track, true);
document.addEventListener('scroll', track, true);
});
}
async function getRecordedEvents(page: Page): Promise<Array<{ type: string; timestamp: number }>> {
return page.evaluate(() => (window as any).__vlEvents || []);
}
// Helper: start a simple page with a button and an input / 启动带按钮和输入框的简单页面
async function setupTestPage(page: Page): Promise<void> {
await page.setContent(`
<!DOCTYPE html>
<html>
<head><title>Human Input Test</title></head>
<body style="height:3000px;">
<button id="target-btn" style="margin:100px;padding:20px;">Click Me</button>
<input id="target-input" type="text" value="" style="margin:100px;padding:10px;font-size:16px;">
<input id="target-input2" type="text" value="prefill" style="margin:100px;">
<div id="bottom-marker" style="margin-top:2800px;">Bottom</div>
</body>
</html>
`);
await page.waitForSelector('#target-btn', { state: 'visible' });
await installEventTracker(page);
}
// ==================== humanClick tests / humanClick 测试 ====================
integration('humanClick', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (browser) await browser.close();
});
it('should dispatch mouse event sequence: mousemove → mousedown → mouseup → click', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
await humanClick(page, '#target-btn', testProfile);
// Give async event handling a moment / 等待异步事件处理完成
await page.waitForTimeout(200);
const events = await getRecordedEvents(page);
// Check event types exist in order / 验证事件类型存在且顺序正确
const eventTypes = events.map((e) => e.type);
const hasMouseMove = eventTypes.some((t) => t === 'mousemove');
const hasMouseDown = eventTypes.some((t) => t === 'mousedown');
const hasMouseUp = eventTypes.some((t) => t === 'mouseup');
const hasClick = eventTypes.some((t) => t === 'click');
expect(hasMouseMove).toBe(true);
expect(hasMouseDown).toBe(true);
expect(hasMouseUp).toBe(true);
expect(hasClick).toBe(true);
// Verify order: mousemove before mousedown, mousedown before mouseup, mouseup before click
// 验证事件顺序
const moveIdx = eventTypes.indexOf('mousemove');
const downIdx = eventTypes.indexOf('mousedown');
const upIdx = eventTypes.indexOf('mouseup');
const clickIdx = eventTypes.indexOf('click');
expect(moveIdx).toBeLessThan(downIdx);
expect(downIdx).toBeLessThan(upIdx);
expect(upIdx).toBeLessThan(clickIdx);
await context.close();
});
it('should produce multiple mousemove events along the path', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
await humanClick(page, '#target-btn', testProfile);
await page.waitForTimeout(200);
const events = await getRecordedEvents(page);
const moveEvents = events.filter((e) => e.type === 'mousemove');
// Should have at least 2 mousemove events (start + approach) / 至少2个 mousemove 事件
expect(moveEvents.length).toBeGreaterThanOrEqual(2);
await context.close();
});
it('should throw for non-existent selector / 不存在选择器应报错', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
await expect(
humanClick(page, '#non-existent-element', testProfile),
).rejects.toThrow(/element not found/i);
await context.close();
});
it('should click within element bounds / 应在元素范围内点击', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
// Track click coordinates / 记录点击坐标
const clickCoords = await page.evaluate(() => {
return new Promise<{ x: number; y: number }>((resolve) => {
const btn = document.getElementById('target-btn')!;
btn.addEventListener('click', (e) => {
resolve({ x: (e as MouseEvent).clientX, y: (e as MouseEvent).clientY });
}, { once: true });
});
});
const box = await page.locator('#target-btn').boundingBox();
expect(box).not.toBeNull();
// Trigger humanClick and capture coordinates / 触发点击并捕获坐标
await humanClick(page, '#target-btn', testProfile);
const coords = await clickCoords;
// Click should be within element bounds (±10px tolerance for jitter) / 点击应在元素范围内
expect(coords.x).toBeGreaterThanOrEqual(box!.x - 10);
expect(coords.x).toBeLessThanOrEqual(box!.x + box!.width + 10);
expect(coords.y).toBeGreaterThanOrEqual(box!.y - 10);
expect(coords.y).toBeLessThanOrEqual(box!.y + box!.height + 10);
await context.close();
});
});
// ==================== humanType tests / humanType 测试 ====================
integration('humanType', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (browser) await browser.close();
});
it('should type text into the input element', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
await humanType(page, '#target-input', 'Hello', testProfile);
await page.waitForTimeout(300);
const value = await page.locator('#target-input').inputValue();
expect(value).toBe('Hello');
await context.close();
});
it('should dispatch keydown → keypress → keyup for each character / 每个字符应触发 keydown → keypress → keyup', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
await humanType(page, '#target-input', 'AB', testProfile);
await page.waitForTimeout(300);
const events = await getRecordedEvents(page);
const eventTypes = events.map((e) => e.type);
const keydownCount = eventTypes.filter((t) => t === 'keydown').length;
const keypressCount = eventTypes.filter((t) => t === 'keypress').length;
const keyupCount = eventTypes.filter((t) => t === 'keyup').length;
// Each character should produce at least 1 of each event / 每个字符至少产生1个事件
expect(keydownCount).toBeGreaterThanOrEqual(2);
expect(keypressCount).toBeGreaterThanOrEqual(2);
expect(keyupCount).toBeGreaterThanOrEqual(2);
await context.close();
});
it('should clear pre-existing text before typing / 应在输入前清除已有文本', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
// Pre-fill the input / 预填充输入框
await page.locator('#target-input2').fill('oldvalue');
const beforeVal = await page.locator('#target-input2').inputValue();
expect(beforeVal).toBe('oldvalue');
await humanType(page, '#target-input2', 'New', testProfile);
await page.waitForTimeout(300);
const afterVal = await page.locator('#target-input2').inputValue();
expect(afterVal).toBe('New');
await context.close();
});
it('should produce inter-character delays within configured range / 字符间延迟应在配置范围内', async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
page = await context.newPage();
await setupTestPage(page);
// Track key events with high-res timestamps / 用高精度时间戳跟踪按键事件
const keyTimestamps = await page.evaluate(() => {
return new Promise<number[]>((resolve) => {
const timestamps: number[] = [];
const input = document.getElementById('target-input')!;
input.addEventListener('keydown', () => {
timestamps.push(performance.now());
});
// Resolve after all characters processed / 所有字符处理后结束
const observer = new MutationObserver(() => {
const val = (input as HTMLInputElement).value;
if (val.length >= 3) {
observer.disconnect();
// Give a little more time for the last events / 给最后的事件留时间
setTimeout(() => resolve(timestamps), 100);
}
});
observer.observe(input, { attributes: true, attributeFilter: ['value'] });
});
});
// Type a short string and capture timestamps / 输入短字符串并捕获时间戳
const typePromise = humanType(page, '#target-input', 'ABC', testProfile);
const timestamps = await keyTimestamps;
await typePromise;
// Calculate inter-key delays / 计算按键间延迟
expect(timestamps.length).toBeGreaterThanOrEqual(3);
// Verify each inter-key delay is reasonable (> 0ms, < 1000ms)
// 验证每个按键间延迟合理
for (let i = 1; i < timestamps.length; i++) {
const delay = timestamps[i] - timestamps[i - 1];
expect(delay).toBeGreaterThan(0);
// Should be within a reasonable upper bound given the profile
// 考虑配置范围内应有合理上限
expect(delay).toBeLessThan(1000);
}
await context.close();
});
it('should handle empty string gracefully / 应优雅处理空字符串', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
await humanType(page, '#target-input', '', testProfile);
await page.waitForTimeout(200);
const value = await page.locator('#target-input').inputValue();
// After clearing, the input should be empty / 清除后输入框应为空
expect(value).toBe('');
await context.close();
});
});
// ==================== humanScroll tests / humanScroll 测试 ====================
integration('humanScroll', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (browser) await browser.close();
});
it('should dispatch scroll events / 应触发 scroll 事件', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
await humanScroll(page, { deltaY: 300 }, testProfile);
await page.waitForTimeout(300);
const events = await getRecordedEvents(page);
const scrollEvents = events.filter((e) => e.type === 'scroll');
// Should have at least one scroll event / 至少有一个 scroll 事件
expect(scrollEvents.length).toBeGreaterThanOrEqual(1);
await context.close();
});
it('should change scroll position with deltaY / deltaY 应改变滚动位置', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
const beforeY = await page.evaluate(() => window.scrollY);
await humanScroll(page, { deltaY: 400 }, testProfile);
await page.waitForTimeout(300);
const afterY = await page.evaluate(() => window.scrollY);
// Should scroll down / 应向下滚动
expect(afterY).toBeGreaterThan(beforeY);
await context.close();
});
it('should scroll to bottom with toBottom option / toBottom 选项应滚动到底', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
await humanScroll(page, { toBottom: true }, testProfile);
await page.waitForTimeout(500);
const atBottom = await page.evaluate(() => {
return window.innerHeight + window.scrollY >= document.body.scrollHeight - 5;
});
expect(atBottom).toBe(true);
await context.close();
});
it('should dispatch wheel events for realism / 应触发 wheel 事件以实现真实性', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
await humanScroll(page, { deltaY: 200 }, testProfile);
await page.waitForTimeout(300);
const events = await getRecordedEvents(page);
const wheelEvents = events.filter((e) => e.type === 'wheel');
// Wheel events simulate real user scrolling / wheel 事件模拟真实用户滚动
expect(wheelEvents.length).toBeGreaterThanOrEqual(1);
await context.close();
});
it('should not error when toBottom on short page / toBottom 在短页面不应报错', async () => {
const context = await browser.newContext();
page = await context.newPage();
await page.setContent(`<html><body style="height:200px;"><p>Short page</p></body></html>`);
await page.waitForLoadState('domcontentloaded');
await installEventTracker(page);
// Should complete without throwing / 应无错误完成
await expect(
humanScroll(page, { toBottom: true }, testProfile),
).resolves.toBeUndefined();
await context.close();
});
it('should scroll in multiple steps for large deltas / 大增量应分步滚动', async () => {
const context = await browser.newContext();
page = await context.newPage();
await setupTestPage(page);
// Large delta should be broken into chunks / 大增量应分块
await humanScroll(page, { deltaY: 800 }, testProfile);
await page.waitForTimeout(500);
const events = await getRecordedEvents(page);
const wheelEvents = events.filter((e) => e.type === 'wheel');
// Should have multiple wheel events for large scroll / 大滚动应有多个 wheel 事件
expect(wheelEvents.length).toBeGreaterThanOrEqual(2);
await context.close();
});
});
// ==================== randBetween unit tests / randBetween 单元测试 ====================
describe('randBetween', () => {
it('should return a value within [min, max] range / 应在 [min, max] 范围内返回值', () => {
for (let i = 0; i < 100; i++) {
const val = randBetween(10, 20);
expect(val).toBeGreaterThanOrEqual(10);
expect(val).toBeLessThanOrEqual(20);
}
});
it('should return an integer / 应返回整数', () => {
for (let i = 0; i < 50; i++) {
const val = randBetween(0, 100);
expect(Number.isInteger(val)).toBe(true);
}
});
it('should return min when min === max / min 等于 max 应返回该值', () => {
for (let i = 0; i < 10; i++) {
expect(randBetween(5, 5)).toBe(5);
}
});
it('should produce varied values over many calls / 多次调用产生不同值', () => {
const values = new Set<number>();
for (let i = 0; i < 50; i++) {
values.add(randBetween(1, 100));
}
// With 100 possible values and 50 calls, expect at least some variation
// 100种可能值、50次调用,至少应有变化
expect(values.size).toBeGreaterThan(1);
});
});
@@ -0,0 +1,136 @@
// Integration tests for injectNavigatorStealth / 隐身导航注入集成测试
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import type { Browser, BrowserContext } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
import { injectNavigatorStealth } from '../stealth/navigator.js';
chromium.use(StealthPlugin());
const integration = process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
const testProfile: FingerprintProfile = {
id: 'fp_stealth_nav',
name: 'Stealth Navigator Test',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
platform: 'Win32',
languages: ['en-US', 'en'],
acceptLanguage: 'en-US,en;q=0.9',
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 },
viewport: { width: 1280, height: 720 },
webgl: { vendor: 'Google Inc.', renderer: 'ANGLE (NVIDIA GeForce RTX 3060)' },
timezone: 'America/New_York',
permissions: {
notifications: 'denied',
geolocation: 'granted',
camera: 'denied',
microphone: 'denied',
},
behavior: {
mouseMoveDelay: { min: 50, max: 150 },
keyPressDelay: { min: 80, max: 200 },
scrollStepDelay: { min: 30, max: 100 },
},
canvasNoise: { enabled: true, strength: 0.5 },
};
integration('injectNavigatorStealth', () => {
let browser: Browser;
let context: BrowserContext;
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
});
afterAll(async () => {
if (context) await context.close();
if (browser) await browser.close();
});
it('should set navigator.webdriver to false', async () => {
context = await browser.newContext();
await injectNavigatorStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const webdriver = await page.evaluate(() => navigator.webdriver);
expect(webdriver).toBe(false);
});
it('should override navigator.languages from profile', async () => {
context = await browser.newContext();
await injectNavigatorStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const langs = await page.evaluate(() => navigator.languages);
expect(langs).toEqual(testProfile.languages);
});
it('should override navigator.platform from profile', async () => {
context = await browser.newContext();
await injectNavigatorStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const platform = await page.evaluate(() => navigator.platform);
expect(platform).toBe(testProfile.platform);
});
it('should set navigator.vendor to "Google Inc."', async () => {
context = await browser.newContext();
await injectNavigatorStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const vendor = await page.evaluate(() => navigator.vendor);
expect(vendor).toBe('Google Inc.');
});
it('should set navigator.productSub to "20030107"', async () => {
context = await browser.newContext();
await injectNavigatorStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const productSub = await page.evaluate(() => navigator.productSub);
expect(productSub).toBe('20030107');
});
it('should inject navigator.connection if missing', async () => {
context = await browser.newContext();
await injectNavigatorStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const connection = await page.evaluate(() => {
const c = (navigator as any).connection;
if (!c) return null;
return { downlink: c.downlink, effectiveType: c.effectiveType, rtt: c.rtt, saveData: c.saveData };
});
expect(connection).not.toBeNull();
expect(connection!.downlink).toBe(10);
expect(connection!.effectiveType).toBe('4g');
expect(connection!.rtt).toBe(50);
expect(connection!.saveData).toBe(false);
});
it('should keep navigator.plugins accessible', async () => {
context = await browser.newContext();
await injectNavigatorStealth(context, testProfile);
const page = await context.newPage();
await page.goto('about:blank');
const plugins = await page.evaluate(() => {
const p = navigator.plugins;
return { length: p.length, hasItem: typeof p.item === 'function', hasNamedItem: typeof p.namedItem === 'function' };
});
expect(typeof plugins.length).toBe('number');
expect(plugins.hasItem).toBe(true);
expect(plugins.hasNamedItem).toBe(true);
});
});
+203
View File
@@ -0,0 +1,203 @@
// BrowserManager — Playwright browser lifecycle and page management with stealth plugin / 浏览器管理器,使用隐身插件管理 Playwright 浏览器生命周期和页面
import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import type { Browser, BrowserContext, Page } from 'playwright';
import type { FingerprintProfile, PageInfo, ConsoleEntry, NetworkEntry } from '@visionl/core';
import { PageRegistry, type RegisteredPage } from './page-registry.js';
import { getProfile } from './stealth/profiles/index.js';
import { applyStealth } from './stealth/index.js';
import { broadcast } from './ws-relay.js';
const MAX_CONSOLE_BUFFER = 100;
const MAX_NETWORK_BUFFER = 200;
// Apply stealth plugin once at module level / 模块级别一次性注入隐身插件
chromium.use(StealthPlugin());
function generatePageId(): string {
const hex = Math.random().toString(16).slice(2, 10);
return `p_${hex}`;
}
export class BrowserManager {
private browser: Browser | null = null;
private registry = new PageRegistry();
private profile: FingerprintProfile;
constructor(profileOrId: FingerprintProfile | string = 'desktop-chrome') {
if (typeof profileOrId === 'string') {
const resolved = getProfile(profileOrId);
if (!resolved) throw new Error(`Unknown profile: ${profileOrId}`);
this.profile = resolved;
} else {
this.profile = profileOrId;
}
}
async init(): Promise<void> {
this.browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled',
],
});
}
async createPage(url: string, alias?: string): Promise<PageInfo> {
if (!this.browser) throw new Error('Browser not initialized');
if (alias && this.registry.hasAlias(alias)) {
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
}
// Validate URL format / 验证 URL 格式
try {
new URL(url);
} catch {
throw Object.assign(new Error(`Invalid URL: ${url}`), { code: 'INVALID_URL' });
}
const context = await this.browser.newContext({
viewport: this.profile.viewport,
userAgent: this.profile.userAgent,
locale: this.profile.languages[0],
timezoneId: this.profile.timezone,
permissions: Object.entries(this.profile.permissions)
.filter(([, v]) => v === 'granted')
.map(([k]) => k as any),
geolocation: this.profile.geolocation,
colorScheme: 'light',
deviceScaleFactor: this.profile.screen.pixelRatio,
});
const page = await context.newPage();
// Apply stealth injections before navigation / 导航前注入隐身模块
await applyStealth(context, page, this.profile);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
const title = await page.title();
const id = generatePageId();
const info: PageInfo = { id, url, alias, title, status: 'active', profile: this.profile.id };
this.registry.add(info, page, context);
this.setupPageListeners(page, id);
// Broadcast page created event / 广播页面创建事件
broadcast({ type: 'page:created', data: info });
console.log(`[browser-manager] Page created: ${id}${url}`);
return info;
}
private setupPageListeners(page: Page, id: string): void {
const entry = this.registry.get(id);
if (!entry) return;
// Console monitoring / 控制台监控
page.on('console', (msg) => {
const level = msg.type();
const text = msg.text();
const consoleEntry: ConsoleEntry = { level, text, timestamp: Date.now() };
this.pushToBuffer(entry.consoleLog, consoleEntry, MAX_CONSOLE_BUFFER);
broadcast({ type: 'page:console', data: { id, level, text } });
});
// Network request monitoring / 网络请求监控
page.on('request', (req) => {
const netEntry: NetworkEntry = {
type: 'request',
url: req.url(),
method: req.method(),
headers: req.headers(),
timestamp: Date.now(),
};
this.pushToBuffer(entry.networkLog, netEntry, MAX_NETWORK_BUFFER);
broadcast({
type: 'page:network:request',
data: { id, url: req.url(), method: req.method(), headers: req.headers() },
});
});
// Network response monitoring / 网络响应监控
page.on('response', (res) => {
const netEntry: NetworkEntry = {
type: 'response',
url: res.url(),
status: res.status(),
headers: res.headers(),
timestamp: Date.now(),
};
this.pushToBuffer(entry.networkLog, netEntry, MAX_NETWORK_BUFFER);
broadcast({
type: 'page:network:response',
data: { id, url: res.url(), status: res.status(), headers: res.headers() },
});
});
// Network failure monitoring / 网络失败监控
page.on('requestfailed', (req) => {
const failureText = req.failure()?.errorText || 'Unknown error';
const netEntry: NetworkEntry = {
type: 'failed',
url: req.url(),
failure: failureText,
timestamp: Date.now(),
};
this.pushToBuffer(entry.networkLog, netEntry, MAX_NETWORK_BUFFER);
broadcast({
type: 'page:network:failed',
data: { id, url: req.url(), failure: failureText },
});
});
// Page crash handling / 页面崩溃处理
page.on('crash', () => {
console.error(`[browser-manager] Page crashed: ${id}`);
entry.info.status = 'crashed';
broadcast({ type: 'page:crashed', data: { id, error: 'Page crashed' } });
});
}
private pushToBuffer<T>(buffer: T[], item: T, maxSize: number): void {
buffer.push(item);
if (buffer.length > maxSize) {
buffer.shift();
}
}
async closePage(idOrAlias: string): Promise<void> {
const entry = this.registry.findByIdOrAlias(idOrAlias);
if (!entry) {
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
}
const pageId = entry.info.id;
await entry.context.close();
this.registry.remove(pageId);
broadcast({ type: 'page:closed', data: { id: pageId } });
console.log(`[browser-manager] Page closed: ${pageId}`);
}
getPage(idOrAlias: string): RegisteredPage | undefined {
return this.registry.findByIdOrAlias(idOrAlias);
}
listPages(): PageInfo[] {
return this.registry.list();
}
async cleanup(): Promise<void> {
// Close all contexts / 关闭所有上下文
for (const entry of this.registry.getAll()) {
try { await entry.context.close(); } catch { /* ignore / 忽略 */ }
}
this.registry.clear();
if (this.browser) {
await this.browser.close();
this.browser = null;
}
}
}
+16
View File
@@ -0,0 +1,16 @@
// @visionl/daemon — browser daemon entry point / 浏览器守护进程入口
export const DAEMON_VERSION = '0.1.0';
// Re-export stealth orchestrator and helpers / 重新导出隐身编排器和辅助函数
export {
applyStealth,
getProfile,
listProfiles,
humanClick,
humanType,
humanScroll,
injectHeaderStealth,
} from './stealth/index.js';
export { BrowserManager } from './browser-manager.js';
export { startServer } from './server.js';
+61
View File
@@ -0,0 +1,61 @@
// PageRegistry — tracks active Playwright pages by ID and alias / 页面注册表,通过 ID 和别名追踪活跃页面
import type { Page, BrowserContext } from 'playwright';
import type { PageInfo, ConsoleEntry, NetworkEntry } from '@visionl/core';
export interface RegisteredPage {
info: PageInfo;
page: Page;
context: BrowserContext;
consoleLog: ConsoleEntry[];
networkLog: NetworkEntry[];
}
export class PageRegistry {
private pages = new Map<string, RegisteredPage>();
private aliasMap = new Map<string, string>(); // alias → id
add(info: PageInfo, page: Page, context: BrowserContext): void {
this.pages.set(info.id, { info, page, context, consoleLog: [], networkLog: [] });
if (info.alias) {
this.aliasMap.set(info.alias, info.id);
}
}
remove(id: string): boolean {
const entry = this.pages.get(id);
if (!entry) return false;
if (entry.info.alias) {
this.aliasMap.delete(entry.info.alias);
}
return this.pages.delete(id);
}
get(id: string): RegisteredPage | undefined {
return this.pages.get(id);
}
findByIdOrAlias(idOrAlias: string): RegisteredPage | undefined {
const id = this.aliasMap.get(idOrAlias);
if (id) return this.pages.get(id);
return this.pages.get(idOrAlias);
}
list(): PageInfo[] {
return Array.from(this.pages.values()).map((entry) => entry.info);
}
hasAlias(alias: string): boolean {
return this.aliasMap.has(alias);
}
/** Return all registered entries for iteration / 返回所有已注册条目用于遍历 */
getAll(): RegisteredPage[] {
return Array.from(this.pages.values());
}
/** Clear all entries / 清除所有条目 */
clear(): void {
this.pages.clear();
this.aliasMap.clear();
}
}
+48
View File
@@ -0,0 +1,48 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const DEFAULT_DIR = path.join(os.homedir(), '.visionl');
function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
export function writePidfile(pid: number, port: number, dir: string = DEFAULT_DIR): void {
ensureDir(dir);
fs.writeFileSync(path.join(dir, 'daemon.pid'), String(pid));
fs.writeFileSync(path.join(dir, 'daemon.port'), String(port));
}
export function readPidfile(dir: string = DEFAULT_DIR): { pid: number; port: number } | null {
const pidPath = path.join(dir, 'daemon.pid');
const portPath = path.join(dir, 'daemon.port');
try {
const pid = parseInt(fs.readFileSync(pidPath, 'utf-8'), 10);
const port = parseInt(fs.readFileSync(portPath, 'utf-8'), 10);
if (isNaN(pid) || isNaN(port)) return null;
return { pid, port };
} catch {
return null;
}
}
export function cleanPidfile(dir: string = DEFAULT_DIR): void {
try {
fs.unlinkSync(path.join(dir, 'daemon.pid'));
fs.unlinkSync(path.join(dir, 'daemon.port'));
} catch {
// Ignore if files don't exist
}
}
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
+206
View File
@@ -0,0 +1,206 @@
// Action routes — click/type/scroll/eval/wait/navigate + cookie management / 操作路由,点击/输入/滚动/执行/等待/导航 + Cookie 管理
import type { IncomingMessage, ServerResponse } from 'node:http';
import { BrowserManager } from '../browser-manager.js';
import { safeStringify } from '@visionl/core';
type RouteHandler = (req: IncomingMessage, res: ServerResponse) => boolean;
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => resolve(body));
});
}
export function actionRoutes(bm: BrowserManager): RouteHandler {
return (req, res) => {
const url = new URL(req.url || '/', 'http://localhost');
const path = url.pathname;
const segments = path.split('/').filter(Boolean);
res.setHeader('Content-Type', 'application/json');
// Only handle /pages/:id/* routes / 只处理 /pages/:id/* 路由
if (segments[0] !== 'pages' || segments.length < 3) {
return false;
}
const id = segments[1];
const action = segments[2];
const entry = bm.getPage(id);
if (!entry) {
res.writeHead(404);
res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } }));
return true;
}
// ========== POST actions / POST 操作 ==========
// POST /pages/:id/click — click on a selector / 点击选择器
if (req.method === 'POST' && action === 'click' && segments.length === 3) {
readBody(req).then(async (body) => {
try {
const { selector } = JSON.parse(body);
await entry.page.click(selector);
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { success: true } }));
} catch (err: any) {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
// POST /pages/:id/type — fill text into a selector / 向选择器输入文本
if (req.method === 'POST' && action === 'type' && segments.length === 3) {
readBody(req).then(async (body) => {
try {
const { selector, text } = JSON.parse(body);
await entry.page.fill(selector, text);
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { success: true } }));
} catch (err: any) {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
// POST /pages/:id/scroll — scroll page by deltaY or to bottom / 滚动页面向下或到底部
if (req.method === 'POST' && action === 'scroll' && segments.length === 3) {
readBody(req).then(async (body) => {
try {
const { deltaY, toBottom } = JSON.parse(body);
await entry.page.evaluate(({ deltaY: dy, toBottom: bottom }) => {
if (bottom) {
window.scrollTo(0, document.body.scrollHeight);
} else {
window.scrollBy(0, dy || 0);
}
}, { deltaY: deltaY || 0, toBottom });
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { success: true } }));
} catch (err: any) {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
// POST /pages/:id/eval — evaluate JavaScript in page context / 在页面中执行 JavaScript
if (req.method === 'POST' && action === 'eval' && segments.length === 3) {
readBody(req).then(async (body) => {
try {
const { code } = JSON.parse(body);
const result = await entry.page.evaluate(code);
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { result } }));
} catch (err: any) {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
// POST /pages/:id/wait — wait for selector or timeout / 等待选择器出现或超时
if (req.method === 'POST' && action === 'wait' && segments.length === 3) {
readBody(req).then(async (body) => {
try {
const { selector, timeout } = JSON.parse(body);
if (selector) {
await entry.page.waitForSelector(selector, { timeout: timeout || 30000 });
} else {
await entry.page.waitForTimeout(timeout || 1000);
}
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { success: true } }));
} catch (err: any) {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
// POST /pages/:id/navigate — navigate page to a new URL / 导航到新 URL
if (req.method === 'POST' && action === 'navigate' && segments.length === 3) {
readBody(req).then(async (body) => {
try {
const { url: pageUrl } = JSON.parse(body);
await entry.page.goto(pageUrl, { waitUntil: 'domcontentloaded' });
const title = await entry.page.title();
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { url: pageUrl, title } }));
} catch (err: any) {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
// ========== Cookie management / Cookie 管理 ==========
// GET /pages/:id/cookies — get all cookies for the page context / 获取页面上下文的所有 cookie
if (req.method === 'GET' && action === 'cookies' && segments.length === 3) {
entry.context.cookies().then((cookies) => {
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { cookies } }));
}).catch((err: any) => {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
});
return true;
}
// POST /pages/:id/cookies — add a cookie to the page context / 向页面上下文添加 cookie
if (req.method === 'POST' && action === 'cookies' && segments.length === 3) {
readBody(req).then(async (body) => {
try {
const { name, value, domain, path: cookiePath, httpOnly, secure, sameSite } = JSON.parse(body);
const cookie: any = { name, value };
if (domain !== undefined) cookie.domain = domain;
if (cookiePath !== undefined) cookie.path = cookiePath;
if (httpOnly !== undefined) cookie.httpOnly = httpOnly;
if (secure !== undefined) cookie.secure = secure;
if (sameSite !== undefined) cookie.sameSite = sameSite;
await entry.context.addCookies([cookie]);
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { success: true } }));
} catch (err: any) {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
// DELETE /pages/:id/cookies/:name — delete a specific cookie by name / 按名称删除特定 cookie
if (req.method === 'DELETE' && action === 'cookies' && segments.length === 4) {
const cookieName = segments[3];
Promise.resolve().then(async () => {
try {
const existingCookies = await entry.context.cookies();
const filtered = existingCookies.filter((c) => c.name !== cookieName);
await entry.context.clearCookies();
if (filtered.length > 0) {
await entry.context.addCookies(filtered);
}
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { success: true } }));
} catch (err: any) {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
return false;
};
}
+63
View File
@@ -0,0 +1,63 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { BrowserManager } from '../browser-manager.js';
import { safeStringify } from '@visionl/core';
type RouteHandler = (req: IncomingMessage, res: ServerResponse) => boolean;
export function contentRoutes(bm: BrowserManager): RouteHandler {
return (req, res) => {
const url = new URL(req.url || '/', 'http://localhost');
const path = url.pathname;
const segments = path.split('/').filter(Boolean);
res.setHeader('Content-Type', 'application/json');
if (req.method !== 'GET' || segments[0] !== 'pages' || segments.length !== 3) {
return false;
}
const id = segments[1];
const action = segments[2]; // screenshot | text | html
const entry = bm.getPage(id);
if (!entry) {
res.writeHead(404);
res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } }));
return true;
}
if (action === 'screenshot') {
entry.page.screenshot({ type: 'png' }).then((buffer) => {
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { base64: buffer.toString('base64'), mime: 'image/png' } }));
}).catch((err: any) => {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
});
return true;
}
if (action === 'text') {
entry.page.evaluate(() => document.body.innerText).then((text) => {
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { text } }));
}).catch((err: any) => {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
});
return true;
}
if (action === 'html') {
entry.page.evaluate(() => document.documentElement.outerHTML).then((html) => {
res.writeHead(200);
res.end(safeStringify({ ok: true, data: { html } }));
}).catch((err: any) => {
res.writeHead(500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
});
return true;
}
return false;
};
}
+11
View File
@@ -0,0 +1,11 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
export function healthRoute(req: IncomingMessage, res: ServerResponse): boolean {
const url = new URL(req.url || '/', 'http://localhost');
if (req.method === 'GET' && url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return true;
}
return false;
}
+99
View File
@@ -0,0 +1,99 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { BrowserManager } from '../browser-manager.js';
import { safeStringify } from '@visionl/core';
type RouteHandler = (req: IncomingMessage, res: ServerResponse) => boolean;
export function pageRoutes(bm: BrowserManager): RouteHandler {
return (req, res) => {
const url = new URL(req.url || '/', 'http://localhost');
const path = url.pathname;
const segments = path.split('/').filter(Boolean);
res.setHeader('Content-Type', 'application/json');
// POST /pages
if (req.method === 'POST' && path === '/pages') {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', async () => {
try {
const { url: pageUrl, alias } = JSON.parse(body);
const info = await bm.createPage(pageUrl, alias);
res.writeHead(200);
res.end(safeStringify({ ok: true, data: info }));
} catch (err: any) {
res.writeHead(err.code === 'ALIAS_EXISTS' ? 409 : 400);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
// GET /pages
if (req.method === 'GET' && path === '/pages') {
const pages = bm.listPages();
res.writeHead(200);
res.end(safeStringify({ ok: true, data: pages }));
return true;
}
// GET /pages/:id/console — console history / 控制台历史
if (req.method === 'GET' && segments[0] === 'pages' && segments[2] === 'console' && segments.length === 3) {
const id = segments[1];
const entry = bm.getPage(id);
if (!entry) {
res.writeHead(404);
res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } }));
return true;
}
res.writeHead(200);
res.end(safeStringify({ ok: true, data: entry.consoleLog }));
return true;
}
// GET /pages/:id/network — network history / 网络请求历史
if (req.method === 'GET' && segments[0] === 'pages' && segments[2] === 'network' && segments.length === 3) {
const id = segments[1];
const entry = bm.getPage(id);
if (!entry) {
res.writeHead(404);
res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } }));
return true;
}
res.writeHead(200);
res.end(safeStringify({ ok: true, data: entry.networkLog }));
return true;
}
// GET /pages/:id
// DELETE /pages/:id
if (segments[0] === 'pages' && segments.length === 2) {
const id = segments[1];
if (req.method === 'GET') {
const entry = bm.getPage(id);
if (!entry) {
res.writeHead(404);
res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } }));
return true;
}
res.writeHead(200);
res.end(safeStringify({ ok: true, data: entry.info }));
return true;
}
if (req.method === 'DELETE') {
bm.closePage(id).then(() => {
res.writeHead(200);
res.end(safeStringify({ ok: true, data: null }));
}).catch((err: any) => {
res.writeHead(err.code === 'PAGE_NOT_FOUND' ? 404 : 500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
});
return true;
}
}
return false;
};
}
+14
View File
@@ -0,0 +1,14 @@
// Profiles API route — list available fingerprint profiles / 指纹配置 API 路由
import type { IncomingMessage, ServerResponse } from 'node:http';
import { listProfiles } from '../stealth/index.js';
export function profilesRoute(req: IncomingMessage, res: ServerResponse): boolean {
const url = new URL(req.url || '/', 'http://localhost');
if (req.method === 'GET' && url.pathname === '/profiles') {
const data = listProfiles();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, data }));
return true;
}
return false;
}
+40
View File
@@ -0,0 +1,40 @@
import http from 'node:http';
import { healthRoute } from './routes/health.js';
import { pageRoutes } from './routes/pages.js';
import { contentRoutes } from './routes/content.js';
import { actionRoutes } from './routes/actions.js';
import { profilesRoute } from './routes/profiles.js';
import type { BrowserManager } from './browser-manager.js';
import { createWsRelay } from './ws-relay.js';
export function startServer(port: number, browserManager?: BrowserManager): Promise<http.Server> {
const routes = [healthRoute, profilesRoute];
if (browserManager) {
routes.push(pageRoutes(browserManager));
routes.push(contentRoutes(browserManager));
routes.push(actionRoutes(browserManager));
}
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
for (const route of routes) {
if (route(req, res)) return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: { code: 'INTERNAL', message: 'not found' } }));
});
server.listen(port, '127.0.0.1', () => {
console.log(`[daemon] VisionL daemon started on http://127.0.0.1:${port}`);
createWsRelay(server);
resolve(server);
});
});
}
// Direct start when running as script (not when imported in tests)
// When imported by other modules, the caller is responsible for calling startServer()
if (process.argv[1] && (process.argv[1].endsWith('server.js') || process.argv[1].endsWith('server.ts'))) {
const port = parseInt(process.env.VISIONL_PORT || '9527', 10);
startServer(port);
}
+257
View File
@@ -0,0 +1,257 @@
// Stealth — Canvas / WebGL / AudioContext noise injection / 隐身 — Canvas/WebGL/AudioContext 噪声注入
// Adds ±1 noise to pixel/byte outputs to defeat browser fingerprinting.
import type { BrowserContext } from 'playwright';
import type { CanvasNoiseConfig } from '@visionl/core';
/** Deterministic hash for per-pixel-byte noise decision / 确定性哈希决定每个像素/字节是否加噪 */
const HASH_MULTIPLIER = 2654435761;
export async function injectCanvasNoise(
context: BrowserContext,
opts: CanvasNoiseConfig,
): Promise<void> {
if (!opts.enabled) return;
// Generate a random session seed on the Node side / 在 Node 侧生成随机会话种子
const seed = Math.floor(Math.random() * 0x7fffffff);
await context.addInitScript((args) => {
const { seed, strength } = args;
const noisePercent = Math.round(strength * 10); // 010% of pixels/bytes affected
if (noisePercent <= 0) return;
// ---------- deterministic pixel/byte selector / 确定性选择器 ----------
function shouldAffect(index: number): boolean {
const hash = ((index * HASH_MULTIPLIER + seed) & 0x7fffffff) >>> 0;
return (hash % 100) < noisePercent;
}
function noiseOffset(): number {
// ±1 or ±0 based on the same deterministic stream (sort of)
const m = ((Math.floor(Math.random() * 100000) * HASH_MULTIPLIER + seed) & 0x7fffffff) >>> 0;
return (m & 1) ? 1 : -1;
}
// Clamp helpers / 钳位辅助
function clampByte(v: number): number {
return Math.max(0, Math.min(255, v));
}
// ──────────────────────────────────────────────────────────────────────
// 1. Canvas 2D noise / Canvas 2D 噪声
// ──────────────────────────────────────────────────────────────────────
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
const origPutImageData = CanvasRenderingContext2D.prototype.putImageData;
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
const origToBlob = HTMLCanvasElement.prototype.toBlob;
// Cast putImageData to avoid overload ambiguity with .call() / 避免 .call() 重载歧义
type PutImageData3 = (imageData: ImageData, dx: number, dy: number) => void;
const put3 = origPutImageData as PutImageData3;
function addNoiseToImageData(imageData: ImageData): void {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const pixelIdx = i / 4;
if (!shouldAffect(pixelIdx)) continue;
data[i] = clampByte(data[i] + noiseOffset()); // R
data[i + 1] = clampByte(data[i + 1] + noiseOffset()); // G
data[i + 2] = clampByte(data[i + 2] + noiseOffset()); // B
// Alpha untouched / Alpha 不变
}
}
// getImageData — add noise to returned ImageData / 给返回的 ImageData 加噪
CanvasRenderingContext2D.prototype.getImageData = function (
x: number,
y: number,
w: number,
h: number,
): ImageData {
const result = origGetImageData.call(this, x, y, w, h);
addNoiseToImageData(result);
return result;
};
// toDataURL — save canvas pixels, add noise, call original, restore
// 先保存像素 → 加噪 → 调用原方法 → 恢复
HTMLCanvasElement.prototype.toDataURL = function (
type?: string,
quality?: any,
): string {
const ctx = (this as HTMLCanvasElement).getContext('2d');
let saved: ImageData | null = null;
const w = (this as HTMLCanvasElement).width;
const h = (this as HTMLCanvasElement).height;
if (ctx && w > 0 && h > 0) {
saved = origGetImageData.call(ctx, 0, 0, w, h);
const noised = new ImageData(
new Uint8ClampedArray(saved.data),
saved.width,
saved.height,
);
addNoiseToImageData(noised);
put3.call(ctx, noised, 0, 0);
}
const result = origToDataURL.call(this, type, quality);
// Restore original pixel data / 恢复原始像素
if (saved && ctx) {
put3.call(ctx, saved, 0, 0);
}
return result;
};
// toBlob — same save-modify-restore pattern / 同样的保存-修改-恢复模式
HTMLCanvasElement.prototype.toBlob = function (
callback: BlobCallback,
type?: string,
quality?: any,
): void {
const ctx = (this as HTMLCanvasElement).getContext('2d');
let saved: ImageData | null = null;
const w = (this as HTMLCanvasElement).width;
const h = (this as HTMLCanvasElement).height;
if (ctx && w > 0 && h > 0) {
saved = origGetImageData.call(ctx, 0, 0, w, h);
const noised = new ImageData(
new Uint8ClampedArray(saved.data),
saved.width,
saved.height,
);
addNoiseToImageData(noised);
put3.call(ctx, noised, 0, 0);
}
origToBlob.call(this, (blob: Blob | null) => {
// Restore after callback fires / 回调后恢复
if (saved && ctx) {
put3.call(ctx, saved, 0, 0);
}
callback(blob);
}, type, quality);
};
// ──────────────────────────────────────────────────────────────────────
// 2. WebGL noise / WebGL 噪声
// ──────────────────────────────────────────────────────────────────────
function addNoiseToWebGLBuffer(
buffer: ArrayBufferView,
bytesPerPixel: number,
elementCount: number,
): void {
const bytes = new Uint8Array(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength,
);
for (let i = 0; i < elementCount; i++) {
const base = i * bytesPerPixel;
for (let b = 0; b < bytesPerPixel; b++) {
const byteIdx = base + b;
if (!shouldAffect(byteIdx)) continue;
bytes[byteIdx] = clampByte(bytes[byteIdx] + noiseOffset());
}
}
}
function hookReadPixels(proto: any): void {
const origReadPixels = proto.readPixels;
proto.readPixels = function (
x: number,
y: number,
width: number,
height: number,
format: number,
type: number,
pixels: ArrayBufferView,
): void {
origReadPixels.call(this, x, y, width, height, format, type, pixels);
const elementCount = width * height;
// RGBA = 4 bytes per pixel, other formats vary
// GL_RGBA = 0x1908, GL_UNSIGNED_BYTE = 0x1401
const bytesPerPixel = 4; // conservative default
addNoiseToWebGLBuffer(pixels, bytesPerPixel, elementCount);
};
}
if (typeof WebGLRenderingContext !== 'undefined') {
hookReadPixels(WebGLRenderingContext.prototype);
}
if (typeof WebGL2RenderingContext !== 'undefined') {
hookReadPixels(WebGL2RenderingContext.prototype);
}
// ──────────────────────────────────────────────────────────────────────
// 3. AudioContext noise / AudioContext 噪声
// ──────────────────────────────────────────────────────────────────────
if (typeof AudioContext === 'undefined' && typeof (window as any).webkitAudioContext === 'undefined') {
return;
}
const AudioCtxCtor: typeof AudioContext =
(window as any).AudioContext || (window as any).webkitAudioContext;
// Noise at ~-100dB FS level (inaudible but modifies fingerprint)
// ~-100dB FS ≈ 0.00001 in linear; db domain add ±1 offset
const origCreateOscillator = AudioCtxCtor.prototype.createOscillator;
AudioCtxCtor.prototype.createOscillator = function () {
const osc = origCreateOscillator.call(this);
// Slight random detune per session / 极小的会话级随机失谐
osc.detune.value = (seed % 20) - 10; // -10 to +10 cents, imperceptible
return osc;
};
function addNoiseToFloatFrequencyData(array: Float32Array<ArrayBuffer>): void {
for (let i = 0; i < array.length; i++) {
if (!shouldAffect(i)) continue;
// Add ±1 to dB values (~-100 dB noise floor) / 在 dB 值上加 ±1(约 -100dB 噪底)
array[i] += noiseOffset();
}
}
function addNoiseToByteFrequencyData(array: Uint8Array<ArrayBuffer>): void {
for (let i = 0; i < array.length; i++) {
if (!shouldAffect(i)) continue;
array[i] = clampByte(array[i] + noiseOffset());
}
}
function addNoiseToFloatTimeDomainData(array: Float32Array<ArrayBuffer>): void {
for (let i = 0; i < array.length; i++) {
if (!shouldAffect(i)) continue;
// Add extremely small offset (~-100 dB FS ≈ 0.00001)
array[i] += noiseOffset() * 0.00001;
}
}
if (typeof AnalyserNode !== 'undefined') {
const origGetFloatFrequencyData = AnalyserNode.prototype.getFloatFrequencyData;
const origGetByteFrequencyData = AnalyserNode.prototype.getByteFrequencyData;
const origGetFloatTimeDomainData = AnalyserNode.prototype.getFloatTimeDomainData;
AnalyserNode.prototype.getFloatFrequencyData = function (array: Float32Array): void {
origGetFloatFrequencyData.call(this, array as Float32Array<ArrayBuffer>);
addNoiseToFloatFrequencyData(array as Float32Array<ArrayBuffer>);
};
AnalyserNode.prototype.getByteFrequencyData = function (array: Uint8Array): void {
origGetByteFrequencyData.call(this, array as Uint8Array<ArrayBuffer>);
addNoiseToByteFrequencyData(array as Uint8Array<ArrayBuffer>);
};
AnalyserNode.prototype.getFloatTimeDomainData = function (array: Float32Array): void {
origGetFloatTimeDomainData.call(this, array as Float32Array<ArrayBuffer>);
addNoiseToFloatTimeDomainData(array as Float32Array<ArrayBuffer>);
};
}
}, { seed, strength: opts.strength });
}
@@ -0,0 +1,66 @@
// Stealth — inject window.chrome object for headless detection avoidance / 隐身 — 注入 window.chrome 对象以规避无头检测
import type { BrowserContext } from 'playwright';
export async function injectChromeRuntime(context: BrowserContext): Promise<void> {
await context.addInitScript(() => {
// Headless Chrome has typeof window.chrome === 'undefined' — inject it / 无头模式下缺少 chrome 对象,注入以伪装真实 Chrome
const win = window as any;
if (typeof win.chrome === 'undefined') {
win.chrome = {};
}
if (!win.chrome.runtime) {
win.chrome.runtime = {
PlatformOs: { mac: 'mac', win: 'win', android: 'android', cros: 'cros', linux: 'linux', openbsd: 'openbsd', fuchsia: 'fuchsia' },
PlatformArch: { arm: 'arm', arm64: 'arm64', x86_32: 'x86-32', x86_64: 'x86-64', mips: 'mips', mips64: 'mips64' },
PlatformNaclArch: { arm: 'arm', x86_32: 'x86-32', x86_64: 'x86-64', mips: 'mips', mips64: 'mips64' },
RequestUpdateCheckStatus: { throttled: 'throttled', no_update: 'no_update', update_available: 'update_available' },
OnInstalledReason: { install: 'install', update: 'update', chrome_update: 'chrome_update', shared_module_update: 'shared_module_update' },
OnRestartRequiredReason: { app_update: 'app_update', os_update: 'os_update', periodic: 'periodic' },
id: void 0,
getManifest(): object { return { version: '0.0.0', name: '', manifest_version: 3 }; },
getURL(path: string): string { return `chrome-extension://invalid/${path}`; },
lastError: void 0,
};
}
if (!win.chrome.loadTimes) {
win.chrome.loadTimes = () => ({
requestTime: Date.now() / 1000,
startLoadTime: Date.now() / 1000,
commitLoadTime: Date.now() / 1000,
finishDocumentLoadTime: Date.now() / 1000,
finishLoadTime: Date.now() / 1000,
firstPaintTime: Date.now() / 1000,
firstPaintAfterLoadTime: 0,
navigationType: 'Other',
wasFetchedViaSpdy: true,
wasNpnNegotiated: true,
npnNegotiatedProtocol: 'http/1.1',
wasAlternateProtocolAvailable: false,
connectionInfo: 'http/1.1',
});
}
if (!win.chrome.csi) {
win.chrome.csi = () => ({
startE: 0,
onloadT: 0,
pageT: 0,
tran: 15,
});
}
if (!win.chrome.app) {
win.chrome.app = {
isInstalled: false,
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
getDetails(): Array<never> { return []; },
getIsInstalled(): boolean { return false; },
runningState(): string { return 'cannot_run'; },
};
}
});
}
+54
View File
@@ -0,0 +1,54 @@
// Stealth — HTTP request header injection / 隐身 — HTTP 请求头注入
// Injects Sec-CH-UA family headers to match a browser profile.
// 注入 Sec-CH-UA 系列请求头以匹配浏览器配置。
import type { Page } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
/** Extract Chrome major version from userAgent string / 从 userAgent 提取 Chrome 主版本号 */
function extractChromeVersion(ua: string): { major: string; full: string } {
const m = ua.match(/Chrome\/(\d+)\.(\d+)\.(\d+)\.(\d+)/);
if (m) {
return {
major: m[1],
full: `${m[1]}.${m[2]}.${m[3]}.${m[4]}`,
};
}
return { major: '132', full: '132.0.6834.160' };
}
/** Resolve platform string for Sec-CH-UA-Platform / 根据 profile.platform 推导平台字符串 */
function resolvePlatform(platform: string): string {
if (platform.includes('Windows')) return 'Windows';
if (platform.includes('Mac')) return 'macOS';
return 'Linux';
}
export async function injectHeaderStealth(page: Page, profile: FingerprintProfile): Promise<void> {
const { major, full: fullVersion } = extractChromeVersion(profile.userAgent);
const platform = resolvePlatform(profile.platform);
const arch = process.arch; // aarch64 / x64 / ...
await page.route('**/*', (route) => {
const request = route.request();
const url = request.url();
// Skip WebSocket upgrades and daemon-internal traffic / 跳过 WebSocket 升级和守护进程内部流量
if (url.startsWith('ws://') || url.startsWith('wss://')) {
return route.continue();
}
// Do NOT intercept requests to localhost / 127.0.0.1 (health endpoint etc.)
if (url.startsWith('http://127.0.0.1') || url.startsWith('http://localhost')) {
return route.continue();
}
const headers = { ...request.headers() };
headers['sec-ch-ua'] = `"Chromium";v="${major}", "Google Chrome";v="${major}", "Not?A_Brand";v="99"`;
headers['sec-ch-ua-platform'] = `"${platform}"`;
headers['sec-ch-ua-mobile'] = '?0';
headers['sec-ch-ua-arch'] = arch;
headers['sec-ch-ua-bitness'] = '64';
headers['sec-ch-ua-full-version'] = fullVersion;
route.continue({ headers });
});
}
+202
View File
@@ -0,0 +1,202 @@
// Stealth — Human-like mouse and keyboard input simulation / 隐身 — 仿人类鼠标键盘输入模拟
import type { Page } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
// Random int in [min, max] inclusive / [min, max] 闭区间随机整数
export function randBetween(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// Helper: sleep for a given milliseconds / 等待指定毫秒
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Moves mouse along a linear path from (x1,y1) to (x2,y2) with mousemove events.
* @param page Playwright Page
* @param x1 Start X
* @param y1 Start Y
* @param x2 End X
* @param y2 End Y
* @param delayMs Delay between each mousemove step in ms / 每步之间的延迟(毫秒)
*/
async function moveAlongPath(
page: Page,
x1: number,
y1: number,
x2: number,
y2: number,
delayMs: number,
): Promise<void> {
// Number of steps / 步数 (10ms interval → number of events = duration / 10)
const steps = Math.max(Math.round(delayMs / 10), 2);
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const cx = Math.round(x1 + (x2 - x1) * t);
const cy = Math.round(y1 + (y2 - y1) * t);
await page.mouse.move(cx, cy);
await sleep(10);
}
}
/**
* Simulates a human-like mouse click on an element.
* Moves mouse from a random start position to the target center (±5px jitter),
* then dispatches mousedown → mouseup → click.
*
* @param page Playwright Page
* @param selector CSS selector for the target element / 目标元素的 CSS 选择器
* @param profile Fingerprint profile with behavior delays / 含行为延迟的指纹配置
*/
export async function humanClick(
page: Page,
selector: string,
profile: FingerprintProfile,
): Promise<void> {
const box = await page.locator(selector).boundingBox();
if (!box) {
throw new Error(`humanClick: element not found or invisible for selector "${selector}" / 未找到或不可见的元素`);
}
// Target center with jitter / 目标中心加抖动
const targetX = Math.round(box.x + box.width / 2) + randBetween(-5, 5);
const targetY = Math.round(box.y + box.height / 2) + randBetween(-5, 5);
// Random start position on the page / 页面上的随机起始位置
const viewport = page.viewportSize();
const vw = viewport ? viewport.width : 1280;
const vh = viewport ? viewport.height : 720;
const startX = randBetween(0, vw - 1);
const startY = randBetween(0, vh - 1);
// Move mouse along path / 沿路径移动鼠标
const moveDuration = randBetween(
profile.behavior.mouseMoveDelay.min,
profile.behavior.mouseMoveDelay.max,
);
await moveAlongPath(page, startX, startY, targetX, targetY, moveDuration);
// Click at target / 在目标位置点击
await page.mouse.move(targetX, targetY);
await page.mouse.down();
await sleep(randBetween(20, 60));
await page.mouse.up();
await page.mouse.click(targetX, targetY);
}
/**
* Simulates human-like typing into an element.
* Focuses the element first, then for each character dispatches:
* keydown → (random delay 50-150ms) → keypress → (10ms) → keyup
*
* @param page Playwright Page
* @param selector CSS selector for the target input element / 目标输入元素的 CSS 选择器
* @param text Text to type / 要输入的文本
* @param profile Fingerprint profile with behavior delays / 含行为延迟的指纹配置
*/
export async function humanType(
page: Page,
selector: string,
text: string,
profile: FingerprintProfile,
): Promise<void> {
// Focus the element by clicking it / 通过点击聚焦元素
const locator = page.locator(selector);
await locator.click();
// Clear existing text (optional, but common for form fields) / 清除现有文本
// We use triple-click + Backspace for natural clearing
await locator.click({ clickCount: 3 });
await page.keyboard.press('Backspace');
for (let i = 0; i < text.length; i++) {
const char = text[i];
// Keydown / 按下
await page.keyboard.down(char);
// Random delay between keydown and keypress / keydown 与 keypress 之间的随机延迟
await sleep(randBetween(50, 150));
// Keypress — use insertText for reliable character input / 使用 insertText 实现可靠输入
await page.keyboard.insertText(char);
// Small fixed delay before keyup / keyup 前的小固定延迟
await sleep(10);
// Keyup / 释放
await page.keyboard.up(char);
// Inter-character delay from profile / 字符间延迟根据配置
if (i < text.length - 1) {
await sleep(
randBetween(
profile.behavior.keyPressDelay.min,
profile.behavior.keyPressDelay.max,
),
);
}
}
}
/**
* Simulates human-like scrolling.
* If `toBottom`: scrolls in 100-200px steps with delays until the bottom is reached.
* If `deltaY`: scrolls that amount in chunks with random delays.
*
* @param page Playwright Page
* @param opts { deltaY?: number; toBottom?: boolean }
* @param profile Fingerprint profile with behavior delays / 含行为延迟的指纹配置
*/
export async function humanScroll(
page: Page,
opts: { deltaY?: number; toBottom?: boolean },
profile: FingerprintProfile,
): Promise<void> {
if (opts.toBottom) {
// Scroll to bottom in steps / 分步滚动到底部
let reachedBottom = false;
while (!reachedBottom) {
const beforeScroll = await page.evaluate(() => window.scrollY);
const step = randBetween(100, 200);
// Dispatch wheel event for realism / 分发真实的 wheel 事件
await page.mouse.wheel(0, step);
// Small delay between steps / 步间小延迟
await sleep(
randBetween(
profile.behavior.scrollStepDelay.min,
profile.behavior.scrollStepDelay.max,
),
);
const afterScroll = await page.evaluate(() => window.scrollY);
// Also check if we're at page bottom / 同时检查是否已到底
const atBottom = await page.evaluate(() => {
return window.innerHeight + window.scrollY >= document.body.scrollHeight;
});
if (afterScroll === beforeScroll || atBottom) {
reachedBottom = true;
}
}
} else if (opts.deltaY !== undefined) {
// Scroll a specific amount in chunks / 分块滚动指定距离
const total = Math.abs(opts.deltaY);
const direction = Math.sign(opts.deltaY);
let remaining = total;
while (remaining > 0) {
const chunk = Math.min(randBetween(50, 200), remaining);
await page.mouse.wheel(0, direction * chunk);
remaining -= chunk;
if (remaining > 0) {
await sleep(
randBetween(
profile.behavior.scrollStepDelay.min,
profile.behavior.scrollStepDelay.max,
),
);
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// Stealth Injection Orchestrator — single entry point for all stealth modules / 隐身注入编排器 — 所有隐身模块的统一入口
import type { BrowserContext, Page } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
import { injectNavigatorStealth } from './navigator.js';
import { injectChromeRuntime } from './chrome-runtime.js';
import { injectScreenStealth } from './screen.js';
import { injectPermissionsStealth } from './permissions.js';
import { injectCanvasNoise } from './canvas-noise.js';
import { injectHeaderStealth } from './headers.js';
// Re-export for external use / 重新导出供外部使用
export { getProfile, listProfiles } from './profiles/index.js';
export { humanClick, humanType, humanScroll } from './human-input.js';
export { injectHeaderStealth } from './headers.js';
/**
* Apply all stealth injections to a newly created page.
* Phase 1: addInitScript (must run before navigation).
* Phase 2: page.route interception (must run after page creation).
*
* @param context BrowserContext
* @param page Playwright Page
* @param profile Fingerprint profile to emulate
*/
export async function applyStealth(
context: BrowserContext,
page: Page,
profile: FingerprintProfile,
): Promise<void> {
// Phase 1: initScript injections (must happen before page navigation)
// 阶段 1: initScript 注入(必须在页面导航前执行)
await injectNavigatorStealth(context, profile);
await injectChromeRuntime(context);
await injectScreenStealth(context, profile);
await injectPermissionsStealth(context, profile);
if (profile.canvasNoise.enabled) {
await injectCanvasNoise(context, profile.canvasNoise);
}
// Phase 2: page.route interception (must happen after page creation)
// 阶段 2: page.route 拦截(必须在页面创建后执行)
await injectHeaderStealth(page, profile);
}
+46
View File
@@ -0,0 +1,46 @@
// Stealth navigator property overrides / 隐身导航属性覆盖
import type { BrowserContext } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
export async function injectNavigatorStealth(
context: BrowserContext,
profile: FingerprintProfile
): Promise<void> {
await context.addInitScript((opts) => {
// Override navigator properties; wrap in try-catch because stealth plugin may have already
// defined some as non-configurable / 用 try-catch 包裹,因为 stealth 插件可能已将某些属性设为不可重定义
function safeDefine(obj: any, prop: string, getter: () => any) {
try { Object.defineProperty(obj, prop, { get: getter, configurable: true }); } catch { /* already set */ }
}
safeDefine(navigator, 'webdriver', () => false);
if (navigator.plugins.length === 0) {
safeDefine(navigator, 'plugins', () => {
const arr = Object.create(PluginArray.prototype);
arr.length = 0;
arr.item = () => null;
arr.namedItem = () => null;
arr.refresh = () => {};
return arr;
});
}
safeDefine(navigator, 'languages', () => opts.languages);
safeDefine(navigator, 'platform', () => opts.platform);
safeDefine(navigator, 'vendor', () => 'Google Inc.');
safeDefine(navigator, 'productSub', () => '20030107');
if (!('connection' in navigator)) {
safeDefine(navigator, 'connection', () => ({
downlink: 10,
effectiveType: '4g',
rtt: 50,
saveData: false,
}));
}
}, {
languages: profile.languages,
platform: profile.platform,
});
}
@@ -0,0 +1,45 @@
// Stealth — override navigator.permissions.query() at JS level / 隐身 — 在 JS 层面覆盖 navigator.permissions.query()
import type { BrowserContext } from 'playwright';
import type { FingerprintProfile, PermissionState } from '@visionl/core';
const WATCHED_NAMES = ['notifications', 'geolocation', 'camera', 'microphone'] as const;
export async function injectPermissionsStealth(
context: BrowserContext,
profile: FingerprintProfile,
): Promise<void> {
const perms: Record<string, PermissionState> = {};
for (const name of WATCHED_NAMES) {
perms[name] = profile.permissions[name];
}
await context.addInitScript((opts) => {
const permissionsNameSet = new Set<string>(opts.watchedNames);
const permissionsMap: Record<string, string> = opts.stateMap;
const origQuery = navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query = function (descriptor: PermissionDescriptor): Promise<PermissionStatus> {
const name = descriptor.name;
// Intercept watched permission names only / 仅拦截关注的权限名称
if (permissionsNameSet.has(name) && name in permissionsMap) {
return Promise.resolve({
name,
state: permissionsMap[name],
onchange: null,
addEventListener() {},
removeEventListener() {},
dispatchEvent(): boolean { return true; },
} as PermissionStatus);
}
// Pass through other queries / 透传其他权限查询
return origQuery(descriptor);
};
}, {
watchedNames: [...WATCHED_NAMES],
stateMap: perms,
});
}
@@ -0,0 +1,32 @@
// Default desktop Chrome fingerprint (Linux) / 默认桌面 Chrome 指纹 (Linux)
import type { FingerprintProfile } from '@visionl/core';
export const desktopChrome: FingerprintProfile = {
id: 'desktop-chrome',
name: '桌面 Chrome (通用)',
userAgent:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
platform: 'Linux x86_64',
languages: ['zh-CN', 'en-US'],
acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 },
viewport: { width: 1920, height: 937 },
webgl: {
vendor: 'Google Inc. (Intel)',
renderer:
'ANGLE (Intel, Mesa Intel(R) UHD Graphics (CML GT2), OpenGL 4.6)',
},
timezone: 'Asia/Shanghai',
permissions: {
notifications: 'prompt',
geolocation: 'prompt',
camera: 'prompt',
microphone: 'prompt',
},
behavior: {
mouseMoveDelay: { min: 5, max: 15 },
keyPressDelay: { min: 50, max: 150 },
scrollStepDelay: { min: 20, max: 40 },
},
canvasNoise: { enabled: true, strength: 0.3 },
};
@@ -0,0 +1,31 @@
// macOS Chrome fingerprint / macOS Chrome 指纹
import type { FingerprintProfile } from '@visionl/core';
export const desktopMac: FingerprintProfile = {
id: 'desktop-mac',
name: '桌面 Chrome (macOS)',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
platform: 'MacIntel',
languages: ['zh-CN', 'en-US'],
acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 2 },
viewport: { width: 1920, height: 937 },
webgl: {
vendor: 'Apple Inc.',
renderer: 'Apple M1',
},
timezone: 'Asia/Shanghai',
permissions: {
notifications: 'prompt',
geolocation: 'prompt',
camera: 'prompt',
microphone: 'prompt',
},
behavior: {
mouseMoveDelay: { min: 5, max: 15 },
keyPressDelay: { min: 50, max: 150 },
scrollStepDelay: { min: 20, max: 40 },
},
canvasNoise: { enabled: true, strength: 0.3 },
};
@@ -0,0 +1,32 @@
// Windows 10 Chrome fingerprint / Windows 10 Chrome 指纹
import type { FingerprintProfile } from '@visionl/core';
export const desktopWindows: FingerprintProfile = {
id: 'desktop-windows',
name: '桌面 Chrome (Windows)',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
platform: 'Win32',
languages: ['zh-CN', 'en-US'],
acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8',
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 },
viewport: { width: 1920, height: 937 },
webgl: {
vendor: 'Google Inc. (NVIDIA)',
renderer:
'ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 Ti Direct3D11 vs_5_0 ps_5_0)',
},
timezone: 'Asia/Shanghai',
permissions: {
notifications: 'prompt',
geolocation: 'prompt',
camera: 'prompt',
microphone: 'prompt',
},
behavior: {
mouseMoveDelay: { min: 5, max: 15 },
keyPressDelay: { min: 50, max: 150 },
scrollStepDelay: { min: 20, max: 40 },
},
canvasNoise: { enabled: true, strength: 0.3 },
};
@@ -0,0 +1,21 @@
// Built-in fingerprint profiles registry / 内置指纹配置注册表
import type { FingerprintProfile } from '@visionl/core';
import { desktopChrome } from './desktop-chrome.js';
import { desktopWindows } from './desktop-windows.js';
import { desktopMac } from './desktop-mac.js';
const profiles = new Map<string, FingerprintProfile>([
[desktopChrome.id, desktopChrome],
[desktopWindows.id, desktopWindows],
[desktopMac.id, desktopMac],
]);
export function getProfile(id: string): FingerprintProfile | undefined {
return profiles.get(id);
}
export function listProfiles(): Array<{ id: string; name: string }> {
return Array.from(profiles.values()).map(({ id, name }) => ({ id, name }));
}
export { desktopChrome, desktopWindows, desktopMac };
+53
View File
@@ -0,0 +1,53 @@
// Stealth — override screen and window dimension properties / 隐身 — 覆盖屏幕和窗口尺寸属性
import type { BrowserContext } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
const TASKBAR_HEIGHT = 40; // typical taskbar height in px / 典型任务栏高度(像素)
export async function injectScreenStealth(
context: BrowserContext,
profile: FingerprintProfile,
): Promise<void> {
const { screen: screenProfile, viewport } = profile;
await context.addInitScript((opts) => {
// Override screen object / 覆盖 screen 对象
Object.defineProperty(screen, 'width', {
get: () => opts.screenWidth,
});
Object.defineProperty(screen, 'height', {
get: () => opts.screenHeight,
});
Object.defineProperty(screen, 'availWidth', {
get: () => opts.availWidth,
});
Object.defineProperty(screen, 'availHeight', {
get: () => opts.availHeight,
});
Object.defineProperty(screen, 'colorDepth', {
get: () => opts.colorDepth,
});
// Override devicePixelRatio / 覆盖设备像素比
Object.defineProperty(window, 'devicePixelRatio', {
get: () => opts.pixelRatio,
});
// Override window.outerWidth/Height > inner for window decorations / 覆盖 outerWidth/Height 使其 > inner 模拟窗口装饰
Object.defineProperty(window, 'outerWidth', {
get: () => opts.outerWidth,
});
Object.defineProperty(window, 'outerHeight', {
get: () => opts.outerHeight,
});
}, {
screenWidth: screenProfile.width,
screenHeight: screenProfile.height,
availWidth: screenProfile.width - TASKBAR_HEIGHT, // side taskbar scenario / 侧边任务栏场景
availHeight: screenProfile.height - TASKBAR_HEIGHT,
colorDepth: screenProfile.colorDepth,
pixelRatio: screenProfile.pixelRatio,
outerWidth: viewport.width + 16, // window frame chrome / 窗口边框
outerHeight: viewport.height + 72, // title bar + window frame / 标题栏 + 窗口边框
});
}
+39
View File
@@ -0,0 +1,39 @@
// WebSocket relay — broadcasts WsEvent JSON to all connected clients / WebSocket 中继,向所有连接的客户端广播事件
import type http from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import type { WsEvent } from '@visionl/core';
let wss: WebSocketServer | null = null;
export function createWsRelay(server: http.Server): WebSocketServer {
wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws: WebSocket) => {
console.log('[ws-relay] Client connected, total:', wss!.clients.size);
ws.on('close', () => {
console.log('[ws-relay] Client disconnected, total:', wss!.clients.size);
});
ws.on('error', (err: Error) => {
console.error('[ws-relay] Client error:', err.message);
});
});
wss.on('error', (err: Error) => {
console.error('[ws-relay] Server error:', err.message);
});
console.log('[ws-relay] WebSocket relay attached at path /ws');
return wss;
}
export function broadcast(event: WsEvent): void {
if (!wss) return;
const payload = JSON.stringify(event);
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"references": [{ "path": "../core" }]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"composite": true,
"outDir": "./dist",
"rootDir": "."
},
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/daemon" },
{ "path": "./packages/cli" }
],
"files": []
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['packages/*/src/**/*.test.ts'],
},
});