← back to NationalPaperHangers

scripts/geocode-installers.js

105 lines

#!/usr/bin/env node
// Geocode installers using city/state via Nominatim (OpenStreetMap, free, no key).
// Rate-limited to 1 req/sec per Nominatim's usage policy. Cached so reruns are cheap.
// Resolution is city-level; multiple installers in the same city get a small jitter
// (~±0.5 mile) so map pins don't stack.

require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs   = require('node:fs/promises');
const path = require('node:path');
const db   = require('../lib/db');

const CACHE_PATH = path.join(__dirname, '..', 'data', 'geo-cache.json');
const UA = 'NationalPaperHangers/0.1 (https://nationalpaperhangers.com; ops@nationalpaperhangers.com)';
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
const REQ_INTERVAL_MS = 1100; // be polite

function jitter(seed) {
  // Deterministic jitter: same installer.id always gets the same offset.
  // ±0.008° ≈ ±0.55 miles. Enough to spread a 6-installer city without
  // moving anyone out of frame.
  const r1 = Math.sin(seed * 12.9898) * 43758.5453; // PRNG-ish
  const r2 = Math.sin(seed * 78.233)  * 43758.5453;
  const f1 = r1 - Math.floor(r1);
  const f2 = r2 - Math.floor(r2);
  return [(f1 - 0.5) * 0.016, (f2 - 0.5) * 0.016];
}

async function loadCache() {
  try { return JSON.parse(await fs.readFile(CACHE_PATH, 'utf8')); }
  catch { return {}; }
}
async function saveCache(c) {
  await fs.writeFile(CACHE_PATH, JSON.stringify(c, null, 2));
}

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

async function geocode(city, state) {
  const params = new URLSearchParams({
    city, state, country: 'United States', format: 'json', limit: '1',
  });
  const res = await fetch(`${NOMINATIM}?${params}`, { headers: { 'User-Agent': UA } });
  if (!res.ok) throw new Error(`nominatim ${res.status}`);
  const arr = await res.json();
  if (!arr.length) return null;
  return { lat: parseFloat(arr[0].lat), lng: parseFloat(arr[0].lon) };
}

async function main() {
  const cache = await loadCache();
  const rows = await db.many(
    `SELECT id, city, state FROM installers
      WHERE city IS NOT NULL AND state IS NOT NULL
        AND (latitude IS NULL OR longitude IS NULL)
      ORDER BY id`
  );
  console.log(`installers needing geocoding: ${rows.length}`);

  const pairs = new Map();
  for (const r of rows) {
    const key = `${r.city.trim()}|${r.state.trim().toUpperCase()}`;
    if (!pairs.has(key)) pairs.set(key, { city: r.city.trim(), state: r.state.trim().toUpperCase(), ids: [] });
    pairs.get(key).ids.push(r.id);
  }
  console.log(`unique city/state pairs: ${pairs.size}`);

  let resolvedPairs = 0, missPairs = 0, updatedRows = 0, idx = 0;
  const total = pairs.size;
  for (const [key, info] of pairs.entries()) {
    idx += 1;
    let coord = cache[key];
    if (!coord) {
      try {
        coord = await geocode(info.city, info.state);
      } catch (e) {
        console.warn(`[${idx}/${total}] ${key} geocode error: ${e.message}`);
        coord = null;
      }
      cache[key] = coord || { miss: true };
      if ((idx % 25) === 0) await saveCache(cache);
      await sleep(REQ_INTERVAL_MS);
    }
    if (!coord || coord.miss) { missPairs += 1; continue; }
    resolvedPairs += 1;
    for (const id of info.ids) {
      const [dlat, dlng] = jitter(id);
      await db.query(
        `UPDATE installers
            SET latitude = $1, longitude = $2,
                geo_accuracy = 'city',
                geocoded_at = now()
          WHERE id = $3`,
        [coord.lat + dlat, coord.lng + dlng, id]
      );
      updatedRows += 1;
    }
    if ((idx % 10) === 0) console.log(`[${idx}/${total}] ${key} → ${coord.lat.toFixed(3)},${coord.lng.toFixed(3)} (${info.ids.length} installers)`);
  }
  await saveCache(cache);
  console.log(`\ndone. pairs resolved=${resolvedPairs} miss=${missPairs}; rows updated=${updatedRows}`);
  process.exit(0);
}

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