/** * Browser extension injection support. * * Chrome extensions require a persistent profile directory — incompatible * with headless Docker by default. This module handles: * 1. Persistent profile directory per extension set * 2. Extension download from .crx URL or load from local path * 3. Launch args for Playwright / Stagehand * * Usage: * const profile = await prepareExtensionProfile(['/path/to/ext.crx']); * // pass profile.userDataDir and profile.args to browser launch */ import { createHash, randomBytes } from 'node:crypto'; import { createWriteStream, existsSync, mkdirSync } from 'node:fs'; import { writeFile, mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { pipeline } from 'node:stream/promises'; import { Readable } from 'node:stream'; const PROFILE_BASE = process.env.EXTENSION_PROFILE_DIR ?? join(tmpdir(), 'zeta-ext-profiles'); export interface ExtensionSource { /** Local .crx file path or https:// URL to download from */ source: string; /** Friendly name for logging */ name?: string; } export interface ExtensionProfile { /** Chrome user-data-dir containing Preferences + extension unpacked dirs */ userDataDir: string; /** Args to append to chromium launch — includes --load-extension and --disable-extensions-except */ args: string[]; /** Cleanup the profile directory when done */ cleanup: () => Promise; } /** * Download a .crx from a URL to a temp file. Returns path. */ async function downloadCrx(url: string): Promise { const dest = join(tmpdir(), `zeta-ext-${randomBytes(6).toString('hex')}.crx`); const res = await fetch(url); if (!res.ok) throw new Error(`Failed to download extension from ${url}: ${res.status}`); const writer = createWriteStream(dest); await pipeline(Readable.fromWeb(res.body as any), writer); return dest; } /** * Unpack a .crx file to a directory Chrome can load as an unpacked extension. * CRX3 format: 4-byte magic + 4-byte version + 4-byte header_size + proto header + zip body */ async function unpackCrx(crxPath: string, destDir: string): Promise { const { readFile } = await import('node:fs/promises'); const buf = await readFile(crxPath); const magic = buf.readUInt32LE(0); if (magic !== 0x43723234) { // Not CRX — assume it's already a zip (unpacked extension) await mkdir(destDir, { recursive: true }); await writeFile(join(destDir, '__raw.crx'), buf); return; } const version = buf.readUInt32LE(4); let zipStart: number; if (version === 3) { const headerSize = buf.readUInt32LE(8); zipStart = 12 + headerSize; } else if (version === 2) { const pubKeyLen = buf.readUInt32LE(8); const sigLen = buf.readUInt32LE(12); zipStart = 16 + pubKeyLen + sigLen; } else { throw new Error(`Unknown CRX version ${version}`); } const zipBuf = buf.subarray(zipStart); const tmpZip = join(tmpdir(), `zeta-ext-${randomBytes(4).toString('hex')}.zip`); await writeFile(tmpZip, zipBuf); await mkdir(destDir, { recursive: true }); // Extract using unzipper (available in Node without native deps) // @ts-ignore – no types for unzipper const unzipper = await import('unzipper').catch(() => null); if (unzipper) { await unzipper.Open.file(tmpZip).then((d: any) => d.extract({ path: destDir })); } else { // Fallback: shell unzip const { execFile } = await import('node:child_process'); await new Promise((res, rej) => execFile('unzip', ['-o', tmpZip, '-d', destDir], (err) => err ? rej(err) : res()) ); } await rm(tmpZip, { force: true }); } /** * Prepare a Chrome user-data-dir with the given extensions loaded. * Returns profile info + cleanup function. */ export async function prepareExtensionProfile( extensions: ExtensionSource[] ): Promise { // Stable profile key based on extension sources const key = createHash('md5') .update(extensions.map(e => e.source).sort().join('|')) .digest('hex') .slice(0, 12); const profileDir = join(PROFILE_BASE, key); const extensionDirs: string[] = []; mkdirSync(profileDir, { recursive: true }); for (const ext of extensions) { const extHash = createHash('md5').update(ext.source).digest('hex').slice(0, 8); const extDir = join(profileDir, `ext-${extHash}`); if (!existsSync(extDir)) { let crxPath = ext.source; if (ext.source.startsWith('http://') || ext.source.startsWith('https://')) { crxPath = await downloadCrx(ext.source); await unpackCrx(crxPath, extDir); await rm(crxPath, { force: true }); } else if (ext.source.endsWith('.crx')) { await unpackCrx(ext.source, extDir); } else { // Assume already-unpacked extension directory — symlink or copy extensionDirs.push(ext.source); continue; } } extensionDirs.push(extDir); } const userDataDir = join(profileDir, 'user-data'); mkdirSync(userDataDir, { recursive: true }); // Write a minimal Preferences file so Chrome doesn't show first-run UI const defaultsDir = join(userDataDir, 'Default'); mkdirSync(defaultsDir, { recursive: true }); const prefsPath = join(defaultsDir, 'Preferences'); if (!existsSync(prefsPath)) { await writeFile(prefsPath, JSON.stringify({ browser: { has_seen_welcome_page: true }, extensions: { alerts: { initialized: true } }, }), 'utf8'); } const loadExtensionArg = extensionDirs.join(','); const args: string[] = [ `--load-extension=${loadExtensionArg}`, `--disable-extensions-except=${loadExtensionArg}`, `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check', ]; return { userDataDir, args, cleanup: async () => { await rm(profileDir, { recursive: true, force: true }); }, }; } /** * Ad-blocker extension profile using the uBlock Origin CRX. * Reduces noise from ad networks during crawls. */ export async function adBlockerProfile(): Promise { // uBlock Origin 1.57.2 from GitHub release (MIT licensed) return prepareExtensionProfile([{ name: 'uBlock Origin', source: 'https://github.com/gorhill/uBlock/releases/download/1.57.2/uBlock0_1.57.2.chromium.zip', }]); }