weflow-cli 1.10.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +141 -0
- package/OPERATIONS.md +40 -2
- package/README.md +2 -2
- package/bin/weflow-cli.ts +23 -1
- package/dist/bin/weflow-cli.js +26 -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 +46 -0
- package/package.json +1 -1
- package/scripts/export_chat_html.py +450 -8
- package/scripts/semantic_search.py +82 -36
- package/src/services/exportService.ts +129 -12
|
@@ -182,50 +182,76 @@ def get_name_map(contact_db, contact_key, contact_salt):
|
|
|
182
182
|
return name_map
|
|
183
183
|
|
|
184
184
|
|
|
185
|
-
def collect_chat_messages(
|
|
186
|
-
"""Collect chat messages for indexing.
|
|
187
|
-
|
|
185
|
+
def collect_chat_messages(conns, name_map, days=90, per_talker=500):
|
|
186
|
+
"""Collect chat messages for indexing. `conns` 是**分片连接列表**,不是单个连接。
|
|
187
|
+
|
|
188
|
+
微信 4.x 的消息按分片存,而且**每片要自己派生的钥匙**才打得开(`nt_decrypt.
|
|
189
|
+
derive_database_key`)。本机实测四片:`message_3.db` 里是近两天的消息,而配置指向的
|
|
190
|
+
`message_0.db` 最新一条是 44 天前的。这里原来只开 `ntDbPath` 一个文件、还用裸的全局钥匙 ——
|
|
191
|
+
于是索引的聊天半边会**静默地收进旧数据**(症状与 issue #10 的"只读到半个月前"同形)。
|
|
192
|
+
索引是要花钱嵌的,收错数据比报错更贵。
|
|
193
|
+
|
|
194
|
+
三处随之改动,各有理由:
|
|
195
|
+
· 会话清单从所有分片取并集 —— 新会话的表可能只存在于新分片里;
|
|
196
|
+
· 不再 `[:100]` 截断:本机 122 个会话,而且那 100 个是**无排序取前 100**(等于随机);
|
|
197
|
+
· id 里带 `rowid`:实测一张表里 12,106 条消息只落在 12,058 个不同秒上,只按 `talker:ts`
|
|
198
|
+
去重会静默顶掉同秒的多条。
|
|
199
|
+
"""
|
|
188
200
|
now = datetime.now(TZ)
|
|
189
201
|
start_ts = int((now - timedelta(days=days)).timestamp())
|
|
190
202
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
except:
|
|
195
|
-
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
196
|
-
sessions = [r[0] for r in c.fetchall()]
|
|
197
|
-
|
|
198
|
-
items = []
|
|
199
|
-
for talker in sessions[:100]: # Limit to avoid too many
|
|
200
|
-
tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
203
|
+
sessions = []
|
|
204
|
+
for conn in conns:
|
|
205
|
+
c = conn.cursor()
|
|
201
206
|
try:
|
|
202
|
-
c.execute(
|
|
203
|
-
|
|
207
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
|
|
208
|
+
rows = [r[0] for r in c.fetchall()]
|
|
209
|
+
except Exception:
|
|
210
|
+
try:
|
|
211
|
+
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
|
|
212
|
+
rows = [r[0] for r in c.fetchall()]
|
|
213
|
+
except Exception:
|
|
214
|
+
rows = []
|
|
215
|
+
for name in rows:
|
|
216
|
+
if name not in sessions:
|
|
217
|
+
sessions.append(name)
|
|
218
|
+
|
|
219
|
+
items, seen = [], set()
|
|
220
|
+
for talker in sessions:
|
|
221
|
+
tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
222
|
+
for conn in conns:
|
|
223
|
+
c = conn.cursor()
|
|
224
|
+
try:
|
|
225
|
+
c.execute('SELECT COUNT(*) FROM sqlite_master WHERE name=?', (tbl,))
|
|
226
|
+
if c.fetchone()[0] == 0:
|
|
227
|
+
continue
|
|
228
|
+
c.execute(f'''
|
|
229
|
+
SELECT rowid, create_time, real_sender_id, message_content
|
|
230
|
+
FROM "{tbl}"
|
|
231
|
+
WHERE create_time >= ?
|
|
232
|
+
ORDER BY create_time DESC
|
|
233
|
+
LIMIT ?
|
|
234
|
+
''', (start_ts, per_talker))
|
|
235
|
+
rows = c.fetchall()
|
|
236
|
+
except Exception:
|
|
204
237
|
continue
|
|
205
|
-
|
|
206
|
-
c.execute(f'''
|
|
207
|
-
SELECT create_time, real_sender_id, message_content
|
|
208
|
-
FROM "{tbl}"
|
|
209
|
-
WHERE create_time >= ?
|
|
210
|
-
ORDER BY create_time DESC
|
|
211
|
-
LIMIT 500
|
|
212
|
-
''', (start_ts,))
|
|
213
|
-
|
|
214
|
-
for ts, sender, content in c.fetchall():
|
|
238
|
+
for rid, ts, sender, content in rows:
|
|
215
239
|
if not content or not isinstance(content, str) or len(content) < 10:
|
|
216
240
|
continue
|
|
241
|
+
item_id = f"chat:{talker}:{ts}:{rid}"
|
|
242
|
+
if item_id in seen:
|
|
243
|
+
continue
|
|
244
|
+
seen.add(item_id)
|
|
217
245
|
sender_name = name_map.get(sender, sender) if sender else name_map.get(talker, talker)
|
|
218
246
|
dt = datetime.fromtimestamp(ts, tz=TZ)
|
|
219
247
|
items.append({
|
|
220
|
-
"id":
|
|
248
|
+
"id": item_id,
|
|
221
249
|
"type": "chat",
|
|
222
250
|
"talker": name_map.get(talker, talker),
|
|
223
251
|
"sender": sender_name,
|
|
224
252
|
"time": dt.strftime('%Y-%m-%d %H:%M'),
|
|
225
253
|
"text": content[:500],
|
|
226
254
|
})
|
|
227
|
-
except:
|
|
228
|
-
pass
|
|
229
255
|
|
|
230
256
|
return items
|
|
231
257
|
|
|
@@ -325,11 +351,25 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
|
|
|
325
351
|
|
|
326
352
|
# Collect items
|
|
327
353
|
items = []
|
|
354
|
+
shards_failed = []
|
|
328
355
|
try:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
356
|
+
import nt_decrypt # 懒加载:纯逻辑路径(关键词/重排/测试)不该被它拖住
|
|
357
|
+
passphrase = decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or '')
|
|
358
|
+
pairs, failures = nt_decrypt.connect_message_shards_detailed(
|
|
359
|
+
nt_db, nt_key, nt_salt, passphrase)
|
|
360
|
+
shards_failed = [f['name'] for f in failures]
|
|
361
|
+
if shards_failed:
|
|
362
|
+
print(f"[WARN] {len(shards_failed)} 个分片读不了: {', '.join(shards_failed)}",
|
|
363
|
+
file=sys.stderr)
|
|
364
|
+
try:
|
|
365
|
+
chat_items = collect_chat_messages([c for _p, c in pairs], name_map, days=days)
|
|
366
|
+
items.extend(chat_items)
|
|
367
|
+
finally:
|
|
368
|
+
for _p, c in pairs:
|
|
369
|
+
try:
|
|
370
|
+
c.close()
|
|
371
|
+
except Exception:
|
|
372
|
+
pass
|
|
333
373
|
except Exception as e:
|
|
334
374
|
print(f"[WARN] 聊天消息收集失败: {e}", file=sys.stderr)
|
|
335
375
|
|
|
@@ -342,7 +382,8 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
|
|
|
342
382
|
|
|
343
383
|
if not new_items:
|
|
344
384
|
return {"status": "up_to_date", "total": len(items),
|
|
345
|
-
"chatDays": days, "articleDays": article_days
|
|
385
|
+
"chatDays": days, "articleDays": article_days,
|
|
386
|
+
"shardsFailed": shards_failed}
|
|
346
387
|
|
|
347
388
|
# Generate embeddings
|
|
348
389
|
print("生成 embeddings...", file=sys.stderr)
|
|
@@ -381,6 +422,9 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
|
|
|
381
422
|
"new": len(new_items),
|
|
382
423
|
"chatDays": days,
|
|
383
424
|
"articleDays": article_days,
|
|
425
|
+
# 分片读不全时**要报出来**:`shardsFailed` 是"全读了"与"读了能读到的"之差,
|
|
426
|
+
# 正是覆盖度报告存在的理由(nt_decrypt.connect_message_shards_detailed 的原话)。
|
|
427
|
+
"shardsFailed": shards_failed,
|
|
384
428
|
}
|
|
385
429
|
|
|
386
430
|
|
|
@@ -501,7 +545,9 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
|
501
545
|
try:
|
|
502
546
|
answers, _usage = client.decide(state, questions)
|
|
503
547
|
except Exception as error:
|
|
504
|
-
|
|
548
|
+
# `file=sys.stderr`:`--json` 时 stdout 是机器在解析的那一份(同文件其它 WARN 早就这样)
|
|
549
|
+
print(' [WARN] 重排失败(%s),保持原检索顺序:%s' % (type(error).__name__, error),
|
|
550
|
+
file=sys.stderr)
|
|
505
551
|
return results
|
|
506
552
|
|
|
507
553
|
scored = []
|
|
@@ -514,7 +560,7 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
|
514
560
|
scored.append((value, i, item))
|
|
515
561
|
|
|
516
562
|
if all(value is None for value, _i, _item in scored):
|
|
517
|
-
print(' [WARN] 重排没有返回任何可用分数,保持原检索顺序')
|
|
563
|
+
print(' [WARN] 重排没有返回任何可用分数,保持原检索顺序', file=sys.stderr)
|
|
518
564
|
return results
|
|
519
565
|
|
|
520
566
|
# 没拿到分数的按原序垫在最后,而不是当成 0 分——"未知"和"不相关"不是一回事。
|
|
@@ -523,7 +569,7 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
|
523
569
|
top = scored[0]
|
|
524
570
|
if best is not None and best != '候选%d' % top[1]:
|
|
525
571
|
print(' [WARN] 重排自检不一致:逐条打分最高的是候选%d,单选却答 %s'
|
|
526
|
-
% (top[1], best))
|
|
572
|
+
% (top[1], best), file=sys.stderr)
|
|
527
573
|
|
|
528
574
|
ordered = []
|
|
529
575
|
for value, i, item in scored:
|
|
@@ -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
|
|