import type { BrowserContext } from 'playwright'; import { redactJson } from './policies/crawl-safety.js'; export interface ApiEndpointObservationInput { method: string; urlPattern: string; statusCode?: number; requestKind?: string; responseShape?: unknown; durationMs?: number; errorBody?: string; } const API_PATTERN = /\/(api|graphql|gql|rest|v\d+)\//i; const ASSET_EXT = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map|json\.map)(\?|$)/i; function normalizeApiUrl(url: string): string { const u = new URL(url); return u.pathname .replace(/\/[a-f0-9-]{8,}(?=\/|$)/gi, '/{id}') .replace(/\/c[a-z0-9]{20,}(?=\/|$)/g, '/{id}') .replace(/\/\d+(?=\/|$)/g, '/{id}'); } function inferShape(value: unknown): unknown { if (Array.isArray(value)) return value.length > 0 ? [inferShape(value[0])] : []; if (value && typeof value === 'object') { const out: Record = {}; for (const [key, item] of Object.entries(value).slice(0, 40)) out[key] = inferShape(item); return out; } return typeof value; } /** P4: Extract page-URL candidates from a JSON response body string. */ function extractPageUrls(jsonStr: string): string[] { const PAGE_URL = /"(?:url|href|path|link|route|to|src|redirect)"\s*:\s*"(\/[\w\-\/\.~@%]+)"/g; const ASSET = /\.(png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|css|js|map|json)$/i; const API_PATH = /^\/(api|v\d+|graphql|gql|rest|rpc|static|_next|assets)\//i; const found = new Set(); let m: RegExpExecArray | null; PAGE_URL.lastIndex = 0; while ((m = PAGE_URL.exec(jsonStr)) !== null) { const u = m[1]; if (u && u.length > 1 && u.length < 120 && !ASSET.test(u) && !API_PATH.test(u)) found.add(u); } return [...found]; } export function attachNetworkObserver( context: BrowserContext, sink: ApiEndpointObservationInput[], onPageUrlDiscovered?: (relPath: string) => void, ) { const seen = new Map(); const requestTimes = new Map(); context.on('request', (request) => { try { const url = request.url(); const rtype = request.resourceType(); if ((rtype !== 'xhr' && rtype !== 'fetch') || !API_PATTERN.test(url) || ASSET_EXT.test(url)) return; const method = request.method(); const normalized = normalizeApiUrl(url); const key = `${method}:${normalized}`; requestTimes.set(url, Date.now()); if (!seen.has(key)) { const row = { method, urlPattern: normalized, requestKind: rtype }; seen.set(key, row); sink.push(row); } } catch { // non-fatal } }); context.on('response', async (response) => { try { const request = response.request(); const url = request.url(); const rtype = request.resourceType(); if ((rtype !== 'xhr' && rtype !== 'fetch') || !API_PATTERN.test(url) || ASSET_EXT.test(url)) return; const normalized = normalizeApiUrl(url); const key = `${request.method()}:${normalized}`; const durationMs = Date.now() - (requestTimes.get(url) ?? Date.now()); let row = seen.get(key); if (!row) { row = { method: request.method(), urlPattern: normalized, requestKind: rtype }; seen.set(key, row); sink.push(row); } row.statusCode = response.status(); row.durationMs = durationMs; const contentType = response.headers()['content-type'] ?? ''; if (contentType.includes('application/json')) { const body = await response.json().catch(() => undefined); if (body !== undefined) { row.responseShape = redactJson(inferShape(body)); // P4: scan JSON body for page-URL candidates (non-API paths) if (onPageUrlDiscovered) { for (const u of extractPageUrls(JSON.stringify(body))) onPageUrlDiscovered(u); } } } if (response.status() >= 400) { try { const errText = await response.text(); row.errorBody = errText.slice(0, 300); } catch { /* non-fatal */ } } } catch { // non-fatal } }); } // ── Full HAR capture ────────────────────────────────────────────────────────── export interface HarEntry { startedAt: string; method: string; url: string; status: number; statusText: string; requestHeaders: Record; responseHeaders: Record; contentType: string; bodySize: number; durationMs: number; timings: { send: number; wait: number; receive: number }; } export interface HarLog { version: '1.2'; creator: { name: 'ZeTa-Crawler'; version: '1.0' }; pages: Array<{ id: string; title: string; startedAt: string }>; entries: HarEntry[]; } /** * Full HAR capture via CDP — intercepts ALL network traffic, not just API endpoints. * Returns a controller with getHar() and detach(). */ export function attachHarCapture(cdpSession: any, pageTitle = 'Page'): { getHar(): HarLog; detach(): void } { const entries: HarEntry[] = []; const pending = new Map; startMs: number }>(); const pageId = `page_${String(Date.now())}`; const pageStarted = new Date().toISOString(); const onRequest = (evt: any) => { const { requestId, request } = evt ?? {}; if (!requestId || !request) return; const headers: Record = {}; for (const h of (request.headers ?? [])) if (h.name) headers[String(h.name).toLowerCase()] = String(h.value ?? ''); pending.set(requestId, { method: request.method ?? 'GET', url: request.url ?? '', headers, startMs: Date.now() }); }; const onResponse = (evt: any) => { const { requestId, response } = evt ?? {}; if (!requestId || !response) return; const req = pending.get(requestId); if (!req) return; pending.delete(requestId); const durationMs = Date.now() - req.startMs; const responseHeaders: Record = {}; for (const h of (response.headers ?? [])) if (h.name) responseHeaders[String(h.name).toLowerCase()] = String(h.value ?? ''); entries.push({ startedAt: new Date(req.startMs).toISOString(), method: req.method, url: req.url, status: response.status ?? 0, statusText: response.statusText ?? '', requestHeaders: req.headers, responseHeaders, contentType: responseHeaders['content-type'] ?? '', bodySize: response.encodedDataLength ?? -1, durationMs, timings: { send: 0, wait: Math.round(durationMs * 0.9), receive: Math.round(durationMs * 0.1) }, }); }; if (cdpSession?.on) { cdpSession.on('Network.requestWillBeSent', onRequest); cdpSession.on('Network.responseReceived', onResponse); } return { getHar(): HarLog { return { version: '1.2', creator: { name: 'ZeTa-Crawler', version: '1.0' }, pages: [{ id: pageId, title: pageTitle, startedAt: pageStarted }], entries: [...entries], }; }, detach() { if (cdpSession?.off) { cdpSession.off('Network.requestWillBeSent', onRequest); cdpSession.off('Network.responseReceived', onResponse); } pending.clear(); }, }; }