weflow-cli 1.6.2 → 1.6.4

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
@@ -4,6 +4,32 @@ The npm package is published separately from GitHub. It may lag behind the `mast
4
4
 
5
5
  All notable user-facing changes are recorded here. This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 1.6.4
8
+
9
+ ### Fixed
10
+
11
+ - Restored four pieces of matching logic in the HTML exporter that the `90b165a` clone-consolidation merge had silently dropped, all of which lowered media coverage without failing loudly:
12
+ - the `unique:<local_id>` fallback, which matches an image when that id resolves to exactly one distinct picture in the conversation (content-deduped, so the shard-collision risk that rules out a bare `local_id` does not apply);
13
+ - `is_encoded_media_type`, needed because WeChat stores some forwards as high-bit variants of type 49 — the mask turns those into a plain 49, which is a registered type, so the branch guarding them had become unreachable;
14
+ - the guard that keeps a type-49 row carrying a title or url on the link-card path instead of hiding it behind a cached thumbnail;
15
+ - the cache-first lookup in the article branch, which had been downloading covers it already had on disk.
16
+ - Seeded `COVER_STATE`'s budgets with their limits instead of 0. They were only ever set by `main()`, so any caller reaching the fetchers another way silently skipped every remote fetch and got `None` back with no error.
17
+ - `pipeline_security_test` no longer reads the developer's real `~/.weflow-cli/config.json`; it points `CONFIG_PATH` at an empty temp file. The test only passed on a machine that happened to have a config, and reading a real config from a unit test is exactly what the repo rules forbid.
18
+ - CI actually runs, for the first time. `npm test` passed locally and failed on every push: the script used `test/**/*.test.ts`, which Git Bash expands locally but CI's bash does not (globstar is off by default), so Node received the literal pattern and reported `Could not find ...`. The glob is now quoted so Node expands it. The single combined job was also split into `node` / `python` / `release-consistency`, because a Node failure previously stopped the Python tests from running at all — they had been failing unnoticed.
19
+ - Raised the Node floor to 22.13.0. `src/core/sqlcipherCore.ts` imports `node:sqlite` at module scope; that builtin arrives in 22.5.0 and stops needing `--experimental-sqlite` at 22.13.0, so on Node 18/20 every command died at load while `engines` still claimed `>=18`. Both are past end-of-life, so the floor moved rather than adding a lazy-load path for dead runtimes.
20
+ - Rebuilt `package-lock.json`, which still said 1.6.1 and still listed `lz4` as an ordinary dependency after the 1.6.3 change moved it to optional. `npm audit fix` then took production vulnerabilities from 17 to 6; the remainder (`@xmldom/xmldom`, `exceljs`, `@wenyan-md/core`, `mermaid`, `speech-rule-engine`, `uuid`) have no non-breaking fix — `exceljs@4.4.0` and `@wenyan-md/core@3.0.11` are already the newest releases and the advisories' suggested "fix" is to downgrade them.
21
+
22
+ ### Added
23
+
24
+ - `docs/PROJECT_STATE.md` no longer carries a hardcoded version — it points at `package.json`, which is what drifted to `1.5.1`. Example version numbers in `docs/NPM-PUBLISH.md` and `docs/HEALTH-CHECK.md` became placeholders for the same reason.
25
+ - Git tags and GitHub Releases for `1.6.0`, `1.6.1` and `1.6.3`, so npm versions map to commits. `1.6.2` has neither: it was published, but its version bump was never committed.
26
+
27
+ ## 1.6.3
28
+
29
+ ### Fixed
30
+
31
+ - `npm install -g weflow-cli` no longer fails on machines without Visual Studio build tools. `lz4@0.6.5` declares an unconditional `"install": "node-gyp rebuild"` with no prebuilt binaries and no fallback, so having it in `dependencies` made the whole install abort with `gyp ERR! find VS could not find a version of Visual Studio 2017 or newer` - for every Windows user without a C++ toolchain, which is most of them. The code only ever used it for 3.x WCDB `CompressContent` decompression, and already loaded it lazily behind a `try/catch` that degrades to `null`, so it is now an `optionalDependency`: npm installs it best-effort and continues when the build fails. Measured on a machine with no Visual Studio: before, `npm install` exited 1; after, it exits 0 and the installed CLI runs.
32
+
7
33
  ## 1.6.2
8
34
 
9
35
  ### Fixed
package/OPERATIONS.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  | 依赖 | 用途 | 检查 |
8
8
  | --- | --- | --- |
9
- | Node.js 18+ | CLI、MCP、构建 | `node --version` |
9
+ | Node.js 22.13+ | CLI、MCP、构建 | `node --version` |
10
10
  | Python 3.10+ | NT 数据、日报、阅读器 | `python --version` |
11
11
  | `requirements.txt` | 标准 4.x 工作流 | `python -m pip install -r requirements.txt` |
12
12
  | `requirements-3x.txt` | 旧版 3.x 数据,可选 | `python -m pip install -r requirements-3x.txt` |
package/README.en.md CHANGED
@@ -78,7 +78,7 @@ weflow-cli mcp-config # one-shot MCP client integration
78
78
 
79
79
  ### 1. Install and Check
80
80
 
81
- Requires Node.js 18+, Python 3.10+, and a signed-in Windows WeChat. After installation, check your environment first:
81
+ Requires Node.js 22.13+, Python 3.10+, and a signed-in Windows WeChat. After installation, check your environment first:
82
82
 
83
83
  ```powershell
84
84
  npm install -g weflow-cli
package/README.md CHANGED
@@ -108,7 +108,7 @@ Linux 的自动初始化能力取决于微信发行版、Python 依赖和当前
108
108
 
109
109
  > npm 包与 GitHub 发布分开进行;需要最新修复时,请先确认 npm 包版本,或直接使用 GitHub `master` 分支代码。
110
110
 
111
- 需要 Node.js 18+、Python 3.10+,以及已登录的 Windows 微信。安装后先检查本机环境:
111
+ 需要 Node.js 22.13+、Python 3.10+,以及已登录的 Windows 微信。安装后先检查本机环境:
112
112
 
113
113
  ```powershell
114
114
  npm install -g weflow-cli
@@ -322,6 +322,7 @@ python scripts/fav_server.py --date YYYY-MM-DD
322
322
  - [技术决策记录](./docs/DECISIONS.md):长期维护中的关键设计取舍。
323
323
  - [分支与合并记录](./docs/BRANCHES.md):分支用途、合并时间线与并行开发的对齐约定。
324
324
  - [发布清单](./docs/RELEASING.md):版本号、打包敏感内容检查、npm 凭据、国内镜像同步。
325
+ - [npm 发布速查](./docs/NPM-PUBLISH.md):404 / 409 / ETARGET 是失败还是在传播,按症状查表。
325
326
  - [定期体检](./docs/HEALTH-CHECK.md):一条命令查出版本漂移、分片漏读、会话不更新与导出失败。
326
327
  - [电子数据证据指南](./docs/EVIDENCE_GUIDE.md):证据保全、法律边界与使用注意事项。
327
328
  - [贡献指南](./CONTRIBUTING.md) 与 [安全策略](./SECURITY.md):开发、反馈和敏感问题处理。
@@ -15,16 +15,17 @@ py scripts/health_check.py # 人读的报告
15
15
  py scripts/health_check.py --json # 机器读
16
16
  ```
17
17
 
18
- 退出码 `0` = 全部通过,`1` = 有失败项。报告长这样:
18
+ 退出码 `0` = 全部通过,`1` = 有失败项。报告长这样(版本号、日期、字节数都是
19
+ 当次运行的真实值,仅作示例):
19
20
 
20
21
  ```
21
- WeFlow 健康检查 · 2026-09-17 23:11
22
+ WeFlow 健康检查 · 2026-09-19 10:04
22
23
 
23
- [OK ] 版本一致 CLI 1.6.1 / package 1.6.1
24
+ [OK ] 版本一致 CLI X.Y.Z / package X.Y.Z
24
25
  [OK ] 环境自检 已初始化=True 消息库=True
25
26
  [OK ] Python 依赖 齐全
26
- [OK ] 会话新鲜度 最新会话 2026-09-17 23:10(0.0 天前)
27
- [OK ] 读取路径一致 (wxid_6xx1ehe4h…) json 最新 2026-09-17 23:10 / html 最新 2026-09-17 23:10:13
27
+ [OK ] 会话新鲜度 最新会话 2026-09-19 10:03(0.0 天前)
28
+ [OK ] 读取路径一致 (wxid_6xx1ehe4h…) json 最新 2026-09-19 10:03 / html 最新 2026-09-19 10:03:12
28
29
  [OK ] 导出格式 json 938 字节
29
30
  [OK ] 导出格式 txt 145 字节
30
31
  [OK ] 导出格式 excel 6788 字节
package/docs/MCP.md CHANGED
@@ -92,4 +92,4 @@ The chat-data tools share service code with the optional `weflow-cli assistant`
92
92
  | --- | --- |
93
93
  | Server fails to start | Run `npm install` in the configured `cwd`, then run `npx tsx mcp-server/index.ts`. |
94
94
  | No articles found | Generate a daily collection first, then confirm `output/biz-daily/` exists under `cwd`. |
95
- | Client cannot find `npx` | Configure an absolute Node.js command path or install Node.js 18+. |
95
+ | Client cannot find `npx` | Configure an absolute Node.js command path or install Node.js 22.13+. |
@@ -0,0 +1,130 @@
1
+ # npm 发布 — 速查与故障对照
2
+
3
+ 只讲 npm 这一侧。**流程清单**在 [RELEASING.md](RELEASING.md),这里只解决
4
+ 「命令跑完了,现在到底算成功没有」。
5
+
6
+ 单个环节按需查,不必通读。
7
+
8
+ ## 0. 省上下文(先看这条)
9
+
10
+ 发布命令会打印几百行 `Tarball Contents`。**永远加过滤**,否则整个文件清单
11
+ 会灌进上下文,既没用又挤掉真正要看的信息:
12
+
13
+ ```bash
14
+ npm publish --registry=https://registry.npmjs.org 2>&1 \
15
+ | grep -vE "^npm notice [0-9.]+[kMB]+ |^npm notice [0-9.]+B " | tail -25
16
+ ```
17
+
18
+ `npm pack --dry-run` 同理。要看文件清单时**输出到文件再 grep**,不要直接打印:
19
+
20
+ ```bash
21
+ npm pack --dry-run 2>&1 | grep "npm notice [0-9]" > /tmp/packlist.txt
22
+ grep -c " dist/" /tmp/packlist.txt # 注意: 行首是体积, 不是路径
23
+ ```
24
+
25
+ ## 1. 唯一可靠的判据
26
+
27
+ **别信命令的输出,信 registry 的 `versions` 表:**
28
+
29
+ 下面用 `X.Y.Z` 代表这次要发的版本,**照抄时换成实际版本号**——写死在这里的版本
30
+ 过几天就是错的。
31
+
32
+ ```bash
33
+ V=X.Y.Z
34
+ curl -s "https://registry.npmjs.org/weflow-cli?nocache=$RANDOM" --max-time 30 \
35
+ | python -c "
36
+ import json,sys,os
37
+ d=json.load(sys.stdin)
38
+ print('latest:', d['dist-tags']['latest'])
39
+ print('目标版本存在:', os.environ['V'] in d['versions'])" V="$V"
40
+ ```
41
+
42
+ - `目标版本存在: True` → **发布成功**,剩下只是传播
43
+ - `False` → 没发出去,看第 2 节
44
+
45
+ `npm view` 会读到客户端缓存,`--prefer-online` 或上面的 `nocache` 才准。
46
+
47
+ ## 2. 症状 → 含义 → 动作
48
+
49
+ | 现象 | 含义 | 动作 |
50
+ | --- | --- | --- |
51
+ | `+ weflow-cli@X.Y.Z` | 已被接受 | 继续,等传播 |
52
+ | `being processed and may take a few minutes` | 正常提示 | 等,别重发 |
53
+ | `npm view` 仍是旧版本 | CDN 传播中 | **不是失败**,等 2–5 分钟 |
54
+ | 重发报 `409 Conflict - Cannot publish over previously staged version` | **已经发布成功了** | 别再发,去等传播 |
55
+ | tarball `404`(`/-/weflow-cli-X.Y.Z.tgz`) | 元数据已发布,tarball 还在传播 | 等,不是失败 |
56
+ | `install` 报 `ETARGET No matching version` | 客户端缓存了旧 packument | 加 `--prefer-online` 重试 |
57
+ | `install` 报 `404` | 元数据在、tarball 未到 | 等传播 |
58
+ | `403 ... bypass 2fa enabled is required` | token 没勾 Bypass 2FA | 重新生成 token(见第 3 节) |
59
+ | `E404` 发布时 | 包名或权限问题 | 查 `npm whoami` |
60
+
61
+ > **404 / 409 / ETARGET 在发布后的几分钟内都是传播现象,不是错误。**
62
+ > 唯一例外:`403` 和发布时的 `E404`。
63
+
64
+ ## 3. 凭据自检
65
+
66
+ 不打印 token 本身:
67
+
68
+ ```bash
69
+ npm whoami --registry=https://registry.npmjs.org
70
+
71
+ TOKEN=$(sed -n 's/.*_authToken=//p' ~/.npmrc | tr -d '\r\n')
72
+ curl -s -H "Authorization: Bearer $TOKEN" \
73
+ "https://registry.npmjs.org/-/npm/v1/tokens" --max-time 30 \
74
+ | python -c "
75
+ import json,sys
76
+ for t in (json.load(sys.stdin).get('objects') or []):
77
+ print(t.get('tokenId','')[:12], 'bypass_2fa=', t.get('bypass2fa'), 'created=', t.get('created'))"
78
+ ```
79
+
80
+ `bypass_2fa` 必须是 `True`,否则发布会要 OTP。
81
+
82
+ ## 4. 触发国内镜像同步
83
+
84
+ ```bash
85
+ curl -s -X PUT "https://registry.npmmirror.com/-/package/weflow-cli/syncs" --max-time 40
86
+ ```
87
+
88
+ `{"ok":true,...,"state":"waiting"}` 即受理,**约 5 分钟**生效。
89
+
90
+ 两个源不一致时,先怀疑镜像同步,不要怀疑包:
91
+
92
+ ```bash
93
+ npm view weflow-cli version --registry=https://registry.npmjs.org
94
+ npm view weflow-cli version --registry=https://registry.npmmirror.com
95
+ ```
96
+
97
+ ## 5. 装一份下来验(最终确认)
98
+
99
+ ```bash
100
+ mkdir -p /tmp/verify && cd /tmp/verify && npm init -y >/dev/null
101
+ npm install weflow-cli@X.Y.Z --registry=https://registry.npmjs.org --prefer-online --no-audit --no-fund
102
+ node -e "console.log(require('./node_modules/weflow-cli/package.json').version)"
103
+ ```
104
+
105
+ 装不上但第 1 节显示版本存在 → 就是 tarball 传播中,等。
106
+
107
+ ## 6. 打包安全检查(发布前,不能跳)
108
+
109
+ ```bash
110
+ npm pack --dry-run 2>&1 | grep "npm notice [0-9]" > /tmp/packlist.txt
111
+ grep -E "output/|models/|node_modules|\.env|\.db$|\.dat$|_env\.json|secret|credential|\.key$|\.pem$" /tmp/packlist.txt
112
+ ```
113
+
114
+ **这条必须没有输出。** 有输出就停下查。
115
+
116
+ 再确认新增文件确实**在**包里(`files` 白名单按目录配,容易漏):
117
+
118
+ ```bash
119
+ for f in scripts/health_check.py docs/HEALTH-CHECK.md cli.cjs; do
120
+ grep -qF "$f" /tmp/packlist.txt && echo "✓ $f" || echo "✗ 缺失 $f"
121
+ done
122
+ ```
123
+
124
+ ## 已知坑
125
+
126
+ - **`bin` 会被 npm 改写**:`"bin": {"weflow-cli": "./cli.cjs"}` 发布时报
127
+ `script name cli.cjs was invalid and removed`,同时 `repository.url` 被规范化。
128
+ 是 warning,不影响发布结果,但要知道它每次都出现,不是本次引入的问题。
129
+ - **`.gitignore` 里 `*.md` 默认忽略、逐个放行**:新增文档必须同时加
130
+ `!docs/<名字>.md`,否则 `git add` 会被挡。
@@ -1,6 +1,6 @@
1
1
  # Project State
2
2
 
3
- > Last reviewed: 2026-09-08. This is the current maintenance snapshot, not a release note. Keep it factual and update it with meaningful project changes.
3
+ > Last reviewed: 2026-09-19. This is the current maintenance snapshot, not a release note. Keep it factual and update it with meaningful project changes.
4
4
 
5
5
  ## Purpose
6
6
 
@@ -8,8 +8,8 @@ WeFlow CLI is a local-first command-line tool and MCP server for user-authorized
8
8
 
9
9
  ## Current Baseline
10
10
 
11
- - Source package version: `1.5.1`. The npm registry may remain on an earlier version until a maintainer publishes a release.
12
- - Runtime: Node.js 18+; Python 3.10+ is required for database and daily-reading workflows.
11
+ - Source package version: take it from `package.json` -> `version` rather than from this file - a hardcoded copy drifts silently, and `package-lock.json` must always match it. The npm registry may remain on an earlier version until a maintainer publishes a release.
12
+ - Runtime: Node.js 22.13+; Python 3.10+ is required for database and daily-reading workflows. The floor is set by `src/core/sqlcipherCore.ts`, which imports `node:sqlite` at module scope; that builtin exists from 22.5.0 and stops needing `--experimental-sqlite` at 22.13.0.
13
13
  - Main development command: `npm run dev -- <command>`.
14
14
  - Compiled/package command: `node cli.cjs <command>`; CLI, MCP, database, export, and assistant services resolve resources from the same package root in both source and compiled layouts.
15
15
  - Build check: `npm run build`.
@@ -53,7 +53,7 @@ Date-bounded message exports and `wechat.export_messages` page through the selec
53
53
  - `search` and `chat` use the same preview/confirmation boundary. Queries, questions, and optional conversation restrictions are inherited through the worker environment instead of appearing in process arguments; machine callers cannot start interactive RAG chat.
54
54
  - `daily-server --status --json` remains read-only. Machine startup uses `daily-server --dry-run --json` followed by `daily-server --yes --json`; the confirmed process is detached and remains bound to loopback. The legacy `fav-server` compatibility entry enforces the same preview and confirmation rules.
55
55
 
56
- Documentation was synchronized with the `1.5.1` source baseline on 2026-09-09. Command behavior is defined by `bin/weflow-cli.ts`; detached reader startup waits for service readiness, and release packages can lag behind the GitHub source until published.
56
+ Documentation was synchronized with the current source baseline on 2026-09-19. Command behavior is defined by `bin/weflow-cli.ts`; detached reader startup waits for service readiness, and release packages can lag behind the GitHub source until published.
57
57
 
58
58
  ## Active Constraints
59
59
 
package/docs/RELEASING.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # 发布清单
2
2
 
3
+ > npm 侧的**症状对照与省上下文技巧**在 [NPM-PUBLISH.md](NPM-PUBLISH.md):
4
+ > 404 / 409 / ETARGET 到底是失败还是在传播,查那一份,不用通读本文。
5
+
3
6
  npm 包与 GitHub 分开发布,中间隔多久都不奇怪——`1.5.0` 与主线之间曾拉开 128 个提交、
4
7
  两个多月,直接导致用户照着 README 敲 `init --path` 却报 `unknown option`。
5
8
 
package/docs/SETUP.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 前置条件
6
6
 
7
- - Windows 10/11 x64 是当前重点验证平台;Node.js 18+、Python 3.10+。
7
+ - Windows 10/11 x64 是当前重点验证平台;Node.js 22.13+、Python 3.10+。
8
8
  - 已安装并登录兼容的微信版本。微信数据库格式和平台行为可能随版本变化。
9
9
  - 足够的磁盘空间保存本地日报或导出副本。
10
10
 
package/package.json CHANGED
@@ -1,81 +1,83 @@
1
- {
2
- "name": "weflow-cli",
3
- "version": "1.6.2",
4
- "description": "让 AI 读懂你的微信 — 公众号日报·聊天导出·知识库搜索·MCP Server",
5
- "type": "module",
6
- "bin": {
7
- "weflow-cli": "./cli.cjs"
8
- },
9
- "files": [
10
- "cli.cjs",
11
- "dist/",
12
- "scripts/**/*.py",
13
- "mcp-server/",
14
- "bin/",
15
- "src/",
16
- "resources/",
17
- "docs/images/",
18
- "docs/*.md",
19
- "README.md",
20
- "OPERATIONS.md",
21
- "ARCHITECTURE.md",
22
- "CONTRIBUTING.md",
23
- "SECURITY.md",
24
- "CHANGELOG.md",
25
- "requirements*.txt",
26
- "LICENSE"
27
- ],
28
- "keywords": [
29
- "wechat",
30
- "weixin",
31
- "mcp",
32
- "rss",
33
- "daily",
34
- "公众号",
35
- "聊天记录",
36
- "ai",
37
- "deepseek",
38
- "claude"
39
- ],
40
- "repository": {
41
- "type": "git",
42
- "url": "https://github.com/zhuobichen/weflow-cli"
43
- },
44
- "scripts": {
45
- "build": "tsc",
46
- "test": "node --import tsx --test test/**/*.test.ts",
47
- "dev": "npx tsx bin/weflow-cli.ts",
48
- "start": "node cli.cjs",
49
- "prepublishOnly": "npm run build"
50
- },
51
- "dependencies": {
52
- "@modelcontextprotocol/sdk": "^1.29.0",
53
- "@wenyan-md/core": "^3.0.10",
54
- "chalk": "^5.4.1",
55
- "commander": "^13.1.0",
56
- "css-tree": "^3.2.1",
57
- "exceljs": "^4.4.0",
58
- "form-data-encoder": "^4.1.0",
59
- "formdata-node": "^6.0.3",
60
- "front-matter": "^4.0.2",
61
- "fzstd": "^0.1.1",
62
- "highlight.js": "^11.11.1",
63
- "inquirer": "^13.4.3",
64
- "jsdom": "^27.4.0",
65
- "koffi": "^2.9.0",
66
- "lz4": "^0.6.5",
67
- "marked": "^18.0.4",
68
- "marked-highlight": "^2.2.4",
69
- "qrcode-terminal": "^0.12.0"
70
- },
71
- "devDependencies": {
72
- "@types/node": "^22.0.0",
73
- "@types/qrcode-terminal": "^0.12.2",
74
- "electron": "^42.0.1",
75
- "tsx": "^4.19.0",
76
- "typescript": "^5.7.0"
77
- },
78
- "engines": {
79
- "node": ">=18"
80
- }
81
- }
1
+ {
2
+ "name": "weflow-cli",
3
+ "version": "1.6.4",
4
+ "description": "让 AI 读懂你的微信 — 公众号日报·聊天导出·知识库搜索·MCP Server",
5
+ "type": "module",
6
+ "bin": {
7
+ "weflow-cli": "./cli.cjs"
8
+ },
9
+ "files": [
10
+ "cli.cjs",
11
+ "dist/",
12
+ "scripts/**/*.py",
13
+ "mcp-server/",
14
+ "bin/",
15
+ "src/",
16
+ "resources/",
17
+ "docs/images/",
18
+ "docs/*.md",
19
+ "README.md",
20
+ "OPERATIONS.md",
21
+ "ARCHITECTURE.md",
22
+ "CONTRIBUTING.md",
23
+ "SECURITY.md",
24
+ "CHANGELOG.md",
25
+ "requirements*.txt",
26
+ "LICENSE"
27
+ ],
28
+ "keywords": [
29
+ "wechat",
30
+ "weixin",
31
+ "mcp",
32
+ "rss",
33
+ "daily",
34
+ "公众号",
35
+ "聊天记录",
36
+ "ai",
37
+ "deepseek",
38
+ "claude"
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/zhuobichen/weflow-cli"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc",
46
+ "test": "node --import tsx --test \"test/**/*.test.ts\"",
47
+ "dev": "npx tsx bin/weflow-cli.ts",
48
+ "start": "node cli.cjs",
49
+ "prepublishOnly": "npm run build"
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.29.0",
53
+ "@wenyan-md/core": "^3.0.10",
54
+ "chalk": "^5.4.1",
55
+ "commander": "^13.1.0",
56
+ "css-tree": "^3.2.1",
57
+ "exceljs": "^4.4.0",
58
+ "form-data-encoder": "^4.1.0",
59
+ "formdata-node": "^6.0.3",
60
+ "front-matter": "^4.0.2",
61
+ "fzstd": "^0.1.1",
62
+ "highlight.js": "^11.11.1",
63
+ "inquirer": "^13.4.3",
64
+ "jsdom": "^27.4.0",
65
+ "koffi": "^2.9.0",
66
+ "marked": "^18.0.4",
67
+ "marked-highlight": "^2.2.4",
68
+ "qrcode-terminal": "^0.12.0"
69
+ },
70
+ "optionalDependencies": {
71
+ "lz4": "^0.6.5"
72
+ },
73
+ "devDependencies": {
74
+ "@types/node": "^22.0.0",
75
+ "@types/qrcode-terminal": "^0.12.2",
76
+ "electron": "^42.0.1",
77
+ "tsx": "^4.19.0",
78
+ "typescript": "^5.7.0"
79
+ },
80
+ "engines": {
81
+ "node": ">=22.13.0"
82
+ }
83
+ }
@@ -72,10 +72,21 @@ except Exception:
72
72
  # Remote media is the single slowest step of an export: each miss costs a page
73
73
  # fetch plus an image download, and inline thumbnails alone run to several
74
74
  # hundred per conversation. Bound it per run and keep results on disk, misses
75
- # included, so re-exports are instant. Set by main().
75
+ # included, so re-exports are instant.
76
+ #
77
+ # Two separate pools: share-page covers are rare and expensive, inline appmsg
78
+ # thumbnails are common and cheap. Sharing one would let a run of thumbnails
79
+ # starve the covers that actually change how a page looks.
80
+ COVER_FETCH_LIMIT = 60
81
+ THUMB_FETCH_LIMIT = 300
82
+
83
+ # Seeded with the full budgets rather than 0: main() re-sets them, but anything
84
+ # that reaches the fetchers without going through main() used to find a budget
85
+ # of 0 and silently skip every remote fetch, returning None with no error.
76
86
  COVER_STATE = {
77
- 'dir': '', 'budget': 0, 'fetched': 0, 'cached': 0, 'skipped': 0,
78
- 'thumb_budget': 0, 'thumb_fetched': 0, 'thumb_cached': 0, 'thumb_skipped': 0,
87
+ 'dir': '', 'budget': COVER_FETCH_LIMIT, 'fetched': 0, 'cached': 0, 'skipped': 0,
88
+ 'thumb_budget': THUMB_FETCH_LIMIT,
89
+ 'thumb_fetched': 0, 'thumb_cached': 0, 'thumb_skipped': 0,
79
90
  }
80
91
 
81
92
  # Off by default: full-resolution originals are 5-24MB / 3000-5700px each, and
@@ -93,12 +104,6 @@ COVER_HEAD_BYTES = 64 * 1024
93
104
  # cost time, which matters across thousands of media entries.
94
105
  EMBED_SHRINK_THRESHOLD = 400 * 1024
95
106
 
96
- # Two separate budgets: share-page covers are rare and expensive, inline
97
- # appmsg thumbnails are common and cheap. Sharing one pool lets a run of
98
- # thumbnails starve the covers that actually change how a page looks.
99
- COVER_FETCH_LIMIT = 60
100
- THUMB_FETCH_LIMIT = 300
101
-
102
107
  # Set up by main(); custom stickers decrypted from WeChat's local cache.
103
108
  STICKER_STATE = {'key': b'', 'dirs': [], 'cache_dir': ''}
104
109
 
@@ -386,6 +391,29 @@ def scan_nt_cache(nt_cache_dir, talker, account_dir='', own_wxid=''):
386
391
  if key.startswith('md5:'):
387
392
  image_map.setdefault(key, image)
388
393
 
394
+ # `unique:<local_id>`: an id that resolves to exactly one distinct image in
395
+ # this conversation is safe to match on its own. The id restarts across
396
+ # shards, but uniqueness inside one conversation's scan rules that out -
397
+ # which is what separates this from the bare-local_id match that
398
+ # get_cached_image deliberately refuses. Deduped by content, so one picture
399
+ # cached at several timestamps still counts as unique.
400
+ local_candidates = {}
401
+ for key, image in image_map.items():
402
+ if isinstance(key, str) and key.startswith('pair:'):
403
+ parts = key.split(':', 2)
404
+ if len(parts) != 3:
405
+ continue
406
+ local_id = parts[1]
407
+ elif isinstance(key, int):
408
+ local_id = str(key)
409
+ else:
410
+ continue
411
+ digest = hashlib.sha256(image[0].encode('ascii')).hexdigest()
412
+ local_candidates.setdefault(local_id, {})[digest] = image
413
+ for local_id, candidates in local_candidates.items():
414
+ if len(candidates) == 1:
415
+ image_map[f'unique:{local_id}'] = next(iter(candidates.values()))
416
+
389
417
  return image_map
390
418
 
391
419
 
@@ -734,7 +762,9 @@ def get_cached_image(image_map, local_id, create_time, content='', resource_md5s
734
762
 
735
763
  Deliberately *not* falling back to a bare `local_id`: that id restarts in
736
764
  every message shard, so matching on it alone attaches one conversation's
737
- image to another conversation's message.
765
+ image to another conversation's message. `unique:<local_id>` is the sound
766
+ version of that idea - scan_nt_cache only emits it when the id maps to a
767
+ single distinct image, so uniqueness has already ruled the collision out.
738
768
  """
739
769
  if not image_map:
740
770
  return None
@@ -750,6 +780,12 @@ def get_cached_image(image_map, local_id, create_time, content='', resource_md5s
750
780
  return None
751
781
  if image_map.get(pair_key):
752
782
  return shrink_embedded(image_map[pair_key])
783
+ try:
784
+ unique_key = f'unique:{int(local_id)}'
785
+ except (TypeError, ValueError):
786
+ return None
787
+ if image_map.get(unique_key):
788
+ return shrink_embedded(image_map[unique_key])
753
789
  return None
754
790
 
755
791
 
@@ -1560,8 +1596,14 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
1560
1596
  local_id = row[0] or 0
1561
1597
  server_id = row[1] or 0
1562
1598
  local_type = row[2] or 0
1599
+ # WeChat stores some forwards as high-bit variants of type 49. The mask
1600
+ # below turns those into plain 49, which is a known type - so the cached
1601
+ # image branch further down can only recognise them if the pre-mask value
1602
+ # is kept. Dropping this made that branch unreachable.
1603
+ raw_local_type = local_type
1563
1604
  if local_type > 0xffffffff:
1564
1605
  local_type &= 0xffffffff
1606
+ is_encoded_media_type = raw_local_type > 0xffffffff and local_type == 49
1565
1607
  real_sender_id = row[4] or 0
1566
1608
  create_time = row[5] or 0
1567
1609
  source = row[7]
@@ -1611,6 +1653,11 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
1611
1653
  if content and content != source_text:
1612
1654
  metadata_parts.append(content)
1613
1655
  metadata_content = '\n'.join(metadata_parts)
1656
+ # A type-49 row carrying a title or url is a link/article card, not a bare
1657
+ # image. Without this guard the encoded-media fallback below would hide the
1658
+ # card (and its title/link) behind a cached thumbnail.
1659
+ has_app_link_metadata = local_type == 49 and bool(
1660
+ extract_xml_text(content, 'title') or extract_xml_text(content, 'url'))
1614
1661
  is_emoji_xml = bool(re.search(r'<(?:msg\s*>)?\s*<emoji\b|<emoji\b', metadata_content, re.IGNORECASE))
1615
1662
  is_contact_card = bool(
1616
1663
  re.search(r'<msg\b[^>]*(?:nickname|username)=', metadata_content, re.IGNORECASE)
@@ -1694,7 +1741,9 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
1694
1741
  if img_data:
1695
1742
  image_b64 = img_data
1696
1743
  display += f'<br><img src="data:{mime};base64,{img_data}" loading="lazy" />'
1697
- elif local_type not in MSG_TYPES and get_cached_image(image_map, local_id, create_time, content, resource_md5s):
1744
+ elif (local_type not in MSG_TYPES or is_encoded_media_type) \
1745
+ and not has_app_link_metadata \
1746
+ and get_cached_image(image_map, local_id, create_time, content, resource_md5s):
1698
1747
  # Some image messages use encoded types (e.g. 21474836529 = images in appmsg)
1699
1748
  # Check image_map for any message type
1700
1749
  img_data, mime = get_cached_image(image_map, local_id, create_time, content, resource_md5s)
@@ -1787,11 +1836,14 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
1787
1836
  builtin_title = _face_index.find_face(title)
1788
1837
  title_html = (render_builtin_emoji(title, builtin_title)
1789
1838
  if builtin_title else escape_html(title))
1790
- # Extract and embed article thumbnail image
1839
+ # Try the conversation cache before the network: a cached
1840
+ # thumbnail is already on disk and costs nothing, and without
1841
+ # this the article branch downloaded covers it already had.
1791
1842
  thumb_url = extract_appmsg_image(content)
1792
- if thumb_url:
1843
+ img_data = get_cached_image(image_map, local_id, create_time, metadata_content, resource_md5s)
1844
+ if not img_data and thumb_url:
1793
1845
  img_data = download_image_as_base64(thumb_url, extract_media_aes_key(content))
1794
- else:
1846
+ if not img_data:
1795
1847
  page_url = extract_xml_text(content, 'url')
1796
1848
  img_data = download_bilibili_cover(page_url)
1797
1849
  if not img_data: