#!/usr/bin/env python3 """ Crawl publicrecords.netronline.com and persist per-county public-records office directories into ``bronze.bronze_jurisdictions_county_directory`` (Neon dev, ``NEON_DATABASE_URL_DEV``). For each state code (defaults to all 50 + DC): 1. GET ``/state/`` → discover county slugs. 2. For each county GET ``/state//county/`` → extract office rows (Name / Phone / Online-URL / access-type label) from the ``.div-table-row`` containers. 3. Best-effort map ``county_name`` to ``int_jurisdictions.jurisdiction_id`` + FIPS code by exact normalized-name match within the state. 4. Insert rows in batches. Polite by default: 1 worker, ``--sleep 1`` second between requests, custom UA. robots.txt at the time of writing permits unrestricted crawling (``User-agent: * / Disallow:``). Re-check before each large run. Run:: .venv/bin/python -m scripts.datasources.netronline.crawl_county_directory \\ --states AL --dry-run # confirm county-slug discovery .venv/bin/python -m scripts.datasources.netronline.crawl_county_directory \\ --states AL # 1 state, polite default .venv/bin/python -m scripts.datasources.netronline.crawl_county_directory \\ --workers 3 # all 50+DC, 3 workers Resume:: .venv/bin/python -m scripts.datasources.netronline.crawl_county_directory \\ --batch-id Checkpoints (per-state JSONL) live at ``data/bronze/netronline_progress/.jsonl``. """ from __future__ import annotations import argparse import json import logging import os import re import signal import sys import time import uuid from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from threading import Event, Lock from typing import Any import psycopg2 import requests from bs4 import BeautifulSoup from dotenv import load_dotenv _ROOT = Path(__file__).resolve().parents[3] if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) from scripts.discovery.bronze_jurisdictions_county_directory_persist import ( # noqa: E402 insert_bronze_county_directory_rows, ) logger = logging.getLogger("netronline_crawl") BASE_URL = "https://publicrecords.netronline.com" DEFAULT_UA = ( "OpenNavigatorJurisdictionPilot/1.0 " "(+https://github.com/getcommunityone/open-navigator-for-engagement)" ) DEFAULT_STATES = ( "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY", ) _CHECKPOINT_ROOT = _ROOT / "data" / "bronze" / "netronline_progress" _PROGRESS_LOCK = Lock() _STOP = Event() # -------------------------------------------------------------------------------------- @dataclass(frozen=True) class CountySlug: state_code: str slug: str display_name: str # as shown on the state index page @dataclass class StateResult: state_code: str counties_seen: int = 0 counties_persisted: int = 0 offices_persisted: int = 0 error: str | None = None duration_s: float = 0.0 # -------------------------------------------------------------------------------------- # DB # -------------------------------------------------------------------------------------- def _resolve_database_url() -> str: load_dotenv(_ROOT / ".env") url = os.getenv("NEON_DATABASE_URL_DEV", "").strip() if not url: raise SystemExit("NEON_DATABASE_URL_DEV is not set in .env — refusing to default to prod.") return url def _normalize_county_name(name: str) -> str: """``Barbour (Clayton)`` -> ``barbour``; ``Saint Clair`` -> ``st clair``; lowercases.""" s = (name or "").strip().lower() s = re.sub(r"\(.*?\)", "", s) # drop courthouse suffix in parens s = re.sub(r"[^a-z]+", " ", s) s = re.sub(r"\bsaint\b", "st", s) s = re.sub(r"\bcounty\b|\bparish\b|\bborough\b|\bcensus area\b", "", s) return s.strip() def load_state_county_index(database_url: str, state_code: str) -> dict[str, tuple[str, str]]: """ Return ``{normalized_county_name -> (jurisdiction_id, fips_5)}`` for one state, sourced from ``intermediate.int_jurisdiction_websites`` (only includes counties with websites, which is the same authority the rest of the pipeline uses). """ out: dict[str, tuple[str, str]] = {} sql = """ SELECT DISTINCT jurisdiction_id, organization_name, city FROM intermediate.int_jurisdiction_websites WHERE state_code = %s AND jurisdiction_category = 'county' """ conn = psycopg2.connect(database_url) try: with conn.cursor() as cur: cur.execute(sql, (state_code,)) for jid, org_name, city in cur.fetchall(): # jurisdiction_id format: "county_<5-digit FIPS>" fips = "" if jid and jid.startswith("county_"): suffix = jid.split("_", 1)[1] if suffix.isdigit() and len(suffix) == 5: fips = suffix for candidate in (org_name, city): norm = _normalize_county_name(candidate or "") if norm: out.setdefault(norm, (jid, fips)) finally: conn.close() return out # -------------------------------------------------------------------------------------- # HTTP # -------------------------------------------------------------------------------------- def _new_session(user_agent: str) -> requests.Session: sess = requests.Session() sess.headers["User-Agent"] = user_agent sess.headers["Accept-Language"] = "en-US,en;q=0.9" return sess def _fetch(session: requests.Session, url: str, *, timeout: int = 25) -> str: resp = session.get(url, timeout=timeout, allow_redirects=True) if resp.status_code == 200: return resp.text or "" raise RuntimeError(f"HTTP {resp.status_code} for {url}") # -------------------------------------------------------------------------------------- # Parsing # -------------------------------------------------------------------------------------- _COUNTY_HREF_RE = re.compile(r"/state/([A-Z]{2})/county/([^/?#]+)") def parse_state_index(html: str, state_code: str) -> list[CountySlug]: """From a /state/ page, return [(slug, display_name), ...] for that state.""" soup = BeautifulSoup(html, "html.parser") out: list[CountySlug] = [] seen: set[str] = set() for a in soup.find_all("a", href=_COUNTY_HREF_RE): href = a.get("href", "") m = _COUNTY_HREF_RE.search(href) if not m: continue st, slug = m.group(1), m.group(2) if st != state_code or slug in seen: continue seen.add(slug) name = a.get_text(" ", strip=True) or slug.replace("_", " ").title() out.append(CountySlug(state_code=state_code, slug=slug, display_name=name)) return out def parse_county_offices(html: str) -> list[dict[str, Any]]: """ Parse a /state//county/ page; return office dicts. Page layout: each office is a ``
`` with child ``
`` cells. Known col-names: ``Name``, ``Phone``, ``Online`` (text label + outbound href), ``Report``. """ soup = BeautifulSoup(html, "html.parser") out: list[dict[str, Any]] = [] for row in soup.find_all("div", class_=lambda c: c and "div-table-row" in c): cells: dict[str, Any] = {} href: str | None = None for col in row.find_all("div", class_=lambda c: c and "div-table-col" in c): key = (col.get("col-name") or "").strip() if not key: continue text = col.get_text(" ", strip=True) a = col.find("a", href=True) cells[key] = text if a and key.lower() == "online": href = a["href"].strip() name = (cells.get("Name") or "").strip() if not name: continue if name.lower() in {"name", "report"}: # header rows continue out.append({ "office_name": name, "office_phone": cells.get("Phone") or None, "office_url": href, "access_type": cells.get("Online") or None, # data_type isn't a column on the page; downstream can derive from office_name. "data_type": None, "raw_row": cells, }) return out # -------------------------------------------------------------------------------------- # Checkpoint # -------------------------------------------------------------------------------------- def _checkpoint_path(batch_id: str) -> Path: return _CHECKPOINT_ROOT / f"{batch_id}.jsonl" def load_completed_states(batch_id: str) -> set[str]: p = _checkpoint_path(batch_id) if not p.exists(): return set() done: set[str] = set() with p.open() as f: for line in f: line = line.strip() if not line: continue try: rec = json.loads(line) except json.JSONDecodeError: continue if rec.get("state_code"): done.add(rec["state_code"]) return done def record_state_checkpoint(batch_id: str, result: StateResult) -> None: p = _checkpoint_path(batch_id) p.parent.mkdir(parents=True, exist_ok=True) line = json.dumps({ "state_code": result.state_code, "counties_seen": result.counties_seen, "counties_persisted": result.counties_persisted, "offices_persisted": result.offices_persisted, "error": result.error, "duration_s": round(result.duration_s, 2), "completed_at": datetime.now(timezone.utc).isoformat(), }) with _PROGRESS_LOCK: with p.open("a") as f: f.write(line + "\n") # -------------------------------------------------------------------------------------- # Per-state work # -------------------------------------------------------------------------------------- def _process_state( state_code: str, *, batch_id: str, database_url: str, user_agent: str, sleep_s: float, dry_run: bool, ) -> StateResult: result = StateResult(state_code=state_code) start = time.monotonic() try: session = _new_session(user_agent) index_url = f"{BASE_URL}/state/{state_code}" index_html = _fetch(session, index_url) counties = parse_state_index(index_html, state_code) result.counties_seen = len(counties) logger.info("[%s] %d county slugs found", state_code, len(counties)) if dry_run: for c in counties[:8]: print(f" {state_code} /county/{c.slug} \"{c.display_name}\"") if len(counties) > 8: print(f" ... and {len(counties) - 8} more") result.duration_s = time.monotonic() - start return result name_to_jid = load_state_county_index(database_url, state_code) logger.info("[%s] FIPS map has %d county-name entries", state_code, len(name_to_jid)) for i, c in enumerate(counties, 1): if _STOP.is_set(): logger.info("[%s] stop requested mid-state at %d/%d", state_code, i, len(counties)) break time.sleep(sleep_s) county_url = f"{BASE_URL}/state/{c.state_code}/county/{c.slug}" try: county_html = _fetch(session, county_url) except Exception as exc: logger.warning("[%s] %s fetch failed: %s", state_code, c.slug, exc) continue offices = parse_county_offices(county_html) if not offices: logger.info("[%s] %s — no office rows parsed", state_code, c.slug) continue jid_match = name_to_jid.get(_normalize_county_name(c.display_name)) jurisdiction_id = jid_match[0] if jid_match else None fips = jid_match[1] if jid_match else None inserted = insert_bronze_county_directory_rows( database_url, scrape_batch_id=batch_id, state_code=state_code, county_slug=c.slug, county_name=c.display_name, source_page_url=county_url, jurisdiction_id=jurisdiction_id, fips_code=fips, offices=offices, ) result.counties_persisted += 1 if inserted else 0 result.offices_persisted += inserted if i % 25 == 0 or i == len(counties): logger.info("[%s] %d/%d counties processed (%d offices so far)", state_code, i, len(counties), result.offices_persisted) except Exception as exc: result.error = f"{type(exc).__name__}: {exc}" logger.exception("[%s] state failed", state_code) result.duration_s = time.monotonic() - start return result # -------------------------------------------------------------------------------------- # Main # -------------------------------------------------------------------------------------- def _install_sigint_handler() -> None: def _h(_s, _f): # type: ignore[no-untyped-def] if _STOP.is_set(): logger.warning("Second Ctrl-C — exiting hard.") os._exit(130) logger.warning("Ctrl-C: finishing in-flight state then stopping (Ctrl-C again to abort).") _STOP.set() signal.signal(signal.SIGINT, _h) def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--states", default=",".join(DEFAULT_STATES), help="Comma-separated state codes (default: all 50 + DC).") p.add_argument("--workers", type=int, default=1, help="Parallel states (default: 1 — be polite). Per-state requests stay serial.") p.add_argument("--sleep", type=float, default=1.0, help="Seconds between county fetches within a state (default: 1.0).") p.add_argument("--batch-id", default=None, help="Resume an existing batch — already-completed states are skipped.") p.add_argument("--user-agent", default=DEFAULT_UA, help="HTTP User-Agent header for outbound requests.") p.add_argument("--dry-run", action="store_true", help="List counties per state; do not fetch county pages or write to DB.") p.add_argument("--verbose", "-v", action="store_true") args = p.parse_args(argv) logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logging.getLogger("urllib3").setLevel(logging.WARNING) states = tuple(s.strip().upper() for s in args.states.split(",") if s.strip()) if not states: logger.error("--states must be non-empty") return 2 database_url = _resolve_database_url() if not args.dry_run else "" batch_id = args.batch_id or str(uuid.uuid4()) done = load_completed_states(batch_id) if args.batch_id else set() pending = [s for s in states if s not in done] logger.info("Batch %s — %d state(s) pending (%d already complete)", batch_id, len(pending), len(done)) if not pending: print("Nothing to do (all states complete in this batch).") return 0 _install_sigint_handler() totals = {"counties": 0, "offices": 0, "errors": 0} start = time.monotonic() if args.workers <= 1: for st in pending: if _STOP.is_set(): break r = _process_state( st, batch_id=batch_id, database_url=database_url, user_agent=args.user_agent, sleep_s=args.sleep, dry_run=args.dry_run, ) record_state_checkpoint(batch_id, r) totals["counties"] += r.counties_persisted totals["offices"] += r.offices_persisted if r.error: totals["errors"] += 1 logger.info("[%s] done in %.0fs: counties=%d offices=%d err=%s", st, r.duration_s, r.counties_persisted, r.offices_persisted, "yes" if r.error else "no") else: with ThreadPoolExecutor(max_workers=args.workers) as pool: fut2st = { pool.submit( _process_state, st, batch_id=batch_id, database_url=database_url, user_agent=args.user_agent, sleep_s=args.sleep, dry_run=args.dry_run, ): st for st in pending } for fut in as_completed(fut2st): st = fut2st[fut] try: r = fut.result() except Exception as exc: logger.exception("worker raised for %s", st) r = StateResult(state_code=st, error=f"{type(exc).__name__}: {exc}") record_state_checkpoint(batch_id, r) totals["counties"] += r.counties_persisted totals["offices"] += r.offices_persisted if r.error: totals["errors"] += 1 logger.info("[%s] done in %.0fs: counties=%d offices=%d err=%s", st, r.duration_s, r.counties_persisted, r.offices_persisted, "yes" if r.error else "no") elapsed = time.monotonic() - start print() print(f"Batch: {batch_id}") print(f"States processed: {len(pending)}") print(f"Counties with rows: {totals['counties']}") print(f"Offices inserted: {totals['offices']}") print(f"States with error: {totals['errors']}") print(f"Elapsed: {elapsed:.0f}s") print(f"Checkpoint file: {_checkpoint_path(batch_id)}") return 0 if __name__ == "__main__": raise SystemExit(main())