Integrate PageSpeed Insights audits

This commit is contained in:
2026-06-04 22:12:59 +02:00
parent 99d61ac736
commit f0a948aec9
19 changed files with 3755 additions and 12 deletions

View File

@@ -0,0 +1,544 @@
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),
};
}

544
lib/pagespeed-insights.ts Normal file
View File

@@ -0,0 +1,544 @@
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<unknown>;
}>;
const PAGESPEED_ENDPOINT =
"https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed";
const DEFAULT_TIMEOUT_MS = 10_000;
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object") {
return null;
}
return value as Record<string, unknown>;
}
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<unknown> },
swallowParseErrors: boolean,
): Promise<unknown> {
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<unknown> {
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);
}
}