pi-browser-use-lite 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE +22 -0
- package/README.md +152 -2
- package/README.zh-CN.md +153 -0
- package/browser-extension/background/capture.js +267 -0
- package/browser-extension/background/dispatch.js +231 -0
- package/browser-extension/background/index.js +66 -0
- package/browser-extension/background/input/debugger.js +185 -0
- package/browser-extension/background/input/keyboard.js +230 -0
- package/browser-extension/background/input/pointer.js +223 -0
- package/browser-extension/background/input/upload.js +93 -0
- package/browser-extension/background/ledger.js +53 -0
- package/browser-extension/background/page/evaluate.js +93 -0
- package/browser-extension/background/page/helpers.js +28 -0
- package/browser-extension/background/page/screenshot.js +106 -0
- package/browser-extension/background/page/snapshot.js +363 -0
- package/browser-extension/background/page/waitFor.js +64 -0
- package/browser-extension/background/tabs.js +182 -0
- package/browser-extension/background/target.js +116 -0
- package/browser-extension/background/transport.js +144 -0
- package/browser-extension/manifest.json +19 -0
- package/browser-extension/page/snapshot.js +255 -0
- package/package.json +41 -5
- package/src/bridge/index.ts +660 -0
- package/src/commands/index.ts +112 -0
- package/src/config/index.ts +79 -0
- package/src/config/schema.ts +120 -0
- package/src/config/validate.ts +396 -0
- package/src/format/index.ts +173 -0
- package/src/fs/index.ts +103 -0
- package/src/index.ts +125 -0
- package/src/policy/index.ts +73 -0
- package/src/protocol/index.ts +136 -0
- package/src/tools/errors.ts +66 -0
- package/src/tools/exec/act.ts +351 -0
- package/src/tools/exec/cdp.ts +27 -0
- package/src/tools/exec/common.ts +186 -0
- package/src/tools/exec/index.ts +65 -0
- package/src/tools/exec/see.ts +107 -0
- package/src/tools/exec/tab.ts +75 -0
- package/src/tools/exec/vision.ts +56 -0
- package/src/tools/index.ts +450 -0
- package/src/tools/result.ts +56 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
本文件记录已发布版本的用户可见变更。更细的过程见 git log。破坏性变更标 **BREAKING**。
|
|
4
|
+
|
|
5
|
+
## 0.1.1 — 2026-11-09
|
|
6
|
+
|
|
7
|
+
- 补中文 README:`README.zh-CN.md`,英文版顶部有切换链接,npm 包同时收录两份。
|
|
8
|
+
|
|
9
|
+
## 0.1.0 — 2026-11-08
|
|
10
|
+
|
|
11
|
+
首版。
|
|
12
|
+
|
|
13
|
+
- 五个工具:`chrome_act`(navigate、click、type、fill、press、hover、drag、scroll、upload、eval、wait、batch)、`chrome_see`(refs、text、changes、full,另可取 console 与 network 日志)、`chrome_tab`(list、new、switch、close)、`chrome_vision`(截图落盘)、`chrome_cdp`(裸 CDP 调用)。
|
|
14
|
+
- `/chrome` 命令:`authorize on|off`、`background on|off`、`status`。扩展装好之前,命令与工具都报 `extension_missing` 并给出装扩展三步。
|
|
15
|
+
- 本地桥:只绑 `127.0.0.1`,多 Pi 会话共用一个服务端,其余退成客户端。扩展每 100 毫秒轮询一次。
|
|
16
|
+
- 每个会话一个独立的浏览器窗口:动作在本次会话的 target 里执行,别的窗口不动,退出时按配置关掉标签。
|
|
17
|
+
- 截图默认落在 `<agent dir>/pi-browser-use-lite/screenshots`,跟着 `PI_CODING_AGENT_DIR` 走,不写进家目录的 `~/.pi/screenshots`。
|
|
18
|
+
- 发布:`CHANGELOG.md` 与 `browser-extension/` 一起进 `files`。用户从 npm 装到扩展源码后 load unpacked。
|
|
19
|
+
- 扩展版本落后于包时自己重载一次,不必再去扩展页手动刷新。
|
|
20
|
+
- 轮询断了由扩展的 1 秒定时器重起;`chrome.alarms` 只作服务工作进程被回收后的兜底。
|
|
21
|
+
- `chrome_act eval` 省掉超长 `data` 时,说明接在正文末尾;此前这条说明到不了模型。
|
|
22
|
+
- 包内带 `README.md`:安装、配置、开发各一节,扩展目录由 `/chrome status` 打印。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 pi-chrome contributors
|
|
4
|
+
Copyright (c) 2026 setwhite
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,153 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-browser-use-lite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[简体中文](README.zh-CN.md) | **English**
|
|
4
|
+
|
|
5
|
+
Five `chrome_*` tools that drive your own browser: the tabs you are already logged into, with your cookies and your sessions.
|
|
6
|
+
|
|
7
|
+
The package has two halves. The pi extension registers the tools and runs a local bridge on `127.0.0.1`. The companion Chrome extension polls that bridge and runs each command through `chrome.debugger`.
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
| Tool | What it does |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `chrome_act` | navigate, click, type, fill, press, hover, drag, scroll, upload, eval, wait, batch |
|
|
14
|
+
| `chrome_see` | snapshot the page as refs, text, changes or full; read console and network logs |
|
|
15
|
+
| `chrome_tab` | list, new, switch, close |
|
|
16
|
+
| `chrome_vision` | screenshot the viewport or the full page |
|
|
17
|
+
| `chrome_cdp` | one raw CDP call for anything the other four do not cover |
|
|
18
|
+
|
|
19
|
+
Behaviour worth knowing:
|
|
20
|
+
|
|
21
|
+
- Each session gets its own browser window. The tools do not touch your own tabs. The tabs the session opened are closed when the session ends.
|
|
22
|
+
- Input goes through trusted CDP events, so pages that reject synthetic events still react.
|
|
23
|
+
- Console and network capture starts at navigation, not at the first read.
|
|
24
|
+
- Every failure carries a class and a `hint` that says what to do next.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install npm:pi-browser-use-lite
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The extension ships inside the package. Load it once per browser:
|
|
33
|
+
|
|
34
|
+
1. Run `/chrome status` in pi. When the extension is missing, the reply prints the directory to load.
|
|
35
|
+
2. Open `chrome://extensions`, turn on Developer mode, click Load unpacked and pick that directory.
|
|
36
|
+
3. Run `/chrome status` again. `connected: yes` means the bridge sees the extension.
|
|
37
|
+
|
|
38
|
+
Then authorize the tools for this session:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
/chrome authorize on
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Authorization ends with the session. Set `policy.authorizeOnStart` to skip this step.
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
One JSON file: `<agent dir>/pi-browser-use-lite/config.json`. The agent dir is `PI_CODING_AGENT_DIR`, or `~/.pi/agent` when the variable is unset.
|
|
49
|
+
|
|
50
|
+
Without the file every value is the default. A wrong type or an unknown key fails startup and names every bad field at once. An unknown enum value falls back to the default. An out-of-range number is clamped to the nearest bound. The comments below only explain. A real file must be strict JSON.
|
|
51
|
+
|
|
52
|
+
**Minimal configuration**: nothing. All five tools work on the defaults.
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Full example**: every key follows with its default and when to change it.
|
|
59
|
+
|
|
60
|
+
```jsonc
|
|
61
|
+
{
|
|
62
|
+
// The local bridge. The extension has the same port compiled in
|
|
63
|
+
// (browser-extension/background/transport.js): change both, then reload the extension
|
|
64
|
+
"bridge": { "port": 17318 },
|
|
65
|
+
|
|
66
|
+
// Session rules
|
|
67
|
+
"policy": {
|
|
68
|
+
"authorizeOnStart": false, // true = the tools work without /chrome authorize on
|
|
69
|
+
"background": true, // true = block every focusing call, chrome_tab switch included.
|
|
70
|
+
// Turn it off when the model must bring a page to the front
|
|
71
|
+
"closeOwnTabsOnExit": true // close the tabs this session opened when the session ends
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// Input and timing
|
|
75
|
+
"input": {
|
|
76
|
+
"dragSteps": 10, // mouse moves between press and release
|
|
77
|
+
"scrollSteps": 5 // wheel events per scroll call
|
|
78
|
+
},
|
|
79
|
+
"timing": { "navigateTimeoutMs": 15000 }, // wait for the load event; the navigate call gets this plus 2s
|
|
80
|
+
|
|
81
|
+
// Model-visible output
|
|
82
|
+
"output": {
|
|
83
|
+
"maxElements": 80, // snapshot element cap
|
|
84
|
+
"textChars": 18000 // body text cap in see mode=text; the rest is dropped with a note
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// Screenshots
|
|
88
|
+
"screenshot": {
|
|
89
|
+
"dir": "/home/me/.pi/agent/pi-browser-use-lite/screenshots", // created on first use
|
|
90
|
+
"format": "png", // png | jpeg
|
|
91
|
+
"keepNewest": 20 // older screenshots in that directory are deleted
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// Global rules for the system prompt. These replace the three defaults; they do not extend them
|
|
95
|
+
"prompt": {
|
|
96
|
+
"guidelines": ["chrome_act: this is the user's real browser; confirm before destructive actions."]
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Tool switches and names. Keep at least one enabled; names must be unique and must not shadow a host tool
|
|
100
|
+
"tools": {
|
|
101
|
+
"chrome_act": { "enabled": true, "name": "chrome_act" },
|
|
102
|
+
"chrome_see": { "enabled": true, "name": "chrome_see" },
|
|
103
|
+
"chrome_tab": { "enabled": true, "name": "chrome_tab" },
|
|
104
|
+
"chrome_vision": { "enabled": true, "name": "chrome_vision" },
|
|
105
|
+
"chrome_cdp": { "enabled": true, "name": "chrome_cdp" }
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Override the built-in prompts per tool. "" clears description or promptSnippet; [] adds no rules
|
|
109
|
+
"guidance": {
|
|
110
|
+
"chrome_act": {
|
|
111
|
+
"description": "Drive the user's real browser.",
|
|
112
|
+
"promptSnippet": "Drive the browser",
|
|
113
|
+
"promptGuidelines": ["chrome_act: pass see: refs on navigate."]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Every key is optional. `src/config/schema.ts` holds the ranges and the defaults.
|
|
120
|
+
|
|
121
|
+
## Docs
|
|
122
|
+
|
|
123
|
+
| Document | Content |
|
|
124
|
+
|---|---|
|
|
125
|
+
| [ARCHITECTURE](docs/ARCHITECTURE.md) | Module boundaries and seams; read it before changing the structure |
|
|
126
|
+
| [GLOSSARY](docs/GLOSSARY.md) | One name per concept |
|
|
127
|
+
| [Modules](docs/modules/) | Trade-offs and traps per module |
|
|
128
|
+
| [Decisions](docs/decisions/) | Why the shape is what it is |
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
From the repository root:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pnpm -C packages/pi-browser-use-lite lint
|
|
136
|
+
pnpm -C packages/pi-browser-use-lite check:imports
|
|
137
|
+
pnpm -C packages/pi-browser-use-lite typecheck
|
|
138
|
+
pnpm -C packages/pi-browser-use-lite test
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The end-to-end test drives a real browser. Load the extension from `packages/pi-browser-use-lite/browser-extension`, keep one Chromium browser open with that extension enabled, then run:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pnpm -C packages/pi-browser-use-lite test:e2e
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Close every other browser that has this extension. Two copies poll the same bridge, and the commands go to whichever copy asks first.
|
|
148
|
+
|
|
149
|
+
The extension reloads itself when the bridge reports a different version. After editing extension code without a version bump, reload it by hand on the extensions page.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT, see [LICENSE](LICENSE).
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# pi-browser-use-lite
|
|
2
|
+
|
|
3
|
+
**[简体中文](README.zh-CN.md)** | [English](README.md)
|
|
4
|
+
|
|
5
|
+
五个 `chrome_*` 工具,操作你自己的浏览器:你已经登录的标签页、你的 cookie 和会话。
|
|
6
|
+
|
|
7
|
+
包分两半。pi 扩展注册这些工具,并在 `127.0.0.1` 上跑一个本地桥。配套的 Chrome 扩展轮询这个桥,用 `chrome.debugger` 执行每条命令。
|
|
8
|
+
|
|
9
|
+
## 工具
|
|
10
|
+
|
|
11
|
+
| 工具 | 做什么 |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `chrome_act` | navigate、click、type、fill、press、hover、drag、scroll、upload、eval、wait、batch |
|
|
14
|
+
| `chrome_see` | 快照:refs、text、changes、full;读控制台与网络日志 |
|
|
15
|
+
| `chrome_tab` | list、new、switch、close |
|
|
16
|
+
| `chrome_vision` | 截视口或整页 |
|
|
17
|
+
| `chrome_cdp` | 一条裸 CDP 调用,另四个盖不住的用它 |
|
|
18
|
+
|
|
19
|
+
值得知道的行为:
|
|
20
|
+
|
|
21
|
+
- 每个会话一个独立浏览器窗口。工具不碰你自己的标签页。会话结束时关掉本会话开的标签。
|
|
22
|
+
- 输入走可信 CDP 事件,拒绝合成事件的页面也会响应。
|
|
23
|
+
- 控制台与网络抓取从导航开始,不从第一次读取开始。
|
|
24
|
+
- 每次失败都带类别和一句 `hint`,说明下一步做什么。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install npm:pi-browser-use-lite
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
扩展就在包里。每个浏览器装一次:
|
|
33
|
+
|
|
34
|
+
1. 在 pi 里运行 `/chrome status`。扩展没连上时,回复会打印要加载的目录。
|
|
35
|
+
2. 打开 `chrome://extensions`,开开发者模式,点「加载已解压的扩展程序」,选那个目录。
|
|
36
|
+
3. 再跑一次 `/chrome status`。`connected: yes` 表示桥看到了扩展。
|
|
37
|
+
|
|
38
|
+
然后给这次会话授权:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
/chrome authorize on
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
授权随会话结束。想省这一步,把 `policy.authorizeOnStart` 设为 true。
|
|
45
|
+
|
|
46
|
+
## 配置
|
|
47
|
+
|
|
48
|
+
一个 JSON 文件:`<agent dir>/pi-browser-use-lite/config.json`。agent dir 是 `PI_CODING_AGENT_DIR`,没设时是 `~/.pi/agent`。
|
|
49
|
+
|
|
50
|
+
没有这个文件时全部取默认值。类型错或未知键会让启动失败,并一次列出所有坏字段。枚举值不认识时取默认值。数字越界时夹到最近的边界。下面的注释只作解释,真正的文件必须是严格 JSON。
|
|
51
|
+
|
|
52
|
+
**最小配置**:不用写。五个工具在默认值下都能用。
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**完整示例**:每个键都跟着默认值,以及什么时候该改。
|
|
59
|
+
|
|
60
|
+
```jsonc
|
|
61
|
+
{
|
|
62
|
+
// 本地桥。扩展里编译了同一个端口
|
|
63
|
+
// (browser-extension/background/transport.js):两处一起改,然后重载扩展
|
|
64
|
+
"bridge": { "port": 17318 },
|
|
65
|
+
|
|
66
|
+
// 会话规则
|
|
67
|
+
"policy": {
|
|
68
|
+
"authorizeOnStart": false, // true = 不用 /chrome authorize on 就能用工具
|
|
69
|
+
"background": true, // true = 挡掉所有聚焦动作,chrome_tab switch 也算。
|
|
70
|
+
// 需要模型把页面带到前台时关掉
|
|
71
|
+
"closeOwnTabsOnExit": true // 会话结束时关掉本会话开的标签
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// 输入与时限
|
|
75
|
+
"input": {
|
|
76
|
+
"dragSteps": 10, // 按下与松开之间的鼠标移动次数
|
|
77
|
+
"scrollSteps": 5 // 每次 scroll 的滚轮事件数
|
|
78
|
+
},
|
|
79
|
+
"timing": { "navigateTimeoutMs": 15000 }, // 等 load 事件的时长;navigate 调用再多给 2 秒
|
|
80
|
+
|
|
81
|
+
// 模型可见的输出
|
|
82
|
+
"output": {
|
|
83
|
+
"maxElements": 80, // 快照元素上限
|
|
84
|
+
"textChars": 18000 // see 的 text 模式正文上限,其余带提示丢掉
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// 截图
|
|
88
|
+
"screenshot": {
|
|
89
|
+
"dir": "/home/me/.pi/agent/pi-browser-use-lite/screenshots", // 第一次用时创建
|
|
90
|
+
"format": "png", // png | jpeg
|
|
91
|
+
"keepNewest": 20 // 这个目录里更旧的截图会被删掉
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// 系统提示里的全局规则。这三条换成你的,不是追加
|
|
95
|
+
"prompt": {
|
|
96
|
+
"guidelines": ["chrome_act: 这是用户真实的浏览器,破坏性操作前先问一句。"]
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// 工具开关与名字。至少留一个启用;名字不能重复,也不能盖住宿主已有的工具
|
|
100
|
+
"tools": {
|
|
101
|
+
"chrome_act": { "enabled": true, "name": "chrome_act" },
|
|
102
|
+
"chrome_see": { "enabled": true, "name": "chrome_see" },
|
|
103
|
+
"chrome_tab": { "enabled": true, "name": "chrome_tab" },
|
|
104
|
+
"chrome_vision": { "enabled": true, "name": "chrome_vision" },
|
|
105
|
+
"chrome_cdp": { "enabled": true, "name": "chrome_cdp" }
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// 按工具改内置提示。"" 清空 description 或 promptSnippet;[] 表示不加规则
|
|
109
|
+
"guidance": {
|
|
110
|
+
"chrome_act": {
|
|
111
|
+
"description": "操作用户真实的浏览器。",
|
|
112
|
+
"promptSnippet": "操作浏览器",
|
|
113
|
+
"promptGuidelines": ["chrome_act: navigate 时带上 see: refs。"]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
每个键都可选。范围与默认值在 `src/config/schema.ts`。
|
|
120
|
+
|
|
121
|
+
## 文档
|
|
122
|
+
|
|
123
|
+
| 文档 | 内容 |
|
|
124
|
+
|---|---|
|
|
125
|
+
| [ARCHITECTURE](docs/ARCHITECTURE.md) | 模块边界与 seam;改结构之前先读它 |
|
|
126
|
+
| [GLOSSARY](docs/GLOSSARY.md) | 一个概念只用一个词 |
|
|
127
|
+
| [Modules](docs/modules/) | 每个模块的取舍与坑 |
|
|
128
|
+
| [Decisions](docs/decisions/) | 为什么是这个形状 |
|
|
129
|
+
|
|
130
|
+
## 开发
|
|
131
|
+
|
|
132
|
+
在仓库根执行:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pnpm -C packages/pi-browser-use-lite lint
|
|
136
|
+
pnpm -C packages/pi-browser-use-lite check:imports
|
|
137
|
+
pnpm -C packages/pi-browser-use-lite typecheck
|
|
138
|
+
pnpm -C packages/pi-browser-use-lite test
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
端到端测试会驱动真实浏览器。把 `packages/pi-browser-use-lite/browser-extension` 加载进扩展,留一个开着的 Chromium 系浏览器,然后运行:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pnpm -C packages/pi-browser-use-lite test:e2e
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
关掉其他装了本扩展的浏览器。两份扩展会抢同一个桥,命令落到先问的那一份。
|
|
148
|
+
|
|
149
|
+
桥报出的版本与扩展不同的时候,扩展会自己重载。改了扩展代码但没升版本号时,去扩展页手动重载一次。
|
|
150
|
+
|
|
151
|
+
## 许可证
|
|
152
|
+
|
|
153
|
+
MIT,见 [LICENSE](LICENSE)。
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志捕获:用 CDP 事件流记 console 与 network,按标签存一份。
|
|
3
|
+
* 缓冲区在后台而不是页面里,所以同一标签再导航一次也不会丢。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { attach, sendCommand } from "./input/debugger.js";
|
|
7
|
+
import { trackedTabIds } from "./target.js";
|
|
8
|
+
|
|
9
|
+
const KEY_PREFIX = "capture.logs.";
|
|
10
|
+
const CONSOLE_LIMIT = 500;
|
|
11
|
+
const NETWORK_LIMIT = 1_000;
|
|
12
|
+
const BODY_LIMIT = 200_000;
|
|
13
|
+
|
|
14
|
+
/** tabId → { console, network, starts, capturing, offReason } */
|
|
15
|
+
const buffers = new Map();
|
|
16
|
+
/** 正在从存储里读的缓冲区。两个调用同时到时共用同一次读,不各建一份。 */
|
|
17
|
+
const pending = new Map();
|
|
18
|
+
let subscribed = false;
|
|
19
|
+
|
|
20
|
+
subscribe();
|
|
21
|
+
|
|
22
|
+
/** 事件监听在模块加载时注册,service worker 才会被它们唤醒。 */
|
|
23
|
+
function subscribe() {
|
|
24
|
+
const debuggerApi = globalThis.chrome?.debugger;
|
|
25
|
+
if (subscribed || debuggerApi === undefined) return;
|
|
26
|
+
subscribed = true;
|
|
27
|
+
debuggerApi.onEvent.addListener(onEvent);
|
|
28
|
+
debuggerApi.onDetach.addListener(onDetach);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 在这个标签上开抓。已经开着就只补一次 attach,不重复开域。
|
|
33
|
+
* 挂不上调试器时只记一句说明,不挡动作。
|
|
34
|
+
* @param {number} tabId 标签 id
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
export async function startCapture(tabId) {
|
|
38
|
+
subscribe();
|
|
39
|
+
const buffer = await bufferOf(tabId);
|
|
40
|
+
buffer.offReason = undefined;
|
|
41
|
+
try {
|
|
42
|
+
await attach(tabId);
|
|
43
|
+
if (buffer.capturing !== true) {
|
|
44
|
+
await sendCommand(tabId, "Runtime.enable");
|
|
45
|
+
await sendCommand(tabId, "Network.enable");
|
|
46
|
+
buffer.capturing = true;
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
buffer.capturing = false;
|
|
50
|
+
buffer.offReason = `capture is off for this tab: ${messageOf(error)}`;
|
|
51
|
+
}
|
|
52
|
+
await save(tabId, buffer);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `chrome.webNavigation.onCommitted` 的处理。只抓本会话台账里的标签。
|
|
57
|
+
* @param {{ tabId?: number, frameId?: number }} details 导航详情
|
|
58
|
+
* @returns {Promise<void>}
|
|
59
|
+
*/
|
|
60
|
+
export async function onCommitted({ tabId, frameId } = {}) {
|
|
61
|
+
if (frameId !== 0 || !Number.isInteger(tabId)) return;
|
|
62
|
+
if (!(await trackedTabIds()).has(tabId)) return;
|
|
63
|
+
await startCapture(tabId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 读 console 条目,最旧的在前。
|
|
68
|
+
* @param {number} tabId 标签 id
|
|
69
|
+
* @param {{ clear?: boolean }} [options] `clear` 读完清空
|
|
70
|
+
* @returns {Promise<{ records: object[], note?: string }>} 条目与说明
|
|
71
|
+
*/
|
|
72
|
+
export async function listConsole(tabId, { clear = false } = {}) {
|
|
73
|
+
const buffer = await bufferOf(tabId);
|
|
74
|
+
const records = buffer.console.slice();
|
|
75
|
+
if (clear) {
|
|
76
|
+
buffer.console.length = 0;
|
|
77
|
+
await save(tabId, buffer);
|
|
78
|
+
}
|
|
79
|
+
return withNote(records, buffer.offReason);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 读网络请求,最早的在前。
|
|
84
|
+
* @param {number} tabId 标签 id
|
|
85
|
+
* @param {{ clear?: boolean }} [options] `clear` 读完清空
|
|
86
|
+
* @returns {Promise<{ records: object[], note?: string }>} 请求与说明
|
|
87
|
+
*/
|
|
88
|
+
export async function listNetwork(tabId, { clear = false } = {}) {
|
|
89
|
+
const buffer = await bufferOf(tabId);
|
|
90
|
+
const records = buffer.network.map((record) => ({ ...record }));
|
|
91
|
+
if (clear) {
|
|
92
|
+
buffer.network.length = 0;
|
|
93
|
+
await save(tabId, buffer);
|
|
94
|
+
}
|
|
95
|
+
return withNote(records, buffer.offReason);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 单条请求的详情,顺带取回响应体。
|
|
100
|
+
* @param {number} tabId 标签 id
|
|
101
|
+
* @param {string} requestId 请求 id
|
|
102
|
+
* @returns {Promise<object>} 请求记录
|
|
103
|
+
*/
|
|
104
|
+
export async function networkRequest(tabId, requestId) {
|
|
105
|
+
const buffer = await bufferOf(tabId);
|
|
106
|
+
const record = buffer.network.find((entry) => entry.id === requestId);
|
|
107
|
+
if (record === undefined)
|
|
108
|
+
throw new Error(`bad_args: no request with id ${requestId}`);
|
|
109
|
+
|
|
110
|
+
const detail = { ...record };
|
|
111
|
+
try {
|
|
112
|
+
const body = await sendCommand(tabId, "Network.getResponseBody", {
|
|
113
|
+
requestId,
|
|
114
|
+
});
|
|
115
|
+
if (body?.base64Encoded === true)
|
|
116
|
+
detail.responseBodyNote = "the response body is binary";
|
|
117
|
+
else if (typeof body?.body === "string")
|
|
118
|
+
detail.responseBody = clipBody(body.body);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
detail.responseBodyNote = `could not read the response body: ${messageOf(error)}`;
|
|
121
|
+
}
|
|
122
|
+
return detail;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function onEvent(source, method, params = {}) {
|
|
126
|
+
void handleEvent(source, method, params);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// 调试器事件可能在 startCapture 之前到(service worker 刚醒来),所以先按标签建缓冲区。
|
|
130
|
+
async function handleEvent(source, method, params) {
|
|
131
|
+
const tabId = source?.tabId;
|
|
132
|
+
if (!Number.isInteger(tabId)) return;
|
|
133
|
+
const buffer = await bufferOf(tabId);
|
|
134
|
+
if (buffer.offReason !== undefined) return;
|
|
135
|
+
if (method === "Runtime.consoleAPICalled") pushConsole(buffer, params);
|
|
136
|
+
else if (method === "Network.requestWillBeSent") pushRequest(buffer, params);
|
|
137
|
+
else if (method === "Network.responseReceived") markStatus(buffer, params);
|
|
138
|
+
else if (method === "Network.loadingFinished") markDuration(buffer, params);
|
|
139
|
+
else return;
|
|
140
|
+
await save(tabId, buffer);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function onDetach(source, reason) {
|
|
144
|
+
const buffer = buffers.get(source?.tabId);
|
|
145
|
+
if (buffer === undefined) return;
|
|
146
|
+
buffer.capturing = false;
|
|
147
|
+
buffer.offReason = `capture is off for this tab: ${reason}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pushConsole(buffer, { type, args = [], timestamp }) {
|
|
151
|
+
buffer.console.push({
|
|
152
|
+
level: levelOf(type),
|
|
153
|
+
text: args.map(textOf).join(" "),
|
|
154
|
+
time: Math.round(timestamp ?? Date.now()),
|
|
155
|
+
});
|
|
156
|
+
if (buffer.console.length > CONSOLE_LIMIT)
|
|
157
|
+
buffer.console.splice(0, buffer.console.length - CONSOLE_LIMIT);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function pushRequest(buffer, { requestId, request = {}, timestamp }) {
|
|
161
|
+
const record = {
|
|
162
|
+
id: requestId,
|
|
163
|
+
method: request.method ?? "GET",
|
|
164
|
+
url: request.url ?? "",
|
|
165
|
+
status: undefined,
|
|
166
|
+
durationMs: undefined,
|
|
167
|
+
};
|
|
168
|
+
if (typeof request.postData === "string") {
|
|
169
|
+
record.requestBody = clipBody(request.postData);
|
|
170
|
+
if (record.requestBody !== request.postData)
|
|
171
|
+
record.requestBodyTruncated = true;
|
|
172
|
+
}
|
|
173
|
+
buffer.starts.set(requestId, timestamp);
|
|
174
|
+
buffer.network.push(record);
|
|
175
|
+
if (buffer.network.length > NETWORK_LIMIT)
|
|
176
|
+
buffer.network.splice(0, buffer.network.length - NETWORK_LIMIT);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function markStatus(buffer, { requestId, response = {} }) {
|
|
180
|
+
const record = buffer.network.find((entry) => entry.id === requestId);
|
|
181
|
+
if (record !== undefined) record.status = response.status;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function markDuration(buffer, { requestId, timestamp }) {
|
|
185
|
+
const record = buffer.network.find((entry) => entry.id === requestId);
|
|
186
|
+
const startedAt = buffer.starts.get(requestId);
|
|
187
|
+
if (record === undefined || startedAt === undefined) return;
|
|
188
|
+
record.durationMs = Math.round((timestamp - startedAt) * 1000);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function bufferOf(tabId) {
|
|
192
|
+
const existing = buffers.get(tabId);
|
|
193
|
+
if (existing !== undefined) return existing;
|
|
194
|
+
let loading = pending.get(tabId);
|
|
195
|
+
if (loading === undefined) {
|
|
196
|
+
const key = KEY_PREFIX + tabId;
|
|
197
|
+
loading = chrome.storage.session.get(key).then((stored) => {
|
|
198
|
+
const saved = stored?.[key] ?? {};
|
|
199
|
+
return {
|
|
200
|
+
console: saved.console ?? [],
|
|
201
|
+
network: saved.network ?? [],
|
|
202
|
+
starts: new Map(Object.entries(saved.starts ?? {})),
|
|
203
|
+
capturing: saved.capturing === true,
|
|
204
|
+
offReason: saved.offReason,
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
pending.set(tabId, loading);
|
|
208
|
+
}
|
|
209
|
+
const buffer = await loading;
|
|
210
|
+
buffers.set(tabId, buffer);
|
|
211
|
+
pending.delete(tabId);
|
|
212
|
+
return buffer;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function save(tabId, buffer) {
|
|
216
|
+
await chrome.storage.session.set({
|
|
217
|
+
[KEY_PREFIX + tabId]: {
|
|
218
|
+
console: buffer.console,
|
|
219
|
+
network: buffer.network,
|
|
220
|
+
starts: Object.fromEntries(buffer.starts),
|
|
221
|
+
capturing: buffer.capturing,
|
|
222
|
+
offReason: buffer.offReason,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function withNote(records, offReason) {
|
|
228
|
+
return offReason === undefined ? { records } : { records, note: offReason };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function levelOf(type) {
|
|
232
|
+
if (type === "warning") return "warn";
|
|
233
|
+
if (type === "error" || type === "assert") return "error";
|
|
234
|
+
if (type === "debug" || type === "trace") return "debug";
|
|
235
|
+
if (type === "info") return "info";
|
|
236
|
+
return "log";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function textOf(arg) {
|
|
240
|
+
if (arg?.value !== undefined) return String(arg.value);
|
|
241
|
+
if (typeof arg?.description === "string") return arg.description;
|
|
242
|
+
return String(arg?.type ?? "");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 按上限切请求体。说明占的字符也算在上限内,丢掉的数量与实际切掉的一致。 */
|
|
246
|
+
function clipBody(text) {
|
|
247
|
+
if (text.length <= BODY_LIMIT) return text;
|
|
248
|
+
let kept = BODY_LIMIT;
|
|
249
|
+
let dropped = text.length - kept;
|
|
250
|
+
// 说明里的数字位数会改变说明本身的长度,迭代到不再变为止。
|
|
251
|
+
for (;;) {
|
|
252
|
+
const next = BODY_LIMIT - bodyNote(dropped).length;
|
|
253
|
+
const nextDropped = text.length - next;
|
|
254
|
+
if (next === kept) break;
|
|
255
|
+
kept = next;
|
|
256
|
+
dropped = nextDropped;
|
|
257
|
+
}
|
|
258
|
+
return text.slice(0, kept) + bodyNote(dropped);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function bodyNote(dropped) {
|
|
262
|
+
return `\n[truncated ${dropped} chars]`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function messageOf(error) {
|
|
266
|
+
return error instanceof Error ? error.message : String(error);
|
|
267
|
+
}
|