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,1150 @@
1
+ /**
2
+ * Content script — ISOLATED world。
3
+ *
4
+ * 接收 background 转发的页面动作命令,操作当前页 DOM。
5
+ * 全域名注入,不做任何安全/域名过滤(通用插件)。
6
+ *
7
+ * 元素定位两种方式:
8
+ * - CSS selector(click/fill 等)
9
+ * - aria ref(snapshot 返回的 eN,*Ref 动作)
10
+ */
11
+
12
+ import { ariaSnapshot, getElementByRef } from '../lib/snapshot'
13
+
14
+ interface ContentResponse {
15
+ ok: boolean
16
+ error?: string
17
+ [k: string]: unknown
18
+ }
19
+
20
+ function asResponse<T extends ContentResponse>(fn: () => T | Promise<T>): Promise<ContentResponse> {
21
+ return Promise.resolve()
22
+ .then(() => fn())
23
+ .then((r) => r as ContentResponse)
24
+ .catch(
25
+ (err): ContentResponse => ({
26
+ ok: false,
27
+ error: err instanceof Error ? err.message : String(err),
28
+ }),
29
+ )
30
+ }
31
+
32
+ /**
33
+ * 统一元素定位,对齐 playwright locator 语义。
34
+ * selector 支持两种:
35
+ * - "aria-ref=e6" → 从 snapshot 的 ref 映射查元素
36
+ * - 其他(CSS) → document.querySelector
37
+ *
38
+ * 判别:以 "aria-ref=" 开头 → ref;否则按 CSS。
39
+ * 合法 CSS selector 不会以 aria-ref= 开头,互不冲突。
40
+ */
41
+ function requireEl<T extends Element = HTMLElement>(selector: string): T {
42
+ return locateEl(selector) as T
43
+ }
44
+
45
+ /**
46
+ * 统一元素定位,对齐 playwright locator 语义。
47
+ * selector 支持两种定位源 + 后缀索引:
48
+ * 定位源:
49
+ * - "aria-ref=e6" → 从 snapshot 的 ref 映射查元素
50
+ * - 其他(CSS) → querySelectorAll
51
+ * 后缀索引(可附加在定位源后,用 >> 分隔):
52
+ * - ">>first" → 第一个匹配(默认)
53
+ * - ">>last" → 最后一个
54
+ * - ">>nth(3)" → 第 N 个(从 0 开始)
55
+ * 例:".item>>nth(2)" / "aria-ref=e6>>last"
56
+ */
57
+ function locateEl(selector: string): Element {
58
+ const idx = selector.indexOf('>>')
59
+ let base = selector
60
+ let suffix = ''
61
+ if (idx >= 0) {
62
+ base = selector.slice(0, idx)
63
+ suffix = selector.slice(idx + 2).trim().toLowerCase()
64
+ }
65
+
66
+ let el: Element | null
67
+ if (base.startsWith('aria-ref=')) {
68
+ const ref = base.slice('aria-ref='.length).trim()
69
+ el = getElementByRef(ref) ?? null
70
+ if (!el) throw new Error(`stale ref: ${ref} (call snapshot first)`)
71
+ if (!document.contains(el)) throw new Error(`detached ref: ${ref}`)
72
+ // ref 指向唯一元素,后缀索引无意义(原样返回)
73
+ return el
74
+ }
75
+
76
+ // CSS:按后缀索引取元素
77
+ const list = Array.from(document.querySelectorAll(base))
78
+ if (list.length === 0) throw new Error(`element not found: ${base}`)
79
+
80
+ let target: Element
81
+ if (suffix === 'last') {
82
+ target = list[list.length - 1]
83
+ } else if (suffix.startsWith('nth(') && suffix.endsWith(')')) {
84
+ const n = Number(suffix.slice(4, -1))
85
+ if (!Number.isInteger(n)) throw new Error(`invalid nth index: ${suffix}`)
86
+ target = list[n]
87
+ if (!target) throw new Error(`nth(${n}) out of range: ${base} has ${list.length} matches`)
88
+ } else {
89
+ // first 或无后缀:默认第一个
90
+ target = list[0]
91
+ }
92
+ return target
93
+ }
94
+
95
+ /** 统一元素计数(对齐 playwright locator.count()) */
96
+ function locateCount(selector: string): number {
97
+ const idx = selector.indexOf('>>')
98
+ const base = idx >= 0 ? selector.slice(0, idx) : selector
99
+ if (base.startsWith('aria-ref=')) {
100
+ // ref 指向唯一元素,计数恒为 1(存在)或 0(失效)
101
+ const ref = base.slice('aria-ref='.length).trim()
102
+ const el = getElementByRef(ref)
103
+ return el && document.contains(el) ? 1 : 0
104
+ }
105
+ return document.querySelectorAll(base).length
106
+ }
107
+
108
+ // 检测元素是否在当前 viewport 内 (不滚动! 滚动统一由 ScrollController 处理)
109
+ // 不在视口返回 false, 调用方应抛 ELEMENT_OUT_OF_VIEWPORT 让 browser 技能滚动
110
+ function inViewport(el: Element): boolean {
111
+ const r = (el as HTMLElement).getBoundingClientRect()
112
+ const vh = window.innerHeight || document.documentElement.clientHeight
113
+ const vw = window.innerWidth || document.documentElement.clientWidth
114
+ // 容差: 元素中心在视口内即可 (允许边缘略出)
115
+ const cx = r.left + r.width / 2
116
+ const cy = r.top + r.height / 2
117
+ return cy >= 0 && cy <= vh && cx >= 0 && cx <= vw
118
+ }
119
+
120
+ // 废弃: 原 intoView 直接 scrollIntoView 造成跳变。保留空函数防意外调用, 但不滚动
121
+ function intoView(el: Element): void {
122
+ // no-op: 滚动统一由 ScrollController 处理。插件不再自己滚动页面
123
+ }
124
+
125
+ /** 当前页面状态 */
126
+ function status(): ContentResponse {
127
+ return {
128
+ ok: true,
129
+ url: location.href,
130
+ title: document.title,
131
+ readyState: document.readyState,
132
+ textLength: document.body?.innerText?.length ?? 0,
133
+ }
134
+ }
135
+
136
+ // ── L2 输入原语:对齐 playwright page.mouse / page.keyboard ──
137
+ // 不绑 DOM 元素的坐标级/按键级事件序列。click/hover/fill/type/press 等高层动作基于它们实现,
138
+ // 使 behavior 模块的拟人轨迹/打字节奏能通过 operate 的高层动作自然注入,而非定向补 action。
139
+
140
+ const MOUSE_BUTTON_TO_NUM: Record<string, number> = { left: 0, middle: 1, right: 2 }
141
+
142
+ function sleep(ms: number): Promise<void> {
143
+ return new Promise((r) => setTimeout(r, ms))
144
+ }
145
+
146
+ function buttonNum(button: string): number {
147
+ return MOUSE_BUTTON_TO_NUM[button] ?? 0
148
+ }
149
+
150
+ function pointerInit(x: number, y: number, button: number, buttons: number) {
151
+ return {
152
+ bubbles: true,
153
+ cancelable: true,
154
+ composed: true,
155
+ clientX: x,
156
+ clientY: y,
157
+ button,
158
+ buttons,
159
+ pointerType: 'mouse',
160
+ isPrimary: true,
161
+ view: window,
162
+ }
163
+ }
164
+
165
+ function targetAt(x: number, y: number): Element {
166
+ return document.elementFromPoint(x, y) || document.body
167
+ }
168
+
169
+ function centerOf(el: Element): { x: number; y: number } {
170
+ const r = el.getBoundingClientRect()
171
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
172
+ }
173
+
174
+ /** 纯移动:pointermove + mousemove(behavior 轨迹多点序列用) */
175
+ function mouseMove(x: number, y: number): void {
176
+ const init = pointerInit(x, y, 0, 0)
177
+ const t = targetAt(x, y)
178
+ t.dispatchEvent(new PointerEvent('pointermove', init))
179
+ t.dispatchEvent(new MouseEvent('mousemove', init))
180
+ }
181
+
182
+ /** 悬停进入:over + enter + move(hover 高层用,元素保持 hover 状态不出) */
183
+ function mouseHover(x: number, y: number): void {
184
+ const init = pointerInit(x, y, 0, 0)
185
+ const t = targetAt(x, y)
186
+ t.dispatchEvent(new PointerEvent('pointerover', init))
187
+ t.dispatchEvent(new MouseEvent('mouseover', init))
188
+ t.dispatchEvent(new PointerEvent('pointerenter', init))
189
+ t.dispatchEvent(new MouseEvent('mouseenter', init))
190
+ t.dispatchEvent(new PointerEvent('pointermove', init))
191
+ t.dispatchEvent(new MouseEvent('mousemove', init))
192
+ }
193
+
194
+ /** 按下:over + enter + down(进入元素后按下) */
195
+ function mouseDown(x: number, y: number, button: string): void {
196
+ const btn = buttonNum(button)
197
+ const init = pointerInit(x, y, btn, 1 << btn)
198
+ const t = targetAt(x, y)
199
+ t.dispatchEvent(new PointerEvent('pointerover', init))
200
+ t.dispatchEvent(new MouseEvent('mouseover', init))
201
+ t.dispatchEvent(new PointerEvent('pointerenter', init))
202
+ t.dispatchEvent(new MouseEvent('mouseenter', init))
203
+ t.dispatchEvent(new PointerEvent('pointerdown', init))
204
+ t.dispatchEvent(new MouseEvent('mousedown', init))
205
+ ;(t as HTMLElement).focus?.()
206
+ }
207
+
208
+ /** 抬起:up + click + out + leave(释放并离开) */
209
+ function mouseUp(x: number, y: number, button: string): void {
210
+ const btn = buttonNum(button)
211
+ const pressed = 1 << btn
212
+ const t = targetAt(x, y)
213
+ const downInit = pointerInit(x, y, btn, pressed)
214
+ const releaseInit = pointerInit(x, y, btn, 0)
215
+ t.dispatchEvent(new PointerEvent('pointerup', downInit))
216
+ t.dispatchEvent(new MouseEvent('mouseup', downInit))
217
+ t.dispatchEvent(new MouseEvent('click', downInit))
218
+ t.dispatchEvent(new PointerEvent('pointerout', releaseInit))
219
+ t.dispatchEvent(new MouseEvent('mouseout', releaseInit))
220
+ t.dispatchEvent(new PointerEvent('pointerleave', releaseInit))
221
+ t.dispatchEvent(new MouseEvent('mouseleave', releaseInit))
222
+ }
223
+
224
+ /** 坐标点击:down [delay] up,可重复实现多击(dblclick) */
225
+ async function mouseClick(
226
+ x: number,
227
+ y: number,
228
+ opts: { button?: string; delay?: number; clickCount?: number } = {},
229
+ ): Promise<void> {
230
+ const button = opts.button ?? 'left'
231
+ const delay = opts.delay ?? 0
232
+ const count = opts.clickCount ?? 1
233
+ for (let i = 0; i < count; i++) {
234
+ mouseDown(x, y, button)
235
+ if (delay > 0) await sleep(delay)
236
+ mouseUp(x, y, button)
237
+ if (i < count - 1 && delay > 0) await sleep(delay)
238
+ }
239
+ // 多击: dispatch 原生 dblclick 事件(很多库如 jQuery 靠 dblclick 或 click.detail===2 判定)
240
+ if (count >= 2) {
241
+ const btn = buttonNum(button)
242
+ const detail = count
243
+ // 补发带 detail 的 click,并 dispatch dblclick
244
+ const t = targetAt(x, y)
245
+ const init = { bubbles: true, cancelable: true, clientX: x, clientY: y, button: btn, buttons: 0, detail, view: window }
246
+ t.dispatchEvent(new MouseEvent('dblclick', init))
247
+ }
248
+ }
249
+
250
+ /** 当前接收键盘事件的元素(activeElement 兜底 body) */
251
+ function keyTarget(): HTMLElement {
252
+ return (document.activeElement as HTMLElement) || document.body
253
+ }
254
+
255
+ /** 向可输入元素插入一个字符(input/textarea 用原生 setter,contenteditable 用 execCommand) */
256
+ function insertChar(el: HTMLElement, ch: string): void {
257
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
258
+ const proto =
259
+ el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
260
+ Object.getOwnPropertyDescriptor(proto, 'value')?.set?.call(el, el.value + ch)
261
+ } else if (el.isContentEditable) {
262
+ try {
263
+ document.execCommand('insertText', false, ch)
264
+ } catch {
265
+ // ignore
266
+ }
267
+ }
268
+ }
269
+
270
+ /** 按键 down:keydown + keypress */
271
+ function keyboardDown(key: string): void {
272
+ const t = keyTarget()
273
+ t.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
274
+ t.dispatchEvent(new KeyboardEvent('keypress', { key, bubbles: true, cancelable: true }))
275
+ }
276
+
277
+ /** 按键 up */
278
+ function keyboardUp(key: string): void {
279
+ keyTarget().dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true }))
280
+ }
281
+
282
+ /** 单次按键:down + up */
283
+ async function keyboardPress(key: string): Promise<void> {
284
+ keyboardDown(key)
285
+ await sleep(0)
286
+ keyboardUp(key)
287
+ }
288
+
289
+ /** 向 activeElement 逐字符输入(模拟人类打字,可选延迟) */
290
+ async function keyboardType(text: string, delay = 50): Promise<void> {
291
+ const t = keyTarget()
292
+ if (t && typeof t.focus === 'function') t.focus()
293
+ for (const ch of text) {
294
+ const target = keyTarget()
295
+ target.dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true, cancelable: true }))
296
+ target.dispatchEvent(new KeyboardEvent('keypress', { key: ch, bubbles: true, cancelable: true }))
297
+ insertChar(target, ch)
298
+ target.dispatchEvent(
299
+ new InputEvent('input', {
300
+ bubbles: true,
301
+ cancelable: true,
302
+ data: ch,
303
+ inputType: 'insertText',
304
+ }),
305
+ )
306
+ target.dispatchEvent(new KeyboardEvent('keyup', { key: ch, bubbles: true, cancelable: true }))
307
+ if (delay > 0) await sleep(delay)
308
+ }
309
+ }
310
+
311
+ /** 点击 */
312
+ async function click(selector: string, x?: number, y?: number): ContentResponse {
313
+ // 有坐标则直接坐标点击(对齐 playwright page.mouse.click(x,y));否则定位元素取中心。
314
+ // 这样 behavior 的坐标点击与按 selector 点击走同一套 mouse 原语。
315
+ let cx: number
316
+ let cy: number
317
+ if (x != null && y != null) {
318
+ cx = Number(x)
319
+ cy = Number(y)
320
+ } else {
321
+ const el = requireEl<HTMLElement>(selector)
322
+ if (!inViewport(el)) throw new Error('ELEMENT_OUT_OF_VIEWPORT: ' + selector + ' (scroll via ScrollController first)')
323
+ const c = centerOf(el)
324
+ cx = c.x
325
+ cy = c.y
326
+ }
327
+ await mouseClick(cx, cy, { button: 'left' })
328
+ return { ok: true, selector, x: cx, y: cy }
329
+ }
330
+
331
+ /** 填充输入框(覆盖原值) */
332
+ function fill(selector: string, value: string): ContentResponse {
333
+ const el = requireEl<HTMLInputElement | HTMLTextAreaElement>(selector)
334
+ el.focus()
335
+ const proto =
336
+ el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
337
+ Object.getOwnPropertyDescriptor(proto, 'value')?.set?.call(el, value)
338
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' }))
339
+ el.dispatchEvent(new Event('change', { bubbles: true }))
340
+ return { ok: true, selector, value }
341
+ }
342
+
343
+ /** 逐字符输入(模拟人类打字,带可选延迟 ms) */
344
+ async function type(selector: string, value: string, delay = 50): ContentResponse {
345
+ const el = requireEl<HTMLElement>(selector)
346
+ // 可输入元素 focus 后输入;非输入元素(如 body)向当前 activeElement 输入,
347
+ // 支持 behavior.typeText 的“无目标纯打字”场景。
348
+ const isInput =
349
+ el instanceof HTMLInputElement ||
350
+ el instanceof HTMLTextAreaElement ||
351
+ el.isContentEditable
352
+ if (isInput) {
353
+ el.focus()
354
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) el.value = ''
355
+ }
356
+ await keyboardType(value, delay)
357
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
358
+ el.dispatchEvent(new Event('change', { bubbles: true }))
359
+ }
360
+ return { ok: true, selector, value }
361
+ }
362
+
363
+ /** 在元素上按键(Enter/Escape/Tab 等) */
364
+ async function press(selector: string, key: string): ContentResponse {
365
+ const el = requireEl<HTMLElement>(selector)
366
+ el.focus()
367
+ await keyboardPress(key)
368
+ // Enter 提交表单容错
369
+ if (key === 'Enter' && el instanceof HTMLInputElement) {
370
+ const form = el.form
371
+ if (form) form.requestSubmit?.()
372
+ }
373
+ return { ok: true, selector, key }
374
+ }
375
+
376
+ /** 悬停(触发 hover 进入事件 + mousemove) */
377
+ function hover(selector: string, x?: number, y?: number): ContentResponse {
378
+ let cx: number
379
+ let cy: number
380
+ if (x != null && y != null) {
381
+ cx = Number(x)
382
+ cy = Number(y)
383
+ } else {
384
+ const el = requireEl<HTMLElement>(selector)
385
+ if (!inViewport(el)) throw new Error('ELEMENT_OUT_OF_VIEWPORT: ' + selector + ' (scroll via ScrollController first)')
386
+ const c = centerOf(el)
387
+ cx = c.x
388
+ cy = c.y
389
+ }
390
+ mouseHover(cx, cy)
391
+ return { ok: true, selector, x: cx, y: cy }
392
+ }
393
+
394
+ /** 聚焦元素 */
395
+ function focus(selector: string): ContentResponse {
396
+ const el = requireEl<HTMLElement>(selector)
397
+ if (!inViewport(el)) throw new Error('ELEMENT_OUT_OF_VIEWPORT: ' + selector + ' (scroll via ScrollController first)')
398
+ el.focus()
399
+ return { ok: true, selector }
400
+ }
401
+
402
+ /** 纯移动到坐标(不点击):behavior 拟人轨迹多点序列注入用 */
403
+ function mouseMoveTo(x: number, y: number): ContentResponse {
404
+ mouseMove(Number(x), Number(y))
405
+ return { ok: true, x: Number(x), y: Number(y) }
406
+ }
407
+
408
+ /** 勾选复选框 / 单选框 */
409
+ function check(selector: string, checked = true): ContentResponse {
410
+ const el = requireEl<HTMLInputElement>(selector)
411
+ if (el.checked !== checked) {
412
+ el.checked = checked
413
+ el.dispatchEvent(new Event('input', { bubbles: true }))
414
+ el.dispatchEvent(new Event('change', { bubbles: true }))
415
+ }
416
+ return { ok: true, selector, checked: el.checked }
417
+ }
418
+
419
+ /** 取消勾选 */
420
+ function uncheck(selector: string): ContentResponse {
421
+ return check(selector, false)
422
+ }
423
+
424
+ /** 下拉选择(支持单值字符串) */
425
+ function selectOption(selector: string, value: string): ContentResponse {
426
+ const el = requireEl<HTMLSelectElement>(selector)
427
+ const matched = Array.from(el.options).find((o) => o.value === value || o.text === value)
428
+ if (!matched) throw new Error(`selectOption: option not found for value="${value}"`)
429
+ el.value = matched.value
430
+ el.dispatchEvent(new Event('input', { bubbles: true }))
431
+ el.dispatchEvent(new Event('change', { bubbles: true }))
432
+ return { ok: true, selector, value: el.value }
433
+ }
434
+
435
+ /** 等待选择器出现(轮询,默认超时 30s) */
436
+ async function waitForSelector(selector: string, timeout = 30000): ContentResponse {
437
+ const deadline = Date.now() + timeout
438
+ while (Date.now() < deadline) {
439
+ const el = document.querySelector(selector)
440
+ if (el) {
441
+ // 不滚动 (滚动统一由 ScrollController)。返回是否在视口
442
+ return { ok: true, selector, visible: true, inViewport: inViewport(el) }
443
+ }
444
+ await new Promise((r) => setTimeout(r, 100))
445
+ }
446
+ throw new Error(`waitForSelector timeout (${timeout}ms): ${selector}`)
447
+ }
448
+
449
+ // ── 读取类(对齐 playwright 的 innerHTML/innerText/textContent 等)──
450
+
451
+ /** 读取元素 innerHTML */
452
+ function innerHTML(selector: string): ContentResponse {
453
+ const el = requireEl<HTMLElement>(selector)
454
+ return { ok: true, selector, value: el.innerHTML }
455
+ }
456
+
457
+ /** 读取元素 innerText */
458
+ function innerText(selector: string): ContentResponse {
459
+ const el = requireEl<HTMLElement>(selector)
460
+ return { ok: true, selector, value: el.innerText }
461
+ }
462
+
463
+ /** 读取元素 textContent */
464
+ function textContent(selector: string): ContentResponse {
465
+ const el = requireEl<HTMLElement>(selector)
466
+ return { ok: true, selector, value: el.textContent ?? '' }
467
+ }
468
+
469
+ /** 读取元素属性 */
470
+ function getAttribute(selector: string, name: string): ContentResponse {
471
+ const el = requireEl<HTMLElement>(selector)
472
+ return { ok: true, selector, name, value: el.getAttribute(name) }
473
+ }
474
+
475
+ /** 读取输入框当前值 */
476
+ function inputValue(selector: string): ContentResponse {
477
+ const el = requireEl<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(selector)
478
+ return { ok: true, selector, value: el.value }
479
+ }
480
+
481
+ /** 读取元素包围盒(位置 + 尺寸) */
482
+ function boundingBox(selector: string): ContentResponse {
483
+ const el = requireEl<HTMLElement>(selector)
484
+ const rect = el.getBoundingClientRect()
485
+ return {
486
+ ok: true,
487
+ selector,
488
+ x: Math.round(rect.x),
489
+ y: Math.round(rect.y),
490
+ width: Math.round(rect.width),
491
+ height: Math.round(rect.height),
492
+ }
493
+ }
494
+
495
+ /** 元素计数(配合 selector 后缀语义:count 忽略后缀,返回匹配总数) */
496
+ function count(selector: string): ContentResponse {
497
+ return { ok: true, selector, count: locateCount(selector) }
498
+ }
499
+
500
+ /**
501
+ * 文件上传。files 是 base64 编码的字节流数组。
502
+ * 构造 File 对象赋予 <input type=file> 的 files 属性,触发 change。
503
+ * 支持多文件。
504
+ *
505
+ * 注意:HTMLInputElement.files 是只读的,直接赋值在受控组件上会被忽略。
506
+ * 这里用 Object.getOwnPropertyDescriptor 拿到原生 setter 强制写入,
507
+ * 这是 playwright 等所有扩展上传库的标准做法。
508
+ */
509
+ function setInputFiles(selector: string, files: Array<{ name: string; mime: string; data: string; webkitRelativePath?: string }>): ContentResponse {
510
+ const el = requireEl<HTMLInputElement>(selector)
511
+ if (el.tagName !== 'INPUT' || el.type !== 'file') {
512
+ throw new Error(`not a file input: ${selector} (tag=${el.tagName} type=${el.type})`)
513
+ }
514
+
515
+ const dt = new DataTransfer()
516
+ const builtNames: string[] = []
517
+ let totalBytes = 0
518
+ for (const f of files) {
519
+ // base64 → bytes(兼容 data: 前缀,但协议要求纯 base64)
520
+ const b64 = f.data.split(',').pop() ?? f.data
521
+ const bin = atob(b64)
522
+ const bytes = new Uint8Array(bin.length)
523
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
524
+ totalBytes += bytes.length
525
+ const file = new File([bytes], f.name, { type: f.mime || 'application/octet-stream' })
526
+ // 文件夹上传(webkitdirectory input):File.webkitRelativePath 只读,强设以表达目录结构
527
+ if (f.webkitRelativePath) {
528
+ try {
529
+ Object.defineProperty(file, 'webkitRelativePath', {
530
+ configurable: true,
531
+ value: f.webkitRelativePath,
532
+ })
533
+ } catch {
534
+ // 部分浏览器不允许覆盖,忽略(文件夹场景可能失效,但不阻塞文件上传)
535
+ }
536
+ }
537
+ dt.items.add(file)
538
+ builtNames.push(f.name)
539
+ }
540
+
541
+ // DataTransfer 本身的文件数(不受 input 状态影响,是“构造成功”的真相)
542
+ const builtCount = dt.files.length
543
+
544
+ // 强制写入 files(绕过只读限制)
545
+ const proto = Object.getPrototypeOf(el)
546
+ const descriptor = Object.getOwnPropertyDescriptor(proto, 'files')
547
+ if (descriptor?.set) {
548
+ descriptor.set.call(el, dt.files)
549
+ } else {
550
+ el.files = dt.files
551
+ }
552
+
553
+ el.dispatchEvent(new Event('input', { bubbles: true }))
554
+ el.dispatchEvent(new Event('change', { bubbles: true }))
555
+
556
+ // 同步读取赋值后、事件后的 input.files(用于诊断)
557
+ const onInput = el.files?.length ?? 0
558
+ const onInputNames = el.files ? Array.from(el.files).map((f) => f.name) : []
559
+
560
+ return {
561
+ ok: builtCount > 0,
562
+ selector,
563
+ requested: files.length,
564
+ built: builtCount, // 成功构造进 DataTransfer 的文件数(必须 == requested)
565
+ onInput, // 赋值+事件后 input.files 里的文件数(可能被框架清空)
566
+ names: builtNames,
567
+ onInputNames,
568
+ totalBytes,
569
+ note:
570
+ onInput === 0 && builtCount > 0
571
+ ? 'files constructed and set, but input.files now empty — 框架可能已接收并清空,看页面 UI 是否出现文件'
572
+ : undefined,
573
+ }
574
+ }
575
+
576
+ // ── localStorage 读写 ──
577
+
578
+ function getLocalStorage(keys?: string[]): ContentResponse {
579
+ const result: Record<string, string | null> = {}
580
+ const targetKeys = keys && keys.length > 0 ? keys : Object.keys(localStorage)
581
+ for (const k of targetKeys) result[k] = localStorage.getItem(k)
582
+ return { ok: true, storage: result }
583
+ }
584
+
585
+ function setLocalStorage(items: Record<string, string>): ContentResponse {
586
+ let n = 0
587
+ for (const [k, v] of Object.entries(items)) {
588
+ localStorage.setItem(k, v)
589
+ n++
590
+ }
591
+ return { ok: true, count: n }
592
+ }
593
+
594
+ function removeLocalStorage(keys?: string[]): ContentResponse {
595
+ const targetKeys = keys && keys.length > 0 ? keys : Object.keys(localStorage)
596
+ for (const k of targetKeys) localStorage.removeItem(k)
597
+ return { ok: true, count: targetKeys.length }
598
+ }
599
+
600
+ function clearLocalStorage(): ContentResponse {
601
+ localStorage.clear()
602
+ return { ok: true }
603
+ }
604
+
605
+ /** 双击 */
606
+ async function dblclick(selector: string): ContentResponse {
607
+ const el = requireEl<HTMLElement>(selector)
608
+ if (!inViewport(el)) throw new Error('ELEMENT_OUT_OF_VIEWPORT: ' + selector + ' (scroll via ScrollController first)')
609
+ const { x, y } = centerOf(el)
610
+ await mouseClick(x, y, { clickCount: 2 })
611
+ return { ok: true, selector }
612
+ }
613
+
614
+ /** 拖拽:从源选择器拖到目标选择器。
615
+ * 两种实现叠加,兼容两类站点:
616
+ * 1) mouse 事件序列 (mousedown/mousemove/mouseup) —— jQuery UI draggable/droppable
617
+ * 这类监听 mouse 事件 (非原生 HTML5 drag)
618
+ * 2) HTML5 drag 事件 dispatch —— 原生 drag/drop 监听者
619
+ */
620
+ function dragTo(sourceSelector: string, targetSelector: string): ContentResponse {
621
+ const source = requireEl<HTMLElement>(sourceSelector)
622
+ const target = requireEl<HTMLElement>(targetSelector)
623
+ // dragTo 不检测视口: 拖拽可能跨视口, 滚动由 browser 技能在调用前用 ScrollController 处理
624
+ const s = centerOf(source)
625
+ const t = centerOf(target)
626
+
627
+ // 1) mouse 序列:source 上 down,分步 move 到 target,最后 up
628
+ mouseDown(s.x, s.y, 'left')
629
+ const steps = 10
630
+ for (let i = 1; i <= steps; i++) {
631
+ const mx = s.x + (t.x - s.x) * i / steps
632
+ const my = s.y + (t.y - s.y) * i / steps
633
+ mouseMove(mx, my)
634
+ }
635
+ mouseUp(t.x, t.y, 'left')
636
+
637
+ // 2) HTML5 drag 事件 dispatch (兼容原生 drag 监听者)
638
+ const dataTransfer = new DataTransfer()
639
+ for (const type of ['dragstart', 'drag', 'dragenter', 'dragover', 'drop', 'dragend'] as const) {
640
+ const evt = new DragEvent(type, {
641
+ bubbles: true,
642
+ cancelable: true,
643
+ dataTransfer,
644
+ clientX: 0,
645
+ clientY: 0,
646
+ })
647
+ ;(type === 'drop' || type === 'dragover' || type === 'dragenter' ? target : source).dispatchEvent(evt)
648
+ }
649
+ return { ok: true, source: sourceSelector, target: targetSelector }
650
+ }
651
+
652
+ // ── 等待类(对齐 playwright 的 waitForFunction / waitForURL / waitForTimeout)──
653
+
654
+ /** 等待自定义条件(函数源码在 content 内执行,返回 truthy 即完成) */
655
+ async function waitForFunction(source: string, timeout = 30000, args?: unknown): ContentResponse {
656
+ if (!source) throw new Error('waitForFunction requires source')
657
+ // eslint-disable-next-line no-eval
658
+ const fn = (0, eval)(`(${source})`) as (args: unknown) => unknown
659
+ const deadline = Date.now() + timeout
660
+ while (Date.now() < deadline) {
661
+ let truthy = false
662
+ let value: unknown
663
+ try {
664
+ value = fn(args)
665
+ truthy = !!value
666
+ } catch {
667
+ // 条件报错继续等
668
+ }
669
+ if (truthy) return { ok: true, source, value }
670
+ await new Promise((r) => setTimeout(r, 100))
671
+ }
672
+ throw new Error(`waitForFunction timeout (${timeout}ms)`)
673
+ }
674
+
675
+ /** 等待当前页 URL 匹配(字符串包含或正则) */
676
+ async function waitForURL(urlMatch: string, timeout = 30000): ContentResponse {
677
+ const deadline = Date.now() + timeout
678
+ let re: RegExp | null = null
679
+ try {
680
+ re = new RegExp(urlMatch)
681
+ } catch {
682
+ re = null // 不是合法正则,当作字符串包含匹配
683
+ }
684
+ while (Date.now() < deadline) {
685
+ const href = location.href
686
+ if ((re && re.test(href)) || (!re && href.includes(urlMatch))) {
687
+ return { ok: true, url: href }
688
+ }
689
+ await new Promise((r) => setTimeout(r, 100))
690
+ }
691
+ throw new Error(`waitForURL timeout (${timeout}ms): ${urlMatch}`)
692
+ }
693
+
694
+ /** 等待固定时长 */
695
+ async function waitForTimeout(ms: number): ContentResponse {
696
+ await new Promise((r) => setTimeout(r, Math.min(ms, 120000)))
697
+ return { ok: true, ms }
698
+ }
699
+
700
+ // ── dialog 自动处理(页面级 window.alert/confirm/prompt 拦截)──
701
+
702
+ /** 全局 dialog 处理策略。不调用本方法时,页面默认弹窗行为不变。 */
703
+ let dialogHandler: ((type: string, message: string) => 'accept' | 'dismiss') | null = null
704
+
705
+ function setDialogHandler(handlerSource: string | null): ContentResponse {
706
+ if (!handlerSource) {
707
+ dialogHandler = null
708
+ return { ok: true, enabled: false }
709
+ }
710
+ // eslint-disable-next-line no-eval
711
+ dialogHandler = (0, eval)(`(${handlerSource})`) as (
712
+ type: string,
713
+ message: string,
714
+ ) => 'accept' | 'dismiss'
715
+ return { ok: true, enabled: true }
716
+ }
717
+
718
+ // content 加载时接管 window 的三个对话框方法
719
+ ;['alert', 'confirm', 'prompt'].forEach((m) => {
720
+ const original = (window as unknown as Record<string, unknown>)[m] as Function
721
+ Object.defineProperty(window, m, {
722
+ configurable: true,
723
+ value(message?: unknown) {
724
+ if (dialogHandler) {
725
+ const action = dialogHandler(m, String(message ?? ''))
726
+ if (action === 'dismiss') return m === 'prompt' ? null : false
727
+ }
728
+ // 默认 accept:调用原生行为
729
+ return m === 'alert' ? undefined : m === 'confirm' ? true : ''
730
+ void original
731
+ },
732
+ })
733
+ })
734
+
735
+ // ── aria snapshot(playwright ariaSnapshot 兼容)──
736
+
737
+ /** 默认每块字符数 */
738
+ const SNAPSHOT_CHUNK_SIZE = 10000
739
+
740
+ /**
741
+ * snapshot 缓存:同一份参数(rootSelector+depth+interactiveOnly)只扫一次,
742
+ * 分块共享同一份 ref 映射,保证 ref 在所有块中稳定一致。
743
+ * 页面变化后调用方传 refresh=true 重扫。
744
+ */
745
+ let snapshotCache: {
746
+ key: string
747
+ yaml: string
748
+ lines: string[]
749
+ chunkSize: number
750
+ chunkCount: number
751
+ } | null = null
752
+
753
+ function makeCacheKey(opts: {
754
+ rootSelector?: string
755
+ depth?: number
756
+ interactiveOnly?: boolean
757
+ }): string {
758
+ return [opts.rootSelector ?? '', String(opts.depth ?? ''), String(opts.interactiveOnly ?? false)].join('|')
759
+ }
760
+
761
+ /** 按行 + 字数分块。块边界落在行边界,不切断行。 */
762
+ function chunkLines(lines: string[], chunkSize: number): string[][] {
763
+ const chunks: string[][] = []
764
+ let cur: string[] = []
765
+ let curLen = 0
766
+ for (const line of lines) {
767
+ if (curLen + line.length > chunkSize && cur.length > 0) {
768
+ chunks.push(cur)
769
+ cur = []
770
+ curLen = 0
771
+ }
772
+ cur.push(line)
773
+ curLen += line.length + 1 // +1 for newline
774
+ }
775
+ if (cur.length > 0) chunks.push(cur)
776
+ return chunks
777
+ }
778
+
779
+ function snapshot(opts: {
780
+ rootSelector?: string
781
+ depth?: number
782
+ interactiveOnly?: boolean
783
+ chunkId?: number
784
+ chunkSize?: number
785
+ refresh?: boolean
786
+ }): ContentResponse {
787
+ const key = makeCacheKey(opts)
788
+ const chunkSize = opts.chunkSize && opts.chunkSize > 0 ? opts.chunkSize : SNAPSHOT_CHUNK_SIZE
789
+
790
+ // 缓存未命中或强制刷新 → 重扫
791
+ if (!snapshotCache || snapshotCache.key !== key || snapshotCache.chunkSize !== chunkSize || opts.refresh) {
792
+ const result = ariaSnapshot({
793
+ rootSelector: opts.rootSelector,
794
+ depth: opts.depth,
795
+ interactiveOnly: opts.interactiveOnly,
796
+ })
797
+ const lines = result.yaml.length > 0 ? result.yaml.split('\n') : []
798
+ const chunks = chunkLines(lines, chunkSize)
799
+ snapshotCache = {
800
+ key,
801
+ yaml: result.yaml,
802
+ lines,
803
+ chunkSize,
804
+ chunkCount: chunks.length,
805
+ }
806
+ // 缓存分块内容到闭包变量(供取块用)
807
+ snapshotChunks = chunks
808
+ }
809
+
810
+ const cache = snapshotCache!
811
+ const id = opts.chunkId ?? 0
812
+ const chunk = snapshotChunks[id]
813
+ const yaml = chunk ? chunk.join('\n') : ''
814
+ const hasMore = id + 1 < cache.chunkCount
815
+
816
+ return {
817
+ ok: true,
818
+ yaml,
819
+ chunkId: id,
820
+ chunkCount: cache.chunkCount,
821
+ chunkSize: cache.chunkSize,
822
+ hasMore,
823
+ totalChars: cache.yaml.length,
824
+ hint: hasMore ? `还有 ${cache.chunkCount - id - 1} 块,用 snapshot({chunkId: ${id + 1}}) 续取` : undefined,
825
+ }
826
+ }
827
+
828
+ /** 分块缓存(snapshot 扫描后填充) */
829
+ let snapshotChunks: string[][] = []
830
+
831
+ export default defineContentScript({
832
+ matches: ['<all_urls>'],
833
+ allFrames: true,
834
+ runAt: 'document_idle',
835
+ main() {
836
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
837
+ if (message?.source !== 'bee-plugin') return
838
+
839
+ const method = message.method as string
840
+ const params = (message.params ?? {}) as Record<string, unknown>
841
+
842
+ let p: Promise<ContentResponse>
843
+ switch (method) {
844
+ // ping: 只证明 listener 已注册, 不碰复杂 DOM, 不抛业务错。
845
+ // background 用它判 content script 是否就绪 (不就绪则注入后重试)。
846
+ case 'ping':
847
+ p = Promise.resolve({ ok: true, route: 'extension-content', url: location.href, readyState: document.readyState })
848
+ break
849
+ case 'status':
850
+ p = asResponse(() => status())
851
+ break
852
+ case 'click':
853
+ p = asResponse(() =>
854
+ click(
855
+ String(params.selector ?? ''),
856
+ typeof params.x === 'number' ? params.x : undefined,
857
+ typeof params.y === 'number' ? params.y : undefined,
858
+ ),
859
+ )
860
+ break
861
+ case 'fill':
862
+ p = asResponse(() => fill(String(params.selector ?? ''), String(params.value ?? '')))
863
+ break
864
+ case 'type':
865
+ p = asResponse(() =>
866
+ type(String(params.selector ?? ''), String(params.value ?? ''), Number(params.delay ?? 50)),
867
+ )
868
+ break
869
+ case 'press':
870
+ p = asResponse(() => press(String(params.selector ?? ''), String(params.key ?? '')))
871
+ break
872
+ case 'hover':
873
+ p = asResponse(() =>
874
+ hover(
875
+ String(params.selector ?? ''),
876
+ typeof params.x === 'number' ? params.x : undefined,
877
+ typeof params.y === 'number' ? params.y : undefined,
878
+ ),
879
+ )
880
+ break
881
+ case 'focus':
882
+ p = asResponse(() => focus(String(params.selector ?? '')))
883
+ break
884
+ case 'mouseMove':
885
+ p = asResponse(() => mouseMoveTo(Number(params.x ?? 0), Number(params.y ?? 0)))
886
+ break
887
+ case 'check':
888
+ p = asResponse(() => check(String(params.selector ?? '')))
889
+ break
890
+ case 'uncheck':
891
+ p = asResponse(() => uncheck(String(params.selector ?? '')))
892
+ break
893
+ case 'selectOption':
894
+ p = asResponse(() =>
895
+ selectOption(String(params.selector ?? ''), String(params.value ?? '')),
896
+ )
897
+ break
898
+ case 'waitForSelector':
899
+ p = asResponse(() =>
900
+ waitForSelector(String(params.selector ?? ''), Number(params.timeout ?? 30000)),
901
+ )
902
+ break
903
+ // 读取类
904
+ case 'innerHTML':
905
+ p = asResponse(() => innerHTML(String(params.selector ?? '')))
906
+ break
907
+ case 'innerText':
908
+ p = asResponse(() => innerText(String(params.selector ?? '')))
909
+ break
910
+ case 'textContent':
911
+ p = asResponse(() => textContent(String(params.selector ?? '')))
912
+ break
913
+ case 'getAttribute':
914
+ p = asResponse(() =>
915
+ getAttribute(String(params.selector ?? ''), String(params.name ?? '')),
916
+ )
917
+ break
918
+ case 'inputValue':
919
+ p = asResponse(() => inputValue(String(params.selector ?? '')))
920
+ break
921
+ case 'boundingBox':
922
+ p = asResponse(() => boundingBox(String(params.selector ?? '')))
923
+ break
924
+ // 元素计数
925
+ case 'count':
926
+ p = asResponse(() => count(String(params.selector ?? '')))
927
+ break
928
+ // 文件上传
929
+ case 'setInputFiles':
930
+ p = asResponse(() =>
931
+ setInputFiles(
932
+ String(params.selector ?? ''),
933
+ Array.isArray(params.files) ? (params.files as Array<Record<string, string>>) : [],
934
+ ),
935
+ )
936
+ break
937
+ // localStorage
938
+ case 'getLocalStorage':
939
+ p = asResponse(() =>
940
+ getLocalStorage(Array.isArray(params.keys) ? (params.keys as string[]) : undefined),
941
+ )
942
+ break
943
+ case 'setLocalStorage':
944
+ p = asResponse(() =>
945
+ setLocalStorage((params.items ?? {}) as Record<string, string>),
946
+ )
947
+ break
948
+ case 'removeLocalStorage':
949
+ p = asResponse(() =>
950
+ removeLocalStorage(Array.isArray(params.keys) ? (params.keys as string[]) : undefined),
951
+ )
952
+ break
953
+ case 'clearLocalStorage':
954
+ p = asResponse(() => clearLocalStorage())
955
+ break
956
+ // 交互进阶
957
+ case 'dblclick':
958
+ p = asResponse(() => dblclick(String(params.selector ?? '')))
959
+ break
960
+ case 'dragTo':
961
+ p = asResponse(() =>
962
+ dragTo(String(params.source ?? ''), String(params.target ?? '')),
963
+ )
964
+ break
965
+ // 等待类
966
+ case 'waitForFunction':
967
+ p = asResponse(() =>
968
+ waitForFunction(
969
+ String(params.source ?? ''),
970
+ Number(params.timeout ?? 30000),
971
+ params.args,
972
+ ),
973
+ )
974
+ break
975
+ case 'waitForURL':
976
+ p = asResponse(() =>
977
+ waitForURL(String(params.url ?? ''), Number(params.timeout ?? 30000)),
978
+ )
979
+ break
980
+ case 'waitForTimeout':
981
+ p = asResponse(() => waitForTimeout(Number(params.ms ?? 1000)))
982
+ break
983
+ // dialog
984
+ case 'setDialogHandler':
985
+ p = asResponse(() =>
986
+ setDialogHandler(params.handler == null ? null : String(params.handler)),
987
+ )
988
+ break
989
+ // ── aria snapshot ──
990
+ case 'snapshot':
991
+ p = asResponse(() =>
992
+ snapshot({
993
+ rootSelector: params.rootSelector == null ? undefined : String(params.rootSelector),
994
+ depth: typeof params.depth === 'number' ? params.depth : undefined,
995
+ interactiveOnly: params.interactiveOnly === true,
996
+ chunkId: typeof params.chunkId === 'number' ? params.chunkId : 0,
997
+ chunkSize: typeof params.chunkSize === 'number' ? params.chunkSize : undefined,
998
+ refresh: params.refresh === true,
999
+ }),
1000
+ )
1001
+ break
1002
+ // ── 可见文本定位 (返回 bbox, 不返回 DOM handle) ──
1003
+ case 'locateVisibleText':
1004
+ p = asResponse(() =>
1005
+ locateVisibleTextInContent({
1006
+ text: String(params.text ?? ''),
1007
+ exact: params.exact === true,
1008
+ index: typeof params.index === 'number' ? params.index : undefined,
1009
+ }),
1010
+ )
1011
+ break
1012
+ // ── 坐标点击 (合成 pointer/mouse 事件序列, route=extension-content) ──
1013
+ case 'clickAt':
1014
+ p = asResponse(async () => {
1015
+ const x = Number(params.x), y = Number(params.y)
1016
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('clickAt: x/y must be finite')
1017
+ const t = targetAt(x, y)
1018
+ await mouseClick(x, y, { button: (params.button as string) || 'left', clickCount: Number(params.clickCount ?? 1) })
1019
+ return { ok: true, x, y, route: 'extension-content', trusted: false, targetTag: t?.tagName, targetText: (t?.textContent || '').trim().slice(0, 40) } as any
1020
+ })
1021
+ break
1022
+ // ── 定位可见文本 → 立即点击 (content script 原子完成, 防 DOM 重渲染) ──
1023
+ case 'clickByText':
1024
+ p = asResponse(async () => {
1025
+ const loc = locateVisibleTextInContent({
1026
+ text: String(params.text ?? ''),
1027
+ exact: params.exact === true,
1028
+ index: typeof params.index === 'number' ? params.index : undefined,
1029
+ })
1030
+ if (!loc.matches || loc.matches.length === 0) {
1031
+ throw new Error('clickByText: text not visible: ' + params.text)
1032
+ }
1033
+ const idx = Math.max(0, Math.min(loc.matches.length - 1, Number(params.index ?? 0)))
1034
+ const m = loc.matches[idx]
1035
+ const x = Math.round(m.centerX + Number(params.offsetX ?? 0))
1036
+ const y = Math.round(m.centerY + Number(params.offsetY ?? 0))
1037
+ const t = targetAt(x, y)
1038
+ await mouseClick(x, y, { button: (params.button as string) || 'left', clickCount: Number(params.clickCount ?? 1) })
1039
+ return { ok: true, text: params.text, x, y, match: m, route: 'extension-content', trusted: false, targetTag: t?.tagName, targetText: (t?.textContent || '').trim().slice(0, 40) } as any
1040
+ })
1041
+ break
1042
+ default:
1043
+ p = Promise.resolve({ ok: false, error: `unknown method: ${method}` })
1044
+ }
1045
+
1046
+ p.then((res) => sendResponse(res))
1047
+ return true // 异步响应
1048
+ })
1049
+
1050
+ console.log('[WebPlater] content script loaded', location.href)
1051
+ },
1052
+ })
1053
+
1054
+ // ── locateVisibleText: 扫描可见文本节点, 返回 bbox (不返回 DOM handle) ────
1055
+ //
1056
+ // 用 Range.getBoundingClientRect() 取文本节点的精确 bbox (比 element.getBoundingClientRect
1057
+ // 更准, 能定位到单元格内的文字而非整个 td)。
1058
+ // 过滤: display:none / visibility:hidden / opacity:0 / 零面积 / 不在 viewport。
1059
+ // 匹配: exact (===) 或 contains (includes)。返回所有 match 的 bbox 列表。
1060
+ function locateVisibleTextInContent(opts: {
1061
+ text: string
1062
+ exact?: boolean
1063
+ index?: number
1064
+ }): { ok: true; matches: Array<{
1065
+ text: string
1066
+ x: number
1067
+ y: number
1068
+ width: number
1069
+ height: number
1070
+ centerX: number
1071
+ centerY: number
1072
+ visible: boolean
1073
+ }> } {
1074
+ const { text, exact = false } = opts
1075
+ const wantIndex = typeof opts.index === 'number' ? opts.index : 0
1076
+ const matches: Array<{
1077
+ text: string
1078
+ x: number
1079
+ y: number
1080
+ width: number
1081
+ height: number
1082
+ centerX: number
1083
+ centerY: number
1084
+ visible: boolean
1085
+ }> = []
1086
+
1087
+ // 可见性判定: 沿祖先链查 display/visibility/opacity + 零面积
1088
+ const isVisible = (el: Element | null): boolean => {
1089
+ let cur: Element | null = el
1090
+ while (cur && cur !== document.body) {
1091
+ const cs = getComputedStyle(cur)
1092
+ if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) {
1093
+ return false
1094
+ }
1095
+ cur = cur.parentElement
1096
+ }
1097
+ return true
1098
+ }
1099
+
1100
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
1101
+ acceptNode: (node) => {
1102
+ const t = (node.nodeValue || '').trim()
1103
+ if (!t) return NodeFilter.FILTER_REJECT
1104
+ // 文本匹配预筛
1105
+ const hit = exact ? t === text : t.includes(text)
1106
+ if (!hit) return NodeFilter.FILTER_REJECT
1107
+ const parent = node.parentElement
1108
+ if (!parent || !isVisible(parent)) return NodeFilter.FILTER_REJECT
1109
+ return NodeFilter.FILTER_ACCEPT
1110
+ },
1111
+ })
1112
+
1113
+ const vh = window.innerHeight || document.documentElement.clientHeight
1114
+ const vw = window.innerWidth || document.documentElement.clientWidth
1115
+
1116
+ while (walker.nextNode()) {
1117
+ const node = walker.currentNode as Text
1118
+ const parent = node.parentElement
1119
+ if (!parent) continue
1120
+ // 用 Range 取文字精确 bbox
1121
+ let rect: DOMRect | null = null
1122
+ try {
1123
+ const range = document.createRange()
1124
+ range.selectNodeContents(node)
1125
+ rect = range.getBoundingClientRect()
1126
+ } catch {
1127
+ rect = parent.getBoundingClientRect()
1128
+ }
1129
+ if (!rect || rect.width <= 0 || rect.height <= 0) continue
1130
+ const centerX = rect.x + rect.width / 2
1131
+ const centerY = rect.y + rect.height / 2
1132
+ // 在 viewport 内才算 (允许部分露出: 中心点在视口)
1133
+ const inViewport = centerY >= 0 && centerY <= vh && centerX >= 0 && centerX <= vw
1134
+ if (!inViewport) continue
1135
+ matches.push({
1136
+ text: (node.nodeValue || '').trim().slice(0, 80),
1137
+ x: Math.round(rect.x),
1138
+ y: Math.round(rect.y),
1139
+ width: Math.round(rect.width),
1140
+ height: Math.round(rect.height),
1141
+ centerX: Math.round(centerX),
1142
+ centerY: Math.round(centerY),
1143
+ visible: true,
1144
+ })
1145
+ }
1146
+
1147
+ // 按 index 选 (默认 0), 但返回全部 match 供调用方决策
1148
+ void wantIndex
1149
+ return { ok: true, matches }
1150
+ }