← back to Commercialrealestate

scripts/geocode-ranked.js

96 lines

#!/usr/bin/env node
/* geocode-ranked.js — enrich data/ranked.json with real zip + lat/lng via the FREE US Census
 * batch geocoder (no API key, 10k addresses/batch, $0). ADDITIVE + IDEMPOTENT: only fills rows
 * missing `zip`; never changes address/city/price or drops rows. Safe to re-run and safe to run
 * against prod's own ranked.json (it enriches in place, preserving the existing snapshot).
 *
 * WHY: the grid snapshot has address+city but no zip, and LA neighborhoods (Encino, Topanga,
 * Bel-Air…) are folded into city="Los Angeles". Per-city scoping for those needs the zip — the
 * only field that distinguishes an Encino address (91316/91436) from the rest of Los Angeles.
 *
 * Usage:  node scripts/geocode-ranked.js [--limit N] [--dry-run]
 *   --dry-run : geocode + report match rate, DON'T write ranked.json
 *   --limit N : only process the first N un-geocoded rows (for a quick test)
 */
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const RANKED = path.join(ROOT, 'data', 'ranked.json');
const BENCHMARK = 'Public_AR_Current';
const CHUNK = 5000;                                   // Census caps at 10k/batch; 5k is comfortable
const args = process.argv.slice(2);
const DRY = args.includes('--dry-run');
const LIMIT = (() => { const i = args.indexOf('--limit'); return i >= 0 ? parseInt(args[i + 1], 10) : Infinity; })();

// Best-effort state inference for the high-volume non-CA cities (improves match rate + disambiguates).
// Everything else defaults to CA — the fleet is all-CA, so CA rows (the only scoping-relevant ones)
// get a correct state; a wrong default on a rare out-of-state city only costs that one row a match.
const STATE = {
  'las vegas': 'NV', 'henderson': 'NV', 'reno': 'NV', 'houston': 'TX', 'dallas': 'TX', 'austin': 'TX',
  'san antonio': 'TX', 'fort worth': 'TX', 'el paso': 'TX', 'miami': 'FL', 'orlando': 'FL', 'tampa': 'FL',
  'jacksonville': 'FL', 'new york': 'NY', 'brooklyn': 'NY', 'bronx': 'NY', 'queens': 'NY', 'phoenix': 'AZ',
  'tucson': 'AZ', 'scottsdale': 'AZ', 'mesa': 'AZ', 'atlanta': 'GA', 'chicago': 'IL', 'seattle': 'WA',
  'portland': 'OR', 'denver': 'CO', 'nashville': 'TN', 'memphis': 'TN', 'charlotte': 'NC', 'columbus': 'OH',
  'philadelphia': 'PA', 'detroit': 'MI', 'boston': 'MA', 'washington': 'DC', 'new orleans': 'LA',
  'oklahoma city': 'OK', 'kansas city': 'MO', 'st louis': 'MO', 'salt lake city': 'UT', 'albuquerque': 'NM',
  'indianapolis': 'IN', 'milwaukee': 'WI', 'louisville': 'KY'
};
const stateFor = c => STATE[String(c || '').toLowerCase().trim()] || 'CA';
const csvSafe = s => String(s == null ? '' : s).replace(/[,\r\n"]/g, ' ').trim();

async function geocodeBatch(rows) {
  // rows: [{idx, address, city}] -> CSV "id,street,city,state,zip"
  const csv = rows.map(r => [r.idx, csvSafe(r.address), csvSafe(r.city), stateFor(r.city), ''].join(',')).join('\n') + '\n';
  const form = new FormData();
  form.append('benchmark', BENCHMARK);
  form.append('addressFile', new Blob([csv], { type: 'text/csv' }), 'addrs.csv');
  const res = await fetch('https://geocoding.geo.census.gov/geocoder/locations/addressbatch', { method: 'POST', body: form });
  if (!res.ok) throw new Error('census HTTP ' + res.status);
  const text = await res.text();
  // Output CSV: "id","input","Match|No_Match|Tie","Exact|Non_Exact","matched addr","lon,lat","tigerid","side"
  const out = {};
  for (const line of text.split('\n')) {
    if (!line.trim()) continue;
    const f = line.match(/"(?:[^"]*)"/g); if (!f) continue;
    const cell = i => (f[i] || '').replace(/^"|"$/g, '');
    const id = cell(0), status = cell(2);
    if (status !== 'Match') continue;
    const matched = cell(4);                          // "304 E 95TH ST, LOS ANGELES, CA, 90003"
    const lonlat = cell(5);                           // "-118.26...,33.95..."
    const zip = (matched.match(/,\s*(\d{5})\s*$/) || [])[1] || '';
    const [lon, lat] = lonlat.split(',');
    if (zip) out[id] = { zip, lat: lat ? +lat : null, lng: lon ? +lon : null };
  }
  return out;
}

(async () => {
  const doc = JSON.parse(fs.readFileSync(RANKED, 'utf8'));
  const arr = doc.ranked || [];
  const todo = [];
  arr.forEach((p, idx) => { if ((!p.zip || !String(p.zip).trim()) && p.address && p.city) todo.push({ idx, address: p.address, city: p.city }); });
  const batchList = todo.slice(0, LIMIT === Infinity ? todo.length : LIMIT);
  console.log(`ranked.json: ${arr.length} rows · ${todo.length} missing zip · geocoding ${batchList.length}${DRY ? ' (DRY RUN)' : ''}`);
  console.log('cost: $0 (US Census batch geocoder — free, no key)');

  let matched = 0;
  for (let i = 0; i < batchList.length; i += CHUNK) {
    const chunk = batchList.slice(i, i + CHUNK);
    process.stdout.write(`  batch ${Math.floor(i / CHUNK) + 1}/${Math.ceil(batchList.length / CHUNK)} (${chunk.length} addrs)… `);
    let res = {};
    try { res = await geocodeBatch(chunk); } catch (e) { console.log('ERR ' + e.message); continue; }
    for (const [id, geo] of Object.entries(res)) {
      const p = arr[+id]; if (!p) continue;
      p.zip = geo.zip; if (geo.lat != null) p.lat = geo.lat; if (geo.lng != null) p.lng = geo.lng;
      matched++;
    }
    console.log(`matched ${Object.keys(res).length}`);
  }
  const rate = batchList.length ? (100 * matched / batchList.length).toFixed(1) : '0';
  console.log(`\nmatched ${matched}/${batchList.length} (${rate}%) · total rows with zip now: ${arr.filter(p => p.zip).length}/${arr.length}`);
  if (DRY) { console.log('DRY RUN — ranked.json not written'); return; }
  fs.copyFileSync(RANKED, RANKED + '.bak-geocode');   // rollback point
  fs.writeFileSync(RANKED, JSON.stringify(doc, null, 0));
  console.log(`wrote ${RANKED} (backup: ranked.json.bak-geocode)`);
})();