Files
gemini-skill/src/browser.js

514 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* browser.js — 浏览器生命周期管理(内部模块,不对外暴露)
*
* 设计思路:
* Skill 内部自己管理浏览器进程,对外只暴露 ensureBrowser()。
* 调用方不需要关心 launch/connect/端口/CDP 等细节。
* 支持 Chrome / Edge / Chromium 等所有基于 Chromium 的浏览器。
*
* 流程:
* 1. 先检查指定端口是否已有浏览器在跑 → 有就 connect
* 2. 没有 → 自动检测或使用配置的浏览器路径启动
* 3. 找到 / 新开 Gemini 标签页
* 4. 返回 { browser, page }
*/
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 插件 ──
const puppeteer = addExtra(puppeteerCore);
puppeteer.use(StealthPlugin());
// ── 模块级单例:跨调用复用同一个浏览器 ──
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',
],
};
/**
* 自动检测系统上可用的 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;
}
// ── userDataDirWJZ_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_DIRconfig 已处理)
* 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>}
*/
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/shmDocker 中该分区常太小导致崩溃)
// ── 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;
}
/**
* 启动新的浏览器实例
* @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<import('puppeteer-core').Browser>}
*/
async function launchBrowser({ executablePath, port, userDataDir, headless, debugOpts = {} }) {
const {
handleSIGINT = true,
handleSIGTERM = true,
handleSIGHUP = true,
} = debugOpts;
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,
});
console.log('[browser] launched, pid:', browser.process()?.pid, 'port:', port, 'path:', executablePath);
return browser;
}
/**
* 在浏览器中找到 Gemini 标签页,或新开一个
* @param {import('puppeteer-core').Browser} browser
* @returns {Promise<import('puppeteer-core').Page>}
*/
async function findOrCreateGeminiPage(browser) {
const pages = await browser.pages();
// 优先复用已有的 Gemini 标签页
for (const page of pages) {
const url = page.url();
if (url.includes('gemini.google.com')) {
console.log('[browser] reusing existing Gemini tab:', url);
await page.bringToFront();
return page;
}
}
// 没找到,新开一个
const page = pages.length > 0 ? pages[0] : await browser.newPage();
await page.goto('https://gemini.google.com/app', {
waitUntil: 'networkidle2',
timeout: 30_000,
});
console.log('[browser] opened new Gemini tab');
return page;
}
/**
* 确保浏览器可用 — Skill 唯一的对外浏览器管理入口
*
* 逻辑:
* 1. 如果已有 _browser 且未断开 → 直接复用
* 2. 检查端口是否有浏览器 → connect
* 3. 否则自动检测 / 使用配置的路径启动浏览器
*
* 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默认 9223
* @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;
// 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);
}
}
// 3. 启动新浏览器:优先用配置路径,否则自动检测
const resolvedPath = executablePath || detectBrowser();
if (!resolvedPath) {
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 到默认位置`
);
}
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;
}
const page = await findOrCreateGeminiPage(_browser);
return { browser: _browser, page };
}
/**
* 断开浏览器连接(不杀进程,方便下次复用)
*
* 在 Windows 上Node 退出时默认会终止所有子进程。
* 因此 disconnect 前先对浏览器子进程做 unref + stdio detach
* 使浏览器进程脱离 Node 进程树,独立存活。
*/
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');
}
}