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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/weflow-cli-electron.cjs +59 -0
- package/bin/weflow-cli.ts +2311 -0
- package/cli.cjs +3 -0
- package/dist/bin/weflow-cli.d.ts +3 -0
- package/dist/bin/weflow-cli.d.ts.map +1 -0
- package/dist/bin/weflow-cli.js +2270 -0
- package/dist/bin/weflow-cli.js.map +1 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.d.ts.map +1 -0
- package/dist/mcp-server/index.js +690 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/core/dbPathService.d.ts +40 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -0
- package/dist/src/core/dbPathService.js +324 -0
- package/dist/src/core/dbPathService.js.map +1 -0
- package/dist/src/core/keyService.d.ts +59 -0
- package/dist/src/core/keyService.d.ts.map +1 -0
- package/dist/src/core/keyService.js +368 -0
- package/dist/src/core/keyService.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +62 -0
- package/dist/src/core/ntCore.d.ts.map +1 -0
- package/dist/src/core/ntCore.js +191 -0
- package/dist/src/core/ntCore.js.map +1 -0
- package/dist/src/core/sqlcipherCore.d.ts +119 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
- package/dist/src/core/sqlcipherCore.js +693 -0
- package/dist/src/core/sqlcipherCore.js.map +1 -0
- package/dist/src/core/wcdbCore.d.ts +738 -0
- package/dist/src/core/wcdbCore.d.ts.map +1 -0
- package/dist/src/core/wcdbCore.js +4545 -0
- package/dist/src/core/wcdbCore.js.map +1 -0
- package/dist/src/core/wechatClient.d.ts +23 -0
- package/dist/src/core/wechatClient.d.ts.map +1 -0
- package/dist/src/core/wechatClient.js +166 -0
- package/dist/src/core/wechatClient.js.map +1 -0
- package/dist/src/services/chatService.d.ts +25 -0
- package/dist/src/services/chatService.d.ts.map +1 -0
- package/dist/src/services/chatService.js +291 -0
- package/dist/src/services/chatService.js.map +1 -0
- package/dist/src/services/configService.d.ts +43 -0
- package/dist/src/services/configService.d.ts.map +1 -0
- package/dist/src/services/configService.js +156 -0
- package/dist/src/services/configService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +30 -0
- package/dist/src/services/exportService.d.ts.map +1 -0
- package/dist/src/services/exportService.js +273 -0
- package/dist/src/services/exportService.js.map +1 -0
- package/dist/src/services/wechat-formatter.d.ts +69 -0
- package/dist/src/services/wechat-formatter.d.ts.map +1 -0
- package/dist/src/services/wechat-formatter.js +248 -0
- package/dist/src/services/wechat-formatter.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +28 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -0
- package/dist/src/services/wechatMessageService.js +341 -0
- package/dist/src/services/wechatMessageService.js.map +1 -0
- package/dist/src/services/wereadService.d.ts +206 -0
- package/dist/src/services/wereadService.d.ts.map +1 -0
- package/dist/src/services/wereadService.js +145 -0
- package/dist/src/services/wereadService.js.map +1 -0
- package/dist/src/services/whitelistService.d.ts +20 -0
- package/dist/src/services/whitelistService.d.ts.map +1 -0
- package/dist/src/services/whitelistService.js +60 -0
- package/dist/src/services/whitelistService.js.map +1 -0
- package/dist/src/types.d.ts +140 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/errors.d.ts +25 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +43 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/pathUtils.d.ts +5 -0
- package/dist/src/utils/pathUtils.d.ts.map +1 -0
- package/dist/src/utils/pathUtils.js +16 -0
- package/dist/src/utils/pathUtils.js.map +1 -0
- package/dist/src/utils/pythonRunner.d.ts +27 -0
- package/dist/src/utils/pythonRunner.d.ts.map +1 -0
- package/dist/src/utils/pythonRunner.js +62 -0
- package/dist/src/utils/pythonRunner.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +14 -0
- package/dist/src/utils/talkerUtils.d.ts.map +1 -0
- package/dist/src/utils/talkerUtils.js +73 -0
- package/dist/src/utils/talkerUtils.js.map +1 -0
- package/mcp-server/index.ts +757 -0
- package/package.json +72 -0
- package/resources/key/win32/x64/wx_key.dll +0 -0
- package/resources/wcdb/win32/x64/SDL2.dll +0 -0
- package/resources/wcdb/win32/x64/WCDB.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
- package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
- package/scripts/_batch_link_sources.py +73 -0
- package/scripts/_utils.py +531 -0
- package/scripts/annual_report.py +796 -0
- package/scripts/auto_tag.py +128 -0
- package/scripts/biz_daily.py +896 -0
- package/scripts/chat_report.py +397 -0
- package/scripts/chat_stats.py +702 -0
- package/scripts/classify_daily.py +411 -0
- package/scripts/compile_wiki.py +258 -0
- package/scripts/create_reading_notes.py +370 -0
- package/scripts/enrich_backlinks.py +163 -0
- package/scripts/export_chat_html.py +766 -0
- package/scripts/extract_3x_key.py +86 -0
- package/scripts/extract_todos.py +404 -0
- package/scripts/fav_server.py +392 -0
- package/scripts/fix_topics.py +111 -0
- package/scripts/generate_ai_report.py +367 -0
- package/scripts/generate_html.py +1372 -0
- package/scripts/generate_review.py +170 -0
- package/scripts/mcp_bridge.py +398 -0
- package/scripts/nt_decrypt.py +562 -0
- package/scripts/pipeline.py +145 -0
- package/scripts/promote_all.py +310 -0
- package/scripts/promote_ideas.py +297 -0
- package/scripts/rag_chat.py +191 -0
- package/scripts/scan_decrypt_4x.py +329 -0
- package/scripts/semantic_search.py +431 -0
- package/scripts/sync_fav.py +146 -0
- package/scripts/sync_weread.py +192 -0
- package/scripts/vault_rag.py +139 -0
- package/scripts/vault_search.py +141 -0
- package/src/core/dbPathService.ts +348 -0
- package/src/core/keyService.ts +409 -0
- package/src/core/ntCore.ts +224 -0
- package/src/core/sqlcipherCore.ts +764 -0
- package/src/core/wcdbCore.ts +4568 -0
- package/src/core/wechatClient.ts +204 -0
- package/src/lz4.d.ts +10 -0
- package/src/services/chatService.ts +296 -0
- package/src/services/configService.ts +197 -0
- package/src/services/exportService.ts +305 -0
- package/src/services/wechat-formatter.ts +348 -0
- package/src/services/wechatMessageService.ts +383 -0
- package/src/services/wereadService.ts +308 -0
- package/src/services/whitelistService.ts +72 -0
- package/src/types.ts +141 -0
- package/src/utils/errors.ts +42 -0
- package/src/utils/pathUtils.ts +16 -0
- package/src/utils/pythonRunner.ts +81 -0
- package/src/utils/talkerUtils.ts +84 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
3
|
+
import { homedir, hostname, userInfo } from 'os'
|
|
4
|
+
import crypto from 'crypto'
|
|
5
|
+
import { expandHomePath } from '../utils/pathUtils.js'
|
|
6
|
+
import type { ConfigData } from '../types.js'
|
|
7
|
+
|
|
8
|
+
const LOCK_PREFIX = 'lock:'
|
|
9
|
+
|
|
10
|
+
interface CliConfig {
|
|
11
|
+
dbPath: string
|
|
12
|
+
wxid: string
|
|
13
|
+
decryptKey: string
|
|
14
|
+
decryptKey3x: string
|
|
15
|
+
dataVersion: string
|
|
16
|
+
dbPath3x: string
|
|
17
|
+
ntDbPath: string
|
|
18
|
+
ntKey: string
|
|
19
|
+
ntSalt: string
|
|
20
|
+
contactDbPath: string
|
|
21
|
+
contactKey: string
|
|
22
|
+
contactSalt: string
|
|
23
|
+
// 微信消息通道
|
|
24
|
+
wechatOcToken: string
|
|
25
|
+
wechatOcAccountId: string
|
|
26
|
+
wechatOcBaseUrl: string
|
|
27
|
+
wechatOcSyncBuf: string
|
|
28
|
+
// 白名单
|
|
29
|
+
whitelist: string[]
|
|
30
|
+
// Vault & Pipeline
|
|
31
|
+
vaultRepo: string
|
|
32
|
+
aiEngine: string
|
|
33
|
+
// 微信读书
|
|
34
|
+
wereadApiKey: string
|
|
35
|
+
// AI 引擎 API Key
|
|
36
|
+
deepseekApiKey: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 需要加密存储的字段 */
|
|
40
|
+
const ENCRYPTED_KEYS: ReadonlySet<keyof CliConfig> = new Set([
|
|
41
|
+
'decryptKey', 'decryptKey3x', 'ntKey', 'contactKey',
|
|
42
|
+
'wechatOcToken', 'wereadApiKey', 'deepseekApiKey',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
const CONFIG_DIR = join(homedir(), '.weflow-cli')
|
|
46
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
|
47
|
+
|
|
48
|
+
export class ConfigService {
|
|
49
|
+
private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', whitelist: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '' }
|
|
50
|
+
|
|
51
|
+
constructor() {
|
|
52
|
+
this.load()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private load(): void {
|
|
56
|
+
try {
|
|
57
|
+
if (existsSync(CONFIG_FILE)) {
|
|
58
|
+
const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
|
|
59
|
+
this.config = {
|
|
60
|
+
dbPath: data.dbPath || '',
|
|
61
|
+
wxid: data.wxid || '',
|
|
62
|
+
decryptKey: data.decryptKey || '',
|
|
63
|
+
decryptKey3x: data.decryptKey3x || '',
|
|
64
|
+
dataVersion: data.dataVersion || '',
|
|
65
|
+
dbPath3x: data.dbPath3x || '',
|
|
66
|
+
ntDbPath: data.ntDbPath || '',
|
|
67
|
+
ntKey: data.ntKey || '',
|
|
68
|
+
ntSalt: data.ntSalt || '',
|
|
69
|
+
contactDbPath: data.contactDbPath || '',
|
|
70
|
+
contactKey: data.contactKey || '',
|
|
71
|
+
contactSalt: data.contactSalt || '',
|
|
72
|
+
wechatOcToken: data.wechatOcToken || '',
|
|
73
|
+
wechatOcAccountId: data.wechatOcAccountId || '',
|
|
74
|
+
wechatOcBaseUrl: data.wechatOcBaseUrl || '',
|
|
75
|
+
wechatOcSyncBuf: data.wechatOcSyncBuf || '',
|
|
76
|
+
whitelist: Array.isArray(data.whitelist) ? data.whitelist : [],
|
|
77
|
+
vaultRepo: data.vaultRepo || '',
|
|
78
|
+
aiEngine: data.aiEngine || 'deepseek',
|
|
79
|
+
wereadApiKey: data.wereadApiKey || '',
|
|
80
|
+
deepseekApiKey: data.deepseekApiKey || '',
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// 配置文件损坏,输出警告并使用默认值
|
|
85
|
+
console.warn(`[weflow-cli] 配置文件解析失败 (${CONFIG_FILE}): ${e instanceof Error ? e.message : e},将使用默认配置`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private save(): void {
|
|
90
|
+
try {
|
|
91
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
92
|
+
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
93
|
+
}
|
|
94
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(this.config, null, 2), 'utf8')
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error('保存配置失败:', e)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get<K extends keyof CliConfig>(key: K): CliConfig[K] {
|
|
101
|
+
const raw = this.config[key]
|
|
102
|
+
if (ENCRYPTED_KEYS.has(key) && typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
|
|
103
|
+
return this.lockDecrypt(raw) as CliConfig[K]
|
|
104
|
+
}
|
|
105
|
+
if ((key === 'dbPath' || key === 'dbPath3x' || key === 'ntDbPath') && typeof raw === 'string') {
|
|
106
|
+
return expandHomePath(raw) as CliConfig[K]
|
|
107
|
+
}
|
|
108
|
+
return raw
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
set<K extends keyof CliConfig>(key: K, value: CliConfig[K]): void {
|
|
112
|
+
if (ENCRYPTED_KEYS.has(key) && typeof value === 'string' && value) {
|
|
113
|
+
this.config[key] = this.lockEncrypt(value) as CliConfig[K]
|
|
114
|
+
} else if ((key === 'dbPath' || key === 'dbPath3x' || key === 'ntDbPath') && typeof value === 'string') {
|
|
115
|
+
this.config[key] = expandHomePath(value) as CliConfig[K]
|
|
116
|
+
} else {
|
|
117
|
+
this.config[key] = value
|
|
118
|
+
}
|
|
119
|
+
this.save()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getAll(): ConfigData {
|
|
123
|
+
return {
|
|
124
|
+
dbPath: this.get('dbPath'),
|
|
125
|
+
wxid: this.get('wxid'),
|
|
126
|
+
decryptKey: this.get('decryptKey'),
|
|
127
|
+
decryptKey3x: this.get('decryptKey3x'),
|
|
128
|
+
dataVersion: (this.config.dataVersion || '4.x') as ConfigData['dataVersion'],
|
|
129
|
+
dbPath3x: this.get('dbPath3x'),
|
|
130
|
+
ntDbPath: this.config.ntDbPath,
|
|
131
|
+
ntKey: this.get('ntKey'),
|
|
132
|
+
ntSalt: this.config.ntSalt,
|
|
133
|
+
contactDbPath: this.config.contactDbPath,
|
|
134
|
+
contactKey: this.get('contactKey'),
|
|
135
|
+
contactSalt: this.config.contactSalt,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getWhitelist(): string[] {
|
|
140
|
+
return this.config.whitelist || []
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setWhitelist(list: string[]): void {
|
|
144
|
+
this.config.whitelist = list
|
|
145
|
+
this.save()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
isConfigured(): boolean {
|
|
149
|
+
const has4x = !!(this.config.dbPath && this.config.decryptKey)
|
|
150
|
+
const has3x = !!(this.config.dbPath3x && this.config.decryptKey3x)
|
|
151
|
+
const hasNt = !!(this.config.ntDbPath && this.config.ntKey && this.config.ntSalt)
|
|
152
|
+
return has4x || has3x || hasNt
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
clear(): void {
|
|
156
|
+
this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', whitelist: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '' }
|
|
157
|
+
this.save()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private lockEncrypt(plaintext: string): string {
|
|
161
|
+
const machineId = this.getMachineId()
|
|
162
|
+
const salt = crypto.randomBytes(16)
|
|
163
|
+
const iv = crypto.randomBytes(12)
|
|
164
|
+
const derivedKey = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256')
|
|
165
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
|
|
166
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
167
|
+
const authTag = cipher.getAuthTag()
|
|
168
|
+
const combined = Buffer.concat([salt, iv, authTag, encrypted])
|
|
169
|
+
return LOCK_PREFIX + combined.toString('base64')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private lockDecrypt(stored: string): string {
|
|
173
|
+
if (!stored || !stored.startsWith(LOCK_PREFIX)) return stored
|
|
174
|
+
try {
|
|
175
|
+
const machineId = this.getMachineId()
|
|
176
|
+
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
|
|
177
|
+
const salt = combined.subarray(0, 16)
|
|
178
|
+
const iv = combined.subarray(16, 28)
|
|
179
|
+
const authTag = combined.subarray(28, 44)
|
|
180
|
+
const ciphertext = combined.subarray(44)
|
|
181
|
+
const derivedKey = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256')
|
|
182
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
|
|
183
|
+
decipher.setAuthTag(authTag)
|
|
184
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
185
|
+
return decrypted.toString('utf8')
|
|
186
|
+
} catch {
|
|
187
|
+
return ''
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private getMachineId(): string {
|
|
192
|
+
// 使用机器名 + 用户名作为机器标识,足够绑定到单台机器
|
|
193
|
+
return `${hostname()}-${userInfo().username}-weflow-cli`
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const configService = new ConfigService()
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { join, basename, dirname } from 'path'
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
|
3
|
+
import { execFile } from 'child_process'
|
|
4
|
+
import { promisify } from 'util'
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
import { chatService } from './chatService.js'
|
|
7
|
+
import { configService } from './configService.js'
|
|
8
|
+
import type { Message } from '../types.js'
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile)
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
12
|
+
const __dirname = dirname(__filename)
|
|
13
|
+
|
|
14
|
+
export class ExportService {
|
|
15
|
+
async exportJson(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
16
|
+
try {
|
|
17
|
+
const messages = await chatService.getMessages(talker, limit)
|
|
18
|
+
if (messages.length === 0) {
|
|
19
|
+
return { success: false, error: '未找到消息' }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dir = this.ensureOutputDir(outputDir)
|
|
23
|
+
const filePath = join(dir, `${talker}_messages.json`)
|
|
24
|
+
writeFileSync(filePath, JSON.stringify(messages, null, 2), 'utf8')
|
|
25
|
+
return { success: true, path: filePath }
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return { success: false, error: String(e) }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async exportTxt(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
32
|
+
try {
|
|
33
|
+
const messages = await chatService.getMessages(talker, limit)
|
|
34
|
+
if (messages.length === 0) {
|
|
35
|
+
return { success: false, error: '未找到消息' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const lines = messages.map(m => {
|
|
39
|
+
const time = new Date(m.createTime * 1000).toLocaleString('zh-CN')
|
|
40
|
+
const sender = m.isSend ? '我' : (m.senderUsername || talker)
|
|
41
|
+
const content = m.parsedContent || m.rawContent || ''
|
|
42
|
+
return `[${time}] ${sender}: ${content}`
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const dir = this.ensureOutputDir(outputDir)
|
|
46
|
+
const filePath = join(dir, `${talker}_messages.txt`)
|
|
47
|
+
writeFileSync(filePath, lines.join('\n'), 'utf8')
|
|
48
|
+
return { success: true, path: filePath }
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { success: false, error: String(e) }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async exportHtml(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
55
|
+
try {
|
|
56
|
+
// Use Python export_chat_html.py for rich HTML with images
|
|
57
|
+
const cfg = configService.getAll()
|
|
58
|
+
const db = cfg.ntDbPath
|
|
59
|
+
const key = cfg.ntKey
|
|
60
|
+
const salt = cfg.ntSalt
|
|
61
|
+
|
|
62
|
+
if (!db || !key || !salt) {
|
|
63
|
+
// Fallback: basic HTML
|
|
64
|
+
return this.exportHtmlBasic(talker, outputDir, limit)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Resolve Python script path (dist/src/services → package root)
|
|
68
|
+
const pkgRoot = join(__dirname, '..', '..', '..')
|
|
69
|
+
const script = join(pkgRoot, 'scripts', 'export_chat_html.py')
|
|
70
|
+
|
|
71
|
+
// Cache dir for image thumbnails
|
|
72
|
+
const cacheDir = cfg.contactDbPath
|
|
73
|
+
? join(dirname(dirname(cfg.contactDbPath)), 'cache')
|
|
74
|
+
: ''
|
|
75
|
+
|
|
76
|
+
// Resolve display name for filename (prefer remark/nickname over wxid)
|
|
77
|
+
let displayName = ''
|
|
78
|
+
try {
|
|
79
|
+
const sessions = await chatService.listSessions()
|
|
80
|
+
const match = sessions.find(s => s.username === talker)
|
|
81
|
+
if (match?.displayName && match.displayName !== talker) {
|
|
82
|
+
displayName = match.displayName
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
|
|
86
|
+
const args: string[] = [
|
|
87
|
+
script,
|
|
88
|
+
'--db', db,
|
|
89
|
+
'--key', key,
|
|
90
|
+
'--salt', salt,
|
|
91
|
+
'--talker', talker,
|
|
92
|
+
'--out', outputDir || `./output`,
|
|
93
|
+
'--single',
|
|
94
|
+
'--parts', '1',
|
|
95
|
+
]
|
|
96
|
+
if (displayName) {
|
|
97
|
+
args.push('--name', displayName)
|
|
98
|
+
}
|
|
99
|
+
if (cacheDir && existsSync(cacheDir)) {
|
|
100
|
+
args.push('--cache-dir', cacheDir)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(` Exporting HTML via Python...`)
|
|
104
|
+
const { stdout } = await execFileAsync('python', args, {
|
|
105
|
+
timeout: 300_000,
|
|
106
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Parse JSON result from Python output
|
|
110
|
+
const lines = stdout.split('\n').filter((l: string) => l.trim())
|
|
111
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
112
|
+
try {
|
|
113
|
+
const result = JSON.parse(lines[i])
|
|
114
|
+
if (result.success && result.files?.length > 0) {
|
|
115
|
+
return { success: true, path: result.files[0] }
|
|
116
|
+
}
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { success: false, error: 'Python export succeeded but no output found' }
|
|
121
|
+
} catch (e: any) {
|
|
122
|
+
console.error(` Python export failed: ${e.message}`)
|
|
123
|
+
// Fallback to basic HTML
|
|
124
|
+
return this.exportHtmlBasic(talker, outputDir, limit)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async exportHtmlBasic(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
129
|
+
const messages = await chatService.getMessages(talker, limit)
|
|
130
|
+
if (messages.length === 0) {
|
|
131
|
+
return { success: false, error: '未找到消息' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const html = this.buildHtml(talker, messages)
|
|
135
|
+
const dir = this.ensureOutputDir(outputDir)
|
|
136
|
+
const filePath = join(dir, `${talker}_messages.html`)
|
|
137
|
+
writeFileSync(filePath, html, 'utf8')
|
|
138
|
+
return { success: true, path: filePath }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async exportExcel(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
142
|
+
try {
|
|
143
|
+
const ExcelJS = await import('exceljs')
|
|
144
|
+
const messages = await chatService.getMessages(talker, limit)
|
|
145
|
+
if (messages.length === 0) {
|
|
146
|
+
return { success: false, error: '未找到消息' }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const workbook = new ExcelJS.Workbook()
|
|
150
|
+
const sheet = workbook.addWorksheet('聊天记录')
|
|
151
|
+
|
|
152
|
+
sheet.columns = [
|
|
153
|
+
{ header: '时间', key: 'time', width: 20 },
|
|
154
|
+
{ header: '发送者', key: 'sender', width: 15 },
|
|
155
|
+
{ header: '类型', key: 'type', width: 10 },
|
|
156
|
+
{ header: '内容', key: 'content', width: 80 },
|
|
157
|
+
{ header: '消息ID', key: 'msgId', width: 15 }
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
for (const m of messages) {
|
|
161
|
+
sheet.addRow({
|
|
162
|
+
time: new Date(m.createTime * 1000).toLocaleString('zh-CN'),
|
|
163
|
+
sender: m.isSend ? '我' : (m.senderUsername || talker),
|
|
164
|
+
type: this.getMessageTypeName(m.localType),
|
|
165
|
+
content: m.parsedContent || m.rawContent || '',
|
|
166
|
+
msgId: m.localId
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const dir = this.ensureOutputDir(outputDir)
|
|
171
|
+
const filePath = join(dir, `${talker}_messages.xlsx`)
|
|
172
|
+
await workbook.xlsx.writeFile(filePath)
|
|
173
|
+
return { success: true, path: filePath }
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return { success: false, error: String(e) }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private buildHtml(talker: string, messages: Message[]): string {
|
|
180
|
+
const rows = messages.map(m => {
|
|
181
|
+
const time = new Date(m.createTime * 1000).toLocaleString('zh-CN')
|
|
182
|
+
const sender = m.isSend ? '我' : this.escapeHtml(m.senderUsername || talker)
|
|
183
|
+
const contentType = m.localType
|
|
184
|
+
const contentHtml = this.renderMessageContent(m)
|
|
185
|
+
const align = m.isSend ? 'right' : 'left'
|
|
186
|
+
const bgColor = m.isSend ? '#95ec69' : '#ffffff'
|
|
187
|
+
|
|
188
|
+
return `<div style="text-align:${align};margin:8px 0;">
|
|
189
|
+
<div style="display:inline-block;background:${bgColor};padding:8px 12px;border-radius:8px;max-width:80%;">
|
|
190
|
+
<div style="font-size:12px;color:#999;">${sender} · ${time}</div>
|
|
191
|
+
<div style="margin-top:4px;white-space:pre-wrap;word-break:break-all;">${contentHtml}</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>`
|
|
194
|
+
}).join('\n')
|
|
195
|
+
|
|
196
|
+
return `<!DOCTYPE html>
|
|
197
|
+
<html lang="zh-CN">
|
|
198
|
+
<head>
|
|
199
|
+
<meta charset="UTF-8">
|
|
200
|
+
<title>聊天记录 - ${this.escapeHtml(talker)}</title>
|
|
201
|
+
<style>
|
|
202
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f0f0; padding: 20px; }
|
|
203
|
+
.container { max-width: 820px; margin: 0 auto; background: #f5f5f5; padding: 20px; border-radius: 12px; }
|
|
204
|
+
.msg-image { color:#888; }
|
|
205
|
+
.msg-image img { max-width:240px; max-height:240px; border-radius:4px; margin-top:6px; display:block; }
|
|
206
|
+
.msg-app { margin:0; }
|
|
207
|
+
.msg-app .app-title { font-size:14px; font-weight:600; color:#333; }
|
|
208
|
+
.msg-app .app-desc { font-size:12px; color:#999; margin-top:2px; }
|
|
209
|
+
.msg-app .app-url { display:block; margin-top:6px; padding:6px 10px; background:#f0f0f0; border-left:3px solid #07c160; text-decoration:none; color:#576b95; border-radius:0 4px 4px 0; font-size:13px; }
|
|
210
|
+
.msg-app .app-file { color:#07c160; font-weight:600; }
|
|
211
|
+
.msg-sys { color:#bbb; font-size:13px; }
|
|
212
|
+
.msg-media { color:#888; }
|
|
213
|
+
</style>
|
|
214
|
+
</head>
|
|
215
|
+
<body>
|
|
216
|
+
<div class="container">
|
|
217
|
+
<h2 style="text-align:center;">聊天记录 - ${this.escapeHtml(talker)}</h2>
|
|
218
|
+
<p style="text-align:center;color:#999;">共 ${messages.length} 条消息</p>
|
|
219
|
+
${rows}
|
|
220
|
+
</div>
|
|
221
|
+
</body>
|
|
222
|
+
</html>`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private renderMessageContent(m: Message): string {
|
|
226
|
+
switch (m.localType) {
|
|
227
|
+
case 1:
|
|
228
|
+
return this.escapeHtml(m.parsedContent || m.rawContent || '')
|
|
229
|
+
|
|
230
|
+
case 3: {
|
|
231
|
+
let html = `<span class="msg-media">[图片]</span>`
|
|
232
|
+
if (m.imageFileName) {
|
|
233
|
+
html = `<span class="msg-media">[图片: ${this.escapeHtml(m.imageFileName)}]</span>`
|
|
234
|
+
}
|
|
235
|
+
if (m.imageBase64) {
|
|
236
|
+
html += `<br><img src="data:image/jpeg;base64,${m.imageBase64}" />`
|
|
237
|
+
}
|
|
238
|
+
return html
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
case 34:
|
|
242
|
+
return '<span class="msg-media">[语音]</span>'
|
|
243
|
+
|
|
244
|
+
case 43:
|
|
245
|
+
return '<span class="msg-media">[视频]</span>'
|
|
246
|
+
|
|
247
|
+
case 47:
|
|
248
|
+
return this.escapeHtml(m.parsedContent || '[表情]')
|
|
249
|
+
|
|
250
|
+
case 49: {
|
|
251
|
+
let html = '<div class="msg-app">'
|
|
252
|
+
if (m.appTitle) {
|
|
253
|
+
html += `<div class="app-title">${this.escapeHtml(m.appTitle)}</div>`
|
|
254
|
+
}
|
|
255
|
+
if (m.appDescription) {
|
|
256
|
+
html += `<div class="app-desc">${this.escapeHtml(m.appDescription)}</div>`
|
|
257
|
+
}
|
|
258
|
+
if (m.appUrl) {
|
|
259
|
+
html += `<a class="app-url" href="${this.escapeHtml(m.appUrl)}" target="_blank">${this.escapeHtml(m.appUrl)}</a>`
|
|
260
|
+
}
|
|
261
|
+
html += '</div>'
|
|
262
|
+
if (!m.appTitle && !m.appDescription && !m.appUrl) {
|
|
263
|
+
html = this.escapeHtml(m.parsedContent || '[链接/文件]')
|
|
264
|
+
}
|
|
265
|
+
return html
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 50:
|
|
269
|
+
return '<span class="msg-media">[语音通话]</span>'
|
|
270
|
+
|
|
271
|
+
case 10000:
|
|
272
|
+
return `<span class="msg-sys">${this.escapeHtml(m.parsedContent || '')}</span>`
|
|
273
|
+
|
|
274
|
+
default:
|
|
275
|
+
return this.escapeHtml(m.parsedContent || m.rawContent || '')
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private escapeHtml(str: string): string {
|
|
280
|
+
return str
|
|
281
|
+
.replace(/&/g, '&')
|
|
282
|
+
.replace(/</g, '<')
|
|
283
|
+
.replace(/>/g, '>')
|
|
284
|
+
.replace(/"/g, '"')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private getMessageTypeName(type: number): string {
|
|
288
|
+
const map: Record<number, string> = {
|
|
289
|
+
1: '文本', 3: '图片', 34: '语音', 42: '名片',
|
|
290
|
+
43: '视频', 47: '表情', 48: '位置', 49: '链接/文件',
|
|
291
|
+
50: '语音通话', 10000: '系统', 10002: '引用'
|
|
292
|
+
}
|
|
293
|
+
return map[type] || `类型${type}`
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private ensureOutputDir(dir: string): string {
|
|
297
|
+
const resolved = dir || './output'
|
|
298
|
+
if (!existsSync(resolved)) {
|
|
299
|
+
mkdirSync(resolved, { recursive: true })
|
|
300
|
+
}
|
|
301
|
+
return resolved
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export const exportService = new ExportService()
|