mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
refactor: 代码解耦合,完善WebUI功能,添加网页VNC
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @fileoverview 浏览器启动与生命周期管理
|
||||
* @description 负责启动 Camoufox(Playwright 内核)、注入指纹与代理,并在进程退出时做资源清理。
|
||||
* 导航和预热行为由工作池负责,本模块只负责启动浏览器。
|
||||
*
|
||||
* 约定:
|
||||
* - 登录模式会尽量保留 Profile(用户数据目录)
|
||||
* - 清理采用三级退出:Playwright close -> SIGTERM -> SIGKILL
|
||||
*/
|
||||
|
||||
import { Camoufox } from 'camoufox-js';
|
||||
import { FingerprintGenerator } from 'fingerprint-generator';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { createCursor } from 'ghost-cursor-playwright-port';
|
||||
import { getRealViewport, clamp, random, sleep } from './utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getBrowserProxy, cleanupProxy } from '../../utils/proxy.js';
|
||||
|
||||
// 全局状态:用于在登录模式下管理残留进程与复用上下文
|
||||
let globalBrowserProcess = null;
|
||||
let globalContext = null; // 替代 globalBrowser
|
||||
|
||||
/**
|
||||
* 清理浏览器资源和进程
|
||||
* 实现三级退出机制: Playwright close -> SIGTERM -> SIGKILL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function cleanup() {
|
||||
|
||||
// Level 1: 通过 Playwright 协议优雅关闭 Context,保存 Profile
|
||||
if (globalContext) {
|
||||
try {
|
||||
logger.debug('浏览器', '正在断开远程调试连接并保存 Profile...');
|
||||
await globalContext.close();
|
||||
globalContext = null;
|
||||
logger.debug('浏览器', '已关闭浏览器上下文');
|
||||
} catch (e) {
|
||||
logger.warn('浏览器', `关闭上下文失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Level 2 & 3: 处理残留进程 (主要用于登录模式)
|
||||
if (globalBrowserProcess && !globalBrowserProcess.killed) {
|
||||
logger.info('浏览器', '正在终止浏览器进程...');
|
||||
try {
|
||||
// Level 2: 发送 SIGTERM (软杀)
|
||||
globalBrowserProcess.kill('SIGTERM');
|
||||
|
||||
// 等待进程退出
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 2000) {
|
||||
try {
|
||||
process.kill(globalBrowserProcess.pid, 0);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch (e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
// Level 3: 强制查杀 (SIGKILL)
|
||||
try {
|
||||
process.kill(globalBrowserProcess.pid, 0);
|
||||
logger.debug('浏览器', '浏览器进程无响应,执行强制终止 (SIGKILL)...');
|
||||
process.kill(-globalBrowserProcess.pid, 'SIGKILL');
|
||||
} catch (e) { }
|
||||
|
||||
globalBrowserProcess = null;
|
||||
logger.info('浏览器', '浏览器进程已终止');
|
||||
}
|
||||
|
||||
// 清理代理
|
||||
await cleanupProxy();
|
||||
}
|
||||
|
||||
// 防止重复注册
|
||||
let signalHandlersRegistered = false;
|
||||
|
||||
/**
|
||||
* 注册进程退出信号处理
|
||||
* @private
|
||||
*/
|
||||
function registerCleanupHandlers() {
|
||||
if (signalHandlersRegistered) return;
|
||||
|
||||
process.on('exit', () => {
|
||||
if (globalBrowserProcess) globalBrowserProcess.kill();
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await cleanup();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
signalHandlersRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前操作系统名称
|
||||
* 将 Node.js 的 platform 转换为 Camoufox/FingerprintGenerator 支持的格式
|
||||
*/
|
||||
function getCurrentOS() {
|
||||
const platform = os.platform();
|
||||
if (platform === 'win32') return 'windows';
|
||||
if (platform === 'darwin') return 'macos';
|
||||
// 其他情况默认为 linux
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或生成持久化指纹
|
||||
* @param {string} filePath - JSON文件保存路径
|
||||
*/
|
||||
function getPersistentFingerprint(filePath) {
|
||||
// 确保 data 目录存在
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// 尝试读取现有指纹
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const savedData = JSON.parse(fileContent);
|
||||
|
||||
// 简单校验:确保读取的是一个对象
|
||||
if (savedData && typeof savedData === 'object') {
|
||||
return savedData;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('浏览器', `读取指纹文件失败,将重新生成: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新指纹
|
||||
const currentOS = getCurrentOS();
|
||||
logger.info('浏览器', `正在为系统 [${currentOS}] 生成新指纹...`);
|
||||
|
||||
// 为不同系统使用不同的配置策略
|
||||
const generatorOptions = {
|
||||
browsers: ['firefox'],
|
||||
operatingSystems: [currentOS],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
screen: {
|
||||
minWidth: 1280, maxWidth: 1366,
|
||||
minHeight: 720, maxHeight: 768
|
||||
}
|
||||
};
|
||||
|
||||
const generator = new FingerprintGenerator(generatorOptions);
|
||||
|
||||
const result = generator.getFingerprint();
|
||||
|
||||
// 关键点:我们只需要 result.fingerprint 部分
|
||||
const fingerprintToSave = result.fingerprint;
|
||||
|
||||
// 保存到文件
|
||||
fs.writeFileSync(filePath, JSON.stringify(fingerprintToSave, null, 2));
|
||||
logger.info('浏览器', `新指纹已保存至: ${filePath}`);
|
||||
|
||||
return fingerprintToSave;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动浏览器实例 (仅负责启动,不负责导航和预热)
|
||||
*
|
||||
* 导航到目标页面、注册导航处理器、预热行为由工作池 (pool.js) 负责。
|
||||
*
|
||||
* @param {object} config - 全局配置对象
|
||||
* @param {object} options - 启动选项
|
||||
* @param {string} options.userDataDir - 用户数据目录路径
|
||||
* @param {string} [options.userDataMark] - 用户数据目录标识 (用于日志显示)
|
||||
* @param {object} [options.proxyConfig] - Worker 级代理配置
|
||||
* @returns {Promise<{context: object, page: object}>} 浏览器上下文和初始页面
|
||||
*/
|
||||
export async function initBrowserBase(config, options = {}) {
|
||||
const {
|
||||
userDataDir,
|
||||
instanceName = null,
|
||||
proxyConfig = null
|
||||
} = options;
|
||||
|
||||
// 日志标识 (优先使用实例名称)
|
||||
const markLabel = instanceName || '默认';
|
||||
|
||||
// 检测登录模式和 Xvfb 模式
|
||||
const isLoginMode = process.argv.some(arg => arg.startsWith('-login'));
|
||||
const isXvfbMode = process.env.XVFB_RUNNING === 'true';
|
||||
const headlessMode = config?.browser?.headless && !isLoginMode && !isXvfbMode;
|
||||
|
||||
// 如果配置了无头模式但被强制禁用,输出原因
|
||||
if (config?.browser?.headless && !headlessMode) {
|
||||
const reasons = [];
|
||||
if (isLoginMode) reasons.push('登录模式');
|
||||
if (isXvfbMode) reasons.push('Xvfb 模式');
|
||||
logger.info('浏览器', `[${markLabel}] 无头模式已被禁用 (${reasons.join(' + ')})`);
|
||||
}
|
||||
|
||||
logger.info('浏览器', `[${markLabel}] 启动浏览器实例...`);
|
||||
|
||||
const browserConfig = config?.browser || {};
|
||||
|
||||
// 获取指纹对象(指纹文件放在对应的 userDataDir 内)
|
||||
const fingerprintPath = path.join(userDataDir, 'fingerprint.json');
|
||||
const myFingerprint = getPersistentFingerprint(fingerprintPath);
|
||||
|
||||
// 构造 Camoufox 启动选项
|
||||
const currentOS = getCurrentOS();
|
||||
const camoufoxLaunchOptions = {
|
||||
executable_path: browserConfig.path || undefined,
|
||||
headless: headlessMode,
|
||||
user_data_dir: userDataDir,
|
||||
window: [1366, 768],
|
||||
ff_version: 135,
|
||||
fingerprint: myFingerprint,
|
||||
os: currentOS,
|
||||
i_know_what_im_doing: true,
|
||||
block_webrtc: true,
|
||||
exclude_addons: ['UBO'],
|
||||
geoip: true,
|
||||
config: {
|
||||
forceScopeAccess: true
|
||||
}
|
||||
};
|
||||
|
||||
// 代理配置
|
||||
const proxyObj = await getBrowserProxy(proxyConfig);
|
||||
if (proxyObj) {
|
||||
camoufoxLaunchOptions.proxy = proxyObj;
|
||||
}
|
||||
|
||||
// 启动 Camoufox
|
||||
const context = await Camoufox(camoufoxLaunchOptions);
|
||||
globalContext = context;
|
||||
|
||||
// 构建状态描述
|
||||
const statusParts = [];
|
||||
statusParts.push(`无头模式: ${headlessMode ? '是' : '否'}`);
|
||||
if (proxyObj) statusParts.push('代理: 已配置');
|
||||
logger.info('浏览器', `[${markLabel}] 浏览器已启动 (${statusParts.join(', ')})`);
|
||||
|
||||
// 注册清理处理器
|
||||
registerCleanupHandlers();
|
||||
|
||||
// 注册断开连接事件
|
||||
context.on('close', async () => {
|
||||
logger.warn('浏览器', `[${markLabel}] 浏览器已断开连接`);
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 获取或创建 Page
|
||||
let page;
|
||||
const existingPages = context.pages();
|
||||
if (existingPages.length > 0) {
|
||||
page = existingPages[0];
|
||||
} else {
|
||||
page = await context.newPage();
|
||||
}
|
||||
|
||||
// 强制刷新视口大小
|
||||
await page.setViewportSize({ width: 1366, height: 768 });
|
||||
|
||||
// 返回 context 和 page(导航、预热、cursor 初始化由工作池负责)
|
||||
return {
|
||||
context,
|
||||
page
|
||||
};
|
||||
}
|
||||
|
||||
// 导出工具函数供 pool.js 使用
|
||||
export { createCursor, getRealViewport, clamp, random, sleep };
|
||||
Reference in New Issue
Block a user