6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【GAS】Gemini in Meetの議事録を自動タスク化しようとしたら、7つの沼にハマった話(最終コードあり)

Posted at

筆者の紹介

こんにちは、わがです。Google Workspaceの自動化が好きで、日々GASを書いています。

やりたかったこと

Gemini in Google Meetが、会議の文字起こしと「推奨される次のステップ」をGoogleドキュメントとして自動生成してくれるようになりました。

しかし、生成されたドキュメントのURLがメールで通知されるだけで、**タスク管理ツールへの登録は結局「手動コピペ」**になっていました。

「この最後のひと手間」を自動化すべく、私はGoogle Apps Script (GAS) を使った自動化に乗り出しました。

当初の甘い計画:

  1. トリガーでGeminiからのメールを検知 (is:unread from:gemini-notes@google.com)
  2. メール本文からドキュメントURLを正規表現で引っこ抜く。
  3. DocumentApp.openByUrl() でドキュメントを開く。
  4. ドキュメントのテキストをパースして「推奨される次のステップ」以下のタスクを抽出する。
  5. SpreadsheetApp.appendRow() で管理用スプレッドシートにタスクを追記する。

簡単そうに見えました。しかし、これが合計7つの「沼」を巡る長い戦いの始まりでした。

結論:最終的に動いた全コード

結論から先に。長いデバッグの末に完成した、Geminiが生成する特殊なファイルに対応した全コードがこちらです。

SPREADSHEET_IDALERT_EMAIL(通知先)だけ設定すれば動きます。

【注意】
このコードは 「Drive API」 サービスを有効にする必要があります。
GASエディタの「サービス」 +ボタン > 「Drive API」 > 「追加」 を必ず実行してください。

// ▼▼▼ ユーザー設定項目 ▼▼▼

// タスクを書き込むスプレッドシートのID
const SPREADSHEET_ID = 'ここにスプレッドシートID';

// Geminiからの通知メールを特定するための検索条件
const GMAIL_SEARCH_QUERY = 'is:unread from:gemini-notes@google.com';

// エラー通知を送信するメールアドレス
const ALERT_EMAIL = 'your-email@example.com'; // ここにご自身のメールアドレスを設定

// ▲▲▲ ユーザー設定ここまで ▲▲▲


/**
 * メインの処理を実行する関数 (トリガーで呼び出す)
 */
function main() {
  console.log('-------------------- SCRIPT START --------------------');
  
  try {
    const threads = GmailApp.search(GMAIL_SEARCH_QUERY);
    
    if (threads.length === 0) {
      console.log('[INFO] 処理対象の未読メールは見つかりませんでした。');
      return;
    }

    console.log(`[INFO] ${threads.length}件のメールスレッドが見つかりました。処理を開始します。`);

    for (let i = 0; i < threads.length; i++) {
      const thread = threads[i];
      const message = thread.getMessages()[0];
      const messageId = message.getId();
      console.log(`\n[INFO] ${i + 1}/${threads.length}件目のスレッドを処理中... (Message ID: ${messageId})`);
      
      try {
        const body = message.getBody(); // HTML本文を取得
        const docUrl = extractGoogleDocUrl(body);
        
        if (docUrl) {
          console.log(`[DEBUG] ドキュメントURLの抽出に成功しました: ${docUrl}`);
          
          const tasks = extractActionItems(docUrl); // ★核心の抽出処理
          
          if (tasks.length > 0) {
            console.log(`[DEBUG] ${tasks.length}件のタスクが見つかりました。`);
            registerTasksToSheet(tasks, docUrl);
          } else {
            console.log('[INFO] ドキュメント内にアクションアイテムが見つかりませんでした。');
          }
        } else {
          console.log('[WARN] このメールにはGoogleドキュメントのURLが見つかりませんでした。');
        }
        
        thread.markRead();
        console.log('[INFO] このメールスレッドを既読にしました。');

      } catch (e) {
        console.error(`[ERROR] メッセージ(ID: ${messageId})の処理中にエラーが発生しました: ${e.toString()}`);
        sendErrorAlert(`メール処理中のエラー (ID: ${messageId})`, e.toString());
      }
    }
  } catch (e) {
    console.error(`[FATAL] スクリプト全体で致命的なエラーが発生しました: ${e.toString()}`);
    sendErrorAlert('GASスクリプトの致命的なエラー', e.toString());
  }
  
  console.log('-------------------- SCRIPT END ----------------------');
}

/**
 * メール本文(HTMLを含む)からGoogleドキュメントのURLを抽出する
 */
function extractGoogleDocUrl(text) {
  const regex = /https:\/\/docs\.google\.com\/document\/d\/[a-zA-Z0-9_-]+/;
  const match = text.match(regex);
  return match ? match[0] : null;
}

/**
 * Googleドキュメントからアクションアイテムを抽出する
 * (実体がPDFであるため、OCRでGoogleドキュメントに変換して読み取る最終戦略版)
 */
function extractActionItems(url) {
  console.log('[DEBUG] extractActionItems: 関数を開始します。');
  let docId = null;
  let ocrDocId = null; // OCRで作成した一時ドキュメントのID

  try {
    // URLからIDを抽出
    if (url.includes('/d/')) {
      docId = url.split('/d/')[1].split('/')[0];
    } else {
      docId = url; // IDが直接渡された場合
    }
    console.log(`[DEBUG] extractActionItems: ドキュメントID「${docId}」を取得しました。`);

    console.log(`[INFO] extractActionItems: ファイルの実体がPDFのようです。OCR変換戦略を開始します。`);
    
    // DriveAppでファイルを取得
    const file = DriveApp.getFileById(docId);
    console.log(`[DEBUG] extractActionItems: DriveAppでファイル「${file.getName()}」を取得しました。`);

    // ファイルのBlobを取得 (これがPDFとして扱われる)
    const pdfBlob = file.getBlob();
    
    // Drive API (v3) を使って、PDF BlobをOCRでGoogleドキュメントに変換
    const resource = {
      title: `[Temp OCR] ${file.getName()}`,
      mimeType: MimeType.GOOGLE_DOCS
    };
    const options = {
      ocr: true, // OCRを有効にする
      ocrLanguage: 'ja' // 言語を日本語に指定
    };
    
    console.log(`[DEBUG] extractActionItems: Drive API (v3) を使い、OCR変換を開始します...`);
    // v3 (create) メソッドを使用
    const ocrDocFile = Drive.Files.create(resource, pdfBlob, options);
    ocrDocId = ocrDocFile.id;
    console.log(`[INFO] extractActionItems: OCR変換が完了しました。新しいドキュメントID: ${ocrDocId}`);

    // --- OCRで作成されたドキュメントのパース処理 ---
    const doc = DocumentApp.openById(ocrDocId);
    const body = doc.getBody();
    const docText = body.getText();
    
    const actionItems = [];
    const sectionTitle = "推奨される次のステップ";
    
    const lines = docText.split('\n');
    let inActionSection = false;
    
    for (const line of lines) {
      const trimmedLine = line.trim();
      
      if (trimmedLine === sectionTitle) {
        inActionSection = true;
        console.log(`[DEBUG] extractActionItems: "${sectionTitle}" セクションを発見しました。`);
        continue;
      }
      
      if (inActionSection && 
          (trimmedLine === "議事録" || trimmedLine === "Transcript" || trimmedLine === "概要" || trimmedLine.startsWith("Gemini"))) {
         console.log(`[DEBUG] extractActionItems: 次のセクション「${trimmedLine}」が見つかったため、タスクの抽出を終了します。`);
         break;
      }
      
      // セクション内で空行でなければ、タスクとみなす (記号なしに対応)
      if (inActionSection && trimmedLine.length > 0) {
         const taskText = cleanListItem(trimmedLine); 
         if (taskText) {
            actionItems.push(taskText);
            console.log(`[DEBUG] extractActionItems: 抽出したタスク -> "${taskText}"`);
         }
      }
    }
    
    if (!inActionSection) {
      console.log(`[WARN] extractActionItems: "${sectionTitle}" の見出しがドキュメント内に見つかりませんでした。`);
    }

    console.log(`[DEBUG] extractActionItems: 関数の処理を終了します。抽出したタスクは計${actionItems.length}件です。`);
    return actionItems;
    
  } catch (e) {
    console.error(`[ERROR] extractActionItems: ドキュメントの処理中に致命的なエラーが発生しました。DocID: "${docId}", OcrDocID: "${ocrDocId}", エラー詳細: ${e.toString()}`);
    throw new Error(`extractActionItemsでエラー: ${e.toString()}`); // エラーをmainに伝播
    
  } finally {
    // エラーがあってもなくても、一時ファイルは削除する
    if (ocrDocId) {
      try {
        DriveApp.getFileById(ocrDocId).setTrashed(true);
        console.log(`[INFO] extractActionItems: 一時OCRファイル(ID: ${ocrDocId})をゴミ箱に移動しました。`);
      } catch (e) {
        console.error(`[ERROR] extractActionItems: 一時OCRファイル(ID: ${ocrDocId})の削除に失敗しました: ${e.toString()}`);
      }
    }
  }
}

/**
 * 抽出したタスクをスプレッドシートに書き込む
 */
function registerTasksToSheet(tasks, docUrl) {
  try {
    const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getActiveSheet();
    const today = new Date();
    
    for (const task of tasks) {
      sheet.appendRow([task, today, docUrl]);
      console.log(`[INFO] registerTasksToSheet: タスク「${task}」をスプレッドシートに追加しました。`);
    }
  } catch (e) {
    console.error(`[ERROR] registerTasksToSheet: スプレッドシートへの書き込み中にエラーが発生しました: ${e.toString()}`);
    throw new Error(`registerTasksToSheetでエラー: ${e.toString()}`); // エラーをmainに伝播
  }
}

/**
 * 補助関数:リスト項目の行から先頭の記号を削除して整形
 */
function cleanListItem(text) {
   let cleaned = text.trim();
   if (cleaned.startsWith('')) {
      cleaned = cleaned.substring(1).trim();
   } else if (cleaned.startsWith('')) {
      cleaned = cleaned.substring(1).trim();
   } else if (cleaned.startsWith('*')) {
      cleaned = cleaned.substring(1).trim();
   } else if (cleaned.startsWith('-')) {
      cleaned = cleaned.substring(1).trim();
   } else if (/^\d+\.\s/.test(cleaned)) {
      cleaned = cleaned.replace(/^\d+\.\s/, '').trim();
   }
   return cleaned;
}

/**
 * エラーアラートメールを送信する
 */
function sendErrorAlert(subject, errorMessage) {
  if (!ALERT_EMAIL) {
    console.error('[ERROR] 通知先メールアドレスが設定されていません。ALERT_EMAILを確認してください。');
    return;
  }
  
  try {
    const body = `
GASスクリプトの実行中にエラーが発生しました。
GASの実行ログを確認してください。

■ スクリプト名
Meet文字起こしタスク化

■ エラー概要
${errorMessage}

■ 発生日時
${new Date().toLocaleString('ja-JP')}
    `;
    
    GmailApp.sendEmail(ALERT_EMAIL, `【GASエラーアラート】${subject}`, body);
    console.log(`[INFO] エラーアラートメールを ${ALERT_EMAIL} に送信しました。`);
    
  } catch (e) {
    console.error(`[FATAL] エラーアラートメールの送信自体に失敗しました: ${e.toString()}`);
  }
}

ここからが本題:7つの沼(格闘の記録)

なぜ、こんなにコードが複雑になってしまったのか。私(とGemini)がハマった沼の数々をご紹介します。

沼1:メールからURLが取得できない

これは序の口でした。

  • 現象: message.getPlainBody() で取得したテキストにURLが含まれていない。
  • 原因: Geminiからの通知メールはHTMLメール。「会議メモを開く」ボタンの href 属性にURLが隠されていた。
  • 解決: message.getBody() を使い、HTMLソースコード全体を取得。正規表現でURLを引っこ抜く。
// 変更前
const body = message.getPlainBody(); 
// 変更後
const body = message.getBody(); 

沼2:DocumentApp.openById() が謎のエラー

URLが取れたので DocumentApp.openById(docId) を実行。すると、無情なエラーが。

Exception: Invalid argument: url (※後に Unexpected error に変化)

ファイルは存在します。ブラウザで開けます。権限もあります。なのにGASからは開けない。ここからが「本当の地獄」でした。

沼3:「Wordファイル」を疑う → 違った

  • 仮説: DocumentApp はGoogleドキュメントしか開けない。もしかして、見た目はドキュメントだが実体はWord (.docx) ではないか?
  • 検証: DriveApp.getFileById(docId).getMimeType() でファイル形式を調査。
  • 結果: application/vnd.google-apps.document
    • 正真正銘のGoogleドキュメントでした。 仮説は外れ、デバッグは振り出しに戻ります。

沼4:「コピー禁止ロック」を疑う → 違った

  • 仮説: DocumentApp で開く動作は「コピー」に近い。ファイルの共有設定で「閲覧者のコピーを禁止」がONになっているのでは?
  • 検証: Drive API (v2) を使い、Drive.Files.get(docId) でメタデータを取得。
  • 結果: copyRequiresWriterPermissionfalse (ロックされていない)。
    • またもや仮説は外れ、完全に八方塞がりになりました。

沼5:「コピーすれば開ける」を試す → コピーも開けない

  • 仮説: オリジナルファイルが持つ「何か」が DocumentApp を拒否している。なら DriveApp.makeCopy() でクリーンなコピーを作成すれば、その「何か」は消えるはずだ。
  • 検証: DocumentApp.openById(copyId) を実行。
  • 結果: Exception: Unexpected error...
    • オリジナルと全く同じエラーで失敗。 呪いはコピーにも引き継がれました。

沼6:【核心】ついに判明した「ファイルの正体」

DocumentApp を使うアプローチを完全に諦め、別ルートを模索しました。

  • 検証: DriveApp.getFileById(docId).getBlob().getAs(MimeType.PLAIN_TEXT)
    • (ファイルの中身をBlobとして取得し、強制的にテキストに変換する)
  • 結果: Exception: Converting from application/pdf to text/plain is not supported.
  • !? PDF !?

ここで、すべての現象が繋がりました。

Geminiが生成したこのファイルは、Googleドキュメントのアイコンを持ちながら、その実体は application/pdf だったのです。

だから DocumentApp は開けず、makeCopy はPDFをコピーし、テキスト変換はPDFだから失敗したのです。

沼7:最終決戦(OCR vs APIバージョン vs 最終パース)

ファイルの正体がPDFと分かれば、やることは一つ。OCRです。

  1. 最終戦略: Drive API を使い、PDFのBlobを OCRでGoogleドキュメントに変換する。
    Drive.Files.insert(resource, pdfBlob, {ocr: true})
  2. 沼7-1 (API v2 vs v3): insert is not a function エラー。
    • 解決: GASの「Drive API」サービスはv3が標準。insert はv2のメソッド。v3の Drive.Files.create() に修正。
  3. 沼7-2 (OCR後の構造): body.getChildElements is not a function エラー。
    • 解決: OCRで生成されたドキュメントは特殊な構造を持つらしく、getChildElements() が使えない。仕方なく body.getText() で全テキストを取得し、改行(\n)で分割して自力でパースするロジックに変更。
  4. 沼7-3 (タスク0件):
    • 現象: OCR後のテキストには、タスク行の先頭に などの記号が一切ない。
    • 解決: isListItem() 関数でのチェックを諦め、「"推奨される次のステップ"セクション内で、空行ではない行はすべてタスクとみなす」というロジックに最終変更。

これで、ついにタスクの抽出に成功しました。

まとめ

当初の「メールからURL取得→ドキュメント開く→コピペ」という単純な計画は、Geminiが生成したファイルが**「Googleドキュメントの皮を被ったPDF」**という特殊なファイルだったために、ことごとく打ち砕かれました。

最終的な処理フローは以下のようになりました。

  1. メールからURL取得
  2. URLのIDで DriveApp.getFileById
  3. file.getBlob() で実体(PDF)を取り出す
  4. Drive.Files.create({ocr: true}) でPDFをOCRにかけ、新しいGoogleドキュメントを作成
  5. 新しいドキュメントDocumentApp.openById() で開く
  6. body.getText() で全テキストを強引に取得
  7. テキストを1行ずつパースし、タスクを抽出
  8. 一時的に作成したOCRドキュメントをゴミ箱に捨てる

もし「GASからGeminiのドキュメントがなぜか開けない」と悩んでいる方がいれば、それはPDFかもしれません。この記事が、同じ沼にハマった誰かの助けになれば幸いです。

長い戦いでした。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?