Add GUI, packaging and export/voice tools
Introduce a tkinter GUI and tooling to produce a single executable and export/convert message data. Adds app_gui.py (GUI launcher that runs decrypt/export/voice subtasks), export_messages.py (export messages to CSV/HTML/JSON), voice_to_mp3.py (extract SILK_V3 voice blobs and convert to MP3 via pilk + ffmpeg), WeChatDecrypt.spec and build.bat (PyInstaller spec and convenience build script), and EXE_USAGE.md (usage for the standalone exe). Update config.py to detect the application base directory when packaged, update README.md to document the GUI and packaging flow, and add pilk/pyinstaller to requirements.txt. Also expand .gitignore with common IDE/build/temp patterns and add output/data directories to ignore. These changes enable one-file packaging and provide end-user tools for decrypting, exporting and converting audio.
This commit is contained in:
406
.gitignore
vendored
406
.gitignore
vendored
@@ -1,25 +1,389 @@
|
|||||||
# Decrypted databases and keys - NEVER upload
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
all_keys.json
|
## files generated by popular Visual Studio add-ons.
|
||||||
wechat_key.txt
|
##
|
||||||
config.json
|
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||||
decrypted/
|
|
||||||
decoded_images/
|
|
||||||
*.db
|
|
||||||
*.db-shm
|
|
||||||
*.db-wal
|
|
||||||
*.db.tmp_monitor
|
|
||||||
|
|
||||||
# Hook outputs
|
# User-specific files
|
||||||
hook_output.txt
|
*.rsuser
|
||||||
hook_start_output.txt
|
*.suo
|
||||||
hook_stderr.txt
|
*.user
|
||||||
run_hook.bat
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
# Python
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Oo]ut/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET Core
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*.egg-info/
|
|
||||||
|
|
||||||
# OS
|
# Cake - Uncomment if you are using it
|
||||||
.DS_Store
|
# tools/**
|
||||||
Thumbs.db
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
/.bevel
|
||||||
|
/.vscode
|
||||||
|
|
||||||
|
# Node.js / npm
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
.npm
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# VitePress
|
||||||
|
docs/.vitepress/cache
|
||||||
|
docs/.vitepress/dist
|
||||||
|
.temp
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Temporary build artifacts
|
||||||
|
x64/
|
||||||
|
|
||||||
|
data/
|
||||||
|
export/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
decrypted/
|
||||||
|
all_keys.json
|
||||||
|
config.json
|
||||||
106
EXE_USAGE.md
Normal file
106
EXE_USAGE.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# WeChat Decrypt 工具箱 使用说明
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. **启动微信**并登录账号
|
||||||
|
2. 双击 `WeChatDecrypt.exe` 打开工具箱
|
||||||
|
3. 按顺序点击三个按钮:
|
||||||
|
- **① 解密数据库** → 从微信进程提取密钥并解密数据库到 `decrypted/` 目录
|
||||||
|
- **② 导出消息** → 将聊天记录导出为 CSV / HTML / JSON 到 `export/` 目录
|
||||||
|
- **③ 转换音频** → 将语音消息从 SILK 格式转为 MP3 到 `data/` 目录
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
- Windows 10 / 11
|
||||||
|
- 微信 PC 版已登录(解密时需要微信进程运行)
|
||||||
|
- [FFmpeg](https://ffmpeg.org/download.html) 已安装并加入 PATH(转换音频需要)
|
||||||
|
|
||||||
|
### 检查 FFmpeg
|
||||||
|
|
||||||
|
打开命令提示符,输入:
|
||||||
|
```
|
||||||
|
ffmpeg -version
|
||||||
|
```
|
||||||
|
如果提示"不是内部或外部命令",需要先安装 FFmpeg。
|
||||||
|
|
||||||
|
## 输出目录说明
|
||||||
|
|
||||||
|
运行后在 exe 所在目录下生成以下文件夹:
|
||||||
|
|
||||||
|
```
|
||||||
|
WeChatDecrypt.exe
|
||||||
|
config.json ← 首次运行自动生成的配置文件
|
||||||
|
decrypted/ ← ① 解密后的数据库文件
|
||||||
|
export/ ← ② 导出的聊天记录
|
||||||
|
张三/
|
||||||
|
.info ← 联系人信息(username/alias/remark/nick_name)
|
||||||
|
message_0.db.csv ← CSV 格式(Excel 可直接打开)
|
||||||
|
message_0.db.html← HTML 格式(浏览器打开,微信气泡样式)
|
||||||
|
message_0.db.json← JSON 格式(程序处理用)
|
||||||
|
李四/
|
||||||
|
...
|
||||||
|
data/ ← ③ 语音 MP3 文件
|
||||||
|
张三/
|
||||||
|
.info
|
||||||
|
20250101_120000_1.mp3
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 导出格式说明
|
||||||
|
|
||||||
|
### CSV
|
||||||
|
- 编码:UTF-8 with BOM,Excel 双击即可正确显示中文
|
||||||
|
- 字段:时间、发送者、消息类型、内容、server_id
|
||||||
|
|
||||||
|
### HTML
|
||||||
|
- 浏览器打开,模拟微信聊天界面
|
||||||
|
- 左侧气泡为接收消息,右侧为发送消息
|
||||||
|
- 按日期自动分组
|
||||||
|
|
||||||
|
### JSON
|
||||||
|
- 完整结构化数据,包含所有元信息
|
||||||
|
- 适合程序二次处理或 AI 训练
|
||||||
|
|
||||||
|
## 配置文件
|
||||||
|
|
||||||
|
首次运行会自动检测微信数据目录并生成 `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"db_dir": "D:\\xwechat_files\\wxid_xxx\\db_storage",
|
||||||
|
"keys_file": "all_keys.json",
|
||||||
|
"decrypted_dir": "decrypted",
|
||||||
|
"wechat_process": "Weixin.exe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果自动检测失败,请手动修改 `db_dir` 为你的微信数据目录。
|
||||||
|
路径可在:微信设置 → 文件管理 中找到。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q: 点击"解密数据库"提示未检测到微信进程**
|
||||||
|
A: 请确保微信 PC 版已启动并登录,然后重试。
|
||||||
|
|
||||||
|
**Q: 解密失败 / 密钥提取失败**
|
||||||
|
A: 检查 `config.json` 中的 `db_dir` 是否与当前登录的微信账号匹配。切换账号后需要删除 `all_keys.json` 重新提取。
|
||||||
|
|
||||||
|
**Q: 转换音频没有输出**
|
||||||
|
A: 确认已安装 FFmpeg 并加入系统 PATH。确认已先执行"① 解密数据库"。
|
||||||
|
|
||||||
|
**Q: 导出消息为空**
|
||||||
|
A: 确认已先执行"① 解密数据库",且 `decrypted/message/` 下有 `.db` 文件。
|
||||||
|
|
||||||
|
**Q: 目录名是 wxid_xxx 而不是昵称**
|
||||||
|
A: 该联系人不在通讯录中(contact.db 无记录),会使用原始 username。
|
||||||
|
|
||||||
|
## 自行打包
|
||||||
|
|
||||||
|
安装依赖后双击 `build.bat` 即可重新打包:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install pyinstaller pycryptodome zstandard pilk
|
||||||
|
build.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
输出文件:`dist\WeChatDecrypt.exe`
|
||||||
31
README.md
31
README.md
@@ -175,6 +175,10 @@ python find_image_key.py
|
|||||||
| 文件 | 说明 |
|
| 文件 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `main.py` | **一键启动入口** — 自动配置、提取密钥、启动服务 |
|
| `main.py` | **一键启动入口** — 自动配置、提取密钥、启动服务 |
|
||||||
|
| `app_gui.py` | **GUI 工具箱** — tkinter 界面,整合解密/导出/音频转换 |
|
||||||
|
| `export_messages.py` | 聊天记录导出(CSV / HTML / JSON) |
|
||||||
|
| `voice_to_mp3.py` | 语音消息 SILK 转 MP3 |
|
||||||
|
| `build.bat` | 一键打包为单 exe(PyInstaller) |
|
||||||
| `config.py` | 配置加载器(自动检测微信数据目录) |
|
| `config.py` | 配置加载器(自动检测微信数据目录) |
|
||||||
| `find_all_keys.py` | 平台分发入口(Windows / Linux) |
|
| `find_all_keys.py` | 平台分发入口(Windows / Linux) |
|
||||||
| `find_all_keys_windows.py` | Windows 版内存扫描提 key |
|
| `find_all_keys_windows.py` | Windows 版内存扫描提 key |
|
||||||
@@ -191,6 +195,33 @@ python find_image_key.py
|
|||||||
|
|
||||||
## 技术细节
|
## 技术细节
|
||||||
|
|
||||||
|
### GUI 工具箱 & 单 exe 打包
|
||||||
|
|
||||||
|
提供 tkinter 图形界面 (`app_gui.py`),集成三个核心功能:
|
||||||
|
|
||||||
|
1. **解密数据库** — 调用 `main.py decrypt`
|
||||||
|
2. **导出消息** — 调用 `export_messages.py`,输出 CSV / HTML / JSON
|
||||||
|
3. **转换音频** — 调用 `voice_to_mp3.py`,SILK_V3 → MP3
|
||||||
|
|
||||||
|
#### 直接运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app_gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 打包为单 exe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pyinstaller
|
||||||
|
build.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
输出 `dist\WeChatDecrypt.exe`(约 18MB),双击即可使用,无需安装 Python。
|
||||||
|
|
||||||
|
> 转换音频需要系统安装 [FFmpeg](https://ffmpeg.org/download.html) 并加入 PATH。
|
||||||
|
|
||||||
|
详细说明见 [EXE_USAGE.md](EXE_USAGE.md)。
|
||||||
|
|
||||||
### WAL 处理
|
### WAL 处理
|
||||||
|
|
||||||
微信使用 SQLite WAL 模式,WAL 文件是**预分配固定大小** (4MB)。检测变化时:
|
微信使用 SQLite WAL 模式,WAL 文件是**预分配固定大小** (4MB)。检测变化时:
|
||||||
|
|||||||
45
WeChatDecrypt.spec
Normal file
45
WeChatDecrypt.spec
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
|
datas = [('main.py', '.'), ('config.py', '.'), ('decrypt_db.py', '.'), ('export_messages.py', '.'), ('voice_to_mp3.py', '.'), ('find_all_keys.py', '.'), ('find_all_keys_windows.py', '.'), ('find_all_keys_linux.py', '.'), ('key_scan_common.py', '.'), ('key_utils.py', '.'), ('decode_image.py', '.'), ('find_image_key.py', '.'), ('find_image_key_monitor.py', '.'), ('monitor.py', '.'), ('monitor_web.py', '.'), ('mcp_server.py', '.'), ('config.example.json', '.')]
|
||||||
|
binaries = []
|
||||||
|
hiddenimports = []
|
||||||
|
tmp_ret = collect_all('pilk')
|
||||||
|
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['app_gui.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=binaries,
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='WeChatDecrypt',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
247
app_gui.py
Normal file
247
app_gui.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""WeChat Decrypt GUI — 一键解密 / 导出消息 / 转换音频"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, scrolledtext
|
||||||
|
|
||||||
|
# 确保工作目录为脚本所在目录(打包后也适用)
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
BASE_DIR = os.path.dirname(sys.executable)
|
||||||
|
else:
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
os.chdir(BASE_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 子任务入口(当以 --task 参数调用时直接执行对应脚本) ──────────────────────
|
||||||
|
|
||||||
|
# 显式导入:让 PyInstaller 收集子脚本需要的所有依赖
|
||||||
|
# (这些脚本通过 exec 动态加载,PyInstaller 无法自动检测)
|
||||||
|
import importlib.util # noqa: F401 - used for dynamic loading
|
||||||
|
if False: # noqa: never executed, only for PyInstaller dependency detection
|
||||||
|
import sqlite3, hashlib, csv, json, re, glob, tempfile # noqa: F401
|
||||||
|
import xml.etree.ElementTree # noqa: F401
|
||||||
|
import functools, platform, ctypes, ctypes.wintypes # noqa: F401
|
||||||
|
import zstandard # noqa: F401
|
||||||
|
import pilk # noqa: F401
|
||||||
|
import Crypto, Crypto.Cipher, Crypto.Cipher.AES, Crypto.Util.Padding # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def _run_subtask(task: str):
|
||||||
|
"""在子进程中被调用,直接执行对应脚本逻辑"""
|
||||||
|
# 强制 stdout/stderr 为 UTF-8
|
||||||
|
if sys.platform == "win32":
|
||||||
|
for s in (sys.stdout, sys.stderr):
|
||||||
|
if hasattr(s, "reconfigure"):
|
||||||
|
s.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
# onefile: _MEIPASS 临时目录; onedir: _internal/; 开发: BASE_DIR
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
script_dir = getattr(sys, "_MEIPASS", os.path.join(os.path.dirname(sys.executable), "_internal"))
|
||||||
|
else:
|
||||||
|
script_dir = BASE_DIR
|
||||||
|
|
||||||
|
# 让 import 能找到脚本同目录的模块
|
||||||
|
if script_dir not in sys.path:
|
||||||
|
sys.path.insert(0, script_dir)
|
||||||
|
if BASE_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
|
||||||
|
mapping = {
|
||||||
|
"decrypt": "main.py",
|
||||||
|
"export": "export_messages.py",
|
||||||
|
"voice": "voice_to_mp3.py",
|
||||||
|
}
|
||||||
|
script = mapping.get(task)
|
||||||
|
if not script:
|
||||||
|
print(f"未知任务: {task}", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
script_path = os.path.join(script_dir, script)
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
# 开发模式回退到 BASE_DIR
|
||||||
|
script_path = os.path.join(BASE_DIR, script)
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
print(f"脚本不存在: {script_path}", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 将 decrypt 命令传给 main.py
|
||||||
|
if task == "decrypt":
|
||||||
|
sys.argv = ["main.py", "decrypt"]
|
||||||
|
else:
|
||||||
|
sys.argv = [script]
|
||||||
|
|
||||||
|
# 设置环境变量,让 config.py 等脚本知道真正的应用目录
|
||||||
|
os.environ["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
|
||||||
|
os.chdir(BASE_DIR)
|
||||||
|
|
||||||
|
# 加载并执行脚本
|
||||||
|
spec = importlib.util.spec_from_file_location("__main__", script_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
mod.__name__ = "__main__"
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 检查是否为子任务模式 ──────────────────────────────────────────────────────
|
||||||
|
if len(sys.argv) >= 3 and sys.argv[1] == "--task":
|
||||||
|
_run_subtask(sys.argv[2])
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# ── GUI 模式:隐藏控制台窗口 ────────────────────────────────────────────────
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class App(tk.Tk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("WeChat Decrypt 工具箱")
|
||||||
|
self.geometry("750x520")
|
||||||
|
self.resizable(True, True)
|
||||||
|
self.configure(bg="#f0f0f0")
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
# ── UI 构建 ────────────────────────────────────────────────────────────
|
||||||
|
def _build_ui(self):
|
||||||
|
style = ttk.Style(self)
|
||||||
|
style.theme_use("clam")
|
||||||
|
style.configure("Big.TButton", font=("Microsoft YaHei UI", 11), padding=(16, 10))
|
||||||
|
style.configure("TLabel", font=("Microsoft YaHei UI", 10), background="#f0f0f0")
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
title = ttk.Label(self, text="WeChat Decrypt 工具箱", font=("Microsoft YaHei UI", 16, "bold"))
|
||||||
|
title.pack(pady=(14, 6))
|
||||||
|
|
||||||
|
# 按钮区域
|
||||||
|
btn_frame = ttk.Frame(self)
|
||||||
|
btn_frame.pack(fill="x", padx=20, pady=(4, 8))
|
||||||
|
|
||||||
|
self.btn_decrypt = ttk.Button(
|
||||||
|
btn_frame, text="① 解密数据库", style="Big.TButton",
|
||||||
|
command=lambda: self._run_task("decrypt")
|
||||||
|
)
|
||||||
|
self.btn_decrypt.pack(side="left", expand=True, fill="x", padx=4)
|
||||||
|
|
||||||
|
self.btn_export = ttk.Button(
|
||||||
|
btn_frame, text="② 导出消息", style="Big.TButton",
|
||||||
|
command=lambda: self._run_task("export")
|
||||||
|
)
|
||||||
|
self.btn_export.pack(side="left", expand=True, fill="x", padx=4)
|
||||||
|
|
||||||
|
self.btn_voice = ttk.Button(
|
||||||
|
btn_frame, text="③ 转换音频", style="Big.TButton",
|
||||||
|
command=lambda: self._run_task("voice")
|
||||||
|
)
|
||||||
|
self.btn_voice.pack(side="left", expand=True, fill="x", padx=4)
|
||||||
|
|
||||||
|
# 进度条
|
||||||
|
self.progress = ttk.Progressbar(self, mode="indeterminate")
|
||||||
|
self.progress.pack(fill="x", padx=20, pady=(0, 4))
|
||||||
|
|
||||||
|
# 日志区域
|
||||||
|
log_label = ttk.Label(self, text="运行日志:")
|
||||||
|
log_label.pack(anchor="w", padx=20)
|
||||||
|
|
||||||
|
self.log = scrolledtext.ScrolledText(
|
||||||
|
self, wrap="word", height=18,
|
||||||
|
font=("Consolas", 10), bg="#1e1e1e", fg="#d4d4d4",
|
||||||
|
insertbackground="#fff", state="disabled"
|
||||||
|
)
|
||||||
|
self.log.pack(fill="both", expand=True, padx=20, pady=(2, 10))
|
||||||
|
|
||||||
|
# 底部状态
|
||||||
|
self.status_var = tk.StringVar(value="就绪")
|
||||||
|
status = ttk.Label(self, textvariable=self.status_var, font=("Microsoft YaHei UI", 9))
|
||||||
|
status.pack(anchor="w", padx=20, pady=(0, 8))
|
||||||
|
|
||||||
|
# ── 日志写入 ───────────────────────────────────────────────────────────
|
||||||
|
def _log(self, text: str):
|
||||||
|
self.log.configure(state="normal")
|
||||||
|
self.log.insert("end", text)
|
||||||
|
self.log.see("end")
|
||||||
|
self.log.configure(state="disabled")
|
||||||
|
|
||||||
|
def _clear_log(self):
|
||||||
|
self.log.configure(state="normal")
|
||||||
|
self.log.delete("1.0", "end")
|
||||||
|
self.log.configure(state="disabled")
|
||||||
|
|
||||||
|
# ── 按钮状态 ───────────────────────────────────────────────────────────
|
||||||
|
def _set_buttons(self, enabled: bool):
|
||||||
|
state = "normal" if enabled else "disabled"
|
||||||
|
self.btn_decrypt.configure(state=state)
|
||||||
|
self.btn_export.configure(state=state)
|
||||||
|
self.btn_voice.configure(state=state)
|
||||||
|
|
||||||
|
# ── 任务调度 ───────────────────────────────────────────────────────────
|
||||||
|
def _run_task(self, task: str):
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._clear_log()
|
||||||
|
self._set_buttons(False)
|
||||||
|
self.progress.start(15)
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
"decrypt": "解密数据库",
|
||||||
|
"export": "导出消息记录",
|
||||||
|
"voice": "转换音频文件",
|
||||||
|
}
|
||||||
|
self.status_var.set(f"正在{labels[task]}...")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=self._exec_task, args=(task,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _exec_task(self, task: str):
|
||||||
|
try:
|
||||||
|
cmd = [sys.executable, "--task", task]
|
||||||
|
|
||||||
|
self._log(f">>> {' '.join(cmd)}\n\n")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
env["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
cwd=BASE_DIR,
|
||||||
|
env=env,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for raw in proc.stdout:
|
||||||
|
line = raw.decode("utf-8", errors="replace")
|
||||||
|
self.after(0, self._log, line)
|
||||||
|
|
||||||
|
proc.wait()
|
||||||
|
rc = proc.returncode
|
||||||
|
if rc == 0:
|
||||||
|
self.after(0, self._log, "\n✅ 完成!\n")
|
||||||
|
self.after(0, self.status_var.set, "完成")
|
||||||
|
else:
|
||||||
|
self.after(0, self._log, f"\n❌ 进程退出,返回码: {rc}\n")
|
||||||
|
self.after(0, self.status_var.set, f"失败 (返回码 {rc})")
|
||||||
|
except Exception as e:
|
||||||
|
self.after(0, self._log, f"\n❌ 异常: {e}\n")
|
||||||
|
self.after(0, self.status_var.set, "异常")
|
||||||
|
finally:
|
||||||
|
self.after(0, self._on_task_done)
|
||||||
|
|
||||||
|
def _on_task_done(self):
|
||||||
|
self._running = False
|
||||||
|
self.progress.stop()
|
||||||
|
self._set_buttons(True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = App()
|
||||||
|
app.mainloop()
|
||||||
53
build.bat
Normal file
53
build.bat
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
echo ========================================
|
||||||
|
echo WeChatDecrypt 打包脚本
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 检查 pyinstaller
|
||||||
|
where pyinstaller >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [!] 未找到 pyinstaller,正在安装...
|
||||||
|
pip install pyinstaller
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [*] 开始打包...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
pyinstaller --noconfirm --onefile --console --name "WeChatDecrypt" ^
|
||||||
|
--add-data "main.py;." ^
|
||||||
|
--add-data "config.py;." ^
|
||||||
|
--add-data "decrypt_db.py;." ^
|
||||||
|
--add-data "export_messages.py;." ^
|
||||||
|
--add-data "voice_to_mp3.py;." ^
|
||||||
|
--add-data "find_all_keys.py;." ^
|
||||||
|
--add-data "find_all_keys_windows.py;." ^
|
||||||
|
--add-data "find_all_keys_linux.py;." ^
|
||||||
|
--add-data "key_scan_common.py;." ^
|
||||||
|
--add-data "key_utils.py;." ^
|
||||||
|
--add-data "decode_image.py;." ^
|
||||||
|
--add-data "find_image_key.py;." ^
|
||||||
|
--add-data "find_image_key_monitor.py;." ^
|
||||||
|
--add-data "monitor.py;." ^
|
||||||
|
--add-data "monitor_web.py;." ^
|
||||||
|
--add-data "mcp_server.py;." ^
|
||||||
|
--add-data "config.example.json;." ^
|
||||||
|
--collect-all pilk ^
|
||||||
|
app_gui.py
|
||||||
|
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo [!] 打包失败
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo 打包完成!
|
||||||
|
echo 输出: dist\WeChatDecrypt.exe
|
||||||
|
for %%F in (dist\WeChatDecrypt.exe) do echo 大小: %%~zF bytes
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
31
config.py
31
config.py
@@ -10,6 +10,18 @@ import sys
|
|||||||
|
|
||||||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||||||
|
|
||||||
|
# 打包后 __file__ 指向临时目录,优先使用环境变量或5 cwd
|
||||||
|
def _app_base_dir():
|
||||||
|
d = os.environ.get("WECHAT_DECRYPT_APP_DIR")
|
||||||
|
if d and os.path.isdir(d):
|
||||||
|
return d
|
||||||
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
|
def _config_file_path():
|
||||||
|
base = _app_base_dir()
|
||||||
|
p = os.path.join(base, "config.json")
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
return CONFIG_FILE
|
||||||
_SYSTEM = platform.system().lower()
|
_SYSTEM = platform.system().lower()
|
||||||
|
|
||||||
if _SYSTEM == "linux":
|
if _SYSTEM == "linux":
|
||||||
@@ -167,12 +179,13 @@ def auto_detect_db_dir():
|
|||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
cfg = {}
|
cfg = {}
|
||||||
if os.path.exists(CONFIG_FILE):
|
config_file = _config_file_path()
|
||||||
|
if os.path.exists(config_file):
|
||||||
try:
|
try:
|
||||||
with open(CONFIG_FILE, encoding="utf-8") as f:
|
with open(config_file, encoding="utf-8") as f:
|
||||||
cfg = json.load(f)
|
cfg = json.load(f)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
print(f"[!] {CONFIG_FILE} 格式损坏,将使用默认配置")
|
print(f"[!] {config_file} 格式损坏,将使用默认配置")
|
||||||
cfg = {}
|
cfg = {}
|
||||||
# db_dir 缺失或仍为模板值时,尝试自动检测
|
# db_dir 缺失或仍为模板值时,尝试自动检测
|
||||||
db_dir = cfg.get("db_dir", "")
|
db_dir = cfg.get("db_dir", "")
|
||||||
@@ -181,15 +194,15 @@ def load_config():
|
|||||||
if detected:
|
if detected:
|
||||||
print(f"[+] 自动检测到微信数据目录: {detected}")
|
print(f"[+] 自动检测到微信数据目录: {detected}")
|
||||||
cfg = {**_DEFAULT, **cfg, "db_dir": detected}
|
cfg = {**_DEFAULT, **cfg, "db_dir": detected}
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
with open(config_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||||
print(f"[+] 已保存到: {CONFIG_FILE}")
|
print(f"[+] 已保存到: {config_file}")
|
||||||
else:
|
else:
|
||||||
if not os.path.exists(CONFIG_FILE):
|
if not os.path.exists(config_file):
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
with open(config_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(_DEFAULT, f, indent=4, ensure_ascii=False)
|
json.dump(_DEFAULT, f, indent=4, ensure_ascii=False)
|
||||||
print(f"[!] 未能自动检测微信数据目录")
|
print(f"[!] 未能自动检测微信数据目录")
|
||||||
print(f" 请手动编辑 {CONFIG_FILE} 中的 db_dir 字段")
|
print(f" 请手动编辑 {config_file} 中的 db_dir 字段")
|
||||||
if _SYSTEM == "linux":
|
if _SYSTEM == "linux":
|
||||||
print(" Linux 默认路径类似: ~/Documents/xwechat_files/<wxid>/db_storage")
|
print(" Linux 默认路径类似: ~/Documents/xwechat_files/<wxid>/db_storage")
|
||||||
else:
|
else:
|
||||||
@@ -199,7 +212,7 @@ def load_config():
|
|||||||
cfg = {**_DEFAULT, **cfg}
|
cfg = {**_DEFAULT, **cfg}
|
||||||
|
|
||||||
# 将相对路径转为绝对路径
|
# 将相对路径转为绝对路径
|
||||||
base = os.path.dirname(os.path.abspath(__file__))
|
base = _app_base_dir()
|
||||||
for key in ("keys_file", "decrypted_dir", "decoded_image_dir"):
|
for key in ("keys_file", "decrypted_dir", "decoded_image_dir"):
|
||||||
if key in cfg and not os.path.isabs(cfg[key]):
|
if key in cfg and not os.path.isabs(cfg[key]):
|
||||||
cfg[key] = os.path.join(base, cfg[key])
|
cfg[key] = os.path.join(base, cfg[key])
|
||||||
|
|||||||
341
export_messages.py
Normal file
341
export_messages.py
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
"""导出微信消息记录到 CSV / HTML / JSON
|
||||||
|
目录结构: export/<display_name>/messages.csv|html|json
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import glob
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import csv
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import zstandard as zstd
|
||||||
|
|
||||||
|
# Windows PowerShell 控制台设为 UTF-8
|
||||||
|
if sys.platform == "win32":
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
MSG_DB_DIR = r"decrypted\message"
|
||||||
|
CONTACT_DB_PATH = r"decrypted\contact\contact.db"
|
||||||
|
OUTPUT_DIR = "export"
|
||||||
|
|
||||||
|
MSG_TYPES = {
|
||||||
|
1: "文本",
|
||||||
|
3: "图片",
|
||||||
|
34: "语音",
|
||||||
|
42: "名片",
|
||||||
|
43: "视频",
|
||||||
|
47: "表情包",
|
||||||
|
48: "位置",
|
||||||
|
49: "分享/文件/小程序",
|
||||||
|
10000: "系统消息",
|
||||||
|
10002: "系统通知",
|
||||||
|
}
|
||||||
|
|
||||||
|
_zstd_ctx = zstd.ZstdDecompressor()
|
||||||
|
|
||||||
|
def decompress_zstd(data: bytes) -> str:
|
||||||
|
try:
|
||||||
|
return _zstd_ctx.decompress(data).decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_content(raw, ct_flag) -> str:
|
||||||
|
if raw is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
if ct_flag == 4:
|
||||||
|
return decompress_zstd(raw)
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
return str(raw)
|
||||||
|
|
||||||
|
def safe_dirname(name: str) -> str:
|
||||||
|
for ch in r'\/:*?"<>|':
|
||||||
|
name = name.replace(ch, "_")
|
||||||
|
return name.strip() or "unknown"
|
||||||
|
|
||||||
|
def xml_extract(content: str, *tags) -> str:
|
||||||
|
"""从 XML 中提取第一个匹配的 tag 文本"""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
for tag in tags:
|
||||||
|
el = root.find(".//" + tag)
|
||||||
|
if el is not None and el.text:
|
||||||
|
return el.text
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for tag in tags:
|
||||||
|
m = re.search(rf"<{tag}>(.*?)</{tag}>", content, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip()
|
||||||
|
return content[:200]
|
||||||
|
|
||||||
|
def friendly_content(msg_type: int, content: str) -> str:
|
||||||
|
"""返回适合显示的内容摘要"""
|
||||||
|
if msg_type == 1:
|
||||||
|
return content
|
||||||
|
if msg_type == 3:
|
||||||
|
return "[图片]"
|
||||||
|
if msg_type == 34:
|
||||||
|
return "[语音]"
|
||||||
|
if msg_type == 42:
|
||||||
|
title = xml_extract(content, "nickname")
|
||||||
|
return f"[名片: {title}]"
|
||||||
|
if msg_type == 43:
|
||||||
|
return "[视频]"
|
||||||
|
if msg_type == 47:
|
||||||
|
return "[表情包]"
|
||||||
|
if msg_type == 48:
|
||||||
|
loc = xml_extract(content, "label")
|
||||||
|
return f"[位置: {loc}]"
|
||||||
|
if msg_type == 49:
|
||||||
|
title = xml_extract(content, "title")
|
||||||
|
return f"[分享: {title}]" if title else "[文件/链接]"
|
||||||
|
if msg_type in (10000, 10002):
|
||||||
|
return f"[系统: {content[:100]}]"
|
||||||
|
return content[:200]
|
||||||
|
|
||||||
|
HTML_TEMPLATE = """\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>
|
||||||
|
*{{box-sizing:border-box;margin:0;padding:0}}
|
||||||
|
body{{background:#ededed;font-family:"PingFang SC","Helvetica Neue",Arial,sans-serif;font-size:14px}}
|
||||||
|
.header{{background:#44A848;color:#fff;padding:12px 16px;font-size:17px;font-weight:bold;position:sticky;top:0;z-index:10;box-shadow:0 1px 3px rgba(0,0,0,.3)}}
|
||||||
|
.chat{{padding:10px 0;max-width:800px;margin:0 auto}}
|
||||||
|
.date-sep{{text-align:center;margin:12px 0;color:#999;font-size:12px}}
|
||||||
|
.date-sep span{{background:#ddd;border-radius:10px;padding:2px 10px}}
|
||||||
|
.msg{{display:flex;align-items:flex-start;margin:6px 12px;max-width:100%}}
|
||||||
|
.msg.sent{{flex-direction:row-reverse}}
|
||||||
|
.msg.system{{justify-content:center;margin:4px 12px}}
|
||||||
|
.msg.system .bubble{{background:transparent;color:#999;font-size:12px;box-shadow:none;border-radius:0;padding:2px 8px}}
|
||||||
|
.avatar{{width:40px;height:40px;border-radius:6px;background:#7CC;color:#fff;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:bold;flex-shrink:0}}
|
||||||
|
.msg.sent .avatar{{background:#4CAF50}}
|
||||||
|
.msg-body{{max-width:70%;margin:0 8px}}
|
||||||
|
.sender-name{{font-size:12px;color:#888;margin-bottom:3px}}
|
||||||
|
.msg.sent .sender-name{{text-align:right}}
|
||||||
|
.bubble{{display:inline-block;padding:8px 12px;border-radius:6px;word-break:break-word;line-height:1.5;box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:pre-wrap}}
|
||||||
|
.received .bubble{{background:#fff;border-radius:0 6px 6px 6px}}
|
||||||
|
.sent .bubble{{background:#95EC69;border-radius:6px 0 6px 6px}}
|
||||||
|
.type-tag{{font-size:11px;color:#aaa;margin-top:2px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">{title}</div>
|
||||||
|
<div class="chat">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _html_escape(s: str) -> str:
|
||||||
|
return s.replace("&","&").replace("<","<").replace(">",">").replace('"','"')
|
||||||
|
|
||||||
|
def _write_html(path: str, title: str, is_group: bool, messages: list):
|
||||||
|
parts = []
|
||||||
|
last_date = None
|
||||||
|
for m in messages:
|
||||||
|
dt = datetime.fromtimestamp(m["create_time"])
|
||||||
|
day = dt.strftime("%Y年%m月%d日")
|
||||||
|
if day != last_date:
|
||||||
|
parts.append(f'<div class="date-sep"><span>{day}</span></div>')
|
||||||
|
last_date = day
|
||||||
|
|
||||||
|
if m["is_system"]:
|
||||||
|
parts.append(
|
||||||
|
f'<div class="msg system"><div class="bubble">'
|
||||||
|
f'{_html_escape(m["display_content"])}</div></div>'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
side = "received" if m["is_received"] else "sent"
|
||||||
|
initial = (m["sender"] or "?")[0].upper()
|
||||||
|
sender_label = ""
|
||||||
|
if is_group or m["is_received"]:
|
||||||
|
sender_label = f'<div class="sender-name">{_html_escape(m["sender"])}</div>'
|
||||||
|
|
||||||
|
type_tag = ""
|
||||||
|
if m["type"] != 1:
|
||||||
|
type_tag = f'<div class="type-tag">{m["type_name"]}</div>'
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
f'<div class="msg {side}">'
|
||||||
|
f'<div class="avatar">{initial}</div>'
|
||||||
|
f'<div class="msg-body">'
|
||||||
|
f'{sender_label}'
|
||||||
|
f'<div class="bubble">{_html_escape(m["display_content"])}</div>'
|
||||||
|
f'{type_tag}'
|
||||||
|
f'<div class="type-tag">{m["time_str"]}</div>'
|
||||||
|
f'</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(parts)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(HTML_TEMPLATE.format(title=_html_escape(title), body=body))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 加载联系人信息 ─────────────────────────────────────────────────────────────
|
||||||
|
contact_map: dict[str, dict] = {}
|
||||||
|
try:
|
||||||
|
cconn = sqlite3.connect(CONTACT_DB_PATH)
|
||||||
|
for uname, alias, remark, nick_name in cconn.execute(
|
||||||
|
"SELECT username, alias, remark, nick_name FROM contact"
|
||||||
|
):
|
||||||
|
contact_map[uname] = {
|
||||||
|
"username": uname,
|
||||||
|
"alias": alias or "",
|
||||||
|
"remark": remark or "",
|
||||||
|
"nick_name": nick_name or "",
|
||||||
|
}
|
||||||
|
cconn.close()
|
||||||
|
print(f"联系人数据库: {len(contact_map)} 条")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"联系人数据库读取失败: {e}")
|
||||||
|
|
||||||
|
def display_name(username: str) -> str:
|
||||||
|
info = contact_map.get(username, {})
|
||||||
|
return info.get("remark") or info.get("nick_name") or username
|
||||||
|
|
||||||
|
# ─── 遍历所有 message_*.db ──────────────────────────────────────────────────────
|
||||||
|
db_files = sorted(
|
||||||
|
f for f in glob.glob(os.path.join(MSG_DB_DIR, "message_*.db"))
|
||||||
|
if not f.endswith(("_fts.db", "_resource.db"))
|
||||||
|
)
|
||||||
|
print(f"找到 {len(db_files)} 个消息数据库")
|
||||||
|
|
||||||
|
total_chats = 0
|
||||||
|
total_msgs = 0
|
||||||
|
|
||||||
|
for db_path in sorted(db_files):
|
||||||
|
db_name = os.path.basename(db_path)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# rowid -> username
|
||||||
|
sender_map: dict[int, str] = {}
|
||||||
|
for row in conn.execute("SELECT rowid, user_name FROM Name2Id"):
|
||||||
|
sender_map[row[0]] = row[1]
|
||||||
|
|
||||||
|
# 计算 username -> hash 映射
|
||||||
|
hash_to_username: dict[str, str] = {}
|
||||||
|
for username in sender_map.values():
|
||||||
|
if username:
|
||||||
|
h = hashlib.md5(username.encode()).hexdigest()
|
||||||
|
hash_to_username[h] = username
|
||||||
|
|
||||||
|
# 找出所有 Msg_<hash> 表
|
||||||
|
all_tables = [
|
||||||
|
r[0] for r in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for table_name in all_tables:
|
||||||
|
h = table_name[4:] # strip "Msg_"
|
||||||
|
chat_username = hash_to_username.get(h, f"unknown_{h[:8]}")
|
||||||
|
dname = safe_dirname(display_name(chat_username))
|
||||||
|
is_group = chat_username.endswith("@chatroom") or chat_username.endswith("@openim")
|
||||||
|
|
||||||
|
# 读取该表所有消息
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT local_id, server_id, local_type, sort_seq, real_sender_id,"
|
||||||
|
f" create_time, status, message_content, WCDB_CT_message_content"
|
||||||
|
f" FROM {table_name} ORDER BY sort_seq"
|
||||||
|
).fetchall()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 读取 {table_name} 失败: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
for r in rows:
|
||||||
|
(local_id, server_id, local_type, sort_seq, real_sender_id,
|
||||||
|
create_time, status, raw_content, ct_flag) = tuple(r)
|
||||||
|
|
||||||
|
content = get_content(raw_content, ct_flag or 0)
|
||||||
|
sender_uname = sender_map.get(real_sender_id, "")
|
||||||
|
sender_dn = display_name(sender_uname) if sender_uname else "我"
|
||||||
|
msg_type_name = MSG_TYPES.get(local_type, f"未知({local_type})")
|
||||||
|
display_content = friendly_content(local_type, content)
|
||||||
|
is_system = local_type in (10000, 10002)
|
||||||
|
|
||||||
|
messages.append({
|
||||||
|
"local_id": local_id,
|
||||||
|
"server_id": server_id,
|
||||||
|
"type": local_type,
|
||||||
|
"type_name": msg_type_name,
|
||||||
|
"sort_seq": sort_seq,
|
||||||
|
"sender_username": sender_uname,
|
||||||
|
"sender": sender_dn,
|
||||||
|
"create_time": create_time,
|
||||||
|
"time_str": datetime.fromtimestamp(create_time).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"status": status,
|
||||||
|
"content": content,
|
||||||
|
"display_content": display_content,
|
||||||
|
"is_system": is_system,
|
||||||
|
# 1-on-1: sender==chat_partner -> received(left), else sent(right)
|
||||||
|
"is_received": (sender_uname == chat_username) if not is_group else True,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 输出目录 ──────────────────────────────────────────────────────────
|
||||||
|
out_dir = os.path.join(OUTPUT_DIR, dname)
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# ── .info 文件 ────────────────────────────────────────────────────────
|
||||||
|
info_path = os.path.join(out_dir, ".info")
|
||||||
|
if not os.path.exists(info_path):
|
||||||
|
info = contact_map.get(chat_username, {
|
||||||
|
"username": chat_username, "alias": "", "remark": "", "nick_name": ""
|
||||||
|
})
|
||||||
|
with open(info_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"username: {info['username']}\n")
|
||||||
|
f.write(f"alias: {info['alias']}\n")
|
||||||
|
f.write(f"nick_name: {info['nick_name']}\n")
|
||||||
|
f.write(f"remark: {info['remark']}\n")
|
||||||
|
f.write(f"is_group: {is_group}\n")
|
||||||
|
|
||||||
|
# ── CSV ───────────────────────────────────────────────────────────────
|
||||||
|
csv_path = os.path.join(out_dir, f"{db_name}.csv")
|
||||||
|
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerow(["时间", "发送者", "消息类型", "内容", "server_id"])
|
||||||
|
for m in messages:
|
||||||
|
w.writerow([
|
||||||
|
m["time_str"], m["sender"], m["type_name"],
|
||||||
|
m["display_content"], m["server_id"]
|
||||||
|
])
|
||||||
|
|
||||||
|
# ── JSON ──────────────────────────────────────────────────────────────
|
||||||
|
json_path = os.path.join(out_dir, f"{db_name}.json")
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({
|
||||||
|
"chat_username": chat_username,
|
||||||
|
"display_name": dname,
|
||||||
|
"is_group": is_group,
|
||||||
|
"message_count": len(messages),
|
||||||
|
"messages": messages,
|
||||||
|
}, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# ── HTML ──────────────────────────────────────────────────────────────
|
||||||
|
html_path = os.path.join(out_dir, f"{db_name}.html")
|
||||||
|
_write_html(html_path, dname, is_group, messages)
|
||||||
|
|
||||||
|
total_chats += 1
|
||||||
|
total_msgs += len(messages)
|
||||||
|
print(f" [{db_name}] {dname}: {len(messages)} 条消息")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\n完成: {total_chats} 个会话, 共 {total_msgs} 条消息")
|
||||||
|
print(f"输出目录: {os.path.abspath(OUTPUT_DIR)}")
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
pycryptodome>=3.19,<4
|
pycryptodome>=3.19,<4
|
||||||
zstandard>=0.22,<1
|
zstandard>=0.22,<1
|
||||||
mcp>=1.0,<2
|
mcp>=1.0,<2
|
||||||
|
pilk>=0.2
|
||||||
|
pyinstaller>=6.0
|
||||||
|
|||||||
123
voice_to_mp3.py
Normal file
123
voice_to_mp3.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""从 media_0.db 提取所有语音数据,按用户名分目录,SILK_V3 转 MP3"""
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
import pilk
|
||||||
|
|
||||||
|
DB_PATH = r"decrypted\message\media_0.db"
|
||||||
|
CONTACT_DB_PATH = r"decrypted\contact\contact.db"
|
||||||
|
OUTPUT_DIR = "data"
|
||||||
|
|
||||||
|
def silk_to_mp3(voice_data, output_path):
|
||||||
|
"""将微信 SILK 语音数据转换为 MP3"""
|
||||||
|
# 去掉微信格式的 0x02 前缀
|
||||||
|
if voice_data[0:1] == b'\x02':
|
||||||
|
silk_data = voice_data[1:]
|
||||||
|
else:
|
||||||
|
silk_data = voice_data
|
||||||
|
|
||||||
|
if not silk_data.startswith(b'#!SILK_V3'):
|
||||||
|
print(f" 警告:数据不以 #!SILK_V3 开头,跳过")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 补上结尾标记
|
||||||
|
if not silk_data.endswith(b'\xff\xff'):
|
||||||
|
silk_data += b'\xff\xff'
|
||||||
|
|
||||||
|
silk_file = tempfile.mktemp(suffix=".silk")
|
||||||
|
pcm_file = tempfile.mktemp(suffix=".pcm")
|
||||||
|
try:
|
||||||
|
with open(silk_file, "wb") as f:
|
||||||
|
f.write(silk_data)
|
||||||
|
|
||||||
|
pilk.decode(silk_file, pcm_file)
|
||||||
|
|
||||||
|
result = subprocess.run([
|
||||||
|
"ffmpeg", "-y", "-f", "s16le", "-ar", "24000", "-ac", "1",
|
||||||
|
"-i", pcm_file, output_path
|
||||||
|
], capture_output=True, encoding="utf-8", errors="replace")
|
||||||
|
return result.returncode == 0
|
||||||
|
finally:
|
||||||
|
if os.path.exists(silk_file):
|
||||||
|
os.remove(silk_file)
|
||||||
|
if os.path.exists(pcm_file):
|
||||||
|
os.remove(pcm_file)
|
||||||
|
|
||||||
|
# 1. 读取 Name2Id 映射 (rowid -> user_name)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
name_map = {}
|
||||||
|
for rowid, user_name in conn.execute("SELECT rowid, user_name FROM Name2Id"):
|
||||||
|
name_map[rowid] = user_name
|
||||||
|
print(f"共 {len(name_map)} 个用户")
|
||||||
|
|
||||||
|
# 2. 读取 contact 信息 (user_name -> {remark, nick_name, alias, ...})
|
||||||
|
contact_map = {}
|
||||||
|
try:
|
||||||
|
cconn = sqlite3.connect(CONTACT_DB_PATH)
|
||||||
|
for row in cconn.execute("SELECT username, alias, remark, nick_name FROM contact"):
|
||||||
|
uname, alias, remark, nick_name = row
|
||||||
|
contact_map[uname] = {"username": uname, "alias": alias or "", "remark": remark or "", "nick_name": nick_name or ""}
|
||||||
|
cconn.close()
|
||||||
|
print(f"联系人数据库加载: {len(contact_map)} 条")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"联系人数据库读取失败: {e}")
|
||||||
|
|
||||||
|
def display_name(user_name):
|
||||||
|
"""优先 remark > nick_name > user_name"""
|
||||||
|
info = contact_map.get(user_name, {})
|
||||||
|
return info.get("remark") or info.get("nick_name") or user_name
|
||||||
|
|
||||||
|
def safe_dirname(name):
|
||||||
|
"""替换目录名中的非法字符"""
|
||||||
|
for ch in r'\/:*?"<>|':
|
||||||
|
name = name.replace(ch, "_")
|
||||||
|
return name.strip() or "unknown"
|
||||||
|
|
||||||
|
# 2. 查询所有语音,按 chat_name_id 关联用户名
|
||||||
|
rows = conn.execute("SELECT chat_name_id, create_time, local_id, voice_data FROM VoiceInfo ORDER BY chat_name_id, create_time").fetchall()
|
||||||
|
conn.close()
|
||||||
|
print(f"共 {len(rows)} 条语音")
|
||||||
|
|
||||||
|
# 3. 遍历转换
|
||||||
|
success = 0
|
||||||
|
fail = 0
|
||||||
|
for chat_name_id, create_time, local_id, voice_data in rows:
|
||||||
|
user_name = name_map.get(chat_name_id, f"unknown_{chat_name_id}")
|
||||||
|
dname = safe_dirname(display_name(user_name))
|
||||||
|
dt = datetime.fromtimestamp(create_time)
|
||||||
|
filename = dt.strftime("%Y%m%d_%H%M%S") + f"_{local_id}.mp3"
|
||||||
|
|
||||||
|
user_dir = os.path.join(OUTPUT_DIR, dname)
|
||||||
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 写入 .info 文件(只写一次)
|
||||||
|
info_path = os.path.join(user_dir, ".info")
|
||||||
|
if not os.path.exists(info_path):
|
||||||
|
info = contact_map.get(user_name, {"username": user_name, "alias": "", "remark": "", "nick_name": ""})
|
||||||
|
with open(info_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"username: {info['username']}\n")
|
||||||
|
f.write(f"alias: {info['alias']}\n")
|
||||||
|
f.write(f"nick_name: {info['nick_name']}\n")
|
||||||
|
f.write(f"remark: {info['remark']}\n")
|
||||||
|
|
||||||
|
output_path = os.path.join(user_dir, filename)
|
||||||
|
if os.path.exists(output_path):
|
||||||
|
success += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ok = silk_to_mp3(voice_data, output_path)
|
||||||
|
if ok:
|
||||||
|
success += 1
|
||||||
|
print(f" [{success}/{len(rows)}] {dname}/{filename}")
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
print(f" 失败: {dname}/{filename}")
|
||||||
|
|
||||||
|
print(f"\n完成: 成功 {success}, 失败 {fail}")
|
||||||
Reference in New Issue
Block a user