125 lines
3.3 KiB
JavaScript
125 lines
3.3 KiB
JavaScript
/**
|
|
* 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<Array<{name,address,phone,latitude,longitude}>>}
|
|
*/
|
|
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;
|
|
}
|