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
|
@@ -1,139 +1,139 @@
|
|
|
1
|
-
"""批量转写语音消息,产出一份可检索的清单(`output/.voice_index/voices.jsonl`)。
|
|
2
|
-
|
|
3
|
-
为什么要有它:`wechat_voice.py` 只能一条会话一条会话地转,转完就躺在一个按**内容哈希**
|
|
4
|
-
命名的缓存里——**知道"这段音频说了什么",但不知道"是哪条消息、谁在什么时候发的"**。
|
|
5
|
-
而检索需要的正是后者。
|
|
6
|
-
|
|
7
|
-
所以这里做三件事:
|
|
8
|
-
1. 从 `media_*.db` 的 `VoiceInfo` 按时间取语音(带上会话、发送者、时间);
|
|
9
|
-
2. 调 `wechat_voice.ensure_transcripts` 填空缺(断点续传:已转过的按内容命中缓存);
|
|
10
|
-
3. 把 `(消息, 时间, 会话, 文本)` 落成 JSONL,供 `phrase-build` 并进 FTS。
|
|
11
|
-
|
|
12
|
-
用法:
|
|
13
|
-
python scripts/voice_transcribe.py --days 30
|
|
14
|
-
python scripts/voice_transcribe.py --days 7 --limit 5 # 先试几条
|
|
15
|
-
python scripts/voice_transcribe.py --days 30 --model tiny # 更快、更糙
|
|
16
|
-
"""
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
import argparse
|
|
20
|
-
import json
|
|
21
|
-
import pathlib
|
|
22
|
-
import sys
|
|
23
|
-
import time
|
|
24
|
-
from datetime import datetime, timezone, timedelta
|
|
25
|
-
|
|
26
|
-
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
27
|
-
sys.path.insert(0, str(SCRIPT_DIR))
|
|
28
|
-
|
|
29
|
-
from _utils import load_config, decrypt_lock # noqa: E402
|
|
30
|
-
import wechat_voice as wv # noqa: E402
|
|
31
|
-
|
|
32
|
-
ROOT = SCRIPT_DIR.parent
|
|
33
|
-
OUT_DIR = ROOT / 'output' / '.voice_index'
|
|
34
|
-
CACHE_DIR = ROOT / 'output' / '.voice_cache'
|
|
35
|
-
TZ = timezone(timedelta(hours=8))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _keys(cfg):
|
|
39
|
-
"""配置里的密钥是 `lock:` 密文,必须先解密 —— 直接拿密文去派生只会得到"不是数据库"。"""
|
|
40
|
-
key = decrypt_lock(cfg.get('ntKey', '') or '')
|
|
41
|
-
salt = decrypt_lock(cfg.get('ntSalt', '') or '')
|
|
42
|
-
pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
|
|
43
|
-
return key, salt, pw
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def collect(cfg, days, limit=0):
|
|
47
|
-
"""[(shard, chat_name_id, local_id, create_time, blob, name)],按时间倒序。"""
|
|
48
|
-
key, salt, pw = _keys(cfg)
|
|
49
|
-
db = cfg.get('ntDbPath') or ''
|
|
50
|
-
if not db:
|
|
51
|
-
raise SystemExit('配置里没有 ntDbPath')
|
|
52
|
-
since = int(time.time()) - days * 86400
|
|
53
|
-
rows = []
|
|
54
|
-
for shard in wv.voice_shards(db):
|
|
55
|
-
conn = None
|
|
56
|
-
try:
|
|
57
|
-
conn, cur = wv._open(shard, key, salt, pw)
|
|
58
|
-
cur.execute('SELECT rowid, user_name FROM Name2Id')
|
|
59
|
-
names = {r[0]: r[1] for r in cur.fetchall()}
|
|
60
|
-
cur.execute(
|
|
61
|
-
'SELECT chat_name_id, local_id, create_time, voice_data FROM VoiceInfo '
|
|
62
|
-
'WHERE voice_data IS NOT NULL AND create_time >= ? '
|
|
63
|
-
'ORDER BY create_time DESC', (since,))
|
|
64
|
-
for cid, lid, ts, blob in cur.fetchall():
|
|
65
|
-
rows.append((str(shard), cid, lid, int(ts or 0), blob,
|
|
66
|
-
names.get(cid, str(cid))))
|
|
67
|
-
except Exception as exc:
|
|
68
|
-
print(f'[WARN] {pathlib.Path(shard).name} 读不了({type(exc).__name__}: {exc})',
|
|
69
|
-
file=sys.stderr)
|
|
70
|
-
finally:
|
|
71
|
-
if conn:
|
|
72
|
-
conn.close()
|
|
73
|
-
rows.sort(key=lambda r: r[3], reverse=True)
|
|
74
|
-
if limit and limit > 0:
|
|
75
|
-
rows = rows[:limit]
|
|
76
|
-
|
|
77
|
-
# 会话名从 **contact.db** 补一层:消息库的 `Name2Id` 只有 id,语音清单里
|
|
78
|
-
# 只写 id 的话,搜索结果显示成 `wxid_xxx`(2026-10-22 第一版就是这样)。
|
|
79
|
-
try:
|
|
80
|
-
from network import load_graph
|
|
81
|
-
people, _rooms, _members = load_graph(cfg)
|
|
82
|
-
by_username = {p['username']: (p['remark'] or p['nick'] or p['username'])
|
|
83
|
-
for p in people.values()}
|
|
84
|
-
rows = [(s, c, l, t, b, by_username.get(name, name)) for s, c, l, t, b, name in rows]
|
|
85
|
-
except Exception as exc:
|
|
86
|
-
print(f'[WARN] 读联系人失败({type(exc).__name__}: {exc}),语音清单里显示 id',
|
|
87
|
-
file=sys.stderr)
|
|
88
|
-
return rows
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def main():
|
|
92
|
-
ap = argparse.ArgumentParser(description='批量转写微信语音,产出可检索清单')
|
|
93
|
-
ap.add_argument('--days', type=int, default=30, help='只转最近 N 天(默认 30)')
|
|
94
|
-
ap.add_argument('--limit', type=int, default=0, help='最多转写几条(0 = 不限)')
|
|
95
|
-
ap.add_argument('--model', default='small', help='faster-whisper 模型名或本地目录')
|
|
96
|
-
ap.add_argument('--language', default='zh', help='语言提示')
|
|
97
|
-
ap.add_argument('--cache-dir', default='', help='转写缓存目录(换模型时换一个,否则会命中旧模型的结果)')
|
|
98
|
-
args = ap.parse_args()
|
|
99
|
-
|
|
100
|
-
cfg = load_config()
|
|
101
|
-
rows = collect(cfg, args.days, args.limit)
|
|
102
|
-
print(f'最近 {args.days} 天语音: {len(rows)} 条')
|
|
103
|
-
if not rows:
|
|
104
|
-
return 0
|
|
105
|
-
cache_dir = args.cache_dir or str(CACHE_DIR)
|
|
106
|
-
pathlib.Path(cache_dir).mkdir(parents=True, exist_ok=True)
|
|
107
|
-
|
|
108
|
-
voices = {(r[5], r[2]): r[4] for r in rows}
|
|
109
|
-
started = time.time()
|
|
110
|
-
processed, cached, failed = wv.ensure_transcripts(
|
|
111
|
-
voices, cache_dir, model_name=args.model, language=args.language)
|
|
112
|
-
elapsed = time.time() - started
|
|
113
|
-
print(f'转写完成: 新增 {processed},缓存命中 {cached},失败 {failed},耗时 {elapsed/60:.1f} 分钟')
|
|
114
|
-
|
|
115
|
-
cache = wv.TranscriptCache(cache_dir)
|
|
116
|
-
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
117
|
-
out = OUT_DIR / 'voices.jsonl'
|
|
118
|
-
written = empty = 0
|
|
119
|
-
with out.open('w', encoding='utf-8') as fh:
|
|
120
|
-
for shard, cid, lid, ts, blob, name in rows:
|
|
121
|
-
text = (cache.get(wv.voice_key(blob)) or '').strip()
|
|
122
|
-
if not text:
|
|
123
|
-
empty += 1
|
|
124
|
-
continue
|
|
125
|
-
dt = datetime.fromtimestamp(ts, tz=TZ)
|
|
126
|
-
fh.write(json.dumps({
|
|
127
|
-
'id': f'voice:{name}:{lid}',
|
|
128
|
-
'talker': name,
|
|
129
|
-
'time': dt.strftime('%Y-%m-%d %H:%M'),
|
|
130
|
-
'ts': ts,
|
|
131
|
-
'text': text[:500],
|
|
132
|
-
}, ensure_ascii=False) + '\n')
|
|
133
|
-
written += 1
|
|
134
|
-
print(f'写入 {out}:{written} 条有文字(空转写/解码失败 {empty} 条)')
|
|
135
|
-
return 0
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if __name__ == '__main__':
|
|
139
|
-
sys.exit(main())
|
|
1
|
+
"""批量转写语音消息,产出一份可检索的清单(`output/.voice_index/voices.jsonl`)。
|
|
2
|
+
|
|
3
|
+
为什么要有它:`wechat_voice.py` 只能一条会话一条会话地转,转完就躺在一个按**内容哈希**
|
|
4
|
+
命名的缓存里——**知道"这段音频说了什么",但不知道"是哪条消息、谁在什么时候发的"**。
|
|
5
|
+
而检索需要的正是后者。
|
|
6
|
+
|
|
7
|
+
所以这里做三件事:
|
|
8
|
+
1. 从 `media_*.db` 的 `VoiceInfo` 按时间取语音(带上会话、发送者、时间);
|
|
9
|
+
2. 调 `wechat_voice.ensure_transcripts` 填空缺(断点续传:已转过的按内容命中缓存);
|
|
10
|
+
3. 把 `(消息, 时间, 会话, 文本)` 落成 JSONL,供 `phrase-build` 并进 FTS。
|
|
11
|
+
|
|
12
|
+
用法:
|
|
13
|
+
python scripts/voice_transcribe.py --days 30
|
|
14
|
+
python scripts/voice_transcribe.py --days 7 --limit 5 # 先试几条
|
|
15
|
+
python scripts/voice_transcribe.py --days 30 --model tiny # 更快、更糙
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import pathlib
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from datetime import datetime, timezone, timedelta
|
|
25
|
+
|
|
26
|
+
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
27
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
28
|
+
|
|
29
|
+
from _utils import load_config, decrypt_lock # noqa: E402
|
|
30
|
+
import wechat_voice as wv # noqa: E402
|
|
31
|
+
|
|
32
|
+
ROOT = SCRIPT_DIR.parent
|
|
33
|
+
OUT_DIR = ROOT / 'output' / '.voice_index'
|
|
34
|
+
CACHE_DIR = ROOT / 'output' / '.voice_cache'
|
|
35
|
+
TZ = timezone(timedelta(hours=8))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _keys(cfg):
|
|
39
|
+
"""配置里的密钥是 `lock:` 密文,必须先解密 —— 直接拿密文去派生只会得到"不是数据库"。"""
|
|
40
|
+
key = decrypt_lock(cfg.get('ntKey', '') or '')
|
|
41
|
+
salt = decrypt_lock(cfg.get('ntSalt', '') or '')
|
|
42
|
+
pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
|
|
43
|
+
return key, salt, pw
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def collect(cfg, days, limit=0):
|
|
47
|
+
"""[(shard, chat_name_id, local_id, create_time, blob, name)],按时间倒序。"""
|
|
48
|
+
key, salt, pw = _keys(cfg)
|
|
49
|
+
db = cfg.get('ntDbPath') or ''
|
|
50
|
+
if not db:
|
|
51
|
+
raise SystemExit('配置里没有 ntDbPath')
|
|
52
|
+
since = int(time.time()) - days * 86400
|
|
53
|
+
rows = []
|
|
54
|
+
for shard in wv.voice_shards(db):
|
|
55
|
+
conn = None
|
|
56
|
+
try:
|
|
57
|
+
conn, cur = wv._open(shard, key, salt, pw)
|
|
58
|
+
cur.execute('SELECT rowid, user_name FROM Name2Id')
|
|
59
|
+
names = {r[0]: r[1] for r in cur.fetchall()}
|
|
60
|
+
cur.execute(
|
|
61
|
+
'SELECT chat_name_id, local_id, create_time, voice_data FROM VoiceInfo '
|
|
62
|
+
'WHERE voice_data IS NOT NULL AND create_time >= ? '
|
|
63
|
+
'ORDER BY create_time DESC', (since,))
|
|
64
|
+
for cid, lid, ts, blob in cur.fetchall():
|
|
65
|
+
rows.append((str(shard), cid, lid, int(ts or 0), blob,
|
|
66
|
+
names.get(cid, str(cid))))
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
print(f'[WARN] {pathlib.Path(shard).name} 读不了({type(exc).__name__}: {exc})',
|
|
69
|
+
file=sys.stderr)
|
|
70
|
+
finally:
|
|
71
|
+
if conn:
|
|
72
|
+
conn.close()
|
|
73
|
+
rows.sort(key=lambda r: r[3], reverse=True)
|
|
74
|
+
if limit and limit > 0:
|
|
75
|
+
rows = rows[:limit]
|
|
76
|
+
|
|
77
|
+
# 会话名从 **contact.db** 补一层:消息库的 `Name2Id` 只有 id,语音清单里
|
|
78
|
+
# 只写 id 的话,搜索结果显示成 `wxid_xxx`(2026-10-22 第一版就是这样)。
|
|
79
|
+
try:
|
|
80
|
+
from network import load_graph
|
|
81
|
+
people, _rooms, _members = load_graph(cfg)
|
|
82
|
+
by_username = {p['username']: (p['remark'] or p['nick'] or p['username'])
|
|
83
|
+
for p in people.values()}
|
|
84
|
+
rows = [(s, c, l, t, b, by_username.get(name, name)) for s, c, l, t, b, name in rows]
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
print(f'[WARN] 读联系人失败({type(exc).__name__}: {exc}),语音清单里显示 id',
|
|
87
|
+
file=sys.stderr)
|
|
88
|
+
return rows
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
ap = argparse.ArgumentParser(description='批量转写微信语音,产出可检索清单')
|
|
93
|
+
ap.add_argument('--days', type=int, default=30, help='只转最近 N 天(默认 30)')
|
|
94
|
+
ap.add_argument('--limit', type=int, default=0, help='最多转写几条(0 = 不限)')
|
|
95
|
+
ap.add_argument('--model', default='small', help='faster-whisper 模型名或本地目录')
|
|
96
|
+
ap.add_argument('--language', default='zh', help='语言提示')
|
|
97
|
+
ap.add_argument('--cache-dir', default='', help='转写缓存目录(换模型时换一个,否则会命中旧模型的结果)')
|
|
98
|
+
args = ap.parse_args()
|
|
99
|
+
|
|
100
|
+
cfg = load_config()
|
|
101
|
+
rows = collect(cfg, args.days, args.limit)
|
|
102
|
+
print(f'最近 {args.days} 天语音: {len(rows)} 条')
|
|
103
|
+
if not rows:
|
|
104
|
+
return 0
|
|
105
|
+
cache_dir = args.cache_dir or str(CACHE_DIR)
|
|
106
|
+
pathlib.Path(cache_dir).mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
voices = {(r[5], r[2]): r[4] for r in rows}
|
|
109
|
+
started = time.time()
|
|
110
|
+
processed, cached, failed = wv.ensure_transcripts(
|
|
111
|
+
voices, cache_dir, model_name=args.model, language=args.language)
|
|
112
|
+
elapsed = time.time() - started
|
|
113
|
+
print(f'转写完成: 新增 {processed},缓存命中 {cached},失败 {failed},耗时 {elapsed/60:.1f} 分钟')
|
|
114
|
+
|
|
115
|
+
cache = wv.TranscriptCache(cache_dir)
|
|
116
|
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
out = OUT_DIR / 'voices.jsonl'
|
|
118
|
+
written = empty = 0
|
|
119
|
+
with out.open('w', encoding='utf-8') as fh:
|
|
120
|
+
for shard, cid, lid, ts, blob, name in rows:
|
|
121
|
+
text = (cache.get(wv.voice_key(blob)) or '').strip()
|
|
122
|
+
if not text:
|
|
123
|
+
empty += 1
|
|
124
|
+
continue
|
|
125
|
+
dt = datetime.fromtimestamp(ts, tz=TZ)
|
|
126
|
+
fh.write(json.dumps({
|
|
127
|
+
'id': f'voice:{name}:{lid}',
|
|
128
|
+
'talker': name,
|
|
129
|
+
'time': dt.strftime('%Y-%m-%d %H:%M'),
|
|
130
|
+
'ts': ts,
|
|
131
|
+
'text': text[:500],
|
|
132
|
+
}, ensure_ascii=False) + '\n')
|
|
133
|
+
written += 1
|
|
134
|
+
print(f'写入 {out}:{written} 条有文字(空转写/解码失败 {empty} 条)')
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == '__main__':
|
|
139
|
+
sys.exit(main())
|