.NET实现爬虫
前几天看到一个.NET Core写成的爬虫,有些莫名的小兴奋,之前一直用集搜客去爬拉勾网的招聘信息,这个傻瓜化工具相当于用HTML模板页去标记DOM节点,然后在浏览器窗口上模拟人的浏览行为同时跟踪节点信息。它有很多好处,但缺点也明显:抓取速度慢;数据清洗和转储麻烦;只知其过程,不知其原理,网站改了模板或者要爬取别的网站,重现效率反而不如自己写个程序。
那么就自己实现一个?说干就干!
首先了解需要拉勾网的网页结构。对于搜索结果需要点击控件才能展示分页,不用这么麻烦,查看网络,发现每次点击下一页会向一个地址发出异步POST请求:
1
|
URL:https: //www.lagou.com/jobs/positionAjax.json?px=new&needAddtionalResult=false |
它的请求数据为(以.Net搜索的第2页为例):first=false&pn=2&kd=.NET
显然pn和kd分别传入的是页码和搜索关键词。
再检查它的响应报文,返回的是单页所有的职位信息,格式是JSON:

可以用JavaScriptSerializer
类的DeserializeObject
方法反序列为字典。
对于职位详情(每个职位的主页),返回的是html,解析html的工具包之前用Html Agility Pack,不过据说AngleSharp性能更优,这次打算换成它。
我马上想到了用Socket做一个客户端程序,先试了一下.NET Core,发现缺很多类库,太麻烦,还是用回.NETFramework,很快碰到了302重定向问题、Https证书问题,线程阻塞等一序列问题,Socket处理起来比较棘手,果断弃之,HttpWebRequest
简便,但是 Post请求同样也会发生302错误,伪装普通浏览器的请求头或者给它重定向都解决不了,试了试改换成Get方式发现可以避开所有的问题,不由得开心了起来,一不小心访问得过于频繁,导致如下结果:

这样就能阻止我?你这么难搞,干脆把整站扒下来。
正好手头有个Azure账号没过期,顺便开个虚拟机玩玩。
测试成功后写个正式的程序,我把它叫做拉勾职位采集器,入门级,今后如果用得多或者出现了新的问题还得动手升级它。
按照面向对象的思想,程序就像在不同的车床构造零部件最后再装配成产品,整个过程流水作业。我的基本思路是单个采集器实例采集一组关联关键词(有些关键词可以不作区分,如C#和.Net),存为单个xml文档(也可以存到数据库、Excel、缓存中,我比较习惯于存为xml然后再映射到Excel文档),过程用Log4Net记录日志。
第一步:规定采集器材料获取方式:
创建类:LagouWebCrawler
,定义它的构造函数和寄存字段:

class LagouWebCrawler
{
string CerPath;//网站证书本地保存地址
string XmlSavePath;//xml保存地址
string[] PositionNames;//关联关键词组
ILog LogToTxt;//Log4Net控制器
/// <summary>
/// 引用拉勾职位采集器
/// </summary>
/// <param name="_cerPath">证书所在位置</param>
/// <param name="_xmlSavePath">xml文件写入地址/param>
/// <param name="_positionNames">关联关键词组</param>
/// <param name="log">Log4Net控制器</param>
public LagouWebCrawler(string _cerPath, string _xmlSavePath,string [] _positionNames ,ILog log)
{
this.CerPath = _cerPath;
this.XmlSavePath = _xmlSavePath;
this.LogToTxt = log;
this.PositionNames = _positionNames;
}

第二步:设计采集器的行为
接下来定义这个采集器的行为,在采集器里用一个主函数作为其他函数的启动区,主函数命名为CrawlerStart
,只负责对搜索关键词组的拆分、json字符串的读(反序列化为字典)和最终xml的写;它有子函数负责对字典的读(数据清洗)和xml里面节点的写,子函数命名为JobCopyToXML
,xml和其节点的写入用XDocument
和XElement
来操作。由于需要从搜索列表进入到每个职位主页去获取详细信息,以及从网上下载下来的数据进行检查,清理某些会导致写入错误的控制字符,要创建两个分别负责网络爬取和特殊字符清理的方法,命名为GetHTMLToString
和ReplaceIllegalClar
,由这两个函数调用。
2.1 主函数:
主函数需要一些成员变量寄存XDocument
和XElement
对象以及对职位的统计和索引,同时它还需要回调证书验证(不懂是什么鬼,没时间研究直接照抄网上的)。

XDocument XWrite;//一组关联词搜索的所有职位放入一个XML文件中
XElement XJobs;//XDocument根节点
List<int> IndexKey;//寄存职位索引键,用于查重。
int CountRepeat = 0;//搜索结果中的重复职位数
int CountAdd = 0;//去重后的总职位数
/// <summary>
/// 爬取一组关联关键词的数据,格式清洗后存为xml文档
/// </summary>
/// <returns>int[0]+int[1]=总搜索结果数;int[0]=去重后的结果数;int[1]=重复数</returns>
public int[] CrawlerStart()
{
XWrite = new XDocument();
XJobs = new XElement("Jobs");//根节点
IndexKey = new List<int>();
foreach (string positionName in PositionNames)//挨个用词组中的关键词搜索
{
for (int i = 1; i <= 30; i++)//单个词搜索结果最多展示30页
{
string jobsPageUrl = "https://www.lagou.com/jobs/positionAjax.json?px=new&needAddtionalResult=false&first=false&kd=" + positionName + "&pn=" + i;
//回调证书验证-总是接受-跳过验证
ServicePointManager.ServerCertificateValidationCallback = new RemoteCertificateValidationCallback(CheckValidationResult);
string json = GetHTMLToString(jobsPageUrl, CerPath);//爬取单页
Match math = Regex.Match(json, @"\[[\S\s]+\]");//贪婪模式匹配,取得单页职位数组,每个职位信息为json字符串。
if (!math.Success) { break; }//若搜索结果不足30页,超出末页时终止当前遍历;或出现异常返回空字符串时终止。
json = "{\"result\":"+ math.Value +"}";
JavaScriptSerializer jss = new JavaScriptSerializer();
try
{
Dictionary<string, object> jsonObj = (Dictionary<string, object>)jss.DeserializeObject(json);//序列化为多层级的object(字典)对象
foreach (var dict in (object[])jsonObj["result"])//对初级对象(职位集合)进行遍历
{
Dictionary<string, object> dtTemp = (Dictionary<string, object>)dict;
Dictionary<string, string> dt = new Dictionary<string, string>();
foreach (KeyValuePair<string, object> item in dtTemp)//职位信息中某些键的值可能为空或者也是个数组对象,需要转换成字符
{
string str = null;
if (item.Value == null)
{
str = "";
}
else if (item.Value.ToString() == "System.Object[]")
{
str = string.Join(" ", (object[])item.Value);
}
else
{
str = item.Value.ToString();
}
dt[item.Key] = ReplaceIllegalClar(str);//清理特殊字符
}
if (!JobCopyToXML(dt))//将单个职位信息添加到XML根节点下。
{
return new int[] { 0, 0 };//如果失败直接退出
}
}
}
catch (Exception ex)
{
LogToTxt.Error("Json序列化失败,url:" + jobsPageUrl + ",错误信息:" + ex);
return new int[] { 0, 0 };//如果失败直接退出
}
}
}
try
{
if (CountAdd>0)//可能关键词搜不到内容
{
XWrite.Add(XJobs);//将根节点添加进XDocument
//XmlDocument doc = new XmlDocument();
//doc.Normalize();
XWrite.Save(XmlSavePath);
LogToTxt.Info("爬取了一组关联词,添加了" + CountAdd + "个职位,文件地址:" + XmlSavePath);
}
return new int[] { CountAdd, CountRepeat };
}
catch (Exception ex)
{
LogToTxt.Error("XDocument导出到xml时失败,文件:" + XmlSavePath + ",错误信息:" + ex);
return new int[] { 0,0};
}
return new int[] { CountAdd, CountRepeat };
}
/// <summary>
/// 回调验证证书-总是返回true-跳过验证
/// </summary>
private bool CheckValidationResult(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) { return true; }

2.2 子函数:

/// <summary>
/// 将每个职位数据清洗后添加到XDocument对象的根节点下
/// </summary>
private bool JobCopyToXML(Dictionary<string, string> dt)
{
int id = Convert.ToInt32(dt["positionId"]);//职位详情页的文件名,当作索引键
if (IndexKey.Contains(id))//用不同关联词搜出的职位可能有重复。
{
CountRepeat++;// 新增重复职位统计
return true;
}
IndexKey.Add(id);//添加一个索引
XElement xjob = new XElement("OneJob");
xjob.SetAttributeValue("id", id);
string positionUrl = @"https://www.lagou.com/jobs/" + id + ".html";//职位主页
try
{
xjob.SetElementValue("职位名称", dt["positionName"]);
xjob.SetElementValue("薪酬范围", dt["salary"]);
xjob.SetElementValue("经验要求", dt["workYear"]);
xjob.SetElementValue("学历要求", dt["education"]);
xjob.SetElementValue("工作城市", dt["city"]);
xjob.SetElementValue("工作性质", dt["jobNature"]);
xjob.SetElementValue("发布时间", Regex.Match(dt["createTime"].ToString(), @"[\d]{4}-[\d]{1,2}-[\d]{1,2}").Value);
xjob.SetElementValue("职位主页", positionUrl);
xjob.SetElementValue("职位诱惑", dt["positionAdvantage"]);
string html = GetHTMLToString(positionUrl, CerPath);//从职位主页爬取职位和企业的补充信息
var dom = new HtmlParser().Parse(html);//HTML解析成IDocument,使用Nuget AngleSharp 安装包
//QuerySelector :选择器语法 ,根据选择器选择dom元素,获取元素中的文本并进行格式清洗
xjob.SetElementValue("工作部门", dom.QuerySelector("div.company").TextContent.Replace((string)dt["companyShortName"], "").Replace("招聘", ""));
xjob.SetElementValue("工作地点", dom.QuerySelector("div.work_addr").TextContent.Replace("\n", "").Replace(" ", "").Replace("查看地图", ""));
string temp = dom.QuerySelector("dd.job_bt>div").TextContent;//职位描述,分别去除多余的空格和换行符
temp = string.Join(" ", temp.Trim().Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries));
xjob.SetElementValue("职位描述", string.Join("\n", temp.Split(new string[] { "\n ", " \n", "\n" }, StringSplitOptions.RemoveEmptyEntries)));
xjob.SetElementValue("企业官网", dom.QuerySelector("ul.c_feature a[rel=nofollow]").TextContent);
xjob.SetElementValue("企业简称", dt["companyShortName"]);
xjob.SetElementValue("企业全称", dt["companyFullName"]);
xjob.SetElementValue("企业规模", dt["companySize"]);
xjob.SetElementValue("发展阶段", dt["financeStage"]);
xjob.SetElementValue("所属领域", dt["industryField"]);
xjob.SetElementValue("企业主页", @"https://www.lagou.com/gongsi/" + dt["companyId"] + ".html");
XJobs.Add(xjob);
CountAdd++;//新增职位统计
return true;
}
catch (Exception ex)
{
LogToTxt.Error("职位转换为XElement时出错,文件:"+ XmlSavePath+",Id="+id+",错误信息:"+ex);
Console.WriteLine("职位转换为XElement时出错,文件:" + XmlSavePath + ",Id=" + id + ",错误信息:" + ex);
return false;
}
}

2.3 网络爬虫:

/// <summary>
/// Get方式请求url,获取报文,转换为string格式
/// </summary>
private string GetHTMLToString(string url, string path)
{
Thread.Sleep(1500);//尽量模仿人正常的浏览行为,每次进来先休息1.5秒,防止拉勾网因为访问太频繁屏蔽本地IP
try
{
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
request.ClientCertificates.Add(new X509Certificate(path));//添加证书
request.Method = "GET";
request.KeepAlive = true;
request.Accept = "text/html, application/xhtml+xml, */*";
request.ContentType = "text/html";
request.UserAgent = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)";
request.Credentials = CredentialCache.DefaultCredentials;//添加身份验证
request.AllowAutoRedirect = false;
byte[] responseByte = null;
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
using (MemoryStream _stream = new MemoryStream())
{
response.GetResponseStream().CopyTo(_stream);
responseByte = _stream.ToArray();
}
string html = Encoding.UTF8.GetString(responseByte);
return ReplaceIllegalClar(html);//进行特殊字符处理
}
catch (Exception ex)
{
LogToTxt.Error("网页:" + url + ",爬取时出现错误:" + ex);
Console.WriteLine("网页:" + url + ",爬取时出现错误:" + ex);
return "";
}
}

2.4 特殊字符处理:

private string ReplaceIllegalClar(string html)
{
StringBuilder info = new StringBuilder();
foreach (char cc in html)
{
int ss = (int)cc;
if (((ss >= 0) && (ss <= 8)) || ((ss >= 11) && (ss <= 12)) || ((ss >= 14) && (ss <= 31)))
info.AppendFormat(" ", ss);
else
{
info.Append(cc);
}
}
return info.ToString();
}

Q:为什么在主函数中要重复进行网络爬虫里已经进行过的特殊字符处理?
因为如果只在下载时处理,程序仍会报错:

这是个退格控制符,C#用转义符\b表示,我跟踪发现这个字符明明已经被替换成空格,却仍在主函数字典化后出现,百思不得其解,网上没找到类似解答,只好对字典中的每个键的值再处理一次。有人知道原因的话望告知。
三、在主程序中引用
我用控制台,尝试.Net+C#两个关键词的搜索,至于拉勾网整站分类的关键词,则从一个文件中读取后分词,然后遍历,根据分类为它们创建文件目录。

Stopwatch sw = new Stopwatch();
sw.Start();
string cerPath = @"C:\Users\gcmmw\Downloads\lagou.cer";//证书所在位置
string xmlSavePath = @"C:\Users\gcmmw\Downloads\lagouCrawler.xml";//xml文件存放位置
log4net.Config.XmlConfigurator.Configure();//读取app.config中log4net的配置
ILog logToTxt = LogManager.GetLogger(typeof(Program));
string[] positionNames = new string[] { ".Net", "C#" };//搜索关键词组
LagouWebCrawler lwc = new LagouWebCrawler(cerPath, xmlSavePath, positionNames,logToTxt);
int[] count = lwc.CrawlerStart();
sw.Stop();
if (count[0] + count[1] > 0)
{
string str = xmlSavePath + ":用时" + sw.Elapsed + ";去重后的总搜索结果数=" + count[0] + ",搜索结果中的重复数=" + count[1];
Console.WriteLine(str);
}
else
{
Console.WriteLine("遇到错误,详情请检查日志");
}
Console.ReadKey();

关于Log4Net的配置网上分享的很多,我改了下日志的格式便于阅读:
<!--日志格式-->
<layout type="log4net.Layout.PatternLayout">
<conversionPattern value="日志级别:%p 日志时间:%date 操作信息:[%m]% 线程ID:%t 线程运行毫秒数:%r %n%n"/>
</layout>
整站的采集结果:

.NET实现爬虫的更多相关文章
- 设计爬虫Hawk背后的故事
本文写于圣诞节北京下午慵懒的午后.本文偏技术向,不过应该大部分人能看懂. 五年之痒 2016年,能记入个人年终总结的事情没几件,其中一个便是开源了Hawk.我花不少时间优化和推广它,得到的评价还算比较 ...
- Scrapy框架爬虫初探——中关村在线手机参数数据爬取
关于Scrapy如何安装部署的文章已经相当多了,但是网上实战的例子还不是很多,近来正好在学习该爬虫框架,就简单写了个Spider Demo来实践.作为硬件数码控,我选择了经常光顾的中关村在线的手机页面 ...
- Python 爬虫模拟登陆知乎
在之前写过一篇使用python爬虫爬取电影天堂资源的博客,重点是如何解析页面和提高爬虫的效率.由于电影天堂上的资源获取权限是所有人都一样的,所以不需要进行登录验证操作,写完那篇文章后又花了些时间研究了 ...
- scrapy爬虫docker部署
spider_docker 接我上篇博客,为爬虫引用创建container,包括的模块:scrapy, mongo, celery, rabbitmq,连接https://github.com/Liu ...
- scrapy 知乎用户信息爬虫
zhihu_spider 此项目的功能是爬取知乎用户信息以及人际拓扑关系,爬虫框架使用scrapy,数据存储使用mongo,下载这些数据感觉也没什么用,就当为大家学习scrapy提供一个例子吧.代码地 ...
- 120项改进:开源超级爬虫Hawk 2.0 重磅发布!
沙漠君在历时半年,修改无数bug,更新一票新功能后,在今天隆重推出最新改进的超级爬虫Hawk 2.0! 啥?你不知道Hawk干吗用的? 这是采集数据的挖掘机,网络猎杀的重狙!半年多以前,沙漠君写了一篇 ...
- Python爬虫小白入门(四)PhatomJS+Selenium第一篇
一.前言 在上一篇博文中,我们的爬虫面临着一个问题,在爬取Unsplash网站的时候,由于网站是下拉刷新,并没有分页.所以不能够通过页码获取页面的url来分别发送网络请求.我也尝试了其他方式,比如下拉 ...
- Python多线程爬虫爬取电影天堂资源
最近花些时间学习了一下Python,并写了一个多线程的爬虫程序来获取电影天堂上资源的迅雷下载地址,代码已经上传到GitHub上了,需要的同学可以自行下载.刚开始学习python希望可以获得宝贵的意见. ...
- QQ空间动态爬虫
作者:虚静 链接:https://zhuanlan.zhihu.com/p/24656161 来源:知乎 著作权归作者所有.商业转载请联系作者获得授权,非商业转载请注明出处. 先说明几件事: 题目的意 ...
- 让你从零开始学会写爬虫的5个教程(Python)
写爬虫总是非常吸引IT学习者,毕竟光听起来就很酷炫极客,我也知道很多人学完基础知识之后,第一个项目开发就是自己写一个爬虫玩玩. 其实懂了之后,写个爬虫脚本是很简单的,但是对于新手来说却并不是那么容易. ...
随机推荐
- BZOJ3129 [Sdoi2013]方程 【扩展Lucas】
题目 给定方程 X1+X2+. +Xn=M 我们对第l..N1个变量进行一些限制: Xl < = A X2 < = A2 Xn1 < = An1 我们对第n1 + 1..n1+n2个 ...
- BZOJ1562 [NOI2009]变换序列 【KM算法】
题目 输入格式 输出格式 输入样例 5 1 1 2 2 1 输出样例 1 2 4 0 3 提示 30%的数据中N≤50: 60%的数据中N≤500: 100%的数据中N≤10000. 题解 每个位置可 ...
- Robot Framework通过Python SMTP进行email收发测试。
工作中需要对发送的邮件进行过滤,方法基本属于ACL控制,即查看“源/目的”邮件地址,邮件标题,邮件正文,邮件附件等进行过滤. 所以需要先模拟一下用Python能否达到邮件Client,Server的功 ...
- BestCoder 2nd Anniversary/HDU 5718 高精度 模拟
Oracle Accepts: 599 Submissions: 2576 Time Limit: 8000/4000 MS (Java/Others) Memory Limit: 262144/26 ...
- [暑假集训--数论]poj2909 Goldbach's Conjecture
For any even number n greater than or equal to 4, there exists at least one pair of prime numbers p1 ...
- 关于javascript的"+="连接符
今天在读<javascript Dom 编程艺术>的时候,看到了自己感觉陌生的+=连接符(小白一枚,各位勿耻笑) "+="连接符,可以看成完成一次“加法和赋值”(或者“ ...
- [CODEVS1130]数字反转
题目描述 Description 给定一个整数,请将该数各个位上数字反转得到一个新数.新数也应满足整数的常见形式,即除非给定的原数为零,否则反转后得到的新数的最高位数字不应为零. 这是一道水题.我必须 ...
- FOR XML PATH 语句的应用【所有列值显示在一行中】
原文发布时间为:2010-10-20 -- 来源于本人的百度文章 [由搬家工具导入] 大家都知道在SQL Server中利用 FOR XML PATH 语句能够把查询的数据生成XML数据,下面是它的一 ...
- 【简单的原创】div简单轮换显示
原文发布时间为:2009-05-10 -- 来源于本人的百度文章 [由搬家工具导入] <html><head><title>div简单轮换显示</title& ...
- 语法错误: 标识符“__RPC__out_xcount_part” 解决方法
1.错误描述 2.解决方案:将 $(DXSDK_DIR)\Include; 放到最后面,如下