Google スプレッドシートを使ってサーバレスなサイト巡回botを作り、slackに通知させる

# はじめに

サーバを監視するサービスはサーバ屋のオプションサービスであったりしますが、サーバがダウンしていなくてもDBのテーブルがクラッシュしているとWebサイトが見られない状態になっていることがあり、サーバ監視では気づけないことがあります。

Webサイトを死活監視するプログラムを作っても、それを設置しているサーバがダウンしてしまっては監視になりません。でも、数える程度のWebサイトを監視するためにわざわざ冗長化されたサーバを構築しては費用対効果が合いません。

そういうときに使えるのが Google Apps Script（GAS）です。Googleアカウントを持っていれば無料で使え、サーバも必要ありません。Googleの膨大なインフラで支えられているので滅多なことでは落ちません。slackに通知させるようにすればお客様より先に気づくことができ、バタバタ慌てることもなくなります。

# Slack App の作成

まずは Slack App を作って監視通知の準備をします。

1. **Slack App を生成する**

    https://api.slack.com/apps にアクセスし、[Create New App] ボタンをクリックします。

     ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/97b2736f-bed9-0e5e-8171-1db99dbb8c64.png)

    アプリの名前やワークスペースを設定します。

    **App Name** : アプリの名前（日本語可）
    **Development Slack Workspace** : botを作りたいワークスペースを選択

     ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/1aa68c70-d0a3-0243-c646-dfcfe8d4ee15.png)

1. **Slack への投稿を有効にする**

    Incoming Webhooks に移動し Activate Incoming Webhooks をOnにします。Incoming Webhooks はslackのチャンネルに投稿する機能です。

     ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/f6d6c744-81f8-c67f-f417-f1f4ef1d37ee.png)

1. **slackでの表示名を設定する**

    App Home に戻り、App Display Name の [Edit] をクリック。アプリのslackでの表示名を設定します。

    **Display Name (Bot Name)** : 表示名（日本語可）
    **Default Name** : 半角のみ

    Always Show My Bot as Online をOnにします。

     ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/a9223804-86f4-7053-27c7-351024cc7a55.png)

1. **Slack App が投稿するチャンネルを選択し、Webhook URL を発行する**

    Incoming Webhooks に戻り [Add New Webhook to Workspace] をクリック。

     ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/64ea1b7f-c958-5115-fee2-48d8362ca7fa.png)

    投稿したいチャンネルを選択して [許可する] をクリック。

     ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/e32ce4c2-6276-02d6-2f6e-71bde15fdc90.png)

    Webhook URL が発行されるので [copy] をクリックしてコピーし、どこかに保存しておきます。

     ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/a79965d2-ec78-cbc6-4628-30a85d44a5e0.png)

1. **Slack で表示されるアイコンを設定する**

    Basic Information に移動し、下の方にある Display Information へスクロールしてそれぞれ入力します。

    **Short description** : アプリの概要
    **App icon** : slack で表示されるアイコン

     ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/ecf9eef3-21da-d56a-184c-7a01636cadab.png)


# Google Apps Script の作成

とりあえず slack へ投稿させてみましょう。

1. **Google Apps Scriptのドキュメントを新規作成**

    Google Driveにアクセスし、左上の [＋新規] から その他 > Google Apps Script を選択。
    ない場合は その他 > アプリを追加 でGoogle Apps Scriptを探して「接続」します。

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/af7c1796-707a-9443-a56e-5a72a6861195.png)

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/38ef69be-2236-0708-397e-d09e262f20e7.png)

    左上にファイルの名前を入力して一旦保存します。

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/b94ea497-5956-93b2-c007-17ad3866f832.png)

1. **スクリプトを書いて投稿できるかテストしてみる**

    以下のサンプルコードを入力します。

```javascript
function myFunction()
{
    const webHook = 'コピーしたURL';
    let send =
    {
        method : "POST",
        payload : JSON.stringify
        ({
            text : "Hello, World !"
        })
    };
    UrlFetchApp.fetch(webHook, send);
}

```

上のメニューから [実行] をクリックし、slack の指定したチャンネルにメッセージが投稿されているか確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/f54eeaa5-7369-6baa-594a-42afe7c5d424.png)

# サイト巡回スクリプトを作成

サイトをチェックして投稿するスクリプトを作成します。

```javascript
// Webhook URL
const webHook = 'コピーしたURL';

// サイトチェック
// 戻り値はオブジェクトを返すようにして、戻り値で条件分岐だけでなく表示内容の切り替えもできるようにする。
function checkWebsite( site_name, url, keyword, match_is_normal )
{
    let response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });    // muteHttpExceptions オプションを付けてエラーコードを取得できるようにする。
    let response_code = response.getResponseCode();
    // NG
    if( response_code != 200 )
    {
        return { status : "error", error_type : "レスポンスコードエラー", response_code : response_code };
    }
    // html検証
    let content = response.getContentText("UTF-8");
    let match = content.indexOf(keyword) !== -1;
    if(( !match && match_is_normal ) || ( match && !match_is_normal ))
    {
        return { status : "error", error_type : "表示エラー", response_code : response_code };
    }
    return { status : "success", response_code : response_code };
}
// slack 投稿
function postSlack( message )
{
    let send =
    {
        "method": "POST",
        "payload": JSON.stringify
        ({
            "text": message
        })
    };
    UrlFetchApp.fetch(webHook, send);
}
// 巡回
function patrol()
{
    let site_name = "サイト名";
    let url = "サイトURL";
    let keyword = "サイトのHTMLで検索するキーワード";
    let match_is_normal = false;    // keyword がマッチで正常なら true, マッチで異常なら false
    let result = checkWebsite( site_name, url, keyword, match_is_normal );
    // エラーの場合はslack投稿
    if( result.status != "success" )
    {
        let message = "<!channel> サイト異常検知\n" + site_name + "\n" + url + "\n" + result.error_type + "\n" + "レスポンスコード : " + result.response_code + "\n";
        postSlack( message );
    }
}
```

## 定期的に実行させる

上のメニューにある [現在のプロジェクトのトリガー] をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/f20df4c0-ce64-244a-f8bb-ee5a17f6165c.png)

右下の [トリガーを追加] ボタンをクリックするとフォームが現れるので、以下のように設定します。ここでは１時間おきに設定しています。

**実行する関数** : patrol
**実行するデプロイ** : Head
**イベントのソース** : 時間主導型
**時間ベースのトリガーのタイプ** : 時間ベースのタイマー
**時間の間隔** : １時間おき

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/6c17febe-f5b5-55d4-e1c0-c334a29ef341.png)

１時間おきに実行されるか確認します。実行されているか分かるように keyword または match_is_normal の値をわざとエラーが出るように設定しておくと良いです。

# スプレッドシートのデータを元に巡回させる

このままではチェックできるのが１サイトだけなので、複数サイトをチェックできるように改修します。配列などで設定しても良いですが、せっかく Google Apps Script なので google のスプレッドシートで巡回させるサイトを管理します。スプレッドシートなので非エンジニアでもメンテナンスが可能になります。

また、サイトの文字コードを UTF-8 と決め打ちしていましたが、サイトによって文字コードはまちまち（まあ、殆どはUTF-8ですが）なので、文字コードを指定できるようにします。

## スプレッドシートを作成する

巡回するサイトのスプレッドシートを作成し、次のようにセルを用意します。

A列 : サイト名
B列 : サイトURL
C列 : 文字コード
D列 : 表示判定キーワード
E列 : 判定基準（表示判定キーワードがマッチで正常なら true, マッチで異常なら false）

用意できたら、巡回するサイトの情報を入力していきます。
このとき、ちゃんと動作しているかを確認するためにわざとエラーになるサイト情報を１つくらい入れておくと良いでしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/b41c4901-2f0b-1ca6-bd33-8b3b0f0bd428.png)

## 巡回するサイトをスプレッドシートから取得するように改修する

patrol関数を次のように修正します。

```javascript
// 巡回
function patrol()
{
    const spreadsheet_obj = SpreadsheetApp.openByUrl( spreadsheet_url );
    const sheet = spreadsheet_obj.getSheets()[0];   // 一番左のシート
    const last_row = sheet.getLastRow(); // 最後の行
    // スプレッドシートの２行目から順に巡回
    for(let i = 2; i <= last_row; i++)
    {
        let site_name = sheet.getRange(i, 1).getValue();
        let url = sheet.getRange(i, 2).getValue();
        let charset = sheet.getRange(i, 3).getValue() || "UTF-8";   // デフォルトはUTF-8
        let keyword = sheet.getRange(i, 4).getValue();
        let match_is_normal = sheet.getRange(i, 5).getValue();
        // チェック
        let result = checkWebsite( site_name, url, charset, keyword, match_is_normal );
        // エラーの場合はslack投稿
        if( result.status != "success" )
        {
            let message = "<!channel> サイト異常検知\n" + site_name + "\n" + url + "\n" + result.error_type + "\n" + "レスポンスコード : " + result.response_code + "\n";
            postSlack( message );
        }
    }
}
```

checkWebsite関数を修正します。引数に文字コードを追加します。

```javascript
// Webhook URL
const webHook = 'コピーしたURL';
// スプレッドシートURL
const spreadsheet_url = 'スプレッドシートURL';

// サイトチェック
function checkWebsite( site_name, url, charset, keyword, match_is_normal )
{
    let response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });    // muteHttpExceptions オプションを付けてエラーコードを取得できるようにする。
    let response_code = response.getResponseCode();
    // NG
    if( response_code != 200 )
    {
        return { status : "error", error_type : "レスポンスコードエラー", response_code : response_code };
    }
    // html検証
    let content = response.getContentText( charset );
    let match = content.indexOf(keyword) !== -1;
    if(( !match && match_is_normal ) || ( match && !match_is_normal ))
    {
        return { status : "error", error_type : "表示エラー", response_code : response_code };
    }
    return { status : "success", response_code : response_code };
}
```

# エラーが発生したときだけ通知させる

このままだとエラーが解消されないと１時間おきに同じエラー通知が投稿されて無駄な投稿が増えます。前回の巡回結果を記録させて、前回と同じなら通知しないようにします。

巡回プログラムを次のように修正します。

```javascript
// 巡回
function patrol()
{
    const spreadsheet_obj = SpreadsheetApp.openByUrl( spreadsheet_url );
    const sheet = spreadsheet_obj.getSheets()[0];   // 一番左のシート
    const last_row = sheet.getLastRow(); // 最後の行
    // スプレッドシートの２行目から順に巡回
    for(let i = 2; i <= last_row; i++)
    {
        let site_name = sheet.getRange(i, 1).getValue();
        let url = sheet.getRange(i, 2).getValue();
        let charset = sheet.getRange(i, 3).getValue() || "UTF-8";
        let keyword = sheet.getRange(i, 4).getValue();
        let match_is_normal = sheet.getRange(i, 5).getValue();
        let prev_status = sheet.getRange(i, 6).getValue();
        // チェック
        let result = checkWebsite( site_name, url, charset, keyword, match_is_normal );
        // エラーの場合はslack投稿
        if( result.status != "success" && prev_status != result.status )
        {
            let message = "<!channel> サイト異常検知\n" + site_name + "\n" + url + "\n" + result.error_type + "\n" + "レスポンスコード : " + result.response_code + "\n";
            postSlack( message );
        }
        // 結果を記録
        if( prev_status != result.status ) sheet.getRange(i, 6).setValue( result.status );
    }
}
```

スプレッドシートに結果が記録されていること、２回目のエラーでは通知されないことを確認してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9064/d7758e5f-03a6-cd59-86c7-5ad3d9c8f204.png)

# さいごに

Google Apps Script は javascript をベースにしているので javascript を使える方なら気軽にサーバレスアプリを作れるのでおすすめです。