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>
59 lines
2.8 KiB
TypeScript
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 };
|
|
},
|
|
};
|
|
}
|