using System.Net; using System.Net.Sockets; namespace wp2ghost { // ============================================================================= // LinkChecker — prüft, ob ein Link tot ist. // // Gemeldet wird nur, was eindeutig ist: Seite nicht gefunden (404/410), Domain existiert // nicht, Server nimmt keine Verbindung an, oder die Adresse ist formal ungültig. // Alles Unklare gilt als erreichbar — viele Server weisen Skripte ab (403, 405, 429), // antworten langsam oder haben kurz einen Fehler (5xx), obwohl der Link im Browser funktioniert. // ============================================================================= /// /// Der tote Link, zu dem der Import gerade eine Entscheidung erfragt. Die Web-Oberfläche liest /// das, um passend dazu eine Suche nach Ersatz anzubieten. /// public record LinkQuestion(string Text, string Href, string Reason) { public static volatile LinkQuestion? Current; } static class LinkChecker { private static readonly HttpClient httpClient = CreateClient(); // Derselbe Link kommt oft in vielen Beiträgen vor → pro Programmlauf nur einmal prüfen private static readonly System.Collections.Concurrent.ConcurrentDictionary Cache = new(); private static HttpClient CreateClient() { // Wie ein Browser auftreten: ohne User-Agent bzw. über HTTP/1.1 antworten manche // Server (u. a. wordpress.com) mit 403, obwohl die Seite existiert var client = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = true, MaxAutomaticRedirections = 10, AutomaticDecompression = DecompressionMethods.All, ConnectTimeout = TimeSpan.FromSeconds(10) }) { Timeout = TimeSpan.FromSeconds(15), DefaultRequestVersion = HttpVersion.Version20, DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower }; client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"); client.DefaultRequestHeaders.Accept.ParseAdd("text/html,application/xhtml+xml,*/*;q=0.8"); client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("de,en;q=0.8"); return client; } /// /// Liefert null, wenn der Link in Ordnung (oder nicht eindeutig prüfbar) ist, /// sonst den Grund, warum er als tot gilt. /// public static async Task CheckAsync(string url) { if (string.IsNullOrWhiteSpace(url)) return "leere Adresse"; url = url.Trim(); // Sprungmarken und relative Adressen sind interne Links if (url.StartsWith("#") || url.StartsWith("/") || !url.Contains(':')) return null; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return "Adresse ist formal ungültig"; // E-Mail-, Telefon- und ähnliche Links lassen sich nicht abrufen und sind gültig if (uri.Scheme is "mailto" or "tel" or "sms" or "geo" or "whatsapp") return null; if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return $"Adresse mit '{uri.Scheme}:' funktioniert im Web nicht"; string key = uri.GetLeftPart(UriPartial.Query); if (Cache.TryGetValue(key, out string? known)) return known; // Erst HEAD (schnell); viele Server beantworten HEAD falsch oder gar nicht → dann GET string? reason = await ProbeAsync(uri, HttpMethod.Head); if (reason != null) reason = await ProbeAsync(uri, HttpMethod.Get); Cache[key] = reason; return reason; } // \"https://…\" oder http://https://… — eine intakte Adresse mit Müll davor/dahinter private static readonly System.Text.RegularExpressions.Regex WrappedUrlRegex = new( @"^(?:https?://)?[\\""'\s]+(https?://[^\s\\""']+)[\\""'\s]*$", System.Text.RegularExpressions.RegexOptions.Compiled); /// /// Repariert Adressen, die eindeutig nur falsch eingefasst sind. Liefert die korrigierte /// Adresse oder null, wenn nichts zu reparieren ist oder das Ergebnis nicht eindeutig wäre. /// public static string? TryRepair(string url) { var match = WrappedUrlRegex.Match(url.Trim()); if (!match.Success) return null; string inner = match.Groups[1].Value; return Uri.TryCreate(inner, UriKind.Absolute, out _) ? inner : null; } private static async Task ProbeAsync(Uri uri, HttpMethod method) { try { using var request = new HttpRequestMessage(method, uri); using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); return response.StatusCode switch { HttpStatusCode.NotFound => "Seite nicht gefunden (404)", HttpStatusCode.Gone => "Seite wurde entfernt (410)", // HEAD wird oft pauschal abgelehnt → mit GET nachprüfen; bei GET gilt das als erreichbar _ when method == HttpMethod.Head && (int)response.StatusCode >= 400 => $"HTTP {(int)response.StatusCode}", _ => null }; } catch (HttpRequestException ex) when (ex.InnerException is SocketException socket) { return socket.SocketErrorCode switch { SocketError.HostNotFound or SocketError.NoData => "Domain existiert nicht", SocketError.ConnectionRefused => "Server nimmt keine Verbindung an", // Zeitüberschreitung, Verbindungsabbruch usw.: nicht eindeutig _ => method == HttpMethod.Head ? "keine Antwort" : null }; } catch { // Zeitüberschreitung, TLS-Problem, Protokollfehler: nicht eindeutig → mit GET nachprüfen, dann gelten lassen return method == HttpMethod.Head ? "keine Antwort" : null; } } } }