Oeffentliche Rueckwaerts-Quelle: Reverse-Lookup gegen dasoertliche.de (inoffiziell, ToS-Risiko dokumentiert)
This commit is contained in:
parent
5f7edf13f5
commit
c4b0148121
5 changed files with 118 additions and 47 deletions
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=<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).
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue