refactor: standardize Lighthouse audit reports with AI-optimized data extraction

This commit is contained in:
Emil Neander 2025-03-06 17:31:05 +01:00
parent 085aabc765
commit 7a7bf8c137
6 changed files with 740 additions and 302 deletions

View File

@ -351,12 +351,11 @@ enum AuditCategory {
// Add tool for accessibility audits, launches a headless browser instance // Add tool for accessibility audits, launches a headless browser instance
server.tool( server.tool(
"runAccessibilityAudit", "runAccessibilityAudit",
"Run a WCAG-compliant accessibility audit on the current page", "Run an accessibility audit on the current page",
{}, {},
async () => { async () => {
return await withServerConnection(async () => { return await withServerConnection(async () => {
try { try {
// Simplified approach - let the browser connector handle the current tab and URL
console.log( console.log(
`Sending POST request to http://${discoveredHost}:${discoveredPort}/accessibility-audit` `Sending POST request to http://${discoveredHost}:${discoveredPort}/accessibility-audit`
); );
@ -369,32 +368,48 @@ server.tool(
Accept: "application/json", Accept: "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
category: AuditCategory.ACCESSIBILITY,
source: "mcp_tool", source: "mcp_tool",
timestamp: Date.now(), timestamp: Date.now(),
}), }),
} }
); );
// Log the response status // Check for errors
console.log(`Accessibility audit response status: ${response.status}`);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error(`Accessibility audit error: ${errorText}`);
throw new Error(`Server returned ${response.status}: ${errorText}`); throw new Error(`Server returned ${response.status}: ${errorText}`);
} }
const json = await response.json(); const json = await response.json();
return { // If the response is in the new format with a nested 'report',
content: [ // flatten it by merging metadata with the report contents
{ if (json.report) {
type: "text", const { metadata, report } = json;
text: JSON.stringify(json, null, 2), const flattened = {
}, ...metadata,
], ...report,
}; };
return {
content: [
{
type: "text",
text: JSON.stringify(flattened, null, 2),
},
],
};
} else {
// Return as-is if it's not in the new format
return {
content: [
{
type: "text",
text: JSON.stringify(json, null, 2),
},
],
};
}
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
@ -484,7 +499,6 @@ server.tool(
async () => { async () => {
return await withServerConnection(async () => { return await withServerConnection(async () => {
try { try {
// Simplified approach - let the browser connector handle the current tab and URL
console.log( console.log(
`Sending POST request to http://${discoveredHost}:${discoveredPort}/seo-audit` `Sending POST request to http://${discoveredHost}:${discoveredPort}/seo-audit`
); );
@ -497,32 +511,48 @@ server.tool(
Accept: "application/json", Accept: "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
category: AuditCategory.SEO,
source: "mcp_tool", source: "mcp_tool",
timestamp: Date.now(), timestamp: Date.now(),
}), }),
} }
); );
// Log the response status // Check for errors
console.log(`SEO audit response status: ${response.status}`);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error(`SEO audit error: ${errorText}`);
throw new Error(`Server returned ${response.status}: ${errorText}`); throw new Error(`Server returned ${response.status}: ${errorText}`);
} }
const json = await response.json(); const json = await response.json();
return { // If the response is in the new format with a nested 'report',
content: [ // flatten it by merging metadata with the report contents
{ if (json.report) {
type: "text", const { metadata, report } = json;
text: JSON.stringify(json, null, 2), const flattened = {
}, ...metadata,
], ...report,
}; };
return {
content: [
{
type: "text",
text: JSON.stringify(flattened, null, 2),
},
],
};
} else {
// Return as-is if it's not in the new format
return {
content: [
{
type: "text",
text: JSON.stringify(json, null, 2),
},
],
};
}
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);

View File

@ -890,7 +890,7 @@ export class BrowserConnector {
console.log("No valid URL available yet, waiting for navigation..."); console.log("No valid URL available yet, waiting for navigation...");
// Wait up to 10 seconds for a URL to be set (20 attempts x 500ms) // Wait up to 10 seconds for a URL to be set (20 attempts x 500ms)
const maxAttempts = 20; const maxAttempts = 50;
const waitTime = 500; // ms const waitTime = 500; // ms
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {

View File

@ -2,6 +2,62 @@ import { Result as LighthouseResult } from "lighthouse";
import { AuditCategory, LighthouseReport } from "./types.js"; import { AuditCategory, LighthouseReport } from "./types.js";
import { runLighthouseAudit } from "./index.js"; import { runLighthouseAudit } from "./index.js";
// === Accessibility Report Types ===
/**
* Accessibility-specific report content structure
*/
export interface AccessibilityReportContent {
score: number; // Overall score (0-100)
audit_counts: {
// Counts of different audit types
failed: number;
passed: number;
manual: number;
informative: number;
not_applicable: number;
};
issues: AIAccessibilityIssue[];
categories: {
[category: string]: {
score: number;
issues_count: number;
};
};
critical_elements: AIAccessibilityElement[];
prioritized_recommendations?: string[]; // Ordered list of recommendations
}
/**
* Full accessibility report implementing the base LighthouseReport interface
*/
export type AIOptimizedAccessibilityReport =
LighthouseReport<AccessibilityReportContent>;
/**
* AI-optimized accessibility issue
*/
interface AIAccessibilityIssue {
id: string; // e.g., "color-contrast"
title: string; // e.g., "Color contrast is sufficient"
impact: "critical" | "serious" | "moderate" | "minor";
category: string; // e.g., "contrast", "aria", "forms", "keyboard"
elements?: AIAccessibilityElement[]; // Elements with issues
score: number | null; // 0-1 or null
}
/**
* Accessibility element with issues
*/
interface AIAccessibilityElement {
selector: string; // CSS selector
snippet?: string; // HTML snippet
label?: string; // Element label
issue_description?: string; // Description of the issue
value?: string | number; // Current value (e.g., contrast ratio)
}
// Original interfaces for backward compatibility
interface AccessibilityAudit { interface AccessibilityAudit {
id: string; // e.g., "color-contrast" id: string; // e.g., "color-contrast"
title: string; // e.g., "Color contrast is sufficient" title: string; // e.g., "Color contrast is sufficient"
@ -27,24 +83,26 @@ type AuditDetails = {
[key: string]: any; // Flexible for other detail types (tables, etc.) [key: string]: any; // Flexible for other detail types (tables, etc.)
}; };
const FAILED_AUDITS_LIMIT = 5; // Original limits were optimized for human consumption
// This ensures we always include critical issues while limiting less important ones
// Define a maximum number of items to include in the audit details const DETAIL_LIMITS = {
const MAX_ITEMS_IN_DETAILS = 3; critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues
serious: 15, // Up to 15 items for serious issues
moderate: 10, // Up to 10 items for moderate issues
minor: 3, // Up to 3 items for minor issues
};
/** /**
* Runs an accessibility audit on the specified URL * Runs an accessibility audit on the specified URL
* @param url The URL to audit * @param url The URL to audit
* @param limit Maximum number of issues to return * @returns Promise resolving to AI-optimized accessibility audit results
* @returns Promise resolving to simplified accessibility audit results
*/ */
export async function runAccessibilityAudit( export async function runAccessibilityAudit(
url: string url: string
): Promise<LighthouseReport> { ): Promise<AIOptimizedAccessibilityReport> {
try { try {
const lhr = await runLighthouseAudit(url, [AuditCategory.ACCESSIBILITY]); const lhr = await runLighthouseAudit(url, [AuditCategory.ACCESSIBILITY]);
const accessibilityReport = extractLhrResult(lhr, url); return extractAIOptimizedData(lhr, url);
return accessibilityReport;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Accessibility audit failed: ${ `Accessibility audit failed: ${
@ -54,145 +112,219 @@ export async function runAccessibilityAudit(
} }
} }
const extractLhrResult = ( /**
* Extract AI-optimized accessibility data from Lighthouse results
*/
const extractAIOptimizedData = (
lhr: LighthouseResult, lhr: LighthouseResult,
url: string url: string
): LighthouseReport => { ): AIOptimizedAccessibilityReport => {
const categoryData = lhr.categories[AuditCategory.ACCESSIBILITY]; const categoryData = lhr.categories[AuditCategory.ACCESSIBILITY];
const audits = lhr.audits || {};
// Add metadata
const metadata = { const metadata = {
url, url,
timestamp: lhr.fetchTime timestamp: lhr.fetchTime || new Date().toISOString(),
? new Date(lhr.fetchTime).toISOString() device: "desktop", // This could be made configurable
: new Date().toISOString(),
device: "desktop", // TODO: pass device from the request instead of hardcoding
lighthouseVersion: lhr.lighthouseVersion, lighthouseVersion: lhr.lighthouseVersion,
}; };
if (!categoryData) // Initialize variables
return { const issues: AIAccessibilityIssue[] = [];
metadata, const criticalElements: AIAccessibilityElement[] = [];
failedAudits: [], const categories: {
overallScore: 0, [category: string]: { score: number; issues_count: number };
failedAuditsCount: 0, } = {};
passedAuditsCount: 0,
manualAuditsCount: 0,
informativeAuditsCount: 0,
notApplicableAuditsCount: 0,
};
const overallScore = Math.round((categoryData.score || 0) * 100); // Count audits by type
const auditRefs = categoryData.auditRefs || []; let failedCount = 0;
const audits = lhr.audits || {}; let passedCount = 0;
let manualCount = 0;
let informativeCount = 0;
let notApplicableCount = 0;
const accessibilityAudits: AccessibilityAudit[] = auditRefs.map((ref) => { // Process audit refs
const auditRefs = categoryData?.auditRefs || [];
// First pass: count audits by type and initialize categories
auditRefs.forEach((ref) => {
const audit = audits[ref.id]; const audit = audits[ref.id];
if (!audit) return;
// Create a simplified version of the audit details // Count by scoreDisplayMode
let simplifiedDetails: AuditDetails | undefined; if (audit.scoreDisplayMode === "manual") {
manualCount++;
if (audit.details) { } else if (audit.scoreDisplayMode === "informative") {
simplifiedDetails = {}; informativeCount++;
} else if (audit.scoreDisplayMode === "notApplicable") {
// Only copy the items array if it exists notApplicableCount++;
if ( } else if (audit.score !== null) {
(audit.details as any).items && // Binary pass/fail
Array.isArray((audit.details as any).items) if (audit.score >= 0.9) {
) { passedCount++;
// Limit the number of items to MAX_ITEMS_IN_DETAILS } else {
const limitedItems = (audit.details as any).items.slice( failedCount++;
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 { // Process categories
id: ref.id, if (ref.group) {
title: audit.title || "Untitled", // Initialize category if not exists
description: audit.description || "No description", if (!categories[ref.group]) {
score: audit.score, // Individual audit score (0-1 or null) categories[ref.group] = { score: 0, issues_count: 0 };
scoreDisplayMode: audit.scoreDisplayMode || "numeric", }
details: simplifiedDetails,
weight: ref.weight || 1, // Update category score and issues count
}; if (audit.score !== null && audit.score < 0.9) {
categories[ref.group].issues_count++;
}
}
}); });
const failedAudits = accessibilityAudits // Second pass: process failed audits into AI-friendly format
.filter((audit) => audit.score !== null && audit.score < 1) auditRefs
.sort( .filter((ref) => {
(a, b) => const audit = audits[ref.id];
b.weight! * (1 - (b.score || 0)) - a.weight! * (1 - (a.score || 0)) return audit && audit.score !== null && audit.score < 0.9;
) })
.slice(0, FAILED_AUDITS_LIMIT); .sort((a, b) => (b.weight || 0) - (a.weight || 0))
// No limit on number of failed audits - we'll show them all
.forEach((ref) => {
const audit = audits[ref.id];
const passedAudits = accessibilityAudits.filter( // Determine impact level based on score and weight
(audit) => audit.score !== null && audit.score >= 1 let impact: "critical" | "serious" | "moderate" | "minor" = "moderate";
); if (audit.score === 0) {
const manualAudits = accessibilityAudits.filter( impact = "critical";
(audit) => audit.scoreDisplayMode === "manual" } else if (audit.score !== null && audit.score <= 0.5) {
); impact = "serious";
const informativeAudits = accessibilityAudits.filter( } else if (audit.score !== null && audit.score > 0.7) {
(audit) => audit.scoreDisplayMode === "informative" impact = "minor";
); }
const notApplicableAudits = accessibilityAudits.filter(
(audit) => audit.scoreDisplayMode === "notApplicable"
);
const result = { // Create elements array
const elements: AIAccessibilityElement[] = [];
if (audit.details) {
const details = audit.details as any;
if (details.items && Array.isArray(details.items)) {
const items = details.items;
// Apply limits based on impact level
const itemLimit = DETAIL_LIMITS[impact];
items.slice(0, itemLimit).forEach((item: any) => {
if (item.node) {
const element: AIAccessibilityElement = {
selector: item.node.selector,
snippet: item.node.snippet,
label: item.node.nodeLabel,
issue_description: item.node.explanation || item.explanation,
};
if (item.value !== undefined) {
element.value = item.value;
}
elements.push(element);
// Add to critical elements if impact is critical or serious
if (impact === "critical" || impact === "serious") {
criticalElements.push(element);
}
}
});
}
}
// Create the issue
const issue: AIAccessibilityIssue = {
id: ref.id,
title: audit.title,
impact,
category: ref.group || "other",
elements: elements.length > 0 ? elements : undefined,
score: audit.score,
};
issues.push(issue);
});
// Calculate overall score
const score = Math.round((categoryData?.score || 0) * 100);
// Generate prioritized recommendations
const prioritized_recommendations: string[] = [];
// Add category-specific recommendations
Object.entries(categories)
.filter(([_, data]) => data.issues_count > 0)
.sort(([_, a], [__, b]) => b.issues_count - a.issues_count)
.forEach(([category, data]) => {
let recommendation = "";
switch (category) {
case "a11y-color-contrast":
recommendation = "Improve color contrast for better readability";
break;
case "a11y-names-labels":
recommendation = "Add proper labels to all interactive elements";
break;
case "a11y-aria":
recommendation = "Fix ARIA attributes and roles";
break;
case "a11y-navigation":
recommendation = "Improve keyboard navigation and focus management";
break;
case "a11y-language":
recommendation = "Add proper language attributes to HTML";
break;
case "a11y-tables-lists":
recommendation = "Fix table and list structures for screen readers";
break;
default:
recommendation = `Fix ${data.issues_count} issues in ${category}`;
}
prioritized_recommendations.push(recommendation);
});
// Add specific high-impact recommendations
if (issues.some((issue) => issue.id === "color-contrast")) {
prioritized_recommendations.push(
"Fix low contrast text for better readability"
);
}
if (issues.some((issue) => issue.id === "document-title")) {
prioritized_recommendations.push("Add a descriptive page title");
}
if (issues.some((issue) => issue.id === "image-alt")) {
prioritized_recommendations.push("Add alt text to all images");
}
// Create the report content
const reportContent: AccessibilityReportContent = {
score,
audit_counts: {
failed: failedCount,
passed: passedCount,
manual: manualCount,
informative: informativeCount,
not_applicable: notApplicableCount,
},
issues,
categories,
critical_elements: criticalElements,
prioritized_recommendations:
prioritized_recommendations.length > 0
? prioritized_recommendations
: undefined,
};
// Return the full report following the LighthouseReport interface
return {
metadata, metadata,
overallScore, report: reportContent,
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

@ -2,16 +2,33 @@ import { Result as LighthouseResult } from "lighthouse";
import { AuditCategory, LighthouseReport } from "./types.js"; import { AuditCategory, LighthouseReport } from "./types.js";
import { runLighthouseAudit } from "./index.js"; import { runLighthouseAudit } from "./index.js";
interface PerformanceAuditDetails { // === Performance Report Types ===
items?: Array<{
resourceUrl?: string; // e.g., "https://example.com/script.js" (for render-blocking resources) /**
wastedMs?: number; // e.g., 150 (potential savings) * Performance-specific report content structure
elementSelector?: string; // e.g., "img.hero" (for LCP element) */
timing?: number; // e.g., 2.5 (specific timing value) export interface PerformanceReportContent {
}>; score: number; // Overall score (0-100)
type?: string; // e.g., "opportunity" or "table" audit_counts: {
// Counts of different audit types
failed: number;
passed: number;
manual: number;
informative: number;
not_applicable: number;
};
metrics: AIOptimizedMetric[];
opportunities: AIOptimizedOpportunity[];
page_stats?: AIPageStats; // Optional page statistics
prioritized_recommendations?: string[]; // Ordered list of recommendations
} }
/**
* Full performance report implementing the base LighthouseReport interface
*/
export type AIOptimizedPerformanceReport =
LighthouseReport<PerformanceReportContent>;
// AI-optimized performance metric format // AI-optimized performance metric format
interface AIOptimizedMetric { interface AIOptimizedMetric {
id: string; // Short ID like "lcp", "fcp" id: string; // Short ID like "lcp", "fcp"
@ -28,7 +45,7 @@ interface AIOptimizedMetric {
interface AIOptimizedOpportunity { interface AIOptimizedOpportunity {
id: string; // Like "render_blocking", "http2" id: string; // Like "render_blocking", "http2"
savings_ms: number; // Time savings in ms savings_ms: number; // Time savings in ms
severity?: "critical" | "major" | "minor"; // Severity classification severity?: "critical" | "serious" | "moderate" | "minor"; // Severity classification
resources: Array<{ resources: Array<{
url: string; // Resource URL url: string; // Resource URL
savings_ms?: number; // Individual resource savings savings_ms?: number; // Individual resource savings
@ -54,28 +71,13 @@ interface AIPageStats {
main_thread_blocking_time_ms: number; // Time spent blocking the main thread main_thread_blocking_time_ms: number; // Time spent blocking the main thread
} }
// AI-optimized performance report // This ensures we always include critical issues while limiting less important ones
interface AIOptimizedPerformanceReport { const DETAIL_LIMITS = {
metadata: { critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues
url: string; // Page URL serious: 15, // Up to 15 items for serious issues
timestamp: string; // ISO timestamp moderate: 10, // Up to 10 items for moderate issues
device: string; // Device type (desktop, mobile) minor: 3, // Up to 3 items for minor issues
lighthouseVersion: string; // Lighthouse version used };
};
score: number; // Overall score (0-100)
audit_counts: {
// Counts of different audit types
failed: number;
passed: number;
manual: number;
informative: number;
not_applicable: number;
};
metrics: AIOptimizedMetric[];
opportunities: AIOptimizedOpportunity[];
page_stats?: AIPageStats; // Optional page statistics
prioritized_recommendations?: string[]; // Ordered list of recommendations
}
/** /**
* Performance audit adapted for AI consumption * Performance audit adapted for AI consumption
@ -84,7 +86,9 @@ interface AIOptimizedPerformanceReport {
* - Key metrics and opportunities clearly structured * - Key metrics and opportunities clearly structured
* - Only actionable data that an AI can use for recommendations * - Only actionable data that an AI can use for recommendations
*/ */
export async function runPerformanceAudit(url: string): Promise<any> { export async function runPerformanceAudit(
url: string
): Promise<AIOptimizedPerformanceReport> {
try { try {
const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]); const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]);
return extractAIOptimizedData(lhr, url); return extractAIOptimizedData(lhr, url);
@ -495,24 +499,43 @@ const extractAIOptimizedData = (
// Extract opportunities // Extract opportunities
if (audits["render-blocking-resources"]) { if (audits["render-blocking-resources"]) {
const rbrAudit = audits["render-blocking-resources"]; const rbrAudit = audits["render-blocking-resources"];
// Determine impact level based on potential savings
let impact: "critical" | "serious" | "moderate" | "minor" = "moderate";
const savings = Math.round(rbrAudit.numericValue || 0);
if (savings > 2000) {
impact = "critical";
} else if (savings > 1000) {
impact = "serious";
} else if (savings < 300) {
impact = "minor";
}
const opportunity: AIOptimizedOpportunity = { const opportunity: AIOptimizedOpportunity = {
id: "render_blocking_resources", id: "render_blocking_resources",
savings_ms: Math.round(rbrAudit.numericValue || 0), savings_ms: savings,
severity: impact,
resources: [], resources: [],
}; };
const rbrDetails = rbrAudit.details as any; const rbrDetails = rbrAudit.details as any;
if (rbrDetails && rbrDetails.items && Array.isArray(rbrDetails.items)) { if (rbrDetails && rbrDetails.items && Array.isArray(rbrDetails.items)) {
rbrDetails.items.forEach((item: { url?: string; wastedMs?: number }) => { // Determine how many items to include based on impact
if (item.url) { const itemLimit = DETAIL_LIMITS[impact];
// Extract file name from full URL
const fileName = item.url.split("/").pop() || item.url; rbrDetails.items
opportunity.resources.push({ .slice(0, itemLimit)
url: fileName, .forEach((item: { url?: string; wastedMs?: number }) => {
savings_ms: Math.round(item.wastedMs || 0), if (item.url) {
}); // Extract file name from full URL
} const fileName = item.url.split("/").pop() || item.url;
}); opportunity.resources.push({
url: fileName,
savings_ms: Math.round(item.wastedMs || 0),
});
}
});
} }
if (opportunity.resources.length > 0) { if (opportunity.resources.length > 0) {
@ -522,9 +545,23 @@ const extractAIOptimizedData = (
if (audits["uses-http2"]) { if (audits["uses-http2"]) {
const http2Audit = audits["uses-http2"]; const http2Audit = audits["uses-http2"];
// Determine impact level based on potential savings
let impact: "critical" | "serious" | "moderate" | "minor" = "moderate";
const savings = Math.round(http2Audit.numericValue || 0);
if (savings > 2000) {
impact = "critical";
} else if (savings > 1000) {
impact = "serious";
} else if (savings < 300) {
impact = "minor";
}
const opportunity: AIOptimizedOpportunity = { const opportunity: AIOptimizedOpportunity = {
id: "http2", id: "http2",
savings_ms: Math.round(http2Audit.numericValue || 0), savings_ms: savings,
severity: impact,
resources: [], resources: [],
}; };
@ -534,13 +571,18 @@ const extractAIOptimizedData = (
http2Details.items && http2Details.items &&
Array.isArray(http2Details.items) Array.isArray(http2Details.items)
) { ) {
http2Details.items.forEach((item: { url?: string }) => { // Determine how many items to include based on impact
if (item.url) { const itemLimit = DETAIL_LIMITS[impact];
// Extract file name from full URL
const fileName = item.url.split("/").pop() || item.url; http2Details.items
opportunity.resources.push({ url: fileName }); .slice(0, itemLimit)
} .forEach((item: { url?: string }) => {
}); if (item.url) {
// Extract file name from full URL
const fileName = item.url.split("/").pop() || item.url;
opportunity.resources.push({ url: fileName });
}
});
} }
if (opportunity.resources.length > 0) { if (opportunity.resources.length > 0) {
@ -705,9 +747,8 @@ const extractAIOptimizedData = (
prioritized_recommendations.push("Reduce JavaScript execution time"); prioritized_recommendations.push("Reduce JavaScript execution time");
} }
// Add to the return object // Create the performance report content
return { const reportContent: PerformanceReportContent = {
metadata,
score, score,
audit_counts, audit_counts,
metrics, metrics,
@ -718,4 +759,10 @@ const extractAIOptimizedData = (
? prioritized_recommendations ? prioritized_recommendations
: undefined, : undefined,
}; };
// Return the full report following the LighthouseReport interface
return {
metadata,
report: reportContent,
};
}; };

View File

@ -2,6 +2,53 @@ import { Result as LighthouseResult } from "lighthouse";
import { AuditCategory, LighthouseReport } from "./types.js"; import { AuditCategory, LighthouseReport } from "./types.js";
import { runLighthouseAudit } from "./index.js"; import { runLighthouseAudit } from "./index.js";
// === SEO Report Types ===
/**
* SEO-specific report content structure
*/
export interface SEOReportContent {
score: number; // Overall score (0-100)
audit_counts: {
// Counts of different audit types
failed: number;
passed: number;
manual: number;
informative: number;
not_applicable: number;
};
issues: AISEOIssue[];
categories: {
[category: string]: {
score: number;
issues_count: number;
};
};
prioritized_recommendations?: string[]; // Ordered list of recommendations
}
/**
* Full SEO report implementing the base LighthouseReport interface
*/
export type AIOptimizedSEOReport = LighthouseReport<SEOReportContent>;
/**
* AI-optimized SEO issue
*/
interface AISEOIssue {
id: string; // e.g., "meta-description"
title: string; // e.g., "Document has a meta description"
impact: "critical" | "serious" | "moderate" | "minor";
category: string; // e.g., "content", "mobile", "crawlability"
details?: {
selector?: string; // CSS selector if applicable
value?: string; // Current value
issue?: string; // Description of the issue
}[];
score: number | null; // 0-1 or null
}
// Original interfaces for backward compatibility
interface SEOAudit { interface SEOAudit {
id: string; // e.g., "meta-description" id: string; // e.g., "meta-description"
title: string; // e.g., "Document has a meta description" title: string; // e.g., "Document has a meta description"
@ -21,13 +68,23 @@ interface SEOAuditDetails {
type?: string; // e.g., "table" type?: string; // e.g., "table"
} }
const FAILED_AUDITS_LIMIT = 5; // This ensures we always include critical issues while limiting less important ones
const MAX_ITEMS_IN_DETAILS = 3; const DETAIL_LIMITS = {
critical: Number.MAX_SAFE_INTEGER, // No limit for critical issues
serious: 15, // Up to 15 items for serious issues
moderate: 10, // Up to 10 items for moderate issues
minor: 3, // Up to 3 items for minor issues
};
export async function runSEOAudit(url: string): Promise<LighthouseReport> { /**
* Runs an SEO audit on the specified URL
* @param url The URL to audit
* @returns Promise resolving to AI-optimized SEO audit results
*/
export async function runSEOAudit(url: string): Promise<AIOptimizedSEOReport> {
try { try {
const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]); const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]);
return extractSEOResult(lhr, url); return extractAIOptimizedData(lhr, url);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`SEO audit failed: ${ `SEO audit failed: ${
@ -37,103 +94,270 @@ export async function runSEOAudit(url: string): Promise<LighthouseReport> {
} }
} }
const extractSEOResult = ( /**
* Extract AI-optimized SEO data from Lighthouse results
*/
const extractAIOptimizedData = (
lhr: LighthouseResult, lhr: LighthouseResult,
url: string url: string
): LighthouseReport => { ): AIOptimizedSEOReport => {
const categoryData = lhr.categories[AuditCategory.SEO]; const categoryData = lhr.categories[AuditCategory.SEO];
const audits = lhr.audits || {};
// Add metadata
const metadata = { const metadata = {
url, url,
timestamp: lhr.fetchTime timestamp: lhr.fetchTime || new Date().toISOString(),
? new Date(lhr.fetchTime).toISOString() device: "desktop", // This could be made configurable
: new Date().toISOString(),
device: "desktop", // TODO: pass device from the request instead of hardcoding
lighthouseVersion: lhr.lighthouseVersion, lighthouseVersion: lhr.lighthouseVersion,
}; };
if (!categoryData) { // Initialize variables
return { const issues: AISEOIssue[] = [];
metadata, const categories: {
failedAudits: [], [category: string]: { score: number; issues_count: number };
overallScore: 0, } = {
failedAuditsCount: 0, content: { score: 0, issues_count: 0 },
passedAuditsCount: 0, mobile: { score: 0, issues_count: 0 },
manualAuditsCount: 0, crawlability: { score: 0, issues_count: 0 },
informativeAuditsCount: 0, other: { score: 0, issues_count: 0 },
notApplicableAuditsCount: 0, };
};
}
const overallScore = Math.round((categoryData.score || 0) * 100); // Count audits by type
const auditRefs = categoryData.auditRefs || []; let failedCount = 0;
const audits = lhr.audits || {}; let passedCount = 0;
let manualCount = 0;
let informativeCount = 0;
let notApplicableCount = 0;
const seoAudits: SEOAudit[] = auditRefs.map((ref) => { // Process audit refs
const auditRefs = categoryData?.auditRefs || [];
// First pass: count audits by type and initialize categories
auditRefs.forEach((ref) => {
const audit = audits[ref.id]; const audit = audits[ref.id];
let simplifiedDetails: SEOAuditDetails | undefined; if (!audit) return;
if (audit.details) { // Count by scoreDisplayMode
simplifiedDetails = {}; if (audit.scoreDisplayMode === "manual") {
if ( manualCount++;
(audit.details as any).items && } else if (audit.scoreDisplayMode === "informative") {
Array.isArray((audit.details as any).items) informativeCount++;
) { } else if (audit.scoreDisplayMode === "notApplicable") {
const limitedItems = (audit.details as any).items.slice( notApplicableCount++;
0, } else if (audit.score !== null) {
MAX_ITEMS_IN_DETAILS // Binary pass/fail
); if (audit.score >= 0.9) {
simplifiedDetails.items = limitedItems.map((item: any) => { passedCount++;
const simplifiedItem: any = {}; } else {
if (item.node?.selector) simplifiedItem.selector = item.node.selector; failedCount++;
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 { // Categorize the issue
id: ref.id, let category = "other";
title: audit.title || "Untitled", if (
description: audit.description || "No description", ref.id.includes("crawl") ||
score: audit.score, ref.id.includes("http") ||
scoreDisplayMode: audit.scoreDisplayMode || "numeric", ref.id.includes("redirect") ||
details: simplifiedDetails, ref.id.includes("robots")
weight: ref.weight || 1, ) {
}; category = "crawlability";
} else if (
ref.id.includes("viewport") ||
ref.id.includes("font-size") ||
ref.id.includes("tap-targets")
) {
category = "mobile";
} else if (
ref.id.includes("document") ||
ref.id.includes("meta") ||
ref.id.includes("description") ||
ref.id.includes("canonical") ||
ref.id.includes("title") ||
ref.id.includes("link")
) {
category = "content";
}
// Update category score and issues count
if (audit.score !== null && audit.score < 0.9) {
categories[category].issues_count++;
}
}); });
const failedAudits = seoAudits // Second pass: process failed audits into AI-friendly format
.filter((audit) => audit.score !== null && audit.score < 1) auditRefs
.sort( .filter((ref) => {
(a, b) => const audit = audits[ref.id];
b.weight! * (1 - (b.score || 0)) - a.weight! * (1 - (a.score || 0)) return audit && audit.score !== null && audit.score < 0.9;
) })
.slice(0, FAILED_AUDITS_LIMIT); .sort((a, b) => (b.weight || 0) - (a.weight || 0))
// No limit on failed audits - we'll filter dynamically based on impact
.forEach((ref) => {
const audit = audits[ref.id];
const passedAudits = seoAudits.filter( // Determine impact level based on score and weight
(audit) => audit.score !== null && audit.score >= 1 let impact: "critical" | "serious" | "moderate" | "minor" = "moderate";
); if (audit.score === 0) {
const manualAudits = seoAudits.filter( impact = "critical";
(audit) => audit.scoreDisplayMode === "manual" } else if (audit.score !== null && audit.score <= 0.5) {
); impact = "serious";
const informativeAudits = seoAudits.filter( } else if (audit.score !== null && audit.score > 0.7) {
(audit) => audit.scoreDisplayMode === "informative" impact = "minor";
); }
const notApplicableAudits = seoAudits.filter(
(audit) => audit.scoreDisplayMode === "notApplicable"
);
// Categorize the issue
let category = "other";
if (
ref.id.includes("crawl") ||
ref.id.includes("http") ||
ref.id.includes("redirect") ||
ref.id.includes("robots")
) {
category = "crawlability";
} else if (
ref.id.includes("viewport") ||
ref.id.includes("font-size") ||
ref.id.includes("tap-targets")
) {
category = "mobile";
} else if (
ref.id.includes("document") ||
ref.id.includes("meta") ||
ref.id.includes("description") ||
ref.id.includes("canonical") ||
ref.id.includes("title") ||
ref.id.includes("link")
) {
category = "content";
}
// Extract details
const details: { selector?: string; value?: string; issue?: string }[] =
[];
if (audit.details) {
const auditDetails = audit.details as any;
if (auditDetails.items && Array.isArray(auditDetails.items)) {
// Determine item limit based on impact
const itemLimit = DETAIL_LIMITS[impact];
auditDetails.items.slice(0, itemLimit).forEach((item: any) => {
const detail: {
selector?: string;
value?: string;
issue?: string;
} = {};
if (item.selector) {
detail.selector = item.selector;
}
if (item.value !== undefined) {
detail.value = item.value;
}
if (item.issue) {
detail.issue = item.issue;
}
if (Object.keys(detail).length > 0) {
details.push(detail);
}
});
}
}
// Create the issue
const issue: AISEOIssue = {
id: ref.id,
title: audit.title,
impact,
category,
details: details.length > 0 ? details : undefined,
score: audit.score,
};
issues.push(issue);
});
// Calculate overall score
const score = Math.round((categoryData?.score || 0) * 100);
// Generate prioritized recommendations
const prioritized_recommendations: string[] = [];
// Add category-specific recommendations
Object.entries(categories)
.filter(([_, data]) => data.issues_count > 0)
.sort(([_, a], [__, b]) => b.issues_count - a.issues_count)
.forEach(([category, data]) => {
if (data.issues_count === 0) return;
let recommendation = "";
switch (category) {
case "content":
recommendation = `Improve SEO content (${data.issues_count} issues): titles, descriptions, and headers`;
break;
case "mobile":
recommendation = `Optimize for mobile devices (${data.issues_count} issues)`;
break;
case "crawlability":
recommendation = `Fix crawlability issues (${data.issues_count} issues): robots.txt, sitemaps, and redirects`;
break;
default:
recommendation = `Fix ${data.issues_count} SEO issues in category: ${category}`;
}
prioritized_recommendations.push(recommendation);
});
// Add specific high-impact recommendations
if (issues.some((issue) => issue.id === "meta-description")) {
prioritized_recommendations.push(
"Add a meta description to improve click-through rate"
);
}
if (issues.some((issue) => issue.id === "document-title")) {
prioritized_recommendations.push(
"Add a descriptive page title with keywords"
);
}
if (issues.some((issue) => issue.id === "hreflang")) {
prioritized_recommendations.push(
"Fix hreflang implementation for international SEO"
);
}
if (issues.some((issue) => issue.id === "canonical")) {
prioritized_recommendations.push("Implement proper canonical tags");
}
// Create the report content
const reportContent: SEOReportContent = {
score,
audit_counts: {
failed: failedCount,
passed: passedCount,
manual: manualCount,
informative: informativeCount,
not_applicable: notApplicableCount,
},
issues,
categories,
prioritized_recommendations:
prioritized_recommendations.length > 0
? prioritized_recommendations
: undefined,
};
// Return the full report following the LighthouseReport interface
return { return {
metadata, metadata,
overallScore, report: reportContent,
failedAuditsCount: failedAudits.length,
passedAuditsCount: passedAudits.length,
manualAuditsCount: manualAudits.length,
informativeAuditsCount: informativeAudits.length,
notApplicableAuditsCount: notApplicableAudits.length,
failedAudits,
}; };
}; };

View File

@ -12,20 +12,25 @@ export enum AuditCategory {
/** /**
* Base interface for Lighthouse report metadata * Base interface for Lighthouse report metadata
*/ */
export interface LighthouseReport { export interface LighthouseReport<T = any> {
metadata: { metadata: {
url: string; url: string;
timestamp: string; // ISO 8601, e.g., "2025-02-27T14:30:00Z" timestamp: string; // ISO 8601, e.g., "2025-02-27T14:30:00Z"
device: string; // e.g., "mobile", "desktop" device: string; // e.g., "mobile", "desktop"
lighthouseVersion: string; // e.g., "10.4.0" lighthouseVersion: string; // e.g., "10.4.0"
}; };
overallScore: number;
failedAuditsCount: number; // For backward compatibility with existing report formats
passedAuditsCount: number; overallScore?: number;
manualAuditsCount: number; failedAuditsCount?: number;
informativeAuditsCount: number; passedAuditsCount?: number;
notApplicableAuditsCount: number; manualAuditsCount?: number;
failedAudits: any[]; informativeAuditsCount?: number;
notApplicableAuditsCount?: number;
failedAudits?: any[];
// New format for specialized reports
report?: T; // Generic report data that will be specialized by each audit type
} }
/** /**