2025/04/20

Google Apps Scriptで、Gmailに届いた予約完了メールをGoogleカレンダーに自動登録する(QQ English)

Gmailに、QQ Englishの予約完了メール、キャンセルメール、フィードバックメール、欠席メールが届いた際に、その内容をGoogleカレンダーに自動入力するスクリプトをGoogle Apps Script(GAS)で書いた。

メールやカレンダーを覗かれる心配を最低限にするため、Zapierなどのトリガーは使わず、Google Apps Scriptの定期実行を利用している。

 

// ${targetHours}時間おきのトリガーを設定すること	
// appsscript.json の timeZone を Asia/Tokyo にしておかないと変な時間にスケジュールが作られてしまうので注意

// イベントを登録するカレンダー
// Googleカレンダーの設定の「カレンダーの統合」セクションにある「カレンダー ID」を指定する
const targetCalendarID = "xxxxxxxxxxxxxxxxxxxxxx@group.calendar.google.com" 
const calendar = CalendarApp.getCalendarById(targetCalendarID);

// もし、デフォルトのカレンダーが良いのなら下記を使う
//const calendar = CalendarApp.getDefaultCalendar();


// メールの検索条件
const targetHours = 1;  //過去何時間のメールを検索するか
const targetHoursInMilliSeconds = 60 * 60 * 1000 * targetHours;
const subject_booked = ["【QQEnglish】レッスン予約完了"]; // 2022年3月以前も対応
//const subject_booked = ["【QQEnglish】レッスン予約完了のお知らせ"]; // 2022年4月以降
//const subject_booked = ["【QQEnglish】レッスン予約完了<送信専用>"]; // 2022年3月以前

const subject_cancelled = ["【QQEnglish】キャンセル受付<送信専用>"];

const subject_feedback = ["【QQEnglish】", "からのメッセージ"]; // 2023年頃以降
//const subject_feedback = ["【QQEnglish】", "教師からのメッセージ"]; // 2023年頃以降
//const subject_feedback = ["【QQEnglish】", "先生からのメッセージ"]; // 2023年1月頃以前

const subject_dns = ["レッスン時間が終了いたしました<送信専用>"]  // did not start

const sentTo = "my_qq_inbox@example.com";
//const sentTo = "";
const sentFrom = "noreply@notify.qqeng.com";
//const sentFrom = "";

// デバッグ用フラグの取得
let debug=(PropertiesService.getScriptProperties().getProperty("debug")==="true");
let dryRun=(PropertiesService.getScriptProperties().getProperty("dryRun")==="true");
let dryDryRun=(PropertiesService.getScriptProperties().getProperty("dryDryRun")==="true");

// 予約完了メール 検索キーワード
const query_booked = `newer_than:${targetHours}h subject:"${subject_booked[0]}" ${(sentTo === "" ? "" : 'to:("' + sentTo + '")')} ${(sentFrom === "" ? "" : 'From:("' + sentFrom + '")')}`;

// キャンセルメール 検索キーワード
const query_cancelled = `newer_than:${targetHours}h subject:"${subject_cancelled[0]}" ${(sentTo === "" ? "" : 'to:("' + sentTo + '")')} ${(sentFrom === "" ? "" : 'From:("' + sentFrom + '")')}`;

// フィードバックメール 検索キーワード
const query_feedback = `newer_than:${targetHours}h (subject:"${subject_feedback[0]}" subject:"${subject_feedback[1]}") ${(sentTo === "" ? "" : 'to:("' + sentTo + '")')} ${(sentFrom === "" ? "" : 'From:("' + sentFrom + '")')}`

// 参加忘れ時のメール 検索キーワード
const query_dns = `newer_than:${targetHours}h subject:"${subject_dns[0]}" ${(sentTo === "" ? "" : 'to:("' + sentTo + '")')} ${(sentFrom === "" ? "" : 'From:("' + sentFrom + '")')}`

// 上記4件の合体版
const query_all = `newer_than:${targetHours}h {subject:"${subject_booked[0]}" subject:"${subject_cancelled[0]}" subject:"${subject_dns[0]}" (subject:"${subject_feedback[0]}" subject:"${subject_feedback[1]}")} ${(sentTo === "" ? "" : 'to:("' + sentTo + '")')} ${(sentFrom === "" ? "" : 'From:("' + sentFrom + '")')}`


if (debug){
  Logger.log(query_booked);
  Logger.log(query_cancelled);
  Logger.log(query_feedback);
  Logger.log(query_dns);
  Logger.log(query_all);
}

function processQQmails(){
  // 現在時刻
  const currentTime = new Date();

  const threads = GmailApp.search(query_all);
  const messagesInEachThread = GmailApp.getMessagesForThreads(threads);
  const messagesToProcess = [];
  for (let messages of messagesInEachThread) {
    for (let message of messages) {
      const messageContents = getMessageContents(message, currentTime);
      if (messageContents == null) continue;
      messagesToProcess.push(messageContents);
    }
  }

  // 取り込んだレッスン情報を、メールの送信時間で古い順にソート
  messagesToProcess.sort((a, b) => a.timestamp - b.timestamp);
  if(debug) Logger.log(`見つかったメールの個数: ${messagesToProcess.length}`)

  for (const content of messagesToProcess){
    if (debug) {
      Logger.log("\n" + 
        Utilities.formatDate(content.timestamp, "JST", "yyyy/MM/dd HH:mm:ss") + " ==> " +
        content.messageType + ": " +
        Utilities.formatDate(content.startTime, "JST", "yyyy/MM/dd HH:mm") + "-" +
        Utilities.formatDate(content.endTime, "JST", "HH:mm") + 
        content.curriculum + " by " +
        content.teacherName);
    }
    if (!dryDryRun) reflectToCalendar(content);
  }
}

function reflectToCalendar (messageContents){
  if (messageContents.messageType === "booked"){
    createCalendarEvent(
      messageContents.calendarTitle, 
      messageContents.calendarLocation, 
      `予約メール時刻: ${Utilities.formatDate(messageContents.timestamp, "JST", "yyyy/MM/dd HH:mm")} (${messageContents.messageId})`, 
      messageContents.startTime, 
      messageContents.endTime, 
      messageContents.messageType);
  } else if (messageContents.messageType === "cancelled"){
    updateCalendarEvent(
      messageContents.calendarTitle, 
      messageContents.calendarLocation, 
      `キャンセルメール時刻: ${Utilities.formatDate(messageContents.timestamp, "JST", "yyyy/MM/dd HH:mm")} (${messageContents.messageId})`, 
      messageContents.startTime, 
      messageContents.endTime, 
      messageContents.messageType);
  } else if (messageContents.messageType === "feedback"){
    updateCalendarEventForFeedback(
      messageContents.calendarTitle, 
      messageContents.calendarLocation, 
      `先生からのフィードバック (${Utilities.formatDate(messageContents.timestamp, "JST", "yyyy/MM/dd HH:mm")} (${messageContents.messageId}))\n` + messageContents.calendarDescription, 
      messageContents.startTime, 
      messageContents.endTime, 
      messageContents.messageType);
  } else if (messageContents.messageType === "dns"){
    updateCalendarEvent(
      messageContents.calendarTitle, 
      messageContents.calendarLocation, 
      `欠席通知メール時刻: (${Utilities.formatDate(messageContents.timestamp, "JST", "yyyy/MM/dd HH:mm")} (${messageContents.messageId}))`, 
      messageContents.startTime, 
      messageContents.endTime, 
      messageContents.messageType);
  } else {return null;}
}


// メールに含まれるレッスン情報を取得
// 処理対象外のメールの場合は、nullを返す
function getMessageContents(message, currentTime){
  // スレッド化により、1つのスレッドに検索対象時間外のメールが含まれている可能性を考慮
  const messageTime = message.getDate();
  if (currentTime - messageTime > targetHoursInMilliSeconds) return null; // 検索対象時間より古い場合はスキップ

  // スレッド化により、1つのスレッドに件名が異なるメールが含まれる可能性を考慮
  const messageSubject = message.getSubject();
  Logger.log(Utilities.formatDate(messageTime, "JST", "yyyy/MM/dd HH:mm:ss") + " - " + messageSubject);

  // メールの件名が、検索条件に合致しているかを確認する。
  const messageTypeIsBooked = subject_booked.every(entity => messageSubject.includes(entity));
  const messageTypeIsCancelled = subject_cancelled.every(entity => messageSubject.includes(entity));
  const messageTypeIsFeedback = subject_feedback.every(entity => messageSubject.includes(entity));
  const messageTypeIsDns = subject_dns.every(entity => messageSubject.includes(entity));

  let feedback = null; // 初期値 null
  let messageType = null; // 初期値 null
  if (messageTypeIsBooked) {messageType="booked";}
  else if (messageTypeIsCancelled) {messageType="cancelled";}
  else if (messageTypeIsFeedback) {
    messageType="feedback"; 
    feedback = getFeedback(message);
    if (feedback === null) {
      Logger.log('フィードバック本文の抽出に失敗しました。');
      // フィードバックが取れなくてもイベント自体はわかるので、null を返さずに続行する選択肢もある
      // return null;
    }
  } else if (messageTypeIsDns) {messageType="dns";}
  else {
    Logger.log(`処理対象外の件名です: ${messageSubject}`);
    return null;
  }

  //const messageId = message.getHeader('Message-ID'); // Message-IDの使い方: https://mail.google.com/mail/u/0/#search/rfc822msgid:${messageId}
  const messageId = message.getId(); // getId()で得られる値の使い方: https://mail.google.com/mail/u/0/#inbox/${messageId}
  const messageBody = message.getBody();
  const lessonDateMatch = messageBody.match(/日付: (\d{4}-\d{2}-\d{2})/);
  const lessonTimeRangeMatch = messageBody.match(/時間: (\d{2}:\d{2}-\d{2}:\d{2})/);
  const teacherNameMatch = messageBody.match(/教師: (.+)/);  
  // const teacherNameMatch = // /教師: (.+)/.exec(messageBody); 
  // という書き方でも同じことができるが、getBody() は HTML を含む可能性があるので .exec より match が安全かも
  const curriculumMatch = messageBody.match(/カリキュラム: (.+)/);

  // どれか一つでもマッチしない場合は null を返す
  if (!lessonDateMatch || !lessonTimeRangeMatch || !teacherNameMatch || !curriculumMatch) {
      Logger.log('メール本文から必要情報(日付・時間・教師・カリキュラム)を抽出できませんでした。');
      return null;
  }
  
  // マッチした場合のみ値を取得 (グループ1を取得)
  // trim() を追加して前後の不要なスペースを削除
  const lessonDate = lessonDateMatch[1];
  const lessonTimeRange = lessonTimeRangeMatch[1];
  const teacherName = teacherNameMatch[1].trim();
  const curriculum = curriculumMatch[1].trim();

  Logger.log(`                    => ${lessonDate} ${lessonTimeRange}: ${curriculum} by ${teacherName} (${messageType})`);
  
  const splitLessonDate = lessonDate.split('-');
  const splitLessonTimeRange = lessonTimeRange.split('-');
  const [year, month, dayOfMonth] = splitLessonDate;
  const [startTimeHour, startTimeMinutes] = splitLessonTimeRange[0].split(':');
  const [endTimeHour, endTimeMinutes] = splitLessonTimeRange[1].split(':');

  return {
    timestamp: messageTime,
    messageId: messageId,
    teacherName: teacherName,
    lessonDate: lessonDate,
    lessonTimeRange: lessonTimeRange,
    curriculum: curriculum,
    startTime: new Date(year, month - 1, dayOfMonth, startTimeHour, startTimeMinutes, 0),
    endTime: new Date(year, month - 1, dayOfMonth, endTimeHour, endTimeMinutes, 0),
    calendarTitle: `QQEnglish (${teacherName}先生/${curriculum})`,
    calendarLocation: "https://www.qqeng.com/q/mypage/",
    calendarDescription: feedback,
    messageType: messageType
  };
}



// メールに含まれるレッスン情報とフィードバックを取得
// 処理対象外のメールの場合は、nullを返す
function getFeedback(message){
  // getFeedback()の呼び出し側で、messageが指しているメールがFeedbackのメールであることを確認済みのため、
  // 以下のチェックは重複したチェックとなることから、コメントアウト
  // const messageSubject = message.getSubject();
  // for (let target_subject of subject_feedback){
  //   if (!messageSubject.includes(target_subject)) messageTypeIsFeedback *= false;
  // }
  
  const messageBodyLines = message.getPlainBody().split("\n");
  var InFeedbackSection = false;
  var IsWentThroughLessonInformation = false;
  var FeedbackMessage = "";
  for( let line of messageBodyLines) {
    if (IsWentThroughLessonInformation == false && !line.match(/^\s*カリキュラム:/)) continue;
    if (IsWentThroughLessonInformation == false && line.match(/^\s*カリキュラム:/)) {
      IsWentThroughLessonInformation = true;
      continue;
    }
    if (InFeedbackSection == false && line.match(/^\s*------------------------------------------------\s*$/) ) {
      InFeedbackSection = true;
      continue;
    }
    if (line.match(/^\s*今後ともQQEnglishのご利用をよろしくお願いいたします。\s*$/)) break;
    if (line.match(/^\s*またのご利用、心よりお待ちしております。\s*$/)) break;    // 2023年頃まで?

    if (InFeedbackSection) {
      if(line.match(/^\s+$/)) continue;
      if(line.match(/------------------------------------------------/)){
        InFeedbackSection=false;
        continue;
      }
      if(FeedbackMessage !== "") FeedbackMessage = FeedbackMessage + "\n";
      FeedbackMessage = FeedbackMessage + line;
    }
  }

  //Logger.log(FeedbackMessage);
  
  // 抽出に失敗した場合(例えば区切り線が見つからないなど)は null を返すようにする
  if (FeedbackMessage === "") return null; // 何も抽出できなかった場合

  return FeedbackMessage;
}




// Googleカレンダーにスケジュール登録
function createCalendarEvent(title, location, description, startTime, endTime, actionType) {
  
  let descriptionPrefix = "";
  switch (actionType){
    case "added": 
      descriptionPrefix = "";
      break;
    
    case "cancelled":
      descriptionPrefix = "予約メール時刻: 受信していない\n";
      break;

    case "feedback":
      descriptionPrefix = "予約メール時刻: 受信していない\n";
      break;

    case "dns":
      descriptionPrefix = "予約メール時刻: 受信していない\n";
      break;
    
    default:
      break;
  }

  const option = {
    location: location,
    description: descriptionPrefix + description,
  };

  // イベントの重複登録を避ける
  const events = calendar.getEvents(startTime, endTime); // 同じ時間に登録されているイベントの取得
  for (let event of events) {
    // if (event.getTitle() === title){ // 同じタイトルのイベントがあるなら、イベントを作らない
    if (event.getDescription().includes(description)){ // イベントの詳細欄に、入力しようとするdescriptionと同じ内容があれば、イベントを作らない
      Logger.log("Found: " + event.getId() + ": " + Utilities.formatDate(event.getStartTime(), "JST", "yyyy-MM-dd HH:mm") + "~" + Utilities.formatDate(event.getEndTime(), "JST", "HH:mm") +  ": " + event.getTitle() + " (既に登録済みのレッスンです)");
      return "";
    }
  };

  if (!dryRun) {
    const thisEvent = calendar.createEvent(title, startTime, endTime, option);
    thisEvent.addPopupReminder(10);
    Logger.log("Added: " + thisEvent.getId() + ": " + Utilities.formatDate(thisEvent.getStartTime(), "JST", "yyyy-MM-dd HH:mm") + "~" + Utilities.formatDate(thisEvent.getEndTime(), "JST", "HH:mm") +  ": " + thisEvent.getTitle() + " (レッスン予約を登録しました)");
    const thisEventId = thisEvent.getId();
    return thisEventId;
  } else {
    Logger.log("Added: " + Utilities.formatDate(startTime, "JST", "yyyy-MM-dd HH:mm") + "~" + Utilities.formatDate(endTime, "JST", "HH:mm") +  ": " + title + " (レッスン予約を登録しました)");
    return "";
  }
}

// Googleカレンダーのスケジュールを更新(キャンセル、欠席)
function updateCalendarEvent(title, location, description, startTime, endTime, actionType) {
  //const option = {
  //  location: location,
  //  description: description,
  //};
  
  let title_prefix = "";
  if (actionType === "cancelled") {title_prefix = "キャンセル";}
  else if (actionType === "dns") {title_prefix = "欠席";}

  // 同じ時間に同じタイトルのイベントがあることを確認し、あれば、そのイベントを更新
  const events = calendar.getEvents(startTime, endTime);
  let eventFoundAndUpdated = false; // 見つかったかどうかのフラグ

  for (let event of events) {
    if (event.getTitle() === title) {
      Logger.log("Found: " + event.getId() + ": " + Utilities.formatDate(event.getStartTime(), "JST", "yyyy-MM-dd HH:mm") + "~" + Utilities.formatDate(event.getEndTime(), "JST", "HH:mm") +  ": " + event.getTitle() + ` (${title_prefix}されたことを記録します)`);
      if (!dryRun) event.setTitle(`(${title_prefix}済) ${title}`);
      if (!dryRun) event.setDescription(event.getDescription() + "\n" + description);
      if (!dryRun) if (actionType === "cancelled") event.setTransparency(CalendarApp.EventTransparency.TRANSPARENT);
      eventFoundAndUpdated = true;
      break; // 更新したらループを抜ける
    }
  }

  // ループ終了後、見つからなかった場合にログ出力
  if (!eventFoundAndUpdated) {
    Logger.log(`${title_prefix}対象のイベントが見つかりませんでした: ${title} ${Utilities.formatDate(startTime, "JST", "yyyy-MM-dd HH:mm")} (作成を試みます)`);
    const newEventId = createCalendarEvent(`(${title_prefix}済) ${title}`, location, description, startTime, endTime, actionType);
    if (!dryRun) if (newEventId) {
      calendar.getEventById(newEventId).setTransparency(CalendarApp.EventTransparency.TRANSPARENT);
    } else {
      //Logger.log(`イベントを作成できませんでした。`)
    }
  }
}


// Googleカレンダーのスケジュールを更新(フィードバックの追加)
function updateCalendarEventForFeedback(title, location, description, startTime, endTime, actionType) {
  const option = {
    location: location,
    description: description,
  }

  // 同じ時間に同じタイトルのイベントがあることを確認し、あれば、そのイベントを更新
  const events = calendar.getEvents(startTime, endTime);
  let eventFoundAndUpdated = false;

  if (events.length == 0) {
    Logger.log("その時間帯にイベントは登録されていません。フィードバックを追記する先がみつかりませんでした。");
    return;
  }
  for (let event of events) {
    if (event.getTitle() === title) {
      let currentDesc = event.getDescription() || ""; // null の場合は空文字に

      // 重複チェック
      if (currentDesc.includes(description)) {
          Logger.log("Found: " + event.getId() + ": " + Utilities.formatDate(event.getStartTime(), "JST", "yyyy-MM-dd HH:mm") + "~" + Utilities.formatDate(event.getEndTime(), "JST", "HH:mm") +  ": " + event.getTitle() + " (フィードバックは既に追記されているようです。スキップします。)");
      } else {
          // 既存の説明があれば改行を2つ入れて区切る、なければそのまま設定
          let newDesc = currentDesc ? (currentDesc + "\n\n" + description) : description;
          if (!dryRun) event.setDescription(newDesc);
          Logger.log("Found: " + event.getId() + ": " + Utilities.formatDate(event.getStartTime(), "JST", "yyyy-MM-dd HH:mm") + "~" + Utilities.formatDate(event.getEndTime(), "JST", "HH:mm") +  ": " + event.getTitle() + " (フィードバックを追記しました)");
      }
      eventFoundAndUpdated = true;
      break; // 一致するイベントを更新したらループを抜ける
    }
  }

  if (!eventFoundAndUpdated) {
    Logger.log(`フィードバック追記対象のイベント(タイトル一致)が見つかりませんでした: ${title} ${Utilities.formatDate(startTime, "JST", "yyyy-MM-dd HH:mm")}`);
  }
}

 

GASのスクリプトの作り方

  1. Google Driveで適当なフォルダに「+新規」→「その他」→「Google Apps Script」でGASのファイルを作る。
  2. ダブルクリックでApps Scriptのエディタが開くので、上記コードを貼り付ける。


設定方法

  1. カレンダーに予定を登録するに際して、どのカレンダーに登録するかを決める。
  2. Googleカレンダーの設定を開き、どのマイカレンダーにするかを決めたら、そのカレンダーの「カレンダー ID」をコピーする。カレンダーIDは、「カレンダーの統合」のセクションにかかれている。
  3. GASのコードの上部(6行目)に「const targetCalendarID =」で始まる行があるので、その後ろにダブルクオーテーションでくくってカレンダーIDを入力する。
  4. もし、デフォルトカレンダーに登録するのでよければ、7行目は「//」でコメントアウトして、10行目の「//const calendar = CalendarApp.getDefaultCalendar();」のコメントアウトを外す。
  5. メールの取得を何時間おきに行うかを決める。基本は1時間でよいが、長くしたい場合は、14行目の「const targetHours = 1;」の「1」を好みの時間数に変更する。(合わせて、トリガーの起動時間の間隔も変える)
  6. 28行目「const sentTo = "my_qq_inbox@example.com";」で、QQ Englishの通知メールを受け取るメールアドレスを指定するようにしている。(Gmailは、アドレスの@マークの前に、「+xyz」のように+をつけて好きな文字をつなげることで、複数のメールアドレスを作れる。「original+sub@gmail.com」みたいなかんじ。)

カレンダーの予約の詳細欄に、どのメールから引っ張ってきた情報かをメモしてあるので、それを見ることで重複した登録が起きないようにしてあるため、同じメールを複数回処理しても問題ない。


デバッグ用の設定

  1. 左のメニューで、「プロジェクトの設定」を開き、「スクリプト プロパティ」のセクションで、「スクリプト プロパティを編集」ボタンをクリック。
  2. debug、dryRun、dryDryRunのプロパティで、trueもしくはfalseを設定する。

  • debugは余分なログを吐かせる。
  • dryRunもしくはdryDryRunをtrueにすると、カレンダーのイベントの変更は行わない。
  • dryDryRunをtrueにすると、変更の機能は呼び出さない。
  • dryRunをtrueにすると、変更の機能の中で、イベントの変更を行うコードを実行しない。


定期実行の設定

左側のメニューから「トリガー」を選び、トリガーの管理画面で「+トリガーを追加」をクリックする。

  • 実行する関数を選択: processQQmails
  • 実行するデプロイを選択: Head
  • イベントのソースを選択: 時間主導型
  • 時間ベースのトリガーのタイプを選択: 時間ベースのタイマー
  • 時間の間隔を選択(時間): 1時間おき
  • エラー通知設定: お好みで(「毎日通知を受け取る」で十分では?)

「保存」をクリック


どこかのタイミングで、カレンダーとメールへのアクセス権限を求められるので、許可する。


 参考

 https://nekonenene.hatenablog.com/entry/qq-english-google-calendar に記載されていたコードを参考にさせていただき、初期コードを作った。

重複チェックの変更、予約キャンセル・無断欠席のメールの処理、フィードバックの取り込み処理などを追加したため、オリジナルのコードはあまり残っていないと思う。


 

 

0 件のコメント:

コメントを投稿