From e64af57ca3f19039af2bce9788253eff0f13896b Mon Sep 17 00:00:00 2001 From: shuaikangzhou <863909694@qq.com> Date: Mon, 29 Jan 2024 20:42:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/DataBase/__init__.py | 2 +- app/DataBase/exporter.py | 175 ++++++++ app/DataBase/exporter_csv.py | 2 +- app/DataBase/exporter_docx.py | 2 +- app/DataBase/exporter_html.py | 2 +- app/DataBase/exporter_txt.py | 2 +- app/DataBase/output.py | 530 ++++++++++++++++++------- app/DataBase/output_pc.py | 437 -------------------- app/ui/contact/export/export_dialog.py | 2 +- app/ui/mainview.py | 2 +- app/ui/menu/export.py | 2 +- doc/开发者手册.md | 84 ++++ doc/数据库介绍.md | 248 +++++++++++- doc/电脑端使用教程.md | 72 ---- readme.md | 122 +----- 15 files changed, 929 insertions(+), 755 deletions(-) create mode 100644 app/DataBase/exporter.py delete mode 100644 app/DataBase/output_pc.py create mode 100644 doc/开发者手册.md delete mode 100644 doc/电脑端使用教程.md diff --git a/app/DataBase/__init__.py b/app/DataBase/__init__.py index 0207b6f..de57c40 100644 --- a/app/DataBase/__init__.py +++ b/app/DataBase/__init__.py @@ -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"] diff --git a/app/DataBase/exporter.py b/app/DataBase/exporter.py new file mode 100644 index 0000000..763ff0f --- /dev/null +++ b/app/DataBase/exporter.py @@ -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 diff --git a/app/DataBase/exporter_csv.py b/app/DataBase/exporter_csv.py index 941d89f..ac065f8 100644 --- a/app/DataBase/exporter_csv.py +++ b/app/DataBase/exporter_csv.py @@ -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): diff --git a/app/DataBase/exporter_docx.py b/app/DataBase/exporter_docx.py index 3364745..22dd927 100644 --- a/app/DataBase/exporter_docx.py +++ b/app/DataBase/exporter_docx.py @@ -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 diff --git a/app/DataBase/exporter_html.py b/app/DataBase/exporter_html.py index 4a75538..086666b 100644 --- a/app/DataBase/exporter_html.py +++ b/app/DataBase/exporter_html.py @@ -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 diff --git a/app/DataBase/exporter_txt.py b/app/DataBase/exporter_txt.py index 74f4f02..753f809 100644 --- a/app/DataBase/exporter_txt.py +++ b/app/DataBase/exporter_txt.py @@ -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 diff --git a/app/DataBase/output.py b/app/DataBase/output.py index 763ff0f..540e7c4 100644 --- a/app/DataBase/output.py +++ b/app/DataBase/output.py @@ -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 diff --git a/app/DataBase/output_pc.py b/app/DataBase/output_pc.py deleted file mode 100644 index 540e7c4..0000000 --- a/app/DataBase/output_pc.py +++ /dev/null @@ -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 diff --git a/app/ui/contact/export/export_dialog.py b/app/ui/contact/export/export_dialog.py index caf828d..cfd1113 100644 --- a/app/ui/contact/export/export_dialog.py +++ b/app/ui/contact/export/export_dialog.py @@ -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, diff --git a/app/ui/mainview.py b/app/ui/mainview.py index 8f539ea..8b6a2b0 100644 --- a/app/ui/mainview.py +++ b/app/ui/mainview.py @@ -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 diff --git a/app/ui/menu/export.py b/app/ui/menu/export.py index 7db8901..794311a 100644 --- a/app/ui/menu/export.py +++ b/app/ui/menu/export.py @@ -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 diff --git a/doc/开发者手册.md b/doc/开发者手册.md new file mode 100644 index 0000000..e8c5bb1 --- /dev/null +++ b/doc/开发者手册.md @@ -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资源文件,供打包使用 +``` \ No newline at end of file diff --git a/doc/数据库介绍.md b/doc/数据库介绍.md index 2e3dd95..fa82698 100644 --- a/doc/数据库介绍.md +++ b/doc/数据库介绍.md @@ -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:目前看这是一个恒为空的字段 + +表1:MSG.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 | 系统通知(特别包含你邀请别人加入群聊) | \ No newline at end of file diff --git a/doc/电脑端使用教程.md b/doc/电脑端使用教程.md deleted file mode 100644 index 6f4b926..0000000 --- a/doc/电脑端使用教程.md +++ /dev/null @@ -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 -``` - -## 解密 - -
- -解密步骤: - -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路径下 - -
- -## 查看聊天记录 - -
- -1. 运行程序 - -```shell -python main.py -``` - -2. 选择联系人 - -运行图片 - -3. 导出聊天记录 - -聊天记录保存在 **/data/聊天记录/** 文件夹下 - - - -
\ No newline at end of file diff --git a/readme.md b/readme.md index b5fb67f..b8275eb 100644 --- a/readme.md +++ b/readme.md @@ -43,33 +43,35 @@ -为了照顾普通用户,我准备在[![](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),下面的东西看了可能要长脑子啦🐶** - ## 源码运行 -
- -### 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) - -显示效果 - -聊天界面 - -### 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) - -**功能部分未集成或开发,请您耐心等待呀~** - -
+[详见开发者手册](./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) -# 贡献者 +# 🤝贡献者