/** * Pharmacies from OpenStreetMap via Overpass API (no API key). * @see https://wiki.openstreetmap.org/wiki/Overpass_API */ import { osmElementToPharmacy } from './normalize.js'; const OVERPASS_ENDPOINTS = ( process.env.OVERPASS_API_URL || 'https://overpass-api.de/api/interpreter,https://overpass.openstreetmap.fr/api/interpreter' ) .split(',') .map((s) => s.trim()) .filter(Boolean); function buildOverpassQuery(lat, lon, radiusM) { // Nodes only (fast); ways/relations are rarer for pharmacies and heavy on public Overpass. return `[out:json][timeout:25]; ( node["amenity"="pharmacy"](around:${radiusM},${lat},${lon}); node["healthcare"="pharmacy"](around:${radiusM},${lat},${lon}); ); out;`; } const FETCH_MS = 35000; async function fetchOverpass(endpoint, body) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), FETCH_MS); try { return await fetch(endpoint, { method: 'POST', signal: ctrl.signal, headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'User-Agent': 'FarmaFinder/1.0 (OSM pharmacy import; local admin)', }, body, }); } finally { clearTimeout(timer); } } /** * @param {{ lat: number, lon: number, radiusMeters?: number }} opts * @returns {Promise>} */ export async function fetchPharmaciesFromOsm({ lat, lon, radiusMeters = 1500 }) { const latN = Number(lat); const lonN = Number(lon); if (!Number.isFinite(latN) || !Number.isFinite(lonN)) { throw new Error('fetchPharmaciesFromOsm: invalid lat or lon'); } const r = Math.round( Math.min(Math.max(Number(radiusMeters) || 1500, 50), 25000) ); const body = `data=${encodeURIComponent(buildOverpassQuery(latN, lonN, r))}`; let lastErr = null; let json = null; let text = ''; for (const endpoint of OVERPASS_ENDPOINTS) { let res; try { res = await fetchOverpass(endpoint, body); } catch (e) { lastErr = e.name === 'AbortError' ? new Error(`Overpass timeout (${FETCH_MS / 1000}s) at ${new URL(endpoint).host}`) : e; continue; } text = await res.text(); let parsed; try { parsed = text ? JSON.parse(text) : {}; } catch { lastErr = new Error( `Overpass returned non-JSON (HTTP ${res.status}): ${(text || '').slice(0, 120)}` ); continue; } if (!res.ok && (res.status === 504 || res.status === 502 || res.status === 429)) { lastErr = new Error( `Overpass busy (HTTP ${res.status}) at ${new URL(endpoint).host}` ); continue; } if (!res.ok) { const msg = parsed.remark || parsed.error || text.slice(0, 200); throw new Error(`Overpass HTTP ${res.status}: ${msg}`); } json = parsed; lastErr = null; break; } if (lastErr && !json) { throw lastErr; } if (!json || !Array.isArray(json.elements)) { throw lastErr || new Error('Overpass: no usable response'); } const elements = json.elements; const seen = new Set(); const out = []; for (const el of elements) { const key = `${el.type}/${el.id}`; if (seen.has(key)) continue; seen.add(key); const row = osmElementToPharmacy(el); if (row.name && row.address) { out.push(row); } } return out; }