Refactor Lighthouse audit modules to standardize result processing

This commit is contained in:
Emil Neander 2025-02-27 10:33:58 +01:00
parent 1603764d7b
commit 70cb154e8f
5 changed files with 461 additions and 649 deletions

View File

@ -1,132 +1,50 @@
import type { Result as LighthouseResult } from "lighthouse";
import {
AuditResult,
AuditIssue,
LighthouseDetails,
ImpactLevel,
AuditCategory,
} from "./types.js";
import {
runLighthouseOnExistingTab,
mapAuditItemsToElements,
createAuditIssue,
createAuditMetadata,
} from "./index.js";
import { Result as LighthouseResult } from "lighthouse";
import { AuditCategory, LighthouseReport } from "./types.js";
import { runLighthouseAudit } from "./index.js";
/**
* Extracts simplified accessibility issues from Lighthouse results
* @param lhr The Lighthouse result object
* @param limit Maximum number of issues to return
* @param detailed Whether to include detailed information about each issue
* @returns Processed audit result with categorized issues
*/
export function extractAccessibilityIssues(
lhr: LighthouseResult,
limit: number = 5,
detailed: boolean = false
): Partial<AuditResult> {
const allIssues: AuditIssue[] = [];
const categoryScores: { [key: string]: number } = {};
// Process each category
Object.entries(lhr.categories).forEach(([categoryName, category]) => {
const score = (category.score || 0) * 100;
categoryScores[categoryName] = score;
// Only process audits that actually failed or have warnings
const failedAudits = (category.auditRefs || [])
.map((ref) => ({ ref, audit: lhr.audits[ref.id] }))
.filter(
({ audit }) =>
// Include if score is less than 100% or has actual items to fix
audit?.score !== null &&
(audit.score < 1 ||
((audit.details as LighthouseDetails)?.items?.length || 0) > 0)
);
if (failedAudits.length > 0) {
failedAudits.forEach(({ ref, audit }) => {
const details = audit.details as LighthouseDetails;
// Use the shared helper function to extract elements
const elements = mapAuditItemsToElements(
details?.items || [],
detailed
);
if (elements.length > 0 || (audit.score || 0) < 1) {
// Use the shared helper function to create an audit issue
const impact =
((details?.items?.[0] as Record<string, unknown>)
?.impact as string) || ImpactLevel.MODERATE;
const issue = createAuditIssue(
audit,
ref,
details,
elements,
categoryName,
impact
);
// Add detailed details if requested
if (detailed) {
issue.details = details;
}
allIssues.push(issue);
}
});
}
});
// Sort issues by impact and score
allIssues.sort((a, b) => {
const impactOrder = {
[ImpactLevel.CRITICAL]: 0,
[ImpactLevel.SERIOUS]: 1,
[ImpactLevel.MODERATE]: 2,
[ImpactLevel.MINOR]: 3,
};
const aImpact = impactOrder[a.impact as keyof typeof impactOrder] || 4;
const bImpact = impactOrder[b.impact as keyof typeof impactOrder] || 4;
if (aImpact !== bImpact) return aImpact - bImpact;
return a.score - b.score;
});
// Return only the specified number of issues
const limitedIssues = allIssues.slice(0, limit);
return {
score: categoryScores.accessibility || 0,
categoryScores,
issues: limitedIssues,
...(detailed && {
auditMetadata: createAuditMetadata(lhr),
}),
};
interface AccessibilityAudit {
id: string; // e.g., "color-contrast"
title: string; // e.g., "Color contrast is sufficient"
description: string; // e.g., "Ensures text is readable..."
score: number | null; // 0-1 (normalized), null for manual/informative
scoreDisplayMode: string; // e.g., "binary", "numeric", "manual"
details?: AuditDetails; // Optional, structured details
weight?: number; // Optional, audit weight for impact calculation
}
type AuditDetails = {
items?: Array<{
node?: {
selector: string; // e.g., ".my-class"
snippet?: string; // HTML snippet
nodeLabel?: string; // e.g., "Modify logging size limits / truncation"
explanation?: string; // Explanation of why the node fails the audit
};
value?: string | number; // Specific value (e.g., contrast ratio)
explanation?: string; // Explanation at the item level
}>;
debugData?: string; // Optional, debug information
[key: string]: any; // Flexible for other detail types (tables, etc.)
};
const FAILED_AUDITS_LIMIT = 5;
// Define a maximum number of items to include in the audit details
const MAX_ITEMS_IN_DETAILS = 3;
/**
* Runs an accessibility audit on the specified URL
* @param url The URL to audit
* @param limit Maximum number of issues to return
* @param detailed Whether to include detailed information about each issue
* @returns Promise resolving to the processed accessibility audit results
* @returns Promise resolving to simplified accessibility audit results
*/
export async function runAccessibilityAudit(
url: string,
limit: number = 5,
detailed: boolean = false
): Promise<Partial<AuditResult>> {
url: string
): Promise<LighthouseReport> {
try {
// Run Lighthouse audit with accessibility category
const lhr = await runLighthouseOnExistingTab(url, [
AuditCategory.ACCESSIBILITY,
]);
// Extract and process accessibility issues
return extractAccessibilityIssues(lhr, limit, detailed);
const lhr = await runLighthouseAudit(url, [AuditCategory.ACCESSIBILITY]);
const accessibilityReport = extractLhrResult(lhr, url);
return accessibilityReport;
} catch (error) {
throw new Error(
`Accessibility audit failed: ${
@ -135,3 +53,148 @@ export async function runAccessibilityAudit(
);
}
}
const extractLhrResult = (
lhr: LighthouseResult,
url: string
): LighthouseReport => {
const categoryData = lhr.categories[AuditCategory.ACCESSIBILITY];
console.log(categoryData);
console.log(lhr);
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,
};
if (!categoryData)
return {
metadata,
failedAudits: [],
overallScore: 0,
failedAuditsCount: 0,
passedAuditsCount: 0,
manualAuditsCount: 0,
informativeAuditsCount: 0,
notApplicableAuditsCount: 0,
};
const overallScore = Math.round((categoryData.score || 0) * 100);
const auditRefs = categoryData.auditRefs || [];
const audits = lhr.audits || {};
const accessibilityAudits: AccessibilityAudit[] = auditRefs.map((ref) => {
const audit = audits[ref.id];
// Create a simplified version of the audit details
let simplifiedDetails: AuditDetails | undefined;
if (audit.details) {
simplifiedDetails = {};
// Only copy the items array if it exists
if (
(audit.details as any).items &&
Array.isArray((audit.details as any).items)
) {
// Limit the number of items to MAX_ITEMS_IN_DETAILS
const limitedItems = (audit.details as any).items.slice(
0,
MAX_ITEMS_IN_DETAILS
);
simplifiedDetails.items = limitedItems.map((item: any) => {
const simplifiedItem: any = {};
// Only include node with selector if they exist
if (item.node) {
// Include the node with all its properties
simplifiedItem.node = {
selector: item.node.selector || null,
nodeLabel: item.node.nodeLabel || null,
snippet: item.node.snippet || null,
};
// Include explanation if it exists
if (item.node.explanation) {
simplifiedItem.node.explanation = item.node.explanation;
}
}
// Include value if it exists
if (item.value !== undefined) {
simplifiedItem.value = item.value;
}
// Include explanation at the item level if it exists
if (item.explanation) {
simplifiedItem.explanation = item.explanation;
}
return simplifiedItem;
});
}
// Copy any other essential properties that might be needed
if ((audit.details as any).type) {
simplifiedDetails.type = (audit.details as any).type;
}
// Include debugData if it exists
if ((audit.details as any).debugData) {
simplifiedDetails.debugData = (audit.details as any).debugData;
}
}
return {
id: ref.id,
title: audit.title || "Untitled",
description: audit.description || "No description",
score: audit.score, // Individual audit score (0-1 or null)
scoreDisplayMode: audit.scoreDisplayMode || "numeric",
details: simplifiedDetails,
weight: ref.weight || 1,
};
});
const failedAudits = accessibilityAudits
.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 = accessibilityAudits.filter(
(audit) => audit.score !== null && audit.score >= 1
);
const manualAudits = accessibilityAudits.filter(
(audit) => audit.scoreDisplayMode === "manual"
);
const informativeAudits = accessibilityAudits.filter(
(audit) => audit.scoreDisplayMode === "informative"
);
const notApplicableAudits = accessibilityAudits.filter(
(audit) => audit.scoreDisplayMode === "notApplicable"
);
const result = {
metadata,
overallScore,
failedAuditsCount: failedAudits.length,
passedAuditsCount: passedAudits.length,
manualAuditsCount: manualAudits.length,
informativeAuditsCount: informativeAudits.length,
notApplicableAuditsCount: notApplicableAudits.length,
failedAudits,
} as LighthouseReport;
console.log(result);
failedAudits.forEach((audit) => {
console.log(JSON.stringify(audit.details, null, 2));
});
return result;
};

View File

@ -4,28 +4,7 @@ import {
connectToHeadlessBrowser,
scheduleBrowserCleanup,
} from "../browser-utils.js";
import {
LighthouseConfig,
AuditCategory,
AuditIssue,
LighthouseDetails,
ImpactLevel,
} from "./types.js";
// ===== Type Definitions =====
/**
* Details about an HTML element that has accessibility issues
*/
export interface ElementDetails {
selector: string;
snippet: string;
explanation: string;
url: string;
size: number;
wastedMs: number;
wastedBytes: number;
}
import { LighthouseConfig, AuditCategory } from "./types.js";
/**
* Creates a Lighthouse configuration object
@ -67,7 +46,7 @@ export function createLighthouseConfig(
* @returns Promise resolving to the Lighthouse result
* @throws Error if the URL is invalid or if the audit fails
*/
export async function runLighthouseOnExistingTab(
export async function runLighthouseAudit(
url: string,
categories: string[] = [AuditCategory.ACCESSIBILITY]
): Promise<LighthouseResult> {
@ -92,7 +71,6 @@ export async function runLighthouseOnExistingTab(
try {
const { port } = await connectToHeadlessBrowser(url, {
blockResources: !isPerformanceAudit,
// Don't pass an audit type - the blockResources flag is what matters
});
console.log(`Connected to browser on port: ${port}`);
@ -153,102 +131,3 @@ export * from "./accessibility.js";
export * from "./performance.js";
export * from "./seo.js";
export * from "./types.js";
/**
* Maps Lighthouse audit items to ElementDetails objects
* @param items Array of audit items from Lighthouse
* @param detailed Whether to include all items or limit them
* @returns Array of ElementDetails objects
*/
export function mapAuditItemsToElements(
items: Record<string, unknown>[] = [],
detailed: boolean = false
): ElementDetails[] {
const elements = items.map(
(item: Record<string, unknown>) =>
({
selector:
((item.node as Record<string, unknown>)?.selector as string) ||
(item.selector as string) ||
"Unknown selector",
snippet:
((item.node as Record<string, unknown>)?.snippet as string) ||
(item.snippet as string) ||
"No snippet available",
explanation:
((item.node as Record<string, unknown>)?.explanation as string) ||
(item.explanation as string) ||
"No explanation available",
url:
(item.url as string) ||
((item.node as Record<string, unknown>)?.url as string) ||
"",
size: (item.totalBytes as number) || (item.transferSize as number) || 0,
wastedMs: (item.wastedMs as number) || 0,
wastedBytes: (item.wastedBytes as number) || 0,
} as ElementDetails)
);
return detailed ? elements : elements.slice(0, 3);
}
/**
* Creates an AuditIssue object from Lighthouse audit data
* @param audit The Lighthouse audit object
* @param ref The audit reference object
* @param details The audit details object
* @param elements Array of ElementDetails objects
* @param categoryName The category name
* @param impact The impact level (optional)
* @returns An AuditIssue object
*/
export function createAuditIssue(
audit: any,
ref: any,
details: LighthouseDetails,
elements: ElementDetails[],
categoryName: string,
impact?: string
): AuditIssue {
return {
id: audit.id,
title: audit.title,
description: audit.description,
score: audit.score || 0,
details: { type: details?.type || "unknown" },
category: categoryName,
wcagReference: ref.relevantAudits || [],
impact:
impact ||
((details?.items?.[0] as Record<string, unknown>)?.impact as string) ||
ImpactLevel.MODERATE,
elements: elements,
failureSummary:
((details?.items?.[0] as Record<string, unknown>)
?.failureSummary as string) ||
audit.explanation ||
"No failure summary available",
recommendations: [],
};
}
/**
* Creates audit metadata from Lighthouse results
* @param lhr The Lighthouse result object
* @returns Audit metadata object
*/
export function createAuditMetadata(lhr: LighthouseResult): any {
return {
fetchTime: lhr.fetchTime || new Date().toISOString(),
url: lhr.finalUrl || "Unknown URL",
deviceEmulation: "desktop",
categories: Object.keys(lhr.categories),
totalAudits: Object.keys(lhr.audits || {}).length,
passedAudits: Object.values(lhr.audits || {}).filter(
(audit) => audit.score === 1
).length,
failedAudits: Object.values(lhr.audits || {}).filter(
(audit) => audit.score !== null && audit.score < 1
).length,
};
}

View File

@ -1,168 +1,38 @@
import type { Result as LighthouseResult } from "lighthouse";
import {
AuditResult,
AuditIssue,
LighthouseDetails,
ElementDetails,
ImpactLevel,
AuditCategory,
} from "./types.js";
import {
runLighthouseOnExistingTab,
mapAuditItemsToElements,
createAuditIssue,
createAuditMetadata,
} from "./index.js";
import { Result as LighthouseResult } from "lighthouse";
import { AuditCategory, LighthouseReport } from "./types.js";
import { runLighthouseAudit } from "./index.js";
/**
* Extracts performance issues from Lighthouse results
* @param lhr The Lighthouse result object
* @param limit Maximum number of issues to return
* @param detailed Whether to include detailed information about each issue
* @returns Processed audit result with performance issues
*/
export function extractPerformanceIssues(
lhr: LighthouseResult,
limit: number = 5,
detailed: boolean = false
): Partial<AuditResult> {
const allIssues: AuditIssue[] = [];
const categoryScores: { [key: string]: number } = {};
// Check if lhr and categories exist
if (!lhr || !lhr.categories) {
console.error("Invalid Lighthouse result: missing categories");
return {
score: 0,
categoryScores: {},
issues: [],
};
}
// Process performance category
Object.entries(lhr.categories).forEach(([categoryName, category]) => {
if (categoryName !== AuditCategory.PERFORMANCE) return;
const score = (category.score || 0) * 100;
categoryScores[categoryName] = score;
// Check if auditRefs exists
if (!category.auditRefs) {
console.error(`No auditRefs found for category: ${categoryName}`);
return;
}
// Only process audits that actually failed or have warnings
const failedAudits = category.auditRefs
.map((ref) => {
const audit = lhr.audits?.[ref.id];
if (!audit) {
console.error(`Audit not found for ref.id: ${ref.id}`);
return null;
}
return { ref, audit };
})
.filter(
(item): item is { ref: any; audit: any } =>
item !== null &&
item.audit?.score !== null &&
(item.audit.score < 0.9 ||
((item.audit.details as LighthouseDetails)?.items?.length || 0) > 0)
);
if (failedAudits.length > 0) {
failedAudits.forEach(({ ref, audit }) => {
try {
const details = audit.details as LighthouseDetails;
// Check if details exists
if (!details) {
console.error(`No details found for audit: ${audit.id}`);
return;
}
// Use the shared helper function to extract elements
const elements = mapAuditItemsToElements(
details.items || [],
detailed
);
if (elements.length > 0 || (audit.score || 0) < 0.9) {
// Use the shared helper function to create an audit issue
const impact = getPerformanceImpact(audit.score || 0);
const issue = createAuditIssue(
audit,
ref,
details,
elements,
categoryName,
impact
);
// Add detailed details if requested
if (detailed) {
issue.details = details;
}
allIssues.push(issue);
}
} catch (error) {
console.error(`Error processing audit ${audit.id}: ${error}`);
}
});
}
});
// Sort issues by score (lowest first)
allIssues.sort((a, b) => a.score - b.score);
// Return only the specified number of issues
const limitedIssues = allIssues.slice(0, limit);
return {
score: categoryScores.performance || 0,
categoryScores,
issues: limitedIssues,
...(detailed && {
auditMetadata: createAuditMetadata(lhr),
}),
};
interface PerformanceAudit {
id: string; // e.g., "first-contentful-paint"
title: string; // e.g., "First Contentful Paint"
description: string; // e.g., "Time to first contentful paint..."
score: number | null; // 0-1 or null
scoreDisplayMode: string; // e.g., "numeric"
numericValue?: number; // e.g., 1.8 (seconds) or 200 (ms)
numericUnit?: string; // e.g., "s" or "ms"
details?: PerformanceAuditDetails; // Optional, structured details
weight?: number; // For prioritization
}
/**
* Determines the impact level based on the performance score
* @param score The performance score (0-1)
* @returns Impact level string
*/
function getPerformanceImpact(score: number): string {
if (score < 0.5) return ImpactLevel.CRITICAL;
if (score < 0.7) return ImpactLevel.SERIOUS;
if (score < 0.9) return ImpactLevel.MODERATE;
return ImpactLevel.MINOR;
interface PerformanceAuditDetails {
items?: Array<{
resourceUrl?: string; // e.g., "https://example.com/script.js" (for render-blocking resources)
wastedMs?: number; // e.g., 150 (potential savings)
elementSelector?: string; // e.g., "img.hero" (for LCP element)
timing?: number; // e.g., 2.5 (specific timing value)
}>;
type?: string; // e.g., "opportunity" or "table"
}
/**
* Runs a performance audit on the specified URL
* @param url The URL to audit
* @param limit Maximum number of issues to return
* @param detailed Whether to include detailed information about each issue
* @returns Promise resolving to the processed performance audit results
*/
const FAILED_AUDITS_LIMIT = 5;
const MAX_ITEMS_IN_DETAILS = 3;
export async function runPerformanceAudit(
url: string,
limit: number = 5,
detailed: boolean = false
): Promise<Partial<AuditResult>> {
url: string
): Promise<LighthouseReport> {
try {
// Run Lighthouse audit with performance category
const lhr = await runLighthouseOnExistingTab(url, [
AuditCategory.PERFORMANCE,
]);
// Extract and process performance issues
const result = extractPerformanceIssues(lhr, limit, detailed);
return result;
const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]);
return extractPerformanceResult(lhr, url);
} catch (error) {
throw new Error(
`Performance audit failed: ${
@ -171,3 +41,107 @@ export async function runPerformanceAudit(
);
}
}
const extractPerformanceResult = (
lhr: LighthouseResult,
url: string
): LighthouseReport => {
const categoryData = lhr.categories[AuditCategory.PERFORMANCE];
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,
};
if (!categoryData) {
return {
metadata,
failedAudits: [],
overallScore: 0,
failedAuditsCount: 0,
passedAuditsCount: 0,
manualAuditsCount: 0,
informativeAuditsCount: 0,
notApplicableAuditsCount: 0,
};
}
const overallScore = Math.round((categoryData.score || 0) * 100);
const auditRefs = categoryData.auditRefs || [];
const audits = lhr.audits || {};
const performanceAudits: PerformanceAudit[] = auditRefs.map((ref) => {
const audit = audits[ref.id];
let simplifiedDetails: PerformanceAuditDetails | 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.url) simplifiedItem.resourceUrl = item.url; // For render-blocking resources
if (item.wastedMs) simplifiedItem.wastedMs = item.wastedMs; // Potential savings
if (item.node?.selector)
simplifiedItem.elementSelector = item.node.selector; // For LCP element
if (item.timing) simplifiedItem.timing = item.timing; // Specific timing
return simplifiedItem;
});
}
if (audit.details.type) simplifiedDetails.type = audit.details.type;
}
return {
id: ref.id,
title: audit.title || "Untitled",
description: audit.description || "No description",
score: audit.score,
scoreDisplayMode: audit.scoreDisplayMode || "numeric",
numericValue: audit.numericValue,
numericUnit: audit.numericUnit,
details: simplifiedDetails,
weight: ref.weight || 1,
};
});
const failedAudits = performanceAudits
.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 = performanceAudits.filter(
(audit) => audit.score !== null && audit.score >= 1
);
const manualAudits = performanceAudits.filter(
(audit) => audit.scoreDisplayMode === "manual"
);
const informativeAudits = performanceAudits.filter(
(audit) => audit.scoreDisplayMode === "informative"
);
const notApplicableAudits = performanceAudits.filter(
(audit) => audit.scoreDisplayMode === "notApplicable"
);
return {
metadata,
overallScore,
failedAuditsCount: failedAudits.length,
passedAuditsCount: passedAudits.length,
manualAuditsCount: manualAudits.length,
informativeAuditsCount: informativeAudits.length,
notApplicableAuditsCount: notApplicableAudits.length,
failedAudits,
};
};

View File

@ -1,177 +1,33 @@
import type { Result as LighthouseResult } from "lighthouse";
import {
AuditResult,
AuditIssue,
LighthouseDetails,
ImpactLevel,
AuditCategory,
} from "./types.js";
import {
runLighthouseOnExistingTab,
mapAuditItemsToElements,
createAuditIssue,
createAuditMetadata,
} from "./index.js";
import { Result as LighthouseResult } from "lighthouse";
import { AuditCategory, LighthouseReport } from "./types.js";
import { runLighthouseAudit } from "./index.js";
/**
* Extracts SEO issues from Lighthouse results
* @param lhr The Lighthouse result object
* @param limit Maximum number of issues to return
* @param detailed Whether to include detailed information about each issue
* @returns Processed audit result with SEO issues
*/
export function extractSEOIssues(
lhr: LighthouseResult,
limit: number = 5,
detailed: boolean = false
): Partial<AuditResult> {
const allIssues: AuditIssue[] = [];
const categoryScores: { [key: string]: number } = {};
// Check if lhr and categories exist
if (!lhr || !lhr.categories) {
console.error("Invalid Lighthouse result: missing categories");
return {
score: 0,
categoryScores: {},
issues: [],
};
}
// Process SEO category
Object.entries(lhr.categories).forEach(([categoryName, category]) => {
if (categoryName !== AuditCategory.SEO) return;
const score = (category.score || 0) * 100;
categoryScores[categoryName] = score;
// Check if auditRefs exists
if (!category.auditRefs) {
console.error(`No auditRefs found for category: ${categoryName}`);
return;
}
// Only process audits that actually failed or have warnings
const failedAudits = category.auditRefs
.map((ref) => {
const audit = lhr.audits?.[ref.id];
if (!audit) {
console.error(`Audit not found for ref.id: ${ref.id}`);
return null;
}
return { ref, audit };
})
.filter(
(item): item is { ref: any; audit: any } =>
item !== null &&
item.audit?.score !== null &&
(item.audit.score < 1 ||
((item.audit.details as LighthouseDetails)?.items?.length || 0) > 0)
);
if (failedAudits.length > 0) {
failedAudits.forEach(({ ref, audit }) => {
try {
const details = audit.details as LighthouseDetails;
// Check if details exists
if (!details) {
console.error(`No details found for audit: ${audit.id}`);
return;
}
// Use the shared helper function to extract elements
const elements = mapAuditItemsToElements(
details.items || [],
detailed
);
if (elements.length > 0 || (audit.score || 0) < 1) {
// Use the shared helper function to create an audit issue
const impact = getSEOImpact(audit.score || 0);
const issue = createAuditIssue(
audit,
ref,
details,
elements,
categoryName,
impact
);
// Add detailed details if requested
if (detailed) {
issue.details = details;
}
allIssues.push(issue);
}
} catch (error) {
console.error(`Error processing audit ${audit.id}: ${error}`);
}
});
}
});
// Sort issues by impact and score
allIssues.sort((a, b) => {
const impactOrder = {
[ImpactLevel.CRITICAL]: 0,
[ImpactLevel.SERIOUS]: 1,
[ImpactLevel.MODERATE]: 2,
[ImpactLevel.MINOR]: 3,
};
const aImpact = impactOrder[a.impact as keyof typeof impactOrder] || 4;
const bImpact = impactOrder[b.impact as keyof typeof impactOrder] || 4;
if (aImpact !== bImpact) return aImpact - bImpact;
return a.score - b.score;
});
// Return only the specified number of issues
const limitedIssues = allIssues.slice(0, limit);
return {
score: categoryScores.seo || 0,
categoryScores,
issues: limitedIssues,
...(detailed && {
auditMetadata: createAuditMetadata(lhr),
}),
};
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
}
/**
* Determines the impact level based on the SEO score
* @param score The SEO score (0-1)
* @returns Impact level string
*/
function getSEOImpact(score: number): string {
if (score < 0.5) return ImpactLevel.CRITICAL;
if (score < 0.7) return ImpactLevel.SERIOUS;
if (score < 0.9) return ImpactLevel.MODERATE;
return ImpactLevel.MINOR;
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"
}
/**
* Runs an SEO audit on the specified URL
* @param url The URL to audit
* @param limit Maximum number of issues to return
* @param detailed Whether to include detailed information about each issue
* @returns Promise resolving to the processed SEO audit results
*/
export async function runSEOAudit(
url: string,
limit: number = 5,
detailed: boolean = false
): Promise<Partial<AuditResult>> {
const FAILED_AUDITS_LIMIT = 5;
const MAX_ITEMS_IN_DETAILS = 3;
export async function runSEOAudit(url: string): Promise<LighthouseReport> {
try {
// Run Lighthouse audit with SEO category
const lhr = await runLighthouseOnExistingTab(url, [AuditCategory.SEO]);
// Extract and process SEO issues
const result = extractSEOIssues(lhr, limit, detailed);
return result;
const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]);
return extractSEOResult(lhr, url);
} catch (error) {
throw new Error(
`SEO audit failed: ${
@ -180,3 +36,104 @@ export async function runSEOAudit(
);
}
}
const extractSEOResult = (
lhr: LighthouseResult,
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,
};
if (!categoryData) {
return {
metadata,
failedAudits: [],
overallScore: 0,
failedAuditsCount: 0,
passedAuditsCount: 0,
manualAuditsCount: 0,
informativeAuditsCount: 0,
notApplicableAuditsCount: 0,
};
}
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;
}
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,
};
});
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"
);
return {
metadata,
overallScore,
failedAuditsCount: failedAudits.length,
passedAuditsCount: passedAudits.length,
manualAuditsCount: manualAudits.length,
informativeAuditsCount: informativeAudits.length,
notApplicableAuditsCount: notApplicableAudits.length,
failedAudits,
};
};

View File

@ -1,71 +1,31 @@
/**
* Types for Lighthouse audit results and related data structures
* Audit categories available in Lighthouse
*/
/**
* Details about an HTML element that has accessibility or performance issues
*/
export interface ElementDetails {
selector: string;
snippet: string;
explanation: string;
url: string;
size: number;
wastedMs: number;
wastedBytes: number;
export enum AuditCategory {
ACCESSIBILITY = "accessibility",
PERFORMANCE = "performance",
SEO = "seo",
BEST_PRACTICES = "best-practices",
PWA = "pwa",
}
/**
* Represents a single audit issue found during an audit
* Base interface for Lighthouse report metadata
*/
export interface AuditIssue {
id: string;
title: string;
description: string;
score: number;
details: LighthouseDetails;
wcagReference: string[];
impact: string;
elements: ElementDetails[];
failureSummary: string;
recommendations?: string[];
category?: string;
}
/**
* The complete result of an audit
*/
export interface AuditResult {
score: number;
categoryScores: { [key: string]: number };
issues: AuditIssue[];
auditMetadata?: {
fetchTime: string;
export interface LighthouseReport {
metadata: {
url: string;
deviceEmulation: string;
categories: string[];
totalAudits: number;
passedAudits: number;
failedAudits: number;
};
}
/**
* Details structure from Lighthouse audit results
*/
export interface LighthouseDetails {
type: string;
headings?: Array<{
key?: string;
itemType?: string;
text?: string;
}>;
items?: Array<Record<string, unknown>>;
debugData?: {
type: string;
impact?: string;
tags?: string[];
timestamp: string; // ISO 8601, e.g., "2025-02-27T14:30:00Z"
device: string; // e.g., "mobile", "desktop"
lighthouseVersion: string; // e.g., "10.4.0"
};
overallScore: number;
failedAuditsCount: number;
passedAuditsCount: number;
manualAuditsCount: number;
informativeAuditsCount: number;
notApplicableAuditsCount: number;
failedAudits: any[];
}
/**
@ -96,24 +56,3 @@ export interface LighthouseConfig {
};
};
}
/**
* Audit categories available in Lighthouse
*/
export enum AuditCategory {
ACCESSIBILITY = "accessibility",
PERFORMANCE = "performance",
SEO = "seo",
BEST_PRACTICES = "best-practices",
PWA = "pwa",
}
/**
* Impact levels for audit issues
*/
export enum ImpactLevel {
CRITICAL = "critical",
SERIOUS = "serious",
MODERATE = "moderate",
MINOR = "minor",
}