Puppeteer、Node.js、Docker、Kubernetesを使用して同時Webスクレイパーを構築する方法
著者は、 Write for DOnations プログラムの一環として、 Free and Open SourceFundを選択して寄付を受け取りました。
序章
Webスクレイピングは、Webクロールとも呼ばれ、ボットを使用してWebサイトからコンテンツとデータを抽出、解析、およびダウンロードします。
1台のマシンを使用して数十のWebページからデータを取得できますが、数百または数千のWebページからデータを取得する必要がある場合は、ワークロードの分散を検討することをお勧めします。
このチュートリアルでは、Puppeteerを使用してbooks.toscrapeをスクレイピングします。これは、初心者がWebスクレイピングを学び、開発者がスクレイピング技術を検証するための安全な場所として機能する架空の本屋です。 これを書いている時点で、books.toscrapeには1000冊の本があり、したがって、1000のWebページをこすり取ることができます。 ただし、このチュートリアルでは、最初の400のみをスクレイプします。 これらすべてのWebページを短時間でスクレイプするには、 ExpressWebフレームワークとPuppeteerブラウザーコントローラーを含むスケーラブルなアプリをビルドしてKubernetesクラスターにデプロイします。 スクレーパーを操作するには、PromiseベースのHTTPクライアントである axios と、Node.js用の小さなJSONデータベースであるlowdbを含むアプリを作成します。
このチュートリアルを完了すると、複数のページから同時にデータを抽出できるスケーラブルなスクレーパーができあがります。 たとえば、デフォルト設定と3ノードのクラスターを使用すると、books.toscrapeで400ページをスクレイプするのに2分もかかりません。 クラスタをスケーリングした後、約30秒かかります。
警告:ウェブスクレイピングの倫理と合法性は非常に複雑であり、絶えず進化しています。 また、場所、データの場所、および問題のWebサイトによっても異なります。 このチュートリアルでは、スクレーパーアプリケーションをテストするために明示的に設計された特別なWebサイトbooks.toscrape.comをスクレイプします。 他のドメインをスクレイピングすることは、このチュートリアルの範囲外です。
前提条件
このチュートリアルに従うには、次のマシンが必要です。
- Dockerがインストールされています。 手順については、Dockerをインストールして使用する方法に関するチュートリアルに従ってください。 DockerのWebサイトには、macOSやWindowsなどの他のオペレーティングシステムのインストール手順が記載されています。
- Dockerイメージを保存するためのDockerHubのアカウント。
- 接続構成が
kubectlデフォルトとして設定されているKubernetes1.17+クラスター。 DigitalOceanでKubernetesクラスタを作成するには、Kubernetesクイックスタートをお読みください。 クラスタに接続するには、DigitalOceanKubernetesクラスタに接続する方法をお読みください。 kubectlがインストールされています。 Kubernetesの使用を開始するためのこのチュートリアル:kubectl CheatSheetに従ってインストールしてください。- 開発マシンにインストールされているNode.js。 このチュートリアルは、Node.jsバージョン12.18.3およびnpmバージョン6.14.6でテストされました。 このガイドに従ってmacOSにNode.jsをインストールするか、このガイドに従ってさまざまなLinuxディストリビューションにNode.jsをインストールします。
- DigitalOcean Kubernetesを使用している場合は、パーソナルアクセストークンも必要になります。 作成するには、パーソナルアクセストークンの作成方法に関するガイドに従ってください。 このトークンを安全な場所に保存します。 アカウントへのフルアクセスを提供します。
ステップ1—ターゲットWebサイトの分析
コードを書く前に、Webブラウザでbooks.toscrapeに移動します。 データがどのように構造化されているか、および同時スクレイピングが最適なソリューションである理由を調べます。
このウェブサイトには1,000冊の本がありますが、各ページには20冊しか表示されていないことに注意してください。
ページの一番下までスクロールします。
このウェブサイトのコンテンツはページ付けされており、合計50ページあります。 各ページには20冊の本が表示され、最初の400冊だけをスクレイプしたいので、最初の20ページに表示されるすべての本のタイトル、価格、評価、およびURLのみを取得します。
全体のプロセスは1分未満かかるはずです。
ブラウザの開発ツールを開き、ページの最初の本を調べます。 次のコンテンツが表示されます。
すべての本は<section>タグ内にあり、各本は独自の<li>タグの下にリストされています。 各<li>タグ内には、product_podと等しいclass属性を持つ<article>タグがあります。 これが私たちが削りたい要素です。
最初の20ページのすべての本のメタデータを取得して保存すると、400冊の本を含むローカルデータベースが作成されます。 ただし、本に関するより詳細な情報は独自のページにあるため、各本のメタデータ内のURLを使用して400の追加ページをナビゲートする必要があります。 次に、必要な不足している本の詳細を取得し、このデータをローカルデータベースに追加します。 取得しようとしている不足しているデータは、説明、UPC(Universal Book Code)、レビューの数、および書籍の入手可能性です。 1台のマシンを使用して400ページを通過するには、7分以上かかる場合があります。そのため、Kubernetesを使用して作業を複数のマシンに分割する必要があります。
次に、ホームページの最初の本のリンクをクリックすると、その本の詳細ページが開きます。 ブラウザの開発ツールをもう一度開き、ページを調べます。
抽出したい不足している情報は、product_pageに等しいclass属性を持つ<article>タグ内にあります。
クラスター内のスクレーパーとやり取りするには、HTTPリクエストをKubernetesクラスターに送信できるクライアントアプリケーションを作成する必要があります。 最初にサーバー側をコーディングし、次にこのプロジェクトのクライアント側をコーディングします。
このセクションでは、スクレーパーが取得する情報と、このスクレーパーをKubernetesクラスターにデプロイする必要がある理由を確認しました。 次のセクションでは、クライアントアプリケーションとサーバーアプリケーションのディレクトリを作成します。
ステップ2—プロジェクトルートディレクトリを作成する
このステップでは、プロジェクトのディレクトリ構造を作成します。 次に、クライアントおよびサーバーアプリケーション用にNode.jsプロジェクトを初期化します。
ターミナルウィンドウを開き、concurrent-webscraperという名前の新しいディレクトリを作成します。
mkdir concurrent-webscraper
ディレクトリに移動します。
cd ./concurrent-webscraper
次に、server、client、およびk8sという名前の3つのサブディレクトリを作成します。
mkdir server client k8s
serverディレクトリに移動します。
cd ./server
新しいNode.jsプロジェクトを作成します。 npmのinitコマンドを実行すると、package.jsonファイルが作成され、依存関係とメタデータの管理に役立ちます。
初期化コマンドを実行します。
npm init
デフォルト値を受け入れるには、ENTERを押してすべてのプロンプトを表示します。 または、応答をパーソナライズすることもできます。 npmの初期化設定の詳細については、チュートリアルのステップ1、npmおよびpackage.jsonでNode.jsモジュールを使用する方法を参照してください。
package.jsonファイルを開き、編集します。
nano package.json
mainプロパティを変更し、scriptsディレクティブに情報を追加してから、dependenciesディレクティブを作成する必要があります。
ファイル内の内容を強調表示されたコードに置き換えます。
./server/package.json
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"body-parser": "^1.19.0",
"express": "^4.17.1",
"puppeteer": "^3.0.0"
}
}
ここでは、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は、ブラウザーとNode.js用のPromiseベースのHTTPクライアントです。 このモジュールを使用して、非同期HTTPリクエストをスクレーパー内のRESTエンドポイントに送信して対話します。 lowdbは、Node.jsとブラウザー用の小さなJSONデータベースであり、スクレイピングされたデータを保存するために使用します。
このステップでは、プロジェクトディレクトリを作成し、スクレーパーを含むアプリケーションサーバーのNode.jsプロジェクトを初期化しました。 次に、アプリケーションサーバーと対話するクライアントアプリケーションに対して同じことを行いました。 また、Kubernetes構成ファイル用のディレクトリも作成しました。 次のステップでは、アプリケーションサーバーの構築を開始します。
ステップ3—最初のスクレーパーファイルを作成する
このステップとステップ4では、サーバー側にスクレーパーを作成します。 このアプリケーションは、puppeteerManager.jsとserver.jsの2つのファイルで構成されます。 puppeteerManager.jsファイルはブラウザセッションを作成および管理し、server.jsファイルは1つまたは複数のWebページをスクレイプする要求を受け取ります。 次に、これらのリクエストはpuppeteerManager.js内のメソッドを呼び出し、特定のWebページをスクレイピングし、スクレイピングされたデータを返します。 このステップでは、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 は1ページあたり20冊の本しか表示しないため、20ページすべてで400冊すべてを取得するには、このプロパティを使用して既存のcommandsを20回繰り返します。 。
このコードブロックでは、受け取ったオブジェクトのプロパティをコンストラクター変数url、existingCommands、およびnrOfPagesにも割り当てました。 次に、allBooksとbooksDetailsの2つの追加変数を作成しました。 変数allBooksを使用して、取得したすべての本のメタデータを保存し、変数booksDetailsを使用して、特定の個々の本の不足している本の詳細を保存します。
これで、PuppeteerManagerクラスにいくつかのメソッドを追加する準備が整いました。 このクラスには、runPuppeteer()、executeCommand()、sleep()、getAllBooks()、およびgetBooksDetails()のメソッドがあります。 これらのメソッドはスクレーパーアプリケーションのコアを形成するため、1つずつ調べる価値があります。
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という空の配列で始まる変数を作成しました。 条件付きロジックを使用して、スクレイプするページ数が1より大きい場合、コードは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でアプリケーションを実行しているため、このプロジェクトに必要です。 次の2つの引数は、グラフィカルユーザーインターフェイスなしでブラウザを作成する場合の標準です。 最後に、 Puppeteerのbrowser.newPage()メソッドを使用して、新しいpageオブジェクトを作成しました。 .launch()メソッドは、 Promise を返します。これには、awaitキーワードが必要です。
これで、URLをナビゲートする方法など、新しいpageオブジェクトに動作を追加する準備が整いました。
runPuppeteer()メソッドの下部に、次のコードを追加します。
puppeteerManager.js
. . .
async runPuppeteer() {
. . .
await page.setRequestInterception(true);
page.on('request', (request) => {
if (['image'].indexOf(request.resourceType()) !== -1) {
request.abort();
} else {
request.continue();
}
});
await page.on('console', msg => {
for (let i = 0; i < msg._args.length; ++i) {
msg._args[i].jsonValue().then(result => {
console.log(result);
})
}
});
await page.goto(this.url);
. . .
}
このコードブロックでは、pageオブジェクトがPuppeteerのpage.setRequestInterception()メソッドを使用してすべてのリクエストをインターセプトし、リクエストがimageをロードする場合、画像の読み込みが不要になるため、ウェブページの読み込みに必要な時間が短縮されます。 次に、pageオブジェクトは、 Puppeteerのpage.on(' console ')event を使用して、ブラウザーコンテキストでメッセージを表示しようとする試みをインターセプトします。 次に、pageは、 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の2つの変数を作成しました。 最初の変数は、コードがWebページ上の要素を待機する時間を制限し、2番目の変数は、commands配列をループする方法を制御します。
whileループ内では、コードはcommands配列内のすべてのcommandを通過します。 まず、 page.frames()メソッドを使用して、ページにアタッチされたすべてのフレームの配列を作成します。 frame.waitForSelector()メソッドとlocatorCssプロパティを使用して、pageのframeオブジェクト内のDOM要素を検索します。 要素が見つかった場合は、executeCommand()メソッドを呼び出し、frameおよびcommandオブジェクトをパラメーターとして渡します。 executeCommandが戻った後、sleep()メソッドを呼び出します。これにより、コードは次のcommandを実行する前に1秒間待機します。 最後に、コマンドがなくなると、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の2番目のメソッドexecuteCommand()をコーディングする準備が整いました。
executeCommand()メソッドのコーディング
runPuppeteer()メソッドを作成したら、executeCommand()メソッドを作成します。 このメソッドは、ボタンをクリックしたり、1つまたは複数のDOM要素を解析したりするなど、Puppeteerが実行するアクションを決定する役割を果たします。
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オブジェクトの2つの引数を想定しています。 このメソッドは、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のケースをトリガーします。 このコードブロックは、次へボタンをクリックして、ページ化された書籍のリストを移動する役割を果たします。
次に、次の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
}
. . .
}
getItemsの場合は、command.typeがgetItemsと等しい場合にトリガーされます。 frame.evaluate()メソッドを使用してブラウザーのコンテキストを切り替え、wordToNumber()という関数を作成しています。 この関数は、本のstarRatingを文字列から整数に変換します。 次に、コードは document.querySelectorAll()メソッドを使用して、DOMを解析および照合し、Webページの特定の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
}
}
getItemDetailsの場合は、command.typeがgetItemDetailsと等しい場合にトリガーされます。 frame.evaluate()および.querySelector()メソッドを再度使用して、ブラウザーコンテキストを切り替え、DOMを解析しました。 しかし今回は、Webページの特定の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クラスの3番目のメソッドsleep()を作成する準備が整いました。
sleep()メソッドのコーディング
executeCommand()メソッドを作成したら、次のステップはsleep()メソッドを作成することです。 このメソッドは、コードを特定の時間待機させてから、次のコード行を実行します。 これは、crawl rateを減らすために不可欠です。 この予防措置がないと、スクレーパーは、たとえば、ページAのボタンをクリックしてから、ページBが読み込まれる前にページBの要素を検索する可能性があります。
PuppeteerManagerクラスの下部に、次のコードを追加します。
puppeteerManager.js
. . .
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
sleep()メソッドに整数を渡しています。 この整数は、コードが待機する必要のあるミリ秒単位の時間です。
次に、PuppeteerManagerクラス内の最後の2つのメソッド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ファイルのコーディングが完了しました。
このセクションで説明する5つのメソッドを追加すると、完成したファイルは次のようになります。
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—2番目のスクレーパーファイルを作成する
このステップでは、server.jsファイル(アプリケーションサーバーの後半)を作成します。 このファイルは、どのデータをスクレイプするかを指示する情報を含む要求を受け取り、そのデータをクライアントに返します。
server.jsファイルを作成し、それを開きます。
nano server.js
次のコードを追加します。
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を作成しました。 これらの変数は、サーバーが作成できるブラウザーインスタンスの数を管理するのに役立ちます。 この場合、アプリケーションは5つのブラウザインスタンスの作成に制限されています。つまり、スクレーパーは5つのページから同時にデータを取得できます。
当社のWebサーバーには、/、/api/books、および/api/booksDetailsのルートがあります。
server.jsファイルの下部で、次のコードを使用して/ルートを定義します。
server.js
. . .
app.get('/', (req, res) => {
console.log(os.hostname())
let response = {
msg: 'hello world',
hostname: os.hostname().toString()
}
res.send(response);
});
/ルートを使用して、アプリケーションサーバーが実行されているかどうかを確認します。 このルートに送信されたGETリクエストは、「helloworld」とのみ表示されるmsgとマシンを識別するhostnameの2つのプロパティを含むオブジェクトを返します。アプリケーションサーバーのインスタンスが実行されています。
次に、/api/booksルートを定義します。
server.jsファイルの下部に、次のコードを追加します。
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ルートは、スクレーパーに特定のWebページ上の本に関連するメタデータを取得するように要求します。 このルートへのPOSTリクエストは、実行中のbrowsersの数がmaxNumberOfBrowsersと等しいかどうかを確認し、等しくない場合は、メソッド[X147Xを呼び出します。 ]。 このメソッドは、PuppeteerManagerクラスの新しいインスタンスを作成し、本のメタデータを取得します。 メタデータを取得すると、応答本文でクライアントに返されます。 応答オブジェクトには、retrieved books を読み取る文字列msg、メタデータを含む配列books、および別の文字列hostnameが含まれます。これにより、アプリケーションが実行されているマシン/コンテナ/ポッドの名前が返されます。
定義する最後のルートが1つあります:/api/booksDetails。
server.jsファイルの最後に次のコードを追加します。
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クラスの新しいインスタンスを作成し、不足している情報を取得します。
次に、プログラムは、取得したデータを応答本文でクライアントに返します。 応答オブジェクトには、retrieved book detailsという文字列msg、アプリケーションを実行しているマシンの名前を返す文字列hostname、および別の文字列[ X179X] 、プロジェクトページのURLを含みます。 また、本の不足しているすべての情報を含む配列booksDetailsも含まれます。
Webサーバーには、getBooksHandler()、getBookDetailsHandler()、およびsleep()の機能もあります。
getBooksHandler()機能から始めます。
server.jsファイルの下部に、次のコードを追加します。
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の数を1つ増やし、本を取得するために必要な情報を含むオブジェクトを渡してから、getAllBooks()メソッドを呼び出します。 データが取得されると、実行中のbrowsersの数が1つ減り、新しく取得されたデータが/api/booksルートに返されます。
次に、次のコードを追加して、getBookDetailsHandler()関数を定義します。
server.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()関数を定義します。
server.js
function sleep(ms) {
console.log(' running maximum number of browsers')
return new Promise(resolve => setTimeout(resolve, ms))
}
sleep()関数は、browsersの数がmaxNumberOfBrowsersと等しい場合に、コードを特定の時間待機させます。 この関数に整数を渡します。この整数は、browsersがmaxNumberOfBrowsersと等しいかどうかを確認できるまでコードが待機する時間をミリ秒単位で表します。
これでファイルが完成しました。
必要なルートと関数をすべて作成すると、server.jsファイルは次のようになります。
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内に次のコードを記述します。
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ファイルをappディレクトリにコピーし、package.jsonファイル内に指定された依存関係をインストールしました。 最後に、アプリソースをバンドルし、ポート5000でアプリを公開し、エントリファイルとしてserver.jsを選択しました。
次に、.dockerignoreファイルを作成して開きます。 これにより、機密性の高い不要なファイルをバージョン管理できなくなります。
お好みのテキストエディタを使用してファイルを作成します。
nano .dockerignore
次のコンテンツをファイルに追加します。
./server/.dockerignore
node_modules npm-debug.log
Dockerfileファイルと.dockerignoreファイルを作成したら、アプリケーションのDockerイメージをビルドして、DockerHubアカウントのリポジトリにプッシュできます。 イメージをプッシュする前に、DockerHubアカウントにサインインしていることを確認してください。
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リクエストを/ルートに送信すると、hello worldとhostnameというmsgを含む応答を受信するはずです。 このhostnameは、DockerコンテナーのIDです。 これと同様の出力が表示されますが、マシンの一意のIDは次のとおりです。
Output{"msg":"hello world","hostname":"0c52d53f97d3"}
次に、POSTリクエストを/api/booksルートに送信して、1つのWebページに表示されるすべての書籍のメタデータを取得します。
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ルートに送信すると、retrieved books 、hostnameと同様のmsgを含む応答が返されます。前のリクエストの1つと、 books.toscrapeWebサイトの最初のページに表示された20冊すべての本を含むbooks配列。 次のような出力が表示されますが、マシンの一意の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ルートに送信すると、retrieved book details、booksDetailsオブジェクトを含むmsgを含む応答が返されます。 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のコードが前の2つの手順の最後のコードブロックと一致していることを確認してください。 また、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
画像のテストに成功したので、画像をリポジトリに送信できます。 イメージをDockerHubアカウントのリポジトリにプッシュします。
docker push your_username/concurrent-scraper:latest
スクレーパーアプリケーションがDockerHubでイメージとして利用できるようになったので、Kubernetesにデプロイする準備が整いました。 これが次のステップになります。
ステップ6—スクレーパーをKubernetesにデプロイする
スクレーパーイメージがビルドされてリポジトリにプッシュされると、デプロイの準備が整います。
まず、kubectlを使用して、concurrent-scraper-contextという名前の新しい名前空間を作成します。
kubectl create namespace concurrent-scraper-context
concurrent-scraper-contextをデフォルトのコンテキストとして設定します。
kubectl config set-context --current --namespace=concurrent-scraper-context
アプリケーションのデプロイメントを作成するには、app-deployment.yamlというファイルを作成する必要がありますが、最初に、プロジェクト内のk8sディレクトリに移動する必要があります。 これは、すべてのKubernetesファイルを保存する場所です。
プロジェクト内のk8sディレクトリに移動します。
cd ../k8s
app-deployment.yamlファイルを作成し、それを開きます。
nano app-deployment.yaml
app-deployment.yaml内に次のコードを記述します。 your_DockerHub_usernameを一意のユーザー名に置き換えてください。
./k8s/app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: scraper
labels:
app: scraper
spec:
replicas: 5
selector:
matchLabels:
app: scraper
template:
metadata:
labels:
app: scraper
spec:
containers:
- name: concurrent-scraper
image: your_DockerHub_username/concurrent-scraper
ports:
- containerPort: 5000
前のブロックのコードのほとんどは、Kubernetesdeploymentファイルの標準です。 まず、アプリのデプロイの名前をscraperに設定し、次にポッドの数を5に設定し、次にコンテナーの名前をconcurrent-scraperに設定します。 その後、アプリのビルドに使用するイメージをyour_DockerHub_username/concurrent-scraperとして指定しましたが、実際のDockerHubユーザー名を使用します。 最後に、アプリでポート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つのインスタンスが実行されます。 各インスタンスは5ページを同時にスクレイプできるため、25ページを同時にスクレイプできるため、400ページすべてをスクレイプするのに必要な時間が短縮されます。
クラスタの外部からアプリにアクセスするには、serviceを作成する必要があります。 このserviceはロードバランサーになり、load-balancer.yamlというファイルが必要になります。
load-balancer.yamlファイルを作成し、それを開きます。
nano load-balancer.yaml
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の3つのファイルが必要です。 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()の2つのメソッドが含まれています。 1つ目はbooks.jsonファイル内に保存されている本を取得し、2つ目は同じファイルに本を保存します。
完成した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
次のコードを追加します。
books.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()という関数を作成しています。この関数は、渡された入力に基づいてgetBooks()またはgetBooksDetails()関数を呼び出すswitchステートメントで構成されています。
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冊の本をすべて取得し、2番目のcommandはページの次のボタンをクリックして、ブラウザーを作成します。次のページに移動します。 これは、最初のcommandが19回繰り返されることを意味します。 axiosを使用して/api/booksルートに送信されたPOSTリクエストは、このオブジェクトをアプリケーションサーバーに送信し、スクレーパーは最初に表示されたすべての書籍の基本的なメタデータを取得します books.toscrapeWebサイトの20ページ。 次に、lowdbHelper.jsファイル内のLowDbHelperクラスを使用して、取得したデータを保存します。
次に、2番目の関数をコーディングします。この関数は、個々のページのより具体的な本のデータを処理します。
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を解析し、リクエストを処理したポッドのhostnameを取得して、podID変数に割り当てます。 このpodIDがすでにpodsWorkDoneアレイの一部であるかどうかをチェックします。 そうでない場合は、podIdをpodsWorkDone配列に追加し、実行された作業の数を1に設定します。 ただし、そうであれば、このポッドによって実行される作業の数が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.jsonファイルの内容を、この関数でループされたbooks配列の内容で上書きします。 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—Webサイトのスクレイピング
クライアントアプリケーションとサーバー側スクレーパーアプリケーションを作成したので、 books.toscrapeWebサイトをスクレイプします。 まず、400冊すべての本のメタデータを取得します。 次に、ページ上のすべての本の不足している詳細を取得し、各ポッドがリアルタイムで処理したリクエストの数を監視します。
./clientディレクトリで、次のコマンドを実行します。 これにより、400冊すべての書籍の基本的なメタデータが取得され、books.jsonファイルに保存されます。
npm start 0
次の出力が表示されます。
Outputgetting books took 40.323secs to retrieve 400 books
20ページすべてに表示されている書籍のメタデータを取得するには、40.323秒かかりましたが、この値はインターネットの速度によって異なる場合があります。
次に、books.jsonファイルに保存されているすべての本の不足している詳細を取得すると同時に、各ポッドが処理するリクエストの数を監視します。
npm startを再度実行して、詳細を取得します。
npm start 1
次のような出力が表示されますが、ポッド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秒もかかりませんでした。 スクレーパーを含む各ポッドは、少なくとも60ページを削りました。 これは、1台のマシンを使用する場合に比べてパフォーマンスが大幅に向上することを表しています。
次に、Kubernetesクラスター内のポッドの数を2倍にして、取得をさらに高速化します。
kubectl scale deployment scraper --replicas=10
ポッドが使用可能になるまで少し時間がかかるため、次のコマンドを実行する前に少なくとも10秒待ちます。
npm startを再実行して、不足している詳細を取得します。
npm start 1
次のような出力が表示されますが、ポッド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
ポッドの数を2倍にした後、400ページすべてをスクレイプするのに必要な時間はほぼ半分に短縮されました。 不足しているすべての詳細を取得するのに35秒もかかりませんでした。
このセクションでは、Kubernetesクラスターにデプロイされたアプリケーションサーバーに400のリクエストを送信し、短時間で400の個別のURLを取得しました。 また、パフォーマンスをさらに向上させるために、クラスター内のポッドの数を増やしました。
結論
このガイドでは、Puppeteer、Docker、Kubernetesを使用して、400のWebページを迅速にスクレイピングできる同時Webスクレイパーを構築しました。 スクレーパーと対話するために、axiosを使用して複数のHTTPリクエストをスクレーパーを含むサーバーに送信するNode.jsアプリを作成しました。
Puppeteerには多くの追加機能が含まれています。 詳細については、Puppeteerの公式ドキュメントをご覧ください。 Node.jsの詳細については、Node.jsでコーディングする方法に関するチュートリアルシリーズをご覧ください。