forked from Zakaria/hermes-agent
204 lines
6.4 KiB
Python
204 lines
6.4 KiB
Python
#!/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 to
|
|
Master to get the doc type and date.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import sys
|
|
import urllib.parse
|
|
from 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())
|