用 Node 抓站(二):Promise 使程式碼更優雅

三水清發表於2019-03-03

本文主要目的是通過抓取「電影天堂」的最新電影名稱和下載地址,展現如何抓取列表之後,繼續抓取正文內容

使用《用Node抓站(一)》(沒看過的可以翻看下本公眾號的歷史文章)當中寫的spider.js 程式碼可以直接用下面的程式碼把列表抓出來:

var spider = require('../lib/spider')

spider({
  url: 'http://www.dytt8.net/index.htm',
  decoding: 'gb2312'
}, (err, data, body, req) => {
  if (!err) {
    console.log(data)
  }
}, {
  items: {
    selector: '.co_area2 .co_content2 ul a!attr:href'
  }
})複製程式碼

這裡不同的是涉及到一個編碼問題,「電影天堂」用的是gb2312編碼,需要轉成utf8,不然抓的內容會亂碼。我擴充套件了request模組的引數增加了decoding:因為encoding被佔用了,而且為了轉碼方便,我將encoding設為null,這樣出來的資料就是Buffer,可以直接用iconv-lite之類的進行轉碼,涉及到編碼問題不是本文討論內容,就不多說了。

抓取列表後,發現title是被截斷的,也要在正文頁面抓取一下;繼續寫抓取下載地址和電影title的程式碼:

spider({
  url: 'http://www.dytt8.net/index.htm',
  decoding: 'gb2312'
}, (err, data, body, req) => {
  if (!err) {
    if (data && data.items) {
      var urls = data.items
      urls.forEach(function (url) {
        url = 'http://www.dytt8.net' + url
        spider({url: url, decoding: 'gb2312'}, (e, d) => {
          if (!e) {
            console.log(d)
          }
        }, {
          url: {
            selector: '#Zoom table td a!text'
          },
          title: {
            selector: '.title_all h1!text'
          }
        })
      })
    }
  }
}, {
  items: {
    selector: '.co_area2 .co_content2 ul a!attr:href'
  }
})複製程式碼

看上去挺簡單的,但是回撥好多啊。。。

處理這種非同步回撥可以使用Promise!

Promise

Promise是CommonJS提出來的這一種規範,有多個版本,在ES6當中已經納入規範,原生支援Promise 物件,非ES6環境可以用類似Bluebird、Q這類庫來支援。

Promise可以將回撥變成鏈式呼叫寫法,流程更加清晰,程式碼更加優雅。

簡單歸納下Promise:三個狀態、兩個過程、一個方法,3-2-1

  • 三個狀態:pending、fulfilled、rejected
  • 兩個過程:
    • pending→fulfilled(resolve)
    • pending→rejected(reject)
  • 一個方法:then

當然還有其他概念,比如:catchPromise.all/race這裡就不展開了。

程式碼的Promise改造

瞭解了Promise之後,先把spider.js改成Promise的

return new Promise((resolve, reject) => {
  opts.callback = function (error, response, body) {
    if (!error) {
      body = iconv.decode(body, opts.decoding || 'utf8')
      // 處理json
      try {
        body = JSON.parse(body)
      } catch (e) {
      }
      var data = parser(body, handlerMap)
      callback(error, data, response)
      resolve(data, response)
    } else {
      callback(error, body, response)
      reject(error)
    }
  }
  request(opts)
})複製程式碼

這裡Promise是個類,接受一個函式,函式引數是兩個函式:resolvereject,當成功的時候resolve(結果),當失敗的時候reject(原因)

完成spider.js改造之後,使用spider抓取程式碼變成了下面這樣:

spider({
  url: 'http://www.dytt8.net/index.htm',
  decoding: 'gb2312'
}, {
  items: {
    selector: '.co_area2 .co_content2 ul a!attr:href'
  }
}).then(function (data) {
  // 第一頁成功
  if (data && data.items) {
    var urls = data.items
    urls.forEach(function (url) {
      url = 'http://www.dytt8.net' + url
      // 遍歷開始抓取第二頁面
      spider({url: url, decoding: 'gb2312'}, {
        url: {
          selector: '#Zoom table td a!text'
        },
        title: {
          selector: '.title_all h1!text'
        }
      }).then((d) => {
        console.log(d)
      })
    })
  }
})複製程式碼

上面的程式碼能夠實現需求,但是沒有充分利用Promise的鏈式寫法,還是出現了回撥,沒有專注程式流程,看上去還是亂糟糟的。

Promise的鏈式呼叫

提到鏈式呼叫,最多的是jQuery的寫法:$(document).click(handler).addClass()….

這裡簡單程式碼實現一個可以鏈式呼叫的類,方便大家舉一反三:


class M {
  constructor (number) {
    this.number = number
  }
  add (n) {
    this.number += n
    return this
  }
  sub (n) {
    this.number -= n
    return this
  }
  result () {
    return this.number
  }
}

var m = new M(1)
m.add(2).sub(3).result()複製程式碼

在Promise中,每個then或者catch 返回的都是一個Promise物件,所以可以繼續用then/catch,而且每次then都是上一次thenreturn結果,如果沒有return那麼就是undefined,例如下面:

var resolve = Promise.resolve(1)

resolve.then((d) => {
  console.log(`第1個:${d}`) // 1
}).then((d) => {
  console.log(`第2個:${d}`) // undefined
})複製程式碼

而如果return 則是return後的結果:

var resolve = Promise.resolve(1)

resolve.then((d) => {
  console.log(`第1個:${d}`) // 1
  return 2 // 2
}).then((d) => {
  console.log(`第2個:${d}`) //2
})複製程式碼

上面的程式碼和下面的程式碼實現一樣,建議每個then都返回一個Promise物件

var resolve = Promise.resolve(1)

resolve.then((d) => {
  console.log(`第1個:${d}`)
  return Promise.resolve(2)
}).then((d) => {
  console.log(`第2個:${d}`)
})複製程式碼

瞭解了上面的知識之後,我將整個流程劃分為三部分:獲取列表fetchList,處理列表資料dealListData和獲取正文內容fetchContents

然後將三個相互關聯序列的流程,通過then串聯起來:

fetchList().then(dealListData).then(fetchContents).then((d) => {
  console.log(d, d.length)
}).catch((e) => {
  console.log(e)
})複製程式碼

再來看下特殊處理的fetchContents,因為傳進來的是一堆需要抓取的正文頁面的url,如果我們使用Promise.all這個方法,其中一個正文頁面抓取失敗,就會導致Promise都rejected,則後續then都失敗,Promise狀態只會改變一次,而且回撥只會執行一次。我們的需求是正文頁面一個抓取失敗不要緊,其他的頁面繼續抓取。所以特殊處理下:

function fetchContents (urls) {
  return new Promise((resolve, reject) => {
    var count = 0
    var len = urls.length
    var results = []
    while (len--) {
      var url = urls[len]
      count++
      spider({url: url, decoding: 'gb2312'}, {
        url: {
          selector: '#Zoom table td a!text'
        },
        title: {
          selector: '.title_all h1!text'
        }
      }).then((d) => {
        results.push(d)
      }).finally(() => {
        count--
        if (count === 0) {
          resolve(results)
        }
      })
    }
  })
}複製程式碼

總結

本文通過抓取「電影天堂」下載地址的例項,粗略的講解了Promise的使用方法。後面抓取系列文章還會介紹怎麼避免封IP等知識,敬請關注本公眾號後續文章。

本文的完整程式碼,在github/ksky521/mpdemo/ 對應文章名資料夾下可以找到

-eof-
@三水清
未經允許,請勿轉載,不用打賞,喜歡請轉發和關注

感覺有用,歡迎關注我的公眾號,每週一篇原創技術文章

用 Node 抓站(二):Promise 使程式碼更優雅
關注三水清

相關文章