前几天看到一个.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和其节点的写入用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, @"[[Ss]+]");//贪婪模式匹配,取得单页职位数组,每个职位信息为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("
", "").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("
", temp.Split(new string[] { "
", "
", "
" }, 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#用转义符表示,我跟踪发现这个字符明明已经被替换成空格,却仍在主函数字典化后出现,百思不得其解,网上没找到类似解答,只好对字典中的每个键的值再处理一次。有人知道原因的话望告知。
三、在主程序中引用
我用控制台,尝试.Net+C#两个关键词的搜索,至于拉勾网整站分类的关键词,则从一个文件中读取后分词,然后遍历,根据分类为它们创建文件目录。
Stopwatch sw = new Stopwatch();
sw.Start();
string cerPath = @"C:UsersgcmmwDownloadslagou.cer";//证书所在位置
string xmlSavePath = @"C:UsersgcmmwDownloadslagouCrawler.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+C#的数据我早就用工具开始爬了,下载见下一篇文章:数据分析:.Net程序员该如何选择?
总结:.Net做爬虫还是太麻烦了,没有好的解决方案,目前只看到有人封装了一个网络爬虫类HttpHelper,里面有收费的框架。