見出し画像

Firebase Functions + Cloud Tasks + Puppeteerでスクレイピングを並列処理する

こんにちは!Tsun Inc.の小笠原です。
業務でページネーションのあるサイトをスクレイピングする機会があったので、その際の知見を共有したいと思います。

1. 今回やること

ページネーションのあるサイトのスクレイピングを並列処理し、かつスケジュール実行できるようにします。
使用するサービスは下記です。
各サービスの選定理由はサーバーレスでやりたかったのと、使い慣れたサービスだったためという理由ですが、すべてGoogle製なので安心して使えます。

ソースコードは下記にありますので、とりあえずソースを読みたい方はどうぞ。

また、FirebaseやPuppeteerに関する細かい説明は省きますので、触ったことのない方は公式ページ等を参照してみてください。

2. なぜやるのか

サーバーレスでスクレイピングをしたい場合、簡易なものであればGoogle App Script (GAS) などが選択肢としてあがりますが、GASは実行時間の制限であったり、細かいハンドリングが難しかったりするためスクレイピングを並行処理したい場合には向きません。

今回はページネーションのあるサイト(ページ数は不定)のスクレイピングをスケジュール実行するという要件であったため、Firebase Functions + Cloud Tasks + Puppeteerを採用しました。

3. コード解説

※ Firebaseは従量制のBlazeプランを契約する必要があります。
(スケジュール実行、Cloud Tasks、Functionsでの外部アクセス等を利用するため)

3-1. 構造

今回のスクレイピング対象のサイトは、ページネーションの全ページ数が取得でき、各ページがクエリパラメータによって変化するという構成(?p=2で2ページ目が表示されるようなサイト)を想定しています。
ページネーションの構成が異なる場合、呼び出す部分の構成を適宜変更すれば対応可能なはずです。

以下の2つのFunctionsを作成し、組み合わせることでページネーションのあるサイトのスクレイピングを実現します。

  • ページから任意の要素を取得するFunction

  • サイトからページネーションのページ数を取得し、1のFunctionをページ数分呼び出すFunction

スケジュール実行はFirebaseにスケジュール実行機能があるのでそれを利用するだけで簡単にできます。
内部的にはCloud SchedulerやPub/Subが使われていますが、その他のGCPサービスとの連携が簡単にできるのもFirebaseの優れたところです。

1のFunctionの呼び出しは、1のFunctionをタスクキュー関数として作成して、2のFunctionからCloud Tasksのキューに入れることで実現します。

3-1. Puppeteerでページの要素を取得する

Puppeteerでページの要素を取得する部分の関数を書きます。
セレクターを適宜修正して、任意の要素を取得できるようにします。

// functions/src/getContents.js
/* eslint-env browser */
const puppeteer = require('puppeteer');

const getContents = async (url) => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  // Get contents
  const contentsSelector = 'your-contents-selector'
  const contents = await page.evaluate((selector) => {
    // Extract contents
    return { name: 'Kyohei Ogasawara' };
  }, contentsSelector);
  await browser.close();
  return contents;
};

exports.getContents = getContents;

上記を使ってページごとに実行されるFunctionを書きます。
Puppeteerで取得したものをFirestoreに保存するなり、お好みでどうぞ。

// functions/src/updateByPage.js
const admin = require('firebase-admin');
const { getContents } = require('./getContents');

if (admin.apps.length === 0) {
  admin.initializeApp();
}

const firestore = admin.firestore();

const updateByPage = async (url) => {
  const contents = await getContents(url);
  // Use contents as you like.
  // ex. save contents to firestore
  const contentsRef = firestore.doc('contents/1');
  try {
    await contentsRef.set(contents, { merge: true });
  } catch (error) {
    console.error(error);
    return Promise.reject(error);
  }
};

exports.updateByPage = updateByPage;

3-2. タスクキュー関数を作成する

3-1で作成した関数を使って、ページの任意の要素を取得するタスクキュー関数を作成します。

// functions/index.js
const functions = require('firebase-functions');
const { updateByPage } = require('./src/updateByPage');

exports.updateByPage = functions
  .runWith({ memory: '512MB' })
  .tasks.taskQueue()
  .onDispatch(async (data) => {
    const { url } = data;
    functions.logger.info(`url: ${url}`);
    if (!url) return;
    await updateByPage(url);
  });

functions.tasks.taskQueue().onDispatch でタスクキュー関数を作成します。
exportsに指定した名前が後ほどエンキューするときに指定する名前になります。
また、Firebase FunctionsでPuppeteerを実行する際にメモリを結構使うため、512MB確保する必要があります。

3-3. サイトのページ数を取得する

ページネーションから全体のページ数を取得します。
Puppeteerの使い方は3-1の関数と同様です。
サイトに応じたセレクターを選択してページ数を取得してください。

// functions/src/getPageCount.js
/* eslint-env browser */
const puppeteer = require('puppeteer');

const getPageCount = async (url) => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  // Get page count
  const pageCountSelector = 'your-page-count-selector';
  const pageCount = await page.evaluate((selector) => {
    // Extract page count
    return 10;
  }, pageCountSelector);
  await browser.close();
  return parseInt(pageCount, 10);
};

exports.getPageCount = getPageCount;

3-4. ページ数分エンキューする

3-3で取得したページ数分、3-2で作成したタスクキュー関数をエンキューします。

// functions/src/updateAllPage.js
const { getFunctions } = require('firebase-admin/functions');
const { getPageCount } = require('./getPageCount');

const updateAllPage = async (url) => {
  const pageCount = await getPageCount(url);
  if (!pageCount) return;
  const queue = getFunctions().taskQueue('updateByPage');
  const enqueues = [...Array(pageCount)].map((_, i) => {
    const data = { url: `${url}?p=${i + 1}` };
    const options = { scheduleDelaySeconds: 60 * i };
    return queue.enqueue(data, options);
  });
  await Promise.all(enqueues);
};

exports.updateAllPage = updateAllPage;

getFunctions().taskQueueに指定する関数名は3-2でexportsした関数名になることに注意してください。
optionsのscheduleDelaySecondsを指定することで、エンキューした関数の実行間隔を制御しています。
今回のケースでは、1ページ目のスクレイピングの1分後に2ページ目のスクレイピングが実行となります。

3-5. Firebase Functionsをスケジュール実行する

最後に3-4で作成した関数をスケジュール実行させます。

// src/index.js
const functions = require('firebase-functions');
const { updateAllPage } = require('./src/updateAllPage');
const { updateByPage } = require('./src/updateByPage');

const timeZone = 'Asia/Tokyo';

process.env.TZ = timeZone;

const targetUrl = 'https://your-target.site'

exports.updateAllPage = functions
  .runWith({ memory: '512MB' })
  .pubsub.schedule('every day 00:00')
  .timeZone(timeZone)
  .onRun(async (context) => {
    await updateAllPage(targetUrl);
  });

exports.updateByPage = functions
  .runWith({ memory: '512MB' })
  .tasks.taskQueue()
  .onDispatch(async (data) => {
    const { url } = data;
    functions.logger.info(`url: ${url}`);
    if (!url) return;
    await updateByPage(url);
  });

functions.pubsub.schedule() で指定した時間にFunctionが実行されます。
こちらもPuppeteerを実行するためmemoryは512MB必要です。

4. まとめ

今回はFirebase Functions、Cloud Tasks、Puppeteerを使ってページネーションのあるサイトのスクレイピングについて紹介しました。
Firebase FunctionsとCloud Tasksを使うことで大規模なスクレイピングも可能であり、かつスクレイピングするサイトへの負荷のコントロールもしやすいことがわかっていただけたと思います。

Tsun Inc.の自社ブログ Tsun Tech Blog ではECサイト構築のプラットフォームShopifyに関する記事も書いてますので、興味ある方はぜひ読んでコメントいただけると嬉しいです!


この記事が気に入ったらサポートをしてみませんか?