feat(monitor_web): 加 Web UI 工具箱 - 替代 tkinter app_gui.py 的 8 个按钮
把 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
This commit is contained in:
247
monitor_web.py
247
monitor_web.py
@@ -6,7 +6,8 @@ http://localhost:5678
|
|||||||
- 检测到变化后:全量解密DB + 全量WAL patch
|
- 检测到变化后:全量解密DB + 全量WAL patch
|
||||||
- SSE 服务器推送
|
- 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
|
import hmac as hmac_mod
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import closing
|
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)}
|
.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)}
|
.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)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1753,8 +1769,27 @@ a.msg-link{text-decoration:none;color:inherit}
|
|||||||
<h1>WeChat Monitor</h1>
|
<h1>WeChat Monitor</h1>
|
||||||
<div class="status ok" id="st">SSE 实时</div>
|
<div class="status ok" id="st">SSE 实时</div>
|
||||||
<div class="stats"><span id="cnt">0 消息</span><span id="perf"></span></div>
|
<div class="stats"><span id="cnt">0 消息</span><span id="perf"></span></div>
|
||||||
|
<button class="tools-btn" onclick="toggleTools()" title="工具箱 (解密 / 导出 / 企业微信)">🛠️ 工具</button>
|
||||||
<button class="settings-btn" onclick="toggleSettings()" title="通知设置">⚙️</button>
|
<button class="settings-btn" onclick="toggleSettings()" title="通知设置">⚙️</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="toolsPanel">
|
||||||
|
<div class="tools-row">
|
||||||
|
<div style="color:#888;font-size:11px;margin-right:6px">个人微信:</div>
|
||||||
|
<button class="tool-task-btn" data-task="wechat_decrypt">① 解密</button>
|
||||||
|
<button class="tool-task-btn" data-task="image_key">② 图片密钥</button>
|
||||||
|
<button class="tool-task-btn" data-task="export_all">③ 导出聊天</button>
|
||||||
|
<button class="tool-task-btn" data-task="decode_images">④ 批量解图片</button>
|
||||||
|
</div>
|
||||||
|
<div class="tools-row">
|
||||||
|
<div style="color:#888;font-size:11px;margin-right:6px">朋友圈/企微:</div>
|
||||||
|
<button class="tool-task-btn" data-task="sns_decrypt">⑤ 朋友圈</button>
|
||||||
|
<button class="tool-task-btn" data-task="wxwork_decrypt">⑥ 企微解密</button>
|
||||||
|
<button class="tool-task-btn" data-task="wxwork_export">⑦ 企微导出</button>
|
||||||
|
<button class="tool-task-btn" data-task="voice_mp3">⑧ 语音转 MP3</button>
|
||||||
|
<span id="toolStatus" class="tool-status" style="display:none"></span>
|
||||||
|
</div>
|
||||||
|
<div class="tool-log-wrap" id="toolLog"></div>
|
||||||
|
</div>
|
||||||
<div class="settings-overlay" id="settingsOverlay" onclick="toggleSettings()"></div>
|
<div class="settings-overlay" id="settingsOverlay" onclick="toggleSettings()"></div>
|
||||||
<div class="settings-panel" id="settingsPanel">
|
<div class="settings-panel" id="settingsPanel">
|
||||||
<div class="sp-header"><h2>通知设置</h2><button class="sp-close" onclick="toggleSettings()">×</button></div>
|
<div class="sp-header"><h2>通知设置</h2><button class="sp-close" onclick="toggleSettings()">×</button></div>
|
||||||
@@ -1890,6 +1925,37 @@ function toggleSettings(){
|
|||||||
o.classList.toggle('show',show);
|
o.classList.toggle('show',show);
|
||||||
if(show) renderRules();
|
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(){
|
function beep(){
|
||||||
try{
|
try{
|
||||||
const ctx=new(window.AudioContext||window.webkitAudioContext)();
|
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=()=>{
|
es.onerror=()=>{
|
||||||
S.textContent='重连...';
|
S.textContent='重连...';
|
||||||
S.className='status err';
|
S.className='status err';
|
||||||
@@ -2031,6 +2110,123 @@ fetch('/api/history').then(r=>r.json()).then(ms=>{
|
|||||||
</html>'''
|
</html>'''
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────── 工具任务 (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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
def log_message(self, *a): pass
|
def log_message(self, *a): pass
|
||||||
def handle(self):
|
def handle(self):
|
||||||
@@ -2144,6 +2340,55 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self.send_error(404)
|
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):
|
class ThreadedServer(ThreadingMixIn, HTTPServer):
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
|
|||||||
Reference in New Issue
Block a user