Skip to main content
Glama
RichardDillman

SEO Audit MCP Server

lighthouse.ts9.76 kB
// src/tools/lighthouse.ts // Lighthouse performance audit tool import { exec } from 'child_process'; import { promisify } from 'util'; import * as fs from 'fs/promises'; import * as path from 'path'; import type { LighthouseResult, CoreWebVitals, MetricResult, LighthouseOpportunity, LighthouseDiagnostic, LighthouseAudit, RunLighthouseInput, } from '../types/index.js'; const execAsync = promisify(exec); /** * Run Lighthouse audit on a URL */ export async function runLighthouse(input: RunLighthouseInput): Promise<LighthouseResult> { const { url, device = 'mobile', categories = ['performance', 'accessibility', 'best-practices', 'seo'], saveReport = false, } = input; // Create temp directory for output const tmpDir = '/tmp/lighthouse-reports'; await fs.mkdir(tmpDir, { recursive: true }); const timestamp = Date.now(); const outputPath = path.join(tmpDir, `report-${timestamp}.json`); const htmlOutputPath = saveReport ? path.join(tmpDir, `report-${timestamp}.html`) : null; // Build Lighthouse command const formFactor = device === 'mobile' ? 'mobile' : 'desktop'; const screenEmulation = device === 'mobile' ? '--screenEmulation.mobile --screenEmulation.width=412 --screenEmulation.height=823' : '--screenEmulation.mobile=false --screenEmulation.width=1920 --screenEmulation.height=1080'; const categoriesArg = categories.map(c => `--only-categories=${c}`).join(' '); const outputArg = saveReport ? `--output=json,html --output-path="${outputPath.replace('.json', '')}"` : `--output=json --output-path="${outputPath}"`; const cmd = `lighthouse "${url}" \ ${outputArg} \ --chrome-flags="--headless --no-sandbox --disable-gpu" \ --preset=${formFactor} \ ${screenEmulation} \ ${categoriesArg} \ --quiet`; console.error(`Running Lighthouse (${device}): ${url}`); try { await execAsync(cmd, { timeout: 120000, maxBuffer: 50 * 1024 * 1024, // 50MB buffer }); } catch (error: any) { // Lighthouse sometimes exits with non-zero even on success // Check if output file was created try { await fs.access(outputPath); } catch { throw new Error(`Lighthouse failed: ${error.message}`); } } // Parse the JSON report const reportJson = await fs.readFile(outputPath, 'utf-8'); const report = JSON.parse(reportJson); // Clean up temp files unless saveReport is true if (!saveReport) { await fs.unlink(outputPath).catch(() => {}); } return parseReport(url, device, report, saveReport ? htmlOutputPath ?? undefined : undefined); } /** * Parse Lighthouse JSON report into our format */ function parseReport( url: string, device: 'mobile' | 'desktop', report: any, reportPath?: string ): LighthouseResult { const { categories, audits } = report; // Extract Core Web Vitals const coreWebVitals = extractCoreWebVitals(audits); // Extract opportunities (things to fix) const opportunities = extractOpportunities(audits); // Extract diagnostics const diagnostics = extractDiagnostics(audits); // Extract SEO audits const seoAudits = extractSeoAudits(audits); // Extract accessibility audits const accessibilityAudits = extractAccessibilityAudits(audits); return { url, timestamp: new Date().toISOString(), device, scores: { performance: Math.round((categories.performance?.score ?? 0) * 100), accessibility: Math.round((categories.accessibility?.score ?? 0) * 100), bestPractices: Math.round((categories['best-practices']?.score ?? 0) * 100), seo: Math.round((categories.seo?.score ?? 0) * 100), }, coreWebVitals, opportunities, diagnostics, seoAudits, accessibilityAudits, reportPath, }; } /** * Extract Core Web Vitals metrics */ function extractCoreWebVitals(audits: any): CoreWebVitals { const getMetric = (id: string, unit: 'ms' | 'score' | 's' = 'ms'): MetricResult => { const audit = audits[id]; const value = audit?.numericValue ?? 0; const displayValue = audit?.displayValue ?? ''; // Determine rating based on metric type let rating: 'good' | 'needs-improvement' | 'poor'; switch (id) { case 'largest-contentful-paint': rating = value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'; break; case 'cumulative-layout-shift': rating = value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor'; break; case 'total-blocking-time': case 'max-potential-fid': rating = value <= 200 ? 'good' : value <= 600 ? 'needs-improvement' : 'poor'; break; case 'first-contentful-paint': rating = value <= 1800 ? 'good' : value <= 3000 ? 'needs-improvement' : 'poor'; break; case 'server-response-time': rating = value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor'; break; case 'speed-index': rating = value <= 3400 ? 'good' : value <= 5800 ? 'needs-improvement' : 'poor'; break; case 'interactive': rating = value <= 3800 ? 'good' : value <= 7300 ? 'needs-improvement' : 'poor'; break; default: rating = 'good'; } return { value: unit === 'score' ? value : Math.round(value), unit, rating, displayValue, }; }; return { lcp: getMetric('largest-contentful-paint'), cls: getMetric('cumulative-layout-shift', 'score'), inp: getMetric('total-blocking-time'), // TBT as lab proxy for INP fcp: getMetric('first-contentful-paint'), ttfb: getMetric('server-response-time'), si: getMetric('speed-index'), tti: getMetric('interactive'), tbt: getMetric('total-blocking-time'), }; } /** * Extract optimization opportunities */ function extractOpportunities(audits: any): LighthouseOpportunity[] { const opportunityIds = [ 'render-blocking-resources', 'unused-css-rules', 'unused-javascript', 'modern-image-formats', 'uses-optimized-images', 'uses-responsive-images', 'efficient-animated-content', 'uses-text-compression', 'preload-lcp-image', 'unminified-css', 'unminified-javascript', 'uses-rel-preconnect', 'server-response-time', 'redirects', 'uses-rel-preload', 'total-byte-weight', 'dom-size', 'offscreen-images', ]; return opportunityIds .filter(id => audits[id] && audits[id].score !== null && audits[id].score < 1) .map(id => { const audit = audits[id]; return { id, title: audit.title, description: audit.description?.replace(/<[^>]+>/g, '') ?? '', // Strip HTML savings: audit.displayValue ?? '', savingsMs: audit.details?.overallSavingsMs, savingsBytes: audit.details?.overallSavingsBytes, }; }) .sort((a, b) => (b.savingsMs ?? 0) - (a.savingsMs ?? 0)); } /** * Extract diagnostics */ function extractDiagnostics(audits: any): LighthouseDiagnostic[] { const diagnosticIds = [ 'critical-request-chains', 'long-tasks', 'layout-shift-elements', 'largest-contentful-paint-element', 'third-party-summary', 'bootup-time', 'mainthread-work-breakdown', 'font-display', 'unsized-images', ]; return diagnosticIds .filter(id => audits[id] && audits[id].score !== null) .map(id => { const audit = audits[id]; return { id, title: audit.title, description: audit.description?.replace(/<[^>]+>/g, '') ?? '', displayValue: audit.displayValue, }; }); } /** * Extract SEO-specific audits */ function extractSeoAudits(audits: any): LighthouseAudit[] { const seoIds = [ 'document-title', 'meta-description', 'http-status-code', 'link-text', 'crawlable-anchors', 'is-crawlable', 'robots-txt', 'hreflang', 'canonical', 'structured-data', 'viewport', 'font-size', 'tap-targets', 'image-alt', ]; return seoIds .filter(id => audits[id]) .map(id => { const audit = audits[id]; return { id, title: audit.title, description: audit.description?.replace(/<[^>]+>/g, '') ?? '', score: audit.score, passed: audit.score === 1, }; }); } /** * Extract accessibility audits */ function extractAccessibilityAudits(audits: any): LighthouseAudit[] { const a11yIds = [ 'aria-allowed-attr', 'aria-hidden-body', 'aria-required-attr', 'aria-valid-attr-value', 'aria-valid-attr', 'button-name', 'bypass', 'color-contrast', 'document-title', 'duplicate-id-active', 'duplicate-id-aria', 'form-field-multiple-labels', 'frame-title', 'heading-order', 'html-has-lang', 'html-lang-valid', 'image-alt', 'input-image-alt', 'label', 'link-name', 'list', 'listitem', 'meta-refresh', 'meta-viewport', 'object-alt', 'tabindex', 'td-headers-attr', 'th-has-data-cells', 'valid-lang', 'video-caption', ]; return a11yIds .filter(id => audits[id] && audits[id].score !== null && audits[id].score < 1) .map(id => { const audit = audits[id]; return { id, title: audit.title, description: audit.description?.replace(/<[^>]+>/g, '') ?? '', score: audit.score, passed: audit.score === 1, }; }); } /** * Check if Lighthouse is installed */ export async function checkLighthouseInstalled(): Promise<boolean> { try { await execAsync('lighthouse --version'); return true; } catch { return false; } } export default runLighthouse;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/RichardDillman/seo-audit-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server