LINE ログビューワを作る
概要
ライフログ取得の一環として、LINE でのやりとりを日毎にまとめたログビューワを作ろうと思い立った。現時点で LINE は bot 向けにしか API を公開していないため、個人アカウントでのトークを取得するには別の方法を考える必要がある。ここでは、Mac (macOS 10.15.4) で iPhone (iOS 12.4.5) のバックアップを取り、バックアップファイルを Reverse Engineering (というほどでもないが) して情報を抜き出すことによりログを取得する方針とする。
iOS バックアップファイルの探索
macOS では、iOS のバックアップファイルは /Users/walking-trashbox/Library/Application Support/MobileSync/Backup/${iOS 端末 の Unique ID}/
に作成される。中身はこんな感じ。
これではハッシュ化されていて何も分からないので、同ディレクトリにある Manifest.db を DB Browser for SQLite で開いて構造を調べてみる。下図は Files というテーブルで適当に検索をかけてみた様子。
どうやら fa0542f482ca8ca37c30213379073f2d985a64c7
ファイルがそれっぽいということが分かったので、次はこれを開いてみる。構造はこんな感じ。
ここで、メッセージを保存しているのがテーブル ZMESSAGE
である。テーブルの中身を見てみると、
- ZTIMESTAMP: UNIX タイムスタンプ (ミリ秒)
- ZCHAT: チャットルームの ID
- ZSENDER: 送信者の ID (NULL になっているのは自分が送信したものか、あるいは「〜さんが退出しました」などのシステムメッセージ)
- ZTEXT: メッセージ内容
っぽいことが分かる。その他の作業としては、
- テーブル
ZUSER
: 送信者 ID と名前の照合 - テーブル
ZCHAT
: チャットルームの情報 - テーブル
ZGROUP
: グループ名の取得 - テーブル
Z_1MEMBERS
: 複数人トークの参加者取得
を参照すれば良さそうだ。
実装
本データベースは SQLite であるため、sqlite3 という Python パッケージを用いて実装した。以下のコードは、iPhone バックアップファイルから情報を抽出し、指定された区間の間で一日ごとに 2020-04-18.md
などという名前の markdown ファイルを書き出すものである。これを Typoraという markdown editor で開くと、ログビューワらしきものが完成する。
import sqlite3 import datetime import numpy as np def get_zmessage(c, start_dt): end_dt = start_dt + datetime.timedelta(days=1) start_timestamp = start_dt.timestamp() * 1000 end_timestamp = end_dt.timestamp() * 1000 t = (start_timestamp, end_timestamp, ) c.execute('SELECT * FROM ZMESSAGE WHERE ZTIMESTAMP >? AND ZTIMESTAMP <?', t) zmessage = c.fetchall() return zmessage def get_user_name_by_user_id(c, user_id): t = (user_id, ) c.execute('SELECT ZNAME FROM ZUSER WHERE Z_PK ==?', t) user_name = c.fetchone() if user_name is None: return 'Walking Trashbox' return user_name[0] def get_room_name_by_room_id(c, room_id): t = (room_id, ) c.execute('SELECT * FROM ZCHAT WHERE Z_PK ==?', t) room_info = c.fetchone() room_type = room_info[12] if room_type == 2: # グループトークの場合 # 'ZCHAT' の ZMID で 'ZGROUP' の ZID 列を検索し、ZNAME でグループ名を取得する group_id = room_info[20] t = (group_id, ) c.execute('SELECT ZNAME FROM ZGROUP WHERE ZID ==?', t) room_name = c.fetchone()[0] else: # 一対一トーク or 複数人トーク # 'ZMESSAGE' の ZCHAT で 'Z_1MEMBERS' の Z_1CHATS 列を検索する。Z_12MEMBERS でメンバーの名前を取得 t = (room_id, ) c.execute('SELECT Z_12MEMBERS FROM Z_1MEMBERS WHERE Z_1CHATS ==?', t) members = c.fetchall() if len(members) == 1: # 一対一トーク return get_user_name_by_user_id(c, members[0][0]) member_list = [] # 複数人トーク for member in members: name = get_user_name_by_user_id(c, member[0]) member_list.append(name) room_name = ', '.join(member_list) return room_name def process_each_message(c, message): timestamp = message[6] dt = datetime.datetime.fromtimestamp(timestamp / 1000) time_str = dt.strftime('%H:%M') room_id = message[7] user_id = message[8] zid = message[11] text = message[13] if (text is None) or (text == '') or (text == ' '): text = '[Stamp or Media]' else: text = text.translate(str.maketrans({'\n': ' ', '`': '\`', '_': '\_'})) if zid is None: user_name = 'System' else: user_name = get_user_name_by_user_id(c, user_id) room_name = get_room_name_by_room_id(c, room_id) return [time_str, room_name, user_name, text] def process_day(c, dt): zmessage = get_zmessage(c, dt) data_str = dt.strftime('%Y-%m-%d') data_store = np.empty((0, 4)) for i in range(len(zmessage)): tmp = np.array(process_each_message(c, zmessage[i])) data_store = np.vstack([data_store, tmp]) room_unique = np.unique(data_store[:, 1]) text = '# ' + data_str + '\n' + '## LINE' + '\n' for room in room_unique: if room == '': continue data_per_room = data_store[data_store[:, 1] == room] text += '### ' + room + '\n' for data in data_per_room: text += data[0] + '\t' + data[2] + '\t' + data[3] + '\n' write_path = data_str + '.md' with open(write_path, mode='w') as f: f.write(text) if __name__ == '__main__': conn = sqlite3.connect('/Users/walking-trashbox/Library/Application Support/MobileSync/Backup/xxxxxxxxxxxxxxxxxxxxxxxxxxx/fa/fa0542f482ca8ca37c30213379073f2d985a64c7') c = conn.cursor() start_dt = datetime.datetime(2020, 3, 1, 0) end_dt = datetime.datetime.now() dt = start_dt while (1): process_day(c, dt) dt += datetime.timedelta(days=1) if dt > end_dt: break
おわりに
- 今回はバックアップデータから LINE の情報を抽出したが、電話やメールなどその他の情報を引っ張り出すこともできる。興味のある方は Reverse Engineering the iOS Backup - Rich Infante もどうぞ。
- 生まれて初めて SQL (のようなもの) を書いたので、お作法などの点でやばそうだったら教えて下さい。