Slackのエクスポートデータ(JSON形式)をCSVに変換する

# 概要
Slackの投稿情報をJSON形式でダウンロードできるので、CSV形式に変換するツールの紹介です。

[2022年9月にSlackのフリープランの内容が変更](https://slack.com/intl/ja-jp/blog/news/pricing-and-plan-updates)となり、90日以前の投稿が見れなくなってしまいます。

そのため、今までの情報を簡単に参照できる形で保存しておきたいので、このツールを作りました。

保存という観点だけであれば、JSON形式を持っておけば問題ないのですが、流石にそのまま見るのは辛いので、チャンネル単位で簡易的に投稿内容を閲覧できるようCSVに変換するツールを作成しました。

あくまで簡易的なので、スレッド構造が維持されない状態で、時系列に行に並べただけの情報となります。
また、添付ファイルのURLもつけてはいますが、9月以降のサービス変更で、今まで通りアクセスできるかは今の所わからないです。

## サンプル

Numbersで変換後のcsvを開いた所。
こんな感じで時系列に投稿情報と添付ファイルを参照できます。

![Screen Shot 2022-07-23 at 13.09.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142886/48865e21-92cb-483a-a087-2f60112362cf.png)

# 注意点

Slackのエクスポートデータは、Windowsだと日本語のファイルやフォルダ名が文字化けしてしまうようです。（中のメッセージは問題ない）

# ツール利用環境

- OS: macOS Monterey (12.5)
- python: 3.10.5
# エクスポート手順

エクスポートの手順については[公式サイト](https://slack.com/intl/ja-jp/help/articles/201658943-%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E3%82%A8%E3%82%AF%E3%82%B9%E3%83%9D%E3%83%BC%E3%83%88%E3%81%99%E3%82%8B)を参照ください。
2022/7/23 の時点では、フリープランでもすべての情報をエクスポートできるみたいです。
また、管理者権限が必要です。

# ツール利用手続き

コードは以下GitHubに配置してます
https://github.com/becky3/json_to_csv_for_slack

`converter.py` というファイルがあるので
ターミナルで以下のように第一引数にslackの展開したエクスポートデータのトップフォルダを **フルパスで指定** して、pythonで実行してください。

```sh
$ python converter.py /Users/username/Desktop/my_workspace
```


実行後、指定フォルダと並列に `slack_csv_output` というフォルダ名が作成され、
その中に `チャンネル名.csv` というファイルが作られます。
すでに同名のフォルダがある場合はエラーとなるので、あらかじめ削除してください。

csv は それぞれ以下のような対応で列が作られ、メッセージ単位を行として変換されます。
`date` > 投稿日 (ファイル名より取得)
`name` > 氏名 または 表示名
`text` > 投稿メッセージ
`files` > 添付ファイルのURL (複数添付がある場合は改行して表示)


# エクスポートデータの内容

エクスポートデータは、すべてjson形式で、メッセージはチャンネル別に日付単位のファイルで出力されます。
また、別途ユーザー情報のjsonファイルもあります。

![Screen Shot 2022-07-23 at 11.15.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142886/5e5fffc1-f817-12b3-390e-7655e2bfc94b.png)

データはzipで圧縮されているので、展開して利用します。

引き続き、今回関係のあるjsonの説明をしていきます。
ちなみに、ID部分やURLは適当に修正してます。

## メッセージデータ

メッセージデータのサンプルです。

ファイル名は `yyyy-MM-dd.json` といった名前で、
1投稿単位の情報が記述されています。

`text` にメッセージの内容が入っています。
`user_profile` にユーザー名なども入ってるんですが、添付ファイルだとこの項目がなかったり、情報の取れ方が不安定だったので、別途ユーザー情報と `user` 項目のIDを紐付けて名前を取得するようにしています。

また、絵文字はslack特有の `:image_name:` みたいな形式で表記されているため、再現できてません。

`ts` がおそらくタイムスタンプなので、ここから日時が取れそうですが、
今回は、ファイル名の日付のみ参照してます。


```json
    {
        "client_msg_id": "XXXXXXX",
        "type": "message",
        "text": "テスト投稿です\n絵文字:shinto_shrine:",
        "user": "XXXXXXX",
        "ts": "1545050919.004400",
        "team": "XXXXXXX",
        "user_team": "XXXXXXX",
        "source_team": "XXXXXXX",
        "user_profile": {
            "avatar_hash": "XXXXXXX",
            "image_72": "https:\/\/xxxx.com\/xxxx.gif",
            "first_name": "ふぁーすとねーむ",
            "real_name": "本名",
            "display_name": "表示名",
            "team": "XXXXXXX",
            "name": "name",
            "is_restricted": false,
            "is_ultra_restricted": false
        }
    },
```

## 添付ファイル

画像などの添付ファイル投稿は以下のような構造で、`files` を参照することで、複数ファイル添付の場合も配列で取得可能です。
`url_private` の URLを参照すると直接添付データを参照できるみたいなので、このURLをcsvに記載しています。
9月以降もこのURLならファイルを直接参照できそうな気もしていますが、現時点では不明です。

```json
    {
        "type": "message",
        "text": "",
        "files": [
            {
                "id": "XXXXXXX",
                "created": 1546261634,
                "timestamp": 1546261634,
                "name": "画像名.jpg",
                "title": "画像名.jpg",
                "mimetype": "image\/jpeg",
                "filetype": "jpg",
                "pretty_type": "JPEG",
                "user": "XXXXXXX",
                "editable": false,
                "size": 2344481,
                "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\/fxxxx.jpg?t=xxxxx",
                "url_private_download": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "media_display_type": "unknown",
                "thumb_64": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "thumb_80": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "thumb_360": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "thumb_360_w": 360,
                "thumb_360_h": 272,
                "thumb_480": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "thumb_480_w": 480,
                "thumb_480_h": 362,
                "thumb_160": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "thumb_720": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "thumb_720_w": 720,
                "thumb_720_h": 544,
                "thumb_800": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "thumb_800_w": 800,
                "thumb_800_h": 604,
                "thumb_960": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "thumb_960_w": 960,
                "thumb_960_h": 725,
                "thumb_1024": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "thumb_1024_w": 1024,
                "thumb_1024_h": 773,
                "image_exif_rotation": 1,
                "original_w": 3676,
                "original_h": 2776,
                "permalink": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "permalink_public": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
                "is_starred": false,
                "has_rich_preview": false
            }
        ],
        "upload": true,
        "user": "XXXXXXX",
        "display_as_bot": false,
        "ts": "1546261642.000300"
    },
```

## ユーザー情報
ユーザー情報はエクスポートデータの直下に `users.json` というファイル名で配置されています。
以下が一人分の情報です。

ここの `id` とメッセージデータの `user` を突き合わせることで、ユーザー情報の参照が可能となります。
`deleted` フラグがあるので、退会ユーザーの情報ももしかして残ってるのかもしれません。
(自身のワークスペースに退会ユーザーがいない環境だったの未確認）

名前は、`first_name` だったり、 `real_name` だったり `display_name` だったり、色々と参照の要素がありますが、
今回はまず `display_name` を参照し、文字がなければ `real_name` を取得するという形式にしています。

```json
[
    {
        "id": "XXXXXXX",
        "team_id": "XXXXXXX",
        "name": "username",
        "deleted": false,
        "color": "9f69e7",
        "real_name": "本名です",
        "tz": "Asia\/Tokyo",
        "tz_label": "Japan Standard Time",
        "tz_offset": 32400,
        "profile": {
            "title": "",
            "phone": "",
            "skype": "",
            "real_name": "本名です",
            "real_name_normalized": "本名です",
            "display_name": "表示名です",
            "display_name_normalized": "表示名です",
            "fields": {},
            "status_text": "",
            "status_emoji": "",
            "status_emoji_display_info": [],
            "status_expiration": 0,
            "avatar_hash": "XXXXXXX",
            "image_original": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
            "is_custom_image": true,
            "first_name": "名字",
            "last_name": "名前",
            "image_24": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
            "image_32": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
            "image_48": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
            "image_72": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
            "image_192": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
            "image_512": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
            "image_1024": "https:\/\/files.slack.com\/fxxxx.jpg?t=xxxxx",
            "status_text_canonical": "",
            "team": "XXXXXXX"
        },
        "is_admin": true,
        "is_owner": true,
        "is_primary_owner": true,
        "is_restricted": false,
        "is_ultra_restricted": false,
        "is_bot": false,
        "is_app_user": false,
        "updated": 1603908706,
        "is_email_confirmed": true,
        "who_can_share_contact_card": "EVERYONE"
    },
    ...
]
```

# コードの解説

スクリプトファイル自体にコメントを入れていますが、
簡単にコードの解説をしたいと思います。

ファイルは[こちら](https://github.com/becky3/json_to_csv_for_slack/blob/main/converter.py)

## 定数定義

先頭部分でjsonのkey名とファイル名などを定義しています。
`OUT_PUT_DIR_NAME` を変更すると、出力フォルダ名を変更できます。

```python
ID_KEY = 'id'
TEXT_KEY = 'text'
USER_KEY = 'user'
PROFILE_KEY = 'profile'
REAL_NAME_KEY = 'real_name'
DISPLAY_NAME_KEY = 'display_name'
FILES_KEY = 'files'
URL_KEY = 'url_private'

OUT_PUT_DIR_NAME = 'slack_csv_output'
USER_FILE_NAME = 'users.json'
```

## 関数定義

まとまった処理を関数で抜き出してます。

### ユーザー情報を取得
`user.json` の情報から抜き出した名前をIDをキーとした辞書形式に変換しています。

```python

def get_users(source_dir):
    users_json = json_file_to_data(source_dir)
    users = {}

    for user in users_json:
        
        name = user[PROFILE_KEY][DISPLAY_NAME_KEY]
        if not name:
            name = user[PROFILE_KEY][REAL_NAME_KEY]
        id = user[ID_KEY]
        users[id] = name

    return users
```

### 1メッセージのjson辞書データをカンマ区切りの1行データに変換

ここが一番肝となる処理です。
1メッセージのjson辞書データからデータを抽出してます。
また、先に作っていたユーザー情報とのID突き合わせでユーザー名も取得しています。

添付ファイルがある場合は、 `urls` に保持し、複数ファイルある場合は改行して追記していきます。

念の為メッセージのダブルクォーテーションはエスケープかけてます。

また、ファイルが削除されていたりして、URLが参照できない場合があるので、
辞書に `URL_KEY` が見つからない場合は参照をスキップします。

```python
# 1メッセージのjson辞書データをカンマ区切りの1行データに変換
def get_line_text(users, item):

    text = f'{item[TEXT_KEY]}'.replace('"', '\"')
    name = ''
    
    if USER_KEY in item.keys():
        user_id = item[USER_KEY]
        if user_id in users.keys():
            name = users[user_id]

    urls = ''

    if FILES_KEY in item.keys():
        for attachmentFile in item[FILES_KEY]:

            if not URL_KEY in attachmentFile.keys():
                continue

            url = f"{attachmentFile[URL_KEY]}".replace('"', '\"')
            urls += f'{url}\n'

    return f'{date},{name},"{text}","{urls}"\n'
```

## メイン処理

メインロジック部分です。

まず、引数からソースフォルダの情報を取得します。
```python

argv = sys.argv

if len(argv) < 2:
    failed('Please add argument of work directory')

source_dir = argv[1]

if not os.path.exists(source_dir):
    failed(f'not exists directory: {source_dir}')

print(f'Source directory > {source_dir}')
```

次に、出力フォルダを予め作成しておきます。
すでにフォルダが有る場合は、エラーで終了します。

```python
output_dir = f'{source_dir}/../{OUT_PUT_DIR_NAME}'

if os.path.exists(output_dir):
    failed(f'already exists output directory: {output_dir}')

print(f'Create output dir > {output_dir}/')
os.makedirs(output_dir)
```

フォルダ一覧を取得します。
念の為ソートもかけます。
ファイル名も取れてしまうので `.json` がつく名称を省いています。

ここで、ユーザー情報一覧も作成します。

```python
# jsonファイルを省いたチャンネル名のフォルダ一覧の取得
channels = sorted(os.listdir(path=source_dir))
channels = [x for x in channels if not x.endswith('.json')] 

users = get_users(f'{source_dir}/{USER_FILE_NAME}')
```

チャンネル一覧情報が取得できたので、
`チャンネル単位 > jsonファイル単位` という流れで、各ファイルにアクセスしていきます。

`lines` という変数に最終的なcsv情報を文字列で追記していき、最後にファイルとして書き出します。

```python
# channelフォルダ単位でループ
for channel in channels: 

    print(f'[{channel}]')

    json_files = sorted(glob.glob(f"{source_dir}/{channel}/*.json"))
    lines = "date,name,text,files\n"

    # 日付名のjsonファイル単位でループ
    for file_full_path in json_files: 

        file_name = os.path.split(file_full_path)[1]
        date = file_name.replace('.json', '')

        json_dic = json_file_to_data(file_full_path)

        # メッセージ単位ループ
        for item in json_dic: 

            if not TEXT_KEY in item.keys():
                continue

            lines += get_line_text(users, item)

        print(f'\t{date} ({len(json_dic)})')

    # 変換した情報をチャンネル名のcsvファイルに書き込み
    out_file_path = f"{output_dir}/{channel}.csv"
    f = open(out_file_path, 'w')
    f.write(lines)
    f.close()

print(f'{len(channels)} channels converted.')
```

以上、簡単ですがコードの説明でした。