From 431ac94f79df9f0cbd4aa43801db19b3ee2e62c0 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:13:50 +0000 Subject: [PATCH 1/4] Add report and HTML web-UI output formats --- breach_scraper/output.py | 945 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 928 insertions(+), 17 deletions(-) diff --git a/breach_scraper/output.py b/breach_scraper/output.py index 24e3a6c..5de4160 100644 --- a/breach_scraper/output.py +++ b/breach_scraper/output.py @@ -1,16 +1,22 @@ -"""Output formatting for breach records (json, csv, markdown).""" +"""Shared output helpers for breach scraper connectors.""" from __future__ import annotations import csv +import html import json from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime from io import StringIO from pathlib import Path from typing import Any +from urllib.parse import urlparse +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError Row = Mapping[str, Any] + PREFERRED_DISPLAY_COLUMNS = ( "source", "date_reported", @@ -30,6 +36,13 @@ ) +def _generated_timestamp() -> str: + try: + return datetime.now(ZoneInfo("America/Los_Angeles")).strftime("%Y-%m-%d %I:%M %p %Z") + except ZoneInfoNotFoundError: + return datetime.now().astimezone().strftime("%Y-%m-%d %I:%M %p %Z") + + def _humanize_column(name: str) -> str: return name.replace("_", " ").title() @@ -47,6 +60,43 @@ def _markdown_link_target(url: Any) -> str: return str(url or "").replace("\\", "%5C").replace(")", "%29").strip() +def _parse_date_value(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + + normalized = text.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + pass + + for fmt in ( + "%Y-%m-%d", + "%Y/%m/%d", + "%m/%d/%Y", + "%m/%d/%y", + "%B %d, %Y", + "%b %d, %Y", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%m/%d/%Y %H:%M:%S", + "%m/%d/%Y %H:%M", + ): + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + +def _date_sort_key(value: Any) -> str: + parsed = _parse_date_value(value) + if parsed is not None: + return parsed.strftime("%Y-%m-%dT%H:%M:%S") + return str(value or "").strip().casefold() + + def _display_columns(rows: list[Row]) -> list[str]: seen: list[str] = [] for row in rows: @@ -57,6 +107,7 @@ def _display_columns(rows: list[Row]) -> list[str]: continue if key not in seen: seen.append(key) + ordered = [column for column in PREFERRED_DISPLAY_COLUMNS if column in seen] ordered.extend(column for column in seen if column not in ordered) return ordered @@ -80,10 +131,859 @@ def _public_records(records: Iterable[Row]) -> list[dict[str, Any]]: return [_public_record(row) for row in records] +def _affected_count_numeric(row: Row) -> int | None: + count_value = _html_affected_count(row).replace(",", "").strip() + return int(count_value) if count_value.isdigit() else None + + +@dataclass(frozen=True) +class ReportStats: + total_rows: int + latest_report: str + latest_report_sort_key: str + known_count_rows: int + known_affected: int + missing_breach_date: int + + +def _report_stats(rows: Sequence[Row]) -> ReportStats: + latest_report_display = "" + latest_report_sort_key = "" + known_affected = 0 + known_count_rows = 0 + missing_breach_date = 0 + + for row in rows: + numeric_count = _affected_count_numeric(row) + if numeric_count is not None: + known_affected += numeric_count + known_count_rows += 1 + if not str(row.get("date_of_breach", "") or "").strip(): + missing_breach_date += 1 + + reported_display = _html_reported_date(row) + reported_sort_key = _date_sort_key(reported_display) + if reported_display and ( + not latest_report_sort_key or reported_sort_key > latest_report_sort_key + ): + latest_report_display = reported_display + latest_report_sort_key = reported_sort_key + + return ReportStats( + total_rows=len(rows), + latest_report=latest_report_display or "Unknown", + latest_report_sort_key=latest_report_sort_key, + known_count_rows=known_count_rows, + known_affected=known_affected, + missing_breach_date=missing_breach_date, + ) + + +def _report_summary(rows: Sequence[Row]) -> list[str]: + stats = _report_stats(rows) + return [ + f"- Total breach notices reviewed: {stats.total_rows}", + f"- Latest reported date: {stats.latest_report}", + f"- Records with known affected counts: {stats.known_count_rows}", + f"- Total persons affected across known counts: {stats.known_affected:,}", + f"- Records missing breach date: {stats.missing_breach_date}", + ] + + +def _report_summary_items(rows: Sequence[Row]) -> list[tuple[str, str]]: + stats = _report_stats(rows) + return [ + ("Total breach notices reviewed", str(stats.total_rows)), + ("Latest reported date", stats.latest_report), + ("Records with known affected counts", str(stats.known_count_rows)), + ("Total persons affected across known counts", f"{stats.known_affected:,}"), + ("Records missing breach date", str(stats.missing_breach_date)), + ] + + +def _html_source_metadata( + row: Row, + default_source_key: str | None, + default_source_name: str | None, +) -> tuple[str, str]: + source_key = str( + row.get("_source_key", "") or row.get("source_key", "") or default_source_key or "unknown" + ) + source_name = str( + row.get("_source_name", "") or row.get("source", "") or default_source_name or source_key + ) + return source_key, source_name + + +def _html_notice_link(row: Row) -> str: + notice_url = str(row.get("notice_url", "") or row.get("organization_name_url", "") or "") + if not notice_url: + return "" + return ( + f'' + "View notice" + "" + ) + + +def _html_source_link(row: Row, source_name: str) -> str: + source_url = str(row.get("_source_url", "") or "") + safe_name = html.escape(source_name) + if not source_url: + return safe_name + return ( + f'' + f"{safe_name}" + "" + ) + + +def _html_organization_link(row: Row) -> str: + organization_name = str(row.get("organization_name", "") or "Unknown organization") + organization_url = str(row.get("organization_name_url", "") or "") + safe_name = html.escape(organization_name) + if not organization_url: + return safe_name + return ( + f'' + f"{safe_name}" + "" + ) + + +def _html_information_compromised(row: Row) -> str: + detail_parts = [ + str(row.get("information_compromised", "") or ""), + str(row.get("type_of_breach", "") or ""), + str(row.get("location_of_breached_information", "") or ""), + str(row.get("breach_description", "") or ""), + ] + compromised = " | ".join(part for part in detail_parts if part) or "Not listed" + parts = [part.strip() for part in compromised.split(";") if part.strip()] + if len(parts) <= 1: + return html.escape(compromised) + return "
".join(html.escape(part) for part in parts) + + +def _html_reported_date(row: Row) -> str: + return str( + row.get("date_reported", "") + or row.get("date_of_consumer_notification", "") + or row.get("date_breach_discovered", "") + or "" + ) + + +def _html_affected_count(row: Row) -> str: + return str( + row.get("persons_affected", "") + or row.get("total_persons_affected", "") + or row.get("number_affected", "") + or row.get("individuals_affected", "") + or "" + ) + + +def to_html( + records: Iterable[Row], + title: str = "Breach Monitoring Report", + source_url: str | None = None, + *, + default_source_key: str | None = None, + default_source_name: str | None = None, +) -> str: + rows = list(records) + safe_title = html.escape(title) + + if not rows: + return f""" + + + + + {safe_title} + + +
+

{safe_title}

+

No records found.

+
+ + +""" + + source_buttons: list[str] = [ + '' + ] + seen_sources: list[tuple[str, str]] = [] + for row in rows: + source_meta = _html_source_metadata(row, default_source_key, default_source_name) + if source_meta not in seen_sources: + seen_sources.append(source_meta) + + source_buttons.extend( + ( + f'" + ) + for source_key, source_name in seen_sources + ) + + source_markup = "" + if source_url and len(seen_sources) == 1: + host = urlparse(source_url).netloc or source_url + source_markup = ( + '

Source page: ' + f'{html.escape(host)}' + "

" + ) + + summary_items = _report_summary_items(rows) + summary_text = " | ".join(f"{label}: {value}" for label, value in summary_items) + generated_at = _generated_timestamp() + + table_rows = "\n".join( + ( + f"" + f'{_html_source_link(row, source_name)}' + f"{html.escape(reported_display or 'Unknown')}" + f'{_html_organization_link(row)}' + f"{html.escape(breach_display or 'Unknown')}" + f"{html.escape(affected_display or 'Unknown')}" + f"{_html_information_compromised(row)}" + f'{_html_notice_link(row) or "Unavailable"}' + "" + ) + for row in rows + for source_key, source_name in [ + _html_source_metadata(row, default_source_key, default_source_name) + ] + for reported_display in [_html_reported_date(row)] + for breach_display in [str(row.get("date_of_breach", "") or "")] + for affected_display in [_html_affected_count(row)] + for affected_numeric in [_affected_count_numeric(row)] + ) + + return f""" + + + + + {safe_title} + + + +
+
+

Breach Monitoring

+

{safe_title}

+ {source_markup} +

Last updated: {html.escape(generated_at)}

+
+ +
+
+ {" ".join(source_buttons)} +
+

{html.escape(summary_text)}

+
+ +
+
+ + + + + + + + + + + + + + {table_rows} + +
+
+
No records match the selected source filter.
+
+

Page 1 of 1

+
+ + + + + + +
+
+
+
+ + + +""" + + +def to_report( + records: Iterable[Row], title: str = "Breach Monitoring Report", source_url: str | None = None +) -> str: + rows = list(records) + if not rows: + return f"# {title}\n\nNo records found." + + lines = [f"# {title}", ""] + if source_url: + host = urlparse(source_url).netloc or source_url + lines.append(f"Source: [{host}]({source_url})") + lines.append("") + + lines.append("## Summary") + lines.extend(_report_summary(rows)) + lines.append("") + lines.append("## Breach Notices") + lines.append("") + + for idx, row in enumerate(rows, start=1): + name = str(row.get("organization_name", "") or "Unknown organization") + lines.append(f"### {idx}. {name}") + lines.append(f"- Date reported: {row.get('date_reported', '') or 'Unknown'}") + lines.append(f"- Date of breach: {row.get('date_of_breach', '') or 'Unknown'}") + lines.append( + "- Persons affected: " + f"{row.get('persons_affected', '') or row.get('total_persons_affected', '') or row.get('number_affected', '') or row.get('individuals_affected', '') or 'Unknown'}" + ) + lines.append( + f"- Information compromised: {row.get('information_compromised', '') or 'Not listed'}" + ) + if row.get("organization_name_url"): + lines.append(f"- Notice letter: {row['organization_name_url']}") + elif row.get("notice_url"): + lines.append(f"- Notice letter: {row['notice_url']}") + lines.append("") + + return "\n".join(lines).strip() + + def to_markdown(records: Iterable[Row]) -> str: rows = list(records) if not rows: return "No records found." + columns = _display_columns(rows) header = "| " + " | ".join(_humanize_column(column) for column in columns) + " |" divider = "| " + " | ".join(["---"] * len(columns)) + " |" @@ -93,27 +993,15 @@ def to_markdown(records: Iterable[Row]) -> str: return "\n".join([header, divider, *body]) -def _to_csv(records: list[dict[str, Any]]) -> str: - if not records: - return "" - columns: list[str] = [] - for row in records: - for key in row: - if key not in columns: - columns.append(key) - buffer = StringIO() - writer = csv.DictWriter(buffer, fieldnames=columns, lineterminator="\n") - writer.writeheader() - writer.writerows(records) - return buffer.getvalue() - - def write_output( records: Sequence[Row], output_format: str, out_file: str | None, *, title: str = "Breach Monitoring Report", + source_url: str | None = None, + source_key: str | None = None, + source_name: str | None = None, ) -> None: public_records = _public_records(records) @@ -121,8 +1009,31 @@ def write_output( payload = json.dumps(public_records, indent=2) elif output_format == "markdown": payload = to_markdown(public_records) + elif output_format == "html": + payload = to_html( + records, + title=title, + source_url=source_url, + default_source_key=source_key, + default_source_name=source_name, + ) + elif output_format == "report": + payload = to_report(public_records, title=title, source_url=source_url) elif output_format == "csv": - payload = _to_csv(public_records) + if not public_records: + payload = "" + else: + columns: list[str] = [] + for row in public_records: + for key in row: + if key not in columns: + columns.append(key) + + buffer = StringIO() + writer = csv.DictWriter(buffer, fieldnames=columns, lineterminator="\n") + writer.writeheader() + writer.writerows(public_records) + payload = buffer.getvalue() else: raise ValueError(f"Unsupported output format: {output_format}") From 4c0995eedda9d5c58beb1dc9a17a5755b7a0ed48 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:13:50 +0000 Subject: [PATCH 2/4] Wire report and html into the CLI --- breach_scraper/cli.py | 4 +++- pyproject.toml | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/breach_scraper/cli.py b/breach_scraper/cli.py index 2d882db..c17b443 100644 --- a/breach_scraper/cli.py +++ b/breach_scraper/cli.py @@ -31,7 +31,9 @@ def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Scrape and combine breach notification data from supported sources." ) - parser.add_argument("--output", choices=["json", "csv", "markdown"], default="json") + parser.add_argument( + "--output", choices=["json", "csv", "markdown", "report", "html"], default="json" + ) parser.add_argument("--out-file", help="Optional output file path.") parser.add_argument( "--start-date", diff --git a/pyproject.toml b/pyproject.toml index 5799e31..fefcff1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ select = ["E", "F", "I", "UP", "B", "SIM", "C4"] [tool.ruff.lint.per-file-ignores] # Test fixtures embed long inline HTML/CSV sample strings. "tests/**" = ["E501"] +# output.py embeds an HTML/CSS/JS web-UI template with unavoidably long lines. +"breach_scraper/output.py" = ["E501"] [tool.mypy] python_version = "3.10" From 0dc97162de2b4cd86377617f1b28a3474c387bfd Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:13:50 +0000 Subject: [PATCH 3/4] Add report/html CLI tests and refresh README for multi-source --- README.md | 74 +++++++++++++++++++++++++++++++++-------------- tests/test_cli.py | 18 ++++++++++++ 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 8cb1d84..89bc239 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,20 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](pyproject.toml) -A Python tool for scraping breach websites to provide a nice summary. The first -supported source is the Washington State Attorney General data breach -notifications page. Runtime dependencies: none (standard library only). +A Python tool for scraping breach-notification websites and combining them into a +single summary. Runtime dependencies: none (standard library only). + +## Sources + +| Key | Source | +| --- | --- | +| `wa_atg` | Washington Attorney General | +| `ca_oag` | California Attorney General | +| `or_doj` | Oregon Department of Justice | +| `maine_ag` | Maine Attorney General | +| `hhs_ocr` | HHS OCR Breach Portal (federal) | + +List them anytime with `breach-scraper --list-sources`. ## Install @@ -19,28 +30,44 @@ This installs the `breach-scraper` console command. ## Usage ```bash -# Fetch live and print JSON (default), limited to 10 rows -breach-scraper --output json --limit 10 +# Combine all sources for the last 6 months (default), as JSON +breach-scraper -# Markdown / CSV to a file -breach-scraper --output markdown --out-file wa_breaches.md -breach-scraper --output csv --out-file wa_breaches.csv +# A single source, an explicit date range, Markdown to a file +breach-scraper --source wa_atg --start-date 2025-01-01 --end-date 2025-06-30 \ + --output markdown --out-file breaches.md -# Offline: parse a previously saved page (no network) -breach-scraper --input-html saved_page.html --output json +# A readable text summary, or a self-contained sortable HTML dashboard +breach-scraper --source ca_oag --output report +breach-scraper --output html --out-file breaches.html -# Override the User-Agent or retry count -breach-scraper --user-agent "my-agent/1.0" --retries 5 +# Offline: parse a previously saved page for one source (no network) +breach-scraper --source wa_atg --input-html saved_page.html + +# Override the User-Agent / retry count; fail hard if any source errors +breach-scraper --user-agent "my-agent/1.0" --retries 5 --strict --verbose ``` -If the source returns HTTP 403, the tool prints an actionable message; use -`--input-html` with a saved copy of the page, a different network, or a -different `--user-agent`. +### Options + +- `--source KEY` (repeatable): limit to specific sources. Default: all. +- `--list-sources`: print supported source keys and exit. +- `--start-date` / `--end-date` (`YYYY-MM-DD`): inclusive date window. Default: last 6 months. +- `--output {json,csv,markdown,report,html}`: output format. Default: `json`. +- `--out-file PATH`: write output to a file instead of stdout. +- `--input-html PATH`: parse a saved page offline (requires exactly one `--source`). +- `--user-agent STR`, `--retries N`: request tuning. +- `--include-undated`: keep records with no parseable date. +- `--strict`: exit non-zero if any source fails. `--verbose`: per-source progress on stderr. + +If a source returns HTTP 403, the tool prints an actionable message; use +`--input-html` with a saved copy, a different network, or a different `--user-agent`. -## Output fields +## Output formats -Column names from the HTML table are normalized to `snake_case`. Cells that -contain links also emit a `_url` field. +- `json` / `csv` / `markdown`: the combined records (column names normalized to `snake_case`). +- `report`: a text summary with totals plus per-notice detail. +- `html`: a self-contained, sortable web dashboard (no external assets). ## Development @@ -53,8 +80,11 @@ bandit -r breach_scraper python -m unittest discover -s tests -v ``` -## Source / maintenance notes +## Notes -- Source URL: `https://www.atg.wa.gov/data-breach-notifications` -- The scraper depends on the page exposing a parseable HTML table; if the WA AG - changes the table structure or field names, parsing may need updates. +- Standard library only at runtime; sources fetch through a shared HTTP helper with + retries, exponential backoff, and helpful HTTP 403 handling. +- Each source exposes `DEFAULT_URL`, `fetch_html`, and `parse_breach_table`, registered in + `breach_scraper/registry.py`. Adding a source is a small, self-contained module. +- Scraping depends on the upstream pages' structure; if a site changes, that source's + parser may need updates. diff --git a/tests/test_cli.py b/tests/test_cli.py index e289ddc..45cf920 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -72,6 +72,24 @@ def test_offline_csv_has_unix_line_endings(self) -> None: self.assertNotIn("\r", out) self.assertIn("date_reported,organization_name", out) + def test_report_output(self) -> None: + rc, out, _ = run( + ["--input-html", str(FIXTURE), "--source", "wa_atg", "--output", "report", *RANGE] + ) + self.assertEqual(rc, 0) + self.assertIn("## Summary", out) + self.assertIn("Total breach notices reviewed: 2", out) + + def test_html_output_is_ascii_and_sortable(self) -> None: + rc, out, _ = run( + ["--input-html", str(FIXTURE), "--source", "wa_atg", "--output", "html", *RANGE] + ) + self.assertEqual(rc, 0) + self.assertIn(" Date: Thu, 25 Jun 2026 01:24:54 +0000 Subject: [PATCH 4/4] Address review: sanitize HTML link URLs (XSS), fix affected-count zero, DRY report --- breach_scraper/output.py | 42 ++++++++++++++++++++++++++-------------- tests/test_output.py | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 tests/test_output.py diff --git a/breach_scraper/output.py b/breach_scraper/output.py index 5de4160..9c7ad62 100644 --- a/breach_scraper/output.py +++ b/breach_scraper/output.py @@ -215,8 +215,20 @@ def _html_source_metadata( return source_key, source_name +def _is_safe_url(url: str) -> bool: + try: + return urlparse(url).scheme in ("http", "https") + except ValueError: + return False + + +def _safe_notice_url(row: Row) -> str: + url = str(row.get("notice_url", "") or row.get("organization_name_url", "") or "").strip() + return url if _is_safe_url(url) else "" + + def _html_notice_link(row: Row) -> str: - notice_url = str(row.get("notice_url", "") or row.get("organization_name_url", "") or "") + notice_url = _safe_notice_url(row) if not notice_url: return "" return ( @@ -240,9 +252,9 @@ def _html_source_link(row: Row, source_name: str) -> str: def _html_organization_link(row: Row) -> str: organization_name = str(row.get("organization_name", "") or "Unknown organization") - organization_url = str(row.get("organization_name_url", "") or "") + organization_url = str(row.get("organization_name_url", "") or "").strip() safe_name = html.escape(organization_name) - if not organization_url: + if not _is_safe_url(organization_url): return safe_name return ( f'' @@ -275,13 +287,16 @@ def _html_reported_date(row: Row) -> str: def _html_affected_count(row: Row) -> str: - return str( - row.get("persons_affected", "") - or row.get("total_persons_affected", "") - or row.get("number_affected", "") - or row.get("individuals_affected", "") - or "" - ) + for key in ( + "persons_affected", + "total_persons_affected", + "number_affected", + "individuals_affected", + ): + value = row.get(key) + if value is not None and value != "": + return str(value) + return "" def to_html( @@ -356,7 +371,7 @@ def to_html( f'data-sort-date_of_breach="{html.escape(_date_sort_key(breach_display), quote=True)}" ' f'data-sort-affected="{affected_numeric if affected_numeric is not None else 0}" ' f'data-sort-information="{html.escape(str(row.get("information_compromised", "") or "").casefold(), quote=True)}" ' - f'data-sort-notice="{html.escape(str(row.get("notice_url", "") or row.get("organization_name_url", "") or ""), quote=True)}"' + f'data-sort-notice="{html.escape(_safe_notice_url(row), quote=True)}"' f">" f'{_html_source_link(row, source_name)}' f"{html.escape(reported_display or 'Unknown')}" @@ -963,10 +978,7 @@ def to_report( lines.append(f"### {idx}. {name}") lines.append(f"- Date reported: {row.get('date_reported', '') or 'Unknown'}") lines.append(f"- Date of breach: {row.get('date_of_breach', '') or 'Unknown'}") - lines.append( - "- Persons affected: " - f"{row.get('persons_affected', '') or row.get('total_persons_affected', '') or row.get('number_affected', '') or row.get('individuals_affected', '') or 'Unknown'}" - ) + lines.append(f"- Persons affected: {_html_affected_count(row) or 'Unknown'}") lines.append( f"- Information compromised: {row.get('information_compromised', '') or 'Not listed'}" ) diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..7fc83c5 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import unittest + +from breach_scraper.output import ( + _html_affected_count, + _html_notice_link, + _html_organization_link, +) + + +class TestHtmlSafety(unittest.TestCase): + def test_notice_link_rejects_javascript_scheme(self) -> None: + self.assertEqual(_html_notice_link({"notice_url": "javascript:alert(1)"}), "") + + def test_notice_link_allows_https(self) -> None: + link = _html_notice_link({"notice_url": "https://example.gov/notice.pdf"}) + self.assertIn('href="https://example.gov/notice.pdf"', link) + + def test_organization_link_rejects_javascript_scheme(self) -> None: + out = _html_organization_link( + {"organization_name": "Acme", "organization_name_url": "javascript:alert(1)"} + ) + self.assertEqual(out, "Acme") + self.assertNotIn("href", out) + + +class TestAffectedCount(unittest.TestCase): + def test_zero_is_not_skipped(self) -> None: + self.assertEqual(_html_affected_count({"persons_affected": 0}), "0") + + def test_empty_falls_through(self) -> None: + self.assertEqual( + _html_affected_count({"persons_affected": "", "number_affected": "5"}), "5" + ) + + +if __name__ == "__main__": + unittest.main()