Skip to content

Commit 8a1acbf

Browse files
authored
Merge pull request #89 from mgifford/copilot/create-news-release-summary
feat: generate daily press-release-style accessibility summary
2 parents ccfdb51 + 699ee82 commit 8a1acbf

4 files changed

Lines changed: 619 additions & 0 deletions

File tree

.github/workflows/daily-scan.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ jobs:
135135
shell: bash
136136
run: node src/cli/generate-accessibility-summary.js
137137

138+
- name: Generate news release summary
139+
if: steps.pipeline.outputs.exit_code == '0' && env.DRY_RUN != 'true'
140+
shell: bash
141+
run: |
142+
if [[ -n "$RUN_DATE" ]]; then
143+
node src/cli/generate-press-release.js --date "$RUN_DATE"
144+
else
145+
node src/cli/generate-press-release.js
146+
fi
147+
138148
- name: Fail workflow on pipeline error
139149
if: steps.pipeline.outputs.exit_code != '0'
140150
run: |

src/cli/generate-press-release.js

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Generates a plain-text press release (news release) summarizing the top
4+
* accessibility barriers found in today's daily DAP scan.
5+
*
6+
* Reads report.json and axe-findings.json from docs/reports/daily/YYYY-MM-DD/
7+
* and writes a Markdown news release suitable for adaptation into a
8+
* communications product.
9+
*
10+
* Output: press-release.md written to the daily report directory (and/or stdout).
11+
*
12+
* Usage:
13+
* node src/cli/generate-press-release.js [--output-root <dir>] [--date YYYY-MM-DD]
14+
*/
15+
16+
import fs from 'node:fs/promises';
17+
import path from 'node:path';
18+
import { fileURLToPath, pathToFileURL } from 'node:url';
19+
import { getPolicyNarrative } from '../data/axe-impact-loader.js';
20+
21+
const BASE_REPORT_URL = 'https://mgifford.github.io/daily-dap/docs/reports/daily';
22+
23+
function getDefaultRepoRoot() {
24+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
25+
return path.resolve(currentDir, '..', '..');
26+
}
27+
28+
function parseArgs(argv) {
29+
const args = { repoRoot: null, runDate: null };
30+
for (let i = 2; i < argv.length; i += 1) {
31+
if (argv[i] === '--output-root') args.repoRoot = argv[++i];
32+
else if (argv[i] === '--date') args.runDate = argv[++i];
33+
}
34+
return args;
35+
}
36+
37+
/**
38+
* Aggregate axe findings from axe-findings.json URLs into a sorted list
39+
* of { id, title, count, total_page_loads, affected_urls[] }.
40+
*/
41+
function buildAxePatternCounts(urls = []) {
42+
const counts = new Map();
43+
for (const entry of urls) {
44+
const pageLoads = entry.page_load_count ?? 0;
45+
const url = entry.url ?? '';
46+
for (const finding of entry.axe_findings ?? []) {
47+
const existing = counts.get(finding.id);
48+
if (existing) {
49+
existing.count += 1;
50+
existing.total_page_loads += pageLoads;
51+
if (url) existing.affected_urls.push(url);
52+
} else {
53+
counts.set(finding.id, {
54+
id: finding.id,
55+
title: finding.title ?? finding.id,
56+
count: 1,
57+
total_page_loads: pageLoads,
58+
affected_urls: url ? [url] : []
59+
});
60+
}
61+
}
62+
}
63+
return [...counts.values()].sort((a, b) => b.count - a.count);
64+
}
65+
66+
/**
67+
* Format a number with comma separators (locale-style).
68+
*/
69+
function fmt(n) {
70+
return Number(n).toLocaleString('en-US');
71+
}
72+
73+
/**
74+
* Return a human-readable date string from a YYYY-MM-DD date string.
75+
*/
76+
function humanDate(dateStr) {
77+
if (!dateStr) return '';
78+
const [year, month, day] = dateStr.split('-').map(Number);
79+
const date = new Date(Date.UTC(year, month - 1, day));
80+
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
81+
}
82+
83+
/**
84+
* Build the FPC exclusion summary section.
85+
* Returns an array of markdown lines.
86+
*
87+
* @param {object} fpcExclusion - report.fpc_exclusion object
88+
*/
89+
function buildFpcExclusionSection(fpcExclusion) {
90+
if (!fpcExclusion || !fpcExclusion.categories) {
91+
return [];
92+
}
93+
94+
const lines = [];
95+
lines.push('## Americans Being Left Out');
96+
lines.push('');
97+
lines.push(
98+
'Based on page traffic data and U.S. Census disability prevalence estimates ' +
99+
'(ACS 2022), today\'s accessibility barriers are estimated to affect the ' +
100+
'following groups of Americans:'
101+
);
102+
lines.push('');
103+
lines.push('| Disability Group | Affected Page Loads | Estimated People Affected |');
104+
lines.push('|-----------------|---------------------|--------------------------|');
105+
106+
const entries = Object.entries(fpcExclusion.categories)
107+
.filter(([, cat]) => cat.affected_page_loads > 0)
108+
.sort(([, a], [, b]) => b.estimated_excluded_users - a.estimated_excluded_users);
109+
110+
if (entries.length === 0) {
111+
lines.push('| No affected groups identified | -- | -- |');
112+
} else {
113+
for (const [, cat] of entries) {
114+
lines.push(
115+
`| ${cat.label} | ${fmt(cat.affected_page_loads)} | ~${fmt(Math.round(cat.estimated_excluded_users))} |`
116+
);
117+
}
118+
}
119+
120+
lines.push('');
121+
lines.push(
122+
`*Total page loads across all scanned URLs today: ${fmt(fpcExclusion.total_page_loads ?? 0)}*`
123+
);
124+
lines.push('');
125+
lines.push(
126+
'*Estimates use disability prevalence rates from the U.S. Census Bureau ' +
127+
'American Community Survey (ACS) 2022, supplemented by CDC, NIDCD, AFB, ' +
128+
'and NIH/NEI data. These are rough estimates intended to illustrate the scale ' +
129+
'of accessibility barriers, not precise measurements.*'
130+
);
131+
lines.push('');
132+
133+
return lines;
134+
}
135+
136+
/**
137+
* Build the top accessibility barriers section with human-impact narratives.
138+
* Returns an array of markdown lines.
139+
*
140+
* @param {Array<{ id: string, title: string, count: number, total_page_loads: number, affected_urls: string[] }>} topPatterns
141+
* Sorted array of axe pattern counts (e.g. from buildAxePatternCounts), already sliced to top N.
142+
* @returns {string[]} Array of markdown lines
143+
*/
144+
function buildTopBarriersSection(topPatterns) {
145+
if (topPatterns.length === 0) return [];
146+
147+
const lines = [];
148+
lines.push('## Top Accessibility Barriers');
149+
lines.push('');
150+
lines.push(
151+
'The following accessibility issues were most frequently found across today\'s scanned ' +
152+
'government websites. Each issue prevents specific groups of Americans from independently ' +
153+
'accessing government services.'
154+
);
155+
lines.push('');
156+
157+
let issueIndex = 1;
158+
for (const pattern of topPatterns) {
159+
const narrative = getPolicyNarrative(pattern.id);
160+
const siteCount = pattern.count;
161+
const sitePlural = siteCount === 1 ? 'website' : 'websites';
162+
const title = narrative ? narrative.title : pattern.title;
163+
164+
lines.push(`### ${issueIndex}. \`${pattern.id}\`: ${title}`);
165+
lines.push('');
166+
lines.push(`*Found on ${fmt(siteCount)} government ${sitePlural} today*`);
167+
lines.push('');
168+
169+
if (narrative && narrative.why_it_matters) {
170+
lines.push(narrative.why_it_matters.trim());
171+
lines.push('');
172+
}
173+
174+
if (narrative && Array.isArray(narrative.affected_demographics) && narrative.affected_demographics.length > 0) {
175+
lines.push('**Affected groups:**');
176+
lines.push('');
177+
for (const group of narrative.affected_demographics) {
178+
lines.push(`- ${group}`);
179+
}
180+
lines.push('');
181+
}
182+
183+
issueIndex += 1;
184+
}
185+
186+
return lines;
187+
}
188+
189+
/**
190+
* Build a full press-release Markdown document from a report and axe findings.
191+
*
192+
* @param {object} report - Parsed report.json object
193+
* @param {object} axeData - Parsed axe-findings.json object
194+
* @param {object} [options]
195+
* @param {number} [options.topN=5] - Number of top issues to include
196+
* @returns {string} Markdown press release text
197+
*/
198+
export function buildPressRelease(report, axeData, options = {}) {
199+
const topN = options.topN ?? 5;
200+
const runDate = report.run_date ?? '';
201+
const reportUrl = `${BASE_REPORT_URL}/${runDate}/index.html`;
202+
const axeJsonUrl = `${BASE_REPORT_URL}/${runDate}/axe-findings.json`;
203+
const axeCsvUrl = `${BASE_REPORT_URL}/${runDate}/axe-findings.csv`;
204+
205+
const urlCounts = report.url_counts ?? {};
206+
const succeeded = urlCounts.succeeded ?? 0;
207+
const processed = urlCounts.processed ?? 0;
208+
const scores = report.aggregate_scores ?? {};
209+
210+
const patterns = buildAxePatternCounts(axeData.urls ?? []);
211+
const topPatterns = patterns.slice(0, topN);
212+
const totalFindings = axeData.total_findings ?? 0;
213+
214+
const lines = [];
215+
216+
// Header
217+
lines.push('FOR IMMEDIATE RELEASE');
218+
lines.push('');
219+
220+
// Title
221+
lines.push(`# U.S. Government Website Accessibility Report: ${humanDate(runDate)}`);
222+
lines.push('');
223+
224+
// Lead paragraph
225+
const topIssueNames = topPatterns.slice(0, 3).map((p) => {
226+
const narrative = getPolicyNarrative(p.id);
227+
return narrative ? narrative.title : p.id;
228+
});
229+
const issueList = topIssueNames.length > 0
230+
? new Intl.ListFormat('en-US', { style: 'long', type: 'conjunction' }).format(topIssueNames)
231+
: 'accessibility barriers';
232+
233+
lines.push(
234+
`*Washington, D.C. -- ${humanDate(runDate)}* -- A daily scan of ${fmt(succeeded)} of the ` +
235+
`most-visited U.S. government websites found ${fmt(totalFindings)} accessibility ` +
236+
`barriers across ${fmt(processed)} URLs today. The most common issues include ` +
237+
`${issueList}.`
238+
);
239+
lines.push('');
240+
lines.push(
241+
'These barriers prevent Americans with disabilities from independently accessing ' +
242+
'essential government services. This is a single daily snapshot of the most popular ' +
243+
`~${fmt(processed)} pages in U.S. federal government web properties, as measured by ` +
244+
'the Digital Analytics Program (DAP).'
245+
);
246+
lines.push('');
247+
248+
// FPC exclusion section
249+
const fpcLines = buildFpcExclusionSection(report.fpc_exclusion);
250+
lines.push(...fpcLines);
251+
252+
// Top barriers section
253+
const barriersLines = buildTopBarriersSection(topPatterns);
254+
lines.push(...barriersLines);
255+
256+
// Scores section
257+
lines.push('## Accessibility Scores');
258+
lines.push('');
259+
lines.push(
260+
`Aggregate Lighthouse scores across ${fmt(succeeded)} scanned U.S. government ` +
261+
'websites today:'
262+
);
263+
lines.push('');
264+
lines.push('| Metric | Score |');
265+
lines.push('|--------|-------|');
266+
lines.push(`| Accessibility | ${scores.accessibility ?? 0} |`);
267+
lines.push(`| Performance | ${scores.performance ?? 0} |`);
268+
lines.push(`| Best Practices | ${scores.best_practices ?? 0} |`);
269+
lines.push(`| SEO | ${scores.seo ?? 0} |`);
270+
lines.push('');
271+
272+
// About section
273+
lines.push('## About This Report');
274+
lines.push('');
275+
lines.push(
276+
'This report captures a daily snapshot of the most-visited U.S. government web pages ' +
277+
'as measured by the Digital Analytics Program (DAP). Scans use Lighthouse (Google\'s ' +
278+
'automated web quality tool, which includes axe-core for accessibility testing). ' +
279+
'Reports are published automatically each day.'
280+
);
281+
lines.push('');
282+
lines.push(`- [View full interactive report](${reportUrl})`);
283+
lines.push(`- [Download accessibility findings (JSON)](${axeJsonUrl})`);
284+
lines.push(`- [Download accessibility findings (CSV)](${axeCsvUrl})`);
285+
lines.push('');
286+
287+
// Footer
288+
lines.push('---');
289+
lines.push('');
290+
lines.push(
291+
`*Generated by [Daily DAP](https://github.com/mgifford/daily-dap) | ` +
292+
`Source: Digital Analytics Program | ` +
293+
`Methodology: Lighthouse + axe-core | ` +
294+
`Date: ${runDate}*`
295+
);
296+
297+
return lines.join('\n');
298+
}
299+
300+
/**
301+
* Read data files and generate a press release for the given date.
302+
*
303+
* @param {string} repoRoot - Absolute path to the repository root
304+
* @param {string|null} runDate - YYYY-MM-DD date string, or null to use latest
305+
* @returns {Promise<{ markdown: string, outputPath: string }>}
306+
*/
307+
export async function generatePressRelease(repoRoot, runDate) {
308+
const reportsRoot = path.join(repoRoot, 'docs', 'reports');
309+
310+
let reportDate = runDate;
311+
if (!reportDate) {
312+
const historyRaw = await fs.readFile(path.join(reportsRoot, 'history.json'), 'utf8');
313+
const history = JSON.parse(historyRaw);
314+
reportDate = history.entries?.[0]?.run_date;
315+
}
316+
317+
if (!reportDate) {
318+
throw new Error('Could not determine report date. Pass --date or ensure history.json exists.');
319+
}
320+
321+
const dailyDir = path.join(reportsRoot, 'daily', reportDate);
322+
const [reportRaw, axeRaw] = await Promise.all([
323+
fs.readFile(path.join(dailyDir, 'report.json'), 'utf8'),
324+
fs.readFile(path.join(dailyDir, 'axe-findings.json'), 'utf8')
325+
]);
326+
327+
const report = JSON.parse(reportRaw);
328+
const axeData = JSON.parse(axeRaw);
329+
330+
const markdown = buildPressRelease(report, axeData);
331+
const outputPath = path.join(dailyDir, 'press-release.md');
332+
333+
return { markdown, outputPath };
334+
}
335+
336+
async function main() {
337+
const args = parseArgs(process.argv);
338+
const repoRoot = path.resolve(args.repoRoot ?? getDefaultRepoRoot());
339+
const { markdown, outputPath } = await generatePressRelease(repoRoot, args.runDate ?? null);
340+
341+
await fs.writeFile(outputPath, `${markdown}\n`, 'utf8');
342+
console.log(`Press release written to ${outputPath}`);
343+
344+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
345+
if (summaryPath) {
346+
await fs.appendFile(summaryPath, `\n${markdown}\n`, 'utf8');
347+
console.log('Press release appended to GITHUB_STEP_SUMMARY');
348+
} else {
349+
console.log('');
350+
console.log(markdown);
351+
}
352+
}
353+
354+
const isDirectExecution = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
355+
if (isDirectExecution) {
356+
main().catch((error) => {
357+
console.error(error.message);
358+
process.exit(1);
359+
});
360+
}

0 commit comments

Comments
 (0)