Zenn
👊

【LookerStudio】Slackにレポートを配信する〜プレビュー画像が有料化しても諦めない編〜

に公開

2025年3月某日、LookerStudioのレポートがSlackに配信されておらず。軽く調べたところ、答えはリリースノートに書いてありました。

https://cloud.google.com/looker-studio/docs/release-notes#March_20_2025

・Image in Preview: A preview of the report will be added to emails only for Pro reports.

どうやら、無料版Looker Studioではメール配信に含まれていたレポートのプレビュー画像が添付されなくったようです。困りました。

プレビュー画像が無いとどうして困るの?

プレビュー画像が無くなってしまって何が問題かというと、それまで稼働していたLookerStudioのレポートをSlackへ配信するための自前のスクリプトが途中で断絶してしまうことにありました。よくある方法ではありますが、ざっくり以下のような流れになってます。

  1. LookerStudioで定期的なメール配信を設定する (LookerStudio)
  2. 配信されたメールに添付された画像を取得する (GAS)
  3. 画像をSlackに送信する (GAS)

まあ無料版を利用し続けているこちらの問題といえばそうですが、画像が添付されなくなっちゃったので、同様の仕組みを実現するためには代替手段が必要になってきました。

対応方法の検討

選択肢① 有料版に移行する

お金で解決できる場合はそれが一番簡単です。有料版に移行するだけでプレビュー画像は復活します。

https://cloud.google.com/looker-studio?hl=ja#section-5

しかし、無料で済むならそっちのほうがいいので、私は粘ります。

選択肢② 画像ではなくPDFを送る

これは場合によりけりですがあり寄りな方法です。元々こっちの方法を取っていた方もいると思います。

ただ、複数レポートを送りたい場合などは画像のほうが見やすいのかなあと思うのと、よく画像としてスライドや議事録に貼り付けてられているので、PDFだと色々面倒になります。そのため、あくまで画像を送信することにこだわってみます。

選択肢③ PDFを画像に変換する

次に考えるのは、PDFの画像変換です。
レポートのPDFファイルは無料版でもまだ添付されているので、添付されているPDFを取得してJPEGにでも変換すればできそうですね。

…と考えますが、GASにそういうAPIが用意されているわけではなく、まだColabとGASの連携はサポートされてないため(2025年4月現在)、結局外部のAPIなどを使う必要が出てきてたりして、途端に腰が重たくなります。

選択肢④ サムネイルを取得する

Google Driveではファイルの種類によってはサムネイルが表示されます。実はそのサムネイルを取得する関数が用意されており、また今回扱うPDFももれなくその対象で、かつそこそこの解像度で画像ファイルが取得できます。

https://developers.google.com/apps-script/reference/drive/file?hl=ja#getthumbnail

PDFを画像に変換する方法ばかり調べていましたが、これを使えばかなり簡単に済ませられそうですね。

前置きが長くなりましたが、今回はDriveのファイルのサムネイルを取得する形で、LookerStudioのレポートをSlackに配信する仕組みを作っていきます。

対応の概要

今回構築するスクリプトの処理を大きく4つに分けます。

  1. LookerStudioから配信されたメールを探す
  2. 添付ファイルをGoogle Driveに保存する
  3. Driveに保存したファイルからサムネイルを取得する
  4. 取得した画像をSlackに送信する

最終的なアウトプットは以下の2つです。

  • Google Drive -> レポートのPDFデータ
  • Slack -> レポートのプレビュー画像(サムネイル)

事前準備として、あらかじめ LookerStudio からメールで配信されているレポートのタイトルを控えておきましょう。

code.gs
const REPORT_TITLE = "Looker Studio Report Title";

function main() {

}

手順① LookerStudioから配信されたメールを探す

LookerStudioから配信されたメールを、控えておいたレポートのタイトルを元に検索して取得します。また、ある程度のまとまりで処理をclassに切り出しながら作成していきます。

Gmailの取得用に以下のclassを定義します。

gmail.gs
class GmailMessageFetcher {
  fetchLookerStudioMessage(title, date) {
    /* 検索用クエリを発行。他の日の分を取ってこないように日付で制限 */
    const afterDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
    const formattedAfterDate = Utilities.formatDate(afterDate, 'JST', 'yyyy/MM/dd');
    const beforeDate = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);
    const formattedBeforeDate = Utilities.formatDate(beforeDate, 'JST', 'yyyy/MM/dd')
    const query =`from:looker-studio-noreply@google.com subject:${title} after:${formattedAfterDate} before:${formattedBeforeDate} `

    const gmailThread = GmailApp.search(query);
    if (gmailThread.length <= 0) {
      throw new Error("not found")
    }

    const messages = gmailThread[0].getMessages();
    return messages[messages.length - 1];
  }
}

Gmail関連の操作はGmailAppを利用します。

https://developers.google.com/apps-script/reference/gmail/gmail-app?hl=ja

LookerStudioから配信されるメールの差出人アドレスはlooker-studio-noreply@google.comになるので、検索用クエリにはfrom:looker-studio-noreply@google.comを指定します。
加えて、過去の日付に遡って実行できるように外からdateを指定できるようにしておいています。
(new Date()をしているのは、Date#setDateによる破壊的変更を避けるためです。)

最後に、見つかったメールの内最新のメッセージを返却します。受け取り側はこのようになります。

code.gs
const REPORT_TITLE = "Looker Studio Report Title";

function main() {
+ const messageFetcher = new GmailMessageFetcher();
+ const today = new Date();

+ // ① Gmailで本日配信分のレポートを取得してくる
+ const message = messageFetcher.fetchLookerStudioMessage(REPORT_TITLE, today)
}

初回実行時は Gmail についての権限リクエストが行われるので、実行アカウントを確認の上承諾してください(LookerStudioから配信されたメールが受け取れるアカウントで実行する必要があります)。Logger.logなどのログ出力を実行して、望みのメールが取れるかをご確認ください。以降の手順でも権限確認が必要になるので、適宜ご対応をお願いします。

また、エラーハンドリングなどは説明の簡単化のためにあえて書いていないので、必要に応じてtry-catchやemptyのチェックなどを追加してください。

手順② 添付ファイルをGoogle Driveに保存する

元々過去のレポートを簡単に見れるようにしたいという要求があったので、日付ごとにDriveにファイルを保存するようにします。Driveの操作にはDriveAppを利用します。

https://developers.google.com/apps-script/reference/drive/drive-app

classも定義します。

drive.gs
class DriveFileManager {
  constructor(id) {
    this.rootFolderId = id
  }

  getFolderByDate(date) {
    const rootFolder = DriveApp.getFolderById(this.rootFolderId);
    const name = Utilities.formatDate(date, 'JST', 'yyyy/MM/dd');
  
    const folders = rootFolder.searchFolders(`title = "${name}"`)
    if (folders.hasNext()) {
      return folders.next()
    } else {
      return rootFolder.createFolder(name);
    };
  }

  getOrCreateFile(folder, blob) {
    const files = folder.searchFiles(`title contains "${blob.getName()}"`);
    if (files.hasNext()) {
      return files.next();
    } else {
      return folder.createFile(blob);
    }
  }
}

フォルダの作成処理と、メールの添付ファイルはBlobとして取得されるので、Blobを受け取ってファイルを作成・保存する処理を用意しました。

他特筆して書くことはありませんが、一応重複して作成させないようにhasNextで分岐をさせています。作成されるフォルダ名は簡易的に日付のみにしていますが、お好みで大丈夫です。

Driveの関連処理をcode.gsに追加します。

code.gs
+const DRIVE_FOLDER_ID = "123456789ABCDEFGHIJKLMNOPQRSTUVWX";
const REPORT_TITLE = "Looker Studio Report Title";

function main() {
  const messageFetcher = new GmailMessageFetcher();
+ const driveManager = new DriveFileManager(DRIVE_FOLDER_ID);
  const today = new Date();

  // ① Gmailで本日配信分のレポートを取得してくる
  const message = messageFetcher.fetchLookerStudioMessage(REPORT_TITLE, today)

+ const folder = driveManager.getFolderByDate(today);
+
+ message.getAttachments().forEach(attachment => {
+   // ② 添付ファイルを指定のフォルダに保存する
+   const file = driveManager.getOrCreateFile(folder, attachment.copyBlob())
+ });
}

一旦実行時点の日付を渡しているだけなので、2025/04/01 のフォルダに 2025/03/31集計分のレポートが入ってる、のようなことが起きる点にご留意ください。実運用においては実行日の一日前の日付を渡すようにしています。

取得してきたメッセージに対して添付ファイルは複数取得される場合があるので、取得されたattachmentの数だけループさせてフォルダに保存します。

手順③ Driveに保存したファイルからサムネイルを取得する

Driveに保存されたファイルはFileというclassで扱えます。

https://developers.google.com/apps-script/reference/drive/file#getthumbnail

Fileには今回の要であるgetThumbnailというメソッドが用意されており、Google Drive上で表示されるプレビュー画像と同等のものがBlob形式で取得することができます。

場所はどこでもいいのですが、今回はドライブ関連の処理であることがわかりやすいようにdrive.gsに追加してあげます。

drive.gs
class DriveFileManager {
...
+ getThumbnailFile(file, name, date) {
+   const thumbnail = file.getThumbnail()
+   const fileName = `${name.replace(/\s/g, "_")}_${Utilities.formatDate(date, 'JST', 'yyyyMMdd')}.png`
+   thumbnail.setName(fileName)
+   return thumbnail
+ }
}

getThumbnailで取得されるBlobに対して任意の名前_日付.pngとして名前をつけてあげています(一応スペースは_に置き換えてます)。また、ここで設定した名前がSlack側にアップロードする際に使われます。

サムネイルの取得処理を元の一連の処理に加えてあげます。

code.gs
const DRIVE_FOLDER_ID = "123456789ABCDEFGHIJKLMNOPQRSTUVWX";
const REPORT_TITLE = "Looker Studio Report Title";

function main() {
  const messageFetcher = new GmailMessageFetcher();
  const driveManager = new DriveFileManager(DRIVE_FOLDER_ID);
  
  const today = new Date();

  // ① Gmailで本日配信分のレポートを取得してくる
  const message = messageFetcher.fetchLookerStudioMessage(REPORT_TITLE, today)

  const folder = driveManager.getFolderByDate(today);

+ const blobs = message.getAttachments().map(attachment => {
- message.getAttachments().forEach(attachment => {
    // ② 添付ファイルを指定のフォルダに保存する
    const file = driveManager.getOrCreateFile(folder, attachment.copyBlob())
+
+   // ③ サムネイルを取得する
+   return driveManager.getThumbnailFile(file, REPORT_TITLE, today);
  });
}

forEachからmapに変更し、後段のSlackへの送信処理に渡しやすいようにサムネイル画像(Blob形式)のリストを作っておきます。

手順④ 取得した画像をSlackに送信する

以前まではよくfiles.uploadAPIが使われていましたが、2025年3月11日に廃止されたようです(なんかまだ動いてる気もしますが)。

https://api.slack.com/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay

そのため、以降はfiles.getUploadURLExternal files.completeUploadExternalを組み合わせて使います。具体的な手順としては、files.getUploadURLExternalによってファイルアップロード用のURLを取得し、アップロード用URLに向けてファイル本体を送ります。最後にfiles.completeUploadExternalを叩いて完了になります。またfiles.getUploadURLExternalはアップロードしたいファイルの分だけ実行可能です。

Slack APIの処理をまとめたclassを以下のように用意します。

slack.gs
class SlackMessageApp {
  constructor(token) {
    this.token = token
  }
  
  post(blobs, message, channelId) {
    const files = blobs.map(blob => { return this._upload(blob) });
    const blocks = this._createBlocks(message);

    if (files.length > 0) {
      this._completeUpload(files, channelId, blocks)
    }
  }

  _upload(blob) {
    const uploadUrlResult = this._getUploadUrl(blob);
    this._uploadFile(uploadUrlResult.upload_url, blob);

    const file = {
      id: uploadUrlResult.file_id,
      title: blob.getName()
    };
    return file
  }

  _getUploadUrl(blob) {
    const url = 'https://slack.com/api/files.getUploadURLExternal';
    const options = {
      method: 'get',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': `Bearer ${this.token}`,
      },
      payload: `filename=${blob.getName()}&length=${blob.getBytes().length}`,
      muteHttpExceptions: true
    };
    const response = UrlFetchApp.fetch(url, options);
    return JSON.parse(response.getContentText());
  }

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

  _createBlocks(message) {
    let blocks = []
    blocks.push(this._createMessageBlock(message))
    return blocks;
  }

  _createMessageBlock(message) {
    return {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": `*${message}*`
      }
    };
  }

  _completeUpload(files, channelId, blocks) {
    const url = 'https://slack.com/api/files.completeUploadExternal';
    const payload = {
      files: files,
      channel_id: channelId,
      blocks: blocks
    };

    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(url, options);
    return JSON.parse(response.getContentText());
  }
}

上記コードについては分量が多いので、抜粋しながら順番に説明します。

slack.gs
  _upload(blob) {
    const uploadUrlResult = this._getUploadUrl(blob);
    this._uploadFile(uploadUrlResult.upload_url, blob);

    const file = {
      id: uploadUrlResult.file_id,
      title: blob.getName()
    };
    return file
  }

  _getUploadUrl(blob) {
    const url = 'https://slack.com/api/files.getUploadURLExternal';
    const options = {
      method: 'get',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': `Bearer ${this.token}`,
      },
      payload: `filename=${blob.getName()}&length=${blob.getBytes().length}`,
      muteHttpExceptions: true
    };
    const response = UrlFetchApp.fetch(url, options);
    return JSON.parse(response.getContentText());
  }

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

files.getUploadURLExternalにはファイル名とファイルサイズを渡すことでアップロード用のURLを取得できます。受け取ったアップロードURLに対しては ContentType とデータを渡してあげましょう。アップロードの成功/失敗が返されるので、必要に応じてハンドリングしてあげます(上記のコードでは端折っています)。

最終的にファイルのIDとファイル名をオブジェクトに包んで返却したものが、後続のfiles.completeUploadExternalで使われます。

slack.gs
  _createBlocks(message) {
    let blocks = []
    blocks.push(this._createMessageBlock(message))
    return blocks;
  }

  _createMessageBlock(message) {
    return {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": `*${message}*`
      }
    };
  }

files.completeUploadExternalには、ファイルのアップロード完了とあわせて、initial_commentblocksのいずれかを渡すことで、Slack投稿時のメッセージを含めることができます。initial_commentblocksのどちらも渡した場合はinitial_commentが優先されるので注意が必要です。

blocksで渡すことができる Block とはSlackのUIフレームワークになります。サンプルではテキストのみですが、テキストの他にボタンなども設置可能です。実運用においてはLooker Studio
やレポートの保存フォルダなどに飛べるようにしています。Block Kit Builderによって簡単にデザインが可能なので、気になる方はぜひ調べてみてください。

https://app.slack.com/block-kit-builder/

今回は後々カスタマイズがしやすいようにリッチな表現が可能なblocksを渡す前提で実装していますが、テキストのみで十分な場合は上記のblocksは用いず、files.completeUploadExternalに対してinitial_commentを渡すようにしてください。

slack.gs
  _completeUpload(files, channelId, blocks) {
    const url = 'https://slack.com/api/files.completeUploadExternal';
    const payload = {
      files: files,
      channel_id: channelId,
      blocks: blocks
    };

    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(url, options);
    return JSON.parse(response.getContentText());
  }

アップロードしたファイルの情報とメッセージ(今回はblocks)、SlackのチャンネルIDをfiles.completeUploadExternalに渡して実行することでSlackにポストされます。

最後に、Slackの関連処理の実行を加えて完了です。

code.gs
const DRIVE_FOLDER_ID = "123456789ABCDEFGHIJKLMNOPQRSTUVWX";
+const SLACK_API_AUTH_TOKEN = "xoxb-000000000000-XXXXXXXXXXXXXXXXXXXXX"
+const SLACK_CHANNEL_ID = "ABCD12345"
const REPORT_TITLE = "Looker Studio Report Title";

function main() {
  const messageFetcher = new GmailMessageFetcher();
  const driveManager = new DriveFileManager(DRIVE_FOLDER_ID);
+ const slackApp = new SlackMessageApp(SLACK_API_AUTH_TOKEN);
  
  const today = new Date();

  // ① Gmailで本日配信分のレポートを取得してくる
  const message = messageFetcher.fetchLookerStudioMessage(REPORT_TITLE, today)

  const folder = driveManager.getFolderByDate(today);
  
  const blobs = message.getAttachments().map(attachment => {
    // ② 添付ファイルを指定のフォルダに保存する
    const file = driveManager.getOrCreateFile(folder, attachment.copyBlob())

    // ③ サムネイルを取得する
    return driveManager.getThumbnailFile(file, REPORT_TITLE, today);
  });
+
+  // ④ Slackに送信する
+  slackApp.post(blobs, "本日のレポート", SLACK_CHANNEL_ID);
}

完成!
あとはこちらのコードを定期実行されるようにトリガーを設定してあげてれば毎日勝手に配信してくれるようになります。
LookerStudioのメール配信より後に確実に実行されるようにしないといけない点にご留意ください。

鬼門はSlack周辺になると思いますが、うまくいかない場合は特に以下の点を確認すると良いです。
エラーレスポンスをログ出力で確認しながら進めましょう。

  • 使用している token の scope に十分な権限が追加されているか (chat:write``files:writeなど)
  • SlackApp が配信したいチャンネルに追加されているか

2025/04/04 追記

確認不足でしたが、どうやらファイルが作成されてからサムネイルが用意されるまでにギャップがあるらしく、同じ一連の処理の中でファイルの作成とgetThumbnailをしてもnullになってしまう可能性があるそう…。以下のようにGmailからDriveへの保存までと、サムネイルの取得からSlackへの送信までで処理を分割したうえでそれぞれに時間差をつけてトリガーを設定してあげれば安全そうです。

code.gs
const DRIVE_FOLDER_ID = "123456789ABCDEFGHIJKLMNOPQRSTUVWX";
const SLACK_API_AUTH_TOKEN = "xoxb-000000000000-XXXXXXXXXXXXXXXXXXXXX"
const SLACK_CHANNEL_ID = "ABCD12345"
const REPORT_TITLE = "Looker Studio Report Title";

function save() {
  const messageFetcher = new GmailMessageFetcher();
  const driveManager = new DriveFileManager(DRIVE_FOLDER_ID);

  const today = new Date();

  // ① Gmailで本日配信分のレポートを取得してくる
  const message = messageFetcher.fetchLookerStudioMessage(REPORT_TITLE, today)

  const folder = driveManager.getFolderByDate(today);
  
  message.getAttachments().map(attachment => {
    // ② 添付ファイルを指定のフォルダに保存する
    driveManager.getOrCreateFile(folder, attachment.copyBlob())
  });
}

function post() {
  const driveManager = new DriveFileManager(DRIVE_FOLDER_ID);
  const slackApp = new SlackMessageApp(SLACK_API_AUTH_TOKEN);
 
  const today = new Date();

  const folder = driveManager.getFolderByDate(today);

  const files = folder.getFiles();
  let blobs = []
  while(files.hasNext()) {
    const file = files.next();
    // ③ サムネイルを取得する
    blobs.push(driveManager.getThumbnailFile(file, REPORT_TITLE, today));
  };

   // ④ Slackに送信する
   slackApp.post(blobs, "本日のレポート", SLACK_CHANNEL_ID);
}

まとめ

LookerStudioの配信メールのプレビュー画像を利用しない Slack へのレポート配信の方法について、GASを中心に紹介させていただきました。配信したいレポートが複数ある場合や複数のチャンネルに配信したい場合などは、スプレッドシードを併用すると簡単に実現できます。

また、今回 token などをベタ打ちしていますが、これら秘密情報を適切に管理するために PropertiesService の利用を推奨します。

https://developers.google.com/apps-script/guides/properties?hl=ja

さいごに、Slackなどの見やすい場所にレポートを掲示することで、メンバーに対して数字を意識してもらう機会を増やすことができます。LookerStudioなどでレポートを作成していてもいまいち浸透していないチームではぜひ取り入れてみてください。

Discussion

ログインするとコメントできます