Integrate PageSpeed Insights audits
This commit is contained in:
544
lib/pagespeed-insights.ts
Normal file
544
lib/pagespeed-insights.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user