weflow-cli 1.5.0

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.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,2311 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander'
3
+ import chalk from 'chalk'
4
+ import inquirer from 'inquirer'
5
+ import { join } from 'path'
6
+ import { existsSync, readFileSync } from 'fs'
7
+ import { homedir } from 'os'
8
+ import { dbPathService } from '../src/core/dbPathService.js'
9
+ import { keyService } from '../src/core/keyService.js'
10
+ import { NtCore } from '../src/core/ntCore.js'
11
+ import { configService } from '../src/services/configService.js'
12
+ import { chatService } from '../src/services/chatService.js'
13
+ import { exportService } from '../src/services/exportService.js'
14
+ import { resolveTalker as resolveTalkerCore } from '../src/utils/talkerUtils.js'
15
+ import { WechatMessageService } from '../src/services/wechatMessageService.js'
16
+ import { whitelistService } from '../src/services/whitelistService.js'
17
+ import type { ChatSession } from '../src/types.js'
18
+
19
+ const program = new Command()
20
+
21
+ /**
22
+ * 尝试从 WeFlow 桌面版配置中读取已保存的密钥
23
+ */
24
+ function tryReadWeFlowKey(): string | null {
25
+ const candidates = [
26
+ join(homedir(), 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json'),
27
+ join(homedir(), 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'),
28
+ join(homedir(), '.weflow', 'config.json'),
29
+ ]
30
+
31
+ for (const configPath of candidates) {
32
+ try {
33
+ if (!existsSync(configPath)) continue
34
+ const raw = readFileSync(configPath, 'utf8')
35
+ const config = JSON.parse(raw)
36
+
37
+ // WeFlow 使用 electron-store,decryptKey 可能是明文或加密的
38
+ let key = config.decryptKey || ''
39
+ // 如果是 lock: 前缀,说明是加密的,无法直接使用
40
+ if (typeof key === 'string' && key.startsWith('lock:')) {
41
+ continue
42
+ }
43
+ // 如果是 safe: 前缀,说明是 safeStorage 加密的,也无法直接使用
44
+ if (typeof key === 'string' && key.startsWith('safe:')) {
45
+ continue
46
+ }
47
+ // 明文 key
48
+ if (typeof key === 'string' && key.length === 64) {
49
+ return key
50
+ }
51
+
52
+ // 检查 wxidConfigs 中的 key
53
+ const wxidConfigs = config.wxidConfigs || {}
54
+ for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
55
+ const cfgKey = cfg.decryptKey || ''
56
+ if (typeof cfgKey === 'string' && cfgKey.length === 64 && !cfgKey.startsWith('lock:') && !cfgKey.startsWith('safe:')) {
57
+ return cfgKey
58
+ }
59
+ }
60
+ } catch {
61
+ continue
62
+ }
63
+ }
64
+ return null
65
+ }
66
+
67
+ /**
68
+ * CLI 包装的 talker 解析 (wxid / 昵称 / 备注名 / 序号)。
69
+ * 委托给 src/utils/talkerUtils 的核心实现,外加 chalk 友好输出。
70
+ */
71
+ async function resolveTalker(input: string): Promise<string> {
72
+ try {
73
+ const result = await resolveTalkerCore(input)
74
+ // 显示解析结果(已知格式跳过打印)
75
+ if (!input.startsWith('wxid_') && !input.includes('@chatroom') && !input.includes('@openim')) {
76
+ const isNum = /^\[?\d+\]?$/.test(input)
77
+ const sessions = await chatService.listSessions(undefined, 50)
78
+ const match = sessions.find(s => s.username === result)
79
+ if (match) {
80
+ const prefix = isNum ? `序号[${input.replace(/[\[\]]/g, '')}] ` : ''
81
+ console.log(chalk.gray(` → ${prefix}${match.displayName} (${match.username})`))
82
+ }
83
+ }
84
+ return result
85
+ } catch (e: any) {
86
+ console.log(chalk.red(`\n❌ ${e.message}\n`))
87
+ process.exit(1)
88
+ }
89
+ }
90
+
91
+ program
92
+ .name('weflow-cli')
93
+ .description('WeFlow CLI - 微信聊天记录命令行查询与导出工具')
94
+ .version('1.5.0')
95
+
96
+ // ==================== init ====================
97
+ program
98
+ .command('init')
99
+ .description('自动检测微信数据目录并提取解密密钥')
100
+ .action(async () => {
101
+ console.log(chalk.cyan('🔧 WeFlow CLI 初始化\n'))
102
+
103
+ // Step 0: 检测微信版本
104
+ console.log(chalk.yellow('步骤 0/3: 检测微信版本...'))
105
+ const version = await keyService.detectWeChatVersion()
106
+
107
+ if (version) {
108
+ console.log(chalk.green(`✓ 检测到微信 ${version} 版本`))
109
+ } else {
110
+ // 没有运行中的微信,尝试已安装的版本
111
+ console.log(chalk.gray(' 未检测到运行中的微信进程'))
112
+ // 检查已有配置或数据目录
113
+ const existingPath = configService.get('dbPath')
114
+ if (existingPath && existsSync(existingPath)) {
115
+ console.log(chalk.green(` 使用已配置的数据目录: ${existingPath}`))
116
+ }
117
+ }
118
+
119
+ if (version === '3.x') {
120
+ // ====== 3.x 初始化 ======
121
+ console.log(chalk.yellow('\n步骤 1/3: 提取 3.x 数据库密钥...'))
122
+ const result = await keyService.extract3xKey((msg) => {
123
+ console.log(chalk.gray(` ${msg}`))
124
+ })
125
+
126
+ if (!result.success || !result.key) {
127
+ console.log(chalk.red(`\n✗ 密钥提取失败: ${result.error}`))
128
+ console.log(chalk.gray('\n解决方案:'))
129
+ console.log(chalk.gray(' 1. 确保 WeChat 3.x (WeChat.exe) 已登录'))
130
+ console.log(chalk.gray(' 2. 确保 Python + pywxdump 已安装'))
131
+ console.log(chalk.gray(' 3. 或手动指定密钥: weflow-cli config set decryptKey3x <64位密钥>'))
132
+ process.exit(1)
133
+ }
134
+
135
+ const msgDir = result.msgDir || ''
136
+ const wxDir = result.wxDir || ''
137
+ const wxid = result.wxid || ''
138
+
139
+ configService.set('decryptKey3x', result.key)
140
+ configService.set('dataVersion', '3.x')
141
+
142
+ // 查找 MSG0.db
143
+ const msg0Path = join(msgDir, 'Multi', 'MSG0.db')
144
+ if (existsSync(msg0Path)) {
145
+ configService.set('dbPath3x', msg0Path)
146
+ console.log(chalk.green(`✓ 数据库: ${msg0Path}`))
147
+ }
148
+
149
+ if (wxid) configService.set('wxid', wxid)
150
+ if (wxDir) configService.set('dbPath', wxDir)
151
+
152
+ console.log(chalk.green('\n✓ 密钥获取成功!'))
153
+ console.log(chalk.cyan('\n=============================='))
154
+ console.log(chalk.cyan('初始化完成! (3.x 模式)'))
155
+ console.log(chalk.cyan('=============================='))
156
+ console.log(chalk.white(`数据目录: ${wxDir}`))
157
+ console.log(chalk.white(`消息数据库: ${msg0Path}`))
158
+ console.log(chalk.white(`账号: ${wxid}`))
159
+ console.log(chalk.white(`密钥: ${result.key.slice(0, 8)}...${result.key.slice(-8)}`))
160
+
161
+ } else {
162
+ // ====== 4.x 初始化 (原有逻辑) ======
163
+ console.log(chalk.yellow('\n步骤 1/3: 检测微信数据目录...'))
164
+ const detected = await dbPathService.autoDetect()
165
+ if (!detected.success || !detected.path) {
166
+ console.log(chalk.red('✗ 未检测到微信数据目录'))
167
+ console.log(chalk.gray(' 请确保微信 4.x 已安装并登录过'))
168
+ console.log(chalk.gray(' 或手动指定: weflow-cli config set dbPath <path>'))
169
+ process.exit(1)
170
+ }
171
+ console.log(chalk.green(`✓ 检测到数据目录: ${detected.path}`))
172
+ configService.set('dbPath', detected.path)
173
+ configService.set('dataVersion', '4.x')
174
+
175
+ // Step 2: 扫描账号
176
+ console.log(chalk.yellow('\n步骤 2/3: 扫描微信账号...'))
177
+ const wxids = dbPathService.scanWxidCandidates(detected.path)
178
+ if (wxids.length === 0) {
179
+ console.log(chalk.red('✗ 未找到微信账号目录'))
180
+ process.exit(1)
181
+ }
182
+
183
+ const selectedWxid = wxids[0]
184
+ console.log(chalk.green(`✓ 找到 ${wxids.length} 个账号`))
185
+ for (const w of wxids) {
186
+ const marker = w === selectedWxid ? chalk.green(' → ') : ' '
187
+ const nickname = w.nickname ? ` (${w.nickname})` : ''
188
+ console.log(`${marker}${w.wxid}${nickname}`)
189
+ }
190
+ configService.set('wxid', selectedWxid.wxid)
191
+
192
+ // Step 3: 提取密钥
193
+ console.log(chalk.yellow('\n步骤 3/3: 提取数据库解密密钥...'))
194
+
195
+ // 优先尝试从 WeFlow 配置读取
196
+ console.log(chalk.gray(' 尝试从 WeFlow 桌面版配置读取...'))
197
+ let extractedKey = tryReadWeFlowKey()
198
+
199
+ if (extractedKey) {
200
+ console.log(chalk.green(' ✓ 从 WeFlow 配置读取到密钥'))
201
+ } else {
202
+ // 如果微信未运行,提示启动
203
+ if (!version) {
204
+ console.log(chalk.cyan('\n ══════════════════════════════════════'))
205
+ console.log(chalk.cyan(' 请现在启动微信 4.x 并登录!'))
206
+ console.log(chalk.cyan(' ══════════════════════════════════════\n'))
207
+ }
208
+ console.log(chalk.gray(' 等待微信进程出现(启动后会自动 hook)...'))
209
+
210
+ const keyResult = await keyService.waitForKey(120000, (msg) => {
211
+ console.log(chalk.gray(` ${msg}`))
212
+ })
213
+
214
+ if (keyResult.success && keyResult.key) {
215
+ extractedKey = keyResult.key
216
+ } else {
217
+ console.log(chalk.red(`\n✗ 密钥提取失败: ${keyResult.error}`))
218
+ console.log(chalk.gray('\n解决方案:'))
219
+ console.log(chalk.gray(' 1. 确保微信已登录'))
220
+ console.log(chalk.gray(' 2. 尝试在微信中发送一条消息'))
221
+ console.log(chalk.gray(' 3. 或手动指定密钥: weflow-cli config set decryptKey <64位密钥>'))
222
+ process.exit(1)
223
+ }
224
+ }
225
+
226
+ configService.set('decryptKey', extractedKey)
227
+ console.log(chalk.green('\n✓ 密钥获取成功!'))
228
+
229
+ // Step 4: 尝试扫描 NT 格式数据库 (xwechat_files)
230
+ // 即使检测到传统路径,也可能存在 NT 格式数据库
231
+ if (version) {
232
+ console.log(chalk.yellow('\n步骤 4/4: 扫描 NT 格式数据库...'))
233
+ console.log(chalk.gray(' 正在从微信内存中匹配数据库密钥...'))
234
+
235
+ const ntResult = await NtCore.scan()
236
+ if (ntResult.success && ntResult.matched && ntResult.matched.length > 0) {
237
+ console.log(chalk.green(` ✓ 找到 ${ntResult.matched.length} 个 NT 数据库`))
238
+
239
+ // 优先选择 message_0.db (主聊天数据库)
240
+ let primaryDb = ntResult.matched.find((db: any) => db.name === 'message_0.db')
241
+ if (!primaryDb) {
242
+ // 按大小降序排序,选择最大的数据库
243
+ primaryDb = ntResult.matched.sort((a: any, b: any) => b.size - a.size)[0]
244
+ }
245
+
246
+ if (primaryDb) {
247
+ configService.set('ntDbPath', primaryDb.path)
248
+ configService.set('ntKey', primaryDb.key)
249
+ configService.set('ntSalt', primaryDb.salt)
250
+ console.log(chalk.green(` ✓ 主数据库: ${primaryDb.name} (${(primaryDb.size / 1024 / 1024).toFixed(1)}MB)`))
251
+
252
+ // 检查 contact.db 是否已匹配
253
+ const contactDb = ntResult.matched.find((db: any) => db.name === 'contact/contact.db')
254
+ if (contactDb) {
255
+ configService.set('contactDbPath', contactDb.path)
256
+ configService.set('contactKey', contactDb.key)
257
+ configService.set('contactSalt', contactDb.salt)
258
+ console.log(chalk.green(` ✓ 联系人数据库: ${contactDb.name} (${(contactDb.size / 1024 / 1024).toFixed(1)}MB)`))
259
+ }
260
+
261
+ // 显示所有匹配的数据库
262
+ for (const db of ntResult.matched) {
263
+ const marker = db === primaryDb || db === contactDb ? chalk.green(' → ') : ' '
264
+ console.log(chalk.gray(`${marker}${db.name} (${(db.size / 1024 / 1024).toFixed(1)}MB)`))
265
+ }
266
+ }
267
+ } else {
268
+ console.log(chalk.gray(` NT 数据库扫描: ${ntResult.error || '未找到匹配的数据库'}`))
269
+ console.log(chalk.gray(' 提示: 可以稍后运行 weflow-cli init 重新扫描'))
270
+ }
271
+ } else if (detected.path.toLowerCase().includes('xwechat_files')) {
272
+ console.log(chalk.yellow('\n步骤 4/4: NT 格式数据库'))
273
+ console.log(chalk.gray(' 微信未运行,无法从内存中提取 NT 密钥'))
274
+ console.log(chalk.gray(' 请启动微信后运行: weflow-cli init'))
275
+ }
276
+
277
+ console.log(chalk.cyan('\n=============================='))
278
+ console.log(chalk.cyan('初始化完成! (4.x 模式)'))
279
+ console.log(chalk.cyan('=============================='))
280
+ console.log(chalk.white(`数据目录: ${detected.path}`))
281
+ console.log(chalk.white(`账号: ${selectedWxid.wxid}${selectedWxid.nickname ? ` (${selectedWxid.nickname})` : ''}`))
282
+ console.log(chalk.white(`密钥: ${extractedKey.slice(0, 8)}...${extractedKey.slice(-8)}`))
283
+ const ntDbPath = configService.get('ntDbPath')
284
+ if (ntDbPath) {
285
+ console.log(chalk.white(`NT 数据库: ${ntDbPath}`))
286
+ }
287
+ }
288
+
289
+ console.log(chalk.gray('\n现在可以使用以下命令:'))
290
+ console.log(chalk.gray(' weflow-cli sessions 查看会话列表'))
291
+ console.log(chalk.gray(' weflow-cli messages <talker> 查看消息'))
292
+ console.log(chalk.gray(' weflow-cli contacts 查看联系人'))
293
+ console.log(chalk.gray(' weflow-cli export <talker> <format> 导出聊天记录'))
294
+ })
295
+
296
+ // ==================== config ====================
297
+ const configCmd = program
298
+ .command('config')
299
+ .description('查看或修改配置')
300
+
301
+ configCmd
302
+ .command('show')
303
+ .description('显示当前配置')
304
+ .action(() => {
305
+ const config = configService.getAll()
306
+ console.log(chalk.cyan('当前配置:\n'))
307
+ console.log(`数据版本: ${config.dataVersion || chalk.gray('(自动检测)')}`)
308
+ console.log(`4.x 数据目录: ${config.dbPath || chalk.gray('(未设置)')}`)
309
+ console.log(`4.x 密钥: ${config.decryptKey ? '已设置' : chalk.gray('(未设置)')}`)
310
+ console.log(`3.x 数据库: ${config.dbPath3x || chalk.gray('(未设置)')}`)
311
+ console.log(`3.x 密钥: ${config.decryptKey3x ? '已设置' : chalk.gray('(未设置)')}`)
312
+ console.log(`NT 数据库: ${config.ntDbPath || chalk.gray('(未设置)')}`)
313
+ console.log(`NT 密钥: ${config.ntKey ? '已设置' : chalk.gray('(未设置)')}`)
314
+ console.log(`联系人DB: ${config.contactDbPath || chalk.gray('(未设置)')}`)
315
+ console.log(`联系人DB密钥: ${config.contactKey ? '已设置' : chalk.gray('(未设置)')}`)
316
+ console.log(`微信账号: ${config.wxid || chalk.gray('(未设置)')}`)
317
+ })
318
+
319
+ configCmd
320
+ .command('set <key> <value>')
321
+ .description('设置配置项 (dbPath, decryptKey, dbPath3x, decryptKey3x, dataVersion, wxid)')
322
+ .action((key: string, value: string) => {
323
+ const validKeys = ['dbPath', 'decryptKey', 'dbPath3x', 'decryptKey3x', 'dataVersion', 'wxid', 'ntDbPath', 'ntKey', 'ntSalt', 'contactDbPath', 'contactKey', 'contactSalt', 'vaultRepo', 'aiEngine']
324
+ if (!validKeys.includes(key)) {
325
+ console.log(chalk.red(`无效的配置项: ${key}`))
326
+ console.log(chalk.gray(`可用: ${validKeys.join(', ')}`))
327
+ process.exit(1)
328
+ }
329
+ configService.set(key as any, value)
330
+ console.log(chalk.green(`✓ 已设置 ${key}`))
331
+ })
332
+
333
+ configCmd
334
+ .command('clear')
335
+ .description('清除所有配置')
336
+ .action(() => {
337
+ configService.clear()
338
+ console.log(chalk.green('✓ 配置已清除'))
339
+ })
340
+
341
+ // ==================== sessions ====================
342
+ program
343
+ .command('sessions')
344
+ .description('查看会话列表')
345
+ .option('-k, --keyword <keyword>', '搜索关键词')
346
+ .option('-n, --limit <number>', '最大数量', '30')
347
+ .action(async (opts) => {
348
+ if (!configService.isConfigured()) {
349
+ console.log(chalk.red('请先运行 weflow-cli init'))
350
+ process.exit(1)
351
+ }
352
+
353
+ const sessions = await chatService.listSessions(opts.keyword, parseInt(opts.limit))
354
+ if (sessions.length === 0) {
355
+ console.log(chalk.gray('未找到会话'))
356
+ return
357
+ }
358
+
359
+ console.log(chalk.cyan(`会话列表 (${sessions.length} 条):\n`))
360
+ console.log(chalk.gray('序号 会话ID 昵称 最后消息'))
361
+ console.log(chalk.gray('─'.repeat(70)))
362
+
363
+ for (let i = 0; i < sessions.length; i++) {
364
+ const s = sessions[i]
365
+ const num = String(i + 1).padStart(3)
366
+ const id = (s.username || '').padEnd(20)
367
+ const name = (s.displayName || '').slice(0, 12).padEnd(12)
368
+ const summary = (s.summary || '').slice(0, 30)
369
+ console.log(`${num} ${id} ${name} ${summary}`)
370
+ }
371
+ })
372
+
373
+ // ==================== messages ====================
374
+ program
375
+ .command('messages <talker>')
376
+ .description('查看指定会话的消息 (支持 wxid / 昵称 / 备注 / 序号)')
377
+ .option('-n, --limit <number>', '最大数量', '50')
378
+ .option('-o, --offset <number>', '偏移量', '0')
379
+ .option('-s, --start <timestamp>', '开始时间戳')
380
+ .option('-e, --end <timestamp>', '结束时间戳')
381
+ .action(async (talkerInput: string, opts) => {
382
+ if (!configService.isConfigured()) {
383
+ console.log(chalk.red('\n❌ 还没配置'))
384
+ console.log(chalk.gray(' 运行: weflow-cli init\n'))
385
+ process.exit(1)
386
+ }
387
+
388
+ const talker = await resolveTalker(talkerInput)
389
+
390
+ const messages = await chatService.getMessages(
391
+ talker,
392
+ parseInt(opts.limit),
393
+ parseInt(opts.offset)
394
+ )
395
+
396
+ if (messages.length === 0) {
397
+ console.log(chalk.gray('未找到消息'))
398
+ return
399
+ }
400
+
401
+ console.log(chalk.cyan(`消息记录 - ${talker} (${messages.length} 条):\n`))
402
+
403
+ for (const m of messages) {
404
+ const time = new Date(m.createTime * 1000).toLocaleString('zh-CN')
405
+ const senderName = m.isSend ? chalk.green('我') : chalk.blue((m as any).senderDisplay || m.senderUsername || talker)
406
+ const content = (m.parsedContent || m.rawContent || '').replace(/\n/g, ' ').slice(0, 80)
407
+ console.log(chalk.gray(`[${time}]`) + ` ${senderName}: ${content}`)
408
+ }
409
+ })
410
+
411
+ // ==================== contacts ====================
412
+ program
413
+ .command('contacts')
414
+ .description('查看联系人列表')
415
+ .option('-k, --keyword <keyword>', '搜索关键词 (自动扩大搜索范围)')
416
+ .option('-n, --limit <number>', '最大数量', '500')
417
+ .action(async (opts) => {
418
+ if (!configService.isConfigured()) {
419
+ console.log(chalk.red('请先运行 weflow-cli init'))
420
+ process.exit(1)
421
+ }
422
+
423
+ const contacts = await chatService.listContacts(opts.keyword, parseInt(opts.limit))
424
+ if (contacts.length === 0) {
425
+ console.log(chalk.gray('未找到联系人'))
426
+ return
427
+ }
428
+
429
+ console.log(chalk.cyan(`联系人列表 (${contacts.length} 条):\n`))
430
+ console.log(chalk.gray('序号 用户ID 昵称/备注'))
431
+ console.log(chalk.gray('─'.repeat(60)))
432
+
433
+ for (let i = 0; i < contacts.length; i++) {
434
+ const c = contacts[i]
435
+ const num = String(i + 1).padStart(3)
436
+ const id = (c.username || '').padEnd(20)
437
+ const name = c.remark || c.displayName || c.nickname || ''
438
+ console.log(`${num} ${id} ${name}`)
439
+ }
440
+ })
441
+
442
+ // ==================== export ====================
443
+ program
444
+ .command('export <talker> <format>')
445
+ .description('导出聊天记录 (支持 wxid / 昵称 / 备注 / 序号)')
446
+ .option('-o, --output <dir>', '输出目录', './output')
447
+ .option('-n, --limit <number>', '最大数量', '10000')
448
+ .action(async (talkerInput: string, format: string, opts) => {
449
+ if (!configService.isConfigured()) {
450
+ console.log(chalk.red('\n❌ 还没配置'))
451
+ console.log(chalk.gray(' 运行: weflow-cli init\n'))
452
+ process.exit(1)
453
+ }
454
+
455
+ const talker = await resolveTalker(talkerInput)
456
+
457
+ const validFormats = ['json', 'txt', 'html', 'excel']
458
+ if (!validFormats.includes(format)) {
459
+ console.log(chalk.red(`无效格式: ${format}`))
460
+ console.log(chalk.gray(`可用: ${validFormats.join(', ')}`))
461
+ process.exit(1)
462
+ }
463
+
464
+ console.log(chalk.cyan(`正在导出 ${talker} 的聊天记录 (${format})...\n`))
465
+
466
+ let result
467
+ const limit = parseInt(opts.limit)
468
+
469
+ switch (format) {
470
+ case 'json':
471
+ result = await exportService.exportJson(talker, opts.output, limit)
472
+ break
473
+ case 'txt':
474
+ result = await exportService.exportTxt(talker, opts.output, limit)
475
+ break
476
+ case 'html':
477
+ result = await exportService.exportHtml(talker, opts.output, limit)
478
+ break
479
+ case 'excel':
480
+ result = await exportService.exportExcel(talker, opts.output, limit)
481
+ break
482
+ }
483
+
484
+ if (result?.success) {
485
+ console.log(chalk.green(`✓ 导出成功: ${result.path}`))
486
+ } else {
487
+ console.log(chalk.red(`✗ 导出失败: ${result?.error}`))
488
+ process.exit(1)
489
+ }
490
+ })
491
+
492
+ // ==================== dbkey ====================
493
+ program
494
+ .command('dbkey')
495
+ .description('从运行中的微信进程提取数据库解密密钥 (自动检测版本)')
496
+ .option('-t, --timeout <ms>', '超时时间(毫秒)', '60000')
497
+ .action(async (opts) => {
498
+ console.log(chalk.cyan('🔑 提取微信数据库密钥\n'))
499
+ console.log(chalk.gray('请确保微信已登录且正在运行...\n'))
500
+
501
+ const version = await keyService.detectWeChatVersion()
502
+ if (!version) {
503
+ console.log(chalk.red('未检测到微信进程 (WeChat.exe 或 Weixin.exe)'))
504
+ process.exit(1)
505
+ }
506
+ console.log(chalk.gray(`检测到微信 ${version} 版本\n`))
507
+
508
+ if (version === '3.x') {
509
+ const result = await keyService.extract3xKey((msg) => {
510
+ console.log(chalk.gray(` ${msg}`))
511
+ })
512
+ if (result.success && result.key) {
513
+ console.log(chalk.green(`\n✓ 密钥: ${result.key}`))
514
+ console.log(chalk.green(` wxid: ${result.wxid}`))
515
+ console.log(chalk.green(` 消息目录: ${result.msgDir}`))
516
+ } else {
517
+ console.log(chalk.red(`\n✗ 失败: ${result.error}`))
518
+ process.exit(1)
519
+ }
520
+ } else {
521
+ const result = await keyService.autoGetDbKey(parseInt(opts.timeout), (msg) => {
522
+ console.log(chalk.gray(` ${msg}`))
523
+ })
524
+ if (result.success && result.key) {
525
+ console.log(chalk.green(`\n✓ 密钥: ${result.key}`))
526
+ } else {
527
+ console.log(chalk.red(`\n✗ 失败: ${result.error}`))
528
+ process.exit(1)
529
+ }
530
+ }
531
+ })
532
+
533
+ // ==================== scan ====================
534
+ program
535
+ .command('scan')
536
+ .description('扫描微信数据目录中的账号')
537
+ .option('-p, --path <path>', '数据目录路径')
538
+ .action(async (opts) => {
539
+ const path = opts.path || configService.get('dbPath') || dbPathService.getDefaultPath()
540
+ console.log(chalk.cyan(`扫描目录: ${path}\n`))
541
+
542
+ const wxids = dbPathService.scanWxidCandidates(path)
543
+ if (wxids.length === 0) {
544
+ console.log(chalk.gray('未找到账号'))
545
+ return
546
+ }
547
+
548
+ console.log(chalk.cyan(`找到 ${wxids.length} 个账号:\n`))
549
+ for (const w of wxids) {
550
+ const time = new Date(w.modifiedTime).toLocaleString('zh-CN')
551
+ const nickname = w.nickname ? ` (${w.nickname})` : ''
552
+ console.log(` ${w.wxid}${nickname} ${chalk.gray(time)}`)
553
+ }
554
+ })
555
+
556
+ // ==================== login-wechat ====================
557
+ program
558
+ .command('login-wechat')
559
+ .description('微信扫码登录,获取消息收发权限')
560
+ .option('--base-url <url>', 'ilink 服务地址')
561
+ .action(async (opts) => {
562
+ const token = configService.get('wechatOcToken')
563
+ if (token) {
564
+ const { reconfirm } = await inquirer.prompt([{
565
+ type: 'confirm',
566
+ name: 'reconfirm',
567
+ message: '已有登录状态,要重新登录吗?',
568
+ default: false,
569
+ }])
570
+ if (!reconfirm) {
571
+ console.log(chalk.gray('保持当前登录状态'))
572
+ return
573
+ }
574
+ }
575
+
576
+ console.log(chalk.cyan('正在获取登录二维码...\n'))
577
+ const service = new WechatMessageService({ baseUrl: opts.baseUrl })
578
+
579
+ try {
580
+ const { qrcodeContent } = await service.startLogin()
581
+ console.log(chalk.green('请用微信扫描以下二维码:\n'))
582
+
583
+ // Print QR in terminal
584
+ try {
585
+ const QRCode = (await import('qrcode-terminal')).default
586
+ QRCode.generate(qrcodeContent, { small: true })
587
+ } catch {
588
+ console.log(chalk.yellow('(终端二维码显示失败,请安装: npm install qrcode-terminal)'))
589
+ }
590
+
591
+ console.log(chalk.cyan('\n等待扫码 (约2分钟有效)...'))
592
+ const session = await service.waitForLogin()
593
+
594
+ if (session.status === 'confirmed') {
595
+ console.log(chalk.green('\n✓ 登录成功!'))
596
+ console.log(chalk.gray(` account_id: ${session.accountId || '未知'}`))
597
+ } else {
598
+ console.log(chalk.red(`\n✗ 登录失败: ${session.error || '超时'}`))
599
+ }
600
+ } catch (e: any) {
601
+ console.log(chalk.red(`\n✗ 登录失败: ${e.message}`))
602
+ }
603
+ })
604
+
605
+ // ==================== logout-wechat ====================
606
+ program
607
+ .command('logout-wechat')
608
+ .description('退出微信消息通道登录')
609
+ .action(() => {
610
+ configService.set('wechatOcToken', '')
611
+ configService.set('wechatOcAccountId', '')
612
+ configService.set('wechatOcSyncBuf', '')
613
+ console.log(chalk.green('✓ 已退出登录'))
614
+ })
615
+
616
+ // ==================== whitelist ====================
617
+ const whitelistCmd = program
618
+ .command('whitelist')
619
+ .description('白名单管理 (不传参数显示列表)')
620
+
621
+ whitelistCmd
622
+ .action(() => {
623
+ const list = whitelistService.getList()
624
+ if (list.length === 0) {
625
+ console.log(chalk.gray('白名单为空'))
626
+ console.log(chalk.gray('使用 weflow-cli whitelist add <昵称> 添加'))
627
+ return
628
+ }
629
+ console.log(chalk.cyan(`白名单 (${list.length}):\n`))
630
+ for (let i = 0; i < list.length; i++) {
631
+ console.log(` ${String(i + 1).padStart(2)}. ${list[i]}`)
632
+ }
633
+ })
634
+
635
+ whitelistCmd
636
+ .command('add <target>')
637
+ .description('添加白名单 (支持 wxid/昵称/备注名/序号)')
638
+ .action(async (target: string) => {
639
+ // 解析目标
640
+ let wxid: string
641
+ let displayName = target
642
+ if (target.startsWith('wxid_') || target.includes('@chatroom') || target.includes('@openim')) {
643
+ wxid = target
644
+ } else {
645
+ try {
646
+ wxid = await resolveTalker(target)
647
+ const sessions = await chatService.listSessions(undefined, 50)
648
+ const match = sessions.find(s => s.username === wxid)
649
+ if (match) displayName = match.displayName || target
650
+ } catch (e: any) {
651
+ console.log(chalk.red(`✗ ${e.message}`))
652
+ return
653
+ }
654
+ }
655
+
656
+ // 检查是否已在名单中
657
+ if (whitelistService.isAllowed(wxid)) {
658
+ console.log(chalk.yellow(`"${displayName}" (${wxid}) 已在白名单中`))
659
+ return
660
+ }
661
+
662
+ // 三重确认
663
+ console.log(chalk.cyan(`\n⚠️ 即将添加以下联系人到白名单:`))
664
+ console.log(chalk.white(` 昵称: ${displayName}`))
665
+ console.log(chalk.gray(` wxid: ${wxid}`))
666
+ console.log(chalk.yellow(` 添加后,该联系人可向你收发消息\n`))
667
+
668
+ const { q1 } = await inquirer.prompt([{
669
+ type: 'confirm',
670
+ name: 'q1',
671
+ message: `确认添加 "${displayName}" 到白名单?`,
672
+ default: false,
673
+ }])
674
+ if (!q1) { console.log(chalk.gray('已取消')); return }
675
+
676
+ // 再确认一次(防止误操作)
677
+ const { q2 } = await inquirer.prompt([{
678
+ type: 'input',
679
+ name: 'q2',
680
+ message: `请输入 "确认" 以继续:`,
681
+ }])
682
+ if (q2 !== '确认') { console.log(chalk.gray('已取消')); return }
683
+
684
+ whitelistService.addDirect(wxid)
685
+ console.log(chalk.green(`\n✓ 已添加 "${displayName}" 到白名单`))
686
+ })
687
+
688
+ whitelistCmd
689
+ .command('rm <wxid>')
690
+ .description('移除白名单中的 wxid')
691
+ .action((wxid: string) => {
692
+ if (whitelistService.remove(wxid)) {
693
+ console.log(chalk.green(`✓ 已移除: ${wxid}`))
694
+ } else {
695
+ console.log(chalk.yellow(`未找到: ${wxid}`))
696
+ }
697
+ })
698
+
699
+ whitelistCmd
700
+ .command('clear')
701
+ .description('清空白名单')
702
+ .action(async () => {
703
+ const { confirm } = await inquirer.prompt([{
704
+ type: 'confirm',
705
+ name: 'confirm',
706
+ message: '确定要清空所有白名单吗?',
707
+ default: false,
708
+ }])
709
+ if (confirm) {
710
+ whitelistService.clear()
711
+ console.log(chalk.green('✓ 白名单已清空'))
712
+ }
713
+ })
714
+
715
+ // ==================== send ====================
716
+ program
717
+ .command('send <target> <message>')
718
+ .description('发送文本消息给指定联系人')
719
+ .option('--image <path>', '发图片')
720
+ .option('--file <path>', '发文件')
721
+ .action(async (target: string, message: string, opts) => {
722
+ // Resolve target
723
+ let wxid: string
724
+ try {
725
+ wxid = await resolveTalker(target)
726
+ } catch (e: any) {
727
+ console.log(chalk.red(`${e.message}`))
728
+ process.exit(1)
729
+ }
730
+
731
+ // Whitelist check
732
+ if (!whitelistService.isAllowed(wxid)) {
733
+ console.log(chalk.red('\n❌ 目标不在白名单中,拒绝发送'))
734
+ console.log(chalk.gray(` 先运行: weflow-cli whitelist add ${target}`))
735
+ console.log(chalk.gray(` 查看名单: weflow-cli whitelist\n`))
736
+ process.exit(1)
737
+ }
738
+
739
+ // 二次确认
740
+ const { confirm } = await inquirer.prompt([{
741
+ type: 'confirm',
742
+ name: 'confirm',
743
+ message: `确认发送给 ${chalk.cyan(target)}?`,
744
+ default: false,
745
+ }])
746
+ if (!confirm) {
747
+ console.log(chalk.gray('已取消'))
748
+ return
749
+ }
750
+
751
+ // Login check
752
+ const token = configService.get('wechatOcToken')
753
+ if (!token) {
754
+ console.log(chalk.red('\n❌ 未登录消息通道'))
755
+ console.log(chalk.gray(' 先运行: weflow-cli login-wechat\n'))
756
+ process.exit(1)
757
+ }
758
+
759
+ const service = new WechatMessageService({ token })
760
+
761
+ let success = false
762
+ if (opts.image) {
763
+ success = await service.sendImage(wxid, opts.image)
764
+ } else if (opts.file) {
765
+ success = await service.sendFile(wxid, opts.file)
766
+ } else {
767
+ success = await service.sendText(wxid, message)
768
+ }
769
+
770
+ if (success) {
771
+ console.log(chalk.green('✓ 发送成功'))
772
+ } else {
773
+ console.log(chalk.red('✗ 发送失败 — 可能需要先收到对方一条消息 (获取 context_token)'))
774
+ }
775
+ })
776
+
777
+ // ==================== listen ====================
778
+ program
779
+ .command('listen')
780
+ .description('监听微信消息 (Ctrl+C 退出)')
781
+ .option('--target <wxid>', '只显示指定用户的消息')
782
+ .action(async (opts) => {
783
+ const token = configService.get('wechatOcToken')
784
+ if (!token) {
785
+ console.log(chalk.red('\n❌ 未登录消息通道'))
786
+ console.log(chalk.gray(' 先运行: weflow-cli login-wechat\n'))
787
+ process.exit(1)
788
+ }
789
+
790
+ // Whitelist must be non-empty before listening
791
+ const wl = whitelistService.getList()
792
+ if (wl.length === 0) {
793
+ console.log(chalk.red('\n❌ 白名单为空,拒绝监听'))
794
+ console.log(chalk.gray(' 为了防止收到非预期消息,必须先配置白名单'))
795
+ console.log(chalk.gray(' 运行: weflow-cli whitelist add <昵称>\n'))
796
+ process.exit(1)
797
+ }
798
+ console.log(chalk.gray(`当前白名单: ${wl.join(', ')}`))
799
+
800
+ const service = new WechatMessageService({ token })
801
+ console.log(chalk.cyan('\n开始监听消息... (Ctrl+C 退出)\n'))
802
+
803
+ service.onMessage((msg) => {
804
+ // Whitelist filter (strict)
805
+ if (!whitelistService.isAllowed(msg.fromUserId)) {
806
+ return
807
+ }
808
+ // Target filter
809
+ if (opts.target && msg.fromUserId !== opts.target) {
810
+ return
811
+ }
812
+
813
+ const time = new Date(msg.timestampMs).toLocaleString('zh-CN')
814
+ const kind = msg.messageKind !== 'text' ? ` [${msg.messageKind}]` : ''
815
+ console.log(chalk.gray(`[${time}]`) + ` ${chalk.blue(msg.senderNickname || msg.fromUserId)}:${kind} ${msg.messageStr}`)
816
+ })
817
+
818
+ // Graceful shutdown
819
+ process.on('SIGINT', async () => {
820
+ console.log(chalk.gray('\n正在停止监听...'))
821
+ await service.stop()
822
+ process.exit(0)
823
+ })
824
+
825
+ await service.startPolling()
826
+ })
827
+
828
+ // ==================== report ====================
829
+ program
830
+ .command('report')
831
+ .description('生成聊天月报(AI 分析任务和回复)')
832
+ .option('--month <YYYY-MM>', '指定月份')
833
+ .option('--talker <昵称>', '指定联系人(可多次使用)')
834
+ .option('--from-whitelist', '使用白名单中的联系人')
835
+ .option('--api-key <key>', 'DeepSeek API key')
836
+ .option('--no-ai', '仅统计,不调用 AI')
837
+ .option('-o, --output <dir>', '输出目录', './output')
838
+ .action(async (opts) => {
839
+ if (!configService.isConfigured()) {
840
+ console.log(chalk.red('\n❌ 还没配置\n 运行: weflow-cli init\n'))
841
+ process.exit(1)
842
+ }
843
+
844
+ const { execFile } = await import('child_process')
845
+ const { promisify } = await import('util')
846
+ const execFileAsync = promisify(execFile)
847
+ const { fileURLToPath } = await import('url')
848
+ const { dirname } = await import('path')
849
+ const __filename = fileURLToPath(import.meta.url)
850
+ const __dirname = dirname(__filename)
851
+ const pkgRoot = join(__dirname, '..', '..')
852
+ const script = join(pkgRoot, 'scripts', 'chat_report.py')
853
+
854
+ const args: string[] = [script]
855
+ if (opts.month) args.push('--month', opts.month)
856
+ if (opts.talker) {
857
+ // commander collects repeated --talker into array, single value is string
858
+ const talkers = Array.isArray(opts.talker) ? opts.talker : [opts.talker]
859
+ for (const t of talkers) args.push('--talker', t)
860
+ }
861
+ if (opts.fromWhitelist) args.push('--from-whitelist')
862
+ if (opts.apiKey) args.push('--api-key', opts.apiKey)
863
+ if (opts.ai === false) args.push('--no-ai')
864
+ if (opts.output) args.push('--output', opts.output)
865
+
866
+ try {
867
+ console.log(chalk.cyan('正在生成月报...\n'))
868
+ const { stdout } = await execFileAsync('python', args, {
869
+ timeout: 600_000,
870
+ maxBuffer: 50 * 1024 * 1024,
871
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
872
+ })
873
+ console.log(stdout)
874
+ } catch (e: any) {
875
+ console.error(chalk.red(`\n✗ 生成失败: ${e.message}`))
876
+ process.exit(1)
877
+ }
878
+ })
879
+
880
+ // vault init
881
+ program
882
+ .command('vault')
883
+ .description('Obsidian Vault 管理')
884
+ .addCommand(
885
+ new Command('init')
886
+ .description('初始化 Obsidian Vault 目录结构')
887
+ .option('-p, --path <dir>', 'Vault 路径', './output/wechat-vault')
888
+ .action(async (opts) => {
889
+ const { mkdirSync, writeFileSync } = await import('fs')
890
+ const { join } = await import('path')
891
+
892
+ const vaultPath = opts.path
893
+ console.log(chalk.cyan(`\n🔧 初始化 Obsidian Vault: ${vaultPath}\n`))
894
+
895
+ // Directory structure
896
+ const dirs = [
897
+ '.obsidian',
898
+ 'Templates',
899
+ 'Sources/WeChat',
900
+ 'Wiki/Concepts',
901
+ 'Wiki/Entities',
902
+ 'Wiki/Topics',
903
+ ]
904
+ for (const d of dirs) {
905
+ mkdirSync(join(vaultPath, d), { recursive: true })
906
+ console.log(` ✓ ${d}/`)
907
+ }
908
+
909
+ // .obsidian/app.json
910
+ writeFileSync(join(vaultPath, '.obsidian', 'app.json'), JSON.stringify({
911
+ "newFileLocation": "folder",
912
+ "newFileFolderPath": "Sources",
913
+ "attachmentFolderPath": "Assets",
914
+ "showInlineTitle": false,
915
+ }, null, 2), 'utf-8')
916
+
917
+ // Templates/article.md
918
+ writeFileSync(join(vaultPath, 'Templates', 'article.md'), [
919
+ '---',
920
+ 'title: "{{title}}"',
921
+ 'source: ""',
922
+ 'date: {{date}}',
923
+ 'topic: AI',
924
+ 'tags: []',
925
+ 'created: {{date}}',
926
+ '---',
927
+ '',
928
+ '# {{title}}',
929
+ '',
930
+ '> 来源: ',
931
+ '> 时间:{{date}} ',
932
+ '',
933
+ '---',
934
+ '',
935
+ '## AI 摘要',
936
+ '',
937
+ '',
938
+ '## 相关概念',
939
+ '',
940
+ '',
941
+ '---',
942
+ '',
943
+ '## 正文',
944
+ '',
945
+ ].join('\n'), 'utf-8')
946
+
947
+ // README.md
948
+ writeFileSync(join(vaultPath, 'README.md'), [
949
+ '# WeChat Knowledge Vault',
950
+ '',
951
+ '> 由 weflow-cli 自动生成,兼容 Obsidian。',
952
+ '',
953
+ '## 目录结构',
954
+ '',
955
+ '| 目录 | 说明 |',
956
+ '|------|------|',
957
+ '| `Sources/WeChat/` | 公众号文章(按日期+主题分类) |',
958
+ '| `Wiki/Concepts/` | 概念页(手动或 AI 生成) |',
959
+ '| `Wiki/Entities/` | 实体页(公众号、作者等) |',
960
+ '| `Wiki/Topics/` | 主题总览页 |',
961
+ '| `Templates/` | 模板文件 |',
962
+ '',
963
+ '## 快速查询',
964
+ '',
965
+ '使用 Obsidian Dataview 插件:',
966
+ '',
967
+ '```dataview',
968
+ 'TABLE date, topic, tags',
969
+ 'FROM "Sources/WeChat"',
970
+ 'WHERE topic = "AI"',
971
+ 'SORT date DESC',
972
+ '```',
973
+ '',
974
+ '```dataview',
975
+ 'TABLE length(rows) as "篇数"',
976
+ 'FROM "Sources/WeChat"',
977
+ 'GROUP BY topic',
978
+ 'SORT rows.length DESC',
979
+ '```',
980
+ '',
981
+ '## 每日更新',
982
+ '',
983
+ '```bash',
984
+ '# 生成今日日报',
985
+ 'python scripts/biz_daily.py --api-key <key>',
986
+ '',
987
+ '# 后处理(广告清洗+深度摘要)',
988
+ 'python scripts/classify_daily.py --api-key <key> --interest AI',
989
+ '',
990
+ '# 同步到 GitHub',
991
+ '# (见 OPERATIONS.md)',
992
+ '```',
993
+ '',
994
+ '---',
995
+ '',
996
+ '*由 weflow-cli vault init 生成*',
997
+ ].join('\n'), 'utf-8')
998
+
999
+ // .gitignore
1000
+ writeFileSync(join(vaultPath, '.gitignore'), [
1001
+ '.obsidian/workspace*.json',
1002
+ '.obsidian/hotkeys.json',
1003
+ '.trash/',
1004
+ '.DS_Store',
1005
+ ].join('\n'), 'utf-8')
1006
+
1007
+ console.log(chalk.green(`\n✓ Vault 创建完成!`))
1008
+ console.log(` 用 Obsidian 打开: File → Open Vault → ${vaultPath}`)
1009
+ })
1010
+ )
1011
+
1012
+ // wiki compile
1013
+ program
1014
+ .command('wiki')
1015
+ .description('概念图谱管理')
1016
+ .addCommand(
1017
+ new Command('compile')
1018
+ .description('扫描文章 [[Wikilinks]] 聚合生成概念页')
1019
+ .option('-l, --limit <n>', '最多生成概念数', '20')
1020
+ .option('--source <dir>', '文章目录', './output/biz-daily')
1021
+ .option('-o, --output <dir>', '概念页输出目录', './output/wechat-vault/Wiki/Concepts')
1022
+ .option('--api-key <key>', 'DeepSeek API key')
1023
+ .action(async (opts) => {
1024
+ const { execFile } = await import('child_process')
1025
+ const { promisify } = await import('util')
1026
+ const execFileAsync = promisify(execFile)
1027
+ const { fileURLToPath } = await import('url')
1028
+ const { dirname } = await import('path')
1029
+ const __filename = fileURLToPath(import.meta.url)
1030
+ const __dirname = dirname(__filename)
1031
+ const pkgRoot = join(__dirname, '..', '..')
1032
+ const script = join(pkgRoot, 'scripts', 'compile_wiki.py')
1033
+
1034
+ const args: string[] = [script, '--limit', opts.limit, '--source', opts.source, '--output', opts.output]
1035
+ if (opts.apiKey) args.push('--api-key', opts.apiKey)
1036
+
1037
+ try {
1038
+ console.log(chalk.cyan('正在编译概念图谱...\n'))
1039
+ const { stdout } = await execFileAsync('python', args, {
1040
+ timeout: 300_000,
1041
+ maxBuffer: 10 * 1024 * 1024,
1042
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1043
+ })
1044
+ console.log(stdout)
1045
+ } catch (e: any) {
1046
+ console.error(chalk.red(`\n✗ 编译失败: ${e.message}`))
1047
+ process.exit(1)
1048
+ }
1049
+ })
1050
+ )
1051
+
1052
+ // pipeline run
1053
+ program
1054
+ .command('pipeline')
1055
+ .description('端到端自动化流水线')
1056
+ .addCommand(
1057
+ new Command('run')
1058
+ .description('一键运行 biz_daily → classify → wiki compile')
1059
+ .option('--date <YYYY-MM-DD>', '日期')
1060
+ .option('--api-key <key>', 'DeepSeek API key')
1061
+ .option('--interest <topic>', '兴趣主题', 'AI')
1062
+ .option('--wiki-limit <n>', '概念编译数', '20')
1063
+ .action(async (opts) => {
1064
+ const { execFile } = await import('child_process')
1065
+ const { promisify } = await import('util')
1066
+ const execFileAsync = promisify(execFile)
1067
+ const { fileURLToPath } = await import('url')
1068
+ const { dirname } = await import('path')
1069
+ const __filename = fileURLToPath(import.meta.url)
1070
+ const __dirname = dirname(__filename)
1071
+ const pkgRoot = join(__dirname, '..', '..')
1072
+ const script = join(pkgRoot, 'scripts', 'pipeline.py')
1073
+
1074
+ const args: string[] = [script, '--api-key', opts.apiKey, '--interest', opts.interest, '--wiki-limit', opts.wikiLimit]
1075
+ if (opts.date) args.push('--date', opts.date)
1076
+
1077
+ try {
1078
+ console.log(chalk.cyan('\n启动端到端流水线...\n'))
1079
+ const { stdout } = await execFileAsync('python', args, {
1080
+ timeout: 600_000,
1081
+ maxBuffer: 50 * 1024 * 1024,
1082
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1083
+ })
1084
+ console.log(stdout)
1085
+ } catch (e: any) {
1086
+ console.error(chalk.red(`\n✗ 流水线失败: ${e.message}`))
1087
+ process.exit(1)
1088
+ }
1089
+ })
1090
+ )
1091
+
1092
+ // vault sync — add to existing vault command
1093
+ program.commands
1094
+ .find(c => c.name() === 'vault')
1095
+ ?.addCommand(
1096
+ new Command('sync')
1097
+ .description('增量提交 Vault 并推送到远端仓库')
1098
+ .option('-r, --repo <url>', '远端 Git 仓库地址')
1099
+ .option('-b, --branch <name>', '分支名', 'main')
1100
+ .option('--include-chat', '包含聊天记录(默认不同步)')
1101
+ .action(async (opts) => {
1102
+ const { execFile } = await import('child_process')
1103
+ const { promisify } = await import('util')
1104
+ const execFileAsync = promisify(execFile)
1105
+ const vaultPath = join(process.cwd(), 'output', 'wechat-vault')
1106
+
1107
+ const repo = opts.repo || configService.get('vaultRepo')
1108
+ if (!repo) {
1109
+ console.error(chalk.red('\n❌ 未指定远端仓库。运行: weflow-cli config set vaultRepo <url> 或使用 --repo 参数\n'))
1110
+ process.exit(1)
1111
+ }
1112
+
1113
+ try {
1114
+ // Ensure vault is a git repo
1115
+ const gitDir = join(vaultPath, '.git')
1116
+ const { existsSync } = await import('fs')
1117
+ if (!existsSync(gitDir)) {
1118
+ console.log(chalk.cyan('初始化 Vault Git 仓库...'))
1119
+ await execFileAsync('git', ['init'], { cwd: vaultPath })
1120
+ await execFileAsync('git', ['remote', 'add', 'origin', repo], { cwd: vaultPath })
1121
+ }
1122
+
1123
+ // Check for changes
1124
+ try {
1125
+ await execFileAsync('git', ['diff', '--cached', '--quiet'], { cwd: vaultPath })
1126
+ await execFileAsync('git', ['diff', '--quiet'], { cwd: vaultPath })
1127
+ console.log(chalk.yellow('没有变更,跳过同步'))
1128
+ return
1129
+ } catch {
1130
+ // Has changes, proceed
1131
+ }
1132
+
1133
+ const dateStr = new Date().toISOString().slice(0, 10)
1134
+ const { stdout: statOut } = await execFileAsync('git', ['diff', '--stat'], { cwd: vaultPath })
1135
+
1136
+ console.log(chalk.cyan('提交变更...'))
1137
+ await execFileAsync('git', ['add', '-A'], { cwd: vaultPath })
1138
+ await execFileAsync('git', ['commit', '-m', `vault sync: ${dateStr} | ${statOut.split('\n').length} files`], { cwd: vaultPath })
1139
+
1140
+ console.log(chalk.cyan('推送到远端...'))
1141
+ await execFileAsync('git', ['push', '-u', 'origin', opts.branch], { cwd: vaultPath, timeout: 60_000 })
1142
+
1143
+ console.log(chalk.green(`\n✓ Vault 已同步到 ${repo} (${opts.branch})`))
1144
+ } catch (e: any) {
1145
+ console.error(chalk.red(`\n✗ 同步失败: ${e.message}`))
1146
+ process.exit(1)
1147
+ }
1148
+ })
1149
+ )
1150
+
1151
+ // vault enrich — bidirectional backlinks
1152
+ program.commands
1153
+ .find(c => c.name() === 'vault')
1154
+ ?.addCommand(
1155
+ new Command('enrich')
1156
+ .description('增强双向链接 — 为文章添加相关阅读段落')
1157
+ .option('--date <YYYY-MM-DD>', '日期(默认今天)')
1158
+ .action(async (opts) => {
1159
+ const { fileURLToPath } = await import('url')
1160
+ const { dirname } = await import('path')
1161
+ const __filename = fileURLToPath(import.meta.url)
1162
+ const __dirname = dirname(__filename)
1163
+ const pkgRoot = join(__dirname, '..', '..')
1164
+ const script = join(pkgRoot, 'scripts', 'enrich_backlinks.py')
1165
+ if (!opts.date) {
1166
+ const now = new Date()
1167
+ opts.date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
1168
+ }
1169
+ await runPythonCmd(script, ['--date', opts.date])
1170
+ })
1171
+ )
1172
+
1173
+ // vault notes — create reading notes
1174
+ program.commands
1175
+ .find(c => c.name() === 'vault')
1176
+ ?.addCommand(
1177
+ new Command('notes')
1178
+ .description('创建阅读笔记 — 为文章生成 Vault 笔记页')
1179
+ .option('--date <YYYY-MM-DD>', '日期(默认今天)')
1180
+ .option('--vault <path>', 'Vault 路径', './output/wechat-vault')
1181
+ .action(async (opts) => {
1182
+ const { fileURLToPath } = await import('url')
1183
+ const { dirname } = await import('path')
1184
+ const __filename = fileURLToPath(import.meta.url)
1185
+ const __dirname = dirname(__filename)
1186
+ const pkgRoot = join(__dirname, '..', '..')
1187
+ const script = join(pkgRoot, 'scripts', 'create_reading_notes.py')
1188
+ if (!opts.date) {
1189
+ const now = new Date()
1190
+ opts.date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
1191
+ }
1192
+ await runPythonCmd(script, ['--date', opts.date, '--vault', opts.vault])
1193
+ })
1194
+ )
1195
+
1196
+ // vault tag — auto tag
1197
+ program.commands
1198
+ .find(c => c.name() === 'vault')
1199
+ ?.addCommand(
1200
+ new Command('tag')
1201
+ .description('自动标签 — AI 为文章补充标签')
1202
+ .option('--date <YYYY-MM-DD>', '日期(默认今天)')
1203
+ .action(async (opts) => {
1204
+ const { fileURLToPath } = await import('url')
1205
+ const { dirname } = await import('path')
1206
+ const __filename = fileURLToPath(import.meta.url)
1207
+ const __dirname = dirname(__filename)
1208
+ const pkgRoot = join(__dirname, '..', '..')
1209
+ const script = join(pkgRoot, 'scripts', 'auto_tag.py')
1210
+ if (!opts.date) { const n=new Date(); opts.date=`${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,'0')}-${String(n.getDate()).padStart(2,'0')}` }
1211
+ await runPythonCmd(script, ['--date', opts.date])
1212
+ })
1213
+ )
1214
+
1215
+ // vault search — search across Vault
1216
+ program.commands
1217
+ .find(c => c.name() === 'vault')
1218
+ ?.addCommand(
1219
+ new Command('search')
1220
+ .description('Vault 搜索 — 文章+概念+笔记')
1221
+ .argument('<query>', '搜索关键词')
1222
+ .option('--type <type>', 'all / article / concept / note', 'all')
1223
+ .option('--top-k <n>', '返回数量', '10')
1224
+ .action(async (query, opts) => {
1225
+ const { fileURLToPath } = await import('url')
1226
+ const { dirname } = await import('path')
1227
+ const __filename = fileURLToPath(import.meta.url)
1228
+ const __dirname = dirname(__filename)
1229
+ const pkgRoot = join(__dirname, '..', '..')
1230
+ const script = join(pkgRoot, 'scripts', 'vault_search.py')
1231
+ await runPythonCmd(script, [query, '--type', opts.type, '--top-k', opts.topK, '--json'])
1232
+ })
1233
+ )
1234
+
1235
+ // vault rag — RAG dialog over Vault
1236
+ program.commands
1237
+ .find(c => c.name() === 'vault')
1238
+ ?.addCommand(
1239
+ new Command('rag')
1240
+ .description('Vault 问答 — 基于知识库的 AI 对话')
1241
+ .argument('<question>', '问题')
1242
+ .option('--top-k <n>', '检索条数', '8')
1243
+ .action(async (question, opts) => {
1244
+ const { fileURLToPath } = await import('url')
1245
+ const { dirname } = await import('path')
1246
+ const __filename = fileURLToPath(import.meta.url)
1247
+ const __dirname = dirname(__filename)
1248
+ const pkgRoot = join(__dirname, '..', '..')
1249
+ const script = join(pkgRoot, 'scripts', 'vault_rag.py')
1250
+ await runPythonCmd(script, [question, '--top-k', opts.topK])
1251
+ })
1252
+ )
1253
+
1254
+ // vault sync-weread — sync WeRead to Vault
1255
+ program.commands
1256
+ .find(c => c.name() === 'vault')
1257
+ ?.addCommand(
1258
+ new Command('sync-weread')
1259
+ .description('微信读书同步到 Vault')
1260
+ .option('--type <type>', 'all / shelf / notes', 'all')
1261
+ .action(async (opts) => {
1262
+ const { fileURLToPath } = await import('url')
1263
+ const { dirname } = await import('path')
1264
+ const __filename = fileURLToPath(import.meta.url)
1265
+ const __dirname = dirname(__filename)
1266
+ const pkgRoot = join(__dirname, '..', '..')
1267
+ const script = join(pkgRoot, 'scripts', 'sync_weread.py')
1268
+ await runPythonCmd(script, ['--type', opts.type])
1269
+ })
1270
+ )
1271
+
1272
+ // chat-stats
1273
+ program
1274
+ .command('chat-stats')
1275
+ .description('微信个人信息消费报告(支付 + 转账)')
1276
+ .option('--period <week|month>', '报告周期', 'week')
1277
+ .option('--month <YYYY-MM>', '指定月份(覆盖 --period)')
1278
+ .option('-o, --output <path>', '输出路径')
1279
+ .action(async (opts) => {
1280
+ const { execFile } = await import('child_process')
1281
+ const { promisify } = await import('util')
1282
+ const execFileAsync = promisify(execFile)
1283
+ const { fileURLToPath } = await import('url')
1284
+ const { dirname } = await import('path')
1285
+ const __filename = fileURLToPath(import.meta.url)
1286
+ const __dirname = dirname(__filename)
1287
+ const pkgRoot = join(__dirname, '..', '..')
1288
+ const script = join(pkgRoot, 'scripts', 'chat_stats.py')
1289
+ const args: string[] = [script, '--period', opts.period]
1290
+ if (opts.month) { args.push('--month', opts.month) }
1291
+ if (opts.output) { args.push('--output', opts.output) }
1292
+ try {
1293
+ const { stdout } = await execFileAsync('python', args, {
1294
+ timeout: 600_000, maxBuffer: 50 * 1024 * 1024,
1295
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1296
+ })
1297
+ console.log(stdout)
1298
+ } catch (e: any) {
1299
+ console.error(chalk.red(`\n✗ 失败: ${e.message}`))
1300
+ process.exit(1)
1301
+ }
1302
+ })
1303
+
1304
+ // generate-review
1305
+ program
1306
+ .command('review')
1307
+ .description('生成 AI 学习日报')
1308
+ .option('--date <YYYY-MM-DD>', '日期')
1309
+ .option('--api-key <key>', 'DeepSeek API key')
1310
+ .action(async (opts) => {
1311
+ const { execFile } = await import('child_process')
1312
+ const { promisify } = await import('util')
1313
+ const execFileAsync = promisify(execFile)
1314
+ const { fileURLToPath } = await import('url')
1315
+ const { dirname } = await import('path')
1316
+ const __filename = fileURLToPath(import.meta.url)
1317
+ const __dirname = dirname(__filename)
1318
+ const pkgRoot = join(__dirname, '..', '..')
1319
+ const script = join(pkgRoot, 'scripts', 'generate_review.py')
1320
+ const args: string[] = [script]
1321
+ if (opts.date) args.push('--date', opts.date)
1322
+ if (opts.apiKey) args.push('--api-key', opts.apiKey)
1323
+ try {
1324
+ const { stdout } = await execFileAsync('python', args, {
1325
+ timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
1326
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1327
+ })
1328
+ console.log(stdout)
1329
+ } catch (e: any) {
1330
+ console.error(chalk.red(`\n✗ 失败: ${e.message}`))
1331
+ process.exit(1)
1332
+ }
1333
+ })
1334
+
1335
+ // fav-server
1336
+ program
1337
+ .command('fav-server')
1338
+ .description('启动收藏服务器 — 浏览器访问 localhost 实时同步收藏夹')
1339
+ .option('--date <YYYY-MM-DD>', '日期(默认今天)')
1340
+ .option('--port <number>', '端口', '8765')
1341
+ .option('--open', '自动打开浏览器')
1342
+ .action(async (opts) => {
1343
+ const { spawn } = await import('child_process')
1344
+ const { fileURLToPath } = await import('url')
1345
+ const { dirname } = await import('path')
1346
+ const __filename = fileURLToPath(import.meta.url)
1347
+ const __dirname = dirname(__filename)
1348
+ const pkgRoot = join(__dirname, '..', '..')
1349
+ const script = join(pkgRoot, 'scripts', 'fav_server.py')
1350
+
1351
+ if (!opts.date) {
1352
+ const now = new Date()
1353
+ const yyyy = now.getFullYear()
1354
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
1355
+ const dd = String(now.getDate()).padStart(2, '0')
1356
+ opts.date = `${yyyy}-${mm}-${dd}`
1357
+ }
1358
+
1359
+ const args = [script, '--date', opts.date, '--port', opts.port]
1360
+ console.log(chalk.cyan(`⭐ 启动收藏服务器 (${opts.date})\n`))
1361
+ console.log(chalk.gray(` 按住 Ctrl 并点击: http://localhost:${opts.port}`))
1362
+
1363
+ if (opts.open) {
1364
+ const { exec } = await import('child_process')
1365
+ exec(`start http://localhost:${opts.port}`)
1366
+ }
1367
+
1368
+ const child = spawn('python', args, {
1369
+ stdio: 'inherit',
1370
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1371
+ })
1372
+ await new Promise<void>((resolve) => child.on('exit', (code) => {
1373
+ if (code !== 0 && code !== null) process.exit(code)
1374
+ resolve()
1375
+ }))
1376
+ })
1377
+
1378
+ // ==================== weread ====================
1379
+ const wereadCmd = program
1380
+ .command('weread')
1381
+ .description('微信读书助手 — 书架、统计、笔记、搜索、书评、推荐')
1382
+
1383
+ wereadCmd
1384
+ .command('shelf')
1385
+ .description('查看书架')
1386
+ .option('--json', 'JSON 输出')
1387
+ .action(async (opts) => {
1388
+ const weread = await getWereadService()
1389
+ const res = await weread.shelf()
1390
+ if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
1391
+ const d = res.data!
1392
+ if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
1393
+
1394
+ const total = d.books.length + d.albums.length + (d.mp ? 1 : 0)
1395
+ console.log(chalk.cyan(`📚 书架 (${total} 条目):\n`))
1396
+ console.log(chalk.gray(` 电子书: ${d.books.length} 本 | 有声书: ${d.albums.length} 个 | 文章收藏: ${d.mp ? '有' : '无'}`))
1397
+ if (d.archive.length) {
1398
+ console.log(chalk.gray(` 书单: ${d.archive.map((a: any) => a.name).join(', ')}`))
1399
+ }
1400
+ console.log()
1401
+ const books = d.books.sort((a: any, b: any) => b.readUpdateTime - a.readUpdateTime)
1402
+ for (let i = 0; i < Math.min(books.length, 30); i++) {
1403
+ const b = books[i]
1404
+ const num = String(i + 1).padStart(3)
1405
+ const done = b.finishReading === 1 ? chalk.green('✓') : ' '
1406
+ console.log(`${num}. ${done} ${b.title} ${chalk.gray(b.author)}`)
1407
+ }
1408
+ if (d.books.length > 30) console.log(chalk.gray(` ... 共 ${d.books.length} 本`))
1409
+ })
1410
+
1411
+ wereadCmd
1412
+ .command('stats')
1413
+ .description('阅读统计')
1414
+ .option('--mode <mode>', 'weekly | monthly | annually | overall', 'monthly')
1415
+ .option('--json', 'JSON 输出')
1416
+ .action(async (opts) => {
1417
+ const weread = await getWereadService()
1418
+ const res = await weread.readData(opts.mode)
1419
+ if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
1420
+ const d = res.data!
1421
+ if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
1422
+
1423
+ const h = (d.totalReadTime || 0) / 3600
1424
+ console.log(chalk.cyan(`📊 阅读统计 (${opts.mode}):\n`))
1425
+ console.log(` 总时长: ${chalk.white(h.toFixed(1) + ' 小时')}`)
1426
+ console.log(` 有效天数: ${chalk.white(String(d.readDays))}`)
1427
+ if (d.dayAverageReadTime) {
1428
+ console.log(` 日均: ${chalk.white(Math.round(d.dayAverageReadTime / 60) + ' 分钟')}`)
1429
+ }
1430
+ if (d.readStat?.length) {
1431
+ console.log(` ${d.readStat.map((s: any) => `${s.stat}: ${chalk.white(s.counts)}`).join(' | ')}`)
1432
+ }
1433
+ if (d.preferCategoryWord) console.log(` 偏好: ${chalk.white(d.preferCategoryWord)}`)
1434
+ if (d.preferTimeWord) console.log(` 时段: ${chalk.white(d.preferTimeWord)}`)
1435
+
1436
+ // 偏好分类
1437
+ if (d.preferCategory?.length) {
1438
+ console.log(chalk.cyan('\n 偏好分类:'))
1439
+ for (const c of d.preferCategory.slice(0, 5)) {
1440
+ const hh = (c.readingTime / 3600).toFixed(1)
1441
+ console.log(` ${c.categoryTitle}: ${chalk.white(hh + 'h')} (${c.readingCount}本)`)
1442
+ }
1443
+ }
1444
+
1445
+ // Read longest
1446
+ if (d.readLongest?.length) {
1447
+ console.log(chalk.cyan('\n 读最久的书:'))
1448
+ for (const item of d.readLongest.slice(0, 5)) {
1449
+ const b = item.book || item.albumInfo || {}
1450
+ const hh = (item.readTime / 3600).toFixed(1)
1451
+ console.log(` ${b.title || '(未知)'}: ${chalk.white(hh + 'h')} ${chalk.gray(b.author || '')}`)
1452
+ }
1453
+ }
1454
+ })
1455
+
1456
+ wereadCmd
1457
+ .command('notes')
1458
+ .description('笔记划线')
1459
+ .option('--book-id <id>', '指定书籍 ID')
1460
+ .option('-n, --limit <n>', '数量', '20')
1461
+ .option('--json', 'JSON 输出')
1462
+ .action(async (opts) => {
1463
+ const weread = await getWereadService()
1464
+ if (opts.bookId) {
1465
+ const res = await weread.bookmarks(opts.bookId, parseInt(opts.limit))
1466
+ if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
1467
+ const d = res.data!
1468
+ if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
1469
+ console.log(chalk.cyan(`📝 划线笔记 (${d.updated?.length || 0} 条):\n`))
1470
+ for (let i = 0; i < Math.min((d.updated || []).length, parseInt(opts.limit)); i++) {
1471
+ const n = d.updated[i]
1472
+ const icon = n.type === 1 ? '💬' : '📌'
1473
+ console.log(`${chalk.gray(` [${i + 1}]`)} ${icon} ${n.markText?.slice(0, 60) || ''}`)
1474
+ if (n.content) console.log(`${chalk.cyan(' 想法:')} ${n.content.slice(0, 100)}`)
1475
+ console.log()
1476
+ }
1477
+ } else {
1478
+ const res = await weread.notebooks(50)
1479
+ if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
1480
+ const d = res.data!
1481
+ if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
1482
+ console.log(chalk.cyan(`📝 笔记本 (${d.books?.length || 0} 本有笔记):\n`))
1483
+ for (let i = 0; i < Math.min((d.books || []).length, 30); i++) {
1484
+ const b: any = d.books[i]
1485
+ const bookInfo = b.book || b
1486
+ const num = String(i + 1).padStart(3)
1487
+ console.log(`${num}. ${bookInfo.title || '(未知)'} ${chalk.gray(`划线:${b.highlightCount || b.noteCount || 0} 想法:${b.reviewCount || 0} 书签:${b.bookmarkCount || 0}`)}`)
1488
+ }
1489
+ }
1490
+ })
1491
+
1492
+ wereadCmd
1493
+ .command('search')
1494
+ .description('搜索书籍')
1495
+ .argument('<keyword>', '搜索关键词')
1496
+ .option('-n, --limit <n>', '数量', '10')
1497
+ .option('--json', 'JSON 输出')
1498
+ .action(async (keyword, opts) => {
1499
+ const weread = await getWereadService()
1500
+ const res = await weread.search(keyword, parseInt(opts.limit))
1501
+ if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
1502
+ const d = res.data!
1503
+ if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
1504
+ console.log(chalk.cyan(`🔍 搜索: "${keyword}" (${d.books?.length || 0} 条):\n`))
1505
+ for (let i = 0; i < (d.books || []).length; i++) {
1506
+ const b = d.books[i]
1507
+ const num = String(i + 1).padStart(2)
1508
+ const star = b.rating ? ` ⭐${b.rating}` : ''
1509
+ console.log(` ${num}. ${b.title} ${chalk.gray(b.author)}${star}`)
1510
+ if (b.intro) console.log(` ${chalk.gray(b.intro.slice(0, 80))}`)
1511
+ }
1512
+ })
1513
+
1514
+ wereadCmd
1515
+ .command('book')
1516
+ .description('书籍详情')
1517
+ .argument('<bookId>', '书籍 ID')
1518
+ .option('--json', 'JSON 输出')
1519
+ .action(async (bookId, opts) => {
1520
+ const weread = await getWereadService()
1521
+ const [infoRes, progressRes] = await Promise.all([
1522
+ weread.bookInfo(bookId),
1523
+ weread.getProgress(bookId),
1524
+ ])
1525
+ if (!infoRes.ok) { console.log(chalk.red(`✗ ${infoRes.error}`)); process.exit(1) }
1526
+ const b = infoRes.data!
1527
+ if (opts.json) { console.log(JSON.stringify(b, null, 2)); return }
1528
+
1529
+ console.log(chalk.cyan(`📖 ${b.title}`))
1530
+ if (b.author) console.log(` 作者: ${chalk.white(b.author)}`)
1531
+ if (b.rating) console.log(` 评分: ${chalk.white('⭐' + b.rating + ' (' + (b as any).ratingCount + '评)')}`)
1532
+ if (b.category) console.log(` 分类: ${chalk.gray(b.category)}`)
1533
+ if (b.wordCount) console.log(` 字数: ${chalk.gray(b.wordCount)}`)
1534
+ if (b.publisher) console.log(` 出版: ${chalk.gray(b.publisher)}`)
1535
+ if (b.intro) console.log(`\n ${b.intro.slice(0, 200)}`)
1536
+ if (progressRes.ok && progressRes.data) {
1537
+ console.log(`\n 📍 进度: ${chalk.white(progressRes.data.chapterTitle || '未开始')}`)
1538
+ }
1539
+ })
1540
+
1541
+ wereadCmd
1542
+ .command('review')
1543
+ .description('书籍点评')
1544
+ .argument('<bookId>', '书籍 ID')
1545
+ .option('-n, --limit <n>', '数量', '10')
1546
+ .option('--json', 'JSON 输出')
1547
+ .action(async (bookId, opts) => {
1548
+ const weread = await getWereadService()
1549
+ const res = await weread.reviews(bookId, parseInt(opts.limit))
1550
+ if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
1551
+ const d = res.data!
1552
+ if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
1553
+ console.log(chalk.cyan(`💬 书评 (${d.reviews?.length || 0} 条):\n`))
1554
+ for (let i = 0; i < (d.reviews || []).length; i++) {
1555
+ const r = d.reviews[i]
1556
+ const num = String(i + 1).padStart(2)
1557
+ const star = r.rating ? ` ⭐${r.rating}` : ''
1558
+ console.log(` ${num}. ${r.user?.name || '匿名'}${star} ${chalk.gray(`👍${r.likeCount || 0}`)}`)
1559
+ if (r.content) console.log(` ${r.content.slice(0, 120)}`)
1560
+ console.log()
1561
+ }
1562
+ })
1563
+
1564
+ wereadCmd
1565
+ .command('discover')
1566
+ .description('推荐好书')
1567
+ .option('--book-id <id>', '基于某本书的相似推荐')
1568
+ .option('-n, --limit <n>', '数量', '10')
1569
+ .option('--json', 'JSON 输出')
1570
+ .action(async (opts) => {
1571
+ const weread = await getWereadService()
1572
+ const res = opts.bookId
1573
+ ? await weread.similar(opts.bookId, parseInt(opts.limit))
1574
+ : await weread.recommend(parseInt(opts.limit))
1575
+ if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
1576
+ const d = res.data!
1577
+ if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
1578
+ const label = opts.bookId ? '相似推荐' : '个性化推荐'
1579
+ console.log(chalk.cyan(`🎯 ${label}:\n`))
1580
+ for (let i = 0; i < (d.books || []).length; i++) {
1581
+ const b = d.books[i]
1582
+ const num = String(i + 1).padStart(2)
1583
+ const star = b.rating ? ` ⭐${b.rating}` : ''
1584
+ console.log(` ${num}. ${b.title} ${chalk.gray(b.author)}${star}`)
1585
+ if (b.intro) console.log(` ${chalk.gray(b.intro.slice(0, 80))}`)
1586
+ }
1587
+ })
1588
+
1589
+ wereadCmd
1590
+ .command('profile')
1591
+ .description('个人阅读概览')
1592
+ .option('--json', 'JSON 输出')
1593
+ .action(async (opts) => {
1594
+ const weread = await getWereadService()
1595
+ const res = await weread.profile()
1596
+ if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
1597
+ const p = res.data!
1598
+ if (opts.json) { console.log(JSON.stringify(p, null, 2)); return }
1599
+
1600
+ const totalH = (p.totalReadTime / 3600).toFixed(1)
1601
+ console.log(chalk.cyan('📖 个人阅读概览:\n'))
1602
+ console.log(` 总时长: ${chalk.white(totalH + ' 小时')}`)
1603
+ console.log(` 阅读天数: ${chalk.white(String(p.totalReadDays) + ' 天')}`)
1604
+ console.log(` 书架: ${chalk.white(String(p.totalBooks) + ' 本')}`)
1605
+ console.log(` 读完: ${chalk.white(String(p.totalFinished) + ' 本')}`)
1606
+ })
1607
+
1608
+ // 辅助函数
1609
+ async function runPythonCmd(script: string, args: string[]) {
1610
+ const { execFile } = await import('child_process')
1611
+ const { promisify } = await import('util')
1612
+ const execFileAsync = promisify(execFile)
1613
+ try {
1614
+ const { stdout } = await execFileAsync('python', [script, ...args], {
1615
+ timeout: 120_000, maxBuffer: 5 * 1024 * 1024,
1616
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1617
+ })
1618
+ console.log(stdout)
1619
+ } catch (e: any) {
1620
+ console.error(chalk.red(`\n✗ 失败: ${e.message}`))
1621
+ }
1622
+ }
1623
+
1624
+ async function getWereadService() {
1625
+ const key = process.env.WEREAD_API_KEY || ''
1626
+ const mod = await import('../src/services/wereadService.js')
1627
+ return new mod.WereadService(key)
1628
+ }
1629
+
1630
+ // semantic-search
1631
+ program
1632
+ .command('search')
1633
+ .description('语义搜索知识库(聊天记录 + 文章)')
1634
+ .argument('<query>', '搜索关键词')
1635
+ .option('--top-k <n>', '返回数量', '10')
1636
+ .option('--api-key <key>', 'DeepSeek API key')
1637
+ .action(async (query, opts) => {
1638
+ const { execFile } = await import('child_process')
1639
+ const { promisify } = await import('util')
1640
+ const execFileAsync = promisify(execFile)
1641
+ const { fileURLToPath } = await import('url')
1642
+ const { dirname } = await import('path')
1643
+ const __filename = fileURLToPath(import.meta.url)
1644
+ const __dirname = dirname(__filename)
1645
+ const pkgRoot = join(__dirname, '..')
1646
+ const script = join(pkgRoot, 'scripts', 'semantic_search.py')
1647
+ const args: string[] = [script, 'search', query, '--top-k', opts.topK]
1648
+ if (opts.apiKey) args.push('--api-key', opts.apiKey)
1649
+ try {
1650
+ const { stdout } = await execFileAsync('python', args, {
1651
+ timeout: 60_000, maxBuffer: 10 * 1024 * 1024,
1652
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1653
+ })
1654
+ console.log(stdout)
1655
+ } catch (e: any) {
1656
+ console.error(chalk.red(`\n✗ 搜索失败: ${e.message}`))
1657
+ process.exit(1)
1658
+ }
1659
+ })
1660
+
1661
+ program
1662
+ .command('search-index')
1663
+ .description('构建语义搜索索引')
1664
+ .option('--full', '全量重建')
1665
+ .option('--api-key <key>', 'DeepSeek API key')
1666
+ .action(async (opts) => {
1667
+ const { execFile } = await import('child_process')
1668
+ const { promisify } = await import('util')
1669
+ const execFileAsync = promisify(execFile)
1670
+ const { fileURLToPath } = await import('url')
1671
+ const { dirname } = await import('path')
1672
+ const __filename = fileURLToPath(import.meta.url)
1673
+ const __dirname = dirname(__filename)
1674
+ const pkgRoot = join(__dirname, '..')
1675
+ const script = join(pkgRoot, 'scripts', 'semantic_search.py')
1676
+ const args: string[] = [script, 'build']
1677
+ if (opts.full) args.push('--full')
1678
+ if (opts.apiKey) args.push('--api-key', opts.apiKey)
1679
+ try {
1680
+ const { stdout } = await execFileAsync('python', args, {
1681
+ timeout: 300_000, maxBuffer: 10 * 1024 * 1024,
1682
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1683
+ })
1684
+ console.log(stdout)
1685
+ } catch (e: any) {
1686
+ console.error(chalk.red(`\n✗ 失败: ${e.message}`))
1687
+ process.exit(1)
1688
+ }
1689
+ })
1690
+
1691
+ // chat
1692
+ program
1693
+ .command('chat')
1694
+ .description('RAG 智能助手 — 基于聊天记录和公众号的对话式问答')
1695
+ .argument('[question]', '要问的问题(不传则进入交互模式)')
1696
+ .option('--top-k <n>', '检索数量', '10')
1697
+ .option('--talker <name>', '限定联系人/群聊')
1698
+ .option('--api-key <key>', 'AI API key')
1699
+ .option('--json', 'JSON 输出')
1700
+ .action(async (question, opts) => {
1701
+ const { execFile, spawn } = await import('child_process')
1702
+ const { promisify } = await import('util')
1703
+ const execFileAsync = promisify(execFile)
1704
+ const { fileURLToPath } = await import('url')
1705
+ const { dirname } = await import('path')
1706
+ const __filename = fileURLToPath(import.meta.url)
1707
+ const __dirname = dirname(__filename)
1708
+ const pkgRoot = join(__dirname, '..')
1709
+ const script = join(pkgRoot, 'scripts', 'rag_chat.py')
1710
+ const args: string[] = [script]
1711
+ if (question) {
1712
+ args.push(question, '--top-k', opts.topK)
1713
+ if (opts.talker) args.push('--talker', opts.talker)
1714
+ if (opts.apiKey) args.push('--api-key', opts.apiKey)
1715
+ if (opts.json) args.push('--json')
1716
+ try {
1717
+ const { stdout } = await execFileAsync('python', args, {
1718
+ timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
1719
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1720
+ })
1721
+ console.log(stdout)
1722
+ } catch (e: any) {
1723
+ console.error(chalk.red(`\n✗ 对话失败: ${e.message}`))
1724
+ process.exit(1)
1725
+ }
1726
+ } else {
1727
+ // 交互模式:使用 spawn 保持终端交互
1728
+ args.push('--interactive', '--top-k', opts.topK)
1729
+ if (opts.talker) args.push('--talker', opts.talker)
1730
+ if (opts.apiKey) args.push('--api-key', opts.apiKey)
1731
+ const child = spawn('python', args, {
1732
+ stdio: 'inherit',
1733
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1734
+ })
1735
+ await new Promise<void>((resolve) => child.on('exit', (code) => {
1736
+ if (code !== 0) process.exit(code || 1);
1737
+ resolve();
1738
+ }))
1739
+ }
1740
+ })
1741
+
1742
+ // Helper: run a Python script
1743
+ async function runPython(script: string, args: string[]): Promise<void> {
1744
+ const { execFile } = await import('child_process')
1745
+ const { promisify } = await import('util')
1746
+ const execFileAsync = promisify(execFile)
1747
+ const { fileURLToPath } = await import('url')
1748
+ const { dirname } = await import('path')
1749
+ const __filename = fileURLToPath(import.meta.url)
1750
+ const __dirname = dirname(__filename)
1751
+ const pkgRoot = join(__dirname, '..')
1752
+ const pyArgs = [join(pkgRoot, script), ...args]
1753
+ try {
1754
+ const { stdout } = await execFileAsync('python', pyArgs, {
1755
+ timeout: 120_000, maxBuffer: 5 * 1024 * 1024,
1756
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1757
+ })
1758
+ console.log(stdout)
1759
+ } catch (e: any) {
1760
+ console.error(chalk.red(`\n✗ 失败: ${e.message}`))
1761
+ process.exit(1)
1762
+ }
1763
+ }
1764
+
1765
+ // annual-report
1766
+ program
1767
+ .command('annual-report')
1768
+ .description('生成年度数字生活报告')
1769
+ .argument('[year]', '年份(默认今年)')
1770
+ .option('--share', '分享模式(紧凑卡片)')
1771
+ .option('--api-key <key>', 'DeepSeek API key')
1772
+ .option('--skip-ai', '跳过 AI 总结')
1773
+ .action(async (year, opts) => {
1774
+ const a = [year || String(new Date().getFullYear())]
1775
+ if (opts.share) a.push('--share')
1776
+ if (opts.apiKey) a.push('--api-key', opts.apiKey)
1777
+ if (opts.skipAi) a.push('--skip-ai')
1778
+ await runPython('scripts/annual_report.py', a)
1779
+ })
1780
+
1781
+ // todos
1782
+ const todosCmd = program
1783
+ .command('todos')
1784
+ .description('待办提取与任务追踪')
1785
+
1786
+ todosCmd
1787
+ .command('extract')
1788
+ .description('AI 扫描聊天记录提取待办')
1789
+ .option('--days <n>', '扫描天数', '7')
1790
+ .option('--api-key <key>', 'DeepSeek API key')
1791
+ .action(async (opts) => {
1792
+ await runPython('scripts/extract_todos.py', ['extract', '--days', opts.days, ...(opts.apiKey ? ['--api-key', opts.apiKey] : [])])
1793
+ })
1794
+
1795
+ todosCmd
1796
+ .command('list')
1797
+ .description('列出待办')
1798
+ .option('--status <s>', 'pending / done')
1799
+ .option('--urgency <u>', '高 / 中 / 低')
1800
+ .option('--json', 'JSON 输出')
1801
+ .action(async (opts) => {
1802
+ const a = ['list']
1803
+ if (opts.status) a.push('--status', opts.status)
1804
+ if (opts.urgency) a.push('--urgency', opts.urgency)
1805
+ if (opts.json) a.push('--json')
1806
+ await runPython('scripts/extract_todos.py', a)
1807
+ })
1808
+
1809
+ todosCmd
1810
+ .command('done')
1811
+ .description('标记待办为已完成')
1812
+ .argument('<id>', '待办 ID')
1813
+ .action(async (id) => {
1814
+ await runPython('scripts/extract_todos.py', ['done', id])
1815
+ })
1816
+
1817
+ todosCmd
1818
+ .command('undone')
1819
+ .description('取消已完成标记')
1820
+ .argument('<id>', '待办 ID')
1821
+ .action(async (id) => {
1822
+ await runPython('scripts/extract_todos.py', ['undone', id])
1823
+ })
1824
+
1825
+ todosCmd
1826
+ .command('rm')
1827
+ .description('删除待办')
1828
+ .argument('<id>', '待办 ID')
1829
+ .action(async (id) => {
1830
+ await runPython('scripts/extract_todos.py', ['rm', id])
1831
+ })
1832
+
1833
+ todosCmd
1834
+ .command('remind')
1835
+ .description('查看待办提醒')
1836
+ .action(async () => {
1837
+ await runPython('scripts/extract_todos.py', ['remind'])
1838
+ })
1839
+
1840
+ // ==================== daily ====================
1841
+ program
1842
+ .command('daily')
1843
+ .description('一键生成公众号日报(抓取 + AI 摘要 + HTML 阅读器 + 行动建议)')
1844
+ .option('-d, --date <YYYY-MM-DD>', '日期')
1845
+ .option('--api-key <key>', 'DeepSeek API key(或设环境变量 DEEPSEEK_API_KEY)')
1846
+ .option('--skip-classify', '跳过后处理')
1847
+ .action(async (opts) => {
1848
+ const { execFile, spawn } = await import('child_process')
1849
+ const { promisify } = await import('util')
1850
+ const execFileAsync = promisify(execFile)
1851
+ const { fileURLToPath } = await import('url')
1852
+ const { dirname } = await import('path')
1853
+ const __filename = fileURLToPath(import.meta.url)
1854
+ const __dirname = dirname(__filename)
1855
+ const pkgRoot = join(__dirname, '..')
1856
+ const pipeline = join(pkgRoot, 'scripts', 'pipeline.py')
1857
+
1858
+ const date = opts.date || new Date().toISOString().slice(0, 10)
1859
+ const apiKey = opts.apiKey || process.env.DEEPSEEK_API_KEY || ''
1860
+ if (!apiKey) {
1861
+ console.log(chalk.red('\n❌ 缺少 DeepSeek API key'))
1862
+ console.log(chalk.gray(' 用法: weflow-cli daily --api-key <key>'))
1863
+ console.log(chalk.gray(' 或设环境变量: set DEEPSEEK_API_KEY=<key>\n'))
1864
+ process.exit(1)
1865
+ }
1866
+
1867
+ console.log(chalk.cyan(`\n📰 正在生成 ${date} 公众号日报...\n`))
1868
+ const args = [pipeline, '--date', date, '--api-key', apiKey, '--interest', 'AI', '--skip-wiki']
1869
+ if (opts.skipClassify) args.push('--skip-classify')
1870
+
1871
+ const child = spawn('python', args, {
1872
+ stdio: 'inherit',
1873
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1874
+ })
1875
+ child.on('exit', (code) => {
1876
+ if (code === 0) {
1877
+ console.log(chalk.green(`\n✓ 日报已生成: output/biz-daily/${date}/`))
1878
+ console.log(chalk.gray(` HTML 阅读器: weflow-cli daily-server --date ${date}`))
1879
+ } else {
1880
+ process.exit(code || 1)
1881
+ }
1882
+ })
1883
+ })
1884
+
1885
+ // ==================== daily-server ====================
1886
+ program
1887
+ .command('daily-server')
1888
+ .description('启动本地日报阅读器(浏览器浏览 + 收藏 + 笔记)')
1889
+ .option('-d, --date <YYYY-MM-DD>', '日期')
1890
+ .option('-p, --port <n>', '端口', '8765')
1891
+ .option('--open', '自动打开浏览器')
1892
+ .action(async (opts) => {
1893
+ const { spawn, exec } = await import('child_process')
1894
+ const { fileURLToPath } = await import('url')
1895
+ const { dirname } = await import('path')
1896
+ const __filename = fileURLToPath(import.meta.url)
1897
+ const __dirname = dirname(__filename)
1898
+ const pkgRoot = join(__dirname, '..')
1899
+ const favServer = join(pkgRoot, 'scripts', 'fav_server.py')
1900
+
1901
+ const date = opts.date || new Date().toISOString().slice(0, 10)
1902
+ console.log(chalk.cyan(`⭐ 启动日报阅读器\n`))
1903
+ console.log(chalk.gray(` 日期: ${date}`))
1904
+ console.log(chalk.gray(` 地址: http://localhost:${opts.port}`))
1905
+ console.log()
1906
+
1907
+ if (opts.open) {
1908
+ exec(`start http://localhost:${opts.port}`)
1909
+ }
1910
+
1911
+ const child = spawn('python', [favServer, '--date', date, '--port', String(opts.port)], {
1912
+ stdio: 'inherit',
1913
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
1914
+ })
1915
+ process.on('SIGINT', () => child.kill())
1916
+ })
1917
+
1918
+ // ==================== Interactive Menu (no arguments) ====================
1919
+ program
1920
+ .command('mcp-config')
1921
+ .description('输出 MCP Server 配置,粘贴到 .mcp.json 即可让 AI 操作本工具')
1922
+ .option('--port <n>', 'MCP Server 端口', '0')
1923
+ .option('-o, --output <file>', '写入文件', '')
1924
+ .action(async (opts) => {
1925
+ const { writeFileSync } = await import('fs')
1926
+ const config = {
1927
+ mcpServers: {
1928
+ weflow: {
1929
+ command: 'npx',
1930
+ args: ['tsx', 'mcp-server/index.ts'],
1931
+ cwd: '${workspaceFolder}',
1932
+ },
1933
+ },
1934
+ }
1935
+ const json = JSON.stringify(config, null, 2)
1936
+ if (opts.output) {
1937
+ writeFileSync(opts.output, json, 'utf-8')
1938
+ console.log(chalk.green(`✓ 已写入 ${opts.output}`))
1939
+ console.log(chalk.gray('AI 助手现在可以使用以下工具:'))
1940
+ } else {
1941
+ console.log(json)
1942
+ }
1943
+ console.log(chalk.cyan('\n可用的 MCP 工具:'))
1944
+ console.log(chalk.white(' wechat.search_articles ') + chalk.gray('搜索知识库文章'))
1945
+ console.log(chalk.white(' wechat.get_daily ') + chalk.gray('获取公众号日报'))
1946
+ console.log(chalk.white(' wechat.get_review ') + chalk.gray('获取 AI 学习日报'))
1947
+ console.log(chalk.white(' wechat.get_stats ') + chalk.gray('知识库统计'))
1948
+ console.log(chalk.white(' wechat.get_concepts ') + chalk.gray('概念图谱'))
1949
+ console.log()
1950
+ if (opts.output) {
1951
+ console.log(chalk.gray(`配置已写入 ${opts.output},重启 AI 编辑器即可生效`))
1952
+ } else {
1953
+ console.log(chalk.gray('复制以上 JSON 到你的 .mcp.json 文件即可'))
1954
+ }
1955
+ })
1956
+
1957
+ // ==================== check ====================
1958
+ program
1959
+ .command('check')
1960
+ .description('检查运行环境(Python、pip 依赖、数据库配置)')
1961
+ .action(async () => {
1962
+ console.log(chalk.cyan('🔍 WeFlow CLI 环境检查\n'))
1963
+
1964
+ // 1. Node.js
1965
+ console.log(chalk.white('Node.js:'), chalk.green(`v${process.versions.node}`))
1966
+
1967
+ // 2. Python
1968
+ const { execSync } = await import('child_process')
1969
+ let pythonOk = false
1970
+ try {
1971
+ const pyVer = execSync('python --version 2>&1 || python3 --version 2>&1', { encoding: 'utf-8', timeout: 5000 }).trim()
1972
+ console.log(chalk.white('Python: '), chalk.green(pyVer))
1973
+ pythonOk = true
1974
+ } catch {
1975
+ console.log(chalk.white('Python: '), chalk.red('✗ 未找到'))
1976
+ }
1977
+
1978
+ // 3. Pip dependencies
1979
+ if (pythonOk) {
1980
+ const deps = ['sqlcipher3', 'html2text', 'zstandard', 'cryptography']
1981
+ console.log(chalk.white('\nPython 依赖:'))
1982
+ let allOk = true
1983
+ for (const dep of deps) {
1984
+ try {
1985
+ execSync(`python -c "import ${dep}"`, { timeout: 5000 })
1986
+ console.log(` ${dep.padEnd(20)} ${chalk.green('✓')}`)
1987
+ } catch {
1988
+ console.log(` ${dep.padEnd(20)} ${chalk.red('✗ 缺失')}`)
1989
+ allOk = false
1990
+ }
1991
+ }
1992
+ if (!allOk) {
1993
+ console.log(chalk.yellow('\n⚠️ 运行以下命令安装缺失依赖:'))
1994
+ console.log(chalk.gray(' pip install sqlcipher3 html2text zstandard cryptography'))
1995
+ }
1996
+ // 可选依赖
1997
+ const optDeps = ['scrapling']
1998
+ console.log(chalk.white('\n可选依赖(提升抓取成功率):'))
1999
+ for (const dep of optDeps) {
2000
+ try {
2001
+ execSync(`python -c "import ${dep}"`, { timeout: 5000 })
2002
+ console.log(` ${dep.padEnd(20)} ${chalk.green('✓')}`)
2003
+ } catch {
2004
+ console.log(` ${dep.padEnd(20)} ${chalk.gray('○ (npm install scrapling)')}`)
2005
+ }
2006
+ }
2007
+ }
2008
+
2009
+ // 4. Config
2010
+ console.log(chalk.white('\n配置状态:'))
2011
+ const configured = configService.isConfigured()
2012
+ const hasKey = !!configService.get('ntKey') || !!configService.get('decryptKey')
2013
+ if (configured && hasKey) {
2014
+ console.log(chalk.green(' ✓ 已初始化 — 可以运行 weflow-cli'))
2015
+ } else {
2016
+ console.log(chalk.yellow(' ○ 未初始化 — 运行 weflow-cli init'))
2017
+ }
2018
+
2019
+ // 5. Database
2020
+ const ntDb = configService.get('ntDbPath')
2021
+ if (ntDb && existsSync(ntDb)) {
2022
+ const stat = (await import('fs')).statSync(ntDb)
2023
+ const mb = (stat.size / 1024 / 1024).toFixed(0)
2024
+ console.log(chalk.white(` 消息数据库: ${ntDb} (${mb}MB)`))
2025
+ }
2026
+ const bizDb = (configService as any).get?.('bizKey')
2027
+ if (bizDb) {
2028
+ console.log(chalk.green(' ✓ 公众号数据库密钥已配置'))
2029
+ }
2030
+ console.log()
2031
+ })
2032
+
2033
+ // ==================== Interactive Menu (no arguments) ====================
2034
+ async function showInteractiveMenu() {
2035
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2036
+ const configured = configService.isConfigured()
2037
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2038
+ const hasNtKey = !!configService.get('ntKey')
2039
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2040
+ const hasLogin = !!configService.get('wechatOcToken')
2041
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2042
+ const hasVault = !!configService.get('vaultRepo')
2043
+
2044
+ // 首次运行:自动引导初始化
2045
+ if (!configured) {
2046
+ console.log(chalk.cyan('\n👋 欢迎使用 WeFlow CLI!'))
2047
+ console.log(chalk.gray('检测到尚未初始化,让我们开始设置...\n'))
2048
+
2049
+ const { startInit } = await inquirer.prompt([{
2050
+ type: 'confirm',
2051
+ name: 'startInit',
2052
+ message: '是否现在开始初始化(检测微信数据目录并提取密钥)?',
2053
+ default: true,
2054
+ }])
2055
+ if (startInit) {
2056
+ const initCmd = program.commands.find(c => c.name() === 'init')
2057
+ if (initCmd) { await (initCmd as any).action(); return }
2058
+ }
2059
+ console.log(chalk.gray('\n稍后可运行: weflow-cli init\n'))
2060
+ return
2061
+ }
2062
+
2063
+ // 构建菜单选项
2064
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2065
+ const choices: any[] = []
2066
+
2067
+ choices.push(new inquirer.Separator(' 📬 聊天记录'))
2068
+ choices.push({ name: ' 查看会话列表', value: 'sessions' })
2069
+ choices.push({ name: ' 查看聊天消息', value: 'messages' })
2070
+ choices.push({ name: ' 导出聊天记录', value: 'export' })
2071
+ choices.push({ name: ' 生成聊天月报', value: 'report' })
2072
+
2073
+ choices.push(new inquirer.Separator(' 📰 公众号日报'))
2074
+ choices.push({ name: ' 生成今日日报', value: 'daily' })
2075
+ choices.push({ name: ' 启动阅读器', value: 'daily-server' })
2076
+
2077
+ choices.push(new inquirer.Separator(' 💬 微信消息'))
2078
+ choices.push({
2079
+ name: ` 扫码登录${hasLogin ? chalk.green(' (已登录)') : ''}`,
2080
+ value: 'login-wechat',
2081
+ })
2082
+ choices.push({
2083
+ name: ` 监听消息${!hasLogin ? chalk.gray(' (需先登录)') : ''}`,
2084
+ value: 'listen',
2085
+ disabled: !hasLogin ? '请先扫码登录' : undefined,
2086
+ })
2087
+ choices.push({
2088
+ name: ` 发送消息${!hasLogin ? chalk.gray(' (需先登录)') : ''}`,
2089
+ value: 'send',
2090
+ disabled: !hasLogin ? '请先扫码登录' : undefined,
2091
+ })
2092
+
2093
+ choices.push(new inquirer.Separator(' 📚 知识库'))
2094
+ choices.push({ name: ' 初始化 Obsidian Vault', value: 'vault-init' })
2095
+ choices.push({ name: ' 编译概念图谱', value: 'wiki-compile' })
2096
+ choices.push({
2097
+ name: ` 同步到远端${hasVault ? '' : chalk.gray(' (未配置仓库)')}`,
2098
+ value: 'vault-sync',
2099
+ disabled: !hasVault ? '请先配置 vaultRepo' : undefined,
2100
+ })
2101
+
2102
+ choices.push(new inquirer.Separator(' 📊 统计 & 报告'))
2103
+ choices.push({ name: ' 📊 个人信息消费报告', value: 'chat-stats' })
2104
+ choices.push({ name: ' 📚 微信读书统计', value: 'weread' })
2105
+ choices.push({ name: ' 🧠 提取待办事项', value: 'extract-todos' })
2106
+ choices.push({ name: ' 📝 AI 学习日报', value: 'generate-review' })
2107
+
2108
+ choices.push(new inquirer.Separator(' ⚙️ 系统'))
2109
+ choices.push({ name: ' 查看配置', value: 'config-show' })
2110
+ choices.push({ name: ' 重新初始化', value: 'init' })
2111
+
2112
+ // 显示状态摘要
2113
+ console.log(chalk.cyan('\n🚀 WeFlow CLI v1.5.0\n'))
2114
+ const statusParts: string[] = []
2115
+ statusParts.push(hasNtKey ? chalk.green('✓ 数据库') : chalk.yellow('○ 数据库'))
2116
+ statusParts.push(hasLogin ? chalk.green('✓ 消息通道') : chalk.gray('○ 消息通道'))
2117
+ statusParts.push(hasVault ? chalk.green('✓ Vault') : chalk.gray('○ Vault'))
2118
+ console.log(chalk.gray(` 状态: ${statusParts.join(' ')}\n`))
2119
+
2120
+ const { action } = await inquirer.prompt([{
2121
+ type: 'select' as any,
2122
+ name: 'action',
2123
+ message: '请选择操作',
2124
+ choices,
2125
+ loop: false,
2126
+ }])
2127
+
2128
+ // 辅助函数:执行命令
2129
+ const runCmd = async (name: string, opts: Record<string, any> = {}) => {
2130
+ const cmd = program.commands.find(c => c.name() === name)
2131
+ if (cmd) await (cmd as any).action(opts)
2132
+ }
2133
+ const runSubCmd = async (parent: string, child: string, opts: Record<string, any> = {}) => {
2134
+ const cmd = program.commands.find(c => c.name() === parent) as any
2135
+ const sub = cmd?.commands?.find((c: any) => c.name() === child)
2136
+ if (sub) await sub.action(opts)
2137
+ }
2138
+ const runPython = async (scriptName: string, label: string) => {
2139
+ const { execFile } = await import('child_process')
2140
+ const { promisify } = await import('util')
2141
+ const execFileAsync = promisify(execFile)
2142
+ const { fileURLToPath } = await import('url')
2143
+ const { dirname } = await import('path')
2144
+ const __filename = fileURLToPath(import.meta.url)
2145
+ const __dirname = dirname(__filename)
2146
+ const pkgRoot = join(__dirname, '..', '..')
2147
+ const script = join(pkgRoot, 'scripts', scriptName)
2148
+ console.log(chalk.cyan(`正在${label}...\n`))
2149
+ try {
2150
+ const { stdout } = await execFileAsync('python', [script], {
2151
+ timeout: 600_000, maxBuffer: 50 * 1024 * 1024,
2152
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
2153
+ })
2154
+ console.log(stdout)
2155
+ } catch (e: any) {
2156
+ console.error(chalk.red(`${label}失败: ${e.message}`))
2157
+ }
2158
+ }
2159
+
2160
+ switch (action) {
2161
+ case 'sessions':
2162
+ await runCmd('sessions')
2163
+ break
2164
+ case 'messages': {
2165
+ const sessions = await chatService.listSessions(undefined, 30)
2166
+ if (sessions.length === 0) { console.log(chalk.gray('无会话')); break }
2167
+ const { talker } = await inquirer.prompt([{
2168
+ type: 'select' as any, name: 'talker', message: '选择会话',
2169
+ choices: sessions.map((s, i) => ({
2170
+ name: `${String(i + 1).padStart(3)}. ${s.displayName || s.username} ${chalk.gray((s.summary || '').slice(0, 30))}`,
2171
+ value: s.username,
2172
+ })),
2173
+ loop: false,
2174
+ }])
2175
+ await runCmd('messages', { talker, limit: '50', offset: '0' })
2176
+ break
2177
+ }
2178
+ case 'export': {
2179
+ const sessions = await chatService.listSessions(undefined, 30)
2180
+ if (sessions.length === 0) { console.log(chalk.gray('无会话')); break }
2181
+ const { talker } = await inquirer.prompt([{
2182
+ type: 'select' as any, name: 'talker', message: '选择要导出的会话',
2183
+ choices: sessions.map((s, i) => ({
2184
+ name: `${String(i + 1).padStart(3)}. ${s.displayName || s.username}`,
2185
+ value: s.username,
2186
+ })),
2187
+ loop: false,
2188
+ }])
2189
+ const { format } = await inquirer.prompt([{
2190
+ type: 'select' as any, name: 'format', message: '选择导出格式',
2191
+ choices: ['html', 'json', 'txt', 'excel'], loop: false,
2192
+ }])
2193
+ await runCmd('export', { talker, format, output: './output' })
2194
+ break
2195
+ }
2196
+ case 'report':
2197
+ await runCmd('report', {})
2198
+ break
2199
+ case 'daily': {
2200
+ const apiKey = process.env.DEEPSEEK_API_KEY || ''
2201
+ if (!apiKey) {
2202
+ console.log(chalk.red('\n❌ 缺少 DeepSeek API key'))
2203
+ console.log(chalk.gray(' 设置环境变量: set DEEPSEEK_API_KEY=<key>'))
2204
+ break
2205
+ }
2206
+ const now = new Date()
2207
+ const yyyy = now.getFullYear()
2208
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
2209
+ const dd = String(now.getDate()).padStart(2, '0')
2210
+ const dateStr = `${yyyy}-${mm}-${dd}`
2211
+ const { spawn } = await import('child_process')
2212
+ const { fileURLToPath } = await import('url')
2213
+ const { dirname } = await import('path')
2214
+ const fn = fileURLToPath(import.meta.url)
2215
+ const pkgs = join(dirname(fn), '..')
2216
+ const pipeline = join(pkgs, 'scripts', 'pipeline.py')
2217
+ console.log(chalk.cyan(`\n📰 正在生成 ${dateStr} 公众号日报...\n`))
2218
+ const child = spawn('python', [pipeline, '--date', dateStr, '--api-key', apiKey, '--interest', 'AI', '--skip-wiki'], {
2219
+ stdio: 'inherit',
2220
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
2221
+ })
2222
+ await new Promise<void>((resolve) => child.on('exit', () => resolve()))
2223
+ break
2224
+ }
2225
+ case 'daily-server': {
2226
+ const now = new Date()
2227
+ const yyyy = now.getFullYear()
2228
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
2229
+ const dd = String(now.getDate()).padStart(2, '0')
2230
+ const dateStr = `${yyyy}-${mm}-${dd}`
2231
+ await runCmd('fav-server', { date: dateStr, port: '8765', open: true })
2232
+ break
2233
+ }
2234
+ case 'login-wechat':
2235
+ await runCmd('login-wechat', {})
2236
+ break
2237
+ case 'listen':
2238
+ await runCmd('listen', {})
2239
+ break
2240
+ case 'send': {
2241
+ const sessions = await chatService.listSessions(undefined, 30)
2242
+ if (sessions.length === 0) { console.log(chalk.gray('无会话')); break }
2243
+ const { talker } = await inquirer.prompt([{
2244
+ type: 'select' as any, name: 'talker', message: '选择接收人',
2245
+ choices: sessions.map((s, i) => ({
2246
+ name: `${String(i + 1).padStart(3)}. ${s.displayName || s.username}`,
2247
+ value: s.username,
2248
+ })),
2249
+ loop: false,
2250
+ }])
2251
+ const { message } = await inquirer.prompt([{
2252
+ type: 'input', name: 'message', message: '输入消息内容',
2253
+ }])
2254
+ if (!message.trim()) { console.log(chalk.gray('已取消')); break }
2255
+ await runCmd('send', { talker, message })
2256
+ break
2257
+ }
2258
+ case 'vault-init':
2259
+ await runSubCmd('vault', 'init', {})
2260
+ break
2261
+ case 'wiki-compile':
2262
+ await runSubCmd('wiki', 'compile', {})
2263
+ break
2264
+ case 'vault-sync':
2265
+ await runSubCmd('vault', 'sync', {})
2266
+ break
2267
+ case 'config-show':
2268
+ await runSubCmd('config', 'show')
2269
+ break
2270
+ case 'chat-stats':
2271
+ await runCmd('chat-stats', { period: 'week' })
2272
+ break
2273
+ case 'weread': {
2274
+ const wCmd = program.commands.find(c => c.name() === 'weread') as any
2275
+ const statsCmd = wCmd?.commands?.find((c: any) => c.name() === 'stats')
2276
+ if (statsCmd) await statsCmd.action({ mode: 'monthly' })
2277
+ break
2278
+ }
2279
+ case 'extract-todos': {
2280
+ const sessions = await chatService.listSessions(undefined, 30)
2281
+ if (sessions.length === 0) { console.log(chalk.gray('无会话')); break }
2282
+ const { talker } = await inquirer.prompt([{
2283
+ type: 'select' as any, name: 'talker', message: '选择联系人',
2284
+ choices: sessions.map((s, i) => ({
2285
+ name: `${String(i + 1).padStart(3)}. ${s.displayName || s.username}`,
2286
+ value: s.displayName || s.username,
2287
+ })),
2288
+ loop: false,
2289
+ }])
2290
+ await runCmd('todos', { talker, days: '7' })
2291
+ break
2292
+ }
2293
+ case 'generate-review':
2294
+ await runCmd('review', {})
2295
+ break
2296
+ case 'init':
2297
+ await runCmd('init')
2298
+ break
2299
+ }
2300
+ }
2301
+
2302
+ // 检测是否无参数启动(显示交互式菜单)
2303
+ const cliArgs = process.argv.slice(2)
2304
+ if (cliArgs.length === 0) {
2305
+ showInteractiveMenu().catch((e) => {
2306
+ console.error(chalk.red(`\n错误: ${e.message}`))
2307
+ process.exit(1)
2308
+ })
2309
+ } else {
2310
+ program.parse()
2311
+ }