一隻node爬蟲的升級打怪之路

相學長發表於2019-03-03

我一直覺得,爬蟲是許多web開發人員難以迴避的點。我們也應該或多或少的去接觸這方面,因為可以從爬蟲中學習到web開發中應當掌握的一些基本知識。而且,它還很有趣。

我是一個知乎輕微重度使用者,之前寫了一隻爬蟲幫我爬取並分析它的資料,我感覺這個過程還是挺有意思,因為這是一個不斷給自己創造問題又去解決問題的過程。其中遇到了一些點,今天總結一下跟大家分享分享。

它都爬了什麼?

先簡單介紹下我的爬蟲。它能夠定時抓取一個問題的關注量、瀏覽量、回答數,以便於我將這些資料繪成圖表展現它的熱點趨勢。為了不讓我錯過一些熱門事件,它還會定時去獲取我關注話題下的熱門問答,並推送到我的郵箱。

作為一個前端開發人員,我必須為這個爬蟲系統做一個介面,能讓我登陸知乎帳號,新增關注的題目、話題,看到視覺化的資料。所以這隻爬蟲還有登陸知乎、搜尋題目的功能。

然後來看下介面。

下面正兒八經講它的開發歷程。

技術選型

Python得益於其簡單快捷的語法、以及豐富的爬蟲庫,一直是爬蟲開發人員的首選。可惜我不熟。當然最重要的是,作為一名前端開發人員,node能滿足爬蟲需求的話,自然更是首選。而且隨著node的發展,也有許多好用的爬蟲庫,甚至有puppeteer這樣直接能模擬Chrome訪問網頁的工具的推出,node在爬蟲方面應該是妥妥能滿足我所有的爬蟲需求了。

於是我選擇從零搭建一個基於koa2的服務端。為什麼不直接選擇egg,express,thinkjs這些更加全面的框架呢?因為我愛折騰嘛。而且這也是一個學習的過程。如果以前不瞭解node,又對搭建node服務端有興趣,可以看我之前的一篇文章-從零搭建Koa2 Server

爬蟲方面我選擇了request+cheerio。雖然知乎有很多地方用到了react,但得益於它絕大部分頁面還是服務端渲染,所以只要能請求網頁與介面(request),解析頁面(cherrio)即可滿足我的爬蟲需求。

其他不一一舉例了,我列個技術棧

服務端

  1. koajs 做node server框架;
  2. request + cheerio 做爬蟲服務;
  3. mongodb 做資料儲存;
  4. node-schedule 做任務排程;
  5. nodemailer 做郵件推送。

客戶端

  1. vuejs 前端框架;
  2. museui Material Design UI庫;
  3. chart.js 圖表庫。

技術選型妥善後,我們就要關心業務了。首要任務就是真正的爬取到頁面。

如何能爬取網站的資料?

知乎並沒有對外開放介面能讓使用者獲取資料,所以想獲取資料,就得自己去爬取網頁資訊。我們知道即使是網頁,它本質上也是個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等知識,就會本能意識的進行一些分層:

  1. model 資料層。負責資料持久化,通俗說就是連線資料庫,對應資料庫表的實體資料模型;
  2. service 業務邏輯層。顧名思義,就是負責實現各種業務邏輯。
  3. controller 控制器。調取業務邏輯服務,實現資料傳遞,返回客戶端檢視或資料。

當然也有些框架或者人會將業務邏輯service實現在controller中,亦或者是model層中。我個人認為一個稍微複雜的專案,應該是單獨抽離出抽象的業務邏輯的。

比如在我這個爬蟲系統中,我將資料庫的添刪改查操作按model層對應抽離出service,另外再將爬取頁面的服務、郵件推送的服務、使用者鑑權的服務抽離到對應的service

最終我們的api能夠設計的更加易讀,整個系統也更加易擴充。

分層在koa上的實踐

如果是直接使用一個成熟的後端框架,分層這事我們是不用多想的。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引數,呼叫父類方法時,又要傳它,還會使得方法損失一些可讀性。

所以總結一下,我們有如下問題:

  1. controller中的方法無法呼叫自身的其他方法、屬性;
  2. 呼叫父類方法時,需要傳遞上下文引數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的值。如何判斷呢,兩個辦法:

  1. 除錯。在我們未bind之前,在中介軟體方法中列印一下this,是undefined的話自然就沒被繫結。
  2. 看koa-router/koa/node的原始碼。

事實是,自然是沒有的。那我們就放心的bind吧。

寫在最後

上述大概就是編寫這個小工具時,遇到的一些點,感覺可以總結的。也並沒有什麼技術難點,不過可以藉此學習學習一些相關的知識,包括網站安全、爬與反爬、、koa底層原理等等。

這個工具本身非常的個人色彩,不一定滿足大家的需要。而且它在半年前就寫好了,只不過最近被我挖墳拿出來總結。而且就在我即將寫完文章時,我發現知乎提示我的賬號不安全了。我估計是以為同一IP同一賬戶發起過多的網路請求,我這臺伺服器IP已經被認為是不安全的IP了,在這上面登入的賬戶都會被提示不安全。所以我不建議大家將其直接拿來使用。

當然,如果還是對其感興趣,本地測試下或者學習使用,還是沒什麼大問題的。或者還有更深的興趣的話,可以自己嘗試去繞開知乎的安全策略。

最後的最後附上 專案GitHub地址

--閱讀原文

--轉載請先經過本人授權。

相關文章