osint-investigation

Follow the money via public records and sanctions data.

  • osint
  • investigation
  • public-records
  • sec
  • sanctions
  • corporate-registry
  • property
  • courts
  • due-diligence
  • journalism

Declared platforms: linux · macos · windows

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/optional-skills/research/osint-investigation'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md
#!/usr/bin/env python3"""Search NYC property records via ACRIS (Automated City Register Information System). Uses the city's Socrata-backed open data API. No auth required for read access. Datasets:  bnx9-e6tj — Real Property Master (one row per recorded document)  636b-3b5g — Real Property Parties (names — grantor, grantee, etc.)  8h5j-fqxa — Real Property Legal (lot / property identifiers)  uqqa-hym2 — Real Property References The Parties dataset has the names. We search by name and optionally join toMaster to get the doc type and date."""from __future__ import annotations import argparseimport csvimport sysimport urllib.parsefrom pathlib import Path sys.path.insert(0, str(Path(__file__).parent))from _http import get_json  # noqa: E402 PARTIES_URL = "https://data.cityofnewyork.us/resource/636b-3b5g.json"MASTER_URL = "https://data.cityofnewyork.us/resource/bnx9-e6tj.json" PARTY_TYPE = {    "1": "grantor (seller / mortgagor / debtor)",    "2": "grantee (buyer / mortgagee / creditor)",    "3": "other party",} BOROUGH = {    "1": "Manhattan",    "2": "Bronx",    "3": "Brooklyn",    "4": "Queens",    "5": "Staten Island",} COLUMNS = [    "document_id",    "name",    "party_type",    "party_role",    "address_1",    "address_2",    "city",    "state",    "zip",    "country",    "doc_type",    "doc_date",    "recorded_date",    "borough",    "amount",    "filing_url",]  def _filing_url(document_id: str) -> str:    if not document_id:        return ""    return (        f"https://a836-acris.nyc.gov/DS/DocumentSearch/DocumentImageView?doc_id={document_id}"    )  def fetch(    name: str | None,    address: str | None,    party_type: str | None,    limit: int,    out_path: str,    enrich: bool = True,) -> int:    if not (name or address):        raise SystemExit("must supply --name or --address")     where_clauses: list[str] = []    if name:        safe = name.upper().replace("'", "''")        where_clauses.append(f"upper(name) like '%{safe}%'")    if address:        safe_addr = address.upper().replace("'", "''")        where_clauses.append(f"upper(address_1) like '%{safe_addr}%'")    if party_type and party_type in {"1", "2", "3"}:        where_clauses.append(f"party_type='{party_type}'")     params = {        "$where": " AND ".join(where_clauses),        "$limit": str(limit),    }    url = f"{PARTIES_URL}?{urllib.parse.urlencode(params)}"    parties = get_json(url)    if not isinstance(parties, list):        raise SystemExit(f"Unexpected ACRIS response: {parties!r}")     # Enrich with master record (doc_type, dates, borough, amount).    doc_ids: list[str] = sorted({        d for d in (p.get("document_id") for p in parties) if d    })    masters: dict[str, dict] = {}    if enrich and doc_ids:        # Batch up to 100 doc_ids per request (Socrata IN-list is fine for this).        for i in range(0, len(doc_ids), 100):            chunk = doc_ids[i : i + 100]            id_list = ",".join(f"'{d}'" for d in chunk)            master_params = {                "$where": f"document_id in ({id_list})",                "$limit": "100",            }            url = f"{MASTER_URL}?{urllib.parse.urlencode(master_params)}"            try:                rows = get_json(url)            except Exception as e:  # noqa: BLE001                print(f"ACRIS master lookup failed for chunk: {e}", file=sys.stderr)                continue            if isinstance(rows, list):                for r in rows:                    did = r.get("document_id", "")                    if did:                        masters[did] = r     out_rows: list[dict[str, str]] = []    for p in parties:        did = p.get("document_id", "") or ""        m = masters.get(did, {})        out_rows.append(            {                "document_id": did,                "name": p.get("name", "") or "",                "party_type": p.get("party_type", "") or "",                "party_role": PARTY_TYPE.get(p.get("party_type", ""), ""),                "address_1": p.get("address_1", "") or "",                "address_2": p.get("address_2", "") or "",                "city": p.get("city", "") or "",                "state": p.get("state", "") or "",                "zip": p.get("zip", "") or "",                "country": p.get("country", "") or "",                "doc_type": m.get("doc_type", "") or "",                "doc_date": (m.get("document_date", "") or "")[:10],                "recorded_date": (m.get("recorded_datetime", "") or "")[:10],                "borough": BOROUGH.get(m.get("recorded_borough", ""), m.get("recorded_borough", "")),                "amount": m.get("document_amt", "") or "",                "filing_url": _filing_url(did),            }        )     Path(out_path).parent.mkdir(parents=True, exist_ok=True)    with open(out_path, "w", newline="", encoding="utf-8") as fh:        w = csv.DictWriter(fh, fieldnames=COLUMNS)        w.writeheader()        w.writerows(out_rows)     if not out_rows:        filters = []        if name:            filters.append(f"name={name!r}")        if address:            filters.append(f"address={address!r}")        print(            f"NYC ACRIS: 0 records for {', '.join(filters)}. "            "ACRIS covers ONLY NYC (5 boroughs). For property records elsewhere, "            "search the relevant county recorder directly.",            file=sys.stderr,        )    return len(out_rows)  def main() -> int:    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)    p.add_argument("--name", help="Party name substring (case-insensitive)")    p.add_argument("--address", help="Address line 1 substring")    p.add_argument(        "--party-type",        choices=["1", "2", "3"],        help="Filter party type: 1=grantor (seller/mortgagor), 2=grantee (buyer/mortgagee), 3=other",    )    p.add_argument("--limit", type=int, default=200)    p.add_argument(        "--no-enrich",        action="store_true",        help="Skip the master-document lookup that adds doc_type/date/amount",    )    p.add_argument("--out", required=True)    a = p.parse_args()    n = fetch(        name=a.name,        address=a.address,        party_type=a.party_type,        limit=a.limit,        out_path=a.out,        enrich=not a.no_enrich,    )    print(f"Wrote {n} NYC ACRIS rows to {a.out}")    return 0  if __name__ == "__main__":    raise SystemExit(main())