简介:面向C#开发者的51job数据采集爬虫项目,基于HtmlAgilityPack完成HTML解析,帮助学习者掌握真实招聘网站数据抓取的完整链路。压缩包共38个文件,约344KB,主要包含C#源码(10个.cs)、工程与解决方案文件(.sln/.csproj)、程序配置(.config)、可执行文件与依赖库(.exe/.dll),以及pdb调试符号、resources/resx资源和settings设置文件,结构清晰,便于直接打开或编译运行。目前已有191人学习,项目虽小但覆盖网络请求、页面解析、数据提取、异常处理、数据存储、日志记录等关键环节。通过源码可学习用HttpClient或WebClient请求网页、用HtmlAgilityPack定位DOM节点与属性、多线程与异步提升采集效率,以及如何设置延时、更换UserAgent、避免IP封锁等实用策略。配套源码还包含请求头构造、编码处理与保存结果到本地的示例,能直接迁移至同类职位数据抓取场景。配合WinForm窗体界面与完整项目骨架,便于逐段调试理解爬虫运行机制,并为后续扩展其他招聘网站的数据采集提供可复用基础。
1. 51job 爬虫真正的难点不在解析,而在请求和采集节奏
很多人拿到这个项目的第一反应是去找 HtmlAgilityPack 的 XPath 怎么写,但真正让 51job 爬虫跑不起来的,往往是更前面的请求层:页面编码判断错、请求头不完整、连续翻页太快导致服务端返回 412。这个解决方案把 .NET 的 HttpClient、HtmlAgilityPack 和 WinForm 组装成了一个完整的采集器,从单个职位页面的 HTML 里扣出职位名、公司、薪资,再在界面上翻页采集。它适合刚学完 C# 语法、想拿第一个完整项目练手的人,也适合已经习惯用正则硬抠 HTML、想换成 DOM 解析思路的开发者。51job 这类站点的列表页结构比小网站稳定,但又不至于像纯静态页面那样毫无对抗性,难度正好卡在日常示例和真实业务之间,值得拆开看一遍。
2. HttpClient 请求层:先解决 412、编码和请求头
2.1 为什么用 HttpClient 而不是 WebClient
老项目里经常能看到 WebClient,因为它短平快,几行代码就能把字符串拉回来。但放到这个 51job 采集场景里,WebClient 有两个明显问题:一是超时控制不直接,默认超时往往让人等很久才报错;二是它拿 ResponseEntity 和原始字节流的能力弱,页面返回 GB2312 还是 UTF-8 不能灵活处理。HttpClient 在这两点上都要干净得多,而且连接复用做得更好,同一个实例连续请求多个分页比每次都新建 TCP 连接效率高。
这个项目的解决方案里同时存在 WinForm 窗体和一个控制台入口,不管从哪个入口发起采集,请求层都应该复用同一个 HttpClient。常见做法是把 HttpClient 声明成 MainForm 的字段,让它的生命周期和窗体一致,分页循环里只切换 URL,不重新 new client。如果按照很多教程那样每翻一页就new HttpClient(),不仅握手开销大,还会让本机端口在大量请求下被 TIME_WAIT 状态拖住。
2.2 一个带编码识别和请求头的 GetHtmlAsync
先看一段最核心的请求封装,它负责把 51job 列表页的 HTML 完整取回来:
private async Task<string> GetHtmlAsync(string url, string cookie = null) { using (HttpClient client = new HttpClient()) { client.Timeout = TimeSpan.FromSeconds(15); client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36"); client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"); client.DefaultRequestHeaders.Add("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); if (!string.IsNullOrEmpty(cookie)) client.DefaultRequestHeaders.Add("Cookie", cookie); HttpResponseMessage resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); resp.EnsureSuccessStatusCode(); byte[] bytes = await resp.Content.ReadAsByteArrayAsync(); string charset = resp.Content.Headers.ContentType?.CharSet; Encoding encoding = Encoding.UTF8; if (!string.IsNullOrEmpty(charset)) { try { encoding = Encoding.GetEncoding(charset.Trim('"')); } catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException) { } } return encoding.GetString(bytes); } }这段代码做了三件容易踩坑的事。HttpCompletionOption.ResponseHeadersRead表示拿到响应头就可以继续,不必等整个 body 全部缓冲完,对很大的页面更可控。ReadAsByteArrayAsync先取原始字节,再根据响应头里的 charset 手动解码,避免ReadAsStringAsync在你不知道页面编码时直接按默认规则解出乱码。Encoding.GetEncoding在 .NET Core/.NET 5+ 下要注意,需要先调用Encoding.RegisterProvider(CodePagesEncodingProvider.Instance),否则像 GB2312 这类代码页会直接抛 NotSupportedException。
2.3 返回 412 时按什么顺序排查
采集 51job 时最容易撞到的状态码就是 412 Precondition Failed。它本身的意思是服务端预条件不满足,落到爬虫场景里,绝大多数是请求头缺失、缺少必要 Cookie,或者短时间请求太集中。建议按下面的顺序排查,不要一上来就怀疑 HtmlAgilityPack 写错了。
| 请求头 | 值示例 | 为什么需要 |
|---|---|---|
| User-Agent | Mozilla/5.0 (Windows NT 10.0; Win64; x64) | 空 UA 或不常见 UA 容易被直接挡掉 |
| Accept | text/html,application/xhtml+xml | 声明客户端能接收的内容类型 |
| Accept-Language | zh-CN,zh;q=0.9,en;q=0.8 | 影响服务端返回的页面语言 |
| Referer | https://www.51job.com/ | 部分列表页校验来源是否站内 |
先补齐这几个再到循环里观察响应。还有一个更容易被忽略的点:浏览器开发者工具里看到的 Cookie 很多是页面加载后由 JavaScript 设置的,直接用 HttpClient 第一次请求拿不到。常见做法是先用浏览器手动访问一次列表页,用 Fiddler 或浏览器控制台把请求的 Cookie 值复制出来,临时塞进上面的cookie参数调试,通了之后再考虑正常会话流程。另一个经验是把第一次请求的 URL 固定到搜索首页,不要一上来就翻第 10 页,页面访问路径也会影响服务端的判断。
提示:如果你在调试时发现第一次请求就返回 412,优先检查 UA 和 Cookie;如果前几页正常、翻页后退到 412,那基本是频率问题,去看第 4 章的延时控制。
3. HtmlAgilityPack 节点定位:把列表页抠成 Job 对象
3.1 Project 里三个文件的分工
项目里 Program.cs 负责程序入口,MainForm.cs 承载按钮、进度条和 DataGridView,Job.cs 则是纯数据模型。这个分工很明确:MainForm 只管 UI 事件和采集调度,Job 只描述一条职位记录长什么样,真正的 HTML 解析可以单独拆成一个方法,不跟控件代码混在一起。
HtmlAgilityPack 和 XmlDocument 最大的区别是它能容忍不规范的 HTML。51job 的页面并不保证每个标签都闭合,属性值也可能带单引号,HtmlAgilityPack 自带修正机制,LoadHtml之后会生成一棵可以查询的节点树。在这种场景下不要去尝试把整个页面转成标准 XML,直接用 HtmlNode 集合做 XPath 查询即可。
3.2 用 XPath 定位职位列表和字段
51job 的列表页通常有一个包含多条职位的容器,每条职位记录是一个 div 块。具体 class 名会随着改版变化,下面这段是抓取时的常见结构示例,选节点的思路比 class 名的精确值更重要:
private List<Job> ParseJobs(string html) { List<Job> jobs = new List<Job>(); HtmlDocument doc = new HtmlDocument(); doc.LoadHtml(html); HtmlNodeCollection rows = doc.DocumentNode.SelectNodes( "//div[contains(@class,'dw_table')]//div[contains(@class,'el')]"); if (rows == null) { Debug.WriteLine("列表节点不存在,页面结构可能已调整"); return jobs; } foreach (HtmlNode row in rows) { Job job = new Job(); job.Title = row.SelectSingleNode(".//a[contains(@class,'jname')]")?.InnerText.Trim(); job.Company = row.SelectSingleNode(".//a[contains(@class,'cname')]")?.InnerText.Trim(); job.Salary = row.SelectSingleNode(".//span[contains(@class,'sal')]")?.InnerText.Trim(); job.Place = row.SelectSingleNode(".//span[contains(@class,'add')]")?.InnerText.Trim(); job.PubDate = row.SelectSingleNode(".//span[contains(@class,'time')]")?.InnerText.Trim(); if (string.IsNullOrWhiteSpace(job.Title)) continue; jobs.Add(job); } return jobs; }这里的 XPath 有几个细节。SelectNodes返回 null 而不是空集合,所以必须先判空,否则 foreach 直接抛 NullReferenceException。SelectSingleNode前面的.//表示从当前 row 节点向后代搜索,不加点就是整棵文档树范围,容易匹配到别的地方。如果某个字段不存在,?.会返回 null,InnerText.Trim()再拼接起来不会抛异常,但如果连 Title 都是空的,这一行数据就没有保存价值,直接continue跳过。
用表格把这里的字段和 XPath 映射关系整理出来,方便后续改版时对照调整:
| 字段 | Job 属性 | XPath 示例 |
|---|---|---|
| 职位名称 | Title | .//a[contains(@class,'jname')] |
| 公司名称 | Company | .//a[contains(@class,'cname')] |
| 薪资范围 | Salary | .//span[contains(@class,'sal')] |
| 工作地点 | Place | .//span[contains(@class,'add')] |
| 发布日期 | PubDate | .//span[contains(@class,'time')] |
3.3 页面结构变化时的兜底策略
51job 的列表页在搜索关键词不同的时候,节点结构可能不完全一样。比如有的页面职位名称放在<a>的 text 里,有的页面则拆成了<span>,这种情况下InnerText取到的内容会不同。稳定做法不是把所有 XPath 写死,而是在舍入逻辑里加一层容错:每个字段优先取精确 class,取不到就按父节点中的文本做兜底清洗。
还有一个经常被忽略的问题:HtmlWeb类和HtmlDocument.LoadHtml不能混着用。HtmlWeb会帮你做网络请求,但它的编码处理比较粗暴;这个项目应该先自己请求 HTML 字符串,再doc.LoadHtml交给解析器。把网络层和解析层拆开,得到的收益是:当页面返回 412 或被压缩成乱码时,你能在进入解析前就发现问题,而不是等到解析结果为空时再去猜是哪一步错了。
另外要注意HtmlAgilityPack版本差异。1.11.x 之后的 XPath 行为更严格,以前有些能匹配的//span[contains(@class,'time')]在改版后也可能失效。升级 NuGet 包之后如果解析结果明显变少,先怀疑版本变化,再怀疑页面改版。每次采集之前把doc.ParseErrors里的内容打出来看一遍,能提前发现 HTML 被截断的问题。
4. c# 循环采集与 UI 刷新:用 Task.Delay 替代 Thread.Sleep
4.1 为什么 Thread.Sleep(1000) 会把界面卡死
WinForm 的界面重绘、按钮点击、进度条更新都在 UI 线程的消息循环里执行。如果直接在按钮点击事件里写一个 for 循环,每翻一页就Thread.Sleep(1000),UI 线程会被整个阻塞住,界面看起来就是未响应,Windows 甚至可能提示用户关闭程序。这不是配置问题,而是同步代码天然的行为。
很多新手把它理解成"采集的时候界面卡一下没关系",但在分页采集 51job 这种场景里,一次完整采集少说要请求几十个页面,加上解析数据和刷新表格,UI 线程被占住的时间可能达到几十秒甚至几分钟。正确思路是让请求和解析跑在后台 Task 上,完成一页后再回到 UI 线程更新一次界面。
4.2 async/await 分页采集的完整写法
用 C# 写分页采集时,我一般把入口事件写成 async void,但实际的循环逻辑放在一个返回 Task 的方法里。这样既能在 UI 线程上启动采集,又不会把消息循环堵死:
private CancellationTokenSource _cts; private async void btn_start_Click(object sender, EventArgs e) { if (_cts != null) return; _cts = new CancellationTokenSource(); btn_start.Enabled = false; try { await LoopCollectAsync(_cts.Token); } catch (OperationCanceledException) { lbl_status.Text = "已停止"; } catch (Exception ex) { Debug.WriteLine(ex.ToString()); } finally { _cts.Dispose(); _cts = null; btn_start.Enabled = true; } } private async Task LoopCollectAsync(CancellationToken token) { for (int page = 1; page <= 20; page++) { token.ThrowIfCancellationRequested(); string url = BuildSearchUrl(txt_keyword.Text.Trim(), page); string html = await GetHtmlAsync(url); List<Job> jobs = ParseJobs(html); dataGridView1.DataSource = null; dataGridView1.DataSource = jobs; lbl_status.Text = $"第 {page} 页,采到 {jobs.Count} 条"; await Task.Delay(1200, token); } }这段代码的关键在await。页面切换时,数据刷新到 DataGridView 之后 UI 线程空闲下来,await Task.Delay会挂起当前方法但释放控件线程,界面可以正常拖动和操作。CancellationToken同时传给ThrowIfCancellationRequested和Task.Delay,用户点击停止按钮时_cts.Cancel()会立刻中断延时,不用等 1.2 秒才退出来。
private void btn_stop_Click(object sender, EventArgs e) { _cts?.Cancel(); }Thread.Sleep和await Task.Delay在采集场景里的差别需要一个表格看懂:
| 对比项 | Thread.Sleep(1200) | await Task.Delay(1200) |
|---|---|---|
| 占用线程 | 当前线程完全阻塞 | 当前线程被释放给线程池 |
| UI 响应 | 卡死 | 正常响应 |
| 取消响应 | 必须等睡眠结束 | 传入 CancellationToken 可立即中断 |
| 异步兼容 | 不适用 | 配合 async/await |
4.3 控制采集节奏,别把翻页写成 for 死循环
51job 的搜索列表页一般有页数上限,循环条件不要写成while (true),否则到达末页后反复请求同一批 URL,除了给自己增加 412 概率没有任何收益。正确做法是先请求第一页,解析出总页数或者判断当前页有没有职位数据,再决定是否继续下一页。
分页循环里翻页间隔不要只加在for语句末尾。每次请求完成之后、下一轮请求开始之前都要有一次延时,这里用的是Task.Delay(1200, token),1200 毫秒是一个相对温和的节奏,既不会慢到让用户等太久,也不会快到触发服务端拦截。如果GetHtmlAsync内部捕获并重试了超时请求,那么重试间隔要单独处理,不要在 catch 里立刻重新请求,否则出现网络抖动时,重试更容易被判定为异常流量。
5. 采集结果落盘与断点续抓:让数据真正可复用
5.1 CSV 保存时要处理逗号和引号
DataGridView 里的职位数据在程序退出后就没了,采集结果要落到磁盘才算完成。最轻量的存法是 CSV,但职位名称、公司介绍里经常出现逗号和双引号,直接拼接会破坏列结构。保存前必须做一次转义:
private static string EscapeCsv(string value) { if (value == null) return ""; if (value.Contains(",") || value.Contains("\"") || value.Contains("\n")) return "\"" + value.Replace("\"", "\"\"") + "\""; return value; } private void AppendCsv(string path, Job job) { string[] fields = { EscapeCsv(job.Title), EscapeCsv(job.Company), EscapeCsv(job.Salary), EscapeCsv(job.Place), EscapeCsv(job.PubDate) }; File.AppendAllText(path, string.Join(",", fields) + Environment.NewLine, Encoding.UTF8); }这里的主要规则是:字段里只要包含逗号、引号或者换行,就把整个字段用双引号包起来,字段内部的引号再替换成两个引号。用 Excel 打开导出文件验证一下列数是否正确,这一步能快速发现转义漏掉的地方。如果想存成结构化数据,建议用 Newtonsoft.Json 把 List 序列化成 JSON 文件,字段名和 Job.cs 属性一一对应,后续给其他程序消费也更方便。
5.2 断点续抓:记录已完成页码而不是重复跑
51job 数据量很大,一次采集跑到一半可能因为网络波动中断。较为省事的做法是每次完成一页后,把当前页码写到一个progress.txt文件里,下次启动时读取这个文件,从下一页继续。实现上只需要在LoopCollectAsync里加一行:
File.WriteAllText("progress.txt", page.ToString());如果MainForm每次启动都从第 1 页开始,之前抓过的数据就会重复,而且更容易触发拦截。把起始页码做成文本框的默认值,让用户自己改,也算一种断点能力。另一种做法是抓完一页就先写 CSV,重启后检查 CSV 里已有数据的最大页码,但这个思路依赖 URL 页码和 CSV 内容能对应上,实际操作不如直接记录进度文件直观。
5.3 抓回来但解析为空时,先看 ParseErrors
当ParseJobs返回 0 条记录时,不要急着改 XPath,先确认你传给 HtmlAgilityPack 的 HTML 到底是完整页面还是 412 报错页。调试期我会在解析前把原始 HTML 落盘:
File.WriteAllText($"_debug/page_{page}.html", html, Encoding.UTF8);然后检查HtmlDocument.ParseErrors,它能列出 HTML 被截断、标签不闭合等问题。如果 ParseErrors 里有大量错误,说明页面下载不完整;如果没有错误但选不到节点,再去对比落盘 HTML 里的实际 class 名。开发阶段我会在解析前把这行写到所有逻辑的前面:if (doc.ParseErrors.Count > 0) Debug.WriteLine(doc.ParseErrors[0].Reason);它和页面 dump 配合,几乎能解决所有“抓回来但是解析为空”的问题。
本文还有配套的精品资源,点击获取