LINEトークの履歴を解析して送信文字数などを数える【Python】

# はじめに
2022/04/14追記：[本記事の改良版](https://qiita.com/shimajiroxyz/items/2ee794d9d417c3632b99)を公開しました。

# 概要
LINEトーク履歴を解析し、各話者の発言文字数、投稿スタンプ数、写真数を数える方法を説明します。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">彼女に私ばかり喋ってるといわれたので、送りあったラインの文字数、スタンプ数，写真の数を数えてみた．結果、彼女が倍くらい喋ってた． <a href="https://t.co/G5Cs84t4d1">pic.twitter.com/G5Cs84t4d1</a></p>&mdash; しまじろう@はかせちゃれんじ (@shimajiroxyz) <a href="https://twitter.com/shimajiroxyz/status/937273477207179264?ref_src=twsrc%5Etfw">December 3, 2017</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">結婚1周年なので妻とのLINEを解析した。<br><br>妻が夫の2倍喋っているのは結婚（同棲）前後で変わらず。<br>ただ妻も夫もやりとりする文字数は同棲後に有意に減っていた(p&lt;.01)<br><br>そこで昼夜に分けて数えると、夜の文字数の減少が顕著だった。<br>同棲後は夜に直接喋れるためLINEの使用量が減ったと考えられる。 <a href="https://t.co/kafepaCZXs">pic.twitter.com/kafepaCZXs</a></p>&mdash; しまじろう@はかせちゃれんじ (@shimajiroxyz) <a href="https://twitter.com/shimajiroxyz/status/1107247653648130048?ref_src=twsrc%5Etfw">March 17, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 環境
Google Colaboratory上で、pythonで解析しました。
# LINEトーク履歴のダウンロード
LINEアプリからトーク履歴をダウンロードします。
スマホから行います。
やり方は下記の「LINEでトーク履歴をメールに送信・Keepに保存」の項を参照してください。
[LINEのトーク履歴をバックアップする方法](https://www.copytrans.jp/support/line%E3%81%AE%E3%83%88%E3%83%BC%E3%82%AF%E5%B1%A5%E6%AD%B4%E3%82%92%E3%83%90%E3%83%83%E3%82%AF%E3%82%A2%E3%83%83%E3%83%97%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95/#line)
# LINEトーク履歴の構造
トーク履歴はプレーンテキストで下記のように書かれています。

```
[LINE] 投稿者２(相手の名前)とのトーク履歴
保存日時：2020/03/19 11:26

2014/10/13(月)
13:47	投稿者１	こんにちは
13:48	投稿者2	[スタンプ]
13:48	投稿者１	"コンドルが！！！！
壁に！！！！
めり込んどる！！！！！"
13:48	メッセージの送信を取り消しました
13:48	投稿者１	[スタンプ]
13:48	投稿者１	[写真]
13:48	投稿者２	[スタンプ]
2014/10/14(火)
23:34	投稿者１	☎ 通話時間 0:07
23:35	投稿者２	☎ 通話をキャンセルしました
23:34	投稿者２	おやすみ
...(略)
```
1、2行目はタイトルと保存日時
3行目は空行で、4行目からトーク履歴です。
4行目以降、投稿日を示す行と、投稿時間、投稿者、投稿文字列がタブ区切りで書かれた行が続きます。

途中で改行が行われる投稿文字列は、ダブルクオテーションで囲った上で、複数行にわたって表示されます。
スタンプ、写真（画像）を送信した場合は投稿文字列がそれぞれ[スタンプ]、[写真]となります。
通話をした場合、キャンセルした場合はそれぞれ、「☎ 通話時間 0:07」「☎ 通話をキャンセルしました」と書かれます。
メッセージの削除やアルバムの作成などを通知する「システムメッセージ」が書かれている場合もあり、そのときは投稿者の要素がなく、「時刻\t本文」の形式になります。
# ファイルのアップロード
Google Drive上で、解析用のColaboratoryファイル（analysis.ipynbとします）を作成します。
そして、トーク履歴（line_talk_history.txtとします）を同じフォルダ内にアップロードします。
<img width="610" alt="スクリーンショット 2020-03-19 12.40.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259353/48ff1191-16be-8b31-6caf-317b7f6201d1.png">
# トーク履歴のGoogleColaboratoryからの読み込み
GoogleDriveのフォルダをpythonから読み込めるように、Colaboratory上で下記を実行します。

```Python
# GoogleDriveをマウントする
from google.colab import drive
drive.mount('/content/drive/')
#解析用のフォルダに移動し、トーク履歴を読み込む
%cd /content/drive/My\ Drive/path/to/directory
log_text = open('line_talk_history.txt','r').read()
```
実行すると下記のように認証キー入力を求められるので、URLをクリックして適切なGoogleアカウントを選びキーを発行して、枠にコピペしてください。
<img width="1194" alt="スクリーンショット 2020-03-19 13.07.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259353/83bdae6a-65eb-881c-f793-9837fa1e56c9.png">

マウントができたらline_talk_history.txtが存在するパスに移動し、ファイルを読み込みます。
"/content/drive/My\ Drive"がGoogleDrive上での「マイドライブ」の場所になるので、そこを基準にパスを指定してください。
cdなどのシェルコマンドをGoogleColaboratory上で使う場合は%を頭に書きます。
移動したらopenで読み込みます。
# トーク履歴構造の整理
トーク履歴は「日付」「トーク本文」「システムメッセージ」「改行されたトーク本文の残り」の行が混在しているので、そのままでは解析しにくいです。

```:line_talk_history.txt
<注：#以降は筆者による注釈>
2014/10/13(月) #「日付」
13:47	投稿者１	こんにちは #「トーク本文」
13:48	投稿者２	[スタンプ] #「トーク本文」
13:48	投稿者１	"コンドルが！！！！ #「トーク本文」
壁に！！！！ #「改行されたトーク本文の残り」
めり込んどる！！！！！" #「改行されたトーク本文の残り」
13:48	メッセージの送信を取り消しました #「システムメッセージ」
13:48	投稿者１	[スタンプ] #「トーク本文」
...（略）
```
そこで、１行ごとにタイムスタンプ、投稿者、本文の情報が書かれるように構造を整理します。
年度ごと、月ごとなどの集計がしやすいように、タイムスタンプは年、月、日、曜日、時、分の情報に分けます。
最終的に下記のように、各行が「年, 月, 日, 曜日, 時, 分, 投稿者, 本文」という形式になるようにします。トーク本文中の改行はとりあえず&lt;br&gt;で置き換えることにします。

```
>> logs
[
  ['2014', '10, '13', '月', '13', '47', '投稿者1', 'こんにちは'],
  ['2014', '10, '13', '月', '13', '48', '投稿者２', '[スタンプ]'],
  ['2014', '10, '13', '月', '13', '48', '投稿者1', '"コンドルが！！！！<br>壁に！！！！<br>めり込んどる！！！！！"'],
  ['2014', '10, '13', '月', '13', '48', '投稿者1', '[スタンプ]'],
  ...
]
```
これを実行するために下記コードを実行します。
logs_textの４行目以降を「空行」「日付」「トーク本文」「改行されたトーク本文の残り」のいずれかであるか判定して、処理を分けています。

```Python:analysis.ipynb
#１行ごとにタイムスタンプ、投稿者、本文が記載されるようにトーク履歴を整理する
#タイムスタンプは年、月、日、曜日、時、分の情報に分ける
#最終的に各行が「年,月,日,曜日,時,分,投稿者,本文」という形式で記述されるようにする。
logs = []
year, month, date, weekday = '', '', '', ''
hour, minute, name = '', '', ''

#log_txtの各行は「空文字」「日付」「トーク本文」「システムメッセージ」「改行されたトーク本文の残り」の４種類のいずれかになるので、
#どの種類かを判断して処理を分ける
#「空行」 -> 「トーク本文」／「システムメッセージ」 -> 「日付」の順に判定し、いずれにも該当しない場合は「改行されたトーク本文の残り」とみなす。
for i,log in enumerate(log_text.splitlines()[3:]):
  #「空文字」の場合何もしない
  if log == '': continue
  
  #「トーク本文」または「システムメッセージ」であるかを判定する
  #「トーク本文」または「システムメッセージ」である必要条件は、タブで分けて長さ２個以上、
  #かつ最初の要素が時刻形式、つまり23以下の数字、コロン、59以下の数字の並びであること
  #これはあくまで必要条件であって、この条件を満たしていても、
  #「改行されたトーク本文の残り」である可能性を厳密には排除できないが、厳密に排除するのはたぶん無理なので、このくらいで妥協する
  talkelem = log.split('\t')
  timeelem = talkelem[0].split(':')
  #「システムメッセージ」または「トーク本文」かどうか
  #タブで分けた要素数が２以上、かつ、タブで分けた最初の要素をコロンで分けた要素数が２
  if len(talkelem) >= 2 and len(timeelem) == 2:
    #タブで分けた最初の要素をコロンで分けた２要素をそれぞれ時、分と仮定する
    tHour = timeelem[0]
    tMinute = timeelem[1]
    #時、分がそれぞれ数字（整数）を表し、かつ、時、分として妥当な範囲の数字であるかを判定する
    if tHour.isdecimal() and int(tHour) <= 23 and int(tHour) >= 0 and tMinute.isdecimal() and int(tMinute) <= 59 and int(tMinute) >= 0:
      hour, minute = tHour, tMinute
      #タブで分けた要素数が２のとき、「システムメッセージ」とみなす
      if len(talkelem) == 2:
        name = 'unknown' #名前に相当する要素がないのでunknownとしておく
        content = talkelem[1]
      #それ以外のとき「トーク本文」とみなす  
      else:
        name = talkelem[1]
        content = '<tab>'.join(talkelem[2:]) #タブを<tab>で置き換える
      #日付情報とともに、logsに追加する
      logs.append([year,month,date,weekday,hour,minute,name,content])
      continue
    #タブで分けた長さが2以上だが、「トーク本文」ではないとき、「改行されたトーク本文の残り」とみなす
    #レアなケースと考えられるので念の為、行番号を出力しておく
    else:
      print('warning: very rare case')
      content = '<tab>'.join(talkelem) #タブを<tab>で置き換える
      logs[-1][-1]+=('<br>'+content)
      continue
  #「日付」であるかを判定する
  #「日付」である必要条件は、タブで分けて長さ1、かつスラッシュで分けて長さ3、
  #かつタブで分けたときの第一要素が西暦として無理のない４桁の数字、第二要素が月を表す２桁の数字、第三要素が日を表す２桁の数字、
  #かつラスト3文字は「(」+「曜日」+「)」となっていること
  #「トーク本文」の判定と同様に、これはあくまで必要条件であって、この条件を満たしていても、
  #「改行されたトーク本文の残り」である可能性を厳密には排除できないが、厳密に排除するのはたぶん無理なので、このくらいで妥協する
  #2021/10/13追記：日付のフォーマットが異なる場合は適宜実情に合わせる。
  elif len(talkelem) == 1 and len(log) == 13 and len(log.split('/')) == 3 and log[-3] == '(' and log[-1] == ')' and log[-2] in ['月','火','水','木','金','土','日']:
    #スラッシュでスプリットした３要素をそれぞれ年、月、日と仮定する
    dateelem = log[:-3].split('/')
    tYear, tMonth, tDate = dateelem[0], dateelem[1],dateelem[2]
    #年、月、日がそれぞれ妥当な範囲の数値文字列であるか判定する
    #条件が満たされたとき、年、月、日、曜日の情報を更新する
    if tYear.isdecimal() and int(tYear) <= 2100 and int(tYear) >= 1900 and tMonth.isdecimal() and int(tMonth) <= 12 and int(tMonth) >= 1 and tDate.isdecimal() and int(tDate) <= 31 and int(tDate) >= 1:
      year, month, date, weekday = tYear, tMonth, tDate, log[-2]
      continue
    #タブで分けたが長さが１だが、「日付」ではないとき、「改行されたトーク本文の残り」とみなす
    #レアなケースと考えられるので念の為、行番号を出力しておく
    else:
      print('warning: very rare case at line:',i)
      content = '\t'.join(talkelem)
      logs[-1][-1]+=('<br>'+content)
      continue
  #「空行」「トーク本文」「システムメッセージ」「日付」のいずれでもないとき、「改行されたトーク本文の残り」とみなす
  else:
      #print('linebreak detected', i,log)
      content = '\t'.join(talkelem)
      logs[-1][-1]+=('<br>'+content)
      continue
```

# 集計
構造が整理できたら送信文字数、スタンプと写真の送信回数をカウントします。
通話の情報はとりあえず無視します。
~~リンクを送信していることがあってそれも本当は分離したいのですが、本文と区別するのが面倒なので、とりあえず送信文字数に含めています。~~
(20210307追記：URLも分離し、カウントするようにコードを変更しました。)

```Python:analysis.ipynb
import re

#正規表現でURLを抽出、削除する関数を定義
pattern = r"(https?|ftp)(:\/\/[-_\.!~*\'()a-zA-Z0-9;\/?:\@&=\+$,%#]+)"
url_pattern = re.compile(pattern)
def count_url(text):
  return len(url_pattern.findall(text))
def remove_url(text):
  return url_pattern.sub("",text)

#月ごとの送信文字数、スタンプ・写真の送信回数をカウントする
#通話の情報はとりあえず無視する
result = {}
for log in logs:
  year, month, date, weekday = log[0],log[1],log[2],log[3]
  hour, minute,name,content = log[4],log[5],log[6],log[7]
  #通話の情報は無視する
  if content == '☎ 通話をキャンセルしました': continue
  if content.startswith('☎ 通話時間'): continue
  #システムメッセージは無視する
  if name == 'unknown': continue
  
  #年、月が初めて登場した場合、カウント用の要素をプリセットする
  if year not in result: result[year]={}
  #１，２，３番目の数字をそれぞれ送信文字数、スタンプ送信回数、写真送信回数とする
  if month not in result[year]:
    result[year][month] = {'投稿者１': [0,0,0,0,0],'投稿者2':[0,0,0,0,0]}
  #本文がスタンプや写真、通話の場合、加算しない
  #スタンプの場合
  if content == '[スタンプ]':
    result[year][month][name][1] += 1
  #写真の場合
  elif content == '[写真]':
    result[year][month][name][2] += 1
  #その他の場合（文字数を加算）
  else:
    url_num = count_url(content)
    content = remove_url(content)
    result[year][month][name][0] += len(content)
    result[year][month][name][3] += url_num
    #contentからurlを除いた部分の長さが1以上であれば、送信回数に加算
    if len(content.strip())>0: result[year][month][name][4] += 1

#出力
for v in result:
  for v2 in result[v]:
    print(v,v2,result[v][v2])
```

月ごとだけでなく、曜日ごとや時間帯ごとなどの集計も上のコードを少し書き換えればできます。
あとは結果をエクセルなどに貼り付けてグラフ化すると最初に載せたようなグラフを作れます。
うまく解析すれば、時期ごとのやりとりの活発さの違いなど興味深いデータが得られるかもしれません。
# 追記・更新
**2020/04/01**
システムメッセージ（「時刻\tメッセージの送信を取り消しました」のようなタブ区切りで２つに分けられる行）の存在を失念していたので、これを分類できるようにコードや説明を修正しました。

**2021/03/07**
トーク本文からURLを分離し、カウントするようにコードを変更しました。また送信回数をカウントするようにしました。

**2021/10/13**
@AnomaloCat 様のコメントに基づき、日付判定部分に補足追加しました。
日付は保存環境などに依存するのか、yyyy/mm/ddの形式ではない場合もあるようです。

**2022/04/14**
改良版コードへの記事リンクを冒頭に追記しました。