Wetterdienst
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user