#!/usr/bin/env node // Worked website benchmark — CometWeb, 24 September 2026. // // Applies the method from "How to benchmark a website against competitors" // (cometweb.io/blog/how-to-benchmark-a-website) to four pages that answer the // same search intent: cometweb.io/website-audit and three pages that appeared in // the US top 10 for both "free website audit tool" and "website audit" on the // day of the run (see serp-2026-09-24.json). // // Per URL: // * 1 plain GET first (pre-flight: status, security headers, checks); // * 5 Lighthouse runs (mobile preset, simulated throttling, identical settings), // rounds interleaved and rotated so no site always goes first or last, // a fresh headless Chromium profile per run, a pause between runs; // Nothing else is requested from third-party sites. No crawling. // // Usage: // npm i lighthouse@13.5.0 chrome-launcher@1 // CHROME_PATH="/path/to/playwright/chromium" node benchmark.mjs // Writes lighthouse-runs.json, headers-and-head.json and summary.json next to itself. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as chromeLauncher from 'chrome-launcher'; import lighthouse from 'lighthouse'; const here = path.dirname(fileURLToPath(import.meta.url)); const RUNS = Number(process.env.RUNS || 5); const PAUSE_MS = Number(process.env.PAUSE_MS || 8000); const CHROME_PATH = process.env.CHROME_PATH; if (!CHROME_PATH) throw new Error('Set CHROME_PATH to the Playwright Chromium binary'); const TARGETS = [ { id: 'cometweb', label: 'CometWeb', url: 'https://cometweb.io/website-audit', role: 'own page' }, // SEOptimer (mean position 2.0) was selected first but answered HTTP 525 and // Lighthouse NO_FCP at 07:07-07:10 UTC; the pre-registered reserve replaces it. // See excluded-seoptimer.json. { id: 'thehoth', label: 'The HOTH', url: 'https://www.thehoth.com/seo-audit-tool/', role: 'SERP' }, { id: 'seositecheckup', label: 'SEO Site Checkup', url: 'https://seositecheckup.com/', role: 'SERP' }, { id: 'rankmath', label: 'Rank Math', url: 'https://rankmath.com/tools/seo-analyzer/', role: 'SERP (reserve)' } ]; const ONLY = process.env.ONLY ? process.env.ONLY.split(',') : null; if (ONLY) TARGETS.splice(0, TARGETS.length, ...TARGETS.filter((t) => ONLY.includes(t.id))); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const median = (xs) => { const s = xs.filter((x) => typeof x === 'number' && Number.isFinite(x)).sort((a, b) => a - b); if (!s.length) return null; const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; }; function extract(lhr) { const a = lhr.audits; const num = (id) => (a[id] && typeof a[id].numericValue === 'number' ? a[id].numericValue : null); const requests = a['network-requests']?.details?.items ?? []; const firstPartyOrigins = new Set(); const entities = lhr.entities ?? []; const fp = entities.find((e) => e.isFirstParty); const fpOrigins = fp ? fp.origins : []; for (const o of fpOrigins) firstPartyOrigins.add(o); let fpBytes = 0; let tpBytes = 0; for (const r of requests) { let origin = null; try { origin = new URL(r.url).origin; } catch { /* data: etc. */ } const bytes = r.transferSize || 0; if (origin && firstPartyOrigins.has(origin)) fpBytes += bytes; else tpBytes += bytes; } const failing = (cat) => (lhr.categories[cat]?.auditRefs ?? []) .filter((ref) => ref.weight > 0 && a[ref.id] && a[ref.id].score !== null && a[ref.id].score < 1) .map((ref) => ref.id); return { requestedUrl: lhr.requestedUrl, finalDisplayedUrl: lhr.finalDisplayedUrl, fetchTime: lhr.fetchTime, runWarnings: lhr.runWarnings, runtimeError: lhr.runtimeError ?? null, scores: Object.fromEntries( Object.entries(lhr.categories).map(([k, v]) => [k, v.score === null ? null : Math.round(v.score * 100)]) ), metrics: { fcp_ms: num('first-contentful-paint'), lcp_ms: num('largest-contentful-paint'), tbt_ms: num('total-blocking-time'), cls: num('cumulative-layout-shift'), si_ms: num('speed-index'), tti_ms: num('interactive') }, transfer: { total_bytes: num('total-byte-weight'), requests: requests.length, first_party_bytes: fpBytes, third_party_bytes: tpBytes }, failing_seo_audits: failing('seo'), failing_accessibility_audits: failing('accessibility'), failing_best_practices_audits: failing('best-practices'), environment: { lighthouseVersion: lhr.lighthouseVersion, hostUserAgent: lhr.environment?.hostUserAgent, networkUserAgent: lhr.environment?.networkUserAgent, benchmarkIndex: lhr.environment?.benchmarkIndex, formFactor: lhr.configSettings?.formFactor, throttlingMethod: lhr.configSettings?.throttlingMethod, throttling: lhr.configSettings?.throttling, screenEmulation: lhr.configSettings?.screenEmulation } }; } async function lighthouseRun(url) { const chrome = await chromeLauncher.launch({ chromePath: CHROME_PATH, chromeFlags: ['--headless=new', '--no-first-run', '--disable-extensions', '--no-default-browser-check'] }); try { const result = await lighthouse(url, { port: chrome.port, output: 'json', logLevel: 'error', onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'] }); return extract(result.lhr); } finally { await chrome.kill(); } } const SECURITY_HEADERS = [ 'strict-transport-security', 'content-security-policy', 'x-content-type-options', 'x-frame-options', 'referrer-policy', 'permissions-policy' ]; async function headersAndHead(target) { const started = new Date().toISOString(); const res = await fetch(target.url, { redirect: 'follow', headers: { 'user-agent': 'Mozilla/5.0 (compatible; CometWeb-benchmark/1.0; one request per page; +https://cometweb.io/blog/how-to-benchmark-a-website)', accept: 'text/html' } }); const html = await res.text(); const h = Object.fromEntries(SECURITY_HEADERS.map((k) => [k, res.headers.get(k)])); const head = html.slice(0, html.search(/<\/head>/i) > 0 ? html.search(/<\/head>/i) : 200000); const attr = (re) => (head.match(re) || [])[1] ?? null; const jsonLdTypes = []; for (const m of html.matchAll(/]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)) { try { const data = JSON.parse(m[1]); const walk = (n) => { if (Array.isArray(n)) return n.forEach(walk); if (n && typeof n === 'object') { if (n['@type']) jsonLdTypes.push(...[].concat(n['@type'])); if (n['@graph']) walk(n['@graph']); } }; walk(data); } catch { jsonLdTypes.push('(unparseable)'); } } const title = attr(/]*>([^<]*)<\/title>/i); return { id: target.id, requested_url: target.url, fetched_at: started, final_url: res.url, status: res.status, https: res.url.startsWith('https://'), security_headers: h, security_headers_present: SECURITY_HEADERS.filter((k) => h[k] !== null).length, server: res.headers.get('server'), html_bytes_uncompressed: Buffer.byteLength(html), head_checks: { title, title_length: title ? title.trim().length : null, meta_description: attr(/]+name=["']description["'][^>]+content=["']([^"']*)["']/i) ?? attr(/]+content=["']([^"']*)["'][^>]+name=["']description["']/i), canonical: attr(/]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i) ?? attr(/]+href=["']([^"']*)["'][^>]+rel=["']canonical["']/i), meta_robots: attr(/]+name=["']robots["'][^>]+content=["']([^"']*)["']/i), html_lang: (html.match(/]*\slang=["']([^"']+)["']/i) || [])[1] ?? null, h1_count: (html.match(/]/gi) || []).length, hreflang_links: (head.match(/hreflang=/gi) || []).length, json_ld_types: [...new Set(jsonLdTypes)] } }; } const headers = []; for (const t of TARGETS) { try { headers.push(await headersAndHead(t)); } catch (err) { headers.push({ id: t.id, error: String(err) }); } await sleep(1500); } fs.writeFileSync(path.join(here, 'headers-and-head.json'), JSON.stringify(headers, null, 2)); const runs = []; const startedAt = new Date().toISOString(); const loadAtStart = os.loadavg(); for (let round = 0; round < RUNS; round++) { const order = TARGETS.map((_, i) => TARGETS[(i + round) % TARGETS.length]); for (const t of order) { const t0 = Date.now(); let rec; try { rec = { site: t.id, round: round + 1, ...(await lighthouseRun(t.url)) }; } catch (err) { rec = { site: t.id, round: round + 1, error: String(err && err.message ? err.message : err) }; } rec.wall_ms = Date.now() - t0; runs.push(rec); console.log(`round ${round + 1} ${t.id}: LCP ${rec.metrics?.lcp_ms?.toFixed?.(0)} ms, TBT ${rec.metrics?.tbt_ms?.toFixed?.(0)} ms, perf ${rec.scores?.performance}, ${rec.error ?? ''}`); fs.writeFileSync(path.join(here, 'lighthouse-runs.json'), JSON.stringify({ startedAt, runs }, null, 2)); await sleep(PAUSE_MS); } } const finishedAt = new Date().toISOString(); const summary = TARGETS.map((t) => { const mine = runs.filter((r) => r.site === t.id && !r.error && !r.runtimeError); const series = (fn) => mine.map(fn).filter((x) => typeof x === 'number'); const stat = (fn) => { const xs = series(fn); return { median: median(xs), min: xs.length ? Math.min(...xs) : null, max: xs.length ? Math.max(...xs) : null, n: xs.length, values: xs }; }; return { id: t.id, label: t.label, url: t.url, role: t.role, valid_runs: mine.length, lcp_ms: stat((r) => r.metrics.lcp_ms), tbt_ms: stat((r) => r.metrics.tbt_ms), fcp_ms: stat((r) => r.metrics.fcp_ms), cls: stat((r) => r.metrics.cls), si_ms: stat((r) => r.metrics.si_ms), performance_score: stat((r) => r.scores.performance), accessibility_score: stat((r) => r.scores.accessibility), seo_score: stat((r) => r.scores.seo), best_practices_score: stat((r) => r.scores['best-practices']), transfer_bytes: stat((r) => r.transfer.total_bytes), requests: stat((r) => r.transfer.requests), third_party_bytes: stat((r) => r.transfer.third_party_bytes) }; }); fs.writeFileSync( path.join(here, 'summary.json'), JSON.stringify({ startedAt, finishedAt, runs_per_url: RUNS, pause_ms: PAUSE_MS, host: { platform: os.platform(), arch: os.arch(), cpus: os.cpus().length, cpu_model: os.cpus()[0]?.model, node: process.version, loadavg_start: loadAtStart, loadavg_end: os.loadavg() }, chrome_path_basename: path.basename(CHROME_PATH), summary }, null, 2) ); console.log('done', startedAt, finishedAt);