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,938 @@
1
+ /**
2
+ * Background service worker.
3
+ *
4
+ * MV3 service worker 会被 Chrome 回收,不再直接持有 WebSocket。WS 长连接改由
5
+ * offscreen document 持有(见 entrypoints/offscreen/)。background 职责:
6
+ * 1. onStartup/onInstalled 时 ensureOffscreen() 建好 offscreen document
7
+ * 2. 收到 offscreen 转发的命令(source:'offscreen-command'),执行 tab 级 /
8
+ * content / scripting / cookies 等操作,结果回 offscreen,再由 offscreen 回 browserd
9
+ * 3. 兜底 ensureOffscreen,防止 SW 重启后 offscreen 未建
10
+ *
11
+ * 命令链路:browserd :17321 ⇿ offscreen (WS) ⇿ background (runtime message)
12
+ *
13
+ * 不依赖窗口焦点:目标 tab 由 switchTab 显式选定,AI 可静默操作任意 tab。
14
+ */
15
+
16
+ const OFFSCREEN_URL = 'offscreen.html'
17
+ const CONTENT_TIMEOUT_MS = 15000
18
+ // 长耗时命令放宽:evaluate/waitForFunction 可能等待页面异步结果(interact 的人机输入最长 120s、
19
+ // 无限滚动等)。对齐 playwright evaluate 超时可由调用方控制的语义。
20
+ const CONTENT_LONG_TIMEOUT_MS = 300000
21
+ const LONG_CONTENT_METHODS = new Set(['evaluate', 'waitForFunction'])
22
+
23
+ export default defineBackground(() => {
24
+ console.log('[WebPlater] background service worker started')
25
+
26
+ // 当前操作的目标 tab。由 switchTab 设置;未设置时取第一个窗口里的活跃 tab 兜底。
27
+ let currentTabId: number | null = null
28
+
29
+ // ── 捕获模式状态 ──
30
+ // service worker 可能重启,状态不跨重启持久;重启后若需重新开启,bee 会重新调 enableCapture
31
+ let captureEnabled = false
32
+ let captureRegistered = false
33
+
34
+ // ── 命令分发 ──────────────────────────────────────────────
35
+
36
+ async function handleCommand(
37
+ method: string,
38
+ params: Record<string, unknown>,
39
+ ): Promise<unknown> {
40
+ // 绝大多数命令需要一个目标 tab,先解析出来
41
+ const tabId = () => resolveTargetTab(params.tabId)
42
+
43
+ switch (method) {
44
+ // ── tab 级:background 直接用 chrome.tabs API ──
45
+ case 'listTabs':
46
+ return listTabs()
47
+ case 'newTab':
48
+ return createTab(String(params.url ?? ''))
49
+ case 'switchTab':
50
+ return switchTab(toTabId(params.tabId))
51
+ case 'reuseTab':
52
+ return reuseTab(String(params.url ?? ''), params.urlMatch != null ? new RegExp(String(params.urlMatch)) : undefined)
53
+ case 'closeTab':
54
+ return closeTab(toTabId(params.tabId))
55
+ case 'goto':
56
+ return gotoTab(await tabId(), String(params.url ?? ''))
57
+ case 'goBack':
58
+ return goBack(await tabId())
59
+ case 'goForward':
60
+ return goForward(await tabId())
61
+ case 'reload':
62
+ return reloadTab(await tabId())
63
+
64
+ // ── evaluate:MAIN world 执行 JS (支持指定 frame 进跨域 iframe) ──
65
+ case 'evaluate':
66
+ return evaluateInMainWorld(await tabId(), String(params.source ?? ''), params.args, params.frameId)
67
+
68
+ // ── listFrames:列出 tab 所有 frame (进 iframe 前先拿 frameId) ──
69
+ case 'listFrames': {
70
+ const tid = await tabId()
71
+ const frames = await chrome.webNavigation.getAllFrames({ tabId: tid })
72
+ return (frames || []).map(f => ({ frameId: f.frameId, url: f.url, parentFrameId: f.parentFrameId }))
73
+ }
74
+
75
+ // ── 下载:chrome.downloads API (原生下载监听, 不轮询目录) ──
76
+ case 'listDownloads':
77
+ return listRecentDownloads(Number(params.limit ?? 20))
78
+ case 'getDownload':
79
+ return getDownloadById(Number(params.id))
80
+ case 'waitForDownload':
81
+ return waitForDownload({
82
+ filenameRegex: params.filenameRegex != null ? String(params.filenameRegex) : undefined,
83
+ sinceMs: params.sinceMs != null ? Number(params.sinceMs) : undefined,
84
+ timeoutMs: params.timeoutMs != null ? Number(params.timeoutMs) : undefined,
85
+ intervalMs: params.intervalMs != null ? Number(params.intervalMs) : undefined,
86
+ })
87
+
88
+ // ── 页面状态:background 直接读 tab 信息 ──
89
+ case 'status':
90
+ return tabStatus(await tabId())
91
+ case 'waitForLoadState':
92
+ return waitForLoadState(await tabId())
93
+
94
+ // ── 坐标/文本点击 + 序列 (content script 合成事件, 不走 CDP/debugger) ──
95
+ // clickAt: 坐标点击, content script 的 pointer/mouse 合成事件序列 (route=extension-content)
96
+ case 'clickAt':
97
+ return sendToContent(await tabId(), 'clickAt', { x: Number(params.x), y: Number(params.y), button: params.button, clickCount: params.clickCount, frameId: params.frameId })
98
+ // clickByText: 定位可见文本 → 立即点击 (content script 原子完成, 防 DOM 重渲染时序漂移)
99
+ case 'clickByText':
100
+ return sendToContent(await tabId(), 'clickByText', { text: params.text, exact: params.exact, index: params.index, offsetX: params.offsetX, offsetY: params.offsetY, frameId: params.frameId })
101
+ // locateVisibleText: 找可见文本节点返回 bbox (content script 扫描, 不返回 DOM handle)
102
+ case 'locateVisibleText':
103
+ return sendToContent(await tabId(), 'locateVisibleText', {
104
+ text: params.text, exact: params.exact, index: params.index, frameId: params.frameId,
105
+ })
106
+ // operateSequence: 一组 locate/click/wait/downloadWait 在同一页面上下文串行执行
107
+ case 'operateSequence':
108
+ return runOperateSequence(await tabId(), Array.isArray(params.steps) ? params.steps : [])
109
+
110
+ // ── 页面动作:转发到 content script(DOM 操作)──
111
+ case 'click':
112
+ return sendToContent(await tabId(), 'click', { selector: params.selector, x: params.x, y: params.y })
113
+ case 'fill':
114
+ return sendToContent(await tabId(), 'fill', { selector: params.selector, value: params.value })
115
+ case 'type':
116
+ return sendToContent(await tabId(), 'type', { selector: params.selector, value: params.value, delay: params.delay })
117
+ case 'press':
118
+ return sendToContent(await tabId(), 'press', { selector: params.selector, key: params.key })
119
+ case 'hover':
120
+ return sendToContent(await tabId(), 'hover', { selector: params.selector, x: params.x, y: params.y })
121
+ case 'focus':
122
+ return sendToContent(await tabId(), 'focus', { selector: params.selector })
123
+ case 'mouseMove':
124
+ return sendToContent(await tabId(), 'mouseMove', { x: params.x, y: params.y })
125
+ case 'check':
126
+ return sendToContent(await tabId(), 'check', { selector: params.selector })
127
+ case 'uncheck':
128
+ return sendToContent(await tabId(), 'uncheck', { selector: params.selector })
129
+ case 'selectOption':
130
+ return sendToContent(await tabId(), 'selectOption', { selector: params.selector, value: params.value })
131
+ case 'waitForSelector':
132
+ return sendToContent(await tabId(), 'waitForSelector', { selector: params.selector, timeout: params.timeout })
133
+
134
+ // ── 读取类:转发 content ──
135
+ case 'innerHTML':
136
+ return sendToContent(await tabId(), 'innerHTML', { selector: params.selector })
137
+ case 'innerText':
138
+ return sendToContent(await tabId(), 'innerText', { selector: params.selector })
139
+ case 'textContent':
140
+ return sendToContent(await tabId(), 'textContent', { selector: params.selector })
141
+ case 'getAttribute':
142
+ return sendToContent(await tabId(), 'getAttribute', { selector: params.selector, name: params.name })
143
+ case 'inputValue':
144
+ return sendToContent(await tabId(), 'inputValue', { selector: params.selector })
145
+ case 'boundingBox':
146
+ return sendToContent(await tabId(), 'boundingBox', { selector: params.selector })
147
+ case 'count':
148
+ return sendToContent(await tabId(), 'count', { selector: params.selector })
149
+ case 'setInputFiles':
150
+ return sendToContent(await tabId(), 'setInputFiles', { selector: params.selector, files: params.files })
151
+ case 'getLocalStorage':
152
+ return sendToContent(await tabId(), 'getLocalStorage', { keys: params.keys })
153
+ case 'setLocalStorage':
154
+ return sendToContent(await tabId(), 'setLocalStorage', { items: params.items })
155
+ case 'removeLocalStorage':
156
+ return sendToContent(await tabId(), 'removeLocalStorage', { keys: params.keys })
157
+ case 'clearLocalStorage':
158
+ return sendToContent(await tabId(), 'clearLocalStorage', {})
159
+
160
+ // ── 交互进阶:转发 content ──
161
+ case 'dblclick':
162
+ return sendToContent(await tabId(), 'dblclick', { selector: params.selector })
163
+ case 'dragTo':
164
+ return sendToContent(await tabId(), 'dragTo', { source: params.source, target: params.target })
165
+
166
+ // ── 等待类:转发 content ──
167
+ case 'waitForFunction':
168
+ return sendToContent(await tabId(), 'waitForFunction', { source: params.source, timeout: params.timeout, args: params.args })
169
+ case 'waitForURL':
170
+ return sendToContent(await tabId(), 'waitForURL', { url: params.url, timeout: params.timeout })
171
+ case 'waitForTimeout':
172
+ return sendToContent(await tabId(), 'waitForTimeout', { ms: params.ms })
173
+
174
+ // ── dialog:转发 content(在页面设置处理函数)──
175
+ case 'setDialogHandler':
176
+ return sendToContent(await tabId(), 'setDialogHandler', { handler: params.handler })
177
+
178
+ // ── aria snapshot:转发 content ──
179
+ case 'snapshot':
180
+ return sendToContent(await tabId(), 'snapshot', {
181
+ rootSelector: params.rootSelector,
182
+ depth: params.depth,
183
+ interactiveOnly: params.interactiveOnly,
184
+ chunkId: params.chunkId,
185
+ chunkSize: params.chunkSize,
186
+ refresh: params.refresh,
187
+ })
188
+
189
+ // ── 截图:chrome.tabs.captureVisibleTab ──
190
+ case 'screenshot':
191
+ return screenshotTab(await tabId())
192
+
193
+ // ── cookie:chrome.cookies API ──
194
+ case 'getCookies':
195
+ return getCookies(params)
196
+ case 'setCookies':
197
+ return setCookies(params)
198
+ case 'clearCookies':
199
+ return clearCookies(params)
200
+
201
+ // ── 捕获模式:bee 下发 captureInjectJS 字符串,插件注入所有 tab + 监听新 tab/导航 ──
202
+ case 'enableCapture':
203
+ return enableCapture(String(params.script ?? ''))
204
+ case 'disableCapture':
205
+ return disableCapture()
206
+ default:
207
+ throw new Error(`Unknown method: ${method}`)
208
+ }
209
+ }
210
+
211
+ // ── tab 操作 ──────────────────────────────────────────────
212
+
213
+ async function listTabs() {
214
+ const tabs = await chrome.tabs.query({})
215
+ return {
216
+ tabs: tabs
217
+ .filter((t) => t.id != null)
218
+ .map((t) => ({
219
+ id: t.id as number,
220
+ url: t.url ?? '',
221
+ title: t.title ?? '',
222
+ active: !!t.active,
223
+ current: t.id === currentTabId,
224
+ })),
225
+ }
226
+ }
227
+
228
+ async function createTab(url: string) {
229
+ const tab = await chrome.tabs.create({ url: url || 'about:blank', active: false })
230
+ if (!tab.id) throw new Error('createTab: no tab id')
231
+ currentTabId = tab.id
232
+ // 等 tab 真正开始导航,避免返回时 url 还是 about:blank
233
+ if (url && url !== 'about:blank') {
234
+ await waitForNavigationStarted(tab.id, url)
235
+ const final = await chrome.tabs.get(tab.id)
236
+ return { ok: true, tabId: tab.id, url: final.url ?? url }
237
+ }
238
+ return { ok: true, tabId: tab.id, url: tab.url ?? url }
239
+ }
240
+
241
+ async function switchTab(tabId: number) {
242
+ // 校验 tab 存在
243
+ await chrome.tabs.get(tabId)
244
+ currentTabId = tabId
245
+ // 真正在 Chrome 层面激活 tab (不只是内部指针), 这样 captureVisibleTab 才能截到
246
+ await chrome.tabs.update(tabId, { active: true })
247
+ return { ok: true, tabId }
248
+ }
249
+
250
+ /**
251
+ * 复用 tab: 按 url 找已开的 tab, 有则切过去复用 (高危平台不重开页面), 无则 newTab。
252
+ * url 精确匹配优先; urlMatch (正则源串) 提供时用于模糊匹配。
253
+ * 返回复用 (reused:true) 或新建 (reused:false) 的 tabId。
254
+ */
255
+ async function reuseTab(url: string, urlMatch?: RegExp) {
256
+ const tabs = await chrome.tabs.query({})
257
+ const hit = urlMatch
258
+ ? tabs.find((t) => t.id != null && urlMatch.test(t.url ?? ''))
259
+ : tabs.find((t) => t.id != null && (t.url ?? '') === url)
260
+ if (hit && hit.id != null) {
261
+ await switchTab(hit.id)
262
+ return { ok: true, reused: true, tabId: hit.id, url: hit.url ?? url }
263
+ }
264
+ const created = await createTab(url)
265
+ return { ok: true, reused: false, tabId: created.tabId, url: created.url ?? url }
266
+ }
267
+
268
+ async function closeTab(tabId: number) {
269
+ await chrome.tabs.remove(tabId)
270
+ if (currentTabId === tabId) currentTabId = null
271
+ return { ok: true }
272
+ }
273
+
274
+ async function gotoTab(tabId: number, url: string) {
275
+ if (!url) throw new Error('goto requires url')
276
+ await chrome.tabs.update(tabId, { url })
277
+ currentTabId = tabId
278
+ // 等 tab 真正开始导航
279
+ await waitForNavigationStarted(tabId, url)
280
+ const final = await chrome.tabs.get(tabId)
281
+ return { ok: true, tabId, url: final.url ?? url }
282
+ }
283
+
284
+ async function goBack(tabId: number) {
285
+ await chrome.tabs.goBack(tabId)
286
+ currentTabId = tabId
287
+ return { ok: true, tabId }
288
+ }
289
+
290
+ async function goForward(tabId: number) {
291
+ await chrome.tabs.goForward(tabId)
292
+ currentTabId = tabId
293
+ return { ok: true, tabId }
294
+ }
295
+
296
+ async function reloadTab(tabId: number) {
297
+ await chrome.tabs.reload(tabId)
298
+ currentTabId = tabId
299
+ return { ok: true, tabId }
300
+ }
301
+
302
+ /** 读取 tab 页面状态(url/title 来自 chrome.tabs,readyState/textLength 来自 content) */
303
+ async function tabStatus(tabId: number) {
304
+ const tab = await chrome.tabs.get(tabId)
305
+ currentTabId = tabId
306
+ // readyState / body 文本长度需要 content script,容错读取
307
+ let readyState = 'unknown'
308
+ let textLength = 0
309
+ try {
310
+ const res = (await sendToContent(tabId, 'status', {})) as {
311
+ readyState?: string
312
+ textLength?: number
313
+ } | undefined
314
+ readyState = res?.readyState ?? 'unknown'
315
+ textLength = res?.textLength ?? 0
316
+ } catch {
317
+ // chrome:// 等无法注入 content 的页面,只返回 tab 基本信息丶
318
+ }
319
+ return {
320
+ ok: true,
321
+ url: tab.url ?? '',
322
+ title: tab.title ?? '',
323
+ readyState,
324
+ textLength,
325
+ }
326
+ }
327
+
328
+ /** 等待页面到达指定加载状态(默认 complete) */
329
+ async function waitForLoadState(tabId: number, state: string = 'complete') {
330
+ const valid = ['loading', 'interactive', 'complete']
331
+ const target = valid.includes(state) ? state : 'complete'
332
+ const deadline = Date.now() + CONTENT_TIMEOUT_MS
333
+ // 先等导航真正启动(status 进入过 loading),避免导航未开始就误判 complete
334
+ let sawLoading = false
335
+ const startDeadline = Date.now() + 5000
336
+ while (Date.now() < startDeadline && Date.now() < deadline) {
337
+ try {
338
+ const tab = await chrome.tabs.get(tabId)
339
+ if (tab.status === 'loading') { sawLoading = true; break }
340
+ // 已是 complete 且 url 非 about:blank:可能导航太快已完成,也认为启动过
341
+ if (tab.status === 'complete' && tab.url && tab.url !== 'about:blank') { sawLoading = true; break }
342
+ } catch {
343
+ throw new Error('waitForLoadState: tab lost')
344
+ }
345
+ await sleep(80)
346
+ }
347
+ void sawLoading
348
+ // 再等到目标状态
349
+ while (Date.now() < deadline) {
350
+ try {
351
+ const tab = await chrome.tabs.get(tabId)
352
+ const status = tab.status ?? 'complete'
353
+ if (target === 'loading') {
354
+ return { ok: true, tabId, status }
355
+ }
356
+ if (status === 'complete') {
357
+ return { ok: true, tabId, status: 'complete' }
358
+ }
359
+ } catch {
360
+ throw new Error('waitForLoadState: tab lost')
361
+ }
362
+ await sleep(150)
363
+ }
364
+ throw new Error(`waitForLoadState timeout: ${state}`)
365
+ }
366
+
367
+ /** 截图:截取指定 tab 可视区域,返回 dataURL(png base64) */
368
+ async function screenshotTab(tabId: number) {
369
+ currentTabId = tabId
370
+
371
+ const tab = await chrome.tabs.get(tabId)
372
+ if (tab.windowId == null) {
373
+ throw new Error(`screenshot failed: tab ${tabId} has no windowId`)
374
+ }
375
+
376
+ // captureVisibleTab 只能截当前窗口真实可见的 active tab。
377
+ // 非 active tab 不自动切(切了画面也不可靠), 直接报明确错误。
378
+ const activeTabs = await chrome.tabs.query({ windowId: tab.windowId, active: true })
379
+ const activeTabId = activeTabs[0]?.id ?? null
380
+ if (activeTabId !== tabId) {
381
+ throw new Error(
382
+ 'screenshot in extension mode only supports the active visible tab; call operate(ph, { action: \'activate\' }) and make sure the tab is visible before screenshot'
383
+ )
384
+ }
385
+
386
+ try {
387
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' })
388
+ return { ok: true, tabId, format: 'png', dataUrl }
389
+ } catch (err) {
390
+ throw new Error(`screenshot failed: ${err instanceof Error ? err.message : String(err)}`)
391
+ }
392
+ }
393
+
394
+ /** 读取 cookie(按 url 过滤,未传 url 则返回全部) */
395
+ async function getCookies(params: Record<string, unknown>) {
396
+ const url = params.url ? String(params.url) : undefined
397
+ const domain = params.domain ? String(params.domain) : undefined
398
+ const query: chrome.cookies.GetAllDetails = {}
399
+ if (url) query.url = url
400
+ if (domain) query.domain = domain
401
+ const cookies = await chrome.cookies.getAll(query)
402
+ return {
403
+ ok: true,
404
+ cookies: cookies.map((c) => ({
405
+ name: c.name,
406
+ value: c.value,
407
+ domain: c.domain,
408
+ path: c.path,
409
+ secure: c.secure,
410
+ httpOnly: c.httpOnly,
411
+ sameSite: c.sameSite,
412
+ expirationDate: c.expirationDate,
413
+ })),
414
+ }
415
+ }
416
+
417
+ /** 设置 cookie */
418
+ async function setCookies(params: Record<string, unknown>) {
419
+ const cookies = (params.cookies ?? []) as Array<Record<string, unknown>>
420
+ for (const c of cookies) {
421
+ const detail: chrome.cookies.SetDetails = {
422
+ url: String(c.url ?? ''),
423
+ name: String(c.name ?? ''),
424
+ value: String(c.value ?? ''),
425
+ }
426
+ if (c.domain) detail.domain = String(c.domain)
427
+ if (c.path) detail.path = String(c.path)
428
+ if (c.secure != null) detail.secure = !!c.secure
429
+ if (c.httpOnly != null) detail.httpOnly = !!c.httpOnly
430
+ await chrome.cookies.set(detail)
431
+ }
432
+ return { ok: true, count: cookies.length }
433
+ }
434
+
435
+ /** 清除 cookie(按 url 或 domain) */
436
+ async function clearCookies(params: Record<string, unknown>) {
437
+ const url = params.url ? String(params.url) : undefined
438
+ const name = params.name ? String(params.name) : undefined
439
+ if (url) {
440
+ const removed = await chrome.cookies.remove({ url, name: name ?? '' })
441
+ return { ok: true, removed: removed != null }
442
+ }
443
+ // 未传 url:按 domain 清除全部
444
+ const domain = params.domain ? String(params.domain) : undefined
445
+ if (domain) {
446
+ const all = await chrome.cookies.getAll({ domain })
447
+ let count = 0
448
+ for (const c of all) {
449
+ const scheme = c.secure ? 'https' : 'http'
450
+ const r = await chrome.cookies.remove({
451
+ url: `${scheme}://${c.domain.replace(/^\./, '')}${c.path}`,
452
+ name: c.name,
453
+ })
454
+ if (r) count++
455
+ }
456
+ return { ok: true, count }
457
+ }
458
+ return { ok: false, error: 'clearCookies requires url or domain' }
459
+ }
460
+
461
+ // ── evaluate:MAIN world 执行 (evaluateInMainWorld 在模块级, 供 IIFE + runOperateSequence 共用) ────────
462
+
463
+ // ── 转发到 content script ────────────────────────────────
464
+
465
+ // sendToContent / evaluateInMainWorld 在模块级 (IIFE 外), 供 IIFE 内 case 分发 +
466
+ // 模块级 runOperateSequence 共用同一套 (含 ping/inject/retry)。
467
+
468
+ // ── 工具 ─────────────────────────────────────────────────
469
+
470
+ function toTabId(raw: unknown): number {
471
+ const n = Number(raw)
472
+ if (!Number.isFinite(n)) throw new Error('tabId must be a number')
473
+ return n
474
+ }
475
+
476
+ function sleep(ms: number): Promise<void> {
477
+ return new Promise((resolve) => setTimeout(resolve, ms))
478
+ }
479
+
480
+ /**
481
+ * 等 tab 真正开始加载指定 url(修复 chrome.tabs.create/update 返回时导航未启动的 race)。
482
+ * chrome.tabs.create/update 触发后,tab.url 更新、status 进入 loading 需要一点时间;
483
+ * 不等的话 waitForLoadState 可能在导航启动前就误判 complete。
484
+ * 等 tab.url 包含期望 url 且 status=loading/complete,最多 timeoutMs。
485
+ */
486
+ async function waitForNavigationStarted(tabId: number, expectedUrl: string, timeoutMs = 5000): Promise<void> {
487
+ const deadline = Date.now() + timeoutMs
488
+ while (Date.now() < deadline) {
489
+ try {
490
+ const tab = await chrome.tabs.get(tabId)
491
+ const urlOk = !expectedUrl || expectedUrl === 'about:blank' || (tab.url && tab.url !== 'about:blank' && tab.url !== '')
492
+ if (urlOk && (tab.status === 'loading' || tab.status === 'complete')) return
493
+ } catch {
494
+ throw new Error('waitForNavigationStarted: tab lost')
495
+ }
496
+ await sleep(80)
497
+ }
498
+ // 超时不报错:某些页面导航太快或 about:blank,留给 waitForLoadState 处理
499
+ }
500
+
501
+ /**
502
+ * 解析命令的目标 tab。
503
+ *
504
+ * tab 隔离原则:页面动作必须显式传 tabId,不做隐式兜底。
505
+ * 多个并行工作流共享同一个浏览器实例,靠各自持有的 tabId 隔离,
506
+ * 避免全局 currentTabId 在并发下串台。
507
+ *
508
+ * 仅当命令确实未传 tabId 时,才回退到 currentTabId(单工作流顺序操作的便利)。
509
+ */
510
+ async function resolveTargetTab(rawTabId?: unknown): Promise<number> {
511
+ if (rawTabId != null) return toTabId(rawTabId)
512
+
513
+ if (currentTabId != null) {
514
+ try {
515
+ await chrome.tabs.get(currentTabId)
516
+ return currentTabId
517
+ } catch {
518
+ currentTabId = null
519
+ }
520
+ }
521
+
522
+ throw new Error('no target tab: pass tabId explicitly (e.g. from newTab/switchTab)')
523
+ }
524
+
525
+ // ── 捕获模式(enableCapture/disableCapture)─────────────────────
526
+ //
527
+ // 脚本本体是插件自带的 public/capture.js(打包进 dist,等价 bee captureInjectJS)。
528
+ // 注入机制:chrome.scripting.registerContentScripts(world:'MAIN', runAt:'document_start',
529
+ // allFrames:true, matches:<all_urls>) ——这是 CDP Page.addScriptToEvaluateOnNewDocument
530
+ // 的真正等价:注册后每个文档加载(导航/刷新/新 tab/SPA 跳转)都自动执行,
531
+ // document_start 即注入,持久存在,不需要 tabs 事件补注入。
532
+ //
533
+ // 脚本 installed 标志位防重复安装;enabled 标志位由 enable/disable 控制。
534
+ // script 参数仍接收(bee 下发)为兼容,但插件用自带的 capture.js,不依赖它。
535
+
536
+ const CAPTURE_SCRIPT_ID = 'bee-capture'
537
+ const CAPTURE_SCRIPT_FILE = 'capture.js'
538
+
539
+ /** 注册持久 capture content script(等价 CDP addScriptToEvaluateOnNewDocument) */
540
+ async function registerCaptureScript(): Promise<void> {
541
+ if (captureRegistered) return
542
+ try {
543
+ await chrome.scripting.unregisterScript({ id: CAPTURE_SCRIPT_ID })
544
+ } catch {
545
+ // 未注册过,忽略
546
+ }
547
+ await chrome.scripting.registerContentScripts([
548
+ {
549
+ id: CAPTURE_SCRIPT_ID,
550
+ matches: ['<all_urls>'],
551
+ js: [CAPTURE_SCRIPT_FILE],
552
+ runAt: 'document_start',
553
+ allFrames: true,
554
+ // ISOLATED world(默认):脚本可直接读 chrome.storage.local,
555
+ // 所有 tab(含新 tab)状态一致,不依赖 background 内存。
556
+ },
557
+ ])
558
+ captureRegistered = true
559
+ console.log('[WebPlater] capture script registered (ISOLATED, document_start)')
560
+ }
561
+
562
+ /**
563
+ * 写 enabled 到 chrome.storage.local。
564
+ * capture.js(ISOLATED world)监听 storage.onChanged,所有 tab(含新 tab)实时同步。
565
+ * 不再需要逐 tab executeScript 设标志位。
566
+ */
567
+ async function setCaptureEnabled(enabled: boolean): Promise<void> {
568
+ await chrome.storage.local.set({ bee_capture_enabled: enabled })
569
+ }
570
+
571
+ async function enableCapture(_script: string): Promise<{ ok: boolean }> {
572
+ await registerCaptureScript()
573
+ captureEnabled = true
574
+ await setCaptureEnabled(true)
575
+ console.log('[WebPlater] enableCapture: storage.bee_capture_enabled=true')
576
+ return { ok: true }
577
+ }
578
+
579
+ async function disableCapture(): Promise<{ ok: boolean }> {
580
+ captureEnabled = false
581
+ await setCaptureEnabled(false)
582
+ console.log('[WebPlater] disableCapture: storage.bee_capture_enabled=false')
583
+ return { ok: true }
584
+ }
585
+
586
+ // ── offscreen document 管理 ──────────────────────────────
587
+ //
588
+ // offscreen document 持有到 browserd 的 WebSocket 长连接,规避 MV3 service worker
589
+ // 被回收导致连接中断。background 通过 ensureOffscreen() 保证它存在。
590
+ let creatingOffscreen: Promise<void> | null = null
591
+
592
+ async function hasOffscreen(): Promise<boolean> {
593
+ // 优先用 getContexts(按类型精确判定);旧 Chrome 回退 hasDocument
594
+ try {
595
+ const contexts = await chrome.runtime.getContexts({
596
+ contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
597
+ })
598
+ return contexts.length > 0
599
+ } catch {
600
+ return await chrome.offscreen.hasDocument()
601
+ }
602
+ }
603
+
604
+ async function ensureOffscreen(): Promise<void> {
605
+ if (await hasOffscreen()) return
606
+ if (creatingOffscreen) {
607
+ await creatingOffscreen
608
+ return
609
+ }
610
+ creatingOffscreen = chrome.offscreen.createDocument({
611
+ url: OFFSCREEN_URL,
612
+ // Chrome offscreen 的 Reason 枚举无 WEB_SOCKETS;此处只需合法枚举值,
613
+ // 用途由 justification 说明。offscreen document 内可直接使用 WebSocket。
614
+ reasons: [chrome.offscreen.Reason.WORKERS],
615
+ justification: 'Hold a persistent WebSocket to the local host bridge (browserd :17321).',
616
+ })
617
+ await creatingOffscreen
618
+ creatingOffscreen = null
619
+ console.log('[WebPlater] offscreen document created')
620
+ }
621
+
622
+
623
+ // ── offscreen 桥接:收到 offscreen 转发的命令,执行后回包 ──
624
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
625
+ // offscreen 转发的 host 命令
626
+ if (message?.source === 'offscreen-command') {
627
+ handleCommand(message.method as string, message.params ?? {})
628
+ .then((result) => sendResponse({ ok: true, result }))
629
+ .catch((err) =>
630
+ sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }),
631
+ )
632
+ return true // 异步响应
633
+ }
634
+
635
+ // popup 调试:复用同一套命令处理;reconnect 触发 ensureOffscreen
636
+ if (message?.source === 'bee-plugin-popup') {
637
+ if (message.method === 'reconnect') {
638
+ ensureOffscreen()
639
+ .then(() => sendResponse({ ok: true, result: { offscreen: true } }))
640
+ .catch((err) =>
641
+ sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }),
642
+ )
643
+ return true
644
+ }
645
+ handleCommand(message.method as string, message.params ?? {})
646
+ .then((result) => sendResponse({ ok: true, result }))
647
+ .catch((err) =>
648
+ sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }),
649
+ )
650
+ return true
651
+ }
652
+
653
+ return false
654
+ })
655
+
656
+ // ── 生命周期钩子:保证 offscreen document 存在 ──
657
+ // service worker 可能休眠/重启;offscreen document 不随 SW 回收销毁,
658
+ // 但扩展装载 / 浏览器启动时还没建,需在这些钩子里 ensureOffscreen。
659
+ chrome.runtime.onStartup.addListener(() => {
660
+ void ensureOffscreen()
661
+ })
662
+ chrome.runtime.onInstalled.addListener(() => {
663
+ void ensureOffscreen()
664
+ })
665
+
666
+ ensureOffscreen()
667
+ })
668
+
669
+ // ── 下载监听 (chrome.downloads API, 实时查询不依赖 SW 内存) ────────────────
670
+ // 设计: SW 会被回收, 内存状态不可靠。下载查询直接用 chrome.downloads.search
671
+ // 实时查, 不维护易失 Map。provide 三个能力:
672
+ // - listDownloads: 列出最近下载 (默认最近 20 条)
673
+ // - getDownload: 查单个下载状态 (by id)
674
+ // - waitForDownload: 轮询等某下载完成 (按 filenameRegex 或 id, 超时控制)
675
+ // 这几个函数在 background handleCommand 里作 case 暴露给 browserd。
676
+
677
+ /** 下载状态摘要 (从 chrome.downloads.DownloadItem 提取) */
678
+ interface DownloadSummary {
679
+ id: number
680
+ filename: string
681
+ url: string
682
+ state: 'in_progress' | 'interrupted' | 'complete'
683
+ bytesReceived: number
684
+ totalBytes: number
685
+ exists: boolean
686
+ }
687
+
688
+ function summarizeDownload(item: chrome.downloads.DownloadItem): DownloadSummary {
689
+ return {
690
+ id: item.id,
691
+ filename: item.filename || '',
692
+ url: item.url || (item.finalUrl ?? ''),
693
+ state: item.state as DownloadSummary['state'],
694
+ bytesReceived: item.bytesReceived,
695
+ totalBytes: item.totalBytes,
696
+ exists: item.exists,
697
+ }
698
+ }
699
+
700
+ async function listRecentDownloads(limit = 20): Promise<{ downloads: DownloadSummary[] }> {
701
+ const items = await chrome.downloads.search({
702
+ orderBy: ['-startTime'],
703
+ limit,
704
+ } as any)
705
+ return { downloads: (items || []).map(summarizeDownload) }
706
+ }
707
+
708
+ async function getDownloadById(id: number): Promise<{ download: DownloadSummary | null }> {
709
+ const items = await chrome.downloads.search({ id })
710
+ return { download: items && items[0] ? summarizeDownload(items[0]) : null }
711
+ }
712
+
713
+ /**
714
+ * 轮询等下载完成。匹配条件:
715
+ * - filenameRegex 提供时, 找最近 startTime 最早的、filename 匹配且 state=complete 的
716
+ * - sinceMs 提供时, 只看 startTime > sinceMs 的 (下载前时间戳, 防 diff 到旧文件)
717
+ * 返回匹配的下载摘要; 超时抛错。
718
+ */
719
+ async function waitForDownload(
720
+ opts: { filenameRegex?: string; sinceMs?: number; timeoutMs?: number; intervalMs?: number },
721
+ ): Promise<{ download: DownloadSummary | null; reason: string }> {
722
+ const timeoutMs = opts.timeoutMs ?? 60000
723
+ const intervalMs = opts.intervalMs ?? 2000
724
+ const regex = opts.filenameRegex ? new RegExp(opts.filenameRegex) : null
725
+ const since = opts.sinceMs ?? 0
726
+ const deadline = Date.now() + timeoutMs
727
+ while (Date.now() < deadline) {
728
+ await new Promise((r) => setTimeout(r, intervalMs))
729
+ const items = (await chrome.downloads.search({ orderBy: ['-startTime'], limit: 20 } as any)) || []
730
+ // 过滤: 时间 (sinceMs) + 文件名 (regex)
731
+ const candidates = items.filter((it) => {
732
+ if (it.startTime == null) return false
733
+ const ts = Date.parse(it.startTime)
734
+ if (isNaN(ts) || ts < since) return false
735
+ if (regex && !regex.test(it.filename || '')) return false
736
+ return true
737
+ })
738
+ // 找一个 complete 的
739
+ const done = candidates.find((it) => it.state === 'complete' && it.exists)
740
+ if (done) return { download: summarizeDownload(done), reason: 'complete' }
741
+ // 有 in_progress/中断 的候选, 继续等
742
+ const pending = candidates.find((it) => it.state === 'in_progress')
743
+ if (pending) continue
744
+ // 候选都没有 = 下载还没触发, 继续等
745
+ }
746
+ return { download: null, reason: 'timeout' }
747
+ }
748
+
749
+ // ── operateSequence: 原子序列执行 ───────────────────────────────
750
+ //
751
+ // 一组 locate/click/wait/downloadWait 在同一页面上下文串行执行, 中间状态 (saveAs 的 bbox)
752
+ // 存在内存, 不暴露给外层 SDK。防 DOM 重渲染导致的外层时序漂移。
753
+ //
754
+ // 支持的 step:
755
+ // { action: 'locateVisibleText', text, exact?, index?, saveAs?: 'name' }
756
+ // { action: 'clickAt', x?, y?, from?: 'name', dx?, dy? } // from 引用之前 saveAs 的 bbox center + dx/dy
757
+ // { action: 'clickByText', text, exact?, index?, offsetX?, offsetY? }
758
+ // { action: 'waitForTimeout', ms }
759
+ // { action: 'waitForDownload', timeout?, filenameRegex?, sinceMs? }
760
+ // { action: 'evaluate', source } // 轻量自定义
761
+
762
+ async function runOperateSequence(
763
+ tabId: number,
764
+ steps: Record<string, unknown>[],
765
+ ): Promise<{ ok: true; results: unknown[] }> {
766
+ const saved: Record<string, any> = {}
767
+ const results: unknown[] = []
768
+ for (let i = 0; i < steps.length; i++) {
769
+ const step = steps[i] || {}
770
+ const action = String(step.action || '')
771
+ try {
772
+ if (action === 'locateVisibleText') {
773
+ const box = await sendToContent(tabId, 'locateVisibleText', {
774
+ text: step.text, exact: step.exact, index: step.index, frameId: step.frameId,
775
+ }) as any
776
+ results.push({ step: i, action, ok: box?.ok, count: box?.matches?.length })
777
+ if (step.saveAs && box?.matches?.length) saved[String(step.saveAs)] = box.matches[0]
778
+ if (!box?.ok || !box?.matches?.length) {
779
+ return { ok: false, results, error: `step ${i} locateVisibleText: text not visible: ${step.text}` } as any
780
+ }
781
+ } else if (action === 'clickAt') {
782
+ let x = step.x != null ? Number(step.x) : undefined
783
+ let y = step.y != null ? Number(step.y) : undefined
784
+ if (step.from && saved[String(step.from)]) {
785
+ const m = saved[String(step.from)]
786
+ x = Math.round(m.centerX + Number(step.dx ?? 0))
787
+ y = Math.round(m.centerY + Number(step.dy ?? 0))
788
+ }
789
+ if (x == null || y == null) throw new Error(`step ${i} clickAt: need x,y or from`)
790
+ const r = await sendToContent(tabId, 'clickAt', { x, y, button: step.button, clickCount: step.clickCount, frameId: step.frameId })
791
+ results.push({ step: i, action, ok: true, x, y })
792
+ } else if (action === 'clickByText') {
793
+ const m0 = step.index != null ? Number(step.index) : 0
794
+ const r: any = await sendToContent(tabId, 'clickByText', {
795
+ text: step.text, exact: step.exact, index: m0, offsetX: step.offsetX, offsetY: step.offsetY, frameId: step.frameId,
796
+ })
797
+ if (!r?.ok) {
798
+ return { ok: false, results, error: `step ${i} clickByText: ${r?.error || 'text not visible: ' + step.text}` } as any
799
+ }
800
+ results.push({ step: i, action, ok: true, x: r.x, y: r.y })
801
+ if (step.saveAs && r?.match) saved[String(step.saveAs)] = r.match
802
+ } else if (action === 'waitForTimeout') {
803
+ await new Promise((r) => setTimeout(r, Number(step.ms ?? 0)))
804
+ results.push({ step: i, action, ok: true })
805
+ } else if (action === 'waitForDownload') {
806
+ const since = step.sinceMs != null ? Number(step.sinceMs) : Date.now()
807
+ const r = await waitForDownload({
808
+ filenameRegex: step.filenameRegex != null ? String(step.filenameRegex) : undefined,
809
+ sinceMs: since,
810
+ timeoutMs: Number(step.timeout ?? 60000),
811
+ intervalMs: Number(step.intervalMs ?? 2000),
812
+ })
813
+ results.push({ step: i, action, ok: !!r.download, download: r.download, reason: r.reason })
814
+ if (!r.download) {
815
+ return { ok: false, results, error: `step ${i} waitForDownload: timeout` } as any
816
+ }
817
+ } else if (action === 'evaluate') {
818
+ // evaluate 走 MAIN world 动态注入 (不进 content script, 修正之前发 content 的 bug)
819
+ const r = await evaluateInMainWorld(tabId, String(step.source ?? ''), step.args, typeof step.frameId === 'number' ? step.frameId : undefined)
820
+ results.push({ step: i, action, ok: true, result: r })
821
+ } else {
822
+ throw new Error(`step ${i} unknown action: ${action}`)
823
+ }
824
+ } catch (e: any) {
825
+ return { ok: false, results, error: `step ${i} (${action}) failed: ${e?.message || e}` } as any
826
+ }
827
+ }
828
+ return { ok: true, results }
829
+ }
830
+
831
+ // ── content script 通信 (模块级, IIFE 外 runOperateSequence 也用这套) ──
832
+ //
833
+ // 稳定性设计: content script 靠 manifest run_at:document_idle 注入, 在 SPA hash 路由切换、
834
+ // document_idle 时机窗口等情况下, listener 可能未就绪 → 'Receiving end does not exist'。
835
+ // 本套发送函数内置 ping/inject/retry: 先 ping 探活, 不就绪则动态注入 content.js 再重试,
836
+ // 不再依赖常驻 content script 的注入时机。
837
+
838
+ // manifest 里的 content script 产物路径 (动态注入用, 不是 capture 脚本)
839
+ const CONTENT_SCRIPT_FILE = 'content-scripts/content.js'
840
+
841
+ // 只把这类错误认作"接收端不存在"(需注入重试), 不吞业务错/timeout/unknown method
842
+ function isNoReceiverError(msg: string): boolean {
843
+ return /Receiving end does not exist|Could not establish connection/i.test(msg)
844
+ }
845
+
846
+ // 动态注入 content.js 到指定 tab/frame (重复注入会被 Chrome 忽略, 安全)
847
+ async function injectContentScript(tabId: number, frameId?: number): Promise<void> {
848
+ const target: chrome.scripting.ScriptTarget = { tabId }
849
+ if (typeof frameId === 'number') target.frameIds = [frameId]
850
+ await chrome.scripting.executeScript({ target, files: [CONTENT_SCRIPT_FILE] })
851
+ }
852
+
853
+ // 裸发送: 只做 sendMessage + timeout + lastError + {ok:false} 处理, 不注入不重试
854
+ function sendToContentRaw(
855
+ tabId: number,
856
+ method: string,
857
+ params?: Record<string, unknown>,
858
+ ): Promise<unknown> {
859
+ const limit = LONG_CONTENT_METHODS.has(method) ? CONTENT_LONG_TIMEOUT_MS : CONTENT_TIMEOUT_MS
860
+ return new Promise((resolve, reject) => {
861
+ const timeout = setTimeout(() => {
862
+ reject(new Error(`content script timeout: ${method}`))
863
+ }, limit)
864
+ const frameId = typeof params?.frameId === 'number' ? params.frameId : undefined
865
+ const sendOptions = frameId !== undefined ? { frameId } : undefined
866
+ chrome.tabs.sendMessage(
867
+ tabId,
868
+ { source: 'bee-plugin', method, params: params ?? {} },
869
+ sendOptions as any,
870
+ (response) => {
871
+ clearTimeout(timeout)
872
+ if (chrome.runtime.lastError) {
873
+ reject(new Error(chrome.runtime.lastError.message))
874
+ return
875
+ }
876
+ if (response && typeof response === 'object' && response.ok === false) {
877
+ reject(new Error((response as { error?: string }).error ?? 'content error'))
878
+ return
879
+ }
880
+ resolve(response)
881
+ },
882
+ )
883
+ })
884
+ }
885
+
886
+ // 统一 content 发送: 解析 frameId → ping 探活(不就绪则注入+重ping) → 发真实 method
887
+ // (真实 method 再次无接收端则再注入+重发一次) → 其他错直接抛
888
+ async function sendToContent(
889
+ tabId: number,
890
+ method: string,
891
+ params?: Record<string, unknown>,
892
+ ): Promise<unknown> {
893
+ const frameId = typeof params?.frameId === 'number' ? params.frameId : undefined
894
+
895
+ // 1. ping 探活 (不就绪则注入一次再 ping)
896
+ try {
897
+ await sendToContentRaw(tabId, 'ping', { frameId })
898
+ } catch (e: any) {
899
+ const msg = String(e?.message || e)
900
+ if (!isNoReceiverError(msg)) throw e // 非"无接收端"错, 直接抛 (timeout 等)
901
+ // content script 未就绪 → 注入后重 ping
902
+ await injectContentScript(tabId, frameId)
903
+ await sendToContentRaw(tabId, 'ping', { frameId })
904
+ }
905
+
906
+ // 2. 发真实 method (再次无接收端则注入+重发一次)
907
+ try {
908
+ return await sendToContentRaw(tabId, method, params)
909
+ } catch (e: any) {
910
+ const msg = String(e?.message || e)
911
+ if (!isNoReceiverError(msg)) throw e // 业务错/timeout 直接抛, 不重试
912
+ await injectContentScript(tabId, frameId)
913
+ return await sendToContentRaw(tabId, method, params)
914
+ }
915
+ }
916
+
917
+ // evaluate: MAIN world 动态注入执行 JS (不依赖常驻 content script, 随时可用)
918
+ // frameId 指定进跨域 iframe (如淘宝统一登录 iframe)。供 IIFE 内 case 'evaluate' +
919
+ // 模块级 runOperateSequence 的 evaluate 子步共用。
920
+ async function evaluateInMainWorld(tabId: number, source: string, args?: unknown, frameId?: number) {
921
+ if (!source) throw new Error('evaluate requires source')
922
+ const target: chrome.scripting.ScriptTarget = { tabId }
923
+ if (typeof frameId === 'number') target.frameIds = [frameId]
924
+ const [injection] = await chrome.scripting.executeScript({
925
+ target,
926
+ world: 'MAIN',
927
+ func: async (src: string, evalArgs: unknown) => {
928
+ // eslint-disable-next-line no-eval
929
+ const value = (0, eval)(`(${src})`)
930
+ if (typeof value === 'function') {
931
+ return await value(evalArgs)
932
+ }
933
+ return value
934
+ },
935
+ args: [source, args ?? null],
936
+ })
937
+ return injection?.result
938
+ }