基于Apify+node+react/vue搭建一個(gè)有點(diǎn)意思的爬蟲平臺(tái)
前言
熟悉我的朋友可能會(huì)知道,我一向是不寫熱點(diǎn)的。為什么不寫呢?是因?yàn)槲也魂P(guān)注熱點(diǎn)嗎?其實(shí)也不是。有些事件我還是很關(guān)注的,也確實(shí)有不少想法和觀點(diǎn)。但我一直奉行一個(gè)原則,就是:要做有生命力的內(nèi)容。
本文介紹的內(nèi)容來(lái)自于筆者之前負(fù)責(zé)研發(fā)的爬蟲管理平臺(tái), 專門抽象出了一個(gè)相對(duì)獨(dú)立的功能模塊為大家講解如何使用nodejs開發(fā)專屬于自己的爬蟲平臺(tái).文章涵蓋的知識(shí)點(diǎn)比較多,包含nodejs, 爬蟲框架, 父子進(jìn)程及其通信, react和umi等知識(shí), 筆者會(huì)以盡可能簡(jiǎn)單的語(yǔ)言向大家一一介紹。
你將收獲
- Apify框架介紹和基本使用
- 如何創(chuàng)建父子進(jìn)程以及父子進(jìn)程通信
- 使用javascript手動(dòng)實(shí)現(xiàn)控制爬蟲最大并發(fā)數(shù)
- 截取整個(gè)網(wǎng)頁(yè)圖片的實(shí)現(xiàn)方案
- nodejs第三方庫(kù)和模塊的使用
- 使用umi3 + antd4.0搭建爬蟲前臺(tái)界面
平臺(tái)預(yù)覽
上圖所示的就是我們要實(shí)現(xiàn)的爬蟲平臺(tái), 我們可以輸入指定網(wǎng)址來(lái)抓取該網(wǎng)站下的數(shù)據(jù),并生成整個(gè)網(wǎng)頁(yè)的快照.在抓取完之后我們可以下載數(shù)據(jù)和圖片.網(wǎng)頁(yè)右邊是用戶抓取的記錄,方便二次利用或者備份.
正文
在開始文章之前,我們有必要了解爬蟲的一些應(yīng)用. 我們一般了解的爬蟲, 多用來(lái)爬取網(wǎng)頁(yè)數(shù)據(jù), 捕獲請(qǐng)求信息, 網(wǎng)頁(yè)截圖等,如下圖:
當(dāng)然爬蟲的應(yīng)用遠(yuǎn)遠(yuǎn)不止如此,我們還可以利用爬蟲庫(kù)做自動(dòng)化測(cè)試, 服務(wù)端渲染, 自動(dòng)化表單提交, 測(cè)試谷歌擴(kuò)展程序, 性能診斷等. 任何語(yǔ)言實(shí)現(xiàn)的爬蟲框架原理往往也大同小異, 接下來(lái)筆者將介紹基于nodejs實(shí)現(xiàn)的爬蟲框架Apify以及用法,并通過一個(gè)實(shí)際的案例方便大家快速上手爬蟲開發(fā).
Apify框架介紹和基本使用
apify是一款用于JavaScript的可伸縮的web爬蟲庫(kù)。能通過無(wú)頭(headless)Chrome 和 Puppeteer 實(shí)現(xiàn)數(shù)據(jù)提取和** Web** 自動(dòng)化作業(yè)的開發(fā)。它提供了管理和自動(dòng)擴(kuò)展無(wú)頭Chrome / Puppeteer實(shí)例池的工具,支持維護(hù)目標(biāo)URL的請(qǐng)求隊(duì)列,并可將爬取結(jié)果存儲(chǔ)到本地文件系統(tǒng)或云端。
我們安裝和使用它非常簡(jiǎn)單, 官網(wǎng)上也有非常多的實(shí)例案例可以參考, 具體安裝使用步驟如下:
安裝
npm install apify --save
復(fù)制代碼
使用Apify開始第一個(gè)案例
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();
});
復(fù)制代碼
使用node執(zhí)行后可能會(huì)出現(xiàn)如下界面:
程序會(huì)自動(dòng)打開瀏覽器并打開滿足條件的url頁(yè)面. 我們還可以使用它提供的cli工具實(shí)現(xiàn)更加便捷的爬蟲服務(wù)管理等功能,感興趣的朋友可以嘗試一下. apify提供了很多有用的api供開發(fā)者使用, 如果想實(shí)現(xiàn)更加復(fù)雜的能力,可以研究一下,下圖是官網(wǎng)api截圖:
筆者要實(shí)現(xiàn)的爬蟲主要使用了Apify集成的Puppeteer能力, 如果對(duì)Puppeteer不熟悉的可以去官網(wǎng)學(xué)習(xí)了解, 本文模塊會(huì)一一列出項(xiàng)目使用的技術(shù)框架的文檔地址.
如何創(chuàng)建父子進(jìn)程以及父子進(jìn)程通信
我們要想實(shí)現(xiàn)一個(gè)爬蟲平臺(tái), 要考慮的一個(gè)關(guān)鍵問題就是爬蟲任務(wù)的執(zhí)行時(shí)機(jī)以及以何種方式執(zhí)行. 因?yàn)榕廊【W(wǎng)頁(yè)和截圖需要等網(wǎng)頁(yè)全部加載完成之后再處理, 這樣才能保證數(shù)據(jù)的完整性, 所以我們可以認(rèn)定它為一個(gè)耗時(shí)任務(wù).
當(dāng)我們使用nodejs作為后臺(tái)服務(wù)器時(shí), 由于nodejs本身是單線程的,所以當(dāng)爬取請(qǐng)求傳入nodejs時(shí), nodejs不得不等待這個(gè)"耗時(shí)任務(wù)"完成才能進(jìn)行其他請(qǐng)求的處理, 這樣將會(huì)導(dǎo)致頁(yè)面其他請(qǐng)求需要等待該任務(wù)執(zhí)行結(jié)束才能繼續(xù)進(jìn)行, 所以為了更好的用戶體驗(yàn)和流暢的響應(yīng),我們不得不考慮多進(jìn)程處理. 好在nodejs設(shè)計(jì)支持子進(jìn)程, 我們可以把爬蟲這類耗時(shí)任務(wù)放入子進(jìn)程中來(lái)處理,當(dāng)子進(jìn)程處理完成之后再通知主進(jìn)程. 整個(gè)流程如下圖所示:
nodejs有3種創(chuàng)建子進(jìn)程的方式, 這里我們使用fork來(lái)處理, 具體實(shí)現(xiàn)方式如下:
// child.js
function computedTotal(arr, cb) {
// 耗時(shí)計(jì)算任務(wù)
}
// 與主進(jìn)程通信
// 監(jiān)聽主進(jìn)程信號(hào)
process.on('message', (msg) => {
computedTotal(bigDataArr, (flag) => {
// 向主進(jìn)程發(fā)送完成信號(hào)
process.send(flag);
})
});
// main.js
const { fork } = require('child_process');
app.use(async (ctx, next) => {
if(ctx.url === '/fetch') {
const data = ctx.request.body;
// 通知子進(jìn)程開始執(zhí)行任務(wù),并傳入數(shù)據(jù)
const res = await createPromisefork('./child.js', data)
}
// 創(chuàng)建異步線程
function createPromisefork(childUrl, data) {
// 加載子進(jìn)程
const res = fork(childUrl)
// 通知子進(jìn)程開始work
data && res.send(data)
return new Promise(reslove => {
res.on('message', f => {
reslove(f)
})
})
}
await next()
})
復(fù)制代碼
以上是一個(gè)實(shí)現(xiàn)父子進(jìn)程通信的簡(jiǎn)單案例, 我們的爬蟲服務(wù)也會(huì)采用該模式來(lái)實(shí)現(xiàn).
使用javascript手動(dòng)實(shí)現(xiàn)控制爬蟲最大并發(fā)數(shù)
以上介紹的是要實(shí)現(xiàn)我們的爬蟲應(yīng)用需要考慮的技術(shù)問題, 接下來(lái)我們開始正式實(shí)現(xiàn)業(yè)務(wù)功能, 因?yàn)榕老x任務(wù)是在子進(jìn)程中進(jìn)行的,所以我們將在子進(jìn)程代碼中實(shí)現(xiàn)我們的爬蟲功能.我們先來(lái)整理一下具體業(yè)務(wù)需求, 如下圖:
接下來(lái)我會(huì)先解決控制爬蟲最大并發(fā)數(shù)這個(gè)問題, 之所以要解決這個(gè)問題, 是為了考慮爬蟲性能問題, 我們不能一次性讓爬蟲爬取所有的網(wǎng)頁(yè),這樣會(huì)開啟很多并行進(jìn)程來(lái)處理, 所以我們需要設(shè)計(jì)一個(gè)節(jié)流裝置,來(lái)控制每次并發(fā)的數(shù)量, 當(dāng)前一次的完成之后再進(jìn)行下一批的頁(yè)面抓取處理. 具體代碼實(shí)現(xiàn)如下:
// 異步隊(duì)列
const queue = []
// 最大并發(fā)數(shù)
const max_parallel = 6
// 開始指針
let start = 0
for(let i = 0; i < urls.length; i++) {
// 添加異步隊(duì)列
queue.push(fetchPage(browser, i, urls[i]))
if(i &&
(i+1) % max_parallel === 0
|| i === (urls.length - 1)) {
// 每隔6條執(zhí)行一次, 實(shí)現(xiàn)異步分流執(zhí)行, 控制并發(fā)數(shù)
await Promise.all(queue.slice(start, i+1))
start = i
}
}
復(fù)制代碼
以上代碼即可實(shí)現(xiàn)每次同時(shí)抓取6個(gè)網(wǎng)頁(yè), 當(dāng)?shù)谝淮稳蝿?wù)都結(jié)束之后才會(huì)執(zhí)行下一批任務(wù).代碼中的urls指的是用戶輸入的url集合, fetchPage為抓取頁(yè)面的爬蟲邏輯, 筆者將其封裝成了promise.
如何截取整個(gè)網(wǎng)頁(yè)快照
我們都知道puppeteer截取網(wǎng)頁(yè)圖片只會(huì)截取加載完成的部分,對(duì)于一般的靜態(tài)網(wǎng)站來(lái)說(shuō)完全沒有問題, 但是對(duì)于頁(yè)面內(nèi)容比較多的內(nèi)容型或者電商網(wǎng)站, 基本上都采用了按需加載的模式, 所以一般手段截取下來(lái)的只是一部分頁(yè)面, 或者截取的是圖片還沒加載出來(lái)的占位符,如下圖所示:
所以為了實(shí)現(xiàn)截取整個(gè)網(wǎng)頁(yè),需要進(jìn)行人為干預(yù).筆者這里提供一種簡(jiǎn)單的實(shí)現(xiàn)思路, 可以解決該問題. 核心思路就是利用puppeteer的api手動(dòng)讓瀏覽器滾動(dòng)到底部, 每次滾動(dòng)一屏, 直到頁(yè)面的滾動(dòng)高度不變時(shí)則認(rèn)為滾動(dòng)到底部.具體實(shí)現(xiàn)如下:
// 滾動(dòng)高度
let scrollStep = 1080;
// 最大滾動(dòng)高度, 防止無(wú)限加載的頁(yè)面導(dǎo)致長(zhǎng)效耗時(shí)任務(wù)
let max_height = 30000;
let m = {prevScroll: -1, curScroll: 0}
while (m.prevScroll !== m.curScroll && m.curScroll < max_height) {
// 如果上一次滾動(dòng)和本次滾動(dòng)高度一樣, 或者滾動(dòng)高度大于設(shè)置的最高高度, 則停止截取
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秒后繼續(xù)滾動(dòng)頁(yè)面, 為了讓頁(yè)面加載充分
await sleep(3000);
}
// 其他業(yè)務(wù)代碼...
// 截取網(wǎng)頁(yè)快照,并設(shè)置圖片質(zhì)量和保存路徑
const screenshot = await page.screenshot({path: `static/${uid}.jpg`, fullPage: true, quality: 70});
復(fù)制代碼
爬蟲代碼的其他部分因?yàn)椴皇呛诵闹攸c(diǎn),這里不一一舉例, 我已經(jīng)放到github上,大家可以交流研究.
有關(guān)如何提取網(wǎng)頁(yè)文本, 也有現(xiàn)成的api可以調(diào)用, 大家可以選擇適合自己業(yè)務(wù)的api去應(yīng)用,筆者這里拿puppeteer的page.$eval來(lái)舉例:
const txt = await page.$eval('body', el => {
// el即為dom節(jié)點(diǎn), 可以對(duì)body的子節(jié)點(diǎn)進(jìn)行提取,分析
return {...}
})
復(fù)制代碼
nodejs第三方庫(kù)和模塊的使用
為了搭建完整的node服務(wù)平臺(tái),筆者采用了
- koa 一款輕量級(jí)可擴(kuò)展node框架
- glob 使用強(qiáng)大的正則匹配模式遍歷文件
- koa2-cors 處理訪問跨域問題
- koa-static 創(chuàng)建靜態(tài)服務(wù)目錄
- koa-body 獲取請(qǐng)求體數(shù)據(jù) 有關(guān)如何使用這些模塊實(shí)現(xiàn)一個(gè)完整的服務(wù)端應(yīng)用, 筆者在代碼里做了詳細(xì)的說(shuō)明, 這里就不一一討論了. 具體代碼如下:
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();
// 創(chuàng)建靜態(tài)目錄
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')
}
// 設(shè)置跨域
app.use(cors({
origin: function (ctx) {
if (ctx.url.indexOf('fetch') > -1) {
return '*'; // 允許來(lái)自所有域名請(qǐng)求
}
return ''; // 這樣就能只允許 http://localhost 這個(gè)域名的請(qǐng)求了
},
exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
maxAge: 5, // 該字段可選,用來(lái)指定本次預(yù)檢請(qǐng)求的有效期,單位為秒
credentials: true,
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization', 'Accept', 'x-requested-with'],
}))
// 創(chuàng)建異步線程
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或者請(qǐng)求超時(shí)或者服務(wù)器內(nèi)部錯(cuò)誤'
}
}
await next()
})
app.listen(80)
復(fù)制代碼
使用umi3 + antd4.0搭建爬蟲前臺(tái)界面
該爬蟲平臺(tái)的前端界面筆者采用umi3+antd4.0開發(fā), 因?yàn)閍ntd4.0相比之前版本確實(shí)體積和性能都提高了不少, 對(duì)于組件來(lái)說(shuō)也做了更合理的拆分. 因?yàn)榍岸隧?yè)面實(shí)現(xiàn)比較簡(jiǎn)單,整個(gè)前端代碼使用hooks寫不到200行,這里就不一一介紹了.大家可以在筆者的github上學(xué)習(xí)研究.
- github項(xiàng)目地址: 基于Apify+node+react搭建的有點(diǎn)意思的爬蟲平臺(tái)
界面如下:
大家可以自己克隆本地運(yùn)行, 也可以基于此開發(fā)屬于自己的爬蟲應(yīng)用.
項(xiàng)目使用的技術(shù)文檔地址
- apify 一款用于JavaScript的可伸縮的web爬蟲庫(kù)
- Puppeteer
- koa -- 基于nodejs平臺(tái)的下一代web開發(fā)框架