From 7a7bf8c137ecd6dd3f76eb7de07b31fc23fc1f73 Mon Sep 17 00:00:00 2001 From: Emil Neander Date: Thu, 6 Mar 2025 17:31:05 +0100 Subject: [PATCH] refactor: standardize Lighthouse audit reports with AI-optimized data extraction --- browser-tools-mcp/mcp-server.ts | 88 ++-- browser-tools-server/browser-connector.ts | 2 +- .../lighthouse/accessibility.ts | 390 ++++++++++++------ .../lighthouse/performance.ts | 155 ++++--- browser-tools-server/lighthouse/seo.ts | 386 +++++++++++++---- browser-tools-server/lighthouse/types.ts | 21 +- 6 files changed, 740 insertions(+), 302 deletions(-) diff --git a/browser-tools-mcp/mcp-server.ts b/browser-tools-mcp/mcp-server.ts index 2ceed22..daeefeb 100644 --- a/browser-tools-mcp/mcp-server.ts +++ b/browser-tools-mcp/mcp-server.ts @@ -351,12 +351,11 @@ enum AuditCategory { // Add tool for accessibility audits, launches a headless browser instance server.tool( "runAccessibilityAudit", - "Run a WCAG-compliant accessibility audit on the current page", + "Run an accessibility audit on the current page", {}, async () => { return await withServerConnection(async () => { try { - // Simplified approach - let the browser connector handle the current tab and URL console.log( `Sending POST request to http://${discoveredHost}:${discoveredPort}/accessibility-audit` ); @@ -369,32 +368,48 @@ server.tool( Accept: "application/json", }, body: JSON.stringify({ - category: AuditCategory.ACCESSIBILITY, source: "mcp_tool", timestamp: Date.now(), }), } ); - // Log the response status - console.log(`Accessibility audit response status: ${response.status}`); - + // Check for errors if (!response.ok) { const errorText = await response.text(); - console.error(`Accessibility audit error: ${errorText}`); throw new Error(`Server returned ${response.status}: ${errorText}`); } const json = await response.json(); - return { - content: [ - { - type: "text", - text: JSON.stringify(json, null, 2), - }, - ], - }; + // If the response is in the new format with a nested 'report', + // flatten it by merging metadata with the report contents + if (json.report) { + const { metadata, report } = json; + 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) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -484,7 +499,6 @@ server.tool( async () => { return await withServerConnection(async () => { try { - // Simplified approach - let the browser connector handle the current tab and URL console.log( `Sending POST request to http://${discoveredHost}:${discoveredPort}/seo-audit` ); @@ -497,32 +511,48 @@ server.tool( Accept: "application/json", }, body: JSON.stringify({ - category: AuditCategory.SEO, source: "mcp_tool", timestamp: Date.now(), }), } ); - // Log the response status - console.log(`SEO audit response status: ${response.status}`); - + // Check for errors if (!response.ok) { const errorText = await response.text(); - console.error(`SEO audit error: ${errorText}`); throw new Error(`Server returned ${response.status}: ${errorText}`); } const json = await response.json(); - return { - content: [ - { - type: "text", - text: JSON.stringify(json, null, 2), - }, - ], - }; + // If the response is in the new format with a nested 'report', + // flatten it by merging metadata with the report contents + if (json.report) { + const { metadata, report } = json; + 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) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/browser-tools-server/browser-connector.ts b/browser-tools-server/browser-connector.ts index 35acbef..b33485b 100644 --- a/browser-tools-server/browser-connector.ts +++ b/browser-tools-server/browser-connector.ts @@ -890,7 +890,7 @@ export class BrowserConnector { 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) - const maxAttempts = 20; + const maxAttempts = 50; const waitTime = 500; // ms for (let attempt = 0; attempt < maxAttempts; attempt++) { diff --git a/browser-tools-server/lighthouse/accessibility.ts b/browser-tools-server/lighthouse/accessibility.ts index 3d1f504..3363447 100644 --- a/browser-tools-server/lighthouse/accessibility.ts +++ b/browser-tools-server/lighthouse/accessibility.ts @@ -2,6 +2,62 @@ import { Result as LighthouseResult } from "lighthouse"; import { AuditCategory, LighthouseReport } from "./types.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; + +/** + * 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 { id: string; // e.g., "color-contrast" title: string; // e.g., "Color contrast is sufficient" @@ -27,24 +83,26 @@ type AuditDetails = { [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; +// Original limits were optimized for human consumption +// This ensures we always include critical issues while limiting less important ones +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 +}; /** * Runs an accessibility audit on the specified URL * @param url The URL to audit - * @param limit Maximum number of issues to return - * @returns Promise resolving to simplified accessibility audit results + * @returns Promise resolving to AI-optimized accessibility audit results */ export async function runAccessibilityAudit( url: string -): Promise { +): Promise { try { const lhr = await runLighthouseAudit(url, [AuditCategory.ACCESSIBILITY]); - const accessibilityReport = extractLhrResult(lhr, url); - return accessibilityReport; + return extractAIOptimizedData(lhr, url); } catch (error) { throw new Error( `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, url: string -): LighthouseReport => { +): AIOptimizedAccessibilityReport => { const categoryData = lhr.categories[AuditCategory.ACCESSIBILITY]; + const audits = lhr.audits || {}; + // Add metadata 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 + timestamp: lhr.fetchTime || new Date().toISOString(), + device: "desktop", // This could be made configurable lighthouseVersion: lhr.lighthouseVersion, }; - if (!categoryData) - return { - metadata, - failedAudits: [], - overallScore: 0, - failedAuditsCount: 0, - passedAuditsCount: 0, - manualAuditsCount: 0, - informativeAuditsCount: 0, - notApplicableAuditsCount: 0, - }; + // Initialize variables + const issues: AIAccessibilityIssue[] = []; + const criticalElements: AIAccessibilityElement[] = []; + const categories: { + [category: string]: { score: number; issues_count: number }; + } = {}; - const overallScore = Math.round((categoryData.score || 0) * 100); - const auditRefs = categoryData.auditRefs || []; - const audits = lhr.audits || {}; + // Count audits by type + let failedCount = 0; + 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]; + if (!audit) return; - // 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; + // Count by scoreDisplayMode + if (audit.scoreDisplayMode === "manual") { + manualCount++; + } else if (audit.scoreDisplayMode === "informative") { + informativeCount++; + } else if (audit.scoreDisplayMode === "notApplicable") { + notApplicableCount++; + } else if (audit.score !== null) { + // Binary pass/fail + if (audit.score >= 0.9) { + passedCount++; + } else { + failedCount++; } } - 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, - }; + // Process categories + if (ref.group) { + // Initialize category if not exists + if (!categories[ref.group]) { + categories[ref.group] = { score: 0, issues_count: 0 }; + } + + // Update category score and issues count + if (audit.score !== null && audit.score < 0.9) { + categories[ref.group].issues_count++; + } + } }); - 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); + // Second pass: process failed audits into AI-friendly format + auditRefs + .filter((ref) => { + const audit = audits[ref.id]; + return audit && audit.score !== null && audit.score < 0.9; + }) + .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( - (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" - ); + // Determine impact level based on score and weight + let impact: "critical" | "serious" | "moderate" | "minor" = "moderate"; + if (audit.score === 0) { + impact = "critical"; + } else if (audit.score !== null && audit.score <= 0.5) { + impact = "serious"; + } else if (audit.score !== null && audit.score > 0.7) { + impact = "minor"; + } - 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, - 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; + report: reportContent, + }; }; diff --git a/browser-tools-server/lighthouse/performance.ts b/browser-tools-server/lighthouse/performance.ts index 3f20e9c..dce9ff5 100644 --- a/browser-tools-server/lighthouse/performance.ts +++ b/browser-tools-server/lighthouse/performance.ts @@ -2,16 +2,33 @@ import { Result as LighthouseResult } from "lighthouse"; import { AuditCategory, LighthouseReport } from "./types.js"; import { runLighthouseAudit } from "./index.js"; -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" +// === Performance Report Types === + +/** + * Performance-specific report content structure + */ +export interface PerformanceReportContent { + 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 } +/** + * Full performance report implementing the base LighthouseReport interface + */ +export type AIOptimizedPerformanceReport = + LighthouseReport; + // AI-optimized performance metric format interface AIOptimizedMetric { id: string; // Short ID like "lcp", "fcp" @@ -28,7 +45,7 @@ interface AIOptimizedMetric { interface AIOptimizedOpportunity { id: string; // Like "render_blocking", "http2" savings_ms: number; // Time savings in ms - severity?: "critical" | "major" | "minor"; // Severity classification + severity?: "critical" | "serious" | "moderate" | "minor"; // Severity classification resources: Array<{ url: string; // Resource URL savings_ms?: number; // Individual resource savings @@ -54,28 +71,13 @@ interface AIPageStats { main_thread_blocking_time_ms: number; // Time spent blocking the main thread } -// AI-optimized performance report -interface AIOptimizedPerformanceReport { - metadata: { - url: string; // Page URL - timestamp: string; // ISO timestamp - device: string; // Device type (desktop, mobile) - 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 -} +// This ensures we always include critical issues while limiting less important ones +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 +}; /** * Performance audit adapted for AI consumption @@ -84,7 +86,9 @@ interface AIOptimizedPerformanceReport { * - Key metrics and opportunities clearly structured * - Only actionable data that an AI can use for recommendations */ -export async function runPerformanceAudit(url: string): Promise { +export async function runPerformanceAudit( + url: string +): Promise { try { const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]); return extractAIOptimizedData(lhr, url); @@ -495,24 +499,43 @@ const extractAIOptimizedData = ( // Extract opportunities if (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 = { id: "render_blocking_resources", - savings_ms: Math.round(rbrAudit.numericValue || 0), + savings_ms: savings, + severity: impact, resources: [], }; const rbrDetails = rbrAudit.details as any; if (rbrDetails && rbrDetails.items && Array.isArray(rbrDetails.items)) { - rbrDetails.items.forEach((item: { url?: string; wastedMs?: number }) => { - 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), - }); - } - }); + // Determine how many items to include based on impact + const itemLimit = DETAIL_LIMITS[impact]; + + rbrDetails.items + .slice(0, itemLimit) + .forEach((item: { url?: string; wastedMs?: number }) => { + 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) { @@ -522,9 +545,23 @@ const extractAIOptimizedData = ( if (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 = { id: "http2", - savings_ms: Math.round(http2Audit.numericValue || 0), + savings_ms: savings, + severity: impact, resources: [], }; @@ -534,13 +571,18 @@ const extractAIOptimizedData = ( http2Details.items && Array.isArray(http2Details.items) ) { - http2Details.items.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 }); - } - }); + // Determine how many items to include based on impact + const itemLimit = DETAIL_LIMITS[impact]; + + http2Details.items + .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) { @@ -705,9 +747,8 @@ const extractAIOptimizedData = ( prioritized_recommendations.push("Reduce JavaScript execution time"); } - // Add to the return object - return { - metadata, + // Create the performance report content + const reportContent: PerformanceReportContent = { score, audit_counts, metrics, @@ -718,4 +759,10 @@ const extractAIOptimizedData = ( ? prioritized_recommendations : undefined, }; + + // Return the full report following the LighthouseReport interface + return { + metadata, + report: reportContent, + }; }; diff --git a/browser-tools-server/lighthouse/seo.ts b/browser-tools-server/lighthouse/seo.ts index 85dc0e1..4b83b29 100644 --- a/browser-tools-server/lighthouse/seo.ts +++ b/browser-tools-server/lighthouse/seo.ts @@ -2,6 +2,53 @@ import { Result as LighthouseResult } from "lighthouse"; import { AuditCategory, LighthouseReport } from "./types.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; + +/** + * 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 { id: string; // e.g., "meta-description" title: string; // e.g., "Document has a meta description" @@ -21,13 +68,23 @@ interface SEOAuditDetails { type?: string; // e.g., "table" } -const FAILED_AUDITS_LIMIT = 5; -const MAX_ITEMS_IN_DETAILS = 3; +// This ensures we always include critical issues while limiting less important ones +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 { +/** + * 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 { try { const lhr = await runLighthouseAudit(url, [AuditCategory.SEO]); - return extractSEOResult(lhr, url); + return extractAIOptimizedData(lhr, url); } catch (error) { throw new Error( `SEO audit failed: ${ @@ -37,103 +94,270 @@ export async function runSEOAudit(url: string): Promise { } } -const extractSEOResult = ( +/** + * Extract AI-optimized SEO data from Lighthouse results + */ +const extractAIOptimizedData = ( lhr: LighthouseResult, url: string -): LighthouseReport => { +): AIOptimizedSEOReport => { const categoryData = lhr.categories[AuditCategory.SEO]; + const audits = lhr.audits || {}; + + // Add metadata 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 + timestamp: lhr.fetchTime || new Date().toISOString(), + device: "desktop", // This could be made configurable lighthouseVersion: lhr.lighthouseVersion, }; - if (!categoryData) { - return { - metadata, - failedAudits: [], - overallScore: 0, - failedAuditsCount: 0, - passedAuditsCount: 0, - manualAuditsCount: 0, - informativeAuditsCount: 0, - notApplicableAuditsCount: 0, - }; - } + // Initialize variables + const issues: AISEOIssue[] = []; + const categories: { + [category: string]: { score: number; issues_count: number }; + } = { + content: { score: 0, issues_count: 0 }, + mobile: { score: 0, issues_count: 0 }, + crawlability: { score: 0, issues_count: 0 }, + other: { score: 0, issues_count: 0 }, + }; - const overallScore = Math.round((categoryData.score || 0) * 100); - const auditRefs = categoryData.auditRefs || []; - const audits = lhr.audits || {}; + // Count audits by type + let failedCount = 0; + 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]; - let simplifiedDetails: SEOAuditDetails | undefined; + if (!audit) return; - 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; - }); + // Count by scoreDisplayMode + if (audit.scoreDisplayMode === "manual") { + manualCount++; + } else if (audit.scoreDisplayMode === "informative") { + informativeCount++; + } else if (audit.scoreDisplayMode === "notApplicable") { + notApplicableCount++; + } else if (audit.score !== null) { + // Binary pass/fail + if (audit.score >= 0.9) { + passedCount++; + } else { + failedCount++; } - 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, - }; + // 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"; + } + + // Update category score and issues count + if (audit.score !== null && audit.score < 0.9) { + categories[category].issues_count++; + } }); - 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); + // Second pass: process failed audits into AI-friendly format + auditRefs + .filter((ref) => { + const audit = audits[ref.id]; + return audit && audit.score !== null && audit.score < 0.9; + }) + .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( - (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" - ); + // Determine impact level based on score and weight + let impact: "critical" | "serious" | "moderate" | "minor" = "moderate"; + if (audit.score === 0) { + impact = "critical"; + } else if (audit.score !== null && audit.score <= 0.5) { + impact = "serious"; + } else if (audit.score !== null && audit.score > 0.7) { + impact = "minor"; + } + // 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 { metadata, - overallScore, - failedAuditsCount: failedAudits.length, - passedAuditsCount: passedAudits.length, - manualAuditsCount: manualAudits.length, - informativeAuditsCount: informativeAudits.length, - notApplicableAuditsCount: notApplicableAudits.length, - failedAudits, + report: reportContent, }; }; diff --git a/browser-tools-server/lighthouse/types.ts b/browser-tools-server/lighthouse/types.ts index 32680f1..5de3536 100644 --- a/browser-tools-server/lighthouse/types.ts +++ b/browser-tools-server/lighthouse/types.ts @@ -12,20 +12,25 @@ export enum AuditCategory { /** * Base interface for Lighthouse report metadata */ -export interface LighthouseReport { +export interface LighthouseReport { metadata: { url: 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[]; + + // For backward compatibility with existing report formats + overallScore?: number; + failedAuditsCount?: number; + passedAuditsCount?: number; + manualAuditsCount?: number; + informativeAuditsCount?: number; + notApplicableAuditsCount?: number; + failedAudits?: any[]; + + // New format for specialized reports + report?: T; // Generic report data that will be specialized by each audit type } /**