「實踐」基於Apify+node+react/vue搭建一個有點意思的爬蟲平台

echa攻城獅 發佈 2020-05-15T09:11:32+00:00

本文介紹的內容來自於筆者之前負責研發的爬蟲管理平台, 專門抽象出了一個相對獨立的功能模塊為大家講解如何使用nodejs開發專屬於自己的爬蟲平台.文章涵蓋的知識點比較多,包含nodejs, 爬蟲框架, 父的進程及其通信, react和umi等知識, 筆者會以儘可能簡單的語言向大家一


作者:徐小夕

轉發連結:https://mp.weixin.qq.com/s/SiBF0xAb4lB-K4p4qsbwRA

前言

熟悉我的朋友可能會知道,我一向是不寫熱點的。為什麼不寫呢?是因為我不關注熱點嗎?其實也不是。有些事件我還是很關注的,也確實有不少想法和觀點。但我一直奉行一個原則,就是:要做有生命力的內容。

本文介紹的內容來自於筆者之前負責研發的爬蟲管理平台, 專門抽象出了一個相對獨立的功能模塊為大家講解如何使用nodejs開發專屬於自己的爬蟲平台.文章涵蓋的知識點比較多,包含nodejs, 爬蟲框架, 父的進程及其通信, react和umi等知識, 筆者會以儘可能簡單的語言向大家一一介紹.

你將收穫

  • Apify框架介紹和基本使用
  • 如何創建父子進程以及父子進程通信
  • 使用javascript手動實現控制爬蟲最大並發數
  • 截取整個網頁圖片的實現方案
  • nodejs第三方庫和模塊的使用
  • 使用umi3 + antd4.0搭建爬蟲前台介面

平台預覽

上圖所示的就是我們要實現的爬蟲平台, 我們可以輸入指定網址來抓取該網站下的數據,並生成整個網頁的快照.在抓取完之後我們可以下載數據和圖片.網頁右邊是用戶抓取的記錄,方便二次利用或者備份.

正文

在開始文章之前,我們有必要了解爬蟲的一些應用. 我們一般了解的爬蟲, 多用來爬取網頁數據, 捕獲請求信息, 網頁截圖等,如下圖:

當然爬蟲的應用遠遠不止如此,我們還可以利用爬蟲庫做自動化測試, 服務端渲染, 自動化表單提交, 測試谷歌擴展程序, 性能診斷等. 任何語言實現的爬蟲框架原理往往也大同小異, 接下來筆者將介紹基於nodejs實現的爬蟲框架Apify以及用法,並通過一個實際的案例方便大家快速上手爬蟲開發.

Apify框架介紹和基本使用

apify是一款用於JavaScript的可伸縮的web爬蟲庫。能通過無頭(headless)Chrome 和 Puppeteer 實現數據提取和** Web** 自動化作業的開發。它提供了管理和自動擴展無頭Chrome / Puppeteer實例池的工具,支持維護目標URL的請求隊列,並可將爬取結果存儲到本地文件系統或雲端。

我們安裝和使用它非常簡單, 官網上也有非常多的實例案例可以參考, 具體安裝使用步驟如下:

安裝

npm install apify --save複製代碼

使用Apify開始第一個案例

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不熟悉的可以去官網學習了解, 本文模塊會一一列出項目使用的技術框架的文檔地址.

如何創建父子進程以及父子進程通信

我們要想實現一個爬蟲平台, 要考慮的一個關鍵問題就是爬蟲任務的執行時機以及以何種方式執行. 因為爬取網頁和截圖需要等網頁全部加載完成之後再處理, 這樣才能保證數據的完整性, 所以我們可以認定它為一個耗時任務.

當我們使用nodejs作為後台伺服器時, 由於nodejs本身是單線程的,所以當爬取請求傳入nodejs時, nodejs不得不等待這個"耗時任務"完成才能進行其他請求的處理, 這樣將會導致頁面其他請求需要等待該任務執行結束才能繼續進行, 所以為了更好的用戶體驗和流暢的響應,我們不得不考慮多進程處理. 好在nodejs設計支持子進程, 我們可以把爬蟲這類耗時任務放入子進程中來處理,當子進程處理完成之後再通知主進程. 整個流程如下圖所示:

nodejs有3種創建子進程的方式, 這裡我們使用fork來處理, 具體實現方式如下:

// 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()
})複製代碼

以上是一個實現父子進程通信的簡單案例, 我們的爬蟲服務也會採用該模式來實現.

使用javascript手動實現控制爬蟲最大並發數

以上介紹的是要實現我們的爬蟲應用需要考慮的技術問題, 接下來我們開始正式實現業務功能, 因為爬蟲任務是在子進程中進行的,所以我們將在子進程代碼中實現我們的爬蟲功能.我們先來整理一下具體業務需求, 如下圖:

接下來我會先解決控制爬蟲最大並發數這個問題, 之所以要解決這個問題, 是為了考慮爬蟲性能問題, 我們不能一次性讓爬蟲爬取所以的網頁,這樣會開啟很多並行進程來處理, 所以我們需要設計一個節流裝置,來控制每次並發的數量, 當前一次的完成之後再進行下一次的頁面抓取處理. 具體代碼實現如下:

// 異步隊列
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 {...}
})複製代碼

nodejs第三方庫和模塊的使用

為了搭建完整的node服務平台,筆者採用了

  • koa 一款輕量級可擴展node框架
  • glob 使用強大的正則匹配模式遍歷文件
  • koa2-cors 處理訪問跨域問題
  • koa-static 創建靜態服務目錄
  • koa-body 獲取請求體數據 有關如何使用這些模塊實現一個完整的服務端應用, 筆者在代碼里做了詳細的說明, 這裡就不一一討論了. 具體代碼如下:
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搭建爬蟲前台介面

該爬蟲平台的前端介面筆者採用umi3+antd4.0開發, 因為antd4.0相比之前版本確實體積和性能都提高了不少, 對於組件來說也做了更合理的拆分. 因為前端頁面實現比較簡單,整個前端代碼使用hooks寫不到200行,這裡就不一一介紹了.

介面如下:

推薦Vue學習資料文章:

《「實踐」深入對比 Vue 3.0 Composition API 和 React Hooks》

《前端網紅框架的插件機制全梳理(axios、koa、redux、vuex)》

《深入Vue 必學高階組件 HOC「進階篇」》

《深入學習Vue的data、computed、watch來實現最精簡響應式系統》

《10個實例小練習,快速入門熟練 Vue3 核心新特性(一)》

《10個實例小練習,快速入門熟練 Vue3 核心新特性(二)》

《教你部署搭建一個Vue-cli4+Webpack移動端框架「實踐」》

《2020前端就業Vue框架篇「實踐」》

《詳解Vue3中 router 帶來了哪些變化?》

《Vue項目部署及性能優化指導篇「實踐」》

《Vue高性能渲染大數據Tree組件「實踐」》

《尤大大細品VuePress搭建技術網站與個人博客「實踐」》

《10個Vue開發技巧「實踐」》

《是什麼導致尤大大選擇放棄Webpack?【vite 原理解析】》

《帶你了解 vue-next(Vue 3.0)之 小試牛刀【實踐】》

《帶你了解 vue-next(Vue 3.0)之 初入茅廬【實踐】》

《實踐Vue 3.0做JSX(TSX)風格的組件開發》

《一篇文章教你並列比較React.js和Vue.js的語法【實踐】》

《手拉手帶你開啟Vue3世界的鬼斧神工【實踐】》

《深入淺出通過vue-cli3構建一個SSR應用程式【實踐】》

《怎樣為你的 Vue.js 單頁應用提速》

《聊聊昨晚尤雨溪現場針對Vue3.0 Beta版本新特性知識點匯總》

《【新消息】Vue 3.0 Beta 版本發布,你還學的動麼?》

《Vue真是太好了 壹萬多字的Vue知識點 超詳細!》

《Vue + Koa從零打造一個H5頁面可視化編輯器——Quark-h5》

《深入淺出Vue3 跟著尤雨溪學 TypeScript 之 Ref 【實踐】》

《手把手教你深入淺出vue-cli3升級vue-cli4的方法》

《Vue 3.0 Beta 和React 開發者分別槓上了》

《手把手教你用vue drag chart 實現一個可以拖動 / 縮放的圖表組件》

《Vue3 嘗鮮》

《總結Vue組件的通信》

《手把手讓你成為更好的Vue.js開發人員的12個技巧和竅門【實踐】》

《Vue 開源項目 TOP45》

《2020 年,Vue 受歡迎程度是否會超過 React?》

《尤雨溪:Vue 3.0的設計原則》

《使用vue實現HTML頁面生成圖片》

《實現全棧收銀系統(Node+Vue)(上)》

《實現全棧收銀系統(Node+Vue)(下)》

《vue引入原生高德地圖》

《Vue合理配置WebSocket並實現群聊》

《多年vue項目實戰經驗匯總》

《vue之將echart封裝為組件》

《基於 Vue 的兩層吸頂踩坑總結》

《Vue插件總結【前端開發必備】》

《Vue 開發必須知道的 36 個技巧【近1W字】》

《構建大型 Vue.js 項目的10條建議》

《深入理解vue中的slot與slot-scope》

《手把手教你Vue解析pdf(base64)轉圖片【實踐】》

《使用vue+node搭建前端異常監控系統》

《推薦 8 個漂亮的 vue.js 進度條組件》

《基於Vue實現拖拽升級(九宮格拖拽)》

《手摸手,帶你用vue擼後台 系列二(登錄權限篇)》

《手摸手,帶你用vue擼後台 系列三(實戰篇)》

《前端框架用vue還是react?清晰對比兩者差異》

《Vue組件間通信幾種方式,你用哪種?【實踐】》

《淺析 React / Vue 跨端渲染原理與實現》

《10個Vue開發技巧助力成為更好的工程師》

《手把手教你Vue之父子組件間通信實踐講解【props、$ref 、$emit】》

《1W字長文+多圖,帶你了解vue的雙向數據綁定源碼實現》

《深入淺出Vue3 的響應式和以前的區別到底在哪裡?【實踐】》

《乾貨滿滿!如何優雅簡潔地實現時鐘翻牌器(支持JS/Vue/React)》

《基於Vue/VueRouter/Vuex/Axios登錄路由和接口級攔截原理與實現》

《手把手教你D3.js 實現數據可視化極速上手到Vue應用》

《吃透 Vue 項目開發實踐|16個方面深入前端工程化開發技巧【上】》

《吃透 Vue 項目開發實踐|16個方面深入前端工程化開發技巧【中】》

《吃透 Vue 項目開發實踐|16個方面深入前端工程化開發技巧【下】》

《Vue3.0權限管理實現流程【實踐】》

《後台管理系統,前端Vue根據角色動態設置菜單欄和路由》

作者:徐小夕

轉發連結:https://mp.weixin.qq.com/s/SiBF0xAb4lB-K4p4qsbwRA

關鍵字: