
简介面向C#开发者的自动化测试与网页抓取实战资源基于Selenium WebDriver与Edge浏览器完整演示如何模拟用户操作获取CSDN博客内容适合需要掌握动态页面数据采集或浏览器自动化的初中级工程师。资源共60个文件、17.43MB核心包含10个C#源码文件涵盖窗体界面、页面数据解析、网络日志辅助与公共函数等模块另有编译生成的dll/exe、NuGet与项目配置文件、selenium-manager及EdgeDriver相关组件可直接打开Sprider.sln运行调试并二次开发。已有691人学习资源随附完整的VS工程目录结构从EdgeDriver初始化、目标页面导航、CSS/XPath元素定位到搜索触发、Ajax分页数据获取与浏览器清理等关键步骤均有对应实现针对动态加载、登录验证和常见反爬策略也给出了等待元素、执行JavaScript强制刷新等处理思路能帮助读者在真实抓取场景中快速上手并规避风险。1. 为什么按 F12 能看到的 CSDN 正文用 C# 的 HttpRequest 却拿不到——Selenium 的用武之地如果你用 C# 写一个普通的 HttpClient 请求去拉某个 CSDN 博客详情页十有八九拿回来的是一堆模板骨架标题在、正文区域的 div 也在但里面的文章内容要么是空的要么只有一小段摘要。这不是你的代码写错了而是因为现在的 CSDN 文章页早就不是纯静态 HTML正文、阅读数、相关推荐这些区块全部由 JavaScript 动态渲染部分图片还走懒加载。你在浏览器按 F12 能看到的 DOM是脚本执行完之后的“成品”HttpClient 拿到的只是“毛坯”。这个场景正是 Selenium 模拟浏览器的主场它启动一个真实浏览器内核让页面脚本完整跑完再用 C# 代码像真人操作一样去读取渲染后的页面内容。这篇笔记就围绕“C# 使用 Selenium 模拟浏览器获取 CSDN 博客内容”这件事从选型原理、环境搭建、核心提取代码到高频踩坑与交付细节一条线讲完。适合需要批量备份自己博客文章、做内容归档、统计阅读数据或者把文章迁移到其他平台的开发者前提是采集对象是你自己有权限或已获授权的内容。2. C# 为什么适合配 SeleniumWebDriver 架构与四个方案横评2.1 WebDriver 的 C/S 架构是谁在替我们操作浏览器在动手写代码之前先把 Selenium 的运行机制讲清楚因为它直接决定了你后面排错的思路。Selenium WebDriver 并不是一个直接调用浏览器底层接口的库而是一套基于 HTTP 协议的 C/S 架构C# 代码是客户端它把“打开网址”“查找元素”“点击按钮”这些操作序列化成 JSON 指令通过 HTTP 发送给一个独立的驱动程序进程这个驱动进程再把这些指令翻译成浏览器能理解的 DevTools 协议调用最终由浏览器内核真正执行。所以整条链路是“C# 代码 → HTTP/JSON 指令 → 驱动程序 → DevTools 协议 → 浏览器”。这个结构解释了三个常见的现象第一驱动程序版本必须和浏览器主版本匹配否则中间翻译层直接罢工第二执行顺序有依赖浏览器还没启动完成就去发指令必然超时第三关闭资源时必须从客户端到驱动到浏览器一层层释放漏掉任何一层都可能留下僵尸进程。我在本地调试时经常看到某公司同事把 ChromeDriver 和 Chrome 版本搞混然后一路“玄学排错”其实就是没理解中间还有个翻译层。2.2 四个方案横评为什么最终选了 Selenium 而不是其他路线我在梳理“C# 获取 CSDN 博客内容”这个需求时通常会把候选方案列成一张表再根据页面特点做取舍。按“能否拿到 JS 渲染后的内容、C# 接入成本、资源开销、适合场景”四个维度对比方案能否拿到渲染后内容C# 接入成本资源开销适合场景HttpClient 正则不能只能拿静态骨架低极低没有任何异步渲染的纯静态页HtmlAgilityPack 静态解析不能正文常为空低极低服务端直出的内容页Selenium WebDriver能完整渲染中需额外下载驱动高要跑一个浏览器进程需要真实渲染的采集与回归测试Playwright能功能更强中.NET SDK 相对年轻高新项目可以认真评估对于 CSDN 博客这种“正文靠 JS 渲染、图片懒加载、模板还经常调整”的目标页来说HttpClient 和 HtmlAgilityPack 拿到的内容是不完整的你还要额外处理字符集、Cookie 校验、重定向成本并不低。Playwright 在 .NET 生态里的 API 设计确实更现代但 Selenium 在 C# 领域的 NuGet 包、社区文章、踩坑记录都更成熟遇到问题时你能找到的答案明显更多。我一般会建议先从 Selenium 入手理由不是它最强而是它在 C# 这边的“确定性强”。2.3 驱动与浏览器版本的对应关系第一个最容易翻车的点现在 Chrome 浏览器几乎每个月都会自动更新而 ChromeDriver 驱动必须匹配 Chrome 的主版本号比如 Chrome 是 126.x驱动也应该是 126 开头。版本对不上时最常见的表现是程序启动后浏览器窗口一闪而过紧接着抛一个 session not created 的异常日志里写着 Chrome failed to start 或者 version mismatch。这里有两个惯例做法。一种是在项目里安装 Selenium.WebDriver.ChromeDriver 这个 NuGet 包它会尝试帮你下载对应版本的驱动但如果你本机 Chrome 刚更新完而 NuGet 包还没跟上节奏依然会翻车。所以我更习惯的做法是打开浏览器地址栏输入 chrome://version 确认当前版本号然后去 ChromeDriver 官方下载页手动拿匹配版本放到一个固定目录里在代码中用 DriverService 显式指定路径。这样版本完全可控升级浏览器时也清楚该更新什么。另外要注意生产服务器上的 Chrome 如果开启了自动更新你的采集脚本可能一夜之间就全挂了建议在部署环境里关闭浏览器自动更新让版本处于可管理状态。3. 把环境跑起来从空控制台到第一个能打印标题的 Selenium 程序3.1 三分钟建出工程NuGet 包安装与最小启动代码先从零开始跑通一个最小程序。打开终端执行下面的命令创建一个控制台工程并安装两个核心包dotnet new console -n CsBlogCrawler cd CsBlogCrawler dotnet add package Selenium.WebDriver dotnet add package Selenium.WebDriver.ChromeDriverSelenium.WebDriver 是客户端库提供 ChromeDriver、By、WebDriverWait 这些类型Selenium.WebDriver.ChromeDriver 是一个辅助包它把驱动文件作为依赖带进项目省去手动下载驱动的第一步。如果你在团队环境里不方便自动下载驱动也可以只装第一个包然后手动指定 DriverService 路径后面会讲。接着在 Program.cs 里写入最简启动代码using OpenQA.Selenium; using OpenQA.Selenium.Chrome; var options new ChromeOptions(); options.AddArgument(--disable-gpu); options.AddArgument(--window-size1920,1080); using var driver new ChromeDriver(options); driver.Navigate().GoToUrl(https://blog.csdn.net/你的用户名/article/details/你的文章ID); Console.WriteLine(driver.Title); driver.Quit();这段代码的逻辑是先构造 ChromeOptions 来声明启动参数然后基于它创建 ChromeDriver 实例Navigate().GoToUrl 告诉浏览器去哪个地址Title 属性会返回当前页面的标题。await 是不需要的因为 Selenium 的 API 是同步阻塞式的。两个启动参数里--disable-gpu 用于避免部分 Linux 环境下的渲染报错--window-size1920,1080 用于保证页面按桌面宽度渲染很多响应式页面在窄窗口下会改变 DOM 结构固定宽度能让选择器更稳定。using var driver 会在作用域结束时自动调用 Dispose它会尝试关闭浏览器和驱动进程。3.2 显式等待把“慢了半拍”赶走的第一道防线第一个能打印标题的程序跑通之后绝大多数人都会遇到同一个翻车现场去定位文章正文元素时偶尔能找到偶尔抛 NoSuchElementException而且抛异常的次数和网络延迟正相关。原因很简单——GoToUrl 方法返回时只代表浏览器收到了响应不代表页面上的 JS 脚本已经执行完更不代表正文已经被渲染进 DOM。Thread.Sleep 固定等几秒看起来能解决问题但这属于给运气写代码网络好时白等网络差时依然不够。正确做法是显式等待也就是让程序轮询某个条件直到条件满足或超时using OpenQA.Selenium.Support.UI; var wait new WebDriverWait(driver, TimeSpan.FromSeconds(15)); IWebElement titleElement wait.Until(d { var el d.FindElement(By.CssSelector(h1.title-article)); return string.IsNullOrWhiteSpace(el.Text) ? null : el; }); Console.WriteLine(titleElement.Text);Until 方法接收一个委托这个委托会被反复执行默认每 500 毫秒轮询一次直到它返回一个非空值或者超过 15 秒抛 WebDriverTimeoutException。我在这里做了一个关键处理它把“元素存在”和“元素有内容”两个条件合并在一起比直接用 FindElement 要稳得多。因为 CSDN 页面里标题标签可能一开始就在 DOM 里但文本还没有被 JS 填充这时候单纯判断“存在”是误判。如果你觉得 500 毫秒的轮询间隔太密可以这样设置wait.PollingInterval TimeSpan.FromSeconds(1);。这个数值在网络抖动大、页面响应慢的场景下反而更平滑。3.3 无头模式与浏览器参数安静跑完一次采集在本地调试时你当然希望浏览器窗口弹出来方便肉眼观察页面状态。但把脚本部署到服务器之后没有显示器也没有桌面环境再弹窗口不现实甚至直接报错。这时候要打开 Chrome 的无头模式让浏览器在后台运行var options new ChromeOptions(); options.AddArgument(--headlessnew); options.AddArgument(--no-sandbox); options.AddArgument(--disable-dev-shm-usage); options.AddArgument(--window-size1920,1080); options.AddArgument(--user-agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36); using var driver new ChromeDriver(options);这里四个参数各有用途--headlessnew 是新版无头模式的写法渲染结果比老版本更接近真实浏览器--no-sandbox 解决容器或部分服务器环境下沙箱权限不足导致的启动崩溃--disable-dev-shm-usage 避免 /dev/shm 空间太小导致页面渲染失败--user-agent 用来声明一个完整的浏览器标识否则某些页面会因为没有 UA 而拒绝返回完整资源。我给的这个 UA 串是示例格式你可以按自己本机浏览器的实际值替换。注意设置 UA 和关闭自动化特征是为了让页面按正常浏览器逻辑渲染而不是为了对抗验证码或登录风控。采集时遇到验证码或登录墙正确做法是停下来检查自己是否有权限而不是绕过。4. 拿到博客正文单篇提取、列表翻页与批量落地的完整代码4.1 页面结构拆解三个稳定的锚点元素在哪要让提取代码经得起模板改版先得知道 CSDN 博客详情页里哪些元素相对稳定。我按自己采集过的几个典型模板总结文章标题通常带 title-article 这个 class偶尔是 article-title发布时间一般在时间类标签里class 常见为 time阅读数的 class 常见为 read-count正文主体最常见的特征是有 idcontent_views 的容器或者直接用 article 标签包裹。但模板会随着平台改版变化写代码时切忌只认死一个选择器。正确的策略是给每个信息点准备一组“候选选择器”按优先级依次尝试。这样即使某个 class 被改掉了只要另一个还在程序就不会罢工。提取方法可以封装成一个工具函数后面所有信息点都复用它。4.2 单篇文章抓取标题、发布时间、阅读数与正文的完整提取代码下面的代码演示如何抓取一篇文章的四个核心字段并输出 JSON 结构的数据using System.Text.Json; using OpenQA.Selenium; using OpenQA.Selenium.Chrome; static IWebElement TryFindFirst(IWebDriver driver, params By[] candidates) { foreach (var by in candidates) { try { var el driver.FindElement(by); if (!string.IsNullOrWhiteSpace(el.Text) || by By.TagName(article)) return el; } catch (NoSuchElementException) { // 这个选择器没命中继续尝试下一个 } } return null; } var options new ChromeOptions(); options.AddArgument(--headlessnew); using var driver new ChromeDriver(options); var wait new WebDriverWait(driver, TimeSpan.FromSeconds(15)); driver.Navigate().GoToUrl(https://blog.csdn.net/你的用户名/article/details/你的文章ID); wait.Until(d TryFindFirst(d, By.CssSelector(h1.title-article), By.CssSelector(h1.article-title)) ! null); var title TryFindFirst(driver, By.CssSelector(h1.title-article), By.CssSelector(h1.article-title))?.Text?.Trim(); var publishTime TryFindFirst(driver, By.CssSelector(div.time), By.CssSelector(span.time))?.Text?.Trim(); var readCountText TryFindFirst(driver, By.CssSelector(span.read-count), By.CssSelector(div.read-count))?.Text?.Trim(); var contentElement TryFindFirst(driver, By.CssSelector(#content_views), By.TagName(article)); var imgs contentElement?.FindElements(By.CssSelector(img)) ?? new ListIWebElement(); foreach (var img in imgs) { var realSrc img.GetAttribute(data-original) ?? img.GetAttribute(src); // 这里可以把 realSrc 替换进正文图片节点便于离线保存 } var content contentElement?.Text?.Trim(); var article new { Title title, PublishTime publishTime, ReadCount readCountText, Content content }; Console.WriteLine(JsonSerializer.Serialize(article, new JsonSerializerOptions { WriteIndented true })); driver.Quit();这段代码的核心逻辑是“候选选择器 文本非空校验”。TryFindFirst 会依次尝试给定的选择器只有找到元素并且文本不为空才返回article 标签作为最后的兜底。GetAttribute(data-original) 的处理是图片懒加载的标准姿势很多模板把真实图片地址放在>var allLinks new HashSetstring(); for (int page 1; page 3; page) { var listUrl $https://blog.csdn.net/你的用户名/article/list/{page}; driver.Navigate().GoToUrl(listUrl); Thread.Sleep(1500); var links driver.FindElements(By.CssSelector(a[href*/article/details/])); var newCount 0; foreach (var link in links) { var href link.GetAttribute(href); if (allLinks.Add(href)) { newCount; // 这里记录 href稍后逐篇进入详情页提取 } } Console.WriteLine($第 {page} 页发现 {links.Count} 个链接新增 {newCount} 个); Thread.Sleep(2000); }HashSet 的作用是去重因为列表页里可能同一篇文章出现在多个分类区块中。翻页时有一个必须注意的细节每次 Navigate().GoToUrl 之后之前拿到的 IWebElement 引用会全部失效任何对旧元素的访问都会抛 StaleElementReferenceException。所以每翻一页必须重新执行 FindElements这就是循环体内先跳转再查找的原因。Thread.Sleep 在这里不是为元素加载兜底而是刻意控制请求频率避免短时间高频访问触发平台的访问频率限制。参数方面我给每个页面之间留了 1.5 秒到 2 秒的间隔这是“宁可慢一点也要稳”的习惯。如果你要采集的目标是允许批量获取的自己的内容可以用一个小的参数表来控制整体节奏参数建议初始值说明显式等待超时15 秒页面渲染最慢时的上限元素轮询间隔500 毫秒WebDriverWait 默认值抖动大可调 1 秒页面间休眠1.5 ~ 2 秒控制请求频率防止风控误判单次任务页数上限视数据量而定建议分批跑防止内存持续增长每页抓到的详情数据我一般会直接以 JSON 行格式追加到一个文件里或者插入 SQLite。最简单的方式是 File.AppendAllText(articles.jsonl, JsonSerializer.Serialize(article) Environment.NewLine);。这种格式的好处是即使程序中途挂了已抓到的数据都在磁盘上不存在“重头再来”的后悔药。5. 高频踩坑排查元素找不到、会话崩溃、风控拦截与内存泄漏5.1 元素找不到报 NoSuchElementException现象浏览器里肉眼能看到正文标题但 FindElement 就是抛 NoSuchElementException而且不是每次必现概率性出现。原因绝大多数情况是页面 JS 还没渲染完你等的不够。另一个隐蔽原因是元素不在默认的顶层文档里而是在 iframe 或 Shadow DOM 内部Selenium 默认只查找当前顶层文档。CSDN 的一些登录弹窗和二维码浮层偶尔会用 iframe 承载。解决先用 WebDriverWait 配合“元素存在且文本非空”的条件把等待做扎实。如果还是找不到在抛异常前打印 driver.PageSource 和当前所有 iframe 的个数确认元素是否在子框架里。确实在 iframe 里的话用 driver.SwitchTo().Frame(0) 切进去再定位定位完记得 SwitchTo().DefaultContent() 切回来。5.2 Chrome 无头模式下 Driver 会话秒崩现象程序一启动就抛 session not created或者浏览器打开后立刻跳到 about:blank日志里提示 Chrome failed to start。原因最常见的两个根源是 Chrome 与 ChromeDriver 主版本不匹配以及 Linux 服务器缺少运行 Chrome 需要的依赖库。无头模式不是去掉所有图形依赖它仍然需要部分系统库。解决先去 chrome://version 确认浏览器版本然后按主版本号重新下载匹配的驱动。如果版本对得上还是崩执行 ldd 检查 Chrome 的依赖是否齐全缺少的库用包管理器装上。我这边踩过一次是容器里缺 libatk 相关的 so 文件装上后就好了。排查时不要凭感觉换版本先把异常日志贴出来搜关键字效率会高很多。5.3 页面被风控拦截验证码与登录墙现象访问频率稍微高一点页面里就出现滑块验证码或者干脆跳到一个提示“系统检测到异常访问”的页面正文区域全部为空。原因短时间内从同一 IP、同一浏览器指纹发出高频请求触发了平台的风控策略。无头浏览器的自动化特征也可能被判为可疑行为。解决第一步是降低采集频率把页面间休眠提高到 3 到 5 秒观察是否恢复。第二步是复用真实用户数据目录让浏览器带上正常的登录状态和 Cookie减少被判定为陌生设备的概率。如果验证码已经出现正确的做法是停下来说明你的采集行为已经越界了不要试图去破解验证码或滑块。我在做任何采集前都会先确认数据权限边界只抓自己账号下的文章遇到风控就停工反思。5.4 批量采集几十页后内存狂涨现象程序跑到第五十页左右内存从刚开始的 300MB 涨到接近 2GB速度越来越慢最后整个进程被系统杀掉。原因每页都创建新的 driver 而没有释放旧会话或者 WebDriverWait 内部持有的页面引用没有被及时清理。浏览器进程本身不退出内存自然不会还给系统。解决把单次任务的页数上限设低一些比如每抓完 20 页就主动 driver.Quit() 并重新 new 一个 ChromeDriver。用 using 声明 driver 是一个好习惯它保证无论代码是否抛异常会话都会在作用域结束时释放。如果你发现进程中 chrome 子进程残留先用 taskkill 清掉再重跑否则下一次启动会因为没有锁而失败。5.5 控制台中文乱码与输出异常现象抓取到的内容是完整的中文但控制台打印出来全是乱码重定向到文件之后反而正常。原因Windows 控制台默认代码页是 GBK而页面内容和 JSON 输出是 UTF-8两边的编码对不上。解决在 Main 方法最开始加一行 Console.OutputEncoding System.Text.Encoding.UTF8;。文件写入时也显式指定 UTF-8不要依赖默认编码。这个小问题不解决会浪费很多时间因为你会误以为是抓取环节出了问题实际上数据本身是好的。6. 让脚本从“能跑”变成“能交付”Cookie 复用、去重与定时落库6.1 用同一个用户数据目录让网站把你当成老朋友每次启动 ChromeDriver 都是一次全新的浏览器会话没有登录状态、没有历史 Cookie、没有任何本地缓存这对采集来说很吃亏。一个很实用的做法是给 Chrome 指定固定的用户数据目录让登录状态和 Cookie 持久化到磁盘options.AddArgument(--user-data-dir/tmp/csdn-chrome-profile);第一次运行时取消无头模式手动在弹出的浏览器里完成登录然后关掉浏览器。之后再用同一个参数启动时Selenium 打开的浏览器就带着你的登录态很多需要登录后才能看到的完整数据就能直接读取了。这里有一个细节要注意程序正常退出时一定要调用 driver.Quit()如果直接 kill 进程用户数据目录里的锁文件可能没有被释放下一次启动会报“profile already in use”。处理办法是启动前检查残留的 chrome 进程或者把目录分两个轮流用。6.2 定时任务与去重落库交付一份干净数据批量采集脚本只是第一步真正能交付的是“定时跑、增量更、结果可查”的小工具。我一般会把流程拆成三块列表页发现新链接、详情页提取内容、结果写入 SQLite。去重在列表页已经用 HashSet 做过一轮落库时再对 URL 或文章 ID 建唯一索引双保险防止重复入库。定时调度不用引入复杂框架Windows 下用任务计划程序Linux 下用 crontab 就够如果是在 .NET 服务里跑用 BackgroundService 即可。配一个简单的日志记录每次任务的开始时间、新增数量、失败 URL这样即使某天模板改版导致抓取全挂也能通过日志快速定位是哪一步先出的问题。回看整个过程最大的教训是不要追求“一个脚本跑所有页面”也不要追求“一次抓完所有数据”。CSDN 的页面模板会变、网络环境会抖保持脚本的容错性比提高单次抓取速度更重要。我做这类采集工具时始终保留原始 JSON 行文件和运行日志这样每次改完选择器都能对比新旧数据确认没有静默丢失字段。希望这篇笔记能帮你把 C# 加 Selenium 这条路走稳少踩几个我踩过的坑。本文还有配套的精品资源点击获取