作为Write for DOnations计划的一部分,作者选择了免费和开源基金来接受捐赠。
介绍
网页抓取,也称为网页抓取,使用机器人从网站中提取、解析和下载内容和数据。
您可以使用一台机器从几十个网页中抓取数据,但是如果您必须从数百甚至数千个网页中检索数据,您可能需要考虑分配工作负载。
在本教程中,您将使用Puppeteer来抓取books.toscrape,这是一个虚构的书店,作为初学者学习网络抓取和开发人员验证他们的抓取技术的安全场所。在撰写本文时,books.toscrape 上有 1000 本书,因此您可以抓取 1000 个网页。但是,在本教程中,您将只抓取前 400 个。为了在短时间内抓取所有这些网页,您将构建一个包含Express Web 框架和 Puppeteer 浏览器控制器的可扩展应用程序并将其部署到Kubernetes集群。为了与您的抓取工具进行交互,您将构建一个包含axios的应用程序,一个基于Promise的 HTTP 客户端,以及lowdb,一个用于 Node.js 的小型 JSON 数据库。
完成本教程后,您将拥有一个能够同时从多个页面提取数据的可扩展刮刀。例如,使用默认设置和三节点集群,在books.toscrape 上抓取400 页只需不到2 分钟。扩展集群后,大约需要 30 秒。
警告:网络抓取的道德和合法性非常复杂且不断发展。它们还因您的位置、数据位置和相关网站而异。本教程抓取了一个专门的网站,books.toscrape.com,专门用于测试刮刀应用程序。抓取任何其他域不在本教程的范围内。
先决条件
要学习本教程,您需要一台具有以下功能的机器:
- 安装了 Docker。按照我们关于如何安装和使用 Docker 的教程获取说明。Docker 的网站提供了其他操作系统(如 macOS 和 Windows)的安装说明。
- Docker Hub 上用于存储 Docker 映像的帐户。
- Kubernetes 1.17+ 集群,将您的连接配置设置为
kubectl
默认值。要在 DigitalOcean 上创建 Kubernetes 集群,请阅读我们的Kubernetes 快速入门。要连接到集群,请阅读如何连接到 DigitalOcean Kubernetes 集群。 kubectl
安装。按照本教程开始使用 Kubernetes:一个 kubectl 备忘单来安装它。- 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
。
要与集群HTTP
中的抓取工具进行交互,您需要创建一个能够向我们的 Kubernetes 集群发送请求的客户端应用程序。您将首先对该项目的服务器端和客户端进行编码。
在本节中,您已经了解了抓取工具将检索哪些信息以及为什么需要将此抓取工具部署到 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 初始化设置的更多信息,如何在 npm 和 package.json 中使用 Node.js 模块。
打开package.json
文件并编辑它:
- nano package.json
您需要修改main
属性,向scripts
指令添加一些信息,然后创建dependencies
指令。
用突出显示的代码替换文件中的内容:
{
"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
用突出显示的代码替换文件中的内容:
{
"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
是一个基于HTTP
Promise 的浏览器和 Node.js 客户端。您将使用此模块向我们的抓取工具中的端点发送异步HTTP
请求REST
以与之交互;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
文件中:
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
类的底部,添加以下代码:
. . .
async runPuppeteer() {
const puppeteer = require('puppeteer')
let commands = []
if (this.nrOfPages > 1) {
for (let i = 0; i < this.nrOfPages; i++) {
if (i < this.nrOfPages - 1) {
commands.push(...this.existingCommands)
} else {
commands.push(this.existingCommands[0])
}
}
} else {
commands = this.existingCommands
}
console.log('commands length', commands.length)
}
在此代码块中,您创建了runPuppeteer()
方法。首先,您需要该puppeteer
模块,然后创建一个变量,该变量以一个名为 的空数组开头commands
。使用条件逻辑,您指出如果要抓取的页面数大于 1,则代码应循环遍历nrOfPages
,并将existingCommands
每个页面的 加入到commands
数组中。然而,当它到达最后一页,它不会添加了最后command
的在existingCommands
阵列到commands
阵列因为最后command
点击的翻页按钮。
下一步是创建浏览器实例。
在runPuppeteer()
您刚刚创建的方法的底部,添加以下代码:
. . .
async runPuppeteer() {
. . .
const browser = await puppeteer.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-gpu",
]
});
let page = await browser.newPage()
. . .
}
在此代码块中,您browser
使用内置puppeteer.launch()
方法创建了一个实例。您指定实例在headless
模式下运行。这是该项目的默认选项和必要选项,因为您正在 Kubernetes 上运行该应用程序。在创建没有图形用户界面的浏览器时,接下来的两个参数是标准的。最后,您page
使用Puppeteer 的browser.newPage()
方法创建了一个新对象。该.launch()
方法返回Promise
,这需要的await
关键字。
您现在已准备好向新page
对象添加一些行为,包括它将如何导航 URL。
在runPuppeteer()
方法的底部,添加以下代码:
. . .
async runPuppeteer() {
. . .
await page.setRequestInterception(true);
page.on('request', (request) => {
if (['image'].indexOf(request.resourceType()) !== -1) {
request.abort();
} else {
request.continue();
}
});
await page.on('console', msg => {
for (let i = 0; i < msg._args.length; ++i) {
msg._args[i].jsonValue().then(result => {
console.log(result);
})
}
});
await page.goto(this.url);
. . .
}
在这段代码中,page
对象使用Puppeteer 的page.setRequestInterception()
方法拦截所有请求,如果请求是加载一个image
,它会阻止图像加载,从而减少加载网页所需的时间。然后该page
对象使用Puppeteer 的page.on('console')
event拦截在浏览器上下文中显示消息的任何尝试。在page
给定然后导航到一个url
使用该page.goto()
方法。
现在向您的page
对象添加更多行为,这些行为将控制它如何在 DOM 中查找元素并在它们上运行命令。
在runPuppeteer()
方法的底部添加以下代码:
. . .
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
循环,代码经过每一个command
中commands
排列。首先,您要创建连接到使用页面所有框架的阵列的page.frames()
方法。它搜索在DOM元素frame
的对象page
使用的frame.waitForSelector()
方法和locatorCss
属性。如果找到一个元素,它会调用该executeCommand()
方法并将frame
和command
对象作为参数传递。在后executeCommand
返回时,它调用sleep()
方法,该方法使得代码等待1秒执行下一个之前command
。最后,当没有更多命令时,browser
实例关闭。
这完成了您的runPuppeteer()
方法。此时,您的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
类的底部添加以下代码:
. . .
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
案例。
将break;
下面case "click":
的代码替换为以下代码:
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
}
. . .
}
您的代码将click
在command.type
equals时触发案例click
。此代码块负责单击下一步按钮以在分页的书籍列表中移动。
现在编写下case
一条语句。
将break;
下面case "getItems":
的代码替换为以下代码:
async executeCommand(frame, command) {
. . .
case "getItems":
try {
let books = await frame.evaluate((command) => {
function wordToNumber(word) {
let number = 0
let words = ["zero","one","two","three","four","five"]
for(let n=0;n<words.length;words++){
if(word == words[n]){
number = n
break
}
}
return number
}
try {
let parsedItems = [];
let items = document.querySelectorAll(command.locatorCss);
items.forEach((item) => {
let link = 'http://books.toscrape.com/catalogue/' + item.querySelector('div.image_container a').getAttribute('href').replace('catalogue/', '')<^>
let starRating = item.querySelector('p.star-rating').getAttribute('class').replace('star-rating ', '').toLowerCase().trim()
let title = item.querySelector('h3 a').getAttribute('title')
let price = item.querySelector('p.price_color').innerText.replace('£', '').trim()
let book = {
title: title,
price: parseInt(price),
rating: wordToNumber(starRating),
url: link
}
parsedItems.push(book)
})
return parsedItems;
} catch (error) {
console.log(error)
}
}, command).then(result => {
this.allBooks.push.apply(this.allBooks, result)
console.log('allBooks length ', this.allBooks.length)
})
return true
} catch (error) {
console.log("error", error)
return false
}
. . .
}
的getItems
时候情况会触发command.type
等于getItems
。您正在使用该frame.evaluate()
方法切换浏览器上下文,然后创建一个名为wordToNumber()
. 此函数将starRating
一本书的字符串从字符串转换为整数。然后代码将使用该document.querySelectorAll()
方法来解析和匹配DOM
并检索给定frame
网页中显示的书籍的元数据。检索到元数据后,代码会将其添加到allBooks
数组中。
现在您可以定义最终case
语句。
将break;
下面case "getItemDetails"
的代码替换为以下代码:
async executeCommand(frame, command) {
. . .
case "getItemDetails":
try {
this.booksDetails = JSON.parse(JSON.stringify(await frame.evaluate((command) => {
try {
let item = document.querySelector(command.locatorCss);
let description = item.querySelector('.product_page > p:nth-child(3)').innerText.trim()
let upc = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(1) > td:nth-child(2)')
.innerText.trim()
let nrOfReviews = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(7) > td:nth-child(2)')
.innerText.trim()
let availability = item.querySelector('.table > tbody:nth-child(1) > tr:nth-child(6) > td:nth-child(2)')
.innerText.replace('In stock (', '').replace(' available)', '')
let details = {
description: description,
upc: upc,
nrOfReviews: parseInt(nrOfReviews),
availability: parseInt(availability)
}
return details;
} catch (error) {
console.log(error)
return error
}
}, command)))
console.log(this.booksDetails)
return true
} catch (error) {
console.log("error", error)
return false
}
}
的getItemDetails
时候情况会触发command.type
等于getItemDetails
。您再次使用frame.evaluate()
和.querySelector()
方法来切换浏览器上下文并解析DOM
. 但是这一次,您在给定frame
的网页中检索了每本书的缺失详细信息。然后,您将这些缺失的详细信息分配给booksDetails
对象。
这完成了您的executeCommand()
方法。您的puppeteerManager.js
文件现在将如下所示:
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
类的底部添加以下代码:
. . .
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
您正在向该sleep()
方法传递一个整数。这个整数是代码应该等待的时间量(以毫秒为单位)。
现在在PuppeteerManager
类中编写最后两个方法:getAllBooks()
和getBooksDetails()
。
编码getAllBooks()
和getBooksDetails()
方法
创建sleep()
方法后,创建getAllBooks()
方法。server.js
文件中的一个函数会调用这个函数。getAllBooks()
负责调用runPuppeteer()
,获取显示在多个给定页面上的书籍,然后将检索到的书籍返回给server.js
文件中调用它的函数。
在PuppeteerManager
类的底部添加以下代码:
. . .
async getAllBooks() {
await this.runPuppeteer()
return this.allBooks
}
请注意此块如何使用另一个 Promise。
现在您可以创建最终方法:getBooksDetails()
. 比如getAllBooks()
,里面的一个函数server.js
会调用这个函数。getBooksDetails()
但是,负责检索每本书缺失的详细信息。它还会将这些详细信息返回给在server.js
文件中调用它的函数。
在PuppeteerManager
类的底部添加以下代码:
. . .
async getBooksDetails() {
await this.runPuppeteer()
return this.booksDetails
}
您现在已经完成了对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
添加以下代码:
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
文件底部,/
使用以下代码定义路由:
. . .
app.get('/', (req, res) => {
console.log(os.hostname())
let response = {
msg: 'hello world',
hostname: os.hostname().toString()
}
res.send(response);
});
您将使用该/
路由来检查您的应用程序服务器是否正在运行。一个GET
发送到该路由请求将返回包含两个属性的对象msg
,这只会说“你好世界”和hostname
,这将确定哪些应用程序服务器的实例运行的机器。
现在定义/api/books
路线。
在server.js
文件底部,添加以下代码:
. . .
app.post('/api/books', async (req, res) => {
req.setTimeout(timeout);
try {
let data = req.body
console.log(req.body.url)
while (browsers == maxNumberOfBrowsers) {
await sleep(1000)
}
await getBooksHandler(data).then(result => {
let response = {
msg: 'retrieved books ',
hostname: os.hostname(),
books: result
}
console.log('done')
res.send(response)
})
} catch (error) {
res.send({ error: error.toString() })
}
});
该/api/books
路由将要求抓取器检索给定网页上与书籍相关的元数据。POST
对该路由的请求将检查browsers
运行次数是否等于maxNumberOfBrowsers
,如果不是,它将调用方法getBooksHandler()
。此方法将创建PuppeteerManager
该类的一个新实例并检索该书的元数据。一旦它检索到元数据,它就会在响应正文中返回给客户端。响应对象将包含一个字符串 ,msg
读取retrieved books
, 数组,books
包含元数据, 和另一个字符串,hostname
将返回运行应用程序的机器/容器/pod 的名称。
我们还有最后一条路线要定义:/api/booksDetails
.
将以下代码添加到server.js
文件底部:
. . .
app.post('/api/booksDetails', async (req, res) => {
req.setTimeout(timeout);
try {
let data = req.body
console.log(req.body.url)
while (browsers == maxNumberOfBrowsers) {
await sleep(1000)
}
await getBookDetailsHandler(data).then(result => {
let response = {
msg: 'retrieved book details',
hostname: os.hostname(),
url: req.body.url,
booksDetails: result
}
console.log('done', response)
res.send(response)
})
} catch (error) {
res.send({ error: error.toString() })
}
});
向路由发送POST
请求/api/booksDetails
将要求抓取器检索给定书籍的缺失信息。应用服务器会检查browsers
运行次数是否等于maxNumberOfBrowsers
. 如果是,它将调用该sleep()
方法并等待 1 秒,然后再次检查,如果不相等,它将调用该方法getBookDetailsHandler()
。与getBooksHandler()
方法一样,此方法将创建类的新实例PuppeteerManager
并检索丢失的信息。
然后程序将在响应正文中将检索到的数据返回给客户端。响应对象将包含一个字符串 , msg
say retrieved book details
,一个字符串, hostname
,它将返回运行应用程序的机器的名称,以及另一个字符串,url
,包含项目页面的 URL。它还将包含一个数组,booksDetails
,其中包含一本书的所有缺失信息。
您的Web服务器也将具有以下功能:getBooksHandler()
,getBookDetailsHandler()
,和sleep()
。
从getBooksHandler()
功能开始。
在server.js
文件底部,添加以下代码:
. . .
async function getBooksHandler(arg) {
let pMng = require('./puppeteerManager')
let puppeteerMng = new pMng.PuppeteerManager(arg)
browsers += 1
try {
let books = await puppeteerMng.getAllBooks().then(result => {
return result
})
browsers -= 1
return books
} catch (error) {
browsers -= 1
console.log(error)
}
}
该getBooksHandler()
函数将创建PuppeteerManager
该类的一个新实例。它将browsers
运行次数加一,传递包含检索书籍所需信息的对象,然后调用该getAllBooks()
方法。检索到数据后,将browsers
运行次数减一,然后将新检索到的数据返回给/api/books
路由。
现在添加以下代码来定义getBookDetailsHandler()
函数:
. . .
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()
函数:
function sleep(ms) {
console.log(' running maximum number of browsers')
return new Promise(resolve => setTimeout(resolve, ms))
}
sleep()
当 的数量browsers
等于时,该函数使代码等待特定的时间量maxNumberOfBrowsers
。我们将一个整数传递给这个函数,这个整数表示代码应该等待的时间量(以毫秒为单位),直到它可以检查是否browsers
等于maxNumberOfBrowsers
。
您的文件现已完成。
创建所有必要的路由和函数后,server.js
文件将如下所示:
const express = require('express');
const bodyParser = require('body-parser')
const os = require('os');
const PORT = 5000;
const app = express();
let timeout = 1500000
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
let browsers = 0
let maxNumberOfBrowsers = 5
app.get('/', (req, res) => {
console.log(os.hostname())
let response = {
msg: 'hello world',
hostname: os.hostname().toString()
}
res.send(response);
});
app.post('/api/books', async (req, res) => {
req.setTimeout(timeout);
try {
let data = req.body
console.log(req.body.url)
while (browsers == maxNumberOfBrowsers) {
await sleep(1000)
}
await getBooksHandler(data).then(result => {
let response = {
msg: 'retrieved books ',
hostname: os.hostname(),
books: result
}
console.log('done')
res.send(response)
})
} catch (error) {
res.send({ error: error.toString() })
}
});
app.post('/api/booksDetails', async (req, res) => {
req.setTimeout(timeout);
try {
let data = req.body
console.log(req.body.url)
while (browsers == maxNumberOfBrowsers) {
await sleep(1000)
}
await getBookDetailsHandler(data).then(result => {
let response = {
msg: 'retrieved book details',
hostname: os.hostname(),
url: req.body.url,
booksDetails: result
}
console.log('done', response)
res.send(response)
})
} catch (error) {
res.send({ error: error.toString() })
}
});
async function getBooksHandler(arg) {
let pMng = require('./puppeteerManager')
let puppeteerMng = new pMng.PuppeteerManager(arg)
browsers += 1
try {
let books = await puppeteerMng.getAllBooks().then(result => {
return result
})
browsers -= 1
return books
} catch (error) {
browsers -= 1
console.log(error)
}
}
async function getBookDetailsHandler(arg) {
let pMng = require('./puppeteerManager')
let puppeteerMng = new pMng.PuppeteerManager(arg)
browsers += 1
try {
let booksDetails = await puppeteerMng.getBooksDetails().then(result => {
return result
})
browsers -= 1
return booksDetails
} catch (error) {
browsers -= 1
console.log(error)
}
}
function sleep(ms) {
console.log(' running maximum number of browsers')
return new Promise(resolve => setTimeout(resolve, ms))
}
app.listen(PORT);
console.log(`Running on port: ${PORT}`);
在此步骤中,您完成了应用服务器的创建。在下一步中,您将为应用程序服务器创建一个映像,然后将其部署到您的 Kubernetes 集群。
第 5 步 – 构建 Docker 镜像
在此步骤中,您将创建一个包含刮刀应用程序的 Docker 映像。在第 6 步中,您将该映像部署到 Kubernetes 集群。
要创建应用程序的 Docker 映像,您需要创建一个 Dockerfile,然后构建容器。
确保您仍在./server
文件夹中。
现在创建 Dockerfile 并打开它:
- nano Dockerfile
在里面写入以下代码Dockerfile
:
FROM node:10
RUN apt-get update
RUN apt-get install -yyq ca-certificates
RUN apt-get install -yyq libappindicator1 libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6
RUN apt-get install -yyq gconf-service lsb-release wget xdg-utils
RUN apt-get install -yyq fonts-liberation
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5000
CMD [ "node", "server.js" ]
此块中的大部分代码是 Dockerfile 的标准命令行代码。您从node:10
图像构建了图像。接下来,您使用该RUN
命令安装了在 Docker 容器中运行 Puppeteer 所需的包,然后创建了 app 目录。您将抓取工具的package.json
文件复制到 app 目录并安装了package.json
文件中指定的依赖项。最后,您捆绑了应用程序源,在端口上公开应用程序5000
,并选择server.js
作为入口文件。
现在创建一个.dockerignore
文件并打开它。这将使敏感和不必要的文件不受版本控制。
使用您喜欢的文本编辑器创建文件:
- nano .dockerignore
将以下内容添加到文件中:
node_modules
npm-debug.log
创建Dockerfile
和.dockerignore
文件后,您可以构建应用程序的 Docker 映像并将其推送到您的 Docker Hub 帐户中的存储库。在推送映像之前,请检查您是否已登录 Docker Hub 帐户。
登录 Docker 中心:
- docker login --username=your_username --password=your_password
构建图像:
- docker build -t your_username/concurrent-scraper .
现在是测试刮刀的时候了。在此测试中,您将向每个路由发送请求。
首先,启动应用程序:
- docker run -p 5000:5000 -d your_username/concurrent-scraper
现在用于curl
向路由发送GET
请求/
:
- curl http://localhost:5000/
通过向路由发送GET
请求/
,您应该会收到一个包含msg
语句hello world
和hostname
. 这hostname
是您的 Docker 容器的 ID。您应该会看到与此类似的输出,但带有您机器的唯一 ID:
Output{"msg":"hello world","hostname":"0c52d53f97d3"}
现在向路由发送POST
请求以/api/books
获取一个网页上显示的所有书籍的元数据:
- curl --header "Content-Type: application/json" --request POST --data '{"url": "http://books.toscrape.com/index.html" , "nrOfPages":1 , "commands":[{"description": "get items metadata", "locatorCss": ".product_pod","type": "getItems"},{"description": "go to next page","locatorCss": ".next > a:nth-child(1)","type": "Click"}]}' http://localhost:5000/api/books
通过发送POST
请求/api/books
路线,你将收到包含响应msg
说法retrieved books
,一个hostname
类似于一个在以前的请求,并且books
所显示的第一页上包含所有20本书阵列books.toscrape网站。您应该会看到这样的输出,但带有您机器的唯一 ID:
Output{"msg":"retrieved books ","hostname":"0c52d53f97d3","books":[{"title":"A Light in the Attic","price":null,"rating":0,"url":"http://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"},{"title":"Tipping the Velvet","price":null,"rating":0,"url":"http://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html"}, [ . . . ] }]}
现在向路由发送POST
请求以/api/booksDetails
获取随机书籍的缺失信息:
- curl --header "Content-Type: application/json" --request POST --data '{"url": "http://books.toscrape.com/catalogue/slow-states-of-collapse-poems_960/index.html" , "nrOfPages":1 , "commands":[{"description": "get item details", "locatorCss": "article.product_page","type": "getItemDetails"}]}' http://localhost:5000/api/booksDetails
通过向路由发送POST
请求,/api/booksDetails
您将收到一个响应,其中包含一个msg
say retrieved book details
,一个booksDetails
包含这本书缺失细节的对象,一个url
包含产品页面地址的对象,以及hostname
之前请求中的一个。你会看到这样的输出:
Output{"msg":"retrieved book details","hostname":"0c52d53f97d3","url":"http://books.toscrape.com/catalogue/slow-states-of-collapse-poems_960/index.html","booksDetails":{"description":"The eagerly anticipated debut from one of Canada’s most exciting new poets In her debut collection, Ashley-Elizabeth Best explores the cultivation of resilience during uncertain and often trying times [...]","upc":"b4fd5943413e089a","nrOfReviews":0,"availability":17}}
如果你的curl
命令不会返回正确的响应,确保在文件中的代码puppeteerManager.js
,并server.js
在前面的两个步骤相匹配的最终代码块。另外,请确保 Docker 容器正在运行并且没有崩溃。您可以尝试在没有-d
选项的情况下运行 Docker 映像(此选项使 Docker 映像以分离模式运行),然后向HTTP
其中一个路由发送请求。
如果在尝试运行 Docker 镜像时仍然遇到错误,请尝试停止所有正在运行的容器并在没有该-d
选项的情况下运行刮刀镜像。
首先停止所有容器:
- docker stop $(docker ps -a -q)
然后运行不带-d
标志的 Docker 命令:
- docker run -p 5000:5000 your_username/concurrent-scraper
如果您没有遇到任何错误,请清理终端窗口:
- clear
现在您已经成功测试了镜像,您可以将其发送到您的存储库。将映像推送到 Docker Hub 帐户中的存储库:
- docker push your_username/concurrent-scraper:latest
现在,您的刮刀应用程序可作为 Docker Hub 上的映像使用,您已准备好部署到 Kubernetes。这将是您的下一步。
第 6 步 – 将 Scraper 部署到 Kubernetes
构建了刮板映像并将其推送到存储库后,您现在可以进行部署了。
首先,使用kubectl
创建一个名为 的新命名空间concurrent-scraper-context
:
- kubectl create namespace concurrent-scraper-context
设置concurrent-scraper-context
为默认上下文:
- kubectl config set-context --current --namespace=concurrent-scraper-context
要创建应用程序的部署,您需要创建一个名为 的文件app-deployment.yaml
,但首先,您必须导航到k8s
项目内的目录。您将在此处存储所有 Kubernetes 文件。
转到k8s
项目内的目录:
- cd ../k8s
创建app-deployment.yaml
文件并打开它:
- nano app-deployment.yaml
在里面写下下面的代码app-deployment.yaml
。确保替换your_DockerHub_username
为您唯一的用户名:
apiVersion: apps/v1
kind: Deployment
metadata:
name: scraper
labels:
app: scraper
spec:
replicas: 5
selector:
matchLabels:
app: scraper
template:
metadata:
labels:
app: scraper
spec:
containers:
- name: concurrent-scraper
image: your_DockerHub_username/concurrent-scraper
ports:
- containerPort: 5000
前面部分中的大部分代码都是 Kubernetesdeployment
文件的标准代码。首先,将应用部署的名称设置为scraper
,然后将 pod 的数量设置为5
,然后将容器的名称设置为concurrent-scraper
。之后,您将要用于构建应用程序的映像指定为your_DockerHub_username/concurrent-scraper
,但您将使用您的实际 Docker Hub 用户名。最后,您指定希望您的应用程序使用 port 5000
。
创建部署文件后,您就可以将应用程序部署到集群了。
部署应用程序:
- kubectl apply -f app-deployment.yaml
您可以通过运行以下命令来监控部署状态:
- kubectl get deployment -w
运行该命令后,您将看到如下输出:
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
:
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
文件中:
const lowdb = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
const adapter = new FileSync('books.json')
在此代码块中,您需要模块lowdb
,然后需要适配器FileSync
,您需要使用它来保存和读取数据。然后指示程序将数据存储在名为 .json 的 JSON 文件中books.json
。
将以下代码添加到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
将如下所示:
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
添加以下代码:
{
"books": []
}
该books.json
文件由一个对象组成,该对象具有一个名为books
. 此属性的初始值是一个空数组。稍后,当您检索书籍时,您的程序将在此处保存它们。
现在您已经创建了lowdbHelper.js
和books.json
文件,您将创建main.js
文件。
创建main.js
并打开它:
- nano main.js
将以下代码添加到main.js
:
let axios = require('axios')
let ldb = require('./lowdbHelper.js').LowDbHelper
let ldbHelper = new ldb()
let allBooks = ldbHelper.getData()
let server = "http://your_load_balancer_external_ip_address"
let podsWorkDone = []
let booksDetails = []
let errors = []
在这段代码中,您需要该lowdbHelper.js
文件和一个名为axios
. 您将使用向您的抓取工具axios
发送 HTTP
请求;该lowdbHelper.js
文件将保存检索到的书籍,allBooks
变量将存储保存在books.json
文件中的所有书籍。在检索任何书籍之前,该变量将保存一个空数组;该server
变量将存储EXTERNAL-IP
您在上一节中创建的负载均衡器的 。确保将其替换为您的唯一 IP。该podsWorkDone
变量将跟踪刮板的每个实例已处理的页数。该booksDetails
变量将存储为单个图书检索的详细信息,并且该errors
变量将跟踪尝试检索图书时可能发生的任何错误。
现在我们需要为scraper过程的每个部分构建一些函数。
将下一个代码块添加到main.js
文件底部:
. . .
function main() {
let execute = process.argv[2] ? process.argv[2] : 0
execute = parseInt(execute)
switch (execute) {
case 0:
getBooks()
break;
case 1:
getBooksDetails()
break;
}
}
您现在正在创建一个名为 的函数main()
,该函数由一个 switch 语句组成,该语句将根据传递的输入调用 getBooks()
或getBooksDetails()
函数。
将break;
下面的替换为getBooks()
以下代码:
. . .
function getBooks() {
console.log('getting books')
let data = {
url: 'http://books.toscrape.com/index.html',
nrOfPages: 20,
commands: [
{
description: 'get items metadata',
locatorCss: '.product_pod',
type: "getItems"
},
{
description: 'go to next page',
locatorCss: '.next > a:nth-child(1)',
type: "Click"
}
],
}
let begin = Date.now();
axios.post(`${server}/api/books`, data).then(result => {
let end = Date.now();
let timeSpent = (end - begin) / 1000 + "secs";
console.log(`took ${timeSpent} to retrieve ${result.data.books.length} books`)
ldbHelper.saveData(result.data.books)
})
}
在这里,您创建了一个名为 的函数getBooks()
。此代码将包含抓取所有 20 页所需信息的对象分配给名为 的变量data
。所述第一command
中commands
此对象检索所有20本书籍页面上显示的阵列,并且第二command
次点击页面上的下一个按钮,从而使浏览器导航到下一个页面。这意味着第一个command
将重复 20 次,第二个将重复 19 次。一POST
,使用请求发送axios
到/api/books
路由将这个对象发送到应用程序服务器,然后将刮板检索第一个20页的显示的每本书的基本的元数据books.toscrape网站。然后它使用LowDbHelper
lowdbHelper.js
文件内的类。
现在编写第二个函数,它将处理单个页面上更具体的书籍数据。
将break;
下面的替换为getBooksDetails()
以下代码:
. . .
function getBooksDetails() {
let begin = Date.now()
for (let j = 0; j < allBooks.length; j++) {
let data = {
url: allBooks[j].url,
nrOfPages: 1,
commands: [
{
description: 'get item details',
locatorCss: 'article.product_page',
type: "getItemDetails"
}
]
}
sendRequest(data, function (result) {
parseResult(result, begin)
})
}
}
该getBooksDetails()
函数将遍历allBooks
保存所有书籍的数组,并为该数组中的每本书创建一个对象,该对象将包含抓取页面所需的信息。创建此对象后,它会将其传递给sendRequest()
函数。然后它将使用该sendRequest()
函数返回的值并将该值传递给一个名为 的函数parseResult()
。
将以下代码添加到main.js
文件底部:
. . .
async function sendRequest(payload, cb) {
let book = payload
try {
await axios.post(`${server}/api/booksDetails`, book).then(response => {
if (Object.keys(response.data).includes('error')) {
let res = {
url: book.url,
error: response.data.error
}
cb(res)
} else {
cb(response.data)
}
})
} catch (error) {
console.log(error)
let res = {
url: book.url,
error: error
}
cb({ res })
}
}
现在您正在创建一个名为sendRequest()
. 您将使用此函数将所有 400 个请求发送到包含您的抓取工具的应用服务器。该代码将包含抓取页面所需信息的对象分配给名为 的变量book
。然后在POST
请求中将此对象发送到/api/booksDetails
应用程序服务器上的路由。响应被发送回getBooksDetails()
函数。
现在创建parseResult()
函数。
将以下代码添加到main.js
文件底部:
. . .
function parseResult(result, begin){
try {
let end = Date.now()
let timeSpent = (end - begin) / 1000 + "secs ";
if (!Object.keys(result).includes("error")) {
let wasSuccessful = Object.keys(result.booksDetails).length > 0 ? true : false
if (wasSuccessful) {
let podID = result.hostname
let podsIDs = podsWorkDone.length > 0 ? podsWorkDone.map(pod => { return Object.keys(pod)[0]}) : []
if (!podsIDs.includes(podID)) {
let podWork = {}
podWork[podID] = 1
podsWorkDone.push(podWork)
} else {
for (let pwd = 0; pwd < podsWorkDone.length; pwd++) {
if (Object.keys(podsWorkDone[pwd]).includes(podID)) {
podsWorkDone[pwd][podID] += 1
break
}
}
}
booksDetails.push(result)
} else {
errors.push(result)
}
} else {
errors.push(result)
}
console.log('podsWorkDone', podsWorkDone, ', retrieved ' + booksDetails.length + " books, ",
"took " + timeSpent + ", ", "used " + podsWorkDone.length + " pods", " errors: " + errors.length)
saveBookDetails()
} catch (error) {
console.log(error)
}
}
parseResult()
receives the result
of the function sendRequest()
containing missing book details. It then parses the result
and retrieves the hostname
of the pod that handled the request and assigns it to the podID
variable. It checks if this podID
is already part of the podsWorkDone
array; if it isn’t, it will add the podId
to the podsWorkDone
array and set the number of work done to 1. But if it is, it will increase the number of work done by this pod by 1. The code will then add the result
to the booksDetails
array, output the overall progress of the getBooksDetails()
function, and then call the saveBookDetails()
function.
Now add the following code to build the saveBookDetails()
function:
. . .
function saveBookDetails() {
let books = ldbHelper.getData()
for (let b = 0; b < books.length; b++) {
for (let d = 0; d < booksDetails.length; d++) {
let item = booksDetails[d]
if (books[b].url === item.url) {
books[b].booksDetails = item.booksDetails
break
}
}
}
ldbHelper.saveData(books)
}
main()
saveBookDetails()
gets all the books stored in the books.json
file using the LowDbHelper
class and assigns it to a variable called books
. It then loops through the books
and booksDetails
arrays to see if it finds elements in both arrays with the same url
property. If it does, it will add the booksDetails
property of the element in the booksDetails
array and assign it to the element in the books
array. Then it will overwrite the contents of the books.json
file with the contents of the books
array looped in this function. After creating the saveBookDetails()
function, the code will call the main()
function to make this file usable. Otherwise, executing this file wouldn’t produce the desired outcome.
Your completed main.js
file will look like this:
let axios = require('axios')
let ldb = require('./lowdbHelper.js').LowDbHelper
let ldbHelper = new ldb()
let allBooks = ldbHelper.getData()
let server = "http://your_load_balancer_external_ip_address"
let podsWorkDone = []
let booksDetails = []
let errors = []
function main() {
let execute = process.argv[2] ? process.argv[2] : 0
execute = parseInt(execute)
switch (execute) {
case 0:
getBooks()
break;
case 1:
getBooksDetails()
break;
}
}
function getBooks() {
console.log('getting books')
let data = {
url: 'http://books.toscrape.com/index.html',
nrOfPages: 20,
commands: [
{
description: 'get items metadata',
locatorCss: '.product_pod',
type: "getItems"
},
{
description: 'go to next page',
locatorCss: '.next > a:nth-child(1)',
type: "Click"
}
],
}
let begin = Date.now();
axios.post(`${server}/api/books`, data).then(result => {
let end = Date.now();
let timeSpent = (end - begin) / 1000 + "secs";
console.log(`took ${timeSpent} to retrieve ${result.data.books.length} books`)
ldbHelper.saveData(result.data.books)
})
}
function getBooksDetails() {
let begin = Date.now()
for (let j = 0; j < allBooks.length; j++) {
let data = {
url: allBooks[j].url,
nrOfPages: 1,
commands: [
{
description: 'get item details',
locatorCss: 'article.product_page',
type: "getItemDetails"
}
]
}
sendRequest(data, function (result) {
parseResult(result, begin)
})
}
}
async function sendRequest(payload, cb) {
let book = payload
try {
await axios.post(`${server}/api/booksDetails`, book).then(response => {
if (Object.keys(response.data).includes('error')) {
let res = {
url: book.url,
error: response.data.error
}
cb(res)
} else {
cb(response.data)
}
})
} catch (error) {
console.log(error)
let res = {
url: book.url,
error: error
}
cb({ res })
}
}
function parseResult(result, begin){
try {
let end = Date.now()
let timeSpent = (end - begin) / 1000 + "secs ";
if (!Object.keys(result).includes("error")) {
let wasSuccessful = Object.keys(result.booksDetails).length > 0 ? true : false
if (wasSuccessful) {
let podID = result.hostname
let podsIDs = podsWorkDone.length > 0 ? podsWorkDone.map(pod => { return Object.keys(pod)[0]}) : []
if (!podsIDs.includes(podID)) {
let podWork = {}
podWork[podID] = 1
podsWorkDone.push(podWork)
} else {
for (let pwd = 0; pwd < podsWorkDone.length; pwd++) {
if (Object.keys(podsWorkDone[pwd]).includes(podID)) {
podsWorkDone[pwd][podID] += 1
break
}
}
}
booksDetails.push(result)
} else {
errors.push(result)
}
} else {
errors.push(result)
}
console.log('podsWorkDone', podsWorkDone, ', retrieved ' + booksDetails.length + " books, ",
"took " + timeSpent + ", ", "used " + podsWorkDone.length + " pods,", " errors: " + errors.length)
saveBookDetails()
} catch (error) {
console.log(error)
}
}
function saveBookDetails() {
let books = ldbHelper.getData()
for (let b = 0; b < books.length; b++) {
for (let d = 0; d < booksDetails.length; d++) {
let item = booksDetails[d]
if (books[b].url === item.url) {
books[b].booksDetails = item.booksDetails
break
}
}
}
ldbHelper.saveData(books)
}
main()
You have now created the client application and are ready to interact with the scraper in your Kubernetes cluster. In the next step, you will use this client application and the application server to scrape all 400 books.
Step 8 — Scraping the Website
Now that you have created the client application and the server-side scraper application it’s time to scrape the books.toscrape website. You will first retrieve the metadata for all 400 books. Then you will retrieve the missing details for every single book on its page and monitor how many requests each pod has handled in real-time .
In the ./client
directory, run the following command. This will retrieve the basic metadata for all 400 books and save it to your books.json
file:
- npm start 0
You will receive the following output:
Outputgetting books
took 40.323secs to retrieve 400 books
Retrieving the metadata for the books displayed on all 20 pages took 40.323 seconds, although this value may differ depending on your internet speed.
Now you want to retrieve the missing details for every book stored in the books.json
file while also monitoring the number of requests that each pod handles.
Run npm start
again to retrieve the details:
- npm start 1
You will receive an output like this but with different pod IDs:
Output. . .
podsWorkDone [ { 'scraper-59cd578ff6-z8zdd': 69 },
{ 'scraper-59cd578ff6-528gv': 96 },
{ 'scraper-59cd578ff6-zjwfg': 94 },
{ 'scraper-59cd578ff6-nk6fr': 80 },
{ 'scraper-59cd578ff6-h2n8r': 61 } ] , retrieved 400 books, took 56.875secs , used 5 pods, errors: 0
Retrieving the missing details for all 400 books using Kubernetes took less than 60 seconds. Each pod containing the scraper scraped at least 60 pages. This represents a massive performance increase over using one machine.
Now double the number of pods in your Kubernetes cluster to accelerate the retrieval even more:
- kubectl scale deployment scraper --replicas=10
It will take a few moments before the pods are available, so wait at least 10 seconds before running the next command.
Rerun npm start
to get the missing details:
- npm start 1
You will receive an output similar to the following but with different pod IDs:
Output. . .
podsWorkDone [ { 'scraper-59cd578ff6-z8zdd': 38 },
{ 'scraper-59cd578ff6-6jlvz': 47 },
{ 'scraper-59cd578ff6-g2mxk': 36 },
{ 'scraper-59cd578ff6-528gv': 41 },
{ 'scraper-59cd578ff6-bj687': 36 },
{ 'scraper-59cd578ff6-zjwfg': 47 },
{ 'scraper-59cd578ff6-nl6bk': 34 },
{ 'scraper-59cd578ff6-nk6fr': 33 },
{ 'scraper-59cd578ff6-h2n8r': 38 },
{ 'scraper-59cd578ff6-5bw2n': 50 } ] , retrieved 400 books, took 34.925secs , used 10 pods, errors: 0
After doubling the number of pods, the time needed to scrape all 400 pages reduced almost by half. It took less than 35 seconds to retrieve all the missing details.
In this section, you sent 400 requests to the application server deployed in your Kubernetes cluster and scraped 400 individual URLs in a short amount of time. You also increased the number of pods in your cluster to improve performance even more.
Conclusion
在本指南中,您使用 Puppeteer、Docker 和 Kubernetes 构建了一个能够快速抓取 400 个网页的并发 Web 抓取工具。为了与抓取器进行交互,您构建了一个 Node.js 应用程序,该应用程序使用 axios 向HTTP
包含抓取器的服务器发送多个请求。
Puppeteer 包括许多附加功能。如果您想了解更多信息,请查看 Puppeteer 的官方文档。要了解有关 Node.js 的更多信息,请查看我们关于如何在 Node.js 中编码的系列教程。