BrowserTools MCP

by oenius
Verified
import { Result as LighthouseResult } from "lighthouse"; import { AuditCategory, LighthouseReport } from "./types.js"; import { runLighthouseAudit } from "./index.js"; // === 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<PerformanceReportContent>; // AI-optimized performance metric format interface AIOptimizedMetric { id: string; // Short ID like "lcp", "fcp" score: number | null; // 0-1 score value_ms: number; // Value in milliseconds element_type?: string; // For LCP: "image", "text", etc. element_selector?: string; // DOM selector for the element element_url?: string; // For images/videos element_content?: string; // For text content (truncated) passes_core_web_vital?: boolean; // Whether this metric passes as a Core Web Vital } // AI-optimized opportunity format interface AIOptimizedOpportunity { id: string; // Like "render_blocking", "http2" savings_ms: number; // Time savings in ms severity?: "critical" | "serious" | "moderate" | "minor"; // Severity classification resources: Array<{ url: string; // Resource URL savings_ms?: number; // Individual resource savings size_kb?: number; // Size in KB type?: string; // Resource type (js, css, img, etc.) is_third_party?: boolean; // Whether this is a third-party resource }>; } // Page stats for AI analysis interface AIPageStats { total_size_kb: number; // Total page weight in KB total_requests: number; // Total number of requests resource_counts: { // Count by resource type js: number; css: number; img: number; font: number; other: number; }; third_party_size_kb: number; // Size of third-party resources main_thread_blocking_time_ms: number; // Time spent blocking the main thread } // 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 * This format is optimized for AI agents with: * - Concise, relevant information without redundant descriptions * - Key metrics and opportunities clearly structured * - Only actionable data that an AI can use for recommendations */ export async function runPerformanceAudit( url: string ): Promise<AIOptimizedPerformanceReport> { try { const lhr = await runLighthouseAudit(url, [AuditCategory.PERFORMANCE]); return extractAIOptimizedData(lhr, url); } catch (error) { throw new Error( `Performance audit failed: ${ error instanceof Error ? error.message : String(error) }` ); } } /** * Extract AI-optimized performance data from Lighthouse results */ const extractAIOptimizedData = ( lhr: LighthouseResult, url: string ): AIOptimizedPerformanceReport => { const audits = lhr.audits || {}; const categoryData = lhr.categories[AuditCategory.PERFORMANCE]; const score = Math.round((categoryData?.score || 0) * 100); // Add metadata const metadata = { url, timestamp: lhr.fetchTime || new Date().toISOString(), device: "desktop", // This could be made configurable lighthouseVersion: lhr.lighthouseVersion, }; // Count audits by type const auditRefs = categoryData?.auditRefs || []; let failedCount = 0; let passedCount = 0; let manualCount = 0; let informativeCount = 0; let notApplicableCount = 0; auditRefs.forEach((ref) => { const audit = audits[ref.id]; if (!audit) return; if (audit.scoreDisplayMode === "manual") { manualCount++; } else if (audit.scoreDisplayMode === "informative") { informativeCount++; } else if (audit.scoreDisplayMode === "notApplicable") { notApplicableCount++; } else if (audit.score !== null) { if (audit.score >= 0.9) { passedCount++; } else { failedCount++; } } }); const audit_counts = { failed: failedCount, passed: passedCount, manual: manualCount, informative: informativeCount, not_applicable: notApplicableCount, }; const metrics: AIOptimizedMetric[] = []; const opportunities: AIOptimizedOpportunity[] = []; // Extract core metrics if (audits["largest-contentful-paint"]) { const lcp = audits["largest-contentful-paint"]; const lcpElement = audits["largest-contentful-paint-element"]; const metric: AIOptimizedMetric = { id: "lcp", score: lcp.score, value_ms: Math.round(lcp.numericValue || 0), passes_core_web_vital: lcp.score !== null && lcp.score >= 0.9, }; // Enhanced LCP element detection // 1. Try from largest-contentful-paint-element audit if (lcpElement && lcpElement.details) { const lcpDetails = lcpElement.details as any; // First attempt - try to get directly from items if ( lcpDetails.items && Array.isArray(lcpDetails.items) && lcpDetails.items.length > 0 ) { const item = lcpDetails.items[0]; // For text elements in tables format if (item.type === "table" && item.items && item.items.length > 0) { const firstTableItem = item.items[0]; if (firstTableItem.node) { if (firstTableItem.node.selector) { metric.element_selector = firstTableItem.node.selector; } // Determine element type based on path or selector const path = firstTableItem.node.path; const selector = firstTableItem.node.selector || ""; if (path) { if ( selector.includes(" > img") || selector.includes(" img") || selector.endsWith("img") || path.includes(",IMG") ) { metric.element_type = "image"; // Try to extract image name from selector const imgMatch = selector.match(/img[.][^> ]+/); if (imgMatch && !metric.element_url) { metric.element_url = imgMatch[0]; } } else if ( path.includes(",SPAN") || path.includes(",P") || path.includes(",H") ) { metric.element_type = "text"; } } // Try to extract text content if available if (firstTableItem.node.nodeLabel) { metric.element_content = firstTableItem.node.nodeLabel.substring( 0, 100 ); } } } // Original handling for direct items else if (item.node?.nodeLabel) { // Determine element type from node label if (item.node.nodeLabel.startsWith("<img")) { metric.element_type = "image"; // Try to extract image URL from the node snippet const match = item.node.snippet?.match(/src="([^"]+)"/); if (match && match[1]) { metric.element_url = match[1]; } } else if (item.node.nodeLabel.startsWith("<video")) { metric.element_type = "video"; } else if (item.node.nodeLabel.startsWith("<h")) { metric.element_type = "heading"; } else { metric.element_type = "text"; } if (item.node?.selector) { metric.element_selector = item.node.selector; } } } } // 2. Try from lcp-lazy-loaded audit const lcpImageAudit = audits["lcp-lazy-loaded"]; if (lcpImageAudit && lcpImageAudit.details) { const lcpImageDetails = lcpImageAudit.details as any; if ( lcpImageDetails.items && Array.isArray(lcpImageDetails.items) && lcpImageDetails.items.length > 0 ) { const item = lcpImageDetails.items[0]; if (item.url) { metric.element_type = "image"; metric.element_url = item.url; } } } // 3. Try directly from the LCP audit details if (!metric.element_url && lcp.details) { const lcpDirectDetails = lcp.details as any; if (lcpDirectDetails.items && Array.isArray(lcpDirectDetails.items)) { for (const item of lcpDirectDetails.items) { if (item.url || (item.node && item.node.path)) { if (item.url) { metric.element_url = item.url; metric.element_type = item.url.match( /\.(jpg|jpeg|png|gif|webp|svg)$/i ) ? "image" : "resource"; } if (item.node && item.node.selector) { metric.element_selector = item.node.selector; } break; } } } } // 4. Check for specific audit that might contain image info const largestImageAudit = audits["largest-image-paint"]; if (largestImageAudit && largestImageAudit.details) { const imageDetails = largestImageAudit.details as any; if ( imageDetails.items && Array.isArray(imageDetails.items) && imageDetails.items.length > 0 ) { const item = imageDetails.items[0]; if (item.url) { // If we have a large image that's close in time to LCP, it's likely the LCP element metric.element_type = "image"; metric.element_url = item.url; } } } // 5. Check for network requests audit to find image resources if (!metric.element_url) { const networkRequests = audits["network-requests"]; if (networkRequests && networkRequests.details) { const networkDetails = networkRequests.details as any; if (networkDetails.items && Array.isArray(networkDetails.items)) { // Get all image resources loaded close to the LCP time const lcpTime = lcp.numericValue || 0; const imageResources = networkDetails.items .filter( (item: any) => item.url && item.mimeType && item.mimeType.startsWith("image/") && item.endTime && Math.abs(item.endTime - lcpTime) < 500 // Within 500ms of LCP ) .sort( (a: any, b: any) => Math.abs(a.endTime - lcpTime) - Math.abs(b.endTime - lcpTime) ); if (imageResources.length > 0) { const closestImage = imageResources[0]; if (!metric.element_type) { metric.element_type = "image"; metric.element_url = closestImage.url; } } } } } metrics.push(metric); } if (audits["first-contentful-paint"]) { const fcp = audits["first-contentful-paint"]; metrics.push({ id: "fcp", score: fcp.score, value_ms: Math.round(fcp.numericValue || 0), passes_core_web_vital: fcp.score !== null && fcp.score >= 0.9, }); } if (audits["speed-index"]) { const si = audits["speed-index"]; metrics.push({ id: "si", score: si.score, value_ms: Math.round(si.numericValue || 0), }); } if (audits["interactive"]) { const tti = audits["interactive"]; metrics.push({ id: "tti", score: tti.score, value_ms: Math.round(tti.numericValue || 0), }); } // Add CLS (Cumulative Layout Shift) if (audits["cumulative-layout-shift"]) { const cls = audits["cumulative-layout-shift"]; metrics.push({ id: "cls", score: cls.score, // CLS is not in ms, but a unitless value value_ms: Math.round((cls.numericValue || 0) * 1000) / 1000, // Convert to 3 decimal places passes_core_web_vital: cls.score !== null && cls.score >= 0.9, }); } // Add TBT (Total Blocking Time) if (audits["total-blocking-time"]) { const tbt = audits["total-blocking-time"]; metrics.push({ id: "tbt", score: tbt.score, value_ms: Math.round(tbt.numericValue || 0), passes_core_web_vital: tbt.score !== null && tbt.score >= 0.9, }); } // 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: savings, severity: impact, resources: [], }; const rbrDetails = rbrAudit.details as any; if (rbrDetails && rbrDetails.items && Array.isArray(rbrDetails.items)) { // 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) { opportunities.push(opportunity); } } 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: savings, severity: impact, resources: [], }; const http2Details = http2Audit.details as any; if ( http2Details && http2Details.items && Array.isArray(http2Details.items) ) { // 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) { opportunities.push(opportunity); } } // After extracting all metrics and opportunities, collect page stats // Extract page stats let page_stats: AIPageStats | undefined; // Total page stats const totalByteWeight = audits["total-byte-weight"]; const networkRequests = audits["network-requests"]; const thirdPartyAudit = audits["third-party-summary"]; const mainThreadWork = audits["mainthread-work-breakdown"]; if (networkRequests && networkRequests.details) { const resourceDetails = networkRequests.details as any; if (resourceDetails.items && Array.isArray(resourceDetails.items)) { const resources = resourceDetails.items; const totalRequests = resources.length; // Calculate total size and counts by type let totalSizeKb = 0; let jsCount = 0, cssCount = 0, imgCount = 0, fontCount = 0, otherCount = 0; resources.forEach((resource: any) => { const sizeKb = resource.transferSize ? Math.round(resource.transferSize / 1024) : 0; totalSizeKb += sizeKb; // Count by mime type const mimeType = resource.mimeType || ""; if (mimeType.includes("javascript") || resource.url.endsWith(".js")) { jsCount++; } else if (mimeType.includes("css") || resource.url.endsWith(".css")) { cssCount++; } else if ( mimeType.includes("image") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(resource.url) ) { imgCount++; } else if ( mimeType.includes("font") || /\.(woff|woff2|ttf|otf|eot)$/i.test(resource.url) ) { fontCount++; } else { otherCount++; } }); // Calculate third-party size let thirdPartySizeKb = 0; if (thirdPartyAudit && thirdPartyAudit.details) { const thirdPartyDetails = thirdPartyAudit.details as any; if (thirdPartyDetails.items && Array.isArray(thirdPartyDetails.items)) { thirdPartyDetails.items.forEach((item: any) => { if (item.transferSize) { thirdPartySizeKb += Math.round(item.transferSize / 1024); } }); } } // Get main thread blocking time let mainThreadBlockingTimeMs = 0; if (mainThreadWork && mainThreadWork.numericValue) { mainThreadBlockingTimeMs = Math.round(mainThreadWork.numericValue); } // Create page stats object page_stats = { total_size_kb: totalSizeKb, total_requests: totalRequests, resource_counts: { js: jsCount, css: cssCount, img: imgCount, font: fontCount, other: otherCount, }, third_party_size_kb: thirdPartySizeKb, main_thread_blocking_time_ms: mainThreadBlockingTimeMs, }; } } // Generate prioritized recommendations const prioritized_recommendations: string[] = []; // Add key recommendations based on failed audits with high impact if ( audits["render-blocking-resources"] && audits["render-blocking-resources"].score !== null && audits["render-blocking-resources"].score === 0 ) { prioritized_recommendations.push("Eliminate render-blocking resources"); } if ( audits["uses-responsive-images"] && audits["uses-responsive-images"].score !== null && audits["uses-responsive-images"].score === 0 ) { prioritized_recommendations.push("Properly size images"); } if ( audits["uses-optimized-images"] && audits["uses-optimized-images"].score !== null && audits["uses-optimized-images"].score === 0 ) { prioritized_recommendations.push("Efficiently encode images"); } if ( audits["uses-text-compression"] && audits["uses-text-compression"].score !== null && audits["uses-text-compression"].score === 0 ) { prioritized_recommendations.push("Enable text compression"); } if ( audits["uses-http2"] && audits["uses-http2"].score !== null && audits["uses-http2"].score === 0 ) { prioritized_recommendations.push("Use HTTP/2"); } // Add more specific recommendations based on Core Web Vitals if ( audits["largest-contentful-paint"] && audits["largest-contentful-paint"].score !== null && audits["largest-contentful-paint"].score < 0.5 ) { prioritized_recommendations.push("Improve Largest Contentful Paint (LCP)"); } if ( audits["cumulative-layout-shift"] && audits["cumulative-layout-shift"].score !== null && audits["cumulative-layout-shift"].score < 0.5 ) { prioritized_recommendations.push("Reduce layout shifts (CLS)"); } if ( audits["total-blocking-time"] && audits["total-blocking-time"].score !== null && audits["total-blocking-time"].score < 0.5 ) { prioritized_recommendations.push("Reduce JavaScript execution time"); } // Create the performance report content const reportContent: PerformanceReportContent = { score, audit_counts, metrics, opportunities, page_stats, prioritized_recommendations: prioritized_recommendations.length > 0 ? prioritized_recommendations : undefined, }; // Return the full report following the LighthouseReport interface return { metadata, report: reportContent, }; };