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.
- package/README.md +213 -0
- package/browser-op/backend/browserd.cjs +1004 -0
- package/browser-op/backend/rpc-client.cjs +64 -0
- package/browser-op/backend/state.cjs +51 -0
- package/browser-op/cdp/capture-inject.js +426 -0
- package/browser-op/cdp/capture-inject.ts +426 -0
- package/browser-op/cdp/capture-service.cjs +172 -0
- package/browser-op/cdp/chrome-launcher.cjs +370 -0
- package/browser-op/cdp/chrome-path.cjs +57 -0
- package/browser-op/cdp/state.cjs +89 -0
- package/browser-op/extension/extension-detect.cjs +228 -0
- package/browser-op/extension/server.cjs +197 -0
- package/browser-op/extension/service.cjs +228 -0
- package/browser-op/extension/state.cjs +78 -0
- package/browser-op/index.cjs +389 -0
- package/browser-op/package.json +17 -0
- package/browser-op/py/behavior.py +138 -0
- package/browser-op/py/browser.py +340 -0
- package/browser-op/py/captcha.py +115 -0
- package/browser-op/py/crawler.py +125 -0
- package/browser-op/py/examples/01_open_and_probe.py +48 -0
- package/browser-op/py/examples/02_reuse_and_probe.py +66 -0
- package/browser-op/py/examples/03_interact.py +66 -0
- package/browser-op/py/find.py +150 -0
- package/browser-op/py/honeypot.py +73 -0
- package/browser-op/py/humanize.py +392 -0
- package/browser-op/py/image.py +186 -0
- package/browser-op/py/interact.py +193 -0
- package/browser-op/py/markdown.py +38 -0
- package/browser-op/py/pyproject.toml +32 -0
- package/browser-op/py/ready.py +208 -0
- package/browser-op/py/scroll.py +180 -0
- package/browser-op/py/upload.py +103 -0
- package/browser-op/py/visual_target.py +47 -0
- package/browser-op/py/visualize.py +91 -0
- package/browser-op/state.cjs +63 -0
- package/browser-op/web/behavior.js +153 -0
- package/browser-op/web/browser.js +231 -0
- package/browser-op/web/captcha.js +85 -0
- package/browser-op/web/crawler.js +109 -0
- package/browser-op/web/find.js +147 -0
- package/browser-op/web/honeypot.js +68 -0
- package/browser-op/web/humanize.js +522 -0
- package/browser-op/web/image.js +177 -0
- package/browser-op/web/interact.js +169 -0
- package/browser-op/web/markdown.js +80 -0
- package/browser-op/web/ready.js +295 -0
- package/browser-op/web/scroll.js +167 -0
- package/browser-op/web/upload.js +116 -0
- package/browser-op/web/visual-runtime.inject.cjs +6 -0
- package/browser-op/webplater/.env.example +7 -0
- package/browser-op/webplater/ARCHITECTURE.md +102 -0
- package/browser-op/webplater/dist/chrome-mv3/assets/popup-BUZEUmsx.css +1 -0
- package/browser-op/webplater/dist/chrome-mv3/background.js +2 -0
- package/browser-op/webplater/dist/chrome-mv3/capture.js +310 -0
- package/browser-op/webplater/dist/chrome-mv3/chunks/_virtual_wxt-html-plugins-DPbbfBKe.js +1 -0
- package/browser-op/webplater/dist/chrome-mv3/chunks/offscreen-CFXYw9Mo.js +1 -0
- package/browser-op/webplater/dist/chrome-mv3/chunks/popup-C-lpxZZO.js +1 -0
- package/browser-op/webplater/dist/chrome-mv3/content-scripts/content.js +7 -0
- package/browser-op/webplater/dist/chrome-mv3/manifest.json +1 -0
- package/browser-op/webplater/dist/chrome-mv3/offscreen.html +16 -0
- package/browser-op/webplater/dist/chrome-mv3/popup.html +31 -0
- package/browser-op/webplater/entrypoints/background.ts +938 -0
- package/browser-op/webplater/entrypoints/content.ts +1150 -0
- package/browser-op/webplater/entrypoints/offscreen/index.html +15 -0
- package/browser-op/webplater/entrypoints/offscreen/main.ts +161 -0
- package/browser-op/webplater/entrypoints/popup/index.html +29 -0
- package/browser-op/webplater/entrypoints/popup/main.ts +61 -0
- package/browser-op/webplater/entrypoints/popup/style.css +100 -0
- package/browser-op/webplater/lib/snapshot.ts +352 -0
- package/browser-op/webplater/package.json +29 -0
- package/browser-op/webplater/pnpm-lock.yaml +3411 -0
- package/browser-op/webplater/public/capture.js +310 -0
- package/browser-op/webplater/scripts/publish-extension.mjs +176 -0
- package/browser-op/webplater/tsconfig.json +19 -0
- package/browser-op/webplater/wxt.config.ts +34 -0
- package/dist/actions.md +102 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +278 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +94 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +277 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +61 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +119 -0
- package/dist/config.js.map +1 -0
- package/dist/protocol.d.ts +195 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +11 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server.d.ts +66 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +259 -0
- package/dist/server.js.map +1 -0
- package/package.json +78 -0
- package/schemas/browser.clearCookies.schema.json +13 -0
- package/schemas/browser.close.schema.json +9 -0
- package/schemas/browser.getCookies.schema.json +13 -0
- package/schemas/browser.getDownload.schema.json +15 -0
- package/schemas/browser.health.schema.json +9 -0
- package/schemas/browser.listDownloads.schema.json +16 -0
- package/schemas/browser.listTabs.schema.json +9 -0
- package/schemas/browser.newTab.schema.json +15 -0
- package/schemas/browser.open.schema.json +15 -0
- package/schemas/browser.operate.schema.json +15 -0
- package/schemas/browser.reuseTab.schema.json +15 -0
- package/schemas/browser.setCookies.schema.json +15 -0
- package/schemas/browser.waitFor.schema.json +15 -0
- package/schemas/browser.waitForDownload.schema.json +15 -0
- package/skills/browser/SKILL.md +110 -0
- package/skills/browser/references/collect.md +163 -0
- package/skills/browser/references/high-risk.md +161 -0
- package/skills/browser/references/operate-actions.md +92 -0
- 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
|
+
}
|