73
65

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

PatheeAdvent Calendar 2019

Day 17

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

Last updated at Posted at 2019-11-18

:star: はじめに

:star: 使用したもの

:star: できたもの

Image from iOS.png
  • 指定の時間にリングフィットアドベンチャーのwebサイトで在庫情報を確認し、LINEで通知するbotを作成しました

  • チェック というメッセージを送ることで好きなタイミングで確認させることもできるようにしました

  • LINEbotの無料枠は月1000通までになってしまったので、残念ながらC向けではなく自分用として作成しました

  • ソースコードはこちら

: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が呼ばれる
  3. Cloud Functionが実行される
  4. Puppeteerでwebサイトのクロールを行う(同じ)
  5. Cloud Firestoreから前回のクロール結果を取得する(同じ)
  6. 今回と前回のクロール結果を比較し、変化があればCloud Firestoreのデータを更新する(同じ)
  7. LINEbotでクロール結果を送信する

:star: 実装手順

:pencil: Firebaseプロジェクトの作成

  • あらかじめ、GCPにアカウントを作成している前提で進めてきます

  • Firebaseのコンソールページを開き、プロジェクトを作成しておきます

  • リソースロケーションを設定する必要があります

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

console.firebase.google.com_project_mikan-no-test-002_settings_general_(Laptop with MDPI screen).png

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

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

npm install -g firebase-tools
  • ログイン認証を行います
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

  • リポジトリの雛形を firebase init で生成できます
  • web-crawl というリポジトリ名にしたい場合は次のようになります
mkdir web-crawl
cd web-crawl
firebase init
  • 表示される指示に従って入力していきます
  • 使用するFirebaseの機能について、今回は FirestoreとFunctionsをspaceキーで選択します
スクリーンショット 2019-11-18 19.18.55.png
  • 次は Use an existing project を選択し、先ほど作成したプロジェクトを選択します
  • ruleとindexはデフォルトのまま進めます
  • 言語選択では TypeScript を使用します
スクリーンショット 2019-11-18 19.39.24.png
  • TSlintも有効化しておき、ライブラリもinstallしておきます
スクリーンショット 2019-11-18 19.42.01.png
  • 以上でCloud Functionで開発する雛形が生成されます
  • この状態 initial commit としてGitHubなどにリポジトリ登録しておくと良いと思います

:pencil: Firebaseにデプロイする

  • 次のコマンドでfirebaseにデプロイできます
firebase deploy
  • firebaseのコンソールのページでデプロイされていることを確認できます

console.firebase.google.com_u_0_project_ferrous-iridium-258611_functions_list(Laptop with HiDPI screen).png

  • cloud functionのみデプロイすることもできます
firebase deploy --only function:helloWorld
  • デプロイされるのは functions/src/index.ts でexportしている関数となります
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 の料金をご覧ください。

  • puppeteerをインストールします
    • package.json があるディレクトリ、つまり functions ディレクトリで行う必要があることに注意しましょう
cd functions
npm i puppeteer --save
スクリーンショット 2019-11-18 23.18.39.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が表示されるので、そこにアクセスすることで動作させることができます
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

:pencil: LINEbotを作成する

developers.line.biz_console_register_messaging-api_channel__provider=1525907520(Laptop with HiDPI screen).png

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

developers.line.biz_console_channel_1653499828_basic_(Laptop with HiDPI screen).png

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

:pencil: LINEのwebhookを受け取る

functions
npm i @line/bot-sdk --save
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 は一度しか使用できません
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を呼ぶようになります
  • メッセージを送信すると次のように返ってくることを確認します
S__93323288.jpg
  • この時に返ってきたメッセージにある userId を使用するのでメモしていおいてください

:pencil: LINEに通知を送る

  • チェック というテキストが送信された時にクロールを行うように変更します
    • targetUserId には先ほど取得した自分の userId を記述します
    • userID が分かっている場合は client.pushMessage() でpushメッセージを送信できます
    • quickReply を付与することで、メッセージと一緒に小さいテキストボタンなどをつけることができます
    • 今回は毎回 チェック という文字を入力しなくて良いように チェック というメッセージアクションをつけています
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 line.user_id="XXX" line.channel_access_token="XXX" line.channel_secret="XXX"
  • 設定した環境変数は次のコマンドで確認できます
firebase functions:config:get

{
  "line": {
    "channel_access_token": "XXX",
    "user_id": "XXX",
    "channel_secret": "XXX"
  }
}
  • 環境変数には functions.config() でアクセスすることができます
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で定期実行

console.cloud.google.com_cloudpubsub_topic_list_project=ferrous-iridium-258611(Laptop with HiDPI screen) (1).png

  • その後、次のページからCloud Schedulerのジョブを作成します

console.cloud.google.com_cloudscheduler_jobs_edit_asia-northeast1_ring-fit-adventure_project=ferrous-iridium-258611&hl=ja(Laptop with MDPI screen).png

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に通知がきてしまうので、変更があった場合のみ通知されるようにします
  • 次のように crawlTypenewResultText を引数とした関数を作成しました
    • Firestoreの crawl コレクションから crawlType が一致するものを探します
    • 既に resultText がある場合は比較し、同じ場合は false を返して終了します
    • それ以外の場合は新しい resultText で更新し、 true を返します
    • FieldValue.serverTimestamp() ではタイムスタンプを自動で付与することができます
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 で返します
    • 上記の返り値に合わせて処理を書き変えます
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;
  });


:star: おわりに

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

:star2: 追記

もりもりやってます

73
65
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
73
65

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?