Skip to content

Commit

Permalink
Merge pull request #7 from Yiyiyimu/dev
Browse files Browse the repository at this point in the history
Update to v2.0
Yiyiyimu authored Jan 2, 2021
2 parents a20b09c + 3d16b58 commit 6f93eca
Showing 4 changed files with 152 additions and 166 deletions.
49 changes: 17 additions & 32 deletions GUI.py
Original file line number Diff line number Diff line change
@@ -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
227 changes: 118 additions & 109 deletions QQ_History.py
Original file line number Diff line number Diff line change
@@ -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(
"<head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" /></head>"
)
allmsg = self.message(num)
allmsg = self.message()
f2.write("<div style='white-space: pre-line'>")
for msg in allmsg:
if not msg[2]:
continue
try:
if self.mode == 1:
if (msg[1] == str(num)):
f2.write("<p align='left'>")
f2.write("<font color=\"blue\"><b>")
f2.write(name2)
f2.write("</b></font>-----<font color=\"green\">")
f2.write(msg[0])
f2.write("</font></br>")
else:
f2.write("<p align='right'>")
f2.write("<font color=\"green\">")
f2.write(msg[0])
f2.write("</font>-----<font color=\"blue\"><b>")
f2.write(name1)
f2.write("</font></b></br>")
if (msg[1] == str(self.qq_self)):
f2.write("<p align='right'>")
f2.write("<font color=\"green\">")
f2.write(msg[0])
f2.write("</font>-----<font color=\"blue\"><b>")
f2.write(name1)
f2.write("</font></b></br>")
else:
f2.write("<p align='left'>")
f2.write("<font color=\"blue\"><b>")
f2.write(msg[1])
f2.write(self.num_to_name.get(msg[1]) or msg[1])
f2.write("</b></font>-----<font color=\"green\">")
f2.write(msg[0])
f2.write("</font></br>")
f2.write(self.AddEmoji(msg[2]))
f2.write(self.add_emoji(msg[2]))
f2.write("</br></br>")
f2.write("</p>")
except:
pass
f2.write("</div>")
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")
42 changes: 17 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,61 +1,53 @@
# 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`

## 输出截图

为了方便离线查看,qq表情gif选择保存在本地,注意移动聊天记录的时候需要同时移动gif文件

![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
Binary file modified img/GUI.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 6f93eca

Please sign in to comment.