vital-core / src /lib /csv.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
11.5 kB
import fs from 'node:fs';
import path from 'node:path';
/**
* CSV exports of the pages affected by each finding, so a developer can
* pull the full list of URLs to reproduce and fix an issue — the report
* shows a few examples and links to the complete CSV.
*
* Files are written under docs/reports/<domain>/<week>/csv/. Filenames
* are slugged from the rule id so they're stable and linkable.
*/
const csvField = (s) => {
const v = String(s ?? '');
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
};
export function toCsv(headers, rows) {
const lines = [headers.map(csvField).join(',')];
for (const row of rows) lines.push(row.map(csvField).join(','));
return lines.join('\n') + '\n';
}
/** Filesystem- and URL-safe slug for a rule id (e.g. "sia-r12", "color-contrast"). */
export function ruleSlug(engine, ruleId) {
return `${engine}__${ruleId}`.toLowerCase().replace(/[^a-z0-9._-]/g, '-');
}
/**
* Write the resource inventory CSV (every linked/embedded non-HTML
* resource: PDFs, docs, iframes, media) with type, pages it appears on,
* and when it was first seen (from the resource ledger). Returns the
* relative path "resources.csv".
*/
/** Write the per-page Lighthouse CSV (scores + Core Web Vitals). */
export function writeLighthouseCsv(repDir, lighthouse) {
if (!lighthouse?.pageDetail?.length) return null;
const rows = lighthouse.pageDetail.map((p) => [
p.url, p.scores.performance, p.scores.accessibility, p.scores.bestPractices,
p.scores.seo, p.scores.agentic, p.metrics.firstContentfulPaintMs,
p.metrics.largestContentfulPaintMs, p.metrics.speedIndexMs,
p.metrics.totalBlockingTimeMs, p.metrics.cumulativeLayoutShift,
]);
fs.writeFileSync(path.join(repDir, 'lighthouse.csv'),
toCsv(['url', 'performance', 'accessibility', 'best_practices', 'seo', 'agentic', 'fcp_ms', 'lcp_ms', 'speed_index_ms', 'tbt_ms', 'cls'], rows));
return 'lighthouse.csv';
}
/** Write per-page readability CSV (words, Flesch reading ease, grade). */
export function writeReadabilityCsv(repDir, plRows) {
if (!plRows?.length) return null;
const rows = plRows.map((r) => [r.url, r.wordCount, r.fleschReadingEase, r.fleschKincaidGrade, r.scored]);
fs.writeFileSync(path.join(repDir, 'readability.csv'),
toCsv(['url', 'words', 'reading_ease', 'grade', 'scored'], rows));
return 'readability.csv';
}
/** Write spelling CSV (misspelled word, pages affected, example URLs). */
export function writeSpellingCsv(repDir, spellRows) {
if (!spellRows?.length) return null;
const rows = spellRows.map((s) => [s.word, s.pages, (s.examplePages ?? []).join(' ')]);
fs.writeFileSync(path.join(repDir, 'spelling.csv'),
toCsv(['word', 'pages_affected', 'example_pages'], rows));
return 'spelling.csv';
}
/** Write tech CSV (technology, category, confidence, pages, example URLs). */
export function writeTechCsv(repDir, tech) {
if (!tech?.length) return null;
const headers = ['technology', 'category', 'all_categories', 'confidence', 'version', 'pages_confirmed', 'website', 'example_pages'];
const rows = tech.map((d) => [
d.name,
d.category,
(d.categories ?? []).join(' | '),
d.confidence,
d.version ?? '',
d.pagesConfirmed ?? '',
d.website ?? '',
(d.examplePages ?? []).join(' '),
]);
fs.writeFileSync(path.join(repDir, 'tech.csv'), toCsv(headers, rows));
return 'tech.csv';
}
/** Write acronyms CSV (unexplained acronym, pages affected, example URLs). */
export function writeAcronymsCsv(repDir, acronymRows) {
if (!acronymRows?.length) return null;
const rows = acronymRows.map((a) => [a.acronym, a.pages, (a.examplePages ?? []).join(' ')]);
fs.writeFileSync(path.join(repDir, 'acronyms.csv'),
toCsv(['acronym', 'pages_affected', 'example_pages'], rows));
return 'acronyms.csv';
}
export function writeResourceCsv(repDir, resources, ledger) {
const rows = resources.list.map((r) => {
const led = ledger.resources[r.url];
return [r.url, r.type, r.pages, led?.firstSeen ?? '', led?.lastSeen ?? ''];
});
fs.writeFileSync(path.join(repDir, 'resources.csv'), toCsv(['url', 'type', 'pages', 'first_seen', 'last_seen'], rows));
return 'resources.csv';
}
/**
* Write a flat bugs.csv containing one row per bug report (one per failing
* rule), with every field an accessibility engineer needs to triage, filter,
* reproduce, and file in JIRA — matching what the HTML report shows:
*
* Identity: bug_id, pattern_id, combined_id (JIRA filter format)
* Classification: engine, rule_id, wcag_category, wcag_sc, wcag_name,
* wcag_level, wcag_version, severity
* Frequency: pages_affected, instances, total_pages_scanned
* Reproduction: example_url, xpath, html_snippet
* Narrative: summary, description, steps_to_reproduce, suggested_fix,
* remediation_tip, testing_environment
* Impact: impact_summary, impact_groups (serialized)
* History: first_seen, last_seen, weeks_seen
* Deduplication: possible_duplicate_of, possible_duplicate_pattern
* Links: affected_pages_csv, rule_url
*
* Returns the relative path "bugs.csv" (from index.html) or null.
*/
export function writeBugsCsv(repDir, bugs) {
if (!bugs?.length) return null;
const headers = [
'bug_id', 'pattern_id', 'combined_id',
'engine', 'rule_id', 'rule_url',
'wcag_category', 'wcag_sc', 'wcag_name', 'wcag_level', 'wcag_version',
'severity', 'pages_affected', 'instances', 'total_pages_scanned',
'example_url', 'xpath', 'html_snippet',
'summary', 'description', 'steps_to_reproduce', 'suggested_fix',
'remediation_tip', 'testing_environment',
'impact_summary', 'impact_groups',
'first_seen', 'last_seen', 'weeks_seen',
'possible_duplicate_of', 'possible_duplicate_pattern',
'affected_pages_csv',
];
const rows = bugs.map((b) => {
const groups = (b.impact?.groups ?? [])
.map((g) => `${g.group} (${g.percent})`)
.join('; ');
return [
b.instance_id,
b.pattern_id,
`${b.instance_id} (pattern ${b.pattern_id})`,
b.engine_key,
b.rule_id,
b.rule_url ?? '',
b.wcag_category ?? '',
b.wcag_sc ?? '',
b.wcag_name ?? '',
b.wcag_level ?? '',
b.wcag_version ?? '',
b.severity,
b.frequency.pages_affected,
b.frequency.instances,
b.frequency.total_pages_scanned,
b.url ?? '',
b.xpath ?? '',
b.html_snippet ?? '',
b.summary ?? '',
b.description ?? '',
(b.steps_to_reproduce ?? []).join(' | '),
b.suggested_fix ?? '',
b.remediation_tip ?? '',
b.testing_environment ?? '',
b.impact?.summary ?? '',
groups,
b.first_seen ?? '',
b.last_seen ?? '',
b.weeks_seen ?? '',
b.possible_duplicate_of ?? '',
b.possible_duplicate_pattern ?? '',
b.affected_pages_csv ?? '',
];
});
fs.writeFileSync(path.join(repDir, 'bugs.csv'), toCsv(headers, rows));
return 'bugs.csv';
}
/**
* Write a flat images.csv — one row per image found across scanned pages.
* Returns the relative path "images.csv" or null if there's nothing to write.
*/
export function writeImagesCsv(repDir, summary) {
const rows = summary.images?.imageRows;
if (!rows?.length) return null;
const headers = ['page_url', 'src', 'alt', 'alt_verdict', 'alt_reason', 'has_alt', 'is_decorative', 'is_missing_alt', 'width', 'height', 'natural_width', 'natural_height', 'loading', 'decoding', 'bytes'];
const data = rows.map((img) => [
img.pageUrl,
img.src,
img.alt ?? '',
img.altVerdict ?? '',
img.altReason ?? '',
img.hasAlt ? 'true' : 'false',
img.isDecorative ? 'true' : 'false',
img.isMissingAlt ? 'true' : 'false',
img.width ?? '',
img.height ?? '',
img.naturalWidth ?? '',
img.naturalHeight ?? '',
img.loading ?? '',
img.decoding ?? '',
img.bytes ?? '',
]);
fs.writeFileSync(path.join(repDir, 'images.csv'), toCsv(headers, data));
return 'images.csv';
}
/**
* Write a flat third-party.csv — one row per third-party vendor (registrable
* domain) with its load cost and finding co-occurrence. Returns the relative
* path "third-party.csv" or null if there's nothing to write.
*/
export function writeThirdPartyCsv(repDir, summary) {
const vendors = summary.thirdParty?.vendors;
if (!vendors?.length) return null;
const headers = ['origin', 'is_script_vendor', 'pages', 'pages_with_scripts', 'median_bytes', 'median_requests', 'median_duration_ms', 'pages_with_finding', 'first_seen', 'last_seen', 'weeks_seen', 'example_pages'];
const data = vendors.map((v) => [
v.origin,
v.isScriptVendor ? 'true' : 'false',
v.pages,
v.pagesWithScripts ?? '',
v.medianBytes,
v.medianRequests,
v.medianDurationMs,
v.pagesWithFindings,
v.firstSeen ?? '',
v.lastSeen ?? '',
v.weeksSeen ?? '',
Array.isArray(v.examplePages) ? v.examplePages.join(' | ') : '',
]);
fs.writeFileSync(path.join(repDir, 'third-party.csv'), toCsv(headers, data));
return 'third-party.csv';
}
/**
* Write a flat errors.csv for broken links and non-404 error pages.
* Returns the relative path "errors.csv" or null if there's nothing to write.
*/
export function writeErrorsCsv(repDir, summary) {
const broken = summary.linkCheck?.broken ?? [];
const errors = (summary.errorPages ?? []).filter((e) => Number(e.status) !== 404);
if (!broken.length && !errors.length) return null;
const headers = ['type', 'url', 'status', 'linked_from'];
const rows = [
...broken.map((b) => [
'broken_link',
b.url,
b.status || b.reason || '',
Array.isArray(b.foundOn) ? b.foundOn.join(' | ') : (b.foundOn ?? ''),
]),
...errors.map((e) => [
'page_error',
e.url,
e.status,
'',
]),
];
fs.writeFileSync(path.join(repDir, 'errors.csv'), toCsv(headers, rows));
return 'errors.csv';
}
/**
* Write all CSVs for one domain/week into <repDir>/csv/. Returns a map of
* { axeAll, alfaAll, byRule: { "<engine>:<ruleId>": "<relative csv path>" } }
* so the report can link to each. Relative paths are from the report's
* index.html (i.e. "csv/<file>.csv").
*/
export function writeCsvs(repDir, summary) {
const csvDir = path.join(repDir, 'csv');
fs.mkdirSync(csvDir, { recursive: true });
const links = { axeAll: null, alfaAll: null, byRule: {} };
const writeFile = (name, content) => {
fs.writeFileSync(path.join(csvDir, name), content);
return `csv/${name}`;
};
// "All pages with an axe violation" / "...Alfa failure".
if (summary.pagesWithAxeList?.length) {
links.axeAll = writeFile('axe-pages-with-violations.csv', toCsv(['url'], summary.pagesWithAxeList.map((u) => [u])));
}
if (summary.pagesWithAlfaList?.length) {
links.alfaAll = writeFile('alfa-pages-with-failures.csv', toCsv(['url'], summary.pagesWithAlfaList.map((u) => [u])));
}
// Per-rule affected-page CSVs (axe, alfa, deprecated-html).
const ruleSets = [
['axe-core', summary.axe?.rules],
['alfa', summary.alfa?.rules],
['deprecated-html', summary.deprecatedHtml?.rules],
];
for (const [engine, rules] of ruleSets) {
for (const [ruleId, rule] of Object.entries(rules ?? {})) {
if (!rule.affectedPages?.length) continue;
const name = `${ruleSlug(engine, ruleId)}.csv`;
const rows = rule.affectedPages.map((p) => [p.url, p.instances]);
links.byRule[`${engine}:${ruleId}`] = writeFile(name, toCsv(['url', 'instances'], rows));
}
}
return links;
}