← back to Nationalrealestate

src/ingest/regions_seed.ts

145 lines

/**
 * Seeds the region anchor table:
 *   - all ~3,144 counties from the Census Gazetteer counties file (GEOID = 5-digit FIPS)
 *   - all ~935 CBSAs (metro + micro) from the Census Gazetteer CBSA file
 *   - 50 states + DC statically
 * Gazetteer zips hold one tab-delimited txt; parsed via `unzip -p` (no zip dep).
 * Idempotent: ON CONFLICT (canonical_key) DO UPDATE.
 */
import { execFileSync } from 'node:child_process';
import { query, pool } from '../../db/pool.ts';
import { openRun, closeRun, download } from './run.ts';

const GAZ_BASE = 'https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2024_Gazetteer';
const COUNTIES_ZIP = `${GAZ_BASE}/2024_Gaz_counties_national.zip`;
const CBSA_ZIP = `${GAZ_BASE}/2024_Gaz_cbsa_national.zip`;

const STATES: Array<[string, string, string]> = [
  ['AL','01','Alabama'],['AK','02','Alaska'],['AZ','04','Arizona'],['AR','05','Arkansas'],
  ['CA','06','California'],['CO','08','Colorado'],['CT','09','Connecticut'],['DE','10','Delaware'],
  ['DC','11','District of Columbia'],['FL','12','Florida'],['GA','13','Georgia'],['HI','15','Hawaii'],
  ['ID','16','Idaho'],['IL','17','Illinois'],['IN','18','Indiana'],['IA','19','Iowa'],
  ['KS','20','Kansas'],['KY','21','Kentucky'],['LA','22','Louisiana'],['ME','23','Maine'],
  ['MD','24','Maryland'],['MA','25','Massachusetts'],['MI','26','Michigan'],['MN','27','Minnesota'],
  ['MS','28','Mississippi'],['MO','29','Missouri'],['MT','30','Montana'],['NE','31','Nebraska'],
  ['NV','32','Nevada'],['NH','33','New Hampshire'],['NJ','34','New Jersey'],['NM','35','New Mexico'],
  ['NY','36','New York'],['NC','37','North Carolina'],['ND','38','North Dakota'],['OH','39','Ohio'],
  ['OK','40','Oklahoma'],['OR','41','Oregon'],['PA','42','Pennsylvania'],['RI','44','Rhode Island'],
  ['SC','45','South Carolina'],['SD','46','South Dakota'],['TN','47','Tennessee'],['TX','48','Texas'],
  ['UT','49','Utah'],['VT','50','Vermont'],['VA','51','Virginia'],['WA','53','Washington'],
  ['WV','54','West Virginia'],['WI','55','Wisconsin'],['WY','56','Wyoming'],
];

// Connecticut switched to planning-region FIPS (09110+) in the 2024 Gazetteer, but
// Zillow/Redfin still publish by legacy county (09001-09015) and the choropleth
// GeoJSON carries legacy county shapes — seed both so CT data lands and renders.
const LEGACY_CT_COUNTIES: Array<[string, string, number, number]> = [
  ['09001', 'Fairfield County', 41.27, -73.39],
  ['09003', 'Hartford County', 41.81, -72.73],
  ['09005', 'Litchfield County', 41.79, -73.24],
  ['09007', 'Middlesex County', 41.43, -72.52],
  ['09009', 'New Haven County', 41.35, -72.90],
  ['09011', 'New London County', 41.47, -72.10],
  ['09013', 'Tolland County', 41.85, -72.34],
  ['09015', 'Windham County', 41.83, -71.99],
];

function parseGazetteer(zipPath: string): Array<Record<string, string>> {
  const text = execFileSync('unzip', ['-p', zipPath], { maxBuffer: 64 * 1024 * 1024 }).toString('utf8');
  const lines = text.split('\n').filter((l) => l.trim().length > 0);
  const headers = lines[0].split('\t').map((h) => h.trim());
  return lines.slice(1).map((line) => {
    const cells = line.split('\t');
    const row: Record<string, string> = {};
    headers.forEach((h, i) => { row[h] = (cells[i] ?? '').trim(); });
    return row;
  });
}

async function upsertRegion(r: {
  region_type: string; canonical_key: string; name: string;
  state_code?: string | null; fips?: string | null; cbsa_code?: string | null;
  lat?: number | null; lng?: number | null;
}): Promise<void> {
  await query(
    `INSERT INTO region (region_type, canonical_key, name, state_code, fips, cbsa_code, lat, lng)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
     ON CONFLICT (canonical_key) DO UPDATE
       SET name = EXCLUDED.name, state_code = EXCLUDED.state_code,
           fips = EXCLUDED.fips, cbsa_code = EXCLUDED.cbsa_code,
           lat = EXCLUDED.lat, lng = EXCLUDED.lng`,
    [r.region_type, r.canonical_key, r.name, r.state_code ?? null, r.fips ?? null,
     r.cbsa_code ?? null, r.lat ?? null, r.lng ?? null],
  );
}

async function main() {
  const runId = await openRun('census_gazetteer', COUNTIES_ZIP);
  let upserted = 0;
  try {
    // states (static)
    for (const [code, fips, name] of STATES) {
      await upsertRegion({ region_type: 'state', canonical_key: `state:${code}`, name, state_code: code, fips });
      upserted++;
    }

    // counties
    const counties = await download(COUNTIES_ZIP, 'gaz_counties_2024.zip', { reuseCached: true });
    const countyRows = parseGazetteer(counties.path);
    if (countyRows.length < 3000) throw new Error(`county gazetteer parsed only ${countyRows.length} rows — format drift?`);
    for (const row of countyRows) {
      const fips = row['GEOID'];
      if (!fips || fips.length !== 5) continue;
      // last header often carries trailing whitespace in the raw file; keys are trimmed at parse
      const lat = parseFloat(row['INTPTLAT'] ?? '');
      const lng = parseFloat(row['INTPTLONG'] ?? '');
      await upsertRegion({
        region_type: 'county',
        canonical_key: `county:${fips}`,
        name: row['NAME'],
        state_code: row['USPS'],
        fips,
        lat: Number.isFinite(lat) ? lat : null,
        lng: Number.isFinite(lng) ? lng : null,
      });
      upserted++;
    }

    // legacy CT counties (coexist with the planning regions)
    for (const [fips, name, lat, lng] of LEGACY_CT_COUNTIES) {
      await upsertRegion({ region_type: 'county', canonical_key: `county:${fips}`, name, state_code: 'CT', fips, lat, lng });
      upserted++;
    }

    // metros (CBSA — metro + micro areas)
    const cbsa = await download(CBSA_ZIP, 'gaz_cbsa_2024.zip', { reuseCached: true });
    const cbsaRows = parseGazetteer(cbsa.path);
    if (cbsaRows.length < 800) throw new Error(`cbsa gazetteer parsed only ${cbsaRows.length} rows — format drift?`);
    for (const row of cbsaRows) {
      const code = row['GEOID'];
      if (!code) continue;
      const lat = parseFloat(row['INTPTLAT'] ?? '');
      const lng = parseFloat(row['INTPTLONG'] ?? '');
      await upsertRegion({
        region_type: 'metro',
        canonical_key: `metro:${code}`,
        name: row['NAME'],
        cbsa_code: code,
        lat: Number.isFinite(lat) ? lat : null,
        lng: Number.isFinite(lng) ? lng : null,
      });
      upserted++;
    }

    await closeRun(runId, 'ok', { upserted, fileHash: counties.sha256, notes: `counties=${countyRows.length} cbsa=${cbsaRows.length} states=${STATES.length}` });
    console.log(`regions seeded: ${upserted} upserts (${countyRows.length} counties, ${cbsaRows.length} CBSAs, ${STATES.length} states)`);
  } catch (e: any) {
    await closeRun(runId, 'failed', { notes: String(e.message || e) });
    throw e;
  } finally {
    await pool.end();
  }
}

main().catch((e) => { console.error(e); process.exit(1); });