using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PuppeteerSharp;
using HtmlToPdfService.Core.Models;
using HtmlToPdfService.Core.Options;
using HtmlToPdfService.Core.Pool;
using HtmlToPdfService.Core.Storage;
namespace HtmlToPdfService.Core.Services;
///
/// Puppeteer 图片转换服务实现
///
public class PuppeteerImageService : IImageService
{
private readonly PdfServiceOptions _options;
private readonly ILogger _logger;
private readonly IBrowserPool _browserPool;
private readonly IFileStorage _fileStorage;
private readonly ICallbackService _callbackService;
public PuppeteerImageService(
IOptions options,
ILogger logger,
IBrowserPool browserPool,
IFileStorage fileStorage,
ICallbackService callbackService)
{
_options = options.Value;
_logger = logger;
_browserPool = browserPool;
_fileStorage = fileStorage;
_callbackService = callbackService;
}
///
/// 将 HTML 内容转换为图片
///
public async Task ConvertHtmlToImageAsync(
string html,
ScreenshotOptions? options = null,
int? viewportWidth = null,
int? viewportHeight = null,
int? delayAfterLoad = null,
string? callbackUrl = null,
Dictionary? callbackHeaders = null,
bool? includePdfInCallback = null,
bool? saveLocal = null,
CancellationToken cancellationToken = default)
{
var requestId = Guid.NewGuid().ToString();
var startTime = DateTime.UtcNow;
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation("开始转换 HTML to Image, RequestId: {RequestId}", requestId);
IBrowser? browser = null;
IPage? page = null;
try
{
// 验证 HTML 内容大小
var htmlSize = System.Text.Encoding.UTF8.GetByteCount(html);
if (htmlSize > _options.Conversion.MaxHtmlSize)
{
throw new ArgumentException(
$"HTML 内容过大: {htmlSize} bytes,最大允许: {_options.Conversion.MaxHtmlSize} bytes");
}
// 从浏览器池获取实例
browser = await _browserPool.AcquireAsync(cancellationToken);
_logger.LogDebug("获取浏览器实例成功, RequestId: {RequestId}", requestId);
// 创建新页面
page = await browser.NewPageAsync();
// 设置视口大小
if (viewportWidth.HasValue || viewportHeight.HasValue)
{
await page.SetViewportAsync(new ViewPortOptions
{
Width = viewportWidth ?? 1920,
Height = viewportHeight ?? 1080
});
}
// 设置 HTML 内容
await page.SetContentAsync(html, new NavigationOptions
{
Timeout = _options.Conversion.DefaultTimeout,
WaitUntil = ParseWaitUntil(_options.Conversion.DefaultWaitUntil)
});
// 如果设置了延迟,等待指定时间(用于动画、延迟加载等)
if (delayAfterLoad.HasValue && delayAfterLoad.Value > 0)
{
_logger.LogDebug("等待 {Delay}ms 以完成动画/延迟渲染", delayAfterLoad.Value);
await Task.Delay(delayAfterLoad.Value, cancellationToken);
}
// 生成图片
var screenshotOptions = options ?? new ScreenshotOptions { FullPage = true };
var imageData = await page.ScreenshotDataAsync(screenshotOptions);
stopwatch.Stop();
_logger.LogInformation("HTML 转图片成功, RequestId: {RequestId}, 耗时: {Duration}ms, 大小: {Size} bytes",
requestId, stopwatch.ElapsedMilliseconds, imageData.Length);
// 构建结果
var result = new ConversionResult
{
RequestId = requestId,
Success = true,
PdfData = imageData,
FileSize = imageData.Length,
Duration = stopwatch.ElapsedMilliseconds,
StartTime = startTime,
CompleteTime = DateTime.UtcNow
};
// 保存本地副本
var shouldSaveLocal = saveLocal ?? _options.Storage.SaveLocalCopy;
if (shouldSaveLocal)
{
var extension = GetImageExtension(screenshotOptions.Type);
var fileName = $"{requestId}{extension}";
var (filePath, downloadUrl) = await SaveImageAsync(requestId, imageData, extension, cancellationToken);
result.LocalFilePath = filePath;
result.DownloadUrl = downloadUrl;
}
// 发送回调
await SendCallbackIfNeededAsync(
result,
"html",
html,
options,
callbackUrl,
callbackHeaders,
includePdfInCallback);
return result;
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "HTML 转图片失败, RequestId: {RequestId}, 耗时: {Duration}ms",
requestId, stopwatch.ElapsedMilliseconds);
var result = new ConversionResult
{
RequestId = requestId,
Success = false,
Duration = stopwatch.ElapsedMilliseconds,
StartTime = startTime,
CompleteTime = DateTime.UtcNow,
ErrorMessage = ex.Message,
ExceptionDetails = ex.ToString()
};
// 发送失败回调
await SendCallbackIfNeededAsync(
result,
"html",
html,
options,
callbackUrl,
callbackHeaders,
includePdfInCallback);
throw;
}
finally
{
// 关闭页面
if (page != null)
{
await page.CloseAsync();
}
// 归还浏览器到池中
if (browser != null)
{
_browserPool.Release(browser);
}
}
}
///
/// 将 URL 转换为图片
///
public async Task ConvertUrlToImageAsync(
string url,
ScreenshotOptions? options = null,
int? viewportWidth = null,
int? viewportHeight = null,
WaitUntilNavigation[]? waitUntil = null,
int? timeout = null,
int? delayAfterLoad = null,
string? callbackUrl = null,
Dictionary? callbackHeaders = null,
bool? includePdfInCallback = null,
bool? saveLocal = null,
CancellationToken cancellationToken = default)
{
var requestId = Guid.NewGuid().ToString();
var startTime = DateTime.UtcNow;
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation("开始转换 URL to Image, RequestId: {RequestId}, URL: {Url}", requestId, url);
IBrowser? browser = null;
IPage? page = null;
try
{
// 验证 URL
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
throw new ArgumentException($"无效的 URL: {url}");
}
// 从浏览器池获取实例
browser = await _browserPool.AcquireAsync(cancellationToken);
_logger.LogDebug("获取浏览器实例成功, RequestId: {RequestId}", requestId);
// 创建新页面
page = await browser.NewPageAsync();
// 设置视口大小
if (viewportWidth.HasValue || viewportHeight.HasValue)
{
await page.SetViewportAsync(new ViewPortOptions
{
Width = viewportWidth ?? 1920,
Height = viewportHeight ?? 1080
});
}
// 导航到 URL
var navigationOptions = new NavigationOptions
{
Timeout = timeout ?? _options.Conversion.DefaultTimeout,
WaitUntil = waitUntil ?? ParseWaitUntil(_options.Conversion.DefaultWaitUntil)
};
await page.GoToAsync(url, navigationOptions);
// 如果设置了延迟,等待指定时间(用于动画、延迟加载等)
if (delayAfterLoad.HasValue && delayAfterLoad.Value > 0)
{
_logger.LogDebug("等待 {Delay}ms 以完成动画/延迟渲染", delayAfterLoad.Value);
await Task.Delay(delayAfterLoad.Value, cancellationToken);
}
// 生成图片
var screenshotOptions = options ?? new ScreenshotOptions { FullPage = true };
var imageData = await page.ScreenshotDataAsync(screenshotOptions);
stopwatch.Stop();
_logger.LogInformation("URL 转图片成功, RequestId: {RequestId}, 耗时: {Duration}ms, 大小: {Size} bytes",
requestId, stopwatch.ElapsedMilliseconds, imageData.Length);
// 构建结果
var result = new ConversionResult
{
RequestId = requestId,
Success = true,
PdfData = imageData,
FileSize = imageData.Length,
Duration = stopwatch.ElapsedMilliseconds,
StartTime = startTime,
CompleteTime = DateTime.UtcNow
};
// 保存本地副本
var shouldSaveLocal = saveLocal ?? _options.Storage.SaveLocalCopy;
if (shouldSaveLocal)
{
var extension = GetImageExtension(screenshotOptions.Type);
var (filePath, downloadUrl) = await SaveImageAsync(requestId, imageData, extension, cancellationToken);
result.LocalFilePath = filePath;
result.DownloadUrl = downloadUrl;
}
// 发送回调
await SendCallbackIfNeededAsync(
result,
"url",
url,
options,
callbackUrl,
callbackHeaders,
includePdfInCallback);
return result;
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "URL 转图片失败, RequestId: {RequestId}, URL: {Url}, 耗时: {Duration}ms",
requestId, url, stopwatch.ElapsedMilliseconds);
var result = new ConversionResult
{
RequestId = requestId,
Success = false,
Duration = stopwatch.ElapsedMilliseconds,
StartTime = startTime,
CompleteTime = DateTime.UtcNow,
ErrorMessage = ex.Message,
ExceptionDetails = ex.ToString()
};
// 发送失败回调
await SendCallbackIfNeededAsync(
result,
"url",
url,
options,
callbackUrl,
callbackHeaders,
includePdfInCallback);
throw;
}
finally
{
// 关闭页面
if (page != null)
{
await page.CloseAsync();
}
// 归还浏览器到池中
if (browser != null)
{
_browserPool.Release(browser);
}
}
}
///
/// 保存图片到本地
///
private async Task<(string FilePath, string DownloadUrl)> SaveImageAsync(
string requestId,
byte[] imageData,
string extension,
CancellationToken cancellationToken)
{
// 修改文件名为图片扩展名
var originalRequestId = requestId;
requestId = $"{requestId}{extension}";
return await _fileStorage.SaveAsync(requestId, imageData, cancellationToken);
}
///
/// 获取图片扩展名
///
private string GetImageExtension(ScreenshotType? type)
{
return type switch
{
ScreenshotType.Jpeg => ".jpg",
ScreenshotType.Webp => ".webp",
_ => ".png"
};
}
///
/// 解析等待条件
///
private WaitUntilNavigation[] ParseWaitUntil(string waitUntil)
{
return waitUntil.ToLowerInvariant() switch
{
"load" => new[] { WaitUntilNavigation.Load },
"domcontentloaded" => new[] { WaitUntilNavigation.DOMContentLoaded },
"networkidle0" => new[] { WaitUntilNavigation.Networkidle0 },
"networkidle2" => new[] { WaitUntilNavigation.Networkidle2 },
_ => new[] { WaitUntilNavigation.Networkidle2 }
};
}
///
/// 发送回调(如果需要)
///
private async Task SendCallbackIfNeededAsync(
ConversionResult result,
string sourceType,
string sourceContent,
object? options,
string? callbackUrl,
Dictionary? callbackHeaders,
bool? includePdfInCallback)
{
// 确定回调 URL(请求级优先于全局配置)
var effectiveCallbackUrl = callbackUrl ?? _options.Callback.DefaultUrl;
if (string.IsNullOrEmpty(effectiveCallbackUrl))
{
return;
}
// 确定是否包含图片数据
var shouldIncludePdf = includePdfInCallback ?? _options.Callback.IncludePdfData;
// 构建回调负载
var payload = new CallbackPayload
{
RequestId = result.RequestId,
Status = result.Success ? "success" : "failed",
Timestamp = DateTime.UtcNow,
Duration = result.Duration,
Source = new CallbackSource
{
Type = sourceType,
Content = sourceContent,
Options = options
}
};
if (result.Success)
{
payload.Result = new CallbackResult
{
FileSize = result.FileSize,
DownloadUrl = result.DownloadUrl,
PdfBase64 = shouldIncludePdf && result.PdfData != null
? Convert.ToBase64String(result.PdfData)
: null,
ExpiresAt = result.DownloadUrl != null
? DateTime.UtcNow.AddHours(_options.Storage.RetentionHours)
: null
};
}
else
{
payload.Error = new CallbackError
{
Code = "CONVERSION_FAILED",
Message = result.ErrorMessage ?? "未知错误",
Details = result.ExceptionDetails
};
}
// 发送回调
await _callbackService.SendCallbackAsync(
effectiveCallbackUrl,
payload,
callbackHeaders);
}
}