GCP Cloud Functions を GAS トリガーを用いて定期的に実行する

先日、会社で GCP のオンラインセミナーを受講しました。それから折をみては GCP のサーバレス環境である Cloud Functions を触っています。

GCP にデプロイした Cloud Function を定期的かつ自動的に実行させたいことがしばしばあります。いくつか方法がありますが、今回は Google App Script のトリガーを用いてみたいと思います。

##Cloud Functions の作成・デプロイ

### ローカル環境で Cloud Functions を開発

Cloud Functions は Node.js, Java, Go, Python など、いくつかの言語に対応するランタイムがありますが、ここでは私が使い慣れてる Python で HTTP イベントで動く処理を作ってみます。

まずはローカル環境にいくつかライブラリをインストールします。

```shell
# pip で functions-framework をインストール
$ pip install functions-framework

# BeautifulSoupを使うので、別途インストール
$ pip install beautifulsoup4
```

適当なディレクトリを作成し、その中に main.py ファイルを追加します。

```shell
$ touch main.py
```

お好きなエディタを使って main.pyを編集します。

今回は Yahoo! Japan のテレビ欄をスクレイピングして、札幌で現在放送している地上波のテレビ番組情報を取得してみます。(注意：スクレイピングはサイトの規約違反になる場合があるので、個人の利用の範囲内で利用するのが無難です。)

```Python
import urllib.request
from bs4 import BeautifulSoup

def main(request):
    # Yahoo! Japan のテレビ欄から現在放送中の番組情報を取得
    url = "https://tv.yahoo.co.jp/?a=10"

    # 開いた html を BeautifulSoup の html.parser で解析
    headers = {}
    request = urllib.request.Request(url=url, headers=headers)
    response = urllib.request.urlopen(request)
    html = response.read().decode(response.headers.get_content_charset(), errors='ignore')
    soup = BeautifulSoup(html, "html.parser")

    results = {
        "programs": []
    }

    items = soup.find_all(class_="programOnairListItem")
    for item in items:
        # チャンネル番号
        channel_num = item.find(class_="programOnairChannelNumber").get_text()
        # 番組サムネイルURL
        onair_thumnail_url = item.find(class_="programOnairThumbnail").get('src')
        # 放送局名
        broad_caster = item.find(class_="programOnairBroadcast").get_text()
        # 放送中の番組名
        program_name = item.find(class_="programOnairtProgramText").get_text()

        obj = {
            "channel_num": channel_num,
            "onair_thumnail_url": onair_thumnail_url,
            "broad_caster": broad_caster,
            "program_name": program_name,
        }
        results['programs'].append(obj)

    return results

```

コードを保存したら、functions-framework の簡易 Web サーバーを起動して動作確認をしてみます。

```shell
# 動作確認
$ functions-framework --target=main --debug
 * Serving Flask app 'main' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
 * Running on all addresses.
   WARNING: This is a development server. Do not use it in a production deployment.
 * Running on http://192.168.100.xx:8080/ (Press CTRL+C to quit)
 * Restarting with watchdog (fsevents)
 * Debugger is active!
```

画面に表示されている `Running on 〜 ` の欄に表示されている URL をコピーし、ブラウザなどからアクセスしてみます。

```json
{
  "programs": [
    {
      "broad_caster": "HBC\u5317\u6d77\u9053\u653e\u90011", 
      "channel_num": "1", 
      "onair_thumnail_url": "https://tv-pctr.c.yimg.jp/d/tv-image/co/Ae6AQYog8AM/20210903/368x207/0b787d7b785cf383f55eedbe5f155b95.jpg?w=58&h=33&pri=fill&fill=true&fc=f4f5f6&fw=60&fh=33", 
      "program_name": "\u5192\u967a\u5c11\u5e74 \u3042\u3070\u308c\u308b\u541b\u6fc0\u6d41\u5ddd\u4e0b\u308a\u306bSnow Man\u5411\u4e95\u53c2\u6226\uff01\u2606\u30bb\u30b7\u30bf\u30de\u30f3\u7121\u4eba\u5cf6\u5236\u8987"
    }, 
    {
      "broad_caster": "NHKE\u30c6\u30ec1\u672d\u5e4c", 
      "channel_num": "2", 
      "onair_thumnail_url": "/img/top/program-channel-noimage.png", 
      "program_name": "\u6cbc\u306b\u30cf\u30de\u3063\u3066\u304d\u3044\u3066\u307f\u305f\u300c\uff45\u30b9\u30dd\u30fc\u30c4\u30e2\u30f3\u30b9\u30c8\u300d"
    }, 
    {
      "broad_caster": "NHK\u7dcf\u54081\u30fb\u672d\u5e4c", 
      "channel_num": "3", 
      "onair_thumnail_url": "/img/top/program-channel-noimage.png", 
      "program_name": "\uff2e\uff28\uff2b\u30cb\u30e5\u30fc\u30b9\uff17"
    }, 
    {
      "broad_caster": "\u672d\u5e4c\u30c6\u30ec\u30d31", 
      "channel_num": "5", 
      "onair_thumnail_url": "https://tv-pctr.c.yimg.jp/d/tv-image/co/Ae6AQQGC0AM/20210902/600x400/20210902140032535_000.jpg?w=60&h=40&pri=fill&fill=true&fc=f4f5f6&fw=60&fh=40", 
      "program_name": "\u6709\u5409\u30bc\u30df\u25bc\u30ae\u30e3\u30eb\u66fd\u6839\uff36\uff33\u73fe\u5f79\u529b\u58eb\u25bc\u30c7\u30ab\u76db\uff36\uff33\u30b8\u30e3\u30cb\u30fc\u30ba\u6ff1\u7530\u68ee\u672c\u25bc\u6fc0\u8f9b\uff36\uff33\u65e5\u5411\u5742"
    }, 
    {
      "broad_caster": "HTB1", 
      "channel_num": "6", 
      "onair_thumnail_url": "https://tv-pctr.c.yimg.jp/d/tv-image/co/Ae6AQoJiAAM/20210901/720x479/bea40ab5436c418caf6b7d75d86793b8.jpg?w=58&h=39&pri=fill&fill=true&fc=f4f5f6&fw=60&fh=39", 
      "program_name": "\uff31\u3055\u307e\u203c\u3000\uff13\u6642\u9593\uff33\uff30"
    }, 
    {
      "broad_caster": "TVh1", 
      "channel_num": "7", 
      "onair_thumnail_url": "https://tv-pctr.c.yimg.jp/d/tv-image/pgm/36963/20201001/720x540/7ee60314108c4dc3a5063e3a2f84ec09.jpg?w=60&h=45&pri=fill&fill=true&fc=f4f5f6&fw=60&fh=45", 
      "program_name": "\uff39\uff2f\uff35\u306f\u4f55\u3057\u306b\u65e5\u672c\u3078\uff1f\u2605\u5357\u30b9\u30fc\u30c0\u30f3\u4e94\u8f2a\u4ee3\u8868\u306e\u6fc0\u95d8\uff06\u7d50\u5a5a\u5f0f\u3067\u30b5\u30d7\u30e9\u30a4\u30ba\u5927\u4f5c\u6226"
    }, 
    {
      "broad_caster": "\u5317\u6d77\u9053\u6587\u5316\u653e\u90011", 
      "channel_num": "8", 
      "onair_thumnail_url": "https://tv-pctr.c.yimg.jp/d/tv-image/co/Ae6AQgSsUAM/20210831/640x426/ee4903b40bf54faaa7ce7e57601b935f.jpg?w=58&h=39&pri=fill&fill=true&fc=f4f5f6&fw=60&fh=39", 
      "program_name": "\u75db\u5feb\uff34\uff36\u3000\u30b9\u30ab\u30c3\u3068\u30b8\u30e3\u30d1\u30f3\u82b8\u80fd\u4eba\u306e\u4eba\u751f\u5909\u3048\u305f\u5927\u4e8b\u4ef6\uff01\u5b9f\u8a71\u3092\u30c9\u30e9\u30de\u5316\uff12\u6642\u9593\uff33\uff30"
    }
  ]
}
```

日本語文字列が Unicode エスケープされてるのが気になりますが、ひとまず結果は戻ってきました。


### requirements.txt の作成

作成したコードはスクレイピングした html 情報を BeautifulSoup4 ライブラリを使って解析しています。ただ BeautifulSoup4 は Python3 の標準外のライブラリのため、Cloud Functions の環境にデプロイしてもライブラリが存在せず動作しない可能性があります。

このライブラリを Cloud Functions の環境に導入するため requiments.txt を用意します。

main.py と同じディレクトリに requirements.txt を追加します。

```shell
$ touch requirements.txt
$ echo beautifulsoup4 > requirements.txt
```

### デプロイ

ローカル環境での動作確認・テストが済んだら GCP の Cloud Functions 環境にデプロイします。
今回は gcloud コマンドを使ってデプロイを行ってみます。

https://cloud.google.com/functions/docs/deploying/filesystem?hl=ja

```shell
$ gcloud functions deploy tv_program --entry-point main --runtime python38 --trigger-http --project --project [gcp_project_name]
Allow unauthenticated invocations of new function [tv_program]? 
(y/N)?  y

Deploying function (may take a while - up to 2 minutes)...⠛                                                                                                               
For Cloud Build Stackdriver Logs, visit: https://〜
Deploying function (may take a while - up to 2 minutes)...⠹                                                                                                               

```

しばらく待つと、画面に

```shell
Updates are available for some Cloud SDK components.  To install them,
please run:
  $ gcloud components update
```

このような文字が出てきます。
これでGCPの指定プロジェクト上に、Cloud Functions コードのデプロイができました。


## Google App Script の作成

### Google SpreadSheet のスクリプト処理で Cloud Functions の処理を呼び出す

次に、GCP から一旦離れて、新規のGoogle SpreadSheet を作成します。

`[ツール] > [スクリプト エディタ]` と選択してスクリプトエディタを開き、先ほどデプロイした GCP の Cloud Functions を呼び出す処理を書きます。

以下のスクリプトを設定します。

```javascript
function getTvProgram() {
  var url = "[デプロイした Cloud Functions の URL]";
  var res = UrlFetchApp.fetch(url).getContentText();
  var json = JSON.parse(res);
  return json;
}

function updateTvProgram() {
  var spreadSheetById = SpreadsheetApp.openById('[スプレッドシートのid]');
  var json = getTvProgram();

  var date = new Date();
  var nowTime = Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy-MM-dd: HH:mm:ss');
  var targetCell = spreadSheetById.getRange('B1');
  targetCell.setValue(nowTime);
  var programs = json['programs'];

  // 番組情報欄を消す
  var targetCells = spreadSheetById.getRange('A3:D20');
  targetCells.clearContent();

  // 番組情報を取得
  var initRow = 3;
  for(var i=0; i<programs.length; i++) {
    // JSONから情報取得
    var channel_num  = programs[i]['channel_num'];
    var broad_caster = programs[i]['broad_caster'];
    var onair_thumnail_url = programs[i]['onair_thumnail_url'];
    var program_name = programs[i]['program_name'];

    // セルに情報を書き込む
    var targetRowIdx = initRow + i;
    spreadSheetById.getActiveSheet().getRange(targetRowIdx, 1).setValue(channel_num);
    spreadSheetById.getActiveSheet().getRange(targetRowIdx, 2).setValue(broad_caster);
    if(onair_thumnail_url != "/img/top/program-channel-noimage.png") {
      image_func = '=IMAGE("' +onair_thumnail_url+ '", 4, 32, 60)';
      spreadSheetById.getActiveSheet().getRange(targetRowIdx, 3).setValue(image_func);
    }
    spreadSheetById.getActiveSheet().getRange(targetRowIdx, 4).setValue(program_name);
  }
}
```
スクリプトの説明です。getTvProgram 関数で Cloud Functions のトリガーURLにアクセスして TV 番組情報を取得しています。
updateTvProgram 関数の方で、getTvProgram 関数から得られた TV 番組情報をもとにして、Google SpreadSheet のシートを書き換える処理を行ってます。

スクリプトエディタの実行部から updateTvProgram の関数を選択して実行すると、スクリプトをどのアカウントから操作するか、許可を求められるので、許可の設定を行います。

設定を行って処理が完了すると、Google SpreadSheet の内容がこのように変わります。


![スクリーンショット 2021-09-06 20.31.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18599/ef1c25eb-649a-515d-46e2-13586656af22.png)


### Google App Script にトリガーを設定

Google SpreadSheet のスクリプトから Cloud Functions を呼び出せることが確認できたら、処理を定期的に動かすためにトリガーを設定します。

![スクリーンショット 2021-09-06 20.44.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18599/73b766bc-8185-1141-f428-100c66c34330.png)

- 実行する関数を選択 : updateTvProgram
- イベントのソースを選択 : 時間主導型
- 時間ベースのトリガーのタイプを選択 : 分ベースのタイマー
- 時間の感覚を選択（分） : 適当に選択

この内容でトリガー設定を保存することで、設定した時間（分）ごとにトリガーが動きだします。
トリガーに関連づけた関数で Cloud Functions が呼び出され、定期的にシート内容を書き変えることができます。Google SpreadSheet はクラウドの環境に存在しているので、PC でシートを開いていなくてもトリガーは実行されつづけます。

これにて Google App Script を使って Cloud Functions の処理を定期的に実行できるようになりました。（単に Cloud Functions を定期実行するだけであれば、Cloud Function の呼び出し関数だけをトリガーに設定すればいいです）

## その他

ここまでの手順では処理を動かすことを優先したため、Cloud Functions に関する権限設定は特に行ってません。そのため Cloud Functions の処理を動かすエントリーポイントの URL がわかれば誰でも処理が実行できてしまう状態です。

これはあまりよろしくないので、Cloud Functions になにかしらのセキュリティ設定を行う必要がありますが、そのあたりについては別の記事にまとめます。