この記事は、Insight Edge Advent Calendar 2025の17日目の記事です!!
はじめに
こんにちは、Insight Edgeでデータサイエンティストをしている善之です
「会議の議事録をSlackに投稿して共有したい」「でも毎回投稿するのは面倒...」
このような課題を抱えていませんか?
私はデータサイエンスチームのLT会を運営しており、週1回の勉強会の内容を全社共有する必要がありました。発表者に投稿を依頼すると負担になりますし、運営側がリマインドや投稿を管理するのも大変です。
そこで、Google MeetのGeminiメモ機能とGoogle Apps Scriptを活用し、議事録を自動でSlackに投稿する仕組みを構築しました。
この仕組みにより、毎週の投稿作業がゼロで、LT会の内容が自動で全社に共有されるようになりました。
この記事では、同様の課題を抱える方に向けて、実装内容と工夫したポイントをご紹介します。
目次
システムの全体像
実装した仕組みは以下の通りです:
- Google Meetで議事録を自動生成: LT会実施時に毎回議事録が自動保存される設定にする(Googleカレンダーで設定)
- 議事録を指定フォルダに格納: 議事録は特定のGoogle Driveフォルダに保存される
- GASによる定期巡回: GASが毎日1回、フォルダを巡回して「データサイエンスチームLT会」から始まるドキュメントを取得
- 内容の解析と整形: 議事録から必要な情報(まとめ、詳細)を抽出して整形
- Slackへの自動投稿: Slack Incoming Webhookを使って対象のチャンネルに投稿

実装のポイント
ここでは、実装において工夫が必要だったポイントをピックアップして解説します。
HTMLからの情報抽出と構造化
最も工夫が必要だったのが、議事録からの情報抽出と構造化です。
Geminiの議事録は構造化されているので、そのうち「まとめ」と「詳細」のセクションだけを投稿に利用したいと思いました。
こちらが実際の議事録ドキュメントです(個人名はマスキングしています)
画像の範囲外にも、「推奨される次のステップ」や、「文字起こし」などのセクションがありますが、
「まとめ」と「詳細」のセクションだけを取得したいです。
しかし、Googleドキュメントのテキストから直接は階層構造がうまく読み取れないため、HTMLにエクスポートして構造を解析する手順にしました。
全体像
まずは全体像を示します。
GoogleドキュメントをHTMLとしてエクスポートし、まとめ・詳細のセクションを抽出します。
その後、slackに投稿できる形式に変換して返します。
function getDocDateSummaryDetails_(docId) { const token = ScriptApp.getOAuthToken(); const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(docId)}/export?mimeType=text/html`; // HTMLエクスポート const res = UrlFetchApp.fetch(url, { method: "get", headers: { Authorization: `Bearer ${token}` }, muteHttpExceptions: true, }); const html = res.getContentText("UTF-8"); // 「まとめ」「詳細」セクションを抽出 const summaryHtml = extractSectionByHeading_(html, "まとめ"); const detailsHtml = extractSectionByHeading_(html, "詳細"); // Slack形式に整形 const summary = htmlSectionToMrkdwn_(summaryHtml); const details = htmlSectionToMrkdwn_(detailsHtml); return {summary, details}; }
主な処理:
- Drive APIでHTMLエクスポート
- 「まとめ」「詳細」の見出しでセクションを抽出
ここからは、セクションの抽出のためのextractSectionByHeading_関数と、Slack形式への変換のためのhtmlSectionToMrkdwn_の中身を紹介します。
セクションの抽出(extractSectionByHeading_)
HTML内の見出しタグ(h1-h6)を検出し、指定した見出し名のセクションを次の見出しまで抽出します。
function extractSectionByHeading_(html, headingText) { const hTagRe = /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi; let m, sections = []; while ((m = hTagRe.exec(html)) !== null) { // タグを除去して見出しテキストを取得 const text = m[2].replace(/<\/?[^>]+>/g, "").trim(); sections.push({ start: m.index, end: hTagRe.lastIndex, text: text }); } // 指定した見出し名を含むセクションを検索 let idx = sections.findIndex(h => h.text.includes(headingText)); if (idx === -1) return ""; // 次の見出しまでを返す const nextStart = (idx + 1 < sections.length) ? sections[idx + 1].start : html.length; return html.slice(sections[idx].end, nextStart); }
主な処理:
- h1-h6タグを正規表現で検出し、位置とテキストを記録
- タグを除去して見出しテキストを取得
- 指定した見出し名を含むセクションを検索
- 該当見出しから次の見出しまでの範囲を返す
Slack形式への変換(htmlSectionToMrkdwn_)
抽出したHTMLセクションをSlackのmarkdown形式に変換します。主な変換処理は以下の通りです:
<a href="...">text</a>→<url|text>(Slack形式のリンク)<strong>text</strong>→*text*(太字)<em>text</em>→_text_(斜体)<ul><li>item</li></ul>→- item(箇条書き)
function htmlSectionToMrkdwn_(sectionHtml) { if (!sectionHtml) return ""; let s = sectionHtml; // HTMLタグをSlack markdown形式に変換 s = s.replace(/<br\s*\/?>/gi, "\n"); s = s.replace(/<\/p>/gi, "\n\n"); s = s.replace(/<a\b[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (_, href, text) => { return `<${href}|${text}>`; }); s = s.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, (_, __, t) => `*${t}*`); s = s.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, (_, __, t) => `_${t}_`); // リストを箇条書きに変換 s = s.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n"); // 残りのタグを除去して整形 s = s.replace(/<\/?[^>]+>/g, ""); s = s.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); return s; }
以上の処理で、Googleドキュメントから必要な部分だけ抽出し、Slack投稿用のフォーマットに変換できました!
Script Propertiesを使った送信済みファイルの管理
同じ議事録を何度も投稿しないよう、Script Propertiesを使って処理済みファイルIDを管理しています。
これにより、GASが毎日実行されても、既に送信済みの議事録は再送信されません。
function runPipeline() { const props = PropertiesService.getScriptProperties(); // Script Propertiesから処理済みIDリストを取得 const processedJson = props.getProperty(`${PROP_NS}_processed_ids`) || "[]"; const processedIds = JSON.parse(processedJson); const processedSet = new Set(processedIds); const folder = DriveApp.getFolderById(FOLDER_ID); const files = folder.getFiles(); const newlyProcessed = []; while (files.hasNext()) { const file = files.next(); const id = file.getId(); // 処理済みファイルはスキップ if (processedSet.has(id)) continue; // 議事録の内容を取得・整形してSlackに投稿 // (省略) // 処理済みIDリストに追加 newlyProcessed.push(id); } // 処理済みIDをScript Propertiesに保存 if (newlyProcessed.length > 0) { const keep = [...processedSet, ...newlyProcessed]; props.setProperty(`${PROP_NS}_processed_ids`, JSON.stringify(keep)); } }
実行権限の設定
GASから外部API(Drive API、Slack Webhook)にアクセスするため、適切な権限設定が必要でした。
appsscript.jsonに以下のOAuthスコープを設定しました:
{ "timeZone": "Asia/Tokyo", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "oauthScopes": [ "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/userinfo.email" ] }
この設定で特に詰まったため、同様の実装をされる方は参考にしてください。
別の実装アプローチ
今回はGoogle MeetのGeminiメモをHTMLとして解析する方式を採用しましたが、別のアプローチとして文字起こしデータをGemini APIで処理する方法もあります。
社内の別チームでは、Google Meetの生の文字起こしデータをAPI経由でGeminiに送信し、議事録を生成する仕組みを構築しました。この方式には以下のメリットがあります:
- Googleドキュメントの構造解析が不要
- 議事録のフォーマットを自由にカスタマイズ可能
プロジェクトの要件に応じて、どちらのアプローチが適しているか検討してみてください。
実際に動かしてみた結果
2025年8月に運用を開始し、これまでトラブルなく自動投稿が成功しています。
↓実際の投稿例です

運用してみての成果と課題をまとめます。
成果:
- 運営・発表者の作業時間がゼロ: 毎週の投稿作業が完全に不要
- 投稿漏れがゼロ: 自動化により、投稿忘れやリマインドの手間が一切不要
- チーム間コミュニケーションの活性化: 投稿をきっかけに他チームからの質問やコメントが増加
- 議事録の精度も十分: 議事録の精度が高く、内容が十分に伝わる
課題と対策:
- 音声認識の誤り: 100%正確ではないため、「AI自動生成のため内容に誤りがある可能性あり」と周知
まとめ
Google Apps ScriptとGoogle MeetのGeminiメモを活用し、議事録を自動でSlackに投稿する仕組みを構築しました。
実装のポイント:
- Drive APIでHTMLエクスポートし、構造化されたデータを抽出
- Script Propertiesで処理済みファイルを管理し、二重送信を防止
- 適切なOAuthスコープを設定して、必要な権限を付与
得られた成果:
- 毎週の投稿作業が完全にゼロ
- 投稿漏れやリマインドの手間が不要
- 全社への情報共有がスムーズになり、チーム間コミュニケーションが活性化
こんな方におすすめ:
- 定例会議の議事録をSlackで共有している方
- Google MeetとSlackを併用している組織
- 手作業の投稿作業を自動化したい方
同様の課題を抱えている方の参考になれば幸いです!
参考リンク: