DX・自動化

2025/10/31

【議事録を完全自動化】GASで実行できるプログラム全文を公開|GoogleMeet編

  • AI

  • GAS

  • kintone

  • Dify

TOP+スポット

【議事録を完全自動化】G...

Note

生成AIを活用して議事録作成を効率化している方も多いと思います。しかし、1日に複数のミーティングがあった場合、文字起こしをダウンロードして生成AIを実行する作業も面倒に感じ、結局議事録を作成しなくなった経験はないでしょうか。スパプラでは、この作業を完全に自動化する仕組みを構築しました。本記事では、プログラム付きで設定を解説しています。ぜひ、ご活用ください。

1. 自作で完全自動化をした背景

続かない議事録作成

当社は、受託開発と自社プロダクト開発を展開するシステム開発会社です。プロジェクト定例、開発ミーティング、商談、企画会議など、日々多くの会議が開催されています。

生成AIが登場した当初は、録音データから文字起こしのテキストファイルを作成し、生成AIで議事録を生成することで大満足していました。しかし、1時間のミーティングが2〜3件連続すると、議事録作成が後回しになりがちで…。結局、作成しないまま放置してしまうケースも増えていきました。

有料ツールの検討と課題

議事録自動化ツールの導入も検討しました。しかし、以下の課題があり、導入には至りませんでした。

  • 会議にbotを参加させることへの心理的抵抗

  • 社内システム(kintone)への手動登録が必要

  • 会議タイプ別のテンプレート適用が困難

各社のツールはアップデートにより機能が充実してきているものの、すべての要件を満たすものは見つかりませんでした。

そんな中、2025年6月頃にGoogle Meetで文字起こしの日本語対応が開始されました。Google Meetは文字起こしデータが自動でGoogleドライブに格納される仕様のため、議事録作成の完全自動化が実現できるのではないかと考え、自作での構築を開始しました。

2. 処理フロー

実行環境の構築を省くためにGAS(Google Apps Script)で実装しました。

①対象ファイルの検索

文字起こしが保存されるGoogle Driveのフォルダ(Meet Recordings)にアクセスし、15分以内に作成されたファイルの中から「Gemini によるメモ」を含むファイルを取得します。

// ========================================
// ①対象ファイルの検索
// ========================================
function searchTargetFiles(folder) {
  const targetFiles = [];
  
  // 検索期間設定(15分以内)
  const cutoffDate = new Date();
  cutoffDate.setMinutes(cutoffDate.getMinutes() - CONFIG.SEARCH_PERIOD_MINUTES);
  
  // フォルダ内のファイルを検索
  const files = folder.getFiles();
  while (files.hasNext()) {
    const file = files.next();
    const fileName = file.getName();
    const createDate = file.getDateCreated();
    
    // 条件に一致するファイルのみ抽出
    if (fileName.includes('Gemini によるメモ') && 
        file.getMimeType() === 'application/vnd.google-apps.document' &&
        createDate >= cutoffDate) {
      targetFiles.push(file);
    }
  }
  
  return targetFiles;
}
※Google Meetで文字起こしされたファイルは、マイドライブ内の「Meet Recordings」というフォルダ内に「[会議名]_[​日時] - Gemini によるメモ」という名前で自動的に格納されます。

②文字起こしファイルを取得

対象ファイルをDOCX形式で変数に格納します。

// ========================================
// ②文字起こしファイルを取得
// ========================================
function getDocxContent(docId) {
  const accessToken = ScriptApp.getOAuthToken();
  const exportUrl = `https://www.googleapis.com/drive/v3/files/${docId}/export?mimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document&alt=media`;
  
  const response = UrlFetchApp.fetch(exportUrl, {
    headers: {
      'Authorization': 'Bearer ' + accessToken
    },
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() !== 200) {
    throw new Error(`DOCXエクスポートに失敗: ${response.getResponseCode()}`);
  }
  
  return response.getBlob().setName("post_doc.docx");
}

③Dify APIを実行

Dify APIを使用してファイルをPOSTします。

// ========================================
// ③Dify APIを実行
// ========================================
function executeDifyWorkflow(title, docxContent) {
  const userId = 'user_' + Utilities.getUuid();
  
  // 1. ファイルをアップロード
  const uploadUrl = CONFIG.DIFY_ENDPOINT + '/files/upload';
  const uploadResponse = UrlFetchApp.fetch(uploadUrl, {
    method: 'post',
    headers: {
      'Authorization': 'Bearer ' + CONFIG.DIFY_API_KEY
    },
    payload: {
      'file': docxContent,
      'user': userId
    },
    muteHttpExceptions: true
  });
  
  if (uploadResponse.getResponseCode() !== 201) {
    throw new Error(`ファイルアップロード失敗: ${uploadResponse.getContentText()}`);
  }
  
  const fileId = JSON.parse(uploadResponse.getContentText()).id;
  
  // 2. ワークフローを実行
  const workflowUrl = CONFIG.DIFY_ENDPOINT + "/workflows/run";
  const payload = {
    "inputs": {
      "title": title,
      "file": {
        "type": "document", 
        "transfer_method": "local_file",
        "upload_file_id": fileId
      }
    },
    "response_mode": "blocking",
    "user": userId
  };
  
  const response = UrlFetchApp.fetch(workflowUrl, {
    method: 'post',
    contentType: 'application/json',
    headers: {
      'Authorization': 'Bearer ' + CONFIG.DIFY_API_KEY
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() !== 200) {
    throw new Error(`ワークフロー実行エラー: ${response.getContentText()}`);
  }
  
  return response.getContentText();
}
※事前にDifyでワークフローを作成し、APIとして公開する設定が必要です。

④生成された議事録を取得

Difyで生成された議事録(JSONデータ)を取得し、展開します。

// ========================================
// ④生成された議事録を取得
// ========================================
function extractDataFromResponse(responseText) {
  try {
    // レスポンスJSONをパース
    const responseObj = JSON.parse(responseText);
    
    // text1, text2, text3のいずれかを特定
    let textContent = null;
    if (responseObj.data && responseObj.data.outputs) {
      textContent = responseObj.data.outputs.text1 || 
                    responseObj.data.outputs.text2 || 
                    responseObj.data.outputs.text3;
    }
    
    if (!textContent) {
      return null;
    }
    
    // JSONコードブロックを抽出
    const jsonMatch = textContent.match(/```json\n([\s\S]*?)\n```/);
    if (!jsonMatch || !jsonMatch[1]) {
      return null;
    }
    
    // JSONデータをパース
    const meetingData = JSON.parse(jsonMatch[1]);
    
    // 必要なフィールドを確認
    if (!meetingData.title || !meetingData.body || !meetingData.date) {
      return null;
    }
    
    return {
      title: meetingData.title,
      body: meetingData.body,
      date: meetingData.date
    };
  } catch (error) {
    Logger.log(`データ抽出エラー: ${error.message}`);
    return null;
  }
}

⑤kintoneの議事録アプリに自動登録

展開されたJSONデータをkintoneの項目名とマッピングし、議事録アプリに自動登録します。

// ========================================
// ⑤kintoneにレコードを作成
// ========================================
function createKintoneRecord(meetingData) {
  const url = `https://${CONFIG.KINTONE_SUBDOMAIN}.cybozu.com/k/v1/record.json`;
  const response = UrlFetchApp.fetch(url, {
    method: 'post',
    headers: {
      "X-Cybozu-API-Token": CONFIG.KINTONE_API_TOKEN,
      "Content-Type": "application/json"
    },
    payload: JSON.stringify({
      app: CONFIG.KINTONE_APP_ID,
      record: {
        "title": { "value": meetingData.title },
        "body": { "value": meetingData.body },
        "date": { "value": meetingData.date }
      }
    }),
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() !== 200) {
    throw new Error(`kintoneレコード作成失敗: ${response.getContentText()}`);
  }
  
  const recordId = JSON.parse(response.getContentText()).id;
  Logger.log(`kintoneレコード作成成功: ID=${recordId}`);
  
  return recordId;
}

※当社はkintoneを利用していますが、Googleドライブのフォルダに格納したり、Notionに連携するなど、自社の環境に合わせてカスタマイズ可能です。

⑥Slack通知

処理完了をSlackに通知し、関係者へ共有します。

// ========================================
// ⑥Slack通知
// ========================================
function sendToSlack(recordId, title) {
  const message = `${title}\nミーティングお疲れ様でした👍\n議事録を作成しました✏️\nhttps://${CONFIG.KINTONE_SUBDOMAIN}.cybozu.com/k/${CONFIG.KINTONE_APP_ID}/show#record=${recordId}`;
  
  const response = UrlFetchApp.fetch(CONFIG.SLACK_WEBHOOK_URL, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: message }),
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() === 200) {
    Logger.log("Slack通知送信成功");
  }
}

※処理済みのGoogle Docファイルは、今後の処理対象から除外するため、別フォルダに移動して整理しています。

3. なぜDifyを選んだのか

Difyの設定画面キャプチャ

当社はシステム開発の受託を行っており、プロジェクトによってはミーティングの議事録を成果物として毎回クライアントに共有する必要があります。プロジェクトごとに議事録のテンプレートが決まっているため、複数のプロンプトを用意し、会議名によって分岐させたいというニーズがありました。

通常、生成AIのAPIを直接使用すればDifyを利用する必要はありませんが、今回はDifyを活用することになりました。

Difyを採用することで、プロジェクトが増えた際もDify側で分岐を追加するだけで対応できます。GASのプログラムを修正する必要がないため、運用負荷の削減も期待できます。

4. プログラム全文

以下プログラムを生成AIに学習させ、自身の環境に合わせてカスタマイズすることでプログラムが書けない方でも実装できると思います。(試行錯誤をお楽しみください)

// ========================================
// 設定(API キーやフォルダIDを設定してください)
// ========================================
const CONFIG = {
  // Google Drive フォルダID
  SOURCE_FOLDER_ID: 'YOUR_SOURCE_FOLDER_ID',           // 文字起こしが自動保存されるフォルダのID
  DESTINATION_FOLDER_ID: 'YOUR_DESTINATION_FOLDER_ID', // 処理済みファイルの移動先フォルダID
  
  // Dify API設定
  DIFY_API_KEY: 'YOUR_DIFY_API_KEY',
  DIFY_ENDPOINT: 'https://api.dify.ai/v1',
  
  // kintone API設定
  KINTONE_API_TOKEN: 'YOUR_KINTONE_API_TOKEN',
  KINTONE_SUBDOMAIN: 'YOUR_SUBDOMAIN',
  KINTONE_APP_ID: 'YOUR_APP_ID',
  
  // Slack Webhook URL
  SLACK_WEBHOOK_URL: 'YOUR_SLACK_WEBHOOK_URL',
  
  // 検索期間(分)
  SEARCH_PERIOD_MINUTES: 15
};


// ========================================
// メイン処理
// ========================================
function main() {
  try {
    const folder = DriveApp.getFolderById(CONFIG.SOURCE_FOLDER_ID);
    const destinationFolder = DriveApp.getFolderById(CONFIG.DESTINATION_FOLDER_ID);
    
    // ①対象ファイルの検索
    const targetFiles = searchTargetFiles(folder);
    
    // 対象ファイルを順次処理
    targetFiles.forEach(file => {
      Logger.log(`処理開始: ${file.getName()}`);
      
      try {
        // 会議タイトル抽出
        const meetingTitle = extractMeetingTitleFromFileName(file.getName());
        
        // ②文字起こしファイルを取得
        const docxContent = getDocxContent(file.getId());
        
        // ③Dify APIを実行
        const responseText = executeDifyWorkflow(file.getName(), docxContent);
        
        // ④生成された議事録を取得
        const meetingData = extractDataFromResponse(responseText);
        
        if (meetingData) {
          // タイトルを上書き
          meetingData.title = meetingTitle;
          
          // ⑤kintoneにレコード作成
          const recordId = createKintoneRecord(meetingData);
          
          // ⑥Slackに通知
          if (recordId) {
            sendToSlack(recordId, meetingTitle);
            
            // ファイル移動(成功時のみ)
            moveFileToDestination(file, destinationFolder);
            Logger.log(`処理完了: ${file.getName()}`);
          }
        } else {
          Logger.log(`議事録データの抽出に失敗: ${file.getName()}`);
        }
      } catch (error) {
        Logger.log(`処理エラー: ${error.message}`);
      }
    });
  } catch (error) {
    Logger.log(`エラーが発生しました: ${error.message}`);
  }
}


// ========================================
// ①対象ファイルの検索
// ========================================
function searchTargetFiles(folder) {
  const targetFiles = [];
  
  // 検索期間設定(15分以内)
  const cutoffDate = new Date();
  cutoffDate.setMinutes(cutoffDate.getMinutes() - CONFIG.SEARCH_PERIOD_MINUTES);
  
  // フォルダ内のファイルを検索
  const files = folder.getFiles();
  while (files.hasNext()) {
    const file = files.next();
    const fileName = file.getName();
    const createDate = file.getDateCreated();
    
    // 条件に一致するファイルのみ抽出
    if (fileName.includes('Gemini によるメモ') && 
        file.getMimeType() === 'application/vnd.google-apps.document' &&
        createDate >= cutoffDate) {
      targetFiles.push(file);
    }
  }
  
  return targetFiles;
}


// ========================================
// ②文字起こしファイルを取得
// ========================================
function getDocxContent(docId) {
  const accessToken = ScriptApp.getOAuthToken();
  const exportUrl = `https://www.googleapis.com/drive/v3/files/${docId}/export?mimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document&alt=media`;
  
  const response = UrlFetchApp.fetch(exportUrl, {
    headers: {
      'Authorization': 'Bearer ' + accessToken
    },
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() !== 200) {
    throw new Error(`DOCXエクスポートに失敗: ${response.getResponseCode()}`);
  }
  
  return response.getBlob().setName("post_doc.docx");
}


// ========================================
// ③Dify APIを実行
// ========================================
function executeDifyWorkflow(title, docxContent) {
  const userId = 'user_' + Utilities.getUuid();
  
  // 1. ファイルをアップロード
  const uploadUrl = CONFIG.DIFY_ENDPOINT + '/files/upload';
  const uploadResponse = UrlFetchApp.fetch(uploadUrl, {
    method: 'post',
    headers: {
      'Authorization': 'Bearer ' + CONFIG.DIFY_API_KEY
    },
    payload: {
      'file': docxContent,
      'user': userId
    },
    muteHttpExceptions: true
  });
  
  if (uploadResponse.getResponseCode() !== 201) {
    throw new Error(`ファイルアップロード失敗: ${uploadResponse.getContentText()}`);
  }
  
  const fileId = JSON.parse(uploadResponse.getContentText()).id;
  
  // 2. ワークフローを実行
  const workflowUrl = CONFIG.DIFY_ENDPOINT + "/workflows/run";
  const payload = {
    "inputs": {
      "title": title,
      "file": {
        "type": "document", 
        "transfer_method": "local_file",
        "upload_file_id": fileId
      }
    },
    "response_mode": "blocking",
    "user": userId
  };
  
  const response = UrlFetchApp.fetch(workflowUrl, {
    method: 'post',
    contentType: 'application/json',
    headers: {
      'Authorization': 'Bearer ' + CONFIG.DIFY_API_KEY
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() !== 200) {
    throw new Error(`ワークフロー実行エラー: ${response.getContentText()}`);
  }
  
  return response.getContentText();
}


// ========================================
// ④生成された議事録を取得
// ========================================
function extractDataFromResponse(responseText) {
  try {
    // レスポンスJSONをパース
    const responseObj = JSON.parse(responseText);
    
    // text1, text2, text3のいずれかを特定
    let textContent = null;
    if (responseObj.data && responseObj.data.outputs) {
      textContent = responseObj.data.outputs.text1 || 
                    responseObj.data.outputs.text2 || 
                    responseObj.data.outputs.text3;
    }
    
    if (!textContent) {
      return null;
    }
    
    // JSONコードブロックを抽出
    const jsonMatch = textContent.match(/```json\n([\s\S]*?)\n```/);
    if (!jsonMatch || !jsonMatch[1]) {
      return null;
    }
    
    // JSONデータをパース
    const meetingData = JSON.parse(jsonMatch[1]);
    
    // 必要なフィールドを確認
    if (!meetingData.title || !meetingData.body || !meetingData.date) {
      return null;
    }
    
    return {
      title: meetingData.title,
      body: meetingData.body,
      date: meetingData.date
    };
  } catch (error) {
    Logger.log(`データ抽出エラー: ${error.message}`);
    return null;
  }
}


// ========================================
// ⑤kintoneにレコードを作成
// ========================================
function createKintoneRecord(meetingData) {
  const url = `https://${CONFIG.KINTONE_SUBDOMAIN}.cybozu.com/k/v1/record.json`;
  const response = UrlFetchApp.fetch(url, {
    method: 'post',
    headers: {
      "X-Cybozu-API-Token": CONFIG.KINTONE_API_TOKEN,
      "Content-Type": "application/json"
    },
    payload: JSON.stringify({
      app: CONFIG.KINTONE_APP_ID,
      record: {
        "title": { "value": meetingData.title },
        "body": { "value": meetingData.body },
        "date": { "value": meetingData.date }
      }
    }),
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() !== 200) {
    throw new Error(`kintoneレコード作成失敗: ${response.getContentText()}`);
  }
  
  const recordId = JSON.parse(response.getContentText()).id;
  Logger.log(`kintoneレコード作成成功: ID=${recordId}`);
  
  return recordId;
}


// ========================================
// ⑥Slack通知
// ========================================
function sendToSlack(recordId, title) {
  const message = `${title}\nミーティングお疲れ様でした👍\n議事録を作成しました✏️\nhttps://${CONFIG.KINTONE_SUBDOMAIN}.cybozu.com/k/${CONFIG.KINTONE_APP_ID}/show#record=${recordId}`;
  
  const response = UrlFetchApp.fetch(CONFIG.SLACK_WEBHOOK_URL, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: message }),
    muteHttpExceptions: true
  });
  
  if (response.getResponseCode() === 200) {
    Logger.log("Slack通知送信成功");
  }
}


// ========================================
// 補助関数
// ========================================

/**
 * ファイルを移動先フォルダに移動する
 */
function moveFileToDestination(file, destinationFolder) {
  try {
    destinationFolder.addFile(file);
    const parents = file.getParents();
    while (parents.hasNext()) {
      parents.next().removeFile(file);
    }
    Logger.log(`ファイル「${file.getName()}」を移動先フォルダに移動しました`);
  } catch (error) {
    Logger.log(`ファイル移動エラー: ${error.message}`);
  }
}

/**
 * ファイル名から会議タイトルを抽出
 */
function extractMeetingTitleFromFileName(fileName) {
  const dashIndex = fileName.indexOf(' - ');
  if (dashIndex !== -1) {
    return fileName.substring(0, dashIndex).trim();
  }
  return fileName.replace('Gemini', '').trim();
}

補足:

  • 設定(CONFIG変数)に自身の環境変数を挿入してください

  • すべての作業が完了したタイミングで実行した文字起こしのファイルを「処理済み」フォルダに移動しています。

  • 生成AIのAPIを直接利用する際は、③の関数を修正してください。

  • 議事録の登録先は⑤、通知先は⑥をそれぞれ修正してください。

5. 運用上の工夫

この環境を最大限活かすために、会議運営も工夫するようにしました。

1. 会議タイトルの命名規則を統一

[プロジェクト名] 会議種類
例:「[/WORKSプロジェクト]社内定例」

AIが適切なテンプレートを自動選択できるように、命名規則を統一しました。

2. 会議開始の明示的な宣言

「では、/WORKSプロジェクトの定例会議を開始します。」

会議の開始を宣言することで、会議前の雑談が議事録に混入するのを防いでいます。

3. 目的とアジェンダを宣言

「この会議で話したいことは、
1. 前回のアクションアイテム確認
2. Q2計画の各担当者からの報告
3. 新機能開発について
です。」

冒頭でアジェンダをこれらを宣言することで、AIが会議の構成を理解し、適切に議事録を構造化してくれます。

※プロジェクトでアジェンダが固まっている場合は、Difyのプロンプトにアジェンダを記載しています。

6. 制約と対応方法

他組織主催の会議

現在、自組織が主催する会議の文字起こしだけがマイドライブ内に格納されるようになっているため、他組織から招待された会議については今回の仕組みは適用できません。そのため会議中のGemini文字起こしから手動でGoogleドキュメントを作成し、Meet Recordingsフォルダに保存しています。

Zoom・Teams会議

当社はZoomやTeamsを有料契約していないため、今回の仕組みは適用できませんが、Xccという拡張機能を利用すれば、ZoomやTeamsなどで開催される他組織の会議も文字起こしすることができます(無料プランは月10回まで)。

文字起こしテキストをMeet Recordingsフォルダに保存すれば、同じ自動化フローで処理できます。

7. 技術スタックまとめ

  • 文字起こし: Google Meetの標準機能

  • プログラム実行基盤: Google Apps Script(無料)

  • AI処理: Dify

  • データ保存: kintone

  • 通知: Slack

  • トリガー: Google Apps Scriptの時間主導型トリガー(15分ごと)

まずは、お気軽に相談ください

当社では、このような業務自動化・DX推進のご相談を承っています。生成AIを活用した業務効率化や、既存システムとの連携など、お気軽にご相談ください。

+SPOTトップ

CONTACT

お問い合わせ

まずは、無料相談から。
構想段階のアイデアやサービスについてお気軽に相談ください。