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 CHANGED
@@ -17,6 +17,147 @@ purpose** - a hand-written "latest is X" is exactly the claim that goes stale he
17
17
 
18
18
  All notable user-facing changes are recorded here. This project follows [Semantic Versioning](https://semver.org/).
19
19
 
20
+ ## 1.11.1
21
+
22
+ - **Five defects in 1.11.0's document export, all found by looking at a real export instead of the code.**
23
+ Re-exporting one 1,914-message conversation (29 documents on disk) and asking whether what got written can
24
+ actually be **opened** turned up two files that would have been unopenable in the reader's own editor:
25
+
26
+ 1. A document whose name contains a half-width parenthesis - `…开题报告(2).pdf` - landed in the link target
27
+ as-is, and **every markdown renderer ends a target at the first `)`**, so the link stopped at `…报告(2`
28
+ and opened nothing. A space breaks it the same way (the target ends at whitespace). Targets are now
29
+ percent-encoded per path segment (`%282%29`, `%20`) while the link text stays the original name and
30
+ Chinese characters stay literal - `%E5%…` would be correct but unreadable.
31
+ 2. The 80-character filename limit was applied to the whole name, so a long title lost its **extension**:
32
+ one file landed as `Chang 等 - 2022 - Environmental Justice Assessment of Fine Particles, Ozone, and`
33
+ with no `.pdf`, leaving the OS and every editor without a clue how to open it. The limit now applies to
34
+ the stem, with the extension kept.
35
+
36
+ Both were **invisible to the code and to the existing tests** - the first pass "worked" and produced a
37
+ complete-looking asset directory. Hunting for the rest of that class turned up three more:
38
+
39
+ 3. **A filename containing a control character killed the whole export at the last step.** A tab in a message
40
+ title makes `open()` raise `OSError: Invalid argument` on Windows, and nothing caught it - minutes of
41
+ parsing and image extraction were thrown away by one bad name. Copy failures (bad name, path too long,
42
+ target locked) are now counted as `unavailable` instead. Control characters are also stripped from the
43
+ on-disk name, the link text, and `sanitize_filename` generally.
44
+ 4. **Windows reserved device names** (`CON.pdf`, `NUL.txt`, `aux.docx`) cannot be created at all; they are now
45
+ prefixed with `_`. Checked alongside: 200-character titles, `[草稿]` brackets, `#`, `%`, quotes, emoji,
46
+ trailing dots, `|*?` - a battery of 21 real-world names, with every resulting link asserted to *resolve*.
47
+ 5. **A narrower export silently erased a wider one's manifest.** The manifest lives in the shared
48
+ `<talker>_assets/` directory, so exporting the whole conversation and then running
49
+ `json --media --limit 600` into the same folder overwrote 585 entries with 150 - the wider run's documents
50
+ kept their links in the markdown but vanished from the manifest, and neither side reported anything. The
51
+ manifest is now **merged** with the existing one (entries whose file is gone are dropped), and it records
52
+ both `copied` (this run) and `exported` (present in the directory), because those were being conflated.
53
+
54
+ One more, not a defect but the same "silent" shape: `--media` together with `html` did nothing at all (HTML
55
+ writes no document bodies by design, so that is not a missing feature) and said nothing, which reads as a
56
+ broken switch. The CLI now names the reason and points at `md` / `json --media`.
57
+
58
+ ## 1.11.0
59
+
60
+ - **Documents now travel with the export — all five formats, bodies included.** The same user report that asked
61
+ about images asked about *documents*; images got fixed, documents had not, and no format exported them (the page
62
+ showed `[文件] <name>` and stopped there). A file message carries only a name and an md5 — the body lives under
63
+ the account directory's `msg/file/` — so the export gained a **stat-only index** of that tree and matches on
64
+ `(normalized name, byte size)`, falling back to md5 only when several candidates share a name. Measured here:
65
+ 1,855 file messages, 1,489 unique matches, 154 resolved by md5, **212 with no local copy** (never downloaded or
66
+ already cleaned) — **88.6%**, and the remaining 11.4% is *counted*, not guessed. Bodies are copied into
67
+ `<output>/<talker>_assets/files/` under their **original name and extension**, and markdown links to them by the
68
+ original file name (an image embed would render as a broken picture); `json`/`txt`/`excel` carry the same through
69
+ the manifest (`media` + `mediaKind`/`mediaName`, `[文件: name]`, the 「媒体」 column). HTML deliberately does not
70
+ embed them — base64-inlining a 3 MB PDF into a page nobody can open is not "supporting documents". Verified end
71
+ to end on a 600-message slice: 61 documents / 27 MB on disk, every extension matching its magic bytes, 63 links
72
+ in the markdown with no broken target, manifest and JSON agreeing on the same 58 entries (and 0 without
73
+ `--media`). The manifest is now `weflow-media-manifest/v2` (values are objects carrying `kind`); v1 files still
74
+ read, because nobody should have to re-export after an upgrade.
75
+
76
+ Two traps, both found by measuring instead of reasoning. First, a quoted reply keeps the quoted text in
77
+ `<refermsg>` — which sits **inside** `<appmsg>` — so it carries a nested `type=6` block with its own
78
+ `<title>`/`<appattach>`. The first pass searched the XML for the first matching tag and reported 7,083 "files",
79
+ nearly all of them quoted text; the parser now reads `<appmsg>`'s **direct children** only. Second, because a
80
+ re-run copies nothing new, a count of "files we wrote" read `0` on the second export into the same directory
81
+ while 67 documents sat on disk — the CLI said "0 document(s)". `exported` (is it there) and `written` (did we
82
+ copy it just now) are separate counts now. `--media` also stopped extracting the **whole** conversation while
83
+ the format it feeds was sliced by `--limit` (67 documents extracted, 33 of them used); it takes the same window.
84
+
85
+ - **`export … json|txt|excel --media` puts the images in one command.** The markdown export is what extracts
86
+ media, so the other formats needed it run first - two steps, and a user who did not know that would see
87
+ `[图片]` and conclude the feature was missing. `--media` runs the same extraction (same Python path, same
88
+ `<output>/<talker>_assets/`, same manifest) and then writes the format it was asked for. Verified from a clean
89
+ state: one `export "…" json --media` produced the assets directory and a JSON in which 556 of 1,914 messages
90
+ carry their image's relative path.
91
+
92
+ - **Animated GIFs survive every export path - and now the files say so.** Asked whether GIFs come through, the
93
+ answer was measured rather than assumed: of the 556 images in one real conversation, **87 are GIFs** (animated:
94
+ `NETSCAPE2.0` plus frame blocks) and 27 are PNGs, and both the markdown assets and the HTML embeds carried them
95
+ intact (`image/gif` ×87 in the page, 84 of them animated) - `wechat_image.shrink` has always passed *animated*
96
+ GIFs through untouched, precisely because a JPEG re-encode would show a still of what the sender saw moving.
97
+ What was wrong was the **file names**: the new markdown path hard-coded `.jpg`, so 87 GIFs and 27 PNGs landed as
98
+ `img_….jpg` - content right, extension lying, which is what an editor decodes by. The extension is now sniffed
99
+ from the bytes (`detect_mime_from_bytes`, which the script already had), so it is
100
+ `.gif`/`.png`/`.jpg`/`.webp` as appropriate: measured after the fix, the four formats agree exactly - 87 `.gif`,
101
+ 27 `.png`, 442 `.jpg` references each (433 distinct jpg files, nine of them shared by two messages).
102
+
103
+ - **`export … md` exists, and the other formats now carry the same images.** A user reported that only HTML
104
+ exported images; that was true, and the root cause was not a missing switch. Images are matched through the
105
+ cache/account indexes, and only the Python exporter did that - so the fix routes markdown through the same
106
+ Python path (`--format md`) instead of reimplementing the matching in TypeScript. Markdown **writes the images
107
+ out** to `<output>/<talker>_assets/` and references them relatively, because markdown's job is to be fed to
108
+ editors and other tools, where `![](data:image/…)` is not understood. That pass also writes
109
+ `manifest.json`, and `json` / `txt` / `excel` exports in the same directory now pick it up automatically
110
+ (a `media` field, `[图片: <path>]`, and a 「媒体」 column respectively) - **the matching runs once**, which is
111
+ the only way two implementations would not drift.
112
+
113
+ Two things surfaced while building it, both worth recording. First, the manifest was keyed by `local_id`, and
114
+ **`local_id` repeats across shards** (a conversation's table can exist in more than one shard, each numbering
115
+ its own rows): measured on a 1,914-message chat, 763 messages matched 522 entries, so the non-markdown formats
116
+ would have pointed at the wrong picture. Keyed by `local_id:create_time` now, and all four formats land on 556
117
+ (= the number of messages with an image). Second, when the Python export failed, the TypeScript side fell back
118
+ to the basic HTML renderer and the CLI still printed `✓ 导出成功` - a failure dressed as success (it was found
119
+ by asking for markdown and getting an `.html` file back). Markdown no longer has a silent fallback at all (there
120
+ is no "basic markdown"), and the HTML fallback now says on stderr that it dropped the images.
121
+
122
+ - **The default semantic search path warned on stdout — and the warning was the important part.** `semantic_search.py`
123
+ has four `[WARN]` prints; three (the rerank ones) went to **stdout** while the rest already used
124
+ `file=sys.stderr`. Since `search` reranks by default, `search --json` answered with a line of prose *before* the
125
+ JSON and a machine caller got a parse error instead of results. Fixed, and the three paths now assert
126
+ `stdout == ''` in `test/semantic_rerank_test.py` — the two tests that already existed there asserted the warning
127
+ on **stdout**, i.e. they pinned the bug, so they were updated rather than duplicated beside them.
128
+
129
+ What the warning was saying, and why it stayed hidden for so long: on this machine the rerank layer **cannot run
130
+ at all** — `JevError … HTTP 451: Typesafe is not available in your region`. Rerank calls Typesafe
131
+ (`api.typesafe.ai`), a cloud decision service, not a local model, so on a geo-blocked machine the "default" path
132
+ is really the un-reranked one and the only signal was that stdout line. Two consequences are deliberately **not**
133
+ fixed here: the machine-readable result does not say the rerank failed (a degraded search is byte-identical to a
134
+ successful un-reranked one, because `rerank` returns just the list), and whether the index/rerank split is what
135
+ users should get on such a machine is a decision, not a patch. Both are written down in `docs/PROJECT_STATE.md`.
136
+
137
+ - **The semantic index was built and used on a real machine for the first time** (2026-10-13, 7-day chat /
138
+ 3-article window, 749 records, inside DashScope's free quota). The scoped search returns
139
+ `indexCounts: {all: 749, wiki: 383, chat: 366}` — the two lines add up to the whole index — and a query for
140
+ `开会` returns a chat message that says `…记得参加会议,8:30`: a semantic hit, not a keyword one. The default
141
+ 90/30 window is still unbuilt (it would exceed the free quota; the measured cost is roughly ¥1.3–2.2).
142
+
143
+ - **The semantic index would have embedded only the *older* part of its window, silently.** `semantic_search.py`
144
+ opened exactly one file — `config.ntDbPath` — with the account's global key. WeChat 4.x spreads messages across
145
+ shards and **each shard needs its own derived key**; the repo already had that path
146
+ (`nt_decrypt.connect_message_shards_detailed`, which also reports *which* shards failed). Measured here on
147
+ 2026-10-13 before the fix: the configured `message_0.db` holds 67,292 messages whose newest is **2026-08-30**,
148
+ while `message_3.db` holds 16,081 whose newest is **that same day's date** — so a build would have indexed 1,396
149
+ chat messages and **nothing newer than six weeks ago**, missing 5,217 that a correct read finds in the same
150
+ 90-day window (7-day window: 0 → 376). Three quieter losses went with it: the session list was `[:100]` —
151
+ unsorted, so effectively arbitrary, on a machine with 122 sessions; the id was `chat:<talker>:<ts>`, which merges
152
+ messages inside one second (measured: 12,106 rows on 12,058 distinct seconds); and one conversation's table can
153
+ exist in several shards, so a shared id would double-count. The build result now reports `shardsFailed`, because
154
+ "read everything" and "read what happened to be reachable" must not look alike.
155
+
156
+ `test/semantic_shards_test.py` pins each of the three with fake shard connections (no sqlcipher, no real vault),
157
+ and all three mutations — single shard, no-rowid id, `[:100]` — turn their own test red. **This is also why the
158
+ index is still not built**: building it first would have spent the embedding budget on a month the user is not
159
+ asking about. Closing that now costs one `search-index --yes` once `dashscopeApiKey` is set.
160
+
20
161
  ## 1.10.0
21
162
 
22
163
  - **`mcp-config --json-result` printed a tool list *after* the JSON.** Its read path printed the config JSON and
package/OPERATIONS.md CHANGED
@@ -86,7 +86,42 @@ weflow-cli export "联系人A" html --output ./output
86
86
  weflow-cli export "联系人A" json --output ./output
87
87
  ```
88
88
 
89
- 支持 `json`、`txt`、`md`、`html` 和 `excel`。HTML 导出会尽力匹配本地图片、表情、公众号卡片和其他媒体;匹配不到时应显示类型或占位信息,不应从其他会话猜图。跨分片数据尤其要保留原始导出和完整上下文,导出后请人工抽查发送者、时间和媒体对应关系。
89
+ 支持 `json`、`txt`、`html`、`md` 和 `excel` 五种。**媒体匹配只做一遍**:`md` 导出是那个"抽取者"——
90
+ 图片落盘到 `<输出>/<会话>_assets/`、**文档本体按原名与扩展名拷进 `<会话>_assets/files/`**,同时写一份
91
+ `manifest.json`;随后在同一输出目录里导出 `json`/`txt`/`excel`,它们会**自动**带上同样的路径(`json`
92
+ 每条多 `media`,文档还多 `mediaKind`/`mediaName`;`txt` 里是 `[图片: <相对路径>]` / `[文件: 名字 (路径)]`;
93
+ `excel` 多一列「媒体」)。**不想分两步就给 `--media`**:`json`/`txt`/`excel` 会先自己抽一次(同一份清单,
94
+ 且**吃同一个 `--limit`/`--date`**),命令只有一条:
95
+ `weflow-cli export "某人" json --media`。没跑过 `md` 导出也没给 `--media` 时,这三种格式与从前一致
96
+ (图片位置是 `[图片]` 这样的占位)。清单的键是 `local_id:create_time` —— **`local_id` 会在分片之间重复**(同一会话
97
+ 的表可能存在于多个分片、各片自己编号),只按它会引用到别的消息的图。清单 schema 是 `weflow-media-manifest/v2`
98
+ (值是带 `kind` 的对象);磁盘上留着的 v1(裸字符串)仍读得出来,升级不必重跑导出。
99
+
100
+ **文档本体是纯拷贝,不重编码、不改名**(只清洗文件名里的非法字符)。消息里只有名字与 md5,文件在账号目录的
101
+ `msg/file/YYYY-MM/` 下,按 `(归一化名, 字节数)` 对上,同名多份时用 md5 定。本机实测 1,855 条文件消息里
102
+ **1,643 条对得上(88.6%)**,其余 212 条本地确实没有那份文件(没下载过或已清理)—— 这些消息仍是
103
+ `[文件] 名字` 的标签,不清单里凑数、也不猜。导出的计数里 `exported` 是"这份文档在 assets 里",
104
+ `written`(清单里叫 `documents.copied`)才是"这次新拷了几个",别把后者当成前者的成绩。
105
+ 另外 `--media` 配 `html` 不生效(HTML 不写文档本体),CLI 会提示一句而不是静默忽略。
106
+
107
+ md 里指向文档的链接**把路径里的空格、括号等字符做了百分号编码**(如 `报告%282%29.pdf`),链接文字仍是原名。
108
+ 这不是洁癖:文档名里带半角括号时(`报告(2).pdf` 这种),链接目标会被任何 markdown 渲染器在**第一个 `)`
109
+ 处截断**,点开是空的(2026-10-13 重导时实测到;同一批里还有一份超长标题被 80 字符上限连 `.pdf` 一起截掉,
110
+ 落成一个没有扩展名的文件——那条也修了)。
111
+
112
+ **HTML 导出(含 `--full-images`)不写文件本体**:HTML 的用法是"单文件可分享",把几 MB 的 PDF 内嵌成
113
+ base64 既没人打得开、也把网页撑大。要在文件本体上要文档,用 `md` 或 `json|txt|excel --media`。
114
+ (`--media` 会把文档拷进输出目录,几百份的时代价几十到几百 MB,先看一眼 `msg/file` 的规模再按整段历史跑。)
115
+
116
+ **动图(GIF)会照原样带出来**:落盘的文件名按**字节**定扩展名(`.gif`/`.png`/`.jpg`/`.webp`),内容不动 ——
117
+ 实测一段真实会话里 556 张图有 87 个是 GIF(带动画扩展的 84 个),markdown 里就是 `.gif`、HTML 里是
118
+ `data:image/gif`。`--full-images` 那档也**不会**把动画 GIF 压成静态图(`wechat_image.shrink` 对动画 GIF
119
+ 直接原样穿过,理由写在它的注释里:PIL 只解第一帧,重编码会把"发的人看到在动"变成一张静止图)。
120
+
121
+ HTML 导出会尽力匹配本地图片、表情、公众号卡片和其他媒体(内嵌,单文件可分享,并给出一份媒体覆盖报告);
122
+ 匹配不到时应显示类型或占位信息,不应从其他会话猜图。`.pdf`/`.docx` 这类文件消息在 HTML 里是一张标题卡片
123
+ (HTML 刻意不嵌文件本体,理由见上一段);文件本体走 `md` 或 `--media`。跨分片数据尤其要保留
124
+ 原始导出和完整上下文,导出后请人工抽查发送者、时间和媒体对应关系。
90
125
 
91
126
  ### 导出耗时与远程媒体
92
127
 
@@ -95,7 +130,10 @@ HTML 导出的时间几乎全部花在远程媒体上(公众号封面、B 站
95
130
  - 同一会话的**重复导出接近瞬时**(实测 1614 条约 2 秒);首次导出才是真正要付网络成本的。
96
131
  - 首次导出会先做一遍**只记录 URL、不联网**的扫描,再用 24 线程并发取回,最后用热缓存正式生成。逐条串行取回会慢一个数量级。
97
132
  - `.cover-cache/` 可以随时删除,删了只会退回「首次导出」的耗时,不会影响产出内容。
98
- - 原图(5–24MB/张)默认不嵌入,用微信缓存里的缩略图;确需原图时加 `--full-images`,代价是数分钟。
133
+ - 默认嵌入的是微信缓存里的**缩略图**;加 `--full-images` 会改用**原图作为来源**(原图常有 5–24MB、3000+px,
134
+ `wechat_image.shrink` 处理它们要数分钟)。**两档嵌入前都会按上限缩放**,所以导出物体积不会因为这一档变成几百 MB
135
+ ——2026-10-13 实测同一会话两档:HTML 体积 2.0 MB vs 1.5 MB、图片像素分布基本一致(`--full-images` 让 4 张的来源
136
+ 从缓存缩略图换成了原图 ⇒ 720px 的那批从 10 张变成 14 张)。它的价值在**那几张缩略图本来就小**的会话上。
99
137
 
100
138
  ### 图片显示为 `[图片]`、群聊看不出谁在说话
101
139
 
package/README.md CHANGED
@@ -92,7 +92,7 @@ Linux 的自动初始化能力取决于微信发行版、Python 依赖和当前
92
92
 
93
93
  | 场景 | 能力 |
94
94
  | --- | --- |
95
- | 聊天记录 | 查询会话、联系人和消息;导出 JSON、TXT、HTML、Excel。HTML 导出会同时给出一份媒体覆盖报告,说明哪些图片/表情没能嵌入以及原因。 |
95
+ | 聊天记录 | 查询会话、联系人和消息;导出 **Markdown / JSON / TXT / HTML / Excel**。Markdown 导出把图片**落盘**到 `<会话>_assets/`、正文用相对路径引用(喂给编辑器或别的工具时这才是能用的形态);**文档本体**(微信里的 Word/Excel/PDF/PPT 等)按原名与扩展名拷进 `<会话>_assets/files/`,正文里按原名链接到 `files/` 下;HTML 导出内嵌图片并给出一份媒体覆盖报告(哪些没嵌上、为什么)。**匹配只做一遍**:Markdown 导出写下清单,之后 JSON/TXT/Excel 导出(或 `--media`)会自动带上同样的图片与文档路径。 |
96
96
  | 公众号日报 | 抓取文章、AI 摘要与分类、生成本地阅读页,保留收藏和已读状态。 |
97
97
  | 个人知识库 | 同步微信读书笔记、构建 Obsidian Vault、语义搜索、RAG 问答和概念 Wiki,以及**双击就能打开**的概念图谱页面(`weflow-cli wiki graph --open` 出 3D、加 `--flat` 出 2D;自包含、不联网、不需要服务器;`--min-degree N` 只看枢纽、`--line wiki\|chat` 只看一条线)。 |
98
98
  | AI 协作 | 通过 MCP 把文章、知识库、日报和受控的本地数据能力交给兼容客户端;工具清单以 `weflow-cli mcp-config` 和 `docs/MCP.md` 为准。 |
@@ -158,7 +158,7 @@ weflow-cli messages "联系人" -n 10
158
158
  weflow-cli export "联系人" html --output ./output
159
159
  ```
160
160
 
161
- HTML 导出默认内嵌微信缓存里的缩略图,并尽力还原图片、表情包、系统消息和公众号卡片。远程媒体按 URL 缓存在输出目录的 `.cover-cache/`,所以**重复导出接近瞬时**(实测 1600+ 条约 2 秒),首次导出才需要联网。自定义表情包从本地缓存离线解密,seed 未配置时会自动扫描微信内存反推并提示固化命令。确需原图(5–24MB/张)时加 `--full-images`,代价是慢数倍。细节见[操作与排障手册](./OPERATIONS.md#4-导出聊天记录)。
161
+ HTML 导出默认内嵌微信缓存里的缩略图,并尽力还原图片、表情包、系统消息和公众号卡片。远程媒体按 URL 缓存在输出目录的 `.cover-cache/`,所以**重复导出接近瞬时**(实测 1600+ 条约 2 秒),首次导出才需要联网。自定义表情包从本地缓存离线解密,seed 未配置时会自动扫描微信内存反推并提示固化命令。想改用**原图作为来源**时加 `--full-images`(原图常有 5–24MB/张,处理它们会慢数倍;**嵌入前仍按上限缩放**,所以导出物不会因此变大成几百 MB,实测两档体积相当)。细节见[操作与排障手册](./OPERATIONS.md#4-导出聊天记录)。
162
162
 
163
163
  **生成当天公众号日报**
164
164
 
package/bin/weflow-cli.ts CHANGED
@@ -1821,8 +1821,9 @@ program
1821
1821
  .option('--json', '输出机器可读的导出结果,不改变导出文件格式')
1822
1822
  .option('--non-interactive', '禁止交互选择;匹配不唯一时返回错误')
1823
1823
  .option('--full-images', '使用原图而非微信缓存缩略图(更清晰,但导出慢数倍)')
1824
+ .option('--media', 'json/txt/excel 导出时一并抽出图片与文档(落盘到 <输出>/<会话>_assets,并在导出物里给出相对路径)')
1824
1825
  .action(async (talkerInput: string, format: string, opts) => {
1825
- const validFormats = ['json', 'txt', 'html', 'excel']
1826
+ const validFormats = ['json', 'txt', 'html', 'md', 'excel']
1826
1827
  if (!validFormats.includes(format)) {
1827
1828
  if (opts.json) console.log(JSON.stringify({ success: false, code: 'INVALID_FORMAT', error: '导出格式无效', supported: validFormats }))
1828
1829
  else {
@@ -1871,6 +1872,23 @@ program
1871
1872
  }
1872
1873
  process.exit(1)
1873
1874
  }
1875
+ // 图片要靠 Python 那条路匹配(缓存缩略图、资源 md5、原图那档)。`--media` 就是"先抽一次",
1876
+ // 抽完 json/txt/excel 各自消费同一份清单 —— 匹配只跑一遍,也不会在 TS 里重写一遍。
1877
+ if (opts.media && (format === 'json' || format === 'txt' || format === 'excel')) {
1878
+ const media = await exportService.extractMedia(talker, opts.output, !!opts.json, { fullImages: opts.fullImages === true }, limit, opts.date || '')
1879
+ if (!media.success) {
1880
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'MEDIA_EXPORT_FAILED', error: media.error || '媒体抽取失败' }))
1881
+ else console.log(chalk.red(`✗ 媒体抽取失败: ${media.error}`))
1882
+ process.exit(1)
1883
+ }
1884
+ } else if (opts.media && format === 'html') {
1885
+ // HTML 的用法是"单文件可分享",所以它不写文档本体(把几 MB 的 PDF 内嵌进网页,既打不开也把文件撑大)。
1886
+ // 静默忽略这个开关会让人以为它生效了 —— 明说一句,并给出该用哪个格式。
1887
+ if (!opts.json) {
1888
+ console.error(chalk.gray(' 提示:HTML 导出不含文档本体(单文件分享的用法),--media 对它不生效;'))
1889
+ console.error(chalk.gray(' 要连文档一起导出请用 md,或 json|txt|excel --media。'))
1890
+ }
1891
+ }
1874
1892
  switch (format) {
1875
1893
  case 'json':
1876
1894
  result = await exportService.exportJson(talker, opts.output, limit, from, to, opts.contract)
@@ -1881,6 +1899,10 @@ program
1881
1899
  case 'html':
1882
1900
  result = await exportService.exportHtml(talker, opts.output, limit, opts.date || '', from, to, !!opts.json, { fullImages: opts.fullImages === true })
1883
1901
  break
1902
+ case 'md':
1903
+ // markdown + 图片落盘(`<输出>/<talker>_assets/`);json/txt/excel 之后会自动带上那些图。
1904
+ result = await exportService.exportMarkdown(talker, opts.output, limit, opts.date || '', from, to, !!opts.json, { fullImages: opts.fullImages === true })
1905
+ break
1884
1906
  case 'excel':
1885
1907
  result = await exportService.exportExcel(talker, opts.output, limit, from, to)
1886
1908
  break
@@ -1845,8 +1845,9 @@ program
1845
1845
  .option('--json', '输出机器可读的导出结果,不改变导出文件格式')
1846
1846
  .option('--non-interactive', '禁止交互选择;匹配不唯一时返回错误')
1847
1847
  .option('--full-images', '使用原图而非微信缓存缩略图(更清晰,但导出慢数倍)')
1848
+ .option('--media', 'json/txt/excel 导出时一并抽出图片与文档(落盘到 <输出>/<会话>_assets,并在导出物里给出相对路径)')
1848
1849
  .action(async (talkerInput, format, opts) => {
1849
- const validFormats = ['json', 'txt', 'html', 'excel'];
1850
+ const validFormats = ['json', 'txt', 'html', 'md', 'excel'];
1850
1851
  if (!validFormats.includes(format)) {
1851
1852
  if (opts.json)
1852
1853
  console.log(JSON.stringify({ success: false, code: 'INVALID_FORMAT', error: '导出格式无效', supported: validFormats }));
@@ -1899,6 +1900,26 @@ program
1899
1900
  }
1900
1901
  process.exit(1);
1901
1902
  }
1903
+ // 图片要靠 Python 那条路匹配(缓存缩略图、资源 md5、原图那档)。`--media` 就是"先抽一次",
1904
+ // 抽完 json/txt/excel 各自消费同一份清单 —— 匹配只跑一遍,也不会在 TS 里重写一遍。
1905
+ if (opts.media && (format === 'json' || format === 'txt' || format === 'excel')) {
1906
+ const media = await exportService.extractMedia(talker, opts.output, !!opts.json, { fullImages: opts.fullImages === true }, limit, opts.date || '');
1907
+ if (!media.success) {
1908
+ if (opts.json)
1909
+ console.log(JSON.stringify({ success: false, code: 'MEDIA_EXPORT_FAILED', error: media.error || '媒体抽取失败' }));
1910
+ else
1911
+ console.log(chalk.red(`✗ 媒体抽取失败: ${media.error}`));
1912
+ process.exit(1);
1913
+ }
1914
+ }
1915
+ else if (opts.media && format === 'html') {
1916
+ // HTML 的用法是"单文件可分享",所以它不写文档本体(把几 MB 的 PDF 内嵌进网页,既打不开也把文件撑大)。
1917
+ // 静默忽略这个开关会让人以为它生效了 —— 明说一句,并给出该用哪个格式。
1918
+ if (!opts.json) {
1919
+ console.error(chalk.gray(' 提示:HTML 导出不含文档本体(单文件分享的用法),--media 对它不生效;'));
1920
+ console.error(chalk.gray(' 要连文档一起导出请用 md,或 json|txt|excel --media。'));
1921
+ }
1922
+ }
1902
1923
  switch (format) {
1903
1924
  case 'json':
1904
1925
  result = await exportService.exportJson(talker, opts.output, limit, from, to, opts.contract);
@@ -1909,6 +1930,10 @@ program
1909
1930
  case 'html':
1910
1931
  result = await exportService.exportHtml(talker, opts.output, limit, opts.date || '', from, to, !!opts.json, { fullImages: opts.fullImages === true });
1911
1932
  break;
1933
+ case 'md':
1934
+ // markdown + 图片落盘(`<输出>/<talker>_assets/`);json/txt/excel 之后会自动带上那些图。
1935
+ result = await exportService.exportMarkdown(talker, opts.output, limit, opts.date || '', from, to, !!opts.json, { fullImages: opts.fullImages === true });
1936
+ break;
1912
1937
  case 'excel':
1913
1938
  result = await exportService.exportExcel(talker, opts.output, limit, from, to);
1914
1939
  break;