#!/usr/bin/env python3 """Turn people exports into one org chart per target company. org_charts.py --companies keep.csv --pulls pulls/*.csv --out run/ Keeps only people who work at a target company: matched on company ID where the list has one, otherwise on the company's own website, then its name. People exported under a company's domain who work somewhere else (or nowhere: "Retired") are dropped and counted. At companies bigger than SMALL_COMPANY the chart is the leadership layer only; small companies keep everyone. Writes org_charts.jsonl and summary.json. """ from __future__ import annotations import argparse import csv import json import re import sys from collections import Counter from pathlib import Path from typing import Any, Callable, Iterator SMALL_COMPANY = 50 # at or under this many people, the whole company is the org chart MAX_PEOPLE = 60 # most senior first; keeps a chart inside one Jev request TARGET_COLUMNS = { "company_id": ("company_id",), "name": ("name", "company_name", "company"), "website": ("website", "company_website", "domain", "company_domain"), "linkedin": ("linkedin", "company_linkedin_url"), "headcount": ("headcount", "linkedin_employee_count_exact"), "about": ("about", "company_description", "summary", "company_summary"), } PEOPLE_COLUMNS = { "contact_id": ("contact_id",), "full_name": ("full_name",), "title": ("current_title",), "seniority": ("seniority_level",), "department": ("department",), "headline": ("linkedin_headline",), "about": ("about_person",), "linkedin_url": ("linkedin_url",), "work_email": ("work_email",), "email_status": ("email_status",), "role_start": ("role_start_date",), "company_id": ("company_id",), "company_name": ("company_name",), "company_website": ("company_website",), # asked for as company_linkedin_url, the export names it current_employer_linkedin_url "company_linkedin": ("current_employer_linkedin_url", "company_linkedin_url"), "company_headcount": ("linkedin_employee_count_exact",), } SHARED_HOSTS = { "linkedin.com", "facebook.com", "instagram.com", "twitter.com", "x.com", "youtube.com", "linktr.ee", "google.com", "sites.google.com", "business.site", "wix.com", "wixsite.com", "squarespace.com", "godaddysites.com", "yelp.com", "tiktok.com", "medium.com", "github.com", "wikipedia.org", "bit.ly", "lnkd.in", "tinyurl.com", "linkin.bio", } SENIORITY_RANK = {"c-level": 0, "vp": 1, "director": 2, "manager": 4, "senior": 5, "junior": 6} # Seniority is guessed from the title and is missing or wrong for about 1 in 8 decision makers, so a # senior title ranks a person too. Strong patterns apply to everyone; weak ones only when no seniority # is recorded. Lowest rank wins. STRONG_TITLES = [ (0, re.compile(r"\b(?:(? dict[str, str]: """Canonical field -> CSV column. A display label matches once lowercased with spaces for underscores; the first listed name present wins.""" lowered = {f.strip().lower().replace(" ", "_"): f for f in fields} columns = {} for key, names in spec.items(): found = next((lowered[n] for n in names if n in lowered), None) if found: columns[key] = found return columns def read_rows(path: Path, spec: dict[str, tuple[str, ...]]) -> Iterator[dict[str, str]]: with path.open(newline="", encoding="utf-8-sig") as handle: reader = csv.DictReader(handle) columns = resolve(list(reader.fieldnames or []), spec) for row in reader: yield {key: (row.get(column) or "").strip() for key, column in columns.items()} def split_site(value: str) -> tuple[str, bool]: """(own host or "", whether the address points below the site's root).""" rest = re.sub(r"^(https?://)?(www\.)?", "", value.strip().lower()) host, _, path = rest.partition("/") host = host.split("?")[0] if "." not in host or host in SHARED_HOSTS or any(host.endswith("." + s) for s in SHARED_HOSTS): return "", bool(path.strip("/")) return host, bool(path.strip("/")) def own_host(value: str) -> str: return split_site(value)[0] def norm_name(value: str) -> str: return re.sub(r"[^a-z0-9]", "", value.lower()) def number(value: str) -> int | None: try: return int(float(value.replace(",", ""))) except ValueError: return None def rank(person: dict[str, str]) -> int: """0 = owner/founder/C-level, 1 = VP, 2 = head/director, then manager and below. A person with no seniority recorded is also read from their headline, where the title is often generic.""" recorded = person.get("seniority") in SENIORITY_RANK text = person.get("title", "") if recorded else f"{person.get('title', '')} {person.get('headline', '')}" patterns = STRONG_TITLES if recorded else STRONG_TITLES + WEAK_TITLES ranks = [SENIORITY_RANK[person["seniority"]]] if recorded else [] ranks += [r for r, pattern in patterns if pattern.search(text)] return min(ranks, default=BLANK_RANK) def order(person: dict[str, str]) -> tuple: """Most senior first: rank, then recorded seniority, then a senior modifier (Senior Director before Director).""" return (rank(person), SENIORITY_RANK.get(person.get("seniority", ""), BLANK_RANK), 0 if SENIOR_MODIFIER.search(person.get("title", "")) else 1, person.get("title", "")) def person_key(person: dict[str, str]) -> str: return person.get("contact_id") or person.get("linkedin_url") or \ f"{norm_name(person.get('full_name', ''))}|{norm_name(person.get('company_name', ''))}" def load_targets(path: Path) -> list[dict[str, str]]: """One target per company. A website below a site's root (a parent company's page) or a host two companies share is no identity for either: those are keyed and matched by name.""" rows = list(read_rows(path, TARGET_COLUMNS)) # companies per host: the same company listed twice is one, two names on one host are two hosts = Counter(h for h, _ in {(split_site(t.get("website", ""))[0], norm_name(t.get("name", ""))) for t in rows}) targets: dict[str, dict[str, str]] = {} for i, t in enumerate(rows): host, deep = split_site(t.get("website", "")) t["host"] = host if host and not deep and hosts[host] == 1 else "" name = norm_name(t.get("name", "")) t["key"] = t.get("company_id") or t["host"] or (f"name:{name}" if name else f"row:{i}") targets.setdefault(t["key"], t) return list(targets.values()) def matcher(targets: list[dict[str, str]]) -> Callable[[dict[str, str]], str | None]: """A company ID, where the target has one, is the only thing that counts: a website or name can belong to another company. Targets without one match on their own website, then on name, and a name only counts when one side has no website of its own (a namesake elsewhere has one).""" by_id = {t["company_id"]: t["key"] for t in targets if t.get("company_id")} rest = [t for t in targets if not t.get("company_id")] by_host = {t["host"]: t["key"] for t in rest if t["host"]} by_name = {n: t for t in rest if (n := norm_name(t.get("name", "")))} def match(person: dict[str, str]) -> str | None: if person.get("company_id") in by_id: return by_id[person["company_id"]] host = own_host(person.get("company_website", "")) if host in by_host: return by_host[host] target = by_name.get(norm_name(person.get("company_name", ""))) if target and (not host or not target["host"]): return target["key"] return None return match def build(targets: list[dict[str, str]], pulls: list[Path]) -> tuple[list[dict[str, Any]], dict[str, Any]]: match = matcher(targets) people: dict[str, dict[str, dict[str, str]]] = {t["key"]: {} for t in targets} rows = dropped = 0 for path in pulls: for person in read_rows(path, PEOPLE_COLUMNS): rows += 1 key = match(person) if key is None: dropped += 1 continue people[key].setdefault(person_key(person), person) charts = [] for t in targets: staff = sorted(people[t["key"]].values(), key=order) reported = Counter(n for p in staff if (n := number(p.get("company_headcount", "")))) headcount = number(t.get("headcount", "")) or (reported.most_common(1)[0][0] if reported else len(staff)) small = headcount <= SMALL_COMPANY and len(staff) <= SMALL_COMPANY leaders = [p for p in staff if rank(p) <= LEADERSHIP] # nobody senior at a big company: still chart its most senior people rather than nobody chart = staff if small else (leaders or staff) charts.append({ "company": {**{k: t.get(k, "") for k in ("key", "company_id", "name", "website", "linkedin", "about")}, "headcount": str(headcount) if staff or t.get("headcount") else ""}, "people_found": len(staff), "whole_company": small, "capped": len(chart) > MAX_PEOPLE, "people": chart[:MAX_PEOPLE], }) summary = { "companies": len(targets), "rows_pulled": rows, "dropped_other_company": dropped, "people_kept": sum(c["people_found"] for c in charts), "companies_without_people": [c["company"]["name"] for c in charts if not c["people"]], "charts_capped": sum(1 for c in charts if c["capped"]), } return charts, summary def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--companies", type=Path, required=True, help="the company list (keep.csv or the user's file)") parser.add_argument("--pulls", nargs="+", type=Path, required=True, help="every people export") parser.add_argument("--out", type=Path, required=True) args = parser.parse_args(argv) targets = load_targets(args.companies) if not targets: sys.exit(f"no companies read from {args.companies}") charts, summary = build(targets, args.pulls) args.out.mkdir(parents=True, exist_ok=True) with (args.out / "org_charts.jsonl").open("w", encoding="utf-8") as handle: for chart in charts: handle.write(json.dumps(chart) + "\n") (args.out / "summary.json").write_text(json.dumps({"org_charts": summary}, indent=1)) print(json.dumps(summary, indent=1)) return 0 if __name__ == "__main__": sys.exit(main())