【Node.js, heroku】Googleドライブの操作(データ取得/更新)

## 1.herokuからGoogleDriveを操作する
現在herokuで運用中のTwitter botがあります。
ツイートに使用する画像やCSVファイルのデータは、元々herokuにデプロイしてfsモジュールで読み込んでいましたが、下記の理由からそれらのデータをGoogleDriveから取得するように変更しました。
・画像・CSVファイルを編集する度にデプロイする必要がある
・HerokuのSlug Size(Slugは実行モジュール的な何か)が増大する

### (1)botで行っているGoogleドライブの操作
以下の処理を行っています。
①ファイルIDの取得
②画像データの取得
③SpreadSheetデータの取得
④SpreadSheetデータの更新
⑤Documentデータの取得
⑥Documentデータの削除
⑦Documentデータの更新

### (2)bot本体の仕様
以前作成した下記の星座占いbotの仕様を改善する形で実装しています。
[参考]【Node.js＋heroku】Twitter星座占いbot作成

https://qiita.com/natsume_410/items/895d6a908da98005e1a0

### (3)設計書・プログラム
最新の設計書・プログラム等は下記のGoogleDriveに格納しています。
(過去の画像データ等が残ってしまっているため、GitHubは非公開設定です)

https://drive.google.com/drive/folders/1W8bNSsk9IMrxx1gzwPH7_aAfSw5K-I8w

## 2.設計
詳しくは設計書参照なのですが、簡単に説明します。
基本的にファイルIDを取得してからなんやかんやします。
### (1)画像データを利用する場合
①画像データのファイルIDの取得
②ファイルから画像データを取得
③画像データをツイートに貼付してツイート送信

### (2)SpreadSheetデータを利用する場合
①SpreadSheetデータのファイルIDの取得
②ファイルから文字データを取得
③文字データよりツイート内容を編集してツイート送信

### (3)SpreadSheetデータを更新する場合
①SpreadSheetデータのファイルIDの取得
②更新データの編集
③SpreadSheetデータの更新

### (4)Documentデータを更新する場合(削除→更新)
①DocumentデータのファイルIDの取得
②Documentデータの最終文字位置を取得
③Documentデータの削除(1桁目～②で取得した最終文字位置まで)
④更新データの編集
⑤Documentデータの更新

## 3.実装
### (1)GoogleCloudPlatformのAPI呼出し
詳しくはプログラム参照なのですが、APIを呼び出す関数は本体から以下のソースに切り出しています。
画像データはダウンロードするのではなく、バイナリデータを取得(responseTypeにarraybufferを指定)して(場合によっては編集を加えて)ツイートに使用しています。

```js:gcpFunc.js
"use strict";
//----------external function declaration----------
const async = require('async');
const {google} = require('googleapis');
const subs = require('./subFunc');

//----------instance declaration----------
const google_auth = new google.auth.GoogleAuth({
    scopes: ['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/documents'],
});
const docs   = new google.docs({version: 'v1'});
const drive  = new google.drive({version: 'v3'});
const sheets = new google.sheets({version: 'v4'});

//----------GoogleCloudPlatform functions----------
//①ファイルIDの取得
function getFileID(folder_id, search_file_name) {
    return new Promise(async function(resolve, reject) {
        const params = {q: `'${folder_id}' in parents and trashed = false`, auth : google_auth};
        try {
            const res = await drive.files.list(params);
            const files = res.data.files;
            if (files.length) {
                files.map((file, index) => {
                    if (file.name == search_file_name) {
                        resolve(file.id);
                    } else if (files.length == (index + 1)){
                        reject("File not found:" + folder_id + "/" + search_file_name);
                    }
                });
            } else {
                reject("No files found:" + folder_id);
            }
        } catch (error) {
            reject("File ID get error." + subs.editErrMsg(error));
        }
    });
}

//②画像データの取得
function getImageData(file_id) {
    return new Promise(async function(resolve, reject) {
        try {
            const res = await drive.files.get({fileId: file_id, alt: 'media', auth: google_auth}, {responseType: 'arraybuffer'});
            resolve(Buffer.from(res.data));
        } catch (error) {
            reject("Image get error." + subs.editErrMsg(error));
        }
    });
}

//③SpreadSheetデータの取得
function getSheetData(file_id, arg_range, dimension) {
    return new Promise(async function(resolve, reject) {
        try {
            const params = {spreadsheetId: file_id, range: arg_range, majorDimension: dimension, auth: google_auth};
            const res = await sheets.spreadsheets.values.get(params);
            const get_data = res.data.values;
            if (get_data.length) {
                resolve(get_data);
            } else {
                reject("No files found:" + file_id);
            }
        } catch (error) {
            reject("Sheet get error." + subs.editErrMsg(error));
        }
    });
}

//④SpreadSheetデータの更新
function updateSheetData(file_id, arg_range, arg_value) {
    return new Promise(async function(resolve, reject) {
        try {
            const params = {spreadsheetId: file_id, range: arg_range, valueInputOption: "USER_ENTERED", auth: google_auth, 
                            resource: {values: arg_value} };
            await sheets.spreadsheets.values.update(params);
            resolve("Succeeded");
        } catch (error) {
            reject("Sheet update error." + subs.editErrMsg(error));
        }
    });
}

//⑤Documentデータの取得(最終文字位置を取得)
function getDocLastIndex(file_id) {
    return new Promise(async function(resolve, reject) {
        let res_length = '';
        try {
            const res = await docs.documents.get({documentId: file_id, auth: google_auth});
            res_length = res.data.body.content.length;
            resolve(res.data.body.content[res_length - 1].endIndex -1);
        } catch (error) {
            reject("Doc last index get error." + subs.editErrMsg(error));
        }
    });
}

//⑥Documentデータの削除
function deleteDoc(file_id, end_index) {
    return new Promise(async function(resolve, reject) {
        try {
            await docs.documents.batchUpdate({
                documentId: file_id,
                requestBody: {
                    requests: [{deleteContentRange: {'range': {startIndex: 1,endIndex: end_index,}}}]
                },
                auth: google_auth
            });
            resolve("Succeeded");
        } catch (error) {
            reject("Doc delete error." + subs.editErrMsg(error));
        }
    });
}

//⑦Documentデータの更新
function updateDoc(file_id, write_text) {
    return new Promise(async function(resolve, reject) {
        try {
            await docs.documents.batchUpdate({
                documentId: file_id,
                requestBody: {
                    requests: [{insertText: {location: {index: 1}, text: write_text}}]
                },
                auth: google_auth
            });
            resolve("Succeeded");
        } catch (error) {
            reject("Doc update error." + subs.editErrMsg(error));
        }
    });
}
```

### (2)使用するフォルダの共有設定
データ格納先のフォルダは、GCPサービスアカウントからアクセスできるように、GCPサービスアカウントを共有ユーザーに追加しておく必要があります。
①フォルダを選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228630/fc031f19-2f6a-96b8-1c81-9e91a190306f.png)

②「共有」を選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228630/d715399c-c10b-5b2b-85eb-508e6d835b45.png)

③GCPサービスアカウントを追加する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228630/017ce259-685a-e7f1-f678-aa166e18fba4.png)

### (3)herokuへのAPIキー設定
GCPサービスアカウントキーはJSON形式になっているので、管理画面からそのままベタ貼りだと認証できません。
一工夫が必要です。
若干違う手順で設定しましたが、ほぼ下記のサイトを参考に設定しました。

[参考]Heroku で Google Cloud API の認証を通す方法

https://mahata.gitlab.io/post/2020-07-15-heroku-google-auth/

①APIキー設定
APIキーはherokuのコンフィグ管理画面から追加しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228630/7892a39a-320a-bf37-6030-06c1948876fa.png)

・GOOGLE_APPLICATION_CREDENTIALS：/app/google-credentials.json
・GOOGLE_CREDENTIALS：GCPサービスアカウントキー(ベタ貼り)
・DOC_FOLDER_ID：GoogleDriveのドキュメントフォルダのフォルダID
・TABLE_FOLDER_ID：GoogleDriveのテーブルフォルダのフォルダID
・IMAGE_FOLDER_ID：GoogleDriveの画像フォルダのフォルダID

②.profileファイルの作成
下記の内容で作成してデプロイします。

```:.profile
echo ${GOOGLE_CREDENTIALS} > /app/google-credentials.json
```

③即時反映したい場合
.profileファイルが実行されるのは次回のdyno再起動時なので、即時反映したい場合は下記の手順が必要。
heroku run bash
echo ${GOOGLE_CREDENTIALS} > /app/google-credentials.json

## 4.参考サイト
### (1)GoogleCloudPlatform API設定関連
[参考]Google APIを利用するためのサービスアカウントの設定(認証)

https://reffect.co.jp/html/google-api%E3%82%92%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B%E3%81%9F%E3%82%81%E3%81%AE%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E3%82%A2%E3%82%AB%E3%82%A6%E3%83%B3%E3%83%88%E3%81%AE%E8%A8%AD%E5%AE%9A%E8%AA%8D

[参考]APIキー、OAuthクライアントID、サービスアカウントキーの違い:Google APIs

https://messefor.hatenablog.com/entry/2020/10/08/080414

[参考(再掲)]Heroku で Google Cloud API の認証を通す方法

https://mahata.gitlab.io/post/2020-07-15-heroku-google-auth/

### (1)GoogleCloudPlatform APIの利用
#### ①画像データの取得
[参考]Node.jsでGoogle Drive上のファイルをダウンロードする (Google Drive API v3)

https://qiita.com/n0bisuke/items/61e037deb1926df5427c

[参考]Downloading an image from Drive API v3 continuously gives corrupt images. How should I decode the response from the promise?

https://coderedirect.com/questions/453076/downloading-an-image-from-drive-api-v3-continuously-gives-corrupt-images-how-sh

[参考]バイナリデータの送信と受信 - Web API - MDN

https://developer.mozilla.org/ja/docs/Web/API/XMLHttpRequest/Sending_and_Receiving_Binary_Data

#### ②SpreadSheetの操作

[参考]【Node.js + Sheets API v4】Googleスプレッドシートを読み書きする

https://qiita.com/vicugna-pacos/items/f7bb0d97bbaa1371edc8

[参考]Google Sheets APIでセルの値を読み込む方法

https://qiita.com/howdy39/items/5473160c93030c386c2d#majordimension%E3%81%A7%E8%A1%8C%E3%81%A8%E5%88%97%E3%81%AE%E4%B8%BB%E4%BD%93%E3%82%92%E5%88%87%E3%82%8A%E6%9B%BF%E3%81%88%E3%82%8B

#### ③Documentの操作
[参考]Insert, delete, and move text  |  Google Docs API  |  Google Developers

https://developers.google.com/docs/api/how-tos/move-text#python

[参考]Creating a node script that can write to google docs

https://dev.to/mcrowder65/creating-a-nodejs-script-that-can-write-to-google-docs-4hpk