e79590a6d8
- Add ws-relay.ts: WebSocket server at /ws with broadcast function - Add network event types (request/response/failed) to core ws.ts types - Add ConsoleEntry and NetworkEntry types - Add consoleLog and networkLog buffers to PageRegistry (100/200 max) - Add console listener in createPage: broadcasts page:console events - Add network listeners (request/response/requestfailed) in createPage - Add page crash handler broadcasting page:crashed events - Add history endpoints: GET /pages/:id/console and GET /pages/:id/network - Integrate createWsRelay in server.ts startup - Bump ws from devDependency to full dependency
204 lines
6.7 KiB
TypeScript
204 lines
6.7 KiB
TypeScript
// 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<void> {
|
|
this.browser = await chromium.launch({
|
|
headless: true,
|
|
args: [
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--disable-blink-features=AutomationControlled',
|
|
],
|
|
});
|
|
}
|
|
|
|
async createPage(url: string, alias?: string): Promise<PageInfo> {
|
|
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<T>(buffer: T[], item: T, maxSize: number): void {
|
|
buffer.push(item);
|
|
if (buffer.length > maxSize) {
|
|
buffer.shift();
|
|
}
|
|
}
|
|
|
|
async closePage(idOrAlias: string): Promise<void> {
|
|
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<void> {
|
|
// 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;
|
|
}
|
|
}
|
|
}
|