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
ディレクティブを作成する必要があります。
ファイル内の内容を強調表示されたコードに置き換えます。
{
"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
は、ブラウザーと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
ファイルに追加します。
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
クラスの下部に、次のコードを追加します。
. . .
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
をexistingCommands
配列の最後のcommand
がcommands
配列に追加されません。これは、最後のcommand
が原因です。 次のページボタンをクリックします。
次のステップは、ブラウザインスタンスを作成することです。
作成したrunPuppeteer()
メソッドの下部に、次のコードを追加します。
. . .
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()
メソッドの下部に、次のコードを追加します。
. . .
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()
メソッドの下部に、次のコードを追加します。
. . .
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
ファイルは次のようになります。
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
クラスの下部に、次のコードを追加します。
. . .
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;
を次のコードに置き換えます。
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;
を次のコードに置き換えます。
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;
を次のコードに置き換えます。
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
ファイルは次のようになります。
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
クラスの下部に、次のコードを追加します。
. . .
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
クラスの下部に、次のコードを追加します。
. . .
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
ファイルのコーディングが完了しました。
このセクションで説明する5つのメソッドを追加すると、完成したファイルは次のようになります。
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
次のコードを追加します。
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
ファイルの下部で、次のコードを使用して/
ルートを定義します。
. . .
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
ファイルの下部に、次のコードを追加します。
. . .
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
と等しいかどうかを確認し、等しくない場合は、メソッドPuppeteerManager
クラスの新しいインスタンスを作成し、本のメタデータを取得します。 メタデータを取得すると、応答本文でクライアントに返されます。 応答オブジェクトには、retrieved books
を読み取る文字列msg
、メタデータを含む配列books
、および別の文字列hostname
が含まれます。これにより、アプリケーションが実行されているマシン/コンテナ/ポッドの名前が返されます。
定義する最後のルートが1つあります:/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
クラスの新しいインスタンスを作成し、不足している情報を取得します。
次に、プログラムは、取得したデータを応答本文でクライアントに返します。 応答オブジェクトには、retrieved book details
という文字列msg
、アプリケーションを実行しているマシンの名前を返す文字列hostname
、および別の文字列[ X179X]