← 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
M PARCEL-COVERAGE.mdA src/ingest/parcels/bexar_tx.tsM src/ingest/parcels/engine.ts
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 →