【GCP】Cloud Functionでリングフィットアドベンチャーの入荷をクロール&結果を通知するLINEbotを作ってみた【TypeScript】

# :star: はじめに
- 最近、リングフィットアドベンチャーが流行っているらしい
  - [Nintendo Switch『リングフィット アドベンチャー』が深刻な入手難に。楽しくムキムキになれるとクチコミを呼び、再入荷も瞬殺 | AUTOMATON](https://www.google.co.jp/amp/s/automaton-media.com/articles/newsjp/20191112-106096/amp/) 
- 自分も運動不足気味なので買おうと思ったのですが、上のリンクにあるようにしばらくは品切れのようです
- いちいちサイトをチェックするのも面倒なので、入荷したら通知するようなシステムを作ってみました
- 無料枠かつシンプルな構成にしたかったのと、GCPでクローラーを作る練習として次のようなものを使ってみました

# :star: 使用したもの
- [GCP](https://cloud.google.com/)
  - [Cloud Functions for Firebase](https://firebase.google.com/docs/functions/)
  - [Cloud Pub/Sub](https://cloud.google.com/pubsub/)
  - [Cloud Scheduler](https://cloud.google.com/scheduler/)
  - [Cloud Firestore](https://firebase.google.com/docs/firestore)
  - [Firebase CLI](https://firebase.google.com/docs/cli)
- [LINE Messaging API](https://developers.line.biz/ja/services/messaging-api/)
  - [line-bot-sdk-nodejs](https://line.github.io/line-bot-sdk-nodejs/)
  - [クイックリプライ](https://developers.line.biz/ja/docs/messaging-api/using-quick-reply/)
- [TypeScript](https://www.typescriptlang.org/index.html)
- [Puppeteer](https://github.com/GoogleChrome/puppeteer)

# :star: できたもの

<img width="375" alt="Image from iOS.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/a800f772-6888-08b6-17ee-3e7300025952.png">

- 指定の時間にリングフィットアドベンチャーのwebサイトで在庫情報を確認し、LINEで通知するbotを作成しました
- `チェック` というメッセージを送ることで好きなタイミングで確認させることもできるようにしました
- LINEbotの無料枠は月１０００通までになってしまったので、残念ながらC向けではなく自分用として作成しました

- ソースコードはこちら
  - https://github.com/mikan3rd/web-crawl

# :star: 動作の流れ

## 定期処理の場合
1. Cloud Schedulerのジョブが実行される
2. Cloud Pub/Sub トリガーを呼び出される
3. Cloud Functionが実行される
4. Puppeteerでwebサイトのクロールを行う
5. Cloud Firestoreから前回のクロール結果を取得する
6. 今回と前回のクロール結果を比較し、変化があればCloud Firestoreのデータを更新する
7. 変更がある場合のみLINEbotでクロール結果を送信する

## LINEのメッセージをトリガーとした場合
1. `チェック` というテキストを送信する
2. LINE MeesagingAPIからwebhookが呼ばれる
2. Cloud Functionが実行される
4. Puppeteerでwebサイトのクロールを行う（同じ）
5. Cloud Firestoreから前回のクロール結果を取得する（同じ）
6. 今回と前回のクロール結果を比較し、変化があればCloud Firestoreのデータを更新する（同じ）
7. LINEbotでクロール結果を送信する


# :star: 実装手順

## :pencil: Firebaseプロジェクトの作成
- あらかじめ、GCPにアカウントを作成している前提で進めてきます
- Firebaseのコンソールページを開き、プロジェクトを作成しておきます
  - https://console.firebase.google.com/

- リソースロケーションを設定する必要があります
  - :gear: アイコンをクリックして設定ページに遷移し、 `Google Cloud Platform（GCP）リソース ロケーション` を設定します
  - 東京であれば `asia-northeast1` を選択します
  - これをやっておかないと次のCLIでエラーになるので注意

![console.firebase.google.com_project_mikan-no-test-002_settings_general_(Laptop with MDPI screen).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/8cda777e-cf6f-32f5-7af3-6616f26b6875.png)

- 以降はterminalから操作していきます

## :pencil: リポジトリの雛形を生成

- terminalを開き、CLIをインストールします
  - https://firebase.google.com/docs/cli 

```bash
npm install -g firebase-tools
```

- ログイン認証を行います

```bash
firebase login
```

- ブラウザが起動するのでログインします

![localhost_9005__state=949310823&code=4_tQH--0ytnaEXFsMxuJXS5ZQpyBBX3yv7bQsI8ITREIPP4mRTDiVhEo9MQbp71e_WZ7SWWwVd_EVvsxqNpnjrE0U&scope=email%20https___www.googleapis.com_auth_userinfo.email%20openid%20https___www.googleapis.com_auth_cloudplat.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/24d14733-2115-cba7-e802-8da798bd82b2.png)

- リポジトリの雛形を `firebase init` で生成できます
- `web-crawl` というリポジトリ名にしたい場合は次のようになります

```bash
mkdir web-crawl
cd web-crawl
firebase init
```

- 表示される指示に従って入力していきます
- 使用するFirebaseの機能について、今回は FirestoreとFunctionsをspaceキーで選択します

<img width="966" alt="スクリーンショット 2019-11-18 19.18.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/ed790edf-ea55-c956-3eec-c7a467536a90.png">

- 次は `Use an existing project` を選択し、先ほど作成したプロジェクトを選択します
- ruleとindexはデフォルトのまま進めます
- 言語選択では `TypeScript` を使用します

<img width="789" alt="スクリーンショット 2019-11-18 19.39.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/e90eb58d-454b-0595-b3f9-cc098ef9789a.png">


- TSlintも有効化しておき、ライブラリもinstallしておきます

<img width="705" alt="スクリーンショット 2019-11-18 19.42.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/7eeec474-75e2-ed32-56b3-19182ff95b35.png">

- 以上でCloud Functionで開発する雛形が生成されます
- この状態 `initial commit` としてGitHubなどにリポジトリ登録しておくと良いと思います


## :pencil: Firebaseにデプロイする
- 次のコマンドでfirebaseにデプロイできます

```bash
firebase deploy
```

- firebaseのコンソールのページでデプロイされていることを確認できます

![console.firebase.google.com_u_0_project_ferrous-iridium-258611_functions_list(Laptop with HiDPI screen).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/c0a9288f-3fb9-6662-b5af-2ae23e06bbea.png)


- cloud functionのみデプロイすることもできます

```bash
firebase deploy --only function:helloWorld
```

- デプロイされるのは `functions/src/index.ts` でexportしている関数となります

```ts:functions/src/index.ts

import * as functions from 'firebase-functions';

export const helloWorld = functions.https.onRequest((request, response) => {
  response.send('Hello from Firebase!');
});

```


## :pencil: Puppeteerでクロールする
- Cloud Functionで外部のネットワークにアクセスするためには従量制のBlazeプランに変更する必要があります
- 今回の利用程度であれば無料枠で利用できますが、使いすぎると課金が発生しますので、よくご確認ください

>Spark プランでは、Google が所有するサービスにのみ送信ネットワーク リクエストを送信できます。受信呼び出しリクエストは割り当ての範囲内で実行できます。
Blaze プランでは、Cloud Functions で永久的な無料枠を提供しています。初回の 2,000,000 回の呼び出し、400,000 GB-秒、200,000 CPU-秒、5 GB のインターネット下りトラフィックが毎月無料で提供されます。この無料の割り当てを超えると、使用量に応じて課金されます。料金は呼び出しの総数とコンピューティング時間に基づいて計算されます。コンピューティング時間は、1 つの機能に用意されているメモリと CPU の量によって変化します。また、使用量の上限は、1 日および 100 秒ごとの割り当てによって適用されます。詳しくは、[Cloud Functions の料金](https://cloud.google.com/functions/pricing)をご覧ください。

- puppeteerをインストールします
  - `package.json` があるディレクトリ、つまり `functions` ディレクトリで行う必要があることに注意しましょう

```bash
cd functions
npm i puppeteer --save
```

- Puppeteerを使ってwebページをクロールしてみましょう
- 今回のクロール対象は次のページです
  - https://store.nintendo.co.jp/item/HAC_Q_AL3PA.html

<img width="1384" alt="スクリーンショット 2019-11-18 23.18.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/ee5ed942-7fb7-cf37-3b4e-536b61f4a9f7.png">

- `getRingFitSaleStatus` という関数を定義してクロール処置を書いてみました
  - `headless: true` にすることで　実際にブラウザの動作を確認できます
- `カートに追加する` もしくは `品切れ` のボタンの部分を取得してみましょう
  - 両方とも `.item-cart-add-area__add-button` セレクタを参照することで確認できます
  - 在庫がある場合はinputタグになっているため、そのvalueを取得します
  - 在庫がない場合はpタグになっているためtextContentを取得します
  - puppeteerの詳しい説明は公式のAPIリファレンスをご確認ください
  - https://github.com/puppeteer/puppeteer/blob/master/docs/api.md
- また、PuppeteerをCloud Functionで使うためにはメモリを増やす必要があります
  - `functions.runWith({ memory: '1GB' })` のようにすることでメモリを増やせます
  - 同様に、 `functions.region('asia-northeast1')` でリージョンを東京に指定できます


```functions/src/index.ts
import * as functions from 'firebase-functions';
import * as puppeteer from 'puppeteer';

const RING_FIT_URL = 'https://store.nintendo.co.jp/item/HAC_Q_AL3PA.html';

export const helloWorld = functions
  .runWith({ memory: '1GB' })
  .region('asia-northeast1')
  .https.onRequest(async (request, response) => {
    const result = await getRingFitSaleStatus();
    response.json({ result });
  });


const getRingFitSaleStatus = async () => {
  let content = '';
  try {
    const browser = await puppeteer.launch({
      headless: true,
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    });
    const page = await browser.newPage();

    await page.goto(RING_FIT_URL);

    const item = await page.$('.item-cart-add-area__add-button');
    if (item) {
      const value = await (await item.getProperty('value')).jsonValue();
      if (value === 'カートに追加する') {
        content = '在庫あり';
      } else {
        const textContent = await (await item.getProperty('textContent')).jsonValue();
        content = value || textContent || '取得できませんでした';
      }
    }

    await browser.close();
  } catch (error) {
    content = JSON.stringify(error);
  }
  console.log('content', content);
  return content;
};
```

- これを実際に試すには `npm run serve` コマンドを使用します
  - これも `package.json` のあるディレクトリで実行する必要があります
  - 成功すると `http://localhost:5000...` のようにendpointが表示されるので、そこにアクセスすることで動作させることができます

```bash:functions
npm run serve

...省略

✔  functions[helloWorld]: http function initialized (http://localhost:5000/XXX/asia-northeast1/helloWorld).
```

- デプロイしてfirebaseのダッシュボードに表示されているendpointにアクセスしてみましょう
- その後、ログのページで正常に動作したログが残っているか確認してみましょう

![console.firebase.google.com_u_0_project_ferrous-iridium-258611_functions_logs_severity=DEBUG(Laptop with HiDPI screen).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/552a9473-0a55-b0ba-b59d-ecdd5683b02f.png)


## :pencil: LINEbotを作成する
- LINEでMessaging APIのチャネルを作成します
  - https://developers.line.biz/ja/
- LINEbotはフリープランで月1000通のメッセージを送信できます
  - https://www.linebiz.com/lp/line-official-account/plan/

![developers.line.biz_console_register_messaging-api_channel__provider=1525907520(Laptop with HiDPI screen).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/0a4a7905-3a4d-4c7f-2aec-f536973ebf0e.png)

- 作成後、アクセストークンを発行しておきます

![developers.line.biz_console_channel_1653499828_basic_(Laptop with HiDPI screen).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/1c8038ce-adce-7ab9-6a17-9869dd2e1649.png)

- また、下の方に `LINEアプリへのQRコード` があるので自分のLINEの友達に追加しておきます

## :pencil: LINEのwebhookを受け取る
- `line-bot-sdk-nodejs` をinstallします
  - https://line.github.io/line-bot-sdk-nodejs/

```bash:functions
npm i @line/bot-sdk --save
```

- LINEのwebhookを受け取るCloud Functionを追加します
  - `channelAccessToken` と `channelSecret` は先ほど作成したbotのものに置き換えてください
  - `validateSignature` は `X-Line-Signature` リクエストヘッダーに含まれる署名を検証してリクエストがLINEプラットフォームから送信されたことを確認するために必要です
- TypeScriptでの実装については次を参考にしました
  - https://line.github.io/line-bot-sdk-nodejs/guide/typescript.html
  - https://github.com/line/line-bot-sdk-nodejs/blob/master/lib/types.ts 

```ts:functions/src/index.ts
import * as line from '@line/bot-sdk';

const config = {
  channelAccessToken: 'XXX',
  channelSecret: 'XXX',
} as line.Config;

const client = new line.Client(config as line.ClientConfig);

export const lineWebhook = functions
  .runWith({ memory: '1GB' })
  .region('asia-northeast1')
  .https.onRequest(async (request, response) => {
    const signature = request.get('x-line-signature');

    if (!signature || !line.validateSignature(request.rawBody, config.channelSecret as string, signature)) {
      throw new line.SignatureValidationFailed('signature validation failed', signature);
    }

    Promise.all(request.body.events.map(handleLineEvent))
      .then(result => response.json(result))
      .catch(error => console.error(error));
  });
```

- さらに、送信されたメッセージによって処理を変えるhandlerを追加します
  - ここではテキストメッセージを受け取った時のeventオブジェクトを文字列化して返すようにしています
  - `replyMessage` で使用している `event.replyToken` は一度しか使用できません

```ts:functions/src/index.ts
const handleLineEvent = async (event: line.WebhookEvent) => {
  if (event.type !== 'message' || event.message.type !== 'text') {
    return Promise.resolve(null);
  }

  const {
    message: { text },
  } = event;

  return client.replyMessage(event.replyToken, {
    type: 'text',
    text: JSON.stringify(event),
  });
};
```

- 上記を追加したものをデプロイし、生成されたURLをfirebaseのコンソールページで確認してください
- それをLINEbot設定画面の `Webhook URL` に登録することで、LINEbotにメッセージが送信された時にwebhookに指定されたURLを呼ぶようになります
- メッセージを送信すると次のように返ってくることを確認します

<img width="375" alt="S__93323288.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/9bbae6dc-dce3-8396-9568-e62bb80d1955.jpeg">

- この時に返ってきたメッセージにある `userId` を使用するのでメモしていおいてください

## :pencil: LINEに通知を送る
- `チェック` というテキストが送信された時にクロールを行うように変更します
  - `targetUserId` には先ほど取得した自分の `userId` を記述します
  - `userID` が分かっている場合は `client.pushMessage()` でpushメッセージを送信できます
  - `quickReply` を付与することで、メッセージと一緒に小さいテキストボタンなどをつけることができます
  - 今回は毎回 `チェック` という文字を入力しなくて良いように `チェック` というメッセージアクションをつけています


```ts:functions/src/index.ts
const targetUserId = 'XXX';

const handleLineEvent = async (event: line.WebhookEvent) => {
  if (event.type !== 'message' || event.message.type !== 'text') {
    return Promise.resolve(null);
  }

  const {
    message: { text },
  } = event;

  let replyText = text;
  if (text === 'チェック') {
    await client.pushMessage(targetUserId, { type: 'text', text: 'クロール中...' });
    replyText = await getRingFitSaleStatus();
  }

  return client.replyMessage(event.replyToken, {
    type: 'text',
    text: replyText,
    quickReply: {
      items: [
        {
          type: 'action',
          action: {
            type: 'message',
            label: 'チェック',
            text: 'チェック',
          },
        },
      ],
    },
  });
};
```

- これをデプロイすると、 `チェック` というメッセージが送られた時だけクロールが実行されるようになります

## :pencil: Cloud Functionの環境変数の設定

- 環境変数は `firebase functions:config:set` で設定できます
  - https://firebase.google.com/docs/functions/config-env

```bash
firebase functions:config:set line.user_id="XXX" line.channel_access_token="XXX" line.channel_secret="XXX"
```

- 設定した環境変数は次のコマンドで確認できます

```bash
firebase functions:config:get

{
  "line": {
    "channel_access_token": "XXX",
    "user_id": "XXX",
    "channel_secret": "XXX"
  }
}
```

- 環境変数には `functions.config()` でアクセスすることができます

```ts:functions/src/index.ts
const LINE_ENV = functions.config().line;

export const targetUserId = LINE_ENV.user_id;
export const config = {
  channelAccessToken: LINE_ENV.channel_access_token,
  channelSecret: LINE_ENV.channel_secret,
} as line.Config;
```

- これで環境変数を用いてCloud Functionを実行できるようになりました


## :pencil: Cloud Schedulerで定期実行
- Cloud Schedulerはフルマネージドcronジョブサービスです
- 毎月3つのジョブまで無料で利用できます
  - https://cloud.google.com/scheduler/pricing
- 先にCloud Schedulerで呼ぶPub/Subトピックを作成しておきます
  - https://console.cloud.google.com/cloudpubsub/topic/list

![console.cloud.google.com_cloudpubsub_topic_list_project=ferrous-iridium-258611(Laptop with HiDPI screen) (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/e15bf643-2efb-8d75-7c36-f5f8e2093134.png)

- その後、次のページからCloud Schedulerのジョブを作成します
  - https://console.cloud.google.com/cloudscheduler
  - 先ほどのPub/Subトピックを指定します
  - 頻度を `0 * * * *` とした場合、毎時0分に実行されます

![console.cloud.google.com_cloudscheduler_jobs_edit_asia-northeast1_ring-fit-adventure_project=ferrous-iridium-258611&hl=ja(Laptop with MDPI screen).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178077/38f09eb6-52a7-8013-7d46-4dc32a36da50.png)

- このPub/Subをトリガーとする関数を作成します
  - https://firebase.google.com/docs/functions/pubsub-events

```ts:functions/src/index.ts
export const helloPubSub = functions
  .runWith({ memory: '1GB' })
  .region('asia-northeast1')
  .pubsub.topic('ring-fit-adventure')
  .onPublish(async message => {
    const text = await getRingFitSaleStatus();
    await client.pushMessage(targetUserId, { type: 'text', text });
    return text;
  });
```
- 上記をデプロイ後、Cloud Schedulerのページで `今すぐ実行` をクリックして試すことができます
- Cloud Functionのログが正常であれば成功です

## :pencil: Cloud Firestoreにクロール結果を保存
- このままだと毎時LINEに通知がきてしまうので、変更があった場合のみ通知されるようにします
- 次のように `crawlType` と `newResultText` を引数とした関数を作成しました
  - Firestoreの `crawl` コレクションから `crawlType` が一致するものを探します
  - 既に `resultText` がある場合は比較し、同じ場合は `false` を返して終了します
  - それ以外の場合は新しい `resultText` で更新し、 `true` を返します
  - `FieldValue.serverTimestamp()` ではタイムスタンプを自動で付与することができます

```ts:functions/src/index.ts
import * as admin from 'firebase-admin';

admin.initializeApp();
const db = admin.firestore();
const { FieldValue } = admin.firestore;

const updateCrawlResult = async (crawlType: string, newResultText: string) => {
  const crawlCollection = db.collection('crawl');
  const doc = await crawlCollection.doc(crawlType).get();

  if (doc.exists) {
    const result = doc.data();
    if (!result) {
      return false;
    }

    const { resultText } = result;
    if (newResultText === resultText) {
      return false;
    }
  }

  const crawlData = {
    resultText: newResultText,
    updated_at: FieldValue.serverTimestamp(),
  };

  crawlCollection
    .doc(crawlType)
    .set(crawlData, { merge: true })
    .catch(err => {
      throw new functions.https.HttpsError('internal', 'Failed to set crawl data', err);
    });

  return true;
};
```

- これを用いて今までの関数に組み込むと次のようになります
  - `checkRingFitStatus()` を作成し、クロールとデータの更新を行い、クロール結果を `resultText: string` 、変更の有無を `difference: boolean` で返します
  - 上記の返り値に合わせて処理を書き変えます

```ts:functions/src/index.ts

const checkRingFitStatus = async () => {
  const ringFitCrawlType = 'ring-fit-adventure';
  const resultText = await getRingFitSaleStatus();
  const difference = await updateCrawlResult(ringFitCrawlType, resultText);
  return { difference, resultText };
};

const handleLineEvent = async (event: line.WebhookEvent) => {
  if (event.type !== 'message' || event.message.type !== 'text') {
    return Promise.resolve(null);
  }

  const {
    message: { text },
  } = event;

  let replyText = text;
  if (text === 'チェック') {
    await client.pushMessage(targetUserId, { type: 'text', text: 'クロール中...' });
    const result = await checkRingFitStatus();
    replyText = result.resultText;
  }

  return client.replyMessage(event.replyToken, {
    type: 'text',
    text: replyText,
    quickReply: {
      items: [
        {
          type: 'action',
          action: {
            type: 'message',
            label: 'チェック',
            text: 'チェック',
          },
        },
      ],
    },
  });
};

export const helloPubSub = functions
  .runWith({ memory: '1GB' })
  .region('asia-northeast1')
  .pubsub.topic('ring-fit-adventure')
  .onPublish(async message => {
    const result = await checkRingFitStatus();
    if (result.difference) {
      await client.pushMessage(targetUserId, { type: 'text', text: result.resultText });
    }
    return result;
  });


```

- これで定期処理とLINEのwebhookの両方をトリガーにした処理が完成しました！
- ソースコードはこちら
  - https://github.com/mikan3rd/web-crawl

# :star: おわりに
- このような仕組みは他の使い方にも応用可能ですし、GCP、puppeteer、TypeScriptを合わせたチュートリアルにもなると思うので、参考にしていただけたら幸いです
- また、内容に不備などございましたら気軽に編集リクエストを送っていただけると助かります :pray: 
- あとは入荷を待つのみ・・・！

## :star2: 追記
もりもりやってます

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://twitter.com/hashtag/%E3%83%AA%E3%83%B3%E3%82%B0%E3%83%95%E3%82%A3%E3%83%83%E3%83%88%E3%82%A2%E3%83%89%E3%83%99%E3%83%B3%E3%83%81%E3%83%A3%E3%83%BC?src=hash&amp;ref_src=twsrc%5Etfw">#リングフィットアドベンチャー</a> <a href="https://twitter.com/hashtag/RingFitAdventure?src=hash&amp;ref_src=twsrc%5Etfw">#RingFitAdventure</a> <a href="https://twitter.com/hashtag/NintendoSwitch?src=hash&amp;ref_src=twsrc%5Etfw">#NintendoSwitch</a> <a href="https://t.co/qDGlfGRYq5">pic.twitter.com/qDGlfGRYq5</a></p>&mdash; みかん三世 (@mikan_the_third) <a href="https://twitter.com/mikan_the_third/status/1213474909839429632?ref_src=twsrc%5Etfw">January 4, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>