前几天看到一个.NET Core写成的爬虫,有些莫名的小兴奋,之前一直用集搜客去爬拉勾网的招聘信息,这个傻瓜化工具相当于用HTML模板页去标记DOM节点,然后在浏览器窗口上模拟人的浏览行为同时跟踪节点信息。它有很多好处,但缺点也明显:抓取速度慢;数据清洗和转储麻烦;只知其过程,不知其原理,网站改了模板或者要爬取别的网站,重现效率反而不如自己写个程序。
那么就自己实现一个?说干就干!
首先了解需要拉勾网的网页结构。对于搜索结果需要点击控件才能展示分页,不用这么麻烦,查看网络,发现每次点击下一页会向一个地址发出异步POST请求:

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 = ;//搜索结果中的重复职位数
int CountAdd = ;//去重后的总职位数
/// <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 = ; i <= ; 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[] { , };//如果失败直接退出
}
}
}
catch (Exception ex)
{
LogToTxt.Error("Json序列化失败,url:" + jobsPageUrl + ",错误信息:" + ex);
return new int[] { , };//如果失败直接退出
}
}
}
try
{
if (CountAdd>)//可能关键词搜不到内容
{
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[] { ,};
}
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();//尽量模仿人正常的浏览行为,每次进来先休息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 >= ) && (ss <= )) || ((ss >= ) && (ss <= )) || ((ss >= ) && (ss <= )))
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[] + count[] > )
{
string str = xmlSavePath + ":用时" + sw.Elapsed + ";去重后的总搜索结果数=" + count[] + ",搜索结果中的重复数=" + count[];
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+C#的数据我早就用工具开始爬了,下载见下一篇文章:数据分析:.Net程序员该如何选择?

总结:.Net做爬虫还是太麻烦了,没有好的解决方案,目前只看到有人封装了一个网络爬虫类HttpHelper,里面有收费的框架。

.Net实现拉勾网爬虫的更多相关文章

  1. pyqt与拉勾网爬虫的结合

    人力部需要做互联网金融行业的从业人员薪酬分析,起初说的是写脚本,然后他们自己改.但这样不太好,让人事部来修改py脚本不太好,这需要安装py环境和一些第三方包,万一脚本改来改去弄错了,就运行不起来了. ...

  2. 【Python3爬虫】拉勾网爬虫

    一.思路分析: 在之前写拉勾网的爬虫的时候,总是得到下面这个结果(真是头疼),当你看到下面这个结果的时候,也就意味着被反爬了,因为一些网站会有相应的反爬虫措施,例如很多网站会检测某一段时间某个IP的访 ...

  3. python3拉勾网爬虫之(您操作太频繁,请稍后访问)

    你是否经历过这个:那就对了~因为需要post和相关的cookie来请求~所以,一个简单的代码爬拉钩~~~

  4. scrapy抓取拉勾网职位信息(三)——爬虫rules内容编写

    在上篇中,分析了拉勾网需要跟进的页面url,本篇开始进行代码编写. 在编写代码前,需要对scrapy的数据流走向有一个大致的认识,如果不是很清楚的话建议先看下:scrapy数据流 本篇目标:让拉勾网爬 ...

  5. 拉勾网爬取全国python职位并数据分析薪资,工作经验,学历等信息

    首先前往拉勾网“爬虫”职位相关页面 确定网页的加载方式是JavaScript加载 通过谷歌浏览器开发者工具分析和寻找网页的真实请求,确定真实数据在position.Ajax开头的链接里,请求方式是PO ...

  6. (Pyhton爬虫03)爬虫初识

    原本的想法是这样的:博客整理知识学习的同时,也记录点心情...集中式学习就没这么多好记录的了! 要学习一门技术,首先要简单认识一下爬虫!其实可以参考爬虫第一章! 整体上介绍该技术包含技能,具体能做什么 ...

  7. 爬虫1.5-ajax数据爬取

    目录 爬虫-ajax数据爬取 1. ajax数据 2. selenium+chromedriver知识准备 3. selenium+chromedriver实战拉勾网爬虫代码 爬虫-ajax数据爬取 ...

  8. python就业班-淘宝-目录.txt

    卷 TOSHIBA EXT 的文件夹 PATH 列表卷序列号为 AE86-8E8DF:.│ python就业班-淘宝-目录.txt│ ├─01 网络编程│ ├─01-基本概念│ │ 01-网络通信概述 ...

  9. 「拉勾网」薪资调查的小爬虫,并将抓取结果保存到excel中

    学习Python也有一段时间了,各种理论知识大体上也算略知一二了,今天就进入实战演练:通过Python来编写一个拉勾网薪资调查的小爬虫. 第一步:分析网站的请求过程 我们在查看拉勾网上的招聘信息的时候 ...

随机推荐

  1. C#可扩展编程之MEF学习笔记(四):见证奇迹的时刻

    前面三篇讲了MEF的基础和基本到导入导出方法,下面就是见证MEF真正魅力所在的时刻.如果没有看过前面的文章,请到我的博客首页查看. 前面我们都是在一个项目中写了一个类来测试的,但实际开发中,我们往往要 ...

  2. jquery插件——点击交换元素位置(带动画效果)

    一.需求的诞生 在我们的网页或者web应用中,想要对列表中的元素进行位置调整(或者说排序)是一个常见的需求.实现方式大概就以下两种,一种是带有类似“上移”.“下移”的按钮,点击可与相邻元素交换位置,另 ...

  3. Java提高篇(二六)-----hashCode

          在前面三篇博文中LZ讲解了(HashMap.HashSet.HashTable),在其中LZ不断地讲解他们的put和get方法,在这两个方法中计算key的hashCode应该是最重要也是最 ...

  4. [FPGA] 2、新建并运行一个工程

    上一篇将开发板的情况大致介绍了一下,这次将一步一步展示如何新建.调试并下载运行一个点亮LED的工程. 1)打开Quartus新建工程: 2)填写规则大致如下: 3)选择我们芯片的类型: 4)点击fil ...

  5. 在设置代理的环境下使用SharePoint CSOM

    SharePoint 的CSOM都是通过HttpRequest来实现和SharePoint服务器的交互的,那么我们如何设置HttpWebRequest的一些特性呢,如Cookie,WebProxy? ...

  6. common-dbcp2数据库连接池参数说明

    参数 默认值 描述 建议值 DefaultAutoCommit  null 通过这个池创建连接的默认自动提交状态.如果不设置,则setAutoCommit 方法将不被调用.  true Default ...

  7. css自适应宽高等腰梯形

    t1是梯形, ct是梯形里面的内容. 梯形的高度会随着内容的高度撑高.宽度随着浏览器窗口变宽. 梯形上窄下宽或上宽下窄可以通过 transform 的大小来修改. <div class=&quo ...

  8. Atitit oodbms的查询,面向对象的sql查询jpa jpql hql

    Atitit oodbms的查询,面向对象的sql查询jpa jpql hql 1.1. 标准API历史1 1.2. JPA定义了独特的JPQL(Java Persistence Query Lang ...

  9. Visual Studio 2015 Bowser Link的功能不停的向服务端发送请求

    Visual Studio 2015新建的mvc项目 默认在每个视图上生成一些JavaScript脚本

  10. ftp下载目录下所有文件及文件夹内(递归)

    ftp下载目录下所有文件及文件夹内(递归)   /// <summary> /// ftp文件上传.下载操作类 /// </summary> public class FTPH ...