UNPKG

@bschauer/webtools-mcp-server

Version:

MCP server providing web analysis tools including screenshot, debug, performance, security, accessibility, SEO, and asset optimization capabilities

417 lines (370 loc) 13.9 kB
/** * Web Vitals Analyzer Tool * Uses Performance Observer API to analyze Core Web Vitals metrics */ import { logInfo, logError } from "../../../utils/logging.js"; import { BROWSER_HEADERS } from "../../../config/constants.js"; import { checkSiteAvailability } from "../../../utils/html.js"; import { fetchWithRetry } from "../../../utils/fetch.js"; import { getDeviceConfig } from "../../../config/devices.js"; import { getNetworkCondition } from "../../../config/network_conditions.js"; import { enableRequiredDomains, setupPerformanceObserver, getWebVitals, configureCdpSession } from "../../../utils/cdp_helpers.js"; import { analyzeWebVitalsData } from "./core_vitals.js"; import { identifyProblematicElements } from "./elements.js"; /** * Run Web Vitals analysis on a webpage * @param {Object} args - The tool arguments * @returns {Promise<Object>} Web Vitals analysis results */ export async function runWebVitalsAnalysis(args) { const { url, timeoutMs = 15000, waitAfterLoadMs = 3000, interactWithPage = true, useProxy = false, ignoreSSLErrors = false, networkConditionName, networkCondition, deviceName, deviceConfig: deviceConfigArg, runMultipleTimes = false, numberOfRuns = 3, } = args; let puppeteer; let browser; try { // Dynamically import puppeteer try { puppeteer = await import("puppeteer"); } catch (e) { return { content: [ { type: "text", text: JSON.stringify( { error: "Web Vitals analysis functionality not available", details: "Puppeteer is not installed", recommendation: "Please install Puppeteer to use Web Vitals analysis functionality", retryable: false, url, }, null, 2 ), }, ], }; } // Check site availability first const availability = await checkSiteAvailability(url, { ignoreSSLErrors }, fetchWithRetry); if (!availability.available) { return { content: [ { type: "text", text: JSON.stringify( { error: "Site unavailable", details: availability.error, recommendation: availability.recommendation, retryable: true, url, }, null, 2 ), }, ], }; } // Get device configuration const deviceConfig = getDeviceConfig({ deviceName, deviceConfig: deviceConfigArg }); // Get network condition if specified const networkConditionConfig = getNetworkCondition({ networkConditionName, networkCondition }); logInfo("web_vitals", "Starting Web Vitals analysis", { url, deviceConfig: deviceConfig.name, networkCondition: networkConditionConfig.name, }); // Launch browser with appropriate settings browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", useProxy ? `--proxy-server=${process.env.PROXY_URL || ""}` : "", "--ignore-certificate-errors"].filter(Boolean), ignoreHTTPSErrors: ignoreSSLErrors, }); // If running multiple times, collect multiple samples const samples = []; const runsCount = runMultipleTimes ? numberOfRuns : 1; for (let run = 0; run < runsCount; run++) { if (runMultipleTimes) { logInfo("web_vitals", `Starting run ${run + 1} of ${runsCount}`, { url }); } const page = await browser.newPage(); // Set HTTP headers await page.setExtraHTTPHeaders(BROWSER_HEADERS); // Set viewport based on device config await page.setViewport({ width: deviceConfig.width, height: deviceConfig.height, deviceScaleFactor: deviceConfig.deviceScaleFactor, isMobile: deviceConfig.isMobile, hasTouch: deviceConfig.hasTouch, isLandscape: deviceConfig.isLandscape, }); // Set custom user agent if provided if (deviceConfig.userAgent) { await page.setUserAgent(deviceConfig.userAgent); } // Create CDP session const client = await page.target().createCDPSession(); // Configure CDP session with network conditions and other settings await configureCdpSession(client, { networkCondition: networkConditionConfig, }); // Set up performance observer to capture web vitals await setupPerformanceObserver(page); // Navigate to the page with timeout try { await page.goto(url, { waitUntil: "networkidle0", timeout: timeoutMs, }); } catch (navError) { // Handle navigation timeout or other navigation errors logError("web_vitals", "Navigation failed", navError, { url }); await page.close(); continue; // Try next run if multiple runs } // Interact with the page if requested to trigger more events if (interactWithPage) { await autoInteractWithPage(page); } // Wait for a moment to capture post-load activity await new Promise((resolve) => setTimeout(resolve, waitAfterLoadMs)); // Get web vitals data const webVitalsData = await getWebVitals(page); if (webVitalsData) { samples.push(webVitalsData); } // Close the page await page.close(); } // If no samples were collected, return an error if (samples.length === 0) { return { content: [ { type: "text", text: JSON.stringify( { error: "Web Vitals analysis failed", details: "No Web Vitals data could be collected", recommendation: "Try increasing the timeout or check if the site supports Performance Observer API", retryable: true, url, }, null, 2 ), }, ], }; } // Analyze the collected web vitals data const webVitalsAnalysis = analyzeWebVitalsData(samples); // Identify problematic elements const problematicElements = identifyProblematicElements(samples); // Prepare the final results const results = { url, deviceConfig: { name: deviceConfig.name, width: deviceConfig.width, height: deviceConfig.height, userAgent: deviceConfig.userAgent, }, networkCondition: { name: networkConditionConfig.name, description: networkConditionConfig.description, }, webVitals: webVitalsAnalysis, problematicElements, samples: runMultipleTimes ? samples : undefined, // Include raw samples only if multiple runs recommendations: generateRecommendations(webVitalsAnalysis, problematicElements), }; logInfo("web_vitals", "Web Vitals analysis completed successfully", { url }); return { content: [ { type: "text", text: JSON.stringify(results, null, 2), }, ], }; } catch (error) { logError("web_vitals", "Web Vitals analysis failed", error, { url }); return { content: [ { type: "text", text: JSON.stringify( { error: "Web Vitals analysis failed", details: error.message, recommendation: "Please try again with different settings", retryable: true, url, errorType: error.name, }, null, 2 ), }, ], }; } finally { if (browser) { try { await browser.close(); } catch (closeError) { logError("web_vitals", "Failed to close browser", closeError, { url }); } } } } /** * Automatically interact with the page to trigger more events * @param {Object} page - Puppeteer page * @returns {Promise<void>} */ async function autoInteractWithPage(page) { try { // Scroll down the page in increments const pageHeight = await page.evaluate(() => document.body.scrollHeight); const viewportHeight = await page.evaluate(() => window.innerHeight); const scrollSteps = Math.ceil(pageHeight / viewportHeight); for (let i = 1; i <= scrollSteps; i++) { await page.evaluate( (step, vh) => { window.scrollTo(0, step * vh); }, i, viewportHeight ); await page.waitForTimeout(100); } // Scroll back to top await page.evaluate(() => { window.scrollTo(0, 0); }); // Try to click on some interactive elements const clickableElements = await page.$$('button, a, [role="button"], .btn, input[type="submit"]'); // Limit to first 3 elements to avoid too much interaction const elementsToClick = clickableElements.slice(0, 3); for (const element of elementsToClick) { try { // Check if element is visible const isVisible = await page.evaluate((el) => { const style = window.getComputedStyle(el); return style && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"; }, element); if (isVisible) { await element.click({ delay: 100 }).catch(() => {}); await page.waitForTimeout(300); } } catch (error) { // Ignore errors from clicking } } // Wait a bit for any triggered events to complete await page.waitForTimeout(500); } catch (error) { // Ignore errors from auto-interaction logInfo("web_vitals", "Auto-interaction with page completed with some errors", { error: error.message }); } } /** * Generate recommendations based on Web Vitals analysis * @param {Object} webVitalsAnalysis - Web Vitals analysis data * @param {Object} problematicElements - Problematic elements data * @returns {Array} Recommendations */ function generateRecommendations(webVitalsAnalysis, problematicElements) { const recommendations = []; // LCP recommendations if (webVitalsAnalysis.lcp && webVitalsAnalysis.lcp.value > 2500) { recommendations.push({ type: "lcp_optimization", title: "Optimize Largest Contentful Paint", description: `LCP is ${webVitalsAnalysis.lcp.value.toFixed(0)}ms, which is ${webVitalsAnalysis.lcp.rating}. Consider optimizing the largest element on your page.`, impact: "high", element: problematicElements.lcp, suggestions: [ "Optimize and preload critical resources", "Implement server-side rendering or static generation", "Use a content delivery network (CDN)", "Optimize images with WebP format and proper sizing", "Minimize render-blocking resources", ], }); } // CLS recommendations if (webVitalsAnalysis.cls && webVitalsAnalysis.cls.value > 0.1) { recommendations.push({ type: "cls_optimization", title: "Reduce Cumulative Layout Shift", description: `CLS is ${webVitalsAnalysis.cls.value.toFixed(3)}, which is ${webVitalsAnalysis.cls.rating}. Address layout shifts to improve user experience.`, impact: "high", elements: problematicElements.cls.slice(0, 3), // Top 3 shifting elements suggestions: [ "Set explicit width and height for images and videos", "Avoid inserting content above existing content", "Use transform animations instead of animations that trigger layout changes", "Reserve space for dynamic content like ads", "Precompute sufficient space for web fonts", ], }); } // FID/INP recommendations if ((webVitalsAnalysis.fid && webVitalsAnalysis.fid.value > 100) || (webVitalsAnalysis.inp && webVitalsAnalysis.inp.value > 200)) { recommendations.push({ type: "interactivity_optimization", title: "Improve Interactivity", description: `${webVitalsAnalysis.inp ? "INP" : "FID"} is ${(webVitalsAnalysis.inp || webVitalsAnalysis.fid).value.toFixed(0)}ms, which is ${ (webVitalsAnalysis.inp || webVitalsAnalysis.fid).rating }. Optimize JavaScript execution to improve interactivity.`, impact: "high", element: problematicElements.inp, suggestions: [ "Break up long tasks into smaller, asynchronous tasks", "Optimize event handlers and reduce their complexity", "Defer non-critical JavaScript", "Use a web worker for heavy computations", "Implement code-splitting and lazy loading", ], }); } // TTFB recommendations if (webVitalsAnalysis.ttfb && webVitalsAnalysis.ttfb.value > 800) { recommendations.push({ type: "ttfb_optimization", title: "Improve Time to First Byte", description: `TTFB is ${webVitalsAnalysis.ttfb.value.toFixed(0)}ms, which is ${webVitalsAnalysis.ttfb.rating}. Optimize server response time.`, impact: "high", suggestions: ["Optimize server processing time", "Implement caching strategies", "Use a CDN to reduce network latency", "Optimize database queries", "Consider serverless architectures for faster cold starts"], }); } // General recommendations if no specific issues found if (recommendations.length === 0) { recommendations.push({ type: "general_optimization", title: "Maintain Good Performance", description: "Your page has good Core Web Vitals scores. Continue monitoring and optimizing as your site evolves.", impact: "low", suggestions: ["Implement performance budgets", "Set up continuous performance monitoring", "Optimize images and fonts", "Minimize third-party impact", "Keep dependencies updated"], }); } return recommendations; }