weflow-cli 1.6.3 → 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,26 @@ 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
+
7
27
  ## 1.6.3
8
28
 
9
29
  ### 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
@@ -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+. |
@@ -26,9 +26,17 @@ grep -c " dist/" /tmp/packlist.txt # 注意: 行首是体积, 不是路径
26
26
 
27
27
  **别信命令的输出,信 registry 的 `versions` 表:**
28
28
 
29
+ 下面用 `X.Y.Z` 代表这次要发的版本,**照抄时换成实际版本号**——写死在这里的版本
30
+ 过几天就是错的。
31
+
29
32
  ```bash
33
+ V=X.Y.Z
30
34
  curl -s "https://registry.npmjs.org/weflow-cli?nocache=$RANDOM" --max-time 30 \
31
- | python -c "import json,sys; d=json.load(sys.stdin); print('latest:', d['dist-tags']['latest']); print('目标版本存在:', '1.6.2' in d['versions'])"
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"
32
40
  ```
33
41
 
34
42
  - `目标版本存在: True` → **发布成功**,剩下只是传播
@@ -40,11 +48,11 @@ curl -s "https://registry.npmjs.org/weflow-cli?nocache=$RANDOM" --max-time 30 \
40
48
 
41
49
  | 现象 | 含义 | 动作 |
42
50
  | --- | --- | --- |
43
- | `+ weflow-cli@1.6.2` | 已被接受 | 继续,等传播 |
51
+ | `+ weflow-cli@X.Y.Z` | 已被接受 | 继续,等传播 |
44
52
  | `being processed and may take a few minutes` | 正常提示 | 等,别重发 |
45
53
  | `npm view` 仍是旧版本 | CDN 传播中 | **不是失败**,等 2–5 分钟 |
46
54
  | 重发报 `409 Conflict - Cannot publish over previously staged version` | **已经发布成功了** | 别再发,去等传播 |
47
- | tarball `404`(`/-/weflow-cli-1.6.2.tgz`) | 元数据已发布,tarball 还在传播 | 等,不是失败 |
55
+ | tarball `404`(`/-/weflow-cli-X.Y.Z.tgz`) | 元数据已发布,tarball 还在传播 | 等,不是失败 |
48
56
  | `install` 报 `ETARGET No matching version` | 客户端缓存了旧 packument | 加 `--prefer-online` 重试 |
49
57
  | `install` 报 `404` | 元数据在、tarball 未到 | 等传播 |
50
58
  | `403 ... bypass 2fa enabled is required` | token 没勾 Bypass 2FA | 重新生成 token(见第 3 节) |
@@ -90,7 +98,7 @@ npm view weflow-cli version --registry=https://registry.npmmirror.com
90
98
 
91
99
  ```bash
92
100
  mkdir -p /tmp/verify && cd /tmp/verify && npm init -y >/dev/null
93
- npm install weflow-cli@1.6.2 --registry=https://registry.npmjs.org --prefer-online --no-audit --no-fund
101
+ npm install weflow-cli@X.Y.Z --registry=https://registry.npmjs.org --prefer-online --no-audit --no-fund
94
102
  node -e "console.log(require('./node_modules/weflow-cli/package.json').version)"
95
103
  ```
96
104
 
@@ -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/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,6 +1,6 @@
1
1
  {
2
2
  "name": "weflow-cli",
3
- "version": "1.6.3",
3
+ "version": "1.6.4",
4
4
  "description": "让 AI 读懂你的微信 — 公众号日报·聊天导出·知识库搜索·MCP Server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "scripts": {
45
45
  "build": "tsc",
46
- "test": "node --import tsx --test test/**/*.test.ts",
46
+ "test": "node --import tsx --test \"test/**/*.test.ts\"",
47
47
  "dev": "npx tsx bin/weflow-cli.ts",
48
48
  "start": "node cli.cjs",
49
49
  "prepublishOnly": "npm run build"
@@ -78,6 +78,6 @@
78
78
  "typescript": "^5.7.0"
79
79
  },
80
80
  "engines": {
81
- "node": ">=18"
81
+ "node": ">=22.13.0"
82
82
  }
83
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: