📈

Google Apps Script(GAS)を使って、Looker Studioで作成したレポートをSlackに定期配信する

2023/12/09に公開

こんにちは。株式会社ZOZO 計測プラットフォーム開発本部 計測アプリ部 iOSブロックの@na9ainです。

Looker Studioは、Google AnalyticsやBigQueryなどと接続して、グラフや表が自動で更新されるレポートを作成できる超便利な無料ツールです。

https://cloud.google.com/looker-studio

しかし、たった1つだけ弱点があります。そう、レポートの配信方法がメールのみなのです。

そこで、本記事では、Google Apps Scriptを使って、Looker Studioで作成したレポートをSlackに定期配信する仕組みとその設定方法を紹介します。

完成イメージ

先に完成イメージを共有します。


ZOZOFITというアプリの日次レポート

こちらの例では、アプリのアクティブユーザー数や問い合わせ数などの重要指標を日毎に可視化したレポートをLooker Studioで作成し、それを毎日12:00にSlackのチャンネルに配信しています。

定期配信する仕組み

全体像

仕組みの全体像はこちらです。

設定方法

それぞれのサービスの設定について細かい部分まで説明すると長くなってしまうので、ここではこの記事特有の部分や要所のみに絞って説明します。

Slack

レポート配信用のSlackアプリを作成し、OAuth & Permissions > Scopes > Bot Token Scopesfiles:writeを追加します。

その後、ワークスペースにアプリをインストールし、レポートを配信したいチャンネルにアプリを追加します。

また、以降の設定で下記の2つが必要になるため控えておきます。

  • アプリのBot User OAuth Token
  • 配信したいチャンネルのID

Looker Studio

Looker Studioで作成したレポートを開き、共有 > 配信のスケジュールからメールの宛先・件名・時刻・頻度などを設定します。

なお、メールの宛先はGoogle Apps Scriptで利用するアカウントと同じになるようにしてください。
また、以降の設定で下記が必要になるため控えておきます。

  • メールの件名

Google Apps Script

プロジェクトを作成し、以下の設定を行います。

トリガーの設定

Google Apps Scriptではトリガー > トリガーを追加から実行する関数・時刻・頻度などを設定します。

このとき、Google Apps Scriptのトリガー実行時刻が、Looker Studioのメール配信時刻よりも必ず後になるように設定しなければいけないことに注意してください。

スクリプト

下記のようなスクリプトを設定します。

また、スクリプト内の変数にここまでの設定で控えていた以下3つを設定します。

変数名 設定する値
SEARCH_QUERY メールの件名(subject:******の部分)
SLACK_APP_BOT_TOKEN アプリのBot User OAuth Token
SLACK_CHANNEL_ID 配信したいチャンネルのID
post_daily_report.gs
const SEARCH_QUERY = 'from:"looker-studio-noreply@google.com" subject:Daily Report';
const SLACK_APP_BOT_TOKEN = 'xoxb-00000000000-0000000000000-XXXXXXXXXXXXXXXXXXXXXXXX';
const SLACK_CHANNEL_ID = 'C00000000';
const SLACK_POST_MESSAGE = 'Daily report is updated.';

function main() {
  const fetcher = new GmailAttachmentFetcher();
  const uploader = new SlackFileUploader(SLACK_APP_BOT_TOKEN);
  const attachments = fetcher.getLatestAttachmentsFromEmail(SEARCH_QUERY);
  const imagesToUpload = attachments.filter(attachment => attachment.getName().endsWith('.jpg'));
  uploader.post(imagesToUpload, SLACK_CHANNEL_ID, SLACK_POST_MESSAGE);
}

class GmailAttachmentFetcher {
  getLatestAttachmentsFromEmail(searchQuery) {
    const threads = GmailApp.search(searchQuery, 0, 1);
    if (threads.length === 0) {
      throw new Error('No threads found matching the search query.');
    }
    const messages = threads[0].getMessages();
    const latestMessage = messages[messages.length - 1];
    return latestMessage.getAttachments();
  }
}

class SlackFileUploader {
  constructor(token) {
    this.token = token;
  }

  post(fileBlobs, channelId, initial_comment) {
    const files = fileBlobs.map(fileBlob => {
      const fileName = fileBlob.getName();
      const fileSize = fileBlob.getBytes().length;
      const getUploadUrlResult = this.getUploadURL(fileName, fileSize);
      if (!getUploadUrlResult.ok) {
        Logger.log(getUploadUrlResult);
        throw new Error('Failed to get upload URL');
      }

      const uploadFileResult = this.uploadFile(getUploadUrlResult.upload_url, fileBlob);
      if (!uploadFileResult) {
        throw new Error('Failed to upload file');
      }

      const fileId = getUploadUrlResult.file_id;
      const fileObject = {
        id: fileId,
        title: fileName
      };
      return fileObject;
    });

    if (files.length > 0) {
      const completeUploadResult = this.completeUpload(files, channelId, initial_comment);
      if (!completeUploadResult.ok) {
        Logger.log(completeUploadResult);
        throw new Error('Failed to complete file upload');
      }
      Logger.log('Result of completing upload is here')
      Logger.log(completeUploadResult);
    }
  }

  getUploadURL(fileName, fileSize) {
    const url = 'https://slack.com/api/files.getUploadURLExternal';
    const payload = {
      filename: fileName,
      length: fileSize
    };
    const options = {
      method: 'get',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': 'Bearer ' + this.token,
      },
      payload: this._toFormUrlEncoded(payload),
      muteHttpExceptions: true
    };
    const response = UrlFetchApp.fetch(url, options);
    return JSON.parse(response.getContentText());
  }

  uploadFile(uploadUrl, fileBlob) {
    const options = {
      method: 'post',
      contentType: fileBlob.getContentType(),
      payload: fileBlob.getBytes(),
      muteHttpExceptions: true
    };
    const response = UrlFetchApp.fetch(uploadUrl, options);
    return response.getResponseCode() === 200;
  }

  completeUpload(files, channelId, initial_comment) {
    const payload = {
      files: files,
      channel_id: channelId,
      initial_comment: initial_comment,
    };

    const options = {
      method: 'post',
      headers: {
        'Authorization': 'Bearer ' + this.token,
        'Content-Type': 'application/json; charset=utf-8'
      },
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    };
    const response = UrlFetchApp.fetch('https://slack.com/api/files.completeUploadExternal', options);
    return JSON.parse(response.getContentText());
  }

  _toFormUrlEncoded(obj) {
    return Object.keys(obj)
      .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]))
      .join('&');
  }
}

おわりに

Looker Studioでも、GASと組み合わせることで、有料版のLookerでしかサポートされていないSlackへの配信を実現することができます。

素敵なLooker Studioライフを!

補足 #1

Looker Studioでメール配信する宛先をSlackチャンネルのメールアドレスにすればGoogle Apps Scriptを使わずに済むんじゃないかと思った方がいるかもしれません。

私も、Google Apps Scriptを使う前にこの方法を試してみたのですが、メールがそのまま共有される形になるため下記のような点が気になりました。

  • 余分な情報・空白が多く、見づらい
  • レポートを拡大して詳しく見るためには別ウィンドウを開く必要がある
  • 添付ファイルは削除されるため、ファイル検索ができない

補足 #2

GASにはスクリプトプロパティというものが設定できるので、Botトークンはベタ書きではなく、スクリプトプロパティから値を読み出す方がよさそうです。説明が必要以上に長くなるのを防ぐため、記事本文からは省略しました。

Discussion