├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── app └── DataBase │ ├── __init__.py │ ├── micro_msg.py │ ├── misc.py │ ├── msg.py │ └── sns.py ├── decrypter ├── db_decrypt.py ├── image_decrypt.py └── video_decrypt.py ├── doc ├── manual_guide.md └── pic │ ├── 中文问号.png │ ├── 主界面.png │ ├── 四幅图.png │ ├── 展开朋友.png │ ├── 打开搜一搜.png │ ├── 打开朋友圈.png │ ├── 朋友圈页面.png │ ├── 点击完成.png │ ├── 点击搜索.png │ └── 限定时间.png ├── entity ├── comment.py ├── contact.py └── moment_msg.py ├── exporter ├── avatar_exporter.py ├── emoji_exporter.py ├── html_exporter.py ├── image_exporter.py └── video_exporter.py ├── gui ├── __init__.py ├── auto_scroll_guide.py ├── auto_scrolls_single_guide.py ├── gui.py ├── listbox_with_search.py └── tool_tip.py ├── helper ├── auto_scroll.py └── auto_scroll_single.py ├── log.py ├── main.py ├── main.spec ├── requirements.txt ├── resource ├── auto_gui │ ├── 1366 │ │ ├── complete.png │ │ ├── friends.png │ │ ├── moments_tab.png │ │ └── search_button.png │ ├── 1600 │ │ ├── complete.png │ │ ├── friends.png │ │ ├── moments_tab.png │ │ └── search_button.png │ ├── 1920 │ │ ├── complete.png │ │ ├── friends.png │ │ ├── moments_tab.png │ │ └── search_button.png │ ├── 2560_100 │ │ ├── complete.png │ │ ├── friends.png │ │ ├── moments_tab.png │ │ └── search_button.png │ ├── 2560_125 │ │ ├── complete.png │ │ ├── friends.png │ │ ├── moments_tab.png │ │ └── search_button.png │ ├── 2560_175 │ │ ├── complete.png │ │ ├── friends.png │ │ ├── moments_tab.png │ │ └── search_button.png │ ├── complete.png │ ├── friends.png │ ├── moments_tab.png │ └── search_button.png ├── ffmpeg.exe ├── ffprobe.exe ├── gui_pictures │ ├── open_moments_guide -原始.png │ ├── open_moments_guide.png │ ├── open_search_guide-原始.png │ └── open_search_guide.png ├── template.html └── template │ ├── css │ ├── bootstrap.min.css │ └── index.css │ ├── icons │ ├── comment-downarrow.svg │ ├── comment-uparrow.svg │ └── massage.svg │ └── js │ ├── bootstrap.min.js │ ├── index.js │ └── jquery-2.1.1.min.js └── test.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: 打包发布 5 | 6 | on: 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | pyinstaller-build: 14 | runs-on: windows-latest 15 | steps: 16 | - name: Create Executable 17 | uses: sayyid5416/pyinstaller@v1 18 | with: 19 | python_ver: '3.11' 20 | requirements: 'requirements.txt' 21 | spec: 'main.spec' 22 | upload_exe_with_name: 'wechat_moments' 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | log/ 3 | build/ 4 | dist/ 5 | output/ 6 | app/DataBase/Msg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [技术爬爬虾(B站 抖音 Youtube同名)] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #
WechatMoments
2 | 3 | # 微信朋友圈导出工具 4 | 5 | # 一、项目介绍 6 | 7 | 8 | ## 0. 求助 9 | 四月底微信对朋友圈图片进行了加密,如果知道如何解密的大佬请指点一下。(帮忙Issue或者PR) 10 | 通过抓包可知朋友圈图片是这样一个请求,现在问题是请求过来的数据不知道是怎么加密的 11 | http://shmmsns.qpic.cn/mmsns/uGxMq1C4wvppcjBbyweK796GtT1hH3LGISYajZ2v7C11XhHk5icyDUXcWNSPk2MooeIa8Es5hXP0/0?idx=1&token=WSEN6qDsKwV8A02w3onOGQYfxnkibdqSOkmHhZGNB4DFumlE9p1vp0e0xjHoXlbbXRzwnQia6X5t3Annc4oqTuDg 12 |
13 | 加密后字节数与原图片一致,可能是某种流式数据加密。key可能是11079841251888681493。 14 | 15 | ## 1. 项目简介 16 | 17 | * [WechatMoments](https://github.com/tech-shrimp/WechatMoments)是一款运行在Windows上的,备份导出朋友圈为html的工具 18 | * 作者:[技术爬爬虾](https://space.bilibili.com/316183842)(B站 抖音 Youtube同名)更多有趣实用项目请关注 19 | * 开源许可: Apache License 20 | * 分发,宣传,二次开发等请注明原作者 21 | 22 | ## 2. 使用说明 23 | * ### [视频演示-Bilibili](https://www.bilibili.com/video/BV1qq421A7aF/) 24 | * (1) 安装[Windows版微信](https://pc.weixin.qq.com/),并且登陆 25 | * (2) 在[Releases](https://github.com/tech-shrimp/WechatMoments/releases)下载压缩包wechat_moments.zip 26 | * (3) 解压文件夹(路径不要包含中文) 27 | * (4) 管理员身份运行wechat_moments.exe,并按提示操作 28 | * (5) 如发生异常,重启微信,重启软件 29 | 30 | # 二. 详细介绍 31 | 32 | ## 1. 核心功能 33 | 34 | * 导出微信朋友圈数据为HTML 35 | * 可以下载图片/视频离线查看,永久保存 36 | * 可以根据联系人,朋友圈时间进行过滤导出 37 | * 强依赖微信Windows客户端,只提供windows版本 38 | * 只测试过python3.11+Win10/Win11,其他环境随缘 39 | * 软件只能导出在电脑微信**浏览过**的朋友圈记录 40 | * 浏览朋友圈方法,参考[电脑微信浏览朋友圈](/doc/manual_guide.md) 41 | * ![主界面.png](/doc/pic/主界面.png) 42 | 43 | ## 2. 已知问题 44 | 45 | * 视频下载不稳定,视频可能不全(如有解决方案欢迎PR) 46 | * 只能开小号导出自己朋友圈(如有解决方案欢迎PR) 47 | * HTML页面比较原始 48 | * 自动浏览朋友圈的功能不稳定(如有解决方案欢迎PR) 49 | 50 | 51 | ## 3. 常见问题与解决方法 52 | 问:图片为什么无法导出 53 | - 答:2024年5月微信对图片进行了加密,2024年后的朋友圈数据清先浏览朋友圈,点开图片缓存到本地,再使用此工具才能导出图片。 54 | 55 | 问:怎么导出自己朋友圈 56 | - 答:登陆另外一个账户搜自己,详见[电脑微信浏览朋友圈](/doc/manual_guide.md) 57 | - 目前没有其他方案,主要我不知道怎么在电脑端查看自己的朋友圈,如有解决方案欢迎PR 58 | 59 | 问:为什么导出的数据不全? 60 | - 答:软件只能导出在电脑微信**浏览过**的朋友圈记录,未浏览过的无法导出。 61 | 62 | 问:怎么在电脑微信浏览朋友圈? 63 | - 答:软件提供了两种自动浏览朋友圈的方法,第一种浏览全部,缺点是最多只能刷到前100天。第二种浏览单个朋友,没有时间限制。 64 | 65 | 问:自动浏览单个朋友功能失效! 66 | - 答:可以手动操作,也可以替换图片提高成功率,详见文档[电脑微信浏览朋友圈](/doc/manual_guide.md)
67 | 68 | 问:为什么视频没法播放? 69 | - 答:请使用Chrome浏览器打开html文件。或者勾选视频转码,获得更多浏览器的兼容性。 70 | 71 | 问:能不能导出聊天记录? 72 | - 答:导出聊天记录请使用这个软件[https://github.com/LC044/WeChatMsg](https://github.com/LC044/WeChatMsg) 73 | 74 | 75 | ## 4. 更新计划 76 | 77 | * 导出点赞,评论等 78 | * HTML网页功能增强,过滤排序等功能 79 | * 支持更多的朋友圈格式(音乐分享等) 80 | * 其他导出格式(Word, PDF等) 81 | * 佛系开发 随缘更新 82 | 83 | ## 5. 问题反馈 84 | 85 | * 请直接提issue,或发送邮箱techshrimp@163.com 86 | * 请附上日志与软件截图,日志地址log\xxxx-xx-xx-output.log 87 | 88 | ## 6. 二次开发 89 | 90 | * Python环境: Python3.11 91 | * 安装依赖: pip install requirements.txt 92 | * 启动: python main.py 93 | * 编译为可执行文件: 使用Github Action(.github/workflows/main.yml) 94 | * 微信数据库解密见项目:[https://github.com/xaoyaoo/PyWxDump](https://github.com/xaoyaoo/PyWxDump) 95 | 96 | # 三、免责声明 97 | 98 | ### 1. 使用目的 99 | 100 | * 本项目仅供学习交流使用,无收费项目,不用于盈利,**请勿用于非法用途**,否则后果自负。 101 | * 本项目只能导出**自己有权查看**的朋友圈数据,无其他越权功能。 102 | * 用户理解并同意,任何违反法律法规、侵犯他人合法权益的行为,均与本项目及其开发者无关,后果由用户自行承担。 103 | * 禁止利用本项目的相关技术从事非法测试或渗透,禁止利用本项目的相关代码或相关技术从事任何非法工作 104 | 105 | 106 | ### 2. 操作规范 107 | 108 | * 本项目仅允许在授权情况下对朋友圈进行备份与查看,严禁用于非法目的,否则自行承担所有相关责任;用户如因违反此规定而引发的任何法律责任,将由用户自行承担,与本项目及其开发者无关。 109 | * 严禁用于窃取他人隐私,否则自行承担所有相关责任。 110 | 111 | ### 3. 免责声明接受 112 | 113 | * 下载、保存、进一步浏览源代码或者下载安装、编译使用本程序,表示你同意本警告,并承诺遵守它。 114 | 115 | # 四、致谢 116 | 117 | * PC微信工具:[https://github.com/xaoyaoo/PyWxDump](https://github.com/xaoyaoo/PyWxDump) 118 | * 留痕(聊天导出工具):[https://github.com/LC044/WeChatMsg](https://github.com/LC044/WeChatMsg) 119 | 120 | # 五、捐赠 121 | 122 | 如有帮助,请帮忙给B站视频点赞充电 123 | [技术爬爬虾](https://space.bilibili.com/316183842) -------------------------------------------------------------------------------- /app/DataBase/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | @File : __init__.py.py 4 | @Author : Shuaikang Zhou 5 | @Time : 2023/1/5 0:10 6 | @IDE : Pycharm 7 | @Version : Python3.10 8 | @comment : ··· 9 | """ 10 | from .micro_msg import MicroMsg 11 | from .misc import Misc 12 | from .msg import Msg 13 | from .sns import Sns 14 | 15 | misc_db = Misc() 16 | msg_db = Msg() 17 | micro_msg_db = MicroMsg() 18 | sns_db = Sns() 19 | 20 | 21 | __all__ = ['misc_db', 'micro_msg_db', 'msg_db', "sns_db"] 22 | -------------------------------------------------------------------------------- /app/DataBase/micro_msg.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import sqlite3 3 | import threading 4 | 5 | lock = threading.Lock() 6 | db_path = "./app/Database/Msg/MicroMsg.db" 7 | 8 | 9 | def singleton(cls): 10 | _instance = {} 11 | 12 | def inner(): 13 | if cls not in _instance: 14 | _instance[cls] = cls() 15 | return _instance[cls] 16 | 17 | return inner 18 | 19 | 20 | def is_database_exist(): 21 | return os.path.exists(db_path) 22 | 23 | @singleton 24 | class MicroMsg: 25 | def __init__(self): 26 | self.DB = None 27 | self.cursor = None 28 | self.open_flag = False 29 | self.init_database() 30 | 31 | def init_database(self): 32 | if not self.open_flag: 33 | if os.path.exists(db_path): 34 | self.DB = sqlite3.connect(db_path, check_same_thread=False) 35 | # '''创建游标''' 36 | self.cursor = self.DB.cursor() 37 | self.open_flag = True 38 | if lock.locked(): 39 | lock.release() 40 | 41 | def get_contact(self): 42 | if not self.open_flag: 43 | return None 44 | try: 45 | lock.acquire(True) 46 | sql = '''SELECT UserName, Alias, Type, Remark, NickName, PYInitial, RemarkPYInitial, ContactHeadImgUrl.smallHeadImgUrl, ContactHeadImgUrl.bigHeadImgUrl,ExTraBuf,COALESCE(ContactLabel.LabelName, 'None') AS labelName 47 | FROM Contact 48 | INNER JOIN ContactHeadImgUrl ON Contact.UserName = ContactHeadImgUrl.usrName 49 | LEFT JOIN ContactLabel ON Contact.LabelIDList = ContactLabel.LabelId 50 | WHERE (Type!=4 AND VerifyFlag=0) 51 | AND NickName != '' 52 | ORDER BY 53 | CASE 54 | WHEN RemarkPYInitial = '' THEN PYInitial 55 | ELSE RemarkPYInitial 56 | END ASC 57 | ''' 58 | self.cursor.execute(sql) 59 | result = self.cursor.fetchall() 60 | except sqlite3.OperationalError: 61 | # 解决ContactLabel表不存在的问题 62 | # lock.acquire(True) 63 | sql = ''' 64 | SELECT UserName, Alias, Type, Remark, NickName, PYInitial, RemarkPYInitial, ContactHeadImgUrl.smallHeadImgUrl, ContactHeadImgUrl.bigHeadImgUrl,ExTraBuf,"None" 65 | FROM Contact 66 | INNER JOIN ContactHeadImgUrl ON Contact.UserName = ContactHeadImgUrl.usrName 67 | WHERE (Type!=4 AND VerifyFlag=0) 68 | AND NickName != '' 69 | ORDER BY 70 | CASE 71 | WHEN RemarkPYInitial = '' THEN PYInitial 72 | ELSE RemarkPYInitial 73 | END ASC 74 | ''' 75 | self.cursor.execute(sql) 76 | result = self.cursor.fetchall() 77 | finally: 78 | lock.release() 79 | from app.DataBase import msg_db 80 | return msg_db.get_contact(result) 81 | 82 | def get_contact_by_username(self, username: object) -> object: 83 | if not self.open_flag: 84 | return None 85 | try: 86 | lock.acquire(True) 87 | sql = ''' 88 | SELECT UserName, Alias, Type, Remark, NickName, PYInitial, RemarkPYInitial, ContactHeadImgUrl.smallHeadImgUrl, ContactHeadImgUrl.bigHeadImgUrl,ExTraBuf,ContactLabel.LabelName 89 | FROM Contact 90 | INNER JOIN ContactHeadImgUrl ON Contact.UserName = ContactHeadImgUrl.usrName 91 | LEFT JOIN ContactLabel ON Contact.LabelIDList = ContactLabel.LabelId 92 | WHERE UserName = ? 93 | ''' 94 | self.cursor.execute(sql, [username]) 95 | result = self.cursor.fetchone() 96 | except sqlite3.OperationalError: 97 | # 解决ContactLabel表不存在的问题 98 | # lock.acquire(True) 99 | sql = ''' 100 | SELECT UserName, Alias, Type, Remark, NickName, PYInitial, RemarkPYInitial, ContactHeadImgUrl.smallHeadImgUrl, ContactHeadImgUrl.bigHeadImgUrl,ExTraBuf,"None" 101 | FROM Contact 102 | INNER JOIN ContactHeadImgUrl ON Contact.UserName = ContactHeadImgUrl.usrName 103 | WHERE UserName = ? 104 | ''' 105 | self.cursor.execute(sql, [username]) 106 | result = self.cursor.fetchone() 107 | finally: 108 | lock.release() 109 | 110 | return result 111 | 112 | def get_chatroom_info(self, chatroomname): 113 | ''' 114 | 获取群聊信息 115 | ''' 116 | if not self.open_flag: 117 | return None 118 | try: 119 | lock.acquire(True) 120 | sql = '''SELECT ChatRoomName, RoomData FROM ChatRoom WHERE ChatRoomName = ?''' 121 | self.cursor.execute(sql, [chatroomname]) 122 | result = self.cursor.fetchone() 123 | finally: 124 | lock.release() 125 | return result 126 | 127 | def close(self): 128 | if self.open_flag: 129 | try: 130 | lock.acquire(True) 131 | self.open_flag = False 132 | self.DB.close() 133 | finally: 134 | lock.release() 135 | 136 | def __del__(self): 137 | self.close() 138 | -------------------------------------------------------------------------------- /app/DataBase/misc.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import sqlite3 3 | import threading 4 | 5 | lock = threading.Lock() 6 | DB = None 7 | cursor = None 8 | db_path = "./app/Database/Msg/Misc.db" 9 | 10 | 11 | # db_path = './Msg/Misc.db' 12 | 13 | 14 | def singleton(cls): 15 | _instance = {} 16 | 17 | def inner(): 18 | if cls not in _instance: 19 | _instance[cls] = cls() 20 | return _instance[cls] 21 | 22 | return inner 23 | 24 | 25 | @singleton 26 | class Misc: 27 | def __init__(self): 28 | self.DB = None 29 | self.cursor = None 30 | self.open_flag = False 31 | self.init_database() 32 | 33 | def init_database(self): 34 | if not self.open_flag: 35 | if os.path.exists(db_path): 36 | self.DB = sqlite3.connect(db_path, check_same_thread=False) 37 | # '''创建游标''' 38 | self.cursor = self.DB.cursor() 39 | self.open_flag = True 40 | if lock.locked(): 41 | lock.release() 42 | 43 | def get_avatar_buffer(self, userName): 44 | if not self.open_flag: 45 | return None 46 | sql = ''' 47 | select smallHeadBuf 48 | from ContactHeadImg1 49 | where usrName=?; 50 | ''' 51 | if not self.open_flag: 52 | self.init_database() 53 | try: 54 | lock.acquire(True) 55 | self.cursor.execute(sql, [userName]) 56 | result = self.cursor.fetchall() 57 | if result: 58 | return result[0][0] 59 | finally: 60 | lock.release() 61 | return None 62 | 63 | def close(self): 64 | if self.open_flag: 65 | try: 66 | lock.acquire(True) 67 | self.open_flag = False 68 | self.DB.close() 69 | finally: 70 | lock.release() 71 | 72 | def __del__(self): 73 | self.close() 74 | 75 | -------------------------------------------------------------------------------- /app/DataBase/msg.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import random 3 | import sqlite3 4 | import threading 5 | import traceback 6 | db_path = "./app/Database/Msg/MSG.db" 7 | lock = threading.Lock() 8 | 9 | 10 | def is_database_exist(): 11 | return os.path.exists(db_path) 12 | 13 | 14 | def singleton(cls): 15 | _instance = {} 16 | 17 | def inner(): 18 | if cls not in _instance: 19 | _instance[cls] = cls() 20 | return _instance[cls] 21 | 22 | return inner 23 | @singleton 24 | class Msg: 25 | def __init__(self): 26 | self.DB = None 27 | self.cursor = None 28 | self.open_flag = False 29 | self.init_database() 30 | 31 | def init_database(self, path=None): 32 | global db_path 33 | if not self.open_flag: 34 | if path: 35 | db_path = path 36 | if os.path.exists(db_path): 37 | self.DB = sqlite3.connect(db_path, check_same_thread=False) 38 | # '''创建游标''' 39 | self.cursor = self.DB.cursor() 40 | self.open_flag = True 41 | if lock.locked(): 42 | lock.release() 43 | 44 | def get_contact(self, contacts): 45 | """这里查了一遍聊天记录,根据聊天记录最后一条按时间 46 | 对联系人进行排序 47 | """ 48 | if not self.open_flag: 49 | return None 50 | try: 51 | lock.acquire(True) 52 | sql = '''select StrTalker, MAX(CreateTime) from MSG group by StrTalker''' 53 | self.cursor.execute(sql) 54 | res = self.cursor.fetchall() 55 | finally: 56 | lock.release() 57 | res = {StrTalker: CreateTime for StrTalker, CreateTime in res} 58 | contacts = [list(cur_contact) for cur_contact in contacts] 59 | for i, cur_contact in enumerate(contacts): 60 | if cur_contact[0] in res: 61 | contacts[i].append(res[cur_contact[0]]) 62 | else: 63 | contacts[i].append(0) 64 | contacts.sort(key=lambda cur_contact: cur_contact[-1], reverse=True) 65 | return contacts 66 | 67 | def get_messages_calendar(self, username_): 68 | sql = ''' 69 | SELECT strftime('%Y-%m-%d',CreateTime,'unixepoch','localtime') as days 70 | from ( 71 | SELECT MsgSvrID, CreateTime 72 | FROM MSG 73 | WHERE StrTalker = ? 74 | ORDER BY CreateTime 75 | ) 76 | group by days 77 | ''' 78 | if not self.open_flag: 79 | print('数据库未就绪') 80 | return None 81 | try: 82 | lock.acquire(True) 83 | self.cursor.execute(sql, [username_]) 84 | result = self.cursor.fetchall() 85 | finally: 86 | lock.release() 87 | return [date[0] for date in result] 88 | 89 | def close(self): 90 | if self.open_flag: 91 | try: 92 | lock.acquire(True) 93 | self.open_flag = False 94 | self.DB.close() 95 | finally: 96 | lock.release() 97 | 98 | def __del__(self): 99 | self.close() 100 | 101 | -------------------------------------------------------------------------------- /app/DataBase/sns.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import random 3 | import sqlite3 4 | import threading 5 | import traceback 6 | from typing import Optional 7 | 8 | db_path = "./app/Database/Msg/Sns.db" 9 | lock = threading.Lock() 10 | 11 | 12 | def is_database_exist(): 13 | return os.path.exists(db_path) 14 | 15 | 16 | def singleton(cls): 17 | _instance = {} 18 | 19 | def inner(): 20 | if cls not in _instance: 21 | _instance[cls] = cls() 22 | return _instance[cls] 23 | 24 | return inner 25 | 26 | @singleton 27 | class Sns: 28 | def __init__(self): 29 | self.DB = None 30 | self.cursor = None 31 | self.open_flag = False 32 | self.init_database() 33 | 34 | def init_database(self, path=None): 35 | global db_path 36 | if not self.open_flag: 37 | if path: 38 | db_path = path 39 | if os.path.exists(db_path): 40 | self.DB = sqlite3.connect(db_path, check_same_thread=False) 41 | # '''创建游标''' 42 | self.cursor = self.DB.cursor() 43 | self.open_flag = True 44 | if lock.locked(): 45 | lock.release() 46 | 47 | def get_messages_in_time(self, start_time, end_time): 48 | if not self.open_flag: 49 | return None 50 | try: 51 | lock.acquire(True) 52 | sql = '''select UserName, Content, FeedId from FeedsV20 where CreateTime>=? 53 | and CreateTime<=? order by CreateTime desc''' 54 | self.cursor.execute(sql, [start_time, end_time]) 55 | res = self.cursor.fetchall() 56 | finally: 57 | lock.release() 58 | 59 | return res 60 | 61 | def get_comment_by_feed_id(self, feed_id): 62 | if not self.open_flag: 63 | return None 64 | try: 65 | lock.acquire(True) 66 | sql = '''select FromUserName, CommentType, Content from CommentV20 where FeedId=? 67 | order by CreateTime desc''' 68 | self.cursor.execute(sql, [feed_id]) 69 | res = self.cursor.fetchall() 70 | finally: 71 | lock.release() 72 | return res 73 | 74 | def get_cover_url(self) -> Optional[str]: 75 | if not self.open_flag: 76 | return None 77 | try: 78 | lock.acquire(True) 79 | sql = '''select StrValue from SnsConfigV20 where Key="6" ''' 80 | self.cursor.execute(sql) 81 | result = self.cursor.fetchall() 82 | if result: 83 | return result[0][0] 84 | finally: 85 | lock.release() 86 | return None 87 | 88 | 89 | def close(self): 90 | if self.open_flag: 91 | try: 92 | lock.acquire(True) 93 | self.open_flag = False 94 | self.DB.close() 95 | finally: 96 | lock.release() 97 | 98 | def __del__(self): 99 | self.close() 100 | 101 | 102 | if __name__ == '__main__': 103 | pass 104 | -------------------------------------------------------------------------------- /decrypter/db_decrypt.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | import tkinter 4 | import traceback 5 | 6 | from pywxdump import decrypt 7 | from log import LOG 8 | 9 | 10 | class DatabaseDecrypter: 11 | 12 | def __init__(self, gui: 'Gui', db_path, key): 13 | self.db_path = db_path 14 | self.key = key 15 | self.gui = gui 16 | # 指定需要解密的数据库 17 | self.db_list = ["MicroMsg.db", "Misc.db", "MSG.db", "Sns.db"] 18 | self.db_list.extend([f"MSG{i}.db" for i in range(0, 50)]) 19 | 20 | def merge_databases(self, source_paths, target_path): 21 | # 创建目标数据库连接 22 | target_conn = sqlite3.connect(target_path) 23 | target_cursor = target_conn.cursor() 24 | try: 25 | # 开始事务 26 | target_conn.execute("BEGIN;") 27 | for i, source_path in enumerate(source_paths): 28 | if not os.path.exists(source_path): 29 | continue 30 | db = sqlite3.connect(source_path) 31 | db.text_factory = str 32 | cursor = db.cursor() 33 | try: 34 | sql = ''' 35 | SELECT TalkerId,MsgsvrID,Type,SubType,IsSender,CreateTime,Sequence,StrTalker,StrContent,DisplayContent,BytesExtra,CompressContent 36 | FROM MSG; 37 | ''' 38 | cursor.execute(sql) 39 | result = cursor.fetchall() 40 | # 附加源数据库 41 | target_cursor.executemany( 42 | "INSERT INTO MSG " 43 | "(TalkerId,MsgsvrID,Type,SubType,IsSender,CreateTime,Sequence,StrTalker,StrContent,DisplayContent," 44 | "BytesExtra,CompressContent)" 45 | "VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", 46 | result) 47 | except: 48 | LOG.error(f'{source_path}数据库合并错误:\n{traceback.format_exc()}') 49 | cursor.close() 50 | db.close() 51 | # 提交事务 52 | target_conn.execute("COMMIT;") 53 | 54 | except Exception as e: 55 | # 发生异常时回滚事务 56 | target_conn.execute("ROLLBACK;") 57 | raise e 58 | 59 | finally: 60 | # 关闭目标数据库连接 61 | target_conn.close() 62 | 63 | def decrypt(self): 64 | 65 | output_dir = 'app/DataBase/Msg' 66 | os.makedirs(output_dir, exist_ok=True) 67 | tasks = [] 68 | if os.path.exists(self.db_path): 69 | for root, dirs, files in os.walk(self.db_path): 70 | for file in files: 71 | if '.db' == file[-3:] and file in self.db_list: 72 | in_path = os.path.join(root, file) 73 | output_path = os.path.join(output_dir, file) 74 | tasks.append([self.key, in_path, output_path]) 75 | for i, task in enumerate(tasks): 76 | flag, result = decrypt(*task) 77 | if not flag: 78 | LOG.error(result) 79 | progress = round((i+1) / len(tasks) * 100) 80 | self.gui.update_decrypt_progressbar(progress) 81 | 82 | target_database = "app/DataBase/Msg/MSG.db" 83 | # 源数据库文件列表 84 | source_databases = [f"app/DataBase/Msg/MSG{i}.db" for i in range(0, 50)] 85 | import shutil 86 | if os.path.exists(target_database): 87 | os.remove(target_database) 88 | try: 89 | shutil.copy2("app/DataBase/Msg/MSG0.db", target_database) # 使用一个数据库文件作为模板 90 | except FileNotFoundError: 91 | LOG.error(traceback.format_exc()) 92 | # 合并数据库 93 | try: 94 | self.merge_databases(source_databases, target_database) 95 | except FileNotFoundError: 96 | LOG.error(traceback.format_exc()) 97 | LOG.error("数据库不存在\n请检查微信版本是否为最新") 98 | 99 | # 解密完成 放开下一步按钮 100 | self.gui.decrypt_note_text.set("复制成功,请点击下一步") 101 | self.gui.next_step_button.config(state=tkinter.NORMAL) 102 | 103 | 104 | -------------------------------------------------------------------------------- /decrypter/image_decrypt.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import shutil 4 | import subprocess 5 | import sys 6 | import traceback 7 | from datetime import date 8 | from pathlib import Path 9 | import filetype 10 | 11 | import log 12 | 13 | 14 | class ImageDecrypter: 15 | 16 | def __init__(self, gui: 'Gui', file_path): 17 | self.file_path = file_path 18 | self.gui = gui 19 | self.sns_cache_path = file_path + "/FileStorage/Sns/Cache" 20 | 21 | @staticmethod 22 | def get_output_path(dir_name, md5, duration): 23 | if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): 24 | # 这是到_internal文件夹 25 | resource_dir = getattr(sys, '_MEIPASS') 26 | # 获取_internal上一级文件夹再拼接 27 | return os.path.join(os.path.dirname(resource_dir), 'output', dir_name, 'videos', f'{md5}_{duration}.mp4') 28 | else: 29 | return os.path.join(os.getcwd(), 'output', dir_name, 'videos', f'{md5}_{duration}.mp4') 30 | 31 | @staticmethod 32 | def calculate_md5(file_path): 33 | with open(file_path, "rb") as f: 34 | file_content = f.read() 35 | return hashlib.md5(file_content).hexdigest() 36 | 37 | @staticmethod 38 | def get_all_month_between_dates(start_date, end_date) -> list[str]: 39 | result = [] 40 | current_date = start_date 41 | while current_date <= end_date: 42 | # 打印当前日期的年份和月份 43 | result.append(current_date.strftime("%Y-%m")) 44 | year = current_date.year + (current_date.month // 12) 45 | month = current_date.month % 12 + 1 46 | # 更新current_date到下个月的第一天 47 | current_date = date(year, month, 1) 48 | return result 49 | 50 | @staticmethod 51 | def decode(magic, buf): 52 | return bytearray([b ^ magic for b in list(buf)]) 53 | 54 | @staticmethod 55 | def guess_image_encoding_magic(buf): 56 | header_code, check_code = 0xff, 0xd8 57 | # 微信图片加密方法对字节逐一“异或”,即 源文件^magic(未知数)=加密后文件 58 | # 已知jpg的头字节是0xff,将0xff与加密文件的头字节做异或运算求解magic码 59 | magic = header_code ^ list(buf)[0] if buf else 0x00 60 | # 尝试使用magic码解密,如果第二字节符合jpg特质,则图片解密成功 61 | _, code = ImageDecrypter.decode(magic, buf[:2]) 62 | if check_code == code: 63 | return magic 64 | 65 | def decrypt_images(self, exporter, start_date, end_date, dir_name) -> None: 66 | """将图片文件从缓存中复制出来,重命名为{主图字节数}_{缩略图字节数}.jpg 67 | duration单位为秒 68 | """ 69 | months = self.get_all_month_between_dates(start_date, end_date) 70 | 71 | total_files = 0 72 | processed_files = 0 73 | for month in months: 74 | source_dir = self.sns_cache_path + "/" + month 75 | total_files = total_files + len(list(Path(source_dir).rglob('*'))) 76 | 77 | for month in months: 78 | source_dir = self.sns_cache_path + "/" + month 79 | for file in Path(source_dir).rglob('*'): 80 | # 排除缩略图 81 | if not exporter.stop_flag and file.is_file() and not file.name.endswith('_t'): 82 | try: 83 | with open(file, 'rb') as f: 84 | buff = bytearray(f.read()) 85 | magic = self.guess_image_encoding_magic(buff) 86 | if magic: 87 | os.makedirs(f"output/{dir_name}/images/{month}/", exist_ok=True) 88 | os.makedirs(f"output/{dir_name}/thumbs/{month}/", exist_ok=True) 89 | main_file_size = file.stat().st_size 90 | thumb_file_size = 0 91 | # 找到对应缩略图 92 | thumb_file = Path(f'{source_dir}/{file.name}_t') 93 | if thumb_file.exists(): 94 | thumb_file_size = thumb_file.stat().st_size 95 | # 读缩略图加密 96 | with open(thumb_file, 'rb') as f: 97 | thumb_buff = bytearray(f.read()) 98 | 99 | # 写缩略图 100 | thumb_destination = (f"output/{dir_name}/thumbs/{month}/" 101 | f"{main_file_size}_{thumb_file_size}.jpg") 102 | with open(thumb_destination, 'wb') as f: 103 | new_thumb_buff = self.decode(magic, thumb_buff) 104 | f.write(new_thumb_buff) 105 | 106 | destination = (f"output/{dir_name}/images/{month}/" 107 | f"{main_file_size}_{thumb_file_size}.jpg") 108 | with open(destination, 'wb') as f: 109 | new_buf = self.decode(magic, buff) 110 | f.write(new_buf) 111 | except Exception: 112 | traceback.print_exc() 113 | processed_files = processed_files + 1 114 | # 15%的进度作为处理图片使用 115 | progress = round(processed_files / total_files * 15) 116 | self.gui.update_export_progressbar(progress) 117 | -------------------------------------------------------------------------------- /decrypter/video_decrypt.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import shutil 4 | import subprocess 5 | import sys 6 | import traceback 7 | from datetime import date 8 | from pathlib import Path 9 | import filetype 10 | 11 | import log 12 | 13 | 14 | class VideoDecrypter: 15 | 16 | def __init__(self, gui: 'Gui', file_path): 17 | self.file_path = file_path 18 | self.gui = gui 19 | self.sns_cache_path = file_path + "/FileStorage/Sns/Cache" 20 | 21 | @staticmethod 22 | def get_ffmpeg_path(): 23 | if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): 24 | # 这是到_internal文件夹 25 | resource_dir = getattr(sys, '_MEIPASS', os.path.abspath(os.path.dirname(__file__))) 26 | # 获取_internal上一级再拼接 27 | return os.path.join(os.path.dirname(resource_dir), 'resource', 'ffmpeg.exe') 28 | else: 29 | return os.path.join(os.getcwd(), 'resource', 'ffmpeg.exe') 30 | 31 | @staticmethod 32 | def get_ffprobe_path(): 33 | if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): 34 | # 这是到_internal文件夹 35 | resource_dir = getattr(sys, '_MEIPASS') 36 | # 获取_internal上一级文件夹再拼接 37 | return os.path.join(os.path.dirname(resource_dir), 'resource', 'ffprobe.exe') 38 | else: 39 | return os.path.join(os.getcwd(), 'resource', 'ffprobe.exe') 40 | 41 | @staticmethod 42 | def get_output_path(dir_name, md5, duration): 43 | if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): 44 | # 这是到_internal文件夹 45 | resource_dir = getattr(sys, '_MEIPASS') 46 | # 获取_internal上一级文件夹再拼接 47 | return os.path.join(os.path.dirname(resource_dir), 'output', dir_name, 'videos', f'{md5}_{duration}.mp4') 48 | else: 49 | return os.path.join(os.getcwd(), 'output', dir_name, 'videos', f'{md5}_{duration}.mp4') 50 | 51 | @staticmethod 52 | def calculate_md5(file_path): 53 | with open(file_path, "rb") as f: 54 | file_content = f.read() 55 | return hashlib.md5(file_content).hexdigest() 56 | 57 | @staticmethod 58 | def get_all_month_between_dates(start_date, end_date) -> list[str]: 59 | result = [] 60 | current_date = start_date 61 | while current_date <= end_date: 62 | # 打印当前日期的年份和月份 63 | result.append(current_date.strftime("%Y-%m")) 64 | year = current_date.year + (current_date.month // 12) 65 | month = current_date.month % 12 + 1 66 | # 更新current_date到下个月的第一天 67 | current_date = date(year, month, 1) 68 | return result 69 | 70 | def get_video_duration(self, video_path) ->float: 71 | """获取视频时长""" 72 | ffprobe_path = self.get_ffprobe_path() 73 | if not os.path.exists(ffprobe_path): 74 | log.LOG.error("Wrong ffprobe path:"+ffprobe_path) 75 | return 0 76 | ffprobe_cmd = f'"{ffprobe_path}" -i "{video_path}" -show_entries format=duration -v quiet -of csv="p=0"' 77 | p = subprocess.Popen( 78 | ffprobe_cmd, 79 | stdout=subprocess.PIPE, 80 | stderr=subprocess.PIPE, 81 | shell=True) 82 | print(ffprobe_cmd) 83 | out, err = p.communicate() 84 | if len(str(err, 'gbk')) > 0: 85 | print(f"subprocess 执行结果:out:{out} err:{str(err, 'gbk')}") 86 | return 0 87 | if len(str(out, 'gbk')) == 0: 88 | return 0 89 | return float(out) 90 | 91 | def decrypt_videos(self, exporter, start_date, end_date, dir_name, convert_video) -> None: 92 | """将视频文件从缓存中复制出来,重命名为{md5}_{duration}.mp4 93 | duration单位为秒 94 | """ 95 | months = self.get_all_month_between_dates(start_date, end_date) 96 | 97 | total_files = 0 98 | processed_files = 0 99 | for month in months: 100 | source_dir = self.sns_cache_path + "/" + month 101 | total_files = total_files + len(list(Path(source_dir).rglob('*'))) 102 | 103 | for month in months: 104 | source_dir = self.sns_cache_path + "/" + month 105 | for file in Path(source_dir).rglob('*'): 106 | if not exporter.stop_flag: 107 | try: 108 | file_type = filetype.guess(file.resolve()) 109 | if file_type and file_type.extension == "mp4": 110 | print("Process Video: "+str(file.resolve())) 111 | md5 = self.calculate_md5(file.resolve()) 112 | print("video md5: "+md5) 113 | duration = self.get_video_duration(str(file.resolve())) 114 | print("video duration: " + str(duration)) 115 | # 是否需要将视频转码 116 | if convert_video: 117 | input_path = str(file.resolve()) 118 | ffmpeg_path = self.get_ffmpeg_path() 119 | output_path = self.get_output_path(dir_name, md5, duration) 120 | print("ffmpeg_path: " + str(ffmpeg_path)) 121 | if os.path.exists(ffmpeg_path): 122 | cmd = f'''"{ffmpeg_path}" -loglevel quiet -i "{input_path}" -c:v libx264 "{output_path}"''' 123 | print("ffmpeg_path cmd:" + cmd) 124 | subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 125 | else: 126 | shutil.copy(file.resolve(), f"output/{dir_name}/videos/{md5}_{duration}.mp4") 127 | except Exception: 128 | traceback.print_exc() 129 | processed_files = processed_files + 1 130 | # 15%的进度作为处理视频使用 + 15%(处理图像) 131 | progress = round(processed_files / total_files * 15 + 15) 132 | self.gui.update_export_progressbar(progress) 133 | -------------------------------------------------------------------------------- /doc/manual_guide.md: -------------------------------------------------------------------------------- 1 | # 电脑微信浏览朋友圈 2 | ## 一、浏览全部朋友圈 3 | 打开微信朋友圈页面进行浏览。缺点是只能浏览最近100天的记录。
4 | ![朋友圈页面.png](/doc/pic/朋友圈页面.png)
5 | ## 二、浏览单个朋友 6 | * 此方法没有最大天数限制
7 | * 打开搜一搜
8 | ![打开搜一搜.png](/doc/pic/打开搜一搜.png)
9 | * 点击朋友圈选项卡
10 | ![打开朋友圈.png](/doc/pic/打开朋友圈.png)
11 | * 输入数字1,点击搜索
12 | ![点击搜索.png](/doc/pic/点击搜索.png)
13 | * 展开朋友下拉框
14 | ![展开朋友.png](/doc/pic/展开朋友.png)
15 | * 搜索一个朋友点击完成
16 | ![点击完成.png](/doc/pic/点击完成.png)
17 | * 输入一个**中文**的问号 ? 再次点击搜索
18 | ![中文问号.png](/doc/pic/中文问号.png)
19 | * 搜一搜有**最大展示数量限制**,如果展示不全,可以限定一下搜索时间
20 | ![限定时间.png](/doc/pic/限定时间.png)
21 | 22 | 23 | ## 三、提高自动化操作成功率 24 | 打开项目 resource/auto_gui文件夹,有四个图片
25 | ![四幅图.png](/doc/pic/四幅图.png)
26 | 程序根据这四个图片定位鼠标位置,进行自动化操作。
27 | 识图成功率与电脑的分辨率,缩放比例有很大关系。
28 | 将这四个图片替换成自己电脑的按钮截图,可以极大提高自动化操作成功率
-------------------------------------------------------------------------------- /doc/pic/中文问号.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/中文问号.png -------------------------------------------------------------------------------- /doc/pic/主界面.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/主界面.png -------------------------------------------------------------------------------- /doc/pic/四幅图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/四幅图.png -------------------------------------------------------------------------------- /doc/pic/展开朋友.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/展开朋友.png -------------------------------------------------------------------------------- /doc/pic/打开搜一搜.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/打开搜一搜.png -------------------------------------------------------------------------------- /doc/pic/打开朋友圈.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/打开朋友圈.png -------------------------------------------------------------------------------- /doc/pic/朋友圈页面.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/朋友圈页面.png -------------------------------------------------------------------------------- /doc/pic/点击完成.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/点击完成.png -------------------------------------------------------------------------------- /doc/pic/点击搜索.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/点击搜索.png -------------------------------------------------------------------------------- /doc/pic/限定时间.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/doc/pic/限定时间.png -------------------------------------------------------------------------------- /entity/comment.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Comment: 6 | from_user_name: str 7 | comment_type: int 8 | content: str 9 | -------------------------------------------------------------------------------- /entity/contact.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Contact: 6 | userName: str 7 | alias: str 8 | type: int 9 | remark: str 10 | nickName: str 11 | pYInitial: str 12 | remarkPYInitial: str 13 | smallHeadImgUrl: str 14 | bigHeadImgUrl: str 15 | exTraBuf: str 16 | labelName: str 17 | latestTalkTime: int -------------------------------------------------------------------------------- /entity/moment_msg.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass, field 3 | from typing import Optional, List 4 | from datetime import datetime, timezone, timedelta 5 | 6 | import xmltodict 7 | from dataclasses_json import dataclass_json, config 8 | 9 | 10 | @dataclass_json 11 | @dataclass 12 | class Location: 13 | poiName: str = field(metadata=config(field_name="@poiName"), default="") 14 | longitude: str = field(metadata=config(field_name="@longitude"), default="") 15 | latitude: str = field(metadata=config(field_name="@latitude"), default="") 16 | country: str = field(metadata=config(field_name="@country"), default="") 17 | 18 | 19 | @dataclass_json 20 | @dataclass 21 | class Url: 22 | type: str = field(metadata=config(field_name="@type")) 23 | text: str = field(metadata=config(field_name="#text"), default="") 24 | md5: str = field(metadata=config(field_name="@md5"), default="") 25 | token: str = field(metadata=config(field_name="@token"), default="") 26 | enc_idx: str = field(metadata=config(field_name="@enc_idx"), default="") 27 | 28 | @dataclass_json 29 | @dataclass 30 | class Thumb: 31 | type: str = field(metadata=config(field_name="@type")) 32 | text: str = field(metadata=config(field_name="#text")) 33 | token: str = field(metadata=config(field_name="@token"), default="") 34 | enc_idx: str = field(metadata=config(field_name="@enc_idx"), default="") 35 | 36 | 37 | @dataclass_json 38 | @dataclass 39 | class Media: 40 | type: Optional[str] = None 41 | id: Optional[str] = None 42 | url: Optional[Url] | str = None 43 | thumb: Optional[Thumb] = None 44 | thumbUrl: Optional[str] = None 45 | videoDuration: Optional[str] = None 46 | title: Optional[str] = None 47 | description: Optional[str] = None 48 | 49 | @dataclass_json 50 | @dataclass 51 | class MediaList: 52 | media: list[Media] 53 | 54 | 55 | 56 | @dataclass_json 57 | @dataclass 58 | class FinderFeed: 59 | feedType: Optional[str] = "" 60 | nickname: Optional[str] = "" 61 | desc: Optional[str] = "" 62 | mediaList: Optional[MediaList] = None 63 | 64 | @dataclass_json 65 | @dataclass 66 | class ContentObject: 67 | contentStyle: int 68 | contentUrl: Optional[str] = "" 69 | title: Optional[str] = "" 70 | description: Optional[str] = "" 71 | mediaList: Optional[MediaList] = None 72 | # 视频号消息 73 | finderFeed: Optional[FinderFeed] = None 74 | 75 | @dataclass_json 76 | @dataclass 77 | class TimelineObject: 78 | username: str 79 | location: Location 80 | ContentObject: ContentObject 81 | createTime: int 82 | contentDesc: Optional[str] = "" 83 | 84 | @property 85 | def create_date(self): 86 | dt = datetime.fromtimestamp(self.createTime, timezone.utc) 87 | # 转换为北京时间(UTC+8) 88 | beijing_timezone = timezone(timedelta(hours=8)) 89 | date = dt.astimezone(beijing_timezone).date() 90 | return date 91 | @property 92 | def create_time(self)->str: 93 | dt = datetime.fromtimestamp(self.createTime, timezone.utc) 94 | # 转换为北京时间(UTC+8) 95 | beijing_timezone = timezone(timedelta(hours=8)) 96 | time_formatted = dt.astimezone(beijing_timezone).strftime('%Y-%m-%d %H:%M:%S') 97 | return time_formatted 98 | @property 99 | def create_year_month(self)->str: 100 | dt = datetime.fromtimestamp(self.createTime, timezone.utc) 101 | # 转换为北京时间(UTC+8) 102 | beijing_timezone = timezone(timedelta(hours=8)) 103 | time_formatted = dt.astimezone(beijing_timezone).strftime('%Y-%m') 104 | return time_formatted 105 | 106 | 107 | @dataclass_json 108 | @dataclass 109 | class MomentMsg: 110 | timelineObject: TimelineObject = field(metadata=config(field_name="TimelineObject")) 111 | 112 | 113 | 114 | 115 | def test(): 116 | 117 | xml = """ 118 | """ 119 | msg_dict = xmltodict.parse(xml.replace(chr(10), '').replace(chr(9), ''), force_list={'media'}) 120 | msg_json = json.dumps(msg_dict, sort_keys=False, indent=2) 121 | momentMsg = MomentMsg.from_json(msg_json) 122 | print(momentMsg) 123 | 124 | def test_time_convert(): 125 | time = 1706592456 126 | dt = datetime.fromtimestamp(time, timezone.utc) 127 | # 转换为北京时间(UTC+8) 128 | beijing_timezone = timezone(timedelta(hours=8)) 129 | beijing_time = dt.astimezone(beijing_timezone).strftime('%Y-%m-%d %H:%M:%S') 130 | print(beijing_time) 131 | 132 | if __name__ == "__main__": 133 | test() 134 | -------------------------------------------------------------------------------- /exporter/avatar_exporter.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | from pathlib import Path 4 | 5 | from PIL import Image 6 | 7 | 8 | class AvatarExporter: 9 | def __init__(self, dir_name: str): 10 | self.dir_name = dir_name 11 | # 头像是否已保存好 Key: userName value: True/False 12 | self._saved_map = {} 13 | if not os.path.exists(f'output/{self.dir_name}/avatars/'): 14 | os.mkdir(f'output/{self.dir_name}/avatars/') 15 | 16 | def get_avatar_path(self, userName) -> str: 17 | if userName in self._saved_map: 18 | return f'avatars/{userName}.png' 19 | 20 | from app.DataBase import misc_db 21 | blob_data = misc_db.get_avatar_buffer(userName) 22 | self._saved_map[userName] = True 23 | if blob_data: 24 | image = Image.open(io.BytesIO(blob_data)) 25 | image.save(f'output/{self.dir_name}/avatars/{userName}.png', 'PNG') 26 | return f'avatars/{userName}.png' 27 | else: 28 | return f'icons/empty-avatar.jpg' 29 | -------------------------------------------------------------------------------- /exporter/emoji_exporter.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class EmojiExporter: 5 | def __init__(self): 6 | pass 7 | 8 | @staticmethod 9 | def replace_emoji(text: str): 10 | replacement_rules = [ 11 | { 12 | "pattern": re.compile(r'\[微笑\]'), 13 | "replacement": '' 14 | }, 15 | { 16 | "pattern": re.compile(r'\[发呆\]'), 17 | "replacement": '' 18 | }, 19 | { 20 | "pattern": re.compile(r'\[撇嘴\]'), 21 | "replacement": '' 22 | }, 23 | { 24 | "pattern": re.compile(r'\[色\]'), 25 | "replacement": '' 26 | }, 27 | { 28 | "pattern": re.compile(r'\[发呆\]'), 29 | "replacement": '' 30 | }, 31 | { 32 | "pattern": re.compile(r'\[得意\]'), 33 | "replacement": '' 34 | }, 35 | { 36 | "pattern": re.compile(r'\[流泪\]'), 37 | "replacement": '' 38 | }, 39 | { 40 | "pattern": re.compile(r'\[害羞\]'), 41 | "replacement": '' 42 | }, 43 | { 44 | "pattern": re.compile(r'\[闭嘴\]'), 45 | "replacement": '' 46 | }, 47 | { 48 | "pattern": re.compile(r'\[睡\]'), 49 | "replacement": '' 50 | }, 51 | { 52 | "pattern": re.compile(r'\[大哭\]'), 53 | "replacement": '' 54 | }, 55 | { 56 | "pattern": re.compile(r'\[尴尬\]'), 57 | "replacement": '' 58 | }, 59 | { 60 | "pattern": re.compile(r'\[发怒\]'), 61 | "replacement": '' 62 | }, 63 | { 64 | "pattern": re.compile(r'\[调皮\]'), 65 | "replacement": '' 66 | }, 67 | { 68 | "pattern": re.compile(r'\[呲牙\]'), 69 | "replacement": '' 70 | }, 71 | { 72 | "pattern": re.compile(r'\[惊讶\]'), 73 | "replacement": '' 74 | }, 75 | { 76 | "pattern": re.compile(r'\[难过\]'), 77 | "replacement": '' 78 | }, 79 | { 80 | "pattern": re.compile(r'\[抓狂\]'), 81 | "replacement": '' 82 | }, 83 | { 84 | "pattern": re.compile(r'\[吐\]'), 85 | "replacement": '' 86 | }, 87 | { 88 | "pattern": re.compile(r'\[偷笑\]'), 89 | "replacement": '' 90 | }, 91 | { 92 | "pattern": re.compile(r'\[愉快\]'), 93 | "replacement": '' 94 | }, 95 | { 96 | "pattern": re.compile(r'\[白眼\]'), 97 | "replacement": '' 98 | }, 99 | { 100 | "pattern": re.compile(r'\[傲慢\]'), 101 | "replacement": '' 102 | }, 103 | { 104 | "pattern": re.compile(r'\[困\]'), 105 | "replacement": '' 106 | }, 107 | { 108 | "pattern": re.compile(r'\[惊恐\]'), 109 | "replacement": '' 110 | }, 111 | { 112 | "pattern": re.compile(r'\[憨笑\]'), 113 | "replacement": '' 114 | }, 115 | { 116 | "pattern": re.compile(r'\[悠闲\]'), 117 | "replacement": '' 118 | }, 119 | { 120 | "pattern": re.compile(r'\[咒骂\]'), 121 | "replacement": '' 122 | }, 123 | { 124 | "pattern": re.compile(r'\[疑问\]'), 125 | "replacement": '' 126 | }, 127 | { 128 | "pattern": re.compile(r'\[嘘\]'), 129 | "replacement": '' 130 | }, 131 | { 132 | "pattern": re.compile(r'\[晕\]'), 133 | "replacement": '' 134 | }, 135 | { 136 | "pattern": re.compile(r'\[衰\]'), 137 | "replacement": '' 138 | }, 139 | { 140 | "pattern": re.compile(r'\[骷髅\]'), 141 | "replacement": '' 142 | }, 143 | { 144 | "pattern": re.compile(r'\[敲打\]'), 145 | "replacement": '' 146 | }, 147 | { 148 | "pattern": re.compile(r'\[再见\]'), 149 | "replacement": '' 150 | }, 151 | { 152 | "pattern": re.compile(r'\[擦汗\]'), 153 | "replacement": '' 154 | }, 155 | { 156 | "pattern": re.compile(r'\[抠鼻\]'), 157 | "replacement": '' 158 | }, 159 | { 160 | "pattern": re.compile(r'\[鼓掌\]'), 161 | "replacement": '' 162 | }, 163 | { 164 | "pattern": re.compile(r'\[坏笑\]'), 165 | "replacement": '' 166 | }, 167 | { 168 | "pattern": re.compile(r'\[右哼哼\]'), 169 | "replacement": '' 170 | }, 171 | { 172 | "pattern": re.compile(r'\[鄙视\]'), 173 | "replacement": '' 174 | }, 175 | { 176 | "pattern": re.compile(r'\[委屈\]'), 177 | "replacement": '' 178 | }, 179 | { 180 | "pattern": re.compile(r'\[快哭了\]'), 181 | "replacement": '' 182 | }, 183 | { 184 | "pattern": re.compile(r'\[阴险\]'), 185 | "replacement": '' 186 | }, 187 | { 188 | "pattern": re.compile(r'\[亲亲\]'), 189 | "replacement": '' 190 | }, 191 | { 192 | "pattern": re.compile(r'\[可怜\]'), 193 | "replacement": '' 194 | }, 195 | { 196 | "pattern": re.compile(r'\[Whimper\]'), 197 | "replacement": '' 198 | }, 199 | { 200 | "pattern": re.compile(r'\[笑脸\]'), 201 | "replacement": '' 202 | }, 203 | { 204 | "pattern": re.compile(r'\[生病\]'), 205 | "replacement": '' 206 | }, 207 | { 208 | "pattern": re.compile(r'\[脸红\]'), 209 | "replacement": '' 210 | }, 211 | { 212 | "pattern": re.compile(r'\[破涕为笑\]'), 213 | "replacement": '' 214 | }, 215 | { 216 | "pattern": re.compile(r'\[恐惧\]'), 217 | "replacement": '' 218 | }, 219 | { 220 | "pattern": re.compile(r'\[失望\]'), 221 | "replacement": '' 222 | }, 223 | { 224 | "pattern": re.compile(r'\[无语\]'), 225 | "replacement": '' 226 | }, 227 | { 228 | "pattern": re.compile(r'\[嘿哈\]'), 229 | "replacement": '' 230 | }, 231 | { 232 | "pattern": re.compile(r'\[捂脸\]'), 233 | "replacement": '' 234 | }, 235 | { 236 | "pattern": re.compile(r'\[奸笑\]'), 237 | "replacement": '' 238 | }, 239 | { 240 | "pattern": re.compile(r'\[机智\]'), 241 | "replacement": '' 242 | }, 243 | { 244 | "pattern": re.compile(r'\[皱眉\]'), 245 | "replacement": '' 246 | }, 247 | { 248 | "pattern": re.compile(r'\[耶\]'), 249 | "replacement": '' 250 | }, 251 | { 252 | "pattern": re.compile(r'\[吃瓜\]'), 253 | "replacement": '' 254 | }, 255 | { 256 | "pattern": re.compile(r'\[加油\]'), 257 | "replacement": '' 258 | }, 259 | { 260 | "pattern": re.compile(r'\[汗\]'), 261 | "replacement": '' 262 | }, 263 | { 264 | "pattern": re.compile(r'\[天啊\]'), 265 | "replacement": '' 266 | }, 267 | { 268 | "pattern": re.compile(r'\[Emm\]'), 269 | "replacement": '' 270 | }, 271 | { 272 | "pattern": re.compile(r'\[社会社会\]'), 273 | "replacement": '' 274 | }, 275 | { 276 | "pattern": re.compile(r'\[旺柴\]'), 277 | "replacement": '' 278 | }, 279 | { 280 | "pattern": re.compile(r'\[好的\]'), 281 | "replacement": '' 282 | }, 283 | { 284 | "pattern": re.compile(r'\[打脸\]'), 285 | "replacement": '' 286 | }, 287 | { 288 | "pattern": re.compile(r'\[哇\]'), 289 | "replacement": '' 290 | }, 291 | { 292 | "pattern": re.compile(r'\[翻白眼\]'), 293 | "replacement": '' 294 | }, 295 | { 296 | "pattern": re.compile(r'\[666\]'), 297 | "replacement": '' 298 | }, 299 | { 300 | "pattern": re.compile(r'\[让我看看\]'), 301 | "replacement": '' 302 | }, 303 | { 304 | "pattern": re.compile(r'\[叹气\]'), 305 | "replacement": '' 306 | }, 307 | { 308 | "pattern": re.compile(r'\[苦涩\]'), 309 | "replacement": '' 310 | }, 311 | { 312 | "pattern": re.compile(r'\[難受\]'), 313 | "replacement": '' 314 | }, 315 | { 316 | "pattern": re.compile(r'\[裂开\]'), 317 | "replacement": '' 318 | }, 319 | { 320 | "pattern": re.compile(r'\[嘴唇\]'), 321 | "replacement": '' 322 | }, 323 | { 324 | "pattern": re.compile(r'\[爱心\]'), 325 | "replacement": '' 326 | }, 327 | { 328 | "pattern": re.compile(r'\[心碎\]'), 329 | "replacement": '' 330 | }, 331 | { 332 | "pattern": re.compile(r'\[拥抱\]'), 333 | "replacement": '' 334 | }, 335 | { 336 | "pattern": re.compile(r'\[强\]'), 337 | "replacement": '' 338 | }, 339 | { 340 | "pattern": re.compile(r'\[弱\]'), 341 | "replacement": '' 342 | }, 343 | { 344 | "pattern": re.compile(r'\[握手\]'), 345 | "replacement": '' 346 | }, 347 | { 348 | "pattern": re.compile(r'\[胜利\]'), 349 | "replacement": '' 350 | }, 351 | { 352 | "pattern": re.compile(r'\[抱拳\]'), 353 | "replacement": '' 354 | }, 355 | { 356 | "pattern": re.compile(r'\[勾引\]'), 357 | "replacement": '' 358 | }, 359 | { 360 | "pattern": re.compile(r'\[拳头\]'), 361 | "replacement": '' 362 | }, 363 | { 364 | "pattern": re.compile(r'\[OK\]'), 365 | "replacement": '' 366 | }, 367 | { 368 | "pattern": re.compile(r'\[合十\]'), 369 | "replacement": '' 370 | }, 371 | { 372 | "pattern": re.compile(r'\[啤酒\]'), 373 | "replacement": '' 374 | }, 375 | { 376 | "pattern": re.compile(r'\[咖啡]\]'), 377 | "replacement": '' 378 | }, 379 | { 380 | "pattern": re.compile(r'\[蛋糕\]'), 381 | "replacement": '' 382 | }, 383 | { 384 | "pattern": re.compile(r'\[玫瑰\]'), 385 | "replacement": '' 386 | }, 387 | { 388 | "pattern": re.compile(r'\[凋谢\]'), 389 | "replacement": '' 390 | }, 391 | { 392 | "pattern": re.compile(r'\[菜刀\]'), 393 | "replacement": '' 394 | }, 395 | { 396 | "pattern": re.compile(r'\[炸弹\]'), 397 | "replacement": '' 398 | }, 399 | { 400 | "pattern": re.compile(r'\[便便\]'), 401 | "replacement": '' 402 | }, 403 | { 404 | "pattern": re.compile(r'\[月亮\]'), 405 | "replacement": '' 406 | }, 407 | { 408 | "pattern": re.compile(r'\[太阳\]'), 409 | "replacement": '' 410 | }, 411 | { 412 | "pattern": re.compile(r'\[庆 祝\]'), 413 | "replacement": '' 414 | }, 415 | { 416 | "pattern": re.compile(r'\[礼物\]'), 417 | "replacement": '' 418 | }, 419 | { 420 | "pattern": re.compile(r'\[红包\]'), 421 | "replacement": '' 422 | }, 423 | { 424 | "pattern": re.compile(r'\[發\]'), 425 | "replacement": '' 426 | }, 427 | { 428 | "pattern": re.compile(r'\[福\]'), 429 | "replacement": '' 430 | }, 431 | { 432 | "pattern": re.compile(r'\[烟花\]'), 433 | "replacement": '' 434 | }, 435 | { 436 | "pattern": re.compile(r'\[爆竹\]'), 437 | "replacement": '' 438 | }, 439 | { 440 | "pattern": re.compile(r'\[猪头\]'), 441 | "replacement": '' 442 | }, 443 | { 444 | "pattern": re.compile(r'\[跳跳\]'), 445 | "replacement": '' 446 | }, 447 | { 448 | "pattern": re.compile(r'\[发抖\]'), 449 | "replacement": '' 450 | }, 451 | { 452 | "pattern": re.compile(r'\[转圈\]'), 453 | "replacement": '' 454 | }] 455 | 456 | for rule in replacement_rules: 457 | pattern = rule.get("pattern") 458 | text = re.sub(pattern, rule.get("replacement"), text) 459 | return text -------------------------------------------------------------------------------- /exporter/html_exporter.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import shutil 4 | import threading 5 | import time 6 | from typing import Tuple 7 | 8 | import xmltodict 9 | 10 | from entity.comment import Comment 11 | from entity.contact import Contact 12 | from exporter.avatar_exporter import AvatarExporter 13 | from exporter.emoji_exporter import EmojiExporter 14 | from exporter.image_exporter import ImageExporter 15 | from exporter.video_exporter import VideoExporter 16 | from log import LOG 17 | from entity.moment_msg import MomentMsg 18 | from pathlib import Path 19 | 20 | 21 | def get_img_div_css(size: int) -> str: 22 | if size == 1: 23 | return 'width:10rem; overflow:hidden' 24 | else: 25 | return 'width:19rem; overflow:hidden' 26 | 27 | 28 | def get_img_css(size: int) -> str: 29 | """object-fit: cover; 预览图居中裁剪 30 | cursor:pointer; 手形鼠标 31 | """ 32 | img_style = "object-fit:cover;cursor:pointer;" 33 | if size == 1: 34 | return f'width:10rem;height:10rem;{img_style}' 35 | elif size == 2: 36 | return f'width:8rem;height:8rem;float:left;margin-bottom:0.2rem;margin-right:0.2rem;{img_style}' 37 | elif size == 4: 38 | return f'width:8rem;height:8rem;float:left;margin-bottom:0.2rem;margin-right:0.2rem;{img_style}' 39 | else: 40 | return f'width:5rem;height:5rem;float:left;margin-bottom:0.2rem;margin-right:0.2rem;{img_style}' 41 | 42 | 43 | def is_music_msg(msg: MomentMsg) -> bool: 44 | """判断一个msg是否为音乐分享 45 | """ 46 | if msg.timelineObject.ContentObject and msg.timelineObject.ContentObject.mediaList and msg.timelineObject.ContentObject.mediaList.media: 47 | media = msg.timelineObject.ContentObject.mediaList.media[0] 48 | if media.type == '5': 49 | return True 50 | return False 51 | 52 | 53 | def get_music_info(msg: MomentMsg) -> Tuple[str, str, str]: 54 | """获取音乐标题,演唱者,音乐源 55 | """ 56 | title = "" 57 | musician = "" 58 | src = "" 59 | if msg.timelineObject.ContentObject and msg.timelineObject.ContentObject.mediaList and msg.timelineObject.ContentObject.mediaList.media: 60 | media = msg.timelineObject.ContentObject.mediaList.media[0] 61 | title = media.title 62 | musician = media.description 63 | if media.url: 64 | src = media.url.text 65 | return title, musician, src 66 | 67 | 68 | class HtmlExporter(threading.Thread): 69 | 70 | def __init__(self, gui: 'Gui', dir_name: str, contacts_map: dict[str, Contact], begin_date: datetime.date, 71 | end_date: datetime.date, convert_video: int): 72 | self.dir_name = dir_name 73 | if Path(f"output/{self.dir_name}").exists(): 74 | shutil.rmtree(f"output/{self.dir_name}") 75 | shutil.copytree("resource/template/", f"output/{self.dir_name}") 76 | 77 | self.gui = gui 78 | self.avatar_exporter = AvatarExporter(dir_name) 79 | self.image_exporter = ImageExporter(dir_name) 80 | self.video_exporter = VideoExporter(dir_name) 81 | self.html_head = None 82 | self.html_end = None 83 | self.file = None 84 | self.contacts_map = contacts_map 85 | self.begin_date = begin_date 86 | self.end_date = end_date 87 | self.convert_video = convert_video 88 | self.stop_flag = False 89 | super().__init__() 90 | 91 | def run(self) -> None: 92 | 93 | with open(f"resource/template.html", encoding='utf-8') as f: 94 | content = f.read() 95 | self.html_head, self.html_end = content.split('/*内容分割线*/') 96 | self.file = open(f"output/{self.dir_name}/index.html", 'w', encoding='utf-8') 97 | 98 | if self.gui.account_info and self.gui.account_info.get('wxid'): 99 | self.avatar_exporter.get_avatar_path(self.gui.account_info.get('wxid')) 100 | self.html_head = self.html_head.replace("{my_wxid}", f"{self.gui.account_info.get('wxid')}") 101 | from app.DataBase import micro_msg_db 102 | my_info = micro_msg_db.get_contact_by_username(self.gui.account_info.get('wxid')) 103 | self.html_head = self.html_head.replace("{my_name}", f"{my_info[4]}") 104 | 105 | from app.DataBase import sns_db 106 | cover_url = sns_db.get_cover_url() 107 | if cover_url: 108 | cover_path = self.image_exporter.save_image((cover_url, "", ""), 'image') 109 | self.html_head = self.html_head.replace("{cover_path}", cover_path) 110 | 111 | self.file.write(self.html_head) 112 | # 加一天 113 | end_date = self.end_date + datetime.timedelta(days=1) 114 | begin_time = time.mktime( 115 | datetime.datetime(self.begin_date.year, self.begin_date.month, self.begin_date.day).timetuple()) 116 | end_time = time.mktime(datetime.datetime(end_date.year, end_date.month, end_date.day).timetuple()) 117 | 118 | self.gui.image_decrypter.decrypt_images(self, self.begin_date, end_date, self.dir_name) 119 | self.gui.video_decrypter.decrypt_videos(self, self.begin_date, end_date, self.dir_name, self.convert_video) 120 | 121 | 122 | message_datas = sns_db.get_messages_in_time(begin_time, end_time) 123 | for index, message_data in enumerate(message_datas): 124 | if not self.stop_flag: 125 | if message_data[0] in self.contacts_map: 126 | comments_datas = sns_db.get_comment_by_feed_id(message_data[2]) 127 | comments: list[Comment] = [] 128 | for c in comments_datas: 129 | contact = Comment(c[0], c[1], c[2]) 130 | comments.append(contact) 131 | self.export_msg(message_data[1], comments, self.contacts_map) 132 | # 更新进度条 前30%视频处理 后70%其他处理 133 | progress = round(index / len(message_datas) * 70) 134 | self.gui.update_export_progressbar(30 + progress) 135 | self.gui.update_export_progressbar(100) 136 | self.finish_file() 137 | self.gui.export_succeed() 138 | 139 | def stop(self) -> None: 140 | self.stop_flag = True 141 | 142 | def export_msg(self, message: str, comments: list[Comment], contacts_map: dict[str, Contact]) -> None: 143 | 144 | LOG.info(message) 145 | # force_list: 强制要求转media为list 146 | msg_dict = xmltodict.parse(message, force_list={'media'}) 147 | msg_json = json.dumps(msg_dict) 148 | msg = MomentMsg.from_json(msg_json) 149 | 150 | # 微信ID 151 | username = msg.timelineObject.username 152 | # 头像路径 153 | avatar_path = self.avatar_exporter.get_avatar_path(username) 154 | 155 | contact = contacts_map.get(username) 156 | # 备注, 或用户名 157 | remark = contact.remark if contact.remark else contact.nickName 158 | 159 | # 朋友圈图片 160 | images = self.image_exporter.get_images(msg) 161 | 162 | # 朋友圈视频 163 | videos = self.video_exporter.get_videos(msg) 164 | 165 | # 样式 3:链接样式 166 | content_style = msg.timelineObject.ContentObject.contentStyle 167 | 168 | html = '
\n' 169 | html += '
\n' 170 | html += '
\n' 171 | html += f' \n' 172 | html += '
\n' 173 | html += '
\n' 174 | html += '
\n' 175 | html += '
\n' 176 | html += f'

{remark}

\n' 177 | if msg.timelineObject.contentDesc: 178 | content_desc = msg.timelineObject.contentDesc.replace("\n", "
") 179 | content_desc = EmojiExporter.replace_emoji(content_desc) 180 | html += f'

{content_desc}

\n' 181 | html += '
\n' 182 | 183 | # 超链接 184 | if content_style == 3: 185 | html += f' \n' 186 | html += ' \n' 192 | html += ' \n' 193 | # 音乐 194 | elif is_music_msg(msg): 195 | 196 | title, musician, src = get_music_info(msg) 197 | html += f' \n' 198 | html += ' \n' 212 | html += ' \n' 213 | # 视频号 214 | elif msg.timelineObject.ContentObject.finderFeed: 215 | html += f'
\n' 216 | # 视频号图片 217 | thumb_path = self.image_exporter.get_finder_images(msg) 218 | html += f""" \n""" 220 | html += '
\n' 221 | 222 | # 视频号说明 223 | html += '
\n' 224 | nickname = msg.timelineObject.ContentObject.finderFeed.nickname 225 | desc = msg.timelineObject.ContentObject.finderFeed.desc 226 | html += f'

视频号 · {nickname} · {desc}

\n' 227 | html += '
\n' 228 | # 普通朋友圈 229 | else: 230 | html += f'
\n' 231 | for thumb_path, image_path in images: 232 | html += f""" \n""" 234 | html += '
\n' 235 | 236 | html += '
\n' 237 | for video_path in videos: 238 | html += f'
\n' 242 | 243 | html += '
\n' 244 | if msg.timelineObject.location and msg.timelineObject.location.poiName: 245 | html += f'

{msg.timelineObject.location.poiName}

\n' 246 | html += f'

{msg.timelineObject.create_time}

\n' 247 | html += '
\n' 248 | html += '
\n' 249 | html += '
\n' 250 | self.file.write(html) 251 | 252 | def finish_file(self): 253 | self.file.write(self.html_end) 254 | self.file.close() 255 | -------------------------------------------------------------------------------- /exporter/image_exporter.py: -------------------------------------------------------------------------------- 1 | import os 2 | from io import BytesIO 3 | from pathlib import Path 4 | from typing import Tuple, Optional 5 | from PIL import Image 6 | from entity.moment_msg import MomentMsg, Media 7 | import requests 8 | import uuid 9 | 10 | 11 | class ImageExporter: 12 | def __init__(self, dir_name: str): 13 | self.dir_name = dir_name 14 | if not os.path.exists(f'output/{self.dir_name}/thumbs/'): 15 | os.mkdir(f'output/{self.dir_name}/thumbs/') 16 | if not os.path.exists(f'output/{self.dir_name}/images/'): 17 | os.mkdir(f'output/{self.dir_name}/images/') 18 | 19 | @staticmethod 20 | def get_image(link: tuple) -> bytes: 21 | """ 向微信服务器请求图片 22 | """ 23 | url, idx, token = link 24 | # 如果需要传递token 25 | if idx and token: 26 | url = f'{url}?idx={idx}&token={token}' 27 | response = requests.get(url) 28 | if response.ok: 29 | return response.content 30 | 31 | def save_image(self, link: tuple, img_type: str) -> str: 32 | """ 下载图片 33 | """ 34 | file_name = uuid.uuid4() 35 | if not (img_type == 'image' or img_type == 'thumb'): 36 | raise Exception("img_type 参数非法") 37 | content = self.get_image(link) 38 | if content: 39 | with open(f'output/{self.dir_name}/{img_type}s/{file_name}.jpg', 'wb') as file: 40 | file.write(content) 41 | return f'{img_type}s/{file_name}.jpg' 42 | 43 | @staticmethod 44 | def get_image_thumb_and_url(media_item, content_style:int) -> Tuple[Tuple, Tuple]: 45 | """ 获取图片的缩略图与大图的链接 46 | """ 47 | thumb = None 48 | url = None 49 | # 普通图片 50 | if media_item.type == "2": 51 | thumb = (media_item.thumb.text, media_item.thumb.enc_idx, media_item.thumb.token) 52 | url = (media_item.url.text, media_item.url.enc_idx, media_item.url.token) 53 | # 微信音乐 54 | if media_item.type == "5": 55 | thumb = (media_item.thumb.text, "", "") 56 | url = (media_item.thumb.text, "", "") 57 | # 超链接类型 58 | if content_style == 3: 59 | thumb = (media_item.thumb.text, "", "") 60 | url = (media_item.thumb.text, "", "") 61 | 62 | return thumb, url 63 | 64 | def get_images(self, msg: MomentMsg) -> list[Tuple]: 65 | """ 获取一条朋友圈的全部图像, 返回值是一个元组列表 66 | [(缩略图路径,原图路径),(缩略图路径,原图路径)] 67 | """ 68 | results = [] 69 | if not msg.timelineObject.ContentObject.mediaList: 70 | return results 71 | 72 | media = msg.timelineObject.ContentObject.mediaList.media 73 | for media_item in media: 74 | thumb, url = self.get_image_thumb_and_url(media_item, msg.timelineObject.ContentObject.contentStyle) 75 | if thumb and url: 76 | thumb_path = None 77 | image_path = None 78 | # 主图内容 79 | image_content = self.get_image(url) 80 | # 如果拿不到主图数据 81 | if not image_content: 82 | continue 83 | # 如果在腾讯服务器获取到jpg图片 84 | if image_content[:2] == b'\xff\xd8': 85 | file_name = uuid.uuid4() 86 | with open(f'output/{self.dir_name}/images/{file_name}.jpg', 'wb') as file: 87 | file.write(image_content) 88 | image_path = f'images/{file_name}.jpg' 89 | # 缩略图内容 90 | thumb_content = self.get_image(thumb) 91 | file_name = uuid.uuid4() 92 | with open(f'output/{self.dir_name}/thumbs/{file_name}.jpg', 'wb') as file: 93 | file.write(thumb_content) 94 | thumb_path = f'thumbs/{file_name}.jpg' 95 | # 如果图片已加密,进入缓存图片中匹配 96 | else: 97 | # 获取2024-06格式的时间 98 | month = msg.timelineObject.create_year_month 99 | image_content = self.get_image(url) 100 | thumb_content = self.get_image(thumb) 101 | # 从缓存里找文件 102 | image_file = Path((f"output/{self.dir_name}/images/{month}/" 103 | f"{len(image_content)}_{len(thumb_content)}.jpg")) 104 | thumb_file = Path((f"output/{self.dir_name}/thumbs/{month}/" 105 | f"{len(image_content)}_{len(thumb_content)}.jpg")) 106 | if image_file.exists(): 107 | image_path = image_file.resolve() 108 | if thumb_file.exists(): 109 | thumb_path = thumb_file.resolve() 110 | 111 | if thumb_path and image_path: 112 | results.append((thumb_path, image_path)) 113 | 114 | return results 115 | 116 | def get_finder_images(self, msg: MomentMsg) -> Optional[str]: 117 | """ 获取视频号的封面图 118 | """ 119 | results = None 120 | if not msg.timelineObject.ContentObject.finderFeed: 121 | return results 122 | 123 | if not msg.timelineObject.ContentObject.finderFeed.mediaList: 124 | return results 125 | 126 | media = msg.timelineObject.ContentObject.finderFeed.mediaList.media 127 | for media_item in media: 128 | thumb_path = self.save_image((media_item.thumbUrl, "", ""), 'thumb') 129 | return thumb_path 130 | -------------------------------------------------------------------------------- /exporter/video_exporter.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | import re 4 | from pathlib import Path 5 | from entity.moment_msg import MomentMsg, Media 6 | 7 | 8 | class VideoExporter: 9 | def __init__(self, dir_name): 10 | self.dir_name = dir_name 11 | if not os.path.exists(f'output/{self.dir_name}/videos/'): 12 | os.mkdir(f'output/{self.dir_name}/videos/') 13 | 14 | 15 | def find_video_by_md5(self, md5): 16 | """ 17 | 使用MD5匹配视频 18 | """ 19 | folder_path = Path(f'output/{self.dir_name}/videos/') 20 | pattern = re.compile(r'^(.*?)(?=_)') 21 | 22 | for file_path in folder_path.iterdir(): 23 | match = pattern.search(file_path.name) 24 | if match: 25 | filename_md5 = match.group() 26 | if filename_md5 == md5: 27 | return file_path.name 28 | 29 | def find_video_by_duration(self, duration): 30 | """ 31 | 使用视频时长匹配视频 32 | """ 33 | folder_path = Path(f'output/{self.dir_name}/videos/') 34 | pattern = re.compile(r'_([0-9.]+)\.mp4') 35 | 36 | for file_path in folder_path.iterdir(): 37 | match = pattern.search(file_path.name) 38 | if match: 39 | filename_duration = float(match.group(1)) 40 | if math.isclose(filename_duration, duration, abs_tol=0.005): 41 | return file_path.name 42 | 43 | def get_videos(self, msg: MomentMsg) -> list[str]: 44 | """ 获取一条朋友圈的全部视频, 返回值是一个文件路径列表 45 | """ 46 | results = [] 47 | if not msg.timelineObject.ContentObject.mediaList: 48 | return results 49 | 50 | media = msg.timelineObject.ContentObject.mediaList.media 51 | for media_item in media: 52 | if media_item.type == "6": 53 | duration = media_item.videoDuration 54 | rounded_duration = round(float(duration), 2) 55 | # 先用MD5匹配缓存中的视频 56 | # 如果找不到使用视频时长再次匹配 57 | video = self.find_video_by_md5(media_item.url.md5) 58 | if video: 59 | results.append(f'videos/{video}') 60 | else: 61 | video = self.find_video_by_duration(rounded_duration) 62 | if video: 63 | results.append(f'videos/{video}') 64 | 65 | return results 66 | -------------------------------------------------------------------------------- /gui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/gui/__init__.py -------------------------------------------------------------------------------- /gui/auto_scroll_guide.py: -------------------------------------------------------------------------------- 1 | import tkinter 2 | import tkinter.ttk 3 | 4 | import win32gui 5 | 6 | from entity.contact import Contact 7 | from helper.auto_scroll import AutoScroll 8 | 9 | 10 | class AutoScrollGuide: 11 | 12 | def __init__(self, root): 13 | self.flood_moments_note = None 14 | self.auto_thread = None 15 | self.frame = tkinter.LabelFrame(root) 16 | 17 | self.open_moments_guide = tkinter.Label(self.frame, text="请打开朋友圈窗口") 18 | self.open_moments_guide.pack() 19 | 20 | image = tkinter.PhotoImage(file='resource/gui_pictures/open_moments_guide.png') 21 | self.open_moments_guide_image = tkinter.Label(self.frame, image=image) 22 | self.open_moments_guide_image.image = image 23 | self.open_moments_guide_image.pack() 24 | 25 | self.auto_scroll_button_text = tkinter.StringVar() 26 | self.auto_scroll_button_text.set("开始") 27 | 28 | self.auto_scroll_button = tkinter.ttk.Button(self.frame, textvariable=self.auto_scroll_button_text, 29 | command=self.switch_auto_scroll) 30 | self.auto_scroll_button.pack(pady=5) 31 | 32 | def switch_auto_scroll(self): 33 | 34 | if self.auto_thread is None: 35 | moments_hwnd = win32gui.FindWindow("SnsWnd", '朋友圈') 36 | if moments_hwnd != 0: 37 | self.auto_thread = AutoScroll(self, moments_hwnd) 38 | self.flood_moments_note = tkinter.Label(self.frame, text="正在自动读取朋友圈数据......." 39 | "\n可将窗口最小化,后台自动执行" 40 | "\n可随时查看进度,可随时停止") 41 | self.flood_moments_note.pack() 42 | self.auto_thread.start() 43 | self.auto_scroll_button_text.set("停止") 44 | else: 45 | pass 46 | else: 47 | if self.auto_thread.scrolling: 48 | self.auto_scroll_button_text.set("继续") 49 | self.auto_thread.set_scrolling(False) 50 | else: 51 | self.auto_scroll_button_text.set("停止") 52 | self.auto_thread.set_scrolling(True) 53 | -------------------------------------------------------------------------------- /gui/auto_scrolls_single_guide.py: -------------------------------------------------------------------------------- 1 | import time 2 | import tkinter 3 | import tkinter.ttk 4 | 5 | import win32con 6 | import win32gui 7 | 8 | from helper.auto_scroll_single import AutoScrollSingle 9 | 10 | 11 | class AutoScrollSingleGuide: 12 | 13 | def __init__(self, root): 14 | self.working_note = None 15 | self.auto_thread = None 16 | self.frame = tkinter.LabelFrame(root) 17 | 18 | self.guide = tkinter.Label(self.frame, text="请打开搜一搜窗口\n点击开始后不要操作键鼠") 19 | self.guide.pack() 20 | 21 | self.search_username = tkinter.Entry(self.frame, width=12) 22 | self.search_username.insert(0, '请输入好友昵称') 23 | self.search_username.config(fg='grey') 24 | self.search_username.bind('', self.on_search_username_click) 25 | self.search_username.pack() 26 | 27 | image = tkinter.PhotoImage(file='resource/gui_pictures/open_search_guide.png') 28 | self.guide_image = tkinter.Label(self.frame, image=image) 29 | self.guide_image.image = image 30 | self.guide_image.pack() 31 | 32 | self.button_text = tkinter.StringVar() 33 | self.button_text.set("开始") 34 | 35 | self.button = tkinter.ttk.Button(self.frame, textvariable=self.button_text, 36 | command=self.switch_auto_scroll_single) 37 | self.button.pack(pady=5) 38 | 39 | def on_search_username_click(self, event): 40 | if self.search_username.get() == '请输入好友昵称': 41 | self.search_username.delete(0, tkinter.END) 42 | self.search_username.config(fg='black') 43 | 44 | def switch_auto_scroll_single(self): 45 | 46 | search_hwnd = win32gui.FindWindow('Chrome_WidgetWin_0', '微信') 47 | wechat_hwnd = win32gui.FindWindow('WeChatMainWndForPC', '微信') 48 | 49 | search_username = self.search_username.get() 50 | 51 | if self.auto_thread is None: 52 | if search_username == '请输入好友昵称' or search_username == '': 53 | self.search_username.config(fg='red') 54 | return 55 | if search_hwnd != 0 and wechat_hwnd != 0: 56 | 57 | self.auto_thread = AutoScrollSingle(self, search_hwnd, search_username) 58 | self.working_note = tkinter.Label(self.frame, text="正在自动读取朋友圈数据......." 59 | "\n请不要遮挡搜一搜窗口") 60 | 61 | 62 | self.working_note.pack() 63 | self.auto_thread.start() 64 | self.button_text.set("停止") 65 | else: 66 | pass 67 | else: 68 | if self.auto_thread.scrolling: 69 | self.button_text.set("开始") 70 | self.auto_thread.set_scrolling(False) 71 | self.auto_thread = None 72 | else: 73 | self.button_text.set("停止") 74 | self.switch_auto_scroll_single() 75 | -------------------------------------------------------------------------------- /gui/gui.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tkinter 3 | import tkinter.font 4 | import tkinter.ttk 5 | from datetime import datetime, timedelta 6 | from pathlib import Path 7 | from typing import Optional 8 | import tkcalendar 9 | from decrypter.db_decrypt import DatabaseDecrypter 10 | from decrypter.image_decrypt import ImageDecrypter 11 | from decrypter.video_decrypt import VideoDecrypter 12 | from gui.auto_scroll_guide import AutoScrollGuide 13 | from gui.auto_scrolls_single_guide import AutoScrollSingleGuide 14 | from gui.tool_tip import ToolTip 15 | from entity.contact import Contact 16 | from exporter.html_exporter import HtmlExporter 17 | from gui.listbox_with_search import ListboxWithSearch 18 | 19 | 20 | class Gui: 21 | def __init__(self): 22 | 23 | self.restart_note1 = None 24 | self.restart_note2 = None 25 | self.auto_scroll_single_guide = None 26 | self.auto_scroll_guide = None 27 | self.auto_scroll_frame = None 28 | self.search_username = None 29 | self.open_search_guide_image = None 30 | self.open_search_guide = None 31 | self.auto_scroll_button_single = None 32 | self.auto_scroll_button_single_text = None 33 | self.convert_video = None 34 | self.convert_video_var = None 35 | self.html_exporter_thread = None 36 | self.confirm_button_text = None 37 | self.succeed_label_2 = None 38 | self.succeed_label = None 39 | self.auto_scroll_button_text = None 40 | self.warning_label = None 41 | self.root = None 42 | self.waiting_label = None 43 | self.listbox = None 44 | self.begin_calendar = None 45 | self.end_calendar = None 46 | self.end_calendar_label = None 47 | self.begin_calendar_label = None 48 | self.confirm_button = None 49 | self.decrypt_progressbar = None 50 | self.export_progressbar = None 51 | self.next_step_button = None 52 | self.decrypter = None 53 | self.auto_scroll_button = None 54 | self.auto_scrolling_thread = None 55 | self.decrypt_note = None 56 | self.decrypt_note_text = None 57 | self.account_info = None 58 | self.video_decrypter = None 59 | self.image_decrypter = None 60 | self.export_dir_name = None 61 | self.exporting = False 62 | # 1: 自动滚动数据 2: 解密数据库 3: 导出 63 | self.page_stage = 1 64 | 65 | def run_gui(self): 66 | self.root = tkinter.Tk() 67 | self.root.geometry('650x650') 68 | self.root.title('朋友圈导出') 69 | 70 | self.waiting_label = tkinter.ttk.Label(self.root, text="正在连接微信....", 71 | font=("微软雅黑", 16), anchor='center') 72 | self.waiting_label.place(relx=0.5, rely=0.05, anchor='center') 73 | 74 | self.root.mainloop() 75 | 76 | def wechat_logged_in(self, account_info): 77 | 78 | self.account_info = account_info 79 | self.waiting_label.config(text="微信已登录") 80 | 81 | self.auto_scroll_button_text = tkinter.StringVar() 82 | self.auto_scroll_button_text.set("自动浏览全部朋友圈") 83 | self.auto_scroll_button = tkinter.ttk.Button(self.root, textvariable=self.auto_scroll_button_text, 84 | command=self.open_auto_scroll_guide) 85 | self.auto_scroll_button.place(relx=0.35, rely=0.15, anchor='center') 86 | 87 | self.auto_scroll_button_single_text = tkinter.StringVar() 88 | self.auto_scroll_button_single_text.set("自动浏览单个朋友") 89 | self.auto_scroll_button_single = tkinter.ttk.Button(self.root, textvariable=self.auto_scroll_button_single_text, 90 | command=self.switch_auto_scroll_single) 91 | self.auto_scroll_button_single.place(relx=0.655, rely=0.15, anchor='center') 92 | 93 | self.next_step_button = tkinter.ttk.Button(self.root, text="下一步", command=self.next_step) 94 | self.next_step_button.place(relx=0.65, rely=0.8) 95 | 96 | 97 | 98 | def open_auto_scroll_guide(self): 99 | 100 | if self.auto_scroll_single_guide and self.auto_scroll_single_guide.frame: 101 | self.auto_scroll_single_guide.frame.place_forget() 102 | 103 | self.auto_scroll_guide = AutoScrollGuide(self.root) 104 | self.auto_scroll_guide.frame.place(relx=0.5, rely=0.5, anchor='center') 105 | 106 | def switch_auto_scroll_single(self): 107 | 108 | if self.auto_scroll_guide and self.auto_scroll_guide.frame: 109 | self.auto_scroll_guide.frame.place_forget() 110 | 111 | self.auto_scroll_single_guide = AutoScrollSingleGuide(self.root) 112 | self.auto_scroll_single_guide.frame.place(relx=0.5, rely=0.5, anchor='center') 113 | 114 | 115 | def next_step(self): 116 | 117 | if self.page_stage == 1: 118 | 119 | if self.auto_scroll_guide and self.auto_scroll_guide.auto_thread: 120 | self.auto_scroll_guide.auto_thread.set_scrolling(False) 121 | 122 | if self.auto_scroll_single_guide and self.auto_scroll_single_guide.auto_thread: 123 | self.auto_scroll_guide.auto_thread.set_scrolling(False) 124 | 125 | self.auto_scroll_button.place_forget() 126 | self.auto_scroll_button_single.place_forget() 127 | 128 | if self.auto_scroll_guide and self.auto_scroll_guide.frame: 129 | self.auto_scroll_guide.frame.place_forget() 130 | 131 | if self.auto_scroll_single_guide and self.auto_scroll_single_guide.frame: 132 | self.auto_scroll_single_guide.frame.place_forget() 133 | 134 | self.restart_note1 = tkinter.Label(self.root, text="请关闭微信客户端", fg="red") 135 | self.restart_note1.place(relx=0.5, rely=0.2, anchor='center') 136 | self.restart_note2 = tkinter.Label(self.root, text="然后点击下一步") 137 | self.restart_note2.place(relx=0.5, rely=0.3, anchor='center') 138 | 139 | if self.page_stage == 2: 140 | 141 | self.restart_note1.place_forget() 142 | self.restart_note2.place_forget() 143 | self.waiting_label.place_forget() 144 | 145 | self.decrypter = DatabaseDecrypter(self, self.account_info.get("filePath"), self.account_info.get("key")) 146 | 147 | self.decrypt_note_text = tkinter.StringVar() 148 | self.decrypt_note_text.set("正在复制数据.....") 149 | 150 | self.decrypt_note = tkinter.Label(self.root, textvariable=self.decrypt_note_text) 151 | self.decrypt_note.place(relx=0.5, rely=0.2, anchor='center') 152 | self.decrypt_progressbar = tkinter.ttk.Progressbar(self.root) 153 | self.decrypt_progressbar.place(relx=0.5, rely=0.3, anchor='center') 154 | 155 | # 进度值最大值 156 | self.decrypt_progressbar['maximum'] = 100 157 | # 进度值初始值 158 | self.decrypt_progressbar['value'] = 0 159 | # 解密过程禁用下一步按钮 160 | self.next_step_button.config(state=tkinter.DISABLED) 161 | self.decrypter.decrypt() 162 | if self.page_stage == 3: 163 | self.decrypt_note.place_forget() 164 | self.decrypt_progressbar.place_forget() 165 | self.init_export_page() 166 | # 不再有下一步按钮 167 | self.next_step_button.place_forget() 168 | # 初始化视频导出器 169 | self.video_decrypter = VideoDecrypter(self, self.account_info.get("filePath")) 170 | # 初始化图片导出器 171 | self.image_decrypter = ImageDecrypter(self, self.account_info.get("filePath")) 172 | 173 | 174 | self.page_stage = self.page_stage + 1 175 | 176 | def init_export_page(self): 177 | 178 | from app.DataBase import micro_msg_db 179 | contact_datas = micro_msg_db.get_contact() 180 | 181 | contacts = [] 182 | for c in contact_datas: 183 | contact = Contact(c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], c[8], c[9], c[10], c[11]) 184 | contacts.append(contact) 185 | 186 | def validate_contact(this_contact: Contact): 187 | c_type = this_contact.type 188 | user_name: str = this_contact.userName 189 | 190 | # 不是其他号码 191 | is_misc_account = c_type == 1 or c_type == 33 or c_type == 513 192 | # 不是公众号 193 | is_gh_account = user_name.startswith("gh_") 194 | # 不是聊天群 195 | is_chatroom = user_name.endswith("@chatroom") 196 | # 不是文件传输助手 197 | is_filehelper = this_contact.userName == "filehelper" 198 | return (not is_misc_account) and (not is_gh_account) and (not is_chatroom) and (not is_filehelper) 199 | 200 | filtered = filter(validate_contact, contacts) 201 | contacts = list(filtered) 202 | 203 | self.listbox = ListboxWithSearch(self.root, contacts) 204 | self.listbox.frame.place(relx=0.05, rely=0.03) 205 | 206 | self.begin_calendar_label = tkinter.ttk.Label(text="开始日期") 207 | self.begin_calendar_label.place(relx=0.65, rely=0.15) 208 | 209 | # 默认开始时间是100天前 210 | current_date = datetime.now() 211 | half_year_ago = current_date - timedelta(days=100) 212 | self.begin_calendar = tkcalendar.DateEntry(master=self.root, locale="zh_CN", year=half_year_ago.year, 213 | month=half_year_ago.month, day=half_year_ago.day, 214 | maxdate=datetime.now()) 215 | self.begin_calendar.place(relx=0.65, rely=0.2) 216 | 217 | self.end_calendar_label = tkinter.ttk.Label(text="截止日期") 218 | self.end_calendar_label.place(relx=0.65, rely=0.25) 219 | self.end_calendar = tkcalendar.DateEntry(master=self.root, locale="zh_CN", maxdate=datetime.now()) 220 | self.end_calendar.place(relx=0.65, rely=0.3) 221 | 222 | 223 | self.convert_video_var = tkinter.IntVar(value=0) 224 | self.convert_video = tkinter.ttk.Checkbutton(self.root, text='视频转码', variable=self.convert_video_var) 225 | self.convert_video.place(relx=0.65, rely=0.45) 226 | ToolTip(self.convert_video, 227 | "视频原始格式为H265,只支持\nChrome浏览器播放,勾选后\n将视频转码为H264,支持大\n部分浏览器,但导出速度变慢") 228 | 229 | self.confirm_button_text = tkinter.StringVar() 230 | self.confirm_button_text.set("开始导出") 231 | 232 | self.confirm_button = tkinter.ttk.Button(self.root, textvariable=self.confirm_button_text, 233 | command=self.confirm_export) 234 | self.confirm_button.place(relx=0.65, rely=0.6) 235 | 236 | # 导出成功的提示 237 | self.succeed_label = tkinter.Label(self.root, text="导出结束") 238 | self.succeed_label_2 = tkinter.Label(self.root, text="打开文件夹", fg="#0000FF", cursor="hand2") 239 | self.succeed_label_2.bind("", self.open_target_folder) 240 | 241 | # 进度条 242 | self.export_progressbar = tkinter.ttk.Progressbar(self.root, length=150) 243 | 244 | def confirm_export(self): 245 | 246 | if self.html_exporter_thread and not self.html_exporter_thread.stop_flag: 247 | self.html_exporter_thread.stop() 248 | else: 249 | if not self.warning_label: 250 | self.warning_label = tkinter.Label(self.root, fg="red") 251 | self.warning_label.place(relx=0.65, rely=0.55) 252 | 253 | self.warning_label.config(text="") 254 | contacts = self.listbox.get_contacts() 255 | if not contacts: 256 | self.warning_label.config(text=f"请选择至少一个联系人") 257 | return 258 | 259 | if self.begin_calendar.get_date() > self.end_calendar.get_date(): 260 | self.warning_label.config(text=f"开始时间必须小于截止时间") 261 | return 262 | 263 | self.export_progressbar.place(relx=0.64, rely=0.68) 264 | # 进度值最大值 265 | self.export_progressbar['maximum'] = 100 266 | # 进度值初始值 267 | self.export_progressbar['value'] = 0 268 | 269 | current_time = datetime.now() 270 | self.export_dir_name = current_time.strftime("%Y_%m_%d_%H%M%S") 271 | contact_map = {contact.userName: contact for contact in contacts} 272 | 273 | self.confirm_button_text.set("停止导出") 274 | self.succeed_label.place_forget() 275 | self.succeed_label_2.place_forget() 276 | 277 | # 导出线程 278 | self.html_exporter_thread = HtmlExporter(self, self.export_dir_name, contact_map, 279 | self.begin_calendar.get_date(), self.end_calendar.get_date(), 280 | self.convert_video_var.get()) 281 | self.html_exporter_thread.start() 282 | 283 | def update_decrypt_progressbar(self, progress): 284 | self.decrypt_progressbar['value'] = progress 285 | self.root.update() 286 | 287 | def update_export_progressbar(self, progress): 288 | self.export_progressbar['value'] = progress 289 | self.root.update() 290 | 291 | def export_succeed(self): 292 | self.confirm_button_text.set("开始导出") 293 | self.succeed_label.place(relx=0.64, rely=0.75) 294 | self.succeed_label_2.place(relx=0.76, rely=0.75) 295 | 296 | def open_target_folder(self, event): 297 | folder_path = Path(f"output/{self.export_dir_name}/") 298 | # 转换为绝对路径 299 | absolute_path = folder_path.resolve() 300 | os.startfile(absolute_path) 301 | -------------------------------------------------------------------------------- /gui/listbox_with_search.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from entity.contact import Contact 3 | 4 | 5 | class ListboxWithSearch: 6 | 7 | def __init__(self, root, contacts: list[Contact]): 8 | 9 | # key index(在控件里的编号) value Contact 10 | self.index_contact_map = {} 11 | 12 | self.frame = tk.LabelFrame(root, text="请选择导出联系人") 13 | self.tool_frame = tk.Frame(self.frame) 14 | self.tool_frame.pack() 15 | 16 | self.search_label = tk.Label(self.tool_frame, text="搜索:") 17 | self.search_label.pack(side='left') 18 | 19 | self.re = tk.Entry(self.tool_frame, width=10) 20 | self.re.pack(side='left') 21 | self.re.bind("", self.filter) 22 | 23 | self.select_all_button = tk.Button(self.tool_frame, text="全选", command=self.select_all) 24 | self.select_all_button.pack(side='left', padx="10") 25 | 26 | self.invert_select_button = tk.Button(self.tool_frame, text="反选", command=self.invert_select) 27 | self.invert_select_button.pack(side='left', padx="2") 28 | 29 | self.scrollbar = tk.Scrollbar(self.frame) 30 | self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) 31 | 32 | self.lb = tk.Listbox(self.frame, selectmode='multiple', height=20, width=30, exportselection=False, 33 | yscrollcommand=self.scrollbar.set) 34 | self.scrollbar.config(command=self.lb.yview) 35 | 36 | self.lb.bind('<>', self.on_select) 37 | 38 | self.lb.pack() 39 | self.contacts = contacts 40 | 41 | 42 | for index, contact in enumerate(contacts): 43 | text = f'{contact.nickName}({contact.remark})' if contact.remark else f'{contact.nickName}' 44 | self.lb.insert(index, text) 45 | self.index_contact_map[index] = contact 46 | 47 | def select_all(self, event=None): 48 | for index in self.index_contact_map.keys(): 49 | self.lb.select_set(index) 50 | self.on_select() 51 | 52 | def on_select(self, event=None): 53 | selection = self.lb.curselection() 54 | self.frame.config(text=f"已选择{len(selection)}个联系人") 55 | 56 | def invert_select(self, event=None): 57 | """反选""" 58 | selected = self.lb.curselection() 59 | for index in self.index_contact_map.keys(): 60 | if index in selected: 61 | self.lb.selection_clear(index) 62 | else: 63 | self.lb.select_set(index) 64 | self.on_select() 65 | 66 | def get_contacts(self, event=None): 67 | contacts = [] 68 | selected = self.lb.curselection() 69 | for index in selected: 70 | contacts.append(self.index_contact_map.get(index)) 71 | return contacts 72 | 73 | def filter(self, event=None): 74 | p = self.re.get() 75 | if p: 76 | for index, contact in self.index_contact_map.items(): 77 | text = f'{contact.nickName}({contact.remark})' if contact.remark else f'{contact.nickName}' 78 | if p in text: 79 | self.lb.yview(index) 80 | break 81 | -------------------------------------------------------------------------------- /gui/tool_tip.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | 4 | class ToolTip: 5 | def __init__(self, widget, text): 6 | self.widget = widget 7 | self.text = text 8 | self.tooltip = None 9 | self.widget.bind("", self.show_tooltip) 10 | self.widget.bind("", self.hide_tooltip) 11 | 12 | def show_tooltip(self, event=None): 13 | x = y = 0 14 | x, y, _, _ = self.widget.bbox("insert") 15 | x += self.widget.winfo_rootx() + 25 16 | y += self.widget.winfo_rooty() + 25 17 | self.tooltip = tk.Toplevel(self.widget) 18 | self.tooltip.wm_overrideredirect(True) 19 | self.tooltip.wm_geometry(f"+{x}+{y}") 20 | label = tk.Label(self.tooltip, text=self.text, background="#ffffe0", relief="solid", borderwidth=1) 21 | label.pack(ipadx=1) 22 | 23 | def hide_tooltip(self, event=None): 24 | if self.tooltip: 25 | self.tooltip.destroy() 26 | self.tooltip = None -------------------------------------------------------------------------------- /helper/auto_scroll.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | import threading 4 | import pywintypes 5 | import win32api 6 | import win32con 7 | import win32gui 8 | 9 | 10 | class AutoScroll(threading.Thread): 11 | 12 | def __init__(self, gui, moments_hwnd): 13 | self.gui = gui 14 | self.moments_hwnd = moments_hwnd 15 | self.scrolling = False 16 | super().__init__() 17 | 18 | def run(self) -> None: 19 | self.scrolling = True 20 | while True: 21 | if self.scrolling: 22 | try: 23 | rect = win32gui.GetWindowRect(self.moments_hwnd) 24 | x = (rect[0] + rect[2]) // 2 25 | y = (rect[1] + rect[3]) // 2 26 | notch = 10 27 | win32api.SendMessage(self.moments_hwnd, win32con.WM_MOUSEWHEEL, 28 | win32api.MAKELONG(0, -120 * notch), win32api.MAKELONG(x, y)) 29 | 30 | self.gui.flood_moments_note.pack() 31 | random_sleep = random.uniform(0.7, 0.8) 32 | time.sleep(random_sleep) 33 | except pywintypes.error as e: 34 | self.moments_hwnd = win32gui.FindWindow("SnsWnd", '朋友圈') 35 | self.gui.flood_moments_note.pack_forget() 36 | time.sleep(1) 37 | else: 38 | self.gui.flood_moments_note.pack_forget() 39 | time.sleep(1) 40 | 41 | def set_scrolling(self, scrolling: bool) -> None: 42 | self.scrolling = scrolling -------------------------------------------------------------------------------- /helper/auto_scroll_single.py: -------------------------------------------------------------------------------- 1 | import math 2 | import threading 3 | import time 4 | import traceback 5 | import pyautogui 6 | import pyperclip 7 | import win32con 8 | import win32gui 9 | from retry import retry 10 | from win32api import GetSystemMetrics 11 | 12 | import log 13 | 14 | 15 | class AutoScrollSingle(threading.Thread): 16 | 17 | def __init__(self, gui, search_hwnd, friend_name): 18 | self.gui = gui 19 | self.search_hwnd = search_hwnd 20 | self.friend_name = friend_name 21 | self.scrolling = False 22 | self.resolutions = ['', '1920', '1600', '2560_125', '2560_175', '2560_100', '1366'] 23 | super().__init__() 24 | 25 | @retry(tries=5, delay=2) 26 | def find_moments_tab(self): 27 | result = None 28 | for resolution in self.resolutions: 29 | try: 30 | result = pyautogui.locateCenterOnScreen(f'resource/auto_gui/{resolution}/moments_tab.png', 31 | grayscale=True, confidence=0.8) 32 | break 33 | except Exception as e: 34 | log.LOG.warn("Can't find_moments_tab in resolution: " + resolution) 35 | pass 36 | 37 | if result is None: 38 | raise Exception("Can 't find_moments_tab") 39 | 40 | return result 41 | 42 | @retry(tries=5, delay=2) 43 | def find_search_button(self): 44 | 45 | element = None 46 | for resolution in self.resolutions: 47 | try: 48 | element = pyautogui.locateOnScreen(f'resource/auto_gui/{resolution}/search_button.png', 49 | grayscale=True, confidence=0.8) 50 | break 51 | except Exception as e: 52 | log.LOG.warn("Can't find_moments_tab in resolution: " + resolution) 53 | pass 54 | 55 | if element is None: 56 | raise Exception("Can 't search_button") 57 | 58 | return element 59 | 60 | @retry(tries=5, delay=2) 61 | def find_friends(self): 62 | result = None 63 | 64 | for resolution in self.resolutions: 65 | try: 66 | result = pyautogui.locateCenterOnScreen(f'resource/auto_gui/{resolution}/friends.png', 67 | grayscale=True, confidence=0.8) 68 | break 69 | except Exception as e: 70 | log.LOG.warn("Can't find_friends in resolution: " + resolution) 71 | pass 72 | 73 | if result is None: 74 | raise Exception("Can 't find_friends") 75 | 76 | return result 77 | 78 | @retry(tries=5, delay=2) 79 | def find_complete(self): 80 | 81 | result = None 82 | 83 | for resolution in self.resolutions: 84 | try: 85 | result = pyautogui.locateCenterOnScreen(f'resource/auto_gui/{resolution}/complete.png', 86 | grayscale=True, confidence=0.8) 87 | break 88 | except Exception as e: 89 | log.LOG.warn("Can't find_complete in resolution: " + resolution) 90 | pass 91 | 92 | if result is None: 93 | raise Exception("Can 't find_complete") 94 | 95 | return result 96 | 97 | def run(self) -> None: 98 | self.scrolling = True 99 | 100 | try: 101 | search_hwnd = win32gui.FindWindow('Chrome_WidgetWin_0', '微信') 102 | wechat_hwnd = win32gui.FindWindow('WeChatMainWndForPC', '微信') 103 | 104 | # 先把微信主窗口放置前台 105 | win32gui.SetForegroundWindow(wechat_hwnd) 106 | win32gui.ShowWindow(wechat_hwnd, win32con.SW_SHOWNORMAL) 107 | win32gui.SetWindowPos(wechat_hwnd, None, 100, 100, 0, 0, win32con.SWP_NOSIZE) 108 | time.sleep(0.3) 109 | # 先把搜一搜窗口放前台 110 | win32gui.SetForegroundWindow(search_hwnd) 111 | win32gui.ShowWindow(search_hwnd, win32con.SW_SHOWNORMAL) 112 | win32gui.SetWindowPos(search_hwnd, None, 50, 50, 0, 0, win32con.SWP_NOSIZE) 113 | 114 | 115 | # 点击朋友圈三个字 116 | x, y = self.find_moments_tab() 117 | pyautogui.click(x, y) 118 | time.sleep(0.1) 119 | 120 | # 点击搜索按钮左侧 121 | element = self.find_search_button() 122 | pyautogui.click(element.left - 100, element.top + element.height / 2) 123 | time.sleep(0.25) 124 | 125 | # 输入字符 126 | pyautogui.write('1') 127 | time.sleep(0.25) 128 | 129 | # 搜索 130 | pyautogui.click(element.left + element.width / 2, element.top + element.height / 2) 131 | time.sleep(1.5) 132 | 133 | # 展开朋友 134 | x, y = self.find_friends() 135 | pyautogui.click(x, y) 136 | time.sleep(0.5) 137 | 138 | # 搜索好友 139 | pyperclip.copy(self.friend_name) 140 | time.sleep(0.25) 141 | pyautogui.hotkey('ctrl', 'v') 142 | time.sleep(0.5) 143 | 144 | # 回车 145 | pyautogui.press('enter') 146 | time.sleep(0.5) 147 | 148 | # 点击完成 149 | x, y = self.find_complete() 150 | pyautogui.click(x, y) 151 | time.sleep(0.25) 152 | 153 | # 点击搜索按钮左侧 154 | element = self.find_search_button() 155 | pyautogui.click(element.left - 100, element.top + element.height / 2) 156 | time.sleep(0.25) 157 | 158 | pyautogui.press('backspace') 159 | time.sleep(0.1) 160 | pyautogui.press('backspace') 161 | time.sleep(0.25) 162 | pyperclip.copy('?') 163 | time.sleep(0.25) 164 | pyautogui.hotkey('ctrl', 'v') 165 | time.sleep(0.25) 166 | 167 | 168 | 169 | element = self.find_search_button() 170 | pyautogui.click(element.left + element.width / 2, element.top + element.height / 2) 171 | time.sleep(1.0) 172 | 173 | while self.scrolling: 174 | 175 | element = self.find_search_button() 176 | right_bottom = (element.left + element.width, element.top + element.height + 300) 177 | pyautogui.scroll(-120) 178 | pyautogui.click(right_bottom) 179 | time.sleep(0.2) 180 | 181 | search_hwnd = win32gui.FindWindow('Chrome_WidgetWin_0', '微信') 182 | moments_hwnd = win32gui.FindWindow('SnsWnd', '朋友圈') 183 | 184 | if search_hwnd and moments_hwnd: 185 | # 调整位置朋友圈不要遮挡 186 | width = GetSystemMetrics(0) 187 | win32gui.SetWindowPos(moments_hwnd, None, 50, 50, 0, 0, win32con.SWP_NOSIZE) 188 | win32gui.SetWindowPos(search_hwnd, None, 50, 50, 0, 0, win32con.SWP_NOSIZE) 189 | 190 | 191 | except Exception: 192 | traceback.print_exc() 193 | 194 | def set_scrolling(self, scrolling: bool) -> None: 195 | self.scrolling = scrolling 196 | if not self.scrolling: 197 | self.gui.working_note.pack_forget() 198 | if self.scrolling and self.gui.working_note: 199 | self.gui.working_note.pack() 200 | -------------------------------------------------------------------------------- /log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | import time 5 | 6 | filename = time.strftime("%Y-%m-%d", time.localtime(time.time())) 7 | 8 | try: 9 | if not os.path.exists('log'): 10 | os.mkdir('log') 11 | log_file = f'log/{filename}-log.log' 12 | console_file = f'log/{filename}-output.log' 13 | except: 14 | log_file = f'{filename}-log.log' 15 | console_file = f'{filename}-output.log' 16 | 17 | if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): 18 | # pyinstaller 输出日志到文件 19 | f = open(console_file, 'a') 20 | sys.stdout = f 21 | sys.stderr = f 22 | 23 | file_handler = logging.FileHandler(log_file, encoding='utf-8') 24 | logging.basicConfig(level='DEBUG', format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") 25 | logging.getLogger().addHandler(file_handler) 26 | LOG = logging.getLogger("WechatMoments") 27 | 28 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import tkinter 3 | import traceback 4 | from time import sleep 5 | from pywxdump import read_info 6 | 7 | def main(): 8 | from gui.gui import Gui 9 | 10 | gui = Gui() 11 | gui_thread = threading.Thread(target=gui.run_gui) 12 | gui_thread.start() 13 | 14 | info = "" 15 | while True: 16 | try: 17 | info = read_info(None, is_logging=True) 18 | except: 19 | traceback.print_exc() 20 | # 如果解密失败,读取到报错信息 21 | if isinstance(info, str): 22 | gui.waiting_label.config(text="请启动微信....") 23 | sleep(0.5) 24 | elif isinstance(info, list) and info[0].get("key") == "None": 25 | gui.waiting_label.config(text="请登陆微信....") 26 | sleep(0.5) 27 | else: 28 | break 29 | 30 | gui.wechat_logged_in(info[0]) 31 | 32 | 33 | if __name__ == "__main__": 34 | main() 35 | -------------------------------------------------------------------------------- /main.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ['main.py'], 6 | pathex=[], 7 | binaries=[], 8 | hiddenimports=['babel.numbers'], 9 | hookspath=[], 10 | hooksconfig={}, 11 | runtime_hooks=[], 12 | excludes=[], 13 | noarchive=False, 14 | ) 15 | pyz = PYZ(a.pure) 16 | 17 | exe = EXE( 18 | pyz, 19 | a.scripts, 20 | [], 21 | exclude_binaries=True, 22 | name='wechat_moments', 23 | debug=False, 24 | bootloader_ignore_signals=False, 25 | strip=False, 26 | upx=True, 27 | console=False, 28 | disable_windowed_traceback=False, 29 | argv_emulation=False, 30 | target_arch=None, 31 | codesign_identity=None, 32 | entitlements_file=None 33 | ) 34 | coll = COLLECT( 35 | exe, 36 | a.binaries, 37 | a.datas, 38 | strip=False, 39 | upx=True, 40 | upx_exclude=[], 41 | name='wechat_moments', 42 | ) 43 | 44 | # 将资源文件夹拷贝出来 45 | import shutil 46 | shutil.copytree('resource', f'{DISTPATH}/wechat_moments/resource') 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pywxdump~=2.4.44 2 | xmltodict~=0.13.0 3 | dataclasses_json 4 | pillow~=10.2.0 5 | tkcalendar~=1.6.1 6 | pywin32 7 | requests~=2.31.0 8 | fileType 9 | pyautogui 10 | opencv-python 11 | retry -------------------------------------------------------------------------------- /resource/auto_gui/1366/complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1366/complete.png -------------------------------------------------------------------------------- /resource/auto_gui/1366/friends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1366/friends.png -------------------------------------------------------------------------------- /resource/auto_gui/1366/moments_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1366/moments_tab.png -------------------------------------------------------------------------------- /resource/auto_gui/1366/search_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1366/search_button.png -------------------------------------------------------------------------------- /resource/auto_gui/1600/complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1600/complete.png -------------------------------------------------------------------------------- /resource/auto_gui/1600/friends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1600/friends.png -------------------------------------------------------------------------------- /resource/auto_gui/1600/moments_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1600/moments_tab.png -------------------------------------------------------------------------------- /resource/auto_gui/1600/search_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1600/search_button.png -------------------------------------------------------------------------------- /resource/auto_gui/1920/complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1920/complete.png -------------------------------------------------------------------------------- /resource/auto_gui/1920/friends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1920/friends.png -------------------------------------------------------------------------------- /resource/auto_gui/1920/moments_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1920/moments_tab.png -------------------------------------------------------------------------------- /resource/auto_gui/1920/search_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/1920/search_button.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_100/complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_100/complete.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_100/friends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_100/friends.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_100/moments_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_100/moments_tab.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_100/search_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_100/search_button.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_125/complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_125/complete.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_125/friends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_125/friends.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_125/moments_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_125/moments_tab.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_125/search_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_125/search_button.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_175/complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_175/complete.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_175/friends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_175/friends.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_175/moments_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_175/moments_tab.png -------------------------------------------------------------------------------- /resource/auto_gui/2560_175/search_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/2560_175/search_button.png -------------------------------------------------------------------------------- /resource/auto_gui/complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/complete.png -------------------------------------------------------------------------------- /resource/auto_gui/friends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/friends.png -------------------------------------------------------------------------------- /resource/auto_gui/moments_tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/moments_tab.png -------------------------------------------------------------------------------- /resource/auto_gui/search_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/auto_gui/search_button.png -------------------------------------------------------------------------------- /resource/ffmpeg.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/ffmpeg.exe -------------------------------------------------------------------------------- /resource/ffprobe.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/ffprobe.exe -------------------------------------------------------------------------------- /resource/gui_pictures/open_moments_guide -原始.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/gui_pictures/open_moments_guide -原始.png -------------------------------------------------------------------------------- /resource/gui_pictures/open_moments_guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/gui_pictures/open_moments_guide.png -------------------------------------------------------------------------------- /resource/gui_pictures/open_search_guide-原始.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/gui_pictures/open_search_guide-原始.png -------------------------------------------------------------------------------- /resource/gui_pictures/open_search_guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tech-shrimp/WechatMoments/958c33900d87dccfb9b6d8d57ea8765fe4da0522/resource/gui_pictures/open_search_guide.png -------------------------------------------------------------------------------- /resource/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 31 | 32 | 35 | 36 | 47 | 48 |
49 |
50 | 朋友圈 51 |
52 |
53 | 54 |
55 | 56 |
57 |

{my_name}

58 |
59 | 60 |
61 |
62 |
63 |
64 | /*内容分割线*/ 65 |

已显示全部内容

66 |
67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /resource/template/css/index.css: -------------------------------------------------------------------------------- 1 | *{margin: 0;padding: 0;} 2 | 3 | 4 | @media screen and (max-width:414px ) { 5 | html{font-size:8px ;} 6 | } 7 | @media screen and (min-width:415px ) and (max-width:878px) { 8 | html{font-size:16px ;} 9 | } 10 | @media screen and (min-width:879px ){ 11 | html{font-size:24px ;} 12 | } 13 | 14 | P{margin-bottom:0.4rem;} 15 | 16 | .header{ 17 | max-width: 800px; 18 | margin: 0 auto; 19 | width:100% ; 20 | background-color: #EDEDED; 21 | color:black; 22 | overflow: hidden; 23 | font-size:1rem ; 24 | padding:0.2rem 0; 25 | position:fixed; 26 | z-index:100; 27 | } 28 | 29 | body { 30 | max-width: 800px; 31 | margin: 0 auto; 32 | } 33 | 34 | 35 | .cover_container .cover_img{ 36 | width:100%; 37 | height:25rem; 38 | object-fit:cover 39 | } 40 | 41 | .cover_container{ 42 | position: relative; 43 | padding-top:0.2rem; 44 | } 45 | 46 | .avatar{height: 3.2rem;position: absolute ;bottom: -1rem;right: 0.8rem;} 47 | .avatar h4{float:left ;color:#FFFFFF;margin-top:1rem ;font-size:1rem ;margin-right:1rem ; text-shadow:1px 1px 0 #000 ;} 48 | .usr_img_box{width:3rem ;height:3rem ;overflow: hidden;border-radius:0.3rem;} 49 | .user_logo{width:100%;height: 100%;} 50 | 51 | .text_box{width:100% ;overflow: hidden;} 52 | .logo01_box{width:2.5rem ;height:2.5rem ;overflow: hidden;border-radius:0.3rem;} 53 | .logo01_box img{width:100% ;height: 100%;} 54 | .item{border-bottom:1px solid whitesmoke ;margin-top:1rem ;} 55 | .p1{font-size:0.9rem ;color:#2e4f7a ;font-weight: bold;} 56 | .p2{font-size:0.8rem ;color:#000000 ;width:95% ;} 57 | .logo01_box{margin-left:0.5rem ;} 58 | .xs8{padding-left:0.8rem ;} 59 | 60 | .out_link {background-color:#F7F7F7 ;font-size:0.7rem ;width:95% ;color:#41454D ;overflow: hidden; cursor:pointer;} 61 | .out_link img{width:2.5rem ;height:2.5rem ;margin:0.5rem 0.5rem ;float: left;} 62 | 63 | .music_link {background-color:#F7F7F7 ;font-size:0.7rem ;width:95% ;color:#41454D ;overflow: hidden; cursor:pointer; display: flex; flex-direction:column} 64 | .music_des {width:100%;display: flex;} 65 | .music_title_musician {width:100%;margin-left: 1.5rem;display: flex;flex-direction:column} 66 | .music_title {font-size:1.0rem;margin-top: 0.5rem;} 67 | .music_musician {font-size:0.85rem;margin-top: 0.5rem;} 68 | .music_link img{width:4rem ;height:4rem ;margin:0.2rem 0.2rem ;float: left;} 69 | .music_audio {height: 1.5rem; width: 95%;} 70 | 71 | 72 | .text{float: left;margin-top: 1.1rem;width:80% ;} 73 | .pl{margin-top:0.5rem ;font-size:0.6rem ;color:#80858c ;clear: both;} 74 | .pl span{display:inline-block ;width:2rem ;} 75 | 76 | .pls{width:100% ;overflow:hidden ;color: #41454D;font-size:0.8rem ;} 77 | .pls span{color:#5BAFFF ;} 78 | .pls p{width:95% ;} 79 | 80 | .text_02{margin-top: 0.9rem;} 81 | .dele{display: inline-block;margin-left:2rem ;width:1rem ;height: auto;cursor:pointer ;} 82 | .up .down{color:#ACB1B7;font-size:0.6rem ;cursor:pointer ;} 83 | .pls img{width:1rem ;height:auto ;} 84 | .end{text-align:center ;margin:0.6rem 0 1rem 0 ;font-size:0.9rem ;color:#ACB1B7 ;} 85 | 86 | 87 | #fullSizeOverlay { 88 | display: none; 89 | position: fixed; 90 | top: 0; 91 | left: 0; 92 | width: 100%; 93 | height: 100%; 94 | background-color: rgba(0, 0, 0, 0.8); 95 | z-index: 9999; 96 | text-align: center; 97 | } 98 | 99 | #fullSizeImage { 100 | max-width: 85%; 101 | max-height: 85%; 102 | margin-top: 3%; 103 | } 104 | 105 | .time{font-size:0.6rem ;color:#ACB1B7 ;margin-top:0.5rem ;} 106 | .location{font-size:0.6rem ;color:#2e4f7a;margin-top:0.5rem ;} 107 | 108 | .emoji_img { 109 | max-width: 1.0rem; 110 | max-height: 1.0rem; 111 | padding-bottom: 0.15rem; 112 | } 113 | 114 | .alert { 115 | position: relative; 116 | top: 10; 117 | left: 0; 118 | width: auto; 119 | height: auto; 120 | padding: 10px; 121 | margin: 10px; 122 | line-height: 1.8; 123 | border-radius: 5px; 124 | cursor: hand; 125 | cursor: pointer; 126 | font-family: sans-serif; 127 | font-weight: 400; 128 | } 129 | 130 | .alertCheckbox { 131 | display: none; 132 | } 133 | 134 | :checked + .alert { 135 | display: none; 136 | } 137 | 138 | .alertText { 139 | display: table; 140 | margin: 0 auto; 141 | text-align: center; 142 | font-size: 16px; 143 | } 144 | 145 | .alertClose { 146 | float: right; 147 | padding-left: 10px; 148 | font-size: 16px; 149 | margin-bottom:15px; 150 | } 151 | 152 | 153 | .clear { 154 | clear: both; 155 | } 156 | 157 | .info { 158 | background-color: #EEE; 159 | border: 1px solid #DDD; 160 | color: #999; 161 | } 162 | 163 | #warningOverlay { 164 | display: none; 165 | position: fixed; 166 | top: 70%; 167 | left: 30%; 168 | width: 40%; 169 | z-index: 9999; 170 | text-align: center; 171 | } -------------------------------------------------------------------------------- /resource/template/icons/comment-downarrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | enter-arrow 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /resource/template/icons/comment-uparrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | enter-arrow 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /resource/template/icons/massage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | massage 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /resource/template/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.2 (http://getbootstrap.com) 3 | * Copyright 2011-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){return a(b.target).is(this)?b.handleObj.handler.apply(this,arguments):void 0}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.2",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a(f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.2",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active"));a&&this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target);d.hasClass("btn")||(d=d.closest(".btn")),b.call(d,"toggle"),c.preventDefault()}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.2",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));return a>this.$items.length-1||0>a?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){return this.sliding?void 0:this.slide("next")},c.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&"show"==b&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a(this.options.trigger).filter('[href="#'+b.id+'"], [data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.2",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0,trigger:'[data-toggle="collapse"]'},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":a.extend({},e.data(),{trigger:this});c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){b&&3===b.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=c(d),f={relatedTarget:this};e.hasClass("open")&&(e.trigger(b=a.Event("hide.bs.dropdown",f)),b.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger("hidden.bs.dropdown",f)))}))}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.2",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a('