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