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,1004 @@
1
+ // browserd.cjs — browser 后端常驻进程 (detached)
2
+ //
3
+ // 这是唯一真正持有 BrowserOp / Chrome / WebPluginServer 的进程。
4
+ // 业务脚本通过 HTTP IPC (:17322) 和它通信。
5
+ //
6
+ // 启动后:
7
+ // 1. 初始化 IPC server
8
+ // 2. 等待 client 发 open 指令
9
+ // 3. 持有 browserHandle + pageHandle registry
10
+ // 4. 收到 close 才关 Chrome + 退出
11
+ //
12
+ // 不自动退出 (除非 close 或 Chrome 被 kill)。
13
+
14
+ 'use strict'
15
+
16
+ const http = require('node:http')
17
+ const os = require('node:os')
18
+ const path = require('node:path')
19
+
20
+ // 找到 skill 根目录 (browserd 在 lib/backend/, 往上两层)
21
+ const SKILL_ROOT = path.resolve(__dirname, '..', '..')
22
+
23
+ // 复用现有双路由核心
24
+ const { BrowserOp, ChromePathResolver } = require('../index.cjs')
25
+
26
+ // visual-runtime 注入串 (single source: webplater/lib/visual-runtime.ts → build-visual-runtime.mjs)
27
+ // MAIN world 注入后 window.__beeVR / window.visualMark 可用。CDP 路解析/高亮/eval 契约共用。
28
+ const VISUAL_RUNTIME_SRC = require('../web/visual-runtime.inject.cjs')
29
+
30
+ // IPC 端口
31
+ const IPC_PORT = Number(process.env.BROWSERD_PORT) || 17322
32
+ const IPC_HOST = '127.0.0.1'
33
+
34
+ // ─── 状态 ───
35
+ let _op = null // BrowserOp 实例
36
+ let _bh = null // browserHandle
37
+ let _browserOpen = false
38
+ let _mode = null // 'extension' | 'cdp'
39
+ let _playwright = null // CDP 模式下 require('playwright-core')
40
+ let _cdpBrowser = null // CDP 模式下的 playwright browser
41
+ let _cdpContext = null // CDP 模式下的 playwright context
42
+ let _wsEndpoint = null
43
+
44
+ // pageHandle registry: pageId → { mode, page?(cdp), tabId?(extension), url }
45
+ const _pages = new Map()
46
+ let _pageSeq = 0
47
+
48
+ // ─── 默认 BrowserOp 工厂 (从 browser.js 搬来) ───
49
+ function getDefaultProfileRoot () {
50
+ const home = process.env.APPDATA ||
51
+ (process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support') : path.join(os.homedir(), '.local', 'share'))
52
+ return path.join(home, 'browser-op', 'profiles')
53
+ }
54
+
55
+ function getDefaultOp (options = {}) {
56
+ if (_op) return _op
57
+ const executablePath = options.executablePath ||
58
+ process.env.BROWSER_OP_CHROME_PATH ||
59
+ new ChromePathResolver().detect()
60
+ if (!executablePath) {
61
+ throw new Error('Chrome 未找到。设 BROWSER_OP_CHROME_PATH 或在 open({executablePath}) 传入。')
62
+ }
63
+ const profileRoot = options.profileRoot || process.env.BROWSER_OP_PROFILE_ROOT || getDefaultProfileRoot()
64
+ const userDataDir = options.userDataDir || process.env.BROWSER_OP_USERDATA || null
65
+ _op = new BrowserOp({ executablePath, profileRoot, userDataDir })
66
+ return _op
67
+ }
68
+
69
+ // ─── getPlaywright (CDP 模式) ───
70
+ async function getPlaywright () {
71
+ if (_playwright) return _playwright
72
+ // 用 playwright-core 而非 playwright:browserd 通过 connectOverCDP 连接宿主 Chrome,
73
+ // 从不启动 playwright 自带浏览器,因此用 playwright-core 避免 install 时下载浏览器。
74
+ // 两者 API(connectOverCDP / ariaSnapshot mode:'ai')完全一致。
75
+ try {
76
+ _playwright = require('playwright-core')
77
+ } catch (e) {
78
+ throw new Error('CDP 路需要 playwright-core。npm i playwright-core (不下载 chromium)。')
79
+ }
80
+ return _playwright
81
+ }
82
+
83
+ // ─── RPC 方法实现 ───
84
+
85
+ async function rpcOpen (params) {
86
+ // 已打开 → 返回当前 handle
87
+ if (_browserOpen && _bh) {
88
+ return _bh
89
+ }
90
+
91
+ _op = _op || getDefaultOp(params)
92
+
93
+ // 状态机在 BrowserOp.open() (lib/index.cjs):
94
+ // 先抢占唯一 17321 → 等已有 offscreen 回连 → 复用; 没回连才 launch Chrome
95
+ // browserd 这里只透传。
96
+ const launchRes = await _op.open(undefined, {
97
+ headless: params.headless || false,
98
+ routeMode: params.routeMode || 'auto'
99
+ })
100
+ if (!launchRes.ok) {
101
+ throw new Error('open failed: ' + (launchRes.error || 'browser.open failed'))
102
+ }
103
+
104
+ _mode = launchRes.mode
105
+ _wsEndpoint = launchRes.wsEndpoint || null
106
+
107
+ if (_mode === 'extension') {
108
+ _bh = { mode: 'extension', notice: launchRes.notice || null, ownerId: 'default' }
109
+ } else {
110
+ // CDP: connectOverCDP
111
+ let wsEndpoint = launchRes.wsEndpoint
112
+ if (!wsEndpoint && launchRes.port) {
113
+ const resp = await fetch('http://127.0.0.1:' + launchRes.port + '/json/version')
114
+ const data = await resp.json()
115
+ wsEndpoint = data.webSocketDebuggerUrl
116
+ }
117
+ if (!wsEndpoint) throw new Error('open failed: no wsEndpoint (port=' + launchRes.port + ')')
118
+ const pw = await getPlaywright()
119
+ _cdpBrowser = await pw.chromium.connectOverCDP(wsEndpoint)
120
+ _cdpContext = _cdpBrowser.contexts()[0] || (await _cdpBrowser.newContext())
121
+ _bh = { mode: 'cdp', notice: launchRes.notice || null, ownerId: 'default', wsEndpoint }
122
+ }
123
+
124
+ _browserOpen = true
125
+
126
+ // 写 state 文件
127
+ const state = require('./state.cjs')
128
+ const ud = _op.launcher.getUserDataDir(_op.profileRoot || '', 'default')
129
+ state.write(ud, { ipcPort: IPC_PORT, mode: _mode, sessionId: 's_' + Date.now() })
130
+
131
+ return _bh
132
+ }
133
+
134
+ async function rpcNewTab (params) {
135
+ if (!_browserOpen) throw new Error('browser not open; call open() first')
136
+
137
+ const url = params.url || ''
138
+ const force = params.force === true
139
+
140
+ // 默认: 同 host 复用已有 tab (导航过去 + 激活), 不开新 tab
141
+ // force:true 才强制开新 tab
142
+ if (!force && url) {
143
+ let urlHost = ''
144
+ try { urlHost = new URL(url).host } catch {}
145
+ if (_mode === 'extension') {
146
+ try {
147
+ const r = await _op.listTabs()
148
+ const tabs = (r && r.tabs) || []
149
+ // 同 host 即视为同一 tab (sycm 等 SPA 会跳转 login, 精确 url 匹配会失败)
150
+ const hit = tabs.find((t) => {
151
+ if (!t.url) return false
152
+ try { return new URL(t.url).host === urlHost } catch { return false }
153
+ })
154
+ if (hit) {
155
+ // 复用: 导航 + 激活
156
+ await _op.goto(url, hit.id).catch(() => {})
157
+ await _op.switchTab(hit.id).catch(() => {})
158
+ // 复用/创建 pageHandle
159
+ let pageId = null
160
+ for (const [pid, info] of _pages) {
161
+ if (info.mode === 'extension' && info.tabId === hit.id) { pageId = pid; break }
162
+ }
163
+ if (!pageId) {
164
+ pageId = 'p' + (++_pageSeq)
165
+ _pages.set(pageId, { mode: 'extension', tabId: hit.id, url })
166
+ }
167
+ return { pageHandle: { mode: 'extension', pageId, tabId: hit.id, url, ownerId: 'default' }, url, title: '', active: false, reused: true }
168
+ }
169
+ } catch { /* listTabs 失败兑底开新 */ }
170
+ } else {
171
+ // CDP: 在现有 pages 找同 host
172
+ for (const [pid, info] of _pages) {
173
+ if (info.mode === 'cdp' && info.page && info.url) {
174
+ try { if (new URL(String(info.url)).host === urlHost) {
175
+ await info.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {})
176
+ return { pageHandle: { mode: 'cdp', pageId: pid, url, ownerId: 'default' }, url, title: '', active: false, reused: true }
177
+ } } catch {}
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ // 开新 tab
184
+ const pageId = 'p' + (++_pageSeq)
185
+
186
+ if (_mode === 'extension') {
187
+ const r = await _op.newTab(url)
188
+ const pageHandle = { mode: 'extension', pageId, tabId: r.tabId, url: r.url || url, ownerId: 'default' }
189
+ _pages.set(pageId, { mode: 'extension', tabId: r.tabId, url: r.url || url })
190
+ return { pageHandle, url: r.url || url, title: '', active: true, reused: false }
191
+ }
192
+
193
+ // CDP
194
+ const page = await _cdpContext.newPage()
195
+ if (url) await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {})
196
+ const finalUrl = page.url() || url
197
+ const pageHandle = { mode: 'cdp', pageId, url: finalUrl, ownerId: 'default' }
198
+ _pages.set(pageId, { mode: 'cdp', page, url: finalUrl })
199
+ return { pageHandle, url: finalUrl, title: '', active: true, reused: false }
200
+ }
201
+
202
+ async function rpcListTabs (params) {
203
+ if (!_browserOpen) throw new Error('browser not open')
204
+
205
+ if (_mode === 'extension') {
206
+ const r = await _op.listTabs()
207
+ const tabs = (r && r.tabs) || []
208
+ return tabs.map((t) => {
209
+ // 复用已有 pageId 或新建
210
+ let pageId = null
211
+ for (const [pid, info] of _pages) {
212
+ if (info.mode === 'extension' && info.tabId === t.id) { pageId = pid; break }
213
+ }
214
+ if (!pageId) {
215
+ pageId = 'p' + (++_pageSeq)
216
+ _pages.set(pageId, { mode: 'extension', tabId: t.id, url: t.url })
217
+ }
218
+ return {
219
+ pageHandle: { mode: 'extension', pageId, tabId: t.id, url: t.url, ownerId: 'default' },
220
+ url: t.url,
221
+ title: t.title || '',
222
+ active: !!t.active
223
+ }
224
+ })
225
+ }
226
+
227
+ // CDP
228
+ const pages = _cdpContext.pages()
229
+ return pages.map((pg, i) => {
230
+ let pageId = null
231
+ for (const [pid, info] of _pages) {
232
+ if (info.mode === 'cdp' && info.page === pg) { pageId = pid; break }
233
+ }
234
+ if (!pageId) {
235
+ pageId = 'p' + (++_pageSeq)
236
+ _pages.set(pageId, { mode: 'cdp', page: pg, url: pg.url() })
237
+ }
238
+ return {
239
+ pageHandle: { mode: 'cdp', pageId, url: pg.url(), ownerId: 'default' },
240
+ url: pg.url(),
241
+ title: '',
242
+ active: i === 0
243
+ }
244
+ })
245
+ }
246
+
247
+ /** rpcReuseTab: 按 url 复用 tab (extension 路调插件 reuseTab; cdp 路 fallback 到 listTabs+goto/newTab)。 */
248
+ async function rpcReuseTab (params) {
249
+ if (!_browserOpen) throw new Error('browser not open')
250
+ const url = params.url || ''
251
+ const urlMatchSrc = params.urlMatch
252
+ const urlMatch = urlMatchSrc ? new RegExp(urlMatchSrc) : null
253
+
254
+ if (_mode === 'extension') {
255
+ const r = await _op.reuseTab(url, urlMatch)
256
+ // 复用 listTabs 的 pageId 维护逻辑: 为返回的 tabId 找/建 pageId
257
+ const tabId = r && r.tabId
258
+ let pageId = null
259
+ for (const [pid, info] of _pages) {
260
+ if (info.mode === 'extension' && info.tabId === tabId) { pageId = pid; break }
261
+ }
262
+ if (!pageId) {
263
+ pageId = 'p' + (++_pageSeq)
264
+ _pages.set(pageId, { mode: 'extension', tabId, url: r.url })
265
+ }
266
+ return {
267
+ ok: true,
268
+ reused: !!r.reused,
269
+ tabId,
270
+ url: r.url,
271
+ pageHandle: { mode: 'extension', pageId, tabId, url: r.url, ownerId: 'default' }
272
+ }
273
+ }
274
+
275
+ // CDP: 找现有 page (url 匹配), 有则复用, 无则 newPage
276
+ const pages = _cdpContext.pages()
277
+ const hit = urlMatch
278
+ ? pages.find((pg) => urlMatch.test(pg.url() || ''))
279
+ : pages.find((pg) => (pg.url() || '') === url)
280
+ if (hit) {
281
+ let pageId = null
282
+ for (const [pid, info] of _pages) { if (info.mode === 'cdp' && info.page === hit) { pageId = pid; break } }
283
+ if (!pageId) {
284
+ pageId = 'p' + (++_pageSeq)
285
+ _pages.set(pageId, { mode: 'cdp', page: hit, url: hit.url() })
286
+ }
287
+ await hit.bringToFront().catch(() => {})
288
+ return { ok: true, reused: true, pageHandle: { mode: 'cdp', pageId, url: hit.url(), ownerId: 'default' }, url: hit.url() }
289
+ }
290
+ const pg = await _cdpContext.newPage()
291
+ if (url) await pg.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {})
292
+ const pageId = 'p' + (++_pageSeq)
293
+ _pages.set(pageId, { mode: 'cdp', page: pg, url: pg.url() })
294
+ return { ok: true, reused: false, pageHandle: { mode: 'cdp', pageId, url: pg.url(), ownerId: 'default' }, url: pg.url() }
295
+ }
296
+
297
+ /** rpcListDownloads / rpcGetDownload / rpcWaitForDownload: 仅 extension 路有 (chrome.downloads API)。 */
298
+ async function rpcListDownloads (params) {
299
+ if (!_browserOpen) throw new Error('browser not open')
300
+ if (_mode !== 'extension') throw new Error('listDownloads only supported in extension mode (chrome.downloads API)')
301
+ return _op.listDownloads(params && params.limit)
302
+ }
303
+ async function rpcGetDownload (params) {
304
+ if (!_browserOpen) throw new Error('browser not open')
305
+ if (_mode !== 'extension') throw new Error('getDownload only supported in extension mode')
306
+ return _op.getDownload(params && params.id)
307
+ }
308
+ async function rpcWaitForDownload (params) {
309
+ if (!_browserOpen) throw new Error('browser not open')
310
+ if (_mode !== 'extension') throw new Error('waitForDownload only supported in extension mode')
311
+ return _op.waitForDownload(params || {})
312
+ }
313
+
314
+ function resolvePage (pageHandle) {
315
+ if (!pageHandle || !pageHandle.pageId) {
316
+ // 明确提示: 传了 TabInfo (有 url/pageHandle 字段) 而非 PageHandle
317
+ if (pageHandle && pageHandle.pageHandle && pageHandle.pageHandle.pageId) {
318
+ throw new Error('operate requires PageHandle; you passed a TabInfo — use tab.pageHandle (e.g. operate({ pageHandle: tab.pageHandle, ... }))')
319
+ }
320
+ throw new Error('invalid pageHandle: missing pageId (pass tab.pageHandle from newTab()/listTabs())')
321
+ }
322
+ const entry = _pages.get(pageHandle.pageId)
323
+ if (!entry) throw new Error(`unknown pageId: ${pageHandle.pageId}; call listTabs() or newTab() again`)
324
+ return entry
325
+ }
326
+
327
+ // ─── setInputFiles 归一化 (双路由统一传文件路径) ───
328
+ const _fs = require('node:fs')
329
+ const _path = require('node:path')
330
+
331
+ // 判断是否为「路径」形式: 字符串、或对象含 buffer/name 且无 data
332
+ function _isPath (f) { return typeof f === 'string' }
333
+
334
+ // CDP 路: 返回纯字符串路径数组 (Playwright 原生接受)
335
+ function normalizeFilePaths (files) {
336
+ if (!Array.isArray(files) || files.length === 0) throw new Error('setInputFiles: files must be a non-empty array of paths')
337
+ return files.map(f => {
338
+ if (_isPath(f)) return f
339
+ throw new Error('setInputFiles CDP: each file must be a path string, got ' + typeof f)
340
+ })
341
+ }
342
+
343
+ // Ext 路: 路径 → { name, mime, data(base64) },content script 只接受此格式
344
+ function normalizeInputFiles (files) {
345
+ if (!Array.isArray(files) || files.length === 0) throw new Error('setInputFiles: files must be a non-empty array of paths')
346
+ return files.map(f => {
347
+ if (!_isPath(f)) throw new Error('setInputFiles extension: each file must be a path string, got ' + typeof f)
348
+ const buf = _fs.readFileSync(f)
349
+ const ext = _path.extname(f).slice(1).toLowerCase()
350
+ return {
351
+ name: _path.basename(f),
352
+ mime: _MIME[ext] || 'application/octet-stream',
353
+ data: buf.toString('base64')
354
+ }
355
+ })
356
+ }
357
+ const _MIME = {
358
+ txt: 'text/plain', log: 'text/plain', csv: 'text/csv',
359
+ js: 'text/javascript', json: 'application/json', xml: 'text/xml', html: 'text/html',
360
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
361
+ pdf: 'application/pdf', zip: 'application/zip',
362
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
363
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
364
+ }
365
+
366
+
367
+ // ─── visual-runtime 注入 + CDP 元素级执行 (resolve → highlight → execute 同一 handle) ───
368
+ // 铁律: 高亮对象 = 执行对象。这里用 playwright evaluateHandle 拿真实元素 ElementHandle,
369
+ // 同一 handle 先 visualMark(el) 再 click/fill/... 不二次解析、不漂移。
370
+
371
+ /** 幂等注入 visual-runtime 到页面 MAIN world。 */
372
+ async function ensureVisualRuntime (page) {
373
+ const ok = await page.evaluate(() => typeof window.__beeVR === 'object' && window.__beeVR !== null).catch(() => false)
374
+ if (ok) return
375
+ // VISUAL_RUNTIME_SRC 是字符串 (IIFE 源码), 必须用 evaluate(fn, [src]) 把它作为参数传入页面后 eval,
376
+ // 不能直接 page.evaluate(字符串) — playwright 会把字符串当函数对象序列化, 闭包丢失, 实际不执行。
377
+ await page.evaluate((src) => { /* eslint-disable no-eval */ (0, eval)(src) }, VISUAL_RUNTIME_SRC)
378
+ }
379
+
380
+ /**
381
+ * 解析 selector 为真实 ElementHandle (CDP 路同源解析)。
382
+ * - aria-ref=eN → Playwright 原生 locator (snapshot 也是 Playwright, 同源于 Playwright)
383
+ * - CSS/>>nth/>>last → 注入的 visual-runtime resolver (单源)
384
+ * 两种都拿到真实 ElementHandle, 保证 resolve→高亮→执行同一对象。失败返回 null。
385
+ */
386
+ async function resolveCdpHandle (page, selector) {
387
+ if (!selector) return null
388
+ const sel = String(selector)
389
+ if (sel.startsWith('aria-ref=')) {
390
+ // Playwright 原生 selector: aria-ref=eN 直接给 page.locator
391
+ return page.locator(sel).elementHandle({ timeout: 10000 }).catch(() => null)
392
+ }
393
+ await ensureVisualRuntime(page)
394
+ const handle = await page.evaluateHandle((s) => (window.__beeVR ? window.__beeVR.resolveSelector(s, undefined) : null), sel)
395
+ const exists = await handle.evaluate((el) => !!el).catch(() => false)
396
+ if (!exists) { await handle.dispose().catch(() => {}); return null }
397
+ return handle
398
+ }
399
+
400
+ /**
401
+ * CDP 交互动作: 解析 selector 出真实 ElementHandle → 高亮同一 handle → 用同一 handle 执行。
402
+ * 坐标动作 (click/hover 带 x,y) 不解析元素,直接 mouse + 画点。
403
+ * @returns 操作结果
404
+ */
405
+ async function cdpPerformInteract (page, action, p) {
406
+ // 坐标动作: 直接 mouse, 不解析元素 (与 content.ts click 坐标分支对齐)
407
+ const hasXY = p.x != null && p.y != null
408
+ if (hasXY && (action === 'click' || action === 'hover')) {
409
+ if (action === 'click') await page.mouse.click(Number(p.x), Number(p.y))
410
+ else await page.mouse.move(Number(p.x), Number(p.y))
411
+ await page.evaluate((c) => window.__beeVR.visualMark({ x: c.x, y: c.y }), { x: Number(p.x), y: Number(p.y) }).catch(() => {})
412
+ return { ok: true, selector: p.selector, x: Number(p.x), y: Number(p.y) }
413
+ }
414
+
415
+ // 元素动作: resolve → highlight → execute 同一 handle
416
+ const handle = await resolveCdpHandle(page, p.selector || '')
417
+ if (!handle) throw new Error('element not found: ' + p.selector)
418
+ try {
419
+ await handle.evaluate((el) => window.__beeVR.visualMark(el)).catch(() => {})
420
+ switch (action) {
421
+ case 'click': await handle.click({ timeout: 10000 }); return { ok: true, selector: p.selector }
422
+ case 'fill': await handle.fill(String(p.value ?? ''), { timeout: 10000 }); return { ok: true, selector: p.selector, value: p.value }
423
+ case 'type': await handle.type(String(p.value ?? ''), { delay: p.delay || 0, timeout: 10000 }); return { ok: true, selector: p.selector }
424
+ case 'press': await handle.press(p.key, { timeout: 10000 }); return { ok: true, selector: p.selector, key: p.key }
425
+ case 'hover': await handle.hover({ timeout: 10000 }); return { ok: true, selector: p.selector }
426
+ case 'focus': await handle.focus(); return { ok: true, selector: p.selector }
427
+ case 'check': await handle.check({ timeout: 10000 }); return { ok: true, selector: p.selector }
428
+ case 'uncheck': await handle.uncheck({ timeout: 10000 }); return { ok: true, selector: p.selector }
429
+ case 'selectOption': await handle.selectOption(p.value, { timeout: 10000 }); return { ok: true, selector: p.selector, value: p.value }
430
+ case 'dblclick': await handle.dblclick({ timeout: 10000 }); return { ok: true, selector: p.selector }
431
+ default: throw new Error('cdpPerformInteract: unsupported action ' + action)
432
+ }
433
+ } finally {
434
+ await handle.dispose().catch(() => {})
435
+ }
436
+ }
437
+
438
+ /**
439
+ * CDP dragTo: 双 handle 解析 source/target → 高亮两个 → 同一 handle 执行拖拽。
440
+ * mouse 序列 (jQuery UI) + HTML5 drag events (原生) 双兼容。
441
+ */
442
+ async function cdpDragTo (page, p) {
443
+ const srcHandle = await resolveCdpHandle(page, p.source || '')
444
+ const dstHandle = await resolveCdpHandle(page, p.target || '')
445
+ if (!srcHandle) throw new Error('dragTo source not found: ' + p.source)
446
+ if (!dstHandle) throw new Error('dragTo target not found: ' + p.target)
447
+ try {
448
+ await page.evaluate(([s, d]) => window.__beeVR.visualMark([s, d]), [srcHandle, dstHandle]).catch(() => {})
449
+ // 取两元素中心 (同一 handle)
450
+ const boxes = await page.evaluate(([s, d]) => {
451
+ const sr = s.getBoundingClientRect()
452
+ const dr = d.getBoundingClientRect()
453
+ return { sx: sr.x + sr.width / 2, sy: sr.y + sr.height / 2, dx: dr.x + dr.width / 2, dy: dr.y + dr.height / 2 }
454
+ }, [srcHandle, dstHandle])
455
+ // 1) mouse 序列
456
+ await page.mouse.move(boxes.sx, boxes.sy)
457
+ await page.mouse.down()
458
+ const steps = 10
459
+ for (let i = 1; i <= steps; i++) {
460
+ await page.mouse.move(boxes.sx + (boxes.dx - boxes.sx) * i / steps, boxes.sy + (boxes.dy - boxes.sy) * i / steps)
461
+ }
462
+ await page.mouse.up()
463
+ // 2) HTML5 drag events (同一 handle)
464
+ await page.evaluate(([s, d]) => {
465
+ const dt = new DataTransfer()
466
+ const mk = (type, el) => new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt, clientX: 0, clientY: 0 })
467
+ s.dispatchEvent(mk('dragstart', s))
468
+ d.dispatchEvent(mk('dragenter', d))
469
+ d.dispatchEvent(mk('dragover', d))
470
+ d.dispatchEvent(mk('drop', d))
471
+ s.dispatchEvent(mk('dragend', s))
472
+ }, [srcHandle, dstHandle])
473
+ return { ok: true, source: p.source, target: p.target }
474
+ } finally {
475
+ await srcHandle.dispose().catch(() => {})
476
+ await dstHandle.dispose().catch(() => {})
477
+ }
478
+ }
479
+
480
+ /**
481
+ * CDP locateVisibleText: 扫描可见文本节点返回 bbox (不返回 DOM handle)。
482
+ * 用 Range.getBoundingClientRect() 取文字精确 bbox。过滤 display/visibility/opacity/零面积/viewport外。
483
+ * 与 content.ts locateVisibleTextInContent 同源逻辑。
484
+ */
485
+ async function cdpLocateVisibleText (page, p) {
486
+ const text = String(p.text ?? '')
487
+ const exact = p.exact === true
488
+ const matches = await page.evaluate((args) => {
489
+ const { text, exact } = args
490
+ const want = String(text)
491
+ const out = []
492
+ const isVisible = (el) => {
493
+ let cur = el
494
+ while (cur && cur !== document.body) {
495
+ const cs = getComputedStyle(cur)
496
+ if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false
497
+ cur = cur.parentElement
498
+ }
499
+ return true
500
+ }
501
+ const vh = window.innerHeight || document.documentElement.clientHeight
502
+ const vw = window.innerWidth || document.documentElement.clientWidth
503
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
504
+ acceptNode: (node) => {
505
+ const t = (node.nodeValue || '').trim()
506
+ if (!t) return NodeFilter.FILTER_REJECT
507
+ const hit = exact ? t === want : t.includes(want)
508
+ if (!hit) return NodeFilter.FILTER_REJECT
509
+ const parent = node.parentElement
510
+ if (!parent || !isVisible(parent)) return NodeFilter.FILTER_REJECT
511
+ return NodeFilter.FILTER_ACCEPT
512
+ },
513
+ })
514
+ while (walker.nextNode()) {
515
+ const node = walker.currentNode
516
+ let rect = null
517
+ try {
518
+ const range = document.createRange()
519
+ range.selectNodeContents(node)
520
+ rect = range.getBoundingClientRect()
521
+ } catch (e) { rect = node.parentElement.getBoundingClientRect() }
522
+ if (!rect || rect.width <= 0 || rect.height <= 0) continue
523
+ const cx = rect.x + rect.width / 2
524
+ const cy = rect.y + rect.height / 2
525
+ if (cy < 0 || cy > vh || cx < 0 || cx > vw) continue
526
+ out.push({
527
+ text: (node.nodeValue || '').trim().slice(0, 80),
528
+ x: Math.round(rect.x), y: Math.round(rect.y),
529
+ width: Math.round(rect.width), height: Math.round(rect.height),
530
+ centerX: Math.round(cx), centerY: Math.round(cy),
531
+ visible: true,
532
+ })
533
+ }
534
+ return out
535
+ }, { text, exact })
536
+ return { ok: true, matches: matches || [] }
537
+ }
538
+
539
+ /**
540
+ * CDP operateSequence: 原子序列执行 (locate/click/wait/downloadWait 在同一 page 串行)。
541
+ * 中间状态 (saveAs 的 bbox) 存在内存, 不暴露给外层 SDK, 防 DOM 重渲染时序漂移。
542
+ */
543
+ async function cdpOperateSequence (page, steps) {
544
+ const saved = {}
545
+ const results = []
546
+ const stepsArr = Array.isArray(steps) ? steps : []
547
+ for (let i = 0; i < stepsArr.length; i++) {
548
+ const step = stepsArr[i] || {}
549
+ const action = String(step.action || '')
550
+ try {
551
+ if (action === 'locateVisibleText') {
552
+ const r = await cdpLocateVisibleText(page, step)
553
+ results.push({ step: i, action, ok: r.ok, count: r.matches?.length })
554
+ if (step.saveAs && r.matches?.length) saved[String(step.saveAs)] = r.matches[0]
555
+ if (!r.matches?.length) return { ok: false, results, error: `step ${i} locateVisibleText: text not visible: ${step.text}` }
556
+ } else if (action === 'clickAt') {
557
+ let x = step.x != null ? Number(step.x) : undefined
558
+ let y = step.y != null ? Number(step.y) : undefined
559
+ if (step.from && saved[String(step.from)]) {
560
+ const m = saved[String(step.from)]
561
+ x = Math.round(m.centerX + Number(step.dx ?? 0))
562
+ y = Math.round(m.centerY + Number(step.dy ?? 0))
563
+ }
564
+ if (x == null || y == null) throw new Error(`step ${i} clickAt: need x,y or from`)
565
+ await page.mouse.click(x, y, { button: step.button || 'left', clickCount: step.clickCount || 1 })
566
+ results.push({ step: i, action, ok: true, x, y })
567
+ } else if (action === 'clickByText') {
568
+ const r = await cdpLocateVisibleText(page, step)
569
+ if (!r.matches?.length) return { ok: false, results, error: `step ${i} clickByText: text not visible: ${step.text}` }
570
+ const m = r.matches[0]
571
+ const x = Math.round(m.centerX + Number(step.offsetX ?? 0))
572
+ const y = Math.round(m.centerY + Number(step.offsetY ?? 0))
573
+ await page.mouse.click(x, y, { button: step.button || 'left', clickCount: step.clickCount || 1 })
574
+ results.push({ step: i, action, ok: true, x, y })
575
+ if (step.saveAs) saved[String(step.saveAs)] = m
576
+ } else if (action === 'waitForTimeout') {
577
+ await new Promise((r) => setTimeout(r, Number(step.ms ?? 0)))
578
+ results.push({ step: i, action, ok: true })
579
+ } else if (action === 'evaluate') {
580
+ const result = await page.evaluate(step.source)
581
+ results.push({ step: i, action, ok: true, result })
582
+ } else if (action === 'waitForSelector') {
583
+ const timeout = Number(step.timeout ?? 30000)
584
+ await page.waitForSelector(String(step.selector || ''), { timeout }).catch(() => {})
585
+ results.push({ step: i, action, ok: true })
586
+ } else {
587
+ throw new Error(`step ${i} unknown action: ${action}`)
588
+ }
589
+ } catch (e) {
590
+ return { ok: false, results, error: `step ${i} (${action}) failed: ${e?.message || e}` }
591
+ }
592
+ }
593
+ return { ok: true, results }
594
+ }
595
+
596
+ async function rpcOperate (params) {
597
+ if (!_browserOpen) throw new Error('browser not open')
598
+ const entry = resolvePage(params.pageHandle)
599
+ const action = params.action
600
+ const p = params.params || {}
601
+
602
+ if (entry.mode === 'extension') {
603
+ const tabId = entry.tabId
604
+ switch (action) {
605
+ case 'goto': return _op.goto(p.url || '', tabId)
606
+ case 'goBack': return _op.goBack(tabId)
607
+ case 'goForward': return _op.goForward(tabId)
608
+ case 'reload': return _op.reload(tabId)
609
+ case 'status': return _op.status(tabId)
610
+ case 'waitForLoadState': return _op.waitForLoadState(p.state || 'complete', tabId)
611
+ case 'closeTab': { const r = await _op.closeTab(tabId); _pages.delete(params.pageHandle.pageId); return r }
612
+ case 'activate': return _op.switchTab(tabId)
613
+ case 'click': return _op.click(p.selector, p.x, p.y, tabId)
614
+ case 'fill': return _op.fill(p.selector, p.value, tabId)
615
+ case 'type': return _op.type(p.selector, p.value, p.delay, tabId)
616
+ case 'press': return _op.press(p.selector, p.key, tabId)
617
+ case 'hover': return _op.hover(p.selector, p.x, p.y, tabId)
618
+ case 'focus': return _op.focus(p.selector, tabId)
619
+ case 'mouseMove': return _op.mouseMove(p.x, p.y, tabId)
620
+ case 'check': return _op.check(p.selector, tabId)
621
+ case 'uncheck': return _op.uncheck(p.selector, tabId)
622
+ case 'selectOption': return _op.selectOption(p.selector, p.value, tabId)
623
+ case 'dblclick': return _op.dblclick(p.selector, tabId)
624
+ case 'dragTo': return _op.dragTo(p.source, p.target, tabId)
625
+ /** @action clickAt | group:交互 | desc:坐标点击(绕过selector,CDP路真实鼠标/扩展路合成事件) | param:x:number:req — X坐标 | param:y:number:req — Y坐标 | param:button:string:opt=left — left/right/middle | param:clickCount:number:opt=1 — 点击次数 | returns:{ok,x,y,route} | ext:yes */
626
+ case 'clickAt': return _op.clickAt(p.x, p.y, { button: p.button, clickCount: p.clickCount }, tabId)
627
+ /** @action locateVisibleText | group:读取 | desc:定位可见文本节点返回bbox(不返回DOM handle) | param:text:string:req — 要查找的文本 | param:exact:boolean:opt=false — 精确匹配 | param:index:number:opt=0 — 选第几个match | returns:{ok,matches:[{text,x,y,width,height,centerX,centerY,visible}]} | ext:yes */
628
+ case 'locateVisibleText': return _op.locateVisibleText(p.text, p.exact, p.index, tabId)
629
+ /** @action clickByText | group:交互 | desc:定位可见文本后立即点击(原子,防DOM重渲染) | param:text:string:req — 要点击的文本 | param:exact:boolean:opt=false — 精确匹配 | param:index:number:opt=0 — 选第几个match | param:offsetX:number:opt=0 — X偏移 | param:offsetY:number:opt=0 — Y偏移 | returns:{ok,text,x,y,match,route} | ext:yes */
630
+ case 'clickByText': return _op.clickByText(p, tabId)
631
+ /** @action operateSequence | group:进阶 | desc:原子序列执行(locate/click/wait在同一页面上下文串行) | param:steps:array:req — 步骤数组 | returns:{ok,results} | ext:yes */
632
+ case 'operateSequence': return _op.operateSequence(p.steps, tabId)
633
+ case 'waitForSelector': return _op.waitForSelector(p.selector, p.timeout, tabId)
634
+ case 'innerHTML': return _op.innerHTML(p.selector, tabId)
635
+ case 'innerText': return _op.innerText(p.selector, tabId)
636
+ case 'textContent': return _op.textContent(p.selector, tabId)
637
+ case 'getAttribute': return _op.getAttribute(p.selector, p.name, tabId)
638
+ case 'inputValue': return _op.inputValue(p.selector, tabId)
639
+ case 'boundingBox': return _op.boundingBox(p.selector, tabId)
640
+ case 'count': return _op.count(p.selector, tabId)
641
+ case 'screenshot': return _op.screenshot(tabId)
642
+ case 'snapshot': return _op.snapshot(p, tabId)
643
+ case 'evaluate': return _op.evaluate(p.source, p.args, tabId, p.frameId)
644
+ case 'listFrames': return _op.listFrames(tabId)
645
+ case 'waitForFunction':
646
+ throw new Error('waitForFunction is not supported in extension mode because page CSP can block string evaluation; use waitForSelector/waitForURL/status or CDP mode')
647
+ case 'waitForURL': return _op.waitForURL(p.url, p.timeout, tabId)
648
+ case 'waitForTimeout': return _op.waitForTimeout(p.ms, tabId)
649
+ case 'setInputFiles': return _op.setInputFiles(p.selector, normalizeInputFiles(p.files), tabId)
650
+ /** @action setDialogHandler | group:进阶 | desc:设置JS对话框处理 | param:handler:string:req — 处理器 | returns:{ok} | ext:only-extension */
651
+ case 'setDialogHandler': return _op.setDialogHandler(p.handler, tabId)
652
+ case 'getLocalStorage': return _op.getLocalStorage(p.keys, tabId)
653
+ case 'setLocalStorage': return _op.setLocalStorage(p.items, tabId)
654
+ case 'removeLocalStorage': return _op.removeLocalStorage(p.keys, tabId)
655
+ case 'clearLocalStorage': return _op.clearLocalStorage(tabId)
656
+ default: throw new Error(`unknown action: ${action}`)
657
+ }
658
+ }
659
+
660
+ // CDP: 直接在 page 对象上操作
661
+ const page = entry.page
662
+ switch (action) {
663
+ /** @action goto | group:导航 | desc:导航到URL,等待DOMContentLoaded | param:url:string:req — 目标地址 | returns:{ok,url} | ext:yes */
664
+ case 'goto': await page.goto(p.url, { waitUntil: 'domcontentloaded', timeout: 20000 }); return { ok: true, url: page.url() }
665
+ /** @action goBack | group:导航 | desc:后退一页 | param:timeout:number:opt=15000 — 超时毫秒 | returns:{ok,url} | ext:yes */
666
+ case 'goBack': await page.goBack({ waitUntil: 'domcontentloaded', timeout: p.timeout || 15000 }); return { ok: true, url: page.url() }
667
+ /** @action goForward | group:导航 | desc:前进一页 | param:timeout:number:opt=15000 — 超时毫秒 | returns:{ok,url} | ext:yes */
668
+ case 'goForward': await page.goForward({ waitUntil: 'domcontentloaded', timeout: p.timeout || 15000 }); return { ok: true, url: page.url() }
669
+ /** @action reload | group:导航 | desc:刷新当前页 | param:timeout:number:opt=15000 — 超时毫秒 | returns:{ok,url} | ext:yes */
670
+ case 'reload': await page.reload({ waitUntil: 'domcontentloaded', timeout: p.timeout || 15000 }); return { ok: true, url: page.url() }
671
+ /** @action status | group:导航 | desc:读当前页状态(url/title/readyState) | returns:{ok,url,title,readyState,textLength} | ext:yes */
672
+ case 'status': return { ok: true, url: page.url(), title: await page.title().catch(() => ''), readyState: 'complete', textLength: 0 }
673
+ /** @action waitForLoadState | group:等待 | desc:等待指定加载状态 | param:state:string:opt=load — load/domcontentloaded/networkidle | param:timeout:number:opt=30000 — 超时毫秒 | returns:{ok} | ext:yes */
674
+ case 'waitForLoadState': await page.waitForLoadState(p.state || 'load', { timeout: p.timeout || 30000 }).catch(() => {}); return { ok: true }
675
+ /** @action closeTab | group:标签页 | desc:关闭当前page | returns:{ok} | ext:yes */
676
+ case 'closeTab': await page.close(); _pages.delete(params.pageHandle.pageId); return { ok: true }
677
+ /** @action activate | group:标签页 | desc:切到最前(仅可视) | returns:{ok} | ext:yes */
678
+ case 'activate': await page.bringToFront(); return { ok: true }
679
+ /** @action click | group:交互 | desc:点击元素(或坐标x,y) | param:selector:string:req — CSS/aria-ref=xx/.cls>>nth(n)/>>last | param:x:number:opt — 坐标(与y同传,绕过selector) | param:y:number:opt — 坐标 | returns:{ok,selector} | ext:yes */
680
+ /** @action fill | group:交互 | desc:在selector元素填入文本(先清空) | param:selector:string:req — 目标元素 | param:value:string:req — 要填入的文本 | returns:{ok,selector,value} | ext:yes */
681
+ /** @action type | group:交互 | desc:逐字输入(带延迟,模拟键盘) | param:selector:string:req — 目标元素 | param:value:string:req — 要输入的文本 | param:delay:number:opt=0 — 每键延迟毫秒 | returns:{ok,selector} | ext:yes */
682
+ /** @action press | group:交互 | desc:在元素上按键 | param:selector:string:req — 目标元素 | param:key:string:req — 键名(如Enter/Tab/a) | returns:{ok,selector,key} | ext:yes */
683
+ /** @action hover | group:交互 | desc:悬停元素(或坐标) | param:selector:string:req — 目标元素 | param:x:number:opt — 坐标 | param:y:number:opt — 坐标 | returns:{ok,selector} | ext:yes */
684
+ /** @action focus | group:交互 | desc:聚焦元素 | param:selector:string:req — 目标元素 | returns:{ok,selector} | ext:yes */
685
+ /** @action check | group:交互 | desc:勾选checkbox/radio | param:selector:string:req — 目标元素 | returns:{ok,selector} | ext:yes */
686
+ /** @action uncheck | group:交互 | desc:取消勾选 | param:selector:string:req — 目标元素 | returns:{ok,selector} | ext:yes */
687
+ /** @action selectOption | group:交互 | desc:选择option | param:selector:string:req — 目标select | param:value:string:req — 选项值 | returns:{ok,selector,value} | ext:yes */
688
+ /** @action dblclick | group:交互 | desc:双击元素 | param:selector:string:req — 目标元素 | returns:{ok,selector} | ext:yes */
689
+ case 'click':
690
+ case 'fill':
691
+ case 'type':
692
+ case 'press':
693
+ case 'hover':
694
+ case 'focus':
695
+ case 'check':
696
+ case 'uncheck':
697
+ case 'selectOption':
698
+ case 'dblclick':
699
+ // resolver 单源 + ElementHandle: 解析→高亮→执行同一 handle (铁律: 高亮=执行对象)
700
+ return await cdpPerformInteract(page, action, p)
701
+ /** @action mouseMove | group:交互 | desc:移动鼠标到坐标 | param:x:number:req — X坐标 | param:y:number:req — Y坐标 | returns:{ok,x,y} | ext:yes */
702
+ case 'mouseMove': await page.mouse.move(p.x ?? 0, p.y ?? 0); return { ok: true, x: p.x, y: p.y }
703
+ /** @action clickAt | group:交互 | desc:坐标点击(CDP路page.mouse真实鼠标) | param:x:number:req — X坐标 | param:y:number:req — Y坐标 | param:button:string:opt=left | param:clickCount:number:opt=1 | returns:{ok,x,y,route} | ext:yes */
704
+ case 'clickAt': {
705
+ await page.mouse.click(Number(p.x), Number(p.y), { button: p.button || 'left', clickCount: p.clickCount || 1 })
706
+ await page.evaluate((c) => window.__beeVR.visualMark({ x: c.x, y: c.y }), { x: Number(p.x), y: Number(p.y) }).catch(() => {})
707
+ return { ok: true, x: Number(p.x), y: Number(p.y), route: 'cdp' }
708
+ }
709
+ /** @action locateVisibleText | group:读取 | desc:定位可见文本节点返回bbox | param:text:string:req | param:exact:boolean:opt=false | param:index:number:opt=0 | returns:{ok,matches:[{text,x,y,width,height,centerX,centerY,visible}]} | ext:yes */
710
+ case 'locateVisibleText': {
711
+ const r = await cdpLocateVisibleText(page, p)
712
+ return r
713
+ }
714
+ /** @action clickByText | group:交互 | desc:定位可见文本后立即点击(CDP路原子) | param:text:string:req | param:exact:boolean:opt=false | param:index:number:opt=0 | param:offsetX:number:opt=0 | param:offsetY:number:opt=0 | returns:{ok,text,x,y,match,route} | ext:yes */
715
+ case 'clickByText': {
716
+ const r = await cdpLocateVisibleText(page, p)
717
+ if (!r.matches || r.matches.length === 0) throw new Error('clickByText: text not visible: ' + p.text)
718
+ const m = r.matches[Number(p.index ?? 0)] || r.matches[0]
719
+ const x = Math.round(m.centerX + Number(p.offsetX ?? 0))
720
+ const y = Math.round(m.centerY + Number(p.offsetY ?? 0))
721
+ await page.mouse.click(x, y, { button: p.button || 'left', clickCount: p.clickCount || 1 })
722
+ return { ok: true, text: p.text, x, y, match: m, route: 'cdp' }
723
+ }
724
+ /** @action operateSequence | group:进阶 | desc:原子序列执行 | param:steps:array:req | returns:{ok,results} | ext:yes */
725
+ case 'operateSequence': return await cdpOperateSequence(page, p.steps)
726
+ /** @action dragTo | group:交互 | desc:拖拽source到target(双兼容mouse+HTML5) | param:source:string:req — 源元素selector | param:target:string:req — 目标元素selector | returns:{ok,source,target} | ext:yes */
727
+ case 'dragTo': return await cdpDragTo(page, p)
728
+ /** @action waitForSelector | group:等待 | desc:等待selector元素可见 | param:selector:string:req — 目标元素 | param:timeout:number:opt=30000 — 超时毫秒 | returns:{ok,selector,visible,inViewport} | ext:yes */
729
+ case 'waitForSelector': {
730
+ const timeout = p.timeout || 30000
731
+ // aria-ref: 走 Playwright 原生 locator (snapshot 同源); 否则走 resolver 轮询
732
+ if (String(p.selector || '').startsWith('aria-ref=')) {
733
+ const loc = page.locator(p.selector)
734
+ await loc.waitFor({ state: 'visible', timeout }).catch(() => {})
735
+ const visible = await loc.isVisible().catch(() => false)
736
+ if (!visible) throw new Error('waitForSelector timeout (' + timeout + 'ms): ' + p.selector)
737
+ return { ok: true, selector: p.selector, visible: true }
738
+ }
739
+ await ensureVisualRuntime(page)
740
+ const deadline = Date.now() + timeout
741
+ let found = null
742
+ while (Date.now() < deadline) {
743
+ found = await page.evaluate((sel) => {
744
+ const el = window.__beeVR.resolveSelector(sel, undefined)
745
+ if (!el) return null
746
+ const r = el.getBoundingClientRect()
747
+ const vh = window.innerHeight || document.documentElement.clientHeight
748
+ const vw = window.innerWidth || document.documentElement.clientWidth
749
+ const cx = r.left + r.width / 2
750
+ const cy = r.top + r.height / 2
751
+ return { inViewport: cy >= 0 && cy <= vh && cx >= 0 && cx <= vw }
752
+ }, p.selector).catch(() => null)
753
+ if (found) return { ok: true, selector: p.selector, visible: true, inViewport: found.inViewport }
754
+ await new Promise((r) => setTimeout(r, 100))
755
+ }
756
+ throw new Error('waitForSelector timeout (' + timeout + 'ms): ' + p.selector)
757
+ }
758
+ /** @action innerHTML | group:读取 | desc:读元素innerHTML | param:selector:string:req — 目标元素 | returns:{ok,selector,value} | ext:yes */
759
+ case 'innerHTML': return { ok: true, selector: p.selector, value: await page.innerHTML(p.selector).catch(() => '') }
760
+ /** @action innerText | group:读取 | desc:读元素innerText | param:selector:string:req — 目标元素 | returns:{ok,selector,value} | ext:yes */
761
+ case 'innerText': return { ok: true, selector: p.selector, value: await page.innerText(p.selector).catch(() => '') }
762
+ /** @action textContent | group:读取 | desc:读元素textContent | param:selector:string:req — 目标元素 | returns:{ok,selector,value} | ext:yes */
763
+ case 'textContent': return { ok: true, selector: p.selector, value: await page.textContent(p.selector).catch(() => '') }
764
+ /** @action getAttribute | group:读取 | desc:读元素属性 | param:selector:string:req — 目标元素 | param:name:string:req — 属性名 | returns:{ok,selector,name,value} | ext:yes */
765
+ case 'getAttribute': return { ok: true, selector: p.selector, name: p.name, value: await page.getAttribute(p.selector, p.name).catch(() => null) }
766
+ /** @action inputValue | group:读取 | desc:读input/select当前值 | param:selector:string:req — 目标元素 | param:timeout:number:opt=10000 — 超时毫秒 | returns:{ok,selector,value} | ext:yes */
767
+ case 'inputValue': { const v = await page.locator(p.selector).inputValue({ timeout: p.timeout || 10000 }).catch(() => ''); return { ok: true, selector: p.selector, value: v } }
768
+ /** @action boundingBox | group:读取 | desc:读元素包围盒(坐标+尺寸) | param:selector:string:req — 目标元素 | returns:{ok,selector,x,y,width,height} | ext:yes */
769
+ case 'boundingBox': {
770
+ // 同源: aria-ref 走 Playwright, CSS 走 resolver (resolveCdpHandle 统一)
771
+ const handle = await resolveCdpHandle(page, p.selector || '')
772
+ if (!handle) return { ok: false, selector: p.selector, reason: 'not found' }
773
+ try {
774
+ const bb = await handle.boundingBox()
775
+ if (!bb) return { ok: false, selector: p.selector, reason: 'no box' }
776
+ return { ok: true, selector: p.selector, x: bb.x, y: bb.y, width: bb.width, height: bb.height }
777
+ } finally { await handle.dispose().catch(() => {}) }
778
+ }
779
+ /** @action count | group:读取 | desc:统计selector匹配数 | param:selector:string:req — 目标元素 | returns:{ok,selector,count} | ext:yes */
780
+ case 'count': {
781
+ // aria-ref 走 Playwright locator.count, CSS 走 resolver resolveCount
782
+ if (String(p.selector || '').startsWith('aria-ref=')) {
783
+ const cnt = await page.locator(p.selector).count().catch(() => 0)
784
+ return { ok: true, selector: p.selector, count: cnt }
785
+ }
786
+ await ensureVisualRuntime(page)
787
+ const c = await page.evaluate((sel) => window.__beeVR.resolveCount(sel, undefined), p.selector).catch(() => 0)
788
+ return { ok: true, selector: p.selector, count: c }
789
+ }
790
+ /** @action screenshot | group:截图 | desc:截整页PNG(返回dataUrl) | returns:{ok,format,dataUrl} | ext:yes */
791
+ case 'screenshot': { const buf = await page.screenshot({ type: 'png' }); return { ok: true, format: 'png', dataUrl: 'data:image/png;base64,' + buf.toString('base64') } }
792
+ /** @action snapshot | group:读取 | desc:aria无障碍快照(返回yaml) | param:depth:number:opt — 树深度 | param:timeout:number:opt=15000 — 超时毫秒 | returns:{ok,yaml,totalChars} | ext:yes */
793
+ case 'snapshot': {
794
+ // 对齐 bee snapshotCdp:page.ariaSnapshot({mode:'ai'}) 返回标准 aria yaml。
795
+ // 不缓存、不切块。depth/interactiveOnly 透传(interactiveOnly 在 ai mode 下不支持,
796
+ // 调用方需 interactiveOnly 时走 extension 路的 snapshotExt)。
797
+ try {
798
+ const full = await page.ariaSnapshot({ mode: 'ai', depth: p.depth, timeout: p.timeout || 15000 })
799
+ return { ok: true, yaml: full, totalChars: full.length }
800
+ } catch (e) {
801
+ // ariaSnapshot 失败(页面卸载/超时)返回空,不阻断调用
802
+ return { ok: true, yaml: '', totalChars: 0, error: String((e && e.message) || e) }
803
+ }
804
+ }
805
+ /** @action evaluate | group:进阶 | desc:执行页面JS函数(返回其结果) | param:source:string:req — 函数表达式字符串(如()=>document.title) | param:args:any:opt — 传给函数的参数 | returns:由函数返回值决定 | ext:yes */
806
+ case 'evaluate': {
807
+ // eval 契约: ensureVisualRuntime 注入后, eval 内可调 window.visualMark(真实元素)
808
+ await ensureVisualRuntime(page)
809
+ return await page.evaluate(
810
+ ({ source, args }) => {
811
+ // eslint-disable-next-line no-eval
812
+ const value = (0, eval)(`(${source})`)
813
+ return typeof value === 'function' ? value(args) : value
814
+ },
815
+ { source: p.source, args: p.args ?? null }
816
+ )
817
+ }
818
+ /** @action waitForFunction | group:等待 | desc:等待页面函数返回truthy | param:source:string:req — 函数表达式字符串 | param:timeout:number:opt=30000 — 超时毫秒 | returns:由函数返回值决定 | ext:no (extension路CSP拦截eval) */
819
+ case 'waitForFunction': return await page.waitForFunction(p.source, { timeout: p.timeout || 30000 })
820
+ /** @action waitForURL | group:等待 | desc:等待URL匹配 | param:url:string:req — 期望URL(支持glob) | param:timeout:number:opt=30000 — 超时毫秒 | returns:{ok,url} | ext:yes */
821
+ case 'waitForURL': await page.waitForURL(p.url, { timeout: p.timeout || 30000 }).catch(() => {}); return { ok: true, url: page.url() }
822
+ /** @action waitForTimeout | group:等待 | desc:固定等待 | param:ms:number:opt=1000 — 等待毫秒 | returns:{ok,ms} | ext:yes */
823
+ case 'waitForTimeout': await page.waitForTimeout(p.ms || 1000); return { ok: true, ms: p.ms }
824
+ // localStorage (CDP 路由: 通过 evaluate 操作)
825
+ /** @action getLocalStorage | group:存储 | desc:读localStorage(不传keys读全部) | param:keys:array:opt — 键名数组(不传读全部) | returns:{ok,storage} | ext:yes */
826
+ case 'getLocalStorage': {
827
+ const keys = Array.isArray(p.keys) ? p.keys : null
828
+ return await page.evaluate(({ keys }) => {
829
+ const out = {}
830
+ const ks = keys || Object.keys(localStorage)
831
+ for (const k of ks) out[k] = localStorage.getItem(k)
832
+ return { ok: true, storage: out }
833
+ }, { keys })
834
+ }
835
+ /** @action setLocalStorage | group:存储 | desc:写localStorage | param:items:object:req — 键值对{key:value} | returns:{ok,count} | ext:yes */
836
+ case 'setLocalStorage': {
837
+ const items = p.items || {}
838
+ await page.evaluate((items) => {
839
+ for (const [k, v] of Object.entries(items)) localStorage.setItem(k, v)
840
+ }, items)
841
+ return { ok: true, count: Object.keys(items).length }
842
+ }
843
+ /** @action removeLocalStorage | group:存储 | desc:删localStorage(不传keys删全部) | param:keys:array:opt — 键名数组(不传删全部) | returns:{ok,count} | ext:yes */
844
+ case 'removeLocalStorage': {
845
+ const keys = Array.isArray(p.keys) ? p.keys : Object.keys(localStorage)
846
+ await page.evaluate((keys) => {
847
+ for (const k of keys) localStorage.removeItem(k)
848
+ }, keys)
849
+ return { ok: true, count: keys.length }
850
+ }
851
+ /** @action clearLocalStorage | group:存储 | desc:清空localStorage | returns:{ok} | ext:yes */
852
+ case 'clearLocalStorage': {
853
+ await page.evaluate(() => localStorage.clear())
854
+ return { ok: true }
855
+ }
856
+ /** @action setInputFiles | group:进阶 | desc:上传文件到file input | param:selector:string:req — file input元素 | param:files:array:req — 文件绝对路径数组 | param:timeout:number:opt=10000 — 超时毫秒 | returns:{ok,selector,count} | ext:yes */
857
+ case 'setInputFiles': {
858
+ // 统一约定: 调用方传字符串文件路径数组。CDP 路 Playwright 原生接受路径。
859
+ const paths = normalizeFilePaths(p.files)
860
+ await page.locator(p.selector).setInputFiles(paths, { timeout: p.timeout || 10000 })
861
+ return { ok: true, selector: p.selector, count: paths.length }
862
+ }
863
+ default: throw new Error(`unknown action: ${action}`)
864
+ }
865
+ }
866
+
867
+ async function rpcGetCookies (params) {
868
+ if (!_op) return { ok: true, cookies: [] }
869
+ return _op.getCookies(params)
870
+ }
871
+ async function rpcSetCookies (params) {
872
+ // CDP 路由: 用 playwright context.addCookies
873
+ if (_cdpContext && params.cookies) {
874
+ const cookies = params.cookies.filter(c => c.name).map(c => ({
875
+ name: c.name, value: c.value,
876
+ url: c.url, domain: c.domain, path: c.path,
877
+ secure: c.secure, httpOnly: c.httpOnly
878
+ }))
879
+ await _cdpContext.addCookies(cookies).catch(() => {})
880
+ return { ok: true, count: cookies.length }
881
+ }
882
+ // 扩展路由
883
+ if (!_op) return { ok: true, count: 0 }
884
+ return _op.setCookies(params)
885
+ }
886
+ async function rpcClearCookies (params) {
887
+ if (!_op) return { ok: true }
888
+ return _op.clearCookies(params)
889
+ }
890
+
891
+ async function rpcClose () {
892
+ // 关 Chrome
893
+ if (_cdpBrowser) { try { await _cdpBrowser.close() } catch {} }
894
+ if (_op) { try { await _op.close() } catch {} }
895
+
896
+ // 清 state
897
+ const state = require('./state.cjs')
898
+ if (_op) {
899
+ const ud = _op.launcher.getUserDataDir(_op.profileRoot || '', 'default')
900
+ state.clear(ud)
901
+ }
902
+
903
+ // 清 registry
904
+ _pages.clear()
905
+ _browserOpen = false
906
+ _bh = null
907
+ _mode = null
908
+ _cdpBrowser = null
909
+ _cdpContext = null
910
+
911
+ // 标记要退出, IPC server 在响应发完后调 shutdownBrowserd
912
+ _pendingShutdown = true
913
+
914
+ return { ok: true, closed: true }
915
+ }
916
+
917
+ let _pendingShutdown = false
918
+ // close 完成后, browserd 退出 (释放端口 + state)
919
+ let _exiting = false
920
+ function shutdownBrowserd () {
921
+ if (_exiting) return
922
+ _exiting = true
923
+ // 主动断开扩展 WS 长连接 (offscreen 连着的话, server.close() 会等它, 卡住)
924
+ try {
925
+ if (typeof _op !== 'undefined' && _op && _op.web && _op.web.server) {
926
+ _op.web.server.stop()
927
+ }
928
+ } catch {}
929
+ // 强制退出: server.close 最多等 500ms, 超时直接 exit, 不给陈旧进程留机会
930
+ try {
931
+ server.close(() => process.exit(0))
932
+ } catch {}
933
+ setTimeout(() => process.exit(0), 500)
934
+ }
935
+
936
+ // ─── IPC Server ───
937
+
938
+ const server = http.createServer(async (req, res) => {
939
+ // CORS + JSON
940
+ res.setHeader('Content-Type', 'application/json')
941
+
942
+ if (req.method === 'GET' && req.url === '/health') {
943
+ res.end(JSON.stringify({
944
+ ok: true,
945
+ browserOpen: _browserOpen,
946
+ mode: _mode,
947
+ pageCount: _pages.size,
948
+ // 真实插件会话状态: offscreen 是否连接当前 browserd + 心跳是否活
949
+ extensionConnected: _op ? _op.web.isConnected : false,
950
+ extensionAlive: _op ? _op.web.isPluginAlive() : false,
951
+ pid: process.pid
952
+ }))
953
+ return
954
+ }
955
+
956
+ if (req.method === 'POST' && req.url === '/rpc') {
957
+ let body = ''
958
+ req.on('data', (c) => { body += c })
959
+ req.on('end', async () => {
960
+ try {
961
+ const msg = JSON.parse(body)
962
+ const { id, method, params = {} } = msg
963
+ let result, error
964
+ try {
965
+ switch (method) {
966
+ case 'open': result = await rpcOpen(params); break
967
+ case 'newTab': result = await rpcNewTab(params); break
968
+ case 'listTabs': result = await rpcListTabs(params); break
969
+ case 'operate': result = await rpcOperate(params); break
970
+ case 'getCookies': result = await rpcGetCookies(params); break
971
+ case 'setCookies': result = await rpcSetCookies(params); break
972
+ case 'clearCookies': result = await rpcClearCookies(params); break
973
+ case 'close': result = await rpcClose(); break
974
+ case 'reuseTab': result = await rpcReuseTab(params); break
975
+ case 'listDownloads': result = await rpcListDownloads(params); break
976
+ case 'getDownload': result = await rpcGetDownload(params); break
977
+ case 'waitForDownload': result = await rpcWaitForDownload(params); break
978
+ default: throw new Error(`unknown method: ${method}`)
979
+ }
980
+ res.end(JSON.stringify({ id, ok: true, result }))
981
+ if (_pendingShutdown) shutdownBrowserd()
982
+ } catch (e) {
983
+ res.end(JSON.stringify({ id, ok: false, error: e.message }))
984
+ }
985
+ } catch (e) {
986
+ res.end(JSON.stringify({ ok: false, error: 'bad request: ' + e.message }))
987
+ }
988
+ })
989
+ return
990
+ }
991
+
992
+ res.statusCode = 404
993
+ res.end(JSON.stringify({ ok: false, error: 'not found' }))
994
+ })
995
+
996
+ server.listen(IPC_PORT, IPC_HOST, () => {
997
+ // 打印到 stderr 供诊断 (stdout 保持干净给 client)
998
+ console.error(`[browserd] listening on ${IPC_HOST}:${IPC_PORT}`)
999
+ })
1000
+
1001
+ server.on('error', (e) => {
1002
+ console.error(`[browserd] server error: ${e.message}`)
1003
+ process.exit(1)
1004
+ })