/** * Lighthouse Scanner Service * * This service provides an HTTP API for running Lighthouse audits. * It's designed to be called from the Django backend via Celery tasks. */ const express = require('express'); const lighthouse = require('lighthouse'); const chromeLauncher = require('chrome-launcher'); const { v4: uuidv4 } = require('uuid'); const fs = require('fs').promises; const path = require('path'); const app = express(); app.use(express.json()); const PORT = process.env.PORT || 3001; const REPORTS_DIR = path.join(__dirname, 'reports'); // Ensure reports directory exists fs.mkdir(REPORTS_DIR, { recursive: true }).catch(console.error); /** * Health check endpoint */ app.get('/health', (req, res) => { res.json({ status: 'healthy', service: 'lighthouse-scanner' }); }); /** * Run Lighthouse audit for a given URL * * POST /scan * Body: { "url": "https://example.com" } * * Returns: Lighthouse audit results as JSON */ app.post('/scan', async (req, res) => { const { url } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); } // Validate URL format try { new URL(url); } catch (e) { return res.status(400).json({ error: 'Invalid URL format' }); } const scanId = uuidv4(); console.log(`[${scanId}] Starting Lighthouse scan for: ${url}`); let chrome = null; try { // Launch Chrome chrome = await chromeLauncher.launch({ chromeFlags: [ '--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', '--disable-extensions', '--disable-background-networking', '--disable-sync', '--disable-translate', '--metrics-recording-only', '--mute-audio', '--no-first-run', '--safebrowsing-disable-auto-update' ] }); console.log(`[${scanId}] Chrome launched on port ${chrome.port}`); // Lighthouse configuration const options = { logLevel: 'error', output: 'json', port: chrome.port, onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'], // Throttling settings for more realistic results throttling: { cpuSlowdownMultiplier: 4, downloadThroughputKbps: 1638.4, uploadThroughputKbps: 675, rttMs: 150 }, screenEmulation: { mobile: false, width: 1920, height: 1080, deviceScaleFactor: 1, disabled: false }, formFactor: 'desktop' }; // Run Lighthouse const runnerResult = await lighthouse(url, options); // Extract the report const report = runnerResult.lhr; // Process and extract key metrics const result = { scanId, url: report.finalUrl || url, fetchTime: report.fetchTime, // Category scores (0-100) scores: { performance: Math.round((report.categories.performance?.score || 0) * 100), accessibility: Math.round((report.categories.accessibility?.score || 0) * 100), bestPractices: Math.round((report.categories['best-practices']?.score || 0) * 100), seo: Math.round((report.categories.seo?.score || 0) * 100) }, // Core Web Vitals and key metrics metrics: { firstContentfulPaint: { value: report.audits['first-contentful-paint']?.numericValue || null, unit: 'ms', score: report.audits['first-contentful-paint']?.score || null }, largestContentfulPaint: { value: report.audits['largest-contentful-paint']?.numericValue || null, unit: 'ms', score: report.audits['largest-contentful-paint']?.score || null }, speedIndex: { value: report.audits['speed-index']?.numericValue || null, unit: 'ms', score: report.audits['speed-index']?.score || null }, timeToInteractive: { value: report.audits['interactive']?.numericValue || null, unit: 'ms', score: report.audits['interactive']?.score || null }, totalBlockingTime: { value: report.audits['total-blocking-time']?.numericValue || null, unit: 'ms', score: report.audits['total-blocking-time']?.score || null }, cumulativeLayoutShift: { value: report.audits['cumulative-layout-shift']?.numericValue || null, unit: 'score', score: report.audits['cumulative-layout-shift']?.score || null } }, // JavaScript and resource audits resources: { totalByteWeight: report.audits['total-byte-weight']?.numericValue || null, bootupTime: report.audits['bootup-time']?.numericValue || null, mainThreadWork: report.audits['mainthread-work-breakdown']?.numericValue || null, // Unused resources unusedJavascript: extractUnusedResources(report.audits['unused-javascript']), unusedCss: extractUnusedResources(report.audits['unused-css-rules']), // Render blocking resources renderBlockingResources: extractRenderBlockingResources(report.audits['render-blocking-resources']), // Large bundles scriptTreemap: extractLargeScripts(report.audits['script-treemap-data']), // Third party usage thirdPartySummary: extractThirdPartySummary(report.audits['third-party-summary']) }, // Diagnostics diagnostics: { numRequests: report.audits['network-requests']?.details?.items?.length || 0, numScripts: countResourcesByType(report.audits['network-requests'], 'Script'), numStylesheets: countResourcesByType(report.audits['network-requests'], 'Stylesheet'), numImages: countResourcesByType(report.audits['network-requests'], 'Image'), numFonts: countResourcesByType(report.audits['network-requests'], 'Font'), totalTransferSize: report.audits['total-byte-weight']?.numericValue || 0 }, // Failed audits (potential issues) issues: extractFailedAudits(report) }; // Save full report to file for debugging const reportPath = path.join(REPORTS_DIR, `${scanId}.json`); await fs.writeFile(reportPath, JSON.stringify(report, null, 2)); console.log(`[${scanId}] Scan completed successfully`); res.json(result); } catch (error) { console.error(`[${scanId}] Scan failed:`, error); res.status(500).json({ error: 'Lighthouse scan failed', message: error.message, scanId }); } finally { if (chrome) { await chrome.kill(); } } }); /** * Get a saved report by ID */ app.get('/report/:scanId', async (req, res) => { const { scanId } = req.params; const reportPath = path.join(REPORTS_DIR, `${scanId}.json`); try { const report = await fs.readFile(reportPath, 'utf8'); res.json(JSON.parse(report)); } catch (error) { res.status(404).json({ error: 'Report not found' }); } }); // ============================================================================= // Helper Functions // ============================================================================= function extractUnusedResources(audit) { if (!audit?.details?.items) return []; return audit.details.items.slice(0, 10).map(item => ({ url: item.url, totalBytes: item.totalBytes, wastedBytes: item.wastedBytes, wastedPercent: item.wastedPercent })); } function extractRenderBlockingResources(audit) { if (!audit?.details?.items) return []; return audit.details.items.map(item => ({ url: item.url, wastedMs: item.wastedMs, totalBytes: item.totalBytes })); } function extractLargeScripts(audit) { if (!audit?.details?.nodes) return []; // Get scripts larger than 100KB const largeScripts = []; const processNode = (node, path = '') => { const currentPath = path ? `${path}/${node.name}` : node.name; if (node.resourceBytes > 100 * 1024) { largeScripts.push({ name: currentPath, resourceBytes: node.resourceBytes, unusedBytes: node.unusedBytes || 0 }); } if (node.children) { node.children.forEach(child => processNode(child, currentPath)); } }; audit.details.nodes.forEach(node => processNode(node)); return largeScripts.slice(0, 20); } function extractThirdPartySummary(audit) { if (!audit?.details?.items) return []; return audit.details.items.slice(0, 10).map(item => ({ entity: item.entity, transferSize: item.transferSize, blockingTime: item.blockingTime, mainThreadTime: item.mainThreadTime })); } function countResourcesByType(audit, type) { if (!audit?.details?.items) return 0; return audit.details.items.filter(item => item.resourceType === type).length; } function extractFailedAudits(report) { const issues = []; const categoriesToCheck = ['performance', 'accessibility', 'best-practices', 'seo']; categoriesToCheck.forEach(categoryId => { const category = report.categories[categoryId]; if (!category?.auditRefs) return; category.auditRefs.forEach(ref => { const audit = report.audits[ref.id]; // Include audits with score < 0.5 (50%) if (audit && audit.score !== null && audit.score < 0.5) { issues.push({ id: audit.id, category: categoryId, title: audit.title, description: audit.description, score: audit.score, displayValue: audit.displayValue, impact: ref.weight || 0 }); } }); }); // Sort by impact (weight) descending issues.sort((a, b) => b.impact - a.impact); return issues.slice(0, 30); } // Start the server app.listen(PORT, '0.0.0.0', () => { console.log(`Lighthouse Scanner Service running on port ${PORT}`); });