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