export type PageSpeedStrategy = "mobile" | "desktop"; export type PageSpeedAuditResultStatus = "succeeded" | "failed"; export type PageSpeedAuditErrorType = | "quota" | "timeout" | "unavailable" | "invalid_url" | "api_error" | "unknown"; export type PageSpeedAuditScores = { performance?: number; accessibility?: number; bestPractices?: number; seo?: number; }; export type PageSpeedAuditMetrics = { firstContentfulPaintMs?: number; largestContentfulPaintMs?: number; cumulativeLayoutShift?: number; totalBlockingTimeMs?: number; speedIndexMs?: number; }; export type PageSpeedAuditNormalized = { metrics?: PageSpeedAuditMetrics; scores?: PageSpeedAuditScores; opportunities?: string[]; implications?: string[]; }; export type PageSpeedMinimalAuditResult = { strategy: PageSpeedStrategy; status: PageSpeedAuditResultStatus; sourceUrl: string; finalUrl?: string; normalized?: PageSpeedAuditNormalized; errorType?: PageSpeedAuditErrorType; errorSummary?: string; }; export type PageSpeedAuditInputs = { technicalSignals: string[]; customerImplications: string[]; internalNotes: string[]; }; type FailureContext = Readonly<{ status: string; sourceUrl: string; strategy: PageSpeedStrategy; errorType?: PageSpeedAuditErrorType; errorSummary?: string; }>; const CUSTOMER_IMPLICATION_LIMIT = 8; const TECHNICAL_SIGNAL_LIMIT = 8; const INTERNAL_NOTE_LIMIT = 6; const SCORE_WORD_PATTERN = /\bscore\b/i; const SCORE_NUMBER_PATTERN = /\b0?\.\d+\b|\b1(?:\.0+)?\b|\b[2-9]\d*\b/; const RAW_STORAGE_PATTERN = /\braw\s*storage\s*id\b/i; const PAGE_SPEED_PATTERN = /\bpagespeed\b/i; const LIGHTHOUSE_PATTERN = /\blighthouse\b/i; const URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s<>"']+/i; const MARKUP_PATTERN = /<[^>]+>/; const JSON_BRACKET_PATTERN = /\{[^}]*\}|\[[^\]]*\]/; const SUSPICIOUS_MACHINE_TOKEN_PATTERN = /\b[a-z\d_-]{24,}\b/i; const PUBLIC_MACHINE_KEYWORDS_PATTERN = /\b(?:raw\s*storage\s*id|rawstorageid|lighthouseresult|lighthouse|pagespeed|score)\b/i; function toTrimmedText(value: unknown): string { if (typeof value !== "string") { return ""; } return value.replace(/\s+/g, " ").trim(); } function containsUntrustedPublicText(value: string): boolean { if (URL_PATTERN.test(value)) { return true; } if (MARKUP_PATTERN.test(value)) { return true; } if (JSON_BRACKET_PATTERN.test(value)) { return true; } if (PUBLIC_MACHINE_KEYWORDS_PATTERN.test(value)) { return true; } if (SUSPICIOUS_MACHINE_TOKEN_PATTERN.test(value)) { return true; } return false; } function isLikelyPlainGermanSentence(value: string): boolean { if (!/[a-zäöüÄÖÜß]/i.test(value)) { return false; } if (value.length > 500) { return false; } return true; } function stripPublicText(value: string): string { let text = toTrimmedText(value); if (!text) { return ""; } if (containsUntrustedPublicText(text)) { return ""; } if (RAW_STORAGE_PATTERN.test(text) || PAGE_SPEED_PATTERN.test(text) || LIGHTHOUSE_PATTERN.test(text)) { return ""; } text = text.replace(/\b0?\.\d+\b/g, ""); text = text.replace(/\d+/g, ""); text = text.trim().replace(/\s{2,}/g, " "); text = text.replace(/^[:\s]+/, ""); text = text.trim(); if (!isLikelyPlainGermanSentence(text)) { return ""; } if (!text) { return ""; } if (SUSPICIOUS_MACHINE_TOKEN_PATTERN.test(text)) { return ""; } if (/[<{[\]}]/.test(text)) { return ""; } if (PAGE_SPEED_PATTERN.test(text) || LIGHTHOUSE_PATTERN.test(text)) { return ""; } text = text.replace(/\s{2,}/g, " ").trim(); return text; } function stripInternalText(value: string): string { let text = toTrimmedText(value); if (!text) { return ""; } if (RAW_STORAGE_PATTERN.test(text)) { return ""; } if (URL_PATTERN.test(text)) { return ""; } if (MARKUP_PATTERN.test(text)) { return ""; } if (JSON_BRACKET_PATTERN.test(text)) { return ""; } text = text.replace(/^\s*score\s*[:\-]?\s*\d+(?:\.\d+)?\s*/i, ""); text = text.replace(/\{\s*[^}]*\}\s*/g, ""); text = text.replace(/\[[^\]]*\]\s*/g, ""); text = text.replace(SCORE_WORD_PATTERN, ""); text = text.replace(SCORE_NUMBER_PATTERN, ""); text = text.replace(/\b\d+(?:\.\d+)?\b/g, ""); text = text.replace(/^[:\s]+/, ""); text = text.trim().replace(/\s{2,}/g, " "); return text; } function addUniqueCapped( bucket: string[], text: string, max: number, sanitize: (value: string) => string = stripPublicText, ): void { const candidate = sanitize(text); if (!candidate) { return; } const normalized = candidate.toLowerCase().replace(/\s+/g, " "); const duplicate = bucket.some( (existing) => existing.toLowerCase().replace(/\s+/g, " ") === normalized, ); if (!duplicate && bucket.length < max) { bucket.push(candidate); } } function hasMetricGap( mobileValue: number | undefined, desktopValue: number | undefined, significantFactor = 1.25, ): boolean { if (mobileValue === undefined || desktopValue === undefined) { return false; } if (mobileValue <= desktopValue) { return false; } if (desktopValue <= 0) { return true; } return mobileValue >= desktopValue * significantFactor; } function addMobileWorseMessage( mobile: PageSpeedAuditMetrics | undefined, desktop: PageSpeedAuditMetrics | undefined, technicalSignals: string[], customerImplications: string[], ) { if (!mobile || !desktop) { return; } const fcpGap = hasMetricGap( mobile.firstContentfulPaintMs, desktop.firstContentfulPaintMs, ); const lcpGap = hasMetricGap( mobile.largestContentfulPaintMs, desktop.largestContentfulPaintMs, ); const tbtGap = hasMetricGap( mobile.totalBlockingTimeMs, desktop.totalBlockingTimeMs, 1.35, ); const speedGap = hasMetricGap( mobile.speedIndexMs, desktop.speedIndexMs, 1.25, ); const clsGap = hasMetricGap( mobile.cumulativeLayoutShift, desktop.cumulativeLayoutShift, 1.2, ); if (!(fcpGap || lcpGap || tbtGap || speedGap || clsGap)) { return; } const gapSentence = "Die mobile Version ist deutlich langsamer als die Desktop-Variante."; const mobileFirstSentence = "Auf Mobilgeraten verlieren Kunden dadurch frueher den ersten Eindruck."; addUniqueCapped(technicalSignals, gapSentence, TECHNICAL_SIGNAL_LIMIT); addUniqueCapped(technicalSignals, mobileFirstSentence, TECHNICAL_SIGNAL_LIMIT); addUniqueCapped( customerImplications, "Die mobile Version ist deutlich langsamer als die Desktop-Variante.", CUSTOMER_IMPLICATION_LIMIT, ); addUniqueCapped( customerImplications, "Kunden auf dem Telefon warten laenger und brechen den Erstkontakt schneller ab.", CUSTOMER_IMPLICATION_LIMIT, ); } function addScoreBasedSignals( scores: PageSpeedAuditScores | undefined, technicalSignals: string[], customerImplications: string[], ) { if (!scores) { return; } if ((scores.accessibility ?? 1) < 0.9) { addUniqueCapped( technicalSignals, "Barrierefreiheit und Bedienbarkeit sollten fuer alle Nutzerinnen und Nutzer verbessert werden.", TECHNICAL_SIGNAL_LIMIT, ); addUniqueCapped( customerImplications, "Einfacher zugang und bessere Bedienbarkeit helfen mehr Interessenten zu erreichen.", CUSTOMER_IMPLICATION_LIMIT, ); } if ((scores.seo ?? 1) < 0.9) { addUniqueCapped( technicalSignals, "Technische Signale deuten auf reduzierte lokale Auffindbarkeit hin.", TECHNICAL_SIGNAL_LIMIT, ); addUniqueCapped( customerImplications, "Lokale Sichtbarkeit kann dadurch bei Neukundenanfragen sinken.", CUSTOMER_IMPLICATION_LIMIT, ); } if ((scores.performance ?? 1) < 0.9) { addUniqueCapped( customerImplications, "Wahrnehmbare Wartezeiten auf der Seite koennen das Vertrauen in den Auftritt mindern.", CUSTOMER_IMPLICATION_LIMIT, ); } } function addMetricSignals( metrics: PageSpeedAuditMetrics | undefined, technicalSignals: string[], customerImplications: string[], ) { if (!metrics) { return; } if ((metrics.firstContentfulPaintMs ?? 0) > 2500) { addUniqueCapped( technicalSignals, "Erster sichtbarer Inhalt erscheint deutlich verzoegert.", TECHNICAL_SIGNAL_LIMIT, ); addUniqueCapped( customerImplications, "Der erste sichtbare Inhalt erscheint spuetbar zu langsam.", CUSTOMER_IMPLICATION_LIMIT, ); } if ((metrics.largestContentfulPaintMs ?? 0) > 4200) { addUniqueCapped( technicalSignals, "Das wichtigste Inhaltselement wird stark verzoegert sichtbar.", TECHNICAL_SIGNAL_LIMIT, ); addUniqueCapped( customerImplications, "Wichtige Inhalte erscheinen zu spaet, was den ersten Eindruck schwaecht.", CUSTOMER_IMPLICATION_LIMIT, ); } if ((metrics.totalBlockingTimeMs ?? 0) > 300) { addUniqueCapped( technicalSignals, "Interaktion und Reaktionszeit sind stark beeintraechtigt.", TECHNICAL_SIGNAL_LIMIT, ); addUniqueCapped( customerImplications, "Bedienaktionen wirken traege und fuehren schneller zu Abbruechen.", CUSTOMER_IMPLICATION_LIMIT, ); } if ((metrics.speedIndexMs ?? 0) > 3500) { addUniqueCapped( technicalSignals, "Die visuelle Komplettierung der Seite verzoegert sich deutlich.", TECHNICAL_SIGNAL_LIMIT, ); addUniqueCapped( customerImplications, "Die Seite wirkt insgesamt schleppend aufgebaut und reduziert die Nutzungsbereitschaft.", CUSTOMER_IMPLICATION_LIMIT, ); } if ((metrics.cumulativeLayoutShift ?? 0) > 0.1) { addUniqueCapped( technicalSignals, "Instabile Layout-Werte weisen auf Spruenge in der Seitendarstellung hin.", TECHNICAL_SIGNAL_LIMIT, ); addUniqueCapped( customerImplications, "Elemente, die beim Laden verschieben, wirken unruhig und schwaechen Vertrauen.", CUSTOMER_IMPLICATION_LIMIT, ); } } function addFailureNote(input: FailureContext, internalNotes: string[]) { if (input.errorType === "quota") { addUniqueCapped( internalNotes, "Die Abfrage wurde wegen Quota-Limit abgebrochen.", INTERNAL_NOTE_LIMIT, stripInternalText, ); return; } if (input.errorType === "unavailable") { addUniqueCapped( internalNotes, "Die Zielseite war nicht erreichbar.", INTERNAL_NOTE_LIMIT, stripInternalText, ); return; } if (input.errorType === "timeout") { addUniqueCapped( internalNotes, "Der Aufruf wurde wegen Timeout beendet.", INTERNAL_NOTE_LIMIT, stripInternalText, ); return; } const base = input.errorType === "invalid_url" ? "Die Zieladresse wurde als ungueltig bewertet" : "Der Lauf wurde mit technischem Fehler abgeschlossen"; const summary = stripInternalText(input.errorSummary || ""); const full = summary ? `${base}: ${summary}` : base; addUniqueCapped(internalNotes, full, INTERNAL_NOTE_LIMIT, stripInternalText); } export function assertNoPublicPageSpeedScores(value: unknown): boolean { const lines = Array.isArray(value) ? value : [value]; for (const line of lines) { if (typeof line !== "string" || !line.trim()) { continue; } if (containsUntrustedPublicText(line)) { return false; } const asString = String(line); if (/\bscore\b/i.test(asString) || /\b\d+\b/.test(asString) || /\b\d+\.\d+\b/.test(asString)) { return false; } } return true; } export function buildPageSpeedAuditInputs( results: readonly PageSpeedMinimalAuditResult[], ): PageSpeedAuditInputs { const technicalSignals: string[] = []; const customerImplications: string[] = []; const internalNotes: string[] = []; const list = Array.isArray(results) ? results : []; let mobileResult: PageSpeedMinimalAuditResult | undefined; let desktopResult: PageSpeedMinimalAuditResult | undefined; for (const result of list) { if (result.status === "succeeded") { const normalized = result.normalized ?? {}; if (result.strategy === "mobile") { mobileResult = result; } else { desktopResult = result; } for (const implication of normalized.implications ?? []) { addUniqueCapped(customerImplications, implication, CUSTOMER_IMPLICATION_LIMIT); } for (const opportunity of normalized.opportunities ?? []) { addUniqueCapped( technicalSignals, `Moegliche Optimierung: ${opportunity}`, TECHNICAL_SIGNAL_LIMIT, ); } addMetricSignals(normalized.metrics, technicalSignals, customerImplications); addScoreBasedSignals( normalized.scores, technicalSignals, customerImplications, ); continue; } addFailureNote(result, internalNotes); } addMobileWorseMessage( mobileResult?.normalized?.metrics, desktopResult?.normalized?.metrics, technicalSignals, customerImplications, ); return { technicalSignals, customerImplications: customerImplications.slice( 0, CUSTOMER_IMPLICATION_LIMIT, ), internalNotes: internalNotes.slice(0, INTERNAL_NOTE_LIMIT), }; }