using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using HtmlAgilityPack; using Microsoft.Playwright; using WordPressStaticExporter.Models; namespace WordPressStaticExporter.Services; public sealed class SiteExporter { private readonly HttpClient _http = new(); public async Task ExportAsync( ExportSettings settings, IProgress? progress, CancellationToken cancellationToken) { Directory.CreateDirectory(settings.OutputDirectory); var result = new CrawlResult(); var robots = new RobotsService(); progress?.Report(new(1, "Checking robots.txt...", result)); if (settings.RespectRobotsTxt) await robots.LoadAsync(settings.StartUrl, cancellationToken); progress?.Report(new(3, "Discovering pages from sitemap...", result)); var sitemap = new SiteMapService(); var sitemapUrls = await sitemap.DiscoverAsync(settings.StartUrl, cancellationToken); using var playwright = await Playwright.CreateAsync(); await using var browser = await playwright.Chromium.LaunchAsync( new BrowserTypeLaunchOptions { Headless = true }); var context = await browser.NewContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true }); var page = await context.NewPageAsync(); var queue = new Queue(); var queued = new HashSet(StringComparer.OrdinalIgnoreCase); var visited = new HashSet(StringComparer.OrdinalIgnoreCase); var downloadedAssets = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); void Enqueue(Uri uri) { uri = Normalize(uri); if (queued.Add(uri.AbsoluteUri)) queue.Enqueue(uri); } Enqueue(settings.StartUrl); foreach (var uri in sitemapUrls.Where(u => IsSameHost(u, settings.StartUrl) && LooksLikePage(u))) Enqueue(uri); while (queue.Count > 0 && result.PagesExported < settings.MaxPages) { cancellationToken.ThrowIfCancellationRequested(); var current = queue.Dequeue(); if (!visited.Add(current.AbsoluteUri)) continue; if (settings.RespectRobotsTxt && !robots.IsAllowed(current)) { result.PagesSkipped++; progress?.Report(new(CalcPercent(result, queue.Count), $"Skipped by robots.txt: {current.AbsolutePath}", result)); continue; } try { progress?.Report(new(CalcPercent(result, queue.Count), $"Rendering {current}", result)); await page.GotoAsync(current.AbsoluteUri, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 60000 }); var html = await page.ContentAsync(); var doc = new HtmlDocument(); doc.LoadHtml(html); foreach (var link in ExtractInternalPages(doc, current, settings.StartUrl)) Enqueue(link); await RewriteAndDownloadAssetsAsync( doc, current, settings, downloadedAssets, result, cancellationToken); RewriteInternalLinks(doc, current, settings.StartUrl); var outputFile = GetHtmlOutputPath(settings.OutputDirectory, current, settings.StartUrl); Directory.CreateDirectory(Path.GetDirectoryName(outputFile)!); await File.WriteAllTextAsync(outputFile, doc.DocumentNode.OuterHtml, cancellationToken); if (settings.CaptureScreenshots) { var screenshot = Path.ChangeExtension(outputFile, ".png"); await page.ScreenshotAsync(new PageScreenshotOptions { Path = screenshot, FullPage = true }); result.ScreenshotsCaptured++; } result.PagesExported++; progress?.Report(new(CalcPercent(result, queue.Count), $"Saved {Path.GetRelativePath(settings.OutputDirectory, outputFile)}", result)); } catch (Exception ex) { result.PagesSkipped++; result.Errors.Add($"{current} :: {ex.Message}"); progress?.Report(new(CalcPercent(result, queue.Count), $"Skipped {current}: {ex.Message}", result)); } } await WriteManifestAsync(settings, result, cancellationToken); progress?.Report(new(100, $"Completed: {result.PagesExported} pages, {result.AssetsDownloaded} assets, {result.CssFilesProcessed} CSS files.", result)); return result; } private static int CalcPercent(CrawlResult result, int remaining) => Math.Min(99, 5 + (int)(90.0 * result.PagesExported / Math.Max(1, result.PagesExported + remaining))); private async Task RewriteAndDownloadAssetsAsync( HtmlDocument doc, Uri current, ExportSettings settings, ConcurrentDictionary downloaded, CrawlResult result, CancellationToken ct) { var nodes = doc.DocumentNode.SelectNodes("//*[@src or @href or @srcset]") ?? Enumerable.Empty(); foreach (var node in nodes) { foreach (var attr in new[] { "src", "href" }) { var value = node.GetAttributeValue(attr, "").Trim(); if (ShouldIgnore(value)) continue; if (!Uri.TryCreate(current, value, out var uri) || LooksLikePage(uri)) continue; if (!settings.DownloadExternalAssets && !IsSameHost(uri, settings.StartUrl)) continue; var local = await DownloadAssetAsync(uri, settings.OutputDirectory, downloaded, result, ct); if (local is null) continue; var from = GetHtmlOutputPath(settings.OutputDirectory, current, settings.StartUrl); node.SetAttributeValue(attr, MakeRelative(from, Path.Combine(settings.OutputDirectory, local))); } var srcset = node.GetAttributeValue("srcset", ""); if (!string.IsNullOrWhiteSpace(srcset)) node.SetAttributeValue("srcset", await RewriteSrcSetAsync( srcset, current, settings, downloaded, result, ct)); } } private async Task RewriteSrcSetAsync(string srcset, Uri current, ExportSettings settings, ConcurrentDictionary downloaded, CrawlResult result, CancellationToken ct) { var output = new List(); foreach (var item in srcset.Split(',')) { var parts = item.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) continue; if (Uri.TryCreate(current, parts[0], out var uri) && (settings.DownloadExternalAssets || IsSameHost(uri, settings.StartUrl))) { var local = await DownloadAssetAsync(uri, settings.OutputDirectory, downloaded, result, ct); if (local is not null) { var from = GetHtmlOutputPath(settings.OutputDirectory, current, settings.StartUrl); output.Add(MakeRelative(from, Path.Combine(settings.OutputDirectory, local)) + (parts.Length > 1 ? " " + parts[1] : "")); continue; } } output.Add(item.Trim()); } return string.Join(", ", output); } private async Task DownloadAssetAsync(Uri uri, string outputRoot, ConcurrentDictionary downloaded, CrawlResult result, CancellationToken ct) { if (downloaded.TryGetValue(uri.AbsoluteUri, out var existing)) return existing; try { var bytes = await _http.GetByteArrayAsync(uri, ct); var ext = Path.GetExtension(uri.AbsolutePath); if (string.IsNullOrWhiteSpace(ext) || ext.Length > 12) ext = ".bin"; var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(uri.AbsoluteUri))).ToLowerInvariant()[..20]; var relative = Path.Combine("assets", hash + ext).Replace('\\', '/'); var fullPath = Path.Combine(outputRoot, relative); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); await File.WriteAllBytesAsync(fullPath, bytes, ct); downloaded.TryAdd(uri.AbsoluteUri, relative); result.AssetsDownloaded++; if (ext.Equals(".css", StringComparison.OrdinalIgnoreCase)) { await RewriteCssUrlsAsync(fullPath, uri, outputRoot, downloaded, result, ct); result.CssFilesProcessed++; } return relative; } catch { return null; } } private async Task RewriteCssUrlsAsync(string cssFile, Uri cssUri, string outputRoot, ConcurrentDictionary downloaded, CrawlResult result, CancellationToken ct) { var css = await File.ReadAllTextAsync(cssFile, ct); var rewritten = await ReplaceAsync(css, new Regex(@"url\((['""]?)(?[^)'""]+)\1\)", RegexOptions.IgnoreCase), async match => { var raw = match.Groups["url"].Value.Trim(); if (ShouldIgnore(raw) || raw.StartsWith("#")) return match.Value; if (!Uri.TryCreate(cssUri, raw, out var assetUri)) return match.Value; var local = await DownloadAssetAsync(assetUri, outputRoot, downloaded, result, ct); if (local is null) return match.Value; var rel = MakeRelative(cssFile, Path.Combine(outputRoot, local)); return $"url('{rel}')"; }); await File.WriteAllTextAsync(cssFile, rewritten, ct); } private static async Task ReplaceAsync(string input, Regex regex, Func> replacer) { var matches = regex.Matches(input); if (matches.Count == 0) return input; var sb = new StringBuilder(); var index = 0; foreach (Match match in matches) { sb.Append(input, index, match.Index - index); sb.Append(await replacer(match)); index = match.Index + match.Length; } sb.Append(input, index, input.Length - index); return sb.ToString(); } private static IEnumerable ExtractInternalPages(HtmlDocument doc, Uri current, Uri root) { foreach (var node in doc.DocumentNode.SelectNodes("//a[@href]") ?? Enumerable.Empty()) { var href = node.GetAttributeValue("href", "").Trim(); if (ShouldIgnore(href) || href.StartsWith("#")) continue; if (Uri.TryCreate(current, href, out var absolute) && IsSameHost(absolute, root) && LooksLikePage(absolute)) yield return Normalize(absolute); } } private static void RewriteInternalLinks(HtmlDocument doc, Uri current, Uri root) { foreach (var node in doc.DocumentNode.SelectNodes("//a[@href]") ?? Enumerable.Empty()) { var href = node.GetAttributeValue("href", ""); if (!Uri.TryCreate(current, href, out var target) || !IsSameHost(target, root)) continue; var currentFile = GetHtmlOutputPath("ROOT", current, root); var targetFile = GetHtmlOutputPath("ROOT", Normalize(target), root); node.SetAttributeValue("href", MakeRelative(currentFile, targetFile)); } } private static bool ShouldIgnore(string value) => string.IsNullOrWhiteSpace(value) || value.StartsWith("data:", StringComparison.OrdinalIgnoreCase) || value.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) || value.StartsWith("tel:", StringComparison.OrdinalIgnoreCase) || value.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase); private static bool LooksLikePage(Uri uri) { var ext = Path.GetExtension(uri.AbsolutePath); return string.IsNullOrWhiteSpace(ext) || ext.Equals(".html", StringComparison.OrdinalIgnoreCase) || ext.Equals(".htm", StringComparison.OrdinalIgnoreCase); } private static bool IsSameHost(Uri a, Uri b) => string.Equals(a.Host, b.Host, StringComparison.OrdinalIgnoreCase); private static Uri Normalize(Uri uri) { var builder = new UriBuilder(uri) { Fragment = "" }; return builder.Uri; } private static string GetHtmlOutputPath(string root, Uri uri, Uri siteRoot) { var relative = uri.AbsolutePath.Trim('/'); if (string.IsNullOrWhiteSpace(relative)) return Path.Combine(root, "index.html"); if (Path.HasExtension(relative)) return Path.Combine(root, relative); return Path.Combine(root, relative, "index.html"); } private static string MakeRelative(string fromFile, string toFile) { var fromDir = Path.GetDirectoryName(Path.GetFullPath(fromFile))!; return Path.GetRelativePath(fromDir, Path.GetFullPath(toFile)).Replace('\\', '/'); } private static async Task WriteManifestAsync(ExportSettings settings, CrawlResult result, CancellationToken ct) { var manifest = System.Text.Json.JsonSerializer.Serialize(new { source = settings.StartUrl.AbsoluteUri, exportedAtUtc = DateTime.UtcNow, pagesExported = result.PagesExported, pagesSkipped = result.PagesSkipped, assetsDownloaded = result.AssetsDownloaded, cssFilesProcessed = result.CssFilesProcessed, screenshotsCaptured = result.ScreenshotsCaptured, errors = result.Errors }, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(Path.Combine(settings.OutputDirectory, "export-manifest.json"), manifest, ct); } }