refactor(browser): 重构浏览器管理为 Daemon 托管模式,支持自动拉起与生命周期管理
This commit is contained in:
4
SKILL.md
4
SKILL.md
@@ -7,7 +7,7 @@ description: 通过 Gemini 官网(gemini.google.com)执行问答与生图操
|
|||||||
|
|
||||||
## 核心规则
|
## 核心规则
|
||||||
|
|
||||||
1. 使用 OpenClaw 内置浏览器,`profile="openclaw"`。
|
1. 使用 Browser Daemon 托管的浏览器(Daemon 未运行时会自动后台拉起,无需手动启动)。
|
||||||
2. 涉及生图关键词(如:生图、绘图、画一张、nano banana)时,优先用无头浏览器流程执行。
|
2. 涉及生图关键词(如:生图、绘图、画一张、nano banana)时,优先用无头浏览器流程执行。
|
||||||
3. 文本问答任务(如"问问Gemini xxx")走 Gemini 文本提问链路。
|
3. 文本问答任务(如"问问Gemini xxx")走 Gemini 文本提问链路。
|
||||||
4. 默认模型:可用列表中最强模型,优先 `Gemini 3.1 Pro`。
|
4. 默认模型:可用列表中最强模型,优先 `Gemini 3.1 Pro`。
|
||||||
@@ -76,7 +76,7 @@ Gemini 页面的操作按钮(`.send-button-container` 内)通过 `aria-label
|
|||||||
- `unknown` → 页面可能异常,做一次 snapshot 兜底排查。
|
- `unknown` → 页面可能异常,做一次 snapshot 兜底排查。
|
||||||
4. 累计耗时超过上限(文本 60s / 生图 120s)→ 走超时回退逻辑。
|
4. 累计耗时超过上限(文本 60s / 生图 120s)→ 走超时回退逻辑。
|
||||||
|
|
||||||
**为什么这样做**:OpenClaw 通过 CDP(Chrome DevTools Protocol)WebSocket 控制浏览器。若长时间(>30s)无消息往来,网关/代理可能判定连接空闲并断开。分段短轮询保证 CDP 通道始终有心跳流量。
|
**为什么这样做**:Skill 通过 CDP(Chrome DevTools Protocol)WebSocket 控制 Daemon 托管的浏览器。若长时间(>30s)无消息往来,网关/代理可能判定连接空闲并断开。分段短轮询保证 CDP 通道始终有心跳流量。
|
||||||
|
|
||||||
## 失败回退
|
## 失败回退
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
- 页面存在可输入提问的输入框
|
- 页面存在可输入提问的输入框
|
||||||
- 右上角有用户头像或账户入口
|
- 右上角有用户头像或账户入口
|
||||||
|
|
||||||
若未登录:提示用户先在 openclaw profile 浏览器中登录。
|
若未登录:提示用户先在 Daemon 托管的浏览器中手动登录 Google 账号(Daemon 未运行时会自动后台拉起)。
|
||||||
|
|
||||||
## 2) 模型策略
|
## 2) 模型策略
|
||||||
|
|
||||||
|
|||||||
550
src/browser.js
550
src/browser.js
@@ -1,367 +1,109 @@
|
|||||||
/**
|
/**
|
||||||
* browser.js — 浏览器生命周期管理(内部模块,不对外暴露)
|
* browser.js — 浏览器客户端连接器(面向 Skill)
|
||||||
*
|
*
|
||||||
* 设计思路:
|
* 职责:
|
||||||
* Skill 内部自己管理浏览器进程,对外只暴露 ensureBrowser()。
|
* 1. 向 Daemon 服务请求 wsEndpoint,并通过 puppeteer.connect() 直连浏览器。
|
||||||
* 调用方不需要关心 launch/connect/端口/CDP 等细节。
|
* 2. 如果 Daemon 未启动,自动以后台进程拉起 server.js,等待就绪后再连接。
|
||||||
* 支持 Chrome / Edge / Chromium 等所有基于 Chromium 的浏览器。
|
|
||||||
*
|
*
|
||||||
* 流程:
|
* 与 Daemon 的关系:
|
||||||
* 1. 先检查指定端口是否已有浏览器在跑 → 有就 connect
|
* browser.js (Skill 侧) Daemon (独立进程)
|
||||||
* 2. 没有 → 自动检测或使用配置的浏览器路径启动
|
* ───────────────────── ──────────────────
|
||||||
* 3. 找到 / 新开 Gemini 标签页
|
* isDaemonAlive() ──▶ GET /health
|
||||||
* 4. 返回 { browser, page }
|
* 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 puppeteerCore from 'puppeteer-core';
|
||||||
import { addExtra } from 'puppeteer-extra';
|
import { addExtra } from 'puppeteer-extra';
|
||||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
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';
|
import config from './config.js';
|
||||||
|
|
||||||
// ── 用 puppeteer-extra 包装 puppeteer-core,注入 stealth 插件 ──
|
// connect 也套上 Stealth,双保险
|
||||||
const puppeteer = addExtra(puppeteerCore);
|
const puppeteer = addExtra(puppeteerCore);
|
||||||
puppeteer.use(StealthPlugin());
|
puppeteer.use(StealthPlugin());
|
||||||
|
|
||||||
// ── 模块级单例:跨调用复用同一个浏览器 ──
|
// ── 路径常量 ──
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const DAEMON_SCRIPT = join(__dirname, 'daemon', 'server.js');
|
||||||
|
|
||||||
|
// ── 模块级单例 ──
|
||||||
let _browser = null;
|
let _browser = null;
|
||||||
|
|
||||||
// ── 各平台浏览器候选路径(Chrome、Edge、Chromium)──
|
const DAEMON_URL = `http://127.0.0.1:${config.daemonPort}`;
|
||||||
const BROWSER_CANDIDATES = {
|
|
||||||
win32: [
|
// ── Daemon 自启动配置 ──
|
||||||
// Chrome
|
/** 拉起 Daemon 后,等待就绪的最长时间(ms) */
|
||||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
const DAEMON_READY_TIMEOUT = 15_000;
|
||||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
/** 轮询间隔(ms) */
|
||||||
// Edge
|
const DAEMON_POLL_INTERVAL = 500;
|
||||||
'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',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动检测系统上可用的 Chromium 系浏览器
|
* 检查 Daemon 是否存活
|
||||||
* @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]
|
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
function isPortAlive(port, host = '127.0.0.1', timeout = 1500) {
|
async function isDaemonAlive() {
|
||||||
return new Promise((resolve) => {
|
try {
|
||||||
const socket = createConnection({ host, port });
|
const res = await fetch(`${DAEMON_URL}/health`, { signal: AbortSignal.timeout(2000) });
|
||||||
const timer = setTimeout(() => {
|
const data = await res.json();
|
||||||
socket.destroy();
|
return data.ok === true;
|
||||||
resolve(false);
|
} catch {
|
||||||
}, timeout);
|
return false;
|
||||||
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<import('puppeteer-core').Browser>}
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动新的浏览器实例
|
* 以后台 detached 进程方式启动 Daemon
|
||||||
* @param {object} opts
|
*
|
||||||
* @param {string} opts.executablePath
|
* 关键:
|
||||||
* @param {number} opts.port
|
* - detached: true → Daemon 独立于当前进程组,Skill 退出不影响它
|
||||||
* @param {string} opts.userDataDir
|
* - stdio: 'ignore' → 不绑定当前终端的 stdin/stdout/stderr
|
||||||
* @param {boolean} opts.headless
|
* - unref() → 当前进程不再等待 Daemon 子进程退出
|
||||||
* @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<import('puppeteer-core').Browser>}
|
|
||||||
*/
|
*/
|
||||||
async function launchBrowser({ executablePath, port, userDataDir, headless, debugOpts = {} }) {
|
function spawnDaemon() {
|
||||||
const {
|
console.log(`[browser] 🚀 Daemon 未运行,正在自动启动: node ${DAEMON_SCRIPT}`);
|
||||||
handleSIGINT = true,
|
|
||||||
handleSIGTERM = true,
|
|
||||||
handleSIGHUP = true,
|
|
||||||
} = debugOpts;
|
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const child = spawn(process.execPath, [DAEMON_SCRIPT], {
|
||||||
executablePath,
|
detached: true,
|
||||||
headless,
|
stdio: 'ignore',
|
||||||
userDataDir,
|
env: { ...process.env }, // 继承环境变量(含 DAEMON_PORT / BROWSER_HEADLESS 等配置)
|
||||||
defaultViewport: null,
|
|
||||||
args: [
|
|
||||||
...BROWSER_ARGS,
|
|
||||||
`--remote-debugging-port=${port}`,
|
|
||||||
],
|
|
||||||
ignoreDefaultArgs: ['--enable-automation'],
|
|
||||||
protocolTimeout: config.browserProtocolTimeout,
|
|
||||||
handleSIGINT,
|
|
||||||
handleSIGTERM,
|
|
||||||
handleSIGHUP,
|
|
||||||
});
|
});
|
||||||
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<void>}
|
||||||
|
*/
|
||||||
|
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) {
|
for (const page of pages) {
|
||||||
const url = page.url();
|
const url = page.url();
|
||||||
if (url.includes('gemini.google.com')) {
|
if (url.includes('gemini.google.com')) {
|
||||||
console.log('[browser] reusing existing Gemini tab:', url);
|
console.log('[browser] 命中已有 Gemini 标签页');
|
||||||
await page.bringToFront();
|
await page.bringToFront();
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
@@ -388,126 +130,72 @@ async function findOrCreateGeminiPage(browser) {
|
|||||||
waitUntil: 'networkidle2',
|
waitUntil: 'networkidle2',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
console.log('[browser] opened new Gemini tab');
|
console.log('[browser] 已打开新的 Gemini 标签页');
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确保浏览器可用 — Skill 唯一的对外浏览器管理入口
|
* 确保浏览器可用 — Skill 唯一的对外入口
|
||||||
*
|
*
|
||||||
* 逻辑:
|
* 流程:
|
||||||
* 1. 如果已有 _browser 且未断开 → 直接复用
|
* 1. 当前进程已连着 → 直接复用
|
||||||
* 2. 检查端口是否有浏览器 → connect
|
* 2. 检查 Daemon 是否存活,未存活则自动拉起
|
||||||
* 3. 否则自动检测 / 使用配置的路径启动浏览器
|
* 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}>}
|
* @returns {Promise<{browser: import('puppeteer-core').Browser, page: import('puppeteer-core').Page}>}
|
||||||
*/
|
*/
|
||||||
export async function ensureBrowser(opts = {}) {
|
export async function ensureBrowser() {
|
||||||
const {
|
|
||||||
executablePath = config.browserPath,
|
|
||||||
port = config.browserDebugPort,
|
|
||||||
userDataDir = resolveUserDataDir(),
|
|
||||||
headless = config.browserHeadless,
|
|
||||||
debugOpts,
|
|
||||||
} = opts;
|
|
||||||
|
|
||||||
// 1. 复用已有连接
|
// 1. 复用已有连接
|
||||||
if (_browser && _browser.isConnected()) {
|
if (_browser && _browser.isConnected()) {
|
||||||
console.log('[browser] reusing existing connection');
|
|
||||||
const page = await findOrCreateGeminiPage(_browser);
|
const page = await findOrCreateGeminiPage(_browser);
|
||||||
return { browser: _browser, page };
|
return { browser: _browser, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 尝试连接已在运行的浏览器
|
// 2. 确保 Daemon 可用(未启动则自动拉起)
|
||||||
const alive = await isPortAlive(port);
|
await ensureDaemon();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 启动新浏览器:优先用配置路径,否则自动检测
|
// 3. 向 Daemon 索要浏览器连接地址
|
||||||
const resolvedPath = executablePath || detectBrowser();
|
let acquireData;
|
||||||
if (!resolvedPath) {
|
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(
|
throw new Error(
|
||||||
`[browser] 端口 ${port} 无可用浏览器,且未找到可执行文件。\n` +
|
`Daemon 已启动但获取浏览器失败!\n` +
|
||||||
`请通过以下任一方式解决:\n` +
|
`底层报错: ${err.message}`
|
||||||
` 1. 设置环境变量 BROWSER_PATH 指向 Chrome / Edge / Chromium 的可执行文件\n` +
|
|
||||||
` 2. 手动启动浏览器并开启调试端口:\n` +
|
|
||||||
` msedge --remote-debugging-port=${port}\n` +
|
|
||||||
` chrome --remote-debugging-port=${port}\n` +
|
|
||||||
` 3. 安装 Chrome 或 Edge 到默认位置`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 4. 拿到 wsEndpoint,通过 WebSocket 直连浏览器
|
||||||
_browser = await launchBrowser({ executablePath: resolvedPath, port, userDataDir, headless, debugOpts });
|
console.log(`[browser] 从 Daemon 获取到 wsEndpoint,正在建立 CDP 直连...`);
|
||||||
} catch (err) {
|
_browser = await puppeteer.connect({
|
||||||
// 大概率是用户数据目录被正在运行的浏览器锁住了
|
browserWSEndpoint: acquireData.wsEndpoint,
|
||||||
if (err.message?.includes('EPERM') || err.message?.includes('lock') || err.message?.includes('already')) {
|
defaultViewport: null,
|
||||||
throw new Error(
|
protocolTimeout: config.browserProtocolTimeout,
|
||||||
`报错信息:${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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await findOrCreateGeminiPage(_browser);
|
const page = await findOrCreateGeminiPage(_browser);
|
||||||
|
console.log(`[browser] CDP 直连成功,pid=${acquireData.pid}`);
|
||||||
return { browser: _browser, page };
|
return { browser: _browser, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断开浏览器连接(不杀进程,方便下次复用)
|
* 断开 WebSocket 连接(不关闭浏览器)
|
||||||
*
|
*
|
||||||
* 在 Windows 上,Node 退出时默认会终止所有子进程。
|
* 注意:绝不能调用 browser.close()!
|
||||||
* 因此 disconnect 前先对浏览器子进程做 unref + stdio detach,
|
* 浏览器的生杀大权已经移交给 Daemon 的 TTL 倒计时了。
|
||||||
* 使浏览器进程脱离 Node 进程树,独立存活。
|
|
||||||
*/
|
*/
|
||||||
export function disconnect() {
|
export function disconnect() {
|
||||||
if (_browser) {
|
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.disconnect();
|
||||||
_browser = null;
|
_browser = null;
|
||||||
console.log('[browser] disconnected (browser process kept alive)');
|
console.log('[browser] 已断开 CDP 连接(浏览器仍由 Daemon 守护)');
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭浏览器(杀进程)
|
|
||||||
*/
|
|
||||||
export async function close() {
|
|
||||||
if (_browser) {
|
|
||||||
await _browser.close();
|
|
||||||
_browser = null;
|
|
||||||
console.log('[browser] closed');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ const config = {
|
|||||||
|
|
||||||
/** 截图 / 图片输出目录 */
|
/** 截图 / 图片输出目录 */
|
||||||
outputDir: envStr('OUTPUT_DIR', resolve('output')),
|
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;
|
export default config;
|
||||||
|
|||||||
@@ -24,6 +24,38 @@ puppeteer.use(StealthPlugin());
|
|||||||
|
|
||||||
// ── 单例 ──
|
// ── 单例 ──
|
||||||
let _browser = null;
|
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 = {
|
const BROWSER_CANDIDATES = {
|
||||||
@@ -208,6 +240,7 @@ export async function ensureBrowserForDaemon() {
|
|||||||
defaultViewport: null,
|
defaultViewport: null,
|
||||||
protocolTimeout: config.browserProtocolTimeout,
|
protocolTimeout: config.browserProtocolTimeout,
|
||||||
});
|
});
|
||||||
|
registerDisconnectHandler(_browser);
|
||||||
console.log(`[engine] 已连接到端口 ${port} 的浏览器`);
|
console.log(`[engine] 已连接到端口 ${port} 的浏览器`);
|
||||||
return _browser;
|
return _browser;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -240,6 +273,7 @@ export async function ensureBrowserForDaemon() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pid = _browser.process()?.pid;
|
const pid = _browser.process()?.pid;
|
||||||
|
registerDisconnectHandler(_browser);
|
||||||
console.log(`[engine] 浏览器已启动 pid=${pid} port=${port} path=${executablePath}`);
|
console.log(`[engine] 浏览器已启动 pid=${pid} port=${port} path=${executablePath}`);
|
||||||
|
|
||||||
return _browser;
|
return _browser;
|
||||||
@@ -251,6 +285,8 @@ export async function ensureBrowserForDaemon() {
|
|||||||
export async function terminateBrowser() {
|
export async function terminateBrowser() {
|
||||||
if (!_browser) return;
|
if (!_browser) return;
|
||||||
|
|
||||||
|
_shuttingDown = true; // 标记主动关闭,防止 disconnected 回调重入
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pid = _browser.process()?.pid;
|
const pid = _browser.process()?.pid;
|
||||||
await _browser.close();
|
await _browser.close();
|
||||||
@@ -263,5 +299,6 @@ export async function terminateBrowser() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
} finally {
|
} finally {
|
||||||
_browser = null;
|
_browser = null;
|
||||||
|
_shuttingDown = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
*
|
*
|
||||||
* 职责:
|
* 职责:
|
||||||
* 管理"惰性销毁"定时器。每次收到请求就 resetHeartbeat()(续命);
|
* 管理"惰性销毁"定时器。每次收到请求就 resetHeartbeat()(续命);
|
||||||
* 超时未活动则触发浏览器优雅关闭,释放系统资源。
|
* 超时未活动则终止浏览器并退出 Daemon 进程,释放全部系统资源。
|
||||||
*
|
*
|
||||||
* 关键设计:
|
* 为什么超时后连 Daemon 一起退出:
|
||||||
* - _idleTimer.unref():定时器不阻止 Node 进程退出,
|
* Daemon 由 browser.js 的 ensureBrowser() 按需 spawn(detached + unref),
|
||||||
* 否则 SIGINT 时进程会因为未执行完的定时器而挂住。
|
* 下次 Skill 调用时会自动重新拉起。闲置时留一个空壳进程占端口没有意义。
|
||||||
*/
|
*/
|
||||||
import { terminateBrowser } from './engine.js';
|
import { terminateBrowser } from './engine.js';
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 分钟
|
|||||||
let _idleTimer = null;
|
let _idleTimer = null;
|
||||||
let _ttlMs = DEFAULT_TTL_MS;
|
let _ttlMs = DEFAULT_TTL_MS;
|
||||||
let _lastHeartbeat = 0;
|
let _lastHeartbeat = 0;
|
||||||
|
let _httpServer = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置 TTL(可通过环境变量覆盖)
|
* 设置 TTL(可通过环境变量覆盖)
|
||||||
@@ -25,6 +26,14 @@ export function setTTL(ms) {
|
|||||||
_ttlMs = ms > 0 ? ms : DEFAULT_TTL_MS;
|
_ttlMs = ms > 0 ? ms : DEFAULT_TTL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入 HTTP server 引用,供超时退出时关闭
|
||||||
|
* @param {import('node:http').Server} server
|
||||||
|
*/
|
||||||
|
export function setServer(server) {
|
||||||
|
_httpServer = server;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置心跳定时器 — 每次 API 调用时执行
|
* 重置心跳定时器 — 每次 API 调用时执行
|
||||||
*/
|
*/
|
||||||
@@ -33,13 +42,22 @@ export function resetHeartbeat() {
|
|||||||
_lastHeartbeat = Date.now();
|
_lastHeartbeat = Date.now();
|
||||||
|
|
||||||
_idleTimer = setTimeout(async () => {
|
_idleTimer = setTimeout(async () => {
|
||||||
console.log(`[lifecycle] 💤 ${(_ttlMs / 60000).toFixed(0)} 分钟未活动,终止浏览器进程`);
|
console.log(`[lifecycle] 💤 ${(_ttlMs / 60000).toFixed(0)} 分钟未活动,终止浏览器并退出 Daemon`);
|
||||||
await terminateBrowser();
|
await terminateBrowser();
|
||||||
|
|
||||||
|
// 关闭 HTTP 服务器,停止接受新连接
|
||||||
|
if (_httpServer) {
|
||||||
|
_httpServer.close();
|
||||||
|
_httpServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
_idleTimer = null;
|
_idleTimer = null;
|
||||||
|
console.log('[lifecycle] ✅ Daemon 进程退出(下次 Skill 调用时会自动重新拉起)');
|
||||||
|
process.exit(0);
|
||||||
}, _ttlMs);
|
}, _ttlMs);
|
||||||
|
|
||||||
// 极度关键:unref 后定时器不会阻止进程退出
|
// 不用 unref — 定时器需要保持 Daemon 进程存活,直到超时或被续命
|
||||||
_idleTimer.unref();
|
// (Daemon 是后台常驻进程,不像 Skill 脚本需要及时退出)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,12 +15,13 @@
|
|||||||
*/
|
*/
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { handleAcquire, handleStatus, handleRelease, handleHealth } from './handlers.js';
|
import { handleAcquire, handleStatus, handleRelease, handleHealth } from './handlers.js';
|
||||||
import { setTTL, cancelHeartbeat } from './lifecycle.js';
|
import { setTTL, cancelHeartbeat, setServer } from './lifecycle.js';
|
||||||
import { terminateBrowser } from './engine.js';
|
import { terminateBrowser, onBrowserExit } from './engine.js';
|
||||||
|
import config from '../config.js';
|
||||||
|
|
||||||
// ── 配置 ──
|
// ── 配置(统一从 config.js 读取) ──
|
||||||
const PORT = parseInt(process.env.DAEMON_PORT || '40225', 10);
|
const PORT = config.daemonPort;
|
||||||
const TTL_MS = parseInt(process.env.DAEMON_TTL_MS || String(30 * 60 * 1000), 10);
|
const TTL_MS = config.daemonTTL;
|
||||||
|
|
||||||
setTTL(TTL_MS);
|
setTTL(TTL_MS);
|
||||||
|
|
||||||
@@ -49,8 +50,21 @@ const server = createServer((req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
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] 🚀 Browser Daemon 已启动 — http://127.0.0.1:${PORT}`);
|
||||||
console.log(`[daemon] ⏱ 闲置 TTL: ${(TTL_MS / 60000).toFixed(0)} 分钟`);
|
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/acquire — 获取/启动浏览器`);
|
||||||
console.log(`[daemon] GET /browser/status — 查询浏览器状态`);
|
console.log(`[daemon] GET /browser/status — 查询浏览器状态`);
|
||||||
console.log(`[daemon] POST /browser/release — 销毁浏览器`);
|
console.log(`[daemon] POST /browser/release — 销毁浏览器`);
|
||||||
|
|||||||
84
src/demo.js
84
src/demo.js
@@ -1,99 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* demo.js — 使用示例
|
* 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
|
* node src/demo.js
|
||||||
*
|
*
|
||||||
* 方式 2:让 skill 自动检测并启动浏览器
|
* Daemon 未运行时会自动后台拉起,无需手动启动。
|
||||||
* node src/demo.js
|
* demo 只需通过 createGeminiSession() 获取会话即可。
|
||||||
* (或指定路径:BROWSER_PATH="C:/..." node src/demo.js)
|
|
||||||
*
|
*
|
||||||
* 所有配置项见 .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 { writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { createGeminiSession, disconnect } from './index.js';
|
import { createGeminiSession, disconnect } from './index.js';
|
||||||
|
|
||||||
const prompt = 'Gemini你好!请你仿造这个风格,给我生成更多表情包吧!来一张玩手机中。。。';
|
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));
|
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() {
|
async function main() {
|
||||||
console.log('=== Gemini Skill Demo ===\n');
|
console.log('=== Gemini Skill Demo ===\n');
|
||||||
|
|
||||||
// 创建会话:自带杀进程重试逻辑
|
// 创建会话(自动连接 Daemon 托管的浏览器)
|
||||||
const { ops } = await createSessionWithRetry();
|
const { ops } = await createGeminiSession();
|
||||||
|
|
||||||
// ── Ctrl+C 时只断开连接,不杀浏览器进程(下次可复用) ──
|
// ── Ctrl+C 时只断开连接,不杀浏览器进程(浏览器由 Daemon 守护) ──
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('\n[demo] Ctrl+C 收到,断开浏览器连接(浏览器保持运行)...');
|
console.log('\n[demo] Ctrl+C 收到,断开浏览器连接(浏览器仍由 Daemon 守护)...');
|
||||||
disconnect();
|
disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
31
src/index.js
31
src/index.js
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* gemini-skill — 统一入口
|
* gemini-skill — 统一入口
|
||||||
*
|
*
|
||||||
* 对外只暴露高层 API,浏览器管理在内部自动完成。
|
* 对外只暴露高层 API,浏览器连接由 Daemon 托管。
|
||||||
|
* Daemon 未运行时会自动后台拉起,无需手动启动。
|
||||||
*
|
*
|
||||||
* 用法:
|
* 用法:
|
||||||
* import { createGeminiSession, disconnect } from './index.js';
|
* import { createGeminiSession, disconnect } from './index.js';
|
||||||
@@ -10,34 +11,26 @@
|
|||||||
* await ops.generateImage('画一只猫');
|
* await ops.generateImage('画一只猫');
|
||||||
* disconnect();
|
* disconnect();
|
||||||
*/
|
*/
|
||||||
import { ensureBrowser, disconnect, close } from './browser.js';
|
import { ensureBrowser, disconnect } from './browser.js';
|
||||||
import { createOps } from './gemini-ops.js';
|
import { createOps } from './gemini-ops.js';
|
||||||
|
|
||||||
export { disconnect, close };
|
export { disconnect };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Gemini 操控会话
|
* 创建 Gemini 操控会话
|
||||||
*
|
*
|
||||||
* 内部自动管理浏览器连接:
|
* 内部通过 Browser Daemon 管理浏览器:
|
||||||
* 1. 端口有 Chrome → 直接 connect
|
* 1. 向 Daemon 发送 HTTP 请求获取 wsEndpoint
|
||||||
* 2. 无 Chrome + 提供了 executablePath → 自动 launch
|
* 2. 通过 WebSocket 直连 Chrome CDP
|
||||||
* 3. 无 Chrome + 无 executablePath → 报错并提示手动启动
|
* 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<typeof createOps>, page: import('puppeteer-core').Page, browser: import('puppeteer-core').Browser}>}
|
* @returns {Promise<{ops: ReturnType<typeof createOps>, page: import('puppeteer-core').Page, browser: import('puppeteer-core').Browser}>}
|
||||||
*/
|
*/
|
||||||
export async function createGeminiSession(opts = {}) {
|
export async function createGeminiSession() {
|
||||||
const { browser, page } = await ensureBrowser(opts);
|
const { browser, page } = await ensureBrowser();
|
||||||
const ops = createOps(page);
|
const ops = createOps(page);
|
||||||
return { ops, page, browser };
|
return { ops, page, browser };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user