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?

【脱・予約忘れ Vol.2】GASのコードを触らずに、スプレッドシートでキャンプ場リストを管理する(進化版)

Last updated at Posted at 2025-12-01

はじめに

こんにちは!キャンプ歴4年目のズボラキャンパーです。

前回「Geminiにキャンプ予約メールを読ませてGoogleカレンダーに自動登録する」 というGASを作りました。


image.png

こんな感じで表示できるようになっています!
誰か私にほったらかしキャンプ場の予約をとれる力を💪!


これで予約忘れはなくなりましたが、運用していて1つだけ 「めんどくさい」 と思うことがありました。

「新しいキャンプ場に行くたびに、GASのコードを開いてリストを更新するのがダルい」

スマホでキャンプ場を予約した後、PCを開いてスクリプトエディタを立ち上げて、ドメインを調べて追記して……。
いや、もっと楽にしたい。スマホからURLをペタッと貼るだけでリスト更新したい。

というわけで今回は、前回の仕組みを 「スプレッドシート連携版」 に進化させます。

今回の改善点

Before(前回)

  • GASのコード内に許可リストを直書き。
  • 更新するにはスクリプトエディタを開く必要がある。

After(今回)

  • スプレッドシートに予約サイトのURLを貼るだけ。
  • 関数が自動でドメインを抽出。
  • GASはシートを見に行くだけ。コードへの追加不要!

実装ステップ

1. 管理用スプレッドシートの作成

まずはGoogleドライブで新規スプレッドシートを作成し、シート名を「キャンプ場リスト」にします。

以下のような列を作ります。

  • A列: キャンプ場名(自分がわかればOK)
  • B列: 予約サイトのURL(ここをコピペする)
  • C列: ドメイン/キーワード(自動抽出エリア

魔法の数式(ここがミソ!)

C2セルに、以下の数式を入力して、下の方までオートフィル(コピー)しておきます。

=IFERROR(REGEXEXTRACT(B2, "https?://([^/]+)/"), B2)

何をしているの?

B列に https://www.nap-camp.com/shizuoka/1234 と入力されると、www.nap-camp.com だけを抜き出してくれます。

もしURLじゃない文字(例:「楽天トラベル」など)を入力した場合は、そのままの文字を表示します。

これで、「B列にURLを貼るだけで、C列にGAS用の検索キーワードが出来上がる」 状態になりました。スマホのGoogleスプレッドシートアプリからも簡単に追記できますね!

2. GASのコード修正

前回のコードを少し修正して、このスプレッドシートを読みに行くようにします。
まず、スプレッドシートのURLに含まれるID(d/と/editの間の文字列)をコピーしておきます。

🏕️ newコード.gs 全文(クリックして展開)
/**
 * キャンプ場予約メール自動カレンダー登録スクリプト
 * - Gmailから予約メールを検索
 * - スプレッドシートのホワイトリストでフィルタリング
 * - Gemini APIで内容を解析
 * - Googleカレンダーに登録
 */

// ==========================================
// 1. 設定エリア
// ==========================================
// ★重要: これより上にコードがないことを確認してください

const SPREADSHEET_ID = 'スプレッドシートID'; // スプレッドシートID
const SHEET_NAME = 'キャンプ場リスト'; // シート名

// 検索クエリ(2025年以降、予約関連、未処理のもの)
const SEARCH_QUERY = 'subject:("予約" OR "完了" OR "Reservation") -subject:予約受付 -subject:仮予約 -label:カレンダー登録済 after:2025/01/01';

const CALENDAR_ID = 'アカウントメールアドレス'; // 登録先カレンダーID

// ==========================================
// 2. メイン処理
// ==========================================
function main() {
  console.log('処理を開始します...');
  
  // Gmailから検索
  const threads = GmailApp.search(SEARCH_QUERY, 0, 20);
  if (threads.length === 0) {
    console.log('検索条件にヒットする未処理メールは0件でした。');
    return;
  }
  console.log(`${threads.length} 件のスレッドがヒットしました。処理を開始します。`);


  // スプレッドシートから許可リストを取得(1回だけ実行)
  const allowList = getAllowListFromSheet();
  if (allowList.length === 0) {
    console.warn('【警告】許可リストが空、もしくは取得できませんでした。処理を中断します。');
    return;
  }
  console.log(`許可リスト読み込み完了: ${allowList.length}件のキーワード`);

  // スレッドごとのループ
  threads.forEach(thread => {
    const messages = thread.getMessages();
    let threadHasProcessed = false; // このスレッド内で1つでも処理成功したかどうかのフラグ

    // スレッド内のメッセージ(メール)ごとのループ
    messages.forEach(message => {
      // 判定ロジック:件名やFromがリストに含まれるか
      if (isTargetEmail(message, allowList)) {
        console.log(`処理対象を発見: ${message.getSubject()}`);
        
        // Gemini実行 & カレンダー登録処理
        const isSuccess = processReservation(message);
        
        // 成功した場合、フラグを立てる
        if (isSuccess) {
          threadHasProcessed = true;
        }
      } else {
        // 対象外の場合はデバッグログ(必要に応じてコメントアウトを外してください)
         console.log(`対象外スキップ: ${message.getSubject()} / From: ${message.getFrom()}`);
      }
    });

    // スレッド内のいずれかのメール処理が成功していれば、スレッド全体にラベルを貼る
    if (threadHasProcessed) {
      markAsProcessed(thread);
      console.log('ラベル付与完了');
    }
  });
  
  console.log('処理終了');
}

// ==========================================
// 3. サブ関数群
// ==========================================

/**
 * スプレッドシートから許可リスト(ドメインやキーワード)を取得
 */
function getAllowListFromSheet() {
  try {
    const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
    if (!sheet) {
      console.error(`シート「${SHEET_NAME}」が見つかりません。`);
      return [];
    }
    const lastRow = sheet.getLastRow();
    
    if (lastRow < 2) return []; // データがない場合
    
    // C列(3列目)の2行目からデータがある最後まで取得
    const data = sheet.getRange(2, 3, lastRow - 1, 1).getValues();
    
    // 空白を除去して一次元配列にする
    return data.flat().filter(String);
  } catch (e) {
    console.error('スプレッドシート読み込みエラー:', e);
    return [];
  }
}

/**
 * メールが処理対象かどうかを判定
 * @param {GoogleAppsScript.Gmail.GmailMessage} message 
 * @param {Array} list 許可キーワードの配列
 */
function isTargetEmail(message, list) {
  const subject = message.getSubject();
  const from = message.getFrom();
  
  // リスト内のキーワードがいずれか含まれていればTrue
  return list.some(keyword => {
    return subject.includes(keyword) || from.includes(keyword);
  });
}

/**
 * 予約メールを処理してカレンダーに登録
 */
function processReservation(message) {
  const body = message.getPlainBody();
  const subject = message.getSubject();
  
  // Geminiに解析を依頼
  const eventDetails = parseEmailWithGemini(body);
  
  // 解析失敗またはカレンダー登録不要なメールだった場合
  if (!eventDetails) return false;

  const start = new Date(eventDetails.startTime);
  const end = new Date(eventDetails.endTime);

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

  // カレンダーのメモ欄用に、時間を整形して取り出しておく
  const timeZone = Session.getScriptTimeZone();
  const timeStrStart = Utilities.formatDate(start, timeZone, 'HH:mm');
  const timeStrEnd   = Utilities.formatDate(end, timeZone, 'HH:mm');

  // 終日イベントのバーを正しく引くため、終了日を+1日する(Googleカレンダーの仕様)
  const endForAllDay = new Date(end);
  endForAllDay.setDate(endForAllDay.getDate() + 1);

  const calendar = CalendarApp.getCalendarById(CALENDAR_ID);
  
  try {
    // 終日イベントとして登録
    calendar.createAllDayEvent(`🏕 ${eventDetails.title}`, start, endForAllDay, {
      description: `【時間】 ${timeStrStart} IN 〜 ${timeStrEnd} OUT\n\n【詳細】\n元メール: ${subject}\nメモ: ${eventDetails.memo || 'なし'}`
    });
    
    console.log(`カレンダー登録成功: ${eventDetails.title} (${Utilities.formatDate(start, timeZone, 'yyyy/MM/dd')})`);
    return true;
  } catch (e) {
    console.error('カレンダー登録エラー:', e);
    return false; 
  }
}

/**
 * Gemini APIを呼び出してメール本文からJSONを抽出
 */
function parseEmailWithGemini(emailBody) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (!apiKey) {
    console.error('スクリプトプロパティに GEMINI_API_KEY が設定されていません');
    return null;
  }

  // モデル設定 (安定版の gemini-1.5-flash を推奨)
  const model = 'gemini-1.5-flash'; 
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;

  const prompt = `
    以下のキャンプ場予約メールの本文から、カレンダー登録に必要な情報を抽出してJSONのみを返してください。
    
    【重要:日付の抽出ルール】
    - チェックイン日とチェックアウト日を正確に抽出してください。
    - 年号がない場合は、メール受信日や文脈から2025年などを推測してください。
    
    【抽出項目】
    - title: キャンプ場名
    - startTime: チェックイン日 (YYYY-MM-DD形式)
    - endTime: チェックアウト日 (YYYY-MM-DD形式) ※1泊2日なら翌日の日付
    - 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 responseCode = response.getResponseCode();
    const json = JSON.parse(response.getContentText());

    if (responseCode !== 200) {
      console.error(`Gemini API Error (${responseCode}):`, json);
      return null;
    }

    if (!json.candidates || json.candidates.length === 0) return null;

    const text = json.candidates[0].content.parts[0].text;
    // コードブロック記号(```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);
}

// ※ processReservation, parseEmailWithGemini, markAsProcessed は前回と同じなので省略
// (前回のコードの下にそのまま残しておいてOKです)

※前回の記事の parseEmailWithGemini 関数などはそのまま使えます。
前回のコードの下にそのまま残しておいてOKです。
isTargetEmail 関数だけ少し変わっているので注意して書き換えてください。

仕組みの解説図

これでシステムは以下のようになりました。

  1. ユーザはスプレットシートにキーワードやURLを張り付けリストを作成
  2. リストを元にGASプログラムを実行する。
    Gmail検索⇒本文取得⇒Gemini解析⇒Googleカレンダーに登録する
  3. 定期実行を設定する(botとして使用する場合に設定)

さいごに

これで、「新しいキャンプ場の予約」から「自動化リストへの追加」まで、すべてがスマホ1つで完結するようになりました。
コード編集というハードルを取り払うことで、楽に 「使い続けられる自動化」 になったと思います。

皆さんも、スプレッドシート関数とGASを組み合わせて、自分だけの最強のキャンプ管理botを作ってみてください!

次回は…Gemを使用して進化できないかに挑戦します。
興味のある方は、ぜひ いいねストック をしてお待ちください!

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?