diff --git a/scripts/cdp-proxy-v1.mjs.bak b/scripts/cdp-proxy-v1.mjs.bak new file mode 100755 index 0000000..210477c --- /dev/null +++ b/scripts/cdp-proxy-v1.mjs.bak @@ -0,0 +1,572 @@ +#!/usr/bin/env node +// CDP Proxy - 通过 HTTP API 操控用户日常 Chrome +// 要求:Chrome 已开启 --remote-debugging-port +// Node.js 22+(使用原生 WebSocket) + +import http from 'node:http'; +import { URL } from 'node:url'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import net from 'node:net'; + +const PORT = parseInt(process.env.CDP_PROXY_PORT || '3456'); +let ws = null; +let cmdId = 0; +const pending = new Map(); // id -> {resolve, timer} +const sessions = new Map(); // targetId -> sessionId + +// --- WebSocket 兼容层 --- +let WS; +if (typeof globalThis.WebSocket !== 'undefined') { + // Node 22+ 原生 WebSocket(浏览器兼容 API) + WS = globalThis.WebSocket; +} else { + // 回退到 ws 模块 + try { + WS = (await import('ws')).default; + } catch { + console.error('[CDP Proxy] 错误:Node.js 版本 < 22 且未安装 ws 模块'); + console.error(' 解决方案:升级到 Node.js 22+ 或执行 npm install -g ws'); + process.exit(1); + } +} + +// --- 自动发现 Chrome 调试端口 --- +async function discoverChromePort() { + // 1. 尝试读 DevToolsActivePort 文件 + const possiblePaths = []; + const platform = os.platform(); + + if (platform === 'darwin') { + const home = os.homedir(); + possiblePaths.push( + path.join(home, 'Library/Application Support/Google/Chrome/DevToolsActivePort'), + path.join(home, 'Library/Application Support/Google/Chrome Canary/DevToolsActivePort'), + path.join(home, 'Library/Application Support/Chromium/DevToolsActivePort'), + ); + } else if (platform === 'linux') { + const home = os.homedir(); + possiblePaths.push( + path.join(home, '.config/google-chrome/DevToolsActivePort'), + path.join(home, '.config/chromium/DevToolsActivePort'), + ); + } else if (platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || ''; + possiblePaths.push( + path.join(localAppData, 'Google/Chrome/User Data/DevToolsActivePort'), + path.join(localAppData, 'Chromium/User Data/DevToolsActivePort'), + ); + } + + for (const p of possiblePaths) { + try { + const content = fs.readFileSync(p, 'utf-8').trim(); + const lines = content.split('\n'); + const port = parseInt(lines[0]); + if (port > 0 && port < 65536) { + const ok = await checkPort(port); + if (ok) { + // 第二行是带 UUID 的 WebSocket 路径(如 /devtools/browser/xxx-xxx) + // 非显式 --remote-debugging-port 启动时,Chrome 可能只接受此路径 + const wsPath = lines[1] || null; + console.log(`[CDP Proxy] 从 DevToolsActivePort 发现端口: ${port}${wsPath ? ' (带 wsPath)' : ''}`); + return { port, wsPath }; + } + } + } catch { /* 文件不存在,继续 */ } + } + + // 2. 扫描常用端口 + const commonPorts = [9222, 9229, 9333]; + for (const port of commonPorts) { + const ok = await checkPort(port); + if (ok) { + console.log(`[CDP Proxy] 扫描发现 Chrome 调试端口: ${port}`); + return { port, wsPath: null }; + } + } + + return null; +} + +// 用 TCP 探测端口是否监听——避免 WebSocket 连接触发 Chrome 安全弹窗 +// (WebSocket 探测会被 Chrome 视为调试连接,弹出授权对话框) +function checkPort(port) { + return new Promise((resolve) => { + const socket = net.createConnection(port, '127.0.0.1'); + const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 2000); + socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); }); + socket.once('error', () => { clearTimeout(timer); resolve(false); }); + }); +} + +function getWebSocketUrl(port, wsPath) { + if (wsPath) return `ws://127.0.0.1:${port}${wsPath}`; + return `ws://127.0.0.1:${port}/devtools/browser`; +} + +// --- WebSocket 连接管理 --- +let chromePort = null; +let chromeWsPath = null; + +let connectingPromise = null; +async function connect() { + if (ws && (ws.readyState === WS.OPEN || ws.readyState === 1)) return; + if (connectingPromise) return connectingPromise; // 复用进行中的连接 + + if (!chromePort) { + const discovered = await discoverChromePort(); + if (!discovered) { + throw new Error( + 'Chrome 未开启远程调试端口。请用以下方式启动 Chrome:\n' + + ' macOS: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222\n' + + ' Linux: google-chrome --remote-debugging-port=9222\n' + + ' 或在 chrome://flags 中搜索 "remote debugging" 并启用' + ); + } + chromePort = discovered.port; + chromeWsPath = discovered.wsPath; + } + + const wsUrl = getWebSocketUrl(chromePort, chromeWsPath); + if (!wsUrl) throw new Error('无法获取 Chrome WebSocket URL'); + + return connectingPromise = new Promise((resolve, reject) => { + ws = new WS(wsUrl); + + const onOpen = () => { + cleanup(); + connectingPromise = null; + console.log(`[CDP Proxy] 已连接 Chrome (端口 ${chromePort})`); + resolve(); + }; + const onError = (e) => { + cleanup(); + connectingPromise = null; + const msg = e.message || e.error?.message || '连接失败'; + console.error('[CDP Proxy] 连接错误:', msg); + reject(new Error(msg)); + }; + const onClose = () => { + console.log('[CDP Proxy] 连接断开'); + ws = null; + chromePort = null; // 重置端口缓存,下次连接重新发现 + chromeWsPath = null; + sessions.clear(); + }; + const onMessage = (evt) => { + const data = typeof evt === 'string' ? evt : (evt.data || evt); + const msg = JSON.parse(typeof data === 'string' ? data : data.toString()); + + if (msg.method === 'Target.attachedToTarget') { + const { sessionId, targetInfo } = msg.params; + sessions.set(targetInfo.targetId, sessionId); + } + if (msg.id && pending.has(msg.id)) { + const { resolve, timer } = pending.get(msg.id); + clearTimeout(timer); + pending.delete(msg.id); + resolve(msg); + } + }; + + function cleanup() { + ws.removeEventListener?.('open', onOpen); + ws.removeEventListener?.('error', onError); + } + + // 兼容 Node 原生 WebSocket 和 ws 模块的事件 API + if (ws.on) { + ws.on('open', onOpen); + ws.on('error', onError); + ws.on('close', onClose); + ws.on('message', onMessage); + } else { + ws.addEventListener('open', onOpen); + ws.addEventListener('error', onError); + ws.addEventListener('close', onClose); + ws.addEventListener('message', onMessage); + } + }); +} + +function sendCDP(method, params = {}, sessionId = null) { + return new Promise((resolve, reject) => { + if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) { + return reject(new Error('WebSocket 未连接')); + } + const id = ++cmdId; + const msg = { id, method, params }; + if (sessionId) msg.sessionId = sessionId; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error('CDP 命令超时: ' + method)); + }, 30000); + pending.set(id, { resolve, timer }); + ws.send(JSON.stringify(msg)); + }); +} + +async function ensureSession(targetId) { + if (sessions.has(targetId)) return sessions.get(targetId); + const resp = await sendCDP('Target.attachToTarget', { targetId, flatten: true }); + if (resp.result?.sessionId) { + sessions.set(targetId, resp.result.sessionId); + return resp.result.sessionId; + } + throw new Error('attach 失败: ' + JSON.stringify(resp.error)); +} + +// --- 等待页面加载 --- +async function waitForLoad(sessionId, timeoutMs = 15000) { + // 启用 Page 域 + await sendCDP('Page.enable', {}, sessionId); + + return new Promise((resolve) => { + let resolved = false; + const done = (result) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + clearInterval(checkInterval); + resolve(result); + }; + + const timer = setTimeout(() => done('timeout'), timeoutMs); + const checkInterval = setInterval(async () => { + try { + const resp = await sendCDP('Runtime.evaluate', { + expression: 'document.readyState', + returnByValue: true, + }, sessionId); + if (resp.result?.result?.value === 'complete') { + done('complete'); + } + } catch { /* 忽略 */ } + }, 500); + }); +} + +// --- 读取 POST body --- +async function readBody(req) { + let body = ''; + for await (const chunk of req) body += chunk; + return body; +} + +// --- HTTP API --- +const server = http.createServer(async (req, res) => { + const parsed = new URL(req.url, `http://localhost:${PORT}`); + const pathname = parsed.pathname; + const q = Object.fromEntries(parsed.searchParams); + + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + + try { + // /health 不需要连接 Chrome + if (pathname === '/health') { + const connected = ws && (ws.readyState === WS.OPEN || ws.readyState === 1); + res.end(JSON.stringify({ status: 'ok', connected, sessions: sessions.size, chromePort })); + return; + } + + await connect(); + + // GET /targets - 列出所有页面 + if (pathname === '/targets') { + const resp = await sendCDP('Target.getTargets'); + const pages = resp.result.targetInfos.filter(t => t.type === 'page'); + res.end(JSON.stringify(pages, null, 2)); + } + + // GET /new?url=xxx - 创建新后台 tab + else if (pathname === '/new') { + const targetUrl = q.url || 'about:blank'; + const resp = await sendCDP('Target.createTarget', { url: targetUrl, background: true }); + const targetId = resp.result.targetId; + + // 等待页面加载 + if (targetUrl !== 'about:blank') { + try { + const sid = await ensureSession(targetId); + await waitForLoad(sid); + } catch { /* 非致命,继续 */ } + } + + res.end(JSON.stringify({ targetId })); + } + + // GET /close?target=xxx - 关闭 tab + else if (pathname === '/close') { + const resp = await sendCDP('Target.closeTarget', { targetId: q.target }); + sessions.delete(q.target); + res.end(JSON.stringify(resp.result)); + } + + // GET /navigate?target=xxx&url=yyy - 导航(自动等待加载) + else if (pathname === '/navigate') { + const sid = await ensureSession(q.target); + const resp = await sendCDP('Page.navigate', { url: q.url }, sid); + + // 等待页面加载完成 + await waitForLoad(sid); + + res.end(JSON.stringify(resp.result)); + } + + // GET /back?target=xxx - 后退 + else if (pathname === '/back') { + const sid = await ensureSession(q.target); + await sendCDP('Runtime.evaluate', { expression: 'history.back()' }, sid); + await waitForLoad(sid); + res.end(JSON.stringify({ ok: true })); + } + + // POST /eval?target=xxx - 执行 JS + else if (pathname === '/eval') { + const sid = await ensureSession(q.target); + const body = await readBody(req); + const expr = body || q.expr || 'document.title'; + const resp = await sendCDP('Runtime.evaluate', { + expression: expr, + returnByValue: true, + awaitPromise: true, + }, sid); + if (resp.result?.result?.value !== undefined) { + res.end(JSON.stringify({ value: resp.result.result.value })); + } else if (resp.result?.exceptionDetails) { + res.statusCode = 400; + res.end(JSON.stringify({ error: resp.result.exceptionDetails.text })); + } else { + res.end(JSON.stringify(resp.result)); + } + } + + // POST /click?target=xxx - 点击(body 为 CSS 选择器) + // POST /click?target=xxx — JS 层面点击(简单快速,覆盖大多数场景) + else if (pathname === '/click') { + const sid = await ensureSession(q.target); + const selector = await readBody(req); + if (!selector) { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' })); + return; + } + const selectorJson = JSON.stringify(selector); + const js = `(() => { + const el = document.querySelector(${selectorJson}); + if (!el) return { error: '未找到元素: ' + ${selectorJson} }; + el.scrollIntoView({ block: 'center' }); + el.click(); + return { clicked: true, tag: el.tagName, text: (el.textContent || '').slice(0, 100) }; + })()`; + const resp = await sendCDP('Runtime.evaluate', { + expression: js, + returnByValue: true, + awaitPromise: true, + }, sid); + if (resp.result?.result?.value) { + const val = resp.result.result.value; + if (val.error) { + res.statusCode = 400; + res.end(JSON.stringify(val)); + } else { + res.end(JSON.stringify(val)); + } + } else { + res.end(JSON.stringify(resp.result)); + } + } + + // POST /clickAt?target=xxx — CDP 浏览器级真实鼠标点击(算用户手势,能触发文件对话框、绕过反自动化检测) + else if (pathname === '/clickAt') { + const sid = await ensureSession(q.target); + const selector = await readBody(req); + if (!selector) { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' })); + return; + } + const selectorJson = JSON.stringify(selector); + const js = `(() => { + const el = document.querySelector(${selectorJson}); + if (!el) return { error: '未找到元素: ' + ${selectorJson} }; + el.scrollIntoView({ block: 'center' }); + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, tag: el.tagName, text: (el.textContent || '').slice(0, 100) }; + })()`; + const coordResp = await sendCDP('Runtime.evaluate', { + expression: js, + returnByValue: true, + awaitPromise: true, + }, sid); + const coord = coordResp.result?.result?.value; + if (!coord || coord.error) { + res.statusCode = 400; + res.end(JSON.stringify(coord || coordResp.result)); + return; + } + await sendCDP('Input.dispatchMouseEvent', { + type: 'mousePressed', x: coord.x, y: coord.y, button: 'left', clickCount: 1 + }, sid); + await sendCDP('Input.dispatchMouseEvent', { + type: 'mouseReleased', x: coord.x, y: coord.y, button: 'left', clickCount: 1 + }, sid); + res.end(JSON.stringify({ clicked: true, x: coord.x, y: coord.y, tag: coord.tag, text: coord.text })); + } + + // POST /setFiles?target=xxx — 给 file input 设置本地文件(绕过文件对话框) + // body: JSON { "selector": "input[type=file]", "files": ["/path/to/file1.png", "/path/to/file2.png"] } + else if (pathname === '/setFiles') { + const sid = await ensureSession(q.target); + const body = JSON.parse(await readBody(req)); + if (!body.selector || !body.files) { + res.statusCode = 400; + res.end(JSON.stringify({ error: '需要 selector 和 files 字段' })); + return; + } + // 获取 DOM 节点 + await sendCDP('DOM.enable', {}, sid); + const doc = await sendCDP('DOM.getDocument', {}, sid); + const node = await sendCDP('DOM.querySelector', { + nodeId: doc.result.root.nodeId, + selector: body.selector + }, sid); + if (!node.result?.nodeId) { + res.statusCode = 400; + res.end(JSON.stringify({ error: '未找到元素: ' + body.selector })); + return; + } + // 设置文件 + await sendCDP('DOM.setFileInputFiles', { + nodeId: node.result.nodeId, + files: body.files + }, sid); + res.end(JSON.stringify({ success: true, files: body.files.length })); + } + + // GET /scroll?target=xxx&y=3000 - 滚动 + else if (pathname === '/scroll') { + const sid = await ensureSession(q.target); + const y = parseInt(q.y || '3000'); + const direction = q.direction || 'down'; // down | up | top | bottom + let js; + if (direction === 'top') { + js = 'window.scrollTo(0, 0); "scrolled to top"'; + } else if (direction === 'bottom') { + js = 'window.scrollTo(0, document.body.scrollHeight); "scrolled to bottom"'; + } else if (direction === 'up') { + js = `window.scrollBy(0, -${Math.abs(y)}); "scrolled up ${Math.abs(y)}px"`; + } else { + js = `window.scrollBy(0, ${Math.abs(y)}); "scrolled down ${Math.abs(y)}px"`; + } + const resp = await sendCDP('Runtime.evaluate', { + expression: js, + returnByValue: true, + }, sid); + // 等待懒加载触发 + await new Promise(r => setTimeout(r, 800)); + res.end(JSON.stringify({ value: resp.result?.result?.value })); + } + + // GET /screenshot?target=xxx&file=/tmp/x.png - 截图 + else if (pathname === '/screenshot') { + const sid = await ensureSession(q.target); + const format = q.format || 'png'; + const resp = await sendCDP('Page.captureScreenshot', { + format, + quality: format === 'jpeg' ? 80 : undefined, + }, sid); + if (q.file) { + fs.writeFileSync(q.file, Buffer.from(resp.result.data, 'base64')); + res.end(JSON.stringify({ saved: q.file })); + } else { + res.setHeader('Content-Type', 'image/' + format); + res.end(Buffer.from(resp.result.data, 'base64')); + } + } + + // GET /info?target=xxx - 获取页面信息 + else if (pathname === '/info') { + const sid = await ensureSession(q.target); + const resp = await sendCDP('Runtime.evaluate', { + expression: 'JSON.stringify({title: document.title, url: location.href, ready: document.readyState})', + returnByValue: true, + }, sid); + res.end(resp.result?.result?.value || '{}'); + } + + else { + res.statusCode = 404; + res.end(JSON.stringify({ + error: '未知端点', + endpoints: { + '/health': 'GET - 健康检查', + '/targets': 'GET - 列出所有页面 tab', + '/new?url=': 'GET - 创建新后台 tab(自动等待加载)', + '/close?target=': 'GET - 关闭 tab', + '/navigate?target=&url=': 'GET - 导航(自动等待加载)', + '/back?target=': 'GET - 后退', + '/info?target=': 'GET - 页面标题/URL/状态', + '/eval?target=': 'POST body=JS表达式 - 执行 JS', + '/click?target=': 'POST body=CSS选择器 - 点击元素', + '/scroll?target=&y=&direction=': 'GET - 滚动页面', + '/screenshot?target=&file=': 'GET - 截图', + }, + })); + } + } catch (e) { + res.statusCode = 500; + res.end(JSON.stringify({ error: e.message })); + } +}); + +// 检查端口是否被占用 +function checkPortAvailable(port) { + return new Promise((resolve) => { + const s = net.createServer(); + s.once('error', () => resolve(false)); + s.once('listening', () => { s.close(); resolve(true); }); + s.listen(port, '127.0.0.1'); + }); +} + +async function main() { + // 检查是否已有 proxy 在运行 + const available = await checkPortAvailable(PORT); + if (!available) { + // 验证已有实例是否健康 + try { + const ok = await new Promise((resolve) => { + http.get(`http://127.0.0.1:${PORT}/health`, { timeout: 2000 }, (res) => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => resolve(d.includes('"ok"'))); + }).on('error', () => resolve(false)); + }); + if (ok) { + console.log(`[CDP Proxy] 已有实例运行在端口 ${PORT},退出`); + process.exit(0); + } + } catch { /* 端口占用但非 proxy,继续报错 */ } + console.error(`[CDP Proxy] 端口 ${PORT} 已被占用`); + process.exit(1); + } + + server.listen(PORT, '127.0.0.1', () => { + console.log(`[CDP Proxy] 运行在 http://localhost:${PORT}`); + // 启动时尝试连接 Chrome(非阻塞) + connect().catch(e => console.error('[CDP Proxy] 初始连接失败:', e.message, '(将在首次请求时重试)')); + }); +} + +// 防止未捕获异常导致进程崩溃 +process.on('uncaughtException', (e) => { + console.error('[CDP Proxy] 未捕获异常:', e.message); +}); +process.on('unhandledRejection', (e) => { + console.error('[CDP Proxy] 未处理拒绝:', e?.message || e); +}); + +main(); diff --git a/scripts/cdp-proxy.mjs b/scripts/cdp-proxy.mjs index af12281..2711f4f 100755 --- a/scripts/cdp-proxy.mjs +++ b/scripts/cdp-proxy.mjs @@ -1,575 +1,329 @@ #!/usr/bin/env node -// CDP Proxy - 通过 HTTP API 操控用户日常 Chrome -// 要求:Chrome 已开启 --remote-debugging-port -// Node.js 22+(使用原生 WebSocket) +/** + * CDP Proxy v2 + * 核心改进:事件驱动等待 + 批处理 + Tab 池化 + */ -import http from 'node:http'; -import { URL } from 'node:url'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import net from 'node:net'; +import http from 'http'; +import fs from 'fs'; -const PORT = parseInt(process.env.CDP_PROXY_PORT || '3456'); -let ws = null; -let cmdId = 0; -const pending = new Map(); // id -> {resolve, timer} -const sessions = new Map(); // targetId -> sessionId - -// --- WebSocket 兼容层 --- +// WebSocket 兼容层(Node.js 22+ 原生,或 ws 模块回退) let WS; if (typeof globalThis.WebSocket !== 'undefined') { - // Node 22+ 原生 WebSocket(浏览器兼容 API) WS = globalThis.WebSocket; } else { - // 回退到 ws 模块 try { WS = (await import('ws')).default; } catch { - console.error('[CDP Proxy] 错误:Node.js 版本 < 22 且未安装 ws 模块'); - console.error(' 解决方案:升级到 Node.js 22+ 或执行 npm install -g ws'); + console.error('[CDP Proxy] 需要 Node.js 22+ 或安装 ws 模块'); process.exit(1); } } -// --- 自动发现 Chrome 调试端口 --- -async function discoverChromePort() { - // 1. 尝试读 DevToolsActivePort 文件 - const possiblePaths = []; - const platform = os.platform(); +const PORT = process.env.CDP_PROXY_PORT || 3456; +const CDP_PORT = process.env.CDP_PORT || 9222; - if (platform === 'darwin') { - const home = os.homedir(); - possiblePaths.push( - path.join(home, 'Library/Application Support/Google/Chrome/DevToolsActivePort'), - path.join(home, 'Library/Application Support/Google/Chrome Canary/DevToolsActivePort'), - path.join(home, 'Library/Application Support/Chromium/DevToolsActivePort'), - ); - } else if (platform === 'linux') { - const home = os.homedir(); - possiblePaths.push( - path.join(home, '.config/google-chrome/DevToolsActivePort'), - path.join(home, '.config/chromium/DevToolsActivePort'), - ); - } else if (platform === 'win32') { - const localAppData = process.env.LOCALAPPDATA || ''; - possiblePaths.push( - path.join(localAppData, 'Google/Chrome/User Data/DevToolsActivePort'), - path.join(localAppData, 'Chromium/User Data/DevToolsActivePort'), - ); - } +let browserWs = null; +const sessions = new Map(); +const tabPool = new Map(); +const eventListeners = new Map(); +let msgId = 1; +const pendingCommands = new Map(); - for (const p of possiblePaths) { - try { - const content = fs.readFileSync(p, 'utf-8').trim(); - const lines = content.split('\n'); - const port = parseInt(lines[0]); - if (port > 0 && port < 65536) { - const ok = await checkPort(port); - if (ok) { - // 第二行是带 UUID 的 WebSocket 路径(如 /devtools/browser/xxx-xxx) - // 非显式 --remote-debugging-port 启动时,Chrome 可能只接受此路径 - const wsPath = lines[1] || null; - console.log(`[CDP Proxy] 从 DevToolsActivePort 发现端口: ${port}${wsPath ? ' (带 wsPath)' : ''}`); - return { port, wsPath }; - } +async function ensureBrowserConnection() { + if (browserWs?.readyState === WS.OPEN) return browserWs; + const res = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`); + const { webSocketDebuggerUrl } = await res.json(); + return new Promise((resolve, reject) => { + browserWs = new WS(webSocketDebuggerUrl, { perMessageDeflate: false }); + const onOpen = () => { console.log('[CDP] Connected'); resolve(browserWs); }; + const onError = (err) => { console.error('[CDP] WS error:', err.message); reject(err); }; + const onClose = () => { browserWs = null; }; + const onMessage = (event) => { + const raw = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data); + const msg = JSON.parse(raw); + if (msg.id && pendingCommands.has(msg.id)) { + pendingCommands.get(msg.id).resolve(msg); + pendingCommands.delete(msg.id); + } else if (msg.method && msg.sessionId) { + handleCDPEvent(msg.sessionId, msg.method, msg.params); } - } catch { /* 文件不存在,继续 */ } - } - - // 2. 扫描常用端口 - const commonPorts = [9222, 9229, 9333]; - for (const port of commonPorts) { - const ok = await checkPort(port); - if (ok) { - console.log(`[CDP Proxy] 扫描发现 Chrome 调试端口: ${port}`); - return { port, wsPath: null }; + }; + if (browserWs.on) { + browserWs.on('open', onOpen); + browserWs.on('error', onError); + browserWs.on('close', onClose); + browserWs.on('message', onMessage); + } else { + browserWs.addEventListener('open', onOpen); + browserWs.addEventListener('error', onError); + browserWs.addEventListener('close', onClose); + browserWs.addEventListener('message', onMessage); } - } - - return null; -} - -// 用 TCP 探测端口是否监听——避免 WebSocket 连接触发 Chrome 安全弹窗 -// (WebSocket 探测会被 Chrome 视为调试连接,弹出授权对话框) -function checkPort(port) { - return new Promise((resolve) => { - const socket = net.createConnection(port, '127.0.0.1'); - const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 2000); - socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); }); - socket.once('error', () => { clearTimeout(timer); resolve(false); }); }); } -function getWebSocketUrl(port, wsPath) { - if (wsPath) return `ws://127.0.0.1:${port}${wsPath}`; - return `ws://127.0.0.1:${port}/devtools/browser`; +function handleCDPEvent(sessionId, method, params) { + const listener = eventListeners.get(sessionId); + if (!listener) return; + if (method === 'Page.loadEventFired') { + listener.loadFired = true; + checkPageReady(sessionId); + } + if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') { + listener.pendingRequests = Math.max(0, (listener.pendingRequests || 0) - 1); + checkPageReady(sessionId); + } + if (method === 'Network.requestWillBeSent') { + listener.pendingRequests = (listener.pendingRequests || 0) + 1; + } } -// --- WebSocket 连接管理 --- -let chromePort = null; -let chromeWsPath = null; - -let connectingPromise = null; -async function connect() { - if (ws && (ws.readyState === WS.OPEN || ws.readyState === 1)) return; - if (connectingPromise) return connectingPromise; // 复用进行中的连接 - - if (!chromePort) { - const discovered = await discoverChromePort(); - if (!discovered) { - throw new Error( - 'Chrome 未开启远程调试端口。请用以下方式启动 Chrome:\n' + - ' macOS: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222\n' + - ' Linux: google-chrome --remote-debugging-port=9222\n' + - ' 或在 chrome://flags 中搜索 "remote debugging" 并启用' - ); - } - chromePort = discovered.port; - chromeWsPath = discovered.wsPath; +function checkPageReady(sessionId) { + const listener = eventListeners.get(sessionId); + if (!listener?.loadResolve) return; + if (listener.loadFired && (listener.pendingRequests || 0) <= 0) { + clearTimeout(listener.timeout); + listener.loadResolve({ success: true }); + listener.loadResolve = null; } +} - const wsUrl = getWebSocketUrl(chromePort, chromeWsPath); - if (!wsUrl) throw new Error('无法获取 Chrome WebSocket URL'); - - return connectingPromise = new Promise((resolve, reject) => { - ws = new WS(wsUrl); - - const onOpen = () => { - cleanup(); - connectingPromise = null; - console.log(`[CDP Proxy] 已连接 Chrome (端口 ${chromePort})`); - resolve(); - }; - const onError = (e) => { - cleanup(); - connectingPromise = null; - ws = null; - chromePort = null; - chromeWsPath = null; - const msg = e.message || e.error?.message || '连接失败'; - console.error('[CDP Proxy] 连接错误:', msg, '(端口缓存已清除,下次将重新发现)'); - reject(new Error(msg)); - }; - const onClose = () => { - console.log('[CDP Proxy] 连接断开'); - ws = null; - chromePort = null; // 重置端口缓存,下次连接重新发现 - chromeWsPath = null; - sessions.clear(); - }; - const onMessage = (evt) => { - const data = typeof evt === 'string' ? evt : (evt.data || evt); - const msg = JSON.parse(typeof data === 'string' ? data : data.toString()); - - if (msg.method === 'Target.attachedToTarget') { - const { sessionId, targetInfo } = msg.params; - sessions.set(targetInfo.targetId, sessionId); - } - if (msg.id && pending.has(msg.id)) { - const { resolve, timer } = pending.get(msg.id); - clearTimeout(timer); - pending.delete(msg.id); - resolve(msg); - } - }; - - function cleanup() { - ws.removeEventListener?.('open', onOpen); - ws.removeEventListener?.('error', onError); - } - - // 兼容 Node 原生 WebSocket 和 ws 模块的事件 API - if (ws.on) { - ws.on('open', onOpen); - ws.on('error', onError); - ws.on('close', onClose); - ws.on('message', onMessage); - } else { - ws.addEventListener('open', onOpen); - ws.addEventListener('error', onError); - ws.addEventListener('close', onClose); - ws.addEventListener('message', onMessage); - } +async function waitForPageLoad(sessionId, timeout = 30000) { + const listener = eventListeners.get(sessionId) || {}; + listener.loadFired = false; + listener.pendingRequests = 0; + eventListeners.set(sessionId, listener); + return new Promise((resolve) => { + listener.loadResolve = resolve; + listener.timeout = setTimeout(() => { + resolve({ success: true, method: 'timeout' }); + listener.loadResolve = null; + }, timeout); }); } -function sendCDP(method, params = {}, sessionId = null) { +async function sendCommand(method, params = {}, sessionId = null) { + await ensureBrowserConnection(); + const id = msgId++; + const msg = { id, method, params }; + if (sessionId) msg.sessionId = sessionId; return new Promise((resolve, reject) => { - if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) { - return reject(new Error('WebSocket 未连接')); - } - const id = ++cmdId; - const msg = { id, method, params }; - if (sessionId) msg.sessionId = sessionId; const timer = setTimeout(() => { - pending.delete(id); - reject(new Error('CDP 命令超时: ' + method)); + pendingCommands.delete(id); + reject(new Error(`Command timeout: ${method}`)); }, 30000); - pending.set(id, { resolve, timer }); - ws.send(JSON.stringify(msg)); + pendingCommands.set(id, { resolve: (r) => { clearTimeout(timer); resolve(r); } }); + browserWs.send(JSON.stringify(msg)); }); } -async function ensureSession(targetId) { - if (sessions.has(targetId)) return sessions.get(targetId); - const resp = await sendCDP('Target.attachToTarget', { targetId, flatten: true }); - if (resp.result?.sessionId) { - sessions.set(targetId, resp.result.sessionId); - return resp.result.sessionId; +async function attachToTarget(targetId) { + if (sessions.has(targetId)) { + sessions.get(targetId).lastUsed = Date.now(); + return sessions.get(targetId).sessionId; } - throw new Error('attach 失败: ' + JSON.stringify(resp.error)); + const result = await sendCommand('Target.attachToTarget', { targetId, flatten: true }); + const sessionId = result.result.sessionId; + await Promise.all([ + sendCommand('Page.enable', {}, sessionId), + sendCommand('Network.enable', {}, sessionId), + sendCommand('Runtime.enable', {}, sessionId), + sendCommand('DOM.enable', {}, sessionId), + ]); + sessions.set(targetId, { sessionId, lastUsed: Date.now() }); + return sessionId; } -// --- 等待页面加载 --- -async function waitForLoad(sessionId, timeoutMs = 15000) { - // 启用 Page 域 - await sendCDP('Page.enable', {}, sessionId); - - return new Promise((resolve) => { - let resolved = false; - const done = (result) => { - if (resolved) return; - resolved = true; - clearTimeout(timer); - clearInterval(checkInterval); - resolve(result); - }; - - const timer = setTimeout(() => done('timeout'), timeoutMs); - const checkInterval = setInterval(async () => { - try { - const resp = await sendCDP('Runtime.evaluate', { - expression: 'document.readyState', - returnByValue: true, - }, sessionId); - if (resp.result?.result?.value === 'complete') { - done('complete'); - } - } catch { /* 忽略 */ } - }, 500); - }); +async function executeBatch(targetId, commands) { + const sessionId = await attachToTarget(targetId); + const results = []; + for (const cmd of commands) { + try { + let r; + switch (cmd.action) { + case 'navigate': + await sendCommand('Page.navigate', { url: cmd.url }, sessionId); + if (cmd.waitLoad !== false) r = await waitForPageLoad(sessionId); + r = { url: cmd.url, loaded: true, ...r }; + break; + case 'eval': + const ev = await sendCommand('Runtime.evaluate', { + expression: cmd.expression, returnByValue: true, awaitPromise: true, + }, sessionId); + r = { value: ev.result?.result?.value }; + break; + case 'click': + const box = await sendCommand('Runtime.evaluate', { + expression: `(()=>{const e=document.querySelector('${cmd.selector.replace(/'/g,"\\'")}');if(!e)return null;const rect=e.getBoundingClientRect();return{x:rect.x+rect.width/2,y:rect.y+rect.height/2};})()`, + }, sessionId); + if (!box.result?.result?.value) throw new Error(`Not found: ${cmd.selector}`); + const { x, y } = box.result.result.value; + await sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, sessionId); + await sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, sessionId); + r = { clicked: true }; + break; + case 'type': + await sendCommand('Runtime.evaluate', { + expression: `document.querySelector('${cmd.selector.replace(/'/g,"\\'")}')?.focus()`, + }, sessionId); + for (const ch of cmd.text) { + await sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', text: ch }, sessionId); + await sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', text: ch }, sessionId); + } + r = { typed: true }; + break; + case 'wait': + const start = Date.now(); + while (Date.now() - start < (cmd.timeout || 10000)) { + const found = await sendCommand('Runtime.evaluate', { + expression: `!!document.querySelector('${cmd.selector.replace(/'/g,"\\'")}')`, + }, sessionId); + if (found.result?.result?.value) { r = { found: true }; break; } + await new Promise(res => setTimeout(res, 100)); + } + r = r || { found: false }; + break; + case 'screenshot': + const shot = await sendCommand('Page.captureScreenshot', { format: 'png' }, sessionId); + if (cmd.file) { + await fs.promises.writeFile(cmd.file, Buffer.from(shot.result.data, 'base64')); + r = { file: cmd.file }; + } + r = r || { captured: true }; + break; + case 'scroll': + const scripts = { top:'window.scrollTo(0,0)', bottom:'window.scrollTo(0,document.body.scrollHeight)', up:'window.scrollBy(0,-window.innerHeight)', down:'window.scrollBy(0,window.innerHeight)' }; + await sendCommand('Runtime.evaluate', { expression: scripts[cmd.direction] || scripts.down }, sessionId); + r = { scrolled: cmd.direction }; + break; + default: + r = { error: `Unknown: ${cmd.action}` }; + } + results.push({ action: cmd.action, success: true, ...r }); + } catch (err) { + results.push({ action: cmd.action, success: false, error: err.message }); + if (cmd.stopOnError) break; + } + } + return results; } -// --- 读取 POST body --- -async function readBody(req) { - let body = ''; - for await (const chunk of req) body += chunk; - return body; +async function getOrCreateTab(url, reuse = true) { + const domain = new URL(url).hostname; + if (reuse && tabPool.has(domain)) { + const targetId = tabPool.get(domain); + try { + const sessionId = await attachToTarget(targetId); + await sendCommand('Page.navigate', { url }, sessionId); + await waitForPageLoad(sessionId); + return { targetId, reused: true }; + } catch { tabPool.delete(domain); } + } + const result = await sendCommand('Target.createTarget', { url }); + const targetId = result.result.targetId; + tabPool.set(domain, targetId); + const sessionId = await attachToTarget(targetId); + await waitForPageLoad(sessionId); + return { targetId, reused: false }; } -// --- HTTP API --- const server = http.createServer(async (req, res) => { - const parsed = new URL(req.url, `http://localhost:${PORT}`); - const pathname = parsed.pathname; - const q = Object.fromEntries(parsed.searchParams); - - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - + const url = new URL(req.url, `http://localhost:${PORT}`); + const path = url.pathname; + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'application/json'); try { - // /health 不需要连接 Chrome - if (pathname === '/health') { - const connected = ws && (ws.readyState === WS.OPEN || ws.readyState === 1); - res.end(JSON.stringify({ status: 'ok', connected, sessions: sessions.size, chromePort })); - return; + let body = ''; + if (req.method === 'POST') { + body = await new Promise(r => { let d = ''; req.on('data', c => d += c); req.on('end', () => r(d)); }); } - - await connect(); - - // GET /targets - 列出所有页面 - if (pathname === '/targets') { - const resp = await sendCDP('Target.getTargets'); - const pages = resp.result.targetInfos.filter(t => t.type === 'page'); - res.end(JSON.stringify(pages, null, 2)); - } - - // GET /new?url=xxx - 创建新后台 tab - else if (pathname === '/new') { - const targetUrl = q.url || 'about:blank'; - const resp = await sendCDP('Target.createTarget', { url: targetUrl, background: true }); - const targetId = resp.result.targetId; - - // 等待页面加载 - if (targetUrl !== 'about:blank') { - try { - const sid = await ensureSession(targetId); - await waitForLoad(sid); - } catch { /* 非致命,继续 */ } + let result; + switch (path) { + case '/batch': + case '/execute': { + const { target, commands } = JSON.parse(body); + result = await executeBatch(target, commands); + break; } - - res.end(JSON.stringify({ targetId })); - } - - // GET /close?target=xxx - 关闭 tab - else if (pathname === '/close') { - const resp = await sendCDP('Target.closeTarget', { targetId: q.target }); - sessions.delete(q.target); - res.end(JSON.stringify(resp.result)); - } - - // GET /navigate?target=xxx&url=yyy - 导航(自动等待加载) - else if (pathname === '/navigate') { - const sid = await ensureSession(q.target); - const resp = await sendCDP('Page.navigate', { url: q.url }, sid); - - // 等待页面加载完成 - await waitForLoad(sid); - - res.end(JSON.stringify(resp.result)); - } - - // GET /back?target=xxx - 后退 - else if (pathname === '/back') { - const sid = await ensureSession(q.target); - await sendCDP('Runtime.evaluate', { expression: 'history.back()' }, sid); - await waitForLoad(sid); - res.end(JSON.stringify({ ok: true })); - } - - // POST /eval?target=xxx - 执行 JS - else if (pathname === '/eval') { - const sid = await ensureSession(q.target); - const body = await readBody(req); - const expr = body || q.expr || 'document.title'; - const resp = await sendCDP('Runtime.evaluate', { - expression: expr, - returnByValue: true, - awaitPromise: true, - }, sid); - if (resp.result?.result?.value !== undefined) { - res.end(JSON.stringify({ value: resp.result.result.value })); - } else if (resp.result?.exceptionDetails) { - res.statusCode = 400; - res.end(JSON.stringify({ error: resp.result.exceptionDetails.text })); - } else { - res.end(JSON.stringify(resp.result)); - } - } - - // POST /click?target=xxx - 点击(body 为 CSS 选择器) - // POST /click?target=xxx — JS 层面点击(简单快速,覆盖大多数场景) - else if (pathname === '/click') { - const sid = await ensureSession(q.target); - const selector = await readBody(req); - if (!selector) { - res.statusCode = 400; - res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' })); - return; + case '/smart-open': { + result = await getOrCreateTab(url.searchParams.get('url'), url.searchParams.get('reuse') !== 'false'); + break; } - const selectorJson = JSON.stringify(selector); - const js = `(() => { - const el = document.querySelector(${selectorJson}); - if (!el) return { error: '未找到元素: ' + ${selectorJson} }; - el.scrollIntoView({ block: 'center' }); - el.click(); - return { clicked: true, tag: el.tagName, text: (el.textContent || '').slice(0, 100) }; - })()`; - const resp = await sendCDP('Runtime.evaluate', { - expression: js, - returnByValue: true, - awaitPromise: true, - }, sid); - if (resp.result?.result?.value) { - const val = resp.result.result.value; - if (val.error) { - res.statusCode = 400; - res.end(JSON.stringify(val)); - } else { - res.end(JSON.stringify(val)); - } - } else { - res.end(JSON.stringify(resp.result)); + case '/new': { + const r2 = await sendCommand('Target.createTarget', { url: url.searchParams.get('url') || 'about:blank' }); + const tid = r2.result.targetId; + const sid = await attachToTarget(tid); + if (url.searchParams.get('url')) await waitForPageLoad(sid); + result = { targetId: tid }; + break; } - } - - // POST /clickAt?target=xxx — CDP 浏览器级真实鼠标点击(算用户手势,能触发文件对话框、绕过反自动化检测) - else if (pathname === '/clickAt') { - const sid = await ensureSession(q.target); - const selector = await readBody(req); - if (!selector) { - res.statusCode = 400; - res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' })); - return; + case '/eval': { + const sid = await attachToTarget(url.searchParams.get('target')); + const ev = await sendCommand('Runtime.evaluate', { expression: body, returnByValue: true, awaitPromise: true }, sid); + result = { value: ev.result?.result?.value }; + break; } - const selectorJson = JSON.stringify(selector); - const js = `(() => { - const el = document.querySelector(${selectorJson}); - if (!el) return { error: '未找到元素: ' + ${selectorJson} }; - el.scrollIntoView({ block: 'center' }); - const rect = el.getBoundingClientRect(); - return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, tag: el.tagName, text: (el.textContent || '').slice(0, 100) }; - })()`; - const coordResp = await sendCDP('Runtime.evaluate', { - expression: js, - returnByValue: true, - awaitPromise: true, - }, sid); - const coord = coordResp.result?.result?.value; - if (!coord || coord.error) { - res.statusCode = 400; - res.end(JSON.stringify(coord || coordResp.result)); - return; + case '/click': { + const sid = await attachToTarget(url.searchParams.get('target')); + const box = await sendCommand('Runtime.evaluate', { + expression: `(()=>{const e=document.querySelector('${body.replace(/'/g,"\\'")}');if(!e)return;const rect=e.getBoundingClientRect();return{x:rect.x+rect.width/2,y:rect.y+rect.height/2};})()`, + }, sid); + const { x, y } = box.result.result.value; + await sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, sid); + await sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, sid); + result = { clicked: true }; + break; } - await sendCDP('Input.dispatchMouseEvent', { - type: 'mousePressed', x: coord.x, y: coord.y, button: 'left', clickCount: 1 - }, sid); - await sendCDP('Input.dispatchMouseEvent', { - type: 'mouseReleased', x: coord.x, y: coord.y, button: 'left', clickCount: 1 - }, sid); - res.end(JSON.stringify({ clicked: true, x: coord.x, y: coord.y, tag: coord.tag, text: coord.text })); - } - - // POST /setFiles?target=xxx — 给 file input 设置本地文件(绕过文件对话框) - // body: JSON { "selector": "input[type=file]", "files": ["/path/to/file1.png", "/path/to/file2.png"] } - else if (pathname === '/setFiles') { - const sid = await ensureSession(q.target); - const body = JSON.parse(await readBody(req)); - if (!body.selector || !body.files) { - res.statusCode = 400; - res.end(JSON.stringify({ error: '需要 selector 和 files 字段' })); - return; + case '/screenshot': { + const sid = await attachToTarget(url.searchParams.get('target')); + const shot = await sendCommand('Page.captureScreenshot', { format: 'png' }, sid); + const file = url.searchParams.get('file'); + if (file) { + await fs.promises.writeFile(file, Buffer.from(shot.result.data, 'base64')); + result = { file }; + } else { + result = { base64: shot.result.data }; + } + break; } - // 获取 DOM 节点 - await sendCDP('DOM.enable', {}, sid); - const doc = await sendCDP('DOM.getDocument', {}, sid); - const node = await sendCDP('DOM.querySelector', { - nodeId: doc.result.root.nodeId, - selector: body.selector - }, sid); - if (!node.result?.nodeId) { - res.statusCode = 400; - res.end(JSON.stringify({ error: '未找到元素: ' + body.selector })); - return; + case '/close': { + await sendCommand('Target.closeTarget', { targetId: url.searchParams.get('target') }); + sessions.delete(url.searchParams.get('target')); + result = { closed: true }; + break; } - // 设置文件 - await sendCDP('DOM.setFileInputFiles', { - nodeId: node.result.nodeId, - files: body.files - }, sid); - res.end(JSON.stringify({ success: true, files: body.files.length })); - } - - // GET /scroll?target=xxx&y=3000 - 滚动 - else if (pathname === '/scroll') { - const sid = await ensureSession(q.target); - const y = parseInt(q.y || '3000'); - const direction = q.direction || 'down'; // down | up | top | bottom - let js; - if (direction === 'top') { - js = 'window.scrollTo(0, 0); "scrolled to top"'; - } else if (direction === 'bottom') { - js = 'window.scrollTo(0, document.body.scrollHeight); "scrolled to bottom"'; - } else if (direction === 'up') { - js = `window.scrollBy(0, -${Math.abs(y)}); "scrolled up ${Math.abs(y)}px"`; - } else { - js = `window.scrollBy(0, ${Math.abs(y)}); "scrolled down ${Math.abs(y)}px"`; + case '/status': { + result = { connected: browserWs?.readyState === WS.OPEN, sessions: sessions.size, tabPool: tabPool.size }; + break; } - const resp = await sendCDP('Runtime.evaluate', { - expression: js, - returnByValue: true, - }, sid); - // 等待懒加载触发 - await new Promise(r => setTimeout(r, 800)); - res.end(JSON.stringify({ value: resp.result?.result?.value })); - } - - // GET /screenshot?target=xxx&file=/tmp/x.png - 截图 - else if (pathname === '/screenshot') { - const sid = await ensureSession(q.target); - const format = q.format || 'png'; - const resp = await sendCDP('Page.captureScreenshot', { - format, - quality: format === 'jpeg' ? 80 : undefined, - }, sid); - if (q.file) { - fs.writeFileSync(q.file, Buffer.from(resp.result.data, 'base64')); - res.end(JSON.stringify({ saved: q.file })); - } else { - res.setHeader('Content-Type', 'image/' + format); - res.end(Buffer.from(resp.result.data, 'base64')); + case '/tabs': { + const r = await sendCommand('Target.getTargets'); + result = { tabs: r.result.targetInfos.filter(t => t.type === 'page').map(t => ({ id: t.targetId, url: t.url, title: t.title })) }; + break; } + default: + res.statusCode = 404; + result = { error: 'Not found' }; } - - // GET /info?target=xxx - 获取页面信息 - else if (pathname === '/info') { - const sid = await ensureSession(q.target); - const resp = await sendCDP('Runtime.evaluate', { - expression: 'JSON.stringify({title: document.title, url: location.href, ready: document.readyState})', - returnByValue: true, - }, sid); - res.end(resp.result?.result?.value || '{}'); - } - - else { - res.statusCode = 404; - res.end(JSON.stringify({ - error: '未知端点', - endpoints: { - '/health': 'GET - 健康检查', - '/targets': 'GET - 列出所有页面 tab', - '/new?url=': 'GET - 创建新后台 tab(自动等待加载)', - '/close?target=': 'GET - 关闭 tab', - '/navigate?target=&url=': 'GET - 导航(自动等待加载)', - '/back?target=': 'GET - 后退', - '/info?target=': 'GET - 页面标题/URL/状态', - '/eval?target=': 'POST body=JS表达式 - 执行 JS', - '/click?target=': 'POST body=CSS选择器 - 点击元素', - '/scroll?target=&y=&direction=': 'GET - 滚动页面', - '/screenshot?target=&file=': 'GET - 截图', - }, - })); - } - } catch (e) { + res.end(JSON.stringify(result)); + } catch (err) { res.statusCode = 500; - res.end(JSON.stringify({ error: e.message })); + res.end(JSON.stringify({ error: err.message })); } }); -// 检查端口是否被占用 -function checkPortAvailable(port) { - return new Promise((resolve) => { - const s = net.createServer(); - s.once('error', () => resolve(false)); - s.once('listening', () => { s.close(); resolve(true); }); - s.listen(port, '127.0.0.1'); - }); -} - -async function main() { - // 检查是否已有 proxy 在运行 - const available = await checkPortAvailable(PORT); - if (!available) { - // 验证已有实例是否健康 - try { - const ok = await new Promise((resolve) => { - http.get(`http://127.0.0.1:${PORT}/health`, { timeout: 2000 }, (res) => { - let d = ''; - res.on('data', c => d += c); - res.on('end', () => resolve(d.includes('"ok"'))); - }).on('error', () => resolve(false)); - }); - if (ok) { - console.log(`[CDP Proxy] 已有实例运行在端口 ${PORT},退出`); - process.exit(0); - } - } catch { /* 端口占用但非 proxy,继续报错 */ } - console.error(`[CDP Proxy] 端口 ${PORT} 已被占用`); - process.exit(1); - } - - server.listen(PORT, '127.0.0.1', () => { - console.log(`[CDP Proxy] 运行在 http://localhost:${PORT}`); - // 启动时尝试连接 Chrome(非阻塞) - connect().catch(e => console.error('[CDP Proxy] 初始连接失败:', e.message, '(将在首次请求时重试)')); - }); -} - -// 防止未捕获异常导致进程崩溃 -process.on('uncaughtException', (e) => { - console.error('[CDP Proxy] 未捕获异常:', e.message); -}); -process.on('unhandledRejection', (e) => { - console.error('[CDP Proxy] 未处理拒绝:', e?.message || e); +server.listen(PORT, () => { + console.log(`[CDP Proxy v2] Running on http://localhost:${PORT}`); + ensureBrowserConnection().catch(e => console.log('[CDP] Will connect on first request:', e.message)); }); -main(); +setInterval(() => { + const now = Date.now(); + for (const [tid, s] of sessions) { + if (now - s.lastUsed > 10 * 60 * 1000) { + sendCommand('Target.closeTarget', { targetId: tid }).catch(() => {}); + sessions.delete(tid); + } + } +}, 60000); diff --git a/tools/browse-extract.mjs b/tools/browse-extract.mjs new file mode 100644 index 0000000..ab6a77d --- /dev/null +++ b/tools/browse-extract.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * 高阶工具:一次调用完成 URL 打开 + 内容提取 + * 用法:node browse-extract.mjs [提取目标] + */ +const PROXY = process.env.CDP_PROXY || 'http://localhost:3456'; + +async function browseAndExtract(url, goal = '页面全部内容') { + // 1. 智能打开(复用同域 Tab) + const openRes = await fetch(`${PROXY}/smart-open?url=${encodeURIComponent(url)}&reuse=true`); + const { targetId, reused } = await openRes.json(); + console.error(`[browse] ${reused ? '复用' : '新建'} Tab: ${targetId}`); + + // 2. 批处理:提取 + const batchRes = await fetch(`${PROXY}/batch`, { + method: 'POST', + body: JSON.stringify({ + target: targetId, + commands: [ + { + action: 'eval', + expression: ` + (() => { + const extractors = { + article: () => { + const a = document.querySelector('article,[role="article"],.article,.post,.entry'); + if (a) return { type: 'article', content: a.innerText.slice(0, 15000) }; + }, + table: () => { + const t = document.querySelector('table'); + if (t) return { type: 'table', rows: [...t.querySelectorAll('tr')].slice(0,50).map(r => [...r.querySelectorAll('td,th')].map(c => c.innerText.trim())) }; + }, + list: () => { + const items = [...document.querySelectorAll('[class*="item"],[class*="card"],li')].slice(0,20); + if (items.length > 3) return { type: 'list', items: items.map(e => e.innerText.slice(0,500)) }; + }, + generic: () => ({ type: 'generic', title: document.title, content: document.body.innerText.slice(0, 15000) }) + }; + for (const fn of Object.values(extractors)) { const r = fn(); if (r) return r; } + })() + ` + }, + { + action: 'eval', + expression: `JSON.stringify({ + title: document.title, + url: location.href, + links: [...document.querySelectorAll('a[href]')].slice(0,20).map(a=>({t:a.innerText.trim().slice(0,80),h:a.href})).filter(l=>l.t&&l.h.startsWith('http')) + })` + } + ] + }) + }); + + const [contentResult, metaResult] = await batchRes.json(); + const content = contentResult?.success ? contentResult.value : null; + const meta = metaResult?.success ? JSON.parse(metaResult.value) : {}; + + return { + targetId, + reused, + type: content?.type, + title: meta.title || content?.title, + content: content?.content || content, + table: content?.rows ? { rows: content.rows } : null, + list: content?.items ? { items: content.items } : null, + links: meta.links || [], + url + }; +} + +if (process.argv[2]) { + browseAndExtract(process.argv[2], process.argv[3]) + .then(r => console.log(JSON.stringify(r, null, 2))) + .catch(e => console.error('Error:', e.message)); +} + +export { browseAndExtract };