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,295 @@
1
+ // -*- coding: utf-8 -*-
2
+ //
3
+ // web/ready — 页面就绪等待 + 网页内弹窗清理(路由无关)
4
+ //
5
+ // 标准采集工作流的基础组件:
6
+ // waitForReady() 等待采集前提 (业务节点/文本出现), 没出现则刷新重试
7
+ // dismissPopups() 关闭网页内 DOM 弹窗 (modal/popup/dialog, 非浏览器 system dialog)
8
+ //
9
+ // 解决 SPA 场景:
10
+ // - 页面加载慢, snapshot/采集时主体还没渲染 → waitForReady 等到就绪
11
+ // - 弹窗挡住主体, snapshot 抓不到目标 → dismissPopups 先清障
12
+ //
13
+ // 依赖:./browser(operate)
14
+
15
+ 'use strict'
16
+
17
+ const { operate } = require('./browser')
18
+ const { findElement } = require('./find')
19
+
20
+ // ─── waitForReady: 等业务就绪, 没出现则刷新重试 ───
21
+
22
+ /**
23
+ * 等待页面就绪 (采集前提)。
24
+ *
25
+ * 判定: snapshot 的 aria 树 / innerText 出现任一 readyText, 或 evaluate fn 返回 truthy。
26
+ * 没出现则刷新页面, 重试 reloadMax 次。全部失败抛错 (采集不可进行)。
27
+ *
28
+ * @param {pageHandle} pageHandle
29
+ * @param {object} opts
30
+ * @param {string[]} [opts.readyText] 出现任一即就绪 (在 snapshot yaml 或 body 文本里找)
31
+ * @param {string} [opts.readySelector] 出现该 selector 即就绪 (DOM 存在)
32
+ * @param {number} [opts.timeoutMs=30000] 每轮等待总时长
33
+ * @param {number} [opts.pollMs=2000] 轮询间隔
34
+ * @param {number} [opts.reloadMax=1] 没就绪时的刷新重试次数 (0=不刷新, 超时就失败)
35
+ * @param {number} [opts.settleMs=2000] 检测到就绪后再等一会 (等子元素稳定)
36
+ * @returns {Promise<{ready:true, via:string, attempts:number}>}
37
+ */
38
+ async function waitForReady (pageHandle, opts = {}) {
39
+ const readyText = opts.readyText || []
40
+ const readySelector = opts.readySelector
41
+ const timeoutMs = opts.timeoutMs || 30000
42
+ const pollMs = opts.pollMs || 2000
43
+ const reloadMax = opts.reloadMax == null ? 1 : opts.reloadMax
44
+ const settleMs = opts.settleMs || 2000
45
+
46
+ const check = async () => {
47
+ // 1. 文本判定: snapshot yaml + body innerText
48
+ if (readyText.length) {
49
+ const snap = await operate(pageHandle, { action: 'snapshot', _skipVisualize: true }).catch(() => ({ yaml: '' }))
50
+ const yaml = snap.yaml || ''
51
+ for (const t of readyText) {
52
+ if (yaml.includes(t)) return { ready: true, via: 'snapshot:' + t }
53
+ }
54
+ const body = await operate(pageHandle, {
55
+ action: 'evaluate',
56
+ source: '() => (document.body && document.body.innerText) || ""',
57
+ _skipVisualize: true
58
+ }).catch(() => '')
59
+ const bodyText = typeof body === 'string' ? body : (body && body.value) || ''
60
+ for (const t of readyText) {
61
+ if (bodyText.includes(t)) return { ready: true, via: 'text:' + t }
62
+ }
63
+ }
64
+ // 2. selector 判定
65
+ if (readySelector) {
66
+ const c = await operate(pageHandle, { action: 'count', selector: readySelector, _skipVisualize: true }).catch(() => ({ count: 0 }))
67
+ if (c.count > 0) return { ready: true, via: 'selector:' + readySelector }
68
+ }
69
+ return { ready: false }
70
+ }
71
+
72
+ let attempts = 0
73
+ for (let reload = 0; reload <= reloadMax; reload++) {
74
+ const deadline = Date.now() + timeoutMs
75
+ while (Date.now() < deadline) {
76
+ attempts++
77
+ const r = await check()
78
+ if (r.ready) {
79
+ if (settleMs > 0) await new Promise(res => setTimeout(res, settleMs))
80
+ return { ready: true, via: r.via, attempts }
81
+ }
82
+ await new Promise(res => setTimeout(res, pollMs))
83
+ }
84
+ // 这一轮没就绪: 还有重试次数就刷新
85
+ if (reload < reloadMax) {
86
+ await operate(pageHandle, { action: 'reload', _skipVisualize: true }).catch(() => {})
87
+ await new Promise(res => setTimeout(res, 3000)) // 刷新后等主体
88
+ }
89
+ }
90
+ throw new Error(`waitForReady 超时: 未等到就绪条件 text=${JSON.stringify(readyText)} sel=${readySelector || '无'} (尝试 ${attempts} 次, 刷新 ${reloadMax} 次)`)
91
+ }
92
+
93
+ // ─── dismissPopups: 关闭网页内 DOM 弹窗 ───
94
+
95
+ // 常见弹窗关闭按钮 selector (语义类 + 通用关闭图标)
96
+ const POPUP_CLOSE_SELECTORS = [
97
+ // 明确的关闭/取消/我知道了按钮 (按可见文本)
98
+ // 注: aria-ref 更稳, 但弹窗往往是动态的, 这里用通用 selector 兜底
99
+ '[class*="close"][aria-label]', '[class*="Close"]',
100
+ 'button[class*="close"]', 'a[class*="close"]',
101
+ '.modal-close', '.dialog-close', '.popup-close',
102
+ '[class*="modal"] [class*="close"]', '[class*="dialog"] [class*="close"]',
103
+ // 通用关闭图标 (×)
104
+ '[class*="icon-close"]', '[class*="iconClose"]', '.icon-close',
105
+ // "我知道了/知道了/确定/关闭" 类文本按钮 (通用引导弹窗)
106
+ // 这类用 evaluate 按文本点更稳, 不放 selector
107
+ ]
108
+
109
+ // 通用引导弹窗的确认文本 (国内站点常见)
110
+ const CONFIRM_TEXTS = ['我知道了', '知道了', '确定', '关闭', '不再提示', '下次再说', '残忍拒绝', '暂不', '取消']
111
+
112
+ /**
113
+ * 关闭网页内的 DOM 弹窗 (modal/popup/dialog)。
114
+ *
115
+ * 区别于 setDialogHandler (那是浏览器原生 alert/confirm/prompt 的 JS dialog)。
116
+ * 这个处理的是网页自己渲染的遮罩弹窗 —— 它们会挡住主体, 让 snapshot/点击失效。
117
+ *
118
+ * 策略 (逐层尝试, 命中即停):
119
+ * 1. 找可见的关闭按钮 selector 点击
120
+ * 2. 找"我知道了/确定/关闭"等文本按钮点击
121
+ * 3. 兜底: 隐藏所有 [class*=modal]/[class*=popup] 的遮罩元素
122
+ *
123
+ * @param {pageHandle} pageHandle
124
+ * @param {object} [opts]
125
+ * @param {string[]} [opts.extraCloseTexts] 额外的关闭按钮文本
126
+ * @param {string[]} [opts.extraSelectors] 额外的关闭按钮 selector
127
+ * @returns {Promise<{dismissed:number, methods:string[]}>} 关闭了几个, 用了什么方法
128
+ */
129
+ async function dismissPopups (pageHandle, opts = {}) {
130
+ const texts = [...CONFIRM_TEXTS, ...(opts.extraCloseTexts || [])]
131
+ const sels = [...POPUP_CLOSE_SELECTORS, ...(opts.extraSelectors || [])]
132
+
133
+ // 一次 evaluate 完成探测+点击+兜底隐藏, 返回关闭了几个 + 方法
134
+ const result = await operate(pageHandle, {
135
+ action: 'evaluate',
136
+ source: `(() => {
137
+ const texts = ${JSON.stringify(texts)}
138
+ const sels = ${JSON.stringify(sels)}
139
+ const methods = []
140
+ let dismissed = 0
141
+ const isVisible = (el) => {
142
+ if (!el) return false
143
+ const r = el.getBoundingClientRect()
144
+ const s = getComputedStyle(el)
145
+ return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0'
146
+ }
147
+ const clickIfVis = (el, method) => {
148
+ if (isVisible(el)) {
149
+ try { el.click(); dismissed++; methods.push(method) } catch (e) {}
150
+ return true
151
+ }
152
+ return false
153
+ }
154
+ // 1. 关闭按钮 selector
155
+ for (const sel of sels) {
156
+ let els = []
157
+ try { els = Array.from(document.querySelectorAll(sel)) } catch (e) { continue }
158
+ for (const el of els) { if (clickIfVis(el, 'sel:' + sel)) return { dismissed, methods } }
159
+ }
160
+ // 2. 文本按钮 (我知道了/确定/关闭...)
161
+ const btns = Array.from(document.querySelectorAll('button, a, span, div'))
162
+ for (const el of btns) {
163
+ const t = (el.innerText || el.textContent || '').trim()
164
+ if (t.length > 0 && t.length <= 6 && texts.includes(t)) {
165
+ if (clickIfVis(el, 'text:' + t)) return { dismissed, methods }
166
+ }
167
+ }
168
+ // 3. 兜底: 隐藏可见的 modal/popup 遮罩 (不点, 直接 display:none)
169
+ const masks = Array.from(document.querySelectorAll('[class*="modal"], [class*="popup"], [class*="dialog"], [class*="Mask"], [class*="overlay"]'))
170
+ for (const el of masks) {
171
+ if (isVisible(el) && el.querySelector('button, a') === null) {
172
+ // 纯遮罩层 (没按钮的) 直接隐藏
173
+ try { el.style.display = 'none'; dismissed++; methods.push('hide:mask') } catch (e) {}
174
+ }
175
+ }
176
+ return { dismissed, methods }
177
+ })()`,
178
+ _skipVisualize: true
179
+ }).catch(() => ({ dismissed: 0, methods: [] }))
180
+
181
+ const r = (result && typeof result === 'object') ? result : { dismissed: 0, methods: [] }
182
+ return { dismissed: r.dismissed || 0, methods: r.methods || [] }
183
+ }
184
+
185
+ // ─── scroll: 滚动封装 (SPA 页面数据常需滚动才渲染) ───
186
+ // 模拟人类: 分步 scrollBy + 随机停顿 (不是原生 smooth, 原生平滑无停顿不触发懒加载)
187
+
188
+ // 分步滚动到目标 scrollY (人类化: 每步 30~60px, 30~60ms 停顿)
189
+ async function humanScrollToY (pageHandle, targetY, maxSteps = 200) {
190
+ await operate(pageHandle, {
191
+ action: 'evaluate',
192
+ source: `(targetY) => new Promise((resolve) => {
193
+ const start = window.scrollY || window.pageYOffset || 0
194
+ const dir = targetY > start ? 1 : -1
195
+ let steps = 0
196
+ const tick = () => {
197
+ const cur = window.scrollY || window.pageYOffset || 0
198
+ steps++
199
+ if (steps > ${maxSteps} || (dir > 0 ? cur >= targetY - 5 : cur <= targetY + 5)) { resolve(); return }
200
+ const step = 30 + Math.floor(Math.random() * 30) // 30~60px/步
201
+ window.scrollBy(0, dir * step)
202
+ setTimeout(tick, 30 + Math.random() * 30) // 30~60ms 停顿
203
+ }
204
+ tick()
205
+ })`,
206
+ args: targetY,
207
+ _skipVisualize: true
208
+ }).catch(() => {})
209
+ }
210
+
211
+ /**
212
+ * 滚动到指定文本所在的元素 (SPA 最稳定位)。
213
+ * 先拿到目标元素 Y, 再人类化分步滚过去 (边滚边看, 触发懒加载)。
214
+ *
215
+ * @param {pageHandle} pageHandle
216
+ * @param {string} text 目标文本 (精确或包含)
217
+ * @param {object} [opts]
218
+ * @param {boolean} [opts.exact=false] true=精确匹配
219
+ * @param {string} [opts.block='center'] 到位后对齐: 'center'|'start'|'end'
220
+ * @returns {Promise<{found:boolean, scrolled:boolean}>}
221
+ */
222
+ async function scrollToText (pageHandle, text, opts = {}) {
223
+ const exact = !!opts.exact
224
+ const block = opts.block || 'center'
225
+ const findPos = async () => {
226
+ const el = await findElement(pageHandle, { text, exact })
227
+ if (!el.found) return { found: false }
228
+ const vh = await operate(pageHandle, { action: 'evaluate', source: '()=>window.innerHeight', _skipVisualize: true }).catch(() => 800)
229
+ let offset = vh / 2
230
+ if (block === 'start') offset = 50
231
+ else if (block === 'end') offset = vh - 50
232
+ return { found: true, targetY: Math.max(0, el.y + el.height/2 - offset) }
233
+ }
234
+ // 1. 先直接找 (元素可能在 DOM 里, 只是没滚到)
235
+ let pos = await findPos()
236
+ // 2. 没找到: 人类化逐步下滚, 边滚边找 (SPA 懒加载, 元素滚近才进 DOM)
237
+ if (!pos || !pos.found) {
238
+ for (let i = 0; i < 60; i++) {
239
+ await operate(pageHandle, {
240
+ action: 'evaluate',
241
+ source: '() => { window.scrollBy(0, 40 + Math.floor(Math.random()*30)); return null }',
242
+ _skipVisualize: true
243
+ })
244
+ await new Promise(r => setTimeout(r, 60 + Math.random() * 60))
245
+ pos = await findPos()
246
+ if (pos && pos.found) break
247
+ // 到底了停
248
+ const atBottom = await operate(pageHandle, { action: 'evaluate', source: '()=>(window.innerHeight + (window.scrollY||0) + 5) >= document.body.scrollHeight', _skipVisualize: true }).catch(()=>false)
249
+ if (atBottom) break
250
+ }
251
+ }
252
+ if (!pos || !pos.found) return { found: false, scrolled: false }
253
+ // 3. 找到了, 人类化分步滚到目标 Y
254
+ await humanScrollToY(pageHandle, pos.targetY)
255
+ await new Promise(r => setTimeout(r, 600))
256
+ return { found: true, scrolled: true }
257
+ }
258
+
259
+ /**
260
+ * 滚动到 selector 元素 (人类化分步)。
261
+ */
262
+ async function scrollTo (pageHandle, selector, opts = {}) {
263
+ const block = opts.block || 'center'
264
+ const el = await findElement(pageHandle, { selector })
265
+ if (!el.found) return { found: false, scrolled: false }
266
+ const vh = await operate(pageHandle, { action: 'evaluate', source: '()=>window.innerHeight', _skipVisualize: true }).catch(() => 800)
267
+ let offset = vh / 2
268
+ if (block === 'start') offset = 50
269
+ else if (block === 'end') offset = vh - 50
270
+ const targetY = Math.max(0, el.y + el.height/2 - offset)
271
+ await humanScrollToY(pageHandle, targetY)
272
+ return { found: true, scrolled: true }
273
+ }
274
+
275
+ /**
276
+ * 滚动到页顶/页底 (人类化分步)。
277
+ * @param {'top'|'bottom'} to
278
+ */
279
+ async function scrollToEnd (pageHandle, to = 'top') {
280
+ if (to === 'bottom') {
281
+ // 逐步下滚直到 scrollHeight 不再增长 (触底)
282
+ await operate(pageHandle, {
283
+ action: 'evaluate',
284
+ source: '() => new Promise((resolve) => { let last=-1; const t=setInterval(()=>{ window.scrollBy(0,80+Math.random()*60); const h=document.body.scrollHeight; if(h===last){clearInterval(t);resolve()} last=h }, 80+Math.random()*60) })',
285
+ _skipVisualize: true
286
+ }).catch(() => {})
287
+ } else {
288
+ const cur = await operate(pageHandle, { action: 'evaluate', source: '()=>window.scrollY||0', _skipVisualize: true }).catch(() => 0)
289
+ await humanScrollToY(pageHandle, 0)
290
+ }
291
+ return true
292
+ }
293
+
294
+
295
+ module.exports = { waitForReady, dismissPopups, scrollToText, scrollTo, scrollToEnd, POPUP_CLOSE_SELECTORS, CONFIRM_TEXTS }
@@ -0,0 +1,167 @@
1
+ // -*- coding: utf-8 -*-
2
+ //
3
+ // web/scroll — 唯一滚动入口 ScrollController(路由无关)
4
+ //
5
+ // 原则: 所有改变 viewport 的动作必须经过这里。
6
+ // - scrollToY: 启动读一次 startY, clamp target, 单次定方向, 单调 easing, 不越过, 不反向
7
+ // - scrollBy: = scrollToY(currentY + delta)
8
+ // - scrollToElement: 算目标→scrollToY→重测→最多一次同向修正, 禁 scrollIntoView
9
+ // - scrollToEnd: 读 maxScrollY→scrollToY, 只单调
10
+ // - 返回 trace: {startY, targetY, positions, maxDelta, reversed} 供异常检测
11
+ //
12
+ // 库绝不制造跳变。若页面 JS/虚拟列表/focus/anchoring 导致 reversed/maxDelta 异常,
13
+ // 返回失败 trace, 不静默通过。
14
+ //
15
+ // 依赖: ./browser (operate) — 底层, 不引 find/humanize/ready (避免循环)
16
+
17
+ 'use strict'
18
+
19
+ const { operate } = require('./browser')
20
+
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)))
22
+
23
+ // 单调缓动 (easeInOutCubic): t∈[0,1] → [0,1], 单调递增
24
+ function ease (t) {
25
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
26
+ }
27
+
28
+ async function readScrollY (ph) {
29
+ const y = await operate(ph, { action: 'evaluate', source: '() => window.scrollY || window.pageYOffset || 0', _skipVisualize: true }).catch(() => 0)
30
+ return Number(y) || 0
31
+ }
32
+
33
+ async function readMaxScrollY (ph) {
34
+ const m = await operate(ph, { action: 'evaluate', source: '() => Math.max(0, (document.body.scrollHeight || 0) - (window.innerHeight || 0))', _skipVisualize: true }).catch(() => 0)
35
+ return Number(m) || 0
36
+ }
37
+
38
+ // 设置绝对 scrollY (用 scrollTo 设精确位置, 内部唯一允许; 外部禁止散落用)
39
+ async function setScrollY (ph, y) {
40
+ await operate(ph, { action: 'evaluate', source: '(y) => { window.scrollTo(0, y); return window.scrollY || 0 }', args: y, _skipVisualize: true }).catch(() => {})
41
+ }
42
+
43
+ /**
44
+ * 滚动到指定 scrollY (绝对坐标)。
45
+ *
46
+ * 规则:
47
+ * - 启动读一次 startY, 之后不靠实时 scrollY 反向修正
48
+ * - clamp targetY 到 [0, maxScrollY]
49
+ * - 方向一次确定, 单调 easing, 每步 clamp 到目标内, 不越过不反向
50
+ *
51
+ * @returns {Promise<{ok, startY, targetY, positions, maxDelta, reversed}>}
52
+ * reversed=true 表示页面外部干扰导致实际滚动反向 (异常)
53
+ */
54
+ async function scrollToY (pageHandle, targetY, opts = {}) {
55
+ const stepPx = opts.stepPx || 40 // 每步约 40px
56
+ const stepMs = opts.stepMs || 16 // 每步约 16ms (~60fps)
57
+ const startY = await readScrollY(pageHandle)
58
+ const maxY = await readMaxScrollY(pageHandle)
59
+ const target = Math.max(0, Math.min(targetY, maxY))
60
+ const distance = Math.abs(target - startY)
61
+ const dir = target >= startY ? 1 : -1 // 一次定方向
62
+
63
+ const steps = Math.max(1, Math.ceil(distance / stepPx))
64
+ const positions = [startY]
65
+ let maxDelta = 0
66
+ let reversed = false
67
+
68
+ for (let i = 1; i <= steps; i++) {
69
+ const t = i / steps
70
+ const eased = ease(t)
71
+ // 单调期望位置 (dir 固定, eased 单调)
72
+ let pos = startY + dir * distance * eased
73
+ // clamp 到目标内, 绝不越过
74
+ pos = dir > 0 ? Math.min(pos, target) : Math.max(pos, target)
75
+ await setScrollY(pageHandle, pos)
76
+
77
+ // 采样真实位置 (检测外部干扰, 但不用它决定方向)
78
+ const real = await readScrollY(pageHandle)
79
+ positions.push(real)
80
+ const delta = Math.abs(real - positions[positions.length - 2])
81
+ if (delta > maxDelta) maxDelta = delta
82
+ // 反向检测: 期望 dir 前进, 但 real 反向移动 (页面干扰)
83
+ if (positions.length >= 2) {
84
+ const prev = positions[positions.length - 2]
85
+ if (dir > 0 && real < prev - 1) reversed = true
86
+ if (dir < 0 && real > prev + 1) reversed = true
87
+ }
88
+ await sleep(stepMs)
89
+ }
90
+
91
+ // 最终确保落在 target (setScrollY 精确)
92
+ await setScrollY(pageHandle, target)
93
+ const final = await readScrollY(pageHandle)
94
+ positions.push(final)
95
+
96
+ return { ok: true, startY, targetY: target, positions, maxDelta, reversed }
97
+ }
98
+
99
+ /**
100
+ * 增量滚动 = 读当前 Y + scrollToY。
101
+ */
102
+ async function scrollBy (pageHandle, deltaPx, opts = {}) {
103
+ const cur = await readScrollY(pageHandle)
104
+ return scrollToY(pageHandle, cur + deltaPx, opts)
105
+ }
106
+
107
+ /**
108
+ * 滚到元素 (禁 scrollIntoView)。
109
+ * 算目标 → scrollToY → 重测 → 容差内即停, 否则最多一次同向单调修正。
110
+ *
111
+ * @param {string} selector
112
+ * @param {object} [opts] opts.block='center'|'start'|'end', opts.tolerance=20
113
+ * @returns {Promise<{ok, targetY, inViewport, ...trace}>}
114
+ */
115
+ async function scrollToElement (pageHandle, selector, opts = {}) {
116
+ const block = opts.block || 'center'
117
+ const tolerance = opts.tolerance || 20
118
+
119
+ const computeTarget = async () => {
120
+ const r = await operate(pageHandle, {
121
+ action: 'evaluate',
122
+ source: `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return null; const r = el.getBoundingClientRect(); const vh = window.innerHeight; let off = vh/2; if (${JSON.stringify(block)}==='start') off=50; else if (${JSON.stringify(block)}==='end') off=vh-50; return { targetY: Math.max(0, r.top + (window.scrollY||0) - off), top: Math.round(r.top), vh } })()`,
123
+ _skipVisualize: true
124
+ }).catch(() => null)
125
+ return r
126
+ }
127
+
128
+ const first = await computeTarget()
129
+ if (!first) return { ok: false, error: 'ELEMENT_NOT_FOUND', selector }
130
+ const trace = await scrollToY(pageHandle, first.targetY, opts)
131
+
132
+ // 重测: 元素是否在容差内
133
+ const remeasure = await computeTarget()
134
+ if (!remeasure) return { ok: false, error: 'ELEMENT_NOT_FOUND_AFTER_SCROLL', selector }
135
+ const inView = Math.abs(remeasure.top - (block === 'start' ? 0 : block === 'end' ? remeasure.vh : remeasure.vh / 2)) < tolerance + 50
136
+ if (inView) return { ok: true, targetY: first.targetY, inViewport: true, ...trace }
137
+
138
+ // 最多一次同向单调修正 (重新算 target 再滚一次)
139
+ const fix = await computeTarget()
140
+ if (!fix) return { ok: false, error: 'ELEMENT_NOT_FOUND', selector, inViewport: false, ...trace }
141
+ const trace2 = await scrollToY(pageHandle, fix.targetY, opts)
142
+ const remeasure2 = await computeTarget()
143
+ const inView2 = remeasure2 ? Math.abs(remeasure2.top - (block === 'start' ? 0 : block === 'end' ? remeasure2.vh : remeasure2.vh / 2)) < tolerance + 50 : false
144
+ return { ok: inView2, targetY: fix.targetY, inViewport: inView2, ...trace, fixTrace: trace2 }
145
+ }
146
+
147
+ /**
148
+ * 滚到页顶/页底 (只单调)。
149
+ * bottom: 读 maxScrollY → scrollToY → 若 scrollHeight 变化则重复 (只向下)。
150
+ */
151
+ async function scrollToEnd (pageHandle, to = 'top', opts = {}) {
152
+ if (to === 'top') {
153
+ return scrollToY(pageHandle, 0, opts)
154
+ }
155
+ // bottom: 单调向下, 若页面增高则再来一轮 (最多 5 轮)
156
+ let lastMax = -1
157
+ let trace
158
+ for (let i = 0; i < 5; i++) {
159
+ const maxY = await readMaxScrollY(pageHandle)
160
+ if (maxY === lastMax) break
161
+ lastMax = maxY
162
+ trace = await scrollToY(pageHandle, maxY, opts)
163
+ }
164
+ return trace || { ok: true, startY: 0, targetY: 0, positions: [], maxDelta: 0, reversed: false }
165
+ }
166
+
167
+ module.exports = { scrollToY, scrollBy, scrollToElement, scrollToEnd, readScrollY, readMaxScrollY }
@@ -0,0 +1,116 @@
1
+ // -*- coding: utf-8 -*-
2
+ //
3
+ // web/upload — 通用文件 / 文件夹上传(路由无关,全走 operate)
4
+ //
5
+ // 完整复刻自 bee/resources/sandbox/builtin-libs/web/upload.js。
6
+ // 统一 operate(pageHandle, {action:'setInputFiles'}),operate 内部按路由分流:
7
+ // cdp 吃本地 paths(playwright 自动展开目录),extension 吃 base64 files。
8
+ // upload 只负责把调用方意图整理成 operate 能懂的参数:
9
+ // 总是同时提供 paths(给 cdp)+ files(给 extension,base64 展开后)。
10
+ //
11
+ // 依赖:./browser(operate), node:fs + node:path(展开 paths 为 base64)
12
+
13
+ 'use strict'
14
+
15
+ const fs = require('node:fs')
16
+ const path = require('node:path')
17
+ const { operate } = require('./browser')
18
+
19
+ const MIME_MAP = {
20
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
21
+ webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', bmp: 'image/bmp',
22
+ pdf: 'application/pdf', txt: 'text/plain', json: 'application/json', csv: 'text/csv',
23
+ html: 'text/html', htm: 'text/html', xml: 'application/xml', zip: 'application/zip',
24
+ gz: 'application/gzip', tar: 'application/x-tar',
25
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
26
+ xls: 'application/vnd.ms-excel',
27
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
28
+ doc: 'application/msword', mp4: 'video/mp4', mp3: 'audio/mpeg',
29
+ js: 'text/javascript', ts: 'text/plain', css: 'text/css', md: 'text/markdown'
30
+ }
31
+
32
+ function guessMime (name) {
33
+ const ext = name.toLowerCase().split('.').pop()
34
+ return MIME_MAP[ext] || 'application/octet-stream'
35
+ }
36
+
37
+ function readFileEntry (full, webkitRelativePath) {
38
+ const buf = fs.readFileSync(full)
39
+ const name = path.basename(full)
40
+ return {
41
+ name,
42
+ mime: guessMime(name),
43
+ data: buf.toString('base64'),
44
+ webkitRelativePath: webkitRelativePath || name
45
+ }
46
+ }
47
+
48
+ function walkDir (dir, relBase, out) {
49
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
50
+ for (const e of entries) {
51
+ const full = path.join(dir, e.name)
52
+ const rel = relBase + '/' + e.name
53
+ if (e.isDirectory()) {
54
+ walkDir(full, rel, out)
55
+ } else if (e.isFile()) {
56
+ out.push(readFileEntry(full, rel))
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 展开本地路径数组(文件/目录混合)→ base64 文件 entry 数组。
63
+ * 插件分支用(content 读不到本地文件,必须转 base64)。
64
+ * CDP 分支不需要(直传路径给 playwright)。
65
+ * @param {string[]} paths
66
+ * @returns {Array<{name, mime, data, webkitRelativePath}>}
67
+ */
68
+ function expandPaths (paths) {
69
+ const out = []
70
+ for (const root of paths) {
71
+ let stat
72
+ try { stat = fs.statSync(root) } catch { throw new Error('upload: path not found: ' + root) }
73
+ if (stat.isFile()) {
74
+ out.push(readFileEntry(root, path.basename(root)))
75
+ } else if (stat.isDirectory()) {
76
+ walkDir(root, path.basename(root), out)
77
+ } else {
78
+ throw new Error('upload: not a file or directory: ' + root)
79
+ }
80
+ }
81
+ return out
82
+ }
83
+
84
+ /**
85
+ * 上传文件 / 文件夹到 <input type=file>。
86
+ *
87
+ * @param {object} pageHandle 单个 page 句柄
88
+ * @param {object} options
89
+ * @param {string} options.selector 文件输入框 selector(CSS 或 aria-ref=eN)
90
+ * @param {string[]} options.paths 本地路径数组(文件或目录,可混合)
91
+ * @returns {Promise<{ok, selector, requested, built, totalBytes}>}
92
+ * built: 实际展开出的文件数(目录会展开为多文件,built 可能 > requested)
93
+ * 不要用 input.files.length 判断是否上传成功(框架接收后会清空),看页面 UI。
94
+ */
95
+ async function uploadFiles (pageHandle, options = {}) {
96
+ if (!pageHandle) throw new Error('uploadFiles: pageHandle required')
97
+ const selector = options.selector
98
+ if (!selector) throw new Error('uploadFiles: selector required')
99
+ const paths = Array.isArray(options.paths) ? options.paths : []
100
+ if (paths.length === 0) throw new Error('uploadFiles: paths required')
101
+
102
+ // 同时准备两套:paths 给 cdp 路由(playwright 直吃路径),files 给 extension 路由(base64)。
103
+ // operate 内部按 pageHandle.mode 选用其一。先展开 base64 以便统一统计 built。
104
+ const files = expandPaths(paths)
105
+ const totalBytes = files.reduce((s, f) => s + Math.floor(f.data.length * 0.75), 0)
106
+ const r = await operate(pageHandle, { action: 'setInputFiles', selector, paths, files })
107
+ return {
108
+ ok: r && r.ok !== false,
109
+ selector,
110
+ requested: paths.length,
111
+ built: files.length,
112
+ totalBytes
113
+ }
114
+ }
115
+
116
+ module.exports = { uploadFiles, expandPaths }
@@ -0,0 +1,6 @@
1
+ // AUTO-GENERATED from webplater/lib/visual-runtime.ts (single source).
2
+ // 由 webplater scripts/build-visual-runtime.mjs 构建后同步到此。
3
+ // MAIN world 注入串: CDP operate 解析/高亮 + eval 契约 (window.visualMark)。
4
+ // 不要手改本文件, 改 webplater/lib/visual-runtime.ts 后重 build + 同步。
5
+ 'use strict'
6
+ module.exports = "(() => {\n var __defProp = Object.defineProperty;\n var __export = (target, all) => {\n for (var name in all)\n __defProp(target, name, { get: all[name], enumerable: true });\n };\n\n // lib/visual-runtime.ts\n var visual_runtime_exports = {};\n __export(visual_runtime_exports, {\n ensureVisualOverlay: () => ensureVisualOverlay,\n highlightResolved: () => highlightResolved,\n locateEl: () => locateEl,\n rectOf: () => rectOf,\n resolveCount: () => resolveCount,\n resolveSelector: () => resolveSelector,\n visualMark: () => visualMark\n });\n function refByDomAttribute(ref) {\n return document.querySelector(`[data-bee-ref=\"${cssEscape(ref)}\"]`) ?? void 0;\n }\n function cssEscape(s) {\n try {\n return window.CSS?.escape ? window.CSS.escape(s) : s.replace(/[\"\\\\]/g, \"\\\\$&\");\n } catch {\n return s.replace(/[\"\\\\]/g, \"\\\\$&\");\n }\n }\n function splitSelector(selector) {\n const idx = selector.indexOf(\">>\");\n if (idx < 0) return { base: selector, suffix: \"\" };\n return { base: selector.slice(0, idx), suffix: selector.slice(idx + 2).trim().toLowerCase() };\n }\n function resolveSelector(selector, refLookup) {\n const { base, suffix } = splitSelector(selector);\n if (base.startsWith(\"aria-ref=\")) {\n const ref = base.slice(\"aria-ref=\".length).trim();\n const lookup = refLookup ?? refByDomAttribute;\n const el = lookup(ref);\n if (!el || !document.contains(el)) return null;\n return el;\n }\n const list = Array.from(document.querySelectorAll(base));\n if (list.length === 0) return null;\n if (suffix === \"last\") return list[list.length - 1] ?? null;\n if (suffix.startsWith(\"nth(\") && suffix.endsWith(\")\")) {\n const n = Number(suffix.slice(4, -1));\n if (!Number.isInteger(n)) return null;\n return list[n] ?? null;\n }\n return list[0] ?? null;\n }\n function locateEl(selector, refLookup) {\n const el = resolveSelector(selector, refLookup);\n if (!el) throw new Error(`element not found: ${selector}`);\n return el;\n }\n function resolveCount(selector, refLookup) {\n const { base } = splitSelector(selector);\n if (base.startsWith(\"aria-ref=\")) {\n const ref = base.slice(\"aria-ref=\".length).trim();\n const lookup = refLookup ?? refByDomAttribute;\n const el = lookup(ref);\n return el && document.contains(el) ? 1 : 0;\n }\n return document.querySelectorAll(base).length;\n }\n function rectOf(el) {\n const r = el.getBoundingClientRect();\n return { x: r.left, y: r.top, w: r.width, h: r.height };\n }\n var OVERLAY_STYLE_ID = \"bee-visual-style\";\n var HL_BOX_CLASS = \"bee-hl-box\";\n var OVERLAY_CSS = [\n `.${HL_BOX_CLASS}{position:fixed;outline:4px solid #ff0000;outline-offset:3px;box-shadow:0 0 0 7px rgba(255,0,0,.45);z-index:2147483647;pointer-events:none;border-radius:2px}`\n ].join(\"\\n\");\n function ensureVisualOverlay() {\n if (document.getElementById(OVERLAY_STYLE_ID)) return;\n const s = document.createElement(\"style\");\n s.id = OVERLAY_STYLE_ID;\n s.textContent = OVERLAY_CSS;\n document.head.appendChild(s);\n }\n function drawRect(x, y, w, h) {\n if (w <= 0 || h <= 0) return;\n ensureVisualOverlay();\n const box = document.createElement(\"div\");\n box.className = HL_BOX_CLASS;\n box.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px`;\n document.body.appendChild(box);\n setTimeout(() => {\n box.style.transition = \"opacity 0.3s\";\n box.style.opacity = \"0\";\n setTimeout(() => box.remove(), 300);\n }, 3e3);\n }\n function markOne(t) {\n if (!t) return;\n if (t instanceof Element) {\n const r = rectOf(t);\n drawRect(r.x, r.y, r.w, r.h);\n return;\n }\n if (typeof t === \"object\" && t.x != null) {\n if (t.w != null && t.h != null) drawRect(t.x, t.y, t.w, t.h);\n else drawRect(t.x - 15, t.y - 8, 30, 16);\n return;\n }\n }\n function visualMark(target) {\n document.querySelectorAll(\".\" + HL_BOX_CLASS).forEach((e) => e.remove());\n if (Array.isArray(target)) target.forEach(markOne);\n else markOne(target);\n }\n function highlightResolved(selector, refLookup) {\n const el = resolveSelector(selector, refLookup);\n if (!el) return false;\n visualMark(el);\n return true;\n }\n\n // visual-runtime-inject.ts\n if (typeof window !== \"undefined\") {\n window.__beeVR = visual_runtime_exports;\n if (typeof window.visualMark === \"undefined\") window.visualMark = visualMark;\n if (typeof window.resolveSelector === \"undefined\") window.resolveSelector = resolveSelector;\n if (typeof window.rectOf === \"undefined\") window.rectOf = rectOf;\n }\n})();\n"
@@ -0,0 +1,7 @@
1
+ # FlashBox 对象存储凭证(发布浏览器插件用)。
2
+ # 复制为 .env.local 并填入真实值(与 mini-app / flashbox 主仓库同源 bucket)。
3
+ # .env.local 已在 .gitignore 中,不会提交。
4
+ FLASHBOX_OS_ENDPOINT=
5
+ FLASHBOX_OS_AK=
6
+ FLASHBOX_OS_SK=
7
+ FLASHBOX_OS_BUCKET=