← back to Homesonspec
scripts/ingest-permits.py
101 lines
#!/usr/bin/env python3
"""
Construction-pipeline layer: ingest NEW-RESIDENTIAL building permits from free,
no-key city open-data APIs (Socrata SODA) into the building_permits table.
Leading indicator — these homes are being built months before they list.
$0, no key. West Coast metros first. Reversible (INSERT ... ON CONFLICT DO NOTHING).
"""
import json, urllib.request, urllib.parse, subprocess, sys
DBURL = "postgresql://macstudio3@localhost/homesonspec?host=/tmp"
UA = "HomesOnSpecBot/0.1 (+https://homesonspec.com/bot; contact: data@homesonspec.com)"
def num(v):
try:
f = float(str(v).replace("$", "").replace(",", "")); return f if f == f else None
except: return None
# each city: (source, base_url, where, mapper(row)->dict)
def sf_map(r):
loc = r.get("location") or {}
lat = num(r.get("latitude")) or (num(loc.get("latitude")) if isinstance(loc, dict) else None)
lon = num(r.get("longitude")) or (num(loc.get("longitude")) if isinstance(loc, dict) else None)
if lat is None and isinstance(loc, dict) and loc.get("coordinates"):
lon, lat = loc["coordinates"][0], loc["coordinates"][1]
st = " ".join(x for x in [r.get("street_number"), r.get("street_name"), r.get("street_suffix")] if x)
return dict(permit_number=r.get("permit_number") or r.get("permit_creation_date","")+st, address=st, city="San Francisco",
state="CA", zip=r.get("zipcode"), lat=lat, lon=lon, permit_type=r.get("permit_type_definition"),
work_class="New Construction", valuation=num(r.get("revised_cost") or r.get("estimated_cost")),
status=r.get("status"), filed_date=(r.get("filed_date") or "")[:10] or None,
issued_date=(r.get("issued_date") or "")[:10] or None, applicant=None)
def sea_map(r):
return dict(permit_number=r.get("permitnum"), address=r.get("originaladdress1"), city=r.get("originalcity") or "Seattle",
state=r.get("originalstate") or "WA", zip=r.get("originalzip"), lat=num(r.get("latitude")), lon=num(r.get("longitude")),
permit_type=r.get("permittypedesc"), work_class=r.get("housingunitsadded") and "New Residential" or "New",
valuation=num(r.get("estprojectcost")), status=r.get("statuscurrent"),
filed_date=(r.get("applieddate") or "")[:10] or None, issued_date=(r.get("issueddate") or "")[:10] or None,
applicant=r.get("contractorcompanyname"))
def la_map(r): # LADBS hbkd-qubn — addresses (no lat/lon; geocode later), all permit types
st = " ".join(x for x in [r.get("address_start"), r.get("street_name"), r.get("street_suffix")] if x)
pt = " / ".join(x for x in [r.get("permit_type"), r.get("permit_sub_type")] if x)
return dict(permit_number=r.get("pcis_permit"), address=st, city="Los Angeles", state="CA", zip=r.get("zip_code"),
lat=None, lon=None, permit_type=pt, work_class=r.get("permit_sub_type"), valuation=num(r.get("valuation")),
status="Issued" if r.get("issue_date") else None, filed_date=None,
issued_date=(r.get("issue_date") or "")[:10] or None,
applicant=r.get("contractors_business_name") or " ".join(x for x in [r.get("applicant_first_name"), r.get("applicant_last_name")] if x))
# (source, url, where, mapper, order_field, max_rows) — LA = ALL permit types (commercial + residential)
CITIES = [
("la", "https://data.lacity.org/resource/hbkd-qubn.json",
"issue_date > '2020-01-01'", la_map, "issue_date", 600000),
("sf", "https://data.sfgov.org/resource/i98e-djp9.json",
"permit_type_definition like '%new construction%' AND filed_date > '2022-01-01'", sf_map, "filed_date", 8000),
("seattle", "https://data.seattle.gov/resource/76t5-zqzr.json",
"permitclassmapped='Residential' AND permittypedesc like '%New%' AND issueddate > '2022-01-01'", sea_map, "issueddate", 8000),
]
def esc(v):
if v is None: return "NULL"
if isinstance(v, (int, float)): return str(v)
return "'" + str(v).replace("'", "''") + "'"
def main():
total = 0
only = sys.argv[1] if len(sys.argv) > 1 else None
for source, base, where, mapper, order, max_rows in CITIES:
if only and source != only: continue
got = 0; offset = 0
while offset < max_rows:
q = {"$limit": 1000, "$offset": offset, "$where": where, "$order": order + " DESC"}
url = base + "?" + urllib.parse.urlencode(q)
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
rows = json.load(urllib.request.urlopen(req, timeout=60))
except Exception as e:
print(f" {source} offset {offset} error: {e}"); break
if not rows: break
vals = []
for r in rows:
m = mapper(r)
if not m.get("permit_number"): continue
pid = f"{source}-{m['permit_number']}"
cols = ["id","source","permit_number","address","city","state","zip","lat","lon","permit_type","work_class","valuation","status","filed_date","issued_date","applicant"]
row = [pid, source, m["permit_number"], m.get("address"), m.get("city"), m.get("state"), m.get("zip"),
m.get("lat"), m.get("lon"), m.get("permit_type"), m.get("work_class"), m.get("valuation"),
m.get("status"), m.get("filed_date"), m.get("issued_date"), m.get("applicant")]
vals.append("(" + ",".join(esc(x) for x in row) + ")")
if vals:
sql = ('insert into building_permits (id,source,permit_number,address,city,state,zip,lat,lon,permit_type,work_class,valuation,status,filed_date,issued_date,applicant) values '
+ ",".join(vals) + " on conflict (id) do nothing;")
subprocess.run(["psql", DBURL, "-q", "-c", sql], capture_output=True)
got += len(rows); offset += 1000
if len(rows) < 1000: break
print(f" {source}: fetched {got} new-residential permits")
total += got
print(f"total permit rows fetched: {total}")
if __name__ == "__main__":
main()