// BrowserManager — Playwright browser lifecycle and page management with stealth plugin / 浏览器管理器,使用隐身插件管理 Playwright 浏览器生命周期和页面 import { chromium } from 'playwright-extra'; import StealthPlugin from 'puppeteer-extra-plugin-stealth'; import type { Browser, BrowserContext, Page } from 'playwright'; import type { FingerprintProfile, PageInfo, ConsoleEntry, NetworkEntry } from '@visionl/core'; import { PageRegistry, type RegisteredPage } from './page-registry.js'; import { getProfile } from './stealth/profiles/index.js'; import { applyStealth } from './stealth/index.js'; import { broadcast } from './ws-relay.js'; const MAX_CONSOLE_BUFFER = 100; const MAX_NETWORK_BUFFER = 200; // Apply stealth plugin once at module level / 模块级别一次性注入隐身插件 chromium.use(StealthPlugin()); function generatePageId(): string { const hex = Math.random().toString(16).slice(2, 10); return `p_${hex}`; } export class BrowserManager { private browser: Browser | null = null; private registry = new PageRegistry(); private profile: FingerprintProfile; constructor(profileOrId: FingerprintProfile | string = 'desktop-chrome') { if (typeof profileOrId === 'string') { const resolved = getProfile(profileOrId); if (!resolved) throw new Error(`Unknown profile: ${profileOrId}`); this.profile = resolved; } else { this.profile = profileOrId; } } async init(): Promise { this.browser = await chromium.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-blink-features=AutomationControlled', ], }); } async createPage(url: string, alias?: string): Promise { if (!this.browser) throw new Error('Browser not initialized'); if (alias && this.registry.hasAlias(alias)) { throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' }); } // Validate URL format / 验证 URL 格式 try { new URL(url); } catch { throw Object.assign(new Error(`Invalid URL: ${url}`), { code: 'INVALID_URL' }); } const context = await this.browser.newContext({ viewport: this.profile.viewport, userAgent: this.profile.userAgent, locale: this.profile.languages[0], timezoneId: this.profile.timezone, permissions: Object.entries(this.profile.permissions) .filter(([, v]) => v === 'granted') .map(([k]) => k as any), geolocation: this.profile.geolocation, colorScheme: 'light', deviceScaleFactor: this.profile.screen.pixelRatio, }); const page = await context.newPage(); // Apply stealth injections before navigation / 导航前注入隐身模块 await applyStealth(context, page, this.profile); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); const title = await page.title(); const id = generatePageId(); const info: PageInfo = { id, url, alias, title, status: 'active', profile: this.profile.id }; this.registry.add(info, page, context); this.setupPageListeners(page, id); // Broadcast page created event / 广播页面创建事件 broadcast({ type: 'page:created', data: info }); console.log(`[browser-manager] Page created: ${id} → ${url}`); return info; } private setupPageListeners(page: Page, id: string): void { const entry = this.registry.get(id); if (!entry) return; // Console monitoring / 控制台监控 page.on('console', (msg) => { const level = msg.type(); const text = msg.text(); const consoleEntry: ConsoleEntry = { level, text, timestamp: Date.now() }; this.pushToBuffer(entry.consoleLog, consoleEntry, MAX_CONSOLE_BUFFER); broadcast({ type: 'page:console', data: { id, level, text } }); }); // Network request monitoring / 网络请求监控 page.on('request', (req) => { const netEntry: NetworkEntry = { type: 'request', url: req.url(), method: req.method(), headers: req.headers(), timestamp: Date.now(), }; this.pushToBuffer(entry.networkLog, netEntry, MAX_NETWORK_BUFFER); broadcast({ type: 'page:network:request', data: { id, url: req.url(), method: req.method(), headers: req.headers() }, }); }); // Network response monitoring / 网络响应监控 page.on('response', (res) => { const netEntry: NetworkEntry = { type: 'response', url: res.url(), status: res.status(), headers: res.headers(), timestamp: Date.now(), }; this.pushToBuffer(entry.networkLog, netEntry, MAX_NETWORK_BUFFER); broadcast({ type: 'page:network:response', data: { id, url: res.url(), status: res.status(), headers: res.headers() }, }); }); // Network failure monitoring / 网络失败监控 page.on('requestfailed', (req) => { const failureText = req.failure()?.errorText || 'Unknown error'; const netEntry: NetworkEntry = { type: 'failed', url: req.url(), failure: failureText, timestamp: Date.now(), }; this.pushToBuffer(entry.networkLog, netEntry, MAX_NETWORK_BUFFER); broadcast({ type: 'page:network:failed', data: { id, url: req.url(), failure: failureText }, }); }); // Page crash handling / 页面崩溃处理 page.on('crash', () => { console.error(`[browser-manager] Page crashed: ${id}`); entry.info.status = 'crashed'; broadcast({ type: 'page:crashed', data: { id, error: 'Page crashed' } }); }); } private pushToBuffer(buffer: T[], item: T, maxSize: number): void { buffer.push(item); if (buffer.length > maxSize) { buffer.shift(); } } async closePage(idOrAlias: string): Promise { const entry = this.registry.findByIdOrAlias(idOrAlias); if (!entry) { throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' }); } const pageId = entry.info.id; await entry.context.close(); this.registry.remove(pageId); broadcast({ type: 'page:closed', data: { id: pageId } }); console.log(`[browser-manager] Page closed: ${pageId}`); } getPage(idOrAlias: string): RegisteredPage | undefined { return this.registry.findByIdOrAlias(idOrAlias); } listPages(): PageInfo[] { return this.registry.list(); } async cleanup(): Promise { // Close all contexts / 关闭所有上下文 for (const entry of this.registry.getAll()) { try { await entry.context.close(); } catch { /* ignore / 忽略 */ } } this.registry.clear(); if (this.browser) { await this.browser.close(); this.browser = null; } } }