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>
This commit is contained in:
2026-09-15 10:13:26 +02:00
co-authored by Claude Opus 5
parent 604f2bfdc5
commit 213bcca3a1
14 changed files with 600 additions and 2 deletions
+81
View File
@@ -0,0 +1,81 @@
/**
* L13 Planung — geocode sites that have no (or outdated) coordinates, respecting the OSM Nominatim
* usage policy: strictly sequential, max. 1 request per second (process-wide limiter in the
* provider), identifying User-Agent, results cached on the site (unchanged addresses are skipped).
* Manually entered coordinates are never overwritten.
*
* Usage:
* npx tsx scripts/geocode-backfill.ts # all tenants
* npx tsx scripts/geocode-backfill.ts --tenant=demo --limit=50
* npx tsx scripts/geocode-backfill.ts --dry-run # only list what would be geocoded
* npx tsx scripts/geocode-backfill.ts --retry-not-found # also retry addresses marked not_found
*
* For larger data sets use a self-hosted or contracted geocoding service (GEOCODING_URL).
*/
import "dotenv/config";
import { isValidLatLng } from "../src/lib/geo/distance";
import { dbForTenant, prisma } from "../src/server/db";
import { geocodingConfig } from "../src/server/services/geo/config";
import { geocodeSite, type GeocodeOutcome } from "../src/server/services/geo/geocode-site";
import { geocodeQueryFor } from "../src/server/services/geo/normalize";
const arg = (name: string) => process.argv.find((a) => a.startsWith(`--${name}=`))?.split("=")[1];
const flag = (name: string) => process.argv.includes(`--${name}`);
async function main() {
const cfg = geocodingConfig();
const dryRun = flag("dry-run");
const retryNotFound = flag("retry-not-found");
const limit = Number(arg("limit") ?? Infinity);
const slug = arg("tenant");
if (cfg.provider === "none" && !dryRun) {
console.log("GEOCODING_PROVIDER=none — nichts zu tun.");
return;
}
console.log(`Provider ${cfg.provider} (${cfg.url}), User-Agent "${cfg.userAgent}", ≤ 1 Anfrage/s${dryRun ? " — Probelauf" : ""}`);
const tenants = await prisma.tenant.findMany({ where: slug ? { slug } : {}, select: { id: true, slug: true }, orderBy: { slug: "asc" } });
const totals: Record<string, number> = {};
let done = 0;
for (const tenant of tenants) {
const db = dbForTenant(tenant.id);
const sites = await db.site.findMany({
where: { deletedAt: null },
select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, country: true, latitude: true, longitude: true, geocodedAt: true, geocodeStatus: true, geocodeQuery: true },
orderBy: { createdAt: "asc" },
});
const pending = sites.filter((s) => {
const query = geocodeQueryFor(s);
if (!query) return false;
if (isValidLatLng(s) && !s.geocodedAt) return false; // manual coordinates
if (s.geocodeQuery !== query) return true;
if (s.geocodeStatus === "ok") return false;
if (s.geocodeStatus === "not_found") return retryNotFound;
return true;
});
if (!pending.length) continue;
console.log(`\n${tenant.slug}: ${pending.length} Objekt(e)`);
for (const site of pending) {
if (done >= limit) break;
done++;
if (dryRun) {
console.log(` · ${site.name} (${[site.postalCode, site.city].filter(Boolean).join(" ")})`);
continue;
}
if (retryNotFound && site.geocodeStatus === "not_found") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: null } });
const outcome: GeocodeOutcome = await geocodeSite(tenant.id, site.id);
totals[outcome] = (totals[outcome] ?? 0) + 1;
console.log(` ${outcome.padEnd(9)} ${site.name}`);
}
}
console.log(`\nFertig: ${done} Objekt(e) ${JSON.stringify(totals)}`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});