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;
}
}
}
}