Compare commits
7 Commits
78a35bafc4
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 36780493a5 | |||
| 9ec6c9add3 | |||
| 452581c3b5 | |||
| e79590a6d8 | |||
| b843eea9ae | |||
| 4f387b5ae7 | |||
| a34e3c72f0 |
@@ -29,7 +29,25 @@ 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
|
||||
|
||||
|
||||
+107
-102
@@ -1,143 +1,148 @@
|
||||
# VisionL 使用示例
|
||||
|
||||
> 面向智能体和人工用户的典型场景。
|
||||
> 基于实际测试验证的场景。
|
||||
|
||||
## 场景一:搜索引擎查询
|
||||
|
||||
```bash
|
||||
# 1. 打开百度
|
||||
visionl page open https://www.baidu.com --alias search
|
||||
# → {"ok":true,"data":{"id":"p_1234","alias":"search",...}}
|
||||
curl -s -X POST http://127.0.0.1:9527/pages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"url":"https://www.baidu.com","alias":"search"}'
|
||||
|
||||
# 2. 输入搜索词
|
||||
visionl type search "#kw" "VisionL 浏览器"
|
||||
# 2. 获取首页文本
|
||||
curl -s http://127.0.0.1:9527/pages/search/text
|
||||
|
||||
# 3. 点击搜索
|
||||
visionl click search "#su"
|
||||
# 3. 输入搜索词并搜索
|
||||
curl -s -X POST http://127.0.0.1:9527/pages/search/type \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"selector":"#kw","text":"VisionL 浏览器"}'
|
||||
curl -s -X POST http://127.0.0.1:9527/pages/search/click \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"selector":"#su"}'
|
||||
curl -s -X POST http://127.0.0.1:9527/pages/search/wait \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ms":2000}'
|
||||
|
||||
# 4. 等待结果加载
|
||||
visionl wait search --selector "#content_left"
|
||||
# 4. 获取搜索结果
|
||||
curl -s http://127.0.0.1:9527/pages/search/text
|
||||
|
||||
# 5. 获取页面文本
|
||||
visionl text search
|
||||
# → {"ok":true,"data":{"text":"搜索结果..."}}
|
||||
# 5. 截图保存
|
||||
curl -s http://127.0.0.1:9527/pages/search/screenshot | jq -r '.data.base64' | base64 -d > result.png
|
||||
|
||||
# 6. 用完关闭
|
||||
visionl page kill search
|
||||
# 6. 关闭
|
||||
curl -s -X DELETE http://127.0.0.1:9527/pages/search
|
||||
```
|
||||
|
||||
## 场景二:多页面信息收集
|
||||
|
||||
```bash
|
||||
# 同时打开多个信息源
|
||||
visionl page open https://news.ycombinator.com --alias hn
|
||||
visionl page open https://www.reddit.com/r/programming --alias reddit
|
||||
visionl page open https://github.com/trending --alias gh
|
||||
curl -s -X POST http://127.0.0.1:9527/pages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"url":"https://www.baidu.com","alias":"baidu"}'
|
||||
curl -s -X POST http://127.0.0.1:9527/pages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"url":"https://www.bing.com","alias":"bing"}'
|
||||
|
||||
# 分别提取内容
|
||||
visionl text hn
|
||||
visionl text reddit
|
||||
visionl text gh
|
||||
curl -s http://127.0.0.1:9527/pages/baidu/text
|
||||
curl -s http://127.0.0.1:9527/pages/bing/text
|
||||
|
||||
# 用完批量关闭
|
||||
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
|
||||
# 1. 打开登录页
|
||||
visionl page open https://example.com/login --alias login
|
||||
# 查看页面 Cookie(百度首页返回 8 个 Cookie)
|
||||
curl -s http://127.0.0.1:9527/pages/baidu/cookies
|
||||
|
||||
# 2. 填写表单
|
||||
visionl type login "#email" "user@example.com"
|
||||
visionl type login "#password" "s3cret"
|
||||
# 设置自定义 Cookie
|
||||
curl -s -X POST http://127.0.0.1:9527/pages/baidu/cookies \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"session","value":"abc123","domain":".baidu.com"}'
|
||||
|
||||
# 3. 提交
|
||||
visionl click login "button[type=submit]"
|
||||
|
||||
# 4. 截图验证
|
||||
visionl screenshot login -o logged-in.png
|
||||
# 删除特定 Cookie
|
||||
curl -s -X DELETE http://127.0.0.1:9527/pages/baidu/cookies/session
|
||||
```
|
||||
|
||||
## 场景四:页面截图
|
||||
## 场景四:网络请求监控
|
||||
|
||||
```bash
|
||||
# 打开并截图
|
||||
visionl page open https://www.example.com --alias page
|
||||
visionl wait page --ms 2000 # 等待渲染
|
||||
visionl screenshot page -o page.png
|
||||
# 打开页面后查看网络请求日志
|
||||
curl -s http://127.0.0.1:9527/pages/baidu/network
|
||||
|
||||
# 滚动后截图
|
||||
visionl scroll page --down 600
|
||||
visionl screenshot page -o page-scrolled.png
|
||||
# 返回包含请求 URL、方法、状态码等信息:
|
||||
# [{"type":"response","url":"https://pss.bdstatic.com/...","status":200,...}]
|
||||
```
|
||||
|
||||
## 场景五:JS 数据提取
|
||||
|
||||
```bash
|
||||
# 获取页面标题
|
||||
curl -s -X POST http://127.0.0.1:9527/pages/baidu/eval \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"code":"document.title"}'
|
||||
# {"ok":true,"data":{"result":"百度一下,你就知道"}}
|
||||
|
||||
# 获取链接数量
|
||||
curl -s -X POST http://127.0.0.1:9527/pages/baidu/eval \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"code":"document.querySelectorAll(\"a\").length"}'
|
||||
|
||||
# 获取页面 meta 信息
|
||||
curl -s -X POST http://127.0.0.1:9527/pages/baidu/eval \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"code":"document.querySelector(\"meta[name=description]\")?.content"}'
|
||||
```
|
||||
|
||||
## 场景六:滚动截图
|
||||
|
||||
```bash
|
||||
# 滚动页面
|
||||
curl -s -X POST http://127.0.0.1:9527/pages/baidu/scroll \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"deltaY":500}'
|
||||
|
||||
# 滚动到底部
|
||||
curl -s -X POST http://127.0.0.1:9527/pages/baidu/scroll \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"toBottom":true}'
|
||||
|
||||
# 截图
|
||||
curl -s http://127.0.0.1:9527/pages/baidu/screenshot | jq -r '.data.base64' | base64 -d > scrolled.png
|
||||
```
|
||||
|
||||
## 场景七:切换指纹配置
|
||||
|
||||
```bash
|
||||
# 查看可用配置
|
||||
curl -s http://127.0.0.1:9527/profiles
|
||||
|
||||
# 使用 Windows Chrome 指纹打开页面
|
||||
curl -s -X POST http://127.0.0.1:9527/pages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"url":"https://www.baidu.com","alias":"win","profile":"desktop-windows"}'
|
||||
```
|
||||
|
||||
## 场景八:页面持久化验证
|
||||
|
||||
```bash
|
||||
# 1. 打开页面
|
||||
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 数据
|
||||
visionl eval data "JSON.parse(document.body.innerText)"
|
||||
# → {"ok":true,"data":{"result":{"items":[...]}}}
|
||||
# 2. 关闭 curl 连接(页面不消失)
|
||||
# 3. 重新查询 — 页面仍在
|
||||
curl -s http://127.0.0.1:9527/pages/persistent
|
||||
# {"ok":true,"data":{"id":"p_xxx","status":"active",...}}
|
||||
|
||||
# 3. 获取页面标题
|
||||
visionl eval data "document.title"
|
||||
# → {"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
|
||||
# 4. 只有显式 kill 才关闭
|
||||
curl -s -X DELETE http://127.0.0.1:9527/pages/persistent
|
||||
```
|
||||
|
||||
+131
-77
@@ -1,138 +1,192 @@
|
||||
# 在 LLM 智能体中集成 VisionL
|
||||
|
||||
> 本文档介绍如何让 LLM 通过工具调用使用 VisionL-CLI 操控浏览器。
|
||||
> 让 LLM 通过 HTTP API 工具调用操控 VisionL 浏览器。
|
||||
|
||||
## 原理
|
||||
|
||||
LLM 将 `visionl` 注册为一个系统命令/工具,在需要浏览网页时调用。
|
||||
所有命令输出结构化的 JSON,LLM 直接解析结果并决定下一步操作。
|
||||
VisionL daemon 提供完整的 REST API。LLM 将 API 调用注册为工具/函数(Function Calling),
|
||||
在需要浏览网页时生成对应的 HTTP 请求。所有接口返回结构化 JSON,LLM 直接解析。
|
||||
|
||||
## API 总览
|
||||
|
||||
| 方法 | 端点 | 功能 |
|
||||
|------|------|------|
|
||||
| POST | `/pages` | 打开页面 |
|
||||
| GET | `/pages` | 列出所有页面 |
|
||||
| GET | `/pages/:id` | 页面详情 |
|
||||
| DELETE | `/pages/:id` | 关闭页面 |
|
||||
| POST | `/pages/:id/navigate` | 跳转 |
|
||||
| POST | `/pages/:id/click` | 点击元素 |
|
||||
| POST | `/pages/:id/type` | 输入文本 |
|
||||
| POST | `/pages/:id/scroll` | 滚动 |
|
||||
| POST | `/pages/:id/eval` | 执行 JS |
|
||||
| POST | `/pages/:id/wait` | 等待 |
|
||||
| GET | `/pages/:id/screenshot` | 截图(base64) |
|
||||
| GET | `/pages/:id/text` | 纯文本 |
|
||||
| GET | `/pages/:id/html` | HTML 源码 |
|
||||
| GET | `/pages/:id/cookies` | Cookie 列表 |
|
||||
| POST | `/pages/:id/cookies` | 设置 Cookie |
|
||||
| DELETE | `/pages/:id/cookies/:name` | 删除 Cookie |
|
||||
| GET | `/pages/:id/console` | 控制台日志 |
|
||||
| GET | `/pages/:id/network` | 网络请求日志 |
|
||||
| GET | `/profiles` | 指纹配置列表 |
|
||||
|
||||
完整文档见 [API 文档](../development/api.md)。
|
||||
|
||||
## 集成方式
|
||||
|
||||
### 方式一:Function Calling(推荐)
|
||||
|
||||
在 LLM 的 function/tool 定义中注册 VisionL 命令。大多数 LLM 平台(OpenAI、Claude、本地模型)都支持。
|
||||
|
||||
**工具定义示例(OpenAI 格式):**
|
||||
注册 `visionl_api` 工具,LLM 直接生成 HTTP 请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "visionl",
|
||||
"description": "通过 VisionL 浏览器操控网页。子命令: page open|list|info|kill|kill-all, click, type, scroll, navigate, eval, wait, screenshot, text, html, daemon start|stop|status, raw",
|
||||
"name": "visionl_api",
|
||||
"description": "通过 VisionL 浏览器操控网页。支持打开页面、点击、输入、截图、提取文本、执行JS、管理Cookie、查看网络请求等。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"method": {
|
||||
"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 决策需要访问网页
|
||||
2. LLM 生成 `visionl page open <url>` 调用
|
||||
3. 宿主程序在终端执行该命令,将 JSON 输出返回给 LLM
|
||||
4. LLM 解析结果,继续决策(截图、点击、提取文本等)
|
||||
2. LLM 调用 `visionl_api`:`POST /pages` 打开 baidu.com
|
||||
3. 宿主程序执行 HTTP 请求,将 JSON 结果返回 LLM
|
||||
4. LLM 解析结果,获得页面 ID `p_xxx`
|
||||
5. LLM 继续:`GET /pages/p_xxx/text` 读取内容
|
||||
6. 或:`POST /pages/p_xxx/click` 点击搜索
|
||||
|
||||
### 方式二:MCP Server
|
||||
### 方式二:多工具注册
|
||||
|
||||
可以封装一个 MCP(Model Context Protocol)Server,将 VisionL-CLI 包装为 MCP 工具:
|
||||
将每个操作注册为独立工具(更细粒度):
|
||||
|
||||
```typescript
|
||||
// 伪代码示意
|
||||
server.tool(
|
||||
"visionl",
|
||||
"通过 VisionL 浏览器操控网页",
|
||||
{ command: z.string() },
|
||||
async ({ command }) => {
|
||||
const { stdout } = await exec(`visionl ${command}`);
|
||||
return JSON.parse(stdout);
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "visionl_open",
|
||||
"description": "打开网页",
|
||||
"parameters": {
|
||||
"url": { "type": "string" },
|
||||
"alias": { "type": "string" }
|
||||
}
|
||||
);
|
||||
},
|
||||
{
|
||||
"name": "visionl_text",
|
||||
"description": "获取页面文本内容",
|
||||
"parameters": {
|
||||
"page_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "visionl_click",
|
||||
"description": "点击页面元素",
|
||||
"parameters": {
|
||||
"page_id": { "type": "string" },
|
||||
"selector": { "type": "string" }
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 方式三:Agent 框架集成
|
||||
|
||||
与 LangChain、AutoGPT、CrewAI 等框架集成,注册为自定义工具。
|
||||
|
||||
**LangChain 示例:**
|
||||
### 方式三:LangChain 集成
|
||||
|
||||
```python
|
||||
from langchain.tools import Tool
|
||||
import subprocess, json
|
||||
from langchain.tools import BaseTool
|
||||
import requests
|
||||
|
||||
def visionl_tool(command: str) -> str:
|
||||
result = subprocess.run(
|
||||
["visionl", *command.split()],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout
|
||||
class VisionLTool(BaseTool):
|
||||
name = "visionl"
|
||||
description = "浏览器操控工具。API 基础 URL: http://127.0.0.1:9527"
|
||||
|
||||
visionl = Tool(
|
||||
name="visionl",
|
||||
description="浏览器操控工具。命令示例:page open <url>, click <id> <sel>, text <id>",
|
||||
func=visionl_tool,
|
||||
)
|
||||
def _run(self, method: str, path: str, body: dict = None) -> str:
|
||||
url = f"http://127.0.0.1:9527{path}"
|
||||
resp = requests.request(method, url, json=body)
|
||||
return resp.text
|
||||
```
|
||||
|
||||
## LLM Prompt 建议
|
||||
|
||||
在系统 prompt 中添加以下指引:
|
||||
## LLM 系统提示词建议
|
||||
|
||||
```
|
||||
你可以使用 visionl 命令操控浏览器:
|
||||
你可以使用 visionl_api 工具操控浏览器:
|
||||
|
||||
1. visionl page open <url> [--alias <name>] — 打开页面
|
||||
2. visionl page list — 列出所有页面
|
||||
3. visionl text <id|alias> — 获取页面文本
|
||||
4. visionl screenshot <id|alias> — 截图(返回 base64)
|
||||
5. visionl click <id|alias> <selector> — 点击元素
|
||||
6. visionl type <id|alias> <selector> <text> — 输入文本
|
||||
7. visionl page kill <id|alias> — 关闭页面
|
||||
打开页面: POST /pages {"url":"...","alias":"..."}
|
||||
页面文本: GET /pages/{id}/text
|
||||
页面截图: GET /pages/{id}/screenshot (返回 base64)
|
||||
点击元素: POST /pages/{id}/click {"selector":"#id"}
|
||||
输入文本: POST /pages/{id}/type {"selector":"#id","text":"..."}
|
||||
执行 JS: POST /pages/{id}/eval {"code":"..."}
|
||||
滚动页面: POST /pages/{id}/scroll {"deltaY":300}
|
||||
等待加载: POST /pages/{id}/wait {"ms":2000}
|
||||
查看Cookie: GET /pages/{id}/cookies
|
||||
网络日志: GET /pages/{id}/network
|
||||
关闭页面: DELETE /pages/{id}
|
||||
|
||||
所有命令返回 JSON。解析 ok 字段判断成功/失败。
|
||||
使用 --alias 给页面起别名方便后续引用。
|
||||
所有接口返回 {"ok":true,"data":{...}} 或 {"ok":false,"error":{...}}。
|
||||
打开页面后记录返回的 page_id,后续操作使用该 id。
|
||||
页面在被显式 kill 之前永远存活,可跨多轮对话复用。
|
||||
```
|
||||
|
||||
## 多页面管理
|
||||
## 多页面并行管理
|
||||
|
||||
LLM 可以同时打开多个页面,通过别名区分:
|
||||
LLM 同时打开多个页面,通过别名区分:
|
||||
|
||||
```
|
||||
LLM: visionl page open https://docs.python.org --alias py
|
||||
LLM: visionl page open https://developer.mozilla.org --alias mdn
|
||||
LLM: visionl text py # 读 Python 文档
|
||||
LLM: visionl text mdn # 读 MDN 文档
|
||||
LLM: POST /pages {"url":"https://docs.python.org","alias":"py"}
|
||||
→ {"ok":true,"data":{"id":"p_aaa",...}}
|
||||
|
||||
LLM: POST /pages {"url":"https://developer.mozilla.org","alias":"mdn"}
|
||||
→ {"ok":true,"data":{"id":"p_bbb",...}}
|
||||
|
||||
LLM: GET /pages/py/text # 读 Python 文档
|
||||
LLM: GET /pages/mdn/text # 读 MDN 文档
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
LLM 应检查返回的 `ok` 字段:
|
||||
|
||||
```json
|
||||
// 失败示例
|
||||
{"ok":false,"error":{"code":"PAGE_NOT_FOUND","message":"页面 py 不存在"}}
|
||||
```
|
||||
|
||||
常见错误及处理:
|
||||
常见错误码及处理:
|
||||
|
||||
| 错误码 | 处理建议 |
|
||||
|--------|---------|
|
||||
| `PAGE_NOT_FOUND` | 页面可能已被关闭,重新打开 |
|
||||
| `DAEMON_UNREACHABLE` | 等待几秒重试(自动拉起正在启动) |
|
||||
| `TIMEOUT` | 页面加载慢,重试或增加等待时间 |
|
||||
| `ALIAS_EXISTS` | 换一个别名或直接用 page ID |
|
||||
| 错误码 | HTTP | 处理建议 |
|
||||
|--------|------|---------|
|
||||
| `PAGE_NOT_FOUND` | 404 | 页面已关闭,重新打开 |
|
||||
| `DAEMON_UNREACHABLE` | 502 | 启动 daemon 或稍后重试 |
|
||||
| `TIMEOUT` | 408 | 页面加载慢,重试或增加等待 |
|
||||
| `ALIAS_EXISTS` | 409 | 换别名或直接用 page_id |
|
||||
|
||||
## 安全注意事项
|
||||
## 反检测能力
|
||||
|
||||
- VisionL daemon 仅监听 127.0.0.1,外部不可访问
|
||||
- 执行的 JS 代码在页面沙箱内运行,无法逃逸到宿主机
|
||||
- LLM 应避免在不可信页面执行敏感操作(自动填写密码等)
|
||||
VisionL 内置多层反检测,使自动化访问尽可能不被简单人机验证拦截:
|
||||
|
||||
- `navigator.webdriver` → `false`
|
||||
- 真实 Chrome User-Agent 和请求头
|
||||
- Canvas/WebGL/Audio 指纹加噪
|
||||
- 屏幕分辨率和视口合理性
|
||||
- 权限状态模拟
|
||||
- 3 套指纹模版可切换
|
||||
|
||||
详见 [反检测设计文档](../development/anti-detection.md)。
|
||||
|
||||
+96
-47
@@ -1,89 +1,138 @@
|
||||
# VisionL 快速开始
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js >= 18
|
||||
- Chromium 浏览器(系统自带或 Playwright 安装)
|
||||
- Linux/macOS/Windows(Android 需配合 Ubuntu proot 容器)
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone ssh://git@git.yeij.top:2222/AskaEth/VisionL.git
|
||||
cd VisionL
|
||||
|
||||
# 2. 安装依赖
|
||||
npm install
|
||||
|
||||
# 3. 安装 Chromium 浏览器
|
||||
npx playwright install chromium
|
||||
|
||||
# 4. 构建
|
||||
npm install --registry=https://registry.npmmirror.com
|
||||
npx playwright install chromium # 如果没有系统 Chromium
|
||||
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
|
||||
visionl daemon start
|
||||
# ✓ Daemon 已启动 (端口 9527, PID 12345)
|
||||
curl -X POST http://127.0.0.1:9527/pages \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"url":"https://www.baidu.com","alias":"baidu"}'
|
||||
```
|
||||
|
||||
### 打开一个页面
|
||||
|
||||
```bash
|
||||
visionl page open https://www.baidu.com --alias baidu
|
||||
# {"ok":true,"data":{"id":"p_a1b2c3d4","url":"https://www.baidu.com","alias":"baidu","title":"百度一下,你就知道","status":"active"}}
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": "p_f1990959",
|
||||
"url": "https://www.baidu.com",
|
||||
"alias": "baidu",
|
||||
"title": "百度一下,你就知道",
|
||||
"status": "active",
|
||||
"profile": "desktop-chrome"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 获取页面文本
|
||||
|
||||
```bash
|
||||
visionl text baidu
|
||||
# {"ok":true,"data":{"text":"百度一下,你就知道\n..."}}
|
||||
curl http://127.0.0.1:9527/pages/baidu/text
|
||||
```
|
||||
|
||||
### 搜索
|
||||
|
||||
```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
|
||||
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
|
||||
visionl page kill baidu
|
||||
# {"ok":true,"data":null}
|
||||
curl -X DELETE http://127.0.0.1:9527/pages/baidu
|
||||
```
|
||||
|
||||
### 停止 daemon
|
||||
### 查看所有页面
|
||||
|
||||
```bash
|
||||
visionl daemon stop
|
||||
```
|
||||
|
||||
## 无需手动启动 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"}]}
|
||||
curl http://127.0.0.1:9527/pages
|
||||
```
|
||||
|
||||
Generated
+24
-2
@@ -11,7 +11,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -2247,6 +2247,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"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": {
|
||||
"name": "@visionl/cli",
|
||||
"version": "0.1.0",
|
||||
@@ -2272,7 +2293,8 @@
|
||||
"@visionl/core": "*",
|
||||
"playwright": "^1.52.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": {
|
||||
"@types/ws": "^8.0.0"
|
||||
|
||||
+8
-3
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"name": "visionl",
|
||||
"private": true,
|
||||
"workspaces": ["packages/*"],
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc -b",
|
||||
"build": "tsc -b",
|
||||
@@ -9,8 +11,11 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0",
|
||||
"@types/node": "^22.0.0"
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"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
|
||||
export const CLI_VERSION = '0.1.0';
|
||||
#!/usr/bin/env node
|
||||
import { Command } from 'commander';
|
||||
import { ensureDaemonRunning } from './auto-daemon.js';
|
||||
import { safeStringify, VisionLClient } from '@visionl/core';
|
||||
|
||||
const DEFAULT_PORT = 9527;
|
||||
|
||||
const program = new Command();
|
||||
program.name('visionl').version('0.1.0');
|
||||
|
||||
program.option('-p, --port <n>', 'daemon port', String(DEFAULT_PORT));
|
||||
program.option('--pretty', 'human-readable output');
|
||||
|
||||
function getClient(): Promise<VisionLClient> {
|
||||
const opts = program.opts<{ port: string }>();
|
||||
const port = parseInt(opts.port, 10) || DEFAULT_PORT;
|
||||
return ensureDaemonRunning(port);
|
||||
}
|
||||
|
||||
function print(result: unknown): void {
|
||||
const opts = program.opts<{ pretty: boolean }>();
|
||||
if (opts.pretty) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(safeStringify(result));
|
||||
}
|
||||
}
|
||||
|
||||
// health — check daemon health
|
||||
program.command('health')
|
||||
.description('Check daemon health')
|
||||
.action(async () => {
|
||||
const client = await getClient();
|
||||
const ok = await client.health();
|
||||
print({ ok });
|
||||
});
|
||||
|
||||
// pages — page management
|
||||
program.command('open <url>')
|
||||
.description('Open a URL in a new page')
|
||||
.option('-a, --alias <alias>', 'Page alias')
|
||||
.option('-p, --profile <profile>', 'Browser profile')
|
||||
.action(async (url: string, options: { alias?: string; profile?: string }) => {
|
||||
const client = await getClient();
|
||||
const result = await client.openPage(url, options.alias, options.profile);
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('list')
|
||||
.description('List all open pages')
|
||||
.action(async () => {
|
||||
const client = await getClient();
|
||||
const result = await client.listPages();
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('get <id>')
|
||||
.description('Get page info by ID or alias')
|
||||
.action(async (id: string) => {
|
||||
const client = await getClient();
|
||||
const result = await client.getPage(id);
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('kill <id>')
|
||||
.description('Close a page by ID or alias')
|
||||
.action(async (id: string) => {
|
||||
const client = await getClient();
|
||||
const result = await client.killPage(id);
|
||||
print(result);
|
||||
});
|
||||
|
||||
// navigation — page interactions
|
||||
program.command('navigate <id> <url>')
|
||||
.description('Navigate an existing page to a URL')
|
||||
.action(async (id: string, url: string) => {
|
||||
const client = await getClient();
|
||||
const result = await client.navigate(id, url);
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('click <id> <selector>')
|
||||
.description('Click an element on a page')
|
||||
.action(async (id: string, selector: string) => {
|
||||
const client = await getClient();
|
||||
const result = await client.click(id, selector);
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('type <id> <selector> <text>')
|
||||
.description('Type text into an element')
|
||||
.action(async (id: string, selector: string, text: string) => {
|
||||
const client = await getClient();
|
||||
const result = await client.type(id, selector, text);
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('scroll <id>')
|
||||
.description('Scroll on a page')
|
||||
.option('-y, --delta-y <n>', 'Vertical scroll delta', '0')
|
||||
.option('-b, --to-bottom', 'Scroll to bottom')
|
||||
.action(async (id: string, options: { deltaY: string; toBottom?: boolean }) => {
|
||||
const client = await getClient();
|
||||
const result = await client.scroll(id, {
|
||||
deltaY: parseInt(options.deltaY, 10) || undefined,
|
||||
toBottom: options.toBottom,
|
||||
});
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('eval <id> <code>')
|
||||
.description('Evaluate JavaScript on a page')
|
||||
.action(async (id: string, code: string) => {
|
||||
const client = await getClient();
|
||||
const result = await client.eval(id, code);
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('wait <id>')
|
||||
.description('Wait for selector or duration on a page')
|
||||
.option('-s, --selector <selector>', 'Wait for CSS selector')
|
||||
.option('-m, --ms <ms>', 'Wait time in milliseconds')
|
||||
.action(async (id: string, options: { selector?: string; ms?: string }) => {
|
||||
const client = await getClient();
|
||||
const result = await client.wait(id, {
|
||||
selector: options.selector,
|
||||
ms: options.ms ? parseInt(options.ms, 10) : undefined,
|
||||
});
|
||||
print(result);
|
||||
});
|
||||
|
||||
// content — page content retrieval
|
||||
program.command('screenshot <id>')
|
||||
.description('Take a screenshot of a page (returns base64)')
|
||||
.action(async (id: string) => {
|
||||
const client = await getClient();
|
||||
const result = await client.screenshot(id);
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('text <id>')
|
||||
.description('Get the text content of a page')
|
||||
.action(async (id: string) => {
|
||||
const client = await getClient();
|
||||
const result = await client.text(id);
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.command('html <id>')
|
||||
.description('Get the HTML source of a page')
|
||||
.action(async (id: string) => {
|
||||
const client = await getClient();
|
||||
const result = await client.html(id);
|
||||
print(result);
|
||||
});
|
||||
|
||||
// profiles
|
||||
program.command('profiles')
|
||||
.description('List available fingerprint profiles')
|
||||
.action(async () => {
|
||||
const client = await getClient();
|
||||
const result = await client.getProfiles();
|
||||
print(result);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../core" }]
|
||||
"references": [{ "path": "../core" }, { "path": "../daemon" }]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { type PageInfo } from './types/page.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 {
|
||||
type FingerprintProfile,
|
||||
type FingerprintPermissions,
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
// WebSocket event type definitions / WebSocket 事件类型定义
|
||||
import type { PageInfo } from './page.js';
|
||||
|
||||
export interface ConsoleEntry {
|
||||
level: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface NetworkEntry {
|
||||
type: 'request' | 'response' | 'failed';
|
||||
url: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
failure?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type WsEvent =
|
||||
| { type: 'page:created'; data: PageInfo }
|
||||
| { type: 'page:closed'; data: { id: string } }
|
||||
| { type: 'page:navigated'; data: { id: string; url: string; title: string } }
|
||||
| { type: 'page:crashed'; data: { id: string; error: string } }
|
||||
| { type: 'page:console'; data: { id: string; level: string; text: string } }
|
||||
| { type: 'page:network:request'; data: { id: string; url: string; method: string; headers: Record<string, string> } }
|
||||
| { type: 'page:network:response'; data: { id: string; url: string; status: number; headers: Record<string, string> } }
|
||||
| { type: 'page:network:failed'; data: { id: string; url: string; failure: string } }
|
||||
| { type: 'page:detection:warning'; data: { id: string; level: string; detail: string } };
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"@visionl/core": "*",
|
||||
"playwright": "^1.52.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": {
|
||||
"@types/ws": "^8.0.0"
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
import { chromium } from 'playwright-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import type { Browser, BrowserContext, Page } from 'playwright';
|
||||
import type { FingerprintProfile, PageInfo } from '@visionl/core';
|
||||
import { PageRegistry } from './page-registry.js';
|
||||
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());
|
||||
@@ -18,8 +24,14 @@ export class BrowserManager {
|
||||
private registry = new PageRegistry();
|
||||
private profile: FingerprintProfile;
|
||||
|
||||
constructor(profile: FingerprintProfile) {
|
||||
this.profile = profile;
|
||||
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> {
|
||||
@@ -61,6 +73,10 @@ export class BrowserManager {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -68,19 +84,104 @@ export class BrowserManager {
|
||||
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(entry.info.id);
|
||||
this.registry.remove(pageId);
|
||||
broadcast({ type: 'page:closed', data: { id: pageId } });
|
||||
console.log(`[browser-manager] Page closed: ${pageId}`);
|
||||
}
|
||||
|
||||
getPage(idOrAlias: string) {
|
||||
getPage(idOrAlias: string): RegisteredPage | undefined {
|
||||
return this.registry.findByIdOrAlias(idOrAlias);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
// 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';
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// PageRegistry — tracks active Playwright pages by ID and alias / 页面注册表,通过 ID 和别名追踪活跃页面
|
||||
import type { Page, BrowserContext } from 'playwright';
|
||||
import type { PageInfo } from '@visionl/core';
|
||||
import type { PageInfo, ConsoleEntry, NetworkEntry } from '@visionl/core';
|
||||
|
||||
export interface RegisteredPage {
|
||||
info: PageInfo;
|
||||
page: Page;
|
||||
context: BrowserContext;
|
||||
consoleLog: ConsoleEntry[];
|
||||
networkLog: NetworkEntry[];
|
||||
}
|
||||
|
||||
export class PageRegistry {
|
||||
@@ -13,7 +15,7 @@ export class PageRegistry {
|
||||
private aliasMap = new Map<string, string>(); // alias → id
|
||||
|
||||
add(info: PageInfo, page: Page, context: BrowserContext): void {
|
||||
this.pages.set(info.id, { info, page, context });
|
||||
this.pages.set(info.id, { info, page, context, consoleLog: [], networkLog: [] });
|
||||
if (info.alias) {
|
||||
this.aliasMap.set(info.alias, info.id);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,34 @@ export function pageRoutes(bm: BrowserManager): RouteHandler {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { healthRoute } from './routes/health.js';
|
||||
import { pageRoutes } from './routes/pages.js';
|
||||
import { contentRoutes } from './routes/content.js';
|
||||
import { actionRoutes } from './routes/actions.js';
|
||||
import { profilesRoute } from './routes/profiles.js';
|
||||
import type { BrowserManager } from './browser-manager.js';
|
||||
import { createWsRelay } from './ws-relay.js';
|
||||
|
||||
export function startServer(port: number, browserManager?: BrowserManager): Promise<http.Server> {
|
||||
const routes = [healthRoute];
|
||||
const routes = [healthRoute, profilesRoute];
|
||||
if (browserManager) {
|
||||
routes.push(pageRoutes(browserManager));
|
||||
routes.push(contentRoutes(browserManager));
|
||||
@@ -24,11 +26,15 @@ export function startServer(port: number, browserManager?: BrowserManager): Prom
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`[daemon] VisionL daemon started on http://127.0.0.1:${port}`);
|
||||
createWsRelay(server);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Direct start when running as script
|
||||
// Direct start when running as script (not when imported in tests)
|
||||
// When imported by other modules, the caller is responsible for calling startServer()
|
||||
if (process.argv[1] && (process.argv[1].endsWith('server.js') || process.argv[1].endsWith('server.ts'))) {
|
||||
const port = parseInt(process.env.VISIONL_PORT || '9527', 10);
|
||||
startServer(port);
|
||||
}
|
||||
|
||||
@@ -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,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,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