/** * Custom JS injector — run user-provided JavaScript in the context of each crawled page. * * Use cases: * - Extract custom data points not captured by standard extractors * - Set localStorage/sessionStorage values before crawl * - Override fetch() to intercept API calls * - Simulate user events (keydown, focus, etc.) * - Bypass specific anti-bot checks * * Security: scripts run in the page context (sandboxed by Chromium's renderer). * They cannot access the Node.js process or file system. * ZeTa validates scripts are strings before injection. */ import type { Page } from 'playwright'; export interface JsInjectionOpts { /** Script to run on every page BEFORE navigation (via page.addInitScript) */ initScript?: string; /** Script to run on every page AFTER navigation completes (via page.evaluate) */ postLoadScript?: string; /** Key-value pairs to set in localStorage before page load */ localStorage?: Record; /** Key-value pairs to set in sessionStorage before page load */ sessionStorage?: Record; /** Cookie key-value pairs to add before page load */ cookies?: Array<{ name: string; value: string; domain?: string; path?: string }>; } export interface JsInjectionResult { initScriptApplied: boolean; postLoadResult: unknown; storageSet: boolean; cookiesSet: number; error?: string; } export async function injectCustomJs(page: Page, opts: JsInjectionOpts): Promise { const result: JsInjectionResult = { initScriptApplied: false, postLoadResult: null, storageSet: false, cookiesSet: 0, }; // Set cookies via Playwright context if (opts.cookies?.length) { try { await page.context().addCookies( opts.cookies.map(c => ({ name: c.name, value: c.value, domain: c.domain ?? '', path: c.path ?? '/', url: page.url() || undefined })) ); result.cookiesSet = opts.cookies.length; } catch (e: any) { result.error = `Cookie injection failed: ${e.message}`; } } // Init script (runs before page load on every navigation) if (opts.initScript || opts.localStorage || opts.sessionStorage) { const storageSetup = [ opts.localStorage ? `Object.entries(${JSON.stringify(opts.localStorage)}).forEach(([k,v])=>localStorage.setItem(k,v));` : '', opts.sessionStorage ? `Object.entries(${JSON.stringify(opts.sessionStorage)}).forEach(([k,v])=>sessionStorage.setItem(k,v));` : '', ].filter(Boolean).join('\n'); const combinedInit = [storageSetup, opts.initScript ?? ''].filter(Boolean).join('\n'); try { await page.addInitScript(combinedInit); result.initScriptApplied = true; result.storageSet = !!(opts.localStorage || opts.sessionStorage); } catch (e: any) { result.error = `Init script failed: ${e.message}`; } } // Post-load script (runs after navigation) if (opts.postLoadScript) { try { result.postLoadResult = await page.evaluate(opts.postLoadScript); } catch (e: any) { result.error = `Post-load script failed: ${e.message}`; } } return result; } /** Safe string validator — ensures script is a string, not an object/function reference */ export function validateScript(script: unknown): string { if (typeof script !== 'string') throw new Error('Script must be a string'); if (script.length > 50_000) throw new Error('Script too large (>50KB)'); return script; }