← back to Sublease Agentabrams

scripts/census-batch-geocode.js

50 lines

'use strict';
// Sweep unmapped listings through the free US Census BATCH geocoder (up to 10k/upload,
// no rate limit). Purpose-built for bulk — the fix for what Nominatim throttled. $0.
const { Pool } = require('pg');
const pool = new Pool({ host: '/tmp', database: 'crunified' });
const CENSUS_URL = 'https://geocoding.geo.census.gov/geocoder/locations/addressbatch';
const csvq = (s) => '"' + String(s == null ? '' : s).replace(/"/g, "'").replace(/[\r\n]+/g, ' ') + '"';

// minimal CSV line parser (handles quoted fields)
function parseLine(line) {
  const out = []; let cur = '', q = false;
  for (let i = 0; i < line.length; i++) { const c = line[i];
    if (q) { if (c === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += c; }
    else if (c === '"') q = true; else if (c === ',') { out.push(cur); cur = ''; } else cur += c; }
  out.push(cur); return out;
}

(async () => {
  const { rows } = await pool.query(
    `SELECT id, address, city, state, zip FROM listings WHERE lat IS NULL AND address IS NOT NULL AND address<>'' ORDER BY id`);
  console.log(`${rows.length} addresses to batch-geocode`);
  if (!rows.length) { await pool.end(); return; }

  const csv = rows.map(r => [r.id, csvq(r.address), csvq(r.city), csvq(r.state || 'CA'), csvq(r.zip)].join(',')).join('\n');
  const fd = new FormData();
  fd.append('benchmark', 'Public_AR_Current');
  fd.append('addressFile', new Blob([csv], { type: 'text/csv' }), 'addrs.csv');

  console.log('uploading to Census batch geocoder…');
  const res = await fetch(CENSUS_URL, { method: 'POST', body: fd, signal: AbortSignal.timeout(180000) });
  const text = await res.text();
  if (!res.ok) { console.log('census error', res.status, text.slice(0, 200)); await pool.end(); return; }

  let matched = 0, nomatch = 0;
  for (const line of text.split('\n')) {
    if (!line.trim()) continue;
    const f = parseLine(line);               // id, input, status, matchtype, matchedAddr, "lon,lat", tiger, side
    const id = +f[0], status = f[2], coords = f[5];
    if ((status === 'Match' || status === 'Tie') && coords && coords.includes(',')) {
      const [lon, lat] = coords.split(',').map(Number);
      if (lat && lon) { await pool.query('UPDATE listings SET lat=$2, lng=$3 WHERE id=$1', [id, lat, lon]); matched++; continue; }
    }
    nomatch++;
  }
  const [{ mapped, total }] = (await pool.query(`SELECT count(*) FILTER (WHERE lat IS NOT NULL) mapped, count(*) total FROM listings`)).rows;
  console.log(`Census matched ${matched}, no-match ${nomatch}`);
  console.log(`coverage now: ${mapped}/${total} (${(100 * mapped / total).toFixed(1)}%)`);
  await pool.end();
})();