[object Object]

← back to Rentv Sheet Enrich Refine

VP wave vp_10-15: +24 marketing VPs (118/1734 cos); split_mvp.py reusable splitter; Cody-gated scope caveats on 4 enterprise/hybrid entries

1576324f3f6002b450ccd986d962265e781a8593 · 2026-08-13 12:11:45 -0700 · Steve Abrams

Files touched

Diff

commit 1576324f3f6002b450ccd986d962265e781a8593
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 13 12:11:45 2026 -0700

    VP wave vp_10-15: +24 marketing VPs (118/1734 cos); split_mvp.py reusable splitter; Cody-gated scope caveats on 4 enterprise/hybrid entries
---
 run_ocfallback.sh |  4 +++
 split_mvp.py      | 65 ++++++++++++++++++++++++++++++++++++++++++++
 team_vp.py        | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 web_li_scrape.py  | 59 ++++++++++++++++++++++++++++++++++++++++
 4 files changed, 209 insertions(+)

diff --git a/run_ocfallback.sh b/run_ocfallback.sh
new file mode 100644
index 0000000..1e2c23e
--- /dev/null
+++ b/run_ocfallback.sh
@@ -0,0 +1,4 @@
+cd ~/Projects/rentv-sheet-enrich
+python3 local_li.py data/missing_li.json >> run_ocfallback.log 2>&1
+python3 propagate.py >> run_ocfallback.log 2>&1
+echo "OC FALLBACK DONE $(date +%H:%M:%S)" >> run_ocfallback.log
diff --git a/split_mvp.py b/split_mvp.py
new file mode 100644
index 0000000..223728d
--- /dev/null
+++ b/split_mvp.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""
+split_mvp.py — parse the packed "Marketing/VP Contact" cell (col 18), format
+    "Name — Title — https://linkedin.com/in/<slug>"  (em-dash separated)
+into the three split columns so coverage/sort read clean fields:
+    22 Marketing/VP Name · 23 Marketing/VP Position · 24 Marketing/VP LinkedIn (HYPERLINK)
+Only fills split cells that are EMPTY (never overwrites — provenance = ADD-only).
+LinkedIn written as a green =HYPERLINK() live link. Run after every VP wave.
+"""
+import lib, re
+GID = 3823360
+DASH = re.compile(r"\s+—\s+|\s+-\s+")  # em-dash primary, hyphen fallback
+LI = re.compile(r"https?://[^\s]*linkedin\.com/in/[^\s]+", re.I)
+
+def parse(packed):
+    """Return (name, title, li_url) from a packed Marketing/VP Contact value."""
+    packed = packed.strip()
+    if not packed:
+        return None
+    li = ""
+    m = LI.search(packed)
+    if m:
+        li = m.group(0).rstrip("/.,)")
+        packed = packed[:m.start()].strip().rstrip("—-").strip()
+    parts = [p.strip() for p in DASH.split(packed) if p.strip()]
+    name = parts[0] if parts else ""
+    title = " ".join(parts[1:]) if len(parts) > 1 else ""
+    if not name:
+        return None
+    return name, title, li
+
+def main():
+    tok = lib.access_token()
+    title = {s["properties"]["sheetId"]: s["properties"]["title"]
+             for s in lib.get_meta(tok)["sheets"]}[GID]
+    rows = lib.read_tab(tok, title)
+    hdr = rows[0]
+    c = {v.strip(): j for j, v in enumerate(hdr) if v.strip()}
+    PACK = c["Marketing/VP Contact"]; NAME = c["Marketing/VP Name"]
+    POS = c["Marketing/VP Position"]; LIC = c["Marketing/VP LinkedIn"]
+    def g(r, i): return (r[i] if i < len(r) else "").strip()
+    cells = []; n = 0
+    for r in range(1, len(rows)):
+        packed = g(rows[r], PACK)
+        if not packed:
+            continue
+        pr = parse(packed)
+        if not pr:
+            continue
+        name, tit, li = pr
+        if name and not g(rows[r], NAME):
+            cells.append({"row0": r, "col0": NAME, "value": name})
+        if tit and not g(rows[r], POS):
+            cells.append({"row0": r, "col0": POS, "value": tit})
+        if li and not g(rows[r], LIC):
+            cells.append({"row0": r, "col0": LIC,
+                          "value": f'=HYPERLINK("{li}","LinkedIn")', "formula": True})
+        n += 1
+    print(f"{n} packed Marketing/VP rows scanned -> {len(cells)} split cells to write")
+    if cells:
+        lib.batch_fill(tok, GID, cells)
+    print("done")
+
+if __name__ == "__main__":
+    main()
diff --git a/team_vp.py b/team_vp.py
new file mode 100644
index 0000000..925e9d8
--- /dev/null
+++ b/team_vp.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+"""
+team_vp.py — $0 LOCAL Marketing/VP finder. All local: headless urllib fetch of a company's
+own Team/Leadership/About pages + Muse (glimmer, local Ollama) to EXTRACT the marketing
+lead from the REAL page text. Anti-fabrication: the extracted name must literally appear
+in the fetched text, else dropped. No Browserbase, no Exa, no cloud.
+
+Usage: team_vp.py [limit]   (processes companies that have a Website but no Marketing/VP Name)
+"""
+import lib, json, re, os, ssl, sys, urllib.request
+from concurrent.futures import ThreadPoolExecutor
+GID=3823360
+MODEL=os.environ.get("VP_MODEL","muse-glimmer:30b-mlx")
+ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
+UA={"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36"}
+MKT=re.compile(r'(chief marketing|vp[, ]+marketing|vice president[, ]+marketing|marketing (director|manager|officer)|head of marketing|director[, ]+marketing|marketing & communications|communications director)',re.I)
+TAG=re.compile(r'<[^>]+>')
+def fetch(u):
+    try: return urllib.request.urlopen(urllib.request.Request(u,headers=UA),timeout=8,context=ctx).read(400000).decode("utf-8","ignore")
+    except Exception: return ""
+def team_text(domain):
+    txt=""
+    for p in ("/team","/leadership","/our-team","/about","/about-us","/people","/management","/company","/who-we-are"):
+        for sch in ("https://","http://"):
+            h=fetch(sch+domain+p)
+            if h and MKT.search(h): txt+=" "+TAG.sub(" ",h); break
+    return re.sub(r'\s+',' ',txt)[:6000]
+def ask(prompt):
+    body=json.dumps({"model":MODEL,"prompt":prompt,"stream":False,"options":{"temperature":0}}).encode()
+    try:
+        r=urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate",data=body,headers={"Content-Type":"application/json"}),timeout=90)
+        return json.load(r).get("response","")
+    except Exception: return ""
+def extract_vp(domain, text):
+    if not text: return None
+    p=(f"From this company web-page text, find the ONE person whose title is a marketing role "
+       f"(Chief Marketing Officer / VP Marketing / Marketing Director / Marketing Manager / Head of Marketing / Communications Director). "
+       f'Reply ONLY compact JSON: {{"name":"Full Name","title":"Their Title"}} — or {{}} if no marketing person is named. '
+       f"Use ONLY a name that appears verbatim in the text. Do not invent.\n\nTEXT: {text}")
+    out=ask(p)
+    m=re.search(r'\{[^{}]*\}',out)
+    if not m: return None
+    try: d=json.loads(m.group(0))
+    except Exception: return None
+    name=(d.get("name") or "").strip()
+    if not name or name.lower() not in text.lower(): return None   # verify-in-fetch guard
+    return {"name":name,"title":(d.get("title") or "").strip()}
+
+def main():
+    limit=int(sys.argv[1]) if len(sys.argv)>1 else 9999
+    tok=lib.access_token()
+    title={s["properties"]["sheetId"]:s["properties"]["title"] for s in lib.get_meta(tok)["sheets"]}[GID]
+    rows=lib.read_tab(tok,title);hdr=rows[0]
+    c={v.strip():j for j,v in enumerate(hdr) if v.strip()}
+    def g(r,i): return (r[i] if i<len(r) else "").strip()
+    wi=c['Website']; ni=c['Marketing/VP Name']; cvi=c['Company/Venue']
+    # unique companies with website but no marketing name yet
+    seen=set(); todo=[]
+    for r in range(1,len(rows)):
+        comp=g(rows[r],cvi); dom=g(rows[r],wi)
+        k=comp.lower()
+        if not comp or not dom or k in seen or g(rows[r],ni): continue
+        seen.add(k); todo.append((comp,dom))
+    todo=todo[:limit]
+    print(f"{len(todo)} companies to try (model={MODEL})",flush=True)
+    # fetch team text concurrently, then extract sequentially (LLM is the bottleneck)
+    with ThreadPoolExecutor(max_workers=24) as ex:
+        texts=list(ex.map(lambda cd: team_text(cd[1]), todo))
+    res=[]; done=0
+    for (comp,dom),text in zip(todo,texts):
+        vp=extract_vp(dom,text); done+=1
+        if vp:
+            res.append({"company":comp,"mvp":f"{vp['name']} — {vp['title']} (from {dom})",
+                        "why":f"Marketing lead named on {dom} team page (local glimmer extract, verified)"})
+            print(f"  {comp[:30]:30s} -> {vp['name']} ({vp['title']})",flush=True)
+        if done%25==0: print(f"  ...{done}/{len(todo)}",flush=True)
+    out="/tmp/team_vp.json"; json.dump(res,open(out,"w"))
+    print(f"{len(res)} marketing leads found",flush=True)
+    if res:
+        import subprocess; subprocess.run(["python3",os.path.join(os.path.dirname(__file__),"write_by_name.py"),str(GID),out])
+if __name__=="__main__": main()
diff --git a/web_li_scrape.py b/web_li_scrape.py
new file mode 100644
index 0000000..08bc2ba
--- /dev/null
+++ b/web_li_scrape.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+"""
+web_li_scrape.py — $0 HEADLESS concurrent LinkedIn-company crawler.
+Fetches each company's own website (homepage + a few common pages) and extracts the
+linkedin.com/company/<slug> link it publishes (footer/social bar). Real + truthful
+(the company itself linked it), fast (threads), no Google/bot-block, no rate limit.
+Saves data/web_li.json {domain: linkedin_company_url}.
+"""
+import lib, json, re, os, ssl, urllib.request
+from concurrent.futures import ThreadPoolExecutor
+GID=3823360
+ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
+UA={"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36"}
+LIC=re.compile(r'linkedin\.com/company/([A-Za-z0-9\-\_%\.]+)', re.I)
+def fetch(u):
+    try: return urllib.request.urlopen(urllib.request.Request(u,headers=UA),timeout=8,context=ctx).read(300000).decode("utf-8","ignore")
+    except Exception: return ""
+def li_of(domain):
+    for path in ("","/contact","/about","/about-us","/team","/company","/contact-us"):
+        for sch in ("https://","http://"):
+            html=fetch(sch+domain+path)
+            if not html: continue
+            m=LIC.search(html)
+            if m:
+                slug=m.group(1).rstrip("/").split("?")[0]
+                if slug.lower() not in ("unavailable","company"): return "https://www.linkedin.com/company/"+slug
+        if path=="": pass
+    return ""
+def main():
+    tok=lib.access_token()
+    title={s["properties"]["sheetId"]:s["properties"]["title"] for s in lib.get_meta(tok)["sheets"]}[GID]
+    rows=lib.read_tab(tok,title);hdr=rows[0]
+    wi=hdr.index("Website"); qi=hdr.index("LinkedIn (Company)")
+    def g(r,i): return (r[i] if i<len(r) else "").strip()
+    # domains that still lack a company LinkedIn
+    domains=sorted({g(rows[r],wi) for r in range(1,len(rows)) if g(rows[r],wi) and not g(rows[r],qi)})
+    print(f"{len(domains)} domains to crawl for LinkedIn",flush=True)
+    cache=os.path.join(os.path.dirname(__file__),"data","web_li.json")
+    out=json.load(open(cache)) if os.path.exists(cache) else {}
+    todo=[d for d in domains if d not in out]
+    done=0
+    with ThreadPoolExecutor(max_workers=40) as ex:
+        for d,li in zip(todo, ex.map(li_of, todo)):
+            out[d]=li; done+=1
+            if done%50==0:
+                json.dump(out,open(cache,"w")); found=sum(1 for v in out.values() if v)
+                print(f"  {done}/{len(todo)} crawled | {found} LinkedIns",flush=True)
+    json.dump(out,open(cache,"w"))
+    print(f"DONE crawl: {sum(1 for v in out.values() if v)} LinkedIns / {len(out)} domains",flush=True)
+    # write to master by matching each row's domain -> its LinkedIn
+    rows=lib.read_tab(tok,title)
+    cells=[]
+    for r in range(1,len(rows)):
+        dom=g(rows[r],wi); li=out.get(dom)
+        if dom and li and not g(rows[r],qi):
+            cells.append({"row0":r,"col0":qi,"value":'=HYPERLINK("%s")'%li,"formula":True})
+    res=lib.batch_fill(tok,GID,cells)
+    print(f"wrote {res.get('totalUpdatedCells',0)} company-LinkedIn cells to master",flush=True)
+if __name__=="__main__": main()

← 4f042f7 auto-data-snapshot: 2026-08-13T11:55:54 (59 data files) — VP  ·  back to Rentv Sheet Enrich Refine  ·  openclaw company-LI grind (oc_run.sh + 669 untried has-websi 9f7bbd0 →