← back to Nationalrealestate
parcels: add Fulton County GA / Atlanta (13121) — dedicated rich full-universe adapter
f01d750d4d0d8309ac953ca715520a1f9040c212 · 2026-07-28 07:06:11 -0700 · Steve Abrams
Dedicated fulton_ga.ts over the keyless Fulton County DoIT/GIS-hosted Tax_Parcels
FeatureServer (Board of Assessors tax digest, ~373,004 parcels, TaxYear 2026).
Coverage: owner 99.98%, property address assembled from structured components 100%,
land-use code 100%, living units 86%, land acres. No market value / year-built /
building sqft / beds-baths / sale-or-deed feed in this open view -> parcel-detail-only
(Bexar/Salt Lake precedent): zero parcel_event rows, never a fabricated sale.
Registered 'fulton' in engine.ts, documented in PARCEL-COVERAGE.md. Verified
end-to-end vs local usre DB (bounded 2-page run, owner/addr/use 100%, geo in Atlanta
bounds, 0 events, 0 parcel_source leak), tsc exit 0, test rows purged. Applies the
Wake/Tarrant/Bexar DTD-B precedent (dedicated *.ts, no re-panel). Full 373k backfill +
Kamatera prod deploy remain Steve-gated and NOT executed this pass.
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/fulton_ga.ts
Diff
commit f01d750d4d0d8309ac953ca715520a1f9040c212
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 28 07:06:11 2026 -0700
parcels: add Fulton County GA / Atlanta (13121) — dedicated rich full-universe adapter
Dedicated fulton_ga.ts over the keyless Fulton County DoIT/GIS-hosted Tax_Parcels
FeatureServer (Board of Assessors tax digest, ~373,004 parcels, TaxYear 2026).
Coverage: owner 99.98%, property address assembled from structured components 100%,
land-use code 100%, living units 86%, land acres. No market value / year-built /
building sqft / beds-baths / sale-or-deed feed in this open view -> parcel-detail-only
(Bexar/Salt Lake precedent): zero parcel_event rows, never a fabricated sale.
Registered 'fulton' in engine.ts, documented in PARCEL-COVERAGE.md. Verified
end-to-end vs local usre DB (bounded 2-page run, owner/addr/use 100%, geo in Atlanta
bounds, 0 events, 0 parcel_source leak), tsc exit 0, test rows purged. Applies the
Wake/Tarrant/Bexar DTD-B precedent (dedicated *.ts, no re-panel). Full 373k backfill +
Kamatera prod deploy remain Steve-gated and NOT executed this pass.
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/fulton_ga.ts | 165 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 167 insertions(+)
diff --git a/PARCEL-COVERAGE.md b/PARCEL-COVERAGE.md
index d4f2d07..a57e73d 100644
--- a/PARCEL-COVERAGE.md
+++ b/PARCEL-COVERAGE.md
@@ -21,6 +21,7 @@ migration 004, adapters in `src/ingest/parcels/`).
| 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) |
+| Fulton, GA — Atlanta (13121) | ~373,004 | Board of Assessors tax digest via Fulton County DoIT/GIS hosted `Tax_Parcels` FeatureServer (keyless ArcGIS, `ParcelID<>''` filter, `outSR=4326`; `ingest:parcels fulton`) | re-run adapter (remote-runnable, no GDAL) | Details (living/dwelling units `LivUnits` 86%, land-use `LUCode` 100%, class code, land acres), **Ownership (owner 99.98%)**, property address assembled from the structured `AddrNumber`/`AddrPreDir`/`AddrStreet`/`AddrSuffix`/`AddrPosDir`/unit components (100%), tax year 2026, Map (polygon centroid, WGS84). **No market/assessed value / year-built / building sqft / beds-baths / sale-or-deed feed** in this open assessor view → parcel-DETAIL-only (like Bexar/Salt Lake); **zero parcel_event rows** (no fabricated sales). The layer serviceDescription self-identifies as the Fulton Board of Assessors annual tax digest. No separate city column (county spans Atlanta + ~14 municipalities) — city left null rather than guessed |
## Probed — gated or absent
diff --git a/src/ingest/parcels/engine.ts b/src/ingest/parcels/engine.ts
index 0108f96..41ae6c0 100644
--- a/src/ingest/parcels/engine.ts
+++ b/src/ingest/parcels/engine.ts
@@ -16,6 +16,7 @@ const ADAPTERS: Record<string, () => Promise<{ run: () => Promise<{ upserted: nu
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 }),
+ fulton: async () => ({ run: (await import('./fulton_ga.ts')).ingestFulton }),
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/fulton_ga.ts b/src/ingest/parcels/fulton_ga.ts
new file mode 100644
index 0000000..2cb36fd
--- /dev/null
+++ b/src/ingest/parcels/fulton_ga.ts
@@ -0,0 +1,165 @@
+/**
+ * Fulton County GA (Atlanta, FIPS 13121) parcel ingest — dedicated full-universe
+ * adapter (applies the Wake/Miami-Dade/Tarrant/Bexar 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 Fulton County Dept. of IT / GIS Division hosted "Tax Parcels"
+ * FeatureServer (built from the Fulton County Board of Assessors tax digest;
+ * keyless, maxRecordCount 1000, supportsPagination, $0):
+ * https://services1.arcgis.com/AQDHTHDrZzfsFsB5/arcgis/rest/services/Tax_Parcels/FeatureServer/0
+ * The serviceDescription self-identifies as "the Fulton County Board of
+ * Assessor's ... annual tax digest." The layer serves outSR=4326 server-side, so
+ * parcel centroids come back as clean WGS84 lat/lng.
+ *
+ * Coverage (373,004-parcel universe, TaxYear 2026 — current): Owner (99.98%,
+ * 372,938), Address (100% — assembled from the structured AddrNumber/PreDir/
+ * Street/Suffix/PosDir/UnitType/Unit components, which is cleaner than the flat
+ * `Address` string), LUCode (land-use code, 100%), ClassCode, LivUnits
+ * (living/dwelling units, 86%), LandAcres, Neighborhood.
+ *
+ * This Fulton assessor open view carries NO market/assessed value, NO
+ * year-built, NO building sqft, NO beds/baths, and NO sale/deed feed. Because
+ * there is no priced-sale feed here, this county is parcel-DETAIL-only (like
+ * Bexar TX / Salt Lake UT): zero parcel_event rows are written — never a
+ * fabricated sale.
+ *
+ * source_id = ParcelID (the Fulton Board of Assessors parcel account id, e.g.
+ * "07 410001590187"). We keep the raw ParcelID including its embedded space (it
+ * is the canonical key on the Fulton digest); rows with a blank ParcelID are
+ * skipped.
+ *
+ * Run: NODE_OPTIONS=--max-old-space-size=4096 npm run ingest:parcels fulton
+ */
+import { pool } from '../../../db/pool.ts';
+import { openRun, closeRun } from '../run.ts';
+import { upsertParcels, registerParcelSource, normAddress, type ParcelUpsertRow } from './upsert.ts';
+
+const FIPS = '13121';
+const LAYER = 'https://services1.arcgis.com/AQDHTHDrZzfsFsB5/arcgis/rest/services/Tax_Parcels/FeatureServer/0';
+const WHERE = "ParcelID IS NOT NULL AND ParcelID <> ''";
+const PAGE = 1000; // layer maxRecordCount is 1000
+
+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; };
+
+/**
+ * Assemble a clean street line from the structured Fulton address components,
+ * in USPS-ish order: "<num> <predir> <street> <suffix> <posdir> <unittype> <unit>".
+ * We prefer the components over the flat `Address` field because they are
+ * separately reliable; fall back to the flat `Address` if no components exist.
+ */
+function buildStreet(a: any): string | null {
+ const parts = [a.AddrNumber, a.AddrPreDir, a.AddrStreet, a.AddrSuffix, a.AddrPosDir, a.AddrUntTyp, a.AddrUnit]
+ .map(s).filter(Boolean);
+ const assembled = parts.join(' ').replace(/\s+/g, ' ').trim();
+ // Fulton uses a literal "0" AddrNumber for unaddressed land parcels; when the
+ // number is "0" and there's a real street, drop the leading 0 so the address
+ // isn't "0 GULLATT RD" (keep the street though — it's still location info).
+ const cleaned = assembled.replace(/^0\s+/, '').trim();
+ return cleaned || s(a.Address);
+}
+
+/** 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 = ['ParcelID', 'TaxYear', 'Address', 'AddrNumber', 'AddrPreDir', 'AddrStreet',
+ 'AddrSuffix', 'AddrPosDir', 'AddrUntTyp', 'AddrUnit', 'Owner', 'LUCode', 'ClassCode',
+ 'ExCode', 'LivUnits', 'LandAcres', 'NbrHood', 'Subdiv'].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(`fulton ${res.status}: ${(await res.text()).slice(0, 140)}`);
+ const j: any = await res.json();
+ if (j.error) throw new Error(`fulton 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 ingestFulton(): Promise<{ upserted: number }> {
+ const runId = await openRun('parcel_fulton_ga', 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 || {};
+ const pid = s(a.ParcelID);
+ if (!pid) { skipped++; continue; }
+ const owner = s(a.Owner);
+ const street = buildStreet(a);
+ // skip pure ghost polygons (no owner AND no street) so we don't store bare geometry rows
+ 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: pid,
+ address: norm, norm_address: norm,
+ // Fulton's tax-parcel view has no separate city column (the county
+ // spans Atlanta + ~14 other municipalities); leave city null rather
+ // than guess it from the neighborhood code.
+ city: null, zip: null,
+ lat, lng,
+ year_built: null, sqft: null, beds: null, baths: null,
+ units: num(a.LivUnits),
+ use_desc: s(a.LUCode),
+ land_value: null, improvement_value: null, total_value: null,
+ tax_year: s(a.TaxYear), owner_name: owner, zoning: null,
+ // no priced-sale / deed feed in this Fulton assessor 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({
+ class_code: s(a.ClassCode) || undefined,
+ ex_code: s(a.ExCode) || undefined,
+ land_acres: num(a.LandAcres) || undefined,
+ neighborhood: s(a.NbrHood) || undefined,
+ subdivision: s(a.Subdiv) || undefined,
+ note: 'Fulton County GA (Board of Assessors tax digest) via Fulton DoIT GIS Tax_Parcels view — owner + structured address + land-use code + living units + land acres; no market value / year-built / building sqft / beds-baths / sale-or-deed feed in this open view',
+ }),
+ });
+ }
+ upserted += await upsertParcels(batch);
+ offset += feats.length;
+ if (seen % 100000 < feats.length) console.log(`[fulton] ${upserted} upserted, ${skipped} stubs skipped`);
+ if (feats.length < PAGE) break;
+ }
+ if (upserted < 340000) throw new Error(`only ${upserted} Fulton parcels (expected ~373k) — layer/paging/filter drift?`);
+
+ const n = await registerParcelSource(FIPS, LAYER,
+ `Fulton County GA (Atlanta) Board of Assessors tax digest via Fulton DoIT GIS Tax_Parcels view — ${upserted} parcels: owner (99.98%), property address assembled from structured components (100%), land-use code (LUCode, 100%), class code, living/dwelling units (LivUnits, 86%), land acres, neighborhood, Map (polygon centroid, WGS84 via outSR). TaxYear 2026 (current). No market/assessed value, year-built, building sqft, beds/baths, or sale/deed feed in this open assessor view (parcel-detail-only, like Bexar/Salt Lake — zero fabricated events). ${skipped} placeholder/geometry-only stubs skipped.`);
+ await closeRun(runId, 'ok', { upserted, skipped, notes: `Fulton GA: ${upserted} parcels, ${skipped} stubs skipped` });
+ console.log(`[fulton] 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]}`) {
+ ingestFulton().then(() => pool.end()).catch(e => { console.error(e); process.exit(1); });
+}
← 994fedb parcels: guard negative sale prices — shared sanitizeSalePri
·
back to Nationalrealestate
·
parcels: guard null/placeholder/junk owner names — shared sa 1c33393 →