export type PageSpeedStrategy = "mobile" | "desktop"; export type PageSpeedErrorType = | "quota" | "timeout" | "unavailable" | "invalid_url" | "api_error" | "unknown"; export type PageSpeedScores = { performance?: number; accessibility?: number; bestPractices?: number; seo?: number; }; export type PageSpeedMetrics = { firstContentfulPaintMs?: number; largestContentfulPaintMs?: number; cumulativeLayoutShift?: number; totalBlockingTimeMs?: number; speedIndexMs?: number; }; export type PageSpeedNormalizedResult = { strategy: PageSpeedStrategy; sourceUrl: string; finalUrl?: string; analysisTimestamp?: string; scores?: PageSpeedScores; metrics: PageSpeedMetrics; opportunities: string[]; implications: string[]; }; type ClassifiedError = { errorType: PageSpeedErrorType; message: string; }; type FetchLike = ( input: string, init: { signal?: AbortSignal } | undefined, ) => Promise<{ ok: boolean; status: number; json: () => Promise; }>; const PAGESPEED_ENDPOINT = "https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed"; const DEFAULT_TIMEOUT_MS = 10_000; function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object") { return null; } return value as Record; } function asString(value: unknown): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function asNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; } return null; } function safeToLower(value: unknown): string { if (typeof value === "string") { return value.toLowerCase(); } if (value instanceof Error) { return `${value.name} ${value.message}`.toLowerCase(); } if (value == null) { return ""; } if (typeof value === "object") { try { return JSON.stringify(value).toLowerCase(); } catch { return ""; } } return ""; } function hasPattern(value: unknown, patterns: string[]): boolean { const lower = safeToLower(value); return patterns.some((pattern) => lower.includes(pattern)); } function firstNonEmptyString(...values: unknown[]): string | null { for (const value of values) { const stringValue = asString(value); if (stringValue) { return stringValue; } } return null; } function extractPageSpeedErrorMessage(body: unknown): string | null { const bodyRecord = asRecord(body); const error = asRecord(bodyRecord?.error); const lighthouseResult = asRecord(bodyRecord?.lighthouseResult); const runtimeError = asRecord(lighthouseResult?.runtimeError); return firstNonEmptyString( error?.message, bodyRecord?.error_message, runtimeError?.message, ); } function buildImpactStatements( scores: PageSpeedScores, metrics: PageSpeedMetrics, ) { const implications: string[] = []; if ((scores.performance ?? 1) < 0.9) { implications.push( "Die allgemeine Seitengeschwindigkeit wirkt noch deutlich verbesserungswürdig.", ); } if ((metrics.firstContentfulPaintMs ?? 0) > 2_000) { implications.push( "Besucher sehen den ersten sichtbaren Inhalt auf der Seite zu langsam.", ); } if ((metrics.largestContentfulPaintMs ?? 0) > 3_000) { implications.push( "Der wichtigste Inhalt wird erst verspätet vollständig sichtbar.", ); } if ((metrics.cumulativeLayoutShift ?? 0) > 0.1) { implications.push( "Inhalte springen beim Laden nach, was den wahrgenommenen Seitenkomfort mindert.", ); } if ((metrics.totalBlockingTimeMs ?? 0) > 300) { implications.push( "Lange Blockierungszeiten können das Bediengefühl auf der Seite merklich spürbar verlangsamen.", ); } if ((metrics.speedIndexMs ?? 0) > 3_500) { implications.push( "Der visuelle Seitenaufbau ist verzögert und die Wahrnehmung der Seitenqualität leidet.", ); } if ((scores.bestPractices ?? 1) < 0.85) { implications.push( "Es gibt mehrere technische Best-Practice-Punkte, die aktuell noch nachgebessert werden sollten.", ); } if ((scores.accessibility ?? 1) < 0.9) { implications.push( "Die Barrierefreiheit sollte verbessert werden, damit alle Nutzerinnen und Nutzer die Seite besser erreichen.", ); } return implications; } function normalizePageSpeedAnalysisTimestamp(raw: unknown): string | undefined { const value = asString( asRecord(raw)?.analysisUTCTimestamp ?? asRecord(raw)?.analysisTimestamp, ); return value ?? undefined; } function normalizePageSpeedScores(lighthouseResult: Record) { const categories = asRecord(lighthouseResult.categories) ?? {}; const scores: PageSpeedScores = {}; const performance = asNumber( asRecord(categories.performance)?.score, ); if (performance !== null) { scores.performance = performance; } const accessibility = asNumber( asRecord(categories.accessibility)?.score, ); if (accessibility !== null) { scores.accessibility = accessibility; } const bestPractices = asNumber( asRecord(categories["best-practices"])?.score, ); if (bestPractices !== null) { scores.bestPractices = bestPractices; } const seo = asNumber(asRecord(categories.seo)?.score); if (seo !== null) { scores.seo = seo; } return scores; } function normalizePageSpeedMetrics(audits: Record) { const metrics: PageSpeedMetrics = {}; const fcp = asNumber(asRecord(audits["first-contentful-paint"])?.numericValue); if (fcp !== null) { metrics.firstContentfulPaintMs = fcp; } const lcp = asNumber(asRecord(audits["largest-contentful-paint"])?.numericValue); if (lcp !== null) { metrics.largestContentfulPaintMs = lcp; } const cls = asNumber(asRecord(audits["cumulative-layout-shift"])?.numericValue); if (cls !== null) { metrics.cumulativeLayoutShift = cls; } const tbt = asNumber(asRecord(audits["total-blocking-time"])?.numericValue); if (tbt !== null) { metrics.totalBlockingTimeMs = tbt; } const speedIndex = asNumber(asRecord(audits["speed-index"])?.numericValue); if (speedIndex !== null) { metrics.speedIndexMs = speedIndex; } return metrics; } function formatSavingsHint(value: number) { const rounded = Math.abs(Math.round(value)); if (rounded >= 1024) { return `${Math.round(rounded / 1024)} MB`; } return `${rounded} ms`; } function normalizeOpportunities(audits: Record) { const opportunities: string[] = []; for (const [id, rawAudit] of Object.entries(audits)) { const audit = asRecord(rawAudit); if (!audit) { continue; } const details = asRecord(audit.details); const type = asString(details?.type); if (type !== "opportunity") { continue; } const title = asString(audit.title) ?? id; const savingsMs = asNumber(details?.overallSavingsMs); const savingsBytes = asNumber(details?.overallSavingsBytes); if (savingsMs !== null) { opportunities.push(`${title}: ca. ${formatSavingsHint(savingsMs)} Einsparung möglich.`); continue; } if (savingsBytes !== null) { opportunities.push(`${title}: potenziell ${formatSavingsHint(savingsBytes)} weniger Last.`); continue; } const score = asNumber(audit.score); if (score !== null && score < 0.9) { opportunities.push(`${title}: hier ist weiteres Optimierungspotenzial vorhanden.`); } } return opportunities; } export function buildPageSpeedRequestUrl(input: { url: string; strategy: PageSpeedStrategy; apiKey?: string | null; locale?: string; }): string { const requestUrl = new URL(PAGESPEED_ENDPOINT); requestUrl.searchParams.append("url", input.url); requestUrl.searchParams.set("strategy", input.strategy); requestUrl.searchParams.append("category", "performance"); requestUrl.searchParams.append("category", "accessibility"); requestUrl.searchParams.append("category", "best-practices"); requestUrl.searchParams.append("category", "seo"); requestUrl.searchParams.set("locale", input.locale ?? "de-DE"); if (asString(input.apiKey)) { requestUrl.searchParams.set("key", input.apiKey as string); } return requestUrl.toString(); } export function normalizePageSpeedResult(input: { strategy: PageSpeedStrategy; sourceUrl: string; raw: unknown; }): PageSpeedNormalizedResult { const lighthouseResult = asRecord(asRecord(input.raw)?.lighthouseResult) ?? {}; const audits = asRecord(lighthouseResult.audits) ?? {}; const scores = normalizePageSpeedScores(lighthouseResult); const metrics = normalizePageSpeedMetrics(audits); const opportunities = normalizeOpportunities(audits); const implications = buildImpactStatements(scores, metrics); for (let i = implications.length - 1; i >= 0; i -= 1) { if (implications[i] === "") { implications.splice(i, 1); } } const finalUrl = asString(lighthouseResult.finalUrl) ?? undefined; const analysisTimestamp = normalizePageSpeedAnalysisTimestamp(input.raw); const result: PageSpeedNormalizedResult = { strategy: input.strategy, sourceUrl: input.sourceUrl, metrics, opportunities, implications, }; if (finalUrl) { result.finalUrl = finalUrl; } if (analysisTimestamp) { result.analysisTimestamp = analysisTimestamp; } const hasAnyScore = Object.values(scores).some((value) => value !== undefined); if (hasAnyScore) { result.scores = scores; } return result; } export function classifyPageSpeedError(input: { error?: unknown; status?: number; body?: unknown; }): ClassifiedError { const status = Number.isFinite(input?.status) ? Math.trunc(input.status as number) : undefined; const statusBodyText = [input?.error, input?.body, input?.status] .map(safeToLower) .join(" "); const abortLike = input?.error instanceof DOMException ? input.error.name === "AbortError" : false; const errorMessage = safeToLower( input?.error instanceof Error ? input.error.message : input?.error, ); if (input?.error instanceof SyntaxError) { return { errorType: "api_error", message: `PageSpeed-Antwort war kein gültiges JSON: ${errorMessage || "Unbekannt"}`, }; } if (abortLike || errorMessage.includes("abort") && errorMessage.includes("timeout")) { return { errorType: "timeout", message: "PageSpeed-Anfrage wurde wegen Timeout abgebrochen.", }; } if ( hasPattern(statusBodyText, [ "429", "quota", "userratelimit", "rate limit", "user rate limit", ]) || status === 429) { return { errorType: "quota", message: "PageSpeed-Anfrage wurde wegen API-Quota abgelehnt.", }; } if ( status === 404 || hasPattern(statusBodyText, [ "failed document", "failed to fetch document", "could not fetch document", "unreachable document", "could not fetch", "not found", ]) ) { return { errorType: "unavailable", message: "Die analysierte Seite ist aktuell nicht erreichbar.", }; } if ( hasPattern(statusBodyText, [ "invalid url", "invalid_url", "bad url", "malformed url", "url parsing", "unsupported url", "missing required parameter: url", ]) ) { return { errorType: "invalid_url", message: "Die angegebene URL ist nicht valide für PageSpeed.", }; } if (status !== undefined && status >= 400 && status < 600) { const apiMessage = extractPageSpeedErrorMessage(input?.body); return { errorType: "api_error", message: apiMessage ? `PageSpeed-API lieferte einen Fehler: ${apiMessage}` : "PageSpeed-API lieferte einen Fehler.", }; } return { errorType: "unknown", message: input?.error instanceof Error && input.error.message ? `Unbekannter Fehler beim PageSpeed-Zugriff: ${input.error.message}` : "Unbekannter Fehler beim PageSpeed-Zugriff.", }; }; function createPageSpeedError(classification: ClassifiedError): Error { const error = new Error(classification.message); return Object.assign(error, { errorType: classification.errorType, name: `PageSpeedError:${classification.errorType}`, }); } function isPageSpeedError(error: unknown): error is Error & { errorType: PageSpeedErrorType } { return ( typeof error === "object" && error !== null && "errorType" in error && typeof (error as { errorType?: unknown }).errorType === "string" ); } async function parseResponseBody( response: { json: () => Promise }, swallowParseErrors: boolean, ): Promise { try { return await response.json(); } catch (error) { if (swallowParseErrors) { return null; } throw error; } } export async function fetchPageSpeedResult(input: { url: string; strategy: PageSpeedStrategy; apiKey?: string | null; timeoutMs?: number; fetchImpl?: FetchLike; }): Promise { const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const fetchImpl: FetchLike = input.fetchImpl ?? ((fetch as typeof globalThis.fetch) as unknown as FetchLike); const requestUrl = buildPageSpeedRequestUrl({ url: input.url, strategy: input.strategy, apiKey: input.apiKey, }); const controller = new AbortController(); const timer = setTimeout(() => { controller.abort(); }, timeoutMs); try { const response = await fetchImpl(requestUrl, { signal: controller.signal, }); const body = await parseResponseBody(response, !response.ok); if (!response.ok) { const classification = classifyPageSpeedError({ status: response.status, body, }); throw createPageSpeedError(classification); } return body; } catch (error) { if (isPageSpeedError(error)) { throw error; } const classification = classifyPageSpeedError({ error }); if (classification.errorType === "unknown") { throw error; } throw createPageSpeedError(classification); } finally { clearTimeout(timer); } }