NodeJs 爬蟲實踐

爬蟲是目前獲取數據的一個重要手段,而 python 是爬蟲最經常使用的語言,有豐富的框架和庫。最近在學習的過程當中,發現 nodjs 也能夠用來爬蟲,直接使用 JavaScript 來編寫,不但簡單,快速,並且還能利用到 Node 異步高併發的特性。下面是個人學習實踐。html

基礎

url 模塊

爬蟲的過程離不開對爬取網址的解析,應用到 Node 的 url 模塊。url 模塊用於處理與解析 URL。node

  • url.parse() 用於解析網址
  • url.resolve() 把一個目標 URL 解析成相對於一個基礎 URL
const url = require('url')

const myUrl = url.parse('https://user:pass@sub.host.com:8080/p/a/t/h?query=string#hash');

console.log(myUrl)
// {
// protocol: 'https:',
// slashes: true,
// auth: 'user:pass',
// host: 'sub.host.com:8080',
// port: '8080',
// hostname: 'sub.host.com',
// hash: '#hash',
// search: '?query=string',
// query: 'query=string',
// pathname: '/p/a/t/h',
// path: '/p/a/t/h?query=string',
// href:'https://user:pass@sub.host.com:8080/p/a/t/h?query=string#hash'
// }

console.log(url.resolve('/one/two/three', 'four'))
// 解析結果爲 '/one/two/four'
console.log(url.resolve('http://example.com/', '/one'))
// 解析結果爲 'http://example.com/one'
console.log(url.resolve('http://example.com/one', '/two'))
// 解析結果爲 'http://example.com/two'
複製代碼

http 模塊

爬蟲須要發送網絡請求,這時須要根據 url 協議採用不一樣模塊,若是是 http 則採用 http 模塊,若是 https 協議則採用 https 模塊。請求須要用到模塊的 request 方法python

使用 http.request(options[, callback]) 發出 HTTP 請求。http.request() 返回 http.ClientRequest 類的實例。git

ClientRequest 實例是一個繼承自 stream 的可寫流。表示正在進行的請求。在請求的時候可用 setHeader(name, value)getHeader(name)removeHeader(name) 等函數改變請求頭。 實際的請求頭將與第一個數據塊一塊兒發送,或者當調用 request.end() 時發送。github

要調用 request.end() 後才能開始發送請求。api

發送 POST 請求瀏覽器

const querystring = require('querystring')
const http = require('http')
const postData = querystring.stringify({
  'msg': 'Hello World!'
});

const options = {
  hostname: 'nodejs.cn',
  port: 80,
  path: '/upload',
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Content-Length': Buffer.byteLength(postData)
  }
};

const req = http.request(options, (res) => {
  console.log(`狀態碼: ${res.statusCode}`);
  console.log(`響應頭: ${JSON.stringify(res.headers)}`);
  res.setEncoding('utf8');
  res.on('data', (chunk) => {
    console.log(`響應主體: ${chunk}`);
  });
  res.on('end', () => {
    console.log('響應中已無數據');
  });
});

req.on('error', (e) => {
  console.error(`請求遇到問題: ${e.message}`);
});

// 將數據寫入請求主體。
req.write(postData);
req.end();
複製代碼

封裝

經過封裝,把經常使用的請求方式封裝成一個函數,便於複用和管理代碼。bash

// 定義默認的請求頭
const _header = {
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36',
  'Accept-Encoding': 'gzip, deflate, br' // 默認加載壓縮過的數據
}
複製代碼

請求時在請求頭添加User-Agent,用於模擬瀏覽器請求;添加 'Accept-Encoding': 'gzip, deflate, br'。請求通過 gzip 壓縮過的數據,減小消耗的流量和響應的時間。這樣在讀取完數據的時候須要用 zlib 模塊進行解壓。網絡

  • zlib.gzip(buffer[, options], callback) 壓縮數據
  • zlib.gunzip(buffer[, options], callback) 解壓數據
// 判斷請求頭裏是否有 gzip 字符串, 有則說明採用了 gzip 壓縮過
if(res.headers['content-encoding'] && res.headers['content-encoding'].split(';').includes('gzip')) {
  // 解壓數據後返回數據
  zlib.gunzip(result, (err, data) => {
    if(err) {
      reject(err)
    } else {
      resolve({
        buffer: data,
        headers: res.headers
      })
    }
  })
}
複製代碼

封裝的函數傳入一個 options 參數,能夠只是一個字符串,也能夠是一個 object 對象,包含各類請求信息。併發

// 判斷 options 是不是字符串
if(typeof options === 'string') {
  // 修改 options 格式爲對象
  options = {
    url: options,
    method: 'GET',
    header: {}
  }
} else {
  // 若是是對象,則給 options 對象添加默認屬性
  options = options || {}
  options.method = options.method || 'GET'
  options.header = options.header || {}
}
複製代碼

函數返回的是一個 Promse 對象,利用 JavaScript 的異步特性發送請求,提升運行效率。以後在 Promise 中利用 url 模塊來解析請求的網址,根據 protocol 判斷請求網址使用的 協議。

// 解析 url 
var obj = url.parse(options.url)

// 解析協議
let mode = null
let port = 0
switch(obj.protocol) {
  // https 協議
  case 'https:':
    mode = require('https')
    port = 443
    break
  // http 協議
  case 'http':
    mode = require('http')
    port = 80
    break
}
複製代碼

http.request 請求後 callback 回調函數裏,經過判斷 response 的 statusCode 是不是 200,來判斷請求是否成功。若是請求失敗,判斷是不是重定向,進行重定向處理。

if(res.statusCode!=200){
  // 判斷是不是跳轉
  if(res.statusCode==302 || res.statusCode==301){
    // 更新 url 爲跳轉的網址
    let location=url.resolve(options.url, res.headers['location']);
    // 更新 options 的設置
    options.url=location;
    options.method='GET';
    // 從新發起請求
    _request(options);
  }else{
    // 返回 response
    reject(res);
  }
}
複製代碼

最終代碼 fetch.js

const assert = require('assert')
const url = require('url')
const zlib = require('zlib')
const querystring = require('querystring') 

// 定義默認的請求頭
const _header = {
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36',
  'Accept-Encoding': 'gzip, deflate, br' // 默認加載壓縮過的數據
}

module.exports = (options) => {
  // 處理參數
  if(typeof options === 'string') {
    options = {
      url: options,
      method: 'GET',
      header: {}
    }
  } else {
    options = options || {}
    options.method = options.method || 'GET'
    options.header = options.header || {}
  }

  // 添加請求頭信息
  for(let name in _header) {
    options.header[name] = options.header[name] || _header[name]
  }
  // 封裝 post 的數據
  if(options.data) {
    options.postData = querystring.stringify(options.data)
    options.header['Content-Length'] = options.postData.length
  }

  // 返回 Promise 對象
  return new Promise((resolve, reject) => {
    _request(options)
    function _request(options) {
      // 解析 url 
      var obj = url.parse(options.url)

      // 解析協議
      let mode = null
      let port = 0
      switch(obj.protocol) {
        case 'https:':
          mode = require('https')
          port = 443
          break
        case 'http':
          mode = require('http')
          port = 80
          break
      }
      // 封裝請求
      let req_options = {
        hostname: obj.hostname,
        port: obj.port || port,
        path: obj.path,
        method: options.method,
        headers: options.header
      }
      // 發送請求
      let req_result = mode.request(req_options, (res) => {
        // 判斷是否出錯
        if(res.statusCode!=200){
          // 判斷是不是跳轉
          if(res.statusCode==302 || res.statusCode==301){
            // 更新 url 爲跳轉的網址
            let location=url.resolve(options.url, res.headers['location']);
            options.url=location;
            options.method='GET';
            _request(options);
          }else{
            // 返回 response
            reject(res);
          }
        } else {
            // 處理數據
          var data = []
          res.on('data', chunk => {
            data.push(chunk)
          })
          // 返回數據
          res.on('end', () => {
            // 處理數據
            var result = Buffer.concat(data)
            if(res.headers['content-length'] && res.headers['content-length'] != result.length) {
              reject('數據加載不完整')
            } else {
              // 判斷是不是壓縮過的數據
              if(res.headers['content-encoding'] && res.headers['content-encoding'].split(';').includes('gzip')) {
                zlib.gunzip(result, (err, data) => {
                  if(err) {
                    reject(err)
                  } else {
                    resolve({
                      buffer: data,
                      headers: res.headers
                    })
                  }
                })
              } else {
                // 直接加載數據
                resolve({
                  buffer: result,
                  headers: res.headers
                })
              }
            }
          })
        }
      })
      // 出錯返回
      req_result.on('error', e=>reject(e));
      // POST 時有數據則發送
      if(options.postData) {
        req_result.write(options.postData)
      }
      req_result.end();
    }
  })
}
複製代碼

實戰

接下來,利用封裝後的函數,來爬取豆瓣的電影數據,將收集的數據按照評分開始排序,最後輸出到 txt 文件上。

實戰代碼

const fetch = require('../fetch')
const fs = require('fs')
// 爬取豆瓣數據
var data = []
// 爬取100頁數據
getData(100)

// 爬取單頁數據
// 參數 time 爬取頁數
async function getData(time) {
  var pageStart = 0
  var pageLimit = 20
  for(var i = 0; i < time; i++) {
    var res = await fetch({
      url: `https://movie.douban.com/j/search_subjects?type=movie&tag=%E8%B1%86%E7%93%A3%E9%AB%98%E5%88%86&sort=rank&page_limit=${pageLimit}&page_start=${pageStart}`
    })
    // 添加數據到 data
    var newData = JSON.parse(res.buffer.toString())
    data.push(...newData.subjects)
    pageStart += pageLimit
  }
  // 對數據進行排序
  data.sort((a, b) => b.rate - a.rate)
  // 處理輸出到文檔的字符串
  var res = data.reduce((str, item) => {
    return str + item.title + ': ' + item.rate + '\n'
  }, '')
  // 保存數據到文件
  fs.writeFile('./sort.txt', res, function(err) {
      if (err) {
          throw err;
      }
  });
}
複製代碼

最終 sort.txt 數據

是,大臣 1984聖誕特輯: 9.8
伊麗莎白: 9.6
霸王別姬: 9.6
肖申克的救贖: 9.6
控方證人: 9.6
莫扎特!: 9.5
辛德勒的名單: 9.5
美麗人生: 9.5
茶館: 9.4
這個殺手不太冷: 9.4
十二怒漢: 9.4
背靠背,臉對臉: 9.4
控方證人: 9.4
福爾摩斯二世: 9.4
十二怒漢: 9.4
燦爛人生: 9.4
阿甘正傳: 9.4
搖滾莫扎特: 9.4
羅密歐與朱麗葉: 9.4
新世紀福音戰士劇場版:Air/真心爲你: 9.4
千與千尋: 9.3
熔爐: 9.3
極品基老伴:完結篇: 9.3
盜夢空間: 9.3
銀魂完結篇:直到永遠的萬事屋: 9.3
巴黎聖母院: 9.3
城市之光: 9.3
...
複製代碼

項目代碼

參考資料

相關文章
相關標籤/搜索