[GAS]Utilities.unzip()で発生するエラーの原因と解決策

Google Apps Script(以下GAS)で実装されているUtilities.unzip()についてのTipsです。

## 事象

> Exception: Utilities オブジェクトでの unzip メソッドまたはプロパティの取得中に予期しないエラーが発生しました。

後述しますが、このエラーの原因を調査したところ、対象の添付ファイルはGASのAPIでは解凍できないことがわかりました。
従って、このエラーに対する代替策を考えました。

## エラーの原因

さて、このエラーですが、**圧縮ファイル内の文字コードが原因**でした。

通常は以下のようなコードになるかと思います。

```js
var targetId = "Drive上のファイルID"
var currentDirId = "Drive上の親フォルダID(解凍したファイル保管用)"

// zipファイルのBlobを取得
var zipblob = DriveApp.getFileById(targetId).getBlob()

// 解凍して中身を抽出
var files = Utilities.unzip(zipblob)

var folder = DriveApp.getFolderById(currentDirId)

// 中身のファイルをフォルダに作成
for(i=0; i<files.length; i++){
  folder.createFile(files[i])
}
```

以下の手順で問題を切り分けて行きました。

## ローカル上(Mac)で解凍したらどうなるか
こちらは問題なくダウンロード、解凍が行われました。
ファイル名も内容も特に問題はありません。

## 解凍したものを再度zip圧縮してunzip()にかけたらどうなるか
こちらはエラーが発生せず、正常に解凍されました。
zipファイルが壊れているわけでも、unzip()そもそもが動かない、というわけではないようです。

## Pythonで解凍してみたらどうなるか
試しに普通に解凍してみました。

```py
import zipfile

with zipfile.ZipFile('drive/My Drive/test.zip') as existing_zip:
    existing_zip.extractall('drive/My Drive/test')
```

ここでようやく気づいたのですが、ファイル名が文字化けしているではありませんか！

## 文字コードを変換して解凍してみたらどうなるか
ということで、ファイル名の文字コードを変換して解凍してみました。

```py
import zipfile

SRC = 'drive/My Drive/test.zip'
DST = 'drive/My Drive/test'

with zipfile.ZipFile(SRC) as z:
    for info in z.infolist():
        info.filename = info.filename.encode('cp437').decode('cp932')
        z.extract(info, path=DST)
```

これで正しく解凍できました。

## 問題の解決策
原因の切り分けはできました。
しかし、GASはJavaScriptベース。どうやって解凍したものか。

その解決策についてご紹介します。

## 世の中のライブラリに頼る
自前でzip解凍の処理を作るのはかなりハードル高いので、先人達のお知恵を拝借することとしました。
JSのライブラリに頼ることにします。

### 下準備

まずは既存のライブラリをGASライブラリ化して参照できるようにします。

#### imaya/zlib.js

[imaya/zlib.js](https://github.com/imaya/zlib.js/) より引用

> zlib.js は ZLIB(RFC1950), DEFLATE(RFC1951), GZIP(RFC1952), PKZIP の JavaScript 実装です。

このライブラリにはzip解凍の機能も付随されていましたので、こちらを試してみました。

1. 新規にGASのプロジェクトを作成する（プロジェクト名は「unzipjs」としました）
2. binの中にあるunzip.min.jsの中身をスクリプトエディタに貼り付ける
3. [ファイル]→[版を管理]で1版として保存する

#### polygonplanet/encoding.js

[polygonplanet/encoding.js](https://github.com/polygonplanet/encoding.js/blob/master/README_ja.md) より引用

> Converts character encoding in JavaScript.
> JavaScript で文字コード変換をします

そのままですね。文字コード変換用のライブラリです。
こちらもGASライブラリ化します。

1. 新規にGASのプロジェクトを作成する（プロジェクト名は「encodingjs」としました）
2. binの中にあるencoding.min.jsの中身をスクリプトエディタに貼り付ける
3. [ファイル]→[版を管理]で1版として保存する

### 実装
準備が整ったところで、zip解凍処理を実装します。(V8エンジン対応、適宜置き換えてください)

対象のzipファイルの中身がPDF(BINARY、UTF32）、XMLファイル(UTF8)でしたので、今回はUTF8、BINARY、UTF32のみを対象として処理を記載します。

```js
  // zipファイルからバイトから配列を取得
  let byteary = DriveApp.getFileById('zipファイルのID').getBlob().getBytes()
  
  // Uint8Arrayに変換
  let a = new Uint8Array(byteary)
  
  // unzip.min.jsを実行
  let unzip = new unzipjs.Zlib.Unzip(a)
  
  // 圧縮ファイル内のファイル名を取得
  let filenames = unzip.getFilenames()
  
  // 解凍先のフォルダ
  let folder = DriveApp.getFolderById('解凍先のフォルダID')
  
  // ファイル変換処理
  for (const i in filenames) {
    const filename = filenames[i]
    
    // 解凍してUint8Arrayを抽出
    let u8 = unzip.decompress(filename)
    // 文字列に変換
    let s = Array.from(u8, e => String.fromCharCode(e)).join("")
    
    // ファイル名の文字化けを解消
    let convertedFilename = encodingjs.Encoding.convert(filename)
    
    // 文字コードを判定
    let enc = encodingjs.Encoding.detect(u8)
    // UTF8ファイルの場合
    if (enc == 'UTF8') {
      // 文字列をエンコードしてBlobを作成
      s = decodeURIComponent(escape(encodingjs.Encoding.convert(s, 'UTF8')))
      let blob = Utilities.newBlob('', 'application/octet-binary', convertedFilename).setDataFromString(s).setContentTypeFromExtension()
      // Drive上にファイルを作成
      folder.createFile(blob)
    // バイナリ、UTF32
    } else if (enc == 'BINARY' || enc == 'UTF32') {
      // Uint8Arrayからバイト配列を抽出
      let ba = []
      for (const value of u8.values()) {
        ba.push(value)
      }
      // Blobを作成
      let blob = Utilities.newBlob(ba, null, convertedFilename).setContentTypeFromExtension()
      // Drive上にファイルを作成
      folder.createFile(blob)
    } else {
      // それ以外は個別に実装
    }
  }
```
# おわりに
GAS単体で解決できない問題は他にも存在すると考えています。
せっかく便利なサービスですので、こうした問題への解決策を今後も取り上げて行きたいと思います。
ありがとうございました。