Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| #!/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/<XX>`` → discover county slugs. | |
| 2. For each county GET ``/state/<XX>/county/<slug>`` → 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 <uuid-from-previous-run> | |
| Checkpoints (per-state JSONL) live at ``data/bronze/netronline_progress/<batch>.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() | |
| # -------------------------------------------------------------------------------------- | |
| class CountySlug: | |
| state_code: str | |
| slug: str | |
| display_name: str # as shown on the state index page | |
| 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/<XX> 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/<XX>/county/<slug> page; return office dicts. | |
| Page layout: each office is a ``<div class="div-table-row">`` with child | |
| ``<div class="div-table-col" col-name="...">`` 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()) | |