🔖

Google Apps Script(GAS) で 楽天カードの使用履歴を通知する LINE Bot を作ってみた

2023/01/07に公開

はじめに

GAS で楽天カードの使用履歴を LINE に通知する LINE Bot を作成しました。

楽天カードは「カードお知らせメール」でカード利用履歴をメールで送信してくれるのですが、
メールだと見逃しちゃうのと、私は、家族カードも使用しているので、使用履歴を LINE で簡単に共有できたらといいなと思い作ってみました。

作ったもの

利用者毎の使用履歴を LINE に通知してくれます。


LINE 通知イメージ

通知以外にも、将来的に月ごとのサマリーや、家計簿に使えたら面白いかなーと思って使用履歴をスプレッドシートに保存する機能も併せて実装しました。


スプレッドシート保存

https://twitter.com/goubou5/status/1609067721576316928?s=20&t=JTrT5KO0RGB8dFjplaNJsw

構成

楽天カードの履歴を取得する API が調べた限りはなかったので、1日1回メールを取得して、メール本文からLINE 通知メッセージを作成してプッシュする構成としています。

構成図

Google Apps Script

Google Apps Script 通称 GAS は Google が提供するローコードのプラットフォームです。
Gmail や Google Drive 、 Google Calendar 等と簡単に連携でき、それらを用いた自動化ツールや拡張機能を実装することが可能です。

また、独自なフレームワークはなく、JavaScript で実装できるのも特徴です。

https://workspace.google.co.jp/intl/ja/products/apps-script/

開発環境

clasp

clasp は、GAS をローカルで開発できるように作成された CLI ツールです。

https://github.com/google/clasp

clasp を使用すれば、自分の好きなエディタで TypeScript で開発することが可能です。
開発環境の構築については、以下の記事を参考にさせていただきました。

https://panda-program.com/posts/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 を使用します。

https://developers.google.com/apps-script/reference/gmail/gmail-app#searchquery,-start,-max

第一引数が、メールを取得するクエリになりますが、今回は以下条件でクエリを定義しています。

  • 件名がカード利用のお知らせ(本人・家族会員ご利用分)と一致していること
  • 速報版という文字列は除外すること

クエリの構文は以下をご参照ください。

https://support.google.com/mail/answer/7190?hl=ja

取得したメッセージから、getPlainBody() すると、メール本文を文字列で取得できます。
getBody()で HTML を取得できますが、HTML構造が複雑すぎたため、単純に文字列に対して正規表現で、欲しい情報を取得しました。

https://developers.google.com/apps-script/reference/gmail/gmail-message#getPlainBody()

ちなみに、メール本文はこんな感じです。(一部抜粋)

メール本文
メール本文
━━━━━━━━━━
カード利用お知らせメール
━━━━━━━━━━
楽天カードを装った不審なメールにご注意ください
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("■利用金額: ");
  }
}

スプレッドシート保存

メール本文から取得した決済情報は、スプレッドシートにも保存しています。
保存処理は以下ロジックで実装しています。

  1. 楽天カード決済履歴シート_現在の年(西暦)でファイルを作成
  2. 既に作成されている場合はファイル ID で読み込み、ない場合は新規作成
  3. 対象のシートを読み込む
  4. 既に作成されている場合はシート名で読み込み、ない場合は新規作成(シートは月毎に作成)
  5. 対象のシートの末尾に決済情報を追加
スプレッドシート操作用コード
spreadSheet.ts
/**
 * スプレットシート操作用クラス
 */
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);
  }
}

レコードの書き込みは以下の記事を参考にさせていただきました。

https://tetsuooo.net/gas/1107/

LINE 通知

LINE通知 は、GAS から Messaging API の push-message を POST することでメッセージを送信しています。
LINE にはメッセージタイプがいくつかありますが、一番幅広い表現が可能な FlexMessage を使用しました。

https://developers.line.biz/ja/reference/messaging-api/#send-push-message

Messaging API は SDK が提供されていますが、GAS で npm install した package を利用するには少々手間がかかります。

以下の記事が参考になりますが、私は断念しました。。。

https://qiita.com/suzukenz/items/dbe13d5f8884752a37f8

GAS では GAS で公開しているアプリケーションをライブラリとして追加することができます。
左ペインのライブラリから、スクリプト ID で追加できます。

LINE Bot SDK を GAS 用に公開している方がいましたので、今回はこちらを使用させていただきました。

https://github.com/kobanyan/line-bot-sdk-gas

appsscript.json に以下を追加して、clasp push して取り込むことも可能です。

appsscript.json
{
  "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 を作成する部分が一番大変だったのですが、説明も大変なので、コードを載せるだけに留めます。

通知メッセージ作成用コード
noticePaymentMessage.ts
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 を紹介します。

1. clasp run で、ローカルPCから GAS を実行

GAS を実行する際、GAS の画面から 実行ボタンを押すことで、実行できますが、clasp run を実行することで、ローカルからも実行することが可能です。

実行ログも clasp log --watch で確認できます。


clasp run 実行イメージ

clasp run を実行するためには、事前準備が必要です。以下をご参照ください。

https://github.com/google/clasp/blob/master/docs/run.md#prerequisites

2. Exception: We're sorry, a server error occurred. Please wait a bit and try again が発生

clasp run の事前準備で、GCP プロジェクトと紐づける必要があるのですが、紐付け後 GAS を実行すると、タイトルのエラーが発生しました。

紐づけた GCP 側で 使用する API が有効にされている必要があります。

https://qiita.com/sa9ra4ma/items/8b25695b0f23435cdf64

おわりに

最近話題の ChatGPT にどうやって実装すれば聞いてみたのですが、あまり参考になりませんでした。
AI に上手に質問する能力も今後必要になりそうですね。

ChatGPT のチャット画面

Discussion