0. 前言

之所以想要提取微信的聊天记录并分析是因为也开始再学习python,但是单纯看看语法什么的又很无趣,无意间看到python可以进行微信聊天记录的分析,就自己尝试做了一下,感觉还是挺有意思的。

1.提取聊天记录数据库

我所用的是小米手机,所以提取聊天记录是主要是通过本地备份功能,其余手机也可参考下述博客,具体流程可参考:

微信聊天记录导出(2020新版)

安卓\电脑微信聊天记录导出表格

微信聊天记录数据提取并分析

利用python做微信聊天记录词云分析

提取微信数据库的主体流程都差不多,基本都是先进行备份,然后将备份文件复制到电脑进行解压,解压完成之后根据得到的数据库密码访问数据库。提取数据库的过程基本上都不会有什么问题,主要会出现的问题在于获取微信数据库密码。我开始使用的是手机IMEI + uin拼接取其32位MD5码前7位的方式,但是因为我手机上有两个IMEI码,尝试了各种组合获得的密码始终是错误的,所以就放弃采用这种方式,但是根据其他博客中的内容,这种方式也可以获取数据库密码。

我最终采用的是反序列化的方式获取数据库密码,即将微信com.tencent.mm\r\MicroMsg\systemInfo.cfg和com.tencent.mm\r\MicroMsg\CompatibleInfo.cfg这两个文件复制出来,通过参考代码获取数据库密码。开始直接在命令行通过javac编译并运行,如下所示

javac IMEI.java
java IMEI systemInfo.cfg CompatibleInfo.cfg

但是直接在命令行中运行会报 “错误: 找不到或无法加载主类 IMEI 原因: java.lang.ClassNotFoundException: IMEI”,但是将代码放在IDEA中可以运行并得到最终数据库密码,代码如下:

import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.security.MessageDigest;
import java.util.HashMap;

public class IMEI {
    public static void main(String[] args) {
        // systemInfo.cfg存储路径,修改为电脑中存储位置
        String systemInfo_path = "D:\\wechet-anayze\\systemInfo.cfg";
        // compatibleInfo.cfg存储路径,修改为电脑中存储位置
        String compatibleInfo_path1 = "D:\\wechet-anayze\\CompatibleInfo.cfg";
        try {
            ObjectInputStream in = new ObjectInputStream(new FileInputStream(
                    path));
            Object DL = in.readObject();
            HashMap hashWithOutFormat = (HashMap) DL;
            ObjectInputStream in1 = new ObjectInputStream(new FileInputStream(
                    path1));
            Object DJ = in1.readObject();
            HashMap hashWithOutFormat1 = (HashMap) DJ;
            String s = String.valueOf(hashWithOutFormat1.get(Integer
                    .valueOf(258))); // 取手机的IMEI
            s = s + hashWithOutFormat.get(Integer.valueOf(1)); //合并到一个字符串
            s = encode(s); // hash
            System.out.println("The Key is : " + s.substring(0, 7));
            in.close();
            in1.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static String encode(String content) {
        try {
            MessageDigest digest = MessageDigest.getInstance("MD5");
            digest.update(content.getBytes());
            return getEncode32(digest);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    private static String getEncode32(MessageDigest digest) {
        StringBuilder builder = new StringBuilder();
        for (byte b : digest.digest()) {
            builder.append(Integer.toHexString((b >> 4) & 0xf));
            builder.append(Integer.toHexString(b & 0xf));
        }
        return builder.toString();
    }
}

微信数据库及数据库密码获取完成之后就可以通过sqlcipher(官方下载地址)软件访问数据库中内容,查看具体的聊天信息等,我在后续处理中主要用到了messagecontact表,message表中记录了所发送的所有消息信息,contact表中记录了所有的联系人信息,message表中主要使用到的内容如下:

message表主要内容

列名

内容

msgId

按所有消息时间顺序的唯一编号

type

聊天内容类型

isSend

标识消息是自己发送还是对方发送,1表示自己,0表示对方

createTime

聊天时间

talker

单聊的wxid或群聊编号"XXXX@chatroom"

content

聊天内容,单聊直接显示内容,群聊格式为“wxid:\n”内容

因为也挺想知道和其他人的聊天类型,所以就也根据message表中的type值结合createTime查找具体的聊天信息,从而对type类型做了如下分类,

type类型信息

type值

表示内容

1

文本内容

2

位置信息

3

图片及视频

34

语音消息

42

名片(公众号名片)

43

图片及视频

47

表情包

48

定位信息

49

小程序链接

10000

撤回消息提醒(XXXX撤回了一条消息)

1048625

照片

16777265

链接

285212721

文件

419430449

微信转账

436207665

微信红包

469762097

微信红包

·11879048186

位置共享


(还有未知type信息,待补充)

contact表主要内容

列名

含义

username

微信id,格式是"wxid_xxxxxxxx"或者一看就是自己设置的

alias

自己设置的那个可以通过查找加好友的微信名(和上面那个有的有区别有的为空)

conRemark

联系人备注名

nickname

微信名片上的名字,公众号的名字

contactLabelIds

联系人标签号

2.数据预处理

2.1 message表和contact表获取

获取到微信数据库及数据库密码后,可通过sqlciper软件查看数据库中内容,同时也可将所需的数据导出,可通过file/Export/Table as CSV file选项,选择需要导出的数据表,将之存储为CSV文件。

这里需要注意一下:

如果在python中直接读取导出的csv文件夹,python中会报错,大致是因为编码错误。其余博客中有提到使用excel打开导出的csv文件,然后以utf-8格式另存,最后读取另存后的文件,通过试验这种方法可以进行读取,但是因为csv文件用excel打开时超过16位的数字将会被强制使用科学计数法表示,所以message表中的createTime会被使用科学计数法表示,另存后的文件中creatTime时间精度会损失,所以我最终采用了一个简单粗暴的方法:用记事本打开csv文件,然后以utf-8的形式另存,在python中就可以正常读取了。(话说用记事本打开要比用excel打开快好多倍)

2.2 message表预处理

因为message表中和contact表中含有大量数据分析时不用的数据列,所以在数据预处理阶段中,只需要提取message表和contact表中我们所关注的一些数据。对于message表而言,我们只关注talker,createTime,type, isSend, content所表示的内容,也即聊天对象,聊天时间,消息类型,消息由谁发送,聊天内容。更近一步,对于聊天内容而言,我们只关注文本消息(文本消息可用于后续制作词云),对于其他类型的消息我们只需要统计出现的次数,而不必关注具体内容。所以在预处理过程中,同时需要将非文本类型消息内容置0,这样做的另一个好处是可以大大减少message表的数据量,就个人而言,将非文本消息置零后,message文件大小由134MB缩小到29M。

提取talker,createTime,type, isSend, content内容

"""
Create on 2020-11-21
@author: muxiaohe
"""
# 对message表中内容进行处理,只留下所需的type, isSend, createTime, talker, content内容,并将之存储于新的文件中
def get_needed_data(file_path, save_file_path):
    message = read_file(file_path)
    message = message[['type', 'isSend', 'createTime', 'talker', 'content']]
    message.to_csv(save_file_path, encoding='utf_8_sig',header=True, index=False)

将非文本消息置零

"""
Create on 2020-11-21
@author: muxiaohe
"""
# 对message表中数据进行处理,,删除content中的无用数据,将type中非1值对应的content数据统统置为零
def data_clean(file_path, save_path):
    message = pd.read_csv(file_path, sep=',', encoding='utf-8', low_memory=False)
    print("源文件大小: ", message.shape)
    message.loc[message.type != 1, 'content'] = 0
    print("处理后文件大小: ", message.shape)
    message.to_csv(save_path, encoding='utf_8_sig', header=True, index=False)

2.3 contact表预处理

与message表类似,contact表也需去除无关数据,contact表中我们只提取 usename, alias, conRemark, nickname信息,同时在处理时只保留了有备注的联系人,没有备注的联系人也没必要分析聊天记录。

"""
Create on 2020-11-21
@author: muxiaohe
"""
# 对contact表中数据进行预处理,获取所需数据,清除其余数据
def contact_pre_treatment(file_path, save_path):
    contact_path = r"D:\wechet-anayze\recontact.txt"
    contact_save_path = r"D:\wechet-anayze\pre-recontact.csv"
    contact = pd.read_csv(contact_path, sep=',', encoding="utf-8", low_memory=False)
    print("处理前文件大小: ", contact.shape)
    contact = contact[['username', 'alias', 'conRemark', 'nickname']]
    # 删除无用记录,只保留有备注的联系人
    contact1 = contact.drop(contact[pd.isna(contact.conRemark)].index)
    print("处理后文件大小: ", contact.shape)
    contact1.to_csv(contact_save_path, encoding='utf_8_sig', header=True, index=False)

3.聊天记录分析

3.1 获取常用联系人聊天次数

在预处理的基础之上,获取每个联系人的聊天次数,并据此使用pyecharts绘制柱状图,使用pyecharts绘制时需特别注意:pyecharts传入数据时需要int数据,而从文件中读取赋值到list中的数值类型为int64,需要先使用int()函数进行转换,否则绘制出来的图形中data数据域将都为NaN

"""
Create on 2020-11-21
@author: muxiaohe
"""
# 获取常用联系人聊天次数
def get_chat_nums(message_path, contact_path):
    """
    :param message_path: 预处理完成后的message表存储路径
    :param contact_path: 预处理完成后的contact表存储路径
    :return:
    """
    # message_path = r'D:\wechet-anayze\pre-message-2.txt'
    # contact_path = r'D:\wechet-anayze\pre-recontact.csv'

    message = pd.read_csv(message_path, sep=',', encoding='utf-8', low_memory=False)
    contact = pd.read_csv(contact_path, sep=',', encoding='utf-8', low_memory=False)
    # 提取出联系人列表中用户名和备注名称
    contact = contact[['username', 'conRemark']]
    # 将用户名提取出来
    username = contact['username'].tolist()
    print(type(username))
    # 将用户名及备注名提取为一个字典
    contact_dict = dict(zip(contact['username'], contact['conRemark']))
    # 联系人及其聊天次数集合
    contact_sum_message = {}
    # 全部联系人聊天次数集合
    sum_message = 0
    # 联系人列表
    uname_list = []
    # 联系人列表对应的聊天次数列表
    chat_num_list = []
    # 遍历联系人列表,并逐一统计聊天次数
    for uname in username:
        # 根据微信id获取真实姓名,key为真实姓名
        key = contact_dict.get(uname)
        # 根据微信id统计聊天次数,value:聊天次数
        value = (message['talker'] == uname).sum()
        # 过滤聊天次数为0的联系人,只保留聊天次数不为0的联系人
        if value != 0:
            contact_sum_message[key] = value
            sum_message += value
            uname_list.append(key)
            # 这里需特别注意:value值也即聊天的次数格式是int64,但是pyecharts中如果传入的是int64时,最终渲染出的html文件中会数据会丢失,
            # 所以需转为int值(血泪教训)
            chat_num_list.append(int(value))

    # print(contact_sum_message)
    print("总聊天次数: ", sum_message)

    # 使用pyecharts绘制柱状图
    c = (
        Bar(init_opts=opts.InitOpts(width="1600px", height="600px", page_title="聊天次数统计"))
        .add_xaxis(uname_list)
        .add_yaxis(series_name="聊天次数", y_axis=chat_num_list, color='#FF6666')
        .set_global_opts(
            # 标题配置
            title_opts=opts.TitleOpts(title="聊天次数统计"),
            # X轴区域缩放配置项,可使用list同时配置多个配置项
            datazoom_opts=[opts.DataZoomOpts(range_start=20, range_end=40), opts.DataZoomOpts(type_="inside")],
            # 区域选择组件
            brush_opts=opts.BrushOpts(),
            # X坐标轴旋转
            xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=-15)),
            # 工具箱组件
            toolbox_opts=opts.ToolboxOpts(),
            # 图例配置
            legend_opts=opts.LegendOpts(is_show=False),
        )
        .set_series_opts(
            label_opts=opts.LabelOpts(is_show=False),
            # 配置最大值最小值刻度线
            markline_opts=opts.MarkLineOpts(
                data=[
                    opts.MarkLineItem(type_="min", name="最小值"),
                    opts.MarkLineItem(type_="max", name="最大值"),
                    opts.MarkLineItem(type_="average", name="平均值"),
                ]
            ),
        )
        .render("chat_num_count.html")
    )

3.2 获取聊天消息中不同类型消息占比

"""
Create on 2020-11-21
@author: muxiaohe
"""
import pandas as pd
from pyecharts import options as opts
from pyecharts.charts import Pie
# 获取各个消息聊天记录数量,并使用pycharts绘图
def get_message_type_frequency(file_path, wxid):
    """
    :param file_path: 经过预处理后的message表存储位置
    :param wxid: 待查询人的微信id
    :return:
    """
    # file_path = r'D:\wechet-anayze\pre-message-2.txt'
    message = pd.read_csv(file_path, sep=',', encoding='utf-8', low_memory=False)
    # wxid = ''

    # 进行数据筛选,选择message表中与所需微信id一致的数据
    message = message[message['talker'] == wxid]
    # 根据消息类型统计每种类型的频次(索引为数字编码)
    chat_type_count = message['type'].groupby(message['type']).size()
    # 消息类型对应关系
    message_type = {'1': '文本内容', "3": "图片及视频", "34": "语音消息", "42": "名片信息", "43": "图片及视频",
                    "47": "表情包", "48": "定位信息", "49": "小程序链接", "10000": "消息撤回提醒", "1048625": "网络照片",
                    "16777265": "链接信息", "419430449": "微信转账", "436207665": "红包", "469762097": "红包",
                    "-1879048186": "位置共享"}
    # 集合对象,功能与chat_type_count相同,存储(聊天类型:频次)信息(索引为对应中文类型)
    chat_type_count_dict = {}
    # 根据消息类型代码
    for key in chat_type_count.index:
        if str(key) in message_type.keys():
            print(message_type.get(str(key)))
            chat_type_count_dict[message_type.get(str(key))] = chat_type_count[key]
        else:
            chat_type_count_dict[key] = chat_type_count[key]
    print("结果集类型: ", type(chat_type_count_dict))
    print(chat_type_count_dict)

    x_data = []
    y_data = []
    for key in chat_type_count_dict:
        temp = [str(key), chat_type_count_dict.get(key)]
        x_data.append(str(key))
        y_data.append(int(chat_type_count_dict.get(key)))

    a1 = []
    for z in zip(x_data, y_data):
        a1.append(z)

    pie = Pie(init_opts=opts.InitOpts(width="1600px", height="600px", page_title="消息类型统计"))
    pie.add(
        "",
        data_pair=a1,
        center=["35%", "60%"],
    )
    pie.set_global_opts(
        title_opts=opts.TitleOpts(title="Pie-调整位置"),
        legend_opts=opts.LegendOpts(pos_left="15%"),
    )
    pie.set_series_opts(label_opts=opts.LabelOpts(formatter="{b}:{c}"))
    pie.render("message_type_count.html")

3.3 聊天记录词云

可选择具体的聊天对象,获取全部的聊天内容,代码中可以chinese_slice选择是否进行分词,如果不进行分词则将全部聊天内容拼接到一起,通过wordcloud模块生成词云,如果选择分词则会调用jiebe模块先进行分词,然后再通过wordcloud模块生成词云。

"""
Create on 2020-11-21
@author: muxiaohe
"""
import imageio
import jieba
import pandas as pd
import matplotlib.pyplot as plt
import wordcloud
# 根据聊天记录生成词云
def get_wordcloud(file_path, stopword_path, wxid, image_path):
    """
    :param file_path: 预处理完成后的message表存储位置
    :param stopword_path: 停用词文件存储位置
    :param wxid: 待查询人微信id
    :return:
    """
    # 分词及词云处理
    # message_path = r'D:/wechet-anayze/pre-message-2.txt'
    # stopword_path = r'D:/wechet-anayze/stopword.txt'
    # 文件读取
    message = pd.read_csv(file_path, sep=',', encoding='utf-8', low_memory=False)
    # 数据筛选,选择对应微信id的信息
    message = message[message['talker'] == wxid]
    # 提取聊天内容信息
    content = message['content']

    # 中文字型存储路径
    font_path = r'C:\Windows\Fonts\MSYH.TTC'
    # 是否选用分词
    wordcut_flag = True
    # 词云图片输出路径
    image_out_name = 'word-heart.png'

    # 读取停用词表
    stopwords = [line.strip() for line in open(stopword_path, encoding='UTF-8').readlines()]

    if image_out_name is None:
        image_out_name = 'word-heart.png'
    if wordcut_flag:
        print("进行中文分词")
        outstr = ""
        text = ",".join(content)
        text_list = jieba.lcut(text, cut_all=False)


        for word in text_list:
            if word not in stopwords:
                if word != '\t' and '\n':
                    outstr += word
                    outstr += " "

        # 如果想存储分词后结果,可取消下方注释
        # savepath = r'D:/wechet-anayze/textlist.txt'
        # fp = open(savepath, 'w', encoding='utf8', errors='ignore')
        # fp.write(outstr)
        # fp.close()

        text = outstr
    else:
        print("不进行中文分词")
        text = " ".join(content)

    # 词云形状图片位置
    mk = imageio.imread(image_path)

    # 构建并配置词云对象w,注意要加scale参数,提高清晰度
    w = wordcloud.WordCloud(width=1000,
                            height=700,
                            background_color='white',
                            font_path=font_path,
                            mask=mk,
                            scale=2,
                            stopwords=None,
                            contour_width=1,
                            contour_color='red')
    # 将string变量传入w的generate()方法,给词云输入文字
    w.generate(text)
    # 展示图片
    # 根据原始背景图片的色调进行上色
    image_colors = wordcloud.ImageColorGenerator(mk)
    plt.imshow(w.recolor(color_func=image_colors))
    # 根据原始黑白色调进行上色
    # plt.imshow(wc.recolor(color_func=grey_color_func, random_state=3), interpolation='bilinear') #生成黑白词云图
    # 根据函数原始设置进行上色
    # plt.imshow(wc)

    # 隐藏图像坐标轴
    plt.axis("off")
    plt.show()

    # 将词云图片导出到当前文件夹
    w.to_file(image_out_name)