本文最先發表在個人我的博客上,轉載請保留出處 http://jsorz.cn/blog/2016/11/node-action-in-website-detection.htmlhtml
(這是拖了近一年的文章。。。)去年這個時候接了一個心力交瘁的項目,大致目標是:一、爲用戶的網站提供安全監測的服務,用戶在咱們的管理端系統中添加一個站點並勾選要檢測的項;二、而後由後臺爬蟲系統完成用戶站點連接的入庫,並定時針對站內連接執行各類檢測任務;三、再由前端系統給用戶交付網站監測報表。在中間這一步,最重要的爬蟲系統,我第一次學習用 Nodejs 來作。前端
安裝node
nvmpython
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.25.2/install.sh | bash
node 0.12.xjquery
nvm install 0.12
cnpm 鏡像git
npm install --registry=http://r.cnpmjs.org -g cnpm
debug 工具github
cnpm install -g node-inspector node-debug yourApp.js
教程web
Node.js 包教不包會ajax
中文API文檔算法
一些庫
用於爬蟲
superagent 處理請求的模塊
request 另外一個處理請求的模塊,比起 superagent,語法配置項更多一些。若是說 superagent 是 $.post()
,那 request 就是 $.ajax()
cheerio 用於DOM解析,提供與 jquery 選擇器相似的接口
異步流程控制
Q in Github Promise 化的一種實現,promise 不是萬能,改造複雜的動態決定的執行鏈是比較煩的事情
async 點贊數很高的庫,有人說有毒,我覺着很好用
eventproxy @樸靈的基於事件的流程控制,我覺着和 promise 或 async 搭配着使用很好用
在摘要中已經提到,這個項目的目標分爲3步:添加檢測任務 => 爬蟲執行任務 => 前端報表展示。但在具體實現時,須要對爬蟲進行細分,最終會有6步過程。
在web端添加一個站點任務,輸入客戶的摘要信息以及要監控的站點列表,而後能夠更改檢測類別的週期以及具體檢測項等配置。
「可用性」檢測主要是網站的一些基本信息,一般都在首頁的 http 頭中就能找到。而「內容檢測」須要對網站中的全部頁面進行檢測,所以會有一個「檢測深度」和「最大頁數」的配置,頁面深度越深,頁面數目會呈指數級增加,這對爬蟲服務器是巨大的壓力。
此外還有一個「安全檢測」的模塊,具體檢測項有:ActiveX掛馬,iframe掛馬,js掛馬,WebShell後門,暗鏈,以及後臺地址檢測。因爲我對安全方面瞭解太少,在這個系統中實現得也很simple,就很少說了。。。
因爲上面「內容檢測」和「安全檢測」都須要對網站首頁下的其餘頁面所有檢測,所以咱們須要維護一個 site
表和 site_link
表,記錄下網站的域名和 ip 信息,而且存下每一個頁面連接的信息,包括該頁面相對於首頁的深度層級,該頁面的連接是 同源
、同域
仍是 外鏈
,以及頁面的發現時間、狀態信息等。
另外注意的是,從首頁開始取連接,而後再對連接再取它的後繼連接,整個過程應採用「廣度優先」的策略。而「檢測深度」和「最大頁數」也是在這個時候進行限制的,若是當前頁面深度爲 N,N + 1 > 檢測深度
,那麼該頁面就再也不往爬取隊列中 push 後繼連接了。
站點任務配置完,站點連接也入完庫,這時候就能夠根據上面配置的檢測週期來定時產生任務。例如當內容檢查的週期到來時,須要取出該站點下全部符合的頁面連接,對每一個頁面的每一個檢測項都生成1個檢測任務。
須要注意生成任務時,要按照頁面連接的順序來遍歷,而不是先遍歷檢測項。這樣的好處是當具體爬蟲執行時,相同 url 的檢測任務在隊列中會連續挨着,這樣能夠給爬蟲作爬取的合併,一樣的 url 只須要爬取1遍,就能夠給不一樣的檢測任務去執行。
若是說第3步生成任務是「生產者」,那麼如今就輪到「消費者」。爬蟲系統要支持併發,多機器 + 多線程。在1臺機器上,1次從任務隊列中取100個出來,如第3步所說檢測任務是按相同url連續排列的,那麼這100個檢測任務能夠抽出20個任務組(舉個例子),每一個任務組均可以只爬取1個頁面,而後將內容傳遞給各檢測項去執行。而每一個檢測項的執行結果都以 log 的形式存儲。
若是爬蟲的機器不是那種「樹莓派」微型機器,那能夠先將 log 以文件形式存到本地,留出帶寬給爬蟲。或者每一個檢測項的結果 log 都發送到1個指定的地方,這就會多佔用帶寬,也須要專門搞1臺日志服務器。
爬蟲的任務執行過程是很連續又很離散的,連續是由於作完1個就要作下1個,離散是說日誌的離散,單獨1個頁面的1個檢測項的1次執行日誌並無太大意義,要整合到整個站點和時間段、檢測類別的維度來看纔有價值。從前面的步驟能夠看到,站點連接的入庫以及週期性的生成任務時,會有必定的「順序」控制,而且只有所有入完庫纔會進入下一步驟。而爬蟲系統是很離散的,它可能分佈到多臺機器上,只要任務隊列中有東西,它就會取出來執行,也不會區分任務是來自哪一個站點哪一個類別的。
所以,若是 log 分佈在各爬蟲機器上,那就須要一個定時的腳本對其進行彙總計算後傳回數據中心。而若是有統一的日誌服務器,那一樣須要將原始日誌計算成有效的數據。
前端系統直接查數據中心,能夠獲得站點級和檢測類別級的報告。前面「生產者」生成檢測任務時,會在數據庫中打上一些任務模塊的時間信息,前端系統也可以查到一些調度狀態信息。
整個系統分爲4個部分:前端系統、任務Producer、爬蟲系統、日誌計算同步進程。架構圖以下,能夠看到箭頭就表明着數據流的方向。
而爬蟲調度和執行是典型的「生產者-消費者」模式,這裏生產者只有1個,而有N個消費者爬蟲進程。
所謂的檢測任務隊列,實際就是數據庫中的一張表,多個爬蟲進程或多臺爬蟲機器都共享這張表,只要保證從表的頂部開始讀取,讀完後就要重置狀態位,防止被其餘進程重複讀取。而當任務執行失敗後,會將該檢測任務的記錄移到 table 的隊尾,並設置1個失敗次數的字段。
在本文第一節中就列出了一些庫,在頁面爬取方面,使用 superagent 獲取頁面內容,使用 cheerio 作文檔解析。而對於一些不須要解析內容的爬取任務(好比查詢某個頁面的 header 信息,或是檢查某個頁面是否 200 狀態),使用 request 來發請求。
對於爬蟲系統中最關鍵的異步流控制,我在實現時作了多種風格的嘗試,在數據庫讀寫層面使用 promise 風格,在檢測項內部使用 async,而在爬蟲的實例中使用 eventproxy 來進行流控制。
base/ 跟繼承和通用相關的
conf/ 各類配置項(檢測項的配置、規則的配置)
constant/ 各類常量的定義和配置
database/ 數據庫配置和鏈接池
models/ 數據庫表對應的 json schema
dao/ 與 model 相應的 Dao 增刪改查封裝
util/ 通用數據的helper
factory/ 工廠封裝類,統一任務的組建過程
modules/ 具體檢測項 (爬蟲上搭載的具體task實現)
basic/ 基礎信息 可用性檢測
content/ 內容檢測大類
secure/ 安全檢測大類
common/ 站點入庫、連接入庫,以及抓取頁面內容也抽象成1個通用task
crawler/ 爬蟲對象與池管理(1個crawler實例只負責1次頁面請求)
scheduler/ 監控和調度進程 (每隔一段時間就執行一輪)
runner.js 用來執行檢測任務 (任務消費者)
siteRunner.js 針對「站點連接入庫」的 runner
app.js 主程序,用來啓動 runner
appSite.js 主程序之一,用來啓動 SiteRunner
appPath.js 用來啓動低頻高請求量的檢測任務(詳見下面的地址型檢測類)
由上面的結構能夠看到,程序運行時的調用過程是:app.js => runner.js => crawler => 具體檢測項。
其中 app.js 只是個程序入口,裏面構造了一些參數給 runner,表明性的參數有:runner 每輪執行時取出的任務數量(即1個消費者每次消耗的數量),以及每輪執行之間的間隔時間(由於執行1輪任務時會有 N 個請求,若是不作間隔時間的限制,同時等待請求響應的線程太多會爆)。
runner.js 首先提供1個支持間隔時間的循環執行接口,這裏使用 async 庫來實現。
RunnerBase.prototype = { start: function(){ var that = this; that._count = 0; async.whilst( function(){ if(that.loopCount){ return that._count < that.loopCount; } return true; }, function (callback){ // 第一次當即執行 if(!that._count){ that._count = 1; that.run(); callback(null, that._count); } else{ that._count++; setTimeout(function(){ that.run(); callback(null, that._count); }, that.loopInterval || 10000); // 默認10秒間隔 } }, function (n){ console.log('Runner with [n=' + n + ']'); } ); } };
而 runner 中的run()
方法就是每輪具體要執行的操做,它其實總體是個串行過程,有如下步驟:
一、取出數據庫中的任務隊列中靠前的 N 個任務
二、若是取出爲空,直接跳到第6步
三、將任務狀態標爲 PROCESSING
四、對 N 個任務按檢測的 url 從新分組,好比獲得 M 個組,則構造 M 個 Crawler 實例(這裏設計的準則就是1個 Crawler 實例只爬取1個頁面)
五、使用 async.parallel
並行執行 M 個 Crawler 的 run()
方法
六、一輪 runner 執行結束
這裏總體是串行,使用了 eventproxy 來進行流程控制
Runner.prototype.run = function(cb){ var that = this; var ep = new EventProxy(); // 統一錯誤處理 TODO ep.fail(function (err){ console.log(err); }); // 各步驟一塊兒調用,經過 event信號 等待 Runner.steps.forEach(function (fn){ fn.call(that, ep, cb); }); }
而其中最關鍵的步驟是並行執行 M 個 Crawler,其實現原理以下
function (ep){ var that = this; ep.on('crawlerReady', function (crawlers){ // 構造 async.parallel 的執行函數 var threads = crawlers.map(function (crawler){ return function (callback){ // run前的累計 that.crawlerCount++; crawler.run(function (err){ // run完的累計 that.crawlerCountDone++; if (err){ console.log(err); callback(err); } else { callback(null); } }); } }); async.parallel(threads, function (err, res){ console.log('[OK] 並行Crawler[len=' + threads.length + ']執行完畢'); }); // 這裏釋放信號不能放在 async.parallel 的回調裏,以防有爬蟲掛了就卡死了 // 直接到最後1步,可在每輪的最後看到爬蟲累計的頁面數目,and 爬蟲監控池 TODO ep.emit('crawlerDone'); }); }, function (ep, cb){ var that = this; ep.on('crawlerDone', function(){ console.log('[OK] 消費者1輪, ' + '累計爬了 ' + that.crawlerCount + ' 個頁面, ' + '累計結束 ' + that.crawlerCountDone + ' 個頁面'); cb && cb(); }); }
上面已經提到了屢次:1個 Crawler
對象只負責 1次
頁面請求,在 Crawler 對象中主要記錄着分組後的任務數組,它們有着一樣的目標 url。先請求頁面 url,獲得頁面文檔後將內容依次傳遞給各檢測項去各自執行任務。因此總的來講,這裏也是一個串行過程,具體步驟以下:
一、將分組後的任務,經過任務類型的 key,反射到具體的類,獲得可執行的任務對象的數組。
二、調用請求頁面內容的通用檢測項,做爲 starter
三、將頁面內容的文檔做爲 context 入參,依次調用各任務對象的數組
四、任務所有完成後,在數據庫中將其移出隊列;若失敗,則先移除,計失敗次數+1,而後從新插入隊尾
這裏也是用 eventproxy 控制流程的,其中關鍵的 context 傳參調用各任務對象,實現以下
function (ep){ var that = this; // 使 task.run() 結果按順序排列 ep.after('oneTaskFinished', that.tasks.length, function (results){ ep.emit('allTasksFinished', results); }); ep.on('starterFinished', function (context){ that.tasks.forEach(function (task){ task.run(context, ep.group('oneTaskFinished')); }); }); },
注意,使用 ep.group()
方法能夠保證獲得的執行結果的順序是與調用順序一致的,而各 task 的執行是並行的。
在上面講 crawler 的第1步中提到:將分組後的任務,經過任務類型的key,反射到具體的類。由於從數據庫任務隊列取來的檢測任務只會含有 模板url
/ 檢測大類
/ 檢測項小類
這樣的數據,因此就要反射到具體的檢測類來構造對象。
「反射」是強類型編程語言中的概念(我最先是在 Java 中瞭解的),而 js 自然的弱類型和動態特性,很容易實現。
// 根據命名規則,取檢測模塊的引用 getModuleFromCrawlTask: function(crawlTask){ var modulePath = ModuleConf.getPath(crawlTask['item_key']); return require(modulePath); }
這裏每一個檢測項任務都會帶有 item_key
字段,表示具體的檢測項,其實還有 mod_key
,對應到系統的3大類檢測:可用性,內容檢測,安全檢測。具體見下一段。
// 代表這個TaskItem屬於哪一大類 (通用的除外) var modDict = { COMMON_CRAWL: 'COMMON_CRAWL', // 通用爬取方法 如請求頁面document, 取連接等 BASIC_DETECT: 'BASIC_DETECT', // 可用性檢測 CONTENT_DETECT: 'CONTENT_DETECT', // 內容檢測 合併了篡改檢測 SECURE_DETECT: 'SECURE_DETECT' // 安全檢測 }; // 代表這個TaskItem可否與其餘合併, 可否共用1次請求 var typeDict = { EXCLUSIVE: 'EXCLUSIVE', // 排它性 (獨佔 request 的任務) INCLUSIVE: 'INCLUSIVE' // 可合併性 (只需傳遞 context) }; // 代表這個TaskItem應該在哪一個表中, 應該交給哪一個Runner處理 var runnerTypeDict = { COMMON: 'COMMON_TASK', // 普通任務 -> Runner -> app.js SITE: 'SITE_TASK', // 站點入庫類 -> SiteRunner -> appSite.js PATH: 'PATH_TASK' // 路徑檢測類 -> Runner -> appPath.js };
這裏定義了兩種表示任務屬性的類型:INCLUSIVE
表示該任務是能夠與同目標 url 的其餘任務合併的,只須要請求1次頁面,傳遞頁面內容的 context 便可,詳見 crawler.js 的部分實現代碼。而 EXCLUSIVE
表示該任務內部要本身發請求,好比查 Whois 信息,或者要檢測是否存在某些後門路徑,這類任務是不能夠合併的。
而 runnerType
又是另外一個層面的另外一種分類,在第二節主程序設計中提到過站點連接入庫。我在實現時作了一層抽象,將「站點入庫」和「連接入庫」也視爲一種檢測項任務,任務放在單獨的一個隊列表中。這樣就能共用一些 crawler 和 runner 的邏輯。
此外對於路徑類檢測,好比 Webshell 地址檢測,或是敏感路徑檢測,因爲規則的字典很大,1個檢測項可能會有上千次請求。所以對於這類「低頻」又「高請求量」的檢測項,我也單獨將任務放在另外一個獨立的隊列表中,也由另外一個程序入口啓動。
總結整個爬蟲調度與檢測項的工做流程,是「串行」與「並行」同時存在着。
由 runner 執行時每輪會取出一堆任務,生成多個 crawler 實例,這是並行的。而在每一個 crawler 內部能夠理解爲像工廠的流水線模式,先請求頁面 url,而後將頁面內容一個個傳遞給各檢測項去完成相應的提取或檢測工做。每一個檢測項內部都會去寫各自的 log 文件,最後全部檢測任務都完成後,間隔一段時間後會進入下一輪 runner 的執行。
注意的是,檢測項是根據目標 url 來分組的,搭載在 crawler 實例上執行的,同一組的檢測項都執行完後,相應的 crawler 實例也應該銷燬。而 runner 對象的實例是始終存在的,它像一個定時器同樣每隔十來秒就會再觸發1輪執行。同時,1個 runner 實例應該對應1個進程,多個 runner 實例應該跑在多臺爬蟲服務器上。
這個項目從去年11月開始需求分析,作界面 demo,到任務分解和組裝過程的設計,再到爬蟲的實現,也是從零開始學 nodejs,整個過程最初都只有我1我的,對本身的挑戰提高很大。熬了兩三個月後纔有人手幫我作日誌的回收計算以及前端的報表部分,以後項目就交給他們了,他們後來作了一些安全檢測項方面的強化以及多機器的部署和通訊。
我在單機(Mac Air Book @ 2012)上運行的效率平均1小時,爬取近1萬個頁面,並完成剩餘的內容檢測項部分。在前面圖中一輪取出的任務數量以及並行 Crawler 的數目,對機器的性能和網速都有要求,若是入不敷出(每輪間隔時間內都只有少數的 crawler 執行完),最終會產生不少內存錯誤,或者 TCP 鏈接數過多,也得不到結果。
我嘗試後獲得的經驗參數是,每隔 10 秒,取 50 個任務,根據目標 url 合併完後大約有 20~30 個任務組。注:用的是雲服務器,內存 2G,帶寬 2M。
這個爬蟲系統和檢測項都還比較簡陋,能夠從如下方面改進:
nodejs 的 cluster 嘗試,可否對爬取效率有性能提高
數據庫「同步寫」的鎖問題,多臺爬蟲機器共用同一個數據庫的任務隊列,有性能瓶頸(或者說集中式存儲的通病)
內容檢測方面的篡改識別算法,目前僅用 content-length 和 md5 來判別頁面更改
引入 phantomJS 來作網頁截圖,警示頁面惡意篡改
增強安全檢測方面的力度,對各類掛馬和暗鏈的代碼模式的識別
支持對檢測項的擴展,能讓更有經驗的安全工程師編寫的 python 代碼也接入到系統中
在開發過程當中還記錄了一些遇到的問題,nodejs爬蟲實現中遇到的坑 後續會慢慢整理補充
有時間再抽象出一個業務無關的可配置的「生產者-消費者」模式的網站檢測框架,供學習交流
本文最先發表在個人我的博客上,轉載請保留出處 http://jsorz.cn/blog/2016/11/node-action-in-website-detection.html