Google Forms（GAS）で大容量ファイルをGCSにアップロード

# はじめに

Google Cloudの生成AI「Gemini」を活用すれば、動画や音声ファイルを入力して、議事録を作成するアプリを作ることができます。
そのためには、まず、Google Cloud Storage(以降GCS)にファイルをアップロードしてから、Vertex AIのGeminiで処理する必要があります。
この記事では、Google Formsを使って、大容量の動画ファイルをGCSにアップロードする方法をご紹介します。

# ファイルサイズ制限を回避するために

Google Apps Script（以降GAS）を使ってGCSにファイルをアップロードしようとしたのですが、GASには[ファイルサイズ50MBまでしか送れないという制限](https://developers.google.com/apps-script/guides/services/quotas?hl=ja)があります。
1時間のオンライン会議動画のファイルサイズは、数百MB程度あるので、この制限にひっかかってしまいます。
そこで、Google Formsの「送信」をトリガーにして、ファイルIDとファイル名の情報のみをGoogle Cloud Functonsのエンドポイントに送って、Cloud Functionsがファイルのアップロード処理を実行する方法を採用しました。

# Google Formsの準備

まず、Google Formsのコンポーネントとして「ファイルのアップロード」を選択します。

![スクリーンショット 2024-08-16 21.54.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/a4ea55ed-9cb9-fe2b-2c7c-83837b769ca1.png)

このフォームでは、アップロードしたファイルは、フォーム作成者の Google ドライブに保存されます。
許可するファイル形式、最大のファイルサイズ（10GBまで）、フォームの合計ファイルサイズ（１TBまで）を指定することが可能です。

![スクリーンショット 2024-08-16 22.04.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/f3e3c7cf-7c26-c829-7602-1fed7a85c60d.png)

このフォームからファイルをアップロードする場合は、PC端末またはGoogleドライブのファイルを選択できます。

![スクリーンショット 2024-08-16 22.10.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/1b1e2917-d8cf-34ad-87ee-783c05ca0661.png)

アップロードしたファイルの保存先のURLがスプレッドシートに登録されます。
続いて、このスプレッドシートの拡張機能から Apps Scriptを作成していきます。

![スクリーンショット 2024-08-16 22.42.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/80827f33-cbd3-2c9a-e872-2214ce85229b.png)


# Google Apps Script(GAS)の作成

ファイルIDとファイル名をGoogle Cloud Functionsに渡すためのGASを作成します。
「Googleドライブ」にあるGoogle Meetの動画ファイルなどは、拡張子がついていないため、ファイル名を渡す際に要注意です。
今回は、mp4形式の動画ファイルに限定してアップロードしたかったため、拡張子がない場合は、mp4をつける処理を記載しています。

``` send-file-data.gs
function sendRequestToEndpoint() {
  Logger.log("処理を開始します...");

  // スプレッドシートから必要な情報を取得
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const lastRow = sheet.getLastRow();
  Logger.log(`スプレッドシートから最終行を取得: ${lastRow}`);
  
  // 最終行のデータを取得
  const fileUrl = sheet.getRange(lastRow, 2).getValue(); // ファイルURL
  Logger.log(`取得したファイルURL: ${fileUrl}`);
    
  // ファイルIDをGoogleドライブのURLから抽出
  const fileId = fileUrl.match(/[-\w]{25,}/)[0]; // ファイルIDを抽出
  Logger.log(`抽出したファイルID: ${fileId}`);
  
  // ファイルIDからファイル名を取得
  let fileName = getFileNameFromFileId(fileId);
  
  if (!fileName) {
    Logger.log("ファイル名の取得に失敗しました。");
    return;
  }
  Logger.log(`取得したファイル名: ${fileName}`);

  // ファイル名に拡張子がない場合は追加
  if (!fileName.toLowerCase().endsWith('.mp4')) {
    fileName += '.mp4';
    Logger.log(`拡張子がなかったため、ファイル名を修正しました: ${fileName}`);
  }

  // Cloud Functionを呼び出してGoogle DriveからGCSへのファイルアップロードを依頼
  Logger.log("Cloud Functionにファイルアップロードを依頼中...");
  const uploadResult = triggerFileUploadToCloudFunction(fileId, fileName);

  if (!uploadResult.success) {
    Logger.log("ファイルアップロードに失敗しました: " + uploadResult.message);
    return;
  }

  Logger.log("ファイルのアップロードが成功しました。");
}

function getFileNameFromFileId(fileId) {
  try {
    // Google Driveのファイルを取得
    const file = DriveApp.getFileById(fileId);
    
    // ファイル名を取得
    let fileName = file.getName();
    
    // ファイル名に.mp4拡張子がない場合は追加
    if (!fileName.toLowerCase().endsWith('.mp4')) {
      fileName += '.mp4';
    }
    
    Logger.log(`ファイルID ${fileId} に対応するファイル名（.mp4確認後）: ${fileName}`);
    return fileName;
  } catch (e) {
    // エラー処理
    Logger.log("ファイル名の取得中にエラーが発生しました: " + e.toString());
    return null;
  }
}

function triggerFileUploadToCloudFunction(fileId, fileName) {
  try {
    const cloudFunctionUrl = "<Cloud FunctionのエンドポイントのURL>"
    const scriptProperties = PropertiesService.getScriptProperties();
    const apiKey = scriptProperties.getProperty('API_KEY');
    
    // ファイル名に.mp4拡張子がない場合は追加（再確認）
    if (!fileName.toLowerCase().endsWith('.mp4')) {
      fileName += '.mp4';
    }

    const payload = {
      fileId: fileId,
      fileName: fileName
    };

    const options = {
      method: 'post',
      contentType: 'application/json',
      headers: {
        'x-api-key': apiKey // スクリプトプロパティから取得したAPIキーを使用
      },
      payload: JSON.stringify(payload)
    };

    const response = UrlFetchApp.fetch(cloudFunctionUrl, options);
    
    if (response.getResponseCode() === 200) {
      return { success: true };
    } else {
      return { success: false, message: response.getContentText() };
    }
  } catch (e) {
    return { success: false, message: e.toString() };
  }
}
```
:::note info
Cloud FunctionsのエンドポイントのURLの入力
このあとの作業でCloud Functionsのデプロイをしますので、発行されるエンドポイントのURLを入力してください。
:::


:::note info
APIキーの作成
推測されにくいランダムな文字列をAPI_KEYとして、スクリプトプロパティに保管します。
スクリプトプロパティは、サイドメニューの「プロジェクトの設定」を開いて、一番下にあります。
:::

![スクリーンショット 2024-08-16 23.24.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/13b83a91-f211-9e57-882f-0b7847ce0749.png)

:::note info
トリガーの設定
サイドメニューの「トリガー」を開いて、新しいトリガーを作成します。
今回は、フォームの送信時にGASを起動させたいので、「フォーム送信時」を選択します。
:::

![スクリーンショット 2024-08-16 23.29.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/348a57cf-037a-3cdd-c443-28252667f577.png)

右下の保存ボタンを押すと、認証を求める画面に遷移するので、画面の指示に従って、認証して下さい。
以下のようなトリガーが作成されます。

![スクリーンショット 2024-08-17 6.55.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/8d98eb44-2690-b002-798f-49b9dddb963f.png)

# Cloud Functionsのスクリプト作成

続いて、今回はPythonで、Cloud Functionsのスクリプトを作成します。GASからファイルIDとファイル名が渡されたら、実際にGoogleドライブのファイルをダウンロードして、GCSへのアップロードを実行する処理になります。セキュリティを確保するため、APIキーを使って認証しています。


``` main.py
import functions_framework
from google.cloud import storage, secretmanager
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload
import io
import os
import json

# シークレットマネージャーからシークレットを取得する関数
def get_secret(secret_id):
    client = secretmanager.SecretManagerServiceClient()
    name = f"projects/{os.getenv('GCP_PROJECT')}/secrets/{secret_id}/versions/latest"
    response = client.access_secret_version(request={"name": name})
    return response.payload.data.decode("UTF-8")

# 環境変数からバケット名を取得
BUCKET_NAME = os.getenv('BUCKET_NAME')

# Cloud Functionsのエントリーポイント
@functions_framework.http
def upload_file_from_drive_to_gcs(request):
    # APIキーの検証
    api_key = request.headers.get('x-api-key')
    if api_key != get_secret('API_KEY'):
        return ("認証エラー", 401)

    # サービスアカウントの認証情報を取得
    service_account_key = get_secret("GOOGLE_APPLICATION_CREDENTIALS")
    credentials = service_account.Credentials.from_service_account_info(json.loads(service_account_key))

    # リクエストからファイルIDとファイル名を取得
    data = request.get_json()
    file_id = data.get('fileId')
    file_name = data.get('fileName')
    if not file_id or not file_name:
        return ("ファイルIDとファイル名が必要です", 400)

    # Google Drive APIクライアントの初期化
    drive_service = build('drive', 'v3', credentials=credentials)

    # Google Driveからファイルをダウンロード
    request = drive_service.files().get_media(fileId=file_id)
    fh = io.BytesIO()
    downloader = MediaIoBaseDownload(fh, request)
    done = False
    while not done:
        status, done = downloader.next_chunk()
        print(f"ダウンロード進捗: {int(status.progress() * 100)}%")

    # ダウンロードしたファイルのストリームを先頭に戻す
    fh.seek(0)

    # Google Cloud Storageクライアントの初期化
    storage_client = storage.Client(credentials=credentials)
    bucket = storage_client.bucket(BUCKET_NAME)
    blob = bucket.blob(file_name)

    # ファイルをGoogle Cloud Storageにアップロード
    try:
        blob.upload_from_file(fh, content_type='video/mp4')
        return (f"ファイル {file_name} を {BUCKET_NAME} に正常にアップロードしました。", 200)
    except Exception as e:
        print(f"GCSへのファイルアップロード中にエラーが発生しました: {e}")
        return ("GCSへのファイルアップロード中にエラーが発生しました", 500)
```

Cloud Functionsの[HTTPリクエストの最大サイズは32MB](https://cloud.google.com/functions/quotas?hl=ja)ですが、上記の方法ですと、HTTPリクエストで渡されるのは、ファイルIDとファイル名のみですので、問題ありません。この処理では、アップロードしたファイルをメモリに保持しているので、メモリの設定が重要です。[Cloud Functionsのメモリは128MB〜32GBの範囲で設定](https://cloud.google.com/functions/quotas?hl=ja)できます。

続いて、requirements.txtに必要なライブラリを記載します。

``` requirements.txt
functions-framework==3.*
google-cloud-storage
google-cloud-secret-manager
google-auth
google-api-python-client
```

GCS、シークレットマネージャ、Google APIの認証に関連するライブラリを追加しています。

# Cloud Functions のデプロイ

作成したスクリプトを[Cloud Functions](https://cloud.google.com/functions/docs/concepts/overview?hl=ja)にデプロイします。
GCPのCloud functionsのコンソールから、「ファンクションを作成」を押して、以下の項目を設定します。
- 関数名 `upload_file_from_drive_to_gcs`
- リージョン `asia-northeast1`
- トリガーのタイプ `HTTPS`
- 割り当てられるメモリ `2GiB`　 ※アップロードするファイルのサイズを考慮して指定
- ランタイムサービスアカウント
    - あらかじめ作成しておいたサービスアカウントを指定
- ランタイム環境変数
    - `BUCKET_NAME` バケット名を指定
    - `GCP_PROJECT` プロジェクト名を指定
- 接続　内向き（上り）　すべてのトラフィックを許可する
- ランタイム `python3.12`
- エントリポイント `upload_file_from_drive_to_gcs`
- ソース `main.py`と`requirements.txt`を編集して、上記のコードを入力

# サービスアカウントの権限設定
GCPの[サービスアカウント](https://cloud.google.com/iam/docs/service-account-overview?hl=ja)を作成して、以下の権限を追加します。
- Secret Manager のシークレット アクセサー
- ストレージ管理者

続いて、[サービスアカウントキー](https://cloud.google.com/iam/docs/creating-managing-service-account-keys?hl=ja#iam-service-account-keys-create-console)を発行して、サービスアカウントキーとなるjsonファイルをダウンロードします。

### 参考記事

https://qiita.com/RyutoYoda/items/e42a575d3d46e160a4d7

# シークレットの作成
シークレットマネージャから以下の[シークレット](https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets?hl=ja#secretmanager-create-secret-console)をそれぞれ作成します。これらのシークレットは、上記のCloud Functionsの関数内で読み取っています。

- `GOOGLE_APPLICATION_CREDENTIALS`　サービスアカウントキーのJSONファイルをアップロード
- `API_KEY` GASのスクリプトプロパティに保管したAPIキーを「シークレットの値」として入力

![スクリーンショット 2024-08-17 10.23.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/794c71b2-b5c1-53c1-870d-9966579c2d74.png)


# Google Drive APIの設定

GCPのコンソールで[Google Drive API](https://developers.google.com/drive/api/guides/about-sdk?hl=ja)を有効化します。

![スクリーンショット 2024-08-17 10.13.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/d240be18-99b5-2433-5526-1266b473bfdb.png)

Google Drive側での共有

次にGoogle Driveのファイルをダンロードできる権限をサービスアカウントに付与します。

- GCPのコンソールのサイドメニューで「IAMと管理」=>「サービスアカウント」と進むと、作成したサービスアカウントのメールアドレスを確認して、コピーします
- 最初に作成したGoogleフォームの「ファイルをアップロード」欄の右下の「フォルダを表示」から、ファイルが保存されるフォルダを開くことができます

![スクリーンショット 2024-08-17 10.50.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/76a79740-1333-db3e-06de-d15f0c09a97c.png)

- フォルダを開いて、このフォルダの共有設定から、「編集者」権限をサービスアカウントのメールに付与します

![スクリーンショット 2024-08-17 10.57.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3861709/5d817027-bbed-be2f-0551-d7df10e43695.png)

この画面の共有設定から、「サービスアカウント」のメールアドレスを入力して、「編集者」の権限をつけることができます。

# おわりに

以上の方法でGoogleドライブやPCのローカルフォルダから、大容量ファイルをGCSにアップロードすることができるはずです。
私の場合、別途、動画ファイルから、Geminiで議事録を作成する処理をCloud Runにデプロイしています。GAS側のコードにCloud Runのエンドポイントを追加して、emailを送信する処理を追加すれば、ユーザーがGoogleフォームからファイルをアップロードした数分後に議事録のメールが届きます。
また、Googleフォームを工夫すれば、Geminiのモデル選択、パラメーター設定、プロンプト入力もできる簡易的なインターフェイスとして、活用できます。
最後までお読みいただき、ありがとうございました。