commit 3c329a5fe61908f02e8d698bf060413637ea188f Author: Mark Eaton Date: Wed Sep 17 13:49:54 2025 -0400 feat: add script to audit guides.csv URLs for WCAG 2.1 AA issues with pa11y and output filtered report Co-authored-by: aider (o3) diff --git a/check_wcag.py b/check_wcag.py new file mode 100644 index 0000000..047c181 --- /dev/null +++ b/check_wcag.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Check each guide URL in guides.csv for WCAG 2.1 AA accessibility +issues using the pa11y CLI and report results. + +The script: +1. Reads guides.csv in the current directory. +2. Runs `pa11y` for each URL with the WCAG2AA standard, requesting JSON. +3. Collects the issues for every guide. +4. Removes any issue codes that appear in *every* guide. +5. Prints the filtered results to the console and writes them to + output.txt. + +Prerequisites: + npm install -g pa11y + +Usage: + python check_wcag.py +""" +from __future__ import annotations + +import csv +import json +import subprocess +import sys +from pathlib import Path +from typing import Dict, List, Set + +GUIDES_CSV = Path(__file__).with_name("guides.csv") +OUTPUT_FILE = Path("output.txt") + + +def run_pa11y(url: str) -> List[dict]: + """ + Run pa11y on a URL and return the parsed JSON list of issues. + + Returns an empty list if pa11y fails. + """ + try: + result = subprocess.run( + ["pa11y", url, "--standard", "WCAG2AA", "--reporter", "json"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print(f"[WARN] pa11y returned code {result.returncode} for {url}", file=sys.stderr) + return [] + return json.loads(result.stdout) + except FileNotFoundError: + print("Error: pa11y not found. Install it with `npm install -g pa11y`.", file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError: + print(f"[WARN] Could not parse pa11y JSON output for {url}", file=sys.stderr) + return [] + + +def read_guides(csv_path: Path) -> List[Dict[str, str]]: + """Load all guides from the CSV into a list of dicts.""" + with csv_path.open(newline="", encoding="utf-8-sig") as fh: + reader = csv.DictReader(fh) + return list(reader) + + +def compute_common_codes(issues_by_guide: Dict[str, List[dict]]) -> Set[str]: + """Return the set of issue codes that appear in every guide.""" + all_codes_iter = ( + {issue["code"] for issue in issues} for issues in issues_by_guide.values() + ) + common_codes: Set[str] | None = None + for codes in all_codes_iter: + if common_codes is None: + common_codes = codes + else: + common_codes &= codes + return common_codes or set() + + +def format_issue(issue: dict) -> str: + """Return a human-readable one-line representation of a pa11y issue.""" + selector = issue.get("selector", "").strip() or "N/A" + return f"[{issue['type']}] {issue['code']}: {issue['message']} (selector: {selector})" + + +def main() -> None: + guides = read_guides(GUIDES_CSV) + issues_by_guide: Dict[str, List[dict]] = {} + + print("Auditing guides with pa11y…\n") + for guide in guides: + name = guide["Name"] + url = guide["URL"] + print(f"• {name} — {url}") + issues = run_pa11y(url) + issues_by_guide[name] = issues + + common_codes = compute_common_codes(issues_by_guide) + + lines_out: List[str] = [] + lines_out.append("\nAccessibility report (WCAG 2.1 AA)") + lines_out.append("=" * 40) + + for name, issues in issues_by_guide.items(): + filtered = [i for i in issues if i["code"] not in common_codes] + lines_out.append(f"\nGuide: {name}") + if not filtered: + lines_out.append(" ✔ No unique issues found.") + continue + for issue in filtered: + lines_out.append(f" • {format_issue(issue)}") + + report_text = "\n".join(lines_out) + print("\n" + report_text) + OUTPUT_FILE.write_text(report_text, encoding="utf-8") + print(f"\nReport written to {OUTPUT_FILE.resolve()}") + + +if __name__ == "__main__": + main()