筆者の紹介
こんにちは、わがです。Google Workspaceの自動化が好きで、日々GASを書いています。
やりたかったこと
Gemini in Google Meetが、会議の文字起こしと「推奨される次のステップ」をGoogleドキュメントとして自動生成してくれるようになりました。
しかし、生成されたドキュメントのURLがメールで通知されるだけで、**タスク管理ツールへの登録は結局「手動コピペ」**になっていました。
「この最後のひと手間」を自動化すべく、私はGoogle Apps Script (GAS) を使った自動化に乗り出しました。
当初の甘い計画:
- トリガーでGeminiからのメールを検知 (
is:unread from:gemini-notes@google.com) - メール本文からドキュメントURLを正規表現で引っこ抜く。
-
DocumentApp.openByUrl()でドキュメントを開く。 - ドキュメントのテキストをパースして「推奨される次のステップ」以下のタスクを抽出する。
-
SpreadsheetApp.appendRow()で管理用スプレッドシートにタスクを追記する。
簡単そうに見えました。しかし、これが合計7つの「沼」を巡る長い戦いの始まりでした。
結論:最終的に動いた全コード
結論から先に。長いデバッグの末に完成した、Geminiが生成する特殊なファイルに対応した全コードがこちらです。
SPREADSHEET_ID と ALERT_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)でメタデータを取得。 -
結果:
copyRequiresWriterPermissionはfalse(ロックされていない)。- またもや仮説は外れ、完全に八方塞がりになりました。
沼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です。
-
最終戦略:
Drive APIを使い、PDFのBlobを OCRでGoogleドキュメントに変換する。
Drive.Files.insert(resource, pdfBlob, {ocr: true}) -
沼7-1 (API v2 vs v3):
insert is not a functionエラー。-
解決: GASの「Drive API」サービスはv3が標準。
insertはv2のメソッド。v3のDrive.Files.create()に修正。
-
解決: GASの「Drive API」サービスはv3が標準。
-
沼7-2 (OCR後の構造):
body.getChildElements is not a functionエラー。-
解決: OCRで生成されたドキュメントは特殊な構造を持つらしく、
getChildElements()が使えない。仕方なくbody.getText()で全テキストを取得し、改行(\n)で分割して自力でパースするロジックに変更。
-
解決: OCRで生成されたドキュメントは特殊な構造を持つらしく、
-
沼7-3 (タスク0件):
-
現象: OCR後のテキストには、タスク行の先頭に
・や→などの記号が一切ない。 -
解決:
isListItem()関数でのチェックを諦め、「"推奨される次のステップ"セクション内で、空行ではない行はすべてタスクとみなす」というロジックに最終変更。
-
現象: OCR後のテキストには、タスク行の先頭に
これで、ついにタスクの抽出に成功しました。
まとめ
当初の「メールからURL取得→ドキュメント開く→コピペ」という単純な計画は、Geminiが生成したファイルが**「Googleドキュメントの皮を被ったPDF」**という特殊なファイルだったために、ことごとく打ち砕かれました。
最終的な処理フローは以下のようになりました。
- メールからURL取得
- URLのIDで
DriveApp.getFileById -
file.getBlob()で実体(PDF)を取り出す -
Drive.Files.create({ocr: true})でPDFをOCRにかけ、新しいGoogleドキュメントを作成 -
新しいドキュメントを
DocumentApp.openById()で開く -
body.getText()で全テキストを強引に取得 - テキストを1行ずつパースし、タスクを抽出
- 一時的に作成したOCRドキュメントをゴミ箱に捨てる
もし「GASからGeminiのドキュメントがなぜか開けない」と悩んでいる方がいれば、それはPDFかもしれません。この記事が、同じ沼にハマった誰かの助けになれば幸いです。
長い戦いでした。