134 lines
6.3 KiB
C#
134 lines
6.3 KiB
C#
|
|
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;
|
||
|
|
}
|
||
|
|
}
|