diff --git a/GUI.py b/GUI.py index 7a80676..f43ce82 100644 --- a/GUI.py +++ b/GUI.py @@ -9,26 +9,22 @@ def Enter(): - db, qq, key, msg, n1, n2 = e1.get(), e2.get(), e3.get(), e4.get(), e5.get( - ), e6.get() - group = 1 if e7.get() == '私聊' else 2 - if (db == "" or qq == "" or (key == "" and msg == "")): + dir, qq_self, qq = e1.get(), e2.get(), e3.get() + group = 1 if e4.get() == '私聊' else 2 + if (dir == "" or qq_self == "" or qq == ""): info.set("信息不完整!") return () info.set("开始导出") try: - realkey = QQ_History.main(db, qq, key, msg, n1, n2, group) + QQ_History.main(dir, qq_self, qq, group) except Exception as e: info.set(repr(e)) return () - if (key == ""): - keyGet.set(realkey) - info.set("完成") def SelectPath(): - pathTmp = filedialog.askopenfilename() - pathGet.set(pathTmp) + dir = filedialog.askdirectory() + pathGet.set(dir) def url(): @@ -46,41 +42,30 @@ def url(): root.title("QQ聊天记录导出") -ttk.Label(root, text="*db文件地址:").grid(row=0, column=0, sticky="e") +ttk.Label(root, text="*com.tencent.mobileqq:").grid(row=0, column=0, sticky="e") e1 = ttk.Entry(root, textvariable=pathGet) e1.grid(row=0, column=1) ttk.Button(root, text="选择", command=SelectPath, width=5).grid(row=0, column=3) -ttk.Label(root, text="*对方QQ号:").grid(row=1, column=0, sticky="e") +ttk.Label(root, text="*自己QQ号:").grid(row=1, column=0, sticky="e") e2 = ttk.Entry(root) e2.grid(row=1, column=1, columnspan=3, sticky="ew", pady=3) -ttk.Label(root, text="手机识别码:").grid(row=2, column=0, sticky="e") -e3 = ttk.Entry(root, textvariable=keyGet) +ttk.Label(root, text="*QQ号/群号:").grid(row=2, column=0, sticky="e") +e3 = ttk.Entry(root) e3.grid(row=2, column=1, columnspan=3, sticky="ew", pady=3) -ttk.Label(root, text="最后一次聊天记录\n(至少六个汉字)").grid(row=3, column=0, sticky="e") -e4 = ttk.Entry(root) +ttk.Label(root, text="私聊/群聊:").grid(row=3, column=0, sticky="e") +e4 = ttk.Combobox(root) +e4['values'] = ('私聊', '群聊') +e4.current(0) e4.grid(row=3, column=1, columnspan=3, sticky="ew", pady=3) -ttk.Label(root, text="我的名字:").grid(row=4, column=0, sticky="e") -e5 = ttk.Entry(root) -e5.grid(row=4, column=1, columnspan=3, sticky="ew", pady=3) -ttk.Label(root, text="对方名字:").grid(row=5, column=0, sticky="e") -e6 = ttk.Entry(root) -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) -ttk.Button(root, text="确认", command=Enter).grid(row=7, column=1) +ttk.Button(root, text="确认", command=Enter).grid(row=4, column=1) l1 = ttk.Label(root, textvariable=info) -l1.grid(row=7, column=1) +l1.grid(row=4, column=1) tmp = open("tmp.png", "wb+") tmp.write(base64.b64decode(github_mark)) @@ -93,4 +78,4 @@ def url(): root.mainloop() -## pyinstaller -F -w -i icon.ico GUI.py +# pyinstaller -F -w -i icon.ico GUI.py diff --git a/QQ_History.py b/QQ_History.py index 3d20c3f..cb03014 100644 --- a/QQ_History.py +++ b/QQ_History.py @@ -2,70 +2,43 @@ import hashlib import sqlite3 import time +import os import traceback class QQoutput(): - def __init__(self, db, key, mode, s): - self.key = key # 解密用的密钥 - self.c = sqlite3.connect(db).cursor() + def __init__(self, dir, qq_self, qq, mode): + self.dir = dir + self.key = self.get_key() # 解密用的密钥 + db = os.path.join(dir, "databases", qq_self + ".db") + self.c1 = sqlite3.connect(db).cursor() + db = os.path.join(dir, "databases", "slowtable_" + qq_self + ".db") + self.c2 = sqlite3.connect(db).cursor() + self.qq_self = qq_self + self.qq = qq self.mode = mode - self.s = s - - def fix(self, data, mode): - # msgdata mode=0 - # other mode=1 - if (mode == 0): - rowbyte = [] - for i in range(0, len(data)): - rowbyte.append(data[i] ^ ord(self.key[i % len(self.key)])) - rowbyte = bytes(rowbyte) + self.num_to_name = {} + + def decrypt(self, data): + if type(data) == bytes: + msg = b'' try: - msg = rowbyte.decode(encoding="utf-8") + for i in range(0, len(data)): + msg += bytes([data[i] ^ ord(self.key[i % len(self.key)])]) + return msg.decode(encoding="utf-8") except: - msg = NULL - return msg - elif (mode == 1): - str = "" + return NULL + elif type(data) == str: + msg = "" try: for i in range(0, len(data)): - str += chr(ord(data[i]) ^ ord(self.key[i % len(self.key)])) + msg += chr(ord(data[i]) ^ ord(self.key[i % len(self.key)])) except: - str = NULL - return str - - def decode(self, cursor): - for row in cursor: - continue - data = row[0] - MsgEnc = self.s.encode(encoding="utf-8") - KeySet = "" - # for i in range(0,min(len(MsgEnc), len(data))): - for i in range(0, len(MsgEnc)): - KeySet += chr(data[i] ^ MsgEnc[i]) - # TO AVOID LOOP - RealKey, restKey = "", "" - for i in range(4, len(KeySet)): - ''' - bug WARNING!! - Assuming Key should be longer than 5 digits - To Prevent string loop in single key - Like "121212456" - ''' - RealKey, nextKey, restKey = KeySet[0:i], KeySet[i:2 * i], KeySet[ - 2 * i:len(KeySet)] - KeyLen = len(RealKey) - flagLoop = True - for j in range(KeyLen): - if ((j < len(nextKey) and RealKey[j] != nextKey[j]) - or (j < len(restKey) and RealKey[j] != restKey[j])): - flagLoop = False - break - if (flagLoop and j == KeyLen - 1): - break - return RealKey + msg = NULL + return msg + return NULL - def AddEmoji(self, msg): + def add_emoji(self, msg): pos = msg.find('\x14') while (pos != -1): lastpos = pos @@ -78,98 +51,134 @@ def AddEmoji(self, msg): break return msg - def message(self, num): + def message(self): # mode=1 friend # mode=2 troop - num = str(num).encode("utf-8") + num = self.qq.encode("utf-8") md5num = hashlib.md5(num).hexdigest().upper() if (self.mode == 1): - execute = "select msgData,senderuin,time from mr_friend_{md5num}_New".format( - md5num=md5num) - elif (self.mode == 2): - execute = "select msgData,senderuin,time from mr_troop_{md5num}_New".format( + cmd = "select msgData,senderuin,time from mr_friend_{md5num}_New".format( md5num=md5num) + self.get_friends() else: - print("error mode") - exit(1) + cmd = "select msgData,senderuin,time from mr_troop_{md5num}_New".format( + md5num=md5num) + self.get_troop_members() - cursor = self.c.execute(execute) - if (self.key == "" and len(self.s) >= 5): - self.key = self.decode(cursor) - cursor = self.c.execute(execute) + cursors = self.fill_cursors(cmd) allmsg = [] - for row in cursor: - msgdata = row[0] - if (not msgdata): - continue - uin = row[1] - ltime = time.localtime(row[2]) - - sendtime = time.strftime("%Y-%m-%d %H:%M:%S", ltime) - msg = self.fix(msgdata, 0) - senderuin = self.fix(uin, 1) - - amsg = [] - amsg.append(sendtime) - amsg.append(senderuin) - amsg.append(msg) - allmsg.append(amsg) + for cs in cursors: + for row in cs: + msgdata = row[0] + if (not msgdata): + continue + uin = row[1] + ltime = time.localtime(row[2]) + sendtime = time.strftime("%Y-%m-%d %H:%M:%S", ltime) + + amsg = [] + amsg.append(sendtime) + amsg.append(self.decrypt(uin)) + amsg.append(self.decrypt(msgdata)) + allmsg.append(amsg) return allmsg - def output(self, num, n1, n2): - name1 = n1 if n1 != "" else "我" - name2 = n2 if n2 != "" else str(num) - file = str(num) + ".html" + def get_friends(self): + cmd = "SELECT uin, remark FROM Friends" + cursors = self.fill_cursors(cmd) + for cs in cursors: + for row in cs: + num = self.decrypt(row[0]) + name = self.decrypt(row[1]) + self.num_to_name[num] = name + + def get_troop_members(self): + cmd = "SELECT troopuin, memberuin, friendnick, troopnick FROM TroopMemberInfo" + cursors = self.fill_cursors(cmd) + for cs in cursors: + for row in cs: + if(self.decrypt(row[0]) != self.qq): + continue + num = self.decrypt(row[1]) + name = self.decrypt(row[3]) or self.decrypt(row[2]) + self.num_to_name[num] = name + + def fill_cursors(self, cmd): + cursors = [] + try: + cursors.append(self.c2.execute(cmd)) + except: + pass + try: + cursors.append(self.c1.execute(cmd)) + except: + pass + return cursors + + def output(self): + name1 = "我" + file = str(self.qq) + ".html" f2 = open(file, "w", encoding="utf-8") f2.write( "" ) - allmsg = self.message(num) + allmsg = self.message() f2.write("
") for msg in allmsg: + if not msg[2]: + continue try: - if self.mode == 1: - if (msg[1] == str(num)): - f2.write("

") - f2.write("") - f2.write(name2) - f2.write("-----") - f2.write(msg[0]) - f2.write("
") - else: - f2.write("

") - f2.write("") - f2.write(msg[0]) - f2.write("-----") - f2.write(name1) - f2.write("
") + if (msg[1] == str(self.qq_self)): + f2.write("

") + f2.write("") + f2.write(msg[0]) + f2.write("-----") + f2.write(name1) + f2.write("
") else: f2.write("

") f2.write("") - f2.write(msg[1]) + f2.write(self.num_to_name.get(msg[1]) or msg[1]) f2.write("-----") f2.write(msg[0]) f2.write("
") - f2.write(self.AddEmoji(msg[2])) + f2.write(self.add_emoji(msg[2])) f2.write("

") f2.write("

") except: pass f2.write("
") - return self.key + def get_key(self): + self.unify_path() + kc_path = os.path.join(self.dir, "files", "kc") + kc_file = open(kc_path, "r") + return kc_file.read() + + def unify_path(self): + if os.path.isdir(os.path.join(self.dir, "f")): + os.rename(os.path.join(self.dir, "f"), + os.path.join(self.dir, "files")) + if os.path.isdir(os.path.join(self.dir, "db")): + os.rename(os.path.join(self.dir, "db"), + os.path.join(self.dir, "databases")) + if os.path.isfile(os.path.join(self.dir, "files", "kc")) == False: + raise OSError( + "File not found. Please report your directory layout.") -def main(db, qq, key, msg, n1, n2, mode): + +def main(dir, qq_self, qq, mode): try: - q = QQoutput(db, key, mode, msg) - return q.output(qq, n1, n2) + q = QQoutput(dir, qq_self, qq, mode) + q.output() except Exception as e: with open('log.txt', 'w') as f: f.write(str(e)) f.write(traceback.format_exc()) err_info = repr(e).split(":")[0] == "OperationalError('no such table" - print(err_info) - print(repr(e)) + print(traceback.format_exc()) if (err_info): raise ValueError("QQ号/私聊群聊选择/db地址/错误") + else: + raise BaseException("Error! See log.txt") diff --git a/README.md b/README.md index 9bfb98a..69b5697 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,32 @@ # QQ聊天记录导出 -可执行文件[Github下载链接](https://github.com/Yiyiyimu/QQ_History_Backup/releases/download/v1.41/QQ_History_Backup-v1.41.zip),[百度网盘下载链接](https://pan.baidu.com/s/1FRcqKiYho-DoDU-RC_uRkw)(sqhc) ,可直接运行。 +可执行文件[Github下载链接](https://github.com/Yiyiyimu/QQ_History_Backup/releases/download/v2.0/QQ_History_Backup-v2.0.zip),[百度网盘下载链接](https://pan.baidu.com/s/1nbJcP5RVc1ID1IFGsN1-yQw)(i4cv) ,可直接运行。 ## 简介 -本项目 fork 自大佬的项目[roadwide/qqmessageoutput](https://github.com/roadwide/qqmessageoutput) 在此非常感谢。因为改动较多不再作为 fork 分支开发 +本项目 fork 自大佬的项目[roadwide/qqmessageoutput](https://github.com/roadwide/qqmessageoutput) 在此非常感谢。因为改动较多,不再作为 fork 分支开发 -在之前版本的基础上完成了原作者提到的无需密钥解密的方法,添加了QQ表情的一并导出,并制作了GUI方便使用 +在之前版本的基础上完成了自动提取密钥解密的方法,自动填入备注/昵称,添加了QQ表情的一并导出,并制作了GUI方便使用 -## 获取db文件方法 +## 获取聊天记录文件夹方法 如果root了,直接在以下地址就可以找到 ``` -data\data\com.tencent.mobileqq\databases\你的QQ.db 和 slowtable_你的QQ.db +data\data\com.tencent.mobileqq ``` - 如果没有root,可以通过手机自带的备份工具备份整个QQ软件,具体方法可以参见 > 怎样导出手机中的QQ聊天记录? - 益新软件的回答 - 知乎 > https://www.zhihu.com/question/28574047/answer/964813560 -导出之前建议发给对方一句话(至少六个汉字),后面解密用 ## GUI使用方法 ![GUI_image](./img/GUI.png) -db文件地址(必填):选择对应的 qq号.db ,如果不全再选择slowtable_qq号.db - -对方QQ号(必填) - -手机识别码(待自动填入,供slowtable使用): -Android Q及以上(19年以后的系统)限制了id获取权限,无法使用手机识别码(IMEI/MEID)作为聊天记录数据库的密钥,只能通过最后一次聊天记录计算key。在导出slowtable里的内容时默认使用前一步输出的手机识别码作为密钥。 - -最后一次聊天记录(非slowtable**必填**): -因为测试所用两部手机密钥分别为9位和14位,一个汉字对应三个utf-8码,为了避免更长的密钥推荐使用至少六个汉字符号。可以在导出之前给对方发一句话过去。 - -我的名字(选填):默认为“我”,填入进行替换 - -对方名字(选填):默认为对方QQ号,填入进行替换 - -私聊/群聊 +com.tencent.mobileqq:选择备份后的相应文件夹,一般为`apps/com.tencent.mobileqq` ## 输出截图 @@ -50,12 +34,20 @@ Android Q及以上(19年以后的系统)限制了id获取权限,无法使 ![screenshot](./img/screenshot.png) -有bug的话记得附上log.txt里的内容 +有bug的话提issue,记得附上log.txt里的内容 + +## v2.0 更新 +- 直接从 `files/kc` 提取明文的密钥,不用再手动输入或解密 +- 支持群聊记录导出 +- 支持 私聊/群聊 的 备注/昵称 自动填入 +- 支持 slowtable 的直接整合 ## TODO - [x] support troop message output -- [ ] use com.tencent.mobileqq/f/kc as key -- [ ] decode friend/troop name, to use in result +- [x] use com.tencent.mobileqq/f/kc as key +- [x] decode friend/troop name, to use in result +- [x] auto-combine db and slow-table +- [ ] update to new qq emoji - [ ] add desensitization data to create e2e test - [ ] add Makefile, to run build/test - [ ] use pic in mobile folder, to better present result \ No newline at end of file diff --git a/img/GUI.png b/img/GUI.png index d85cd6e..0df9b89 100644 Binary files a/img/GUI.png and b/img/GUI.png differ