出错了
", f"{self.contact.remark}
") + f.write(html_head) + self.rangeSignal.emit(len(messages)) + for index, message in enumerate(messages): + type_ = message[2] + sub_type = message[3] + timestamp = message[5] + if (type_ == 3 and self.message_types.get(3)) or (type_ == 34 and self.message_types.get(34)) or ( + type_ == 47 and self.message_types.get(47)): + pass + else: + self.progressSignal.emit(1) + + if type_ == 1 and self.message_types.get(type_): + self.text(f, message) + elif type_ == 3 and self.message_types.get(type_): + self.image(f, message) + elif type_ == 34 and self.message_types.get(type_): + self.audio(f, message) + elif type_ == 43 and self.message_types.get(type_): + self.video(f, message) + elif type_ == 47 and self.message_types.get(type_): + self.emoji(f, message) + elif type_ == 10000 and self.message_types.get(type_): + self.system_msg(f, message) + elif type_ == 49 and sub_type == 57 and self.message_types.get(1): + self.refermsg(f, message) + elif type_ == 49 and sub_type == 6 and self.message_types.get(4906): + self.file(f, message) + elif type_ == 49 and sub_type == 3 and self.message_types.get(4903): + self.music_share(f, message) + elif type_ == 49 and sub_type == 5 and self.message_types.get(4905): + self.share_card(f, message) + elif type_ == 49 and sub_type == 2000 and self.message_types.get(492000): + self.transfer(f, message) + elif type_ == 50 and self.message_types.get(50): + self.call(f, message) + if index % 2000 == 0: + print(f"【导出 HTML {self.contact.remark}】{index}/{len(messages)}") + f.write(html_end) + f.close() + print(f"【完成导出 HTML {self.contact.remark}】{len(messages)}") + self.count_finish_num(1) + + def count_finish_num(self, num): + """ + 记录子线程完成个数 + @param num: + @return: + """ + self.num += 1 + print("子线程完成", self.num, "/", self.total_num) + if self.num == self.total_num: + # 所有子线程都完成之后就发送完成信号 + self.okSignal.emit(1) + + +class OutputMedia(QThread): + """ + 导出语音消息 + """ + + okSingal = pyqtSignal(int) + progressSignal = pyqtSignal(int) + + def __init__(self, contact): + super().__init__() + self.contact = contact + + def run(self): + origin_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', self.contact.remark) + messages = msg_db.get_messages_by_type(self.contact.wxid, 34) + for message in messages: + is_send = message[4] + msgSvrId = message[9] + try: + audio_path = media_msg_db.get_audio( + msgSvrId, output_path=origin_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): + super().__init__() + self.contact = contact + + def run(self): + origin_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', self.contact.remark) + messages = msg_db.get_messages_by_type(self.contact.wxid, 47) + for message in messages: + str_content = message[7] + try: + pass + # emoji_path = get_emoji(str_content, thumb=True, output_path=origin_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): + super().__init__() + self.contact = contact + self.child_thread_num = 2 + 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_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', self.contact.remark) + messages = msg_db.get_messages_by_type(self.contact.wxid, 3) + base_path = os.path.join(OUTPUT_DIR, '聊天记录', self.contact.remark, 'image') + 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=base_path + ) + try: + os.utime(origin_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): + super().__init__() + self.contact = contact + self.messages = messages + + def run(self): + origin_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', 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_path + image_path[1:], (timestamp, timestamp)) + except: + pass + except: + logger.error(traceback.format_exc()) + finally: + self.progressSignal.emit(1) + self.okSingal.emit(47) + print("图片子线程完成") diff --git a/app/util/exporter/exporter_json.py b/app/util/exporter/exporter_json.py new file mode 100644 index 0000000..0de2510 --- /dev/null +++ b/app/util/exporter/exporter_json.py @@ -0,0 +1,193 @@ +import json +import random +import os + +from app.DataBase import msg_db +from app.person import Me +from .exporter import ExporterBase + + +def merge_content(conversions_list) -> list: + """ + 合并一组对话中连续发送的句子 + @param conversions_list: + @return: + """ + merged_data = [] + current_role = None + current_content = "" + str_time = '' + for item in conversions_list: + if 'str_time' in item: + str_time = item['str_time'] + else: + str_time = '' + if current_role is None: + current_role = item["role"] + current_content = item["content"] + elif current_role == item["role"]: + current_content += "\n" + item["content"] + else: + # merged_data.append({"role": current_role, "content": current_content, 'str_time': str_time}) + merged_data.append({"role": current_role, "content": current_content}) + current_role = item["role"] + current_content = item["content"] + str_time = item.get('str_time') + + # 处理最后一组 + if current_role is not None: + # merged_data.append({"role": current_role, "content": current_content,'str_time': str_time}) + merged_data.append({"role": current_role, "content": current_content}) + return merged_data + + +def system_prompt(): + system = { + "role": "system", + # "content": f"你是{Me().name},一个聪明、热情、善良的男大学生,后面的对话来自{self.contact.remark}(!!!注意:对方的身份十分重要,你务必记住对方的身份,因为跟不同的人对话要用不同的态度、语气),你要认真地回答他" + "content": f"你是{Me().name},一个聪明、热情、善良的人,后面的对话来自你的朋友,你要认真地回答他" + } + return system + + +def message_to_conversion(group): + conversions = [system_prompt()] + while len(group) and group[-1][4] == 0: + group.pop() + for message in group: + is_send = message[4] + if len(conversions) == 1 and is_send: + continue + if is_send: + json_msg = { + "role": "assistant", + "content": message[7] + } + else: + json_msg = { + "role": "user", + "content": message[7] + } + json_msg['str_time'] = message[8] + conversions.append(json_msg) + if len(conversions) == 1: + return [] + return merge_content(conversions) + + +class JsonExporter(ExporterBase): + def split_by_time(self, length=300): + messages = msg_db.get_messages_by_type(self.contact.wxid, type_=1, time_range=self.time_range) + start_time = 0 + res = [] + i = 0 + while i < len(messages): + message = messages[i] + timestamp = message[5] + is_send = message[4] + group = [ + system_prompt() + ] + while i < len(messages) and timestamp - start_time < length: + if is_send: + json_msg = { + "role": "assistant", + "content": message[7] + } + else: + json_msg = { + "role": "user", + "content": message[7] + } + group.append(json_msg) + i += 1 + if i >= len(messages): + break + message = messages[i] + timestamp = message[5] + is_send = message[4] + while is_send: + json_msg = { + "role": "assistant", + "content": message[7] + } + group.append(json_msg) + i += 1 + if i >= len(messages): + break + message = messages[i] + timestamp = message[5] + is_send = message[4] + start_time = timestamp + res.append( + { + "conversations": group + } + ) + res_ = [] + for item in res: + conversations = item['conversations'] + res_.append({ + 'conversations': merge_content(conversations) + }) + return res_ + + def split_by_intervals(self, max_diff_seconds=300): + messages = msg_db.get_messages_by_type(self.contact.wxid, type_=1, time_range=self.time_range) + res = [] + i = 0 + current_group = [] + while i < len(messages): + message = messages[i] + timestamp = message[5] + is_send = message[4] + while is_send and i + 1 < len(messages): + i += 1 + message = messages[i] + is_send = message[4] + current_group = [messages[i]] + i += 1 + while i < len(messages) and messages[i][5] - current_group[-1][5] <= max_diff_seconds: + current_group.append(messages[i]) + i += 1 + while i < len(messages) and messages[i][4]: + current_group.append(messages[i]) + i += 1 + res.append(current_group) + res_ = [] + for group in res: + conversations = message_to_conversion(group) + if conversations: + res_.append({ + 'conversations': conversations + }) + return res_ + + def to_json(self): + print(f"【开始导出 json {self.contact.remark}】") + origin_path = self.origin_path + os.makedirs(origin_path, exist_ok=True) + filename = os.path.join(origin_path, f"{self.contact.remark}") + + # res = self.split_by_time() + res = self.split_by_intervals(60) + # 打乱列表顺序 + random.shuffle(res) + + # 计算切分比例 + split_ratio = 0.2 # 20% for the second list + + # 计算切分点 + split_point = int(len(res) * split_ratio) + + # 分割列表 + train_data = res[split_point:] + dev_data = res[:split_point] + with open(f'{filename}_train.json', "w", encoding="utf-8") as f: + json.dump(train_data, f, ensure_ascii=False, indent=4) + with open(f'{filename}_dev.json', "w", encoding="utf-8") as f: + json.dump(dev_data, f, ensure_ascii=False, indent=4) + self.okSignal.emit(1) + + def run(self): + self.to_json() diff --git a/app/util/exporter/exporter_txt.py b/app/util/exporter/exporter_txt.py new file mode 100644 index 0000000..9e37105 --- /dev/null +++ b/app/util/exporter/exporter_txt.py @@ -0,0 +1,146 @@ +import os + +from app.DataBase import msg_db +from app.util.exporter.exporter import ExporterBase +from app.config import OUTPUT_DIR +from app.util.compress_content import parser_reply, share_card + + +class TxtExporter(ExporterBase): + def text(self, doc, message): + str_content = message[7] + str_time = message[8] + is_send = message[4] + display_name = self.get_display_name(is_send, message) + name = display_name + doc.write( + f'''{str_time} {name}\n{str_content}\n\n''' + ) + + def image(self, doc, message): + str_time = message[8] + is_send = message[4] + display_name = self.get_display_name(is_send, message) + doc.write( + f'''{str_time} {display_name}\n[图片]\n\n''' + ) + + def audio(self, doc, message): + str_time = message[8] + is_send = message[4] + display_name = self.get_display_name(is_send, message) + doc.write( + f'''{str_time} {display_name}\n[语音]\n\n''' + ) + def emoji(self, doc, message): + str_time = message[8] + is_send = message[4] + display_name = self.get_display_name(is_send, message) + doc.write( + f'''{str_time} {display_name}\n[表情包]\n\n''' + ) + + def file(self, doc, message): + str_time = message[8] + is_send = message[4] + display_name = self.get_display_name(is_send, message) + doc.write( + f'''{str_time} {display_name}\n[文件]\n\n''' + ) + + def refermsg(self, doc, message): + """ + 处理回复消息 + @param doc: + @param message: + @return: + """ + str_time = message[8] + is_send = message[4] + content = parser_reply(message[11]) + refer_msg = content.get('refer') + display_name = self.get_display_name(is_send, message) + if refer_msg: + doc.write( + f'''{str_time} {display_name}\n{content.get('title')}\n引用:{refer_msg.get('displayname')}:{refer_msg.get('content')}\n\n''' + ) + else: + doc.write( + f'''{str_time} {display_name}\n{content.get('title')}\n引用:未知\n\n''' + ) + + def system_msg(self, doc, message): + str_content = message[7] + str_time = message[8] + str_content = str_content.replace('重新编辑]]>', "") + doc.write( + f'''{str_time} {str_content}\n\n''' + ) + + def video(self, doc, message): + str_time = message[8] + is_send = message[4] + display_name = self.get_display_name(is_send, message) + doc.write( + f'''{str_time} {display_name}\n[视频]\n\n''' + ) + def music_share(self, doc, message): + is_send = message[4] + str_time = message[8] + display_name = self.get_display_name(is_send, message) + doc.write( + f'''{str_time} {display_name}\n[音乐分享]\n\n''' + ) + + def share_card(self, doc, message): + is_send = message[4] + bytesExtra = message[10] + compress_content_ = message[11] + str_time = message[8] + card_data = share_card(bytesExtra, compress_content_) + display_name = self.get_display_name(is_send, message) + doc.write( + f'''{str_time} {display_name} + [链接]:title:{card_data.get('title')} + description:{card_data.get('description')} + url:{card_data.get('url')} + name:{card_data.get('app_name')} + \n\n''' + ) + + def export(self): + # 实现导出为txt的逻辑 + print(f"【开始导出 TXT {self.contact.remark}】") + origin_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', self.contact.remark) + os.makedirs(origin_path, exist_ok=True) + filename = os.path.join(origin_path, self.contact.remark+'.txt') + messages = msg_db.get_messages(self.contact.wxid, time_range=self.time_range) + total_steps = len(messages) + with open(filename, mode='w', newline='', encoding='utf-8') as f: + for index, message in enumerate(messages): + type_ = message[2] + sub_type = message[3] + self.progressSignal.emit(int((index + 1) / total_steps * 100)) + if type_ == 1 and self.message_types.get(type_): + self.text(f, message) + elif type_ == 3 and self.message_types.get(type_): + self.image(f, message) + elif type_ == 34 and self.message_types.get(type_): + self.audio(f, message) + elif type_ == 43 and self.message_types.get(type_): + self.video(f, message) + elif type_ == 47 and self.message_types.get(type_): + self.emoji(f, message) + elif type_ == 10000 and self.message_types.get(type_): + self.system_msg(f, message) + elif type_ == 49 and sub_type == 57 and self.message_types.get(1): + self.refermsg(f, message) + elif type_ == 49 and sub_type == 6 and self.message_types.get(4906): + self.file(f, message) + elif type_ == 49 and sub_type == 3 and self.message_types.get(4903): + self.music_share(f, message) + elif type_ == 49 and sub_type == 5 and self.message_types.get(4905): + self.share_card(f, message) + print(f"【完成导出 TXT {self.contact.remark}】") + self.okSignal.emit(1) \ No newline at end of file diff --git a/app/util/exporter/output.py b/app/util/exporter/output.py new file mode 100644 index 0000000..963df8d --- /dev/null +++ b/app/util/exporter/output.py @@ -0,0 +1,466 @@ +import csv +import os +import time +import traceback +from typing import List + +import docx +from PyQt5.QtCore import pyqtSignal, QThread +from PyQt5.QtWidgets import QFileDialog +from docx.oxml.ns import qn +from docxcompose.composer import Composer + +from app.util.exporter.exporter_ai_txt import AiTxtExporter +from app.util.exporter.exporter_csv import CSVExporter +from app.util.exporter.exporter_docx import DocxExporter +from app.util.exporter.exporter_html import HtmlExporter +from app.util.exporter.exporter_json import JsonExporter +from app.util.exporter.exporter_txt import TxtExporter +from app.DataBase.hard_link import decodeExtraBuf +from app.config import OUTPUT_DIR +from app.DataBase.package_msg import PackageMsg +from app.DataBase import media_msg_db, hard_link_db, micro_msg_db, msg_db +from app.log import logger +from app.person import Me +from app.util.image import get_image + +os.makedirs(os.path.join(OUTPUT_DIR, '聊天记录'), 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 + JSON = 6 + AI_TXT = 7 + 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_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录') + os.makedirs(origin_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.AI_TXT: + # print('批量导出txt') + self.to_ai_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) + elif type_ == self.JSON: + self.to_json(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_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', conRemark) + filename = f"{origin_path}/{conRemark}_{n}.docx" + if n == 10086: + # self.document.append(self.document) + file = os.path.join(origin_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_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_json(self, contact, message_types, is_batch=False): + Child = JsonExporter(contact, type_=self.JSON, 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_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_ai_txt(self, contact, message_types, is_batch=False): + Child = AiTxtExporter(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.AI_TXT: + self.to_ai_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.JSON: + self.to_json(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_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', 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_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_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', 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_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_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', self.contact.remark) + messages = msg_db.get_messages_by_type(self.contact.wxid, 3, time_range=self.time_range) + base_path = os.path.join(OUTPUT_DIR, '聊天记录', self.contact.remark, 'image') + 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, up_dir=Me().wx_dir, thumb=False) + image_path = get_image(image_path, base_path=base_path) + try: + os.utime(origin_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_path = os.path.join(os.getcwd(), OUTPUT_DIR, '聊天记录', 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_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/util/file.py b/app/util/file.py new file mode 100644 index 0000000..8c8562f --- /dev/null +++ b/app/util/file.py @@ -0,0 +1,59 @@ +import os +import traceback +import shutil + +import requests + +from app.log import log, logger +from app.util.protocbuf.msg_pb2 import MessageBytesExtra +from ..person import Me + +root_path = './data/files/' +if not os.path.exists('./data'): + os.mkdir('./data') +if not os.path.exists(root_path): + os.mkdir(root_path) + + +class File: + def __init__(self): + self.open_flag = False + + +def get_file(bytes_extra, file_name, output_path=root_path) -> str: + try: + msg_bytes = MessageBytesExtra() + msg_bytes.ParseFromString(bytes_extra) + file_path = '' + real_path = '' + if len(msg_bytes.message2) > 0: + for filed in msg_bytes.message2: + if filed.field1 == 4: + file_original_path = filed.field2 + file_path = os.path.join(output_path, file_name) + if os.path.exists(file_path): + # print('文件' + file_path + '已存在') + return file_path + if os.path.isabs(file_original_path): # 绝对路径可能迁移过文件目录,也可能存在其他位置 + if os.path.exists(file_original_path): + real_path = file_original_path + else: # 如果没找到再判断一次是否是迁移了目录 + if file_original_path.find(r"FileStorage") != -1: + real_path = Me().wx_dir + file_original_path[ + file_original_path.find("FileStorage") - 1:] + else: + if file_original_path.find(Me().wxid) != -1: + real_path = Me().wx_dir + file_original_path.replace(Me().wxid, '') + else: + real_path = Me().wx_dir + file_original_path + if real_path != "": + if os.path.exists(real_path): + print('开始获取文件' + real_path) + shutil.copy2(real_path, file_path) + else: + print('文件' + file_original_path + '已丢失') + file_path = '' + return file_path + except: + logger.error(traceback.format_exc()) + return "" diff --git a/app/util/image.py b/app/util/image.py new file mode 100644 index 0000000..e8915cf --- /dev/null +++ b/app/util/image.py @@ -0,0 +1,135 @@ +import os +import traceback + +from app.log import logger +from app.person import Me + +# 图片字节头信息, +# [0][1]为jpg头信息, +# [2][3]为png头信息, +# [4][5]为gif头信息 +pic_head = [0xff, 0xd8, 0x89, 0x50, 0x47, 0x49] +# 解密码 +decode_code = 0 + + +def get_code(dat_read) -> tuple[int, int]: + """ + 自动判断文件类型,并获取dat文件解密码 + :param file_path: dat文件路径 + :return: 如果文件为jpg/png/gif格式,则返回解密码,否则返回-1 + """ + try: + if not dat_read: + return -1, -1 + head_index = 0 + while head_index < len(pic_head): + # 使用第一个头信息字节来计算加密码 + # 第二个字节来验证解密码是否正确 + code = dat_read[0] ^ pic_head[head_index] + idf_code = dat_read[1] ^ code + head_index = head_index + 1 + if idf_code == pic_head[head_index]: + return head_index, code + head_index = head_index + 1 + print("not jpg, png, gif") + return -1, -1 + except: + logger.error(f'image解析发生了错误:\n\n{traceback.format_exc()}') + return -1, -1 + + +def decode_dat(file_path, out_path) -> str: + """ + 解密文件,并生成图片 + :param file_path: dat文件路径 + :return: 无 + """ + if not os.path.exists(file_path): + return None + with open(file_path, 'rb') as file_in: + data = file_in.read() + + file_type, decode_code = get_code(data[:2]) + if decode_code == -1: + return '' + + filename = os.path.basename(file_path) + if file_type == 1: + pic_name = os.path.basename(file_path)[:-4] + ".jpg" + elif file_type == 3: + pic_name = filename[:-4] + ".png" + elif file_type == 5: + pic_name = filename[:-4] + ".gif" + else: + pic_name = filename[:-4] + ".jpg" + file_outpath = os.path.join(out_path, pic_name) + if os.path.exists(file_outpath): + return file_outpath + + # 对数据进行异或加密/解密 + with open(file_outpath, 'wb') as file_out: + file_out.write(bytes([byte ^ decode_code for byte in data])) + print(file_path, '->', file_outpath) + return file_outpath + + +def decode_dat_path(file_path, out_path) -> str: + """ + 解密文件,并生成图片 + :param file_path: dat文件路径 + :return: 无 + """ + if not os.path.exists(file_path): + return '' + with open(file_path, 'rb') as file_in: + data = file_in.read(2) + file_type, decode_code = get_code(data) + if decode_code == -1: + return '' + filename = os.path.basename(file_path) + if file_type == 1: + pic_name = os.path.basename(file_path)[:-4] + ".jpg" + elif file_type == 3: + pic_name = filename[:-4] + ".png" + elif file_type == 5: + pic_name = filename[:-4] + ".gif" + else: + pic_name = filename[:-4] + ".jpg" + file_outpath = os.path.join(out_path, pic_name) + return file_outpath + + +def get_image(path, base_path) -> str: + if path: + base_path = os.path.join(os.getcwd(),base_path) + output_path = decode_dat(os.path.join(Me().wx_dir, path), base_path) + relative_path = './image/' + os.path.basename( + output_path) if output_path else 'https://www.bing.com/images/search?view=detailV2&ccid=Zww6woP3&id=CCC91337C740656E800E51247E928ACD3052FECF&thid=OIP.Zww6woP3Em49TdSG_lnggAHaEK&mediaurl=https%3a%2f%2fmeekcitizen.files.wordpress.com%2f2018%2f09%2f404.jpg%3fw%3d656&exph=360&expw=640&q=404&simid=608040792714530493&FORM=IRPRST&ck=151E7337A86F1B9C5C5DB08B15B90809&selectedIndex=21&itb=0' + return relative_path + else: + return ':/icons/icons/404.png' + + +def get_image_abs_path(path, base_path) -> str: + if path: + base_path = os.path.join(os.getcwd(),base_path) + output_path = decode_dat(os.path.join(Me().wx_dir, path), base_path) + return output_path + else: + return ':/icons/icons/404.png' + + +def get_image_path(path, base_path) -> str: + if path: + base_path = os.getcwd() + base_path + output_path = decode_dat_path(os.path.join(Me().wx_dir, path), base_path) + relative_path = './image/' + os.path.basename( + output_path) if output_path else 'https://www.bing.com/images/search?view=detailV2&ccid=Zww6woP3&id=CCC91337C740656E800E51247E928ACD3052FECF&thid=OIP.Zww6woP3Em49TdSG_lnggAHaEK&mediaurl=https%3a%2f%2fmeekcitizen.files.wordpress.com%2f2018%2f09%2f404.jpg%3fw%3d656&exph=360&expw=640&q=404&simid=608040792714530493&FORM=IRPRST&ck=151E7337A86F1B9C5C5DB08B15B90809&selectedIndex=21&itb=0' + return relative_path + else: + return ':/icons/icons/404.png' + + +if __name__ == "__main__": + pass diff --git a/app/util/music.py b/app/util/music.py new file mode 100644 index 0000000..6ad9296 --- /dev/null +++ b/app/util/music.py @@ -0,0 +1,55 @@ +import os +import traceback +import shutil + +from app.log import log, logger +from app.util.protocbuf.msg_pb2 import MessageBytesExtra +import requests +from urllib.parse import urlparse, parse_qs +import re + +root_path = './data/music/' +if not os.path.exists('./data'): + os.mkdir('./data') +if not os.path.exists(root_path): + os.mkdir(root_path) + + +class File: + def __init__(self): + self.open_flag = False + + +def get_music_path(url, file_title, output_path=root_path) -> str: + try: + parsed_url = urlparse(url) + if '.' in parsed_url.path: + # 获取扩展名 + file_extension = parsed_url.path.split('.')[-1] + + pattern = r'[\\/:*?"<>|\r\n]+' + file_title = re.sub(pattern, "_", file_title) + file_name = file_title + '.' + file_extension + music_path = os.path.join(output_path, file_name) + if os.path.exists(music_path): + # print('文件' + music_path + '已存在') + return music_path + header = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.40 Safari/537.36 Edg/87.0.664.24' + } + requests.packages.urllib3.disable_warnings() + response = requests.get(url,headers=header,verify=False) + if response.status_code == 200: + with open(music_path, 'wb') as f: + f.write(response.content) + else: + music_path = '' + print("音乐" + file_name + "获取失败:请求地址:" + url) + else: + music_path = '' + print('音乐文件已失效,url:' + url) + return music_path + except Exception as e: + print(f"Get Music Path Error: {e}") + logger.error(traceback.format_exc()) + return "" diff --git a/app/util/path.py b/app/util/path.py new file mode 100644 index 0000000..54b4da7 --- /dev/null +++ b/app/util/path.py @@ -0,0 +1,81 @@ +import os +import winreg + +from app.person import Me +from app.util import image + +os.makedirs('./data/image', exist_ok=True) + + +def get_abs_path(path, base_path="/data/image"): + # return os.path.join(os.getcwd(), 'app/data/icons/404.png') + if path: + base_path = os.getcwd() + base_path + output_path = image.decode_dat(os.path.join(Me().wx_dir, path), base_path) + return output_path if output_path else ':/icons/icons/404.png' + else: + return ':/icons/icons/404.png' + + +def get_relative_path(path, base_path, type_='image'): + if path: + base_path = os.getcwd() + base_path + output_path = image.decode_dat(os.path.join(Me().wx_dir, path), base_path) + relative_path = './image/' + os.path.basename( + output_path) if output_path else 'https://www.bing.com/images/search?view=detailV2&ccid=Zww6woP3&id=CCC91337C740656E800E51247E928ACD3052FECF&thid=OIP.Zww6woP3Em49TdSG_lnggAHaEK&mediaurl=https%3a%2f%2fmeekcitizen.files.wordpress.com%2f2018%2f09%2f404.jpg%3fw%3d656&exph=360&expw=640&q=404&simid=608040792714530493&FORM=IRPRST&ck=151E7337A86F1B9C5C5DB08B15B90809&selectedIndex=21&itb=0' + return relative_path + else: + return ':/icons/icons/404.png' + + +def mkdir(path): + if not os.path.exists(path): + os.mkdir(path) + + +def wx_path(): + try: + is_w_dir = False + + try: + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Tencent\WeChat", 0, winreg.KEY_READ) + value, _ = winreg.QueryValueEx(key, "FileSavePath") + winreg.CloseKey(key) + w_dir = value + is_w_dir = True + except Exception as e: + w_dir = "MyDocument:" + + if not is_w_dir: + try: + user_profile = os.environ.get("USERPROFILE") + path_3ebffe94 = os.path.join(user_profile, "AppData", "Roaming", "Tencent", "WeChat", "All Users", + "config", + "3ebffe94.ini") + with open(path_3ebffe94, "r", encoding="utf-8") as f: + w_dir = f.read() + is_w_dir = True + except Exception as e: + w_dir = "MyDocument:" + + if w_dir == "MyDocument:": + try: + # 打开注册表路径 + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders") + documents_path = winreg.QueryValueEx(key, "Personal")[0] # 读取文档实际目录路径 + winreg.CloseKey(key) # 关闭注册表 + documents_paths = os.path.split(documents_path) + if "%" in documents_paths[0]: + w_dir = os.environ.get(documents_paths[0].replace("%", "")) + w_dir = os.path.join(w_dir, os.path.join(*documents_paths[1:])) + # print(1, w_dir) + else: + w_dir = documents_path + except Exception as e: + profile = os.environ.get("USERPROFILE") + w_dir = os.path.join(profile, "Documents") + msg_dir = os.path.join(w_dir, "WeChat Files") + return msg_dir + except FileNotFoundError: + return '.' diff --git a/app/util/protocbuf/__init__.py b/app/util/protocbuf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/util/protocbuf/msg.proto b/app/util/protocbuf/msg.proto new file mode 100644 index 0000000..1d88cec --- /dev/null +++ b/app/util/protocbuf/msg.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; +package app.protobuf; +option go_package=".;proto"; + +message SubMessage1 { + int32 field1 = 1; + int32 field2 = 2; +} + +message SubMessage2 { + int32 field1 = 1; + string field2 = 2; +} + +message MessageBytesExtra { + SubMessage1 message1 = 1; + repeated SubMessage2 message2 = 3; +} diff --git a/app/util/protocbuf/msg_pb2.py b/app/util/protocbuf/msg_pb2.py new file mode 100644 index 0000000..f5f31c5 --- /dev/null +++ b/app/util/protocbuf/msg_pb2.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: msg.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tmsg.proto\x12\x0c\x61pp.protobuf\"-\n\x0bSubMessage1\x12\x0e\n\x06\x66ield1\x18\x01 \x01(\x05\x12\x0e\n\x06\x66ield2\x18\x02 \x01(\x05\"-\n\x0bSubMessage2\x12\x0e\n\x06\x66ield1\x18\x01 \x01(\x05\x12\x0e\n\x06\x66ield2\x18\x02 \x01(\t\"m\n\x11MessageBytesExtra\x12+\n\x08message1\x18\x01 \x01(\x0b\x32\x19.app.protobuf.SubMessage1\x12+\n\x08message2\x18\x03 \x03(\x0b\x32\x19.app.protobuf.SubMessage2b\x06proto3') + + + +_SUBMESSAGE1 = DESCRIPTOR.message_types_by_name['SubMessage1'] +_SUBMESSAGE2 = DESCRIPTOR.message_types_by_name['SubMessage2'] +_MESSAGEBYTESEXTRA = DESCRIPTOR.message_types_by_name['MessageBytesExtra'] +SubMessage1 = _reflection.GeneratedProtocolMessageType('SubMessage1', (_message.Message,), { + 'DESCRIPTOR' : _SUBMESSAGE1, + '__module__' : 'msg_pb2' + # @@protoc_insertion_point(class_scope:app.protobuf.SubMessage1) + }) +_sym_db.RegisterMessage(SubMessage1) + +SubMessage2 = _reflection.GeneratedProtocolMessageType('SubMessage2', (_message.Message,), { + 'DESCRIPTOR' : _SUBMESSAGE2, + '__module__' : 'msg_pb2' + # @@protoc_insertion_point(class_scope:app.protobuf.SubMessage2) + }) +_sym_db.RegisterMessage(SubMessage2) + +MessageBytesExtra = _reflection.GeneratedProtocolMessageType('MessageBytesExtra', (_message.Message,), { + 'DESCRIPTOR' : _MESSAGEBYTESEXTRA, + '__module__' : 'msg_pb2' + # @@protoc_insertion_point(class_scope:app.protobuf.MessageBytesExtra) + }) +_sym_db.RegisterMessage(MessageBytesExtra) + +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _SUBMESSAGE1._serialized_start=27 + _SUBMESSAGE1._serialized_end=72 + _SUBMESSAGE2._serialized_start=74 + _SUBMESSAGE2._serialized_end=119 + _MESSAGEBYTESEXTRA._serialized_start=121 + _MESSAGEBYTESEXTRA._serialized_end=230 +# @@protoc_insertion_point(module_scope) diff --git a/app/util/protocbuf/readme.md b/app/util/protocbuf/readme.md new file mode 100644 index 0000000..15b3c63 --- /dev/null +++ b/app/util/protocbuf/readme.md @@ -0,0 +1,34 @@ +# 说明 + +## 解析 +```shell +protoc --decode_raw < msg_data.txt +``` + +## 根据解析结果,设置.proto文件 +```shell +1 { + 1: 16 + 2: 0 +} +3 { + 1: 1 + 2: "wxid_4b1t09d63spw22" +} +3 { + 1: 7 + 2: "我们第一次聊天在
+{{first_time}}
+距今已有
+我们第一次聊天在
+{{first_time}}
+距今已有
+未眠人
+{{sub_title}}
++ | + +
+
+
+ + {{nickname}} + |
+
+ 我们第一次聊天发生在
++ {{first_time}}
+可掌控的才真正属于你
+ +你一共给{{contact_num}}个联系人
发送了{{send_msg_num}}条消息
收到了{{receive_msg_num}}条消息
总计{{total_text_num}}字
男{{man_contact_num}}人 女{{woman_contact_num}}人
+ + + + + +{{contact.remark}}
+Copyrights © 2022-2024 SiYuan 版权所有. Inc.
+ +++ +## 🍉功能 +- [](https://memotrace.cn/) +[](https://github.com/LC044/WeChatMsg) +[](https://gitee.com/lc044/WeChatMsg) +[](https://memotrace.cn/) +- 🔒️🔑🔓️Windows本地微信数据库 +- 还原微信聊天界面 + - 🗨文本✅ + - 🏝图片✅ + - 拍一拍等系统消息✅ +- 导出数据 + - 批量导出数据✅ + - 导出联系人✅ + - sqlite数据库✅ + - HTML✅ + - 文本、图片、视频、表情包、语音、文件、分享链接、系统消息、引用消息、合并转发的聊天记录、转账、音视频通话、位置分享、名片、小程序、视频号 + - 支持时间轴跳转 + - 引用消息可定位到原文 + - 分享链接、小程序支持超链接跳转 + - 合并转发的聊天记录支持展开 + - CSV文档✅ + - TXT文档✅ + - Word文档✅ +- 分析聊天数据,做成[可视化年报](https://memotrace.cn/demo.html) + +## 2024年度报告 + +### 预览 + +[个人年度报告在线预览](https://memotrace.cn/2024/single/) + +[双人年度报告在线预览](https://memotrace.cn/2024Report/) + +手机可以扫码观看 + ++++ ++前言
++++
我深信有意义的不是微信,而是隐藏在对话框背后的一个个深刻故事。未来,每个人都能拥有AI的陪伴,而你的数据能够赋予它有关于你过去的珍贵记忆。我希望每个人都有将自己的生活痕迹👨👩👦👚🥗🏠️🚴🧋⛹️🛌🛀留存的权利,而不是遗忘💀。
+AI的发展不仅仅是技术的提升,更是情感💞的延续。每一个对话、每一个互动都是生活中独一无二的片段,是真实而动人的情感交流。因此,我希望AI工作者们能够善用这些自己的数据,用于培训独特的、属于个体的人工智能。让个人AI成为生活中的朋友,能够理解、记录并分享我们的欢笑、泪水和成长。
+那天,AI不再是高不可攀的存在,而是融入寻常百姓家的一部分。因为每个人能拥有自己的AI,将科技的力量融入生活的方方面面。这是一场关于真情实感的革命,一场让技术变得更加人性化的探索,让我们共同见证未来的美好。
+所以《留痕》
+
{{ chart.title_opts.title }}
+{{ chart.title_opts.subtitle }}
+ {{ chart.html_content }} +{{ chart.title_opts.title }}
+{{ chart.title_opts.subtitle }}
+