OpenCLI 架构深度解析
基于源码 v1.7.7 分析,npm 包
@jackwener/opencli,GitHub 17K+ stars
一、OpenCLI 是什么
OpenCLI 是一个开源的 统一 CLI 集成平台,核心理念是把任何网站、Electron 桌面应用、本地 CLI 工具统一变成命令行接口。
覆盖范围
| 类别 | 平台(部分) |
|---|---|
| 社交媒体 | Twitter/X、小红书、微博、Instagram、Reddit |
| 内容平台 | B站、知乎、豆瓣、YouTube、HackerNews、掘金 |
| 电商 | 1688、闲鱼、Amazon |
| 桌面应用 | Cursor、ChatGPT、Notion、Discord、Doubao |
| 开发工具 | gh、docker、vercel |
| AI 工具 | Gemini、元宝、NotebookLM |
90+ 适配器,550+ 命令。
核心特性
- 零 LLM 开销:运行时不消耗 token,纯确定性执行
- 账号安全:复用 Chrome 登录态,凭证不离开浏览器
- AI Agent 原生:为 Claude Code / Cursor / Codex 等设计了 skill 体系
- 确定性输出:相同命令、相同输出 schema,可管道、可脚本、CI 友好
数据获取方式与优先级
常见误解:OpenCLI 用 Playwright?CLI 直接调网站 API?都不是。
OpenCLI 的数据获取完全依赖 Chrome 浏览器 + Extension 作为执行环境(公开 API 和本地工具除外)。它不使用 Playwright,不从 CLI 进程直接发请求,而是在你已登录的 Chrome 浏览器里操作。
每个适配器在编译时静态声明一种策略,不存在运行时自动降级或瀑布选择。但从适配器设计的角度,有一个清晰的优先级偏好:
sql
优先级从高到低:
PUBLIC(直接 API) > LOCAL(本地工具) > COOKIE(浏览器 DOM/Fetch)
> HEADER(浏览器认证请求) > INTERCEPT(XHR 拦截) > UI(CDP 直连)
原则:能不开浏览器就不开,必须开时优先读 API 而非 DOM
| 优先级 | 策略 | 获取方式 | 需要浏览器 | 典型平台 |
|---|---|---|---|---|
| 1 | PUBLIC |
Node.js 直接 fetch 公开 API | 否 | HackerNews、GitHub Trending |
| 2 | LOCAL |
调用本地已安装的 CLI/二进制 | 否 | gh、docker、vercel |
| 3 | COOKIE |
浏览器开页面,注入 JS 读 DOM 或 fetch | 是 | 小红书、B站、知乎、Twitter |
| 4 | HEADER |
浏览器获取认证 Header,带 Token 发请求 | 是 | 需 Bearer Token 的站点 |
| 5 | INTERCEPT |
注入拦截器,捕获页面自身的 API 响应 | 是 | 动态 Token 的 SPA 应用 |
| 6 | UI |
CDP 直连 Electron 应用的调试端口 | 是(CDP) | Cursor、Notion、ChatGPT 桌面版 |
为什么这个优先级有意义:
- PUBLIC/LOCAL(不开浏览器):最快、最稳定,毫秒级返回
- COOKIE/HEADER(浏览器内操作):需要数秒启动浏览器连接,但复用登录态无需配置
- INTERCEPT(被动捕获):最灵活但最脆弱,API URL pattern 变了就要更新适配器
- UI(桌面应用):依赖应用暴露调试端口,版本升级可能 break
二、整体架构:三层通信模型
arduino
┌──────────────┐ HTTP POST ┌──────────────┐ WebSocket ┌──────────────────┐
│ CLI 进程 │ ──────────────────→│ Daemon │ ──────────────→ │ Chrome Extension │
│ (opencli xxx) │ ←────────────────── │ :19825 │ ←────────────── │ (Browser Bridge) │
└──────────────┘ HTTP Response └──────────────┘ WS Result └──────────────────┘
│
│ Chrome Extension API
│ (tabs.create, scripting.executeScript,
│ debugger.attach)
▼
┌──────────────┐
│ Chrome 页面 │
│ (已登录状态) │
└──────────────┘
关键认知:OpenCLI 不直接使用 Playwright,也不是 CLI 直连 CDP。它通过 Chrome Extension 作为桥梁,Extension 才是真正操作浏览器的执行者。
三、各层详解
3.1 第一层:Daemon(消息中转站)
源码位置 :dist/src/daemon.js
Daemon 是一个轻量 HTTP + WebSocket 服务,运行在 localhost:19825:
objectivec
架构注释原文:
CLI → HTTP POST /command → daemon → WebSocket → Extension
Extension → WebSocket result → daemon → HTTP response → CLI
职责
| 功能 | 说明 |
|---|---|
| 命令中转 | 接收 CLI 的 HTTP 请求,通过 WS 转发给 Extension |
| 结果回传 | Extension 通过 WS 返回结果,Daemon 通过 HTTP 回给 CLI |
| 连接管理 | 维护与 Extension 的 WebSocket 连接 |
| 心跳保活 | 每 15 秒 ping Extension,连续 2 次无 pong 则断开 |
| 安全防护 | Origin 检查 + X-OpenCLI 自定义头 + 无 CORS 头 |
| 日志缓冲 | 保留最近 200 条日志,可通过 /logs 查询 |
安全设计(defense-in-depth)
markdown
1. Origin 检查 --- 拒绝非 chrome-extension:// 来源的请求
2. 自定义头 --- 要求 X-OpenCLI 头(浏览器无法在简单请求中发送)
3. 无 CORS 头 --- 命令端点不返回 Access-Control-Allow-Origin
4. Body 限制 --- 最大 1MB 防止 OOM
5. WS 验证 --- 在连接建立前拒绝非法 WebSocket 升级
生命周期
- 自动启动 :首次执行浏览器命令时由
BrowserBridge自动 spawn - 持久运行:启动后常驻,不随 CLI 进程退出
- 显式关闭 :
opencli daemon stop或SIGTERM
关键代码
javascript
// daemon.js:154-183 --- 命令中转核心逻辑
if (req.method === 'POST' && url === '/command') {
const body = JSON.parse(await readBody(req));
// 检查 Extension 是否连接
if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
jsonResponse(res, 503, { error: 'Extension not connected' });
return;
}
const result = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(body.id);
reject(new Error('Command timeout'));
}, timeoutMs);
pending.set(body.id, { resolve, reject, timer });
extensionWs.send(JSON.stringify(body)); // 转发给 Extension
});
jsonResponse(res, 200, result); // Extension 结果回给 CLI
}
3.2 第二层:Chrome Extension(Browser Bridge)
Extension 是真正执行浏览器操作的地方。
通信方式
- 启动时通过 WebSocket 连接到 Daemon(
ws://localhost:19825/ext) - 连接后发送
hello消息(包含版本号)进行握手 - 收到命令后调用 Chrome Extension API 操作浏览器
- 结果通过 WebSocket 返回给 Daemon
核心能力
| Chrome API | 用途 |
|---|---|
chrome.tabs.create() |
创建新标签页(导航) |
chrome.scripting.executeScript() |
在页面中注入并执行 JS |
chrome.debugger.attach() |
附加到标签页(CDP 入口) |
chrome.cookies.getAll() |
读取指定域名的 Cookie |
安全约束
javascript
// daemon.js:198-208 --- 只接受 Extension 的 WebSocket 连接
const wss = new WebSocketServer({
server: httpServer,
path: '/ext',
verifyClient: ({ req }) => {
const origin = req.headers['origin'];
return !origin || origin.startsWith('chrome-extension://');
},
});
3.3 第三层:CLI 客户端
源码位置 :dist/src/browser/daemon-client.js
CLI 进程通过 HTTP 与 Daemon 通信。
重试机制
- 最多 4 次重试
- 网络错误(TypeError, AbortError):500ms 后重试
- 瞬态浏览器错误:按
classifyBrowserError()建议的延迟重试 - 重复命令 ID(409):直接重试
javascript
// daemon-client.js:72-99 --- 带重试的命令发送
async function sendCommandRaw(action, params) {
const maxRetries = 4;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const command = { id: generateId(), action, ...params };
const res = await requestDaemon('/command', {
method: 'POST',
body: JSON.stringify(command),
timeout: 30000,
});
const result = await res.json();
if (!result.ok) {
const advice = classifyBrowserError(new Error(result.error));
if (advice.retryable && attempt < maxRetries) {
await sleep(advice.delayMs);
continue;
}
throw new Error(result.error);
}
return result;
}
}
3.4 旁路:CDPBridge(直连模式)
源码位置 :dist/src/browser/cdp.js
专门为 Electron 桌面应用 设计,绕过 Daemon 和 Extension,直接通过 WebSocket 连接应用暴露的 CDP 端口:
javascript
export class CDPBridge {
async connect(opts) {
const endpoint = opts?.cdpEndpoint ?? process.env.OPENCLI_CDP_ENDPOINT;
// 获取可调试的 target
const targets = await fetchJsonDirect(`${endpoint}/json`);
const target = selectCDPTarget(targets);
// 直接 WebSocket 连接
const ws = new WebSocket(target.webSocketDebuggerUrl);
// 启用 CDP 协议
await this.send('Page.enable');
await this.send('Page.addScriptToEvaluateOnNewDocument', {
source: generateStealthJs()
});
}
}
用于控制 Cursor、Notion、ChatGPT 桌面版等 Electron 应用。
四、认证策略(Strategy)
源码位置 :dist/src/registry.js
javascript
export var Strategy;
Strategy["PUBLIC"] = "public"; // 公开 API,无需认证
Strategy["LOCAL"] = "local"; // 本地二进制/API
Strategy["COOKIE"] = "cookie"; // 复用浏览器 Cookie
Strategy["HEADER"] = "header"; // 带 Auth Header
Strategy["INTERCEPT"] = "intercept"; // 注入拦截器捕获网络请求
Strategy["UI"] = "ui"; // CDP 直连桌面应用
策略路由逻辑
策略在适配器代码中编译时声明,不是运行时自动判断:
javascript
// registry.js:60-76 --- normalizeCommand
function normalizeCommand(cmd) {
const strategy = cmd.strategy ?? (cmd.browser === false
? Strategy.PUBLIC
: Strategy.COOKIE);
// 非 PUBLIC/LOCAL 都需要浏览器
const browser = strategy !== Strategy.PUBLIC
&& strategy !== Strategy.LOCAL;
// COOKIE/HEADER 自动预导航到域名首页
let navigateBefore;
if ((strategy === Strategy.COOKIE || strategy === Strategy.HEADER) && cmd.domain) {
navigateBefore = `https://${cmd.domain}`;
}
return { ...cmd, strategy, browser, navigateBefore };
}
策略对比
| Strategy | 需要浏览器 | 预导航 | 数据来源 | 典型场景 |
|---|---|---|---|---|
PUBLIC |
否 | 无 | Node.js 直接 fetch | HackerNews、GitHub trending |
LOCAL |
否 | 无 | 本地二进制 | gh、docker |
COOKIE |
是 | 域名首页 | 浏览器内 JS 执行 | 小红书、B站、知乎、Twitter |
HEADER |
是 | 域名首页 | 浏览器内带 Header fetch | 需 Bearer Token 的站 |
INTERCEPT |
是 | 触发页 | 注入拦截器捕获 API 响应 | 动态 Token 的 SPA 站 |
UI |
是(CDP) | 无 | CDP 直连 Electron | Cursor、Notion、ChatGPT |
五、完整执行链路:opencli xiaohongshu search xxx
Step 1: CLI 入口 → 适配器定位
bash
main.js → cli.js → 查 registry → 找到 xiaohongshu/search
适配器声明:
javascript
// clis/xiaohongshu/search.js
cli({
site: 'xiaohongshu',
name: 'search',
strategy: Strategy.COOKIE, // 需要浏览器 + Cookie
domain: 'www.xiaohongshu.com',
navigateBefore: false, // 覆盖默认,直接去搜索页
columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
func: async (page, kwargs) => { ... }
})
Step 2: 建立浏览器连接
scss
BrowserBridge.connect()
└→ _ensureDaemon()
├→ getDaemonHealth() // HTTP GET localhost:19825/status
├→ Daemon 没运行?spawn 新进程(detached, 常驻)
├→ Extension 没连?等待(poll 200ms 间隔,最多 10s)
└→ 返回 Page 实例
Step 3: 导航到搜索页
lua
page.goto("https://www.xiaohongshu.com/search_result?keyword=xxx")
└→ sendCommandFull('navigate', { url, workspace })
└→ HTTP POST localhost:19825/command
└→ Daemon WS 转发给 Extension
└→ Extension: chrome.tabs.create({ url })
└→ ⚡ Chrome 自动携带 xiaohongshu.com 的 Cookie
└→ Extension 返回 { ok: true, page: targetId }
└→ Daemon HTTP 200 回给 CLI
关键点:Cookie 不需要"提取"或"传递"。Chrome 自己开标签页,自动带上该域名的所有 Cookie。
Step 4: 注入 Stealth + DOM 稳定检测
javascript
// page.js:62-66
const combinedCode = `${generateStealthJs()};\n${waitForDomStableJs(maxMs)}`;
await sendCommand('exec', { code: combinedCode });
- Stealth:伪装自动化指纹(navigator.webdriver = false 等)
- DOM Stable:MutationObserver 检测 DOM 变化停止
Step 5: 等待搜索结果渲染
javascript
// search.js --- 用 MutationObserver 等待内容出现
const waitResult = await page.evaluate(`
new Promise((resolve) => {
const detect = () => {
if (document.querySelector('section.note-item')) return 'content';
if (/登录后查看/.test(document.body?.innerText)) return 'login_wall';
return null;
};
// MutationObserver 监听 DOM 变化
const observer = new MutationObserver(() => {
const result = detect();
if (result) { observer.disconnect(); resolve(result); }
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => resolve('timeout'), 5000);
})
`);
Step 6: 滚动加载更多
javascript
await page.autoScroll({ times: 2 });
Step 7: DOM 数据提取
javascript
const payload = await page.evaluate(`
(() => {
const results = [];
document.querySelectorAll('section.note-item').forEach(el => {
const title = el.querySelector('.title')?.textContent;
const author = el.querySelector('a.author .name')?.textContent;
const likes = el.querySelector('.count')?.textContent;
const url = el.querySelector('a.cover.mask')?.getAttribute('href');
results.push({ title, author, likes, url });
});
return results;
})()
`);
完整的 evaluate 链路:
php
page.evaluate(js)
└→ sendCommand('exec', { code: js })
└→ HTTP POST /command → Daemon WS 转发 → Extension
└→ chrome.scripting.executeScript({ target: tabId, func: ... })
└→ 在页面 V8 引擎里执行 JS,读 DOM
└→ 返回 JSON 结果
└→ 原路返回给 CLI
Step 8: 格式化输出
bash
opencli xiaohongshu search "关键词" -f json # JSON
opencli xiaohongshu search "关键词" -f table # 表格(默认)
opencli xiaohongshu search "关键词" -f csv # CSV
opencli xiaohongshu search "关键词" -f md # Markdown
六、三种数据提取模式
模式 A:DOM 提取(小红书 search 用的方式)
scss
导航到页面 → 等待渲染 → evaluate(JS 读 DOM) → 返回结构化数据
优点:所见即所得,不依赖 API 稳定性 缺点:DOM 结构变化会 break
模式 B:浏览器内 Fetch(pipeline/steps/fetch.js)
javascript
// 在浏览器 V8 里发 fetch,自动带 Cookie
await page.evaluate(`
async () => {
const resp = await fetch(url, {
credentials: "include" // 关键:自动带 Cookie
});
return await resp.json();
}
`)
优点:拿到 API 原始 JSON,比 DOM 稳定 关键:不是 CLI 进程发请求,是浏览器内发,所以自动带认证
模式 C:XHR 拦截(pipeline/steps/intercept.js)
markdown
1. 注入拦截器:monkey-patch window.fetch + XMLHttpRequest
2. 触发动作(导航/点击/滚动)
3. 等待捕获:匹配 URL pattern 的响应体存入 window.__opencli_net[]
4. 取出数据:page.getInterceptedRequests()
拦截器代码(cli.js:567)会同时 hook window.fetch 和 XMLHttpRequest.prototype,捕获所有 JSON/text 类型的网络响应。
优点:零侵入拿到原始 API 响应 缺点:需要知道 API URL pattern
七、Token / Cookie / 凭证管理
Token 在哪里?
| 问题 | 答案 |
|---|---|
| Token 存储位置 | Chrome 浏览器进程里(Cookie jar、localStorage、session storage) |
| Token 是否经过 Daemon? | 不经过。Daemon 只转发命令和结果,不接触凭证 |
| Token 是否经过 CLI 进程? | 不经过。CLI 只收到最终的数据结果 |
| 需要手动配置 Token 吗? | 不需要。在 Chrome 里登录目标网站就行 |
| 凭证持久化在哪? | Chrome 自身的 profile 目录 |
保活机制
- 无独立保活:依赖 Chrome 浏览器的登录状态
- Cookie 过期 → 在 Chrome 里重新登录即可
opencli doctor检查连接状态- Daemon 有心跳(15s ping/pong)保持与 Extension 的连接
安全边界
scss
┌─────────────────────────────────────────────┐
│ Chrome 浏览器进程 │
│ ┌────────────────────────────────────────┐ │
│ │ Cookie / Token / Session / LocalStorage│ ← 凭证只在这里
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ Extension(Browser Bridge) │ ← 操作浏览器
│ │ ↕ WebSocket │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
↕ WebSocket (localhost only)
┌─────────────────────────────────────────────┐
│ Daemon (:19825) │ ← 纯消息中转
│ ↕ HTTP (localhost only, X-OpenCLI header) │
└─────────────────────────────────────────────┘
↕ HTTP
┌─────────────────────────────────────────────┐
│ CLI 进程 (opencli xxx) │ ← 只收到结果数据
└─────────────────────────────────────────────┘
八、Pipeline 执行器
源码位置 :dist/src/pipeline/executor.js
对于 YAML 声明式适配器(非 JS 函数式),通过 Pipeline 执行:
javascript
// 顺序执行每个 step
for (const step of pipeline) {
for (const [op, params] of Object.entries(step)) {
data = await executeStepWithRetry(handler, page, params, data, args, op);
}
}
可用的 Pipeline Steps
| Step | 说明 |
|---|---|
navigate |
导航到 URL |
click |
点击元素 |
type |
输入文本 |
wait |
等待时间/文本出现 |
press |
按键 |
snapshot |
DOM 快照 |
evaluate |
执行 JS |
fetch |
浏览器内 HTTP 请求 |
intercept |
XHR/fetch 拦截 |
transform |
数据转换 |
download |
下载文件 |
Pipeline 有内置重试(浏览器类 step 默认重试 2 次)。
九、与 Claude Code 的集成
安装的 5 个 Skills
bash
npx skills add jackwener/opencli --yes --global
| Skill | 用途 |
|---|---|
opencli-usage |
命令总览和站点参考 |
opencli-adapter-author |
为新站点编写适配器(端到端) |
opencli-autofix |
修复坏掉的适配器 |
opencli-browser |
浏览器自动化参考 |
smart-search |
智能搜索路由 |
Skills 通过 symlink 安装到 ~/.claude/skills/,Claude Code 启动时自动加载。
使用方式
直接用自然语言描述需求,Claude Code 会调用对应的 opencli 命令:
sql
"帮我搜下小红书上关于 xxx 的笔记"
→ opencli xiaohongshu search "xxx" -f json
"看看 Twitter 上 xxx 的最新推文"
→ opencli twitter timeline xxx -f json
"下载这个B站视频"
→ opencli bilibili download BV1xxx --output ./
十、配置参考
| 环境变量 | 默认值 | 说明 |
|---|---|---|
OPENCLI_DAEMON_PORT |
19825 |
Daemon 端口 |
OPENCLI_WINDOW_FOCUSED |
false |
前台打开自动化窗口 |
OPENCLI_LIVE |
false |
命令结束后保持窗口 |
OPENCLI_BROWSER_CONNECT_TIMEOUT |
30 |
浏览器连接超时(秒) |
OPENCLI_BROWSER_COMMAND_TIMEOUT |
60 |
单条命令超时(秒) |
OPENCLI_CDP_ENDPOINT |
--- | CDP 端点(Electron 直连) |
OPENCLI_VERBOSE |
false |
详细日志 |
OPENCLI_DIAGNOSTIC |
false |
失败时输出诊断上下文 |
Exit Codes
| Code | 含义 |
|---|---|
0 |
成功 |
2 |
参数错误 |
66 |
空结果 |
69 |
服务不可用(Extension 未连接) |
75 |
临时失败(超时,可重试) |
77 |
需要登录 |
78 |
配置错误 |
十一、安装步骤
bash
# 1. 安装 CLI
npm install -g @jackwener/opencli
# 2. 安装 Chrome 扩展(手动)
# 下载:https://github.com/jackwener/opencli/releases
# chrome://extensions → 开发者模式 → 加载已解压的扩展
# 3. 验证
opencli doctor
# 4. 安装 AI Agent Skills
npx skills add jackwener/opencli --yes --global
# 5. 试用
opencli hackernews top --limit 5 # 公开 API,不需要浏览器
opencli xiaohongshu search "关键词" # 需要浏览器+登录