pi-browser-use-lite 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/LICENSE +22 -0
- package/README.md +150 -2
- 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,18 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
本文件记录已发布版本的用户可见变更。更细的过程见 git log。破坏性变更标 **BREAKING**。
|
|
4
|
+
|
|
5
|
+
## 0.1.0 — 2026-11-08
|
|
6
|
+
|
|
7
|
+
首版。
|
|
8
|
+
|
|
9
|
+
- 五个工具:`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 调用)。
|
|
10
|
+
- `/chrome` 命令:`authorize on|off`、`background on|off`、`status`。扩展装好之前,命令与工具都报 `extension_missing` 并给出装扩展三步。
|
|
11
|
+
- 本地桥:只绑 `127.0.0.1`,多 Pi 会话共用一个服务端,其余退成客户端。扩展每 100 毫秒轮询一次。
|
|
12
|
+
- 每个会话一个独立的浏览器窗口:动作在本次会话的 target 里执行,别的窗口不动,退出时按配置关掉标签。
|
|
13
|
+
- 截图默认落在 `<agent dir>/pi-browser-use-lite/screenshots`,跟着 `PI_CODING_AGENT_DIR` 走,不写进家目录的 `~/.pi/screenshots`。
|
|
14
|
+
- 发布:`CHANGELOG.md` 与 `browser-extension/` 一起进 `files`。用户从 npm 装到扩展源码后 load unpacked。
|
|
15
|
+
- 扩展版本落后于包时自己重载一次,不必再去扩展页手动刷新。
|
|
16
|
+
- 轮询断了由扩展的 1 秒定时器重起;`chrome.alarms` 只作服务工作进程被回收后的兜底。
|
|
17
|
+
- `chrome_act eval` 省掉超长 `data` 时,说明接在正文末尾;此前这条说明到不了模型。
|
|
18
|
+
- 包内带 `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,151 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-browser-use-lite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Five `chrome_*` tools that drive your own browser: the tabs you are already logged into, with your cookies and your sessions.
|
|
4
|
+
|
|
5
|
+
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`.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | What it does |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `chrome_act` | navigate, click, type, fill, press, hover, drag, scroll, upload, eval, wait, batch |
|
|
12
|
+
| `chrome_see` | snapshot the page as refs, text, changes or full; read console and network logs |
|
|
13
|
+
| `chrome_tab` | list, new, switch, close |
|
|
14
|
+
| `chrome_vision` | screenshot the viewport or the full page |
|
|
15
|
+
| `chrome_cdp` | one raw CDP call for anything the other four do not cover |
|
|
16
|
+
|
|
17
|
+
Behaviour worth knowing:
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
- Input goes through trusted CDP events, so pages that reject synthetic events still react.
|
|
21
|
+
- Console and network capture starts at navigation, not at the first read.
|
|
22
|
+
- Every failure carries a class and a `hint` that says what to do next.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi install npm:pi-browser-use-lite
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The extension ships inside the package. Load it once per browser:
|
|
31
|
+
|
|
32
|
+
1. Run `/chrome status` in pi. When the extension is missing, the reply prints the directory to load.
|
|
33
|
+
2. Open `chrome://extensions`, turn on Developer mode, click Load unpacked and pick that directory.
|
|
34
|
+
3. Run `/chrome status` again. `connected: yes` means the bridge sees the extension.
|
|
35
|
+
|
|
36
|
+
Then authorize the tools for this session:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
/chrome authorize on
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Authorization ends with the session. Set `policy.authorizeOnStart` to skip this step.
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
**Minimal configuration**: nothing. All five tools work on the defaults.
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Full example**: every key follows with its default and when to change it.
|
|
57
|
+
|
|
58
|
+
```jsonc
|
|
59
|
+
{
|
|
60
|
+
// The local bridge. The extension has the same port compiled in
|
|
61
|
+
// (browser-extension/background/transport.js): change both, then reload the extension
|
|
62
|
+
"bridge": { "port": 17318 },
|
|
63
|
+
|
|
64
|
+
// Session rules
|
|
65
|
+
"policy": {
|
|
66
|
+
"authorizeOnStart": false, // true = the tools work without /chrome authorize on
|
|
67
|
+
"background": true, // true = block every focusing call, chrome_tab switch included.
|
|
68
|
+
// Turn it off when the model must bring a page to the front
|
|
69
|
+
"closeOwnTabsOnExit": true // close the tabs this session opened when the session ends
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Input and timing
|
|
73
|
+
"input": {
|
|
74
|
+
"dragSteps": 10, // mouse moves between press and release
|
|
75
|
+
"scrollSteps": 5 // wheel events per scroll call
|
|
76
|
+
},
|
|
77
|
+
"timing": { "navigateTimeoutMs": 15000 }, // wait for the load event; the navigate call gets this plus 2s
|
|
78
|
+
|
|
79
|
+
// Model-visible output
|
|
80
|
+
"output": {
|
|
81
|
+
"maxElements": 80, // snapshot element cap
|
|
82
|
+
"textChars": 18000 // body text cap in see mode=text; the rest is dropped with a note
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// Screenshots
|
|
86
|
+
"screenshot": {
|
|
87
|
+
"dir": "/home/me/.pi/agent/pi-browser-use-lite/screenshots", // created on first use
|
|
88
|
+
"format": "png", // png | jpeg
|
|
89
|
+
"keepNewest": 20 // older screenshots in that directory are deleted
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// Global rules for the system prompt. These replace the three defaults; they do not extend them
|
|
93
|
+
"prompt": {
|
|
94
|
+
"guidelines": ["chrome_act: this is the user's real browser; confirm before destructive actions."]
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// Tool switches and names. Keep at least one enabled; names must be unique and must not shadow a host tool
|
|
98
|
+
"tools": {
|
|
99
|
+
"chrome_act": { "enabled": true, "name": "chrome_act" },
|
|
100
|
+
"chrome_see": { "enabled": true, "name": "chrome_see" },
|
|
101
|
+
"chrome_tab": { "enabled": true, "name": "chrome_tab" },
|
|
102
|
+
"chrome_vision": { "enabled": true, "name": "chrome_vision" },
|
|
103
|
+
"chrome_cdp": { "enabled": true, "name": "chrome_cdp" }
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// Override the built-in prompts per tool. "" clears description or promptSnippet; [] adds no rules
|
|
107
|
+
"guidance": {
|
|
108
|
+
"chrome_act": {
|
|
109
|
+
"description": "Drive the user's real browser.",
|
|
110
|
+
"promptSnippet": "Drive the browser",
|
|
111
|
+
"promptGuidelines": ["chrome_act: pass see: refs on navigate."]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Every key is optional. `src/config/schema.ts` holds the ranges and the defaults.
|
|
118
|
+
|
|
119
|
+
## Docs
|
|
120
|
+
|
|
121
|
+
| Document | Content |
|
|
122
|
+
|---|---|
|
|
123
|
+
| [ARCHITECTURE](docs/ARCHITECTURE.md) | Module boundaries and seams; read it before changing the structure |
|
|
124
|
+
| [GLOSSARY](docs/GLOSSARY.md) | One name per concept |
|
|
125
|
+
| [Modules](docs/modules/) | Trade-offs and traps per module |
|
|
126
|
+
| [Decisions](docs/decisions/) | Why the shape is what it is |
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
From the repository root:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pnpm -C packages/pi-browser-use-lite lint
|
|
134
|
+
pnpm -C packages/pi-browser-use-lite check:imports
|
|
135
|
+
pnpm -C packages/pi-browser-use-lite typecheck
|
|
136
|
+
pnpm -C packages/pi-browser-use-lite test
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
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:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pnpm -C packages/pi-browser-use-lite test:e2e
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Close every other browser that has this extension. Two copies poll the same bridge, and the commands go to whichever copy asks first.
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT, see [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
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 动作路由表:动作名 → 处理函数。键与 src/protocol 的 `ACTION_NAMES` 一致。
|
|
3
|
+
*
|
|
4
|
+
* 用 Map 而不是对象:动作名来自命令,对象查表会命中原型链上的名字(`toString` 等)。
|
|
5
|
+
* target 解析在 dispatch 里统一做一步,不散进每个处理函数。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
listConsole,
|
|
10
|
+
listNetwork,
|
|
11
|
+
networkRequest,
|
|
12
|
+
startCapture,
|
|
13
|
+
} from "./capture.js";
|
|
14
|
+
import { sendCommand } from "./input/debugger.js";
|
|
15
|
+
import { fill, press, type as typeText } from "./input/keyboard.js";
|
|
16
|
+
import { click, drag, hover, scroll } from "./input/pointer.js";
|
|
17
|
+
import { upload } from "./input/upload.js";
|
|
18
|
+
import { evaluatePage } from "./page/evaluate.js";
|
|
19
|
+
import { screenshotPage } from "./page/screenshot.js";
|
|
20
|
+
import {
|
|
21
|
+
changesSince,
|
|
22
|
+
elementAt,
|
|
23
|
+
markBaseline,
|
|
24
|
+
snapshotPage,
|
|
25
|
+
} from "./page/snapshot.js";
|
|
26
|
+
import { waitFor } from "./page/waitFor.js";
|
|
27
|
+
import { activateTab, closeTab, listTabs, navigateTab } from "./tabs.js";
|
|
28
|
+
import { cleanupAutomation, createTarget, targetFor } from "./target.js";
|
|
29
|
+
|
|
30
|
+
/** 需要一个标签 id 的动作。其余动作自己从 params 取会话信息。 */
|
|
31
|
+
const TAB_ACTIONS = new Set([
|
|
32
|
+
"page.navigate",
|
|
33
|
+
"page.snapshot",
|
|
34
|
+
"page.inspect",
|
|
35
|
+
"page.evaluate",
|
|
36
|
+
"page.click",
|
|
37
|
+
"page.hover",
|
|
38
|
+
"page.drag",
|
|
39
|
+
"page.upload",
|
|
40
|
+
"page.type",
|
|
41
|
+
"page.fill",
|
|
42
|
+
"page.key",
|
|
43
|
+
"page.scroll",
|
|
44
|
+
"page.screenshot",
|
|
45
|
+
"page.waitFor",
|
|
46
|
+
"page.console.list",
|
|
47
|
+
"page.network.list",
|
|
48
|
+
"page.network.get",
|
|
49
|
+
"cdp.call",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** 结果要带变更量的动作。悬停不在里面:鼠标经过不改 DOM,拍两次快照只会白花时间。 */
|
|
53
|
+
const CHANGED_ACTIONS = new Set([
|
|
54
|
+
"page.click",
|
|
55
|
+
"page.type",
|
|
56
|
+
"page.fill",
|
|
57
|
+
"page.key",
|
|
58
|
+
"page.drag",
|
|
59
|
+
"page.upload",
|
|
60
|
+
"page.scroll",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export const routes = new Map([
|
|
64
|
+
[
|
|
65
|
+
"tab.version",
|
|
66
|
+
() => ({ extensionVersion: chrome.runtime.getManifest().version }),
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
"tab.list",
|
|
70
|
+
async ({ sessionKey } = {}) => ({ tabs: await listTabs({ sessionKey }) }),
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
"tab.new",
|
|
74
|
+
async ({ sessionKey, url, background } = {}) => ({
|
|
75
|
+
id: await createTarget({ sessionKey, url, background }),
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
"tab.activate",
|
|
80
|
+
({ tabId, background } = {}) => activateTab({ tabId, background }),
|
|
81
|
+
],
|
|
82
|
+
["tab.close", ({ tabId } = {}) => closeTab({ tabId })],
|
|
83
|
+
[
|
|
84
|
+
"page.navigate",
|
|
85
|
+
({ tabId, url, initScript, timeoutMs } = {}) =>
|
|
86
|
+
navigateTab({ tabId, url, initScript, timeoutMs }),
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
"page.snapshot",
|
|
90
|
+
({ tabId, mode, query, ref, selector, maxElements, textChars } = {}) =>
|
|
91
|
+
snapshotPage({
|
|
92
|
+
tabId,
|
|
93
|
+
mode,
|
|
94
|
+
query,
|
|
95
|
+
ref,
|
|
96
|
+
selector,
|
|
97
|
+
maxElements,
|
|
98
|
+
textChars,
|
|
99
|
+
}),
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
"page.inspect",
|
|
103
|
+
({ tabId, ref, selector } = {}) => elementAt(tabId, { ref, selector }),
|
|
104
|
+
],
|
|
105
|
+
["page.evaluate", ({ tabId, js } = {}) => evaluatePage({ tabId, js })],
|
|
106
|
+
["page.click", async (params) => click(await atPoint(params))],
|
|
107
|
+
["page.hover", async (params) => hover(await atPoint(params))],
|
|
108
|
+
[
|
|
109
|
+
"page.drag",
|
|
110
|
+
async ({ tabId, from, to, steps } = {}) =>
|
|
111
|
+
drag({
|
|
112
|
+
tabId,
|
|
113
|
+
from: await locator(tabId, from),
|
|
114
|
+
to: await locator(tabId, to),
|
|
115
|
+
steps,
|
|
116
|
+
}),
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
"page.upload",
|
|
120
|
+
({ tabId, paths, timeoutMs } = {}) => upload({ tabId, paths, timeoutMs }),
|
|
121
|
+
],
|
|
122
|
+
[
|
|
123
|
+
"page.type",
|
|
124
|
+
async ({ tabId, text, replace, press: key } = {}) => {
|
|
125
|
+
const typed = await typeText({ tabId, text, replace });
|
|
126
|
+
if (key === undefined) return typed;
|
|
127
|
+
await press({ tabId, key });
|
|
128
|
+
return typed;
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
["page.fill", ({ tabId, fields } = {}) => fill({ tabId, fields })],
|
|
132
|
+
["page.key", ({ tabId, key } = {}) => press({ tabId, key })],
|
|
133
|
+
[
|
|
134
|
+
"page.scroll",
|
|
135
|
+
({ tabId, dx, dy, steps } = {}) => scroll({ tabId, dx, dy, steps }),
|
|
136
|
+
],
|
|
137
|
+
[
|
|
138
|
+
"page.screenshot",
|
|
139
|
+
({ tabId, format, fullPage } = {}) =>
|
|
140
|
+
screenshotPage({ tabId, format, fullPage }),
|
|
141
|
+
],
|
|
142
|
+
[
|
|
143
|
+
"page.console.list",
|
|
144
|
+
({ tabId, clear } = {}) => listConsole(tabId, { clear: clear === true }),
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
"page.network.list",
|
|
148
|
+
({ tabId, clear } = {}) => listNetwork(tabId, { clear: clear === true }),
|
|
149
|
+
],
|
|
150
|
+
[
|
|
151
|
+
"page.network.get",
|
|
152
|
+
({ tabId, requestId } = {}) => networkRequest(tabId, requestId),
|
|
153
|
+
],
|
|
154
|
+
[
|
|
155
|
+
"page.waitFor",
|
|
156
|
+
({ tabId, kind, value, timeoutMs } = {}) =>
|
|
157
|
+
waitFor({ tabId, kind, value, timeoutMs }),
|
|
158
|
+
],
|
|
159
|
+
[
|
|
160
|
+
"cdp.call",
|
|
161
|
+
async ({ tabId, method, params } = {}) => ({
|
|
162
|
+
result: await sendCommand(tabId, method, params),
|
|
163
|
+
}),
|
|
164
|
+
],
|
|
165
|
+
[
|
|
166
|
+
"automation.cleanup",
|
|
167
|
+
({ sessionKey, closeOwnTabs } = {}) =>
|
|
168
|
+
cleanupAutomation({ sessionKey, closeOwnTabs }),
|
|
169
|
+
],
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 路由一条命令。
|
|
174
|
+
* 动作名不在表里抛 `Unknown action: …`;在表里但实现没接抛 `… is not wired yet`。
|
|
175
|
+
* 需要标签 id 的动作先取 target 并开日志抓取,改元素的动作前后各拍一次快照比出变更量。
|
|
176
|
+
* @param {string} action 动作名
|
|
177
|
+
* @param {Record<string, unknown>} [params] 动作参数
|
|
178
|
+
* @returns {Promise<unknown>} 动作的结果
|
|
179
|
+
*/
|
|
180
|
+
export async function dispatch(action, params = {}) {
|
|
181
|
+
if (!routes.has(action)) throw new Error(`Unknown action: ${action}`);
|
|
182
|
+
const route = routes.get(action);
|
|
183
|
+
if (route === undefined) throw new Error(`${action} is not wired yet`);
|
|
184
|
+
if (!TAB_ACTIONS.has(action)) return await route(params);
|
|
185
|
+
|
|
186
|
+
const tabId = await tabIdOf(action, params);
|
|
187
|
+
await startCapture(tabId);
|
|
188
|
+
const withTab = { ...params, tabId };
|
|
189
|
+
if (!CHANGED_ACTIONS.has(action)) return await route(withTab);
|
|
190
|
+
await markBaseline(tabId);
|
|
191
|
+
const result = await route(withTab);
|
|
192
|
+
return { ...result, changed: await changesSince(tabId) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 点名的标签 id 不合法就报错:退回会话 target 会作用到别的标签上。 */
|
|
196
|
+
function requireTabId(tabId) {
|
|
197
|
+
if (!Number.isInteger(tabId))
|
|
198
|
+
throw new Error(`bad_args: tabId must be an integer, got ${tabId}`);
|
|
199
|
+
return tabId;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 命令作用的标签:调用方点了标签就用它,否则用本会话的自动化 target。
|
|
204
|
+
* @returns {Promise<number>} 标签 id
|
|
205
|
+
*/
|
|
206
|
+
async function tabIdOf(action, params) {
|
|
207
|
+
if (params.tabId !== undefined) return requireTabId(params.tabId);
|
|
208
|
+
return targetFor({
|
|
209
|
+
sessionKey: params.sessionKey,
|
|
210
|
+
action,
|
|
211
|
+
background: true,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 指针层的入参:ref 换成元素中心的视口坐标,其余原样。 */
|
|
216
|
+
async function atPoint({ tabId, ...target }) {
|
|
217
|
+
return { tabId, ...(await locator(tabId, target)) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 指针层认 selector 或坐标,ref 由快照解析成坐标。
|
|
222
|
+
* @returns {Promise<{ selector?: string, x?: number, y?: number }>} 定位
|
|
223
|
+
*/
|
|
224
|
+
async function locator(tabId, target = {}) {
|
|
225
|
+
const { ref, selector, x, y } = target;
|
|
226
|
+
if (typeof ref !== "string" || ref === "") return { selector, x, y };
|
|
227
|
+
const { element } = await elementAt(tabId, { ref });
|
|
228
|
+
const rect = element?.rect;
|
|
229
|
+
if (!rect) throw new Error(`page_error: ref ${ref} is not on the page`);
|
|
230
|
+
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
|
231
|
+
}
|