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,370 @@
|
|
|
1
|
+
// -*- coding: utf-8 -*-
|
|
2
|
+
//
|
|
3
|
+
// launcher.cjs — CDP 模式浏览器启动器。
|
|
4
|
+
// 完整复刻自 bee/packages/app/browser-op/main/cdp-browser-launcher.ts(去 DI、纯 JS)。
|
|
5
|
+
//
|
|
6
|
+
// 职责:
|
|
7
|
+
// - spawn Chrome(带 --remote-debugging-port 或不带,enableCdpPort 控制)
|
|
8
|
+
// - 分配空闲端口(listen 0 拿可用端口)
|
|
9
|
+
// - 等 CDP endpoint 就绪(轮询 http://127.0.0.1:<port>/json/version 拿 webSocketDebuggerUrl)
|
|
10
|
+
// - 停止进程(Win 用 taskkill /T /F,Unix 用 SIGTERM→SIGKILL)
|
|
11
|
+
|
|
12
|
+
'use strict'
|
|
13
|
+
|
|
14
|
+
const { execFile, spawn } = require('node:child_process')
|
|
15
|
+
const { createServer } = require('node:net')
|
|
16
|
+
const { join } = require('node:path')
|
|
17
|
+
const { promisify } = require('node:util')
|
|
18
|
+
const http = require('node:http')
|
|
19
|
+
const fs = require('node:fs')
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 跨进程检测某个 user-data-dir 的 Chrome 是否正在运行, 并返回其 CDP 端点。
|
|
25
|
+
*
|
|
26
|
+
* 背景:AI 协作场景下每个脚本是独立 node 进程, 进程内的 launcher.isRunning
|
|
27
|
+
* 只能看本进程启的 Chrome, 看不到别的进程启的同一 profile。每个脚本都
|
|
28
|
+
* 以为"还没开"又启动一个 Chrome, 同一 user-data-dir 被启 N 次 = N 个窗口。
|
|
29
|
+
*
|
|
30
|
+
* 正确做法:Chrome 用 --remote-debugging-port 启动后, 会把端口和 ws path
|
|
31
|
+
* 写进 user-data-dir/DevToolsActivePort 文件 (Chrome 原生机制)。别的进程
|
|
32
|
+
* 读这个文件拿端点, 直接 connectOverCDP 连上去, 不要再 spawn。
|
|
33
|
+
*
|
|
34
|
+
* @param {string} userDataDir
|
|
35
|
+
* @param {number[]=} candidatePorts 兼容用:手动指定端口时试探 (一般留空, 靠 DevToolsActivePort)
|
|
36
|
+
* @returns {Promise<{inUse:boolean, port?:number, wsEndpoint?:string, via:string}>}
|
|
37
|
+
*/
|
|
38
|
+
async function isProfileInUse (userDataDir, candidatePorts = []) {
|
|
39
|
+
// 1. 读 Chrome 原生的 DevToolsActivePort 文件 (最可靠)
|
|
40
|
+
if (userDataDir) {
|
|
41
|
+
const endpoint = readDevToolsActivePort(userDataDir)
|
|
42
|
+
if (endpoint && await isCdpPortAlive(endpoint.port)) {
|
|
43
|
+
return {
|
|
44
|
+
inUse: true,
|
|
45
|
+
port: endpoint.port,
|
|
46
|
+
wsEndpoint: `ws://127.0.0.1:${endpoint.port}${endpoint.wsPath}`,
|
|
47
|
+
via: 'devtools-active-port'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// 2. fallback: 试连手工指定的端口
|
|
52
|
+
for (const port of candidatePorts) {
|
|
53
|
+
if (await isCdpPortAlive(port)) {
|
|
54
|
+
// 拿该端口的 ws path
|
|
55
|
+
const wsPath = await fetchBrowserWsPath(port)
|
|
56
|
+
return { inUse: true, port, wsEndpoint: `ws://127.0.0.1:${port}${wsPath}`, via: 'cdp-port' }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { inUse: false, via: 'none' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读 user-data-dir/DevToolsActivePort 文件。
|
|
64
|
+
* 格式: 第一行端口, 第二行 ws path (如 /devtools/browser/<uuid>)。
|
|
65
|
+
* 返回 { port, wsPath } 或 null。
|
|
66
|
+
*/
|
|
67
|
+
function readDevToolsActivePort (userDataDir) {
|
|
68
|
+
try {
|
|
69
|
+
const f = join(userDataDir, 'DevToolsActivePort')
|
|
70
|
+
if (!fs.existsSync(f)) return null
|
|
71
|
+
const raw = fs.readFileSync(f, 'utf8').trim()
|
|
72
|
+
const lines = raw.split(/\r?\n/)
|
|
73
|
+
const port = parseInt(lines[0], 10)
|
|
74
|
+
const wsPath = lines[1] || ''
|
|
75
|
+
if (!port || !wsPath) return null
|
|
76
|
+
return { port, wsPath }
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 从 /json/version 拿 browser 的 ws path (fallback 用)。
|
|
84
|
+
*/
|
|
85
|
+
async function fetchBrowserWsPath (port) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const req = http.get(`http://127.0.0.1:${port}/json/version`, { timeout: 800 }, (res) => {
|
|
88
|
+
let body = ''
|
|
89
|
+
res.on('data', (c) => { body += c })
|
|
90
|
+
res.on('end', () => {
|
|
91
|
+
try { resolve(JSON.parse(body).webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/, '') || '') }
|
|
92
|
+
catch { resolve('') }
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
req.on('timeout', () => { req.destroy(); resolve('') })
|
|
96
|
+
req.on('error', () => resolve(''))
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 探测某个端口是否有 Chrome CDP 服务在监听。
|
|
102
|
+
* GET http://127.0.0.1:port/json/version 200 即活。
|
|
103
|
+
*/
|
|
104
|
+
function isCdpPortAlive (port) {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const req = http.get(
|
|
107
|
+
`http://127.0.0.1:${port}/json/version`,
|
|
108
|
+
{ timeout: 400 },
|
|
109
|
+
(res) => {
|
|
110
|
+
res.resume()
|
|
111
|
+
resolve(res.statusCode === 200)
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
req.on('timeout', () => { req.destroy(); resolve(false) })
|
|
115
|
+
req.on('error', () => resolve(false))
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @typedef {Object} CdpLaunchResult
|
|
121
|
+
* @property {boolean} ok
|
|
122
|
+
* @property {number=} port
|
|
123
|
+
* @property {string=} wsEndpoint
|
|
124
|
+
* @property {string=} error
|
|
125
|
+
*/
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @typedef {Object} CdpLaunchOptions
|
|
129
|
+
* @property {string} executablePath
|
|
130
|
+
* @property {string} userDataDir
|
|
131
|
+
* @property {string=} url
|
|
132
|
+
* @property {boolean=} headless
|
|
133
|
+
* @property {number=} preferPort
|
|
134
|
+
* @property {NodeJS.ProcessEnv=} environment
|
|
135
|
+
* @property {boolean=} enableCdpPort true(默认)=CDP 模式;false=插件模式(纯 WS,不可 CDP 连)
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
class CdpBrowserLauncher {
|
|
139
|
+
constructor () {
|
|
140
|
+
this.chromeProcess = null
|
|
141
|
+
this.closeCallbacks = new Set()
|
|
142
|
+
this.activeResult = null
|
|
143
|
+
this.intentionalStop = false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
onClose (callback) {
|
|
147
|
+
this.closeCallbacks.add(callback)
|
|
148
|
+
return () => this.closeCallbacks.delete(callback)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async findAvailablePort () {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const server = createServer()
|
|
154
|
+
server.unref()
|
|
155
|
+
server.on('error', reject)
|
|
156
|
+
server.listen(0, '127.0.0.1', () => {
|
|
157
|
+
const port = server.address().port
|
|
158
|
+
server.close(() => resolve(port))
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get isRunning () {
|
|
164
|
+
return this.chromeProcess !== null && !hasProcessExited(this.chromeProcess)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get processId () {
|
|
168
|
+
return this.chromeProcess ? this.chromeProcess.pid : undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get currentResult () {
|
|
172
|
+
return this.activeResult
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {CdpLaunchOptions} options
|
|
177
|
+
* @returns {Promise<CdpLaunchResult>}
|
|
178
|
+
*/
|
|
179
|
+
async launch (options) {
|
|
180
|
+
if (this.isRunning && this.activeResult) return this.activeResult
|
|
181
|
+
|
|
182
|
+
const enableCdpPort = options.enableCdpPort !== false
|
|
183
|
+
const port = enableCdpPort && options.preferPort && options.preferPort > 0
|
|
184
|
+
? options.preferPort
|
|
185
|
+
: enableCdpPort
|
|
186
|
+
? await this.findAvailablePort()
|
|
187
|
+
: undefined
|
|
188
|
+
|
|
189
|
+
const args = [
|
|
190
|
+
...(port != null ? [`--remote-debugging-port=${port}`] : []),
|
|
191
|
+
`--user-data-dir=${options.userDataDir}`,
|
|
192
|
+
'--no-first-run',
|
|
193
|
+
'--no-default-browser-check'
|
|
194
|
+
]
|
|
195
|
+
if (options.headless) args.push('--headless=new')
|
|
196
|
+
|
|
197
|
+
// NOTE: 不在启动参数里带业务 URL,避免 Chrome 一启动就被风控脚本采集
|
|
198
|
+
// (对齐 mooncat:先启动空白页,连接后再由 page.goto 导航)
|
|
199
|
+
|
|
200
|
+
let spawnError
|
|
201
|
+
try {
|
|
202
|
+
const child = spawn(options.executablePath, args, {
|
|
203
|
+
stdio: 'ignore',
|
|
204
|
+
detached: true,
|
|
205
|
+
windowsHide: true,
|
|
206
|
+
env: options.environment || process.env
|
|
207
|
+
})
|
|
208
|
+
child.unref()
|
|
209
|
+
this.chromeProcess = child
|
|
210
|
+
this.intentionalStop = false
|
|
211
|
+
|
|
212
|
+
child.once('error', (error) => {
|
|
213
|
+
spawnError = error
|
|
214
|
+
})
|
|
215
|
+
child.once('close', () => {
|
|
216
|
+
if (this.chromeProcess !== child) return
|
|
217
|
+
this.chromeProcess = null
|
|
218
|
+
this.activeResult = null
|
|
219
|
+
if (!this.intentionalStop) {
|
|
220
|
+
for (const cb of this.closeCallbacks) cb()
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// 插件模式(无端口):不等 CDP 端点,只确认进程活着即可
|
|
225
|
+
if (port == null) {
|
|
226
|
+
await waitForProcessReady(child, 8000)
|
|
227
|
+
this.activeResult = { ok: true }
|
|
228
|
+
return this.activeResult
|
|
229
|
+
}
|
|
230
|
+
const wsEndpoint = await this.waitForEndpoint(child, port, 15000, () => spawnError)
|
|
231
|
+
this.activeResult = { ok: true, port, wsEndpoint }
|
|
232
|
+
return this.activeResult
|
|
233
|
+
} catch (error) {
|
|
234
|
+
await this.stop()
|
|
235
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async waitForEndpoint (child, port, timeoutMs, getSpawnError) {
|
|
240
|
+
const url = `http://127.0.0.1:${port}/json/version`
|
|
241
|
+
const deadline = Date.now() + timeoutMs
|
|
242
|
+
let lastError = ''
|
|
243
|
+
|
|
244
|
+
while (Date.now() < deadline) {
|
|
245
|
+
const spawnError = getSpawnError()
|
|
246
|
+
if (spawnError) throw spawnError
|
|
247
|
+
if (hasProcessExited(child)) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Chrome exited before CDP was ready: ${child.exitCode || child.signalCode || 'unknown'}`
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(2000) })
|
|
254
|
+
if (!response.ok) throw new Error(`CDP responded with HTTP ${response.status}`)
|
|
255
|
+
const data = await response.json()
|
|
256
|
+
if (data.webSocketDebuggerUrl) return data.webSocketDebuggerUrl
|
|
257
|
+
lastError = 'webSocketDebuggerUrl not in response'
|
|
258
|
+
} catch (error) {
|
|
259
|
+
lastError = error instanceof Error ? error.message : String(error)
|
|
260
|
+
}
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`CDP endpoint not ready after ${timeoutMs}ms: ${lastError}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
getUserDataDir (profileRoot, profileId = 'default') {
|
|
267
|
+
return join(profileRoot, profileId)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async stop () {
|
|
271
|
+
// close 语义 (handshake-first 后):
|
|
272
|
+
// - chromeProcess 有值 = 本次 browserd 启动的 Chrome → 杀
|
|
273
|
+
// - chromeProcess 为 null = 复用已有 session (attach) → 不杀, 只让上层关 browserd/17321
|
|
274
|
+
// (杀用户真实浏览器是严重事故)
|
|
275
|
+
const child = this.chromeProcess
|
|
276
|
+
if (!child) {
|
|
277
|
+
this.activeResult = null
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
this.intentionalStop = true
|
|
281
|
+
this.activeResult = null
|
|
282
|
+
const closePromise = waitForProcessClose(child, 5000)
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
let terminationError
|
|
286
|
+
if (process.platform === 'win32' && child.pid) {
|
|
287
|
+
const systemRoot = process.env.SystemRoot || 'C:\\Windows'
|
|
288
|
+
const taskkill = join(systemRoot, 'System32', 'taskkill.exe')
|
|
289
|
+
try {
|
|
290
|
+
await execFileAsync(taskkill, ['/PID', String(child.pid), '/T', '/F'], {
|
|
291
|
+
windowsHide: true
|
|
292
|
+
})
|
|
293
|
+
} catch (error) {
|
|
294
|
+
terminationError = error
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
child.kill('SIGTERM')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const closed = await closePromise
|
|
301
|
+
if (!closed && process.platform !== 'win32' && !hasProcessExited(child)) {
|
|
302
|
+
child.kill('SIGKILL')
|
|
303
|
+
await waitForProcessClose(child, 2000)
|
|
304
|
+
}
|
|
305
|
+
if (process.platform === 'win32' && !closed && !hasProcessExited(child)) {
|
|
306
|
+
throw new Error(`Chrome process tree did not exit: PID ${child.pid}`)
|
|
307
|
+
}
|
|
308
|
+
if (terminationError && !hasProcessExited(child)) throw terminationError
|
|
309
|
+
} finally {
|
|
310
|
+
if (this.chromeProcess === child) this.chromeProcess = null
|
|
311
|
+
this.intentionalStop = false
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function waitForProcessClose (child, timeoutMs) {
|
|
317
|
+
if (hasProcessExited(child)) return true
|
|
318
|
+
return new Promise((resolve) => {
|
|
319
|
+
const timeout = setTimeout(() => {
|
|
320
|
+
child.removeListener('close', onClose)
|
|
321
|
+
resolve(false)
|
|
322
|
+
}, timeoutMs)
|
|
323
|
+
const onClose = () => {
|
|
324
|
+
clearTimeout(timeout)
|
|
325
|
+
resolve(true)
|
|
326
|
+
}
|
|
327
|
+
child.once('close', onClose)
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function hasProcessExited (child) {
|
|
332
|
+
return child.exitCode !== null || child.signalCode !== null
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 等待进程 spawn 完成(插件模式用,无 CDP 端口可探)。
|
|
337
|
+
* 给一点时间让 Chrome 起来;若 spawn 后迅速退出或报 error 则抛出。
|
|
338
|
+
*/
|
|
339
|
+
async function waitForProcessReady (child, timeoutMs) {
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
let done = false
|
|
342
|
+
const finish = (fn) => {
|
|
343
|
+
if (done) return
|
|
344
|
+
done = true
|
|
345
|
+
child.removeListener('error', onError)
|
|
346
|
+
child.removeListener('close', onClose)
|
|
347
|
+
fn()
|
|
348
|
+
}
|
|
349
|
+
const onError = (err) =>
|
|
350
|
+
finish(() => reject(new Error(`Chrome failed to start: ${err.message}`)))
|
|
351
|
+
const onClose = () =>
|
|
352
|
+
finish(() => reject(new Error(
|
|
353
|
+
`Chrome exited immediately: ${child.exitCode || child.signalCode || 'unknown'}`
|
|
354
|
+
)))
|
|
355
|
+
child.once('error', onError)
|
|
356
|
+
child.once('close', onClose)
|
|
357
|
+
setTimeout(() => {
|
|
358
|
+
if (!hasProcessExited(child)) finish(() => resolve())
|
|
359
|
+
}, 1500)
|
|
360
|
+
setTimeout(() => {
|
|
361
|
+
finish(() =>
|
|
362
|
+
hasProcessExited(child)
|
|
363
|
+
? reject(new Error('Chrome exited during startup'))
|
|
364
|
+
: resolve()
|
|
365
|
+
)
|
|
366
|
+
}, timeoutMs)
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = { CdpBrowserLauncher, isProfileInUse, isCdpPortAlive, readDevToolsActivePort }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// -*- coding: utf-8 -*-
|
|
2
|
+
//
|
|
3
|
+
// chrome-path.cjs — 按操作系统自动检测 Chrome 安装路径。
|
|
4
|
+
// 完整复刻自 bee/packages/app/browser-op/main/chrome-path-resolver.ts。
|
|
5
|
+
//
|
|
6
|
+
// 检测顺序:
|
|
7
|
+
// Windows: %LOCALAPPDATA% → Program Files → Program Files (x86)
|
|
8
|
+
// macOS: /Applications/Google Chrome.app
|
|
9
|
+
// Linux: /usr/bin/google-chrome → chromium-browser
|
|
10
|
+
|
|
11
|
+
'use strict'
|
|
12
|
+
|
|
13
|
+
const { existsSync } = require('node:fs')
|
|
14
|
+
const { platform } = require('node:os')
|
|
15
|
+
|
|
16
|
+
class ChromePathResolver {
|
|
17
|
+
/** 尝试所有已知路径,返回第一个存在的路径。全部不存在返回 null */
|
|
18
|
+
detect () {
|
|
19
|
+
const candidates = this.getCandidates()
|
|
20
|
+
for (const candidate of candidates) {
|
|
21
|
+
if (existsSync(candidate)) return candidate
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getCandidates () {
|
|
27
|
+
switch (platform()) {
|
|
28
|
+
case 'win32': return this.getWindowsCandidates()
|
|
29
|
+
case 'darwin': return this.getMacOsCandidates()
|
|
30
|
+
case 'linux': return this.getLinuxCandidates()
|
|
31
|
+
default: return []
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getWindowsCandidates () {
|
|
36
|
+
const localAppData = process.env.LOCALAPPDATA || ''
|
|
37
|
+
const candidates = []
|
|
38
|
+
if (localAppData) {
|
|
39
|
+
candidates.push(`${localAppData}\\Google\\Chrome\\Application\\chrome.exe`)
|
|
40
|
+
}
|
|
41
|
+
candidates.push(
|
|
42
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
43
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
|
|
44
|
+
)
|
|
45
|
+
return candidates
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getMacOsCandidates () {
|
|
49
|
+
return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getLinuxCandidates () {
|
|
53
|
+
return ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium-browser']
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { ChromePathResolver }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// -*- coding: utf-8 -*-
|
|
2
|
+
//
|
|
3
|
+
// lib/cdp/state.cjs — CDP 路由专属运行时状态。
|
|
4
|
+
//
|
|
5
|
+
// CDP 路由的跨进程复用靠 wsEndpoint 字符串 (对齐 playwright/puppeteer):
|
|
6
|
+
// 主进程 launch 成功 → 把 wsEndpoint/port 写入 <userDataDir>/.cdp-endpoint
|
|
7
|
+
// 子进程 open 开头 → 读该文件, 有且端口活着 → connectOverCDP 复用, 不再 spawn
|
|
8
|
+
//
|
|
9
|
+
// 文件位置: <userDataDir>/.cdp-endpoint
|
|
10
|
+
|
|
11
|
+
'use strict'
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
const http = require('node:http')
|
|
16
|
+
|
|
17
|
+
const STATE_FILENAME = '.cdp-endpoint'
|
|
18
|
+
|
|
19
|
+
function statePath (userDataDir) {
|
|
20
|
+
return path.join(userDataDir, STATE_FILENAME)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 读 CDP 状态。返回 { wsEndpoint, port, pid, updatedAt } 或 null。 */
|
|
24
|
+
function read (userDataDir) {
|
|
25
|
+
try {
|
|
26
|
+
const p = statePath(userDataDir)
|
|
27
|
+
if (!fs.existsSync(p)) return null
|
|
28
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 写 CDP 状态 (主进程 launch 拿到 wsEndpoint 后调)。 */
|
|
35
|
+
function write (userDataDir, info) {
|
|
36
|
+
try {
|
|
37
|
+
const p = statePath(userDataDir)
|
|
38
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
39
|
+
fs.writeFileSync(p, JSON.stringify({
|
|
40
|
+
wsEndpoint: info.wsEndpoint || null,
|
|
41
|
+
port: info.port || null,
|
|
42
|
+
pid: info.pid || process.pid,
|
|
43
|
+
updatedAt: Date.now()
|
|
44
|
+
}, null, 2))
|
|
45
|
+
} catch { /* ignore */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 清 CDP 状态 (close 后调)。 */
|
|
49
|
+
function clear (userDataDir) {
|
|
50
|
+
try {
|
|
51
|
+
const p = statePath(userDataDir)
|
|
52
|
+
if (fs.existsSync(p)) fs.unlinkSync(p)
|
|
53
|
+
} catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 探测 CDP 端口是否还活着 (GET /json/version 200)。 */
|
|
57
|
+
function isPortAlive (port, timeoutMs = 500) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const req = http.get(`http://127.0.0.1:${port}/json/version`, { timeout: timeoutMs }, (res) => {
|
|
60
|
+
res.resume()
|
|
61
|
+
resolve(res.statusCode === 200)
|
|
62
|
+
})
|
|
63
|
+
req.on('timeout', () => { req.destroy(); resolve(false) })
|
|
64
|
+
req.on('error', () => resolve(false))
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 读状态 + 验证端口存活, 返回是否可复用。
|
|
70
|
+
* @returns {Promise<{active:boolean, wsEndpoint?:string, port?:number, via:string}>}
|
|
71
|
+
*/
|
|
72
|
+
async function probe (userDataDir) {
|
|
73
|
+
const st = read(userDataDir)
|
|
74
|
+
if (!st) return { active: false, via: 'no-state' }
|
|
75
|
+
if (!st.port) return { active: false, via: 'no-port' }
|
|
76
|
+
const alive = await isPortAlive(st.port)
|
|
77
|
+
if (!alive) return { active: false, via: 'port-dead' }
|
|
78
|
+
return { active: true, wsEndpoint: st.wsEndpoint, port: st.port, via: 'cdp-alive' }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
STATE_FILENAME,
|
|
83
|
+
statePath,
|
|
84
|
+
read,
|
|
85
|
+
write,
|
|
86
|
+
clear,
|
|
87
|
+
isPortAlive,
|
|
88
|
+
probe
|
|
89
|
+
}
|