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}/ に作成される。中身はこんな感じ。

f:id:I-was-a-Ki:20200417124603p:plain

これではハッシュ化されていて何も分からないので、同ディレクトリにある Manifest.db を DB Browser for SQLite で開いて構造を調べてみる。下図は Files というテーブルで適当に検索をかけてみた様子。

f:id:I-was-a-Ki:20200417124648p:plain

どうやら fa0542f482ca8ca37c30213379073f2d985a64c7 ファイルがそれっぽいということが分かったので、次はこれを開いてみる。構造はこんな感じ。

f:id:I-was-a-Ki:20200417124754p:plain

ここで、メッセージを保存しているのがテーブル ZMESSAGE である。テーブルの中身を見てみると、

f:id:I-was-a-Ki:20200417124711p:plain

  • 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 で開くと、ログビューワらしきものが完成する。

f:id:I-was-a-Ki:20200417124733p:plain

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 (のようなもの) を書いたので、お作法などの点でやばそうだったら教えて下さい。