
简介一套基于C#的完整网络爬虫项目源代码面向C#学习者、开发者和毕业设计团队覆盖网页抓取、HTML解析、数据提取、存储及反爬处理等核心环节并结合多线程异步编程、设计模式与日志记录等工程实践适合系统掌握爬虫开发或用于课设、毕设参考。压缩包内共867个文件容量约5.15MB以413个.cs源代码文件为主体辅以html文档、gif示意图、dll依赖库及项目配置文件便于阅读代码和调试运行。目录按Models、Services、Utilities、Configs、Tests等模块划分结构清晰可以快速定位爬取规则、网络请求、HTML解析和测试用例等关键内容。目前已有156人学习浏览。通过这套资料读者能够理解C#爬虫的完整实现思路掌握使用HttpClient发送请求、通过HtmlAgilityPack解析DOM、利用正则表达式提取目标数据并学会多线程调度与异常处理等实用技巧是一份适合深入研究与实践的参考资源。1. 为什么用C#写网络爬虫不只是“能用”这么简单大多数教程一提爬虫默认就是 Python。但如果你所在团队的技术栈是 .NET爬来的数据要直接进公司现有数据库后续还要跟报表、定时任务、消息队列串在一起C#网络爬虫反而是交付最快的那条路。C#的强类型模型可以先定义好商品、列表、详情这些业务对象解析完直接绑定字段错位在编译期就能暴露async/await 做并发抓取的代码又短又直观不会像回调嵌套那样绕。这个源代码 zip 给的不是一个只能抓一个网站的硬编码脚本而是一套能往上加新站点、换存储、调并发的项目骨架请求层、解析层、存储层和执行队列都有明确边界。适合已经在写 C#、想把爬虫能力沉淀进业务系统的开发者看完照着改 URL 和解析规则就能跑起来。2. C#爬虫骨架设计HttpClient请求层与HTML解析层的分层实现2.1 最小可运行的请求代码HttpClient的正确打开方式写爬虫的第一步永远是请求不管目标页面是静态 HTML 还是 JS 渲染的空壳总得先把响应内容拿回内存。C# 里做这件事的标准入口是 HttpClient但恰恰是这个类让不少人踩过坑——最常见的错法是每个方法里 new 一个 HttpClient用完就丢。单看逻辑没错并发一高程序就变慢接着开始报 SocketException原因在于 HttpClient 底层持有连接池反复创建实例等于每次建立新 TCP 连接旧连接还没来得及释放端口和句柄就被占光了。using System; using System.Net.Http; using System.Threading.Tasks; class Crawler { // HttpClient 是线程安全的进程内全局共享一个实例即可 private static readonly HttpClient client new HttpClient(); static async Task Main(string[] args) { client.Timeout TimeSpan.FromSeconds(30); client.DefaultRequestHeaders.Add(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36); client.DefaultRequestHeaders.Add(Accept, text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8); client.DefaultRequestHeaders.Add(Accept-Language, zh-CN,zh;q0.9); string url https://example.com/product/list; try { HttpResponseMessage resp await client.GetAsync(url); resp.EnsureSuccessStatusCode(); string html await resp.Content.ReadAsStringAsync(); Console.WriteLine($抓取成功页面长度: {html.Length}); } catch (HttpRequestException ex) { Console.WriteLine($请求失败: {ex.Message}); } } }这段代码里有几个参数值得说明。Timeout 设成 30 秒是经验值默认的 100 秒太长网络异常时一个请求能拖一分钟不返回程序就像卡死一样30 秒对绝大多数站点够用超时直接抛异常走重试逻辑。User-Agent 这里写完整浏览器标识不要简写成 Mozilla/5.0不少反爬组件会校验 UA 里的 Chrome 版本号和 WebKit 标记。EnsureSuccessStatusCode() 的作用是让所有非 2xx 状态码直接变成异常你不会拿着 404 页面继续往解析层传日志也能更早暴露 URL 配置问题。还有一个细节DefaultRequestHeaders 设置的请求头会作用于该 HttpClient 发出的所有请求适合放每个请求都需要的通用标识。如果某个页面需要独立的 Referer 或者登录 Cookie应该用 HttpRequestMessage 局部覆盖全局头保持最小化不然后续加新站点时头会越堆越乱。2.2 解析层选型正则、XPath还是CsQuery请求层拿到的是一整段 HTML 字符串解析层的任务是从里面对抽出结构化数据。C# 生态里没有像 Python 的 BeautifulSoup 那样一家独大的库常见选择是正则、HtmlAgilityPack 配合 XPath、以及 CsQuery。正则性能最好维护成本也最高。HTML 是嵌套结构正则表达式的设计目标却是匹配扁平文本一旦标签嵌套超过两层或者属性值里有换行写出来的表达式就开始失控谁也看不清。我的意见是正则只用在两个场景从 script 标签里抠 JSON 数据以及做字符串清理比如去掉标签只留纯文本。想用正则匹配整个页面结构基本等于给自己埋雷一个属性值的换行就能让匹配全盘错乱。XPath 方案的代表是 HtmlAgilityPack它把 HTML 解析成文档树按路径精确定位节点比如 div[classproduct-item] 这种写法定位很准但路径表达式对新手不友好结构一变就要跟着改。CsQuery 则是 C# 世界里最接近 jQuery 体验的库按 CSS 选择器找元素代码最直观。以我的经验像 .title 这种 class 名称在全页面大量出现时用 CsQuery 的 CSS 选择器最顺手按元素在父节点里的位置精确取数时XPath 的 nth-child 更可靠。两个库共存于一个项目完全没有问题各管一段场景就好。using CsQuery; string html await FetchPageAsync(url); var dom CQ.Create(html); var products dom[.product-item].Select(item { var node item.Cq(); return new { Name node.Find(.title).Text(), Price node.Find(.price).Text(), Link node.Find(a).Attr(href) }; }).ToList(); foreach (var p in products) { Console.WriteLine(${p.Name} | {p.Price} | {p.Link}); }CQ.Create(html) 把页面字符串解析成内存中的 DOM 结构dom[.product-item] 用 CSS 选择器匹配所有 class 包含 product-item 的元素返回集合。注意 item.Cq() 这一步CsQuery 里必须把原生元素重新包装成 CsQuery 对象才能在局部范围内继续用 Find直接拿 item.Find 是会编译报错的。Text() 取去掉标签后的纯文本Attr(href) 取第一个匹配 a 标签的 href 属性值。这套写法要求 class 名称不能与其他商品重复如果页面结构混乱、class 复用严重换 XPath 更可靠。2.3 三层架构的边界请求、解析、存储为什么要分家如果爬虫目标只有一个站点页面只有列表和详情两种类型写成一个脚本加循环没什么问题。但要把爬虫沉淀成一个可持续维护的软件程序而不是用完即弃的一次性脚本分层就是必需品。源代码包里采用的结构很清晰请求层只负责拿 HTML解析层只负责出结构化数据存储层只负责落盘层与层之间用接口衔接。// 存储层接口解析层完全不关心数据写到哪里 public interface IStorage { Task SaveAsync(ProductItem item); Taskbool ExistsAsync(string fingerprint); } // 解析层只依赖接口将来从 SQLite 换到 MSSQL 不需要改这里 public class Parser { private readonly IStorage _storage; public Parser(IStorage storage) { _storage storage; } public async Task ProcessAsync(string html) { var products ExtractProducts(html); foreach (var p in products) { string key ${p.Name}:{p.Price}; if (await _storage.ExistsAsync(key)) continue; await _storage.SaveAsync(p); } } }这段代码里最关键的是 Parser 依赖 IStorage 接口而不是某个具体存储类。依赖倒置在爬虫项目里有一个很现实的好处本地调试解析逻辑时你可以先写一个假的 InMemoryStorage不用连数据库调完再换成 SqliteStorage解析代码一行不用改。边界感带来的另一个好处是解析规则可以做单元测试固定一份 HTML 样本断言输出对象的字段值以后改代码不会把旧站点的解析规则弄坏。我在实际项目里见过太多把请求、正则、数据库操作全塞进同一个方法的代码加一个字段要改四处加一个新站点等于复制粘贴整个方法再微调。分层的初期工作量稍大但从第二次迭代开始收益会越来越明显这也是源代码包按这个结构组织的原因——它让使用者沿着明确的边界往里填业务规则而不是在一个类里堆出两三千行的网络。3. 动态页面采集思路抓接口、驱动浏览器还是中间人拦截3.1 方案一抓包找接口绕过渲染直接拿JSON现在的大型运营站点列表和详情数据大部分是异步渲染的直接请求页面 URL 返回的 HTML 里可能只有框架和加载动画真正的数据是页面加载后 JS 再向后端接口要来的。这反而留了一条近路与其等着浏览器渲染不如直接模拟那个数据接口。做法是先打开开发者工具的 Network 面板刷新目标页面在请求列表里筛选 XHR 类型的请求找到一个返回 JSON 的接口。这个接口往往已经排好序、分好页请求它比解析 HTML 轻松得多。// 从浏览器 Network 里抓到的真实数据接口 string apiUrl https://example.com/api/list?page1size50; var request new HttpRequestMessage(HttpMethod.Get, apiUrl); request.Headers.Add(Referer, https://example.com/list); request.Headers.Add(X-Requested-With, XMLHttpRequest); HttpResponseMessage resp await client.SendAsync(request); string json await resp.Content.ReadAsStringAsync(); // 强类型定义返回结构字段名和 JSON 里的 key 一一对应 public class ProductResponse { public int total { get; set; } public ListProductItem data { get; set; } } var result JsonSerializer.DeserializeProductResponse(json); foreach (var item in result.data) { Console.WriteLine(${item.name}: {item.price}); }这里两个请求头是关键。Referer 告诉服务器你是从列表页跳转过来的不是直接访问 API 的机器人很多后端防爬第一件事就是校验 Referer少了它接口直接返回 401。X-Requested-With 是一个约定俗成的标记用 XMLHttpRequest 发起的异步请求都会带它后端框架能靠这个头区分普通页面访问和 AJAX 请求虽然不是所有站点都校验但加上能提升成功率。要提醒的是动态接口的 JSON 结构变化可能比页面 HTML 更频繁后端同学重构一个字段名就像吃饭一样平常。我的习惯是把每个接口的原始返回存一份样本到项目里的 samples 目录一旦解析结果异常先对比样本和线上返回的差别五分钟内就能判断是字段名变了还是数据结构层级变了不用对着代码瞎猜。3.2 方案二Selenium驱动无头浏览器等元素出现再操作不是所有站点都能靠接口方案解决。有的页面数据逻辑全部收敛在加密的 JS 里接口路径都找不到有的页面需要登录态和复杂交互之后才能看到目标内容。这种情况只能上浏览器自动化C# 里的标准选择是 Selenium WebDriver。using OpenQA.Selenium; using OpenQA.Selenium.Chrome; var options new ChromeOptions(); options.AddArgument(--headless); options.AddArgument(--disable-gpu); options.AddArgument(--no-sandbox); options.AddArgument(--window-size1920,1080); using var driver new ChromeDriver(options); driver.Navigate().GoToUrl(https://example.com/list); // 显式等待目标元素出现比固定 Sleep 可靠 var wait new WebDriverWait(driver, TimeSpan.FromSeconds(15)); wait.Until(d d.FindElement(By.CssSelector(.product-item))); var items driver.FindElements(By.CssSelector(.product-item)); foreach (var item in items) { string title item.FindElement(By.CssSelector(.title)).Text; string price item.FindElement(By.CssSelector(.price)).Text; Console.WriteLine(${title}: {price}); }WebDriverWait 的用法是这个方案里最值得学习的部分。新手看到页面要等 JS 渲染完习惯性写 Thread.Sleep(3000)固定等三秒。可网络一波动三秒不够元素没出现程序就崩了网络好的时候三秒又浪费在空等上。显式等待是每半秒轮询一次页面检查预期选择器是否出现最长等 15 秒既可靠又不浪费。这个策略在源代码包里是默认写法你自己写爬虫时也建议养成这个习惯少用 Sleep。ChromeDriver 的版本匹配是另一个高频坑Chrome 浏览器升到某个主版本ChromeDriver 也必须对应。服务器上抓取前先执行 chrome --version 确认版本号再去对应源下载匹配驱动。另外 Linux 服务器无头模式下--disable-gpu 和 --no-sandbox 几乎是必加的参数否则 Chromium 启动时因为缺少 GPU 和沙箱权限会直接报错退出。这两个参数加了能解决九成启动即崩的问题。3.3 三种方案对比与选型路径接口、浏览器还是流量记录方案优势边界适用场景直接请求 JSON 接口速度快、资源占用最低接口加密或校验严格时失效数据由常规 XHR 返回Selenium 驱动浏览器兼容复杂渲染和页面交互并发低、内存开销大、驱动版本敏感JS 渲染重、需要真实环境中间人拦截响应一次配置自动收集所有接口数据证书替换和信任配置繁琐接口多、手工抓包太累选型逻辑并不复杂先抓包看能不能找到不加密的接口能找到就走第一条路找不到且页面必须靠浏览器执行 JS 时再上 Selenium。中间人拦截适合数据由十几个接口拼接的复杂站点先用类库把流量自动记录成样本再做字段提取好处是省去手工翻包但证书配置的学习成本不小不建议作为第一版方案。我一般不会把鸡蛋全放在一个篮子里。一个大型站点的列表页是接口返回 JSON详情页却需要登录态和 JS 加密这种组合在现实里很常见。实现上把它们拆成两个独立工作流一个走 Http 接口直连一个走 Selenium 浏览器互不干扰哪个挂了就单独修哪条链路这样的架构在后期维护时最顺手。4. 反爬应对避坑清单五个真实排查记录4.1 请求头伪装齐全还是收到403 Forbidden现象描述抓某个资讯站点User-Agent、Referer、Accept-Language 都设置好了但请求详情页仍然返回 403。手动用浏览器访问同一个地址却完全正常。排查原因目标站点的反爬逻辑要求客户端先访问首页让服务器种下会话 Cookie后续请求详情页时必须携带这个 Cookie 才能通过校验。直接用 HttpClient 请求详情页缺少建会话的步骤所以被判定为非法来源。解决方案用 HttpClientHandler 接上一个 CookieContainer让它自动维护会话。发详情页请求之前先 GET 一次首页等服务器种好 Cookie。var handler new HttpClientHandler { CookieContainer new CookieContainer(), UseCookies true }; var client new HttpClient(handler); // 第一步访问首页让服务器种下会话 Cookie await client.GetAsync(https://example.com/); // 第二步带着 Cookie 请求详情页 string detailHtml await client.GetStringAsync(https://example.com/detail/123);这个坑的特征是浏览器能打开、代码打不开而且只影响一部分页面。遇到类似情况先怀疑 Cookie 与会话状态不要一上来就换网络环境。排查顺序是首页能否正常访问详情页是否校验 RefererCookie 容器有没有真正把会话维持住。4.2 设置了请求延时跑了二十分钟IP还是被封现象描述代码在每次请求之间加 500 毫秒延时自以为限速够文明了结果跑了二十分钟后还是收到封禁通知。排查原因延时加在了单个 Task 内部程序却开了 8 个并行线程每个线程独立循环、独立延时。算下来一秒内的请求总量远不止 2 个而是 8 个线程叠加起来的十几二十个。限速必须做在全局视角下而不是局部循环里。解决方案引入 SemaphoreSlim 信号量把所有请求收敛到一个统一出口信号量限定最大并发数同时加固定间隔让所有线程共享同一个节拍。private static readonly SemaphoreSlim gate new SemaphoreSlim(2); async Taskstring FetchWithRateLimit(string url) { await gate.WaitAsync(); try { return await client.GetStringAsync(url); } finally { await Task.Delay(300); // 全局统一间隔 gate.Release(); } }信号量参数为 2 表示最多两个请求同时在网络上Task.Delay(300) 在两个请求之间至少留下 300 毫秒空档整体算下来每秒大约发出几个请求。表面看比之前的 8 线程并发慢但抓取可靠性提升非常明显。限速的核心要义不是让总时长变长而是把请求频率压到目标服务器能容忍的范围以内。4.3 抓到的中文全是乱码页面里爬出一片锟斤拷现象描述抓某个老牌论坛的帖子列表数据库里中文全部变成各种乱码英文数字却一切正常。排查原因服务器返回的是 GB2312 编码的 HTML而 ReadAsStringAsync 默认按 UTF-8 解码字节序列被错误解释中文内容自然就废了。解决方案先读取原始字节从响应头的 Content-Type 参数里找 charset找不到再去 HTML 页面的 meta 标签里找最后用查到的编码重新解码。byte[] bytes await resp.Content.ReadAsByteArrayAsync(); // 优先从响应头读 charset string charset resp.Content.Headers.ContentType?.CharSet ?? utf-8; // 不少服务器会漏掉响应头里的 charset需要从 meta 标签兜底 if (!charset.Equals(utf-8, StringComparison.OrdinalIgnoreCase)) { string html Encoding.GetEncoding(charset).GetString(bytes); var metaCharset Regex.Match(html, meta[^]charset\\s*\\s*[\]?([^\\\s]), RegexOptions.IgnoreCase); if (metaCharset.Success) { charset metaCharset.Groups[1].Value; } } string content Encoding.GetEncoding(charset).GetString(bytes);这个坑总结起来就是编码声明的来源可能不可信处理要落到字节层面。很多爬虫框架的默认 ReadAsStringAsync 已经做了编码中转但中转依据未必和服务器真实返回一致。处理编码时记住一个原则永远先拿字节再依次从响应头、meta 标签确认编码最后才转成字符串。4.4 SQLite并发写入疯狂报错database is locked现象描述爬虫跑起来后用 SQLite 存数据日志里刷屏式出现 database is locked 异常抓取任务频繁中断。排查原因SQLite 的默认日志模式下同时只允许一个写者多个解析线程并发 INSERT 时锁竞争激烈写入失败属于典型的并发写冲突。解决方案从两个方向降低锁竞争。第一连接串加上 Journal ModeWAL开启预写日志读写不再互相阻塞第二在应用层把所有写入操作收敛到单一线程解析层只把结果推进内存队列后台专职线程循环消费并落库。// 连接串启用 WAL 模式明显降低锁冲突 string connStr Data Sourcecrawl.db;Version3;Journal ModeWAL;; // 后台单写线程解析线程不直接碰数据库 BlockingCollectionProductItem writeQueue new BlockingCollectionProductItem(); Task.Run(async () { foreach (var item in writeQueue.GetConsumingEnumerable()) { await SaveToDatabase(item); } });BlockingCollection 是 C# 自带的生产者消费者队列解析线程往里面丢数据写线程从中取数据落库。写线程只有一个SQLite 就永远不会被并发写入锁冲突从根上消失。WAL 模式起辅助作用让读取和写入不再互相等待进一步降低延迟。这套组合在我经手的项目里解决过多次类似问题没有翻过车。4.5 重试逻辑无上限把对方服务打到宕机现象描述网络超时后代码自动重试但重试逻辑是 while(true) 式的无限重试配合高并发目标服务器网关最终返回 502自己的爬虫也全链路失败。排查原因重试必须有退避策略和次数上限。没有上限的循环重试在网络波动时就是请求洪峰对目标站点来说和攻击没有区别。解决方案指数退避加最大次数限制。第一次失败等 1 秒第二次等 2 秒第三次等 4 秒最多 5 次后放弃并记录到日志。int maxRetries 5; for (int i 0; i maxRetries; i) { try { return await client.GetStringAsync(url); } catch (HttpRequestException ex) when (i maxRetries - 1) { int delayMs (int)Math.Pow(2, i) * 1000; Console.WriteLine($第 {i 1} 次失败{delayMs}ms 后重试错误{ex.Message}); await Task.Delay(delayMs); } } return null; // 重试耗尽由上层决定如何处理失败 URL指数退避的倍增基数可以根据目标服务器容忍度调整响应快的站点 1 秒加倍就够响应慢的站点可以从 5 秒起加倍。关键是必须有放弃这个动作放弃后把 URL 写入失败队列留到下一轮任务再试。无限重试在爬虫世界永远不是勇敢而是事故的开始。5. 数据持久化与断点续抓如何让爬虫下次启动接着跑5.1 队列表设计用SQLite记录URL与状态断点续抓的前提是程序崩溃后重新启动能知道上次抓到哪个 URL。这要求待抓取队列和抓取状态不能只存在内存里必须持续落盘。SQLite 是 C# 爬虫项目里很自然的本地存储选择单文件、零配置、跨平台支持好放服务器上也不用额外装数据库服务。// 初始化队列表程序启动时执行一次 const string createTableSql CREATE TABLE IF NOT EXISTS crawl_queue ( url TEXT PRIMARY KEY, status INTEGER NOT NULL DEFAULT 0, -- 0:待抓取 1:成功 2:失败 3:处理中 retries INTEGER DEFAULT 0, fingerprint TEXT, updated_at DATETIME );; // 启动时把上次崩溃残留的处理中任务重置为待抓取 const string resetSql UPDATE crawl_queue SET status0 WHERE status3;; // 从队列里取一个待抓取 URL public string? GetNextUrl() { using var cmd conn.CreateCommand(); cmd.CommandText SELECT url FROM crawl_queue WHERE status 0 OR (status 2 AND retries 5) ORDER BY rowid LIMIT 1; return cmd.ExecuteScalar() as string; }status 字段是这个表的灵魂。0 表示还没抓1 表示抓成功了2 表示抓了几次都失败3 表示正在抓。启动时把 status3 重置成 0 这个操作很关键因为进程崩溃时那些处理中任务实际上没完成如果不重置它们会永远卡在中间态后续再也不会被任何线程处理。这也是状态机思维在爬虫里的实际应用。GetNextUrl 的 WHERE 条件里加上了 retries 小于 5 的限制失败超过阈值的 URL 不会被无限取出避免同一个坏地址反复消耗资源。用 ORDER BY rowid LIMIT 1 而不是随机抽取是为了让抓取顺序稳定先入队的先抓列表页靠前的数据总是先完成调试时行为更符合预期。5.2 内容指纹高效判断页面是新的还是重复的断点续抓处理的是崩溃后恢复增量更新处理的是已经抓过的还要不要重抓。网络爬虫最常见的形态是周期性抓取每五分钟抓一次价格每十分钟抓一次新闻列表。如果每次都全量重新入库既有一堆重复写入又不断堆积脏数据。内容指纹的方案是对已抓取内容算一个哈希值入库时存下来。下次抓同一页面先算新哈希和库里旧哈希比对一致就跳过不一致说明内容变了才更新。public string ComputeFingerprint(string url, string html) { // 用 URL、网页长度和前 200 字符做指纹兼顾效率与准确度 string input ${url}:{html.Length}:{html[..Math.Min(200, html.Length)]}; byte[] hash System.Security.Cryptography.SHA256.HashData( System.Text.Encoding.UTF8.GetBytes(input)); return Convert.ToHexString(hash); } public async Taskbool IsChangedAsync(string url, string html) { string newFp ComputeFingerprint(url, html); string? oldFp await GetFingerprintFromDbAsync(url); return oldFp ! newFp; }指纹为什么由 URL 加长度加前 200 字符组成直接对整页做 SHA256 最准确但大网页每次抓完都读全字节算哈希开销偏高。只取 URL 又太粗页面内容变了但地址没变时检测不到。前 200 字符加长度是一种性价比很高的折中页面主要结构的变化几乎都发生在开头标题、meta 描述或头部导航再加上长度变化做补充绝大多数实质更新都能被捕捉。如果你的页面数据主体不在头部比如价格列表放在页面中部靠后的位置就把那一段单独抽出来拼进指纹输入串比前 200 字符更贴合业务。指纹方案没有放之四海皆准的公式它其实是给爬虫加了一层业务过滤逻辑帮你省掉大量重复写入。5.3 状态转换与日志让每次中断恢复都有迹可循断点续抓能正常工作前提是你清楚每个 URL 在任意时刻的准确状态。这就要求状态切换的每个动作都写日志并且日志格式要能被工具直接检索而不是只能靠人眼逐行看。// 用结构化日志记录每个 URL 的状态变迁 _logger.LogInformation(URL{Url} StatusStart Time{Time}, url, DateTime.Now); try { string html await FetchAsync(url); _logger.LogInformation( URL{Url} StatusSuccess Bytes{Length} Cost{Ms}ms, url, html.Length, stopwatch.ElapsedMilliseconds); } catch (Exception ex) { _logger.LogWarning( URL{Url} StatusFailed Error{Message}, url, ex.Message); }一份好日志应该每行都带 URL 标识、时间点、状态。后期排查时打开日志文件用 grep 过滤某个 URL能看到它从头到尾的所有经历——几点开始抓、抓了多久、成功还是失败、失败原因是什么。如果每个方法都用 Console.WriteLine 打一段自由格式的文本排查时就是在玩解密游戏。日志落盘方式也要考虑可维护性。Console 输出本地调试方便服务器上总不能天天打屏。我一般给爬虫配文件日志按天滚动一天的日志一个文件。某天数据异常时只需要翻那一天的文件就能定位是哪一轮抓取的任务失败比在无界面的服务器上翻控制台输出省力得多。6. URL规范化与内容指纹的落地细节给爬虫留一个后悔药最后一章讲一个很多人忽略、但对抓取效率和稳定性影响巨大的细节URL 规范化与去重。先看现象。目标列表页的 URL 长这样/list?category3sortpricepage1每个商品链接到详情页时又带了 utm_source、spm 这类追踪参数。如果直接把 URL 原样作为队列主键同一个商品详情页可能因为访问来源不同、参数顺序不同被当成两个任务抓两次分页 URL 参数顺序一变也会导致同一页重复入队。解决办法是入队之前先做 URL 规范化。public static string NormalizeUrl(string rawUrl) { var uri new Uri(rawUrl); var query HttpUtility.ParseQueryString(uri.Query); // 剔除追踪类参数和已知无意义的变量 string[] blacklist { utm_source, utm_medium, spm, from }; foreach (string key in blacklist) { query.Remove(key); } // 对剩余参数按键排序避免顺序不同导致重复 var sorted query.AllKeys .Where(k !string.IsNullOrEmpty(k)) .OrderBy(k k) .ToList(); var builder new UriBuilder(uri) { Query string.Join(, sorted.Select(k ${k}{query[k]})) }; return builder.Uri.ToString(); }这里两个关键操作。第一是删除追踪参数utm_source、from 这类参数不影响页面内容只会在 URL 指纹里制造噪音。第二是对剩余参数按键排序让 category3sortprice 和 sortpricecategory3 归一到同一个 URL之后才进入队列主键做去重。但 URL 去重只解决同一地址抓两次的问题解决不了同一内容不同地址抓两次的问题。很多网站有 PC 版和移动版、域名带 www 和不带 www、同一篇文章有多个入口URL 各不相同内容却相同。这时候需要内容指纹兜底抓取完成后对正文取 Hash存进指纹表入库前先查发现相同指纹就丢弃。这个动作相当于给爬虫加了后悔药——就算 URL 规范化漏了什么内容级判重也能拦住最后一关。规模上URL 去重用内存里的 HashSet 足够支撑几十万量级不需要额外引入基础设施如果目标是几千万页面内存放不下全部 URL再考虑布隆过滤器那种概率型结构用很小内存换取判重能力但要接受一定的误判率中小项目通常用不到。我自己的习惯是入队前 URL 去重加入库前内容指纹去重双保险。后者虽然多一次哈希计算但防住的脏数据远比想象多。有一次抓新闻站点同一篇文章因为编辑重新分类出现了两个 URL 版本标题正文完全相同没有指纹表数据库就会躺着两条重复记录下游报表聚合时怎么查都对不上数。后来加了这道关卡这类问题直接从根源消失。给爬虫留后悔药的实质是允许自己在抓取阶段犯点错但存储阶段必须把关。希望这个思路在你自己的项目里也能用上希望帮到你。本文还有配套的精品资源点击获取