weflow-cli 1.17.0 → 1.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +3 -0
- package/bin/weflow-cli.ts +167 -0
- package/dist/bin/weflow-cli.js +199 -0
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/services/diagnosticReport.d.ts +6 -0
- package/dist/src/services/diagnosticReport.d.ts.map +1 -1
- package/dist/src/services/diagnosticReport.js +16 -0
- package/dist/src/services/diagnosticReport.js.map +1 -1
- package/dist/src/services/wechatCompat.d.ts +29 -0
- package/dist/src/services/wechatCompat.d.ts.map +1 -0
- package/dist/src/services/wechatCompat.js +65 -0
- package/dist/src/services/wechatCompat.js.map +1 -0
- package/docs/DATA_CONTRACT.md +25 -25
- package/docs/DECISIONS.md +26 -0
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
- package/docs/HEALTH-CHECK.md +104 -104
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
- package/docs/NEXT_ACTIONS.md +236 -236
- package/docs/NPM-PUBLISH.md +130 -130
- package/docs/NT_DATABASES.md +47 -0
- package/docs/POSITIONING.md +68 -68
- package/docs/PROJECT_STATE.md +3 -2
- package/docs/RELEASING.md +4 -0
- package/package.json +1 -1
- package/requirements-3x.txt +4 -4
- package/requirements-voice.txt +15 -15
- package/resources/js/graph3d/NOTICE.txt +35 -35
- package/resources/js/graph3d/d3-binarytree.min.js +2 -2
- package/resources/js/graph3d/d3-dispatch.min.js +2 -2
- package/resources/js/graph3d/d3-force-3d.min.js +2 -2
- package/resources/js/graph3d/d3-octree.min.js +2 -2
- package/resources/js/graph3d/d3-quadtree.min.js +2 -2
- package/resources/js/graph3d/d3-timer.min.js +2 -2
- package/resources/js/graph3d/three.min.js +6 -6
- package/resources/panel/quick-menu.cjs +197 -197
- package/scripts/_utils.py +1338 -1338
- package/scripts/annual_report.py +3 -1
- package/scripts/article_notes.py +423 -423
- package/scripts/contact_schema.py +339 -339
- package/scripts/create_reading_notes.py +11 -11
- package/scripts/daily_mail.py +51 -5
- package/scripts/daily_task.ps1 +216 -213
- package/scripts/extract_todos.py +3 -1
- package/scripts/fav_notes.py +321 -321
- package/scripts/health_check.py +473 -473
- package/scripts/imported_chats.py +68 -68
- package/scripts/install_tasks.ps1 +105 -105
- package/scripts/key_cache.py +232 -0
- package/scripts/leads.py +179 -179
- package/scripts/mcp_bridge.py +3 -1
- package/scripts/media_index.py +150 -0
- package/scripts/network.py +206 -206
- package/scripts/nt_common.py +32 -0
- package/scripts/nt_decrypt.py +2023 -1954
- package/scripts/opinion_timeline.py +138 -138
- package/scripts/replay.py +803 -803
- package/scripts/run_hidden.vbs +24 -24
- package/scripts/semantic_search.py +5 -1
- package/scripts/user_notes.py +315 -315
- package/scripts/voice_transcribe.py +139 -139
- package/scripts/watch_issues.py +212 -212
- package/scripts/watch_mail.py +131 -131
- package/scripts/wechat_avatar.py +185 -0
- package/scripts/wechat_ledger.py +190 -0
- package/src/services/cursorProbe.ts +222 -222
- package/src/services/diagnosticReport.ts +22 -0
- package/src/services/qchatImport.ts +327 -327
- package/src/services/setupVenv.ts +238 -238
- package/src/services/syncService.ts +363 -363
- package/src/services/syncState.ts +275 -275
- package/src/services/wechatCompat.ts +67 -0
- package/src/services/wechatMedia.ts +166 -166
package/scripts/health_check.py
CHANGED
|
@@ -1,473 +1,473 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Periodic health check for weflow-cli.
|
|
3
|
-
|
|
4
|
-
A pure script - no model, no tokens. Every check here exists because that exact
|
|
5
|
-
thing has silently broken at least once, and the failure was invisible:
|
|
6
|
-
|
|
7
|
-
版本一致 1.6.0 自报 1.5.1, so "which build am I running" was unanswerable
|
|
8
|
-
读取路径一致 只读 message_0.db 时, export json 有 31 条而 export html 有 1145 条
|
|
9
|
-
会话新鲜度 同上, 会话列表停在 18 天前而命令仍然"成功"
|
|
10
|
-
导出格式 excel 全程从未成功过, 报错只有"导出失败"四个字
|
|
11
|
-
Python 依赖 缺 sqlcipher3 时命令报的是"数据库连接失败"
|
|
12
|
-
计划任务 提醒脚本静默停跑, 没人会发现
|
|
13
|
-
微信客户端 从没报过客户端版本; 而"读 exe 里的版本字面量"是错的判据(实测那只是兜底),
|
|
14
|
-
磁盘上还可能留着一个不能用的版本目录(指向它的加载会失败)
|
|
15
|
-
|
|
16
|
-
Run it ad hoc, or schedule it and read the summary line:
|
|
17
|
-
|
|
18
|
-
py scripts/health_check.py
|
|
19
|
-
py scripts/health_check.py --json
|
|
20
|
-
|
|
21
|
-
Exit code is 0 only when every check passes, so a scheduler can act on it.
|
|
22
|
-
"""
|
|
23
|
-
import argparse
|
|
24
|
-
import datetime
|
|
25
|
-
import json
|
|
26
|
-
import os
|
|
27
|
-
import shutil
|
|
28
|
-
import subprocess
|
|
29
|
-
import sys
|
|
30
|
-
import tempfile
|
|
31
|
-
import time
|
|
32
|
-
from pathlib import Path
|
|
33
|
-
|
|
34
|
-
ROOT = Path(__file__).resolve().parent.parent
|
|
35
|
-
CLI = ROOT / 'cli.cjs'
|
|
36
|
-
|
|
37
|
-
# Windows consoles default to a legacy codepage that cannot encode the report.
|
|
38
|
-
for _stream in (sys.stdout, sys.stderr):
|
|
39
|
-
try:
|
|
40
|
-
_stream.reconfigure(encoding='utf-8', errors='replace')
|
|
41
|
-
except (AttributeError, ValueError):
|
|
42
|
-
pass
|
|
43
|
-
|
|
44
|
-
# Each check shells out to the CLI, which takes seconds; keep the sample small.
|
|
45
|
-
SAMPLE_LIMIT = 5
|
|
46
|
-
TIMEOUT = 300
|
|
47
|
-
|
|
48
|
-
# A session list that has not moved in this long means reads are stale. WeChat
|
|
49
|
-
# is not chatty for everyone, so this is deliberately generous.
|
|
50
|
-
STALE_SESSION_DAYS = 7
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def run(args, timeout=TIMEOUT):
|
|
54
|
-
"""(returncode, stdout, stderr) with Node's experimental warning suppressed."""
|
|
55
|
-
env = {**os.environ, 'NODE_NO_WARNINGS': '1', 'PYTHONIOENCODING': 'utf-8'}
|
|
56
|
-
try:
|
|
57
|
-
proc = subprocess.run(
|
|
58
|
-
['node', str(CLI), *args], capture_output=True, timeout=timeout,
|
|
59
|
-
encoding='utf-8', errors='replace', env=env, cwd=str(ROOT))
|
|
60
|
-
except (OSError, subprocess.SubprocessError) as error:
|
|
61
|
-
return 1, '', f'{type(error).__name__}: {error}'
|
|
62
|
-
return proc.returncode, proc.stdout or '', proc.stderr or ''
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def run_json(args):
|
|
66
|
-
"""Parsed JSON payload, or None.
|
|
67
|
-
|
|
68
|
-
The CLI pretty-prints, so the payload spans many lines - parse from the
|
|
69
|
-
first brace rather than looking for a single-line blob.
|
|
70
|
-
"""
|
|
71
|
-
code, out, _ = run([*args, '--json'])
|
|
72
|
-
start = out.find('{')
|
|
73
|
-
if start < 0:
|
|
74
|
-
return code, None
|
|
75
|
-
try:
|
|
76
|
-
return code, json.JSONDecoder().raw_decode(out[start:])[0]
|
|
77
|
-
except ValueError:
|
|
78
|
-
return code, None
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
class Report:
|
|
82
|
-
def __init__(self):
|
|
83
|
-
self.rows = []
|
|
84
|
-
|
|
85
|
-
def add(self, name, ok, detail, hint=''):
|
|
86
|
-
self.rows.append({'name': name, 'ok': bool(ok), 'detail': detail, 'hint': hint})
|
|
87
|
-
|
|
88
|
-
@property
|
|
89
|
-
def failed(self):
|
|
90
|
-
return [r for r in self.rows if not r['ok']]
|
|
91
|
-
|
|
92
|
-
def render(self):
|
|
93
|
-
stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
94
|
-
lines = [f'WeFlow 健康检查 · {stamp}', '']
|
|
95
|
-
width = max(len(r['name']) for r in self.rows) + 2
|
|
96
|
-
for row in self.rows:
|
|
97
|
-
mark = 'OK ' if row['ok'] else 'FAIL'
|
|
98
|
-
lines.append(f'[{mark}] {row["name"]:<{width}}{row["detail"]}')
|
|
99
|
-
if not row['ok'] and row['hint']:
|
|
100
|
-
lines.append(f'{"":<7}{"":<{width}}↳ {row["hint"]}')
|
|
101
|
-
lines.append('')
|
|
102
|
-
if self.failed:
|
|
103
|
-
lines.append(f'{len(self.rows) - len(self.failed)} 项通过 / {len(self.failed)} 项失败')
|
|
104
|
-
else:
|
|
105
|
-
lines.append(f'全部通过({len(self.rows)} 项)')
|
|
106
|
-
return '\n'.join(lines)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def check_version(report):
|
|
110
|
-
"""The CLI must report the version it actually is."""
|
|
111
|
-
try:
|
|
112
|
-
declared = json.loads((ROOT / 'package.json').read_text(encoding='utf-8'))['version']
|
|
113
|
-
except (OSError, ValueError, KeyError) as error:
|
|
114
|
-
report.add('版本一致', False, f'读不到 package.json ({type(error).__name__})')
|
|
115
|
-
return
|
|
116
|
-
code, out, _ = run(['--version'])
|
|
117
|
-
reported = out.strip().split('\n')[-1].strip() if out.strip() else ''
|
|
118
|
-
report.add('版本一致', code == 0 and reported == declared,
|
|
119
|
-
f'CLI {reported or "?"} / package {declared}',
|
|
120
|
-
'两者不一致说明 --version 又写死了,或 dist 未重新构建')
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def check_environment(report):
|
|
124
|
-
"""Environment self-check: config present, Python deps importable."""
|
|
125
|
-
_, data = run_json(['check'])
|
|
126
|
-
if not data:
|
|
127
|
-
report.add('环境自检', False, 'check 未返回 JSON', '先跑 weflow-cli check 看原始输出')
|
|
128
|
-
return
|
|
129
|
-
config = data.get('configuration') or {}
|
|
130
|
-
deps = ((data.get('dependencies') or {}).get('required') or {})
|
|
131
|
-
missing = [name for name, status in deps.items() if not status]
|
|
132
|
-
ok = bool(config.get('initialized')) and bool(config.get('messageDatabase')) and not missing
|
|
133
|
-
detail = f'已初始化={config.get("initialized")} 消息库={config.get("messageDatabase")}'
|
|
134
|
-
if missing:
|
|
135
|
-
detail += f' 缺依赖={",".join(missing)}'
|
|
136
|
-
report.add('环境自检', ok, detail,
|
|
137
|
-
'缺依赖: pip install -r requirements.txt;未初始化: weflow-cli init')
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def pick_talkers(report):
|
|
141
|
-
"""The most recently active sessions - the likeliest to span shards.
|
|
142
|
-
|
|
143
|
-
**挑不出来时要说出来**:没有会话是"没登录/没 init"(会报红),而**有会话、却一条都拿不到
|
|
144
|
-
`username`** —— 那是"字段长变了"或"列表是半截的"。这一支原来直接返回空,`main()` 里
|
|
145
|
-
`if talkers:` 于是**静默跳过**后两项检查,报告照样写"全部通过"(只是项数悄悄少了两个)。
|
|
146
|
-
同一个假绿在"计划任务"那一项也出现过(见那一段的注释)。
|
|
147
|
-
"""
|
|
148
|
-
_, data = run_json(['sessions', '--limit', str(SAMPLE_LIMIT)])
|
|
149
|
-
sessions = ((data or {}).get('sessions') or [])
|
|
150
|
-
if not sessions:
|
|
151
|
-
report.add('选择样本会话', False, '会话列表为空', '先确认微信已登录且 init 成功')
|
|
152
|
-
return []
|
|
153
|
-
ordered = sorted(sessions, key=lambda s: s.get('sortTimestamp') or 0, reverse=True)
|
|
154
|
-
picked = [s['username'] for s in ordered[:2] if s.get('username')]
|
|
155
|
-
if not picked:
|
|
156
|
-
# 有会话却挑不出样本 ⇒ 后面两项(读取路径一致 / 导出格式)会整段不跑,
|
|
157
|
-
# 不说的话这一份报告就是"少了两个检查的全绿"
|
|
158
|
-
report.add('选择样本会话', False, f'会话有 {len(sessions)} 条,但都没有 username 字段',
|
|
159
|
-
'sessions 的输出字段可能变了 —— 这两项检查因此没跑,不代表通过')
|
|
160
|
-
return picked
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def read_newest_json(out_dir):
|
|
164
|
-
"""那个目录里 json 导出的最新消息时间(秒);读不出来给 None。"""
|
|
165
|
-
newest = None
|
|
166
|
-
for path in Path(out_dir).glob('*.json'):
|
|
167
|
-
try:
|
|
168
|
-
rows = json.loads(path.read_text(encoding='utf-8'))
|
|
169
|
-
except (OSError, ValueError):
|
|
170
|
-
continue
|
|
171
|
-
times = [int(r.get('createTime') or 0) for r in rows]
|
|
172
|
-
if times:
|
|
173
|
-
newest = max(times)
|
|
174
|
-
break
|
|
175
|
-
return newest
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def check_read_paths(report, talker):
|
|
179
|
-
"""`export json` and `export html` read the same chat by different code.
|
|
180
|
-
|
|
181
|
-
They disagreed by 1114 messages when the JSON path read only the first
|
|
182
|
-
shard, so comparing their newest message is the cheapest way to notice the
|
|
183
|
-
read paths drifting apart again.
|
|
184
|
-
|
|
185
|
-
**但"最新消息"是会动的**:json 先导、html 后导,中间隔着一两分钟 —— 这期间聊天里来了
|
|
186
|
-
一条新消息,两条路径就会"不一致",而那是**聊天活着**,不是读取断了。原来判据是精确到分钟
|
|
187
|
-
的相等(2026-10-11 实测撞到过:`json 13:38 / html 13:40:14` 判红,紧接着同一条聊天
|
|
188
|
-
`13:40 vs 13:40:14` 判绿)。放宽容差解决不了:繁忙的群里分片轮转本来就在分钟级,
|
|
189
|
-
放宽就会把这个检查唯一要抓的东西放过去。
|
|
190
|
-
|
|
191
|
-
所以不一致时**再读一次 json 做仲裁**:那一读能看到 html 那条 ⇒ 差别来自"消息是新的";
|
|
192
|
-
看不到 ⇒ 两条路径真的不一致(这才是要报的)。仲裁只在**本来就不一致**时才花这一次导出。
|
|
193
|
-
"""
|
|
194
|
-
with tempfile.TemporaryDirectory(prefix='wf-health-') as tmp:
|
|
195
|
-
out_json = Path(tmp) / 'json'
|
|
196
|
-
# 这次导出的退出码与 stderr **不看**:这一项判的是"两条路径读到的内容一致不一致",
|
|
197
|
-
# 而 json 读不出来时 `read_newest_json` 会给 None、下面照样报红。留两个不用的变量
|
|
198
|
-
# 只会让"检查了什么"看起来比实际多。
|
|
199
|
-
run(['export', talker, 'json', '--limit', '5',
|
|
200
|
-
'--output', str(out_json), '--non-interactive'])
|
|
201
|
-
newest_json = read_newest_json(out_json)
|
|
202
|
-
|
|
203
|
-
out_html = Path(tmp) / 'html'
|
|
204
|
-
code_h, out_h, _ = run(['export', talker, 'html', '--limit', '5',
|
|
205
|
-
'--output', str(out_html), '--non-interactive'])
|
|
206
|
-
total_html = None
|
|
207
|
-
for line in out_h.split('\n'):
|
|
208
|
-
line = line.strip()
|
|
209
|
-
if line.startswith('Total:') and 'messages' in line:
|
|
210
|
-
try:
|
|
211
|
-
total_html = int(line.split()[1])
|
|
212
|
-
except (IndexError, ValueError):
|
|
213
|
-
pass
|
|
214
|
-
# Read the pages while the temp directory still exists.
|
|
215
|
-
newest_html = html_newest_stamp(out_html)
|
|
216
|
-
|
|
217
|
-
label = f'读取路径一致 ({talker[:14]}…)'
|
|
218
|
-
if newest_json is None:
|
|
219
|
-
report.add(label, False, 'json 导出没有产出可比对的消息',
|
|
220
|
-
'export json 失败时先单独跑一次看报错')
|
|
221
|
-
return
|
|
222
|
-
if total_html is None:
|
|
223
|
-
report.add(label, False, 'html 导出没有报告条数',
|
|
224
|
-
'export html 失败时先单独跑一次看报错')
|
|
225
|
-
return
|
|
226
|
-
|
|
227
|
-
newest_txt = datetime.datetime.fromtimestamp(newest_json).strftime('%Y-%m-%d %H:%M')
|
|
228
|
-
if not newest_html:
|
|
229
|
-
# No timestamp in the page (empty chat); the count comparison still holds.
|
|
230
|
-
report.add(label, total_html > 0, f'json 最新 {newest_txt} / html 共 {total_html} 条')
|
|
231
|
-
return
|
|
232
|
-
|
|
233
|
-
if newest_html[:16] == newest_txt:
|
|
234
|
-
report.add(label, True, f'json 最新 {newest_txt} / html 最新 {newest_html}')
|
|
235
|
-
return
|
|
236
|
-
|
|
237
|
-
# 不一致 —— **仲裁**:再读一次 json。它能看到 html 那条,说明差别只是"消息是新的"。
|
|
238
|
-
with tempfile.TemporaryDirectory(prefix='wf-health-arbiter-') as tmp:
|
|
239
|
-
again = Path(tmp) / 'json'
|
|
240
|
-
run(['export', talker, 'json', '--limit', '5',
|
|
241
|
-
'--output', str(again), '--non-interactive'])
|
|
242
|
-
arbiter = read_newest_json(again)
|
|
243
|
-
if arbiter is not None and arbiter >= newest_json:
|
|
244
|
-
arbiter_txt = datetime.datetime.fromtimestamp(arbiter).strftime('%Y-%m-%d %H:%M')
|
|
245
|
-
if arbiter_txt == newest_html[:16]:
|
|
246
|
-
report.add(label, True,
|
|
247
|
-
f'json 最新 {newest_txt} → 复查 {arbiter_txt} / html 最新 {newest_html}',
|
|
248
|
-
'差别来自检查期间来了新消息(这一项防的是"只剩一个分片被读了")')
|
|
249
|
-
return
|
|
250
|
-
|
|
251
|
-
# 仲裁没说通(复查也没读到 html 那条,或者根本没读出来)⇒ 这是真的不一致
|
|
252
|
-
if arbiter is None:
|
|
253
|
-
reason = '复查 json 也没读出来'
|
|
254
|
-
else:
|
|
255
|
-
reason = '复查 json 仍是 %s,没有更近的消息' % \
|
|
256
|
-
datetime.datetime.fromtimestamp(arbiter).strftime('%Y-%m-%d %H:%M')
|
|
257
|
-
report.add(label, False, f'json 最新 {newest_txt} / html 最新 {newest_html}',
|
|
258
|
-
'两条路径读到不同的最新消息,通常又只剩一个分片被读了(%s)' % reason)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def html_newest_stamp(out_dir):
|
|
262
|
-
"""Newest 'YYYY-MM-DD HH:MM:SS' stamp rendered into the exported pages."""
|
|
263
|
-
import re
|
|
264
|
-
newest = ''
|
|
265
|
-
try:
|
|
266
|
-
for page in sorted(Path(out_dir).glob('*.html')):
|
|
267
|
-
text = page.read_text(encoding='utf-8', errors='replace')
|
|
268
|
-
stamps = re.findall(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', text)
|
|
269
|
-
if stamps and stamps[-1] > newest:
|
|
270
|
-
newest = stamps[-1]
|
|
271
|
-
except OSError:
|
|
272
|
-
pass
|
|
273
|
-
return newest
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def check_session_freshness(report):
|
|
277
|
-
"""A read path that silently stops at an old shard shows up here first.
|
|
278
|
-
|
|
279
|
-
⚠️ **但"最旧"与"只是没聊天"是同一个观测**:这一项看的是会话列表里最新那条消息有多旧,
|
|
280
|
-
而一个人一周没发消息,读数与"读取停在某个旧分片"**完全一样**。所以提示里必须把两种解释
|
|
281
|
-
都说出来,并且点出判别器在隔壁:`读取路径一致` 绿着 ⇒ 多半只是这段时间没聊天;
|
|
282
|
-
两项一起红才更像读取断了。**别在这里替人下结论。**
|
|
283
|
-
"""
|
|
284
|
-
_, data = run_json(['sessions', '--limit', '5'])
|
|
285
|
-
sessions = ((data or {}).get('sessions') or [])
|
|
286
|
-
stamps = [s.get('sortTimestamp') or 0 for s in sessions]
|
|
287
|
-
if not stamps or not max(stamps):
|
|
288
|
-
report.add('会话新鲜度', False, '会话列表没有时间戳', 'init 可能未完成')
|
|
289
|
-
return
|
|
290
|
-
newest = max(stamps)
|
|
291
|
-
age = (time.time() - newest) / 86400
|
|
292
|
-
when = datetime.datetime.fromtimestamp(newest).strftime('%Y-%m-%d %H:%M')
|
|
293
|
-
report.add('会话新鲜度', age <= STALE_SESSION_DAYS,
|
|
294
|
-
f'最新会话 {when}({age:.1f} 天前)',
|
|
295
|
-
f'超过 {STALE_SESSION_DAYS} 天没有新消息。两种解释:**这段时间没聊天**(最可能)'
|
|
296
|
-
f'或**读取只覆盖了部分分片** —— 看「读取路径一致」那一项:它绿着就多半是前者,'
|
|
297
|
-
f'两项一起红才更像读取断了')
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def check_export_formats(report, talker):
|
|
301
|
-
"""json / txt / excel must each produce a file. html is covered above."""
|
|
302
|
-
with tempfile.TemporaryDirectory(prefix='wf-health-') as tmp:
|
|
303
|
-
for fmt, pattern in (('json', '*.json'), ('txt', '*.txt'), ('excel', '*.xlsx')):
|
|
304
|
-
out = Path(tmp) / fmt
|
|
305
|
-
code, stdout, stderr = run(['export', talker, fmt, '--limit', '3',
|
|
306
|
-
'--output', str(out), '--non-interactive'])
|
|
307
|
-
produced = list(out.glob(pattern)) if out.exists() else []
|
|
308
|
-
size = produced[0].stat().st_size if produced else 0
|
|
309
|
-
detail = f'{size} 字节' if produced else (stderr.strip().split('\n')[-1][:60] or f'exit {code}')
|
|
310
|
-
report.add(f'导出格式 {fmt}', code == 0 and size > 0, detail)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def check_python_deps(report):
|
|
314
|
-
"""The NT reader is a Python subprocess; a missing module looks like a DB error."""
|
|
315
|
-
required = {
|
|
316
|
-
'sqlcipher3': 'sqlcipher3',
|
|
317
|
-
'html2text': 'html2text',
|
|
318
|
-
'zstandard': 'zstandard',
|
|
319
|
-
'cryptography': 'cryptography',
|
|
320
|
-
}
|
|
321
|
-
missing = []
|
|
322
|
-
for module, package in required.items():
|
|
323
|
-
try:
|
|
324
|
-
__import__(module)
|
|
325
|
-
except ImportError:
|
|
326
|
-
missing.append(package)
|
|
327
|
-
report.add('Python 依赖', not missing,
|
|
328
|
-
'齐全' if not missing else f'缺 {", ".join(missing)}',
|
|
329
|
-
'pip install -r requirements.txt')
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
def check_scheduled_tasks(report):
|
|
333
|
-
"""The watcher tasks must exist and their last run must have succeeded.
|
|
334
|
-
|
|
335
|
-
**"读不出来"必须与"上次成功"分开**:这一项的判据是从 `schtasks` 的文本输出里找
|
|
336
|
-
`Last Result` / `上次运行结果` 那一行 —— 系统语言或输出格式一变,那一行就找不到了,
|
|
337
|
-
而**两个清单都会是空的**,于是它落到 `else`、报"均在位且上次成功"。
|
|
338
|
-
那正是把"没验证"说成"通过"(同一类:`docs/DECISIONS.md` D-107 里那条"取不到就说未知")。
|
|
339
|
-
"""
|
|
340
|
-
if os.name != 'nt':
|
|
341
|
-
report.add('计划任务', True, '非 Windows,跳过')
|
|
342
|
-
return
|
|
343
|
-
if not shutil.which('schtasks'):
|
|
344
|
-
report.add('计划任务', True, '找不到 schtasks,跳过')
|
|
345
|
-
return
|
|
346
|
-
names = ['WeFlow Issue Watch', 'WeFlow Mail Watch']
|
|
347
|
-
broken, absent, unreadable, not_yet_run = [], [], [], []
|
|
348
|
-
for name in names:
|
|
349
|
-
try:
|
|
350
|
-
proc = subprocess.run(['schtasks', '/query', '/tn', name, '/fo', 'LIST', '/v'],
|
|
351
|
-
capture_output=True, timeout=60, encoding='utf-8', errors='replace')
|
|
352
|
-
except (OSError, subprocess.SubprocessError):
|
|
353
|
-
absent.append(name)
|
|
354
|
-
continue
|
|
355
|
-
if proc.returncode != 0:
|
|
356
|
-
absent.append(name)
|
|
357
|
-
continue
|
|
358
|
-
text = proc.stdout or ''
|
|
359
|
-
result = None
|
|
360
|
-
for line in text.split('\n'):
|
|
361
|
-
if 'Last Result' in line or '上次运行结果' in line:
|
|
362
|
-
result = line.split(':', 1)[-1].strip()
|
|
363
|
-
break
|
|
364
|
-
if result is None:
|
|
365
|
-
# 输出里**没有那个字段**:要么语言/格式变了,要么它没在汇报这一项。
|
|
366
|
-
# 这时该说的是"读不出来",不是"上次成功"。
|
|
367
|
-
unreadable.append(name)
|
|
368
|
-
elif result in ('267011', '0x41303'):
|
|
369
|
-
# `SCHED_S_TASK_HAS_NOT_RUN`(267011 / 0x41303)—— **不是失败**:注册之后还没到
|
|
370
|
-
# 第一次触发。原来它落进下面那支"上次运行失败",提示还写着"脚本可能抛异常了",把刚
|
|
371
|
-
# 注册的人引去查一个不存在的异常(2026-10-11 本机注册这两个任务时实测到)。
|
|
372
|
-
# 但也**不算通过**:这一项要的是"跑过一次且成功",而任务一直"没跑过"正是它要防的事
|
|
373
|
-
# (触发条件写坏,或设成了"仅在用户登录时运行"而没人登录)。
|
|
374
|
-
not_yet_run.append(name)
|
|
375
|
-
elif result not in ('0', '0x0'):
|
|
376
|
-
broken.append(f'{name}={result}')
|
|
377
|
-
if broken:
|
|
378
|
-
report.add('计划任务', False, f'上次运行失败: {", ".join(broken)}',
|
|
379
|
-
'脚本可能抛异常了,手动跑一次看输出')
|
|
380
|
-
elif absent:
|
|
381
|
-
report.add('计划任务', False, f'不存在: {", ".join(absent)}',
|
|
382
|
-
'提醒不会触发;见 docs/HEALTH-CHECK.md 的注册命令')
|
|
383
|
-
elif unreadable:
|
|
384
|
-
# **不算通过**:这一项没被验证过(不是"上次成功")
|
|
385
|
-
report.add('计划任务', False, f'读不出上次运行结果: {", ".join(unreadable)}',
|
|
386
|
-
'schtasks 的输出格式或系统语言变了 —— 这一项**没有验证**,不代表成功;'
|
|
387
|
-
'手动跑 `schtasks /query /tn "WeFlow Issue Watch" /fo LIST /v` 看一眼')
|
|
388
|
-
elif not_yet_run:
|
|
389
|
-
report.add('计划任务', False, f'还没跑过: {", ".join(not_yet_run)}',
|
|
390
|
-
'不算通过 —— 这一项要的是"跑过一次且成功"。刚注册的话等一次触发(30 分钟);'
|
|
391
|
-
'一直如此就查触发条件,以及任务是不是设成了"仅在用户登录时运行"')
|
|
392
|
-
else:
|
|
393
|
-
report.add('计划任务', True, '均在位且上次成功')
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def check_wechat_client(report):
|
|
397
|
-
"""**微信客户端版本**,以及磁盘上有没有"不能用的版本目录"。
|
|
398
|
-
|
|
399
|
-
判据在 CLI 那一侧(`src/services/wechatClient.ts`):版本来自**活进程命令行的
|
|
400
|
-
`--user-lib-dir`** —— 不是 exe 里的版本字面量(实测那只是兜底),也不是"磁盘上有没有
|
|
401
|
-
这个版本目录"(本机就有一个只剩 1 个文件的残留目录,指向它的加载会失败)。
|
|
402
|
-
|
|
403
|
-
取不到就写"未知",**不写"没问题"**:与仓库里那条"没找到必须报 UNKNOWN,别报 CLEAR"同一条纪律。
|
|
404
|
-
字段整个不在 = CLI 是该重建的旧版本 ⇒ **不算通过**(这正是"打开成功≠能用"那一类:
|
|
405
|
-
少了一项却一片绿)。
|
|
406
|
-
"""
|
|
407
|
-
_, data = run_json(['check'])
|
|
408
|
-
if not data:
|
|
409
|
-
report.add('微信客户端', False, 'check 未返回 JSON', '先跑 weflow-cli check 看原始输出')
|
|
410
|
-
return
|
|
411
|
-
client = data.get('wechatClient')
|
|
412
|
-
if not isinstance(client, dict):
|
|
413
|
-
report.add('微信客户端', False, 'check 没报这一项',
|
|
414
|
-
'CLI 可能没重新构建(npm run build),或 dist 是旧的')
|
|
415
|
-
return
|
|
416
|
-
version = client.get('version')
|
|
417
|
-
loaded = client.get('loadedDirs') or []
|
|
418
|
-
if version:
|
|
419
|
-
detail = str(version) + (f'({loaded[0]})' if loaded else '')
|
|
420
|
-
else:
|
|
421
|
-
detail = '未知 —— ' + str(client.get('note') or '')
|
|
422
|
-
dead = client.get('unusableVersionDirs') or []
|
|
423
|
-
if dead:
|
|
424
|
-
shown = '、'.join(f'{d.get("dir")}({d.get("files")} 个文件)' for d in dead)
|
|
425
|
-
detail += f' ⚠ 残留版本目录(没有 Weixin.dll,加载会失败): {shown}'
|
|
426
|
-
# 客户端自报的数据目录 vs 配置推出来的根:不一致 = 两边可能不是同一份库(CLI 那边已按真身比过,
|
|
427
|
-
# 本机数据目录有 D 盘软链,所以这里只报结论)
|
|
428
|
-
if client.get('dataDirMatchesConfig') is False:
|
|
429
|
-
detail += ' ⚠ 客户端自报的数据目录与配置里那个数据库推出来的根**不是同一份**'
|
|
430
|
-
crashes = client.get('crashRecords') or {}
|
|
431
|
-
count = int(crashes.get('reports') or 0) + int(crashes.get('records') or 0)
|
|
432
|
-
if count:
|
|
433
|
-
newest = str(crashes.get('newest') or '时间读不到')[:16]
|
|
434
|
-
detail += f' · 客户端崩溃记录 {count} 份(最近 {newest})—— 崩过时数据读不出来先看这条'
|
|
435
|
-
# `update.data` 在场 ⇒ 上面那个版本**未必**是加载的那个:这句必须跟着版本一起出现
|
|
436
|
-
for caveat in client.get('caveats') or []:
|
|
437
|
-
detail += f' ⚠ {caveat}'
|
|
438
|
-
report.add('微信客户端', True, detail)
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
def main():
|
|
442
|
-
parser = argparse.ArgumentParser(description='weflow-cli periodic health check')
|
|
443
|
-
parser.add_argument('--json', action='store_true', help='机器可读输出')
|
|
444
|
-
args = parser.parse_args()
|
|
445
|
-
|
|
446
|
-
if not CLI.exists():
|
|
447
|
-
print(f'找不到 {CLI}', file=sys.stderr)
|
|
448
|
-
return 2
|
|
449
|
-
|
|
450
|
-
report = Report()
|
|
451
|
-
check_version(report)
|
|
452
|
-
check_environment(report)
|
|
453
|
-
check_wechat_client(report)
|
|
454
|
-
check_python_deps(report)
|
|
455
|
-
check_session_freshness(report)
|
|
456
|
-
|
|
457
|
-
talkers = pick_talkers(report)
|
|
458
|
-
if talkers:
|
|
459
|
-
check_read_paths(report, talkers[0])
|
|
460
|
-
check_export_formats(report, talkers[0])
|
|
461
|
-
|
|
462
|
-
check_scheduled_tasks(report)
|
|
463
|
-
|
|
464
|
-
if args.json:
|
|
465
|
-
print(json.dumps({'success': not report.failed, 'checks': report.rows},
|
|
466
|
-
ensure_ascii=False, indent=2))
|
|
467
|
-
else:
|
|
468
|
-
print(report.render())
|
|
469
|
-
return 1 if report.failed else 0
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if __name__ == '__main__':
|
|
473
|
-
sys.exit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Periodic health check for weflow-cli.
|
|
3
|
+
|
|
4
|
+
A pure script - no model, no tokens. Every check here exists because that exact
|
|
5
|
+
thing has silently broken at least once, and the failure was invisible:
|
|
6
|
+
|
|
7
|
+
版本一致 1.6.0 自报 1.5.1, so "which build am I running" was unanswerable
|
|
8
|
+
读取路径一致 只读 message_0.db 时, export json 有 31 条而 export html 有 1145 条
|
|
9
|
+
会话新鲜度 同上, 会话列表停在 18 天前而命令仍然"成功"
|
|
10
|
+
导出格式 excel 全程从未成功过, 报错只有"导出失败"四个字
|
|
11
|
+
Python 依赖 缺 sqlcipher3 时命令报的是"数据库连接失败"
|
|
12
|
+
计划任务 提醒脚本静默停跑, 没人会发现
|
|
13
|
+
微信客户端 从没报过客户端版本; 而"读 exe 里的版本字面量"是错的判据(实测那只是兜底),
|
|
14
|
+
磁盘上还可能留着一个不能用的版本目录(指向它的加载会失败)
|
|
15
|
+
|
|
16
|
+
Run it ad hoc, or schedule it and read the summary line:
|
|
17
|
+
|
|
18
|
+
py scripts/health_check.py
|
|
19
|
+
py scripts/health_check.py --json
|
|
20
|
+
|
|
21
|
+
Exit code is 0 only when every check passes, so a scheduler can act on it.
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import datetime
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
35
|
+
CLI = ROOT / 'cli.cjs'
|
|
36
|
+
|
|
37
|
+
# Windows consoles default to a legacy codepage that cannot encode the report.
|
|
38
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
39
|
+
try:
|
|
40
|
+
_stream.reconfigure(encoding='utf-8', errors='replace')
|
|
41
|
+
except (AttributeError, ValueError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# Each check shells out to the CLI, which takes seconds; keep the sample small.
|
|
45
|
+
SAMPLE_LIMIT = 5
|
|
46
|
+
TIMEOUT = 300
|
|
47
|
+
|
|
48
|
+
# A session list that has not moved in this long means reads are stale. WeChat
|
|
49
|
+
# is not chatty for everyone, so this is deliberately generous.
|
|
50
|
+
STALE_SESSION_DAYS = 7
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run(args, timeout=TIMEOUT):
|
|
54
|
+
"""(returncode, stdout, stderr) with Node's experimental warning suppressed."""
|
|
55
|
+
env = {**os.environ, 'NODE_NO_WARNINGS': '1', 'PYTHONIOENCODING': 'utf-8'}
|
|
56
|
+
try:
|
|
57
|
+
proc = subprocess.run(
|
|
58
|
+
['node', str(CLI), *args], capture_output=True, timeout=timeout,
|
|
59
|
+
encoding='utf-8', errors='replace', env=env, cwd=str(ROOT))
|
|
60
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
61
|
+
return 1, '', f'{type(error).__name__}: {error}'
|
|
62
|
+
return proc.returncode, proc.stdout or '', proc.stderr or ''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_json(args):
|
|
66
|
+
"""Parsed JSON payload, or None.
|
|
67
|
+
|
|
68
|
+
The CLI pretty-prints, so the payload spans many lines - parse from the
|
|
69
|
+
first brace rather than looking for a single-line blob.
|
|
70
|
+
"""
|
|
71
|
+
code, out, _ = run([*args, '--json'])
|
|
72
|
+
start = out.find('{')
|
|
73
|
+
if start < 0:
|
|
74
|
+
return code, None
|
|
75
|
+
try:
|
|
76
|
+
return code, json.JSONDecoder().raw_decode(out[start:])[0]
|
|
77
|
+
except ValueError:
|
|
78
|
+
return code, None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Report:
|
|
82
|
+
def __init__(self):
|
|
83
|
+
self.rows = []
|
|
84
|
+
|
|
85
|
+
def add(self, name, ok, detail, hint=''):
|
|
86
|
+
self.rows.append({'name': name, 'ok': bool(ok), 'detail': detail, 'hint': hint})
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def failed(self):
|
|
90
|
+
return [r for r in self.rows if not r['ok']]
|
|
91
|
+
|
|
92
|
+
def render(self):
|
|
93
|
+
stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
94
|
+
lines = [f'WeFlow 健康检查 · {stamp}', '']
|
|
95
|
+
width = max(len(r['name']) for r in self.rows) + 2
|
|
96
|
+
for row in self.rows:
|
|
97
|
+
mark = 'OK ' if row['ok'] else 'FAIL'
|
|
98
|
+
lines.append(f'[{mark}] {row["name"]:<{width}}{row["detail"]}')
|
|
99
|
+
if not row['ok'] and row['hint']:
|
|
100
|
+
lines.append(f'{"":<7}{"":<{width}}↳ {row["hint"]}')
|
|
101
|
+
lines.append('')
|
|
102
|
+
if self.failed:
|
|
103
|
+
lines.append(f'{len(self.rows) - len(self.failed)} 项通过 / {len(self.failed)} 项失败')
|
|
104
|
+
else:
|
|
105
|
+
lines.append(f'全部通过({len(self.rows)} 项)')
|
|
106
|
+
return '\n'.join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_version(report):
|
|
110
|
+
"""The CLI must report the version it actually is."""
|
|
111
|
+
try:
|
|
112
|
+
declared = json.loads((ROOT / 'package.json').read_text(encoding='utf-8'))['version']
|
|
113
|
+
except (OSError, ValueError, KeyError) as error:
|
|
114
|
+
report.add('版本一致', False, f'读不到 package.json ({type(error).__name__})')
|
|
115
|
+
return
|
|
116
|
+
code, out, _ = run(['--version'])
|
|
117
|
+
reported = out.strip().split('\n')[-1].strip() if out.strip() else ''
|
|
118
|
+
report.add('版本一致', code == 0 and reported == declared,
|
|
119
|
+
f'CLI {reported or "?"} / package {declared}',
|
|
120
|
+
'两者不一致说明 --version 又写死了,或 dist 未重新构建')
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_environment(report):
|
|
124
|
+
"""Environment self-check: config present, Python deps importable."""
|
|
125
|
+
_, data = run_json(['check'])
|
|
126
|
+
if not data:
|
|
127
|
+
report.add('环境自检', False, 'check 未返回 JSON', '先跑 weflow-cli check 看原始输出')
|
|
128
|
+
return
|
|
129
|
+
config = data.get('configuration') or {}
|
|
130
|
+
deps = ((data.get('dependencies') or {}).get('required') or {})
|
|
131
|
+
missing = [name for name, status in deps.items() if not status]
|
|
132
|
+
ok = bool(config.get('initialized')) and bool(config.get('messageDatabase')) and not missing
|
|
133
|
+
detail = f'已初始化={config.get("initialized")} 消息库={config.get("messageDatabase")}'
|
|
134
|
+
if missing:
|
|
135
|
+
detail += f' 缺依赖={",".join(missing)}'
|
|
136
|
+
report.add('环境自检', ok, detail,
|
|
137
|
+
'缺依赖: pip install -r requirements.txt;未初始化: weflow-cli init')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def pick_talkers(report):
|
|
141
|
+
"""The most recently active sessions - the likeliest to span shards.
|
|
142
|
+
|
|
143
|
+
**挑不出来时要说出来**:没有会话是"没登录/没 init"(会报红),而**有会话、却一条都拿不到
|
|
144
|
+
`username`** —— 那是"字段长变了"或"列表是半截的"。这一支原来直接返回空,`main()` 里
|
|
145
|
+
`if talkers:` 于是**静默跳过**后两项检查,报告照样写"全部通过"(只是项数悄悄少了两个)。
|
|
146
|
+
同一个假绿在"计划任务"那一项也出现过(见那一段的注释)。
|
|
147
|
+
"""
|
|
148
|
+
_, data = run_json(['sessions', '--limit', str(SAMPLE_LIMIT)])
|
|
149
|
+
sessions = ((data or {}).get('sessions') or [])
|
|
150
|
+
if not sessions:
|
|
151
|
+
report.add('选择样本会话', False, '会话列表为空', '先确认微信已登录且 init 成功')
|
|
152
|
+
return []
|
|
153
|
+
ordered = sorted(sessions, key=lambda s: s.get('sortTimestamp') or 0, reverse=True)
|
|
154
|
+
picked = [s['username'] for s in ordered[:2] if s.get('username')]
|
|
155
|
+
if not picked:
|
|
156
|
+
# 有会话却挑不出样本 ⇒ 后面两项(读取路径一致 / 导出格式)会整段不跑,
|
|
157
|
+
# 不说的话这一份报告就是"少了两个检查的全绿"
|
|
158
|
+
report.add('选择样本会话', False, f'会话有 {len(sessions)} 条,但都没有 username 字段',
|
|
159
|
+
'sessions 的输出字段可能变了 —— 这两项检查因此没跑,不代表通过')
|
|
160
|
+
return picked
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_newest_json(out_dir):
|
|
164
|
+
"""那个目录里 json 导出的最新消息时间(秒);读不出来给 None。"""
|
|
165
|
+
newest = None
|
|
166
|
+
for path in Path(out_dir).glob('*.json'):
|
|
167
|
+
try:
|
|
168
|
+
rows = json.loads(path.read_text(encoding='utf-8'))
|
|
169
|
+
except (OSError, ValueError):
|
|
170
|
+
continue
|
|
171
|
+
times = [int(r.get('createTime') or 0) for r in rows]
|
|
172
|
+
if times:
|
|
173
|
+
newest = max(times)
|
|
174
|
+
break
|
|
175
|
+
return newest
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def check_read_paths(report, talker):
|
|
179
|
+
"""`export json` and `export html` read the same chat by different code.
|
|
180
|
+
|
|
181
|
+
They disagreed by 1114 messages when the JSON path read only the first
|
|
182
|
+
shard, so comparing their newest message is the cheapest way to notice the
|
|
183
|
+
read paths drifting apart again.
|
|
184
|
+
|
|
185
|
+
**但"最新消息"是会动的**:json 先导、html 后导,中间隔着一两分钟 —— 这期间聊天里来了
|
|
186
|
+
一条新消息,两条路径就会"不一致",而那是**聊天活着**,不是读取断了。原来判据是精确到分钟
|
|
187
|
+
的相等(2026-10-11 实测撞到过:`json 13:38 / html 13:40:14` 判红,紧接着同一条聊天
|
|
188
|
+
`13:40 vs 13:40:14` 判绿)。放宽容差解决不了:繁忙的群里分片轮转本来就在分钟级,
|
|
189
|
+
放宽就会把这个检查唯一要抓的东西放过去。
|
|
190
|
+
|
|
191
|
+
所以不一致时**再读一次 json 做仲裁**:那一读能看到 html 那条 ⇒ 差别来自"消息是新的";
|
|
192
|
+
看不到 ⇒ 两条路径真的不一致(这才是要报的)。仲裁只在**本来就不一致**时才花这一次导出。
|
|
193
|
+
"""
|
|
194
|
+
with tempfile.TemporaryDirectory(prefix='wf-health-') as tmp:
|
|
195
|
+
out_json = Path(tmp) / 'json'
|
|
196
|
+
# 这次导出的退出码与 stderr **不看**:这一项判的是"两条路径读到的内容一致不一致",
|
|
197
|
+
# 而 json 读不出来时 `read_newest_json` 会给 None、下面照样报红。留两个不用的变量
|
|
198
|
+
# 只会让"检查了什么"看起来比实际多。
|
|
199
|
+
run(['export', talker, 'json', '--limit', '5',
|
|
200
|
+
'--output', str(out_json), '--non-interactive'])
|
|
201
|
+
newest_json = read_newest_json(out_json)
|
|
202
|
+
|
|
203
|
+
out_html = Path(tmp) / 'html'
|
|
204
|
+
code_h, out_h, _ = run(['export', talker, 'html', '--limit', '5',
|
|
205
|
+
'--output', str(out_html), '--non-interactive'])
|
|
206
|
+
total_html = None
|
|
207
|
+
for line in out_h.split('\n'):
|
|
208
|
+
line = line.strip()
|
|
209
|
+
if line.startswith('Total:') and 'messages' in line:
|
|
210
|
+
try:
|
|
211
|
+
total_html = int(line.split()[1])
|
|
212
|
+
except (IndexError, ValueError):
|
|
213
|
+
pass
|
|
214
|
+
# Read the pages while the temp directory still exists.
|
|
215
|
+
newest_html = html_newest_stamp(out_html)
|
|
216
|
+
|
|
217
|
+
label = f'读取路径一致 ({talker[:14]}…)'
|
|
218
|
+
if newest_json is None:
|
|
219
|
+
report.add(label, False, 'json 导出没有产出可比对的消息',
|
|
220
|
+
'export json 失败时先单独跑一次看报错')
|
|
221
|
+
return
|
|
222
|
+
if total_html is None:
|
|
223
|
+
report.add(label, False, 'html 导出没有报告条数',
|
|
224
|
+
'export html 失败时先单独跑一次看报错')
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
newest_txt = datetime.datetime.fromtimestamp(newest_json).strftime('%Y-%m-%d %H:%M')
|
|
228
|
+
if not newest_html:
|
|
229
|
+
# No timestamp in the page (empty chat); the count comparison still holds.
|
|
230
|
+
report.add(label, total_html > 0, f'json 最新 {newest_txt} / html 共 {total_html} 条')
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
if newest_html[:16] == newest_txt:
|
|
234
|
+
report.add(label, True, f'json 最新 {newest_txt} / html 最新 {newest_html}')
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
# 不一致 —— **仲裁**:再读一次 json。它能看到 html 那条,说明差别只是"消息是新的"。
|
|
238
|
+
with tempfile.TemporaryDirectory(prefix='wf-health-arbiter-') as tmp:
|
|
239
|
+
again = Path(tmp) / 'json'
|
|
240
|
+
run(['export', talker, 'json', '--limit', '5',
|
|
241
|
+
'--output', str(again), '--non-interactive'])
|
|
242
|
+
arbiter = read_newest_json(again)
|
|
243
|
+
if arbiter is not None and arbiter >= newest_json:
|
|
244
|
+
arbiter_txt = datetime.datetime.fromtimestamp(arbiter).strftime('%Y-%m-%d %H:%M')
|
|
245
|
+
if arbiter_txt == newest_html[:16]:
|
|
246
|
+
report.add(label, True,
|
|
247
|
+
f'json 最新 {newest_txt} → 复查 {arbiter_txt} / html 最新 {newest_html}',
|
|
248
|
+
'差别来自检查期间来了新消息(这一项防的是"只剩一个分片被读了")')
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
# 仲裁没说通(复查也没读到 html 那条,或者根本没读出来)⇒ 这是真的不一致
|
|
252
|
+
if arbiter is None:
|
|
253
|
+
reason = '复查 json 也没读出来'
|
|
254
|
+
else:
|
|
255
|
+
reason = '复查 json 仍是 %s,没有更近的消息' % \
|
|
256
|
+
datetime.datetime.fromtimestamp(arbiter).strftime('%Y-%m-%d %H:%M')
|
|
257
|
+
report.add(label, False, f'json 最新 {newest_txt} / html 最新 {newest_html}',
|
|
258
|
+
'两条路径读到不同的最新消息,通常又只剩一个分片被读了(%s)' % reason)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def html_newest_stamp(out_dir):
|
|
262
|
+
"""Newest 'YYYY-MM-DD HH:MM:SS' stamp rendered into the exported pages."""
|
|
263
|
+
import re
|
|
264
|
+
newest = ''
|
|
265
|
+
try:
|
|
266
|
+
for page in sorted(Path(out_dir).glob('*.html')):
|
|
267
|
+
text = page.read_text(encoding='utf-8', errors='replace')
|
|
268
|
+
stamps = re.findall(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', text)
|
|
269
|
+
if stamps and stamps[-1] > newest:
|
|
270
|
+
newest = stamps[-1]
|
|
271
|
+
except OSError:
|
|
272
|
+
pass
|
|
273
|
+
return newest
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def check_session_freshness(report):
|
|
277
|
+
"""A read path that silently stops at an old shard shows up here first.
|
|
278
|
+
|
|
279
|
+
⚠️ **但"最旧"与"只是没聊天"是同一个观测**:这一项看的是会话列表里最新那条消息有多旧,
|
|
280
|
+
而一个人一周没发消息,读数与"读取停在某个旧分片"**完全一样**。所以提示里必须把两种解释
|
|
281
|
+
都说出来,并且点出判别器在隔壁:`读取路径一致` 绿着 ⇒ 多半只是这段时间没聊天;
|
|
282
|
+
两项一起红才更像读取断了。**别在这里替人下结论。**
|
|
283
|
+
"""
|
|
284
|
+
_, data = run_json(['sessions', '--limit', '5'])
|
|
285
|
+
sessions = ((data or {}).get('sessions') or [])
|
|
286
|
+
stamps = [s.get('sortTimestamp') or 0 for s in sessions]
|
|
287
|
+
if not stamps or not max(stamps):
|
|
288
|
+
report.add('会话新鲜度', False, '会话列表没有时间戳', 'init 可能未完成')
|
|
289
|
+
return
|
|
290
|
+
newest = max(stamps)
|
|
291
|
+
age = (time.time() - newest) / 86400
|
|
292
|
+
when = datetime.datetime.fromtimestamp(newest).strftime('%Y-%m-%d %H:%M')
|
|
293
|
+
report.add('会话新鲜度', age <= STALE_SESSION_DAYS,
|
|
294
|
+
f'最新会话 {when}({age:.1f} 天前)',
|
|
295
|
+
f'超过 {STALE_SESSION_DAYS} 天没有新消息。两种解释:**这段时间没聊天**(最可能)'
|
|
296
|
+
f'或**读取只覆盖了部分分片** —— 看「读取路径一致」那一项:它绿着就多半是前者,'
|
|
297
|
+
f'两项一起红才更像读取断了')
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_export_formats(report, talker):
|
|
301
|
+
"""json / txt / excel must each produce a file. html is covered above."""
|
|
302
|
+
with tempfile.TemporaryDirectory(prefix='wf-health-') as tmp:
|
|
303
|
+
for fmt, pattern in (('json', '*.json'), ('txt', '*.txt'), ('excel', '*.xlsx')):
|
|
304
|
+
out = Path(tmp) / fmt
|
|
305
|
+
code, stdout, stderr = run(['export', talker, fmt, '--limit', '3',
|
|
306
|
+
'--output', str(out), '--non-interactive'])
|
|
307
|
+
produced = list(out.glob(pattern)) if out.exists() else []
|
|
308
|
+
size = produced[0].stat().st_size if produced else 0
|
|
309
|
+
detail = f'{size} 字节' if produced else (stderr.strip().split('\n')[-1][:60] or f'exit {code}')
|
|
310
|
+
report.add(f'导出格式 {fmt}', code == 0 and size > 0, detail)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def check_python_deps(report):
|
|
314
|
+
"""The NT reader is a Python subprocess; a missing module looks like a DB error."""
|
|
315
|
+
required = {
|
|
316
|
+
'sqlcipher3': 'sqlcipher3',
|
|
317
|
+
'html2text': 'html2text',
|
|
318
|
+
'zstandard': 'zstandard',
|
|
319
|
+
'cryptography': 'cryptography',
|
|
320
|
+
}
|
|
321
|
+
missing = []
|
|
322
|
+
for module, package in required.items():
|
|
323
|
+
try:
|
|
324
|
+
__import__(module)
|
|
325
|
+
except ImportError:
|
|
326
|
+
missing.append(package)
|
|
327
|
+
report.add('Python 依赖', not missing,
|
|
328
|
+
'齐全' if not missing else f'缺 {", ".join(missing)}',
|
|
329
|
+
'pip install -r requirements.txt')
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def check_scheduled_tasks(report):
|
|
333
|
+
"""The watcher tasks must exist and their last run must have succeeded.
|
|
334
|
+
|
|
335
|
+
**"读不出来"必须与"上次成功"分开**:这一项的判据是从 `schtasks` 的文本输出里找
|
|
336
|
+
`Last Result` / `上次运行结果` 那一行 —— 系统语言或输出格式一变,那一行就找不到了,
|
|
337
|
+
而**两个清单都会是空的**,于是它落到 `else`、报"均在位且上次成功"。
|
|
338
|
+
那正是把"没验证"说成"通过"(同一类:`docs/DECISIONS.md` D-107 里那条"取不到就说未知")。
|
|
339
|
+
"""
|
|
340
|
+
if os.name != 'nt':
|
|
341
|
+
report.add('计划任务', True, '非 Windows,跳过')
|
|
342
|
+
return
|
|
343
|
+
if not shutil.which('schtasks'):
|
|
344
|
+
report.add('计划任务', True, '找不到 schtasks,跳过')
|
|
345
|
+
return
|
|
346
|
+
names = ['WeFlow Issue Watch', 'WeFlow Mail Watch']
|
|
347
|
+
broken, absent, unreadable, not_yet_run = [], [], [], []
|
|
348
|
+
for name in names:
|
|
349
|
+
try:
|
|
350
|
+
proc = subprocess.run(['schtasks', '/query', '/tn', name, '/fo', 'LIST', '/v'],
|
|
351
|
+
capture_output=True, timeout=60, encoding='utf-8', errors='replace')
|
|
352
|
+
except (OSError, subprocess.SubprocessError):
|
|
353
|
+
absent.append(name)
|
|
354
|
+
continue
|
|
355
|
+
if proc.returncode != 0:
|
|
356
|
+
absent.append(name)
|
|
357
|
+
continue
|
|
358
|
+
text = proc.stdout or ''
|
|
359
|
+
result = None
|
|
360
|
+
for line in text.split('\n'):
|
|
361
|
+
if 'Last Result' in line or '上次运行结果' in line:
|
|
362
|
+
result = line.split(':', 1)[-1].strip()
|
|
363
|
+
break
|
|
364
|
+
if result is None:
|
|
365
|
+
# 输出里**没有那个字段**:要么语言/格式变了,要么它没在汇报这一项。
|
|
366
|
+
# 这时该说的是"读不出来",不是"上次成功"。
|
|
367
|
+
unreadable.append(name)
|
|
368
|
+
elif result in ('267011', '0x41303'):
|
|
369
|
+
# `SCHED_S_TASK_HAS_NOT_RUN`(267011 / 0x41303)—— **不是失败**:注册之后还没到
|
|
370
|
+
# 第一次触发。原来它落进下面那支"上次运行失败",提示还写着"脚本可能抛异常了",把刚
|
|
371
|
+
# 注册的人引去查一个不存在的异常(2026-10-11 本机注册这两个任务时实测到)。
|
|
372
|
+
# 但也**不算通过**:这一项要的是"跑过一次且成功",而任务一直"没跑过"正是它要防的事
|
|
373
|
+
# (触发条件写坏,或设成了"仅在用户登录时运行"而没人登录)。
|
|
374
|
+
not_yet_run.append(name)
|
|
375
|
+
elif result not in ('0', '0x0'):
|
|
376
|
+
broken.append(f'{name}={result}')
|
|
377
|
+
if broken:
|
|
378
|
+
report.add('计划任务', False, f'上次运行失败: {", ".join(broken)}',
|
|
379
|
+
'脚本可能抛异常了,手动跑一次看输出')
|
|
380
|
+
elif absent:
|
|
381
|
+
report.add('计划任务', False, f'不存在: {", ".join(absent)}',
|
|
382
|
+
'提醒不会触发;见 docs/HEALTH-CHECK.md 的注册命令')
|
|
383
|
+
elif unreadable:
|
|
384
|
+
# **不算通过**:这一项没被验证过(不是"上次成功")
|
|
385
|
+
report.add('计划任务', False, f'读不出上次运行结果: {", ".join(unreadable)}',
|
|
386
|
+
'schtasks 的输出格式或系统语言变了 —— 这一项**没有验证**,不代表成功;'
|
|
387
|
+
'手动跑 `schtasks /query /tn "WeFlow Issue Watch" /fo LIST /v` 看一眼')
|
|
388
|
+
elif not_yet_run:
|
|
389
|
+
report.add('计划任务', False, f'还没跑过: {", ".join(not_yet_run)}',
|
|
390
|
+
'不算通过 —— 这一项要的是"跑过一次且成功"。刚注册的话等一次触发(30 分钟);'
|
|
391
|
+
'一直如此就查触发条件,以及任务是不是设成了"仅在用户登录时运行"')
|
|
392
|
+
else:
|
|
393
|
+
report.add('计划任务', True, '均在位且上次成功')
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def check_wechat_client(report):
|
|
397
|
+
"""**微信客户端版本**,以及磁盘上有没有"不能用的版本目录"。
|
|
398
|
+
|
|
399
|
+
判据在 CLI 那一侧(`src/services/wechatClient.ts`):版本来自**活进程命令行的
|
|
400
|
+
`--user-lib-dir`** —— 不是 exe 里的版本字面量(实测那只是兜底),也不是"磁盘上有没有
|
|
401
|
+
这个版本目录"(本机就有一个只剩 1 个文件的残留目录,指向它的加载会失败)。
|
|
402
|
+
|
|
403
|
+
取不到就写"未知",**不写"没问题"**:与仓库里那条"没找到必须报 UNKNOWN,别报 CLEAR"同一条纪律。
|
|
404
|
+
字段整个不在 = CLI 是该重建的旧版本 ⇒ **不算通过**(这正是"打开成功≠能用"那一类:
|
|
405
|
+
少了一项却一片绿)。
|
|
406
|
+
"""
|
|
407
|
+
_, data = run_json(['check'])
|
|
408
|
+
if not data:
|
|
409
|
+
report.add('微信客户端', False, 'check 未返回 JSON', '先跑 weflow-cli check 看原始输出')
|
|
410
|
+
return
|
|
411
|
+
client = data.get('wechatClient')
|
|
412
|
+
if not isinstance(client, dict):
|
|
413
|
+
report.add('微信客户端', False, 'check 没报这一项',
|
|
414
|
+
'CLI 可能没重新构建(npm run build),或 dist 是旧的')
|
|
415
|
+
return
|
|
416
|
+
version = client.get('version')
|
|
417
|
+
loaded = client.get('loadedDirs') or []
|
|
418
|
+
if version:
|
|
419
|
+
detail = str(version) + (f'({loaded[0]})' if loaded else '')
|
|
420
|
+
else:
|
|
421
|
+
detail = '未知 —— ' + str(client.get('note') or '')
|
|
422
|
+
dead = client.get('unusableVersionDirs') or []
|
|
423
|
+
if dead:
|
|
424
|
+
shown = '、'.join(f'{d.get("dir")}({d.get("files")} 个文件)' for d in dead)
|
|
425
|
+
detail += f' ⚠ 残留版本目录(没有 Weixin.dll,加载会失败): {shown}'
|
|
426
|
+
# 客户端自报的数据目录 vs 配置推出来的根:不一致 = 两边可能不是同一份库(CLI 那边已按真身比过,
|
|
427
|
+
# 本机数据目录有 D 盘软链,所以这里只报结论)
|
|
428
|
+
if client.get('dataDirMatchesConfig') is False:
|
|
429
|
+
detail += ' ⚠ 客户端自报的数据目录与配置里那个数据库推出来的根**不是同一份**'
|
|
430
|
+
crashes = client.get('crashRecords') or {}
|
|
431
|
+
count = int(crashes.get('reports') or 0) + int(crashes.get('records') or 0)
|
|
432
|
+
if count:
|
|
433
|
+
newest = str(crashes.get('newest') or '时间读不到')[:16]
|
|
434
|
+
detail += f' · 客户端崩溃记录 {count} 份(最近 {newest})—— 崩过时数据读不出来先看这条'
|
|
435
|
+
# `update.data` 在场 ⇒ 上面那个版本**未必**是加载的那个:这句必须跟着版本一起出现
|
|
436
|
+
for caveat in client.get('caveats') or []:
|
|
437
|
+
detail += f' ⚠ {caveat}'
|
|
438
|
+
report.add('微信客户端', True, detail)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def main():
|
|
442
|
+
parser = argparse.ArgumentParser(description='weflow-cli periodic health check')
|
|
443
|
+
parser.add_argument('--json', action='store_true', help='机器可读输出')
|
|
444
|
+
args = parser.parse_args()
|
|
445
|
+
|
|
446
|
+
if not CLI.exists():
|
|
447
|
+
print(f'找不到 {CLI}', file=sys.stderr)
|
|
448
|
+
return 2
|
|
449
|
+
|
|
450
|
+
report = Report()
|
|
451
|
+
check_version(report)
|
|
452
|
+
check_environment(report)
|
|
453
|
+
check_wechat_client(report)
|
|
454
|
+
check_python_deps(report)
|
|
455
|
+
check_session_freshness(report)
|
|
456
|
+
|
|
457
|
+
talkers = pick_talkers(report)
|
|
458
|
+
if talkers:
|
|
459
|
+
check_read_paths(report, talkers[0])
|
|
460
|
+
check_export_formats(report, talkers[0])
|
|
461
|
+
|
|
462
|
+
check_scheduled_tasks(report)
|
|
463
|
+
|
|
464
|
+
if args.json:
|
|
465
|
+
print(json.dumps({'success': not report.failed, 'checks': report.rows},
|
|
466
|
+
ensure_ascii=False, indent=2))
|
|
467
|
+
else:
|
|
468
|
+
print(report.render())
|
|
469
|
+
return 1 if report.failed else 0
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
if __name__ == '__main__':
|
|
473
|
+
sys.exit(main())
|