← back to Costa Rica
scripts/cr-osm-match.js
99 lines
'use strict';
// Match OSM CR POIs (with website/phone/email tags) to our places by
// normalized name, geo-sanity-checked against the place's region centroid.
// CONSERVATIVE: exact normalized-name match, unique candidate only, fills
// NULL fields only, provenance in website_source='osm'. $0, ODbL-compliant
// (attribution note added to about page separately if desired).
const fs = require('fs');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const norm = (s) => (s || '')
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/\b(sociedad anonima|s\.?a\.?|ltda\.?|s\.?r\.?l\.?|inc\.?|cia\.?)\b/g, '')
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ').trim();
const km = (a, b, c, d) => {
const R = 6371, dLat = (c - a) * Math.PI / 180, dLon = (d - b) * Math.PI / 180;
const x = Math.sin(dLat / 2) ** 2 + Math.cos(a * Math.PI / 180) * Math.cos(c * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(x));
};
(async () => {
const osm = JSON.parse(fs.readFileSync(__dirname + '/data/cache/osm-cr-websites.json', 'utf8')).elements;
const byName = new Map();
for (const e of osm) {
const t = e.tags || {};
const n = norm(t.name);
if (n.length < 4) continue;
const lat = e.lat ?? e.center?.lat, lon = e.lon ?? e.center?.lon;
const rec = {
website: t.website || t['contact:website'] || null,
phone: t.phone || t['contact:phone'] || null,
email: t.email || t['contact:email'] || null,
lat, lon,
};
if (!byName.has(n)) byName.set(n, []);
byName.get(n).push(rec);
}
console.log(`osm index: ${byName.size} distinct names`);
await pool.query(`ALTER TABLE places ADD COLUMN IF NOT EXISTS website_source TEXT`);
const { rows: places } = await pool.query(`
SELECT p.id, p.name, p.website, p.phone, p.email,
r.lat AS rlat, r.lng AS rlng
FROM places p LEFT JOIN regions r ON r.id = p.region_id
WHERE p.website IS NULL`);
console.log(`places without website: ${places.length}`);
const osmNames = [...byName.keys()];
let matched = 0, geoRejected = 0, ambiguous = 0;
for (const p of places) {
const n = norm(p.name);
if (n.length < 4) continue;
let cands = byName.get(n);
let via = 'exact';
if (!cands && n.length >= 8) {
// containment as a CONTIGUOUS WORD subsequence (not raw substring —
// "miguel angEL CASTILLO diaz" must NOT match "el castillo").
// Requires a UNIQUE containing candidate; min length 8 avoids junk.
const wordsContain = (long, short) => {
const L = long.split(' '), S = short.split(' ');
if (S.length < 2 && S[0].length < 8) return false;
for (let i = 0; i + S.length <= L.length; i++)
if (S.every((w, j) => L[i + j] === w)) return true;
return false;
};
const hits = osmNames.filter(o => o.length >= 8 && (wordsContain(o, n) || wordsContain(n, o)));
if (hits.length === 1) { cands = byName.get(hits[0]); via = 'contain'; }
else if (hits.length > 1) { ambiguous++; continue; }
}
if (!cands) continue;
if (cands.length > 1) { ambiguous++; continue; }
const c = cands[0];
if (!c.website) continue;
// geo sanity: within 60km of the region centroid. A missing centroid is
// NOT a pass — matches without geo confirmation get '-nogeo' provenance
// so the UI/audits can treat them as weaker evidence.
let geoTag = '';
if (p.rlat && c.lat) {
if (km(Number(p.rlat), Number(p.rlng), c.lat, c.lon) > 60) { geoRejected++; continue; }
} else {
geoTag = '-nogeo';
}
const socialTag = /facebook\.com|instagram\.com/i.test(c.website) ? '-social' : '';
await pool.query(
`UPDATE places SET website = $1,
phone = COALESCE(phone, $2),
email = COALESCE(email, $3),
website_source = $4
WHERE id = $5`,
[c.website, c.phone, c.email, 'osm-' + via + socialTag + geoTag, p.id]);
matched++;
}
console.log(`DONE matched=${matched} geo_rejected=${geoRejected} ambiguous_skipped=${ambiguous}`);
await pool.end();
})();