Files
skills/web-access/scripts/cdp-proxy.mjs
T
hmo 04db423416 Initial commit: skills library
- 70 skills with code and documentation
- Add .gitignore (ignore __pycache__, output/, temp/, venv/)
- Clean up test intermediates and caches
2026-04-26 19:27:40 +08:00

573 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();