metatron-open-seo/src/server/lib/dataforseoLighthousePayload.ts
Ben Senescu 638f5a6602 refactor: move lighthouse audits to dataforseo (#43)
* refactor: move lighthouse audits to dataforseo

* chore: remove obsolete audit settings modal

* refactor: rename psi flows to lighthouse

* save

* refactor: simplify audit lighthouse storage flow

* fix: separate lighthouse metrics from actionable audits

* refactor: remove redundant audit project inputs

* feat: redesign lighthouse issues screen with score gauges and table layout

Replace flat score cards with circular SVG gauges, condense metrics into
a compact grid, and switch issue list from cards to an expandable table
with fixed column widths.

* test: harden lighthouse regression coverage

* fix: restore project-scoped audit inputs

* refactor: simplify lighthouse payload handling

* refactor: inline lighthouse server handlers

* refactor: share audit workflow types

* refactor: simplify lighthouse payload flows

* save

* refactor: drop project pagespeed api key

* fix: restore lighthouse issues loading with resilient project context

* fix: restore audit issues back navigation

* refactor: simplify project context and lighthouse error handling

* fix: tolerate DataForSEO lighthouse payload drift

* refactor: route audit lighthouse through dataforseo client
2026-03-26 20:25:27 -04:00

173 lines
4.8 KiB
TypeScript

import { z } from "zod";
import {
buildStoredLighthouseIssues,
buildStoredLighthouseMetrics,
type RawLighthouseAudit,
type RawLighthouseCategory,
scoreToPercent,
type StoredLighthousePayload,
} from "@/server/lib/lighthouseStoredPayload";
export const requestCategories = [
"performance",
"accessibility",
"best_practices",
"seo",
] as const;
export type LighthouseStrategy = "mobile" | "desktop";
const lighthouseAuditItemsSchema = z
.union([
z.array(z.record(z.string(), z.unknown())),
z.record(z.string(), z.unknown()),
])
.transform((items) => (Array.isArray(items) ? items : [items]));
const lighthouseAuditSchema = z
.object({
score: z.number().nullable().optional(),
displayValue: z.string().optional(),
numericValue: z.number().optional(),
title: z.string().optional(),
description: z.string().optional(),
scoreDisplayMode: z.string().optional(),
details: z
.object({
overallSavingsMs: z.number().optional(),
overallSavingsBytes: z.number().optional(),
items: lighthouseAuditItemsSchema.optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const lighthouseCategorySchema = z
.object({
score: z.number().nullable().optional(),
auditRefs: z
.array(
z
.object({
id: z.string().optional(),
})
.passthrough(),
)
.optional(),
})
.passthrough();
const lighthouseResponseSchema = z
.object({
requestedUrl: z.string().optional(),
finalUrl: z.string().optional(),
lighthouseVersion: z.string().optional(),
categories: z
.record(z.string(), lighthouseCategorySchema)
.optional()
.default({}),
audits: z.record(z.string(), lighthouseAuditSchema).optional().default({}),
})
.passthrough();
const dataforseoTaskSchema = z
.object({
id: z.string().optional(),
cost: z.number().optional(),
status_code: z.number().optional(),
status_message: z.string().optional(),
result: z.array(lighthouseResponseSchema).optional(),
})
.passthrough();
const dataforseoLighthouseResponseSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
tasks: z.array(dataforseoTaskSchema).optional(),
})
.passthrough();
function summarizeZodIssues(error: z.ZodError, maxIssues = 3): string {
return error.issues
.slice(0, maxIssues)
.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
return `${path}: ${issue.message}`;
})
.join("; ");
}
export function parseDataforseoLighthousePayload(
payload: unknown,
input: { url: string; strategy: LighthouseStrategy },
): StoredLighthousePayload {
const parsed = dataforseoLighthouseResponseSchema.safeParse(payload);
if (!parsed.success) {
throw new Error(
`DataForSEO Lighthouse returned an invalid response: ${summarizeZodIssues(parsed.error)}`,
);
}
if (parsed.data.status_code !== 20000) {
throw new Error(
parsed.data.status_message ?? "DataForSEO Lighthouse request failed",
);
}
const task = parsed.data.tasks?.[0];
if (!task) {
throw new Error("DataForSEO Lighthouse response missing task");
}
if (task.status_code !== 20000) {
throw new Error(task.status_message ?? "DataForSEO Lighthouse task failed");
}
const result = task.result?.[0];
if (!result) {
throw new Error("DataForSEO Lighthouse response missing result");
}
const fetchedAt = new Date().toISOString();
const categories: Record<string, RawLighthouseCategory> =
result.categories ?? {};
const audits: Record<string, RawLighthouseAudit> = result.audits ?? {};
const issueReport = buildStoredLighthouseIssues({ audits, categories });
const metrics = buildStoredLighthouseMetrics({ audits });
const storedPayload: StoredLighthousePayload = {
version: 2,
source: "dataforseo-lighthouse",
hasIssueDetails: issueReport.hasIssueDetails,
metadata: {
requestedUrl: result.requestedUrl ?? input.url,
finalUrl: result.finalUrl ?? input.url,
strategy: input.strategy,
fetchedAt,
lighthouseVersion: result.lighthouseVersion ?? null,
taskId: task.id ?? null,
cost: task.cost ?? null,
},
scores: {
performance: scoreToPercent(categories.performance?.score),
accessibility: scoreToPercent(categories.accessibility?.score),
"best-practices": scoreToPercent(categories["best-practices"]?.score),
seo: scoreToPercent(categories.seo?.score),
},
metrics,
issues: issueReport.issues,
};
const allScoresMissing = Object.values(storedPayload.scores).every(
(score) => score == null,
);
if (allScoresMissing) {
throw new Error(
`DataForSEO Lighthouse returned no category scores for ${storedPayload.metadata.finalUrl}`,
);
}
return storedPayload;
}