GASでカレンダーの予定から
AIで週報を自動作成する方法
金曜の夕方に週報を書く時間を、CalendarApp(GASからGoogleカレンダーを読み書きする標準機能)とClaude API(Anthropic社の生成AI)で削ります。 1週間分の予定を集め、時間の使い方を集計し、週報の下書きをメールで受け取るまでを、そのまま動くコードで解説します。
Table of Contents
週報に時間がかかるのは「思い出す」からだ
結論から言うと、週報の下書きはGAS(Google Apps Script。Googleサービスを操作する無料のプログラム実行環境)と AIで自動生成できます。週報が面倒なのは、書くこと自体ではありません。月曜に何をしていたかを金曜に思い出す作業が重いのです。その記録は、すでにカレンダーに残っています。
作るのは、今週の予定を集めて集計し、AIに週報の下書きを書かせて自分宛にメールする自動処理です。 毎週金曜の夕方に動かせば、受信箱を開いた時点で下書きができています。処理は3段に分かれます。
集める
CalendarAppで1週間分の予定を取得。辞退した予定と終日予定を仕分ける
数える
合計時間・打ち合わせ時間・単独作業時間を集計。AIに計算させない
書かせる
予定一覧と集計値を渡し、決まった見出しで下書きを生成させる
重要なのは2段目です。時間の集計はコードでやり、AIには文章化だけを任せます。AIに足し算をさせると、もっともらしい数字を書いてしまうことがあるためです。数字はプログラムが出し、 AIはそれを日本語に直すだけ——この分担が、信用できる週報の条件です。
準備:週の範囲を出し、APIキーを保管する
まずスタンドアロンのスクリプトを1つ作ります(script.google.comから新規作成)。スプレッドシートに紐づける必要はありません。 Anthropic のAPIキーは、プロジェクトの設定 → スクリプト プロパティにANTHROPIC_API_KEYという名前で保存します。コードに直接書くと、共有した相手にキーが渡ってしまいます。
次に、今週の月曜0時から翌週月曜0時までの範囲を計算します。getDay()は日曜を0、月曜を1として返すため、そのまま引くと日曜だけ翌週扱いになります。(getDay() + 6) % 7とすることで、月曜を起点に正しく戻せます。
// 今週の月曜0時から日曜24時までの範囲を作る
// baseDate を渡さなければ実行日を基準にする
function getWeekRange(baseDate) {
const base = baseDate || new Date();
const monday = new Date(base);
// getDay() は 日=0, 月=1 ... 土=6。日曜は前週の月曜まで6日戻す
const offset = (base.getDay() + 6) % 7;
monday.setDate(base.getDate() - offset);
monday.setHours(0, 0, 0, 0);
const nextMonday = new Date(monday);
nextMonday.setDate(monday.getDate() + 7); // 終端は翌週月曜0時(この時刻は含まれない)
return { start: monday, end: nextMonday };
}
// APIキーはコードに直書きせず、スクリプトプロパティから読み出す
function getApiKey() {
const key = PropertiesService.getScriptProperties()
.getProperty("ANTHROPIC_API_KEY");
if (!key) throw new Error("APIキーが未設定です。");
return key;
}終端を「日曜23:59:59」ではなく「翌週月曜0時」にしているのは、境界の取りこぼしを防ぐためです。 GASのgetEvents(start, end)は終端時刻を含まないため、この書き方なら日曜の深夜まで確実に拾えます。
カレンダーから予定を集める(辞退・終日の扱い)
予定の取得自体は1行です。難しいのは、そのままでは週報の材料にならない予定が混ざっている点です。辞退した会議は実績ではありませんし、「夏季休暇」のような終日予定を所要時間に足すと、1日8時間ではなく24時間として数えられてしまいます。
// 1週間分の予定を集め、AIに渡しやすい形に整える
function collectEvents(start, end) {
const events = CalendarApp.getDefaultCalendar().getEvents(start, end);
const tz = Session.getScriptTimeZone();
const meetings = [];
const allDay = [];
events.forEach(function (ev) {
// 自分が辞退した予定は実績ではないので除外する
if (ev.getMyStatus() === CalendarApp.GuestStatus.NO) return;
const title = ev.getTitle();
if (!title) return;
if (ev.isAllDayEvent()) {
// 終日予定(休暇・出張など)は所要時間の集計に混ぜない
allDay.push({
date: Utilities.formatDate(ev.getStartTime(), tz, "MM/dd"),
title: title
});
return;
}
const from = ev.getStartTime();
const to = ev.getEndTime();
const minutes = Math.round((to.getTime() - from.getTime()) / 60000);
meetings.push({
date: Utilities.formatDate(from, tz, "MM/dd(E)"),
time: Utilities.formatDate(from, tz, "HH:mm") + "-" +
Utilities.formatDate(to, tz, "HH:mm"),
minutes: minutes,
title: title,
// 説明欄のメモが週報の質を決める。長すぎる場合は先頭だけ渡す
note: (ev.getDescription() || "").trim().slice(0, 300),
guests: ev.getGuestList().length
});
});
return { meetings: meetings, allDay: allDay };
}getMyStatus()は自分の出欠ステータスを返します。CalendarApp.GuestStatus.NO(辞退)だけを弾いているのは、未回答のまま出席した会議も多いためです。 運用に合わせてYESのみを残す条件に変えても構いません。
説明欄(getDescription())を300文字で切っているのは、会議URLや長い定型文が本文を埋め尽くすのを防ぐためです。 ここに一言「〇〇の仕様を確定」とメモしておくと、週報の質が跳ね上がります。 AIが知り得るのは、カレンダーに書いてあることだけです。
時間の使い方を数字で集計する
合計時間、打ち合わせ時間、単独作業時間の3つを、コード側で計算します。 参加者が2名以上の予定を「打ち合わせ」、自分だけの予定を「作業ブロック」とみなす、という単純な線引きです。 厳密ではありませんが、週報に載せる粒度としては十分に機能します。
// 予定を「見出しごと」に集計して、時間の使い方を数字で出す
function summarizeHours(meetings) {
const totalMinutes = meetings.reduce(function (sum, m) {
return sum + m.minutes;
}, 0);
// 参加者2人以上を「打ち合わせ」、1人だけの予定を「作業ブロック」とみなす
const withOthers = meetings.filter(function (m) { return m.guests >= 2; });
const soloMinutes = totalMinutes - withOthers.reduce(function (sum, m) {
return sum + m.minutes;
}, 0);
return {
count: meetings.length,
totalHours: (totalMinutes / 60).toFixed(1),
meetingHours: ((totalMinutes - soloMinutes) / 60).toFixed(1),
soloHours: (soloMinutes / 60).toFixed(1)
};
}
// AIに渡すテキストを組み立てる(予定を1行1件に整形する)
function buildEventText(data) {
const lines = data.meetings.map(function (m) {
const note = m.note ? " / メモ: " + m.note : "";
return "・" + m.date + " " + m.time + "(" + m.minutes + "分, 参加" +
m.guests + "名) " + m.title + note;
});
if (data.allDay.length > 0) {
lines.push("【終日予定】");
data.allDay.forEach(function (a) {
lines.push("・" + a.date + " " + a.title);
});
}
return lines.join("\n");
}予定は1行1件のテキストに整形してからAIへ渡します。JSONで渡すより、人が読める形のほうが要約の精度が上がる傾向があります。日付・時刻・所要時間・参加人数・タイトル・メモを1行に並べれば、AIは前後関係を読み取れます。
集計値をAIに計算させないのは、この記事で最も強調したい点です。「合計18.5時間」という数字は、 足し算の結果でなければ意味がありません。AIは自然言語の処理は得意ですが、数十件の分数の足し算を確実にこなす保証はありません。reduce()で出した確実な数字を、プロンプトに埋め込んで渡します。
AIに週報の下書きを書かせるプロンプト
プロンプト(AIへの指示文)で決めるのは3つです。出力の見出しを固定すること、文字数の上限を切ること、 そして「カレンダーに無い活動は書かない」と禁止事項を書くことです。3つ目が抜けると、AIは予定名から業務内容を想像して、それらしい成果を作文します。
// 予定一覧と集計値をAIに渡し、週報の下書きを作らせる
function generateReport(eventText, stats) {
const url = "https://api.anthropic.com/v1/messages";
const prompt =
"あなたは会社員の週報を清書するアシスタントです。\n" +
"以下のカレンダー実績から、上長に提出する週報の下書きを作ってください。\n\n" +
"出力形式(この見出しをそのまま使う):\n" +
"【今週の主な活動】\n" +
"・3〜5件の箇条書き。似た予定はまとめる\n" +
"【時間の使い方】\n" +
"・与えられた集計値を使って2行以内で述べる\n" +
"【来週に向けて】\n" +
"・カレンダー上の未完了・継続案件から2件以内\n\n" +
"ルール:\n" +
"・カレンダーに無い活動は書かない。予定名から業務内容を推測して膨らませない。\n" +
"・成果や進捗は、メモ欄に書かれている場合のみ書く。無ければ活動事実だけを書く。\n" +
"・「〜と思われる」などの推測表現は使わない。\n" +
"・敬体(です・ます)で、全体を400文字以内にする。\n\n" +
"集計値: 予定" + stats.count + "件 / 合計" + stats.totalHours + "時間" +
"(打ち合わせ" + stats.meetingHours + "時間, 単独作業" + stats.soloHours + "時間)\n\n" +
"カレンダー実績:\n" + eventText;
const payload = {
model: "claude-haiku-4-5",
max_tokens: 1500,
messages: [{ role: "user", content: prompt }]
};
const res = UrlFetchApp.fetch(url, {
method: "post",
contentType: "application/json",
headers: {
"x-api-key": getApiKey(),
"anthropic-version": "2023-06-01"
},
payload: JSON.stringify(payload),
muteHttpExceptions: true // エラー時も例外にせずレスポンスを受け取る
});
if (res.getResponseCode() !== 200) {
throw new Error("API エラー (" + res.getResponseCode() + "): " + res.getContentText());
}
return JSON.parse(res.getContentText()).content[0].text.trim();
}「成果や進捗は、メモ欄に書かれている場合のみ書く」という一文が効きます。 これを入れないと、「A社定例」という予定名だけを見て「A社との商談が前進しました」と書いてしまう。 事実とAIの想像が混ざった週報は、提出する側にとって最も危険な成果物です。
モデルは軽量で安価なclaude-haiku-4-5から始めます。1週間分の予定は数千文字程度なので、これで足ります。要約が雑に感じたら上位モデルへ差し替えてください。muteHttpExceptions: trueを付けておくと、401(キーの誤り)と429(レート制限)を自分で見分けられます。
毎週金曜に自動でメールを受け取る
3段をつなぐ本体を書きます。生成した下書きの下に、元になったカレンダー実績をそのまま添えるのがポイントです。 「この一文はどの予定から来たのか」を、メールの中だけで確かめられます。
// 本体:集める → AIに渡す → 自分宛にメールで送る
function sendWeeklyReport() {
const range = getWeekRange();
const data = collectEvents(range.start, range.end);
if (data.meetings.length === 0 && data.allDay.length === 0) {
console.log("対象の予定がないため送信をスキップしました。");
return;
}
const stats = summarizeHours(data.meetings);
const eventText = buildEventText(data);
const report = generateReport(eventText, stats);
const tz = Session.getScriptTimeZone();
const label = Utilities.formatDate(range.start, tz, "yyyy/MM/dd") + " 週";
// 下書きの根拠が追えるよう、元になったカレンダー実績も本文に添える
const body =
report + "\n\n" +
"----------------------------------------\n" +
"以下は自動生成の根拠となったカレンダー実績です。\n" +
"提出前に必ず内容を確認してください。\n\n" +
eventText;
GmailApp.sendEmail(
Session.getActiveUser().getEmail(),
"[週報ドラフト] " + label,
body
);
}予定が0件のときは送信をスキップします。長期休暇の週に「今週の活動はありませんでした」という週報が届いても、 API費用を払っただけで得るものがありません。GmailApp.sendEmail()の送信先を上長にせず自分宛にしているのは、AIの下書きを人が読んでから出す運用を崩さないためです。
最後に、週次の時間主導トリガー(決まった曜日・時間帯に関数を自動実行するGASの仕組み)を仕掛けます。 トリガー登録の関数は一度だけ手動で実行します。
// 毎週金曜の17時台に sendWeeklyReport を自動実行する
// この関数は一度だけ手動で実行する(実行のたびに重複登録されるため)
function createWeeklyTrigger() {
// 既存の同名トリガーを消してから作り直す
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === "sendWeeklyReport") {
ScriptApp.deleteTrigger(t);
}
});
ScriptApp.newTrigger("sendWeeklyReport")
.timeBased()
.onWeekDay(ScriptApp.WeekDay.FRIDAY)
.atHour(17) // 17:00〜18:00の間に実行される(分単位の指定はできない)
.create();
}atHour(17)は「17時ちょうど」ではなく「17時台のどこか」で実行されます。GASの時間主導トリガーは分単位の正確な指定ができません。 週報の締切が18時なら、余裕を持って16時台に設定してください。 既存の同名トリガーを消してから作り直しているのは、実行のたびにトリガーが増殖して週報が何通も届くのを防ぐためです。
まとめ
週報の下書き作成は、GASとClaude APIで自動化できます。押さえるべきは5つです。 週の範囲は(getDay() + 6) % 7で月曜起点に揃える。辞退した予定と終日予定は仕分ける。時間の集計はコードで行い、AIには計算させない。 「カレンダーに無い活動は書かない」と禁止事項を明示する。生成物は自分宛に送り、根拠の実績を併記する。
この仕組みが返してくれるのは、金曜夕方の30分だけではありません。「予定の説明欄に一言メモを残す」習慣が身につきます。メモがそのまま週報の材料になると分かれば、書くようになる。 AIに書かせる仕組みを作った結果、人間の記録が良くなる——自動化の副産物として、これはかなり大きい部類です。 同じ型は月次報告や勤怠の振り返りにもそのまま応用できます。
よくある質問
予定のタイトルに加えて、説明欄・参加者・所要時間も一緒に渡せば、実用的な下書きになります。それでもAIが知らないのは「その打ち合わせで何が決まったか」です。週報の精度を上げたいなら、予定の説明欄に一言メモを残す習慣をつけるのが最も効きます。カレンダー側の情報が薄いままAIに膨らませると、それらしいだけの作文になります。
本文のコードでは、自分の出欠ステータスが「辞退(NO)」の予定を除外しています。GASでは各予定の getMyStatus() で自分の出欠を確認できるため、辞退・未回答を弾けます。また終日予定は所要時間の集計を狂わせるので、isAllDayEvent() で判定して別枠に分けています。
プロンプト(AIへの指示文)で「カレンダーに無い活動は書かない」「成果や進捗を推測で補わない」と明示するのが基本です。本文のコードでもそう指示しています。加えて、生成した週報の下書きはメールで自分宛に送るだけにし、提出前に必ず人が読む運用にしてください。AIは清書役であって、報告者ではありません。
できます。CalendarApp.getCalendarById() でカレンダーIDを指定すれば、共有カレンダーやサブカレンダーの予定も取得できます。取得した予定の配列を結合してから開始時刻でソートすれば、1つの週報にまとまります。ただしカレンダーごとに閲覧権限が必要です。
出力先だけ差し替えれば済みます。Slackなら Incoming Webhook に UrlFetchApp で送信、Googleドキュメントなら DocumentApp.create() で新規作成して本文を書き込みます。この記事の構成は「集める → AIに渡す → 出す」の3段なので、最後の1段を入れ替えるだけです。
Googleカレンダーの読み取り、Gmailの送信、外部API呼び出しの3つの権限が必要です。初回実行時にGoogleの認可画面が出ます。API費用は週1回の実行で1週間分の予定を送るだけなので、軽量モデルを使えば月あたり数円程度に収まります。GAS自体は無料です。
AI×GASの業務自動化を
相談する。
週報の自動生成のほかにも、議事録の要約・問い合わせ分類・翻訳・返信下書きなど、 AI APIとGASを組み合わせた自動化をご相談いただけます。既存のカレンダー・メール運用にもそのまま組み込めます。