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

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

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

爬蟲是數據抓取的重要手段之一,而以 ScrapyCrawler4jNutch 爲表明的開源框架可以幫咱們快速構建分佈式爬蟲系統;就筆者淺見,咱們在開發大規模爬蟲系統時可能會面臨如下挑戰:前端

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

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

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

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

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

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

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

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

設計思想與架構概覽

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

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

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

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

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

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

整個爬蟲網絡架構以下所示,目前所有代碼參考這裏

自定義蜘蛛與爬蟲

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

type ExtraType = {
  module?: string,
  name?: string,
  menuCode?: string,
  category?: string
};

export default class UAListSpider extends Spider {

  displayName = "通用公告列表蜘蛛";

  extra: ExtraType = {};

  model = {
    $announcements: 'tr[height="25"]'
  };

  constructor(extra: ExtraType) {
    super();

    this.extra = extra;
  }

  before_extract(pageHTML: string) {
    return pageHTML.replace(/<TR height=\d*>/gim, "<tr height=25>");
  }

  parse(pageElements: Object) {
    let announcements = [];

    let announcementsLength = pageElements.$announcements.length;

    for (let i = 0; i < announcementsLength; i++) {
      let $announcement = $(pageElements.$announcements[i]);

      let $a = $announcement.find("a");
      let title = $a.text();
      let href = $a.attr("href");
      let date = $announcement.find('td[align="right"]').text();

      announcements.push({ title: title, date: date, href: href });
    }

    return announcements;
  }

  /**
   * @function 對採集到的數據進行持久化更新
   * @param pageObject
   */
  async persist(announcements): Promise<boolean> {
    let flag = true;

    // 這裏每一個 URL 對應一個公告數組
    for (let announcement of announcements) {
      try {
        await insertOrUpdateAnnouncement({
          ...this.extra,
          ...announcement,
          infoID: href2infoID(announcement.href)
        });
      } catch (err) {
        flag = false;
      }
    }

    return flag;
  }
}

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

var expect = require("chai").expect;

import UAListSpider from "../../src/universal_announcements/UAListSpider.js";

let uaListSpider: UAListSpider = new UAListSpider({
  module: "jsgc",
  name: "房建市政招標公告-服務類",
  menuCode: "001001/001001001/00100100100",
  category: "1"
}).setRequest(
  "http://ggzy.njzwfw.gov.cn/njggzy/jsgc/001001/001001001/001001001001/?Paging=1",
  {}
);

test("抓取公共列表", async () => {
  let announcements = await uaListSpider.run(false);

  expect(announcements, "返回數據爲列表而且長度大於10").to.have.length.above(2);
});

test("抓取公共列表 而且進行持久化操做", async () => {
  let announcements = await uaListSpider.run(true);

  expect(announcements, "返回數據爲列表而且長度大於10").to.have.length.above(2);
});

同理,咱們能夠定義對於詳情頁的蜘蛛:

export default class UAContentSpider extends Spider {
  displayName = "通用公告內容蜘蛛";

  model = {
    // 標題
    $title: "#tblInfo #tdTitle b",

    // 時間
    $time: "#tblInfo #tdTitle font",

    // 內容
    $content: "#tblInfo #TDContent"
  };

  parse(pageElements: Object) {
    ...
  }

  async persist(announcement: Object) {
    ...
  }
}

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

/**
 * @function 通用的爬蟲
 */
export default class UACrawler extends Crawler {
  displayName = "通用公告爬蟲";

  /**
   * @構造函數
   * @param config
   * @param extra
   */
  constructor(extra: ExtraType) {
    super();

    extra && (this.extra = extra);
  }

  initialize() {
    // 構建全部的爬蟲
    let requests = [];

    for (let i = startPage; i < endPage + 1; i++) {
      requests.push(
        buildRequest({
          ...this.extra,
          page: i
        })
      );
    }

    this.setRequests(requests)
      .setSpider(new UAListSpider(this.extra))
      .transform(announcements => {
        if (!Array.isArray(announcements)) {
          throw new Error("爬蟲鏈接失敗!");
        }
        return announcements.map(announcement => ({
          url: `http://ggzy.njzwfw.gov.cn/${announcement.href}`
        }));
      })
      .setSpider(new UAContentSpider(this.extra));
  }
}

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

本地運行

定義完 Crawler 以後,咱們能夠經過將爬蟲註冊到 CrawlerScheduler 來運行爬蟲:

const crawlerScheduler: CrawlerScheduler = new CrawlerScheduler();

let uaCrawler = new UACrawler({
  module: "jsgc",
  name: "房建市政招標公告-服務類",
  menuCode: "001001/001001001/00100100100",
  category: "1"
});

crawlerScheduler.register(uaCrawler);

dcEmitter.on("StoreChange", () => {
  console.log("-----------" + new Date() + "-----------");
  console.log(store.crawlerStatisticsMap);
});

crawlerScheduler.run().then(() => {});

這裏的 dcEmitter 是整個狀態的中轉站,若是選擇使用本地運行,能夠本身監聽 dcEmitter 中的事件:

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

服務端運行

咱們也能夠以服務的方式運行爬蟲:

const crawlerScheduler: CrawlerScheduler = new CrawlerScheduler();

let uaCrawler = new UACrawler({
  module: "jsgc",
  name: "房建市政招標公告-服務類",
  menuCode: "001001/001001001/00100100100",
  category: "1"
});

crawlerScheduler.register(uaCrawler);

new CrawlerServer(crawlerScheduler).run().then(()=>{},(error)=>{console.log(error)});

此時會啓動框架內置的 Koa 服務器,容許用戶經過 RESTful 接口來控制爬蟲網絡與獲取當前狀態。

接口說明

關鍵字段

  • 爬蟲

// 判斷爬蟲是否正在運行
isRunning: boolean = false;

// 爬蟲最後一次激活時間
lastStartTime: Date;

// 爬蟲最後一次運行結束時間
lastFinishTime: Date;

// 爬蟲最後的異常信息
lastError: Error;
  • 蜘蛛

// 最後一次運行時間
lastActiveTime: Date;

// 平均總執行時間 / ms
executeDuration: number = 0;

// 爬蟲次數統計
count: number = 0;

// 異常次數統計
errorCount: number = 0;

countByTime: { [number]: number } = {};

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

  • 還沒有啓動

[
    {
        name: "UACrawler",
        displayName: "通用公告爬蟲",
        isRunning: false,
    }
]
  • 正常返回

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

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

http://localhost:3001/start 啓動爬蟲

{
    message:"OK"
}

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

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

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

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

自定義監控界面

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

相關文章
相關標籤/搜索