← back to Nationalrealestate
src/ingest/parcels/maricopa_bulk.ts
70 lines
/**
* Maricopa County (Phoenix, 04013) loader from the FREE assessor bulk file (st42060,
* launched Mar 2026, no fees). The public ArcGIS has no property-use, but this
* fixed-width extract carries the PROPERTY USE CODE. We map the PUC's authoritative
* 2-digit prefix → its descriptive label (Maricopa PUC manual), so use_desc becomes
* descriptive text and from_parcel classifies it (same SD ASR_ZONE trick).
*
* Fixed-width layout (st42060, 1-indexed): RECORD TYPE 1-3 (only '100' rows carry
* PUC+situs), APN 4-12, PROPERTY USE CODE 14-17, SITUS STREET NUM 52-57, DIR 58-59,
* NAME 60-99, TYPE 100-111, CITY 133-162, ZIP 163-171, LPV FULL CASH VALUE 459-470.
*
* npm run ingest:parcels -- maricopa-bulk /path/to/st42060.dat
*/
import { readFileSync } from 'node:fs';
import { upsertParcels, normAddress, type ParcelUpsertRow } from './upsert.ts';
import { openRun, closeRun } from '../run.ts';
// Authoritative Maricopa PUC 2-digit prefix → descriptive label (PUC manual). The label
// text carries the keyword classifyCommercialByDesc matches; residential/vacant/ag → the
// classifier returns null (not commercial). Prefixes we don't have a label for → skipped.
const PUC: Record<string, string> = {
'00': 'Vacant Land', '01': 'Single Family Residential', '02': 'Residential Recreation',
'03': 'Multiple Family Residential', '04': 'High Density Agriculture', '05': 'Water Utilities',
'06': 'Railroad & Utilities', '07': 'Condominium Residential', '08': 'Mobile Homes',
'09': 'Salvage & Misc Commercial', '10': 'Miscellaneous Commercial', '11': 'Convenience Retail Store',
'12': 'Department Store', '13': 'Shopping Center', '14': 'Office Building', '15': 'Residential over 5 Acres',
'16': 'Bank & Financial', '17': 'Service Station & Auto', '18': 'Auto Sales', '19': 'Nursing Home Medical',
'20': 'Restaurant', '21': 'Medical Facility', '22': 'Race Track & Air Field', '23': 'Cemetery Mortuary',
'24': 'Golf Course', '25': 'Amusement Recreation', '26': 'Parking Facility', '27': 'Club & Health Facility',
'28': 'Improvements', '29': 'Private School', '30': 'Industrial Park',
};
const fw = (line: string, start1: number, len: number): string => line.substr(start1 - 1, len).trim();
const digits = (v: string): string | null => { const t = (v || '').replace(/\D/g, ''); return t || null; };
const num = (v: string): number | null => { const x = Number((v || '').replace(/[^0-9.]/g, '')); return Number.isFinite(x) && x > 0 ? x : null; };
export async function ingestMaricopaBulk(datPath: string) {
const runId = await openRun('maricopa_bulk', datPath);
try {
const text = readFileSync(datPath, 'latin1');
const lines = text.split(/\r?\n/);
const rows: ParcelUpsertRow[] = [];
let skipped = 0;
for (const line of lines) {
if (line.substr(0, 3) !== '100') { skipped++; continue; } // only master rows carry PUC+situs
const apn = digits(fw(line, 4, 9)); if (!apn) { skipped++; continue; }
const puc = fw(line, 14, 4);
const label = PUC[puc.slice(0, 2)] ?? null; // authoritative prefix → label; unknown → null (safe)
const addr = [fw(line, 52, 6), fw(line, 58, 2), fw(line, 60, 40), fw(line, 100, 12)].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() || null;
const city = fw(line, 133, 30) || null;
const zip = (fw(line, 163, 9) || '').slice(0, 5) || null;
const value = num(fw(line, 459, 12));
rows.push({
county_fips: '04013', source_id: apn, address: addr, norm_address: addr ? normAddress(addr) : null,
city, zip, lat: null, lng: null, year_built: null, sqft: null, beds: null, baths: null,
units: null, use_desc: label, land_value: null, improvement_value: null, total_value: value,
tax_year: null, owner_name: null, zoning: null, last_sale_date: null, last_sale_price: null,
extra: JSON.stringify({ puc }), sourceKey: 'maricopa_bulk', rawSource: null,
});
}
const up = await upsertParcels(rows);
await closeRun(runId, 'ok', { upserted: up, skipped, notes: `Maricopa bulk st42060: ${up} parcels (${skipped} non-master/blank)` });
console.log(`[maricopa-bulk] done: ${up} parcels upserted (${skipped} skipped)`);
return { upserted: up };
} catch (e: any) {
await closeRun(runId, 'failed', { notes: e.message });
throw e;
}
}