batch_enrich.py
Language Python7.0 KB163 lines
#!/usr/bin/env python3"""batch_enrich.py: deterministic batch write-back for client-enrich (posts + frontmatter mode).Takes a list of stubs and a flat list of LinkedIn company posts, groups posts bycompany slug, auto-selects the most outreach-relevant ones, builds a Researchpayload per stub (Person from existing frontmatter, Company minimal, Signals fromposts, Sources), and calls enrich_writeback.py for each stub so the vault contractis enforced (writes only under ## Research, never Human Notes).Usage:python3 batch_enrich.py --batch checkin_batch.json --posts posts.json \--stubdir "/path/to/03-Clients/Check-In" --script "/path/to/enrich_writeback.py" [--date YYYY-MM-DD]checkin_batch.json: [{file,email,linkedin,company,company_li,slug,title,seniority,city,state}, ...]posts.json: [{targetUrl, content, date, type, authorName, authorType, repostedByName, linkedinUrl}, ...]"""import argparse, json, os, re, subprocess, sys, datetime, tempfileREL_KEYWORDS = ["launch", "launching", "introducing", "new product", "now available", "now at","hiring", "we're hiring", "join our team", "join us", "open role", "opening","opened", "grand opening", "expansion", "expanding", "expand", "new location","event", "festival", "conference", "expo", "trade show", "tradeshow", "booth","sponsor", "sponsoring", "award", "awarded", "won", "ranked", "honored","milestone", "anniversary", "celebrat", "partnership", "partnered", "partner with","funding", "raised", "series a", "series b", "series c", "acquisition", "acquire","store", "retail", "ribbon", "exhibit", "summit", "kickoff", "unveil", "debut",]def slug_of(url):m = re.search(r'/company/([^/?]+)', url or "")return m.group(1).lower() if m else ""def short_date(iso):try:return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).strftime("%b %Y")except Exception:return (iso or "")[:7]def one_line(text, limit=190):t = re.sub(r'\s+', ' ', (text or "").strip())if len(t) > limit:cut = t[:limit]# try to end on sentence/word boundaryfor sep in (". ", "! ", "? "):i = cut.rfind(sep)if i > 60:return cut[:i+1].strip()return cut.rsplit(" ", 1)[0].strip() + "…"return tdef score(post, company_name):c = (post.get("content") or "").lower()s = 0for kw in REL_KEYWORDS:if kw in c:s += 3# prefer company-authored over reposts / individual profilesif (post.get("authorType") == "company"):s += 4if post.get("type") == "post":s += 1if post.get("repostedByName"):s -= 1# mild recency tiebreak handled by sort on dateif not c.strip():s -= 5return sdef select_posts(posts, company_name, n=3):ranked = sorted(posts, key=lambda p: (score(p, company_name), p.get("date") or ""), reverse=True)out = [p for p in ranked if (p.get("content") or "").strip()][:n]return outdef main():ap = argparse.ArgumentParser()ap.add_argument("--batch", required=True)ap.add_argument("--posts", required=True)ap.add_argument("--stubdir", required=True)ap.add_argument("--script", required=True)ap.add_argument("--date", default=datetime.date.today().isoformat())args = ap.parse_args()batch = json.load(open(args.batch, encoding="utf-8"))raw = json.load(open(args.posts, encoding="utf-8"))# accept: flat list of normalized dicts, raw harvestapi items, or {items:[...]}if isinstance(raw, dict) and "items" in raw:raw = raw["items"]posts = []for p in raw:if "targetUrl" in p: # already normalizedposts.append(p); continueposts.append({"targetUrl": (p.get("query") or {}).get("targetUrl", ""),"content": p.get("content", ""),"date": (p.get("postedAt") or {}).get("date", ""),"type": p.get("type", ""),"authorName": (p.get("author") or {}).get("name", ""),"authorType": (p.get("author") or {}).get("type", ""),"repostedByName": (p.get("repostedBy") or {}).get("name", ""),"linkedinUrl": p.get("linkedinUrl", ""),})by_slug = {}for p in posts:s = slug_of(p.get("targetUrl"))if s:by_slug.setdefault(s, []).append(p)summary = []for r in batch:stub_path = os.path.join(args.stubdir, r["file"])# Person from existing frontmatter (free, no API)person = []if r.get("title"): person.append(f"- Title: {r['title']}")if r.get("seniority"): person.append(f"- Seniority: {r['seniority']}")loc = ", ".join([x for x in [r.get("city"), r.get("state")] if x])if loc: person.append(f"- Location: {loc}")if r.get("linkedin"): person.append(f"- LinkedIn: {r['linkedin']}")person = person or ["[TBD]"]# Company minimal (Apollo skipped this run)company = []if r.get("company"): company.append(f"- {r['company']}")if r.get("company_li"): company.append(f"- LinkedIn: {r['company_li']}")company.append("- [TBD: firmographics (revenue/headcount/funding) not pulled, Apollo skipped this run]")# Signals from postsslug = r.get("slug", "")chosen = select_posts(by_slug.get(slug, []), r.get("company", ""), n=3) if slug else []signals, sources = [], []for p in chosen:signals.append(f"- {short_date(p.get('date'))}: {one_line(p.get('content'))}")if p.get("linkedinUrl"):sources.append(f"- LinkedIn post ({short_date(p.get('date'))}): {p['linkedinUrl']}")if not signals:signals = ["[TBD: no recent company LinkedIn posts found in last 3 months]"]sources.append(f"- Company LinkedIn posts via Apify harvestapi/linkedin-company-posts, {args.date}")sources.append(f"- Stub frontmatter (prior enrichment)")payload = {"frontmatter": {}, "person": person, "company": company,"signals": signals, "sources": sources}with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tf:json.dump(payload, tf)pjson = tf.nameres = subprocess.run([sys.executable, args.script, "--stub", stub_path,"--payload", pjson, "--date", args.date],capture_output=True, text=True)os.unlink(pjson)ok = res.returncode == 0summary.append((r["file"], len([s for s in signals if not s.startswith("[TBD")]), ok,res.stderr.strip()[:120]))good = sum(1 for _,_,ok,_ in summary if ok)withsig = sum(1 for _,n,_,_ in summary if n > 0)print(f"WROTE {good}/{len(summary)} stubs OK; {withsig} have >=1 real signal.")print("\nstub | signals | status")for f, n, ok, err in summary:print(f" {f[:34]:36} | {n} | {'OK' if ok else 'FAIL: '+err}")if __name__ == "__main__":main()