Google Driveの更新通知を受け取って記録を取る【GAS】
GoogleはBoxと違って簡単にGoogle Driveの変更通知を受け取って処理といったことが出来ません。他にもパスワード保護も無いし、外部共有の制限などの機能が大雑把など細かい点で機能不足が目立ちます。
しかし、Drive APIを利用する事でフォルダやファイルの監視をする事が可能という情報を耳にしたので、今回Google Apps ScriptとGoogle Drive API、通知を受け取る窓口としてGoogle Cloud Functionsを使って構築してみようと思います。
Boxでの変更通知のテクニックは以下のエントリーをご覧ください。
目次
今回利用するサービスとスプレッドシート
- Drive APIでwatchする - Google Spreadsheet
- Google Cloud Functions
- Google Drive API - Changes:watch
今回のプログラムはGoogle Apps Script単体では構築が出来ません。そこで、次項の制限事項にある内容をクリアする為に、Google Cloud Functionsを利用しています。関連エントリーは以下のページです。
仕組みと制限
Drive APIを使って特定のファイルやドライブ全体に対して、監視するチャンネルを追加し、変更を受け取ってシートに内容を書き込んだり、通知をChatで送ったりが今回の目的。しかし、Google Apps Scriptの仕様上の問題で、監視チャンネルの設置は出来るものの、変更通知を受け取る事が出来ず目的を達成出来ない。
という事で以下の制限を踏まえた上で、仕組みの内容を構築準備する必要性があります。
GASの制限
Drive APIにチャンネルを設置して、有効期限が来る前に再設置する。また、GCFからの書き込み依頼を受けてシートに書き込むのが今回の役割。本来は通知も受け取りそのままGASで完結したい所が、Google Apps Scriptには以下の仕様がある為、それが叶わず。
- Drive APIからの通知にはリクエストヘッダにカスタムヘッダ項目が含まれていて、ここにどんな変更内容なのかが含まれているが、GASはリクエストヘッダの中身を取得する事が出来ない。(X-Goog-Resource-Stateの値が必要になる)
- 公開されていない制限があり、watchのAPIのQuotaは変更監視出来る上限が決まってる(StackOverFlowによると60秒未満、70ファイルを超えるwatchを設定すると403エラーになる)。またこの上限は割増申請が出来ない。
- 監視チャンネルの寿命は、最大24時間。そのリミットが来る前にチャンネルを削除して再設置する必要がある。
- Drive APIから飛んでくる「resourceIdとChannel ID」が削除時に必要になるのでスクリプトプロパティに格納しておく必要がある
- watchをする前にStartPage Tokenというものが必要。
- レスポンスを受けるサイトの所有権証明をsearch consoleにてしなければ行けないので、GASだけだと難しい。
仕組み
役割分担的には以下のようなイメージ
- GAS:Drive APIに監視チャンネル作ったり、24H毎に設置し直したり。またGCFからの書き込み命令を受け取ってシートに書き込む役割
- Drive API:ドライブやファイルに対する監視とGCFへの通知を行う
- GCF:Drive API側からの通知を受け取り解析、GAS側へ書き込み命令を送る
この仕組を応用する事で、Google Driveの変更通知だけじゃなくAdmin SDKのDirectory APIからの通知を受け取って処理などが出来るようになるので、色々と使えそうです。
事前準備
今回の仕組みはGCP側の機能を利用する為、いくつかの事前準備が必要になります。予め、GCP側にはDrive APIを有効化しておいたプロジェクトを用意しておく必要があります。また、サービスアカウントを使う必要があるので、それらの設定も必要になります。詳細は以下のエントリーを参考にしてみてください。
GAS側での準備
APIの有効化
今回はGoogle Drive APIを利用するので、スクリプトエディタに入って、以下の作業が必要になります。
- スクリプトエディタを開く
- エディタの左側、サービスの横の+をクリックする
- Drive APIを探して選択し、追加をクリック
これで、Drive APIの各種メソッドが利用する事ができるようになります。
図:Drive APIを追加する
プロジェクトを移動
図:プロジェクト変更画面
デプロイを行う
GAS側で処理するためのコードを記述したら、doPostの受け口を有効化するためにデプロイをします。
- スクリプトエディタを開く
- 右上のデプロイをクリック
- 新しいデプロイをクリック
- 種類の選択ではウェブアプリを選択し、次のユーザとして実行は自分にしておきます。
- アクセスできるユーザは、ドメイン名内のユーザとして起きます
- 末尾がexecで終わるURLが発行される。これがGCF側のNode.jsで送信先として指定するURLとなるので控えておく。
- 次回以降コードを編集して再デプロイ時はデプロイを管理から同じURLにて、新しいバージョンを指定して発行することが出来ます。
APIを有効にする
Google Cloud Console側でもAPIを有効化する必要性があります。
- GCPのプロジェクトを開く
- 左サイドバーからAPIとサービスにて、「APIとサービスの有効化」をクリックする
- driveと検索すると出てくるので、クリックします。
- 有効化をクリックします。
- 認証情報の作成は不要です
図:有効化をしておくだけでOK
Cloud Functionの準備
インスタンスの準備
今回はCloud Functionsは受け専門でスプレッドシート側へ解析内容を転送するのが仕事なので、以下のような手順でIncoming Webhookのようなスタイルで構築します。
- 右上のハンバーガーメニュー(≡)をクリックし、サーバーレス項目にあるCloud Functionsをクリック
- 関数を作成をクリック
- 環境は第一世代で問題なし
- 関数名はデフォルトのfunction-1のままで問題なし
- us-central1のリージョン、トリガーはHTTP、未認証の呼び出しを許可にチェックをする
- 保存をクリックする
- ランタイム、ビルド、接続、セキュリティの設定を開く
- 続けて下にある「ランタイム、ビルド、接続の設定」をクリック
- メモリは256MBを指定、使用するサービスアカウントの指定をして次へをクリック(サービスアカウントを作っていない場合には、App Engine Default service accountここでデフォルトのものが作成されます)
- 次へをクリックする
- ランタイムでは、今回はNode.js 18を指定、エントリポイントは最初に実行する関数名を指定します。今回はwebhookFunctionとしました。
- これでとりあえず、準備は完了。とりあえずデプロイボタンを押します。但しこのデプロイは緑色のチェックマークがついたら成功なのですが、かなり時間が掛かります。
- この時、トリガーURLのリンクをコピーしておきましょう。
図:Clound Functionsの設定画面
図:インスタンス設定の画面
図:ランタイムの指定
requestモジュールを追加
GAS側へGCF側から通知を送るためにGAS側のdoPostで用意したエンドポイントに更新されたよという通知を送るために利用します。GCF側のNode.jsにあるpackage.jsonにモジュールを追記しておきます。これでrequireでrequestモジュールが使えるようになります。
1 2 3 4 5 6 7 |
{ "name": "sample-http", "version": "0.0.1", "dependencies": { "request": "^2.88.0" } } |
図:requestモジュールを追加しておく
Search Consoleで所有権証明
Drive APIからの通知を受けるサイトの所有権証明をしなければなりません。以下の手順で所有権証明を行います。
- Search Consoleにログインする
- すでに使ってる人は、左上のドメイン一覧のドロップダウンをクリックして、プロパティを追加をクリック
- URLプレフィックスに対して、Cloud Functionsで取得しておいたトリガーURLを入れる
- 続行をクリック
- 所有権の確認では、HTMLタグを選んでコピーする
- Cloud Functionsのページに戻って、編集をクリックし、コードの編集画面までゆく
- 以下のようにコードを書き換えて、デプロイする。<html><head>ここにHTMLタグを記述</head></html>の形式で返してあげるようにします。
123exports.webhookFunction = (req, res) => {res.status(200).send('<html><head><meta name="google-site-verification" content="wwwwwwwwwww" /></head></html>');};
res.status(200).sendの中身に5.で取得しておいたHTMLタグをそのまま返すように記述を追加する。デプロイしてからしばらく待つのがポイント。すぐ確認をしてしまうと失敗する。 - Search Consoleの画面に戻って、「確認」をクリックする
- 所有権確認が完了したら、閉じる。
- GCP側に戻って、APIとサービスのOAuth同意画面を開く
- アプリを編集をクリック
- 認証済みドメインにトリガーURLのhttpsと関数名を除いたドメイン名を追加登録しておく(例:us-central1-gasgas-99999.cloudfunctions.net)
図:URL PrefixにトリガーURLを入れる
図:所有権確認が完了しました。
図:GCP側でも認証済みドメインとして追加しておく
ソースコード
GCF側コード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
//requestモジュール const request = require('request'); //GAS側エンドポイント const gaspoint = "ここにデプロイしたexecで終わるURLを入力"; //GCFのエンドポイントとなる関数 exports.webhookFunction = (req, res) => { //更新通知のヘッダを取得する let header = req.get("X-Goog-Resource-State"); if(header == "change" || header == "update"){ //リクエストオプション let options = { url: gaspoint, method: 'POST', body: JSON.stringify(header), } console.log("送ったよ") //GASへリクエスト request(options, function(error, response, body) { if (!error && response.statusCode == 200) { res.status(201).send("成功"); res.end(); }else{ // エラーハンドラー res.status(503).send("エラー"); } }); } }; |
- gaspointにはGAS側でデプロイしたウェブアプリケーションのURLを入力します。
- Drive APIで受け取ったカスタムヘッダの中身を見てGAS側へ通知を送るだけのシンプル仕様
- HTTPリクエストには古いモジュールだけれど、requestモジュールを今回は利用しています。現在はnode-fetchなどを使うと良いでしょう。
- bodyの中身はGAS側ではe.postData.contentで受け取ることが可能です。
GAS側コード
今回は、ドライブ全体の更新履歴を取るパターンです。ファイル単位の場合はgetStartPageTokenが不要で、またDrive APIへのリクエストエンドポイントが異なるので、GASの場合は「Drive.Files.watch」を使うことになります。同じく引数にオプションとファイルIDを指定することになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
//通知先URL let webhookurl = "ここにGCF側トリガーURLを入力"; //初期セットアップ function startSetting() { let ui = SpreadsheetApp.getUi(); //プロパティを取得する let prop = PropertiesService.getScriptProperties(); //現在のシートIDを取得する let sheet = SpreadsheetApp.getActiveSpreadsheet(); let myid = sheet.getId(); prop.setProperty("sheetid", myid); //メッセージを表示 ui.alert("セットアップ完了しました。"); } function doGet(e){ } //GCF側からのリクエストを受け付ける function doPost(e){ //リクエストbodyを取得する let body = e.postData.contents; //ロックを開始 let lock = LockService.getScriptLock() try{ //30秒間のロックを取得する lock.waitLock(30000); //pageTokenを取得する let prop = PropertiesService.getScriptProperties(); const pageToken = prop.getProperty('pageToken'); //pageTokenを使って更新されたファイル一覧を取得する const res = Drive.Changes.list({ pageToken }); //配列を用意 let array = []; for(let i = 0;i<res.items.length;i++){ //レコードを一個取り出す let rec = res.items[i]; //一時配列を構築 let temparr = [ rec.fileId, rec.file.title, rec.file.mimeType, rec.file.modifiedByMeDate, rec.file.lastModifyingUser.emailAddress, rec.file.fileSize ] //arrayにpush array.push(temparr); } //最終行を取得 let ssid = prop.getProperty("sheetid"); let ss = SpreadsheetApp.openById(ssid).getSheetByName("notif"); let endrow = Number(ss.getLastRow()) + 1; //一括書き込み let lastColumn = array[0].length; //カラムの数を取得する let lastRow = array.length; //行の数を取得する ss.getRange(endrow,1,lastRow,lastColumn).setValues(array); //新しいpageTokenを保存する prop.setProperty('pageToken', res.newStartPageToken); }catch(e){ var checkword = "ロックのタイムアウト: 別のプロセスがロックを保持している時間が長すぎました。"; //通常のエラーとロックエラーを区別する if(e.message == checkword){ //ロックエラーの場合 flag = 1; }else{ //ソレ以外のエラーの場合 flag = 0; } }finally{ //ロック解除 lock.releaseLock(); } } //Watchを開始する function resetWatch(){ //プロパティを取得する let prop = PropertiesService.getScriptProperties(); //startPageTokenを取得する let startToken = Drive.Changes.getStartPageToken() let pageToken = JSON.parse(startToken).startPageToken; prop.setProperty('pageToken', pageToken) //24時間のUNIXTIMEを計算する(ms) - 24Hを指定 let oneday = new Date(Date.now() + 60 * 60 * 24 * 1000).getTime(); //リクエスト let resdata = { id: Utilities.getUuid(), type: "webhook", expiration: oneday, //UnixTimeで指定する address: webhookurl } //監視開始 const res = Drive.Changes.watch(resdata); //作成済みChがあった場合は停止する try{ stopWatch() }catch(e){ console.log(e.message) } //各種IDをプロパティに格納しておく prop.setProperty('channleId', resdata.id); prop.setProperty('resourceId', res.resourceId || ''); } //Watchを停止する function stopWatch() { //プロパティを取得する let prop = PropertiesService.getScriptProperties(); //IDを取得する let channelId = prop.getProperty('channleId'); let resourceId = prop.getProperty('resourceId'); if(channelId !== '') { const response = Drive.Channels.stop({ id:channelId, resourceId:resourceId }) console.log(JSON.stringify(response, null, ' ')) } } |
- webhookurlにはGCF側のトリガーURLを入力します。
- startSettingは初回の1回だけ実行してシートのIDを格納しておきます。
- resetWatchにてDrive APIに対して監視のチャンネルを作成します。expirationは有効時間で最大24時間ですが、ミリ秒で指定します。
- idはユニークなIDを指定するので、uuidを生成し割当。
- pageTokenを取得してから投げる理由は、このページトークンによって、次回の通知時には前回取得したもの以降の更新リストを取得できるようにするためです(でないと何度も同じ更新通知がかぶって入ってきてしまう)
- CHが作りっぱなしにならないように、stopWatchで一度削除してから新しいchannelIdとresourceIdを保存し直しています。
- doPostはGCF側からの通知に従って、pageTokenを使って更新されたものだけのリストをスプレッドシートに記述しています。
- 複数リクエストが来る場合を想定してLock Serviceを使って排他制御を入れています。
- シート書き込み終わったら最後に新しいpageTokenを取得してプロパティに格納し直します。
- 今回は用意していませんが、resetWatchを24時間ごとに実行し直すようなスクリプトトリガーを仕掛けておくと、永続的にファイルの更新をウォッチすることが可能になります。
ドライブ全体の場合膨大な内容が来るので、parentなどのディレクトリのIDなどでフィルタして書き込みするであったり、またドライブ全体ではなくファイル単位で指定で特定のファイルだけウォッチすると良いでしょう(ただし、70個程度しかCH設定が出来ないので要注意)
図:無事に更新履歴を取ることが出来るようになった
関連リンク
- Google Drive API Changes:list にはまった話
- Google Drive の変更通知を Google Apps Script で受け取る
- Google Drive APIで隠し制限のsubscriptionRateLimitExceededエラーがでた
- GASでGoogleDriveの変更通知をする
- Is it possible to watch Directory API changes from Google App Maker/Google Apps Script?
- Google Driveの変更通知をGoogle Cloud Functionsで受け取る
- Admin SDK API - プッシュ通知
- Google Drive API - リソース変更の通知
- Google ドライブで変更が発生したら通知を受け取る
- Google Drive API file subscription rate limit (403 subscriptionRateLimitExceeded)
- Google カレンダーの予定に変更が発生したら通知を受け取る(Reboot)
- Google Drive API watch channel is only 24h
- Share Files in Google Drive without Sending Email Notifications