アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

GAS で Zip 圧縮された Excel を解凍しようとしてエラーが出た話

morishitaです。

いこーよは外部サービスとの連携をいくつかしています。
チケット販売もいくつかのサービスとの連携をしています。

その様な連携先の1つに毎日の実績データの Excel ファイルをメールに添付して送ってくるものがありました。
担当者に毎日メールされてくるので、それを Google Drive 上の共有ディレクトリに保存して共有するということをしていました。
日次実績ファイルを1つづつ開いて確認するのは面倒ですし、日次集計の数字を見ていても週間、月間でのトレンドを把握するのも難しいです。
それを解決しようと担当者が Data Studio で可視化する様にしてくれました。
ただ、結局メールの添付ファイルを共有ディレクトリに保存するとという手作業が残っているのでそれを自動化したいという依頼を受けました。
その中で、ちょっとハマったところがあったのでそれについて書きます。

要件

前述の自動化したい作業で毎日実行したい1回の処理の要件は次の通りです。
ちなみにアクトインディでは全社で G Suite を利用しています。

  1. 特定の From アドレスから特定の受信アドレスに送信されてくるメールを探す
  2. 上記メールの添付ファイル(Zip 圧縮された Excel ファイル)を解凍する
  3. 取り出した Excel ファイルを指定された Google Drive 上のフォルダーに保存する

この要件を満たすのに最も便利なツールは Google Apps Scripts 、通称 GAS と見た瞬間に思いました。
GAS ならば、Gmail にも Google Drive にも簡単にアクセスできます。
しかも、定期実行できる実行環境も用意されています。
たしか、Zip ファイルの解凍だってできたはず!

ということで GAS を使うことにしました。

GAS の標準ユーティリティでは解凍できない Zip ファイルだった!

いつものように clasp で GAS のプロジェクトを作成し、 Typescript で実装し始めました。
メールを取得するのも、そこから添付ファイルを取り出すのも、そのファイルを Google Drive に保存するのも難なく実装できました。

エラー処理や細かいところで雑ですが、次の様なコードです。
定期的に実行するのは run() 関数で、他の関数はその中で必要な処理を分割したものになっています。

/**
 * メールボックスから条件にあうメールを取得する。
 * @param query メールの検索条件
 * @returns メールのリスト
 */
function fetchMessagezs_(query: string) {
  const messages = Gmail.Users.Messages.list('me', { q: query });
  if (messages.resultSizeEstimate == 0) return [];
  return messages.messages.map(msg => {
    return GmailApp.getMessageById(msg.id);
  });
}

/**
 * メールの添付ファイルから Excel ファイル
 * @param message メールオブジェクト
 * @returns 添付ファイルから取り出した Excel ファイル
 */
function extractExcel_(message: GoogleAppsScript.Gmail.GmailMessage) {
  const zipFile = message.getAttachments()[0];
  const excelFile = unzipExcel_(zipFile);
  return excelFile;
}

/**
 * Zip 圧縮された Excel ファイルを解凍する
 * @param zipFile Zip 圧縮された Excel ファイル
 * @returns 解凍したファイル
 */
function unzipExcel_(zipFile: GoogleAppsScript.Gmail.GmailAttachment) {
  return Utilities.unzip(zipFile.copyBlob())[0];
}

/**
 * Google Drive にファイルを保存する。
 * @param file 保存するファイル
 */
function saveToGDrive_(file: GoogleAppsScript.Base.Blob): void {
  const folderId = '<保存先のフォルダID>';
  const folder = DriveApp.getFolderById(folderId);
  folder.createFile(file);
}

/**
 * 定期実行する関数
 */
function run() {
  const query = '<メールの検索条件>';
  const messages = fetchMessagezs_(query);
  messages.forEach(msg => {
    const excelFile = extractExcel_(msg)
    saveToGDrive_(excelFile);
  });
}

自分で作った Zip 圧縮した Excel ファイルを添付したメールではうまく動作しました。 しかし、連携先から送信されてくるホンモノを処理してみるを次のようなエラーが発生しました。

Exception: Unexpected error while getting the method or property unzip on object Utilities.

f:id:HeRo:20210926170813p:plain
unzip でのエラー

どうも Zip の解凍でエラーが出ているようです。
Zip の解凍では標準ユーティリティである Utilities.unzip() を利用しています。
調べてみると、Zip 内のファイル名がどうも ShiftJIS になっているのが良くないようです。
ファイル名を無視して解凍する方法もなさそうだし、うーむどうしようか…。

別のライブラリを使って解凍する

なにか他の方法はないかと探してみると GAS 上でも動く JavaScript で実装された Unzip 実装を見つけました。

次のリポジトリで公開されています。

この中の zlib.js/bin/unzip.min.js をまるっとコピーして GAS のソースに加えます。

これを使って前述のソースの中の unzipExcel_() 関数を次の様に書き換えます。

function unzipExcel_(zipFile: GoogleAppsScript.Gmail.GmailAttachment): GoogleAppsScript.Base.Blob {
  const filename = zipFile.getName();
  const byteData = new Uint8Array(zipFile.getBytes());
  const unzipedData = new Zlib.Unzip(byteData);
  const filenameInZip = unzipedData.getFilenames()[0];
  const extractData = unzipedData.decompress(filenameInZip);
  const contentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  const fileName =  filename.replace('.zip', '.xlsx')
  const blobXlsx = Utilities.newBlob(extractData, contentType, fileName);
  return blobXlsx;
}

解凍後のファイル名は文字コードを変換したものを使うほうがいいですが、今回のケースでは結局 Zip ファイルと拡張子違うだけのファイル名だったので、拡張子を置換するのみにしています。

Typescript だと、いきなり Zlib なんて使うと定義されていないと VSCode などのエディタでは警告が出るので次の様な型定義ファイルを types/unzipjs.d.ts 等においておくと良いでしょう。

declare namespace Zlib{
  class Unzip {
    constructor(data: Uint8Array);
    getFilenames(): string[];
    decompress(name: string): number[];
  }
}

なお、エディタ上に警告が出て鬱陶しいだけで、この型定義ファイルがなくても clasp push で問題なくコードをトランスパイルして GAS 上にアップロードできます。

ライブラリとして利用する

ソースに zlib.js/bin/unzip.min.js を加える方法でもいいのですが、人のコードを丸コピーで自分のリポジトリに入れるのはちょっと…とか、他の GAS プロジェクトでも使いたくなるたびにコピーするのもどうかなぁと思うかもしれません。
そんなときには、GAS のライブラリとして zlib.js/bin/unzip.min.js を公開して利用すれば良いです。

ライブラリの作成

zlib.js/bin/unzip.min.js をライブラリとして登録する手順は次の通りです。ローカルでソースを実装したいわけではないので、GAS の Web エディタ上の操作で作成します。

  1. 別の GAS プロジェクトを新たに作成する
  2. そのプロジェクトにファイルを追加し、zlib.js/bin/unzip.min.js をコピー&ペーストする
  3. Web エディタの右上のデプロイボタンをクリック、「新しいデプロイ」を選択する
    f:id:HeRo:20210926171254p:plain
    新しいデプロイ
  4. 表示されるダイアログの種類の選択で「ライブラリ」を選択デプロイボタンをクリック
    f:id:HeRo:20210926171348p:plain
    ライブラリとしてデプロイ
  5. プロジェクトの設定を開いてスクリプトID を控えておきます。

ライブラリの利用

ライブラリを作成したら、それを利用したい側の GAS プロジェクトを Web エディタで開きます。
次の手順でライブラリをプロジェクトに追加します。

  1. ライブラリの「+」(ライブラリを追加)をクリックする
    f:id:HeRo:20210926171457p:plain
    ライブラリを追加
  2. ダイアログが開くのでスクリプト IDに先程控えたライブラリのスクリプト ID を入力する
  3. 検索すると先程のライブラリが見つかるので、バージョン1を選択する
  4. ID にコード内でライブラリを参照する際の名前を入力し、追加ボタンをクリックする。
    • ここでは ID を Unzipjs として以下説明します。
      f:id:HeRo:20210926171544p:plain
      ライブラリの追加

これで、コードから Unzipjs という名前で参照できるようになりました。

さて、コードの変更ですが、先に型定義ファイルを示すとライブラリとして利用する場合、次のようにネームスペース Unzipjs で包まれた形になります。

declare namespace Unzipjs{
  namespace Zlib{
    class Unzip {
      constructor(data: Uint8Array);
      getFilenames(): string[];
      decompress(name: string): number[];
    }
  }
}

したがって、前述の unzipExcel_() を変更すると次の様になります。

function unzipExcel_(zipFile: GoogleAppsScript.Gmail.GmailAttachment): GoogleAppsScript.Base.Blob {
  const filename = zipFile.getName();
  const byteData = new Uint8Array(zipFile.getBytes());
  const unzipedData = new Unzipjs.Zlib.Unzip(byteData); // <== この行を変更。
  const filenameInZip = unzipedData.getFilenames()[0];
  const extractData = unzipedData.decompress(filenameInZip);
  const contentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  const fileName =  filename.replace('.zip', '.xlsx')
  const blobXlsx = Utilities.newBlob(extractData, contentType, fileName);
  return blobXlsx;
}

ソースコードに追加していたzlib.js/bin/unzip.min.jsは削除して構いません。

まとめ

GAS の標準ユーティリティの Utilities.unzip()では、Zip 内のファイル名も UTF-8 でなければエラーが発生して使えないという話でした。 でも、 zlib.js/bin/unzip.min.jsを使えば解決できます。

最後に

アクトインディではエンジニアを募集しています。

actindi.net

参考