QQ 聊天记录 MHT 文件转 HTML

8 min read

写在前面

虽然最近博客没有更新,但博主一直在写一些回忆性的文章(都存放在某个赛博空间里)。最近突然回想起一些往事,想翻看以前的聊天记录。尽管那时明月在,曾照彩云归,好在博主没有删掉数据的习惯,消息记录几乎都还保留着。虽然,我们的对话仅仅持续了一年半。

正文

目前,QQ 客户端提供三种导出格式:.bak.mht.txt。凭借一些常识和对导出后文件大小的观察,很容易发现,baktxt 应该只是单纯的纯文本消息,不含任何图文内容。因此,如果想保留消息中的图片或其他资源,毫无疑问,mht 格式便成了唯一的选择。

MHT 它是一种能够将图片、样式表、脚本等多种资源打包成一个独立文件的格式。通过这种方式,所有的信息都能被封装在一个文件内,方便进行管理和导出。

img

当博主尝试导出消息记录时,由于聊天内容过多,客户端一度陷入了假死状态,持续了相当长一段时间。博主此时隐隐地觉得不妙。

导出的文件在用 Edge 打开时,由于体积过大(高达 500MB),导致无法显示任何内容。转而使用 Chrome 时,则出现了如下错误提示(🤣👉🤡):

Malformed multipart archive

通过在 file.org 查阅资料,博主才意识到,原来 .mht 文件并不支持所有浏览器。它必须依赖于 Internet ExplorerEdge 才能正常打开。

MHT files are commonly associated with the Internet Explorer Web browser.

这也解释了为什么 Edge 会自动切换到 Internet Explorer 模式(但是,打开一直无响应和转圈圈…)。

找了一些转换工具也没转换成功,没办法,只能自己造轮子了。其实也并不复杂,只需要简单了解一下文件结构~

导出的 .mht 文件结构大致如下:

  • Header 部分:包含文件类型、编码方式、内容类型等元数据。
  • Body 部分:存放页面的 HTML 内容,以及相关的图片、样式表、脚本等资源。
  • 分隔符:各个部分通过边界(boundary)分隔,确保资源能够正确解析。
From: <Save by Tencent MsgMgr>  <!-- 发件人信息 -->
Subject: Tencent IM Message  <!-- 邮件主题 -->
MIME-Version: 1.0  <!-- MIME 版本,指示文件的编码和多部分结构 -->
Content-Type: multipart/related;  <!-- 邮件的主要内容类型是 multipart/related,表示文件有多个相关部分 -->
    charset="utf-8"  <!-- 编码格式为 UTF-8 -->
    type="text/html";  <!-- 内容类型为 HTML -->
    boundary="----=_NextPart_4938232B_D5C6_4d39_AC69.64B3BD4C94EF"  <!-- 分隔不同内容部分的 boundary,标识每个部分的开始和结束 -->

------=_NextPart_4938232B_D5C6_4d39_AC69.64B3BD4C94EF  <!-- 内容的第一个部分开始 -->
Content-Type: text/html  <!-- 该部分的内容类型是 HTML -->
Content-Transfer-Encoding:7bit  <!-- 内容传输编码 -->

<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>QQ Message</title>
    <style type="text/css">
      body {
        font-size: 12px;
        line-height: 22px;
        margin: 2px;
      }

      td {
        font-size: 12px;
        line-height: 22px;
      }
    </style>
  </head>

  <body>
    <table width="100%" cellspacing="0">
      <tr>
        <td>
          <div style="padding-left: 10px"><br /><b>消息记录</b></div>
        </td>
      </tr>
      <tr>
        <td>
          <div style="padding-left: 10px">消息分组:已删除联系人</div>
        </td>
      </tr>
      <tr>
        <td>
          <div style="padding-left: 10px">消息对象:iami233</div>
        </td>
      </tr>
      <tr>
        <td>
          <div style="padding-left: 10px">&nbsp;</div>
        </td>
      </tr>
      <tr>
        <td style=" border-bottom-width: 1px; border-bottom-color: #8ec3eb; border-bottom-style: solid; color: #3568bb; font-weight: 700; height: 24px; line-height: 24px; padding-left: 10px; margin-bottom: 5px;" >
          日期:2024-02-24
        </td>
      </tr>
      <tr>
        <td>
          <div style="color: #006efe; padding-left: 10px">
            <div style="float: left; margin-right: 6px">iami233</div>
            21:09:45
          </div>
          <div style="padding-left: 20px">
            <font style="font-size: 10pt; font-family: &quot" color="000000" >我是一条示例消息捏~</font>
          </div>
        </td>
      </tr>
      <tr>
        <td>
          <div style="color: #42b475; padding-left: 10px">
            <div style="float: left; margin-right: 6px">小 iami233</div>
            13:44:52
          </div>
          <div style="padding-left: 20px">
            <img src="{E3D98612-5231-47ad-B7E4-1160C3A24C55}.dat" />
          </div>
        </td>
      </tr>
    </table>
  </body>
</html>

------=_NextPart_4938232B_D5C6_4d39_AC69.64B3BD4C94EF  <!-- 内容的第二部分开始 -->
Content-Type: image/gif  <!-- 图片类型 -->
Content-Transfer-Encoding: base64  <!-- 图片采用 Base64 编码 -->
Content-Location:{E3D98612-5231-47ad-B7E4-1160C3A24C55}.dat  <!-- 图片的唯一标识符 -->

... Base64 encoded data ...  <!-- Base64 编码后图片数据 -->

------=_NextPart_4938232B_D5C6_4d39_AC69.64B3BD4C94EF--  <!-- 文件结束 -->

简单了解完文件结构后,我们只需提取出 <html>...</html> 部分,同时将图片资源转存,便能实现所需的核心功能🥰

import re
import base64

with open ( 'example.mht', "r", encoding="utf-8" ) as f:
    content = f.read ( )

# 匹配 boundary,获取 boundary 的值
boundary_match = re.search ( r'boundary=" ( [^"]+ ) "', content )
boundary = boundary_match.group ( 1 )

# 查找 HTML 部分,通过 boundary 分割
parts = re.split ( rf"--{boundary} ( ?:-- ) ?\n", content )
html_part = next (( p for p in parts if "Content-Type: text/html" in p ) , None )

# 解析 HTML 内容
html_match = re.search ( r" ( ?: \n\n|\r\n\r\n ) ( .*? ) ( ?=\n--|$ ) ", html_part, re.DOTALL )

print ( html_match.group ( 1 ) .strip ( ) , "\n" )

for part in parts:
    # 跳过不包含 Content-Location 的部分
    if "Content-Location:" not in part:
        continue

    headers_str, _, body = part.partition ( "\n\n" )

    # 解析 headers
    headers = {}
    for line in headers_str.split ( "\n" ) :
        if ":" in line:
            key, value = line.split ( ":", 1 )
            headers [key.strip ( ) .lower ( )] = value.strip ( )

    # 获取 content-location
    content_location = headers.get ( "content-location" )

    # 转存资源
    content = base64.b64decode ( body.strip ( ))
    with open ( content_location, "wb" ) as f:
        f.write ( content )
    print ( f"{content_location} 已保存" )

剩下的只需要亿点点优化就可以了,比如:

  • 将 HTML 中的内联样式全部提取并转换为 CSS 类,尽可能的减小文件体积和冗余样式
  • 支持将资源文件输出到指定文件夹,并自动更新 HTML 中的资源引用路径。
  • 消息记录无法导出语音,视频等内容,所以需要自动处理空白记录并插入提示文本。

好啦,剩下的直接前往 5ime/mht2html 查看即可。

不过,需特别提醒,此项目针对性非常强,基本只为博主查看聊天记录所用。如果你有其他需求,比如大文件切割什么的,就得自己动手研究啦~

写在后面

在写代码的某一刻,我也曾幻想过,想着将聊天记录中的所有文字提取出来,分词生成词云,甚至训练个模型什么的。可是,随着时光流转,那些想法渐渐褪去光彩,变得毫无意义。

毕竟,那些文字,终究只是过去的碎片罢了。

难道不是吗?

最终,博主依旧没去翻看那些文字。脑海中的记忆早已模糊不清,留下的,或许只剩下空白与迷惘,etc.