vital-core / src /aggregate.js
vital-scans[bot]
Switch HF Space to Docker appliance (VA profile): Dockerfile + serve-hf.js, sync source, sdk: docker
421ed2a
Raw
History Blame Contribute Delete
44.6 kB
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { loadConfig, loadProfile, applyProfile, DIRS } from './lib/config.js';
import { compareWeeks } from './lib/week.js';
import { renderDomainReport, renderIndex, writeAsset, setSustainabilityMetric, renderLighthousePage, renderReadabilityPage, renderTechPage, renderArchivePage, renderAccessibilityPage, renderStandardsPage, renderErrorsPage, renderImagesPage, renderTechFindingsPage, renderThirdPartyPage } from './report-html.js';
import { buildBugReports, bugReportsMarkdown } from './lib/bug-report.js';
import { loadPriorityUrls } from './lib/top-tasks.js';
import { loadFindings, saveFindings, updateFindings } from './lib/findings.js';
import { writeCsvs, writeBugsCsv, writeErrorsCsv, writeResourceCsv, writeLighthouseCsv, writeReadabilityCsv, writeSpellingCsv, writeAcronymsCsv, writeTechCsv, writeImagesCsv, writeThirdPartyCsv } from './lib/csv.js';
import { buildConsensus } from './lib/consensus.js';
import { loadInventory, saveInventory, updateInventory, inventorySummary } from './lib/inventory.js';
import { scoreFor } from './lib/score.js';
import { loadResourceLedger, saveResourceLedger, updateResourceLedger } from './lib/resource-ledger.js';
import { loadLinkLedger, saveLinkLedger, updateLinkLedger } from './lib/link-ledger.js';
import { buildCooccurrence, rankAssociations } from './lib/tech-findings.js';
import { rollupThirdParty } from './lib/third-party-rollup.js';
import { loadThirdPartyLedger, saveThirdPartyLedger, updateThirdPartyLedger } from './lib/third-party-ledger.js';
/**
* Pure function of the data/ directory. Idempotent: run it as many
* times as you like, the output is the same. No database, no cache,
* no fallback path that can diverge.
*
* Produces:
* data/<domain>/<week>/summary.json (committed; survives page-detail pruning)
* docs/index.html (dashboard)
* docs/reports/<domain>/<week>/index.html
* docs/data/<domain>/weekly.json (trend series for anyone to reuse)
*/
const MAX_RULE_INSTANCES = 5; // representative failing instances kept per rule
const MAX_AFFECTED_PAGES = 5000; // full affected-page list cap per rule (for CSV)
// VITAL_PROFILE (optional) scopes the build to one deployment profile —
// a subset of targets + report branding (config/profiles/<name>.yml). Unset
// means the full site with default branding: the GitHub Pages behavior,
// unchanged. See config/profiles/README.md.
const profile = loadProfile(process.env.VITAL_PROFILE);
const config = applyProfile(loadConfig(), profile);
if (profile) console.log(`Building profile "${profile.name}" (${config.targets.length} targets).`);
setSustainabilityMetric(config.sustainabilityMetric);
fs.mkdirSync(DIRS.docs, { recursive: true });
const dashboard = [];
for (const target of config.targets) {
const domainDir = path.join(DIRS.data, target.key);
if (!fs.existsSync(domainDir)) continue;
const reporting = target.reporting ?? {};
const keyPages = reporting.include_key_page_issues ? loadPriorityUrls(target) : [];
const weeks = fs.readdirSync(domainDir).filter((w) => /^\d{4}-W\d{2}$/.test(w)).sort(compareWeeks);
const series = [];
for (const week of weeks) {
const summary = summarizeWeek(target, week);
if (summary) {
series.push(summary);
// The full per-page lists are large and reconstructable; keep them
// in memory for CSV generation but don't commit them to summary.json.
const omit = new Set(['pagesWithAxeList', 'pagesWithAlfaList', 'pageDetail', 'pageRows', 'bytesList', 'imageRows', 'uniqueImageList']);
fs.writeFileSync(
path.join(domainDir, week, 'summary.json'),
JSON.stringify(summary, (k, v) => (omit.has(k) ? undefined : v), 1)
);
}
}
if (series.length === 0) continue;
// Rolling site inventory: last-known status for every URL ever scanned.
// Updated incrementally from each retained week's page records (older
// weeks may already be pruned, but their results persist in inventory).
const inventory = loadInventory(target.key, target.domain);
for (const week of weeks) {
const pagesDir = path.join(domainDir, week, 'pages');
if (!fs.existsSync(pagesDir)) continue;
const records = fs.readdirSync(pagesDir)
.filter((f) => f.endsWith('.json'))
.map((f) => JSON.parse(fs.readFileSync(path.join(pagesDir, f), 'utf8')));
updateInventory(inventory, week, records);
}
saveInventory(target.key, inventory);
const invSummary = inventorySummary(inventory, series[series.length - 1].week);
// Week-over-week diffs between consecutive summaries.
const diffs = {};
for (let i = 1; i < series.length; i++) {
diffs[series[i].week] = diffWeeks(series[i - 1], series[i]);
}
// Machine-readable trend series.
const dataOut = path.join(DIRS.docs, 'data', target.key);
fs.mkdirSync(dataOut, { recursive: true });
fs.writeFileSync(path.join(dataOut, 'weekly.json'), JSON.stringify({ domain: target.domain, series, diffs }, null, 1));
// Findings ledger: first/last-seen per unique finding (pattern_id),
// accumulated across the domain's whole history. Rebuilt from scratch
// each aggregate run (idempotent) by replaying weeks oldest-first.
const ledger = loadFindings(target.key, target.domain);
ledger.findings = {}; // recompute deterministically from retained weeks
// Resource inventory ledger (PDFs, docs, iframes, media) — same pattern.
const resLedger = loadResourceLedger(target.key, target.domain);
resLedger.resources = {};
// Broken-link ledger — accumulated across whole history.
const linkLedger = loadLinkLedger(target.key, target.domain);
linkLedger.links = {};
// Third-party vendor ledger — first/last-seen per vendor across history.
const tpLedger = loadThirdPartyLedger(target.key, target.domain);
tpLedger.vendors = {}; // recompute deterministically from retained weeks
// Human reports + structured bug reports (Markdown, JSON, and inline HTML).
let latestBugs = []; // latest week's bugs, for the fleet-wide worst-offenders view
for (let i = 0; i < series.length; i++) {
const summary = series[i];
const prev = i > 0 ? series[i - 1] : null;
const bugs = buildBugReports(target, summary);
// Update the ledger for this week and annotate each bug with its
// first/last-seen history.
const history = updateFindings(ledger, summary.week, bugs);
for (const b of bugs) {
const h = history[b.pattern_id];
if (h) {
b.first_seen = h.firstSeen;
b.last_seen = h.lastSeen;
b.weeks_seen = h.weeksSeen;
}
}
const repDir = path.join(DIRS.docs, 'reports', target.key, summary.week);
fs.mkdirSync(repDir, { recursive: true });
// CSVs of affected pages, then link each bug to its per-rule CSV.
const csvLinks = writeCsvs(repDir, summary);
for (const b of bugs) {
b.affected_pages_csv = csvLinks.byRule[`${b.engine_key}:${b.rule_id}`] ?? null;
}
// Flat bugs.csv: all findings in one spreadsheet-friendly file.
// Written after affected_pages_csv is set on each bug so the links are included.
csvLinks.bugsAll = writeBugsCsv(repDir, bugs);
// Broken-link ledger: track first/last-seen and weeks-broken per URL,
// then annotate summary entries so the errors page can show history.
if (summary.linkCheck?.broken?.length) {
const annotated = updateLinkLedger(linkLedger, summary.week, summary.linkCheck.broken);
summary.linkCheck.broken = annotated;
}
// Third-party vendor ledger: annotate vendors with first/last-seen so the
// report can flag ones new this week.
if (summary.thirdParty?.vendors?.length) {
summary.thirdParty.vendors = updateThirdPartyLedger(tpLedger, summary.week, summary.thirdParty.vendors);
}
// Broken links + error pages CSV.
csvLinks.errorsAll = writeErrorsCsv(repDir, summary);
// Resource inventory: update the ledger, mark which are new this week,
// and write a resources CSV.
const newResources = summary.resources
? updateResourceLedger(resLedger, summary.week, summary.resources.list)
: [];
if (summary.resources) {
summary.resources.newThisWeek = newResources;
summary.resources.csv = writeResourceCsv(repDir, summary.resources, resLedger);
}
// Evidence CSVs: Lighthouse per-page, readability per-page, spelling.
const lhCsv = writeLighthouseCsv(repDir, summary.lighthouse);
const readabilityCsv = writeReadabilityCsv(repDir, summary.plainLanguage?.pageRows);
const spellingCsv = writeSpellingCsv(repDir, summary.plainLanguage?.topMisspellings);
const acronymsCsv = writeAcronymsCsv(repDir, summary.plainLanguage?.topUnexplainedAcronyms);
if (summary.lighthouse) summary.lighthouse.csv = lhCsv;
if (summary.plainLanguage) {
summary.plainLanguage.readabilityCsv = readabilityCsv;
summary.plainLanguage.spellingCsv = spellingCsv;
summary.plainLanguage.acronymsCsv = acronymsCsv;
// JSON downloads for spelling + acronyms (word/acronym, pages, examples).
const pl = summary.plainLanguage;
if (pl.topMisspellings?.length) {
fs.writeFileSync(path.join(repDir, 'spelling.json'),
JSON.stringify({ domain: target.domain, week: summary.week, generatedAt: summary.generatedAt, misspellings: pl.topMisspellings }, null, 1));
pl.spellingJson = 'spelling.json';
}
if (pl.topUnexplainedAcronyms?.length) {
fs.writeFileSync(path.join(repDir, 'acronyms.json'),
JSON.stringify({ domain: target.domain, week: summary.week, generatedAt: summary.generatedAt, acronyms: pl.topUnexplainedAcronyms }, null, 1));
pl.acronymsJson = 'acronyms.json';
}
}
// Every sub-page is written for every week, so the shared subnav is
// identical on every domain and every week — no missing tabs, no 404s. A
// criterion with no data this week renders a clear empty-state page. CSV/
// JSON downloads are still only written when there's data to put in them.
// Accessibility (always has content — shows "no findings" when clean).
fs.writeFileSync(path.join(repDir, 'accessibility.html'), renderAccessibilityPage(target, summary, bugs, csvLinks, { ...reporting, keyPages }));
fs.writeFileSync(path.join(repDir, 'standards.html'), renderStandardsPage(target, summary));
fs.writeFileSync(path.join(repDir, 'errors.html'), renderErrorsPage(target, summary, csvLinks.errorsAll ?? null));
fs.writeFileSync(path.join(repDir, 'lighthouse.html'), renderLighthousePage(target, summary, lhCsv));
fs.writeFileSync(path.join(repDir, 'readability.html'), renderReadabilityPage(target, summary, readabilityCsv));
const techCsv = summary.tech?.length ? writeTechCsv(repDir, summary.tech) : null;
if (summary.tech?.length) {
fs.writeFileSync(path.join(repDir, 'tech.json'),
JSON.stringify({ domain: target.domain, week: summary.week, generatedAt: summary.generatedAt, pagesScanned: summary.pagesScanned, technologies: summary.tech }, null, 1));
}
fs.writeFileSync(path.join(repDir, 'tech.html'), renderTechPage(target, summary, techCsv));
fs.writeFileSync(path.join(repDir, 'tech-findings.html'), renderTechFindingsPage(target, summary));
const tpCsv = summary.thirdParty?.vendors?.length ? writeThirdPartyCsv(repDir, summary) : null;
fs.writeFileSync(path.join(repDir, 'third-party.html'), renderThirdPartyPage(target, summary, tpCsv));
const imagesCsv = summary.images?.imageRows?.length ? writeImagesCsv(repDir, summary) : null;
// Deduplicated image inventory as JSON (src, alt, bytes, occurrences,
// alt-text verdict, example pages).
if (summary.images?.uniqueImageList?.length) {
fs.writeFileSync(
path.join(repDir, 'images.json'),
JSON.stringify({ domain: target.domain, week: summary.week, generatedAt: summary.generatedAt, images: summary.images.uniqueImageList }, null, 1)
);
}
fs.writeFileSync(path.join(repDir, 'images.html'), renderImagesPage(target, summary, imagesCsv));
// Archive page (all weeks). Written in each week folder so the subnav
// "Archive" link resolves from any week's report.
const archiveHtml = renderArchivePage(target, series, series[series.length - 1].week);
if (archiveHtml) fs.writeFileSync(path.join(repDir, 'archive.html'), archiveHtml);
// inventory totals only make sense on the latest week's report.
const isLatest = i === series.length - 1;
if (isLatest) latestBugs = bugs.map((b) => ({ ...b, _week: summary.week }));
const html = renderDomainReport(target, summary, prev, diffs[summary.week] ?? null, series, bugs, csvLinks, isLatest ? invSummary : null);
fs.writeFileSync(path.join(repDir, 'index.html'), html);
fs.writeFileSync(path.join(repDir, 'bugs.md'), bugReportsMarkdown(target, summary, bugs));
fs.writeFileSync(
path.join(repDir, 'bugs.json'),
JSON.stringify({ domain: target.domain, week: summary.week, generatedAt: summary.generatedAt, reports: bugs }, null, 1)
);
}
saveFindings(target.key, ledger);
saveResourceLedger(target.key, resLedger);
saveLinkLedger(target.key, linkLedger);
saveThirdPartyLedger(target.key, tpLedger);
// Single downloadable snapshot of everything known about the domain:
// every scanned URL's latest status, current known findings (with
// first/last-seen), the weekly trend series, and the latest score.
const latest = series[series.length - 1];
fs.writeFileSync(
path.join(dataOut, 'domain.json'),
JSON.stringify(
{
domain: target.domain,
generatedAt: new Date().toISOString(),
latestWeek: latest.week,
latestScore: scoreFor(latest),
inventorySummary: invSummary,
// Last-known result for every URL ever scanned (survives pruning).
pages: Object.entries(inventory.pages).map(([url, p]) => ({ url, ...p })),
// Every unique finding with first/last-seen history.
findings: ledger.findings,
// Latest week's tech↔finding associations (lift-ranked), for anyone
// building cross-site analyses. The full model lives in weekly.series.
techFindings: latest.techFindings?.associations ?? null,
// Week-over-week trend series + diffs.
weekly: { series, diffs },
},
null,
1
)
);
// Trailing-7-day rolling summary for the dashboard (and the latest
// report's headline), so the headline isn't a partial ISO week measured
// against full historic weeks. Falls back to the latest week if the
// window is empty (e.g. old data only).
const windowSummary = summarizeWindow(target, 7) ?? series[series.length - 1];
dashboard.push({ target, series, diffs, inventory: invSummary, bugs: latestBugs, windowSummary });
console.log(`${target.key}: ${series.length} week(s) aggregated, ${Object.keys(ledger.findings).length} tracked findings`);
}
fs.writeFileSync(path.join(DIRS.docs, 'index.html'), renderIndex(dashboard, { branding: profile?.branding }));
writeAsset(DIRS.docs);
console.log('docs/ written');
// ---------------------------------------------------------------------
function summarizeWeek(target, week) {
const pagesDir = path.join(DIRS.data, target.key, week, 'pages');
const summaryPath = path.join(DIRS.data, target.key, week, 'summary.json');
// If page detail was pruned, reuse the committed summary verbatim.
if (!fs.existsSync(pagesDir)) {
return fs.existsSync(summaryPath) ? JSON.parse(fs.readFileSync(summaryPath, 'utf8')) : null;
}
const records = fs.readdirSync(pagesDir)
.filter((f) => f.endsWith('.json'))
.map((f) => JSON.parse(fs.readFileSync(path.join(pagesDir, f), 'utf8')));
if (records.length === 0) return null;
const runsDir = path.join(DIRS.data, target.key, week, 'runs');
const broken = readBrokenLinks(runsDir);
const tally = readTallyTotals(runsDir);
const summary = summarizeRecords(target, week, records, broken);
if (summary && tally) {
summary.pagesAttempted = tally.attempted;
summary.pagesSucceeded = tally.succeeded;
}
return summary;
}
/**
* Trailing-window summary: aggregate all page records across retained
* weeks whose scannedAt falls within the last `days` days. This is the
* default "last 7 days" view, so the headline numbers aren't a partial
* ISO week compared against full historic weeks.
*/
function summarizeWindow(target, days = 7) {
const domainDir = path.join(DIRS.data, target.key);
if (!fs.existsSync(domainDir)) return null;
const cutoff = Date.now() - days * 86400000;
const records = [];
const runDirs = [];
for (const week of fs.readdirSync(domainDir).filter((w) => /^\d{4}-W\d{2}$/.test(w))) {
const pagesDir = path.join(domainDir, week, 'pages');
if (!fs.existsSync(pagesDir)) continue;
for (const f of fs.readdirSync(pagesDir).filter((x) => x.endsWith('.json'))) {
const rec = JSON.parse(fs.readFileSync(path.join(pagesDir, f), 'utf8'));
if (rec.scannedAt && Date.parse(rec.scannedAt) >= cutoff) records.push(rec);
}
runDirs.push(path.join(domainDir, week, 'runs'));
}
if (records.length === 0) return null;
const broken = readBrokenLinks(runDirs, cutoff);
const tally = readTallyTotals(runDirs, cutoff);
const s = summarizeRecords(target, 'last-7-days', records, broken);
if (s) {
s.windowDays = days;
if (tally) { s.pagesAttempted = tally.attempted; s.pagesSucceeded = tally.succeeded; }
}
return s;
}
/**
* Sum tally fields across all run logs in one or more runs directories.
* Returns { attempted, succeeded } where attempted = ok+blocked+timeout+error
* and succeeded = ok (status < 400, non-redirect HTML responses).
* Returns null when no run logs with a tally are found.
*/
function readTallyTotals(runsDirOrDirs, sinceMs = null) {
const dirs = Array.isArray(runsDirOrDirs) ? runsDirOrDirs : [runsDirOrDirs];
let attempted = 0, succeeded = 0, found = false;
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
for (const rf of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
const run = JSON.parse(fs.readFileSync(path.join(dir, rf), 'utf8'));
if (sinceMs && run.finishedAt && Date.parse(run.finishedAt) < sinceMs) continue;
if (!run.tally) continue;
found = true;
const t = run.tally;
attempted += (t.ok ?? 0) + (t.blocked ?? 0) + (t.timeout ?? 0) + (t.error ?? 0);
succeeded += (t.ok ?? 0);
}
}
return found ? { attempted, succeeded } : null;
}
/** Fold broken links from one or more runs dirs (optionally since cutoff ms). */
function readBrokenLinks(runsDirOrDirs, sinceMs = null) {
const dirs = Array.isArray(runsDirOrDirs) ? runsDirOrDirs : [runsDirOrDirs];
const brokenLinks = new Map();
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
for (const rf of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
const run = JSON.parse(fs.readFileSync(path.join(dir, rf), 'utf8'));
if (sinceMs && run.finishedAt && Date.parse(run.finishedAt) < sinceMs) continue;
for (const b of run.linkCheck?.broken ?? []) {
const existing = brokenLinks.get(b.url);
if (!existing) brokenLinks.set(b.url, { ...b, foundOn: new Set(b.foundOn ?? []) });
else for (const s of b.foundOn ?? []) existing.foundOn.add(s);
}
}
}
return brokenLinks;
}
/** Aggregate a set of page records into a summary (shared by week + window). */
function summarizeRecords(target, week, records, brokenLinks) {
const files = records; // loop below iterates parsed records directly
const axeRules = {}; // ruleId -> { count, pages, impact, help, helpUrl, examples }
const alfaRules = {};
const deprecatedRules = {}; // ruleId -> { count, pages, help, examplePages, instances }
const enginePageCounts = {}; // engine -> unique pages it ran on (coverage)
const resourceMap = new Map(); // resource url -> { url, type, foundOn:Set }
// Standards (per-page): checkId -> { label, pass, total } pass counts.
const standardsChecks = {};
let standardsPages = 0;
const socialSeen = new Map(); // platform -> example href
// Security (per-origin): keep the latest result seen this week.
let securityLatest = null;
let pagesScanned = 0;
let pagesWithAxeViolations = 0;
let pagesWithAlfaFailures = 0;
let axeViolationTotal = 0;
let alfaFailedTotal = 0;
// Per-page failure counts (one entry per page the engine ran on, zeros
// included) for medians, and unique-page counts per engine. Each page
// record is one unique URL (file keyed by pageId), so counting records
// is inherently deduplicated.
const axeCountsPerPage = [];
const alfaCountsPerPage = [];
const auditedPageIds = new Set(); // pages scanned by axe and/or alfa
const pagesWithAxe = []; // URLs with >=1 axe violation (for the CSV behind "37 of 598")
const pagesWithAlfa = []; // URLs with >=1 alfa failure
const bytesList = [];
const requestsList = [];
let co2Total = 0;
let energyTotal = 0;
let pagesWithAudit = 0;
const errorPages = [];
const blockedStatuses = {}; // status code -> count, for the blocked callout
const techDetections = new Map(); // name -> detection (merged across pages)
// Plain-language: collect scored pages' readability for medians.
const freList = [];
const gradeList = [];
let plPagesScored = 0;
const acronymCounts = {}; // acronym -> { pages, examplePages[] }
const misspellingCounts = {}; // word -> { pages, examplePages[] }
const plRows = []; // per-page readability rows (for CSV)
let plPagesChecked = 0; // pages plain-language ran on (for words/page)
const wordCounts = []; // per-page main-content word counts
// Lighthouse: collect sampled scores + Core Web Vitals metrics for
// medians, and keep per-sampled-page detail for the Lighthouse page.
const lhScores = { performance: [], accessibility: [], bestPractices: [], seo: [], agentic: [] };
const lhMetrics = { firstContentfulPaintMs: [], largestContentfulPaintMs: [], speedIndexMs: [], totalBlockingTimeMs: [], cumulativeLayoutShift: [] };
const lhPages = []; // { url, scores, metrics }
// Lighthouse recommendations (non-a11y audits) rolled up across sampled pages.
const lhReco = {}; // auditId -> { id, category, title, pages, examplePages[], savingsBytes, savingsMs }
// Images: per-page flat list for CSV + aggregate alt-text metrics.
const imageRows = []; // { pageUrl, src, alt, hasAlt, isDecorative, isMissingAlt, width, height, loading, bytes }
let imagePagesScanned = 0;
let imagesTotalCount = 0;
let imagesMissingAlt = 0;
let imagesDecorative = 0;
// Deduplicated by src: the same image reused across pages collapses to one
// entry with an occurrence count, so the page table isn't full of repeats.
const imageBySrc = new Map(); // src -> { src, alt, bytes, occurrences, pages:Set, altVerdict, altReason, alts:Set }
const altVerdictCounts = {}; // verdict -> unique-image count (for the quality summary)
// Tech↔finding join: one row per page where the tech engine ran, listing
// the technologies on it and the accessibility findings on it. Only pages
// with tech detection contribute, so the co-occurrence denominator is the
// set of pages we actually have technology data for.
const techFindingPages = []; // { techs: string[], findings: string[] }
// Third-party rollup: one row per page the third-party engine ran on.
const thirdPartyPages = []; // { pageUrl, hasFindings, origins[] }
// brokenLinks is supplied by the caller (folded from run logs).
for (const rec of files) {
pagesScanned++;
if (rec.axe || rec.alfa || rec.sustainability || rec.plainLanguage || rec.lighthouse) pagesWithAudit++;
if (typeof rec.status === 'number' && rec.status >= 400) {
errorPages.push({ url: rec.url, status: rec.status });
blockedStatuses[rec.status] = (blockedStatuses[rec.status] ?? 0) + 1;
}
if (rec.axe) {
auditedPageIds.add(rec.pageId ?? rec.url);
axeCountsPerPage.push(rec.axe.violationCount);
if (rec.axe.violationCount > 0) { pagesWithAxeViolations++; pagesWithAxe.push(rec.url); }
axeViolationTotal += rec.axe.violationCount;
for (const [id, v] of Object.entries(rec.axe.violations)) {
const r = (axeRules[id] ??= { count: 0, pages: 0, impact: v.impact, help: v.help, helpUrl: v.helpUrl, tags: v.tags ?? [], examplePages: [], affectedPages: [], instances: [] });
r.count += v.count;
r.pages++;
if (r.examplePages.length < 3) r.examplePages.push(rec.url);
if (r.affectedPages.length < MAX_AFFECTED_PAGES) r.affectedPages.push({ url: rec.url, instances: v.count });
addInstances(r, rec.url, v.examples);
}
}
if (rec.alfa) {
auditedPageIds.add(rec.pageId ?? rec.url);
alfaCountsPerPage.push(rec.alfa.failedCount);
if (rec.alfa.failedCount > 0) { pagesWithAlfaFailures++; pagesWithAlfa.push(rec.url); }
alfaFailedTotal += rec.alfa.failedCount;
for (const [id, v] of Object.entries(rec.alfa.failed)) {
const r = (alfaRules[id] ??= { count: 0, pages: 0, ruleUrl: v.ruleUrl, examplePages: [], affectedPages: [], instances: [] });
r.count += v.count;
r.pages++;
if (r.examplePages.length < 3) r.examplePages.push(rec.url);
if (r.affectedPages.length < MAX_AFFECTED_PAGES) r.affectedPages.push({ url: rec.url, instances: v.count });
addInstances(r, rec.url, v.examples);
}
}
if (rec.deprecatedHtml) {
for (const [id, v] of Object.entries(rec.deprecatedHtml.findings)) {
const r = (deprecatedRules[id] ??= { count: 0, pages: 0, help: v.help, examplePages: [], affectedPages: [], instances: [] });
r.count += v.count;
r.pages++;
if (r.examplePages.length < 3) r.examplePages.push(rec.url);
if (r.affectedPages.length < MAX_AFFECTED_PAGES) r.affectedPages.push({ url: rec.url, instances: v.count });
addInstances(r, rec.url, v.examples);
}
}
if (rec.resources) {
for (const res of rec.resources.resources ?? []) {
const entry = resourceMap.get(res.url) ?? { url: res.url, type: res.type, foundOn: new Set() };
entry.foundOn.add(rec.url);
resourceMap.set(res.url, entry);
}
}
if (rec.standards) {
standardsPages++;
for (const c of rec.standards.checks ?? []) {
const s = (standardsChecks[c.id] ??= { label: c.label, pass: 0, total: 0 });
s.total++;
if (c.pass) s.pass++;
s.label = c.label; // keep latest label (counts may be embedded)
}
for (const soc of rec.standards.social ?? []) {
if (!socialSeen.has(soc.platform)) socialSeen.set(soc.platform, soc.href);
}
}
if (rec.security) securityLatest = rec.security; // per-origin; latest wins
if (rec.tech) {
for (const d of rec.tech) {
const existing = techDetections.get(d.name);
// Merge: keep highest-confidence detection; track how many pages
// confirmed it and a few example pages where it was found.
// confidence is 0–100 (Wappalyzer's scale), higher is better.
if (!existing) {
techDetections.set(d.name, { ...d, pagesConfirmed: 1, examplePages: [rec.url] });
} else {
existing.pagesConfirmed++;
if (existing.examplePages.length < 10) existing.examplePages.push(rec.url);
if (d.confidence > existing.confidence) {
// Adopt the higher-confidence detection's fields but keep the
// accumulated count + example pages.
Object.assign(existing, d, { pagesConfirmed: existing.pagesConfirmed, examplePages: existing.examplePages });
}
}
}
// Tech↔finding row for this page: the technologies on it paired with the
// accessibility findings on it. Keyed engine:ruleId. Only built for pages
// with tech data so the co-occurrence denominator is well-defined.
const findings = [
...Object.keys(rec.axe?.violations ?? {}).map((id) => `axe:${id}`),
...Object.keys(rec.alfa?.failed ?? {}).map((id) => `alfa:${id}`),
];
techFindingPages.push({ techs: rec.tech.map((d) => d.name), findings });
}
if (rec.thirdParty) {
// hasFindings: did this page have any accessibility finding? Used to
// correlate third-party presence with findings (association only).
const hasFindings = !!(rec.axe?.violationCount || rec.alfa?.failedCount);
thirdPartyPages.push({ pageUrl: rec.url, hasFindings, origins: rec.thirdParty.origins ?? [] });
}
if (rec.images) {
imagePagesScanned++;
imagesTotalCount += rec.images.count;
for (const img of rec.images.images ?? []) {
if (img.isMissingAlt) imagesMissingAlt++;
if (img.isDecorative) imagesDecorative++;
imageRows.push({ pageUrl: rec.url, ...img });
// Deduplicate by src and alt for the page view.
const key = `${img.src}|${img.alt ?? ''}`;
let e = imageBySrc.get(key);
if (!e) {
e = {
src: img.src,
alt: img.alt,
bytes: img.bytes ?? null,
occurrences: 0,
pages: new Set(),
loadings: new Set(),
altVerdict: img.altVerdict,
altReason: img.altReason
};
imageBySrc.set(key, e);
}
e.occurrences++;
e.pages.add(rec.url);
if (img.loading) e.loadings.add(img.loading);
if (e.bytes == null && img.bytes != null) e.bytes = img.bytes;
}
}
if (rec.sustainability) {
bytesList.push(rec.sustainability.bytes);
requestsList.push(rec.sustainability.requests);
co2Total += rec.sustainability.co2g;
energyTotal += rec.sustainability.energyWh ?? 0;
}
// Per-engine coverage: which engines actually ran on this page.
for (const e of ['axe', 'alfa', 'plain-language', 'deprecated-html', 'resources', 'standards', 'security', 'lighthouse', 'sustainability', 'tech']) {
const key = { 'plain-language': 'plainLanguage', 'deprecated-html': 'deprecatedHtml' }[e] ?? e;
if (rec[key]) enginePageCounts[e] = (enginePageCounts[e] ?? 0) + 1;
}
if (rec.plainLanguage) {
plPagesChecked++;
if (typeof rec.plainLanguage.wordCount === 'number') wordCounts.push(rec.plainLanguage.wordCount);
// Per-page readability row (for the readability CSV).
plRows.push({
url: rec.url,
wordCount: rec.plainLanguage.wordCount ?? 0,
fleschReadingEase: rec.plainLanguage.fleschReadingEase ?? '',
fleschKincaidGrade: rec.plainLanguage.fleschKincaidGrade ?? '',
scored: rec.plainLanguage.scored ?? false,
});
if (rec.plainLanguage.scored) {
plPagesScored++;
if (rec.plainLanguage.fleschReadingEase != null) freList.push(rec.plainLanguage.fleschReadingEase);
if (rec.plainLanguage.fleschKincaidGrade != null) gradeList.push(rec.plainLanguage.fleschKincaidGrade);
}
for (const a of rec.plainLanguage.unexplainedAcronyms ?? []) {
const e = (acronymCounts[a] ??= { pages: 0, examplePages: [] });
e.pages++;
if (e.examplePages.length < 5) e.examplePages.push(rec.url);
}
for (const w of rec.plainLanguage.misspelled ?? []) {
const m = (misspellingCounts[w] ??= { pages: 0, examplePages: [] });
m.pages++;
if (m.examplePages.length < 5) m.examplePages.push(rec.url);
}
}
if (rec.lighthouse?.scores) {
for (const k of Object.keys(lhScores)) {
const v = rec.lighthouse.scores[k];
if (typeof v === 'number') lhScores[k].push(v);
}
for (const k of Object.keys(lhMetrics)) {
const v = rec.lighthouse.metrics?.[k];
if (typeof v === 'number') lhMetrics[k].push(v);
}
lhPages.push({ url: rec.url, scores: rec.lighthouse.scores, metrics: rec.lighthouse.metrics ?? {} });
// Roll up failing audits into recommendations (like axe rules): one
// entry per audit id, counting pages affected and summing savings.
for (const a of rec.lighthouse.audits ?? []) {
const r = (lhReco[a.id] ??= { id: a.id, category: a.category, title: a.title, pages: 0, examplePages: [], savingsBytes: 0, savingsMs: 0 });
r.pages++;
if (r.examplePages.length < 10) r.examplePages.push(rec.url);
r.savingsBytes += a.savingsBytes ?? 0;
r.savingsMs += a.savingsMs ?? 0;
}
}
}
// A target is "blocked" when it returned only error responses (e.g. a
// WAF answering 403 to the scanner) and produced no audit data at all.
// The dominant error status is surfaced in the dashboard callout.
const dominantStatus = Object.entries(blockedStatuses).sort((a, b) => b[1] - a[1])[0]?.[0];
const blocked =
pagesScanned > 0 && pagesWithAudit === 0 && dominantStatus
? { status: Number(dominantStatus) }
: null;
return {
domain: target.domain,
week,
generatedAt: new Date().toISOString(),
pagesScanned,
// Full lists of pages with any axe/alfa finding — used to write the
// CSVs behind the "N of M" report numbers. Omitted from the committed
// summary.json (see the JSON replacer at write time); kept in memory.
pagesWithAxeList: pagesWithAxe,
pagesWithAlfaList: pagesWithAlfa,
// Unique pages scanned by axe and/or alfa this week (deduped by page).
pagesAudited: auditedPageIds.size,
// Per-engine coverage: unique pages each engine ran on, vs pages
// scanned. Reflects the configured weekly sampling rates.
coverage: enginePageCounts,
blocked,
axe: {
violationTotal: axeViolationTotal,
pagesWithViolations: pagesWithAxeViolations,
pagesScanned: axeCountsPerPage.length,
medianViolations: axeCountsPerPage.length ? median(axeCountsPerPage) : null,
rules: axeRules,
},
alfa: {
failedTotal: alfaFailedTotal,
pagesWithFailures: pagesWithAlfaFailures,
pagesScanned: alfaCountsPerPage.length,
medianFailures: alfaCountsPerPage.length ? median(alfaCountsPerPage) : null,
rules: alfaRules,
},
// Cross-engine consolidation via W3C ACT rules: how many unique issues
// there really are (not axe + alfa double-counted), and how many both
// engines agree on.
consensus: buildConsensus(axeRules, alfaRules),
sustainability: bytesList.length
? {
pages: bytesList.length,
medianBytes: median(bytesList),
meanBytes: Math.round(bytesList.reduce((a, b) => a + b, 0) / bytesList.length),
medianRequests: median(requestsList),
totalCo2g: Math.round(co2Total * 100) / 100,
meanCo2g: Math.round((co2Total / bytesList.length) * 10000) / 10000,
totalEnergyWh: Math.round(energyTotal * 100) / 100,
meanEnergyWh: Math.round((energyTotal / bytesList.length) * 10000) / 10000,
// Per-page byte sizes for the performance-impact estimate
// (omitted from committed summary.json — see the JSON replacer).
bytesList,
}
: null,
deprecatedHtml: Object.keys(deprecatedRules).length
? {
findingTotal: Object.values(deprecatedRules).reduce((s, r) => s + r.count, 0),
rules: deprecatedRules,
}
: null,
resources: resourceMap.size
? {
total: resourceMap.size,
byType: countBy([...resourceMap.values()], (r) => r.type),
// Full list (url, type, count of pages it appears on) for the
// ledger, inventory, and CSV. foundOn Set -> count.
list: [...resourceMap.values()].map((r) => ({ url: r.url, type: r.type, pages: r.foundOn.size })),
}
: null,
standards: standardsPages
? {
pagesChecked: standardsPages,
// Per-check pass rate across the checked pages.
checks: Object.entries(standardsChecks).map(([id, s]) => ({
id, label: s.label, pass: s.pass, total: s.total,
rate: Math.round((s.pass / s.total) * 100),
})).sort((a, b) => a.rate - b.rate),
social: [...socialSeen.entries()].map(([platform, href]) => ({ platform, href })),
}
: null,
security: securityLatest
? { checks: securityLatest.checks, passed: securityLatest.passed, total: securityLatest.total }
: null,
plainLanguage: plPagesChecked
? {
pagesChecked: plPagesChecked,
pagesScored: plPagesScored,
medianWordsPerPage: wordCounts.length ? median(wordCounts) : null,
// Readability only over pages with enough prose to score.
medianReadingEase: freList.length ? median(freList) : null,
medianGrade: gradeList.length ? median(gradeList) : null,
// Most common unexplained acronyms, by pages affected.
topUnexplainedAcronyms: Object.entries(acronymCounts)
.sort((a, b) => b[1].pages - a[1].pages)
.slice(0, 15)
.map(([acronym, e]) => ({ acronym, pages: e.pages, examplePages: e.examplePages })),
// Most common misspellings, by pages affected (with examples).
topMisspellings: Object.entries(misspellingCounts)
.sort((a, b) => b[1].pages - a[1].pages)
.slice(0, 25)
.map(([word, m]) => ({ word, pages: m.pages, examplePages: m.examplePages })),
// Per-page rows for the readability CSV (omitted from committed summary.json).
pageRows: plRows,
}
: null,
lighthouse: lhScores.performance.length
? {
pagesSampled: lhScores.performance.length,
medianPerformance: median(lhScores.performance),
medianAccessibility: lhScores.accessibility.length ? median(lhScores.accessibility) : null,
medianBestPractices: lhScores.bestPractices.length ? median(lhScores.bestPractices) : null,
medianSeo: lhScores.seo.length ? median(lhScores.seo) : null,
medianAgentic: lhScores.agentic.length ? median(lhScores.agentic) : null,
metrics: {
firstContentfulPaintMs: lhMetrics.firstContentfulPaintMs.length ? median(lhMetrics.firstContentfulPaintMs) : null,
largestContentfulPaintMs: lhMetrics.largestContentfulPaintMs.length ? median(lhMetrics.largestContentfulPaintMs) : null,
speedIndexMs: lhMetrics.speedIndexMs.length ? median(lhMetrics.speedIndexMs) : null,
totalBlockingTimeMs: lhMetrics.totalBlockingTimeMs.length ? median(lhMetrics.totalBlockingTimeMs) : null,
cumulativeLayoutShift: lhMetrics.cumulativeLayoutShift.length ? median(lhMetrics.cumulativeLayoutShift) : null,
},
pageDetail: lhPages, // per-sampled-page detail for the Lighthouse page (omitted from committed summary.json)
// Aggregated non-accessibility recommendations, ranked by reach then
// impact. Stored in the committed summary (compact, like axe rules).
recommendations: Object.values(lhReco).sort(
(a, b) => b.pages - a.pages || (b.savingsBytes + b.savingsMs * 1000) - (a.savingsBytes + a.savingsMs * 1000)
),
}
: null,
linkCheck: brokenLinks.size
? {
brokenCount: brokenLinks.size,
broken: [...brokenLinks.values()].slice(0, 50).map((b) => ({
url: b.url,
status: b.status,
reason: b.reason,
foundOn: [...(b.foundOn ?? [])], // pages that link to this broken URL
})),
}
: null,
errorPages: errorPages.slice(0, 25),
tech: techDetections.size
? [...techDetections.values()].sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name))
: null,
images: imagePagesScanned
? (() => {
const srcAltCounts = {};
for (const e of imageBySrc.values()) {
srcAltCounts[e.src] = (srcAltCounts[e.src] ?? 0) + 1;
}
// Deduplicated, occurrence-counted unique images for the page table,
// plus the alt-text quality verdict tally (counted per unique image).
const uniqueImages = [...imageBySrc.values()]
.map((e) => ({
src: e.src,
alt: e.alt,
bytes: e.bytes,
occurrences: e.occurrences,
pages: e.pages.size,
loading: e.loadings.size > 0 ? [...e.loadings].sort().join(', ') : null,
altCount: srcAltCounts[e.src], // >1 means inconsistent alt across uses
altVerdict: e.altVerdict,
altReason: e.altReason,
examplePages: [...e.pages].slice(0, 10),
}))
.sort((a, b) => b.occurrences - a.occurrences || (b.bytes ?? 0) - (a.bytes ?? 0));
for (const u of uniqueImages) altVerdictCounts[u.altVerdict] = (altVerdictCounts[u.altVerdict] ?? 0) + 1;
return {
pagesScanned: imagePagesScanned,
totalImages: imagesTotalCount,
uniqueImages: uniqueImages.length,
missingAlt: imagesMissingAlt,
decorative: imagesDecorative,
withAlt: imagesTotalCount - imagesMissingAlt - imagesDecorative,
// Alt-text quality verdict counts over unique images.
altVerdicts: altVerdictCounts,
// Deduplicated unique-image list (committed; bounded by site image variety).
uniqueImageList: uniqueImages,
// Per-occurrence rows for the CSV (omitted from committed summary.json).
imageRows,
};
})()
: null,
// Tech↔finding association: the compact co-occurrence model (for the
// fleet merge on the dashboard) plus this week's ranked associations.
// Null when no page had both tech detection and the join is meaningless.
techFindings: techFindingPages.length
? (() => {
const model = buildCooccurrence(techFindingPages);
return { model, associations: rankAssociations(model, { minPages: 5, limit: 50 }) };
})()
: null,
// Third-party resource/JS cost rolled up per vendor across the pages the
// third-party engine ran on. Page-resolved (3rd-party JS varies per page).
thirdParty: thirdPartyPages.length ? rollupThirdParty(thirdPartyPages) : null,
};
}
function diffWeeks(prev, curr) {
const diffEngine = (prevRules, currRules) => {
const appeared = Object.keys(currRules).filter((id) => !(id in prevRules));
const resolved = Object.keys(prevRules).filter((id) => !(id in currRules));
const changed = Object.keys(currRules)
.filter((id) => id in prevRules && currRules[id].pages !== prevRules[id].pages)
.map((id) => ({ id, pagesBefore: prevRules[id].pages, pagesAfter: currRules[id].pages }));
return { appeared, resolved, changed };
};
return {
prevWeek: prev.week,
pagesDelta: curr.pagesScanned - prev.pagesScanned,
axe: {
violationDelta: curr.axe.violationTotal - prev.axe.violationTotal,
...diffEngine(prev.axe.rules, curr.axe.rules),
},
alfa: {
failedDelta: curr.alfa.failedTotal - prev.alfa.failedTotal,
...diffEngine(prev.alfa.rules, curr.alfa.rules),
},
sustainability:
prev.sustainability && curr.sustainability
? {
medianBytesDelta: curr.sustainability.medianBytes - prev.sustainability.medianBytes,
meanCo2gDelta: Math.round((curr.sustainability.meanCo2g - prev.sustainability.meanCo2g) * 10000) / 10000,
}
: null,
};
}
// Keep a small, capped set of representative failing instances per rule
// (page URL + element selector/snippet) so bug reports carry real DOM
// context. Caps total per rule to keep summary.json small.
function addInstances(rule, url, examples) {
if (!Array.isArray(examples)) return;
for (const ex of examples) {
if (rule.instances.length >= MAX_RULE_INSTANCES) break;
rule.instances.push({
url,
target: ex.target ?? null, // CSS selector (axe) / element description (alfa)
html: ex.html ?? null, // minimal failing markup (axe only)
});
}
}
function countBy(items, keyFn) {
const out = {};
for (const it of items) {
const k = keyFn(it);
out[k] = (out[k] ?? 0) + 1;
}
return out;
}
function median(list) {
const s = [...list].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
}