329 lines
11 KiB
JavaScript
329 lines
11 KiB
JavaScript
/**
|
|
* 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}`);
|
|
});
|