← back to Small Business Builder
scripts/ingest-osm-salons.js
250 lines
#!/usr/bin/env node
// Ingest salon-adjacent businesses from OpenStreetMap via Overpass API.
//
// Why this complements the BBCB ingest:
// - OSM has shops that BBCB doesn't (massage parlors, day spas, lash/brow
// studios, blowout bars not registered as full establishments)
// - OSM provides lat/lng — BBCB only gives addresses, so we use OSM coords
// to backfill geocoding on shops that match by name+zip
// - Free, no API key, single bulk query
//
// Targets: 5 counties — LA, Orange, Riverside, San Bernardino, Ventura.
//
// OSM tags we treat as salons:
// shop=hairdresser → barbershop or salon
// shop=beauty → salon (catch-all: nails, lashes, brows, waxing, esthetics)
// shop=massage → spa
// amenity=spa → spa
// leisure=spa → spa
//
// node scripts/ingest-osm-salons.js # full ingest
// DRY=1 node scripts/ingest-osm-salons.js # parse only, no DB writes
// COUNTY=LA node scripts/ingest-osm-salons.js # one county at a time
import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import { query, one } from '../src/lib/db.js';
import { slugify } from '../src/lib/slug.js';
const TMP_DIR = '/tmp/osm-ingest';
const OVERPASS_URL = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
const COUNTIES = {
LA: { name: 'Los Angeles County', key: 'LOS ANGELES' },
ORANGE: { name: 'Orange County', key: 'ORANGE' },
RIVERSIDE: { name: 'Riverside County', key: 'RIVERSIDE' },
SAN_BERNARDINO:{ name: 'San Bernardino County', key: 'SAN BERNARDINO' },
VENTURA: { name: 'Ventura County', key: 'VENTURA' },
};
const TAG_TO_CATEGORY = {
hairdresser: 'salon', // OSM uses hairdresser for both barbers AND hair salons; refine via name
beauty: 'salon',
massage: 'spa',
};
const DRY = process.env.DRY === '1';
const ONLY = process.env.COUNTY ? process.env.COUNTY.toUpperCase() : null;
function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
function log(m) { console.log(`[${ts()}] ${m}`); }
// Build Overpass QL for one county.
function buildQuery(countyName) {
return `[out:json][timeout:180];
area["name"="${countyName}"]["admin_level"="6"]->.a;
(
node["shop"="hairdresser"](area.a);
way["shop"="hairdresser"](area.a);
node["shop"="beauty"](area.a);
way["shop"="beauty"](area.a);
node["shop"="massage"](area.a);
way["shop"="massage"](area.a);
node["amenity"="spa"](area.a);
way["amenity"="spa"](area.a);
node["leisure"="spa"](area.a);
way["leisure"="spa"](area.a);
);
out center tags;`;
}
// Cache the Overpass response per county so re-runs don't hammer the public API.
async function fetchCounty(countyKey, countyName) {
const cachePath = path.join(TMP_DIR, `osm_${countyKey.toLowerCase().replace(/_/g, '-')}.json`);
if (fs.existsSync(cachePath) && fs.statSync(cachePath).size > 1000) {
log(` ${countyKey}: reusing cache (${(fs.statSync(cachePath).size / 1024).toFixed(0)} KB)`);
return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
}
log(` ${countyKey}: querying Overpass (~30-60s)…`);
// Overpass 406s without explicit Accept + User-Agent (anti-abuse heuristic).
const body = 'data=' + encodeURIComponent(buildQuery(countyName));
const res = await fetch(OVERPASS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'User-Agent': 'la-salon-directory/1.0 (+steveabramsdesigns@gmail.com)',
},
body,
});
if (!res.ok) throw new Error(`Overpass HTTP ${res.status} for ${countyKey}`);
const j = await res.json();
fs.writeFileSync(cachePath, JSON.stringify(j));
log(` ${countyKey}: ${j.elements?.length || 0} elements, cached → ${cachePath}`);
return j;
}
// Heuristic: "barbershop" if name contains barber/cuts/fade keywords AND tag is hairdresser.
function refineCategory(rawTag, name) {
const n = String(name || '').toLowerCase();
if (rawTag === 'hairdresser' && /\b(barber|barbers|barbershop|fade|cuts?|men.?s)\b/.test(n)) return 'barbershop';
return TAG_TO_CATEGORY[rawTag] || 'salon';
}
function makeSlug(name, zip, osmId) {
const base = slugify(name) || 'salon';
if (zip && /^\d{5}/.test(zip)) return `${base}-${zip.slice(0, 5)}`;
return `osm-${osmId}-${base}`.slice(0, 200);
}
// === Per-element processor =========================================
function processElement(el, countyKey) {
const t = el.tags || {};
const name = (t.name || '').trim();
if (!name) return null;
// Coordinates: nodes have lat/lon directly; ways carry .center.lat / .center.lon
const lat = el.lat ?? el.center?.lat ?? null;
const lon = el.lon ?? el.center?.lon ?? null;
// Determine the raw tag we matched on (priority order matches the Overpass query)
let rawTag = null;
if (t.shop === 'hairdresser') rawTag = 'hairdresser';
else if (t.shop === 'beauty') rawTag = 'beauty';
else if (t.shop === 'massage') rawTag = 'massage';
else if (t.amenity === 'spa') rawTag = 'massage';
else if (t.leisure === 'spa') rawTag = 'massage';
else return null;
const category = refineCategory(rawTag, name);
const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ').trim();
const city = (t['addr:city'] || '').trim();
const state = (t['addr:state'] || 'CA').trim();
const zip = (t['addr:postcode'] || '').trim().slice(0, 10);
const phone = (t.phone || t['contact:phone'] || '').trim().slice(0, 32) || null;
const website = (t.website || t['contact:website'] || '').trim() || null;
return {
slug: makeSlug(name, zip, el.id),
name,
category,
address: street || null,
city: city || null,
state,
zip: zip || null,
neighborhood: countyKey === 'LA' ? null : COUNTIES[countyKey].key, // leave LA neighborhoods blank for now (real LA neighborhoods need geocoding)
latitude: lat,
longitude: lon,
phone,
website,
source_data_json: {
source: 'osm',
source_id: `${el.type}/${el.id}`,
source_url: `https://www.openstreetmap.org/${el.type}/${el.id}`,
tier: 'B',
retrieved_at: new Date().toISOString(),
raw_tag: rawTag,
county_search: COUNTIES[countyKey].key,
tags: t,
},
};
}
async function upsert(row) {
// Use COALESCE to NEVER overwrite a non-null with a null on update — this
// matters when an existing BBCB row gets matched by slug and we just want
// to fill in lat/lng + phone + website without nuking the BBCB metadata.
const r = await query(
`INSERT INTO businesses
(slug, name, category, address, city, state, zip, neighborhood,
latitude, longitude, phone, website, source_data_json, tier)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'free')
ON CONFLICT (slug) DO UPDATE SET
latitude = COALESCE(businesses.latitude, EXCLUDED.latitude),
longitude = COALESCE(businesses.longitude, EXCLUDED.longitude),
phone = COALESCE(businesses.phone, EXCLUDED.phone),
website = COALESCE(businesses.website, EXCLUDED.website),
address = COALESCE(NULLIF(businesses.address,''), EXCLUDED.address),
city = COALESCE(NULLIF(businesses.city,''), EXCLUDED.city),
zip = COALESCE(NULLIF(businesses.zip,''), EXCLUDED.zip),
updated_at = now()
RETURNING (xmax = 0) AS was_insert`,
[row.slug, row.name, row.category, row.address, row.city, row.state, row.zip,
row.neighborhood, row.latitude, row.longitude, row.phone, row.website,
JSON.stringify(row.source_data_json)]
);
return r.rows[0]?.was_insert;
}
// === Main ==========================================================
async function main() {
fs.mkdirSync(TMP_DIR, { recursive: true });
log(`mode=${DRY ? 'DRY' : 'WRITE'}${ONLY ? ` only=${ONLY}` : ''}`);
const counties = ONLY ? [[ONLY, COUNTIES[ONLY]]] : Object.entries(COUNTIES);
if (ONLY && !COUNTIES[ONLY]) { console.error('unknown COUNTY:', ONLY); process.exit(2); }
const totals = { matched: 0, inserted: 0, updated: 0, skipped: 0 };
const byCategory = {};
for (const [key, def] of counties) {
const data = await fetchCounty(key, def.name);
let countyMatched = 0, countyInserted = 0, countyUpdated = 0;
for (const el of data.elements || []) {
const row = processElement(el, key);
if (!row) { totals.skipped++; continue; }
countyMatched++;
byCategory[row.category] = (byCategory[row.category] || 0) + 1;
if (DRY) {
if (countyMatched <= 5) console.log(` ${row.slug.padEnd(50)} ${row.name} · ${row.city || '?'} · ${row.category} · ${row.latitude?.toFixed(4) || '—'},${row.longitude?.toFixed(4) || '—'}`);
continue;
}
try {
const inserted = await upsert(row);
if (inserted) countyInserted++;
else countyUpdated++;
} catch (e) {
if (totals.matched < 5) log(` ERR ${row.slug}: ${e.message.slice(0, 200)}`);
}
}
totals.matched += countyMatched;
totals.inserted += countyInserted;
totals.updated += countyUpdated;
log(` ${key}: matched=${countyMatched} inserted=${countyInserted} updated=${countyUpdated}`);
}
log(`TOTAL: matched=${totals.matched} inserted=${totals.inserted} updated=${totals.updated} skipped=${totals.skipped}`);
log(`BY CATEGORY: ${Object.entries(byCategory).map(([k,v])=>`${k}=${v}`).join(' ')}`);
if (!DRY) {
const tally = await one(`
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE source_data_json->>'source' = 'dca_bbcb') AS bbcb,
COUNT(*) FILTER (WHERE source_data_json->>'source' = 'osm') AS osm,
COUNT(*) FILTER (WHERE latitude IS NOT NULL) AS geocoded,
COUNT(*) FILTER (WHERE category = 'spa') AS spas,
COUNT(*) FILTER (WHERE category = 'salon') AS salons,
COUNT(*) FILTER (WHERE category = 'barbershop') AS barbershops
FROM businesses
`);
log(`DB now: total=${tally.total} bbcb=${tally.bbcb} osm=${tally.osm} geocoded=${tally.geocoded} salons=${tally.salons} barbershops=${tally.barbershops} spas=${tally.spas}`);
}
process.exit(0);
}
main().catch(e => { console.error(e); process.exit(2); });