Files
craftvia/src/server/services/geo/nominatim.ts
T
msolarczekandClaude Opus 5 213bcca3a1 L13 Planung: Geocoding der Objekte über OpenStreetMap Nominatim
Provider-Interface (nominatim|none), strukturierte Suche mit User-Agent und Accept-Language, Drosselung 1/s je Prozess, Job geocode-site (Worker: Concurrency 1 + Limiter), Cache am Objekt, manuelle Koordinaten bleiben, Auslöser Anlage/Adressänderung/Import-Bestätigung nur per Queue, Backfill-Skript, Env-Beispiele. Tests mit Fake-Provider, Nominatim nie aufgerufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:13:26 +02:00

59 lines
2.8 KiB
TypeScript

import { GeocodingError, type GeocodeAddress, type GeocodeResult, type GeocodingProvider } from "@/server/services/geo/provider";
import { nominatimLimiter } from "@/server/services/geo/rate-limit";
/**
* Nominatim (OpenStreetMap) provider. Usage policy: server side only, max. 1 request/s process-wide,
* identifying User-Agent, results are cached in Site.latitude/longitude by the caller, no bulk
* geocoding inside requests (only the geocode-site job and scripts/geocode-backfill.ts call this).
*/
type Config = { url: string; userAgent: string; timeoutMs: number };
type Deps = { fetch: typeof fetch; limiter: { acquire(): Promise<number> } };
/** Structured search URL (no free text → fewer ambiguous hits). */
export function buildNominatimUrl(baseUrl: string, address: GeocodeAddress): URL {
const url = new URL(`${baseUrl.replace(/\/+$/, "")}/search`);
url.searchParams.set("format", "jsonv2");
url.searchParams.set("limit", "1");
url.searchParams.set("addressdetails", "0");
const street = [address.houseNumber, address.street].map((s) => s?.trim()).filter(Boolean).join(" ");
if (street) url.searchParams.set("street", street);
if (address.postalCode?.trim()) url.searchParams.set("postalcode", address.postalCode.trim());
if (address.city?.trim()) url.searchParams.set("city", address.city.trim());
const country = (address.country ?? "DE").trim().toLowerCase();
if (/^[a-z]{2}$/.test(country)) url.searchParams.set("countrycodes", country);
return url;
}
export function createNominatimProvider(config: Config, deps: Deps = { fetch: globalThis.fetch, limiter: nominatimLimiter }): GeocodingProvider {
return {
name: "nominatim",
async geocode(address: GeocodeAddress): Promise<GeocodeResult> {
await deps.limiter.acquire();
let res: Response;
try {
res = await deps.fetch(buildNominatimUrl(config.url, address), {
headers: { "User-Agent": config.userAgent, "Accept-Language": "de", Accept: "application/json" },
signal: AbortSignal.timeout(config.timeoutMs),
});
} catch (err) {
throw new GeocodingError(`request failed: ${(err as Error).name}`);
}
if (!res.ok) throw new GeocodingError(`http ${res.status}`);
let body: unknown;
try {
body = await res.json();
} catch {
throw new GeocodingError("invalid response");
}
const hit = Array.isArray(body) ? (body[0] as { lat?: unknown; lon?: unknown } | undefined) : undefined;
const latitude = Number(hit?.lat);
const longitude = Number(hit?.lon);
if (!hit || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
return { status: "not_found" };
}
return { status: "ok", latitude, longitude };
},
};
}