jagd-apps/nachsuche/backend/utils/geocode.js

242 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const https = require('https');
const fs = require('fs').promises;
const path = require('path');
const config = require('../config/env');
const logger = require('./logger');
// Pfad konfigurierbar: im Container zeigt GEOCODE_CACHE_FILE in ein
// Verzeichnis-Volume. Ein Named Volume ist immer ein Verzeichnis und kann
// nicht auf einen Dateipfad gemountet werden — vorher lief jeder Lese- und
// Schreibversuch in EISDIR, still verschluckt vom try/catch.
const CACHE_FILE = process.env.GEOCODE_CACHE_FILE
|| path.join(__dirname, '..', 'geocode-cache.json');
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map();
let lastRequestTime = 0;
let cacheModified = false;
/**
* Einziger Schreibpfad in den Cache, inklusive Größenbegrenzung.
*
* Vorher war nur der Erfolgspfad begrenzt; die beiden Negativ-Pfade
* (Adresse nicht gefunden / unbrauchbare Koordinaten) haben ungebremst
* geschrieben. Über den öffentlichen /api/public/geocode genügten damit
* erfundene Postleitzahlen, um Speicher und Cache-Datei beliebig wachsen
* zu lassen — bei --max_old_space_size=256 eine reale Grenze.
*/
const rememberInCache = (key, value) => {
// Map behält die Einfügereihenfolge: ein vorhandener Schlüssel muss neu
// eingefügt werden, damit er als "zuletzt benutzt" ans Ende rückt.
cache.delete(key);
while (cache.size >= CACHE_MAX_ENTRIES) {
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
cacheModified = true;
};
// Load cache from file on startup
const loadCache = async () => {
try {
const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data);
// Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
cache.set(key, value);
});
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
} catch (error) {
if (error.code !== 'ENOENT') {
logger.warn('Failed to load geocoding cache:', error.message);
}
}
};
// Save cache to file
const saveCache = async () => {
if (!cacheModified) return;
try {
const obj = Object.fromEntries(cache);
await fs.writeFile(CACHE_FILE, JSON.stringify(obj, null, 2));
cacheModified = false;
logger.info(`Geocoding cache saved: ${cache.size} entries`);
} catch (error) {
logger.error('Failed to save geocoding cache:', error.message);
}
};
// Periodically save cache
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
const flushOnShutdown = () => {
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
};
process.on('SIGINT', flushOnShutdown);
process.on('SIGTERM', flushOnShutdown);
// Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err));
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const fetchJson = (url, headers) => new Promise((resolve, reject) => {
const req = https.get(url, { headers }, (res) => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
return reject(new Error(`Geocode HTTP ${res.statusCode}`));
}
try {
resolve(JSON.parse(data));
} catch (error) {
reject(error);
}
});
});
req.on('error', reject);
req.end();
});
const geocodeAddress = async (address) => {
const normalized = (address || '').trim();
if (!normalized) {
return null;
}
const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) {
// Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
}
const elapsed = Date.now() - lastRequestTime;
if (elapsed < config.geocodeMinDelayMs) {
await sleep(config.geocodeMinDelayMs - elapsed);
}
const url = `${config.geocodeUrl}?format=json&limit=1&countrycodes=de&q=${encodeURIComponent(normalized)}`;
const headers = {
'User-Agent': config.geocodeUserAgent
};
try {
const results = await fetchJson(url, headers);
lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) {
rememberInCache(cacheKey, null);
return null;
}
const hit = results[0];
const lat = parseFloat(hit.lat);
const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
rememberInCache(cacheKey, null);
return null;
}
const coords = { lat, lng };
rememberInCache(cacheKey, coords);
return coords;
} catch (error) {
logger.warn('Geocoding fehlgeschlagen', {
address: normalized,
error: error.message
});
lastRequestTime = Date.now();
return null;
}
};
// Nur die Felder, die das Adressformular im Frontend tatsächlich auswertet.
// Alles andere aus der Nominatim-Antwort wird verworfen.
const ADDRESS_PARTS = [
'road', 'house_number', 'postcode',
'city', 'town', 'village', 'municipality', 'hamlet',
'county', 'state'
];
const trimSuggestion = (hit) => {
const address = {};
for (const key of ADDRESS_PARTS) {
if (hit.address && hit.address[key]) address[key] = hit.address[key];
}
return {
lat: hit.lat,
lon: hit.lon,
display_name: hit.display_name,
address
};
};
/**
* Freitext-Adresssuche mit mehreren Treffern — für die Autovervollständigung
* im Benutzerformular.
*
* Läuft bewusst über den Server: vorher hat der Browser Nominatim direkt
* angefragt, wodurch weder die Mindestwartezeit noch der vorgeschriebene
* User-Agent griffen und die IP jedes Admins beim Dienst landete.
*
* Teilt sich Wartezeit und Cache mit geocodeAddress; der Cache-Schlüssel ist
* mit einem Präfix versehen, damit die Einträge sich nicht überschneiden.
*/
const searchAddresses = async (query, limit = 5) => {
const normalized = (query || '').trim();
if (normalized.length < 3) {
return [];
}
const count = Math.min(Math.max(parseInt(limit, 10) || 5, 1), 10);
const cacheKey = `search:${count}:${normalized.toLowerCase()}`;
if (cache.has(cacheKey)) {
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit || [];
}
const elapsed = Date.now() - lastRequestTime;
if (elapsed < config.geocodeMinDelayMs) {
await sleep(config.geocodeMinDelayMs - elapsed);
}
const url = `${config.geocodeUrl}?format=json&addressdetails=1&limit=${count}`
+ `&countrycodes=de&q=${encodeURIComponent(normalized)}`;
try {
const results = await fetchJson(url, { 'User-Agent': config.geocodeUserAgent });
lastRequestTime = Date.now();
const list = Array.isArray(results) ? results.map(trimSuggestion) : [];
rememberInCache(cacheKey, list);
return list;
} catch (error) {
logger.warn('Adresssuche fehlgeschlagen', { query: normalized, error: error.message });
lastRequestTime = Date.now();
return [];
}
};
module.exports = {
geocodeAddress,
searchAddresses
};