← back to Ventura Claw Leads
scripts/backfill-corridor-geo.js
128 lines
#!/usr/bin/env node
/**
* backfill-corridor-geo.js
*
* 244 corridor-seeded businesses came in with NULL latitude/longitude
* (the corridor row had no lat/lng — likely BTRC-only entries that OSM
* never matched). They're invisible on /map. Backfill by:
* 1. Match VC row → ALL ventura_corridor rows with lat/lng at the same
* address (street-prefix + city). If 1+ found, take the first.
* 2. Match VC row → corridor rows with the same business name (any
* address). Last-resort because a Starbucks at one address may be
* mis-geocoded to another Starbucks.
*
* Sets latitude/longitude in place. Reversible: drop the corridor seed
* + reseed if anything looks off.
*/
require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
const { Pool } = require('pg');
const target = require('../lib/db');
const corridor = new Pool({
host: process.env.PGHOST || 'localhost',
port: parseInt(process.env.PGPORT || '5432', 10),
database: 'ventura_corridor',
user: process.env.PGUSER || process.env.USER,
max: 4
});
function streetKey(s) {
if (!s) return null;
return s.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/(suite|ste|unit|apt|#)\s*\S+/g, '')
.replace(/\bbl(?:vd)?\b/g, 'blvd')
.replace(/\bboulevard\b/g, 'blvd')
.trim();
}
(async () => {
// Load every VC row missing geo
const missing = await target.many(`
SELECT id, slug, business_name, street, city, zip
FROM businesses
WHERE source='public_records' AND status='active' AND latitude IS NULL
`);
console.log(`[geo-backfill] ${missing.length} VC rows missing lat/lng`);
// Load all corridor rows with lat/lng — they fit in memory (15K rows)
const corrRows = (await corridor.query(`
SELECT name, address, street_number, street_name, city, zip, lat, lng
FROM businesses
WHERE on_corridor=true AND lat IS NOT NULL
`)).rows;
console.log(`[geo-backfill] ${corrRows.length} corridor rows with lat/lng`);
// Index by street-key + city for fast lookup. Skip rows whose
// streetKey() comes back empty — otherwise every empty-addr row
// collapses into a single "|city" bucket and matches unrelated
// corridor entries with the same gap, producing wrong lat/lng.
// (PROSECUTOR caught this in the codex-3way debate.)
const byAddr = new Map();
for (const c of corrRows) {
const addr = c.address || [c.street_number, c.street_name].filter(Boolean).join(' ');
const sk = streetKey(addr);
if (!sk) continue;
const k = `${sk}|${(c.city || '').toLowerCase().trim()}`;
if (!byAddr.has(k)) byAddr.set(k, []);
byAddr.get(k).push(c);
}
// Index by name as fallback
const byName = new Map();
for (const c of corrRows) {
const k = (c.name || '').toLowerCase().trim();
if (!byName.has(k)) byName.set(k, []);
byName.get(k).push(c);
}
let backfilled_addr = 0, backfilled_name = 0, no_match = 0;
for (const m of missing) {
let lat = null, lng = null, why = null;
const msk = streetKey(m.street);
// Same null-street-key guard on the lookup side — otherwise empty-addr
// VC rows match the wrong corridor row.
const k = msk ? `${msk}|${(m.city || '').toLowerCase().trim()}` : null;
if (k && byAddr.has(k)) {
const cand = byAddr.get(k)[0];
lat = cand.lat; lng = cand.lng; why = 'address';
backfilled_addr++;
} else {
const nk = (m.business_name || '').toLowerCase().trim();
if (byName.has(nk)) {
// Pick the closest-by-zip if multiple
const candList = byName.get(nk);
let best = candList[0];
if (m.zip) {
const z = m.zip.slice(0, 5);
const zMatch = candList.find(c => (c.zip || '').slice(0, 5) === z);
if (zMatch) best = zMatch;
}
lat = best.lat; lng = best.lng; why = 'name+zip';
backfilled_name++;
} else {
no_match++;
}
}
if (lat != null && lng != null) {
await target.query(
`UPDATE businesses SET latitude=$1, longitude=$2 WHERE id=$3`,
[lat, lng, m.id]
);
}
}
console.log(`[geo-backfill] address-match=${backfilled_addr} name-match=${backfilled_name} no-match=${no_match}`);
await corridor.end();
await target.pool.end();
process.exit(0);
})().catch(async (err) => {
console.error('[geo-backfill] fatal', err);
// Close pools before exit — leaving them open can hold the process up to
// the idleTimeoutMillis. Best-effort: catch and swallow close errors.
// (PROSECUTOR caught this in the codex-3way debate.)
try { await corridor.end(); } catch (_) {}
try { await target.pool.end(); } catch (_) {}
process.exit(1);
});