From c4b0148121dc92a19a39145b1c831a4b6c96b320 Mon Sep 17 00:00:00 2001 From: dimedtec Date: Mon, 7 Sep 2026 00:07:14 +0200 Subject: [PATCH] Oeffentliche Rueckwaerts-Quelle: Reverse-Lookup gegen dasoertliche.de (inoffiziell, ToS-Risiko dokumentiert) --- .../appsettings.Local.json.example | 5 +- src/Anrufmonitor.App/appsettings.json | 5 +- src/Anrufmonitor.Contacts/ContactsOptions.cs | 15 ++- .../PublicDirectoryLookupProvider.cs | 91 ++++++++++++------- .../PublicDirectoryLookupProviderTests.cs | 49 ++++++++++ 5 files changed, 118 insertions(+), 47 deletions(-) create mode 100644 tests/Anrufmonitor.Tests/PublicDirectoryLookupProviderTests.cs diff --git a/src/Anrufmonitor.App/appsettings.Local.json.example b/src/Anrufmonitor.App/appsettings.Local.json.example index cb1e726..42b215b 100644 --- a/src/Anrufmonitor.App/appsettings.Local.json.example +++ b/src/Anrufmonitor.App/appsettings.Local.json.example @@ -32,9 +32,8 @@ "_comment": "Liest Kontakte direkt aus dem lokal installierten klassischen Outlook (COM/MAPI), deckt alle dort eingerichteten Konten ab. Kein Azure/OAuth noetig, aber ggf. Outlook-Sicherheitsdialog bestaetigen." }, "PublicDirectory": { - "Enabled": false, - "RequestUrlTemplate": "https://example-directory.local/api/lookup?number={number}", - "ApiKey": "" + "Enabled": true, + "_comment": "Reverse-Lookup gegen dasoertliche.de fuer unbekannte Nummern (nicht in Office365/MAPI/Google gefunden). Keine offizielle API, ToS-Risiko - siehe PublicDirectoryLookupProvider.cs." } } } diff --git a/src/Anrufmonitor.App/appsettings.json b/src/Anrufmonitor.App/appsettings.json index 8988208..6658615 100644 --- a/src/Anrufmonitor.App/appsettings.json +++ b/src/Anrufmonitor.App/appsettings.json @@ -25,9 +25,8 @@ }, "PublicDirectory": { "Enabled": false, - "RequestUrlTemplate": "", - "ApiKey": "", - "TimeoutMilliseconds": 2000 + "TimeoutMilliseconds": 3000, + "_comment": "Reverse-Lookup gegen dasoertliche.de - keine offizielle API, siehe PublicDirectoryLookupProvider.cs" } }, "Logging": { diff --git a/src/Anrufmonitor.Contacts/ContactsOptions.cs b/src/Anrufmonitor.Contacts/ContactsOptions.cs index 73f847e..9b2b6b7 100644 --- a/src/Anrufmonitor.Contacts/ContactsOptions.cs +++ b/src/Anrufmonitor.Contacts/ContactsOptions.cs @@ -84,17 +84,16 @@ public sealed class OutlookMapiOptions } /// -/// Oeffentliches Telefonverzeichnis fuer On-Demand-Reverse-Lookup unbekannter Nummern. -/// Bewusst generisch gehalten (URL-Template + JSON-Pfade), da Anbieter/API je nach Land/Vertrag variieren. +/// On-Demand-Reverse-Lookup gegen die oeffentliche Rueckwaertssuche von "Das Oertliche" +/// (dasoertliche.de). ACHTUNG: keine offizielle API, sondern das Auslesen der normalen, +/// fuer Menschen gedachten HTML-Ergebnisseite - verstoesst vermutlich gegen deren +/// Nutzungsbedingungen und kann jederzeit brechen, wenn sich die Seite aendert. Bewusste +/// Entscheidung, da es keine kostenlose offizielle Alternative fuer Namens-Rueckwaertssuche +/// in Deutschland gibt. Siehe . /// public sealed class PublicDirectoryOptions { public bool Enabled { get; set; } - /// URL-Template, "{number}" wird durch die normalisierte Rufnummer ersetzt. - public string RequestUrlTemplate { get; set; } = string.Empty; - - public string? ApiKey { get; set; } - - public int TimeoutMilliseconds { get; set; } = 2000; + public int TimeoutMilliseconds { get; set; } = 3000; } diff --git a/src/Anrufmonitor.Contacts/PublicDirectoryLookupProvider.cs b/src/Anrufmonitor.Contacts/PublicDirectoryLookupProvider.cs index 89451d7..eb8f8a8 100644 --- a/src/Anrufmonitor.Contacts/PublicDirectoryLookupProvider.cs +++ b/src/Anrufmonitor.Contacts/PublicDirectoryLookupProvider.cs @@ -1,35 +1,47 @@ -using System.Net.Http.Json; -using System.Text.Json; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Anrufmonitor.Contacts; /// -/// On-Demand-Reverse-Lookup gegen ein oeffentliches Telefonverzeichnis. -/// Bewusst generisch (URL-Template statt fest verdrahtetem Anbieter), da es kein -/// einheitliches kostenloses API fuer sowas gibt und der Anbieter je nach Land/Vertrag -/// unterschiedlich ist. Erwartet eine simple JSON-Antwort mit einem "name"-Feld; -/// bei Bedarf an den tatsaechlichen Anbieter anpassen. +/// On-Demand-Reverse-Lookup gegen die Rueckwaertssuche von "Das Oertliche" (dasoertliche.de). +/// +/// WICHTIG: dies ist KEINE offizielle API. Es wird die normale HTML-Ergebnisseite der +/// Rueckwaertssuche abgerufen (URL-Schema per Browser-Analyse ermittelt, Stand 2026: +/// https://www.dasoertliche.de/rueckwaertssuche/?ph=<nummer>), die eigentlich fuer Menschen im +/// Browser gedacht ist. Das duerfte gegen die Nutzungsbedingungen der Seite verstossen und kann +/// jederzeit brechen, wenn sich die Seite aendert - bewusste, vom Nutzer getroffene Entscheidung, +/// da es keine kostenlose offizielle Alternative fuer Namens-Rueckwaertssuche in Deutschland gibt. +/// +/// Extraktion: bei einem Treffer bettet die Seite ein JS-Konfigurationsobjekt fuer ihr Chat-Widget +/// ein (chatwerkLoaderConfig...organizationQuery.generic), das Name/Strasse/PLZ/Ort/Telefon/Email +/// als schlichte JS-Objektliteral-Syntax enthaelt - das wird per Regex ausgelesen, da es stabiler +/// ist als CSS-Klassennamen (die bei Layout-Aenderungen haeufiger wechseln). /// public sealed class PublicDirectoryLookupProvider : IReverseLookupProvider { - private readonly ContactsOptions _options; + private static readonly Regex GenericBlockRegex = new(@"generic:\s*\{(?[^}]*)\}", RegexOptions.Compiled); + private static readonly Regex NameRegex = new(@"name:\s*""(?[^""]*)""", RegexOptions.Compiled); + private static readonly Regex CityRegex = new(@"city:\s*""(?[^""]*)""", RegexOptions.Compiled); + + private readonly PublicDirectoryOptions _options; private readonly HttpClient _httpClient; private readonly ILogger _logger; public PublicDirectoryLookupProvider( IOptions options, HttpClient httpClient, ILogger logger) { - _options = options.Value; + _options = options.Value.PublicDirectory; _httpClient = httpClient; _logger = logger; } public ContactSource Source => ContactSource.PublicDirectory; - public bool IsEnabled => - _options.PublicDirectory.Enabled && !string.IsNullOrWhiteSpace(_options.PublicDirectory.RequestUrlTemplate); + public bool IsEnabled => _options.Enabled; public async Task LookupAsync(string normalizedNumber, CancellationToken cancellationToken) { @@ -38,18 +50,22 @@ public sealed class PublicDirectoryLookupProvider : IReverseLookupProvider return null; } - var url = _options.PublicDirectory.RequestUrlTemplate.Replace("{number}", normalizedNumber); + // dasoertliche.de erwartet nationale Schreibweise mit fuehrender 0 (z. B. "06921201"), + // unser normalisiertes Format ist "49..." ohne "+"/"0". + var nationalNumber = normalizedNumber.StartsWith("49", StringComparison.Ordinal) + ? "0" + normalizedNumber[2..] + : normalizedNumber; + + var url = $"https://www.dasoertliche.de/rueckwaertssuche/?ph={Uri.EscapeDataString(nationalNumber)}"; using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(_options.PublicDirectory.TimeoutMilliseconds)); + timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(_options.TimeoutMilliseconds)); try { using var request = new HttpRequestMessage(HttpMethod.Get, url); - if (!string.IsNullOrWhiteSpace(_options.PublicDirectory.ApiKey)) - { - request.Headers.Add("Authorization", $"Bearer {_options.PublicDirectory.ApiKey}"); - } + request.Headers.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"); using var response = await _httpClient.SendAsync(request, timeoutCts.Token); if (!response.IsSuccessStatusCode) @@ -57,41 +73,50 @@ public sealed class PublicDirectoryLookupProvider : IReverseLookupProvider return null; } - await using var stream = await response.Content.ReadAsStreamAsync(timeoutCts.Token); - using var document = await JsonDocument.ParseAsync(stream, cancellationToken: timeoutCts.Token); + var html = await response.Content.ReadAsStringAsync(timeoutCts.Token); - var name = ExtractDisplayName(document.RootElement); - if (string.IsNullOrWhiteSpace(name)) + if (html.Contains("Leider konnten wir keinen Eintrag", StringComparison.OrdinalIgnoreCase)) { return null; } + var blockMatch = GenericBlockRegex.Match(html); + if (!blockMatch.Success) + { + _logger.LogDebug("Reverse-Lookup (Das Oertliche): erwartetes Datenobjekt nicht gefunden fuer {Number}", normalizedNumber); + return null; + } + + var body = blockMatch.Groups["body"].Value; + var nameMatch = NameRegex.Match(body); + if (!nameMatch.Success || string.IsNullOrWhiteSpace(nameMatch.Groups["value"].Value)) + { + return null; + } + + var name = WebUtility.HtmlDecode(nameMatch.Groups["value"].Value); + var cityMatch = CityRegex.Match(body); + var city = cityMatch.Success && !string.IsNullOrWhiteSpace(cityMatch.Groups["value"].Value) + ? WebUtility.HtmlDecode(cityMatch.Groups["value"].Value) + : null; + return new ContactInfo( Id: normalizedNumber, Source: ContactSource.PublicDirectory, DisplayName: name, - Company: null, + Company: city, PhoneNumbers: [normalizedNumber], PhotoUrl: null); } catch (OperationCanceledException) { - _logger.LogDebug("Reverse-Lookup fuer {Number} hat Timeout ueberschritten", normalizedNumber); + _logger.LogDebug("Reverse-Lookup (Das Oertliche) fuer {Number} hat Timeout ueberschritten", normalizedNumber); return null; } catch (Exception ex) { - _logger.LogWarning(ex, "Reverse-Lookup fuer {Number} fehlgeschlagen", normalizedNumber); + _logger.LogWarning(ex, "Reverse-Lookup (Das Oertliche) fuer {Number} fehlgeschlagen", normalizedNumber); return null; } } - - /// - /// TODO: an das tatsaechlich verwendete oeffentliche Verzeichnis anpassen. - /// Aktuell wird ein einfaches Top-Level-Feld "name" erwartet. - /// - private static string? ExtractDisplayName(JsonElement root) - { - return root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; - } } diff --git a/tests/Anrufmonitor.Tests/PublicDirectoryLookupProviderTests.cs b/tests/Anrufmonitor.Tests/PublicDirectoryLookupProviderTests.cs new file mode 100644 index 0000000..943a219 --- /dev/null +++ b/tests/Anrufmonitor.Tests/PublicDirectoryLookupProviderTests.cs @@ -0,0 +1,49 @@ +using Anrufmonitor.Contacts; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Anrufmonitor.Tests; + +/// +/// Integrationstest gegen die echte dasoertliche.de-Seite (kein Mock) - stellt sicher, dass +/// die HTML-Extraktion (siehe PublicDirectoryLookupProvider) noch funktioniert. Nutzt die +/// oeffentlich bekannte, garantiert gelistete Nummer der Stadtverwaltung Frankfurt. +/// Faellt dieser Test irgendwann aus, hat sich vermutlich die Seitenstruktur geaendert. +/// +public class PublicDirectoryLookupProviderTests +{ + [Fact] + public async Task LookupAsync_FindsKnownPublicEntry() + { + var options = Options.Create(new ContactsOptions + { + PublicDirectory = new PublicDirectoryOptions { Enabled = true, TimeoutMilliseconds = 5000 }, + }); + + using var httpClient = new HttpClient(); + var provider = new PublicDirectoryLookupProvider(options, httpClient, NullLogger.Instance); + + var result = await provider.LookupAsync("4969212 01".Replace(" ", ""), CancellationToken.None); + + Assert.NotNull(result); + Assert.Contains("Frankfurt", result!.DisplayName, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task LookupAsync_UnknownNumber_ReturnsNull() + { + var options = Options.Create(new ContactsOptions + { + PublicDirectory = new PublicDirectoryOptions { Enabled = true, TimeoutMilliseconds = 5000 }, + }); + + using var httpClient = new HttpClient(); + var provider = new PublicDirectoryLookupProvider(options, httpClient, NullLogger.Instance); + + // Ungueltige/nicht existierende Nummer + var result = await provider.LookupAsync("491511234567", CancellationToken.None); + + Assert.Null(result); + } +}