ReactとFirebase、Puppeteerで行うスクレイピング

この記事ではReactとFirebase(FirebaseCloudFunctions)、Puppeteerを使ってスクレイピングを行うまでの一連の流れを紹介して行きます。
今回はgoogle.comで何か検索した時の検索ページをスクレイピングします。

## この記事での環境
```js
// FirebaseCloudFunctions
"firebase-admin": "^11.5.0",
"firebase-functions": "^4.2.0",
"puppeteer": "^19.7.4",
"puppeteer-chromium-resolver": "^19.2.0"

// React
"firebase": "^9.17.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"

// Node.js
"node": "16.15.0"
```

## ①Firebasプロジェクトを作成する
まず、最初にFirebaseのプロジェクトを作成するところから始めます。

下記のURLにアクセスして「使ってみる」→「プロジェクトを作成」ボタンをクリックします。（既にFirebaseのプロジェクトを作成したことのある方は「プロジェクトを追加」をクリックになります。）
https://firebase.google.com/?hl=ja

プロジェクト作成ボタンを押すとプロジェクト名を設定する画面になりますのでプロジェクト名を入れて「続行」を押下します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/79bb9343-a3c1-517c-6ff8-c9e1019e7d23.png" width="300px">

次にGoogleアナリティクスを有効にするかの画面が出ますので、どちらかを選んで「プロジェクトを作成」ボタンを押下します。（今回は無効で進めます。）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/7d6836ba-417d-692a-61af-020933706f28.png" width="300px">

「プロジェクトを作成」ボタンを押下した後はプロジェクトの作成が行われますので、下記の表示が出るまで待って「続行」を押下します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/3f94ef4d-54d0-4542-3684-332fc1ae2876.png" width="300px">

すると下記のページに遷移しますので、「Sparkプラン」となっているところを押下してください。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/03aaa097-ffd6-4c08-d8fe-a1964e48e2ca.png" width="600px">

Sparkプランは無料で使えるプランとなりますが、それだとFirebaseCloudFunctionsを使用することができないため従量制プランのBlazeプランに変更します。（Blazeプランにも無料枠があり、それを越えなければ請求が発生することはありません。詳しくは[公式の料金ページ](https://cloud.google.com/functions/pricing?hl=ja)をご確認ください。）

「Sparkプラン」を押下して表示されたポップアップの中のBlazeの「プランを選択」ボタンを押下します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/184f86a9-65fa-7d05-74c5-3c371a927eba.png" width="300px">

すると請求先アカウントを選択するポップアップが出ますので、既存のアカウントがあれば「請求先アカウント」を選択して「続行」を押下、なければ「新しい請求先アカウントを作成する」を選択して「続行」ボタンを押下します。（ここでは請求先アカウントがあるため、既存の請求先アカウントを使って進めます。）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/aed1f2fa-6bda-16b9-3710-5771ff715e0a.png" width="300px">

請求先アカウントを選択すると請求が発生した場合、指定の金額に近づいた時にメールで通知してくれる課金予算の設定が出ますので、スキップもしくは予算を入力して「続行」ボタンを押下します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/ecd3aca1-e013-febe-5097-c600b67aa25f.png" width="300px">

最後に「購入を確定」ポップアップが開きますので、「購入」ボタンを押下して完了させます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/e906291a-c669-dc62-d018-d29e5205ca3e.png" width="300px">

## ②FirebaseCloudFunctionsを使用するための準備
プロジェクトの作成が完了したら実際にスクレイピングを行うコードを作成していきます。
まず、VSCODEを立ち上げ、ターミナルから下記のコマンドを実行し、フォルダを作成した後、作成したフォルダに移動します。（ここではフォルダの名前を「scraping-test-firebase」としています）
```
mkdir scraping-test-firebase
```
```
cd scraping-test-firebase
```
次にCLIでfirebaseコマンドを使用できるようにするため、下記のコマンドを実行してfirebase-toolsをインストールします。
```
npm install -g firebase-tools
```
インストールが完了したら、作成したFirebaseプロジェクトと紐付ける必要があるため、下記のコマンドを実行します
```
firebase login
```
コマンドを実行すると製品の改善に役立てるためにCLIの使用状況やエラー報告情報を収集することを許可するかの確認が出ますので、「Y」か「n」を入力してエンターキーを押します。
するとGoogleのログイン画面が表示されるので、Firebaseプロジェクトを作成したアカウントでログインします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/695e598c-bff4-531e-853b-43eff52eb28e.png" width="300px">
「Firebase CLIがGoogleアカウントへのアクセスをリクエストしています」という表示が出るので許可を押下します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/7dbfb9b7-2837-0d46-cf97-9eefcde9a257.png" width="300px">
下記の表示が出たら正常にログインできました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/271c7a67-029d-3865-012d-4f0db3baab20.png" width="300px">

ログインができたら次はFirebaseプロジェクトの初期化を行います。
これを行うことによってFirebaseCloudFunctionsへデプロイなどをするためのプロジェクトディレクトリが作成されます。
初期化するためには、先ほど`firebase login`を入力したVSCODEのターミナルへ下記のコマンドを入力してください
```
firebase init
```
するとターミナルに下記の表示出ます。
今回はFunctionsを使用するのでキーボードの「↓」を押して画像のように「Functions」にカーソルを合わせて「space」キーを押して選択後、「Enter」キーを押します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/9c3a43e2-02cf-5912-1f73-70e2335d2ff5.png" width="600px">
次に「scriping-test-firebase」と紐づけるFirebaseプロジェクトを選択する項目が出てますので、「Use an existing project」にカーソルを合わせて「Enter」キーを押します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/4fbdf08f-12b3-f4a4-7cbf-1a5ea29ea741.png" width="600px">
作成してあるFirebaseプロジェクトの一覧が表示されるので、今回用に作成した「scraping-test」にカーソルを合わせて「Enter」キーを押します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/bb3dd75f-0034-0a2d-ee62-77bccf7d66fe.png" width="600px">
次にJavascriptとTypescriptのどちらを使用するかと聞かれますので、今回はJavascriptにカーソルを合わせて「Enter」キーを押します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/89ac0162-76c8-9524-877c-8cd9e7c752bd.png" width="600px">
その後、「ESLintを使用するか」と「npmの依存関係インストールするか」を聞かれますのでどちらもそのまま「Enter」キーを押して進めます。
ターミナルに「Firebase initialization complete!」が表示されていて、フォルダの構成が下記画像のようになっていればFirebaseCloudFunctionsを使用する準備は完了となります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/589b0784-b9a0-b1f8-0f25-ef606008216f.png" width="300px">

## ③スクレイピングのコードを追加
ここまでの準備ができたら、スクレイピングを行うために必要な「Puppeteer」をインストールします。
下記のコマンドを実行してfunctionsフォルダに移動し、
```
cd functions
```
Puppeteerをインストールするコマンドを実行します。
```
npm i puppeteer
```
Puppeteerがインストールできたら、次に下記のコマンドをターミナルで実行します。
```
npm i puppeteer-chromium-resolver
```
Puppeteerはv19.0.0からnode_modules内にchromiumを持たないように変更されたため、そのままだとPuppeteerをFirebeseCloudFunctionsで起動したとき「chromiumが見つからない」というエラー発生してしまい処理が実行されません。（v18以前のPuppeteerをインストールすれば特に問題無く実行できます。）
[puppeteer-chromium-resolver](https://www.npmjs.com/package/puppeteer-chromium-resolver)を使用することで、chormiumのダウンロードや動的検出を自動で行なってくれエラーを回避することができます。

ここまでのインストールが完了したら
「functions」フォルダ配下の「index.js」を表示します。
デフォルトでは下記のようになっており、「Hellow from Firebase!」を返すコードがコメントアウトされていますので、この部分を削除します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/63abd30a-7809-2fe0-8fd8-c268d3543bcb.png" width="600px">
ReactからFirebaseCloudFunctionsの関数へアクセスするには「[functions.https.onCall](https://firebase.google.com/docs/functions/callable?hl=ja#web-version-9_2)」をトリガーとする方法と「[functions.https.onRequest](https://firebase.google.com/docs/functions/http-events?hl=ja)」をトリガーとする方法がありますが、今回は「[functions.https.onCall](https://firebase.google.com/docs/functions/callable?hl=ja#web-version-9_2)」をトリガーとする方法で進めます。
初めにPuppeteerを読み込み、Reactから呼び出す関数「scraping」を定義します。

```js
const functions = require("firebase-functions");
// puppeteerの読み込み
const puppeteer = require("puppeteer");

// Reactから呼び出す関数を定義
exports.helloWorld = functions.https.onCall((data, context) => {

});
```

FirebaseCloudFunctionsは関数に割り当てるメモリの量やタイムアウトの秒数などを設定できるオプションがあります。（[RuntimeOptions](https://firebase.google.com/docs/reference/functions/firebase-functions.runtimeoptions?hl=ja)）
Puppeteerは動作させるのにメモリを多く消費するため、次はメモリの割り当てを変更します。デフォルトでは256MBになっており、最低でも1GBは必要になります。また、スクレイピングは時間がかかることもあるためタイムアウトの秒数も下記のようにオプションを設定して変更します。

```js
const functions = require("firebase-functions");
const puppeteer = require("puppeteer");

// オプションの構成オブジェクトを作成
const runtimeOptions = {
    timeoutSeconds: 300,
    memory: "1GB",
}

// runWith()の引数に構成オブジェクトを設定することでオプションが変更可能
exports.helloWorld = functions
    .runWith(runtimeOptions)
    .https.onCall((data, context) => {

});
```

次に関数を実行する[リージョン](https://firebase.google.com/docs/functions/locations?hl=ja)を設定します。デフォルトでは「us-central1（アイオワ）」に設定されており、このままだと関数を実行する度に通信がアイオワと行き来するため多少の遅延が発生します。日本で使用する想定であれば「asia-northeast1（東京）」か「asia-northeast2（大阪）」に変更したほうが無難なため、今回は「asia-northeast1（東京）」に変更します。
```js
const functions = require("firebase-functions");
const puppeteer = require("puppeteer");

const runtimeOptions = {
    timeoutSeconds: 300,
    memory: "1GB",
}

// リージョンを東京に変更
exports.helloWorld = functions
    .runWith(runtimeOptions)
    .region("asia-northeast1")
    .https.onCall((data, context) => {

});
```

次は実際にPuppeteerでスクレイピングを行うコードを追加します。

```js
const functions = require("firebase-functions");
const puppeteer = require("puppeteer");

const runtimeOptions = {
    timeoutSeconds: 300,
    memory: "1GB",
}

exports.helloWorld = functions
    .runWith(runtimeOptions)
    .region("asia-northeast1")
    // awaitを使うので引数の前にasyncを追加
    .https.onCall(async (data, context) => {
        // puppeteerを起動
        const browser = await puppeteer.launch({
            headless: true,// ヘッドレスモード（操作しているブラウザの表示の有無）をtrue
            // chromiumに渡すコマンドライン
            args: [
            "--disable-gpu",
            "--disable-dev-shm-usage",
            "--disable-setuid-sandbox",
            "--no-first-run",
            "--no-sandbox",
            "--no-zygote",
            "--single-process",
            ],
        });
        // スクレイピングを行うURLを設定（今回はReactから受け取った検索ワードでgoogleの検索ページのURLを設定）
        const url = `https://www.google.com/search?q=${data}`;
        // 新しいブラウザを作成
        const page = await browser.newPage();
        // URLのページへ遷移
        await page.goto(url);
        // 遷移したページのタイトルを取得
        const title = await page.title();
        // ブラウザを閉じる
        await browser.close();
        
        // 取得したタイトルをReactに送る
        return {title};
});
```
内容としてはコメントアウトで記載した通りですが、補足するとしたらpuppeteer.launchに設定しているオプションのargsには多くのコマンドがあり、内容は[こちら](https://peter.sh/experiments/chromium-command-line-switches/)で確認できます。

最後にpuppeteer-chromium-resolverの設定を行います。

```js
const functions = require("firebase-functions");
const puppeteer = require("puppeteer");
// puppeteer-chromium-resolverを読み込み
const PCR = require("puppeteer-chromium-resolver");

const runtimeOptions = {
    timeoutSeconds: 300,
    memory: "1GB",
}

exports.helloWorld = functions
    .runWith(runtimeOptions)
    .region("asia-northeast1")
    .https.onCall(async (data, context) => {
        // PCR関数にオプションを追加（今回は空です）
        const options = {};
        const stats = await PCR(options);

        // puppeteerの前に作成したstatsをつける
        const browser = await stats.puppeteer.launch({
            headless: true,
            args: [
            "--disable-gpu",
            "--disable-dev-shm-usage",
            "--disable-setuid-sandbox",
            "--no-first-run",
            "--no-sandbox",
            "--no-zygote",
            "--single-process",
            ],
            // 起動ブラウザのパスを指定
            executablePath: stats.executablePath
        });

        const url = `https://www.google.com/search?q=${data}`;
        const page = await browser.newPage();
        await page.goto(url);
        const title = await page.title();
        await browser.close();
        
        return {title};
});
```
これでスクレイピングに必要なコードは完了になります。

## ④FirebaseCloudFunctionsにコードをデプロイ
コードが完成したので、「scraping-test-firebase」をFirebaseCloudFunctionsに反映します。
VSCODEのターミナルで下記のコマンドを実行し、ターミナルでの位置を「functions」から「scraping-test-firebase」に移動します。

```
cd ..
```

移動ができたらデプロイするための下記コマンドを実行します。

```
firebase deploy --only functions
```
処理が進んでいって最後にこちらが表示されたらデプロイは完了です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/dd33aff6-603a-09b9-1e66-921ad9792236.png" width="200px">

## ⑤React側の準備
ここまででFirebase側の実装が完了したので、ここからはReact側の実装に移っていきます。
下記コマンドをターミナルで実行し、Reactのプロジェクトを作成します。
```
cd
```
```
npx create-react-app scraping-test-react
```
ここまでできたら、ReactとFirebaseを紐づける作業を[Firebaseコンソール](https://console.firebase.google.com/?hl=ja)で行います。
作成した「scraping-test」を押下して開き、左側にある歯車アイコンを押下→プロジェクトの設定を押下します。
最初から選択されている「全般」タブの一番下にスクロールするとマイアプリという項目に下記の表示があるので、今回はWEBアプリを追加するため「\</>」のアイコンを押下します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/c35e6e7a-db10-f68c-eafc-c18552c4588a.png" width="600px">

するとアプリの登録画面が開きますのでアプリのニックネームのところに「scraping-test」を入力します（名前はなんでも大丈夫です。）
今回はFirebaseHostingは使用しないので「このアプリのFirebase Hostingも設定します」にはチェックを入れません。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/086f3de9-bf4c-fcc8-feab-74d1f8555a38.png" width="600px">

すると下記のページに遷移できますので、ここに記載の通りにReactにFirebaseの追加を行なっていきます。（このページに記載の内容は、先ほどの全般タブの一番下に追加されていますので閉じてしまっても大丈夫です。）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/78e4455c-b8f2-822c-94eb-3cb5e372827d.png" width="600px">

先ほど作成したscraping-test-reactフォルダをVSCODEで開き、VSCODE内のターミナルでマイアプリに記載のあった通りに下記のコマンドを実行し、Firebaseをインストールします。
```
npm install firebase
```
Firebaseのインストールが完了したら、srcフォルダ内に「firebase.js」ファイルを作成し、その中にマイアプリに記載のあるSDKの内容をコピペし、余計なコメントアウトを削除します。
```js
import { initializeApp } from "firebase/app";

const firebaseConfig = {
  apiKey: "SDKのapiKeyの値",
  authDomain: "SDKのauthDomainの値",
  projectId: "SDKのprojectIdの値",
  storageBucket: "SDKのstorageBucketの値",
  messagingSenderId: "SDKのmessagingSenderIdの値",
  appId: "SDKのappIdの値"
};

const app = initializeApp(firebaseConfig);
```

次にFirebaseCloudFunctionsを使用するための記述と、FirebaseCloudFunctions内に作成した関数を呼び出す記述を行います。


```js
import { initializeApp } from "firebase/app";
// FirebaseCloudFunctionsとFunctions内の関数を呼び出す関数をインポート
import { getFunctions, httpsCallable } from 'firebase/functions';

const firebaseConfig = {
  apiKey: "SDKのapiKeyの値",
  authDomain: "SDKのauthDomainの値",
  projectId: "SDKのprojectIdの値",
  storageBucket: "SDKのstorageBucketの値",
  messagingSenderId: "SDKのmessagingSenderIdの値",
  appId: "SDKのappIdの値"
};

const app = initializeApp(firebaseConfig);
// プロジェクトの情報とリージョンで、作成したFirebaseCloudFunctionsを取得
const functions = getFunctions(app, "asia-northeast1");
// 取得したFunctions内のscraping関数を取得してエクスポートする
export const scraping = httpsCallable(functions, 'scraping');
```

上記でFirebaseとの紐付けは完了したので、srcフォルダ内にあるApp.jsを開き余分な部分を削除して、下記の状態にします。

```js
function App() {
  return (
    <div className="App">

    </div>
  );
}

export default App;
```
次に下記のようにコードを追加します。
```js
import { useState } from "react";
// 先ほど作成したfirebase.jsからscraping関数を読み込む
import { scraping } from "./firebase"

function App() {
  const [ searchWord, setSearchWord ] = useState('')

  const onChangeSearch = (e) => setSearchWord(e.target.value);
  const onClickSearch = () => {
    // scraping関数の引数にinputエリアに記載したテキストを設定
    scraping(searchWord).then(res => {
      // スクレイピングした内容を表示
      console.log(res);
    })
  }

  return (
    <div className="App">
      <input onChange={onChangeSearch} />
      <button onClick={onClickSearch}>検索</button>
    </div>
  );
}

export default App;

```
この状態になりましたらscraping-test-reactのVSCODEのターミナルで下記のコマンドを実行してください
```
npm start
```
新しいタブが開いて下記の検索エリアが表示されますので、「Qiita」と入力して検索ボタンを押下します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/5cdb549c-2d8a-3621-63c1-83eaec020b74.png" width="200px">
しばらく待つとコンソールにgoogleで「Qiita」を検索した際のページのタイトルが表示がされスクレイピングは完了になります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3115415/2434f4e5-4e5a-b62f-2287-9dd81954db7d.png" width="600px">
以上になります。
これを応用すれば他のサイトのスクレイピングなどもできるようになるかと思います。