← back to La Permits School

enrich_contractors.py

139 lines

#!/usr/bin/env python3
"""
enrich_contractors.py — dedupe permits.csv down to UNIQUE contractors and attach
free entity-lookup links (school project). $0, standard library only.

Why this shape: the CA Secretary of State bizfile site is behind Imperva/Incapsula
bot protection (blocks plain HTTP), OpenCorporates now needs a paid token, and the
CSLB license page is session-gated. So there is no reliable *free* HTTP endpoint to
auto-scrape entity data. The high-leverage move instead:

  1. collapse thousands of permit rows to the handful of UNIQUE contractors,
  2. rank them by total project value (the "top contractors" list you actually want),
  3. hand each one ready-to-click lookups for CA SOS (by name) AND CSLB (by license).

You then click a small, deduped worklist — not thousands of rows.

Usage:
  python3 enrich_contractors.py                        # reads permits.csv -> contractors.csv
  python3 enrich_contractors.py --in sample.csv --out firms.csv
  python3 enrich_contractors.py --min-permits 2        # only firms with >=2 permits
"""

import argparse
import csv
import re
import urllib.parse

CA_SOS_SEARCH = "https://bizfileonline.sos.ca.gov/search/business"
CSLB_LOOKUP = "https://www.cslb.ca.gov/OnlineServices/CheckLicenseII/CheckLicense.aspx"


def norm_name(name):
    """Normalize a business name for grouping (uppercase, collapse space/punct)."""
    n = (name or "").upper().strip()
    n = re.sub(r"[.,]", " ", n)
    n = re.sub(r"\s+", " ", n)
    return n.strip()


def money(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return 0.0


def sos_url(name):
    return CA_SOS_SEARCH + "?" + urllib.parse.urlencode({"searchType": "BUSINESS", "q": name}) if name else ""


def cslb_url(license_no):
    # CSLB gates the direct license-detail page, so link the public lookup and
    # carry the number in its own column for a one-paste check.
    return CSLB_LOOKUP + "?" + urllib.parse.urlencode({"LicNum": license_no}) if license_no else CSLB_LOOKUP


def main():
    ap = argparse.ArgumentParser(description="Dedupe permits to unique contractors + entity lookups.")
    ap.add_argument("--in", dest="infile", default="permits.csv", help="input permits CSV (from pull_permits.py)")
    ap.add_argument("--out", default="contractors.csv", help="output unique-contractor CSV")
    ap.add_argument("--min-permits", type=int, default=1, help="only keep firms with >= this many permits")
    args = ap.parse_args()

    firms = {}  # norm_name -> aggregate dict
    total_rows = 0
    with open(args.infile, newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            name = (row.get("contractors_business_name") or "").strip()
            if not name:
                continue
            key = norm_name(name)
            # "OWNER-BUILDER" is a placeholder (owner pulled their own permit), not a
            # firm — it aggregates many unrelated owners, so drop it from the ranking.
            if key in ("OWNER-BUILDER", "OWNER BUILDER", "OWNER/BUILDER"):
                continue
            total_rows += 1
            fm = firms.setdefault(key, {
                "contractor": name, "permits": 0, "total_valuation": 0.0,
                "first_seen": "", "last_seen": "", "licenses": set(),
                "city": row.get("contractor_city", ""), "state": row.get("contractor_state", ""),
                "top_projects": [],  # (valuation, address)
            })
            fm["permits"] += 1
            val = money(row.get("valuation"))
            fm["total_valuation"] += val
            d = (row.get("issue_date") or "")[:10]
            if d:
                fm["first_seen"] = min(fm["first_seen"], d) if fm["first_seen"] else d
                fm["last_seen"] = max(fm["last_seen"], d) if fm["last_seen"] else d
            lic = (row.get("license") or "").strip()
            if lic and lic.strip("0"):  # ignore "0"/"00000" placeholders
                fm["licenses"].add(lic)
            fm["top_projects"].append((val, row.get("address", "")))

    rows_out = []
    for fm in firms.values():
        if fm["permits"] < args.min_permits:
            continue
        top = sorted(fm["top_projects"], reverse=True)[:3]
        best_license = sorted(fm["licenses"])[0] if fm["licenses"] else ""
        rows_out.append({
            "contractor": fm["contractor"],
            "permits": fm["permits"],
            "total_valuation": int(fm["total_valuation"]),
            "first_seen": fm["first_seen"],
            "last_seen": fm["last_seen"],
            "licenses": ";".join(sorted(fm["licenses"])),
            "city": fm["city"],
            "state": fm["state"],
            "top_projects": " | ".join(f"${int(v):,} {a}" for v, a in top if a),
            "ca_sos_lookup_url": sos_url(fm["contractor"]),
            "cslb_license": best_license,
            "cslb_lookup_url": cslb_url(best_license),
        })

    rows_out.sort(key=lambda r: r["total_valuation"], reverse=True)

    cols = ["contractor", "permits", "total_valuation", "first_seen", "last_seen",
            "licenses", "city", "state", "top_projects",
            "ca_sos_lookup_url", "cslb_license", "cslb_lookup_url"]
    with open(args.out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=cols)
        w.writeheader()
        w.writerows(rows_out)

    print("Contractor enrichment  —  cost: $0 (local dedupe + free lookup links)")
    print(f"  {total_rows} permit rows  ->  {len(rows_out)} unique contractors")
    print(f"  written to {args.out} (sorted by total project value)")
    if rows_out:
        print("\n  Top firms by total build value:")
        for r in rows_out[:5]:
            print(f"    ${r['total_valuation']:>14,}  {r['permits']:>3} permits  {r['contractor']}")
        print("\n  Enrich: open each row's ca_sos_lookup_url (legal entity + agent) and")
        print("  cslb_lookup_url (license status/classification). Small deduped worklist.")


if __name__ == "__main__":
    main()