diff --git a/SKILL.md b/SKILL.md index bcbd21b..13384ca 100644 --- a/SKILL.md +++ b/SKILL.md @@ -7,7 +7,7 @@ description: 通过 Gemini 官网(gemini.google.com)执行问答与生图操 ## 核心规则 -1. 使用 OpenClaw 内置浏览器,`profile="openclaw"`。 +1. 使用 Browser Daemon 托管的浏览器(Daemon 未运行时会自动后台拉起,无需手动启动)。 2. 涉及生图关键词(如:生图、绘图、画一张、nano banana)时,优先用无头浏览器流程执行。 3. 文本问答任务(如"问问Gemini xxx")走 Gemini 文本提问链路。 4. 默认模型:可用列表中最强模型,优先 `Gemini 3.1 Pro`。 @@ -76,7 +76,7 @@ Gemini 页面的操作按钮(`.send-button-container` 内)通过 `aria-label - `unknown` → 页面可能异常,做一次 snapshot 兜底排查。 4. 累计耗时超过上限(文本 60s / 生图 120s)→ 走超时回退逻辑。 -**为什么这样做**:OpenClaw 通过 CDP(Chrome DevTools Protocol)WebSocket 控制浏览器。若长时间(>30s)无消息往来,网关/代理可能判定连接空闲并断开。分段短轮询保证 CDP 通道始终有心跳流量。 +**为什么这样做**:Skill 通过 CDP(Chrome DevTools Protocol)WebSocket 控制 Daemon 托管的浏览器。若长时间(>30s)无消息往来,网关/代理可能判定连接空闲并断开。分段短轮询保证 CDP 通道始终有心跳流量。 ## 失败回退 diff --git a/references/gemini-flow.md b/references/gemini-flow.md index e2435cd..98037b4 100644 --- a/references/gemini-flow.md +++ b/references/gemini-flow.md @@ -6,7 +6,7 @@ - 页面存在可输入提问的输入框 - 右上角有用户头像或账户入口 -若未登录:提示用户先在 openclaw profile 浏览器中登录。 +若未登录:提示用户先在 Daemon 托管的浏览器中手动登录 Google 账号(Daemon 未运行时会自动后台拉起)。 ## 2) 模型策略 diff --git a/src/browser.js b/src/browser.js index 1a98664..1930d9a 100644 --- a/src/browser.js +++ b/src/browser.js @@ -1,367 +1,109 @@ /** - * browser.js — 浏览器生命周期管理(内部模块,不对外暴露) + * browser.js — 浏览器客户端连接器(面向 Skill) * - * 设计思路: - * Skill 内部自己管理浏览器进程,对外只暴露 ensureBrowser()。 - * 调用方不需要关心 launch/connect/端口/CDP 等细节。 - * 支持 Chrome / Edge / Chromium 等所有基于 Chromium 的浏览器。 + * 职责: + * 1. 向 Daemon 服务请求 wsEndpoint,并通过 puppeteer.connect() 直连浏览器。 + * 2. 如果 Daemon 未启动,自动以后台进程拉起 server.js,等待就绪后再连接。 * - * 流程: - * 1. 先检查指定端口是否已有浏览器在跑 → 有就 connect - * 2. 没有 → 自动检测或使用配置的浏览器路径启动 - * 3. 找到 / 新开 Gemini 标签页 - * 4. 返回 { browser, page } + * 与 Daemon 的关系: + * browser.js (Skill 侧) Daemon (独立进程) + * ───────────────────── ────────────────── + * isDaemonAlive() ──▶ GET /health + * spawnDaemon() ──▶ node src/daemon/server.js (detached) + * fetch /browser/acquire ──▶ engine.js: launch/connect + * puppeteer.connect(ws) ──▶ Chrome CDP wsEndpoint + * disconnect() ──▶ 浏览器继续由 Daemon 守护 */ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; import puppeteerCore from 'puppeteer-core'; import { addExtra } from 'puppeteer-extra'; import StealthPlugin from 'puppeteer-extra-plugin-stealth'; -import { createConnection } from 'node:net'; -import { existsSync, mkdirSync, cpSync } from 'node:fs'; -import { platform, homedir } from 'node:os'; -import { join, basename } from 'node:path'; import config from './config.js'; -// ── 用 puppeteer-extra 包装 puppeteer-core,注入 stealth 插件 ── +// connect 也套上 Stealth,双保险 const puppeteer = addExtra(puppeteerCore); puppeteer.use(StealthPlugin()); -// ── 模块级单例:跨调用复用同一个浏览器 ── +// ── 路径常量 ── +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const DAEMON_SCRIPT = join(__dirname, 'daemon', 'server.js'); + +// ── 模块级单例 ── let _browser = null; -// ── 各平台浏览器候选路径(Chrome、Edge、Chromium)── -const BROWSER_CANDIDATES = { - win32: [ - // Chrome - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', - // Edge - 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', - 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', - // Chromium - 'C:\\Program Files\\Chromium\\Application\\chrome.exe', - ], - darwin: [ - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - ], - linux: [ - '/usr/bin/google-chrome', - '/usr/bin/google-chrome-stable', - '/usr/bin/microsoft-edge', - '/usr/bin/microsoft-edge-stable', - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/snap/bin/chromium', - ], -}; +const DAEMON_URL = `http://127.0.0.1:${config.daemonPort}`; + +// ── Daemon 自启动配置 ── +/** 拉起 Daemon 后,等待就绪的最长时间(ms) */ +const DAEMON_READY_TIMEOUT = 15_000; +/** 轮询间隔(ms) */ +const DAEMON_POLL_INTERVAL = 500; /** - * 自动检测系统上可用的 Chromium 系浏览器 - * @returns {string | undefined} 找到的浏览器可执行文件路径 - */ -function detectBrowser() { - // 还可以检查用户通过环境变量传入的常用别名 - const envPaths = [ - process.env.PROGRAMFILES, - process.env['PROGRAMFILES(X86)'], - process.env.LOCALAPPDATA, - ]; - - const os = platform(); - const candidates = BROWSER_CANDIDATES[os] || []; - - // Windows 额外:从环境变量目录组合路径 - if (os === 'win32') { - for (const base of envPaths) { - if (!base) continue; - candidates.push( - `${base}\\Google\\Chrome\\Application\\chrome.exe`, - `${base}\\Microsoft\\Edge\\Application\\msedge.exe`, - ); - } - } - - for (const p of candidates) { - if (existsSync(p)) { - console.log('[browser] auto-detected:', p); - return p; - } - } - - return undefined; -} - -// ── userDataDir:WJZ_P 全局浏览器数据目录 ── -// 所有伟大的 WJZ_P 项目共享同一个浏览器数据目录,保证 cookie / 登录态跨项目统一。 -// 不使用浏览器默认数据目录的原因: -// - macOS 下 Chrome 不能用默认路径开启 debug 模式(数据目录被锁) -// - 独立目录保证与日常浏览器完全隔离,反爬更安全 -const GLOBAL_WJZ_DATA_DIR = join(homedir(), '.wjz_browser_data'); - -/** - * 获取浏览器默认 userDataDir 路径(作为克隆源) - * - * 按优先级尝试 Chrome > Edge > Chromium,返回第一个存在的路径。 - * - * @returns {string | undefined} - */ -function getDefaultBrowserDataDir() { - const os = platform(); - const home = homedir(); - - const candidates = []; - - if (os === 'win32') { - const localAppData = process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'); - candidates.push( - join(localAppData, 'Google', 'Chrome', 'User Data'), - join(localAppData, 'Microsoft', 'Edge', 'User Data'), - join(localAppData, 'Chromium', 'User Data'), - ); - } else if (os === 'darwin') { - const lib = join(home, 'Library', 'Application Support'); - candidates.push( - join(lib, 'Google', 'Chrome'), - join(lib, 'Microsoft Edge'), - join(lib, 'Chromium'), - ); - } else { - // Linux - candidates.push( - join(home, '.config', 'google-chrome'), - join(home, '.config', 'microsoft-edge'), - join(home, '.config', 'chromium'), - ); - } - - for (const dir of candidates) { - if (existsSync(dir)) { - console.log('[browser] found default browser data dir:', dir); - return dir; - } - } - - return undefined; -} - -/** - * 从浏览器默认数据目录克隆关键资产到 WJZ 数据目录 - * - * 只拷贝 cookie、登录态、偏好设置等"资产",跳过锁文件和缓存, - * 确保克隆后的目录能正常启动且不与原浏览器实例冲突。 - * - * 跳过的文件 / 目录(basename 匹配): - * - SingletonLock / SingletonSocket / SingletonCookie — 进程锁,拷贝会导致无法启动 - * - lockfile — 锁文件 - * - Cache / Code Cache / GPUCache / DawnCache / GrShaderCache — 缓存目录,体积大且不必要 - * - CrashpadMetrics-active.pma — 崩溃指标活跃文件 - * - BrowserMetrics / BrowserMetrics-spare.pma — 浏览器指标文件 - * - * @param {string} sourceDir - 浏览器默认数据目录 - * @param {string} targetDir - WJZ 数据目录 - */ -function cloneProfileFromDefault(sourceDir, targetDir) { - console.log(`[browser] 首次运行,正在从浏览器默认数据克隆资产...`); - console.log(`[browser] 源:${sourceDir}`); - console.log(`[browser] 目标:${targetDir}`); - - /** 需要跳过的文件 / 目录名(全部小写比较) */ - const SKIP_NAMES = new Set([ - // 进程锁 - 'singletonlock', - 'singletonsocket', - 'singletoncookie', - 'lockfile', - // 缓存(体积大,浏览器会自动重建) - 'cache', - 'code cache', - 'gpucache', - 'dawncache', - 'grshadercache', - // 崩溃 / 指标 - 'crashpadmetrics-active.pma', - 'browsermetrics', - 'browsermetrics-spare.pma', - ]); - - /** - * cpSync 的 filter 回调:返回 true 表示拷贝,false 表示跳过 - * @param {string} src - * @param {string} _dest - * @returns {boolean} - */ - const filterFunc = (src, _dest) => { - const name = basename(src).toLowerCase(); - if (SKIP_NAMES.has(name)) { - return false; - } - return true; - }; - - try { - cpSync(sourceDir, targetDir, { recursive: true, filter: filterFunc }); - console.log(`[browser] 克隆完成`); - } catch (err) { - // 克隆失败不致命:目录已创建,浏览器会以全新状态启动(需手动登录) - console.warn(`[browser] ⚠ 克隆过程中出现错误(浏览器仍可启动,但需要重新登录):`, err.message); - } -} - -/** - * 解析 userDataDir - * - * 优先级: - * 1. 环境变量 BROWSER_USER_DATA_DIR(config 已处理) - * 2. WJZ_P 全局目录 ~/.wjz_browser_data - * - 目录已存在 → 直接使用 - * - 目录不存在(首次运行)→ 创建并从浏览器默认数据目录克隆关键资产 - * - * @returns {string} - */ -function resolveUserDataDir() { - // 1. 环境变量(已由 config 读取) - if (config.browserUserDataDir) { - return config.browserUserDataDir; - } - - // 2. WJZ_P 全局目录 - if (existsSync(GLOBAL_WJZ_DATA_DIR)) { - console.log(`[browser] using WJZ data dir: ${GLOBAL_WJZ_DATA_DIR}`); - return GLOBAL_WJZ_DATA_DIR; - } - - // 首次运行:创建目录并尝试从浏览器默认数据克隆 - console.log(`[browser] WJZ data dir not found, initializing: ${GLOBAL_WJZ_DATA_DIR}`); - mkdirSync(GLOBAL_WJZ_DATA_DIR, { recursive: true }); - - const defaultDir = getDefaultBrowserDataDir(); - if (defaultDir) { - cloneProfileFromDefault(defaultDir, GLOBAL_WJZ_DATA_DIR); - } else { - console.log('[browser] 未找到浏览器默认数据目录,将使用空白配置(首次启动需手动登录)'); - } - - return GLOBAL_WJZ_DATA_DIR; -} - -/** - * 探测指定端口是否有浏览器在监听 - * @param {number} port - * @param {string} [host='127.0.0.1'] - * @param {number} [timeout=1500] + * 检查 Daemon 是否存活 * @returns {Promise} */ -function isPortAlive(port, host = '127.0.0.1', timeout = 1500) { - return new Promise((resolve) => { - const socket = createConnection({ host, port }); - const timer = setTimeout(() => { - socket.destroy(); - resolve(false); - }, timeout); - socket.on('connect', () => { - clearTimeout(timer); - socket.destroy(); - resolve(true); - }); - socket.on('error', () => { - clearTimeout(timer); - resolve(false); - }); - }); -} - -/** 浏览器启动参数(适用于所有 Chromium 系浏览器) */ -const BROWSER_ARGS = [ - // ── 基础 ── - '--no-first-run', // 跳过首次运行的欢迎页 / 引导流程 - '--disable-default-apps', // 不安装 Chrome 默认应用(Gmail、Drive 等) - '--disable-popup-blocking', // 允许弹窗,避免 Gemini 功能被拦截 - - // ── 渲染稳定性 ── - '--disable-gpu', // 禁用 GPU 硬件加速,防止无显卡环境崩溃 - '--disable-software-rasterizer', // 禁用软件光栅化后备,减少 CPU 开销 - '--disable-dev-shm-usage', // 不使用 /dev/shm(Docker 中该分区常太小导致崩溃) - - // ── sandbox:仅 Linux 无图形环境需要,Windows/macOS 桌面不加 ── - // --no-sandbox / --disable-setuid-sandbox 在 Windows Edge 上会触发安全警告横幅 - ...(platform() === 'linux' - ? [ - '--no-sandbox', // 关闭 Chromium 沙箱(Linux root 用户必须) - '--disable-setuid-sandbox', // 关闭 setuid 沙箱(配合 --no-sandbox) - ] - : []), - - // ── 反检测(配合 stealth 插件 + ignoreDefaultArgs) ── - //'--disable-blink-features=AutomationControlled', // 移除 navigator.webdriver 标记,降低被检测为自动化的风险。stealth已经带上了,这里额外写会造成参数错误。 - - // ── 网络 / 性能 ── - '--disable-background-networking', // 禁止后台网络请求(更新检查、遥测等) - '--disable-background-timer-throttling', // 后台标签页定时器不降频,保证轮询精度 - '--disable-backgrounding-occluded-windows',// 被遮挡的窗口不降级渲染 - '--disable-renderer-backgrounding', // 渲染进程进入后台时不降优先级 - - // ── UI 纯净度 ── - '--disable-features=Translate', // 禁用自动翻译弹窗 - '--no-default-browser-check', // 不弹"设为默认浏览器"提示 - '--disable-crash-reporter', // 禁用崩溃上报,减少后台进程 - '--hide-crash-restore-bubble', // 隐藏"恢复上次会话"气泡 - '--test-type', // 专门用来屏蔽“不受支持的命令行标记”的黄条警告 -]; - -/** - * 连接到已运行的浏览器 - * @param {number} port - * @returns {Promise} - */ -async function connectBrowser(port) { - const browserURL = `http://127.0.0.1:${port}`; - const browser = await puppeteer.connect({ - browserURL, - defaultViewport: null, - protocolTimeout: config.browserProtocolTimeout, - }); - console.log('[browser] connected to existing browser on port', port); - return browser; +async function isDaemonAlive() { + try { + const res = await fetch(`${DAEMON_URL}/health`, { signal: AbortSignal.timeout(2000) }); + const data = await res.json(); + return data.ok === true; + } catch { + return false; + } } /** - * 启动新的浏览器实例 - * @param {object} opts - * @param {string} opts.executablePath - * @param {number} opts.port - * @param {string} opts.userDataDir - * @param {boolean} opts.headless - * @param {object} [opts.debugOpts] - 调试/信号控制选项 - * @param {boolean} [opts.debugOpts.handleSIGINT=true] - Puppeteer 是否在 SIGINT 时自动关闭浏览器 - * @param {boolean} [opts.debugOpts.handleSIGTERM=true] - Puppeteer 是否在 SIGTERM 时自动关闭浏览器 - * @param {boolean} [opts.debugOpts.handleSIGHUP=true] - Puppeteer 是否在 SIGHUP 时自动关闭浏览器 - * @returns {Promise} + * 以后台 detached 进程方式启动 Daemon + * + * 关键: + * - detached: true → Daemon 独立于当前进程组,Skill 退出不影响它 + * - stdio: 'ignore' → 不绑定当前终端的 stdin/stdout/stderr + * - unref() → 当前进程不再等待 Daemon 子进程退出 */ -async function launchBrowser({ executablePath, port, userDataDir, headless, debugOpts = {} }) { - const { - handleSIGINT = true, - handleSIGTERM = true, - handleSIGHUP = true, - } = debugOpts; +function spawnDaemon() { + console.log(`[browser] 🚀 Daemon 未运行,正在自动启动: node ${DAEMON_SCRIPT}`); - const browser = await puppeteer.launch({ - executablePath, - headless, - userDataDir, - defaultViewport: null, - args: [ - ...BROWSER_ARGS, - `--remote-debugging-port=${port}`, - ], - ignoreDefaultArgs: ['--enable-automation'], - protocolTimeout: config.browserProtocolTimeout, - handleSIGINT, - handleSIGTERM, - handleSIGHUP, + const child = spawn(process.execPath, [DAEMON_SCRIPT], { + detached: true, + stdio: 'ignore', + env: { ...process.env }, // 继承环境变量(含 DAEMON_PORT / BROWSER_HEADLESS 等配置) }); - console.log('[browser] launched, pid:', browser.process()?.pid, 'port:', port, 'path:', executablePath); - return browser; + + child.unref(); + console.log(`[browser] Daemon 进程已分离 (pid=${child.pid}),等待就绪...`); +} + +/** + * 确保 Daemon 可用 — 如果没启动则自动拉起并等待就绪 + * @returns {Promise} + */ +async function ensureDaemon() { + // 先探测一次 + if (await isDaemonAlive()) { + return; // 已经活着 + } + + // 拉起 Daemon + spawnDaemon(); + + // 轮询等待就绪 + const deadline = Date.now() + DAEMON_READY_TIMEOUT; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, DAEMON_POLL_INTERVAL)); + if (await isDaemonAlive()) { + console.log('[browser] ✅ Daemon 就绪'); + return; + } + } + + throw new Error( + `Daemon 自动启动超时(${DAEMON_READY_TIMEOUT / 1000}s 内未响应 /health)!\n` + + `请检查端口 ${config.daemonPort} 是否被占用,或手动运行: npm run daemon` + ); } /** @@ -376,7 +118,7 @@ async function findOrCreateGeminiPage(browser) { for (const page of pages) { const url = page.url(); if (url.includes('gemini.google.com')) { - console.log('[browser] reusing existing Gemini tab:', url); + console.log('[browser] 命中已有 Gemini 标签页'); await page.bringToFront(); return page; } @@ -388,126 +130,72 @@ async function findOrCreateGeminiPage(browser) { waitUntil: 'networkidle2', timeout: 30_000, }); - console.log('[browser] opened new Gemini tab'); + console.log('[browser] 已打开新的 Gemini 标签页'); return page; } /** - * 确保浏览器可用 — Skill 唯一的对外浏览器管理入口 + * 确保浏览器可用 — Skill 唯一的对外入口 * - * 逻辑: - * 1. 如果已有 _browser 且未断开 → 直接复用 - * 2. 检查端口是否有浏览器 → connect - * 3. 否则自动检测 / 使用配置的路径启动浏览器 + * 流程: + * 1. 当前进程已连着 → 直接复用 + * 2. 检查 Daemon 是否存活,未存活则自动拉起 + * 3. 向 Daemon 发 HTTP 请求索要 wsEndpoint + * 4. 通过 WebSocket 直连 Chrome CDP + * 5. 找到 / 新开 Gemini 标签页 * - * userDataDir 解析优先级: - * opts.userDataDir > env BROWSER_USER_DATA_DIR > ~/.wjz_browser_data(首次自动从浏览器默认数据克隆) - * - * @param {object} [opts] - * @param {string} [opts.executablePath] - 浏览器路径(不传则自动检测) - * @param {number} [opts.port] - 调试端口(env: BROWSER_DEBUG_PORT,默认 40821) - * @param {string} [opts.userDataDir] - 用户数据目录(env: BROWSER_USER_DATA_DIR,不传则多级兜底) - * @param {boolean} [opts.headless] - 无头模式(env: BROWSER_HEADLESS,默认 false) - * @param {object} [opts.debugOpts] - 调试/信号控制选项(透传给 Puppeteer launch) * @returns {Promise<{browser: import('puppeteer-core').Browser, page: import('puppeteer-core').Page}>} */ -export async function ensureBrowser(opts = {}) { - const { - executablePath = config.browserPath, - port = config.browserDebugPort, - userDataDir = resolveUserDataDir(), - headless = config.browserHeadless, - debugOpts, - } = opts; - +export async function ensureBrowser() { // 1. 复用已有连接 if (_browser && _browser.isConnected()) { - console.log('[browser] reusing existing connection'); const page = await findOrCreateGeminiPage(_browser); return { browser: _browser, page }; } - // 2. 尝试连接已在运行的浏览器 - const alive = await isPortAlive(port); - if (alive) { - try { - _browser = await connectBrowser(port); - const page = await findOrCreateGeminiPage(_browser); - return { browser: _browser, page }; - } catch (err) { - console.warn('[browser] connect failed, will try launch:', err.message); - } - } + // 2. 确保 Daemon 可用(未启动则自动拉起) + await ensureDaemon(); - // 3. 启动新浏览器:优先用配置路径,否则自动检测 - const resolvedPath = executablePath || detectBrowser(); - if (!resolvedPath) { + // 3. 向 Daemon 索要浏览器连接地址 + let acquireData; + try { + console.log(`[browser] 正在呼叫 Daemon: ${DAEMON_URL}/browser/acquire ...`); + const res = await fetch(`${DAEMON_URL}/browser/acquire`); + acquireData = await res.json(); + + if (!acquireData.ok) { + throw new Error(acquireData.error || 'Daemon 返回失败'); + } + } catch (err) { throw new Error( - `[browser] 端口 ${port} 无可用浏览器,且未找到可执行文件。\n` + - `请通过以下任一方式解决:\n` + - ` 1. 设置环境变量 BROWSER_PATH 指向 Chrome / Edge / Chromium 的可执行文件\n` + - ` 2. 手动启动浏览器并开启调试端口:\n` + - ` msedge --remote-debugging-port=${port}\n` + - ` chrome --remote-debugging-port=${port}\n` + - ` 3. 安装 Chrome 或 Edge 到默认位置` + `Daemon 已启动但获取浏览器失败!\n` + + `底层报错: ${err.message}` ); } - try { - _browser = await launchBrowser({ executablePath: resolvedPath, port, userDataDir, headless, debugOpts }); - } catch (err) { - // 大概率是用户数据目录被正在运行的浏览器锁住了 - if (err.message?.includes('EPERM') || err.message?.includes('lock') || err.message?.includes('already')) { - throw new Error( - `报错信息:${err.message}\n`+ - `[browser] 无法启动浏览器,用户数据目录可能被占用:${userDataDir}\n` + - `这通常是因为该浏览器正在运行且锁定了数据目录。\n\n` + - `请选择以下任一方式解决:\n` + - ` 方式 1(推荐):关闭正在运行的浏览器,让 skill 自动启动带调试端口的实例\n` + - ` 方式 2:保持浏览器运行,手动启用调试端口后重启浏览器:\n` + - ` ${resolvedPath} --remote-debugging-port=${port}\n` + - ` 方式 3:设置 BROWSER_USER_DATA_DIR 为独立目录(将无法复用登录态)` - ); - } - throw err; - } + // 4. 拿到 wsEndpoint,通过 WebSocket 直连浏览器 + console.log(`[browser] 从 Daemon 获取到 wsEndpoint,正在建立 CDP 直连...`); + _browser = await puppeteer.connect({ + browserWSEndpoint: acquireData.wsEndpoint, + defaultViewport: null, + protocolTimeout: config.browserProtocolTimeout, + }); const page = await findOrCreateGeminiPage(_browser); + console.log(`[browser] CDP 直连成功,pid=${acquireData.pid}`); return { browser: _browser, page }; } /** - * 断开浏览器连接(不杀进程,方便下次复用) + * 断开 WebSocket 连接(不关闭浏览器) * - * 在 Windows 上,Node 退出时默认会终止所有子进程。 - * 因此 disconnect 前先对浏览器子进程做 unref + stdio detach, - * 使浏览器进程脱离 Node 进程树,独立存活。 + * 注意:绝不能调用 browser.close()! + * 浏览器的生杀大权已经移交给 Daemon 的 TTL 倒计时了。 */ export function disconnect() { if (_browser) { - // 解除 Node 对浏览器子进程的引用,防止 Node 退出时杀掉它 - const proc = _browser.process(); - if (proc) { - proc.unref(); - // 同时 unref 所有 stdio 流,避免 Node 因为管道未关闭而挂住 - if (proc.stdin) proc.stdin.unref(); - if (proc.stdout) proc.stdout.unref(); - if (proc.stderr) proc.stderr.unref(); - } - _browser.disconnect(); _browser = null; - console.log('[browser] disconnected (browser process kept alive)'); - } -} - -/** - * 关闭浏览器(杀进程) - */ -export async function close() { - if (_browser) { - await _browser.close(); - _browser = null; - console.log('[browser] closed'); + console.log('[browser] 已断开 CDP 连接(浏览器仍由 Daemon 守护)'); } } diff --git a/src/config.js b/src/config.js index 9a46f22..89c5ee6 100644 --- a/src/config.js +++ b/src/config.js @@ -126,6 +126,14 @@ const config = { /** 截图 / 图片输出目录 */ outputDir: envStr('OUTPUT_DIR', resolve('output')), + + // ── Daemon 配置 ── + + /** Daemon HTTP 服务端口 */ + daemonPort: envInt('DAEMON_PORT', 40225), + + /** Daemon 闲置超时时间(ms),超时后自动终止浏览器释放资源 */ + daemonTTL: envInt('DAEMON_TTL_MS', 30 * 60 * 1000), }; export default config; diff --git a/src/daemon/engine.js b/src/daemon/engine.js index 86e9fa0..e475902 100644 --- a/src/daemon/engine.js +++ b/src/daemon/engine.js @@ -24,6 +24,38 @@ puppeteer.use(StealthPlugin()); // ── 单例 ── let _browser = null; +let _shuttingDown = false; // 防止 disconnected 回调与主动 terminate 重入 +let _shutdownCallback = null; // 由 server 注入的关闭回调 + +/** + * 注入 Daemon 关闭回调 + * + * 当浏览器意外断开(如用户手动关闭窗口)时调用此回调, + * 让 Daemon 进程也一并退出。 + * + * @param {() => void} cb + */ +export function onBrowserExit(cb) { + _shutdownCallback = cb; +} + +/** + * 为浏览器实例注册 disconnected 监听 + * 用户手动关闭浏览器窗口 → Puppeteer 触发 disconnected → Daemon 跟着退出 + */ +function registerDisconnectHandler(browser) { + browser.on('disconnected', () => { + // 如果是主动 terminateBrowser() 触发的断开,跳过 + if (_shuttingDown) return; + + console.log('[engine] 🔌 浏览器连接断开(用户关闭了浏览器窗口?)'); + _browser = null; + + if (_shutdownCallback) { + _shutdownCallback(); + } + }); +} // ── 浏览器候选路径 ── const BROWSER_CANDIDATES = { @@ -208,6 +240,7 @@ export async function ensureBrowserForDaemon() { defaultViewport: null, protocolTimeout: config.browserProtocolTimeout, }); + registerDisconnectHandler(_browser); console.log(`[engine] 已连接到端口 ${port} 的浏览器`); return _browser; } catch (err) { @@ -240,6 +273,7 @@ export async function ensureBrowserForDaemon() { }); const pid = _browser.process()?.pid; + registerDisconnectHandler(_browser); console.log(`[engine] 浏览器已启动 pid=${pid} port=${port} path=${executablePath}`); return _browser; @@ -251,6 +285,8 @@ export async function ensureBrowserForDaemon() { export async function terminateBrowser() { if (!_browser) return; + _shuttingDown = true; // 标记主动关闭,防止 disconnected 回调重入 + try { const pid = _browser.process()?.pid; await _browser.close(); @@ -263,5 +299,6 @@ export async function terminateBrowser() { } catch { /* ignore */ } } finally { _browser = null; + _shuttingDown = false; } } diff --git a/src/daemon/lifecycle.js b/src/daemon/lifecycle.js index e6d258e..d8fbc41 100644 --- a/src/daemon/lifecycle.js +++ b/src/daemon/lifecycle.js @@ -3,11 +3,11 @@ * * 职责: * 管理"惰性销毁"定时器。每次收到请求就 resetHeartbeat()(续命); - * 超时未活动则触发浏览器优雅关闭,释放系统资源。 + * 超时未活动则终止浏览器并退出 Daemon 进程,释放全部系统资源。 * - * 关键设计: - * - _idleTimer.unref():定时器不阻止 Node 进程退出, - * 否则 SIGINT 时进程会因为未执行完的定时器而挂住。 + * 为什么超时后连 Daemon 一起退出: + * Daemon 由 browser.js 的 ensureBrowser() 按需 spawn(detached + unref), + * 下次 Skill 调用时会自动重新拉起。闲置时留一个空壳进程占端口没有意义。 */ import { terminateBrowser } from './engine.js'; @@ -16,6 +16,7 @@ const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 分钟 let _idleTimer = null; let _ttlMs = DEFAULT_TTL_MS; let _lastHeartbeat = 0; +let _httpServer = null; /** * 设置 TTL(可通过环境变量覆盖) @@ -25,6 +26,14 @@ export function setTTL(ms) { _ttlMs = ms > 0 ? ms : DEFAULT_TTL_MS; } +/** + * 注入 HTTP server 引用,供超时退出时关闭 + * @param {import('node:http').Server} server + */ +export function setServer(server) { + _httpServer = server; +} + /** * 重置心跳定时器 — 每次 API 调用时执行 */ @@ -33,13 +42,22 @@ export function resetHeartbeat() { _lastHeartbeat = Date.now(); _idleTimer = setTimeout(async () => { - console.log(`[lifecycle] 💤 ${(_ttlMs / 60000).toFixed(0)} 分钟未活动,终止浏览器进程`); + console.log(`[lifecycle] 💤 ${(_ttlMs / 60000).toFixed(0)} 分钟未活动,终止浏览器并退出 Daemon`); await terminateBrowser(); + + // 关闭 HTTP 服务器,停止接受新连接 + if (_httpServer) { + _httpServer.close(); + _httpServer = null; + } + _idleTimer = null; + console.log('[lifecycle] ✅ Daemon 进程退出(下次 Skill 调用时会自动重新拉起)'); + process.exit(0); }, _ttlMs); - // 极度关键:unref 后定时器不会阻止进程退出 - _idleTimer.unref(); + // 不用 unref — 定时器需要保持 Daemon 进程存活,直到超时或被续命 + // (Daemon 是后台常驻进程,不像 Skill 脚本需要及时退出) } /** diff --git a/src/daemon/server.js b/src/daemon/server.js index 09742ce..eeea6d6 100644 --- a/src/daemon/server.js +++ b/src/daemon/server.js @@ -15,12 +15,13 @@ */ import { createServer } from 'node:http'; import { handleAcquire, handleStatus, handleRelease, handleHealth } from './handlers.js'; -import { setTTL, cancelHeartbeat } from './lifecycle.js'; -import { terminateBrowser } from './engine.js'; +import { setTTL, cancelHeartbeat, setServer } from './lifecycle.js'; +import { terminateBrowser, onBrowserExit } from './engine.js'; +import config from '../config.js'; -// ── 配置 ── -const PORT = parseInt(process.env.DAEMON_PORT || '40225', 10); -const TTL_MS = parseInt(process.env.DAEMON_TTL_MS || String(30 * 60 * 1000), 10); +// ── 配置(统一从 config.js 读取) ── +const PORT = config.daemonPort; +const TTL_MS = config.daemonTTL; setTTL(TTL_MS); @@ -49,8 +50,21 @@ const server = createServer((req, res) => { }); server.listen(PORT, () => { + // 注入 server 引用给 lifecycle,超时退出时优雅关闭 HTTP 服务 + setServer(server); + + // 浏览器被用户手动关闭时,Daemon 也跟着退出 + onBrowserExit(() => { + console.log('[daemon] 🛑 浏览器已关闭,Daemon 跟随退出(下次 Skill 调用时会自动重新拉起)'); + cancelHeartbeat(); + server.close(); + process.exit(0); + }); + console.log(`[daemon] 🚀 Browser Daemon 已启动 — http://127.0.0.1:${PORT}`); console.log(`[daemon] ⏱ 闲置 TTL: ${(TTL_MS / 60000).toFixed(0)} 分钟`); + console.log(`[daemon] 🖥 无头模式: ${config.browserHeadless ? '是' : '否'}`); + console.log(`[daemon] 🔌 CDP 端口: ${config.browserDebugPort}`); console.log(`[daemon] GET /browser/acquire — 获取/启动浏览器`); console.log(`[daemon] GET /browser/status — 查询浏览器状态`); console.log(`[daemon] POST /browser/release — 销毁浏览器`); diff --git a/src/demo.js b/src/demo.js index fb1b157..fbc608b 100644 --- a/src/demo.js +++ b/src/demo.js @@ -1,99 +1,33 @@ /** * demo.js — 使用示例 * - * 两种启动方式: - * - * 方式 1(推荐):先手动启动浏览器,再运行 demo - * chrome --remote-debugging-port=40821 --user-data-dir="~/.gemini-skill/browser-data" - * (也可以用 Edge:msedge --remote-debugging-port=40821 --user-data-dir=...) + * 运行: * node src/demo.js * - * 方式 2:让 skill 自动检测并启动浏览器 - * node src/demo.js - * (或指定路径:BROWSER_PATH="C:/..." node src/demo.js) + * Daemon 未运行时会自动后台拉起,无需手动启动。 + * demo 只需通过 createGeminiSession() 获取会话即可。 * - * 所有配置项见 .env,可直接编辑或通过命令行设环境变量。 + * 所有配置项见 config.js / .env,也可通过命令行设环境变量: + * DAEMON_PORT=40225 DAEMON_TTL_MS=600000 node src/demo.js */ -import { execSync } from 'node:child_process'; -import { platform, homedir } from 'node:os'; import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { createGeminiSession, disconnect } from './index.js'; const prompt = 'Gemini你好!请你仿造这个风格,给我生成更多表情包吧!来一张玩手机中。。。'; -// ── Demo 专用:杀掉所有 Chromium 系浏览器进程 ── -function killAllBrowserProcesses() { - const os = platform(); - const commands = os === 'win32' - ? [ - 'taskkill /F /IM msedge.exe /T', - 'taskkill /F /IM chrome.exe /T', - 'taskkill /F /IM chromium.exe /T', - ] - : [ - 'pkill -f msedge || true', - 'pkill -f chrome || true', - 'pkill -f chromium || true', - ]; - - for (const cmd of commands) { - try { - execSync(cmd, { stdio: 'ignore', timeout: 5000 }); - } catch { - // 进程不存在时会报错,忽略 - } - } - console.log('[demo] 已清理所有浏览器进程'); -} - /** 异步等待 */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -/** - * 创建会话,如果因浏览器目录被锁而失败,自动杀掉全部浏览器进程后重试一次 - */ -async function createSessionWithRetry() { - // 禁止 Puppeteer 在 Ctrl+C 等信号时自动杀浏览器进程, - // 由 demo 自己处理 SIGINT → disconnect,浏览器保持运行可复用。 - const opts = { - debugOpts: { - handleSIGINT: false, - handleSIGTERM: false, - handleSIGHUP: false, - }, - }; - - try { - return await createGeminiSession(opts); - } catch (err) { - const msg = err.message || ''; - const isLocked = msg.includes('EPERM') || msg.includes('lock') || msg.includes('already'); - - if (!isLocked) throw err; - - console.warn( - `[demo] 浏览器数据目录被占用,正在清理所有浏览器进程后重试...\n` + - ` 原始错误:${msg}` - ); - - killAllBrowserProcesses(); - await sleep(2000); - - // 重试一次,还失败就直接抛出 - return await createGeminiSession(opts); - } -} - async function main() { console.log('=== Gemini Skill Demo ===\n'); - // 创建会话:自带杀进程重试逻辑 - const { ops } = await createSessionWithRetry(); + // 创建会话(自动连接 Daemon 托管的浏览器) + const { ops } = await createGeminiSession(); - // ── Ctrl+C 时只断开连接,不杀浏览器进程(下次可复用) ── + // ── Ctrl+C 时只断开连接,不杀浏览器进程(浏览器由 Daemon 守护) ── process.on('SIGINT', () => { - console.log('\n[demo] Ctrl+C 收到,断开浏览器连接(浏览器保持运行)...'); + console.log('\n[demo] Ctrl+C 收到,断开浏览器连接(浏览器仍由 Daemon 守护)...'); disconnect(); process.exit(0); }); diff --git a/src/index.js b/src/index.js index af99189..4ee59d8 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,8 @@ /** * gemini-skill — 统一入口 * - * 对外只暴露高层 API,浏览器管理在内部自动完成。 + * 对外只暴露高层 API,浏览器连接由 Daemon 托管。 + * Daemon 未运行时会自动后台拉起,无需手动启动。 * * 用法: * import { createGeminiSession, disconnect } from './index.js'; @@ -10,34 +11,26 @@ * await ops.generateImage('画一只猫'); * disconnect(); */ -import { ensureBrowser, disconnect, close } from './browser.js'; +import { ensureBrowser, disconnect } from './browser.js'; import { createOps } from './gemini-ops.js'; -export { disconnect, close }; +export { disconnect }; /** * 创建 Gemini 操控会话 * - * 内部自动管理浏览器连接: - * 1. 端口有 Chrome → 直接 connect - * 2. 无 Chrome + 提供了 executablePath → 自动 launch - * 3. 无 Chrome + 无 executablePath → 报错并提示手动启动 + * 内部通过 Browser Daemon 管理浏览器: + * 1. 向 Daemon 发送 HTTP 请求获取 wsEndpoint + * 2. 通过 WebSocket 直连 Chrome CDP + * 3. 找到 / 新开 Gemini 标签页 * - * 所有参数均可通过环境变量配置(见 .env),opts 传参优先级更高。 + * 浏览器的启动、反爬、生命周期全部由 Daemon 负责, + * 这里只是一个轻量的 CDP 客户端连接器。 * - * @param {object} [opts] - * @param {string} [opts.executablePath] - 浏览器路径(env: BROWSER_PATH,不设则自动检测) - * @param {number} [opts.port] - 调试端口(env: BROWSER_DEBUG_PORT,默认 40821) - * @param {string} [opts.userDataDir] - 用户数据目录(env: BROWSER_USER_DATA_DIR) - * @param {boolean} [opts.headless] - 无头模式(env: BROWSER_HEADLESS,默认 false) - * @param {object} [opts.debugOpts] - 调试/信号控制选项(透传给 Puppeteer launch) - * @param {boolean} [opts.debugOpts.handleSIGINT=true] - Puppeteer 是否在 SIGINT 时自动关闭浏览器 - * @param {boolean} [opts.debugOpts.handleSIGTERM=true] - Puppeteer 是否在 SIGTERM 时自动关闭浏览器 - * @param {boolean} [opts.debugOpts.handleSIGHUP=true] - Puppeteer 是否在 SIGHUP 时自动关闭浏览器 * @returns {Promise<{ops: ReturnType, page: import('puppeteer-core').Page, browser: import('puppeteer-core').Browser}>} */ -export async function createGeminiSession(opts = {}) { - const { browser, page } = await ensureBrowser(opts); +export async function createGeminiSession() { + const { browser, page } = await ensureBrowser(); const ops = createOps(page); return { ops, page, browser }; }