mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
Victim names are stored as a flat set of lowercased name variants with no person details — the V badge appears but is not clickable, protecting victim identities. A result can show both T1/T2/T3 and V badges simultaneously. - 15 known victims from public court records → 43 name variants - Enabler/perpetrator categories promoted to T2 - Purple V badge with pointer-events disabled - Victim matching uses prefix/suffix combination like associate matching Co-Authored-By: Claude Opus 4.6 <[email protected]>
297 lines
9.3 KiB
Python
297 lines
9.3 KiB
Python
"""Build the associates.json lookup file from rhowardstone/Epstein-research-data.
|
|
|
|
Usage:
|
|
python scripts/build_associates.py [--data-dir PATH]
|
|
|
|
Downloads persons_registry.json and knowledge_graph_relationships.json if not
|
|
present, then processes them into unredact/data/associates.json.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
OUTPUT_PATH = Path(__file__).parent.parent / "unredact" / "data" / "associates.json"
|
|
|
|
# Common nickname mappings (canonical → [nicknames])
|
|
NICKNAMES = {
|
|
"jeffrey": ["jeff"],
|
|
"william": ["bill", "will", "willy", "billy"],
|
|
"richard": ["rick", "dick", "rich"],
|
|
"robert": ["rob", "bob", "bobby"],
|
|
"james": ["jim", "jimmy", "jamie"],
|
|
"michael": ["mike", "mikey"],
|
|
"joseph": ["joe", "joey"],
|
|
"thomas": ["tom", "tommy"],
|
|
"charles": ["charlie", "chuck"],
|
|
"christopher": ["chris"],
|
|
"daniel": ["dan", "danny"],
|
|
"matthew": ["matt"],
|
|
"anthony": ["tony"],
|
|
"donald": ["don", "donny"],
|
|
"steven": ["steve"],
|
|
"stephen": ["steve"],
|
|
"edward": ["ed", "eddie", "ted"],
|
|
"benjamin": ["ben"],
|
|
"samuel": ["sam", "sammy"],
|
|
"alexander": ["alex"],
|
|
"nicholas": ["nick"],
|
|
"jonathan": ["jon"],
|
|
"timothy": ["tim", "timmy"],
|
|
"andrew": ["andy", "drew"],
|
|
"lawrence": ["larry"],
|
|
"raymond": ["ray"],
|
|
"gerald": ["gerry", "jerry"],
|
|
"kenneth": ["ken", "kenny"],
|
|
"elizabeth": ["liz", "beth", "lizzy"],
|
|
"katherine": ["kate", "kathy", "katie"],
|
|
"catherine": ["kate", "cathy", "katie"],
|
|
"margaret": ["maggie", "meg", "peggy"],
|
|
"jennifer": ["jen", "jenny"],
|
|
"patricia": ["pat", "patty"],
|
|
"virginia": ["ginny"],
|
|
"victoria": ["vicky", "tori"],
|
|
"deborah": ["deb", "debbie"],
|
|
"dorothy": ["dot", "dotty"],
|
|
"suzanne": ["sue", "suzy"],
|
|
"alexandra": ["alex"],
|
|
}
|
|
|
|
# Tier 2 categories (staff/financial get auto-promoted)
|
|
TIER2_CATEGORIES = {"staff", "financial"}
|
|
|
|
# Known victims from public court records and reporting (Giuffre v. Maxwell, etc.)
|
|
# Stored as a separate name set — no person details exposed in the UI.
|
|
KNOWN_VICTIMS = {
|
|
"Virginia Giuffre", "VIRGINIA ROBERTS", "Virginia Roberts",
|
|
"Courtney Wild",
|
|
"Sarah Ransome",
|
|
"Maria Farmer", "Annie Farmer",
|
|
"Chauntae Davies",
|
|
"Johanna Sjoberg",
|
|
"Michelle Licata",
|
|
"Carolyn Andriano",
|
|
"Priscilla Doe",
|
|
"Teresa Helm",
|
|
"Anouska De Georgiou",
|
|
"Haley Robson",
|
|
}
|
|
|
|
|
|
def _compute_tiers(registry: list[dict], relationships: list[dict]) -> dict[str, int]:
|
|
"""Compute tier for each person name.
|
|
|
|
Returns: {canonical_name: tier}
|
|
"""
|
|
# Count relationships and check for traveled_with
|
|
traveled = set()
|
|
rel_counts: dict[str, int] = defaultdict(int)
|
|
|
|
for rel in relationships:
|
|
src = rel.get("source_name", rel.get("source", ""))
|
|
tgt = rel.get("target_name", rel.get("target", ""))
|
|
rtype = rel.get("relationship_type", "")
|
|
|
|
rel_counts[src] += 1
|
|
rel_counts[tgt] += 1
|
|
|
|
if rtype == "traveled_with":
|
|
traveled.add(src)
|
|
traveled.add(tgt)
|
|
|
|
tiers = {}
|
|
for person in registry:
|
|
name = person["name"]
|
|
category = person.get("category", "other")
|
|
|
|
if name in traveled:
|
|
tiers[name] = 1
|
|
elif category in TIER2_CATEGORIES or category in {"enabler", "perpetrator"} or rel_counts.get(name, 0) >= 3:
|
|
tiers[name] = 2
|
|
else:
|
|
tiers[name] = 3
|
|
|
|
return tiers
|
|
|
|
|
|
def _add_name_entries(
|
|
names: dict[str, list],
|
|
text: str,
|
|
person_id: str,
|
|
tier: int,
|
|
match_type: str,
|
|
):
|
|
"""Add a lookup entry for a name string (lowercased)."""
|
|
key = text.lower().strip()
|
|
if not key or len(key) < 2:
|
|
return
|
|
if key not in names:
|
|
names[key] = []
|
|
# Avoid duplicate entries for same person + match_type
|
|
if not any(e["person_id"] == person_id and e["match_type"] == match_type for e in names[key]):
|
|
names[key].append({"person_id": person_id, "match_type": match_type, "tier": tier})
|
|
|
|
|
|
def _split_name(full_name: str) -> tuple[str, str]:
|
|
"""Split a full name into (first, last). Returns ('', '') if can't split."""
|
|
parts = full_name.strip().split()
|
|
if len(parts) < 2:
|
|
return ("", "")
|
|
return (parts[0], parts[-1])
|
|
|
|
|
|
def _generate_lookups_for_name(
|
|
names: dict[str, list],
|
|
full_name: str,
|
|
person_id: str,
|
|
tier: int,
|
|
):
|
|
"""Generate all lookup entries for a single name string."""
|
|
# Full name
|
|
_add_name_entries(names, full_name, person_id, tier, "full")
|
|
|
|
first, last = _split_name(full_name)
|
|
if first and last:
|
|
# First and last separately
|
|
_add_name_entries(names, first, person_id, tier, "first")
|
|
_add_name_entries(names, last, person_id, tier, "last")
|
|
|
|
# Initials + last
|
|
initial = first[0]
|
|
_add_name_entries(names, f"{initial}. {last}", person_id, tier, "initial_last")
|
|
_add_name_entries(names, f"{initial} {last}", person_id, tier, "initial_last")
|
|
|
|
# Nicknames
|
|
first_lower = first.lower()
|
|
if first_lower in NICKNAMES:
|
|
for nick in NICKNAMES[first_lower]:
|
|
_add_name_entries(names, nick, person_id, tier, "nickname")
|
|
_add_name_entries(names, f"{nick} {last}", person_id, tier, "nickname_full")
|
|
|
|
|
|
def _build_victim_names() -> set[str]:
|
|
"""Build a flat set of lowercased victim name variants for matching.
|
|
|
|
No person details — just strings to check against. This keeps victim
|
|
identities private in the UI (the V badge shows but is not clickable).
|
|
"""
|
|
victim_names: set[str] = set()
|
|
|
|
for full_name in KNOWN_VICTIMS:
|
|
low = full_name.lower().strip()
|
|
if len(low) < 2:
|
|
continue
|
|
victim_names.add(low)
|
|
|
|
parts = low.split()
|
|
if len(parts) >= 2:
|
|
first, last = parts[0], parts[-1]
|
|
victim_names.add(first)
|
|
victim_names.add(last)
|
|
|
|
# Nicknames
|
|
if first in NICKNAMES:
|
|
for nick in NICKNAMES[first]:
|
|
victim_names.add(nick)
|
|
victim_names.add(f"{nick} {last}")
|
|
|
|
return victim_names
|
|
|
|
|
|
def process_associates(
|
|
registry: list[dict],
|
|
relationships: list[dict],
|
|
) -> dict:
|
|
"""Process raw registry + relationships into the associates lookup format.
|
|
|
|
Returns: {"names": {str: [...]}, "persons": {str: {...}}, "victim_names": [str, ...]}
|
|
"""
|
|
tiers = _compute_tiers(registry, relationships)
|
|
|
|
names: dict[str, list] = {}
|
|
persons: dict[str, dict] = {}
|
|
|
|
pid_counter = 0
|
|
for person in registry:
|
|
canonical_name = person["name"]
|
|
|
|
# Skip redaction placeholders and parsing artifacts from upstream data
|
|
if re.search(r'[()]', canonical_name) or len(canonical_name) < 3:
|
|
continue
|
|
|
|
person_id = f"p{pid_counter:04d}"
|
|
pid_counter += 1
|
|
tier = tiers.get(canonical_name, 3)
|
|
category = person.get("category", "other")
|
|
|
|
persons[person_id] = {
|
|
"name": canonical_name,
|
|
"tier": tier,
|
|
"category": category,
|
|
}
|
|
|
|
# Generate lookups for canonical name
|
|
_generate_lookups_for_name(names, canonical_name, person_id, tier)
|
|
|
|
# Generate lookups for each alias
|
|
for alias in person.get("aliases", []):
|
|
_generate_lookups_for_name(names, alias, person_id, tier)
|
|
|
|
victim_names = sorted(_build_victim_names())
|
|
|
|
return {"names": names, "persons": persons, "victim_names": victim_names}
|
|
|
|
|
|
def download_data(data_dir: Path) -> tuple[list, list]:
|
|
"""Download the rhowardstone data files if not already present.
|
|
|
|
Returns: (registry, relationships)
|
|
"""
|
|
import urllib.request
|
|
|
|
base_url = "https://raw.githubusercontent.com/rhowardstone/Epstein-research-data/main"
|
|
registry_path = data_dir / "persons_registry.json"
|
|
rels_path = data_dir / "knowledge_graph_relationships.json"
|
|
|
|
for filename, path in [("persons_registry.json", registry_path), ("knowledge_graph_relationships.json", rels_path)]:
|
|
if not path.exists():
|
|
url = f"{base_url}/{filename}"
|
|
print(f"Downloading {url}...")
|
|
urllib.request.urlretrieve(url, path)
|
|
print(f" -> saved to {path}")
|
|
|
|
registry = json.loads(registry_path.read_text())
|
|
relationships = json.loads(rels_path.read_text())
|
|
return registry, relationships
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Build associates.json from Epstein research data")
|
|
parser.add_argument("--data-dir", type=Path, default=Path(__file__).parent / ".cache",
|
|
help="Directory to cache downloaded data files")
|
|
args = parser.parse_args()
|
|
|
|
args.data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
registry, relationships = download_data(args.data_dir)
|
|
print(f"Loaded {len(registry)} persons, {len(relationships)} relationships")
|
|
|
|
result = process_associates(registry, relationships)
|
|
print(f"Generated {len(result['names'])} name lookups for {len(result['persons'])} persons")
|
|
|
|
tier_counts = defaultdict(int)
|
|
for p in result["persons"].values():
|
|
tier_counts[p["tier"]] += 1
|
|
for tier in sorted(tier_counts):
|
|
print(f" Tier {tier}: {tier_counts[tier]} persons")
|
|
|
|
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
OUTPUT_PATH.write_text(json.dumps(result, separators=(",", ":")))
|
|
print(f"Wrote {OUTPUT_PATH}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|