feat: add browser manager with page registry and Playwright stealth integration

- PageRegistry: thread-safe in-memory registry for tracking active pages by ID and alias
- BrowserManager: Playwright browser lifecycle with stealth plugin, context creation, and page navigation
- 8 unit tests for PageRegistry (add, get, findByIdOrAlias, hasAlias, remove, list)
- 8 integration tests for BrowserManager (skipped by default, guard: VISIONL_INTEGRATION=1)
- Fix: PageInfo import in core/client.ts (was incorrectly imported from api.js instead of page.js)
This commit is contained in:
2026-08-12 20:51:08 +08:00
parent 513da621c6
commit a8fb781a7c
6 changed files with 407 additions and 2 deletions
+102
View File
@@ -0,0 +1,102 @@
// 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 } from '@visionl/core';
import { PageRegistry } from './page-registry.js';
// 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(profile: FingerprintProfile) {
this.profile = profile;
}
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();
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);
return info;
}
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' });
}
await entry.context.close();
this.registry.remove(entry.info.id);
}
getPage(idOrAlias: string) {
return this.registry.findByIdOrAlias(idOrAlias);
}
listPages(): PageInfo[] {
return this.registry.list();
}
async cleanup(): Promise<void> {
// Close all contexts / 关闭所有上下文
const entries = this.registry['pages']?.values() ?? [];
for (const entry of entries) {
try { await entry.context.close(); } catch { /* ignore / 忽略 */ }
}
if (this.browser) {
await this.browser.close();
this.browser = null;
}
}
}