scripts/fetch_icij_offshore.py
scripts/fetch_icij_offshore.pyBrowse 29 files
1,977 tokens
8,596 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Search ICIJ Offshore Leaks via the bulk CSV database.3 4The old reconcile endpoint (https://offshoreleaks.icij.org/reconcile) returns5404 — ICIJ has removed it. The remaining stable access path is the public6bulk download:7 8 https://offshoreleaks-data.icij.org/offshoreleaks/csv/full-oldb.LATEST.zip9 10~70 MB, ~6 CSVs inside (nodes-entities, nodes-officers, nodes-intermediaries,11nodes-addresses, relationships, ...). We cache it under12$HERMES_OSINT_CACHE/icij/ (default: ~/.cache/hermes-osint/icij/) and search13locally so the agent doesn't re-download for every query.14 15Output CSV columns match the original `fetch_icij_offshore.py` contract.16"""17from __future__ import annotations18 19import argparse20import csv21import io22import os23import re24import sys25import time26import urllib.request27import zipfile28from pathlib import Path29 30BULK_URL = "https://offshoreleaks-data.icij.org/offshoreleaks/csv/full-oldb.LATEST.zip"31 32COLUMNS = [33 "node_id",34 "name",35 "node_type",36 "country_codes",37 "countries",38 "jurisdiction",39 "incorporation_date",40 "inactivation_date",41 "source",42 "entity_url",43 "connections",44]45 46 47def _cache_dir() -> Path:48 base = os.environ.get("HERMES_OSINT_CACHE")49 if base:50 return Path(base) / "icij"51 return Path.home() / ".cache" / "hermes-osint" / "icij"52 53 54def _download(dest: Path, force: bool = False) -> Path:55 """Download (or reuse cached) ICIJ bulk ZIP."""56 dest.mkdir(parents=True, exist_ok=True)57 zip_path = dest / "full-oldb.zip"58 if zip_path.exists() and not force:59 # Re-check age: refetch if older than 30 days.60 age_days = (time.time() - zip_path.stat().st_mtime) / 8640061 if age_days < 30:62 return zip_path63 print(f"Downloading ICIJ bulk database (~70 MB) to {zip_path}", file=sys.stderr)64 req = urllib.request.Request(65 BULK_URL,66 headers={"User-Agent": "hermes-agent osint-investigation skill"},67 )68 with urllib.request.urlopen(req, timeout=120) as resp: # noqa: S31069 tmp = zip_path.with_suffix(".zip.tmp")70 with open(tmp, "wb") as fh:71 while True:72 chunk = resp.read(1 << 16)73 if not chunk:74 break75 fh.write(chunk)76 tmp.replace(zip_path)77 return zip_path78 79 80def _open_csv(zf: zipfile.ZipFile, name_pattern: str):81 """Open the first CSV matching name_pattern (case-insensitive substring)."""82 for info in zf.infolist():83 if name_pattern.lower() in info.filename.lower() and info.filename.lower().endswith(".csv"):84 return zf.open(info), info.filename85 return None, None86 87 88def _match(needle_norm: str, hay: str) -> bool:89 return needle_norm in (hay or "").upper()90 91 92def _normalize_query(s: str) -> str:93 s = s.upper()94 s = re.sub(r"[^\w\s]", " ", s)95 s = re.sub(r"\s+", " ", s).strip()96 return s97 98 99def fetch(100 entity: str | None,101 officer: str | None,102 jurisdiction: str | None,103 out_path: str,104 cache_dir: Path,105 force_refresh: bool = False,106 limit: int = 500,107) -> int:108 zip_path = _download(cache_dir, force=force_refresh)109 rows: list[dict[str, str]] = []110 needles: list[tuple[str, str]] = [] # (kind, normalized needle)111 if entity:112 needles.append(("Entity", _normalize_query(entity)))113 if officer:114 needles.append(("Officer", _normalize_query(officer)))115 jur_norm = _normalize_query(jurisdiction) if jurisdiction else None116 117 targets = [118 ("Entity", "nodes-entities"),119 ("Officer", "nodes-officers"),120 ("Intermediary", "nodes-intermediaries"),121 ]122 123 with zipfile.ZipFile(zip_path) as zf:124 for node_type, csv_substring in targets:125 relevant_needles = [n for (k, n) in needles if k in {node_type, "Entity", "Officer"}] or []126 # Only scan a CSV if we have a needle that could plausibly match it,127 # or if we have ONLY a jurisdiction filter.128 applicable_needles = [n for (k, n) in needles if k == node_type]129 if needles and not applicable_needles and not jur_norm:130 continue131 stream, fname = _open_csv(zf, csv_substring)132 if not stream:133 continue134 with stream:135 text = io.TextIOWrapper(stream, encoding="utf-8", errors="replace")136 reader = csv.DictReader(text)137 for row in reader:138 name = (row.get("name") or "").strip()139 if not name:140 continue141 name_u = name.upper()142 matched = False143 for n in applicable_needles or relevant_needles:144 if _match(n, name_u):145 matched = True146 break147 if not needles:148 matched = True # jurisdiction-only sweep149 if not matched:150 continue151 jur = (row.get("jurisdiction_description") or row.get("country_codes") or "").strip()152 if jur_norm and jur_norm not in jur.upper() and jur_norm not in (row.get("countries") or "").upper():153 continue154 node_id = (row.get("node_id") or "").strip()155 rows.append(156 {157 "node_id": node_id,158 "name": name,159 "node_type": node_type,160 "country_codes": row.get("country_codes", "") or "",161 "countries": row.get("countries", "") or "",162 "jurisdiction": jur,163 "incorporation_date": row.get("incorporation_date", "") or "",164 "inactivation_date": row.get("inactivation_date", "") or "",165 "source": row.get("sourceID", "") or row.get("source", "") or "",166 "entity_url": (167 f"https://offshoreleaks.icij.org/nodes/{node_id}" if node_id else ""168 ),169 "connections": "",170 }171 )172 if len(rows) >= limit:173 break174 if len(rows) >= limit:175 break176 177 Path(out_path).parent.mkdir(parents=True, exist_ok=True)178 with open(out_path, "w", newline="", encoding="utf-8") as fh:179 w = csv.DictWriter(fh, fieldnames=COLUMNS)180 w.writeheader()181 w.writerows(rows)182 if not rows:183 bits = []184 if entity:185 bits.append(f"entity={entity!r}")186 if officer:187 bits.append(f"officer={officer!r}")188 if jurisdiction:189 bits.append(f"jurisdiction={jurisdiction!r}")190 print(191 f"ICIJ: 0 matches for {', '.join(bits)}. "192 "The bulk database covers offshore leaks (Panama, Paradise, Pandora, "193 "Bahamas, Offshore Leaks). Most private US individuals are NOT in it.",194 file=sys.stderr,195 )196 return len(rows)197 198 199def main() -> int:200 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)201 p.add_argument("--entity", help="Search by entity name (substring, case-insensitive)")202 p.add_argument("--officer", help="Search by officer / individual name (substring, case-insensitive)")203 p.add_argument("--jurisdiction", help="Filter results by jurisdiction substring")204 p.add_argument("--limit", type=int, default=500)205 p.add_argument("--out", required=True)206 p.add_argument(207 "--cache-dir",208 type=Path,209 default=None,210 help="Override cache directory (default: $HERMES_OSINT_CACHE/icij or ~/.cache/hermes-osint/icij)",211 )212 p.add_argument(213 "--force-refresh",214 action="store_true",215 help="Re-download the bulk ZIP even if a recent cached copy exists.",216 )217 a = p.parse_args()218 if not (a.entity or a.officer or a.jurisdiction):219 p.error("must supply at least one of --entity / --officer / --jurisdiction")220 n = fetch(221 entity=a.entity,222 officer=a.officer,223 jurisdiction=a.jurisdiction,224 out_path=a.out,225 cache_dir=a.cache_dir or _cache_dir(),226 force_refresh=a.force_refresh,227 limit=a.limit,228 )229 print(f"Wrote {n} ICIJ Offshore Leaks rows to {a.out}")230 return 0231 232 233if __name__ == "__main__":234 raise SystemExit(main())235