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

作为Write for DOnations计划的一部分,作者选择了免费和开源基金来接受捐赠

介绍

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

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

在本教程中,您将使用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检查数据的结构以及为什么并发抓取是最佳解决方案。

book.toscrape 主页标题

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

滚动到页面底部。

book.toscrape 主页页脚

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

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

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

带有开发工具的books.toscrape主页

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

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

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

book.toscrape 书页与开发工具

您想要提取的缺失信息同样<article>位于class属性等于标签product_page

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

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

步骤 2 — 创建项目根目录

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

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

  • mkdir concurrent-webscraper

导航到目录:

  • cd ./concurrent-webscraper

现在创建三个子目录命名serverclient以及k8s

  • mkdir server client k8s

导航到server目录:

  • cd ./server

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

运行初始化命令:

  • npm init

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

打开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是一个基于HTTPPromise 的浏览器和 Node.js 客户端。您将使用此模块向我们的抓取工具中的端点发送异步HTTP请求REST以与之交互;lowdb是一个用于 Node.js 和浏览器的小型 JSON 数据库,您将使用它来存储抓取的数据。

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

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

在此步骤和步骤 4 中,您将在服务器端创建刮刀。此应用程序将包含两个文件:puppeteerManager.jsserver.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具有以下属性:descriptionlocatorCss,和typedescription告诉你 做什么,在commandlocatorCss 找到合适的元素DOM,然后type选择特定的动作。
  • nrOfPages:此属性将保存一个整数,您的应用程序将使用它来确定commands应重复多少次例如,books.toscrape.com 每页仅显示 20 本书,因此要获取所有 20 页上的所有 400 本书,您将使用此属性重复现有的commands20 次。

在此代码块,还分配接收的对象属性的构造函数的变量urlexistingCommandsnrOfPages然后,您创建了两个额外的变量:allBooksbooksDetails您将使用该变量allBooks来存储所有检索到的书籍的元数据,并使用该变量booksDetails来存储给定的单个书籍丢失的书籍详细信息。

您现在已准备好向PuppeteerManager该类添加一些方法这个类有以下几种方法:runPuppeteer()executeCommand()sleep()getAllBooks(),和getBooksDetails()因为这些方法构成了刮板应用程序的核心,所以值得一一检查它们。

编码runPuppeteer()方法

类中的第一个方法PuppeteerManagerrunPuppeteer(). 这将需要 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使用条件逻辑,您指出如果要抓取的页面数大于 1,则代码应循环遍历nrOfPages,并将existingCommands每个页面的 加入到commands数组中。然而,当它到达最后一页,它不会添加了最后command的在existingCommands阵列到commands阵列因为最后command点击的翻页按钮。

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

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

puppeteerManager.js
. . .
    async runPuppeteer() {
        . . .

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

        . . .
    }

在此代码块中,您browser使用内置puppeteer.launch()方法创建了一个实例您指定实例在headless模式下运行这是该项目的默认选项和必要选项,因为您正在 Kubernetes 上运行该应用程序。在创建没有图形用户界面的浏览器时,接下来的两个参数是标准的。最后,您page使用Puppeteer 的browser.newPage()方法创建了一个新对象.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')event拦截在浏览器上下文中显示消息的任何尝试page给定然后导航到一个url使用page.goto()方法。

现在向您的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循环,代码经过每一个commandcommands排列。首先,您要创建连接到使用页面所有框架的阵列page.frames()方法它搜索在DOM元素frame的对象page使用frame.waitForSelector()方法locatorCss属性。如果找到一个元素,它会调用该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.jsexecuteCommand()

编码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具有下列情况的声明:clickgetItems,和getItemDetails

定义click案例。

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

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

您的代码将clickcommand.typeequals触发案例click此代码块负责单击下一步按钮以在分页的书籍列表中移动。

现在编写下case一条语句。

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

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

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

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

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

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

getItemDetails时候情况会触发command.type等于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,这只会说“你好世界”和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,如果不是,它将调用方法getBooksHandler()此方法将创建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() })
  }
});

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

然后程序将在响应正文中将检索到的数据返回给客户端。响应对象将包含一个字符串 , msgsay retrieved book details,一个字符串, hostname,它将返回运行应用程序的机器的名称,以及另一个字符串,url,包含项目页面的 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运行次数减一,然后将新检索到的数据返回给/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))
  }

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

文件
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 所需的包,然后创建了 app 目录。您将抓取工具的package.json文件复制到 app 目录并安装了package.json文件中指定的依赖项最后,您捆绑了应用程序源,在端口上公开应用程序5000,并选择server.js作为入口文件。

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

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

  • nano .dockerignore

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

./server/.dockerignore
node_modules
npm-debug.log

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

登录 Docker 中心:

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

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

  • 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

通过发送POST请求/api/books路线,你将收到包含响应msg说法retrieved books,一个hostname类似于一个在以前的请求,并且books所显示的第一页上包含所有20本书阵列books.toscrape网站。您应该会看到这样的输出,但带有您机器的唯一 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"}, [ . . . ] }]}

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

  • 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

通过向路由发送POST请求,/api/booksDetails您将收到一个响应,其中包含一个msgsay retrieved book details,一个booksDetails包含这本书缺失细节的对象,一个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

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

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

部署应用程序:

  • kubectl apply -f app-deployment.yaml

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

  • kubectl get deployment -w

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

Output
NAME 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:

Output
NAME 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.jslowdbHelper.js,和books.jsonmain.js文件是客户端应用程序的主文件。它向您的应用程序服务器发送请求,然后使用您将在lowdbHelper.js文件中创建的方法保存检索到的数据lowdbHelper.js文件将数据保存在本地文件中并检索其中的数据。books.json文件是您将保存所有抓取数据的本地文件。

首先回到你的client目录:

  • cd ../client

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

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

  • nano lowdbHelper.js

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

低数据库助手.js
const lowdb = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
const adapter = new FileSync('books.json')

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

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

低数据库助手.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将如下所示:

低数据库助手.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

主文件
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变量将跟踪尝试检索图书时可能发生的任何错误。

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

将下一个代码块添加到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()函数。

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

主文件
. . .
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所述第一commandcommands此对象检索所有20本书籍页面上显示的阵列,并且第二command次点击页面上的下一个按钮,从而使浏览器导航到下一个页面。这意味着第一个command将重复 20 次,第二个将重复 19 次。POST,使用请求发送axios/api/books路由将这个对象发送到应用程序服务器,然后将刮板检索第一个20页的显示的每本书的基本的元数据books.toscrape网站。然后它使用LowDbHelperlowdbHelper.js文件内的类

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

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

主文件
. . .

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文件底部

主文件
. . .

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文件底部

主文件
. . .

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() receives the result of the function sendRequest() containing missing book details. It then parses the result and retrieves the hostname of the pod that handled the request and assigns it to the podID variable. It checks if this podID is already part of the podsWorkDone array; if it isn’t, it will add the podId to the podsWorkDone array and set the number of work done to 1. But if it is, it will increase the number of work done by this pod by 1. The code will then add the result to the booksDetails array, output the overall progress of the getBooksDetails() function, and then call the saveBookDetails() function.

Now add the following code to build the saveBookDetails() function:

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() gets all the books stored in the books.json file using the LowDbHelper class and assigns it to a variable called books. It then loops through the books and booksDetails arrays to see if it finds elements in both arrays with the same url property. If it does, it will add the booksDetails property of the element in the booksDetails array and assign it to the element in the books array. Then it will overwrite the contents of the books.json file with the contents of the books array looped in this function. After creating the saveBookDetails() function, the code will call the main() function to make this file usable. Otherwise, executing this file wouldn’t produce the desired outcome.

Your completed main.js file will look like this:

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()

You have now created the client application and are ready to interact with the scraper in your Kubernetes cluster. In the next step, you will use this client application and the application server to scrape all 400 books.

Step 8 — Scraping the Website

Now that you have created the client application and the server-side scraper application it’s time to scrape the books.toscrape website. You will first retrieve the metadata for all 400 books. Then you will retrieve the missing details for every single book on its page and monitor how many requests each pod has handled in real-time .

In the ./client directory, run the following command. This will retrieve the basic metadata for all 400 books and save it to your books.json file:

  • npm start 0

You will receive the following output:

Output
getting books took 40.323secs to retrieve 400 books

Retrieving the metadata for the books displayed on all 20 pages took 40.323 seconds, although this value may differ depending on your internet speed.

Now you want to retrieve the missing details for every book stored in the books.json file while also monitoring the number of requests that each pod handles.

Run npm start again to retrieve the details:

  • npm start 1

You will receive an output like this but with different pod IDs:

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

Retrieving the missing details for all 400 books using Kubernetes took less than 60 seconds. Each pod containing the scraper scraped at least 60 pages. This represents a massive performance increase over using one machine.

Now double the number of pods in your Kubernetes cluster to accelerate the retrieval even more:

  • kubectl scale deployment scraper --replicas=10

It will take a few moments before the pods are available, so wait at least 10 seconds before running the next command.

Rerun npm start to get the missing details:

  • npm start 1

You will receive an output similar to the following but with different pod IDs:

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

After doubling the number of pods, the time needed to scrape all 400 pages reduced almost by half. It took less than 35 seconds to retrieve all the missing details.

In this section, you sent 400 requests to the application server deployed in your Kubernetes cluster and scraped 400 individual URLs in a short amount of time. You also increased the number of pods in your cluster to improve performance even more.

Conclusion

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

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

觉得文章有用?

点个广告表达一下你的爱意吧 !😁