我一直以爲,爬蟲是許多web開發人員難以迴避的點。咱們也應該或多或少的去接觸這方面,由於能夠從爬蟲中學習到web開發中應當掌握的一些基本知識。並且,它還頗有趣。javascript
我是一個知乎輕微重度用戶,以前寫了一隻爬蟲幫我爬取並分析它的數據,我感受這個過程仍是挺有意思,由於這是一個不斷給本身創造問題又去解決問題的過程。其中遇到了一些點,今天總結一下跟你們分享分享。css
先簡單介紹下個人爬蟲。它可以定時抓取一個問題的關注量、瀏覽量、回答數,以便於我將這些數據繪成圖表展示它的熱點趨勢。爲了避免讓我錯過一些熱門事件,它還會定時去獲取我關注話題下的熱門問答,並推送到個人郵箱。html
做爲一個前端開發人員,我必須爲這個爬蟲系統作一個界面,能讓我登錄知乎賬號,添加關注的題目、話題,看到可視化的數據。因此這隻爬蟲還有登錄知乎、搜索題目的功能。前端
而後來看下界面。vue
下面正兒八經講它的開發歷程。java
Python得益於其簡單快捷的語法、以及豐富的爬蟲庫,一直是爬蟲開發人員的首選。惋惜我不熟。固然最重要的是,做爲一名前端開發人員,node能知足爬蟲需求的話,天然更是首選。並且隨着node的發展,也有許多好用的爬蟲庫,甚至有puppeteer這樣直接能模擬Chrome訪問網頁的工具的推出,node在爬蟲方面應該是妥妥能知足我全部的爬蟲需求了。node
因而我選擇從零搭建一個基於koa2的服務端。爲何不直接選擇egg,express,thinkjs這些更加全面的框架呢?由於我愛折騰嘛。並且這也是一個學習的過程。若是之前不瞭解node,又對搭建node服務端有興趣,能夠看我以前的一篇文章-從零搭建Koa2 Server。react
爬蟲方面我選擇了request+cheerio。雖然知乎有不少地方用到了react,但得益於它絕大部分頁面仍是服務端渲染,因此只要能請求網頁與接口(request),解析頁面(cherrio)便可知足個人爬蟲需求。git
其餘不一一舉例了,我列個技術棧github
koajs 作node server框架;
mongodb 作數據存儲;
node-schedule 作任務調度;
nodemailer 作郵件推送。
技術選型妥善後,咱們就要關心業務了。首要任務就是真正的爬取到頁面。
知乎並無對外開放接口能讓用戶獲取數據,因此想獲取數據,就得本身去爬取網頁信息。咱們知道即便是網頁,它本質上也是個GET請求的接口,咱們只要在服務端去請求對應網頁的地址(客戶端請求會跨域),再把html結構解析下,獲取想要的數據便可。
那爲何我要搞一個登錄呢?由於非登錄賬號獲取信息,知乎只會展示有限的數據,並且也沒法得知本身知乎賬戶關注的話題、問題等信息。並且如果想本身的系統也給其餘朋友使用,也必須搞一個賬戶系統。
你們都會用Chrome等現代瀏覽器看請求信息,咱們在知乎的登陸頁進行登錄,而後查看捕獲接口信息就能知道,登錄無非就是向一個登錄api發送帳戶、密碼等信息,若是成功。服務端會向客戶端設置一個cookie,這個cookie便是登錄憑證。
因此咱們的思路也是如此,經過爬蟲服務端去請求接口,帶上咱們的賬號密碼信息,成功後再將返回的cookie存到咱們的系統數據庫,之後再去爬取其餘頁面時,帶上此cookie便可。
固然,等咱們真正嘗試時,會受到更多挫折,由於會遇到token、驗證碼等問題。不過,因爲咱們有客戶端了,能夠將驗證碼的識別交給真正的人,而不是服務端去解析圖片字符,這下降了咱們實現登錄的難度。
一波三折的是,即便你把正確驗證碼提交了,仍是會提示驗證碼錯誤。若是咱們本身作過驗證碼提交的系統就可以迅速的定位緣由。若是沒作過,咱們再次查看登錄時涉及的請求與響應,咱們也能猜到:
在客戶端獲取驗證碼時,知乎服務端還會往客戶端設置一個新cookie,提交登錄請求時,必須把驗證碼與此cookie一同提交,來驗證這次提交的驗證碼確實是當時給予用戶的驗證碼。
語言描述有些繞,我以圖的形式來表達一個登錄請求的完整流程。
注:我編寫爬蟲時,知乎還部分採起圖片字符驗證碼,現已所有改成「點擊倒立文字」的形式。這樣會加大提交正確驗證碼的難度,但也並不是機關用盡。獲取圖片後,由人工識別並點擊倒立文字,將點擊的座標提交到登錄接口便可。固然有興趣有能力的同窗也能夠本身編寫算法識別驗證碼。
上一步中,咱們已經獲取到了登錄後的憑證cookie。用戶登錄成功後,咱們把登錄的賬戶信息與其憑證cookie存到mongo中。之後此用戶發起的爬取需求,包括對其跟蹤問題的數據爬取都根據此cookie爬取。
固然cookie是有時間期限的,因此當咱們存cookie時,應該把過時時間也記錄下來,當後面再獲取此cookie時,多加一步過時校驗,若過時了則返回過時提醒。
爬蟲的基礎搞定後,就能夠真正去獲取想要的數據了。個人需求是想知道某個知乎問題的熱點趨勢。先用瀏覽器去看看一個問題頁面下都有哪些數據,能夠被我爬取分析。舉個例子,好比這個問題:有哪些使人拍案叫絕的推理橋段。
打開連接後,頁面上最直接展示出來的有關注者,被瀏覽,1xxxx個回答,還要默認展現的幾個高贊回答及其點贊評論數量。右鍵查看網站源代碼,確認這些數據是服務端渲染出來的,咱們就能夠經過request請求網頁,再經過cherrio,使用css選擇器定位到數據節點,獲取並存儲下來。代碼示例以下:
async getData (cookie, qid) { const options = { url: `${zhihuRoot}/question/${qid}`, method: 'GET', headers: { 'Cookie': cookie, 'Accept-Encoding': 'deflate, sdch, br' // 不容許gzip,開啓gzip會開啓知乎客戶端渲染,致使沒法爬取 } } const rs = await this.request(options) if (rs.error) { return this.failRequest(rs) } const $ = cheerio.load(rs) const NumberBoard = $('.NumberBoard-item .NumberBoard-value') const $title = $('.QuestionHeader-title') $title.find('button').remove() return { success: true, title: $title.text(), data: { qid: qid, followers: Number($(NumberBoard[0]).text()), readers: Number($(NumberBoard[1]).text()), answers: Number($('h4.List-headerText span').text().replace(' 個回答', '')) } } }
這樣咱們就爬取了一個問題的數據,只要咱們可以按必定時間間隔不斷去執行此方法獲取數據,最終咱們就能繪製出一個題目的數據曲線,分析起熱點趨勢。
那麼問題來了,如何去作這個定時任務呢?
我使用了node-schedule作任務調度。若是以前作過定時任務的同窗,可能對其相似cron的語法比較熟悉,不熟悉也不要緊,它提供了not-cron-like的,更加直觀的設置去配置任務,看下文檔就能大體瞭解。
固然這個定時任務不是簡單的不斷去執行上述的爬取方法getData
。由於這個爬蟲系統不只是一個用戶,一個用戶不只只跟蹤了一個問題。
因此咱們此處的完整任務應該是遍歷系統的每一個cookie未過時用戶,再遍歷每一個用戶的跟蹤問題,再去獲取這些問題的數據。
系統還有另外兩個定時任務,一個是定時爬取用戶關注話題的熱門回答,另外一個是推送這個話題熱門回答給相應的用戶。這兩個任務跟上述任務大體流程同樣,就不細講了。
可是在咱們作定時任務時會有個細節問題,就是如何去控制爬取時的併發問題。具體舉例來講:若是爬蟲請求併發過高,知乎多是會限制此IP的訪問的,因此咱們須要讓爬蟲請求一個一個的,或者若干個若干個的進行。
簡單思考下,咱們會採起循環await。我不假思索的寫下了以下代碼:
// 爬蟲方法 async function getQuestionData () { // do spider action } // questions爲獲取到的關注問答 questions.forEach(await getQuestionData)
然而執行以後,咱們會發現這樣其實仍是併發執行的,爲何呢?其實仔細想下就明白了。forEach只是循環的語法糖,若是沒有這個方法,讓你來實現它,你會怎麼寫呢?你大概也寫的出來:
Array.prototype.forEach = function (callback) { for (let i = 0; i < this.length; i++) { callback(this[i], i, this) } }
雖然forEach
自己會更復雜點,但大體就是這樣吧。這時候咱們把一個異步方法做爲參數callback
傳遞進去,而後循環執行它,這個執行依舊是併發執行,並不是是同步的。
因此咱們若是想實現真正的同步請求,仍是須要用for循環去執行,以下:
async function getQuestionData () { // do spider action } for (let i = 0; i < questions.length; i++) { await getQuestionData() }
除了for循環,還能夠經過for-of,若是對這方面感興趣,能夠去多瞭解下數組遍歷的幾個方法,順便研究下ES6的迭代器Iterator
。
其實若是業務量大,即便這樣作也是不夠的。還須要更加細分任務顆粒度,甚至要加代理IP來分散請求。
下面說的點跟爬蟲自己沒有太大關係了,屬於服務端架構的一些分享,若是隻關心爬蟲自己的話,能夠不用再往下閱讀了。
咱們把爬蟲功能都寫的差很少了,後面只要編寫相應的路由,能讓前端訪問到數據就行了。可是編寫一個沒那麼差勁的服務端,仍是須要咱們深思熟慮的。
我看過一些前端同窗寫的node服務,常常就會把系統全部的接口(router action)都寫到一個文件中,好一點的會根據模塊分幾個對於文件。
可是若是咱們接觸過其餘成熟的後端框架、或者大學學過一些J2EE等知識,就會本能意識的進行一些分層:
model
數據層。負責數據持久化,通俗說就是鏈接數據庫,對應數據庫表的實體數據模型;
service
業務邏輯層。顧名思義,就是負責實現各類業務邏輯。
controller
控制器。調取業務邏輯服務,實現數據傳遞,返回客戶端視圖或數據。
固然也有些框架或者人會將業務邏輯service
實如今controller
中,亦或者是model
層中。我我的認爲一個稍微複雜的項目,應該是單獨抽離出抽象的業務邏輯的。
好比在我這個爬蟲系統中,我將數據庫的添刪改查操做按model
層對應抽離出service
,另外再將爬取頁面的服務、郵件推送的服務、用戶鑑權的服務抽離到對應的service
。
最終咱們的api
可以設計的更加易讀,整個系統也更加易拓展。
若是是直接使用一個成熟的後端框架,分層這事咱們是不用多想的。node這樣的框架也有,我以前介紹的我廠開源的api-mocker採用的egg.js,也幫咱們作好了合理的分層。
可是若是本身基於koa從零搭建一個服務端,在這方面上就會遇到一些挫折。koa自己邏輯很是簡單,就是調取一系列中間件(就是一個個function),來處理請求。官方本身提供的koa-router,便是幫助咱們識別請求路徑,而後加載對應的接口方法。
咱們爲了區分業務模塊,會把一些接口方法寫在同一個controller
中,好比個人questionController負責處理問題相關的接口;topicController負責處理話題相關的接口。
那麼咱們可能會這樣編寫路由文件:
const Router = require('koa-router') const router = new Router() const question = require('./controller/question') const topic = require('./controller/topic') router.post('/api/question', question.create) router.get('/api/question', question.get) router.get('/api/topic', topic.get) router.post('/api/topic/follow', topic.follow) module.exports = router
個人question文件多是這樣寫的:
class Question { async get () { // return data } async create () { // create question and return data } } module.exports = new Question()
單純這樣寫是沒有辦法真正的以面向對象的形式來編寫controller
的。爲何呢?
由於咱們將question對象的屬性方法做爲中間件傳遞到了koa-router
中,而後由koa
底層來合併這些中間件方法,做爲參數傳遞到http.createServer
方法中,最終由node底層監聽請求時調用。那這個this
到底會是誰,不進行調試,或者查看koa與node源代碼,是無從得知的。可是不管如何方法調用者確定不是這個對象自身了(實際上它會是undefined
)。
也就是說,咱們不能經過this
來獲取對象自身的屬性或方法。
那怎麼辦呢?有的同窗可能會選擇將自身一些公共方法,直接寫在class
外部,或者寫在某個utils
文件中,而後在接口方法中使用。好比這樣:
const error = require('utils/error') const success = (ctx, data) => { ctx.body = { success: true, data: data } } class Question { async get () { success(data) } async create () { error(result) } } module.exports = new Question()
這樣確實ok,可是又會有新的問題---這些方法就不是對象本身的屬性,也就沒辦法被子類繼承了。
爲何須要繼承呢?由於有時候咱們但願一些不一樣的controller
有着公共的方法或屬性,舉個例子:我但願我全部的成功or失敗都是這樣的格式:
{ success: false, message: '對應的錯誤消息' } { success: true, data: '對應的數據' }
按照koa
的核心思想,這個通用的格式轉化,應該是專門編寫一箇中間件,在路由中間件以後(即執行完controller裏的方法以後)去作專門處理並response。
然而這樣會致使每有一個公共方法,就必需要加一箇中間件。並且controller
自己已經失去了對這些方法的控制權。這個中間件是執行自身仍是直接next()
將會很是難判斷。
若是是抽離成utils
方法再引用,也不是不能夠,就是方法多的話,聲明引用稍微麻煩些,並且沒有抽象類的意義。
更理想的狀態應該是如剛纔所說的,你們都繼承一個抽象的父類,而後去調用父類的公共相應方法便可,如:
class AbstractController { success (ctx, data) { ctx.body = { success: true, data: data } } error (ctx, error) { ctx.body = { success: false, msg: error } } } class Question extends AbstractController { async get (ctx) { const data = await getData(ctx.params.id) return super.success(ctx, data) } }
這樣就方便多了,不過若是寫過koa的人可能會有這樣的煩惱,一個上下文ctx
老是要做爲參數傳遞來傳遞去。好比上述控制器的全部中間件方法都得傳ctx
參數,調用父類方法時,又要傳它,還會使得方法損失一些可讀性。
因此總結一下,咱們有以下問題:
controller
中的方法沒法調用自身的其餘方法、屬性;
調用父類方法時,須要傳遞上下文參數ctx
。
其實解決的辦法很簡單,咱們只要想辦法讓controller
方法中的this指向實例化對象自身,再把ctx
掛在到這個this
上便可。
怎麼作呢?咱們只要再封裝一下koa-router
就行了,以下所示:
const Router = require('koa-router') const router = new Router() const question = require('./controller/question') const topic = require('./controller/topic') const routerMap = [ ['post', '/api/question', question, 'create'], ['get', '/api/question', question, 'get'], ['get', '/api/topic', topic, 'get'], ['post', '/api/topic/follow', topic, 'follow'] ] routerMap.map(route => { const [ method, path, controller, action ] = route router[method](path, async (ctx, next) => controller[action].bind(Object.assign(controller, { ctx }))(ctx, next) ) }) module.exports = router
大意就是在路由傳遞controller
方法時,將controller
自身與ctx
合併,經過bind
指定該方法的this
。這樣咱們就能經過this
獲取方法所屬controller
對象的其餘方法。此外子類方法與父類方法也能經過this.ctx
來獲取上下文對象ctx
。
可是bind
以前咱們其實應該考慮如下,其餘中間件以及koa自己會不會也幹了相似的事,修改了this的值。如何判斷呢,兩個辦法:
調試。在咱們未bind
以前,在中間件方法中打印一下this
,是undefined
的話天然就沒被綁定。
看koa-router/koa/node的源代碼。
事實是,天然是沒有的。那咱們就放心的bind
吧。
上述大概就是編寫這個小工具時,遇到的一些點,感受能夠總結的。也並無什麼技術難點,不過能夠藉此學習學習一些相關的知識,包括網站安全、爬與反爬、、koa底層原理等等。
這個工具自己很是的我的色彩,不必定知足你們的須要。並且它在半年前就寫好了,只不過最近被我挖墳拿出來總結。並且就在我即將寫完文章時,我發現知乎提示個人帳號不安全了。我估計是覺得同一IP同一帳戶發起過多的網絡請求,我這臺服務器IP已經被認爲是不安全的IP了,在這上面登陸的帳戶都會被提示不安全。因此我不建議你們將其直接拿來使用。
固然,若是仍是對其感興趣,本地測試下或者學習使用,仍是沒什麼大問題的。或者還有更深的興趣的話,能夠本身嘗試去繞開知乎的安全策略。
最後的最後附上 項目GitHub地址
--閱讀原文
--轉載請先通過本人受權。