はじめに
こんにちは!キャンプを始めて早4年。
焚き火をつまみにお酒が飲めるようになった今日この頃です。
でも、キャンパーなら誰もが抱える悩みがありますよね。
「キャンプ場の予約、2ヶ月前が基本すぎ問題」
必死にクリック合戦に勝って予約したのはいいものの、当日まで期間が空きすぎて 「あれ? 来週だっけ? 再来週だっけ?」 と不安になること、ありませんか?(私はあります)
予約完了メールは来ているけれど、いちいちカレンダーに入力するのは面倒くさい……。
かといって、適当な自動化をすると、美容院の予約メールまでカレンダーに入ってしまう……。
そこで今回は、「特定のキャンプ場リストにあるメールだけを拾い、Gemini先生に内容を読んでもらってカレンダー登録する」 という、ズボラかつ確実な仕組みをGoogle Apps Script (GAS)で作ってみました。
今回やりたいこと(完成イメージ)
【概要図】
判定ロジックのイメージ
メールが届いた時、以下のようなフィルターを通します。
これなら、新しいキャンプ場に行くことになったら、リストに一行追加するだけで対応 できます。
準備するもの
- Googleアカウント(Gmailとカレンダーを使います)
- Gemini API Key(Google AI Studioで無料で取得可能)
- Google Apps Script (GAS)
1. Gemini APIキーの取得
Google AI Studio(https://aistudio.google.com/)にアクセスし、「Get API key」からキーを発行します。
※このキーは後でGASに設定します。
2. GASプロジェクトの作成
- Googleドライブで「新規」>「その他」>「Google Apps Script」。
- プロジェクト名は「キャンプ予約自動化」などに変更。
- 左側の「プロジェクトの設定(歯車アイコン)」>「スクリプトプロパティ」を開き、以下の設定を追加します。
- プロパティ名:
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 できるように、以下の工夫をしています。
- 出力形式の指定: 「JSONのみを返してください」「マークダウン記法は不要」と明記し、パースエラーを防ぐ。
-
日付の補完: メール本文に「1/1」としか書かれていない場合でも、
Dateオブジェクトで扱えるよう「年号の補完」や「YYYY-MM-DD形式」での出力を指示。 - デフォルト値: 時間の記載がないメールに備え、チェックイン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回あるかないかです)。
トリガー設定
最後に、このスクリプトが定期的に動くように設定します。
- GAS画面左の「トリガー(時計マーク)」をクリック。
- 「トリガーを追加」ボタンを押す。
- 実行する関数:
main - イベントのソース:
時間主導型 - タイプ:
1時間おき(予約メールなんて即時じゃなくていいので、これで十分です)
さいごに
これで、予約メールが届いてから1時間以内には、勝手にカレンダーに予定が入るようになりました。
Geminiが「チェックイン時間」などの表記ゆれもよしなに解釈してくれるので、メールのフォーマットが変わっても動じません。
私たちキャンパーがやるべきことは、カレンダーへの入力作業ではなく、当日の天気を祈ること だけです。