in

使用无头浏览器Puppeteer进行网页爬取的教程

使用无头浏览器Puppeteer进行网页爬取的教程

当涉及到 javascript 中的网页抓取时,通常有两种流行的方法:使用HTTP 客户端(如fetch和 )axios和使用浏览器自动化工具(如 Puppeteer)来控制网页浏览器。 虽然传统的基于 HTTP 客户端的抓取非常有效,但抓取动态网页可能非常困难。虽然消耗更多资源且速度较慢,但​​浏览器自动化是一种更容易和更易于访问的网络抓取形式。 在本教程中,我们将了解Puppeteer——一个出色的 JavaScript (NodeJS) 开源浏览器自动化库。 我们将从一般的 Puppeteer API 概述开始,然后重点介绍网络抓取功能,例如如何检索页面并等待它们加载所有内容。 最后,我们将看看常见的问题和挑战,并用一个示例https://www.tiktok.com/ web scraper来总结一切!

Puppeteer概述

什么是 Puppeteer,它是如何工作的? 现代网络浏览器现在包含用于自动化和跨程序通信的特殊访问工具。特别是,Chrome Devtools 协议(又名CDP)——是一种高级 API 协议,允许程序通过套接字连接控制Chrome 或 Firefox网络浏览器实例。 换句话说,Puppeteer 允许我们在 Chrome 或 Firefox 浏览器中创建网络抓取工具。 可以想象,Puppeteer 是一款出色的网页抓取工具!自动化网络浏览器为我们的网络抓取工具提供了几个优势:
  • 基于 Web 浏览器的抓取工具看到用户看到的内容。换句话说,浏览器呈现所有脚本、图像等——使网络抓取工具的开发更加容易。
  • 基于 Web 浏览器的抓取程序更难检测和阻止。由于我们看起来像普通的网站用户,因此我们更难被识别为机器人。
话虽这么说,但还是有一些负面影响。 浏览器是复杂的软件项目并且非常耗费资源。反过来,更多的复杂性也需要更多的开发人员勤奋和维护。

提示:REPL 中的 Puppeteer

试验和掌握 Puppeteer 的最简单方法是使用 nodejs REPL 并实时试用 Puppeteer。请观看此视频以获取快速介绍:
 
现在,让我们更详细地看一下这一点。

基础

可以使用以下终端命令通过 NodeJS 包管理器npm安装 Puppeteer 节点 js 库:
$ mkdir myproject && cd myproject
$ npm init
$ npm install puppeteer
我们首先要注意的是 Puppeteer 是一个异步节点库。这意味着我们将在 Promises 和异步/等待编程的上下文中工作。如果您不熟悉 Javascript 中的异步等待语法,我们推荐MDN 的这篇快速介绍文章。 现在我们的包准备好了,让我们从最基本的例子开始。我们将启动一个无头 Chrome 网络浏览器(无头模式意味着没有 GUI 元素的特殊版本的浏览器),告诉它去一些网站,等待它加载并检索 HTML 页面源:
// import puppeteer library
const puppeteer = require('puppeteer')

async function run(){
    // First, we must launch a browser instance
    const browser = await puppeteer.launch({
        // Headless option allows us to disable visible GUI, so the browser runs in the "background"
        // for development lets keep this to true so we can see what's going on but in
        // on a server we must set this to true
        headless: false,
        // This setting allows us to scrape non-https websites easier
        ignoreHTTPSErrors: true,
    })
    // then we need to start a browser tab
    let page = await browser.newPage();
    // and tell it to go to some URL
    await page.goto('http://httpbin.org/html', {
        waitUntil: 'domcontentloaded',
    });
    // print html content of the website
    console.log(await page.content());
    // close everything
    await page.close();
    await browser.close();
}

run();
在这个基本示例中,我们创建一个可见的浏览器实例,启动一个新选项卡,转到http://httpbin.org/html网页并打印其内容。当使用 Puppeteer 进行抓取时,我们将主要处理Page本质上是网络浏览器选项卡的对象。在此示例中,我们使用了两种方法:goto()告诉选项卡导航到何处以及content()返回网页源代码。 有了这些基础知识,我们就可以开始探索常见的 Puppeteer 使用模式,让我们从基本的解析开始。

等待内容

在这个基本脚本中,我们遇到了第一个问题:我们如何知道页面何时加载并准备好解析数据? 在此示例中,我们使用waitUntil参数告诉浏览器等待domcontentloaded浏览器读取页面的 HTML 内容时触发的信号。但是,这可能不适用于每个页面,因为即使浏览器读取 HTML 页面,动态页面也可能继续加载内容。
浏览器页面加载顺序
Web 浏览器如何加载网页的图示 在处理使用 javascript 的现代动态网站时,最好明确地等待内容而不是依赖loaddomcontentloaded信号:
await page.goto('http://httpbin.org/html');
await page.waitForSelector('h1', {timeout: 5_000})
在这里,我们告诉 Puppeteer 等待<h1>节点出现在文档主体中最多 5 秒(5000 毫秒)。由于我们正在抓取 HTML 内容,因此依赖 HTML 结构加载比浏览器事件安全得多。使用waitForSelector()是确保我们的内容已加载的最佳方式!

选择内容

由于 Puppeteer 运行一个完整的网络浏览器,我们可以访问 CSS 选择器和 XPath 选择器。这两个工具允许我们选择特定的页面部分并提取显示的数据或提交点击和文本输入等事件。让我们看看如何在 puppeteer 抓取中选择 HTML 元素。
Page对象带有几个方法,允许我们找到我们可以提取或用作点击/输入目标的ElementHandle对象
// we can use .setContent to set page html to some test value:
await page.setContent(`
<div class="links">
  <a href="https://twitter.com/@jingzhengli_dev">Twitter</a>
  <a href="https://www.linkedin.com/company/jingzhengli/">LinkedIn</a>
</div>
`);
// using .$ we can select first occurring value and get it's inner text or attribute:
await (await page.$('.links a')).evaluate( node => node.innerText);
await (await page.$('.links a')).evaluate( node => node.getAttribute("href"));

// using .$$ we can select multiple values:
let links = await page.$$('.links a');
// or using xpath selectors instead of css selectors:
// let links = await page.$x('//*[contains(@class, "links")]//a');
for (const link of links){
    console.log(await link.evaluate( node => node.innerText));
    console.log(await link.evaluate( node => node.getAttribute("href")));
}
如您所见,Page对象使我们能够访问 CSS 和 XPATH 选择器。我们可以使用该方法提取第一个找到的元素.$,也可以使用该方法提取所有匹配元素.$$
我们还可以用同样的方式触发鼠标点击、按钮按下和文本输入:
await page.setContent(`
<div class="links">
  <a href="https://twitter.com/@jingzhengli_dev">Twitter</a>
  <input></input>
</div>
`);
// enter text to the input
(await page.$('input')).type('hello jingzhengli!', {delay: 100});
// press enter button
(await page.$('input')).press('Enter');
// click on the first link
(await page.$('.links a')).click();
Puppeteer 使我们能够访问浏览器的导航和解析功能——我们可以使用这些功能来抓取我们的目标,甚至解析页面内容!
既然我们知道如何导航我们的浏览器、等待内容加载和解析 HTML DOM,我们应该通过一个真实的例子来巩固这些知识。

示例项目:tiktok.com

在此示例项目中,我们将抓取https://www.tiktok.com/公共用户详细信息及其视频元数据。我们的爬虫目标是:
  1. 前往https://www.tiktok.com
  2. 搜索标签 #cats 的热门视频
  3. 转到每个顶级视频创作者的页面
  4. 收集创作者的详细信息:姓名、关注者和点赞数
  5. 转到最新的 5 个视频并收集它们的详细信息:描述、链接和点赞数
我们将在这个例子中使用函数式编程,所以让我们从下往上开始:
// scrapes video details
async function scrapeVideo(browser, videoUrl){
    let page = await browser.newPage();
    await page.goto(videoUrl, { waitUntil: 'domcontentloaded'});

    // wait for the page to load
    await page.waitForSelector('strong[data-e2e=like-count]')

    let likes = await(await page.$('strong[data-e2e=like-count]')).evaluate(node => node.innerText); 
    let comments = await(await page.$('strong[data-e2e=comment-count]')).evaluate(node => node.innerText); 
    let desc = await(await page.$('div[data-e2e=video-desc]')).evaluate(node => node.innerText); 
    let music = await(await page.$('h4[data-e2e=video-music] a')).evaluate(node => node.getAttribute('href')); 
    await page.close();
    return {likes, comments, desc, music}
}
在这里,我们有第一个函数,它接受一个Browser对象和一个 tiktok 视频的 url。我们将设计我们的网络抓取工具以使用 1 个浏览器对象并将其传递给函数以将事物保持在函数式编程的范围内。这也将允许我们稍后通过允许使用多个浏览器抓取多个页面来扩大规模。 在这种情况下,我们将启动一个新选项卡,导航到视频 URL 并等待点赞数出现。一旦它在那里,我们解析我们的细节,关闭选项卡并返回结果。 现在,让我们对创作者的页面做同样的事情:
// scrapes user and their top 5 video details
async function scrapeCreator(browser, username){
    let page = await browser.newPage();
    await page.goto('http://tiktok.com/' + username);
    await page.waitForSelector('div[data-e2e="user-post-item"] a');
    // parse user data
    let followers = await(await page.$('strong[data-e2e=followers-count]')).evaluate(node => node.innerText); 
    let likes = await(await page.$('strong[data-e2e=likes-count]')).evaluate(node => node.innerText); 

    // parse user's video data
    let videoLinks = [];
    links = await page.$$('div[data-e2e="user-post-item"] a');
    for (const link of links){
        videoLinks.push(await link.evaluate( node => node.getAttribute('href')));
    };
    let videoData = await Promise.all(videoLinks.slice(0, 5).map(
        url => scrapeVideo(browser, url)
    ))
    await page.close()
    return {username, likes, followers, videoData}
}
在这里,我们整合了我们的scrapeVideo()功能,不仅可以获取创作者的详细信息,还可以获取最近 5 个视频的详细信息。我们用于Promise.all并发执行 5 个承诺,因此在浏览器中,您会看到打开 5 个选项卡并同时抓取 5 个视频详细信息! 我们剩下来实现我们的发现功能。我们想去 tiktok.com,在搜索栏中输入一些文本并提取前几个结果:
// finds users of top videos of a given query
async function findTopVideoCreators(browser, query){
    let page = await browser.newPage();
    // search for cat videos:
    await page.goto('http://tiktok.com/', { waitUntil: 'domcontentloaded'});
    let searchBox = await page.$('input[type=search]')
    await searchBox.type(query, {delay:111});
    await page.waitForTimeout(500);  // we need to wait a bit before pressing enter
    await searchBox.press('Enter');

    // wait for search results to load
    await page.waitForSelector('a[data-e2e="search-card-user-link"]');

    // find all user links
    let userLinks = [];
    links = await page.$$('a[data-e2e="search-card-user-link"]');
    for (const link of links){
        userLinks.push(await link.evaluate( node => node.getAttribute('href')));
    };
    await page.close();
    return userLinks;
}
在上面的示例中,我们要转到网站的主页,将一些文本延迟发送到搜索输入框以显得更人性化,按下该键并等待结果加载Enter。一切加载完成后,我们将获取第一页上显示的用户名。 最后,我们应该用一个连接这些单独部分的运行器函数来包装所有东西:
async function run(query){
    const browser = await puppeteer.launch({
          headless: false,
          ignoreHTTPSErrors: true,
          args: [`--window-size=1920,1080`],
          defaultViewport: {
            width:1920,
            height:1080
          }
        });

    creatorNames = await findTopVideoCreators(browser, query);
    let creators = await Promise.all(creatorNames.slice(0, 3).map(
        url => scrapeCreator(browser, url)
    ))
    console.log(creators);
    await browser.close();
}

// run scraper with cats!
run("#cats");
在这里,我们创建了我们的主要功能,它接受一个查询文本并抓取该查询的前 3 位创作者及其视频详细信息。我们应该看到类似的结果:
{
  username: '/@cutecatcats',
  likes: '10.2M',
  followers: '571.7K',
  videoData: [
    {
      likes: '7942',
      comments: '105',
      desc: 'Standing like a human🤣🤣🤣#cutecatcats #catoftiktok #fyp #고양이 #catlover #cat #catbaby',
      music: '/music/original-sound-7055891421471001390'
    },
    ...
  ]
}
我们剩下数据后处理任务(将字符串数字转换为实数等)。此外,我们还跳过了大量的错误捕获以保持本节简洁,但理想情况下,至少实现基本的重试逻辑总是一个好主意,因为 Web 浏览器可能会出现错误和中断!
现在我们已经通过网络抓取工具示例巩固了我们的知识,让我们来看看我们可以从这里移动到哪里。Puppeteer 网络爬虫面临哪些常见挑战,我们如何解决这些挑战?

共同挑战

关于 headless browser scraping,主要有两种挑战:Scraping speedBot Detection。 让我们来看看我们可以在 puppeteer 库支持的网络抓取工具中应用的常见提示和技巧来解决这两个问题。

抓取速度/资源优化

为了加快我们的 Puppeteer 爬虫速度,我们可以做的最有效的事情是禁用图像和视频加载。当网页抓取时,我们不关心图像是否加载到网页中,因为我们不需要看到它们。
注意:图片和视频仍在页面源中,因此通过禁用加载,我们不会丢失任何数据。
我们可以使用规则来配置 Puppeteer headless 浏览器,以阻止 puppeteer 图像和分析流量:
// we can block by resrouce type like fonts, images etc.
const blockResourceType = [
  'beacon',
  'csp_report',
  'font',
  'image',
  'imageset',
  'media',
  'object',
  'texttrack',
];
// we can also block by domains, like google-analytics etc.
const blockResourceName = [
  'adition',
  'adzerk',
  'analytics',
  'cdn.api.twitter',
  'clicksor',
  'clicktale',
  'doubleclick',
  'exelator',
  'facebook',
  'fontawesome',
  'google',
  'google-analytics',
  'googletagmanager',
  'mixpanel',
  'optimizely',
  'quantserve',
  'sharethrough',
  'tiqcdn',
  'zedo',
];

const page = await browser.newPage();
// we need to enable interception feature
await page.setRequestInterception(true);
// then we can add a call back which inspects every
// outgoing request browser makes and decides whether to allow it
page.on('request', request => {
  const requestUrl = request._url.split('?')[0];
  if (
    (request.resourceType() in blockedResourceType) ||
    blockResourceName.some(resource => requestUrl.includes(resource))
  ) {
    request.abort();
  } else {
    request.continue();
  }
});
}
在此示例中,我们将向页面添加一个扩展,以禁用加载被阻止的资源和资源类型。这将大大加快网络抓取速度,在媒体密集型网站上,这可能高达 10 倍!不仅如此,它还会为我们的爬虫节省大量带宽。

避免机器人检测

当我们使用真实的浏览器时,对于我们的网络抓取网站来说,判断我们是人还是机器人并不难。 由于无头浏览器执行所有 javascript 并在单个 IP 地址上运行,因此网站可以使用连接分析和 javascript 指纹识别等各种技术来确定浏览器是否是网络抓取工具。 为了提高我们的机会,我们可以做两件事:
  • 使用代理
  • 将隐形补丁应用到我们的浏览器

使用代理

在 Puppeteer 中使用代理的默认方式是将它们应用于 Browser 对象:
const browser = await puppeteer.launch({
   args: [ '--proxy-server=http://12.34.56.78:8000' ]
});
然而,这种方法有一些缺陷。首先,这意味着每次我们想要切换代理时,我们都需要重新启动我们的网络浏览器——如果我们正在做某事怎么办?对于小型网络抓取工具,单个代理可能就足够了——为了扩展我们需要更好的东西。 不幸的是,Puppeteer 无法为每个请求甚至页面设置代理。有一些解决方案,如puppeteer-page-proxypuppeteer-proxy,但这些扩展只是劫持无头浏览器的请求并通过 NodeJS HTTP 客户端发出请求,这增加了被检测为机器人的可能性。 在 Puppeteer 中使用多个代理的最佳方式是启动您自己的代理服务器。这样,网络抓取工具将连接到一个代理,然后该代理服务器将从列表中随机选择一个代理。例如,我们可以使用ProxyChainnodeJS 包来实现:
const puppeteer = require('puppeteer')
const ProxyChain = require('proxy-chain');

const proxies = [
  'http://user:[email protected]:8000',
  'http://user:[email protected]:8000',
  'http://user:[email protected]:8000',
]

const server = new ProxyChain.Server({
  port: 8000,
  prepareRequestFunction: ({request}) => {
    let randomProxy = proxies[proxies.length * Math.random() | 0];
    return {
      upstreamProxyUrl: randomProxy,
    };
  });
});

server.listen(() => console.log('Proxy server started on 127.0.0.1:8000'));

const browser = await puppeteer.launch({
   args: [ '--proxy-server=http://127.0.0.1:8000' ]
});
这种方法最适合高质量的住宅代理
通过这种方法,我们的浏览器将使用单个代理链代理。但实际上,我们的代理会为每个请求选择一个随机代理地址!

使Puppeteer操纵者隐身

当我们使用网络浏览器进行网络抓取时,我们授予对网站的完整代码执行访问权限。这意味着网站可以使用各种 javascript 脚本来收集有关我们浏览器的信息。此信息可以将我们识别为 Puppeteer 控制的 Web 浏览器或用于构建 javascript 指纹。 为了绕过指纹识别,我们可以强化我们的无头浏览器来模拟它的功能。Javascript 指纹抵抗是一个很大的话题,尽管首先有社区维护的工具,如puppeteer-stealth插件。
const puppeteer = require('puppeteer-extra')

// add stealth plugin and use defaults (all evasion techniques)
const StealthPlugin = require('puppeteer-extra-plugin-stealth')
puppeteer.use(StealthPlugin())

// puppeteer usage as normal
puppeteer.launch({ headless: true }).then(async browser => {
  console.log('Running tests..')
  const page = await browser.newPage()
  await page.goto('https://bot.sannysoft.com')
  await page.waitForTimeout(5000)
  await page.screenshot({ path: 'testresult.png', fullPage: true })
  await browser.close()
  console.log(`All done, check the screenshot. ✨`)
})
在此示例中,我们安装了puppeteer-extra插件包 ( npm install puppeteer-extra) 并使用隐形补丁修补了我们的 puppeteer 包。虽然这不是一个万能的解决方案,但它是强化 Puppeteer 浏览器进行网络抓取的一个很好的起点。

常问问题

为了总结这个 puppeteer 教程,让我们看一下有关使用 javascript 和 puppeteer 进行网页抓取的常见问题:

为什么部署的 Puppeteer scraper 表现不同?

Puppeteer 正在自动化一个真正的浏览器,因此它的自然功能取决于主机。也就是说,由 Puppeteer 控制的无头 Chrome 浏览器继承了操作系统包。因此,如果我们在 MacOs 上开发我们的代码并在 Linux 上运行它——爬虫的行为会略有不同。

如何使用 Puppeteer 更快地抓取数据?

Puppeteer 提供了一个用于控制浏览器的高级 API,但它不是专用的网络抓取框架。因此,有很多方法可以加快网页抓取速度。 最简单的方法是利用该库的异步特性。我们可以使用Promise.allPromise.allSettled并发函数启动多个浏览器并在单个爬虫应用程序中使用它们。

如何使用 Puppeteer 捕获后台请求和响应?

动态网站通常会使用后台请求 (XHR) 在页面加载后生成一些数据。我们可以使用信号捕获功能捕获这些请求和响应page.on。例如,我们可以捕获所有 XHR 类型的请求并丢弃它们或读取/修改它们的数据:
(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  // capture background requests:
  await page.setRequestInterception(true);
  page.on('request', request => {
    if (request.resourceType() === 'xhr') {
      console.log(request):
      // we can block these requests with:
      // request.abort();
    } else {
      request.continue();
    }
  });
  // capture background responses:
  page.on('response', response => {
    if (response.resourceType() === 'xhr') {
      console.log(response);
    }
  })
  await browser.close();
})();

你如何拼写 Puppeteer?

这个项目有一个出了名的难懂的名字,而且它经常以千种不同的方式拼写错误:pupeteer、puppeter、puperter、puppetier 等。 最容易记住的方法是遵循这个简单的公式:pup++ 。 在查找特定资源时注意有问题的名称 – 有时,故意拼错它可以帮助您找到错误解决方案!peteer

概括

在这篇介绍性文章中,我们了解了用于 NodeJS 的 Puppeteer 网络浏览器自动化包,以及我们如何使用它进行网络抓取。我们涵盖了一些常见的用例场景,并探讨了如何使用代理和避免被发现等挑战。我们还写了一个小例子,它从 tiktok.com 收集创作者数据。
 

Written by 河小马

河小马是一位杰出的数字营销行业领袖,广告中国论坛的重要成员,其专业技能涵盖了PPC广告、域名停放、网站开发、联盟营销以及跨境电商咨询等多个领域。作为一位资深程序开发者,他不仅具备强大的技术能力,而且在出海网络营销方面拥有超过13年的经验。