← back to La Permits School
pull_permits_live.py
151 lines
#!/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()