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,78 @@
1
+ // -*- coding: utf-8 -*-
2
+ //
3
+ // lib/extension/state.cjs — 扩展路由专属运行时状态。
4
+ //
5
+ // 扩展路由不开远程调试端口, 跨进程复用靠两点:
6
+ // 1. WS server 端口 (默认 17321) 是否被别的进程占用 (EADDRINUSE)
7
+ // → 被占 = 别的进程在管扩展路由, 本进程复用
8
+ // 2. 本进程是否收到扩展心跳 (isPluginAlive)
9
+ // → 收到 = 本进程就是路由主人
10
+ //
11
+ // 文件位置: <userDataDir>/.extension-endpoint (记录 WS 端口 + 最近心跳时间,
12
+ // 便于诊断; 复用判据主要靠端口探测, 文件是辅助)
13
+
14
+ 'use strict'
15
+
16
+ const fs = require('node:fs')
17
+ const path = require('node:path')
18
+ const net = require('node:net')
19
+
20
+ const STATE_FILENAME = '.extension-endpoint'
21
+ const DEFAULT_WS_PORT = 17321
22
+
23
+ function statePath (userDataDir) {
24
+ return path.join(userDataDir, STATE_FILENAME)
25
+ }
26
+
27
+ /** 读扩展状态。返回 { wsPort, lastHeartbeatAt, pid, updatedAt } 或 null。 */
28
+ function read (userDataDir) {
29
+ try {
30
+ const p = statePath(userDataDir)
31
+ if (!fs.existsSync(p)) return null
32
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ /** 写扩展状态 (WS server 起来后 / 收到心跳时调)。 */
39
+ function write (userDataDir, info) {
40
+ try {
41
+ const p = statePath(userDataDir)
42
+ fs.mkdirSync(path.dirname(p), { recursive: true })
43
+ fs.writeFileSync(p, JSON.stringify({
44
+ wsPort: info.wsPort || DEFAULT_WS_PORT,
45
+ lastHeartbeatAt: info.lastHeartbeatAt || null,
46
+ pid: info.pid || process.pid,
47
+ updatedAt: Date.now()
48
+ }, null, 2))
49
+ } catch { /* ignore */ }
50
+ }
51
+
52
+ /** 清扩展状态 (close 后调)。 */
53
+ function clear (userDataDir) {
54
+ try {
55
+ const p = statePath(userDataDir)
56
+ if (fs.existsSync(p)) fs.unlinkSync(p)
57
+ } catch { /* ignore */ }
58
+ }
59
+
60
+ /** 探测 WS 端口是否被占 (别的进程的 server 在听)。 */
61
+ function isWsPortOccupied (port = DEFAULT_WS_PORT) {
62
+ return new Promise((resolve) => {
63
+ const tester = net.createServer()
64
+ tester.once('error', () => resolve(true))
65
+ tester.once('listening', () => tester.close(() => resolve(false)))
66
+ tester.listen(port, '127.0.0.1')
67
+ })
68
+ }
69
+
70
+ module.exports = {
71
+ STATE_FILENAME,
72
+ DEFAULT_WS_PORT,
73
+ statePath,
74
+ read,
75
+ write,
76
+ clear,
77
+ isWsPortOccupied
78
+ }
@@ -0,0 +1,389 @@
1
+ // -*- coding: utf-8 -*-
2
+ //
3
+ // index.cjs — browser-op 双路由总入口。
4
+ //
5
+ // 复刻自 bee/packages/app/browser-op/main/browser-op-service.ts 的 open()/close() 决策逻辑,
6
+ // 整合 launcher(CDP 启动)+ WebService(插件路)+ CaptureService(CDP 捕获)。
7
+ //
8
+ // 设计(与 bee 一致):
9
+ // - open(routeMode) 统一入口:检测 profile 是否装了 webplater 插件
10
+ // 装了且可用 → 插件模式(无 CDP 端口,WS 通信)mode=extension
11
+ // 没装/禁用/不活 → CDP 模式(带调试端口)mode=cdp
12
+ // - 插件模式:所有 API 转发到扩展(callPlugin)
13
+ // - CDP 模式:本进程不实现页面操作 API(那需要 playwright),仅提供捕获能力(CaptureService)
14
+ // 页面操作在 CDP 模式下请改用 playwright 直连 wsEndpoint
15
+ //
16
+ // 用法(node / bash):
17
+ // const { BrowserOp } = require('./lib')
18
+ // const op = new BrowserOp({ executablePath, profileRoot, userDataDir })
19
+ // const { mode, port, wsEndpoint } = await op.open(undefined, { routeMode: 'auto' })
20
+ // if (mode === 'extension') {
21
+ // await op.fill('#q', 'hello', tabId)
22
+ // } else {
23
+ // // CDP 模式:用 playwright.connectOverCDP(wsEndpoint) 操作
24
+ // }
25
+ // await op.close()
26
+
27
+ 'use strict'
28
+
29
+ const { CdpBrowserLauncher } = require('./cdp/chrome-launcher.cjs')
30
+ const { ChromePathResolver } = require('./cdp/chrome-path.cjs')
31
+ const { CaptureService } = require('./cdp/capture-service.cjs')
32
+ const { WebService } = require('./extension/service.cjs')
33
+ const { detectWebplaterExtension } = require('./extension/extension-detect.cjs')
34
+ const { captureInjectJS } = require('./cdp/capture-inject.js')
35
+ const globalState = require('./state.cjs')
36
+ const cdpState = require('./cdp/state.cjs')
37
+ const extState = require('./extension/state.cjs')
38
+
39
+ function defaultInfo () {
40
+ if (process.env.BROWSER_OP_DEBUG) console.error.apply(console, ['[browser-op]'].concat(Array.from(arguments)))
41
+ }
42
+ function defaultError () {
43
+ console.error.apply(console, ['[browser-op][error]'].concat(Array.from(arguments)))
44
+ }
45
+
46
+ class BrowserOp {
47
+ /**
48
+ * @param {Object} deps
49
+ * @param {string} deps.executablePath Chrome 路径(必填)
50
+ * @param {string} deps.profileRoot profile 根目录(检测插件用,可选)
51
+ * @param {string=} deps.userDataDir 显式 userDataDir(不传则 launcher 用 profileRoot/default)
52
+ * @param {Function=} deps.info 日志函数
53
+ * @param {Function=} deps.errorLog 错误日志函数
54
+ */
55
+ constructor (deps) {
56
+ if (!deps || !deps.executablePath) {
57
+ throw new Error('BrowserOp: deps.executablePath is required (Chrome path)')
58
+ }
59
+ this.executablePath = deps.executablePath
60
+ this.profileRoot = deps.profileRoot || null
61
+ this.userDataDirOverride = deps.userDataDir || null
62
+ this.info = deps.info || defaultInfo
63
+ this.errorLog = deps.errorLog || defaultError
64
+
65
+ this.launcher = new CdpBrowserLauncher()
66
+ this.web = new WebService({
67
+ info: this.info,
68
+ errorLog: this.errorLog,
69
+ profileRoot: this.profileRoot
70
+ })
71
+ /** CDP 链路捕获服务。插件链路不用它,走 web.enableCapture/disableCapture */
72
+ this.captureService = new CaptureService()
73
+
74
+ this.currentMode = null // 'extension' | 'cdp'
75
+ this.currentPort = null
76
+ this.currentWsEndpoint = null
77
+ this._serverStarted = false
78
+ }
79
+
80
+ /**
81
+ * 统一启动入口(双路由唯一正确启动方式)。
82
+ *
83
+ * 检测 profile 是否装了 webplater 插件:
84
+ * 装了 → 启动 Chrome(无 CDP 端口,纯插件通信),ping 心跳
85
+ * alive → mode=extension
86
+ * 不alive → mode=extension + notice(心跳缺失)
87
+ * 没装 → 启动 Chrome(带 CDP 端口)→ mode=cdp + notice(建议装插件)
88
+ *
89
+ * @param {string=} url 打开后导航到的 URL(可选,导航在连接后做)
90
+ * @param {Object=} options
91
+ * @param {'auto'|'extension'|'cdp'} options.routeMode 默认 'auto'
92
+ * @param {number=} options.preferPort CDP 模式优先端口
93
+ * @param {boolean=} options.headless CDP 模式 headless(插件模式忽略)
94
+ * @param {(connected:boolean, port?:number)=>void=} options.onStatus
95
+ * @returns {Promise<{ok:boolean, mode:'extension'|'cdp', port?:number, wsEndpoint?:string, pid?:number, notice?:Object|null, error?:string}>}
96
+ */
97
+ async open (url, options = {}) {
98
+ const routeMode = options.routeMode || 'auto'
99
+ const userDataDir = this.userDataDirOverride || this.launcher.getUserDataDir(this.profileRoot || '', 'default')
100
+
101
+ // routeMode=auto 才检测;extension/cdp 强制走对应通道
102
+ // 注意: 传 userDataDir (profiles/default), 不是 profileRoot (profiles/)。
103
+ // 扩展在 user-data-dir/Default/Secure Preferences 下。
104
+ const detect = routeMode === 'auto' && userDataDir
105
+ ? detectWebplaterExtension(userDataDir)
106
+ : null
107
+ const forceCdp = routeMode === 'cdp'
108
+ let tryPlugin = routeMode === 'extension' || (routeMode === 'auto' && detect && detect.installed && !detect.disabled)
109
+ let pluginFallbackNotice = null
110
+
111
+ // 启动前:开插件 WS server(插件链路需要;CDP 链路开起来也不碍事,便于 checkRoute)
112
+ // startServer 现在 EADDRINUSE 会 reject: 17321 被占 = 已有 browserd 在管插件
113
+ if (!this._serverStarted) {
114
+ try {
115
+ await this.web.startServer()
116
+ this._serverStarted = true
117
+ } catch (e) {
118
+ const portBusy = /EADDRINUSE|already in use/.test(e.message)
119
+ const message = portBusy
120
+ ? 'WebPlater WS 端口 17321 已被占用(另一个 browserd 正在管理插件,或残留进程)。'
121
+ : `WS server 启动失败: ${e.message}`
122
+ if (routeMode === 'extension') {
123
+ return { ok: false, mode: 'extension', notice: { level: 'error', message }, error: message }
124
+ }
125
+ // auto/CDP: 端口被占不影响 CDP 路, 继续 (不开 server, 后面走 CDP)
126
+ pluginFallbackNotice = { level: portBusy ? 'warn' : 'error', message }
127
+ tryPlugin = false
128
+ }
129
+ }
130
+
131
+ // 进程内已运行 → 复用,返回当前 mode
132
+ if (this.launcher.isRunning && this.launcher.currentResult) {
133
+ const mode = forceCdp
134
+ ? 'cdp'
135
+ : routeMode === 'extension'
136
+ ? 'extension'
137
+ : (this.web.isPluginAlive() ? 'extension' : 'cdp')
138
+ this.currentMode = mode
139
+ return { ok: true, mode, pid: this.launcher.processId, notice: null }
140
+ }
141
+
142
+ // CDP 路由跨进程复用检测: 读 .cdp-endpoint 拿 wsEndpoint, 端口活着就连。
143
+ // (扩展路由复用检测已在 startServer 前完成)
144
+ if (!tryPlugin) {
145
+ const probe = await cdpState.probe(userDataDir)
146
+ if (probe.active) {
147
+ this.info('cdp profile already in use via %s (port=%s) — reusing',
148
+ probe.via, probe.port || 'n/a')
149
+ this.currentMode = 'cdp'
150
+ this.currentPort = probe.port
151
+ this.currentWsEndpoint = probe.wsEndpoint
152
+ return {
153
+ ok: true, mode: 'cdp',
154
+ port: probe.port, wsEndpoint: probe.wsEndpoint,
155
+ notice: { level: 'info', message: `检测到该 profile 的 Chrome 已由其他进程启动,已复用,未重复启动。` }
156
+ }
157
+ }
158
+ }
159
+
160
+ // tryPlugin=true → 插件模式启动(无端口)
161
+ if (tryPlugin) {
162
+ // ★ 扩展路 handshake-first: startServer 后先等已有 Chrome 的 offscreen 回连 (2s)。
163
+ // 已有 Chrome 开着 (别的进程起的/上次没关干净) → offscreen 自动重连当前 17321 → 复用, 不 launch。
164
+ // 没回连 → Chrome 没开或坏了 → 才 launch。
165
+ const reused = await this.waitForPluginHeartbeat(2000)
166
+ if (reused) {
167
+ this.info('open mode=extension (reused: plugin already connected, skip launch)')
168
+ this.currentMode = 'extension'
169
+ extState.write(userDataDir, { wsPort: extState.DEFAULT_WS_PORT, lastHeartbeatAt: Date.now() })
170
+ globalState.writeGlobal(userDataDir, { profile: 'default' })
171
+ return { ok: true, mode: 'extension', notice: { level: 'info', message: '检测到该 profile 的 Chrome 已开且插件已连接,复用,未重复启动。' } }
172
+ }
173
+
174
+ // 没回连 → Chrome 没开, 正常 launch
175
+ const result = await this.launcher.launch({
176
+ executablePath: this.executablePath,
177
+ userDataDir,
178
+ url,
179
+ enableCdpPort: false
180
+ })
181
+ if (!result.ok) {
182
+ this.errorLog('plugin-mode launch failed: %s', result.error)
183
+ // launch 失败也要清理本次 server 占用的端口
184
+ await this.web.stopServer().catch(() => {})
185
+ this._serverStarted = false
186
+ return { ok: false, mode: 'extension', notice: null, error: result.error }
187
+ }
188
+ // 启动响应后等 offscreen hello (offscreen + extension cold start 不稳定, 用 15s)
189
+ const alive = await this.waitForPluginHeartbeat(15000)
190
+ if (options.onStatus) options.onStatus(alive, undefined)
191
+ if (alive) {
192
+ this.info('open mode=extension (plugin alive)')
193
+ this.currentMode = 'extension'
194
+ extState.write(userDataDir, { wsPort: extState.DEFAULT_WS_PORT, lastHeartbeatAt: Date.now() })
195
+ globalState.writeGlobal(userDataDir, { profile: 'default' })
196
+ return { ok: true, mode: 'extension', pid: this.launcher.processId, notice: null }
197
+ }
198
+ // 装了但 hello 没来: 必须清理本次启动 (Chrome + server)
199
+ const message =
200
+ `WebPlater 插件已安装但未响应(启动后 15s 内无 hello)。` +
201
+ `本进程不能使用 extension 通道。` +
202
+ (detect ? `当前 profile:${detect.profileDir || userDataDir},扩展 id:${detect.extensionId || '未知'}。` : '')
203
+ this.errorLog('%s', message)
204
+ await this.launcher.stop().catch(() => {})
205
+ await this.web.stopServer().catch(() => {})
206
+ this._serverStarted = false
207
+ this.currentMode = null
208
+ if (routeMode === 'extension') {
209
+ return { ok: false, mode: 'extension', notice: { level: 'error', message }, error: message }
210
+ }
211
+ pluginFallbackNotice = { level: 'error', message }
212
+ tryPlugin = false
213
+ }
214
+
215
+ // 没装 / 被禁用 → CDP 模式启动(带端口)
216
+ const preferPort = options.preferPort && options.preferPort > 0 ? options.preferPort : undefined
217
+ const result = await this.launcher.launch({
218
+ executablePath: this.executablePath,
219
+ userDataDir,
220
+ url,
221
+ enableCdpPort: true,
222
+ preferPort,
223
+ headless: options.headless
224
+ })
225
+ if (!result.ok) {
226
+ this.errorLog('cdp-mode launch failed: %s', result.error)
227
+ return { ok: false, mode: 'cdp', notice: null, error: result.error }
228
+ }
229
+ if (options.onStatus) options.onStatus(true, result.port)
230
+
231
+ const notice = pluginFallbackNotice ||
232
+ (forceCdp
233
+ ? null
234
+ : detect && detect.disabled
235
+ ? { level: 'warn', message: 'WebPlater 插件已安装但被禁用,当前使用 CDP 模式。请在浏览器扩展页启用 WebPlater 以使用插件模式(对高危平台更安全)。' }
236
+ : { level: 'warn', message: '未检测到 WebPlater 插件,当前使用 CDP 模式。对于淘宝/京东等高危平台,CDP(--remote-debugging-port)可能触发风控,建议安装 WebPlater 插件以使用插件模式。' })
237
+ this.info('open mode=cdp (forceCdp=%s)', forceCdp)
238
+
239
+ this.currentMode = 'cdp'
240
+ this.currentPort = result.port
241
+ this.currentWsEndpoint = result.wsEndpoint
242
+ // 写状态: 供其他进程 connectOverCDP 复用
243
+ cdpState.write(userDataDir, { wsEndpoint: result.wsEndpoint, port: result.port, pid: this.launcher.processId })
244
+ globalState.writeGlobal(userDataDir, { profile: 'default' })
245
+ return { ok: true, mode: 'cdp', port: result.port, wsEndpoint: result.wsEndpoint, pid: this.launcher.processId, notice }
246
+ }
247
+
248
+ /**
249
+ * 关闭浏览器 + 停止 WS server。
250
+ * 先关 CDP 捕获服务(若开了),再关 Chrome 进程,最后停 server。
251
+ */
252
+ async close () {
253
+ try { await this.captureService.disable() } catch {}
254
+ // 清理状态文件 (本进程是路由主人时才清)
255
+ const ud = this.userDataDirOverride || this.launcher.getUserDataDir(this.profileRoot || '', 'default')
256
+ try { cdpState.clear(ud) } catch {}
257
+ try { extState.clear(ud) } catch {}
258
+ try { globalState.clearGlobal(ud) } catch {}
259
+ this.currentMode = null
260
+ this.currentPort = null
261
+ this.currentWsEndpoint = null
262
+ await this.launcher.stop()
263
+ if (this._serverStarted) {
264
+ await this.web.stopServer()
265
+ this._serverStarted = false
266
+ }
267
+ }
268
+
269
+ /**
270
+ * 等插件心跳到达(最多 timeoutMs)。插件模式启动后 WS 需时间连上。
271
+ */
272
+ async waitForPluginHeartbeat (timeoutMs = 8000) {
273
+ const deadline = Date.now() + timeoutMs
274
+ while (Date.now() < deadline) {
275
+ if (this.web.isPluginAlive()) return true
276
+ await new Promise((r) => setTimeout(r, 300))
277
+ }
278
+ return this.web.isPluginAlive()
279
+ }
280
+
281
+ // ── CDP 链路:捕获模式 ──
282
+
283
+ /** 开启 CDP 链路捕获(向所有 page 注入 captureInjectJS)。仅 mode=cdp 时有意义。 */
284
+ async enableCapture () {
285
+ if (this.currentMode !== 'cdp' || !this.currentPort) {
286
+ throw new Error('enableCapture (CDP) requires mode=cdp; current mode=' + this.currentMode)
287
+ }
288
+ return this.captureService.enable(this.currentPort)
289
+ }
290
+
291
+ /** 关闭 CDP 链路捕获。 */
292
+ async disableCapture () {
293
+ return this.captureService.disable()
294
+ }
295
+
296
+ // ── 插件链路 API 转发 ──
297
+ //
298
+ // 以下全部转发到 WebService(callPlugin)。
299
+ // 仅 mode=extension 时有意义;mode=cdp 下调用会因 "Plugin not connected" 抛错。
300
+
301
+ get isConnected () { return this.web.isConnected }
302
+ isPluginAlive (thresholdMs) { return this.web.isPluginAlive(thresholdMs) }
303
+ checkRoute () { return this.web.checkRoute() }
304
+
305
+ listTabs () { return this.web.listTabs() }
306
+ switchTab (tabId) { return this.web.switchTab(tabId) }
307
+ reuseTab (url, urlMatch) { return this.web.reuseTab(url, urlMatch) }
308
+ closeTab (tabId) { return this.web.closeTab(tabId) }
309
+ newTab (url) { return this.web.newTab(url) }
310
+
311
+ // ── 下载 (chrome.downloads API, 原生监听不轮询目录) ──
312
+ listDownloads (limit) { return this.web.listDownloads(limit) }
313
+ getDownload (id) { return this.web.getDownload(id) }
314
+ waitForDownload (opts) { return this.web.waitForDownload(opts) }
315
+
316
+ goto (url, tabId) { return this.web.goto(url, tabId) }
317
+ goBack (tabId) { return this.web.goBack(tabId) }
318
+ goForward (tabId) { return this.web.goForward(tabId) }
319
+ reload (tabId) { return this.web.reload(tabId) }
320
+ waitForLoadState (state, tabId) { return this.web.waitForLoadState(state, tabId) }
321
+ waitForURL (url, timeout, tabId) { return this.web.waitForURL(url, timeout, tabId) }
322
+ status (tabId) { return this.web.status(tabId) }
323
+
324
+ click (selector, x, y, tabId) { return this.web.click(selector, x, y, tabId) }
325
+ dblclick (selector, tabId) { return this.web.dblclick(selector, tabId) }
326
+ fill (selector, value, tabId) { return this.web.fill(selector, value, tabId) }
327
+ type (selector, value, delay, tabId) { return this.web.type(selector, value, delay, tabId) }
328
+ press (selector, key, tabId) { return this.web.press(selector, key, tabId) }
329
+ hover (selector, x, y, tabId) { return this.web.hover(selector, x, y, tabId) }
330
+ mouseMove (x, y, tabId) { return this.web.mouseMove(x, y, tabId) }
331
+ focus (selector, tabId) { return this.web.focus(selector, tabId) }
332
+ check (selector, tabId) { return this.web.check(selector, tabId) }
333
+ uncheck (selector, tabId) { return this.web.uncheck(selector, tabId) }
334
+ selectOption (selector, value, tabId) { return this.web.selectOption(selector, value, tabId) }
335
+ setInputFiles (selector, files, tabId) { return this.web.setInputFiles(selector, files, tabId) }
336
+ dragTo (source, target, tabId) { return this.web.dragTo(source, target, tabId) }
337
+
338
+ // 坐标/文本点击 + 用户视角定位 (扩展路=合成事件, 不走 CDP/debugger)
339
+ clickAt (x, y, opts, tabId) { return this.web.clickAt(x, y, opts || {}, tabId) }
340
+ locateVisibleText (text, exact, index, tabId) { return this.web.locateVisibleText(text, exact, index, tabId) }
341
+ clickByText (p, tabId) { return this.web.clickByText(p || {}, tabId) }
342
+ operateSequence (steps, tabId) { return this.web.operateSequence(steps || [], tabId) }
343
+
344
+ waitForSelector (selector, timeout, tabId) { return this.web.waitForSelector(selector, timeout, tabId) }
345
+ waitForFunction (source, timeout, args, tabId) { return this.web.waitForFunction(source, timeout, args, tabId) }
346
+ waitForTimeout (ms, tabId) { return this.web.waitForTimeout(ms, tabId) }
347
+
348
+ // evaluate: frameId 透传给扩展路 (WebService.evaluate 第 4 参), 用于 eval 进指定 iframe (如登录框)
349
+ evaluate (source, args, tabId, frameId) { return this.web.evaluate(source, args, tabId, frameId) }
350
+ // listFrames: 枚举当前 tab 的所有 frame (含 iframe), 返回 [{frameId, url, parentFrameId}]
351
+ listFrames (tabId) { return this.web.listFrames(tabId) }
352
+ innerHTML (selector, tabId) { return this.web.innerHTML(selector, tabId) }
353
+ innerText (selector, tabId) { return this.web.innerText(selector, tabId) }
354
+ textContent (selector, tabId) { return this.web.textContent(selector, tabId) }
355
+ getAttribute (selector, name, tabId) { return this.web.getAttribute(selector, name, tabId) }
356
+ inputValue (selector, tabId) { return this.web.inputValue(selector, tabId) }
357
+ boundingBox (selector, tabId) { return this.web.boundingBox(selector, tabId) }
358
+ count (selector, tabId) { return this.web.count(selector, tabId) }
359
+ snapshot (options, tabId) { return this.web.snapshot(options, tabId) }
360
+
361
+ getLocalStorage (keys, tabId) { return this.web.getLocalStorage(keys, tabId) }
362
+ setLocalStorage (items, tabId) { return this.web.setLocalStorage(items, tabId) }
363
+ removeLocalStorage (keys, tabId) { return this.web.removeLocalStorage(keys, tabId) }
364
+ clearLocalStorage (tabId) { return this.web.clearLocalStorage(tabId) }
365
+
366
+ screenshot (tabId) { return this.web.screenshot(tabId) }
367
+
368
+ getCookies (filter) { return this.web.getCookies(filter) }
369
+ setCookies (cookies) { return this.web.setCookies(cookies) }
370
+ clearCookies (filter) { return this.web.clearCookies(filter) }
371
+
372
+ setDialogHandler (handlerSource, tabId) { return this.web.setDialogHandler(handlerSource, tabId) }
373
+
374
+ /** 插件链路捕获:把 captureInjectJS 字符串下发给插件 */
375
+ enablePluginCapture (script) { return this.web.enableCapture(script || captureInjectJS) }
376
+ disablePluginCapture () { return this.web.disableCapture() }
377
+ }
378
+
379
+ module.exports = {
380
+ BrowserOp,
381
+ ChromePathResolver,
382
+ CdpBrowserLauncher,
383
+ WebService,
384
+ CaptureService,
385
+ detectWebplaterExtension,
386
+ WEBPLATER_EXTENSION_ID: 'npkapgkjeaaipldb',
387
+ WEBPLATER_EXTENSION_NAME: 'WebPlater',
388
+ captureInjectJS
389
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "browser-op",
3
+ "version": "0.1.0",
4
+ "description": "Dual-route (CDP + WebPlater extension) browser automation. Ports bee/packages/app/browser-op.",
5
+ "main": "index.cjs",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "browser-op": "index.cjs"
9
+ },
10
+ "dependencies": {
11
+ "cheerio": "^1.2.0",
12
+ "playwright-core": "^1.61.0",
13
+ "turndown": "^7.2.4",
14
+ "ws": "^8.21.0"
15
+ },
16
+ "_comment_runtime_deps": "这些依赖由上层 @mooncat/browser 的 package.json dependencies 安装到包根 node_modules;browserd.cjs 的 require 从此处向上解析。playwright-core 用于 connectOverCDP 连接宿主 Chrome,不下载自带浏览器。"
17
+ }
@@ -0,0 +1,138 @@
1
+ """web/behavior — 人类行为模拟 (路由无关, 全走 operate).
2
+
3
+ 忠实复刻 lib/web/behavior.js。
4
+ 贝塞尔鼠标轨迹 / 随机点击偏移 / 打字节奏 / 平滑滚动。
5
+ 所有页面动作统一走 operate(pageHandle, {action}),本模块只编排动作序列。
6
+
7
+ 依赖: browser (operate)
8
+ """
9
+ from __future__ import annotations
10
+ import math
11
+ import random
12
+ import time
13
+ from typing import Any
14
+
15
+ import sys
16
+ sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else __file__.rsplit("/", 1)[0])
17
+ from browser import operate # noqa: E402
18
+
19
+
20
+ def _bezier(p0: float, p1: float, p2: float, p3: float, t: float) -> float:
21
+ u = 1 - t
22
+ return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
23
+
24
+
25
+ def _trajectory(from_pt: dict, to_pt: dict, steps: int) -> list[dict]:
26
+ """生成贝塞尔轨迹点 (含随机抖动)."""
27
+ pts = []
28
+ mx = (from_pt["x"] + to_pt["x"]) / 2 + (random.random() - 0.5) * abs(to_pt["x"] - from_pt["x"]) * 0.6
29
+ my = (from_pt["y"] + to_pt["y"]) / 2 + (random.random() - 0.5) * abs(to_pt["y"] - from_pt["y"]) * 0.6
30
+ c1 = {"x": from_pt["x"] + (mx - from_pt["x"]) * 0.3, "y": from_pt["y"] + (my - from_pt["y"]) * 0.3}
31
+ c2 = {"x": to_pt["x"] + (mx - to_pt["x"]) * 0.3, "y": to_pt["y"] + (my - to_pt["y"]) * 0.3}
32
+ for i in range(steps + 1):
33
+ t = i / steps
34
+ pts.append({
35
+ "x": _bezier(from_pt["x"], c1["x"], c2["x"], to_pt["x"], t) + (random.random() - 0.5) * 2,
36
+ "y": _bezier(from_pt["y"], c1["y"], c2["y"], to_pt["y"], t) + (random.random() - 0.5) * 2,
37
+ })
38
+ return pts
39
+
40
+
41
+ def _random_point_in_rect(rect: dict) -> dict:
42
+ return {
43
+ "x": rect["x"] + rect["width"] * (0.3 + random.random() * 0.4),
44
+ "y": rect["y"] + rect["height"] * (0.3 + random.random() * 0.4),
45
+ }
46
+
47
+
48
+ def _move_trajectory(page_handle: dict, from_pt: dict, to_pt: dict) -> None:
49
+ """沿贝塞尔轨迹多点 mouseMove 注入."""
50
+ pts = _trajectory(from_pt, to_pt, 10 + random.randint(0, 5))
51
+ for pt in pts:
52
+ operate(page_handle, {"action": "mouseMove", "x": pt["x"], "y": pt["y"]})
53
+ time.sleep(0.008 + random.random() * 0.016)
54
+
55
+
56
+ def create_behavior(page_handle: dict) -> dict:
57
+ """创建绑定到 pageHandle 的行为模拟器.
58
+
59
+ 返回: move_to, click, click_element, type_text, type_in_element, scroll_down, scroll_to_bottom, scroll_to_element
60
+ """
61
+ if not page_handle:
62
+ raise ValueError("create_behavior: page_handle required")
63
+ last_pos = {"x": 100, "y": 100}
64
+
65
+ def move_to(x: float, y: float) -> None:
66
+ nonlocal last_pos
67
+ _move_trajectory(page_handle, last_pos, {"x": x, "y": y})
68
+ last_pos = {"x": x, "y": y}
69
+
70
+ def click(x: float, y: float, options: dict | None = None) -> None:
71
+ nonlocal last_pos
72
+ _move_trajectory(page_handle, last_pos, {"x": x, "y": y})
73
+ last_pos = {"x": x, "y": y}
74
+ time.sleep(0.05 + random.random() * 0.1)
75
+ operate(page_handle, {"action": "click", "x": x, "y": y})
76
+ delay = (options or {}).get("delay", 0.05 + random.random() * 0.1)
77
+ time.sleep(delay)
78
+
79
+ def click_element(selector: str, timeout: int = 10000) -> None:
80
+ nonlocal last_pos
81
+ operate(page_handle, {"action": "waitForSelector", "selector": selector, "timeout": timeout})
82
+ box_r = operate(page_handle, {"action": "boundingBox", "selector": selector})
83
+ box = None
84
+ if isinstance(box_r, dict) and box_r.get("width") is not None:
85
+ box = box_r
86
+ if not box:
87
+ operate(page_handle, {"action": "click", "selector": selector})
88
+ time.sleep(0.05 + random.random() * 0.1)
89
+ return
90
+ pt = _random_point_in_rect(box)
91
+ move_to(pt["x"], pt["y"])
92
+ time.sleep(0.05 + random.random() * 0.1)
93
+ operate(page_handle, {"action": "click", "x": pt["x"], "y": pt["y"]})
94
+ time.sleep(0.05 + random.random() * 0.1)
95
+
96
+ def type_text(text: str, delay: float = 0.1) -> None:
97
+ for ch in text:
98
+ operate(page_handle, {"action": "press", "selector": "body", "key": ch})
99
+ d = delay * (0.5 + random.random())
100
+ if random.random() < 0.1:
101
+ d += 0.2 + random.random() * 0.4
102
+ time.sleep(d)
103
+
104
+ def type_in_element(selector: str, text: str, options: dict | None = None) -> None:
105
+ opts = options or {}
106
+ delay = opts.get("delay", 0.1)
107
+ timeout = opts.get("timeout", 10000)
108
+ operate(page_handle, {"action": "waitForSelector", "selector": selector, "timeout": timeout})
109
+ operate(page_handle, {"action": "click", "selector": selector})
110
+ operate(page_handle, {"action": "type", "selector": selector, "value": text, "delay": delay})
111
+
112
+ def scroll_down(pixels: int = 400) -> None:
113
+ step = 30 + random.randint(0, 29)
114
+ operate(page_handle, {
115
+ "action": "evaluate",
116
+ "source": f"(p) => new Promise((r) => {{ let done = 0; const t = setInterval(() => {{ window.scrollBy(0, {step}); done += {step}; if (done >= p) {{ clearInterval(t); r() }} }}, 30 + Math.random() * 30) }})",
117
+ "args": pixels
118
+ })
119
+
120
+ def scroll_to_bottom() -> None:
121
+ operate(page_handle, {
122
+ "action": "evaluate",
123
+ "source": "() => new Promise((resolve) => { let last=-1; const t=setInterval(()=>{ window.scrollBy(0,100+Math.random()*100); const h=document.body.scrollHeight; if(h===last){clearInterval(t);resolve()} last=h }, 150) })"
124
+ })
125
+
126
+ def scroll_to_element(selector: str, timeout: int = 10000) -> None:
127
+ operate(page_handle, {
128
+ "action": "evaluate",
129
+ "source": f'(s) => {{ const el = document.querySelector(s); if (el) el.scrollIntoView({{ behavior: "smooth", block: "center" }}) }}',
130
+ "args": selector
131
+ })
132
+
133
+ return {
134
+ "move_to": move_to, "click": click, "click_element": click_element,
135
+ "type_text": type_text, "type_in_element": type_in_element,
136
+ "scroll_down": scroll_down, "scroll_to_bottom": scroll_to_bottom,
137
+ "scroll_to_element": scroll_to_element,
138
+ }