@bschauer/webtools-mcp-server
Version:
MCP server providing web analysis tools including screenshot, debug, performance, security, accessibility, SEO, and asset optimization capabilities
322 lines (269 loc) • 10.7 kB
JavaScript
/**
* Problematic Elements Analyzer
* Identifies elements causing Core Web Vitals issues
*/
import { logInfo, logError } from "../../../utils/logging.js";
/**
* Identify problematic elements from Web Vitals data
* @param {Array} samples - Web Vitals data samples
* @returns {Object} Problematic elements analysis
*/
export function identifyProblematicElements(samples) {
try {
if (!samples || !Array.isArray(samples) || samples.length === 0) {
return {
error: "No Web Vitals data available",
lcp: null,
cls: [],
inp: null,
};
}
// Extract elements data from samples
const lcpElements = samples.filter((sample) => sample.elements && sample.elements.lcp).map((sample) => sample.elements.lcp);
const clsElements = samples.filter((sample) => sample.elements && sample.elements.cls).flatMap((sample) => sample.elements.cls);
const inpElements = samples.filter((sample) => sample.elements && sample.elements.inp).map((sample) => sample.elements.inp);
// Identify LCP element (most frequent)
const lcpElement = identifyMostFrequentElement(lcpElements);
// Identify CLS elements (sorted by impact)
const clsElementsAnalysis = analyzeClsElements(clsElements);
// Identify INP element (most frequent)
const inpElement = identifyMostFrequentElement(inpElements);
return {
lcp: lcpElement,
cls: clsElementsAnalysis,
inp: inpElement,
};
} catch (error) {
logError("elements_analyzer", "Failed to identify problematic elements", error);
return {
error: `Failed to identify problematic elements: ${error.message}`,
lcp: null,
cls: [],
inp: null,
};
}
}
/**
* Identify the most frequent element in an array of elements
* @param {Array} elements - Array of elements
* @returns {Object|null} Most frequent element or null if no elements
*/
function identifyMostFrequentElement(elements) {
if (!elements || elements.length === 0) {
return null;
}
// Count occurrences of each element path
const pathCounts = {};
for (const element of elements) {
if (!element || !element.path) continue;
const path = element.path;
pathCounts[path] = (pathCounts[path] || 0) + 1;
}
// Find the most frequent path
let mostFrequentPath = null;
let maxCount = 0;
for (const path in pathCounts) {
if (pathCounts[path] > maxCount) {
mostFrequentPath = path;
maxCount = pathCounts[path];
}
}
// Find the first element with this path
const mostFrequentElement = elements.find((element) => element && element.path === mostFrequentPath);
if (mostFrequentElement) {
return {
...mostFrequentElement,
frequency: maxCount,
totalOccurrences: elements.length,
};
}
return null;
}
/**
* Analyze CLS elements to identify the most problematic ones
* @param {Array} elements - Array of CLS elements
* @returns {Array} Analyzed CLS elements sorted by impact
*/
function analyzeClsElements(elements) {
if (!elements || elements.length === 0) {
return [];
}
// Group elements by path
const elementsByPath = {};
for (const element of elements) {
if (!element || !element.path) continue;
const path = element.path;
if (!elementsByPath[path]) {
elementsByPath[path] = {
path,
tagName: element.tagName,
id: element.id,
className: element.className,
shifts: [],
totalShiftDistance: 0,
occurrences: 0,
};
}
// Add shift information
if (element.currentRect && element.previousRect) {
const shiftX = Math.abs(element.currentRect.x - element.previousRect.x);
const shiftY = Math.abs(element.currentRect.y - element.previousRect.y);
const shiftDistance = Math.sqrt(shiftX * shiftX + shiftY * shiftY);
elementsByPath[path].shifts.push({
x: shiftX,
y: shiftY,
distance: shiftDistance,
currentRect: element.currentRect,
previousRect: element.previousRect,
});
elementsByPath[path].totalShiftDistance += shiftDistance;
}
elementsByPath[path].occurrences++;
}
// Convert to array and calculate average shift
const analyzedElements = Object.values(elementsByPath).map((element) => {
return {
...element,
averageShiftDistance: element.totalShiftDistance / (element.shifts.length || 1),
impact: calculateClsImpact(element),
};
});
// Sort by impact (descending)
return analyzedElements.sort((a, b) => b.impact - a.impact);
}
/**
* Calculate CLS impact score for an element
* @param {Object} element - Element with shift data
* @returns {number} Impact score
*/
function calculateClsImpact(element) {
if (!element || !element.shifts || element.shifts.length === 0) {
return 0;
}
// Impact is based on:
// 1. Total shift distance
// 2. Number of occurrences
// 3. Element size (larger elements have more impact)
const averageSize =
element.shifts.reduce((sum, shift) => {
const currentArea = shift.currentRect.width * shift.currentRect.height;
const previousArea = shift.previousRect.width * shift.previousRect.height;
return sum + (currentArea + previousArea) / 2;
}, 0) / element.shifts.length;
// Normalize size (0-1 scale, assuming 1,000,000 pixels² as max)
const normalizedSize = Math.min(averageSize / 1000000, 1);
// Calculate impact score
return element.totalShiftDistance * element.occurrences * (0.3 + 0.7 * normalizedSize);
}
/**
* Get element type based on tag name and attributes
* @param {Object} element - Element data
* @returns {string} Element type description
*/
export function getElementType(element) {
if (!element) return "Unknown";
const tagName = (element.tagName || "").toLowerCase();
const className = element.className || "";
const id = element.id || "";
// Check for images
if (tagName === "img" || tagName === "picture" || tagName === "svg") {
return "Image";
}
// Check for videos
if (tagName === "video" || (tagName === "iframe" && (element.src || "").includes("youtube"))) {
return "Video";
}
// Check for ads
if (className.includes("ad") || id.includes("ad") || (tagName === "iframe" && (element.src || "").includes("ads"))) {
return "Advertisement";
}
// Check for headers/hero sections
if (tagName === "header" || id.includes("header") || className.includes("header") || className.includes("hero")) {
return "Header/Hero Section";
}
// Check for navigation
if (tagName === "nav" || id.includes("nav") || className.includes("nav") || className.includes("menu")) {
return "Navigation";
}
// Check for buttons
if (tagName === "button" || tagName === "a" || element.role === "button") {
return "Button/Link";
}
// Check for forms
if (tagName === "form" || tagName === "input" || tagName === "select" || tagName === "textarea") {
return "Form Element";
}
// Check for text content
if (tagName === "p" || tagName === "h1" || tagName === "h2" || tagName === "h3" || tagName === "h4" || tagName === "h5" || tagName === "h6" || tagName === "span" || (tagName === "div" && !className && !id)) {
return "Text Content";
}
// Default to container
if (tagName === "div" || tagName === "section" || tagName === "article") {
return "Container";
}
return `${tagName.charAt(0).toUpperCase() + tagName.slice(1)} Element`;
}
/**
* Get optimization suggestions for an element based on its type and metrics
* @param {Object} element - Element data
* @param {string} metricType - Metric type (lcp, cls, inp)
* @returns {Array<string>} Optimization suggestions
*/
export function getElementOptimizationSuggestions(element, metricType) {
if (!element) return [];
const elementType = getElementType(element);
const suggestions = [];
// LCP suggestions
if (metricType === "lcp") {
if (elementType === "Image") {
suggestions.push("Use responsive images with srcset and sizes attributes");
suggestions.push("Implement lazy loading for images below the fold");
suggestions.push("Consider using WebP or AVIF formats for better compression");
suggestions.push("Preload the LCP image with <link rel='preload'>");
suggestions.push("Optimize image dimensions and quality");
} else if (elementType === "Text Content") {
suggestions.push("Ensure text is visible during webfont loading with font-display: swap");
suggestions.push("Preload critical webfonts");
suggestions.push("Consider using system fonts or variable fonts");
} else {
suggestions.push("Minimize render-blocking resources");
suggestions.push("Implement critical CSS for above-the-fold content");
suggestions.push("Consider server-side rendering or static generation");
}
}
// CLS suggestions
if (metricType === "cls") {
if (elementType === "Image") {
suggestions.push("Set explicit width and height attributes on images");
suggestions.push("Use aspect-ratio CSS property");
suggestions.push("Implement content-visibility for off-screen images");
} else if (elementType === "Advertisement") {
suggestions.push("Reserve space for ad slots with min-height and min-width");
suggestions.push("Use placeholder elements for ads");
} else if (elementType === "Form Element") {
suggestions.push("Avoid inserting new elements above existing content after user input");
suggestions.push("Pre-allocate space for dynamic content");
} else {
suggestions.push("Avoid inserting content above existing content");
suggestions.push("Use transform animations instead of properties that trigger layout");
suggestions.push("Ensure all dynamic content has reserved space");
}
}
// INP suggestions
if (metricType === "inp") {
if (elementType === "Button/Link") {
suggestions.push("Optimize event handlers to be less complex");
suggestions.push("Use event delegation for multiple similar elements");
suggestions.push("Debounce or throttle event handlers for frequent events");
} else if (elementType === "Form Element") {
suggestions.push("Validate forms asynchronously");
suggestions.push("Implement progressive enhancement for form submissions");
suggestions.push("Use requestAnimationFrame for visual updates");
} else {
suggestions.push("Break up long tasks into smaller, asynchronous tasks");
suggestions.push("Use a web worker for heavy computations");
suggestions.push("Implement code-splitting and lazy loading");
}
}
return suggestions;
}