← back to Rentv Sheet Enrich Refine

split_names.py

105 lines

#!/usr/bin/env python3
"""
split_names.py — add "First Name" + "Last Name" columns to EVERY tab that has a
"Contact Name" column, parsed from that name. Quota-safe: writes each tab's two columns
as ONE values-range update (not per-cell) + ONE green-format call = 2 API calls/tab, so
it never trips the 60-writes/min Sheets limit. Handles "First Last", "First M. Last",
"Last, First"; skips non-person rows. Columns appended at the right (non-destructive).
Runs tabs with light parallelism + 429 retry. ADD-only: won't clobber existing values.
"""
import lib, re, time, urllib.request, urllib.error
from concurrent.futures import ThreadPoolExecutor

SUFFIX = {"jr","sr","ii","iii","iv","cpa","esq","mba","phd","mai","ccim"}
GREEN = lib.GREEN
NAME_LABELS = ["Contact Name", "Contact Name(s)", "Contact Person", "Full Name", "Contact", "Name", "Client"]

def split(name):
    name = (name or "").strip()
    if not name or not name[0].isalpha(): return "", ""
    # these tabs are "First Last"; a comma/&/;/"and" separates MULTIPLE people -> take the first
    name = re.split(r'\s*(?:[,;&/]|\band\b)\s*', name)[0].strip()
    toks = [t for t in name.split() if t]
    while len(toks) > 1 and re.sub(r'[^a-z]', '', toks[-1].lower()) in SUFFIX: toks.pop()
    if not toks: return "", ""
    if len(toks) == 1: return toks[0], ""
    return toks[0], toks[-1]

def find_header(rows):
    """Return (header_row_index, name_col_index) or (None, None)."""
    for hr in range(min(5, len(rows))):
        row = [c.strip() for c in rows[hr]]
        for lbl in NAME_LABELS:
            if lbl in row:
                return hr, row.index(lbl)
    return None, None

def req(method, url, tok, body, tries=5):
    for a in range(tries):
        try:
            r = urllib.request.Request(url, data=(None if body is None else __import__("json").dumps(body).encode()),
                method=method, headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"})
            return __import__("json").load(urllib.request.urlopen(r))
        except urllib.error.HTTPError as e:
            if e.code == 429 and a < tries-1:
                time.sleep(8 * (a+1)); continue
            raise RuntimeError(f"{e.code}: {e.read().decode()[:300]}")

def process(tok, title, gid):
    rows = lib.read_tab(tok, title)
    if not rows: return (title, "empty")
    hr, NAME = find_header(rows)
    if hr is None: return (title, "skip (no name column)")
    hdr = [h.strip() for h in rows[hr]]
    def g(r, i): return (r[i] if i < len(r) else "").strip()
    right = max((i for i, v in enumerate(hdr) if v.strip()), default=0)
    existF = hdr.index("First Name") if "First Name" in hdr else None
    existL = hdr.index("Last Name") if "Last Name" in hdr else None
    FN = existF if existF is not None else right + 1
    LN = existL if existL is not None else (FN + 1)
    # ensure the grid is wide enough (expand columns if needed) before writing
    width = max(len(r) for r in rows)
    need = LN + 1
    if need > width:
        req("POST", f"{lib.API}/{lib.SID}:batchUpdate", tok, {"requests": [{"appendDimension": {
            "sheetId": gid, "dimension": "COLUMNS", "length": need - width}}]})
    # build full-length columns: blanks above header, header at hr, split values below
    fcol = [[""] for _ in range(len(rows))]; lcol = [[""] for _ in range(len(rows))]
    fcol[hr] = ["First Name"]; lcol[hr] = ["Last Name"]
    filled = 0
    for ri in range(hr + 1, len(rows)):
        first, last = split(g(rows[ri], NAME))
        if existF is not None and g(rows[ri], existF): first = g(rows[ri], existF)  # ADD-only
        if existL is not None and g(rows[ri], existL): last = g(rows[ri], existL)
        fcol[ri] = [first]; lcol[ri] = [last]
        filled += (1 if first else 0) + (1 if last else 0)
    fL, lL = lib.col_letter(FN), lib.col_letter(LN)
    req("POST", f"{lib.API}/{lib.SID}/values:batchUpdate", tok, {
        "valueInputOption": "RAW",
        "data": [
            {"range": f"'{title}'!{fL}1:{fL}{len(fcol)}", "values": fcol},
            {"range": f"'{title}'!{lL}1:{lL}{len(lcol)}", "values": lcol},
        ]})
    def greenreq(col):
        return {"repeatCell": {
            "range": {"sheetId": gid, "startRowIndex": hr, "endRowIndex": len(rows),
                      "startColumnIndex": col, "endColumnIndex": col+1},
            "cell": {"userEnteredFormat": {"backgroundColor": GREEN}},
            "fields": "userEnteredFormat.backgroundColor"}}
    req("POST", f"{lib.API}/{lib.SID}:batchUpdate", tok, {"requests": [greenreq(FN), greenreq(LN)]})
    return (title, f"header@row{hr} '{hdr[NAME]}' -> split {len(rows)-1-hr} rows ({filled} cells)")

def main():
    tok = lib.access_token()
    sheets = [(s["properties"]["title"], s["properties"]["sheetId"]) for s in lib.get_meta(tok)["sheets"]]
    def safe(ts):
        try: return process(tok, ts[0], ts[1])
        except Exception as e: return (ts[0], f"ERROR {e}")
    with ThreadPoolExecutor(max_workers=3) as ex:   # 3 tabs at a time * 2 calls = well under 60/min
        results = list(ex.map(safe, sheets))
    for title, msg in results:
        print(f"  {title[:36]:36s} -> {msg}")

if __name__ == "__main__":
    main()