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);
}
}
这段代码定义了一个抽象的 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 的小数),用于向前端推送进度条。_progressSemaphore:SemaphoreSlim,保证进度值的线程安全更新。_concurrencyLimiter:SemaphoreSlim,初始化为 3,限制同时爬取文章的最大并发数(3 个并发)。Tags:文章标签列表。
3. 抽象方法(必须由子类实现)
GetTaskUrlsAsync():返回一个异步任务,获取需要爬取的文章 URL 列表。GetArticleContentAsync(CreateArticleRequest article, string url):给定一个文章请求对象和 URL,抓取文章内容,返回作者信息(VmUserInfo?)。通常实现会填充article的标题、Markdown 内容等。
这两个抽象方法是模板模式的核心,由具体任务(如爬取 CNBeta 或其他网站)实现特定逻辑。
4. 核心方法详解
4.1 StartAsync() —— 爬虫任务主入口
这是最核心的方法,使用 [ProlongExpirationTime] 特性(可能是 Hangfire 的特性,防止任务因超时被终止)。
流程:
生成批次 ID:
batch_20250321123000_abdc123...并重置成功计数和进度。
调用
GetTaskUrlsAsync()获取待爬取 URL 集合。使用
Parallel.ForEachAsync并发处理每个 URL,但通过_concurrencyLimiter确保同时最多只有 3 个爬取任务运行。在并发循环中:
- 更新进度(通过
Interlocked和信号量保证线程安全)。 - 每处理 3 个 URL 就等待 1.5 秒,并推送一条“等待”信息,避免对目标网站造成过大压力。
- 调用
PublishArticleAsync(url, batchId, token)处理单个文章。
- 更新进度(通过
异常处理:整个批次若有任何未能捕获的异常,会记录错误日志并结束任务。
finally块:- 记录任务结束日志。
- 如果成功发布了至少一条消息,则构造
BatchCompletionMessage(包含批次 ID、文章总数、起止时间、来源、标签等元数据),并通过_batchCompletionPublisher发布。这用于通知下游系统批次已完成。 - 推送“爬虫任务结束”消息(类型为
Over,表示进度条达到 100%)。
4.2 PublishArticleAsync() —— 抓取并发布单篇文章
这是单篇文章的处理流程:
创建一个
CreateArticleRequest对象,设置初始标题为空,来源 URL,标签列表。调用抽象方法
GetArticleContentAsync(article, feedUrl),让子类去实际抓取网页内容并填充article对象。- 若作者返回
null(可能抓取失败),则直接返回。
- 若作者返回
AI 分析(可选):从配置中读取
ArticleUesAIAnalyze(注意拼写可能是Use,但代码原文如此),如果为true,则调用AnalyzeArticleAsync对文章进行 AI 分析,为文章添加摘要和广告权重。构建
NewsArticleMessage对象,包含文章的所有信息和作者信息。通过
_messagePublisher.PublishAsync将文章消息发送到消息队列(例如 RabbitMQ、Kafka 等)。发送成功则推送“已发布文章: {标题}”的进度消息,并用
Interlocked.Increment增加成功计数。失败处理:如果消息发送失败,会发布一个
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 等),并返回新的元数据。
HandleUrl(url, "x-bce-process"):- 处理 URL,移除查询字符串中的
x-bce-process参数(可能是某些对象存储的图片处理参数,比如锐化、缩放等)。 - 如果 URL 为空或无法解析,返回
null。
- 处理 URL,移除查询字符串中的
下载图片:
- 调用
ApplicationTools.RetryAsync,传一个DownloadImageAsync的异步委托,出现异常时每隔 1 秒重试一次(直到成功或达到默认重试次数)。 DownloadImageAsync内部:- 使用名称为
"edge"的 HttpClient 发送 GET 请求(可能配置了特殊 User-Agent 或代理)。 - 如果响应不成功则抛出
BusinessException。 - 如果响应内容类型是 SVG,则读取成字节数组返回后缀
"svg";否则交给RecognizeImageAsync判断格式(通过IImageFormatDetector检测图片真实格式)。
- 使用名称为
- 调用
上传图片:
- 再次使用 RetryAsync 调用
UploadImageAsync(buffer, fileExtension)。 UploadImageAsync生成路径:在 Debug 模式下路径包含Test/images/yyyy/M/d;Release 模式下不包含Test。- 文件名使用
ObjectId.GenerateNewId()(MongoDB 的 ObjectId),保证唯一。 - 调用
objectStorageService.UploadAsync上传,返回ImageMetadata(包含新的 URL 等)。
- 再次使用 RetryAsync 调用
若整个流程出现异常,则推送错误消息并记录日志,返回
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.ForEachAsync与SemaphoreSlim实现并发限制,但未看到动态控制并发量的选项(固定 3)。 - 进度更新基于已开始处理的 URL 数,而不是真正完成的 URL 数,可能导致显示不准确。
HandleUrl中参数顺序:params string[] key调用"x-bce-process",可能只删除了与图片处理相关的参数。- AI 模型名
"deepseek-v4-flash"看起来不规范,可能笔误。 - 代码中的
ArticleUesAIAnalyze拼写错误,应为ArticleUseAIAnalyze,但配置键如果保持一致也可以工作。
总结
TaskService 是一个高内聚、可扩展的爬虫任务基类,使用了现代 C# 特性(主构造函数、并行异步、联合取消),集成了多项复杂服务(HTTP、对象存储、消息队列、AI),帮助系统定期从网络中获取文章,自动处理图片、AI分析垃圾内容,并以批次为单位向业务系统分发消息。它是典型的内容采集与管道处理系统的中枢组件。