263 lines
7.2 KiB
TypeScript
263 lines
7.2 KiB
TypeScript
import { z } from "zod";
|
|
import { LIGHTHOUSE_CATEGORIES } from "@/shared/lighthouse";
|
|
|
|
export type RawLighthouseAudit = {
|
|
title?: string;
|
|
description?: string;
|
|
score?: number | null;
|
|
scoreDisplayMode?: string;
|
|
displayValue?: string;
|
|
numericValue?: number;
|
|
details?: {
|
|
overallSavingsMs?: number;
|
|
overallSavingsBytes?: number;
|
|
items?: Array<Record<string, unknown>>;
|
|
};
|
|
};
|
|
|
|
export type RawLighthouseCategory = {
|
|
score?: number | null;
|
|
auditRefs?: Array<{
|
|
id?: string;
|
|
}>;
|
|
};
|
|
|
|
const storedLighthouseMetricSchema = z.object({
|
|
score: z.number().nullable(),
|
|
displayValue: z.string().nullable(),
|
|
numericValue: z.number().nullable(),
|
|
});
|
|
|
|
const storedLighthouseMetricsSchema = z.object({
|
|
firstContentfulPaint: storedLighthouseMetricSchema,
|
|
largestContentfulPaint: storedLighthouseMetricSchema,
|
|
totalBlockingTime: storedLighthouseMetricSchema,
|
|
cumulativeLayoutShift: storedLighthouseMetricSchema,
|
|
speedIndex: storedLighthouseMetricSchema,
|
|
timeToInteractive: storedLighthouseMetricSchema,
|
|
interactionToNextPaint: storedLighthouseMetricSchema,
|
|
serverResponseTime: storedLighthouseMetricSchema,
|
|
});
|
|
|
|
const storedLighthouseIssueSchema = z.object({
|
|
category: z.enum(LIGHTHOUSE_CATEGORIES),
|
|
auditKey: z.string(),
|
|
title: z.string(),
|
|
description: z.string(),
|
|
score: z.number().nullable(),
|
|
scoreDisplayMode: z.string().nullable(),
|
|
displayValue: z.string().nullable(),
|
|
impactMs: z.number().nullable(),
|
|
impactBytes: z.number().nullable(),
|
|
severity: z.enum(["critical", "warning", "info"]),
|
|
items: z.array(z.string()),
|
|
});
|
|
|
|
export const storedLighthousePayloadSchema = z.object({
|
|
version: z.literal(2),
|
|
source: z.literal("dataforseo-lighthouse"),
|
|
hasIssueDetails: z.boolean(),
|
|
metadata: z.object({
|
|
requestedUrl: z.string(),
|
|
finalUrl: z.string(),
|
|
strategy: z.enum(["mobile", "desktop"]),
|
|
fetchedAt: z.string(),
|
|
lighthouseVersion: z.string().nullable(),
|
|
taskId: z.string().nullable(),
|
|
cost: z.number().nullable(),
|
|
}),
|
|
scores: z.object({
|
|
performance: z.number().nullable(),
|
|
accessibility: z.number().nullable(),
|
|
"best-practices": z.number().nullable(),
|
|
seo: z.number().nullable(),
|
|
}),
|
|
metrics: storedLighthouseMetricsSchema,
|
|
issues: z.array(storedLighthouseIssueSchema),
|
|
});
|
|
|
|
type StoredLighthouseMetric = z.infer<typeof storedLighthouseMetricSchema>;
|
|
type StoredLighthouseMetrics = z.infer<typeof storedLighthouseMetricsSchema>;
|
|
export type StoredLighthouseIssue = z.infer<typeof storedLighthouseIssueSchema>;
|
|
export type StoredLighthousePayload = z.infer<
|
|
typeof storedLighthousePayloadSchema
|
|
>;
|
|
|
|
export function scoreToPercent(
|
|
score: number | null | undefined,
|
|
): number | null {
|
|
if (score == null || Number.isNaN(score)) return null;
|
|
return Math.round(score * 100);
|
|
}
|
|
|
|
function buildStoredMetric(
|
|
audit: RawLighthouseAudit | undefined,
|
|
): StoredLighthouseMetric {
|
|
return {
|
|
score: scoreToPercent(audit?.score),
|
|
displayValue: audit?.displayValue ?? null,
|
|
numericValue:
|
|
typeof audit?.numericValue === "number" ? audit.numericValue : null,
|
|
};
|
|
}
|
|
|
|
const DIAGNOSTIC_AUDIT_KEYS = new Set([
|
|
"largest-contentful-paint-element",
|
|
"layout-shifts",
|
|
"diagnostics",
|
|
"metrics",
|
|
"network-requests",
|
|
"network-rtt",
|
|
"network-server-latency",
|
|
"main-thread-tasks",
|
|
"screenshot-thumbnails",
|
|
"final-screenshot",
|
|
"script-treemap-data",
|
|
"resource-summary",
|
|
]);
|
|
|
|
function compactItem(item: Record<string, unknown>): string {
|
|
const preferredKeys = [
|
|
"url",
|
|
"source",
|
|
"nodeLabel",
|
|
"snippet",
|
|
"totalBytes",
|
|
"wastedBytes",
|
|
"wastedMs",
|
|
"label",
|
|
"value",
|
|
];
|
|
|
|
const output: Record<string, unknown> = {};
|
|
for (const key of preferredKeys) {
|
|
if (item[key] != null) {
|
|
output[key] = item[key];
|
|
}
|
|
}
|
|
|
|
if (Object.keys(output).length === 0) {
|
|
for (const [key, value] of Object.entries(item).slice(0, 6)) {
|
|
output[key] = value;
|
|
}
|
|
}
|
|
|
|
return JSON.stringify(output);
|
|
}
|
|
|
|
function getSeverity(input: {
|
|
score: number | null;
|
|
impactMs: number | null;
|
|
impactBytes: number | null;
|
|
}): "critical" | "warning" | "info" {
|
|
if ((input.impactMs ?? 0) >= 300 || (input.impactBytes ?? 0) >= 150_000) {
|
|
return "critical";
|
|
}
|
|
|
|
if (input.score != null && input.score < 50) {
|
|
return "critical";
|
|
}
|
|
|
|
if ((input.impactMs ?? 0) >= 100 || (input.impactBytes ?? 0) >= 50_000) {
|
|
return "warning";
|
|
}
|
|
|
|
if (input.score != null && input.score < 90) {
|
|
return "warning";
|
|
}
|
|
|
|
return "info";
|
|
}
|
|
|
|
export function buildStoredLighthouseIssues(input: {
|
|
audits: Record<string, RawLighthouseAudit>;
|
|
categories: Record<string, RawLighthouseCategory>;
|
|
}) {
|
|
const hasIssueDetails = LIGHTHOUSE_CATEGORIES.some(
|
|
(category) => (input.categories[category]?.auditRefs?.length ?? 0) > 0,
|
|
);
|
|
|
|
const issues: StoredLighthouseIssue[] = [];
|
|
|
|
for (const category of LIGHTHOUSE_CATEGORIES) {
|
|
const refs = input.categories[category]?.auditRefs ?? [];
|
|
for (const ref of refs) {
|
|
const auditKey = ref.id;
|
|
if (!auditKey) continue;
|
|
|
|
const audit = input.audits[auditKey];
|
|
if (!audit) continue;
|
|
|
|
const score = scoreToPercent(audit.score);
|
|
const scoreDisplayMode = audit.scoreDisplayMode ?? null;
|
|
|
|
if (scoreDisplayMode === "numeric") continue;
|
|
if (DIAGNOSTIC_AUDIT_KEYS.has(auditKey)) continue;
|
|
|
|
const isPass =
|
|
score == null ||
|
|
score >= 90 ||
|
|
scoreDisplayMode === "notApplicable" ||
|
|
scoreDisplayMode === "informative" ||
|
|
scoreDisplayMode === "manual" ||
|
|
scoreDisplayMode === "error";
|
|
|
|
if (isPass) continue;
|
|
|
|
const impactMs =
|
|
typeof audit.details?.overallSavingsMs === "number"
|
|
? audit.details.overallSavingsMs
|
|
: null;
|
|
const impactBytes =
|
|
typeof audit.details?.overallSavingsBytes === "number"
|
|
? audit.details.overallSavingsBytes
|
|
: null;
|
|
const items = Array.isArray(audit.details?.items)
|
|
? audit.details.items.slice(0, 10).map(compactItem)
|
|
: [];
|
|
|
|
issues.push({
|
|
category,
|
|
auditKey,
|
|
title: audit.title ?? auditKey,
|
|
description: audit.description ?? "",
|
|
score,
|
|
scoreDisplayMode,
|
|
displayValue: audit.displayValue ?? null,
|
|
impactMs,
|
|
impactBytes,
|
|
severity: getSeverity({ score, impactMs, impactBytes }),
|
|
items,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
hasIssueDetails,
|
|
issues,
|
|
};
|
|
}
|
|
|
|
export function buildStoredLighthouseMetrics(input: {
|
|
audits: Record<string, RawLighthouseAudit>;
|
|
}): StoredLighthouseMetrics {
|
|
return {
|
|
firstContentfulPaint: buildStoredMetric(
|
|
input.audits["first-contentful-paint"],
|
|
),
|
|
largestContentfulPaint: buildStoredMetric(
|
|
input.audits["largest-contentful-paint"],
|
|
),
|
|
totalBlockingTime: buildStoredMetric(input.audits["total-blocking-time"]),
|
|
cumulativeLayoutShift: buildStoredMetric(
|
|
input.audits["cumulative-layout-shift"],
|
|
),
|
|
speedIndex: buildStoredMetric(input.audits["speed-index"]),
|
|
timeToInteractive: buildStoredMetric(input.audits.interactive),
|
|
interactionToNextPaint: buildStoredMetric(
|
|
input.audits["interaction-to-next-paint"],
|
|
),
|
|
serverResponseTime: buildStoredMetric(input.audits["server-response-time"]),
|
|
};
|
|
}
|