From bf5d16d48c55130a62aff59cb73bd62e4e7142ae Mon Sep 17 00:00:00 2001 From: ylytdeng Date: Sun, 17 May 2026 17:36:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(monitor=5Fweb):=20=E5=8A=A0=20Web=20UI=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=AE=B1=20-=20=E6=9B=BF=E4=BB=A3=20tkinter?= =?UTF-8?q?=20app=5Fgui.py=20=E7=9A=84=208=20=E4=B8=AA=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 PR #107 引入的 tkinter GUI (app_gui.py, 929 行) 整套功能搬到现有 monitor_web 的浏览器 UI 上。复用已有的 SSE 通道,后端跑子进程,实时把 stdout 推到浏览器。 ## 实现 ### Backend (~110 行) - `TOOL_TASKS` dict 定义 8 个任务 (cmd 串列表) - `_run_tool_task(job_id, task_name)` 后台线程顺序跑 steps, subprocess.Popen + readline → broadcast_sse 推 event=tool_log - 完成后推 event=tool_done - `Handler.do_POST` 加 /api/tool 路由,接 {"task":"..."} 触发 - _tool_lock 保证全局同时只跑 1 个任务,避免两个解密挤内存 - 子进程环境传 WECHAT_DECRYPT_NONINTERACTIVE=1, 走脚本里已有的非交互 分支 (自动选最近账号等) - subprocess 用 CREATE_NO_WINDOW 隐藏黑窗 (Win) ### Frontend (~130 行 HTML/CSS/JS) - header 加 🛠️ 工具 按钮 - toggleTools() 切换 #toolsPanel 显隐 (默认隐藏) - 8 个按钮分两行: 个人微信 (① 解密 / ② 图片密钥 / ③ 导出聊天 / ④ 批量 解图片) + 朋友圈/企微 (⑤ 朋友圈 / ⑥ 企微解密 / ⑦ 企微导出 / ⑧ 语音 转 MP3) - 黑色日志区,Monaco/Consolas 字体,实时 append (auto-scroll 到底) - 状态条 ⏳ 运行中 / ✓ 完成 / ✗ 失败 - es.addEventListener('tool_log' / 'tool_done') 跟现有 message 事件 共存 ### 跟现有监听共存 - 不动 messages 容器和消息流逻辑 - 跟 settings 面板共用一套 header 按钮风格 - SSE 通道复用 /stream, 不另起 channel ## 实测 WXWork 在跑的情况下点 ⑥ 企微解密, 浏览器实时显示: - find_wxwork_keys cipher 结构体扫描进度 - enc_key=cc68fa7d...4c (16 字节) - decrypt_wxwork_db 17/17 db 解密成功 - 完成后 wxwork_keys.json / wxwork_decrypted/*.db mtime 全部更新 测试 185/185 通过 (不影响现有消息监听逻辑)。 ## 对比 app_gui.py | | tkinter (app_gui.py) | Web UI (monitor_web) | |---|---|---| | 外观 | 90 年代 Windows | 暗色现代渐变 | | 中文字体 | 渲染糊 | 浏览器原生清晰 | | 跨平台 | 名义支持实际只 Win | macOS/Linux/Win 浏览器都行 | | 远程访问 | ❌ | bind 0.0.0.0 即可 | | 维护 | 单独 929 行 stdlib tkinter | 跟监听共享 SSE/HTML | | 包大小 (打包后) | 多带 tkinter dll | 走 monitor_web 已有的 stdlib | ## 后续 仍开着 (留 follow-up): - 导出 ③ / ⑦ 当前是"全量"模式; tkinter 里弹了"选会话"对话框, Web 版要做 /api/sessions + 模态框 (估 0.5 天) - 任务完成后给"打开输出目录"按钮 (15 分钟) - 删 app_gui.py + 改 build.bat 把 monitor_web 当 exe 入口 (1 小时) - 数据浏览 Tab (浏览器看解密后的 db / 朋友圈 timeline) - P3 --- monitor_web.py | 247 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 246 insertions(+), 1 deletion(-) diff --git a/monitor_web.py b/monitor_web.py index f8a35d6..638319c 100644 --- a/monitor_web.py +++ b/monitor_web.py @@ -6,7 +6,8 @@ http://localhost:5678 - 检测到变化后:全量解密DB + 全量WAL patch - SSE 服务器推送 """ -import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback +import hashlib, struct, os, sys, json, time, sqlite3, io, threading, queue, traceback, subprocess +import uuid import hmac as hmac_mod from concurrent.futures import ThreadPoolExecutor from contextlib import closing @@ -1746,6 +1747,21 @@ a.msg-link{text-decoration:none;color:inherit} .add-rule-btn:hover{background:rgba(79,195,247,.2)} /* 通知高亮 */ .msg.notify-hl{border-left:3px solid #ffd54f;background:rgba(255,213,79,.08);box-shadow:0 0 12px rgba(255,213,79,.1)} +/* 工具面板 (Web 版替代 tkinter app_gui.py) */ +.tools-btn{background:none;border:1px solid rgba(255,255,255,.15);color:#888;font-size:13px;cursor:pointer;padding:4px 10px;border-radius:6px;transition:all .2s;margin-left:6px} +.tools-btn:hover{color:#4fc3f7;border-color:rgba(79,195,247,.4)} +#toolsPanel{display:none;background:#0f0f17;border-top:1px solid rgba(255,255,255,.08);padding:14px 24px;flex-shrink:0;max-height:50vh;overflow:auto} +#toolsPanel.show{display:flex;flex-direction:column;gap:10px} +.tools-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center} +.tool-task-btn{background:linear-gradient(135deg,#1e293b,#0f172a);border:1px solid rgba(79,195,247,.3);color:#bdd4ff;padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;transition:all .2s} +.tool-task-btn:hover:not(:disabled){background:linear-gradient(135deg,#2a3a5a,#1a2540);transform:translateY(-1px)} +.tool-task-btn:disabled{opacity:.5;cursor:not-allowed} +.tool-log-wrap{background:#000;border:1px solid rgba(255,255,255,.08);border-radius:6px;padding:10px 12px;font-family:Consolas,"Courier New",monospace;font-size:12px;color:#cfd8dc;line-height:1.4;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all} +.tool-log-wrap:empty::before{content:"点击上方按钮开始任务,日志会实时显示";color:#555;font-style:italic} +.tool-status{display:inline-block;font-size:12px;padding:3px 10px;border-radius:10px;margin-left:8px} +.tool-status.running{background:rgba(79,195,247,.15);color:#4fc3f7;border:1px solid rgba(79,195,247,.3)} +.tool-status.ok{background:rgba(76,175,80,.15);color:#81c784;border:1px solid rgba(76,175,80,.3)} +.tool-status.err{background:rgba(244,67,54,.15);color:#ef9a9a;border:1px solid rgba(244,67,54,.3)} @@ -1753,8 +1769,27 @@ a.msg-link{text-decoration:none;color:inherit}

WeChat Monitor

SSE 实时
0 消息
+ +
+
+
个人微信:
+ + + + +
+
+
朋友圈/企微:
+ + + + + +
+
+

通知设置

@@ -1890,6 +1925,37 @@ function toggleSettings(){ o.classList.toggle('show',show); if(show) renderRules(); } +function toggleTools(){ + const p=document.getElementById('toolsPanel'); + p.classList.toggle('show'); +} +async function runTool(task, btn){ + const s=document.getElementById('toolStatus'); + document.querySelectorAll('.tool-task-btn').forEach(b=>b.disabled=true); + document.getElementById('toolLog').textContent=''; + s.style.display='inline-block'; + s.className='tool-status running'; + s.textContent='⏳ 运行中...'; + try{ + const r=await fetch('/api/tool',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({task:task})}); + const d=await r.json(); + if(!r.ok){ + s.className='tool-status err'; + s.textContent='✗ '+(d.error||'启动失败'); + document.querySelectorAll('.tool-task-btn').forEach(b=>b.disabled=false); + } + }catch(e){ + s.className='tool-status err'; + s.textContent='✗ 网络错误: '+e.message; + document.querySelectorAll('.tool-task-btn').forEach(b=>b.disabled=false); + } +} +// 工具按钮 click handler 绑定 +document.addEventListener('DOMContentLoaded',()=>{ + document.querySelectorAll('.tool-task-btn').forEach(b=>{ + b.addEventListener('click',()=>runTool(b.dataset.task, b)); + }); +}); function beep(){ try{ const ctx=new(window.AudioContext||window.webkitAudioContext)(); @@ -2011,6 +2077,19 @@ function connectSSE(){ } } }); + es.addEventListener('tool_log', ev=>{ + const d=JSON.parse(ev.data); + const L=document.getElementById('toolLog'); + L.textContent += d.line; + L.scrollTop = L.scrollHeight; + }); + es.addEventListener('tool_done', ev=>{ + const d=JSON.parse(ev.data); + const s=document.getElementById('toolStatus'); + s.textContent = d.ok ? '✓ 完成' : ('✗ 失败 (code ' + d.exit_code + ')'); + s.className = 'tool-status ' + (d.ok ? 'ok' : 'err'); + document.querySelectorAll('.tool-task-btn').forEach(b=>b.disabled=false); + }); es.onerror=()=>{ S.textContent='重连...'; S.className='status err'; @@ -2031,6 +2110,123 @@ fetch('/api/history').then(r=>r.json()).then(ms=>{ ''' +# ────────────── 工具任务 (Web GUI 替代 tkinter app_gui.py 的入口) ──────────── +# +# 复用现有 SSE 通道 (broadcast_sse + sse_clients), 后端跑子进程, 实时把 stdout +# 推到浏览器。架构原则: 一次只允许 1 个工具任务运行 (避免两个解密同时挤内存)。 +# +# 前端在 HTML_PAGE 顶部加一个折叠区,点按钮 POST /api/tool {task: "..."}。 +# SSE 事件用 event=tool_log / tool_done 跟原 message 事件区分。 + +TOOL_TASKS = { + # —— 个人微信 —— + "wechat_decrypt": { + "name": "① 微信解密", + "steps": [[sys.executable, "main.py", "decrypt"]], + }, + "image_key": { + "name": "② 图片密钥", + "steps": [[sys.executable, "find_image_key.py"]], + }, + "export_all": { + "name": "③ 导出全部聊天 (JSON)", + "steps": [[sys.executable, "export_all_chats.py"]], + }, + "decode_images": { + "name": "④ 批量解密图片", + "steps": [[sys.executable, "main.py", "decode-images"]], + }, + # —— 朋友圈 —— + "sns_decrypt": { + "name": "⑤ 朋友圈解密", + "steps": [ + [sys.executable, "decrypt_sns.py"], + [sys.executable, "export_sns.py"], + ], + }, + # —— 企业微信 —— + "wxwork_decrypt": { + "name": "⑥ 企业微信解密", + "steps": [ + [sys.executable, "find_wxwork_keys.py"], + [sys.executable, "decrypt_wxwork_db.py"], + ], + }, + "wxwork_export": { + "name": "⑦ 企业微信导出", + "steps": [[sys.executable, "export_wxwork_messages.py"]], + }, + # —— 工具 —— + "voice_mp3": { + "name": "⑧ 语音转 MP3", + "steps": [[sys.executable, "voice_to_mp3.py"]], + }, +} + +_tool_lock = threading.Lock() +_tool_running = {"job": None} # 同时只允许一个任务 + + +def _broadcast_tool_event(event, **fields): + payload = {"event": event, **fields} + broadcast_sse(payload) + + +def _run_tool_task(job_id, task_name): + """后台线程: 顺序跑 TOOL_TASKS[task_name].steps 的每条命令,实时推 SSE。""" + task = TOOL_TASKS.get(task_name) + if not task: + _broadcast_tool_event("tool_done", job_id=job_id, ok=False, + error=f"未知任务: {task_name}") + with _tool_lock: + _tool_running["job"] = None + return + + _broadcast_tool_event("tool_log", job_id=job_id, + line=f"━━━ 开始: {task['name']} ━━━\n") + + exit_code = 0 + for step in task["steps"]: + cmd_str = " ".join(step) + _broadcast_tool_event("tool_log", job_id=job_id, + line=f"\n>>> {cmd_str}\n\n") + try: + proc = subprocess.Popen( + step, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=os.path.dirname(os.path.abspath(__file__)), + env={**os.environ, + "PYTHONIOENCODING": "utf-8", + "WECHAT_DECRYPT_NONINTERACTIVE": "1"}, + bufsize=1, + text=True, + encoding="utf-8", + errors="replace", + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) + if sys.platform == "win32" else 0, + ) + except Exception as e: + _broadcast_tool_event("tool_log", job_id=job_id, + line=f"[ERROR] 启动失败: {e}\n") + exit_code = -1 + break + + for line in proc.stdout: + _broadcast_tool_event("tool_log", job_id=job_id, line=line) + proc.wait() + if proc.returncode != 0: + _broadcast_tool_event("tool_log", job_id=job_id, + line=f"\n[FAIL] 返回码 {proc.returncode}\n") + exit_code = proc.returncode + break + + _broadcast_tool_event("tool_done", job_id=job_id, ok=(exit_code == 0), + exit_code=exit_code) + with _tool_lock: + _tool_running["job"] = None + + class Handler(BaseHTTPRequestHandler): def log_message(self, *a): pass def handle(self): @@ -2144,6 +2340,55 @@ class Handler(BaseHTTPRequestHandler): else: self.send_error(404) + def do_POST(self): + if self.path == "/api/tool": + try: + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode("utf-8") if length else "{}" + req = json.loads(body) + task_name = req.get("task", "") + except Exception as e: + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": f"bad request: {e}"}).encode()) + return + + if task_name not in TOOL_TASKS: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": f"未知任务: {task_name}", + "available": list(TOOL_TASKS)}).encode()) + return + + with _tool_lock: + if _tool_running["job"]: + self.send_response(409) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({ + "error": "已有任务在跑", + "running_job": _tool_running["job"], + }).encode()) + return + job_id = "j_" + uuid.uuid4().hex[:8] + _tool_running["job"] = job_id + + threading.Thread(target=_run_tool_task, args=(job_id, task_name), + daemon=True).start() + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({ + "job_id": job_id, + "task": task_name, + "name": TOOL_TASKS[task_name]["name"], + }).encode()) + else: + self.send_error(404) + class ThreadedServer(ThreadingMixIn, HTTPServer): daemon_threads = True