[object Object]

← back to La Permits School

Add pull_permits_live.py: LIVE current LA permits (feed pi9x-tg5x, ~daily) with assessor+LADBS enrichment URLs; document live-vs-named tradeoff

4485919f6dae9592ef32756b9831ecf45a6cd679 · 2026-08-10 14:32:55 -0700 · Steve Abrams

Files touched

Diff

commit 4485919f6dae9592ef32756b9831ecf45a6cd679
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 14:32:55 2026 -0700

    Add pull_permits_live.py: LIVE current LA permits (feed pi9x-tg5x, ~daily) with assessor+LADBS enrichment URLs; document live-vs-named tradeoff
---
 FINDINGS.md          |  12 +++++
 README.md            |  16 ++++++
 pull_permits_live.py | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 178 insertions(+)

diff --git a/FINDINGS.md b/FINDINGS.md
index 55654b2..97fd914 100644
--- a/FINDINGS.md
+++ b/FINDINGS.md
@@ -53,6 +53,18 @@ account for ~$7.56B (≈21%)** of all $36.1B.
 Note the two business models visible in the numbers: **Hensel Phelps = few, huge jobs**
 (10 permits, $161M avg) vs **Swinerton = many, smaller jobs** (23 permits, $30M avg).
 
+## Data availability — live vs. named (important)
+The City of LA **stopped publishing contractor/applicant/principal NAMES in its open
+permit feed after 2023-05-19** (a privacy/policy change). As of Aug 2026:
+- The **only openly-updated feed** (`pi9x-tg5x`, refreshed to within ~2 days) carries
+  **valuation / type / address / APN / council district / status but NO names.**
+- Every **name-bearing** dataset (`d9aa-v8bm`, `hbkd-qubn`, `jqz4-22tu`) is **frozen at
+  2023-05-19**; the previously-open `nbyu-2ha9` is now login-gated.
+
+So this project ships two pullers: `pull_permits.py` (named, 2013–2023 snapshot) and
+`pull_permits_live.py` (current, nameless — enrich the owner/developer live via the LA
+County Assessor by APN). No free source is both live AND contractor-named.
+
 ## Method & honesty notes
 - **Source:** public City of LA open data (LADBS `d9aa-v8bm`) — a stable 2013–2023 snapshot.
   No paid databases used. Contractor entity enrichment (CA SOS / CSLB) is a guided lookup, not
diff --git a/README.md b/README.md
index a741f96..eb17b9b 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,22 @@ public LA Open Data Portal and writes a clean CSV — including **contractor,
 applicant, and principal names**, project **valuation**, address, **APN**, and a
 ready-made **California Secretary of State** lookup URL per contractor.
 
+## ⚡ LIVE vs. historical (read this first)
+The City **stopped publishing contractor/applicant NAMES in the open feed after
+2023-05-19** (privacy/policy change). So you pick one:
+
+- **Want CURRENT permits (updated ~daily)?** → `pull_permits_live.py` (feed `pi9x-tg5x`,
+  refreshed to within ~2 days). Has valuation / type / address / **APN** / council
+  district / status — but **no contractor names**. Enrich the **owner/developer** live
+  via the LA County Assessor (by APN) and the contractor via an LADBS permit lookup.
+  ```bash
+  python3 pull_permits_live.py --min-valuation 5000000 --days 120   # -> permits_live.csv
+  ```
+- **Want contractor/applicant/principal NAMES?** → `pull_permits.py` (feed `d9aa-v8bm`),
+  but that data is a **frozen 2013–2023 snapshot** — the last free named permit data.
+
+There is no free source that is BOTH live AND contractor-named; that ended mid-2023.
+
 Two free public data sources, no signup, no cost:
 
 | Source | What it gives | Access |
diff --git a/pull_permits_live.py b/pull_permits_live.py
new file mode 100644
index 0000000..0f01c3f
--- /dev/null
+++ b/pull_permits_live.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""
+pull_permits_live.py — LIVE, up-to-date LA building permits (updated ~daily). $0, stdlib only.
+
+Uses the City's CURRENT open feed `pi9x-tg5x` (refreshed to within ~2 days of today),
+filtered to significant commercial/new construction. Writes a clean CSV with, per permit,
+a live LA County Assessor OWNER lookup (by APN) and an LADBS permit lookup (by permit #).
+
+IMPORTANT — why this differs from pull_permits.py:
+  The City STOPPED publishing contractor/applicant NAMES in the open feed after
+  2023-05-19 (privacy/policy change). The only openly-updated feed (`pi9x-tg5x`) is
+  LIVE but carries NO name fields. So for current data the party you enrich live is
+  the OWNER/DEVELOPER (via the county Assessor by APN) — which for a loan officer is
+  the more useful party anyway. The GC name, if needed, is a per-permit LADBS lookup.
+
+Usage:
+  python3 pull_permits_live.py                          # commercial >$1M, last 365d -> permits_live.csv
+  python3 pull_permits_live.py --min-valuation 5000000 --days 90
+  python3 pull_permits_live.py --permit-types "Bldg-New" --out new_live.csv
+"""
+
+import argparse
+import csv
+import datetime as _dt          # only for arg parsing of --days offset input by the user
+import sys
+import time
+import urllib.parse
+import urllib.request
+
+DATASET = "pi9x-tg5x"
+BASE = "https://data.lacity.org/resource/{}.json".format(DATASET)
+PAGE = 50000
+# LA County Assessor parcel detail (current owner) by 10-digit AIN (= apn, no dashes):
+ASSESSOR = "https://portal.assessor.lacounty.gov/parceldetail/"
+# LADBS Online Building Records search (find the permit -> contractor):
+LADBS = "https://www.ladbsservices2.lacity.org/OnlineServices/OnlineServices/OnlineBuildingRecords"
+
+OUT_FIELDS = [
+    "permit_nbr", "issue_date", "status_desc", "permit_type", "permit_sub_type",
+    "use_desc", "valuation", "square_footage",
+    "address", "zip_code", "council_district", "apn",
+    "assessor_owner_url", "ladbs_permit_url",
+]
+
+
+def build_where(min_valuation, permit_types, since):
+    clauses = ["valuation IS NOT NULL"]
+    if min_valuation:
+        # valuation is stored as TEXT on the live feed -> cast to number to compare
+        clauses.append(f"(valuation::number) > {int(min_valuation)}")
+    if permit_types:
+        quoted = ",".join("'" + t.replace("'", "''") + "'" for t in permit_types)
+        clauses.append(f"permit_type in({quoted})")
+    if since:
+        clauses.append(f"issue_date >= '{since}T00:00:00'")
+    return " and ".join(clauses)
+
+
+def fetch_page(where, offset, app_token=None):
+    import json
+    params = {"$where": where, "$order": "(valuation::number) DESC", "$limit": PAGE, "$offset": offset}
+    if app_token:
+        params["$$app_token"] = app_token
+    url = BASE + "?" + urllib.parse.urlencode(params)
+    req = urllib.request.Request(url, headers={"Accept": "application/json"})
+    for attempt in range(4):
+        try:
+            with urllib.request.urlopen(req, timeout=60) as r:
+                data = json.load(r)
+            if isinstance(data, dict):  # Socrata error object
+                print("  ! API error:", data.get("message", data), file=sys.stderr)
+                return []
+            return data
+        except Exception as e:
+            if attempt == 3:
+                print(f"  ! giving up on offset {offset}: {e}", file=sys.stderr)
+                return []
+            time.sleep(2 * (attempt + 1))
+    return []
+
+
+def shape(row):
+    apn = (row.get("apn") or "").strip()
+    return {
+        "permit_nbr": row.get("permit_nbr", ""),
+        "issue_date": (row.get("issue_date") or "")[:10],
+        "status_desc": row.get("status_desc", ""),
+        "permit_type": row.get("permit_type", ""),
+        "permit_sub_type": row.get("permit_sub_type", ""),
+        "use_desc": row.get("use_desc", ""),
+        "valuation": row.get("valuation", ""),
+        "square_footage": row.get("square_footage", ""),
+        "address": row.get("primary_address", ""),
+        "zip_code": row.get("zip_code", ""),
+        "council_district": row.get("cd", ""),
+        "apn": apn,
+        "assessor_owner_url": (ASSESSOR + apn) if apn else "",
+        "ladbs_permit_url": LADBS,
+    }
+
+
+def main():
+    ap = argparse.ArgumentParser(description="LIVE LA permits from the current open feed.")
+    ap.add_argument("--min-valuation", type=int, default=1_000_000, help="min $ (default 1000000; 0 = off)")
+    ap.add_argument("--permit-types", default="Bldg-New,Bldg-Addition",
+                    help="comma list; '' = all. e.g. Bldg-New, Bldg-Addition, Bldg-Alter/Repair")
+    ap.add_argument("--days", type=int, default=365, help="only permits issued in the last N days (0 = all)")
+    ap.add_argument("--since", default="", help="explicit YYYY-MM-DD floor (overrides --days)")
+    ap.add_argument("--limit", type=int, default=0, help="cap rows (0 = all)")
+    ap.add_argument("--out", default="permits_live.csv")
+    ap.add_argument("--app-token", default="")
+    args = ap.parse_args()
+
+    since = args.since
+    if not since and args.days:
+        since = (_dt.date.today() - _dt.timedelta(days=args.days)).isoformat()
+
+    permit_types = [t.strip() for t in args.permit_types.split(",") if t.strip()]
+    where = build_where(args.min_valuation if args.min_valuation > 0 else 0, permit_types, since)
+
+    print("LIVE LADBS permit puller  —  cost: $0 (current LA Open Data feed pi9x-tg5x)")
+    print(f"  filter : {where}")
+    print(f"  output : {args.out}")
+
+    written, offset = 0, 0
+    with open(args.out, "w", newline="", encoding="utf-8") as f:
+        w = csv.DictWriter(f, fieldnames=OUT_FIELDS)
+        w.writeheader()
+        while True:
+            rows = fetch_page(where, offset, args.app_token or None)
+            if not rows:
+                break
+            for row in rows:
+                w.writerow(shape(row))
+                written += 1
+                if args.limit and written >= args.limit:
+                    break
+            print(f"  ... {written} rows", flush=True)
+            if (args.limit and written >= args.limit) or len(rows) < PAGE:
+                break
+            offset += PAGE
+            time.sleep(0.3)
+
+    print(f"\nDone. {written} LIVE permits -> {args.out}")
+    print("Enrich (free, live): open assessor_owner_url for the CURRENT owner/developer by APN;")
+    print("open ladbs_permit_url and search the permit # for the contractor of record.")
+
+
+if __name__ == "__main__":
+    main()

← 7bbff9c Add make_chart.py: stdlib SVG 'value by year' bar chart (202  ·  back to La Permits School  ·  Add enrich_live_contractor.py: recover LIVE contractor+CSLB 69d607c →