如何使用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 本书,您将使用此属性重复现有的commands20 次.
在此代码块中,您还将接收到的对象属性分配给构造函数变量 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 中编码的教程系列。