用 Node-js 開發頁面爬蟲

本文講解怎樣用 Node.js 高效地從 Web 爬取數據。

前提條件

本文主要針對具有一定 JavaScript 經驗的程序員。如果你對 Web 抓取有深刻的瞭解,但對 JavaScript 並不熟悉,那麼本文仍然能夠對你有所幫助。

你將學到

通過本文你將學到:

瞭解 Node.js

Javascript 是一種簡單的現代編程語言,最初是爲了向瀏覽器中的網頁添加動態效果。當加載網站後,Javascript 代碼由瀏覽器的 Javascript 引擎運行。爲了使 Javascript 與你的瀏覽器進行交互,瀏覽器還提供了運行時環境(document、window 等)。

這意味着 Javascript 不能直接與計算機資源交互或對其進行操作。例如在 Web 服務器中,服務器必須能夠與文件系統進行交互,這樣才能讀寫文件。

Node.js 使 Javascript 不僅能夠運行在客戶端,而且還可以運行在服務器端。爲了做到這一點,其創始人 Ryan Dahl 選擇了 Google Chrome 瀏覽器的 v8 Javascript Engine,並將其嵌入到用 C++ 開發的 Node 程序中。所以 Node.js 是一個運行時環境,它允許 Javascript 代碼也能在服務器上運行。

與其他語言(例如 C 或 C++)通過多個線程來處理併發性相反,Node.js 利用單個主線程並並在事件循環的幫助下以非阻塞方式執行任務。

要創建一個簡單的 Web 服務器非常簡單,如下所示:

const http = require('http');
const PORT = 3000;

const server = http.createServer((req, res) ={
  res.statusCode = 200;
  res.setHeader('Content-Type''text/plain');
  res.end('Hello World');
});

server.listen(port, () ={
  console.log(`Server running at PORT:${port}/`);
});

如果你已安裝了 Node.js,可以試着運行上面的代碼。Node.js 非常適合 I/O 密集型程序。

HTTP 客戶端:訪問 Web

HTTP 客戶端是能夠將請求發送到服務器,然後接收服務器響應的工具。下面提到的所有工具底的層都是用 HTTP 客戶端來訪問你要抓取的網站。

Request

Request 是 Javascript 生態中使用最廣泛的 HTTP 客戶端之一,但是 Request 庫的作者已正式聲明棄用了。不過這並不意味着它不可用了,相當多的庫仍在使用它,並且非常好用。用 Request 發出 HTTP 請求是非常簡單的:

const request = require('request')
request('https://www.reddit.com/r/programming.json'function (
  error,
  response,
  body
) {
  console.error('error:', error)
  console.log('body:', body)
})

你可以在 Github 上找到 Request 庫,安裝它非常簡單。你還可以在 https://github.com/request/request/issues/3142 找到棄用通知及其含義。

Axios

Axios 是基於 promise 的 HTTP 客戶端,可在瀏覽器和 Node.js 中運行。如果你用 Typescript,那麼 axios 會爲你覆蓋內置類型。通過 Axios 發起 HTTP 請求非常簡單,默認情況下它帶有 Promise 支持,而不是在 Request 中去使用回調:

const axios = require('axios')

axios
 .get('https://www.reddit.com/r/programming.json')
 .then((response) ={
  console.log(response)
 })
 .catch((error) ={
  console.error(error)
 });

如果你喜歡 Promises API 的 async/await 語法糖,那麼你也可以用,但是由於頂級 await 仍處於 stage 3 ,所以我們只好先用異步函數來代替:

async function getForum() {
 try {
  const response = await axios.get(
   'https://www.reddit.com/r/programming.json'
  )
  console.log(response)
 } catch (error) {
  console.error(error)
 }
}

你所要做的就是調用 getForum!可以在 https://github.com/axios/axios 上找到 Axios 庫。

Superagent

與 Axios 一樣,Superagent 是另一個強大的 HTTP 客戶端,它支持 Promise 和 async/await 語法糖。它具有像 Axios 這樣相當簡單的 API,但是 Superagent 由於存在更多的依賴關係並且不那麼流行。

用 promise、async/await 或回調向 Superagent 發出 HTTP 請求看起來像這樣:

const superagent = require("superagent")
const forumURL = "https://www.reddit.com/r/programming.json"

// callbacks
superagent
 .get(forumURL)
 .end((error, response) ={
  console.log(response)
 })

// promises
superagent
 .get(forumURL)
 .then((response) ={
  console.log(response)
 })
 .catch((error) ={
  console.error(error)
 })

// promises with async/await
async function getForum() {
 try {
  const response = await superagent.get(forumURL)
  console.log(response)
 } catch (error) {
  console.error(error)
 }
}

可以在 https://github.com/visionmedia/superagent 找到 Superagent。

正則表達式:艱難的路

在沒有任何依賴性的情況下,最簡單的進行網絡抓取的方法是,使用 HTTP 客戶端查詢網頁時,在收到的 HTML 字符串上使用一堆正則表達式。正則表達式不那麼靈活,而且很多專業人士和業餘愛好者都難以編寫正確的正則表達式。

讓我們試一試,假設其中有一個帶有用戶名的標籤,我們需要該用戶名,這類似於你依賴正則表達式時必須執行的操作

const htmlString = '<label>Username: John Doe</label>'
const result = htmlString.match(/<label>(.+)<\/label>/)

console.log(result[1], result[1].split(": ")[1])
// Username: John Doe, John Doe

在 Javascript 中,match()  通常返回一個數組,該數組包含與正則表達式匹配的所有內容。第二個元素(在索引 1 中)將找到我們想要的 <label> 標記的 textContentinnerHTML。但是結果中包含一些不需要的文本( “Username: “),必須將其刪除。

如你所見,對於一個非常簡單的用例,步驟和要做的工作都很多。這就是爲什麼應該依賴 HTML 解析器的原因,我們將在後面討論。

Cheerio:用於遍歷 DOM 的核心 JQuery

Cheerio 是一個高效輕便的庫,它使你可以在服務器端使用 JQuery 的豐富而強大的 API。如果你以前用過 JQuery,那麼將會對 Cheerio 感到很熟悉,它消除了 DOM 所有不一致和與瀏覽器相關的功能,並公開了一種有效的 API 來解析和操作 DOM。

const cheerio = require('cheerio')
const $ = cheerio.load('<h2 class="title">Hello world</h2>')

$('h2.title').text('Hello there!')
$('h2').addClass('welcome')

$.html()
// <h2 class="title welcome">Hello there!</h2>

如你所見,Cheerio 與 JQuery 用起來非常相似。

但是,儘管它的工作方式不同於網絡瀏覽器,也就這意味着它不能:

因此,如果你嘗試爬取的網站或 Web 應用是嚴重依賴 Javascript 的(例如 “單頁應用”),那麼 Cheerio 並不是最佳選擇,你可能不得不依賴稍後討論的其他選項。

爲了展示 Cheerio 的強大功能,我們將嘗試在 Reddit 中抓取 r/programming 論壇,嘗試獲取帖子名稱列表。

首先,通過運行以下命令來安裝 Cheerio 和 axios:npm install cheerio axios

然後創建一個名爲 crawler.js 的新文件,並複製粘貼以下代碼:

const axios = require('axios');
const cheerio = require('cheerio');

const getPostTitles = async () ={
 try {
  const { data } = await axios.get(
   'https://old.reddit.com/r/programming/'
  );
  const $ = cheerio.load(data);
  const postTitles = [];

  $('div > p.title > a').each((_idx, el) ={
   const postTitle = $(el).text()
   postTitles.push(postTitle)
  });

  return postTitles;
 } catch (error) {
  throw error;
 }
};

getPostTitles()
.then((postTitles) => console.log(postTitles));

getPostTitles() 是一個異步函數,將對舊的 reddit 的 r/programming 論壇進行爬取。首先,用帶有 axios HTTP 客戶端庫的簡單 HTTP GET 請求獲取網站的 HTML,然後用 cheerio.load() 函數將 html 數據輸入到 Cheerio 中。

然後在瀏覽器的 Dev Tools 幫助下,可以獲得可以定位所有列表項的選擇器。如果你使用過 JQuery,則必須非常熟悉 $('div> p.title> a')。這將得到所有帖子,因爲你只希望單獨獲取每個帖子的標題,所以必須遍歷每個帖子,這些操作是在 each() 函數的幫助下完成的。

要從每個標題中提取文本,必須在 Cheerio 的幫助下獲取 DOM 元素( el 指代當前元素)。然後在每個元素上調用 text() 能夠爲你提供文本。

現在,打開終端並運行 node crawler.js,然後你將看到大約存有標題的數組,它會很長。儘管這是一個非常簡單的用例,但它展示了 Cheerio 提供的 API 的簡單性質。

如果你的用例需要執行 Javascript 並加載外部源,那麼以下幾個選項將很有幫助。

JSDOM:Node 的 DOM

JSDOM 是在 Node.js 中使用的文檔對象模型的純 Javascript 實現,如前所述,DOM 對 Node 不可用,但是 JSDOM 是最接近的。它或多或少地模仿了瀏覽器。

由於創建了 DOM,所以可以通過編程與要爬取的 Web 應用或網站進行交互,也可以模擬單擊按鈕。如果你熟悉 DOM 操作,那麼使用 JSDOM 將會非常簡單。

const { JSDOM } = require('jsdom')
const { document } = new JSDOM(
 '<h2 class="title">Hello world</h2>'
).window
const heading = document.querySelector('.title')
heading.textContent = 'Hello there!'
heading.classList.add('welcome')

heading.innerHTML
// <h2 class="title welcome">Hello there!</h2>

代碼中用 JSDOM 創建一個 DOM,然後你可以用和操縱瀏覽器 DOM 相同的方法和屬性來操縱該 DOM。

爲了演示如何用 JSDOM 與網站進行交互,我們將獲得 Reddit r/programming 論壇的第一篇帖子並對其進行投票,然後驗證該帖子是否已被投票。

首先運行以下命令來安裝 jsdom 和 axios:npm install jsdom axios

然後創建名爲 crawler.js的文件,並複製粘貼以下代碼:

const { JSDOM } = require("jsdom")
const axios = require('axios')

const upvoteFirstPost = async () ={
  try {
    const { data } = await axios.get("https://old.reddit.com/r/programming/");
    const dom = new JSDOM(data, {
      runScripts: "dangerously",
      resources: "usable"
    });
    const { document } = dom.window;
    const firstPost = document.querySelector("div > div.midcol > div.arrow");
    firstPost.click();
    const isUpvoted = firstPost.classList.contains("upmod");
    const msg = isUpvoted
      ? "Post has been upvoted successfully!"
      : "The post has not been upvoted!";

    return msg;
  } catch (error) {
    throw error;
  }
};

upvoteFirstPost().then(msg => console.log(msg));

upvoteFirstPost() 是一個異步函數,它將在 r/programming 中獲取第一個帖子,然後對其進行投票。axios 發送 HTTP GET 請求獲取指定 URL 的 HTML。然後通過先前獲取的 HTML 來創建新的 DOM。JSDOM 構造函數把 HTML 作爲第一個參數,把 option 作爲第二個參數,已添加的 2 個 option 項執行以下功能:

創建 DOM 後,用相同的 DOM 方法得到第一篇文章的 upvote 按鈕,然後單擊。要驗證是否確實單擊了它,可以檢查 classList 中是否有一個名爲 upmod 的類。如果存在於 classList 中,則返回一條消息。

打開終端並運行 node crawler.js,然後會看到一個整潔的字符串,該字符串將表明帖子是否被贊過。儘管這個例子很簡單,但你可以在這個基礎上構建功能強大的東西,例如,一個圍繞特定用戶的帖子進行投票的機器人。

如果你不喜歡缺乏表達能力的 JSDOM ,並且實踐中要依賴於許多此類操作,或者需要重新創建許多不同的 DOM,那麼下面將是更好的選擇。

Puppeteer:無頭瀏覽器

顧名思義,Puppeteer 允許你以編程方式操縱瀏覽器,就像操縱木偶一樣。它通過爲開發人員提供高級 API 來默認控制無頭版本的 Chrome。

Puppeteer 比上述工具更有用,因爲它可以使你像真正的人在與瀏覽器進行交互一樣對網絡進行爬取。這就具備了一些以前沒有的可能性:

它還可以在 Web 爬取之外的其他任務中發揮重要作用,例如 UI 測試、輔助性能優化等。

通常你會想要截取網站的屏幕截圖,也許是爲了瞭解競爭對手的產品目錄,可以用 puppeteer 來做到。首先運行以下命令安裝 puppeteer,:npm install puppeteer

這將下載 Chromium 的 bundle 版本,根據操作系統的不同,該版本大約 180 MB 至 300 MB。如果你要禁用此功能。

讓我們嘗試在 Reddit 中獲取 r/programming 論壇的屏幕截圖和 PDF,創建一個名爲 crawler.js的新文件,然後複製粘貼以下代碼:

const puppeteer = require('puppeteer')

async function getVisual() {
 try {
  const URL = 'https://www.reddit.com/r/programming/'
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  await page.goto(URL)
  await page.screenshot({ path: 'screenshot.png' })
  await page.pdf({ path: 'page.pdf' })

  await browser.close()
 } catch (error) {
  console.error(error)
 }
}

getVisual()

getVisual() 是一個異步函數,它將獲 URL 變量中 url 對應的屏幕截圖和 pdf。首先,通過 puppeteer.launch() 創建瀏覽器實例,然後創建一個新頁面。可以將該頁面視爲常規瀏覽器中的選項卡。然後通過以 URL 爲參數調用  page.goto() ,將先前創建的頁面定向到指定的 URL。最終,瀏覽器實例與頁面一起被銷燬。

完成操作並完成頁面加載後,將分別使用 page.screenshot() 和  page.pdf() 獲取屏幕截圖和 pdf。你也可以偵聽 javascript load 事件,然後執行這些操作,在生產環境級別下強烈建議這樣做。

在終端上運行 node crawler.js  ,幾秒鐘後,你會注意到已經創建了兩個文件,分別名爲  screenshot.jpgpage.pdf

Nightmare:Puppeteer 的替代者

Nightmare 是類似 Puppeteer 的高級瀏覽器自動化庫,該庫使用 Electron,但據說速度是其前身 PhantomJS 的兩倍。

如果你在某種程度上不喜歡 Puppeteer 或對 Chromium 捆綁包的大小感到沮喪,那麼 nightmare 是一個理想的選擇。首先,運行以下命令安裝 nightmare 庫:npm install nightmare

然後,一旦下載了 nightmare,我們將用它通過 Google 搜索引擎找到 ScrapingBee 的網站。創建一個名爲crawler.js的文件,然後將以下代碼複製粘貼到其中:

const Nightmare = require('nightmare')
const nightmare = Nightmare()

nightmare
 .goto('https://www.google.com/')
 .type("input[title='Search']"'ScrapingBee')
 .click("input[value='Google Search']")
 .wait('#rso > div:nth-child(1) > div > div > div.r > a')
 .evaluate(
  () =>
   document.querySelector(
    '#rso > div:nth-child(1) > div > div > div.r > a'
   ).href
 )
 .end()
 .then((link) ={
  console.log('Scraping Bee Web Link': link)
 })
 .catch((error) ={
  console.error('Search failed:', error)
 })

首先創建一個 Nighmare 實例,然後通過調用 goto() 將該實例定向到 Google 搜索引擎,加載後,使用其選擇器獲取搜索框,然後使用搜索框的值(輸入標籤)更改爲 “ScrapingBee”。完成後,通過單擊 “Google 搜索” 按鈕提交搜索表單。然後告訴 Nightmare 等到第一個鏈接加載完畢,一旦完成,它將使用 DOM 方法來獲取包含該鏈接的定位標記的 href 屬性的值。

最後,完成所有操作後,鏈接將打印到控制檯。

總結

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8bCXOlU3_-l-aT9YVnkNWg