130 lines
6.3 KiB
C#
130 lines
6.3 KiB
C#
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.
|
|
// =============================================================================
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string, string?> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Liefert null, wenn der Link in Ordnung (oder nicht eindeutig prüfbar) ist,
|
|
/// sonst den Grund, warum er als tot gilt.
|
|
/// </summary>
|
|
public static async Task<string?> 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);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string?> 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;
|
|
}
|
|
}
|
|
}
|
|
}
|