Skip to content

Multi-source foundation: registry, shared fetch, and CLI#4

Merged
noderaven merged 6 commits into
mainfrom
feature/multi-source-foundation
Jun 25, 2026
Merged

Multi-source foundation: registry, shared fetch, and CLI#4
noderaven merged 6 commits into
mainfrom
feature/multi-source-foundation

Conversation

@noderaven

Copy link
Copy Markdown
Owner

First stage of adopting the richer multi-source design (stdlib-only throughout).

Summary

  • Add a registry of sources (SourceDefinition) and refactor Washington into breach_scraper/sources/wa_atg.py with richer normalization (dates, affected counts, field ordering).
  • Add a shared breach_scraper/http.py fetch helper merging round-1 robustness (retries + exponential backoff capped at 30s, charset fallback, actionable HTTP 403) with candidate-URL fallback and optional POST support for later sources.
  • Replace the single-source CLI with a multi-source breach_scraper/cli.py: --source/--list-sources, --start-date/--end-date date filtering (default last 6 months), --strict, --verbose, plus merged round-1 options --input-html (offline), --user-agent, --retries.
  • output.py provides json/csv/markdown (the report and html web UI land in the next stage).
  • Point the breach-scraper console script at breach_scraper.cli:main; remove the old single-source module.

Test Plan

  • CI green on 3.10-3.13: ruff, ruff format, mypy strict, bandit, unittest
  • breach-scraper --list-sources shows wa_atg
  • breach-scraper --input-html tests/fixtures/wa_atg_sample.html --start-date 2024-01-01 --end-date 2024-12-31 prints the 2 sample rows with source annotation
  • 16 unit tests pass locally

Part of a staged adoption: (1) foundation [this PR], (2) more sources, (3) summary + web UI.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the single-file Washington Attorney General breach scraper into a modular package structure, introducing a shared CLI, HTTP fetcher with backoff, multiple output formatters (JSON, CSV, Markdown), and a source registry, alongside comprehensive unit tests. The review feedback highlights opportunities to improve robustness and code quality, including handling non-numeric ranges in count normalization, validating candidate URLs in the HTTP fetcher, avoiding double carriage returns in CSV generation on Windows, and removing redundant strict-mode error checks in the CLI orchestration.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread breach_scraper/sources/wa_atg.py Outdated
Comment on lines +47 to +54
def _normalize_count(value: str) -> str:
cleaned = _clean_text(value)
if not cleaned:
return ""
digits = re.sub(r"[^\d]", "", cleaned)
if not digits:
return cleaned
return f"{int(digits):,}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of _normalize_count uses re.sub(r"[^\d]", "", cleaned) which strips all non-digits and concatenates them. If the input is a range like "1,200 - 1,500", it will be incorrectly normalized to "12,001,500". A safer approach is to check if the string (with commas removed) is purely numeric before formatting it, and otherwise return it as-is.

Suggested change
def _normalize_count(value: str) -> str:
cleaned = _clean_text(value)
if not cleaned:
return ""
digits = re.sub(r"[^\d]", "", cleaned)
if not digits:
return cleaned
return f"{int(digits):,}"
def _normalize_count(value: str) -> str:
cleaned = _clean_text(value)
if not cleaned:
return ""
no_commas = cleaned.replace(",", "")
if no_commas.isdigit():
return f"{int(no_commas):,}"
return cleaned

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14aeba0. _normalize_count now formats only when the comma-stripped value is all digits, otherwise it returns the original string. Covered by test_normalize_count_keeps_ranges_intact.

Comment thread breach_scraper/http.py
Comment on lines +27 to +44
def fetch_url(
candidates: list[str],
*,
timeout: int = 30,
retries: int = 3,
backoff: float = 0.5,
user_agent: str | None = None,
headers: dict[str, str] | None = None,
data: bytes | None = None,
) -> str:
"""Fetch the first candidate URL that succeeds.

Each candidate is tried in order; transient failures (timeouts, connection
errors, HTTP 5xx, protocol errors) are retried with exponential backoff
(capped at 30s). HTTP 403 raises an actionable error; other 4xx are not
retried and fall through to the next candidate.
"""
request_headers = {"User-Agent": user_agent or DEFAULT_USER_AGENT, **DEFAULT_HEADERS}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fetch_url function does not validate if the candidates list is empty. If an empty list is passed, it will raise a generic RuntimeError: Failed to fetch source page: None which is not very descriptive. Adding an explicit check at the beginning of the function improves robustness.

def fetch_url(
    candidates: list[str],
    *,
    timeout: int = 30,
    retries: int = 3,
    backoff: float = 0.5,
    user_agent: str | None = None,
    headers: dict[str, str] | None = None,
    data: bytes | None = None,
) -> str:
    """Fetch the first candidate URL that succeeds.

    Each candidate is tried in order; transient failures (timeouts, connection
    errors, HTTP 5xx, protocol errors) are retried with exponential backoff
    (capped at 30s). HTTP 403 raises an actionable error; other 4xx are not
    retried and fall through to the next candidate.
    """
    if not candidates:
        raise ValueError("At least one candidate URL must be provided.")

    request_headers = {"User-Agent": user_agent or DEFAULT_USER_AGENT, **DEFAULT_HEADERS}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14aeba0. fetch_url now raises ValueError on an empty candidates list. Covered by test_empty_candidates_raises_value_error.

Comment thread breach_scraper/output.py
Comment on lines +96 to +108
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)
writer.writeheader()
writer.writerows(records)
return buffer.getvalue()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

By default, csv.DictWriter uses \r\n as the line terminator. When the resulting string is written to a file using Path.write_text on Windows, Python's default text-mode translation will convert \n to \r\n, resulting in \r\r\n (double carriage returns). Specifying lineterminator="\n" ensures consistent \n line endings in the generated string, which plays nicely with standard text-mode file writing and printing.

Suggested change
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)
writer.writeheader()
writer.writerows(records)
return buffer.getvalue()
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()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14aeba0. DictWriter now uses lineterminator='\n'. Covered by test_offline_csv_has_unix_line_endings.

Comment thread breach_scraper/cli.py Outdated
Comment on lines +256 to +264
except Exception as exc:
message = f"source {source.key} failed: {exc}"
failures.append(message)
LOGGER.error(message)
if strict:
raise SourceRunError(message) from exc

if failures and not combined and strict:
raise SourceRunError("; ".join(failures))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In scrape_all_sources, the check if failures and not combined and strict: at the end of the function is redundant and unreachable when strict is True. This is because any exception raised inside the loop already immediately raises SourceRunError(message) from exc when strict is True, terminating the function execution. Removing this dead code simplifies the function.

Suggested change
except Exception as exc:
message = f"source {source.key} failed: {exc}"
failures.append(message)
LOGGER.error(message)
if strict:
raise SourceRunError(message) from exc
if failures and not combined and strict:
raise SourceRunError("; ".join(failures))
except Exception as exc:
message = f"source {source.key} failed: {exc}"
failures.append(message)
LOGGER.error(message)
if strict:
raise SourceRunError(message) from exc

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14aeba0. Removed the unreachable end-of-function strict check and the now-unused failures list; strict mode still raises on the first source failure inside the loop.

@noderaven
noderaven merged commit 0403dd0 into main Jun 25, 2026
4 checks passed
@noderaven
noderaven deleted the feature/multi-source-foundation branch June 25, 2026 00:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant