基於 Node.js 的聲明式可監控爬蟲網路

基於 Node.js 的聲明式可監控爬蟲網路從屬於筆者的,記述了筆者重構我司簡單爬蟲過程中構建簡單的爬蟲框架的思想與實現,代碼參考這裡

基於 Node.js 的聲明式可監控爬蟲網路

爬蟲是數據抓取的重要手段之一,而以 Scrapy、Crawler4j、Nutch 為代表的開源框架能夠幫我們快速構建分散式爬蟲系統;就筆者淺見,我們在開發大規模爬蟲系統時可能會面臨以下挑戰:

  • 網頁抓取:最簡單的抓取就是使用 HTTPClient 或者 fetch 或者 request 這樣的 HTTP 客戶端。現在隨著單頁應用這樣富客戶端應用的流行,我們可以使用 Selenium、PhantomJS 這樣的 Headless Brwoser 來動態執行腳本進行渲染。

  • 網頁解析:對於網頁內容的抽取與解析是個很麻煩的問題,DOM4j、Cherrio、beautifulsoup 這些為我們提供了基本的解析功能。筆者也嘗試過構建全配置型的爬蟲,類似於 Web-Scraper,然而還是輸給了複雜多變,多層嵌套的 iFrame 頁面。這裡筆者秉持代碼即配置的理念,對於使用配置來聲明的內建複雜度比較低,但是對於那些業務複雜度較高的網頁,整體複雜度會以幾何倍數增長。而使用代碼來聲明其內建複雜度與門檻相對較高,但是能較好地處理業務複雜度較高的網頁。筆者在構思未來的互動式爬蟲生成界面時,也是希望借鑒 FaaS 的思路,直接使用代碼聲明整個解析流程,而不是使用配置。

  • 反爬蟲對抗:類似於淘寶這樣的主流網站基本上都有反爬蟲機制,它們會對於請求頻次、請求地址、請求行為與目標的連貫性等多個維度進行分析,從而判斷請求者是爬蟲還是真實用戶。我們常見的方式就是使用多 IP 或者多代理來避免同一源的頻繁請求,或者可以借鑒 GAN 或者增強學習的思路,讓爬蟲自動地針對目標網站的反爬蟲策略進行自我升級與改造。另一個常見的反爬蟲方式就是驗證碼,從最初的混淆圖片到現在常見的拖動式驗證碼都是不小的障礙,我們可以使用圖片中文字提取、模擬用戶行為等方式來嘗試繞過。

  • 分散式調度:單機的吞吐量和性能總是有瓶頸的,而分散式爬蟲與其他分散式系統一樣,需要考慮分散式治理、數據一致性、任務調度等多個方面的問題。筆者個人的感覺是應該將爬蟲的工作節點儘可能地無狀態化,以 Redis 或者 Consul 這樣的能保證高可用性的中心存儲存放整個爬蟲集群的狀態。

  • 在線有價值頁面預判:Google 經典的 PageRank 能夠基於網路中的連接信息判斷某個 URL 的有價值程度,從而優先索引或者抓取有價值的頁面。而像 Anthelion 這樣的智能解析工具能夠基於之前的頁面提取內容的有價值程度來預判某個 URL 是否有抓取的必要。

  • 頁面內容提取與存儲:對於網頁中的結構化或者非結構化的內容實體提取是自然語言處理中的常見任務之一,而自動從海量數據中提取出有意義的內容也涉及到機器學習、大數據處理等多個領域的知識。我們可以使用 Hadoop MapReduce、Spark、Flink 等離線或者流式計算引擎來處理海量數據,使用詞嵌入、主題模型、LSTM 等等機器學習技術來分析文本,可以使用 HBase、ElasticSearch 來存儲或者對文本建立索引。

筆者本意並非想重新造個輪子,不過在改造我司某個簡單的命令式爬蟲的過程中發現,很多的調度與監控操作應該交由框架完成。Node.js 在開發大規模分散式應用程序的一致性(JavaScript 的不規範)與性能可能不如 Java 或者 Go。但是正如筆者在上文中提及,JavaScript 的優勢在於能夠通過同構代碼同時運行在客戶端與服務端,那麼未來對於解析這一步完全可以在客戶端調試完畢然後直接將代碼運行在服務端,這對於構建靈活多變的解析可能有一定意義。

總而言之,我只是想有一個可擴展、能監控、簡單易用的爬蟲框架,所以我快速擼了一個 declarative-crawler,目前只是處於原型階段,尚未發布到 npm 中;希望有興趣的大大不吝賜教,特別是發現了有同類型的框架可以吱一聲,我看看能不能拿來主義,多多學習。

設計思想與架構概覽

當筆者幾年前編寫第一個爬蟲時,整體思路是典型的命令式編程,即先抓取再解析,最後持久化存儲,就如下述代碼:

await fetchListAndContentThenIndex(n jsgc,n section.name,n section.menuCode,n section.categoryn).then(() => {n}).catch(error => {n console.log(error);n});n

不過就好像筆者在 2016-我的前端之路:工具化與工程化 與 2015-我的前端之路:數據流驅動的界面 中討論的,命令式編程相較於聲明式編程耦合度更高,可測試性與可控性更低;就好像從 jQuery 切換到 React、Angular、Vue.js 這樣的框架,我們應該儘可能將業務之外的事情交由工具,交由框架去管理與解決,這樣也會方便我們進行自定義地監控。總結而言,筆者的設計思想主要包含以下幾點:

  • 關注點分離,整個架構分為了爬蟲調度 CrawlerScheduler、Crawler、Spider、dcEmitter、Store、KoaServer、MonitorUI 等幾個部分,儘可能地分離職責。

  • 聲明式編程,每個蜘蛛的生命周期包含抓取、抽取、解析與持久化存儲這幾個部分;開發者應該獨立地聲明這幾個部分,而完整的調用與調度應該由框架去完成。

  • 分層獨立可測試,以爬蟲的生命周期為例,抽取與解析應當聲明為純函數,而抓取與持久化存儲更多的是面向業務,可以進行 Mock 或者包含副作用進行測試。

整個爬蟲網路架構如下所示,目前全部代碼參考這裡。

自定義蜘蛛與爬蟲

我們以抓取某個在線列表與詳情頁為例,首先我們需要針對兩個頁面構建蜘蛛,注意,每個蜘蛛負責針對某個 URL 進行抓取與解析,用戶應該首先編寫列表爬蟲,其需要聲明 model 屬性、複寫 before_extract、parse 與 persist 方法,各個方法會被串列調用。另一個需要注意的是,我們爬蟲可能會外部傳入一些配置信息,統一的聲明在了 extra 屬性內,這樣在持久化時也能用到。

type ExtraType = {n module?: string,n name?: string,n menuCode?: string,n category?: stringn};nnexport default class UAListSpider extends Spider {nn displayName = "通用公告列表蜘蛛";nn extra: ExtraType = {};nn model = {n $announcements: tr[height="25"]n };nn constructor(extra: ExtraType) {n super();nn this.extra = extra;n }nn before_extract(pageHTML: string) {n return pageHTML.replace(/<TR height=d*>/gim, "<tr height=25>");n }nn parse(pageElements: Object) {n let announcements = [];nn let announcementsLength = pageElements.$announcements.length;nn for (let i = 0; i < announcementsLength; i++) {n let $announcement = $(pageElements.$announcements[i]);nn let $a = $announcement.find("a");n let title = $a.text();n let href = $a.attr("href");n let date = $announcement.find(td[align="right"]).text();nn announcements.push({ title: title, date: date, href: href });n }nn return announcements;n }nn /**n * @function 對採集到的數據進行持久化更新n * @param pageObjectn */n async persist(announcements): Promise<boolean> {n let flag = true;nn // 這裡每個 URL 對應一個公告數組n for (let announcement of announcements) {n try {n await insertOrUpdateAnnouncement({n ...this.extra,n ...announcement,n infoID: href2infoID(announcement.href)n });n } catch (err) {n flag = false;n }n }nn return flag;n }n}n

我們可以針對這個蜘蛛進行單獨測試,這裡使用 Jest。注意,這裡為了方便描述沒有對抽取、解析等進行單元測試,在大型項目中我們是建議要加上這些純函數的測試用例。

var expect = require("chai").expect;nnimport UAListSpider from "../../src/universal_announcements/UAListSpider.js";nnlet uaListSpider: UAListSpider = new UAListSpider({n module: "jsgc",n name: "房建市政招標公告-服務類",n menuCode: "001001/001001001/00100100100",n category: "1"n}).setRequest(n "http://ggzy.njzwfw.gov.cn/njggzy/jsgc/001001/001001001/001001001001/?Paging=1",n {}n);nntest("抓取公共列表", async () => {n let announcements = await uaListSpider.run(false);nn expect(announcements, "返回數據為列表並且長度大於10").to.have.length.above(2);n});nntest("抓取公共列表 並且進行持久化操作", async () => {n let announcements = await uaListSpider.run(true);nn expect(announcements, "返回數據為列表並且長度大於10").to.have.length.above(2);n});n

同理,我們可以定義對於詳情頁的蜘蛛:

export default class UAContentSpider extends Spider {n displayName = "通用公告內容蜘蛛";nn model = {n // 標題n $title: "#tblInfo #tdTitle b",nn // 時間n $time: "#tblInfo #tdTitle font",nn // 內容n $content: "#tblInfo #TDContent"n };nn parse(pageElements: Object) {n ...n }nn async persist(announcement: Object) {n ...n }n}n

在定義完蜘蛛之後,我們可以定義負責爬取整個系列任務的 Crawler,注意,Spider 僅負責爬取單個頁面,而分頁等操作是由 Crawler 進行:

/**n * @function 通用的爬蟲n */nexport default class UACrawler extends Crawler {n displayName = "通用公告爬蟲";nn /**n * @構造函數n * @param confign * @param extran */n constructor(extra: ExtraType) {n super();nn extra && (this.extra = extra);n }nn initialize() {n // 構建所有的爬蟲n let requests = [];nn for (let i = startPage; i < endPage + 1; i++) {n requests.push(n buildRequest({n ...this.extra,n page: in })n );n }nn this.setRequests(requests)n .setSpider(new UAListSpider(this.extra))n .transform(announcements => {n if (!Array.isArray(announcements)) {n throw new Error("爬蟲連接失敗!");n }n return announcements.map(announcement => ({n url: `http://ggzy.njzwfw.gov.cn/${announcement.href}`n }));n })n .setSpider(new UAContentSpider(this.extra));n }n}n

一個 Crawler 最關鍵的就是 initialize 函數,需要在其中完成爬蟲的初始化。首先我們需要構造所有的種子鏈接,這裡既是多個列表頁;然後通過 setSpider 方法加入對應的蜘蛛。不同蜘蛛之間通過自定義的 Transformer 函數來從上一個結果中抽取出所需要的鏈接傳入到下一個蜘蛛中。至此我們爬蟲網路的關鍵組件定義完畢。

本地運行

定義完 Crawler 之後,我們可以通過將爬蟲註冊到 CrawlerScheduler 來運行爬蟲:

const crawlerScheduler: CrawlerScheduler = new CrawlerScheduler();nnlet uaCrawler = new UACrawler({n module: "jsgc",n name: "房建市政招標公告-服務類",n menuCode: "001001/001001001/00100100100",n category: "1"n});nncrawlerScheduler.register(uaCrawler);nndcEmitter.on("StoreChange", () => {n console.log("-----------" + new Date() + "-----------");n console.log(store.crawlerStatisticsMap);n});nncrawlerScheduler.run().then(() => {});n

這裡的 dcEmitter 是整個狀態的中轉站,如果選擇使用本地運行,可以自己監聽 dcEmitter 中的事件:

-----------Wed Apr 19 2017 22:12:54 GMT+0800 (CST)-----------n{ UACrawler: n CrawlerStatistics {n isRunning: true,n spiderStatisticsList: { UAListSpider: [Object], UAContentSpider: [Object] },n instance: n UACrawler {n name: UACrawler,n displayName: 通用公告爬蟲,n spiders: [Object],n transforms: [Object],n requests: [Object],n isRunning: true,n extra: [Object] },n lastStartTime: 2017-04-19T14:12:51.373Z } }n

服務端運行

我們也可以以服務的方式運行爬蟲:

const crawlerScheduler: CrawlerScheduler = new CrawlerScheduler();nnlet uaCrawler = new UACrawler({n module: "jsgc",n name: "房建市政招標公告-服務類",n menuCode: "001001/001001001/00100100100",n category: "1"n});nncrawlerScheduler.register(uaCrawler);nnnew CrawlerServer(crawlerScheduler).run().then(()=>{},(error)=>{console.log(error)});n

此時會啟動框架內置的 Koa 伺服器,允許用戶通過 RESTful 介面來控制爬蟲網路與獲取當前狀態。

介面說明

關鍵欄位

  • 爬蟲

// 判斷爬蟲是否正在運行nisRunning: boolean = false;nn// 爬蟲最後一次激活時間nlastStartTime: Date;nn// 爬蟲最後一次運行結束時間nlastFinishTime: Date;nn// 爬蟲最後的異常信息nlastError: Error;n

  • 蜘蛛

// 最後一次運行時間nlastActiveTime: Date;nn// 平均總執行時間 / msnexecuteDuration: number = 0;nn// 爬蟲次數統計ncount: number = 0;nn// 異常次數統計nerrorCount: number = 0;nncountByTime: { [number]: number } = {};n

localhost:3001/ 獲取當前爬蟲運行狀態

  • 尚未啟動

[n {n name: "UACrawler",n displayName: "通用公告爬蟲",n isRunning: false,n }n]n

  • 正常返回

[n {n name: "UACrawler",n displayName: "通用公告爬蟲",n isRunning: true,n lastStartTime: "2017-04-19T06:41:55.407Z"n }n]n

  • 出現錯誤

[n {n name: "UACrawler",n displayName: "通用公告爬蟲",n isRunning: true,n lastStartTime: "2017-04-19T06:46:05.410Z",n lastError: {n spiderName: "UAListSpider",n message: "抓取超時",n url: "http://ggzy.njzwfw.gov.cn/njggzy/jsgc/001001/001001001/001001001001?Paging=1",n time: "2017-04-19T06:47:05.414Z"n }n }n]n

localhost:3001/start 啟動爬蟲

{n message:"OK"n}n

localhost:3001/status 返回當前系統狀態

{n "cpu":0,n "memory":0.9945211410522461n}n

localhost:3001/UACrawle 根據爬蟲名查看爬蟲運行狀態

[ n { n "name":"UAListSpider",n "displayName":"通用公告列表蜘蛛",n "count":6,n "countByTime":{ n "0":0,n "1":0,n "2":0,n "3":0,n ...n "58":0,n "59":0n },n "lastActiveTime":"2017-04-19T06:50:06.935Z",n "executeDuration":1207.4375,n "errorCount":0n },n { n "name":"UAContentSpider",n "displayName":"通用公告內容蜘蛛",n "count":120,n "countByTime":{ n "0":0,n ...n "59":0n },n "lastActiveTime":"2017-04-19T06:51:11.072Z",n "executeDuration":1000.1596102359835,n "errorCount":0n }n]n

自定義監控界面

CrawlerServer 提供了 RESTful API 來返回當前爬蟲的狀態信息,我們可以利用 React 或者其他框架來快速搭建監控界面。


推薦閱讀:

Node.js 性能調優之調試篇(三)——三款實用調試工具
深入 Promise(二)——進擊的 Promise
採用Symbol和process.nextTick實現Promise
《球球大作戰》源碼解析——(1)運行起來
nodeJS 2016年官方技術調查報告

TAG:Nodejs | Web开发 | 爬虫 |