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);
+ }
+}