Files
pitchfast/lib/pagespeed-audit-input.ts

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