weflow-cli 1.10.0 → 1.11.0

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.
@@ -15,7 +15,9 @@ import base64
15
15
  import urllib.request
16
16
  import urllib.error
17
17
  import concurrent.futures
18
+ import shutil
18
19
  import struct
20
+ import xml.etree.ElementTree as ET
19
21
  import json
20
22
  from pathlib import Path
21
23
 
@@ -94,6 +96,25 @@ COVER_STATE = {
94
96
  'thumb_fetched': 0, 'thumb_cached': 0, 'thumb_skipped': 0,
95
97
  }
96
98
 
99
+ # 落盘时用的扩展名:按**字节**判(`detect_mime_from_bytes`),不按来源猜 —— 实测同一会话 556 张图里
100
+ # 87 个是 GIF、27 个是 PNG,一律叫 .jpg 会让编辑器按扩展名解码、把动画表情显示成坏图。
101
+ _IMAGE_EXT_BY_MIME = {'image/jpeg': '.jpg', 'image/png': '.png',
102
+ 'image/gif': '.gif', 'image/webp': '.webp'}
103
+
104
+ # 文档本体(appmsg `type=6`)从账号目录的 `msg/file/` 拷进 assets。原件是用户自己的文件:不重编码、
105
+ # 扩展名保持原样(编辑器/系统按扩展名决定用什么打开)。上限只防"一个几百 MB 的压缩包把导出变成磁盘炸弹",
106
+ # 超限的**不静默丢**:计入 `FILE_ASSET_STATE['unavailable']` 并写进清单顶部计数。
107
+ MAX_ASSET_FILE_BYTES = 64 * 1024 * 1024
108
+ _FILE_ASSET_SUBDIR = 'files'
109
+
110
+ # 文档那半的计数(图片那半的计数在写盘时现算)。放在模块级是为了不改 `write_assets` 的返回形状 ——
111
+ # 它已经有两个调用方(markdown 导出与 `--media`),与 COVER_STATE/VOICE_STATE 是同一套写法。
112
+ #
113
+ # `exported` 与 `written` 是两件事:`written` 只数**这次新拷**的,`exported` 数"这条消息的文档确实在
114
+ # assets 里"。分开是因为第二次跑同一个目录时新拷贝是 0 —— 只报 `written` 会打印成"0 document(s)",
115
+ # 而实际上 67 份都在,那是给自己制造假象(2026-10-13 真的这么打印过一次)。
116
+ FILE_ASSET_STATE = {'exported': 0, 'written': 0, 'unavailable': 0}
117
+
97
118
  # Off by default: full-resolution originals are 5-24MB / 3000-5700px each, and
98
119
  # downscaling ~500 of them costs minutes (PIL decode dominates). The cache's
99
120
  # own thumbnails need no processing at all and are what an export normally
@@ -488,6 +509,71 @@ def scan_account_media(account_dir, own_wxid='', talker=''):
488
509
  return image_map
489
510
 
490
511
 
512
+ def normalize_asset_name(name):
513
+ """把消息里的 `title` 与磁盘文件名归一成同一个键。
514
+
515
+ 微信写盘时会给自己加一份副本后缀:消息里是 `晚餐聚餐安排(学术研讨会后).docx`,磁盘上是
516
+ `晚餐聚餐安排(学术研讨会后)(1).docx`(实测)。半角全角都出现过,所以两种括号都剥。
517
+ """
518
+ stem, ext = os.path.splitext(str(name or ''))
519
+ return (re.sub(r'[\s]*[((]\d+[))]$', '', stem).strip().lower() + ext.lower())
520
+
521
+
522
+ def scan_account_file_index(account_dir):
523
+ """`msg/file/**` 的**只 stat 索引**:`{(归一化名, 字节数): [路径]}`。
524
+
525
+ 为什么不照图片那套按 talker 缩范围:`msg/file` 按月分目录(`msg/file/2024-04/…`),目录里没有
526
+ talker 的痕迹,一条会话的文件散在所有月份里。好在它只 `stat` 不读内容 —— 真机 1,677 个文件
527
+ 不到一秒。键带上字节数是因为同名而内容不同的情形确实存在;真正的消歧靠 md5,但那要读盘,
528
+ 所以只留给**多候选**那一档(实测 1,855 条文件消息里 154 条落到这档,全部认下)。
529
+ """
530
+ index = {}
531
+ root = os.path.join(account_dir, 'msg', 'file')
532
+ if not os.path.isdir(root):
533
+ return index
534
+ for current_root, _, files in os.walk(root):
535
+ for fname in files:
536
+ path = os.path.join(current_root, fname)
537
+ try:
538
+ size = os.path.getsize(path)
539
+ except OSError:
540
+ continue
541
+ index.setdefault((normalize_asset_name(fname), size), []).append(path)
542
+ return index
543
+
544
+
545
+ def resolve_account_file(info, file_index):
546
+ """把一条文件消息对到本地那份文件上;对不上返回 None(不猜,D-014)。
547
+
548
+ 先按 `(归一化名, 字节数)` 查:真机 1,855 条里 1,489 条唯一命中;多候选才读盘比 md5。整体命中
549
+ 88.6%,剩下的 11.4% 是本地确实没有那份文件(未下载 / 已清理),与图片那半同一种情况。
550
+ """
551
+ if not info or not file_index:
552
+ return None
553
+ name = normalize_asset_name(info.get('title', ''))
554
+ size = info.get('bytes') or 0
555
+ candidates = file_index.get((name, size)) if size else None
556
+ if candidates is None:
557
+ # `totallen` 缺失(少数消息没有)时退化成按名字查一遍:只遍历键,不读盘。
558
+ candidates = [path for (key_name, _size), paths in file_index.items()
559
+ if key_name == name for path in paths]
560
+ if not candidates:
561
+ return None
562
+ if len(candidates) == 1:
563
+ return candidates[0]
564
+ want = (info.get('md5') or '').lower()
565
+ if want:
566
+ for path in candidates:
567
+ try:
568
+ with open(path, 'rb') as fh:
569
+ if hashlib.md5(fh.read()).hexdigest() == want:
570
+ return path
571
+ except OSError:
572
+ continue
573
+ return None
574
+ return candidates[0]
575
+
576
+
491
577
  def clean_account_wxid(value):
492
578
  value = str(value or '').strip()
493
579
  parts = value.rsplit('_', 1)
@@ -1610,7 +1696,49 @@ def sender_matches_account(sender_user_name, own_wxid):
1610
1696
  return False
1611
1697
 
1612
1698
 
1613
- def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display_name='', resource_map=None, own_wxid=''):
1699
+ def parse_file_appmsg(content):
1700
+ """顶层 appmsg `type=6`(文件)的元数据,或 None。
1701
+
1702
+ 取的是 `<appmsg>` 的**直接子元素**,不是"整段里第一个同名标签"。原因:引用消息(type 57)把被引用
1703
+ 的原文整个塞进 `<refermsg>`,而 `<refermsg>` 就在 `<appmsg>` **内部** —— 那份原文里的 `<type>6</type>`
1704
+ /`<title>`/`<appattach>` 与这条消息自己的字段处在同一段文本里,"切到 appmsg 块"和"取第一个匹配"
1705
+ 都分不开,只有按层级取才分得开。2026-10-13 首次测量用正则满文档搜,7,083 条"像文件"里绝大多数
1706
+ 就是这种引用原文。
1707
+ """
1708
+ if not content:
1709
+ return None
1710
+ match = re.search(r'<appmsg[\s>].*?</appmsg>', str(content), re.S)
1711
+ if not match:
1712
+ return None
1713
+ try:
1714
+ element = ET.fromstring('<root>' + match.group(0) + '</root>').find('appmsg')
1715
+ except ET.ParseError:
1716
+ # 真机 7,878 行里有 2 行解析不了(转义残缺)。返回 None = 当"不是文件消息":少带出一个文件,
1717
+ # 好过把某份引用原文的名字当成本条文件的名字。
1718
+ return None
1719
+ if element is None:
1720
+ return None
1721
+ fields = {child.tag: (child.text or '').strip() for child in element}
1722
+ if fields.get('type') != '6':
1723
+ return None
1724
+ title = fields.get('title', '')
1725
+ if not title:
1726
+ return None
1727
+ attachment = element.find('appattach')
1728
+ attach = {child.tag: (child.text or '').strip() for child in attachment} if attachment is not None else {}
1729
+ try:
1730
+ total = int(fields.get('totallen') or attach.get('totallen') or '0')
1731
+ except ValueError:
1732
+ total = 0
1733
+ return {
1734
+ 'title': title,
1735
+ 'ext': (fields.get('fileext') or attach.get('fileext') or '').lower(),
1736
+ 'md5': (fields.get('md5') or attach.get('md5') or '').lower(),
1737
+ 'bytes': total,
1738
+ }
1739
+
1740
+
1741
+ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display_name='', resource_map=None, own_wxid='', file_index=None):
1614
1742
  """Format a single message for HTML display.
1615
1743
 
1616
1744
  Args:
@@ -1620,6 +1748,8 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
1620
1748
  image_map: {local_id: (base64_data, mime_type)} from NT cache scan
1621
1749
  sender_map: {sender_id: user_name} from Name2Id table
1622
1750
  display_name: human-readable name for the target talker
1751
+ file_index: {(归一化名, 字节数): [路径]} from scan_account_file_index, for
1752
+ resolving a document attachment to the copy on disk
1623
1753
  """
1624
1754
  is_group = '@chatroom' in str(talker or '')
1625
1755
  local_id = row[0] or 0
@@ -1730,6 +1860,12 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
1730
1860
  if '\x00' in content or sum(ord(char) < 32 and char not in '\n\r\t' for char in content) > 2:
1731
1861
  content = ''
1732
1862
 
1863
+ # 文档本体:消息里只有名字与 md5,文件本身在账号目录的 `msg/file/` 下。这里只**解析并定位**,
1864
+ # 不读也不拷 —— 拷贝发生在写 assets 那一步(HTML 那条路根本不落盘:它把图片内嵌成 base64,
1865
+ # 一个几 MB 的 PDF 内嵌进网页既没人打得开、也把文件撑大)。
1866
+ file_info = parse_file_appmsg(content) if local_type == 49 else None
1867
+ file_hit = resolve_account_file(file_info, file_index)
1868
+
1733
1869
  # Determine display content
1734
1870
  display = ''
1735
1871
  image_b64 = None
@@ -1920,19 +2056,24 @@ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display
1920
2056
  'local_type': local_type,
1921
2057
  'display': display,
1922
2058
  'image_b64': image_b64,
2059
+ # 文档本体的落点(`path` 为 None = 本地没有那份文件)。写 assets 时才拷。
2060
+ 'file': ({'title': file_info['title'], 'ext': file_info['ext'],
2061
+ 'bytes': file_info['bytes'], 'md5': file_info['md5'],
2062
+ 'path': file_hit} if file_info else None),
1923
2063
  # Built here rather than accumulated in module state: format_message
1924
2064
  # runs twice (the prefetch dry sweep and the real pass) and the dry
1925
2065
  # pass is discarded, so a counter would double-count.
1926
2066
  'media': build_media_record(
1927
2067
  local_type, image_b64, image_map, local_id, create_time, content,
1928
- resource_md5s, metadata_content, bool(server_id)),
2068
+ resource_md5s, metadata_content, bool(server_id),
2069
+ file_info, file_hit),
1929
2070
  }
1930
2071
 
1931
2072
 
1932
2073
  MEDIA_KIND_BY_TYPE = {
1933
2074
  3: 'image', 34: 'voice', 43: 'video-thumb', 47: 'emoji', 49: 'link-cover',
1934
2075
  }
1935
- MEDIA_KINDS = {'image', 'emoji', 'voice', 'video-thumb', 'link-cover'}
2076
+ MEDIA_KINDS = {'image', 'emoji', 'voice', 'video-thumb', 'link-cover', 'file'}
1936
2077
 
1937
2078
 
1938
2079
  def build_media_report(items, scope, talker, per_page):
@@ -1980,7 +2121,8 @@ def build_media_report(items, scope, talker, per_page):
1980
2121
 
1981
2122
 
1982
2123
  def build_media_record(local_type, image_b64, image_map, local_id, create_time,
1983
- content, resource_md5s, metadata_content, has_server_id):
2124
+ content, resource_md5s, metadata_content, has_server_id,
2125
+ file_info=None, file_hit=None):
1984
2126
  """What happened to this message's media, or None for a non-media message.
1985
2127
 
1986
2128
  Derived from the same `resolve_media` the page used, so the report cannot
@@ -1995,6 +2137,19 @@ def build_media_record(local_type, image_b64, image_map, local_id, create_time,
1995
2137
  'mime': None, 'bytes': None, 'sha256': None, 'reason': None,
1996
2138
  }
1997
2139
 
2140
+ if file_info is not None:
2141
+ # 文档本体(appmsg type 6)走的是另一条路:没有缩略图、也不在图片索引里,能不能导出只看
2142
+ # 本地 `msg/file/` 有没有那份文件。不并入图片那条链,否则报告会把"文件没下载"写成"没有可靠标识"。
2143
+ record['kind'] = 'file'
2144
+ record['bytes'] = file_info.get('bytes') or None
2145
+ if file_hit:
2146
+ record['status'] = 'exported'
2147
+ record['mediaKey'] = 'file'
2148
+ else:
2149
+ record['status'] = 'missing'
2150
+ record['reason'] = 'file-not-in-local-storage'
2151
+ return record
2152
+
1998
2153
  if image_b64:
1999
2154
  hit = resolve_media(image_map, local_id, create_time, metadata_content,
2000
2155
  resource_md5s)
@@ -2293,6 +2448,141 @@ p {{ margin:8px 0; font-size:14px; }}
2293
2448
  </html>'''
2294
2449
 
2295
2450
 
2451
+ def _asset_file_name(title, used_names, source, md5):
2452
+ """落盘的文档文件名:保持原样(只清洗非法字符),重名才加 md5 前 8 位。
2453
+
2454
+ 不改名是刻意的 —— 那是用户自己的文件名,读者要靠它认东西;微信自己也会加 `(1)`。但两个不同来源
2455
+ 的文件可能同名,那种情况必须区分,否则后来者会覆盖先到者、清单指错文件。
2456
+ """
2457
+ safe = sanitize_filename(title).strip().strip('.') or 'file'
2458
+ if used_names.get(safe) in (None, source):
2459
+ return safe
2460
+ stem, ext = os.path.splitext(safe)
2461
+ return '%s_%s%s' % (stem, (md5 or 'copy')[:8], ext)
2462
+
2463
+
2464
+ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
2465
+ """把媒体**落盘**并把 `manifest.json` 写好;返回 `(assets 目录名, manifest, 新写文件数)`。
2466
+
2467
+ HTML 走的是"内嵌 base64 ⇒ 单文件可分享",而 markdown 与其它格式的用法是喂给编辑器/别的工具 ——
2468
+ 那里 `![](data:image/…)` 多数不被认,图片必须是**文件 + 相对路径**。所以这里写文件,并留一份清单
2469
+ (键 `local_id:create_time` → `{path, kind, …}`):**抽取只做一遍**,json/txt/excel 只是消费它。
2470
+
2471
+ 图片与文档本体都进同一个清单:图片来自内存里的 base64,文档是**从 `msg/file/` 拷过来**的(路径在
2472
+ `format_message` 阶段就定好了)。清单 v2 起值是对象(带 `kind`),读取端要兼容 v1 的裸字符串。
2473
+ """
2474
+ import base64
2475
+ import hashlib
2476
+ os.makedirs(out_dir, exist_ok=True)
2477
+ assets = assets_dir or os.path.join(out_dir, file_prefix + '_assets')
2478
+ os.makedirs(assets, exist_ok=True)
2479
+
2480
+ manifest = {}
2481
+ written = 0
2482
+ for item in formatted:
2483
+ blob = item.get('image_b64')
2484
+ if not blob:
2485
+ continue
2486
+ data = base64.b64decode(blob)
2487
+ # 扩展名按**字节**判:实测同一会话 556 张图里 87 个是 GIF、27 个是 PNG,一律叫 .jpg 会让
2488
+ # 编辑器按扩展名解码、把动画表情显示成坏图。
2489
+ mime = detect_mime_from_bytes(data[:32]) or 'image/jpeg'
2490
+ name = 'img_%s_%s%s' % (item.get('local_id'),
2491
+ hashlib.md5(blob[:64].encode()).hexdigest()[:8],
2492
+ _IMAGE_EXT_BY_MIME.get(mime, '.jpg'))
2493
+ path = os.path.join(assets, name)
2494
+ if not os.path.exists(path):
2495
+ with open(path, 'wb') as fh:
2496
+ fh.write(data)
2497
+ written += 1
2498
+ # 键带时间戳:**同一个 local_id 会在分片之间重复**(每条会话的表可能存在于多个分片,
2499
+ # id 各片自己编号)—— 只按 local_id 做键,json/txt/excel 会拿别人的图。
2500
+ manifest['%s:%s' % (item.get('local_id'), item.get('create_time'))] = {
2501
+ 'path': os.path.relpath(path, out_dir).replace('\\', '/'),
2502
+ 'kind': 'image',
2503
+ }
2504
+
2505
+ file_exported = 0
2506
+ file_written = 0
2507
+ file_unavailable = 0
2508
+ files_dir = os.path.join(assets, _FILE_ASSET_SUBDIR)
2509
+ used_names = {}
2510
+ for item in formatted:
2511
+ info = item.get('file')
2512
+ if not info:
2513
+ continue
2514
+ source = info.get('path')
2515
+ size = 0
2516
+ if source:
2517
+ try:
2518
+ size = os.path.getsize(source)
2519
+ except OSError:
2520
+ source = None
2521
+ # 超限与"本地没有"归到同一档计数(都是"这条消息的文档没能带出来"),但不静默:计数写进清单顶部。
2522
+ if not source or size > MAX_ASSET_FILE_BYTES:
2523
+ file_unavailable += 1
2524
+ continue
2525
+ name = _asset_file_name(info.get('title') or os.path.basename(source), used_names, source,
2526
+ info.get('md5'))
2527
+ target = os.path.join(files_dir, name)
2528
+ 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
2534
+ used_names[name] = source
2535
+ file_exported += 1
2536
+ manifest['%s:%s' % (item.get('local_id'), item.get('create_time'))] = {
2537
+ 'path': os.path.relpath(target, out_dir).replace('\\', '/'),
2538
+ 'kind': 'file',
2539
+ 'name': info.get('title') or name,
2540
+ 'bytes': size,
2541
+ }
2542
+
2543
+ FILE_ASSET_STATE['exported'] = file_exported
2544
+ FILE_ASSET_STATE['written'] = file_written
2545
+ FILE_ASSET_STATE['unavailable'] = file_unavailable
2546
+ with open(os.path.join(assets, 'manifest.json'), 'w', encoding='utf-8') as fh:
2547
+ json.dump({'schema': 'weflow-media-manifest/v2', 'messages': manifest,
2548
+ 'documents': {'exported': file_exported, 'unavailable': file_unavailable}},
2549
+ fh, ensure_ascii=False, indent=2)
2550
+ return os.path.basename(os.path.normpath(assets)), manifest, written + file_written
2551
+
2552
+
2553
+ def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
2554
+ """markdown 正文 + 媒体落盘(落盘那半在 `write_assets`,两者共用同一份清单)。"""
2555
+ assets_name, manifest, written = write_assets(out_dir, file_prefix, formatted, assets_dir)
2556
+
2557
+ lines = ['# %s' % file_prefix, '']
2558
+ for item in formatted:
2559
+ who = '我' if (item.get('is_self') or item.get('is_send')) else (item.get('sender') or '')
2560
+ # 这个文件是 `import datetime`(模块),且它的时间戳一律是**本地时区**(见 render 那几处),
2561
+ # 所以这里也照它的写法,不带 tz —— 导出页里显示的时间与本文件其它时间戳保持一致。
2562
+ stamp = datetime.datetime.fromtimestamp(item.get('create_time') or 0).strftime('%Y-%m-%d %H:%M')
2563
+ body = plain_fragment(item.get('display') or '', limit=100000) or ''
2564
+ record = manifest.get('%s:%s' % (item.get('local_id'), item.get('create_time')))
2565
+ if record:
2566
+ # v1 的值是裸字符串;v2 起是对象。两种都要认(磁盘上可能还留着上一版的清单)。
2567
+ if isinstance(record, dict):
2568
+ rel = record.get('path') or ''
2569
+ kind = record.get('kind') or 'image'
2570
+ label = record.get('name') or ''
2571
+ else:
2572
+ rel, kind, label = record, 'image', ''
2573
+ if rel and kind == 'file':
2574
+ # 文档不是图片:`![](…)` 会被当成坏图,要写成链接,链接文字用原始文件名。
2575
+ 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)
2577
+ elif rel:
2578
+ body = ('' if (not body or body == '[图片]') else body + chr(10) + chr(10)) + '![图片](%s)' % rel
2579
+ lines.extend(['**%s** %s' % (who, stamp), '', body.strip() or '(空)', ''])
2580
+
2581
+ md_path = os.path.join(out_dir, file_prefix + '.md')
2582
+ with open(md_path, 'w', encoding='utf-8') as fh:
2583
+ fh.write(chr(10).join(lines))
2584
+ return md_path, len(manifest), written, assets_name
2585
+
2296
2586
  def main():
2297
2587
  import argparse
2298
2588
  parser = argparse.ArgumentParser(description='Export WeChat NT chat as HTML')
@@ -2302,6 +2592,12 @@ def main():
2302
2592
  parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
2303
2593
  parser.add_argument('--name', default=os.environ.get('WEFLOW_EXPORT_NAME', ''), help='Display name')
2304
2594
  parser.add_argument('--out', default=os.environ.get('WEFLOW_EXPORT_OUTPUT', './output'), help='Output directory')
2595
+ parser.add_argument('--format', choices=('html', 'md'), default='html',
2596
+ help='html=内嵌图片的多分页页面(默认);md=markdown + 图片落盘到 assets/')
2597
+ parser.add_argument('--assets-only', action='store_true',
2598
+ help='只抽取媒体到 assets/ 并写清单,不写 markdown(给 json/txt/excel 的 --media 用)')
2599
+ parser.add_argument('--assets-dir', default='',
2600
+ help='md 模式下图片落到哪个目录(默认 <out>/<前缀>_assets;同时写 manifest.json)')
2305
2601
  parser.add_argument('--parts', type=int, default=5, help='Number of parts to split into')
2306
2602
  parser.add_argument('--wx-dir', default='', help='Traditional WeChat data dir (FileStorage fallback)')
2307
2603
  parser.add_argument('--cache-dir', default=os.environ.get('WEFLOW_EXPORT_CACHE_DIR', ''), help='NT cache directory for image thumbnails')
@@ -2347,6 +2643,14 @@ def main():
2347
2643
  value = shrink_embedded(value, max_side=720, force=True)
2348
2644
  image_map[key] = value
2349
2645
  print(f" Found {len(image_map)} media entries for embedding")
2646
+
2647
+ # 文档本体的索引:只 stat,不读内容(1,677 个文件的走一遍不到一秒)。没有它,`appmsg type=6`
2648
+ # 的消息就只能渲染成 `[文件] 名字` —— 正是反馈里"其余格式不支持导出文档"的那一半。
2649
+ file_index = {}
2650
+ if args.account_dir:
2651
+ file_index = scan_account_file_index(args.account_dir)
2652
+ if file_index:
2653
+ print(f" Account file index: {len(file_index)} key(s)")
2350
2654
  _phase("media scan")
2351
2655
 
2352
2656
  # Custom stickers: derive the local cache key from the account seed.
@@ -2462,7 +2766,7 @@ def main():
2462
2766
  PREFETCH['sink'] = sink
2463
2767
  try:
2464
2768
  for row in messages:
2465
- format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid)
2769
+ format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid, file_index)
2466
2770
  finally:
2467
2771
  PREFETCH['sink'] = None
2468
2772
  if sink:
@@ -2483,7 +2787,7 @@ def main():
2483
2787
  for i, row in enumerate(messages):
2484
2788
  if i % progress_step == 0:
2485
2789
  print(f" Formatting {i}/{len(messages)}...", flush=True)
2486
- result = format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid)
2790
+ result = format_message(row, args.talker, wx_dir, image_map, sender_map, display_name, resource_map, args.own_wxid, file_index)
2487
2791
  if result.get('image_b64'):
2488
2792
  img_hit_count += 1
2489
2793
  if result.get('local_type') == 49:
@@ -2513,6 +2817,42 @@ def main():
2513
2817
  # Use display name for filename if provided, otherwise fallback to wxid
2514
2818
  file_prefix = sanitize_filename(display_name) if display_name else args.talker.replace('@', '_').replace('/', '_')
2515
2819
 
2820
+ if args.assets_only:
2821
+ # 只抽媒体:其它格式的 `--media` 会先跑这一步,再各自消费清单。
2822
+ assets_name, manifest, asset_new = write_assets(args.out, file_prefix, formatted, args.assets_dir)
2823
+ print(f"Assets: {len(manifest)} media item(s), {FILE_ASSET_STATE['exported']} document(s) "
2824
+ f"({FILE_ASSET_STATE['unavailable']} unavailable, {FILE_ASSET_STATE['written']} new) "
2825
+ f"({asset_new} new) -> {assets_name}")
2826
+ print(json.dumps({"success": True, "total": len(formatted),
2827
+ "files": [os.path.join(args.out, assets_name)], "format": "assets",
2828
+ "assets": len(manifest), "assetsNew": asset_new,
2829
+ "documents": FILE_ASSET_STATE['exported'],
2830
+ "documentsNew": FILE_ASSET_STATE['written'],
2831
+ "documentsUnavailable": FILE_ASSET_STATE['unavailable'],
2832
+ "assetsDir": assets_name}, ensure_ascii=False))
2833
+ conn.close()
2834
+ return
2835
+ if args.format == 'md':
2836
+ md_path, asset_total, asset_new, assets_name = write_markdown(args.out, file_prefix, formatted, args.assets_dir)
2837
+ if media_items:
2838
+ media_report_path = os.path.join(args.out, f'{file_prefix}_media.json')
2839
+ with open(media_report_path, 'w', encoding='utf-8') as handle:
2840
+ json.dump(build_media_report(media_items, display_name, args.talker, len(formatted)),
2841
+ handle, ensure_ascii=False, indent=2)
2842
+ print(f"Markdown: {len(formatted)} message(s), {asset_total} media item(s), "
2843
+ f"{FILE_ASSET_STATE['exported']} document(s) ({FILE_ASSET_STATE['unavailable']} unavailable, "
2844
+ f"{FILE_ASSET_STATE['written']} new) ({asset_new} new) -> {os.path.basename(md_path)}")
2845
+ print(json.dumps({"success": True, "total": len(formatted), "parts": 1,
2846
+ "files": [md_path], "format": "md",
2847
+ "assets": asset_total, "assetsNew": asset_new,
2848
+ "documents": FILE_ASSET_STATE['exported'],
2849
+ "documentsNew": FILE_ASSET_STATE['written'],
2850
+ "documentsUnavailable": FILE_ASSET_STATE['unavailable'],
2851
+ "assetsDir": assets_name},
2852
+ ensure_ascii=False))
2853
+ conn.close()
2854
+ return
2855
+
2516
2856
  # Media coverage report. Written beside the HTML parts, under the same
2517
2857
  # prefix, so it is always found next to what it describes.
2518
2858
  media_report = None
@@ -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(conn, name_map, days=90):
186
- """Collect chat messages for indexing."""
187
- c = conn.cursor()
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
- try:
192
- c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
193
- sessions = [r[0] for r in c.fetchall()]
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(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
203
- if c.fetchone()[0] == 0:
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": f"chat:{talker}:{ts}",
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
- conn = open_db(nt_db, nt_key, nt_salt)
330
- chat_items = collect_chat_messages(conn, name_map, days=days)
331
- items.extend(chat_items)
332
- conn.close()
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
- print(' [WARN] 重排失败(%s),保持原检索顺序:%s' % (type(error).__name__, error))
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: