545 lines
14 KiB
TypeScript
545 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|