← back to Nationalrealestate
parcels: add Wake County NC (37183) — 437k parcels, richest layer (owner+assessed value+sqft+year+sale)
869978516ac0b351d6b5ed2aa79512ecb71f3009 · 2026-07-26 14:28:38 -0700 · Steve Abrams
Authoritative Wake Assessor ArcGIS MapServer (the www data page 404s but the REST service is live+paginated). Dedicated streaming adapter (Miami DTD-B precedent). 437,112 parcels: 100% owner, 96% assessed LAND+BLDG+TOTAL value, 86% sqft, 87% year-built, 78% sale price -> 355k parcel_event rows current to 2024+. First county with assessed value+sqft+year populated. Deed-based in-DB bulk guard: 62,147 assemblage sales flagged. Ticket TK-5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M PARCEL-COVERAGE.mdM src/ingest/parcels/engine.tsA src/ingest/parcels/wake_nc.ts
Diff
commit 869978516ac0b351d6b5ed2aa79512ecb71f3009
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 26 14:28:38 2026 -0700
parcels: add Wake County NC (37183) — 437k parcels, richest layer (owner+assessed value+sqft+year+sale)
Authoritative Wake Assessor ArcGIS MapServer (the www data page 404s but the REST service is live+paginated). Dedicated streaming adapter (Miami DTD-B precedent). 437,112 parcels: 100% owner, 96% assessed LAND+BLDG+TOTAL value, 86% sqft, 87% year-built, 78% sale price -> 355k parcel_event rows current to 2024+. First county with assessed value+sqft+year populated. Deed-based in-DB bulk guard: 62,147 assemblage sales flagged. Ticket TK-5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
PARCEL-COVERAGE.md | 3 +-
src/ingest/parcels/engine.ts | 1 +
src/ingest/parcels/wake_nc.ts | 146 ++++++++++++++++++++++++++++++++++++++++++
3 files changed, 149 insertions(+), 1 deletion(-)
diff --git a/PARCEL-COVERAGE.md b/PARCEL-COVERAGE.md
index 180e5ca..f572417 100644
--- a/PARCEL-COVERAGE.md
+++ b/PARCEL-COVERAGE.md
@@ -14,6 +14,7 @@ migration 004, adapters in `src/ingest/parcels/`).
| 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) |
+| Wake, NC — Raleigh (37183) | 437,112 | County Assessor authoritative ArcGIS MapServer (`ingest:parcels wake`) | re-run adapter (remote-runnable) | **Richest layer: Details (sqft 86%, year-built 87%, use, units), Ownership (owner 100%), assessed LAND+BLDG+TOTAL value (96%), Property History (sale price+date+deed → 355k events, current to 2024+), Map (polygon centroid)**. No beds/baths or zoning. 62,147 multi-parcel-deed sales flagged bulk |
| Miami-Dade, FL (12086) | 937,182 | Property Appraiser GIS `MDC.PaGis` (keyless ArcGIS MapServer; `ingest:parcels miami`) | re-run adapter (remote-runnable, no GDAL) | Details (use class), **Ownership (TRUE_OWNER1, current)**, **Zoning**, **Property History: up to 3 real sales each w/ price+grantor+grantee+deed → 2.06M parcel_event rows, dates current to 2025**, Map (point lat/lng). Assessed value/beds/baths/sqft/year-built NULL in the public layer. 62,745 multi-folio-deed (assemblage) latest sales flagged bulk + price nulled |
| Franklin, OH — Columbus (39049) | 122,047 | Auditor monthly FileGeoDatabase, `TaxParcelSale` layer (GDAL-extracted; `ingest:parcels franklin`) — see FRANKLIN-RUNBOOK.md | re-run adapter on a GDAL machine, push rows to prod | Details (site address, res sqft, use class), **Ownership (latest grantee)**, **Property History: real sale date+price**, Map (point lat/lng). **Sale window fixed 2020-01..2023-05** (owner = latest-in-window, not current-day); no assessed value/year-built/beds-baths; ~341k geometry-only parcels are a full-coverage TODO |
| 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 |
@@ -41,7 +42,7 @@ migration 004, adapters in `src/ingest/parcels/`).
| County | Verdict | Detail |
|---|---|---|
| Franklin, OH | **DONE 2026-07-26 (via .gdb + GDAL)** | The polygon shapefile turned out geometry-only (PARCELID + Shape). The rich CAMA data is inside the 603MB monthly **FileGeoDatabase**; parsed with GDAL/ogr2ogr (local-only dep) → 122,047 parcels from the `TaxParcelSale` layer. Landed via `ingest:parcels franklin`. See the covered-counties table above + FRANKLIN-RUNBOOK.md. |
-| Wake, NC | **URL drift** | The documented data-files page 404s now (site reorg). Wake still publishes open RE data files; entry point needs re-discovery before an adapter. |
+| Wake, NC | **DONE 2026-07-26** | The www.wake.gov data PAGE 404s, but the authoritative ArcGIS MapServer (maps.wakegov.com) is live + paginated + rich. Landed via wake_nc.ts → 437,112 parcels. See covered table above. |
| Broward, FL | **GATED (WAF)** | `gis.broward.org` ArcGIS returns 000 to curl; hosted-org guesses empty. BCPA rich data not reachable via direct probe — needs a real-browser or the BCPA bulk-download route (future). |
| Palm Beach, FL | **thin (cached-only)** | `maps.co.palm-beach.fl.us` responds with PAO/Parcels folders, but every layer (Parcels, salesovertime) is geometry-only cached tiles (FID/Shape/Id, no attributes, pagination:false). Rich PAPA data is behind the WAF'd `gisservices.pbcgov.org` (000). Not a clean load — defer to a PAPA-bulk / real-browser pass. |
| Miami-Dade, FL | **DONE 2026-07-26 (ArcGIS)** | Socrata is dead there, but the Property Appraiser's keyless ArcGIS MapServer layer `MDC.PaGis` (`gisweb.miamidade.gov`) serves the full 943k universe with owner+address+use+zoning+3 sales. Landed via dedicated `miami_dade.ts` (`ingest:parcels miami`) → 937,182 parcels, 2.06M sale events. See covered-counties table above. |
diff --git a/src/ingest/parcels/engine.ts b/src/ingest/parcels/engine.ts
index 411868d..4d74eb1 100644
--- a/src/ingest/parcels/engine.ts
+++ b/src/ingest/parcels/engine.ts
@@ -13,6 +13,7 @@ const ADAPTERS: Record<string, () => Promise<{ run: () => Promise<{ upserted: nu
cook: async () => ({ run: (await import('./cook_il.ts')).ingestCook }),
franklin: async () => ({ run: (await import('./franklin_oh.ts')).ingestFranklin }),
miami: async () => ({ run: (await import('./miami_dade.ts')).ingestMiami }),
+ wake: async () => ({ run: (await import('./wake_nc.ts')).ingestWake }),
sandiego: async () => { const m = await import('./sandiego_ca.ts'); return { run: () => m.ingestSanDiego('poway') }; },
'sandiego-county': async () => { const m = await import('./sandiego_ca.ts'); return { run: () => m.ingestSanDiego('county') }; },
'oregon-rlis': async () => { const m = await import('./arcgis_sales.ts'); return { run: m.ingestArcgisSales('oregon-rlis') }; },
diff --git a/src/ingest/parcels/wake_nc.ts b/src/ingest/parcels/wake_nc.ts
new file mode 100644
index 0000000..8b7511e
--- /dev/null
+++ b/src/ingest/parcels/wake_nc.ts
@@ -0,0 +1,146 @@
+/**
+ * Wake County NC (Raleigh, FIPS 37183) parcel ingest — dedicated full-universe
+ * adapter (applies the Miami-Dade DTD verdict B precedent: rich full-universe
+ * ArcGIS county gets a dedicated adapter, not the sale-cursor generic one, so the
+ * real OWNER field is preserved).
+ *
+ * Source: the county's authoritative keyless ArcGIS MapServer (437,234 parcels,
+ * polygon geom, supportsPagination, 2k/page, $0):
+ * https://maps.wakegov.com/arcgis/rest/services/Property/Parcels/MapServer/0
+ * (The county's www.wake.gov *data-download page* 404s, but this REST service is
+ * live — that URL drift is what stalled the earlier pass.)
+ *
+ * Richest county in the system: OWNER, SITE_ADDRESS/CITY/ZIP, LAND_VAL +
+ * BLDG_VAL + TOTAL_VALUE_ASSD (assessed values), HEATEDAREA (sqft), YEAR_BUILT,
+ * TYPE_USE_DECODE (use), UNITS, TOTSALPRICE + SALE_DATE (epoch-ms, current to
+ * 2024+), DEED_BOOK/PAGE. Only beds/baths + zoning are absent. Bulk guard:
+ * a deed (DEED_BOOK-DEED_PAGE) spanning >1 REID is an assemblage → price nulled
+ * + flagged in-DB (Miami/Franklin lesson).
+ *
+ * Run: NODE_OPTIONS=--max-old-space-size=4096 npm run ingest:parcels wake
+ */
+import { pool, query } from '../../../db/pool.ts';
+import { openRun, closeRun } from '../run.ts';
+import { upsertParcels, registerParcelSource, normAddress, type ParcelUpsertRow } from './upsert.ts';
+
+const FIPS = '37183';
+const LAYER = 'https://maps.wakegov.com/arcgis/rest/services/Property/Parcels/MapServer/0';
+const PAGE = 2000;
+
+const s = (v: unknown): string | null => { const t = v == null ? '' : String(v).trim(); return t ? t : null; };
+const num = (v: unknown): number | null => { const x = Number(v); return Number.isFinite(x) && x !== 0 ? x : null; };
+const epochToISO = (v: unknown): string | null => { const x = Number(v); if (!Number.isFinite(x) || x <= 0) return null; return new Date(x).toISOString().slice(0, 10); };
+/** area-weighted-ish centroid of the first ring (cheap; good enough for a map pin) */
+function centroid(geom: any): [number | null, number | null] {
+ const ring = geom?.rings?.[0]; if (!Array.isArray(ring) || !ring.length) return [null, null];
+ let sx = 0, sy = 0; for (const [x, y] of ring) { sx += x; sy += y; }
+ return [+(sx / ring.length).toFixed(6), +(sy / ring.length).toFixed(6)];
+}
+
+const OUT = ['REID', 'PIN_NUM', 'OWNER', 'SITE_ADDRESS', 'CITY_DECODE', 'ZIPNUM',
+ 'LAND_VAL', 'BLDG_VAL', 'TOTAL_VALUE_ASSD', 'HEATEDAREA', 'YEAR_BUILT', 'UNITS',
+ 'TYPE_USE_DECODE', 'DESIGN_STYLE_DECODE', 'TOTSALPRICE', 'SALE_DATE', 'DEED_BOOK', 'DEED_PAGE', 'DEED_DATE'].join(',');
+
+async function fetchPage(offset: number): Promise<any[]> {
+ const u = new URL(LAYER + '/query');
+ u.searchParams.set('where', '1=1');
+ u.searchParams.set('outFields', OUT);
+ u.searchParams.set('orderByFields', 'OBJECTID ASC');
+ u.searchParams.set('resultOffset', String(offset));
+ u.searchParams.set('resultRecordCount', String(PAGE));
+ u.searchParams.set('returnGeometry', 'true');
+ u.searchParams.set('outSR', '4326');
+ u.searchParams.set('f', 'json');
+ let lastErr: any;
+ for (let a = 0; a < 3; a++) {
+ try {
+ const res = await fetch(u, { signal: AbortSignal.timeout(120_000) });
+ if (!res.ok) throw new Error(`wake ${res.status}: ${(await res.text()).slice(0, 140)}`);
+ const j: any = await res.json();
+ if (j.error) throw new Error(`wake error: ${JSON.stringify(j.error).slice(0, 140)}`);
+ return j.features || [];
+ } catch (e) { lastErr = e; if (a < 2) await new Promise(r => setTimeout(r, 2000 * (a + 1))); }
+ }
+ throw lastErr;
+}
+
+export async function ingestWake(): Promise<{ upserted: number }> {
+ const runId = await openRun('parcel_wake_nc', LAYER);
+ try {
+ let offset = 0, seen = 0, upserted = 0, sales = 0;
+ for (;;) {
+ const feats = await fetchPage(offset);
+ if (!feats.length) break;
+ const batch: ParcelUpsertRow[] = [];
+ const events: any[] = [];
+ for (const f of feats) {
+ const a = f.attributes || {};
+ const reid = s(a.REID); if (!reid) continue;
+ seen++;
+ const [lng, lat] = centroid(f.geometry);
+ const addr = s(a.SITE_ADDRESS);
+ const norm = addr ? normAddress(addr) : null;
+ const saleDate = epochToISO(a.SALE_DATE);
+ const salePrice = num(a.TOTSALPRICE);
+ const deed = `${s(a.DEED_BOOK) ?? ''}-${s(a.DEED_PAGE) ?? ''}`;
+ batch.push({
+ county_fips: FIPS, source_id: reid,
+ address: norm, norm_address: norm,
+ city: s(a.CITY_DECODE), zip: s(a.ZIPNUM),
+ lat, lng,
+ year_built: num(a.YEAR_BUILT), sqft: num(a.HEATEDAREA), beds: null, baths: null, units: num(a.UNITS),
+ use_desc: s(a.TYPE_USE_DECODE),
+ land_value: num(a.LAND_VAL), improvement_value: num(a.BLDG_VAL), total_value: num(a.TOTAL_VALUE_ASSD),
+ tax_year: null, owner_name: s(a.OWNER), zoning: null,
+ last_sale_date: saleDate, last_sale_price: salePrice,
+ extra: JSON.stringify({
+ pin: s(a.PIN_NUM) || undefined, design_style: s(a.DESIGN_STYLE_DECODE) || undefined,
+ latest_deed: deed !== '-' ? deed : undefined,
+ note: 'Wake County NC Assessor — assessed value; no beds/baths or zoning in this layer',
+ }),
+ });
+ if (salePrice && saleDate) events.push({ reid, price: salePrice, date: saleDate, deed });
+ }
+ upserted += await upsertParcels(batch);
+ for (let i = 0; i < events.length; i += 500) {
+ const chunk = events.slice(i, i + 500);
+ await query(
+ `INSERT INTO parcel_event (county_fips, source_id, event_type, event_date, amount, doc_type, doc_number, source, source_url, detail)
+ VALUES ${chunk.map((_, j) => { const b = j * 9; return `($${b + 1},$${b + 2},'sale',$${b + 3}::date,$${b + 4},$${b + 5},$${b + 6},$${b + 7},$${b + 8},$${b + 9}::jsonb)`; }).join(',')}
+ ON CONFLICT (county_fips, source_id, event_type, event_date, doc_number) DO NOTHING`,
+ chunk.flatMap(e => [FIPS, e.reid, e.date, e.price, 'Deed', e.deed && e.deed !== '-' ? e.deed : e.date,
+ 'parcel_wake_nc', `https://services.wake.gov/realestate/Account.asp?id=${e.reid}`, JSON.stringify({ deed: e.deed })]),
+ );
+ sales += chunk.length;
+ }
+ offset += feats.length;
+ if (seen % 100000 < feats.length) console.log(`[wake] ${upserted} upserted, ${sales} sales`);
+ if (feats.length < PAGE) break;
+ }
+ if (upserted < 380000) throw new Error(`only ${upserted} Wake parcels (expected ~437k) — layer/paging drift?`);
+
+ // in-DB bulk flag: a deed spanning >1 REID is an assemblage; null the un-allocatable price + flag.
+ const b = await query(
+ `WITH bulk AS (
+ SELECT extra->>'latest_deed' AS deed, COUNT(*)::int AS cnt
+ FROM parcel WHERE county_fips=$1 AND extra->>'latest_deed' IS NOT NULL AND extra->>'latest_deed' !~ '^-?$'
+ GROUP BY 1 HAVING COUNT(*) > 1)
+ UPDATE parcel p SET last_sale_price = NULL,
+ extra = p.extra || jsonb_build_object('bulk_sale', true, 'bulk_deed_folio_count', bulk.cnt)
+ FROM bulk WHERE p.county_fips=$1 AND p.extra->>'latest_deed' = bulk.deed RETURNING 1`, [FIPS]);
+ const bulk = b.rowCount ?? 0;
+
+ const n = await registerParcelSource(FIPS, LAYER,
+ `Wake County NC Assessor (authoritative ArcGIS MapServer) — ${upserted} parcels: owner, site address, assessed LAND+BLDG+TOTAL value, sqft (heated area), year built, use, units, latest sale (price+date+deed) → ${sales} parcel_event sale rows. No beds/baths or zoning in this layer. ${bulk} multi-parcel-deed (assemblage) sales flagged bulk + price nulled.`);
+ await closeRun(runId, 'ok', { upserted, notes: `Wake NC: ${upserted} parcels, ${sales} sales, ${bulk} bulk-flagged` });
+ console.log(`[wake] ok: ${upserted} parcels, ${sales} sales, ${bulk} bulk (registry ${n})`);
+ return { upserted };
+ } catch (e: any) {
+ await closeRun(runId, 'failed', { notes: String(e.message || e).slice(0, 500) });
+ throw e;
+ }
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ ingestWake().then(() => pool.end()).catch(e => { console.error(e); process.exit(1); });
+}
← 4118d04 parcel coverage: record Broward (WAF) + Palm Beach (cached-o
·
back to Nationalrealestate
·
wake: contrarian gate fixes — all-zero deed sentinel + pre-1 d39bdf3 →