使用無頭瀏覽器進行網頁抓取:Puppeteer 教程

已發表: 2022-03-11

在本文中,我們將看到使用無頭瀏覽器這種有點非傳統的方法來執行網絡抓取(網絡自動化)是多麼容易。

什麼是無頭瀏覽器以及為什麼需要它?

在過去的幾年裡,Web 已經從使用純 HTML 和 CSS 構建的簡單網站演變而來。 現在有更多具有漂亮 UI 的交互式 Web 應用程序,這些應用程序通常使用 Angular 或 React 等框架構建。 換句話說,如今 JavaScript 統治著網絡,包括您在網站上與之交互的幾乎所有內容。

就我們而言,JavaScript 是一種客戶端語言。 服務器返回注入到 HTML 響應中的 JavaScript 文件或腳本,然後瀏覽器對其進行處理。 現在,如果我們正在做某種網絡抓取或網絡自動化,這是一個問題,因為很多時候,我們希望看到或抓取的內容實際上是由 JavaScript 代碼呈現的,並且無法從原始 HTML 響應中訪問服務器提供的。

正如我們上面提到的,瀏覽器確實知道如何處理 JavaScript 並呈現漂亮的網頁。 現在,如果我們可以利用這個功能來滿足我們的抓取需求並有辦法以編程方式控制瀏覽器呢? 這正是無頭瀏覽器自動化介入的地方!

無頭? 打擾一下? 是的,這只是意味著沒有圖形用戶界面 (GUI)。 您無需像通常那樣與視覺元素交互——例如使用鼠標或觸摸設備——而是使用命令行界面 (CLI) 自動化用例。

無頭 Chrome 和 Puppeteer

有許多網頁抓取工具可用於無頭瀏覽,例如 Zombie.js 或使用 Selenium 的無頭 Firefox。 但是今天我們將通過 Puppeteer 探索無頭 Chrome,因為它是一個相對較新的播放器,於 2018 年初發布。編者註:值得一提的是 Intoli 的遠程瀏覽器,另一個新播放器,但這必須是另一個主題文章。

Puppeteer到底是什麼? 它是一個 Node.js 庫,提供高級 API 來控制無頭 Chrome 或 Chromium 或與 DevTools 協議交互。 它由 Chrome DevTools 團隊和一個很棒的開源社區維護。

說得夠多了——讓我們跳入代碼並探索如何使用 Puppeteer 的無頭瀏覽自動抓取網頁!

準備環境

首先,你需要在你的機器上安裝 Node.js 8+。 您可以在此處安裝它,或者如果您像我一樣是 CLI 愛好者並且喜歡在 Ubuntu 上工作,請遵循以下命令:

 curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash - sudo apt-get install -y nodejs

您還需要一些可能在您的系統上可用或不可用的軟件包。 為了安全起見,請嘗試安裝這些:

 sudo apt-get install -yq --no-install-recommends libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 libnss3

設置 Headless Chrome 和 Puppeteer

我建議使用npm安裝 Puppeteer,因為它還包括穩定的最新 Chromium 版本,可以保證與該庫一起使用。

在您的項目根目錄中運行此命令:

 npm i puppeteer --save

注意:這可能需要一段時間,因為 Puppeteer 需要在後台下載並安裝 Chromium。

好的,現在我們都已設置和配置,讓樂趣開始吧!

使用 Puppeteer API 進行自動 Web 抓取

讓我們從一個基本示例開始我們的 Puppeteer 教程。 我們將編寫一個腳本,使我們的無頭瀏覽器截取我們選擇的網站的屏幕截圖。

在您的項目目錄中創建一個名為screenshot.js的新文件,然後在您喜歡的代碼編輯器中打開它。

首先,讓我們在腳本中導入 Puppeteer 庫:

 const puppeteer = require('puppeteer');

接下來,讓我們從命令行參數中獲取 URL:

 const url = process.argv[2]; if (!url) { throw "Please provide a URL as the first argument"; }

現在,我們需要記住 Puppeteer 是一個基於 Promise 的庫:它在後台執行對無頭 Chrome 實例的異步調用。 讓我們使用 async/await 來保持代碼乾淨。 為此,我們需要先定義一個async函數並將所有 Puppeteer 代碼放入其中:

 async function run () { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url); await page.screenshot({path: 'screenshot.png'}); browser.close(); } run();

總而言之,最終代碼如下所示:

 const puppeteer = require('puppeteer'); const url = process.argv[2]; if (!url) { throw "Please provide URL as a first argument"; } async function run () { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url); await page.screenshot({path: 'screenshot.png'}); browser.close(); } run();

您可以通過在項目的根目錄中執行以下命令來運行它:

 node screenshot.js https://github.com

等一下,然後砰! 我們的無頭瀏覽器剛剛創建了一個名為screenshot.png的文件,您可以在其中看到 GitHub 主頁。 太好了,我們有一個正常工作的 Chrome 網絡抓取工具!

讓我們停下來看看上面的run()函數發生了什麼。

首先,我們啟動一個新的無頭瀏覽器實例,然後我們打開一個新頁面(選項卡)並導航到命令行參數中提供的 URL。 最後,我們使用 Puppeteer 的內置方法進行截圖,我們只需要提供應該保存的路徑即可。 我們還需要確保在完成自動化後關閉無頭瀏覽器。

現在我們已經介紹了基礎知識,讓我們繼續進行一些更複雜的事情。

第二個 Puppeteer 抓取示例

對於我們 Puppeteer 教程的下一部分,假設我們想從 Hacker News 中抓取最新的文章。

創建一個名為ycombinator-scraper.js的新文件並粘貼以下代碼片段:

 const puppeteer = require('puppeteer'); function run () { return new Promise(async (resolve, reject) => { try { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto("https://news.ycombinator.com/"); let urls = await page.evaluate(() => { let results = []; let items = document.querySelectorAll('a.storylink'); items.forEach((item) => { results.push({ url: item.getAttribute('href'), text: item.innerText, }); }); return results; }) browser.close(); return resolve(urls); } catch (e) { return reject(e); } }) } run().then(console.log).catch(console.error);

好的,與前面的示例相比,這裡還有更多內容。

您可能會注意到的第一件事是run()函數現在返回一個 Promise,因此async前綴已移至 Promise 函數的定義。

我們還將所有代碼包裝在一個 try-catch 塊中,以便我們可以處理任何導致我們的 Promise 被拒絕的錯誤。

最後,我們使用了 Puppeteer 的內置方法evaluate() 。 這種方法讓我們可以運行自定義 JavaScript 代碼,就好像我們在 DevTools 控制台中執行它一樣。 從該函數返回的任何內容都由 Promise 解決。 在抓取信息或執行自定義操作時,此方法非常方便。

傳遞給evaluate()方法的代碼是非常基本的 JavaScript,它構建了一個對像數組,每個對像都有urltext字段,代表我們在 https://news.ycombinator.com/ 上看到的故事 URL。

腳本的輸出看起來像這樣(但最初有 30 個條目):

 [ { url: 'https://www.nature.com/articles/d41586-018-05469-3', text: 'Bias detectives: the researchers striving to make algorithms fair' }, { url: 'https://mino-games.workable.com/jobs/415887', text: 'Mino Games Is Hiring Programmers in Montreal' }, { url: 'http://srobb.net/pf.html', text: 'A Beginner\'s Guide to Firewalling with pf' }, // ... { url: 'https://tools.ietf.org/html/rfc8439', text: 'ChaCha20 and Poly1305 for IETF Protocols' } ]

相當整潔,我會說!

好吧,讓我們繼續前進。 我們只退回了 30 件商品,而還有更多可用的商品——它們只是在其他頁面上。 我們需要點擊“更多”按鈕來加載下一頁結果。

讓我們稍微修改一下腳本以添加對分頁的支持:

 const puppeteer = require('puppeteer'); function run (pagesToScrape) { return new Promise(async (resolve, reject) => { try { if (!pagesToScrape) { pagesToScrape = 1; } const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto("https://news.ycombinator.com/"); let currentPage = 1; let urls = []; while (currentPage <= pagesToScrape) { let newUrls = await page.evaluate(() => { let results = []; let items = document.querySelectorAll('a.storylink'); items.forEach((item) => { results.push({ url: item.getAttribute('href'), text: item.innerText, }); }); return results; }); urls = urls.concat(newUrls); if (currentPage < pagesToScrape) { await Promise.all([ await page.click('a.morelink'), await page.waitForSelector('a.storylink') ]) } currentPage++; } browser.close(); return resolve(urls); } catch (e) { return reject(e); } }) } run(5).then(console.log).catch(console.error);

讓我們回顧一下我們在這裡做了什麼:

  1. 我們在主run()函數中添加了一個名為pagesToScrape的參數。 我們將使用它來限制我們的腳本將抓取多少頁。
  2. 還有一個名為currentPage的新變量,它表示我們當前查看的結果頁數。 它最初設置為1 。 我們還將evaluate()函數包裝在一個while循環中,這樣只要currentPage小於或等於pagesToScrape ,它就會一直運行。
  3. 我們添加了用於移動到新頁面並在重新啟動while循環之前等待頁面加載的塊。

您會注意到我們使用page.click()方法讓無頭瀏覽器單擊“更多”按鈕。 我們還使用了waitForSelector()方法來確保我們的邏輯被暫停,直到頁面內容被加載。

這兩種方法都是開箱即用的高級 Puppeteer API 方法。

在使用 Puppeteer 進行抓取時,您可能會遇到的問題之一是等待頁面加載。 Hacker News 的結構相對簡單,等待頁面加載完成相當容易。 對於更複雜的用例,Puppeteer 提供了廣泛的內置功能,您可以在 GitHub 上的 API 文檔中進行探索。

這一切都很酷,但是我們的 Puppeteer 教程還沒有涉及優化。 讓我們看看如何讓 Puppeteer 跑得更快。

優化我們的 Puppeteer 腳本

一般的想法是不要讓無頭瀏覽器做任何額外的工作。 這可能包括加載圖像、應用 CSS 規則、觸發 XHR 請求等。

與其他工具一樣,Puppeteer 的優化取決於確切的用例,因此請記住,其中一些想法可能不適合您的項目。 例如,如果我們在第一個示例中避免加載圖像,我們的屏幕截圖可能看起來不像我們想要的那樣。

無論如何,這些優化可以通過在第一個請求上緩存資產來完成,或者在網站發起時直接取消 HTTP 請求。

讓我們先看看緩存是如何工作的。

您應該知道,當您啟動一個新的無頭瀏覽器實例時,Puppeteer 會為其配置文件創建一個臨時目錄。 它在瀏覽器關閉時被刪除,並且在您啟動新實例時無法使用——因此所有存儲的圖像、CSS、cookie 和其他對象將無法再訪問。

我們可以強制 Puppeteer 使用自定義路徑來存儲諸如 cookie 和緩存之類的數據,每次我們再次運行它時都會重複使用它們——直到它們過期或被手動刪除。

 const browser = await puppeteer.launch({ userDataDir: './data', });

這應該會給我們帶來很好的性能提升,因為大量 CSS 和圖像將在第一次請求時緩存在數據目錄中,Chrome 不需要一次又一次地下載它們。

但是,在呈現頁面時仍將使用這些資產。 在我們對 Y Combinator 新聞文章的抓取需求中,我們真的不需要擔心任何視覺效果,包括圖像。 我們只關心純 HTML 輸出,所以讓我們嘗試阻止每個請求。

幸運的是,在這種情況下,使用 Puppeteer 非常酷,因為它支持自定義鉤子。 我們可以為每個請求提供一個攔截器,並取消我們真正不需要的那些。

攔截器可以通過以下方式定義:

 await page.setRequestInterception(true); page.on('request', (request) => { if (request.resourceType() === 'document') { request.continue(); } else { request.abort(); } });

如您所見,我們可以完全控制發起的請求。 我們可以編寫自定義邏輯來根據其resourceType允許或中止特定請求。 我們還可以訪問許多其他數據,例如request.url ,因此我們可以根據需要僅阻止特定的 URL。

在上面的示例中,我們只允許資源類型為"document"的請求通過我們的過濾器,這意味著我們將阻止所有圖像、CSS 以及除原始 HTML 響應之外的所有其他內容。

這是我們的最終代碼:

 const puppeteer = require('puppeteer'); function run (pagesToScrape) { return new Promise(async (resolve, reject) => { try { if (!pagesToScrape) { pagesToScrape = 1; } const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.setRequestInterception(true); page.on('request', (request) => { if (request.resourceType() === 'document') { request.continue(); } else { request.abort(); } }); await page.goto("https://news.ycombinator.com/"); let currentPage = 1; let urls = []; while (currentPage <= pagesToScrape) { await page.waitForSelector('a.storylink'); let newUrls = await page.evaluate(() => { let results = []; let items = document.querySelectorAll('a.storylink'); items.forEach((item) => { results.push({ url: item.getAttribute('href'), text: item.innerText, }); }); return results; }); urls = urls.concat(newUrls); if (currentPage < pagesToScrape) { await Promise.all([ await page.waitForSelector('a.morelink'), await page.click('a.morelink'), await page.waitForSelector('a.storylink') ]) } currentPage++; } browser.close(); return resolve(urls); } catch (e) { return reject(e); } }) } run(5).then(console.log).catch(console.error);

使用速率限制保持安全

無頭瀏覽器是非常強大的工具。 他們能夠執行幾乎任何類型的網絡自動化任務,而 Puppeteer 使這變得更加容易。 儘管有各種可能性,但我們必須遵守網站的服務條款,以確保我們不會濫用該系統。

由於這方面與架構更相關,因此我不會在本 Puppeteer 教程中深入介紹這一點。 也就是說,減慢 Puppeteer 腳本的最基本方法是向其添加 sleep 命令:

js await page.waitFor(5000);

此語句將強制您的腳本休眠五秒鐘(5000 毫秒)。 你可以把它放在browser.close()之前的任何地方。

就像限制您使用第三方服務一樣,還有許多其他更強大的方法可以控制您對 Puppeteer 的使用。 一個例子是用有限數量的工人構建一個隊列系統。 每次你想使用 Puppeteer 時,你都會將一個新任務推入隊列,但只有有限數量的工作人員能夠處理其中的任務。 在處理第三方 API 速率限制時,這是一種相當普遍的做法,也可以應用於 Puppeteer 網絡數據抓取。

Puppeteer 在快速發展的網絡中的位置

在這個 Puppeteer 教程中,我展示了它作為網絡抓取工具的基本功能。 但是,它有更廣泛的用例,包括無頭瀏覽器測試、PDF 生成和性能監控等等。

Web 技術正在快速發展。 一些網站非常依賴 JavaScript 渲染,以至於幾乎不可能執行簡單的 HTTP 請求來抓取它們或執行某種自動化。 幸運的是,得益於 Puppeteer 之類的項目及其背後的優秀團隊,無頭瀏覽器變得越來越容易處理我們所有的自動化需求!