← back to Marketing Command Center

scripts/backfill-address-from-csv.py

120 lines

#!/usr/bin/env python3
"""
backfill-address-from-csv.py — enrich staged prospect JSONs with the full
address / state / category fields that the email crawler dropped at ingest.

The email crawler (crawl-csv-emails.py) only kept title/city/phone/website/
email from the raw Google-Places CSV. This backfills the richer location
fields straight from the same source CSV, keyed by website host (fallback:
title|city), WITHOUT disturbing the expensive email / instagram / linkedin
enrichment already in the JSON.

  python3 scripts/backfill-address-from-csv.py            # all prospect groups
  python3 scripts/backfill-address-from-csv.py --dry-run  # report only

Each prospects-<slug>.json records its own `source` CSV basename, which we
resolve against data/sources/. Groups whose source CSV is missing are skipped
and reported. Nothing is uploaded to Constant Contact — local staging only.
"""
import csv, sys, os, re, json, glob, argparse, urllib.parse

csv.field_size_limit(10_000_000)
HERE = os.path.dirname(__file__)
SRC_DIR = os.path.join(HERE, "..", "data", "sources")
DATA_DIR = os.path.join(HERE, "..", "public", "data")

# CSV column -> JSON field. Only filled if the JSON value is empty (non-destructive).
FIELD_MAP = {
    "address": "address",            # full formatted address, e.g. "970 W 190th St Ste 250, Torrance, CA 90502"
    "state": "state",
    "postalCode": "postal",
    "neighborhood": "neighborhood",
    "categoryName": "category",
    "url": "mapsUrl",                # Google Maps place URL
}

def host_of(u):
    u = (u or "").strip()
    if not u:
        return ""
    try:
        return urllib.parse.urlparse(u if u.startswith(("http://", "https://")) else "http://" + u).netloc.lower().replace("www.", "")
    except Exception:
        return ""

def key_of(rec):
    return host_of(rec.get("website")) or ((rec.get("title") or "").strip().lower() + "|" + (rec.get("city") or "").strip().lower())

def build_lut(csv_path):
    lut = {}
    with open(csv_path, newline="", encoding="utf-8", errors="replace") as fh:
        for row in csv.DictReader(fh):
            k = key_of(row)
            if k and k not in lut:
                lut[k] = row
    return lut

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--glob", default="prospects-*.json")
    a = ap.parse_args()

    srcmap = {os.path.basename(f): f for f in glob.glob(os.path.join(SRC_DIR, "*.csv"))}

    # Global host->row fallback across EVERY present CSV (rows with an address).
    # Used when a group's own source CSV is missing, or to fill records absent
    # from their own crawl. LA firms overlap across the architect/ID lists.
    glut = {}
    for f in srcmap.values():
        with open(f, newline="", encoding="utf-8", errors="replace") as fh:
            for row in csv.DictReader(fh):
                k = host_of(row.get("website"))
                if k and k not in glut and (row.get("address") or "").strip():
                    glut[k] = row

    total_files = total_matched = 0

    for jf in sorted(glob.glob(os.path.join(DATA_DIR, a.glob))):
        with open(jf) as _jf:
            d = json.load(_jf)
        biz = d.get("businesses", [])
        src = d.get("source")
        path = srcmap.get(src)
        base = os.path.basename(jf)
        lut = build_lut(path) if path else {}
        via = "own CSV" if path else "cross-CSV fallback (own source missing)"
        matched = 0
        for b in biz:
            r = lut.get(key_of(b)) or glut.get(host_of(b.get("website")))
            if not r:
                continue
            got = False
            for col, field in FIELD_MAP.items():
                val = (r.get(col) or "").strip()
                if val and not (b.get(field) or "").strip():
                    b[field] = val
                    got = True
            # fill phone if the crawler had none but Places did
            if not (b.get("phone") or "").strip() and (r.get("phone") or "").strip():
                b["phone"] = r["phone"].strip()
                got = True
            # a machine-diallable phone for tel: links
            if not (b.get("phoneUnformatted") or "").strip() and (r.get("phoneUnformatted") or "").strip():
                b["phoneUnformatted"] = r["phoneUnformatted"].strip()
            if got:
                matched += 1
        with_addr = sum(1 for b in biz if (b.get("address") or "").strip())
        d["with_address"] = with_addr
        total_files += 1
        total_matched += matched
        print(f"{'· ' if a.dry_run else '✓ '}{base:44s} {matched:5d} enriched · {with_addr:5d}/{len(biz):5d} now have address  [{via}]")
        if not a.dry_run:
            with open(jf, "w") as fh:
                json.dump(d, fh)

    print(f"\n{'DRY-RUN — no files written. ' if a.dry_run else ''}{total_files} groups, {total_matched} records enriched.  [$0 local]")

if __name__ == "__main__":
    main()