From b598b0d5afdfd54376af25d499993c5cb13f265a Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 22 Sep 2026 08:08:51 +0200 Subject: [PATCH] Improve plugin and Fetcher --- .gitignore | 1 + CSharpGhostImporter/Converter.cs | 253 ++++++++++++++++++++- CSharpGhostImporter/GhostDto.cs | 1 + CSharpGhostImporter/HtmlConverter.cs | 71 +++++- CSharpGhostImporter/ImageManager.cs | 29 ++- CSharpGhostImporter/LinkDecisionManager.cs | 138 +++++++++++ CSharpGhostImporter/PostFetcher.cs | 17 +- CSharpGhostImporter/WPDto.cs | 1 + WPPlugin/rbrExportRestApi.php | 36 ++- 9 files changed, 522 insertions(+), 25 deletions(-) create mode 100644 CSharpGhostImporter/LinkDecisionManager.cs diff --git a/.gitignore b/.gitignore index b5b1290..5613df6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /CSharpGhostImporter/.vs/* /CSharpGhostImporter/bin/* /CSharpGhostImporter/obj/* +/0 diff --git a/CSharpGhostImporter/Converter.cs b/CSharpGhostImporter/Converter.cs index fd07da8..5ccafc9 100644 --- a/CSharpGhostImporter/Converter.cs +++ b/CSharpGhostImporter/Converter.cs @@ -1,5 +1,8 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Net; +using System.Net.Http; +using HtmlAgilityPack; using wp2ghost; class Program @@ -22,6 +25,7 @@ class Program ImageManager.Init(BaseDir); PostFetcher.Init(BaseDir); GhostExporter.Init(BaseDir); + LinkDecisionManager.Init(BaseDir); GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath); Console.OutputEncoding = System.Text.Encoding.UTF8; @@ -57,8 +61,12 @@ class Program case "7": await GhostMenuAsync(); break; + case "8": + LinkDecisionManager.ShowInfo(); + break; case "0": ImageManager.SaveCache(); + LinkDecisionManager.Save(); running = false; Console.WriteLine("Programm beendet. Auf Wiedersehen!"); break; @@ -89,6 +97,7 @@ class Program Console.WriteLine(" 5 - Abgefragte Objekte zurücksetzen"); Console.WriteLine(" 6 - Bilder lokal löschen"); Console.WriteLine(" 7 - Beiträge direkt an Ghost senden"); + Console.WriteLine(" 8 - Gespeicherte Link-Entscheidungen anzeigen"); Console.WriteLine(" 0 - Beenden"); Console.WriteLine("-----------------------------------------"); Console.Write("Auswahl: "); @@ -352,6 +361,9 @@ class Program Console.Write("HTML-Inhalt: "); string? html = Console.ReadLine()?.Trim(); + // Links validieren und ggf. korrigieren + html = await ValidateAndFixLinksAsync(html); + Console.Write("Status (published/draft) [published]: "); string? status = Console.ReadLine()?.Trim(); if (string.IsNullOrEmpty(status)) status = "published"; @@ -459,6 +471,233 @@ class Program Console.WriteLine($" Hochgeladen: {uploaded}/{cachedFiles.Count}"); } + // ========================================================================= + // HTML-Links validieren und bei Bedarf korrigieren + // ========================================================================= + private static async Task ValidateAndFixLinksAsync(string? html) + { + if (string.IsNullOrWhiteSpace(html)) return html ?? ""; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var invalidLinks = new List<(string text, string href, HtmlNode node)>(); + + // Alle -Tags suchen + var anchors = doc.DocumentNode.SelectNodes("//a[@href]"); + if (anchors == null) return html; + + foreach (var anchor in anchors) + { + string href = anchor.GetAttributeValue("href", "").Trim(); + string text = anchor.InnerText.Trim(); + + if (string.IsNullOrEmpty(href)) continue; + + // Prüfe, ob URL valide ist (inkl. Reichbarkeitsprüfung) + if (!await IsValidUrlAsync(href, checkReachability: true)) + { + invalidLinks.Add((text, href, anchor)); + } + } + + if (invalidLinks.Count == 0) return html; + + // User über invaliden Links informieren und zur Korrektur auffordern + Console.WriteLine($"\n {invalidLinks.Count} ungültiger Link(s) gefunden:"); + for (int i = 0; i < invalidLinks.Count; i++) + { + Console.WriteLine($" [{i + 1}] Text: \"{invalidLinks[i].text}\" | Link: {invalidLinks[i].href}"); + } + + Console.WriteLine("\nOptionen pro Link:"); + Console.WriteLine(" d = Link löschen (Text bleibt, -Tag entfernt)"); + Console.WriteLine(" e = Link bearbeiten (neue URL eingeben)"); + Console.WriteLine(" Enter = Link so übernehmen (beibehalten)"); + + // Für jeden invaliden Link die User-Entscheidung einholen + foreach (var (linkText, linkHref, anchorNode) in invalidLinks) + { + // 1. Memory: Frühere Entscheidung suchen und vorschlagen + var previous = LinkDecisionManager.GetPreviousDecision(linkText, linkHref); + + Console.WriteLine($"\n ---------------------------------------------"); + Console.WriteLine($" Link-Text : \"{linkText}\""); + Console.WriteLine($" Aktueller Link : {linkHref}"); + + if (previous != null) + { + string prevInfo = previous.Decision switch + { + "delete" => "LÖSCHEN", + "edit" => $"ERSETZEN durch '{previous.NewUrl}'", + _ => "BEIBEHALTEN" + }; + Console.WriteLine($" [Memory] Frühere Entscheidung: {prevInfo} (vom {previous.Timestamp:dd.MM.yyyy HH:mm})"); + } + + // 2. Browser-Öffnen anbieten (da Link nicht erreichbar ist) + string searchQuery = Uri.EscapeDataString(linkText); + string searchUrl = $"https://www.google.com/search?q={searchQuery}"; + Console.WriteLine($" [Suche] Fallstrick: Link nicht erreichbar. Browser-Suche verfügbar."); + + Console.Write($" Optionen: [Enter=übernehmen" + (previous != null ? $" / v=Vorschlag '{previous.Decision}' anwenden" : "") + " / d=löschen / e=bearbeiten / b=Browser öffnen]: "); + string? choice = Console.ReadLine()?.Trim()?.ToLowerInvariant(); + + // Browser öffnen + if (choice == "b") + { + Console.WriteLine($" -> Öffne Browser mit Suche nach: \"{linkText}\""); + OpenBrowser(searchUrl); + // Nach dem Browser-Öffnen erneut nach der Entscheidung fragen + Console.Write($" Jetzt entscheiden? [Enter=übernehmen / d=löschen / e=bearbeiten]: "); + choice = Console.ReadLine()?.Trim()?.ToLowerInvariant(); + } + + // Memory-Vorschlag anwenden + if (choice == "v" && previous != null) + { + ApplyDecision(anchorNode, previous, linkText, linkHref, ref html, doc); + Console.WriteLine($" -> Frühere Entscheidung angewendet: {previous.Decision}"); + continue; + } + + if (choice == "d") + { + // Link löschen: -Tag entfernen, Text erhalten + var parent = anchorNode.ParentNode; + if (parent != null) + { + parent.ReplaceChild(HtmlNode.CreateNode(anchorNode.InnerText), anchorNode); + } + Console.WriteLine($" -> Link gelöscht"); + LinkDecisionManager.SaveDecision(linkText, linkHref, "delete"); + } + else if (choice == "e") + { + // Link bearbeiten: neue URL eingeben + Console.Write(" Neue URL eingeben: "); + string? newUrl = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(newUrl)) + { + anchorNode.SetAttributeValue("href", newUrl); + Console.WriteLine($" -> Auf '{newUrl}' aktualisiert"); + LinkDecisionManager.SaveDecision(linkText, linkHref, "edit", newUrl); + } + else + { + Console.WriteLine($" -> Beibehalten"); + LinkDecisionManager.SaveDecision(linkText, linkHref, "keep"); + } + } + else + { + Console.WriteLine($" -> Beibehalten"); + LinkDecisionManager.SaveDecision(linkText, linkHref, "keep"); + } + } + + return doc.DocumentNode.OuterHtml; + } + + // ========================================================================= + // Hilfsmethoden für Link-Entscheidungen + // ========================================================================= + + /// + /// Wendet eine gespeicherte (frühere) Entscheidung auf den Link an. + /// + private static void ApplyDecision(HtmlNode anchorNode, LinkDecision decision, string linkText, string linkHref, ref string? html, HtmlDocument doc) + { + if (decision.Decision == "delete") + { + var parent = anchorNode.ParentNode; + if (parent != null) + { + parent.ReplaceChild(HtmlNode.CreateNode(anchorNode.InnerText), anchorNode); + } + } + else if (decision.Decision == "edit" && !string.IsNullOrEmpty(decision.NewUrl)) + { + anchorNode.SetAttributeValue("href", decision.NewUrl); + } + // "keep" = nichts tun + } + + /// + /// Öffnet die Standard-Browser-Anwendung mit der angegebenen URL. + /// + private static void OpenBrowser(string url) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }; + System.Diagnostics.Process.Start(psi); + } + catch (Exception ex) + { + Console.WriteLine($" [Browser-Fehler] Konnte Browser nicht öffnen: {ex.Message}"); + } + } + + private static async Task IsValidUrlAsync(string url, bool checkReachability = false) + { + if (string.IsNullOrWhiteSpace(url)) return false; + + // Relative URLs (beginnen mit / oder sind Pfade) sind für interne Links gültig + if (url.StartsWith("/") || !url.Contains(":")) + { + if (checkReachability) { /* relative links are considered valid regardless */ } + return true; + } + + // Prüfe auf gültiges URI-Format mit http/https Schemes + try + { + var uri = new Uri(url, UriKind.Absolute); + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + return false; + + // Optionale Reichbarkeitsprüfung + if (checkReachability) + { + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; + try + { + var request = new HttpRequestMessage(HttpMethod.Head, uri); + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + return (int)response.StatusCode < 400; // 2xx oder 3xx = erreichbar + } + catch (HttpRequestException) + { + // Verbindungsfehler, DNS-Failure, Timeout etc. + return false; + } + catch (TaskCanceledException) + { + // Timeout + return false; + } + } + + return true; + } + catch + { + return false; + } + } + + // Synchronen Wrapper für die bestehende Validierung + private static bool IsValidUrl(string url) + { + return IsValidUrlAsync(url, false).Result; + } + static async Task GhostCreateOrUpdateFromJsonAsync() { string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts"); @@ -509,12 +748,14 @@ class Program Console.WriteLine($" Post: {postFile.Title}"); Console.WriteLine($" Slug: {postFile.Slug}"); Console.WriteLine($" Kategorien: {postFile.Categories.Count}"); + Console.WriteLine($" Tags: {postFile.Tags.Count}"); Console.WriteLine($" Kommentare: {postFile.Comments.Count}"); Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}"); // 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen var ghostUrlMap = new Dictionary(); string html = postFile.Html ?? ""; + html = await ValidateAndFixLinksAsync(html); if (postFile.ImageFiles.Count > 0) { @@ -572,12 +813,18 @@ class Program Console.WriteLine($" [Warnung] {remaining} unrestrierte __GHOST_URL__ Platzhalter im HTML"); } - // 4. Tags erstellen/zuordnen + // 4. Tags erstellen/zuordnen (Kategorien + Tags) var tags = new List(); + var allTagSources = new List(); if (postFile.Categories.Count > 0) + allTagSources.AddRange(postFile.Categories); + if (postFile.Tags.Count > 0) + allTagSources.AddRange(postFile.Tags); + + if (allTagSources.Count > 0) { - Console.WriteLine(" [Tags] Verarbeite Kategorien..."); - tags = await GhostApi.EnsureTagsAsync(postFile.Categories); + Console.WriteLine($" [Tags] Verarbeite {postFile.Categories.Count} Kategorien + {postFile.Tags.Count} Tags..."); + tags = await GhostApi.EnsureTagsAsync(allTagSources); } // 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert diff --git a/CSharpGhostImporter/GhostDto.cs b/CSharpGhostImporter/GhostDto.cs index 283717b..1e6aac8 100644 --- a/CSharpGhostImporter/GhostDto.cs +++ b/CSharpGhostImporter/GhostDto.cs @@ -31,6 +31,7 @@ namespace wp2ghost [JsonPropertyName("published_at")] public string? PublishedAt { get; set; } [JsonPropertyName("feature_image")] public string? FeatureImage { get; set; } [JsonPropertyName("categories")] public List Categories { get; set; } = new(); + [JsonPropertyName("tags")] public List Tags { get; set; } = new(); [JsonPropertyName("keywords")] public string? Keywords { get; set; } [JsonPropertyName("comments")] public List Comments { get; set; } = new(); [JsonPropertyName("image_files")] public List ImageFiles { get; set; } = new(); diff --git a/CSharpGhostImporter/HtmlConverter.cs b/CSharpGhostImporter/HtmlConverter.cs index 31c4b2a..5b2268d 100644 --- a/CSharpGhostImporter/HtmlConverter.cs +++ b/CSharpGhostImporter/HtmlConverter.cs @@ -99,6 +99,7 @@ namespace wp2ghost ConvertSingleImages(htmlDoc); ConvertFigures(htmlDoc); ConvertWpClasses(htmlDoc); + AddTargetBlankToExternalLinks(htmlDoc); return htmlDoc.DocumentNode.OuterHtml; } @@ -134,11 +135,27 @@ namespace wp2ghost container.AddClass("kg-gallery-container"); var wrappers = new List(); + var imageCaptions = new List(); + foreach (var item in items) { var img = item.SelectSingleNode(".//img"); if (img == null) continue; + // Bild-Titel aus data-image-caption Attribut extrahieren + string dataCaption = img.GetAttributeValue("data-image-caption", "").Trim(); + if (!string.IsNullOrEmpty(dataCaption)) + imageCaptions.Add(dataCaption); + + // Auch figcaption innerhalb des gallery-items prüfen + var figCap = item.SelectSingleNode(".//figcaption"); + if (figCap != null && !string.IsNullOrWhiteSpace(figCap.InnerText)) + { + string capText = figCap.InnerText.Trim(); + if (!string.IsNullOrEmpty(capText) && !imageCaptions.Contains(capText)) + imageCaptions.Add(capText); + } + string src = GetBestImageUrl(img); if (string.IsNullOrEmpty(src)) continue; @@ -150,11 +167,14 @@ namespace wp2ghost AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); ghostGallery.AppendChild(container); - var captionNode = gallery.SelectSingleNode(".//*[contains(@class, 'gallery-caption')] | .//figcaption"); - if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText)) + // Gallery-Bild-Titel übertragen, wenn vorhanden + if (imageCaptions.Count > 0) { + // Ersten nicht-leeren Titel nehmen als Gallery-Kapitel + string firstCaption = imageCaptions[0]; + var newCaption = htmlDoc.CreateElement("figcaption"); - newCaption.InnerHtml = captionNode.InnerHtml; + newCaption.InnerHtml = firstCaption; ghostGallery.AppendChild(newCaption); ghostGallery.AddClass("kg-card-hascaption"); } @@ -207,14 +227,23 @@ namespace wp2ghost AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); ghostGallery.AppendChild(container); - // Caption aus erstem figcaption im Gallery-Container extrahieren - var captionNode = gallery.SelectSingleNode(".//figcaption"); - if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText)) + // Alle individuellen Bildunterschriften aus der Gutenberg-Gallery sammeln + var captionNodes = gallery.SelectNodes(".//figcaption"); + if (captionNodes != null && captionNodes.Count > 0) { - var newCaption = htmlDoc.CreateElement("figcaption"); - newCaption.InnerHtml = captionNode.InnerHtml; - ghostGallery.AppendChild(newCaption); - ghostGallery.AddClass("kg-card-hascaption"); + var distinctCaptions = captionNodes + .Select(n => n.InnerText.Trim()) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Distinct() + .ToList(); + + if (distinctCaptions.Count > 0) + { + var newCaption = htmlDoc.CreateElement("figcaption"); + newCaption.InnerHtml = string.Join(" · ", distinctCaptions); + ghostGallery.AppendChild(newCaption); + ghostGallery.AddClass("kg-card-hascaption"); + } } // data-carousel-extra als Ghost-Attribut übertragen (falls vorhanden) @@ -567,6 +596,28 @@ namespace wp2ghost } } + // ========================================================================= + // Externe Links mit target="_blank" versehen + // ========================================================================= + private static void AddTargetBlankToExternalLinks(HtmlDocument htmlDoc) + { + var anchors = htmlDoc.DocumentNode.SelectNodes("//a[@href]"); + if (anchors == null) return; + + foreach (var anchor in anchors.ToList()) + { + string href = anchor.GetAttributeValue("href", "").Trim(); + if (string.IsNullOrEmpty(href)) continue; + + // Nur absolute externe URLs (http/https) + if (href.StartsWith("http://") || href.StartsWith("https://")) + { + anchor.SetAttributeValue("target", "_blank"); + anchor.SetAttributeValue("rel", "noopener noreferrer"); + } + } + } + private static bool HasClass(HtmlNode node, string className) { string classes = node.GetAttributeValue("class", ""); diff --git a/CSharpGhostImporter/ImageManager.cs b/CSharpGhostImporter/ImageManager.cs index 6dcabf3..861e608 100644 --- a/CSharpGhostImporter/ImageManager.cs +++ b/CSharpGhostImporter/ImageManager.cs @@ -20,11 +20,28 @@ namespace wp2ghost public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip; public static bool TryGetRelativePath(string url, out string relativePath) { + // 1. Direkte URL-Prüfung im Cache if (Cache.TryGetValue(url, out var cached)) { relativePath = cached.RelativePath; return true; } + + // 2. Prüfung über WordPress-Dateinamen (fürFeatured Images mit anderer URL) + string wpName = Path.GetFileNameWithoutExtension(new Uri(url).AbsolutePath); + if (WordpressToGhostImageName.TryGetValue(wpName, out string? relPath) && !string.IsNullOrEmpty(relPath)) + { + string localPath = Path.Combine(LocalImageDir, relPath); + if (File.Exists(localPath)) + { + relativePath = relPath; + // Auch im Cache eintragen für zukünftige Suche + Cache[url] = (relPath, localPath); + return true; + } + WordpressToGhostImageName.Remove(wpName); + } + relativePath = ""; return false; } @@ -46,7 +63,10 @@ namespace wp2ghost { var lines = new List(); foreach (var kvp in Cache) - lines.Add($"{kvp.Key}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}"); + { + string wpName = Path.GetFileNameWithoutExtension(new Uri(kvp.Key).AbsolutePath); + lines.Add($"{kvp.Key}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}|{wpName}"); + } File.WriteAllLines(CachePath, lines); } @@ -223,8 +243,13 @@ namespace wp2ghost foreach (var line in lines) { var parts = line.Split('|'); - if (parts.Length == 3) + if (parts.Length >= 3) + { Cache[parts[0]] = (parts[1], parts[2]); + // WordPress-Dateinamen auch ins Dictionary laden + if (parts.Length >= 4 && !string.IsNullOrEmpty(parts[3])) + WordpressToGhostImageName[parts[3]] = parts[1]; + } } } catch (Exception ex) diff --git a/CSharpGhostImporter/LinkDecisionManager.cs b/CSharpGhostImporter/LinkDecisionManager.cs new file mode 100644 index 0000000..5163bd5 --- /dev/null +++ b/CSharpGhostImporter/LinkDecisionManager.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace wp2ghost +{ + // ============================================================================= + // LinkDecision — gespeicherte Entscheidung für einen Link + // ============================================================================= + public class LinkDecision + { + [JsonPropertyName("link_text")] + public string LinkText { get; set; } = ""; + + [JsonPropertyName("link_href")] + public string LinkHref { get; set; } = ""; + + // Entscheidung: "keep" (beibehalten), "delete" (löschen), "edit" (ersetzt) + [JsonPropertyName("decision")] + public string Decision { get; set; } = ""; + + // Nur bei "edit": die neue URL + [JsonPropertyName("new_url")] + public string? NewUrl { get; set; } + + [JsonPropertyName("timestamp")] + public DateTime Timestamp { get; set; } = DateTime.Now; + } + + // ============================================================================= + // LinkDecisionManager — merkt sich User-Entscheidungen für Links (Memory) + // ============================================================================= + public static class LinkDecisionManager + { + private static Dictionary _decisions = new(StringComparer.OrdinalIgnoreCase); + private static string _decisionsPath = ""; + + // Key = Normalisierter Link (Text|Href) + private static string NormalizeKey(string linkText, string linkHref) + { + return $"{linkText.Trim().ToLowerInvariant()}|{linkHref.Trim().ToLowerInvariant()}"; + } + + public static void Init(string baseDir) + { + _decisionsPath = Path.Combine(baseDir, "link_decisions.json"); + Load(); + } + + public static void Save() + { + if (string.IsNullOrEmpty(_decisionsPath)) return; + try + { + var options = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(_decisionsPath, JsonSerializer.Serialize(_decisions.Values.ToList(), options)); + } + catch (Exception ex) + { + Console.WriteLine($" [Link-Memory] Fehler beim Speichern der Entscheidungen: {ex.Message}"); + } + } + + private static void Load() + { + if (!File.Exists(_decisionsPath)) return; + try + { + var json = File.ReadAllText(_decisionsPath); + var list = JsonSerializer.Deserialize>(json); + if (list != null) + { + foreach (var d in list) + { + string key = NormalizeKey(d.LinkText, d.LinkHref); + _decisions[key] = d; + } + } + } + catch (Exception ex) + { + Console.WriteLine($" [Link-Memory] Fehler beim Laden der Entscheidungen: {ex.Message}"); + } + } + + /// + /// Speichert eine User-Entscheidung für einen Link. + /// + public static void SaveDecision(string linkText, string linkHref, string decision, string? newUrl = null) + { + string key = NormalizeKey(linkText, linkHref); + _decisions[key] = new LinkDecision + { + LinkText = linkText, + LinkHref = linkHref, + Decision = decision, + NewUrl = newUrl + }; + Save(); + } + + /// + /// Versucht eine frühere Entscheidung für diesen Link zu finden. + /// + public static LinkDecision? GetPreviousDecision(string linkText, string linkHref) + { + string key = NormalizeKey(linkText, linkHref); + if (_decisions.TryGetValue(key, out var d)) + return d; + return null; + } + + /// + /// Gibt die Anzahl gespeicherter Entscheidungen zurück (für Info-Anzeige). + /// + public static int Count => _decisions.Count; + + public static void ShowInfo() + { + if (_decisions.Count > 0) + { + Console.WriteLine($"\nGespeicherte Link-Entscheidungen: {_decisions.Count}"); + foreach (var kvp in _decisions.Take(20)) + { + var d = kvp.Value; + string info = d.Decision switch + { + "delete" => "gelöscht", + "edit" => $"ersetzt durch '{d.NewUrl}'", + _ => "beibehalten" + }; + Console.WriteLine($" \"{d.LinkText}\" -> {info}"); + } + if (_decisions.Count > 20) + Console.WriteLine($" ... und {_decisions.Count - 20} weitere"); + } + } + } +} diff --git a/CSharpGhostImporter/PostFetcher.cs b/CSharpGhostImporter/PostFetcher.cs index 5375be8..c24d087 100644 --- a/CSharpGhostImporter/PostFetcher.cs +++ b/CSharpGhostImporter/PostFetcher.cs @@ -78,6 +78,9 @@ namespace wp2ghost return; } + ProcessedPosts.Clear(); + LoadProcessedPosts(); + var pendingIds = allPostIds.Where(id => !ProcessedPosts.Contains(id)).ToList(); Console.WriteLine($"Insgesamt gefunden: {allPostIds.Count}, Bereits abgefragt: {allPostIds.Count - pendingIds.Count}, Noch zu verarbeiten: {pendingIds.Count}"); @@ -105,11 +108,15 @@ namespace wp2ghost Console.WriteLine($"[{++processed}/{pendingIds.Count}] -> Beitrag/Seite ID: {wpId}..."); string detailUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/post-details/{wpId}"; + //string postUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/wp/v2/posts/{wpId}?_embed"; + try { var detailJson = await httpClient.GetStringAsync(detailUrl); var wpPost = JsonSerializer.Deserialize(detailJson); + //var postJson = await httpClient.GetStringAsync(postUrl); + if (wpPost == null) { Console.WriteLine($" Übersprungen (null)."); @@ -217,7 +224,7 @@ namespace wp2ghost GhostPosts.Add(ghostPost); // JSON-Datei in ghostPosts/ speichern - var postFile = new GhostPostFile + var ghostPostFile = new GhostPostFile { WpId = wpId, WpSlug = wpPost.Slug, @@ -230,13 +237,17 @@ namespace wp2ghost PublishedAt = postDate.ToString("o"), FeatureImage = featureImageGhostPath, Categories = wpPost.Categories ?? new(), + Tags = wpPost.Tags ?? new(), Keywords = wpPost.Keywords ?? "", Comments = wpPost.Comments ?? new(), ImageFiles = imageFiles, Html = htmlContent }; - string jsonPath = Path.Combine(GhostPostsDir, $"{postFile.WpId}.json"); - File.WriteAllText(jsonPath, JsonSerializer.Serialize(postFile, new JsonSerializerOptions { WriteIndented = true })); + string jsonPath = Path.Combine(GhostPostsDir, $"{ghostPostFile.WpId}.json"); + File.WriteAllText(jsonPath, JsonSerializer.Serialize(ghostPostFile, new JsonSerializerOptions { WriteIndented = true })); + + // Cache sofort speichern (nicht erst beim Beenden) + ImageManager.SaveCache(); MarkProcessed(wpId, wpPost.Slug); internalId++; diff --git a/CSharpGhostImporter/WPDto.cs b/CSharpGhostImporter/WPDto.cs index c8336d4..9b8f521 100644 --- a/CSharpGhostImporter/WPDto.cs +++ b/CSharpGhostImporter/WPDto.cs @@ -24,6 +24,7 @@ namespace wp2ghost [JsonPropertyName("excerpt")] public string? Excerpt { get; set; } [JsonPropertyName("featured_image")] public string? FeaturedImage { get; set; } [JsonPropertyName("categories")] public List? Categories { get; set; } = new(); + [JsonPropertyName("tags")] public List? Tags { get; set; } = new(); [JsonPropertyName("keywords")] public string? Keywords { get; set; } [JsonPropertyName("comments")] public List? Comments { get; set; } = new(); [JsonPropertyName("media_files")] public List? MediaFiles { get; set; } = new(); diff --git a/WPPlugin/rbrExportRestApi.php b/WPPlugin/rbrExportRestApi.php index d1bf907..970755d 100644 --- a/WPPlugin/rbrExportRestApi.php +++ b/WPPlugin/rbrExportRestApi.php @@ -175,7 +175,7 @@ function rbr_get_post_details_with_media($data) { // Duplikate entfernen $unique_media = array_values(array_unique($media_urls)); - // 4. Kategorien ( Categories Only, keine Tags) + // 4. Kategorien $categories = array(); $cats = get_the_category($post_id); if (!empty($cats) && !is_wp_error($cats)) { @@ -188,23 +188,44 @@ function rbr_get_post_details_with_media($data) { } } - // 5. Keywords (aus Yoast SEO oder Rank Math oder Custom Field) + // 5. Tags (post_tag Taxonomy) + $tags = array(); + $post_tags = get_the_tags($post_id); + if (!empty($post_tags) && !is_wp_error($post_tags)) { + foreach ($post_tags as $tag) { + $tags[] = array( + 'id' => intval($tag->term_id), + 'name' => $tag->name, + 'slug' => $tag->slug, + ); + } + } + + // 6. Keywords (aus Yoast SEO oder Rank Math oder Custom Field) $keywords = ''; - // Yoast SEO - if (function_exists('yoast_get_primary_term')) { - $keywords = get_post_meta($post_id, '_yoast_wpseo_focuskw', true); + // Yoast SEO: Prüfe auf Meta-Key direkt (funktioniert in allen Versionen) + $yoast_kw = get_post_meta($post_id, '_yoast_wpseo_focuskw', true); + if (!empty($yoast_kw)) { + $keywords = $yoast_kw; } // Rank Math if (empty($keywords) && function_exists('rank_math_get_single_post_data')) { $rank_data = rank_math_get_single_post_data($post_id); - if (!empty($rank_data->get_keywords())) { - $keywords = $rank_data->get_keywords(); + if (method_exists($rank_data, 'get_keywords')) { + $kw = $rank_data->get_keywords(); + if (!empty($kw)) { + $keywords = $kw; + } } } // Fallback: Custom Field 'post_keywords' if (empty($keywords)) { $keywords = get_post_meta($post_id, 'post_keywords', true); } + // Fallback: Tags als Keywords + if (empty($keywords) && !empty($tags)) { + $keywords = implode(', ', array_column($tags, 'name')); + } // Fallback: Kategorien als Keywords if (empty($keywords) && !empty($categories)) { $keywords = implode(', ', array_column($categories, 'name')); @@ -241,6 +262,7 @@ function rbr_get_post_details_with_media($data) { ), 'featured_image' => $featured_image_url, 'categories' => $categories, + 'tags' => $tags, 'keywords' => $keywords, 'comments' => $comments, 'media_files' => $unique_media,