weflow-cli 1.11.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/docs/RELEASING.md CHANGED
@@ -21,6 +21,19 @@ npm 包与 GitHub 分开发布,中间隔多久都不奇怪——`1.5.0` 与主
21
21
  已备好但**从未发布**的版本号可以并进下一个:`1.5.1` 准备过却没发,
22
22
  它的修复就直接并进了 `1.6.0`。
23
23
 
24
+ **改 `package.json` 的版本号时必须同时改 `package-lock.json`**(lockfileVersion 3 里有两个地方:
25
+ 顶层的 `version` 与 `packages[""].version`)。**2026-10-13 发 1.11.0 时就是这么红了一次**:包已经发出去、
26
+ tag 也打了,`release-consistency` 才报「版本号与锁文件必须一致」—— 我本地只跑了 build/test/文档命令检查,
27
+ 而这三样都不看锁文件。发布物本身没问题(`files` 白名单里没有锁文件),但 master 的 CI 红了。
28
+
29
+ ```powershell
30
+ node -p "require('./package.json').version" # 两个必须相等
31
+ node -p "require('./package-lock.json').version"
32
+ ```
33
+
34
+ 顺手验一下锁文件没跑偏(只写锁、不动 `node_modules`):
35
+ `npm install --package-lock-only --dry-run` —— 报 `up to date` 即可,那也正是 `npm ci` 会不会炸的判据。
36
+
24
37
  ## 2. 整理 CHANGELOG
25
38
 
26
39
  - 把 `## Unreleased` 改名为 `## <新版本号>`
@@ -229,6 +242,26 @@ npm view weflow-cli version --registry=https://registry.npmjs.org # 用官方
229
242
  3cf5605,它不在包里那一份 tarball 的对应关系里 —— 写下来是因为 1.8.0 那次正是"事后不知道 tag 对应哪个
230
243
  提交")。GitHub Release `v1.10.0` 同样指向它,正文由 `## 1.10.0` 段机械切出(92,213 字符,未触及
231
244
  125,000 上限 ⇒ 未截断),List 上已是 **Latest**。
245
+ - **1.11.0 的出处核过了(2026-10-13,发布当天,四种证据)**:
246
+ 1. 本地 `npm pack` 的 shasum = `21db27cbb151987331c7993c625eb4626c23f58a`,与**两个源**上
247
+ `weflow-cli@1.11.0` 的 `dist.shasum` **三者相同** ⇒ 发出去的就是我构建的那一份,且镜像同步的是同一份;
248
+ 2. 从 registry(不是本地 tarball)装进一个**空目录**,用**装好的那份**跑 `cli.cjs --version` 得到 `1.11.0`、
249
+ `--help` 72 行含 `Commands:`;1.9.0 缺过的 `logo.png` 与 `scripts/data/wechat_emoji.json` 都在,
250
+ 开发用探针 `.mjs` 不在包里(有意,见 `test/pack-contents.test.ts`);
251
+ 3. **本次真正改动的那个脚本逐字节核过**:装好的那份 `scripts/export_chat_html.py` 的 sha256
252
+ = `73117B7A35347D68FE239D660C9A0531E231BA8CA7A0A31F8DA0C07D5D54C041`,与仓库里那份**相同**
253
+ ⇒ 用户拿到的是这一版脚本,不是旧的。这是比"文件在不在"更强的一层:**在,而且是我这次写的那份**;
254
+ 4. 两个源都是 1.11.0:官方源与 `registry.npmmirror.com`(`curl -X PUT …/syncs` 受理后,发布后约 1 分钟
255
+ 查 shasum 仍取不到、20 分钟内再查时两源都已生效 —— 这次的窗口比 1.10.0 那次(约 4 分钟)长,
256
+ 但**没有逐点采样**,所以只记"两次查询的区间",不写一个假的分钟数)。
257
+ **发布日志 0 条告警**;**tag 打在被发布的那个提交上**:`v1.11.0` → `bd82316`(`821479b` 是功能提交,
258
+ `bd82316` 只改了 `package.json` 版本号与 CHANGELOG 段名)。GitHub Release `v1.11.0` 指向 `bd82316`,
259
+ 正文由 `## 1.11.0` 段机械切出(9,689 字符、7 条 bullet、未触及 125,000 上限 ⇒ 未截断),
260
+ 建完在 List 上已是 **Latest**(把 1.10.0 挤下去是预期行为)。
261
+ ⚠️ **这次发布本身有一次 CI 失败,记在这里免得事后只看到"一切正常"**:`chore: 1.11.0` 那次提交的
262
+ `release-consistency` 报「版本号与锁文件必须一致」—— 我改了 `package.json` 忘了 `package-lock.json`,
263
+ 连红三次(含紧随其后的文档提交)。**发布物不受影响**(白名单里没有锁文件、shasum 与装出来的那份都验过),
264
+ 但 master 的 CI 是红的;修在 `f9e55c8`,之后两次推送都是 success。清单第 1 步已补上这条检查。
232
265
 
233
266
 
234
267
  ## 发布后
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weflow-cli",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "让 AI 读懂你的微信 — 公众号日报·聊天导出·知识库搜索·MCP Server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@ import re
14
14
  import base64
15
15
  import urllib.request
16
16
  import urllib.error
17
+ import urllib.parse
17
18
  import concurrent.futures
18
19
  import shutil
19
20
  import struct
@@ -539,6 +540,10 @@ def scan_account_file_index(account_dir):
539
540
  except OSError:
540
541
  continue
541
542
  index.setdefault((normalize_asset_name(fname), size), []).append(path)
543
+ # 排序是为了**可复现**:多候选且消息没带 md5 时取 `[0]`,而 `os.walk` 的顺序不保证稳定 ——
544
+ # 不排的话,同一次导出在两台机器上可能落到不同名字的副本上。
545
+ for paths in index.values():
546
+ paths.sort()
542
547
  return index
543
548
 
544
549
 
@@ -2448,19 +2453,77 @@ p {{ margin:8px 0; font-size:14px; }}
2448
2453
  </html>'''
2449
2454
 
2450
2455
 
2456
+ _WINDOWS_RESERVED_NAMES = frozenset(
2457
+ ['con', 'prn', 'aux', 'nul']
2458
+ + ['com%d' % index for index in range(1, 10)]
2459
+ + ['lpt%d' % index for index in range(1, 10)])
2460
+
2461
+
2462
+ def _clean_asset_component(text):
2463
+ """把一段文件名清洗到"任何 Windows 上都能创建"。
2464
+
2465
+ 在 `sanitize_filename` 之外多管一件会**静默改结果**的事:首尾的空白与点(Windows 会把 `a .` 存成 `a`,
2466
+ 于是清单里的名字和磁盘上的名字对不上)。
2467
+ """
2468
+ return sanitize_filename(text).strip().strip('.')
2469
+
2470
+
2451
2471
  def _asset_file_name(title, used_names, source, md5):
2452
2472
  """落盘的文档文件名:保持原样(只清洗非法字符),重名才加 md5 前 8 位。
2453
2473
 
2454
2474
  不改名是刻意的 —— 那是用户自己的文件名,读者要靠它认东西;微信自己也会加 `(1)`。但两个不同来源
2455
2475
  的文件可能同名,那种情况必须区分,否则后来者会覆盖先到者、清单指错文件。
2476
+
2477
+ **截断要在扩展名之前**:真机实测过一个超长标题,80 字符上限把 `.pdf` 一起切掉了,落盘成没有扩展名的
2478
+ 文件 —— 系统与编辑器都不知道该用什么打开它。
2456
2479
  """
2457
- safe = sanitize_filename(title).strip().strip('.') or 'file'
2480
+ stem, ext = os.path.splitext(str(title or ''))
2481
+ # 扩展名单独保留(也过一遍清洗),但不参与"给主干剩下的额度";异常长的"扩展名"当没扩展名处理
2482
+ ext = _clean_asset_component(ext).replace('.', '')
2483
+ if len(ext) > 16:
2484
+ stem, ext = str(title or ''), ''
2485
+ stem = _clean_asset_component(stem)
2486
+ room = max(1, 80 - (len(ext) + 1 if ext else 0))
2487
+ base = stem[:room] or 'file'
2488
+ if base.lower() in _WINDOWS_RESERVED_NAMES:
2489
+ # `CON.pdf` 这类在 Windows 上打不开(会被当成设备名)—— 加个前缀就正常了
2490
+ base = '_' + base
2491
+ safe = base + ('.' + ext if ext else '')
2458
2492
  if used_names.get(safe) in (None, source):
2459
2493
  return safe
2460
2494
  stem, ext = os.path.splitext(safe)
2461
2495
  return '%s_%s%s' % (stem, (md5 or 'copy')[:8], ext)
2462
2496
 
2463
2497
 
2498
+ def _load_existing_manifest(manifest_path, out_dir):
2499
+ """读回同目录里已有的清单条目(v1 的裸字符串也认),丢掉**已经不在磁盘上**的那些。
2500
+
2501
+ 为什么不是直接覆盖:清单住在 `<会话>_assets/` 这个**共享**目录里,而导出可以分批做
2502
+ (`--limit 200` 一次、整段再一次;md 一次、`json --media` 再一次)。覆盖会让窄窗口那次的清单把宽窗口
2503
+ 那次的条目**删掉**,而 md 正文里的链接还在 —— 之后任何"读清单带媒体"的导出都会少一批,
2504
+ 且两边都不报错。2026-10-13 真机就是这么撞上的:整段导出的 585 条被 `--limit 600` 那次的 150 条盖掉。
2505
+ """
2506
+ try:
2507
+ with open(manifest_path, 'r', encoding='utf-8') as handle:
2508
+ data = json.load(handle)
2509
+ except (OSError, ValueError):
2510
+ return {}
2511
+ messages = data.get('messages') if isinstance(data, dict) else None
2512
+ if not isinstance(messages, dict):
2513
+ return {}
2514
+ kept = {}
2515
+ for key, value in messages.items():
2516
+ if isinstance(value, str):
2517
+ record = {'path': value, 'kind': 'image'}
2518
+ elif isinstance(value, dict) and isinstance(value.get('path'), str):
2519
+ record = value
2520
+ else:
2521
+ continue
2522
+ if os.path.isfile(os.path.join(out_dir, record['path'].replace('/', os.sep))):
2523
+ kept[key] = record
2524
+ return kept
2525
+
2526
+
2464
2527
  def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
2465
2528
  """把媒体**落盘**并把 `manifest.json` 写好;返回 `(assets 目录名, manifest, 新写文件数)`。
2466
2529
 
@@ -2477,7 +2540,9 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
2477
2540
  assets = assets_dir or os.path.join(out_dir, file_prefix + '_assets')
2478
2541
  os.makedirs(assets, exist_ok=True)
2479
2542
 
2480
- manifest = {}
2543
+ # 与已有清单**合并**:分批导出时,窄窗口那一次不该把宽窗口那次的条目删掉(见 `_load_existing_manifest`)。
2544
+ manifest_path = os.path.join(assets, 'manifest.json')
2545
+ manifest = _load_existing_manifest(manifest_path, out_dir)
2481
2546
  written = 0
2482
2547
  for item in formatted:
2483
2548
  blob = item.get('image_b64')
@@ -2502,7 +2567,6 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
2502
2567
  'kind': 'image',
2503
2568
  }
2504
2569
 
2505
- file_exported = 0
2506
2570
  file_written = 0
2507
2571
  file_unavailable = 0
2508
2572
  files_dir = os.path.join(assets, _FILE_ASSET_SUBDIR)
@@ -2526,13 +2590,18 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
2526
2590
  info.get('md5'))
2527
2591
  target = os.path.join(files_dir, name)
2528
2592
  if used_names.get(name) != source:
2529
- os.makedirs(files_dir, exist_ok=True)
2530
- if not os.path.exists(target):
2531
- with open(target, 'wb') as dst, open(source, 'rb') as src:
2532
- shutil.copyfileobj(src, dst)
2533
- file_written += 1
2593
+ try:
2594
+ os.makedirs(files_dir, exist_ok=True)
2595
+ if not os.path.exists(target):
2596
+ with open(target, 'wb') as dst, open(source, 'rb') as src:
2597
+ shutil.copyfileobj(src, dst)
2598
+ file_written += 1
2599
+ except OSError:
2600
+ # 一个文件拷不进去(名字太怪、路径过长、目标被占用)不能让**整次导出**在最后一步崩掉 ——
2601
+ # 前面几分钟的解析与图片抽取都在,代价只是这一份文档缺失,且它会计入 unavailable。
2602
+ file_unavailable += 1
2603
+ continue
2534
2604
  used_names[name] = source
2535
- file_exported += 1
2536
2605
  manifest['%s:%s' % (item.get('local_id'), item.get('create_time'))] = {
2537
2606
  'path': os.path.relpath(target, out_dir).replace('\\', '/'),
2538
2607
  'kind': 'file',
@@ -2540,16 +2609,38 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
2540
2609
  'bytes': size,
2541
2610
  }
2542
2611
 
2612
+ # `exported` 数的是**合并后清单里真实存在的文档条目**(第二遍跑同一目录时它不为 0,而新拷贝是 0);
2613
+ # `copied` 才是"这次新拷了几个"。两个都写出来,免得读清单的人把后者当前者。
2614
+ file_exported = sum(1 for record in manifest.values() if record.get('kind') == 'file')
2543
2615
  FILE_ASSET_STATE['exported'] = file_exported
2544
2616
  FILE_ASSET_STATE['written'] = file_written
2545
2617
  FILE_ASSET_STATE['unavailable'] = file_unavailable
2546
- with open(os.path.join(assets, 'manifest.json'), 'w', encoding='utf-8') as fh:
2618
+ with open(manifest_path, 'w', encoding='utf-8') as fh:
2547
2619
  json.dump({'schema': 'weflow-media-manifest/v2', 'messages': manifest,
2548
- 'documents': {'exported': file_exported, 'unavailable': file_unavailable}},
2620
+ 'documents': {'exported': file_exported, 'copied': file_written,
2621
+ 'unavailable': file_unavailable}},
2549
2622
  fh, ensure_ascii=False, indent=2)
2550
2623
  return os.path.basename(os.path.normpath(assets)), manifest, written + file_written
2551
2624
 
2552
2625
 
2626
+ def _markdown_link_target(rel):
2627
+ """把相对路径转成 markdown 链接目标:只编码会**截断链接**的那些字符。
2628
+
2629
+ 真机实测(2026-10-13,重导咸鱼梦想家时核对产物才发现):文件名叫
2630
+ `…研究生学位(毕业)论文开题报告(2).pdf`,写出来是 `[名字](…报告(2).pdf)` —— 任何 markdown 渲染器
2631
+ 都在第一个半角 `)` 处结束目标,链接指向 `…报告(2`,点开是空的。带空格的文件名同理(目标在空白处结束)。
2632
+ 所以按段编码这几个字符(括号、空白、`<`、`>`、`[`、`]`、`#`、`?`、`%`、引号、反斜杠),
2633
+ **中文保持原样**:本地预览器都按 UTF-8 解路径,编码成 `%E5%…` 反而让人看不懂。
2634
+ """
2635
+ out = []
2636
+ for ch in str(rel or '').replace('\\', '/'):
2637
+ if ch.isspace() or ch in '()[]<>#"\'%?\\':
2638
+ out.append('%%%02X' % ord(ch))
2639
+ else:
2640
+ out.append(ch)
2641
+ return ''.join(out)
2642
+
2643
+
2553
2644
  def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
2554
2645
  """markdown 正文 + 媒体落盘(落盘那半在 `write_assets`,两者共用同一份清单)。"""
2555
2646
  assets_name, manifest, written = write_assets(out_dir, file_prefix, formatted, assets_dir)
@@ -2573,9 +2664,15 @@ def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
2573
2664
  if rel and kind == 'file':
2574
2665
  # 文档不是图片:`![](…)` 会被当成坏图,要写成链接,链接文字用原始文件名。
2575
2666
  keep = '' if (not body or body.startswith('[文件]')) else body + chr(10) + chr(10)
2576
- body = keep + '[%s](%s)' % (label or os.path.basename(rel), rel)
2667
+ # 链接文字用原始文件名;`[`/`]` 换成实体(`[草稿] 方案.docx` 会把链接本身的结构打断),
2668
+ # 控制字符去掉(标题里可能带换行/制表符,落盘名已去掉,文字也得跟上,否则渲染出来断开)
2669
+ text = ''.join(ch for ch in (label or os.path.basename(rel))
2670
+ if ord(ch) >= 32 and ord(ch) != 127)
2671
+ text = text.replace('[', '&#91;').replace(']', '&#93;')
2672
+ body = keep + '[%s](%s)' % (text, _markdown_link_target(rel))
2577
2673
  elif rel:
2578
- body = ('' if (not body or body == '[图片]') else body + chr(10) + chr(10)) + '![图片](%s)' % rel
2674
+ body = ('' if (not body or body == '[图片]') else body + chr(10) + chr(10)) \
2675
+ + '![图片](%s)' % _markdown_link_target(rel)
2579
2676
  lines.extend(['**%s** %s' % (who, stamp), '', body.strip() or '(空)', ''])
2580
2677
 
2581
2678
  md_path = os.path.join(out_dir, file_prefix + '.md')
@@ -2924,8 +3021,13 @@ def _phase(label):
2924
3021
 
2925
3022
 
2926
3023
  def sanitize_filename(name: str) -> str:
2927
- """Remove characters unsafe for filenames."""
2928
- return re.sub(r'[\\/:*?"<>|]', '_', name)[:80]
3024
+ """Remove characters unsafe for filenames.
3025
+
3026
+ 控制字符也要去掉:它们不在"非法字符"那张表里,但 Windows 创建时会直接
3027
+ `OSError: Invalid argument`(2026-10-13 实测到一个带制表符的标题把整个导出搞崩)。
3028
+ """
3029
+ kept = ''.join(ch for ch in str(name or '') if ord(ch) >= 32 and ord(ch) != 127)
3030
+ return re.sub(r'[\\/:*?"<>|]', '_', kept)[:80]
2929
3031
 
2930
3032
 
2931
3033
  if __name__ == '__main__':