feat: 支持自动检测浏览器并兼容 Chrome/Edge/Chromium

This commit is contained in:
WJZ_P
2026-03-16 00:02:56 +08:00
parent 39ec4942ea
commit e97810ff82
5 changed files with 133 additions and 61 deletions

18
.env
View File

@@ -1,24 +1,24 @@
# ── Gemini Skill 环境变量 / Environment Variables ── # ── Gemini Skill 环境变量 / Environment Variables ──
# Chrome / Chromium 可执行文件路径(不设则需手动启动 Chrome # 浏览器可执行文件路径,支持 Chrome / Edge / Chromium(不设则自动检测
# Path to Chrome/Chromium executable (if not set, you need to start Chrome manually) # Browser executable path, supports Chrome / Edge / Chromium (auto-detect if not set)
CHROME_PATH= BROWSER_PATH=
# CDP 远程调试端口 # CDP 远程调试端口
# CDP remote debugging port # CDP remote debugging port
CHROME_DEBUG_PORT= BROWSER_DEBUG_PORT=
# Chrome 用户数据目录保持登录态、cookies 等) # 浏览器用户数据目录保持登录态、cookies 等)
# Chrome user data directory (persists login session, cookies, etc.) # Browser user data directory (persists login session, cookies, etc.)
CHROME_USER_DATA_DIR= BROWSER_USER_DATA_DIR=
# 是否无头模式true / false # 是否无头模式true / false
# Headless mode (true / false) # Headless mode (true / false)
CHROME_HEADLESS= BROWSER_HEADLESS=
# CDP 协议超时时间(毫秒) # CDP 协议超时时间(毫秒)
# CDP protocol timeout (milliseconds) # CDP protocol timeout (milliseconds)
CHROME_PROTOCOL_TIMEOUT= BROWSER_PROTOCOL_TIMEOUT=
# 截图 / 图片输出目录 # 截图 / 图片输出目录
# Screenshot / image output directory # Screenshot / image output directory

View File

@@ -2,12 +2,13 @@
* browser.js — 浏览器生命周期管理(内部模块,不对外暴露) * browser.js — 浏览器生命周期管理(内部模块,不对外暴露)
* *
* 设计思路: * 设计思路:
* Skill 内部自己管理 Chrome 进程,对外只暴露 getSession()。 * Skill 内部自己管理浏览器进程,对外只暴露 ensureBrowser()。
* 调用方不需要关心 launch/connect/端口/CDP 等细节。 * 调用方不需要关心 launch/connect/端口/CDP 等细节。
* 支持 Chrome / Edge / Chromium 等所有基于 Chromium 的浏览器。
* *
* 流程: * 流程:
* 1. 先检查指定端口是否已有 Chrome 在跑 → 有就 connect * 1. 先检查指定端口是否已有浏览器在跑 → 有就 connect
* 2. 没有 → 启动新 Chrome需要 executablePath * 2. 没有 → 自动检测或使用配置的浏览器路径启动
* 3. 找到 / 新开 Gemini 标签页 * 3. 找到 / 新开 Gemini 标签页
* 4. 返回 { browser, page } * 4. 返回 { browser, page }
*/ */
@@ -15,6 +16,8 @@ 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 { createConnection } from 'node:net';
import { existsSync } from 'node:fs';
import { platform } from 'node:os';
import config from './config.js'; import config from './config.js';
// ── 用 puppeteer-extra 包装 puppeteer-core注入 stealth 插件 ── // ── 用 puppeteer-extra 包装 puppeteer-core注入 stealth 插件 ──
@@ -24,8 +27,72 @@ puppeteer.use(StealthPlugin());
// ── 模块级单例:跨调用复用同一个浏览器 ── // ── 模块级单例:跨调用复用同一个浏览器 ──
let _browser = null; 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',
],
};
/** /**
* 探测指定端口是否有 Chrome 在监听 * 自动检测系统上可用的 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;
}
/**
* 探测指定端口是否有浏览器在监听
* @param {number} port * @param {number} port
* @param {string} [host='127.0.0.1'] * @param {string} [host='127.0.0.1']
* @param {number} [timeout=1500] * @param {number} [timeout=1500]
@@ -50,8 +117,8 @@ function isPortAlive(port, host = '127.0.0.1', timeout = 1500) {
}); });
} }
/** Chrome 启动参数 */ /** 浏览器启动参数(适用于所有 Chromium 系浏览器) */
const CHROME_ARGS = [ const BROWSER_ARGS = [
// ── 基础 ── // ── 基础 ──
'--no-first-run', '--no-first-run',
'--disable-default-apps', '--disable-default-apps',
@@ -81,23 +148,23 @@ const CHROME_ARGS = [
]; ];
/** /**
* 连接到已运行的 Chrome * 连接到已运行的浏览器
* @param {number} port * @param {number} port
* @returns {Promise<import('puppeteer-core').Browser>} * @returns {Promise<import('puppeteer-core').Browser>}
*/ */
async function connectToChrome(port) { async function connectBrowser(port) {
const browserURL = `http://127.0.0.1:${port}`; const browserURL = `http://127.0.0.1:${port}`;
const browser = await puppeteer.connect({ const browser = await puppeteer.connect({
browserURL, browserURL,
defaultViewport: null, defaultViewport: null,
protocolTimeout: config.chromeProtocolTimeout, protocolTimeout: config.browserProtocolTimeout,
}); });
console.log('[browser] connected to existing Chrome on port', port); console.log('[browser] connected to existing browser on port', port);
return browser; return browser;
} }
/** /**
* 启动新的 Chrome 实例 * 启动新的浏览器实例
* @param {object} opts * @param {object} opts
* @param {string} opts.executablePath * @param {string} opts.executablePath
* @param {number} opts.port * @param {number} opts.port
@@ -105,20 +172,20 @@ async function connectToChrome(port) {
* @param {boolean} opts.headless * @param {boolean} opts.headless
* @returns {Promise<import('puppeteer-core').Browser>} * @returns {Promise<import('puppeteer-core').Browser>}
*/ */
async function launchChrome({ executablePath, port, userDataDir, headless }) { async function launchBrowser({ executablePath, port, userDataDir, headless }) {
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
executablePath, executablePath,
headless, headless,
userDataDir, userDataDir,
defaultViewport: null, defaultViewport: null,
args: [ args: [
...CHROME_ARGS, ...BROWSER_ARGS,
`--remote-debugging-port=${port}`, `--remote-debugging-port=${port}`,
], ],
ignoreDefaultArgs: ['--enable-automation'], ignoreDefaultArgs: ['--enable-automation'],
protocolTimeout: config.chromeProtocolTimeout, protocolTimeout: config.browserProtocolTimeout,
}); });
console.log('[browser] launched Chrome, pid:', browser.process()?.pid, 'port:', port, 'dataDir:', userDataDir); console.log('[browser] launched, pid:', browser.process()?.pid, 'port:', port, 'path:', executablePath);
return browser; return browser;
} }
@@ -155,22 +222,22 @@ async function findOrCreateGeminiPage(browser) {
* *
* 逻辑: * 逻辑:
* 1. 如果已有 _browser 且未断开 → 直接复用 * 1. 如果已有 _browser 且未断开 → 直接复用
* 2. 检查端口是否有 Chrome → connect * 2. 检查端口是否有浏览器 → connect
* 3. 否则 launch 新 Chrome需要 executablePath * 3. 否则自动检测 / 使用配置的路径启动浏览器
* *
* @param {object} [opts] * @param {object} [opts]
* @param {string} [opts.executablePath] - Chrome 路径(仅 launch 时需要) * @param {string} [opts.executablePath] - 浏览器路径(仅 launch 时需要,不传则自动检测
* @param {number} [opts.port=9222] - 调试端口 * @param {number} [opts.port] - 调试端口env: BROWSER_DEBUG_PORT默认 9222
* @param {string} [opts.userDataDir] - 用户数据目录 * @param {string} [opts.userDataDir] - 用户数据目录env: BROWSER_USER_DATA_DIR
* @param {boolean} [opts.headless=false] * @param {boolean} [opts.headless] - 无头模式env: BROWSER_HEADLESS默认 false
* @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(opts = {}) {
const { const {
executablePath = config.chromePath, executablePath = config.browserPath,
port = config.chromeDebugPort, port = config.browserDebugPort,
userDataDir = config.chromeUserDataDir, userDataDir = config.browserUserDataDir,
headless = config.chromeHeadless, headless = config.browserHeadless,
} = opts; } = opts;
// 1. 复用已有连接 // 1. 复用已有连接
@@ -180,11 +247,11 @@ export async function ensureBrowser(opts = {}) {
return { browser: _browser, page }; return { browser: _browser, page };
} }
// 2. 尝试连接已在运行的 Chrome // 2. 尝试连接已在运行的浏览器
const alive = await isPortAlive(port); const alive = await isPortAlive(port);
if (alive) { if (alive) {
try { try {
_browser = await connectToChrome(port); _browser = await connectBrowser(port);
const page = await findOrCreateGeminiPage(_browser); const page = await findOrCreateGeminiPage(_browser);
return { browser: _browser, page }; return { browser: _browser, page };
} catch (err) { } catch (err) {
@@ -192,22 +259,25 @@ export async function ensureBrowser(opts = {}) {
} }
} }
// 3. 启动新 Chrome // 3. 启动新浏览器:优先用配置路径,否则自动检测
if (!executablePath) { const resolvedPath = executablePath || detectBrowser();
if (!resolvedPath) {
throw new Error( throw new Error(
`[browser] 端口 ${port} 无可用 Chrome且未提供 executablePath\n` + `[browser] 端口 ${port} 无可用浏览器,且未找到可执行文件\n` +
`先手动启动 Chromechrome --remote-debugging-port=${port} --user-data-dir="${userDataDir}"\n` + `通过以下任一方式解决:\n` +
`或传入 executablePath 让 skill 自动启动。` ` 1. 设置环境变量 BROWSER_PATH 指向 Chrome / Edge / Chromium 的可执行文件\n` +
` 2. 手动启动浏览器chrome --remote-debugging-port=${port} --user-data-dir="${userDataDir}"\n` +
` 3. 安装 Chrome 或 Edge 到默认位置`
); );
} }
_browser = await launchChrome({ executablePath, port, userDataDir, headless }); _browser = await launchBrowser({ executablePath: resolvedPath, port, userDataDir, headless });
const page = await findOrCreateGeminiPage(_browser); const page = await findOrCreateGeminiPage(_browser);
return { browser: _browser, page }; return { browser: _browser, page };
} }
/** /**
* 断开浏览器连接(不杀 Chrome 进程,方便下次复用) * 断开浏览器连接(不杀进程,方便下次复用)
*/ */
export function disconnect() { export function disconnect() {
if (_browser) { if (_browser) {
@@ -218,7 +288,7 @@ export function disconnect() {
} }
/** /**
* 关闭浏览器(杀 Chrome 进程) * 关闭浏览器(杀进程)
*/ */
export async function close() { export async function close() {
if (_browser) { if (_browser) {

View File

@@ -38,23 +38,23 @@ function envStr(key, fallback) {
// ── 导出配置 ── // ── 导出配置 ──
const config = { const config = {
/** Chrome / Chromium 可执行文件路径(不设则需手动启动 Chrome */ /** 浏览器可执行文件路径,支持 Chrome / Edge / Chromium(不设则自动检测 */
chromePath: envStr('CHROME_PATH', undefined), browserPath: envStr('BROWSER_PATH', undefined),
/** CDP 远程调试端口 */ /** CDP 远程调试端口 */
chromeDebugPort: envInt('CHROME_DEBUG_PORT', 9222), browserDebugPort: envInt('BROWSER_DEBUG_PORT', 9222),
/** Chrome 用户数据目录 */ /** 浏览器用户数据目录 */
chromeUserDataDir: envStr( browserUserDataDir: envStr(
'CHROME_USER_DATA_DIR', 'BROWSER_USER_DATA_DIR',
join(homedir(), '.gemini-skill', 'chrome-data'), join(homedir(), '.gemini-skill', 'browser-data'),
), ),
/** 是否无头模式 */ /** 是否无头模式 */
chromeHeadless: envBool('CHROME_HEADLESS', false), browserHeadless: envBool('BROWSER_HEADLESS', false),
/** CDP 协议超时时间ms */ /** CDP 协议超时时间ms */
chromeProtocolTimeout: envInt('CHROME_PROTOCOL_TIMEOUT', 60_000), browserProtocolTimeout: envInt('BROWSER_PROTOCOL_TIMEOUT', 60_000),
/** 截图 / 图片输出目录 */ /** 截图 / 图片输出目录 */
outputDir: envStr('OUTPUT_DIR', resolve('output')), outputDir: envStr('OUTPUT_DIR', resolve('output')),

View File

@@ -3,12 +3,14 @@
* *
* 两种启动方式: * 两种启动方式:
* *
* 方式 1推荐先手动启动 Chrome,再运行 demo * 方式 1推荐先手动启动浏览器,再运行 demo
* chrome --remote-debugging-port=9222 --user-data-dir="~/.gemini-skill/chrome-data" * chrome --remote-debugging-port=9222 --user-data-dir="~/.gemini-skill/browser-data"
* (也可以用 Edgemsedge --remote-debugging-port=9222 --user-data-dir=...
* node src/demo.js * node src/demo.js
* *
* 方式 2通过环境变量让 skill 自动启动 Chrome * 方式 2让 skill 自动检测并启动浏览器
* CHROME_PATH="C:/Program Files/Google/Chrome/Application/chrome.exe" node src/demo.js * node src/demo.js
* 或指定路径BROWSER_PATH="C:/..." node src/demo.js
* *
* 所有配置项见 .env可直接编辑或通过命令行设环境变量。 * 所有配置项见 .env可直接编辑或通过命令行设环境变量。
*/ */

View File

@@ -26,10 +26,10 @@ export { disconnect, close };
* 所有参数均可通过环境变量配置(见 .envopts 传参优先级更高。 * 所有参数均可通过环境变量配置(见 .envopts 传参优先级更高。
* *
* @param {object} [opts] * @param {object} [opts]
* @param {string} [opts.executablePath] - Chrome 路径env: CHROME_PATH * @param {string} [opts.executablePath] - 浏览器路径env: BROWSER_PATH不设则自动检测
* @param {number} [opts.port] - 调试端口env: CHROME_DEBUG_PORT默认 9222 * @param {number} [opts.port] - 调试端口env: BROWSER_DEBUG_PORT默认 9222
* @param {string} [opts.userDataDir] - 用户数据目录env: CHROME_USER_DATA_DIR * @param {string} [opts.userDataDir] - 用户数据目录env: BROWSER_USER_DATA_DIR
* @param {boolean} [opts.headless] - 无头模式env: CHROME_HEADLESS默认 false * @param {boolean} [opts.headless] - 无头模式env: BROWSER_HEADLESS默认 false
* @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(opts = {}) {