← back to Ventura Corridor
src/ingest/osm_overpass.ts
213 lines
/**
* OpenStreetMap Overpass ingest — Ventura Blvd corridor.
*
* Free, no API key. Hits the public Overpass API at overpass-api.de.
* Be polite: one big query rather than 27 small ones.
*
* Strategy:
* 1. Bbox the entire 17-mi corridor (~34.130..34.165 lat × -118.620..-118.370 lng).
* 2. Pull every node + way that has an `addr:street` tag matching `Ventura B*`
* AND at least one business-y tag (shop, amenity, office, craft, tourism, healthcare, leisure).
* 3. UPSERT into businesses(source='osm', source_id=node-/way-/relation-id).
*
* Coverage: OSM has ~40-60% of Ventura Blvd storefronts depending on which
* stretch (Studio City and Sherman Oaks better-mapped than Calabasas).
* We layer this with HERE/Foursquare later — UPSERT keys are (source, source_id)
* so each provider has its own row; dedupe across providers is a separate pass.
*
* Run: npm run ingest:overpass
*/
import 'dotenv/config';
import { pool, query } from '../../db/pool.ts';
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
const BBOX = {
south: parseFloat(process.env.VENTURA_BBOX_SOUTH || '34.130'),
west: parseFloat(process.env.VENTURA_BBOX_WEST || '-118.620'),
north: parseFloat(process.env.VENTURA_BBOX_NORTH || '34.165'),
east: parseFloat(process.env.VENTURA_BBOX_EAST || '-118.370'),
};
// Overpass QL — businesses with a Ventura Blvd address.
// We require BOTH: (a) some kind of business tag, (b) addr:street matches.
const QUERY = `
[out:json][timeout:90];
(
node["addr:street"~"Ventura B(l)?(v)?d|Ventura Boulevard",i]
[~"^(shop|amenity|office|craft|tourism|healthcare|leisure)$"~".",i]
(${BBOX.south},${BBOX.west},${BBOX.north},${BBOX.east});
way["addr:street"~"Ventura B(l)?(v)?d|Ventura Boulevard",i]
[~"^(shop|amenity|office|craft|tourism|healthcare|leisure)$"~".",i]
(${BBOX.south},${BBOX.west},${BBOX.north},${BBOX.east});
);
out tags center;
`.trim();
interface OsmElement {
type: 'node' | 'way' | 'relation';
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function topCategory(tags: Record<string, string>): { name: string | null; raw: string | null } {
// Order matters — pick the most specific business tag first.
for (const k of ['shop', 'amenity', 'craft', 'office', 'healthcare', 'tourism', 'leisure']) {
if (tags[k]) {
const friendly = `${k}: ${tags[k].replace(/_/g, ' ')}`;
return { name: friendly, raw: `${k}=${tags[k]}` };
}
}
return { name: null, raw: null };
}
function isVenturaBlvd(s?: string): boolean {
if (!s) return false;
return /\bventura\s+(b(l)?(v)?d|boulevard)\b/i.test(s);
}
function normalizeStreet(s?: string): string | null {
if (!s) return null;
return s.toUpperCase().replace(/\s+/g, ' ').trim();
}
async function upsertElement(el: OsmElement): Promise<'new' | 'updated' | 'skipped'> {
const t = el.tags || {};
if (!t.name) return 'skipped'; // anonymous nodes are noise
const cat = topCategory(t);
if (!cat.name) return 'skipped'; // no business tag — shouldn't happen given our query
const lat = el.lat ?? el.center?.lat ?? null;
const lng = el.lon ?? el.center?.lon ?? null;
const street = t['addr:street'] || null;
const houseNum = t['addr:housenumber'] || null;
const block = houseNum ? Math.floor(parseInt(houseNum, 10) / 100) * 100 || null : null;
const onCorridor = isVenturaBlvd(street);
const r = await query<{ id: number; first_seen_at: string }>(`
INSERT INTO businesses (
source, source_id, name, category, category_raw,
address, street_number, street_name, city, zip, lat, lng,
phone, website, on_corridor, corridor_block, raw, last_seen_at
) VALUES (
'osm', $1, $2, $3, $4,
$5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, NOW()
)
ON CONFLICT (source, source_id) DO UPDATE SET
name = EXCLUDED.name,
category = EXCLUDED.category,
category_raw = EXCLUDED.category_raw,
address = EXCLUDED.address,
street_number = EXCLUDED.street_number,
street_name = EXCLUDED.street_name,
city = COALESCE(EXCLUDED.city, businesses.city),
zip = COALESCE(EXCLUDED.zip, businesses.zip),
lat = EXCLUDED.lat,
lng = EXCLUDED.lng,
phone = COALESCE(EXCLUDED.phone, businesses.phone),
website = COALESCE(EXCLUDED.website, businesses.website),
on_corridor = EXCLUDED.on_corridor OR businesses.on_corridor,
corridor_block = COALESCE(EXCLUDED.corridor_block, businesses.corridor_block),
raw = EXCLUDED.raw,
last_seen_at = NOW()
RETURNING id, first_seen_at
`, [
`${el.type}/${el.id}`,
t.name,
cat.name,
cat.raw,
[houseNum, street, t['addr:city'], t['addr:postcode']].filter(Boolean).join(' '),
houseNum,
normalizeStreet(street),
t['addr:city'] || null,
t['addr:postcode'] || null,
lat,
lng,
t['phone'] || t['contact:phone'] || null,
t['website'] || t['contact:website'] || t['url'] || null,
onCorridor,
block,
JSON.stringify(el),
]);
return Date.parse(r.rows[0].first_seen_at) > Date.now() - 5_000 ? 'new' : 'updated';
}
async function main() {
const run = await query<{ id: number }>(
`INSERT INTO ingest_runs (source, notes) VALUES ('osm', $1) RETURNING id`,
[`Overpass — bbox ${BBOX.south},${BBOX.west} → ${BBOX.north},${BBOX.east} · addr:street ~ Ventura B*`],
);
const runId = run.rows[0].id;
console.log(`[osm] ingest_run #${runId} starting · single Overpass query`);
console.log(`[osm] bbox ${BBOX.south},${BBOX.west} → ${BBOX.north},${BBOX.east}`);
let inTotal = 0, newTotal = 0, updTotal = 0, skippedTotal = 0, errMessage: string | null = null;
try {
process.stdout.write('[osm] firing Overpass query (up to 90s timeout) ... ');
const r = await fetch(OVERPASS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'ventura-corridor/0.1 (local research; contact via project repo)',
},
body: 'data=' + encodeURIComponent(QUERY),
});
if (!r.ok) {
const txt = await r.text().catch(() => '');
throw new Error(`Overpass ${r.status} ${r.statusText}: ${txt.slice(0, 300)}`);
}
const j = await r.json() as { elements?: OsmElement[] };
const elements = j.elements || [];
console.log(`got ${elements.length} elements`);
inTotal = elements.length;
let i = 0;
for (const el of elements) {
i++;
if (i % 25 === 0) process.stdout.write(` · processed ${i}/${elements.length}\n`);
const v = await upsertElement(el);
if (v === 'new') newTotal++;
else if (v === 'updated') updTotal++;
else skippedTotal++;
}
} catch (e: any) {
console.log(`ERROR: ${e.message}`);
errMessage = e.message;
}
await query(
`UPDATE ingest_runs SET finished_at=NOW(), rows_in=$1, rows_new=$2, rows_updated=$3, error_message=$4 WHERE id=$5`,
[inTotal, newTotal, updTotal, errMessage, runId],
);
// Summary
const corridor = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM businesses WHERE on_corridor`);
const withSite = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM businesses WHERE on_corridor AND website IS NOT NULL`);
const cities = await query<{ city: string; n: string }>(`
SELECT COALESCE(city, '(none)') AS city, COUNT(*)::text AS n
FROM businesses WHERE on_corridor
GROUP BY city ORDER BY COUNT(*) DESC LIMIT 10
`);
console.log('');
console.log(`[osm] done. fetched ${inTotal} · new ${newTotal} · updated ${updTotal} · skipped ${skippedTotal}`);
console.log(`[osm] businesses on Ventura Blvd: ${corridor.rows[0].n} (with website: ${withSite.rows[0].n})`);
if (cities.rows.length) {
console.log('[osm] by city:');
for (const c of cities.rows) console.log(` ${c.city.padEnd(20)} ${c.n}`);
}
await pool.end();
}
main().catch((e) => {
console.error('[osm]', e);
process.exit(1);
});