如何使用Puppeteer、Node.js、Docker和Kubernetes构建并发WebScraper

来自菜鸟教程
跳转至:导航、​搜索

作为 Write for DOnations 计划的一部分,作者选择了 Free and Open Source Fund 来接受捐赠。

介绍

网络抓取,也称为网络爬虫,使用机器人从网站中提取、解析和下载内容和数据。

您可以使用一台机器从几十个网页中抓取数据,但如果您必须从数百甚至数千个网页中检索数据,您可能需要考虑分配工作负载。

在本教程中,您将使用 Puppeteer 抓取 books.toscrape,这是一个虚构的书店,可作为初学者学习网络抓取和开发人员验证其抓取技术的安全场所。 在撰写本文时,books.toscrape 上有 1000 本书,因此您可以抓取 1000 个网页。 但是,在本教程中,您只会抓取前 400 个。 要在短时间内抓取所有这些网页,您将构建一个包含 Express Web 框架和 Puppeteer 浏览器控制器的可扩展应用程序并将其部署到 Kubernetes 集群。 为了与您的爬虫交互,您将构建一个包含 axios(基于 Promise 的 HTTP 客户端)和 lowdb(用于 Node.js 的小型 JSON 数据库)的应用程序。

完成本教程后,您将拥有一个可扩展的抓取工具,能够同时从多个页面中提取数据。 例如,使用默认设置和三节点集群,在 books.toscrape 上抓取 400 页将花费不到 2 分钟的时间。 扩展集群后,大约需要 30 秒。

警告: 网络抓取的道德和合法性非常复杂且不断发展。 它们还会根据您的位置、数据的位置和相关网站而有所不同。 本教程抓取了一个特殊的网站 books.toscrape.com,专门用于测试抓取应用程序。 抓取任何其他域不在本教程的范围内。


先决条件

要学习本教程,您需要一台具有以下功能的机器:

第 1 步 — 分析目标网站

在编写任何代码之前,请在 Web 浏览器中导航到 books.toscrape。 检查数据的结构以及为什么并发抓取是最佳解决方案。

请注意,该网站上有 1,000 本书,但每个页面仅显示 20 本书。

滚动到页面底部。

本网站的内容是分页的,共有50页。 因为每页显示 20 本书,而您只想抓取前 400 本书,所以您将只检索前 20 页上显示的每本书的标题、价格、评级和 URL。

整个过程应该不到 1 分钟。

打开浏览器的开发工具并检查页面上的第一本书。 您将看到以下内容:

每本书都在 <section> 标签内,每本书都列在自己的 <li> 标签下。 在每个 <li> 标记内都有一个 <article> 标记,其 class 属性等于 product_pod。 这是我们要抓取的元素。

获取前 20 页每本书的元数据并存储后,您将拥有一个包含 400 本书的本地数据库。 但是,由于有关该书的更多详细信息存在于其自己的页面上,因此您需要使用每本书元数据中的 URL 浏览 400 个额外的页面。 然后,您将检索所需的缺失图书详细信息,并将此数据添加到本地数据库。 您要检索的缺失数据是描述、UPC(通用图书代码)、评论数量和图书的可用性。 使用一台机器浏览 400 个页面可能需要 7 分钟以上,这就是为什么您需要 Kubernetes 将工作分配到多台机器上的原因。

现在单击主页上第一本书的链接,这将打开该书的详细信息页面。 再次打开浏览器的开发工具并检查页面。

同样,您要提取的缺失信息位于 <article> 标记内,其 class 属性等于 product_page。

要与集群中的爬虫进行交互,您需要创建一个能够向我们的 Kubernetes 集群发送 HTTP 请求的客户端应用程序。 您将首先对该项目的服务器端进行编码,然后再对客户端进行编码。

在本节中,您已经查看了爬虫将检索哪些信息以及为什么需要将此爬虫部署到 Kubernetes 集群。 在下一部分中,您将为客户端和服务器应用程序创建目录。

第 2 步 — 创建项目根目录

在此步骤中,您将创建项目的目录结构。 然后,您将为您的客户端和服务器应用程序初始化一个 Node.js 项目。

打开一个终端窗口并创建一个名为 concurrent-webscraper 的新目录:

mkdir concurrent-webscraper

导航到目录:

cd ./concurrent-webscraper

现在创建三个名为 server、client 和 k8s 的子目录:

mkdir server client k8s

导航到 server 目录:

cd ./server

创建一个新的 Node.js 项目。 运行 npm 的 init 命令将创建一个 package.json 文件,它将帮助您管理依赖项和元数据。

运行初始化命令:

npm init

要接受默认值,请按 ENTER 到所有提示; 或者,您可以个性化您的回复。 您可以在我们的教程的 步骤一中阅读有关 npm 初始化设置的更多信息,如何将 Node.js 模块与 npm 和 package.json 一起使用。

打开 package.json 文件并编辑它:

nano package.json

您需要修改 main 属性,在 scripts 指令中添加一些信息,然后创建 dependencies 指令。

用突出显示的代码替换文件中的内容:

./server/package.json

{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
  "body-parser": "^1.19.0",
  "express": "^4.17.1",
  "puppeteer": "^3.0.0"
  }
}

在这里,您更改了 main 和 scripts 属性,并且还编辑了 dependencies 属性。 因为服务器应用程序将在 Docker 容器中运行,所以您不需要运行 npm install 命令,该命令通常在初始化之后自动添加每个依赖项到 package.json。

保存并关闭文件。

导航到您的 client 目录:

cd ../client

创建另一个 Node.js 项目:

npm init

按照相同的程序接受默认设置或自定义您的响应。

打开 package.json 文件并编辑它:

nano package.json

用突出显示的代码替换文件中的内容:

./client/package.json

{
  "name": "client",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "start": "node main.js"
  },
  "author": "",
  "license": "ISC"
}

在这里,您更改了 main 和 scripts 属性。

这次,使用 npm 安装必要的依赖项:

npm install axios lowdb --save

在此代码块中,您已经安装了 axios 和 lowdb。 axios 是一个基于 Promise 的 HTTP 客户端,用于浏览器和 Node.js。 您将使用此模块向我们的爬虫中的 REST 端点发送异步 HTTP 请求以与之交互; lowdb 是用于 Node.js 和浏览器的小型 JSON 数据库,您将使用它来存储抓取的数据。

在这一步中,您创建了一个项目目录并为您的应用程序服务器初始化了一个 Node.js 项目,该项目将包含刮板; 然后,您对将与应用程序服务器交互的客户端应用程序执行相同的操作。 您还为 Kubernetes 配置文件创建了一个目录。 在下一步中,您将开始构建应用程序服务器。

第 3 步 — 构建第一个 Scraper 文件

在这一步和第 4 步中,您将在服务器端创建爬虫。 此应用程序将包含两个文件:puppeteerManager.js 和 server.js。 puppeteerManager.js 文件将创建和管理浏览器会话,server.js 文件将接收抓取一个或多个网页的请求。 反过来,这些请求将调用 puppeteerManager.js 内部的方法,该方法将抓取给定的网页并返回抓取的数据。 在此步骤中,您将创建 puppeteerManager.js 文件。 在第 4 步中,您将创建 server.js 文件。

首先,返回服务器目录并创建一个名为 puppeteerManager.js 的文件。

导航到 server 文件夹:

cd ../server

使用您喜欢的文本编辑器创建并打开 puppeteerManager.js 文件:

nano puppeteerManager.js

您的 puppeteerManager.js 文件将包含一个名为 PuppeteerManager 的类,该类将创建和管理一个 Puppeteer 浏览器实例。 您将首先创建此类,然后向其添加构造函数。

将以下代码添加到您的 puppeteerManager.js 文件中:

puppeteerManager.js

class PuppeteerManager {
    constructor(args) {
        this.url = args.url
        this.existingCommands = args.commands
        this.nrOfPages = args.nrOfPages
        this.allBooks = [];
        this.booksDetails = {}
    }
}
module.exports = { PuppeteerManager }

在第一个代码块中,您创建了 PuppeteerManager 类并向其添加了 构造函数。 构造函数期望接收包含以下属性的对象:

  • url:此属性将包含一个字符串,它将是您要抓取的页面的地址。
  • commands:该属性将保存一个数组,为浏览器提供指令。 例如,它将引导浏览器单击按钮或解析特定的 DOM 元素。 每个 command 具有以下属性:description、locatorCss 和 type。 description 告诉你 command 做了什么,locatorCss 在 DOM 中找到合适的元素,type 选择具体动作。
  • nrOfPages:此属性将保存一个整数,您的应用程序将使用它来确定 commands 应该重复多少次。 例如,books.toscrape.com 每页只显示 20 本书,因此要在所有 20 页上获得所有 400 本书,您将使用此属性重复现有的 commands 20 次.

在此代码块中,您还将接收到的对象属性分配给构造函数变量 url、existingCommands 和 nrOfPages。 然后,您创建了两个附加变量:allBooks 和 booksDetails。 您将使用变量 allBooks 来存储所有检索到的书籍的元数据,并使用变量 booksDetails 来存储给定的单个书籍的缺失书籍详细信息。

您现在已准备好向 PuppeteerManager 类添加一些方法。 此类将具有以下方法:runPuppeteer()、executeCommand()、sleep()、getAllBooks() 和 getBooksDetails()。 因为这些方法构成了爬虫应用程序的核心,所以值得一一研究。

编码 runPuppeteer() 方法

PuppeteerManager 类中的第一个方法是 runPuppeteer()。 这将需要 Puppeteer 模块并启动您的浏览器实例。

在 PuppeteerManager 类的底部,添加以下代码:

puppeteerManager.js

. . .
    async runPuppeteer() {
        const puppeteer = require('puppeteer')
        let commands = []
        if (this.nrOfPages > 1) {
            for (let i = 0; i < this.nrOfPages; i++) {
                if (i < this.nrOfPages - 1) {
                    commands.push(...this.existingCommands)
                } else {
                    commands.push(this.existingCommands[0])
                }
            }
        } else {
            commands = this.existingCommands
        }
        console.log('commands length', commands.length)
    }

在此代码块中,您创建了 runPuppeteer() 方法。 首先,您需要 puppeteer 模块,然后创建一个变量,该变量以一个名为 commands 的空数组开头。 使用条件逻辑,您声明如果要抓取的页面数大于一,代码应循环通过 nrOfPages,并将每个页面的 existingCommands 添加到 [ X186X] 数组。 但是,当它到达最后一页时,它不会将 existingCommands 数组中的最后一个 command 添加到 commands 数组中,因为最后一个 command单击下一页按钮。

下一步是创建浏览器实例。

在刚刚创建的 runPuppeteer() 方法的底部,添加以下代码:

puppeteerManager.js

. . .
    async runPuppeteer() {
        . . .

        const browser = await puppeteer.launch({
            headless: true,
            args: [
                "--no-sandbox",
                "--disable-gpu",
            ]
        });
        let page = await browser.newPage()

        . . .
    }

在这段代码中,您使用 内置的 puppeteer.launch() 方法 创建了一个 browser 实例。 您指定实例在 headless 模式下运行。 这是此项目的默认选项和必需选项,因为您在 Kubernetes 上运行应用程序。 在创建没有图形用户界面的浏览器时,接下来的两个参数是标准的。 最后,您使用 Puppeteer 的 browser.newPage() 方法 创建了一个新的 page 对象。 .launch() 方法返回一个 Promise,它需要 await 关键字。

您现在已准备好向新的 page 对象添加一些行为,包括它将如何导航 URL。

在 runPuppeteer() 方法的底部,添加以下代码:

puppeteerManager.js

. . .
    async runPuppeteer() {
        . . .

        await page.setRequestInterception(true);
        page.on('request', (request) => {
            if (['image'].indexOf(request.resourceType()) !== -1) {
                request.abort();
            } else {
                request.continue();
            }
        });

        await page.on('console', msg => {
            for (let i = 0; i < msg._args.length; ++i) {
                msg._args[i].jsonValue().then(result => {
                    console.log(result);
                })
            }
        });

        await page.goto(this.url);

        . . .
    }

在此代码块中,page 对象使用 Puppeteer 的 page.setRequestInterception() 方法 拦截所有请求,如果请求是加载 image,它会阻止从加载图像,从而减少加载网页所需的时间。 然后 page 对象使用 Puppeteer 的 page.on('console') 事件 拦截任何在浏览器上下文中显示消息的尝试。 然后 page 使用 page.goto() 方法导航到给定的 url。

现在向您的 page 对象添加更多行为,这些行为将控制它如何在 DOM 中查找元素并在它们上运行命令。

在 runPuppeteer() 方法的底部添加以下代码:

puppeteerManager.js

. . .
    async runPuppeteer() {
        . . .

        let timeout = 6000
        let commandIndex = 0
        while (commandIndex < commands.length) {
            try {
                console.log(`command ${(commandIndex + 1)}/${commands.length}`)
                let frames = page.frames()
                await frames[0].waitForSelector(commands[commandIndex].locatorCss, { timeout: timeout })
                await this.executeCommand(frames[0], commands[commandIndex])
                await this.sleep(1000)
            } catch (error) {
                console.log(error)
                break
            }
            commandIndex++
        }
        console.log('done')
        await browser.close()
    }

在此代码块中,您创建了两个变量,timeout 和 commandIndex。 第一个变量将限制代码在网页上等待元素的时间,第二个变量控制如何循环遍历 commands 数组。

在 while 循环中,代码遍历 commands 数组中的每个 command。 首先,您正在使用 page.frames() 方法 创建一个包含所有附加到页面的框架的数组。 它使用 、frame.waitForSelector() 方法 和 locatorCss 属性在 page 的 frame 对象中搜索 DOM 元素。 如果找到一个元素,它将调用 executeCommand() 方法并将 frame 和 command 对象作为参数传递。 executeCommand返回后,调用sleep()方法,让代码等待1秒再执行下一个command。 最后,当没有更多命令时,browser 实例关闭。

这完成了您的 runPuppeteer() 方法。 此时,您的 puppeteerManager.js 文件应如下所示:

puppeteerManager.js

class PuppeteerManager {
    constructor(args) {
        this.url = args.url
        this.existingCommands = args.commands
        this.nrOfPages = args.nrOfPages
        this.allBooks = [];
        this.booksDetails = {}
    }

    async runPuppeteer() {
        const puppeteer = require('puppeteer')
        let commands = []
        if (this.nrOfPages > 1) {
            for (let i = 0; i < this.nrOfPages; i++) {
                if (i < this.nrOfPages - 1) {
                    commands.push(...this.existingCommands)
                } else {
                    commands.push(this.existingCommands[0])
                }
            }
        } else {
            commands = this.existingCommands
        }
        console.log('commands length', commands.length)

        const browser = await puppeteer.launch({
            headless: true,
            args: [
                "--no-sandbox",
                "--disable-gpu",
            ]
        });

        let page = await browser.newPage()
        await page.setRequestInterception(true);
        page.on('request', (request) => {
            if (['image'].indexOf(request.resourceType()) !== -1) {
                request.abort();
            } else {
                request.continue();
            }
        });

        await page.on('console', msg => {
            for (let i = 0; i < msg._args.length; ++i) {
                msg._args[i].jsonValue().then(result => {
                    console.log(result);
                })

            }
        });

        await page.goto(this.url);

        let timeout = 6000
        let commandIndex = 0
        while (commandIndex < commands.length) {
            try {
         
                console.log(`command ${(commandIndex + 1)}/${commands.length}`)
                let frames = page.frames()
                await frames[0].waitForSelector(commands[commandIndex].locatorCss, { timeout: timeout })
                await this.executeCommand(frames[0], commands[commandIndex])
                await this.sleep(1000)
            } catch (error) {
                console.log(error)
                break
            }
            commandIndex++
        }
        console.log('done')
        await browser.close();
    }
}

现在您已准备好为 puppeteerManager.js 编写第二种方法:executeCommand()。

编码 executeCommand() 方法

创建 runPuppeteer() 方法后,现在是创建 executeCommand() 方法的时候了。 此方法负责决定 Puppeteer 应该执行哪些操作,例如单击按钮或解析一个或多个 DOM 元素。

在 PuppeteerManager 类的底部添加以下代码:

puppeteerManager.js

. . .
    async executeCommand(frame, command) {
        await console.log(command.type, command.locatorCss)
        switch (command.type) {
            case "click":
                break;
            case "getItems":
                break;
            case "getItemDetails":
                break;
        }
    }

在此代码块中,您创建了 executeCommand() 方法。 此方法需要两个参数,一个包含页面元素的 frame 对象和一个包含命令的 command 对象。 此方法由 switch 语句组成,具有以下情况:click、getItems 和 getItemDetails。

定义 click 情况。

将 case "click": 下面的 break; 替换为以下代码:

puppeteerManager.js

    async executeCommand(frame, command) {
        . . .
            case "click":
                try {
                    await frame.$eval(command.locatorCss, element => element.click());
                    return true
                } catch (error) {
                    console.log("error", error)
                    return false
                }
        . . .        
    }

当 command.type 等于 click 时,您的代码将触发 click 情况。 此代码块负责单击 next 按钮在分页的书籍列表中移动。

现在编写下一个 case 语句。

将 case "getItems": 下面的 break; 替换为以下代码:

puppeteerManager.js

    async executeCommand(frame, command) {
        . . .
            case "getItems":
                try {
                    let books = await frame.evaluate((command) => {
                        function wordToNumber(word) {
                            let number = 0
                            let words = ["zero","one","two","three","four","five"]
                            for(let n=0;n<words.length;words++){
                                if(word == words[n]){
                                    number = n
                                    break
                                }
                            }
                            return number
                        }

                        try {
                            let parsedItems = [];
                            let items = document.querySelectorAll(command.locatorCss);
                            items.forEach((item) => {
                                let link = 'http://books.toscrape.com/catalogue/' + item.querySelector('div.image_container a').getAttribute('href').replace('catalogue/', '')<^>
                                let starRating = item.querySelector('p.star-rating').getAttribute('class').replace('star-rating ', '').toLowerCase().trim()
                                let title = item.querySelector('h3 a').getAttribute('title')
                                let price = item.querySelector('p.price_color').innerText.replace('£', '').trim()
                                let book = {
                                    title: title,
                                    price: parseInt(price),
                                    rating: wordToNumber(starRating),
                                    url: link
                                }
                                parsedItems.push(book)
                            })
                            return parsedItems;
                        } catch (error) {
                            console.log(error)
                        }
                    }, command).then(result => {
                        this.allBooks.push.apply(this.allBooks, result)
                        console.log('allBooks length ', this.allBooks.length)
                    })
                    return true
                } catch (error) {
                    console.log("error", error)
                    return false
                }
        . . .
    }

当 command.type 等于 getItems 时,将触发 getItems 情况。 您正在使用 frame.evaluate() 方法 切换浏览器上下文,然后创建一个名为 wordToNumber() 的函数。 此函数将一本书的 starRating 从字符串转换为整数。 然后代码将使用 document.querySelectorAll() 方法 解析和匹配 DOM 并检索网页的给定 frame 中显示的书籍的元数据. 检索到元数据后,代码会将其添加到 allBooks 数组中。

现在您可以定义最终的 case 语句。

将 case "getItemDetails" 下面的 break; 替换为以下代码:

puppeteerManager.js

    async executeCommand(frame, command) {
        . . .
            case "getItemDetails":
                try {
                    this.booksDetails = JSON.parse(JSON.stringify(await frame.evaluate((command) => {
                        try {
                            let item = document.querySelector(command.locatorCss);
                            let description = item.querySelector('.product_page > p:nth-child(3)').innerText.trim()
                            let upc = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(1) > td:nth-child(2)')
                                .innerText.trim()
                            let nrOfReviews = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(7) > td:nth-child(2)')
                                .innerText.trim()
                            let availability = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(6) > td:nth-child(2)')
                                .innerText.replace('In stock (', '').replace(' available)', '')
                            let details = {
                                description: description,
                                upc: upc,
                                nrOfReviews: parseInt(nrOfReviews),
                                availability: parseInt(availability)
                            }
                            return details;
                        } catch (error) {
                            console.log(error)
                            return error
                        }

                    }, command)))
                    console.log(this.booksDetails)
                    return true
                } catch (error) {
                    console.log("error", error)
                    return false
                }
    }

当 command.type 等于 getItemDetails 时,将触发 getItemDetails 情况。 您再次使用 frame.evaluate() 和 .querySelector() 方法来切换浏览器上下文并解析 DOM。 但是这一次,您检索了网页的给定 frame 中每本书的缺失详细信息。 然后,您将这些缺失的细节分配给 booksDetails 对象。

这完成了您的 executeCommand() 方法。 您的 puppeteerManager.js 文件现在将如下所示:

puppeteerManager.js

class PuppeteerManager {
    constructor(args) {
        this.url = args.url
        this.existingCommands = args.commands
        this.nrOfPages = args.nrOfPages
        this.allBooks = [];
        this.booksDetails = {}
    }

    async runPuppeteer() {
        const puppeteer = require('puppeteer')
        let commands = []
        if (this.nrOfPages > 1) {
            for (let i = 0; i < this.nrOfPages; i++) {
                if (i < this.nrOfPages - 1) {
                    commands.push(...this.existingCommands)
                } else {
                    commands.push(this.existingCommands[0])
                }
            }
        } else {
            commands = this.existingCommands
        }
        console.log('commands length', commands.length)

        const browser = await puppeteer.launch({
            headless: true,
            args: [
                "--no-sandbox",
                "--disable-gpu",
            ]
        });

        let page = await browser.newPage()
        await page.setRequestInterception(true);
        page.on('request', (request) => {
            if (['image'].indexOf(request.resourceType()) !== -1) {
                request.abort();
            } else {
                request.continue();
            }
        });

        await page.on('console', msg => {
            for (let i = 0; i < msg._args.length; ++i) {
                msg._args[i].jsonValue().then(result => {
                    console.log(result);
                })

            }
        });

        await page.goto(this.url);

        let timeout = 6000
        let commandIndex = 0
        while (commandIndex < commands.length) {
            try {
         
                console.log(`command ${(commandIndex + 1)}/${commands.length}`)
                let frames = page.frames()
                await frames[0].waitForSelector(commands[commandIndex].locatorCss, { timeout: timeout })
                await this.executeCommand(frames[0], commands[commandIndex])
                await this.sleep(1000)
            } catch (error) {
                console.log(error)
                break
            }
            commandIndex++
        }
        console.log('done')
        await browser.close();
    }

    async executeCommand(frame, command) {
        await console.log(command.type, command.locatorCss)
        switch (command.type) {
            case "click":
                try {
                    await frame.$eval(command.locatorCss, element => element.click());
                    return true
                } catch (error) {
                    console.log("error", error)
                    return false
                }
            case "getItems":
                try {
                    let books = await frame.evaluate((command) => {
                        function wordToNumber(word) {
                            let number = 0
                            let words = ["zero","one","two","three","four","five"]
                            for(let n=0;n<words.length;words++){
                                if(word == words[n]){
                                    number = n
                                    break
                                }
                            }  
                            return number
                        }
                        try {
                            let parsedItems = [];
                            let items = document.querySelectorAll(command.locatorCss);
                            
                            items.forEach((item) => {
                                let link = 'http://books.toscrape.com/catalogue/' + item.querySelector('div.image_container a').getAttribute('href').replace('catalogue/', '')
                                let starRating = item.querySelector('p.star-rating').getAttribute('class').replace('star-rating ', '').toLowerCase().trim()
                                let title = item.querySelector('h3 a').getAttribute('title')
                                let price = item.querySelector('p.price_color').innerText.replace('£', '').trim()
                                let book = {
                                    title: title,
                                    price: parseInt(price),
                                    rating: wordToNumber(starRating),
                                    url: link
                                }
                                parsedItems.push(book)
                            })
                            return parsedItems;
                        } catch (error) {
                            console.log(error)
                        }
                    }, command).then(result => {
                        this.allBooks.push.apply(this.allBooks, result)
                        console.log('allBooks length ', this.allBooks.length)
                    })
                    return true
                } catch (error) {
                    console.log("error", error)
                    return false
                }
            case "getItemDetails":
                try {
                    this.booksDetails = JSON.parse(JSON.stringify(await frame.evaluate((command) => {
                        try {
                            let item = document.querySelector(command.locatorCss);
                            let description = item.querySelector('.product_page > p:nth-child(3)').innerText.trim()
                            let upc = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(1) > td:nth-child(2)')
                                .innerText.trim()
                            let nrOfReviews = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(7) > td:nth-child(2)')
                                .innerText.trim()
                            let availability = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(6) > td:nth-child(2)')
                                .innerText.replace('In stock (', '').replace(' available)', '')
                            let details = {
                                description: description,
                                upc: upc,
                                nrOfReviews: parseInt(nrOfReviews),
                                availability: parseInt(availability)
                            }
                            return details;
                        } catch (error) {
                            console.log(error)
                            return error
                        }

                    }, command))) 
                    console.log(this.booksDetails)
                    return true
                } catch (error) {
                    console.log("error", error)
                    return false
                }
        }
    }
}

您现在可以为 PuppeteerManager 类创建第三种方法:sleep()。

编码 sleep() 方法

创建 executeCommand() 方法后,下一步是创建 sleep() 方法。 此方法将使您的代码在执行下一行代码之前等待特定的时间。 这对于减小 crawl rate 至关重要。 如果没有这种预防措施,例如,抓取工具可以单击页面 A 上的按钮,然后在页面 B 加载之前搜索页面 B 上的元素。

在 PuppeteerManager 类的底部添加以下代码:

puppeteerManager.js

. . .
    sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms))
    }

您正在将一个整数传递给 sleep() 方法。 此整数是代码应等待的时间量(以毫秒为单位)。

现在在 PuppeteerManager 类中编写最后两个方法:getAllBooks() 和 getBooksDetails()。

编码 getAllBooks() 和 getBooksDetails() 方法

创建 sleep() 方法后,创建 getAllBooks() 方法。 server.js 文件中的函数将调用此函数。 getAllBooks() 负责调用 runPuppeteer(),获取显示在给定页数上的书籍,然后将检索到的书籍返回给在 server.js 文件中调用它的函数。

在 PuppeteerManager 类的底部添加以下代码:

puppeteerManager.js

. . .
    async getAllBooks() {
        await this.runPuppeteer()
        return this.allBooks
    }

注意这个块如何使用另一个 Promise。

现在您可以创建最终方法:getBooksDetails()。 和 getAllBooks() 一样,server.js 内部的函数会调用这个函数。 然而,getBooksDetails() 负责检索每本书缺失的详细信息。 它还将这些详细信息返回给在 server.js 文件中调用它的函数。

在 PuppeteerManager 类的底部添加以下代码:

puppeteerManager.js

. . .
    async getBooksDetails() {
        await this.runPuppeteer()
        return this.booksDetails
    }

您现在已经完成了对 puppeteerManager.js 文件的编码。

添加本节中描述的五种方法后,您完成的文件将如下所示:

puppeteerManager.js

class PuppeteerManager {
    constructor(args) {
        this.url = args.url
        this.existingCommands = args.commands
        this.nrOfPages = args.nrOfPages
        this.allBooks = [];
        this.booksDetails = {}
    }

    async runPuppeteer() {
        const puppeteer = require('puppeteer')
        let commands = []
        if (this.nrOfPages > 1) {
            for (let i = 0; i < this.nrOfPages; i++) {
                if (i < this.nrOfPages - 1) {
                    commands.push(...this.existingCommands)
                } else {
                    commands.push(this.existingCommands[0])
                }
            }
        } else {
            commands = this.existingCommands
        }
        console.log('commands length', commands.length)

        const browser = await puppeteer.launch({
            headless: true,
            args: [
                "--no-sandbox",
                "--disable-gpu",
            ]
        });

        let page = await browser.newPage()
        await page.setRequestInterception(true);
        page.on('request', (request) => {
            if (['image'].indexOf(request.resourceType()) !== -1) {
                request.abort();
            } else {
                request.continue();
            }
        });

        await page.on('console', msg => {
            for (let i = 0; i < msg._args.length; ++i) {
                msg._args[i].jsonValue().then(result => {
                    console.log(result);
                })

            }
        });

        await page.goto(this.url);

        let timeout = 6000
        let commandIndex = 0
        while (commandIndex < commands.length) {
            try {
         
                console.log(`command ${(commandIndex + 1)}/${commands.length}`)
                let frames = page.frames()
                await frames[0].waitForSelector(commands[commandIndex].locatorCss, { timeout: timeout })
                await this.executeCommand(frames[0], commands[commandIndex])
                await this.sleep(1000)
            } catch (error) {
                console.log(error)
                break
            }
            commandIndex++
        }
        console.log('done')
        await browser.close();
    }

    async executeCommand(frame, command) {
        await console.log(command.type, command.locatorCss)
        switch (command.type) {
            case "click":
                try {
                    await frame.$eval(command.locatorCss, element => element.click());
                    return true
                } catch (error) {
                    console.log("error", error)
                    return false
                }
            case "getItems":
                try {
                    let books = await frame.evaluate((command) => {
                        function wordToNumber(word) {
                            let number = 0
                            let words = ["zero","one","two","three","four","five"]
                            for(let n=0;n<words.length;words++){
                                if(word == words[n]){
                                    number = n
                                    break
                                }
                            }  
                            return number
                        }

                        try {
                            let parsedItems = [];
                            let items = document.querySelectorAll(command.locatorCss);
                            
                            items.forEach((item) => {
                                let link = 'http://books.toscrape.com/catalogue/' + item.querySelector('div.image_container a').getAttribute('href').replace('catalogue/', '')
                                let starRating = item.querySelector('p.star-rating').getAttribute('class').replace('star-rating ', '').toLowerCase().trim()
                                let title = item.querySelector('h3 a').getAttribute('title')
                                let price = item.querySelector('p.price_color').innerText.replace('£', '').trim()
                                let book = {
                                    title: title,
                                    price: parseInt(price),
                                    rating: wordToNumber(starRating),
                                    url: link
                                }
                                parsedItems.push(book)
                            })
                            return parsedItems;
                        } catch (error) {
                            console.log(error)
                        }
                    }, command).then(result => {
                        this.allBooks.push.apply(this.allBooks, result)
                        console.log('allBooks length ', this.allBooks.length)
                    })
                    return true
                } catch (error) {
                    console.log("error", error)
                    return false
                }
            case "getItemDetails":
                try {
                    this.booksDetails = JSON.parse(JSON.stringify(await frame.evaluate((command) => {
                        try {
                            let item = document.querySelector(command.locatorCss);
                            let description = item.querySelector('.product_page > p:nth-child(3)').innerText.trim()
                            let upc = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(1) > td:nth-child(2)')
                                .innerText.trim()
                            let nrOfReviews = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(7) > td:nth-child(2)')
                                .innerText.trim()
                            let availability = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(6) > td:nth-child(2)')
                                .innerText.replace('In stock (', '').replace(' available)', '')
                            let details = {
                                description: description,
                                upc: upc,
                                nrOfReviews: parseInt(nrOfReviews),
                                availability: parseInt(availability)
                            }
                            return details;
                        } catch (error) {
                            console.log(error)
                            return error
                        }

                    }, command))) 
                    console.log(this.booksDetails)
                    return true
                } catch (error) {
                    console.log("error", error)
                    return false
                }
        }
    }

    sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms))
    }

    async getAllBooks() {
        await this.runPuppeteer()
        return this.allBooks
    }

    async getBooksDetails() {
        await this.runPuppeteer()
        return this.booksDetails
    }
}

module.exports = { PuppeteerManager }

在此步骤中,您使用模块 Puppeteer 创建了 puppeteerManager.js 文件。 该文件构成了刮板的核心。 在下一节中,您将创建 server.js 文件。

第 4 步 - 构建第二个 Scraper 文件

在这一步中,您将创建 server.js 文件 — 应用程序服务器的后半部分。 该文件将接收包含指导要抓取哪些数据的信息的请求,然后将该数据返回给客户端。

创建 server.js 文件并打开它:

nano server.js

添加以下代码:

服务器.js

const express = require('express');
const bodyParser = require('body-parser')
const os = require('os');

const PORT = 5000;
const app = express();
let timeout = 1500000

app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())

let browsers = 0
let maxNumberOfBrowsers = 5

在此代码块中,您需要模块 express 和 body-parser。 这些模块是创建能够处理 HTTP 请求的应用程序服务器所必需的。 express 模块会创建一个应用服务器,body-parser 模块会先在中间件中解析传入的请求体,然后再获取请求体的内容。 然后您需要 os 模块,它将检索运行您的应用程序的机器的名称。 之后,您为应用程序指定了一个端口并创建了变量 browsers 和 maxNumberOfBrowsers。 这些变量将有助于管理服务器可以创建的浏览器实例的数量。 在这种情况下,应用程序仅限于创建五个浏览器实例,这意味着爬虫将能够同时从五个页面中检索数据。

我们的 Web 服务器将具有以下路由:/、/api/books 和 /api/booksDetails。

在 server.js 文件的底部,使用以下代码定义 / 路由:

服务器.js

. . .

app.get('/', (req, res) => {
  console.log(os.hostname())
  let response = {
    msg: 'hello world',
    hostname: os.hostname().toString()
  }
  res.send(response);
});

您将使用 / 路由来检查您的应用程序服务器是否正在运行。 发送到此路由的 GET 请求将返回一个包含两个属性的对象:msg,它只会说“hello world”,和 hostname,它将识别所在的机器应用程序服务器的一个实例正在运行。

现在定义 /api/books 路线。

在 server.js 文件的底部,添加以下代码:

服务器.js

. . .

app.post('/api/books', async (req, res) => {
  req.setTimeout(timeout);
  try {
    let data = req.body
    console.log(req.body.url)
    while (browsers == maxNumberOfBrowsers) {
      await sleep(1000)
    }
    await getBooksHandler(data).then(result => {
      let response = {
        msg: 'retrieved books ',
        hostname: os.hostname(),
        books: result
      }
      console.log('done')
      res.send(response)
    })
  } catch (error) {
    res.send({ error: error.toString() })
  }
});

/api/books 路由将要求爬虫检索给定网页上与图书相关的元数据。 对该路由的 POST 请求将检查 browsers 运行的数量是否等于 maxNumberOfBrowsers,如果不是,它将调用方法 [X147X ]。 此方法将创建 PuppeteerManager 类的新实例并检索图书的元数据。 一旦它检索到元数据,它就会在响应正文中返回给客户端。 响应对象将包含一个字符串 msg,它读取 retrieved books ,一个数组,books,它包含元数据,以及另一个字符串,hostname,这将返回运行应用程序的机器/容器/pod 的名称。

我们要定义最后一条路线:/api/booksDetails。

将以下代码添加到 server.js 文件的底部:

服务器.js

. . .

app.post('/api/booksDetails', async (req, res) => {
  req.setTimeout(timeout);
  try {
    let data = req.body
    console.log(req.body.url)
    while (browsers == maxNumberOfBrowsers) {
      await sleep(1000)
    }
    await getBookDetailsHandler(data).then(result => {
      let response = {
        msg: 'retrieved book details',
        hostname: os.hostname(),
        url: req.body.url,
        booksDetails: result
      }
      console.log('done', response)
      res.send(response)
    })
  } catch (error) {
    res.send({ error: error.toString() })
  }
});

向 /api/booksDetails 路由发送 POST 请求将要求爬虫检索给定书籍的缺失信息。 应用服务器会检查browsers运行的数量是否等于maxNumberOfBrowsers。 如果是,则调用sleep()方法并等待1秒再检查,如果不相等,则调用方法getBookDetailsHandler()。 与 getBooksHandler() 方法一样,此方法将创建 PuppeteerManager 类的新实例并检索缺失的信息。

然后程序将在响应正文中将检索到的数据返回给客户端。 响应对象将包含一个字符串 msg,表示 retrieved book details,一个字符串 hostname,它将返回运行应用程序的机器的名称,以及另一个字符串 [ X179X],包含项目页面的 URL。 它还将包含一个数组 booksDetails,其中包含一本书的所有缺失信息。

您的 Web 服务器还将具有以下功能:getBooksHandler()、getBookDetailsHandler() 和 sleep()。

从 getBooksHandler() 函数开始。

在 server.js 文件的底部,添加以下代码:

服务器.js

. . .

async function getBooksHandler(arg) {
  let pMng = require('./puppeteerManager')
  let puppeteerMng = new pMng.PuppeteerManager(arg)
  browsers += 1
  try {
    let books = await puppeteerMng.getAllBooks().then(result => {
      return result
    })
    browsers -= 1
    return books
  } catch (error) {
    browsers -= 1
    console.log(error)
  }
}

getBooksHandler() 函数将创建 PuppeteerManager 类的新实例。 它将运行的 browsers 的数量增加一,传递包含检索书籍所需信息的对象,然后调用 getAllBooks() 方法。 数据取回后,将browsers运行的次数减1,然后将新取到的数据返回给/api/books路由。

现在添加以下代码来定义 getBookDetailsHandler() 函数:

服务器.js

. . .

async function getBookDetailsHandler(arg) {
  let pMng = require('./puppeteerManager')
  let puppeteerMng = new pMng.PuppeteerManager(arg)
  browsers += 1
  try {
    let booksDetails = await puppeteerMng.getBooksDetails().then(result => {
      return result
    })
    browsers -= 1
    return booksDetails
  } catch (error) {
    browsers -= 1
    console.log(error)
  }
}

getBookDetailsHandler() 函数将创建 PuppeteerManager 类的新实例。 它的功能与 getBooksHandler() 函数类似,只是它处理每本书丢失的元数据并将其返回到 /api/booksDetails 路由。

在 server.js 文件的底部添加以下代码来定义 sleep() 函数:

服务器.js

  function sleep(ms) {
    console.log(' running maximum number of browsers')
    return new Promise(resolve => setTimeout(resolve, ms))
  }

当 browsers 的数量等于 maxNumberOfBrowsers 时,sleep() 函数使代码等待特定的时间。 我们将一个整数传递给这个函数,这个整数表示代码应该等待的时间量(以毫秒为单位),直到它可以检查 browsers 是否等于 maxNumberOfBrowsers。

您的文件现已完成。

创建所有必要的路由和函数后,server.js 文件将如下所示:

服务器.js

const express = require('express');
const bodyParser = require('body-parser')
const os = require('os');

const PORT = 5000;
const app = express();
let timeout = 1500000

app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())

let browsers = 0
let maxNumberOfBrowsers = 5

app.get('/', (req, res) => {
  console.log(os.hostname())
  let response = {
    msg: 'hello world',
    hostname: os.hostname().toString()
  }
  res.send(response);
});

app.post('/api/books', async (req, res) => {
  req.setTimeout(timeout);
  try {
    let data = req.body
    console.log(req.body.url)
    while (browsers == maxNumberOfBrowsers) {
      await sleep(1000)
    }
    await getBooksHandler(data).then(result => {
      let response = {
        msg: 'retrieved books ',
        hostname: os.hostname(),
        books: result
      }
      console.log('done')
      res.send(response)
    })
  } catch (error) {
    res.send({ error: error.toString() })
  }
});


app.post('/api/booksDetails', async (req, res) => {
  req.setTimeout(timeout);
  try {
    let data = req.body
    console.log(req.body.url)
    while (browsers == maxNumberOfBrowsers) {
      await sleep(1000)
    }
    await getBookDetailsHandler(data).then(result => {
      let response = {
        msg: 'retrieved book details',
        hostname: os.hostname(),
        url: req.body.url,
        booksDetails: result
      }
      console.log('done', response)
      res.send(response)
    })
  } catch (error) {
    res.send({ error: error.toString() })
  }
});

async function getBooksHandler(arg) {
  let pMng = require('./puppeteerManager')
  let puppeteerMng = new pMng.PuppeteerManager(arg)
  browsers += 1
  try {
    let books = await puppeteerMng.getAllBooks().then(result => {
      return result
    })
    browsers -= 1
    return books
  } catch (error) {
    browsers -= 1
    console.log(error)
  }
}

async function getBookDetailsHandler(arg) {
  let pMng = require('./puppeteerManager')
  let puppeteerMng = new pMng.PuppeteerManager(arg)
  browsers += 1
  try {
    let booksDetails = await puppeteerMng.getBooksDetails().then(result => {
      return result
    })
    browsers -= 1
    return booksDetails
  } catch (error) {
    browsers -= 1
    console.log(error)
  }
}

function sleep(ms) {
  console.log(' running maximum number of browsers')
  return new Promise(resolve => setTimeout(resolve, ms))
}

app.listen(PORT);
console.log(`Running on port: ${PORT}`);

在这一步中,您完成了应用程序服务器的创建。 在下一步中,您将为应用程序服务器创建一个映像,然后将其部署到您的 Kubernetes 集群。

第 5 步 — 构建 Docker 映像

在这一步中,您将创建一个包含您的爬虫应用程序的 Docker 映像。 在第 6 步中,您将把该映像部署到 Kubernetes 集群。

要创建应用程序的 Docker 映像,您需要创建 Dockerfile,然后构建容器。

确保您仍在 ./server 文件夹中。

现在创建 Dockerfile 并打开它:

nano Dockerfile

在 Dockerfile 中写入以下代码:

Dockerfile

FROM node:10

RUN apt-get update

RUN apt-get install -yyq ca-certificates

RUN apt-get install -yyq libappindicator1 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 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6

RUN apt-get install -yyq gconf-service lsb-release wget xdg-utils

RUN apt-get install -yyq fonts-liberation

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install

COPY . .

EXPOSE 5000
CMD [ "node", "server.js" ]

此块中的大部分代码是 Dockerfile 的标准命令行代码。 您从 node:10 图像构建图像。 接下来,您使用 RUN 命令安装必要的包以在 Docker 容器中运行 Puppeteer,然后创建应用程序目录。 您将刮板的 package.json 文件复制到应用程序目录并安装了 package.json 文件中指定的依赖项。 最后,您捆绑了应用程序源,将应用程序暴露在端口 5000 上,并选择 server.js 作为入口文件。

现在创建一个 .dockerignore 文件并打开它。 这将使敏感和不必要的文件不受版本控制。

使用您喜欢的文本编辑器创建文件:

nano .dockerignore

将以下内容添加到文件中:

./server/.dockerignore

node_modules
npm-debug.log

创建 Dockerfile 和 .dockerignore 文件后,您可以构建应用程序的 Docker 映像并将其推送到 Docker Hub 帐户中的存储库。 在推送映像之前,请检查您是否已登录 Docker Hub 帐户。

登录 Docker Hub:

docker login --username=your_username --password=your_password

构建镜像:

docker build -t your_username/concurrent-scraper .

现在是时候测试刮板了。 在此测试中,您将向每个路由发送一个请求。

首先,启动应用程序:

docker run -p 5000:5000 -d your_username/concurrent-scraper

现在使用 curl 向 / 路由发送 GET 请求:

curl http://localhost:5000/

通过向 / 路由发送 GET 请求,您应该会收到包含 msg 的响应,其中包含 hello world 和 hostname。 这个 hostname 是你的 Docker 容器的 id。 您应该会看到与此类似的输出,但带有您机器的唯一 ID:

Output{"msg":"hello world","hostname":"0c52d53f97d3"}

现在向 /api/books 路由发送 POST 请求,以获取显示在一个网页上的所有书籍的元数据:

curl --header "Content-Type: application/json"   --request POST   --data '{"url": "http://books.toscrape.com/index.html" , "nrOfPages":1 , "commands":[{"description": "get items metadata", "locatorCss": ".product_pod","type": "getItems"},{"description": "go to next page","locatorCss": ".next > a:nth-child(1)","type": "Click"}]}'   http://localhost:5000/api/books

通过向 /api/books 路由发送 POST 请求,您将收到包含 msg 表示 retrieved books 、hostname 的响应,类似于上一个请求中的一个,以及一个 books 数组,其中包含在 books.toscrape 网站的第一页上显示的所有 20 本书。 您应该会看到这样的输出,但带有您机器的唯一 ID:

Output{"msg":"retrieved books ","hostname":"0c52d53f97d3","books":[{"title":"A Light in the Attic","price":null,"rating":0,"url":"http://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"},{"title":"Tipping the Velvet","price":null,"rating":0,"url":"http://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html"}, [ . . . ] }]}

现在向 /api/booksDetails 路由发送 POST 请求以获取随机书的缺失信息:

curl --header "Content-Type: application/json"   --request POST   --data '{"url": "http://books.toscrape.com/catalogue/slow-states-of-collapse-poems_960/index.html" , "nrOfPages":1 , "commands":[{"description": "get item details", "locatorCss": "article.product_page","type": "getItemDetails"}]}'   http://localhost:5000/api/booksDetails

通过向 /api/booksDetails 路由发送 POST 请求,您将收到包含 msg 的响应,其中包含 retrieved book details,booksDetails 对象包含 [ X153X]这本书缺少的细节,一个包含产品页面地址的url,以及一个类似于之前请求的hostname。 你会看到这样的输出:

Output{"msg":"retrieved book details","hostname":"0c52d53f97d3","url":"http://books.toscrape.com/catalogue/slow-states-of-collapse-poems_960/index.html","booksDetails":{"description":"The eagerly anticipated debut from one of Canada’s most exciting new poets In her debut collection, Ashley-Elizabeth Best explores the cultivation of resilience during uncertain and often trying times [...]","upc":"b4fd5943413e089a","nrOfReviews":0,"availability":17}}

如果您的 curl 命令没有返回正确的响应,请确保文件 puppeteerManager.js 和 server.js 中的代码与前两步中的最终代码块匹配。 此外,请确保 Docker 容器正在运行并且没有崩溃。 您可以尝试在没有 -d 选项的情况下运行 Docker 映像(此选项使 Docker 映像以分离模式运行),然后向其中一个路由发送 HTTP 请求。

如果在尝试运行 Docker 镜像时仍然遇到错误,请尝试停止所有正在运行的容器并运行不带 -d 选项的刮板镜像。

首先停止所有容器:

docker stop $(docker ps -a -q)

然后运行不带 -d 标志的 Docker 命令:

docker run -p 5000:5000 your_username/concurrent-scraper

如果您没有遇到任何错误,请清理终端窗口:

clear

现在您已经成功测试了映像,您可以将其发送到您的存储库。 将映像推送到 Docker Hub 帐户中的存储库:

docker push your_username/concurrent-scraper:latest

现在,您的爬虫应用程序可作为 Docker Hub 上的映像使用,您已准备好部署到 Kubernetes。 这将是您的下一步。

第 6 步 — 将 Scraper 部署到 Kubernetes

随着您的刮板图像构建并推送到您的存储库,您现在可以进行部署了。

首先,使用 kubectl 创建一个名为 concurrent-scraper-context 的新命名空间:

kubectl create namespace concurrent-scraper-context

将 concurrent-scraper-context 设置为默认上下文:

kubectl config set-context --current --namespace=concurrent-scraper-context

要创建应用程序的部署,您需要创建一个名为 app-deployment.yaml 的文件,但首先,您必须导航到项目中的 k8s 目录。 这是您将存储所有 Kubernetes 文件的地方。

转到项目中的 k8s 目录:

cd ../k8s

创建 app-deployment.yaml 文件并打开它:

nano app-deployment.yaml

在 app-deployment.yaml 中写入以下代码。 确保将 your_DockerHub_username 替换为您的唯一用户名:

./k8s/app-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: scraper
  labels:
    app: scraper
spec:
  replicas: 5
  selector:
    matchLabels:
      app: scraper
  template:
    metadata:
      labels:
        app: scraper
    spec:
      containers:
      - name: concurrent-scraper
        image: your_DockerHub_username/concurrent-scraper
        ports:
        - containerPort: 5000

前面代码块中的大部分代码都是 Kubernetes deployment 文件的标准代码。 首先,将应用部署的名称设置为 scraper,然后将 pod 的数量设置为 5,然后将容器的名称设置为 concurrent-scraper。 之后,您将要用于构建应用程序的映像指定为 your_DockerHub_username/concurrent-scraper,但您将使用您的实际 Docker Hub 用户名。 最后,您指定希望您的应用程序使用端口 5000。

创建部署文件后,您就可以将应用程序部署到集群了。

部署应用程序:

kubectl apply -f app-deployment.yaml

您可以通过运行以下命令来监控部署状态:

kubectl get deployment -w

运行命令后,您将看到如下输出:

OutputNAME      READY   UP-TO-DATE   AVAILABLE   AGE
scraper   0/5     5            0           7s
scraper   1/5     5            1           23s
scraper   2/5     5            2           25s
scraper   3/5     5            3           25s
scraper   4/5     5            4           33s
scraper   5/5     5            5           33s

所有部署都需要几秒钟才能开始运行,但一旦开始运行,您将有五个刮板实例正在运行。 每个实例可以同时抓取 5 页,因此您将能够同时抓取 25 页,从而减少了抓取所有 400 页所需的时间。

要从集群外部访问您的应用程序,您需要创建一个 service。 这个 service 将是一个负载均衡器,它需要一个名为 load-balancer.yaml 的文件。

创建 load-balancer.yaml 文件并打开它:

nano load-balancer.yaml

在 load-balancer.yaml 中写入以下代码:

负载均衡器.yaml

apiVersion: v1
kind: Service
metadata:
  name: load-balancer
  labels:
    app: scraper
spec:
  type: LoadBalancer
  ports:
  - port: 80
    targetPort: 5000
    protocol: TCP
  selector:
    app: scraper

前面块中的大部分代码都是 service 文件的标准代码。 首先,您将服务的名称设置为 load-balancer。 您指定了服务类型,然后使服务可在端口 80 上访问。 最后,您指定此服务用于应用程序 scraper。

现在您已经创建了 load-balancer.yaml 文件,将服务部署到集群。

部署服务:

kubectl apply -f load-balancer.yaml

运行以下命令来监控服务的状态:

kubectl get services -w

运行此命令后,您将看到如下输出,但外部 IP 需要几秒钟才能出现:

OutputNAME            TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
load-balancer   LoadBalancer   10.245.91.92   <pending>     80:30802/TCP   10s
load-balancer   LoadBalancer   10.245.91.92   161.35.252.69   80:30802/TCP   69s

您的服务的 EXTERNAL-IP 和 CLUSTER-IP 将与上述不同。 记下您的 EXTERNAL-IP。 您将在下一节中使用它。

在此步骤中,您将爬虫应用程序部署到 Kubernetes 集群。 在下一步中,您将创建一个客户端应用程序来与新部署的应用程序进行交互。

第 7 步 — 创建客户端应用程序

在此步骤中,您将构建您的客户端应用程序,这将需要以下三个文件:main.js、lowdbHelper.js 和 books.json。 main.js 文件是客户端应用程序的主文件。 它将请求发送到您的应用程序服务器,然后使用您将在 lowdbHelper.js 文件中创建的方法保存检索到的数据。 lowdbHelper.js 文件将数据保存在本地文件中并检索其中的数据。 books.json 文件是保存所有抓取数据的本地文件。

首先回到你的 client 目录:

cd ../client

因为它们小于 main.js,您将首先创建 lowdbHelper.js 和 books.json 文件。

创建并打开一个名为 lowdbHelper.js 的文件:

nano lowdbHelper.js

将以下代码添加到 lowdbHelper.js 文件中:

lowdbHelper.js

const lowdb = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
const adapter = new FileSync('books.json')

在此代码块中,您需要模块 lowdb,然后需要适配器 FileSync,您需要保存和读取数据。 然后,您指示程序将数据存储在名为 books.json 的 JSON 文件中。

将以下代码添加到 lowdbHelper.js 文件的底部:

lowdbHelper.js

. . .
class LowDbHelper {
    constructor() {
        this.db = lowdb(adapter);
    }

    getData() {
        try {
            let data = this.db.getState().books
            return data
        } catch (error) {
            console.log('error', error)
        }
    }

    saveData(arg) {
        try {
            this.db.set('books', arg).write()
            console.log('data saved successfully!!!')
        } catch (error) {
            console.log('error', error)
        }
    }
}

module.exports = { LowDbHelper }

在这里,您创建了一个名为 LowDbHelper 的类。 该类包含以下两个方法:getData()和saveData()。 第一个将检索保存在 books.json 文件中的书籍,第二个会将您的书籍保存到同一文件中。

您完成的 lowdbHelper.js 将如下所示:

lowdbHelper.js

const lowdb = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
const adapter = new FileSync('books.json')

class LowDbHelper {
    constructor() {
        this.db = lowdb(adapter);
    }

    getData() {
        try {
            let data = this.db.getState().books
            return data
        } catch (error) {
            console.log('error', error)
        }
    }

    saveData(arg) {
        try {
            this.db.set('books', arg).write()
            //console.log('data saved successfully!!!')
        } catch (error) {
            console.log('error', error)
        }
    }

}

module.exports = { LowDbHelper }

现在您已经创建了 lowdbHelper.js 文件,是时候创建 books.json 文件了。

创建 books.json 文件并打开它:

nano books.json

添加以下代码:

书籍.json

{
    "books": []
}

books.json 文件由一个具有称为 books 属性的对象组成。 此属性的初始值是一个空数组。 稍后,当您检索书籍时,您的程序将在此处保存它们。

现在您已经创建了 lowdbHelper.js 和 books.json 文件,您将创建 main.js 文件。

创建 main.js 并打开它:

nano main.js

将以下代码添加到 main.js:

main.js

let axios = require('axios')
let ldb = require('./lowdbHelper.js').LowDbHelper
let ldbHelper = new ldb()
let allBooks = ldbHelper.getData()

let server = "http://your_load_balancer_external_ip_address"
let podsWorkDone = []
let booksDetails = []
let errors = []

在这段代码中,您需要 lowdbHelper.js 文件和一个名为 axios 的模块。 您将使用 axios 向您的爬虫发送 HTTP 请求; lowdbHelper.js 文件将保存检索到的书籍,allBooks 变量将存储保存在 books.json 文件中的所有书籍。 在检索任何书之前,此变量将保存一个空数组; server 变量将存储您在上一节中创建的负载均衡器的 EXTERNAL-IP。 确保将其替换为您的唯一 IP。 podsWorkDone 变量将跟踪每个刮板实例已处理的页数。 booksDetails 变量将存储检索到的各个书籍的详细信息,errors 变量将跟踪尝试检索书籍时可能发生的任何错误。

现在我们需要为爬虫过程的每个部分构建一些函数。

将下一个代码块添加到 main.js 文件的底部:

main.js

. . .
function main() {
  let execute = process.argv[2] ? process.argv[2] : 0
  execute = parseInt(execute)
  switch (execute) {
    case 0:
      getBooks()
      break;
    case 1:
      getBooksDetails()
      break;
  }
}

您现在正在创建一个名为 main() 的函数,它由一个 switch 语句组成,该语句将根据传递的输入调用 getBooks() 或 getBooksDetails() 函数。

将 getBooks() 下面的 break; 替换为以下代码:

main.js

. . .
function getBooks() {
  console.log('getting books')
  let data = {
    url: 'http://books.toscrape.com/index.html',
    nrOfPages: 20,
    commands: [
      {
        description: 'get items metadata',
        locatorCss: '.product_pod',
        type: "getItems"
      },
      {
        description: 'go to next page',
        locatorCss: '.next > a:nth-child(1)',
        type: "Click"
      }
    ],
  }
  let begin = Date.now();
  axios.post(`${server}/api/books`, data).then(result => {
    let end = Date.now();
    let timeSpent = (end - begin) / 1000 + "secs";
    console.log(`took ${timeSpent} to retrieve ${result.data.books.length} books`)
    ldbHelper.saveData(result.data.books)
  })
}

在这里,您创建了一个名为 getBooks() 的函数。 此代码将包含抓取所有 20 页所需信息的对象分配给名为 data 的变量。 该对象的 commands 数组中的第一个 command 检索页面上显示的所有 20 本书,第二个 command 单击页面上的下一步按钮,从而使浏览器导航到下一页。 这意味着第一个 command 将重复 20 次,第二个 19 次。 使用 axios 发送到 /api/books 路由的 POST 请求会将此对象发送到您的应用程序服务器,然后爬虫将检索第一个显示的每本书的基本元数据books.toscrape 网站的 20 页。 然后它使用 lowdbHelper.js 文件中的 LowDbHelper 类保存检索到的数据。

现在编写第二个函数,它将处理各个页面上更具体的书籍数据。

将 getBooksDetails() 下面的 break; 替换为以下代码:

main.js

. . .

function getBooksDetails() {
  let begin = Date.now()
  for (let j = 0; j < allBooks.length; j++) {
    let data = {
      url: allBooks[j].url,
      nrOfPages: 1,
      commands: [
        {
          description: 'get item details',
          locatorCss: 'article.product_page',
          type: "getItemDetails"
        }
      ]
    }
    sendRequest(data, function (result) {
      parseResult(result, begin)
    })
  }
}

getBooksDetails() 函数将遍历包含所有书籍的 allBooks 数组,并为该数组中的每本书创建一个对象,该对象将包含抓取页面所需的信息。 创建此对象后,它会将其传递给 sendRequest() 函数。 然后它将使用 sendRequest() 函数返回的值并将该值传递给名为 parseResult() 的函数。

将以下代码添加到 main.js 文件的底部:

main.js

. . .

async function sendRequest(payload, cb) {
  let book = payload
  try {
    await axios.post(`${server}/api/booksDetails`, book).then(response => {
      if (Object.keys(response.data).includes('error')) {
        let res = {
          url: book.url,
          error: response.data.error
        }
        cb(res)
      } else {
        cb(response.data)
      }
    })
  } catch (error) {
    console.log(error)
    let res = {
      url: book.url,
      error: error
    }
    cb({ res })
  }
}

现在您正在创建一个名为 sendRequest() 的函数。 您将使用此功能将所有 400 个请求发送到包含您的爬虫的应用程序服务器。 该代码将包含抓取页面所需信息的对象分配给名为 book 的变量。 然后在 POST 请求中将此对象发送到应用程序服务器上的 /api/booksDetails 路由。 响应被发送回 getBooksDetails() 函数。

现在创建 parseResult() 函数。

将以下代码添加到 main.js 文件的底部:

main.js

. . .

function parseResult(result, begin){
  try {
    let end = Date.now()
    let timeSpent = (end - begin) / 1000 + "secs ";
    if (!Object.keys(result).includes("error")) {
      let wasSuccessful = Object.keys(result.booksDetails).length > 0 ? true : false
      if (wasSuccessful) {
        let podID = result.hostname
        let podsIDs = podsWorkDone.length > 0 ? podsWorkDone.map(pod => { return Object.keys(pod)[0]}) : []
        if (!podsIDs.includes(podID)) {
          let podWork = {}
          podWork[podID] = 1
          podsWorkDone.push(podWork)
        } else {
          for (let pwd = 0; pwd < podsWorkDone.length; pwd++) {
            if (Object.keys(podsWorkDone[pwd]).includes(podID)) {
              podsWorkDone[pwd][podID] += 1
              break
            }
          }
        }
        booksDetails.push(result)
      } else {
        errors.push(result)
      }
    } else {
      errors.push(result)
    }
    console.log('podsWorkDone', podsWorkDone, ', retrieved ' + booksDetails.length + " books, ",
      "took " + timeSpent + ", ", "used " + podsWorkDone.length + " pods", " errors: " + errors.length)
    saveBookDetails()
  } catch (error) {
    console.log(error)
  }
}

parseResult() 接收函数 sendRequest() 的 result 包含缺少的书籍详细信息。 然后它解析 result 并检索处理请求的 pod 的 hostname 并将其分配给 podID 变量。 它检查这个 podID 是否已经是 podsWorkDone 数组的一部分; 如果不是,它会将 podId 添加到 podsWorkDone 数组并将完成的工作数设置为 1。 但如果是这样,它会将这个 pod 完成的工作数量增加 1。 然后代码会将result添加到booksDetails数组中,输出getBooksDetails()函数的整体进度,然后调用saveBookDetails()函数。

现在添加以下代码来构建 saveBookDetails() 函数:

main.js

. . .

function saveBookDetails() {
  let books = ldbHelper.getData()
  for (let b = 0; b < books.length; b++) {
    for (let d = 0; d < booksDetails.length; d++) {
      let item = booksDetails[d]
      if (books[b].url === item.url) {
        books[b].booksDetails = item.booksDetails
        break
      }
    }
  }
  ldbHelper.saveData(books)
}

main()

saveBookDetails() 使用 LowDbHelper 类获取存储在 books.json 文件中的所有书籍,并将其分配给名为 books 的变量。 然后它遍历 books 和 booksDetails 数组,看看它是否在两个数组中找到具有相同 url 属性的元素。 如果是这样,它将添加 booksDetails 数组中元素的 booksDetails 属性,并将其分配给 books 数组中的元素。 然后它将用此函数中循环的 books 数组的内容覆盖 books.json 文件的内容。 创建 saveBookDetails() 函数后,代码将调用 main() 函数以使该文件可用。 否则,执行此文件不会产生预期的结果。

您完成的 main.js 文件将如下所示:

main.js

let axios = require('axios')
let ldb = require('./lowdbHelper.js').LowDbHelper
let ldbHelper = new ldb()
let allBooks = ldbHelper.getData()

let server = "http://your_load_balancer_external_ip_address"
let podsWorkDone = []
let booksDetails = []
let errors = []

function main() {
  let execute = process.argv[2] ? process.argv[2] : 0
  execute = parseInt(execute)
  switch (execute) {
    case 0:
      getBooks()
      break;
    case 1:
      getBooksDetails()
      break;
  }
}

function getBooks() {
  console.log('getting books')
  let data = {
    url: 'http://books.toscrape.com/index.html',
    nrOfPages: 20,
    commands: [
      {
        description: 'get items metadata',
        locatorCss: '.product_pod',
        type: "getItems"
      },
      {
        description: 'go to next page',
        locatorCss: '.next > a:nth-child(1)',
        type: "Click"
      }
    ],
  }
  let begin = Date.now();
  axios.post(`${server}/api/books`, data).then(result => {
    let end = Date.now();
    let timeSpent = (end - begin) / 1000 + "secs";
    console.log(`took ${timeSpent} to retrieve ${result.data.books.length} books`)
    ldbHelper.saveData(result.data.books)
  })
}

function getBooksDetails() {
  let begin = Date.now()
  for (let j = 0; j < allBooks.length; j++) {
    let data = {
      url: allBooks[j].url,
      nrOfPages: 1,
      commands: [
        {
          description: 'get item details',
          locatorCss: 'article.product_page',
          type: "getItemDetails"
        }
      ]
    }
    sendRequest(data, function (result) {
      parseResult(result, begin)
    })
  }
}

async function sendRequest(payload, cb) {
  let book = payload
  try {
    await axios.post(`${server}/api/booksDetails`, book).then(response => {
      if (Object.keys(response.data).includes('error')) {
        let res = {
          url: book.url,
          error: response.data.error
        }
        cb(res)
      } else {
        cb(response.data)
      }
    })
  } catch (error) {
    console.log(error)
    let res = {
      url: book.url,
      error: error
    }
    cb({ res })
  }
}

function parseResult(result, begin){
  try {
    let end = Date.now()
    let timeSpent = (end - begin) / 1000 + "secs ";
    if (!Object.keys(result).includes("error")) {
      let wasSuccessful = Object.keys(result.booksDetails).length > 0 ? true : false
      if (wasSuccessful) {
        let podID = result.hostname
        let podsIDs = podsWorkDone.length > 0 ? podsWorkDone.map(pod => { return Object.keys(pod)[0]}) : []
        if (!podsIDs.includes(podID)) {
          let podWork = {}
          podWork[podID] = 1
          podsWorkDone.push(podWork)
        } else {
          for (let pwd = 0; pwd < podsWorkDone.length; pwd++) {
            if (Object.keys(podsWorkDone[pwd]).includes(podID)) {
              podsWorkDone[pwd][podID] += 1
              break
            }
          }
        }
        booksDetails.push(result)
      } else {
        errors.push(result)
      }
    } else {
      errors.push(result)
    }
    console.log('podsWorkDone', podsWorkDone, ', retrieved ' + booksDetails.length + " books, ",
      "took " + timeSpent + ", ", "used " + podsWorkDone.length + " pods,", " errors: " + errors.length)
    saveBookDetails()
  } catch (error) {
    console.log(error)
  }
}

function saveBookDetails() {
  let books = ldbHelper.getData()
  for (let b = 0; b < books.length; b++) {
    for (let d = 0; d < booksDetails.length; d++) {
      let item = booksDetails[d]
      if (books[b].url === item.url) {
        books[b].booksDetails = item.booksDetails
        break
      }
    }
  }
  ldbHelper.saveData(books)
}

main()

您现在已经创建了客户端应用程序,并准备好与 Kubernetes 集群中的爬虫进行交互。 在下一步中,您将使用此客户端应用程序和应用程序服务器来抓取所有 400 本书。

第 8 步 — 抓取网站

现在您已经创建了客户端应用程序和服务器端抓取应用程序,是时候抓取 books.toscrape 网站了。 您将首先检索所有 400 本书的元数据。 然后,您将检索其页面上每本书的缺失详细信息,并实时监控每个 pod 处理了多少请求。

在 ./client 目录下,运行以下命令。 这将检索所有 400 本书的基本元数据并将其保存到您的 books.json 文件中:

npm start 0

您将收到以下输出:

Outputgetting books
took 40.323secs to retrieve 400 books

检索所有 20 页上显示的书籍的元数据需要 40.323 秒,尽管此值可能因您的互联网速度而异。

现在,您想要检索存储在 books.json 文件中的每本书的缺失详细信息,同时还要监控每个 pod 处理的请求数。

再次运行 npm start 以检索详细信息:

npm start 1

您将收到这样的输出,但具有不同的 pod ID:

Output. . .
podsWorkDone [ { 'scraper-59cd578ff6-z8zdd': 69 },
  { 'scraper-59cd578ff6-528gv': 96 },
  { 'scraper-59cd578ff6-zjwfg': 94 },
  { 'scraper-59cd578ff6-nk6fr': 80 },
  { 'scraper-59cd578ff6-h2n8r': 61 } ] , retrieved 400 books,  took 56.875secs ,  used 5 pods,  errors: 0

使用 Kubernetes 检索所有 400 本书缺失的详细信息不到 60 秒。 每个包含刮板的 pod 至少刮了 60 页。 与使用一台机器相比,这代表了巨大的性能提升。

现在将 Kubernetes 集群中的 pod 数量翻倍,以进一步加快检索速度:

kubectl scale deployment scraper --replicas=10

pod 可用之前需要一些时间,因此请至少等待 10 秒,然后再运行下一个命令。

重新运行 npm start 以获取缺失的详细信息:

npm start 1

您将收到类似于以下内容但具有不同 pod ID 的输出:

Output. . .
podsWorkDone [ { 'scraper-59cd578ff6-z8zdd': 38 },
  { 'scraper-59cd578ff6-6jlvz': 47 },
  { 'scraper-59cd578ff6-g2mxk': 36 },
  { 'scraper-59cd578ff6-528gv': 41 },
  { 'scraper-59cd578ff6-bj687': 36 },
  { 'scraper-59cd578ff6-zjwfg': 47 },
  { 'scraper-59cd578ff6-nl6bk': 34 },
  { 'scraper-59cd578ff6-nk6fr': 33 },
  { 'scraper-59cd578ff6-h2n8r': 38 },
  { 'scraper-59cd578ff6-5bw2n': 50 } ] , retrieved 400 books,  took 34.925secs ,  used 10 pods,  errors: 0

在将 pod 数量翻倍后,抓取所有 400 个页面所需的时间几乎减少了一半。 检索所有丢失的详细信息不到 35 秒。

在本节中,您向部署在 Kubernetes 集群中的应用程序服务器发送了 400 个请求,并在短时间内抓取了 400 个单独的 URL。 您还增加了集群中的 pod 数量以进一步提高性能。

结论

在本指南中,您使用 Puppeteer、Docker 和 Kubernetes 构建了一个能够快速抓取 400 个网页的并发 Web 抓取工具。 为了与爬虫交互,您构建了一个 Node.js 应用程序,它使用 axios 向包含爬虫的服务器发送多个 HTTP 请求。

Puppeteer 包括许多附加功能。 如果你想了解更多,查看Puppeteer的官方文档。 要了解有关 Node.js 的更多信息,查看我们关于如何在 Node.js 中编码的教程系列。