Wp2ghost/CSharpGhostImporter/VideoLinkCleaner.cs

262 lines
12 KiB
C#
Raw Normal View History

2026-10-14 19:12:29 +02:00
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
namespace wp2ghost
{
// =============================================================================
// VideoLinkCleaner — tote Verweise auf Videos (Links, <video>, Einbettungen) entfernen
// =============================================================================
static class VideoLinkCleaner
{
private static readonly HttpClient httpClient = CreateClient();
// Ergebnis pro URL merken: dieselben Videos kommen in mehreren Beiträgen vor
private static readonly Dictionary<string, bool> DeadCache = new();
private static readonly string[] VideoExtensions = { ".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv", ".3gp", ".wmv" };
private static readonly string[] VideoHosts = { "youtube.com", "youtube-nocookie.com", "youtu.be", "vimeo.com", "videopress.com", "video.wordpress.com", "videos.files.wordpress.com" };
private static readonly Regex VimeoRegex = new(@"vimeo\.com/(?:video/)?(\d+)(?:/([0-9a-f]+))?", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex VimeoHashRegex = new(@"[?&]h=([0-9a-f]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex YouTubeRegex = new(@"(?:youtube(?:-nocookie)?\.com/(?:embed/|shorts/|watch\?(?:[^""'\s]*&)?v=)|youtu\.be/)([\w-]{11})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static HttpClient CreateClient()
{
// HTTP/2 ist nötig: über HTTP/1.1 beantworten die wordpress.com-Videoserver
// jede Anfrage dieses Clients mit 403 — auch für funktionierende Videos.
var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(10),
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");
return client;
}
/// <summary>
/// Entfernt Links, &lt;video&gt;-Elemente und Einbettungen, deren Video nicht mehr existiert.
/// Nicht eindeutig prüfbare Fälle (Timeout, Serverfehler) bleiben unverändert.
/// </summary>
public static async Task<string> RemoveDeadVideosAsync(string html)
{
if (string.IsNullOrWhiteSpace(html)) return html;
var doc = new HtmlDocument();
doc.LoadHtml(html);
int changes = ConvertPrestoPlayers(doc);
if (changes > 0)
Console.WriteLine($" [Video] {changes} Presto-Player-Block/Blöcke in normale Video-Einbettung umgewandelt");
var nodes = doc.DocumentNode.SelectNodes("//a[@href] | //video | //iframe[@src] | //embed[@src]");
if (nodes == null) return changes > 0 ? doc.DocumentNode.OuterHtml : html;
foreach (var node in nodes.ToList())
{
string url = GetUrl(node);
// <video> mit lokaler Quelle (file://, blob:, content://) hat nie funktioniert
if (node.Name == "video" && Uri.TryCreate(url, UriKind.Absolute, out var videoUri)
&& videoUri.Scheme != Uri.UriSchemeHttp && videoUri.Scheme != Uri.UriSchemeHttps)
{
Console.WriteLine($" [Video] Totes Video entfernt (lokale Quelle): {url}");
RemoveNode(node);
changes++;
continue;
}
if (!IsVideoUrl(node, url)) continue;
if (!await IsDeadAsync(url, isVideoFile: node.Name == "video" || HasVideoExtension(url))) continue;
Console.WriteLine($" [Video] Totes Video entfernt ({DescribeNode(node)}): {url}");
RemoveNode(node);
changes++;
}
return changes > 0 ? doc.DocumentNode.OuterHtml : html;
}
/// <summary>
/// Presto-Player-Blöcke (&lt;presto-player src="https://vimeo.com/ID/HASH"&gt;) funktionieren nur mit
/// dem WordPress-Plugin. Sie werden durch eine normale Einbettung bzw. das enthaltene
/// &lt;video&gt; ersetzt, damit das Video in Ghost abspielbar bleibt.
/// </summary>
private static int ConvertPrestoPlayers(HtmlDocument doc)
{
var players = doc.DocumentNode.SelectNodes("//presto-player");
if (players == null) return 0;
int converted = 0;
foreach (var player in players.ToList())
{
string src = HtmlEntity.DeEntitize(player.GetAttributeValue("src", "")).Trim();
var vimeo = VimeoRegex.Match(src);
var youtube = YouTubeRegex.Match(src);
HtmlNode? replacement = null;
if (vimeo.Success)
{
// Der Hash gehört bei nicht gelisteten Videos zwingend dazu
string hash = vimeo.Groups[2].Success ? "?h=" + vimeo.Groups[2].Value : "";
replacement = CreateEmbedIframe(doc, $"https://player.vimeo.com/video/{vimeo.Groups[1].Value}{hash}");
}
else if (youtube.Success)
{
replacement = CreateEmbedIframe(doc, $"https://www.youtube.com/embed/{youtube.Groups[1].Value}");
}
else
{
// Selbst gehostet: das enthaltene <video> übernehmen
replacement = player.SelectSingleNode(".//video");
replacement?.Remove();
}
if (replacement == null || player.ParentNode == null) continue;
player.ParentNode.ReplaceChild(replacement, player);
converted++;
}
return converted;
}
private static HtmlNode CreateEmbedIframe(HtmlDocument doc, string src)
{
var iframe = doc.CreateElement("iframe");
iframe.SetAttributeValue("src", src);
iframe.SetAttributeValue("width", "640");
iframe.SetAttributeValue("height", "360");
iframe.SetAttributeValue("frameborder", "0");
iframe.SetAttributeValue("allow", "autoplay; fullscreen; picture-in-picture");
iframe.SetAttributeValue("allowfullscreen", "");
return iframe;
}
private static string GetUrl(HtmlNode node)
{
string raw = node.Name switch
{
"a" => node.GetAttributeValue("href", ""),
"video" => node.GetAttributeValue("src", "") is { Length: > 0 } src
? src
: node.SelectSingleNode("./source[@src]")?.GetAttributeValue("src", "") ?? "",
_ => node.GetAttributeValue("src", "")
};
return HtmlEntity.DeEntitize(raw).Trim();
}
private static string DescribeNode(HtmlNode node) => node.Name switch
{
"a" => "Link",
"video" => "Video",
_ => "Einbettung"
};
private static bool IsVideoUrl(HtmlNode node, string url)
{
// Nur absolute URLs sind prüfbar
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
return false;
if (node.Name == "video") return true;
string host = uri.Host.ToLowerInvariant();
if (VideoHosts.Any(h => host == h || host.EndsWith("." + h))) return true;
return HasVideoExtension(url);
}
private static bool HasVideoExtension(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) && VideoExtensions.Contains(Path.GetExtension(uri.AbsolutePath).ToLowerInvariant());
private static async Task<bool> IsDeadAsync(string url, bool isVideoFile)
{
if (DeadCache.TryGetValue(url, out bool known)) return known;
bool dead;
var vimeo = VimeoRegex.Match(url);
var youtube = YouTubeRegex.Match(url);
if (vimeo.Success)
{
// Die Player-Seite antwortet Skripten mit 401/403 — maßgeblich ist die oEmbed-Auskunft.
// Nicht gelistete Videos brauchen dafür ihren Hash (?h=...).
string hash = vimeo.Groups[2].Success ? vimeo.Groups[2].Value : VimeoHashRegex.Match(url).Groups[1].Value;
string target = $"https://vimeo.com/{vimeo.Groups[1].Value}" + (hash.Length > 0 ? "/" + hash : "");
var status = await GetStatusAsync("https://vimeo.com/api/oembed.json?url=" + Uri.EscapeDataString(target));
dead = false;
if (status == HttpStatusCode.NotFound)
{
// oEmbed meldet 404 auch für private Videos; die Player-Seite nur für gelöschte
var playerStatus = await GetStatusAsync($"https://player.vimeo.com/video/{vimeo.Groups[1].Value}");
dead = playerStatus == HttpStatusCode.NotFound;
if (!dead)
Console.WriteLine($" [Warnung] Vimeo-Video ist privat und für Leser nicht abspielbar (bleibt erhalten): {url}");
}
}
else if (youtube.Success)
{
// Die Watch-/Embed-Seite liefert auch für gelöschte Videos 200 — oEmbed meldet 404
string target = "https://www.youtube.com/watch?v=" + youtube.Groups[1].Value;
var status = await GetStatusAsync("https://www.youtube.com/oembed?format=json&url=" + Uri.EscapeDataString(target));
dead = status == HttpStatusCode.NotFound || status == HttpStatusCode.BadRequest;
}
else
{
var status = await GetStatusAsync(url);
// 403 gilt nur bei einer Videodatei als tot — Seiten/Einbettungen sperren damit oft bloß Skripte aus
dead = status == HttpStatusCode.NotFound || status == HttpStatusCode.Gone || status == HostNotFound
|| (isVideoFile && status == HttpStatusCode.Forbidden);
}
DeadCache[url] = dead;
return dead;
}
// Kein echter HTTP-Status: der Hostname existiert nicht mehr
private const HttpStatusCode HostNotFound = (HttpStatusCode)(-1);
/// <summary>HTTP-Status der URL; null, wenn er nicht ermittelt werden konnte (Timeout o.ä.).</summary>
private static async Task<HttpStatusCode?> GetStatusAsync(string url)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 0); // Video nicht herunterladen
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
return response.StatusCode;
}
catch (HttpRequestException ex) when (ex.InnerException is SocketException { SocketErrorCode: SocketError.HostNotFound })
{
return HostNotFound;
}
catch
{
return null;
}
}
private static void RemoveNode(HtmlNode node)
{
if (node.Name == "a")
{
// Linktext bleibt stehen — außer er ist selbst nur die URL
string text = HtmlEntity.DeEntitize(node.InnerText).Trim();
bool keepContent = text.Length > 0 && !text.StartsWith("http", StringComparison.OrdinalIgnoreCase) && node.ParentNode != null;
if (keepContent)
{
foreach (var child in node.ChildNodes.ToList())
node.ParentNode!.InsertBefore(child, node);
}
node.Remove();
return;
}
// Umschließende Figure (wp-block-video / wp-block-embed) samt Unterschrift mit entfernen,
// wenn sie nichts anderes enthält
var figure = node.Ancestors("figure").FirstOrDefault();
bool figureOnlyHoldsThis = figure != null
&& figure.SelectSingleNode(".//img") == null
&& (figure.SelectNodes(".//video | .//iframe | .//embed")?.Count ?? 0) == 1;
(figureOnlyHoldsThis ? figure! : node).Remove();
}
}
}