熟悉個人朋友可能會知道,我一貫是不寫熱點的。爲何不寫呢?是由於我不關注熱點嗎?其實也不是。有些事件我仍是很關注的,也確實有很多想法和觀點。
但我一直奉行一個原則,就是:要作有生命力的內容。javascript
本文介紹的內容來自於筆者以前負責研發的爬蟲管理平臺, 專門抽象出了一個相對獨立的功能模塊爲你們講解如何使用nodejs開發專屬於本身的爬蟲平臺.文章涵蓋的知識點比較多,包含nodejs, 爬蟲框架, 父子進程及其通訊, react和umi等知識, 筆者會以儘量簡單的語言向你們一一介紹.css
上圖所示的就是咱們要實現的爬蟲平臺, 咱們能夠輸入指定網址來抓取該網站下的數據,並生成整個網頁的快照.在抓取完以後咱們能夠下載數據和圖片.網頁右邊是用戶抓取的記錄,方便二次利用或者備份.前端
在開始文章以前,咱們有必要了解爬蟲的一些應用. 咱們通常瞭解的爬蟲, 多用來爬取網頁數據, 捕獲請求信息, 網頁截圖等,以下圖:
固然爬蟲的應用遠遠不止如此,咱們還能夠利用爬蟲庫作自動化測試, 服務端渲染, 自動化表單提交, 測試谷歌擴展程序, 性能診斷等.
任何語言實現的爬蟲框架原理每每也大同小異, 接下來筆者將介紹基於nodejs實現的爬蟲框架Apify以及用法,並經過一個實際的案例方便你們快速上手爬蟲開發.java
apify是一款用於 JavaScript的可伸縮的 web爬蟲庫。能經過無頭( headless) Chrome 和 Puppeteer 實現數據提取和 Web 自動化做業的開發。 它提供了管理和自動擴展無頭 Chrome / Puppeteer實例池的工具,支持維護目標URL的請求隊列,並可將爬取結果存儲到本地文件系統或雲端。
咱們安裝和使用它很是簡單, 官網上也有很是多的實例案例能夠參考, 具體安裝使用步驟以下:node
npm install apify --save
const Apify = require('apify'); Apify.main(async () => { const requestQueue = await Apify.openRequestQueue(); await requestQueue.addRequest({ url: 'https://www.iana.org/' }); const pseudoUrls = [new Apify.PseudoUrl('https://www.iana.org/[.*]')]; const crawler = new Apify.PuppeteerCrawler({ requestQueue, handlePageFunction: async ({ request, page }) => { const title = await page.title(); console.log(`Title of ${request.url}: ${title}`); await Apify.utils.enqueueLinks({ page, selector: 'a', pseudoUrls, requestQueue, }); }, maxRequestsPerCrawl: 100, maxConcurrency: 10, }); await crawler.run(); });
使用node執行後可能會出現以下界面:
程序會自動打開瀏覽器並打開知足條件的url頁面. 咱們還可使用它提供的cli工具實現更加便捷的爬蟲服務管理等功能,感興趣的朋友能夠嘗試一下. apify提供了不少有用的api供開發者使用, 若是想實現更加複雜的能力,能夠研究一下,下圖是官網api截圖:
筆者要實現的爬蟲主要使用了Apify集成的Puppeteer能力, 若是對Puppeteer不熟悉的能夠去官網學習瞭解, 本文模塊會一一列出項目使用的技術框架的文檔地址.react
咱們要想實現一個爬蟲平臺, 要考慮的一個關鍵問題就是爬蟲任務的執行時機以及以何種方式執行. 由於爬取網頁和截圖須要等網頁所有加載完成以後再處理, 這樣才能保證數據的完整性, 因此咱們能夠認定它爲一個耗時任務.webpack
當咱們使用nodejs做爲後臺服務器時, 因爲nodejs自己是單線程的,因此當爬取請求傳入nodejs時, nodejs不得不等待這個"耗時任務"完成才能進行其餘請求的處理, 這樣將會致使頁面其餘請求須要等待該任務執行結束才能繼續進行, 因此爲了更好的用戶體驗和流暢的響應,咱們不德不考慮多進程處理. 好在nodejs設計支持子進程, 咱們能夠把爬蟲這類耗時任務放入子進程中來處理,當子進程處理完成以後再通知主進程. 整個流程以下圖所示:
css3
nodejs有3種建立子進程的方式, 這裏咱們使用fork來處理, 具體實現方式以下:git
// child.js function computedTotal(arr, cb) { // 耗時計算任務 } // 與主進程通訊 // 監聽主進程信號 process.on('message', (msg) => { computedTotal(bigDataArr, (flag) => { // 向主進程發送完成信號 process.send(flag); }) }); // main.js const { fork } = require('child_process'); app.use(async (ctx, next) => { if(ctx.url === '/fetch') { const data = ctx.request.body; // 通知子進程開始執行任務,並傳入數據 const res = await createPromisefork('./child.js', data) } // 建立異步線程 function createPromisefork(childUrl, data) { // 加載子進程 const res = fork(childUrl) // 通知子進程開始work data && res.send(data) return new Promise(reslove => { res.on('message', f => { reslove(f) }) }) } await next() })
以上是一個實現父子進程通訊的簡單案例, 咱們的爬蟲服務也會採用該模式來實現.github
以上介紹的是要實現咱們的爬蟲應用須要考慮的技術問題, 接下來咱們開始正式實現業務功能, 由於爬蟲任務是在子進程中進行的,因此咱們將在子進程代碼中實現咱們的爬蟲功能.咱們先來整理一下具體業務需求, 以下圖:
j'接下來我會先解決控制爬蟲最大併發數這個問題, 之因此要解決這個問題, 是爲了考慮爬蟲性能問題, 咱們不能一次性讓爬蟲爬取因此的網頁,這樣會開啓不少並行進程來處理, 因此咱們須要設計一個節流裝置,來控制每次併發的數量, 當前一次的完成以後再進行下一批的頁面抓取處理. 具體代碼實現以下:
// 異步隊列 const queue = [] // 最大併發數 const max_parallel = 6 // 開始指針 let start = 0 for(let i = 0; i < urls.length; i++) { // 添加異步隊列 queue.push(fetchPage(browser, i, urls[i])) if(i && (i+1) % max_parallel === 0 || i === (urls.length - 1)) { // 每隔6條執行一次, 實現異步分流執行, 控制併發數 await Promise.all(queue.slice(start, i+1)) start = i } }
以上代碼便可實現每次同時抓取6個網頁, 當第一次任務都結束以後纔會執行下一批任務.代碼中的urls指的是用戶輸入的url集合, fetchPage爲抓取頁面的爬蟲邏輯, 筆者將其封裝成了promise.
咱們都知道puppeteer截取網頁圖片只會截取加載完成的部分,對於通常的靜態網站來講徹底沒有問題, 可是對於頁面內容比較多的內容型或者電商網站, 基本上都採用了按需加載的模式, 因此通常手段截取下來的只是一部分頁面, 或者截取的是圖片還沒加載出來的佔位符,以下圖所示:
因此爲了實現截取整個網頁,須要進行人爲干預.筆者這裏提供一種簡單的實現思路, 能夠解決該問題. 核心思路就是利用puppeteer的api手動讓瀏覽器滾動到底部, 每次滾動一屏, 直到頁面的滾動高度不變時則認爲滾動到底部.具體實現以下:
// 滾動高度 let scrollStep = 1080; // 最大滾動高度, 防止無限加載的頁面致使長效耗時任務 let max_height = 30000; let m = {prevScroll: -1, curScroll: 0} while (m.prevScroll !== m.curScroll && m.curScroll < max_height) { // 若是上一次滾動和本次滾動高度同樣, 或者滾動高度大於設置的最高高度, 則中止截取 m = await page.evaluate((scrollStep) => { if (document.scrollingElement) { let prevScroll = document.scrollingElement.scrollTop; document.scrollingElement.scrollTop = prevScroll + scrollStep; let curScroll = document.scrollingElement.scrollTop return {prevScroll, curScroll} } }, scrollStep); // 等待3秒後繼續滾動頁面, 爲了讓頁面加載充分 await sleep(3000); } // 其餘業務代碼... // 截取網頁快照,並設置圖片質量和保存路徑 const screenshot = await page.screenshot({path: `static/${uid}.jpg`, fullPage: true, quality: 70});
爬蟲代碼的其餘部分由於不是核心重點,這裏不一一舉例, 我已經放到github上,你們能夠交流研究.
有關如何提取網頁文本, 也有現成的api能夠調用, 你們能夠選擇適合本身業務的api去應用,筆者這裏拿puppeteer的page.$eval來舉例:
const txt = await page.$eval('body', el => { // el即爲dom節點, 能夠對body的子節點進行提取,分析 return {...} })
爲了搭建完整的node服務平臺,筆者採用了
有關如何使用這些模塊實現一個完整的服務端應用, 筆者在代碼裏作了詳細的說明, 這裏就不一一討論了. 具體代碼以下:
const Koa = require('koa'); const { resolve } = require('path'); const staticServer = require('koa-static'); const koaBody = require('koa-body'); const cors = require('koa2-cors'); const logger = require('koa-logger'); const glob = require('glob'); const { fork } = require('child_process'); const app = new Koa(); // 建立靜態目錄 app.use(staticServer(resolve(__dirname, './static'))); app.use(staticServer(resolve(__dirname, './db'))); app.use(koaBody()); app.use(logger()); const config = { imgPath: resolve('./', 'static'), txtPath: resolve('./', 'db') } // 設置跨域 app.use(cors({ origin: function (ctx) { if (ctx.url.indexOf('fetch') > -1) { return '*'; // 容許來自全部域名請求 } return ''; // 這樣就能只容許 http://localhost 這個域名的請求了 }, exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'], maxAge: 5, // 該字段可選,用來指定本次預檢請求的有效期,單位爲秒 credentials: true, allowMethods: ['GET', 'POST', 'PUT', 'DELETE'], allowHeaders: ['Content-Type', 'Authorization', 'Accept', 'x-requested-with'], })) // 建立異步線程 function createPromisefork(childUrl, data) { const res = fork(childUrl) data && res.send(data) return new Promise(reslove => { res.on('message', f => { reslove(f) }) }) } app.use(async (ctx, next) => { if(ctx.url === '/fetch') { const data = ctx.request.body; const res = await createPromisefork('./child.js', data) // 獲取文件路徑 const txtUrls = []; let reg = /.*?(\d+)\.\w*$/; glob.sync(`${config.txtPath}/*.*`).forEach(item => { if(reg.test(item)) { txtUrls.push(item.replace(reg, '$1')) } }) ctx.body = { state: res, data: txtUrls, msg: res ? '抓取完成' : '抓取失敗,緣由多是非法的url或者請求超時或者服務器內部錯誤' } } await next() }) app.listen(80)
該爬蟲平臺的前端界面筆者採用umi3+antd4.0開發, 由於antd4.0相比以前版本確實體積和性能都提升了很多, 對於組件來講也作了更合理的拆分. 由於前端頁面實現比較簡單,整個前端代碼使用hooks寫不到200行,這裏就不一一介紹了.你們能夠在筆者的github上學習研究.
界面以下:
你們能夠本身克隆本地運行, 也能夠基於此開發屬於本身的爬蟲應用.
若是想學習更多H5遊戲, webpack,node,gulp,css3,javascript,nodeJS,canvas數據可視化等前端知識和實戰,歡迎在《趣談前端》專欄學習討論,共同探索前端的邊界。