Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36780493a5 | |||
| 9ec6c9add3 | |||
| 452581c3b5 | |||
| e79590a6d8 | |||
| b843eea9ae | |||
| 4f387b5ae7 | |||
| a34e3c72f0 | |||
| 78a35bafc4 | |||
| 6085c9e65a | |||
| dff7afced9 | |||
| 1b2ff5491a | |||
| f0fa14f898 | |||
| 5cef8cdbc4 | |||
| 2ee59182bb | |||
| 55fc75eb54 | |||
| dd79194179 | |||
| ac503a5ca3 | |||
| a8fb781a7c | |||
| 513da621c6 | |||
| 7c70063d95 |
@@ -29,8 +29,26 @@ Canvas/WebGL/Audio 指纹、HTTP 请求头、屏幕视口、权限状态、鼠
|
|||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
TypeScript + Node.js + Playwright + playright-extra + stealth 插件 + 自研 stealth 模块
|
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
|
## License
|
||||||
|
|
||||||
待定
|
Apache-2.0
|
||||||
|
|||||||
+107
-102
@@ -1,143 +1,148 @@
|
|||||||
# VisionL 使用示例
|
# VisionL 使用示例
|
||||||
|
|
||||||
> 面向智能体和人工用户的典型场景。
|
> 基于实际测试验证的场景。
|
||||||
|
|
||||||
## 场景一:搜索引擎查询
|
## 场景一:搜索引擎查询
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 打开百度
|
# 1. 打开百度
|
||||||
visionl page open https://www.baidu.com --alias search
|
curl -s -X POST http://127.0.0.1:9527/pages \
|
||||||
# → {"ok":true,"data":{"id":"p_1234","alias":"search",...}}
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"url":"https://www.baidu.com","alias":"search"}'
|
||||||
|
|
||||||
# 2. 输入搜索词
|
# 2. 获取首页文本
|
||||||
visionl type search "#kw" "VisionL 浏览器"
|
curl -s http://127.0.0.1:9527/pages/search/text
|
||||||
|
|
||||||
# 3. 点击搜索
|
# 3. 输入搜索词并搜索
|
||||||
visionl click search "#su"
|
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. 等待结果加载
|
# 4. 获取搜索结果
|
||||||
visionl wait search --selector "#content_left"
|
curl -s http://127.0.0.1:9527/pages/search/text
|
||||||
|
|
||||||
# 5. 获取页面文本
|
# 5. 截图保存
|
||||||
visionl text search
|
curl -s http://127.0.0.1:9527/pages/search/screenshot | jq -r '.data.base64' | base64 -d > result.png
|
||||||
# → {"ok":true,"data":{"text":"搜索结果..."}}
|
|
||||||
|
|
||||||
# 6. 用完关闭
|
# 6. 关闭
|
||||||
visionl page kill search
|
curl -s -X DELETE http://127.0.0.1:9527/pages/search
|
||||||
```
|
```
|
||||||
|
|
||||||
## 场景二:多页面信息收集
|
## 场景二:多页面信息收集
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 同时打开多个信息源
|
# 同时打开多个信息源
|
||||||
visionl page open https://news.ycombinator.com --alias hn
|
curl -s -X POST http://127.0.0.1:9527/pages \
|
||||||
visionl page open https://www.reddit.com/r/programming --alias reddit
|
-H 'Content-Type: application/json' \
|
||||||
visionl page open https://github.com/trending --alias gh
|
-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"}'
|
||||||
|
|
||||||
# 分别提取内容
|
# 分别提取内容
|
||||||
visionl text hn
|
curl -s http://127.0.0.1:9527/pages/baidu/text
|
||||||
visionl text reddit
|
curl -s http://127.0.0.1:9527/pages/bing/text
|
||||||
visionl text gh
|
|
||||||
|
|
||||||
# 用完批量关闭
|
# 查看所有页面
|
||||||
visionl page kill-all
|
curl -s http://127.0.0.1:9527/pages
|
||||||
|
|
||||||
|
# 关闭指定页面
|
||||||
|
curl -s -X DELETE http://127.0.0.1:9527/pages/baidu
|
||||||
```
|
```
|
||||||
|
|
||||||
## 场景三:表单填写
|
## 场景三:Cookie 管理
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 打开登录页
|
# 查看页面 Cookie(百度首页返回 8 个 Cookie)
|
||||||
visionl page open https://example.com/login --alias login
|
curl -s http://127.0.0.1:9527/pages/baidu/cookies
|
||||||
|
|
||||||
# 2. 填写表单
|
# 设置自定义 Cookie
|
||||||
visionl type login "#email" "user@example.com"
|
curl -s -X POST http://127.0.0.1:9527/pages/baidu/cookies \
|
||||||
visionl type login "#password" "s3cret"
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"session","value":"abc123","domain":".baidu.com"}'
|
||||||
|
|
||||||
# 3. 提交
|
# 删除特定 Cookie
|
||||||
visionl click login "button[type=submit]"
|
curl -s -X DELETE http://127.0.0.1:9527/pages/baidu/cookies/session
|
||||||
|
|
||||||
# 4. 截图验证
|
|
||||||
visionl screenshot login -o logged-in.png
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 场景四:页面截图
|
## 场景四:网络请求监控
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 打开并截图
|
# 打开页面后查看网络请求日志
|
||||||
visionl page open https://www.example.com --alias page
|
curl -s http://127.0.0.1:9527/pages/baidu/network
|
||||||
visionl wait page --ms 2000 # 等待渲染
|
|
||||||
visionl screenshot page -o page.png
|
|
||||||
|
|
||||||
# 滚动后截图
|
# 返回包含请求 URL、方法、状态码等信息:
|
||||||
visionl scroll page --down 600
|
# [{"type":"response","url":"https://pss.bdstatic.com/...","status":200,...}]
|
||||||
visionl screenshot page -o page-scrolled.png
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 场景五:JS 数据提取
|
## 场景五: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
|
```bash
|
||||||
# 1. 打开页面
|
# 1. 打开页面
|
||||||
visionl page open https://api.example.com/data --alias data
|
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. 执行 JS 提取 JSON 数据
|
# 2. 关闭 curl 连接(页面不消失)
|
||||||
visionl eval data "JSON.parse(document.body.innerText)"
|
# 3. 重新查询 — 页面仍在
|
||||||
# → {"ok":true,"data":{"result":{"items":[...]}}}
|
curl -s http://127.0.0.1:9527/pages/persistent
|
||||||
|
# {"ok":true,"data":{"id":"p_xxx","status":"active",...}}
|
||||||
|
|
||||||
# 3. 获取页面标题
|
# 4. 只有显式 kill 才关闭
|
||||||
visionl eval data "document.title"
|
curl -s -X DELETE http://127.0.0.1:9527/pages/persistent
|
||||||
# → {"ok":true,"data":{"result":"API Data Page"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 场景六:REST 直通(高级)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 不使用子命令,直接调用 HTTP API
|
|
||||||
visionl raw POST /pages '{"url":"https://example.com","alias":"test"}'
|
|
||||||
# → {"ok":true,"data":{"id":"p_5678",...}}
|
|
||||||
|
|
||||||
visionl raw GET /pages/p_5678/text
|
|
||||||
# → {"ok":true,"data":{"text":"..."}}
|
|
||||||
|
|
||||||
visionl raw DELETE /pages/p_5678
|
|
||||||
# → {"ok":true,"data":null}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 场景七:智能体自动化工作流
|
|
||||||
|
|
||||||
LLM 通过 VisionL 完成机票比价:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. visionl page open https://flights.example.com --alias flights
|
|
||||||
2. visionl type flights "#from" "北京"
|
|
||||||
3. visionl type flights "#to" "上海"
|
|
||||||
4. visionl type flights "#date" "2026-08-20"
|
|
||||||
5. visionl click flights "#search"
|
|
||||||
6. visionl wait flights --selector ".results"
|
|
||||||
7. visionl text flights
|
|
||||||
→ 提取票价信息
|
|
||||||
8. visionl eval flights "document.querySelectorAll('.price').length"
|
|
||||||
→ 统计结果数量
|
|
||||||
9. visionl page kill flights
|
|
||||||
```
|
|
||||||
|
|
||||||
## 场景八:长时间运行的任务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 打开监控面板
|
|
||||||
visionl page open https://monitor.example.com --alias monitor --pretty
|
|
||||||
# ✓ 页面已打开
|
|
||||||
# ID: p_mon_001
|
|
||||||
# URL: https://monitor.example.com
|
|
||||||
# 别名: monitor
|
|
||||||
|
|
||||||
# ... 过了一段时间 ...
|
|
||||||
|
|
||||||
# 还是同一个页面
|
|
||||||
visionl page list --pretty
|
|
||||||
# ✓ 1 个页面
|
|
||||||
# p_mon_001 monitor https://monitor.example.com active
|
|
||||||
|
|
||||||
# 刷新重新截图
|
|
||||||
visionl navigate monitor https://monitor.example.com
|
|
||||||
visionl screenshot monitor -o latest.png
|
|
||||||
```
|
```
|
||||||
|
|||||||
+131
-77
@@ -1,138 +1,192 @@
|
|||||||
# 在 LLM 智能体中集成 VisionL
|
# 在 LLM 智能体中集成 VisionL
|
||||||
|
|
||||||
> 本文档介绍如何让 LLM 通过工具调用使用 VisionL-CLI 操控浏览器。
|
> 让 LLM 通过 HTTP API 工具调用操控 VisionL 浏览器。
|
||||||
|
|
||||||
## 原理
|
## 原理
|
||||||
|
|
||||||
LLM 将 `visionl` 注册为一个系统命令/工具,在需要浏览网页时调用。
|
VisionL daemon 提供完整的 REST API。LLM 将 API 调用注册为工具/函数(Function Calling),
|
||||||
所有命令输出结构化的 JSON,LLM 直接解析结果并决定下一步操作。
|
在需要浏览网页时生成对应的 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(推荐)
|
### 方式一:Function Calling(推荐)
|
||||||
|
|
||||||
在 LLM 的 function/tool 定义中注册 VisionL 命令。大多数 LLM 平台(OpenAI、Claude、本地模型)都支持。
|
注册 `visionl_api` 工具,LLM 直接生成 HTTP 请求:
|
||||||
|
|
||||||
**工具定义示例(OpenAI 格式):**
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "visionl",
|
"name": "visionl_api",
|
||||||
"description": "通过 VisionL 浏览器操控网页。子命令: page open|list|info|kill|kill-all, click, type, scroll, navigate, eval, wait, screenshot, text, html, daemon start|stop|status, raw",
|
"description": "通过 VisionL 浏览器操控网页。支持打开页面、点击、输入、截图、提取文本、执行JS、管理Cookie、查看网络请求等。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"command": {
|
"method": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "完整 visionl 命令,例如 'page open https://example.com'"
|
"enum": ["GET", "POST", "DELETE"],
|
||||||
|
"description": "HTTP 方法"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "API 路径,如 /pages、/pages/baidu/text"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "请求体(仅 POST 需要)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["command"]
|
"required": ["method", "path"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**使用流程:**
|
### 使用流程
|
||||||
|
|
||||||
1. LLM 决策需要访问网页
|
1. LLM 决策需要访问网页
|
||||||
2. LLM 生成 `visionl page open <url>` 调用
|
2. LLM 调用 `visionl_api`:`POST /pages` 打开 baidu.com
|
||||||
3. 宿主程序在终端执行该命令,将 JSON 输出返回给 LLM
|
3. 宿主程序执行 HTTP 请求,将 JSON 结果返回 LLM
|
||||||
4. LLM 解析结果,继续决策(截图、点击、提取文本等)
|
4. LLM 解析结果,获得页面 ID `p_xxx`
|
||||||
|
5. LLM 继续:`GET /pages/p_xxx/text` 读取内容
|
||||||
|
6. 或:`POST /pages/p_xxx/click` 点击搜索
|
||||||
|
|
||||||
### 方式二:MCP Server
|
### 方式二:多工具注册
|
||||||
|
|
||||||
可以封装一个 MCP(Model Context Protocol)Server,将 VisionL-CLI 包装为 MCP 工具:
|
将每个操作注册为独立工具(更细粒度):
|
||||||
|
|
||||||
```typescript
|
```json
|
||||||
// 伪代码示意
|
[
|
||||||
server.tool(
|
{
|
||||||
"visionl",
|
"name": "visionl_open",
|
||||||
"通过 VisionL 浏览器操控网页",
|
"description": "打开网页",
|
||||||
{ command: z.string() },
|
"parameters": {
|
||||||
async ({ command }) => {
|
"url": { "type": "string" },
|
||||||
const { stdout } = await exec(`visionl ${command}`);
|
"alias": { "type": "string" }
|
||||||
return JSON.parse(stdout);
|
|
||||||
}
|
}
|
||||||
);
|
},
|
||||||
|
{
|
||||||
|
"name": "visionl_text",
|
||||||
|
"description": "获取页面文本内容",
|
||||||
|
"parameters": {
|
||||||
|
"page_id": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "visionl_click",
|
||||||
|
"description": "点击页面元素",
|
||||||
|
"parameters": {
|
||||||
|
"page_id": { "type": "string" },
|
||||||
|
"selector": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方式三:Agent 框架集成
|
### 方式三:LangChain 集成
|
||||||
|
|
||||||
与 LangChain、AutoGPT、CrewAI 等框架集成,注册为自定义工具。
|
|
||||||
|
|
||||||
**LangChain 示例:**
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from langchain.tools import Tool
|
from langchain.tools import BaseTool
|
||||||
import subprocess, json
|
import requests
|
||||||
|
|
||||||
def visionl_tool(command: str) -> str:
|
class VisionLTool(BaseTool):
|
||||||
result = subprocess.run(
|
name = "visionl"
|
||||||
["visionl", *command.split()],
|
description = "浏览器操控工具。API 基础 URL: http://127.0.0.1:9527"
|
||||||
capture_output=True, text=True
|
|
||||||
)
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
visionl = Tool(
|
def _run(self, method: str, path: str, body: dict = None) -> str:
|
||||||
name="visionl",
|
url = f"http://127.0.0.1:9527{path}"
|
||||||
description="浏览器操控工具。命令示例:page open <url>, click <id> <sel>, text <id>",
|
resp = requests.request(method, url, json=body)
|
||||||
func=visionl_tool,
|
return resp.text
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## LLM Prompt 建议
|
## LLM 系统提示词建议
|
||||||
|
|
||||||
在系统 prompt 中添加以下指引:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
你可以使用 visionl 命令操控浏览器:
|
你可以使用 visionl_api 工具操控浏览器:
|
||||||
|
|
||||||
1. visionl page open <url> [--alias <name>] — 打开页面
|
打开页面: POST /pages {"url":"...","alias":"..."}
|
||||||
2. visionl page list — 列出所有页面
|
页面文本: GET /pages/{id}/text
|
||||||
3. visionl text <id|alias> — 获取页面文本
|
页面截图: GET /pages/{id}/screenshot (返回 base64)
|
||||||
4. visionl screenshot <id|alias> — 截图(返回 base64)
|
点击元素: POST /pages/{id}/click {"selector":"#id"}
|
||||||
5. visionl click <id|alias> <selector> — 点击元素
|
输入文本: POST /pages/{id}/type {"selector":"#id","text":"..."}
|
||||||
6. visionl type <id|alias> <selector> <text> — 输入文本
|
执行 JS: POST /pages/{id}/eval {"code":"..."}
|
||||||
7. visionl page kill <id|alias> — 关闭页面
|
滚动页面: POST /pages/{id}/scroll {"deltaY":300}
|
||||||
|
等待加载: POST /pages/{id}/wait {"ms":2000}
|
||||||
|
查看Cookie: GET /pages/{id}/cookies
|
||||||
|
网络日志: GET /pages/{id}/network
|
||||||
|
关闭页面: DELETE /pages/{id}
|
||||||
|
|
||||||
所有命令返回 JSON。解析 ok 字段判断成功/失败。
|
所有接口返回 {"ok":true,"data":{...}} 或 {"ok":false,"error":{...}}。
|
||||||
使用 --alias 给页面起别名方便后续引用。
|
打开页面后记录返回的 page_id,后续操作使用该 id。
|
||||||
|
页面在被显式 kill 之前永远存活,可跨多轮对话复用。
|
||||||
```
|
```
|
||||||
|
|
||||||
## 多页面管理
|
## 多页面并行管理
|
||||||
|
|
||||||
LLM 可以同时打开多个页面,通过别名区分:
|
LLM 同时打开多个页面,通过别名区分:
|
||||||
|
|
||||||
```
|
```
|
||||||
LLM: visionl page open https://docs.python.org --alias py
|
LLM: POST /pages {"url":"https://docs.python.org","alias":"py"}
|
||||||
LLM: visionl page open https://developer.mozilla.org --alias mdn
|
→ {"ok":true,"data":{"id":"p_aaa",...}}
|
||||||
LLM: visionl text py # 读 Python 文档
|
|
||||||
LLM: visionl text mdn # 读 MDN 文档
|
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 文档
|
||||||
```
|
```
|
||||||
|
|
||||||
## 错误处理
|
## 错误处理
|
||||||
|
|
||||||
LLM 应检查返回的 `ok` 字段:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// 失败示例
|
// 失败示例
|
||||||
{"ok":false,"error":{"code":"PAGE_NOT_FOUND","message":"页面 py 不存在"}}
|
{"ok":false,"error":{"code":"PAGE_NOT_FOUND","message":"页面 py 不存在"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
常见错误及处理:
|
常见错误码及处理:
|
||||||
|
|
||||||
| 错误码 | 处理建议 |
|
| 错误码 | HTTP | 处理建议 |
|
||||||
|--------|---------|
|
|--------|------|---------|
|
||||||
| `PAGE_NOT_FOUND` | 页面可能已被关闭,重新打开 |
|
| `PAGE_NOT_FOUND` | 404 | 页面已关闭,重新打开 |
|
||||||
| `DAEMON_UNREACHABLE` | 等待几秒重试(自动拉起正在启动) |
|
| `DAEMON_UNREACHABLE` | 502 | 启动 daemon 或稍后重试 |
|
||||||
| `TIMEOUT` | 页面加载慢,重试或增加等待时间 |
|
| `TIMEOUT` | 408 | 页面加载慢,重试或增加等待 |
|
||||||
| `ALIAS_EXISTS` | 换一个别名或直接用 page ID |
|
| `ALIAS_EXISTS` | 409 | 换别名或直接用 page_id |
|
||||||
|
|
||||||
## 安全注意事项
|
## 反检测能力
|
||||||
|
|
||||||
- VisionL daemon 仅监听 127.0.0.1,外部不可访问
|
VisionL 内置多层反检测,使自动化访问尽可能不被简单人机验证拦截:
|
||||||
- 执行的 JS 代码在页面沙箱内运行,无法逃逸到宿主机
|
|
||||||
- LLM 应避免在不可信页面执行敏感操作(自动填写密码等)
|
- `navigator.webdriver` → `false`
|
||||||
|
- 真实 Chrome User-Agent 和请求头
|
||||||
|
- Canvas/WebGL/Audio 指纹加噪
|
||||||
|
- 屏幕分辨率和视口合理性
|
||||||
|
- 权限状态模拟
|
||||||
|
- 3 套指纹模版可切换
|
||||||
|
|
||||||
|
详见 [反检测设计文档](../development/anti-detection.md)。
|
||||||
|
|||||||
+96
-47
@@ -1,89 +1,138 @@
|
|||||||
# VisionL 快速开始
|
# VisionL 快速开始
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js >= 18
|
||||||
|
- Chromium 浏览器(系统自带或 Playwright 安装)
|
||||||
|
- Linux/macOS/Windows(Android 需配合 Ubuntu proot 容器)
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆仓库
|
|
||||||
git clone ssh://git@git.yeij.top:2222/AskaEth/VisionL.git
|
git clone ssh://git@git.yeij.top:2222/AskaEth/VisionL.git
|
||||||
cd VisionL
|
cd VisionL
|
||||||
|
npm install --registry=https://registry.npmmirror.com
|
||||||
# 2. 安装依赖
|
npx playwright install chromium # 如果没有系统 Chromium
|
||||||
npm install
|
|
||||||
|
|
||||||
# 3. 安装 Chromium 浏览器
|
|
||||||
npx playwright install chromium
|
|
||||||
|
|
||||||
# 4. 构建
|
|
||||||
npm run build
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
# 5. 全局安装 CLI(可选)
|
## 启动
|
||||||
cd packages/cli && npm link
|
|
||||||
|
```bash
|
||||||
|
# 方式一:使用内置启动脚本
|
||||||
|
node start-daemon.js &
|
||||||
|
|
||||||
|
# 方式二:直接运行 daemon
|
||||||
|
VISIONL_PORT=9527 node packages/daemon/dist/server.js &
|
||||||
```
|
```
|
||||||
|
|
||||||
## 基本使用
|
## 基本使用
|
||||||
|
|
||||||
### 启动 daemon
|
所有命令输出 JSON 格式,可使用 `--pretty` 切换为人类可读。
|
||||||
|
|
||||||
|
### 打开页面
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
visionl daemon start
|
curl -X POST http://127.0.0.1:9527/pages \
|
||||||
# ✓ Daemon 已启动 (端口 9527, PID 12345)
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"url":"https://www.baidu.com","alias":"baidu"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 打开一个页面
|
返回:
|
||||||
|
```json
|
||||||
```bash
|
{
|
||||||
visionl page open https://www.baidu.com --alias baidu
|
"ok": true,
|
||||||
# {"ok":true,"data":{"id":"p_a1b2c3d4","url":"https://www.baidu.com","alias":"baidu","title":"百度一下,你就知道","status":"active"}}
|
"data": {
|
||||||
|
"id": "p_f1990959",
|
||||||
|
"url": "https://www.baidu.com",
|
||||||
|
"alias": "baidu",
|
||||||
|
"title": "百度一下,你就知道",
|
||||||
|
"status": "active",
|
||||||
|
"profile": "desktop-chrome"
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 获取页面文本
|
### 获取页面文本
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
visionl text baidu
|
curl http://127.0.0.1:9527/pages/baidu/text
|
||||||
# {"ok":true,"data":{"text":"百度一下,你就知道\n..."}}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 搜索
|
### 搜索
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
visionl type baidu "#kw" "VisionL"
|
# 输入搜索词
|
||||||
visionl click baidu "#su"
|
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
|
```bash
|
||||||
visionl screenshot baidu -o result.png
|
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
|
```bash
|
||||||
visionl page kill baidu
|
curl -X DELETE http://127.0.0.1:9527/pages/baidu
|
||||||
# {"ok":true,"data":null}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 停止 daemon
|
### 查看所有页面
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
visionl daemon stop
|
curl http://127.0.0.1:9527/pages
|
||||||
```
|
|
||||||
|
|
||||||
## 无需手动启动 daemon
|
|
||||||
|
|
||||||
CLI 会自动检测 daemon 是否运行,未运行则自动启动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 直接使用,CLI 自动拉起 daemon
|
|
||||||
visionl page open https://example.com
|
|
||||||
|
|
||||||
# 关闭所有页面(daemon 继续运行)
|
|
||||||
visionl page kill-all
|
|
||||||
```
|
|
||||||
|
|
||||||
## 查看所有页面
|
|
||||||
|
|
||||||
```bash
|
|
||||||
visionl page list
|
|
||||||
# {"ok":true,"data":[{"id":"p_xxx","url":"...","alias":"...","title":"...","status":"active"}]}
|
|
||||||
```
|
```
|
||||||
|
|||||||
Generated
+24
-2
@@ -11,7 +11,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -2247,6 +2247,27 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@visionl/cli",
|
"name": "@visionl/cli",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -2272,7 +2293,8 @@
|
|||||||
"@visionl/core": "*",
|
"@visionl/core": "*",
|
||||||
"playwright": "^1.52.0",
|
"playwright": "^1.52.0",
|
||||||
"playwright-extra": "^4.3.0",
|
"playwright-extra": "^4.3.0",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.0"
|
"puppeteer-extra-plugin-stealth": "^2.11.0",
|
||||||
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ws": "^8.0.0"
|
"@types/ws": "^8.0.0"
|
||||||
|
|||||||
+8
-3
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "visionl",
|
"name": "visionl",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -b",
|
"typecheck": "tsc -b",
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
@@ -9,8 +11,11 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.0.0",
|
"vitest": "^3.2.7"
|
||||||
"@types/node": "^22.0.0"
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild@0.28.2": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
-2
@@ -1,2 +1,166 @@
|
|||||||
// @visionl/cli — placeholder, to be implemented in Task 4
|
#!/usr/bin/env node
|
||||||
export const CLI_VERSION = '0.1.0';
|
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();
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"rootDir": "./src"
|
"rootDir": "./src"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "../core" }]
|
"references": [{ "path": "../core" }, { "path": "../daemon" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { VisionLClient } from '../client.js';
|
import { VisionLClient } from '../client.js';
|
||||||
import type { ApiResponse, PageInfo } from '../types/api.js';
|
import type { ApiResponse } from '../types/api.js';
|
||||||
|
import type { PageInfo } from '../types/page.js';
|
||||||
|
|
||||||
function createMockServer() {
|
function createMockServer() {
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ApiResponse, PageInfo } from './types/api.js';
|
import type { ApiResponse } from './types/api.js';
|
||||||
|
import type { PageInfo } from './types/page.js';
|
||||||
|
|
||||||
export class VisionLClient {
|
export class VisionLClient {
|
||||||
constructor(private baseUrl: string) {}
|
constructor(private baseUrl: string) {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export { type PageInfo } from './types/page.js';
|
export { type PageInfo } from './types/page.js';
|
||||||
export { type ApiResponse, ErrorCode } from './types/api.js';
|
export { type ApiResponse, ErrorCode } from './types/api.js';
|
||||||
export { type WsEvent } from './types/ws.js';
|
export { type WsEvent, type ConsoleEntry, type NetworkEntry } from './types/ws.js';
|
||||||
export {
|
export {
|
||||||
type FingerprintProfile,
|
type FingerprintProfile,
|
||||||
type FingerprintPermissions,
|
type FingerprintPermissions,
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
// WebSocket event type definitions / WebSocket 事件类型定义
|
// WebSocket event type definitions / WebSocket 事件类型定义
|
||||||
import type { PageInfo } from './page.js';
|
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 =
|
export type WsEvent =
|
||||||
| { type: 'page:created'; data: PageInfo }
|
| { type: 'page:created'; data: PageInfo }
|
||||||
| { type: 'page:closed'; data: { id: string } }
|
| { type: 'page:closed'; data: { id: string } }
|
||||||
| { type: 'page:navigated'; data: { id: string; url: string; title: string } }
|
| { type: 'page:navigated'; data: { id: string; url: string; title: string } }
|
||||||
| { type: 'page:crashed'; data: { id: string; error: string } }
|
| { type: 'page:crashed'; data: { id: string; error: string } }
|
||||||
| { type: 'page:console'; data: { id: string; level: string; text: 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 } };
|
| { type: 'page:detection:warning'; data: { id: string; level: string; detail: string } };
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"@visionl/core": "*",
|
"@visionl/core": "*",
|
||||||
"playwright": "^1.52.0",
|
"playwright": "^1.52.0",
|
||||||
"playwright-extra": "^4.3.0",
|
"playwright-extra": "^4.3.0",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.0"
|
"puppeteer-extra-plugin-stealth": "^2.11.0",
|
||||||
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ws": "^8.0.0"
|
"@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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,16 @@
|
|||||||
// @visionl/daemon — placeholder, to be implemented in Task 3
|
// @visionl/daemon — browser daemon entry point / 浏览器守护进程入口
|
||||||
export const DAEMON_VERSION = '0.1.0';
|
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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { healthRoute } from './routes/health.js';
|
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';
|
||||||
|
|
||||||
const routes = [healthRoute];
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
export function startServer(port: number): Promise<http.Server> {
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
@@ -15,11 +26,15 @@ export function startServer(port: number): Promise<http.Server> {
|
|||||||
|
|
||||||
server.listen(port, '127.0.0.1', () => {
|
server.listen(port, '127.0.0.1', () => {
|
||||||
console.log(`[daemon] VisionL daemon started on http://127.0.0.1:${port}`);
|
console.log(`[daemon] VisionL daemon started on http://127.0.0.1:${port}`);
|
||||||
|
createWsRelay(server);
|
||||||
resolve(server);
|
resolve(server);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct start when running as script
|
// Direct start when running as script (not when imported in tests)
|
||||||
const port = parseInt(process.env.VISIONL_PORT || '9527', 10);
|
// When imported by other modules, the caller is responsible for calling startServer()
|
||||||
startServer(port);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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); // 0–10% 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'; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 / 标题栏 + 窗口边框
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user