diff --git a/README.md b/README.md index e0c8b9f..9e0ac37 100644 --- a/README.md +++ b/README.md @@ -57,22 +57,25 @@ npm install -g @jackwener/opencli@latest Browser commands need: 1. **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com) -2. **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** extension installed -3. Configure `PLAYWRIGHT_MCP_EXTENSION_TOKEN` (from the extension settings page) in your MCP config: -```json -{ - "mcpServers": { - "playwright": { - "command": "npx", - "args": ["@playwright/mcp@latest", "--extension"], - "env": { - "PLAYWRIGHT_MCP_EXTENSION_TOKEN": "" - } - } - } -} -``` +#### Option A: Chrome 144+ Auto-Discovery (Recommended) + +No extensions needed. Just enable Chrome's built-in remote debugging: + +1. Open `chrome://inspect#remote-debugging` in Chrome +2. Check **"Allow remote debugging for this browser instance"** +3. That's it — opencli will auto-detect the running Chrome via `DevToolsActivePort` + +You can also manually specify a CDP endpoint via `OPENCLI_CDP_ENDPOINT` env var. + +#### Option B: Playwright MCP Bridge Extension (Legacy) + +For older Chrome versions or if auto-discovery doesn't work: + +1. Install **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** extension +2. Configure `PLAYWRIGHT_MCP_EXTENSION_TOKEN` (from the extension settings page) + +> opencli will automatically fall back to extension mode if no running Chrome with remote debugging is detected. Public API commands (`hackernews`, `github search`, `v2ex`) need no browser at all. diff --git a/README.zh-CN.md b/README.zh-CN.md index a8ff9a1..b4490a8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -58,22 +58,25 @@ npm install -g @jackwener/opencli@latest 浏览器命令需要: 1. **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com) -2. 安装 **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** 扩展 -3. 在 MCP 配置中设置 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`(从扩展设置页获取): -```json -{ - "mcpServers": { - "playwright": { - "command": "npx", - "args": ["@playwright/mcp@latest", "--extension"], - "env": { - "PLAYWRIGHT_MCP_EXTENSION_TOKEN": "" - } - } - } -} -``` +#### 方式 A:Chrome 144+ 自动发现(推荐) + +无需安装扩展,只需开启 Chrome 内置远程调试: + +1. 在 Chrome 中打开 `chrome://inspect#remote-debugging` +2. 勾选 **"允许对此浏览器实例进行远程调试"** +3. 完成 — opencli 会通过 `DevToolsActivePort` 自动发现运行中的 Chrome + +也可通过 `OPENCLI_CDP_ENDPOINT` 环境变量手动指定 CDP endpoint。 + +#### 方式 B:Playwright MCP Bridge 扩展(旧版) + +适用于旧版 Chrome 或自动发现不可用的情况: + +1. 安装 **[Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm)** 扩展 +2. 配置 `PLAYWRIGHT_MCP_EXTENSION_TOKEN`(从扩展设置页获取) + +> 如果未检测到开启远程调试的 Chrome,opencli 会自动回退到扩展模式。 公共 API 命令(`hackernews`、`github search`、`v2ex`)无需浏览器。 diff --git a/SKILL.md b/SKILL.md index d0687b6..e0f12e2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -34,8 +34,8 @@ npm update -g @jackwener/opencli Browser commands require: 1. Chrome browser running **(logged into target sites)** -2. [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension -3. Configure `PLAYWRIGHT_MCP_EXTENSION_TOKEN` (from extension settings) in your MCP config +2. **Chrome 144+** (recommended): Open `chrome://inspect#remote-debugging` → enable remote debugging. opencli auto-discovers via `DevToolsActivePort` +3. **Fallback**: [Playwright MCP Bridge](https://chromewebstore.google.com/detail/playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm) extension (auto-used when CDP not available) > **Note**: You must be logged into the target website in Chrome before running commands. Tabs opened during command execution are auto-closed afterwards. @@ -332,6 +332,8 @@ ${{ index + 1 }} | `OPENCLI_BROWSER_COMMAND_TIMEOUT` | 45 | Command execution timeout (sec) | | `OPENCLI_BROWSER_EXPLORE_TIMEOUT` | 120 | Explore timeout (sec) | | `OPENCLI_EXTENSION_LOCK_TIMEOUT` | 120 | Extension lock timeout (sec) | +| `OPENCLI_CDP_ENDPOINT` | — | Manual CDP WebSocket endpoint (overrides auto-discovery) | +| `OPENCLI_FORCE_EXTENSION` | — | Set to `1` to skip CDP and force extension mode | | `PLAYWRIGHT_MCP_EXTENSION_TOKEN` | — | Auto-approve extension connection | ## Troubleshooting @@ -339,7 +341,7 @@ ${{ index + 1 }} | Issue | Solution | |-------|----------| | `npx not found` | Install Node.js: `brew install node` | -| `Timed out connecting to browser` | 1) Chrome must be open 2) Install MCP Bridge extension 3) Click to approve | +| `Timed out connecting to browser` | 1) Chrome must be open 2) Enable remote debugging at `chrome://inspect#remote-debugging` or install MCP Bridge extension | | `Extension lock timed out` | Another opencli command is running; browser commands run serially | | `Target page context` error | Add `navigate:` step before `evaluate:` in YAML | | Empty table data | Check if evaluate returns JSON string (MCP parsing) or data path is wrong | diff --git a/src/browser.ts b/src/browser.ts index 44fce0a..d78d0d6 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -4,6 +4,7 @@ */ import { spawn, execSync, type ChildProcess } from 'node:child_process'; +import * as net from 'node:net'; import { fileURLToPath } from 'node:url'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -20,7 +21,19 @@ import { formatSnapshot } from './snapshotFormatter.js'; * * Priority: OPENCLI_CDP_ENDPOINT env > DevToolsActivePort auto-discovery > --extension fallback */ -function discoverChromeEndpoint(): string | null { + +/** Quick TCP port probe to verify Chrome is actually listening */ +function isPortReachable(port: number, host = '127.0.0.1', timeoutMs = 800): Promise { + return new Promise(resolve => { + const sock = net.createConnection({ port, host }); + sock.setTimeout(timeoutMs); + sock.on('connect', () => { sock.destroy(); resolve(true); }); + sock.on('error', () => resolve(false)); + sock.on('timeout', () => { sock.destroy(); resolve(false); }); + }); +} + +async function discoverChromeEndpoint(): Promise { const candidates: string[] = []; // User-specified Chrome data dir takes highest priority @@ -50,7 +63,11 @@ function discoverChromeEndpoint(): string | null { const port = parseInt(lines[0], 10); const browserPath = lines[1]; // e.g. /devtools/browser/ if (port > 0 && browserPath.startsWith('/devtools/browser/')) { - return `ws://127.0.0.1:${port}${browserPath}`; + const endpoint = `ws://127.0.0.1:${port}${browserPath}`; + // Verify the port is actually reachable (Chrome may have closed, leaving a stale file) + if (await isPortReachable(port)) { + return endpoint; + } } } } catch {} @@ -287,32 +304,38 @@ export class PlaywrightMCP { private _page: Page | null = null; - async connect(opts: { timeout?: number } = {}): Promise { + async connect(opts: { timeout?: number; forceExtension?: boolean } = {}): Promise { await this._acquireLock(); const timeout = opts.timeout ?? CONNECT_TIMEOUT; const mcpPath = findMcpServerPath(); if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp'); + // Chrome 144+ auto-discovery via DevToolsActivePort file. + // Falls back to --extension mode if no running Chrome is detected. + // Some anti-bot sites (e.g. BOSS Zhipin) detect CDP — use forceExtension to bypass. + const forceExt = opts.forceExtension || process.env.OPENCLI_FORCE_EXTENSION === '1'; + const cdpEndpoint = forceExt ? null : (process.env.OPENCLI_CDP_ENDPOINT ?? await discoverChromeEndpoint()); + return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`Timed out connecting to browser (${timeout}s)`)), timeout * 1000); - // Chrome 144+ auto-discovery via DevToolsActivePort file. - // Falls back to --extension mode if no running Chrome is detected. - const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT ?? discoverChromeEndpoint(); const mcpArgs: string[] = [mcpPath]; if (cdpEndpoint) { mcpArgs.push('--cdp-endpoint', cdpEndpoint); } else { mcpArgs.push('--extension'); } - if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) { - mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH); - } + if (process.env.OPENCLI_VERBOSE) { + console.error(`[opencli] CDP mode: ${cdpEndpoint ? `auto-discovered ${cdpEndpoint}` : 'fallback to --extension'}`); + } + if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) { + mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH); + } - this._proc = spawn('node', mcpArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env }, - }); + this._proc = spawn('node', mcpArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); // Increase max listeners to avoid warnings this._proc.setMaxListeners(20); diff --git a/src/clis/boss/search.ts b/src/clis/boss/search.ts index 0942743..92abbf6 100644 --- a/src/clis/boss/search.ts +++ b/src/clis/boss/search.ts @@ -67,6 +67,7 @@ cli({ description: 'BOSS直聘搜索职位', domain: 'www.zhipin.com', strategy: Strategy.COOKIE, + forceExtension: true, // BOSS Zhipin detects CDP mode — must use extension bridge args: [ { name: 'query', required: true, help: 'Search keyword (e.g. AI agent, 前端)' }, { name: 'city', default: '北京', help: 'City name or code (e.g. 杭州, 上海, 101010100)' }, diff --git a/src/main.ts b/src/main.ts index a98c313..b10937b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -99,9 +99,10 @@ for (const [, cmd] of registry) { else if (arg.default != null) kwargs[arg.name] = arg.default; } try { + if (actionOpts.verbose) process.env.OPENCLI_VERBOSE = '1'; let result: any; if (cmd.browser) { - result = await browserSession(PlaywrightMCP, async (page) => runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) })); + result = await browserSession(PlaywrightMCP, async (page) => runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) }), { forceExtension: cmd.forceExtension }); } else { result = await executeCommand(cmd, null, kwargs, actionOpts.verbose); } if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) { console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`)); diff --git a/src/registry.ts b/src/registry.ts index c40d887..d77bb46 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -37,6 +37,8 @@ export interface CliCommand { /** Internal: lazy-loaded TS module support */ _lazy?: boolean; _modulePath?: string; + /** Force extension bridge mode (bypass CDP), for anti-bot sites */ + forceExtension?: boolean; } export interface CliOptions { @@ -51,6 +53,8 @@ export interface CliOptions { func?: (page: IPage | null, kwargs: Record, debug?: boolean) => Promise; pipeline?: any[]; timeoutSeconds?: number; + /** Force extension bridge mode (bypass CDP), for anti-bot sites */ + forceExtension?: boolean; } const _registry = new Map(); @@ -68,6 +72,7 @@ export function cli(opts: CliOptions): CliCommand { func: opts.func, pipeline: opts.pipeline, timeoutSeconds: opts.timeoutSeconds, + forceExtension: opts.forceExtension, }; const key = fullName(cmd); diff --git a/src/runtime.ts b/src/runtime.ts index ff6626f..03408e5 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -27,10 +27,11 @@ export async function runWithTimeout( export async function browserSession( BrowserFactory: new () => any, fn: (page: IPage) => Promise, + opts?: { forceExtension?: boolean }, ): Promise { const mcp = new BrowserFactory(); try { - const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT }); + const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, forceExtension: opts?.forceExtension }); return await fn(page); } finally { await mcp.close().catch(() => {});