using System.Text;
using System.Text.Json;
using System.Web;
using Dpz.Core.Entity.Base.Image;
using Dpz.Core.EnumLibrary;
using Dpz.Core.Infrastructure;
using Dpz.Core.Infrastructure.Imaging;
using Dpz.Core.MessageQueue.Abstractions;
using Dpz.Core.MessageQueue.Enums;
using Dpz.Core.Public.ViewModel;
using Dpz.Core.Public.ViewModel.Messages;
using Dpz.Core.Public.ViewModel.Request;
using Dpz.Core.Service.Network;
using Dpz.Core.Service.ObjectStorage.Services;
using Dpz.Core.Service.RepositoryService;
using Dpz.Core.Web.Jobs.Hangfire;
using Dpz.Core.Web.Jobs.Models;
using Hangfire;
using MongoDB.Bson;
using OpenAI.Chat;
using ReverseMarkdown;

namespace Dpz.Core.Web.Jobs.Services;

public abstract class TaskService(
    IArticleService articleService,
    IObjectStorageOperation objectStorageService,
    IHttpClientFactory httpClientFactory,
    IPushMessage pushMessage,
    ILoggerFactory logger,
    List<string> tags,
    IOpenAiService openAiService,
    IConfiguration configuration,
    IMessagePublisher<NewsArticleMessage> messagePublisher,
    IMessagePublisher<BatchCompletionMessage> batchCompletionPublisher,
    IMessagePublisher<DeleteMarkdownMessage> deleteMarkdownMessagePublisher,
    IImageFormatDetector imageFormatDetector
) : JobActivator
{
    /// <summary>
    /// 日志记录
    /// </summary>
    private readonly ILogger<TaskService> _logger = logger.CreateLogger<TaskService>();

    /// <summary>
    /// 文章Service
    /// </summary>
    protected readonly IArticleService ArticleService = articleService;

    /// <summary>
    /// HttpClientFactory
    /// </summary>
    protected readonly IHttpClientFactory HttpClientFactory = httpClientFactory;

    /// <summary>
    /// 消息发布者
    /// </summary>
    private readonly IMessagePublisher<NewsArticleMessage> _messagePublisher = messagePublisher;

    /// <summary>
    /// 批次完成消息发布者
    /// </summary>
    private readonly IMessagePublisher<BatchCompletionMessage> _batchCompletionPublisher =
        batchCompletionPublisher;

    /// <summary>
    /// 当前批次ID
    /// </summary>
    private string? _currentBatchId;

    /// <summary>
    /// 当前批次已成功发送的消息数
    /// </summary>
    private int _sentMessageCount;

    /// <summary>
    /// 爬取进度
    /// </summary>
    private decimal Progress { get; set; }

    /// <summary>
    /// 进度单调递增保护
    /// </summary>
    private readonly SemaphoreSlim _progressSemaphore = new(1, 1);

    /// <summary>
    /// 并发爬取限制
    /// </summary>
    private readonly SemaphoreSlim _concurrencyLimiter = new(3, 3);

    /// <summary>
    /// 文章标签
    /// </summary>
    private List<string> Tags { get; } = tags;

    protected abstract Task<IReadOnlyCollection<string>> GetTaskUrlsAsync();

    /// <summary>
    /// 爬取文章内容
    /// </summary>
    /// <param name="article">文章实体</param>
    /// <param name="url">文章地址</param>
    /// <returns></returns>
    protected abstract Task<VmUserInfo?> GetArticleContentAsync(
        CreateArticleRequest article,
        string url
    );

    /// <summary>
    /// 推送爬取消息
    /// </summary>
    /// <param name="message"></param>
    /// <param name="type"></param>
    protected virtual async Task PushProgressMessage(
        string message,
        MessageType type = MessageType.Success
    )
    {
        var progressMessage = new ProgressMessage
        {
            ProgressValues = [type == MessageType.Over ? 1m : Progress],
            Message = message,
            Type = type,
        };
        await pushMessage.PushCnBetaMessageAsync(progressMessage);
    }

    /// <summary>
    /// 下载图片并上传到云储存
    /// </summary>
    /// <param name="url">需要下载图片的地址</param>
    /// <returns></returns>
    protected virtual async Task<ImageMetadata?> DownloadImageToObjectStorageAsync(string url)
    {
        var uri = HandleUrl(url, "x-bce-process");
        if (uri == null)
        {
            return null;
        }

        try
        {
            // download
            var (buffer, fileExtension) = await ApplicationTools.RetryAsync(
                async () => await DownloadImageAsync(uri),
                TimeSpan.FromSeconds(1)
            );

            // upload
            return await ApplicationTools.RetryAsync(
                async () => await UploadImageAsync(buffer, fileExtension),
                TimeSpan.FromSeconds(1)
            );
        }
        catch (Exception e)
        {
            var message = $"downloading images from the {uri} failed";
            await PushProgressMessage($"{message},{e.Message}");
            _logger.LogError(e, "{Message}", message);
            return null;
        }
    }

    /// <summary>
    /// 下载图片
    /// </summary>
    /// <param name="uri"></param>
    /// <returns></returns>
    /// <exception cref="BusinessException"></exception>
    protected virtual async Task<(byte[] Buffer, string FileExtension)> DownloadImageAsync(Uri uri)
    {
        var httpClient = HttpClientFactory.CreateClient("edge");
        var request = new HttpRequestMessage(HttpMethod.Get, uri);
        var response = await httpClient.SendAsync(request);
        if (!response.IsSuccessStatusCode)
        {
            throw new BusinessException(
                $"download image fail,response status code:{response.StatusCode}"
            );
        }

        var stream = await response.Content.ReadAsStreamAsync();

        if (
            string.Equals(
                response.Content.Headers.ContentType?.ToString() ?? "",
                "image/svg+xml",
                StringComparison.OrdinalIgnoreCase
            )
        )
        {
            using var memoryStream = new MemoryStream();
            stream.Position = 0;
            await stream.CopyToAsync(memoryStream);
            var bytes = memoryStream.ToArray();
            await stream.DisposeAsync();
            return (bytes, "svg");
        }

        return await RecognizeImageAsync(stream);
    }

    /// <summary>
    /// 上传图片
    /// </summary>
    /// <param name="buffer"></param>
    /// <param name="fileExtension"></param>
    /// <returns></returns>
    protected virtual async Task<ImageMetadata?> UploadImageAsync(
        byte[] buffer,
        string fileExtension
    )
    {
        var date = DateTime.Now;
        var path =
#if DEBUG
        new List<string>
        {
            "Test",
            "images",
            date.Year.ToString(),
            date.Month.ToString(),
            date.Day.ToString(),
        };
#else
        new List<string>
        {
            "images",
            date.Year.ToString(),
            date.Month.ToString(),
            date.Day.ToString(),
        };
#endif
        var result = await objectStorageService.UploadAsync(
            buffer,
            path,
            $"{ObjectId.GenerateNewId()}.{fileExtension}"
        );

        return result as ImageMetadata?;
    }

    /// <summary>
    /// handle url
    /// </summary>
    /// <param name="url">url</param>
    /// <param name="key">remove query string key</param>
    /// <returns></returns>
    protected virtual Uri? HandleUrl(string url, params string[] key)
    {
        if (string.IsNullOrEmpty(url))
        {
            _logger.LogError("url is empty");
            return null;
        }

        if (url.StartsWith("//"))
        {
            url = "https:" + url;
        }

        if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
        {
            _logger.LogError("{Url}未能成功构建Uri", url);
            return null;
        }

        // this gets all the query string key value pairs as a collection
        var newQueryString = HttpUtility.ParseQueryString(uri.Query);

        // this removes the key if exists
        foreach (var item in key)
        {
            newQueryString.Remove(item);
        }

        // this gets the page path from root without QueryString
        var pagePathWithoutQueryString = uri.GetLeftPart(UriPartial.Path);

        return new Uri(
            newQueryString.Count > 0
                ? $"{pagePathWithoutQueryString}?{newQueryString}"
                : pagePathWithoutQueryString
        );
    }

    /// <summary>
    /// 识别图像
    /// </summary>
    /// <param name="stream"></param>
    /// <returns></returns>
    protected virtual async Task<(byte[] Buffer, string FileExtension)> RecognizeImageAsync(
        Stream stream
    )
    {
        if (stream == null)
        {
            throw new ArgumentNullException(nameof(stream));
        }

        using var memory = new MemoryStream();
        await stream.CopyToAsync(memory);
        var buffer = memory.ToArray();

        var info = imageFormatDetector.Detect(buffer);

        if (!info.IsKnown)
        {
            throw new BusinessException("未获取到图像格式");
        }

        return (buffer, info.DefaultExtension);
    }

    /// <summary>
    /// 获取到的html源码转成markdown
    /// </summary>
    /// <param name="html"></param>
    /// <returns></returns>
    protected virtual string HtmlToMarkdown(string html)
    {
        var config = new Config
        {
            Tags = { Unknown = Config.UnknownTagsOption.Bypass },
            GithubFlavored = true,
            Flavor = Config.MarkdownFlavor.CommonMark,
            Formatting = { RemoveComments = true },
            Links = { SmartHref = true },
        };
        var converter = new Converter(config);
        var markdown = converter.Convert(html);
        return markdown;
    }

    /// <summary>
    /// 发布文章
    /// </summary>
    /// <param name="feedUrl">文章地址</param>
    /// <param name="batchId">批次ID</param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    protected virtual async Task PublishArticleAsync(
        string feedUrl,
        string? batchId = null,
        CancellationToken cancellationToken = default
    )
    {
        var article = new CreateArticleRequest
        {
            Title = "",
            Markdown = "",
            From = feedUrl,
            Tags = Tags,
        };

        var author = await GetArticleContentAsync(article, feedUrl);
        if (author == null)
        {
            return;
        }
        var useAiAnalyze = configuration.GetValue("ArticleUesAIAnalyze", false);
        try
        {
            if (useAiAnalyze)
            {
                await AnalyzeArticleAsync(article, cancellationToken);
            }

            var message = new NewsArticleMessage
            {
                Title = article.Title,
                Markdown = article.Markdown,
                Introduction = article.Introduction,
                From = article.From,
                PublishTime = article.PublishTime ?? DateTime.Now,
                Tags = article.Tags,
                Author = author,
                AdWeight = article.AdWeight,
                Source = "Dpz.Core.Web.Jobs",
                BatchId = batchId,
                MainImageMetadata = article.MainImageMetadata,
                Images = article.Images,
            };

            await _messagePublisher.PublishAsync(message, cancellationToken: cancellationToken);
            await PushProgressMessage($"已发布文章: {article.Title}");
            Interlocked.Increment(ref _sentMessageCount);
        }
        catch (Exception ex)
        {
            await deleteMarkdownMessagePublisher.PublishAsync(
                new DeleteMarkdownMessage { MarkdownContents = [article.Markdown] },
                cancellationToken: cancellationToken
            );
            _logger.LogError(ex, "发送文章消息失败: {Title}", article.Title);
        }
    }

    private async Task AnalyzeArticleAsync(
        CreateArticleRequest article,
        CancellationToken cancellationToken = default
    )
    {
        var prompt = BuildAnalysisPrompt();
        var result = await openAiService.SendMessageAsync(
            messages: [new SystemChatMessage(prompt), new UserChatMessage(article.Markdown)],
            options: option =>
            {
                option.Model = "deepseek-v4-flash";
            },
            cancellationToken: cancellationToken
        );

        if (!result.Success || result.Data == null)
        {
            _logger.LogWarning(
                "发送AI分析失败,错误消息:{Message},参数:{@Arguments}",
                result.Message,
                result.Arguments
            );
            return;
        }

        if (
            !result.Data.TryDeserialize<ArticleAnalyze>(
                out var analyze,
                out var message,
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
            )
            || analyze == null
        )
        {
            _logger.LogWarning("解析AI结果失败,错误消息:{Message}", message);
            return;
        }

        _logger.LogInformation("AI分析结果:{@Result}", analyze);

        if (!string.IsNullOrEmpty(analyze.Introduction))
        {
            article.Introduction = analyze.Introduction;
        }

        article.AdWeight = analyze.AdWeight;

        if (analyze.AdWeight is > 0.7 and < 0.8)
        {
            _logger.LogWarning("广告概率:{AdWeight}%", analyze.AdWeight * 100);
        }

        if (analyze.AdWeight >= 0.8)
        {
            var dic = new Dictionary<string, string> { { "From", article.From ?? "" } };
            using (_logger.BeginScope(dic))
            {
                _logger.LogWarning("广告概率:{AdWeight}%", analyze.AdWeight * 100);
            }
        }
        await Task.Delay(300, cancellationToken);
    }

    /// <summary>
    /// 构建 AI 分析提示词
    /// </summary>
    private static string BuildAnalysisPrompt()
    {
        const string json = """
            { "category": "分类","introduction": "文章摘要","adWeight": 0.00 }
            """;
        return new StringBuilder()
            .AppendLine("帮我分析一下文章,并严格以以下JSON格式返回。")
            .AppendLine()
            .AppendLine("```json")
            .AppendLine(json)
            .AppendLine("```")
            .AppendLine()
            .AppendLine("字段说明:")
            .AppendLine("1. category :文章分类")
            .AppendLine("2. introduction :文章摘要")
            .AppendLine(
                "3. adWeight : 是否为广告,以权重来区分,权重包含小数,最小为0,即不是广告,最大为1,一定是广告"
            )
            .AppendLine()
            .AppendLine("------")
            .AppendLine("发送给你的内容是一篇新闻文章")
            .AppendLine()
            .AppendLine()
            .ToString();
    }

    /// <summary>
    /// 开始爬虫任务
    /// </summary>
    [ProlongExpirationTime]
    public async Task StartAsync()
    {
        _currentBatchId = $"batch_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
        _sentMessageCount = 0;
        var batchStartTime = DateTime.UtcNow;

        _logger.LogInformation("爬虫任务开始, BatchId={BatchId}", _currentBatchId);
        try
        {
            var source = await GetTaskUrlsAsync();
            _logger.LogInformation("获取到{Count}条数据", source.Count);

            var parallelOptions = new ParallelOptions
            {
                CancellationToken = CancellationToken.None,
            };

            var totalCount = source.Count;
            var requestCounter = 0;
            var processedCount = 0;

            await Parallel.ForEachAsync(
                source,
                parallelOptions,
                async (item, cancelToken) =>
                {
                    await _concurrencyLimiter.WaitAsync(cancelToken);
                    try
                    {
                        var current = Interlocked.Increment(ref processedCount);
                        var newProgress = (decimal)current / totalCount;

                        await _progressSemaphore.WaitAsync(cancelToken);
                        try
                        {
                            if (newProgress > Progress)
                            {
                                Progress = newProgress;
                            }
                        }
                        finally
                        {
                            _progressSemaphore.Release();
                        }

                        var myCount = Interlocked.Increment(ref requestCounter);
                        if (myCount % 3 == 0)
                        {
                            const int delay = 1500;
                            await PushProgressMessage(
                                $"等待{delay / 1000m}秒后继续执行",
                                MessageType.Info
                            );
                            await Task.Delay(delay, cancelToken);
                        }

                        await PublishArticleAsync(item, _currentBatchId, cancelToken);
                    }
                    finally
                    {
                        _concurrencyLimiter.Release();
                    }
                }
            );
        }
        catch (Exception exception)
        {
            _logger.LogError(exception, "任务异常结束");
            return;
        }
        finally
        {
            var batchEndTime = DateTime.UtcNow;

            _logger.LogInformation(
                "爬虫任务结束, BatchId={BatchId}, 成功发送{SentCount}条消息",
                _currentBatchId,
                _sentMessageCount
            );

            if (_sentMessageCount > 0)
            {
                var batchCompletionMessage = new BatchCompletionMessage
                {
                    BatchId = _currentBatchId,
                    TotalMessages = _sentMessageCount,
                    BatchType = MessageQueueBusinessType.Article,
                    StartTime = batchStartTime,
                    EndTime = batchEndTime,
                    Source = "Dpz.Core.Web.Jobs",
                    Metadata = new Dictionary<string, string>
                    {
                        ["Duration"] = (batchEndTime - batchStartTime).TotalSeconds.ToString("F2"),
                        ["Tags"] = string.Join(",", Tags),
                    },
                };

                await _batchCompletionPublisher.PublishAsync(batchCompletionMessage);

                _logger.LogInformation(
                    "批次完成消息已发送, BatchId={BatchId}, TotalMessages={TotalMessages}",
                    _currentBatchId,
                    _sentMessageCount
                );
            }
        }

        await PushProgressMessage("爬虫任务结束", MessageType.Over);
        _logger.LogInformation("爬虫任务结束");
    }

    public override object ActivateJob(Type jobType)
    {
        _logger.LogInformation("invoke activate job");
        return base.ActivateJob(jobType);
    }

    [ProlongExpirationTime]
    public async Task DeleteAsync()
    {
        await ArticleService.DeleteOldCnBetaAsync(12, 50);
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这段代码定义了一个抽象的 TaskService 类,它是一个用于爬取网页文章、处理内容、并发布到消息队列的后台任务系统的一部分。从命名空间和引用可以看出,它属于 Dpz.Core 项目,可能是一个内容聚合或新闻爬虫服务。下面将对这个类进行详细解释。


1. 类定义与构造函数

public abstract class TaskService(
    IArticleService articleService,
    IObjectStorageOperation objectStorageService,
    IHttpClientFactory httpClientFactory,
    IPushMessage pushMessage,
    ILoggerFactory logger,
    List<string> tags,
    IOpenAiService openAiService,
    IConfiguration configuration,
    IMessagePublisher<NewsArticleMessage> messagePublisher,
    IMessagePublisher<BatchCompletionMessage> batchCompletionPublisher,
    IMessagePublisher<DeleteMarkdownMessage> deleteMarkdownMessagePublisher,
    IImageFormatDetector imageFormatDetector
) : JobActivator
  • 这是一个抽象类,并且使用了 C# 12 的主构造函数语法。
  • 它继承自 JobActivator(可能来自 Hangfire 或类似的后台任务库),用于支持在后台作业中激活任务。
  • 构造函数注入了多个依赖服务,包括:
    • 文章服务
    • 对象存储操作(用于上传图片)
    • HTTP 客户端工厂
    • 消息推送服务(用于向前端推送进度)
    • 日志工厂
    • 标签列表(文章标签)
    • OpenAI 服务(用于 AI 分析文章)
    • 配置对象
    • 多个消息发布者(用于发布爬取结果、批次完成通知、删除 Markdown 的消息)
    • 图片格式检测器

这些依赖被存储在私有或受保护字段中,供类的内部方法使用。


2. 字段与属性

  • _logger:日志记录器,用于记录任务日志。
  • ArticleService:受保护的文章服务,子类可以使用。
  • HttpClientFactory:受保护的 HTTP 客户端工厂,用于爬取图片等。
  • _messagePublisher:发布文章消息的发布者。
  • _batchCompletionPublisher:发布批次完成消息的发布者。
  • _currentBatchId:当前批次的唯一标识,每次启动任务时生成。
  • _sentMessageCount:当前批次成功发布的消息数量。
  • Progress:爬取进度(0 到 1 的小数),用于向前端推送进度条。
  • _progressSemaphoreSemaphoreSlim,保证进度值的线程安全更新。
  • _concurrencyLimiterSemaphoreSlim,初始化为 3,限制同时爬取文章的最大并发数(3 个并发)。
  • Tags:文章标签列表。

3. 抽象方法(必须由子类实现)

  1. GetTaskUrlsAsync():返回一个异步任务,获取需要爬取的文章 URL 列表。
  2. GetArticleContentAsync(CreateArticleRequest article, string url):给定一个文章请求对象和 URL,抓取文章内容,返回作者信息(VmUserInfo?)。通常实现会填充 article 的标题、Markdown 内容等。

这两个抽象方法是模板模式的核心,由具体任务(如爬取 CNBeta 或其他网站)实现特定逻辑。


4. 核心方法详解

4.1 StartAsync() —— 爬虫任务主入口

这是最核心的方法,使用 [ProlongExpirationTime] 特性(可能是 Hangfire 的特性,防止任务因超时被终止)。

流程:

  1. 生成批次 ID:

    batch_20250321123000_abdc123...
    

    并重置成功计数和进度。

  2. 调用 GetTaskUrlsAsync() 获取待爬取 URL 集合。

  3. 使用 Parallel.ForEachAsync 并发处理每个 URL,但通过 _concurrencyLimiter 确保同时最多只有 3 个爬取任务运行。

  4. 在并发循环中:

    • 更新进度(通过 Interlocked 和信号量保证线程安全)。
    • 每处理 3 个 URL 就等待 1.5 秒,并推送一条“等待”信息,避免对目标网站造成过大压力。
    • 调用 PublishArticleAsync(url, batchId, token) 处理单个文章。
  5. 异常处理:整个批次若有任何未能捕获的异常,会记录错误日志并结束任务。

  6. finally

    • 记录任务结束日志。
    • 如果成功发布了至少一条消息,则构造 BatchCompletionMessage(包含批次 ID、文章总数、起止时间、来源、标签等元数据),并通过 _batchCompletionPublisher 发布。这用于通知下游系统批次已完成。
    • 推送“爬虫任务结束”消息(类型为 Over,表示进度条达到 100%)。

4.2 PublishArticleAsync() —— 抓取并发布单篇文章

这是单篇文章的处理流程:

  1. 创建一个 CreateArticleRequest 对象,设置初始标题为空,来源 URL,标签列表。

  2. 调用抽象方法 GetArticleContentAsync(article, feedUrl),让子类去实际抓取网页内容并填充 article 对象。

    • 若作者返回 null(可能抓取失败),则直接返回。
  3. AI 分析(可选):从配置中读取 ArticleUesAIAnalyze(注意拼写可能是 Use,但代码原文如此),如果为 true,则调用 AnalyzeArticleAsync 对文章进行 AI 分析,为文章添加摘要和广告权重。

  4. 构建 NewsArticleMessage 对象,包含文章的所有信息和作者信息。

  5. 通过 _messagePublisher.PublishAsync 将文章消息发送到消息队列(例如 RabbitMQ、Kafka 等)。

  6. 发送成功则推送“已发布文章: {标题}”的进度消息,并用 Interlocked.Increment 增加成功计数。

  7. 失败处理:如果消息发送失败,会发布一个 DeleteMarkdownMessage,其中包含文章的 Markdown 内容,这可能是为了清理已经上传到对象存储中的图片(防止孤立文件)。然后记录错误日志。

4.3 AnalyzeArticleAsync() —— AI 分析文章

利用 OpenAI 的聊天模型分析文章内容,返回结构化数据。

  • 构建一个分析提示词(BuildAnalysisPrompt),要求模型返回 JSON,包含分类、摘要、广告权重。
  • 调用 openAiService.SendMessageAsync,模型设置为 "deepseek-v4-flash"(看似是自定义或某个模型的名称)。
  • 如果响应成功,反序列化 JSON 为 ArticleAnalyze 对象,并填充 article 的摘要和广告权重。
  • 根据广告权重记录不同级别的警告:
    • >0.7 且 <0.8:记录警告。
    • >=0.8:记录带作用域的警告(使用结构化日志字典)。
  • 添加 300ms 延迟,避免频繁调用 API。

ArticleAnalyze 类型(未在代码片段中定义)应该包含 Introduction, AdWeight, Category 等属性。

4.4 DownloadImageToObjectStorageAsync() —— 下载并上传图片

用于将文章中的远程图片下载后上传到对象存储(如 BOS、S3 等),并返回新的元数据。

  1. HandleUrl(url, "x-bce-process")

    • 处理 URL,移除查询字符串中的 x-bce-process 参数(可能是某些对象存储的图片处理参数,比如锐化、缩放等)。
    • 如果 URL 为空或无法解析,返回 null
  2. 下载图片

    • 调用 ApplicationTools.RetryAsync,传一个 DownloadImageAsync 的异步委托,出现异常时每隔 1 秒重试一次(直到成功或达到默认重试次数)。
    • DownloadImageAsync 内部:
      • 使用名称为 "edge" 的 HttpClient 发送 GET 请求(可能配置了特殊 User-Agent 或代理)。
      • 如果响应不成功则抛出 BusinessException
      • 如果响应内容类型是 SVG,则读取成字节数组返回后缀 "svg";否则交给 RecognizeImageAsync 判断格式(通过 IImageFormatDetector 检测图片真实格式)。
  3. 上传图片

    • 再次使用 RetryAsync 调用 UploadImageAsync(buffer, fileExtension)
    • UploadImageAsync 生成路径:在 Debug 模式下路径包含 Test/images/yyyy/M/d;Release 模式下不包含 Test
    • 文件名使用 ObjectId.GenerateNewId()(MongoDB 的 ObjectId),保证唯一。
    • 调用 objectStorageService.UploadAsync 上传,返回 ImageMetadata(包含新的 URL 等)。
  4. 若整个流程出现异常,则推送错误消息并记录日志,返回 null

4.5 HtmlToMarkdown() —— HTML 转 Markdown

使用 ReverseMarkdown 库将 HTML 源码转换为 Markdown 格式。配置项包括:

  • 未知标签直接绕过,保留原本内容。
  • 启用 GitHub Flavored Markdown。
  • 使用 CommonMark 风格。
  • 移除注释。
  • 智能处理链接。

该方法方便将网页正文内容转为 Markdown 格式以存入文章系统。

4.6 HandleUrl() —— 处理 URL

  • 去除 URL 中指定的查询参数(例如图片处理参数)。
  • 如果 URL 以 // 开头,自动补充 https:
  • 如果不能解析为合法 Uri,记录日志并返回 null
  • 返回不含这些查询参数的新 URL 的 Uri 对象。

4.7 PushProgressMessage() —— 推送进度消息

  • 构造 ProgressMessage 对象(包含进度数值、消息内容、消息类型)。
  • 消息类型若为 Over 则进度固定为 1,否则取当前进度。
  • 通过 pushMessage.PushCnBetaMessageAsync 将进度推送给前端(可能是 WebSocket 或 SignalR 机制,用于 UI 显示)。

4.8 RecognizeImageAsync() —— 识别图片格式

  • 将流复制到 MemoryStream 得到字节数组。
  • 使用 imageFormatDetector.Detect 检测图片格式。
  • 如果未识别,抛出 BusinessException
  • 返回字节数组和默认扩展名(如 jpg、png、webp 等)。

4.9 DeleteAsync() —— 清理旧数据

  • 一个独立方法,也带有 [ProlongExpirationTime],调用 ArticleService.DeleteOldCnBetaAsync(12, 50)
  • 可能用于删除旧文章,参数 12 和 50 可能是保留最近 12 天,或删除超过 50 篇等含义,具体要看服务实现。

5. 其他方法

5.1 ActivateJob(Type jobType)

  • 重写自 JobActivator,在任务激活时记录日志,并调用基类实现。这对于调试非常有帮助。

5.2 BuildAnalysisPrompt()(私有静态)

  • 返回一个固定的字符串提示词,要求 AI 分析新闻文章,并返回严格的 JSON 格式。提示词中定义了字段的含义,特别是 adWeight 广告权重。

6. 整体设计模式与作用

模板方法模式

TaskService 是一个抽象基类,定义了爬虫任务的骨架:

  • 获取 URL → 限流并发抓取 → 内容清洗 → 图片处理 → AI 分析 → 发布消息。
  • 子类只需要实现 GetTaskUrlsAsync()GetArticleContentAsync() 即可接入一个新的新闻源。

支持多批次与进度上报

  • 每次任务启动生成一个 BatchId,用于将多个消息聚合为一个批次。
  • 任务结束后通过 BatchCompletionMessage 通知下游消费者(如聚合入库、索引等)。
  • 实时将进度推送给前端显示。

容错与资源清理

  • 图片下载重试,防止网络抖动。
  • 消息发送失败时删除可能上传的图片(通过发布 DeleteMarkdownMessage),避免残留垃圾数据。
  • 并发限制为 3,避免对来源网站攻击性爬取。

依赖注入

利用构造函数注入所有依赖,且将公共依赖服务暴露给子类(ArticleService, HttpClientFactory),遵循 DI 原则,便于测试和维护。


7. 关键点总结

  • “文章”:通常以 CreateArticleRequest 承载,包含标题、Markdown、来源 URL、标签、摘要、广告权重、发布时间、图片列表等。
  • “作者信息”VmUserInfo 可能包含作者昵称、头像等信息,来自爬取结果。
  • “消息队列”:这套系统采用消息驱动架构,爬虫将文章作为消息发布,后续的服务(如 API、索引器)会消费消息执行实际存储与内容管理。
  • AI 分析:引入大语言模型判断文章是否为广告,并生成摘要,提高内容质量。
  • 对象存储:将爬取的图片重新上传到自己的对象存储,避免外部图片失效或防盗链问题,同时去除外部处理参数。

8. 可能的问题/改进点

  • 使用 Parallel.ForEachAsyncSemaphoreSlim 实现并发限制,但未看到动态控制并发量的选项(固定 3)。
  • 进度更新基于已开始处理的 URL 数,而不是真正完成的 URL 数,可能导致显示不准确。
  • HandleUrl 中参数顺序:params string[] key 调用 "x-bce-process",可能只删除了与图片处理相关的参数。
  • AI 模型名 "deepseek-v4-flash" 看起来不规范,可能笔误。
  • 代码中的 ArticleUesAIAnalyze 拼写错误,应为 ArticleUseAIAnalyze,但配置键如果保持一致也可以工作。

总结

TaskService 是一个高内聚、可扩展的爬虫任务基类,使用了现代 C# 特性(主构造函数、并行异步、联合取消),集成了多项复杂服务(HTTP、对象存储、消息队列、AI),帮助系统定期从网络中获取文章,自动处理图片、AI分析垃圾内容,并以批次为单位向业务系统分发消息。它是典型的内容采集与管道处理系统的中枢组件。

评论加载中...