简介:这是一份面向C#初学者与进阶开发者的学习型网络爬虫项目源码,聚焦Web数据采集核心能力训练,适用于课程设计、毕业设计及实际工具开发场景。资源包含867个文件,主体为413个C#源码文件(.cs),辅以34个项目配置文件(.csproj)、7个解决方案文件(.sln)、22个文本说明(.txt)及59个HTML示例页,完整呈现从HTTP请求、HtmlAgilityPack解析、多线程调度到本地存储的全流程实现;压缩包仅5.15MB,轻量易部署。已有156人学习下载,项目结构规范,按Models、Services、Utilities、Configs等模块分层组织,内置单元测试(Tests目录)与构建脚本(.build文件),并涵盖代理配置、异常日志、反爬基础策略等工程化实践要点,可直接运行、调试与二次扩展。
1. 为什么用 C# 写网络爬虫不是“反常识”,而是一线工业场景里的务实选择?
很多人看到“C# 网络爬虫”第一反应是:Python 不香吗?Scrapy、Requests、BeautifulSoup 三件套一敲就跑,C# 做爬虫是不是大炮打蚊子?——这其实是把技术选型和场景割裂开了。真实产线里,大量 Windows 桌面端系统、MES 上位机、工控数据采集平台、企业内网报表生成工具,底层就是 .NET Framework 或 .NET 6+ 的 WinForms/WPF 应用;它们要从内部 OA、ERP、PLM 系统(比如用 ASP.NET MVC 构建的老旧后台)抓取结构化数据,或定时拉取 OPC UA 服务器暴露的 XML 接口、JSON-RPC 端点、甚至带 Form 表单验证的 Web 页面,这时候硬塞 Python 脚本进去,得搭环境、配 PATH、处理 DLL 依赖、跨进程通信……反而比直接用HttpClient+HtmlAgilityPack+System.Text.Json写一个可双击运行的.exe更慢、更不可靠。
这个标题里的“基于 C# 网络爬虫软件程序设计”,本质不是教你怎么写个通用爬虫框架,而是解决一类具体问题:在已有 .NET 技术栈的封闭/半封闭企业环境中,用最小侵入方式,把网页、API、XML/JSON 接口的数据,稳定、可维护、可调试地“搬”进本地数据库或 Excel 报表里。它不追求高并发、分布式、反爬对抗,但必须扛住 IE 内核兼容性、证书错误、302 跳转链断裂、Cookie 失效、CSRF Token 动态刷新这些“玄学”问题。源代码包(.zip)里真正值钱的,从来不是那几行GetAsync(),而是CookieContainer的生命周期管理、HttpClientHandler的 SSL/TLS 版本锁定、HtmlDocument解析时对 malformed HTML 的容错策略——这些才是你上线后不被半夜电话叫醒的关键。
适合谁?不是刚学 C# 的学生照着《C# 程序设计》抄控制台输出,而是:
✅ 已接手一个用 WinForms 做的设备监控软件,老板说“把车间日报页的数据自动导出到 Excel”;
✅ 正在开发上位机,需要从西门子 S7-1500 的 Web Server(HTTP 接口)读取诊断日志;
✅ 维护一套老 OA 系统,其审批流状态只在网页表格里显示,没提供 API,但必须每天 8:00 同步到 SQL Server。
——这类需求,C# 爬虫不是“能用”,而是“最省心”。
2. 从零启动:用 .NET 6+ 搭建可调试、可部署的爬虫核心骨架
2.1 为什么选 .NET 6+ 而非 .NET Framework?三个硬约束决定的
很多老项目还在用 .NET Framework 4.7.2,但新写的爬虫程序,我一律推荐从 .NET 6 开始。这不是赶时髦,而是三个现实约束逼出来的:
- TLS 版本兼容性:大量企业内网系统(尤其是老旧 Java WebLogic 或 IIS 7.5 部署的后台)只支持 TLS 1.2,而 .NET Framework 默认启用 TLS 1.0/1.1,强行调用会抛
AuthenticationException。.NET 6 默认禁用 TLS 1.0/1.1,且可通过AppContext.SetSwitch("System.Net.Http.UseTransportSecurity", true)精确控制。 - HttpClient 生命周期管理:Framework 下
HttpClient长期滥用导致 socket 耗尽是经典翻车现场;.NET 6 内置IHttpClientFactory,配合 DI 容器自动复用连接池,避免SocketException: An operation on a socket could not be performed because the system lacked sufficient buffer space。 - JSON 解析性能与易用性:
System.Text.Json在 .NET 6 中已完全成熟,序列化速度比 Newtonsoft.Json 快 2–3 倍,且原生支持JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase,对接主流 REST API 无需额外转换层。
提示:如果你必须兼容 Framework(比如客户明确要求 .NET 4.6.1),请跳过本节,直接看第 4 章的兼容方案;但请做好心理准备——你要手动管理
ServicePointManager.SecurityProtocol和HttpClient实例池。
2.2 创建最小可运行项目:命令行 + 依赖注入 + 配置驱动
我们不建 WinForms 界面,先用控制台项目验证核心逻辑。这样便于单元测试、CI/CD 集成,也方便后期包装成 Windows Service。
dotnet new console -n CrawlerCore -f net6.0 cd CrawlerCore dotnet add package HtmlAgilityPack --version 1.11.40 dotnet add package Microsoft.Extensions.DependencyInjection --version 6.0.0 dotnet add package Microsoft.Extensions.Configuration.Json --version 6.0.0 dotnet add package Microsoft.Extensions.Logging.Console --version 6.0.0关键不是装包,而是初始化顺序:HttpClient必须通过IHttpClientFactory获取,不能new HttpClient();配置必须从appsettings.json加载,不能硬编码 URL 和超时;日志必须用ILogger<T>,方便后期接入 Serilog 或写入文件。
Program.cs主入口(精简版,保留 DI 核心):
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; var host = Host.CreateDefaultBuilder(args) .ConfigureServices((context, services) => { // 1. 注册配置(读 appsettings.json) services.Configure<CrawlerOptions>(context.Configuration.GetSection("Crawler")); // 2. 注册 HttpClientFactory(关键!) services.AddHttpClient<WebClientService>(client => { client.BaseAddress = new Uri(context.Configuration["Crawler:BaseUrl"] ?? "https://example.com/"); client.Timeout = TimeSpan.FromSeconds(30); }) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { // 强制 TLS 1.2,绕过老旧服务器的协议协商失败 SslProtocols = System.Security.Authentication.SslProtocols.Tls12, // 自动处理 Cookie(登录态维持必需) UseCookies = true, CookieContainer = new CookieContainer() }); // 3. 注册业务服务 services.AddScoped<WebClientService>(); services.AddScoped<DataProcessor>(); }) .Build(); // 执行爬取逻辑 var crawler = host.Services.GetRequiredService<WebClientService>(); await crawler.RunAsync();appsettings.json示例(注意:生产环境务必用appsettings.Production.json分离敏感配置):
{ "Logging": { "LogLevel": { "Default": "Information" } }, "Crawler": { "BaseUrl": "https://intranet.oa.company/", "LoginUrl": "/login", "DataUrl": "/report/daily?date={0}", "TimeoutSeconds": 30, "MaxRetry": 3 } }参数说明:
BaseUrl是根域名,所有请求路径拼接在此之后;DataUrl中的{0}是日期占位符,后续用string.Format()替换;MaxRetry不是 HttpClient 内置重试,而是业务层手动循环——因为某些 503 错误需等待 2 秒再试,而Polly策略太重,此处用简单 while 循环更可控。
2.3 核心服务类:WebClientService —— 封装登录、请求、解析三步闭环
爬虫不是发个 GET 就完事。真实场景中,90% 的目标网站需要先 POST 登录表单获取 Cookie,再用该 Cookie 访问受保护页面。WebClientService就是干这个的:它持有IHttpClientFactory创建的客户端,封装了登录态维持、错误重试、HTML 解析入口。
public class WebClientService { private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger<WebClientService> _logger; private readonly CrawlerOptions _options; public WebClientService(IHttpClientFactory httpClientFactory, ILogger<WebClientService> logger, IOptions<CrawlerOptions> options) { _httpClientFactory = httpClientFactory; _logger = logger; _options = options.Value; } public async Task RunAsync() { try { // Step 1: 登录(POST 表单) await LoginAsync(); // Step 2: 获取数据页(GET 带日期参数) var today = DateTime.Today.ToString("yyyy-MM-dd"); var dataUrl = string.Format(_options.DataUrl, today); var html = await GetHtmlAsync(dataUrl); // Step 3: 解析 HTML 表格 var rows = ParseTable(html); _logger.LogInformation("成功解析 {Count} 行数据", rows.Count); // Step 4: 保存(此处简化为打印,实际调 DataProcessor) foreach (var row in rows.Take(5)) Console.WriteLine($"[{row.Date}] {row.Machine}: {row.Status}"); } catch (Exception ex) { _logger.LogError(ex, "爬取流程异常终止"); throw; } } private async Task LoginAsync() { var client = _httpClientFactory.CreateClient(); var loginUri = new Uri(_options.BaseUrl + _options.LoginUrl); // 构造登录表单(常见字段:username/password/__RequestVerificationToken) var content = new FormUrlEncodedContent(new Dictionary<string, string> { ["username"] = "admin", ["password"] = "123456", ["__RequestVerificationToken"] = await GetAntiForgeryToken(client, loginUri) }); var response = await client.PostAsync(loginUri, content); response.EnsureSuccessStatusCode(); // 若返回 401/403,此处直接抛异常 _logger.LogInformation("登录成功,Cookie 已自动保存"); } private async Task<string> GetAntiForgeryToken(HttpClient client, Uri loginUri) { // 先 GET 登录页,提取隐藏字段 __RequestVerificationToken var html = await client.GetStringAsync(loginUri); var doc = new HtmlDocument(); doc.LoadHtml(html); var tokenNode = doc.DocumentNode.SelectSingleNode("//input[@name='__RequestVerificationToken']"); return tokenNode?.GetAttributeValue("value", "") ?? ""; } private async Task<string> GetHtmlAsync(string url) { var client = _httpClientFactory.CreateClient(); var fullUrl = new Uri(_options.BaseUrl + url); for (int i = 0; i < _options.MaxRetry; i++) { try { var response = await client.GetAsync(fullUrl); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); } catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable && i < _options.MaxRetry - 1) { _logger.LogWarning("第 {Retry} 次请求 {Url} 失败,{Delay}s 后重试", i + 1, url, 2); await Task.Delay(TimeSpan.FromSeconds(2)); } } throw new InvalidOperationException($"重试 {_options.MaxRetry} 次后仍无法访问 {url}"); } private List<ReportRow> ParseTable(string html) { var doc = new HtmlDocument(); doc.LoadHtml(html); var rows = new List<ReportRow>(); var table = doc.DocumentNode.SelectSingleNode("//table[@id='daily-report']"); // 定位目标表格 if (table == null) throw new InvalidOperationException("未找到 ID 为 daily-report 的表格"); foreach (var rowNode in table.SelectNodes("tr").Skip(1)) // 跳过表头 { var cells = rowNode.SelectNodes("td"); if (cells?.Count < 4) continue; // 至少 4 列:日期、设备、状态、备注 rows.Add(new ReportRow { Date = cells[0].InnerText.Trim(), Machine = cells[1].InnerText.Trim(), Status = cells[2].InnerText.Trim(), Remark = cells[3].InnerText.Trim() }); } return rows; } } public class CrawlerOptions { public string BaseUrl { get; set; } = ""; public string LoginUrl { get; set; } = "/login"; public string DataUrl { get; set; } = "/report/daily?date={0}"; public int MaxRetry { get; set; } = 3; } public class ReportRow { public string Date { get; set; } = ""; public string Machine { get; set; } = ""; public string Status { get; set; } = ""; public string Remark { get; set; } = ""; }逻辑说明:
LoginAsync()先 GET 登录页提取 CSRF Token,再 POST 表单——这是绕过 ASP.NET MVC 防伪机制的标准做法;GetHtmlAsync()带重试逻辑,但只对503 Service Unavailable重试(其他错误如 404、401 直接失败,避免无限循环);ParseTable()用 XPath 定位表格,Skip(1)跳过表头,SelectNodes("td")提取单元格——HtmlAgilityPack对 malformed HTML(如<tr><td>xxx</tr></td>)有极强容错能力,比正则或System.Xml更稳;- 所有异常都经由
_logger记录,方便排查是网络问题、登录失效还是 HTML 结构变更。
3. 解析与落地:从 HTML 表格到结构化数据的三道关卡
3.1 HtmlAgilityPack 的 XPath 写法避坑指南:为什么//table/tr/td总是空?
新手常犯的错误是:看到网页源码里有<table><tr><td>数据</td></tr></table>,就写doc.DocumentNode.SelectNodes("//table/tr/td"),结果返回null。原因有三:
| 现象 | 原因 | 解决方案 |
|---|---|---|
SelectNodes返回空集合 | HTML 未闭合标签(如<tr><td>xxx</td>缺</tr>)导致 DOM 树解析失败 | 用doc.OptionFixNestedTags = true启用自动修复(默认 false) |
InnerText取到空字符串 | <td>内含<span>或<div>,InnerText只取直接文本节点 | 改用cell.InnerText.Trim().Replace("\r\n", "").Replace("\t", "")或cell.DescendantsAndSelf().Where(x => x.NodeType == HtmlNodeType.Text).Select(x => x.InnerText).Aggregate((a,b)=>a+b) |
| XPath 匹配不到元素 | 浏览器开发者工具看到的 HTML 是 JS 渲染后的,而HttpClient获取的是原始 HTML | 先用File.WriteAllText("debug.html", html)保存响应内容,用浏览器打开确认结构 |
正确写法示例(带容错):
private List<ReportRow> ParseTable(string html) { var doc = new HtmlDocument(); doc.OptionFixNestedTags = true; // 关键!修复嵌套错误 doc.LoadHtml(html); // 方式1:用 ID 精确定位(最稳) var table = doc.DocumentNode.SelectSingleNode("//table[@id='daily-report']"); // 方式2:用 class 名(若无 ID,但 class 唯一) // var table = doc.DocumentNode.SelectSingleNode("//table[contains(@class, 'report-table')]"); if (table == null) { // 备用方案:找第一个 table(仅用于调试,生产环境必须明确) table = doc.DocumentNode.SelectSingleNode("//table"); _logger.LogWarning("未找到指定 ID 表格,降级使用第一个 table"); } var rows = new List<ReportRow>(); var trNodes = table?.SelectNodes("tr"); if (trNodes == null || trNodes.Count == 0) return rows; foreach (var tr in trNodes.Skip(1)) // Skip(1) 跳过表头 { var tds = tr.SelectNodes("td"); if (tds == null || tds.Count < 4) continue; // 安全提取文本:递归取所有 Text 节点并拼接 string GetText(HtmlNode node) => string.Join("", node.DescendantsAndSelf() .Where(x => x.NodeType == HtmlNodeType.Text) .Select(x => x.InnerText)) .Trim() .Replace("\r", "").Replace("\n", "").Replace("\t", ""); rows.Add(new ReportRow { Date = GetText(tds[0]), Machine = GetText(tds[1]), Status = GetText(tds[2]), Remark = GetText(tds[3]) }); } return rows; }注意:
GetText()函数是血泪经验——某次客户 OA 系统升级,把<td>正常</td>改成<td><span class="status-ok">正常</span></td>,旧代码tds[2].InnerText就返回空,加了这层递归提取才救回。
3.2 JSON/API 接口解析:当目标系统提供 REST 而非 HTML 时
不是所有系统都只有 HTML 页面。越来越多的 MES、SCADA 上位机开始暴露 JSON 接口(如/api/v1/machines/status)。此时HtmlAgilityPack退场,System.Text.Json登场。
假设接口返回:
{ "code": 0, "msg": "success", "data": [ { "machineId": "M001", "status": "RUNNING", "lastUpdate": "2024-05-20T08:30:15Z", "temperature": 65.2 } ] }解析代码(强类型 + 错误处理):
public class ApiResponse<T> { public int Code { get; set; } public string Msg { get; set; } = ""; public List<T> Data { get; set; } = new(); } public class MachineStatus { public string MachineId { get; set; } = ""; public string Status { get; set; } = ""; public DateTime LastUpdate { get; set; } public double Temperature { get; set; } } // 在 WebClientService 中新增方法: private async Task<List<MachineStatus>> GetApiDataAsync() { var client = _httpClientFactory.CreateClient(); var apiUri = new Uri(_options.BaseUrl + "/api/v1/machines/status"); try { var json = await client.GetStringAsync(apiUri); var response = JsonSerializer.Deserialize<ApiResponse<MachineStatus>>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (response.Code != 0) throw new InvalidOperationException($"API 返回错误码 {response.Code}: {response.Msg}"); return response.Data; } catch (JsonException ex) { _logger.LogError(ex, "JSON 解析失败,原始响应:{RawJson}", await client.GetStringAsync(apiUri)); throw; } }参数说明:
PropertyNameCaseInsensitive = true是必备开关,否则lastUpdate字段无法映射到LastUpdate属性;DateTime默认解析 ISO 8601 字符串(如"2024-05-20T08:30:15Z"),无需额外配置;若遇到"2024-05-20 08:30:15"这种格式,需自定义JsonConverter<DateTime>。
3.3 数据落地:不写数据库,先搞定 Excel 导出(一线最刚需)
客户不要中间过程,只要最终 Excel。EPPlus是 .NET 生态最成熟的 Excel 库,支持 .xlsx 且无需 Office 依赖。
dotnet add package EPPlus --version 6.2.12添加导出方法(DataProcessor类):
public class DataProcessor { private readonly ILogger<DataProcessor> _logger; public DataProcessor(ILogger<DataProcessor> logger) { _logger = logger; } public void ExportToExcel(List<ReportRow> data, string outputPath) { ExcelPackage.LicenseContext = LicenseContext.NonCommercial; // 开源版限制,生产请购商业许可 using var package = new ExcelPackage(); var worksheet = package.Workbook.Worksheets.Add("日报"); // 写表头 worksheet.Cells[1, 1].Value = "日期"; worksheet.Cells[1, 2].Value = "设备编号"; worksheet.Cells[1, 3].Value = "运行状态"; worksheet.Cells[1, 4].Value = "备注"; // 写数据(从第2行开始) for (int i = 0; i < data.Count; i++) { worksheet.Cells[i + 2, 1].Value = data[i].Date; worksheet.Cells[i + 2, 2].Value = data[i].Machine; worksheet.Cells[i + 2, 3].Value = data[i].Status; worksheet.Cells[i + 2, 4].Value = data[i].Remark; } // 自动列宽 worksheet.Cells.AutoFitColumns(); // 保存 File.WriteAllBytes(outputPath, package.GetAsByteArray()); _logger.LogInformation("已导出 {Count} 行数据至 {Path}", data.Count, outputPath); } }关键细节:
LicenseContext.NonCommercial是开源版强制要求,若用于商业系统,必须购买 EPPlus 商业许可(约 $299/年);AutoFitColumns()避免中文列宽过窄,但大数据量时会变慢,可改用worksheet.Column(i).Width = 20;手动设宽;GetAsByteArray()比SaveAs()更安全——后者可能因文件被占用抛IOException,前者内存操作无风险。
4. 避坑 / 常见问题 / 排查:C# 爬虫上线后被叫醒的 4 个深夜电话
4.1 现象:程序运行几天后突然报SocketException: An operation on a socket could not be performed because the system lacked sufficient buffer space
原因:HttpClient实例未复用,每次new HttpClient()都新建 TCP 连接,Windows 默认每个进程最多 1024 个 socket 句柄。爬虫每分钟请求 10 次,2 小时就耗尽。
解决:
✅ 严格使用IHttpClientFactory(见第 2 章),绝不new HttpClient();
✅ 若必须手动创建(如特殊证书验证),用static readonly HttpClient单例,并设置Timeout;
❌ 禁止在using (var client = new HttpClient())中使用——Dispose()会关闭连接池。
4.2 现象:登录成功,但后续请求返回 401 Unauthorized
原因:
目标网站使用SameSite=Strict的 Cookie,而 .NET 6 默认HttpClientHandler的UseCookies = true无法处理跨域跳转中的 Strict Cookie。常见于登录后重定向到/dashboard时 Cookie 丢失。
解决:
在HttpClientHandler初始化时显式设置CookieContainer并禁用AutomaticDecompression(有时 gzip 压缩干扰 Cookie 解析):
var handler = new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer(), AutomaticDecompression = DecompressionMethods.None // 关键! };4.3 现象:HtmlAgilityPack解析出的InnerText包含大量\r\n\t,且中文乱码(显示为 ``)
原因:
网页响应头未声明charset,HttpClient.GetStringAsync()默认用 UTF-8 解码,但网页实际是 GB2312 或 UTF-8 with BOM。
解决:
不用GetStringAsync(),改用GetByteArrayAsync()+ 手动解码:
private async Task<string> GetHtmlAsync(string url) { var client = _httpClientFactory.CreateClient(); var fullUrl = new Uri(_options.BaseUrl + url); var response = await client.GetAsync(fullUrl); response.EnsureSuccessStatusCode(); var bytes = await response.Content.ReadAsByteArrayAsync(); // 尝试从响应头获取 charset var charset = response.Content.Headers.ContentType?.CharSet ?? "utf-8"; // 若响应头无 charset,用 HtmlAgilityPack 自动探测 if (string.IsNullOrEmpty(charset)) { var htmlStr = Encoding.Default.GetString(bytes); // 先用系统默认编码粗略读 var doc = new HtmlDocument(); doc.LoadHtml(htmlStr); var metaCharset = doc.DocumentNode.SelectSingleNode("//meta[@charset]"); charset = metaCharset?.GetAttributeValue("charset", "utf-8") ?? "utf-8"; } return Encoding.GetEncoding(charset).GetString(bytes); }4.4 现象:程序在服务器上运行正常,在客户现场 Win10 机器上报HttpRequestException: The SSL connection could not be established
原因:
客户机器 TLS 1.2 未启用(尤其 Win7/Win10 旧版),或证书链不完整(自签名证书未导入受信任根证书)。
解决:
在Program.cs最顶部(Main方法第一行)强制启用 TLS 1.2:
// 必须放在 Main() 第一行! AppContext.SetSwitch("System.Net.Http.UseTransportSecurity", true); ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;注意:
ServicePointManager是全局静态类,必须在任何HttpClient创建前设置,否则无效。
5. 进阶技巧:让爬虫从“能跑”变成“敢放生产”的 3 个硬核习惯
5.1 用IHostedService封装为 Windows Service:告别双击运行
控制台程序适合调试,但生产环境必须是 Windows Service——开机自启、崩溃自恢复、日志集中管理。.NET 6+的IHostedService是最佳实践。
新建CrawlerHostedService.cs:
public class CrawlerHostedService : IHostedService, IDisposable { private readonly ILogger<CrawlerHostedService> _logger; private readonly WebClientService _crawler; private Timer _timer; public CrawlerHostedService(ILogger<CrawlerHostedService> logger, WebClientService crawler) { _logger = logger; _crawler = crawler; } public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("爬虫服务启动"); // 每天 8:00 执行一次(可按需调整) _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromHours(24)); return Task.CompletedTask; } private async void DoWork(object state) { try { _logger.LogInformation("开始执行每日爬取任务"); await _crawler.RunAsync(); _logger.LogInformation("每日爬取任务完成"); } catch (Exception ex) { _logger.LogError(ex, "每日爬取任务异常"); } } public Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("爬虫服务停止"); _timer?.Change(Timeout.Infinite, 0); return Task.CompletedTask; } public void Dispose() { _timer?.Dispose(); } }注册服务(Program.cs中):
services.AddHostedService<CrawlerHostedService>();发布为 Service:
# 发布自包含 exe dotnet publish -c Release -r win-x64 --self-contained true -o ./publish # 安装为服务(管理员权限) sc create CrawlerService binPath= "C:\path\to\publish\CrawlerCore.exe" start= auto sc start CrawlerService # 查看日志(事件查看器 → Windows 日志 → 应用程序)优势:
- 无需用户登录,系统启动即运行;
sc命令可远程管理,比 Task Scheduler 更可靠;- 异常直接写入 Windows Event Log,运维可统一监控。
5.2 添加断点续爬与幂等校验:防止重复抓取和数据错乱
客户最怕的不是爬不到,而是“今天爬了 100 条,明天又爬 100 条,数据库里存了 200 条重复”。解决方案:每次爬取前先查数据库是否存在当天数据,存在则跳过。
在WebClientService.RunAsync()开头加入:
// 检查今日数据是否已存在(假设用 SqlServer) var connectionString = "Server=localhost;Database=CrawlerDB;Trusted_Connection=true;"; using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); var cmd = new SqlCommand("SELECT COUNT(1) FROM DailyReports WHERE ReportDate = @date", conn); cmd.Parameters.AddWithValue("@date", DateTime.Today); var count = (int)await cmd.ExecuteScalarAsync(); if (count > 0) { _logger.LogInformation("今日数据已存在,跳过爬取"); return; }进阶:若目标系统无日期参数(如只提供最新一页),则用
MD5(html)作为指纹存库,对比指纹决定是否更新——避免 HTML 微小变动(如时间戳)触发误更新。
5.3 日志分级与告警:把“半夜电话”变成“邮件通知”
Microsoft.Extensions.Logging默认只输出到 Console,生产必须接入持久化。最轻量方案:写入文件 + 邮件告警。
安装包:
dotnet add package Serilog.Sinks.File --version 5.0.0 dotnet add package Serilog.Sinks.Email --version 3.0.0配置Program.cs:
Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.File("logs/crawler-.log", rollingInterval: RollingInterval.Day) .WriteTo.Email(new EmailConnectionInfo { FromEmail = "crawler@company.com", ToEmail = "admin@company.com", MailServer = "smtp.company.com", NetworkCredentials = new NetworkCredential("crawler", "pwd"), Port = 25 }, restrictedToMinimumLevel: LogEventLevel.Error) // 仅错误发邮件 .CreateLogger(); var host = Host.CreateDefaultBuilder(args) .UseSerilog() // 替换默认 logger ...效果:
- 正常日志写入
logs/crawler-20240520.log,按天滚动;LogError自动触发邮件,标题含Crawler Error: [异常类型],正文含堆栈;- 运维不用守着屏幕,微信收到邮件就知道哪台机器的爬虫挂了。
我做 C# 爬虫五年,从给车间老师傅写“一键导出日报”小程序,到给集团 ERP 做跨系统数据同步管道,踩过的最大坑不是代码写错,而是低估了企业环境的“不标准”——IE 兼容模式、自签名证书、没有 API 只有 HTML、登录页每年换一次 CSRF Token 生成逻辑……所以现在我的原则是:
✅ 所有 HTTP 请求必带try-catch和ILogger;
✅ 所有 HTML 解析必先File.WriteAllText("debug.html", html)保存原始响应;
✅ 所有生产部署必用IHostedService+ Windows Service + Serilog;
✅ 所有客户交付物必附README.md,写明“如何修改登录账号”“如何调整爬取时间”“日志在哪查”。
不是代码越炫越高级,而是让运维同事能自己改密码、重启服务、看日志定位问题,这才是真正的“可维护”。希望帮到你。
本文还有配套的精品资源,点击获取