Wp2ghost/GhostImageManager/Services/ImageAnalyzerService.cs
2026-10-14 19:12:29 +02:00

338 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.RegularExpressions;
using GhostImageManager.Data;
using GhostImageManager.Models;
using HtmlAgilityPack;
namespace GhostImageManager.Services;
public class ImageAnalyzerService
{
private readonly GhostApiService _ghostApiService;
private readonly ExifService _exifService;
private readonly ImageDatabaseService _dbService;
private readonly ILogger<ImageAnalyzerService> _logger;
public ImageAnalyzerService(
GhostApiService ghostApiService,
ExifService exifService,
ImageDatabaseService dbService,
ILogger<ImageAnalyzerService> logger)
{
_ghostApiService = ghostApiService;
_exifService = exifService;
_dbService = dbService;
_logger = logger;
}
// Hinweis der letzten Analyse (z.B. Bildordner passt nicht zur Ghost-Instanz)
public string? LastWarning { get; private set; }
// Findet Bildpfade unterhalb von /content/images/ in HTML, Lexical-JSON und API-Feldern.
// Größen-Varianten (size/w600/..., size/w600/format/webp/...) werden auf das Ursprungsbild zurückgeführt.
private static readonly Regex ContentImageRegex = new(
@"content\\?/images\\?/(?:size\\?/w\d+\\?/(?:format\\?/\w+\\?/)?)?(?<path>[^""'\s\\()<>?#,]+(?:\\?/[^""'\s\\()<>?#,]+)*)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance)
{
var images = new List<GhostImage>();
LastWarning = null;
_logger.LogInformation("Starte Bildanalyse für Instanz {InstanceName} ({Url}), Ordner: {Folder}", instance.Name, instance.Url, instance.ImageFolderPath);
if (!Directory.Exists(instance.ImageFolderPath))
{
_logger.LogError("Image folder not found: {Path}", instance.ImageFolderPath);
LastWarning = $"Bildordner nicht gefunden: {instance.ImageFolderPath}";
return images;
}
// 1. Immer ALLE Posts und Seiten abrufen – sonst gelten Bilder aus nicht geladenen Posts als verwaist
var posts = await _ghostApiService.GetPostsAsync(instance);
posts.AddRange(await _ghostApiService.GetPostsAsync(instance, resource: "pages"));
_logger.LogInformation("{Count} Posts/Seiten geladen, extrahiere Bild-Referenzen", posts.Count);
// 2. Bild-Referenzen sammeln. Schlüssel: relativer Pfad unter content/images (Ghost-Bilder)
// bzw. die vollständige URL (externe Bilder)
var imageReferences = new Dictionary<string, List<PostReference>>(StringComparer.OrdinalIgnoreCase);
PostReference GetReference(string key, GhostPost post)
{
if (!imageReferences.TryGetValue(key, out var list))
imageReferences[key] = list = new List<PostReference>();
var reference = list.FirstOrDefault(r => r.PostId == post.Id);
if (reference == null)
{
reference = new PostReference { PostId = post.Id, Title = post.Title, Slug = post.Slug };
list.Add(reference);
}
return reference;
}
foreach (var post in posts)
{
// Feature Image
if (!string.IsNullOrEmpty(post.FeatureImage))
GetReference(GetReferenceKey(post.FeatureImage), post).IsFeatureImage = true;
// Ghost-Bilder im Inhalt: HTML (src, srcset, Hintergrundbilder), Lexical (Galerien, Karten), og/twitter
foreach (var path in ExtractContentImagePaths($"{post.Html}\n{post.Lexical}\n{post.OtherImageFields}"))
GetReference(path, post).IsInContent = true;
// Externe Bilder im HTML
foreach (var imgUrl in ExtractImageUrls(post.Html))
{
if (!ContentImageRegex.IsMatch(imgUrl))
GetReference(imgUrl, post).IsInContent = true;
}
}
// Tags, Benutzer und Website-Einstellungen (Logo, Icon, Cover ...)
foreach (var (label, json) in await _ghostApiService.GetOtherImageSourcesAsync(instance))
{
var source = new GhostPost { Id = label, Title = label };
foreach (var path in ExtractContentImagePaths(json))
GetReference(path, source);
}
// 3. Lokale Dateien scannen
_logger.LogInformation("Scanne lokalen Image-Ordner: {Folder}", instance.ImageFolderPath);
var localFiles = ScanLocalImageFiles(instance.ImageFolderPath);
_logger.LogInformation("{Count} lokale Bilddateien gefunden", localFiles.Count);
var localPaths = localFiles
.Select(f => GetRelativePath(instance.ImageFolderPath, f.FullName))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var ghostReferenceCount = imageReferences.Keys.Count(k => !IsExternalUrl(k));
if (ghostReferenceCount > 0 && localFiles.Count > 0 && !imageReferences.Keys.Any(localPaths.Contains))
{
LastWarning = $"Keines der {ghostReferenceCount} in Ghost verwendeten Bilder liegt im Ordner {instance.ImageFolderPath}. " +
"Der Ordner ist vermutlich nicht (mehr) der content/images-Ordner dieser Ghost-Instanz – alle lokalen Dateien erscheinen deshalb als verwaist.";
_logger.LogWarning("{Warning}", LastWarning);
}
// 4. GhostImage-Objekte erstellen und mit DB synchronisieren
foreach (var file in localFiles)
{
var absoluteUrl = ConvertLocalPathToUrl(instance, file.FullName);
var relativePath = GetRelativePath(instance.ImageFolderPath, file.FullName);
imageReferences.TryGetValue(relativePath, out var refs);
var image = new GhostImage
{
FilePath = file.FullName,
FileName = file.Name,
RelativePath = relativePath,
Url = absoluteUrl,
FileSize = file.Length,
LastModified = file.LastWriteTime,
UsedInPosts = refs ?? new List<PostReference>(),
IsOrphaned = refs == null
};
// "bild_o.jpg" ist Ghosts Originalkopie von "bild.jpg": zählt als verwendet, solange das Bild verwendet wird
var basePath = GetOriginalBasePath(relativePath);
if (refs == null && basePath != null && localPaths.Contains(basePath))
{
image.IsOriginal = true;
image.OriginalOf = Path.GetFileName(basePath);
image.IsOrphaned = !imageReferences.ContainsKey(basePath);
}
// Dimensionen ermitteln
GetImageDimensions(image);
// EXIF-Daten extrahieren
ExifData exifData = null!;
if (!file.Extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
{
exifData = _exifService.ExtractExifData(file.FullName);
ApplyExifData(image, exifData);
}
// Mit Datenbank synchronisieren
var managedImage = await _dbService.SyncImageAsync(image, instance.Id, exifData);
ApplyDatabaseData(image, managedImage);
images.Add(image);
}
// Auch URLs aus Posts, die lokal nicht existieren, hinzufügen (z.B. externe Bilder)
foreach (var key in imageReferences.Keys)
{
if (localPaths.Contains(key))
continue;
var url = IsExternalUrl(key) ? key : $"{instance.Url.TrimEnd('/')}/content/images/{key}";
var localPath = ConvertUrlToLocalPath(instance, url);
var externalImage = new GhostImage
{
Url = url,
FileName = Path.GetFileName(url),
RelativePath = GetRelativePath(instance.ImageFolderPath, localPath),
FilePath = localPath,
UsedInPosts = imageReferences[key],
IsOrphaned = false
};
var managedImage = await _dbService.SyncImageAsync(externalImage, instance.Id, null);
ApplyDatabaseData(externalImage, managedImage);
images.Add(externalImage);
}
var orphanedCount = images.Count(i => i.IsOrphaned);
_logger.LogInformation("Bildanalyse abgeschlossen: {Total} Bilder, {Orphaned} verwaist, {Used} verwendet", images.Count, orphanedCount, images.Count - orphanedCount);
return images;
}
private void ApplyExifData(GhostImage image, ExifData exifData)
{
image.CameraMake = exifData.CameraMake;
image.CameraModel = exifData.CameraModel;
image.Lens = exifData.Lens;
image.Aperture = exifData.Aperture;
image.ShutterSpeed = exifData.ShutterSpeed;
image.Iso = exifData.Iso;
image.FocalLength = exifData.FocalLength;
image.CaptureDate = exifData.CaptureDate;
}
private void ApplyDatabaseData(GhostImage image, ManagedImage managedImage)
{
image.ManagedImageId = managedImage.Id;
image.AuthorId = managedImage.AuthorId;
image.AuthorName = managedImage.AuthorName;
image.Theme = managedImage.Theme;
image.Category = managedImage.Category;
// CaptureDate aus DB hat Priorität, falls manuell überschrieben
if (managedImage.CaptureDate.HasValue)
image.CaptureDate = managedImage.CaptureDate.Value;
}
private List<FileInfo> ScanLocalImageFiles(string folderPath)
{
var files = new List<FileInfo>();
var extensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg" };
if (!Directory.Exists(folderPath))
return files;
foreach (var ext in extensions)
{
files.AddRange(new DirectoryInfo(folderPath)
.EnumerateFiles($"*{ext}", SearchOption.AllDirectories));
}
var excludedDirs = Path.Combine(folderPath, "size");
return files.Where(f => f.DirectoryName.StartsWith(excludedDirs) == false).OrderBy(f => f.FullName).ToList();
}
private List<string> ExtractImageUrls(string html)
{
var urls = new List<string>();
try
{
var doc = new HtmlDocument();
doc.LoadHtml(html);
var imgTags = doc.DocumentNode.SelectNodes("//img[@src]");
if (imgTags != null)
{
foreach (var img in imgTags)
{
var src = img.GetAttributeValue("src", "").Trim();
if (!string.IsNullOrEmpty(src))
urls.Add(src);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Parsen von HTML nach Bild-URLs");
}
return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private static List<string> ExtractContentImagePaths(string text)
{
return ContentImageRegex.Matches(text)
.Select(m => Uri.UnescapeDataString(m.Groups["path"].Value.Replace("\\/", "/")))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
// Ghost-Bild -> relativer Pfad, alles andere -> URL unverändert
private static string GetReferenceKey(string url)
{
return ExtractContentImagePaths(url).FirstOrDefault() ?? url;
}
private static bool IsExternalUrl(string key)
{
return key.Contains("://") || key.StartsWith("//") || key.StartsWith("data:", StringComparison.OrdinalIgnoreCase);
}
// "2026/09/bild_o.jpg" -> "2026/09/bild.jpg"; null, wenn der Name nicht auf _o endet
private static string? GetOriginalBasePath(string relativePath)
{
var extension = Path.GetExtension(relativePath);
var withoutExtension = relativePath[..^extension.Length];
return withoutExtension.EndsWith("_o", StringComparison.OrdinalIgnoreCase)
? withoutExtension[..^2] + extension
: null;
}
private string ConvertLocalPathToUrl(GhostInstance instance, string localPath)
{
var relativePath = GetRelativePath(instance.ImageFolderPath, localPath);
return $"{instance.Url.TrimEnd('/')}/content/images/{relativePath.TrimStart('/')}";
}
private string ConvertUrlToLocalPath(GhostInstance instance, string url)
{
var contentImagesUrl = $"{instance.Url.TrimEnd('/')}/content/images/";
if (url.StartsWith(contentImagesUrl, StringComparison.OrdinalIgnoreCase))
{
var relativePath = url[contentImagesUrl.Length..];
return Path.Combine(instance.ImageFolderPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
}
return Path.Combine(instance.ImageFolderPath, Path.GetFileName(url));
}
private string GetRelativePath(string basePath, string targetPath)
{
if (!basePath.EndsWith(Path.DirectorySeparatorChar) && !basePath.EndsWith(Path.AltDirectorySeparatorChar))
basePath += Path.DirectorySeparatorChar;
var baseUri = new Uri(basePath);
var targetUri = new Uri(targetPath);
var relativeUri = baseUri.MakeRelativeUri(targetUri);
return Uri.UnescapeDataString(relativeUri.ToString());
}
private void GetImageDimensions(GhostImage image)
{
if (image.FilePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
return;
try
{
using var fs = File.OpenRead(image.FilePath);
var (width, height) = ImageDimensionReader.Read(fs);
image.Width = width;
image.Height = height;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Konnte Bilddimensionen nicht ermitteln für {File}", image.FilePath);
}
}
}