diff --git a/.gitignore b/.gitignore index ee741a3..4d54918 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,12 @@ *.db-shm *.db-wal *.zip +*.exe *.spec build/ dist/ __pycache__/ qq/ QQ*/ -com.tencent.mobileqq/ \ No newline at end of file +com.tencent.mobileqq/ +chatimg/ diff --git a/GUI.py b/GUI.py index af70caf..598113a 100644 --- a/GUI.py +++ b/GUI.py @@ -9,15 +9,18 @@ def Enter(): - db_path, qq_self, qq, img_path = e1.get(), e2.get(), e3.get(), e6.get() + db_path, qq_self, qq = e1.get(), e2.get(), e3.get() group = 1 if e4.get() == '私聊' else 2 emoji = 1 if e5.get() == '新' else 2 + with_img = True if e6.get() == '是' else False + combine_img = True if e7.get() == '否' else False if (db_path == "" or qq_self == "" or qq == ""): info.set("信息不完整!") return () info.set("开始导出") try: - QQ_History.main(db_path, qq_self, qq, group, emoji, img_path) + QQ_History.main(db_path, qq_self, qq, group, + emoji, with_img, combine_img) info.set("完成") except Exception as e: info.set(repr(e)) @@ -76,15 +79,21 @@ def url(): e5.current(0) e5.grid(row=4, column=1, columnspan=3, sticky="ew", pady=3) -ttk.Label(root, text="chatimg:").grid(row=5, column=0, sticky="e") -e6 = ttk.Entry(root, textvariable=img_path_get) -e6.grid(row=5, column=1, columnspan=2, sticky="ew", pady=3) -ttk.Button(root, text="选择", command=SelectImgPath, - width=5).grid(row=5, column=3) +ttk.Label(root, text="导出图片:").grid(row=5, column=0, sticky="e") +e6 = ttk.Combobox(root) +e6['values'] = ('是', '否') +e6.current(0) +e6.grid(row=5, column=1, columnspan=3, sticky="ew", pady=3) + +ttk.Label(root, text="合并图片:").grid(row=6, column=0, sticky="e") +e7 = ttk.Combobox(root) +e7['values'] = ('否', '是') +e7.current(0) +e7.grid(row=6, column=1, columnspan=3, sticky="ew", pady=3) root.grid_columnconfigure(2, weight=1) info.set("开始") -ttk.Button(root, textvariable=info, command=Enter).grid(row=6, column=1) +ttk.Button(root, textvariable=info, command=Enter).grid(row=7, column=1) tmp = open("tmp.png", "wb+") tmp.write(base64.b64decode(github_mark)) @@ -93,6 +102,6 @@ def url(): os.remove("tmp.png") button_img = tk.Button(root, image=github, text='b', command=url, bd=0) -button_img.grid(row=6, rowspan=7, column=0, sticky="ws") +button_img.grid(row=7, rowspan=7, column=0, sticky="ws") root.mainloop() diff --git a/QQ_History.py b/QQ_History.py index 43c0338..ded499c 100644 --- a/QQ_History.py +++ b/QQ_History.py @@ -30,50 +30,8 @@ def crc64(s): return v -def get_base64_from_pic(path): - with open(path, "rb") as image_file: - return (b'data:image/png;base64,' + base64.b64encode(image_file.read())).decode("utf-8") - - -def decode_pic(data, img_path): - try: - doc = PicRec() - doc.ParseFromString(data) - url = 'chatimg:' + doc.md5 - filename = hex(crc64(url)) - filename = 'Cache_' + filename.replace('0x', '') - rel_path = os.path.join(img_path, filename[-3:], filename) - if os.path.exists(rel_path): - w = 'auto' if doc.uint32_thumb_width == 0 else str( - doc.uint32_thumb_width) - h = 'auto' if doc.uint32_thumb_height == 0 else str( - doc.uint32_thumb_height) - return ''.format(get_base64_from_pic(rel_path), w, h) - except: - pass - return '[图片]' - - -def decode_mix_msg(data): - try: - doc = Elem() - doc.ParseFromString(data) - img_src = '' - if doc.picMsg: - img_src = decode_pic(doc.picMsg) - return img_src + doc.textMsg.decode('utf-8') - except: - pass - return '[混合消息]' - - -def decode_share_url(msg): - # TODO - return '[分享卡片]' - - class QQoutput(): - def __init__(self, path, qq_self, qq, mode, emoji, img_path): + def __init__(self, path, qq_self, qq, mode, emoji, with_img, combine_img): self.db_path = path self.key = self.get_key() # 解密用的密钥 db = os.path.join(path, "databases", qq_self + ".db") @@ -85,7 +43,8 @@ def __init__(self, path, qq_self, qq, mode, emoji, img_path): self.qq = qq self.mode = mode self.emoji = emoji - self.img_path = img_path + self.with_img = with_img + self.combine_img = combine_img self.num_to_name = {} self.emoji_map = self.map_new_emoji() @@ -101,6 +60,7 @@ def decrypt(self, data, msg_type=-1000): for i in range(0, len(data)): msg += chr(ord(data[i]) ^ ord(self.key[i % len(self.key)])) return msg + if msg_type == -1000: try: return msg.decode('utf-8') @@ -108,12 +68,15 @@ def decrypt(self, data, msg_type=-1000): # print(msg) pass return '[decode error]' + + if not self.with_img: + return None elif msg_type == -2000: - return decode_pic(msg, self.img_path) + return self.decode_pic(msg) elif msg_type == -1035: - return decode_mix_msg(msg) + return self.decode_mix_msg(msg) elif msg_type == -5008: - return decode_share_url(msg) + return self.decode_share_url(msg) # for debug # return '[unknown msg_type {}]'.format(msg_type) return None @@ -125,13 +88,18 @@ def add_emoji(self, msg): num = ord(msg[pos + 1]) if str(num) in self.emoji_map: index = self.emoji_map[str(num)] + if self.emoji == 1: filename = "new/s" + index + ".png" else: filename = "old/" + index + ".gif" + + emoticon_path = os.path.join('emoticon', filename) + if self.combine_img: + emoticon_path = self.get_base64_from_pic(emoticon_path) + msg = msg.replace( - msg[pos:pos + 2], - '{}'.format(get_base64_from_pic(os.path.join('emoticon', filename)), index)) + msg[pos:pos + 2], '{}'.format(emoticon_path, index)) else: msg = msg.replace(msg[pos:pos + 2], '[emoji:{}]'.format(str(num))) @@ -266,10 +234,50 @@ def map_new_emoji(self): new_emoji_map[e["AQLid"]] = str(int(e["EMCode"]) - 100) return new_emoji_map + def get_base64_from_pic(self, path): + with open(path, "rb") as image_file: + return (b'data:image/png;base64,' + base64.b64encode(image_file.read())).decode("utf-8") -def main(db_path, qq_self, qq, mode, emoji, img_path): + def decode_pic(self, data): + try: + doc = PicRec() + doc.ParseFromString(data) + url = 'chatimg:' + doc.md5 + filename = hex(crc64(url)) + filename = 'Cache_' + filename.replace('0x', '') + rel_path = os.path.join("./chatimg/", filename[-3:], filename) + if os.path.exists(rel_path): + w = 'auto' if doc.uint32_thumb_width == 0 else str( + doc.uint32_thumb_width) + h = 'auto' if doc.uint32_thumb_height == 0 else str( + doc.uint32_thumb_height) + if self.combine_img: + rel_path = self.get_base64_from_pic(rel_path) + return ''.format(rel_path, w, h) + except: + pass + return '[图片]' + + def decode_mix_msg(self, data): + try: + doc = Elem() + doc.ParseFromString(data) + img_src = '' + if doc.picMsg: + img_src = self.decode_pic(doc.picMsg) + return img_src + doc.textMsg.decode('utf-8') + except: + pass + return '[混合消息]' + + def decode_share_url(self, msg): + # TODO + return '[分享卡片]' + + +def main(db_path, qq_self, qq, mode, emoji, with_img, combine_img): try: - q = QQoutput(db_path, qq_self, qq, mode, emoji, img_path) + q = QQoutput(db_path, qq_self, qq, mode, emoji, with_img, combine_img) q.output() except Exception as e: with open('log.txt', 'w') as f: @@ -281,12 +289,3 @@ def main(db_path, qq_self, qq, mode, emoji, img_path): raise ValueError("信息填入错误") else: raise BaseException("Error! See log.txt") - - -db_path = "C:/Users/30857/Desktop/qq备份/apps/com.tencent.mobileqq/" -qq_self = "308571034" -qq = "939840382" -mode = 2 -emoji = 1 -img_path = "C:/Users/30857/Desktop/qq备份/chatpic/chatimg" -main(db_path, qq_self, qq, mode, emoji, img_path) diff --git a/README.md b/README.md index 1dc68e3..a3f5b41 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,46 @@ # QQ聊天记录导出 -可执行文件[Github下载链接](https://github.com/Yiyiyimu/QQ_History_Backup/releases/download/v2.1/QQ_History_Backup-v2.1.zip),[百度网盘下载链接](https://pan.baidu.com/s/1zp3Cg724B-Z65eJjGuKHVQ)(86y6) ,可直接运行。 +可执行文件[Github下载链接](https://github.com/Yiyiyimu/QQ_History_Backup/releases/download/v2.2/QQ_History_Backup-v2.2.zip),[百度网盘下载链接](https://pan.baidu.com/s/1Qit4IRfZdCzJ88n6sfONCg)(ovxt) ,可直接运行。 ## 简介 作为国内最常用的聊天工具之一,QQ 为了用户留存度,默认聊天记录备份无法脱离 QQ 被独立打开。 -目前[版本](#致谢)往往需要自行编译,本方法在之前版本的基础上简化了操作,制作了GUI方便使用;并且不再需要提供密钥,自动填入备注/昵称,添加了QQ表情的一并导出。 +目前[版本](#致谢)往往需要自行编译,本方法在之前版本的基础上简化了操作,制作了GUI方便使用;并且不再需要提供密钥,自动填入备注/昵称,添加了QQ表情和图片的一并导出。 ## 获取聊天记录文件夹方法 -如果root了,直接在以下地址就可以找到。建议压缩文件夹后复制导出。 +如果手机root,聊天记录可在以下地址找到。因为小文件较多建议压缩文件夹后复制导出。 ``` data\data\com.tencent.mobileqq ``` -如果没有root,可以通过手机自带的备份工具备份整个QQ软件,具体方法可以参见 +如果没有root,可以通过手机自带的备份工具备份整个QQ,具体方法可以参见 > 怎样导出手机中的QQ聊天记录? - 益新软件的回答 - 知乎 > https://www.zhihu.com/question/28574047/answer/964813560 +如果同时需要在聊天记录中显示图片,拷贝手机中 `Android/data/com.tencent.mobileqq/Tencent/MobileQQ/chatpic/chatimg` 至 `GUI.exe` 同一文件夹中 + ## GUI使用方法 ![GUI_image](./img/GUI.png) - com.tencent.mobileqq:选择备份后的相应文件夹,一般为`apps/com.tencent.mobileqq` - 表情版本:默认为新版QQ表情。如果你的聊天记录来自很早以前(比如我),可以切换为旧版的表情 +- 单一文件:默认为否 + - 不启用单一文件好处在于:1. 使导出的 HTML 文件具有可读性;2. 减小 HTML 文件体积方便打开 + - 启用单一文件好处:拷贝时不需要和 `emoticon` 以及 `chatimg` 文件夹一起拷贝,更加方便 ## 输出截图 -为了方便离线查看,emoji 已经集成到了输出的 `HTML` 文件中 - -![screenshot](./img/screenshot.png) - -有bug的话提issue,记得附上log.txt里的内容 - -## 显示图片 +![screenshot](./img/layout.png) +![screenshot](./img/images.png) -- 需要额外的步骤 -- 手机连电脑 adb pull /sdcard/Andoird/data/com.tencent.mobileqq ./ -- 或者 找工具把这个路径放到和运行程序同目录 +如果没有启用单一文件,拷贝生成的聊天记录时需要一起拷贝 `emoticon` 以及 `chatimg` 文件夹. -![screenshot](./img/example_img.png) - -- 注:图片必须在手机上看过一次才有,因为QQ是看了才下载原图 +有bug的话提issue,记得附上log.txt里的内容。 ## v2 更新 - 直接从 `files/kc` 提取明文的密钥,不用再手动输入或解密 @@ -52,7 +48,10 @@ data\data\com.tencent.mobileqq - 支持 私聊/群聊 的 备注/昵称 自动填入 - 支持 slowtable 的直接整合 - 支持新版 QQ 表情 -- 20210120 支持图片 + +## v2.2 更新 +- 支持导出图片至聊天记录 +- 支持合并图片至单一文件方便传输 ## TODO - [x] support troop message output @@ -60,12 +59,12 @@ data\data\com.tencent.mobileqq - [x] decode friend/troop name, to use in result - [x] auto-combine db and slow-table - [x] update to new qq emoji +- [x] use pic in mobile folder, to better present result +- [ ] export voicelines - [ ] add desensitization data to create e2e test - [ ] add Makefile, to run build/test -- [x] use pic in mobile folder, to better present result -- [ ] 支持图片缩略图的加载 -- [ ] 支持分享卡片消息 -- [ ] 提高图文消息显示兼容性 +- [ ] support thumbnail images +- [ ] support sharing cards ## 致谢 1. [roadwide/qqmessageoutput](https://github.com/roadwide/qqmessageoutput) diff --git a/img/GUI.png b/img/GUI.png index 163126e..1f5a621 100644 Binary files a/img/GUI.png and b/img/GUI.png differ diff --git a/img/example_img.png b/img/example_img.png deleted file mode 100644 index f6721a8..0000000 Binary files a/img/example_img.png and /dev/null differ diff --git a/img/images.png b/img/images.png new file mode 100644 index 0000000..f7ab0d1 Binary files /dev/null and b/img/images.png differ diff --git a/img/screenshot.png b/img/layout.png similarity index 100% rename from img/screenshot.png rename to img/layout.png