こんにちは、学びの探求者です。
普段は note で活動しています。
2025年のQiitaアドベントカレンダーでは、
「ノーコード/ローコードで、自分のコンテンツ基盤を自動化していく」
をテーマに、25日間の仕組みづくりを記録していく予定です。
……のはずが、Day16は急にトーンが生活方面に寄ります。
でもこれ、私にとっては“自動化の本命”でした。
本当は真っ先に作って紹介するつもりだったんです。
「Gmailで届く保護者会の案内を、そのままGoogleカレンダーに入れる」みたいなやつ。
ところが、どうしてもDifyでGmailのOAuth認証がうまくいかず。
いったん諦めて、現実解として GAS(Google Apps Script)でやる という決断をしました。
結果、ちゃんと動きました。
(そして、GASはたまに人生も拾います。転職系の「カジュアル面談のお誘い」まで……w)
というわけで今日は、「保護者会メール → Googleカレンダー自動登録」 をGASで作ってみた話です。
今日やったこと
ゴール
Gmailに保護者会っぽいメールが来たら、本文から日付を拾って、Googleカレンダーに終日イベントで登録する。
ついでに、
- 処理済みのメールは既読にする
- Gmailに「カレンダー登録済み」ラベルを付ける
まで自動化。
なぜ「メール来た瞬間」じゃなくて「定期チェック」なの?
GASは、Gmailの「メール受信イベント」で直接トリガーできるわけではない(少なくとも気軽にやる方法はない)ので、
- 15分おきに実行して未読をチェック
- 条件に合うものがあれば処理する
という形にしました。
生活用途なら、このくらいの遅延は全然OKかなって思います。
ただ、保護者会だけだったらもっと長くてもいいかもしれないので、そこはおいおい直していきたいと思います。
※クエリは QUERY: を含めず、Gmail検索文そのものだけを書くのがポイントです(そのまま GmailApp.search() に渡せます)
実装のポイント
1)Gmail検索(QUERY)を絞るのが大事
最初はこれでやりました。
- 件名に「保護者会」「懇談」「面談」が入っている未読を拾う
ただ、「面談」 が強すぎて、転職系の「カジュアル面談のお誘い」まで拾うwww
(GAS、人生も拾う)
なので現実解として、私は Gmail側で「学校関連」ラベルを付けていたので、最終的にこうしました。
QUERY: 'label:学校関連 is:unread (subject:保護者会 OR subject:懇談 OR subject:面談) -label:カレンダー登録済み'
「学校関連」ラベルが付いているメールだけを見るので、誤爆が一気に減ります。
(それでもゼロにはならないけど、許容範囲…!)
2)日付抽出は「学校メールっぽい表記」に合わせる
学校メールって、
- 4月26日
- 4/26(土)
- 2026/4/26
みたいに表記が揺れがちなので、よくあるパターンをまとめて拾うようにしました。
3)重複登録を避ける
同日に同タイトル(保護者会)があれば、作らない。
今回はシンプルな重複防止で運用してます。
コード(全文)
※設定は CONFIG.QUERY のところだけ、環境に合わせて調整すればOKです
コード全文(クリックで開く)
const CONFIG = {
QUERY: 'label:学校関連 is:unread (subject:保護者会 OR subject:懇談 OR subject:面談) -label:カレンダー登録済み',
LABEL_NAME: 'カレンダー登録済み',
EVENT_TITLE: '保護者会',
TZ: 'Asia/Tokyo',
BODY_SNIPPET_LEN: 400,
};
function processSchoolEmails() {
const label = GmailApp.getUserLabelByName(CONFIG.LABEL_NAME) || GmailApp.createLabel(CONFIG.LABEL_NAME);
const calendar = CalendarApp.getDefaultCalendar();
const threads = GmailApp.search(CONFIG.QUERY);
Logger.log('HIT threads: ' + threads.length);
threads.forEach(thread => {
const messages = thread.getMessages().filter(m => m.isUnread());
if (messages.length === 0) {
thread.addLabel(label);
return;
}
messages.forEach(message => {
const subject = message.getSubject();
const body = message.getPlainBody();
const receivedAt = message.getDate();
const dates = extractDates(body, receivedAt);
Logger.log('subject=' + subject + ' dates=' + dates.length);
if (dates.length === 0) return;
dates.forEach(d => {
const existing = calendar.getEventsForDay(d);
const exists = existing.some(ev => ev.getTitle() === CONFIG.EVENT_TITLE);
if (exists) return;
const desc =
`件名: ${subject}\n` +
`受信日: ${Utilities.formatDate(receivedAt, CONFIG.TZ, 'yyyy/MM/dd HH:mm')}\n\n` +
`--- 本文(先頭${CONFIG.BODY_SNIPPET_LEN}文字)---\n` +
body.substring(0, CONFIG.BODY_SNIPPET_LEN);
calendar.createAllDayEvent(CONFIG.EVENT_TITLE, d, { description: desc });
Logger.log('created: ' + Utilities.formatDate(d, CONFIG.TZ, 'yyyy/MM/dd'));
});
message.markRead();
});
thread.addLabel(label);
});
}
function extractDates(text, referenceDate) {
const dates = [];
const currentYear = referenceDate.getFullYear();
const refMonth = referenceDate.getMonth() + 1;
text = text.normalize('NFKC');
let match;
// 1) 4月26日
const p1 = /(\d{1,2})月(\d{1,2})日/g;
while ((match = p1.exec(text)) !== null) {
const month = parseInt(match[1], 10);
const day = parseInt(match[2], 10);
let year = currentYear;
if (refMonth >= 12 && month <= 3) year = currentYear + 1;
else if (refMonth <= 2 && month >= 11) year = currentYear - 1;
const d = new Date(year, month - 1, day);
if (!isNaN(d.getTime())) dates.push(d);
}
// 2) 2026/4/26 / 2026-4-26 / 2026年4月26日
const p2 = /(\d{4})[年\/\-](\d{1,2})[月\/\-](\d{1,2})/g;
while ((match = p2.exec(text)) !== null) {
const year = parseInt(match[1], 10);
const month = parseInt(match[2], 10);
const day = parseInt(match[3], 10);
const d = new Date(year, month - 1, day);
if (!isNaN(d.getTime())) dates.push(d);
}
// 3) 4/26 or 4-26
const p3 = /(\d{1,2})[\/\-](\d{1,2})/g;
while ((match = p3.exec(text)) !== null) {
const month = parseInt(match[1], 10);
const day = parseInt(match[2], 10);
let year = currentYear;
if (refMonth >= 12 && month <= 3) year = currentYear + 1;
else if (refMonth <= 2 && month >= 11) year = currentYear - 1;
const d = new Date(year, month - 1, day);
if (!isNaN(d.getTime())) dates.push(d);
}
// 重複削除(同日)
const seen = new Set();
const uniq = [];
dates.forEach(d => {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
if (!seen.has(key)) {
seen.add(key);
uniq.push(d);
}
});
return uniq;
}
トリガー設定(時間主導)
Apps Scriptの「トリガー」から、
- 実行する関数:processSchoolEmails
- イベントのソース:時間主導型
- 分ベース:15分ごと(好みでOK)
にしました。
初回は手動実行して認可(Gmail/Calendarアクセス許可)を通しておくと、トリガーが安定します。
ハマりどころ(そして学び)
- トリガー設定、最初は意味がわからない
- Gmail検索は、雑にやると“人生”まで拾う
- でも、「60点でも回る」状態に持っていくと生活がラクになる
自動化って、100点を目指して止まるより、
回しながらチューニングの方が結果的に強いなと思いました。
いつかやりたい
- 「面談」誤爆をさらに減らす(除外ワード or 本文判定)
- 保護者会だけじゃなく、進路説明会・授業参観も同じ仕組みで登録
- スプシにログを貯めて、月別のイベント密度を可視化する