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." "_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": { "PublicDirectory": {
"Enabled": false, "Enabled": true,
"RequestUrlTemplate": "https://example-directory.local/api/lookup?number={number}", "_comment": "Reverse-Lookup gegen dasoertliche.de fuer unbekannte Nummern (nicht in Office365/MAPI/Google gefunden). Keine offizielle API, ToS-Risiko - siehe PublicDirectoryLookupProvider.cs."
"ApiKey": ""
} }
} }
} }

View file

@ -25,9 +25,8 @@
}, },
"PublicDirectory": { "PublicDirectory": {
"Enabled": false, "Enabled": false,
"RequestUrlTemplate": "", "TimeoutMilliseconds": 3000,
"ApiKey": "", "_comment": "Reverse-Lookup gegen dasoertliche.de - keine offizielle API, siehe PublicDirectoryLookupProvider.cs"
"TimeoutMilliseconds": 2000
} }
}, },
"Logging": { "Logging": {

View file

@ -84,17 +84,16 @@ public sealed class OutlookMapiOptions
} }
/// <summary> /// <summary>
/// Oeffentliches Telefonverzeichnis fuer On-Demand-Reverse-Lookup unbekannter Nummern. /// On-Demand-Reverse-Lookup gegen die oeffentliche Rueckwaertssuche von "Das Oertliche"
/// Bewusst generisch gehalten (URL-Template + JSON-Pfade), da Anbieter/API je nach Land/Vertrag variieren. /// (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> /// </summary>
public sealed class PublicDirectoryOptions public sealed class PublicDirectoryOptions
{ {
public bool Enabled { get; set; } public bool Enabled { get; set; }
/// <summary>URL-Template, "{number}" wird durch die normalisierte Rufnummer ersetzt.</summary> public int TimeoutMilliseconds { get; set; } = 3000;
public string RequestUrlTemplate { get; set; } = string.Empty;
public string? ApiKey { get; set; }
public int TimeoutMilliseconds { get; set; } = 2000;
} }

View file

@ -1,35 +1,47 @@
using System.Net.Http.Json; using System.Net;
using System.Text.Json; using System.Net.Http;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Anrufmonitor.Contacts; namespace Anrufmonitor.Contacts;
/// <summary> /// <summary>
/// On-Demand-Reverse-Lookup gegen ein oeffentliches Telefonverzeichnis. /// On-Demand-Reverse-Lookup gegen die Rueckwaertssuche von "Das Oertliche" (dasoertliche.de).
/// Bewusst generisch (URL-Template statt fest verdrahtetem Anbieter), da es kein ///
/// einheitliches kostenloses API fuer sowas gibt und der Anbieter je nach Land/Vertrag /// WICHTIG: dies ist KEINE offizielle API. Es wird die normale HTML-Ergebnisseite der
/// unterschiedlich ist. Erwartet eine simple JSON-Antwort mit einem "name"-Feld; /// Rueckwaertssuche abgerufen (URL-Schema per Browser-Analyse ermittelt, Stand 2026:
/// bei Bedarf <see cref="ExtractDisplayName"/> an den tatsaechlichen Anbieter anpassen. /// 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> /// </summary>
public sealed class PublicDirectoryLookupProvider : IReverseLookupProvider 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 HttpClient _httpClient;
private readonly ILogger<PublicDirectoryLookupProvider> _logger; private readonly ILogger<PublicDirectoryLookupProvider> _logger;
public PublicDirectoryLookupProvider( public PublicDirectoryLookupProvider(
IOptions<ContactsOptions> options, HttpClient httpClient, ILogger<PublicDirectoryLookupProvider> logger) IOptions<ContactsOptions> options, HttpClient httpClient, ILogger<PublicDirectoryLookupProvider> logger)
{ {
_options = options.Value; _options = options.Value.PublicDirectory;
_httpClient = httpClient; _httpClient = httpClient;
_logger = logger; _logger = logger;
} }
public ContactSource Source => ContactSource.PublicDirectory; public ContactSource Source => ContactSource.PublicDirectory;
public bool IsEnabled => public bool IsEnabled => _options.Enabled;
_options.PublicDirectory.Enabled && !string.IsNullOrWhiteSpace(_options.PublicDirectory.RequestUrlTemplate);
public async Task<ContactInfo?> LookupAsync(string normalizedNumber, CancellationToken cancellationToken) public async Task<ContactInfo?> LookupAsync(string normalizedNumber, CancellationToken cancellationToken)
{ {
@ -38,18 +50,22 @@ public sealed class PublicDirectoryLookupProvider : IReverseLookupProvider
return null; 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); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(_options.PublicDirectory.TimeoutMilliseconds)); timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(_options.TimeoutMilliseconds));
try try
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (!string.IsNullOrWhiteSpace(_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");
request.Headers.Add("Authorization", $"Bearer {_options.PublicDirectory.ApiKey}");
}
using var response = await _httpClient.SendAsync(request, timeoutCts.Token); using var response = await _httpClient.SendAsync(request, timeoutCts.Token);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@ -57,41 +73,50 @@ public sealed class PublicDirectoryLookupProvider : IReverseLookupProvider
return null; return null;
} }
await using var stream = await response.Content.ReadAsStreamAsync(timeoutCts.Token); var html = await response.Content.ReadAsStringAsync(timeoutCts.Token);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: timeoutCts.Token);
var name = ExtractDisplayName(document.RootElement); if (html.Contains("Leider konnten wir keinen Eintrag", StringComparison.OrdinalIgnoreCase))
if (string.IsNullOrWhiteSpace(name))
{ {
return null; 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( return new ContactInfo(
Id: normalizedNumber, Id: normalizedNumber,
Source: ContactSource.PublicDirectory, Source: ContactSource.PublicDirectory,
DisplayName: name, DisplayName: name,
Company: null, Company: city,
PhoneNumbers: [normalizedNumber], PhoneNumbers: [normalizedNumber],
PhotoUrl: null); PhotoUrl: null);
} }
catch (OperationCanceledException) 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; return null;
} }
catch (Exception ex) 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; 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);
}
}