前几天看到一个.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和其节点的写入用XDocumentXElement来操作。由于需要从搜索列表进入到每个职位主页去获取详细信息,以及从网上下载下来的数据进行检查,清理某些会导致写入错误的控制字符,要创建两个分别负责网络爬取和特殊字符清理的方法,命名为GetHTMLToStringReplaceIllegalClar,由这两个函数调用。

2.1 主函数:

主函数需要一些成员变量寄存XDocumentXElement对象以及对职位的统计和索引,同时它还需要回调证书验证(不懂是什么鬼,没时间研究直接照抄网上的)。

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实现爬虫的更多相关文章

  1. 设计爬虫Hawk背后的故事

    本文写于圣诞节北京下午慵懒的午后.本文偏技术向,不过应该大部分人能看懂. 五年之痒 2016年,能记入个人年终总结的事情没几件,其中一个便是开源了Hawk.我花不少时间优化和推广它,得到的评价还算比较 ...

  2. Scrapy框架爬虫初探——中关村在线手机参数数据爬取

    关于Scrapy如何安装部署的文章已经相当多了,但是网上实战的例子还不是很多,近来正好在学习该爬虫框架,就简单写了个Spider Demo来实践.作为硬件数码控,我选择了经常光顾的中关村在线的手机页面 ...

  3. Python 爬虫模拟登陆知乎

    在之前写过一篇使用python爬虫爬取电影天堂资源的博客,重点是如何解析页面和提高爬虫的效率.由于电影天堂上的资源获取权限是所有人都一样的,所以不需要进行登录验证操作,写完那篇文章后又花了些时间研究了 ...

  4. scrapy爬虫docker部署

    spider_docker 接我上篇博客,为爬虫引用创建container,包括的模块:scrapy, mongo, celery, rabbitmq,连接https://github.com/Liu ...

  5. scrapy 知乎用户信息爬虫

    zhihu_spider 此项目的功能是爬取知乎用户信息以及人际拓扑关系,爬虫框架使用scrapy,数据存储使用mongo,下载这些数据感觉也没什么用,就当为大家学习scrapy提供一个例子吧.代码地 ...

  6. 120项改进:开源超级爬虫Hawk 2.0 重磅发布!

    沙漠君在历时半年,修改无数bug,更新一票新功能后,在今天隆重推出最新改进的超级爬虫Hawk 2.0! 啥?你不知道Hawk干吗用的? 这是采集数据的挖掘机,网络猎杀的重狙!半年多以前,沙漠君写了一篇 ...

  7. Python爬虫小白入门(四)PhatomJS+Selenium第一篇

    一.前言 在上一篇博文中,我们的爬虫面临着一个问题,在爬取Unsplash网站的时候,由于网站是下拉刷新,并没有分页.所以不能够通过页码获取页面的url来分别发送网络请求.我也尝试了其他方式,比如下拉 ...

  8. Python多线程爬虫爬取电影天堂资源

    最近花些时间学习了一下Python,并写了一个多线程的爬虫程序来获取电影天堂上资源的迅雷下载地址,代码已经上传到GitHub上了,需要的同学可以自行下载.刚开始学习python希望可以获得宝贵的意见. ...

  9. QQ空间动态爬虫

    作者:虚静 链接:https://zhuanlan.zhihu.com/p/24656161 来源:知乎 著作权归作者所有.商业转载请联系作者获得授权,非商业转载请注明出处. 先说明几件事: 题目的意 ...

  10. 让你从零开始学会写爬虫的5个教程(Python)

    写爬虫总是非常吸引IT学习者,毕竟光听起来就很酷炫极客,我也知道很多人学完基础知识之后,第一个项目开发就是自己写一个爬虫玩玩. 其实懂了之后,写个爬虫脚本是很简单的,但是对于新手来说却并不是那么容易. ...

随机推荐

  1. 提高Java开发效率,Idea必装的几款插件

    1.codeMaker 重复代码生成工具 domian和dto两个类基本差不多,这个工具就可以通过一个类快速生成另一个类. 2.gsonFormat json转换成Java类 很多时候根据别人接口给的 ...

  2. HDOJ-1671 Phone List

    Phone List Time Limit: 3000/1000 MS (Java/Others)    Memory Limit: 32768/32768 K (Java/Others) Probl ...

  3. hihocoder #1419 : 后缀数组四·重复旋律4

    #1419 : 后缀数组四·重复旋律4 时间限制:5000ms 单点时限:1000ms 内存限制:256MB 描述 小Hi平时的一大兴趣爱好就是演奏钢琴.我们知道一个音乐旋律被表示为长度为 N 的数构 ...

  4. Bzoj2829 信用卡凸包

    Time Limit: 10 Sec  Memory Limit: 128 MBSec  Special JudgeSubmit: 333  Solved: 155 Description Input ...

  5. codeforce div2 426 D. The Bakery

    D. The Bakery time limit per test 2.5 seconds memory limit per test 256 megabytes input standard inp ...

  6. 实现多线程sokect

    上一篇文章说到怎样写一个最简单的Java Socket通信,但是在上一篇文章中的例子有一个问题就是Server只能接受一个Client请求,当第一个Client连接后就占据了这个位置,后 续Clien ...

  7. VIM使用技巧2

    假如有以下程序片段: var foo = 1 var bar = 'a' var foobar = foo + bar 如果我们想在每行行尾加上分号, (1)使用$移动光标到行尾, (2)执行a;&l ...

  8. nodejs递归创建目录

    var fs = require("fs"); var path = require("path"); // 递归创建目录 异步方法 function mkdi ...

  9. RadioGroup动态添加RadioButton,并且获得事件

    由于有许多的RadioButton是动态的,不是固定的一些,所以需要在代码中,动态的添加到RadioGroup中,下面是我的实现方法. 1.添加RadioButton到RadioGroup中 Radi ...

  10. Pacman常用命令

    Pacman是Arch Linux 的包管理器.它将一个简单的二进制包格式和易用的构建系统结合了起来.不管软件包是来自官方的 Arch 库还是用户自己创建,Pacman 都能方便得管理. 更新系统 在 ...