Improve plugin and Fetcher

This commit is contained in:
Roland 2026-09-22 08:08:51 +02:00
parent d9df4947f4
commit b598b0d5af
9 changed files with 522 additions and 25 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
/CSharpGhostImporter/.vs/* /CSharpGhostImporter/.vs/*
/CSharpGhostImporter/bin/* /CSharpGhostImporter/bin/*
/CSharpGhostImporter/obj/* /CSharpGhostImporter/obj/*
/0

View file

@ -1,5 +1,8 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Net;
using System.Net.Http;
using HtmlAgilityPack;
using wp2ghost; using wp2ghost;
class Program class Program
@ -22,6 +25,7 @@ class Program
ImageManager.Init(BaseDir); ImageManager.Init(BaseDir);
PostFetcher.Init(BaseDir); PostFetcher.Init(BaseDir);
GhostExporter.Init(BaseDir); GhostExporter.Init(BaseDir);
LinkDecisionManager.Init(BaseDir);
GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath); GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath);
Console.OutputEncoding = System.Text.Encoding.UTF8; Console.OutputEncoding = System.Text.Encoding.UTF8;
@ -57,8 +61,12 @@ class Program
case "7": case "7":
await GhostMenuAsync(); await GhostMenuAsync();
break; break;
case "8":
LinkDecisionManager.ShowInfo();
break;
case "0": case "0":
ImageManager.SaveCache(); ImageManager.SaveCache();
LinkDecisionManager.Save();
running = false; running = false;
Console.WriteLine("Programm beendet. Auf Wiedersehen!"); Console.WriteLine("Programm beendet. Auf Wiedersehen!");
break; break;
@ -89,6 +97,7 @@ class Program
Console.WriteLine(" 5 - Abgefragte Objekte zurücksetzen"); Console.WriteLine(" 5 - Abgefragte Objekte zurücksetzen");
Console.WriteLine(" 6 - Bilder lokal löschen"); Console.WriteLine(" 6 - Bilder lokal löschen");
Console.WriteLine(" 7 - Beiträge direkt an Ghost senden"); Console.WriteLine(" 7 - Beiträge direkt an Ghost senden");
Console.WriteLine(" 8 - Gespeicherte Link-Entscheidungen anzeigen");
Console.WriteLine(" 0 - Beenden"); Console.WriteLine(" 0 - Beenden");
Console.WriteLine("-----------------------------------------"); Console.WriteLine("-----------------------------------------");
Console.Write("Auswahl: "); Console.Write("Auswahl: ");
@ -352,6 +361,9 @@ class Program
Console.Write("HTML-Inhalt: "); Console.Write("HTML-Inhalt: ");
string? html = Console.ReadLine()?.Trim(); string? html = Console.ReadLine()?.Trim();
// Links validieren und ggf. korrigieren
html = await ValidateAndFixLinksAsync(html);
Console.Write("Status (published/draft) [published]: "); Console.Write("Status (published/draft) [published]: ");
string? status = Console.ReadLine()?.Trim(); string? status = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(status)) status = "published"; if (string.IsNullOrEmpty(status)) status = "published";
@ -459,6 +471,233 @@ class Program
Console.WriteLine($" Hochgeladen: {uploaded}/{cachedFiles.Count}"); Console.WriteLine($" Hochgeladen: {uploaded}/{cachedFiles.Count}");
} }
// =========================================================================
// HTML-Links validieren und bei Bedarf korrigieren
// =========================================================================
private static async Task<string> 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 <a>-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, <a>-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: <a>-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
// =========================================================================
/// <summary>
/// Wendet eine gespeicherte (frühere) Entscheidung auf den Link an.
/// </summary>
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
}
/// <summary>
/// Öffnet die Standard-Browser-Anwendung mit der angegebenen URL.
/// </summary>
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<bool> 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() static async Task GhostCreateOrUpdateFromJsonAsync()
{ {
string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts"); string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts");
@ -509,12 +748,14 @@ class Program
Console.WriteLine($" Post: {postFile.Title}"); Console.WriteLine($" Post: {postFile.Title}");
Console.WriteLine($" Slug: {postFile.Slug}"); Console.WriteLine($" Slug: {postFile.Slug}");
Console.WriteLine($" Kategorien: {postFile.Categories.Count}"); Console.WriteLine($" Kategorien: {postFile.Categories.Count}");
Console.WriteLine($" Tags: {postFile.Tags.Count}");
Console.WriteLine($" Kommentare: {postFile.Comments.Count}"); Console.WriteLine($" Kommentare: {postFile.Comments.Count}");
Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}"); Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}");
// 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen // 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen
var ghostUrlMap = new Dictionary<string, string>(); var ghostUrlMap = new Dictionary<string, string>();
string html = postFile.Html ?? ""; string html = postFile.Html ?? "";
html = await ValidateAndFixLinksAsync(html);
if (postFile.ImageFiles.Count > 0) if (postFile.ImageFiles.Count > 0)
{ {
@ -572,12 +813,18 @@ class Program
Console.WriteLine($" [Warnung] {remaining} unrestrierte __GHOST_URL__ Platzhalter im HTML"); Console.WriteLine($" [Warnung] {remaining} unrestrierte __GHOST_URL__ Platzhalter im HTML");
} }
// 4. Tags erstellen/zuordnen // 4. Tags erstellen/zuordnen (Kategorien + Tags)
var tags = new List<GhostAdminTag>(); var tags = new List<GhostAdminTag>();
var allTagSources = new List<WpCategory>();
if (postFile.Categories.Count > 0) 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..."); Console.WriteLine($" [Tags] Verarbeite {postFile.Categories.Count} Kategorien + {postFile.Tags.Count} Tags...");
tags = await GhostApi.EnsureTagsAsync(postFile.Categories); tags = await GhostApi.EnsureTagsAsync(allTagSources);
} }
// 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert // 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert

View file

@ -31,6 +31,7 @@ namespace wp2ghost
[JsonPropertyName("published_at")] public string? PublishedAt { get; set; } [JsonPropertyName("published_at")] public string? PublishedAt { get; set; }
[JsonPropertyName("feature_image")] public string? FeatureImage { get; set; } [JsonPropertyName("feature_image")] public string? FeatureImage { get; set; }
[JsonPropertyName("categories")] public List<WpCategory> Categories { get; set; } = new(); [JsonPropertyName("categories")] public List<WpCategory> Categories { get; set; } = new();
[JsonPropertyName("tags")] public List<WpCategory> Tags { get; set; } = new();
[JsonPropertyName("keywords")] public string? Keywords { get; set; } [JsonPropertyName("keywords")] public string? Keywords { get; set; }
[JsonPropertyName("comments")] public List<WpComment> Comments { get; set; } = new(); [JsonPropertyName("comments")] public List<WpComment> Comments { get; set; } = new();
[JsonPropertyName("image_files")] public List<string> ImageFiles { get; set; } = new(); [JsonPropertyName("image_files")] public List<string> ImageFiles { get; set; } = new();

View file

@ -99,6 +99,7 @@ namespace wp2ghost
ConvertSingleImages(htmlDoc); ConvertSingleImages(htmlDoc);
ConvertFigures(htmlDoc); ConvertFigures(htmlDoc);
ConvertWpClasses(htmlDoc); ConvertWpClasses(htmlDoc);
AddTargetBlankToExternalLinks(htmlDoc);
return htmlDoc.DocumentNode.OuterHtml; return htmlDoc.DocumentNode.OuterHtml;
} }
@ -134,11 +135,27 @@ namespace wp2ghost
container.AddClass("kg-gallery-container"); container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>(); var wrappers = new List<HtmlNode>();
var imageCaptions = new List<string>();
foreach (var item in items) foreach (var item in items)
{ {
var img = item.SelectSingleNode(".//img"); var img = item.SelectSingleNode(".//img");
if (img == null) continue; 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); string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue; if (string.IsNullOrEmpty(src)) continue;
@ -150,11 +167,14 @@ namespace wp2ghost
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container); ghostGallery.AppendChild(container);
var captionNode = gallery.SelectSingleNode(".//*[contains(@class, 'gallery-caption')] | .//figcaption"); // Gallery-Bild-Titel übertragen, wenn vorhanden
if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText)) if (imageCaptions.Count > 0)
{ {
// Ersten nicht-leeren Titel nehmen als Gallery-Kapitel
string firstCaption = imageCaptions[0];
var newCaption = htmlDoc.CreateElement("figcaption"); var newCaption = htmlDoc.CreateElement("figcaption");
newCaption.InnerHtml = captionNode.InnerHtml; newCaption.InnerHtml = firstCaption;
ghostGallery.AppendChild(newCaption); ghostGallery.AppendChild(newCaption);
ghostGallery.AddClass("kg-card-hascaption"); ghostGallery.AddClass("kg-card-hascaption");
} }
@ -207,14 +227,23 @@ namespace wp2ghost
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container); ghostGallery.AppendChild(container);
// Caption aus erstem figcaption im Gallery-Container extrahieren // Alle individuellen Bildunterschriften aus der Gutenberg-Gallery sammeln
var captionNode = gallery.SelectSingleNode(".//figcaption"); var captionNodes = gallery.SelectNodes(".//figcaption");
if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText)) if (captionNodes != null && captionNodes.Count > 0)
{ {
var newCaption = htmlDoc.CreateElement("figcaption"); var distinctCaptions = captionNodes
newCaption.InnerHtml = captionNode.InnerHtml; .Select(n => n.InnerText.Trim())
ghostGallery.AppendChild(newCaption); .Where(t => !string.IsNullOrWhiteSpace(t))
ghostGallery.AddClass("kg-card-hascaption"); .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) // 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) private static bool HasClass(HtmlNode node, string className)
{ {
string classes = node.GetAttributeValue("class", ""); string classes = node.GetAttributeValue("class", "");

View file

@ -20,11 +20,28 @@ namespace wp2ghost
public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip; public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip;
public static bool TryGetRelativePath(string url, out string relativePath) public static bool TryGetRelativePath(string url, out string relativePath)
{ {
// 1. Direkte URL-Prüfung im Cache
if (Cache.TryGetValue(url, out var cached)) if (Cache.TryGetValue(url, out var cached))
{ {
relativePath = cached.RelativePath; relativePath = cached.RelativePath;
return true; 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 = ""; relativePath = "";
return false; return false;
} }
@ -46,7 +63,10 @@ namespace wp2ghost
{ {
var lines = new List<string>(); var lines = new List<string>();
foreach (var kvp in Cache) 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); File.WriteAllLines(CachePath, lines);
} }
@ -223,8 +243,13 @@ namespace wp2ghost
foreach (var line in lines) foreach (var line in lines)
{ {
var parts = line.Split('|'); var parts = line.Split('|');
if (parts.Length == 3) if (parts.Length >= 3)
{
Cache[parts[0]] = (parts[1], parts[2]); 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) catch (Exception ex)

View file

@ -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<string, LinkDecision> _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<List<LinkDecision>>(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}");
}
}
/// <summary>
/// Speichert eine User-Entscheidung für einen Link.
/// </summary>
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();
}
/// <summary>
/// Versucht eine frühere Entscheidung für diesen Link zu finden.
/// </summary>
public static LinkDecision? GetPreviousDecision(string linkText, string linkHref)
{
string key = NormalizeKey(linkText, linkHref);
if (_decisions.TryGetValue(key, out var d))
return d;
return null;
}
/// <summary>
/// Gibt die Anzahl gespeicherter Entscheidungen zurück (für Info-Anzeige).
/// </summary>
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");
}
}
}
}

View file

@ -78,6 +78,9 @@ namespace wp2ghost
return; return;
} }
ProcessedPosts.Clear();
LoadProcessedPosts();
var pendingIds = allPostIds.Where(id => !ProcessedPosts.Contains(id)).ToList(); 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}"); 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}..."); Console.WriteLine($"[{++processed}/{pendingIds.Count}] -> Beitrag/Seite ID: {wpId}...");
string detailUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/post-details/{wpId}"; string detailUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/post-details/{wpId}";
//string postUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/wp/v2/posts/{wpId}?_embed";
try try
{ {
var detailJson = await httpClient.GetStringAsync(detailUrl); var detailJson = await httpClient.GetStringAsync(detailUrl);
var wpPost = JsonSerializer.Deserialize<WpPostResponse>(detailJson); var wpPost = JsonSerializer.Deserialize<WpPostResponse>(detailJson);
//var postJson = await httpClient.GetStringAsync(postUrl);
if (wpPost == null) if (wpPost == null)
{ {
Console.WriteLine($" Übersprungen (null)."); Console.WriteLine($" Übersprungen (null).");
@ -217,7 +224,7 @@ namespace wp2ghost
GhostPosts.Add(ghostPost); GhostPosts.Add(ghostPost);
// JSON-Datei in ghostPosts/ speichern // JSON-Datei in ghostPosts/ speichern
var postFile = new GhostPostFile var ghostPostFile = new GhostPostFile
{ {
WpId = wpId, WpId = wpId,
WpSlug = wpPost.Slug, WpSlug = wpPost.Slug,
@ -230,13 +237,17 @@ namespace wp2ghost
PublishedAt = postDate.ToString("o"), PublishedAt = postDate.ToString("o"),
FeatureImage = featureImageGhostPath, FeatureImage = featureImageGhostPath,
Categories = wpPost.Categories ?? new(), Categories = wpPost.Categories ?? new(),
Tags = wpPost.Tags ?? new(),
Keywords = wpPost.Keywords ?? "", Keywords = wpPost.Keywords ?? "",
Comments = wpPost.Comments ?? new(), Comments = wpPost.Comments ?? new(),
ImageFiles = imageFiles, ImageFiles = imageFiles,
Html = htmlContent Html = htmlContent
}; };
string jsonPath = Path.Combine(GhostPostsDir, $"{postFile.WpId}.json"); string jsonPath = Path.Combine(GhostPostsDir, $"{ghostPostFile.WpId}.json");
File.WriteAllText(jsonPath, JsonSerializer.Serialize(postFile, new JsonSerializerOptions { WriteIndented = true })); File.WriteAllText(jsonPath, JsonSerializer.Serialize(ghostPostFile, new JsonSerializerOptions { WriteIndented = true }));
// Cache sofort speichern (nicht erst beim Beenden)
ImageManager.SaveCache();
MarkProcessed(wpId, wpPost.Slug); MarkProcessed(wpId, wpPost.Slug);
internalId++; internalId++;

View file

@ -24,6 +24,7 @@ namespace wp2ghost
[JsonPropertyName("excerpt")] public string? Excerpt { get; set; } [JsonPropertyName("excerpt")] public string? Excerpt { get; set; }
[JsonPropertyName("featured_image")] public string? FeaturedImage { get; set; } [JsonPropertyName("featured_image")] public string? FeaturedImage { get; set; }
[JsonPropertyName("categories")] public List<WpCategory>? Categories { get; set; } = new(); [JsonPropertyName("categories")] public List<WpCategory>? Categories { get; set; } = new();
[JsonPropertyName("tags")] public List<WpCategory>? Tags { get; set; } = new();
[JsonPropertyName("keywords")] public string? Keywords { get; set; } [JsonPropertyName("keywords")] public string? Keywords { get; set; }
[JsonPropertyName("comments")] public List<WpComment>? Comments { get; set; } = new(); [JsonPropertyName("comments")] public List<WpComment>? Comments { get; set; } = new();
[JsonPropertyName("media_files")] public List<string>? MediaFiles { get; set; } = new(); [JsonPropertyName("media_files")] public List<string>? MediaFiles { get; set; } = new();

View file

@ -175,7 +175,7 @@ function rbr_get_post_details_with_media($data) {
// Duplikate entfernen // Duplikate entfernen
$unique_media = array_values(array_unique($media_urls)); $unique_media = array_values(array_unique($media_urls));
// 4. Kategorien ( Categories Only, keine Tags) // 4. Kategorien
$categories = array(); $categories = array();
$cats = get_the_category($post_id); $cats = get_the_category($post_id);
if (!empty($cats) && !is_wp_error($cats)) { 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 = ''; $keywords = '';
// Yoast SEO // Yoast SEO: Prüfe auf Meta-Key direkt (funktioniert in allen Versionen)
if (function_exists('yoast_get_primary_term')) { $yoast_kw = get_post_meta($post_id, '_yoast_wpseo_focuskw', true);
$keywords = get_post_meta($post_id, '_yoast_wpseo_focuskw', true); if (!empty($yoast_kw)) {
$keywords = $yoast_kw;
} }
// Rank Math // Rank Math
if (empty($keywords) && function_exists('rank_math_get_single_post_data')) { if (empty($keywords) && function_exists('rank_math_get_single_post_data')) {
$rank_data = rank_math_get_single_post_data($post_id); $rank_data = rank_math_get_single_post_data($post_id);
if (!empty($rank_data->get_keywords())) { if (method_exists($rank_data, 'get_keywords')) {
$keywords = $rank_data->get_keywords(); $kw = $rank_data->get_keywords();
if (!empty($kw)) {
$keywords = $kw;
}
} }
} }
// Fallback: Custom Field 'post_keywords' // Fallback: Custom Field 'post_keywords'
if (empty($keywords)) { if (empty($keywords)) {
$keywords = get_post_meta($post_id, 'post_keywords', true); $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 // Fallback: Kategorien als Keywords
if (empty($keywords) && !empty($categories)) { if (empty($keywords) && !empty($categories)) {
$keywords = implode(', ', array_column($categories, 'name')); $keywords = implode(', ', array_column($categories, 'name'));
@ -241,6 +262,7 @@ function rbr_get_post_details_with_media($data) {
), ),
'featured_image' => $featured_image_url, 'featured_image' => $featured_image_url,
'categories' => $categories, 'categories' => $categories,
'tags' => $tags,
'keywords' => $keywords, 'keywords' => $keywords,
'comments' => $comments, 'comments' => $comments,
'media_files' => $unique_media, 'media_files' => $unique_media,