[object Object]

← back to Nationalrealestate

parcels: add Tarrant County TX / Fort Worth (48439) — dedicated rich full-universe adapter

0e3ce56f9dceffa4195cc6373465bee12ecbcfe5 · 2026-07-27 21:19:30 -0700 · Steve Abrams

715k parcels via City-of-Fort-Worth hosted TAD Parcels_Public_Vview FeatureServer
(keyless ArcGIS, COUNTYNAME='Tarrant' filter). Owner 100%, TRUE market value 99%,
sqft 89%, year-built 90%, deed date 98%. No beds/baths or sale price in the feed —
deed events recorded as event_type='deed' with amount NULL (honest, no fabricated
price). Drops the 12-31-1900 'no deed on record' sentinel (~18k rows). Applies the
Wake/Miami DTD-B precedent (rich full-universe ArcGIS → dedicated *.ts adapter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 0e3ce56f9dceffa4195cc6373465bee12ecbcfe5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 21:19:30 2026 -0700

    parcels: add Tarrant County TX / Fort Worth (48439) — dedicated rich full-universe adapter
    
    715k parcels via City-of-Fort-Worth hosted TAD Parcels_Public_Vview FeatureServer
    (keyless ArcGIS, COUNTYNAME='Tarrant' filter). Owner 100%, TRUE market value 99%,
    sqft 89%, year-built 90%, deed date 98%. No beds/baths or sale price in the feed —
    deed events recorded as event_type='deed' with amount NULL (honest, no fabricated
    price). Drops the 12-31-1900 'no deed on record' sentinel (~18k rows). Applies the
    Wake/Miami DTD-B precedent (rich full-universe ArcGIS → dedicated *.ts adapter).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 PARCEL-COVERAGE.md               |   1 +
 src/ingest/parcels/engine.ts     |   1 +
 src/ingest/parcels/tarrant_tx.ts | 172 +++++++++++++++++++++++++++++++++++++++
 3 files changed, 174 insertions(+)

diff --git a/PARCEL-COVERAGE.md b/PARCEL-COVERAGE.md
index 3617b3e..3a28466 100644
--- a/PARCEL-COVERAGE.md
+++ b/PARCEL-COVERAGE.md
@@ -19,6 +19,7 @@ migration 004, adapters in `src/ingest/parcels/`).
 | 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 |
+| Tarrant, TX — Fort Worth (48439) | ~714,982 | TAD appraisal roll via City-of-Fort-Worth hosted `Parcels_Public_Vview` FeatureServer (keyless ArcGIS, `COUNTYNAME='Tarrant'` filter; `ingest:parcels tarrant`) | re-run adapter (remote-runnable, no GDAL) | Details (sqft/living-area 89%, year-built 90%, state use code), **Ownership (owner 100%)**, site address + real city name, LAND+IMPR + **TRUE MARKET value (99%)** + appraised value, **Property History: deed/ownership-change DATE (98%, no price in this feed) → parcel_event `deed` rows w/ amount NULL**, Map (polygon centroid). **No beds/baths or sale PRICE** (TAD withholds them in the public view). Feed sentinel `12-31-1900` (~18k, "no deed on record") dropped. The TAD county's own `gis.tad.org` 000s to plain curl — this CFW-hosted public view is the reachable authoritative copy |
 
 ## Probed — gated or absent
 
diff --git a/src/ingest/parcels/engine.ts b/src/ingest/parcels/engine.ts
index b07c03d..4df2375 100644
--- a/src/ingest/parcels/engine.ts
+++ b/src/ingest/parcels/engine.ts
@@ -14,6 +14,7 @@ const ADAPTERS: Record<string, () => Promise<{ run: () => Promise<{ upserted: nu
   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 }),
+  tarrant: async () => ({ run: (await import('./tarrant_tx.ts')).ingestTarrant }),
   saltlake: async () => ({ run: (await import('./saltlake_ut.ts')).ingestSaltLake }),
   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') }; },
diff --git a/src/ingest/parcels/tarrant_tx.ts b/src/ingest/parcels/tarrant_tx.ts
new file mode 100644
index 0000000..391bf60
--- /dev/null
+++ b/src/ingest/parcels/tarrant_tx.ts
@@ -0,0 +1,172 @@
+/**
+ * Tarrant County TX (Fort Worth, FIPS 48439) parcel ingest — dedicated
+ * full-universe adapter (applies the Wake/Miami-Dade DTD verdict B precedent:
+ * a rich full-universe keyless ArcGIS county gets a dedicated adapter, not the
+ * sale-cursor generic one, so the real OWNER + market-value fields are preserved).
+ *
+ * Source: the City of Fort Worth hosted "Parcels_Public_Vview" FeatureServer
+ * (keyless, maxRecordCount 2000, supportsPagination, $0):
+ *   https://services5.arcgis.com/3ddLCBXe1bRt7mzj/arcgis/rest/services/Parcels_Public_Vview/FeatureServer/0
+ * This layer is MULTI-COUNTY (Tarrant + neighboring Denton/Parker/Johnson/Wise
+ * fringes) — we filter server-side to COUNTYNAME='Tarrant' = ~714,982 parcels.
+ * (The TAD county's own gis.tad.org endpoints 000 to plain curl; this CFW-hosted
+ * public view is the reachable authoritative copy of the same TAD appraisal roll.)
+ *
+ * Richest MARKET-value county in the system: OWNER_NAME (100%), SITUS_ADDR +
+ * CITYNAME (real city names), LAND_VAL + IMPR_VAL + MARKET_VALUE + APPRAISED_VALUE
+ * (99% have MARKET_VALUE>0 — TRUE market value, not the assessed fraction Wake
+ * ships), LIVING_AREA (sqft, 89%), YR_BUILT (90%), STATE_USE_CODE (use),
+ * DEED_DATE (98%, "MM-DD-YYYY" string — deed/ownership-change date, NO price in
+ * this feed). No beds/baths (TAD withholds them in the public view) and no sale
+ * PRICE, so deed events are recorded as event_type='deed' with amount=NULL — an
+ * honest ownership-change record, never a fabricated price.
+ *
+ * source_id = ACCOUNT (the 8-digit / R-prefixed TAD appraisal account). Rows with
+ * a null ACCOUNT are geometry-only stubs (TAXPIN-only) and are skipped.
+ *
+ * Run: NODE_OPTIONS=--max-old-space-size=4096 npm run ingest:parcels tarrant
+ */
+import { pool, query } from '../../../db/pool.ts';
+import { openRun, closeRun } from '../run.ts';
+import { upsertParcels, registerParcelSource, normAddress, type ParcelUpsertRow } from './upsert.ts';
+import { sanitizeEventDate } from './date_guard.ts';
+
+const FIPS = '48439';
+const LAYER = 'https://services5.arcgis.com/3ddLCBXe1bRt7mzj/arcgis/rest/services/Parcels_Public_Vview/FeatureServer/0';
+const WHERE = "COUNTYNAME='Tarrant'";
+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; };
+
+/**
+ * Parse the feed's "MM-DD-YYYY" deed-date string to ISO 'YYYY-MM-DD', then run
+ * it through the shared sanity guard (future / pre-1900 / non-calendar reject).
+ * Returns null on any malformed or implausible date.
+ */
+function deedDateToISO(v: unknown): string | null {
+  const t = s(v);
+  if (!t) return null;
+  const m = t.match(/^(\d{2})-(\d{2})-(\d{4})$/);
+  if (!m) return null;
+  // sentinel: the feed writes 12-31-1900 / 01-01-1900 as "no deed on record"
+  // (~18k Tarrant rows, ~2.5%). Any year <= 1900 here is a placeholder, not a
+  // real recorded deed — drop it (same class as Wake's all-zero-deed guard).
+  if (Number(m[3]) <= 1900) return null;
+  const iso = `${m[3]}-${m[1]}-${m[2]}`;
+  return sanitizeEventDate(iso);
+}
+
+/** plain vertex-average of ring[0] — NOT a true polygon centroid (fine for a map
+ *  pin; multipart parcels only get the first ring). */
+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 = ['ACCOUNT', 'TAXPIN', 'OWNER_NAME', 'OWNER_ADDRESS', 'SITUS_ADDR', 'CITYNAME', 'TAX_YEAR',
+  'YR_BUILT', 'LIVING_AREA', 'LAND_SQFT', 'STATE_USE_CODE', 'PROPERTY_CLASS_CODE',
+  'LAND_VAL', 'IMPR_VAL', 'MARKET_VALUE', 'APPRAISED_VALUE',
+  'DEED_DATE', 'DEED_BOOK', 'DEED_PAGE'].join(',');
+
+async function fetchPage(offset: number): Promise<any[]> {
+  const u = new URL(LAYER + '/query');
+  u.searchParams.set('where', WHERE);
+  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, { headers: { 'User-Agent': 'Mozilla/5.0 (usre-parcel-ingest)' }, signal: AbortSignal.timeout(120_000) });
+      if (!res.ok) throw new Error(`tarrant ${res.status}: ${(await res.text()).slice(0, 140)}`);
+      const j: any = await res.json();
+      if (j.error) throw new Error(`tarrant 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 ingestTarrant(): Promise<{ upserted: number }> {
+  const runId = await openRun('parcel_tarrant_tx', LAYER);
+  try {
+    let offset = 0, seen = 0, skipped = 0, upserted = 0, deeds = 0;
+    for (;;) {
+      const feats = await fetchPage(offset);
+      if (!feats.length) break;
+      const batch: ParcelUpsertRow[] = [];
+      const events: { acct: string; date: string }[] = [];
+      for (const f of feats) {
+        const a = f.attributes || {};
+        // skip geometry-only stubs (no ACCOUNT = no CAMA row behind the polygon)
+        const acct = s(a.ACCOUNT);
+        if (!acct) { skipped++; continue; }
+        seen++;
+        const [lng, lat] = centroid(f.geometry);
+        const addr = s(a.SITUS_ADDR);
+        const norm = addr ? normAddress(addr) : null;
+        const deedDate = deedDateToISO(a.DEED_DATE);
+        const cityRaw = s(a.CITYNAME);
+        // the feed writes 'No City Limit' / ' ' for unincorporated parcels — null those
+        const city = cityRaw && cityRaw !== 'No City Limit' ? cityRaw : null;
+        batch.push({
+          county_fips: FIPS, source_id: acct,
+          address: norm, norm_address: norm,
+          city, zip: null,
+          lat, lng,
+          year_built: num(a.YR_BUILT), sqft: num(a.LIVING_AREA), beds: null, baths: null, units: null,
+          use_desc: s(a.STATE_USE_CODE),
+          land_value: num(a.LAND_VAL), improvement_value: num(a.IMPR_VAL), total_value: num(a.MARKET_VALUE),
+          tax_year: s(a.TAX_YEAR), owner_name: s(a.OWNER_NAME), zoning: null,
+          // deed date is an ownership-change date, NOT a priced sale — record the date
+          // as last_sale_date but leave last_sale_price NULL (this feed carries no price).
+          last_sale_date: deedDate, last_sale_price: null,
+          extra: JSON.stringify({
+            taxpin: s(a.TAXPIN) || undefined,
+            appraised_value: num(a.APPRAISED_VALUE) || undefined,
+            property_class: s(a.PROPERTY_CLASS_CODE) || undefined,
+            land_sqft: num(a.LAND_SQFT) || undefined,
+            note: 'Tarrant County TX (TAD) via City of Fort Worth public parcel view — TRUE market value; no beds/baths, no sale price (deed date only) in this feed',
+          }),
+        });
+        if (deedDate) events.push({ acct, date: deedDate });
+      }
+      upserted += await upsertParcels(batch);
+      // record deed/ownership-change events (no amount — this feed has no price).
+      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 * 8; return `($${b + 1},$${b + 2},'deed',$${b + 3}::date,NULL,$${b + 4},$${b + 5},$${b + 6},$${b + 7},$${b + 8}::jsonb)`; }).join(',')}
+           ON CONFLICT (county_fips, source_id, event_type, event_date, doc_number) DO NOTHING`,
+          chunk.flatMap(e => [FIPS, e.acct, e.date, 'Deed', e.date,
+            'parcel_tarrant_tx', `https://www.tad.org/property/?account=${e.acct}`, JSON.stringify({ note: 'deed date only, no price in TAD public feed' })]),
+        );
+        deeds += chunk.length;
+      }
+      offset += feats.length;
+      if (seen % 100000 < feats.length) console.log(`[tarrant] ${upserted} upserted, ${deeds} deeds, ${skipped} stubs skipped`);
+      if (feats.length < PAGE) break;
+    }
+    if (upserted < 650000) throw new Error(`only ${upserted} Tarrant parcels (expected ~715k) — layer/paging/filter drift?`);
+
+    const n = await registerParcelSource(FIPS, LAYER,
+      `Tarrant County TX (Fort Worth) TAD appraisal roll via City of Fort Worth public parcel view (COUNTYNAME='Tarrant' filter) — ${upserted} parcels: owner (100%), situs address + city, LAND+IMPR + TRUE MARKET value (99%) + appraised value, sqft (living area, 89%), year built (90%), state use code, deed/ownership-change date (98%) → ${deeds} parcel_event deed rows. No beds/baths or sale PRICE in this feed (deed date only, amount NULL). ${skipped} geometry-only stubs (null ACCOUNT) skipped.`);
+    await closeRun(runId, 'ok', { upserted, skipped, notes: `Tarrant TX: ${upserted} parcels, ${deeds} deed events, ${skipped} stubs skipped` });
+    console.log(`[tarrant] ok: ${upserted} parcels, ${deeds} deed events, ${skipped} stubs (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]}`) {
+  ingestTarrant().then(() => pool.end()).catch(e => { console.error(e); process.exit(1); });
+}

← 0bae506 parcels: extend deed-date sanity guard to franklin_oh, king_  ·  back to Nationalrealestate  ·  parcels: add Bexar County TX / San Antonio (48029) — dedicat f29163c →