更新文档

This commit is contained in:
shuaikangzhou 2024-01-29 20:42:58 +08:00
parent 1f9f478ed3
commit e64af57ca3
15 changed files with 929 additions and 755 deletions

View File

@ -37,4 +37,4 @@ def init_db():
media_msg_db.init_database()
__all__ = ['output', 'misc_db', 'micro_msg_db', 'msg_db', 'hard_link_db', 'MsgType', "media_msg_db","close_db"]
__all__ = ['exporter.py', 'misc_db', 'micro_msg_db', 'msg_db', 'hard_link_db', 'MsgType', "media_msg_db", "close_db"]

175
app/DataBase/exporter.py Normal file
View File

@ -0,0 +1,175 @@
import csv
import html
import os
import shutil
import sys
import filecmp
from PyQt5.QtCore import pyqtSignal, QThread
from ..person import Me, Contact
os.makedirs('./data/聊天记录', exist_ok=True)
def set_global_font(doc, font_name):
# 创建一个新样式
style = doc.styles['Normal']
# 设置字体名称
style.font.name = font_name
# 遍历文档中的所有段落,将样式应用到每个段落
for paragraph in doc.paragraphs:
for run in paragraph.runs:
run.font.name = font_name
def makedirs(path):
os.makedirs(path, exist_ok=True)
os.makedirs(os.path.join(path, 'image'), exist_ok=True)
os.makedirs(os.path.join(path, 'emoji'), exist_ok=True)
os.makedirs(os.path.join(path, 'video'), exist_ok=True)
os.makedirs(os.path.join(path, 'voice'), exist_ok=True)
os.makedirs(os.path.join(path, 'file'), exist_ok=True)
os.makedirs(os.path.join(path, 'avatar'), exist_ok=True)
os.makedirs(os.path.join(path, 'music'), exist_ok=True)
os.makedirs(os.path.join(path, 'icon'), exist_ok=True)
resource_dir = os.path.join('app', 'resources', 'data', 'icons')
if not os.path.exists(resource_dir):
# 获取打包后的资源目录
resource_dir = getattr(sys, '_MEIPASS', os.path.abspath(os.path.dirname(__file__)))
# 构建 FFmpeg 可执行文件的路径
resource_dir = os.path.join(resource_dir, 'app', 'resources', 'data', 'icons')
target_folder = os.path.join(path, 'icon')
# 拷贝一些必备的图标
for root, dirs, files in os.walk(resource_dir):
relative_path = os.path.relpath(root, resource_dir)
target_path = os.path.join(target_folder, relative_path)
# 遍历文件夹中的文件
for file in files:
source_file_path = os.path.join(root, file)
target_file_path = os.path.join(target_path, file)
if not os.path.exists(target_file_path):
shutil.copy(source_file_path, target_file_path)
else:
# 比较文件内容
if not filecmp.cmp(source_file_path, target_file_path, shallow=False):
# 文件内容不一致,进行覆盖拷贝
shutil.copy(source_file_path, target_file_path)
def escape_js_and_html(input_str):
if not input_str:
return ''
# 转义HTML特殊字符
html_escaped = html.escape(input_str, quote=False)
# 手动处理JavaScript转义字符
js_escaped = (
html_escaped
.replace("\\", "\\\\")
.replace("'", r"\'")
.replace('"', r'\"')
.replace("\n", r'\n')
.replace("\r", r'\r')
.replace("\t", r'\t')
)
return js_escaped
class ExporterBase(QThread):
progressSignal = pyqtSignal(int)
rangeSignal = pyqtSignal(int)
okSignal = pyqtSignal(int)
i = 1
CSV = 0
DOCX = 1
HTML = 2
CSV_ALL = 3
CONTACT_CSV = 4
TXT = 5
def __init__(self, contact, type_=DOCX, message_types={}, time_range=None, messages=None,index=0, parent=None):
super().__init__(parent)
self.message_types = message_types # 导出的消息类型
self.contact: Contact = contact # 联系人
self.output_type = type_ # 导出文件类型
self.total_num = 1 # 总的消息数量
self.num = 0 # 当前处理的消息数量
self.index = index #
self.last_timestamp = 0
self.time_range = time_range
self.messages = messages
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
makedirs(origin_docx_path)
def run(self):
self.export()
def export(self):
raise NotImplementedError("export method must be implemented in subclasses")
def cancel(self):
self.requestInterruption()
def is_5_min(self, timestamp) -> bool:
if abs(timestamp - self.last_timestamp) > 300:
self.last_timestamp = timestamp
return True
return False
def get_avatar_path(self, is_send, message, is_absolute_path=False) -> str:
if is_absolute_path:
if self.contact.is_chatroom:
avatar = message[13].avatar_path
else:
avatar = Me().avatar_path if is_send else self.contact.avatar_path
else:
if self.contact.is_chatroom:
avatar = message[13].smallHeadImgUrl
else:
avatar = Me().smallHeadImgUrl if is_send else self.contact.smallHeadImgUrl
return avatar
def get_display_name(self, is_send, message) -> str:
if self.contact.is_chatroom:
if is_send:
display_name = Me().name
else:
display_name = message[13].remark
else:
display_name = Me().name if is_send else self.contact.remark
return escape_js_and_html(display_name)
def text(self, doc, message):
return
def image(self, doc, message):
return
def audio(self, doc, message):
return
def emoji(self, doc, message):
return
def file(self, doc, message):
return
def refermsg(self, doc, message):
return
def system_msg(self, doc, message):
return
def video(self, doc, message):
return
def music_share(self, doc, message):
return
def share_card(self, doc, message):
return

View File

@ -2,7 +2,7 @@ import csv
import os
from app.DataBase import msg_db
from app.DataBase.output import ExporterBase
from app.DataBase.exporter import ExporterBase
class CSVExporter(ExporterBase):

View File

@ -12,7 +12,7 @@ from docx.oxml.ns import qn
from docxcompose.composer import Composer
from app.DataBase import msg_db, hard_link_db
from app.DataBase.output import ExporterBase, escape_js_and_html
from app.DataBase.exporter import ExporterBase, escape_js_and_html
from app.log import logger
from app.person import Me
from app.util.compress_content import parser_reply, share_card, music_share

View File

@ -7,7 +7,7 @@ from re import findall
from PyQt5.QtCore import pyqtSignal, QThread
from app.DataBase import msg_db, hard_link_db, media_msg_db
from app.DataBase.output import ExporterBase, escape_js_and_html
from app.DataBase.exporter import ExporterBase, escape_js_and_html
from app.log import logger
from app.person import Me
from app.util import path

View File

@ -1,7 +1,7 @@
import os
from app.DataBase import msg_db
from app.DataBase.output import ExporterBase
from app.DataBase.exporter import ExporterBase
from app.util.compress_content import parser_reply, share_card

View File

@ -1,89 +1,39 @@
import csv
import html
import os
import shutil
import sys
import time
import traceback
from typing import List
import filecmp
import docx
from PyQt5.QtCore import pyqtSignal, QThread, QObject
from PyQt5.QtWidgets import QFileDialog
from docx.oxml.ns import qn
from docxcompose.composer import Composer
from PyQt5.QtCore import pyqtSignal, QThread
from ..person import Me, Contact
from app.DataBase.exporter_csv import CSVExporter
from app.DataBase.exporter_docx import DocxExporter
from app.DataBase.exporter_html import HtmlExporter
from app.DataBase.exporter_txt import TxtExporter
from app.DataBase.hard_link import decodeExtraBuf
from .package_msg import PackageMsg
from ..DataBase import media_msg_db, hard_link_db, micro_msg_db, msg_db
from ..log import logger
from ..person import Me
from ..util.image import get_image
os.makedirs('./data/聊天记录', exist_ok=True)
def set_global_font(doc, font_name):
# 创建一个新样式
style = doc.styles['Normal']
# 设置字体名称
style.font.name = font_name
# 遍历文档中的所有段落,将样式应用到每个段落
for paragraph in doc.paragraphs:
for run in paragraph.runs:
run.font.name = font_name
def makedirs(path):
os.makedirs(path, exist_ok=True)
os.makedirs(os.path.join(path, 'image'), exist_ok=True)
os.makedirs(os.path.join(path, 'emoji'), exist_ok=True)
os.makedirs(os.path.join(path, 'video'), exist_ok=True)
os.makedirs(os.path.join(path, 'voice'), exist_ok=True)
os.makedirs(os.path.join(path, 'file'), exist_ok=True)
os.makedirs(os.path.join(path, 'avatar'), exist_ok=True)
os.makedirs(os.path.join(path, 'music'), exist_ok=True)
os.makedirs(os.path.join(path, 'icon'), exist_ok=True)
resource_dir = os.path.join('app', 'resources', 'data', 'icons')
if not os.path.exists(resource_dir):
# 获取打包后的资源目录
resource_dir = getattr(sys, '_MEIPASS', os.path.abspath(os.path.dirname(__file__)))
# 构建 FFmpeg 可执行文件的路径
resource_dir = os.path.join(resource_dir, 'app', 'resources', 'data', 'icons')
target_folder = os.path.join(path, 'icon')
# 拷贝一些必备的图标
for root, dirs, files in os.walk(resource_dir):
relative_path = os.path.relpath(root, resource_dir)
target_path = os.path.join(target_folder, relative_path)
# 遍历文件夹中的文件
for file in files:
source_file_path = os.path.join(root, file)
target_file_path = os.path.join(target_path, file)
if not os.path.exists(target_file_path):
shutil.copy(source_file_path, target_file_path)
else:
# 比较文件内容
if not filecmp.cmp(source_file_path, target_file_path, shallow=False):
# 文件内容不一致,进行覆盖拷贝
shutil.copy(source_file_path, target_file_path)
def escape_js_and_html(input_str):
if not input_str:
return ''
# 转义HTML特殊字符
html_escaped = html.escape(input_str, quote=False)
# 手动处理JavaScript转义字符
js_escaped = (
html_escaped
.replace("\\", "\\\\")
.replace("'", r"\'")
.replace('"', r'\"')
.replace("\n", r'\n')
.replace("\r", r'\r')
.replace("\t", r'\t')
)
return js_escaped
class ExporterBase(QThread):
class Output(QThread):
"""
发送信息线程
"""
startSignal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
rangeSignal = pyqtSignal(int)
okSignal = pyqtSignal(int)
batchOkSignal = pyqtSignal(int)
nowContact = pyqtSignal(str)
i = 1
CSV = 0
DOCX = 1
@ -91,85 +41,397 @@ class ExporterBase(QThread):
CSV_ALL = 3
CONTACT_CSV = 4
TXT = 5
Batch = 10086
def __init__(self, contact, type_=DOCX, message_types={}, time_range=None, messages=None,index=0, parent=None):
def __init__(self, contact, type_=DOCX, message_types={}, sub_type=[], time_range=None, parent=None):
super().__init__(parent)
self.message_types = message_types # 导出的消息类型
self.contact: Contact = contact # 联系人
self.output_type = type_ # 导出文件类型
self.total_num = 1 # 总的消息数量
self.num = 0 # 当前处理的消息数量
self.index = index #
self.children = []
self.last_timestamp = 0
self.sub_type = sub_type
self.time_range = time_range
self.messages = messages
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
makedirs(origin_docx_path)
self.message_types = message_types
self.sec = 2 # 默认1000秒
self.contact = contact
self.msg_id = 0
self.output_type: int | List[int] = type_
self.total_num = 1
self.num = 0
def progress(self, value):
self.progressSignal.emit(value)
def output_image(self):
"""
导出全部图片
@return:
"""
return
def output_emoji(self):
"""
导出全部表情包
@return:
"""
return
def to_csv_all(self):
"""
导出全部聊天记录到CSV
@return:
"""
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/"
os.makedirs(origin_docx_path, exist_ok=True)
filename = QFileDialog.getSaveFileName(None, "save file", os.path.join(os.getcwd(), 'messages.csv'),
"csv files (*.csv);;all files(*.*)")
if not filename[0]:
return
self.startSignal.emit(1)
filename = filename[0]
# columns = ["用户名", "消息内容", "发送时间", "发送状态", "消息类型", "isSend", "msgId"]
columns = ['localId', 'TalkerId', 'Type', 'SubType',
'IsSender', 'CreateTime', 'Status', 'StrContent',
'StrTime', 'Remark', 'NickName', 'Sender']
packagemsg = PackageMsg()
messages = packagemsg.get_package_message_all()
# 写入CSV文件
with open(filename, mode='w', newline='', encoding='utf-8-sig') as file:
writer = csv.writer(file)
writer.writerow(columns)
# 写入数据
writer.writerows(messages)
self.okSignal.emit(1)
def contact_to_csv(self):
"""
导出联系人到CSV
@return:
"""
filename = QFileDialog.getSaveFileName(None, "save file", os.path.join(os.getcwd(), 'contacts.csv'),
"csv files (*.csv);;all files(*.*)")
if not filename[0]:
return
self.startSignal.emit(1)
filename = filename[0]
# columns = ["用户名", "消息内容", "发送时间", "发送状态", "消息类型", "isSend", "msgId"]
columns = ['UserName', 'Alias', 'Type', 'Remark', 'NickName', 'PYInitial', 'RemarkPYInitial', 'smallHeadImgUrl',
'bigHeadImgUrl', 'label', 'gender', 'telephone', 'signature', 'country/region', 'province', 'city']
contacts = micro_msg_db.get_contact()
# 写入CSV文件
with open(filename, mode='w', newline='', encoding='utf-8-sig') as file:
writer = csv.writer(file)
writer.writerow(columns)
# 写入数据
# writer.writerows(contacts)
for contact in contacts:
detail = decodeExtraBuf(contact[9])
gender_code = detail.get('gender')
if gender_code == 0:
gender = '未知'
elif gender_code == 1:
gender = ''
else:
gender = ''
writer.writerow([*contact[:9], contact[10], gender, detail.get('telephone'), detail.get('signature'),
*detail.get('region')])
self.okSignal.emit(1)
def batch_export(self):
print('开始批量导出')
print(self.sub_type, self.message_types)
print(len(self.contact))
print([contact.remark for contact in self.contact])
self.batch_num_total = len(self.contact) * len(self.sub_type)
self.batch_num = 0
self.rangeSignal.emit(self.batch_num_total)
for contact in self.contact:
# print('联系人', contact.remark)
for type_ in self.sub_type:
# print('导出类型', type_)
if type_ == self.DOCX:
self.to_docx(contact, self.message_types, True)
elif type_ == self.TXT:
# print('批量导出txt')
self.to_txt(contact, self.message_types, True)
elif type_ == self.CSV:
self.to_csv(contact, self.message_types, True)
elif type_ == self.HTML:
self.to_html(contact, self.message_types, True)
def batch_finish_one(self, num):
self.nowContact.emit(self.contact[self.batch_num // len(self.sub_type)].remark)
self.batch_num += 1
if self.batch_num == self.batch_num_total:
self.okSignal.emit(1)
def merge_docx(self, n):
conRemark = self.contact.remark
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{conRemark}"
filename = f"{origin_docx_path}/{conRemark}_{n}.docx"
if n == 10086:
# self.document.append(self.document)
file = os.path.join(origin_docx_path, f'{conRemark}.docx')
try:
self.document.save(file)
except PermissionError:
file = file[:-5] + f'{time.time()}' + '.docx'
self.document.save(file)
self.okSignal.emit(1)
return
doc = docx.Document(filename)
self.document.append(doc)
os.remove(filename)
if n % 50 == 0:
# self.document.append(self.document)
file = os.path.join(origin_docx_path, f'{conRemark}-{n//50}.docx')
try:
self.document.save(file)
except PermissionError:
file = file[:-5] + f'{time.time()}' + '.docx'
self.document.save(file)
doc = docx.Document()
doc.styles["Normal"].font.name = "Cambria"
doc.styles["Normal"]._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
self.document = Composer(doc)
def to_docx(self, contact, message_types, is_batch=False):
doc = docx.Document()
doc.styles["Normal"].font.name = "Cambria"
doc.styles["Normal"]._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
self.document = Composer(doc)
Child = DocxExporter(contact, type_=self.DOCX, message_types=message_types, time_range=self.time_range)
self.children.append(Child)
Child.progressSignal.connect(self.progress)
if not is_batch:
Child.rangeSignal.connect(self.rangeSignal)
Child.okSignal.connect(self.merge_docx if not is_batch else self.batch_finish_one)
Child.start()
def to_txt(self, contact, message_types, is_batch=False):
Child = TxtExporter(contact, type_=self.TXT, message_types=message_types, time_range=self.time_range)
self.children.append(Child)
Child.progressSignal.connect(self.progress)
if not is_batch:
Child.rangeSignal.connect(self.rangeSignal)
Child.okSignal.connect(self.okSignal if not is_batch else self.batch_finish_one)
Child.start()
def to_html(self, contact, message_types, is_batch=False):
Child = HtmlExporter(contact, type_=self.output_type, message_types=message_types, time_range=self.time_range)
self.children.append(Child)
Child.progressSignal.connect(self.progress)
if not is_batch:
Child.rangeSignal.connect(self.rangeSignal)
Child.okSignal.connect(self.count_finish_num)
Child.start()
self.total_num = 1
if message_types.get(34):
# 语音消息单独的线程
self.total_num += 1
output_media = OutputMedia(contact, time_range=self.time_range)
self.children.append(output_media)
output_media.okSingal.connect(self.count_finish_num)
output_media.progressSignal.connect(self.progressSignal)
output_media.start()
if message_types.get(47):
# emoji消息单独的线程
self.total_num += 1
output_emoji = OutputEmoji(contact, time_range=self.time_range)
self.children.append(output_emoji)
output_emoji.okSingal.connect(self.count_finish_num)
output_emoji.progressSignal.connect(self.progressSignal)
output_emoji.start()
if message_types.get(3):
# 图片消息单独的线程
self.total_num += 1
output_image = OutputImage(contact, time_range=self.time_range)
self.children.append(output_image)
output_image.okSingal.connect(self.count_finish_num)
output_image.progressSignal.connect(self.progressSignal)
output_image.start()
def to_csv(self, contact, message_types, is_batch=False):
Child = CSVExporter(contact, type_=self.CSV, message_types=message_types, time_range=self.time_range)
self.children.append(Child)
Child.progressSignal.connect(self.progress)
if not is_batch:
Child.rangeSignal.connect(self.rangeSignal)
Child.okSignal.connect(self.okSignal if not is_batch else self.batch_finish_one)
Child.start()
def run(self):
self.export()
if self.output_type == self.DOCX:
self.to_docx(self.contact, self.message_types)
elif self.output_type == self.CSV_ALL:
self.to_csv_all()
elif self.output_type == self.CONTACT_CSV:
self.contact_to_csv()
elif self.output_type == self.TXT:
self.to_txt(self.contact, self.message_types)
elif self.output_type == self.CSV:
self.to_csv(self.contact, self.message_types)
elif self.output_type == self.HTML:
self.to_html(self.contact, self.message_types)
elif self.output_type == self.Batch:
self.batch_export()
def export(self):
raise NotImplementedError("export method must be implemented in subclasses")
def count_finish_num(self, num):
"""
记录子线程完成个数
@param num:
@return:
"""
self.num += 1
if self.num == self.total_num:
# 所有子线程都完成之后就发送完成信号
if self.output_type == self.Batch:
self.batch_finish_one(1)
else:
self.okSignal.emit(1)
self.num = 0
def cancel(self):
self.requestInterruption()
def is_5_min(self, timestamp) -> bool:
if abs(timestamp - self.last_timestamp) > 300:
self.last_timestamp = timestamp
return True
return False
def get_avatar_path(self, is_send, message, is_absolute_path=False) -> str:
if is_absolute_path:
if self.contact.is_chatroom:
avatar = message[13].avatar_path
else:
avatar = Me().avatar_path if is_send else self.contact.avatar_path
else:
if self.contact.is_chatroom:
avatar = message[13].smallHeadImgUrl
else:
avatar = Me().smallHeadImgUrl if is_send else self.contact.smallHeadImgUrl
return avatar
class OutputMedia(QThread):
"""
导出语音消息
"""
okSingal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
def get_display_name(self, is_send, message) -> str:
if self.contact.is_chatroom:
if is_send:
display_name = Me().name
else:
display_name = message[13].remark
else:
display_name = Me().name if is_send else self.contact.remark
return escape_js_and_html(display_name)
def __init__(self, contact, time_range=None):
super().__init__()
self.contact = contact
self.time_range = time_range
def text(self, doc, message):
return
def run(self):
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
messages = msg_db.get_messages_by_type(self.contact.wxid, 34, time_range=self.time_range)
for message in messages:
is_send = message[4]
msgSvrId = message[9]
try:
audio_path = media_msg_db.get_audio(msgSvrId, output_path=origin_docx_path + "/voice")
except:
logger.error(traceback.format_exc())
finally:
self.progressSignal.emit(1)
self.okSingal.emit(34)
def image(self, doc, message):
return
def audio(self, doc, message):
return
class OutputEmoji(QThread):
"""
导出表情包
"""
okSingal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
def emoji(self, doc, message):
return
def __init__(self, contact, time_range=None):
super().__init__()
self.contact = contact
self.time_range = time_range
def file(self, doc, message):
return
def run(self):
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
messages = msg_db.get_messages_by_type(self.contact.wxid, 47, time_range=self.time_range)
for message in messages:
str_content = message[7]
try:
pass
# emoji_path = get_emoji(str_content, thumb=True, output_path=origin_docx_path + '/emoji')
except:
logger.error(traceback.format_exc())
finally:
self.progressSignal.emit(1)
self.okSingal.emit(47)
def refermsg(self, doc, message):
return
def system_msg(self, doc, message):
return
class OutputImage(QThread):
"""
导出图片
"""
okSingal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
def video(self, doc, message):
return
def __init__(self, contact, time_range):
super().__init__()
self.contact = contact
self.child_thread_num = 2
self.time_range = time_range
self.child_threads = [0] * (self.child_thread_num + 1)
self.num = 0
def music_share(self, doc, message):
return
def count1(self, num):
self.num += 1
print('图片导出完成一个')
if self.num == self.child_thread_num:
self.okSingal.emit(47)
print('图片导出完成')
def share_card(self, doc, message):
return
def run(self):
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
messages = msg_db.get_messages_by_type(self.contact.wxid, 3, time_range=self.time_range)
for message in messages:
str_content = message[7]
BytesExtra = message[10]
timestamp = message[5]
try:
image_path = hard_link_db.get_image(str_content, BytesExtra, thumb=False)
if not os.path.exists(os.path.join(Me().wx_dir, image_path)):
image_thumb_path = hard_link_db.get_image(str_content, BytesExtra, thumb=True)
if not os.path.exists(os.path.join(Me().wx_dir, image_thumb_path)):
continue
image_path = image_thumb_path
image_path = get_image(image_path, base_path=f'/data/聊天记录/{self.contact.remark}/image')
try:
os.utime(origin_docx_path + image_path[1:], (timestamp, timestamp))
except:
pass
except:
logger.error(traceback.format_exc())
finally:
self.progressSignal.emit(1)
self.okSingal.emit(47)
class OutputImageChild(QThread):
okSingal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
def __init__(self, contact, messages, time_range):
super().__init__()
self.contact = contact
self.messages = messages
self.time_range = time_range
def run(self):
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
for message in self.messages:
str_content = message[7]
BytesExtra = message[10]
timestamp = message[5]
try:
image_path = hard_link_db.get_image(str_content, BytesExtra, thumb=False)
if not os.path.exists(os.path.join(Me().wx_dir, image_path)):
image_thumb_path = hard_link_db.get_image(str_content, BytesExtra, thumb=True)
if not os.path.exists(os.path.join(Me().wx_dir, image_thumb_path)):
continue
image_path = image_thumb_path
image_path = get_image(image_path, base_path=f'/data/聊天记录/{self.contact.remark}/image')
try:
os.utime(origin_docx_path + image_path[1:], (timestamp, timestamp))
except:
pass
except:
logger.error(traceback.format_exc())
finally:
self.progressSignal.emit(1)
self.okSingal.emit(47)
print('图片子线程完成')
if __name__ == "__main__":
pass

View File

@ -1,437 +0,0 @@
import csv
import os
import time
import traceback
from typing import List
import docx
from PyQt5.QtCore import pyqtSignal, QThread, QObject
from PyQt5.QtWidgets import QFileDialog
from docx.oxml.ns import qn
from docxcompose.composer import Composer
from app.DataBase.exporter_csv import CSVExporter
from app.DataBase.exporter_docx import DocxExporter
from app.DataBase.exporter_html import HtmlExporter
from app.DataBase.exporter_txt import TxtExporter
from app.DataBase.hard_link import decodeExtraBuf
from .package_msg import PackageMsg
from ..DataBase import media_msg_db, hard_link_db, micro_msg_db, msg_db
from ..log import logger
from ..person import Me
from ..util.image import get_image
os.makedirs('./data/聊天记录', exist_ok=True)
class Output(QThread):
"""
发送信息线程
"""
startSignal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
rangeSignal = pyqtSignal(int)
okSignal = pyqtSignal(int)
batchOkSignal = pyqtSignal(int)
nowContact = pyqtSignal(str)
i = 1
CSV = 0
DOCX = 1
HTML = 2
CSV_ALL = 3
CONTACT_CSV = 4
TXT = 5
Batch = 10086
def __init__(self, contact, type_=DOCX, message_types={}, sub_type=[], time_range=None, parent=None):
super().__init__(parent)
self.children = []
self.last_timestamp = 0
self.sub_type = sub_type
self.time_range = time_range
self.message_types = message_types
self.sec = 2 # 默认1000秒
self.contact = contact
self.msg_id = 0
self.output_type: int | List[int] = type_
self.total_num = 1
self.num = 0
def progress(self, value):
self.progressSignal.emit(value)
def output_image(self):
"""
导出全部图片
@return:
"""
return
def output_emoji(self):
"""
导出全部表情包
@return:
"""
return
def to_csv_all(self):
"""
导出全部聊天记录到CSV
@return:
"""
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/"
os.makedirs(origin_docx_path, exist_ok=True)
filename = QFileDialog.getSaveFileName(None, "save file", os.path.join(os.getcwd(), 'messages.csv'),
"csv files (*.csv);;all files(*.*)")
if not filename[0]:
return
self.startSignal.emit(1)
filename = filename[0]
# columns = ["用户名", "消息内容", "发送时间", "发送状态", "消息类型", "isSend", "msgId"]
columns = ['localId', 'TalkerId', 'Type', 'SubType',
'IsSender', 'CreateTime', 'Status', 'StrContent',
'StrTime', 'Remark', 'NickName', 'Sender']
packagemsg = PackageMsg()
messages = packagemsg.get_package_message_all()
# 写入CSV文件
with open(filename, mode='w', newline='', encoding='utf-8-sig') as file:
writer = csv.writer(file)
writer.writerow(columns)
# 写入数据
writer.writerows(messages)
self.okSignal.emit(1)
def contact_to_csv(self):
"""
导出联系人到CSV
@return:
"""
filename = QFileDialog.getSaveFileName(None, "save file", os.path.join(os.getcwd(), 'contacts.csv'),
"csv files (*.csv);;all files(*.*)")
if not filename[0]:
return
self.startSignal.emit(1)
filename = filename[0]
# columns = ["用户名", "消息内容", "发送时间", "发送状态", "消息类型", "isSend", "msgId"]
columns = ['UserName', 'Alias', 'Type', 'Remark', 'NickName', 'PYInitial', 'RemarkPYInitial', 'smallHeadImgUrl',
'bigHeadImgUrl', 'label', 'gender', 'telephone', 'signature', 'country/region', 'province', 'city']
contacts = micro_msg_db.get_contact()
# 写入CSV文件
with open(filename, mode='w', newline='', encoding='utf-8-sig') as file:
writer = csv.writer(file)
writer.writerow(columns)
# 写入数据
# writer.writerows(contacts)
for contact in contacts:
detail = decodeExtraBuf(contact[9])
gender_code = detail.get('gender')
if gender_code == 0:
gender = '未知'
elif gender_code == 1:
gender = ''
else:
gender = ''
writer.writerow([*contact[:9], contact[10], gender, detail.get('telephone'), detail.get('signature'),
*detail.get('region')])
self.okSignal.emit(1)
def batch_export(self):
print('开始批量导出')
print(self.sub_type, self.message_types)
print(len(self.contact))
print([contact.remark for contact in self.contact])
self.batch_num_total = len(self.contact) * len(self.sub_type)
self.batch_num = 0
self.rangeSignal.emit(self.batch_num_total)
for contact in self.contact:
# print('联系人', contact.remark)
for type_ in self.sub_type:
# print('导出类型', type_)
if type_ == self.DOCX:
self.to_docx(contact, self.message_types, True)
elif type_ == self.TXT:
# print('批量导出txt')
self.to_txt(contact, self.message_types, True)
elif type_ == self.CSV:
self.to_csv(contact, self.message_types, True)
elif type_ == self.HTML:
self.to_html(contact, self.message_types, True)
def batch_finish_one(self, num):
self.nowContact.emit(self.contact[self.batch_num // len(self.sub_type)].remark)
self.batch_num += 1
if self.batch_num == self.batch_num_total:
self.okSignal.emit(1)
def merge_docx(self, n):
conRemark = self.contact.remark
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{conRemark}"
filename = f"{origin_docx_path}/{conRemark}_{n}.docx"
if n == 10086:
# self.document.append(self.document)
file = os.path.join(origin_docx_path, f'{conRemark}.docx')
try:
self.document.save(file)
except PermissionError:
file = file[:-5] + f'{time.time()}' + '.docx'
self.document.save(file)
self.okSignal.emit(1)
return
doc = docx.Document(filename)
self.document.append(doc)
os.remove(filename)
if n % 50 == 0:
# self.document.append(self.document)
file = os.path.join(origin_docx_path, f'{conRemark}-{n//50}.docx')
try:
self.document.save(file)
except PermissionError:
file = file[:-5] + f'{time.time()}' + '.docx'
self.document.save(file)
doc = docx.Document()
doc.styles["Normal"].font.name = "Cambria"
doc.styles["Normal"]._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
self.document = Composer(doc)
def to_docx(self, contact, message_types, is_batch=False):
doc = docx.Document()
doc.styles["Normal"].font.name = "Cambria"
doc.styles["Normal"]._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
self.document = Composer(doc)
Child = DocxExporter(contact, type_=self.DOCX, message_types=message_types, time_range=self.time_range)
self.children.append(Child)
Child.progressSignal.connect(self.progress)
if not is_batch:
Child.rangeSignal.connect(self.rangeSignal)
Child.okSignal.connect(self.merge_docx if not is_batch else self.batch_finish_one)
Child.start()
def to_txt(self, contact, message_types, is_batch=False):
Child = TxtExporter(contact, type_=self.TXT, message_types=message_types, time_range=self.time_range)
self.children.append(Child)
Child.progressSignal.connect(self.progress)
if not is_batch:
Child.rangeSignal.connect(self.rangeSignal)
Child.okSignal.connect(self.okSignal if not is_batch else self.batch_finish_one)
Child.start()
def to_html(self, contact, message_types, is_batch=False):
Child = HtmlExporter(contact, type_=self.output_type, message_types=message_types, time_range=self.time_range)
self.children.append(Child)
Child.progressSignal.connect(self.progress)
if not is_batch:
Child.rangeSignal.connect(self.rangeSignal)
Child.okSignal.connect(self.count_finish_num)
Child.start()
self.total_num = 1
if message_types.get(34):
# 语音消息单独的线程
self.total_num += 1
output_media = OutputMedia(contact, time_range=self.time_range)
self.children.append(output_media)
output_media.okSingal.connect(self.count_finish_num)
output_media.progressSignal.connect(self.progressSignal)
output_media.start()
if message_types.get(47):
# emoji消息单独的线程
self.total_num += 1
output_emoji = OutputEmoji(contact, time_range=self.time_range)
self.children.append(output_emoji)
output_emoji.okSingal.connect(self.count_finish_num)
output_emoji.progressSignal.connect(self.progressSignal)
output_emoji.start()
if message_types.get(3):
# 图片消息单独的线程
self.total_num += 1
output_image = OutputImage(contact, time_range=self.time_range)
self.children.append(output_image)
output_image.okSingal.connect(self.count_finish_num)
output_image.progressSignal.connect(self.progressSignal)
output_image.start()
def to_csv(self, contact, message_types, is_batch=False):
Child = CSVExporter(contact, type_=self.CSV, message_types=message_types, time_range=self.time_range)
self.children.append(Child)
Child.progressSignal.connect(self.progress)
if not is_batch:
Child.rangeSignal.connect(self.rangeSignal)
Child.okSignal.connect(self.okSignal if not is_batch else self.batch_finish_one)
Child.start()
def run(self):
if self.output_type == self.DOCX:
self.to_docx(self.contact, self.message_types)
elif self.output_type == self.CSV_ALL:
self.to_csv_all()
elif self.output_type == self.CONTACT_CSV:
self.contact_to_csv()
elif self.output_type == self.TXT:
self.to_txt(self.contact, self.message_types)
elif self.output_type == self.CSV:
self.to_csv(self.contact, self.message_types)
elif self.output_type == self.HTML:
self.to_html(self.contact, self.message_types)
elif self.output_type == self.Batch:
self.batch_export()
def count_finish_num(self, num):
"""
记录子线程完成个数
@param num:
@return:
"""
self.num += 1
if self.num == self.total_num:
# 所有子线程都完成之后就发送完成信号
if self.output_type == self.Batch:
self.batch_finish_one(1)
else:
self.okSignal.emit(1)
self.num = 0
def cancel(self):
self.requestInterruption()
class OutputMedia(QThread):
"""
导出语音消息
"""
okSingal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
def __init__(self, contact, time_range=None):
super().__init__()
self.contact = contact
self.time_range = time_range
def run(self):
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
messages = msg_db.get_messages_by_type(self.contact.wxid, 34, time_range=self.time_range)
for message in messages:
is_send = message[4]
msgSvrId = message[9]
try:
audio_path = media_msg_db.get_audio(msgSvrId, output_path=origin_docx_path + "/voice")
except:
logger.error(traceback.format_exc())
finally:
self.progressSignal.emit(1)
self.okSingal.emit(34)
class OutputEmoji(QThread):
"""
导出表情包
"""
okSingal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
def __init__(self, contact, time_range=None):
super().__init__()
self.contact = contact
self.time_range = time_range
def run(self):
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
messages = msg_db.get_messages_by_type(self.contact.wxid, 47, time_range=self.time_range)
for message in messages:
str_content = message[7]
try:
pass
# emoji_path = get_emoji(str_content, thumb=True, output_path=origin_docx_path + '/emoji')
except:
logger.error(traceback.format_exc())
finally:
self.progressSignal.emit(1)
self.okSingal.emit(47)
class OutputImage(QThread):
"""
导出图片
"""
okSingal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
def __init__(self, contact, time_range):
super().__init__()
self.contact = contact
self.child_thread_num = 2
self.time_range = time_range
self.child_threads = [0] * (self.child_thread_num + 1)
self.num = 0
def count1(self, num):
self.num += 1
print('图片导出完成一个')
if self.num == self.child_thread_num:
self.okSingal.emit(47)
print('图片导出完成')
def run(self):
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
messages = msg_db.get_messages_by_type(self.contact.wxid, 3, time_range=self.time_range)
for message in messages:
str_content = message[7]
BytesExtra = message[10]
timestamp = message[5]
try:
image_path = hard_link_db.get_image(str_content, BytesExtra, thumb=False)
if not os.path.exists(os.path.join(Me().wx_dir, image_path)):
image_thumb_path = hard_link_db.get_image(str_content, BytesExtra, thumb=True)
if not os.path.exists(os.path.join(Me().wx_dir, image_thumb_path)):
continue
image_path = image_thumb_path
image_path = get_image(image_path, base_path=f'/data/聊天记录/{self.contact.remark}/image')
try:
os.utime(origin_docx_path + image_path[1:], (timestamp, timestamp))
except:
pass
except:
logger.error(traceback.format_exc())
finally:
self.progressSignal.emit(1)
self.okSingal.emit(47)
class OutputImageChild(QThread):
okSingal = pyqtSignal(int)
progressSignal = pyqtSignal(int)
def __init__(self, contact, messages, time_range):
super().__init__()
self.contact = contact
self.messages = messages
self.time_range = time_range
def run(self):
origin_docx_path = f"{os.path.abspath('.')}/data/聊天记录/{self.contact.remark}"
for message in self.messages:
str_content = message[7]
BytesExtra = message[10]
timestamp = message[5]
try:
image_path = hard_link_db.get_image(str_content, BytesExtra, thumb=False)
if not os.path.exists(os.path.join(Me().wx_dir, image_path)):
image_thumb_path = hard_link_db.get_image(str_content, BytesExtra, thumb=True)
if not os.path.exists(os.path.join(Me().wx_dir, image_thumb_path)):
continue
image_path = image_thumb_path
image_path = get_image(image_path, base_path=f'/data/聊天记录/{self.contact.remark}/image')
try:
os.utime(origin_docx_path + image_path[1:], (timestamp, timestamp))
except:
pass
except:
logger.error(traceback.format_exc())
finally:
self.progressSignal.emit(1)
self.okSingal.emit(47)
print('图片子线程完成')
if __name__ == "__main__":
pass

View File

@ -11,7 +11,7 @@ from app.DataBase import msg_db
from app.components import ScrollBar
from app.ui.menu.export_time_range import TimeRangeDialog
from .exportUi import Ui_Dialog
from app.DataBase.output_pc import Output
from app.DataBase.output import Output
types = {
'文本': 1,

View File

@ -23,7 +23,7 @@ from .chat import ChatWindow
from .contact import ContactWindow
from app.ui.tool.tool_window import ToolWindow
from .menu.export import ExportDialog
from ..DataBase.output_pc import Output
from ..DataBase.output import Output
from ..components.QCursorGif import QCursorGif
from ..log import logger
from ..person import Me

View File

@ -9,7 +9,7 @@ from PyQt5.QtGui import QTextCursor
from PyQt5.QtWidgets import QApplication, QDialog, QCheckBox, QMessageBox
from app.DataBase import micro_msg_db, misc_db
from app.DataBase.output_pc import Output
from app.DataBase.output import Output
from app.components import ScrollBar
from app.components.export_contact_item import ContactQListWidgetItem
from app.person import Contact

84
doc/开发者手册.md Normal file
View File

@ -0,0 +1,84 @@
## 源码运行
运行前请确保您的电脑上已经安装了Git、版本不低于3.10的Python、部分第三方库需要用到MSVC,需要提前安装Windows构建工具
### 1. 安装
```shell
# Python>=3.10 仅支持3.10、3.11、3.12,请勿使用其他Python版本
git clone https://github.com/LC044/WeChatMsg
# 网络不好推荐用Gitee
# git clone https://gitee.com/lc044/WeChatMsg.git
cd WeChatMsg
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
```
### 2. 使用
1. 登录微信
手机端使用聊天记录迁移功能将聊天数据迁移到电脑上(可选)
操作步骤:
- 安卓: 手机微信->我->设置->聊天->聊天记录迁移与备份->迁移-> 迁移到电脑微信(迁移完成后重启微信)[否则](https://github.com/LC044/WeChatMsg/issues/27)
- iOS 手机微信->我->设置->通用->聊天记录迁移与备份->迁移-> 迁移到电脑微信(迁移完成后重启微信)[否则](https://github.com/LC044/WeChatMsg/issues/27)
2. 运行程序
```shell
python main.py
```
3. 点击获取信息
![](./images/pc_decrypt_info.png)
4. 设置微信安装路径(如果自动设置好了就**不用管**了)
可以到微信->设置->文件管理查看
![](./images/setting.png)
点击**设置微信路径**按钮选择该文件夹路径下的带有wxid_xxx的路径(没有wxid的话先选择其中一个文件夹不对的话换其他文件夹)
![](./images/path_select.png)
5. 获取到key和微信路径之后点击开始启动
6. 数据库文件保存在./app/DataBase/Msg路径下
### 3. 查看
随便下载一个SQLite数据库查看软件就能打开数据库例如[DB Browser for SQLite](https://sqlitebrowser.org/dl/)
* [数据库功能介绍](./数据库介绍.md)
## 仓库目录功能介绍
```text
├─app
│ ├─analysis # 聊天数据分析、画图的实现
│ ├─components # PyQt写的一些自定义UI组件
│ ├─data # 存储程序用到的必要数据文件
│ ├─DataBase # 有关数据库的操作和聊天记录导出
│ ├─decrypt # 数据库解密
│ ├─log # 日志存储
│ ├─resources # 必要的资源文件
│ ├─ui # ui界面实现
│ │ ├─chat # 聊天界面
│ │ ├─contact # 联系人界面
│ │ │ ├─export # 联系人聊天记录导出
│ │ │ └─userinfo # 联系人详细信息
│ │ ├─menu # 菜单栏功能的实现
│ │ ├─QSS # 样式表
│ │ └─tool # 工具界面
│ │ ├─get_bias_addr # 获取微信基址
│ │ ├─pc_decrypt # 数据库解密
│ │ └─setting # 设置
│ ├─util # 用到的一些通用工具(聊天数据解析、音视频处理)
│ │ └─protocbuf
│ └─web_ui # 年度报告等网页的实现flask
│ └─templates # HTML模板
├─doc # 文档
└─resource # pyecharts资源文件供打包使用
```

View File

@ -1,4 +1,250 @@
# 微信数据库介绍
**这个人比较懒,还什么都没写**
**文章来源:[https://github.com/xaoyaoo/PyWxDump/blob/master/doc/wx%E6%95%B0%E6%8D%AE%E5%BA%93%E7%AE%80%E8%BF%B0.md](https://github.com/xaoyaoo/PyWxDump/blob/master/doc/wx%E6%95%B0%E6%8D%AE%E5%BA%93%E7%AE%80%E8%BF%B0.md)**
# 微信PC端各个数据库简述
* 说明:针对 .../WeChat Files/wxid_xxxxxxxx/Msg下的各个文件解密后的内容进行概述
* 未作特别说明的情况下“聊天记录数据”指代的数据结构上都和Multi文件夹中的完整聊天记录数据相同或类似。
* 本文档仅供学习交流使用,严禁用于商业用途及非法用途,否则后果自负
## 一、微信小程序相关
微信小程序的相关数据,包括但不限于:
* 你使用过的小程序 RecentWxApp
* 星标的小程序 StarWxApp
* 各个小程序的基本信息 WAContact
用处不大不过可以看到你使用过的小程序的名称和图标以及小程序的AppID
## 二、企业微信相关
### BizChat
企业微信联系人数据,包括但不限于:
* 在微信中可以访问的企业微信会话ChatInfo
* 一部分会话的信息ChatSession未确认与ChatInfo的关系这其中的Content字段是最近一条消息疑似用于缓存展示的内容
* 包括群聊在内的聊天涉及的所有企业微信用户身份信息UsrInfo
* 该微信账号绑定的企业微信身份MyUsrInfo
* 特别说明:未经详细查证,这其中的聊天是否包含使用普通微信身份与企业微信用户发起的聊天,还是只包含使用绑定到普通微信的企业微信身份与其它企业微信身份发起的聊天。
### BizChatMsg
* 企业微信聊天记录数据,包括所有和企业微信聊天的数据。
* 与BizChat一样未确定涉及的范围究竟是只有企业微信-企业微信还是同时包含普通微信-企业微信。
* 另外此处的消息与Multi文件夹中真正的微信消息不同的是在于没有拆分数据库。
### OpenIM 前缀
* 这个也是企业微信的数据,包括联系人、企业信息、与企业微信联系人的消息等。
* 这个是普通微信-企业微信的数据上面biz前缀的是企业微信-企业微信
* 这个不常用,而且也没有全新的数据结构,不再详细说了。
### PublicMsg
* 看起来像是企业微信的通知消息,可以理解为企业微信的企业应用消息
## 三、微信功能相关
### Emotion
顾名思义表情包相关,包括但不限于以下内容:
* CustomEmotion顾名思义用户手动上传的GIF表情包含下载链接不过看起来似乎有加密内有aesKey字段但我没测试
* EmotionDes1 和 EmotionItem 应该也是类似的内容,没仔细研究
* EmotionPackageItem账号添加的表情包的集合列表从商店下载的那种
ps用处不大微信的MSG文件夹有表情包的url链接可以直接网络获取聊天记录中的表情包。
### Favorite
* FavItems收藏的消息条目列表
* FavDataItem收藏的具体数据。没有自习去看他的存储逻辑不过大概可以确定以下两点
* 即使只是简单收藏一篇公众号文章也会在 FavDataItem 中有一个对应的记录
* 对于收藏的合并转发类型的消息,合并转发中的每一条消息在 FavDataItem 中都是一个独立的记录
* FavTags为收藏内容添加的标签
### Misc
* 有BizContactHeadImg和ContactHeadImg1两张表应该是二进制格式的各个头像
### Sns
微信朋友圈的相关数据:
* FeedsV20朋友圈的XML数据
* CommentV20朋友圈点赞或评论记录
* NotificationV7朋友圈通知
* SnsConfigV20一些配置信息能读懂的是其中有你的朋友圈背景图
* SnsGroupInfoV5猜测是旧版微信朋友圈可见范围的可见或不可见名单
### FTS搜索
* 前缀为 FTS 的数据库可能都和全文搜索Full-Text Search相关(就是微信那个搜索框)
### FTSContact
有一堆表
* FTSChatroom15_content 和 FTSContact15_content
分别对应的是微信“聊天”界面会展示的消息会话(包括公众号等)和“联系人”界面会出现的所有人(有的时候并不是所有联系人都会出现在“聊天”中),信息包含昵称、备注名和微信号,也和微信支持搜索的字段相匹配。
### FTSFavorite
搜索收藏内容的索引
* 命名方式类似上面一条
ps对于收藏内容通过文字搜索电脑版是把所有东西拼接成一个超长字符串来实现的。这对于文本、链接等没啥问题但是对于合并转发消息就会出现搜索\[图片]
这一关键词。
### MultiSearchChatMsg
* 这个数据库前缀不一样,但是看内容和结构应该还是一个搜索相关,搜索的是聊天记录中的文件
* 存储了文件名和其所在的聊天
* 不过FTSMsgSearch18_content和SessionAttachInfo两张表记录数量有显著差异不确定是哪个少了或是怎样。
### HardLink文件在磁盘存储的位置
* 将文件/图片/视频的文件名指向保存它们的文件夹名称例如2023-04有用但不多。
### Media
* ChatCRVoice和MediaInfo 可能为语音信息
## 三、MicroMsg (联系人核心)
一个数据库,不应该和分类平级,但是我认为这是分析到目前以来最核心的,因此单独来说了。
### AppInfo
一些软件的介绍猜测可能是关于某些直接从手机APP跳转到微信的转发会带有的转发来源小尾巴的信息
### Biz 前缀
与公众号相关的内容,应该主要是账号本身相关。
能确定的是 BizSessionNewFeeds 这张表保存的是订阅号大分类底下的会话信息,包括头像、最近一条推送等。
### ChatInfo
保存“聊天”列表中每个会话最后一次标记已读的时间
### ChatRoom 和 ChatRoomInfo
存储群聊相关信息
* ChatRoom存储每个群聊的用户列表包括微信号列表和群昵称列表和个人群昵称等信息
* ChatRoomInfo群聊相关信息主要是群公告内容与成员无关
顺便再吐槽一下微信这个位置有一个命名出现异常的别的表前缀都是ChatRoom而突然出现一个ChatroomTool
### Contact
顾名思义,联系人。不过这里的联系人并不是指你的好友,而是所有你可能看见的人,除好友外还有所有群聊中的所有陌生人。
* Contact这张表存储的是用户基本信息包括但不限于微信号没有好友的陌生人也能看、昵称、备注名、设置的标签等等甚至还有生成的各种字段的拼音可能是用于方便搜索的吧
* ContactHeadImgUrl头像地址
* ContactLabel好友标签 ID 与名称对照
* ExtraBuf: 存储位置信息、手机号、邮箱等信息
### PatInfo
存了一部分好友的拍一拍后缀,但是只有几个,我记得我电脑上显示过的拍一拍似乎没有这么少?
### Session
真正的“聊天”栏目显示的会话列表,一个不多一个不少,包括“折叠的群聊”这样子的特殊会话;信息包括名称、未读消息数、最近一条消息等
### TicketInfo
这张表在我这里有百余条数据,但是我实在没搞明白它是什么
## 四、FTSMSG
FTS 这一前缀了——这代表的是搜索时所需的索引。
其内主要的内容是这样的两张表:
* FTSChatMsg2_content内有三个字段
* docid从1开始递增的数字相当于当前条目的 ID
* c0content搜索关键字在微信搜索框输入的关键字被这个字段包含的内容可以被搜索到
* c1entityId尚不明确用途可能是校验相关
* FTSChatMsg2_MetaData
* docid与FTSChatMsg2_content表中的 docid 对应
* msgId与MSG数据库中的内容对应
* entityId与FTSChatMsg2_content表中的 c1entityId 对应
* type可能是该消息的类型
* 其余字段尚不明确
特别地表名中的这个数字2个人猜测可能是当前数据库格式的版本号。
## 五、MediaMSG (语音消息)
这里存储了所有的语音消息。数据库中有且仅有Media一张表内含三个有效字段
* Key
* Reserved0 与MSG数据库中消息的MsgSvrID一一对应
* Buf silk格式的语音数据
## 六、MSG聊天记录核心数据库
内部主要的两个表是`MSG`和`Name2ID`
### Name2ID
* `Name2ID`这张表只有一列内容格式是微信号或群聊ID@chatroom
* 作用是使MSG中的某些字段与之对应。虽然表中没有 ID 这一列,但事实上微信默认了第几行 ID 就是几从1开始编号
### MSG
* localId字面意思消息在本地的 ID暂未发现其功用
* TalkerId消息所在房间的 ID该信息为猜测猜测原因见 StrTalker 字段与Name2ID对应。
* MsgSvrID猜测 Srv 可能是 Server 的缩写,代指服务器端存储的消息 ID
* Type消息类型具体对照见表1
* SubType消息类型子分类暂时未见其实际用途
* IsSender是否是自己发出的消息也就是标记消息展示在对话页左边还是右边取值0或1
* CreateTime消息创建时间的秒级时间戳。此处需要进一步实验来确认该时间具体标记的是哪个时间节点个人猜测的规则如下
* 从这台电脑上发出的消息:标记代表的是每个消息点下发送按钮的那一刻
* 从其它设备上发出的/收到的来自其它用户的消息:标记的是本地从服务器接收到这一消息的时间
* Sequence次序虽然看起来像一个毫秒级时间戳但其实不是。这是`CreateTime`
字段末尾接上三位数字组成的通常情况下为000如果在出现两条`CreateTime`
相同的消息则最后三位依次递增。需要进一步确认不重复范围是在一个会话内还是所有会话。`CreateTime`
相同的消息则最后三位依次递增。需要进一步确认不重复范围是在一个会话内还是所有会话。
* StatusEx、FlagEx、Status、MsgServerSeq、MsgSequence这五个字段个人暂时没有分析出有效信息
* StrTalker消息发送者的微信号。特别说明从这里来看的话上面的`TalkerId`
字段大概率是指的消息所在的房间ID而非发送者ID当然也可能和`TalkerId`属于重复内容,这一点待确认。
* StrContent字符串格式的数据。特别说明的是除了文本类型的消息外别的大多类型这一字段都会是一段 XML
数据标记一些相关信息。通过解析xml可以得到更多的信息例如图片的宽高、语音的时长等等。
* DisplayContent对于拍一拍保存拍者和被拍者账号信息
* Reserved0~6这些字段也还没有分析出有效信息也有的字段恒为空
* CompressContent字面意思是压缩的数据实际上也就是微信任性不想存在 StrContent
里的数据在这里例如带有引用的文本消息等采用lz4压缩算法压缩
* BytesExtra额外的二进制格式数据
* BytesTrans目前看这是一个恒为空的字段
表1MSG.Type字段数值与含义对照表可能可以扩展到其它数据库中同样标记消息类型这一信息的字段
| 分类`Type` | 子分类`SubType` | 对应类型 |
|----------|--------------|-------------------------------------------------------------|
| 1 | 0 | 文本 |
| 3 | 0 | 图片 |
| 34 | 0 | 语音 |
| 43 | 0 | 视频 |
| 47 | 0 | 动画表情(第三方开发的表情包) |
| 49 | 1 | 类似文字消息而不一样的消息目前只见到一个阿里云盘的邀请注册是这样的。估计和57子类的情况一样 |
| 49 | 5 | 卡片式链接CompressContent 中有标题、简介等BytesExtra 中有本地缓存的封面路径 |
| 49 | 6 | 文件CompressContent 中有文件名和下载链接但不会读BytesExtra 中有本地保存的路径 |
| 49 | 8 | 用户上传的 GIF 表情CompressContent 中有CDN链接不过似乎不能直接访问下载 |
| 49 | 19 | 合并转发的聊天记录CompressContent 中有详细聊天记录BytesExtra 中有图片视频等的缓存 |
| 49 | 33/36 | 分享的小程序CompressContent 中有卡片信息BytesExtra 中有封面缓存位置 |
| 49 | 57 | 带有引用的文本消息(这种类型下 StrContent 为空,发送和引用的内容均在 CompressContent 中) |
| 49 | 63 | 视频号直播或直播回放等 |
| 49 | 87 | 群公告 |
| 49 | 88 | 视频号直播或直播回放等 |
| 49 | 2000 | 转账消息(包括发出、接收、主动退还) |
| 49 | 2003 | 赠送红包封面 |
| 10000 | 0 | 系统通知(居中出现的那种灰色文字) |
| 10000 | 4 | 拍一拍 |
| 10000 | 8000 | 系统通知(特别包含你邀请别人加入群聊) |

View File

@ -1,72 +0,0 @@
# 一、解密微信数据库
## 主要功能
1. 解密微信数据库
2. 查看聊天记录
3. 导出聊天记录
* CSV
* docx(待实现)
* HTML(待实现)
## 安装
```shell
git clone https://github.com/LC044/WeChatMsg
cd WeChatMsg
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
```
## 解密
<details>
解密步骤:
1. 登录微信
2. 运行程序
```shell
python decrypt_window.py
```
3. 点击获取信息
![](./images/pc_decrypt_info.png)
4. 设置微信安装路径
可以到微信->设置->文件管理查看
![](./images/setting.png)
点击**设置微信路径**按钮选择该文件夹路径下的带有wxid_xxx的路径
![](./images/path_select.png)
5. 获取到密钥和微信路径之后点击开始解密
6. 解密后的数据库文件保存在./app/DataBase/Msg路径下
</details>
## 查看聊天记录
<details>
1. 运行程序
```shell
python main.py
```
2. 选择联系人
<img src='./images/pc_contact.png' alt="运行图片"/>
3. 导出聊天记录
聊天记录保存在 **/data/聊天记录/** 文件夹下
<img src='./images/messages_demo.png' />
</details>

122
readme.md
View File

@ -43,33 +43,35 @@
</div>
</blockquote>
为了照顾普通用户,我准备在[![](https://img.shields.io/badge/Gitee-red.svg)](https://gitee.com/lc044/WeChatMsg)上同步创建一个发行版,但是普通[![](https://img.shields.io/badge/Gitee-red.svg)](https://gitee.com/lc044/WeChatMsg)项目附件不能超过100M大家可以去[![](https://img.shields.io/badge/Gitee-red.svg)](https://gitee.com/lc044/WeChatMsg)上点点star项目活跃起来之后我看看能不能申请GVP把附件大小提升至200M这样大家就能高速下载了。[https://gitee.com/lc044/WeChatMsg](https://gitee.com/lc044/WeChatMsg)
## 🍉功能
- [![](https://img.shields.io/badge/MemoTrace-官网-blue)](https://memotrace.lc044.love/)
[![](https://img.shields.io/badge/GitHub-black.svg)](https://github.com/LC044/WeChatMsg)
[![](https://img.shields.io/badge/Gitee-red.svg)](https://gitee.com/lc044/WeChatMsg)
[![](https://img.shields.io/badge/Download-yellow.svg)](https://memotrace.lc044.love/)
- 🔒🔑🔓Windows本地微信数据库
- 还原微信聊天界面
- 🗨文本✅
- 🏝图片✅
- 🐻‍❄️表情包✅
- 拍一拍等系统消息✅
- 导出聊天记录
- sqlite数据库✅
- HTML(文本、图片、视频、表情包、语音、文件、系统消息)✅
- CSV文档✅
- TXT文档✅
- Word文档✅
- 导出数据
- 批量导出数据✅
- 导出联系人✅
- sqlite数据库✅
- HTML(文本、图片、视频、表情包、语音、文件、系统消息)✅
- CSV文档✅
- TXT文档✅
- Word文档✅
- 分析聊天数据,做成可视化年报[点击预览](https://memotrace.lc044.love/demo.html)
- 🔥**项目持续更新中**
- 开发计划
- 各种分析图表
- 个人年度报告
- 群组年度报告
- 情感分析
- 自主选择年度报告年份
- 一键导出全部表情包、文件、图片、视频、语音
- 合并多个备份数据
- 批量导出数据
- 个人年度报告
- 群组年度报告
- 按日期、关键词索引
- 支持企业微信好友
- 小伙伴们想要其他功能可以留言哦📬
@ -99,102 +101,15 @@
# ⌛使用
网络有问题可移步国内网站Gitee:[https://gitee.com/lc044/WeChatMsg](https://gitee.com/lc044/WeChatMsg)
下载地址:[https://memotrace.lc044.love/](https://memotrace.lc044.love/)
小白可以先点个star⭐(💘项目不断更新中),然后去旁边[Releases](https://github.com/LC044/WeChatMsg/releases)
下载打包好的exe可执行文件双击即可运行
**⚠注意若出现闪退情况请右击选择用管理员身份运行exe程序该程序不存在任何病毒若杀毒软件提示有风险选择略过即可key为none可重启电脑**
**不懂编程的请移步[Releases](https://github.com/LC044/WeChatMsg/releases),下面的东西看了可能要长脑子啦🐶**
## 源码运行
<details>
### 1. 安装
```shell
# Python>=3.10 仅支持3.10、3.11、3.12,请勿使用其他Python版本
git clone https://github.com/LC044/WeChatMsg
cd WeChatMsg
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
```
### 2. 使用
1. 登录微信
手机端使用聊天记录迁移功能将聊天数据迁移到电脑上
操作步骤:
- 安卓: 手机微信->我->设置->聊天->聊天记录迁移与备份->迁移-> 迁移到电脑微信(迁移完成后重启微信)[否则](https://github.com/LC044/WeChatMsg/issues/27)
- iOS 手机微信->我->设置->通用->聊天记录迁移与备份->迁移-> 迁移到电脑微信(迁移完成后重启微信)[否则](https://github.com/LC044/WeChatMsg/issues/27)
2. 运行程序
```shell
python main.py
```
3. 点击获取信息
![](./doc/images/pc_decrypt_info.png)
4. 设置微信安装路径(如果自动设置好了就**不用管**了)
可以到微信->设置->文件管理查看
![](./doc/images/setting.png)
点击**设置微信路径**按钮选择该文件夹路径下的带有wxid_xxx的路径(没有wxid的话先选择其中一个文件夹不对的话换其他文件夹)
![](./doc/images/path_select.png)
5. 获取到key和微信路径之后点击开始启动
6. 数据库文件保存在./app/DataBase/Msg路径下
### 3. 查看
随便下载一个SQLite数据库查看软件就能打开数据库例如[DB Browser for SQLite](https://sqlitebrowser.org/dl/)
不懂SQL的稍微学下SQL咱再来或者自动跳过该步骤直接往下看最终效果
* [数据库功能介绍](./doc/数据库介绍.md)
* [更多功能介绍](./doc/电脑端使用教程.md)
显示效果
<img alt="聊天界面" src="doc/images/chat.png"/>
### 4. pc端功能展示
#### 4.1 最上方导航栏
可以点击获取教程相关信息导出全部信息的csv文件。
![](./doc/images/main_window.png)
#### 4.2 聊天界面
点击**左侧导航栏——>聊天**
,会随机跳转到某一个好友的界面,滚轮滚动,可以向上翻看更早的聊天记录。目前聊天记录中文字、图片基本可以正常显示~
![](./doc/images/chat_window1.png)
当你想要查找某一位好友的信息时可以在图中红框输入信息点击Enter回车键进行检索
![](./doc/images/chat_window2.png)
#### 4.3 好友界面
点击**左侧导航栏——>好友**会跳转到好友的界面同样可以选择好友右上方导航栏中有1统计信息2情感分析3年度报告4退出5导出聊天记录可选择导出为word、csv、html、txt格式。
![](./doc/images/contact_window.png)
**功能部分未集成或开发,请您耐心等待呀~**
</details>
[详见开发者手册](./doc/开发者手册.md)
## PC端使用过程中部分问题解决可参考
@ -202,9 +117,10 @@ python main.py
#### 🤔如果您在pc端使用的时候出现问题可以先参考以下方面如果仍未解决可以在群里交流~
* 不支持Win7(可自行下载代码构建可执行程序)
* 不支持Win7
* 不支持Mac(未来或许会实现)
* 遇到问题四大法宝
* 首先要删除app/Database/Msg文件夹
* 重启微信
* 重启exe程序
* 重启电脑
@ -240,7 +156,7 @@ python main.py
* PC微信工具:[https://github.com/xaoyaoo/PyWxDump](https://github.com/xaoyaoo/PyWxDump)
* PyQt组件库:[https://github.com/PyQt5/CustomWidgets](https://github.com/PyQt5/CustomWidgets)
* 我的得力助手:[ChatGPT](https://chat.openai.com/)
* 得力助手:[ChatGPT](https://chat.openai.com/)
---
> \[!IMPORTANT]
@ -253,7 +169,7 @@ python main.py
[![Star History Chart](https://api.star-history.com/svg?repos=LC044/WeChatMsg&type=Date)](https://star-history.com/?utm_source=bestxtools.com#LC044/WeChatMsg&Date)
# 贡献者
# 🤝贡献者
<a href="https://github.com/lc044/wechatmsg/graphs/contributors">
<img src="https://contrib.rocks/image?repo=lc044/wechatmsg" />