3
1

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 1 year has passed since last update.

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

Last updated at Posted at 2023-05-30

はじめに

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

完成イメージ

最終的なイメージは下記になります。人気記事が平日に毎朝自動で配信されます!
761b2e89-5656-5796-06bc-a49e2dd24d6f (1).png

今回の構成

今回はGoogle Cloudメインで作成しました!
下記構成図となります。
Qiita記事.jpg
ざっくりとした処理の流れです。

  1. Cloud Schedulerで指定した時間にPub/SubからCloud Functionsにメッセージを配信
  2. Pub/Subからのメッセージ受信をトリガーにCloud Functionsを実行
  3. Cloud FunctionsがQiita API v2から人気記事を取得し、Messaging APIを使用してLINEにメッセージを配信

Cloud Functions単体で定期実行の仕組みがなく、またCloud SchedulerとCloud Functionsの直接連携ができないため、Cloud Scheduler + Pub/Subを使用してCloud Functionsの定期実行を行います。
公式ドキュメントにも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を取得

以前投稿した記事にチャネルの作成方法やトークンなどの取得方法を紹介しているので、参考にしながらチャネルを作成してChannelSecretChanelAccessTokenを取得します。

Pub/Subでトピック作成

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

82ebc3cd-f6f6-4dfd-8395-e4be4ed5b536.png

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

スケジュールの定義

決まった日時でPub/Subでメッセージを配信するためにスケジューラーを設定します。
cron式で設定します。今回は通勤中に確認したく平日の朝6時に配信してほしいので0 06 * * 1-5と設定し、名前はpublish-article-topic-jobにします。1097f774-0030-f83f-9ab4-e9bb3abbe685 (1).png

実行内容の構成

ターゲットタイプPub/Subを選択して、先ほど作成したトピックarticle-topicを選択します。メッセージ本文は使用しないですが、必須のため適当なメッセージを入力しておきます。
aa489eae-e49f-ac6c-9e05-be1405299004.png

Cloud Functionsの作成

構成とトリガーの追加

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

d9ff9209-381e-7372-b4ba-34db7f044da5.png

トリガーの設定

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

このロール付与を行う理由はトリガー起動時に権限エラーが発生し、連携が上手くいかないためです。

a0f182ff-7efa-586a-0029-42d2c2a3caa4.png

デプロイ

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

スクリーンショット 2023-05-20 11.35.17.png

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

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

ライブラリインストール

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

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

環境変数

.envファイルに取得したCHANNEL_ACCESS_TOKENCHANNEL_SECRETを設定します。

.env
CHANNEL_ACCESS_TOKEN = "XXX"
CHANNEL_SECRET = "XXX"

ソースコード

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

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

動作確認

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

2fd2bc5b-4a27-fa71-fa73-19c0dbefbdf8.png

実行結果

4cb29154-306f-da13-f331-6fd8f56f9320.png
上手く配信できていますね!
下準備は完了で、本格的にQiitaの人気記事を取得してメッセージを配信するよう実装を進めていきます。

実装

Qiitaから人気記事を取得

Qiitaから記事を取得するため、Qiita API v2を使用します。
リファレンスを確認してどのAPIが使えそうか探していきましょう。

使用するエンドポイント

確認したところ記事を取得するにはGET /api/v2/itemsが使用できそうです!
このエンドポイントを使用するのに認証トークンなども必要ないのでお手軽ですね!

設定パラメータ

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

いいね数ではなく、ストック数を使用しているのはAPIの仕様でいいね数で検索できないためです。
検索で使用できるオプションは興味があれば確認してください。
検索時に使用できるオプション

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

動作確認

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

スクリーンショット 2023-05-21 15.51.00.png
無事取得できていますねー!ちなみにレスポンスのデータ構造がどうなっているか確認しておきます。

記事データ
{       
        // 一部抜粋
    	"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とします。

getArticles.js
const axios = require("axios");

//デフォルトパラメータ
//PAGE:取得ページ数
//PER_PAGE:1ページ数あたりの取得記事
//ストック数
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にアクセスします。取得した記事からtitleurlだけ抽出してエントリポイントに返却します。

エントリポイント

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

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

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

Flex Messageの活用

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

出来上がりイメージ

アイコンやいいね数、ストック数、ユーザー名なども表示するようなカードにします。
カードをタッチしたらQiitaの記事を表示するようにURLを埋め込みます。
スクリーンショット 2023-05-20 18.56.39.png

FlexMessage定義

下記メッセージ定義をflexMessage.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を作成してメッセージを配信するようソースコードを全体的に修正していきます。

人気記事取得処理

articlesListprofile_image_url,likes_count,stocks_count,nameを追加するよう修正します。

getArticles.js
const axios = require("axios");

//デフォルトパラメータ
//PAGE:取得ページ数
//PER_PAGE:1ページ数あたりの取得記事
//ストック数
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を作成します。

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を作成してメッセージを配信するよう修正します。

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
おお!!デザインもいい感じですね!
今回はこれで無事実装完了です!

おわりに

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

3
1
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?