[object Object]

← back to Nationalrealestate

Consolidate agent M-P2/wave-2 work: Cook IL parcel adapter, parcel + broker coverage matrices

a8083411b5e67df57e070312b3e0b6a9b5bdb431 · 2026-07-21 17:23:43 -0700 · Steve Abrams

Files touched

Diff

commit a8083411b5e67df57e070312b3e0b6a9b5bdb431
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 21 17:23:43 2026 -0700

    Consolidate agent M-P2/wave-2 work: Cook IL parcel adapter, parcel + broker coverage matrices
---
 BROKER-COVERAGE-WAVE2.md        |  70 ++++++++++++++++++
 PARCEL-COVERAGE.md              |  49 +++++++++++++
 data/screenshots/firm-1815.jpg  | Bin 0 -> 70327 bytes
 data/screenshots/firm-1931.jpg  | Bin 0 -> 194299 bytes
 data/screenshots/firm-27269.jpg | Bin 0 -> 115087 bytes
 data/screenshots/firm-27334.jpg | Bin 0 -> 194299 bytes
 data/screenshots/firm-27342.jpg | Bin 0 -> 109401 bytes
 data/screenshots/firm-28043.jpg | Bin 0 -> 28310 bytes
 data/screenshots/firm-30123.jpg | Bin 0 -> 41242 bytes
 data/screenshots/firm-65704.jpg | Bin 0 -> 63678 bytes
 data/screenshots/firm-70948.jpg | Bin 0 -> 194299 bytes
 src/ingest/parcels/cook_il.ts   | 152 ++++++++++++++++++++++++++++++++++++++++
 12 files changed, 271 insertions(+)

diff --git a/BROKER-COVERAGE-WAVE2.md b/BROKER-COVERAGE-WAVE2.md
new file mode 100644
index 0000000..f73d130
--- /dev/null
+++ b/BROKER-COVERAGE-WAVE2.md
@@ -0,0 +1,70 @@
+# Broker registry — wave-2 state coverage (M-B2)
+
+Probed 2026-07-21, curl-only, same OPEN-FILES-ONLY rule as wave 1 (no portal
+scraping fights, no paid data, no browser sessions). Statuses match the wave-1
+matrix vocabulary. This file is wave-2 only — merge into BROKER-COVERAGE.md later.
+
+## Newly ingested (OPEN-INGESTED)
+
+| State | Source | URL | Rows ingested | Notes |
+|---|---|---|---|---|
+| IL | IDFPR "Professional Licensing" Socrata `pzzh-kp68` on illinois-edp.data.socrata.com | https://illinois-edp.data.socrata.com/resource/pzzh-kp68.csv?$where=license_type='REAL ESTATE' | **549,380 brokers + 30,295 firms** | 4.26M-row all-professions dataset; RE slice = 663,185 rows / 593,494 distinct license numbers (renewal-era duplicate rows — adapter orders by license_number,lastmodifieddate so engine last-write-wins keeps newest). `description` = specific credential (SALESPERSON 362k / BROKER 184k / MANAGING BROKER 26k / ASSOCIATE BROKER 5k / LEASING AGENT 40k individuals; BROKER CORPORATION 15.5k / LLF 5.2k / BRANCH OFFICE 9.7k / PARTNERSHIP / ASSOCIATION / VIRTUAL OFFICE firms). Education credentials (CE/pre-license courses, schools, instructors, sponsors) skipped. All statuses kept (ACTIVE 77k, NOT RENEWED 329k, EXPIRED 136k…), license_status preserved. No employer linkage columns → individuals carry no firm (CO-style). SODA CSV paginated $limit=300000, ≤2 req/s. Adapter `il_idfpr.ts`. |
+| DE | Division of Professional Regulation "Professional and Occupational Licensing" Socrata `pjnv-eaih` on data.delaware.gov | https://data.delaware.gov/resource/pjnv-eaih.csv?$where=profession_id='Real Estate' | **29,895 brokers + 997 firms** | 351k-row all-professions dataset; RE slice = 30,895 rows. Individual types: (Resident/Nonresident) Salesperson 24.9k, (Resident/Nonresident/Associate) Broker 5.9k. Main Office Permit (638) + Branch Office Permit (361) = brokerage offices → firm rows (company name in last_name/combined_name). All statuses kept. No employer linkage. Adapter `de_dpr.ts`. |
+
+Registry after wave 2: **1,506,248 brokers / 172,883 firms** across 7 states
+(TX, FL, NY, CO, CT, IL, DE). Both ingest_runs green; `all` regression re-run
+green across all 7 adapters. Cost $0 (public records).
+
+## Probed and blocked this pass
+
+| State | Source | Status | Detail |
+|---|---|---|---|
+| AZ | ADRE PdbWeb (services.azre.gov/PdbWeb) | **GATED-confirmed** | Wave-1's "underlying JSON API" candidate does not exist: PdbWeb is classic ASP.NET MVC + Kendo, not a JSON SPA. Search pages (SearchIndividualLicenses / SearchEntityLicenses) are form-POSTs with `__RequestVerificationToken` anti-forgery + session cookie, REQUIRE ≥1 populated text field (prefix-match), and the "export" is built client-side with jszip from the returned search-result table. Bulk access would mean enumerating name/license-number prefixes through the search form = portal-scraping fight. |
+| PA | data.pa.gov `fwj2-whnj` "Pennsylvania Professional Licensee Data County State" | **GATED-portal** (counts only) | Looked licensee-level from the title, is actually per-county × license-type ACTIVE COUNTS (6,052 rows). PALS lookup remains the only per-licensee surface. |
+| IA | data.iowa.gov | **GATED (platform migration)** | The Socrata portal is GONE — data.iowa.gov is now a custom Next.js data hub (catalog renders client-side; no discoverable JSON API from the RSC payload or JS chunks; /resource/ endpoints 404; Socrata discovery resultSetSize=0 for the domain). "Iowa Professional Licenses" dataset unreachable without a browser. Recheck when the new portal ships a public API (CSP hints data files live on storage.googleapis.com). |
+| OH | eLicense (Salesforce) + com.ohio.gov DRE | **NOT-FOUND (recheck confirmed dead)** | eLicense is a Salesforce community (Aura endpoints = portal fight); the old com.ohio.gov DRE roster pages 404. No RE dataset on any Socrata/CKAN surface. |
+| WA | data.wa.gov | **GATED-portal (recheck confirmed)** | Searched `q=licenses` across the domain: only COUNTS (`a2n7-rij5`), transactions, and — bizarrely — individual-level professional licenses for ASOTIN COUNTY ONLY (`c738-vqng`). No statewide licensee-level RE roster. |
+| NJ | data.nj.gov + mylicense portal | **GATED-portal (recheck confirmed)** | Socrata domain exists but has no professional-licensee datasets (child care, vehicle inspection…). |
+| IN | PLA bulk download (secure.in.gov/apps/pla/license/bulk/pla_bulk_ia) | **GATED-paid** | Beautiful trap: free-looking public bulk form (agree → pick "Real Estate *" types → search) returns "33,606 records for a total of **$500.01**" + credit-card form. IN.gov charges per record. |
+| NV | red.nv.gov | **GATED (bot-wall)** | Entire site serves a JS challenge shell (base64 placeholder images, no real HTML) to curl. |
+| AK | commerce.alaska.gov CBPL | **GATED (WAF)** | The known free professional-license DB download page 403s curl (even with browser UA). Candidate for a later real-browser pass — the underlying CSV is genuinely free. |
+| SC | LLR (llr.sc.gov / verify.llronline.com) | **GATED (WAF)** | 403 on verify; datasets page redirects home. |
+| MA | mass.gov DOL licensee lists | **GATED (WAF)** | Akamai 403s curl even with full browser headers. MA does publish licensee lists — real-browser candidate for wave 3. |
+| NH | oplc.nh.gov license-data | **GATED (WAF)** | 403. |
+| MO | pr.mo.gov | **GATED-portal** | Division of Professional Registration migrated to Salesforce Lightning ("/license/s/sfsites/") — old listings.asp gone. |
+| MI | LARA / data.michigan.gov | **GATED** | Socrata domain has only boilerplate report PDFs; LARA lookup pages moved/404; BPL custom lists are a paid service. |
+| OR | Oregon REA (oregon.gov/rea) | **GATED-portal** | eLicense = Micropact portal; no data/roster links on the licensing pages; data.oregon.gov has no RE datasets. |
+| MD | opendata.maryland.gov + onestop.md.gov | **GATED** | Socrata has property assessments, not licensees. OneStop is a "Formability" JS platform (JSON endpoints exist but registries are behind hashed IDs + search UI). |
+| VT | data.vermont.gov + sos.vermont.gov OPR | **NOT-FOUND** | "DFS Licensing MasterList" is Division of FIRE SAFETY trades (gas installers…), not RE. OPR publishes only monthly discipline reports. |
+| MT | data.mt.gov / ebiz.mt.gov | **GATED-portal** | No Socrata licensee data; ebiz redirects to Accela portal. |
+| AR | arec.arkansas.gov / labor.arkansas.gov roster search | **GATED (WAF)** | Roster-search page 403s curl. |
+| KY | krec.ky.gov (data.ky.gov doesn't exist) | **NOT-FOUND** | No roster/data downloads on the site. |
+| LA | lrec.gov | **NOT-FOUND** | No data/roster page in nav; licensee search only. |
+| KS | krec.ks.gov | **GATED (WAF)** | 403. |
+| NE | nrec.nebraska.gov | **GATED-portal** | igovsolution verification portal only. |
+| WY | realestate.wyo.gov | **NOT-FOUND** | No roster downloads. |
+| SD | dlr.sd.gov/realestate | **NOT-FOUND** | Application forms only; sdrec.sd.gov is a login portal. |
+| TN | tn.gov/commerce/regboards/trec | **UNREACHABLE** | Connection failed this pass (000); recheck next pass. |
+| HI | data.hawaii.gov | **NOT-FOUND** | Socrata domain returns 0 datasets (also migrated/dead). |
+| MN / WI / MS / AL / SC / DC | various | **NOT-FOUND** | No state Socrata domains (`data.mn.gov`, `data.wi.gov`, `data.ms.gov`, `data.alabama.gov`, `data.sc.gov`, `opendata.dc.gov` all absent from Socrata discovery). WI DSPS + MN Commerce are known paid/portal. |
+
+## Framework changes
+
+None to the shared engine/harness beyond registry appends (`ilIdfpr`, `deDpr`
+in engine.ts ADAPTERS). Both new adapters are plain StateAdapter
+implementations; IL introduced the SODA-paginated-CSV fetch pattern
+($limit/$offset pages, header-only page = stop, 500ms inter-page delay)
+reusable for any future big Socrata slice.
+
+## Wave-3 candidates
+
+1. **AK CBPL CSV** — genuinely free full professional-license DB; only a WAF
+   between us and it. One real-browser (openclaw/Browserbase) fetch would do it.
+2. **MA DOL licensee lists** — published Excel lists behind an Akamai wall;
+   same real-browser treatment.
+3. **CT upgrade** — `eqtn-rppv` (status w/ broker) + DCP full credential
+   extract to add CT firm HQ cities + broker-side roster.
+4. **IA new portal API** — watch for the Next.js data hub exposing its catalog
+   API; files live on GCS per CSP.
+5. **TN TREC** — retry (network blip this pass); TN C&I historically posts
+   roster files.
diff --git a/PARCEL-COVERAGE.md b/PARCEL-COVERAGE.md
new file mode 100644
index 0000000..4caa426
--- /dev/null
+++ b/PARCEL-COVERAGE.md
@@ -0,0 +1,49 @@
+# Parcel-level coverage (M-P2)
+
+Property-detail layer sources per county. Everything here is **$0 genuinely-open
+bulk data** — no paid APIs, no registration-gated feeds ingested. Registry of
+record: the `parcel_source` PG table (what `/api/property/coverage` serves).
+LA reads in place from CRCP's SQLite (`source_kind='sqlite'`); every other
+covered county lives in the generic `parcel` PG table (`source_kind='pg'`,
+migration 004, adapters in `src/ingest/parcels/`).
+
+## Covered counties
+
+| County (FIPS) | Rows | Source | Refresh | Fills |
+|---|---|---|---|---|
+| Los Angeles, CA (06037) | 2,427,010 | CRCP SQLite export of the 2025 secured roll (read in place) | via CRCP | Details (beds/baths/sqft/yr), Tax (1 roll yr), recording date; **no owner / zoning / sale price** (see LA probe below) |
+| NYC — Manhattan 36061 (42,544), Bronx 36005 (89,496), Brooklyn 36047 (276,311), Queens 36081 (324,559), Staten Island 36085 (125,692) = **858,602 lots** | | MapPLUTO via NYC Open Data Socrata `64uk-42ks` (JSON paging; `ingest:parcels nyc`) | re-run adapter (PLUTO versioned, currently 26v1) | Details (lot-level: sqft/yr/units — no beds/baths), Tax (assessed land/imp/total), **Ownership (owner of record)**, **Zoning (zonedist1)**, Map (parcel lat/lng) |
+| King, WA — Seattle (53033) | 628,417 | Assessor EXTR bulk zips, `aqua.kingcounty.gov/extranet/assessor` (Parcel / ResBldg / RPAcct / RPSale / Lookup; `ingest:parcels king`) | re-run adapter (extracts refresh weekly) | Details (beds/baths/sqft/yr — 525k w/ address), Tax (2026 appraised land+imp), **Property History: REAL recorded deed sales w/ price+buyer+seller (536k parcels)**, Zoning (627k), Owner = latest deed buyer (names file is `_NoName` by policy) |
+| Cook, IL — Chicago (17031) | ~1,863,530 | Assessor Socrata: universe `pabr-t5kh` (2026, lat/lng+class) + addresses/owner `3723-97qp` (2026) + certified ASSESSED values `uzyt-m557` (2025) + res characteristics `x54s-btds` (2026) (`ingest:parcels cook`) | re-run adapter (annual cycle) | Details (beds/baths/sqft/yr for 1.1M res pins), Tax (2025 certified **assessed** value — 10%/25% of market, labeled), **Ownership (owner/taxpayer name + mailing)**, Map (parcel lat/lng); no zoning (municipal), no sale feed |
+
+## Probed — gated or absent
+
+| Target | Verdict | Detail |
+|---|---|---|
+| Maricopa, AZ (04013) | **GATED** | mcassessor.maricopa.gov "file/home" download center is an SPA; guessed bulk URLs (e.g. `/file/home/MC_Parcels.zip`) return the HTML app, real downloads sit behind a registration/agreement form. Not open-bulk → skipped per rules. |
+| LA ownership enrichment | **ABSENT (by policy)** | data.lacounty.gov Socrata is DEAD (redirects to hub.arcgis.com/legacy — URL drift). The live public layer `public.gis.lacounty.gov/.../LACounty_Cache/LACounty_Parcel/MapServer/0` exposes 92 fields — situs, use, beds/baths/sqft (5 bldgs), roll values, legal desc, CENTER_LAT/LON — but **no owner-name field anywhere**. LA County excludes owner names from public bulk data. |
+| LA zoning enrichment | **ABSENT (city-scoped)** | No zoning field in the county parcel layer. ZIMAS zoning is City-of-LA GIS only (doesn't cover the other 87 cities); deferred, not rabbit-holed. Upgrade path noted: the county layer's CENTER_LAT/LON could geocode LA parcels (map precision parcel-level) — 2.4M features at maxRecordCount=1000 ≈ 2.4k requests, future pass. |
+| NYC DCP zip (bytes of the big apple) | **URL drift** | `s-media.nyc.gov/.../nyc_pluto_25v{1,2}_csv.zip` → 404. Socrata `64uk-42ks` is the stable path (858,602 rows verified vs ~850k expected). |
+
+## Opportunistic top-20 probes (not ingested this pass)
+
+| County | Signal | Note |
+|---|---|---|
+| Miami-Dade, FL | promising | opendata.miamidade.gov Socrata + ArcGIS open data respond; property-appraiser GIS extracts exist. Next candidate. |
+| Franklin, OH (Columbus) | promising | `apps.franklincountyauditor.com/GIS_Shapefiles/` open directory (200) — auditor publishes open extracts. |
+| Wake, NC (Raleigh) | promising | wake.gov real-estate data files page live (200) — RE data files are open CSVs. |
+| Travis, TX (Austin) | drifted | traviscad.org export page moved (404 on known path); TCAD publishes appraisal-roll exports but the entry point needs re-discovery. |
+
+## Sale-history depth
+
+Only King ships true sale records this pass (EXTR_RPSale deed chain, 10 most
+recent priced sales per parcel in `parcel.extra.sales`). NYC's equivalent is
+ACRIS (open, large) — queued as the next history enrich. Cook sale prices live
+with the Recorder (paid/bulk-request) — pending. LA recording date only.
+
+## Refresh
+
+`npm run ingest:parcels -- <nyc|king|cook>` — each adapter is idempotent
+(upsert on `(county_fips, source_id)`), opens/closes an `ingest_runs` row, and
+re-registers `parcel_source` counts. Failures close the run `failed` (red),
+never silent.
diff --git a/data/screenshots/firm-1815.jpg b/data/screenshots/firm-1815.jpg
new file mode 100644
index 0000000..707d75c
Binary files /dev/null and b/data/screenshots/firm-1815.jpg differ
diff --git a/data/screenshots/firm-1931.jpg b/data/screenshots/firm-1931.jpg
new file mode 100644
index 0000000..ab3e35e
Binary files /dev/null and b/data/screenshots/firm-1931.jpg differ
diff --git a/data/screenshots/firm-27269.jpg b/data/screenshots/firm-27269.jpg
new file mode 100644
index 0000000..de4bcdf
Binary files /dev/null and b/data/screenshots/firm-27269.jpg differ
diff --git a/data/screenshots/firm-27334.jpg b/data/screenshots/firm-27334.jpg
new file mode 100644
index 0000000..ab3e35e
Binary files /dev/null and b/data/screenshots/firm-27334.jpg differ
diff --git a/data/screenshots/firm-27342.jpg b/data/screenshots/firm-27342.jpg
new file mode 100644
index 0000000..c454be9
Binary files /dev/null and b/data/screenshots/firm-27342.jpg differ
diff --git a/data/screenshots/firm-28043.jpg b/data/screenshots/firm-28043.jpg
new file mode 100644
index 0000000..d90b96b
Binary files /dev/null and b/data/screenshots/firm-28043.jpg differ
diff --git a/data/screenshots/firm-30123.jpg b/data/screenshots/firm-30123.jpg
new file mode 100644
index 0000000..7fb2bcc
Binary files /dev/null and b/data/screenshots/firm-30123.jpg differ
diff --git a/data/screenshots/firm-65704.jpg b/data/screenshots/firm-65704.jpg
new file mode 100644
index 0000000..4322e4f
Binary files /dev/null and b/data/screenshots/firm-65704.jpg differ
diff --git a/data/screenshots/firm-70948.jpg b/data/screenshots/firm-70948.jpg
new file mode 100644
index 0000000..ab3e35e
Binary files /dev/null and b/data/screenshots/firm-70948.jpg differ
diff --git a/src/ingest/parcels/cook_il.ts b/src/ingest/parcels/cook_il.ts
new file mode 100644
index 0000000..393795a
--- /dev/null
+++ b/src/ingest/parcels/cook_il.ts
@@ -0,0 +1,152 @@
+/**
+ * Cook County IL (Chicago, FIPS 17031) parcel ingest from the county's open
+ * Socrata portal (datacatalog.cookcountyil.gov, $0, no auth):
+ *   pabr-t5kh  Assessor - Parcel Universe (Current Year Only)     1.86M pins: class, township, zip, lat/lng
+ *   3723-97qp  Assessor - Parcel Addresses (year=2026)            situs address + owner/taxpayer name + mailing
+ *   uzyt-m557  Assessor - Assessed Values (year=2025, certified)  latest fully-certified assessment year
+ *   x54s-btds  Assessor - Single & Multi-Family Improvement       year built, bldg sqft, beds, baths (1.1M res pins)
+ *              Characteristics (year=2026)
+ * No zoning dataset (zoning is municipal in Cook) and no sale-price feed here —
+ * both recorded as pending. Values are ASSESSED values (10%/25% of market),
+ * not market value — noted in the payload.
+ *
+ * Run: npm run ingest:parcels cook   (NODE_OPTIONS=--max-old-space-size=6144)
+ */
+import { pool } from '../../../db/pool.ts';
+import { openRun, closeRun } from '../run.ts';
+import { upsertParcels, registerParcelSource, normAddress, type ParcelUpsertRow } from './upsert.ts';
+
+const FIPS = '17031';
+const BASE = 'https://datacatalog.cookcountyil.gov/resource/';
+const PAGE = 200000;
+
+const CLASS_GROUP: Record<string, string> = {
+  '1': 'Vacant Land', '2': 'Residential (<7 units)', '3': 'Multi-Family (7+ units)',
+  '4': 'Not-for-Profit', '5': 'Commercial', '6': 'Industrial (incentive)',
+  '7': 'Commercial (incentive)', '8': 'Commercial/Industrial (incentive)', '9': 'Residential (incentive)',
+  '0': 'Exempt/Railroad',
+};
+
+const num = (v: unknown) => { const n = Number(v); return Number.isFinite(n) && n !== 0 ? n : null; };
+
+async function fetchPage(dataset: string, params: string, offset: number, tries = 3): Promise<any[]> {
+  const url = `${BASE}${dataset}.json?${params}&$limit=${PAGE}&$offset=${offset}`;
+  for (let t = 1; ; t++) {
+    try {
+      const res = await fetch(url, { headers: { accept: 'application/json' } });
+      if (!res.ok) throw new Error(`${dataset} HTTP ${res.status} at offset ${offset}`);
+      return await res.json() as any[];
+    } catch (e) {
+      if (t >= tries) throw e;
+      console.log(`[cook] retry ${t} for ${dataset} @${offset}: ${e}`);
+      await new Promise(r => setTimeout(r, 5000 * t));
+    }
+  }
+}
+
+async function* pages(dataset: string, select: string, where?: string): AsyncGenerator<any[]> {
+  const params = `$select=${encodeURIComponent(select)}` +
+    (where ? `&$where=${encodeURIComponent(where)}` : '') + `&$order=row_id`;
+  for (let offset = 0; ; offset += PAGE) {
+    const data = await fetchPage(dataset, params, offset);
+    if (!data.length) return;
+    yield data;
+    if (data.length < PAGE) return;
+  }
+}
+
+export async function ingestCook(): Promise<{ upserted: number }> {
+  const runId = await openRun('parcel_cook_il', BASE + 'pabr-t5kh.json');
+  try {
+    // 1) situs address + owner/taxpayer name (current assessment year)
+    const addr = new Map<string, [addr: string, city: string, zip: string, owner: string, mailing: string]>();
+    for await (const page of pages('3723-97qp',
+      'pin,prop_address_full,prop_address_city_name,prop_address_zipcode_1,owner_address_name,mail_address_name,mail_address_full,mail_address_city_name,mail_address_state,mail_address_zipcode_1',
+      'year=2026')) {
+      for (const o of page) {
+        const mailing = [o.mail_address_full, o.mail_address_city_name, o.mail_address_state, o.mail_address_zipcode_1]
+          .map((s: string) => (s || '').trim()).filter(Boolean).join(', ');
+        addr.set(o.pin, [(o.prop_address_full || '').trim(), (o.prop_address_city_name || '').trim(),
+          (o.prop_address_zipcode_1 || '').trim(), (o.owner_address_name || o.mail_address_name || '').trim(), mailing]);
+      }
+      console.log(`[cook] addresses: ${addr.size}`);
+    }
+
+    // 2) latest fully-certified assessed values (2025; 2026 not yet mailed county-wide)
+    const vals = new Map<string, [land: number | null, bldg: number | null, tot: number | null]>();
+    for await (const page of pages('uzyt-m557',
+      'pin,certified_land,certified_bldg,certified_tot,board_land,board_bldg,board_tot', 'year=2025')) {
+      for (const o of page) {
+        vals.set(o.pin, [num(o.board_land) ?? num(o.certified_land), num(o.board_bldg) ?? num(o.certified_bldg),
+          num(o.board_tot) ?? num(o.certified_tot)]);
+      }
+      console.log(`[cook] values: ${vals.size}`);
+    }
+
+    // 3) residential improvement characteristics (largest card wins on multi-card pins)
+    const chars = new Map<string, [yrblt: number | null, sf: number | null, beds: number | null, baths: number | null]>();
+    for await (const page of pages('x54s-btds',
+      'pin,char_yrblt,char_bldg_sf,char_beds,char_fbath,char_hbath', 'year=2026')) {
+      for (const o of page) {
+        const sf = num(o.char_bldg_sf);
+        const prev = chars.get(o.pin);
+        if (prev && (prev[1] ?? 0) >= (sf ?? 0)) continue;
+        const baths = (Number(o.char_fbath) || 0) + 0.5 * (Number(o.char_hbath) || 0);
+        chars.set(o.pin, [num(o.char_yrblt), sf, num(o.char_beds), baths || null]);
+      }
+      console.log(`[cook] chars: ${chars.size}`);
+    }
+
+    // 4) parcel universe drives; join + upsert
+    let upserted = 0;
+    for await (const page of pages('pabr-t5kh',
+      'pin,class,township_name,zip_code,lon,lat,cook_municipality_name')) {
+      const batch: ParcelUpsertRow[] = [];
+      for (const o of page) {
+        if (!o.pin) continue;
+        const a = addr.get(o.pin);
+        const v = vals.get(o.pin);
+        const c = chars.get(o.pin);
+        const cls = (o.class || '').trim();
+        const group = CLASS_GROUP[cls[0]] || null;
+        const address = a?.[0] ? normAddress(a[0]) : null;
+        batch.push({
+          county_fips: FIPS, source_id: o.pin,
+          address, norm_address: address,
+          city: a?.[1] || (o.cook_municipality_name || '').trim() || null,
+          zip: a?.[2] || (o.zip_code || '').trim() || null,
+          lat: num(o.lat), lng: num(o.lon),
+          year_built: c?.[0] ?? null, sqft: c?.[1] ?? null, beds: c?.[2] ?? null, baths: c?.[3] ?? null,
+          units: null,
+          use_desc: group ? `${group} — Class ${cls}` : cls ? `Class ${cls}` : null,
+          land_value: v?.[0] ?? null, improvement_value: v?.[1] ?? null, total_value: v?.[2] ?? null,
+          tax_year: v ? '2025' : null,
+          owner_name: a?.[3] || null, zoning: null,
+          last_sale_date: null, last_sale_price: null,
+          extra: JSON.stringify({
+            class: cls, township: (o.township_name || '').trim() || undefined,
+            mailing_address: a?.[4] || undefined,
+            owner_source: a?.[3] ? 'owner/taxpayer name, Cook County Assessor parcel addresses (2026)' : undefined,
+            value_basis: v ? 'ASSESSED value (Cook County 2025 certified/BOR — 10% res / 25% comm of market value)' : undefined,
+          }),
+        });
+      }
+      upserted += await upsertParcels(batch);
+      console.log(`[cook] ${upserted} upserted`);
+    }
+    if (upserted < 1500000) throw new Error(`only ${upserted} Cook parcels — expected ~1.86M, dataset drift?`);
+
+    const n = await registerParcelSource(FIPS, BASE + 'pabr-t5kh.json',
+      'Cook County IL Assessor open data — situs address + owner/taxpayer (3723-97qp 2026), certified ASSESSED values (uzyt-m557 2025), res characteristics (x54s-btds 2026), lat/lng+class (pabr-t5kh). No zoning (municipal) or sale-price feed.');
+    await closeRun(runId, 'ok', { upserted, notes: `Cook IL: ${upserted} parcels (addr ${addr.size}, vals ${vals.size}, chars ${chars.size})` });
+    console.log(`[cook] ok: ${upserted} parcels (registry ${n})`);
+    return { upserted };
+  } catch (e: any) {
+    await closeRun(runId, 'failed', { notes: String(e.message || e) });
+    throw e;
+  }
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  ingestCook().then(() => pool.end()).catch(e => { console.error(e); process.exit(1); });
+}

← 93eff11 Broker wave-2: DE DPR adapter ingested (29,895 brokers + 997  ·  back to Nationalrealestate  ·  Public Records Sources page (/sources.html) — every open-dat 841ad6f →