2025-02-27 10:33:58 +01:00
|
|
|
import { Result as LighthouseResult } from "lighthouse";
|
|
|
|
import { AuditCategory, LighthouseReport } from "./types.js";
|
|
|
|
import { runLighthouseAudit } from "./index.js";
|
|
|
|
|
|
|
|
interface SEOAudit {
|
|
|
|
id: string; // e.g., "meta-description"
|
|
|
|
title: string; // e.g., "Document has a meta description"
|
|
|
|
description: string; // e.g., "Meta descriptions improve SEO..."
|
|
|
|
score: number | null; // 0-1 or null
|
|
|
|
scoreDisplayMode: string; // e.g., "binary"
|
|
|
|
details?: SEOAuditDetails; // Optional, structured details
|
|
|
|
weight?: number; // For prioritization
|
|
|
|
}
|
|
|
|
|
|
|
|
interface SEOAuditDetails {
|
|
|
|
items?: Array<{
|
|
|
|
selector?: string; // e.g., "meta[name='description']"
|
|
|
|
issue?: string; // e.g., "Meta description is missing"
|
|
|
|
value?: string; // e.g., Current meta description text
|
|
|
|
}>;
|
|
|
|
type?: string; // e.g., "table"
|
|
|
|
}
|
|
|
|
|
|
|
|
const FAILED_AUDITS_LIMIT = 5;
|
|
|
|
const MAX_ITEMS_IN_DETAILS = 3;
|
|
|
|
|
|
|
|
export async function runSEOAudit(url: string): Promise<LighthouseReport> {
|
|
|
|
try {
|
|
|
|
const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]);
|
|
|
|
return extractSEOResult(lhr, url);
|
|
|
|
} catch (error) {
|
|
|
|
throw new Error(
|
|
|
|
`SEO audit failed: ${
|
|
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
}`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
2025-02-26 19:57:18 +01:00
|
|
|
|
2025-02-27 10:33:58 +01:00
|
|
|
const extractSEOResult = (
|
2025-02-26 19:57:18 +01:00
|
|
|
lhr: LighthouseResult,
|
2025-02-27 10:33:58 +01:00
|
|
|
url: string
|
|
|
|
): LighthouseReport => {
|
|
|
|
const categoryData = lhr.categories[AuditCategory.SEO];
|
|
|
|
const metadata = {
|
|
|
|
url,
|
|
|
|
timestamp: lhr.fetchTime
|
|
|
|
? new Date(lhr.fetchTime).toISOString()
|
|
|
|
: new Date().toISOString(),
|
|
|
|
device: "desktop", // TODO: pass device from the request instead of hardcoding
|
|
|
|
lighthouseVersion: lhr.lighthouseVersion,
|
|
|
|
};
|
2025-02-26 19:57:18 +01:00
|
|
|
|
2025-02-27 10:33:58 +01:00
|
|
|
if (!categoryData) {
|
2025-02-26 19:57:18 +01:00
|
|
|
return {
|
2025-02-27 10:33:58 +01:00
|
|
|
metadata,
|
|
|
|
failedAudits: [],
|
|
|
|
overallScore: 0,
|
|
|
|
failedAuditsCount: 0,
|
|
|
|
passedAuditsCount: 0,
|
|
|
|
manualAuditsCount: 0,
|
|
|
|
informativeAuditsCount: 0,
|
|
|
|
notApplicableAuditsCount: 0,
|
2025-02-26 19:57:18 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2025-02-27 10:33:58 +01:00
|
|
|
const overallScore = Math.round((categoryData.score || 0) * 100);
|
|
|
|
const auditRefs = categoryData.auditRefs || [];
|
|
|
|
const audits = lhr.audits || {};
|
|
|
|
|
|
|
|
const seoAudits: SEOAudit[] = auditRefs.map((ref) => {
|
|
|
|
const audit = audits[ref.id];
|
|
|
|
let simplifiedDetails: SEOAuditDetails | undefined;
|
|
|
|
|
|
|
|
if (audit.details) {
|
|
|
|
simplifiedDetails = {};
|
|
|
|
if (
|
|
|
|
(audit.details as any).items &&
|
|
|
|
Array.isArray((audit.details as any).items)
|
|
|
|
) {
|
|
|
|
const limitedItems = (audit.details as any).items.slice(
|
|
|
|
0,
|
|
|
|
MAX_ITEMS_IN_DETAILS
|
|
|
|
);
|
|
|
|
simplifiedDetails.items = limitedItems.map((item: any) => {
|
|
|
|
const simplifiedItem: any = {};
|
|
|
|
if (item.node?.selector) simplifiedItem.selector = item.node.selector;
|
|
|
|
if (item.explanation)
|
|
|
|
simplifiedItem.issue = item.explanation.split("\n")[0]; // First line for brevity
|
|
|
|
if (item.value) simplifiedItem.value = item.value; // e.g., meta description text
|
|
|
|
return simplifiedItem;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
if (audit.details.type) simplifiedDetails.type = audit.details.type;
|
2025-02-26 19:57:18 +01:00
|
|
|
}
|
|
|
|
|
2025-02-27 10:33:58 +01:00
|
|
|
return {
|
|
|
|
id: ref.id,
|
|
|
|
title: audit.title || "Untitled",
|
|
|
|
description: audit.description || "No description",
|
|
|
|
score: audit.score,
|
|
|
|
scoreDisplayMode: audit.scoreDisplayMode || "numeric",
|
|
|
|
details: simplifiedDetails,
|
|
|
|
weight: ref.weight || 1,
|
2025-02-26 19:57:18 +01:00
|
|
|
};
|
|
|
|
});
|
|
|
|
|
2025-02-27 10:33:58 +01:00
|
|
|
const failedAudits = seoAudits
|
|
|
|
.filter((audit) => audit.score !== null && audit.score < 1)
|
|
|
|
.sort(
|
|
|
|
(a, b) =>
|
|
|
|
b.weight! * (1 - (b.score || 0)) - a.weight! * (1 - (a.score || 0))
|
|
|
|
)
|
|
|
|
.slice(0, FAILED_AUDITS_LIMIT);
|
|
|
|
|
|
|
|
const passedAudits = seoAudits.filter(
|
|
|
|
(audit) => audit.score !== null && audit.score >= 1
|
|
|
|
);
|
|
|
|
const manualAudits = seoAudits.filter(
|
|
|
|
(audit) => audit.scoreDisplayMode === "manual"
|
|
|
|
);
|
|
|
|
const informativeAudits = seoAudits.filter(
|
|
|
|
(audit) => audit.scoreDisplayMode === "informative"
|
|
|
|
);
|
|
|
|
const notApplicableAudits = seoAudits.filter(
|
|
|
|
(audit) => audit.scoreDisplayMode === "notApplicable"
|
|
|
|
);
|
2025-02-26 19:57:18 +01:00
|
|
|
|
|
|
|
return {
|
2025-02-27 10:33:58 +01:00
|
|
|
metadata,
|
|
|
|
overallScore,
|
|
|
|
failedAuditsCount: failedAudits.length,
|
|
|
|
passedAuditsCount: passedAudits.length,
|
|
|
|
manualAuditsCount: manualAudits.length,
|
|
|
|
informativeAuditsCount: informativeAudits.length,
|
|
|
|
notApplicableAuditsCount: notApplicableAudits.length,
|
|
|
|
failedAudits,
|
2025-02-26 19:57:18 +01:00
|
|
|
};
|
2025-02-27 10:33:58 +01:00
|
|
|
};
|