如何使用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

现在创建三个名为 serverclientk8s 的子目录:

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"
  }
}

在这里,您更改了 mainscripts 属性,并且还编辑了 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"
}

在这里,您更改了 mainscripts 属性。

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

npm install axios lowdb --save

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

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

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

在这一步和第 4 步中,您将在服务器端创建爬虫。 此应用程序将包含两个文件:puppeteerManager.jsserver.jspuppeteerManager.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 具有以下属性:descriptionlocatorCsstypedescription 告诉你 command 做了什么,locatorCssDOM 中找到合适的元素,type 选择具体动作。
  • nrOfPages:此属性将保存一个整数,您的应用程序将使用它来确定 commands 应该重复多少次。 例如,books.toscrape.com 每页只显示 20 本书,因此要在所有 20 页上获得所有 400 本书,您将使用此属性重复现有的 commands 20 次.

在此代码块中,您还将接收到的对象属性分配给构造函数变量 urlexistingCommandsnrOfPages。 然后,您创建了两个附加变量:allBooksbooksDetails。 您将使用变量 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()
    }

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

while 循环中,代码遍历 commands 数组中的每个 command。 首先,您正在使用 page.frames() 方法 创建一个包含所有附加到页面的框架的数组。 它使用 、frame.waitForSelector() 方法locatorCss 属性在 pageframe 对象中搜索 DOM 元素。 如果找到一个元素,它将调用 executeCommand() 方法并将 framecommand 对象作为参数传递。 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 语句组成,具有以下情况:clickgetItemsgetItemDetails

定义 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

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

我们的 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 worldhostname。 这个 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 detailsbooksDetails 对象包含 [ 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.jsserver.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-IPCLUSTER-IP 将与上述不同。 记下您的 EXTERNAL-IP。 您将在下一节中使用它。

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

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

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

首先回到你的 client 目录:

cd ../client

因为它们小于 main.js,您将首先创建 lowdbHelper.jsbooks.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.jsbooks.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 的变量。 然后它遍历 booksbooksDetails 数组,看看它是否在两个数组中找到具有相同 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 中编码的教程系列。