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

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);
}
}