scripts/registry.py
scripts/registry.pyBrowse 56 files
3,477 tokens
13,826 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""Ingest the California Data Broker Registry into broker records (coverage breadth).2 3The CA registry (CPPA, under the Delete Act) is the authoritative universe of data4brokers doing business with California residents -- ~545 businesses in 2025, each5required to publish a name, website, contact email, and a CCPA-rights/deletion URL.6This is the same universe commercial services (DeleteMe/Incogni/Optery) draw from,7plus the FCRA/GLBA-regulated and marketing/risk brokers most lists omit.8 9These are NOT people-search sites you scan with a name -- most have no per-person10lookup UI. They are worked through the LEGAL lane: the CA DROP portal11(privacy.ca.gov/drop) is a single request that deletes from ALL registered brokers12at once (CA residents), and per-broker CCPA deletion emails to the contact address13are the fallback / non-CA path. So registry records are kept in their own lane14(loaded only when asked) and never dumped into the people-search scan pipeline.15 16`parse()` is pure (CSV text in, records out) so it is tested offline; `fetch()` is17the only network call and can be bypassed by passing csv_text directly to refresh().18"""19from __future__ import annotations20 21import csv22import datetime23import io24import re25import urllib.request26from pathlib import Path27 28import storage29 30# CA CPPA registry CSVs are published per year (registry2024.csv, registry2025.csv, ...).31# 2025 is the latest COMPLETE dataset; the current year's file is empty until the Jan32# registration window closes. DEFAULT_URL is the known-good fallback; `ca_candidate_urls`33# probes newer years first so coverage auto-advances when the next year is published.34_CA_CSV = "https://cppa.ca.gov/data_broker_registry/registry{year}.csv"35_CA_FLOOR_YEAR = 202536DEFAULT_URL = _CA_CSV.format(year=_CA_FLOOR_YEAR)37DROP_URL = "https://privacy.ca.gov/drop"38USER_AGENT = "Mozilla/5.0 (compatible; unbroker/1.0; data opt-out)"39 40 41def ca_candidate_urls(today: datetime.date | None = None) -> list[str]:42 """Newest-year-first CA registry URLs to try (auto-advances; never below the 2025 floor)."""43 year = (today or datetime.date.today()).year44 years = list(range(max(year, _CA_FLOOR_YEAR), _CA_FLOOR_YEAR - 1, -1))45 return [_CA_CSV.format(year=y) for y in years]46 47# Multi-source registry lane. Only California publishes a clean bulk CSV (with contact email +48# CCPA-rights URL per broker) AND offers a one-shot deletion portal (DROP). Vermont, Oregon, and49# Texas maintain registries too, but only as searchable PORTALS (no reliable bulk export) and with50# no DROP-equivalent -- and they overlap CA heavily (CA is effectively the superset). So they are51# wired as first-class portal sources (official URL surfaced to the operator) rather than scraped.52# Adding any state that later publishes a CSV is a one-line "format: csv" entry (the parser is53# column-detection based, not CA-specific).54SOURCES = {55 "ca": {"jurisdiction": "US-CA", "format": "csv", "url": DEFAULT_URL, "has_drop": True,56 "name": "California Data Broker Registry (CPPA)"},57 "vt": {"jurisdiction": "US-VT", "format": "portal", "has_drop": False,58 "url": "https://bizfilings.vermont.gov/online/DatabrokerInquire/",59 "name": "Vermont Data Broker Registry (Secretary of State)"},60 "or": {"jurisdiction": "US-OR", "format": "portal", "has_drop": False,61 "url": "https://dfr.oregon.gov/business/licensing/data-broker-registry/Pages/index.aspx",62 "name": "Oregon Data Broker Registry (DCBS)"},63 "tx": {"jurisdiction": "US-TX", "format": "portal", "has_drop": False,64 "url": "https://texas-sos.appianportalsgov.com/data-broker-registry",65 "name": "Texas Data Broker Registry (Secretary of State)"},66}67 68 69def portals() -> list[dict]:70 """Registry sources that are searchable portals (no bulk export) -- surfaced to the operator."""71 return [{"key": k, "jurisdiction": s["jurisdiction"], "name": s["name"], "url": s["url"]}72 for k, s in SOURCES.items() if s["format"] == "portal"]73 74# Field label -> substring to locate its column on the header row (robust to75# year-to-year column shifts; the registry re-orders/adds columns between years).76_LABELS = {77 "name": "data broker name:",78 "dba": "doing business as",79 "website": "data broker primary website:",80 "email": "primary contact email",81 "rights_url": "exercise their ca consumer privacy act rights",82 "fcra": "regulated by the federal fair credit reporting act (fcra):",83}84 85 86def _norm(s: str) -> str:87 """Registry CSVs use NBSPs and a BOM; normalize for matching + clean values."""88 return re.sub(r"\s+", " ", (s or "").replace("\ufeff", "").replace("\xa0", " ")).strip()89 90 91def slug(name: str, website: str = "") -> str:92 base = re.sub(r"\.(com|org|net|io|ai|inc|co|us|info|llc)\b", "", (name or "").strip(), flags=re.I)93 s = re.sub(r"[^a-z0-9]+", "", base.lower())94 if s:95 return s96 dom = re.sub(r"^https?://(www\.)?", "", (website or "").lower())97 return re.sub(r"[^a-z0-9]+", "", dom.split("/")[0]) or "broker"98 99 100def _domain(website: str) -> str:101 dom = re.sub(r"^https?://(www\.)?", "", (website or "").strip().lower())102 return dom.split("/")[0]103 104 105def _find_colmap(rows: list[list[str]]) -> tuple[int, dict[str, int]]:106 """Locate the label row (col0 == 'Data broker name:') and map fields to columns."""107 for i, row in enumerate(rows[:5]):108 if row and _norm(row[0]).lower().startswith("data broker name:"):109 colmap: dict[str, int] = {}110 for field, needle in _LABELS.items():111 for j, cell in enumerate(row):112 c = _norm(cell).lower()113 if needle in c and not c.startswith("if the data broker"):114 colmap[field] = j115 break116 return i, colmap117 raise ValueError("CA registry: could not locate the header row")118 119 120def _get(row: list[str], idx: int | None) -> str:121 return _norm(row[idx]) if idx is not None and idx < len(row) else ""122 123 124def _build(row: list[str], cm: dict[str, int], jurisdiction: str = "US-CA",125 has_drop: bool = True) -> dict | None:126 name = _get(row, cm.get("name"))127 website = _get(row, cm.get("website"))128 if not (name or website):129 return None130 email = _get(row, cm.get("email"))131 rights = _get(row, cm.get("rights_url"))132 dba = _get(row, cm.get("dba"))133 fcra = _get(row, cm.get("fcra")).lower().startswith("y")134 state = jurisdiction.split("-")[-1]135 136 method = "email" if email else ("web_form" if rights else "drop")137 if has_drop:138 notes = ("Registered CA data broker. One CA DROP request (privacy.ca.gov/drop) deletes from "139 "this and every registered broker at once; or send a CCPA deletion request to the "140 "contact email.")141 else:142 notes = (f"Registered {state} data broker (no one-shot delete portal in {state}). Send a "143 "CCPA/state-law deletion request to the contact email.")144 if fcra:145 notes += (" FCRA-regulated: some data is credit-reporting data with separate rules -- deletion "146 "may be limited; a consumer report dispute/security-freeze may apply instead.")147 return {148 "id": slug(name, website),149 "name": name or _domain(website),150 "dba": dba or None,151 "category": "data_broker",152 "priority": "long_tail",153 "jurisdictions": [jurisdiction],154 "search": {"method": "none", "url": website, "fetch": "none", "by": ["registry"]},155 "optout": {156 "method": method,157 "url": rights or website or None,158 "email": email or None,159 "requires": {"profile_url": False, "email_verification": False, "captcha": False,160 "gov_id": False, "account": False, "phone_callback": False, "payment": False},161 "inputs": ["full_name", "contact_email"],162 "deletion": {163 "via": "drop" if has_drop else "email",164 "email": email or None,165 "url": rights or None,166 "kinds": ["ccpa", "generic"],167 "notes": ("Covered by the CA DROP one-shot (privacy.ca.gov/drop); CCPA email fallback."168 if has_drop else "CCPA/state-law deletion email (no one-shot portal)."),169 },170 "fcra": fcra,171 "est_processing_days": 45,172 "notes": notes,173 },174 "source": f"{state}-registry",175 "confidence": "registry",176 "last_verified": None,177 }178 179 180def parse(csv_text: str, jurisdiction: str = "US-CA", has_drop: bool = True) -> list[dict]:181 """Parse a data-broker-registry CSV into broker records (deduped by id).182 183 Column detection is by header label, not fixed position, so any state that publishes a184 registry CSV with name/website/email/rights columns parses without new code.185 """186 rows = list(csv.reader(io.StringIO(csv_text)))187 if not rows:188 return []189 header_i, cm = _find_colmap(rows)190 out: list[dict] = []191 seen: dict[str, int] = {}192 for row in rows[header_i + 1:]:193 if not any(c.strip() for c in row):194 continue195 rec = _build(row, cm, jurisdiction, has_drop)196 if not rec:197 continue198 bid = rec["id"]199 if bid in seen: # disambiguate id collisions by domain, then a counter200 dom = re.sub(r"[^a-z0-9]+", "", _domain(rec["search"]["url"]))201 cand = f"{bid}-{dom}" if dom and dom != bid else bid202 while cand in seen:203 seen[bid] += 1204 cand = f"{bid}-{seen[bid]}"205 rec["id"] = cand206 seen.setdefault(rec["id"], 0)207 seen.setdefault(bid, 0)208 out.append(rec)209 return out210 211 212MIN_EXPECTED_CA = 100 # CA registry has ~500+; far fewer => wrong/empty file, warn213 214 215def fetch(url: str = DEFAULT_URL, timeout: int = 60) -> str:216 req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})217 with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310218 return resp.read().decode("utf-8", errors="replace")219 220 221def _fetch_ca_latest() -> tuple[str, list[dict]]:222 """Try newest CA registry year first; return (url, records) for the first non-empty."""223 last: tuple[str, list[dict]] = (DEFAULT_URL, [])224 for url in ca_candidate_urls():225 try:226 recs = parse(fetch(url), jurisdiction="US-CA", has_drop=True)227 except Exception: # noqa: BLE001 - a missing year 404s; fall through to older years228 continue229 if recs:230 return url, recs231 last = (url, recs)232 return last233 234 235def refresh(cache_path: Path, url: str = DEFAULT_URL, csv_text: str | None = None) -> dict:236 """CA single-source refresh: fetch (or accept) the CA CSV and write the cache."""237 text = csv_text if csv_text is not None else fetch(url)238 records = parse(text)239 storage.write_json(cache_path, records)240 fcra = sum(1 for r in records if (r.get("optout") or {}).get("fcra"))241 return {"parsed": len(records), "fcra_regulated": fcra,242 "cache_path": str(cache_path), "source_url": url}243 244 245def refresh_all(cache_path: Path, fetched: dict[str, str] | None = None) -> dict:246 """Multi-source refresh: pull every CSV source, dedupe across states by domain, cache.247 248 `fetched` optionally supplies {source_key: csv_text} to bypass the network (tests). CSV249 sources are ingested as broker records; portal sources contribute their URL for the operator250 (no bulk export exists) but no records. CA is processed first so it wins domain collisions.251 """252 all_recs: list[dict] = []253 seen_domains: set[str] = set()254 per_source: dict[str, dict] = {}255 for key, src in SOURCES.items():256 if src["format"] != "csv":257 per_source[key] = {"jurisdiction": src["jurisdiction"], "format": "portal",258 "url": src["url"], "records": 0,259 "note": "searchable portal (no bulk export); operator/agent searches by name"}260 continue261 used_url = src["url"]262 try:263 if fetched is not None:264 text = fetched.get(key)265 if text is None:266 raise RuntimeError("no CSV text supplied")267 recs = parse(text, jurisdiction=src["jurisdiction"], has_drop=src["has_drop"])268 elif key == "ca":269 used_url, recs = _fetch_ca_latest() # newest-year-first with fallback270 else:271 recs = parse(fetch(src["url"]), jurisdiction=src["jurisdiction"], has_drop=src["has_drop"])272 except Exception as exc: # noqa: BLE001 - one source failing must not sink the rest273 per_source[key] = {"jurisdiction": src["jurisdiction"], "format": "csv", "error": str(exc)}274 continue275 added = 0276 for r in recs:277 dom = _domain(r["search"]["url"])278 if dom and dom in seen_domains:279 continue280 if dom:281 seen_domains.add(dom)282 all_recs.append(r)283 added += 1284 entry = {"jurisdiction": src["jurisdiction"], "format": "csv", "url": used_url,285 "parsed": len(recs), "added_after_dedupe": added,286 "fcra": sum(1 for r in recs if (r.get("optout") or {}).get("fcra"))}287 if key == "ca" and len(recs) < MIN_EXPECTED_CA:288 entry["warning"] = (f"only {len(recs)} parsed (expected >{MIN_EXPECTED_CA}); the CA "289 "registry file may be empty/moved - verify the source URL")290 per_source[key] = entry291 storage.write_json(cache_path, all_recs)292 return {"total": len(all_recs), "sources": per_source, "portals": portals(),293 "cache_path": str(cache_path)}294