使用 Google Apps Script 實現網頁錄音、多檔案與文字上傳至 Google Drive

Aldo YangAldo Yang
5 min read

目標

使用者經常需要在網頁上進行錄音、上傳多個檔案(例如圖片、文件),或者上傳一段文字,並希望將這些資料直接儲存到 Google Drive。傳統方法可能需要複雜的後端設定或第三方服務。本筆記旨在提供一個基於 Google Apps Script 的解決方案,讓使用者無需複雜設定,即可透過網頁介面直接完成錄音、檔案和文字上傳,並將資料儲存至指定的 Google Drive 資料夾。

核心步驟

  1. 建立 Google Apps Script 專案: 建立一個新的 Google Apps Script 專案,用於處理網頁前端和 Google Drive API 之間的互動。
  2. 部署網頁應用程式: 編寫 HTML、CSS 和 JavaScript 程式碼,建立使用者介面,包含錄音、檔案拖曳/選擇、文字輸入等功能,並將其部署為網頁應用程式。
  3. 取得使用者授權: 首次使用時,需要取得使用者授權,允許 Apps Script 存取其 Google Drive。
  4. 前端錄音與檔案處理: 使用 JavaScript 的 MediaRecorder API 進行錄音,並將錄音資料轉換為 Blob 格式。使用 FileReader API 讀取使用者選擇的檔案,並將其轉換為 Data URL 格式。
  5. 前端文字處理: 取得使用者在 textarea 中輸入的文字內容。
  6. 後端資料處理與上傳: 將前端取得的錄音 Data URL、檔案 Data URL 和文字內容傳送至 Google Apps Script 後端。後端將 Data URL 解碼為二進位資料,並使用 Google Drive API 將其建立為檔案,儲存至指定的資料夾。 建立以時間戳記為名稱的子資料夾,以組織每次上傳。
  7. 顯示上傳狀態: 前端即時顯示上傳狀態和訊息,讓使用者了解目前進度。

步驟實現

1. 建立 Google Apps Script 專案

  • 前往 Google Drive (drive.google.com)。
  • 點擊「新增」>「更多」>「Google Apps Script」。
  • 將專案重新命名 (例如:"WebRecorderUploader")。

2. 部署網頁應用程式

  • 在 Apps Script 編輯器中,建立兩個檔案:Code.gs (後端程式碼) 和 index.html (前端程式碼)。

    Code.gs:

   function doGet(e) {
     return HtmlService.createHtmlOutputFromFile('index');
   }

   // 建立以時間戳記為名稱的子資料夾
   function createSessionFolder() {
     var parentFolderId = "YOUR_PARENT_FOLDER_ID"; // 替換成你的 Google Drive 父資料夾 ID
     var parentFolder = DriveApp.getFolderById(parentFolderId);
     var timestamp = new Date().toISOString();
     var sessionFolder = parentFolder.createFolder(timestamp);
     return sessionFolder.getId();
   }

   function uploadAudio(dataUrl, folderId) {
     try {
       // dataUrl 格式類似 data:audio/webm;base64,xxxx...
       var parts = dataUrl.split(',');
       if (parts.length !== 2) {
         throw new Error('Invalid data URL format.');
       }
       // 解析 MIME 類型,例如 audio/webm
       var contentType = parts[0].match(/:(.*?);/)[1];
       var decoded = Utilities.base64Decode(parts[1]);
       // 產生 Blob 物件,並以 timestamp 當作檔名
       var blob = Utilities.newBlob(decoded, contentType, new Date().toISOString() + ".webm");

       var folder = DriveApp.getFolderById(folderId);
       var file = folder.createFile(blob);
       return 'Audio File uploaded successfully: ' + file.getUrl();
     } catch (error) {
       return 'Error uploading audio file: ' + error.toString();
     }
   }

   function uploadFile(dataUrl, filename, folderId) {
     try {
       var parts = dataUrl.split(',');
        if (parts.length !== 2) {
         throw new Error('Invalid data URL format.');
       }
       var contentType = parts[0].match(/:(.*?);/)[1];
       var decoded = Utilities.base64Decode(parts[1]);
       var blob = Utilities.newBlob(decoded, contentType, filename);

       var folder = DriveApp.getFolderById(folderId);
       var file = folder.createFile(blob);
       return 'File uploaded successfully: ' + file.getUrl();
     } catch (error) {
       return 'Error uploading file: ' + error.toString();
     }
   }

    function uploadText(text, folderId) {
     try {
        var folder = DriveApp.getFolderById(folderId);
        var fileName =  new Date().toISOString() + ".txt"
        var file = folder.createFile(fileName, text, MimeType.PLAIN_TEXT);
        return 'Text uploaded successfully: ' + file.getUrl();
     }
     catch (error){
        return 'Error uploading text file: ' + error.toString();
     }
   }

index.html:

   <!DOCTYPE html>
   <html>
   <head>
     <title>Record, Upload Files & Text to Google Drive</title>
     <style>
       /* 訊息面板樣式 */
       #log {
         position: fixed;
         bottom: 0;
         left: 0;
         width: 100%;
         background: #f0f0f0;
         max-height: 150px;
         overflow-y: auto;
         padding: 10px;
         font-size: 14px;
         border-top: 1px solid #ccc;
       }
       /* 按鈕樣式 */
       button {
         padding: 10px 20px;
         margin: 5px;
         font-size: 16px;
       }
       /* Drag & drop 區域樣式 */
       #drop-area {
         border: 2px dashed #ccc;
         padding: 20px;
         text-align: center;
         margin-top: 20px;
       }
       /* Highlight state for drag & drop */
       #drop-area.highlight {
         border-color: #333;
         background-color: #f9f9f9;
       }
       /* Text upload 區域樣式 */
       #text-upload {
         margin-top: 20px;
       }
       textarea {
         width: 100%;
         height: 100px;
         font-size: 16px;
       }
     </style>
   </head>
   <body>
     <h1>Record, Upload Files & Text to Google Drive</h1>

     <!-- Audio recording buttons -->
     <button id="start">Start recording</button>
     <button id="stop" disabled>Stop recording</button>

     <!-- File upload area -->
     <div id="drop-area">
       <p>Drag and drop files here</p>
       <p>or</p>
       <input type="file" id="file-input" multiple style="display: none;">
       <button id="select-files">Select Files</button>
     </div>

      <!-- Text upload area -->
     <div id="text-upload">
       <textarea id="text-input" placeholder="Enter text here..."></textarea>
       <button id="upload-text">Upload Text</button>
     </div>

     <!-- 狀態訊息面板 -->
     <div id="log">
       <p>Status Message: </p>
     </div>

     <script>
       let folderId;

       // 建立上傳資料夾
       google.script.run.withSuccessHandler(
         id => {
           folderId = id;
           logMessage('Session folder created with ID: ' + folderId);
         }
       ).createSessionFolder();

       let mediaRecorder;
       let recordedChunks = [];

       // 在頁面上顯示狀態訊息
       function logMessage(message) {
         const logDiv = document.getElementById('log');
         const p = document.createElement('p');
         p.textContent = message;
         logDiv.appendChild(p);
         logDiv.scrollTop = logDiv.scrollHeight;
       }

       // Audio recording logic
       if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
         navigator.mediaDevices.getUserMedia({ audio: true })
         .then(stream => {
           mediaRecorder = new MediaRecorder(stream);

           mediaRecorder.ondataavailable = function(e) {
             if (e.data && e.data.size > 0) {
               recordedChunks.push(e.data);
             }
           };

           mediaRecorder.onstop = function() {
             let blob = new Blob(recordedChunks, { type: 'audio/webm' });
             recordedChunks = [];
             logMessage('Recording stopped. Preparing to upload...');
             uploadAudio(blob);
           };

           document.getElementById('start').onclick = function() {
             mediaRecorder.start();
             logMessage('Recording started...');
             this.disabled = true;
             document.getElementById('stop').disabled = false;
           };

           document.getElementById('stop').onclick = function() {
             mediaRecorder.stop();
             logMessage('Stopping recording...');
             this.disabled = true;
             document.getElementById('start').disabled = false;
           };
         })
         .catch(err => {
           logMessage('Error accessing microphone: ' + err);
         });
       } else {
         logMessage('getUserMedia not supported.');
       }

       // 將錄音 Blob 轉成 Data URL 並呼叫伺服器端上傳
       function uploadAudio(blob) {
         let reader = new FileReader();
         reader.onloadend = function() {
           let dataUrl = reader.result;
           logMessage('Uploading audio...');
           google.script.run.withSuccessHandler(logMessage).uploadAudio(dataUrl, folderId);
         };
         reader.readAsDataURL(blob);
       }

       // File upload logic
       document.getElementById('select-files').addEventListener('click', () => {
         document.getElementById('file-input').click();
       });

       const dropArea = document.getElementById('drop-area');

       ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
         dropArea.addEventListener(eventName, preventDefaults, false);
       });

       function preventDefaults(e) {
         e.preventDefault();
         e.stopPropagation();
       }

       ['dragenter', 'dragover'].forEach(eventName => {
         dropArea.addEventListener(eventName, highlight, false);
       });

       ['dragleave', 'drop'].forEach(eventName => {
         dropArea.addEventListener(eventName, unhighlight, false);
       });

       function highlight(e) {
         dropArea.classList.add('highlight');
       }

       function unhighlight(e) {
         dropArea.classList.remove('highlight');
       }

       dropArea.addEventListener('drop', handleDrop, false);

       function handleDrop(e) {
         let dt = e.dataTransfer;
         let files = dt.files;
         handleFiles(files);
       }

       document.getElementById('file-input').addEventListener('change', function(e) {
         handleFiles(this.files);
       });

       function handleFiles(files) {
         ([...files]).forEach(uploadFile);
       }

       function uploadFile(file) {
         let reader = new FileReader();
         reader.onloadend = function() {
           let dataUrl = reader.result;
           logMessage('Uploading file: ' + file.name);
           google.script.run.withSuccessHandler(logMessage).uploadFile(dataUrl, file.name, folderId);
         }
         reader.readAsDataURL(file);
       }

        // Text upload logic
        document.getElementById('upload-text').addEventListener('click', () => {
            const text = document.getElementById('text-input').value;
            if (text.trim() !== '') {
                logMessage('Uploading text...');
                google.script.run.withSuccessHandler(logMessage).uploadText(text, folderId);
            } else {
                logMessage('Please enter some text to upload.');
            }
        });
     </script>
   </body>
   </html>
  • 重要:Code.gs 中的 "YOUR_PARENT_FOLDER_ID" 替換成你 Google Drive 中想要存放上傳檔案的父資料夾 ID。 (取得資料夾 ID 的方法:開啟該資料夾,網址列中 folders/ 後面的那一串字元即為資料夾 ID。)

  • 點擊「部署」>「新增部署作業」。

  • 在「設定」中,選擇「網頁應用程式」。
  • 「網頁應用程式」設定:
    • 「執行身分」:選擇「我」。
    • 「誰可以存取」:選擇「任何人」。
  • 點擊「部署」。
  • 複製「網頁應用程式」的網址,這就是你的網頁應用程式的網址。

3. 取得使用者授權

  • 首次在瀏覽器中開啟網頁應用程式網址時,系統會要求你授權該應用程式存取你的 Google Drive。請按照指示完成授權。

4. 前端錄音與檔案處理 (已在 index.html 程式碼中)

  • 錄音: 使用 navigator.mediaDevices.getUserMedia 取得麥克風權限,並建立 MediaRecorder 物件。點擊「Start recording」按鈕開始錄音,點擊「Stop recording」按鈕停止錄音。錄音資料會以 audio/webm 格式的 Blob 儲存在 recordedChunks 陣列中。停止錄音後,會將 recordedChunks 中的 Blob 合併成一個 Blob,並呼叫 uploadAudio 函式。
  • 檔案上傳: 使用者可以透過拖曳檔案到指定區域,或點擊「Select Files」按鈕選擇檔案。handleFiles 函式會處理選擇的檔案,並對每個檔案呼叫 uploadFile 函式。
  • Data URL 轉換: uploadAudiouploadFile 函式中使用 FileReaderreadAsDataURL 方法將 Blob 或 File 物件轉換為 Data URL。

5. 前端文字處理 (已在 index.html 程式碼中)

  • 使用者在<textarea>中輸入文字。
  • 點擊 "Upload Text" 按鈕後,會取得textarea的值,並傳送至uploadText

6. 後端資料處理與上傳 (已在 Code.gs 程式碼中)

  • createSessionFolder 函式: 建立一個以目前時間戳記為名稱的子資料夾,用於存放本次上傳的所有檔案。
  • uploadAudio 函式: 接收前端傳來的 Data URL,將其解碼為二進位資料,建立 audio/webm 格式的 Blob 物件,並使用 DriveApp.getFolderById(folderId).createFile(blob) 將其上傳到 Google Drive 指定的資料夾。
  • uploadFile 函式: 接收前端傳來的 Data URL 和檔名,將其解碼為二進位資料,建立 Blob 物件,並使用 DriveApp.getFolderById(folderId).createFile(blob) 將其上傳到 Google Drive 指定的資料夾。
  • uploadText 函式: 接收前端傳來的文字內容和資料夾 ID,並使用 DriveApp.getFolderById(folderId).createFile(filename, text, MimeType.PLAIN_TEXT) 將其上傳到 Google Drive 指定的資料夾,檔案類型為純文字。

7. 顯示上傳狀態 (已在 index.html 程式碼中)

  • logMessage 函式用於在網頁下方的訊息面板中顯示狀態訊息。
  • 前端和後端的各個步驟都會呼叫 logMessage 函式,顯示目前的操作狀態 (例如:「Recording started...」、「Uploading audio...」、「Audio File uploaded successfully: ...」)。
  • 使用 google.script.run.withSuccessHandler(logMessage) 將後端函式的回傳值 (上傳成功訊息或錯誤訊息) 顯示在前端。

結論

本筆記提供了一個完整的解決方案,讓使用者可以透過簡單的網頁介面,直接進行錄音、上傳多個檔案和文字,並將資料儲存到 Google Drive。此方案基於 Google Apps Script,無需複雜的後端設定,易於部署和使用。

優點:

  • 簡單易用: 使用者只需透過網頁介面即可操作,無需安裝任何軟體或插件。
  • 無需後端設定: 所有程式碼都在 Google Apps Script 中執行,無需架設伺服器。
  • 直接整合 Google Drive: 資料直接儲存到 Google Drive,方便管理和分享。
  • 支援多檔案上傳: 可以一次上傳多個檔案,提高效率。
0
Subscribe to my newsletter

Read articles from Aldo Yang directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Aldo Yang
Aldo Yang