很多从 Python 转过来的朋友,开口就问“.NET 里有像 Scrapy 那样开箱即用的爬虫库吗”。早期你翻遍 NuGet,答案基本都是“没有,自己拼”。但这两年情况真的变了:.NET 生态里出现了能对标 Scrapy 的爬虫框架,而且天然享受 .NET 的跨平台红利,一套代码跑 Windows、Linux、macOS 都没问题。如果你正打算用 .NET 做数据采集,又不想从零手写调度、去重、下载、解析那一整套流水线,这篇文章就是冲你来的。
我要推荐的主角是 DotnetSpider,一个真正开箱即用的 .NET 多平台爬虫库。文章后面会把它拆开来讲,附上可以直接跑通的最小示例,再补两套轻量级备选方案(AngleSharp 组合、HttpClient + HtmlAgilityPack 组合),最后把我在多平台部署和实际抓取中踩过的坑整理成一份速查表。读完你至少能判断两件事:自己的场景适不适合上框架,以及上手后哪些坑可以提前绕开。
1. 什么算“开箱即用”,.NET 为什么需要它
1.1 一个能用的爬虫,最少要包含哪些部件
很多人觉得“爬虫就是发个 HTTP 请求然后把 HTML 解析一下”,这个理解太乐观了。真实项目里,一个能稳定跑的爬虫,至少需要下面这几块东西协同工作:
- 并发下载器:不能一个页面一个页面地串行请求,否则抓个几千页会等到天荒地老
- URL 调度器:要决定接下来抓哪个链接,还要记录哪些已经抓过,避免重复抓
- 去重机制:尤其是抓列表页、翻页链接、详情页链接时,不去重会浪费大量请求,还容易被网站封
- 页面解析:CSS 选择器、XPath、正则,总要有一个好用的抽象,不能每次写一堆字符串切割
- 数据持久化:抓下来的数据要落库,落到 MySQL、MongoDB、CSV、JSON 都行,最好切换时不用改业务代码
- 异常处理与重试:网络抖动、目标站超时、偶发 5xx,都需要自动重试而不是直接崩掉
- 日志与监控:大规模采集时,你总得知道当前跑到哪、成功多少、失败多少
如果你用最朴素的 HttpClient 手写,以上每一项都要自己造轮子。单独看每一项都不难,但它们组合在一起,工程量就很可观了,而且每做一个新项目都要重写一遍。这也是“框架”存在的意义:把通用能力沉淀成约定,你只管写业务规则。
1.2 Python 有 Scrapy,.NET 为什么不能凑合
Python 生态里 Scrapy 太成熟了,以至于大家都觉得“爬虫库 = Python 专属”。但现实是很多团队的核心业务是 C# / .NET 写的,数据管道、消息队列、业务系统都在 .NET 这一侧。为了抓数据再单独起一套 Python 服务,维护成本、部署成本、跨语言沟通成本都会翻倍。
.NET 以前的尴尬在于:标准库里没有好用的 HTML 解析器,第三方库也比较散,抓个网页要拼装很多组件。.NET Core 出现之后,局面有了两个根本性改变。第一,运行时跨平台了,写好的爬虫可以发布到 Linux 服务器、容器里,不用再被 Windows 绑定;第二,社区开始出现组织化程度更高的爬虫框架。DotnetSpider 就是其中诚意比较足的一个,它把 Scrapy 那套“下载器 + 调度器 + 中间件 + Item Pipeline”的思路搬到了 C# 里,同时保留了 C# 的强类型和 async 特性。
说白了,.NET 不是没有爬虫库,而是过去没有一个足够“开箱即用”的。现在有了,而且它跟 .NET 主栈天然兼容,你可以在一个项目里同时写完采集、清洗、入库、分析的整条链路,这对团队来说是非常舒服的。
2. DotnetSpider 怎么做到“开箱即用”
2.1 核心能力拆解:调度、去重、下载、数据流
DotnetSpider 的架构思路,用一句话概括:把爬虫的通用流程拆成一个个可插拔的环节,你只管往流水线上放自己的逻辑。
- Spider 抽象类:你通过继承 Spider 来定义一次爬取任务。在构造方法里指定起始请求、要抽取的实体类型、数据处理方式,框架负责调度和执行
- DataFlow 数据流:这是它的灵魂。爬虫从发出请求到拿到数据,会经过一长串数据流处理节点,比如 URL 规范化、去重、内容解析、数据清洗、持久化。你可以像写中间件一样把这些节点挂到任务上,也可以自定义自己的处理节点插进链里
- 去重机制:内置基于内存的去重队列,抓小站点开箱即用;如果抓取量大,可以换成 Redis 做分布式去重
- 多平台支持:库本身面向 .NET Standard,所以只要你的目标环境能跑 .NET,它就能跑。Windows 服务器、Linux 容器、macOS 开发机,都不需要额外适配
我特别喜欢它的一点是,你不用关心“下一个请求是怎么被选出来”的,也不用操心“一个页面解析出来的链接什么时候再压入队列”。这些框架都替你处理好了,你写的代码基本上就是“告诉我抓哪、怎么抓”。
2.2 比手写方案舒服的几个点
第一个舒服点是强类型实体抽取。手写解析的时候,你拿到的是一个字符串,然后做正则、Replace、Trim,非常啰嗦。DotnetSpider 允许你定义一个 C# 类来表示目标数据,字段上标注对应的 XPath 或 CSS 选择器,框架解析完 HTML 后会自动把数据映射到实体对象上。这不仅仅是省了几行代码,更关键的是让字段语义变得极其清晰,后期维护一看实体类就知道抓了哪些数据。
第二个舒服点是内置持久化方案。像 Scrapy 需要自己写 Item Pipeline 一样,DotnetSpider 也支持把实体直接输出到多种存储。你可以把一次采集的数据落地为 Json 文件做归档,也可以直接写进 MySQL 表,甚至集成消息队列做进一步分发。这种“切换存储不用改爬虫逻辑”的设计,在实际项目中太实用了。
第三个舒服点是并发控制。无脑开 100 个线程去抓,目标站几十毫秒就给你封 IP。框架里可以配置请求延迟、并发量、重试次数,方便你对不同站点做不同的压力策略。这些都是手写方案容易被忽略但实际非常关键的细节。
3. 实战:先用 5 分钟跑通一个商品列表爬虫
3.1 初始化项目,两步装好依赖
打开终端,先建立一个控制台项目,然后通过 NuGet 引入 DotnetSpider。具体的包版本以你在 NuGet 上拉到的为准,我这边用的是 4.x 系列。
dotnet new console -n SimpleCrawler cd SimpleCrawler dotnet add package DotnetSpider装完包的 csproj 看起来会多一行 PackageReference。如果你是在国内网络环境下拉取 NuGet 包偶尔超时,可以给 nuget.org 源配置一下超时时间,或者使用国内镜像源。这个跟爬虫本身无关,但属于新人经常卡住的环境问题。
3.2 定义实体类,声明抽取规则
假设我要抓的是一个演示站点的商品列表页,页面上每个商品包含名称、价格、描述三个字段,对应的 HTML 结构大概是:
<div class="card"> <h2 class="title"><a href="/p/1001">无线机械键盘</a></h2> <p class="price">299.00</p> <p class="desc">支持蓝牙三模连接,兼容多平台</p> </div>我定义一个 C# 实体类来承接数据。这里用到了特性标注选择器,具体特性名可能随版本略有变化,但思路是一致的:用 XP 标签声明这个字段对应的 XPath 路径,用 Css 声明 CSS 选择器路径。
using DotnetSpider.Selector; public class Product { [PropertyDefine(Expression = "//h2[@class='title']/a", Type = SelectorType.XPath)] public string Name { get; set; } [PropertyDefine(Expression = "//p[@class='price']", Type = SelectorType.XPath)] public decimal Price { get; set; } [PropertyDefine(Expression = "//p[@class='desc']", Type = SelectorType.XPath)] public string Description { get; set; } }这里有个细节值得注意:如果目标站的 HTML 结构经常变,XPath 写得太绝对(比如div[3]/div[1]/a)会很脆,优先用可靠的 class 属性或 id 锚定节点。等采集稳定了再考虑提取下一页、分页规律等扩展逻辑。
3.3 写一个最小的 Spider 入口
接下来继承 Spider,在构造函数里注册起始请求和实体类型,然后在底层方法里处理解析后的数据。框架会自动完成 HTML 下载和实体映射,我只需要关注业务结果。
using DotnetSpider; public class ProductSpider : Spider { public ProductSpider() : base(new SpiderOptions()) { AddRequests(new Request("https://example.com/list?page=1")); AddEntityType<Product>(); } protected override async Task OnExtracted(DataFlowContext context) { var products = context.GetEntities<Product>(); if (products == null || !products.Any()) { _logger.LogWarning("页面解析结果为 0,检查选择器是否匹配"); return; } await File.WriteAllTextAsync( "products.json", System.Text.Json.JsonSerializer.Serialize(products), Encoding.UTF8 ); _logger.LogInformation("本页提取到 {Count} 条商品", products.Count); } }看起来是不是没有“手写请求再手动解析再手工存文件”那种臃肿感?你在 OnExtracted 里拿到的是强类型实体列表,而不是一个充满字符串的文档对象。这个抽象在字段多、页面多的时候优势特别明显。
3.4 加入翻页与频率控制
真实的商品列表往往不止一页。一个常见的做法是在抽取完当前页后,从页面里找到“下一页”的链接,然后把它提交给调度器。这个逻辑可以放在自定义 DataFlow 里,也可以简单地在 OnExtracted 中解析下一页 URL 并 AddRequests。
var nextPage = context.Selectable.XPath("//a[@rel='next']/@href").GetValue(); if (!string.IsNullOrEmpty(nextPage)) { AddRequests(new Request(new Uri(new Uri("https://example.com"), nextPage).ToString())); }同时,我建议在 SpiderOptions 中设置请求延迟和重试次数,避免短时间高频率请求被目标站限制。比如每次请求间隔 200~500 毫秒,对大多数小站点来说已经是足够礼貌的节奏了。
3.5 发布到 Linux 服务器或容器
开发机上跑通只是第一步,爬虫迟早要挂到服务器上持续采。.NET 的发布命令可以把程序打成单目录或无依赖包,方便部署:
dotnet publish -c Release -r linux-x64 -o ./publish如果要用容器运行,一个最小的 Dockerfile 长这样:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app COPY publish/ . ENTRYPOINT ["dotnet", "SimpleCrawler.dll"]构建镜像时记得把源码和发布产物分开,运行容器时不带 SDK,镜像体积会小很多。这里的坑往往不是程序本身,而是容器内的时区、地区语言设置。比如有些网站在输出时间时依赖服务器时区,你得在容器里设置 TZ 环境变量,否则日志时间和数据里的时间会差出 8 小时。
4. 不想上框架?两套轻量组合同样能打
4.1 AngleSharp:只想要解析能力时选它
有些场景其实就是“定期抓一个接口或一个静态页,提取几个字段”,这么小的任务引入完整爬虫框架反而显得重。这时候我非常推荐 AngleSharp,它是 .NET 生态里最接近浏览器 DOM 的解析库,使用体验接近 jQuery 选择器。
它的用法非常直接。先安装包:
dotnet add package AngleSharp然后直接用 BrowsingContext 打开一个地址,使用 QuerySelectorAll 抽取节点:
using AngleSharp; using AngleSharp.Html.Parser; var context = BrowsingContext.New(Configuration.Default); var document = await context.OpenAsync("https://example.com/list"); var titles = document.QuerySelectorAll("h2.title a") .Select(x => x.TextContent.Trim()) .ToList(); foreach (var title in titles) { Console.WriteLine(title); }是不是很像在浏览器开发者工具里写选择器?AngleSharp 的优点是解析能力强、API 直觉化、不依赖浏览器进程,适合快速验证和中小规模采集。它的局限是不会主动帮你做 URL 调度和持久化,请求和存储还是要自己写。所以它更像是“一把趁手的解析刀”,而不是完整爬虫框架。
4.2 HttpClient + HtmlAgilityPack:最朴素但最可控
另一种非常常见的组合是 HttpClient 负责下载,HtmlAgilityPack 负责解析。HtmlAgilityPack 老牌、稳定、资料多,网上十个 .NET 爬虫教程有八个在用。它的 XPath 能力很扎实,适合目标页面结构比较固定、你愿意手写节点路径的场景。
using System.Net.Http; using HtmlAgilityPack; var http = new HttpClient(); http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 ..."); var html = await http.GetStringAsync("https://example.com/list"); var doc = new HtmlDocument(); doc.LoadHtml(html); var nodes = doc.DocumentNode.SelectNodes("//h2[@class='title']/a"); if (nodes != null) { foreach (var node in nodes) { Console.WriteLine(node.InnerText.Trim()); } }这个组合最大的优点是“可控”。每一层都是自己写的代码,出了问题你完全知道是哪一行导致的。缺点也很明显:所有调度、去重、解析失败处理都得自己搭。我自己的经验是,抓一次性的小报表、小接口,这套组合最快;要长期维护一个几十个页面的大型采集任务,还是上框架省心。
4.3 到底怎么选:我的场景对照表
| 选型 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|
| DotnetSpider | 长期、大型、多页面、需要去重和分布式 | 开箱即用、调度去重完备、支持多种持久化 | 学习曲线略陡,框架抽象较重 |
| AngleSharp | 中小规模、页面解析复杂、需要流畅 DOM 查询 | API 直观、解析能力强、启动轻量 | 不提供调度和持久化 |
| HttpClient + HtmlAgilityPack | 快速脚本、单页抓取、高定制需求 | 最灵活、无额外依赖、容易定位问题 | 所有组件都要自己写 |
| PuppeteerSharp | 目标站是动态渲染、必须执行 JS 才能拿数据 | 无头浏览器,所见即所得 | 资源占用高、速度慢、并发难控制 |
我见过很多团队一上来就上无头浏览器,结果因为不控制并发,CPU 和内存直接被打满。你要记住:能用普通 HTTP 抓的页面,不要用无头浏览器;能用框架解决的调度问题,不要自己维护一个类库去解决;能用正则/选择器定位的节点,不要反复解析整段 HTML。选型不迷信,要看场景。
5. 我踩过的坑和排查记录
5.1 常见问题速查表
这部分内容是我在实际跑采集任务时反复遇到并排查过的,直接整理成表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 目标站返回 403 | 请求头没有设置 User-Agent 或访问频率过快 | 添加浏览器 UA、Cookie,拉长请求间隔 |
| 解析结果全是空 | 目标站是 JS 动态渲染,HTML 里根本没有数据节点 | 检查响应 HTML 是否包含目标字段,必要时切换到无头浏览器 |
| 中文乱码 | 目标页面不是 UTF-8 编码,HttpClient 按 UTF-8 解码了 | 根据响应头 charset 指定编码,GBK 页面用 GBK 解码 |
| 请求超时/连接被重置 | 网络不稳定或目标站对高频访问做限制 | 增加超时时间、加入重试退避策略、降低并发 |
| 拉到网页但找不到想要的 class | 网站的 CSS 类名是动态生成的,可能每次加载不同 | 改用更稳定的结构锚点,比如 id 或 data 属性 |
| 程序能跑但数据重复 | 没有启用去重配置,同一个 URL 被反复请求 | 启用框架的去重队列,或自己维护一个 URL 集合 |
5.2 几个非常值得养成的习惯
第一,采集频率一定要克制。不管你的目标是电商还是资讯站,疯狂请求只会导致 IP 被封、网站性能受损。设置合理的请求间隔,像每天跑增量任务而不是 24 小时全速抓取,才是长期可持续的姿势。第二,解析 HTML 前先看一眼原始响应。很多新人拿不到数据就直接怀疑代码写错了,其实打开目标页面按 F12 看网络响应,十次里有六次是“数据根本不在静态 HTML 里”。第三,失败重试要做幂等。比如同一批详情页抓了两次,入库前要按业务主键去重,避免产生脏数据。
我自己的习惯是:每次解析失败时,把原始 HTML 落一份到本地 debug 目录,文件名用时间戳标记。这招看起来笨,但排查问题时特别有效,因为它保留了“现场”。等你真正面对一个解析偶发失败的诡异问题时,就知道现场有多重要了。
5.3 我在多平台部署时踩过的几个具体坑
第一次把爬虫发布到 Linux 容器时,我遇到过一个很有意思的问题:程序里打印的路径在本地 Windows 能用,到 Linux 上就报“找不到文件”。后来才意识到,文件路径分隔符和相对路径基准在跨平台时都不同。从那以后,我写任何涉及路径的代码都会用Path.Combine,绝不硬编码\或/,并且用Environment.CurrentDirectory明确基准目录。
另一个坑是字符编码。Windows 默认的文本编码和 Linux 容器里的 UTF-8 存在差异,如果抓取的页面本身不是 UTF-8,你需要在下载后显式指定编码去解码,而不是老是依赖默认配置。很多“换了个环境就乱码”的问题,根源都在这里。
再就是日志时区。容器里默认 UTC 时间,你人却在东八区,造成日志时间看起来永远差 8 小时。这个问题不影响采集逻辑,但排查问题时非常容易误判“任务是不是卡了”。我现在都会在 Dockerfile 里设置ENV TZ=Asia/Shanghai,把时区问题提前解决掉。
我个人的体会是,爬虫技术栈里没有银弹。DotnetSpider 这类框架帮你解决了调度、去重和持久化这些脏活,但页面结构变化、站点反爬策略、数据质量校验,这些永远需要你自己持续关注。框架再强,也只是把你的精力从“造轮子”挪到了“维护业务规则”上,这恰恰是它最有价值的地方。
最后再分享一个小技巧:如果你正在开发的爬虫会运行在多个服务器上,建议把采集任务的配置(起始 URL、选择器、请求间隔)外置到配置文件或者在管理后台动态下发,而不是硬编码在代码里。目标站的页面结构变了,不用重新编译发布程序,改一下配置就能恢复采集。这套思路搭配 DotnetSpider 的数据流扩展点,可以做出一个真正可维护的采集服务。