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?

任意のGmailをChatGPTで解析して自動でGoogleカレンダーに追加するの巻

Posted at

開発経緯

飛行機のフライト確定メールを見て、Googleカレンダーに予定を追加する時間を無くしたい。
今のAIであれば、メールをよしなに解析して登録するくらいのことはできるだろう。

しかし調査したところ、Google geminiの有料プランでなくてはこのGmail2GoogleCalenderの自動連携機能は使用できなかった。

金額的な話よりも、サブスクリプションが1つ増えることによる脳みそリソース圧迫の方が死活問題である。
というわけで、GASを用いて、GmailからchatGPTを経由して、GoogleCalenderにリクエストするシステムを作った。
(openaiに関しては、毎日バリバリに使っているのでサブスク契約していた)

同じようなことをしている人は他にもいたが、自分が連携したい送信元だけで、容易に連携先の追加が可能なことにこだわった

必須要件

  • Gmailを解析して、Googleカレンダーに予定を追加する
  • 簡単に新しい連携の追加が可能にしたい

プログラム

const CHATGPT_API_KEY = "API_KEY";
const CHATGPT_URL = "https://api.openai.com/v1/chat/completions";

// 作りたいカレンダーごとにtitleのフォーマットを変える
const ANA_PROMPT = "あなたは優秀なアシスタントです。以下のメールから予定情報を以下のJSON形式で抽出してください。ただしエスケープはしなくていいです。複数の場合は、この配列の中にカンマ区切りで指定してください。また、一つだけの場合でもこの配列で返却してください。絶対にjson以外回答しないでください。[{ \"title\"\: \"〇〇空港→〇〇空港\", \"start\": \"YYYY-MM-DDTHH:mm\", \"end\": \"YYYY-MM-DDTHH:mm\" }] メール本文:";

// ここにメール検索のパターンを追加していく: queryはメールの検索。handlerは専用関数名
const ROUTES = [
  {
    label: "ANAフライト",
    query: 'ANA お支払い完了 予約',
    handler: handleAnaFlight
  }
];

/**
 * ANAフライト予約
 * 
 * 件名フィルターで検索結果から厳密にフィルタリングする。個別に色設定ができる
 */
function handleAnaFlight(message) {
  const subject = message.getSubject();
  
  // 件名フィルター
  if (!subject.includes("ANA") || !subject.includes("お支払い完了のお知らせ")) {
    Logger.log(`スキップ: ${subject} は対象外`);
    return;
  }

  const body = message.getPlainBody();
  const json = callChatGPT(ANA_PROMPT, body);

  for (const item of json) {
    createCalendarEvent(item, CalendarApp.EventColor.BLUE);
  }
}

/**
 * メイン関数
 * 
 * トリガーを1時間に1回実行する前提で常に1時間前までのメールに検索をかける
 */
function autoSchedule() {
  Logger.log(CalendarApp.getAllCalendars()) 
  for (const route of ROUTES) {

    // 1時間に一回実行させる
    const date = new Date();
    date.setHours(date.getHours() - 1);
    const formattedDate = Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy/MM/dd');

    // // 各ルートごとの検索実行
    const threads = GmailApp.search(`${route.query} after:${formattedDate}`);
    const messages = GmailApp.getMessagesForThreads(threads);

    Logger.log(messages.length+"");

    for (const thread of messages) {
      for (const message of thread) {
        route.handler(message);
        // レートリミット回避
        Utilities.sleep(1);
      }
    }
  }
}

/**
 * chatgptに問い合わせ
 * 
 * content: プロンプト
 * body: メール本文
 */
function callChatGPT(content, body) {

  const payload = {
    "model": "gpt-4o",
    "messages": [{
      "role": "user",
      "content": `${content} ${body}`
    }],
    "max_tokens": 500
  };

  const options = {
    "method": "post",
    "headers": {
      "Authorization": `Bearer ${CHATGPT_API_KEY}`,
      "Content-Type": "application/json"
    },
    "payload": JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch(CHATGPT_URL, options);
  const json = JSON.parse(response.getContentText());
  let gptReply = json.choices[0].message.content;

  // コードブロック除去。gptはjsonで返答する場合でも必ずコードブロックで整形してしまうため。
  gptReply = gptReply.replace(/```json|```/g, '').trim();

  try {
    return JSON.parse(gptReply);
  } catch (e) {
    Logger.log(`GPTのレスポンスが不正: ${gptReply}`);
    throw new Error("GPT JSONパース失敗");
  }
}

/**
 * カレンダーに追加
 * 
 * TODO: 説明の追加
 */
function createCalendarEvent(details, color) {

  if (!details || !details.title || !details.start || !details.end) {
    Logger.log("スキップ: detailsの中身が不完全");
    return;
  }

  const calendar = CalendarApp.getDefaultCalendar();
  const now = new Date();
  const startDate = new Date(details.start);
  const endDate = new Date(details.end);

  // 日時不正
  if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
    Logger.log("スキップ: 日付形式が不正");
    return;
  }

  // 未来の日付のみ追加する
  if (startDate < now) {
    Logger.log(`スキップ: 過去の予定 - ${details.title}`);
    return;
  }

  // 日時反転
  if (startDate >= endDate) {
    Logger.log(`スキップ: 終了が開始より前 - ${details.title}`);
    return;
  }

  // 重複チェック
  const events = calendar.getEvents(startDate, endDate, {search: details.title});
  if (events.length > 0) {
    Logger.log(`イベント「${details.title}」は既に存在するのでスキップ`);
    return;
  }

  // イベント作成
  const event = calendar.createEvent(details.title, startDate, endDate);
  event.setColor(color);
  Logger.log(`イベント作成: ${details.title}, 開始: ${startDate.toISOString()}, 終了: ${endDate.toISOString()}, イベントID: ${event.getId()}`);
}

OPEN APIのAPIキーをCHATGPT_API_KEYに設定してください。
この設定の仕方は、たくさん情報あると思うので割愛します。

連携先追加方法

ROUTESにjson形式で、メールの検索クエリと専用関数を定義します。
次に追加した専用関数を「handleAnaFlight」を参考に追加します。
件名フィルターとは、Gmailのクエリ検索では機能が柔軟であるがゆえにほかの関係ないメールも検索結果に引っかかってしまうため、ここで厳密なフィルタリング処理を行っています。
私は、まずANAの支払い後の予約確定メールからカレンダー追加したいので、handleAnaFlightを作りました。
後続の処理は同じです。

あと重要なのが、ANA_PROMPTです。
ANA_PROMPTを参考に他の通知を追加する場合、プロンプトを組み直します。
連携先個別にプロンプトを設定できる方がいいかなと思いました。

GASの設定

1時間に一回トリガー実行するように設定して、過去1時間のメールを探索します。
これでもれなくメールを調査して、過去の無駄なメールを捜査することを防ぎます。

困ったこと

私はgoogleアカウントを2つ使い分けています。
ANAのメールはAアカウントに届きますが、GoogleカレンダーはBアカウントで利用していました。

API連携どうするかとかごちゃごちゃトライしていたのですが、
そんなことしなくても、Googleカレンダーをアカウント間で連携して仕舞えば関係ありませんでした笑

まとめ

chatGPTを使って、GmailからGoogleCalederに予定を自動作成するシステムを作りました。
これでひとまずgeminiの契約はしなくてよさそうです。
2時間くらいでぱぱっと作ったものなので、今後よりエラーハンドリングや機能追加など検討します
ありがとうございました。

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?