365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import { chromium } from 'playwright';
|
|
import type { BrowserContext, Page, Browser as PlaywrightBrowser } from 'playwright';
|
|
import type { BrowserOptions, NetworkEvent, NetworkEventHandler } from './types';
|
|
|
|
export class Browser {
|
|
private browser?: PlaywrightBrowser;
|
|
private contexts: Map<string, BrowserContext> = new Map();
|
|
private logger: any;
|
|
private options: BrowserOptions;
|
|
private initialized = false;
|
|
|
|
constructor(logger?: any, defaultOptions?: BrowserOptions) {
|
|
this.logger = logger || console;
|
|
this.options = {
|
|
headless: true,
|
|
timeout: 30000,
|
|
blockResources: false,
|
|
enableNetworkLogging: false,
|
|
...defaultOptions,
|
|
};
|
|
}
|
|
|
|
async initialize(options: BrowserOptions = {}): Promise<void> {
|
|
if (this.initialized) {
|
|
return;
|
|
}
|
|
|
|
// Merge options
|
|
this.options = {
|
|
...this.options,
|
|
...options,
|
|
};
|
|
|
|
this.logger.info('Initializing browser...');
|
|
|
|
try {
|
|
this.browser = await chromium.launch({
|
|
headless: this.options.headless,
|
|
timeout: this.options.timeout,
|
|
args: [
|
|
// Security and sandbox
|
|
'--no-sandbox',
|
|
// '--disable-setuid-sandbox',
|
|
// '--disable-dev-shm-usage',
|
|
// '--disable-web-security',
|
|
// '--disable-features=VizDisplayCompositor',
|
|
// '--disable-blink-features=AutomationControlled',
|
|
|
|
// // Performance optimizations
|
|
// '--disable-gpu',
|
|
// '--disable-gpu-sandbox',
|
|
// '--disable-software-rasterizer',
|
|
// '--disable-background-timer-throttling',
|
|
// '--disable-renderer-backgrounding',
|
|
// '--disable-backgrounding-occluded-windows',
|
|
// '--disable-field-trial-config',
|
|
// '--disable-back-forward-cache',
|
|
// '--disable-hang-monitor',
|
|
// '--disable-ipc-flooding-protection',
|
|
|
|
// // Extensions and plugins
|
|
// '--disable-extensions',
|
|
// '--disable-plugins',
|
|
// '--disable-component-extensions-with-background-pages',
|
|
// '--disable-component-update',
|
|
// '--disable-plugins-discovery',
|
|
// '--disable-bundled-ppapi-flash',
|
|
|
|
// // Features we don't need
|
|
// '--disable-default-apps',
|
|
// '--disable-sync',
|
|
// '--disable-translate',
|
|
// '--disable-client-side-phishing-detection',
|
|
// '--disable-domain-reliability',
|
|
// '--disable-features=TranslateUI',
|
|
// '--disable-features=Translate',
|
|
// '--disable-breakpad',
|
|
// '--disable-preconnect',
|
|
// '--disable-print-preview',
|
|
// '--disable-password-generation',
|
|
// '--disable-password-manager-reauthentication',
|
|
// '--disable-save-password-bubble',
|
|
// '--disable-single-click-autofill',
|
|
// '--disable-autofill',
|
|
// '--disable-autofill-keyboard-accessory-view',
|
|
// '--disable-full-form-autofill-ios',
|
|
|
|
// // Audio/Video/Media
|
|
// '--mute-audio',
|
|
// '--disable-audio-output',
|
|
// '--autoplay-policy=user-gesture-required',
|
|
// '--disable-background-media-playback',
|
|
|
|
// // Networking
|
|
// '--disable-background-networking',
|
|
// '--disable-sync',
|
|
// '--aggressive-cache-discard',
|
|
// '--disable-default-apps',
|
|
|
|
// // UI/UX optimizations
|
|
// '--no-first-run',
|
|
// '--disable-infobars',
|
|
// '--disable-notifications',
|
|
// '--disable-desktop-notifications',
|
|
// '--disable-prompt-on-repost',
|
|
// '--disable-logging',
|
|
// '--disable-file-system',
|
|
// '--hide-scrollbars',
|
|
|
|
// // Memory optimizations
|
|
// '--memory-pressure-off',
|
|
// '--max_old_space_size=4096',
|
|
// '--js-flags="--max-old-space-size=4096"',
|
|
// '--media-cache-size=1',
|
|
// '--disk-cache-size=1',
|
|
|
|
// // Process management
|
|
// '--use-mock-keychain',
|
|
// '--password-store=basic',
|
|
// '--enable-automation',
|
|
// '--no-pings',
|
|
// '--no-service-autorun',
|
|
// '--metrics-recording-only',
|
|
// '--safebrowsing-disable-auto-update',
|
|
|
|
// // Disable unnecessary features for headless mode
|
|
// '--disable-speech-api',
|
|
// '--disable-gesture-typing',
|
|
// '--disable-voice-input',
|
|
// '--disable-wake-on-wifi',
|
|
// '--disable-webgl',
|
|
// '--disable-webgl2',
|
|
// '--disable-3d-apis',
|
|
// '--disable-accelerated-2d-canvas',
|
|
// '--disable-accelerated-jpeg-decoding',
|
|
// '--disable-accelerated-mjpeg-decode',
|
|
// '--disable-accelerated-video-decode',
|
|
// '--disable-canvas-aa',
|
|
// '--disable-2d-canvas-clip-aa',
|
|
// '--disable-gl-drawing-for-tests',
|
|
],
|
|
});
|
|
|
|
this.initialized = true;
|
|
this.logger.info('Browser initialized successfully');
|
|
} catch (error) {
|
|
this.logger.error('Failed to initialize browser', { error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async createPageWithProxy(
|
|
url: string,
|
|
proxy?: string
|
|
): Promise<{
|
|
page: Page & {
|
|
onNetworkEvent: (handler: NetworkEventHandler) => void;
|
|
offNetworkEvent: (handler: NetworkEventHandler) => void;
|
|
clearNetworkListeners: () => void;
|
|
};
|
|
contextId: string;
|
|
}> {
|
|
if (!this.browser) {
|
|
throw new Error('Browser not initialized. Call Browser.initialize() first.');
|
|
}
|
|
|
|
const contextId = `ctx-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
const contextOptions: Record<string, unknown> = {
|
|
ignoreHTTPSErrors: true,
|
|
bypassCSP: true,
|
|
};
|
|
|
|
if (proxy) {
|
|
const [protocol, rest] = proxy.split('://');
|
|
if (!rest) {
|
|
throw new Error('Invalid proxy format. Expected protocol://host:port or protocol://user:pass@host:port');
|
|
}
|
|
|
|
const [auth, hostPort] = rest.includes('@') ? rest.split('@') : [null, rest];
|
|
const finalHostPort = hostPort || rest;
|
|
const [host, port] = finalHostPort.split(':');
|
|
|
|
contextOptions['proxy'] = {
|
|
server: `${protocol}://${host}:${port}`,
|
|
username: auth?.split(':')[0] || '',
|
|
password: auth?.split(':')[1] || '',
|
|
};
|
|
}
|
|
|
|
const context = await this.browser.newContext(contextOptions);
|
|
|
|
// Block resources for performance
|
|
if (this.options.blockResources) {
|
|
await context.route('**/*.{png,jpg,jpeg,gif,svg,ico,woff,woff2,ttf,css}', route => {
|
|
route.abort();
|
|
});
|
|
}
|
|
|
|
this.contexts.set(contextId, context);
|
|
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(this.options.timeout || 30000);
|
|
page.setDefaultNavigationTimeout(this.options.timeout || 30000);
|
|
|
|
// Create network event handlers for this page
|
|
const networkEventHandlers: Set<NetworkEventHandler> = new Set();
|
|
|
|
// Add network monitoring methods to the page
|
|
const enhancedPage = page as Page & {
|
|
onNetworkEvent: (handler: NetworkEventHandler) => void;
|
|
offNetworkEvent: (handler: NetworkEventHandler) => void;
|
|
clearNetworkListeners: () => void;
|
|
};
|
|
|
|
enhancedPage.onNetworkEvent = (handler: NetworkEventHandler) => {
|
|
networkEventHandlers.add(handler);
|
|
|
|
// Set up network monitoring on first handler
|
|
if (networkEventHandlers.size === 1) {
|
|
this.setupNetworkMonitoring(page, networkEventHandlers);
|
|
}
|
|
};
|
|
|
|
enhancedPage.offNetworkEvent = (handler: NetworkEventHandler) => {
|
|
networkEventHandlers.delete(handler);
|
|
};
|
|
|
|
enhancedPage.clearNetworkListeners = () => {
|
|
networkEventHandlers.clear();
|
|
};
|
|
|
|
if (url) {
|
|
await page.goto(url, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: this.options.timeout,
|
|
});
|
|
}
|
|
|
|
return { page: enhancedPage, contextId };
|
|
}
|
|
|
|
private setupNetworkMonitoring(page: Page, handlers: Set<NetworkEventHandler>): void {
|
|
// Listen to requests
|
|
page.on('request', async request => {
|
|
const event: NetworkEvent = {
|
|
url: request.url(),
|
|
method: request.method(),
|
|
type: 'request',
|
|
timestamp: Date.now(),
|
|
headers: request.headers(),
|
|
};
|
|
|
|
// Capture request data for POST/PUT/PATCH requests
|
|
if (['POST', 'PUT', 'PATCH'].includes(request.method())) {
|
|
try {
|
|
const postData = request.postData();
|
|
if (postData) {
|
|
event.requestData = postData;
|
|
}
|
|
} catch {
|
|
// Some requests might not have accessible post data
|
|
}
|
|
}
|
|
|
|
this.emitNetworkEvent(event, handlers);
|
|
});
|
|
|
|
// Listen to responses
|
|
page.on('response', async response => {
|
|
const event: NetworkEvent = {
|
|
url: response.url(),
|
|
method: response.request().method(),
|
|
status: response.status(),
|
|
type: 'response',
|
|
timestamp: Date.now(),
|
|
headers: response.headers(),
|
|
};
|
|
|
|
// Capture response data for GET/POST requests with JSON content
|
|
const contentType = response.headers()['content-type'] || '';
|
|
if (contentType.includes('application/json') || contentType.includes('text/')) {
|
|
try {
|
|
const responseData = await response.text();
|
|
event.responseData = responseData;
|
|
} catch {
|
|
// Response might be too large or not accessible
|
|
}
|
|
}
|
|
|
|
this.emitNetworkEvent(event, handlers);
|
|
});
|
|
|
|
// Listen to failed requests
|
|
page.on('requestfailed', request => {
|
|
const event: NetworkEvent = {
|
|
url: request.url(),
|
|
method: request.method(),
|
|
type: 'failed',
|
|
timestamp: Date.now(),
|
|
headers: request.headers(),
|
|
};
|
|
|
|
// Try to capture request data for failed requests too
|
|
if (['POST', 'PUT', 'PATCH'].includes(request.method())) {
|
|
try {
|
|
const postData = request.postData();
|
|
if (postData) {
|
|
event.requestData = postData;
|
|
}
|
|
} catch {
|
|
// Ignore errors when accessing post data
|
|
}
|
|
}
|
|
|
|
this.emitNetworkEvent(event, handlers);
|
|
});
|
|
}
|
|
|
|
private emitNetworkEvent(event: NetworkEvent, handlers: Set<NetworkEventHandler>): void {
|
|
for (const handler of handlers) {
|
|
try {
|
|
handler(event);
|
|
} catch (error) {
|
|
this.logger.error('Network event handler error', { error });
|
|
}
|
|
}
|
|
}
|
|
|
|
async evaluate<T>(page: Page, fn: () => T): Promise<T> {
|
|
return page.evaluate(fn);
|
|
}
|
|
|
|
async closeContext(contextId: string): Promise<void> {
|
|
const context = this.contexts.get(contextId);
|
|
if (context) {
|
|
await context.close();
|
|
this.contexts.delete(contextId);
|
|
}
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
// Close all contexts
|
|
for (const [, context] of this.contexts) {
|
|
await context.close();
|
|
}
|
|
this.contexts.clear();
|
|
|
|
// Close browser
|
|
if (this.browser) {
|
|
await this.browser.close();
|
|
this.browser = undefined;
|
|
}
|
|
|
|
this.initialized = false;
|
|
this.logger.info('Browser closed');
|
|
}
|
|
|
|
get isInitialized(): boolean {
|
|
return this.initialized;
|
|
}
|
|
}
|
|
|
|
// Export default for backward compatibility
|
|
export default Browser;
|