0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【アドカレ2025 Day16】GASで「保護者会メール→Googleカレンダー登録」やってみた(誤爆もする)

0
Last updated at Posted at 2025-12-15

こんにちは、学びの探求者です。

普段は 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 本文判定)
  • 保護者会だけじゃなく、進路説明会・授業参観も同じ仕組みで登録
  • スプシにログを貯めて、月別のイベント密度を可視化する
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?