# ----------------------------------------------------------------------------- # Home-Ka — US real-estate aggregator (Groupe KA) # Author: Simon-Pierre Boucher — contact@spboucher.ai # geocode.py : US geocoding — Census Bureau batch geocoder (free, no key, # 10 000 addresses per request) with persistent cache, Nominatim fallback # on a small budget. Lowest-level source available, zero proxy. # ----------------------------------------------------------------------------- from __future__ import annotations import csv import io import time import requests from . import db CENSUS_BATCH = ("https://geocoding.geo.census.gov/geocoder/locations/" "addressbatch") NOMINATIM = "https://nominatim.openstreetmap.org/search" UA = "HomeKaBot/1.0 (+https://www.home-ka.com/bot; contact@spboucher.ai)" BATCH_SIZE = 5000 def _cache_key(street: str, city: str, state: str, zip_code: str) -> str: return f"{street}|{city}|{state}|{zip_code}".lower().strip() def run_batch(limit: int | None = None, nominatim_budget: int = 25) -> dict: """Geocode active listings without coordinates. 1) persistent cache; 2) Census batch (5k/req); 3) Nominatim fallback for the few that Census can't match (budgeted, 1 req/s politeness). """ con = db.connect() rows = con.execute( "SELECT uid, street_address, city, state, zip_code FROM listings" " WHERE active=1 AND lat IS NULL AND geocode_failed=0" " AND street_address<>'' AND (city<>'' OR zip_code<>'')" " LIMIT ?", (limit or 20000,)).fetchall() if not rows: con.close() return {"pending": 0, "geocoded": 0} hits = 0 todo = [] for r in rows: key = _cache_key(r["street_address"], r["city"], r["state"], r["zip_code"]) c = con.execute("SELECT lat, lng, failed FROM geocode_cache WHERE address=?", (key,)).fetchone() if c is not None: if c["lat"] is not None: con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", (c["lat"], c["lng"], r["uid"])) hits += 1 elif c["failed"]: con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", (r["uid"],)) else: todo.append(r) con.commit() geocoded = failed = 0 for i in range(0, len(todo), BATCH_SIZE): chunk = todo[i:i + BATCH_SIZE] results = _census_batch(chunk) now = time.time() unmatched = [] for r in chunk: key = _cache_key(r["street_address"], r["city"], r["state"], r["zip_code"]) hit = results.get(r["uid"]) if hit: lat, lng = hit con.execute( "INSERT OR REPLACE INTO geocode_cache (address, lat, lng," " provider, failed, ts) VALUES (?,?,?,?,0,?)", (key, lat, lng, "census", now)) con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", (lat, lng, r["uid"])) geocoded += 1 else: unmatched.append(r) # Nominatim fallback (budgeted, polite) for r in unmatched: key = _cache_key(r["street_address"], r["city"], r["state"], r["zip_code"]) if nominatim_budget > 0: nominatim_budget -= 1 hit = _nominatim(r) if hit: con.execute( "INSERT OR REPLACE INTO geocode_cache (address, lat," " lng, provider, failed, ts) VALUES (?,?,?,?,0,?)", (key, hit[0], hit[1], "nominatim", time.time())) con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", (hit[0], hit[1], r["uid"])) geocoded += 1 continue con.execute( "INSERT OR REPLACE INTO geocode_cache (address, lat, lng," " provider, failed, ts) VALUES (?, NULL, NULL, 'census', 1, ?)", (key, time.time())) con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", (r["uid"],)) failed += 1 con.commit() print(f"[home-ka] geocode: batch {i // BATCH_SIZE + 1} — " f"{geocoded} geocoded, {failed} failed") con.close() return {"pending": len(rows), "cache_hits": hits, "geocoded": geocoded, "failed": failed} def _census_batch(rows) -> dict[str, tuple[float, float]]: """POST a CSV batch to the Census geocoder; {uid: (lat, lng)}.""" buf = io.StringIO() w = csv.writer(buf) for r in rows: w.writerow([r["uid"], r["street_address"], r["city"], r["state"], r["zip_code"]]) try: resp = requests.post( CENSUS_BATCH, files={"addressFile": ("batch.csv", buf.getvalue().encode(), "text/csv")}, data={"benchmark": "Public_AR_Current"}, headers={"User-Agent": UA}, timeout=300) resp.raise_for_status() except requests.RequestException as exc: print(f"[home-ka] geocode: census batch failed: {exc}") return {} out: dict[str, tuple[float, float]] = {} for row in csv.reader(io.StringIO(resp.text)): # id, input, match, exact, matched addr, "lng,lat", tigerline, side if len(row) >= 6 and row[2].strip().lower() == "match" and row[5]: try: lng, lat = (float(v) for v in row[5].split(",")) out[row[0]] = (lat, lng) except ValueError: continue return out def _nominatim(r) -> tuple[float, float] | None: q = ", ".join(filter(None, (r["street_address"], r["city"], r["state"], r["zip_code"], "USA"))) try: time.sleep(1.1) # Nominatim usage policy resp = requests.get(NOMINATIM, params={ "q": q, "format": "json", "limit": 1, "countrycodes": "us"}, headers={"User-Agent": UA}, timeout=30) resp.raise_for_status() data = resp.json() if data: return float(data[0]["lat"]), float(data[0]["lon"]) except (requests.RequestException, ValueError, KeyError, IndexError): pass return None