API, Backend & Frontend

This commit is contained in:
Ichitux
2026-04-01 01:18:21 +02:00
parent 331c04fbef
commit 0fe8ec9bc0
44 changed files with 10060 additions and 0 deletions

46
API/index.js Normal file
View File

@@ -0,0 +1,46 @@
/**
* FarmaFinder external pharmacy sources (OpenStreetMap, Google Places, open-data URLs).
* Used by the backend admin import; OSM does not require n8n.
*/
export { buildAddressFromOsmTags, osmElementToPharmacy } from './normalize.js';
export { fetchPharmaciesFromOsm } from './osm-overpass.js';
export { fetchPharmaciesFromOpenDataUrl } from './open-data.js';
import { fetchPharmaciesFromOsm } from './osm-overpass.js';
import { fetchPharmaciesFromOpenDataUrl } from './open-data.js';
/**
* @param {{
* source: 'osm' | 'openData',
* lat?: number|string,
* lon?: number|string,
* lng?: number|string,
* radio?: number|string,
* openDataUrl?: string,
* }} opts
*/
export async function fetchPharmaciesExternal(opts) {
const source = opts?.source;
if (source === 'openData') {
return fetchPharmaciesFromOpenDataUrl(opts.openDataUrl);
}
const lat = parseFloat(opts.lat);
const lon = parseFloat(opts.lon ?? opts.lng);
const radio = parseFloat(opts.radio) || 1500;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new Error('lat and lon are required for osm sources');
}
if (source === 'osm') {
return fetchPharmaciesFromOsm({
lat,
lon,
radiusMeters: radio,
});
}
throw new Error('source must be "osm", or "openData"');
}

57
API/normalize.js Normal file
View File

@@ -0,0 +1,57 @@
/**
* Shared helpers: OSM tags → single-line address, element → pharmacy row.
*/
export function buildAddressFromOsmTags(tags) {
if (!tags || typeof tags !== 'object') return '';
if (tags['addr:full'] && String(tags['addr:full']).trim()) {
return String(tags['addr:full']).trim();
}
const street = [tags['addr:street'], tags['addr:housenumber']].filter(Boolean).join(' ').trim();
const cityLine = [tags['addr:postcode'], tags['addr:city'] || tags['addr:place'] || tags['addr:suburb']]
.filter(Boolean)
.join(' ')
.trim();
const parts = [street, cityLine].filter(Boolean);
if (parts.length) return parts.join(', ');
if (tags['addr:province'] && tags['addr:city']) {
return `${tags['addr:city']}, ${tags['addr:province']}`.trim();
}
return '';
}
/**
* @param {object} el - Overpass element (node | way | relation with optional center)
*/
export function osmElementToPharmacy(el) {
const tags = el.tags || {};
let lat;
let lon;
if (el.type === 'node' && el.lat != null && el.lon != null) {
lat = Number(el.lat);
lon = Number(el.lon);
} else if (el.center && el.center.lat != null && el.center.lon != null) {
lat = Number(el.center.lat);
lon = Number(el.center.lon);
}
const name = tags.name || tags.brand || tags.operator || null;
let address = buildAddressFromOsmTags(tags);
if (!address && tags['addr:country']) {
address = tags['addr:city'] || tags['addr:place'] || '';
}
if (!address && name) {
address = `OpenStreetMap (no address tags; id ${el.type}/${el.id})`;
}
const phone =
tags.phone || tags['contact:phone'] || tags['contact:mobile'] || tags['contact:whatsapp'] || null;
return {
name,
address: address || null,
phone: phone ? String(phone).trim() : null,
latitude: Number.isFinite(lat) ? lat : null,
longitude: Number.isFinite(lon) ? lon : null,
};
}

116
API/open-data.js Normal file
View File

@@ -0,0 +1,116 @@
/**
* Load pharmacy rows from a public open-data URL (JSON array, GeoJSON, or similar).
* No default URL — pass the dataset URL from the client (regional open-data portals).
*/
function rowsFromGeoJson(json) {
if (!json || json.type !== 'FeatureCollection' || !Array.isArray(json.features)) {
return [];
}
return json.features
.map((f) => {
const p = f.properties || {};
const name = p.name || p.nombre || p.denominacion || p.title;
const address =
p.address ||
p.direccion ||
p.domicilio ||
[p.calle, p.municipio, p.codigo_postal].filter(Boolean).join(', ');
let lat;
let lon;
if (f.geometry?.type === 'Point' && Array.isArray(f.geometry.coordinates)) {
lon = f.geometry.coordinates[0];
lat = f.geometry.coordinates[1];
}
return {
name: name ? String(name).trim() : null,
address: address ? String(address).trim() : null,
phone: p.phone || p.telefono || p.tel || null,
latitude: lat != null ? Number(lat) : null,
longitude: lon != null ? Number(lon) : null,
};
})
.filter((r) => r.name && r.address);
}
function rowsFromJsonArray(json) {
if (!Array.isArray(json)) return [];
return json
.map((row) => {
if (!row || typeof row !== 'object') return null;
const name = row.name || row.nombre || row.farmacia;
const address =
row.address ||
row.direccion ||
row.domicilio ||
row.full_address;
return {
name: name ? String(name).trim() : null,
address: address ? String(address).trim() : null,
phone: row.phone || row.telefono || null,
latitude:
row.latitude != null
? Number(row.latitude)
: row.lat != null
? Number(row.lat)
: null,
longitude:
row.longitude != null
? Number(row.longitude)
: row.lon != null
? Number(row.lon)
: row.lng != null
? Number(row.lng)
: null,
};
})
.filter((r) => r && r.name && r.address);
}
/**
* @param {string} openDataUrl - HTTPS URL returning JSON
* @returns {Promise<Array<{name,address,phone,latitude,longitude}>>}
*/
export async function fetchPharmaciesFromOpenDataUrl(openDataUrl) {
if (!openDataUrl || typeof openDataUrl !== 'string' || !openDataUrl.trim()) {
throw new Error('openData source requires a non-empty openDataUrl');
}
const u = openDataUrl.trim();
if (!/^https?:\/\//i.test(u)) {
throw new Error('openDataUrl must be an http(s) URL');
}
const res = await fetch(u, {
headers: {
Accept: 'application/json',
'User-Agent': 'FarmaFinder/1.0 (open-data pharmacy import)',
},
});
const text = await res.text();
if (!res.ok) {
throw new Error(`Open data HTTP ${res.status}: ${text.slice(0, 120)}`);
}
let json;
try {
json = text ? JSON.parse(text) : null;
} catch {
throw new Error('Open data response is not JSON');
}
if (json && json.type === 'FeatureCollection') {
return rowsFromGeoJson(json);
}
if (Array.isArray(json)) {
return rowsFromJsonArray(json);
}
if (json && Array.isArray(json.data)) {
return rowsFromJsonArray(json.data);
}
if (json && Array.isArray(json.records)) {
return rowsFromJsonArray(json.records);
}
throw new Error(
'Unsupported open-data JSON shape (expected FeatureCollection, array, or { data: [] })'
);
}

124
API/osm-overpass.js Normal file
View File

@@ -0,0 +1,124 @@
/**
* 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;
}