做数据采集的时候很多人第一反应是上 Python这句话在技术圈几乎成了默认答案。但如果你所在团队的技术栈是 .NET为了一个爬虫任务去引入第二套语言体系维护成本真的不低。今天我想认真推荐一个我在生产环境用了一年多的开源 .NET 爬虫库——DotnetSpider。它主打开箱即用基于 .NET Core/.NET 5 实现能在 Windows、Linux、Docker 上跑同一套代码从请求调度、页面解析到数据入库都给你安排好了基本不用自己从零封装那套爬虫基础设施。这篇文章不会只夸它好用我会把我当初的选型对比、最小可运行示例、多平台部署时踩过的坑、以及生产环境里遇到的实际问题都摊开讲。适合下面这类人看团队技术栈是 .NET/C#需要做数据采集但不想维护一套 Python 服务或者你已经用 HtmlAgilityPack HttpClient 手搓过爬虫想找一个更框架化的方案再或者你纯粹好奇 .NET 生态里到底有没有能打的爬虫库。看完你应该能判断它适不适合你的场景。1. 为什么我在 .NET 里做爬虫而不直接上 Python1.1 技术栈统一的成本账先算一笔账。假设你公司现有的业务系统是 .NET 写的数据库访问、日志、配置中心、定时任务全是 .NET 一套这时候爬虫需求来了。方案 A 是上 PythonScrapy 确实成熟但你得额外维护一套 Python 环境、一套依赖管理、一种新的代码风格还要解决Python 爬虫产出怎么进到现有数据库的对接问题。方案 B 是直接让爬虫成为现有 .NET 服务的一个模块用同一个日志框架、同一套配置中心、同一个 ORM爬下来的数据直接走现有入库逻辑。我选的是方案 B。不是因为 Python 不行而是因为对于规规矩矩抓公开页面、提取结构化数据、定时跑这类典型需求.NET 完全够用而技术栈统一带来的长期收益运维简单、团队上手快、代码可维护远比Python 生态更多库这个优势更实在。尤其是爬虫这种本身就容易出各种边界问题的场景多一套技术栈就多一份排障成本。1.2 .NET 做爬虫的真实优劣势.NET 做爬虫很多人担心性能但实际用下来瓶颈几乎从来不在语言本身。C# 的 async/await 处理高并发 IO 非常成熟一个普通进程开几十上百个并发请求毫无压力Span 和 ValueTask 在做了大量字符串解析时能明显降低 GC 压力而且 .NET 支持单文件发布部署一个爬虫进程就是一两个文件的事比装 Python 环境再 pip install 一堆依赖干净得多。劣势也是真实存在的。.NET 的爬虫生态确实比 Python 小比如 JS 逆向、验证码识别、各种反爬对抗的现成库和教程数量都少一个量级。所以如果你的核心场景是必须过复杂的风控、需要逆向加密参数、每天面对各种反爬对抗那 Python 甚至 Node 生态可能更合适。但反过来说大多数业务爬虫面对的是普通网站和公开接口这类场景 .NET 完全能扛住DotnetSpider 这类框架已经把通用脏活都封装好了。1.3 什么情况下我不建议你用 .NET 爬虫把丑话说在前面。如果你要抓的网站重度依赖浏览器渲染比如 Vue/React 这类 SPA 页面直接拿 HttpClient 抓 HTML 只能拿到空壳。这种情况在 .NET 里也有方案后面我会讲怎么接但整体体验确实不如 Python 的 Playwright 生态那么顺。另外如果你的目标是大型搜索引擎级别的全网抓取需要非常精细的调度策略和分布式的极致调优那 .NET 这边能参考的资料和案例都少得做好自己啃源码的准备。DotnetSpider 最舒服的区间是目标站点以服务端渲染为主或者你能直接调它的公开 JSON 接口数据量在百万级以内需要稳定的定时采集和入库。这个区间里它的开箱即用程度是能打的。2. 选型过程我比较过的几个 .NET 爬虫方案2.1 我当时放进候选清单的四个方案先说结论.NET 生态里没有一个像 Scrapy 一样一家独大的爬虫框架但可选的东西其实不少。我最开始从四个方向比较Abot一个老牌的 C# 爬虫框架胜在轻量、简单单机跑点小任务很快上手。缺点是开发活跃度一般太久没大版本更新框架感也比较弱调度、去重、解析都得自己拼。HtmlAgilityPack HttpClient这不算爬虫库是手搓派的标准组合。灵活度最高但所有基础设施都得自己写请求重试、URL 去重、队列管理、并发控制、异常恢复每一项都是工作量。我做第一个爬虫项目就是这么干的后来发现时间都花在重复造轮子上。PuppeteerSharp / Playwright for .NET无头浏览器方案能处理动态渲染调试体验也算好。但资源占用大一个浏览器实例动辄几百 MB 内存并发一高机器就吃紧不适合做大规模采集。DotnetSpider框架级方案自带了请求队列、URL 去重、下载器、解析器、数据存储、甚至分布式调度。我第一次看到它的文档时就感觉这就是 Scrapy 在 .NET 里的对应物。2.2 四个方案的横向对比方案定位动态渲染开箱即用程度分布式适用场景Abot轻量爬虫框架不支持中等需自行组装不支持单机小任务HtmlAgilityPack HttpClientHTML 解析库 HTTP 客户端不支持低纯手搓不支持高度定制的小爬虫PuppeteerSharp / Playwright无头浏览器支持中等需自己管理调度需自己实现需要 JS 渲染的页面DotnetSpider完整爬虫框架可扩展高默认已集成支持中大规模结构化采集2.3 最终选择 DotnetSpider 的理由我最终选 DotnetSpider核心不是某个单一功能而是它把爬虫的通用脏活都收敛到了一套框架里。你要做的只是定义抓什么、怎么解析、存到哪里剩下的事情——调度器怎么分配请求、下载器如何处理 HTTP 细节、失败的请求怎么重试、重复的 URL 怎么过滤——框架都有对应实现而且都是可以替换的接口设计。这一点在长期维护上特别值钱。举个例子我最早手搓爬虫时URL 去重是自己写 HashSet后来数据量大了 HashSet 内存扛不住又去换布隆过滤器折腾了一整周。用 DotnetSpider 之后调度器换一个实现就行业务代码一行不用动。这种框架替你兜底的感觉用久了就回不去了。3. 开箱即用体现在哪最小爬虫 30 分钟跑通3.1 环境准备和 NuGet 安装我的环境是 .NET 8 SDKVisual Studio 和命令行都行。新建一个控制台项目然后装包。以我用的 3.x 版本为例核心包是DotnetSpider如果你想直接入库 MySQL再加DotnetSpider.MySql用 Redis 做分布式队列就加DotnetSpider.Redis。我在 NuGet 里搜索 DotnetSpider安装主包就自动把 HttpClientDownloader、解析器、队列调度这些核心组件带进来了。dotnet new console -n DemoSpider cd DemoSpider dotnet add package DotnetSpider dotnet add package DotnetSpider.MySql装完之后项目基本就能跑了。这里要提醒一句DotnetSpider 的 API 在小版本之间确实会调整下面代码是以我实际使用的版本整理的你拿到新版本后如果发现方法名有出入以 NuGet 包里的 XML 文档为准整体思路不会变。3.2 定义实体和解析规则爬虫的第一步是告诉框架你要抓什么。最容易上手的方式是定义实体类用特性标注元素的提取规则。比如我要抓博客园首页的文章列表可以这样写public class ArticleEntity : EntityBaseArticleEntity { [PropertyDefine(Expression //div[classpost_item]//a[classtitlelnk]/text())] public string Title { get; set; } [PropertyDefine(Expression //div[classpost_item]//div[classpost_item_summary]/text())] public string Summary { get; set; } [PropertyDefine(Expression //div[classpost_item]//a[classtitlelnk]/href)] public string Url { get; set; } }这个写法的好处是解析和数据模型绑定在了一起字段和页面元素的关系一目了然。Expression里写的是 XPath这是 DotnetSpider 对结构化 HTML 最拿手的提取方式同样也支持 CSS 选择器和正则后面我会展开讲。3.3 启动入口接下来写启动代码。DotnetSpider 的启动方式类似 ASP.NET Core 的 builder 模式可以清晰地看到每个环节在用什么实现using DotnetSpider; using DotnetSpider.Downloader; using DotnetSpider.Scheduler; using Microsoft.Extensions.Hosting; var builder SpiderBuilder.CreateDefaultBuilder(); builder.UseSerilog(); builder.UseMySqlStorage(serverlocalhost;port3306;databasespider;userroot;passwordxxx;); builder.UseDownloaderHttpClientDownloader(); builder.UseSchedulerQueueDistinctScheduler(); builder.AddSpiderBlogSpider(); await builder.Build().RunAsync();对应的爬虫类长这样public class BlogSpider : Spider { public BlogSpider(IServiceProvider services) : base(services) { AddRequests(https://www.cnblogs.com/); } protected override async Task ParseAsync(Response response) { var selectable response.Selectable; var entities selectable.ParseToEntityArticleEntity(); // 框架会自动把实体写入配置好的存储 await Task.CompletedTask; } }跑起来之后观察运行日志你会看到框架自己完成了这些事启动调度器、发请求、下载 HTML、按 XPath 提取字段、把数据写进 MySQL、然后继续处理下一批请求。这个流程里你真正写的业务代码只有实体定义和启动配置这就是开箱即用的含义。3.4 第一个版本跑通后的目录结构和日志跑通一次之后项目结构很简单核心就几个文件实体类、Spider 类、Program.cs。Serilog 会在命令行直接输出每个请求的状态码、耗时、解析到的条数出问题一眼就能看到是请求挂了还是解析空了。我第一次跑通后最大的感受是以前手搓爬虫光是请求失败自动重试 日志记录这套东西就要写一天在这里居然是标配。4. 请求、解析、存储三个核心环节的实战写法4.1 请求配置UA、Cookie、Header、超时爬虫的请求层最常见的问题是被目标站点识别。DotnetSpider 的下载器允许你在请求层面做精细控制。一个比较实用的做法是设置随机 User-Agent避免所有请求带着同一个默认 UA 显得可疑var request new Request(https://example.com/list/1); request.Headers[User-Agent] GetRandomUserAgent(); request.Headers[Referer] https://example.com/; request.Timeout 10000; AddRequests(request);需要登录态的站点通常是在请求头里带 Cookie。我习惯在启动时从配置中心读 Cookie 字符串统一塞到每个请求的 Headers 里这样不会把敏感信息硬编码在代码中。超时设置方面我建议按目标站点的响应速度来别设太长否则一个卡住的请求会占着并发额度很久拖慢整体进度。4.2 解析器XPath、正则、CSS 选择器怎么选解析是爬虫里最花时间的活。DotnetSpider 支持三种主流方式我的选择经验是XPath最常用适合结构清晰的 HTML。它的表达能力最强能基于 DOM 层级精确定位比如找所有 div 里 class 包含 article 的区块。唯一要注意的是别写太长的绝对路径下面会讲为什么。正则适合 HTML 里嵌着 JSON 或者特定格式文本的场景。比如有些页面把数据塞在script里的 JSON 变量中XPath 提取不方便直接正则搜出那段 JSON 再反序列化反而更快。CSS 选择器如果你更熟悉前端CSS 选择器写起来最顺手。框架同样支持本质和 XPath 是同一套提取机制的两种表达。我自己的习惯是先用浏览器开发者工具在页面上验证 XPath 能选中目标元素再粘到实体特性里。这一步能省掉大量试错时间强烈建议做。4.3 存储MySQL、MongoDB、还是文件DotnetSpider 对存储做了抽象你只需要在启动时选择对应的扩展。我用过 MySQL 和 MongoDB 两种体验都挺顺。MySQL 场景下框架启动时会自动根据实体类建表按特性里的字段映射创建列MongoDB 则直接把实体序列化成文档存进去字段结构完全由你决定。选哪一个是业务问题不是技术问题。如果爬下来的数据要跟现有业务表做 join那就进 MySQL如果数据格式经常变、字段不固定MongoDB 更灵活如果只是临时调研先落成 JSON 文件也完全没问题。框架默认也支持输出到控制台适合一开始调试用。4.4 动态渲染页面怎么接遇到 SPA 页面纯 HttpClient 下载器拿到的是空 HTML。DotnetSpider 的下载器是可替换的社区里有基于 PuppeteerSharp 的下载器实现本质是把下载 HTML这一步换成启动无头浏览器渲染页面再取渲染后的 HTML。我自己实际用下来的经验是能不用就别用。无头浏览器方案内存消耗大、并发能力弱一个实例就吃掉几百 MB 内存。正确思路是优先找页面背后的 JSON 接口很多 SPA 都会有直接请求接口拿数据又快又稳。实在只能渲染时再把下载器切换成浏览器方案并且把并发数调低比如只开 2 到 4 个并发保证机器扛得住。5. 多平台部署Windows、Linux、Docker 下的差异和坑5.1 为什么多平台对爬虫这么重要爬虫这种任务天生适合部署在服务器上跑而生产服务器十有八九是 Linux。早期的 .NET Framework 绑死在 Windows 上跑个爬虫还得专门搞一台 Windows 服务器成本高且别扭。.NET Core 之后这个问题彻底消失同一套代码编译出来Windows、Linux、macOS 都能跑这也是我敢在生产用 .NET 爬虫的一个重要原因。5.2 Linux 服务器部署要点我的部署流程很简单服务器装好 .NET Runtime把项目发布产物传上去用 systemd 配一个服务来托管。关键一步是发布时用dotnet publish -c Release加--self-contained参数这样产物会带上 .NET 运行时服务器上连 SDK 都不用装一个文件夹拷过去就能跑dotnet publish -c Release -r linux-x64 --self-contained true -o ./publish然后写一个 systemd 服务文件设置好启动命令、日志路径、自动重启策略用systemctl start启动即可。我踩过的一个小坑是服务器 locale 问题日志里中文输出乱码。解决办法是在环境变量里强制设置 UTF-8或者干脆让日志走 Serilog 的 JSON 格式输出。5.3 Docker 容器化的完整姿势Docker 部署更干净。一个典型的 Dockerfile 大概长这样FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app COPY publish/ . ENV TZAsia/Shanghai ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT1 ENTRYPOINT [dotnet, DemoSpider.dll]这里有两个容易踩的坑。第一个是时区容器默认 UTC爬虫里如果用了DateTime.Now入库时间会和北京时间差 8 小时所以一定要显式设置TZ环境变量。第二个是全球化文化环境基础镜像里默认没有 ICU 包如果代码里有字符串比较、大小写转换这类操作建议设置DOTNET_SYSTEM_GLOBALIZATION_INVARIANT1但同时要注意这个设置会影响某些文化相关行为真需要做多语言处理时还是装 ICU 包更稳妥。5.4 容器里的资源限制和并发调优容器化之后一定要记得限制资源不然爬虫跑疯了会把整台机器拖垮。我在 docker-compose 里习惯这样配置services: spider: image: demospider:latest mem_limit: 2g cpus: 2 restart: unless-stopped内存限制在 2GB 以内对普通爬虫足够如果采集数据量大再根据实际监控调整。DotnetSpider 默认的并发量可能偏保守我自己会调大一点比如目标站点响应快、反爬不严的话把并发提升到 32 都很稳如果目标站点脆弱降到 4 到 8。记住一条原则爬虫的并发不是越大越好是目标站点能接受才叫好。6. 进阶玩法去重、限速、断点续爬与分布式6.1 URL 去重机制爬虫跑到一定规模最大的浪费源就是重复抓取。DotnetSpider 的调度器分两种QueueScheduler不去重QueueDistinctScheduler内置去重。单机数据量大时默认的内存去重容器哈希集合占用可观我会切换成带布隆过滤器的实现用很小的内存代价换掉大部分重复请求。分布式场景下把调度器的容器换成 Redis多个节点共享同一个 URL 队列去重就在 Redis 层完成。这个切换基本是配置级的业务代码完全不用动这也是我推荐用框架而不是手搓的关键原因之一。6.2 限速礼貌爬取也是长期收益限速这个问题很多初学者根本不设直到 IP 被对方风控系统封掉才追悔莫及。我的经验是哪怕目标站点看着毫无反爬也一定要设置请求间隔。DotnetSpider 里可以通过配置请求的延迟和并发上限来控制速率。一个务实的做法是初始阶段保守一点观察对方的响应头和日志确认没有异常后再逐步调大并发。这里多说一句道德层面的问题爬虫永远要尊重目标站点的robots.txt和服务条款控制好频率不要给对方服务器造成压力。我做爬虫的原则一直是——能友好采集就友好采集这既是对对方负责也是对自己长期稳定运行负责。6.3 断点续爬的价值爬虫跑一半挂了是常态服务器重启、内存不足、目标站点临时故障。如果队列是纯内存的重启之后所有进度全部丢失得从头开始。DotnetSpider 支持把调度队列持久化到 Redis配合框架的失败重试机制进程重启后可以接着上次的断点继续爬。我第一次用到断点续爬是在抓一个数据量比较大的站点预计跑 20 个小时。第 8 个小时服务器因为内核更新被重启了一次一开始我以为白跑了结果服务起来后发现队列自动恢复继续从断点开始抓那种感觉确实踏实。6.4 分布式调度与业务幂等当你有多台机器或者需要横向扩容时分布式就有意义了。架构很简单所有节点连接同一个 Redis共享一个队列谁空闲谁取任务。加节点就是加处理能力整个集群的吞吐量基本线性扩展。但分布式之后有个新问题——业务幂等。同一批数据可能被不同节点抓取多次虽然 URL 级去重能挡住大部分重复但入库时最好还是靠唯一键兜底比如在数据库表里给 URL 字段建唯一索引发生重复时用ON DUPLICATE KEY逻辑跳过。框架保证的是请求级去重最终的存储幂等必须自己在业务层负责。7. 我在生产环境踩过的几个坑7.1 解析规则写太死页面一改版全挂我第一次写给文章列表提取用的 XPath 写得特别长直接定位到div[1]/div[3]/div[2]这种深度结构。当时跑得欢结果站点改版只是加了一层容器整个爬虫一夜之间抓回来的数据全是空。从那以后我给自己立了规矩XPath 尽量基于 class 或 id 这种语义化特征来写不要依赖绝对层级解析完加一层数据校验比如标题为空或长度异常就判为解析失败发告警而不是默默入库。7.2 不设限速导致 IP 被限制还有一次爬一个数据量很大的公开站点图快把并发调到 64结果跑了几分钟对方直接把我们的 IP 段限制了那个站点的数据后来好几天都抓不了。教训很直接限速不是给对面面子是给自己留后路。现在我的配置都会先跑一个 30 分钟的小流量测试确认响应时间和错误率正常再决定要不要加并发。7.3 内存异常增长爬虫进程跑久了内存持续上涨一开始我怀疑是实体对象没释放。查下来发现真正的问题是我在解析回调里订阅了一些事件导致对象生命周期被拉长GC 根本回收不掉。这类问题的排查思路是内存涨了先 dump 进程看哪个对象数量异常大部分时候不是框架泄漏而是业务代码自己把引用留住了。7.4 时间字段和编码问题前文提到的时区问题我踩了一次之后长记性了。现在所有入库的时间字段统一用 UTC 存储展示层再转本地时间彻底避免不同服务器时区不一致导致的数据错乱。编码方面也出过一次问题目标页面没有声明 charset抓回来乱码。解决方式是在解析前先根据页面 Meta 或 HTTP 响应头判断编码再转成 UTF-8 交给解析器。DotnetSpider 的下载器有编码处理能力但有些老站点不规范还是自己兜底一层更稳。最后再分享一个我自己的习惯新爬虫上线前先抓 100 条数据人工核对一遍再让它自动跑。解析规则对不对、字段值干不干净、时间对不对这些一眼就能看出来。等确认无误了再放心交给调度器去做周期性的增量采集。爬虫这类系统稳定压倒一切慢一点没关系挂一次损失的时间远比调快那点并发要大。