如何使用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,专门用于测试抓取应用程序。 抓取任何其他域不在本教程的范围内。
先决条件
要学习本教程,您需要一台具有以下功能的机器:
- 安装了 Docker。 按照我们关于 如何安装和使用 Docker 的教程进行操作。 Docker 的网站 提供了 macOS 和 Windows 等其他操作系统的安装说明。
- Docker Hub 上的一个帐户,用于存储您的 Docker 映像。
- 一个 Kubernetes 1.17+ 集群,您的连接配置设置为
kubectl
默认值。 要在 DigitalOcean 上创建 Kubernetes 集群,请阅读我们的 Kubernetes 快速入门 。 要连接到集群,请阅读 如何连接到 DigitalOcean Kubernetes 集群。 kubectl
已安装。 按照 this tutorial on getting started with Kubernetes: A kubectl Cheat Sheet 进行安装。- Node.js 安装在您的开发机器上。 本教程在 Node.js 版本 12.18.3 和 npm 版本 6.14.6 上进行了测试。 按照本指南在 macOS 上安装 Node.js,或 按照本指南在各种 Linux 发行版上安装 Node.js 。
- 如果您使用的是 DigitalOcean Kubernetes,那么您还需要一个个人访问令牌。 要创建一个,您可以按照 我们关于如何创建个人访问令牌 的指南进行操作。 将此令牌保存在安全的地方; 它提供对您帐户的完全访问权限。
第 1 步 — 分析目标网站
在编写任何代码之前,请在 Web 浏览器中导航到 books.toscrape。 检查数据的结构以及为什么并发抓取是最佳解决方案。
请注意,该网站上有 1,000 本书,但每个页面仅显示 20 本书。
滚动到页面底部。
本网站的内容是分页的,共有50页。 因为每页显示 20 本书,而您只想抓取前 400 本书,所以您将只检索前 20 页上显示的每本书的标题、价格、评级和 URL。
整个过程应该不到 1 分钟。
打开浏览器的开发工具并检查页面上的第一本书。 您将看到以下内容:
每本书都在 <section>
标签内,每本书都列在自己的 <li>
标签下。 在每个 <li>
标记内都有一个 <article>
标记,其 class
属性等于 product_pod
。 这是我们要抓取的元素。
获取前 20 页每本书的元数据并存储后,您将拥有一个包含 400 本书的本地数据库。 但是,由于有关该书的更多详细信息存在于其自己的页面上,因此您需要使用每本书元数据中的 URL 浏览 400 个额外的页面。 然后,您将检索所需的缺失图书详细信息,并将此数据添加到本地数据库。 您要检索的缺失数据是描述、UPC(通用图书代码)、评论数量和图书的可用性。 使用一台机器浏览 400 个页面可能需要 7 分钟以上,这就是为什么您需要 Kubernetes 将工作分配到多台机器上的原因。
现在单击主页上第一本书的链接,这将打开该书的详细信息页面。 再次打开浏览器的开发工具并检查页面。
同样,您要提取的缺失信息位于 <article>
标记内,其 class
属性等于 product_page
。
要与集群中的爬虫进行交互,您需要创建一个能够向我们的 Kubernetes 集群发送 HTTP
请求的客户端应用程序。 您将首先对该项目的服务器端进行编码,然后再对客户端进行编码。
在本节中,您已经查看了爬虫将检索哪些信息以及为什么需要将此爬虫部署到 Kubernetes 集群。 在下一部分中,您将为客户端和服务器应用程序创建目录。
第 2 步 — 创建项目根目录
在此步骤中,您将创建项目的目录结构。 然后,您将为您的客户端和服务器应用程序初始化一个 Node.js 项目。
打开一个终端窗口并创建一个名为 concurrent-webscraper
的新目录:
mkdir concurrent-webscraper
导航到目录:
cd ./concurrent-webscraper
现在创建三个名为 server
、client
和 k8s
的子目录:
mkdir server client k8s
导航到 server
目录:
cd ./server
创建一个新的 Node.js 项目。 运行 npm 的 init
命令将创建一个 package.json
文件,它将帮助您管理依赖项和元数据。
运行初始化命令:
npm init
要接受默认值,请按 ENTER
到所有提示; 或者,您可以个性化您的回复。 您可以在我们的教程的 步骤一中阅读有关 npm 初始化设置的更多信息,如何将 Node.js 模块与 npm 和 package.json 一起使用。
打开 package.json
文件并编辑它:
nano package.json
您需要修改 main
属性,在 scripts
指令中添加一些信息,然后创建 dependencies
指令。
用突出显示的代码替换文件中的内容:
./server/package.json
{ "name": "server", "version": "1.0.0", "description": "", "main": "server.js", "scripts": { "start": "node server.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "body-parser": "^1.19.0", "express": "^4.17.1", "puppeteer": "^3.0.0" } }
在这里,您更改了 main
和 scripts
属性,并且还编辑了 dependencies
属性。 因为服务器应用程序将在 Docker 容器中运行,所以您不需要运行 npm install
命令,该命令通常在初始化之后自动添加每个依赖项到 package.json
。
保存并关闭文件。
导航到您的 client
目录:
cd ../client
创建另一个 Node.js 项目:
npm init
按照相同的程序接受默认设置或自定义您的响应。
打开 package.json
文件并编辑它:
nano package.json
用突出显示的代码替换文件中的内容:
./client/package.json
{ "name": "client", "version": "1.0.0", "description": "", "main": "main.js", "scripts": { "start": "node main.js" }, "author": "", "license": "ISC" }
在这里,您更改了 main
和 scripts
属性。
这次,使用 npm 安装必要的依赖项:
npm install axios lowdb --save
在此代码块中,您已经安装了 axios
和 lowdb
。 axios
是一个基于 Promise 的 HTTP
客户端,用于浏览器和 Node.js。 您将使用此模块向我们的爬虫中的 REST
端点发送异步 HTTP
请求以与之交互; lowdb
是用于 Node.js 和浏览器的小型 JSON 数据库,您将使用它来存储抓取的数据。
在这一步中,您创建了一个项目目录并为您的应用程序服务器初始化了一个 Node.js 项目,该项目将包含刮板; 然后,您对将与应用程序服务器交互的客户端应用程序执行相同的操作。 您还为 Kubernetes 配置文件创建了一个目录。 在下一步中,您将开始构建应用程序服务器。
第 3 步 — 构建第一个 Scraper 文件
在这一步和第 4 步中,您将在服务器端创建爬虫。 此应用程序将包含两个文件:puppeteerManager.js
和 server.js
。 puppeteerManager.js
文件将创建和管理浏览器会话,server.js
文件将接收抓取一个或多个网页的请求。 反过来,这些请求将调用 puppeteerManager.js
内部的方法,该方法将抓取给定的网页并返回抓取的数据。 在此步骤中,您将创建 puppeteerManager.js
文件。 在第 4 步中,您将创建 server.js
文件。
首先,返回服务器目录并创建一个名为 puppeteerManager.js
的文件。
导航到 server
文件夹:
cd ../server
使用您喜欢的文本编辑器创建并打开 puppeteerManager.js
文件:
nano puppeteerManager.js
您的 puppeteerManager.js
文件将包含一个名为 PuppeteerManager
的类,该类将创建和管理一个 Puppeteer
浏览器实例。 您将首先创建此类,然后向其添加构造函数。
将以下代码添加到您的 puppeteerManager.js
文件中:
puppeteerManager.js
class PuppeteerManager { constructor(args) { this.url = args.url this.existingCommands = args.commands this.nrOfPages = args.nrOfPages this.allBooks = []; this.booksDetails = {} } } module.exports = { PuppeteerManager }
在第一个代码块中,您创建了 PuppeteerManager
类并向其添加了 构造函数。 构造函数期望接收包含以下属性的对象:
url
:此属性将包含一个字符串,它将是您要抓取的页面的地址。commands
:该属性将保存一个数组,为浏览器提供指令。 例如,它将引导浏览器单击按钮或解析特定的DOM
元素。 每个command
具有以下属性:description
、locatorCss
和type
。description
告诉你command
做了什么,locatorCss
在DOM
中找到合适的元素,type
选择具体动作。nrOfPages
:此属性将保存一个整数,您的应用程序将使用它来确定commands
应该重复多少次。 例如,books.toscrape.com 每页只显示 20 本书,因此要在所有 20 页上获得所有 400 本书,您将使用此属性重复现有的commands
20 次.
在此代码块中,您还将接收到的对象属性分配给构造函数变量 url
、existingCommands
和 nrOfPages
。 然后,您创建了两个附加变量:allBooks
和 booksDetails
。 您将使用变量 allBooks
来存储所有检索到的书籍的元数据,并使用变量 booksDetails
来存储给定的单个书籍的缺失书籍详细信息。
您现在已准备好向 PuppeteerManager
类添加一些方法。 此类将具有以下方法:runPuppeteer()
、executeCommand()
、sleep()
、getAllBooks()
和 getBooksDetails()
。 因为这些方法构成了爬虫应用程序的核心,所以值得一一研究。
编码 runPuppeteer()
方法
PuppeteerManager
类中的第一个方法是 runPuppeteer()
。 这将需要 Puppeteer 模块并启动您的浏览器实例。
在 PuppeteerManager
类的底部,添加以下代码:
puppeteerManager.js
. . . async runPuppeteer() { const puppeteer = require('puppeteer') let commands = [] if (this.nrOfPages > 1) { for (let i = 0; i < this.nrOfPages; i++) { if (i < this.nrOfPages - 1) { commands.push(...this.existingCommands) } else { commands.push(this.existingCommands[0]) } } } else { commands = this.existingCommands } console.log('commands length', commands.length) }
在此代码块中,您创建了 runPuppeteer()
方法。 首先,您需要 puppeteer
模块,然后创建一个变量,该变量以一个名为 commands
的空数组开头。 使用条件逻辑,您声明如果要抓取的页面数大于一,代码应循环通过 nrOfPages
,并将每个页面的 existingCommands
添加到 [ X186X] 数组。 但是,当它到达最后一页时,它不会将 existingCommands
数组中的最后一个 command
添加到 commands
数组中,因为最后一个 command
单击下一页按钮。
下一步是创建浏览器实例。
在刚刚创建的 runPuppeteer()
方法的底部,添加以下代码:
puppeteerManager.js
. . . async runPuppeteer() { . . . const browser = await puppeteer.launch({ headless: true, args: [ "--no-sandbox", "--disable-gpu", ] }); let page = await browser.newPage() . . . }
在这段代码中,您使用 内置的 puppeteer.launch() 方法 创建了一个 browser
实例。 您指定实例在 headless
模式下运行。 这是此项目的默认选项和必需选项,因为您在 Kubernetes 上运行应用程序。 在创建没有图形用户界面的浏览器时,接下来的两个参数是标准的。 最后,您使用 Puppeteer 的 browser.newPage() 方法 创建了一个新的 page
对象。 .launch()
方法返回一个 Promise,它需要 await 关键字。
您现在已准备好向新的 page
对象添加一些行为,包括它将如何导航 URL。
在 runPuppeteer()
方法的底部,添加以下代码:
puppeteerManager.js
. . . async runPuppeteer() { . . . await page.setRequestInterception(true); page.on('request', (request) => { if (['image'].indexOf(request.resourceType()) !== -1) { request.abort(); } else { request.continue(); } }); await page.on('console', msg => { for (let i = 0; i < msg._args.length; ++i) { msg._args[i].jsonValue().then(result => { console.log(result); }) } }); await page.goto(this.url); . . . }
在此代码块中,page
对象使用 Puppeteer 的 page.setRequestInterception() 方法 拦截所有请求,如果请求是加载 image
,它会阻止从加载图像,从而减少加载网页所需的时间。 然后 page
对象使用 Puppeteer 的 page.on('console') 事件 拦截任何在浏览器上下文中显示消息的尝试。 然后 page
使用 page.goto() 方法导航到给定的 url
。
现在向您的 page
对象添加更多行为,这些行为将控制它如何在 DOM 中查找元素并在它们上运行命令。
在 runPuppeteer()
方法的底部添加以下代码:
puppeteerManager.js
. . . async runPuppeteer() { . . . let timeout = 6000 let commandIndex = 0 while (commandIndex < commands.length) { try { console.log(`command ${(commandIndex + 1)}/${commands.length}`) let frames = page.frames() await frames[0].waitForSelector(commands[commandIndex].locatorCss, { timeout: timeout }) await this.executeCommand(frames[0], commands[commandIndex]) await this.sleep(1000) } catch (error) { console.log(error) break } commandIndex++ } console.log('done') await browser.close() }
在此代码块中,您创建了两个变量,timeout
和 commandIndex
。 第一个变量将限制代码在网页上等待元素的时间,第二个变量控制如何循环遍历 commands
数组。
在 while
循环中,代码遍历 commands
数组中的每个 command
。 首先,您正在使用 page.frames() 方法 创建一个包含所有附加到页面的框架的数组。 它使用 、frame.waitForSelector() 方法 和 locatorCss
属性在 page
的 frame
对象中搜索 DOM 元素。 如果找到一个元素,它将调用 executeCommand()
方法并将 frame
和 command
对象作为参数传递。 executeCommand
返回后,调用sleep()
方法,让代码等待1秒再执行下一个command
。 最后,当没有更多命令时,browser
实例关闭。
这完成了您的 runPuppeteer()
方法。 此时,您的 puppeteerManager.js
文件应如下所示:
puppeteerManager.js
class PuppeteerManager { constructor(args) { this.url = args.url this.existingCommands = args.commands this.nrOfPages = args.nrOfPages this.allBooks = []; this.booksDetails = {} } async runPuppeteer() { const puppeteer = require('puppeteer') let commands = [] if (this.nrOfPages > 1) { for (let i = 0; i < this.nrOfPages; i++) { if (i < this.nrOfPages - 1) { commands.push(...this.existingCommands) } else { commands.push(this.existingCommands[0]) } } } else { commands = this.existingCommands } console.log('commands length', commands.length) const browser = await puppeteer.launch({ headless: true, args: [ "--no-sandbox", "--disable-gpu", ] }); let page = await browser.newPage() await page.setRequestInterception(true); page.on('request', (request) => { if (['image'].indexOf(request.resourceType()) !== -1) { request.abort(); } else { request.continue(); } }); await page.on('console', msg => { for (let i = 0; i < msg._args.length; ++i) { msg._args[i].jsonValue().then(result => { console.log(result); }) } }); await page.goto(this.url); let timeout = 6000 let commandIndex = 0 while (commandIndex < commands.length) { try { console.log(`command ${(commandIndex + 1)}/${commands.length}`) let frames = page.frames() await frames[0].waitForSelector(commands[commandIndex].locatorCss, { timeout: timeout }) await this.executeCommand(frames[0], commands[commandIndex]) await this.sleep(1000) } catch (error) { console.log(error) break } commandIndex++ } console.log('done') await browser.close(); } }
现在您已准备好为 puppeteerManager.js
编写第二种方法:executeCommand()
。
编码 executeCommand()
方法
创建 runPuppeteer()
方法后,现在是创建 executeCommand()
方法的时候了。 此方法负责决定 Puppeteer 应该执行哪些操作,例如单击按钮或解析一个或多个 DOM
元素。
在 PuppeteerManager
类的底部添加以下代码:
puppeteerManager.js
. . . async executeCommand(frame, command) { await console.log(command.type, command.locatorCss) switch (command.type) { case "click": break; case "getItems": break; case "getItemDetails": break; } }
在此代码块中,您创建了 executeCommand()
方法。 此方法需要两个参数,一个包含页面元素的 frame
对象和一个包含命令的 command
对象。 此方法由 switch
语句组成,具有以下情况:click
、getItems
和 getItemDetails
。
定义 click
情况。
将 case "click":
下面的 break;
替换为以下代码:
puppeteerManager.js
async executeCommand(frame, command) { . . . case "click": try { await frame.$eval(command.locatorCss, element => element.click()); return true } catch (error) { console.log("error", error) return false } . . . }
当 command.type
等于 click
时,您的代码将触发 click
情况。 此代码块负责单击 next 按钮在分页的书籍列表中移动。
现在编写下一个 case
语句。
将 case "getItems":
下面的 break;
替换为以下代码:
puppeteerManager.js
async executeCommand(frame, command) { . . . case "getItems": try { let books = await frame.evaluate((command) => { function wordToNumber(word) { let number = 0 let words = ["zero","one","two","three","four","five"] for(let n=0;n<words.length;words++){ if(word == words[n]){ number = n break } } return number } try { let parsedItems = []; let items = document.querySelectorAll(command.locatorCss); items.forEach((item) => { let link = 'http://books.toscrape.com/catalogue/' + item.querySelector('div.image_container a').getAttribute('href').replace('catalogue/', '')<^> let starRating = item.querySelector('p.star-rating').getAttribute('class').replace('star-rating ', '').toLowerCase().trim() let title = item.querySelector('h3 a').getAttribute('title') let price = item.querySelector('p.price_color').innerText.replace('£', '').trim() let book = { title: title, price: parseInt(price), rating: wordToNumber(starRating), url: link } parsedItems.push(book) }) return parsedItems; } catch (error) { console.log(error) } }, command).then(result => { this.allBooks.push.apply(this.allBooks, result) console.log('allBooks length ', this.allBooks.length) }) return true } catch (error) { console.log("error", error) return false } . . . }
当 command.type
等于 getItems
时,将触发 getItems
情况。 您正在使用 frame.evaluate() 方法 切换浏览器上下文,然后创建一个名为 wordToNumber()
的函数。 此函数将一本书的 starRating
从字符串转换为整数。 然后代码将使用 document.querySelectorAll() 方法 解析和匹配 DOM
并检索网页的给定 frame
中显示的书籍的元数据. 检索到元数据后,代码会将其添加到 allBooks
数组中。
现在您可以定义最终的 case
语句。
将 case "getItemDetails"
下面的 break;
替换为以下代码:
puppeteerManager.js
async executeCommand(frame, command) { . . . case "getItemDetails": try { this.booksDetails = JSON.parse(JSON.stringify(await frame.evaluate((command) => { try { let item = document.querySelector(command.locatorCss); let description = item.querySelector('.product_page > p:nth-child(3)').innerText.trim() let upc = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(1) > td:nth-child(2)') .innerText.trim() let nrOfReviews = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(7) > td:nth-child(2)') .innerText.trim() let availability = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(6) > td:nth-child(2)') .innerText.replace('In stock (', '').replace(' available)', '') let details = { description: description, upc: upc, nrOfReviews: parseInt(nrOfReviews), availability: parseInt(availability) } return details; } catch (error) { console.log(error) return error } }, command))) console.log(this.booksDetails) return true } catch (error) { console.log("error", error) return false } }
当 command.type
等于 getItemDetails
时,将触发 getItemDetails
情况。 您再次使用 frame.evaluate()
和 .querySelector()
方法来切换浏览器上下文并解析 DOM
。 但是这一次,您检索了网页的给定 frame
中每本书的缺失详细信息。 然后,您将这些缺失的细节分配给 booksDetails
对象。
这完成了您的 executeCommand()
方法。 您的 puppeteerManager.js
文件现在将如下所示:
puppeteerManager.js
class PuppeteerManager { constructor(args) { this.url = args.url this.existingCommands = args.commands this.nrOfPages = args.nrOfPages this.allBooks = []; this.booksDetails = {} } async runPuppeteer() { const puppeteer = require('puppeteer') let commands = [] if (this.nrOfPages > 1) { for (let i = 0; i < this.nrOfPages; i++) { if (i < this.nrOfPages - 1) { commands.push(...this.existingCommands) } else { commands.push(this.existingCommands[0]) } } } else { commands = this.existingCommands } console.log('commands length', commands.length) const browser = await puppeteer.launch({ headless: true, args: [ "--no-sandbox", "--disable-gpu", ] }); let page = await browser.newPage() await page.setRequestInterception(true); page.on('request', (request) => { if (['image'].indexOf(request.resourceType()) !== -1) { request.abort(); } else { request.continue(); } }); await page.on('console', msg => { for (let i = 0; i < msg._args.length; ++i) { msg._args[i].jsonValue().then(result => { console.log(result); }) } }); await page.goto(this.url); let timeout = 6000 let commandIndex = 0 while (commandIndex < commands.length) { try { console.log(`command ${(commandIndex + 1)}/${commands.length}`) let frames = page.frames() await frames[0].waitForSelector(commands[commandIndex].locatorCss, { timeout: timeout }) await this.executeCommand(frames[0], commands[commandIndex]) await this.sleep(1000) } catch (error) { console.log(error) break } commandIndex++ } console.log('done') await browser.close(); } async executeCommand(frame, command) { await console.log(command.type, command.locatorCss) switch (command.type) { case "click": try { await frame.$eval(command.locatorCss, element => element.click()); return true } catch (error) { console.log("error", error) return false } case "getItems": try { let books = await frame.evaluate((command) => { function wordToNumber(word) { let number = 0 let words = ["zero","one","two","three","four","five"] for(let n=0;n<words.length;words++){ if(word == words[n]){ number = n break } } return number } try { let parsedItems = []; let items = document.querySelectorAll(command.locatorCss); items.forEach((item) => { let link = 'http://books.toscrape.com/catalogue/' + item.querySelector('div.image_container a').getAttribute('href').replace('catalogue/', '') let starRating = item.querySelector('p.star-rating').getAttribute('class').replace('star-rating ', '').toLowerCase().trim() let title = item.querySelector('h3 a').getAttribute('title') let price = item.querySelector('p.price_color').innerText.replace('£', '').trim() let book = { title: title, price: parseInt(price), rating: wordToNumber(starRating), url: link } parsedItems.push(book) }) return parsedItems; } catch (error) { console.log(error) } }, command).then(result => { this.allBooks.push.apply(this.allBooks, result) console.log('allBooks length ', this.allBooks.length) }) return true } catch (error) { console.log("error", error) return false } case "getItemDetails": try { this.booksDetails = JSON.parse(JSON.stringify(await frame.evaluate((command) => { try { let item = document.querySelector(command.locatorCss); let description = item.querySelector('.product_page > p:nth-child(3)').innerText.trim() let upc = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(1) > td:nth-child(2)') .innerText.trim() let nrOfReviews = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(7) > td:nth-child(2)') .innerText.trim() let availability = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(6) > td:nth-child(2)') .innerText.replace('In stock (', '').replace(' available)', '') let details = { description: description, upc: upc, nrOfReviews: parseInt(nrOfReviews), availability: parseInt(availability) } return details; } catch (error) { console.log(error) return error } }, command))) console.log(this.booksDetails) return true } catch (error) { console.log("error", error) return false } } } }
您现在可以为 PuppeteerManager
类创建第三种方法:sleep()
。
编码 sleep()
方法
创建 executeCommand()
方法后,下一步是创建 sleep()
方法。 此方法将使您的代码在执行下一行代码之前等待特定的时间。 这对于减小 crawl rate
至关重要。 如果没有这种预防措施,例如,抓取工具可以单击页面 A 上的按钮,然后在页面 B 加载之前搜索页面 B 上的元素。
在 PuppeteerManager
类的底部添加以下代码:
puppeteerManager.js
. . . sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) }
您正在将一个整数传递给 sleep()
方法。 此整数是代码应等待的时间量(以毫秒为单位)。
现在在 PuppeteerManager
类中编写最后两个方法:getAllBooks()
和 getBooksDetails()
。
编码 getAllBooks()
和 getBooksDetails()
方法
创建 sleep()
方法后,创建 getAllBooks()
方法。 server.js
文件中的函数将调用此函数。 getAllBooks()
负责调用 runPuppeteer()
,获取显示在给定页数上的书籍,然后将检索到的书籍返回给在 server.js
文件中调用它的函数。
在 PuppeteerManager
类的底部添加以下代码:
puppeteerManager.js
. . . async getAllBooks() { await this.runPuppeteer() return this.allBooks }
注意这个块如何使用另一个 Promise。
现在您可以创建最终方法:getBooksDetails()
。 和 getAllBooks()
一样,server.js
内部的函数会调用这个函数。 然而,getBooksDetails()
负责检索每本书缺失的详细信息。 它还将这些详细信息返回给在 server.js
文件中调用它的函数。
在 PuppeteerManager
类的底部添加以下代码:
puppeteerManager.js
. . . async getBooksDetails() { await this.runPuppeteer() return this.booksDetails }
您现在已经完成了对 puppeteerManager.js
文件的编码。
添加本节中描述的五种方法后,您完成的文件将如下所示:
puppeteerManager.js
class PuppeteerManager { constructor(args) { this.url = args.url this.existingCommands = args.commands this.nrOfPages = args.nrOfPages this.allBooks = []; this.booksDetails = {} } async runPuppeteer() { const puppeteer = require('puppeteer') let commands = [] if (this.nrOfPages > 1) { for (let i = 0; i < this.nrOfPages; i++) { if (i < this.nrOfPages - 1) { commands.push(...this.existingCommands) } else { commands.push(this.existingCommands[0]) } } } else { commands = this.existingCommands } console.log('commands length', commands.length) const browser = await puppeteer.launch({ headless: true, args: [ "--no-sandbox", "--disable-gpu", ] }); let page = await browser.newPage() await page.setRequestInterception(true); page.on('request', (request) => { if (['image'].indexOf(request.resourceType()) !== -1) { request.abort(); } else { request.continue(); } }); await page.on('console', msg => { for (let i = 0; i < msg._args.length; ++i) { msg._args[i].jsonValue().then(result => { console.log(result); }) } }); await page.goto(this.url); let timeout = 6000 let commandIndex = 0 while (commandIndex < commands.length) { try { console.log(`command ${(commandIndex + 1)}/${commands.length}`) let frames = page.frames() await frames[0].waitForSelector(commands[commandIndex].locatorCss, { timeout: timeout }) await this.executeCommand(frames[0], commands[commandIndex]) await this.sleep(1000) } catch (error) { console.log(error) break } commandIndex++ } console.log('done') await browser.close(); } async executeCommand(frame, command) { await console.log(command.type, command.locatorCss) switch (command.type) { case "click": try { await frame.$eval(command.locatorCss, element => element.click()); return true } catch (error) { console.log("error", error) return false } case "getItems": try { let books = await frame.evaluate((command) => { function wordToNumber(word) { let number = 0 let words = ["zero","one","two","three","four","five"] for(let n=0;n<words.length;words++){ if(word == words[n]){ number = n break } } return number } try { let parsedItems = []; let items = document.querySelectorAll(command.locatorCss); items.forEach((item) => { let link = 'http://books.toscrape.com/catalogue/' + item.querySelector('div.image_container a').getAttribute('href').replace('catalogue/', '') let starRating = item.querySelector('p.star-rating').getAttribute('class').replace('star-rating ', '').toLowerCase().trim() let title = item.querySelector('h3 a').getAttribute('title') let price = item.querySelector('p.price_color').innerText.replace('£', '').trim() let book = { title: title, price: parseInt(price), rating: wordToNumber(starRating), url: link } parsedItems.push(book) }) return parsedItems; } catch (error) { console.log(error) } }, command).then(result => { this.allBooks.push.apply(this.allBooks, result) console.log('allBooks length ', this.allBooks.length) }) return true } catch (error) { console.log("error", error) return false } case "getItemDetails": try { this.booksDetails = JSON.parse(JSON.stringify(await frame.evaluate((command) => { try { let item = document.querySelector(command.locatorCss); let description = item.querySelector('.product_page > p:nth-child(3)').innerText.trim() let upc = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(1) > td:nth-child(2)') .innerText.trim() let nrOfReviews = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(7) > td:nth-child(2)') .innerText.trim() let availability = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(6) > td:nth-child(2)') .innerText.replace('In stock (', '').replace(' available)', '') let details = { description: description, upc: upc, nrOfReviews: parseInt(nrOfReviews), availability: parseInt(availability) } return details; } catch (error) { console.log(error) return error } }, command))) console.log(this.booksDetails) return true } catch (error) { console.log("error", error) return false } } } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } async getAllBooks() { await this.runPuppeteer() return this.allBooks } async getBooksDetails() { await this.runPuppeteer() return this.booksDetails } } module.exports = { PuppeteerManager }
在此步骤中,您使用模块 Puppeteer
创建了 puppeteerManager.js
文件。 该文件构成了刮板的核心。 在下一节中,您将创建 server.js
文件。
第 4 步 - 构建第二个 Scraper 文件
在这一步中,您将创建 server.js
文件 — 应用程序服务器的后半部分。 该文件将接收包含指导要抓取哪些数据的信息的请求,然后将该数据返回给客户端。
创建 server.js
文件并打开它:
nano server.js
添加以下代码:
服务器.js
const express = require('express'); const bodyParser = require('body-parser') const os = require('os'); const PORT = 5000; const app = express(); let timeout = 1500000 app.use(bodyParser.urlencoded({ extended: true })) app.use(bodyParser.json()) let browsers = 0 let maxNumberOfBrowsers = 5
在此代码块中,您需要模块 express
和 body-parser
。 这些模块是创建能够处理 HTTP
请求的应用程序服务器所必需的。 express
模块会创建一个应用服务器,body-parser
模块会先在中间件中解析传入的请求体,然后再获取请求体的内容。 然后您需要 os
模块,它将检索运行您的应用程序的机器的名称。 之后,您为应用程序指定了一个端口并创建了变量 browsers
和 maxNumberOfBrowsers
。 这些变量将有助于管理服务器可以创建的浏览器实例的数量。 在这种情况下,应用程序仅限于创建五个浏览器实例,这意味着爬虫将能够同时从五个页面中检索数据。
我们的 Web 服务器将具有以下路由:/
、/api/books
和 /api/booksDetails
。
在 server.js
文件的底部,使用以下代码定义 /
路由:
服务器.js
. . . app.get('/', (req, res) => { console.log(os.hostname()) let response = { msg: 'hello world', hostname: os.hostname().toString() } res.send(response); });
您将使用 /
路由来检查您的应用程序服务器是否正在运行。 发送到此路由的 GET
请求将返回一个包含两个属性的对象:msg
,它只会说“hello world”,和 hostname
,它将识别所在的机器应用程序服务器的一个实例正在运行。
现在定义 /api/books
路线。
在 server.js
文件的底部,添加以下代码:
服务器.js
. . . app.post('/api/books', async (req, res) => { req.setTimeout(timeout); try { let data = req.body console.log(req.body.url) while (browsers == maxNumberOfBrowsers) { await sleep(1000) } await getBooksHandler(data).then(result => { let response = { msg: 'retrieved books ', hostname: os.hostname(), books: result } console.log('done') res.send(response) }) } catch (error) { res.send({ error: error.toString() }) } });
/api/books
路由将要求爬虫检索给定网页上与图书相关的元数据。 对该路由的 POST
请求将检查 browsers
运行的数量是否等于 maxNumberOfBrowsers
,如果不是,它将调用方法 [X147X ]。 此方法将创建 PuppeteerManager
类的新实例并检索图书的元数据。 一旦它检索到元数据,它就会在响应正文中返回给客户端。 响应对象将包含一个字符串 msg
,它读取 retrieved books
,一个数组,books
,它包含元数据,以及另一个字符串,hostname
,这将返回运行应用程序的机器/容器/pod 的名称。
我们要定义最后一条路线:/api/booksDetails
。
将以下代码添加到 server.js
文件的底部:
服务器.js
. . . app.post('/api/booksDetails', async (req, res) => { req.setTimeout(timeout); try { let data = req.body console.log(req.body.url) while (browsers == maxNumberOfBrowsers) { await sleep(1000) } await getBookDetailsHandler(data).then(result => { let response = { msg: 'retrieved book details', hostname: os.hostname(), url: req.body.url, booksDetails: result } console.log('done', response) res.send(response) }) } catch (error) { res.send({ error: error.toString() }) } });
向 /api/booksDetails
路由发送 POST
请求将要求爬虫检索给定书籍的缺失信息。 应用服务器会检查browsers
运行的数量是否等于maxNumberOfBrowsers
。 如果是,则调用sleep()
方法并等待1秒再检查,如果不相等,则调用方法getBookDetailsHandler()
。 与 getBooksHandler()
方法一样,此方法将创建 PuppeteerManager
类的新实例并检索缺失的信息。
然后程序将在响应正文中将检索到的数据返回给客户端。 响应对象将包含一个字符串 msg
,表示 retrieved book details
,一个字符串 hostname
,它将返回运行应用程序的机器的名称,以及另一个字符串 [ X179X],包含项目页面的 URL。 它还将包含一个数组 booksDetails
,其中包含一本书的所有缺失信息。
您的 Web 服务器还将具有以下功能:getBooksHandler()
、getBookDetailsHandler()
和 sleep()
。
从 getBooksHandler()
函数开始。
在 server.js
文件的底部,添加以下代码:
服务器.js
. . . async function getBooksHandler(arg) { let pMng = require('./puppeteerManager') let puppeteerMng = new pMng.PuppeteerManager(arg) browsers += 1 try { let books = await puppeteerMng.getAllBooks().then(result => { return result }) browsers -= 1 return books } catch (error) { browsers -= 1 console.log(error) } }
getBooksHandler()
函数将创建 PuppeteerManager
类的新实例。 它将运行的 browsers
的数量增加一,传递包含检索书籍所需信息的对象,然后调用 getAllBooks()
方法。 数据取回后,将browsers
运行的次数减1,然后将新取到的数据返回给/api/books
路由。
现在添加以下代码来定义 getBookDetailsHandler()
函数:
服务器.js
. . . async function getBookDetailsHandler(arg) { let pMng = require('./puppeteerManager') let puppeteerMng = new pMng.PuppeteerManager(arg) browsers += 1 try { let booksDetails = await puppeteerMng.getBooksDetails().then(result => { return result }) browsers -= 1 return booksDetails } catch (error) { browsers -= 1 console.log(error) } }
getBookDetailsHandler()
函数将创建 PuppeteerManager
类的新实例。 它的功能与 getBooksHandler()
函数类似,只是它处理每本书丢失的元数据并将其返回到 /api/booksDetails
路由。
在 server.js
文件的底部添加以下代码来定义 sleep()
函数:
服务器.js
function sleep(ms) { console.log(' running maximum number of browsers') return new Promise(resolve => setTimeout(resolve, ms)) }
当 browsers
的数量等于 maxNumberOfBrowsers
时,sleep()
函数使代码等待特定的时间。 我们将一个整数传递给这个函数,这个整数表示代码应该等待的时间量(以毫秒为单位),直到它可以检查 browsers
是否等于 maxNumberOfBrowsers
。
您的文件现已完成。
创建所有必要的路由和函数后,server.js
文件将如下所示:
服务器.js
const express = require('express'); const bodyParser = require('body-parser') const os = require('os'); const PORT = 5000; const app = express(); let timeout = 1500000 app.use(bodyParser.urlencoded({ extended: true })) app.use(bodyParser.json()) let browsers = 0 let maxNumberOfBrowsers = 5 app.get('/', (req, res) => { console.log(os.hostname()) let response = { msg: 'hello world', hostname: os.hostname().toString() } res.send(response); }); app.post('/api/books', async (req, res) => { req.setTimeout(timeout); try { let data = req.body console.log(req.body.url) while (browsers == maxNumberOfBrowsers) { await sleep(1000) } await getBooksHandler(data).then(result => { let response = { msg: 'retrieved books ', hostname: os.hostname(), books: result } console.log('done') res.send(response) }) } catch (error) { res.send({ error: error.toString() }) } }); app.post('/api/booksDetails', async (req, res) => { req.setTimeout(timeout); try { let data = req.body console.log(req.body.url) while (browsers == maxNumberOfBrowsers) { await sleep(1000) } await getBookDetailsHandler(data).then(result => { let response = { msg: 'retrieved book details', hostname: os.hostname(), url: req.body.url, booksDetails: result } console.log('done', response) res.send(response) }) } catch (error) { res.send({ error: error.toString() }) } }); async function getBooksHandler(arg) { let pMng = require('./puppeteerManager') let puppeteerMng = new pMng.PuppeteerManager(arg) browsers += 1 try { let books = await puppeteerMng.getAllBooks().then(result => { return result }) browsers -= 1 return books } catch (error) { browsers -= 1 console.log(error) } } async function getBookDetailsHandler(arg) { let pMng = require('./puppeteerManager') let puppeteerMng = new pMng.PuppeteerManager(arg) browsers += 1 try { let booksDetails = await puppeteerMng.getBooksDetails().then(result => { return result }) browsers -= 1 return booksDetails } catch (error) { browsers -= 1 console.log(error) } } function sleep(ms) { console.log(' running maximum number of browsers') return new Promise(resolve => setTimeout(resolve, ms)) } app.listen(PORT); console.log(`Running on port: ${PORT}`);
在这一步中,您完成了应用程序服务器的创建。 在下一步中,您将为应用程序服务器创建一个映像,然后将其部署到您的 Kubernetes 集群。
第 5 步 — 构建 Docker 映像
在这一步中,您将创建一个包含您的爬虫应用程序的 Docker 映像。 在第 6 步中,您将把该映像部署到 Kubernetes 集群。
要创建应用程序的 Docker 映像,您需要创建 Dockerfile,然后构建容器。
确保您仍在 ./server
文件夹中。
现在创建 Dockerfile 并打开它:
nano Dockerfile
在 Dockerfile
中写入以下代码:
Dockerfile
FROM node:10 RUN apt-get update RUN apt-get install -yyq ca-certificates RUN apt-get install -yyq libappindicator1 libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 RUN apt-get install -yyq gconf-service lsb-release wget xdg-utils RUN apt-get install -yyq fonts-liberation WORKDIR /usr/src/app COPY package*.json ./ RUN npm install COPY . . EXPOSE 5000 CMD [ "node", "server.js" ]
此块中的大部分代码是 Dockerfile 的标准命令行代码。 您从 node:10
图像构建图像。 接下来,您使用 RUN
命令安装必要的包以在 Docker 容器中运行 Puppeteer,然后创建应用程序目录。 您将刮板的 package.json
文件复制到应用程序目录并安装了 package.json
文件中指定的依赖项。 最后,您捆绑了应用程序源,将应用程序暴露在端口 5000
上,并选择 server.js
作为入口文件。
现在创建一个 .dockerignore
文件并打开它。 这将使敏感和不必要的文件不受版本控制。
使用您喜欢的文本编辑器创建文件:
nano .dockerignore
将以下内容添加到文件中:
./server/.dockerignore
node_modules npm-debug.log
创建 Dockerfile
和 .dockerignore
文件后,您可以构建应用程序的 Docker 映像并将其推送到 Docker Hub 帐户中的存储库。 在推送映像之前,请检查您是否已登录 Docker Hub 帐户。
登录 Docker Hub:
docker login --username=your_username --password=your_password
构建镜像:
docker build -t your_username/concurrent-scraper .
现在是时候测试刮板了。 在此测试中,您将向每个路由发送一个请求。
首先,启动应用程序:
docker run -p 5000:5000 -d your_username/concurrent-scraper
现在使用 curl
向 /
路由发送 GET
请求:
curl http://localhost:5000/
通过向 /
路由发送 GET
请求,您应该会收到包含 msg
的响应,其中包含 hello world
和 hostname
。 这个 hostname
是你的 Docker 容器的 id。 您应该会看到与此类似的输出,但带有您机器的唯一 ID:
Output{"msg":"hello world","hostname":"0c52d53f97d3"}
现在向 /api/books
路由发送 POST
请求,以获取显示在一个网页上的所有书籍的元数据:
curl --header "Content-Type: application/json" --request POST --data '{"url": "http://books.toscrape.com/index.html" , "nrOfPages":1 , "commands":[{"description": "get items metadata", "locatorCss": ".product_pod","type": "getItems"},{"description": "go to next page","locatorCss": ".next > a:nth-child(1)","type": "Click"}]}' http://localhost:5000/api/books
通过向 /api/books
路由发送 POST
请求,您将收到包含 msg
表示 retrieved books
、hostname
的响应,类似于上一个请求中的一个,以及一个 books
数组,其中包含在 books.toscrape 网站的第一页上显示的所有 20 本书。 您应该会看到这样的输出,但带有您机器的唯一 ID:
Output{"msg":"retrieved books ","hostname":"0c52d53f97d3","books":[{"title":"A Light in the Attic","price":null,"rating":0,"url":"http://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"},{"title":"Tipping the Velvet","price":null,"rating":0,"url":"http://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html"}, [ . . . ] }]}
现在向 /api/booksDetails
路由发送 POST
请求以获取随机书的缺失信息:
curl --header "Content-Type: application/json" --request POST --data '{"url": "http://books.toscrape.com/catalogue/slow-states-of-collapse-poems_960/index.html" , "nrOfPages":1 , "commands":[{"description": "get item details", "locatorCss": "article.product_page","type": "getItemDetails"}]}' http://localhost:5000/api/booksDetails
通过向 /api/booksDetails
路由发送 POST
请求,您将收到包含 msg
的响应,其中包含 retrieved book details
,booksDetails
对象包含 [ X153X]这本书缺少的细节,一个包含产品页面地址的url
,以及一个类似于之前请求的hostname
。 你会看到这样的输出:
Output{"msg":"retrieved book details","hostname":"0c52d53f97d3","url":"http://books.toscrape.com/catalogue/slow-states-of-collapse-poems_960/index.html","booksDetails":{"description":"The eagerly anticipated debut from one of Canada’s most exciting new poets In her debut collection, Ashley-Elizabeth Best explores the cultivation of resilience during uncertain and often trying times [...]","upc":"b4fd5943413e089a","nrOfReviews":0,"availability":17}}
如果您的 curl
命令没有返回正确的响应,请确保文件 puppeteerManager.js
和 server.js
中的代码与前两步中的最终代码块匹配。 此外,请确保 Docker 容器正在运行并且没有崩溃。 您可以尝试在没有 -d
选项的情况下运行 Docker 映像(此选项使 Docker 映像以分离模式运行),然后向其中一个路由发送 HTTP
请求。
如果在尝试运行 Docker 镜像时仍然遇到错误,请尝试停止所有正在运行的容器并运行不带 -d
选项的刮板镜像。
首先停止所有容器:
docker stop $(docker ps -a -q)
然后运行不带 -d
标志的 Docker 命令:
docker run -p 5000:5000 your_username/concurrent-scraper
如果您没有遇到任何错误,请清理终端窗口:
clear
现在您已经成功测试了映像,您可以将其发送到您的存储库。 将映像推送到 Docker Hub 帐户中的存储库:
docker push your_username/concurrent-scraper:latest
现在,您的爬虫应用程序可作为 Docker Hub 上的映像使用,您已准备好部署到 Kubernetes。 这将是您的下一步。
第 6 步 — 将 Scraper 部署到 Kubernetes
随着您的刮板图像构建并推送到您的存储库,您现在可以进行部署了。
首先,使用 kubectl
创建一个名为 concurrent-scraper-context
的新命名空间:
kubectl create namespace concurrent-scraper-context
将 concurrent-scraper-context
设置为默认上下文:
kubectl config set-context --current --namespace=concurrent-scraper-context
要创建应用程序的部署,您需要创建一个名为 app-deployment.yaml
的文件,但首先,您必须导航到项目中的 k8s
目录。 这是您将存储所有 Kubernetes 文件的地方。
转到项目中的 k8s
目录:
cd ../k8s
创建 app-deployment.yaml
文件并打开它:
nano app-deployment.yaml
在 app-deployment.yaml
中写入以下代码。 确保将 your_DockerHub_username
替换为您的唯一用户名:
./k8s/app-deployment.yaml
apiVersion: apps/v1 kind: Deployment metadata: name: scraper labels: app: scraper spec: replicas: 5 selector: matchLabels: app: scraper template: metadata: labels: app: scraper spec: containers: - name: concurrent-scraper image: your_DockerHub_username/concurrent-scraper ports: - containerPort: 5000
前面代码块中的大部分代码都是 Kubernetes deployment
文件的标准代码。 首先,将应用部署的名称设置为 scraper
,然后将 pod 的数量设置为 5
,然后将容器的名称设置为 concurrent-scraper
。 之后,您将要用于构建应用程序的映像指定为 your_DockerHub_username/concurrent-scraper
,但您将使用您的实际 Docker Hub 用户名。 最后,您指定希望您的应用程序使用端口 5000
。
创建部署文件后,您就可以将应用程序部署到集群了。
部署应用程序:
kubectl apply -f app-deployment.yaml
您可以通过运行以下命令来监控部署状态:
kubectl get deployment -w
运行命令后,您将看到如下输出:
OutputNAME READY UP-TO-DATE AVAILABLE AGE scraper 0/5 5 0 7s scraper 1/5 5 1 23s scraper 2/5 5 2 25s scraper 3/5 5 3 25s scraper 4/5 5 4 33s scraper 5/5 5 5 33s
所有部署都需要几秒钟才能开始运行,但一旦开始运行,您将有五个刮板实例正在运行。 每个实例可以同时抓取 5 页,因此您将能够同时抓取 25 页,从而减少了抓取所有 400 页所需的时间。
要从集群外部访问您的应用程序,您需要创建一个 service
。 这个 service
将是一个负载均衡器,它需要一个名为 load-balancer.yaml
的文件。
创建 load-balancer.yaml
文件并打开它:
nano load-balancer.yaml
在 load-balancer.yaml
中写入以下代码:
负载均衡器.yaml
apiVersion: v1 kind: Service metadata: name: load-balancer labels: app: scraper spec: type: LoadBalancer ports: - port: 80 targetPort: 5000 protocol: TCP selector: app: scraper
前面块中的大部分代码都是 service
文件的标准代码。 首先,您将服务的名称设置为 load-balancer
。 您指定了服务类型,然后使服务可在端口 80
上访问。 最后,您指定此服务用于应用程序 scraper
。
现在您已经创建了 load-balancer.yaml
文件,将服务部署到集群。
部署服务:
kubectl apply -f load-balancer.yaml
运行以下命令来监控服务的状态:
kubectl get services -w
运行此命令后,您将看到如下输出,但外部 IP 需要几秒钟才能出现:
OutputNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE load-balancer LoadBalancer 10.245.91.92 <pending> 80:30802/TCP 10s load-balancer LoadBalancer 10.245.91.92 161.35.252.69 80:30802/TCP 69s
您的服务的 EXTERNAL-IP
和 CLUSTER-IP
将与上述不同。 记下您的 EXTERNAL-IP
。 您将在下一节中使用它。
在此步骤中,您将爬虫应用程序部署到 Kubernetes 集群。 在下一步中,您将创建一个客户端应用程序来与新部署的应用程序进行交互。
第 7 步 — 创建客户端应用程序
在此步骤中,您将构建您的客户端应用程序,这将需要以下三个文件:main.js
、lowdbHelper.js
和 books.json
。 main.js
文件是客户端应用程序的主文件。 它将请求发送到您的应用程序服务器,然后使用您将在 lowdbHelper.js
文件中创建的方法保存检索到的数据。 lowdbHelper.js
文件将数据保存在本地文件中并检索其中的数据。 books.json
文件是保存所有抓取数据的本地文件。
首先回到你的 client
目录:
cd ../client
因为它们小于 main.js
,您将首先创建 lowdbHelper.js
和 books.json
文件。
创建并打开一个名为 lowdbHelper.js
的文件:
nano lowdbHelper.js
将以下代码添加到 lowdbHelper.js
文件中:
lowdbHelper.js
const lowdb = require('lowdb') const FileSync = require('lowdb/adapters/FileSync') const adapter = new FileSync('books.json')
在此代码块中,您需要模块 lowdb
,然后需要适配器 FileSync
,您需要保存和读取数据。 然后,您指示程序将数据存储在名为 books.json
的 JSON 文件中。
将以下代码添加到 lowdbHelper.js
文件的底部:
lowdbHelper.js
. . . class LowDbHelper { constructor() { this.db = lowdb(adapter); } getData() { try { let data = this.db.getState().books return data } catch (error) { console.log('error', error) } } saveData(arg) { try { this.db.set('books', arg).write() console.log('data saved successfully!!!') } catch (error) { console.log('error', error) } } } module.exports = { LowDbHelper }
在这里,您创建了一个名为 LowDbHelper
的类。 该类包含以下两个方法:getData()
和saveData()
。 第一个将检索保存在 books.json
文件中的书籍,第二个会将您的书籍保存到同一文件中。
您完成的 lowdbHelper.js
将如下所示:
lowdbHelper.js
const lowdb = require('lowdb') const FileSync = require('lowdb/adapters/FileSync') const adapter = new FileSync('books.json') class LowDbHelper { constructor() { this.db = lowdb(adapter); } getData() { try { let data = this.db.getState().books return data } catch (error) { console.log('error', error) } } saveData(arg) { try { this.db.set('books', arg).write() //console.log('data saved successfully!!!') } catch (error) { console.log('error', error) } } } module.exports = { LowDbHelper }
现在您已经创建了 lowdbHelper.js
文件,是时候创建 books.json
文件了。
创建 books.json
文件并打开它:
nano books.json
添加以下代码:
书籍.json
{ "books": [] }
books.json
文件由一个具有称为 books
属性的对象组成。 此属性的初始值是一个空数组。 稍后,当您检索书籍时,您的程序将在此处保存它们。
现在您已经创建了 lowdbHelper.js
和 books.json
文件,您将创建 main.js
文件。
创建 main.js
并打开它:
nano main.js
将以下代码添加到 main.js
:
main.js
let axios = require('axios') let ldb = require('./lowdbHelper.js').LowDbHelper let ldbHelper = new ldb() let allBooks = ldbHelper.getData() let server = "http://your_load_balancer_external_ip_address" let podsWorkDone = [] let booksDetails = [] let errors = []
在这段代码中,您需要 lowdbHelper.js
文件和一个名为 axios
的模块。 您将使用 axios
向您的爬虫发送 HTTP
请求; lowdbHelper.js
文件将保存检索到的书籍,allBooks
变量将存储保存在 books.json
文件中的所有书籍。 在检索任何书之前,此变量将保存一个空数组; server
变量将存储您在上一节中创建的负载均衡器的 EXTERNAL-IP
。 确保将其替换为您的唯一 IP。 podsWorkDone
变量将跟踪每个刮板实例已处理的页数。 booksDetails
变量将存储检索到的各个书籍的详细信息,errors
变量将跟踪尝试检索书籍时可能发生的任何错误。
现在我们需要为爬虫过程的每个部分构建一些函数。
将下一个代码块添加到 main.js
文件的底部:
main.js
. . . function main() { let execute = process.argv[2] ? process.argv[2] : 0 execute = parseInt(execute) switch (execute) { case 0: getBooks() break; case 1: getBooksDetails() break; } }
您现在正在创建一个名为 main()
的函数,它由一个 switch 语句组成,该语句将根据传递的输入调用 getBooks()
或 getBooksDetails()
函数。
将 getBooks()
下面的 break;
替换为以下代码:
main.js
. . . function getBooks() { console.log('getting books') let data = { url: 'http://books.toscrape.com/index.html', nrOfPages: 20, commands: [ { description: 'get items metadata', locatorCss: '.product_pod', type: "getItems" }, { description: 'go to next page', locatorCss: '.next > a:nth-child(1)', type: "Click" } ], } let begin = Date.now(); axios.post(`${server}/api/books`, data).then(result => { let end = Date.now(); let timeSpent = (end - begin) / 1000 + "secs"; console.log(`took ${timeSpent} to retrieve ${result.data.books.length} books`) ldbHelper.saveData(result.data.books) }) }
在这里,您创建了一个名为 getBooks()
的函数。 此代码将包含抓取所有 20 页所需信息的对象分配给名为 data
的变量。 该对象的 commands
数组中的第一个 command
检索页面上显示的所有 20 本书,第二个 command
单击页面上的下一步按钮,从而使浏览器导航到下一页。 这意味着第一个 command
将重复 20 次,第二个 19 次。 使用 axios
发送到 /api/books
路由的 POST
请求会将此对象发送到您的应用程序服务器,然后爬虫将检索第一个显示的每本书的基本元数据books.toscrape 网站的 20 页。 然后它使用 lowdbHelper.js
文件中的 LowDbHelper
类保存检索到的数据。
现在编写第二个函数,它将处理各个页面上更具体的书籍数据。
将 getBooksDetails()
下面的 break;
替换为以下代码:
main.js
. . . function getBooksDetails() { let begin = Date.now() for (let j = 0; j < allBooks.length; j++) { let data = { url: allBooks[j].url, nrOfPages: 1, commands: [ { description: 'get item details', locatorCss: 'article.product_page', type: "getItemDetails" } ] } sendRequest(data, function (result) { parseResult(result, begin) }) } }
getBooksDetails()
函数将遍历包含所有书籍的 allBooks
数组,并为该数组中的每本书创建一个对象,该对象将包含抓取页面所需的信息。 创建此对象后,它会将其传递给 sendRequest()
函数。 然后它将使用 sendRequest()
函数返回的值并将该值传递给名为 parseResult()
的函数。
将以下代码添加到 main.js
文件的底部:
main.js
. . . async function sendRequest(payload, cb) { let book = payload try { await axios.post(`${server}/api/booksDetails`, book).then(response => { if (Object.keys(response.data).includes('error')) { let res = { url: book.url, error: response.data.error } cb(res) } else { cb(response.data) } }) } catch (error) { console.log(error) let res = { url: book.url, error: error } cb({ res }) } }
现在您正在创建一个名为 sendRequest()
的函数。 您将使用此功能将所有 400 个请求发送到包含您的爬虫的应用程序服务器。 该代码将包含抓取页面所需信息的对象分配给名为 book
的变量。 然后在 POST
请求中将此对象发送到应用程序服务器上的 /api/booksDetails
路由。 响应被发送回 getBooksDetails()
函数。
现在创建 parseResult()
函数。
将以下代码添加到 main.js
文件的底部:
main.js
. . . function parseResult(result, begin){ try { let end = Date.now() let timeSpent = (end - begin) / 1000 + "secs "; if (!Object.keys(result).includes("error")) { let wasSuccessful = Object.keys(result.booksDetails).length > 0 ? true : false if (wasSuccessful) { let podID = result.hostname let podsIDs = podsWorkDone.length > 0 ? podsWorkDone.map(pod => { return Object.keys(pod)[0]}) : [] if (!podsIDs.includes(podID)) { let podWork = {} podWork[podID] = 1 podsWorkDone.push(podWork) } else { for (let pwd = 0; pwd < podsWorkDone.length; pwd++) { if (Object.keys(podsWorkDone[pwd]).includes(podID)) { podsWorkDone[pwd][podID] += 1 break } } } booksDetails.push(result) } else { errors.push(result) } } else { errors.push(result) } console.log('podsWorkDone', podsWorkDone, ', retrieved ' + booksDetails.length + " books, ", "took " + timeSpent + ", ", "used " + podsWorkDone.length + " pods", " errors: " + errors.length) saveBookDetails() } catch (error) { console.log(error) } }
parseResult()
接收函数 sendRequest()
的 result
包含缺少的书籍详细信息。 然后它解析 result
并检索处理请求的 pod 的 hostname
并将其分配给 podID
变量。 它检查这个 podID
是否已经是 podsWorkDone
数组的一部分; 如果不是,它会将 podId
添加到 podsWorkDone
数组并将完成的工作数设置为 1。 但如果是这样,它会将这个 pod 完成的工作数量增加 1。 然后代码会将result
添加到booksDetails
数组中,输出getBooksDetails()
函数的整体进度,然后调用saveBookDetails()
函数。
现在添加以下代码来构建 saveBookDetails()
函数:
main.js
. . . function saveBookDetails() { let books = ldbHelper.getData() for (let b = 0; b < books.length; b++) { for (let d = 0; d < booksDetails.length; d++) { let item = booksDetails[d] if (books[b].url === item.url) { books[b].booksDetails = item.booksDetails break } } } ldbHelper.saveData(books) } main()
saveBookDetails()
使用 LowDbHelper
类获取存储在 books.json
文件中的所有书籍,并将其分配给名为 books
的变量。 然后它遍历 books
和 booksDetails
数组,看看它是否在两个数组中找到具有相同 url
属性的元素。 如果是这样,它将添加 booksDetails
数组中元素的 booksDetails
属性,并将其分配给 books
数组中的元素。 然后它将用此函数中循环的 books
数组的内容覆盖 books.json
文件的内容。 创建 saveBookDetails()
函数后,代码将调用 main()
函数以使该文件可用。 否则,执行此文件不会产生预期的结果。
您完成的 main.js
文件将如下所示:
main.js
let axios = require('axios') let ldb = require('./lowdbHelper.js').LowDbHelper let ldbHelper = new ldb() let allBooks = ldbHelper.getData() let server = "http://your_load_balancer_external_ip_address" let podsWorkDone = [] let booksDetails = [] let errors = [] function main() { let execute = process.argv[2] ? process.argv[2] : 0 execute = parseInt(execute) switch (execute) { case 0: getBooks() break; case 1: getBooksDetails() break; } } function getBooks() { console.log('getting books') let data = { url: 'http://books.toscrape.com/index.html', nrOfPages: 20, commands: [ { description: 'get items metadata', locatorCss: '.product_pod', type: "getItems" }, { description: 'go to next page', locatorCss: '.next > a:nth-child(1)', type: "Click" } ], } let begin = Date.now(); axios.post(`${server}/api/books`, data).then(result => { let end = Date.now(); let timeSpent = (end - begin) / 1000 + "secs"; console.log(`took ${timeSpent} to retrieve ${result.data.books.length} books`) ldbHelper.saveData(result.data.books) }) } function getBooksDetails() { let begin = Date.now() for (let j = 0; j < allBooks.length; j++) { let data = { url: allBooks[j].url, nrOfPages: 1, commands: [ { description: 'get item details', locatorCss: 'article.product_page', type: "getItemDetails" } ] } sendRequest(data, function (result) { parseResult(result, begin) }) } } async function sendRequest(payload, cb) { let book = payload try { await axios.post(`${server}/api/booksDetails`, book).then(response => { if (Object.keys(response.data).includes('error')) { let res = { url: book.url, error: response.data.error } cb(res) } else { cb(response.data) } }) } catch (error) { console.log(error) let res = { url: book.url, error: error } cb({ res }) } } function parseResult(result, begin){ try { let end = Date.now() let timeSpent = (end - begin) / 1000 + "secs "; if (!Object.keys(result).includes("error")) { let wasSuccessful = Object.keys(result.booksDetails).length > 0 ? true : false if (wasSuccessful) { let podID = result.hostname let podsIDs = podsWorkDone.length > 0 ? podsWorkDone.map(pod => { return Object.keys(pod)[0]}) : [] if (!podsIDs.includes(podID)) { let podWork = {} podWork[podID] = 1 podsWorkDone.push(podWork) } else { for (let pwd = 0; pwd < podsWorkDone.length; pwd++) { if (Object.keys(podsWorkDone[pwd]).includes(podID)) { podsWorkDone[pwd][podID] += 1 break } } } booksDetails.push(result) } else { errors.push(result) } } else { errors.push(result) } console.log('podsWorkDone', podsWorkDone, ', retrieved ' + booksDetails.length + " books, ", "took " + timeSpent + ", ", "used " + podsWorkDone.length + " pods,", " errors: " + errors.length) saveBookDetails() } catch (error) { console.log(error) } } function saveBookDetails() { let books = ldbHelper.getData() for (let b = 0; b < books.length; b++) { for (let d = 0; d < booksDetails.length; d++) { let item = booksDetails[d] if (books[b].url === item.url) { books[b].booksDetails = item.booksDetails break } } } ldbHelper.saveData(books) } main()
您现在已经创建了客户端应用程序,并准备好与 Kubernetes 集群中的爬虫进行交互。 在下一步中,您将使用此客户端应用程序和应用程序服务器来抓取所有 400 本书。
第 8 步 — 抓取网站
现在您已经创建了客户端应用程序和服务器端抓取应用程序,是时候抓取 books.toscrape 网站了。 您将首先检索所有 400 本书的元数据。 然后,您将检索其页面上每本书的缺失详细信息,并实时监控每个 pod 处理了多少请求。
在 ./client
目录下,运行以下命令。 这将检索所有 400 本书的基本元数据并将其保存到您的 books.json
文件中:
npm start 0
您将收到以下输出:
Outputgetting books took 40.323secs to retrieve 400 books
检索所有 20 页上显示的书籍的元数据需要 40.323 秒,尽管此值可能因您的互联网速度而异。
现在,您想要检索存储在 books.json
文件中的每本书的缺失详细信息,同时还要监控每个 pod 处理的请求数。
再次运行 npm start
以检索详细信息:
npm start 1
您将收到这样的输出,但具有不同的 pod ID:
Output. . . podsWorkDone [ { 'scraper-59cd578ff6-z8zdd': 69 }, { 'scraper-59cd578ff6-528gv': 96 }, { 'scraper-59cd578ff6-zjwfg': 94 }, { 'scraper-59cd578ff6-nk6fr': 80 }, { 'scraper-59cd578ff6-h2n8r': 61 } ] , retrieved 400 books, took 56.875secs , used 5 pods, errors: 0
使用 Kubernetes 检索所有 400 本书缺失的详细信息不到 60 秒。 每个包含刮板的 pod 至少刮了 60 页。 与使用一台机器相比,这代表了巨大的性能提升。
现在将 Kubernetes 集群中的 pod 数量翻倍,以进一步加快检索速度:
kubectl scale deployment scraper --replicas=10
pod 可用之前需要一些时间,因此请至少等待 10 秒,然后再运行下一个命令。
重新运行 npm start
以获取缺失的详细信息:
npm start 1
您将收到类似于以下内容但具有不同 pod ID 的输出:
Output. . . podsWorkDone [ { 'scraper-59cd578ff6-z8zdd': 38 }, { 'scraper-59cd578ff6-6jlvz': 47 }, { 'scraper-59cd578ff6-g2mxk': 36 }, { 'scraper-59cd578ff6-528gv': 41 }, { 'scraper-59cd578ff6-bj687': 36 }, { 'scraper-59cd578ff6-zjwfg': 47 }, { 'scraper-59cd578ff6-nl6bk': 34 }, { 'scraper-59cd578ff6-nk6fr': 33 }, { 'scraper-59cd578ff6-h2n8r': 38 }, { 'scraper-59cd578ff6-5bw2n': 50 } ] , retrieved 400 books, took 34.925secs , used 10 pods, errors: 0
在将 pod 数量翻倍后,抓取所有 400 个页面所需的时间几乎减少了一半。 检索所有丢失的详细信息不到 35 秒。
在本节中,您向部署在 Kubernetes 集群中的应用程序服务器发送了 400 个请求,并在短时间内抓取了 400 个单独的 URL。 您还增加了集群中的 pod 数量以进一步提高性能。
结论
在本指南中,您使用 Puppeteer、Docker 和 Kubernetes 构建了一个能够快速抓取 400 个网页的并发 Web 抓取工具。 为了与爬虫交互,您构建了一个 Node.js 应用程序,它使用 axios 向包含爬虫的服务器发送多个 HTTP
请求。
Puppeteer 包括许多附加功能。 如果你想了解更多,查看Puppeteer的官方文档。 要了解有关 Node.js 的更多信息,查看我们关于如何在 Node.js 中编码的教程系列。