LoginSignup
6
2

More than 1 year has passed since last update.

Slackからエクスポートしたデータをきれいに見れるビューワーを作った話

Last updated at Posted at 2022-09-04

Slack フリープランでのメッセージ、ファイルの保存期間が 90日間 になって困った友人から相談を受けたのがきっかけ

  • React.js + MUI でサクッと
  • Slack からエクスポートした zip ファイルをアップロードするだけ
    image.png
    デモサイト : Slack Archive Viewer

何ができるの?

  • Slackライクな UI 上でログが読めます
  • 画像、ファイル等も表示および保存ができます
  • ローカル(ブラウザ)にデータを保存できます1

作ってみて面倒だったこと

zip 解凍→マルチバイト文字列が文字化け

解凍には定番の zlib.js を使用
解凍後、チャンネル名が日本語の場合に文字化けが発生するので encoding.js を使用する事で回避

import Encoding from 'encoding-japanese';
import {Zlib} from '../node_modules/zlibjs/bin/unzip.min.js';

const unzipSlackExportFiles = (event, callback) => {
    const zipfile = event.target.files?.[0] || null;

    if(!zipfile){
        alert('Please select a zip file');
        return;
    }
    const slackName  = zipfile.name.replace(/\sSlack\sexport.*zip$/, '');
    const fileReader = new FileReader();

    fileReader.onload = function(evt) {
        const buffer = evt.target.result;
        const uint8Array = new Uint8Array(buffer);
        const unzip = new Zlib.Unzip(uint8Array);
        const lists = unzip.getFilenames();
        const slackData = {
            setting  : {},
            channels : {}, 
            workSpace: slackName,
        };
        lists.forEach(list => {
            const name = Encoding.convert(list,"UNICODE","AUTO");
            if(list.slice(-1) !== '/'){
                console.log({name});
                const dataAsU8Array = unzip.decompress(list);
                const jsonString = Buffer.from(dataAsU8Array).toString('utf8');
                const json = JSON.parse(jsonString);
                const path = name.split('/');
                if(path.length > 1){
                    const [
                        pathName,
                        fileName,
                    ] = path;
                    const yyyymmdd = fileName.replace('.json', '');
                    if(slackData.channels[pathName] === undefined){
                        slackData.channels[pathName] = {};
                    }
                    slackData.channels[pathName][yyyymmdd] = json;
                }else{
                    slackData.setting[name.replace('.json', '')] = json;
                }
            }
        });
        console.log({slackData});
        callback({
            [slackName] : slackData,
        }, slackName);
    };
    fileReader.readAsArrayBuffer(zipfile);
}

ワークスペース名は json ファイルに含まれていないようなので、 zip のファイル名から取得

親スレッド / 子スレッドの判定

zipを解凍すると中身はこんな感じ

export.zip
├── channels.json
├── integration_logs.json
├── users.json
└── {channel name}
    ├── YYYY-MM-DD.json
    ...
    └── YYYY-MM-DD.json

最初に各チャンネルのログ (/{channel_name}/YYYY-MM-DD.json) をざっと見た感じ下記の仕様でいけるように見えた

  • client_msg_id + replies パラメータを持つログが親スレッド
  • client_msg_id を持たないログが子スレッド
log.json
[{
    "client_msg_id": "c5f2db7d-ae1a-46dc-8b7b-a5b90d3668ca",
    "type": "message",
    "text": "xxxxxx",
    "user": "U017T8Z3U86",
    "ts": "1618660451.001300",
    "team": "T017YKHG8R3",
    "user_team": "T017YKHG8R3",
    "source_team": "T017YKHG8R3",
    "user_profile": {...},
    "replies": [
        {
            "user": "U01LC16DU72",
            "ts": "1618708626.000100"
        }
    ],
    ...
},{...}]

が、上記は間違いで下記の仕様に変更

  • replies を持つログを親スレッド
  • replies 配列から user + ts でログを検索して子スレッドを判定

当初、親スレッドのログには必ず client_msg_id が存在すると思っていたが無いログが散見された為、
client_msg_id が存在しないログに関しては user + ts を ID の代わりにした

ログのフォーマット

Slack からエクスポートしたデータの読み方 に最低限のフォーマットの解説はあリマス

添付ファイルの保存

エクスポートした zip には画像をはじめとした添付ファイル等は含まれない
データは Slackサーバー( https://files.slack.com/ )に保存されていて、 json ファイル内にパスが書いてあります

[{
    "files": [
        {
            "id": "F040R2PCH40",
            "created": 1661653151,
            "timestamp": 1661653151,
            "name": "image.png",
            "title": "image.png",
            "mimetype": "image\/png",
            "filetype": "png",
            "pretty_type": "PNG",
            "user": "U040E0FV46M",
            "editable": false,
            "size": 209178,
            "mode": "hosted",
            "is_external": false,
            "external_type": "",
            "is_public": true,
            "public_url_shared": false,
            "display_as_bot": false,
            "username": "",
            "url_private": "https:\/\/files.slack.com\/files-pri\/T0403QUD0EQ-F040R2PCH40\/image.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "url_private_download": "https:\/\/files.slack.com\/files-pri\/T0403QUD0EQ-F040R2PCH40\/download\/image.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "media_display_type": "unknown",
            "thumb_64": "https:\/\/files.slack.com\/files-tmb\/T0403QUD0EQ-F040R2PCH40-e125949425\/image_64.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "thumb_80": "https:\/\/files.slack.com\/files-tmb\/T0403QUD0EQ-F040R2PCH40-e125949425\/image_80.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "thumb_360": "https:\/\/files.slack.com\/files-tmb\/T0403QUD0EQ-F040R2PCH40-e125949425\/image_360.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "thumb_360_w": 360,
            "thumb_360_h": 244,
            "thumb_480": "https:\/\/files.slack.com\/files-tmb\/T0403QUD0EQ-F040R2PCH40-e125949425\/image_480.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "thumb_480_w": 480,
            "thumb_480_h": 325,
            "thumb_160": "https:\/\/files.slack.com\/files-tmb\/T0403QUD0EQ-F040R2PCH40-e125949425\/image_160.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "thumb_720": "https:\/\/files.slack.com\/files-tmb\/T0403QUD0EQ-F040R2PCH40-e125949425\/image_720.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "thumb_720_w": 720,
            "thumb_720_h": 487,
            "thumb_800": "https:\/\/files.slack.com\/files-tmb\/T0403QUD0EQ-F040R2PCH40-e125949425\/image_800.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "thumb_800_w": 800,
            "thumb_800_h": 542,
            "thumb_960": "https:\/\/files.slack.com\/files-tmb\/T0403QUD0EQ-F040R2PCH40-e125949425\/image_960.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "thumb_960_w": 960,
            "thumb_960_h": 650,
            "thumb_1024": "https:\/\/files.slack.com\/files-tmb\/T0403QUD0EQ-F040R2PCH40-e125949425\/image_1024.png?t=xoxe-4003844442500-4056197772560-4034953908116-4bc6374563bac811389d699126e26a02",
            "thumb_1024_w": 1024,
            "thumb_1024_h": 693,
            "original_w": 1616,
            "original_h": 1094,
            "thumb_tiny": "AwAgADDTP0zTc\/7JpxpPxoAPwpcCkyOmaMigBcCijIooADnBwMmoGuoFYqzgEcHg1MxwpPAwO9V3tYXcswOSc\/eoAUXcBPDAnrwD\/hU4OQCOhqstpEpyuQSMZ3mrC\/KABjAFADqKTn2paAP\/2Q==",
            "permalink": "https:\/\/export-eog3912.slack.com\/files\/U040E0FV46M\/F040R2PCH40\/image.png",
            "permalink_public": "https:\/\/slack-files.com\/T0403QUD0EQ-F040R2PCH40-4466d3eace",
            "is_starred": false,
            "has_rich_preview": false,
            "file_access": "visible"
        }
    ]
}]

これらのデータもいずれは削除されて閲覧不可になるので保存したい...が、

  • DB準備したりするの面倒
  • fetchしてzipで圧縮してローカルに保存するにしてもデータサイズが予測できないので不安(最悪 JSON.parse できるサイズを超える可能性大)

どこに保存するべきか悩んだ結果

ブラウザに内蔵されている IndexedDB に保存すれば良いじゃないか!

  • 各種モダンブラウザで利用可能
  • ストレージサイズは PC のストレージの 1/2 まで利用可能 (内蔵ストレージが 100GB なら 50GB まで利用可能すごいぜ)

素の IndexedDB API は扱いにくいので Dexie.js を使用

  1. fetch  で slack サーバからファイルを取得
  2. blob データで取得して content-type を取得
  3. blob データを base64 に変換
  4. IndexedDB に保存
import Dexie from 'dexie';

const db = new Dexie('slackViewerDB');
db.version(1).stores({
    store: '&id',
});
const fetchFile = async (params) => {
    const {
        id,
        url,
    } = params;
    let successfulToSave = false;
    const file = await fetch(url)
        .then(res => {
            if(res.status === 200){
                return res.blob().then(blob => ({
                    contentType: res.headers.get("Content-Type"),
                    blob: blob
                }));
            }
            throw new Error('File does not exist');

        })
        .catch(err => {
            console.error(err)
            return null;
        });
    if(file){
        const {
            blob,
            contentType,
        } = file;
        const base64 = await convertBlobToBase64(blob);
        db.store.put({
            id          : id, 
            contentType : contentType,
            data        : base64,
        });
        successfulToSave = true;
    }
    return {
        id,
        successfulToSave,
    };
}
const convertBlobToBase64 = (blob) => new Promise((resolve, reject) => {
    const reader = new FileReader;
    reader.onerror = reject;
    reader.onload = () => {
        resolve(reader.result);
    };
    reader.readAsDataURL(blob);
});

当然 CORS(Cross-Origin Resource Sharing error) エラーが発生する

ドメインが異なるslackサーバに対して fetch するので CORS エラーが発生します
お行儀が悪いですが下記のいづれかの方法で回避

ブラウザの起動オプションを指定して回避

Windows
[PATH_TO_CHROME]chrome.exe" --disable-web-security --disable-gpu --user-data-dir=~/chromeTemp
Mac OS
open -n -a /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --args --user-data-dir="/tmp/chrome_dev_test" --disable-web-security

CORS エラー回避アドオンをブラウザにインストール

90日間を超えてもログが見えるように

土日を使ってツール作った翌日、きれいにログが見えなくなってた
image.png
なお、ログが見えなくなった状態 = 即データ削除ではない模様
本ツールで https://files.slack.com/ にリクエストを実行したところ問題なくデータ取得して保存することができた
あらかじめデータのエクスポートさえ終えておけば、Slack上でログが見えなくなっても慌てる必要はなさそう

  1. データの保存には CORS エラーを回避する必要があります

6
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2