Google Apps Script(GAS) で 楽天カードの使用履歴を通知する LINE Bot を作ってみた
はじめに
GAS で楽天カードの使用履歴を LINE に通知する LINE Bot を作成しました。
楽天カードは「カードお知らせメール」でカード利用履歴をメールで送信してくれるのですが、
メールだと見逃しちゃうのと、私は、家族カードも使用しているので、使用履歴を LINE で簡単に共有できたらといいなと思い作ってみました。
作ったもの
利用者毎の使用履歴を LINE に通知してくれます。
LINE 通知イメージ
通知以外にも、将来的に月ごとのサマリーや、家計簿に使えたら面白いかなーと思って使用履歴をスプレッドシートに保存する機能も併せて実装しました。
スプレッドシート保存
構成
楽天カードの履歴を取得する API が調べた限りはなかったので、1日1回メールを取得して、メール本文からLINE 通知メッセージを作成してプッシュする構成としています。
構成図
Google Apps Script
Google Apps Script 通称 GAS は Google が提供するローコードのプラットフォームです。
Gmail や Google Drive 、 Google Calendar 等と簡単に連携でき、それらを用いた自動化ツールや拡張機能を実装することが可能です。
また、独自なフレームワークはなく、JavaScript で実装できるのも特徴です。
開発環境
clasp
clasp は、GAS をローカルで開発できるように作成された CLI ツールです。
clasp を使用すれば、自分の好きなエディタで TypeScript で開発することが可能です。
開発環境の構築については、以下の記事を参考にさせていただきました。
今回 TypeScript は初めて触りましたが、記事にも書いてある通り、GAS をローカルで TypeScript で開発するメリットはすごく大きかったのでオススメです。
特に、GAS の型注釈や、コード補完が効いてくれるのは最高です。
コード補完
機能
以降は各機能をピックアップしながら、どのように実装したかを記載していきます。
楽天メール取得
楽天カードで決済が完了すると、楽天カードから、カードお知らせメールが届きます。
GAS で Gmail の受信フォルダから、楽天カードメールを抽出する処理を実装しました。
/**
* Gmail の受信ボックスから楽天決済案内メールを取得します。
* @returns メール情報
*/
const getRakutenMail = (): GoogleAppsScript.Gmail.GmailMessage | undefined => {
const threads = GmailApp.search('subject:"カード利用のお知らせ(本人・家族会員ご利用分)" -:"速報版"', 1, 1);
if (!threads) {
return;
}
const message = threads[0].getMessages();
return message[0];
};
メールの取得は、GmailApp.search
を使用します。
第一引数が、メールを取得するクエリになりますが、今回は以下条件でクエリを定義しています。
- 件名が
カード利用のお知らせ(本人・家族会員ご利用分)
と一致していること -
速報版
という文字列は除外すること
クエリの構文は以下をご参照ください。
取得したメッセージから、getPlainBody()
すると、メール本文を文字列で取得できます。
getBody()
で HTML を取得できますが、HTML構造が複雑すぎたため、単純に文字列に対して正規表現で、欲しい情報を取得しました。
ちなみに、メール本文はこんな感じです。(一部抜粋)
メール本文
━━━━━━━━━━
カード利用お知らせメール
━━━━━━━━━━
楽天カードを装った不審なメールにご注意ください
https://r10.to/hONHt2
楽天カード(Visa)をご利用いただき誠にありがとうございます。
お客様のカード利用情報が弊社に新たに登録されましたのでご案内いたします。
カード利用お知らせメールは、加盟店から楽天カードのご利用データが弊社に
到着した原則2営業日後にご指定のメールアドレスへ通知するサービスです。
<カードご利用情報>
《ショッピングご利用分》
■利用日: 2022/12/22
■利用先: セブン-イレブン
■利用者: 家族
■支払方法: 1回
■利用金額: 140 円
■支払月: 2023/01
■カード利用獲得ポイント:
1 ポイント
■ポイント獲得予定月:
2023/01
■利用日: 2022/12/22
■利用先: ダイレツクス
■利用者: 家族
■支払方法: 1回
■利用金額: 2,145 円
■支払月: 2023/01
■カード利用獲得ポイント:
21 ポイント
■ポイント獲得予定月:
2023/01
......
━━━━━━━━━━━
このメールは配信専用です
発行元 楽天カード株式会社
https://www.rakuten-card.co.jp/
この文字列から、■利用日: ~ ■ ポイント獲得予定月
までを一塊となるように、正規表現で取得して、各情報をパースしています。(今考えると、少々力技ですが、正規表現はそんなに複雑ではないです)
/**
* メール本文から決済履歴の情報を抽出し、決済情報オブジェクトを取得します。
* @param message メール本文
* @returns 決済情報オブジェクト
*/
const parseMessage = (message: string): PaymentInfo[] => {
const paymentInfoList = [];
const matched: RegExpMatchArray | null = message.match(new RegExp("■利用日.+? ポイント", "sg"));
if (matched) {
for (const paymentMessage of matched) {
const m = new Message(paymentMessage);
paymentInfoList.push(new PaymentInfo(m.getUseDay(), m.getUseStore(), m.getUser(), m.getAmount()));
}
}
return paymentInfoList;
};
export class Message {
message: string;
constructor(message: string) {
this.message = message;
}
private extractPaymentInfo = (prefix: string): string => {
const matched: RegExpMatchArray | null = this.message.match(`${prefix}.+`);
return matched ? matched[0].replace(prefix, "") : "";
};
getUseDay(): string {
return this.extractPaymentInfo("■利用日: ");
}
getUseStore(): string {
return this.extractPaymentInfo("■利用先: ");
}
getUser(): string {
return this.extractPaymentInfo("■利用者: ");
}
getAmount(): string {
return this.extractPaymentInfo("■利用金額: ");
}
}
スプレッドシート保存
メール本文から取得した決済情報は、スプレッドシートにも保存しています。
保存処理は以下ロジックで実装しています。
- 楽天カード決済履歴シート_現在の年(西暦)でファイルを作成
- 既に作成されている場合はファイル ID で読み込み、ない場合は新規作成
- 対象のシートを読み込む
- 既に作成されている場合はシート名で読み込み、ない場合は新規作成(シートは月毎に作成)
- 対象のシートの末尾に決済情報を追加
スプレッドシート操作用コード
/**
* スプレットシート操作用クラス
*/
export class SpreadSheet {
activeSheet: GoogleAppsScript.Spreadsheet.Sheet;
header: String[] = ["日時", "使用店舗", "利用者", "利用金額"];
constructor({ fileName, targetSheetName }: { fileName: string; targetSheetName: string }) {
const spreadSheet = this.getSpreadsheet(fileName);
this.activeSheet = this.getSpreadSheetSheet(spreadSheet, targetSheetName);
}
/**
* 対象の spreadSheet の操作クラスを取得します
* 既に作成されている場合はファイル ID で読み込み、ない場合は新規作成し、その結果を返します。
* @param fileName 対象spreadSheet ファイル名
* @returns
*/
getSpreadsheet(fileName: string): GoogleAppsScript.Spreadsheet.Spreadsheet {
const files = DriveApp.searchFiles("title contains " + "'" + fileName + "'");
let spreadSheet;
if (files.hasNext()) {
const fileId = files.next().getId();
spreadSheet = SpreadsheetApp.openById(fileId);
} else {
spreadSheet = SpreadsheetApp.create(fileName);
}
return spreadSheet;
}
/**
* 対象の spreadSheet の Sheet 操作クラスを取得します(ex, 楽天カード決済履歴シート_2022 ファイルの "1月"シート)
* 既に作成されている場合はシート名で読み込み、ない場合は新規作成し、その結果を返します。
* @param spreadSheet 対象の spreadSheet の操作クラス
* @param targetSheetName 対象の spreadSheet のシート名
* @returns
*/
getSpreadSheetSheet(
spreadSheet: GoogleAppsScript.Spreadsheet.Spreadsheet,
targetSheetName: string
): GoogleAppsScript.Spreadsheet.Sheet {
let activeSheet;
// 当月のシートがない場合は作成する
const targetSheet = spreadSheet.getSheetByName(targetSheetName);
if (!targetSheet) {
activeSheet = spreadSheet.insertSheet(targetSheetName);
activeSheet.appendRow(this.header);
} else {
activeSheet = spreadSheet.setActiveSheet(targetSheet);
}
return activeSheet;
}
/**
* 対象のスプレッドシートの末尾に行を追加します。
* @param records 追加するレコード
*/
addRecords(records: (string | number)[][]): void {
const values = this.activeSheet.getDataRange().getValues();
for (const record of records) {
values.push(record);
}
const column = this.activeSheet.getDataRange().getLastColumn();
const row = values.length;
//書き出し
this.activeSheet.getRange(1, 1, row, column).setValues(values);
}
}
レコードの書き込みは以下の記事を参考にさせていただきました。
LINE 通知
LINE通知 は、GAS から Messaging API の push-message を POST することでメッセージを送信しています。
LINE にはメッセージタイプがいくつかありますが、一番幅広い表現が可能な FlexMessage を使用しました。
Messaging API は SDK が提供されていますが、GAS で npm install
した package を利用するには少々手間がかかります。
以下の記事が参考になりますが、私は断念しました。。。
GAS では GAS で公開しているアプリケーションをライブラリとして追加することができます。
左ペインのライブラリから、スクリプト ID で追加できます。
LINE Bot SDK を GAS 用に公開している方がいましたので、今回はこちらを使用させていただきました。
appsscript.json に以下を追加して、clasp push
して取り込むことも可能です。
{
"timeZone": "Asia/Tokyo",
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"dependencies": {
// 追加
"libraries": [
{
"userSymbol": "LineBotSDK",
"version": "5",
"libraryId": "1EvYoqrPLkKgsV8FDgSjnHjW1jLp3asOSfDGEtLFO86pPSIm9PbuCQU7b"
}
]
}
}
こんな感じで使えます。
// @ts-ignore ts(2304) GAS のライブラリから読み込んでいるため
//https://github.com/kobanyan/line-bot-sdk-gas
const lineClient = new LineBotSDK.Client({
channelAccessToken: LINE_CHANNEL_ACCESS_TOKEN,
});
/**
* LINE 操作用クラス
*/
export class Line {
pushMessage(message: any): void {
lineClient.pushMessage(LINE_GROUP_ID, message);
}
}
あとは、楽天メールから取得した情報から、FlexMessage を作成する部分が一番大変だったのですが、説明も大変なので、コードを載せるだけに留めます。
通知メッセージ作成用コード
import { PaymentInfo, PaymentInfoList } from "./paymentInfo";
import { BoxContent, TextContent, Saparator } from "./lineMessage";
const DISPLAY_HIMSELF = PropertiesService.getScriptProperties().getProperty("DISPLAY_HIMSELF");
const DISPLAY_FAMILIY = PropertiesService.getScriptProperties().getProperty("DISPLAY_FAMILIY");
/**
* 決済情報通知メッセージ用クラス
*/
export class NoticePaymentHistoryMessage {
type: string;
header: BoxContent;
body?: BoxContent;
constructor(paymentInfoList: PaymentInfo[]) {
this.type = "bubble";
this.header = this.getHeader();
this.body = this.getBody(new PaymentInfoList(paymentInfoList));
}
/**
* 通知メッセージのヘッダー部分を取得します。
* @returns Flex メッセージのヘッダーコンテント
*/
getHeader() {
const header = new BoxContent({ layout: "vertical", backgroundColor: "#E57F0FFF" });
const headerContent = new TextContent({
text: "カード利用のお知らせ",
align: "center",
color: "#FFFFFFFF",
weight: "bold",
size: "xl",
});
header.addContent(headerContent);
return header;
}
/**
* 通知メッセージの本文を取得します
* @param paymentInfoList 決済情報リスト
* @returns 通知メッセージ本文を表示するメッセージオブジェクト
*/
getBody(paymentInfoList: PaymentInfoList) {
const bodyContent = new BoxContent({ layout: "vertical" });
const himselfPaymentContent = this.createPaymentHistoryMessage(paymentInfoList, "himself");
const familiyPaymentContent = this.createPaymentHistoryMessage(paymentInfoList, "familiy");
const allTotalAmountContent = this.createTotalAmountRecord(paymentInfoList, true);
bodyContent.addContent(himselfPaymentContent);
bodyContent.addContent(new BoxContent({ layout: "vertical", margin: "lg" }));
bodyContent.addContent(familiyPaymentContent);
bodyContent.addContent(new Saparator("xl"));
bodyContent.addContent(allTotalAmountContent);
return bodyContent;
}
/**
* 指定した利用者毎の決済情報メッセージを作成します。
* @param paymentInfoList 決済情報リスト
* @param userType himself or familiy
* @returns 決済情報全体を表示するメッセージオブジェクト
*/
createPaymentHistoryMessage(paymentInfoList: PaymentInfoList, userType: "himself" | "familiy") {
const paymentContent = new BoxContent({ layout: "vertical" });
const subjectContent = this.createSubjectMessage(userType);
// 利用者毎にフィルタリングする
const paymentList = paymentInfoList.extractPerUser(userType);
const paymentRecordsContent = this.createPaymentMessage(paymentList);
paymentContent.addContent(subjectContent);
paymentContent.addContent(paymentRecordsContent);
return paymentContent;
}
/**
* 決済情報メッセージの件名部分を作成します。
* @param userType himself or familiy
* @returns 決済情報の件名を表示するメッセージオブジェクト
*/
createSubjectMessage(userType: "himself" | "familiy"): BoxContent {
let subject;
const displayHimself = DISPLAY_HIMSELF ? DISPLAY_HIMSELF : "本人";
const displayFamiliy = DISPLAY_HIMSELF ? DISPLAY_FAMILIY : "家族";
// メッセージ件名部分
const subjectContent = new BoxContent({ layout: "vertical" });
switch (userType) {
case "himself":
subject = new TextContent({ text: `利用者: ${displayHimself}`, weight: "bold" });
break;
case "familiy":
subject = new TextContent({ text: `利用者: ${displayFamiliy}`, weight: "bold" });
break;
}
const separator = new Saparator("sm");
subjectContent.addContent(subject);
subjectContent.addContent(separator);
return subjectContent;
}
/**
* 決済通知メッセージの決済情報部分を作成します。
* @param paymentInfoList
* @returns 決済情報部分を表示するメッセージオブジェクト
*/
createPaymentMessage(paymentInfoList: PaymentInfoList): BoxContent {
// メッセージ履歴レコード部分
const paymentRecordsContent = new BoxContent({ layout: "vertical" });
for (const paymentInfo of paymentInfoList.paymentInfoList) {
paymentRecordsContent.addContent(this.createPaymentMessageRecord(paymentInfo));
}
// 合計金額
const totalAmountRecordContent = this.createTotalAmountRecord(paymentInfoList);
paymentRecordsContent.addContent(totalAmountRecordContent);
return paymentRecordsContent;
}
/**
* 決済情報の 1 レコード部分を作成します。
* @param paymentInfo
* @returns 決済情報の1 レコードを表示するッセージオブジェクト
*/
createPaymentMessageRecord(paymentInfo: PaymentInfo) {
const paymentRecordContent = new BoxContent({ layout: "horizontal", margin: "xs", justifyContent: "flex-start" });
const date = new TextContent({
text: paymentInfo.date,
flex: 3,
});
const store = new TextContent({
text: paymentInfo.store,
flex: 3,
});
const amount = new TextContent({
text: paymentInfo.amount,
align: "end",
flex: 3,
margin: "none",
});
paymentRecordContent.addContent(date);
paymentRecordContent.addContent(store);
paymentRecordContent.addContent(amount);
return paymentRecordContent;
}
/**
* トータル金額を表示するメッセージを作成します。
* @param paymentInfoList 決済情報リスト
* @param isAll true 全て、false 利用者毎
* @returns 合計金額を表示するメッセージオブジェクト
*/
createTotalAmountRecord(paymentInfoList: PaymentInfoList, isAll: boolean = false) {
const totalAmountRecordContent = new BoxContent({
layout: "horizontal",
margin: "sm",
justifyContent: "flex-start",
});
const totalAmount = paymentInfoList.calcTotalAmount();
const subjectText = isAll ? "合計" : "計";
const totalSubject = new TextContent({
text: subjectText,
align: "end",
flex: 4,
});
const totalAmountContent = new TextContent({
text: `${totalAmount.toLocaleString()} 円`,
align: "end",
flex: 2,
});
totalAmountRecordContent.addContent(totalSubject);
totalAmountRecordContent.addContent(totalAmountContent);
return totalAmountRecordContent;
}
}
重複メッセージの防止等、細かい部分の実装はまだまだありますが、主要な機能は以上です。
その内コードは公開する予定なので、興味がある方は覗いていただけますと幸いです。
Tips
さいごに開発の中での Tips を紹介します。
clasp run
で、ローカルPCから GAS を実行
1. GAS を実行する際、GAS の画面から 実行ボタンを押すことで、実行できますが、clasp run
を実行することで、ローカルからも実行することが可能です。
実行ログも clasp log --watch
で確認できます。
clasp run 実行イメージ
clasp run を実行するためには、事前準備が必要です。以下をご参照ください。
2. Exception: We're sorry, a server error occurred. Please wait a bit and try again が発生
clasp run の事前準備で、GCP プロジェクトと紐づける必要があるのですが、紐付け後 GAS を実行すると、タイトルのエラーが発生しました。
紐づけた GCP 側で 使用する API が有効にされている必要があります。
おわりに
最近話題の ChatGPT にどうやって実装すれば聞いてみたのですが、あまり参考になりませんでした。
AI に上手に質問する能力も今後必要になりそうですね。
ChatGPT のチャット画面
Discussion