微信群裡的二三事(上)

MirrorTan發表於2020-03-02

微信基本上是使用最頻繁的一款軟體了,因為工作、學習、興趣愛好等各種各樣的原因,加入了很多很多群。今天,就利用 Python 對加入的微信群聊天記錄進行一些簡單的分析。

資料分析工作在 Jupyter 環境下開展,使用到的庫主要有:

  • pandas
  • numpy
  • matplotlib
  • seaborn
  • re

1 資料獲取

要對微信群聊天記錄進行分析,首先需要獲取微信群聊天記錄。微信聊天記錄除儲存在騰訊的伺服器,也會在本地進行儲存。因此,我們可以從手機端將聊天記錄匯出,獲取我們需要的資料。

iPhone 版微信聊天記錄匯出可以參考 hangcom 的分享,Android 版可借鑑於 @godweiyang 的分享

微信本地資料庫儲存在 EnMicroMsg.db 中,該資料庫中的比較重要的表有:

  • userinfo: 使用者個人資訊
  • voiceinfo: 傳送過的語音訊息
  • VoiceTransText: 轉換為語音訊息後的文字
  • chatroom: 微信群資訊
  • message: 聊天記錄
  • HardDeviceRankInfo: 硬體裝置資訊 ~ 微信運動資料
  • EmojiGroupInfo: 表情包組資訊
  • rcontact: 聯絡人資訊
  • friend_ext: 朋友相關資訊
  • SportStepItem: 個人運動步數 ~ 一天可能會存在多條資料

本次分析中主要使用到的有 chatroom, message, rcontact 三張表,使用 sqlcipher.exe 及之前獲得的密碼將需要用到的表匯出為 csv 格式。

2 資料預處理

在獲得相關資料後,我們將之匯入 pandas 進行一些預處理,以便於後續資料分析工作。

import re

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns


pd.options.display.max_rows = 10
plt.rcParams['font.sans-serif']=['SimHei']
sns.set(font='SimHei')

%matplotlib inline

定義一些後續會使用到變數:

  • my: 個人微訊號
my = 'wxid_********22'

2.1 資料讀取

匯入資料時需注意:

  • 先將 csv 檔案在本地轉換為 ‘utf-8’ 格式(使用 pandas 以 ‘gb2312’ 匯入出錯);
  • 匯入時將 message 設定 index_col=6,以訊息傳送或收取時間為索引,便於後續分析。
chatroom = pd.read_csv('chatroom.csv')
message  = pd.read_csv('message.csv', index_col=6)
rcontact = pd.read_csv('rcontact.csv')

各 DataFrame 中要使用的資料及含義如下:

  1. chatroom: 微信群資訊
    • chatroomname: 微信群名 ~ 微信自動生成 ~ 唯一
    • memberlist: 使用者列表 ~ 微訊號列表
    • roomowner: 群主微訊號
    • memberCount: 群成員數量(可能存在錯誤)
  2. message: 聊天記錄
    • type: 訊息型別
    • isSend: 傳送或接收訊息
    • createTime: 傳送訊息的時間 ~ 已被設定為 Index
    • talker: 聊天物件
    • content: 聊天內容
  3. rcontact: 聯絡人 ~ 實際上是有過聯絡的所有人(包括微信群中的非好友)
    • username: 使用者名稱資訊 ~ 微訊號 ~ 自動生成
    • alias: 別名 ~ 修改過的微訊號
    • conRemark: 備註名
    • nickname: 暱稱
    • type: 聯絡人型別
chatroom = chatroom[['chatroomname', 'memberlist', 'roomowner', 'memberCount']]
chatroom.head()
chatroomname memberlist roomowner memberCount
0 5604******@chatroom wxid_********22;fj*****71;wxid_****82... Gr******92 18
1 1202******@chatroom Gr******92;lk******09;zh******75;sh... su***e 24
2 7766*****@chatroom ww**4;cha****;wxid_***22;wxid_... dc****25 166
3 1682****@chatroom wxid_****;wxid_****21;wxid_2... wxid_****21 9
4 4346****@chatroom wxid_*****22;wxid_****22;wxid_2... wxid_*****22 52
message = message[['type', 'isSend', 'talker', 'content']]
message.head()
type isSend talker content
createTime
1537709186000 318767153 0.0 weixin <msg>\n <appmsg appid="" sdkver="0">\n ...
1537709314000 1 0.0 weixin 歡迎你再次回到微信。如果你在使用過程中有任何的問題或建議,記得給我發信反饋哦。
1534500421000 1 0.0 5604****@chatroom w***i:\n又下雨了[捂臉]
1534500558000 1 0.0 5604****@chatroom ch***6:\n吃個飯先
1534500839000 1 0.0 5604****@chatroom XL***45:\n來玩嗎
rcontact = rcontact[['username', 'alias', 'conRemark', 'nickname', 'type']]
rcontact.head()
username alias conRemark nickname type
0 filehelper NaN NaN 檔案傳輸助手 1
1 qqmail NaN NaN QQ郵箱提醒 33
2 floatbottle NaN NaN 漂流瓶 33
3 shakeapp NaN NaN 搖一搖 33
4 lbsapp NaN NaN 附近的人 33

2.2 rcontact 預處理

rcontact.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4569 entries, 0 to 4568
Data columns (total 5 columns):
username     4568 non-null object
alias        1036 non-null object
conRemark    389 non-null object
nickname     4498 non-null object
type         4569 non-null int64
dtypes: int64(1), object(4)
memory usage: 178.6+ KB

對於部分聯絡物件(如群聊)無 alias, conRemark 是十分正常的現象,對於這兩列資料直接填充為 ‘EMPTY’;而無 username 的聯絡人僅 1 條資料,直接刪除即可。

rcontact.dropna(subset=['username'], inplace=True)
rcontact.fillna({'alias': 'EMPTY', 'conRemark': 'EMPTY'}, inplace=True)
rcontact[rcontact.nickname.isnull()]
username alias conRemark nickname type
436 5479****@chatroom EMPTY EMPTY NaN 2
445 fake_1573021893227 EMPTY EMPTY NaN 0
1261 fake_1538661262204 EMPTY EMPTY NaN 0
1340 fake_1541554264077 EMPTY EMPTY NaN 0
1342 fake_1539142500399 EMPTY EMPTY NaN 0
... ... ... ... ... ...
4174 fake_1576654843913 EMPTY EMPTY NaN 0
4205 fake_1577183589467 EMPTY EMPTY NaN 0
4217 fake_1577447590269 EMPTY EMPTY NaN 0
4328 fake_1578041960745 EMPTY EMPTY NaN 0
4501 fake_1580712150796 EMPTY EMPTY NaN 0

70 rows × 5 columns

對於 nickname 為空剩餘 70 位聯絡人,其中 63 位以 'fake_' 開頭,4 個群聊和另外 3 個使用者,暫時將這部分資料先保留,填充為 'ALIEN'

nan = rcontact.nickname.isnull()
name = rcontact.username.str

print('fake_: ', rcontact[nan & name.startswith('fake_') ]['username'].count())
print('@chatroom: ', rcontact[nan & name.endswith('@chatroom') ]['username'].count())
fake_:  63
@chatroom:  4
rcontact.fillna({'nickname': 'ALIEN'}, inplace=True)

經個人整理推斷,rcontact.type 表示含義如下:

  • 0: 使用過的小程式
  • 1: 新增使用者的好友 ~ 含公眾號
  • 2: 群聊
  • 3: 使用者主動新增的好友 ~ 含關注的公眾號
  • 4: 同微信群非好友
  • 7: 聊天頻次高的好友
  • 8, 9, 10, 11: 已刪除或被刪除的好友
  • 33: 微信官方
  • 259: 不讓他看我朋友圈
  • 2051: 置頂好友
  • 8193: 未聊過天的好友
  • 65536, 65537, 65539: 不看對方朋友圈的好友
rcontact[rcontact.username.str.endswith('@chatroom')].type.value_counts()
2    92
0     2
Name: type, dtype: int64

也即,在本地資料庫中存在兩個微信群 'type' 標註錯誤的情況,將之修正。

rcontact.loc[rcontact.username.str.endswith('@chatroom'), 'type'] = 2

為便於後續運算將微信聯絡物件簡化為並選取 contact_type['好友', '非好友', '群聊'] 三類。

contact_dict = {1:'好友', 2:'群聊', 3:'好友', 4:'非好友', 7:'好友', 8:'非好友', 9:'非好友', 10:'非好友',
                11:'非好友', 259:'好友', 2051:'好友', 8193:'好友', 65536:'好友', 65537:'好友', 65539:'好友'}

rcontact['contact_type'] = rcontact['type'].map(contact_dict)
rcontact = rcontact[rcontact.contact_type.isin(['好友', '非好友', '群聊'])]
rcontact.contact_type.value_counts()
非好友    2697
好友      552
群聊       94
Name: contact_type, dtype: int64

2.3 chatroom 處理

從匯入的情況來看,共有 94 個群聊,為了獲取關於群聊更詳細的資料,我們需要將 chatroomrcontact 合併。

chatroom = pd.merge(chatroom, rcontact, left_on='chatroomname', right_on='username')

chatroom.groupby(['alias', 'conRemark'])['chatroomname'].count()
alias  conRemark
EMPTY  EMPTY        94
Name: chatroomname, dtype: int64

也即,alias, conRemark 兩列資料均為填充資料,實際上,微信群也沒有「別名」和「備註」的概念,將這兩列直接拿掉。另,chatroomnameusername 重複,去掉其中一列。

chatroom.drop(columns=['alias', 'conRemark', 'username'], inplace=True)
chatroom.head()
chatroomname memberlist roomowner memberCount nickname type contact_type
0 5712****@chatroom wxid_***... Gr****92 18 首屆吐槽大會 2 群聊
1 1202****@chatroom Gr*6... s****e 24 2018?? 2 群聊
2 7766***@chatroom ww*_... d**** 166 建投公司 2 群聊
3 1682****@chatroom wxid_... wxid_** 9 2017 2 群聊
4 4346****@chatroom wxid_... wxid_****22 52 沒有煩惱的青春 2 群聊
chatroom['memberCount'].value_counts()
-1      9
 7      5
 11     5
 10     5
 18     4
       ..
 36     1
 25     1
 230    1
 34     1
 418    1
Name: memberCount, Length: 47, dtype: int64

我們發現,memberCount 群成員數量中出現負數 ~ 明顯不正常,需要進行調整;而群成員數量可通過 memberlist 計數獲取。

chatroom.memberlist.str.split(';').str.len().value_counts().sort_index()
2      2
3      3
4      4
5      3
6      1
      ..
166    1
206    1
230    1
405    1
418    1
Name: memberlist, Length: 48, dtype: int64
(chatroom.memberlist.str.split(';').str.len() == chatroom.memberCount).sum()
85

經測算,通過對群成員計數獲得的群成員數與 memberCount 相等共有 85 項,也即除 memberCount 為 -1 的情況均相等;採用對 memberlist 計數的值完善 memberCount

chatroom['memberCount'] = chatroom.memberlist.str.split(';').str.len()

2.4 message 預處理

本次計劃對微信群聊天記錄進行分析,也即對 talker'@chatroom' 的聊天記錄進行分析。

message = message[message.talker.str.endswith('@chatroom')]

索引 createTime 是以毫秒為單位的累計時間,將之轉換為常見格式。

message.index = pd.to_datetime(message.index, unit='ms', utc=True).tz_convert('Asia/Shanghai')
message.head()
type isSend talker content
createTime
2018-08-17 18:07:01+08:00 1 0.0 5604****@chatroom we****:\n又下雨了[捂臉]
2018-08-17 18:09:18+08:00 1 0.0 5604****@chatroom ch**:\n吃個飯先
2018-08-17 18:13:59+08:00 1 0.0 2434****@chatroom tc**:\n來玩嗎
2018-08-17 18:14:04+08:00 3 0.0 5604****@chatroom XL**:\n<msg><img cdnbigimgurl="null" hd...
2018-08-17 18:14:13+08:00 1 0.0 1285**@chatroom l***:\n加班
message.info()
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 155485 entries, 2018-08-17 18:07:01+08:00 to 2020-02-16 11:38:21+08:00
Data columns (total 4 columns):
type       155485 non-null int64
isSend     155477 non-null float64
talker     155485 non-null object
content    155415 non-null object
dtypes: float64(1), int64(1), object(2)
memory usage: 5.9+ MB

message 表 isSend 缺 8 項,content 缺 70 項:

  • isSend 為空值時表示群通話結束 ~ 與發起群語音通話對應,可直接刪除;
  • content 為分析重點,缺項少,空值直接刪除。
message.dropna(subset=['isSend', 'content'], inplace=True)

需注意的是,isSend 有三種值:

  • 1: 表示自己發出的訊息;
  • 0: 表示接受訊息;
  • 2: 其他訊息,出現的包括群語音通話和我發起的拉人入群。
message.isSend.value_counts()
0.0    153785
1.0      1611
2.0        11
Name: isSend, dtype: int64

經推斷,不同的 type 表示的訊息種類如下:

  • 1: 普通文字訊息
  • 3: 普通圖片
  • 34: 語音訊息
  • 42: 公眾號名片
  • 43: 普通視訊
  • 47: 表情包
  • 48: 定位訊息
  • 49: 公眾號或小程式分享
  • 64: 群語音通話 ~ 發起群語音通話 isSend 為 2,結束 isSend 為 NaN
  • 10000: 撤回訊息
  • 1048625: 收藏的表情
  • 16777265: 網頁分享
  • 436207665: 微信紅包
  • 486539313: 轉發公眾號內的視訊
  • 520093745: 微信卡包 ~ 禮品卡
  • 570425393: 邀請加入群聊資訊
  • 587202609: 小程式訊息 ~ 遊戲
  • 805306417: 微信接龍
  • 822083633: 引用回覆訊息
  • -1879048186: 位置共享

為便於後續處理,對型別進行簡化處理:

message_dict = {1: '文字', 3: '圖片', 34: '語音', 42: '公眾號', 43: '視訊', 47: '表情', 48: '位置',
                49: '公眾號', 64: '通話', 10000: '撤回', 1048625: '表情', 16777265: '網頁',
                436207665: '紅包', 486539313: '視訊', 520093745: '公眾號',   570425393: '加群',
                587202609: '公眾號',   805306417: '文字',   822083633: '文字', -1879048186: '位置'}

message['message_type'] = message.type.map(message_dict)

此外,content 列不僅包含聊天的內容,還包括髮言人員的微訊號;微訊號與聊天內容以 ':' 進行分割,微訊號僅包含 [a-zA-Z0-9_\-] 字元。

使用 map 適合單列對映,但此處還需用到 type 資訊幫助判斷,因此並不適合 ~ 直接拆分後文字中含 : 的非使用者發言會被當做使用者發言;無 : 會被全部當成本人。此處藉助於 apply 函式實現

regex = re.compile('([a-zA-Z0-9_\-]+):(.*)', flags=re.S)
username = set(rcontact.username)

def split_content(record):
    content = str(record.content).strip()
    if record.isSend == 0:
        # 即使用者接受的訊息
        m = regex.match(content)
#         print(m.groups())
        if m and (m.group(1) in username):
            # 即正確匹配上並且拆分的使用者名稱在 username 中
            return (m.group(1), m.group(2).strip())
        else:
            # 實際上沒有使用者的情況
            return ('None User', content)
    elif record.isSend == 1:
        # 使用者傳送的訊息
        return (my, content)
    else:
        # 其他訊息 record.isSend == 2
        return ('None User', content)
message['username'], message['real_content'] = message.apply(split_content, axis=1).str
message[message.username == 'None User'].type.value_counts()
10000        1745
570425393     780
64              8
Name: type, dtype: int64

也即,只有「撤回訊息」、「拉人進群」以及「群通話」共 2533 條訊息被標記為 ‘None User’。

3 初步分析

進行簡單初步分析:

  1. 聯絡人 ~ 好友、非好友發言頻次分析
  2. 發言型別 ~ 依據群聊人數多少分析發言型別之間的差異

3.1 發言頻次分析

首先,統計所有聯絡人發言的頻次資訊;再將之與 rcontact 合併。

people = message.groupby('username')['real_content'].count()
people = pd.merge(people, rcontact, left_index=True, right_on='username', how='right')
people.head()
real_content username alias conRemark nickname type contact_type
0 NaN filehelper EMPTY EMPTY 檔案傳輸助手 1 好友
48 1611.0 wxid_*****22 **** EMPTY M** 1 好友
49 NaN gh_** z*u EMPTY Z*會 3 好友
51 NaN gh_* ls** EMPTY 龍*網 3 好友
52 NaN wxid_** EMPTY 大* 學* 1 好友

'real_content' 列名改為 'counts',該列為空的實際就表示該聯絡人未發言,可直接填充為 0。

people.columns = ['counts', 'username', 'alias', 'conRemark', 'nickname', 'type', 'contact_type']
people.fillna(0, inplace=True)
people.sort_values(by='counts').tail(10)
counts username alias conRemark nickname type contact_type
2888 2432.0 wxid_** li**h EMPTY 雨眠** 4 非好友
2459 2473.0 wa** EMPTY EMPTY 小業* 4 非好友
2815 2677.0 wxid_** a5** EMPTY 白濤** 4 非好友
2955 2757.0 wxid_** Y** EMPTY 花** 4 非好友
3125 2976.0 O* EMPTY EMPTY 開心** 4 非好友
3115 4117.0 wxid_** EMPTY EMPTY 金** 4 非好友
357 5264.0 wxid_** Y** 飛** 大** 3 好友
2869 5300.0 H* EMPTY EMPTY T* 4 非好友
2920 5646.0 wxid_** EMPTY EMPTY rap** 4 非好友
2790 10204.0 h** s** EMPTY 豆* 4 非好友

也即,10 個發言最多的人 ~ None User 除外以及一位是自己的好友,其餘均為同微信群中的非好友。

而作為加群最多的自己發言排在第 23 位,哈哈。

rank = people['counts'].rank(method='max')

rank.max() - rank[people[people.username == my].index] + 1
48    23.0
Name: counts, dtype: float64

再來看看不同型別的好友發言量總量、平均量、方差。

people.groupby('contact_type')['counts'].agg(['sum', 'mean', 'median', 'std', 'count'])
sum mean median std count
contact_type
好友 58605.0 106.168478 0.0 354.541844 552
群聊 0.0 0.000000 0.0 0.000000 94
非好友 94269.0 34.953281 0.0 309.216804 2697

我們可以很明顯的看到好友和非好友之間發言上的差異,好友的發言均值要明顯高於非好友;而兩者都有的共同點就是方差大 ~ 而潛水使用者佔據比例高。

people[people.counts == 0].groupby('contact_type')['username'].count() / people.contact_type.value_counts()
好友     0.54529
群聊     1.00000
非好友    0.62551
dtype: float64

在分析聊天物件時微信群可直接去掉。

people = people[people.contact_type != '群聊']

將使用者按發言次數進行分組:[[0.0, 1.0) < [1.0, 10.0) < [10.0, 100.0) < [100.0, 1000.0) < [1000.0, 10205.0)],檢視各組人數並繪製柱狀圖。

people_groups = ['深水炸彈', '潛伏者', '小氣泡', '活躍分子', '資深話癆']
people_bins   = [0, 1, 10, 100, 1000, people.counts.max()+1]
people['cat'] = pd.cut(people['counts'], people_bins, labels=people_groups,right=False)
people['cat'].value_counts()
深水炸彈    1988
潛伏者      610
小氣泡      450
活躍分子     168
資深話癆      33
Name: cat, dtype: int64
cat_counts = pd.crosstab(people.cat, people.contact_type)
cat_counts.plot(kind='bar')

也即,無論是好友或非好友,不發言的均佔據絕大多數。

3.2 發言型別分析

將群聊和發言合併,並選取需要的資料列

room = pd.merge(chatroom, message, left_on='chatroomname', right_on='talker')
room = room[['chatroomname', 'memberCount', 'message_type']]

按群大小進行分組:(0, 10] < (10, 20] < (20, 50] < (50, 100] < (100, 419],檢視各個大小群發言型別偏好。

room_groups = ['小小群', '小群', '中型群', '大型群', '巨型群']
room_bins   = [0, 10, 20, 50, 100, chatroom.memberCount.max()+1]
room['cat'] = pd.cut(room['memberCount'], room_bins, labels=room_groups)

不同大小群各種型別發言總量偏好

room_total = room.groupby('cat')['chatroomname'].count()
talk_count = pd.crosstab(room.cat, room.message_type)
talk_count
message_type 位置 公眾號 加群 圖片 撤回 文字 紅包 網頁 表情 視訊 語音 通話
cat
小小群 19 365 57 891 145 7920 207 1 523 90 68 0
小群 56 619 90 1751 396 28293 328 0 2650 205 235 2
中型群 27 405 235 733 264 10278 49 0 536 88 492 0
大型群 6 346 103 1562 159 4053 465 6 183 384 600 0
巨型群 32 1073 295 4011 798 75176 241 4 6999 500 387 6
talk_per = talk_count.div(room_total, axis=0) * 100
talk_per
message_type 位置 公眾號 加群 圖片 撤回 文字 紅包 網頁 表情 視訊 語音 通話
cat
小小群 0.184717 3.548513 0.554151 8.662259 1.409683 76.997861 2.012444 0.009722 5.084581 0.874976 0.661093 0.000000
小群 0.161733 1.787726 0.259928 5.057040 1.143682 81.712635 0.947292 0.000000 7.653430 0.592058 0.678700 0.005776
中型群 0.205997 3.089952 1.792935 5.592432 2.014191 78.416114 0.373846 0.000000 4.089418 0.671397 3.753719 0.000000
大型群 0.076268 4.398119 1.309267 19.855091 2.021101 51.519003 5.910766 0.076268 2.326173 4.881149 7.626795 0.000000
巨型群 0.035745 1.198588 0.329528 4.480463 0.891401 83.974889 0.269208 0.004468 7.818190 0.558522 0.432296 0.006702

利用各種分組的群發訊息的類別繪製熱力圖。

f, ax = plt.subplots(figsize=(9, 6))
sns.heatmap(np.sqrt(np.sqrt(talk_per)), annot=True, linewidths=.5, ax=ax, cmap='Set3')

從佔比上看,任何分組的「文字訊息」均佔據絕大多數,而且佔比之間差異過大,因此在將佔比兩次開方後繪製。

從結果上看,除文字訊息均佔據最大比中外,圖片、表情也是使用佔比普遍較高的訊息種類;小、中型群分享位置更多,中大型群撤回訊息佔比更多!

本次分析主要集中在資料預處理上,具體分析比較少,後續再依據發言時間、發言具體內容進行分析。

4 參考資料

  1. 韋陽的部落格, 韋陽, 微信聊天記錄匯出為電腦 txt 檔案教程, 2020/3/2.
  2. hangcom 寫字的地方, hangcom, 微信聊天記錄匯出–釋出, 2020/3/2.
  3. Asher117, 【Python】DataFrame 一列拆成多列以及一行拆成多行, 2020/3/2.
  4. 風淺安然, Matplotlib 及 Seaborn 中文顯示問題, 2020/3/2.
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章