GAS + CloudFunctionsでVisionAPIの処理結果をスプレッドシートに出力する


スプレッドシートとOCRを連携した社内ツールを作成しました。
具体的には、スプレッドシートに入力された大量の画像urlから、Vision　APIを使って抽出したテキストデータを、スプレッドシートのセルに出力する仕組みです。
今回、技術選定/要件作りからスタートし、VisionAPI、GCP、CloudFunctions,GASなど全て未知の状態から、実装/実運用まで漕ぎ着けたのでざっくりまとめてみます。

最終的な構成はこんな感じ。
![gas-visionapi.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1464615/a867ad40-dcf1-2b3e-1c42-3afee46b632d.png)
使用言語: python, JavaScript
インフラ: GCP
主なライブラリ: Vision API, gcroud, clasp

1.　スプレッドシートに入力された画像urlをGASを使って取得
2.　GASでCloudFunctionsのトリガーを叩き、JSON形式でデータを送信
3.　VisionAPIを使ってURL先の画像からテキストデータを読み取る
4.　テキストデータから必要なデータを抽出/加工
5.　gspreadで特定のセルに出力

本記事では、インフラ/認証/API部分のつまづきポイントを中心にまとめています。
基本的なリソース作成方法などは省略ぎみです。
またgcloudやVisionAPIの処理方法などについては触れていませんのでごめんなさい（いずれどこかで）。
### CloudFunctionsをセットアップ
GCPにてprojectを作成します。
`Google Sheets API`,`Cloud Vision API`など必要なAPIを有効にしてcloud functionsを作成。
HTTPトリガーを選択。
CloudFunctionsは第2世代(gen2)を使用。

当初第1世代で作成したのですが、画像処理データの件数が多く（1000件を超える）処理中にタイムアウトしてしまいました。540秒（9分）の制限では要件を満たせないため、第2世代に変更。3600秒（60分）まで実行可能です。

最低限必要となるファイルは２つ。エントリーポイントとなるfunction用のファイル（main.py）と追加ライブラリを設定するrequirements.txtファイル。
requirementsは以下のような感じ。
```txt:rquirements.txt
google-cloud-vision
google-cloud-language
gspread
requests
```
### スプレッドシート、GASのセットアップ
次にGoogleAppScriptの作成をしていきます。
準備したスプレッドシートからコンテナバインドのGoogleAppScriptを作成。
先ほど作成したGCPと連携するための設定をしていきます。
- GCPのプロジェクト番号を設定から追加
- スプレッドシートのアクセス権限にGCPのサービスアカウント(**@developer.gserviceaccount.com)を追加
- CloudFunctionsのトリガーurlをスクリプトプロパティなどから呼び出せるようにしておく
- appsscript.jsonを設定（以下具体例）

##### appsscript.jsonに認証が必要なgoogle関連サービスの設定
appsscript.json(タイポに注意。appsです)のoauthScopesに、google関連サービスを追加しておくことで、認証が使えるようになります。今回の設定はこんな感じ。
```json:appsscript.json
{
    "timeZone": "Asia/Tokyo",
    "exceptionLogging": "STACKDRIVER",
    "runtimeVersion": "V8",
    "oauthScopes": [
        "https://www.googleapis.com/auth/userinfo.profile",
        "https://www.googleapis.com/auth/userinfo.email",
        "https://www.googleapis.com/auth/script.external_request"
        "https://www.googleapis.com/auth/drive.readonly",
        "https://www.googleapis.com/auth/spreadsheets.currentonly",
        "https://www.googleapis.com/auth/script.container.ui"
      ]  
}
```
- `userinfo.profile`,`userinfo.email`はcloudfunctionsでの認証に必要なIDトークン取得できる
- `script.external_request`は`urlFetchApp`を使用する際に必要になる。

### GASからCloudFunctionsのHTTPトリガーを叩く
HTTPトリガーを叩く際にheaderにIDトークンを含める必要がありますが、こんな感じにします。
```javascript:main.gs
UrlFetchApp.fetch(url,{
  method: POST,
  header: {
    Authorization: `Bearer ${ScriptApp.getIdentityToken()}`
  },
  'Content-Type': 'application/json',
  'payload': JSON.stringify(data),
  'muteHttoExceptions': true
})
```
`url`はCloudFunctionsのHTTPトリガーのurlを設定。
`ScriptApp.getIdentityToken()`でIDトークンが取得。
CloudFunctionsに送る画像urlなどの`data`をJSON形式にしてfunctionsに渡す。

### WEB上の画像データからVisionAPIでテキスト抽出
GASから渡されたurl先の画像からVisionAPIでテキスト抽出します。
```python:ocr.py
import io
import requests
from google.cloud import vision 

def extract_from_image(image_url)
  #clientの初期化
  client = vision.ImageAnnotatorClient()
  #url先の画像からファイルからオブジェクトを生成
  image_file = io.BytesIO(requests.get(image_url).content)
  content = image_file.read()
  image = vision.Image(content=content)　#画像認識するオブジェクトを作成
  response =　client.document_text_detection(image=image,image_context={"language_hints": ["ja","en"]})
```
抽出したテキストデータを含んだデータ（テキストの位置座標やブロック、パラグラフ、多単語、文字など階層構造になったもの）が`response`に返却されます。
ちなみに`language_hints`を指定しなくても自動的に言語の判別をしてくれますが、任意に読み取り言語を指定してあげることで認識精度が上がります。

### スプレッドシートへの書き込み認証(gcloud)
当初CloudFunctionsからデータをレスポンスとしてGASに返す形にしていましたが、GASの最大実行時間は６分です。処理件数が多いとすぐにタイムアウトしてしまうため、CloudFunctionsの最大60分の実行時間を利用して、gspreadを使ってダイレクトに書き込む実装に変更しました。

gspreadでスプレッドシートへ書き込みする際の認証をどうすればよいのかでつまづきました。
ADC([Application Default Credencials](https://cloud.google.com/docs/authentication/application-default-credentials?hl=ja))についてのドキュメントにあるように、特に新しいサービスアカウントキーを発行しなくても、google.auth.default()([※こちらを参照](https://googleapis.dev/python/google-auth/latest/user-guide.html))でGCPの実行環境から`credencials`を取得できます。
```python:main.py
import gspread
import google.auth

credentials, _ = google.auth.default(scopes=['https://spreadsheets.google.com/feeds','https://www.googleapis.com/auth/drive'])
gc = gspread.authorize(credencials)
wb = gc.open_by_key(SPREADSHEET_KEY)
```
こんな感じで認証情報を付与し、指定のスプレッドシートを直接編集できます。
SPREADSHEET_KEYはスプレッドシートのurlに含まれる一意の文字列で、`https://docs.google.com/spreadsheets/d/*****/edit~~~`の`*****`の部分にあたります。

### ディレクトリ構成&デプロイ
GASとCloudFunctionsのコードの管理１つのリポジトリで管理したかったので、ざっくり以下のような感じにしました。
```
project/
    gas/
        main.js
        appsscript.json
    gcf/
        main.py
        ocr.py
        requirements.txt
    .clasp.json
    .gcloudignore
    .gitignore
    deploy_gcf.sh
    push_gas.sh
```
基本的にGASもCloudFunctionsもローカルからのデプロイを前提で作りました。

GASのデプロイには`clasp`を使用。
claspのpush用のシェルスクリプトファイルを用意し、実行時に引数を渡して、検証環境か本番環境かを指定してデプロイする仕組み。

CloudFunctionsもclasp同様gcfディレクトリ以下を`deploy_gcf.sh`を実行してデプロイを行います。

### GoogleSheetAPIのリクエスト上限を上げる
gcloudを使ってスプレッドシートを操作していますが、テスト段階でときどきエラーが出ていました。その原因がGoogleSheetAPIの[上限](https://developers.google.com/sheets/api/limits?hl=ja)に引っかかっていたためです（CloudFunctionsのログから確認できます）
GoogleSheetAPIは回数による制限はありませんが、時間単位だと以下の制限がかかっています。
![スクリーンショット 2023-03-21 21.37.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1464615/715d2819-f808-e402-81ef-497e216bea76.png)
単一のfunctionでapiを使用する場合、読み書きそれぞれ1分間に60回という制限が課せられます。複数のセルをまとめて処理するなど時間あたりの回数を少なくする工夫をしましたが、限界がありました。
実はこの制限、申請することで上限を上げることが可能です。

1. GCPのナビゲーションメニューから「APIとサービス」を表示
2. 有効なAPI一覧が表示されるので、GoogleSheetAPIを選択。ここでAPIの使用回数/エラーなどのメトリクスが確認できます。
3. 「割り当て」タブから割り当て一覧を表示(上限に対する使用量のリストが表示されます)
4. 割り当てリストの上限を上げたい項目にチェックをつけて、右上にある「割り当てを編集」をクリック
5. チェックした項目の新しい上限を入力し、理由などを書いてリクエスト

上記申請を行うと3日後には上限が反映されていました。
ちなみに今回、`Read requests per minute per user`と`Write requests per minute per user`をそれぞれ60回から120回まで上げました。

--------
スプレッドシート、GASと,CloudFunctionsの連携を中心に書きました。
VisionAPI,GASのスクリプトプロパティの有効な使い方、CloudStoreとの連携など他にも新たな学びがありましたので、別の機会にまとめてみたいと思います。