mooncat-browser 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.
Files changed (117) hide show
  1. package/README.md +213 -0
  2. package/browser-op/backend/browserd.cjs +1004 -0
  3. package/browser-op/backend/rpc-client.cjs +64 -0
  4. package/browser-op/backend/state.cjs +51 -0
  5. package/browser-op/cdp/capture-inject.js +426 -0
  6. package/browser-op/cdp/capture-inject.ts +426 -0
  7. package/browser-op/cdp/capture-service.cjs +172 -0
  8. package/browser-op/cdp/chrome-launcher.cjs +370 -0
  9. package/browser-op/cdp/chrome-path.cjs +57 -0
  10. package/browser-op/cdp/state.cjs +89 -0
  11. package/browser-op/extension/extension-detect.cjs +228 -0
  12. package/browser-op/extension/server.cjs +197 -0
  13. package/browser-op/extension/service.cjs +228 -0
  14. package/browser-op/extension/state.cjs +78 -0
  15. package/browser-op/index.cjs +389 -0
  16. package/browser-op/package.json +17 -0
  17. package/browser-op/py/behavior.py +138 -0
  18. package/browser-op/py/browser.py +340 -0
  19. package/browser-op/py/captcha.py +115 -0
  20. package/browser-op/py/crawler.py +125 -0
  21. package/browser-op/py/examples/01_open_and_probe.py +48 -0
  22. package/browser-op/py/examples/02_reuse_and_probe.py +66 -0
  23. package/browser-op/py/examples/03_interact.py +66 -0
  24. package/browser-op/py/find.py +150 -0
  25. package/browser-op/py/honeypot.py +73 -0
  26. package/browser-op/py/humanize.py +392 -0
  27. package/browser-op/py/image.py +186 -0
  28. package/browser-op/py/interact.py +193 -0
  29. package/browser-op/py/markdown.py +38 -0
  30. package/browser-op/py/pyproject.toml +32 -0
  31. package/browser-op/py/ready.py +208 -0
  32. package/browser-op/py/scroll.py +180 -0
  33. package/browser-op/py/upload.py +103 -0
  34. package/browser-op/py/visual_target.py +47 -0
  35. package/browser-op/py/visualize.py +91 -0
  36. package/browser-op/state.cjs +63 -0
  37. package/browser-op/web/behavior.js +153 -0
  38. package/browser-op/web/browser.js +231 -0
  39. package/browser-op/web/captcha.js +85 -0
  40. package/browser-op/web/crawler.js +109 -0
  41. package/browser-op/web/find.js +147 -0
  42. package/browser-op/web/honeypot.js +68 -0
  43. package/browser-op/web/humanize.js +522 -0
  44. package/browser-op/web/image.js +177 -0
  45. package/browser-op/web/interact.js +169 -0
  46. package/browser-op/web/markdown.js +80 -0
  47. package/browser-op/web/ready.js +295 -0
  48. package/browser-op/web/scroll.js +167 -0
  49. package/browser-op/web/upload.js +116 -0
  50. package/browser-op/web/visual-runtime.inject.cjs +6 -0
  51. package/browser-op/webplater/.env.example +7 -0
  52. package/browser-op/webplater/ARCHITECTURE.md +102 -0
  53. package/browser-op/webplater/dist/chrome-mv3/assets/popup-BUZEUmsx.css +1 -0
  54. package/browser-op/webplater/dist/chrome-mv3/background.js +2 -0
  55. package/browser-op/webplater/dist/chrome-mv3/capture.js +310 -0
  56. package/browser-op/webplater/dist/chrome-mv3/chunks/_virtual_wxt-html-plugins-DPbbfBKe.js +1 -0
  57. package/browser-op/webplater/dist/chrome-mv3/chunks/offscreen-CFXYw9Mo.js +1 -0
  58. package/browser-op/webplater/dist/chrome-mv3/chunks/popup-C-lpxZZO.js +1 -0
  59. package/browser-op/webplater/dist/chrome-mv3/content-scripts/content.js +7 -0
  60. package/browser-op/webplater/dist/chrome-mv3/manifest.json +1 -0
  61. package/browser-op/webplater/dist/chrome-mv3/offscreen.html +16 -0
  62. package/browser-op/webplater/dist/chrome-mv3/popup.html +31 -0
  63. package/browser-op/webplater/entrypoints/background.ts +938 -0
  64. package/browser-op/webplater/entrypoints/content.ts +1150 -0
  65. package/browser-op/webplater/entrypoints/offscreen/index.html +15 -0
  66. package/browser-op/webplater/entrypoints/offscreen/main.ts +161 -0
  67. package/browser-op/webplater/entrypoints/popup/index.html +29 -0
  68. package/browser-op/webplater/entrypoints/popup/main.ts +61 -0
  69. package/browser-op/webplater/entrypoints/popup/style.css +100 -0
  70. package/browser-op/webplater/lib/snapshot.ts +352 -0
  71. package/browser-op/webplater/package.json +29 -0
  72. package/browser-op/webplater/pnpm-lock.yaml +3411 -0
  73. package/browser-op/webplater/public/capture.js +310 -0
  74. package/browser-op/webplater/scripts/publish-extension.mjs +176 -0
  75. package/browser-op/webplater/tsconfig.json +19 -0
  76. package/browser-op/webplater/wxt.config.ts +34 -0
  77. package/dist/actions.md +102 -0
  78. package/dist/cli.d.ts +2 -0
  79. package/dist/cli.d.ts.map +1 -0
  80. package/dist/cli.js +278 -0
  81. package/dist/cli.js.map +1 -0
  82. package/dist/client.d.ts +94 -0
  83. package/dist/client.d.ts.map +1 -0
  84. package/dist/client.js +277 -0
  85. package/dist/client.js.map +1 -0
  86. package/dist/config.d.ts +61 -0
  87. package/dist/config.d.ts.map +1 -0
  88. package/dist/config.js +119 -0
  89. package/dist/config.js.map +1 -0
  90. package/dist/protocol.d.ts +195 -0
  91. package/dist/protocol.d.ts.map +1 -0
  92. package/dist/protocol.js +11 -0
  93. package/dist/protocol.js.map +1 -0
  94. package/dist/server.d.ts +66 -0
  95. package/dist/server.d.ts.map +1 -0
  96. package/dist/server.js +259 -0
  97. package/dist/server.js.map +1 -0
  98. package/package.json +78 -0
  99. package/schemas/browser.clearCookies.schema.json +13 -0
  100. package/schemas/browser.close.schema.json +9 -0
  101. package/schemas/browser.getCookies.schema.json +13 -0
  102. package/schemas/browser.getDownload.schema.json +15 -0
  103. package/schemas/browser.health.schema.json +9 -0
  104. package/schemas/browser.listDownloads.schema.json +16 -0
  105. package/schemas/browser.listTabs.schema.json +9 -0
  106. package/schemas/browser.newTab.schema.json +15 -0
  107. package/schemas/browser.open.schema.json +15 -0
  108. package/schemas/browser.operate.schema.json +15 -0
  109. package/schemas/browser.reuseTab.schema.json +15 -0
  110. package/schemas/browser.setCookies.schema.json +15 -0
  111. package/schemas/browser.waitFor.schema.json +15 -0
  112. package/schemas/browser.waitForDownload.schema.json +15 -0
  113. package/skills/browser/SKILL.md +110 -0
  114. package/skills/browser/references/collect.md +163 -0
  115. package/skills/browser/references/high-risk.md +161 -0
  116. package/skills/browser/references/operate-actions.md +92 -0
  117. package/skills/browser/references/probing.md +302 -0
@@ -0,0 +1,92 @@
1
+ # operate 的 action 速查
2
+
3
+ `browser.operate({ pageHandle, action, params })` 支持 40+ 个 action,覆盖导航/交互/读取/等待/存储/截图。
4
+ 这是内核公开契约的速查表;完整说明见包内 `dist/actions.md`。
5
+
6
+ ### 交互
7
+
8
+ | action | params | returns | description | ext |
9
+ | --- | --- | --- | --- | --- |
10
+ | `clickAt` | `x`:number, `y`:number, `button`?:string=left, `clickCount`?:number=1 | {ok,x,y,route} | 坐标点击(绕过selector,CDP路真实鼠标/扩展路合成事件) | ✓ |
11
+ | `clickByText` | `text`:string, `exact`?:boolean=false, `index`?:number=0, `offsetX`?:number=0, `offsetY`?:number=0 | {ok,text,x,y,match,route} | 定位可见文本后立即点击(原子,防DOM重渲染) | ✓ |
12
+ | `click` | `selector`:string, `x`?:number, `y`?:number | {ok,selector} | 点击元素(或坐标x,y) | ✓ |
13
+ | `fill` | `selector`:string, `value`:string | {ok,selector,value} | 在selector元素填入文本(先清空) | ✓ |
14
+ | `type` | `selector`:string, `value`:string, `delay`?:number=0 | {ok,selector} | 逐字输入(带延迟,模拟键盘) | ✓ |
15
+ | `press` | `selector`:string, `key`:string | {ok,selector,key} | 在元素上按键 | ✓ |
16
+ | `hover` | `selector`:string, `x`?:number, `y`?:number | {ok,selector} | 悬停元素(或坐标) | ✓ |
17
+ | `focus` | `selector`:string | {ok,selector} | 聚焦元素 | ✓ |
18
+ | `check` | `selector`:string | {ok,selector} | 勾选checkbox/radio | ✓ |
19
+ | `uncheck` | `selector`:string | {ok,selector} | 取消勾选 | ✓ |
20
+ | `selectOption` | `selector`:string, `value`:string | {ok,selector,value} | 选择option | ✓ |
21
+ | `dblclick` | `selector`:string | {ok,selector} | 双击元素 | ✓ |
22
+ | `mouseMove` | `x`:number, `y`:number | {ok,x,y} | 移动鼠标到坐标 | ✓ |
23
+ | `clickAt` | `x`:number, `y`:number, `button`?:string=left, `clickCount`?:number=1 | {ok,x,y,route} | 坐标点击(CDP路page.mouse真实鼠标) | ✓ |
24
+ | `clickByText` | `text`:string, `exact`?:boolean=false, `index`?:number=0, `offsetX`?:number=0, `offsetY`?:number=0 | {ok,text,x,y,match,route} | 定位可见文本后立即点击(CDP路原子) | ✓ |
25
+ | `dragTo` | `source`:string, `target`:string | {ok,source,target} | 拖拽source到target(双兼容mouse+HTML5) | ✓ |
26
+
27
+ ### 读取
28
+
29
+ | action | params | returns | description | ext |
30
+ | --- | --- | --- | --- | --- |
31
+ | `locateVisibleText` | `text`:string, `exact`?:boolean=false, `index`?:number=0 | {ok,matches:[{text,x,y,width,height,centerX,centerY,visible}]} | 定位可见文本节点返回bbox(不返回DOM handle) | ✓ |
32
+ | `locateVisibleText` | `text`:string, `exact`?:boolean=false, `index`?:number=0 | {ok,matches:[{text,x,y,width,height,centerX,centerY,visible}]} | 定位可见文本节点返回bbox | ✓ |
33
+ | `innerHTML` | `selector`:string | {ok,selector,value} | 读元素innerHTML | ✓ |
34
+ | `innerText` | `selector`:string | {ok,selector,value} | 读元素innerText | ✓ |
35
+ | `textContent` | `selector`:string | {ok,selector,value} | 读元素textContent | ✓ |
36
+ | `getAttribute` | `selector`:string, `name`:string | {ok,selector,name,value} | 读元素属性 | ✓ |
37
+ | `inputValue` | `selector`:string, `timeout`?:number=10000 | {ok,selector,value} | 读input/select当前值 | ✓ |
38
+ | `boundingBox` | `selector`:string | {ok,selector,x,y,width,height} | 读元素包围盒(坐标+尺寸) | ✓ |
39
+ | `count` | `selector`:string | {ok,selector,count} | 统计selector匹配数 | ✓ |
40
+ | `snapshot` | `depth`?:number, `timeout`?:number=15000 | {ok,yaml,totalChars} | aria无障碍快照(返回yaml) | ✓ |
41
+
42
+ ### 进阶
43
+
44
+ | action | params | returns | description | ext |
45
+ | --- | --- | --- | --- | --- |
46
+ | `operateSequence` | `steps`:array | {ok,results} | 原子序列执行(locate/click/wait在同一页面上下文串行) | ✓ |
47
+ | `setDialogHandler` | `handler`:string | {ok} | 设置JS对话框处理 | 仅 ext |
48
+ | `operateSequence` | `steps`:array | {ok,results} | 原子序列执行 | ✓ |
49
+ | `evaluate` | `source`:string, `args`?:any | 由函数返回值决定 | 执行页面JS函数(返回其结果) | ✓ |
50
+ | `setInputFiles` | `selector`:string, `files`:array, `timeout`?:number=10000 | {ok,selector,count} | 上传文件到file input | ✓ |
51
+
52
+ ### 导航
53
+
54
+ | action | params | returns | description | ext |
55
+ | --- | --- | --- | --- | --- |
56
+ | `goto` | `url`:string | {ok,url} | 导航到URL,等待DOMContentLoaded | ✓ |
57
+ | `goBack` | `timeout`?:number=15000 | {ok,url} | 后退一页 | ✓ |
58
+ | `goForward` | `timeout`?:number=15000 | {ok,url} | 前进一页 | ✓ |
59
+ | `reload` | `timeout`?:number=15000 | {ok,url} | 刷新当前页 | ✓ |
60
+ | `status` | — | {ok,url,title,readyState,textLength} | 读当前页状态(url/title/readyState) | ✓ |
61
+
62
+ ### 等待
63
+
64
+ | action | params | returns | description | ext |
65
+ | --- | --- | --- | --- | --- |
66
+ | `waitForLoadState` | `state`?:string=load, `timeout`?:number=30000 | {ok} | 等待指定加载状态 | ✓ |
67
+ | `waitForSelector` | `selector`:string, `timeout`?:number=30000 | {ok,selector,visible,inViewport} | 等待selector元素可见 | ✓ |
68
+ | `waitForFunction` | `source`:string, `timeout`?:number=30000 | 由函数返回值决定 | 等待页面函数返回truthy | ✗ (extension路CSP拦截eval) |
69
+ | `waitForURL` | `url`:string, `timeout`?:number=30000 | {ok,url} | 等待URL匹配 | ✓ |
70
+ | `waitForTimeout` | `ms`?:number=1000 | {ok,ms} | 固定等待 | ✓ |
71
+
72
+ ### 标签页
73
+
74
+ | action | params | returns | description | ext |
75
+ | --- | --- | --- | --- | --- |
76
+ | `closeTab` | — | {ok} | 关闭当前page | ✓ |
77
+ | `activate` | — | {ok} | 切到最前(仅可视) | ✓ |
78
+
79
+ ### 截图
80
+
81
+ | action | params | returns | description | ext |
82
+ | --- | --- | --- | --- | --- |
83
+ | `screenshot` | — | {ok,format,dataUrl} | 截整页PNG(返回dataUrl) | ✓ |
84
+
85
+ ### 存储
86
+
87
+ | action | params | returns | description | ext |
88
+ | --- | --- | --- | --- | --- |
89
+ | `getLocalStorage` | `keys`?:array | {ok,storage} | 读localStorage(不传keys读全部) | ✓ |
90
+ | `setLocalStorage` | `items`:object | {ok,count} | 写localStorage | ✓ |
91
+ | `removeLocalStorage` | `keys`?:array | {ok,count} | 删localStorage(不传keys删全部) | ✓ |
92
+ | `clearLocalStorage` | — | {ok} | 清空localStorage | ✓ |
@@ -0,0 +1,302 @@
1
+ # 探查方法论
2
+
3
+ 探查 = 搞清楚页面真实 DOM 结构,找出 selector / 就绪条件 / 元素位置。
4
+ **探查优先,绝不照文字/截图猜 DOM**。本文件是通用探查方法论,不依赖任何特定运行时。
5
+
6
+ ## frame 意识(探查任何元素的前提)
7
+
8
+ **现代 web 应用大量用 iframe 嵌套**(内容区、表单、tab、表格、弹窗、第三方组件都可能
9
+ 是独立 iframe)。**枚举不完哪些东西在 iframe 里**——登录表单会、内容区会、报表会、
10
+ 甚至一个按钮都可能。所以规则不是"X 在 iframe"(特定场景枚举),而是一个**通用前提**:
11
+ 探查任何 DOM 元素之前,先搞清楚页面有几层 frame,目标在哪个 frame。
12
+
13
+ **铁律: 截图/文字里有的元素一定存在, 查不到就把全部可能枚举一遍, 不原地打转。**
14
+ SOP 截图或文字描述里出现的元素, 页面上一定有。查不到不是"换 selector 再试", 而是系统性枚举
15
+ 所有可能的位置/形态逐个排查:
16
+
17
+ 1. **顶层 document** — 默认 evaluate 查的这里。
18
+ 2. **各子 iframe** — `operate({action:'listFrames'})` 拿到所有 frame(顶层 frameId:0 +
19
+ 各子 iframe), **逐个子 frame eval 找**。现代 web 大量用 iframe 嵌套, 枚举不完哪些在 iframe,
20
+ 必须逐个查。
21
+ 3. **动态渲染** — SPA 异步加载, 元素还没出来。加 `waitForTimeout` / `waitForSelector` 重查。
22
+ 4. **portal 弹层** — ant-dropdown / modal / popover 等组件渲染在 `document.body` 末尾的
23
+ portal 里, 不在其逻辑父容器下。点触发后去 body 末尾找(如 `.ant-dropdown-menu-item`)。
24
+ 5. **shadow DOM** — 元素在 closed/open shadow root 里, 需穿透 shadow 查。
25
+
26
+ **原地打转的反模式**: 只查顶层一个地方, 查不到就反复换 selector/class/正则。永远找不到
27
+ iframe/portal/shadow 里的元素。正确做法是上面 5 类系统性枚举, 逐个排除。
28
+
29
+ **跨域 iframe 的硬约束** (安全策略):
30
+ - 跨域 iframe 不能从顶层读 contentDocument(为 null)。
31
+ - 必须在 iframe 自己的 frame context 里 eval(`evaluate` 支持 `frameId` 参数, 直接进对应
32
+ Frame 执行), 不能从顶层越权读。
33
+ - 所以 frameId 不是可选参数——目标是子 frame 元素时, frameId 是必须的。
34
+
35
+ ### listFrames + 逐 frame eval 范式
36
+
37
+ ```ts
38
+ import { BrowserClient } from "@mooncat/browser";
39
+ const browser = new BrowserClient({ baseUrl: "http://127.0.0.1:17322" });
40
+
41
+ const tab = await browser.newTab({ url: "https://example.com" });
42
+ const page = tab.pageHandle;
43
+
44
+ // 1. 先看页面有几层 frame
45
+ const frames = await browser.operate({ pageHandle: page, action: "listFrames" });
46
+ // frames: [{ frameId: 0, url: "...", parentFrameId: null }, { frameId: 1, ... }, ...]
47
+
48
+ // 2. 在某个子 iframe 里 eval 找元素 (传 frameId)
49
+ const found = await browser.operate({
50
+ pageHandle: page, action: "evaluate",
51
+ params: {
52
+ frameId: 1, // 目标在子 iframe 时必须传
53
+ source: "() => { const el = document.querySelector('#login-form'); return el ? 'found' : 'none'; }",
54
+ },
55
+ });
56
+ ```
57
+
58
+ ## 点击/验证节点必须复核位置(核心铁律)
59
+
60
+ **找任何点击/验证节点,绝不能只靠文本匹配**。页面常出现**同名不同位置**的元素
61
+ (文本完全一样,但在不同容器里),只靠 `textContent==='取数报表'` 点击,很可能点到错的那个。
62
+
63
+ 实战案例(生意参谋自助分析页): 页面上有**两个** "取数报表":
64
+ - 左侧菜单项(导航入口,父级)
65
+ - 中间内容区 tab(分类筛选,子级)
66
+
67
+ 两者文本完全相同,但功能不同。**只有同时"左侧菜单在取数报表 + 中间 tab 选取数报表"才显示
68
+ 报表列表**。只靠文本匹配的 evaluate 永远会撞坑。
69
+
70
+ **探查时必须确认节点的周边位置(context)**:
71
+ - **所在容器**: 节点的祖先链是导航菜单? 内容区 tab 栏? 表格行操作列?
72
+ 探查时返回 `el.closest('[class*=menu]')` / `el.closest('[class*=tab]')` / `el.closest('tr')` 确认在哪个区。
73
+ - **同级兄弟**: 同一父级下还有哪些元素(定位是 tab 栏第 N 个,还是菜单第 N 项)。
74
+ - **区分同名**: 多个同名时,用**容器 + 路径**消歧,不是靠文本。例:
75
+ `document.querySelector('[class*=menu] [class*=item]:nth-child(2)')`(左侧菜单第 2 项)
76
+ vs `[class*=tabs] [class*=tab]:nth-child(4)`(中间 tab 第 4 个)。
77
+
78
+ **evaluate 探查必须返回 context,不只返回"找到了"**:
79
+ ```js
80
+ // 正确: 返回周边位置, 能判定是哪个
81
+ const t = all.find(e => e.textContent.trim() === '取数报表');
82
+ return {
83
+ text: '取数报表',
84
+ tag: t.tagName,
85
+ inMenu: !!t.closest('[class*=menu],[class*=side]'), // 关键: 在哪个容器
86
+ inTabBar: !!t.closest('[class*=tab],[role=tablist]'),
87
+ parentClass: t.parentElement?.className,
88
+ siblings: [...t.parentElement.children].map(e => e.textContent.trim()), // 同级兄弟
89
+ };
90
+ // 错误: 只返回 ok:true, 无法区分是哪个同名元素
91
+ ```
92
+
93
+ 每个点击/验证节点都要写清**「在哪个容器 + 第几个」**,不能只写文字。
94
+ 文本不是全部,位置也是 DOM 的一部分。
95
+
96
+ ## 五阶段采集模型(写每个采集目标的根本方法论)
97
+
98
+ 一个采集目标 = 五个阶段,顺序不能乱,缺一不可。这五阶段来自真实探查验证,不是脑补:
99
+
100
+ ```
101
+ 开始环境 → 动作 → 采集环境 → 锁定物(waitFor) → 目标(采集)
102
+ ```
103
+
104
+ | 阶段 | 是什么 | 举例(生意参谋粉丝数) |
105
+ |---|---|---|
106
+ | **开始环境** | goto 直达的页面(动作前的初始状态) | goto 首页 portal/home |
107
+ | **动作** | 进入采集环境要做的交互(点 tab/切视图/滚动) | 点店铺资产 tab + 滚动到底 |
108
+ | **采集环境** | 动作完成后,目标数据"应该出现"的页面状态 | 店铺资产板块展开 + 页面底部 |
109
+ | **锁定物** | 在采集环境里 waitFor 的关键元素(确认环境就绪) | waitFor "粉丝" 文案出现 |
110
+ | **目标** | 真正要采集的数据(读 DOM/表格/文本) | 读"累积了 X 个粉丝" |
111
+
112
+ ### 锁定物铁律
113
+
114
+ **锁定物一定是在"采集环境"里 waitFor 的,不是在"开始环境"里。**
115
+
116
+ - 错: goto 完就 waitFor 目标文案(此时还没做动作,文案当然没有)→ waitFor 触发刷新,把页面状态冲了
117
+ - 对: 做完动作(点 tab/切视图/滚动)进入采集环境后,**才** waitFor 目标文案
118
+
119
+ ### waitFor 的 maxRefresh 区分场景
120
+
121
+ waitFor 内部有 maxRefresh(等不到就刷新页面重试)。**不是所有场景都该刷新**:
122
+
123
+ | 场景 | maxRefresh | 原因 |
124
+ |---|---|---|
125
+ | goto 直达页 | 2(可刷新) | 页面是初始态,刷新无害,应对风控/懒加载 |
126
+ | 需动作才到的环境(点表格后/点 tab 后) | **0**(只轮询不刷新) | 刷新会丢失动作状态(表格视图收起/tab 收回) |
127
+
128
+ **点过 tab/切过视图的页面,waitFor 绝不刷新(maxRefresh=0),只轮询等。** 刷新=丢失状态=白做动作。
129
+
130
+ ### 为什么固定 sleep 不够
131
+
132
+ 高危平台有风控/懒加载/SPA 异步渲染,goto 后内容**不一定出来**(可能风控拦截/慢加载/需触发)。
133
+ 固定 sleep N 秒后直接采,可能采到空。必须 waitFor 锁定物确认环境就绪。
134
+ 固定 sleep 只在 waitFor 之后做"渲染稳定"用(如 waitFor 到表头后 sleep 1s 让数据行填完)。
135
+
136
+ ## SOP 截图的识图纪律(高亮 = 位置与路径标识)
137
+
138
+ 用户给的 SOP 截图里,**高亮/红框/箭头标注不是"示意该点什么"**,而是硬性的**页面位置标识**:
139
+ 那个被高亮/选中的按钮或标签,标识了**用户当前停在哪个页面、从哪条路径进来的**。
140
+
141
+ - **高亮所在的那一排** = 路径层级。在中间 tab 栏第 4 个被选中 = 当前视图是
142
+ 「某模块 → 某分组 → 中间第 4 个 tab」。在左侧菜单某项被选中 = 当前在该菜单项的功能区。
143
+ - **高亮的选中态** = 这个位置现在是 active。要复现到这个页面,就得**让那个位置变成选中态**,
144
+ 即点到它高亮为止,不是点同名但不同位置的入口。
145
+ - **高亮是路标,不是装饰**。读图的第一件事是**找所有高亮元素,读出它们的位置**,由此倒推
146
+ 到达该页面的导航路径。而不是看截图"大概理解"后凭印象写 selector。
147
+
148
+ ### 识图只看三点(统一标准)
149
+
150
+ 用 `image_analyze` 读 SOP 截图时,统一问这三件事,不随手写 prompt:
151
+
152
+ > **识图约定(写进 prompt 开头)**: 截图里所有**红色粗框、红色字体都是用户给的操作标注**,
153
+ > 不是页面原有元素。**红色字体的数字编号(1/2/3...)是点击顺序**,按编号从小到大就是操作序列。
154
+
155
+ 1. **用户标注(最重要)**: 列出所有红框框选的文字、所有红字编号 + 说明、红色箭头指向。
156
+ 按编号顺序整理成操作序列:1 点哪里、2 点哪里……
157
+ 2. **到达路径**: 哪些元素是选中态/高亮态(蓝色高亮/下划线/背景突出)?分别在什么位置
158
+ (哪个菜单选中、哪个 tab 选中)?由此倒推到达当前页面的导航路径。
159
+ 3. **重要文本的邻居上下文(不报方位,报邻居)**: 对要点/要读的元素,报告它**周围的文字和按钮**
160
+ ——它和哪些元素同处一组/互相挨着。邻居是防同名混淆的定位锚点,比"页面右下角"有用。
161
+ **严禁报"页面上方/下方/左上角"这种像素方位**——对 DOM 探查无意义。
162
+
163
+ **为什么只问三点**: 卡片数值、logo、无关菜单都是噪音,淹没真正要的信息。prompt 越聚焦,
164
+ 模型报告越准。
165
+
166
+ ### 图证据 vs 探查结果矛盾
167
+
168
+ **图证据 > probe 结果**,但不是二选一。两者矛盾时**两者都复查**:
169
+ - 图显示某元素存在,探查顶层查不到 → 几乎总是**该元素在子 iframe**,listFrames + 进 iframe eval 就找到了。
170
+ - 探查可能错(selector 漏/查错 frame/SPA 没渲染够),图也可能错(prompt 差/模型臆测)。
171
+ - 矛盾时用更好 prompt 重读图 + 换手段重查(换 frame/换 selector/加等待),不要改结论迁就任一方。
172
+
173
+ ## 成功判据必须可判定
174
+
175
+ 把"加载完成""看到报表"这类人话,转成**机器可判定的二值判定**:
176
+ - `waitForSelector X visible`(元素出现即就绪)
177
+ - `innerText` / `snapshot` 含某文本
178
+
179
+ 不可判定的判据是失败源,必须补成可检查的。每一步都要有成功判据,没有判据 = 不知道成功没成功。
180
+
181
+ ## 下载类操作的两层判据
182
+
183
+ 点击下载/导出涉及产物文件,必须能**二值判定下载是否成功**,否则"点了下载但文件没下来"
184
+ 会静默失败。难点:浏览器下载是异步的,点按钮 ≠ 文件立即可见。判据分两层:
185
+
186
+ - **触发判据**(点没点到):点击后按钮状态变化("下载中" loading)/ 出现"导出成功" toast /
187
+ `waitForSelector` 下载进度条出现。这是"动作生效"的证据,不是"文件就绪"。
188
+ - **就绪判据**(文件真的下来了):**轮询下载目录**——文件出现且**不再增长**(size 稳定,
189
+ 即下载完成不是半截)+ 文件名含预期日期/关键词。
190
+
191
+ 常见坑:
192
+ - 文件名带时间戳/随机串 → 判定用**模式匹配**(glob / 正则),不要等固定名。
193
+ - 同名文件已存在被覆盖 → 下载前记录目录 snapshot,下载后 diff 出新文件。
194
+ - 下载的是 .xls/.xlsx/.csv → 探查确认实际格式,别假设。
195
+ - 大文件导出有"准备中"阶段 → 点完按钮先 `waitForTimeout` + 轮询,别点完立刻判。
196
+
197
+ ### 用 BrowserClient 的下载 API(仅 extension 模式)
198
+
199
+ WebPlater 扩展路基于 `chrome.downloads` 原生 API,有专门的下载查询方法:
200
+
201
+ ```ts
202
+ // 列出最近下载
203
+ const { downloads } = await browser.listDownloads(20);
204
+
205
+ // 查单个下载状态
206
+ const { download } = await browser.getDownload(id);
207
+
208
+ // 轮询等下载完成 (按 filenameRegex + sinceMs 匹配, 等 state=complete)
209
+ const { download, reason } = await browser.waitForDownload({
210
+ filenameRegex: "每日数据_.*\\.xlsx$",
211
+ sinceMs: Date.now() - 1000, // 只看点击后的新下载
212
+ timeoutMs: 60000,
213
+ });
214
+ if (reason === "complete") { /* download.filename 是落盘路径 */ }
215
+ ```
216
+
217
+ ### Chrome 下载拦截(隐蔽坑)
218
+
219
+ Chrome 有**自动下载保护**:点击下载后,文件可能被浏览器**静默拦截**(顶部出现
220
+ "xxx 已被拦截 / [保留危险文件]"黄色提示条),用户必须在浏览器 UI **手动点"保留"**
221
+ 才真正落盘。
222
+
223
+ **这个拦截藏在浏览器 UI 层,不在页面 DOM 里**——页面 evaluate / snapshot / innerText 都读不到
224
+ 这条提示。表现就是"点了下载但文件迟迟不出现"。往往要靠人眼看浏览器才发现。
225
+
226
+ 判定: 点下载后轮询下载目录——文件出现且 size 稳定 = 成功;超时未出现 = 可能被拦截,
227
+ **截图取证**(整页截图能拍下浏览器顶部的拦截条,人能从截图看到)+ 报告用户"下载超时,可能
228
+ 被浏览器拦截,请打开浏览器点[保留危险文件]"。
229
+
230
+ **绝不在 flow 里尝试关闭 Chrome 安全下载保护**——那是用户安全设置,只能通知,不能改。
231
+
232
+ ## 探查留痕:配合 cateye-probe(推荐, 非本包依赖)
233
+
234
+ 探查的价值不只是"这次探出来了", 更是**过程可回溯**——事后能重建"当时怎么一步步探出来的、
235
+ 每步页面的反应是什么"。采集审计、bug 复现、问题排查都依赖这个。
236
+
237
+ **[cateye-probe](https://www.npmjs.com/package/cateye-probe)** 是一个独立的 trace 工具(npm 包),
238
+ 能把探查过程结构化留痕:事件流(events.ndjson)+ 三态结果(pass/fail/skip)+ 产物附件(截图/数据)。
239
+ 它和 BrowserClient 是**天然搭档**:browser 做探查动作,cateye-probe 记录反应。
240
+
241
+ > cateye-probe 是**可选推荐**,不是 `@mooncat/browser` 的依赖。不需要它也能探查(手动 fs 落盘
242
+ > 即可);需要可回溯 trace 时推荐搭配。两者都是独立包,互不耦合。
243
+
244
+ ### 分工
245
+
246
+ | | 职责 | 来源 |
247
+ |---|---|---|
248
+ | browser | 决定做什么探查(导航/交互/读取),执行动作 | `@mooncat/browser` 的 `BrowserClient` |
249
+ | cateye-probe | 把过程留痕(事件流/结果/产物) | 独立 npm 包 `cateye-probe`(`import { probe } from "cateye-probe/sdk"`) |
250
+
251
+ ### 组合范式骨架
252
+
253
+ 一个探查脚本里:browser 做动作,probe 在关键节点写痕迹。每个 step 问一个问题、留一段 trace。
254
+
255
+ ```ts
256
+ import { BrowserClient } from "@mooncat/browser";
257
+ import { probe } from "cateye-probe/sdk"; // 可选搭配, 独立包
258
+ import { writeFileSync } from "node:fs";
259
+
260
+ const browser = new BrowserClient({ baseUrl: "http://127.0.0.1:17322" });
261
+
262
+ const done = probe.section("采集订单"); // 结构化段落
263
+
264
+ await browser.open({ headless: false });
265
+ const tab = await browser.newTab({ url: "https://example.com/orders" });
266
+ const page = tab.pageHandle;
267
+ await browser.operate({
268
+ pageHandle: page, action: "waitForSelector",
269
+ params: { selector: "#orders-table", timeout: 15000 },
270
+ });
271
+
272
+ const snap = await browser.operate({ pageHandle: page, action: "snapshot" });
273
+ probe.metric("rows", String(snap.yaml).split("\n").length); // 指标
274
+
275
+ const shot = await browser.operate({ pageHandle: page, action: "screenshot" });
276
+ writeFileSync("./tmp_shot.png", Buffer.from(shot.dataUrl.split(",")[1], "base64"));
277
+ probe.attach("订单页截图", "./tmp_shot.png", "orders.png"); // 产物进 probe artifacts
278
+
279
+ done();
280
+ probe.pass("订单采集完成"); // 三态结果
281
+ ```
282
+
283
+ ### 探查长会话分阶段(trace 驱动的探查节奏)
284
+
285
+ cateye-probe 的 session 天生分阶段——一个 session 跨多个 step,每个 step 问一个问题。
286
+ **严禁把"开页面+导航+探查+截图"全塞进单个 step,然后每个 step 都重开页面**。那是把分阶段的
287
+ step 退化成一次性脚本,白费了长会话。
288
+
289
+ **正确模型**:前面的 step 把页面开好 + 导航到位(状态留在浏览器),后面的 step 直接
290
+ **复用同一个 tab**(`browser.listTabs()` 按 url 匹配找到已开的 tab,拿它的 pageHandle)
291
+ 继续探下一个问题,不重开。浏览器状态跨 step 持久——这才是长会话的意义。
292
+
293
+ > 高危平台(淘宝/京东/生意参谋)这是**安全红线**:反复 newTab+导航 = 高频异常访问 =
294
+ > 加速风控触发。同一个页面一旦打开,在 session 内反复复用。详见 [high-risk.md](high-risk.md)。
295
+
296
+ ### 何时用这个组合
297
+
298
+ - **要留 trace**:探查过程要给人/AI 事后回溯(采集审计、bug 复现、问题排查)→ 用组合
299
+ - **一次操作即可**:随手打开页面看一眼、单次截图 → 直接 browser,不必套 cateye-probe
300
+
301
+ browser 的调用纪律(步进 co-work / 长会话 / 路由不泄漏)见 [SKILL.md](../SKILL.md),
302
+ cateye-probe 的 API 与 session 管理见其包文档。本节只讲两者怎么在脚本里组合。