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でコーディングする方法に関するチュートリアルシリーズをご覧ください。