Skip to content

Add four breach sources: California, Oregon, Maine, HHS OCR#5

Merged
noderaven merged 4 commits into
mainfrom
feature/add-sources
Jun 25, 2026
Merged

Add four breach sources: California, Oregon, Maine, HHS OCR#5
noderaven merged 4 commits into
mainfrom
feature/add-sources

Conversation

@noderaven

Copy link
Copy Markdown
Owner

Stage 2 of the multi-source integration. Adds four sources behind the registry from stage 1 (stdlib-only).

Summary

  • New source modules: ca_oag (California OAG), or_doj (Oregon DOJ), maine_ag (Maine AG), hhs_ocr (HHS OCR breach portal), each exposing DEFAULT_URL / fetch_html / parse_breach_table with source-specific normalization.
  • GET sources (CA, OR, ME) route through the shared http.fetch_url (retries, backoff, 403 handling); HHS keeps its cookie-jar two-step CSV export (it needs session cookies).
  • Registered all four in registry.py; breach-scraper --list-sources now shows five sources and a run aggregates across them with date filtering.
  • Ported each source's unit tests (parsing only, inline sample data); added a ruff per-file E501 ignore for the long inline HTML/CSV fixtures in tests.

Test Plan

  • CI green on 3.10-3.13: ruff, ruff format, mypy strict, bandit, unittest (32 tests)
  • breach-scraper --list-sources lists wa_atg, ca_oag, or_doj, maine_ag, hhs_ocr

Next stage: report summary + the HTML 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 registers and implements four new breach scraper sources: California Attorney General (ca_oag), Oregon Department of Justice (or_doj), Maine Attorney General (maine_ag), and HHS OCR Breach Portal (hhs_ocr), along with corresponding unit tests and CLI updates. The code review identifies several critical improvements to make the scrapers more robust: wrapping date parsing in try-except blocks to prevent crashes on malformed dates in the Oregon and HHS scrapers, propagating nested href links in the Maine parser, using a more reliable CSV detection method and ensuring custom User-Agents are passed during POST requests in the HHS scraper, and robustly splitting date ranges in the Oregon scraper using regular expressions.

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/or_doj.py Outdated
Comment on lines +47 to +51
def _normalize_single_date(value: str) -> str:
cleaned = _clean_text(value)
if not cleaned or cleaned in SENTINEL_DATES:
return ""
return datetime.strptime(cleaned, "%m/%d/%Y").date().isoformat()

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

Parsing dates with datetime.strptime without a try-except block is extremely fragile. Any malformed date or unexpected format on the Oregon DOJ website will raise a ValueError and crash the entire scraper. Wrap the parsing in a try-except block and return the original string as a fallback.

Suggested change
def _normalize_single_date(value: str) -> str:
cleaned = _clean_text(value)
if not cleaned or cleaned in SENTINEL_DATES:
return ""
return datetime.strptime(cleaned, "%m/%d/%Y").date().isoformat()
def _normalize_single_date(value: str) -> str:
cleaned = _clean_text(value)
if not cleaned or cleaned in SENTINEL_DATES:
return ""
try:
return datetime.strptime(cleaned, "%m/%d/%Y").date().isoformat()
except ValueError:
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. _normalize_single_date now wraps strptime in try/except and returns the original string on ValueError. Covered by test_invalid_date_returns_original.

Comment thread breach_scraper/sources/hhs_ocr.py Outdated
Comment on lines +46 to +50
def _normalize_date(value: str) -> str:
cleaned = _clean_text(value)
if not cleaned:
return ""
return datetime.strptime(cleaned, "%m/%d/%Y").date().isoformat()

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

Parsing dates with datetime.strptime without a try-except block is fragile. Any malformed date or unexpected format on the HHS OCR portal will raise a ValueError and crash the entire scraper. Wrap the parsing in a try-except block and return the original string as a fallback.

Suggested change
def _normalize_date(value: str) -> str:
cleaned = _clean_text(value)
if not cleaned:
return ""
return datetime.strptime(cleaned, "%m/%d/%Y").date().isoformat()
def _normalize_date(value: str) -> str:
cleaned = _clean_text(value)
if not cleaned:
return ""
try:
return datetime.strptime(cleaned, "%m/%d/%Y").date().isoformat()
except ValueError:
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. _normalize_date now wraps strptime in try/except and returns the original string on ValueError.

Comment on lines +409 to +415
if tag == "li" and self.item_stack:
item = self.item_stack.pop()
text = item.text
if self.item_stack:
self.item_stack[-1].add_child_text(text)
elif text:
self.items.append((self.current_section, text, item.href))

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

When parsing nested list items (e.g., <li> inside another <li>), the nested item's href is set on the nested _DetailItem. However, when the nested item is popped and its text is added to the parent via add_child_text, its href is completely lost. Propagate the href to the parent item if the parent doesn't already have one.

Suggested change
if tag == "li" and self.item_stack:
item = self.item_stack.pop()
text = item.text
if self.item_stack:
self.item_stack[-1].add_child_text(text)
elif text:
self.items.append((self.current_section, text, item.href))
if tag == "li" and self.item_stack:
item = self.item_stack.pop()
text = item.text
if self.item_stack:
self.item_stack[-1].add_child_text(text)
if item.href and not self.item_stack[-1].href:
self.item_stack[-1].href = item.href
elif text:
self.items.append((self.current_section, text, item.href))

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. Nested

  • href is now propagated to the parent item when the parent has none.

  • Comment on lines +230 to +232
    def _parse_csv_export(text: str) -> list[dict[str, str]]:
    if not text.lstrip().startswith('"'):
    return []

    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

    Checking if the text starts with a double quote (") to detect a CSV export is fragile. If the first field is unquoted or if there are leading spaces, this check will fail. A more robust approach is to verify that the content does not start with < (which indicates HTML) and contains expected CSV headers like "Covered Entity Type".

    Suggested change
    def _parse_csv_export(text: str) -> list[dict[str, str]]:
    if not text.lstrip().startswith('"'):
    return []
    def _parse_csv_export(text: str) -> list[dict[str, str]]:
    cleaned = text.lstrip()
    if cleaned.startswith("<") or "Covered Entity Type" not in cleaned:
    return []

    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. _parse_csv_export now rejects content starting with '<' and requires the 'Covered Entity Type' header. Covered by test_html_is_not_treated_as_csv and test_csv_without_known_header_is_rejected.

    Comment on lines +217 to +221
    export_request = Request(
    url,
    data=data,
    headers={**_build_headers(), "Content-Type": "application/x-www-form-urlencoded"},
    )

    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 POST request to export the CSV does not pass the custom user_agent to _build_headers(), meaning it will fall back to the default Chrome 123 User-Agent. This causes an inconsistency with the initial GET request if a custom User-Agent was configured.

    Suggested change
    export_request = Request(
    url,
    data=data,
    headers={**_build_headers(), "Content-Type": "application/x-www-form-urlencoded"},
    )
    export_request = Request(
    url,
    data=data,
    headers={**_build_headers(user_agent), "Content-Type": "application/x-www-form-urlencoded"},
    )

    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. The export POST now passes _build_headers(user_agent) so a custom --user-agent is used consistently.

    Comment thread breach_scraper/sources/or_doj.py Outdated
    Comment on lines +64 to +69
    if " - " in part:
    range_parts = [_normalize_single_date(item) for item in part.split(" - ")]
    range_parts = [item for item in range_parts if item]
    if range_parts:
    normalized_parts.append(" to ".join(range_parts))
    continue

    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

    Splitting date ranges by strictly checking for " - " (with spaces) is fragile. If the website contains a date range with different spacing (e.g., "8/13/2025-8/14/2025"), it won't be split correctly. Use re.split(r'\s*-\s*', part) to robustly handle any spacing around the hyphen.

    Suggested change
    if " - " in part:
    range_parts = [_normalize_single_date(item) for item in part.split(" - ")]
    range_parts = [item for item in range_parts if item]
    if range_parts:
    normalized_parts.append(" to ".join(range_parts))
    continue
    if "-" in part:
    range_parts = [_normalize_single_date(item) for item in re.split(r'\s*-\s*', part)]
    range_parts = [item for item in range_parts if item]
    if range_parts:
    normalized_parts.append(" to ".join(range_parts))
    continue

    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. Ranges are now split with re.split(r'\s*-\s*', part), guarded by 'if "-" in part'. Covered by test_tight_range_is_split.

    @noderaven
    noderaven merged commit 65ae384 into main Jun 25, 2026
    4 checks passed
    @noderaven
    noderaven deleted the feature/add-sources branch June 25, 2026 01:06
    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