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,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
|
+
}
|