商談後の面倒な議事録作成、提案資料の送付…。その作業、まさかまだ手動でやっていませんか?多くのビジネスパーソンにとって、これらの定型業務は重要でありながらも、多くの時間を奪う悩みの種です。もし、この一連の作業が商談終了と同時に自動で完了するとしたら、あなたの働き方はどう変わるでしょうか。
本記事では、AIアシスタント「Claude」とGoogleの無料開発ツール「Google Apps Script (GAS)」を組み合わせ、商談後のあらゆる付帯業務を全自動で処理する革新的なシステムを、AI初心者でもコピペで構築できるように、ステップバイステップで徹底的に解説します。もう、面倒な定型業務にあなたの貴重な時間を費やすのは今日で終わりにしましょう。
このシステムが実現すること:驚きの業務効率化
今回構築するシステムは、あなたがZoomでの商談を終えた瞬間、以下の処理をすべて自動で実行します。
- 商談の録画と文字起こしを自動取得 (tl;dv連携)
- 文字起こしデータをAIが解析し、要約・決定事項をまとめた議事録を生成 (Claude API)
- 議事録をデザインされたGoogleスライド資料に自動で変換
- 該当の顧客情報(メールアドレスなど)をスプレッドシートから自動取得
- 生成された議事録PDFと、事前に用意した商談資料を添付し、顧客に自動でメール送信
- 実行ログをスプレッドシートに記録
この一連の流れを、以下のツールを連携させることで実現します。ほとんどが無料で利用開始できるツールですので、ご安心ください。
| ツール名 | 主な役割 | 無料利用の可否 |
|---|---|---|
| Claude | システム全体の設計、GASコード生成、議事録の要約・生成 | ✓ (API利用は有料) |
| tl;dv | Zoom商談の自動録画・文字起こし、Webhookによる通知 | ✓ (API利用は有料) |
| Google Apps Script (GAS) | 全ての処理を自動実行するプログラムの実行環境 | ✓ (Googleアカウント) |
| Google スプレッドシート | 顧客リスト、設定情報、実行ログの管理 | ✓ (Googleアカウント) |
| Google スライド | 議事録資料のテンプレート、または自動生成のベース | ✓ (Googleアカウント) |
| Google Drive | 生成された議事録PDFや商談資料の保管場所 | ✓ (Googleアカウント) |
| Gmail | 顧客へのメール自動送信 | ✓ (Googleアカウント) |
システムの全体像(アーキテクチャ)
この自動化システムの全体像は以下のようになります。商談が終了すると、それをトリガーにして各ツールが連携し、一気通貫で処理が進んでいく様子がわかります。

(図:商談終了からメール送信までの自動化フロー)
それでは、早速この未来の働き方を実現するための構築手順を見ていきましょう。
Step 1: Claudeに相談して「設計図」と「手順書」を作ってもらう
この自動化システムの構築は、驚くほど簡単です。なぜなら、私たちはコードを一行も書く必要がないからです。すべての設計とコード生成は、AIアシスタントであるClaudeが担当します。私たちの仕事は、Claudeに「何を実現したいか」を正確に伝えることだけです。
最初の「魔法の言葉」:プロンプトの入力
まず、Claudeを開き、新しい会話を始めます。そして、実現したいことを具体的に、しかし簡潔に伝えます。今回のプロジェクトで、動画の演者が実際にClaudeに送った最初のプロンプトがこちらです。
【プロンプト】
お客様との商談後に、レコーディングと文字起こしを自動で取得して、その内容から振り返り用の議事録資料を作成して、商談資料と一緒にメールで送信する、という作業を自動化したい。どうすればいい?
たったこれだけです。この「やりたいこと」を伝えるだけで、Claudeは優秀なプロジェクトマネージャー兼エンジニアとして動き始めます。

対話による要件定義:AIとのブレインストーミング
プロンプトを受け取ったClaudeは、すぐさま実現に向けたステップを提案し、さらに具体的な仕様を固めるための質問を投げかけてきます。これは、人間同士のプロジェクト会議と何ら変わりません。
Claudeからの質問例:
- 商談ツールはZoom、Google Meetのどちらですか? (→ Webhookの実装が変わるため)
- 文字起こしには専用ツール(tl;dvなど)を使っていますか?
- 議事録のフォーマットに希望はありますか?
これに対し、私たちは自身の環境や希望を伝えるだけでOKです。
ユーザーの回答例:
- Zoomを使っている。
- 文字起こしにはtl;dvを使っている。
- 視覚的に見やすいスライド形式の資料にしたい。

(スクリーンショット:Claudeからの質問に回答し、仕様を固めていく様子)
このような対話を数回繰り返すことで、Claudeは私たちの頭の中にある漠然としたイメージを、実行可能な技術仕様へと具体化していきます。このプロセスを通じて、最終的なシステムのアーキテクチャ(全体構成)が確定します。
成果物の生成:設計書、手順書、コードが完成
驚くべきことに、対話を通じて仕様が固まると、Claudeはシステム構築に必要なすべてのドキュメントとコードを自動で生成してくれます。
- 実装設計書: システムの目的、機能一覧、アーキテクチャ、各機能の詳細な仕様がまとめられたドキュメント。
- セットアップガイド: 私たちが実際に行うべき作業手順をステップバイステップで解説した、非常に親切な取扱説明書。
- GASコード: このシステムの心臓部となる、1000行を超えるGoogle Apps Scriptの完全なコード。

(スクリーンショット:Claudeが生成した実装設計書の一部)

(スクリーンショット:Claudeが生成したセットアップガイドの一部)
この時点で、プロジェクトの8割は完了したと言っても過言ではありません。あとは、Claudeが作成した「セットアップガイド」という名のレシピに従って、料理(=システム構築)を進めていくだけです。
Step 2: セットアップガイドに従い、Google環境を準備する
ここからは、Claudeが作成した「セットアップガイド」に従って、具体的な作業を進めていきます。まずは、この自動化システムの土台となるGoogleの各種サービスを準備しましょう。
1. Googleスプレッドシートの作成
まず、顧客情報や商談のログを管理するためのGoogleスプレッドシートを新規作成します。これは、後ほどGASが顧客のメールアドレスを取得したり、処理結果を記録したりするためのデータベースとして機能します。
- Google Drive上で、任意の場所に新しいスプレッドシートを作成します。
- ファイル名は「商談自動議事録システム」など、分かりやすい名前をつけておきましょう。

(スクリーンショット:Google Drive内にスプレッドシートとフォルダを準備した様子)
2. Google Driveフォルダの作成
次に、AIが生成した議事録のPDFファイルを保存するためのGoogle Driveフォルダを準備します。ここに成果物が自動で格納されていきます。
- スプレッドシートと同じ場所(または任意の場所)に、新しいフォルダを作成します。
- フォルダ名は「議事録PDF保存用」などとしておくと良いでしょう。
3. Google Apps Script (GAS)の準備
いよいよ、このシステムの心臓部であるGASを設定します。
- 先ほど作成したスプレッドシートを開きます。
- メニューバーから「拡張機能」>「Apps Script」を選択します。

(スクリーンショット:スプレッドシートの拡張機能からApps Scriptを選択)
- 新しいタブでGASのエディタ画面が開きます。プロジェクト名が「無題のプロジェクト」になっているので、スプレッドシートと同じ「商談自動議事録システム」などの名前に変更しておきましょう。
- エディタ内に最初から書かれている
function myFunction() { ... }というコードをすべて削除します。 - Step1でClaudeが生成した「商談自動議事録 gasコード」の内容をすべてコピーし、空になったGASエディタに貼り付けます。

(スクリーンショット:Claudeが生成した1000行を超えるコードをGASエディタに貼り付けた様子)
これで、システムを動かすためのプログラムの設置が完了しました。次のステップでは、このコードを自分の環境に合わせて設定していきます。
Step 3: GASコードを設定し、外部連携の準備を整える
プログラムの設置が完了したら、次はそのプログラムが自分のGoogle環境や外部サービスと正しく連携できるように、いくつかの設定を行います。ここでも、Claudeのセットアップガイドが丁寧に導いてくれるので、心配は無用です。
1. CONFIGセクションの編集:プログラムに自分の環境を教える
GASコードの冒頭部分には、CONFIG(コンフィグ)と呼ばれる設定エリアがあります。ここに、先ほど作成したスプレッドシートやフォルダの「住所」にあたるIDを書き込むことで、GASが操作対象を正確に認識できるようになります。
- GASエディタの上部にある
CONFIGセクションを見つけます。 SPREADSHEET_ID: Step2で作成したスプレッドシートのURLからID部分(/d/と/editの間の長い文字列)をコピーし、シングルクォーテーション(”)の間に貼り付けます。OUTPUT_FOLDER_ID: 同様に、議事録PDF保存用フォルダのURLからID部分をコピーして貼り付けます。CC_EMAIL,ADMIN_EMAIL: 必要に応じて、メールのCC先やエラー通知先のアドレスを設定します。

(スクリーンショット:スプレッドシートIDやフォルダIDをコードに設定している様子)
2. APIキーの設定:外部サービスとの「秘密の鍵」を登録
次に、このシステムがtl;dvやClaudeと連携するために必要な「APIキー」を設定します。APIキーは、サービス間の連携を許可するための秘密の鍵のようなものです。GASでは、この秘密の情報を安全に管理するための仕組みが用意されています。
- GASエディタ上部の関数選択ドロップダウンから
setupSecretsを選択します。 - 「実行」ボタンをクリックします。初回実行時には、スクリプトがあなたのGoogleアカウント情報へアクセスするための許可を求められますので、画面の指示に従って承認してください。
- 実行すると、スプレッドシート上にダイアログボックスが表示され、まず「tl;dvのAPIキー」の入力を求められます。tl;dvのサイトにログインし、設定画面からAPIキーをコピーして貼り付けます。
- 次に「ClaudeのAPIキー」の入力を求められます。同様に、Claudeの公式サイトからAPIキーを取得し、貼り付けます。

(スクリーンショット:setupSecrets関数を実行し、APIキーをダイアログで設定する様子)
3. 顧客リストシートの作成
APIキーの設定が完了したら、次はメールの送信先となる顧客情報を管理するためのシートを自動で作成します。
- GASエディタの関数選択ドロップダウンから
setupCustomerSheetを選択し、「実行」をクリックします。 - 実行後、スプレッドシートを確認すると、「顧客リスト」という名前の新しいシートが作成され、会社名、担当者名、メールアドレスなどのヘッダーが自動で入力されています。ここに、テスト用の顧客情報を入力しておきましょう。

(スクリーンショット:自動作成された「顧客リスト」シートに情報を入力する)
4. デプロイとWebhook設定:自動実行のトリガーをセット
最後に、このシステムが「商談の終了」を検知して自動で動き出すための「きっかけ(トリガー)」を設定します。これには「デプロイ」と「Webhook」という2つの重要なステップが必要です。
- デプロイ: GASエディタ右上の「デプロイ」ボタン > 「新しいデプロイ」を選択します。「種類の選択」で「ウェブアプリ」を選び、アクセスできるユーザーを「全員」に設定して「デプロイ」をクリックします。これにより、このGASプログラムを外部から呼び出すための固有のURLが生成されます。

(スクリーンショット:GASをウェブアプリとしてデプロイする設定画面)
- Webhook設定: 生成されたウェブアプリのURLをコピーし、tl;dvのサイトに移動します。設定メニューの中にある「Webhooks」を開き、「Configure new Webhook」をクリックします。
- Endpoint URL: 先ほどコピーしたGASのURLを貼り付けます。
- Event Action: 「Transcript Ready」(文字起こし完了時)を選択します。これが、GASを実行するタイミングとなります。

(スクリーンショット:tl;dvにGASのWebhook URLを設定する画面)
これで、すべての準備が整いました。商談が終了し、tl;dvの文字起こしが完了すると、このWebhookが発火し、GASプログラムが自動的に実行されるようになります。次のステップでは、システムが正しく動作するかをテストしていきます。
Step 4: 段階的なテストとデバッグ
すべての設定が完了しましたが、いきなり本番の商談で使うのは不安です。そこで、システムが意図通りに動作するかを、部分ごとにテストしていきます。Claudeが用意してくれたテスト用の関数を順番に実行することで、どこかに問題がないかを確認できます。
プログラミングの世界では、エラーはつきものです。重要なのは、エラーが発生したときにどう対処するかです。このステップでは、動画内で実際に発生したエラーと、それをClaudeと対話しながら解決していくプロセスも包み隠さず紹介します。これは、AIを単なる「コード生成機」ではなく、「問題解決のパートナー」として活用する絶好の実例となるでしょう。
テストの実行とエラー対応の実例
Claudeが生成したGASコードには、test_1_getMeetingsからtest_5_fullPipelineまで、段階的に動作確認できる便利なテスト関数が含まれています。
test_1&test_2: 外部サービスとの接続確認test_1_getMeetingsを実行すると、tl;dvに保存されている最近の会議リストが取得できます。これで、GASとtl;dvが正しく連携できていることを確認します。- 次に、取得した会議リストの中からテストしたい会議のIDをコード内の
TEST_MEETING_IDに設定し、test_2_getTranscriptを実行します。文字起こしデータが取得できれば、第二段階もクリアです。

(スクリーンショット:テスト2が成功し、文字起こしデータが取得できた様子)test_3: エラー発生!Claudeと共にデバッグ開始- 続いて、議事録生成の核となる
test_3_generateMinutesを実行します。しかし、ここでエラーが発生!GASの実行ログに赤いエラーメッセージが表示されます。

(スクリーンショット:テスト3実行時にエラーが発生した画面)- ここで慌てる必要はありません。 このエラー画面のスクリーンショットを撮り、そのままClaudeに「こんなエラーが出たんだけど」と画像を貼り付けて質問します。
- Claudeは即座に画像とエラー内容を解析し、「原因はClaudeのAPIキーが間違っている可能性が高いです」と指摘。さらに、原因を特定するための確認用コードまで提案してくれます。
- 指示に従って確認用コードを実行し、その結果を再度Claudeに伝えると、「やはりAPIキーがtl;dvのものと取り違えられていますね」と、人間側の単純な設定ミスであったことを突き止めてくれました。
setupSecretsを再度実行し、正しいAPIキーを設定し直すことで、この問題は無事解決しました。

(スクリーンショット:Claudeにエラー画像を投げて原因を特定してもらう様子)- 続いて、議事録生成の核となる
test_4: 再びのエラーと、AIによるコード修正- APIキーの問題を解決し、
test_3が成功。次にスライドを生成するtest_4_createSlideを実行すると、またもやエラーが発生しました。今度は「テンプレートIDが未設定」という内容です。 - 再びエラー画面のスクリーンショットをClaudeに投げると、「テンプレートを使わない場合でも動作するように、コードを修正する必要があります」と回答。そして、驚くべきことに、修正後のコードをその場で生成してくれました。
- 私たちは、Claudeが示した725行目のコードを、提案された新しいコードに書き換えるだけです。これにより、スライドのテンプレートが無くても、AIがデザインを自動生成するモードで動作するようになりました。

(スクリーンショット:Claudeの指示に従いコードを修正後、テスト4が成功した様子)- APIキーの問題を解決し、
このように、エラーが発生してもClaudeに相談すれば、原因の特定から解決策の提示、さらにはコードの修正までサポートしてくれます。AIと共に開発を進める心強さを、ぜひ体感してください。
Step 5: 最終動作確認(フルパイプラインテスト)
すべての部品が正しく動作することを確認できたら、いよいよ最終テストです。test_5_fullPipeline 関数を実行し、商談終了からメール送信までの一連の流れがすべて自動で実行されるかを確認します。
このテストを実行すると、GASはこれまでテストしてきた各処理を連続して実行します。
- tl;dvから文字起こしを取得
- Claude APIで議事録を生成
- Googleスライドで議事録資料を作成し、PDF化
- Google Driveから関連資料を検索(設定していれば)
- スプレッドシートの顧客リストから宛先を取得
- Gmailで議事録PDFと資料を添付したメールを送信
実行後、しばらくして自分の受信トレイを確認してみましょう。

(スクリーンショット:自動生成された議事録PDFが添付されたメールが実際に届いた様子)
見事に、AIが作成した議事録が添付されたメールが届いているはずです。メールの件名や本文も、Claudeが商談内容に合わせて適切に生成してくれています。また、Google Driveの保存用フォルダには、生成されたGoogleスライドとPDFファイルが格納され、スプレッドシートには実行ログが記録されています。

(スクリーンショット:すべての処理が完了し、スプレッドシートにログが記録された様子)
これで、あなたの「AI営業アシスタント」は完成です。今後は、あなたがZoom商談を終えるたびに、このシステムが自動で面倒な事後処理をすべて代行してくれます。
【プレゼント】今すぐ試せる!プロンプト&GASコード
この記事を読んで、「自分も今すぐ試してみたい!」と思った方のために、動画内で使用されたClaudeへの指示プロンプトと、最終的に完成したGASコード(約1400行)をプレゼントします。
以下のリンクからダウンロードし、ぜひご自身の環境でこの自動化システムを構築してみてください。
お客様との商談後に、レコーディングと文字起こしを自動で取得して、その内容から振り返り用の議事録資料を作成して、商談資料と一緒にメールで送信するのを自動化したいんだけど、どうすればいい?
// ============================================================================
// 商談自動議事録生成&メール送信システム
// Phase 1: tl;dv Webhook受信 & Claude API連携 & Google Slides & Gmail送信
//
// NEXT INNOVAITION株式会社
// ============================================================================
// ===== 定数・設定 =====
const CONFIG = {
// tl;dv API
TLDV_API_BASE: 'https://pasta.tldv.io/v1alpha1',
// Claude API
CLAUDE_API_URL: 'https://api.anthropic.com/v1/messages',
CLAUDE_MODEL: 'claude-sonnet-4-5-20250929',
CLAUDE_MAX_TOKENS: 4096,
// Google Slides テンプレートID(事前に作成したテンプレート)
// ※ 後述の setupTemplate() で自動作成可能
SLIDES_TEMPLATE_ID: '', // ← テンプレートIDをここに設定
// スプレッドシートID
SPREADSHEET_ID: '', // ← 顧客リスト&ログ用スプレッドシートIDをここに設定
// シート名
SHEET_CUSTOMERS: '顧客リスト',
SHEET_LOG: '商談ログ',
// Google Drive - 議事録保存先フォルダID
OUTPUT_FOLDER_ID: '', // ← 議事録PDFの保存先フォルダIDをここに設定
// メール設定
CC_EMAIL: '', // ← 社内CC先メールアドレス(空なら CC なし)
SENDER_NAME: 'NEXT INNOVAITION株式会社',
// エラー通知先
ADMIN_EMAIL: '', // ← 管理者メールアドレス
// リトライ設定
MAX_RETRIES: 3,
RETRY_DELAY_MS: 2000,
};
// ============================================================================
// 1. WEBHOOK 受信 (doPost)
// ============================================================================
/**
* tl;dv Webhookを受信するエンドポイント
* GASをWebアプリとしてデプロイし、このURLをtl;dvのWebhook設定に登録する
*/
function doPost(e) {
try {
const payload = JSON.parse(e.postData.contents);
logToSheet_('INFO', 'Webhook受信', JSON.stringify(payload).substring(0, 500));
const event = payload.event;
// --- TranscriptReady イベント: 文字起こし完了時に全処理を実行 ---
if (event === 'TranscriptReady') {
// TranscriptReady の場合、data 内に直接 transcript データが含まれる
const meetingId = payload.data?.meetingId || payload.data?.id;
if (!meetingId) {
logToSheet_('ERROR', 'meeting_id取得失敗', JSON.stringify(payload));
return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: 'No meeting ID' }))
.setMimeType(ContentService.MimeType.JSON);
}
// メイン処理を非同期的にトリガー(Webhook応答を素早く返すため)
// ※ GASのWebhookは30秒タイムアウトがあるため、重い処理はトリガー経由で実行
const trigger = ScriptApp.newTrigger('processMeeting_')
.timeBased()
.after(1000) // 1秒後に実行
.create();
// トリガーに渡すデータをScript Propertiesに一時保存
PropertiesService.getScriptProperties().setProperty(
'pending_meeting_' + trigger.getUniqueId(),
JSON.stringify({ meetingId: meetingId, triggerId: trigger.getUniqueId() })
);
return ContentService.createTextOutput(JSON.stringify({ status: 'accepted', meetingId: meetingId }))
.setMimeType(ContentService.MimeType.JSON);
}
// --- MeetingReady イベント: ログのみ記録(参加者情報の補完に利用可能) ---
if (event === 'MeetingReady') {
const meetingData = payload.data;
logToSheet_('INFO', 'MeetingReady', `会議: ${meetingData?.name || 'N/A'}`);
// MeetingReadyの参加者情報をキャッシュ(TranscriptReadyには含まれないため)
if (meetingData?.id) {
CacheService.getScriptCache().put(
'meeting_meta_' + meetingData.id,
JSON.stringify({
name: meetingData.name,
happenedAt: meetingData.happenedAt,
organizer: meetingData.organizer,
invitees: meetingData.invitees,
url: meetingData.url,
}),
21600 // 6時間キャッシュ
);
}
return ContentService.createTextOutput(JSON.stringify({ status: 'ok' }))
.setMimeType(ContentService.MimeType.JSON);
}
// その他のイベント
logToSheet_('INFO', '未処理イベント', event);
return ContentService.createTextOutput(JSON.stringify({ status: 'ignored', event: event }))
.setMimeType(ContentService.MimeType.JSON);
} catch (error) {
logToSheet_('ERROR', 'doPost例外', error.toString());
notifyAdmin_('Webhook処理エラー', error.toString());
return ContentService.createTextOutput(JSON.stringify({ status: 'error', message: error.toString() }))
.setMimeType(ContentService.MimeType.JSON);
}
}
/**
* Webhookの動作確認用(GETリクエスト)
*/
function doGet(e) {
return ContentService.createTextOutput(JSON.stringify({
status: 'ok',
message: 'NEXT INNOVATION 商談自動議事録システム - Webhook Endpoint',
timestamp: new Date().toISOString(),
})).setMimeType(ContentService.MimeType.JSON);
}
// ============================================================================
// 2. メイン処理パイプライン
// ============================================================================
/**
* トリガーから呼び出されるメイン処理
* ※ doPostの中で直接呼ぶとタイムアウトするため、トリガー経由で実行
*/
function processMeeting_(e) {
// トリガーIDからデータを取得
const triggerId = e?.triggerUid;
const props = PropertiesService.getScriptProperties();
const pendingData = props.getProperty('pending_meeting_' + triggerId);
if (!pendingData) {
logToSheet_('WARN', 'トリガーデータなし', triggerId);
cleanupTrigger_(triggerId);
return;
}
const { meetingId } = JSON.parse(pendingData);
props.deleteProperty('pending_meeting_' + triggerId);
cleanupTrigger_(triggerId);
logToSheet_('INFO', '処理開始', `meeting_id: ${meetingId}`);
try {
// Step 1: tl;dv APIから会議情報&文字起こし取得
const meetingData = getMeetingData_(meetingId);
const transcript = getTranscript_(meetingId);
if (!transcript || transcript.length === 0) {
throw new Error('文字起こしデータが空です');
}
logToSheet_('INFO', '文字起こし取得完了', `${transcript.length}文字`);
// Step 2: Claude APIで議事録JSON生成
const minutesJson = generateMinutes_(meetingData, transcript);
logToSheet_('INFO', '議事録JSON生成完了', minutesJson.meeting_title || 'N/A');
// Step 3: Google Slidesで議事録スライド生成 → PDF化
const { slideId, pdfBlob } = createMinutesSlides_(minutesJson);
logToSheet_('INFO', 'スライド&PDF生成完了', slideId);
// Step 4: 顧客情報取得&商談資料取得
const customerInfo = findCustomer_(meetingData, minutesJson);
const presentationBlob = getPresentationFile_(customerInfo);
// Step 5: メール送信
sendMinutesEmail_(customerInfo, minutesJson, pdfBlob, presentationBlob);
logToSheet_('INFO', 'メール送信完了', customerInfo?.email || 'N/A');
// Step 6: 商談ログ記録
recordMeetingLog_(meetingData, minutesJson, slideId, customerInfo, '完了');
logToSheet_('INFO', '全処理完了', meetingId);
} catch (error) {
logToSheet_('ERROR', '処理失敗', `${meetingId}: ${error.toString()}`);
notifyAdmin_('商談議事録処理エラー', `Meeting ID: ${meetingId}\nError: ${error.toString()}\nStack: ${error.stack}`);
recordMeetingLog_(null, null, null, null, `エラー: ${error.message}`);
}
}
// ============================================================================
// テスト・デバッグ用関数群
// ※ 既存のtl;dvレコーディングを使ってテストできます
// ============================================================================
/**
* 【STEP 1】まずこれを実行: tl;dvの最近の会議一覧を表示
* → ログに会議名とIDが表示されるので、テストに使うIDをコピー
*/
function test_1_listRecentMeetings() {
const apiKey = getSecret_('TLDV_API_KEY');
const response = UrlFetchApp.fetch(`${CONFIG.TLDV_API_BASE}/meetings?limit=10`, {
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
muteHttpExceptions: true,
});
if (response.getResponseCode() !== 200) {
Logger.log('❌ エラー: ' + response.getContentText());
return;
}
const meetings = JSON.parse(response.getContentText());
const list = Array.isArray(meetings) ? meetings : (meetings.results || meetings.data || []);
Logger.log('========================================');
Logger.log('📋 最近の会議一覧(最新10件)');
Logger.log('========================================');
if (list.length === 0) {
Logger.log('会議が見つかりません。tl;dvで録画した会議があるか確認してください。');
return;
}
list.forEach((m, i) => {
Logger.log(`\n--- ${i + 1} ---`);
Logger.log(`会議名: ${m.name || '(名前なし)'}`);
Logger.log(`Meeting ID: ${m.id}`);
Logger.log(`日時: ${m.happenedAt || m.created_at || 'N/A'}`);
Logger.log(`URL: ${m.url || 'N/A'}`);
if (m.organizer) Logger.log(`主催者: ${m.organizer.name || ''} (${m.organizer.email || ''})`);
});
Logger.log('\n========================================');
Logger.log('👆 テストしたい会議のMeeting IDをコピーして');
Logger.log(' 下の TEST_MEETING_ID に貼り付けてください');
Logger.log('========================================');
}
/**
* ▼▼▼ テストに使う Meeting ID をここに貼り付け ▼▼▼
*/
const TEST_MEETING_ID = ''; // ← ここにIDを貼り付け(例: '653663ac7c8dbd00130f11d9')
/**
* 【STEP 2】文字起こし取得テスト
* → tl;dv APIから会議データと文字起こしが正しく取れるか確認
*/
function test_2_fetchTranscript() {
if (!TEST_MEETING_ID) {
Logger.log('❌ TEST_MEETING_ID が空です。test_1 を実行してIDを設定してください。');
return;
}
Logger.log('📡 tl;dv APIからデータ取得中...');
try {
// 会議メタデータ
const meetingData = getMeetingData_(TEST_MEETING_ID);
Logger.log('\n✅ 会議メタデータ取得成功:');
Logger.log(` 会議名: ${meetingData.name || 'N/A'}`);
Logger.log(` 日時: ${meetingData.happenedAt || 'N/A'}`);
Logger.log(` 主催者: ${meetingData.organizer?.name || 'N/A'}`);
Logger.log(` 参加者: ${(meetingData.invitees || []).map(i => i.name || i.email).join(', ') || 'N/A'}`);
// 文字起こし
const transcript = getTranscript_(TEST_MEETING_ID);
Logger.log(`\n✅ 文字起こし取得成功: ${transcript.length}文字`);
Logger.log('\n--- 先頭1000文字 ---');
Logger.log(transcript.substring(0, 1000));
Logger.log('--- (省略) ---');
Logger.log('\n✅ STEP 2 完了!次は test_3_generateMinutes を実行してください。');
} catch (error) {
Logger.log('❌ エラー: ' + error.toString());
Logger.log(error.stack);
}
}
/**
* 【STEP 3】Claude API 議事録生成テスト
* → 文字起こしから議事録JSONが正しく生成されるか確認
*/
function test_3_generateMinutes() {
if (!TEST_MEETING_ID) {
Logger.log('❌ TEST_MEETING_ID が空です。');
return;
}
Logger.log('📡 tl;dv APIからデータ取得中...');
const meetingData = getMeetingData_(TEST_MEETING_ID);
const transcript = getTranscript_(TEST_MEETING_ID);
Logger.log('🤖 Claude APIで議事録生成中...');
try {
const minutesJson = generateMinutes_(meetingData, transcript);
Logger.log('\n✅ 議事録JSON生成成功!');
Logger.log('========================================');
Logger.log(`📌 タイトル: ${minutesJson.meeting_title}`);
Logger.log(`📅 日時: ${minutesJson.date}`);
Logger.log(`👥 参加者: ${(minutesJson.participants || []).map(p => `${p.name}(${p.role})`).join(', ')}`);
Logger.log(`\n📝 サマリ:\n${minutesJson.summary}`);
Logger.log(`\n📋 議題:`);
(minutesJson.agenda_items || []).forEach((a, i) => Logger.log(` ${i + 1}. ${a}`));
Logger.log(`\n✅ 決定事項 (${(minutesJson.decisions || []).length}件):`);
(minutesJson.decisions || []).forEach((d, i) => Logger.log(` ${i + 1}. ${d.content}`));
Logger.log(`\n📌 アクションアイテム (${(minutesJson.action_items || []).length}件):`);
(minutesJson.action_items || []).forEach((a, i) => Logger.log(` ${i + 1}. [${a.assignee}] ${a.task}`));
Logger.log(`\n💡 お客様の関心事項:`);
(minutesJson.client_concerns || []).forEach((c, i) => Logger.log(` ${i + 1}. ${c.concern}`));
Logger.log(`\n➡️ 次のステップ:`);
(minutesJson.next_steps || []).forEach((n, i) => Logger.log(` ${i + 1}. ${n.step}`));
Logger.log(`\n🔒 社内メモ:\n${minutesJson.internal_notes}`);
Logger.log('========================================');
// JSONをScript Propertiesに一時保存(次のテストで使用)
PropertiesService.getScriptProperties().setProperty(
'test_minutes_json', JSON.stringify(minutesJson)
);
PropertiesService.getScriptProperties().setProperty(
'test_meeting_data', JSON.stringify(meetingData)
);
Logger.log('\n✅ STEP 3 完了!次は test_4_createSlides を実行してください。');
} catch (error) {
Logger.log('❌ エラー: ' + error.toString());
Logger.log(error.stack);
}
}
/**
* 【STEP 4】Google Slides 議事録スライド生成テスト
* → スライドが正しく生成されPDF化できるか確認(メール送信はしない)
*/
function test_4_createSlides() {
const props = PropertiesService.getScriptProperties();
const minutesStr = props.getProperty('test_minutes_json');
if (!minutesStr) {
Logger.log('❌ test_3 を先に実行してください(議事録JSONが必要です)');
return;
}
const minutesJson = JSON.parse(minutesStr);
Logger.log('📊 Google Slides 議事録生成中...');
try {
const { slideId, pdfBlob } = createMinutesSlides_(minutesJson);
Logger.log('\n✅ スライド生成成功!');
Logger.log(` Slides URL: https://docs.google.com/presentation/d/${slideId}/edit`);
Logger.log(` PDF サイズ: ${(pdfBlob.getBytes().length / 1024).toFixed(1)} KB`);
// PDFもDriveに保存して確認できるようにする
const pdfFile = DriveApp.createFile(pdfBlob);
Logger.log(` PDF URL: ${pdfFile.getUrl()}`);
if (CONFIG.OUTPUT_FOLDER_ID) {
DriveApp.getFolderById(CONFIG.OUTPUT_FOLDER_ID).addFile(pdfFile);
DriveApp.getRootFolder().removeFile(pdfFile);
}
Logger.log('\n✅ STEP 4 完了!');
Logger.log('👆 上のURLを開いて、スライドとPDFの内容を確認してください。');
Logger.log('\n問題なければ test_5_fullPipeline でメール送信を含む全体テストができます。');
} catch (error) {
Logger.log('❌ エラー: ' + error.toString());
Logger.log(error.stack);
}
}
/**
* 【STEP 5】フルパイプラインテスト(メール送信含む)
* ⚠️ 実際にメールが送信されます!
* → 自分宛にテスト送信したい場合は testEmail を設定してください
*/
function test_5_fullPipeline() {
if (!TEST_MEETING_ID) {
Logger.log('❌ TEST_MEETING_ID が空です。');
return;
}
// ⚠️ テスト時は自分のメールアドレスに送信(顧客に誤送信しないため)
const testEmail = ''; // ← テスト送信先メールアドレスを入力(空なら顧客リストから検索)
Logger.log('🚀 フルパイプラインテスト開始...');
Logger.log('========================================');
try {
// Step 1
Logger.log('\n[1/6] tl;dv APIからデータ取得...');
const meetingData = getMeetingData_(TEST_MEETING_ID);
const transcript = getTranscript_(TEST_MEETING_ID);
Logger.log(` ✅ 文字起こし: ${transcript.length}文字`);
// Step 2
Logger.log('\n[2/6] Claude APIで議事録生成...');
const minutesJson = generateMinutes_(meetingData, transcript);
Logger.log(` ✅ タイトル: ${minutesJson.meeting_title}`);
Logger.log(` ✅ 決定事項: ${(minutesJson.decisions || []).length}件`);
Logger.log(` ✅ アクション: ${(minutesJson.action_items || []).length}件`);
// Step 3
Logger.log('\n[3/6] Google Slides生成 & PDF変換...');
const { slideId, pdfBlob } = createMinutesSlides_(minutesJson);
Logger.log(` ✅ Slides: https://docs.google.com/presentation/d/${slideId}/edit`);
Logger.log(` ✅ PDF: ${(pdfBlob.getBytes().length / 1024).toFixed(1)} KB`);
// Step 4
Logger.log('\n[4/6] 顧客情報 & 商談資料取得...');
let customerInfo;
if (testEmail) {
// テスト用: 自分のメールアドレスに送信
customerInfo = {
companyName: '【テスト送信】',
contactName: 'テストユーザー',
email: testEmail,
folderId: null,
rowIndex: null,
};
Logger.log(` ✅ テストモード: ${testEmail} に送信`);
} else {
customerInfo = findCustomer_(meetingData, minutesJson);
Logger.log(` ✅ 顧客: ${customerInfo?.companyName || '不明'} (${customerInfo?.email || 'N/A'})`);
}
const presentationBlob = getPresentationFile_(customerInfo);
Logger.log(` ✅ 商談資料: ${presentationBlob ? 'あり' : 'なし(議事録のみ送信)'}`);
// Step 5
Logger.log('\n[5/6] メール送信...');
if (customerInfo?.email) {
sendMinutesEmail_(customerInfo, minutesJson, pdfBlob, presentationBlob);
Logger.log(` ✅ 送信完了: ${customerInfo.email}`);
} else {
Logger.log(' ⚠️ メール送信スキップ(送信先なし)');
}
// Step 6
Logger.log('\n[6/6] 商談ログ記録...');
recordMeetingLog_(meetingData, minutesJson, slideId, customerInfo, 'テスト完了');
Logger.log(' ✅ ログ記録完了');
Logger.log('\n========================================');
Logger.log('🎉 フルパイプラインテスト完了!');
Logger.log('========================================');
} catch (error) {
Logger.log(`\n❌ エラー発生: ${error.toString()}`);
Logger.log(error.stack);
}
}
/**
* 【おまけ】特定のmeeting_idの文字起こし全文をログに表示
*/
function test_showFullTranscript() {
if (!TEST_MEETING_ID) {
Logger.log('❌ TEST_MEETING_ID が空です。');
return;
}
const transcript = getTranscript_(TEST_MEETING_ID);
Logger.log(`文字起こし全文 (${transcript.length}文字):\n`);
// GASのLoggerは文字数制限があるため分割出力
const chunkSize = 4000;
for (let i = 0; i < transcript.length; i += chunkSize) {
Logger.log(transcript.substring(i, i + chunkSize));
}
}
// ============================================================================
// 3. tl;dv API 連携
// ============================================================================
/**
* tl;dv APIから会議メタデータを取得
*/
function getMeetingData_(meetingId) {
const apiKey = getSecret_('TLDV_API_KEY');
// まずキャッシュからMeetingReadyのデータを確認
const cached = CacheService.getScriptCache().get('meeting_meta_' + meetingId);
// API からも取得(最新情報)
const response = callWithRetry_(() => {
return UrlFetchApp.fetch(`${CONFIG.TLDV_API_BASE}/meetings/${meetingId}`, {
method: 'get',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
muteHttpExceptions: true,
});
});
if (response.getResponseCode() !== 200) {
// APIがエラーの場合、キャッシュデータで代替
if (cached) {
logToSheet_('WARN', 'tl;dv API fallback', 'キャッシュデータを使用');
return JSON.parse(cached);
}
throw new Error(`tl;dv meetings API error: ${response.getResponseCode()} - ${response.getContentText()}`);
}
const apiData = JSON.parse(response.getContentText());
// キャッシュデータとマージ(MeetingReadyの参加者情報を補完)
if (cached) {
const cachedData = JSON.parse(cached);
if (!apiData.organizer && cachedData.organizer) apiData.organizer = cachedData.organizer;
if ((!apiData.invitees || apiData.invitees.length === 0) && cachedData.invitees) {
apiData.invitees = cachedData.invitees;
}
}
return apiData;
}
/**
* tl;dv APIから文字起こし全文を取得
*/
function getTranscript_(meetingId) {
const apiKey = getSecret_('TLDV_API_KEY');
const response = callWithRetry_(() => {
return UrlFetchApp.fetch(`${CONFIG.TLDV_API_BASE}/meetings/${meetingId}/transcript`, {
method: 'get',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
muteHttpExceptions: true,
});
});
if (response.getResponseCode() !== 200) {
throw new Error(`tl;dv transcript API error: ${response.getResponseCode()} - ${response.getContentText()}`);
}
const data = JSON.parse(response.getContentText());
// transcript APIのレスポンスを整形
// data が配列の場合: [{startTime, endTime, speaker, text}, ...]
if (Array.isArray(data)) {
return data.map(segment => `[${segment.speaker}] ${segment.text}`).join('\n');
}
// data.data が配列の場合
if (data.data && Array.isArray(data.data)) {
return data.data.map(segment => `[${segment.speaker}] ${segment.text}`).join('\n');
}
// その他の形式
return typeof data === 'string' ? data : JSON.stringify(data);
}
/**
* tl;dv APIからハイライト(AIノート)を取得
*/
function getHighlights_(meetingId) {
const apiKey = getSecret_('TLDV_API_KEY');
try {
const response = UrlFetchApp.fetch(`${CONFIG.TLDV_API_BASE}/meetings/${meetingId}/highlights`, {
method: 'get',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
muteHttpExceptions: true,
});
if (response.getResponseCode() === 200) {
return JSON.parse(response.getContentText());
}
} catch (e) {
logToSheet_('WARN', 'ハイライト取得スキップ', e.message);
}
return null;
}
// ============================================================================
// 4. CLAUDE API 連携(議事録生成)
// ============================================================================
/**
* Claude APIで文字起こしから構造化議事録JSONを生成
*/
function generateMinutes_(meetingData, transcript) {
const apiKey = getSecret_('CLAUDE_API_KEY');
const systemPrompt = `あなたはNEXT INNOVATION株式会社の優秀な議事録作成アシスタントです。
Zoomでの商談の文字起こしテキストから、構造化された議事録を作成してください。
以下のJSON形式で出力してください。JSON以外のテキストは一切出力しないでください。
{
"meeting_title": "会議タイトル(内容から推測)",
"date": "実施日時",
"duration_minutes": 推定所要時間(分),
"participants": [
{"name": "名前", "role": "役割(顧客/自社など)", "company": "所属会社"}
],
"summary": "全体サマリ(3-5文で簡潔に)",
"agenda_items": ["議題1", "議題2"],
"decisions": [
{"content": "決定事項の内容", "detail": "補足説明"}
],
"action_items": [
{"assignee": "担当者", "task": "タスク内容", "deadline": "期限(分かれば)"}
],
"client_concerns": [
{"concern": "懸念・関心事項", "detail": "詳細・背景"}
],
"next_steps": [
{"step": "次のステップ", "owner": "担当", "timing": "時期"}
],
"internal_notes": "社内向けメモ(お客様には共有しない。提案のポイントや温度感など)",
"key_quotes": ["印象的な発言1", "印象的な発言2"]
}
注意事項:
- 会議の核心を捉え、簡潔かつ正確に記載すること
- 決定事項とアクションアイテムは漏れなく抽出すること
- お客様の関心・懸念は丁寧に拾い上げること
- internal_notesは営業戦略的な視点で記載すること
- 不明確な情報は「確認中」と記載し、推測で埋めないこと
- 日本語で出力すること`;
const userMessage = `以下は商談の情報と文字起こしです。議事録JSONを生成してください。
【会議情報】
会議名: ${meetingData?.name || '不明'}
日時: ${meetingData?.happenedAt || '不明'}
主催者: ${meetingData?.organizer?.name || '不明'} (${meetingData?.organizer?.email || ''})
参加者: ${(meetingData?.invitees || []).map(i => `${i.name || ''} (${i.email || ''})`).join(', ') || '不明'}
【文字起こし】
${transcript}`;
const requestBody = {
model: CONFIG.CLAUDE_MODEL,
max_tokens: CONFIG.CLAUDE_MAX_TOKENS,
messages: [
{ role: 'user', content: userMessage },
],
system: systemPrompt,
};
const response = callWithRetry_(() => {
return UrlFetchApp.fetch(CONFIG.CLAUDE_API_URL, {
method: 'post',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
payload: JSON.stringify(requestBody),
muteHttpExceptions: true,
});
});
if (response.getResponseCode() !== 200) {
throw new Error(`Claude API error: ${response.getResponseCode()} - ${response.getContentText()}`);
}
const result = JSON.parse(response.getContentText());
const text = result.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('');
// JSONを安全にパース(```json ... ``` でラップされている場合に対応)
const cleanJson = text.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
try {
return JSON.parse(cleanJson);
} catch (parseError) {
logToSheet_('ERROR', 'JSON解析失敗', text.substring(0, 500));
throw new Error('Claude APIの出力がJSON形式ではありません: ' + parseError.message);
}
}
// ============================================================================
// 5. GOOGLE SLIDES 議事録生成
// ============================================================================
/**
* Google Slidesテンプレートから議事録を生成し、PDFに変換
*/
function createMinutesSlides_(minutesJson) {
const templateId = CONFIG.SLIDES_TEMPLATE_ID || getSecret_('SLIDES_TEMPLATE_ID');
if (!templateId) {
// テンプレートがない場合は自動生成
logToSheet_('INFO', 'テンプレート自動生成', 'テンプレートIDが未設定のため新規作成');
return createMinutesSlidesFromScratch_(minutesJson);
}
// テンプレートをコピー
const title = `議事録_${minutesJson.meeting_title || '商談'}_${Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd')}`;
const copyFile = DriveApp.getFileById(templateId).makeCopy(title);
if (CONFIG.OUTPUT_FOLDER_ID) {
DriveApp.getFolderById(CONFIG.OUTPUT_FOLDER_ID).addFile(copyFile);
DriveApp.getRootFolder().removeFile(copyFile);
}
const slideId = copyFile.getId();
const presentation = SlidesApp.openById(slideId);
// プレースホルダーの置換
const replacements = {
'{{TITLE}}': minutesJson.meeting_title || '商談議事録',
'{{DATE}}': minutesJson.date || Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd'),
'{{PARTICIPANTS}}': (minutesJson.participants || []).map(p => `${p.name}(${p.company || ''})`).join('、'),
'{{SUMMARY}}': minutesJson.summary || '',
'{{AGENDA_ITEMS}}': (minutesJson.agenda_items || []).map((item, i) => `${i + 1}. ${item}`).join('\n'),
'{{DECISIONS}}': (minutesJson.decisions || []).map((d, i) => `${i + 1}. ${d.content}${d.detail ? '\n → ' + d.detail : ''}`).join('\n'),
'{{ACTION_ITEMS}}': (minutesJson.action_items || []).map((a, i) => `${i + 1}. [${a.assignee || '未定'}] ${a.task}${a.deadline ? '(期限: ' + a.deadline + ')' : ''}`).join('\n'),
'{{CLIENT_CONCERNS}}': (minutesJson.client_concerns || []).map((c, i) => `${i + 1}. ${c.concern}${c.detail ? '\n → ' + c.detail : ''}`).join('\n'),
'{{NEXT_STEPS}}': (minutesJson.next_steps || []).map((n, i) => `${i + 1}. ${n.step}${n.owner ? '(' + n.owner + ')' : ''}${n.timing ? ' - ' + n.timing : ''}`).join('\n'),
};
const slides = presentation.getSlides();
slides.forEach(slide => {
Object.entries(replacements).forEach(([placeholder, value]) => {
slide.replaceAllText(placeholder, value || '');
});
});
presentation.saveAndClose();
// PDF変換
const pdfBlob = exportSlideToPdf_(slideId);
return { slideId, pdfBlob };
}
/**
* テンプレートなしで議事録スライドを新規作成
* ※ テンプレートID未設定時のフォールバック
*/
function createMinutesSlidesFromScratch_(minutesJson) {
const title = `議事録_${minutesJson.meeting_title || '商談'}_${Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd')}`;
const presentation = SlidesApp.create(title);
const slideId = presentation.getId();
// NEXT INNOVATIONブランドカラー
const BRAND_BLUE = '#4BA3D8';
const BRAND_DARK = '#2E87C8';
const WHITE = '#FFFFFF';
const DARK_TEXT = '#1D1D1D';
const GRAY = '#737373';
// --- Slide 1: 表紙 ---
const slide1 = presentation.getSlides()[0];
slide1.getBackground().setSolidFill(BRAND_BLUE);
// 既存の要素を削除
slide1.getPageElements().forEach(el => el.remove());
// タイトル
const titleBox = slide1.insertTextBox(minutesJson.meeting_title || '商談議事録', 50, 120, 620, 80);
const titleText = titleBox.getText();
titleText.getTextStyle().setFontSize(32).setBold(true).setForegroundColor(WHITE).setFontFamily('Arial');
titleText.getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.CENTER);
// 議事録ラベル
const labelBox = slide1.insertTextBox('Meeting Minutes', 50, 210, 620, 40);
const labelText = labelBox.getText();
labelText.getTextStyle().setFontSize(18).setForegroundColor(WHITE).setFontFamily('Arial');
labelText.getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.CENTER);
// 日付・参加者
const participants = (minutesJson.participants || []).map(p => p.name).join('、');
const infoText = `${minutesJson.date || ''}\n${participants}`;
const infoBox = slide1.insertTextBox(infoText, 50, 310, 620, 60);
const infoStyle = infoBox.getText();
infoStyle.getTextStyle().setFontSize(12).setForegroundColor(WHITE).setFontFamily('Arial');
infoStyle.getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.CENTER);
// NEXT INNOVATION
const companyBox = slide1.insertTextBox('NEXT INNOVATION株式会社', 50, 400, 620, 30);
const companyText = companyBox.getText();
companyText.getTextStyle().setFontSize(10).setForegroundColor(WHITE).setFontFamily('Arial');
companyText.getParagraphStyle().setParagraphAlignment(SlidesApp.ParagraphAlignment.CENTER);
// --- Slide 2: サマリ ---
const slide2 = presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK);
slide2.getBackground().setSolidFill(WHITE);
const s2Title = slide2.insertTextBox('サマリ', 40, 20, 640, 50);
s2Title.getText().getTextStyle().setFontSize(24).setBold(true).setForegroundColor(BRAND_BLUE).setFontFamily('Arial');
const agendaText = (minutesJson.agenda_items || []).map((a, i) => `${i + 1}. ${a}`).join('\n');
const summaryContent = `${minutesJson.summary || ''}\n\n【議題】\n${agendaText}`;
const s2Body = slide2.insertTextBox(summaryContent, 40, 80, 640, 320);
s2Body.getText().getTextStyle().setFontSize(13).setForegroundColor(DARK_TEXT).setFontFamily('Arial');
// --- Slide 3: 決定事項 ---
const slide3 = presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK);
slide3.getBackground().setSolidFill(WHITE);
const s3Title = slide3.insertTextBox('決定事項', 40, 20, 640, 50);
s3Title.getText().getTextStyle().setFontSize(24).setBold(true).setForegroundColor(BRAND_BLUE).setFontFamily('Arial');
const decisionsText = (minutesJson.decisions || []).map((d, i) =>
`${i + 1}. ${d.content}${d.detail ? '\n → ' + d.detail : ''}`
).join('\n\n');
const s3Body = slide3.insertTextBox(decisionsText || 'なし', 40, 80, 640, 320);
s3Body.getText().getTextStyle().setFontSize(13).setForegroundColor(DARK_TEXT).setFontFamily('Arial');
// --- Slide 4: アクションアイテム ---
const slide4 = presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK);
slide4.getBackground().setSolidFill(WHITE);
const s4Title = slide4.insertTextBox('アクションアイテム', 40, 20, 640, 50);
s4Title.getText().getTextStyle().setFontSize(24).setBold(true).setForegroundColor(BRAND_BLUE).setFontFamily('Arial');
const actionsText = (minutesJson.action_items || []).map((a, i) =>
`${i + 1}. [${a.assignee || '未定'}] ${a.task}${a.deadline ? '\n 期限: ' + a.deadline : ''}`
).join('\n\n');
const s4Body = slide4.insertTextBox(actionsText || 'なし', 40, 80, 640, 320);
s4Body.getText().getTextStyle().setFontSize(13).setForegroundColor(DARK_TEXT).setFontFamily('Arial');
// --- Slide 5: お客様の関心事項 ---
const slide5 = presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK);
slide5.getBackground().setSolidFill(WHITE);
const s5Title = slide5.insertTextBox('お客様の関心事項', 40, 20, 640, 50);
s5Title.getText().getTextStyle().setFontSize(24).setBold(true).setForegroundColor(BRAND_BLUE).setFontFamily('Arial');
const concernsText = (minutesJson.client_concerns || []).map((c, i) =>
`${i + 1}. ${c.concern}${c.detail ? '\n → ' + c.detail : ''}`
).join('\n\n');
const s5Body = slide5.insertTextBox(concernsText || 'なし', 40, 80, 640, 320);
s5Body.getText().getTextStyle().setFontSize(13).setForegroundColor(DARK_TEXT).setFontFamily('Arial');
// --- Slide 6: 次のステップ ---
const slide6 = presentation.appendSlide(SlidesApp.PredefinedLayout.BLANK);
slide6.getBackground().setSolidFill(WHITE);
const s6Title = slide6.insertTextBox('次のステップ', 40, 20, 640, 50);
s6Title.getText().getTextStyle().setFontSize(24).setBold(true).setForegroundColor(BRAND_BLUE).setFontFamily('Arial');
const stepsText = (minutesJson.next_steps || []).map((n, i) =>
`${i + 1}. ${n.step}${n.owner ? '(' + n.owner + ')' : ''}${n.timing ? '\n 時期: ' + n.timing : ''}`
).join('\n\n');
const s6Body = slide6.insertTextBox(stepsText || '確認中', 40, 80, 640, 320);
s6Body.getText().getTextStyle().setFontSize(13).setForegroundColor(DARK_TEXT).setFontFamily('Arial');
presentation.saveAndClose();
// 保存先フォルダに移動
if (CONFIG.OUTPUT_FOLDER_ID) {
const file = DriveApp.getFileById(slideId);
DriveApp.getFolderById(CONFIG.OUTPUT_FOLDER_ID).addFile(file);
DriveApp.getRootFolder().removeFile(file);
}
// PDF変換
const pdfBlob = exportSlideToPdf_(slideId);
return { slideId, pdfBlob };
}
/**
* Google SlidesをPDFにエクスポート
*/
function exportSlideToPdf_(slideId) {
const url = `https://docs.google.com/presentation/d/${slideId}/export/pdf`;
const response = UrlFetchApp.fetch(url, {
headers: {
'Authorization': 'Bearer ' + ScriptApp.getOAuthToken(),
},
muteHttpExceptions: true,
});
if (response.getResponseCode() !== 200) {
throw new Error(`PDF変換エラー: ${response.getResponseCode()}`);
}
return response.getBlob().setName(`議事録_${Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd')}.pdf`);
}
// ============================================================================
// 6. メール送信
// ============================================================================
/**
* 顧客リストから該当する顧客情報を検索
*/
function findCustomer_(meetingData, minutesJson) {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = ss.getSheetByName(CONFIG.SHEET_CUSTOMERS);
if (!sheet) {
logToSheet_('WARN', '顧客リストシートなし', CONFIG.SHEET_CUSTOMERS);
return null;
}
const data = sheet.getDataRange().getValues();
const headers = data[0]; // ヘッダー行
// 参加者のメールアドレスで顧客を検索
const inviteeEmails = (meetingData?.invitees || []).map(i => i.email?.toLowerCase()).filter(Boolean);
const organizerEmail = meetingData?.organizer?.email?.toLowerCase();
// 顧客名でも検索(Claudeが抽出した参加者情報)
const participantCompanies = (minutesJson?.participants || [])
.filter(p => p.role === '顧客' || p.role === 'お客様' || p.role === 'クライアント')
.map(p => p.company)
.filter(Boolean);
for (let i = 1; i < data.length; i++) {
const row = data[i];
const companyName = row[0]; // A列: 会社名
const contactName = row[1]; // B列: 担当者名
const email = row[2]; // C列: メールアドレス
const folderId = row[3]; // D列: 商談フォルダID
// メールアドレスで一致
if (email && inviteeEmails.includes(email.toLowerCase())) {
return { companyName, contactName, email, folderId, rowIndex: i + 1 };
}
// 会社名で一致
if (companyName && participantCompanies.some(c => c.includes(companyName) || companyName.includes(c))) {
return { companyName, contactName, email, folderId, rowIndex: i + 1 };
}
}
// 見つからない場合、主催者でない参加者の情報を返す(フォールバック)
if (inviteeEmails.length > 0) {
const firstInvitee = meetingData.invitees[0];
logToSheet_('WARN', '顧客リスト不一致', `${firstInvitee.email}でフォールバック`);
return {
companyName: '(顧客リスト未登録)',
contactName: firstInvitee.name || '',
email: firstInvitee.email,
folderId: null,
rowIndex: null,
};
}
logToSheet_('WARN', '送信先特定不可', '顧客情報が見つかりません');
return null;
}
/**
* Google Driveから商談プレゼン資料を取得
*/
function getPresentationFile_(customerInfo) {
if (!customerInfo?.folderId) return null;
try {
const folder = DriveApp.getFolderById(customerInfo.folderId);
const files = folder.getFiles();
// 最新のプレゼン資料を取得(pptx, pdf, slides)
let latestFile = null;
let latestDate = new Date(0);
while (files.hasNext()) {
const file = files.next();
const mimeType = file.getMimeType();
const isPresentation = [
'application/vnd.google-apps.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/pdf',
].includes(mimeType);
if (isPresentation && file.getLastUpdated() > latestDate) {
latestFile = file;
latestDate = file.getLastUpdated();
}
}
if (latestFile) {
// Google Slidesの場合はPDFに変換
if (latestFile.getMimeType() === 'application/vnd.google-apps.presentation') {
return exportSlideToPdf_(latestFile.getId()).setName(`商談資料_${customerInfo.companyName}.pdf`);
}
return latestFile.getBlob();
}
} catch (e) {
logToSheet_('WARN', '商談資料取得失敗', e.message);
}
return null;
}
/**
* 議事録メールを送信
*/
function sendMinutesEmail_(customerInfo, minutesJson, pdfBlob, presentationBlob) {
if (!customerInfo?.email) {
logToSheet_('WARN', 'メール送信スキップ', '送信先メールアドレスなし');
return;
}
const dateStr = minutesJson.date || Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd');
const subject = `【議事録】${minutesJson.meeting_title || '商談'}(${dateStr})- NEXT INNOVATION`;
// 次のステップをテキストに整形
const nextStepsText = (minutesJson.next_steps || [])
.map((n, i) => ` ${i + 1}. ${n.step}${n.owner ? '(担当: ' + n.owner + ')' : ''}${n.timing ? ' - ' + n.timing : ''}`)
.join('\n');
// 決定事項をテキストに整形
const decisionsText = (minutesJson.decisions || [])
.map((d, i) => ` ${i + 1}. ${d.content}`)
.join('\n');
const body = `${customerInfo.contactName || 'ご担当者'} 様
いつもお世話になっております。
NEXT INNOVATION株式会社です。
本日はお忙しい中、お打ち合わせのお時間をいただき、誠にありがとうございました。
本日の打ち合わせ内容を議事録としてまとめましたので、添付にてお送りいたします。
内容にお気づきの点やご不明な点がございましたら、お気軽にお知らせください。
━━━━━━━━━━━━━━━━━━━━━━
■ 打ち合わせ概要
━━━━━━━━━━━━━━━━━━━━━━
${minutesJson.summary || ''}
${decisionsText ? `■ 主な決定事項\n${decisionsText}\n` : ''}
${nextStepsText ? `■ 次のステップ\n${nextStepsText}\n` : ''}
━━━━━━━━━━━━━━━━━━━━━━
詳細は添付の議事録資料をご確認ください。
引き続きどうぞよろしくお願いいたします。
──────────────────────────
NEXT INNOVATION株式会社
──────────────────────────`;
// 添付ファイルの準備
const attachments = [];
if (pdfBlob) attachments.push(pdfBlob);
if (presentationBlob) attachments.push(presentationBlob);
const options = {
name: CONFIG.SENDER_NAME,
attachments: attachments,
htmlBody: null, // プレーンテキストのみ
};
if (CONFIG.CC_EMAIL) {
options.cc = CONFIG.CC_EMAIL;
}
GmailApp.sendEmail(customerInfo.email, subject, body, options);
logToSheet_('INFO', 'メール送信完了', `To: ${customerInfo.email}, 件名: ${subject}`);
}
// ============================================================================
// 7. ログ記録
// ============================================================================
/**
* 商談ログをスプレッドシートに記録
*/
function recordMeetingLog_(meetingData, minutesJson, slideId, customerInfo, status) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
let sheet = ss.getSheetByName(CONFIG.SHEET_LOG);
if (!sheet) {
sheet = ss.insertSheet(CONFIG.SHEET_LOG);
sheet.appendRow([
'日時', '会議タイトル', '顧客企業', '参加者',
'決定事項数', 'アクション数', '議事録URL',
'メール送信先', 'ステータス', '社内メモ', '処理日時',
]);
// ヘッダー行の書式設定
sheet.getRange(1, 1, 1, 11).setFontWeight('bold').setBackground('#4BA3D8').setFontColor('#FFFFFF');
sheet.setFrozenRows(1);
}
const slideUrl = slideId ? `https://docs.google.com/presentation/d/${slideId}/edit` : '';
sheet.appendRow([
minutesJson?.date || meetingData?.happenedAt || '',
minutesJson?.meeting_title || meetingData?.name || '',
customerInfo?.companyName || '',
(minutesJson?.participants || []).map(p => p.name).join(', '),
(minutesJson?.decisions || []).length,
(minutesJson?.action_items || []).length,
slideUrl,
customerInfo?.email || '',
status,
minutesJson?.internal_notes || '',
Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss'),
]);
// 顧客リストの「最終商談日」も更新
if (customerInfo?.rowIndex) {
const custSheet = ss.getSheetByName(CONFIG.SHEET_CUSTOMERS);
if (custSheet) {
custSheet.getRange(customerInfo.rowIndex, 5).setValue(new Date()); // E列: 最終商談日
}
}
} catch (e) {
logToSheet_('ERROR', 'ログ記録失敗', e.message);
}
}
// ============================================================================
// 8. ユーティリティ関数
// ============================================================================
/**
* Script PropertiesからAPIキー等の機密情報を取得
*/
function getSecret_(key) {
const value = PropertiesService.getScriptProperties().getProperty(key);
if (!value) {
throw new Error(`Script Property "${key}" が設定されていません。setupSecrets() を実行してください。`);
}
return value;
}
/**
* リトライ付きHTTPリクエスト
*/
function callWithRetry_(fetchFn) {
let lastError;
for (let i = 0; i < CONFIG.MAX_RETRIES; i++) {
try {
const response = fetchFn();
const code = response.getResponseCode();
// 成功 or クライアントエラー(リトライしても無駄)
if (code < 500) return response;
lastError = new Error(`HTTP ${code}: ${response.getContentText().substring(0, 200)}`);
logToSheet_('WARN', `リトライ ${i + 1}/${CONFIG.MAX_RETRIES}`, lastError.message);
} catch (e) {
lastError = e;
logToSheet_('WARN', `リトライ ${i + 1}/${CONFIG.MAX_RETRIES}`, e.message);
}
if (i < CONFIG.MAX_RETRIES - 1) {
Utilities.sleep(CONFIG.RETRY_DELAY_MS * (i + 1)); // 指数バックオフ
}
}
throw lastError;
}
/**
* 処理ログをスプレッドシートに記録
*/
function logToSheet_(level, action, detail) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
let sheet = ss.getSheetByName('システムログ');
if (!sheet) {
sheet = ss.insertSheet('システムログ');
sheet.appendRow(['日時', 'レベル', 'アクション', '詳細']);
sheet.getRange(1, 1, 1, 4).setFontWeight('bold').setBackground('#333333').setFontColor('#FFFFFF');
sheet.setFrozenRows(1);
}
sheet.appendRow([
Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss'),
level,
action,
(detail || '').toString().substring(0, 1000),
]);
// ログが1000行を超えたら古いものを削除
if (sheet.getLastRow() > 1000) {
sheet.deleteRows(2, sheet.getLastRow() - 500);
}
} catch (e) {
// ログ記録自体が失敗した場合はconsoleに出力
console.error(`[${level}] ${action}: ${detail}`);
}
}
/**
* 管理者にエラー通知メール送信
*/
function notifyAdmin_(subject, body) {
if (!CONFIG.ADMIN_EMAIL) return;
try {
GmailApp.sendEmail(
CONFIG.ADMIN_EMAIL,
`[自動議事録システム] ${subject}`,
`${body}\n\n---\n処理日時: ${Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss')}`,
{ name: 'NEXT INNOVATION 自動議事録システム' }
);
} catch (e) {
console.error('管理者通知失敗: ' + e.message);
}
}
/**
* 使い終わったトリガーを削除
*/
function cleanupTrigger_(triggerId) {
ScriptApp.getProjectTriggers().forEach(trigger => {
if (trigger.getUniqueId() === triggerId) {
ScriptApp.deleteTrigger(trigger);
}
});
}
// ============================================================================
// 9. 初期セットアップ用関数
// ============================================================================
/**
* 初回セットアップ: Script Propertiesにシークレットを設定
* ※ GASエディタから手動で1回だけ実行
*/
function setupSecrets() {
const ui = SpreadsheetApp.getUi();
const tldvKey = ui.prompt('tl;dv APIキーを入力してください:').getResponseText();
const claudeKey = ui.prompt('Claude APIキーを入力してください:').getResponseText();
const props = PropertiesService.getScriptProperties();
if (tldvKey) props.setProperty('TLDV_API_KEY', tldvKey);
if (claudeKey) props.setProperty('CLAUDE_API_KEY', claudeKey);
ui.alert('APIキーを設定しました。');
}
/**
* 顧客リストシートの初期テンプレートを作成
*/
function setupCustomerSheet() {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
let sheet = ss.getSheetByName(CONFIG.SHEET_CUSTOMERS);
if (!sheet) {
sheet = ss.insertSheet(CONFIG.SHEET_CUSTOMERS);
}
// ヘッダー設定
const headers = ['会社名', '担当者名', 'メールアドレス', '商談フォルダID', '最終商談日'];
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
sheet.getRange(1, 1, 1, headers.length)
.setFontWeight('bold')
.setBackground('#4BA3D8')
.setFontColor('#FFFFFF');
sheet.setFrozenRows(1);
// 列幅調整
sheet.setColumnWidth(1, 200); // 会社名
sheet.setColumnWidth(2, 150); // 担当者名
sheet.setColumnWidth(3, 250); // メールアドレス
sheet.setColumnWidth(4, 300); // フォルダID
sheet.setColumnWidth(5, 120); // 最終商談日
// サンプルデータ
sheet.getRange(2, 1, 1, 5).setValues([[
'株式会社サンプル', '田中 太郎', 'tanaka@example.com', '(DriveフォルダIDをここに)', '',
]]);
SpreadsheetApp.getUi().alert('顧客リストシートを作成しました。\n顧客情報を登録してください。');
}
/**
* システムの動作確認テスト
* ※ APIキーの設定確認と疎通テスト
*/
function testConnections() {
const results = [];
// 1. tl;dv API テスト
try {
const tldvKey = getSecret_('TLDV_API_KEY');
const response = UrlFetchApp.fetch(`${CONFIG.TLDV_API_BASE}/meetings?limit=1`, {
headers: { 'Content-Type': 'application/json', 'x-api-key': tldvKey },
muteHttpExceptions: true,
});
results.push(`✅ tl;dv API: ${response.getResponseCode() === 200 ? '接続OK' : 'エラー ' + response.getResponseCode()}`);
} catch (e) {
results.push(`❌ tl;dv API: ${e.message}`);
}
// 2. Claude API テスト
try {
const claudeKey = getSecret_('CLAUDE_API_KEY');
const response = UrlFetchApp.fetch(CONFIG.CLAUDE_API_URL, {
method: 'post',
headers: {
'Content-Type': 'application/json',
'x-api-key': claudeKey,
'anthropic-version': '2023-06-01',
},
payload: JSON.stringify({
model: CONFIG.CLAUDE_MODEL,
max_tokens: 100,
messages: [{ role: 'user', content: 'テスト。「OK」とだけ返答してください。' }],
}),
muteHttpExceptions: true,
});
results.push(`✅ Claude API: ${response.getResponseCode() === 200 ? '接続OK' : 'エラー ' + response.getResponseCode()}`);
} catch (e) {
results.push(`❌ Claude API: ${e.message}`);
}
// 3. スプレッドシート テスト
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
results.push(`✅ スプレッドシート: "${ss.getName()}" に接続OK`);
} catch (e) {
results.push(`❌ スプレッドシート: ${e.message}`);
}
// 4. Google Slides テスト
try {
if (CONFIG.SLIDES_TEMPLATE_ID) {
const file = DriveApp.getFileById(CONFIG.SLIDES_TEMPLATE_ID);
results.push(`✅ Slidesテンプレート: "${file.getName()}" 確認OK`);
} else {
results.push(`⚠️ Slidesテンプレート: 未設定(自動生成モードで動作)`);
}
} catch (e) {
results.push(`❌ Slidesテンプレート: ${e.message}`);
}
const message = '=== 接続テスト結果 ===\n\n' + results.join('\n');
Logger.log(message);
try {
SpreadsheetApp.getUi().alert(message);
} catch (e) {
// UI がない場合(トリガー実行時など)
Logger.log(message);
}
}
/**
* 不要なトリガーを一括削除(メンテナンス用)
*/
function cleanupAllTriggers() {
const triggers = ScriptApp.getProjectTriggers();
let count = 0;
triggers.forEach(trigger => {
if (trigger.getHandlerFunction() === 'processMeeting_') {
ScriptApp.deleteTrigger(trigger);
count++;
}
});
Logger.log(`${count}個のトリガーを削除しました`);
}
※ご注意: コードをコピペして使うだけでも動作しますが、本記事で解説したように、Claudeとの対話を通じてご自身の業務に合わせてカスタマイズしていくことで、AI活用のスキルは飛躍的に向上します。ぜひ、まずはご自身でClaudeに話しかけるところから始めてみることを強くお勧めします。
まとめ:AIと共に、次のステージの働き方へ
本記事では、ClaudeとGASを使って、商談後の議事録作成からメール送信までを完全自動化するシステムの構築方法を解説しました。エラー対応の過程も含めてご覧いただいたことで、AIは単に作業をこなすだけのツールではなく、対話を通じて共に問題を解決し、創造的な仕事を進める「パートナー」になり得ることを感じていただけたのではないでしょうか。
今回構築したシステムは、ほんの一例に過ぎません。例えば、
- PowerPointのテンプレートを読み込ませて、自社デザインの資料を生成させる
- SalesforceやHubSpotなどのCRMツールと連携させ、商談記録を自動で更新する
- SlackやMicrosoft Teamsに、議事録完成を通知する
など、Claudeとの対話次第で、カスタマイズの可能性は無限に広がります。
面倒な定型業務はAIという優秀なアシスタントに任せ、あなたはもっと創造的で、人間にしかできない本質的な仕事に集中する。そんな未来の働き方を、ぜひ今日から始めてみてください。

コメント