@miphamai/cli 0.48.0 → 0.49.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/package.json +1 -1
- package/skills/mipham/doc-sync.mipham-skill.md +198 -0
- package/skills/mipham/self-audit.mipham-skill.md +1 -1
- package/skills/standard/web-access/references/cdp-api.md +132 -0
- package/skills/standard/web-access/references/site-patterns/.gitkeep +0 -0
- package/skills/standard/web-access/scripts/cdp-proxy.mjs +756 -0
- package/skills/standard/web-access/scripts/check-deps.mjs +187 -0
- package/skills/standard/web-access/scripts/find-url.mjs +271 -0
- package/skills/standard/web-access/scripts/match-site.mjs +48 -0
- package/skills/standard/web-access.SKILL.md +77 -158
- package/src/shared/package-info.ts +1 -1
- package/src/skills/bundled-skill-assets.ts +19 -0
- package/src/skills/bundled-skills.ts +3 -2
- package/src/skills/skill-assets.ts +37 -0
- package/src/tools/agent/skill.ts +13 -2
|
@@ -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()
|