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."
|
"_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": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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=<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>
|
/// </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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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