79 lines
3.4 KiB
C#
79 lines
3.4 KiB
C#
using System.Globalization;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json.Serialization;
|
|
using LehrerApp.Core.Models;
|
|
|
|
namespace LehrerApp.Api;
|
|
|
|
public interface IGeocoder
|
|
{
|
|
Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken);
|
|
}
|
|
|
|
public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName);
|
|
|
|
/// <summary>Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim-
|
|
/// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore.</summary>
|
|
public sealed class NominatimGeocoder(HttpClient http) : IGeocoder
|
|
{
|
|
private static readonly SemaphoreSlim RequestGate = new(1, 1);
|
|
private static DateTime _lastRequestAt = DateTime.MinValue;
|
|
|
|
public async Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var structured = Query(new Dictionary<string, string>
|
|
{
|
|
["street"] = address.Street,
|
|
["postalcode"] = address.PostalCode,
|
|
["city"] = address.City,
|
|
});
|
|
var result = await SearchRespectingRateLimitAsync(structured, cancellationToken);
|
|
if (result is not null) return result;
|
|
|
|
// Manche Schulen/Ortsteile sind bei OSM anders adressiert. Ein kontrollierter zweiter
|
|
// Versuch mit der gesamten Suchzeile ist robuster, bleibt aber weit weg vom 1-rps-Limit.
|
|
var freeForm = string.Join(", ", new[]
|
|
{
|
|
address.SchoolName, address.Street, $"{address.PostalCode} {address.City}", "Deutschland",
|
|
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
|
return await SearchRespectingRateLimitAsync(
|
|
Query(new Dictionary<string, string> { ["q"] = freeForm }), cancellationToken);
|
|
}
|
|
|
|
private async Task<GeocodingResult?> SearchRespectingRateLimitAsync(string query,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await RequestGate.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
var wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - _lastRequestAt);
|
|
if (wait > TimeSpan.Zero) await Task.Delay(wait, cancellationToken);
|
|
_lastRequestAt = DateTime.UtcNow;
|
|
var candidates = await http.GetFromJsonAsync<List<NominatimCandidate>>(
|
|
$"search?format=jsonv2&limit=1&countrycodes=de{query}", cancellationToken);
|
|
var candidate = candidates?.FirstOrDefault();
|
|
if (candidate is null ||
|
|
!double.TryParse(candidate.Lat, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) ||
|
|
!double.TryParse(candidate.Lon, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
|
|
return null;
|
|
return new(latitude, longitude, candidate.DisplayName ?? "");
|
|
}
|
|
finally { RequestGate.Release(); }
|
|
}
|
|
|
|
private static string Query(IReadOnlyDictionary<string, string> values) => string.Concat(
|
|
values.Where(x => !string.IsNullOrWhiteSpace(x.Value))
|
|
.Select(x => $"&{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value.Trim())}"));
|
|
|
|
private sealed class NominatimCandidate
|
|
{
|
|
[JsonPropertyName("lat")]
|
|
public string? Lat { get; set; }
|
|
[JsonPropertyName("lon")]
|
|
public string? Lon { get; set; }
|
|
[JsonPropertyName("display_name")]
|
|
public string? DisplayName { get; set; }
|
|
}
|
|
}
|