Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
958c33900d | ||
![]() |
25a95a1b15 | ||
![]() |
d263a661fd | ||
![]() |
259bfddcc0 | ||
![]() |
d6dced3c57 | ||
![]() |
d1782c346e | ||
![]() |
1c5d6368a3 | ||
![]() |
3e00811e1c | ||
![]() |
225123d291 | ||
![]() |
644e7549f1 | ||
![]() |
05f672442a | ||
![]() |
2ebb15efdf | ||
![]() |
536d3806fa | ||
![]() |
b9fa5ee17a | ||
![]() |
eb8ba50e7b | ||
![]() |
9550df696c | ||
![]() |
af16f2abc5 | ||
![]() |
53ad53ecba | ||
![]() |
3f311f3731 |
47
README.md
47
README.md
@ -4,6 +4,14 @@
|
||||
|
||||
# 一、项目介绍
|
||||
|
||||
|
||||
## 0. 求助
|
||||
四月底微信对朋友圈图片进行了加密,如果知道如何解密的大佬请指点一下。(帮忙Issue或者PR)
|
||||
通过抓包可知朋友圈图片是这样一个请求,现在问题是请求过来的数据不知道是怎么加密的
|
||||
http://shmmsns.qpic.cn/mmsns/uGxMq1C4wvppcjBbyweK796GtT1hH3LGISYajZ2v7C11XhHk5icyDUXcWNSPk2MooeIa8Es5hXP0/0?idx=1&token=WSEN6qDsKwV8A02w3onOGQYfxnkibdqSOkmHhZGNB4DFumlE9p1vp0e0xjHoXlbbXRzwnQia6X5t3Annc4oqTuDg
|
||||
<br>
|
||||
加密后字节数与原图片一致,可能是某种流式数据加密。key可能是11079841251888681493。
|
||||
|
||||
## 1. 项目简介
|
||||
|
||||
* [WechatMoments](https://github.com/tech-shrimp/WechatMoments)是一款运行在Windows上的,备份导出朋友圈为html的工具
|
||||
@ -12,9 +20,9 @@
|
||||
* 分发,宣传,二次开发等请注明原作者
|
||||
|
||||
## 2. 使用说明
|
||||
|
||||
* (1) 安装[Windows版微信](https://pc.weixin.qq.com/)
|
||||
* (2) 在release下载压缩包wechat_moments.zip
|
||||
* ### [视频演示-Bilibili](https://www.bilibili.com/video/BV1qq421A7aF/)
|
||||
* (1) 安装[Windows版微信](https://pc.weixin.qq.com/),并且登陆
|
||||
* (2) 在[Releases](https://github.com/tech-shrimp/WechatMoments/releases)下载压缩包wechat_moments.zip
|
||||
* (3) 解压文件夹(路径不要包含中文)
|
||||
* (4) 管理员身份运行wechat_moments.exe,并按提示操作
|
||||
* (5) 如发生异常,重启微信,重启软件
|
||||
@ -28,34 +36,47 @@
|
||||
* 可以根据联系人,朋友圈时间进行过滤导出
|
||||
* 强依赖微信Windows客户端,只提供windows版本
|
||||
* 只测试过python3.11+Win10/Win11,其他环境随缘
|
||||
* 软件只能导出在电脑微信**浏览过**的朋友圈记录
|
||||
* 浏览朋友圈方法,参考[电脑微信浏览朋友圈](/doc/manual_guide.md)
|
||||
* 
|
||||
|
||||
## 2. 已知问题
|
||||
|
||||
* 视频下载不稳定(如有解决方案欢迎PR)
|
||||
* 视频下载不稳定,视频可能不全(如有解决方案欢迎PR)
|
||||
* 只能开小号导出自己朋友圈(如有解决方案欢迎PR)
|
||||
* HTML页面比较原始
|
||||
* 音乐等朋友圈格式不支持
|
||||
* 自动浏览朋友圈的功能不稳定(如有解决方案欢迎PR)
|
||||
|
||||
|
||||
## 3. 常见问题与解决方法
|
||||
问:图片为什么无法导出
|
||||
- 答:2024年5月微信对图片进行了加密,2024年后的朋友圈数据清先浏览朋友圈,点开图片缓存到本地,再使用此工具才能导出图片。
|
||||
|
||||
问:怎么导出自己朋友圈
|
||||
- 答:登陆另外一个账户搜自己,详见[电脑微信浏览朋友圈](/doc/manual_guide.md)
|
||||
- 目前没有其他方案,主要我不知道怎么在电脑端查看自己的朋友圈,如有解决方案欢迎PR
|
||||
|
||||
问:为什么导出的数据不全?
|
||||
- 答:软件只能导出在电脑微信**浏览过**的朋友圈记录,未浏览过的无法导出。
|
||||
|
||||
问:怎么在电脑微信浏览朋友圈?
|
||||
- 答:软件提供了两种自动浏览朋友圈的方法,第一种浏览全部,缺点是最多只能刷到前100天。第二种浏览单个朋友,没有时间限制。
|
||||
|
||||
问:自动浏览单个朋友圈功能失效!
|
||||
问:自动浏览单个朋友功能失效!
|
||||
- 答:可以手动操作,也可以替换图片提高成功率,详见文档[电脑微信浏览朋友圈](/doc/manual_guide.md)<br/>
|
||||
|
||||
问:为什么视频没法看?
|
||||
问:为什么视频没法播放?
|
||||
- 答:请使用Chrome浏览器打开html文件。或者勾选视频转码,获得更多浏览器的兼容性。
|
||||
|
||||
问:能不能导出聊天记录?
|
||||
- 答:导出聊天记录请使用这个软件[https://github.com/LC044/WeChatMsg](https://github.com/LC044/WeChatMsg)
|
||||
|
||||
|
||||
## 4. 更新计划
|
||||
|
||||
* 导出点赞,评论等
|
||||
* HTML网页功能增强,过滤排序等功能
|
||||
* 支持更多的朋友圈格式(音乐分享等)
|
||||
* 其他导出格式(Word, PDF等)
|
||||
* 佛系开发 随缘更新
|
||||
|
||||
@ -72,28 +93,24 @@
|
||||
* 编译为可执行文件: 使用Github Action(.github/workflows/main.yml)
|
||||
* 微信数据库解密见项目:[https://github.com/xaoyaoo/PyWxDump](https://github.com/xaoyaoo/PyWxDump)
|
||||
|
||||
|
||||
# 三、免责声明
|
||||
|
||||
### 1. 使用目的
|
||||
|
||||
* 本项目仅供学习交流使用,本项目无收费项目,不用于盈利,**请勿用于非法用途**,否则后果自负。
|
||||
* 本项目仅供学习交流使用,无收费项目,不用于盈利,**请勿用于非法用途**,否则后果自负。
|
||||
* 本项目只能导出**自己有权查看**的朋友圈数据,无其他越权功能。
|
||||
* 用户理解并同意,任何违反法律法规、侵犯他人合法权益的行为,均与本项目及其开发者无关,后果由用户自行承担。
|
||||
* 禁止利用本项目的相关技术从事非法测试或渗透,禁止利用本项目的相关代码或相关技术从事任何非法工作
|
||||
|
||||
### 2. 使用期限
|
||||
|
||||
* 您应该在下载保存,编译使用本项目的24小时内,删除本项目的源代码和(编译出的)程序;超出此期限的任何使用行为,一概与本项目及其开发者无关。
|
||||
|
||||
### 3. 操作规范
|
||||
### 2. 操作规范
|
||||
|
||||
* 本项目仅允许在授权情况下对朋友圈进行备份与查看,严禁用于非法目的,否则自行承担所有相关责任;用户如因违反此规定而引发的任何法律责任,将由用户自行承担,与本项目及其开发者无关。
|
||||
* 严禁用于窃取他人隐私,否则自行承担所有相关责任。
|
||||
|
||||
### 4. 免责声明接受
|
||||
### 3. 免责声明接受
|
||||
|
||||
* 下载、保存、进一步浏览源代码或者下载安装、编译使用本程序,表示你同意本警告,并承诺遵守它;
|
||||
* 下载、保存、进一步浏览源代码或者下载安装、编译使用本程序,表示你同意本警告,并承诺遵守它。
|
||||
|
||||
# 四、致谢
|
||||
|
||||
|
@ -49,7 +49,7 @@ class Sns:
|
||||
return None
|
||||
try:
|
||||
lock.acquire(True)
|
||||
sql = '''select UserName, Content from FeedsV20 where CreateTime>=?
|
||||
sql = '''select UserName, Content, FeedId from FeedsV20 where CreateTime>=?
|
||||
and CreateTime<=? order by CreateTime desc'''
|
||||
self.cursor.execute(sql, [start_time, end_time])
|
||||
res = self.cursor.fetchall()
|
||||
@ -58,6 +58,19 @@ class Sns:
|
||||
|
||||
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]:
|
||||
if not self.open_flag:
|
||||
return None
|
||||
|
116
decrypter/image_decrypt.py
Normal file
116
decrypter/image_decrypt.py
Normal file
@ -0,0 +1,116 @@
|
||||
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)
|
@ -127,6 +127,6 @@ class VideoDecrypter:
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
processed_files = processed_files + 1
|
||||
# 前30%的进度作为 处理视频使用
|
||||
progress = round(processed_files / total_files * 30)
|
||||
# 15%的进度作为处理视频使用 + 15%(处理图像)
|
||||
progress = round(processed_files / total_files * 15 + 15)
|
||||
self.gui.update_export_progressbar(progress)
|
||||
|
@ -4,8 +4,9 @@
|
||||
<br/>
|
||||
## 二、浏览单个朋友
|
||||
* 此方法没有最大天数限制<br/>
|
||||
* 打开搜一搜,点击朋友圈<br/>
|
||||
* 打开搜一搜<br/>
|
||||
<br/>
|
||||
* 点击朋友圈选项卡<br/>
|
||||
<br/>
|
||||
* 输入数字1,点击搜索<br/>
|
||||
<br/>
|
||||
@ -15,6 +16,9 @@
|
||||
<br/>
|
||||
* 输入一个**中文**的问号 ? 再次点击搜索<br/>
|
||||
<br/>
|
||||
* 搜一搜有**最大展示数量限制**,如果展示不全,可以限定一下搜索时间<br/>
|
||||
<br/>
|
||||
|
||||
|
||||
## 三、提高自动化操作成功率
|
||||
打开项目 resource/auto_gui文件夹,有四个图片<br/>
|
||||
|
BIN
doc/pic/主界面.png
Normal file
BIN
doc/pic/主界面.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 61 KiB |
BIN
doc/pic/限定时间.png
Normal file
BIN
doc/pic/限定时间.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
8
entity/comment.py
Normal file
8
entity/comment.py
Normal file
@ -0,0 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Comment:
|
||||
from_user_name: str
|
||||
comment_type: int
|
||||
content: str
|
@ -22,12 +22,16 @@ class Url:
|
||||
type: str = field(metadata=config(field_name="@type"))
|
||||
text: str = field(metadata=config(field_name="#text"), 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
|
||||
class Thumb:
|
||||
type: str = field(metadata=config(field_name="@type"))
|
||||
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
|
||||
@ -39,6 +43,8 @@ class Media:
|
||||
thumb: Optional[Thumb] = None
|
||||
thumbUrl: Optional[str] = None
|
||||
videoDuration: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
@ -61,6 +67,7 @@ class ContentObject:
|
||||
contentStyle: int
|
||||
contentUrl: Optional[str] = ""
|
||||
title: Optional[str] = ""
|
||||
description: Optional[str] = ""
|
||||
mediaList: Optional[MediaList] = None
|
||||
# 视频号消息
|
||||
finderFeed: Optional[FinderFeed] = None
|
||||
@ -88,6 +95,13 @@ class TimelineObject:
|
||||
beijing_timezone = timezone(timedelta(hours=8))
|
||||
time_formatted = dt.astimezone(beijing_timezone).strftime('%Y-%m-%d %H:%M:%S')
|
||||
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
|
||||
|
@ -3,9 +3,11 @@ import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from typing import Tuple
|
||||
|
||||
import xmltodict
|
||||
|
||||
from entity.comment import Comment
|
||||
from entity.contact import Contact
|
||||
from exporter.avatar_exporter import AvatarExporter
|
||||
from exporter.emoji_exporter import EmojiExporter
|
||||
@ -38,10 +40,35 @@ 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}'
|
||||
|
||||
|
||||
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):
|
||||
|
||||
def __init__(self, gui: 'Gui', dir_name: str, contacts_map: dict[str, Contact], begin_date: datetime.date,
|
||||
end_date: datetime.date, download_pic: int, convert_video: int):
|
||||
end_date: datetime.date, convert_video: int):
|
||||
self.dir_name = dir_name
|
||||
if Path(f"output/{self.dir_name}").exists():
|
||||
shutil.rmtree(f"output/{self.dir_name}")
|
||||
@ -57,7 +84,6 @@ class HtmlExporter(threading.Thread):
|
||||
self.contacts_map = contacts_map
|
||||
self.begin_date = begin_date
|
||||
self.end_date = end_date
|
||||
self.download_pic = download_pic
|
||||
self.convert_video = convert_video
|
||||
self.stop_flag = False
|
||||
super().__init__()
|
||||
@ -79,15 +105,17 @@ class HtmlExporter(threading.Thread):
|
||||
from app.DataBase import sns_db
|
||||
cover_url = sns_db.get_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.file.write(self.html_head)
|
||||
# 加一天
|
||||
end_date = self.end_date + datetime.timedelta(days=1)
|
||||
begin_time = time.mktime(datetime.datetime(self.begin_date.year, self.begin_date.month, self.begin_date.day).timetuple())
|
||||
begin_time = time.mktime(
|
||||
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())
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -95,7 +123,12 @@ class HtmlExporter(threading.Thread):
|
||||
for index, message_data in enumerate(message_datas):
|
||||
if not self.stop_flag:
|
||||
if message_data[0] in self.contacts_map:
|
||||
self.export_msg(message_data[1], self.contacts_map, self.download_pic)
|
||||
comments_datas = sns_db.get_comment_by_feed_id(message_data[2])
|
||||
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%其他处理
|
||||
progress = round(index / len(message_datas) * 70)
|
||||
self.gui.update_export_progressbar(30 + progress)
|
||||
@ -106,7 +139,7 @@ class HtmlExporter(threading.Thread):
|
||||
def stop(self) -> None:
|
||||
self.stop_flag = True
|
||||
|
||||
def export_msg(self, message: str, contacts_map: dict[str, Contact], download_pic: int) -> None:
|
||||
def export_msg(self, message: str, comments: list[Comment], contacts_map: dict[str, Contact]) -> None:
|
||||
|
||||
LOG.info(message)
|
||||
# force_list: 强制要求转media为list
|
||||
@ -124,7 +157,7 @@ class HtmlExporter(threading.Thread):
|
||||
remark = contact.remark if contact.remark else contact.nickName
|
||||
|
||||
# 朋友圈图片
|
||||
images = self.image_exporter.get_images(msg, download_pic)
|
||||
images = self.image_exporter.get_images(msg)
|
||||
|
||||
# 朋友圈视频
|
||||
videos = self.video_exporter.get_videos(msg)
|
||||
@ -157,12 +190,33 @@ class HtmlExporter(threading.Thread):
|
||||
html += f' <div class ="text" >{msg.timelineObject.ContentObject.title}</div>\n'
|
||||
html += ' </div >\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:
|
||||
html += f' <div style="width:10rem; overflow:hidden">\n'
|
||||
# 视频号图片
|
||||
thumb_path = self.image_exporter.get_finder_images(msg)
|
||||
html += f' <img src="{thumb_path}" onclick="openWarningOverlay(event)" style="width:10rem;height:10rem;object-fit:cover;cursor:pointer;"/>\n'
|
||||
html += f""" <img src=\"{thumb_path}\" onclick=\"openWarningOverlay(event)\"
|
||||
style=\"width:10rem;height:10rem;object-fit:cover;cursor:pointer;\"/>\n"""
|
||||
html += ' </div>\n'
|
||||
|
||||
# 视频号说明
|
||||
@ -175,7 +229,8 @@ class HtmlExporter(threading.Thread):
|
||||
else:
|
||||
html += f' <div style="{get_img_div_css(len(images))}">\n'
|
||||
for thumb_path, image_path in images:
|
||||
html += f' <img src="{thumb_path}" full_img="{image_path}" onclick="openFullSize(event)" style="{get_img_css(len(images))}"/>\n'
|
||||
html += f""" <img src="{thumb_path}" full_img="{image_path}" onclick="openFullSize(event)"
|
||||
style="{get_img_css(len(images))}"/>\n"""
|
||||
html += ' </div>\n'
|
||||
|
||||
html += ' <div>\n'
|
||||
|
@ -1,7 +1,8 @@
|
||||
import os
|
||||
import re
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional
|
||||
|
||||
from PIL import Image
|
||||
from entity.moment_msg import MomentMsg, Media
|
||||
import requests
|
||||
import uuid
|
||||
@ -15,19 +16,52 @@ class ImageExporter:
|
||||
if not os.path.exists(f'output/{self.dir_name}/images/'):
|
||||
os.mkdir(f'output/{self.dir_name}/images/')
|
||||
|
||||
def save_image(self, url: str, img_type: str) -> str:
|
||||
""" 下载图片
|
||||
@staticmethod
|
||||
def get_image(link: tuple) -> bytes:
|
||||
""" 向微信服务器请求图片
|
||||
"""
|
||||
if not (img_type == 'image' or img_type == 'thumb'):
|
||||
raise Exception("img_type 参数非法")
|
||||
file_name = uuid.uuid4()
|
||||
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'):
|
||||
raise Exception("img_type 参数非法")
|
||||
content = self.get_image(link)
|
||||
if content:
|
||||
with open(f'output/{self.dir_name}/{img_type}s/{file_name}.jpg', 'wb') as file:
|
||||
file.write(response.content)
|
||||
file.write(content)
|
||||
return f'{img_type}s/{file_name}.jpg'
|
||||
|
||||
def get_images(self, msg: MomentMsg, download_pic: int) -> list[Tuple]:
|
||||
@staticmethod
|
||||
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]:
|
||||
""" 获取一条朋友圈的全部图像, 返回值是一个元组列表
|
||||
[(缩略图路径,原图路径),(缩略图路径,原图路径)]
|
||||
"""
|
||||
@ -37,13 +71,43 @@ class ImageExporter:
|
||||
|
||||
media = msg.timelineObject.ContentObject.mediaList.media
|
||||
for media_item in media:
|
||||
if media_item.type == "2":
|
||||
if download_pic:
|
||||
thumb_path = self.save_image(media_item.thumb.text, 'thumb')
|
||||
image_path = self.save_image(media_item.url.text, 'image')
|
||||
thumb, url = self.get_image_thumb_and_url(media_item, msg.timelineObject.ContentObject.contentStyle)
|
||||
if thumb and url:
|
||||
thumb_path = None
|
||||
image_path = None
|
||||
# 主图内容
|
||||
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:
|
||||
thumb_path = media_item.thumb.text
|
||||
image_path = media_item.url.text
|
||||
# 获取2024-06格式的时间
|
||||
month = msg.timelineObject.create_year_month
|
||||
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:
|
||||
results.append((thumb_path, image_path))
|
||||
|
||||
@ -61,5 +125,5 @@ class ImageExporter:
|
||||
|
||||
media = msg.timelineObject.ContentObject.finderFeed.mediaList.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
|
||||
|
13
gui/gui.py
13
gui/gui.py
@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
import tkcalendar
|
||||
from decrypter.db_decrypt import DatabaseDecrypter
|
||||
from decrypter.image_decrypt import ImageDecrypter
|
||||
from decrypter.video_decrypt import VideoDecrypter
|
||||
from gui.auto_scroll_guide import AutoScrollGuide
|
||||
from gui.auto_scrolls_single_guide import AutoScrollSingleGuide
|
||||
@ -35,8 +36,6 @@ class Gui:
|
||||
self.confirm_button_text = None
|
||||
self.succeed_label_2 = None
|
||||
self.succeed_label = None
|
||||
self.download_pic_var = Optional[tkinter.IntVar]
|
||||
self.download_pic = None
|
||||
self.auto_scroll_button_text = None
|
||||
self.warning_label = None
|
||||
self.root = None
|
||||
@ -57,6 +56,7 @@ class Gui:
|
||||
self.decrypt_note_text = None
|
||||
self.account_info = None
|
||||
self.video_decrypter = None
|
||||
self.image_decrypter = None
|
||||
self.export_dir_name = None
|
||||
self.exporting = False
|
||||
# 1: 自动滚动数据 2: 解密数据库 3: 导出
|
||||
@ -167,6 +167,9 @@ class Gui:
|
||||
self.next_step_button.place_forget()
|
||||
# 初始化视频导出器
|
||||
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
|
||||
|
||||
@ -216,10 +219,6 @@ class Gui:
|
||||
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.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 = tkinter.ttk.Checkbutton(self.root, text='视频转码', variable=self.convert_video_var)
|
||||
@ -278,7 +277,7 @@ class Gui:
|
||||
# 导出线程
|
||||
self.html_exporter_thread = HtmlExporter(self, self.export_dir_name, contact_map,
|
||||
self.begin_calendar.get_date(), self.end_calendar.get_date(),
|
||||
self.download_pic_var.get(), self.convert_video_var.get())
|
||||
self.convert_video_var.get())
|
||||
self.html_exporter_thread.start()
|
||||
|
||||
def update_decrypt_progressbar(self, progress):
|
||||
|
@ -60,6 +60,15 @@ padding-top:0.2rem;
|
||||
.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;}
|
||||
|
||||
.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% ;}
|
||||
.pl{margin-top:0.5rem ;font-size:0.6rem ;color:#80858c ;clear: both;}
|
||||
.pl span{display:inline-block ;width:2rem ;}
|
||||
|
7
test.py
7
test.py
@ -1,4 +1,6 @@
|
||||
import datetime
|
||||
|
||||
from decrypter.image_decrypt import ImageDecrypter
|
||||
from decrypter.video_decrypt import VideoDecrypter
|
||||
import threading
|
||||
from time import sleep
|
||||
@ -12,8 +14,8 @@ def stage_3():
|
||||
gui_thread.start()
|
||||
gui.init_export_page()
|
||||
|
||||
gui.begin_calendar.set_date(datetime.date(2024, 3, 6))
|
||||
gui.end_calendar.set_date(datetime.date(2024, 3, 6))
|
||||
gui.begin_calendar.set_date(datetime.date(2024, 5, 6))
|
||||
gui.end_calendar.set_date(datetime.date(2024, 5, 6))
|
||||
|
||||
# 后台读取微信信息
|
||||
# 请等待完全接入微信再进行UI操作
|
||||
@ -31,6 +33,7 @@ def stage_3():
|
||||
gui.waiting_label.config(text="微信已登录")
|
||||
# 初始化视频导出器
|
||||
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()
|
||||
break
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user