← 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
A run_ocfallback.shA split_mvp.pyA team_vp.pyA web_li_scrape.py
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 →