GoogleDriveへのファイル追加をトリガーにCloudFunctionsを実行する

## はじめに

こんにちは、京セラコミュニケーションシステム 森田 (@kccs_kai-morita）です。

GoogleDriveは検索も共有も簡単にできて便利ですよね。仕事でもプライベートでもよく使っています。

ドライブのフォルダーにファイルを配置するだけでプログラムが自動で実行されると便利だなと思い、実装してみました。

この記事では、DriveAPIを用いてGoogleDriveにPDFファイルを配置すると、GoogleCloudPlatform - CloudFunctionsが実行されることを目標としています。

ただ実行されるだけでは面白くないので、OCRによりファイルの中の文字を解析した文字列が生成されるようにしました。コードの中身としてはGoogleCloudPlatform - CloudVisionAPIを用いているだけですのでOCR部分は本記事では省略しております。

:::note info

本記事は2022年8月ごろに作成しております。
また、Google Cloudの機能を利用した場合は、利用料が発生します。
:::

## この記事の対象者
* GoogleDriveをもっと活用したい方
* Pythonで動かしてみようと思っている方


## GoogleDriveAPIのプッシュ通知を利用する
フォルダーにファイル追加やファイルの変更が加えられるとCloudFunctionsにおける関数が実行されるという部分には、[GoogleDriveAPIのプッシュ通知](https://developers.google.com/drive/api/guides/push)が利用できると考えました。こちらのサービスは指定した監視対象のリソースが変更されると、DriveAPIがアプリケーションにプッシュ通知されるという機能となります。

流れとしては以下のとおりです。
1. あるアカウントが権限を持つフォルダーに変更が加えられる
2. プッシュ通知が作動
3. CloudFunctionsにおける関数が実行される

## 事前準備
### サービスアカウントの作成
DriveAPIのプッシュ通知では、フォルダー単位での監視をするわけではなくアカウント単位での監視となるため、ファイルを置くフォルダーに対して対象のアカウントの編集権限を付与するという形をとります。個人の既存アカウントに設定すると権限があるすべてのファイルに対して変更通知が飛ぶため、新規にアカウントを作成することとします。今回はGoogleCloudPlatformよりサービスアカウントを作成しました。

「IAMと管理」画面より「サービスアカウントの作成」を選択します。

![サービスアカウントの作成画面g](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780555/d5881fa8-ed18-a4d8-b4f4-a40ac2713247.png)
<font color="Gray">_サービスアカウントの作成画面_</font>

任意のサービスアカウント名およびIDを記入しその他デフォルトで完了します。

![サービスアカウントキーの管理画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780555/4e0ea75c-41b7-0eb1-d7f3-2942921f6234.png)
<font color="Gray">_サービスアカウントキーの管理画面_</font>

サービスアカウント管理画面にて、作成したサービスアカウントを選択します。開いたページにて「キー」タブを選択し、「鍵を追加」よりサービスアカウントキーをダウンロードします。

### フォルダーへの権限追加
GoogleDriveにてトリガーとなるフォルダーを作成し、右クリックにて共有を選択します。

![GoogleDrive画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780555/9ef5e749-3084-4b64-b13c-426a93bdd879.png)
<font color="Gray">_GoogleDrive画面_</font>

作成したサービスアカウントのメールアドレスを追加し、権限を編集者として付与し共有設定します。

### Cloud Functionsの作成
OCRを実行するCloudFunctionsを作成します。

![CloudFunctions作成画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780555/f4109f4b-dcd8-e4bf-75f7-fd78af042b17.png)
<font color="Gray">_CloudFunctions作成画面_</font>

関数名リージョンを入力し、トリガーのタイプはHTTPで新規作成します。作成後トリガーURLはメモします。

## 通知チャンネルの作成
プッシュ通知をリクエストするためには、監視するリソースやアカウントごとに通知チャネルを設定する必要があります。単一のファイルに対する変更を管理する手法もありますが、今回は特定のフォルダー追加したファイルすべてに対して監視を行う設定となるためアカウント単位での通知チャンネルの設定となります。

環境としてはGoogleCloudPlatform - VertexAIのワークベンチ（Jupyternotebookです）にて、pythonで記述して検証しています。

### トークンの発行
まずアプリとGoogleのサーバが通信するためのトークンを発行します。

```python:drive_notification.ipynb
from oauth2client.service_account import ServiceAccountCredentials

# ドライブのすべての編集権限スコープ
SCOPES = ['https://www.googleapis.com/auth/drive']
# key.jsonは先程作成したサービスアカウントのキーファイル
credentials = ServiceAccountCredentials.from_json_keyfile_name(
      'key.json', SCOPES)
res = credentials.get_access_token()
token = res.access_token

#最新の更新を取得するためのトークンを発行
header = {
    'Authorization': f'Bearer {token}',
    'Content-Type': 'application/json'
}
token_result = requests.get(url='https://www.googleapis.com/drive/v3/changes/startPageToken', headers=header)
get_token = json.loads(token_result.text)
page_token = get_token["startPageToken"]
page_token
```
実行結果
```
'93'
```

[スコープ](https://developers.google.com/identity/protocols/oauth2/scopes#docs)はdriveという権限が強いものを用いています。auth.readonlyでは動作しませんでした。

またpage_tokenに最新の更新番号を取得しています。

### 有効期限の作成
通知チャンネルには有効期限を設定する必要があります。Unixタイムスタンプとしてミリ秒単位の実際の有効期限を設定できます。

```python:drive_notification.ipynb
import datetime
#有効期限を設定
hour = 60 * 60 * 1000
day = hour * 7
expilation_milisec = int(time.time() * 1000) + 1 * day
datetime.datetime.fromtimestamp(expilation_milisec/1000,datetime.timezone(datetime.timedelta(hours=9)))
```
実行結果
```
datetime.datetime(2022, 9, 1, 10, 42, 17, 808000, tzinfo=datetime.timezone(datetime.timedelta(seconds=32400)))
```

問題点は有効期限をどれだけ長く設定しても反映されるのは一週間分となる点です。有効期限の設定は無効にはできなく、また自動更新する方法もありません。半永続的に有効にするためには、有効期限設定を行うCloudFunctionsを新規作成しCloudScheduler等を用いて定期実行する工夫が必要となりますが、重複期間が発生するという問題点があります。

### 通知チャンネルの作成
新しい通知チャンネルを一意に識別するためのチャンネルIDを作成します。 

```python:drive_notification.ipynb
#チャンネルID 
channel_id = str(uuid.uuid4())
print(channel_id)
```
通知チャンネルを作成します。
```python:drive_notification.ipynb
header = {
    'Authorization': f'Bearer {token}',
    'Content-Type': 'application/json'
}
body = {
    "id": channel_id,
    "type": "web_hook",
    "address": f'{"Cloud functionのトリガーとなるURL"}',
    "expiration": f'{expilation_milisec}'
 }

result_create = requests.post(url=f'https://www.googleapis.com/drive/v3/changes/watch?pageToken={page_token}', data=json.dumps(body), headers=header)
result_create
```

実行結果
```
<Response [200]>
```

200で返答があれば成功しています。

## CloudFunctionsの内容
ファイルがフォルダーに配置されるとCloudFunctionsが実行されますが、受け取ったリクエストの内容には**どのファイルが更新されたかという情報**はありません。

そのため、アカウントが権限を持つファイルの一覧から最新のものの取得をします。
```python:main.py
from apiclient.discovery import build
from oauth2client.service_account import ServiceAccountCredentials
from googleapiclient.http import MediaIoBaseDownload

# 認証
scope = ['https://www.googleapis.com/auth/drive']
keyFile = 'key.json'
credentials = ServiceAccountCredentials.from_json_keyfile_name(keyFile, scopes=scope)
drive_service = build("drive", "v3", credentials=credentials, cache_discovery=False)

# starttokenの取得
response = drive_service.changes().getStartPageToken().execute()
saved_start_page_token = response.get('startPageToken')
chenged_page = str(int(saved_start_page_token) - 1)

# 更新されたファイル情報の取得
response = drive_service.changes().list(pageToken=chenged_page,spaces='drive').execute()
new_file_info = response.get('changes')[0]
file_id = new_file_info['fileId']
fileName = new_file_info['file']['name']     

# ファイルのダウンロード
request = drive_service.files().get_media(fileId=file_id)
fh = io.FileIO(f'/tmp/{fileName}', mode='wb')
downloader = MediaIoBaseDownload(fh, request)
done = False
while done is False:
    status, done = downloader.next_chunk()
```

以上でファイルの配置から取得までできました。あとはファイルに対して好きに加工や1つのクラウドストレージに集約したり、GoogleCloudPlatform - VertexAI Pipelineの実行などさまざまなことが可能になります。


## 確認してみた
冗長になるので省略しましたがCloudFunctionsにはPDFファイルを読み取ってOCRして中に書かれている文章を読み取るコードを書いています。

この記事の冒頭の一文をPDFファイルを作成しましたので、正常に動作するか確認します。

![作成したPDFファイル](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780555/d7d17dbb-8acd-d3b9-2f3e-9b52046aa1f8.png)
<font color="Gray">_テスト用PDFファイル_</font>

こちらを作成したサービスアカウントが権限をもつフォルダーに配置します。

![実行ログ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780555/b30116b1-062e-ff2e-db02-6d0cf7533ed5.png)
<font color="Gray">_CloudFunctions実行ログ_</font>

正常に更新したファイルをダウンロードできていることがわかります。

![OCR結果ログ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2780555/be12842d-d6cf-a5de-dbc8-34f40be5d1fb.png)
<font color="Gray">_OCR結果ログ_</font>


正常に文字列を読み取れていました。

### 確認した動作

以下の動作によりトリガーが作動したことを確認しました。

-   ファイルの名前の変更 (例：test.txt ⇢ test_2.txt)

-   ファイルの削除

-   ファイルの追加

-   ファイルの中身の変更(docs)

-   ファイルの上書き

-   通知チャンネル作成時

ファイルの追加以外でも動作してしまうため、注意が必要です。

また、前述の追加されたファイルの名称がわからないという問題から、同時に2つのファイルを追加してみましたが、容量の差からか正常に順に実行されました。

## 通知の停止

作成時の応答に含まれるchennnel_idとresourcedIDが必要となりますので、あらかじめ保存しておきます。

```python:drive_notification.ipynb
header = {

'Authorization': f'Bearer {token}',

'Content-Type': 'application/json'

}

body = {

"id":channel_id,

"resourceId":resourceId

}

stop_result =
requests.post(url=f'https://www.googleapis.com/drive/v3/channels/stop',
data=json.dumps(body), headers=header)

```

成功すると空の応答が返ります。


## まとめ

できたこと
-   GoogleDriveへのファイル追加をトリガーに関数実行できる

課題
- 有効期限が最大1週間として設けられているため、定期的に再設定の必要がある

- 変更されたファイルの名称やどういう変更が加えられたかはわからない



### おわりに

ドライブにファイルが追加することによるプログラムの実行は実現可能であることが判明しました。有効期限の問題やなにが追加されたかはわからないといった課題があり、まだまだ商業利用には向いていないかもしれませんが、これによってITリテラシーの高くない方でも気軽に使えて環境でデータの集約や簡単な検証ができるようになるのではないでしょうか。

## 参考

<https://developers.google.com/drive/api/guides/push#renewing-notification-channels>

<https://developers.google.com/drive/api/guides/manage-changes#using_push_notifications>

<https://qiita.com/bino98/items/22904cdd8af2debed32c>

<https://zenn.dev/wtkn25/articles/python-googledriveapi-auth>

<https://python2021notes.blogspot.com/2021/02/23google-drive-pydrive-google-drive-api.html>