Initial clean project import
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 浏览器管理器 - 单例模式管理浏览器实例
|
||||
* 避免重复初始化和过早关闭问题
|
||||
*/
|
||||
|
||||
import { chromium, Browser, BrowserContext, Page } from 'playwright';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { app } from 'electron';
|
||||
import { getPlaywrightChromiumPath, COMMON_BROWSER_ARGS, BROWSER_IGNORE_DEFAULT_ARGS, STEALTH_INIT_SCRIPT } from '../../lib/browser-path';
|
||||
import { getRuntimeDataPath } from '../../lib/resource-path';
|
||||
|
||||
export interface BrowserManager {
|
||||
getInstance(): BrowserManager;
|
||||
initBrowser(): Promise<void>;
|
||||
newPage(): Promise<Page>;
|
||||
close(): Promise<void>;
|
||||
isInitialized(): boolean;
|
||||
}
|
||||
|
||||
class DouyinBrowserManager implements BrowserManager {
|
||||
private static instance: DouyinBrowserManager;
|
||||
private browser: Browser | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private isInitializing: boolean = false;
|
||||
private userDataPath: string;
|
||||
|
||||
private constructor() {
|
||||
this.userDataPath = getRuntimeDataPath('browser-data', 'douyin');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取浏览器可执行文件路径(使用统一工具)
|
||||
*/
|
||||
private getChromiumExecutablePath(): string | undefined {
|
||||
return getPlaywrightChromiumPath();
|
||||
}
|
||||
|
||||
public static getInstance(): DouyinBrowserManager {
|
||||
if (!DouyinBrowserManager.instance) {
|
||||
DouyinBrowserManager.instance = new DouyinBrowserManager();
|
||||
}
|
||||
return DouyinBrowserManager.instance;
|
||||
}
|
||||
|
||||
public async initBrowser(): Promise<void> {
|
||||
if (this.isInitializing) {
|
||||
console.log('浏览器正在初始化中,等待完成...');
|
||||
while (this.isInitializing) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.context) {
|
||||
console.log('浏览器已初始化,直接返回');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isInitializing = true;
|
||||
|
||||
try {
|
||||
console.log('开始初始化浏览器实例...');
|
||||
|
||||
const executablePath = this.getChromiumExecutablePath();
|
||||
if (executablePath) {
|
||||
console.log('✅ [Browser] 使用打包的浏览器:', executablePath);
|
||||
try {
|
||||
fs.accessSync(executablePath, fs.constants.R_OK);
|
||||
console.log('✅ [Browser] 浏览器文件可读');
|
||||
} catch (e) {
|
||||
console.error('❌ [Browser] 浏览器文件不可访问:', executablePath, e);
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ [Browser] 未找到打包的浏览器,将使用 Playwright 默认浏览器');
|
||||
}
|
||||
|
||||
// === 阶段1:正常启动 ===
|
||||
try {
|
||||
console.log('[阶段1] 使用现有用户数据目录启动:', this.userDataPath);
|
||||
await this.launchBrowserContext(executablePath, this.userDataPath);
|
||||
console.log('✅ [阶段1] 浏览器启动成功');
|
||||
return;
|
||||
} catch (error1) {
|
||||
const errMsg1 = error1 instanceof Error ? error1.message : String(error1);
|
||||
console.error('❌ [阶段1] 启动失败:', errMsg1);
|
||||
|
||||
// === 阶段2:清理锁文件后重试 ===
|
||||
try {
|
||||
console.log('[阶段2] 清理锁文件后重试...');
|
||||
this.cleanupBrowserLocks(this.userDataPath);
|
||||
await this.launchBrowserContext(executablePath, this.userDataPath);
|
||||
console.log('✅ [阶段2] 浏览器启动成功(清理锁文件后)');
|
||||
return;
|
||||
} catch (error2) {
|
||||
const errMsg2 = error2 instanceof Error ? error2.message : String(error2);
|
||||
console.error('❌ [阶段2] 启动失败:', errMsg2);
|
||||
|
||||
// === 阶段3:使用全新临时目录 ===
|
||||
try {
|
||||
const tempDataPath = getRuntimeDataPath('temp', `douyin-browser-${Date.now()}`);
|
||||
console.log('[阶段3] 使用全新临时目录启动:', tempDataPath);
|
||||
fs.mkdirSync(tempDataPath, { recursive: true });
|
||||
await this.launchBrowserContext(executablePath, tempDataPath);
|
||||
console.log('✅ [阶段3] 浏览器启动成功(使用临时目录)');
|
||||
return;
|
||||
} catch (error3) {
|
||||
const errMsg3 = error3 instanceof Error ? error3.message : String(error3);
|
||||
console.error('❌ [阶段3] 所有启动方式均失败:', errMsg3);
|
||||
|
||||
// 给出详细的诊断信息
|
||||
const isDllError = errMsg3.includes('3221225781') || errMsg3.includes('0xC0000135');
|
||||
const isClosed = errMsg3.includes('has been closed');
|
||||
|
||||
if (isDllError || isClosed) {
|
||||
throw new Error(
|
||||
'浏览器启动失败(错误码: 0xC0000135)。\n' +
|
||||
'可能原因:\n' +
|
||||
'1. 系统缺少 Visual C++ 运行时库,请安装: https://aka.ms/vs/17/release/vc_redist.x64.exe\n' +
|
||||
'2. 系统版本过低,Chromium 要求 Windows 10 或更高版本\n' +
|
||||
'3. 杀毒软件拦截了浏览器进程\n' +
|
||||
'安装 VC++ 运行库后重启应用即可。'
|
||||
);
|
||||
}
|
||||
throw error3;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动浏览器上下文(内部方法)
|
||||
*/
|
||||
private async launchBrowserContext(executablePath: string | undefined, userDataPath: string): Promise<void> {
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
this.context = await chromium.launchPersistentContext(userDataPath, {
|
||||
executablePath,
|
||||
headless: true,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
locale: 'zh-CN',
|
||||
acceptDownloads: true,
|
||||
ignoreHTTPSErrors: true,
|
||||
permissions: ['geolocation', 'notifications'],
|
||||
serviceWorkers: 'block',
|
||||
args: COMMON_BROWSER_ARGS,
|
||||
ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS,
|
||||
});
|
||||
|
||||
// 注入反检测脚本(与自动发布模块一致)
|
||||
await this.context.addInitScript(STEALTH_INIT_SCRIPT);
|
||||
|
||||
this.browser = this.context.browser();
|
||||
|
||||
console.log('✅ 浏览器实例初始化成功');
|
||||
|
||||
if (this.browser) {
|
||||
this.browser.on('disconnected', () => {
|
||||
console.log('浏览器已断开连接');
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理浏览器锁文件(修复崩溃后无法重启的问题)
|
||||
*/
|
||||
private cleanupBrowserLocks(userDataPath: string): void {
|
||||
const lockFiles = ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'Lock'];
|
||||
for (const lockFile of lockFiles) {
|
||||
const lockPath = path.join(userDataPath, lockFile);
|
||||
try {
|
||||
if (fs.existsSync(lockPath)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
console.log('🗑️ 已清理锁文件:', lockFile);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('清理锁文件失败(忽略):', lockFile, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async newPage(): Promise<Page> {
|
||||
if (!this.browser || !this.context) {
|
||||
await this.initBrowser();
|
||||
}
|
||||
|
||||
if (!this.context) {
|
||||
throw new Error('浏览器上下文未初始化');
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await this.context.newPage();
|
||||
|
||||
// 设置页面配置
|
||||
page.setDefaultTimeout(60000);
|
||||
|
||||
// 监听页面事件
|
||||
page.on('close', () => {
|
||||
console.log('页面已关闭');
|
||||
});
|
||||
|
||||
page.on('error', (error) => {
|
||||
console.error('页面错误:', error);
|
||||
});
|
||||
|
||||
return page;
|
||||
} catch (error) {
|
||||
console.error('创建新页面失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
try {
|
||||
console.log('开始关闭浏览器管理器...');
|
||||
|
||||
// 保存状态
|
||||
if (this.context) {
|
||||
try {
|
||||
const statePath = path.join(this.userDataPath, 'douyin-state.json');
|
||||
await this.context.storageState({ path: statePath });
|
||||
console.log('✅ 浏览器状态已保存');
|
||||
} catch (saveError) {
|
||||
console.log('保存浏览器状态失败:', saveError);
|
||||
}
|
||||
|
||||
// 🔧 关键修复:从 context 对象中获取浏览器实例
|
||||
try {
|
||||
const browserInstance = (this.context as any).browser?.() || (this.context as any)._browser;
|
||||
if (browserInstance) {
|
||||
console.log('找到浏览器实例,准备直接关闭浏览器窗口...');
|
||||
try {
|
||||
await browserInstance.close();
|
||||
console.log('✅ 浏览器窗口已关闭');
|
||||
} catch (browserCloseError) {
|
||||
console.log('浏览器关闭失败:', browserCloseError);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('无法获取浏览器实例,尝试通过 context.close() 关闭:', e);
|
||||
}
|
||||
|
||||
// 关闭上下文
|
||||
try {
|
||||
await this.context.close();
|
||||
console.log('✅ 上下文已关闭');
|
||||
} catch (contextCloseError) {
|
||||
console.log('上下文关闭失败(忽略):', contextCloseError);
|
||||
}
|
||||
|
||||
this.context = null;
|
||||
}
|
||||
|
||||
// 确保浏览器进程被关闭
|
||||
if (this.browser) {
|
||||
try {
|
||||
console.log('正在关闭浏览器主进程...');
|
||||
await this.browser.close();
|
||||
console.log('✅ 浏览器主进程已关闭');
|
||||
} catch (browserCloseError) {
|
||||
console.log('浏览器主进程关闭失败:', browserCloseError);
|
||||
}
|
||||
this.browser = null;
|
||||
}
|
||||
|
||||
console.log('✅ 浏览器管理器已彻底关闭');
|
||||
} catch (error) {
|
||||
console.error('关闭浏览器管理器失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public isInitialized(): boolean {
|
||||
return this.browser !== null && this.context !== null;
|
||||
}
|
||||
|
||||
public getBrowserInfo(): { browser: Browser | null; context: BrowserContext | null } {
|
||||
return {
|
||||
browser: this.browser,
|
||||
context: this.context
|
||||
};
|
||||
}
|
||||
|
||||
// 全局清理方法
|
||||
public static async cleanup(): Promise<void> {
|
||||
if (DouyinBrowserManager.instance) {
|
||||
await DouyinBrowserManager.instance.close();
|
||||
DouyinBrowserManager.instance = null as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DouyinBrowserManager };
|
||||
Reference in New Issue
Block a user