Glide AppsとCloud Functionでウマ娘着順管理アプリを作った

### はじめに
こんにちは。いりすです。最近ウマ娘にハマってます。
ウマ娘をプレイしていると目標レースに対して過去の育成結果を参照しながら育成方針を考えるみたいなことがやりたくなります。
そのため、twitterに着順とステータスを張り付けてたりしたのですが、検索性が悪く活用している感じありません。

そこでGlideというノーコードサービスを用いてデータ管理アプリを作成してみました。

こういう感じ

https://twitter.com/irisuinwl/status/1383467163252449286

URL: https://uma-search.glideapp.io/

### GlideAppってなに？
GlideとはスプレッドシートをデータベースとしてノーコードでPWAが作成できるサービスです。

https://www.glideapps.com/

どのようなアプリケーションをノーコードで作成できるかはGlide公式の従業員管理アプリのpreviewなどを見るとわかりやすいです。

https://go.glideapps.com/app/employees-template

### Glideでレース検索画面を作る

Glideでは、スプレッドシートのシートをDBのテーブルと見立て、その情報を表示するアプリケーションをノーコードで作成します。
なので、まずはシートにどのようなデータを入れるかの設計が大事になってきます。ウマ娘のレース情報として以下をデータとして持つこととします。

- レース名: レースの名前、天皇賞（春）とか、一意に定まる。
- 詳細: 表示詳細
- グレード: G1,G2,G3など
- 開催月	: 1月前半など
- 時期: ジュニア、クラシック、シニアをマルチタグ形式で保存、glideでマルチタグで保存するためには[このdiscussion](https://community.glideapps.com/t/categories-tags-multiple-relations-not-sure-how/3726)などを参照
- 区分: 短距離、中距離、マイルなど
- 獲得ファン人数: 何人入るか


#### 一覧画面

以下のようなレース一覧を参照する画面を作りました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/eefe7f8a-1efc-24f4-0205-8edd4a4464ad.png)

データを攻略wikiからspread sheetに持ってきて、画面をデザインしました。

画面のデザインは右のサイドバーで表示形式を選択（今回はCards）することで、その画面におけるデータを表示するデザインを決めることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/784021ad-dfe7-098d-072f-44be22a92e64.png)

[Edit List]を押下すると、表示するデザインの詳細を変更できます。
画面にfavoriteを追加し、ユーザーごとにお気に入りレースを閲覧などもこの画面でできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/bd47a6a2-0864-196c-bb7a-7dfad821954b.png)

タブをGeneralからOptionsにすると検索やフィルターを設定することができます。
以下のように、時期（ジュニア、クラシック、シニア区分）でフィルターできるようにして、検索性を高めました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/10e9825c-21ac-075a-7e7f-41ac1d015bf0.png)

#### 詳細画面

一覧画面のカードをクリックすると、以下の詳細画面が出るようになっております。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/e49ccc7c-56b0-d91d-2343-231c577acf64.png)

デフォルトだと選択したレコードの値が単に表示される味気ない表示なので、表示デザインを変えました。
表示デザインの変更は左サイドバーで行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/510a20fb-114b-20fa-1a9e-fab746d701ce.png)

`+`ボタンを押せばコンポーネントを追加できます。
左サイドバーのコンポーネントをクリックすることで右サイドバーに詳細の表示形式を設定する画面が出ます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/190214fb-24ba-b3fb-dd10-b1a76406745b.png)

このようにデータの入手さえすれば、いい感じにコンポーネントを配置して、表示を整えて15分程度でアプリケーションが作成できてしまいます。

### 着順レコード画面

着順レコードを閲覧する画面です。シートを以下の構造で管理します。

- 時期: ジュニア・クラシック・シニア
- レース名: レースの名前、レース一覧を参照できる外部キー的に使う。
- 育成ウマ娘: 育成対象ウマ娘、ダイワスカーレットやダイワスカーレットやダイワスカーレットなど
- photo: アップロード画像
- 着順: レースに出た結果何位だったか

画面としては以下のような感じ、設定は上記と同じなので省略

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/ff45040f-a60f-ada0-186e-542d781dbd11.png)

#### 管理者画面を作成する。
着順を登録する管理者画面を作成します。
左サイドバーの`+`を押して画面を追加、MENU部に追加した画面コンポーネントを持っていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/6e523263-7e85-8727-c693-aaab9d45fc17.png)

右サイドバーでSTYLEをDetailにして画面コンポーネントを配置できるようにします。
Sourceは追加する着順を管理するシート(`record`)にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/a49cfdc5-b96e-d370-ec05-2ce0fa1386aa.png)

ここで、管理画面は特定のユーザーにしか見れないように右サイドバーから`Options`を選択、`Visibility`に自分のemailを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/c795064b-f989-49f9-fd95-97c1d1628c93.png)

管理者画面にボタンと登録レコード数を配置して、画面レイアウトを整えます。
ここで、ボタンは`Form Button`のコンポーネントにし、ボタンを押すとformが表示されデータが追加されるようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/d5e5e082-9a7b-e18c-fe74-35239c1c9cf5.png)

ボタンを押すと以下のような画面が出てくるように、SCREENを修正しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/810e9bd5-90ec-dfd1-4495-4e69ad704dab.png)


各コンポーネントとして、sourceに選択したいマスターテーブルとなるシートを、DATAにデータ書き込み先のカラムを指定することで、formをsubmitしたときに自動でデータが追加されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/6afe4a0d-62ee-c530-3b98-36da14933cc3.png)

このようにポチポチすることで管理者用のデータ追加画面が出来てしまいました。

### VisionAPI+GCF+GASでステータスを自動でsheetに入力する

さて、Glide経由でレース着順を入れることができました。
これで画像一覧を見ながらレース結果を参照して育成に活かすことができそうです。
しかし、参考にするステータスが画像でのみしか参照できなく、機械にやさしくなく、他のアプリケーションの連携が悪かったりなどがあります。
なので、OCRをして、シートが変更されたら、ステータスをシートに書き込む処理を実現してみましょう。

全体的な流れとして、以下のような処理を実装します。

1. Spread Sheetが更新
2. GASが発火し、更新したセルの情報を取得
3. 画像URIに対してOCRをかけるようなAPIをcall
4. GASがAPIの結果から各ステータスをsheetに代入

#### OCRをするCloud Functionの作成

URIを渡したらOCRし、ステータス情報を返すAPIをcloud functionで作ります。
OCRとして画像認識APIとして精度の高いGoogle Vision APIを用います。

GCFのコード:

```python
import re

from google.cloud import vision
from flask import abort

def exec_vision_ocr_uri(uri: str):
    client = vision.ImageAnnotatorClient()
    image = vision.Image()
    image.source.image_uri = uri

    response = client.text_detection(image=image)
    texts = response.text_annotations
    return texts


def extract_uma_status(uri: str) -> dict:
    texts = exec_vision_ocr_uri(uri)

    texts = [text for text in texts if re.fullmatch(r'[0-9]{2,4}', text.description)]
    # 左から右に並ぶようにソート
    texts = sorted(texts, key = lambda t: t.bounding_poly.vertices[0].x) 

    umamusume_status = {
        'speed': texts[0].description,
        'stamina': texts[1].description,
        'power': texts[2].description,
        'guts': texts[3].description,
        'wise': texts[4].description,
    }
    return umamusume_status


def main(request):
    request_json = request.get_json()
    if request.args and 'uri' in request.args:
        uri = request.args.get('uri')
        
    elif request_json and 'uri' in request_json:
        uri =  request_json['uri']
    else:
        return abort(400)
    
    return extract_uma_status(uri)
```

`requirements.txt`

```
google-cloud-vision
```

GCFの設定として、誰でもcallできてしまうと料金的に辛いので、認証を必要とし、HTTPSを必須とします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/806e1133-1f7d-d96b-b73b-3b35cc4b6012.png)


ちゃんと動くことをテスト:

```
$ curl -H 'Content-Type: application/json' https://PROJECT_ID.cloudfunctions.net/FUNCTION_NAME -d '{"uri": $IMAGE_URI}' -H "Authorization: bearer $(gcloud auth print-identity-token)"
{"guts":"224","power":"747","speed":"744","stamina":"459","wise":"377"}
```

実装にあたって、以下を参考にしました。

https://cloud.google.com/vision/docs/ocr?hl=ja

#### GASとCloud Functions連携

GASを書くのが初めてだったり、認証周りがめっちゃ辛かったですが、stackoverflowに同じことをやりたい人がいたので、それを参考に設定しました。

https://stackoverflow.com/questions/61781421/securely-calling-a-google-cloud-function-via-a-google-apps-script

手順としては以下です。

- 1. GCFを作成したGCP ProjectにOAuth画面を作る。
    - テストユーザーに自分のメールアドレスを登録。
- 2. プロジェクト設定でGCPプロジェクトをGCFを作成したGCP Projectに変更する。
- 3. プロジェクトの設定で `appsscript.json` を表示するように設定。
- 4. `appsscript.json` にapps scriptの権限scopeを設定する。設定例は以下:

```
  "oauthScopes": [
    "openid",
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/script.external_request"
  ],
```

- 5. Cloud Functionsの再ビルド(これをしないと認証情報を読み込めてないのか、上手くいかない)
- 6. 上記手順により認証情報を扱えるようになったので、以下のようにGASを書く。

```javascript
function myFunction(e) {
  var sheet = SpreadsheetApp.getActiveSheet(); 
  var activeCell = sheet.getActiveCell();
  var sheetName = sheet.getName();
  var currentRow = activeCell.getRow(); 
  var uriCell = sheet.getRange(currentRow, 5).getValue();

  Logger.log(sheetName + "," + currentRow + ","+ uriCell);
  if (sheetName != "record" || uriCell == "" || currentRow == 1) return;

  var updateSpeed = sheet.getRange(`G${currentRow}`);
  var updateStamina = sheet.getRange(`H${currentRow}`);
  var updatePower = sheet.getRange(`I${currentRow}`);
  var updateGuts = sheet.getRange(`J${currentRow}`);
  var updateWise = sheet.getRange(`K${currentRow}`);
  var updateId = sheet.getRange(`A${currentRow}`);

  Logger.log(updateSpeed.getValue() +","+
    updateStamina.getValue() +","+
    updatePower.getValue() +","+
    updateGuts.getValue() +","+
    updateWise.getValue());
  if (
    updateSpeed.getValue() != "" || 
    updateStamina.getValue() != "" ||
    updatePower.getValue() != "" ||
    updateGuts.getValue() != "" ||
    updateWise.getValue() != ""
  ) return;

  status = getUmaStatus(uriCell);
  Logger.log(status);

  updateSpeed.setValue(status.speed)
  updateStamina.setValue(status.stamina)
  updatePower.setValue(status.power)
  updateGuts.setValue(status.guts)
  updateWise.setValue(status.wise)
  updateId.setValue(currentRow-1)
}

function getUmaStatus(uri) {
  url = "CLOUD_FUNCTIONS_URI"
  fetchOptions = createRequestOption("POST", {"uri": uri});
  var response = sendRequest(url, fetchOptions);
  return response
}

function sendRequest(url, fetchOptions) {
    var res = UrlFetchApp.fetch(url, fetchOptions);
    Logger.log(res);
    return JSON.parse(res);
}

function createRequestOption(method, payload) {
    const token = ScriptApp.getIdentityToken();
    return { 
      "method": method,
      "muteHttpExceptions" : true,
      "contentType": "application/json",
      "payload": JSON.stringify(payload),
      "headers": { "Authorization": "Bearer " + token } };
}
```

- 7. トリガー設定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/1abd732f-6ede-745b-fae2-dea2f14bda33.png)

あとは実際にセルをいじったりして、動くことを確認。

完成！

### レース一覧画面の検索性を上げる

Glide Appsデフォルトのフィルターは一つの項目しかできず、いろいろ融通が利かないので複数項目のフィルターを実装します。
これは公式のcommunityにマルチフィルターを実装する方法が乗っておりますので、それを参考に作りました。

https://community.glideapps.com/t/use-multiple-choice-components-to-filter-an-inline-list/16932

以下のような感じで、グレード・時期・距離の絞り込みが出来るようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299097/2c1c8507-a43b-b157-bb87-7c3f5cfe256e.png)


### おわりに

以上で、Glide Appsを使って簡単にデータ管理し、ML APIを連携したアプリケーションを作りました。

Glide及びノーコードサービスは初めて利用しましたが、爆速でアプリケーションを作成でき、想定より様々なことができて驚愕しました。
もちろんGCPなどの外部連携が難しいという点など不足に感じる点はあるものの、思いついたアイデアをサッとアプリケーションで実装し、プロトタイピングするといったことが容易に出来そうで非常に興味深いと感じました。
また、エンジニアでない方も簡単にアプリケーションを作ることができるので、ソフトウェアをベースにコミュニケーションが出来、組織や文化自体が変化していきそうで、ノーコードという分野自体の可能性を感じられました。

また、GASとの連携も行いましたが、Glideで作る分には1時間程度で終わったものの、GASとGCF連携部分は4時間以上かかりました（ここまでやると、ノーコードとは一体…って気持ちになった）
Glideは便利な反面、制限があるので、特性を理解し、使っていくことが大切に感じました。

そして、これはアイコン用に描いて頂いた可愛いダイワスカーレット

https://twitter.com/irisuinwl/status/1383963553636372480