weflow-cli 1.5.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.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,766 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Export WeChat NT chat history as self-contained HTML files.
4
+ Splits large conversations into multiple parts.
5
+ Embeds cached image thumbnails from NT cache directory.
6
+ """
7
+ import sys
8
+ import os
9
+ import hashlib
10
+ import datetime
11
+ import json
12
+ import re
13
+ import base64
14
+ import urllib.request
15
+ import urllib.error
16
+ from pathlib import Path
17
+
18
+ try:
19
+ from sqlcipher3 import dbapi2 as sqlcipher
20
+ except ImportError:
21
+ print("Install sqlcipher3: pip install sqlcipher3")
22
+ sys.exit(1)
23
+
24
+ PAGE_SIZE = 4096
25
+ MSG_TYPES = {
26
+ 1: 'text', 3: 'image', 34: 'voice', 42: 'card',
27
+ 43: 'video', 47: 'emoji', 48: 'location', 49: 'link',
28
+ 50: 'voip', 10000: 'system', 10002: 'quote',
29
+ }
30
+ MAX_EMBED_SIZE = 256 * 1024 # Max 256KB per embedded image
31
+
32
+
33
+ def connect(db_path, key_hex, salt_hex):
34
+ raw_key = f"x'{key_hex}{salt_hex}'"
35
+ conn = sqlcipher.connect(db_path)
36
+ c = conn.cursor()
37
+ c.execute(f'PRAGMA key = "{raw_key}";')
38
+ return conn, c
39
+
40
+
41
+ def fetch_messages(conn, talker):
42
+ """Fetch all messages for a talker, ordered by time ascending."""
43
+ tbl = 'Msg_' + hashlib.md5(talker.encode()).hexdigest()
44
+ c = conn.cursor()
45
+
46
+ c.execute(f"SELECT COUNT(*) FROM \"{tbl}\"")
47
+ total = c.fetchone()[0]
48
+ print(f"Total messages: {total}")
49
+
50
+ c.execute(f'''
51
+ SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
52
+ create_time, status, source, message_content, compress_content
53
+ FROM "{tbl}"
54
+ ORDER BY create_time ASC
55
+ ''')
56
+
57
+ messages = []
58
+ batch = 0
59
+ while True:
60
+ rows = c.fetchmany(5000)
61
+ if not rows:
62
+ break
63
+ for row in rows:
64
+ messages.append(row)
65
+ batch += 1
66
+ print(f" Fetched {len(messages)}/{total}...")
67
+
68
+ return messages
69
+
70
+
71
+ def build_sender_map(conn, talker):
72
+ """Map sender_id -> display name using Name2Id table and contact DB."""
73
+ sender_map = {}
74
+ c = conn.cursor()
75
+
76
+ # Get all sender IDs from the message table
77
+ tbl = 'Msg_' + hashlib.md5(talker.encode()).hexdigest()
78
+ c.execute(f'SELECT DISTINCT real_sender_id FROM \"{tbl}\"')
79
+ sender_ids = [row[0] for row in c.fetchall()]
80
+
81
+ # Map sender_id -> user_name using Name2Id
82
+ c2 = conn.cursor()
83
+ for sid in sender_ids:
84
+ c2.execute('SELECT user_name FROM Name2Id WHERE rowid = ?', (sid,))
85
+ row = c2.fetchone()
86
+ if row and row[0]:
87
+ sender_map[sid] = row[0]
88
+
89
+ return sender_map
90
+
91
+
92
+ def scan_nt_cache(nt_cache_dir, talker):
93
+ """Scan NT cache directory for image thumbnails and temp images.
94
+
95
+ NT cache structure:
96
+ cache/YYYY-MM/Message/<talker_md5>/
97
+ Thumb/<local_id>_<timestamp>_thumb.jpg
98
+ ImageTemp/<local_id>_<timestamp>_hd_temp_convert
99
+ ImageTemp/<local_id>_<timestamp>_mid_temp_convert
100
+
101
+ Returns dict: {local_id: (base64_data, mime_type)}
102
+ """
103
+ talker_md5 = hashlib.md5(talker.encode()).hexdigest()
104
+ image_map = {}
105
+
106
+ if not nt_cache_dir or not os.path.isdir(nt_cache_dir):
107
+ return image_map
108
+
109
+ for month_dir in sorted(os.listdir(nt_cache_dir)):
110
+ msg_dir = os.path.join(nt_cache_dir, month_dir, 'Message', talker_md5)
111
+ if not os.path.isdir(msg_dir):
112
+ continue
113
+
114
+ # Priority 1: ImageTemp (HD/mid quality)
115
+ img_temp_dir = os.path.join(msg_dir, 'ImageTemp')
116
+ if os.path.isdir(img_temp_dir):
117
+ for fname in os.listdir(img_temp_dir):
118
+ fpath = os.path.join(img_temp_dir, fname)
119
+ if not os.path.isfile(fpath):
120
+ continue
121
+ size = os.path.getsize(fpath)
122
+ if size > MAX_EMBED_SIZE:
123
+ continue
124
+ # Parse local_id from filename: <local_id>_<timestamp>_...
125
+ parts = fname.split('_', 1)
126
+ if parts and parts[0].isdigit():
127
+ local_id = int(parts[0])
128
+ mime = detect_mime(fpath)
129
+ if mime:
130
+ try:
131
+ with open(fpath, 'rb') as fh:
132
+ data = fh.read()
133
+ if len(data) < MAX_EMBED_SIZE:
134
+ image_map[local_id] = (base64.b64encode(data).decode(), mime)
135
+ except:
136
+ pass
137
+
138
+ # Priority 2: Thumb (fill in gaps)
139
+ thumb_dir = os.path.join(msg_dir, 'Thumb')
140
+ if os.path.isdir(thumb_dir):
141
+ for fname in os.listdir(thumb_dir):
142
+ if not fname.endswith('.jpg'):
143
+ continue
144
+ parts = fname.split('_', 1)
145
+ if parts and parts[0].isdigit():
146
+ local_id = int(parts[0])
147
+ if local_id not in image_map: # Don't override ImageTemp
148
+ fpath = os.path.join(thumb_dir, fname)
149
+ size = os.path.getsize(fpath)
150
+ if size < MAX_EMBED_SIZE:
151
+ try:
152
+ with open(fpath, 'rb') as fh:
153
+ data = fh.read()
154
+ if len(data) < MAX_EMBED_SIZE:
155
+ image_map[local_id] = (base64.b64encode(data).decode(), 'image/jpeg')
156
+ except:
157
+ pass
158
+
159
+ return image_map
160
+
161
+
162
+ def detect_mime(filepath):
163
+ """Detect MIME type from file header."""
164
+ try:
165
+ with open(filepath, 'rb') as f:
166
+ header = f.read(8)
167
+ if header[:2] == b'\xff\xd8':
168
+ return 'image/jpeg'
169
+ if header[:4] == b'\x89PNG':
170
+ return 'image/png'
171
+ if header[:3] == b'GIF':
172
+ return 'image/gif'
173
+ if header[:4] == b'RIFF' and header[8:12] == b'WEBP':
174
+ return 'image/webp'
175
+ except:
176
+ pass
177
+ return None
178
+
179
+
180
+ def find_thumbnail(create_time, msg_local_id, wx_dir):
181
+ """Try to find image thumbnail from traditional FileStorage path (fallback)."""
182
+ if not wx_dir or not os.path.isdir(wx_dir):
183
+ return None
184
+
185
+ dt = datetime.datetime.fromtimestamp(create_time)
186
+ month_dir = dt.strftime('%Y-%m')
187
+
188
+ for sub in ['Image', 'Image2']:
189
+ img_dir = os.path.join(wx_dir, 'FileStorage', sub, month_dir)
190
+ if not os.path.isdir(img_dir):
191
+ continue
192
+ try:
193
+ for f in os.listdir(img_dir):
194
+ fpath = os.path.join(img_dir, f)
195
+ if not os.path.isfile(fpath):
196
+ continue
197
+ fstat = os.stat(fpath)
198
+ time_diff = abs(fstat.st_mtime - create_time)
199
+ if time_diff < 300 and os.path.getsize(fpath) < MAX_EMBED_SIZE:
200
+ with open(fpath, 'rb') as fh:
201
+ data = fh.read()
202
+ if len(data) < MAX_EMBED_SIZE:
203
+ return (base64.b64encode(data).decode(), 'image/jpeg')
204
+ except:
205
+ pass
206
+ return None
207
+
208
+
209
+ def download_image_as_base64(url, timeout=10):
210
+ """Download image from URL and return (base64_data, mime_type) or None."""
211
+ if not url or not url.startswith(('http://', 'https://')):
212
+ return None
213
+ try:
214
+ req = urllib.request.Request(url, headers={
215
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
216
+ 'Referer': 'https://mp.weixin.qq.com/',
217
+ })
218
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
219
+ data = resp.read(MAX_EMBED_SIZE + 1)
220
+ if len(data) > MAX_EMBED_SIZE:
221
+ return None
222
+ mime = resp.headers.get('Content-Type', 'image/jpeg').split(';')[0].strip()
223
+ if mime not in ('image/jpeg', 'image/png', 'image/gif', 'image/webp'):
224
+ # Try detecting from data
225
+ mime = detect_mime_from_bytes(data) or 'image/jpeg'
226
+ return (base64.b64encode(data).decode(), mime)
227
+ except Exception:
228
+ return None
229
+
230
+
231
+ def detect_mime_from_bytes(header_bytes):
232
+ """Detect MIME type from byte header."""
233
+ if header_bytes[:2] == b'\xff\xd8':
234
+ return 'image/jpeg'
235
+ if header_bytes[:4] == b'\x89PNG':
236
+ return 'image/png'
237
+ if header_bytes[:3] == b'GIF':
238
+ return 'image/gif'
239
+ if header_bytes[:4] == b'RIFF' and len(header_bytes) >= 12 and header_bytes[8:12] == b'WEBP':
240
+ return 'image/webp'
241
+ return None
242
+
243
+
244
+ def extract_appmsg_image(content):
245
+ """Extract image URL from appmsg XML content."""
246
+ if not content:
247
+ return None
248
+ # Try common image URL fields in appmsg XML
249
+ for tag in ('thumburl', 'cdnthumburl', 'appthumburl'):
250
+ m = re.search(rf'<{tag}>([^<]+)</{tag}>', content)
251
+ if m:
252
+ url = m.group(1).strip()
253
+ if url.startswith(('http://', 'https://')):
254
+ return url
255
+ # Also check for <msg><appmsg> nested structure
256
+ m = re.search(r'<thumburl>([^<]+)</thumburl>', content)
257
+ if m:
258
+ url = m.group(1).strip()
259
+ if url.startswith(('http://', 'https://')):
260
+ return url
261
+ return None
262
+
263
+
264
+ def escape_html(text):
265
+ if not text:
266
+ return ''
267
+ return (str(text)
268
+ .replace('&', '&amp;')
269
+ .replace('<', '&lt;')
270
+ .replace('>', '&gt;')
271
+ .replace('"', '&quot;'))
272
+
273
+
274
+ def parse_source(source_text):
275
+ """Parse source field to extract sender and content."""
276
+ sender = ''
277
+ content = ''
278
+ if not source_text:
279
+ return sender, content
280
+
281
+ if isinstance(source_text, bytes):
282
+ try:
283
+ source_text = source_text.decode('utf-8', errors='ignore')
284
+ except:
285
+ return '', ''
286
+
287
+ # Format: "wxid_xxx:\ncontent..."
288
+ if ':\n' in source_text:
289
+ parts = source_text.split(':\n', 1)
290
+ sender = parts[0]
291
+ content = parts[1] if len(parts) > 1 else ''
292
+ elif ':' in source_text:
293
+ parts = source_text.split(':', 1)
294
+ sender = parts[0]
295
+ content = parts[1] if len(parts) > 1 else ''
296
+
297
+ return sender, content
298
+
299
+
300
+ def format_message(row, talker, wx_dir, image_map=None, sender_map=None, display_name=''):
301
+ """Format a single message for HTML display.
302
+
303
+ Args:
304
+ row: DB row tuple
305
+ talker: target wxid
306
+ wx_dir: traditional FileStorage path (fallback)
307
+ image_map: {local_id: (base64_data, mime_type)} from NT cache scan
308
+ sender_map: {sender_id: user_name} from Name2Id table
309
+ display_name: human-readable name for the target talker
310
+ """
311
+ local_id = row[0] or 0
312
+ local_type = row[2] or 0
313
+ real_sender_id = row[4] or 0
314
+ create_time = row[5] or 0
315
+ source = row[7]
316
+ message_content = row[8]
317
+
318
+ # Resolve sender name
319
+ sender_user_name = (sender_map or {}).get(real_sender_id, '')
320
+ is_self = (sender_user_name != talker) # not the target talker = sent by me
321
+
322
+ # Build display sender name
323
+ if is_self:
324
+ sender_display = '我'
325
+ elif display_name:
326
+ sender_display = display_name
327
+ elif sender_user_name:
328
+ sender_display = sender_user_name
329
+ else:
330
+ sender_display = talker
331
+
332
+ # Get content
333
+ content = ''
334
+ if isinstance(message_content, str) and message_content:
335
+ content = message_content
336
+ elif isinstance(message_content, bytes):
337
+ try:
338
+ content = message_content.decode('utf-8', errors='ignore')
339
+ except:
340
+ pass
341
+
342
+ if not content and isinstance(source, str):
343
+ _, content = parse_source(source)
344
+
345
+ # Determine display content
346
+ display = ''
347
+ image_b64 = None
348
+
349
+ if local_type == 1:
350
+ # Text
351
+ display = escape_html(content)
352
+ elif local_type == 3:
353
+ # Image - try cache map first, then traditional FileStorage
354
+ display = '<span class="msg-media">[图片]</span>'
355
+ img_data = None
356
+ mime = 'image/jpeg'
357
+
358
+ # Priority 1: NT cache thumbnails
359
+ if image_map and local_id in image_map:
360
+ img_data, mime = image_map[local_id]
361
+ # Priority 2: Traditional FileStorage
362
+ else:
363
+ result = find_thumbnail(create_time, local_id, wx_dir)
364
+ if result:
365
+ img_data, mime = result
366
+
367
+ if img_data:
368
+ image_b64 = img_data
369
+ display += f'<br><img src="data:{mime};base64,{img_data}" loading="lazy" />'
370
+ elif image_map and local_id in image_map:
371
+ # Some image messages use encoded types (e.g. 21474836529 = images in appmsg)
372
+ # Check image_map for any message type
373
+ img_data, mime = image_map[local_id]
374
+ if img_data:
375
+ image_b64 = img_data
376
+ display = f'<span class="msg-media">[图片]</span><br><img src="data:{mime};base64,{img_data}" loading="lazy" />'
377
+ elif local_type == 34:
378
+ display = '<span class="msg-media">[语音]</span>'
379
+ elif local_type == 43:
380
+ display = '<span class="msg-media">[视频]</span>'
381
+ elif local_type == 47:
382
+ display = escape_html(content) if content else '<span class="msg-media">[表情]</span>'
383
+ elif local_type == 49:
384
+ # App message (link/file/article)
385
+ if content:
386
+ # Try to parse XML for title/desc
387
+ title_m = re.search(r'<title>([^<]*)</title>', content)
388
+ desc_m = re.search(r'<des>([^<]*)</des>', content)
389
+ url_m = re.search(r'<url>([^<]*)</url>', content)
390
+ type_m = re.search(r'<type>(\d+)</type>', content)
391
+ fname_m = re.search(r'<title>([^<]+\.\w+)</title>', content)
392
+
393
+ if type_m and type_m.group(1) == '6' and fname_m:
394
+ display = f'<span class="msg-file">[文件] {escape_html(fname_m.group(1))}</span>'
395
+ elif title_m:
396
+ parts = []
397
+ # Extract and embed article thumbnail image
398
+ thumb_url = extract_appmsg_image(content)
399
+ if thumb_url:
400
+ img_data = download_image_as_base64(thumb_url)
401
+ if img_data:
402
+ b64, mime = img_data
403
+ image_b64 = b64
404
+ parts.append(f'<img class="msg-app-thumb" src="data:{mime};base64,{b64}" loading="lazy" />')
405
+ if url_m:
406
+ parts.append(f'<a class="msg-link" href="{escape_html(url_m.group(1))}" target="_blank">{escape_html(decode_xml(title_m.group(1)))}</a>')
407
+ else:
408
+ parts.append(f'<span class="msg-app-title">{escape_html(decode_xml(title_m.group(1)))}</span>')
409
+ if desc_m:
410
+ parts.append(f'<div class="msg-app-desc">{escape_html(decode_xml(desc_m.group(1)))}</div>')
411
+ display = '<div class="msg-app">' + ''.join(parts) + '</div>'
412
+ else:
413
+ display = '<span class="msg-media">[链接/文件]</span>'
414
+ else:
415
+ display = '<span class="msg-media">[链接/文件]</span>'
416
+ elif local_type == 50:
417
+ display = '<span class="msg-media">[语音通话]</span>'
418
+ elif local_type == 10000:
419
+ display = f'<span class="msg-sys">{escape_html(content)}</span>'
420
+ elif local_type == 10002:
421
+ display = escape_html(content) if content else '<span class="msg-media">[引用]</span>'
422
+ else:
423
+ if content:
424
+ display = escape_html(content)
425
+ else:
426
+ type_name = MSG_TYPES.get(local_type, f'类型{local_type}')
427
+ display = f'<span class="msg-media">[{type_name}]</span>'
428
+
429
+ return {
430
+ 'local_id': local_id,
431
+ 'create_time': create_time,
432
+ 'is_send': is_self,
433
+ 'sender': sender_display,
434
+ 'is_self': is_self,
435
+ 'local_type': local_type,
436
+ 'display': display,
437
+ 'image_b64': image_b64,
438
+ }
439
+
440
+
441
+ def decode_xml(text):
442
+ """Decode XML entities."""
443
+ return (text
444
+ .replace('&amp;', '&')
445
+ .replace('&lt;', '<')
446
+ .replace('&gt;', '>')
447
+ .replace('&quot;', '"')
448
+ .replace('&apos;', "'"))
449
+
450
+
451
+ def build_html_page(talker, messages_part, part_num, total_parts, display_name):
452
+ """Build a single HTML page for a part."""
453
+ talker_safe = talker.replace('@', '_').replace('/', '_')
454
+ rows = []
455
+ for m in messages_part:
456
+ dt = datetime.datetime.fromtimestamp(m['create_time'])
457
+ time_str = dt.strftime('%Y-%m-%d %H:%M:%S')
458
+ sender = m['sender']
459
+ is_self = m['is_self']
460
+ content_html = m['display']
461
+
462
+ align = 'right' if is_self else 'left'
463
+ bg = '#95ec69' if is_self else '#ffffff'
464
+ sender_display = escape_html(sender)
465
+
466
+ rows.append(f'''<div class="msg-row" style="text-align:{align};">
467
+ <div class="msg-bubble" style="background:{bg};">
468
+ <div class="msg-sender">{sender_display} · {time_str}</div>
469
+ <div class="msg-content">{content_html}</div>
470
+ </div>
471
+ </div>''')
472
+
473
+ name = display_name or talker
474
+ from_time = datetime.datetime.fromtimestamp(messages_part[0]['create_time']).strftime('%Y-%m-%d %H:%M')
475
+ to_time = datetime.datetime.fromtimestamp(messages_part[-1]['create_time']).strftime('%Y-%m-%d %H:%M')
476
+
477
+ return f'''<!DOCTYPE html>
478
+ <html lang="zh-CN">
479
+ <head>
480
+ <meta charset="UTF-8">
481
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
482
+ <title>聊天记录 - {escape_html(name)} (第{part_num}/{total_parts}部分)</title>
483
+ <style>
484
+ * {{ margin:0; padding:0; box-sizing:border-box; }}
485
+ body {{
486
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
487
+ background: #ededed;
488
+ padding: 20px 0;
489
+ }}
490
+ .container {{
491
+ max-width: 720px;
492
+ margin: 0 auto;
493
+ padding: 0 12px;
494
+ }}
495
+ .header {{
496
+ background: #fff;
497
+ border-radius: 12px;
498
+ padding: 20px;
499
+ margin-bottom: 16px;
500
+ text-align: center;
501
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08);
502
+ }}
503
+ .header h2 {{ font-size: 18px; color: #333; margin-bottom: 4px; }}
504
+ .header p {{ font-size: 13px; color: #999; }}
505
+ .part-nav {{
506
+ display: flex;
507
+ justify-content: center;
508
+ gap: 8px;
509
+ margin: 12px 0;
510
+ flex-wrap: wrap;
511
+ }}
512
+ .part-nav a {{
513
+ display: inline-block;
514
+ padding: 4px 14px;
515
+ background: #fff;
516
+ border-radius: 6px;
517
+ text-decoration: none;
518
+ color: #576b95;
519
+ font-size: 13px;
520
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06);
521
+ }}
522
+ .part-nav a.active {{
523
+ background: #07c160;
524
+ color: #fff;
525
+ }}
526
+ .msg-row {{ margin: 8px 0; }}
527
+ .msg-bubble {{
528
+ display: inline-block;
529
+ max-width: 82%;
530
+ padding: 8px 12px;
531
+ border-radius: 8px;
532
+ text-align: left;
533
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06);
534
+ word-break: break-all;
535
+ }}
536
+ .msg-sender {{ font-size: 11px; color: #999; margin-bottom: 3px; }}
537
+ .msg-content {{ font-size: 15px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }}
538
+ .msg-content img {{ max-width: 240px; max-height: 240px; border-radius: 4px; margin-top: 6px; display: block; }}
539
+ .msg-media {{ color: #888; font-size: 14px; }}
540
+ .msg-sys {{ color: #bbb; font-size: 13px; }}
541
+ .msg-file {{ color: #07c160; font-weight: 500; }}
542
+ .msg-app {{ margin: 0; }}
543
+ .msg-app-title {{ font-size: 14px; font-weight: 600; color: #333; }}
544
+ .msg-app-desc {{ font-size: 12px; color: #999; margin-top: 2px; }}
545
+ .msg-link {{
546
+ display: block;
547
+ margin-top: 6px;
548
+ padding: 6px 10px;
549
+ background: #f5f5f5;
550
+ border-left: 3px solid #07c160;
551
+ color: #576b95;
552
+ text-decoration: none;
553
+ border-radius: 0 4px 4px 0;
554
+ font-size: 13px;
555
+ }}
556
+ .msg-app-thumb {{
557
+ max-width: 240px;
558
+ max-height: 180px;
559
+ border-radius: 6px;
560
+ margin-bottom: 6px;
561
+ display: block;
562
+ }}
563
+ .footer {{
564
+ text-align: center;
565
+ padding: 20px;
566
+ color: #bbb;
567
+ font-size: 12px;
568
+ }}
569
+ .footer .hint {{
570
+ color: #ccc;
571
+ font-size: 11px;
572
+ margin-top: 4px;
573
+ }}
574
+ .search-box {{
575
+ margin: 10px 0;
576
+ }}
577
+ .search-box input {{
578
+ width: 100%;
579
+ padding: 8px 12px;
580
+ border: 1px solid #e0e0e0;
581
+ border-radius: 6px;
582
+ font-size: 14px;
583
+ outline: none;
584
+ }}
585
+ .search-box input:focus {{
586
+ border-color: #07c160;
587
+ }}
588
+ .search-info {{
589
+ font-size: 12px;
590
+ color: #999;
591
+ margin-top: 4px;
592
+ display: none;
593
+ }}
594
+ .msg-row.hidden {{
595
+ display: none;
596
+ }}
597
+ </style>
598
+ </head>
599
+ <body>
600
+ <div class="container">
601
+ <div class="header">
602
+ <h2>聊天记录 - {escape_html(name)}</h2>
603
+ <p>第 {part_num}/{total_parts} 部分 · {len(messages_part)} 条消息 · {from_time} ~ {to_time}</p>
604
+ <div class="part-nav">
605
+ {chr(10).join(f' <a href="{talker_safe}_part{i+1}.html" class="{"active" if i+1 == part_num else ""}">第{i+1}部分</a>' for i in range(total_parts))}
606
+ </div>
607
+ <div class="search-box">
608
+ <input type="text" placeholder="搜索聊天记录..." oninput="searchMessages(this.value)">
609
+ <div class="search-info" id="search-info"></div>
610
+ </div>
611
+ </div>
612
+ {chr(10).join(rows)}
613
+ </div>
614
+ <div class="footer">
615
+ <p>Exported by WeFlow CLI · {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}</p>
616
+ <p class="hint">💡 图片来自微信本地缓存,仅覆盖最近2个月。滚动查看更多聊天可生成更多缩略图。</p>
617
+ </div>
618
+ <script>
619
+ function searchMessages(query) {{
620
+ const rows = document.querySelectorAll('.msg-row');
621
+ const info = document.getElementById('search-info');
622
+ let found = 0;
623
+ const q = query.toLowerCase().trim();
624
+ rows.forEach(row => {{
625
+ if (!q) {{
626
+ row.classList.remove('hidden');
627
+ found++;
628
+ }} else {{
629
+ const text = row.textContent.toLowerCase();
630
+ if (text.includes(q)) {{
631
+ row.classList.remove('hidden');
632
+ found++;
633
+ }} else {{
634
+ row.classList.add('hidden');
635
+ }}
636
+ }}
637
+ }});
638
+ if (q) {{
639
+ info.style.display = 'block';
640
+ info.textContent = `找到 ${{found}} 条匹配`;
641
+ }} else {{
642
+ info.style.display = 'none';
643
+ }}
644
+ }}
645
+ </script>
646
+ </body>
647
+ </html>'''
648
+
649
+
650
+ def main():
651
+ import argparse
652
+ parser = argparse.ArgumentParser(description='Export WeChat NT chat as HTML')
653
+ parser.add_argument('--db', required=True, help='Path to NT database (message_0.db)')
654
+ parser.add_argument('--key', required=True, help='Key hex (64 chars)')
655
+ parser.add_argument('--salt', required=True, help='Salt hex (32 chars)')
656
+ parser.add_argument('--talker', required=True, help='Talker username')
657
+ parser.add_argument('--name', default='', help='Display name')
658
+ parser.add_argument('--out', default='./output', help='Output directory')
659
+ parser.add_argument('--parts', type=int, default=5, help='Number of parts to split into')
660
+ parser.add_argument('--wx-dir', default='', help='Traditional WeChat data dir (FileStorage fallback)')
661
+ parser.add_argument('--cache-dir', default='', help='NT cache directory for image thumbnails')
662
+ parser.add_argument('--single', action='store_true', help='Generate a single HTML file (no splitting)')
663
+ args = parser.parse_args()
664
+
665
+ os.makedirs(args.out, exist_ok=True)
666
+
667
+ # Scan NT cache for image thumbnails
668
+ image_map = {}
669
+ if args.cache_dir:
670
+ print(f"Scanning NT cache: {args.cache_dir}")
671
+ image_map = scan_nt_cache(args.cache_dir, args.talker)
672
+ print(f" Found {len(image_map)} cached images for embedding")
673
+
674
+ # Connect
675
+ print(f"Connecting to {args.db}...")
676
+ conn, c = connect(args.db, args.key, args.salt)
677
+
678
+ # Fetch messages
679
+ print(f"Fetching messages for {args.talker}...")
680
+ messages = fetch_messages(conn, args.talker)
681
+
682
+ if not messages:
683
+ print("No messages found!")
684
+ conn.close()
685
+ sys.exit(1)
686
+
687
+ # Build sender name map
688
+ print(f"Building sender name map...")
689
+ sender_map = build_sender_map(conn, args.talker)
690
+ print(f" Found {len(sender_map)} sender(s): {list(sender_map.values())}")
691
+
692
+ # Format messages
693
+ display_name = args.name or args.talker
694
+ print(f"Formatting {len(messages)} messages...")
695
+ wx_dir = args.wx_dir or ''
696
+ formatted = []
697
+ img_hit_count = 0
698
+ article_img_count = 0
699
+ for i, row in enumerate(messages):
700
+ if i % 2000 == 0:
701
+ print(f" Formatting {i}/{len(messages)}...")
702
+ result = format_message(row, args.talker, wx_dir, image_map, sender_map, display_name)
703
+ if result.get('image_b64'):
704
+ img_hit_count += 1
705
+ if result.get('local_type') == 49:
706
+ article_img_count += 1
707
+ formatted.append(result)
708
+ print(f" Messages with embedded images: {img_hit_count} (including {article_img_count} article thumbnails)")
709
+
710
+ # Split into parts (or single file)
711
+ total = len(formatted)
712
+ if args.single:
713
+ parts = 1
714
+ else:
715
+ parts = min(args.parts, total)
716
+ per_part = (total + parts - 1) // parts
717
+
718
+ print(f"Splitting into {parts} part(s) (~{per_part} messages each)...")
719
+
720
+ # Use display name for filename if provided, otherwise fallback to wxid
721
+ file_prefix = sanitize_filename(display_name) if display_name else args.talker.replace('@', '_').replace('/', '_')
722
+
723
+ html_files = []
724
+ for i in range(parts):
725
+ start = i * per_part
726
+ end = min(start + per_part, total)
727
+ chunk = formatted[start:end]
728
+
729
+ if not chunk:
730
+ break
731
+
732
+ html = build_html_page(args.talker, chunk, i + 1, parts, display_name)
733
+ if args.single:
734
+ filename = f"{file_prefix}.html"
735
+ else:
736
+ filename = f"{file_prefix}_part{i+1}.html"
737
+ filepath = os.path.join(args.out, filename)
738
+
739
+ with open(filepath, 'w', encoding='utf-8') as f:
740
+ f.write(html)
741
+
742
+ size_kb = os.path.getsize(filepath) / 1024
743
+ print(f" Part {i+1}: {filename} ({len(chunk)} msgs, {size_kb:.1f} KB)")
744
+ html_files.append(filepath)
745
+
746
+ conn.close()
747
+
748
+ print(f"\nDone! {len(html_files)} HTML files written to {args.out}")
749
+ print(f"Total: {total} messages")
750
+
751
+ # Print JSON summary for CLI integration
752
+ print(json.dumps({
753
+ "success": True,
754
+ "total": total,
755
+ "parts": len(html_files),
756
+ "files": html_files,
757
+ }))
758
+
759
+
760
+ def sanitize_filename(name: str) -> str:
761
+ """Remove characters unsafe for filenames."""
762
+ return re.sub(r'[\\/:*?"<>|]', '_', name)[:80]
763
+
764
+
765
+ if __name__ == '__main__':
766
+ main()