熟悉個人朋友可能會知道,我一貫是不寫熱點的。爲何不寫呢?是由於我不關注熱點嗎?其實也不是。有些事件我仍是很關注的,也確實有很多想法和觀點。 但我一直奉行一個原則,就是:要作有生命力的內容。javascript
本文介紹的內容來自於筆者以前負責研發的爬蟲管理平臺, 專門抽象出了一個相對獨立的功能模塊爲你們講解如何使用nodejs開發專屬於本身的爬蟲平臺.文章涵蓋的知識點比較多,包含nodejs, 爬蟲框架, 父子進程及其通訊, react和umi等知識, 筆者會以儘量簡單的語言向你們一一介紹.css
在開始文章以前,咱們有必要了解爬蟲的一些應用. 咱們通常瞭解的爬蟲, 多用來爬取網頁數據, 捕獲請求信息, 網頁截圖等,以下圖: 前端
apify是一款用於JavaScript的可伸縮的web爬蟲庫。能經過無頭(headless)Chrome 和 Puppeteer 實現數據提取和** Web** 自動化做業的開發。 它提供了管理和自動擴展無頭Chrome / Puppeteer實例池的工具,支持維護目標URL的請求隊列,並可將爬取結果存儲到本地文件系統或雲端。vue
咱們安裝和使用它很是簡單, 官網上也有很是多的實例案例能夠參考, 具體安裝使用步驟以下:java
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執行後可能會出現以下界面: node
咱們要想實現一個爬蟲平臺, 要考慮的一個關鍵問題就是爬蟲任務的執行時機以及以何種方式執行. 由於爬取網頁和截圖須要等網頁所有加載完成以後再處理, 這樣才能保證數據的完整性, 因此咱們能夠認定它爲一個耗時任務.react
當咱們使用nodejs做爲後臺服務器時, 因爲nodejs自己是單線程的,因此當爬取請求傳入nodejs時, nodejs不得不等待這個"耗時任務"完成才能進行其餘請求的處理, 這樣將會致使頁面其餘請求須要等待該任務執行結束才能繼續進行, 因此爲了更好的用戶體驗和流暢的響應,咱們不德不考慮多進程處理. 好在nodejs設計支持子進程, 咱們能夠把爬蟲這類耗時任務放入子進程中來處理,當子進程處理完成以後再通知主進程. 整個流程以下圖所示: webpack
nodejs有3種建立子進程的方式, 這裏咱們使用fork來處理, 具體實現方式以下:css3
// 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()
})
複製代碼
以上是一個實現父子進程通訊的簡單案例, 咱們的爬蟲服務也會採用該模式來實現.git
以上介紹的是要實現咱們的爬蟲應用須要考慮的技術問題, 接下來咱們開始正式實現業務功能, 由於爬蟲任務是在子進程中進行的,因此咱們將在子進程代碼中實現咱們的爬蟲功能.咱們先來整理一下具體業務需求, 以下圖:
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截取網頁圖片只會截取加載完成的部分,對於通常的靜態網站來講徹底沒有問題, 可是對於頁面內容比較多的內容型或者電商網站, 基本上都採用了按需加載的模式, 因此通常手段截取下來的只是一部分頁面, 或者截取的是圖片還沒加載出來的佔位符,以下圖所示:
// 滾動高度
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數據可視化等前端知識和實戰,歡迎在公號《趣談前端》加入咱們的技術羣一塊兒學習討論,共同探索前端的邊界。