分分鐘教你用node.js寫個爬蟲

MagicEyes發表於2018-07-18

寫在前面

十分感謝大家的點贊和關注。其實,這是我第一次在掘金上寫文章。因為我也是前段時間偶然之間才開始瞭解和學習爬蟲,而且學習node的時間也不是很長。雖然用node做過一些後端的專案,但其實在node和爬蟲方面我還是一個新人,這篇文章主要是想和大家分享一下node和爬蟲方面的基本知識,希望對大家有幫助,也想和大家一起交流,一起學習,再次謝謝大家的支援!

對了,我開通了個人的 GitHub主頁,裡面有自己的技術文章,還會有個人的隨想、思考和日誌。以後所有的文章都會第一時間更新到這裡,然後同步到其他平臺。有喜歡的朋友可以沒事去逛逛,再次感謝大家的支援!

一、什麼是爬蟲

網路爬蟲(又被稱為網頁蜘蛛,網路機器人,在FOAF社群中間,更經常的稱為網頁追逐者),是一種按照一定的規則,自動地抓取全球資訊網資訊的程式或者指令碼。另外一些不常使用的名字還有螞蟻、自動索引、模擬程式或者蠕蟲。
WIKIPEDIA 爬蟲介紹

二、爬蟲的分類

  • 通用網路爬蟲(全網爬蟲)
爬行物件從一些 種子URL 擴充到整個 Web,主要為門戶站點搜尋引擎和大型 Web 服務提供商採集資料。

分分鐘教你用node.js寫個爬蟲

  • 聚焦網路爬蟲(主題網路爬蟲)
指選擇性 地爬行那些與預先定義好的主題相關頁面的網路爬蟲。
  • 增量式網路爬蟲
指對已下載網頁採取增量式更新和 只爬行新產生的或者已經發生變化網頁 的爬蟲,它能夠在一定程度上保證所爬行的頁面是儘可能新的頁面。
  • Deep Web 爬蟲
爬行物件是一些在使用者填入關鍵字搜尋或登入後才能訪問到的深層網頁資訊的爬蟲。

三、爬蟲的爬行策略

  • 通用網路爬蟲(全網爬蟲)
深度優先策略、廣度優先策略

分分鐘教你用node.js寫個爬蟲

  • 聚焦網路爬蟲(主題網路爬蟲)
基於內容評價的爬行策略(內容相關性),基於連結結構評價的爬行策略、基於增強學習的爬行策略(連結重要性),基於語境圖的爬行策略(距離,圖論中兩節點間邊的權重)
  • 增量式網路爬蟲
統一更新法、個體更新法、基於分類的更新法、自適應調頻更新法
  • Deep Web 爬蟲
Deep Web 爬蟲爬行過程中最重要部分就是表單填寫,包含兩種型別:基於領域知識的表單填寫、基於網頁結構分析的表單填寫

現代的網頁爬蟲的行為通常是四種策略組合的結果:

選擇策略:決定所要下載的頁面;
重新訪問策略:決定什麼時候檢查頁面的更新變化;
平衡禮貌策略:指出怎樣避免站點超載;
並行策略:指出怎麼協同達到分散式抓取的效果;

分分鐘教你用node.js寫個爬蟲

四、寫一個簡單網頁爬蟲的流程

  1. 確定爬取物件(網站/頁面)
  2. 分析頁面內容(目標資料/DOM結構)
  3. 確定開發語言、框架、工具等
  4. 編碼 測試,爬取資料
  5. 優化

一個簡單的百度新聞爬蟲

確定爬取物件(網站/頁面)

百度新聞news.baidu.com/

分析頁面內容(目標資料/DOM結構)

······

確定開發語言、框架、工具等

node.js (express) + SublimeText 3

編碼,測試,爬取資料

coding ···

Let's start

新建專案目錄

1.在合適的磁碟目錄下建立專案目錄baiduNews(我的專案目錄是:F:\web\baiduNews

注:因為在寫這篇文章的時候用的電腦真心比較渣。安裝WebStorm或者VsCode跑專案有些吃力。所以後面的命令列操作我都是在Window自帶的DOS命令列視窗中執行的。

初始化package.json

1.在DOS命令列中進入專案根目錄 baiduNews
2.執行npm init,初始化package.json檔案

安裝依賴

express (使用express來搭建一個簡單的Http伺服器。當然,你也可以使用node中自帶的http模組)
superagent (superagent是node裡一個非常方便的、輕量的、漸進式的第三方客戶端請求代理模組,用他來請求目標頁面)
cheerio (cheerio相當於node版的jQuery,用過jQuery的同學會非常容易上手。它主要是用來獲取抓取到的頁面元素和其中的資料資訊)
// 個人比較喜歡使用yarn來安裝依賴包,當然你也可以使用 npm install 來安裝依賴,看個人習慣。
yarn add express
yarn add superagent
yarn add cheerio複製程式碼

依賴安裝完成後你可以在package.json中檢視剛才安裝的依賴是否成功。
安裝正確後如下圖:

分分鐘教你用node.js寫個爬蟲

開始coding

一、使用express啟動一個簡單的本地Http伺服器

1、在專案根目錄下建立index.js檔案(後面都會在這個index檔案中進行coding)

2、建立好index.js後,我們首先例項化一個express物件,用它來啟動一個本地監聽3000埠的Http服務。

const express = require('express');
const app = express();

// ...

let server = app.listen(3000, function () {
  let host = server.address().address;
  let port = server.address().port;
  console.log('Your App is running at http://%s:%s', host, port);
});複製程式碼

對,就是這麼簡單,不到10行程式碼,搭建啟動一個簡單的本地Http服務。

3、按照國際慣例,我們希望在訪問本機地址http://localhost:3000的時候,這個服務能給我們犯規一個Hello World!index.js中加入如下程式碼:

app.get('/', function (req, res) {
  res.send('Hello World!');
});複製程式碼
此時,在DOS中專案根目錄baiduNews下執行node index.js,讓專案跑起來。之後,開啟瀏覽器,訪問http://localhost:3000,你就會發現頁面上顯示'Hellow World!'字樣。
這樣,在後面我們獲取到百度新聞首頁的資訊後,就可以在訪問http://localhost:3000時看到這些資訊。

二、抓取百度新聞首頁的新聞資訊

1、 首先,我們先來分析一下百度新聞首頁的頁面資訊。

分分鐘教你用node.js寫個爬蟲

分分鐘教你用node.js寫個爬蟲

百度新聞首頁大體上分為“熱點新聞”、“本地新聞”、“國內新聞”、“國際新聞”......等。這次我們先來嘗試抓取左側“熱點新聞”和下方的“本地新聞”兩處的新聞資料。

分分鐘教你用node.js寫個爬蟲

F12開啟Chrome的控制檯,審查頁面元素,經過檢視左側“熱點新聞”資訊所在DOM的結構,我們發現所有的“熱點新聞”資訊(包括新聞標題和新聞頁面連結)都在id#pane-news<div>下面<ul><li>下的<a>標籤中。用jQuery的選擇器表示為:#pane-news ul li a

2、為了爬取新聞資料,首先我們要用superagent請求目標頁面,獲取整個新聞首頁資訊

// 引入所需要的第三方包
const superagent= require('superagent');

let hotNews = [];                                // 熱點新聞
let localNews = [];                              // 本地新聞

/**
 * index.js
 * [description] - 使用superagent.get()方法來訪問百度新聞首頁
 */
superagent.get('http://news.baidu.com/').end((err, res) => {
  if (err) {
    // 如果訪問失敗或者出錯,會這行這裡
    console.log(`熱點新聞抓取失敗 - ${err}`)
  } else {
   // 訪問成功,請求http://news.baidu.com/頁面所返回的資料會包含在res
   // 抓取熱點新聞資料
   hotNews = getHotNews(res)
  }
});複製程式碼

3、獲取頁面資訊後,我們來定義一個函式getHotNews()來抓取頁面內的“熱點新聞”資料。

/**
 * index.js
 * [description] - 抓取熱點新聞頁面
 */
// 引入所需要的第三方包
const cheerio = require('cheerio');

let getHotNews = (res) => {
  let hotNews = [];
  // 訪問成功,請求http://news.baidu.com/頁面所返回的資料會包含在res.text中。
  
  /* 使用cheerio模組的cherrio.load()方法,將HTMLdocument作為引數傳入函式
     以後就可以使用類似jQuery的$(selectior)的方式來獲取頁面元素
   */
  let $ = cheerio.load(res.text);

  // 找到目標資料所在的頁面元素,獲取資料
  $('div#pane-news ul li a').each((idx, ele) => {
    // cherrio中$('selector').each()用來遍歷所有匹配到的DOM元素
    // 引數idx是當前遍歷的元素的索引,ele就是當前便利的DOM元素
    let news = {
      title: $(ele).text(),        // 獲取新聞標題
      href: $(ele).attr('href')    // 獲取新聞網頁連結
    };
    hotNews.push(news)              // 存入最終結果陣列
  });
  return hotNews
};複製程式碼

這裡要多說幾點:

  1. async/await據說是非同步程式設計的終級解決方案,它可以讓我們以同步的思維方式來進行非同步程式設計。Promise解決了非同步程式設計的“回撥地獄”,async/await同時使非同步流程控制變得友好而有清晰,有興趣的同學可以去了解學習一下,真的很好用。
  2. superagent模組提供了很多比如getpostdelte等方法,可以很方便地進行Ajax請求操作。在請求結束後執行.end()回撥函式。.end()接受一個函式作為引數,該函式又有兩個引數error和res。當請求失敗,error會包含返回的錯誤資訊,請求成功,error值為null,返回的資料會包含在res引數中。
  3. cheerio模組的.load()方法,將HTML document作為引數傳入函式,以後就可以使用類似jQuery的$(selectior)的方式來獲取頁面元素。同時可以使用類似於jQuery中的.each()來遍歷元素。此外,還有很多方法,大家可以自行Google/Baidu。

4、將抓取的資料返回給前端瀏覽器

前面,const app = express();例項化了一個express物件app
app.get('', async() => {})接受兩個引數,第一個引數接受一個String型別的路由路徑,表示Ajax的請求路徑。第二個引數接受一個函式Function,當請求此路徑時就會執行這個函式中的程式碼。
/**
 * [description] - 跟路由
 */
// 當一個get請求 http://localhost:3000時,就會後面的async函式
app.get('/', async (req, res, next) => {
  res.send(hotNews);
});複製程式碼
在DOS中專案根目錄baiduNews下執行node index.js,讓專案跑起來。之後,開啟瀏覽器,訪問http://localhost:3000,你就會發現抓取到的資料返回到了前端頁面。我執行程式碼後瀏覽器展示的返回資訊如下:
注:因為我的Chrome安裝了JSONView擴充套件程式,所以返回的資料在頁面展示的時候會被自動格式化為結構性的JSON格式,方便檢視。

分分鐘教你用node.js寫個爬蟲

OK!!這樣,一個簡單的百度“熱點新聞”的爬蟲就大功告成啦!!

簡單總結一下,其實步驟很簡單:

  1. express啟動一個簡單的Http服務
  2. 分析目標頁面DOM結構,找到所要抓取的資訊的相關DOM元素
  3. 使用superagent請求目標頁面
  4. 使用cheerio獲取頁面元素,獲取目標資料
  5. 返回資料到前端瀏覽器

現在,繼續我們的目標,抓取“本地新聞”資料(編碼過程中,我們會遇到一些有意思的問題)
有了前面的基礎,我們自然而然的會想到利用和上面相同的方法“本地新聞”資料。
1、 分析頁面中“本地新聞”部分的DOM結構,如下圖:

分分鐘教你用node.js寫個爬蟲

F12開啟控制檯,審查“本地新聞”DOM元素,我們發現,“本地新聞”分為兩個主要部分,“左側新聞”和右側的“新聞資訊”。這所有目標資料都在id#local_newsdiv中。“左側新聞”資料又在id#localnews-focusul標籤下的li標籤下的a標籤中,包括新聞標題和頁面連結。“本地資訊”資料又在id#localnews-zixundiv下的ul標籤下的li標籤下的a標籤中,包括新聞標題和頁面連結。

2、OK!分析了DOM結構,確定了資料的位置,接下來和爬取“熱點新聞”一樣,按部就班,定義一個getLocalNews()函式,爬取這些資料。

/**
 * [description] - 抓取本地新聞頁面
 */
let getLocalNews = (res) => {
  let localNews = [];
  let $ = cheerio.load(res);
    
  // 本地新聞
  $('ul#localnews-focus li a').each((idx, ele) => {
    let news = {
      title: $(ele).text(),
      href: $(ele).attr('href'),
    };
    localNews.push(news)
  });
    
  // 本地資訊
  $('div#localnews-zixun ul li a').each((index, item) => {
    let news = {
      title: $(item).text(),
      href: $(item).attr('href')
    };
    localNews.push(news);
  });

  return localNews
};複製程式碼

對應的,在superagent.get()中請求頁面後,我們需要呼叫getLocalNews()函式,來爬去本地新聞資料。
superagent.get()函式修改為:

superagent.get('http://news.baidu.com/').end((err, res) => {
  if (err) {
    // 如果訪問失敗或者出錯,會這行這裡
    console.log(`熱點新聞抓取失敗 - ${err}`)
  } else {
   // 訪問成功,請求http://news.baidu.com/頁面所返回的資料會包含在res
   // 抓取熱點新聞資料
   hotNews = getHotNews(res)
   localNews = getLocalNews(res)
  }
});複製程式碼

同時,我們要在app.get()路由中也要將資料返回給前端瀏覽器。app.get()路由程式碼修改為:

/**
 * [description] - 跟路由
 */
// 當一個get請求 http://localhost:3000時,就會後面的async函式
app.get('/', async (req, res, next) => {
  res.send({
    hotNews: hotNews,
    localNews: localNews
  });
});複製程式碼
編碼完成,激動不已!!DOS中讓專案跑起來,用瀏覽器訪問http://localhost:3000

尷尬的事情發生了!!返回的資料只有熱點新聞,而本地新聞返回一個空陣列[ ]。檢查程式碼,發現也沒有問題,但為什麼一直返回的空陣列呢?
經過一番原因查詢,才返現問題出在哪裡!!

一個有意思的問題

為了找到原因,首先,我們看看用superagent.get('http://news.baidu.com/').end((err, res) => {})請求百度新聞首頁在回撥函式.end()中的第二個引數res中到底拿到了什麼內容?
// 新定義一個全域性變數 pageRes
let pageRes = {};        // supergaent頁面返回值

// superagent.get()中將res存入pageRes
superagent.get('http://news.baidu.com/').end((err, res) => {
  if (err) {
    // 如果訪問失敗或者出錯,會這行這裡
    console.log(`熱點新聞抓取失敗 - ${err}`)
  } else {
   // 訪問成功,請求http://news.baidu.com/頁面所返回的資料會包含在res
   // 抓取熱點新聞資料
   // hotNews = getHotNews(res)
   // localNews = getLocalNews(res)
   pageRes = res
  }
});

// 將pageRes返回給前端瀏覽器,便於檢視
app.get('/', async (req, res, next) => {
  res.send({
    // {}hotNews: hotNews,
    // localNews: localNews,
    pageRes: pageRes
  });
});複製程式碼
訪問瀏覽器http://localhost:3000,頁面展示如下內容:

分分鐘教你用node.js寫個爬蟲

可以看到,返回值中的text欄位應該就是整個頁面的HTML程式碼的字串格式。為了方便我們觀察,可以直接把這個text欄位值返回給前端瀏覽器,這樣我們就能夠清晰地看到經過瀏覽器渲染後的頁面。

修改給前端瀏覽器的返回值

app.get('/', async (req, res, next) => {
  res.send(pageRes.text)
}複製程式碼

訪問瀏覽器http://localhost:3000,頁面展示如下內容:

分分鐘教你用node.js寫個爬蟲

審查元素才發現,原來我們抓取的目標資料所在的DOM元素中是空的,裡面沒有資料!
到這裡,一切水落石出!在我們使用superagent.get()訪問百度新聞首頁時,res中包含的獲取的頁面內容中,我們想要的“本地新聞”資料還沒有生成,DOM節點元素是空的,所以出現前面的情況!抓取後返回的資料一直是空陣列[ ]

分分鐘教你用node.js寫個爬蟲

在控制檯的Network中我們發現頁面請求了一次這樣的介面:
http://localhost:3000/widget?id=LocalNews&ajax=json&t=1526295667917,介面狀態 404
這應該就是百度新聞獲取“本地新聞”的介面,到這裡一切都明白了!“本地新聞”是在頁面載入後動態請求上面這個介面獲取的,所以我們用superagent.get()請求的頁面再去請求這個介面時,介面URLhostname部分變成了本地IP地址,而本機上沒有這個介面,所以404,請求不到資料。

找到原因,我們來想辦法解決這個問題!!

  1. 直接使用superagent訪問正確合法的百度“本地新聞”的介面,獲取資料後返回給前端瀏覽器。
  2. 使用第三方npm包,模擬瀏覽器訪問百度新聞首頁,在這個模擬瀏覽器中當“本地新聞”載入成功後,抓取資料,返回給前端瀏覽器。

以上方法均可,我們來試試比較有意思的第二種方法

使用Nightmare自動化測試工具

Electron可以讓你使用純JavaScript呼叫Chrome豐富的原生的介面來創造桌面應用。你可以把它看作一個專注於桌面應用的Node.js的變體,而不是Web伺服器。其基於瀏覽器的應用方式可以極方便的做各種響應式的互動

Nightmare是一個基於Electron的框架,針對Web自動化測試和爬蟲,因為其具有跟PlantomJS一樣的自動化測試的功能可以在頁面上模擬使用者的行為觸發一些非同步資料載入,也可以跟Request庫一樣直接訪問URL來抓取資料,並且可以設定頁面的延遲時間,所以無論是手動觸發指令碼還是行為觸發指令碼都是輕而易舉的。

安裝依賴

// 安裝nightmare
yarn add nightmare複製程式碼

為獲取“本地新聞”,繼續coding...

index.js中新增如下程式碼:

const Nightmare = require('nightmare');          // 自動化測試包,處理動態頁面
const nightmare = Nightmare({ show: true });     // show:true  顯示內建模擬瀏覽器

/**
 * [description] - 抓取本地新聞頁面
 * [nremark] - 百度本地新聞在訪問頁面後載入js定位IP位置後獲取對應新聞,
 * 所以抓取本地新聞需要使用 nightmare 一類的自動化測試工具,
 * 模擬瀏覽器環境訪問頁面,使js執行,生成動態頁面再抓取
 */
// 抓取本地新聞頁面
nightmare
.goto('http://news.baidu.com/')
.wait("div#local_news")
.evaluate(() => document.querySelector("div#local_news").innerHTML)
.then(htmlStr => {
  // 獲取本地新聞資料
  localNews = getLocalNews(htmlStr)
})
.catch(error => {
  console.log(`本地新聞抓取失敗 - ${error}`);
})複製程式碼

修改getLocalNews()函式為:

/**
 * [description]- 獲取本地新聞資料
 */
let getLocalNews = (htmlStr) => {
  let localNews = [];
  let $ = cheerio.load(htmlStr);

  // 本地新聞
  $('ul#localnews-focus li a').each((idx, ele) => {
    let news = {
      title: $(ele).text(),
      href: $(ele).attr('href'),
    };
    localNews.push(news)
  });

  // 本地資訊
  $('div#localnews-zixun ul li a').each((index, item) => {
    let news = {
      title: $(item).text(),
      href: $(item).attr('href')
    };
    localNews.push(news);
  });

  return localNews
}複製程式碼

修改app.get('/')路由為:

/**
 * [description] - 跟路由
 */
// 當一個get請求 http://localhost:3000時,就會後面的async函式
app.get('/', async (req, res, next) => {
  res.send({
    hotNews: hotNews,
    localNews: localNews
  })
});複製程式碼
此時,DOS命令列中重新讓專案跑起來,瀏覽器訪問https://localhost:3000,看看頁面展示的資訊,看是否抓取到了“本地新聞”資料!

至此,一個簡單而又完整的抓取百度新聞頁面“熱點新聞”和“本地新聞”的爬蟲就大功告成啦!!

最後總結一下,整體思路如下:

  1. express啟動一個簡單的Http服務
  2. 分析目標頁面DOM結構,找到所要抓取的資訊的相關DOM元
  3. 使用superagent請求目標頁面
  4. 動態頁面(需要載入頁面後執行JS或請求介面的頁面)可以使用Nightmare模擬瀏覽器訪問
  5. 使用cheerio獲取頁面元素,獲取目標資料

完整程式碼

爬蟲完整程式碼GitHub地址:完整程式碼

後面,應該還會做一些進階,來爬取某些網站上比較好看的圖片(手動滑稽),會牽扯到併發控制反-反爬蟲的一些策略。再用爬蟲取爬去一些需要登入和輸入驗證碼的網站,歡迎到時大家關注和指正交流。

我想說

再次感謝大家的點贊和關注和評論,謝謝大家的支援,謝謝!我自己覺得我算是一個愛文字,愛音樂,同時也喜歡coding的半文藝程式設計師。之前也一直想著寫一寫技術性和其他偏文學性的文章。雖然自己的底子沒有多麼優秀,但總是覺得在寫文章的過程中,不論是技術性的還是偏文學性的,這個過程中可以督促自己去思考,督促自己去學習和交流。畢竟每天忙忙碌碌之餘,還是要活出自己不一樣的生活。所以,以後如果有一些好的文章我會積極和大家分享!再次感謝大家的支援!


相關文章