weflow-cli 1.10.0 → 1.11.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/CHANGELOG.md +103 -0
- package/OPERATIONS.md +34 -2
- package/README.md +2 -2
- package/bin/weflow-cli.ts +16 -1
- package/dist/bin/weflow-cli.js +18 -1
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/services/exportService.d.ts +43 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +116 -11
- package/dist/src/services/exportService.js.map +1 -1
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +21 -0
- package/docs/PROJECT_STATE.md +3 -1
- package/docs/RELEASING.md +13 -0
- package/package.json +1 -1
- package/scripts/export_chat_html.py +346 -6
- package/scripts/semantic_search.py +82 -36
- package/src/services/exportService.ts +129 -12
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join, basename, dirname } from 'path'
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
3
3
|
import { execFile } from 'child_process'
|
|
4
4
|
import { promisify } from 'util'
|
|
5
5
|
import { chatService } from './chatService.js'
|
|
@@ -15,10 +15,67 @@ export type ExportContract = 'raw' | 'weflow-v1'
|
|
|
15
15
|
export interface ExportResult { success: boolean; path?: string; count?: number; error?: string }
|
|
16
16
|
|
|
17
17
|
const execFileAsync = promisify(execFile)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* md 导出落盘的媒体清单:`<output>/<talker>_assets/manifest.json`(`local_id:create_time` → 条目)。
|
|
21
|
+
*
|
|
22
|
+
* 这是"其余格式也能带图/带文件"的那一半:**md 导出抽取媒体,json/txt/excel 消费同一份清单** —— 匹配
|
|
23
|
+
* 逻辑(缓存缩略图、资源 md5、原图那档、`msg/file` 里的文档本体)只存在于 Python 那条路上,在这里重写
|
|
24
|
+
* 一遍必然与它漂移。读不到就返回空表:那说明还没跑过 md/`--media` 导出,行为与从前一致。
|
|
25
|
+
*/
|
|
26
|
+
export interface MediaRecord {
|
|
27
|
+
/** 相对输出目录的路径;`kind: 'file'` 时指向 `files/` 下的文档。 */
|
|
28
|
+
path: string
|
|
29
|
+
kind: 'image' | 'file'
|
|
30
|
+
/** 文档的原始文件名(图片没有)。 */
|
|
31
|
+
name?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 从清单里取这条消息的媒体条目。
|
|
36
|
+
*
|
|
37
|
+
* 键是 `local_id:create_time`:**local_id 会在分片之间重复**(同一会话的表可能存在于多个分片,
|
|
38
|
+
* 各片自己编号),只按 local_id 查会让 json/txt/excel 引用到别的消息的图。两条消息硬要同 id 同秒
|
|
39
|
+
* 才算撞上,那个概率可以忽略;回退查裸 local_id 是为了兼容更早写出的清单。
|
|
40
|
+
*/
|
|
41
|
+
export function mediaFor(manifest: Record<string, MediaRecord>, message: { localId?: unknown; createTime?: unknown }): MediaRecord | undefined {
|
|
42
|
+
const id = String(message.localId)
|
|
43
|
+
return manifest[`${id}:${message.createTime}`] ?? manifest[id]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readMediaManifest(outputDir: string, talker: string): Record<string, MediaRecord> {
|
|
47
|
+
try {
|
|
48
|
+
const file = join(outputDir, `${talker}_assets`, 'manifest.json')
|
|
49
|
+
if (!existsSync(file)) return {}
|
|
50
|
+
const data = JSON.parse(readFileSync(file, 'utf8'))
|
|
51
|
+
const map = data && data.messages
|
|
52
|
+
if (!map || typeof map !== 'object') return {}
|
|
53
|
+
// v1 的值是裸字符串(只有图片),v2 起是带 `kind` 的对象。升级不该让磁盘上已有的清单失效 ——
|
|
54
|
+
// 用户不会因为升了个版本就重跑一次导出。
|
|
55
|
+
const normalized: Record<string, MediaRecord> = {}
|
|
56
|
+
for (const [key, value] of Object.entries(map as Record<string, unknown>)) {
|
|
57
|
+
if (typeof value === 'string') {
|
|
58
|
+
normalized[key] = { path: value, kind: 'image' }
|
|
59
|
+
} else if (value && typeof value === 'object' && typeof (value as MediaRecord).path === 'string') {
|
|
60
|
+
const record = value as MediaRecord
|
|
61
|
+
normalized[key] = { ...record, kind: record.kind === 'file' ? 'file' : 'image' }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return normalized
|
|
65
|
+
} catch {
|
|
66
|
+
return {}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
18
69
|
function getPackageRoot(): string {
|
|
19
70
|
return resolvePackageRoot(import.meta.url)
|
|
20
71
|
}
|
|
21
72
|
|
|
73
|
+
/** txt/excel 共用的文字标签:文档要带上原名,光一个相对路径看不出那是什么东西。 */
|
|
74
|
+
function mediaLabel(record: MediaRecord | undefined): string {
|
|
75
|
+
if (!record) return ''
|
|
76
|
+
return record.kind === 'file' ? `${record.name || record.path} (${record.path})` : record.path
|
|
77
|
+
}
|
|
78
|
+
|
|
22
79
|
export class ExportService {
|
|
23
80
|
async exportJson(talker: string, outputDir: string, limit = 0, from?: number, to?: number, contract: ExportContract = 'raw'): Promise<ExportResult> {
|
|
24
81
|
try {
|
|
@@ -29,13 +86,26 @@ export class ExportService {
|
|
|
29
86
|
|
|
30
87
|
const dir = this.ensureOutputDir(outputDir)
|
|
31
88
|
const filePath = join(dir, `${talker}_messages.json`)
|
|
89
|
+
// 有清单就带上媒体相对路径(清单由 markdown 导出产生,见 readMediaManifest)
|
|
90
|
+
const mediaMap = readMediaManifest(dir, talker)
|
|
91
|
+
const withMedia = Object.keys(mediaMap).length
|
|
92
|
+
? messages.map(m => {
|
|
93
|
+
const media = mediaFor(mediaMap, m)
|
|
94
|
+
// `media` 保持"相对路径字符串"这个老形状(已有消费方按字符串读);`mediaKind`/`mediaName`
|
|
95
|
+
// 是新增的 —— 不带这两项,读 JSON 的人只能靠扩展名猜这是图片还是文档,而导出明明知道。
|
|
96
|
+
if (!media) return m
|
|
97
|
+
return media.kind === 'file'
|
|
98
|
+
? { ...m, media: media.path, mediaKind: media.kind, mediaName: media.name }
|
|
99
|
+
: { ...m, media: media.path, mediaKind: media.kind }
|
|
100
|
+
})
|
|
101
|
+
: messages
|
|
32
102
|
const payload = contract === 'weflow-v1'
|
|
33
|
-
? createWeFlowEnvelope(
|
|
103
|
+
? createWeFlowEnvelope(withMedia, new Date().toISOString(), {
|
|
34
104
|
requestedFrom: from,
|
|
35
105
|
requestedTo: to,
|
|
36
106
|
requestedLimit: limit,
|
|
37
107
|
})
|
|
38
|
-
:
|
|
108
|
+
: withMedia
|
|
39
109
|
writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8')
|
|
40
110
|
return { success: true, path: filePath, count: messages.length }
|
|
41
111
|
} catch {
|
|
@@ -50,10 +120,15 @@ export class ExportService {
|
|
|
50
120
|
return { success: false, error: '未找到消息' }
|
|
51
121
|
}
|
|
52
122
|
|
|
123
|
+
const mediaMap = readMediaManifest(this.ensureOutputDir(outputDir), talker)
|
|
53
124
|
const lines = messages.map(m => {
|
|
54
125
|
const time = new Date(m.createTime * 1000).toLocaleString('zh-CN')
|
|
55
126
|
const sender = m.isSend ? '我' : (m.senderUsername || talker)
|
|
56
|
-
|
|
127
|
+
// 有清单就把 `[图片]` / `[文件] 名字` 换成能直接打开的引用,没有就保持原样
|
|
128
|
+
const media = mediaFor(mediaMap, m)
|
|
129
|
+
const content = media
|
|
130
|
+
? (media.kind === 'file' ? `[文件: ${mediaLabel(media)}]` : `[图片: ${media.path}]`)
|
|
131
|
+
: (m.parsedContent || m.rawContent || '')
|
|
57
132
|
return `[${time}] ${sender}: ${content}`
|
|
58
133
|
})
|
|
59
134
|
|
|
@@ -66,7 +141,12 @@ export class ExportService {
|
|
|
66
141
|
}
|
|
67
142
|
}
|
|
68
143
|
|
|
69
|
-
async exportHtml(talker: string, outputDir: string, limit = 0, date = '', from?: number, to?: number, quiet = false, options: { fullImages?: boolean } = {}): Promise<ExportResult> {
|
|
144
|
+
async exportHtml(talker: string, outputDir: string, limit = 0, date = '', from?: number, to?: number, quiet = false, options: { fullImages?: boolean; format?: 'html' | 'md'; assetsOnly?: boolean } = {}): Promise<ExportResult> {
|
|
145
|
+
const format = options.format ?? 'html'
|
|
146
|
+
if (format === 'md' && (from !== undefined || to !== undefined)) {
|
|
147
|
+
// 不静默降级成「导出全部」:范围是调用方明确要的,做不到就说出来。
|
|
148
|
+
return { success: false, error: 'markdown 导出暂不支持 --from/--to:请用 --date,或整段导出' }
|
|
149
|
+
}
|
|
70
150
|
try {
|
|
71
151
|
if (from !== undefined || to !== undefined) {
|
|
72
152
|
return this.exportHtmlBasic(talker, outputDir, limit, from, to)
|
|
@@ -83,6 +163,9 @@ export class ExportService {
|
|
|
83
163
|
const passphrase = configService.get('favPassphrase') || cfg.decryptKey || ''
|
|
84
164
|
|
|
85
165
|
if (!db || !key || !salt) {
|
|
166
|
+
if (format === 'md') {
|
|
167
|
+
return { success: false, error: 'markdown 导出需要已配置的数据库与密钥(这条路没有简版回退)' }
|
|
168
|
+
}
|
|
86
169
|
// Fallback: basic HTML
|
|
87
170
|
let fallbackFrom: number | undefined = from
|
|
88
171
|
let fallbackTo: number | undefined = to
|
|
@@ -114,11 +197,16 @@ export class ExportService {
|
|
|
114
197
|
}
|
|
115
198
|
} catch {}
|
|
116
199
|
|
|
117
|
-
const args: string[] = [
|
|
118
|
-
|
|
200
|
+
const args: string[] = [script]
|
|
201
|
+
if (format === 'md') {
|
|
202
|
+
// 图片落盘 + 清单。资产目录按 **talker** 命名,好让 json/txt/excel 导出能确定性找到它
|
|
203
|
+
// (它们的文件前缀是 talker,而 md 文件名用的是显示名,不显式指定就对不上)。
|
|
204
|
+
args.push('--format', 'md', '--assets-dir', join(outputDir || './output', `${talker}_assets`))
|
|
205
|
+
if (options.assetsOnly) args.push('--assets-only')
|
|
206
|
+
} else {
|
|
119
207
|
// Paginate: a whole year of chat in one file makes the browser crawl.
|
|
120
|
-
'--per-page', '100'
|
|
121
|
-
|
|
208
|
+
args.push('--per-page', '100')
|
|
209
|
+
}
|
|
122
210
|
const ownWxid = String(cfg.wxid || '').trim()
|
|
123
211
|
const exportCacheDir = cacheDir && existsSync(cacheDir) ? cacheDir : undefined
|
|
124
212
|
const exportAccountDir = accountDir && existsSync(accountDir) ? accountDir : undefined
|
|
@@ -189,11 +277,36 @@ export class ExportService {
|
|
|
189
277
|
if (date) {
|
|
190
278
|
return { success: false, error: `指定日期导出失败: ${failure}` }
|
|
191
279
|
}
|
|
192
|
-
|
|
280
|
+
if (format === 'md') {
|
|
281
|
+
// markdown 没有「简版」:回退成 HTML 会让调用方以为拿到了 .md。失败就是失败。
|
|
282
|
+
return { success: false, error: failure }
|
|
283
|
+
}
|
|
284
|
+
// Fallback to basic HTML:**这条回退不带内嵌图片**,调用方得知道 ——
|
|
285
|
+
// 2026-10-13 实测:Python 因一个 AttributeError 退出后,这里静默回退,
|
|
286
|
+
// 而 CLI 照样打印「✓ 导出成功」,人只会以为导出正常。
|
|
287
|
+
if (!quiet) console.error(' ⚠ 已回退到简版 HTML(不含内嵌图片)')
|
|
193
288
|
return this.exportHtmlBasic(talker, outputDir, limit, from, to)
|
|
194
289
|
}
|
|
195
290
|
}
|
|
196
291
|
|
|
292
|
+
/**
|
|
293
|
+
* 只抽媒体(图片与文档落盘 + 清单),不产出正文 —— `export … json|txt|excel --media` 先跑这一步。
|
|
294
|
+
* 与 markdown 导出共用同一条 Python 通路与同一个资产目录,所以清单只有一份。
|
|
295
|
+
*
|
|
296
|
+
* `limit`/`date` 必须跟着调用方走:抽取原本固定按"整个会话"跑,而消费它的 json/txt/excel 是按
|
|
297
|
+
* `--limit` 切片的 —— `--limit 200` 于是把整段历史的媒体都抽出来(文档还要拷成文件),
|
|
298
|
+
* 实测 67 份里只有 33 份落在导出的 200 条里。
|
|
299
|
+
*/
|
|
300
|
+
async extractMedia(talker: string, outputDir: string, quiet = false, options: { fullImages?: boolean } = {}, limit = 0, date = ''): Promise<ExportResult> {
|
|
301
|
+
return this.exportHtml(talker, outputDir, limit, date, undefined, undefined, quiet,
|
|
302
|
+
{ ...options, format: 'md', assetsOnly: true })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** markdown + 图片落盘到 `<output>/<talker>_assets/`(同一条 Python 通路,换个输出形态)。 */
|
|
306
|
+
async exportMarkdown(talker: string, outputDir: string, limit = 0, date = '', from?: number, to?: number, quiet = false, options: { fullImages?: boolean } = {}): Promise<ExportResult> {
|
|
307
|
+
return this.exportHtml(talker, outputDir, limit, date, from, to, quiet, { ...options, format: 'md' })
|
|
308
|
+
}
|
|
309
|
+
|
|
197
310
|
private async exportHtmlBasic(talker: string, outputDir: string, limit = 0, from?: number, to?: number): Promise<ExportResult> {
|
|
198
311
|
const messages = await chatService.getMessagesInRange(talker, limit, from, to)
|
|
199
312
|
if (messages.length === 0) {
|
|
@@ -228,16 +341,20 @@ export class ExportService {
|
|
|
228
341
|
{ header: '发送者', key: 'sender', width: 15 },
|
|
229
342
|
{ header: '类型', key: 'type', width: 10 },
|
|
230
343
|
{ header: '内容', key: 'content', width: 80 },
|
|
231
|
-
{ header: '消息ID', key: 'msgId', width: 15 }
|
|
344
|
+
{ header: '消息ID', key: 'msgId', width: 15 },
|
|
345
|
+
// 有清单时填相对路径(清单由 markdown 导出产生)
|
|
346
|
+
{ header: '媒体', key: 'media', width: 40 }
|
|
232
347
|
]
|
|
233
348
|
|
|
349
|
+
const mediaMap = readMediaManifest(this.ensureOutputDir(outputDir), talker)
|
|
234
350
|
for (const m of messages) {
|
|
235
351
|
sheet.addRow({
|
|
236
352
|
time: new Date(m.createTime * 1000).toLocaleString('zh-CN'),
|
|
237
353
|
sender: m.isSend ? '我' : (m.senderUsername || talker),
|
|
238
354
|
type: this.getMessageTypeName(m.localType),
|
|
239
355
|
content: m.parsedContent || m.rawContent || '',
|
|
240
|
-
msgId: m.localId
|
|
356
|
+
msgId: m.localId,
|
|
357
|
+
media: mediaLabel(mediaFor(mediaMap, m))
|
|
241
358
|
})
|
|
242
359
|
}
|
|
243
360
|
|