【Google Cloud】Cloud Functionsを使ってQiitaの人気記事を配信するLINE Botを作成してみた

## はじめに

Google CloudのCloud Functionsを使用して、Qiitaの人気記事を配信するLINE Botを作成しました！
理由として、Qiitaから送られてくるメールは1週間に1回かつあまり個人メールを見ないので毎日見るLINEなら記事を見る回数増えそうだなーと思って作成してみました！

## 完成イメージ

最終的なイメージは下記になります。人気記事が平日に毎朝自動で配信されます！
![761b2e89-5656-5796-06bc-a49e2dd24d6f (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/cf820d15-3cf4-471f-5ca2-56c57eb6624f.png)

## 今回の構成

今回はGoogle Cloudメインで作成しました！
下記構成図となります。
![Qiita記事.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/7c6d2f64-bd86-abb2-d3cf-3033286034f6.jpeg)
ざっくりとした処理の流れです。
1. Cloud Schedulerで指定した時間にPub/SubからCloud Functionsにメッセージを配信
2. Pub/Subからのメッセージ受信をトリガーにCloud Functionsを実行
3. Cloud FunctionsがQiita API v2から人気記事を取得し、Messaging APIを使用してLINEにメッセージを配信

:::note warn
Cloud Functions単体で定期実行の仕組みがなく、またCloud SchedulerとCloud Functionsの直接連携ができないため、Cloud Scheduler + Pub/Subを使用してCloud Functionsの定期実行を行います。
[公式ドキュメント](https://cloud.google.com/scheduler/docs/tut-pub-sub?hl=ja)にもCloud Functionsを定期実行する手法として上記の連携手法が紹介されています。
:::

### 使用したサービス

| サービス名      | 用途                                                         |
| --------------- | ------------------------------------------------------------ |
| Cloud Scheduler | 1日1回ジョブを起動し、Pub/Subでメッセージを配信する。        |
| Pub/Sub         | Cloud Schedulerで指定した時間にメッセージを配信し、Cloud Functionsのトリガーとする。 |
| Cloud Functions | Qiita API v2にアクセスして、人気記事を取得し、LINEのMessaging APIを使用してメッセージを配信する。 |
| Firestore       | 1度配信した人気記事を記録して、既に配信した人気記事を配信しないようにする。（※次回使用） |
| Qiita API v2    | Qiitaが公開しているWeb API。今回はこちらにアクセスして記事を取得する。 |
| Messaging API   | LINEが提供しているメッセージ配信用のAPI。                    |

##  下準備

### チャネル作成・ChannelSecretとChanelAccessTokenを取得

[以前投稿した記事](https://qiita.com/nanndot/items/ac01a5214c9fa32fff2f)にチャネルの作成方法やトークンなどの取得方法を紹介しているので、参考にしながらチャネルを作成して`ChannelSecret`と`ChanelAccessToken`を取得します。

### Pub/Subでトピック作成

Cloud SchedulerとCloud Functionsを連携するために、繋ぎのサービスとしてPub/Subを使用します。
今回トピック名は`article-topic`としてスクリーンショットのように作成します。

![82ebc3cd-f6f6-4dfd-8395-e4be4ed5b536.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/26b45335-85d8-3c60-7125-5800836e41a9.png)


### Cloud Schedulerでスケジューラー設定

#### スケジュールの定義

決まった日時でPub/Subでメッセージを配信するためにスケジューラーを設定します。
cron式で設定します。今回は通勤中に確認したく平日の朝6時に配信してほしいので`0 06 * * 1-5`と設定し、名前は`publish-article-topic-job`にします。![1097f774-0030-f83f-9ab4-e9bb3abbe685 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/277c52d6-27ee-39d3-e12c-2c13724cbfba.png)

#### 実行内容の構成

ターゲットタイプ`Pub/Sub`を選択して、先ほど作成したトピック`article-topic`を選択します。メッセージ本文は使用しないですが、必須のため適当なメッセージを入力しておきます。
![aa489eae-e49f-ac6c-9e05-be1405299004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/47b98645-a49c-1451-b77f-063c4efed3cc.png)

### Cloud Functionsの作成

#### 構成とトリガーの追加

環境は`第2世代`を選択し、関数名はLINEにQiita記事を送信するので`sendQiitaArticles`と命名します。
Pub/Subから配信されたメッセージの受信をトリガーに関数実行してほしいので、`EVENTARC トリガーを追加`ボタンをクリックしてトリガーを追加します。

![d9ff9209-381e-7372-b4ba-34db7f044da5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/f2c5be25-adb7-8a3c-cc1a-ee726fc70220.png)

#### トリガーの設定

イベント プロパイダに`Cloud Pub/Sub`を選択して、先ほど作成したトピック`article-topic`を選択します。
また注意メッセージに従ってサービスアカウントのロール付与も行います。

:::note warn
このロール付与を行う理由はトリガー起動時に権限エラーが発生し、連携が上手くいかないためです。
:::
![a0f182ff-7efa-586a-0029-42d2c2a3caa4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/8df9293f-737b-75b3-72a1-9f7fe18b5db7.png)

####  デプロイ

ソースコードは後でアップロードするため、一旦デフォルトのままデプロイを行います。

![スクリーンショット 2023-05-20 11.35.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/5abc4917-322a-c116-b8ad-7813a5d24168.png)

### 各サービス連携の疎通確認

作成したCloud Functionsにメッセージを配信するソースコードをデプロイして各サービスが連携できるか確認してみましょう。

#### ライブラリインストール
必要なライブラリをインストールします。Cloud Functionsの標準ライブラリ、LINE Bot SDK、環境変数、HTTPライブラリを使用したいため下記ライブラリをインストールして実装を進めていきます。

```shell:実行コマンド
npm install @line/bot-sdk dotenv @google-cloud/functions-framework axios
```

#### 環境変数

`.env`ファイルに取得した`CHANNEL_ACCESS_TOKEN`と`CHANNEL_SECRET`を設定します。

```js:.env
CHANNEL_ACCESS_TOKEN = "XXX"
CHANNEL_SECRET = "XXX"
```

####  ソースコード

`index.js`にLINEにメッセージを配信するコードを記載します。

```javascript:index.js
const line = require("@line/bot-sdk");
require("dotenv").config();
const functions = require("@google-cloud/functions-framework");

//channelAccessTokenとchannelSecretを環境変数から取得
const config = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.CHANNEL_SECRET,
};

//LINEのクライアントインスタンス生成
const client = new line.Client(config);

//エントリポイント
functions.cloudEvent("sendQiitaArticles", async (cloudEvent) => {
  const messages = [
    {
      type: "text",
      text: "テスト送信です！",
    },
  ];

  try {
    //LINEにメッセージを配信
    await client.broadcast(messages);
    console.log("配信に成功しました！");
  } catch (error) {
    console.log(`エラー: ${error.statusMessage}`);
    console.log(error.originalError.response.data);
  }
});
```

#### デプロイ・動作確認

作成したソースコードをzipで圧縮してCloud Functionsへアップロードします。
アップロード後、Cloud Schedulerを手動実行して、LINEへメッセージが配信されるか確認します。

##### デプロイ

![3f820319-330b-83c3-752b-ea9eda7e0df8 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/affcf691-8ce6-384f-ede6-a55b413b2c7f.png)

##### 動作確認

`publish-article-topic-job`を選択して、強制実行をクリックしてジョブを起動します。

![2fd2bc5b-4a27-fa71-fa73-19c0dbefbdf8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/998d8b89-4786-9be6-06b7-386c937c2315.png)


##### 実行結果
![4cb29154-306f-da13-f331-6fd8f56f9320.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/b5dbac5e-6cd4-767d-4c48-04da5b24d11a.png)
上手く配信できていますね！
下準備は完了で、本格的にQiitaの人気記事を取得してメッセージを配信するよう実装を進めていきます。

## 実装

### Qiitaから人気記事を取得

Qiitaから記事を取得するため、[Qiita API v2](https://qiita.com/api/v2/docs)を使用します。
リファレンスを確認してどのAPIが使えそうか探していきましょう。

#### 使用するエンドポイント

確認したところ記事を取得するには[GET /api/v2/items](https://qiita.com/api/v2/docs#get-apiv2items)が使用できそうです！
このエンドポイントを使用するのに認証トークンなども必要ないのでお手軽ですね！

#### 設定パラメータ

1回の配信で4記事配信したいので、パラメータの`取得ページ`は`1`で`1ページ数あたりの取得記事`は`4`を設定します。
`query`は検索条件に当たり、今回は直近の人気記事を取得したいので、ストック数20以上で1週間以内の記事を取得する`created:>"1週間前の日付"+likes:>20`を設定します。

:::note warn
いいね数ではなく、ストック数を使用しているのはAPIの仕様でいいね数で検索できないためです。
検索で使用できるオプションは興味があれば確認してください。
[検索時に使用できるオプション](https://help.qiita.com/ja/articles/qiita-search-options)
:::


| クエリパラメータ | パラメータ名               | 設定値                              |
| ---------------- | -------------------------- | ----------------------------------- |
| page             | 取得ページ数               | 1                                   |
| per_page         | １ページ数あたりの取得記事 | 4                                   |
| query            | 取得条件                   | created:>"1週間前の日付"+stocks:>20 |

#### 動作確認

試しにPostmanでGetリクエストを送信して取得できるか確認してみましょう。

![スクリーンショット 2023-05-21 15.51.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/19bc4675-035f-a3d7-829e-5f589629790d.png)
無事取得できていますねー！ちなみにレスポンスのデータ構造がどうなっているか確認しておきます。

```js:記事データ
{       
        // 一部抜粋
    	"coediting": false,
        "comments_count": 0,
        "created_at": "2021-03-11T18:49:59+09:00",
        "group": null,
        "id": "ac01a5214c9fa32fff2f",
        "likes_count": 6,
        "private": false,
        "reactions_count": 0,
        "stocks_count": 0,
        "tags": [
            {
                "name": "Node.js",
                "versions": []
            },
            {
                "name": "Heroku",
                "versions": []
            },
            {
                "name": "Express",
                "versions": []
            },
            {
                "name": "linebot",
                "versions": []
            },
            {
                "name": "LINEmessagingAPI",
                "versions": []
            }
        ],
        "title": "Node.jsを使ってお手軽インドカレー検索LINE Bot を作ってみた(1.簡単な応答)",
        "updated_at": "2021-03-17T08:27:05+09:00",
        "url": "https://qiita.com/nanndot/items/ac01a5214c9fa32fff2f",
        "user": {
            "description": "ピチピチフレッシュマンです。クラウド技術・モダン開発などに興味があり、フレッシュらしく何事にも熱く取り組むことをモットーにしています。世界一インドカレー・ナンが大好きで、好きなナンはチーズナン。",
            "facebook_id": "",
            "followees_count": 0,
            "followers_count": 3,
            "github_login_name": null,
            "id": "nanndot",
            "items_count": 4,
            "linkedin_id": "",
            "location": "",
            "name": "",
            "organization": "アビームシステムズ株式会社",
            "permanent_id": 1178305,
            "profile_image_url": "https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/1178305/f2a2224406a0bd9488573b5a952e4b601880c72e/x_large.png?1614940376",
            "team_only": false,
            "twitter_screen_name": null,
            "website_url": ""
        },
        "page_views_count": null,
        "team_membership": null,
        "organization_url_name": "abeam-s"
}
```

記事のタイトル、URL、いいね数、ストック数などしっかり取得できていますね！後続の処理で活用していきます！
### ソースコード
#### 人気記事取得処理

Qiitaから人気記事を取得する部分を別のjsファイルとして切り出します。
命名は`getArticles.js`とします。

```javascript:getArticles.js
const axios = require("axios");

//デフォルトパラメータ
//PAGE:取得ページ数
//PER_PAGE:１ページ数あたりの取得記事
//ストック数
const DEFAULT_PARAMS = {
  PAGE: 1,
  PER_PAGE: 4,
  STOCKS: 20,
};

/**
 * 記事をQiitaから取得する。
 * @module getArticles
 * @param {Object} params - 使用するパラメータオブジェクト
 */
const getArticles = async (params = DEFAULT_PARAMS) => {
  const targetDate = new Date();
  //今日の日付から一週間前を設定
  targetDate.setDate(targetDate.getDate() - 7);
  //YYYY-MM-DD部分だけ抽出
  const dateBeforeWeek = targetDate.toISOString().split("T")[0];
  //Qiita API V2URL、パラメータを設定
  const qiitaURL = `https://qiita.com/api/v2/items?page=${params.PAGE}&per_page=${params.PER_PAGE}&query=created:>${dateBeforeWeek}+stocks:>${params.STOCKS}`;
  try {
    //Qiita API v2をコール
    const result = await axios.get(qiitaURL);
    //取得した記事からtitle,urlを抽出
    const articlesList = result.data.map((article) => {
      return {
        title: article.title,
        url: article.url
      };
    });
    return articlesList;
  } catch (error) {
    console.log(`エラー: ${error.statusMessage}`);
    console.log(error.originalError.response.data);
  }
};

module.exports = getArticles;
```

HTTPライブラリ`axios`を使用して、`Qiita API v2`にアクセスします。取得した記事から`title`と`url`だけ抽出してエントリポイントに返却します。

#### エントリポイント

エントリポイントも`getArticles`を使用するよう修正して、メッセージを配信します。

```diff_javascript:index.js
const line = require("@line/bot-sdk");
require("dotenv").config();
const functions = require("@google-cloud/functions-framework");
+ const getArticles = require("./getArticles");

//channelAccessTokenとchannelSecretを環境変数から取得
const config = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.CHANNEL_SECRET,
};
const client = new line.Client(config);

functions.cloudEvent("sendQiitaArticles", async (cloudEvent) => {
+ const articles = await getArticles();
+ //取得した記事をLINEで配信するよう加工
+ const articlesMessages = articles.map((article) => {
+   return {
+     type: "text",
+     text: "タイトル：" + article.title + "\nURL:" + article.url,
+   };
+ });
  const messages = [
    {
      type: "text",
      text: "本日の上位記事4件です！",
    },
+   ...articlesMessages,
  ];

  try {
    //LINEにメッセージを配信
    await client.broadcast(messages);
    console.log("配信に成功しました！");
  } catch (error) {
    console.log(`エラー: ${error.statusMessage}`);
    console.log(error.originalError.response.data);
  }
});
```

修正完了したら、先ほどと同様にCloud Schedulerから強制実行して挙動を確認してみます！

#### 動作確認

![6cfbf9e5-161f-4cd1-d6f8-cbaef6ca9f24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/3cdab4dd-c6d3-34e9-2ecc-ef60aad47cf6.png)

人気記事をLINE上で確認できていますね！！これで今回はおしまい...と思いきやちょっとデザインが味気なくないですかね...?
もう少しおしゃれな感じにするために一工夫していきます！

## Flex Messageの活用

LINEでデザインを意識したメッセージを配信するには[Flex Message](https://developers.line.biz/ja/docs/messaging-api/using-flex-messages/)を使用します。
CSSのFlexBoxを活用して、レイアウトの自由度が高いメッセージを作成することが可能です。
ただゼロから作成するのは中々大変なので、公式で提供されている[Flex Message Simulator](https://developers.line.biz/flex-simulator/)を活用して、GUIベースでメッセージのデザインを作成することが可能です！
今回は紹介だけに留めて出来上がりのメッセージ定義だけ記載しておきます。興味があれば使用してみることをおすすめします！

### 出来上がりイメージ

アイコンやいいね数、ストック数、ユーザー名なども表示するようなカードにします。
カードをタッチしたらQiitaの記事を表示するようにURLを埋め込みます。
![スクリーンショット 2023-05-20 18.56.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/40cdd2bf-84a3-96f4-b46b-da4633c87c9c.png)
### FlexMessage定義
下記メッセージ定義を`flexMessage.json`として作成して配置しておきます。
```json:flexMessage.json
{
    "type": "bubble",
    "size": "giga",
    "body": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "box",
          "layout": "horizontal",
          "contents": [
            {
              "type": "box",
              "layout": "vertical",
              "contents": [
                {
                  "type": "image",
                  "url": "",
                  "aspectMode": "cover",
                  "size": "full"
                }
              ],
              "cornerRadius": "100px",
              "width": "72px",
              "height": "72px",
              "spacing": "md",
              "position": "relative",
              "margin": "md",
              "offsetTop": "lg"
            },
            {
              "type": "box",
              "layout": "vertical",
              "contents": [
                {
                  "type": "text",
                  "contents": [
                    {
                      "type": "span",
                      "text": "Title:",
                      "weight": "bold",
                      "color": "#000000"
                    },
                    {
                      "type": "span",
                      "text": " "
                    },
                    {
                      "type": "span",
                      "text": ""
                    }
                  ],
                  "size": "sm",
                  "wrap": true
                },
                {
                  "type": "box",
                  "layout": "baseline",
                  "contents": [
                    {
                      "type": "text",
                      "text": "",
                      "size": "sm",
                      "color": "#bcbcbc"
                    }
                  ],
                  "spacing": "sm",
                  "margin": "md"
                },
                {
                  "type": "box",
                  "layout": "baseline",
                  "contents": [
                    {
                      "type": "text",
                      "text": "",
                      "size": "sm",
                      "color": "#bcbcbc"
                    }
                  ],
                  "spacing": "sm",
                  "margin": "md"
                }
              ]
            }
          ],
          "spacing": "xl",
          "paddingAll": "20px"
        }
      ],
      "paddingAll": "0px"
    },
    "action": {
      "type": "uri",
      "label": "action",
      "uri": ""
    }
  }
```

### ソースコードの修正

FlexMessageを作成してメッセージを配信するようソースコードを全体的に修正していきます。

#### 人気記事取得処理

`articlesList`に`profile_image_url,likes_count,stocks_count,name`を追加するよう修正します。

```diff_javascript:getArticles.js
const axios = require("axios");

//デフォルトパラメータ
//PAGE:取得ページ数
//PER_PAGE:１ページ数あたりの取得記事
//ストック数
const DEFAULT_PARAMS = {
  PAGE: 1,
  PER_PAGE: 4,
  STOCKS: 20,
};

/**
 * 記事をQiitaから取得する。
 * @module getArticles
 * @param {Object} params - 使用するパラメータオブジェクト
 */
const getArticles = async (params = DEFAULT_PARAMS) => {
  const targetDate = new Date();
  //今日の日付から一週間前を設定
  targetDate.setDate(targetDate.getDate() - 7);
  //YYYY-MM-DD部分だけ抽出
  const dateBeforeWeek = targetDate.toISOString().split("T")[0];
  //Qiita API V2URL、パラメータを設定
  const qiitaURL = `https://qiita.com/api/v2/items?page=${params.PAGE}&per_page=${params.PER_PAGE}&query=created:>${dateBeforeWeek}+stocks:>${params.STOCKS}`;
  try {
    //Qiita API v2をコール
    const result = await axios.get(qiitaURL);
+   //取得した記事からtitle,url,profile_image_url,likes_count,stocks_count,nameを抽出
    const articlesList = result.data.map((article) => {
      return {
        title: article.title,
        url: article.url,
+       profile_image_url: article.user.profile_image_url,
+       likes_count: article.likes_count,
+       stocks_count: article.stocks_count,
+       name: article.user.name
      };
    });
    return articlesList;
  } catch (error) {
    console.log(`エラー: ${error.statusMessage}`);
    console.log(error.originalError.response.data);
  }
};

module.exports = getArticles;

```

#### FlexMessage作成処理

`makeFlexMessages.js`を新規作成します。
ここでは、取得した人気記事と`flexMessage.json`を元にFlexMessageを作成します。

```js:makeFlexMessages.js
const baseMessage = require("./flexMessage.json");

/**
 * 取得した記事を元にFlexMessageに作成
 * @module makeFlexMessages
 * @param {Array} articles - 取得した人気記事
 */
const makeFlexMessages = (articles) => {
    console.log(articles)
  const flexMessages = articles.map((article) => {

    const flexMessage = Object.assign(
      {},
      JSON.parse(JSON.stringify(baseMessage))
    );
    //user_icon
    flexMessage.body.contents[0].contents[0].contents[0].url =
      article.profile_image_url;
    //title
    flexMessage.body.contents[0].contents[1].contents[0].contents[2].text =
      article.title;
    //url
    flexMessage.action.uri = article.url;
    //likes and stocks
    flexMessage.body.contents[0].contents[1].contents[1].contents[0].text = `${article.stocks_count} Stocks 🗂️ ${article.likes_count} Likes 👍`;
    //create_user
    flexMessage.body.contents[0].contents[1].contents[2].contents[0].text = `created by ${article.name}`;
    return {
      type: "flex",
      altText: "#",
      contents: {
        ...flexMessage,
      },
    };
  });
  return flexMessages;
};

module.exports = makeFlexMessages;
```

####  エントリポイント

`makeFlexMessages`を実行してFlexMessageを作成してメッセージを配信するよう修正します。

```diff_javascript:index.js
const line = require("@line/bot-sdk");
require("dotenv").config();
const functions = require("@google-cloud/functions-framework");
const getArticles = require("./getArticles");
+ const makeFlexMessages = require("./makeFlexMessages");

//チャネルアクセストークンとチャネルシークレットを環境変数から取得
const config = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.CHANNEL_SECRET,
};
const client = new line.Client(config);

functions.cloudEvent("sendQiitaArticles", async (cloudEvent) => {
  const articles = await getArticles();

-  //取得した記事をLINEで配信するよう加工
-  const articlesMessages = articles.map((article) => {
-    return {
-      type: "text",
-      text: "タイトル：" + article.title + "\nURL:" + article.url,
-    };
-  });
-  const messages = [
-    {
-      type: "text",
-      text: "本日の上位記事4件です！",
-    },
-   ...articlesMessages,
-  ];
+  //取得した記事をFlexMessageで配信するよう加工
+  const articlesFlexMessages = makeFlexMessages(articles);
+  const messages = [
+    {
+      type: "text",
+      text: `本日の上位記事${articlesFlexMessages.length}件です！`,
+    },
+    ...articlesFlexMessages,
+  ];

  try {
    //LINEにメッセージを配信
    await client.broadcast(messages);
    console.log("配信に成功しました！");
  } catch (error) {
    console.log(`エラー: ${error.statusMessage}`);
    console.log(error.originalError.response.data);
  }
});
```

###  動作確認

Cloud Schedulerから手動実行して挙動を確認してみます！

![761b2e89-5656-5796-06bc-a49e2dd24d6f (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1178305/cf820d15-3cf4-471f-5ca2-56c57eb6624f.png)
おお！！デザインもいい感じですね！
今回はこれで無事実装完了です！

## おわりに

無事完了としたいところですが、今のままだと1度配信された記事は人気であれば何度でも配信されるような作りになっています。。。
次回でNoSQLサービスであるFirestoreを活用して一度配信した記事は配信されないような機能を実装していきます！
ご覧いただきありがとうございました！