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?

【脱・予約忘れ】キャンプ歴4年目の私が、Geminiに頼って予約メールをGoogleカレンダーに自動登録させた話

Posted at

はじめに

こんにちは!キャンプを始めて早4年。
焚き火をつまみにお酒が飲めるようになった今日この頃です。

でも、キャンパーなら誰もが抱える悩みがありますよね。
「キャンプ場の予約、2ヶ月前が基本すぎ問題」

必死にクリック合戦に勝って予約したのはいいものの、当日まで期間が空きすぎて 「あれ? 来週だっけ? 再来週だっけ?」 と不安になること、ありませんか?(私はあります)

予約完了メールは来ているけれど、いちいちカレンダーに入力するのは面倒くさい……。
かといって、適当な自動化をすると、美容院の予約メールまでカレンダーに入ってしまう……。

そこで今回は、「特定のキャンプ場リストにあるメールだけを拾い、Gemini先生に内容を読んでもらってカレンダー登録する」 という、ズボラかつ確実な仕組みをGoogle Apps Script (GAS)で作ってみました。

今回やりたいこと(完成イメージ)

【概要図】

なぜ「リスト方式」なのか?

最初は「件名に『予約』と入っているメールを全部Geminiに投げればいいじゃん」と思っていました。
しかし、やってみると以下の壁にぶつかりました。

  1. 誤検知の嵐: 美容院、居酒屋、Amazonの注文確認……世の中は「予約」や「確認」メールで溢れていました。
  2. フォーマットの壁: 日付の書き方が「2025/05/01」だったり「5月1日」だったりバラバラ。正規表現で戦うのは辛い。

そこで今回は、「信頼できるドメイン(キャンプ場)リストを作り、それに合致するものだけをGeminiに解析させる」 という運用重視の構成にしました。

判定ロジックのイメージ

メールが届いた時、以下のようなフィルターを通します。

これなら、新しいキャンプ場に行くことになったら、リストに一行追加するだけで対応 できます。

準備するもの

  1. Googleアカウント(Gmailとカレンダーを使います)
  2. Gemini API Key(Google AI Studioで無料で取得可能)
  3. Google Apps Script (GAS)

1. Gemini APIキーの取得

Google AI Studio(https://aistudio.google.com/)にアクセスし、「Get API key」からキーを発行します。
※このキーは後でGASに設定します。

2. GASプロジェクトの作成

  1. Googleドライブで「新規」>「その他」>「Google Apps Script」。
  2. プロジェクト名は「キャンプ予約自動化」などに変更。
  3. 左側の「プロジェクトの設定(歯車アイコン)」>「スクリプトプロパティ」を開き、以下の設定を追加します。
    • プロパティ名: GEMINI_API_KEY
    • 値: (先ほど取得したAPIキー)

コードの実装

以下のコードをコード.gsに貼り付けます。

🏕️ コード.gs 全文(クリックして展開)
// ==========================================
// 設定エリア
// ==========================================

// 1. 検索対象
// - subject:(...): 対象となるキーワード(予約、完了、キャンプ場名など)
// - -subject:予約受付: 「予約受付(確定前)」などを除外するためのマイナス検索
// - -label:カレンダー登録済: 二重登録を防ぐためのラベル除外
// - after:2024/01/01: 検索対象期間(運用に合わせて変更してください)
const SEARCH_QUERY = 'subject:("予約" OR "完了" OR "Reservation" OR "ほったらかしキャンプ場") -subject:予約受付 -label:カレンダー登録済 after:2024/01/01';

// 2. 対象とするキャンプ場・予約サイトのキーワードリスト
// 件名または送信元アドレスに、以下のいずれかが含まれていれば処理対象とします
const ALLOW_LIST = [
  'camp-reserve.com',   // 一般的なキャンプ予約システム
  'recamp.jp',          // RECAMP
  'fumotoppara.net',    // ふもとっぱら
  'reserva.be',         // その他予約システムなど
  'キャンプ'           // 件名に「キャンプ」という文字があれば許可
];

// 3. カレンダー設定
const CALENDAR_ID = 'XXX'; // ★ここに登録先のカレンダーID(自分のGmailアドレス等)を入力

// ==========================================
// メイン処理
// ==========================================
function main() {
  console.log(`検索開始: ${SEARCH_QUERY}`);
  
  // 1. 未処理のメール(スレッド)を検索(一度に最大30件)
  const threads = GmailApp.search(SEARCH_QUERY, 0, 30);
  
  if (threads.length === 0) {
    console.log('検索条件にヒットする未処理メールは0件でした。');
    return;
  }

  console.log(`${threads.length} 件のスレッドがヒットしました。処理を開始します。`);

  // スレッドごとにループ処理
  threads.forEach(thread => {
    // Gmailの設定で「スレッド表示OFF」推奨ですが、念のためスレッド内の全メールを確認
    const messages = thread.getMessages();
    let threadHasProcessed = false; // このスレッド内で処理成功があったかフラグ

    messages.forEach(message => {
      // 2. リストと照合して、対象メールか判定
      if (isTargetEmail(message)) {
        console.log(`処理対象メールを発見: ${message.getSubject()}`);
        
        // 3. Geminiで解析してカレンダー登録
        // 成功した場合は true が返ってくる
        const isSuccess = processReservation(message);
        
        if (isSuccess) {
          threadHasProcessed = true;
        }
      }
    });

    // 4. スレッド内のどれか1つでも成功したら、スレッド全体に「処理済」ラベルを貼る
    // (これで次回以降の検索対象から外れます)
    if (threadHasProcessed) {
      markAsProcessed(thread);
      console.log('ラベル付与完了:処理済みとしました');
    }
  });
}

// リストに含まれるキーワードが、件名か送信元にあるかチェックする関数
function isTargetEmail(message) {
  const subject = message.getSubject();
  const from = message.getFrom();
  
  // ALLOW_LISTのどれか1つでも含まれていれば true
  return ALLOW_LIST.some(keyword => {
    return subject.includes(keyword) || from.includes(keyword);
  });
}

// Gemini連携 & カレンダー登録を行う中核関数
function processReservation(message) {
  const body = message.getPlainBody();
  const subject = message.getSubject();
  
  // Geminiに本文を投げて解析結果(JSON)をもらう
  const eventDetails = parseEmailWithGemini(body);
  
  // 解析失敗などでnullが返ってきたら終了
  if (!eventDetails) return false;

  // 日付オブジェクトを作成
  const start = new Date(eventDetails.startTime);
  const end = new Date(eventDetails.endTime);

  // 日付が無効(Invalid Date)でないかチェック
  if (isNaN(start.getTime()) || isNaN(end.getTime())) {
    console.error(`日付形式エラー: start=${eventDetails.startTime}, end=${eventDetails.endTime}`);
    return false;
  }

  // ★詳細欄(Description)用に、時間テキストだけを抽出 (例: "13:00")
  // Session.getScriptTimeZone() を使うことでGASのタイムゾーン設定に合わせます
  const timeStrStart = Utilities.formatDate(start, Session.getScriptTimeZone(), 'HH:mm');
  const timeStrEnd   = Utilities.formatDate(end, Session.getScriptTimeZone(), 'HH:mm');

  // ★重要: 帯表示のための終了日調整
  // Googleカレンダーの終日イベントは「終了日の0:00まで」となる仕様のため、
  // 「1/1〜1/2」の予定を2日分の帯にするには、終了日を「1/3」にする必要があります。
  // そのため、解析された終了日に +1日 します。
  end.setDate(end.getDate() + 1);

  const calendar = CalendarApp.getCalendarById(CALENDAR_ID);
  
  try {
    // ★終日イベント(createAllDayEvent)として登録
    // これによりカレンダー月表示などで「帯(線)」として表示されます
    calendar.createAllDayEvent(`🏕 ${eventDetails.title}`, start, end, {
      description: `【時間】 ${timeStrStart} IN 〜 ${timeStrEnd} OUT\n\n【詳細】\n元メール: ${subject}\nメモ: ${eventDetails.memo || 'なし'}`
    });
    
    console.log(`カレンダー登録成功(終日): ${eventDetails.title}`);
    return true; // 成功
  } catch (e) {
    console.error('カレンダー登録時にエラー発生:', e);
    return false; // 失敗
  }
}

// Gemini APIを呼び出す関数
function parseEmailWithGemini(emailBody) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  
  // ★モデル指定: gemini-2.0-flash を使用(1.5系でエラーが出る場合の対策)
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;

  const prompt = `
    以下のキャンプ場予約メールの本文から、カレンダー登録に必要な情報を抽出してJSONのみを返してください。
    マークダウン記法は不要です。
    
    【重要:日付の抽出ルール】
    - チェックイン日とチェックアウト日を正確に抽出してください。
    - 年号がない場合は、メール受信日や文脈から適切に(例: 2025年)補完してください。
    
    【抽出項目】
    - title: キャンプ場名(短く分かりやすく)
    - startTime: チェックイン日時 (YYYY-MM-DD HH:mm形式) ※記載なければ13:00
    - endTime: チェックアウト日時 (YYYY-MM-DD HH:mm形式) ※記載なければ11:00
    - memo: サイト名やプラン名など、メモに残すべき情報
    
    【メール本文】
    ${emailBody}
  `;

  const payload = { contents: [{ parts: [{ text: prompt }] }] };
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    const json = JSON.parse(response.getContentText());

    // APIエラーチェック
    if (json.error) {
      console.error('Gemini API Error:', JSON.stringify(json.error, null, 2));
      return null;
    }
    // 回答が空の場合のチェック
    if (!json.candidates || json.candidates.length === 0) {
      console.warn('Geminiからの回答がありませんでした。');
      return null;
    }

    const text = json.candidates[0].content.parts[0].text;
    // 余計な文字(Markdownの ```json 等)を削除してパース
    const cleanJson = text.replace(/```json/g, '').replace(/```/g, '').trim();
    return JSON.parse(cleanJson);
  } catch (e) {
    console.error('Gemini解析エラー:', e);
    return null;
  }
}

// 処理済みラベルを付与する関数
function markAsProcessed(thread) {
  const labelName = 'カレンダー登録済';
  let label = GmailApp.getUserLabelByName(labelName);
  
  // ラベルが存在しなければ作成する
  if (!label) {
    label = GmailApp.createLabel(labelName);
  }
  thread.addLabel(label);
}

💡 Geminiへのプロンプト設計ポイント
今回のスクリプトでは、Geminiからの回答をGASで直接 JSON.parse できるように、以下の工夫をしています。

  1. 出力形式の指定: 「JSONのみを返してください」「マークダウン記法は不要」と明記し、パースエラーを防ぐ。
  2. 日付の補完: メール本文に「1/1」としか書かれていない場合でも、Date オブジェクトで扱えるよう「年号の補完」や「YYYY-MM-DD形式」での出力を指示。
  3. デフォルト値: 時間の記載がないメールに備え、チェックイン13:00 / アウト11:00 というデフォルト値をプロンプト内で定義。

運用のコツ

このスクリプトの肝は、冒頭の ALLOW_LIST です。

const ALLOW_LIST = [
  'nap-camp.com',
  'fumotoppara.net',
  // ここに新しいキャンプ場のドメインを追記していく
];

最初はリストを最小限にしておき、新しいキャンプ場を予約するたびに「お、ここのドメインはこれか」とリストに追加していく運用にしています。
少し手間なように見えますが、「確実に自分の行きたいキャンプ場だけがカレンダーに積み上がっていく」 感覚があり、意外と楽しい作業です。

トラブルがあったときは?

運用していて躓いたポイントと対処法をまとめておきます。

1. Geminiがエラーを吐く(JSON parse error)

ごく稀に、Geminiがテンション高く「はい、抽出しました!こちらです!」のような前置きテキストを返してしまい、JSONの読み込みに失敗することがあります。
対策: コード内で replace を使って余計な文字を削る処理を入れていますが、それでも失敗する場合はプロンプト(命令文)に「余計な言葉は一切喋るな」と強く追記してみてください。

2. メールが検索に引っかからない

Gmailの検索仕様上、受信直後のメールは GmailApp.search でヒットしないことがあります。
対策: 私はトリガーを「1時間に1回」に設定しています。予約直後に反映されなくても、キャンプ当日までに反映されればOKという精神で運用しましょう。

3. 日付がおかしい

「来週の土曜日」のような曖昧な表記の場合、Geminiが解析した時点の日時を基準にするため、ズレることがあります。
対策: 予約メールには基本的に「yyyy年mm月dd日」と正確に書いてあるはずなので、それを優先するようGeminiを信じましょう。もし間違っていたら手動で直します(3年に1回あるかないかです)。

トリガー設定

最後に、このスクリプトが定期的に動くように設定します。

  1. GAS画面左の「トリガー(時計マーク)」をクリック。
  2. 「トリガーを追加」ボタンを押す。
  3. 実行する関数:main
  4. イベントのソース:時間主導型
  5. タイプ:1時間おき(予約メールなんて即時じゃなくていいので、これで十分です)

さいごに

これで、予約メールが届いてから1時間以内には、勝手にカレンダーに予定が入るようになりました。
Geminiが「チェックイン時間」などの表記ゆれもよしなに解釈してくれるので、メールのフォーマットが変わっても動じません。

私たちキャンパーがやるべきことは、カレンダーへの入力作業ではなく、当日の天気を祈ること だけです。

今後の展望

今の仕組みだと、新しいキャンプ場を開拓するたびに 「GASのエディタを開いてリストに追加する」 という作業が必要です。 ここもちょっと面倒ですよね(ズボラなので)。

そこで次回は、「スプレッドシートに予約サイトのURLを貼るだけで、勝手にリストが更新される仕組み(進化版)」 を作ってみようと思います。 興味のある方は、ぜひ いいねやストックをしてお待ちいただけると嬉しいです!

それでは、よいキャンプライフを:tent:

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?