Files
zWorkFlow/voice_to_mp3.py
ylytdeng cf0e67b50b feat(monitor_web): 加终止按钮 + voice_to_mp3 优雅降级
## 痛点

用户反馈:
1. 没终止按钮 - 任务一旦点了, 跑死(✗ 失败)或跑长(解密 30s+)都没法停
2. ⑧ 语音转 MP3 报 ModuleNotFoundError: 'pilk', 直接 traceback

## Patch 1: 终止任务

### Backend
- _tool_running 加 proc / cancelled 字段
- _run_tool_task 把 proc 暴露到 _tool_running, 每轮 stdout read 后
  检查 cancelled 标志, 命中就 break
- 新加 POST /api/tool/cancel 路由: proc.terminate() + 1.5s 后 kill,
  设 cancelled=True 让 tool runner 收尾
- tool_done 事件加 cancelled 字段

### Frontend
- 任务运行时, 触发按钮临时变成 "🛑 终止" + 红色脉冲动画
- 再点一下就调 /api/tool/cancel
- tool_done 收到后还原按钮文本和颜色
- "⊘ 已终止" 状态徽章替代 "✓ 完成"

### CSS
- .tool-task-btn.cancel: 实心红渐变 + box-shadow + pulseRed 1.5s 动画

## Patch 2: voice_to_mp3 优雅降级

之前直接 `import pilk` 失败时打 traceback, 用户看不懂。改成:

  try: import pilk
  except ImportError:
      print 友好提示 + pip install pilk 命令 + 退出

加 ffmpeg 在 PATH 检查 (pilk 解码后需要 ffmpeg 编 MP3), 给三平台
安装指引。

requirements.txt 加注释说明 pilk 还需要 ffmpeg 配合, pyinstaller
仅打包需要(开发不必装)。

## 测试

185/185 通过 (不影响现有功能)。

实测 web UI:
- 点 ⑧ 语音转 MP3 → 立刻看到友好提示而不是 traceback
- 点任何任务 → 按钮变红色"🛑 终止" → 再点 → 任务收尾 → 状态变"⊘ 已终止"

## 仍未跟进 (follow-up)

- 导出筛选: ③ 导出全部聊天 / ⑦ 企微导出 一点就跑全量很可怕。
  应该弹模态框选会话 + 格式. 单独开 issue #112 跟进, 需要:
  - 新加 GET /api/sessions 路由列会话
  - export_all_chats / export_wxwork_messages 加 --filter 参数
  - 前端模态框 UI
2026-05-17 18:14:28 +08:00

149 lines
5.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""从 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")
try:
import pilk
except ImportError:
print("[ERROR] 缺少 pilk 库 (SILK 解码必需)", file=sys.stderr)
print(" 请运行: pip install pilk", file=sys.stderr)
print(" 然后重新启动本任务", file=sys.stderr)
sys.exit(1)
import shutil as _shutil
if not _shutil.which("ffmpeg"):
print("[ERROR] ffmpeg 不在 PATH 中 (MP3 编码必需)", file=sys.stderr)
print(" Windows: https://ffmpeg.org/download.html 下载后加入 PATH", file=sys.stderr)
print(" macOS: brew install ffmpeg", file=sys.stderr)
print(" Linux: apt install ffmpeg / yum install ffmpeg", file=sys.stderr)
sys.exit(1)
from config import load_config
_cfg = load_config()
DB_PATH = os.path.join(_cfg["decrypted_dir"], "message", "media_0.db")
CONTACT_DB_PATH = os.path.join(_cfg["decrypted_dir"], "contact", "contact.db")
OUTPUT_DIR = _cfg["output_base_dir"]
_CONTACT_FILTER = None
_filter_raw = os.environ.get("WECHAT_EXPORT_CONTACTS", "").strip()
if _filter_raw:
_CONTACT_FILTER = set(_filter_raw.split(","))
print(f"联系人筛选: {len(_CONTACT_FILTER)}")
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}")
if _CONTACT_FILTER and user_name not in _CONTACT_FILTER:
continue
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, "voice")
os.makedirs(user_dir, exist_ok=True)
# 写入 .info 文件(只写一次,写到联系人根目录)
info_path = os.path.join(OUTPUT_DIR, dname, ".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}")