| LLM scoring | gpt-5.4-mini | OpenAI Responses API | 15-60 parallel workers |
| People search | Apollo | POST /api/v1/mixed_people/api_search | 100 domains/call, 429→60s sleep |
| People enrich | Apollo | POST /api/v1/people/bulk_match | Batches of 10 |
| LinkedIn profiles | Apify supreme_coder/linkedin-profile-scraper | Apify Actor API | 60 workers, token rotation |
| LinkedIn posts | Apify LQQIXN9Othf8f7R5n | Apify Actor API | 60 workers, token rotation |
| LinkedIn people | Serper | POST google.serper.dev/search | 50 calls/sec |
| LinkedIn companies | Serper | POST google.serper.dev/search | 50 calls/sec |
| Company domains | Serper | POST google.serper.dev/search | 50 calls/sec |
| Company news | Serper | POST google.serper.dev/news | 50 calls/sec |
This is a complete, self-contained Python script that runs the post-TAL pipeline (Apollo → Findymail → Score → Restaurants → XLSX). The TAL discovery step (Section 6) uses Claude Code agents and runs before this script.
#!/usr/bin/env python3
"""
Dinner Play Pipeline — runs after TAL is built.
Input: phase1/companies_unified.csv (from TAL discovery agents)
Output: 3 XLSX files in output/
"""
import csv, os, json, time, threading, requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from collections import Counter
from urllib.parse import urlparse
from datetime import datetime, timedelta
from openai import OpenAI
from pydantic import BaseModel
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
# ===== CONFIG (edit these per campaign) =====
CAMPAIGN_NAME = "cq.CHANGEME.YYYY-MM-DD"
IDEA = "CHANGEME — one sentence describing the target"
THEME = "CHANGEME — dinner discussion topic"
DINNER_DATE = "YYYY-MM-DD" # Must be Tue/Wed/Thu
TITLES = ["Marketing", "Demand Gen", "ABM", "Growth", "Digital Marketing"]
SENIORITIES = ["c_suite", "vp"]
# ===== API KEYS =====
OPENAI_API_KEY = "<your-openai-api-key>"
APOLLO_API_KEY = "<your-apollo-api-key>"
FINDYMAIL_API_KEY = "<your-findymail-api-key>"
SERPER_API_KEY = "<your-serper-api-key>"
BASE = f"campaigns/{CAMPAIGN_NAME}"
os.makedirs(f"{BASE}/phase1", exist_ok=True)
os.makedirs(f"{BASE}/phase2", exist_ok=True)
os.makedirs(f"{BASE}/output", exist_ok=True)
# ===== STEP 1: Load TAL =====
print("=" * 60)
print("STEP 1: Load TAL")
tal = []
with open(f"{BASE}/phase1/companies_unified.csv", "r") as f:
for row in csv.DictReader(f):
tal.append(row)
domains = [r["domain"] for r in tal if r.get("domain")]
print(f" {len(tal)} companies, {len(domains)} domains")
# ===== STEP 2: Apollo Search (US only) =====
print("=" * 60)
print("STEP 2: Apollo Search (US only)")
all_people = []
for i in range(0, len(domains), 100):
batch = domains[i:i+100]
page = 1
while True:
resp = requests.post("https://api.apollo.io/api/v1/mixed_people/api_search",
headers={"x-api-key": APOLLO_API_KEY, "Content-Type": "application/json"},
json={"q_organization_domains_list": batch, "person_titles": TITLES,
"include_similar_titles": True, "person_seniorities": SENIORITIES,
"person_locations": ["United States"], "page": page, "per_page": 100})
if resp.status_code == 429: time.sleep(60); continue
if resp.status_code != 200: break
data = resp.json(); people = data.get("people", [])
if not people: break
all_people.extend(people)
if len(all_people) >= data.get("total_entries", 0) or page >= 10: break
page += 1; time.sleep(0.3)
print(f" {len(all_people)} people found")
# ===== STEP 3: Apollo Enrich =====
print("=" * 60)
print("STEP 3: Apollo Enrich")
enriched = []; credits = 0
search_rows = []
for p in all_people:
org = p.get("organization", {}) or {}
search_rows.append({"first_name": p.get("first_name",""), "last_name": p.get("last_name",""),
"title": p.get("title",""), "organization": org.get("name",""),
"domain": org.get("primary_domain",""), "apollo_id": p.get("id",""),
"linkedin_url": p.get("linkedin_url",""), "email": p.get("email",""),
"city": "", "state": "", "country": "", "seniority": ""})
for i in range(0, len(search_rows), 10):
batch = search_rows[i:i+10]
details = [{"id": p["apollo_id"]} if p.get("apollo_id") else
{"first_name": p["first_name"], "last_name": p["last_name"], "domain": p["domain"]}
for p in batch]
resp = requests.post("https://api.apollo.io/api/v1/people/bulk_match",
headers={"x-api-key": APOLLO_API_KEY, "Content-Type": "application/json"},
params={"reveal_personal_emails": "false", "reveal_phone_number": "false"},
json={"details": details}, timeout=30)
if resp.status_code == 429: time.sleep(60); continue
if resp.status_code != 200:
if "insufficient credits" in resp.text.lower(): print(f" OUT OF CREDITS at {len(enriched)}"); break
for p in batch: enriched.append(p); continue
data = resp.json(); credits += data.get("credits_consumed", 0)
for j, m in enumerate(data.get("matches", [])):
if m is None: enriched.append(search_rows[i+j]); continue
org = m.get("organization", {}) or {}
enriched.append({"first_name": m.get("first_name",""), "last_name": m.get("last_name",""),
"title": m.get("title",""), "seniority": m.get("seniority",""),
"organization": org.get("name",""), "domain": org.get("primary_domain",""),
"email": m.get("email",""), "email_status": m.get("email_status",""),
"linkedin_url": m.get("linkedin_url",""), "city": m.get("city",""),
"state": m.get("state",""), "country": m.get("country",""),
"apollo_id": m.get("id","")})
if (i//10+1) % 20 == 0: print(f" {len(enriched)}/{len(search_rows)} enriched, {credits} credits")
time.sleep(0.2)
print(f" {len(enriched)} enriched, {credits} credits")
# ===== STEP 4: Findymail fill + bounce =====
print("=" * 60)
print("STEP 4: Findymail email fill + bounce check")
fm_h = {"Authorization": f"Bearer {FINDYMAIL_API_KEY}", "Content-Type": "application/json"}
# Fill missing emails
missing = [p for p in enriched if not p.get("email")]
found = 0
for p in missing:
payload = ({"linkedin_url": p["linkedin_url"]} if p.get("linkedin_url") else
{"first_name": p["first_name"], "last_name": p["last_name"], "domain": p["domain"]}
if p.get("first_name") and p.get("domain") else None)
if not payload: continue
try:
r = requests.post("https://app.findymail.com/api/search/mail", headers=fm_h, json=payload, timeout=30)
if r.status_code == 200:
email = r.json().get("contact", {}).get("email")
if email: p["email"] = email; found += 1
except: pass
time.sleep(0.1)
print(f" Found {found} new emails")
# Bounce check ALL
with_email = [p for p in enriched if p.get("email")]
print(f" Bounce checking {len(with_email)} emails...")
_lock = threading.Lock(); _verified = 0
def verify_one(person):
global _verified
try:
r = requests.post("https://app.findymail.com/api/verify", headers=fm_h, json={"email": person["email"]}, timeout=30)
if r.status_code == 200:
d = r.json(); person["email_verified"] = str(d.get("verified", False))
person["email_provider"] = d.get("provider", "")
with _lock:
if d.get("verified"): _verified += 1
return
except: pass
person["email_verified"] = ""; person["email_provider"] = ""
with ThreadPoolExecutor(max_workers=20) as ex:
list(ex.map(verify_one, with_email))
for p in enriched:
if not p.get("email"): p["email_verified"] = ""; p["email_provider"] = ""
print(f" {sum(1 for p in enriched if p.get('email'))} with email, {_verified} verified")
# ===== STEP 5: Restaurant mapping =====
print("=" * 60)
print("STEP 5: Restaurant mapping")
cities = {}
for p in enriched:
if p.get("city") and p.get("email"):
key = f"{p['city']}, {p['state']}"
if key not in cities: cities[key] = {"city": p["city"], "state": p["state"]}
class Restaurant(BaseModel):
restaurant_name: str; address: str; price_range: str; why_selected: str; booking_url: str
oai = OpenAI(api_key=OPENAI_API_KEY)
restaurants = {}
def find_restaurant(ck, ci):
try:
result = oai.responses.parse(model="gpt-5.4-mini", reasoning={"effort": "low"},
tools=[{"type": "web_search"}],
input=[{"role": "system", "content": "You are a restaurant concierge."},
{"role": "user", "content": f"Find a restaurant in {ci['city']}, {ci['state']} for a business dinner of 6-10. Not too loud, not a date spot, $50-$100/pp, well-reviewed, not a chain. Include booking URL."}],
text_format=Restaurant).output_parsed
return ck, {"city": ci["city"], "state": ci["state"], "restaurant_name": result.restaurant_name,
"address": result.address, "price_range": result.price_range,
"why_selected": result.why_selected, "booking_url": result.booking_url}
except Exception as e:
return ck, {"city": ci["city"], "state": ci["state"], "restaurant_name": "TBD",
"address": "", "price_range": "", "why_selected": str(e)[:100], "booking_url": ""}
done = 0
with ThreadPoolExecutor(max_workers=20) as ex:
futs = {ex.submit(find_restaurant, k, v): k for k, v in cities.items()}
for f in as_completed(futs):
ck, res = f.result(); restaurants[ck] = res; done += 1
if done % 30 == 0: print(f" {done}/{len(cities)}")
print(f" {len(restaurants)} restaurants found")
# ===== STEP 6: Build XLSX =====
print("=" * 60)
print("STEP 6: Build XLSX")
companies_map = {r["domain"]: r for r in tal}
def style_header(ws):
fill = PatternFill(start_color="1F2937", end_color="1F2937", fill_type="solid")
font = Font(color="FFFFFF", bold=True, size=11)
for cell in ws[1]: cell.fill = fill; cell.font = font; cell.alignment = Alignment(horizontal="left")
for col in ws.columns:
ml = max(len(str(c.value or "")) for c in col)
ws.column_dimensions[col[0].column_letter].width = min(ml + 4, 50)
# TAL
wb1 = Workbook(); ws1 = wb1.active; ws1.title = "Target Account List"
h = ["company_name","domain","verification_source","discovery_source"]; ws1.append(h)
for r in tal: ws1.append([r.get(k,"") for k in h])
style_header(ws1); wb1.save(f"{BASE}/output/{CAMPAIGN_NAME}-tal.xlsx")
# Unified TAL
wb2 = Workbook(); ws2 = wb2.active; ws2.title = "Unified TAL"; ws2.append(h)
for r in tal: ws2.append([r.get(k,"") for k in h])
style_header(ws2); wb2.save(f"{BASE}/output/{CAMPAIGN_NAME}-tal-unified.xlsx")
# People Scored
wb3 = Workbook(); ws3 = wb3.active; ws3.title = "People Scored"
ph = ["first_name","last_name","title","seniority","organization","domain","email","email_verified",
"email_provider","linkedin_url","city","state","company_icp_fit","person_icp_fit",
"dinner_city","dinner_restaurant","dinner_address","dinner_date","dinner_theme"]
ws3.append(ph); scored = 0
for p in enriched:
if not p.get("email"): continue
co = companies_map.get(p.get("domain",""), {})
sources = co.get("discovery_source","")
sc = len(sources.split("; ")) if sources else 0
company_score = min(60 + sc * 10, 95)
sen = p.get("seniority",""); title = (p.get("title","") or "").lower()
person_score = 90 if sen == "c_suite" else 80 if sen == "vp" else 50
if any(kw in title for kw in ["abm","demand gen","growth","revenue","marketing ops","marketing automation"]):
person_score = min(person_score + 10, 95)
ck = f"{p.get('city','')}, {p.get('state','')}"; rest = restaurants.get(ck, {})
ws3.append([p.get("first_name",""), p.get("last_name",""), p.get("title",""), sen,
p.get("organization",""), p.get("domain",""), p.get("email",""),
p.get("email_verified",""), p.get("email_provider",""), p.get("linkedin_url",""),
p.get("city",""), p.get("state",""), company_score, person_score,
p.get("city",""), rest.get("restaurant_name",""), rest.get("address",""),
DINNER_DATE, THEME])
scored += 1
style_header(ws3)
# Dinner Summary sheet
ws_sum = wb3.create_sheet("Dinner Summary")
ws_sum.append(["City","State","Restaurant","Address","Price Range","Date","People Count",
"Verified Emails","Theme"])
cc = Counter(f"{p.get('city','')}, {p.get('state','')}" for p in enriched if p.get("email") and p.get("city"))
cv = Counter(f"{p.get('city','')}, {p.get('state','')}" for p in enriched if p.get("email_verified")=="True" and p.get("city"))
for ck, count in cc.most_common():
rest = restaurants.get(ck, {})
ws_sum.append([rest.get("city",""), rest.get("state",""), rest.get("restaurant_name",""),
rest.get("address",""), rest.get("price_range",""), DINNER_DATE, count,
cv.get(ck, 0), THEME])
style_header(ws_sum)
wb3.save(f"{BASE}/output/{CAMPAIGN_NAME}-people-scored.xlsx")
print(f"\n{'='*60}")
print(f"DONE: {CAMPAIGN_NAME}")
print(f" TAL: {len(tal)} companies")
print(f" People: {scored} with email ({_verified} verified)")
print(f" Cities: {len(restaurants)} with restaurants")
print(f" Theme: {THEME}")
print(f" Date: {DINNER_DATE}")To use: (1) run TAL discovery agents to produce phase1/companies_unified.csv, (2) edit the CONFIG block at the top, (3) run the script. Everything else is automatic.