前幾天看到一個.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
,由這兩個函數調用。
主函數需要一些成員變量寄存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; }
/// <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; } }
/// <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 ""; } }
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+C#的數據我早就用工具開始爬了,下載見下一篇文章:數據分析:.Net程序員該如何選擇?
總結:.Net做爬蟲還是太麻煩了,沒有好的解決方案,目前只看到有人封裝了一個網絡爬蟲類HttpHelper,裡面有收費的框架。