使用 Node.js 和 Puppeteer 對動態網站進行道德抓取的指南

已發表: 2022-03-10
快速總結↬對於很多網頁抓取任務,一個 HTTP 客戶端就足以提取頁面的數據。 然而,當涉及到動態網站時,無頭瀏覽器有時變得不可或缺。 在本教程中,我們將構建一個基於 Node.js 和 Puppeteer 可以抓取動態網站的網絡爬蟲。

讓我們從關於網絡抓取實際含義的一小部分開始。 我們所有人都在日常生活中使用網絡抓取。 它僅描述了從網站中提取信息的過程。 因此,如果您將您最喜歡的麵條菜譜從互聯網上複製並粘貼到您的個人筆記本上,您就是在執行網絡抓取

在軟件行業中使用這個術語時,我們通常指的是通過使用一個軟件來自動化這個手動任務。 以我們之前的“麵條”為例,這個過程通常包括兩個步驟:

  • 獲取頁面
    我們首先必須下載整個頁面。 這一步就像手動抓取時在網絡瀏覽器中打開頁面一樣。
  • 解析數據
    現在,我們必須在網站的 HTML 中提取配方,並將其轉換為機器可讀的格式,如 JSON 或 XML。

過去,我曾在多家公司擔任數據顧問。 我很驚訝地看到有多少數據提取、聚合和豐富任務仍然是手動完成的,儘管它們可以很容易地通過幾行代碼實現自動化。 這正是網絡抓取對我而言的全部意義:從網站中提取和規範有價值的信息,以推動另一個價值驅動的業務流程。

在此期間,我看到公司使用網絡抓取來處理各種用例。 投資公司主要專注於收集替代數據,如產品評論、價格信息或社交媒體帖子,以支持他們的金融投資。

這是一個例子。 一位客戶找我,從多個電子商務網站上抓取產品評論數據以獲取大量產品列表,包括評級、評論者的位置以及每條提交評論的評論文本。 結果數據使客戶能夠識別產品在不同市場的流行趨勢。 這是一個很好的例子,說明與大量信息相比,看似“無用”的單條信息如何變得有價值。

其他公司通過使用網絡抓取來產生潛在客戶來加速他們的銷售過程。 此過程通常涉及為給定的網站列表提取聯繫信息,例如電話號碼、電子郵件地址和聯繫人姓名。 自動執行此任務使銷售團隊有更多時間接近潛在客戶。 因此,銷售過程的效率提高了。

跳躍後更多! 繼續往下看↓

遵守規則

一般來說,網絡抓取公開可用數據是合法的,正如 Linkedin 與 HiQ 案件的管轄權所證實的那樣。 但是,我為自己設定了一套道德規則,我喜歡在開始一個新的網絡抓取項目時遵守這些規則。 這包括:

  • 檢查 robots.txt 文件。
    它通常包含有關頁面所有者可以被機器人和抓取工具訪問的網站的哪些部分的明確信息,並突出顯示不應訪問的部分。
  • 閱讀條款和條件。
    與 robots.txt 相比,這條信息的可用頻率並不低,但通常會說明他們如何處理數據抓取工具。
  • 以中等速度刮擦。
    抓取會在目標站點的基礎架構上創建服務器負載。 根據您抓取的內容以及您的抓取工具運行的並發級別,流量可能會導致目標站點的服務器基礎架構出現問題。 當然,服務器容量在這個等式中起著重要作用。 因此,我的抓取工具的速度始終是我旨在抓取的數據量和目標網站的受歡迎程度之間的平衡。 找到這種平衡可以通過回答一個問題來實現:“計劃的速度會顯著改變網站的自然流量嗎?”。 如果我不確定網站的自然流量,我會使用 ahrefs 之類的工具來大致了解一下。

選擇正確的技術

事實上,使用無頭瀏覽器進行抓取是您可以使用的性能最低的技術之一,因為它會嚴重影響您的基礎架構。 您機器處理器的一個內核大約可以處理一個 Chrome 實例。

讓我們做一個快速的示例計算,看看這對現實世界的網絡抓取項目意味著什麼。

設想

  • 你想抓取 20,000 個 URL。
  • 目標站點的平均響應時間為 6 秒。
  • 您的服務器有 2 個 CPU 內核。

該項目將需要16個小時才能完成。

因此,在對動態網站進行抓取可行性測試時,我總是盡量避免使用瀏覽器。

這是我經常檢查的一個小清單:

  • 我可以通過 URL 中的 GET 參數強制要求的頁面狀態嗎? 如果是,我們可以簡單地運行帶有附加參數的 HTTP 請求。
  • 頁面源的動態信息部分是否可以通過 DOM 中某處的 JavaScript 對象獲得? 如果是,我們可以再次使用正常的 HTTP 請求並從字符串化對像中解析數據。
  • 數據是通過 XHR 請求獲取的嗎? 如果是這樣,我可以使用 HTTP 客戶端直接訪問端點嗎? 如果是,我們可以直接向端點發送 HTTP 請求。 很多時候,響應甚至被格式化為 JSON,這讓我們的生活變得更加輕鬆。

如果所有問題都以明確的“否”回答,我們正式用完了使用 HTTP 客戶端的可行選項。 當然,我們可以嘗試更多特定於站點的調整,但通常情況下,與無頭瀏覽器的較慢性能相比,找出它們所需的時間太長了。 使用瀏覽器抓取的美妙之處在於,您可以抓取符合以下基本規則的任何內容:

如果您可以使用瀏覽器訪問它,則可以抓取它。

讓我們以以下站點為例,我們的爬蟲:https://quotes.toscrape.com/search.aspx。 它包含來自給定作者列表的主題列表的引用。 所有數據都是通過 XHR 獲取的。

具有動態呈現數據的網站
具有動態呈現數據的示例網站。 (大預覽)

無論誰仔細查看了網站的功能並查看了上面的清單,都可能意識到實際上可以使用 HTTP 客戶端抓取報價,因為可以通過直接在報價端點上發出 POST 請求來檢索報價。 但是由於本教程應該介紹如何使用 Puppeteer 抓取網站,我們將假裝這是不可能的。

安裝先決條件

由於我們將使用 Node.js 構建所有內容,因此我們首先創建並打開一個新文件夾,並在其中創建一個新的 Node 項目,運行以下命令:

 mkdir js-webscraper cd js-webscraper npm init

請確保您已經安裝了 npm。 安裝程序會問我們一些關於這個項目的元信息的問題,我們都可以跳過,點擊Enter

安裝 Puppeteer

我們之前一直在談論使用瀏覽器進行抓取。 Puppeteer 是一個 Node.js API,它允許我們以編程方式與無頭 Chrome 實例對話。

讓我們使用 npm 安裝它:

 npm install puppeteer

建造我們的刮刀

現在,讓我們通過創建一個名為scraper.js的新文件來開始構建我們的爬蟲。

首先,我們導入之前安裝的庫 Puppeteer:

 const puppeteer = require('puppeteer');

下一步,我們告訴 Puppeteer 在異步和自執行函數中打開一個新的瀏覽器實例:

 (async function scrape() { const browser = await puppeteer.launch({ headless: false }); // scraping logic comes here… })();

注意默認情況下,無頭模式是關閉的,因為這會提高性能。 但是,在構建新的爬蟲時,我喜歡關閉無頭模式。 這使我們能夠跟踪瀏覽器正在經歷的過程並查看所有呈現的內容。 這將幫助我們稍後調試我們的腳本。

在我們打開的瀏覽器實例中,我們現在打開一個新頁面並指向我們的目標 URL:

 const page = await browser.newPage(); await page.goto('https://quotes.toscrape.com/search.aspx');

作為異步函數的一部分,我們將使用await語句等待執行以下命令,然後再繼續執行下一行代碼。

現在我們已經成功打開瀏覽器窗口並導航到頁面,我們必須創建網站的狀態,以便可以看到所需的信息片段以供抓取。

可用主題是為選定的作者動態生成的。 因此,我們將首先選擇“Albert Einstein”並等待生成的主題列表。 列表完全生成後,我們選擇“學習”作為主題,並將其作為第二個表單參數。 然後我們單擊提交並從保存結果的容器中提取檢索到的報價。

由於我們現在將其轉換為 JavaScript 邏輯,因此我們首先列出我們在上一段中討論過的所有元素選擇器:

作者選擇字段#author
標記選擇字段#tag
提交按鈕input[type="submit"]
報價容器.quote

在我們開始與頁面交互之前,我們將確保我們將訪問的所有元素都是可見的,方法是在我們的腳本中添加以下行:

 await page.waitForSelector('#author'); await page.waitForSelector('#tag');

接下來,我們將為我們的兩個選擇字段選擇值:

 await page.select('select#author', 'Albert Einstein'); await page.select('select#tag', 'learning');

我們現在準備好通過點擊頁面上的“搜索”按鈕進行搜索,然後等待報價出現:

 await page.click('.btn'); await page.waitForSelector('.quote');

因為我們現在要訪問頁面的 HTML DOM 結構,所以我們調用提供的page.evaluate()函數,選擇包含引號的容器(在這種情況下只有一個)。 然後我們構建一個對象並將 null 定義為每個object參數的後備值:

 let quotes = await page.evaluate(() => { let quotesElement = document.body.querySelectorAll('.quote'); let quotes = Object.values(quotesElement).map(x => { return { author: x.querySelector('.author').textContent ?? null, quote: x.querySelector('.content').textContent ?? null, tag: x.querySelector('.tag').textContent ?? null, }; }); return quotes; });

我們可以通過記錄它們使所有結果在我們的控制台中可見:

 console.log(quotes);

最後,讓我們關閉瀏覽器並添加一條 catch 語句:

 await browser.close();

完整的刮板如下所示:

 const puppeteer = require('puppeteer'); (async function scrape() { const browser = await puppeteer.launch({ headless: false }); const page = await browser.newPage(); await page.goto('https://quotes.toscrape.com/search.aspx'); await page.waitForSelector('#author'); await page.select('#author', 'Albert Einstein'); await page.waitForSelector('#tag'); await page.select('#tag', 'learning'); await page.click('.btn'); await page.waitForSelector('.quote'); // extracting information from code let quotes = await page.evaluate(() => { let quotesElement = document.body.querySelectorAll('.quote'); let quotes = Object.values(quotesElement).map(x => { return { author: x.querySelector('.author').textContent ?? null, quote: x.querySelector('.content').textContent ?? null, tag: x.querySelector('.tag').textContent ?? null, } }); return quotes; }); // logging results console.log(quotes); await browser.close(); })();

讓我們嘗試運行我們的爬蟲:

 node scraper.js

我們去吧! 刮板按預期返回我們的報價對象:

我們的網絡爬蟲的結果
我們的網絡爬蟲的結果。 (大預覽)

高級優化

我們的基本刮板現在正在工作。 讓我們添加一些改進,為一些更嚴重的抓取任務做好準備。

設置用戶代理

默認情況下,Puppeteer 使用包含字符串HeadlessChrome的用戶代理。 相當多的網站會尋找這種簽名並阻止帶有類似簽名的傳入請求。 為了避免這種情況成為刮板失敗的潛在原因,我總是通過在我們的代碼中添加以下行來設置自定義用戶代理:

 await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4298.0 Safari/537.36');

這可以通過從前 5 個最常見的用戶代理的數組中為每個請求選擇一個隨機的用戶代理來進一步改進。 最常見的用戶代理列表可以在最常見的用戶代理中找到。

實現代理

Puppeteer 使連接到代理非常容易,因為代理地址可以在啟動時傳遞給 Puppeteer,如下所示:

 const browser = await puppeteer.launch({ headless: false, args: [ '--proxy-server=<PROXY-ADDRESS>' ] });

sslproxies 提供了大量免費代理供您使用。 或者,可以使用輪換代理服務。 由於代理通常在許多客戶(或本例中的免費用戶)之間共享,因此連接變得比正常情況下更加不可靠。 這是討論錯誤處理和重試管理的最佳時機。

錯誤和重試管理

很多因素都會導致您的刮刀失敗。 因此,重要的是要處理錯誤並決定在發生故障時應該發生什麼。 由於我們已將爬蟲連接到代理,並且預計連接會不穩定(尤其是因為我們使用的是免費代理),因此我們希望在放棄之前重試四次

此外,如果以前失敗了,再次重試具有相同 IP 地址的請求是沒有意義的。 因此,我們將構建一個小型代理旋轉系統

首先,我們創建兩個新變量:

 let retry = 0; let maxRetries = 5;

每次我們運行我們的函數scrape()時,我們都會將我們的重試變量增加 1。然後我們用 try 和 catch 語句包裝我們完整的抓取邏輯,以便我們可以處理錯誤。 重試管理髮生在我們的catch函數中:

之前的瀏覽器實例將被關閉,如果我們的 retry 變量小於我們的maxRetries變量,則遞歸調用 scrape 函數。

我們的刮板現在看起來像這樣:

 const browser = await puppeteer.launch({ headless: false, args: ['--proxy-server=' + proxy] }); try { const page = await browser.newPage(); … // our scraping logic } catch(e) { console.log(e); await browser.close(); if (retry < maxRetries) { scrape(); } };

現在,讓我們添加前面提到的代理旋轉器。

讓我們首先創建一個包含代理列表的數組:

 let proxyList = [ '202.131.234.142:39330', '45.235.216.112:8080', '129.146.249.135:80', '148.251.20.79' ];

現在,從數組中選擇一個隨機值:

 var proxy = proxyList[Math.floor(Math.random() * proxyList.length)];

我們現在可以將動態生成的代理與我們的 Puppeteer 實例一起運行:

 const browser = await puppeteer.launch({ headless: false, args: ['--proxy-server=' + proxy] });

當然,這個代理輪換器可以進一步優化以標記死代理等等,但這肯定超出了本教程的範圍。

這是我們刮板的代碼(包括所有改進):

 const puppeteer = require('puppeteer'); // starting Puppeteer let retry = 0; let maxRetries = 5; (async function scrape() { retry++; let proxyList = [ '202.131.234.142:39330', '45.235.216.112:8080', '129.146.249.135:80', '148.251.20.79' ]; var proxy = proxyList[Math.floor(Math.random() * proxyList.length)]; console.log('proxy: ' + proxy); const browser = await puppeteer.launch({ headless: false, args: ['--proxy-server=' + proxy] }); try { const page = await browser.newPage(); await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4298.0 Safari/537.36'); await page.goto('https://quotes.toscrape.com/search.aspx'); await page.waitForSelector('select#author'); await page.select('select#author', 'Albert Einstein'); await page.waitForSelector('#tag'); await page.select('select#tag', 'learning'); await page.click('.btn'); await page.waitForSelector('.quote'); // extracting information from code let quotes = await page.evaluate(() => { let quotesElement = document.body.querySelectorAll('.quote'); let quotes = Object.values(quotesElement).map(x => { return { author: x.querySelector('.author').textContent ?? null, quote: x.querySelector('.content').textContent ?? null, tag: x.querySelector('.tag').textContent ?? null, } }); return quotes; }); console.log(quotes); await browser.close(); } catch (e) { await browser.close(); if (retry < maxRetries) { scrape(); } } })();

瞧! 在我們的終端中運行我們的爬蟲將返回引號。

劇作家作為木偶戲的替代品

Puppeteer 是由 Google 開發的。 2020 年初,微軟發布了一款名為 Playwright 的替代品。 微軟從 Puppeteer-Team 挖了很多工程師。 因此,Playwright 是由許多已經在 Puppeteer 上工作的工程師開發的。 除了作為博客上的新手之外,Playwright 最大的不同之處在於跨瀏覽器支持,因為它支持 Chromium、Firefox 和 WebKit (Safari)。

性能測試(比如 Checkly 進行的測試)表明,與 Playwright 相比,Puppeteer 的性能通常提高了 30%,這與我自己的經驗相符——至少在撰寫本文時是這樣。

其他差異,例如您可以使用一個瀏覽器實例運行多個設備這一事實,對於網絡抓取的上下文而言並不真正有價值。

資源和附加鏈接

  • Puppeteer 文檔
  • 學習木偶劇作家
  • Zenscrape 使用 Javascript 進行網頁抓取
  • 最常見的用戶代理
  • 木偶師與劇作家