使用无头浏览器进行网页抓取: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 之类的项目及其背后的优秀团队,无头浏览器变得越来越容易处理我们所有的自动化需求!