Oeffentliche Rueckwaerts-Quelle: Reverse-Lookup gegen dasoertliche.de (inoffiziell, ToS-Risiko dokumentiert)

This commit is contained in:
dimedtec 2026-09-07 00:07:14 +02:00
parent 5f7edf13f5
commit c4b0148121
5 changed files with 118 additions and 47 deletions

View file

@ -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."
}
}
}

View file

@ -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": {

View file

@ -84,17 +84,16 @@ public sealed class OutlookMapiOptions
}
/// <summary>
/// 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 <see cref="PublicDirectoryLookupProvider"/>.
/// </summary>
public sealed class PublicDirectoryOptions
{
public bool Enabled { get; set; }
/// <summary>URL-Template, "{number}" wird durch die normalisierte Rufnummer ersetzt.</summary>
public string RequestUrlTemplate { get; set; } = string.Empty;
public string? ApiKey { get; set; }
public int TimeoutMilliseconds { get; set; } = 2000;
public int TimeoutMilliseconds { get; set; } = 3000;
}

View file

@ -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;
/// <summary>
/// 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 <see cref="ExtractDisplayName"/> 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=&lt;nummer&gt;), 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).
/// </summary>
public sealed class PublicDirectoryLookupProvider : IReverseLookupProvider
{
private readonly ContactsOptions _options;
private static readonly Regex GenericBlockRegex = new(@"generic:\s*\{(?<body>[^}]*)\}", RegexOptions.Compiled);
private static readonly Regex NameRegex = new(@"name:\s*""(?<value>[^""]*)""", RegexOptions.Compiled);
private static readonly Regex CityRegex = new(@"city:\s*""(?<value>[^""]*)""", RegexOptions.Compiled);
private readonly PublicDirectoryOptions _options;
private readonly HttpClient _httpClient;
private readonly ILogger<PublicDirectoryLookupProvider> _logger;
public PublicDirectoryLookupProvider(
IOptions<ContactsOptions> options, HttpClient httpClient, ILogger<PublicDirectoryLookupProvider> 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<ContactInfo?> 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;
}
}
/// <summary>
/// TODO: an das tatsaechlich verwendete oeffentliche Verzeichnis anpassen.
/// Aktuell wird ein einfaches Top-Level-Feld "name" erwartet.
/// </summary>
private static string? ExtractDisplayName(JsonElement root)
{
return root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
}
}

View file

@ -0,0 +1,49 @@
using Anrufmonitor.Contacts;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace Anrufmonitor.Tests;
/// <summary>
/// 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.
/// </summary>
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<PublicDirectoryLookupProvider>.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<PublicDirectoryLookupProvider>.Instance);
// Ungueltige/nicht existierende Nummer
var result = await provider.LookupAsync("491511234567", CancellationToken.None);
Assert.Null(result);
}
}