@glittering0717/ai-browser 1.3.1 → 2.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.
Files changed (37) hide show
  1. package/README.md +56 -5
  2. package/bin/ai-browser.cjs +41 -4
  3. package/examples/AGENT_INSTRUCTIONS.md +49 -164
  4. package/llms-install.md +67 -5
  5. package/package.json +3 -1
  6. package/scripts/cli_call.cjs +222 -0
  7. package/scripts/ensure_running.cjs +195 -0
  8. package/scripts/skill.cjs +133 -0
  9. package/skills/ai-browser/SKILL.md +56 -0
  10. package/skills/ai-browser/references/cli.md +81 -0
  11. package/skills/ai-browser/references/hard-pages.md +52 -0
  12. package/skills/ai-browser/references/network.md +39 -0
  13. package/skills/ai-browser/references/tasks.md +44 -0
  14. package/skills/ai-browser/references/troubleshooting.md +31 -0
  15. package/skills/content-publish/SKILL.md +61 -0
  16. package/skills/read-webpage/SKILL.md +52 -0
  17. package/skills/web-network-monitor/SKILL.md +52 -0
  18. package/src/main/axExtractor.js +50 -6
  19. package/src/main/capability_probe.js +93 -5
  20. package/src/main/index.js +99 -6
  21. package/src/main/mcp_args.js +122 -0
  22. package/src/main/mcp_server.js +72 -159
  23. package/src/main/mcp_tools.js +127 -86
  24. package/src/main/mcp_ws.js +7 -1
  25. package/src/main/page_manager.js +481 -71
  26. package/src/main/window_open_shim_source.js +117 -0
  27. package/src/preload/bridge.cjs +22 -4
  28. package/src/preload/extractor.cjs +161 -17
  29. package/src/shared/doc_coverage.js +264 -0
  30. package/src/shared/effect_state.js +160 -0
  31. package/src/shared/frame_probe.cjs +91 -1
  32. package/src/shared/frame_read_source.js +260 -0
  33. package/src/shared/opened_tab.js +71 -0
  34. package/src/shared/ref_epoch.js +99 -0
  35. package/src/shared/tab_hooks.js +33 -0
  36. package/src/shared/version.js +1 -1
  37. package/tools/coverage_probe.cjs +111 -9
@@ -0,0 +1,81 @@
1
+ # CLI — 不用 MCP 也能干活的那条路
2
+
3
+ ## 什么时候得用它
4
+
5
+ `browse_*` 工具是 MCP 层的东西。MCP 要挂上,得经过客户端的三道闸门:配置文件、一次性授权、
6
+ 会话启动时的工具快照。任何一环没走完(最常见:**改完配置没开新会话**),工具就不出现。
7
+
8
+ 这时候别顺着"配置 → 授权 → 重启"往下修 —— 那条路在客户端手里,你改不动。
9
+ CLI 是同一个 JSON-RPC 的另一个门,只依赖一件事:命令能在 PATH 里跑起来。
10
+
11
+ ```bash
12
+ ai-browser call ui.get_tree '{"subset":"interactive"}'
13
+ # 没装全局命令时:
14
+ npx -y @glittering0717/ai-browser call ui.get_tree '{"subset":"interactive"}'
15
+ ```
16
+
17
+ stdout 只有 result(紧凑 JSON,可直接 `| jq`),进度和错误走 stderr。
18
+
19
+ ## 先确认它是不是活着
20
+
21
+ ```bash
22
+ ai-browser status # {"running":true,"pid":…,"port":9223,"tabs":[…]}
23
+ ai-browser ensure # 幂等:没开就拉起,开了就直接返回
24
+ ai-browser call ui.get_tree # 连不上会自动 ensure 再重试一次
25
+ ```
26
+
27
+ `status` **不会**拉起浏览器 —— 它问的是"在跑吗",答"不在"才算答对。
28
+ `call` 会。想快速失败(脚本里)加 `--no-start`。
29
+
30
+ ## 方法对照表
31
+
32
+ MCP 工具名去掉 `browse_`,换成 `ui.`,就是 CLI 方法名。
33
+
34
+ | 一一对应 | get_tree · act · navigate · evaluate · scroll · wait · screenshot · errors · capabilities · downloads · quit · auth_state · repaint |
35
+ |---|---|
36
+ | `browse_tabs` | `ui.list_tabs` / `ui.new_tab` / `ui.close_tab` / `ui.set_active_tab` |
37
+ | `browse_network` | `ui.network_list` / `ui.network_get`(两个,不是一个) |
38
+ | `browse_canvas` | `ui.canvas_list` / `ui.canvas_read` / `ui.canvas_capture` / `ui.canvas_configure` |
39
+ | `browse_diff` | `ui.snapshot` / `ui.diff` |
40
+
41
+ ## 常用参数(底层契约,CLI 直传)
42
+
43
+ ```bash
44
+ ai-browser call ui.navigate '{"url":"https://example.com","tab":1,"wait_until":"load"}'
45
+ ai-browser call ui.get_tree '{"tab":1,"subset":"interactive"}' # 交互树
46
+ ai-browser call ui.get_tree '{"tab":1,"ax":true}' # 阅读树(长正文用它)
47
+ ai-browser call ui.act '{"action":"click","target":"e:a-3"}'
48
+ ai-browser call ui.act '{"action":"type","target":"e:input-1","text":"关键词"}'
49
+ ai-browser call ui.evaluate '{"expr":"document.title"}'
50
+ ai-browser call ui.evaluate '{"frame_ref":"frame-1","expr":"document.body.innerText"}'
51
+ ai-browser call ui.wait '{"condition":"text_contains","text":"加载完成"}'
52
+ ai-browser call ui.network_list '{"tab":1}'
53
+ ai-browser call ui.screenshot '{"tab":1}'
54
+ ```
55
+
56
+ `ui.act` 的 `target` 只认树里的 `id`(形如 `e:a-3`),不认页面上的文字。
57
+
58
+ 长 JSON 别跟 shell 引号搏斗,用 `-` 从 stdin 读:
59
+
60
+ ```bash
61
+ echo '{"action":"type","target":"e:input-1","text":"很长很长的内容"}' | ai-browser call ui.act -
62
+ ```
63
+
64
+ ## 没连上时它在说什么
65
+
66
+ | stderr 里出现 | 意思 | 下一步 |
67
+ |---|---|---|
68
+ | `没在 127.0.0.1:9223 上跑` | 浏览器没起(`--no-start` 时最常见) | 去掉 `--no-start`,或 `ai-browser ensure` |
69
+ | `could not start the browser: …cause: exited early` | 拉起了但立刻崩 | 看 cause:`ELECTRON_RUN_AS_NODE` 没清 / Chromium 沙箱被拦 / 二进制没装全 |
70
+ | `Method not found (ui.xxx)` | 方法名错(拼错,或用了 MCP 的名字) | 对照上面的表 |
71
+ | `no reply from … within …ms` | 方法执行太慢 | 加大 `--timeout` |
72
+
73
+ ## 端口与多实例
74
+
75
+ ```bash
76
+ ai-browser status --port 9701
77
+ AI_BROWSER_PORT=9701 AI_BROWSER_USER_DATA=/tmp/ab-test ai-browser ensure
78
+ ```
79
+
80
+ 同一 `AI_BROWSER_USER_DATA` 有单实例锁 —— 换 profile 才能开第二个。
81
+ 搞 e2e / 并行任务时必须换,否则会撞锁,端口永远不开。
@@ -0,0 +1,52 @@
1
+ # 难搞的页面
2
+
3
+ ## canvas(纯绘制,没有 DOM 节点)
4
+
5
+ 读内容**不要 OCR**。canvas 里的文本来自 `fillText` 调用,能被直接截获。
6
+
7
+ - 前提:浏览器启动时带 `AI_BROWSER_CANVAS_HOOK=1`(默认关,因为要挂 CDP 绘制层)。
8
+ - `browse_canvas {operation:"list"}` → 拿到 canvas 与 `canvas_id`
9
+ - `browse_canvas {operation:"read", canvas_id}` → 文本 + 坐标 + 字体
10
+ - **WebGL 是黑盒**:捕获不到文本,只能 `browse_canvas {operation:"capture"}` 拿 PNG,自己承担视觉成本。
11
+
12
+ 在 canvas 上落点用坐标动作,不要找元素:
13
+
14
+ ```
15
+ browse_act { action:"click", drag:{to:{x:420,y:180}} } // 绝对值
16
+ browse_act { action:"wheel", wheel:{dx:0, dy:-240, hold:["Control"]} } // 缩放
17
+ browse_act { action:"drag", drag:{from:{x:100,y:100}, to:{x:300,y:260}} } // 平移/框选
18
+ ```
19
+
20
+ ## 富文本编辑器(Draft.js / ProseMirror / Quill / CKEditor / 各类 contenteditable)
21
+
22
+ **不要逐字符输入,也不要设 `innerHTML`。** 用 paste,让编辑器走自己的粘贴管线:
23
+
24
+ ```
25
+ browse_act { action:"paste", target:"<id>", html:"<h2>标题</h2><p>正文…</p>" }
26
+ ```
27
+
28
+ `html` 优先;只给 `text` 会退化成纯文本,标题/列表/代码块/加粗全丢。
29
+ 所有主流编辑器共用同一条 CDP 通道,不需要站点特判。
30
+
31
+ ## 跨进程 iframe(OOPIF)
32
+
33
+ iframe 里的节点在读层有独立 id(形如 `axf-3-1`),`browse_get_tree` 未必带出来。
34
+
35
+ - 先 `browse_get_tree {ax: true}` —— AX 层把 iframe 内容也纳进来了
36
+ - 仍然没有 → `browse_capabilities` 看 `iframes` 清单
37
+ - 动作可以直接瞄准 `axf-3-1` 这类 id,跨进程也能点到
38
+
39
+ ## 上传
40
+
41
+ ```
42
+ browse_act { action:"upload", target:"<id>", file:"/abs/path/to/file.pdf" }
43
+ ```
44
+
45
+ `target` 可以是文件输入框、触发选择器的按钮、**或者 CSS 选择器**(唯一允许选择器的动作,
46
+ 因为纯 div 的上传控件常常没有语义节点)。跨进程 iframe 里同样可用。
47
+ `file` 必须是绝对路径。
48
+
49
+ ## 弹窗与浮层
50
+
51
+ 树里的 `context` 会带 modal / required hint / toast / 登录态 / 统计。
52
+ 点了之后先看 `context.modals`,不要靠截图猜弹窗有没有出现。
@@ -0,0 +1,39 @@
1
+ # 网络与结构化数据
2
+
3
+ ## 抓请求(像 Chrome Network 面板)
4
+
5
+ 抓包**从 tab 打开那一刻就开始**,不需要订阅,所以 POST 请求体也在。
6
+
7
+ ```
8
+ browse_network { operation:"list", resource_type:["XHR","Fetch"] }
9
+ browse_network { operation:"list", url_contains:"/api/", status_min:400 }
10
+ browse_network { operation:"get", network_id:"<id>", include_response_body:true }
11
+ ```
12
+
13
+ - 敏感头(`authorization` / `cookie`)默认脱敏;要看真值需启动时 `AI_BROWSER_NETWORK_SENSITIVE=1`
14
+ - 大响应体分页取:`body_limit` + `*_body_offset`,返回里有 `next_before_seq` 之类的游标
15
+ - 关掉抓包省内存:`browse_network { operation:"configure", enabled:false }`
16
+
17
+ **什么时候值得看网络而不是看树**:表单提交后页面没反应、想知道到底发了什么;
18
+ 想知道某个列表页背后的 API(比解析 DOM 稳得多);排查 4xx/5xx。
19
+
20
+ ## 从页面取结构化数据
21
+
22
+ 优先级从高到低 —— **顺着这个顺序试,越靠前越稳、越省**:
23
+
24
+ 1. **页面自己的数据模型**。很多 SPA 把数据挂在全局对象上:
25
+ `browse_evaluate { js: "JSON.stringify(window.__NUXT__?.data ?? null)" }`
26
+ 拿到的是原始结构,比从 DOM 反推可靠一个数量级。
27
+ 2. **背后的 API**。用 `browse_network` 抓到接口,再 `browse_evaluate` 里 `fetch` 同一接口
28
+ (注意带凭据:`credentials:'include'`)。
29
+ 3. **语义树**。`browse_get_tree {subset:"interactive"}` 逐节点取 `label`/`value`。
30
+ 适合表格、列表这类结构规整的页面。
31
+ 4. **截图 + 视觉**。只在上面全都不通时用,且要接受 token 成本。
32
+
33
+ `browse_evaluate` 的上限:5000 字符表达式(不是语句块),禁用 `process` / `require`。
34
+ 要返回大对象就先在页面里 `JSON.stringify` 再切片取。
35
+
36
+ ## 产出交付物
37
+
38
+ - 表格类结果直接给 Markdown 表或 JSON,不要贴截图。
39
+ - 需要下载文件:点下载后 `browse_downloads` 轮询拿落盘路径(下载是异步的)。
@@ -0,0 +1,44 @@
1
+ # 任务配方
2
+
3
+ ## 登录 / 需要人的环节
4
+
5
+ 登录态存在持久分区里,**重启浏览器也还在**,所以通常只需人工介入一次。
6
+
7
+ 1. 判断现状:`browse_auth_state`。`auth_wall.detected=true` 就是被踢了。
8
+ 2. 需要人:在对话里说一句人话("请在浏览器窗口里扫码登录,完成后回复我'好了'"),然后**停下来等**。
9
+ 3. 收到回复后 `browse_get_tree` 确认真的进去了,再继续。
10
+ 4. 不要编造凭据,不要连续重试注定失败的点击——重试只会让站点的风控更严。
11
+
12
+ ## 表单填写
13
+
14
+ 1. `browse_get_tree {subset:"interactive"}` 拿到所有可交互节点,注意 `required` 与 `states`。
15
+ 2. 按节点 `id` 逐个 `browse_act {action:"type"}`。**不要用 Tab 键跳转**——顺序依赖布局,很脆。
16
+ 3. 提交前重读一次树,看 `context` 里的必填提示与远端校验错误。
17
+ 4. 提交后 `browse_diff` 证明真的变了;`browse_network` 看 POST 到底发了什么。
18
+ 5. 只看到 toast 就当成功是常见误判——**toast 不等于后端接受**,要看到列表/详情页出现新数据。
19
+
20
+ ## 多标签
21
+
22
+ - `browse_tabs {op:"list"}` 看现状;`{op:"new", url}` 新开;`{op:"switch", tab}` 切换。
23
+ - 默认动作落在 **active tab** 上,所以切换是隐式上下文,不用每次传 `tab`。
24
+ - `window.open` / `target="_blank"` 打开的新标签会被自动接管为 active(想留在原地传 `keep_tab:true`)。
25
+
26
+ ## 证明操作生效(`browse_diff`)
27
+
28
+ `success` 不是证据,`diff` 才是。
29
+
30
+ ```
31
+ browse_diff { operation:"snapshot", js:"app.state" } // 取基线(js 可选,不带就比 DOM)
32
+ browse_act { action:"click", target:"<id>" }
33
+ browse_diff { operation:"diff", snapshot:"<基线>" } // verdict: changed | unchanged
34
+ ```
35
+
36
+ `unchanged` 是个强信号:不是你点错目标,就是页面根本没响应。别接着瞎点。
37
+
38
+ ## 有专用 skill 的活儿
39
+
40
+ 这三类别在这里硬做,用对应的技能(它们带更细的校验步骤):
41
+
42
+ - 把文章发布到 CSDN / 知乎 / 掘金 → `content-publish`
43
+ - 读一篇文章的正文并总结 → `read-webpage`
44
+ - 长时间盯某个页面的请求 → `web-network-monitor`
@@ -0,0 +1,31 @@
1
+ # 失败排查顺序
2
+
3
+ 按这个顺序走,**别一上来就写站点专用选择器**。绝大多数"点不动"不是选择器问题。
4
+
5
+ 1. **重读树,或先 wait。** SPA 常常只是还没渲染完。`browse_wait {condition:"text_contains"}` 或
6
+ `browse_get_tree {mode:"diff"}` 看有没有新增节点。
7
+ 2. **节点在树里但操作失败 → 动作层问题。** 跨进程 iframe、canvas、需要受信输入、上传控件未挂载。
8
+ 写选择器解决不了——换动作(`press` / `wheel` / `drag`)或换目标才是解法。
9
+ 3. **树里没有、页面上明明有 → 读层缺口。** 这是浏览器该修的东西,不是你的脚本该绕的。
10
+ 先跑 `tools/coverage_probe.cjs <url>` 量化覆盖率,再决定是否绕过。
11
+ 4. **以上都不成立**,才在自己脚本里用选择器兜底。别把"某个站点的选择器"记成通用知识。
12
+
13
+ ## 四条别做
14
+
15
+ - **别把 `success: true` 当成功。** 要证明页面变了:act 前后各 `browse_diff`,看 `verdict`。
16
+ - **别用截图当读页面的主要手段。** 截图只用来判断"视觉上怎么了"(浮层、遮挡、渲染错位),
17
+ 读内容一律走语义树。
18
+ - **别在登录态失效时继续点。** 先 `browse_auth_state`,`auth_wall.detected=true` 就停下来找用户。
19
+ - **别在用户正操作窗口时 `browse_quit`。** 它会关掉整个窗口和进程。
20
+
21
+ ## 读树的省 token 参数
22
+
23
+ | 参数 | 作用 |
24
+ |---|---|
25
+ | `subset: "interactive"` | 只留可交互节点,砍掉纯布局分支(默认就该带) |
26
+ | `mode: "diff"` | 只看自上次读取以来新增/消失的交互节点 |
27
+ | `ax: true` | 走 AX 语义层,链接会带 `url`(iframe 内容也更全) |
28
+ | `focused_only: true` | 只看焦点子树 |
29
+
30
+ 树里 `value` 超过 200 字符会被截断。要全文用
31
+ `browse_act {action:"get_value", target, offset, limit}` —— 只读,不聚焦、不触发 input 事件。
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: content-publish
3
+ description: >-
4
+ 适用于用户要求"把内容/文章发布到某个平台(CSDN、知乎、掘金、其他博客/社区等)"、或用编辑器录入并提交内容的自动化发布/提交流程任务。触发词:发布到、发博客、发文章、submit/publish this post、发布内容。
5
+ 注意边界:若用户只是想编辑某个已有页面而非提交发布,用 browse_navigate / browse_act 直接处理即可,不需本 skill 的发布校验步骤。
6
+ ---
7
+
8
+ # 内容发布 / 提交流程(content-publish)
9
+
10
+ ## 目标
11
+ 用 AI Browser 把标题 + 正文内容发布到目标平台的编辑器并确认提交成功。完全用已保留的 MCP 原语(`browse_navigate`/`browse_get_tree`/`browse_act`/`browse_evaluate`/`browse_wait`),不引入额外脚本或运行环境。
12
+
13
+ ## 前置
14
+ - 已在目标平台登录(登录态未过期)。未登录时先引导用户完成登录/验证。
15
+ - 已知"编辑器入口 URL"或当前已在可编辑的页面。
16
+
17
+ ## 核心步骤(通用,按平台微调)
18
+
19
+ 1. 导航到目标平台的文章/编辑器页面:
20
+ `browse_navigate({ url: "<编辑器URL>" })`;必要时
21
+ `browse_wait({ condition: "url_contains", text: "<编辑器路径特征>" })`。
22
+
23
+ 2. 读取页面结构,定位标题框与正文框:
24
+ `browse_get_tree({})`,用返回元素的 `label`/`role` 找到标题输入框与正文编辑区(编辑器可能是 `contenteditable`,role 为 textbox 或 div)。
25
+
26
+ 3. 录入标题:
27
+ `browse_act({ action: "type", target: "<标题框 data-ai-id>", text: "<标题>" })`。
28
+
29
+ 4. 录入正文:
30
+ - 普通 `<textarea>`:`browse_act({ action: "type", target: "...", text: "<正文>" })`。
31
+ - 富文本/contenteditable 编辑器:`browse_act` 的 `type` 已支持 contenteditable / Draft / ProseMirror / CodeMirror,直接复用即可;若失效,用
32
+ `browse_evaluate` 手动设置 `textContent` 并派发 `input` 事件兜底。
33
+
34
+ 5. 可选:若需先预览或勾选多平台发布,按 `browse_get_tree` 找到对应控件并 `browse_act`。
35
+
36
+ 6. **先记下时间点**(用于事后区分"这次点击引发的请求/报错"):
37
+ 需要看接口时用 `browse_network({ operation: "clear" })` 清一次记录;需要看 JS 报错时记住当前状态,
38
+ 发布后再 `browse_errors` 对照。没有「订阅事件」这类工具:抓请求用 browse_network,看报错用 browse_errors。
39
+
40
+ 7. 点击发布按钮:用 `browse_get_tree` 找到 label 为"发布/发布博客/提交"的按钮,再
41
+ `browse_act({ action: "click", target: "<发布按钮 data-ai-id>" })`。
42
+
43
+ 8. 等待并校验结果:
44
+ - `browse_wait({ condition: "text_contains", text: "发布成功" })` 或"多平台发布";
45
+ - 用 `browse_evaluate` 读取 `document.body.innerText`,检查是否出现:
46
+ "发布成功"(成功)、"多平台发布"(多平台已推)、"审核"(进入审核)、或报错提示(失败)。
47
+ - 按结果把状态反馈给用户,失败时给出页面提示原文。
48
+
49
+ ## 校验信号(常见平台)
50
+ | 信号 | 含义 |
51
+ |---|---|
52
+ | 发布成功 / 保存成功 | 已发布 |
53
+ | 多平台发布 | 已同步分发到多个平台 |
54
+ | 审核 / 待审核 | 进入人工/系统审核 |
55
+ | 报错 toast / message_appeared 事件体 | 失败,需读取提示原文 |
56
+
57
+ ## 边界与替代
58
+ - skill 不保存账号/密码,登录与验证码需用户配合。
59
+ - 若某平台按钮文案不同,用 `browse_get_tree` 的 label 动态定位,不要硬编码选择器。
60
+ - 发布属于不可逆操作:点击前可用 `browse_get_tree` 确认拿到的确实是发布按钮(label 含"发布/提交"),避免误点。
61
+ - 本 skill 是操作指南,依赖的仍是 MCP 常驻原语,不额外启动脚本/进程。
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: read-webpage
3
+ description: >-
4
+ 适用于需要阅读、理解或总结某个网页的正文文章内容(标题 + 段落)的任务,尤其是新闻/博客/公众号等文章型页面。触发词:读这篇文章、阅读该页、总结内容、提炼要点、这篇文章讲了什么、extract the article、summarize this page。
5
+ 注意边界:若只需读取页面上的控件/标签/按钮/少量文本,请用 browse_get_tree,不要用本 skill;目标是交互(登录、表单、评论)而非读正文时也不要用本 skill。
6
+ ---
7
+
8
+ # 网页正文提取(read-webpage)
9
+
10
+ ## 目标
11
+ 把当前页面"正文文章"(标题 + 段落)从 HTML 日报纹路中提取出来,交付给用户阅读/总结。完全用 `browse_evaluate` 实现(历史上曾有一个专门的读正文工具,已下线,别再找它)。
12
+
13
+ ## 前置
14
+ - 已打开目标文章页,页面已加载完成(必要时先 `browse_wait({condition:'url_contains', ...})`)。
15
+ - 本 skill 只做"读",不做跳转/点击。
16
+
17
+ ## 步骤
18
+ 1. 确保浏览器已指向文章页。
19
+ 2. 调用 `browse_evaluate`,传入下述正文提取 JS 表达式,`params.tab` 按需指定:
20
+ js:
21
+ ```
22
+ (function(){
23
+ var h1 = document.querySelector('h1');
24
+ var title = h1 ? h1.textContent.trim() : document.title;
25
+ var article = document.querySelector('.article-body, .article-content, .main-content, .rich_media_content, article, .txt-article, .Body, #content, .post-content');
26
+ var ps = article ? article.querySelectorAll('p') : document.querySelectorAll('p');
27
+ var paras = [];
28
+ for (var i=0; i<Math.min(ps.length, 30); i++) {
29
+ var t = ps[i].textContent.trim();
30
+ if (t.length > 25) paras.push(t.slice(0, 400));
31
+ }
32
+ return JSON.stringify({title:title, pCount:paras.length, paras:paras.slice(0,15)});
33
+ })()
34
+ ```
35
+ 3. 解析返回值里的 `JSON`(字段:`title`、`pCount`、`paras[]`)。
36
+ 4. 若 `pCount === 0`:页面无可提取正文,改用 `browse_get_tree` 读界面控件,并向用户说明"此页非文章型内容"。
37
+ 5. 组装交付:`title` + 每段 `paras`(按顺序拼接,保留换行),再根据用户意图做总结/提炼要点。
38
+
39
+ ## 示例(Claude Code 风格)
40
+ 用户:"读下这篇掘金文章讲了什么"
41
+ - `browse_evaluate({ js: <上面脚本>, tab })`
42
+ - 解析返回 → 输出 `标题 + 逐段摘要`。
43
+
44
+ ## 验证
45
+ - 命中 `<article>` / `.article-content` 等容器时为二段式(title + paras)。
46
+ - 输出为标题 + 段落数组(同一套提取脚本主体),可直接交给用户阅读或总结。
47
+ - 空结果时明确提示,不强行拼接垃圾文本。
48
+
49
+ ## 边界与替代
50
+ - 只用于"正文文章"。登录框、评论、表格、导航等非文章内容走 `browse_get_tree` / `browse_act`。
51
+ - 不分页/不处理懒加载(动态新增段落不会出现);长文分段处理或提示用户。
52
+ - 静默失败判据:若取回的 title 与 paras 均为空,判为"非文章页"。
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: web-network-monitor
3
+ description: >-
4
+ 适用于监控页面发出的网络请求、抓取指定 API 的响应体、验证请求参数/返回值、或查看页面 JS 错误的排查任务。触发词:抓包、网络请求、接口响应、接口返回了什么、response body、看下这个接口、js error、请求参数。
5
+ 仅当任务需要"网络层信息"时使用;普通点击/读写页面请直接用 browse_act / browse_get_tree。
6
+ ---
7
+
8
+ # 网络 / 接口监控(web-network-monitor)
9
+
10
+ 用**两个**工具就够,不需要订阅、不需要写脚本:
11
+
12
+ | 想做什么 | 调用 |
13
+ |---|---|
14
+ | 看页面发了哪些请求 | `browse_network {operation:"list"}` |
15
+ | 按 URL 找某个请求 | `browse_network {operation:"list", filters:{url_contains:"/api/user"}}` |
16
+ | 看某条请求的详情 + 响应体 | `browse_network {operation:"get", network_id:"<从 list 拿>", include:{response_body:true}}` |
17
+ | 清空已记录请求 | `browse_network {operation:"clear"}` |
18
+ | 看页面 JS 报错 | `browse_errors` |
19
+
20
+ ## 标准四步
21
+
22
+ 1. `browse_network {operation:"clear"}` —— 先清,避免混进旧请求。
23
+ 2. 触发请求:`browse_act {action:"click", target:"<按钮 data-ai-id>"}`。
24
+ 3. `browse_network {operation:"list", filters:{url_contains:"<关键字>"}}`
25
+ → 每条带 `id`、`method`、`url`、`status`、`state`。
26
+ 4. `browse_network {operation:"get", network_id:"<id>", include:{response_body:true, request_body:true}}`
27
+ → 拿 headers 与 body。大 body 用 `body_offset` 翻页。
28
+
29
+ ## 关键事实(不按这个来会白等)
30
+
31
+ - **网络记录是按"从现在起"累积的**,`clear` 只清当前 tab 的记录。
32
+ - `list` 默认返回 50 条(最多 200);更多用 `filters.before_seq` 翻页。
33
+ - `response_body` 只在请求**已完成**后才有;`state:"pending"` 时先 `browse_wait {condition:"text_contains"}` 或稍等再 `get`。
34
+ - 敏感 header(Cookie/Authorization)默认打码,需要时由人设置 `AI_BROWSER_NETWORK_SENSITIVE=1` 并重启。
35
+ - 请求体/响应体可能很大:先用 `filters` 收窄,再 `include.body_limit` 控制。
36
+
37
+ ## 示例
38
+
39
+ 用户:"我点提交之后到底调了哪个接口、返回了什么?"
40
+
41
+ ```
42
+ browse_network {operation:"clear"}
43
+ browse_act {action:"click", target:"e:button-7"}
44
+ browse_network {operation:"list", filters:{method:["POST"]}}
45
+ browse_network {operation:"get", network_id:"<上一步的 id>", include:{response_body:true}}
46
+ ```
47
+
48
+ ## 边界
49
+
50
+ - 页面关闭后该 tab 的记录一起没了;跨 tab 各自独立。
51
+ - `browse_evaluate` 不能替代:它只看得到页面 JS 上下文,看不到网络层。
52
+ - 拿不到 body 时先确认 `state` 是否为 `finished`,再确认请求是否真的发生过(`list` 里有没有它)。
@@ -170,6 +170,12 @@ function build(node, idFor) {
170
170
  // 被 AX 标为 ignored 的只是包裹层(见 buildThroughIgnored);它的子节点
171
171
  // 才是内容,必须被提升上来而不是连同子树一起丢掉。
172
172
  if (c.ignored) { kids.push(...buildThroughIgnored(c, idFor)); continue; }
173
+ if (isRedundantInlineBox(c, tn.label)) {
174
+ // 也要登记 seen:剪掉的节点如果还能从别的路径(被提升上来的 ignored
175
+ // 包裹层)走一遍,就会重新出现在树里,剪枝变成随机的。
176
+ seenNodes.add(c.nodeId);
177
+ continue;
178
+ }
173
179
  seenNodes.add(c.nodeId);
174
180
  kids.push(build(c, idFor));
175
181
  }
@@ -177,6 +183,34 @@ function build(node, idFor) {
177
183
  return tn;
178
184
  }
179
185
 
186
+ /**
187
+ * `InlineTextBox` 是 CDP 把一个 `StaticText` 按**换行和样式**切出来的文字碎片:
188
+ * 一段两行的正文会变成两个 inline box,一个加粗的词再切一个。
189
+ *
190
+ * 它们不是结构,是排版产物。实测 CSDN 长文(2026-10-19):
191
+ * AX 树 493 个节点里 163 个是 InlineTextBox,剪掉后
192
+ * **体积 84 420 → 64 054 bytes(-24%)、正文字数 5182 一字不差**。
193
+ * 这条读法现在被 page_coverage 推荐给"要读正文"的调用方,它贵 24% 就是
194
+ * 每次读文章都多花 24% 的 token。
195
+ *
196
+ * 判据是**内容已被父节点包含**,不是"角色叫 InlineTextBox 就删":
197
+ * 万一某个 StaticText 的 name 是空的、文字只在 inline box 上,
198
+ * 删掉就是丢字 —— 那比省 24% 严重得多。所以这里只删"父节点 label 里
199
+ * 已经写着这段文字"的那一种;拿不准的一律保留,宁可多一个节点。
200
+ *
201
+ * 只在叶子时剪:有子节点的 inline box 意味着还有别的东西挂在下面。
202
+ */
203
+ function isRedundantInlineBox(rawChild, parentLabel) {
204
+ const role = (rawChild.role && rawChild.role.value) || '';
205
+ if (role !== 'InlineTextBox') return false;
206
+ if (rawChild.childIds && rawChild.childIds.length) return false;
207
+ const text = (rawChild.name && rawChild.name.value) || '';
208
+ // 空 label 的节点没有任何信息,一起剪掉;父节点为空的场景上面已经排除了
209
+ // (parentLabel 为空时 indexOf 必为 -1,不会命中)。
210
+ if (!text) return !!parentLabel;
211
+ return !!parentLabel && parentLabel.indexOf(text) >= 0;
212
+ }
213
+
180
214
  let readValue = null; // opts.readValue — per-node DOM read hook for textbox values
181
215
 
182
216
  // Public pure API.
@@ -234,16 +268,23 @@ export function mergeFrameTrees(mainTree, frames = []) {
234
268
  const usable = (frames || []).filter((f) => f && f.tree);
235
269
  if (!usable.length) return mainTree;
236
270
 
237
- // Lift interactive/editable subtrees out of each frame tree (frame roots are
238
- // usually generic wrappers; keep their actionable leaves, drop pure layout).
271
+ // Lift interactive/editable subtrees AND text blocks out of each frame tree
272
+ // (frame roots are usually generic wrappers; keep their leaves, drop pure layout).
273
+ //
274
+ // 为什么内容块也要留:这个函数原来只留 `actions.length > 0` 的节点,也就是
275
+ // 只有"能点的东西"算数。于是跨进程 frame 里的标题/列表/表格单元——也就是
276
+ // agent 真正要**读懂**的东西——即使被读出来也会在这里被丢第二次(第一次是
277
+ // 采集端的选择器)。超星泛雅的章节列表就是这么整片消失的。
278
+ const CONTENT_ROLES = new Set(['text', 'heading', 'listitem', 'cell', 'paragraph', 'term', 'note']);
239
279
  const lift = (tv) => {
240
280
  const out = [];
241
281
  (function w(t, depth) {
242
282
  if (!t) return;
243
- // Lift any actionable/editable node (skip generic layout wrappers),
244
- // regardless of depth — so a single-leaf interactive root is kept.
245
- const actionable = t.role !== 'generic' && (t.actions || []).length > 0;
246
- if (actionable) { out.push(t); return; }
283
+ // Keep any actionable/editable node or text block (skip generic layout
284
+ // wrappers), regardless of depth — so a single-leaf root is kept.
285
+ const keep = t.role !== 'generic'
286
+ && ((t.actions || []).length > 0 || CONTENT_ROLES.has(t.role));
287
+ if (keep) { out.push(t); return; }
247
288
  for (const c of t.children || []) w(c, depth + 1);
248
289
  })(tv, 0);
249
290
  return out;
@@ -254,6 +295,9 @@ export function mergeFrameTrees(mainTree, frames = []) {
254
295
  label: f.label || '子框架',
255
296
  states: ['visible'],
256
297
  actions: [],
298
+ // 绝对地址:树里出现的 iframe 应该能直接喂给 ui.new_tab / ui.navigate。
299
+ // 相对 src 做不到这一点,等于让调用方自己去拼 base。
300
+ ...(f.src ? { src: f.src } : {}),
257
301
  bounds: { x: 0, y: 0, width: 0, height: 0 },
258
302
  children: lift(f.tree),
259
303
  }));