LINEボットでGoogle Alertを通知

Google Alertは、キーワードを登録しておくと、ウェブ上の新着コンテンツを知らせてくれるサービスです。私は、今まで、メールで受信していたのですが、せっかくコンテンツを通知してくれたのに、時間がたつと埋もれてしまっていました。

そこで、新着コンテンツが来たタイミングでLINEボットで通知すると同時に、データベースに登録して、あとでも参照できるようにしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/3c29e58b-20ec-a19a-d99d-a1339ac2fd04.png)

2か所でNode.jsを使います。
1つ目は、コンテンツの定期的な取得のため。
2つ目は、過去コンテンツの格納とLINEボット用のサーバです。

過去コンテンツは、MySQLサーバに格納しています。

もろもろはGitHubに上げておきました。

poruruba/GoogleAlert
　https://github.com/poruruba/GoogleAlert

#流れ

①Googleアラートに、キーワードを登録しておきます。そうすると、RSSフィードのURLが取得できます。
②Node.jsなどで、定期的にRSSフィードからコンテンツを取得するとともに、Node.jsサーバにコンテンツ登録を依頼します。
③Node.jsサーバでは、コンテンツがすでにデータベースに登録されているか確認し、登録されていない場合はデータベースに登録します。それと同時に、LINEボットにコンテンツをメッセージとして送信します。
④ユーザは、LINEアプリにコンテンツがメッセージで送信されてきます。
⑤(必要に応じて)ユーザはLIFFアプリを起動し、Node.jsサーバからコンテンツ一覧を取得し表示します。

#準備：Googleアラートにキーワードを登録

以下のサイトでキーワードを登録します。

Googleアラート
　https://www.google.co.jp/alerts

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/cc390fba-2dbc-492b-265d-12790f99603f.png)

アラートを作成、と表示されているところにキーワードを入力します。
今回は、「ESP32」としてみました。オプションを表示となっている場合はクリックしてオプションを表示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/abe5d7aa-a3c1-5dd4-85ca-5ac76fe2f64d.png)

ここで、配信先として、自身のGmailアドレスではなく、「RSSフィード」を選択します。
最後に、アラートを作成 を押下します。

そうすると、ESP32が追加され、無線のようなマークがでていますので、クリックします。
そうすると、RSSフィードが表示されました。
まだコンテンツの監視が始まったばかりで、コンテンツは1件もないです。ブラウザに表示されているこのURLを覚えておきます。

#データベースの準備

以下のようなスキーマのテーブルを作成しました。

データベース名：googlealert

テーブル名：items
コンテンツを格納します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/732484b6-18a4-8957-6fab-7940cbdd5e8d.png)

テーブル名：members
LINEボットからコンテンツをメッセージ送信する先のユーザIDを格納します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/2a541062-5bf1-b29c-5a30-494d485533ec.png)

#LINEボットの作成

すみませんが、以下の投稿を参考にしてください。

　[LINEボットを立ち上げるまで。LINEビーコンも。](https://qiita.com/poruruba/items/c71c96ca83c7403c2930)

LINEボット名は「Googleアラート」にしてみました。

#定期的なコンテンツの取得

定期的なコンテンツ取得は、GoogleアラートのRSSフィードを参照することで行います。
また、これから立ち上げるNode.jsサーバへコンテンツをHTTP Postしています。

RSSフィードの参照およびHTTP Postには以下のnpmモジュールを使っています。

rbren/rss-parser
　https://github.com/rbren/rss-parser

node-fetch/node-fetch
　https://github.com/node-fetch/node-fetch

```js:cron_googlealert/index1.js
'use strict';

const GOOGLE_ALERT_RSS_URL = process.env.GOOGLE_ALERT_RSS_URL || '【GoogleアラートのRSSフィードのURL】';
const GOOGLE_ALERT_SEARCH_KEYWORD = process.env.GOOGLE_ALERT_SEARCH_KEYWORD || '【Googleアラートに指定したキーワード】';

const base_url = "【Node.jsサーバのURL】";

const fetch = require('node-fetch');
const { URL, URLSearchParams } = require('url');
const Headers = fetch.Headers;

const Parser = require('rss-parser');
const parser = new Parser();

(async () =>{
  var feed = await parser.parseURL(GOOGLE_ALERT_RSS_URL);  
  if( feed.items.length <= 0 )
    return;

  feed.items.forEach(item =>{
    console.log(item.title);
  });

  try{
    var created_at = new Date().getTime();
    for( var i = 0 ; i < feed.items.length ; i++ ){
      var item = feed.items[i];
      console.log(item);
      var param = {
        keyword: GOOGLE_ALERT_SEARCH_KEYWORD,
        title: item.title,
        pubDate: item.pubDate,
        contentSnippet: item.contentSnippet,
        id: item.id,
        link: item.link,
        created_at: created_at
      };
      await do_post(base_url + '/linebot-googlealert-push', param );
    }
  }catch(error){
    console.error(error);
  }
})();

function do_post(url, body) {
  const headers = new Headers({ "Content-Type": "application/json; charset=utf-8" });

  return fetch(new URL(url).toString(), {
      method: 'POST',
      body: JSON.stringify(body),
      headers: headers
    })
    .then((response) => {
      if (!response.ok)
        throw 'status is not 200';
      return response.json();
    });
}
```

以下の部分を、各自の環境に合わせて変更します。これから立ち上げるNode.jsサーバのURLです。

【Node.jsサーバのURL】

#Node.jsサーバの実装

とりあえず、以下ダウンロードしてNode.jsサーバを立ち上げます。

```
unzip GoogleAlert-master.zip
cd GoogleAlert-master
mkdir cert
npm install
```

HTTPSである必要がありまして、SSL証明書をcertフォルダに置きます。フォルダ名は、app.jsを見ればわかります。
起動は以下の通りです。

```
$ node app.js
```

RSSフィードされたコンテンツを受信する部分を抜粋します。

```js:server/api/controllers/linebot-googlealert/index.js
exports.handler = async (event, context, callback) => {
  if( event.path == '/linebot-googlealert-push' ){
    var body = JSON.parse(event.body);

    var sql_query = `SELECT id FROM items WHERE id = '${body.id}'`;
    const [rows] = await dbconn.query(sql_query);

    var index = rows.findIndex(rows_item => body.id == rows_item.id );
    if( index < 0 ){
      var sql_insert = `INSERT INTO items (id, keyword, content, pubDate, created_at) VALUES ('${body.id}', '${body.keyword}', '${JSON.stringify(body)}', '${new Date(body.pubDate).getTime()}', ${body.created_at})`;
      await dbconn.query(sql_insert);
    
      var sql_select = `SELECT memberId FROM members`;
      const [rows] = await dbconn.query(sql_select);

      var message = app.createSimpleCard(body.title, 'キーワード: ' + body.keyword, body.contentSnippet, 'ブラウザで開く', { type: 'uri', uri: body.link } );
      rows.forEach( row =>{
        app.client.pushMessage(row.memberId, message);
      });
    }

    return new Response({});
  }else
```

以下の部分を環境に合わせて変更します。

```js:server/api/controllers/linebot-googlealert/index.js
const DB_HOST = '【MySQLサーバのホスト名】';
const DB_USER = '【MySQLサーバのユーザ名】';
const DB_PASSWORD = "【MySQLサーバのパスワード】";
const DB_PORT = 3306;
const DB_DATABASE = "googlealert";
```

上記のうち、以下の部分がLINEボットとしてメッセージ送信する部分です。

```js:server/api/controllers/linebot-googlealert/index.js
      var message = app.createSimpleCard(body.title, 'キーワード: ' + body.keyword, body.contentSnippet, 'ブラウザで開く', { type: 'uri', uri: body.link } );
      rows.forEach( row =>{
        app.client.pushMessage(row.memberId, message);
      });
```

以下の部分を環境に合わせて変更します。

```js:server/api/controllers/linebot-googlealert/index.js
const config = {
  channelAccessToken: '【LINEボットのチャネルアクセストークン(長期)】',
  channelSecret: '【LINEボットのチャネルシークレット】',
};
```

上記のシークレットを変更しないと、LINEボットのWebhook設定で、Webhook URLの検証が成功しないです。

#コンテンツ取得とLINE通知を試してみる。

それでは、LINEボットを自身のスマホのLINEアプリから登録しましょう。
登録が完了すると、LINEボットがそれを認識し、LINEユーザのユーザIDをデータベースに登録します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/86cae144-93ea-007e-7c70-5fc22a8168a4.png)

以下の部分です。

```js:server/api/controllers/linebot-googlealert/index.js
app.follow(async (event, client) =>{
  var memberId = (event.source.type == 'user') ? event.source.userId : event.source.groupId;
  var sql_insert = `INSERT INTO members (memberId, type) VALUES ('${memberId}', '${event.source.type}')`;
  await dbconn.query(sql_insert);
});

app.unfollow(async (event, client) =>{
  var memberId = event.source.type == 'user' ? event.source.userId : event.source.groupId;
  var sql_delete = `DELETE FROM members WHERE memberId = '${memberId}' AND type = '${event.source.type}'`;
  await dbconn.query(sql_delete);
});

exports.fulfillment = app.lambda();
```

そして、定期的なコンテンツ取得として用意した```cron_googlealert/index1.js```を起動します。

起動に便利な、シェルスクリプトを用意しました。

```cron_googlealert/index1.sh
#!/bin/sh

export GOOGLE_ALERT_RSS_URL="【GoogleアラートのRSSフィードのURL】"
export GOOGLE_ALERT_SEARCH_KEYWORD=" 【Googleアラートに指定したキーワード】"
cd /home/XXXX/projects/node/cron_googlealert
/home/XXXX/.nvm/versions/node/v12.19.0/bin/node index.js
```

環境に合わせて以下を変更します。後者は、「ESP32」でした。

【Googleアラートに指定したキーワード】
【GoogleアラートのRSSフィードのURL】

```
$cd cron_googlealert
$chmod +x index1.sh
$ ./index1.sh
```

(まだコンテンツは見つかっていないかもしれません。気長に待ちましょう)

別のキーワードですが以下のようにDBに登録され、LINEにも通知されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/3cfad6eb-25cb-670d-27f8-c1eaba3cc693.png)

同時に、LINEアプリにも通知が届いているかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/228bc652-6ac1-a6e8-5cb9-7c3baf9bed89.png)

あとは、これをCronで起動すればよいです。例えば、1時間ごとに。

```
$crontab -e
★以下を入力★
15 * * * * /home/XXXX/projects/node/cron_googlealert/index1.sh
```

# コンテンツ一覧表示するLIFFアプリ

普通のWebページでもよいのですが、せっかくなのでLIFFアプリにして、LINEアプリ内で表示できるようにします。

LIFFアプリの登録には、LINE Developersで作ったMessaging APIのチャネルではなく、LINEログインのチャネルが必要です。

LINE Developers
https://developers.line.biz/console/

登録が完了すると、LIFF IDが割り当たります。

Node.jsサーバの以下の部分を書き換えます。

```js:server/api/controllers/linebot-googlealert/index.js
const LIFF_ID = "【LINEのLIFF-ID】";
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/17393952-539e-1515-b608-ce96bbe29322.png)

画面はこんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/574c87ad-6cda-aea4-4bdd-9d3909f2a84e.png)

HTMLはこんな感じです。

```html:public/googlealert/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

  <link rel="stylesheet" href="css/start.css">
  <script src="js/methods_bootstrap.js"></script>
  <script src="js/components_bootstrap.js"></script>
  <script src="js/vue_utils.js"></script>

  <script src="dist/js/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>

  <title>Google Alert</title>
</head>
<body>
    <div id="top" class="container">
        <button class="btn btn-default pull-right" v-on:click="list_update">更新</button>
        <h1>Google Alert</h1>
        <br>

        <h2>本日のアイテム</h2>
        <div class="panel panel-default" v-for="(value, index) in item_list_today">
          <div class="panel-heading"><h3>{{value.content.title}}</h3></div>
          <div class="panel-body">
            <span class="pull-left">pubDate: {{new Date(value.pubDate).toLocaleString()}}</span>
            <span class="pull-right">keyword: {{value.keyword}}</span>
            <br><br>
            {{value.content.contentSnippet}}
          </div>
          <div class="panel-footer text-right">
            <a class="pull-left" v-bind:href="value.content.link">ブラウザで開く</a>
            いいね数:{{value.likes}}
            <button class="btn btn-default btn-sm" v-on:click="change_likes(value, true)">↑</button><button class="btn btn-default btn-sm" v-on:click="change_likes(value, false)">↓</button>
          </div>
        </div>
        
        <hr>
        <h2>過去のアイテム</h2>
        <div class="form-inline">
          <button class="btn btn-default" v-on:click="list_update_default">今月</button>
          <select class="form-control" v-model.number="target_year" v-on:change="list_update">
            <option v-for="(value, index) in target_year_list" v-bind:value="value">{{value}}年</option>
          </select>
          <select class="form-control" v-model.number="target_month" v-on:change="list_update">
            <option value="0">通年</option>
            <option v-for="(value, index) in [1,2,3,4,5,6,7,8,9,10,11,12]" v-bind:value="value">{{value}}月</option>
          </select>
          <select class="form-control" v-model.number="has_likes">
            <option value="1">いいね有のみ</option>
            <option value="0">すべて</option>
          </select>
        </div>

        <table class="table table-striped">
          <thead>
            <tr><th>keyworkd</th><th>title</th><th>pubDate</th><th>いいね</th></tr>
          </thead>
          <tbody>
              <tr v-for="(value, index) in item_list" v-if="has_likes==0||value.likes>0">
                  <td>{{value.keyword}}</td>
                  <td><a v-bind:href="value.content.link">{{value.content.title}}</a></td>
                  <td>{{new Date(value.pubDate).toLocaleString()}}</td>
                  <td>{{value.likes}}
                    <button class="btn btn-default btn-xs" v-on:click="change_likes(value, true)">↑</button><button class="btn btn-default btn-xs" v-on:click="change_likes(value, false)">↓</button>
                  </td>
                </tr>
          </tbody>
        </table>


        <!-- for progress-dialog -->
        <progress-dialog v-bind:title="progress_title"></progress-dialog>
    </div>

    <script src="js/start.js"></script>
</body>
```

Javascriptはこんな感じです。

```js:public/googlealert/js/start.js
'use strict';

//var vConsole = new VConsole();

const base_url = "【Node.jsサーバのURL】";


var vue_options = {
    el: "#top",
    data: {
        progress_title: '', // for progress-dialog

        item_list_today: [],
        item_list: [],
        target_month: 0,
        target_year: 0,
        target_year_list: [],
        has_likes: 0
    },
    computed: {
    },
    methods: {
        list_update_default: async function(){
            this.target_month = this.now.getMonth() + 1;
            this.target_year = this.now.getFullYear();
            return this.list_update();
        },
        list_update_today: async function(today){
            var param = {};
            var list = await do_post(base_url + "/linebot-googlealert-list", param );
            for( var i = 0 ; i < list.length ; i++ )
                list[i].content = JSON.parse(list[i].content);
            this.item_list_today = list;
        },
        list_update: async function(){
            var param = {
                year: this.target_year,
                month: this.target_month,
            };
            var list = await do_post(base_url + "/linebot-googlealert-list", param );
            for( var i = 0 ; i < list.length ; i++ )
                list[i].content = JSON.parse(list[i].content);
            this.item_list = list;
        },
        change_likes: async function(target, increment){
            console.log(target);
            var target_likes = ( increment ) ? (target.likes + 1) : (target.likes - 1);
            if( target_likes < 0 ) target_likes = 0;
            var param = {
                id: target.id,
                likes: target_likes
            };
            await do_post(base_url + "/linebot-googlealert-likes", param );
            var t1 = this.item_list.find(item => item.id == target.id );
            if( t1 )
                this.$set(t1, "likes", target_likes);
            var t2 = this.item_list_today.find(item => item.id == target.id );
            if( t2 )
                this.$set(t2, "likes", target_likes);
        },
    },
    created: function(){
    },
    mounted: async function(){
        proc_load();

        this.now = new Date();
        for( var i = 0 ; i < 5 ; i++ )
            this.target_year_list.push(this.now.getFullYear() - i );
        this.target_month = this.now.getMonth() + 1;
        this.target_year = this.now.getFullYear();

        this.list_update_today();
        this.list_update();
    }
};
vue_add_methods(vue_options, methods_bootstrap);
vue_add_components(vue_options, components_bootstrap);
var vue = new Vue( vue_options );

function do_post(url, body) {
    const headers = new Headers({ "Content-Type": "application/json; charset=utf-8" });
  
    return fetch(new URL(url).toString(), {
        method: 'POST',
        body: JSON.stringify(body),
        headers: headers
      })
      .then((response) => {
        if (!response.ok)
          throw 'status is not 200';
        return response.json();
      });
  }
```

Node.jsサーバ側では、それにこたえられるように、以下のエンドポイントを用意しています。
一覧の取得といいねカウントです。一覧の取得では、本日のコンテンツ、月ごとのコンテンツ、年ごとのコンテンツ、のようにフィルタリングして返しています。

```js:server/api/controllers/linebot-googlealert/index.js
  if( event.path == '/linebot-googlealert-list' ){
    var body = JSON.parse(event.body);

    var startTime;
    var endTime;
    if( !body.year || !body.month ){
      var today = new Date();
      today.setHours(0, 0, 0, 0);
      startTime = today.getTime();
      var tomorrow = new Date(today);
      tomorrow.setDate(today.getDate() + 1);
      endTime = tomorrow.getTime();
    }else
    if( body.year && body.month == 0 ){
      var thisYear = new Date();
      thisMonth.setFullYear(body.year);
      thisMonth.setMonth(0);
      thisMonth.setDate(1);
      thisMonth.setHours(0, 0, 0, 0);
      startTime = thisYear.getTime();
      var nextYear = new Date(thisYear);
      nextMonth.setFullYear(thisYear.getFullYear() + 1);
      endTime = nextYear.getTime();
    }else{
      var thisMonth = new Date();
      thisMonth.setFullYear(body.year);
      thisMonth.setMonth(body.month - 1);
      thisMonth.setDate(1);
      thisMonth.setHours(0, 0, 0, 0);
      startTime = thisMonth.getTime();
      var nextMonth = new Date(thisMonth);
      nextMonth.setMonth(thisMonth.getMonth() + 1);
      endTime = nextMonth.getTime();
    }

    var sql_select = `SELECT * FROM items WHERE pubDate >= ${startTime} AND pubDate < ${endTime} ORDER BY pubDate DESC`;
    const [rows] = await dbconn.query(sql_select);

    return new Response(rows);
  }else
  if( event.path == '/linebot-googlealert-likes' ){
    var body = JSON.parse(event.body);

    var sql_update = `UPDATE items SET likes = ${body.likes} WHERE id = '${body.id}'`;
    await dbconn.query(sql_update);

    return new Response({});
  }
```

以上