基于Apify+node+react/vue搭建一個有點意思的爬蟲平臺
前言
熟悉我的朋友可能會知道,我一向是不寫熱點的。為什么不寫呢?是因為我不關注熱點嗎?其實也不是。有些事件我還是很關注的,也確實有不少想法和觀點。但我一直奉行一個原則,就是:要做有生命力的內容。
本文介紹的內容來自于筆者之前負責研發的爬蟲管理平臺, 專門抽象出了一個相對獨立的功能模塊為大家講解如何使用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行,這里就不一一介紹了.大家可以在筆者的github上學習研究.
- github項目地址: 基于Apify+node+react搭建的有點意思的爬蟲平臺
界面如下:
大家可以自己克隆本地運行, 也可以基于此開發屬于自己的爬蟲應用.
項目使用的技術文檔地址
- apify 一款用于JavaScript的可伸縮的web爬蟲庫
- Puppeteer
- koa -- 基于nodejs平臺的下一代web開發框架