using System.Text.RegularExpressions;
using AngleSharp;
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.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.Services;
using JetBrains.Annotations;
using Microsoft.Toolkit.Parsers.Rss;
using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;

namespace Dpz.Core.Web.Jobs.Hangfire;

[UsedImplicitly]
public sealed partial class ItHomeActivator(
    IArticleService articleService,
    IObjectStorageOperation objectStorageService,
    IHttpClientFactory httpClientFactory,
    IPushMessage pushMessage,
    ILoggerFactory logger,
    IOpenAiService openAiService,
    IConfiguration configuration,
    IMessagePublisher<NewsArticleMessage> messagePublisher,
    IMessagePublisher<BatchCompletionMessage> batchCompletionPublisher,
    IMessagePublisher<DeleteMarkdownMessage> deleteMarkdownMessagePublisher,
    IImageFormatDetector imageFormatDetector
)
    : TaskService(
        articleService,
        objectStorageService,
        httpClientFactory,
        pushMessage,
        logger,
        ["ItHome"],
        openAiService,
        configuration,
        messagePublisher,
        batchCompletionPublisher,
        deleteMarkdownMessagePublisher,
        imageFormatDetector
    )
{
    private readonly ILogger<ItHomeActivator> _logger = logger.CreateLogger<ItHomeActivator>();
    private readonly IConfiguration _configuration = configuration;

    protected override async Task<IReadOnlyCollection<string>> GetTaskUrlsAsync()
    {
        string content;
        try
        {
            content = await ApplicationTools.RetryAsync(
                async () =>
                {
                    var httpClient = HttpClientFactory.CreateClient("edge");
                    var request = new HttpRequestMessage(
                        HttpMethod.Get,
                        "https://www.ithome.com/rss/"
                    );
                    var response = await httpClient.SendAsync(request);
                    if (!response.IsSuccessStatusCode)
                    {
                        throw new HttpRequestException(
                            $"RSS请求失败,状态码: {response.StatusCode}"
                        );
                    }
                    return await response.Content.ReadAsStringAsync();
                },
                retryInterval: TimeSpan.FromSeconds(2)
            );
        }
        catch (Exception e)
        {
            await PushProgressMessage("获取RSS源失败", type: MessageType.Error);
            _logger.LogError(e, "获取RSS源失败");
            return [];
        }
        if (string.IsNullOrEmpty(content))
        {
            return [];
        }

        var parser = new RssParser();
        var feedUrls = parser.Parse(content).Select(x => x.FeedUrl).ToList();

        var noExists = await ArticleService.NoExistsByFromAsync(feedUrls);
        feedUrls = feedUrls.IntersectBy(noExists, x => x).ToList();
        return feedUrls;
    }

    private VmUserInfo GetUserInfo(string userName)
    {
        var cdnHost = _configuration["S3:CdnHost"];
        return new VmUserInfo
        {
            Id = "itHome",
            Avatar = $"{cdnHost}/images/ItHome.svg",
            Sex = Sex.Man,
            Sign = "cnBeta.COM - IT之家,青岛软媒旗下的前沿科技门户网站。",
            Name = userName,
            Key = "",
        };
    }

    protected override async Task<VmUserInfo?> GetArticleContentAsync(
        CreateArticleRequest article,
        string url
    )
    {
        string html;

        try
        {
            html = await ApplicationTools.RetryAsync(
                async () =>
                {
                    var httpClient = HttpClientFactory.CreateClient("edge");
                    var request = new HttpRequestMessage(HttpMethod.Get, url);
                    var response = await httpClient.SendAsync(request);

                    if (!response.IsSuccessStatusCode)
                    {
                        throw new HttpRequestException(
                            $"文章请求失败,状态码: {response.StatusCode}"
                        );
                    }
                    return await response.Content.ReadAsStringAsync();
                },
                retryInterval: TimeSpan.FromSeconds(2)
            );
        }
        catch (Exception e)
        {
            await PushProgressMessage($"获取文章内容失败:{url}");
            _logger.LogError(e, "获取文章内容失败:{Url}", url);
            return null;
        }

        if (string.IsNullOrEmpty(html))
        {
            return null;
        }

        // 解析Html源码,获取文章内容,查找文章所有图片,并上传到DB然后替换
        var context = BrowsingContext.New(Configuration.Default);
        var htmlDoc = await context.OpenAsync(x => x.Content(html));

        VmUserInfo? AdContent(string reason)
        {
            _logger.LogInformation("文章:{Url}{Reason},跳过", url, reason);
            return null;
        }

        var ad1 = htmlDoc.QuerySelector("#paragraph > div.tagging1");
        var ad2 = htmlDoc.QuerySelector("#paragraph > dir");
        if (ad1 != null || ad2 != null)
        {
            return AdContent("疑似广告");
        }

        // 正文
        var articleBody = htmlDoc.QuerySelector("#paragraph");
        if (articleBody == null)
        {
            return null;
        }

        // 移除不需要的元素
        var remove1 = articleBody.QuerySelector("#paragraph > div");
        remove1?.Remove();

        articleBody.GetElementsByTagName("iframe").ForEach(x => x.Remove());
        articleBody.GetElementsByTagName("video").ForEach(x => x.Remove());

        articleBody
            .GetElementsByTagName("a")
            .ForEach(x =>
            {
                var href = x.GetAttribute("href");
                if (
                    !string.IsNullOrEmpty(href)
                    && Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)
                    && uri.Host.Equals("www.ithome.com", StringComparison.OrdinalIgnoreCase)
                    && x.TextContent.Trim() == "IT之家"
                )
                {
                    x.Remove();
                }
            });

        articleBody
            .GetElementsByTagName("p")
            .ForEach(x =>
            {
                var other = x.GetAttribute("class");
                if (!string.IsNullOrEmpty(other))
                {
                    x.Remove();
                }
            });

        // 标题
        var title = htmlDoc.QuerySelector("#dt > div.fl.content > h1")?.TextContent;
        if (string.IsNullOrWhiteSpace(title))
        {
            await PushProgressMessage("获取文章标题失败,跳过");
            _logger.LogWarning("获取文章标题失败,访问地址:{Url}", url);
            return null;
        }
        article.Title = title;
        await PushProgressMessage($"获取《{title}》正文内容");

        // 简介 默认取第一段
        article.Introduction = htmlDoc.QuerySelector("#paragraph > p")?.TextContent;

        // 发布时间
        article.PublishTime = DateTime.TryParse(
            htmlDoc.QuerySelector("#pubtime_baidu")?.TextContent,
            out var publishDate
        )
            ? publishDate
            : DateTime.Now;

        var minDateTime = DateTime.Now.AddDays(-7);
        if (article.PublishTime < minDateTime)
        {
            _logger.LogInformation("发布时间晚于{MinDateTime},跳过", minDateTime);
            return null;
        }

        var imageElements = articleBody.GetElementsByTagName("img").ToList();

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

        var imageCounter = 0;
        var imageIndex = 0;
        var images = new ImageMetadata?[imageElements.Count];

        await Parallel.ForEachAsync(
            imageElements,
            parallelOptions,
            async (image, cancellationToken) =>
            {
                var idx = Interlocked.Increment(ref imageIndex);

                var myCount = Interlocked.Increment(ref imageCounter);
                if (myCount % 8 == 0)
                {
                    await Task.Delay(1000, cancellationToken);
                }

                await PushProgressMessage($"正在获取该文章第{idx}张图片,并上传到数据库。");
                var src = image.Attributes["data-original"];
                if (!string.IsNullOrEmpty(src?.Value))
                {
                    var imageMetadata = await DownloadImageToObjectStorageAsync(src.Value);
                    if (
                        imageMetadata.HasValue
                        && imageMetadata.Value.Format.Kind != ImageFormatKind.Unknown
                        && !string.IsNullOrEmpty(imageMetadata.Value.Url)
                    )
                    {
                        await PushProgressMessage(
                            $"image url :{imageMetadata.Value.Url}",
                            MessageType.Info
                        );
                        image.SetAttribute("src", imageMetadata.Value.Url);
                        images[idx - 1] = imageMetadata.Value;
                    }
                    else
                    {
                        image.Remove();
                    }
                }
                else
                {
                    image.Remove();
                    await PushProgressMessage(
                        $"该文章第{idx}张图片无明确资源地址,已剔除!",
                        MessageType.Info
                    );
                }
            }
        );

        await PushProgressMessage("图片替换完成,正在发布");

        article.Images.AddRange(images.OfType<ImageMetadata>());
        article.MainImageMetadata = article.Images.Count > 0 ? article.Images[0] : null;

        var articleContent = FilterLinkRegex().Replace(articleBody.InnerHtml, "");
        var markdown = HtmlToMarkdown(articleContent);
        article.Markdown = markdown;
        // 获取文章作者信息
        var author = htmlDoc.QuerySelector("#author_baidu > strong")?.TextContent ?? "ItHome";
        var userInfo = GetUserInfo(author);
        return userInfo;
    }

    [GeneratedRegex(@"(<\/?a.*?>)|(<\/?span.*?>)")]
    private static partial Regex FilterLinkRegex();
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

代码解释

这是一个用于抓取和处理 IT之家 网站文章的后台任务服务类。主要功能是从 RSS 源获取文章列表,下载文章内容,处理图片,并转换为 Markdown 格式存储。

类结构

继承关系

  • 继承自 TaskService 基类
  • 实现了文章抓取的具体逻辑

依赖注入

通过构造函数注入了多个服务:

  • IArticleService: 文章服务
  • IObjectStorageOperation: 对象存储服务
  • IHttpClientFactory: HTTP 客户端工厂
  • IPushMessage: 消息推送服务
  • IOpenAiService: AI 服务
  • 多个消息发布器: 用于发布不同类型的消息
  • IImageFormatDetector: 图片格式检测器

核心方法

1. GetTaskUrlsAsync()

功能: 从 RSS 源获取待处理的文章 URL 列表

流程:

  • 请求 IT之家的 RSS 源(https://www.ithome.com/rss/)
  • 使用 RssParser 解析 RSS 内容
  • 过滤掉数据库中已存在的文章
  • 返回新文章的 URL 列表

异常处理: 如果获取失败,记录错误日志并推送错误消息

2. GetUserInfo()

功能: 构建文章作者信息

返回: 包含作者 ID、头像、性别、签名等信息的 VmUserInfo 对象

3. GetArticleContentAsync()

功能: 核心方法,抓取并处理文章内容

详细流程:

a. 下载 HTML 内容

- 使用重试机制请求文章 URL
- 失败时记录日志并返回 null

b. 解析 HTML 文档

使用 AngleSharp 库解析 HTML:

var context = BrowsingContext.New(Configuration.Default);
var htmlDoc = await context.OpenAsync(x => x.Content(html));

c. 广告过滤

检查特定元素,判断是否为广告文章:

var ad1 = htmlDoc.QuerySelector("#paragraph > div.tagging1");
var ad2 = htmlDoc.QuerySelector("#paragraph > dir");

d. 清理无用内容

  • 移除 iframe 和 video 标签
  • 移除指向 IT之家 的链接
  • 移除带有 class 属性的 p 标签

e. 提取文章元数据

  • 标题: #dt > div.fl.content > h1
  • 简介: 第一段内容
  • 发布时间: #pubtime_baidu 元素的内容
  • 过滤掉 7 天前的旧文章

f. 图片处理(并行处理)

await Parallel.ForEachAsync(images, parallelOptions, async (image, cancellationToken) => {
    // 1. 获取图片 URL (data-original 属性)
    // 2. 下载图片并上传到对象存储
    // 3. 替换图片 src 为新 URL
    // 4. 无效图片则移除
    // 5. 每 8 张图片延迟 1 秒(防止请求过快)
});

g. 内容转换

  • 使用正则表达式过滤 <a><span> 标签
  • 将 HTML 转换为 Markdown 格式
  • 设置文章的主图片

h. 返回作者信息

#author_baidu > strong 元素提取作者名,默认为 "ItHome"

4. FilterLinkRegex()

功能: 生成正则表达式,用于过滤链接和 span 标签

正则模式: (<\/?a.*?>)|(<\/?span.*?>)

技术特点

  1. 异步处理: 全程使用 async/await
  2. 重试机制: 使用 ApplicationTools.RetryAsync 处理网络请求失败
  3. 并行处理: 使用 Parallel.ForEachAsync 并行下载图片
  4. 流量控制: 每处理 8 张图片延迟 1 秒
  5. 错误处理: 完善的异常捕获和日志记录
  6. 进度通知: 通过 PushProgressMessage 推送处理进度
  7. 内容过滤: 过滤广告、旧文章等无效内容

使用场景

这是一个 Hangfire 定时任务,用于自动化采集 IT之家 的新闻文章,适合构建新闻聚合平台或内容管理系统。

评论加载中...