feat: 支持自动检测浏览器并兼容 Chrome/Edge/Chromium
This commit is contained in:
18
.env
18
.env
@@ -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
|
||||||
|
|||||||
140
src/browser.js
140
src/browser.js
@@ -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` +
|
||||||
`请先手动启动 Chrome:chrome --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) {
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
10
src/demo.js
10
src/demo.js
@@ -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"
|
||||||
|
* (也可以用 Edge:msedge --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,可直接编辑或通过命令行设环境变量。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ export { disconnect, close };
|
|||||||
* 所有参数均可通过环境变量配置(见 .env),opts 传参优先级更高。
|
* 所有参数均可通过环境变量配置(见 .env),opts 传参优先级更高。
|
||||||
*
|
*
|
||||||
* @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 = {}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user