Compare commits

..

No commits in common. "main" and "0.0.1" have entirely different histories.
main ... 0.0.1

14 changed files with 52 additions and 354 deletions

View File

@ -4,14 +4,6 @@
# 一、项目介绍 # 一、项目介绍
## 0. 求助
四月底微信对朋友圈图片进行了加密如果知道如何解密的大佬请指点一下。帮忙Issue或者PR
通过抓包可知朋友圈图片是这样一个请求,现在问题是请求过来的数据不知道是怎么加密的
http://shmmsns.qpic.cn/mmsns/uGxMq1C4wvppcjBbyweK796GtT1hH3LGISYajZ2v7C11XhHk5icyDUXcWNSPk2MooeIa8Es5hXP0/0?idx=1&token=WSEN6qDsKwV8A02w3onOGQYfxnkibdqSOkmHhZGNB4DFumlE9p1vp0e0xjHoXlbbXRzwnQia6X5t3Annc4oqTuDg
<br>
加密后字节数与原图片一致可能是某种流式数据加密。key可能是11079841251888681493。
## 1. 项目简介 ## 1. 项目简介
* [WechatMoments](https://github.com/tech-shrimp/WechatMoments)是一款运行在Windows上的备份导出朋友圈为html的工具 * [WechatMoments](https://github.com/tech-shrimp/WechatMoments)是一款运行在Windows上的备份导出朋友圈为html的工具
@ -20,9 +12,9 @@ http://shmmsns.qpic.cn/mmsns/uGxMq1C4wvppcjBbyweK796GtT1hH3LGISYajZ2v7C11XhHk5ic
* 分发,宣传,二次开发等请注明原作者 * 分发,宣传,二次开发等请注明原作者
## 2. 使用说明 ## 2. 使用说明
* ### [视频演示-Bilibili](https://www.bilibili.com/video/BV1qq421A7aF/)
* (1) 安装[Windows版微信](https://pc.weixin.qq.com/),并且登陆 * (1) 安装[Windows版微信](https://pc.weixin.qq.com/)
* (2) 在[Releases](https://github.com/tech-shrimp/WechatMoments/releases)下载压缩包wechat_moments.zip * (2) 在release下载压缩包wechat_moments.zip
* (3) 解压文件夹(路径不要包含中文) * (3) 解压文件夹(路径不要包含中文)
* (4) 管理员身份运行wechat_moments.exe并按提示操作 * (4) 管理员身份运行wechat_moments.exe并按提示操作
* (5) 如发生异常,重启微信,重启软件 * (5) 如发生异常,重启微信,重启软件
@ -36,47 +28,34 @@ http://shmmsns.qpic.cn/mmsns/uGxMq1C4wvppcjBbyweK796GtT1hH3LGISYajZ2v7C11XhHk5ic
* 可以根据联系人,朋友圈时间进行过滤导出 * 可以根据联系人,朋友圈时间进行过滤导出
* 强依赖微信Windows客户端只提供windows版本 * 强依赖微信Windows客户端只提供windows版本
* 只测试过python3.11+Win10/Win11其他环境随缘 * 只测试过python3.11+Win10/Win11其他环境随缘
* 软件只能导出在电脑微信**浏览过**的朋友圈记录
* 浏览朋友圈方法,参考[电脑微信浏览朋友圈](/doc/manual_guide.md)
* ![主界面.png](/doc/pic/主界面.png)
## 2. 已知问题 ## 2. 已知问题
* 视频下载不稳定视频可能不全如有解决方案欢迎PR * 视频下载不稳定如有解决方案欢迎PR
* 只能开小号导出自己朋友圈如有解决方案欢迎PR
* HTML页面比较原始 * HTML页面比较原始
* 音乐等朋友圈格式不支持
* 自动浏览朋友圈的功能不稳定如有解决方案欢迎PR * 自动浏览朋友圈的功能不稳定如有解决方案欢迎PR
## 3. 常见问题与解决方法 ## 3. 常见问题与解决方法
问:图片为什么无法导出
- 答2024年5月微信对图片进行了加密2024年后的朋友圈数据清先浏览朋友圈点开图片缓存到本地再使用此工具才能导出图片。
问:怎么导出自己朋友圈
- 答:登陆另外一个账户搜自己,详见[电脑微信浏览朋友圈](/doc/manual_guide.md)
- 目前没有其他方案主要我不知道怎么在电脑端查看自己的朋友圈如有解决方案欢迎PR
问:为什么导出的数据不全? 问:为什么导出的数据不全?
- 答:软件只能导出在电脑微信**浏览过**的朋友圈记录,未浏览过的无法导出。 - 答:软件只能导出在电脑微信**浏览过**的朋友圈记录,未浏览过的无法导出。
问:怎么在电脑微信浏览朋友圈? 问:怎么在电脑微信浏览朋友圈?
- 答软件提供了两种自动浏览朋友圈的方法第一种浏览全部缺点是最多只能刷到前100天。第二种浏览单个朋友没有时间限制。 - 答软件提供了两种自动浏览朋友圈的方法第一种浏览全部缺点是最多只能刷到前100天。第二种浏览单个朋友没有时间限制。
问:自动浏览单个朋友功能失效! 问:自动浏览单个朋友圈功能失效!
- 答:可以手动操作,也可以替换图片提高成功率,详见文档[电脑微信浏览朋友圈](/doc/manual_guide.md)<br/> - 答:可以手动操作,也可以替换图片提高成功率,详见文档[电脑微信浏览朋友圈](/doc/manual_guide.md)<br/>
问:为什么视频没法播放 问:为什么视频没法
- 答请使用Chrome浏览器打开html文件。或者勾选视频转码获得更多浏览器的兼容性。 - 答请使用Chrome浏览器打开html文件。或者勾选视频转码获得更多浏览器的兼容性。
问:能不能导出聊天记录?
- 答:导出聊天记录请使用这个软件[https://github.com/LC044/WeChatMsg](https://github.com/LC044/WeChatMsg)
## 4. 更新计划 ## 4. 更新计划
* 导出点赞,评论等 * 导出点赞,评论等
* HTML网页功能增强过滤排序等功能 * HTML网页功能增强过滤排序等功能
* 支持更多的朋友圈格式(音乐分享等)
* 其他导出格式Word, PDF等 * 其他导出格式Word, PDF等
* 佛系开发 随缘更新 * 佛系开发 随缘更新
@ -93,24 +72,28 @@ http://shmmsns.qpic.cn/mmsns/uGxMq1C4wvppcjBbyweK796GtT1hH3LGISYajZ2v7C11XhHk5ic
* 编译为可执行文件: 使用Github Action(.github/workflows/main.yml) * 编译为可执行文件: 使用Github Action(.github/workflows/main.yml)
* 微信数据库解密见项目:[https://github.com/xaoyaoo/PyWxDump](https://github.com/xaoyaoo/PyWxDump) * 微信数据库解密见项目:[https://github.com/xaoyaoo/PyWxDump](https://github.com/xaoyaoo/PyWxDump)
# 三、免责声明 # 三、免责声明
### 1. 使用目的 ### 1. 使用目的
* 本项目仅供学习交流使用,无收费项目,不用于盈利,**请勿用于非法用途**,否则后果自负。 * 本项目仅供学习交流使用,本项目无收费项目,不用于盈利,**请勿用于非法用途**,否则后果自负。
* 本项目只能导出**自己有权查看**的朋友圈数据,无其他越权功能。 * 本项目只能导出**自己有权查看**的朋友圈数据,无其他越权功能。
* 用户理解并同意,任何违反法律法规、侵犯他人合法权益的行为,均与本项目及其开发者无关,后果由用户自行承担。 * 用户理解并同意,任何违反法律法规、侵犯他人合法权益的行为,均与本项目及其开发者无关,后果由用户自行承担。
* 禁止利用本项目的相关技术从事非法测试或渗透,禁止利用本项目的相关代码或相关技术从事任何非法工作 * 禁止利用本项目的相关技术从事非法测试或渗透,禁止利用本项目的相关代码或相关技术从事任何非法工作
### 2. 使用期限
### 2. 操作规范 * 您应该在下载保存编译使用本项目的24小时内删除本项目的源代码和编译出的程序超出此期限的任何使用行为一概与本项目及其开发者无关。
### 3. 操作规范
* 本项目仅允许在授权情况下对朋友圈进行备份与查看,严禁用于非法目的,否则自行承担所有相关责任;用户如因违反此规定而引发的任何法律责任,将由用户自行承担,与本项目及其开发者无关。 * 本项目仅允许在授权情况下对朋友圈进行备份与查看,严禁用于非法目的,否则自行承担所有相关责任;用户如因违反此规定而引发的任何法律责任,将由用户自行承担,与本项目及其开发者无关。
* 严禁用于窃取他人隐私,否则自行承担所有相关责任。 * 严禁用于窃取他人隐私,否则自行承担所有相关责任。
### 3. 免责声明接受 ### 4. 免责声明接受
* 下载、保存、进一步浏览源代码或者下载安装、编译使用本程序,表示你同意本警告,并承诺遵守它 * 下载、保存、进一步浏览源代码或者下载安装、编译使用本程序,表示你同意本警告,并承诺遵守它;
# 四、致谢 # 四、致谢

View File

@ -49,7 +49,7 @@ class Sns:
return None return None
try: try:
lock.acquire(True) lock.acquire(True)
sql = '''select UserName, Content, FeedId from FeedsV20 where CreateTime>=? sql = '''select UserName, Content from FeedsV20 where CreateTime>=?
and CreateTime<=? order by CreateTime desc''' and CreateTime<=? order by CreateTime desc'''
self.cursor.execute(sql, [start_time, end_time]) self.cursor.execute(sql, [start_time, end_time])
res = self.cursor.fetchall() res = self.cursor.fetchall()
@ -58,19 +58,6 @@ class Sns:
return res return res
def get_comment_by_feed_id(self, feed_id):
if not self.open_flag:
return None
try:
lock.acquire(True)
sql = '''select FromUserName, CommentType, Content from CommentV20 where FeedId=?
order by CreateTime desc'''
self.cursor.execute(sql, [feed_id])
res = self.cursor.fetchall()
finally:
lock.release()
return res
def get_cover_url(self) -> Optional[str]: def get_cover_url(self) -> Optional[str]:
if not self.open_flag: if not self.open_flag:
return None return None

View File

@ -1,116 +0,0 @@
import hashlib
import os
import shutil
import subprocess
import sys
import traceback
from datetime import date
from pathlib import Path
import filetype
import log
class ImageDecrypter:
def __init__(self, gui: 'Gui', file_path):
self.file_path = file_path
self.gui = gui
self.sns_cache_path = file_path + "/FileStorage/Sns/Cache"
@staticmethod
def get_output_path(dir_name, md5, duration):
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# 这是到_internal文件夹
resource_dir = getattr(sys, '_MEIPASS')
# 获取_internal上一级文件夹再拼接
return os.path.join(os.path.dirname(resource_dir), 'output', dir_name, 'videos', f'{md5}_{duration}.mp4')
else:
return os.path.join(os.getcwd(), 'output', dir_name, 'videos', f'{md5}_{duration}.mp4')
@staticmethod
def calculate_md5(file_path):
with open(file_path, "rb") as f:
file_content = f.read()
return hashlib.md5(file_content).hexdigest()
@staticmethod
def get_all_month_between_dates(start_date, end_date) -> list[str]:
result = []
current_date = start_date
while current_date <= end_date:
# 打印当前日期的年份和月份
result.append(current_date.strftime("%Y-%m"))
year = current_date.year + (current_date.month // 12)
month = current_date.month % 12 + 1
# 更新current_date到下个月的第一天
current_date = date(year, month, 1)
return result
@staticmethod
def decode(magic, buf):
return bytearray([b ^ magic for b in list(buf)])
@staticmethod
def guess_image_encoding_magic(buf):
header_code, check_code = 0xff, 0xd8
# 微信图片加密方法对字节逐一“异或”,即 源文件^magic(未知数)=加密后文件
# 已知jpg的头字节是0xff将0xff与加密文件的头字节做异或运算求解magic码
magic = header_code ^ list(buf)[0] if buf else 0x00
# 尝试使用magic码解密如果第二字节符合jpg特质则图片解密成功
_, code = ImageDecrypter.decode(magic, buf[:2])
if check_code == code:
return magic
def decrypt_images(self, exporter, start_date, end_date, dir_name) -> None:
"""将图片文件从缓存中复制出来,重命名为{主图字节数}_{缩略图字节数}.jpg
duration单位为秒
"""
months = self.get_all_month_between_dates(start_date, end_date)
total_files = 0
processed_files = 0
for month in months:
source_dir = self.sns_cache_path + "/" + month
total_files = total_files + len(list(Path(source_dir).rglob('*')))
for month in months:
source_dir = self.sns_cache_path + "/" + month
for file in Path(source_dir).rglob('*'):
# 排除缩略图
if not exporter.stop_flag and file.is_file() and not file.name.endswith('_t'):
try:
with open(file, 'rb') as f:
buff = bytearray(f.read())
magic = self.guess_image_encoding_magic(buff)
if magic:
os.makedirs(f"output/{dir_name}/images/{month}/", exist_ok=True)
os.makedirs(f"output/{dir_name}/thumbs/{month}/", exist_ok=True)
main_file_size = file.stat().st_size
thumb_file_size = 0
# 找到对应缩略图
thumb_file = Path(f'{source_dir}/{file.name}_t')
if thumb_file.exists():
thumb_file_size = thumb_file.stat().st_size
# 读缩略图加密
with open(thumb_file, 'rb') as f:
thumb_buff = bytearray(f.read())
# 写缩略图
thumb_destination = (f"output/{dir_name}/thumbs/{month}/"
f"{main_file_size}_{thumb_file_size}.jpg")
with open(thumb_destination, 'wb') as f:
new_thumb_buff = self.decode(magic, thumb_buff)
f.write(new_thumb_buff)
destination = (f"output/{dir_name}/images/{month}/"
f"{main_file_size}_{thumb_file_size}.jpg")
with open(destination, 'wb') as f:
new_buf = self.decode(magic, buff)
f.write(new_buf)
except Exception:
traceback.print_exc()
processed_files = processed_files + 1
# 15%的进度作为处理图片使用
progress = round(processed_files / total_files * 15)
self.gui.update_export_progressbar(progress)

View File

@ -127,6 +127,6 @@ class VideoDecrypter:
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
processed_files = processed_files + 1 processed_files = processed_files + 1
# 15%的进度作为处理视频使用 + 15%(处理图像) # 前30%的进度作为 处理视频使用
progress = round(processed_files / total_files * 15 + 15) progress = round(processed_files / total_files * 30)
self.gui.update_export_progressbar(progress) self.gui.update_export_progressbar(progress)

View File

@ -4,9 +4,8 @@
![朋友圈页面.png](/doc/pic/朋友圈页面.png)<br/> ![朋友圈页面.png](/doc/pic/朋友圈页面.png)<br/>
## 二、浏览单个朋友 ## 二、浏览单个朋友
* 此方法没有最大天数限制<br/> * 此方法没有最大天数限制<br/>
* 打开搜一搜<br/> * 打开搜一搜,点击朋友圈<br/>
![打开搜一搜.png](/doc/pic/打开搜一搜.png)<br/> ![打开搜一搜.png](/doc/pic/打开搜一搜.png)<br/>
* 点击朋友圈选项卡<br/>
![打开朋友圈.png](/doc/pic/打开朋友圈.png)<br/> ![打开朋友圈.png](/doc/pic/打开朋友圈.png)<br/>
* 输入数字1点击搜索<br/> * 输入数字1点击搜索<br/>
![点击搜索.png](/doc/pic/点击搜索.png)<br/> ![点击搜索.png](/doc/pic/点击搜索.png)<br/>
@ -16,9 +15,6 @@
![点击完成.png](/doc/pic/点击完成.png)<br/> ![点击完成.png](/doc/pic/点击完成.png)<br/>
* 输入一个**中文**的问号 再次点击搜索<br/> * 输入一个**中文**的问号 再次点击搜索<br/>
![中文问号.png](/doc/pic/中文问号.png)<br/> ![中文问号.png](/doc/pic/中文问号.png)<br/>
* 搜一搜有**最大展示数量限制**,如果展示不全,可以限定一下搜索时间<br/>
![限定时间.png](/doc/pic/限定时间.png)<br/>
## 三、提高自动化操作成功率 ## 三、提高自动化操作成功率
打开项目 resource/auto_gui文件夹有四个图片<br/> 打开项目 resource/auto_gui文件夹有四个图片<br/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@ -1,8 +0,0 @@
from dataclasses import dataclass
@dataclass
class Comment:
from_user_name: str
comment_type: int
content: str

View File

@ -22,16 +22,12 @@ class Url:
type: str = field(metadata=config(field_name="@type")) type: str = field(metadata=config(field_name="@type"))
text: str = field(metadata=config(field_name="#text"), default="") text: str = field(metadata=config(field_name="#text"), default="")
md5: str = field(metadata=config(field_name="@md5"), default="") md5: str = field(metadata=config(field_name="@md5"), default="")
token: str = field(metadata=config(field_name="@token"), default="")
enc_idx: str = field(metadata=config(field_name="@enc_idx"), default="")
@dataclass_json @dataclass_json
@dataclass @dataclass
class Thumb: class Thumb:
type: str = field(metadata=config(field_name="@type")) type: str = field(metadata=config(field_name="@type"))
text: str = field(metadata=config(field_name="#text")) text: str = field(metadata=config(field_name="#text"))
token: str = field(metadata=config(field_name="@token"), default="")
enc_idx: str = field(metadata=config(field_name="@enc_idx"), default="")
@dataclass_json @dataclass_json
@ -43,8 +39,6 @@ class Media:
thumb: Optional[Thumb] = None thumb: Optional[Thumb] = None
thumbUrl: Optional[str] = None thumbUrl: Optional[str] = None
videoDuration: Optional[str] = None videoDuration: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
@dataclass_json @dataclass_json
@dataclass @dataclass
@ -67,7 +61,6 @@ class ContentObject:
contentStyle: int contentStyle: int
contentUrl: Optional[str] = "" contentUrl: Optional[str] = ""
title: Optional[str] = "" title: Optional[str] = ""
description: Optional[str] = ""
mediaList: Optional[MediaList] = None mediaList: Optional[MediaList] = None
# 视频号消息 # 视频号消息
finderFeed: Optional[FinderFeed] = None finderFeed: Optional[FinderFeed] = None
@ -95,13 +88,6 @@ class TimelineObject:
beijing_timezone = timezone(timedelta(hours=8)) beijing_timezone = timezone(timedelta(hours=8))
time_formatted = dt.astimezone(beijing_timezone).strftime('%Y-%m-%d %H:%M:%S') time_formatted = dt.astimezone(beijing_timezone).strftime('%Y-%m-%d %H:%M:%S')
return time_formatted return time_formatted
@property
def create_year_month(self)->str:
dt = datetime.fromtimestamp(self.createTime, timezone.utc)
# 转换为北京时间UTC+8
beijing_timezone = timezone(timedelta(hours=8))
time_formatted = dt.astimezone(beijing_timezone).strftime('%Y-%m')
return time_formatted
@dataclass_json @dataclass_json

View File

@ -3,11 +3,9 @@ import json
import shutil import shutil
import threading import threading
import time import time
from typing import Tuple
import xmltodict import xmltodict
from entity.comment import Comment
from entity.contact import Contact from entity.contact import Contact
from exporter.avatar_exporter import AvatarExporter from exporter.avatar_exporter import AvatarExporter
from exporter.emoji_exporter import EmojiExporter from exporter.emoji_exporter import EmojiExporter
@ -40,35 +38,10 @@ def get_img_css(size: int) -> str:
return f'width:5rem;height:5rem;float:left;margin-bottom:0.2rem;margin-right:0.2rem;{img_style}' return f'width:5rem;height:5rem;float:left;margin-bottom:0.2rem;margin-right:0.2rem;{img_style}'
def is_music_msg(msg: MomentMsg) -> bool:
"""判断一个msg是否为音乐分享
"""
if msg.timelineObject.ContentObject and msg.timelineObject.ContentObject.mediaList and msg.timelineObject.ContentObject.mediaList.media:
media = msg.timelineObject.ContentObject.mediaList.media[0]
if media.type == '5':
return True
return False
def get_music_info(msg: MomentMsg) -> Tuple[str, str, str]:
"""获取音乐标题,演唱者,音乐源
"""
title = ""
musician = ""
src = ""
if msg.timelineObject.ContentObject and msg.timelineObject.ContentObject.mediaList and msg.timelineObject.ContentObject.mediaList.media:
media = msg.timelineObject.ContentObject.mediaList.media[0]
title = media.title
musician = media.description
if media.url:
src = media.url.text
return title, musician, src
class HtmlExporter(threading.Thread): class HtmlExporter(threading.Thread):
def __init__(self, gui: 'Gui', dir_name: str, contacts_map: dict[str, Contact], begin_date: datetime.date, def __init__(self, gui: 'Gui', dir_name: str, contacts_map: dict[str, Contact], begin_date: datetime.date,
end_date: datetime.date, convert_video: int): end_date: datetime.date, download_pic: int, convert_video: int):
self.dir_name = dir_name self.dir_name = dir_name
if Path(f"output/{self.dir_name}").exists(): if Path(f"output/{self.dir_name}").exists():
shutil.rmtree(f"output/{self.dir_name}") shutil.rmtree(f"output/{self.dir_name}")
@ -84,6 +57,7 @@ class HtmlExporter(threading.Thread):
self.contacts_map = contacts_map self.contacts_map = contacts_map
self.begin_date = begin_date self.begin_date = begin_date
self.end_date = end_date self.end_date = end_date
self.download_pic = download_pic
self.convert_video = convert_video self.convert_video = convert_video
self.stop_flag = False self.stop_flag = False
super().__init__() super().__init__()
@ -105,17 +79,15 @@ class HtmlExporter(threading.Thread):
from app.DataBase import sns_db from app.DataBase import sns_db
cover_url = sns_db.get_cover_url() cover_url = sns_db.get_cover_url()
if cover_url: if cover_url:
cover_path = self.image_exporter.save_image((cover_url, "", ""), 'image') cover_path = self.image_exporter.save_image(cover_url, 'image')
self.html_head = self.html_head.replace("{cover_path}", cover_path) self.html_head = self.html_head.replace("{cover_path}", cover_path)
self.file.write(self.html_head) self.file.write(self.html_head)
# 加一天 # 加一天
end_date = self.end_date + datetime.timedelta(days=1) end_date = self.end_date + datetime.timedelta(days=1)
begin_time = time.mktime( begin_time = time.mktime(datetime.datetime(self.begin_date.year, self.begin_date.month, self.begin_date.day).timetuple())
datetime.datetime(self.begin_date.year, self.begin_date.month, self.begin_date.day).timetuple())
end_time = time.mktime(datetime.datetime(end_date.year, end_date.month, end_date.day).timetuple()) end_time = time.mktime(datetime.datetime(end_date.year, end_date.month, end_date.day).timetuple())
self.gui.image_decrypter.decrypt_images(self, self.begin_date, end_date, self.dir_name)
self.gui.video_decrypter.decrypt_videos(self, self.begin_date, end_date, self.dir_name, self.convert_video) self.gui.video_decrypter.decrypt_videos(self, self.begin_date, end_date, self.dir_name, self.convert_video)
@ -123,12 +95,7 @@ class HtmlExporter(threading.Thread):
for index, message_data in enumerate(message_datas): for index, message_data in enumerate(message_datas):
if not self.stop_flag: if not self.stop_flag:
if message_data[0] in self.contacts_map: if message_data[0] in self.contacts_map:
comments_datas = sns_db.get_comment_by_feed_id(message_data[2]) self.export_msg(message_data[1], self.contacts_map, self.download_pic)
comments: list[Comment] = []
for c in comments_datas:
contact = Comment(c[0], c[1], c[2])
comments.append(contact)
self.export_msg(message_data[1], comments, self.contacts_map)
# 更新进度条 前30%视频处理 后70%其他处理 # 更新进度条 前30%视频处理 后70%其他处理
progress = round(index / len(message_datas) * 70) progress = round(index / len(message_datas) * 70)
self.gui.update_export_progressbar(30 + progress) self.gui.update_export_progressbar(30 + progress)
@ -139,7 +106,7 @@ class HtmlExporter(threading.Thread):
def stop(self) -> None: def stop(self) -> None:
self.stop_flag = True self.stop_flag = True
def export_msg(self, message: str, comments: list[Comment], contacts_map: dict[str, Contact]) -> None: def export_msg(self, message: str, contacts_map: dict[str, Contact], download_pic: int) -> None:
LOG.info(message) LOG.info(message)
# force_list: 强制要求转media为list # force_list: 强制要求转media为list
@ -157,7 +124,7 @@ class HtmlExporter(threading.Thread):
remark = contact.remark if contact.remark else contact.nickName remark = contact.remark if contact.remark else contact.nickName
# 朋友圈图片 # 朋友圈图片
images = self.image_exporter.get_images(msg) images = self.image_exporter.get_images(msg, download_pic)
# 朋友圈视频 # 朋友圈视频
videos = self.video_exporter.get_videos(msg) videos = self.video_exporter.get_videos(msg)
@ -190,33 +157,12 @@ class HtmlExporter(threading.Thread):
html += f' <div class ="text" >{msg.timelineObject.ContentObject.title}</div>\n' html += f' <div class ="text" >{msg.timelineObject.ContentObject.title}</div>\n'
html += ' </div >\n' html += ' </div >\n'
html += ' </a>\n' html += ' </a>\n'
# 音乐
elif is_music_msg(msg):
title, musician, src = get_music_info(msg)
html += f' <a href="{msg.timelineObject.ContentObject.contentUrl}" target="_blank">\n'
html += ' <div class ="music_link" >\n'
html += ' <div class ="music_des" >\n'
if images:
thumb_path, image_path = images[0]
html += f' <img src = "{thumb_path}"/>\n'
html += ' <div class = "music_title_musician">\n'
html += f' <div class = "music_title">{title}</div>\n'
html += f' <div class = "music_musician">{musician}</div>\n'
html += ' </div>\n'
html += ' </div>\n'
html += f' <audio class = "music_audio" controls>'
html += f' <source src="{src}" type="audio/mpeg">'
html += f' </audio>'
html += ' </div >\n'
html += ' </a>\n'
# 视频号 # 视频号
elif msg.timelineObject.ContentObject.finderFeed: elif msg.timelineObject.ContentObject.finderFeed:
html += f' <div style="width:10rem; overflow:hidden">\n' html += f' <div style="width:10rem; overflow:hidden">\n'
# 视频号图片 # 视频号图片
thumb_path = self.image_exporter.get_finder_images(msg) thumb_path = self.image_exporter.get_finder_images(msg)
html += f""" <img src=\"{thumb_path}\" onclick=\"openWarningOverlay(event)\" html += f' <img src="{thumb_path}" onclick="openWarningOverlay(event)" style="width:10rem;height:10rem;object-fit:cover;cursor:pointer;"/>\n'
style=\"width:10rem;height:10rem;object-fit:cover;cursor:pointer;\"/>\n"""
html += ' </div>\n' html += ' </div>\n'
# 视频号说明 # 视频号说明
@ -229,8 +175,7 @@ class HtmlExporter(threading.Thread):
else: else:
html += f' <div style="{get_img_div_css(len(images))}">\n' html += f' <div style="{get_img_div_css(len(images))}">\n'
for thumb_path, image_path in images: for thumb_path, image_path in images:
html += f""" <img src="{thumb_path}" full_img="{image_path}" onclick="openFullSize(event)" html += f' <img src="{thumb_path}" full_img="{image_path}" onclick="openFullSize(event)" style="{get_img_css(len(images))}"/>\n'
style="{get_img_css(len(images))}"/>\n"""
html += ' </div>\n' html += ' </div>\n'
html += ' <div>\n' html += ' <div>\n'

View File

@ -1,8 +1,7 @@
import os import os
from io import BytesIO import re
from pathlib import Path
from typing import Tuple, Optional from typing import Tuple, Optional
from PIL import Image
from entity.moment_msg import MomentMsg, Media from entity.moment_msg import MomentMsg, Media
import requests import requests
import uuid import uuid
@ -16,52 +15,19 @@ class ImageExporter:
if not os.path.exists(f'output/{self.dir_name}/images/'): if not os.path.exists(f'output/{self.dir_name}/images/'):
os.mkdir(f'output/{self.dir_name}/images/') os.mkdir(f'output/{self.dir_name}/images/')
@staticmethod def save_image(self, url: str, img_type: str) -> str:
def get_image(link: tuple) -> bytes:
""" 向微信服务器请求图片
"""
url, idx, token = link
# 如果需要传递token
if idx and token:
url = f'{url}?idx={idx}&token={token}'
response = requests.get(url)
if response.ok:
return response.content
def save_image(self, link: tuple, img_type: str) -> str:
""" 下载图片 """ 下载图片
""" """
file_name = uuid.uuid4()
if not (img_type == 'image' or img_type == 'thumb'): if not (img_type == 'image' or img_type == 'thumb'):
raise Exception("img_type 参数非法") raise Exception("img_type 参数非法")
content = self.get_image(link) file_name = uuid.uuid4()
if content: response = requests.get(url)
if response.ok:
with open(f'output/{self.dir_name}/{img_type}s/{file_name}.jpg', 'wb') as file: with open(f'output/{self.dir_name}/{img_type}s/{file_name}.jpg', 'wb') as file:
file.write(content) file.write(response.content)
return f'{img_type}s/{file_name}.jpg' return f'{img_type}s/{file_name}.jpg'
@staticmethod def get_images(self, msg: MomentMsg, download_pic: int) -> list[Tuple]:
def get_image_thumb_and_url(media_item, content_style:int) -> Tuple[Tuple, Tuple]:
""" 获取图片的缩略图与大图的链接
"""
thumb = None
url = None
# 普通图片
if media_item.type == "2":
thumb = (media_item.thumb.text, media_item.thumb.enc_idx, media_item.thumb.token)
url = (media_item.url.text, media_item.url.enc_idx, media_item.url.token)
# 微信音乐
if media_item.type == "5":
thumb = (media_item.thumb.text, "", "")
url = (media_item.thumb.text, "", "")
# 超链接类型
if content_style == 3:
thumb = (media_item.thumb.text, "", "")
url = (media_item.thumb.text, "", "")
return thumb, url
def get_images(self, msg: MomentMsg) -> list[Tuple]:
""" 获取一条朋友圈的全部图像, 返回值是一个元组列表 """ 获取一条朋友圈的全部图像, 返回值是一个元组列表
[(缩略图路径原图路径)(缩略图路径原图路径)] [(缩略图路径原图路径)(缩略图路径原图路径)]
""" """
@ -71,43 +37,13 @@ class ImageExporter:
media = msg.timelineObject.ContentObject.mediaList.media media = msg.timelineObject.ContentObject.mediaList.media
for media_item in media: for media_item in media:
thumb, url = self.get_image_thumb_and_url(media_item, msg.timelineObject.ContentObject.contentStyle) if media_item.type == "2":
if thumb and url: if download_pic:
thumb_path = None thumb_path = self.save_image(media_item.thumb.text, 'thumb')
image_path = None image_path = self.save_image(media_item.url.text, 'image')
# 主图内容
image_content = self.get_image(url)
# 如果拿不到主图数据
if not image_content:
continue
# 如果在腾讯服务器获取到jpg图片
if image_content[:2] == b'\xff\xd8':
file_name = uuid.uuid4()
with open(f'output/{self.dir_name}/images/{file_name}.jpg', 'wb') as file:
file.write(image_content)
image_path = f'images/{file_name}.jpg'
# 缩略图内容
thumb_content = self.get_image(thumb)
file_name = uuid.uuid4()
with open(f'output/{self.dir_name}/thumbs/{file_name}.jpg', 'wb') as file:
file.write(thumb_content)
thumb_path = f'thumbs/{file_name}.jpg'
# 如果图片已加密,进入缓存图片中匹配
else: else:
# 获取2024-06格式的时间 thumb_path = media_item.thumb.text
month = msg.timelineObject.create_year_month image_path = media_item.url.text
image_content = self.get_image(url)
thumb_content = self.get_image(thumb)
# 从缓存里找文件
image_file = Path((f"output/{self.dir_name}/images/{month}/"
f"{len(image_content)}_{len(thumb_content)}.jpg"))
thumb_file = Path((f"output/{self.dir_name}/thumbs/{month}/"
f"{len(image_content)}_{len(thumb_content)}.jpg"))
if image_file.exists():
image_path = image_file.resolve()
if thumb_file.exists():
thumb_path = thumb_file.resolve()
if thumb_path and image_path: if thumb_path and image_path:
results.append((thumb_path, image_path)) results.append((thumb_path, image_path))
@ -125,5 +61,5 @@ class ImageExporter:
media = msg.timelineObject.ContentObject.finderFeed.mediaList.media media = msg.timelineObject.ContentObject.finderFeed.mediaList.media
for media_item in media: for media_item in media:
thumb_path = self.save_image((media_item.thumbUrl, "", ""), 'thumb') thumb_path = self.save_image(media_item.thumbUrl, 'thumb')
return thumb_path return thumb_path

View File

@ -7,7 +7,6 @@ from pathlib import Path
from typing import Optional from typing import Optional
import tkcalendar import tkcalendar
from decrypter.db_decrypt import DatabaseDecrypter from decrypter.db_decrypt import DatabaseDecrypter
from decrypter.image_decrypt import ImageDecrypter
from decrypter.video_decrypt import VideoDecrypter from decrypter.video_decrypt import VideoDecrypter
from gui.auto_scroll_guide import AutoScrollGuide from gui.auto_scroll_guide import AutoScrollGuide
from gui.auto_scrolls_single_guide import AutoScrollSingleGuide from gui.auto_scrolls_single_guide import AutoScrollSingleGuide
@ -36,6 +35,8 @@ class Gui:
self.confirm_button_text = None self.confirm_button_text = None
self.succeed_label_2 = None self.succeed_label_2 = None
self.succeed_label = None self.succeed_label = None
self.download_pic_var = Optional[tkinter.IntVar]
self.download_pic = None
self.auto_scroll_button_text = None self.auto_scroll_button_text = None
self.warning_label = None self.warning_label = None
self.root = None self.root = None
@ -56,7 +57,6 @@ class Gui:
self.decrypt_note_text = None self.decrypt_note_text = None
self.account_info = None self.account_info = None
self.video_decrypter = None self.video_decrypter = None
self.image_decrypter = None
self.export_dir_name = None self.export_dir_name = None
self.exporting = False self.exporting = False
# 1: 自动滚动数据 2: 解密数据库 3: 导出 # 1: 自动滚动数据 2: 解密数据库 3: 导出
@ -167,9 +167,6 @@ class Gui:
self.next_step_button.place_forget() self.next_step_button.place_forget()
# 初始化视频导出器 # 初始化视频导出器
self.video_decrypter = VideoDecrypter(self, self.account_info.get("filePath")) self.video_decrypter = VideoDecrypter(self, self.account_info.get("filePath"))
# 初始化图片导出器
self.image_decrypter = ImageDecrypter(self, self.account_info.get("filePath"))
self.page_stage = self.page_stage + 1 self.page_stage = self.page_stage + 1
@ -219,6 +216,10 @@ class Gui:
self.end_calendar = tkcalendar.DateEntry(master=self.root, locale="zh_CN", maxdate=datetime.now()) self.end_calendar = tkcalendar.DateEntry(master=self.root, locale="zh_CN", maxdate=datetime.now())
self.end_calendar.place(relx=0.65, rely=0.3) self.end_calendar.place(relx=0.65, rely=0.3)
self.download_pic_var = tkinter.IntVar(value=0)
self.download_pic = tkinter.ttk.Checkbutton(self.root, text='下载图片', variable=self.download_pic_var)
self.download_pic.place(relx=0.65, rely=0.4)
ToolTip(self.download_pic, "将图片下载到电脑上,网页\n可离线查看,导出速度变慢")
self.convert_video_var = tkinter.IntVar(value=0) self.convert_video_var = tkinter.IntVar(value=0)
self.convert_video = tkinter.ttk.Checkbutton(self.root, text='视频转码', variable=self.convert_video_var) self.convert_video = tkinter.ttk.Checkbutton(self.root, text='视频转码', variable=self.convert_video_var)
@ -277,7 +278,7 @@ class Gui:
# 导出线程 # 导出线程
self.html_exporter_thread = HtmlExporter(self, self.export_dir_name, contact_map, self.html_exporter_thread = HtmlExporter(self, self.export_dir_name, contact_map,
self.begin_calendar.get_date(), self.end_calendar.get_date(), self.begin_calendar.get_date(), self.end_calendar.get_date(),
self.convert_video_var.get()) self.download_pic_var.get(), self.convert_video_var.get())
self.html_exporter_thread.start() self.html_exporter_thread.start()
def update_decrypt_progressbar(self, progress): def update_decrypt_progressbar(self, progress):

View File

@ -60,15 +60,6 @@ padding-top:0.2rem;
.out_link {background-color:#F7F7F7 ;font-size:0.7rem ;width:95% ;color:#41454D ;overflow: hidden; cursor:pointer;} .out_link {background-color:#F7F7F7 ;font-size:0.7rem ;width:95% ;color:#41454D ;overflow: hidden; cursor:pointer;}
.out_link img{width:2.5rem ;height:2.5rem ;margin:0.5rem 0.5rem ;float: left;} .out_link img{width:2.5rem ;height:2.5rem ;margin:0.5rem 0.5rem ;float: left;}
.music_link {background-color:#F7F7F7 ;font-size:0.7rem ;width:95% ;color:#41454D ;overflow: hidden; cursor:pointer; display: flex; flex-direction:column}
.music_des {width:100%;display: flex;}
.music_title_musician {width:100%;margin-left: 1.5rem;display: flex;flex-direction:column}
.music_title {font-size:1.0rem;margin-top: 0.5rem;}
.music_musician {font-size:0.85rem;margin-top: 0.5rem;}
.music_link img{width:4rem ;height:4rem ;margin:0.2rem 0.2rem ;float: left;}
.music_audio {height: 1.5rem; width: 95%;}
.text{float: left;margin-top: 1.1rem;width:80% ;} .text{float: left;margin-top: 1.1rem;width:80% ;}
.pl{margin-top:0.5rem ;font-size:0.6rem ;color:#80858c ;clear: both;} .pl{margin-top:0.5rem ;font-size:0.6rem ;color:#80858c ;clear: both;}
.pl span{display:inline-block ;width:2rem ;} .pl span{display:inline-block ;width:2rem ;}

View File

@ -1,6 +1,4 @@
import datetime import datetime
from decrypter.image_decrypt import ImageDecrypter
from decrypter.video_decrypt import VideoDecrypter from decrypter.video_decrypt import VideoDecrypter
import threading import threading
from time import sleep from time import sleep
@ -14,8 +12,8 @@ def stage_3():
gui_thread.start() gui_thread.start()
gui.init_export_page() gui.init_export_page()
gui.begin_calendar.set_date(datetime.date(2024, 5, 6)) gui.begin_calendar.set_date(datetime.date(2024, 3, 6))
gui.end_calendar.set_date(datetime.date(2024, 5, 6)) gui.end_calendar.set_date(datetime.date(2024, 3, 6))
# 后台读取微信信息 # 后台读取微信信息
# 请等待完全接入微信再进行UI操作 # 请等待完全接入微信再进行UI操作
@ -33,7 +31,6 @@ def stage_3():
gui.waiting_label.config(text="微信已登录") gui.waiting_label.config(text="微信已登录")
# 初始化视频导出器 # 初始化视频导出器
gui.video_decrypter = VideoDecrypter(gui, gui.account_info.get("filePath")) gui.video_decrypter = VideoDecrypter(gui, gui.account_info.get("filePath"))
gui.image_decrypter = ImageDecrypter(gui, gui.account_info.get("filePath"))
gui.waiting_label.place_forget() gui.waiting_label.place_forget()
break break