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のスクリプトの作り方
- Google Driveで適当なフォルダに「+新規」→「その他」→「Google Apps Script」でGASのファイルを作る。
- ダブルクリックでApps Scriptのエディタが開くので、上記コードを貼り付ける。
設定方法
- カレンダーに予定を登録するに際して、どのカレンダーに登録するかを決める。
- Googleカレンダーの設定を開き、どのマイカレンダーにするかを決めたら、そのカレンダーの「カレンダー ID」をコピーする。カレンダーIDは、「カレンダーの統合」のセクションにかかれている。
- GASのコードの上部(6行目)に「const targetCalendarID =」で始まる行があるので、その後ろにダブルクオーテーションでくくってカレンダーIDを入力する。
- もし、デフォルトカレンダーに登録するのでよければ、7行目は「//」でコメントアウトして、10行目の「//const calendar = CalendarApp.getDefaultCalendar();」のコメントアウトを外す。
- メールの取得を何時間おきに行うかを決める。基本は1時間でよいが、長くしたい場合は、14行目の「const targetHours = 1;」の「1」を好みの時間数に変更する。(合わせて、トリガーの起動時間の間隔も変える)
- 28行目「const sentTo = "my_qq_inbox@example.com";」で、QQ Englishの通知メールを受け取るメールアドレスを指定するようにしている。(Gmailは、アドレスの@マークの前に、「+xyz」のように+をつけて好きな文字をつなげることで、複数のメールアドレスを作れる。「original+sub@gmail.com」みたいなかんじ。)
カレンダーの予約の詳細欄に、どのメールから引っ張ってきた情報かをメモしてあるので、それを見ることで重複した登録が起きないようにしてあるため、同じメールを複数回処理しても問題ない。
デバッグ用の設定
- 左のメニューで、「プロジェクトの設定」を開き、「スクリプト プロパティ」のセクションで、「スクリプト プロパティを編集」ボタンをクリック。
- 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 件のコメント:
コメントを投稿