using System.Net; using System.Text.Json; using HtmlAgilityPack; namespace Wp2GhostUI; /// Ein Vorschlag für den Ersatz eines toten Links. public record LinkSuggestion(string Title, string Url, string Snippet, string Source); /// /// 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. /// 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"); } /// Archivierte Fassung der toten Adresse, falls das Internet Archive eine hat. public async Task 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> _cache = new(StringComparer.OrdinalIgnoreCase); /// /// 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.) /// public async Task> 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?> 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(); 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=&... 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; } }