基于 Node.js 的聲明式可監(jiān)控爬蟲網(wǎng)絡
爬蟲是數(shù)據(jù)抓取的重要手段之一,而以 Scrapy、Crawler4j、Nutch 為代表的開源框架能夠幫我們快速構建分布式爬蟲系統(tǒng);就筆者淺見,我們在開發(fā)大規(guī)模爬蟲系統(tǒng)時可能會面臨以下挑戰(zhàn):
- 網(wǎng)頁抓取:最簡單的抓取就是使用 HTTPClient 或者 fetch 或者 request 這樣的 HTTP 客戶端?,F(xiàn)在隨著單頁應用這樣富客戶端應用的流行,我們可以使用 Selenium、PhantomJS 這樣的 Headless Brwoser 來動態(tài)執(zhí)行腳本進行渲染。
- 網(wǎng)頁解析:對于網(wǎng)頁內(nèi)容的抽取與解析是個很麻煩的問題,DOM4j、Cherrio、beautifulsoup 這些為我們提供了基本的解析功能。筆者也嘗試過構建全配置型的爬蟲,類似于 Web-Scraper,然而還是輸給了復雜多變,多層嵌套的 iFrame 頁面。這里筆者秉持代碼即配置的理念,對于使用配置來聲明的內(nèi)建復雜度比較低,但是對于那些業(yè)務復雜度較高的網(wǎng)頁,整體復雜度會以幾何倍數(shù)增長。而使用代碼來聲明其內(nèi)建復雜度與門檻相對較高,但是能較好地處理業(yè)務復雜度較高的網(wǎng)頁。筆者在構思未來的交互式爬蟲生成界面時,也是希望借鑒 FaaS 的思路,直接使用代碼聲明整個解析流程,而不是使用配置。
- 反爬蟲對抗:類似于淘寶這樣的主流網(wǎng)站基本上都有反爬蟲機制,它們會對于請求頻次、請求地址、請求行為與目標的連貫性等多個維度進行分析,從而判斷請求者是爬蟲還是真實用戶。我們常見的方式就是使用多 IP 或者多代理來避免同一源的頻繁請求,或者可以借鑒 GAN 或者增強學習的思路,讓爬蟲自動地針對目標網(wǎng)站的反爬蟲策略進行自我升級與改造。另一個常見的反爬蟲方式就是驗證碼,從最初的混淆圖片到現(xiàn)在常見的拖動式驗證碼都是不小的障礙,我們可以使用圖片中文字提取、模擬用戶行為等方式來嘗試繞過。
- 分布式調(diào)度:單機的吞吐量和性能總是有瓶頸的,而分布式爬蟲與其他分布式系統(tǒng)一樣,需要考慮分布式治理、數(shù)據(jù)一致性、任務調(diào)度等多個方面的問題。筆者個人的感覺是應該將爬蟲的工作節(jié)點盡可能地無狀態(tài)化,以 Redis 或者 Consul 這樣的能保證高可用性的中心存儲存放整個爬蟲集群的狀態(tài)。
- 在線有價值頁面預判:Google 經(jīng)典的 PageRank 能夠基于網(wǎng)絡中的連接信息判斷某個 URL 的有價值程度,從而優(yōu)先索引或者抓取有價值的頁面。而像 Anthelion 這樣的智能解析工具能夠基于之前的頁面提取內(nèi)容的有價值程度來預判某個 URL 是否有抓取的必要。
- 頁面內(nèi)容提取與存儲:對于網(wǎng)頁中的結構化或者非結構化的內(nèi)容實體提取是自然語言處理中的常見任務之一,而自動從海量數(shù)據(jù)中提取出有意義的內(nèi)容也涉及到機器學習、大數(shù)據(jù)處理等多個領域的知識。我們可以使用 Hadoop MapReduce、Spark、Flink 等離線或者流式計算引擎來處理海量數(shù)據(jù),使用詞嵌入、主題模型、LSTM 等等機器學習技術來分析文本,可以使用 HBase、ElasticSearch 來存儲或者對文本建立索引。
筆者本意并非想重新造個輪子,不過在改造我司某個簡單的命令式爬蟲的過程中發(fā)現(xiàn),很多的調(diào)度與監(jiān)控操作應該交由框架完成。Node.js 在開發(fā)大規(guī)模分布式應用程序的一致性(JavaScript 的不規(guī)范)與性能可能不如 Java 或者 Go。但是正如筆者在上文中提及,JavaScript 的優(yōu)勢在于能夠通過同構代碼同時運行在客戶端與服務端,那么未來對于解析這一步完全可以在客戶端調(diào)試完畢然后直接將代碼運行在服務端,這對于構建靈活多變的解析可能有一定意義。
總而言之,我只是想有一個可擴展、能監(jiān)控、簡單易用的爬蟲框架,所以我快速擼了一個 declarative-crawler,目前只是處于原型階段,尚未發(fā)布到 npm 中;希望有興趣的大大不吝賜教,特別是發(fā)現(xiàn)了有同類型的框架可以吱一聲,我看看能不能拿來主義,多多學習。
設計思想與架構概覽
當筆者幾年前編寫***個爬蟲時,整體思路是典型的命令式編程,即先抓取再解析,***持久化存儲,就如下述代碼:
- await fetchListAndContentThenIndex(
- 'jsgc',
- section.name,
- section.menuCode,
- section.category
- ).then(() => {
- }).catch(error => {
- console.log(error);
- });
命令式編程相較于聲明式編程耦合度更高,可測試性與可控性更低;就好像從 jQuery 切換到 React、Angular、Vue.js 這樣的框架,我們應該盡可能將業(yè)務之外的事情交由工具,交由框架去管理與解決,這樣也會方便我們進行自定義地監(jiān)控??偨Y而言,筆者的設計思想主要包含以下幾點:
- 關注點分離,整個架構分為了爬蟲調(diào)度 CrawlerScheduler、Crawler、Spider、dcEmitter、Store、KoaServer、MonitorUI 等幾個部分,盡可能地分離職責。
- 聲明式編程,每個蜘蛛的生命周期包含抓取、抽取、解析與持久化存儲這幾個部分;開發(fā)者應該獨立地聲明這幾個部分,而完整的調(diào)用與調(diào)度應該由框架去完成。
- 分層獨立可測試,以爬蟲的生命周期為例,抽取與解析應當聲明為純函數(shù),而抓取與持久化存儲更多的是面向業(yè)務,可以進行 Mock 或者包含副作用進行測試。
整個爬蟲網(wǎng)絡架構如下所示,目前全部代碼參考這里。
自定義蜘蛛與爬蟲
我們以抓取某個在線列表與詳情頁為例,首先我們需要針對兩個頁面構建蜘蛛,注意,每個蜘蛛負責針對某個 URL 進行抓取與解析,用戶應該首先編寫列表爬蟲,其需要聲明 model 屬性、復寫 before_extract、parse 與 persist 方法,各個方法會被串行調(diào)用。另一個需要注意的是,我們爬蟲可能會外部傳入一些配置信息,統(tǒng)一的聲明在了 extra 屬性內(nèi),這樣在持久化時也能用到。
- 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 對采集到的數(shù)據(jù)進行持久化更新
- * @param pageObject
- */
- async persist(announcements): Promise<boolean> {
- let flag = true;
- // 這里每個 URL 對應一個公告數(shù)組
- for (let announcement of announcements) {
- try {
- await insertOrUpdateAnnouncement({
- ...this.extra,
- ...announcement,
- infoID: href2infoID(announcement.href)
- });
- } catch (err) {
- flag = false;
- }
- }
- return flag;
- }
- }
我們可以針對這個蜘蛛進行單獨測試,這里使用 Jest。注意,這里為了方便描述沒有對抽取、解析等進行單元測試,在大型項目中我們是建議要加上這些純函數(shù)的測試用例。
- 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, "返回數(shù)據(jù)為列表并且長度大于10").to.have.length.above(2);
- });
- test("抓取公共列表 并且進行持久化操作", async () => {
- let announcements = await uaListSpider.run(true);
- expect(announcements, "返回數(shù)據(jù)為列表并且長度大于10").to.have.length.above(2);
- });
同理,我們可以定義對于詳情頁的蜘蛛:
- export default class UAContentSpider extends Spider {
- displayName = "通用公告內(nèi)容蜘蛛";
- model = {
- // 標題
- $title: "#tblInfo #tdTitle b",
- // 時間
- $time: "#tblInfo #tdTitle font",
- // 內(nèi)容
- $content: "#tblInfo #TDContent"
- };
- parse(pageElements: Object) {
- ...
- }
- async persist(announcement: Object) {
- ...
- }
- }
在定義完蜘蛛之后,我們可以定義負責爬取整個系列任務的 Crawler,注意,Spider 僅負責爬取單個頁面,而分頁等操作是由 Crawler 進行:
- /**
- * @function 通用的爬蟲
- */
- export default class UACrawler extends Crawler {
- displayName = "通用公告爬蟲";
- /**
- * @構造函數(shù)
- * @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 函數(shù),需要在其中完成爬蟲的初始化。首先我們需要構造所有的種子鏈接,這里既是多個列表頁;然后通過 setSpider 方法加入對應的蜘蛛。不同蜘蛛之間通過自定義的 Transformer 函數(shù)來從上一個結果中抽取出所需要的鏈接傳入到下一個蜘蛛中。至此我們爬蟲網(wǎng)絡的關鍵組件定義完畢。
本地運行
定義完 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 是整個狀態(tài)的中轉站,如果選擇使用本地運行,可以自己監(jiān)聽 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)});
此時會啟動框架內(nèi)置的 Koa 服務器,允許用戶通過 RESTful 接口來控制爬蟲網(wǎng)絡與獲取當前狀態(tài)。
接口說明
關鍵字段
- 爬蟲
- // 判斷爬蟲是否正在運行
- isRunning: boolean = false;
- // 爬蟲***一次激活時間
- lastStartTime: Date;
- // 爬蟲***一次運行結束時間
- lastFinishTime: Date;
- // 爬蟲***的異常信息
- lastError: Error;
- 蜘蛛
- // ***一次運行時間
- lastActiveTime: Date;
- // 平均總執(zhí)行時間 / ms
- executeDuration: number = 0;
- // 爬蟲次數(shù)統(tǒng)計
- count: number = 0;
- // 異常次數(shù)統(tǒng)計
- errorCount: number = 0;
- countByTime: { [number]: number } = {};
localhost:3001/ 獲取當前爬蟲運行狀態(tài)
- 尚未啟動
- [
- {
- name: "UACrawler",
- displayName: "通用公告爬蟲",
- isRunning: false,
- }
- ]
- 正常返回
- [
- {
- name: "UACrawler",
- displayName: "通用公告爬蟲",
- isRunning: true,
- lastStartTime: "2017-04-19T06:41:55.407Z"
- }
- ]
- 出現(xiàn)錯誤
- [
- {
- 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"
- }
- }
- ]
localhost:3001/start 啟動爬蟲
- {
- message:"OK"
- }
localhost:3001/status 返回當前系統(tǒng)狀態(tài)
- {
- "cpu":0,
- "memory":0.9945211410522461
- }
localhost:3001/UACrawler 根據(jù)爬蟲名查看爬蟲運行狀態(tài)
- [
- {
- "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":"通用公告內(nèi)容蜘蛛",
- "count":120,
- "countByTime":{
- "0":0,
- ...
- "59":0
- },
- "lastActiveTime":"2017-04-19T06:51:11.072Z",
- "executeDuration":1000.1596102359835,
- "errorCount":0
- }
- ]
自定義監(jiān)控界面
CrawlerServer 提供了 RESTful API 來返回當前爬蟲的狀態(tài)信息,我們可以利用 React 或者其他框架來快速搭建監(jiān)控界面。
【本文是51CTO專欄作者“張梓雄 ”的原創(chuàng)文章,如需轉載請通過51CTO與作者聯(lián)系】