feat: release v0.74.0 - Chrome Bridge 浏览器自动化集成

🎉 重大功能
- Chrome Bridge: 完整的浏览器自动化控制系统
- 零配置自动连接:扩展自动加载和配置
- AI 原生集成:作为 MCP 工具支持自然语言控制
- 支持导航、页面读取、元素交互、表单操作等

🐛 Bug 修复
- WebSocket 路由修复 (Axum 路径参数语法)
- Chrome 扩展存储清理
- 扩展重复注入防护
- 剪贴板权限添加

🔧 代码质量
- 修复 33+ Clippy 警告
- 所有 259 个测试通过
- ESLint 无警告

📝 文档
- Chrome Bridge 使用指南
- API 技术文档
- 快速参考卡片
This commit is contained in:
coso
2026-02-28 16:05:36 +08:00
parent 0686737331
commit cd95cac72f
108 changed files with 18916 additions and 1921 deletions
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
/**
* 检查 Chrome Bridge 状态
*/
import WebSocket from 'ws';
const SERVER_URL = 'ws://127.0.0.1:8999';
const BRIDGE_KEY = 'Proxycast-key11';
// 连接 Observer 通道查看状态
const observerUrl = `${SERVER_URL}/proxycast-chrome-observer/${BRIDGE_KEY}?profileKey=test`;
console.log(`[检查] 连接 Observer 通道: ${observerUrl}`);
const ws = new WebSocket(observerUrl);
ws.on('open', () => {
console.log('[检查] ✅ Observer 通道连接成功');
console.log('[检查] 这说明服务器正常运行\n');
setTimeout(() => {
ws.close();
}, 2000);
});
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
console.log('[检查] 收到消息:', JSON.stringify(message, null, 2));
});
ws.on('error', (error) => {
console.error('[检查] ❌ 错误:', error.message);
});
ws.on('close', () => {
console.log('\n[检查] 连接关闭');
process.exit(0);
});
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env node
import { randomUUID } from 'node:crypto';
import process from 'node:process';
const DEFAULTS = {
server: 'ws://127.0.0.1:8787',
key: '',
profile: 'default',
timeoutMs: 15000,
};
function parseArgs(argv) {
const args = { ...DEFAULTS };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--server' && argv[i + 1]) {
args.server = argv[i + 1];
i += 1;
continue;
}
if (arg === '--key' && argv[i + 1]) {
args.key = argv[i + 1];
i += 1;
continue;
}
if (arg === '--profile' && argv[i + 1]) {
args.profile = argv[i + 1];
i += 1;
continue;
}
if (arg === '--timeout-ms' && argv[i + 1]) {
args.timeoutMs = Number(argv[i + 1]);
i += 1;
continue;
}
if (arg === '--help' || arg === '-h') {
printHelp();
process.exit(0);
}
}
return args;
}
function printHelp() {
console.log(`
Proxycast Chrome Bridge E2E 联调脚本
用法:
node scripts/chrome-bridge-e2e.mjs --key <proxycast_api_key> [选项]
选项:
--server <ws_url> 服务地址,默认 ws://127.0.0.1:8787
--key <api_key> Proxycast API Key(必填)
--profile <profile_key> profileKey,默认 default
--timeout-ms <ms> 单步超时毫秒,默认 15000
-h, --help 显示帮助
示例:
node scripts/chrome-bridge-e2e.mjs --server ws://127.0.0.1:8787 --key proxy_cast --profile default
`);
}
function assertGlobalWebSocket() {
if (typeof WebSocket !== 'undefined') {
return;
}
throw new Error(
'当前 Node 运行时不支持全局 WebSocket,请使用 Node 20+ 或安装支持 WebSocket 的运行环境。',
);
}
function normalizeServer(server) {
return String(server || '').trim().replace(/\/$/, '');
}
function toText(data) {
if (typeof data === 'string') return data;
if (Buffer.isBuffer(data)) return data.toString('utf8');
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer).toString('utf8');
return String(data);
}
function createClient(url, label, timeoutMs) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const state = {
label,
ws,
messages: [],
waiters: [],
};
const timer = setTimeout(() => {
reject(new Error(`[${label}] 连接超时: ${url}`));
try {
ws.close();
} catch (_) {
// ignore
}
}, timeoutMs);
ws.onopen = () => {
clearTimeout(timer);
resolve(state);
};
ws.onerror = (event) => {
clearTimeout(timer);
reject(new Error(`[${label}] WebSocket 连接失败: ${event?.message || 'unknown error'}`));
};
ws.onmessage = (event) => {
let payload;
const text = toText(event.data);
try {
payload = JSON.parse(text);
} catch (_) {
payload = { type: 'raw_text', data: text };
}
state.messages.push(payload);
const pending = [...state.waiters];
for (const waiter of pending) {
if (waiter.predicate(payload)) {
waiter.resolve(payload);
state.waiters = state.waiters.filter((item) => item !== waiter);
}
}
};
});
}
function waitForMessage(client, predicate, timeoutMs, desc) {
const found = client.messages.find(predicate);
if (found) {
return Promise.resolve(found);
}
return new Promise((resolve, reject) => {
const waiter = { predicate, resolve };
client.waiters.push(waiter);
const timer = setTimeout(() => {
client.waiters = client.waiters.filter((item) => item !== waiter);
reject(
new Error(
`[${client.label}] 等待消息超时(${timeoutMs}ms): ${desc}\n最近消息: ${JSON.stringify(
client.messages.slice(-5),
null,
2,
)}`,
),
);
}, timeoutMs);
waiter.resolve = (payload) => {
clearTimeout(timer);
resolve(payload);
};
});
}
function send(client, payload) {
client.ws.send(JSON.stringify(payload));
}
async function closeClient(client) {
if (!client) return;
await new Promise((resolve) => {
try {
client.ws.onclose = () => resolve();
client.ws.close();
setTimeout(resolve, 200);
} catch (_) {
resolve();
}
});
}
async function main() {
assertGlobalWebSocket();
const args = parseArgs(process.argv.slice(2));
if (!args.key) {
printHelp();
throw new Error('缺少必填参数: --key');
}
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 1000) {
throw new Error('--timeout-ms 必须是 >= 1000 的数字');
}
const server = normalizeServer(args.server);
const key = encodeURIComponent(args.key);
const profile = encodeURIComponent(args.profile || 'default');
const observerUrl = `${server}/proxycast-chrome-observer/Proxycast_Key=${key}?profileKey=${profile}`;
const controlUrl = `${server}/proxycast-chrome-control/Proxycast_Key=${key}`;
console.log('[E2E] observer:', observerUrl);
console.log('[E2E] control :', controlUrl);
let observer;
let control;
try {
observer = await createClient(observerUrl, 'observer', args.timeoutMs);
control = await createClient(controlUrl, 'control', args.timeoutMs);
await waitForMessage(
observer,
(msg) => msg.type === 'connection_ack',
args.timeoutMs,
'observer connection_ack',
);
await waitForMessage(
control,
(msg) => msg.type === 'connection_ack',
args.timeoutMs,
'control connection_ack',
);
console.log('[E2E] 连接握手通过');
send(observer, { type: 'heartbeat', timestamp: Date.now() });
send(control, { type: 'heartbeat', timestamp: Date.now() });
await waitForMessage(
observer,
(msg) => msg.type === 'heartbeat_ack',
args.timeoutMs,
'observer heartbeat_ack',
);
await waitForMessage(
control,
(msg) => msg.type === 'heartbeat_ack',
args.timeoutMs,
'control heartbeat_ack',
);
console.log('[E2E] 心跳通道通过');
const requestId1 = `e2e-${randomUUID()}`;
send(control, {
type: 'command',
data: {
requestId: requestId1,
command: 'get_page_info',
wait_for_page_info: true,
},
});
const cmdFromServer1 = await waitForMessage(
observer,
(msg) => msg.type === 'command' && msg.data?.requestId === requestId1,
args.timeoutMs,
'observer 收到 get_page_info 命令',
);
console.log('[E2E] observer 收到命令:', cmdFromServer1.data?.command);
send(observer, {
type: 'command_result',
data: {
requestId: requestId1,
status: 'success',
message: 'get_page_info executed by e2e observer',
},
});
send(observer, {
type: 'pageInfoUpdate',
data: {
markdown: '# E2E Page\nURL: https://example.com/e2e\n\n## 内容\nbridge e2e test',
},
});
await waitForMessage(
control,
(msg) =>
msg.type === 'command_result' &&
msg.data?.requestId === requestId1 &&
msg.data?.status === 'success',
args.timeoutMs,
'control 收到 command_result(success)',
);
await waitForMessage(
control,
(msg) =>
msg.type === 'page_info_update' &&
msg.data?.requestId === requestId1 &&
typeof msg.data?.markdown === 'string' &&
msg.data.markdown.includes('E2E Page'),
args.timeoutMs,
'control 收到 page_info_update',
);
console.log('[E2E] wait_for_page_info 命令链路通过');
const requestId2 = `e2e-${randomUUID()}`;
send(control, {
type: 'command',
data: {
requestId: requestId2,
command: 'scroll',
text: 'down:300',
wait_for_page_info: false,
},
});
const cmdFromServer2 = await waitForMessage(
observer,
(msg) => msg.type === 'command' && msg.data?.requestId === requestId2,
args.timeoutMs,
'observer 收到 scroll 命令',
);
if (cmdFromServer2.data?.command !== 'scroll') {
throw new Error(`期望 scroll,实际为 ${cmdFromServer2.data?.command || 'unknown'}`);
}
send(observer, {
type: 'command_result',
data: {
requestId: requestId2,
status: 'success',
message: 'scroll executed by e2e observer',
},
});
await waitForMessage(
control,
(msg) =>
msg.type === 'command_result' &&
msg.data?.requestId === requestId2 &&
msg.data?.status === 'success',
args.timeoutMs,
'control 收到 scroll command_result',
);
console.log('[E2E] 非 wait_for_page_info 命令链路通过');
console.log('\n[E2E] ✅ Chrome Bridge 联调通过');
} finally {
await closeClient(control);
await closeClient(observer);
}
}
main().catch((error) => {
console.error('\n[E2E] ❌ Chrome Bridge 联调失败');
console.error(error?.stack || error?.message || String(error));
process.exit(1);
});
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* Chrome Bridge 测试脚本
*
* 使用方式:
* 1. 确保 ProxyCast 服务器正在运行
* 2. 确保 Chrome Profile 已打开并连接
* 3. 运行: node scripts/test-chrome-bridge.mjs
*/
import WebSocket from 'ws';
const SERVER_URL = 'ws://127.0.0.1:8999';
const BRIDGE_KEY = 'Proxycast-key11';
const PROFILE_KEY = 'search_google';
// 连接 Control 通道
const controlUrl = `${SERVER_URL}/proxycast-chrome-control/${BRIDGE_KEY}`;
console.log(`[测试] 连接 Control 通道: ${controlUrl}`);
const ws = new WebSocket(controlUrl);
ws.on('open', async () => {
console.log('[测试] ✅ Control 通道连接成功\n');
// 测试 1: 获取页面信息
console.log('=== 测试 1: 获取当前页面信息 ===');
ws.send(JSON.stringify({
type: 'command',
request_id: 'test-1',
profile_key: PROFILE_KEY,
command: 'get_page_info',
wait_for_page_info: true
}));
// 等待 3 秒
await new Promise(resolve => setTimeout(resolve, 3000));
// 测试 2: 打开 URL
console.log('\n=== 测试 2: 打开 Google ===');
ws.send(JSON.stringify({
type: 'command',
request_id: 'test-2',
profile_key: PROFILE_KEY,
command: 'open_url',
url: 'https://www.google.com',
wait_for_page_info: true
}));
// 等待 5 秒后关闭
setTimeout(() => {
console.log('\n[测试] 测试完成,关闭连接');
ws.close();
}, 8000);
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (message.type === 'connection_ack') {
console.log('[测试] 收到连接确认:', message.message);
console.log('[测试] Client ID:', message.data?.clientId);
} else if (message.type === 'command_result') {
console.log(`\n[结果] Request ID: ${message.request_id}`);
console.log(`[结果] 命令: ${message.command}`);
console.log(`[结果] 成功: ${message.success}`);
if (message.message) {
console.log(`[结果] 消息: ${message.message}`);
}
if (message.error) {
console.log(`[结果] 错误: ${message.error}`);
}
if (message.page_info) {
console.log(`[结果] 页面标题: ${message.page_info.title}`);
console.log(`[结果] 页面 URL: ${message.page_info.url}`);
console.log(`[结果] Markdown 长度: ${message.page_info.markdown.length} 字符`);
console.log(`[结果] Markdown 预览:\n${message.page_info.markdown.substring(0, 200)}...`);
}
} else if (message.type === 'heartbeat_ack') {
// 忽略心跳响应
} else {
console.log('[测试] 收到消息:', message);
}
} catch (error) {
console.error('[测试] 解析消息失败:', error.message);
console.log('[测试] 原始消息:', data.toString());
}
});
ws.on('error', (error) => {
console.error('[测试] ❌ WebSocket 错误:', error.message);
});
ws.on('close', (code, reason) => {
console.log(`\n[测试] 连接关闭: code=${code}, reason=${reason.toString()}`);
process.exit(code === 1000 ? 0 : 1);
});
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
import WebSocket from 'ws';
const serverUrl = 'ws://127.0.0.1:8999';
const bridgeKey = 'Proxycast-key11';
const profileKey = 'search_google';
const url = `${serverUrl}/proxycast-chrome-observer/${encodeURIComponent(bridgeKey)}?profileKey=${encodeURIComponent(profileKey)}`;
console.log(`[测试] 连接 URL: ${url}`);
const ws = new WebSocket(url);
ws.on('open', () => {
console.log('[测试] ✅ WebSocket 连接成功');
// 发送心跳
const heartbeat = JSON.stringify({ type: 'heartbeat' });
console.log(`[测试] 发送心跳: ${heartbeat}`);
ws.send(heartbeat);
setTimeout(() => {
console.log('[测试] 关闭连接');
ws.close();
}, 2000);
});
ws.on('message', (data) => {
console.log('[测试] 收到消息:', data.toString());
});
ws.on('error', (error) => {
console.error('[测试] ❌ WebSocket 错误:', error.message);
});
ws.on('close', (code, reason) => {
console.log(`[测试] 连接关闭: code=${code}, reason=${reason.toString()}`);
process.exit(code === 1000 ? 0 : 1);
});