@miphamai/cli 0.48.0 → 0.50.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 (32) hide show
  1. package/package.json +1 -1
  2. package/skills/mipham/doc-sync.mipham-skill.md +198 -0
  3. package/skills/mipham/self-audit.mipham-skill.md +1 -1
  4. package/skills/standard/github-ops.SKILL.md +1 -1
  5. package/skills/standard/web-access/references/cdp-api.md +132 -0
  6. package/skills/standard/web-access/references/site-patterns/.gitkeep +0 -0
  7. package/skills/standard/web-access/scripts/cdp-proxy.mjs +756 -0
  8. package/skills/standard/web-access/scripts/check-deps.mjs +187 -0
  9. package/skills/standard/web-access/scripts/find-url.mjs +271 -0
  10. package/skills/standard/web-access/scripts/match-site.mjs +48 -0
  11. package/skills/standard/web-access.SKILL.md +77 -158
  12. package/src/agent/cross-session/discovery.ts +26 -1
  13. package/src/config/defaults.ts +3 -0
  14. package/src/core/behavior-tasks.json +101 -0
  15. package/src/core/behavior-tasks.ts +44 -0
  16. package/src/core/crsi-producer.ts +214 -0
  17. package/src/core/crsi-sandbox.ts +5 -0
  18. package/src/core/eval-harness.ts +7 -0
  19. package/src/core/instructions.ts +11 -0
  20. package/src/core/proposal-guard.ts +100 -0
  21. package/src/core/task-runner-tasks.json +14 -0
  22. package/src/core/task-runner.ts +163 -0
  23. package/src/daemon/heartbeat.ts +83 -0
  24. package/src/daemon/server.ts +34 -2
  25. package/src/shared/package-info.ts +4 -1
  26. package/src/skills/bundled-skill-assets.ts +19 -0
  27. package/src/skills/bundled-skills.ts +4 -3
  28. package/src/skills/skill-assets.ts +37 -0
  29. package/src/tools/agent/skill.ts +13 -2
  30. package/src/tools/file/grep.ts +11 -1
  31. package/src/ui/app.tsx +21 -1
  32. package/src/ui/commands.ts +81 -6
@@ -0,0 +1,756 @@
1
+ #!/usr/bin/env node
2
+ // CDP Proxy - 通过 HTTP API 操控用户日常 Chrome
3
+ // 要求:Chrome 已开启 --remote-debugging-port
4
+ // Node.js 22+(使用原生 WebSocket)
5
+
6
+ import http from 'node:http'
7
+ import { URL } from 'node:url'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import os from 'node:os'
11
+ import net from 'node:net'
12
+
13
+ const PORT = parseInt(process.env.CDP_PROXY_PORT || '3456')
14
+ let ws = null
15
+ let cmdId = 0
16
+ const pending = new Map() // id -> {resolve, timer}
17
+ const sessions = new Map() // targetId -> sessionId
18
+ const managedTabs = new Map() // targetId -> { lastAccessed: number }
19
+ const TAB_IDLE_TIMEOUT = parseInt(process.env.CDP_TAB_IDLE_TIMEOUT || '900000') // 15 min default
20
+ const CLEANUP_INTERVAL = 60000 // sweep every 60s
21
+
22
+ // --- WebSocket 兼容层 ---
23
+ let WS
24
+ if (typeof globalThis.WebSocket !== 'undefined') {
25
+ // Node 22+ 原生 WebSocket(浏览器兼容 API)
26
+ WS = globalThis.WebSocket
27
+ } else {
28
+ // 回退到 ws 模块
29
+ try {
30
+ WS = (await import('ws')).default
31
+ } catch {
32
+ console.error('[CDP Proxy] 错误:Node.js 版本 < 22 且未安装 ws 模块')
33
+ console.error(' 解决方案:升级到 Node.js 22+ 或执行 npm install -g ws')
34
+ process.exit(1)
35
+ }
36
+ }
37
+
38
+ // --- 自动发现 Chrome 调试端口 ---
39
+ async function discoverChromePort() {
40
+ // 1. 尝试读 DevToolsActivePort 文件
41
+ const possiblePaths = []
42
+ const platform = os.platform()
43
+
44
+ if (platform === 'darwin') {
45
+ const home = os.homedir()
46
+ possiblePaths.push(
47
+ path.join(home, 'Library/Application Support/Google/Chrome/DevToolsActivePort'),
48
+ path.join(home, 'Library/Application Support/Google/Chrome Canary/DevToolsActivePort'),
49
+ path.join(home, 'Library/Application Support/Chromium/DevToolsActivePort'),
50
+ )
51
+ } else if (platform === 'linux') {
52
+ const home = os.homedir()
53
+ possiblePaths.push(
54
+ path.join(home, '.config/google-chrome/DevToolsActivePort'),
55
+ path.join(home, '.config/chromium/DevToolsActivePort'),
56
+ )
57
+ } else if (platform === 'win32') {
58
+ const localAppData = process.env.LOCALAPPDATA || ''
59
+ possiblePaths.push(
60
+ path.join(localAppData, 'Google/Chrome/User Data/DevToolsActivePort'),
61
+ path.join(localAppData, 'Chromium/User Data/DevToolsActivePort'),
62
+ )
63
+ }
64
+
65
+ for (const p of possiblePaths) {
66
+ try {
67
+ const content = fs.readFileSync(p, 'utf-8').trim()
68
+ const lines = content.split('\n')
69
+ const port = parseInt(lines[0])
70
+ if (port > 0 && port < 65536) {
71
+ const ok = await checkPort(port)
72
+ if (ok) {
73
+ // 第二行是带 UUID 的 WebSocket 路径(如 /devtools/browser/xxx-xxx)
74
+ // 非显式 --remote-debugging-port 启动时,Chrome 可能只接受此路径
75
+ const wsPath = lines[1] || null
76
+ console.log(
77
+ `[CDP Proxy] 从 DevToolsActivePort 发现端口: ${port}${wsPath ? ' (带 wsPath)' : ''}`,
78
+ )
79
+ return { port, wsPath }
80
+ }
81
+ }
82
+ } catch {
83
+ /* 文件不存在,继续 */
84
+ }
85
+ }
86
+
87
+ // 2. 扫描常用端口
88
+ const commonPorts = [9222, 9229, 9333]
89
+ for (const port of commonPorts) {
90
+ const ok = await checkPort(port)
91
+ if (ok) {
92
+ console.log(`[CDP Proxy] 扫描发现 Chrome 调试端口: ${port}`)
93
+ return { port, wsPath: null }
94
+ }
95
+ }
96
+
97
+ return null
98
+ }
99
+
100
+ // 用 TCP 探测端口是否监听——避免 WebSocket 连接触发 Chrome 安全弹窗
101
+ // (WebSocket 探测会被 Chrome 视为调试连接,弹出授权对话框)
102
+ function checkPort(port) {
103
+ return new Promise((resolve) => {
104
+ const socket = net.createConnection(port, '127.0.0.1')
105
+ const timer = setTimeout(() => {
106
+ socket.destroy()
107
+ resolve(false)
108
+ }, 2000)
109
+ socket.once('connect', () => {
110
+ clearTimeout(timer)
111
+ socket.destroy()
112
+ resolve(true)
113
+ })
114
+ socket.once('error', () => {
115
+ clearTimeout(timer)
116
+ resolve(false)
117
+ })
118
+ })
119
+ }
120
+
121
+ function getWebSocketUrl(port, wsPath) {
122
+ if (wsPath) return `ws://127.0.0.1:${port}${wsPath}`
123
+ return `ws://127.0.0.1:${port}/devtools/browser`
124
+ }
125
+
126
+ // --- WebSocket 连接管理 ---
127
+ let chromePort = null
128
+ let chromeWsPath = null
129
+
130
+ let connectingPromise = null
131
+ async function connect() {
132
+ if (ws && (ws.readyState === WS.OPEN || ws.readyState === 1)) return
133
+ if (connectingPromise) return connectingPromise // 复用进行中的连接
134
+
135
+ if (!chromePort) {
136
+ const discovered = await discoverChromePort()
137
+ if (!discovered) {
138
+ throw new Error(
139
+ 'Chrome 未开启远程调试端口。请用以下方式启动 Chrome:\n' +
140
+ ' macOS: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222\n' +
141
+ ' Linux: google-chrome --remote-debugging-port=9222\n' +
142
+ ' 或在 chrome://flags 中搜索 "remote debugging" 并启用',
143
+ )
144
+ }
145
+ chromePort = discovered.port
146
+ chromeWsPath = discovered.wsPath
147
+ }
148
+
149
+ const wsUrl = getWebSocketUrl(chromePort, chromeWsPath)
150
+ if (!wsUrl) throw new Error('无法获取 Chrome WebSocket URL')
151
+
152
+ return (connectingPromise = new Promise((resolve, reject) => {
153
+ ws = new WS(wsUrl)
154
+
155
+ const onOpen = () => {
156
+ cleanup()
157
+ connectingPromise = null
158
+ console.log(`[CDP Proxy] 已连接 Chrome (端口 ${chromePort})`)
159
+ resolve()
160
+ }
161
+ const onError = (e) => {
162
+ cleanup()
163
+ connectingPromise = null
164
+ ws = null
165
+ chromePort = null
166
+ chromeWsPath = null
167
+ const msg = e.message || e.error?.message || '连接失败'
168
+ console.error('[CDP Proxy] 连接错误:', msg, '(端口缓存已清除,下次将重新发现)')
169
+ reject(new Error(msg))
170
+ }
171
+ const onClose = () => {
172
+ console.log('[CDP Proxy] 连接断开')
173
+ ws = null
174
+ chromePort = null // 重置端口缓存,下次连接重新发现
175
+ chromeWsPath = null
176
+ sessions.clear()
177
+ managedTabs.clear()
178
+ }
179
+ const onMessage = (evt) => {
180
+ const data = typeof evt === 'string' ? evt : evt.data || evt
181
+ const msg = JSON.parse(typeof data === 'string' ? data : data.toString())
182
+
183
+ if (msg.method === 'Target.attachedToTarget') {
184
+ const { sessionId, targetInfo } = msg.params
185
+ sessions.set(targetInfo.targetId, sessionId)
186
+ }
187
+ // 拦截页面对 Chrome 调试端口的探测请求(反风控)
188
+ if (msg.method === 'Fetch.requestPaused') {
189
+ const { requestId, sessionId: sid } = msg.params
190
+ sendCDP('Fetch.failRequest', { requestId, errorReason: 'ConnectionRefused' }, sid).catch(
191
+ () => {},
192
+ )
193
+ }
194
+ if (msg.id && pending.has(msg.id)) {
195
+ const { resolve, timer } = pending.get(msg.id)
196
+ clearTimeout(timer)
197
+ pending.delete(msg.id)
198
+ resolve(msg)
199
+ }
200
+ }
201
+
202
+ function cleanup() {
203
+ ws.removeEventListener?.('open', onOpen)
204
+ ws.removeEventListener?.('error', onError)
205
+ }
206
+
207
+ // 兼容 Node 原生 WebSocket 和 ws 模块的事件 API
208
+ if (ws.on) {
209
+ ws.on('open', onOpen)
210
+ ws.on('error', onError)
211
+ ws.on('close', onClose)
212
+ ws.on('message', onMessage)
213
+ } else {
214
+ ws.addEventListener('open', onOpen)
215
+ ws.addEventListener('error', onError)
216
+ ws.addEventListener('close', onClose)
217
+ ws.addEventListener('message', onMessage)
218
+ }
219
+ }))
220
+ }
221
+
222
+ function sendCDP(method, params = {}, sessionId = null) {
223
+ return new Promise((resolve, reject) => {
224
+ if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) {
225
+ return reject(new Error('WebSocket 未连接'))
226
+ }
227
+ const id = ++cmdId
228
+ const msg = { id, method, params }
229
+ if (sessionId) msg.sessionId = sessionId
230
+ const timer = setTimeout(() => {
231
+ pending.delete(id)
232
+ reject(new Error('CDP 命令超时: ' + method))
233
+ }, 30000)
234
+ pending.set(id, { resolve, timer })
235
+ ws.send(JSON.stringify(msg))
236
+ })
237
+ }
238
+
239
+ // 已启用端口拦截的 session 集合(避免重复启用)
240
+ const portGuardedSessions = new Set()
241
+
242
+ async function ensureSession(targetId) {
243
+ if (sessions.has(targetId)) return sessions.get(targetId)
244
+ const resp = await sendCDP('Target.attachToTarget', { targetId, flatten: true })
245
+ if (resp.result?.sessionId) {
246
+ const sid = resp.result.sessionId
247
+ sessions.set(targetId, sid)
248
+ // 启用调试端口探测拦截
249
+ await enablePortGuard(sid)
250
+ return sid
251
+ }
252
+ throw new Error('attach 失败: ' + JSON.stringify(resp.error))
253
+ }
254
+
255
+ // 拦截页面对 Chrome 调试端口的探测(反风控)
256
+ // 只拦截 127.0.0.1:{chromePort} 的请求,不影响其他任何本地服务
257
+ async function enablePortGuard(sessionId) {
258
+ if (!chromePort || portGuardedSessions.has(sessionId)) return
259
+ try {
260
+ await sendCDP(
261
+ 'Fetch.enable',
262
+ {
263
+ patterns: [
264
+ { urlPattern: `http://127.0.0.1:${chromePort}/*`, requestStage: 'Request' },
265
+ { urlPattern: `http://localhost:${chromePort}/*`, requestStage: 'Request' },
266
+ ],
267
+ },
268
+ sessionId,
269
+ )
270
+ portGuardedSessions.add(sessionId)
271
+ } catch {
272
+ /* Fetch 域启用失败不影响主流程 */
273
+ }
274
+ }
275
+
276
+ // --- 闲置 Tab 自动清理 ---
277
+ function touchTab(targetId) {
278
+ const entry = managedTabs.get(targetId)
279
+ if (entry) entry.lastAccessed = Date.now()
280
+ }
281
+
282
+ async function cleanupIdleTabs() {
283
+ if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) return
284
+ const now = Date.now()
285
+ for (const [targetId, info] of managedTabs) {
286
+ if (now - info.lastAccessed < TAB_IDLE_TIMEOUT) continue
287
+ try {
288
+ await sendCDP('Target.closeTarget', { targetId })
289
+ } catch {
290
+ /* tab may already be closed */
291
+ }
292
+ sessions.delete(targetId)
293
+ managedTabs.delete(targetId)
294
+ console.log(`[CDP Proxy] Auto-closed idle tab: ${targetId}`)
295
+ }
296
+ }
297
+
298
+ async function closeAllManagedTabs() {
299
+ if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) return
300
+ const targets = [...managedTabs.keys()]
301
+ for (const targetId of targets) {
302
+ try {
303
+ await sendCDP('Target.closeTarget', { targetId })
304
+ } catch {
305
+ /* ignore */
306
+ }
307
+ sessions.delete(targetId)
308
+ managedTabs.delete(targetId)
309
+ }
310
+ if (targets.length) console.log(`[CDP Proxy] Shutdown: closed ${targets.length} managed tab(s)`)
311
+ }
312
+
313
+ // --- 等待页面加载 ---
314
+ async function waitForLoad(sessionId, timeoutMs = 15000) {
315
+ // 启用 Page 域
316
+ await sendCDP('Page.enable', {}, sessionId)
317
+
318
+ return new Promise((resolve) => {
319
+ let resolved = false
320
+ const done = (result) => {
321
+ if (resolved) return
322
+ resolved = true
323
+ clearTimeout(timer)
324
+ clearInterval(checkInterval)
325
+ resolve(result)
326
+ }
327
+
328
+ const timer = setTimeout(() => done('timeout'), timeoutMs)
329
+ const checkInterval = setInterval(async () => {
330
+ try {
331
+ const resp = await sendCDP(
332
+ 'Runtime.evaluate',
333
+ {
334
+ expression: 'document.readyState',
335
+ returnByValue: true,
336
+ },
337
+ sessionId,
338
+ )
339
+ if (resp.result?.result?.value === 'complete') {
340
+ done('complete')
341
+ }
342
+ } catch {
343
+ /* 忽略 */
344
+ }
345
+ }, 500)
346
+ })
347
+ }
348
+
349
+ // --- 读取 POST body ---
350
+ async function readBody(req) {
351
+ let body = ''
352
+ for await (const chunk of req) body += chunk
353
+ return body
354
+ }
355
+
356
+ // --- HTTP API ---
357
+ const server = http.createServer(async (req, res) => {
358
+ const parsed = new URL(req.url, `http://localhost:${PORT}`)
359
+ const pathname = parsed.pathname
360
+ const q = Object.fromEntries(parsed.searchParams)
361
+ if (q.target) touchTab(q.target)
362
+
363
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
364
+
365
+ try {
366
+ // /health 不需要连接 Chrome
367
+ if (pathname === '/health') {
368
+ const connected = ws && (ws.readyState === WS.OPEN || ws.readyState === 1)
369
+ res.end(
370
+ JSON.stringify({
371
+ status: 'ok',
372
+ connected,
373
+ sessions: sessions.size,
374
+ managedTabs: managedTabs.size,
375
+ chromePort,
376
+ }),
377
+ )
378
+ return
379
+ }
380
+
381
+ await connect()
382
+
383
+ // GET /targets - 列出所有页面
384
+ if (pathname === '/targets') {
385
+ const resp = await sendCDP('Target.getTargets')
386
+ const pages = resp.result.targetInfos.filter((t) => t.type === 'page')
387
+ res.end(JSON.stringify(pages, null, 2))
388
+ }
389
+
390
+ // GET /new?url=xxx - 创建新后台 tab
391
+ else if (pathname === '/new') {
392
+ const targetUrl = q.url || 'about:blank'
393
+ const resp = await sendCDP('Target.createTarget', { url: targetUrl, background: true })
394
+ const targetId = resp.result.targetId
395
+ managedTabs.set(targetId, { lastAccessed: Date.now() })
396
+
397
+ // 等待页面加载
398
+ if (targetUrl !== 'about:blank') {
399
+ try {
400
+ const sid = await ensureSession(targetId)
401
+ await waitForLoad(sid)
402
+ } catch {
403
+ /* 非致命,继续 */
404
+ }
405
+ }
406
+
407
+ res.end(JSON.stringify({ targetId }))
408
+ }
409
+
410
+ // GET /close?target=xxx - 关闭 tab
411
+ else if (pathname === '/close') {
412
+ const resp = await sendCDP('Target.closeTarget', { targetId: q.target })
413
+ sessions.delete(q.target)
414
+ managedTabs.delete(q.target)
415
+ res.end(JSON.stringify(resp.result))
416
+ }
417
+
418
+ // GET /navigate?target=xxx&url=yyy - 导航(自动等待加载)
419
+ else if (pathname === '/navigate') {
420
+ const sid = await ensureSession(q.target)
421
+ const resp = await sendCDP('Page.navigate', { url: q.url }, sid)
422
+
423
+ // 等待页面加载完成
424
+ await waitForLoad(sid)
425
+
426
+ res.end(JSON.stringify(resp.result))
427
+ }
428
+
429
+ // GET /back?target=xxx - 后退
430
+ else if (pathname === '/back') {
431
+ const sid = await ensureSession(q.target)
432
+ await sendCDP('Runtime.evaluate', { expression: 'history.back()' }, sid)
433
+ await waitForLoad(sid)
434
+ res.end(JSON.stringify({ ok: true }))
435
+ }
436
+
437
+ // POST /eval?target=xxx - 执行 JS
438
+ else if (pathname === '/eval') {
439
+ const sid = await ensureSession(q.target)
440
+ const body = await readBody(req)
441
+ const expr = body || q.expr || 'document.title'
442
+ const resp = await sendCDP(
443
+ 'Runtime.evaluate',
444
+ {
445
+ expression: expr,
446
+ returnByValue: true,
447
+ awaitPromise: true,
448
+ },
449
+ sid,
450
+ )
451
+ if (resp.result?.result?.value !== undefined) {
452
+ res.end(JSON.stringify({ value: resp.result.result.value }))
453
+ } else if (resp.result?.exceptionDetails) {
454
+ res.statusCode = 400
455
+ res.end(JSON.stringify({ error: resp.result.exceptionDetails.text }))
456
+ } else {
457
+ res.end(JSON.stringify(resp.result))
458
+ }
459
+ }
460
+
461
+ // POST /click?target=xxx - 点击(body 为 CSS 选择器)
462
+ // POST /click?target=xxx — JS 层面点击(简单快速,覆盖大多数场景)
463
+ else if (pathname === '/click') {
464
+ const sid = await ensureSession(q.target)
465
+ const selector = await readBody(req)
466
+ if (!selector) {
467
+ res.statusCode = 400
468
+ res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' }))
469
+ return
470
+ }
471
+ const selectorJson = JSON.stringify(selector)
472
+ const js = `(() => {
473
+ const el = document.querySelector(${selectorJson});
474
+ if (!el) return { error: '未找到元素: ' + ${selectorJson} };
475
+ el.scrollIntoView({ block: 'center' });
476
+ el.click();
477
+ return { clicked: true, tag: el.tagName, text: (el.textContent || '').slice(0, 100) };
478
+ })()`
479
+ const resp = await sendCDP(
480
+ 'Runtime.evaluate',
481
+ {
482
+ expression: js,
483
+ returnByValue: true,
484
+ awaitPromise: true,
485
+ },
486
+ sid,
487
+ )
488
+ if (resp.result?.result?.value) {
489
+ const val = resp.result.result.value
490
+ if (val.error) {
491
+ res.statusCode = 400
492
+ res.end(JSON.stringify(val))
493
+ } else {
494
+ res.end(JSON.stringify(val))
495
+ }
496
+ } else {
497
+ res.end(JSON.stringify(resp.result))
498
+ }
499
+ }
500
+
501
+ // POST /clickAt?target=xxx — CDP 浏览器级真实鼠标点击(算用户手势,能触发文件对话框、绕过反自动化检测)
502
+ else if (pathname === '/clickAt') {
503
+ const sid = await ensureSession(q.target)
504
+ const selector = await readBody(req)
505
+ if (!selector) {
506
+ res.statusCode = 400
507
+ res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' }))
508
+ return
509
+ }
510
+ const selectorJson = JSON.stringify(selector)
511
+ const js = `(() => {
512
+ const el = document.querySelector(${selectorJson});
513
+ if (!el) return { error: '未找到元素: ' + ${selectorJson} };
514
+ el.scrollIntoView({ block: 'center' });
515
+ const rect = el.getBoundingClientRect();
516
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, tag: el.tagName, text: (el.textContent || '').slice(0, 100) };
517
+ })()`
518
+ const coordResp = await sendCDP(
519
+ 'Runtime.evaluate',
520
+ {
521
+ expression: js,
522
+ returnByValue: true,
523
+ awaitPromise: true,
524
+ },
525
+ sid,
526
+ )
527
+ const coord = coordResp.result?.result?.value
528
+ if (!coord || coord.error) {
529
+ res.statusCode = 400
530
+ res.end(JSON.stringify(coord || coordResp.result))
531
+ return
532
+ }
533
+ await sendCDP(
534
+ 'Input.dispatchMouseEvent',
535
+ {
536
+ type: 'mousePressed',
537
+ x: coord.x,
538
+ y: coord.y,
539
+ button: 'left',
540
+ clickCount: 1,
541
+ },
542
+ sid,
543
+ )
544
+ await sendCDP(
545
+ 'Input.dispatchMouseEvent',
546
+ {
547
+ type: 'mouseReleased',
548
+ x: coord.x,
549
+ y: coord.y,
550
+ button: 'left',
551
+ clickCount: 1,
552
+ },
553
+ sid,
554
+ )
555
+ res.end(
556
+ JSON.stringify({ clicked: true, x: coord.x, y: coord.y, tag: coord.tag, text: coord.text }),
557
+ )
558
+ }
559
+
560
+ // POST /setFiles?target=xxx — 给 file input 设置本地文件(绕过文件对话框)
561
+ // body: JSON { "selector": "input[type=file]", "files": ["/path/to/file1.png", "/path/to/file2.png"] }
562
+ else if (pathname === '/setFiles') {
563
+ const sid = await ensureSession(q.target)
564
+ const body = JSON.parse(await readBody(req))
565
+ if (!body.selector || !body.files) {
566
+ res.statusCode = 400
567
+ res.end(JSON.stringify({ error: '需要 selector 和 files 字段' }))
568
+ return
569
+ }
570
+ // 获取 DOM 节点
571
+ await sendCDP('DOM.enable', {}, sid)
572
+ const doc = await sendCDP('DOM.getDocument', {}, sid)
573
+ const node = await sendCDP(
574
+ 'DOM.querySelector',
575
+ {
576
+ nodeId: doc.result.root.nodeId,
577
+ selector: body.selector,
578
+ },
579
+ sid,
580
+ )
581
+ if (!node.result?.nodeId) {
582
+ res.statusCode = 400
583
+ res.end(JSON.stringify({ error: '未找到元素: ' + body.selector }))
584
+ return
585
+ }
586
+ // 设置文件
587
+ await sendCDP(
588
+ 'DOM.setFileInputFiles',
589
+ {
590
+ nodeId: node.result.nodeId,
591
+ files: body.files,
592
+ },
593
+ sid,
594
+ )
595
+ res.end(JSON.stringify({ success: true, files: body.files.length }))
596
+ }
597
+
598
+ // GET /scroll?target=xxx&y=3000 - 滚动
599
+ else if (pathname === '/scroll') {
600
+ const sid = await ensureSession(q.target)
601
+ const y = parseInt(q.y || '3000')
602
+ const direction = q.direction || 'down' // down | up | top | bottom
603
+ let js
604
+ if (direction === 'top') {
605
+ js = 'window.scrollTo(0, 0); "scrolled to top"'
606
+ } else if (direction === 'bottom') {
607
+ js = 'window.scrollTo(0, document.body.scrollHeight); "scrolled to bottom"'
608
+ } else if (direction === 'up') {
609
+ js = `window.scrollBy(0, -${Math.abs(y)}); "scrolled up ${Math.abs(y)}px"`
610
+ } else {
611
+ js = `window.scrollBy(0, ${Math.abs(y)}); "scrolled down ${Math.abs(y)}px"`
612
+ }
613
+ const resp = await sendCDP(
614
+ 'Runtime.evaluate',
615
+ {
616
+ expression: js,
617
+ returnByValue: true,
618
+ },
619
+ sid,
620
+ )
621
+ // 等待懒加载触发
622
+ await new Promise((r) => setTimeout(r, 800))
623
+ res.end(JSON.stringify({ value: resp.result?.result?.value }))
624
+ }
625
+
626
+ // GET /screenshot?target=xxx&file=/tmp/x.png - 截图
627
+ else if (pathname === '/screenshot') {
628
+ const sid = await ensureSession(q.target)
629
+ const format = q.format || 'png'
630
+ const resp = await sendCDP(
631
+ 'Page.captureScreenshot',
632
+ {
633
+ format,
634
+ quality: format === 'jpeg' ? 80 : undefined,
635
+ },
636
+ sid,
637
+ )
638
+ if (q.file) {
639
+ fs.writeFileSync(q.file, Buffer.from(resp.result.data, 'base64'))
640
+ res.end(JSON.stringify({ saved: q.file }))
641
+ } else {
642
+ res.setHeader('Content-Type', 'image/' + format)
643
+ res.end(Buffer.from(resp.result.data, 'base64'))
644
+ }
645
+ }
646
+
647
+ // GET /info?target=xxx - 获取页面信息
648
+ else if (pathname === '/info') {
649
+ const sid = await ensureSession(q.target)
650
+ const resp = await sendCDP(
651
+ 'Runtime.evaluate',
652
+ {
653
+ expression:
654
+ 'JSON.stringify({title: document.title, url: location.href, ready: document.readyState})',
655
+ returnByValue: true,
656
+ },
657
+ sid,
658
+ )
659
+ res.end(resp.result?.result?.value || '{}')
660
+ } else {
661
+ res.statusCode = 404
662
+ res.end(
663
+ JSON.stringify({
664
+ error: '未知端点',
665
+ endpoints: {
666
+ '/health': 'GET - 健康检查',
667
+ '/targets': 'GET - 列出所有页面 tab',
668
+ '/new?url=': 'GET - 创建新后台 tab(自动等待加载)',
669
+ '/close?target=': 'GET - 关闭 tab',
670
+ '/navigate?target=&url=': 'GET - 导航(自动等待加载)',
671
+ '/back?target=': 'GET - 后退',
672
+ '/info?target=': 'GET - 页面标题/URL/状态',
673
+ '/eval?target=': 'POST body=JS表达式 - 执行 JS',
674
+ '/click?target=': 'POST body=CSS选择器 - 点击元素',
675
+ '/scroll?target=&y=&direction=': 'GET - 滚动页面',
676
+ '/screenshot?target=&file=': 'GET - 截图',
677
+ },
678
+ }),
679
+ )
680
+ }
681
+ } catch (e) {
682
+ res.statusCode = 500
683
+ res.end(JSON.stringify({ error: e.message }))
684
+ }
685
+ })
686
+
687
+ // 检查端口是否被占用
688
+ function checkPortAvailable(port) {
689
+ return new Promise((resolve) => {
690
+ const s = net.createServer()
691
+ s.once('error', () => resolve(false))
692
+ s.once('listening', () => {
693
+ s.close()
694
+ resolve(true)
695
+ })
696
+ s.listen(port, '127.0.0.1')
697
+ })
698
+ }
699
+
700
+ async function main() {
701
+ // 检查是否已有 proxy 在运行
702
+ const available = await checkPortAvailable(PORT)
703
+ if (!available) {
704
+ // 验证已有实例是否健康
705
+ try {
706
+ const ok = await new Promise((resolve) => {
707
+ http
708
+ .get(`http://127.0.0.1:${PORT}/health`, { timeout: 2000 }, (res) => {
709
+ let d = ''
710
+ res.on('data', (c) => (d += c))
711
+ res.on('end', () => resolve(d.includes('"ok"')))
712
+ })
713
+ .on('error', () => resolve(false))
714
+ })
715
+ if (ok) {
716
+ console.log(`[CDP Proxy] 已有实例运行在端口 ${PORT},退出`)
717
+ process.exit(0)
718
+ }
719
+ } catch {
720
+ /* 端口占用但非 proxy,继续报错 */
721
+ }
722
+ console.error(`[CDP Proxy] 端口 ${PORT} 已被占用`)
723
+ process.exit(1)
724
+ }
725
+
726
+ server.listen(PORT, '127.0.0.1', () => {
727
+ console.log(`[CDP Proxy] 运行在 http://localhost:${PORT}`)
728
+ // 启动时尝试连接 Chrome(非阻塞)
729
+ connect().catch((e) =>
730
+ console.error('[CDP Proxy] 初始连接失败:', e.message, '(将在首次请求时重试)'),
731
+ )
732
+ })
733
+
734
+ // 定时清理闲置 tab
735
+ const cleanupTimer = setInterval(cleanupIdleTabs, CLEANUP_INTERVAL)
736
+ cleanupTimer.unref()
737
+
738
+ const shutdown = async (sig) => {
739
+ console.log(`[CDP Proxy] ${sig}, cleaning up...`)
740
+ clearInterval(cleanupTimer)
741
+ await closeAllManagedTabs()
742
+ process.exit(0)
743
+ }
744
+ process.on('SIGINT', () => shutdown('SIGINT'))
745
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
746
+ }
747
+
748
+ // 防止未捕获异常导致进程崩溃
749
+ process.on('uncaughtException', (e) => {
750
+ console.error('[CDP Proxy] 未捕获异常:', e.message)
751
+ })
752
+ process.on('unhandledRejection', (e) => {
753
+ console.error('[CDP Proxy] 未处理拒绝:', e?.message || e)
754
+ })
755
+
756
+ main()