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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/weflow-cli-electron.cjs +59 -0
- package/bin/weflow-cli.ts +2311 -0
- package/cli.cjs +3 -0
- package/dist/bin/weflow-cli.d.ts +3 -0
- package/dist/bin/weflow-cli.d.ts.map +1 -0
- package/dist/bin/weflow-cli.js +2270 -0
- package/dist/bin/weflow-cli.js.map +1 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.d.ts.map +1 -0
- package/dist/mcp-server/index.js +690 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/core/dbPathService.d.ts +40 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -0
- package/dist/src/core/dbPathService.js +324 -0
- package/dist/src/core/dbPathService.js.map +1 -0
- package/dist/src/core/keyService.d.ts +59 -0
- package/dist/src/core/keyService.d.ts.map +1 -0
- package/dist/src/core/keyService.js +368 -0
- package/dist/src/core/keyService.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +62 -0
- package/dist/src/core/ntCore.d.ts.map +1 -0
- package/dist/src/core/ntCore.js +191 -0
- package/dist/src/core/ntCore.js.map +1 -0
- package/dist/src/core/sqlcipherCore.d.ts +119 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
- package/dist/src/core/sqlcipherCore.js +693 -0
- package/dist/src/core/sqlcipherCore.js.map +1 -0
- package/dist/src/core/wcdbCore.d.ts +738 -0
- package/dist/src/core/wcdbCore.d.ts.map +1 -0
- package/dist/src/core/wcdbCore.js +4545 -0
- package/dist/src/core/wcdbCore.js.map +1 -0
- package/dist/src/core/wechatClient.d.ts +23 -0
- package/dist/src/core/wechatClient.d.ts.map +1 -0
- package/dist/src/core/wechatClient.js +166 -0
- package/dist/src/core/wechatClient.js.map +1 -0
- package/dist/src/services/chatService.d.ts +25 -0
- package/dist/src/services/chatService.d.ts.map +1 -0
- package/dist/src/services/chatService.js +291 -0
- package/dist/src/services/chatService.js.map +1 -0
- package/dist/src/services/configService.d.ts +43 -0
- package/dist/src/services/configService.d.ts.map +1 -0
- package/dist/src/services/configService.js +156 -0
- package/dist/src/services/configService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +30 -0
- package/dist/src/services/exportService.d.ts.map +1 -0
- package/dist/src/services/exportService.js +273 -0
- package/dist/src/services/exportService.js.map +1 -0
- package/dist/src/services/wechat-formatter.d.ts +69 -0
- package/dist/src/services/wechat-formatter.d.ts.map +1 -0
- package/dist/src/services/wechat-formatter.js +248 -0
- package/dist/src/services/wechat-formatter.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +28 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -0
- package/dist/src/services/wechatMessageService.js +341 -0
- package/dist/src/services/wechatMessageService.js.map +1 -0
- package/dist/src/services/wereadService.d.ts +206 -0
- package/dist/src/services/wereadService.d.ts.map +1 -0
- package/dist/src/services/wereadService.js +145 -0
- package/dist/src/services/wereadService.js.map +1 -0
- package/dist/src/services/whitelistService.d.ts +20 -0
- package/dist/src/services/whitelistService.d.ts.map +1 -0
- package/dist/src/services/whitelistService.js +60 -0
- package/dist/src/services/whitelistService.js.map +1 -0
- package/dist/src/types.d.ts +140 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/errors.d.ts +25 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +43 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/pathUtils.d.ts +5 -0
- package/dist/src/utils/pathUtils.d.ts.map +1 -0
- package/dist/src/utils/pathUtils.js +16 -0
- package/dist/src/utils/pathUtils.js.map +1 -0
- package/dist/src/utils/pythonRunner.d.ts +27 -0
- package/dist/src/utils/pythonRunner.d.ts.map +1 -0
- package/dist/src/utils/pythonRunner.js +62 -0
- package/dist/src/utils/pythonRunner.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +14 -0
- package/dist/src/utils/talkerUtils.d.ts.map +1 -0
- package/dist/src/utils/talkerUtils.js +73 -0
- package/dist/src/utils/talkerUtils.js.map +1 -0
- package/mcp-server/index.ts +757 -0
- package/package.json +72 -0
- package/resources/key/win32/x64/wx_key.dll +0 -0
- package/resources/wcdb/win32/x64/SDL2.dll +0 -0
- package/resources/wcdb/win32/x64/WCDB.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
- package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
- package/scripts/_batch_link_sources.py +73 -0
- package/scripts/_utils.py +531 -0
- package/scripts/annual_report.py +796 -0
- package/scripts/auto_tag.py +128 -0
- package/scripts/biz_daily.py +896 -0
- package/scripts/chat_report.py +397 -0
- package/scripts/chat_stats.py +702 -0
- package/scripts/classify_daily.py +411 -0
- package/scripts/compile_wiki.py +258 -0
- package/scripts/create_reading_notes.py +370 -0
- package/scripts/enrich_backlinks.py +163 -0
- package/scripts/export_chat_html.py +766 -0
- package/scripts/extract_3x_key.py +86 -0
- package/scripts/extract_todos.py +404 -0
- package/scripts/fav_server.py +392 -0
- package/scripts/fix_topics.py +111 -0
- package/scripts/generate_ai_report.py +367 -0
- package/scripts/generate_html.py +1372 -0
- package/scripts/generate_review.py +170 -0
- package/scripts/mcp_bridge.py +398 -0
- package/scripts/nt_decrypt.py +562 -0
- package/scripts/pipeline.py +145 -0
- package/scripts/promote_all.py +310 -0
- package/scripts/promote_ideas.py +297 -0
- package/scripts/rag_chat.py +191 -0
- package/scripts/scan_decrypt_4x.py +329 -0
- package/scripts/semantic_search.py +431 -0
- package/scripts/sync_fav.py +146 -0
- package/scripts/sync_weread.py +192 -0
- package/scripts/vault_rag.py +139 -0
- package/scripts/vault_search.py +141 -0
- package/src/core/dbPathService.ts +348 -0
- package/src/core/keyService.ts +409 -0
- package/src/core/ntCore.ts +224 -0
- package/src/core/sqlcipherCore.ts +764 -0
- package/src/core/wcdbCore.ts +4568 -0
- package/src/core/wechatClient.ts +204 -0
- package/src/lz4.d.ts +10 -0
- package/src/services/chatService.ts +296 -0
- package/src/services/configService.ts +197 -0
- package/src/services/exportService.ts +305 -0
- package/src/services/wechat-formatter.ts +348 -0
- package/src/services/wechatMessageService.ts +383 -0
- package/src/services/wereadService.ts +308 -0
- package/src/services/whitelistService.ts +72 -0
- package/src/types.ts +141 -0
- package/src/utils/errors.ts +42 -0
- package/src/utils/pathUtils.ts +16 -0
- package/src/utils/pythonRunner.ts +81 -0
- 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('&', '&')
|
|
269
|
+
.replace('<', '<')
|
|
270
|
+
.replace('>', '>')
|
|
271
|
+
.replace('"', '"'))
|
|
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('&', '&')
|
|
445
|
+
.replace('<', '<')
|
|
446
|
+
.replace('>', '>')
|
|
447
|
+
.replace('"', '"')
|
|
448
|
+
.replace(''', "'"))
|
|
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()
|