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.*?>)
技术特点
- 异步处理: 全程使用 async/await
- 重试机制: 使用
ApplicationTools.RetryAsync处理网络请求失败 - 并行处理: 使用
Parallel.ForEachAsync并行下载图片 - 流量控制: 每处理 8 张图片延迟 1 秒
- 错误处理: 完善的异常捕获和日志记录
- 进度通知: 通过
PushProgressMessage推送处理进度 - 内容过滤: 过滤广告、旧文章等无效内容
使用场景
这是一个 Hangfire 定时任务,用于自动化采集 IT之家 的新闻文章,适合构建新闻聚合平台或内容管理系统。
AI 正在分析代码…
评论加载中...