Wp2ghost/Wp2GhostUI/LinkSearchService.cs

134 lines
6.3 KiB
C#
Raw Normal View History

2026-10-14 19:12:29 +02:00
using System.Net;
using System.Text.Json;
using HtmlAgilityPack;
namespace Wp2GhostUI;
/// <summary>Ein Vorschlag für den Ersatz eines toten Links.</summary>
public record LinkSuggestion(string Title, string Url, string Snippet, string Source);
/// <summary>
/// Sucht Ersatz für einen toten Link: eine archivierte Fassung der alten Adresse (Internet Archive)
/// und Treffer einer Websuche nach dem Linktext (DuckDuckGo). Beides läuft auf dem Server, weil sich
/// Suchmaschinen nicht in eine Seite einbetten lassen.
/// </summary>
public class LinkSearchService
{
private readonly HttpClient _http;
public LinkSearchService()
{
_http = new HttpClient(new SocketsHttpHandler { AutomaticDecompression = DecompressionMethods.All }) { Timeout = TimeSpan.FromSeconds(15) };
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36");
_http.DefaultRequestHeaders.AcceptLanguage.ParseAdd("de,en;q=0.8");
}
/// <summary>Archivierte Fassung der toten Adresse, falls das Internet Archive eine hat.</summary>
public async Task<LinkSuggestion?> FindArchivedAsync(string deadUrl)
{
try
{
string json = await _http.GetStringAsync("https://archive.org/wayback/available?url=" + Uri.EscapeDataString(deadUrl));
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("archived_snapshots", out var snapshots) || !snapshots.TryGetProperty("closest", out var closest))
return null;
if (closest.TryGetProperty("available", out var available) && !available.GetBoolean()) return null;
string url = (closest.GetProperty("url").GetString() ?? "").Replace("http://web.archive.org", "https://web.archive.org");
string stamp = closest.TryGetProperty("timestamp", out var ts) ? ts.GetString() ?? "" : "";
string date = stamp.Length >= 8 ? $"{stamp.Substring(6, 2)}.{stamp.Substring(4, 2)}.{stamp.Substring(0, 4)}" : "";
return url.Length == 0 ? null
: new LinkSuggestion("Archivierte Fassung der alten Seite", url, date.Length > 0 ? $"Stand vom {date} im Internet Archive" : "Internet Archive", "Archiv");
}
catch
{
return null;
}
}
// Treffer je Suchbegriff merken: derselbe Linktext kommt oft in mehreren Beiträgen vor,
// und jede gesparte Abfrage schont das knappe Kontingent der Suchmaschine
private readonly Dictionary<string, List<LinkSuggestion>> _cache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Websuche über DuckDuckGo. Die Suchmaschine sperrt automatische Abfragen nach wenigen Anfragen
/// für einige Zeit; dann gibt es eine Ausnahme mit verständlichem Text, und der User sucht über
/// "Bei Google suchen" im Browser weiter. (Bing als Ausweichquelle wurde verworfen: es liefert
/// automatischen Abfragen unpassende Treffer.)
/// </summary>
public async Task<List<LinkSuggestion>> SearchAsync(string query, int max = 8)
{
query = query.Trim();
if (query.Length == 0) return new();
if (_cache.TryGetValue(query, out var cached)) return cached;
var results = await SearchDuckDuckGoAsync(query, max)
?? throw new InvalidOperationException("die Suchmaschine lässt im Moment keine weiteren automatischen Abfragen zu (das legt sich nach einigen Minuten)");
_cache[query] = results;
return results;
}
// Liefert null, wenn die Suchmaschine die Abfrage abgewiesen hat (im Unterschied zu "keine Treffer")
private async Task<List<LinkSuggestion>?> SearchDuckDuckGoAsync(string query, int max)
{
string html;
try
{
using var response = await _http.GetAsync("https://html.duckduckgo.com/html/?kl=de-de&q=" + Uri.EscapeDataString(query));
// Bei einer Sperre antwortet DuckDuckGo mit 202 und einer Prüfseite statt Treffern
if (response.StatusCode != HttpStatusCode.OK) return null;
html = await response.Content.ReadAsStringAsync();
}
catch
{
return null;
}
var doc = new HtmlDocument();
doc.LoadHtml(html);
var results = new List<LinkSuggestion>();
var nodes = doc.DocumentNode.SelectNodes("//div[contains(@class,'result') and contains(@class,'results_links')]");
if (nodes == null) return html.Contains("anomaly", StringComparison.OrdinalIgnoreCase) ? null : results;
foreach (var node in nodes)
{
var link = node.SelectSingleNode(".//a[contains(@class,'result__a')]");
if (link == null) continue;
string? url = ResolveTarget(link.GetAttributeValue("href", ""));
if (url == null) continue;
string title = HtmlEntity.DeEntitize(link.InnerText).Trim();
string snippet = HtmlEntity.DeEntitize(node.SelectSingleNode(".//*[contains(@class,'result__snippet')]")?.InnerText ?? "").Trim();
if (results.All(r => r.Url != url))
results.Add(new LinkSuggestion(title, url, snippet, "Suche"));
if (results.Count >= max) break;
}
return results;
}
// Trefferlinks laufen über eine Weiterleitung: //duckduckgo.com/l/?uddg=<Zieladresse>&...
private static string? ResolveTarget(string href)
{
href = HtmlEntity.DeEntitize(href);
if (href.StartsWith("//")) href = "https:" + href;
if (!Uri.TryCreate(href, UriKind.Absolute, out var uri)) return null;
if (uri.Host.EndsWith("duckduckgo.com"))
{
// Werbetreffer (y.js) haben kein Ziel in "uddg"
foreach (string part in uri.Query.TrimStart('?').Split('&'))
{
if (part.StartsWith("uddg="))
{
string target = Uri.UnescapeDataString(part.Substring(5));
// Anzeigen zeigen wieder auf die Suchmaschine selbst
bool isAd = Uri.TryCreate(target, UriKind.Absolute, out var targetUri) && targetUri.Host.EndsWith("duckduckgo.com");
return target.StartsWith("http") && !isAd ? target : null;
}
}
return null;
}
return uri.Scheme is "http" or "https" ? href : null;
}
}