[object Object]

← back to Nationalrealestate

parcels: add Bexar County TX / San Antonio (48029) — dedicated rich full-universe adapter

f29163c67589055e875ee62f0c5044b591ab5881 · 2026-07-27 21:28:23 -0700 · Steve Abrams

BCAD appraisal roll via City-of-San-Antonio open-data BCAD_Parcels FeatureServer
(keyless ArcGIS, PropID>0, outSR=4326 server-side reproject). ~721k parcels:
owner 99%, situs property address parsed to street+city+zip 99%, living-area
sqft 87%, land sqft, state use code, legal acreage. Parcel-detail-only (no
appraised value/year-built/beds-baths/sale feed here) so zero parcel_event rows
- never a fabricated sale. Applies the Wake/Tarrant DTD-B precedent (rich
full-universe ArcGIS -> dedicated *.ts). Registered in engine.ts, documented in
PARCEL-COVERAGE.md. Verified end-to-end vs local usre DB (2-page bounded run:
3996 parcels, owner/city/zip/latlng 100%, sqft populated, 0 events, real SA
centroids, 4 stubs skipped); tsc exit 0; test rows purged. Full 721k backfill +
Kamatera deploy remain Steve-gated.

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

Files touched

Diff

commit f29163c67589055e875ee62f0c5044b591ab5881
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 21:28:23 2026 -0700

    parcels: add Bexar County TX / San Antonio (48029) — dedicated rich full-universe adapter
    
    BCAD appraisal roll via City-of-San-Antonio open-data BCAD_Parcels FeatureServer
    (keyless ArcGIS, PropID>0, outSR=4326 server-side reproject). ~721k parcels:
    owner 99%, situs property address parsed to street+city+zip 99%, living-area
    sqft 87%, land sqft, state use code, legal acreage. Parcel-detail-only (no
    appraised value/year-built/beds-baths/sale feed here) so zero parcel_event rows
    - never a fabricated sale. Applies the Wake/Tarrant DTD-B precedent (rich
    full-universe ArcGIS -> dedicated *.ts). Registered in engine.ts, documented in
    PARCEL-COVERAGE.md. Verified end-to-end vs local usre DB (2-page bounded run:
    3996 parcels, owner/city/zip/latlng 100%, sqft populated, 0 events, real SA
    centroids, 4 stubs skipped); tsc exit 0; test rows purged. Full 721k backfill +
    Kamatera deploy remain Steve-gated.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 PARCEL-COVERAGE.md             |   1 +
 src/ingest/parcels/bexar_tx.ts | 176 +++++++++++++++++++++++++++++++++++++++++
 src/ingest/parcels/engine.ts   |   1 +
 3 files changed, 178 insertions(+)

diff --git a/PARCEL-COVERAGE.md b/PARCEL-COVERAGE.md
index 3a28466..d4f2d07 100644
--- a/PARCEL-COVERAGE.md
+++ b/PARCEL-COVERAGE.md
@@ -20,6 +20,7 @@ migration 004, adapters in `src/ingest/parcels/`).
 | 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 |
+| Bexar, TX — San Antonio (48029) | ~721,005 | BCAD appraisal roll via City-of-San-Antonio open-data hosted `BCAD_Parcels` FeatureServer (keyless ArcGIS, `PropID>0` filter, `outSR=4326` server-side reproject; `ingest:parcels bexar`) | re-run adapter (remote-runnable, no GDAL) | Details (living-area sqft 87%, state use code, land sqft, legal acreage), **Ownership (owner 99%)**, `Situs` **property** address parsed → street+city+zip (99%; the mailing `addr_*` fields are the owner's, so we read situs instead), Map (polygon centroid, WGS84). **No appraised value / year-built / beds-baths / sale-or-deed feed** in this CoSA-hosted BCAD open view → parcel-DETAIL-only (like Salt Lake); **zero parcel_event rows** (no fabricated sales). The authoritative `maps.bcad.org` PAMapSearch service DOES carry appraised value + owner but blocks bulk paging (no offset/orderBy) — so the pageable CoSA layer is the full-universe backfill source (Tarrant precedent) |
 
 ## Probed — gated or absent
 
diff --git a/src/ingest/parcels/bexar_tx.ts b/src/ingest/parcels/bexar_tx.ts
new file mode 100644
index 0000000..a7672f3
--- /dev/null
+++ b/src/ingest/parcels/bexar_tx.ts
@@ -0,0 +1,176 @@
+/**
+ * Bexar County TX (San Antonio, FIPS 48029) parcel ingest — dedicated
+ * full-universe adapter (applies the Wake/Miami-Dade/Tarrant 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 + physical-detail
+ * fields are preserved).
+ *
+ * Source: the City of San Antonio open-data hosted "BCAD_Parcels" FeatureServer
+ * (BCAD = Bexar Central Appraisal District roll; keyless, maxRecordCount 2000,
+ * supportsPagination, $0):
+ *   https://services.arcgis.com/g1fRTDLeMgspWrYp/arcgis/rest/services/BCAD_Parcels/FeatureServer/0
+ * The layer serves outSR=4326 server-side, so parcel centroids come back as
+ * clean WGS84 lat/lng (the raw geometry is Texas South Central State Plane,
+ * wkid 2278, US survey feet — we let the server reproject rather than doing it
+ * ourselves).
+ *
+ * Coverage: OWNER_NAME (99%, 715,613 of 721,005 real parcels), SITUS (full
+ * "STREET, CITY, STATE ZIP" property address, 99% — we parse street/city/zip
+ * from it because the mailing addr_* fields are the OWNER's mailing address, not
+ * the property location), GBA_Living (living-area sqft, 87%), LandSqft, state_cd
+ * (use code), legal_desc, legal_acre.
+ *
+ * This CoSA-hosted BCAD open view carries NO appraised value, NO year-built, NO
+ * beds/baths, and NO sale/deed feed. (The authoritative maps.bcad.org
+ * PAMapSearch service DOES carry appraised value + owner, but it is a
+ * point-and-click MapSearch service that blocks bulk paging — no resultOffset,
+ * no orderBy — so it is not a safe full-universe backfill source. We take the
+ * reliably-pageable CoSA hosted layer, matching the Tarrant precedent of
+ * choosing the pageable full-universe copy.) Because there is no priced-sale
+ * feed here, this county is parcel-DETAIL-only (like Salt Lake UT): zero
+ * parcel_event rows are written — never a fabricated sale.
+ *
+ * source_id = PropID (the BCAD property account id). Rows with PropID <= 0 are
+ * geometry-only / placeholder stubs (blank owner, PropID=-1030 etc.) and are
+ * skipped.
+ *
+ * Run: NODE_OPTIONS=--max-old-space-size=4096 npm run ingest:parcels bexar
+ */
+import { pool } from '../../../db/pool.ts';
+import { openRun, closeRun } from '../run.ts';
+import { upsertParcels, registerParcelSource, normAddress, type ParcelUpsertRow } from './upsert.ts';
+
+const FIPS = '48029';
+const LAYER = 'https://services.arcgis.com/g1fRTDLeMgspWrYp/arcgis/rest/services/BCAD_Parcels/FeatureServer/0';
+const WHERE = 'PropID>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; };
+
+/**
+ * The Situs field is a full formatted property address: "STREET, CITY, STATE ZIP"
+ * (e.g. "309 ALBANY ST, ALAMO HEIGHTS, TX 78209"). Parse out the street line, the
+ * city, and the 5-digit zip. Returns nulls on any shape we don't recognize rather
+ * than guessing.
+ */
+function parseSitus(v: unknown): { street: string | null; city: string | null; zip: string | null } {
+  const t = s(v);
+  if (!t) return { street: null, city: null, zip: null };
+  const parts = t.split(',').map(p => p.trim()).filter(Boolean);
+  // expected: [street, city, "STATE ZIP"] (3 parts) — but street may itself contain
+  // no comma, and some rows are "STREET, CITY TX 78xxx" (2 parts). Handle both.
+  let street: string | null = null, city: string | null = null, zip: string | null = null;
+  const zipM = t.match(/\b(\d{5})(?:-\d{4})?\s*$/);
+  if (zipM) zip = zipM[1];
+  if (parts.length >= 3) {
+    street = parts[0] || null;
+    city = parts[1] || null;
+  } else if (parts.length === 2) {
+    street = parts[0] || null;
+    // second part is "CITY TX 78209" — strip the trailing state + zip
+    city = parts[1].replace(/\s+TX\s+\d{5}(?:-\d{4})?\s*$/i, '').trim() || null;
+  } else {
+    street = parts[0] || null;
+  }
+  return { street, city, zip };
+}
+
+/** plain vertex-average of ring[0] — NOT a true polygon centroid (fine for a map
+ *  pin; multipart parcels only get the first ring). Geometry is already WGS84
+ *  because we request outSR=4326. */
+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 = ['PropID', 'Geo_id', 'Situs', 'Owner_Name', 'state_cd',
+  'GBA_Living', 'LandSqft', 'legal_acre', 'legal_desc', 'Exemptions'].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(`bexar ${res.status}: ${(await res.text()).slice(0, 140)}`);
+      const j: any = await res.json();
+      if (j.error) throw new Error(`bexar 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 ingestBexar(): Promise<{ upserted: number }> {
+  const runId = await openRun('parcel_bexar_tx', LAYER);
+  try {
+    let offset = 0, seen = 0, skipped = 0, upserted = 0;
+    for (;;) {
+      const feats = await fetchPage(offset);
+      if (!feats.length) break;
+      const batch: ParcelUpsertRow[] = [];
+      for (const f of feats) {
+        const a = f.attributes || {};
+        // PropID>0 is the server-side filter, but guard again defensively: skip
+        // any placeholder row with no real owner AND no situs (ghost polygon).
+        const pid = num(a.PropID);
+        if (!pid || pid <= 0) { skipped++; continue; }
+        const owner = s(a.Owner_Name);
+        const { street, city, zip } = parseSitus(a.Situs);
+        if (!owner && !street) { skipped++; continue; }
+        seen++;
+        const [lng, lat] = centroid(f.geometry);
+        const norm = street ? normAddress(street) : null;
+        batch.push({
+          county_fips: FIPS, source_id: String(pid),
+          address: norm, norm_address: norm,
+          city, zip,
+          lat, lng,
+          year_built: null, sqft: num(a.GBA_Living), beds: null, baths: null, units: null,
+          use_desc: s(a.state_cd),
+          land_value: null, improvement_value: null, total_value: null,
+          tax_year: null, owner_name: owner, zoning: null,
+          // no priced-sale / deed feed in this CoSA-hosted BCAD open view — leave
+          // both sale fields NULL (never fabricate a sale date or price).
+          last_sale_date: null, last_sale_price: null,
+          extra: JSON.stringify({
+            geo_id: s(a.Geo_id) || undefined,
+            land_sqft: num(a.LandSqft) || undefined,
+            legal_acre: num(a.legal_acre) || undefined,
+            legal_desc: s(a.legal_desc) || undefined,
+            exemptions: s(a.Exemptions) || undefined,
+            note: 'Bexar County TX (BCAD) via City of San Antonio open-data parcel view — owner + situs + living-area sqft + use code; no appraised value / year-built / beds-baths / sale in this feed',
+          }),
+        });
+      }
+      upserted += await upsertParcels(batch);
+      offset += feats.length;
+      if (seen % 100000 < feats.length) console.log(`[bexar] ${upserted} upserted, ${skipped} stubs skipped`);
+      if (feats.length < PAGE) break;
+    }
+    if (upserted < 650000) throw new Error(`only ${upserted} Bexar parcels (expected ~721k) — layer/paging/filter drift?`);
+
+    const n = await registerParcelSource(FIPS, LAYER,
+      `Bexar County TX (San Antonio) BCAD appraisal roll via City of San Antonio open-data parcel view (PropID>0 filter) — ${upserted} parcels: owner (99%), situs property address parsed to street+city+zip (99%), living-area sqft (87%), land sqft, state use code, legal acreage/desc, Map (polygon centroid, WGS84 via outSR). No appraised value, year-built, beds/baths, or sale/deed feed in this CoSA-hosted BCAD open view (parcel-detail-only, like Salt Lake — zero fabricated events). ${skipped} placeholder/geometry-only stubs skipped.`);
+    await closeRun(runId, 'ok', { upserted, skipped, notes: `Bexar TX: ${upserted} parcels, ${skipped} stubs skipped` });
+    console.log(`[bexar] ok: ${upserted} parcels, ${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]}`) {
+  ingestBexar().then(() => pool.end()).catch(e => { console.error(e); process.exit(1); });
+}
diff --git a/src/ingest/parcels/engine.ts b/src/ingest/parcels/engine.ts
index 4df2375..9035304 100644
--- a/src/ingest/parcels/engine.ts
+++ b/src/ingest/parcels/engine.ts
@@ -15,6 +15,7 @@ const ADAPTERS: Record<string, () => Promise<{ run: () => Promise<{ upserted: nu
   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 }),
+  bexar: async () => ({ run: (await import('./bexar_tx.ts')).ingestBexar }),
   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') }; },

← 0e3ce56 parcels: add Tarrant County TX / Fort Worth (48439) — dedica  ·  back to Nationalrealestate  ·  parcels: add Yakima County WA (53077) free priced-deed feed 1e35d6a →