/** * Redirect chain tracker — records the full redirect chain for crawled URLs. * * Why: redirect chains (A→B→C) slow down pages; circular redirects crash crawlers; * redirect loops indicate config errors. ZeTa records chains for SEO/perf audit. * * Integration: hook into Playwright's page.on('response') to see each redirect hop. */ export interface RedirectHop { from: string; to: string; statusCode: number; durationMs: number; } export interface RedirectChain { originalUrl: string; finalUrl: string; hops: RedirectHop[]; isCircular: boolean; hopCount: number; totalDurationMs: number; } import type { Page } from 'playwright'; export async function trackRedirects(page: Page, url: string): Promise { const hops: RedirectHop[] = []; const seen = new Set(); let lastResponseTime = Date.now(); const onResponse = (response: any) => { const status = response.status(); if (status >= 300 && status < 400) { const from = response.url(); const to = response.headers()['location'] ?? ''; const now = Date.now(); hops.push({ from, to, statusCode: status, durationMs: now - lastResponseTime }); lastResponseTime = now; } }; page.on('response', onResponse); try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {}); } finally { page.off('response', onResponse); } const finalUrl = page.url(); const isCircular = hops.some(h => { if (seen.has(h.to)) return true; seen.add(h.from); return false; }); return { originalUrl: url, finalUrl, hops, isCircular, hopCount: hops.length, totalDurationMs: hops.reduce((sum, h) => sum + h.durationMs, 0), }; } export class RedirectRegistry { private chains = new Map(); register(chain: RedirectChain): void { this.chains.set(chain.originalUrl, chain); } get(url: string): RedirectChain | undefined { return this.chains.get(url); } getAll(): RedirectChain[] { return [...this.chains.values()]; } getProblematic(): RedirectChain[] { return [...this.chains.values()].filter(c => c.isCircular || c.hopCount > 3); } stats(): { total: number; withRedirects: number; circular: number; deepChains: number } { const all = [...this.chains.values()]; return { total: all.length, withRedirects: all.filter(c => c.hopCount > 0).length, circular: all.filter(c => c.isCircular).length, deepChains: all.filter(c => c.hopCount > 3).length, }; } }