[object Object]

← 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

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 →