weflow-cli 1.19.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1958 +1,2023 @@
1
- #!/usr/bin/env python3
2
- """
3
- WeChat NT (4.x) Database Access Tool
4
- Uses sqlcipher3 to decrypt and query NT-format databases.
5
- """
6
- import sys
7
- import os
8
- import json
9
- import html
10
- import re
11
- import hmac
12
- import struct
13
- import hashlib
14
- import ctypes
15
- from ctypes import wintypes, c_void_p, c_size_t, create_string_buffer, byref, sizeof
16
- from pathlib import Path
17
-
18
- sqlcipher = None
19
-
20
-
21
- def require_sqlcipher():
22
- """Load SQLCipher only for database operations, not path discovery."""
23
- global sqlcipher
24
- if sqlcipher is not None:
25
- return sqlcipher
26
- try:
27
- from sqlcipher3 import dbapi2 as sqlcipher_module
28
- except ImportError as error:
29
- raise RuntimeError("需要 sqlcipher3: pip install sqlcipher3") from error
30
- sqlcipher = sqlcipher_module
31
- return sqlcipher
32
-
33
- # ========== Memory Scanner ==========
34
- PROCESS_VM_READ = 0x0010
35
- PROCESS_QUERY_INFORMATION = 0x0400
36
- MEM_COMMIT = 0x1000
37
- PAGE_NOACCESS = 0x01
38
- PAGE_GUARD = 0x100
39
-
40
- class MEMORY_BASIC_INFORMATION(ctypes.Structure):
41
- _fields_ = [
42
- ('BaseAddress', ctypes.c_void_p),
43
- ('AllocationBase', ctypes.c_void_p),
44
- ('AllocationProtect', wintypes.DWORD),
45
- ('PartitionId', wintypes.WORD),
46
- ('RegionSize', ctypes.c_size_t),
47
- ('State', wintypes.DWORD),
48
- ('Protect', wintypes.DWORD),
49
- ('Type', wintypes.DWORD),
50
- ]
51
-
52
- IS_WINDOWS = os.name == 'nt'
53
-
54
- if IS_WINDOWS:
55
- kernel32 = ctypes.windll.kernel32
56
- ReadProcessMemory = kernel32.ReadProcessMemory
57
- ReadProcessMemory.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID, ctypes.c_size_t, ctypes.POINTER(c_size_t)]
58
- ReadProcessMemory.restype = wintypes.BOOL
59
- VirtualQueryEx = kernel32.VirtualQueryEx
60
- VirtualQueryEx.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
61
- VirtualQueryEx.restype = ctypes.c_size_t
62
- else:
63
- kernel32 = None
64
-
65
-
66
- def find_weixin_pid():
67
- """Find WeChat process ID (Windows: pymem; Linux: /proc scan)."""
68
- if not IS_WINDOWS:
69
- return find_weixin_pid_linux()
70
- try:
71
- import pymem, pymem.process
72
- for proc in pymem.process.list_processes():
73
- try:
74
- name = proc.szExeFile
75
- if isinstance(name, bytes):
76
- name = name.decode('utf-8', errors='ignore')
77
- if name.lower() == 'weixin.exe':
78
- return proc.th32ProcessID
79
- except:
80
- pass
81
- except ImportError:
82
- pass
83
- return None
84
-
85
-
86
- _LINUX_WECHAT_COMMS = {'wechat', 'wechatappex', 'weixin'}
87
- _LINUX_EXE_PREFIX_DENY = ('python', 'bash', 'sh', 'zsh', 'node', 'perl', 'ruby', 'electron')
88
-
89
-
90
- def _is_linux_wechat_process(pid):
91
- if pid == os.getpid():
92
- return False
93
- try:
94
- with open(f'/proc/{pid}/comm', encoding='utf-8') as f:
95
- comm = f.read().strip().lower()
96
- if comm in _LINUX_WECHAT_COMMS:
97
- return True
98
- try:
99
- exe = os.path.realpath(os.readlink(f'/proc/{pid}/exe'))
100
- except OSError:
101
- return False
102
- name = os.path.basename(exe).lower()
103
- if any(name.startswith(p) for p in _LINUX_EXE_PREFIX_DENY):
104
- return False
105
- return 'wechat' in name or 'weixin' in name
106
- except (PermissionError, FileNotFoundError, ProcessLookupError):
107
- return False
108
-
109
-
110
- def find_weixin_pid_linux():
111
- """Find Linux WeChat main process (largest RSS among candidates)."""
112
- best = None
113
- best_rss = -1
114
- try:
115
- pids = os.listdir('/proc')
116
- except OSError:
117
- return None
118
- for pid_str in pids:
119
- if not pid_str.isdigit():
120
- continue
121
- pid = int(pid_str)
122
- if not _is_linux_wechat_process(pid):
123
- continue
124
- try:
125
- with open(f'/proc/{pid}/statm', encoding='utf-8') as f:
126
- rss_kb = int(f.read().split()[1]) * 4
127
- except (OSError, IndexError, ValueError):
128
- rss_kb = 0
129
- if rss_kb > best_rss:
130
- best_rss = rss_kb
131
- best = pid
132
- return best
133
-
134
-
135
- def scan_memory_keys(pid):
136
- """Scan process memory for x'<64hex_key><32hex_salt>' patterns.
137
-
138
- Returns (keys, error): keys is a list of {"key","salt"} dicts,
139
- error is None on success or 'permission' / 'gone' / 'not_windows'.
140
- """
141
- if not IS_WINDOWS:
142
- return scan_memory_keys_linux(pid)
143
-
144
- hProcess = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
145
- if not hProcess:
146
- return [], None
147
-
148
- pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
149
- keys_found = []
150
- address = 0x10000
151
-
152
- while address < 0x7FFFFFFFFFFF:
153
- mbi = MEMORY_BASIC_INFORMATION()
154
- result = VirtualQueryEx(hProcess, ctypes.c_void_p(address), ctypes.byref(mbi), sizeof(mbi))
155
- if result == 0:
156
- break
157
-
158
- region_addr = mbi.BaseAddress or 0
159
- region_size = mbi.RegionSize or 0
160
-
161
- if (mbi.State == MEM_COMMIT and
162
- region_size > 256 and region_size < 200 * 1024 * 1024 and
163
- mbi.Protect not in (0, PAGE_NOACCESS, PAGE_GUARD)):
164
-
165
- pos = region_addr
166
- end = region_addr + region_size
167
- while pos < end:
168
- chunk_size = min(65536, end - pos)
169
- buf = create_string_buffer(chunk_size)
170
- bytesRead = c_size_t(0)
171
- ok = ReadProcessMemory(hProcess, ctypes.c_void_p(pos), buf, chunk_size, byref(bytesRead))
172
- if ok and bytesRead.value > 0:
173
- data = buf.raw[:bytesRead.value]
174
- for m in pattern.finditer(data):
175
- key_hex = m.group(1).decode()
176
- salt_hex = m.group(2).decode()
177
- keys_found.append((key_hex, salt_hex))
178
- pos += chunk_size
179
-
180
- address = region_addr + region_size
181
-
182
- kernel32.CloseHandle(hProcess)
183
-
184
- # Deduplicate
185
- seen = set()
186
- unique_keys = []
187
- for k, s in keys_found:
188
- pair = (k, s)
189
- if pair not in seen:
190
- seen.add(pair)
191
- unique_keys.append({"key": k, "salt": s})
192
-
193
- return unique_keys, None
194
-
195
-
196
- _LINUX_SKIP_MAPPINGS = {'[vdso]', '[vsyscall]', '[vvar]'}
197
- _LINUX_SKIP_PREFIXES = ('/usr/lib/', '/lib/', '/usr/share/')
198
-
199
-
200
- def scan_memory_keys_linux(pid):
201
- """Scan /proc/<pid>/maps + /proc/<pid>/mem for the key pattern.
202
-
203
- Requires root or CAP_SYS_PTRACE (or the target being a descendant
204
- of this process when yama ptrace_scope=1).
205
- """
206
- regions = []
207
- try:
208
- with open(f'/proc/{pid}/maps', encoding='utf-8') as f:
209
- for line in f:
210
- parts = line.split()
211
- if len(parts) < 2 or 'r' not in parts[1]:
212
- continue
213
- if len(parts) >= 6:
214
- name = parts[5]
215
- if name in _LINUX_SKIP_MAPPINGS:
216
- continue
217
- name_lower = name.lower()
218
- if name.startswith(_LINUX_SKIP_PREFIXES) and \
219
- 'wcdb' not in name_lower and 'wechat' not in name_lower and 'weixin' not in name_lower:
220
- continue
221
- try:
222
- start_s, end_s = parts[0].split('-')
223
- start = int(start_s, 16)
224
- size = int(end_s, 16) - start
225
- except ValueError:
226
- continue
227
- if 0 < size < 500 * 1024 * 1024:
228
- regions.append((start, size))
229
- except PermissionError:
230
- return [], 'permission'
231
- except (FileNotFoundError, ProcessLookupError):
232
- return [], 'gone'
233
-
234
- pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
235
- keys_found = []
236
- try:
237
- with open(f'/proc/{pid}/mem', 'rb') as mem:
238
- for base, size in regions:
239
- try:
240
- mem.seek(base)
241
- data = mem.read(size)
242
- except (OSError, ValueError):
243
- continue
244
- for m in pattern.finditer(data):
245
- keys_found.append((m.group(1).decode(), m.group(2).decode()))
246
- except PermissionError:
247
- return [], 'permission'
248
- except (FileNotFoundError, ProcessLookupError):
249
- return [], 'gone'
250
-
251
- seen = set()
252
- unique_keys = []
253
- for k, s in keys_found:
254
- pair = (k, s)
255
- if pair not in seen:
256
- seen.add(pair)
257
- unique_keys.append({"key": k, "salt": s})
258
-
259
- return unique_keys, None
260
-
261
-
262
- # ========== NT Database Discovery ==========
263
-
264
- def _is_nt_account_dir(path):
265
- return os.path.isdir(os.path.join(path, 'db_storage')) or \
266
- os.path.isdir(os.path.join(path, 'Msg'))
267
-
268
-
269
- def _normalize_nt_root(root):
270
- if not root:
271
- return None
272
- path = os.path.abspath(os.path.expandvars(os.path.expanduser(root)))
273
- if os.path.isfile(path):
274
- path = os.path.dirname(path)
275
- if not os.path.isdir(path):
276
- return None
277
-
278
- try:
279
- for entry in os.listdir(path):
280
- candidate = os.path.join(path, entry)
281
- if os.path.isdir(candidate) and _is_nt_account_dir(candidate):
282
- return path
283
- except OSError:
284
- return None
285
-
286
- for _ in range(6):
287
- if _is_nt_account_dir(path):
288
- return os.path.dirname(path)
289
- parent = os.path.dirname(path)
290
- if parent == path:
291
- break
292
- path = parent
293
-
294
- return None
295
-
296
-
297
- def find_nt_databases(root=None):
298
- """Find all NT-format databases under xwechat_files (message + contact)."""
299
- if root:
300
- normalized_root = _normalize_nt_root(root)
301
- candidates = [normalized_root] if normalized_root else []
302
- elif IS_WINDOWS:
303
- candidates = [
304
- os.path.expandvars(r'%USERPROFILE%\xwechat_files'),
305
- os.path.expandvars(r'%USERPROFILE%\Documents\xwechat_files'),
306
- ]
307
- else:
308
- home = os.path.expanduser('~')
309
- candidates = [
310
- os.path.join(home, '.local', 'share', 'xwechat_files'),
311
- os.path.join(home, 'xwechat_files'),
312
- os.path.join(home, 'Documents', 'xwechat_files'),
313
- os.path.join(home, '文档', 'xwechat_files'),
314
- ]
315
- xwechat = None
316
- for c in candidates:
317
- if os.path.isdir(c):
318
- xwechat = c
319
- break
320
- if not xwechat:
321
- return []
322
-
323
- databases = []
324
- for wxid_dir in os.listdir(xwechat):
325
- # Scan message databases
326
- msg_storage = os.path.join(xwechat, wxid_dir, 'db_storage', 'message')
327
- if os.path.isdir(msg_storage):
328
- for f in os.listdir(msg_storage):
329
- if f.endswith('.db') and not any(x in f for x in ['-shm', '-wal']):
330
- full_path = os.path.join(msg_storage, f)
331
- try:
332
- with open(full_path, 'rb') as fh:
333
- salt = fh.read(16)
334
- databases.append({
335
- "path": full_path,
336
- "name": f"message/{f}",
337
- "salt": salt.hex(),
338
- "size": os.path.getsize(full_path),
339
- "wxid": wxid_dir,
340
- })
341
- except:
342
- pass
343
-
344
- # Scan contact database
345
- contact_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'contact', 'contact.db')
346
- if os.path.isfile(contact_db):
347
- try:
348
- with open(contact_db, 'rb') as fh:
349
- salt = fh.read(16)
350
- databases.append({
351
- "path": contact_db,
352
- "name": "contact/contact.db",
353
- "salt": salt.hex(),
354
- "size": os.path.getsize(contact_db),
355
- "wxid": wxid_dir,
356
- })
357
- except:
358
- pass
359
-
360
- # Scan SNS (朋友圈) database
361
- sns_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'sns', 'sns.db')
362
- if os.path.isfile(sns_db):
363
- try:
364
- with open(sns_db, 'rb') as fh:
365
- salt = fh.read(16)
366
- databases.append({
367
- "path": sns_db,
368
- "name": "sns/sns.db",
369
- "salt": salt.hex(),
370
- "size": os.path.getsize(sns_db),
371
- "wxid": wxid_dir,
372
- })
373
- except:
374
- pass
375
-
376
- # Scan favorites (收藏) database
377
- fav_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'favorite', 'favorite.db')
378
- if os.path.isfile(fav_db):
379
- try:
380
- with open(fav_db, 'rb') as fh:
381
- salt = fh.read(16)
382
- databases.append({
383
- "path": fav_db,
384
- "name": "favorite/favorite.db",
385
- "salt": salt.hex(),
386
- "size": os.path.getsize(fav_db),
387
- "wxid": wxid_dir,
388
- })
389
- except:
390
- pass
391
-
392
- return databases
393
-
394
-
395
- def find_contact_db_path(message_db_path):
396
- """Derive contact.db path from message_0.db path.
397
-
398
- message_0.db: <xwechat_files>/<wxid>/db_storage/message/message_0.db
399
- contact.db: <xwechat_files>/<wxid>/db_storage/contact/contact.db
400
- """
401
- msg_dir = os.path.dirname(message_db_path)
402
- wxid_dir = os.path.dirname(msg_dir) # .../db_storage
403
- xwechat_dir = os.path.dirname(wxid_dir) # .../<wxid>
404
- contact_db = os.path.join(xwechat_dir, 'db_storage', 'contact', 'contact.db')
405
- if os.path.isfile(contact_db):
406
- return contact_db
407
- return None
408
-
409
-
410
- def load_contact_names(contact_db_path, contact_key_hex, contact_salt_hex):
411
- """Load wxid -> {remark, nick_name} map from contact.db.
412
-
413
- Returns dict: {wxid: display_name}
414
- display_name priority: remark > nick_name > alias > wxid
415
-
416
- 取不到就返回空表(名字是装饰性的,调用方会退回 wxid)。**连接必须在 finally
417
- 里关**:原先 `conn.close()` 写在 try 末尾,异常路径上泄漏——而在 Windows 上
418
- 一个没关的连接会把文件锁住(同一个坑早先在这个文件的分片读取里踩过一次)。
419
- 读了一半也保留已经拿到的名字,而不是整份丢掉。
420
- """
421
- if not contact_db_path or not contact_key_hex or not contact_salt_hex:
422
- return {}
423
- if not os.path.isfile(contact_db_path):
424
- return {}
425
-
426
- name_map = {}
427
- conn = None
428
- try:
429
- raw_key = f"x'{contact_key_hex}{contact_salt_hex}'"
430
- conn = require_sqlcipher().connect(contact_db_path)
431
- c = conn.cursor()
432
- c.execute(f'PRAGMA key = "{raw_key}";')
433
-
434
- # contact.db schema: username, alias, remark, nick_name, ...
435
- c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), NULLIF(alias,''), username) FROM contact")
436
- for username, display in c.fetchall():
437
- if username:
438
- name_map[username] = display
439
- except Exception:
440
- pass
441
- finally:
442
- if conn is not None:
443
- try:
444
- conn.close()
445
- except Exception:
446
- pass
447
- return name_map
448
-
449
-
450
- def filter_contacts(contacts, keyword, limit=0):
451
- """按关键字过滤联系人,**然后**才截断到 `limit`。
452
-
453
- 顺序是这里的全部要点:先截断会让第 `limit` 个之后的人搜不到(实测 500 人的通讯录里,
454
- 按备注名找 10 个只命中 3 个)。关键字比对 `username + displayName + remark + nickname`,
455
- 大小写不敏感。
456
- """
457
- if not keyword:
458
- return contacts[:limit] if limit else contacts
459
- needle = keyword.lower()
460
- hits = [
461
- c for c in contacts
462
- if needle in (c.get('username', '') + c.get('displayName', '')
463
- + c.get('remark', '') + c.get('nickname', '')).lower()
464
- ]
465
- return hits[:limit] if limit else hits
466
-
467
-
468
- def apply_contact_names(sessions, name_map):
469
- """Apply contact names to session list, replacing bare wxid displayNames."""
470
- if not name_map:
471
- return sessions
472
- for s in sessions:
473
- username = s.get('username', '')
474
- if username in name_map:
475
- s['displayName'] = name_map[username]
476
- return sessions
477
-
478
-
479
- def match_keys_to_databases(keys, databases):
480
- """Match memory keys to databases by comparing salts."""
481
- salt_to_key = {}
482
- for k in keys:
483
- salt_to_key[k["salt"]] = k["key"]
484
-
485
- matched = []
486
- for db in databases:
487
- if db["salt"] in salt_to_key:
488
- db["key"] = salt_to_key[db["salt"]]
489
- matched.append(db)
490
-
491
- return matched
492
-
493
-
494
- # ========== Database Operations ==========
495
-
496
- def connect_nt_db(db_path, key_hex, salt_hex):
497
- """Connect to an NT database using sqlcipher3."""
498
- raw_key = f"x'{key_hex}{salt_hex}'"
499
- conn = require_sqlcipher().connect(db_path)
500
- c = conn.cursor()
501
- c.execute(f'PRAGMA key = "{raw_key}";')
502
- return conn, c
503
-
504
-
505
- # WeChat rolls a conversation into a new shard over time, and every shard is
506
- # encrypted with its own key. Reading only the configured database therefore
507
- # shows a transcript that stops wherever the first shard's last write left off
508
- # - which is silent, because the query still succeeds. Only the HTML exporter
509
- # used to merge shards, so the same chat exported two different histories
510
- # depending on the format asked for.
511
-
512
- # 分片发现、密钥派生、列探测都在 nt_common 里——**只此一份**。
513
- # 原先 `nt_decrypt` 与 `export_chat_html` 各有一份,而契约并不相同。
514
- #
515
- # 这一行不能省:本文件被 `spec_from_file_location` 加载时(测试就是这么加的),
516
- # 解释器**不会**把脚本目录放进 sys.path——只有直接运行才会。少了它,单独跑
517
- # `nt_decrypt_shards_test.py` 会以 ModuleNotFoundError 失败,而全套一起跑却能过
518
- # (别的测试文件先插了路径)。那种"碰巧能过"正是要避免的。
519
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
520
- from nt_common import (discover_message_shards, derive_database_key,
521
- table_columns, MESSAGE_ANCHOR_COLUMNS)
522
-
523
-
524
- def verify_passphrase_native(passphrase_hex, db_path, internal_key_hex=''):
525
- """Check a passphrase against a database without sqlcipher3.
526
-
527
- Verifies the SQLCipher page-1 HMAC directly with stdlib primitives:
528
-
529
- mac_salt = header_salt XOR 0x3a
530
- key = PBKDF2-HMAC-SHA512(passphrase, header_salt, 256000, 32)
531
- mac_key = PBKDF2-HMAC-SHA512(key, mac_salt, 2, 32)
532
- compare HMAC-SHA512(mac_key, page1_body || page_number=1) with the
533
- digest stored at the end of page 1
534
-
535
- Why this exists: the normal way to check a key is to open the database,
536
- which needs the native library. When sqlcipher3 is missing or misbehaving,
537
- that route says nothing about whether the key itself is right - and the
538
- two failures look identical. This separates them.
539
-
540
- Returns True (correct), False (wrong), or None (cannot tell - bad input,
541
- file too small, or a passphrase that is not hex).
542
- """
543
- try:
544
- raw_passphrase = bytes.fromhex(passphrase_hex)
545
- except (TypeError, ValueError):
546
- return None
547
- if internal_key_hex:
548
- # Some WeChat builds XOR the passphrase with a key embedded in
549
- # Weixin.dll before deriving. Optional: the installs seen so far do
550
- # not need it, so an empty value must stay the default.
551
- try:
552
- internal = bytes.fromhex(internal_key_hex)
553
- except (TypeError, ValueError):
554
- return None
555
- if len(internal) == len(raw_passphrase):
556
- raw_passphrase = bytes(a ^ b for a, b in zip(raw_passphrase, internal))
557
-
558
- try:
559
- with open(db_path, 'rb') as handle:
560
- page = handle.read(4096)
561
- except OSError:
562
- return None
563
- if len(page) < 4096 or page[:16] == b'\x00' * 16:
564
- return None
565
-
566
- salt = page[:16]
567
- mac_salt = bytes(byte ^ 0x3a for byte in salt)
568
- key = hashlib.pbkdf2_hmac('sha512', raw_passphrase, salt, 256000, 32)
569
- mac_key = hashlib.pbkdf2_hmac('sha512', key, mac_salt, 2, 32)
570
-
571
- reserve = 16 + 64 # IV + HMAC-SHA512
572
- reserve = ((reserve + 15) // 16) * 16 # rounded up to the AES block size
573
- body_end = 4096 - reserve + 16
574
- mac = hmac.new(mac_key, page[16:body_end], hashlib.sha512)
575
- mac.update(struct.pack('<I', 1)) # page number
576
- return hmac.compare_digest(mac.digest(), page[body_end:body_end + 64])
577
-
578
-
579
- def connect_message_shards_detailed(db_path, key_hex, salt_hex, passphrase=''):
580
- """Open every shard and report what happened to each one.
581
-
582
- Returns (pairs, failures):
583
- pairs [(shard_path, conn)] for the shards that opened
584
- failures [{"name": basename, "reason": "KEY_REJECTED" | "OPEN_FAILED"}]
585
-
586
- A shard that fails is still skipped rather than fatal - a partially
587
- readable transcript beats a command that refuses to run at all - but the
588
- failure stops being invisible. `shardsFailed` is the difference between
589
- "read everything" and "read what happened to be reachable", which is the
590
- whole point of the coverage report.
591
-
592
- Only the basename is reported: the caller stores this in a state file, and
593
- absolute paths must not end up there.
594
- """
595
- pairs = []
596
- failures = []
597
- for shard in discover_message_shards(db_path):
598
- derived_key, derived_salt = derive_database_key(
599
- shard, key_hex, salt_hex, passphrase)
600
- # Try the derived key first; fall back to the configured pair so
601
- # installs without a passphrase keep working exactly as before.
602
- candidates = [(derived_key, derived_salt)]
603
- if (derived_key, derived_salt) != (key_hex, salt_hex):
604
- candidates.append((key_hex, salt_hex))
605
- failure_reason = 'OPEN_FAILED'
606
- for candidate_key, candidate_salt in candidates:
607
- conn = None
608
- try:
609
- conn, _ = connect_nt_db(shard, candidate_key, candidate_salt)
610
- # PRAGMA key alone never fails; only a read surfaces a bad key.
611
- conn.execute('SELECT count(*) FROM sqlite_master').fetchone()
612
- except Exception:
613
- # Reached the database but could not decrypt it: the key is
614
- # wrong, not the file. Close before retrying, or the handle
615
- # leaks and on Windows the file stays locked.
616
- if conn is not None:
617
- try:
618
- conn.close()
619
- except Exception:
620
- pass
621
- failure_reason = 'KEY_REJECTED'
622
- continue
623
- pairs.append((shard, conn))
624
- break
625
- else:
626
- failures.append({'name': os.path.basename(shard), 'reason': failure_reason})
627
- return pairs, failures
628
-
629
-
630
- def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
631
- """Open the configured database plus every sibling shard.
632
-
633
- Returns the connections that actually opened. A shard that fails is
634
- skipped rather than fatal: a partially readable transcript beats a
635
- command that refuses to run at all.
636
-
637
- Behaviour-preserving delegate: callers that do not ask for the failure
638
- detail get exactly what they got before.
639
- """
640
- pairs, _failures = connect_message_shards_detailed(
641
- db_path, key_hex, salt_hex, passphrase)
642
- return [conn for _path, conn in pairs]
643
-
644
-
645
- def count_talker_rows(conn, msg_table):
646
- """Rows this conversation has in one shard, or None if it has no table there.
647
-
648
- Distinguishes "this shard holds nothing for the conversation" from "this
649
- shard could not be read" - without it both look like zero.
650
- """
651
- try:
652
- cur = conn.execute(
653
- "SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
654
- if cur.fetchone()[0] == 0:
655
- return None
656
- return conn.execute('SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
657
- except Exception:
658
- return None
659
-
660
-
661
- def msg_tables(conn):
662
- """Names of the per-conversation Msg_ tables in one shard."""
663
- try:
664
- rows = conn.execute(
665
- "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg\\_%' ESCAPE '\\'"
666
- ).fetchall()
667
- except Exception:
668
- return set()
669
- return {row[0] for row in rows}
670
-
671
-
672
- def get_fav_schema(db_path, key_hex):
673
- """Dump favorite.db schema + sample rows (key verification / exploration)."""
674
- try:
675
- with open(db_path, 'rb') as fh:
676
- salt = fh.read(16).hex()
677
- conn = require_sqlcipher().connect(db_path)
678
- conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
679
- tables = conn.execute(
680
- "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
681
- ).fetchall()
682
- result = {"success": True, "tables": [t[0] for t in tables]}
683
- if not tables:
684
- result["error"] = "数据库为空或密钥错误"
685
- result["success"] = False
686
- conn.close()
687
- return result
688
- except Exception as e:
689
- return {"success": False, "error": str(e).split('\n')[0][:200]}
690
-
691
-
692
- FAV_TYPE_NAMES = {
693
- 1: 'text', # 文字
694
- 2: 'image', # 图片
695
- 4: 'video', # 视频
696
- 5: 'article', # 公众号文章/网页链接
697
- 14: 'chatrecord', # 聊天记录
698
- }
699
-
700
-
701
- def parse_fav_content(content):
702
- """Extract display fields (title/link/desc/source/cover) from favitem XML."""
703
- out = {}
704
- if not content:
705
- return out
706
- import xml.etree.ElementTree as ET
707
- try:
708
- root = ET.fromstring(content)
709
- except ET.ParseError:
710
- return out
711
-
712
- def text(path):
713
- el = root.find(path)
714
- if el is not None and el.text and el.text.strip():
715
- return el.text.strip()
716
- return None
717
-
718
- title = (text('weburlitem/pagetitle')
719
- or text('datalist/dataitem/datatitle')
720
- or text('title')
721
- or text('desc'))
722
- if title:
723
- out['title'] = title
724
- link = (text('weburlitem/clean_url')
725
- or text('source/link')
726
- or text('datalist/dataitem/stream_weburl'))
727
- if link:
728
- out['link'] = link
729
- desc = text('weburlitem/pagedesc') or text('datalist/dataitem/datadesc')
730
- if desc and desc != out.get('title'):
731
- out['desc'] = desc
732
- src = text('weburlitem/appmsgshareitem/srcdisplayname')
733
- if src:
734
- out['source_name'] = src
735
- cover = (text('weburlitem/pagethumb_url')
736
- or text('datalist/dataitem/dataext')
737
- or text('datalist/dataitem/cdn_thumburl'))
738
- if cover:
739
- out['cover'] = cover
740
- fmt = text('datalist/dataitem/datafmt')
741
- if fmt:
742
- out['format'] = fmt
743
- return out
744
-
745
-
746
- def get_favorites(db_path, key_hex, limit=100, offset=0, keyword=None, fav_type=None):
747
- """List favorite items from favorite.db with parsed content."""
748
- try:
749
- with open(db_path, 'rb') as fh:
750
- salt = fh.read(16).hex()
751
- conn = require_sqlcipher().connect(db_path)
752
- conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
753
- c = conn.cursor()
754
-
755
- tables = [t[0] for t in c.execute(
756
- "SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
757
- if 'fav_db_item' not in tables:
758
- conn.close()
759
- return {"error": f"未找到 fav_db_item 表,现有表: {tables[:20]}"}
760
-
761
- total = c.execute('SELECT count(*) FROM fav_db_item').fetchone()[0]
762
-
763
- sql = ('SELECT local_id, server_id, type, update_time, fromusr, realchatname, content '
764
- 'FROM fav_db_item WHERE 1=1')
765
- params = []
766
- if fav_type is not None:
767
- sql += ' AND type = ?'
768
- params.append(fav_type)
769
- if keyword:
770
- sql += ' AND content LIKE ?'
771
- params.append(f'%{keyword}%')
772
- sql += ' ORDER BY update_time DESC LIMIT ? OFFSET ?'
773
- params.extend([limit, offset])
774
- rows = c.execute(sql, params).fetchall()
775
- conn.close()
776
-
777
- items = []
778
- for local_id, server_id, ftype, update_time, fromusr, realchatname, content in rows:
779
- ct = content if isinstance(content, str) else (
780
- content.decode('utf-8', errors='replace') if content else '')
781
- item = {
782
- 'local_id': local_id,
783
- 'server_id': server_id,
784
- 'type': ftype,
785
- 'type_name': FAV_TYPE_NAMES.get(ftype, 'type_%s' % ftype),
786
- 'update_time': update_time,
787
- 'from_user': fromusr,
788
- 'chat_name': realchatname or None,
789
- }
790
- item.update(parse_fav_content(ct))
791
- items.append(item)
792
- return {"favorites": items, "total": total, "count": len(items),
793
- "limit": limit, "offset": offset}
794
- except Exception as e:
795
- return {"error": str(e).split('\n')[0][:200]}
796
-
797
-
798
- #: 非文本消息在**列表预览**里的显示形态。与 `sqlcipherCore.processMessage` 的口径一致。
799
- #: 列表预览只需要回答"这是什么",不需要内容 —— 而 49 类的 `source` 往往是被压缩的
800
- #: appmsg,硬去剥 XML 标签只会得到 `(/`$` 这种碎片(2026-10-19 用户在 `sessions` 里看到)。
801
- _PREVIEW_LABELS = {
802
- 3: '[图片]', 34: '[语音]', 43: '[视频]', 47: '[表情]', 48: '[位置]',
803
- 42: '[名片]', 49: '[链接/文件]', 10000: '[系统消息]', 10002: '[系统消息]',
804
- }
805
-
806
-
807
- def _clean_preview(text, limit=50):
808
- """预览用:清掉控制字符、压掉换行/连续空白、去掉行首的二进制残渣、截断。
809
-
810
- 行首清洗只针对 **ASCII 标点**:群消息的 `source` 有时前面带三四个字节的碎片
811
- (实测一条广告消息长这样:`(/ }25984981651785937@openim:🔰周黑鸭…`),
812
- 不处理的话列表第一列就是这堆符号。中文标点与 emoji 开头**不动**——
813
- `?好的`、`😂收到` 都是正常消息。
814
- """
815
- if not isinstance(text, str):
816
- return ''
817
- cleaned = ''.join(ch for ch in text if ch >= ' ' or ch == '\t')
818
- cleaned = re.sub(r'\s+', ' ', cleaned).strip()
819
- # 空格也要算在"垃圾前缀"里:实测前缀是 `(/ }`,中间那个空格会把
820
- # 「连续 ASCII 标点」这条匹配打断,于是整段清理失效(2026-10-19 实测)。
821
- cleaned = re.sub(r'^[\x20-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]{1,8}(?=[A-Za-z0-9\u4e00-\u9fff])', '', cleaned)
822
- return cleaned[:limit]
823
-
824
-
825
- def _looks_broken(text):
826
- """这段"文本"其实是被当成文本的二进制吗?
827
-
828
- 判据只看两样:UTF-8 解码时留下的替换字符、以及不可打印字符的占比。
829
- 实测样本:一条 openim 消息的 `message_content` 解出来是
830
- `(/ }25984981651785937@openim:` 加一个替换字符 —— 直接显示就是垃圾。
831
- """
832
- if not text:
833
- return False
834
- # 出现**任何**替换字符就说明原始字节不是合法 UTF-8 —— 正常中文/emoji 不会产生它。
835
- # 阈值不能是"两个以上":实测那条 openim 消息只在结尾丢了一个字节,
836
- # 前面全是可打印的压缩碎片,比例判据根本拦不住。
837
- if '\ufffd' in text:
838
- return True
839
- printable = sum(1 for ch in text if ch.isprintable())
840
- return printable / max(1, len(text)) < 0.9
841
-
842
-
843
- def _summarise(row):
844
- """(last_time, summary) from the newest message row of a conversation."""
845
- last_time = row[0] or 0
846
- msg_type = row[3] or 0
847
- source_text = row[1]
848
- content_text = row[2]
849
-
850
- if msg_type == 1:
851
- # 文本:`message_content` 与 `source` 里**哪个更像人话就用哪个**。
852
- # 只认 `message_content` 会踩到"这一列其实是二进制"的消息(见 `_looks_broken`)。
853
- candidates = []
854
- for value in (content_text, source_text):
855
- if isinstance(value, bytes):
856
- value = value.decode('utf-8', errors='ignore')
857
- if isinstance(value, str) and value.strip():
858
- candidates.append(value)
859
- if not candidates:
860
- return last_time, ""
861
- best = min(candidates, key=lambda s: s.count('\ufffd') / max(1, len(s)))
862
- if _looks_broken(best):
863
- return last_time, '[内容无法显示]'
864
- return last_time, _clean_preview(best)
865
-
866
- # 非文本:先按类型给标签。**不再从 `source` 里剥标签抠文字** —— 那条路只有在
867
- # XML 是明文 appmsg 时才成立,而 4.x 上它经常是压缩的,抠出来就是二进制碎片。
868
- #
869
- # `local_type` 在 4.x 里是**打包值**:高位放 subtype,低 32 位才是基础类型
870
- # (实测 `25769803825` = 0x6_00000031,低 8 位 0x31 = 49 = 链接/文件)。
871
- # 不 mask 的话,一张「链接卡片」会被报成 `[消息类型 25769803825]`。
872
- raw_type = int(msg_type) if msg_type else 0
873
- base_type = raw_type & 0xFFFFFFFF
874
- label = _PREVIEW_LABELS.get(base_type)
875
- if label:
876
- return last_time, label
877
-
878
- if isinstance(source_text, str) and source_text.strip():
879
- clean = re.sub(r'<[^>]+>', '', source_text)
880
- if clean.strip():
881
- # 兜底也要过一遍"这其实是二进制吗":某些消息两列都是压缩数据,
882
- # 剥掉尖括号后看着像文字,实际是 `(/ }25984...` 这种碎片。
883
- if _looks_broken(clean):
884
- return last_time, '[内容无法显示]'
885
- return last_time, _clean_preview(clean)
886
- return last_time, ('[消息类型 %d]' % base_type) if base_type else ''
887
-
888
-
889
- def get_sessions(conns):
890
- """Get chat sessions, newest message per conversation across every shard."""
891
- sessions = {}
892
-
893
- # NT format: each chat has its own Msg_<MD5> table
894
- # The Name2Id table maps usernames to IDs (user_name, is_session)
895
- for conn in conns:
896
- c = conn.cursor()
897
- try:
898
- c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1 LIMIT 500")
899
- usernames = [row[0] for row in c.fetchall() if row[0]]
900
- except Exception:
901
- continue
902
-
903
- tables = msg_tables(conn)
904
- for username in usernames:
905
- entry = sessions.setdefault(username, {"summary": "", "last_time": 0})
906
- msg_table = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
907
- if msg_table not in tables:
908
- continue
909
- try:
910
- c.execute(f'SELECT create_time, source, message_content, local_type FROM "{msg_table}" ORDER BY create_time DESC LIMIT 1')
911
- row = c.fetchone()
912
- if row:
913
- last_time, summary = _summarise(row)
914
- # A conversation spans shards; only the newest speaks for it.
915
- if last_time >= entry["last_time"]:
916
- entry["last_time"] = last_time
917
- entry["summary"] = summary
918
- except Exception:
919
- continue
920
-
921
- result = [{
922
- "username": username,
923
- "type": 1 if "@chatroom" in username else 0,
924
- "unreadCount": 0,
925
- "summary": entry["summary"],
926
- "sortTimestamp": entry["last_time"],
927
- "lastTimestamp": entry["last_time"],
928
- "displayName": username,
929
- } for username, entry in sessions.items()]
930
-
931
- # Sort by timestamp descending
932
- result.sort(key=lambda s: s.get("sortTimestamp", 0), reverse=True)
933
- return {"sessions": result}
934
-
935
-
936
- def _strip_group_speaker(content, known_ids):
937
- """Drop the `wxid_...:` prefix group rows carry in their content.
938
-
939
- Only an id the shard's Name2Id actually knows is accepted, so a message
940
- that merely starts with `note: ...` is left alone.
941
- """
942
- match = re.match(r'^([A-Za-z0-9_@.-]{5,64})\s*[::]\s', str(content or ''))
943
- if not match or match.group(1) not in known_ids:
944
- return content
945
- return content[match.end():]
946
-
947
-
948
- # The columns the reader actually consumes. The SELECT is built from these
949
- # only: a WeChat version that renames or drops any *other* column of the
950
- # message table cannot break a read of a column nobody looks at.
951
- MESSAGE_COLUMNS = ('local_id', 'server_id', 'local_type', 'real_sender_id',
952
- 'create_time', 'message_content')
953
-
954
- # Any one of these is enough to give rows an identity and an order. With none
955
- # of them a row cannot become a message at all, and that is a schema mismatch
956
- # rather than an empty conversation.
957
- # 定义搬去了 `nt_common`:`ORDER BY` 那处(同一个文件下面)过去自己又写了一份
958
- # 字面量,导出器再写一份——顺序在这里就是行为(先按 create_time 排),
959
- # 三份各自为政等于谁改一处谁静默换一种排法。
960
-
961
-
962
- # ---- 非文本消息的显示形态 ---------------------------------------------------
963
- #
964
- # 这条路径此前**把非文本消息丢掉了**:`parsedContent` 只在 local_type == 1 时才有值,
965
- # 而 BLOB 内容不是 str,会在下面被置成空串。于是图片/表情/文件/引用/撤回/红包在下游
966
- # 一律表现为"空内容"——实测那 44 条里"空"的三条,分别是**一条撤回提示**和两个表情。
967
- #
968
- # 类型编码有结构:`local_type = apptype * 2**32 + 49`,49 表示 appmsg 家族。所以这里
969
- # 是**推导**而不是一张长表;只有少数几个固定类型需要单独列。
970
- APPMSG_SUBTYPE = 49
971
- NON_TEXT_LABELS = {
972
- 3: '图片', 34: '语音', 42: '名片', 43: '视频', 47: '表情', 48: '位置',
973
- }
974
- APPMSG_LABELS = {
975
- 4: '链接', 5: '链接', 6: '文件', 19: '聊天记录', 33: '小程序', 57: '引用', 63: '直播',
976
- 2000: '转账', 2001: '红包',
977
- }
978
- # 不认识的 apptype 回 `[应用消息]` 而不是猜成"链接":标签是给下游读的,不能支撑不起也写。
979
- APPMSG_UNKNOWN_LABEL = '应用消息'
980
-
981
- # 引用消息(apptype 57)的正文与被引原文之间的分隔。用词而不是符号,因为下游会把它读成
982
- # 一句话;`|` 在别处会被当表格分隔。
983
- QUOTE_SEP = ' | 引:'
984
- # 被引原文的截断长度。实测 37 条真实引用消息:中位 36 字、17 条超过 60 字、最长的
985
- # 12733 字(引用了一整篇公众号文章)。60 会切掉近一半,120 能留住大多数完整句子,
986
- # 又把那个极端值压住。
987
- QUOTE_CLIP = 120
988
-
989
-
990
- ZSTD_MAGIC = bytes([0x28, 0xB5, 0x2F, 0xFD])
991
- def _decode_content(raw):
992
- """消息内容 → 文本。实现在 `nt_common.decode_content`(各脚本共用一份)。
993
-
994
- 保留这个名字是为了不动 `non_text_display` 等既有调用点;行为与公共函数一致:
995
- str 原样、zstd frame 解压、其它 bytes 按 UTF-8 解码,解不出回空串。
996
- """
997
- from nt_common import decode_content
998
- return decode_content(raw)
999
-
1000
-
1001
- def _xml_block(xml, tag):
1002
- """取一个元素的起始标签到它自己的结束标签之间的整段(含标签)。
1003
-
1004
- 只在区块内找 `title`/`des`:整份 payload 里还有 `<emotionpageshared><title>` 这类
1005
- 同名标签,在全文里搜会匹配到那个去(自验时真的把一段 XML 当文本吐出来过)。
1006
- """
1007
- text = xml or ''
1008
- match = re.search(r'<%s(?:\s[^>]*)?>.*?</%s>' % (tag, tag), text, re.S)
1009
- return match.group(0) if match else ''
1010
-
1011
-
1012
- def _xml_text(xml, tag):
1013
- """取一个标签的文本(含 CDATA)。取不到回空串——`<title />` 这种自闭合就是取不到。
1014
-
1015
- 起始标签里**不许出现 `/`**:否则 `<title />` 会被当成开标签,一路吃到后面某个
1016
- `</title>`,把中间整段 XML 当成文本返回(自验时真的吐出来过)。
1017
-
1018
- 实体在这**解掉**:显示形态是给人(和模型)读的,`a&amp;b` 该显示成 `a&b`。
1019
- TS 侧的 `appMsgFormat.tagText` 同样解,两边一致——否则同一条消息 3.x 与 4.x 读起来
1020
- 不一样。
1021
- """
1022
- match = re.search(
1023
- r'<%s(?:\s[^>/]*)?>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</%s>' % (tag, tag), xml or '', re.S)
1024
- return html.unescape(match.group(1).strip()) if match else ''
1025
-
1026
-
1027
- def _clip(text, limit):
1028
- """超长截断并**留下省略号**。
1029
-
1030
- 不标记的截断读起来像一句说完的话——`title[:60]` 此前就是这样,被引原文最长的
1031
- 12733 字,切完看着像"这就是全部"。
1032
- """
1033
- if len(text) <= limit:
1034
- return text
1035
- return text[:limit] + '…'
1036
-
1037
-
1038
- def non_text_display(local_type, raw):
1039
- """非文本消息 → 给下游看的文本。**绝不返回空串**——这是这次修复的全部要点。
1040
-
1041
- 宁可写 `[未识别的消息类型 81604378673]`,也不要让下游看到空字符串:空串在下游与
1042
- "这条消息不存在"无法区分,而它的真实后果是助手回答"我没解析出内容"。
1043
- """
1044
- lt = int(local_type or 0)
1045
- text = _decode_content(raw)
1046
-
1047
- if lt & 0xFFFFFFFF == APPMSG_SUBTYPE:
1048
- apptype = lt >> 32
1049
- label = APPMSG_LABELS.get(apptype, APPMSG_UNKNOWN_LABEL)
1050
- block = _xml_block(text, 'appmsg')
1051
- title = _xml_text(block, 'title') or _xml_text(block, 'des')
1052
- # 实测 payload 里 `<emotionpageshared>` 这类**嵌套容器**也带 `<title>`,取到的是
1053
- # 它们自己的占位值(`null`)。这种不是标题,按"没有标题"处理。
1054
- if title.lower() in ('null', 'undefined', '0'):
1055
- title = ''
1056
- # 截断:解析万一走偏,也不许把一段 XML 当成标题交给下游。
1057
- title = _clip(title, 60)
1058
- # 引用消息(apptype 57):`title` 是**回复正文**,被引用的原文在 `refermsg/content`。
1059
- # 实测 37 条真实引用消息,被引原文中位 36 字。此前只留回复,模型看到的是一句
1060
- # "是呀,够得意个"却不知道在回什么——引用不带原文等于没引用。
1061
- if apptype == 57:
1062
- quoted = _xml_text(_xml_block(text, 'refermsg'), 'content')
1063
- if quoted.lower() in ('null', 'undefined', '0'):
1064
- quoted = ''
1065
- quoted = _clip(quoted, QUOTE_CLIP)
1066
- if quoted:
1067
- body = (title + QUOTE_SEP + quoted) if title else quoted
1068
- return '[%s] %s' % (label, body)
1069
- return '[%s] %s' % (label, title) if title else '[%s]' % label
1070
-
1071
- label = NON_TEXT_LABELS.get(lt)
1072
- if label:
1073
- return '[%s]' % label
1074
- if lt == 10000: # 系统消息:撤回、拍一拍、入群提示
1075
- return _xml_text(text, 'content') or '[系统消息]'
1076
- return '[未识别的消息类型 %d]' % lt
1077
-
1078
-
1079
- def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
1080
- """One message row -> the CLI's message shape.
1081
-
1082
- `row` is a column-name keyed dict, not a tuple. Positional access assumes
1083
- every column exists; when a WeChat version drops one, the whole shard read
1084
- fails and the conversation looks empty. Reading by name means a missing
1085
- column costs that one field instead.
1086
- """
1087
- local_type = row.get('local_type') or 0
1088
- create_time = row.get('create_time') or 0
1089
- real_sender_id = row.get('real_sender_id') or 0
1090
-
1091
- # Resolve sender: real_sender_id -> Name2Id -> user_name
1092
- sender_username = sender_id_map.get(real_sender_id, "")
1093
-
1094
- # Determine if message is from self
1095
- # own_wxid may have _xxxx suffix (from xwechat_files dir), try both
1096
- is_self = bool(own_wxid and (
1097
- sender_username == own_wxid or
1098
- (own_wxid.endswith('_') is False and sender_username.startswith(own_wxid))
1099
- ))
1100
- if not is_self and own_wxid:
1101
- # Strip _xxxx suffix and retry
1102
- parts = own_wxid.rsplit('_', 1)
1103
- if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
1104
- is_self = (sender_username == parts[0])
1105
-
1106
- # Resolve sender display name from contact map
1107
- if is_self:
1108
- sender_display = "" # Let the CLI show "我"
1109
- else:
1110
- sender_display = name_map.get(sender_username, sender_username) if sender_username else sender_username
1111
-
1112
- # Parse message_content - TEXT column
1113
- raw_content = row.get('message_content')
1114
- # **普通文本也可能是 zstd 压缩的 BLOB**(issue #12)。这里原来只认 `str`,
1115
- # 于是 `local_type == 1` 的压缩正文被置空、再交给 `non_text_display()`,
1116
- # 最终输出 `[未识别的消息类型 1]` —— 消息还在,正文没了,下游按
1117
- # `parsedContent` 做的关键词 / @所有人 筛选就漏掉这些通知。
1118
- # `_decode_content()` 已经处理三种形态(str / zstd frame / 普通 bytes),复用它即可。
1119
- if local_type == 1:
1120
- content = _decode_content(raw_content)
1121
- else:
1122
- content = raw_content if isinstance(raw_content, str) else ""
1123
-
1124
- # `content`/`rawContent` stay exactly as stored; only `parsedContent` - the
1125
- # field every consumer reads first - gets the display-ready form.
1126
- #
1127
- # 非文本消息(图片/表情/文件/引用/撤回/红包…)在过去得到的是**空串**:它们的内容是
1128
- # JSON 里不是 str 的 BLOB,上面那行会把它置空。现在它们走 `non_text_display`,
1129
- # 至少拿到 `[图片]`,多数还能带上文件名、被引用的原文或"谁撤回了一条消息"。
1130
- if content:
1131
- display = _strip_group_speaker(content, set(sender_id_map.values())) if is_group else content
1132
- else:
1133
- display = non_text_display(local_type, raw_content)
1134
-
1135
- return {
1136
- "localId": row.get('local_id') or 0,
1137
- "serverId": str(row.get('server_id') or ''),
1138
- "localType": local_type,
1139
- "createTime": create_time,
1140
- "isSend": 1 if is_self else 0, # 1 = I sent this
1141
- "senderUsername": sender_username,
1142
- "senderDisplay": sender_display,
1143
- "content": content,
1144
- "rawContent": content,
1145
- # 文本不截断:末尾的 `@所有人` 一类关键词正是下游筛选要用的,截掉等于静默漏消息
1146
- # (issue #12)。非文本仍保留 200 字的展示上限——它们是标签和标题,不是正文。
1147
- "parsedContent": display if local_type == 1 else display[:200],
1148
- }
1149
-
1150
-
1151
- def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None,
1152
- shard_names=None, shard_report=None, from_time=None, to_time=None):
1153
- """Get messages for a specific talker, merged across every shard.
1154
-
1155
- Args:
1156
- name_map: optional {wxid: display_name} dict for resolving sender names
1157
- own_wxid: account owner wxid for self-message detection
1158
- shard_names: optional basename per connection, parallel to `conns`
1159
- shard_report: optional list to append one outcome dict per shard to.
1160
- Purely additive: the returned messages are identical with or
1161
- without it, which is asserted by test/nt_decrypt_shards_test.py.
1162
- from_time: optional inclusive lower bound on `create_time` (unix
1163
- seconds), pushed into SQL so a bounded read does not have to fetch
1164
- and discard the whole conversation.
1165
- to_time: optional inclusive upper bound, same units.
1166
-
1167
- A requested window is only ever honoured or refused, never approximated: a
1168
- shard whose table has no `create_time` is skipped and reported as
1169
- WINDOW_UNAVAILABLE rather than returning out-of-range rows that a caller
1170
- tracking coverage would count as covered. Every in-tree caller that passes
1171
- a window also passes `shard_report`, so that refusal is always visible.
1172
- """
1173
- if name_map is None:
1174
- name_map = {}
1175
-
1176
- window_requested = from_time is not None or to_time is not None
1177
-
1178
- msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
1179
- is_group = '@chatroom' in talker
1180
-
1181
- def record(index, opened, has_table, rows_for_talker, reason, missing=None):
1182
- if shard_report is None:
1183
- return
1184
- name = shard_names[index] if shard_names and index < len(shard_names) else ''
1185
- shard_report.append({
1186
- 'name': name,
1187
- 'opened': opened,
1188
- 'hasTalkerTable': has_table,
1189
- 'rowsForTalker': rows_for_talker,
1190
- 'reason': reason,
1191
- # Columns this shard does not have. A non-empty list together with
1192
- # a null `reason` means the read succeeded but lost fields, which is
1193
- # a different thing from a failed read and has to stay visible.
1194
- 'missingColumns': list(missing or ()),
1195
- })
1196
-
1197
- # Each shard only needs to yield its newest window: once every shard's rows
1198
- # are merged and re-sorted, nothing older than that can reach this page.
1199
- window = 0 if limit <= 0 else limit + offset
1200
- collected = []
1201
- found = False
1202
-
1203
- for index, conn in enumerate(conns):
1204
- c = conn.cursor()
1205
- try:
1206
- c.execute("SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
1207
- if c.fetchone()[0] == 0:
1208
- # The shard is readable and simply holds nothing for this
1209
- # conversation - distinct from not being readable at all.
1210
- record(index, True, False, None, None)
1211
- continue
1212
- found = True
1213
- rows_for_talker = c.execute(
1214
- 'SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
1215
-
1216
- available = table_columns(c, msg_table)
1217
- selected = [col for col in MESSAGE_COLUMNS if col in available]
1218
- missing = [col for col in MESSAGE_COLUMNS if col not in available]
1219
- if not [col for col in MESSAGE_ANCHOR_COLUMNS if col in available]:
1220
- # The row shape no longer matches anything the reader can name.
1221
- record(index, True, True, rows_for_talker, 'SCHEMA_MISMATCH',
1222
- missing=missing)
1223
- continue
1224
- if window_requested and 'create_time' not in available:
1225
- record(index, True, True, rows_for_talker, 'WINDOW_UNAVAILABLE',
1226
- missing=missing)
1227
- continue
1228
-
1229
- order = [col for col in MESSAGE_ANCHOR_COLUMNS
1230
- if col in available]
1231
- sql = 'SELECT %s FROM "%s"' % (
1232
- ', '.join('"%s"' % col for col in selected), msg_table)
1233
- params = []
1234
- clauses = []
1235
- if from_time is not None:
1236
- clauses.append('"create_time" >= ?')
1237
- params.append(from_time)
1238
- if to_time is not None:
1239
- clauses.append('"create_time" <= ?')
1240
- params.append(to_time)
1241
- if clauses:
1242
- sql += ' WHERE ' + ' AND '.join(clauses)
1243
- sql += ' ORDER BY ' + ', '.join('"%s" DESC' % col for col in order)
1244
- if window:
1245
- sql += ' LIMIT ?'
1246
- params.append(window)
1247
- c.execute(sql, params)
1248
- # Dicts rather than driver rows: the SELECT list varies per shard,
1249
- # so position no longer identifies a column. Built here rather than
1250
- # via row_factory, which every other reader sharing these
1251
- # connections would inherit.
1252
- rows = [dict(zip(selected, values)) for values in c.fetchall()]
1253
-
1254
- # Sender ids are rowids, so the map has to come from the same
1255
- # shard. Absent, it costs sender names and nothing else, so it is
1256
- # probed rather than left to raise and lose the whole shard.
1257
- has_name_table = c.execute(
1258
- "SELECT COUNT(*) FROM sqlite_master WHERE name='Name2Id'").fetchone()[0] > 0
1259
- sender_id_map = {}
1260
- if has_name_table:
1261
- c.execute("SELECT rowid, user_name FROM Name2Id")
1262
- sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
1263
- except Exception:
1264
- # This shard had the conversation's table but could not be read.
1265
- # Previously this was indistinguishable from "no rows here".
1266
- record(index, True, None, None, 'READ_FAILED')
1267
- continue
1268
- record(index, True, True, rows_for_talker, None, missing=missing)
1269
-
1270
- for row in rows:
1271
- collected.append(_message_dict(row, sender_id_map, name_map, own_wxid, is_group))
1272
-
1273
- if not found:
1274
- return {"error": f"未找到会话: {talker}"}
1275
-
1276
- collected.sort(key=lambda m: (m["createTime"], m["localId"]), reverse=True)
1277
- if limit > 0:
1278
- collected = collected[offset:offset + limit]
1279
- return {"messages": collected}
1280
-
1281
-
1282
- def cursor_probe(db_path, key_hex, salt_hex, passphrase='',
1283
- tables_per_shard=5, rows_per_table=500):
1284
- """Measure what a stable incremental cursor would need on this database.
1285
-
1286
- D-027 keeps `stableCursor: false` until every message backend can honour
1287
- the same semantics. This probe answers the NT backend's **data-side** half:
1288
- whether every message table carries the anchor columns, and whether sampled
1289
- rows actually hold a nonzero `server_id` (the reader's identity falls back
1290
- to localId+createTime without one, and `local_id` is per-shard, so those
1291
- rows cannot be resumed across shards). Duplicate server ids are collected
1292
- across the sample too - an id that appears in two places cannot key a
1293
- cursor, whatever the column definition says.
1294
-
1295
- Sampling is bounded and says so (`exhaustive: false`): `tables_per_shard`
1296
- tables per shard (sorted names, deterministic) read at both rowid ends,
1297
- `rows_per_table` rows each. A full scan of every shard would cost minutes
1298
- on a real account; the schema check IS exhaustive - `PRAGMA table_info` is
1299
- cheap, so every Msg_* table's anchor columns are checked, not sampled.
1300
-
1301
- Read-only: no write, no network, nothing leaves stdout.
1302
- """
1303
- pairs, failures = connect_message_shards_detailed(
1304
- db_path, key_hex, salt_hex, passphrase)
1305
- shard_items = []
1306
- totals = {'tables': 0, 'tablesSampled': 0, 'rowsSampled': 0,
1307
- 'serverIdMissing': 0}
1308
- anchor_missing_total = 0
1309
- # server_id(str) -> {(shard, table)} it was sampled in. A set per id keeps
1310
- # the two rowid ends of one small table from double-counting the same row
1311
- # as a "duplicate" - both ends see it, but it is one location.
1312
- id_locations = {}
1313
-
1314
- for failure in failures:
1315
- shard_items.append({
1316
- 'name': failure['name'], 'opened': False, 'reason': failure['reason'],
1317
- 'tables': None,
1318
- })
1319
-
1320
- for path, conn in pairs:
1321
- item = {'name': os.path.basename(path), 'opened': True, 'reason': None}
1322
- try:
1323
- tables = sorted(msg_tables(conn))
1324
- item['tables'] = len(tables)
1325
- totals['tables'] += len(tables)
1326
-
1327
- # Schema half: exhaustive over every message table.
1328
- anchor_missing = []
1329
- for table in tables:
1330
- cols = table_columns(conn.cursor(), table)
1331
- missing = [c for c in MESSAGE_ANCHOR_COLUMNS if c not in cols]
1332
- if missing and missing not in anchor_missing:
1333
- anchor_missing.append(missing)
1334
- anchor_missing_total += len(anchor_missing)
1335
- item['anchorColumnsMissing'] = anchor_missing
1336
-
1337
- # Data half: bounded sample, both rowid ends per table. A table
1338
- # shorter than the bound hands the same rows to both ends - they
1339
- # are one sample, not two, so rowids are deduplicated per table
1340
- # (and the same id read at both ends must not look "duplicated").
1341
- sample_targets = tables[:tables_per_shard]
1342
- item['tablesSampled'] = len(sample_targets)
1343
- totals['tablesSampled'] += len(sample_targets)
1344
- sampled = 0
1345
- missing_ids = 0
1346
- for table in sample_targets:
1347
- c = conn.cursor()
1348
- seen_rowids = set()
1349
- table_ids = set()
1350
- for order in ('ASC', 'DESC'):
1351
- c.execute(
1352
- 'SELECT rowid, server_id FROM "%s" ORDER BY rowid %s LIMIT ?'
1353
- % (table, order), (rows_per_table,))
1354
- for rowid, server_id in c.fetchall():
1355
- if rowid in seen_rowids:
1356
- continue
1357
- seen_rowids.add(rowid)
1358
- sampled += 1
1359
- if server_id is None or server_id == 0:
1360
- missing_ids += 1
1361
- else:
1362
- table_ids.add(str(int(server_id)))
1363
- for sid in table_ids:
1364
- id_locations.setdefault(sid, set()).add((item['name'], table))
1365
- item['rowsSampled'] = sampled
1366
- item['serverIdMissing'] = missing_ids
1367
- totals['rowsSampled'] += sampled
1368
- totals['serverIdMissing'] += missing_ids
1369
- except Exception as exc:
1370
- item['reason'] = 'READ_FAILED: %s' % str(exc).split('\n')[0][:120]
1371
- finally:
1372
- shard_items.append(item)
1373
- conn.close()
1374
-
1375
- duplicates = {sid: locs for sid, locs in id_locations.items() if len(locs) > 1}
1376
- return {
1377
- 'shards': shard_items,
1378
- 'schemaAnchorMissingTables': anchor_missing_total,
1379
- 'sample': {
1380
- 'tablesPerShard': tables_per_shard,
1381
- 'rowsPerTable': rows_per_table,
1382
- 'exhaustive': False,
1383
- 'tablesTotal': totals['tables'],
1384
- 'tablesSampled': totals['tablesSampled'],
1385
- 'rowsSampled': totals['rowsSampled'],
1386
- 'serverIdMissing': totals['serverIdMissing'],
1387
- 'duplicateServerIds': len(duplicates),
1388
- 'duplicateServerIdSamples': [
1389
- {'serverId': sid, 'locations': sorted('%s/%s' % loc for loc in locs)}
1390
- for sid, locs in sorted(duplicates.items())[:20]
1391
- ],
1392
- },
1393
- }
1394
-
1395
-
1396
- def get_contacts(conns, limit=200, keyword=None):
1397
- """Get contacts from NT database, merged across every shard.
1398
-
1399
- **给了 `keyword` 就不加 `LIMIT`**,由调用方在名字解析之后过滤、再按 `limit` 截断。
1400
- 原来无条件 `LIMIT`,于是过滤发生在**截断之后**:关键字只能在前 `limit` 行里找,
1401
- 后面的人搜不到(实测 500 人的通讯录、按备注名找 10 个只命中 3 个)。名字解析要用
1402
- contact.db 的备注/昵称,那是调用方才有的东西,所以截断必须挪到过滤之后。
1403
- """
1404
- seen = set()
1405
- contacts = []
1406
- for conn in conns:
1407
- try:
1408
- sql = "SELECT user_name FROM Name2Id" if keyword else "SELECT user_name FROM Name2Id LIMIT ?"
1409
- rows = conn.execute(sql, () if keyword else (limit,)).fetchall()
1410
- except Exception:
1411
- continue
1412
- for (username,) in rows:
1413
- # Name2Id carries a placeholder row with no user_name; rendering it
1414
- # produced a blank line at the top of every contact list.
1415
- if not username or username in seen:
1416
- continue
1417
- seen.add(username)
1418
- contacts.append({"username": username, "displayName": username})
1419
- return {"contacts": contacts}
1420
-
1421
-
1422
- # ========== SNS (朋友圈) Queries ==========
1423
-
1424
- def parse_sns_content(content_str):
1425
- """Parse SNS content XML/Protobuf text to extract title, description, media etc."""
1426
- result = {
1427
- 'content': '',
1428
- 'create_time': 0,
1429
- 'username': '',
1430
- 'object_id': '',
1431
- 'media_count': 0,
1432
- }
1433
- if not content_str:
1434
- return result
1435
-
1436
- import re
1437
-
1438
- # Extract createTime
1439
- m = re.search(r'<createTime>(\d+)</createTime>', content_str)
1440
- if m:
1441
- result['create_time'] = int(m.group(1))
1442
-
1443
- # Extract username
1444
- m = re.search(r'<username>([^<]+)</username>', content_str)
1445
- if m:
1446
- result['username'] = m.group(1)
1447
-
1448
- # Extract id
1449
- m = re.search(r'<id>(\d+)</id>', content_str)
1450
- if m:
1451
- result['object_id'] = m.group(1)
1452
-
1453
- # Extract contentDesc (main text)
1454
- m = re.search(r'<contentDesc>([^<]*)</contentDesc>', content_str)
1455
- if m:
1456
- result['content'] = m.group(1)
1457
-
1458
- # Extract contentDesc CDATA
1459
- m = re.search(r'<contentDesc>\s*<!\[CDATA\[(.*?)\]\]>\s*</contentDesc>', content_str, re.DOTALL)
1460
- if m:
1461
- result['content'] = m.group(1).strip()
1462
-
1463
- # Extract title if present
1464
- m = re.search(r'<title>([^<]*)</title>', content_str)
1465
- if m:
1466
- title = m.group(1)
1467
- if title and not result['content']:
1468
- result['content'] = title
1469
- elif title:
1470
- result['content'] = title + '\n' + result['content']
1471
-
1472
- # Count media (ContentObject tags)
1473
- result['media_count'] = len(re.findall(r'<ContentObject[ >]', content_str))
1474
-
1475
- return result
1476
-
1477
-
1478
- def get_sns_timeline(cursor, limit=20, offset=0, usernames=None, keyword=None,
1479
- start_time=None, end_time=None):
1480
- """Query SNS timeline posts from SnsTimeLine table."""
1481
- # Check if SnsTimeLine exists, fall back to SnsTopItem_1
1482
- tables = [r[0] for r in cursor.execute(
1483
- "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1484
-
1485
- table = 'SnsTimeLine' if 'SnsTimeLine' in tables else None
1486
- if not table:
1487
- table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else None
1488
- if not table:
1489
- return {'success': False, 'error': 'No SNS table found'}
1490
-
1491
- conditions = []
1492
- params = []
1493
-
1494
- if table == 'SnsTimeLine':
1495
- if usernames:
1496
- placeholders = ','.join(['?' for _ in usernames])
1497
- conditions.append(f'user_name IN ({placeholders})')
1498
- params.extend(usernames)
1499
- if keyword:
1500
- conditions.append('content LIKE ?')
1501
- params.append(f'%{keyword}%')
1502
- if start_time:
1503
- conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) >= ?")
1504
- params.append(start_time)
1505
- if end_time:
1506
- conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) <= ?")
1507
- params.append(end_time)
1508
- elif table == 'SnsTopItem_1':
1509
- uname_col = 'username' if 'username' in [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()] else 'user_name'
1510
- if usernames:
1511
- placeholders = ','.join(['?' for _ in usernames])
1512
- conditions.append(f'{uname_col} IN ({placeholders})')
1513
- params.extend(usernames)
1514
- if keyword:
1515
- conditions.append('summary LIKE ?')
1516
- params.append(f'%{keyword}%')
1517
- if start_time:
1518
- conditions.append('create_time >= ?')
1519
- params.append(start_time)
1520
- if end_time:
1521
- conditions.append('create_time <= ?')
1522
- params.append(end_time)
1523
-
1524
- where = ' AND '.join(conditions) if conditions else '1=1'
1525
- order = 'tid DESC' if table == 'SnsTimeLine' else 'create_time DESC'
1526
-
1527
- try:
1528
- cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1529
- # Only select known text columns to avoid binary decode errors
1530
- text_cols = [c for c in cols if c in ('tid', 'user_name', 'content', 'username', 'summary',
1531
- 'create_time', 'last_read_time', 'is_read',
1532
- 'from_username', 'from_nickname', 'to_username',
1533
- 'to_nickname', 'comment_id', 'feed_id',
1534
- 'createTime', 'userName')]
1535
- if not text_cols:
1536
- text_cols = ['*']
1537
- select_str = ', '.join(text_cols)
1538
- rows = cursor.execute(
1539
- f'SELECT {select_str} FROM [{table}] WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?',
1540
- params + [limit, offset]
1541
- ).fetchall()
1542
-
1543
- timeline = []
1544
-
1545
- for row in rows:
1546
- item = dict(zip(text_cols, row))
1547
-
1548
- if table == 'SnsTimeLine':
1549
- # Parse XML content
1550
- content_str = item.get('content', '') or ''
1551
- parsed = parse_sns_content(content_str)
1552
- create_time = parsed['create_time'] or 0
1553
- username = parsed['username'] or item.get('user_name', '')
1554
- text = parsed['content'] or ''
1555
- media_count = parsed['media_count']
1556
- else:
1557
- create_time = item.get('create_time', 0) or 0
1558
- username = item.get('username', '') or item.get('user_name', '')
1559
- text = item.get('summary', '') or ''
1560
- media_count = 0
1561
-
1562
- timeline.append({
1563
- 'create_time': create_time,
1564
- 'username': username,
1565
- 'content': text,
1566
- 'media_count': media_count,
1567
- 'table': table,
1568
- })
1569
-
1570
- return {'success': True, 'timeline': timeline, 'table': table}
1571
- except Exception as e:
1572
- return {'success': False, 'error': str(e)}
1573
-
1574
-
1575
- def get_sns_usernames(cursor):
1576
- """Get unique usernames from SnsTopItem_1."""
1577
- tables = [r[0] for r in cursor.execute(
1578
- "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1579
-
1580
- # Prefer SnsTopItem_1 for user listing (larger)
1581
- table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
1582
- if not table:
1583
- return {'success': False, 'error': 'No SNS table found'}
1584
-
1585
- # Discover username column
1586
- cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1587
- uname_col = 'username' if 'username' in cols else 'user_name'
1588
-
1589
- try:
1590
- rows = cursor.execute(
1591
- f'SELECT [{uname_col}], COUNT(*) as cnt FROM [{table}] GROUP BY [{uname_col}] ORDER BY cnt DESC'
1592
- ).fetchall()
1593
- usernames = [r[0] for r in rows if r[0]]
1594
- counts = {r[0]: r[1] for r in rows if r[0]}
1595
- return {'success': True, 'usernames': usernames, 'counts': counts}
1596
- except Exception as e:
1597
- return {'success': False, 'error': str(e)}
1598
-
1599
-
1600
- def get_sns_stats(cursor, my_wxid=None):
1601
- """Get SNS statistics from SnsTopItem_1."""
1602
- tables = [r[0] for r in cursor.execute(
1603
- "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1604
-
1605
- table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
1606
- if not table:
1607
- return {'success': False, 'error': 'No SNS table found'}
1608
-
1609
- try:
1610
- total = cursor.execute(f'SELECT COUNT(*) FROM [{table}]').fetchone()[0]
1611
-
1612
- cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1613
- uname_col = 'username' if 'username' in cols else 'user_name'
1614
-
1615
- total_friends = 0
1616
- if uname_col:
1617
- total_friends = cursor.execute(
1618
- f'SELECT COUNT(DISTINCT [{uname_col}]) FROM [{table}] WHERE [{uname_col}] IS NOT NULL'
1619
- ).fetchone()[0]
1620
-
1621
- my_posts = None
1622
- if my_wxid and uname_col:
1623
- my_posts = cursor.execute(
1624
- f'SELECT COUNT(*) FROM [{table}] WHERE [{uname_col}] = ?',
1625
- (my_wxid,)).fetchone()[0]
1626
-
1627
- return {
1628
- 'success': True,
1629
- 'data': {
1630
- 'totalPosts': total,
1631
- 'totalFriends': total_friends,
1632
- 'myPosts': my_posts,
1633
- }
1634
- }
1635
- except Exception as e:
1636
- return {'success': False, 'error': str(e)}
1637
-
1638
-
1639
- # ========== Main CLI ==========
1640
-
1641
- def main():
1642
- import argparse
1643
- parser = argparse.ArgumentParser(description='WeChat NT Database Tool')
1644
- sub = parser.add_subparsers(dest='command')
1645
-
1646
- # scan command
1647
- scan_parser = sub.add_parser('scan', help='Scan memory for keys and match NT databases')
1648
- scan_parser.add_argument('--json', action='store_true', help='Output as JSON')
1649
- scan_parser.add_argument('--root', default=os.environ.get('WEFLOW_SCAN_ROOT'), help='xwechat_files root directory override')
1650
-
1651
- # sessions command
1652
- sessions_parser = sub.add_parser('sessions', help='List chat sessions')
1653
- sessions_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1654
- sessions_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1655
- sessions_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1656
- sessions_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1657
- sessions_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1658
- sessions_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1659
- sessions_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1660
- sessions_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1661
-
1662
- # messages command
1663
- msg_parser = sub.add_parser('messages', help='Get messages')
1664
- msg_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1665
- msg_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1666
- msg_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1667
- msg_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1668
- msg_parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
1669
- msg_parser.add_argument('--limit', type=int, default=100)
1670
- msg_parser.add_argument('--offset', type=int, default=0)
1671
- # Unix seconds, inclusive at both ends. Pushed into SQL, so a bounded read
1672
- # no longer has to fetch the whole conversation to discard most of it.
1673
- msg_parser.add_argument('--from', dest='from_time', type=int,
1674
- default=os.environ.get('WEFLOW_FROM_TIME'),
1675
- help='Only messages at or after this unix time')
1676
- msg_parser.add_argument('--to', dest='to_time', type=int,
1677
- default=os.environ.get('WEFLOW_TO_TIME'),
1678
- help='Only messages at or before this unix time')
1679
- msg_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for sender names')
1680
- msg_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1681
- msg_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1682
- msg_parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid (for self-message detection)')
1683
- # Opt-in: without it the JSON is byte-identical to before this flag existed.
1684
- msg_parser.add_argument('--report-shards', action='store_true',
1685
- help='Add a per-shard read report to the result (additive)')
1686
-
1687
- # contacts command
1688
- contacts_parser = sub.add_parser('contacts', help='List contacts')
1689
- contacts_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1690
- contacts_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1691
- contacts_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1692
- contacts_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1693
- contacts_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1694
- contacts_parser.add_argument('--limit', type=int, default=200)
1695
- contacts_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1696
- contacts_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1697
- contacts_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1698
-
1699
- # sns-timeline command
1700
- sns_tl_parser = sub.add_parser('sns-timeline', help='Get SNS/Moments timeline')
1701
- sns_tl_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
1702
- sns_tl_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1703
- sns_tl_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1704
- sns_tl_parser.add_argument('--limit', type=int, default=20)
1705
- sns_tl_parser.add_argument('--offset', type=int, default=0)
1706
- sns_tl_parser.add_argument('--usernames', default=os.environ.get('WEFLOW_QUERY_USERNAMES'), help='JSON array of usernames to filter')
1707
- sns_tl_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
1708
- sns_tl_parser.add_argument('--start-time', type=int, help='Start timestamp')
1709
- sns_tl_parser.add_argument('--end-time', type=int, help='End timestamp')
1710
-
1711
- # sns-usernames command
1712
- sns_un_parser = sub.add_parser('sns-usernames', help='List usernames with SNS posts')
1713
- sns_un_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
1714
- sns_un_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1715
- sns_un_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1716
-
1717
- # sns-stats command
1718
- sns_stats_parser = sub.add_parser('sns-stats', help='SNS statistics')
1719
- sns_stats_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
1720
- sns_stats_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1721
- sns_stats_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1722
- sns_stats_parser.add_argument('--my-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid for my-posts count')
1723
-
1724
- # fav-schema command
1725
- fav_schema_parser = sub.add_parser('fav-schema', help='Dump favorite.db schema (key verification)')
1726
- fav_schema_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to favorite.db')
1727
- fav_schema_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1728
-
1729
- verify_parser = sub.add_parser('verify', help='Verify a key+salt can open a database')
1730
- verify_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1731
- verify_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1732
- verify_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1733
-
1734
- # Same question as `verify`, answered without the native library - so a
1735
- # missing or broken sqlcipher3 does not hide whether the key is correct.
1736
- native_parser = sub.add_parser('verify-native',
1737
- help='Verify a passphrase from the file header alone (no sqlcipher3)')
1738
- native_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to any database in the set')
1739
- native_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), required=not os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Passphrase hex (64 chars)')
1740
- native_parser.add_argument('--internal-key', default=os.environ.get('WEFLOW_INTERNAL_DB_KEY', ''), help='Optional 64-char hex XOR key (some builds)')
1741
-
1742
- # fav-list command
1743
- fav_list_parser = sub.add_parser('fav-list', help='List favorite items')
1744
- fav_list_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to favorite.db')
1745
- fav_list_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1746
- fav_list_parser.add_argument('--limit', type=int, default=100)
1747
- fav_list_parser.add_argument('--offset', type=int, default=0)
1748
- fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1749
- fav_list_parser.add_argument('--type', type=int, dest='fav_type',
1750
- help='Filter by type: 1=text 2=image 4=video 5=article 14=chatrecord')
1751
-
1752
- # cursor-probe command: the data-side half of D-027's stable-cursor answer.
1753
- cursor_parser = sub.add_parser(
1754
- 'cursor-probe',
1755
- help='Probe what a stable incremental cursor would need here (read-only, bounded sample)')
1756
- cursor_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1757
- cursor_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1758
- cursor_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1759
- cursor_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1760
- cursor_parser.add_argument('--tables-per-shard', type=int, default=5,
1761
- help='Message tables sampled per shard for the identity half (schema half is exhaustive)')
1762
- cursor_parser.add_argument('--rows-per-table', type=int, default=500,
1763
- help='Rows read at each rowid end per sampled table')
1764
-
1765
- args = parser.parse_args()
1766
-
1767
- if args.command == 'scan':
1768
- # Discover the database files before touching the process. Walking the
1769
- # filesystem does not need WeChat running, but key matching does - and
1770
- # the caller derives keys from the passphrase when the memory scan
1771
- # finds nothing, which only needs the file list. Returning early on
1772
- # "process not running" without the databases threw that list away and
1773
- # left the caller with nothing to derive from.
1774
- databases = find_nt_databases(getattr(args, 'root', None))
1775
-
1776
- pid = find_weixin_pid()
1777
- if not pid:
1778
- if IS_WINDOWS:
1779
- print(json.dumps({"error": "Weixin.exe 未运行", "databases": databases}))
1780
- else:
1781
- print(json.dumps({"error": "未检测到 Linux 微信进程,请确认微信已启动并登录",
1782
- "databases": databases}))
1783
- return
1784
-
1785
- if not args.json:
1786
- print(f"扫描进程 PID {pid}...")
1787
-
1788
- keys, scan_err = scan_memory_keys(pid)
1789
- if scan_err == 'permission':
1790
- print(json.dumps({"error": "PERMISSION_DENIED: 读取微信进程内存需要 root 或 CAP_SYS_PTRACE 权限。可使用 sudo 运行,或执行: sudo setcap cap_sys_ptrace=ep $(which python3)",
1791
- "databases": databases}))
1792
- return
1793
- if scan_err == 'gone':
1794
- print(json.dumps({"error": "微信进程已退出,请重试", "databases": databases}))
1795
- return
1796
- if not args.json:
1797
- print(f"找到 {len(keys)} 个密钥")
1798
-
1799
- if not args.json:
1800
- print(f"找到 {len(databases)} 个 NT 数据库")
1801
-
1802
- matched = match_keys_to_databases(keys, databases)
1803
- if not args.json:
1804
- print(f"匹配 {len(matched)} 个数据库")
1805
- for db in matched:
1806
- print(f" {db['name']} ({db['size']/1024/1024:.1f}MB) key={db['key'][:16]}... salt={db['salt'][:16]}...")
1807
- else:
1808
- print(json.dumps({"keys": keys, "databases": databases, "matched": matched}))
1809
- return
1810
-
1811
- if args.command == 'verify':
1812
- # sqlcipher 打开+读 sqlite_master 才触发解密, 错误密钥在此报 "file is not a database"
1813
- try:
1814
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1815
- n = conn.execute(
1816
- "SELECT count(*) FROM sqlite_master WHERE type='table'"
1817
- ).fetchone()[0]
1818
- conn.close()
1819
- print(json.dumps({"success": n > 0, "tables": n}, ensure_ascii=True))
1820
- except Exception as e:
1821
- print(json.dumps({"success": False, "error": str(e).split('\n')[0][:200]}, ensure_ascii=True))
1822
- return
1823
-
1824
- if args.command == 'verify-native':
1825
- verdict = verify_passphrase_native(args.passphrase, args.db,
1826
- getattr(args, 'internal_key', '') or '')
1827
- if verdict is None:
1828
- print(json.dumps({"success": False, "verified": None,
1829
- "error": "无法判定(文件不可读、过短,或 passphrase 不是 64 位 hex)"},
1830
- ensure_ascii=True))
1831
- return
1832
- print(json.dumps({"success": True, "verified": verdict}, ensure_ascii=True))
1833
- return
1834
-
1835
- # Build contact name map once if contact db provided
1836
- contact_name_map = {}
1837
- contact_db = getattr(args, 'contact_db', None)
1838
- contact_key = getattr(args, 'contact_key', None)
1839
- contact_salt = getattr(args, 'contact_salt', None)
1840
- if contact_db and contact_key and contact_salt:
1841
- contact_name_map = load_contact_names(contact_db, contact_key, contact_salt)
1842
-
1843
- if args.command == 'sessions':
1844
- conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1845
- if not conns:
1846
- print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1847
- return
1848
- result = get_sessions(conns)
1849
- if 'sessions' in result:
1850
- result['sessions'] = apply_contact_names(result['sessions'], contact_name_map)
1851
- if args.keyword:
1852
- kw = args.keyword.lower()
1853
- result['sessions'] = [
1854
- s for s in result['sessions']
1855
- if kw in (s.get('username', '') + s.get('displayName', '') + s.get('summary', '')).lower()
1856
- ]
1857
- print(json.dumps(result, ensure_ascii=True))
1858
- for conn in conns:
1859
- conn.close()
1860
-
1861
- elif args.command == 'messages':
1862
- passphrase = getattr(args, 'passphrase', '') or ''
1863
- own_wxid = getattr(args, 'own_wxid', None)
1864
-
1865
- if getattr(args, 'report_shards', False):
1866
- pairs, failures = connect_message_shards_detailed(
1867
- args.db, args.key, args.salt, passphrase)
1868
- if not pairs:
1869
- print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1870
- return
1871
- conns = [conn for _path, conn in pairs]
1872
- names = [os.path.basename(path) for path, _conn in pairs]
1873
- shard_items = []
1874
- result = get_messages(conns, args.talker, args.limit, args.offset,
1875
- contact_name_map, own_wxid,
1876
- shard_names=names, shard_report=shard_items,
1877
- from_time=args.from_time, to_time=args.to_time)
1878
- # Failure entries carry no opened connection, so they are not in
1879
- # `conns` and could not be recorded by get_messages itself.
1880
- result['shards'] = {
1881
- 'scanned': len(pairs) + len(failures),
1882
- 'opened': len(pairs),
1883
- 'failed': len(failures),
1884
- 'items': failures + shard_items,
1885
- }
1886
- else:
1887
- conns = connect_message_shards(args.db, args.key, args.salt, passphrase)
1888
- if not conns:
1889
- print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1890
- return
1891
- result = get_messages(conns, args.talker, args.limit, args.offset,
1892
- contact_name_map, own_wxid,
1893
- from_time=args.from_time, to_time=args.to_time)
1894
-
1895
- print(json.dumps(result, ensure_ascii=True))
1896
- for conn in conns:
1897
- conn.close()
1898
-
1899
- elif args.command == 'contacts':
1900
- conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1901
- if not conns:
1902
- print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1903
- return
1904
- result = get_contacts(conns, args.limit, keyword=args.keyword)
1905
- if 'contacts' in result:
1906
- result['contacts'] = apply_contact_names(result['contacts'], contact_name_map)
1907
- result['contacts'] = filter_contacts(result['contacts'], args.keyword, args.limit)
1908
- print(json.dumps(result, ensure_ascii=True))
1909
- for conn in conns:
1910
- conn.close()
1911
-
1912
- elif args.command == 'sns-timeline':
1913
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1914
- usernames = None
1915
- if args.usernames:
1916
- try:
1917
- usernames = json.loads(args.usernames)
1918
- except: pass
1919
- result = get_sns_timeline(conn.cursor(), args.limit, args.offset,
1920
- usernames, args.keyword,
1921
- args.start_time, args.end_time)
1922
- print(json.dumps(result, ensure_ascii=True, default=str))
1923
- conn.close()
1924
-
1925
- elif args.command == 'sns-usernames':
1926
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1927
- result = get_sns_usernames(conn.cursor())
1928
- print(json.dumps(result, ensure_ascii=True, default=str))
1929
- conn.close()
1930
-
1931
- elif args.command == 'sns-stats':
1932
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1933
- result = get_sns_stats(conn.cursor(), args.my_wxid)
1934
- print(json.dumps(result, ensure_ascii=True, default=str))
1935
- conn.close()
1936
-
1937
- elif args.command == 'fav-schema':
1938
- result = get_fav_schema(args.db, args.key)
1939
- print(json.dumps(result, ensure_ascii=True))
1940
-
1941
- elif args.command == 'fav-list':
1942
- result = get_favorites(args.db, args.key, args.limit, args.offset,
1943
- args.keyword, getattr(args, 'fav_type', None))
1944
- print(json.dumps(result, ensure_ascii=True, default=str))
1945
-
1946
- elif args.command == 'cursor-probe':
1947
- result = cursor_probe(args.db, args.key, args.salt,
1948
- getattr(args, 'passphrase', '') or '',
1949
- tables_per_shard=args.tables_per_shard,
1950
- rows_per_table=args.rows_per_table)
1951
- print(json.dumps(result, ensure_ascii=True))
1952
-
1953
- else:
1954
- parser.print_help()
1955
-
1956
-
1957
- if __name__ == '__main__':
1958
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ WeChat NT (4.x) Database Access Tool
4
+ Uses sqlcipher3 to decrypt and query NT-format databases.
5
+ """
6
+ import sys
7
+ import os
8
+ import json
9
+ import html
10
+ import re
11
+ import hmac
12
+ import struct
13
+ import hashlib
14
+ import ctypes
15
+ from ctypes import wintypes, c_void_p, c_size_t, create_string_buffer, byref, sizeof
16
+ from pathlib import Path
17
+
18
+ sqlcipher = None
19
+
20
+
21
+ def require_sqlcipher():
22
+ """Load SQLCipher only for database operations, not path discovery."""
23
+ global sqlcipher
24
+ if sqlcipher is not None:
25
+ return sqlcipher
26
+ try:
27
+ from sqlcipher3 import dbapi2 as sqlcipher_module
28
+ except ImportError as error:
29
+ raise RuntimeError("需要 sqlcipher3: pip install sqlcipher3") from error
30
+ sqlcipher = sqlcipher_module
31
+ return sqlcipher
32
+
33
+ # ========== Memory Scanner ==========
34
+ PROCESS_VM_READ = 0x0010
35
+ PROCESS_QUERY_INFORMATION = 0x0400
36
+ MEM_COMMIT = 0x1000
37
+ PAGE_NOACCESS = 0x01
38
+ PAGE_GUARD = 0x100
39
+
40
+ class MEMORY_BASIC_INFORMATION(ctypes.Structure):
41
+ _fields_ = [
42
+ ('BaseAddress', ctypes.c_void_p),
43
+ ('AllocationBase', ctypes.c_void_p),
44
+ ('AllocationProtect', wintypes.DWORD),
45
+ ('PartitionId', wintypes.WORD),
46
+ ('RegionSize', ctypes.c_size_t),
47
+ ('State', wintypes.DWORD),
48
+ ('Protect', wintypes.DWORD),
49
+ ('Type', wintypes.DWORD),
50
+ ]
51
+
52
+ IS_WINDOWS = os.name == 'nt'
53
+
54
+ if IS_WINDOWS:
55
+ kernel32 = ctypes.windll.kernel32
56
+ ReadProcessMemory = kernel32.ReadProcessMemory
57
+ ReadProcessMemory.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID, ctypes.c_size_t, ctypes.POINTER(c_size_t)]
58
+ ReadProcessMemory.restype = wintypes.BOOL
59
+ VirtualQueryEx = kernel32.VirtualQueryEx
60
+ VirtualQueryEx.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
61
+ VirtualQueryEx.restype = ctypes.c_size_t
62
+ else:
63
+ kernel32 = None
64
+
65
+
66
+ def find_weixin_pid():
67
+ """Find WeChat process ID (Windows: pymem; Linux: /proc scan)."""
68
+ if not IS_WINDOWS:
69
+ return find_weixin_pid_linux()
70
+ try:
71
+ import pymem, pymem.process
72
+ for proc in pymem.process.list_processes():
73
+ try:
74
+ name = proc.szExeFile
75
+ if isinstance(name, bytes):
76
+ name = name.decode('utf-8', errors='ignore')
77
+ if name.lower() == 'weixin.exe':
78
+ return proc.th32ProcessID
79
+ except:
80
+ pass
81
+ except ImportError:
82
+ pass
83
+ return None
84
+
85
+
86
+ _LINUX_WECHAT_COMMS = {'wechat', 'wechatappex', 'weixin'}
87
+ _LINUX_EXE_PREFIX_DENY = ('python', 'bash', 'sh', 'zsh', 'node', 'perl', 'ruby', 'electron')
88
+
89
+
90
+ def _is_linux_wechat_process(pid):
91
+ if pid == os.getpid():
92
+ return False
93
+ try:
94
+ with open(f'/proc/{pid}/comm', encoding='utf-8') as f:
95
+ comm = f.read().strip().lower()
96
+ if comm in _LINUX_WECHAT_COMMS:
97
+ return True
98
+ try:
99
+ exe = os.path.realpath(os.readlink(f'/proc/{pid}/exe'))
100
+ except OSError:
101
+ return False
102
+ name = os.path.basename(exe).lower()
103
+ if any(name.startswith(p) for p in _LINUX_EXE_PREFIX_DENY):
104
+ return False
105
+ return 'wechat' in name or 'weixin' in name
106
+ except (PermissionError, FileNotFoundError, ProcessLookupError):
107
+ return False
108
+
109
+
110
+ def find_weixin_pid_linux():
111
+ """Find Linux WeChat main process (largest RSS among candidates)."""
112
+ best = None
113
+ best_rss = -1
114
+ try:
115
+ pids = os.listdir('/proc')
116
+ except OSError:
117
+ return None
118
+ for pid_str in pids:
119
+ if not pid_str.isdigit():
120
+ continue
121
+ pid = int(pid_str)
122
+ if not _is_linux_wechat_process(pid):
123
+ continue
124
+ try:
125
+ with open(f'/proc/{pid}/statm', encoding='utf-8') as f:
126
+ rss_kb = int(f.read().split()[1]) * 4
127
+ except (OSError, IndexError, ValueError):
128
+ rss_kb = 0
129
+ if rss_kb > best_rss:
130
+ best_rss = rss_kb
131
+ best = pid
132
+ return best
133
+
134
+
135
+ def scan_memory_keys(pid):
136
+ """Scan process memory for x'<64hex_key><32hex_salt>' patterns.
137
+
138
+ Returns (keys, error): keys is a list of {"key","salt"} dicts,
139
+ error is None on success or 'permission' / 'gone' / 'not_windows'.
140
+ """
141
+ if not IS_WINDOWS:
142
+ return scan_memory_keys_linux(pid)
143
+
144
+ hProcess = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
145
+ if not hProcess:
146
+ return [], None
147
+
148
+ pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
149
+ keys_found = []
150
+ address = 0x10000
151
+
152
+ while address < 0x7FFFFFFFFFFF:
153
+ mbi = MEMORY_BASIC_INFORMATION()
154
+ result = VirtualQueryEx(hProcess, ctypes.c_void_p(address), ctypes.byref(mbi), sizeof(mbi))
155
+ if result == 0:
156
+ break
157
+
158
+ region_addr = mbi.BaseAddress or 0
159
+ region_size = mbi.RegionSize or 0
160
+
161
+ if (mbi.State == MEM_COMMIT and
162
+ region_size > 256 and region_size < 200 * 1024 * 1024 and
163
+ mbi.Protect not in (0, PAGE_NOACCESS, PAGE_GUARD)):
164
+
165
+ pos = region_addr
166
+ end = region_addr + region_size
167
+ while pos < end:
168
+ chunk_size = min(65536, end - pos)
169
+ buf = create_string_buffer(chunk_size)
170
+ bytesRead = c_size_t(0)
171
+ ok = ReadProcessMemory(hProcess, ctypes.c_void_p(pos), buf, chunk_size, byref(bytesRead))
172
+ if ok and bytesRead.value > 0:
173
+ data = buf.raw[:bytesRead.value]
174
+ for m in pattern.finditer(data):
175
+ key_hex = m.group(1).decode()
176
+ salt_hex = m.group(2).decode()
177
+ keys_found.append((key_hex, salt_hex))
178
+ pos += chunk_size
179
+
180
+ address = region_addr + region_size
181
+
182
+ kernel32.CloseHandle(hProcess)
183
+
184
+ # Deduplicate
185
+ seen = set()
186
+ unique_keys = []
187
+ for k, s in keys_found:
188
+ pair = (k, s)
189
+ if pair not in seen:
190
+ seen.add(pair)
191
+ unique_keys.append({"key": k, "salt": s})
192
+
193
+ return unique_keys, None
194
+
195
+
196
+ _LINUX_SKIP_MAPPINGS = {'[vdso]', '[vsyscall]', '[vvar]'}
197
+ _LINUX_SKIP_PREFIXES = ('/usr/lib/', '/lib/', '/usr/share/')
198
+
199
+
200
+ def scan_memory_keys_linux(pid):
201
+ """Scan /proc/<pid>/maps + /proc/<pid>/mem for the key pattern.
202
+
203
+ Requires root or CAP_SYS_PTRACE (or the target being a descendant
204
+ of this process when yama ptrace_scope=1).
205
+ """
206
+ regions = []
207
+ try:
208
+ with open(f'/proc/{pid}/maps', encoding='utf-8') as f:
209
+ for line in f:
210
+ parts = line.split()
211
+ if len(parts) < 2 or 'r' not in parts[1]:
212
+ continue
213
+ if len(parts) >= 6:
214
+ name = parts[5]
215
+ if name in _LINUX_SKIP_MAPPINGS:
216
+ continue
217
+ name_lower = name.lower()
218
+ if name.startswith(_LINUX_SKIP_PREFIXES) and \
219
+ 'wcdb' not in name_lower and 'wechat' not in name_lower and 'weixin' not in name_lower:
220
+ continue
221
+ try:
222
+ start_s, end_s = parts[0].split('-')
223
+ start = int(start_s, 16)
224
+ size = int(end_s, 16) - start
225
+ except ValueError:
226
+ continue
227
+ if 0 < size < 500 * 1024 * 1024:
228
+ regions.append((start, size))
229
+ except PermissionError:
230
+ return [], 'permission'
231
+ except (FileNotFoundError, ProcessLookupError):
232
+ return [], 'gone'
233
+
234
+ pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
235
+ keys_found = []
236
+ try:
237
+ with open(f'/proc/{pid}/mem', 'rb') as mem:
238
+ for base, size in regions:
239
+ try:
240
+ mem.seek(base)
241
+ data = mem.read(size)
242
+ except (OSError, ValueError):
243
+ continue
244
+ for m in pattern.finditer(data):
245
+ keys_found.append((m.group(1).decode(), m.group(2).decode()))
246
+ except PermissionError:
247
+ return [], 'permission'
248
+ except (FileNotFoundError, ProcessLookupError):
249
+ return [], 'gone'
250
+
251
+ seen = set()
252
+ unique_keys = []
253
+ for k, s in keys_found:
254
+ pair = (k, s)
255
+ if pair not in seen:
256
+ seen.add(pair)
257
+ unique_keys.append({"key": k, "salt": s})
258
+
259
+ return unique_keys, None
260
+
261
+
262
+ # ========== NT Database Discovery ==========
263
+
264
+ def _is_nt_account_dir(path):
265
+ return os.path.isdir(os.path.join(path, 'db_storage')) or \
266
+ os.path.isdir(os.path.join(path, 'Msg'))
267
+
268
+
269
+ def _normalize_nt_root(root):
270
+ if not root:
271
+ return None
272
+ path = os.path.abspath(os.path.expandvars(os.path.expanduser(root)))
273
+ if os.path.isfile(path):
274
+ path = os.path.dirname(path)
275
+ if not os.path.isdir(path):
276
+ return None
277
+
278
+ try:
279
+ for entry in os.listdir(path):
280
+ candidate = os.path.join(path, entry)
281
+ if os.path.isdir(candidate) and _is_nt_account_dir(candidate):
282
+ return path
283
+ except OSError:
284
+ return None
285
+
286
+ for _ in range(6):
287
+ if _is_nt_account_dir(path):
288
+ return os.path.dirname(path)
289
+ parent = os.path.dirname(path)
290
+ if parent == path:
291
+ break
292
+ path = parent
293
+
294
+ return None
295
+
296
+
297
+ def find_nt_databases(root=None):
298
+ """Find all NT-format databases under xwechat_files (message + contact)."""
299
+ if root:
300
+ normalized_root = _normalize_nt_root(root)
301
+ candidates = [normalized_root] if normalized_root else []
302
+ elif IS_WINDOWS:
303
+ candidates = [
304
+ os.path.expandvars(r'%USERPROFILE%\xwechat_files'),
305
+ os.path.expandvars(r'%USERPROFILE%\Documents\xwechat_files'),
306
+ ]
307
+ else:
308
+ home = os.path.expanduser('~')
309
+ candidates = [
310
+ os.path.join(home, '.local', 'share', 'xwechat_files'),
311
+ os.path.join(home, 'xwechat_files'),
312
+ os.path.join(home, 'Documents', 'xwechat_files'),
313
+ os.path.join(home, '文档', 'xwechat_files'),
314
+ ]
315
+ xwechat = None
316
+ for c in candidates:
317
+ if os.path.isdir(c):
318
+ xwechat = c
319
+ break
320
+ if not xwechat:
321
+ return []
322
+
323
+ databases = []
324
+ for wxid_dir in os.listdir(xwechat):
325
+ # Scan message databases
326
+ msg_storage = os.path.join(xwechat, wxid_dir, 'db_storage', 'message')
327
+ if os.path.isdir(msg_storage):
328
+ for f in os.listdir(msg_storage):
329
+ if f.endswith('.db') and not any(x in f for x in ['-shm', '-wal']):
330
+ full_path = os.path.join(msg_storage, f)
331
+ try:
332
+ with open(full_path, 'rb') as fh:
333
+ salt = fh.read(16)
334
+ databases.append({
335
+ "path": full_path,
336
+ "name": f"message/{f}",
337
+ "salt": salt.hex(),
338
+ "size": os.path.getsize(full_path),
339
+ "wxid": wxid_dir,
340
+ })
341
+ except:
342
+ pass
343
+
344
+ # Scan contact database
345
+ contact_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'contact', 'contact.db')
346
+ if os.path.isfile(contact_db):
347
+ try:
348
+ with open(contact_db, 'rb') as fh:
349
+ salt = fh.read(16)
350
+ databases.append({
351
+ "path": contact_db,
352
+ "name": "contact/contact.db",
353
+ "salt": salt.hex(),
354
+ "size": os.path.getsize(contact_db),
355
+ "wxid": wxid_dir,
356
+ })
357
+ except:
358
+ pass
359
+
360
+ # Scan SNS (朋友圈) database
361
+ sns_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'sns', 'sns.db')
362
+ if os.path.isfile(sns_db):
363
+ try:
364
+ with open(sns_db, 'rb') as fh:
365
+ salt = fh.read(16)
366
+ databases.append({
367
+ "path": sns_db,
368
+ "name": "sns/sns.db",
369
+ "salt": salt.hex(),
370
+ "size": os.path.getsize(sns_db),
371
+ "wxid": wxid_dir,
372
+ })
373
+ except:
374
+ pass
375
+
376
+ # Scan favorites (收藏) database
377
+ fav_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'favorite', 'favorite.db')
378
+ if os.path.isfile(fav_db):
379
+ try:
380
+ with open(fav_db, 'rb') as fh:
381
+ salt = fh.read(16)
382
+ databases.append({
383
+ "path": fav_db,
384
+ "name": "favorite/favorite.db",
385
+ "salt": salt.hex(),
386
+ "size": os.path.getsize(fav_db),
387
+ "wxid": wxid_dir,
388
+ })
389
+ except:
390
+ pass
391
+
392
+ return databases
393
+
394
+
395
+ def find_contact_db_path(message_db_path):
396
+ """Derive contact.db path from message_0.db path.
397
+
398
+ message_0.db: <xwechat_files>/<wxid>/db_storage/message/message_0.db
399
+ contact.db: <xwechat_files>/<wxid>/db_storage/contact/contact.db
400
+ """
401
+ msg_dir = os.path.dirname(message_db_path)
402
+ wxid_dir = os.path.dirname(msg_dir) # .../db_storage
403
+ xwechat_dir = os.path.dirname(wxid_dir) # .../<wxid>
404
+ contact_db = os.path.join(xwechat_dir, 'db_storage', 'contact', 'contact.db')
405
+ if os.path.isfile(contact_db):
406
+ return contact_db
407
+ return None
408
+
409
+
410
+ def load_contact_names(contact_db_path, contact_key_hex, contact_salt_hex):
411
+ """Load wxid -> {remark, nick_name} map from contact.db.
412
+
413
+ Returns dict: {wxid: display_name}
414
+ display_name priority: remark > nick_name > alias > wxid
415
+
416
+ 取不到就返回空表(名字是装饰性的,调用方会退回 wxid)。**连接必须在 finally
417
+ 里关**:原先 `conn.close()` 写在 try 末尾,异常路径上泄漏——而在 Windows 上
418
+ 一个没关的连接会把文件锁住(同一个坑早先在这个文件的分片读取里踩过一次)。
419
+ 读了一半也保留已经拿到的名字,而不是整份丢掉。
420
+ """
421
+ if not contact_db_path or not contact_key_hex or not contact_salt_hex:
422
+ return {}
423
+ if not os.path.isfile(contact_db_path):
424
+ return {}
425
+
426
+ name_map = {}
427
+ conn = None
428
+ try:
429
+ raw_key = f"x'{contact_key_hex}{contact_salt_hex}'"
430
+ conn = require_sqlcipher().connect(contact_db_path)
431
+ c = conn.cursor()
432
+ c.execute(f'PRAGMA key = "{raw_key}";')
433
+
434
+ # contact.db schema: username, alias, remark, nick_name, ...
435
+ c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), NULLIF(alias,''), username) FROM contact")
436
+ for username, display in c.fetchall():
437
+ if username:
438
+ name_map[username] = display
439
+ except Exception:
440
+ pass
441
+ finally:
442
+ if conn is not None:
443
+ try:
444
+ conn.close()
445
+ except Exception:
446
+ pass
447
+ return name_map
448
+
449
+
450
+ def filter_contacts(contacts, keyword, limit=0):
451
+ """按关键字过滤联系人,**然后**才截断到 `limit`。
452
+
453
+ 顺序是这里的全部要点:先截断会让第 `limit` 个之后的人搜不到(实测 500 人的通讯录里,
454
+ 按备注名找 10 个只命中 3 个)。关键字比对 `username + displayName + remark + nickname`,
455
+ 大小写不敏感。
456
+ """
457
+ if not keyword:
458
+ return contacts[:limit] if limit else contacts
459
+ needle = keyword.lower()
460
+ hits = [
461
+ c for c in contacts
462
+ if needle in (c.get('username', '') + c.get('displayName', '')
463
+ + c.get('remark', '') + c.get('nickname', '')).lower()
464
+ ]
465
+ return hits[:limit] if limit else hits
466
+
467
+
468
+ def apply_contact_names(sessions, name_map):
469
+ """Apply contact names to session list, replacing bare wxid displayNames."""
470
+ if not name_map:
471
+ return sessions
472
+ for s in sessions:
473
+ username = s.get('username', '')
474
+ if username in name_map:
475
+ s['displayName'] = name_map[username]
476
+ return sessions
477
+
478
+
479
+ def match_keys_to_databases(keys, databases, use_cache=True):
480
+ """Match memory keys to databases by comparing salts.
481
+
482
+ Args:
483
+ keys: list of {"key": str, "salt": str} from memory scan
484
+ databases: list of {"path": str, "salt": str, ...}
485
+ use_cache: if True, try cache first and store new keys
486
+
487
+ Returns:
488
+ list of matched databases with "key" field added
489
+ """
490
+ cache = get_key_cache() if use_cache else None
491
+
492
+ # Build salt -> key map from memory scan
493
+ salt_to_key = {}
494
+ for k in keys:
495
+ salt_to_key[k["salt"]] = k["key"]
496
+
497
+ # Store newly scanned keys to cache
498
+ if cache and keys:
499
+ cache.put_batch(keys)
500
+
501
+ matched = []
502
+ unmatched = []
503
+
504
+ for db in databases:
505
+ db_salt = db["salt"]
506
+
507
+ # Try cache first
508
+ if cache:
509
+ cached_key = cache.get(db_salt)
510
+ if cached_key:
511
+ db["key"] = cached_key
512
+ db["key_source"] = "cache"
513
+ matched.append(db)
514
+ continue
515
+
516
+ # Try memory scan result
517
+ if db_salt in salt_to_key:
518
+ db["key"] = salt_to_key[db_salt]
519
+ db["key_source"] = "memory"
520
+ matched.append(db)
521
+ else:
522
+ unmatched.append(db)
523
+
524
+ return matched
525
+
526
+
527
+ def match_databases_from_cache_only(databases):
528
+ """Match databases using only cached keys (no memory scan).
529
+
530
+ 用于微信未运行或不想扫内存的场景。
531
+
532
+ Args:
533
+ databases: list of {"path": str, "salt": str, ...}
534
+
535
+ Returns:
536
+ tuple: (matched, unmatched)
537
+ matched: list of databases with "key" field
538
+ unmatched: list of databases without key
539
+ """
540
+ cache = get_key_cache()
541
+ matched = []
542
+ unmatched = []
543
+
544
+ for db in databases:
545
+ db_salt = db["salt"]
546
+ cached_key = cache.get(db_salt)
547
+
548
+ if cached_key:
549
+ db["key"] = cached_key
550
+ db["key_source"] = "cache"
551
+ matched.append(db)
552
+ else:
553
+ unmatched.append(db)
554
+
555
+ return matched, unmatched
556
+
557
+
558
+ # ========== Database Operations ==========
559
+
560
+ def connect_nt_db(db_path, key_hex, salt_hex):
561
+ """Connect to an NT database using sqlcipher3."""
562
+ raw_key = f"x'{key_hex}{salt_hex}'"
563
+ conn = require_sqlcipher().connect(db_path)
564
+ c = conn.cursor()
565
+ c.execute(f'PRAGMA key = "{raw_key}";')
566
+ return conn, c
567
+
568
+
569
+ # WeChat rolls a conversation into a new shard over time, and every shard is
570
+ # encrypted with its own key. Reading only the configured database therefore
571
+ # shows a transcript that stops wherever the first shard's last write left off
572
+ # - which is silent, because the query still succeeds. Only the HTML exporter
573
+ # used to merge shards, so the same chat exported two different histories
574
+ # depending on the format asked for.
575
+
576
+ # 分片发现、密钥派生、列探测都在 nt_common 里——**只此一份**。
577
+ # 原先 `nt_decrypt` 与 `export_chat_html` 各有一份,而契约并不相同。
578
+ #
579
+ # 这一行不能省:本文件被 `spec_from_file_location` 加载时(测试就是这么加的),
580
+ # 解释器**不会**把脚本目录放进 sys.path——只有直接运行才会。少了它,单独跑
581
+ # `nt_decrypt_shards_test.py` 会以 ModuleNotFoundError 失败,而全套一起跑却能过
582
+ # (别的测试文件先插了路径)。那种"碰巧能过"正是要避免的。
583
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
584
+ from nt_common import (discover_message_shards, derive_database_key,
585
+ table_columns, MESSAGE_ANCHOR_COLUMNS)
586
+ from key_cache import get_key_cache
587
+
588
+
589
+ def verify_passphrase_native(passphrase_hex, db_path, internal_key_hex=''):
590
+ """Check a passphrase against a database without sqlcipher3.
591
+
592
+ Verifies the SQLCipher page-1 HMAC directly with stdlib primitives:
593
+
594
+ mac_salt = header_salt XOR 0x3a
595
+ key = PBKDF2-HMAC-SHA512(passphrase, header_salt, 256000, 32)
596
+ mac_key = PBKDF2-HMAC-SHA512(key, mac_salt, 2, 32)
597
+ compare HMAC-SHA512(mac_key, page1_body || page_number=1) with the
598
+ digest stored at the end of page 1
599
+
600
+ Why this exists: the normal way to check a key is to open the database,
601
+ which needs the native library. When sqlcipher3 is missing or misbehaving,
602
+ that route says nothing about whether the key itself is right - and the
603
+ two failures look identical. This separates them.
604
+
605
+ Returns True (correct), False (wrong), or None (cannot tell - bad input,
606
+ file too small, or a passphrase that is not hex).
607
+ """
608
+ try:
609
+ raw_passphrase = bytes.fromhex(passphrase_hex)
610
+ except (TypeError, ValueError):
611
+ return None
612
+ if internal_key_hex:
613
+ # Some WeChat builds XOR the passphrase with a key embedded in
614
+ # Weixin.dll before deriving. Optional: the installs seen so far do
615
+ # not need it, so an empty value must stay the default.
616
+ try:
617
+ internal = bytes.fromhex(internal_key_hex)
618
+ except (TypeError, ValueError):
619
+ return None
620
+ if len(internal) == len(raw_passphrase):
621
+ raw_passphrase = bytes(a ^ b for a, b in zip(raw_passphrase, internal))
622
+
623
+ try:
624
+ with open(db_path, 'rb') as handle:
625
+ page = handle.read(4096)
626
+ except OSError:
627
+ return None
628
+ if len(page) < 4096 or page[:16] == b'\x00' * 16:
629
+ return None
630
+
631
+ salt = page[:16]
632
+ mac_salt = bytes(byte ^ 0x3a for byte in salt)
633
+ key = hashlib.pbkdf2_hmac('sha512', raw_passphrase, salt, 256000, 32)
634
+ mac_key = hashlib.pbkdf2_hmac('sha512', key, mac_salt, 2, 32)
635
+
636
+ reserve = 16 + 64 # IV + HMAC-SHA512
637
+ reserve = ((reserve + 15) // 16) * 16 # rounded up to the AES block size
638
+ body_end = 4096 - reserve + 16
639
+ mac = hmac.new(mac_key, page[16:body_end], hashlib.sha512)
640
+ mac.update(struct.pack('<I', 1)) # page number
641
+ return hmac.compare_digest(mac.digest(), page[body_end:body_end + 64])
642
+
643
+
644
+ def connect_message_shards_detailed(db_path, key_hex, salt_hex, passphrase=''):
645
+ """Open every shard and report what happened to each one.
646
+
647
+ Returns (pairs, failures):
648
+ pairs [(shard_path, conn)] for the shards that opened
649
+ failures [{"name": basename, "reason": "KEY_REJECTED" | "OPEN_FAILED"}]
650
+
651
+ A shard that fails is still skipped rather than fatal - a partially
652
+ readable transcript beats a command that refuses to run at all - but the
653
+ failure stops being invisible. `shardsFailed` is the difference between
654
+ "read everything" and "read what happened to be reachable", which is the
655
+ whole point of the coverage report.
656
+
657
+ Only the basename is reported: the caller stores this in a state file, and
658
+ absolute paths must not end up there.
659
+ """
660
+ pairs = []
661
+ failures = []
662
+ for shard in discover_message_shards(db_path):
663
+ derived_key, derived_salt = derive_database_key(
664
+ shard, key_hex, salt_hex, passphrase)
665
+ # Try the derived key first; fall back to the configured pair so
666
+ # installs without a passphrase keep working exactly as before.
667
+ candidates = [(derived_key, derived_salt)]
668
+ if (derived_key, derived_salt) != (key_hex, salt_hex):
669
+ candidates.append((key_hex, salt_hex))
670
+ failure_reason = 'OPEN_FAILED'
671
+ for candidate_key, candidate_salt in candidates:
672
+ conn = None
673
+ try:
674
+ conn, _ = connect_nt_db(shard, candidate_key, candidate_salt)
675
+ # PRAGMA key alone never fails; only a read surfaces a bad key.
676
+ conn.execute('SELECT count(*) FROM sqlite_master').fetchone()
677
+ except Exception:
678
+ # Reached the database but could not decrypt it: the key is
679
+ # wrong, not the file. Close before retrying, or the handle
680
+ # leaks and on Windows the file stays locked.
681
+ if conn is not None:
682
+ try:
683
+ conn.close()
684
+ except Exception:
685
+ pass
686
+ failure_reason = 'KEY_REJECTED'
687
+ continue
688
+ pairs.append((shard, conn))
689
+ break
690
+ else:
691
+ failures.append({'name': os.path.basename(shard), 'reason': failure_reason})
692
+ return pairs, failures
693
+
694
+
695
+ def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
696
+ """Open the configured database plus every sibling shard.
697
+
698
+ Returns the connections that actually opened. A shard that fails is
699
+ skipped rather than fatal: a partially readable transcript beats a
700
+ command that refuses to run at all.
701
+
702
+ Behaviour-preserving delegate: callers that do not ask for the failure
703
+ detail get exactly what they got before.
704
+ """
705
+ pairs, _failures = connect_message_shards_detailed(
706
+ db_path, key_hex, salt_hex, passphrase)
707
+ return [conn for _path, conn in pairs]
708
+
709
+
710
+ def count_talker_rows(conn, msg_table):
711
+ """Rows this conversation has in one shard, or None if it has no table there.
712
+
713
+ Distinguishes "this shard holds nothing for the conversation" from "this
714
+ shard could not be read" - without it both look like zero.
715
+ """
716
+ try:
717
+ cur = conn.execute(
718
+ "SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
719
+ if cur.fetchone()[0] == 0:
720
+ return None
721
+ return conn.execute('SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
722
+ except Exception:
723
+ return None
724
+
725
+
726
+ def msg_tables(conn):
727
+ """Names of the per-conversation Msg_ tables in one shard."""
728
+ try:
729
+ rows = conn.execute(
730
+ "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg\\_%' ESCAPE '\\'"
731
+ ).fetchall()
732
+ except Exception:
733
+ return set()
734
+ return {row[0] for row in rows}
735
+
736
+
737
+ def get_fav_schema(db_path, key_hex):
738
+ """Dump favorite.db schema + sample rows (key verification / exploration)."""
739
+ try:
740
+ with open(db_path, 'rb') as fh:
741
+ salt = fh.read(16).hex()
742
+ conn = require_sqlcipher().connect(db_path)
743
+ conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
744
+ tables = conn.execute(
745
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
746
+ ).fetchall()
747
+ result = {"success": True, "tables": [t[0] for t in tables]}
748
+ if not tables:
749
+ result["error"] = "数据库为空或密钥错误"
750
+ result["success"] = False
751
+ conn.close()
752
+ return result
753
+ except Exception as e:
754
+ return {"success": False, "error": str(e).split('\n')[0][:200]}
755
+
756
+
757
+ FAV_TYPE_NAMES = {
758
+ 1: 'text', # 文字
759
+ 2: 'image', # 图片
760
+ 4: 'video', # 视频
761
+ 5: 'article', # 公众号文章/网页链接
762
+ 14: 'chatrecord', # 聊天记录
763
+ }
764
+
765
+
766
+ def parse_fav_content(content):
767
+ """Extract display fields (title/link/desc/source/cover) from favitem XML."""
768
+ out = {}
769
+ if not content:
770
+ return out
771
+ import xml.etree.ElementTree as ET
772
+ try:
773
+ root = ET.fromstring(content)
774
+ except ET.ParseError:
775
+ return out
776
+
777
+ def text(path):
778
+ el = root.find(path)
779
+ if el is not None and el.text and el.text.strip():
780
+ return el.text.strip()
781
+ return None
782
+
783
+ title = (text('weburlitem/pagetitle')
784
+ or text('datalist/dataitem/datatitle')
785
+ or text('title')
786
+ or text('desc'))
787
+ if title:
788
+ out['title'] = title
789
+ link = (text('weburlitem/clean_url')
790
+ or text('source/link')
791
+ or text('datalist/dataitem/stream_weburl'))
792
+ if link:
793
+ out['link'] = link
794
+ desc = text('weburlitem/pagedesc') or text('datalist/dataitem/datadesc')
795
+ if desc and desc != out.get('title'):
796
+ out['desc'] = desc
797
+ src = text('weburlitem/appmsgshareitem/srcdisplayname')
798
+ if src:
799
+ out['source_name'] = src
800
+ cover = (text('weburlitem/pagethumb_url')
801
+ or text('datalist/dataitem/dataext')
802
+ or text('datalist/dataitem/cdn_thumburl'))
803
+ if cover:
804
+ out['cover'] = cover
805
+ fmt = text('datalist/dataitem/datafmt')
806
+ if fmt:
807
+ out['format'] = fmt
808
+ return out
809
+
810
+
811
+ def get_favorites(db_path, key_hex, limit=100, offset=0, keyword=None, fav_type=None):
812
+ """List favorite items from favorite.db with parsed content."""
813
+ try:
814
+ with open(db_path, 'rb') as fh:
815
+ salt = fh.read(16).hex()
816
+ conn = require_sqlcipher().connect(db_path)
817
+ conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
818
+ c = conn.cursor()
819
+
820
+ tables = [t[0] for t in c.execute(
821
+ "SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
822
+ if 'fav_db_item' not in tables:
823
+ conn.close()
824
+ return {"error": f"未找到 fav_db_item 表,现有表: {tables[:20]}"}
825
+
826
+ total = c.execute('SELECT count(*) FROM fav_db_item').fetchone()[0]
827
+
828
+ sql = ('SELECT local_id, server_id, type, update_time, fromusr, realchatname, content '
829
+ 'FROM fav_db_item WHERE 1=1')
830
+ params = []
831
+ if fav_type is not None:
832
+ sql += ' AND type = ?'
833
+ params.append(fav_type)
834
+ if keyword:
835
+ sql += ' AND content LIKE ?'
836
+ params.append(f'%{keyword}%')
837
+ sql += ' ORDER BY update_time DESC LIMIT ? OFFSET ?'
838
+ params.extend([limit, offset])
839
+ rows = c.execute(sql, params).fetchall()
840
+ conn.close()
841
+
842
+ items = []
843
+ for local_id, server_id, ftype, update_time, fromusr, realchatname, content in rows:
844
+ ct = content if isinstance(content, str) else (
845
+ content.decode('utf-8', errors='replace') if content else '')
846
+ item = {
847
+ 'local_id': local_id,
848
+ 'server_id': server_id,
849
+ 'type': ftype,
850
+ 'type_name': FAV_TYPE_NAMES.get(ftype, 'type_%s' % ftype),
851
+ 'update_time': update_time,
852
+ 'from_user': fromusr,
853
+ 'chat_name': realchatname or None,
854
+ }
855
+ item.update(parse_fav_content(ct))
856
+ items.append(item)
857
+ return {"favorites": items, "total": total, "count": len(items),
858
+ "limit": limit, "offset": offset}
859
+ except Exception as e:
860
+ return {"error": str(e).split('\n')[0][:200]}
861
+
862
+
863
+ #: 非文本消息在**列表预览**里的显示形态。与 `sqlcipherCore.processMessage` 的口径一致。
864
+ #: 列表预览只需要回答"这是什么",不需要内容 —— 而 49 类的 `source` 往往是被压缩的
865
+ #: appmsg,硬去剥 XML 标签只会得到 `(/`$` 这种碎片(2026-10-19 用户在 `sessions` 里看到)。
866
+ _PREVIEW_LABELS = {
867
+ 3: '[图片]', 34: '[语音]', 43: '[视频]', 47: '[表情]', 48: '[位置]',
868
+ 42: '[名片]', 49: '[链接/文件]', 10000: '[系统消息]', 10002: '[系统消息]',
869
+ }
870
+
871
+
872
+ def _clean_preview(text, limit=50):
873
+ """预览用:清掉控制字符、压掉换行/连续空白、去掉行首的二进制残渣、截断。
874
+
875
+ 行首清洗只针对 **ASCII 标点**:群消息的 `source` 有时前面带三四个字节的碎片
876
+ (实测一条广告消息长这样:`(/ }25984981651785937@openim:🔰周黑鸭…`),
877
+ 不处理的话列表第一列就是这堆符号。中文标点与 emoji 开头**不动**——
878
+ `?好的`、`😂收到` 都是正常消息。
879
+ """
880
+ if not isinstance(text, str):
881
+ return ''
882
+ cleaned = ''.join(ch for ch in text if ch >= ' ' or ch == '\t')
883
+ cleaned = re.sub(r'\s+', ' ', cleaned).strip()
884
+ # 空格也要算在"垃圾前缀"里:实测前缀是 `(/ }`,中间那个空格会把
885
+ # 「连续 ASCII 标点」这条匹配打断,于是整段清理失效(2026-10-19 实测)。
886
+ cleaned = re.sub(r'^[\x20-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]{1,8}(?=[A-Za-z0-9\u4e00-\u9fff])', '', cleaned)
887
+ return cleaned[:limit]
888
+
889
+
890
+ def _looks_broken(text):
891
+ """这段"文本"其实是被当成文本的二进制吗?
892
+
893
+ 判据只看两样:UTF-8 解码时留下的替换字符、以及不可打印字符的占比。
894
+ 实测样本:一条 openim 消息的 `message_content` 解出来是
895
+ `(/ }25984981651785937@openim:` 加一个替换字符 —— 直接显示就是垃圾。
896
+ """
897
+ if not text:
898
+ return False
899
+ # 出现**任何**替换字符就说明原始字节不是合法 UTF-8 —— 正常中文/emoji 不会产生它。
900
+ # 阈值不能是"两个以上":实测那条 openim 消息只在结尾丢了一个字节,
901
+ # 前面全是可打印的压缩碎片,比例判据根本拦不住。
902
+ if '\ufffd' in text:
903
+ return True
904
+ printable = sum(1 for ch in text if ch.isprintable())
905
+ return printable / max(1, len(text)) < 0.9
906
+
907
+
908
+ def _summarise(row):
909
+ """(last_time, summary) from the newest message row of a conversation."""
910
+ last_time = row[0] or 0
911
+ msg_type = row[3] or 0
912
+ source_text = row[1]
913
+ content_text = row[2]
914
+
915
+ if msg_type == 1:
916
+ # 文本:`message_content` 与 `source` 里**哪个更像人话就用哪个**。
917
+ # 只认 `message_content` 会踩到"这一列其实是二进制"的消息(见 `_looks_broken`)。
918
+ candidates = []
919
+ for value in (content_text, source_text):
920
+ if isinstance(value, bytes):
921
+ value = value.decode('utf-8', errors='ignore')
922
+ if isinstance(value, str) and value.strip():
923
+ candidates.append(value)
924
+ if not candidates:
925
+ return last_time, ""
926
+ best = min(candidates, key=lambda s: s.count('\ufffd') / max(1, len(s)))
927
+ if _looks_broken(best):
928
+ return last_time, '[内容无法显示]'
929
+ return last_time, _clean_preview(best)
930
+
931
+ # 非文本:先按类型给标签。**不再从 `source` 里剥标签抠文字** —— 那条路只有在
932
+ # XML 是明文 appmsg 时才成立,而 4.x 上它经常是压缩的,抠出来就是二进制碎片。
933
+ #
934
+ # `local_type` 在 4.x 里是**打包值**:高位放 subtype,低 32 位才是基础类型
935
+ # (实测 `25769803825` = 0x6_00000031,低 8 位 0x31 = 49 = 链接/文件)。
936
+ # 不 mask 的话,一张「链接卡片」会被报成 `[消息类型 25769803825]`。
937
+ raw_type = int(msg_type) if msg_type else 0
938
+ base_type = raw_type & 0xFFFFFFFF
939
+ label = _PREVIEW_LABELS.get(base_type)
940
+ if label:
941
+ return last_time, label
942
+
943
+ if isinstance(source_text, str) and source_text.strip():
944
+ clean = re.sub(r'<[^>]+>', '', source_text)
945
+ if clean.strip():
946
+ # 兜底也要过一遍"这其实是二进制吗":某些消息两列都是压缩数据,
947
+ # 剥掉尖括号后看着像文字,实际是 `(/ }25984...` 这种碎片。
948
+ if _looks_broken(clean):
949
+ return last_time, '[内容无法显示]'
950
+ return last_time, _clean_preview(clean)
951
+ return last_time, ('[消息类型 %d]' % base_type) if base_type else ''
952
+
953
+
954
+ def get_sessions(conns):
955
+ """Get chat sessions, newest message per conversation across every shard."""
956
+ sessions = {}
957
+
958
+ # NT format: each chat has its own Msg_<MD5> table
959
+ # The Name2Id table maps usernames to IDs (user_name, is_session)
960
+ for conn in conns:
961
+ c = conn.cursor()
962
+ try:
963
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1 LIMIT 500")
964
+ usernames = [row[0] for row in c.fetchall() if row[0]]
965
+ except Exception:
966
+ continue
967
+
968
+ tables = msg_tables(conn)
969
+ for username in usernames:
970
+ entry = sessions.setdefault(username, {"summary": "", "last_time": 0})
971
+ msg_table = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
972
+ if msg_table not in tables:
973
+ continue
974
+ try:
975
+ c.execute(f'SELECT create_time, source, message_content, local_type FROM "{msg_table}" ORDER BY create_time DESC LIMIT 1')
976
+ row = c.fetchone()
977
+ if row:
978
+ last_time, summary = _summarise(row)
979
+ # A conversation spans shards; only the newest speaks for it.
980
+ if last_time >= entry["last_time"]:
981
+ entry["last_time"] = last_time
982
+ entry["summary"] = summary
983
+ except Exception:
984
+ continue
985
+
986
+ result = [{
987
+ "username": username,
988
+ "type": 1 if "@chatroom" in username else 0,
989
+ "unreadCount": 0,
990
+ "summary": entry["summary"],
991
+ "sortTimestamp": entry["last_time"],
992
+ "lastTimestamp": entry["last_time"],
993
+ "displayName": username,
994
+ } for username, entry in sessions.items()]
995
+
996
+ # Sort by timestamp descending
997
+ result.sort(key=lambda s: s.get("sortTimestamp", 0), reverse=True)
998
+ return {"sessions": result}
999
+
1000
+
1001
+ def _strip_group_speaker(content, known_ids):
1002
+ """Drop the `wxid_...:` prefix group rows carry in their content.
1003
+
1004
+ Only an id the shard's Name2Id actually knows is accepted, so a message
1005
+ that merely starts with `note: ...` is left alone.
1006
+ """
1007
+ match = re.match(r'^([A-Za-z0-9_@.-]{5,64})\s*[::]\s', str(content or ''))
1008
+ if not match or match.group(1) not in known_ids:
1009
+ return content
1010
+ return content[match.end():]
1011
+
1012
+
1013
+ # The columns the reader actually consumes. The SELECT is built from these
1014
+ # only: a WeChat version that renames or drops any *other* column of the
1015
+ # message table cannot break a read of a column nobody looks at.
1016
+ MESSAGE_COLUMNS = ('local_id', 'server_id', 'local_type', 'real_sender_id',
1017
+ 'create_time', 'message_content')
1018
+
1019
+ # Any one of these is enough to give rows an identity and an order. With none
1020
+ # of them a row cannot become a message at all, and that is a schema mismatch
1021
+ # rather than an empty conversation.
1022
+ # 定义搬去了 `nt_common`:`ORDER BY` 那处(同一个文件下面)过去自己又写了一份
1023
+ # 字面量,导出器再写一份——顺序在这里就是行为(先按 create_time 排),
1024
+ # 三份各自为政等于谁改一处谁静默换一种排法。
1025
+
1026
+
1027
+ # ---- 非文本消息的显示形态 ---------------------------------------------------
1028
+ #
1029
+ # 这条路径此前**把非文本消息丢掉了**:`parsedContent` 只在 local_type == 1 时才有值,
1030
+ # 而 BLOB 内容不是 str,会在下面被置成空串。于是图片/表情/文件/引用/撤回/红包在下游
1031
+ # 一律表现为"空内容"——实测那 44 条里"空"的三条,分别是**一条撤回提示**和两个表情。
1032
+ #
1033
+ # 类型编码有结构:`local_type = apptype * 2**32 + 49`,49 表示 appmsg 家族。所以这里
1034
+ # 是**推导**而不是一张长表;只有少数几个固定类型需要单独列。
1035
+ APPMSG_SUBTYPE = 49
1036
+ NON_TEXT_LABELS = {
1037
+ 3: '图片', 34: '语音', 42: '名片', 43: '视频', 47: '表情', 48: '位置',
1038
+ }
1039
+ APPMSG_LABELS = {
1040
+ 4: '链接', 5: '链接', 6: '文件', 19: '聊天记录', 33: '小程序', 57: '引用', 63: '直播',
1041
+ 2000: '转账', 2001: '红包',
1042
+ }
1043
+ # 不认识的 apptype 回 `[应用消息]` 而不是猜成"链接":标签是给下游读的,不能支撑不起也写。
1044
+ APPMSG_UNKNOWN_LABEL = '应用消息'
1045
+
1046
+ # 引用消息(apptype 57)的正文与被引原文之间的分隔。用词而不是符号,因为下游会把它读成
1047
+ # 一句话;`|` 在别处会被当表格分隔。
1048
+ QUOTE_SEP = ' | 引:'
1049
+ # 被引原文的截断长度。实测 37 条真实引用消息:中位 36 字、17 条超过 60 字、最长的
1050
+ # 12733 字(引用了一整篇公众号文章)。60 会切掉近一半,120 能留住大多数完整句子,
1051
+ # 又把那个极端值压住。
1052
+ QUOTE_CLIP = 120
1053
+
1054
+
1055
+ ZSTD_MAGIC = bytes([0x28, 0xB5, 0x2F, 0xFD])
1056
+ def _decode_content(raw):
1057
+ """消息内容 → 文本。实现在 `nt_common.decode_content`(各脚本共用一份)。
1058
+
1059
+ 保留这个名字是为了不动 `non_text_display` 等既有调用点;行为与公共函数一致:
1060
+ str 原样、zstd frame 解压、其它 bytes 按 UTF-8 解码,解不出回空串。
1061
+ """
1062
+ from nt_common import decode_content
1063
+ return decode_content(raw)
1064
+
1065
+
1066
+ def _xml_block(xml, tag):
1067
+ """取一个元素的起始标签到它自己的结束标签之间的整段(含标签)。
1068
+
1069
+ 只在区块内找 `title`/`des`:整份 payload 里还有 `<emotionpageshared><title>` 这类
1070
+ 同名标签,在全文里搜会匹配到那个去(自验时真的把一段 XML 当文本吐出来过)。
1071
+ """
1072
+ text = xml or ''
1073
+ match = re.search(r'<%s(?:\s[^>]*)?>.*?</%s>' % (tag, tag), text, re.S)
1074
+ return match.group(0) if match else ''
1075
+
1076
+
1077
+ def _xml_text(xml, tag):
1078
+ """取一个标签的文本(含 CDATA)。取不到回空串——`<title />` 这种自闭合就是取不到。
1079
+
1080
+ 起始标签里**不许出现 `/`**:否则 `<title />` 会被当成开标签,一路吃到后面某个
1081
+ `</title>`,把中间整段 XML 当成文本返回(自验时真的吐出来过)。
1082
+
1083
+ 实体在这**解掉**:显示形态是给人(和模型)读的,`a&amp;b` 该显示成 `a&b`。
1084
+ TS 侧的 `appMsgFormat.tagText` 同样解,两边一致——否则同一条消息 3.x 与 4.x 读起来
1085
+ 不一样。
1086
+ """
1087
+ match = re.search(
1088
+ r'<%s(?:\s[^>/]*)?>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</%s>' % (tag, tag), xml or '', re.S)
1089
+ return html.unescape(match.group(1).strip()) if match else ''
1090
+
1091
+
1092
+ def _clip(text, limit):
1093
+ """超长截断并**留下省略号**。
1094
+
1095
+ 不标记的截断读起来像一句说完的话——`title[:60]` 此前就是这样,被引原文最长的
1096
+ 12733 字,切完看着像"这就是全部"。
1097
+ """
1098
+ if len(text) <= limit:
1099
+ return text
1100
+ return text[:limit] + '…'
1101
+
1102
+
1103
+ def non_text_display(local_type, raw):
1104
+ """非文本消息 → 给下游看的文本。**绝不返回空串**——这是这次修复的全部要点。
1105
+
1106
+ 宁可写 `[未识别的消息类型 81604378673]`,也不要让下游看到空字符串:空串在下游与
1107
+ "这条消息不存在"无法区分,而它的真实后果是助手回答"我没解析出内容"。
1108
+ """
1109
+ lt = int(local_type or 0)
1110
+ text = _decode_content(raw)
1111
+
1112
+ if lt & 0xFFFFFFFF == APPMSG_SUBTYPE:
1113
+ apptype = lt >> 32
1114
+ label = APPMSG_LABELS.get(apptype, APPMSG_UNKNOWN_LABEL)
1115
+ block = _xml_block(text, 'appmsg')
1116
+ title = _xml_text(block, 'title') or _xml_text(block, 'des')
1117
+ # 实测 payload 里 `<emotionpageshared>` 这类**嵌套容器**也带 `<title>`,取到的是
1118
+ # 它们自己的占位值(`null`)。这种不是标题,按"没有标题"处理。
1119
+ if title.lower() in ('null', 'undefined', '0'):
1120
+ title = ''
1121
+ # 截断:解析万一走偏,也不许把一段 XML 当成标题交给下游。
1122
+ title = _clip(title, 60)
1123
+ # 引用消息(apptype 57):`title` 是**回复正文**,被引用的原文在 `refermsg/content`。
1124
+ # 实测 37 条真实引用消息,被引原文中位 36 字。此前只留回复,模型看到的是一句
1125
+ # "是呀,够得意个"却不知道在回什么——引用不带原文等于没引用。
1126
+ if apptype == 57:
1127
+ quoted = _xml_text(_xml_block(text, 'refermsg'), 'content')
1128
+ if quoted.lower() in ('null', 'undefined', '0'):
1129
+ quoted = ''
1130
+ quoted = _clip(quoted, QUOTE_CLIP)
1131
+ if quoted:
1132
+ body = (title + QUOTE_SEP + quoted) if title else quoted
1133
+ return '[%s] %s' % (label, body)
1134
+ return '[%s] %s' % (label, title) if title else '[%s]' % label
1135
+
1136
+ label = NON_TEXT_LABELS.get(lt)
1137
+ if label:
1138
+ return '[%s]' % label
1139
+ if lt == 10000: # 系统消息:撤回、拍一拍、入群提示
1140
+ return _xml_text(text, 'content') or '[系统消息]'
1141
+ return '[未识别的消息类型 %d]' % lt
1142
+
1143
+
1144
+ def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
1145
+ """One message row -> the CLI's message shape.
1146
+
1147
+ `row` is a column-name keyed dict, not a tuple. Positional access assumes
1148
+ every column exists; when a WeChat version drops one, the whole shard read
1149
+ fails and the conversation looks empty. Reading by name means a missing
1150
+ column costs that one field instead.
1151
+ """
1152
+ local_type = row.get('local_type') or 0
1153
+ create_time = row.get('create_time') or 0
1154
+ real_sender_id = row.get('real_sender_id') or 0
1155
+
1156
+ # Resolve sender: real_sender_id -> Name2Id -> user_name
1157
+ sender_username = sender_id_map.get(real_sender_id, "")
1158
+
1159
+ # Determine if message is from self
1160
+ # own_wxid may have _xxxx suffix (from xwechat_files dir), try both
1161
+ is_self = bool(own_wxid and (
1162
+ sender_username == own_wxid or
1163
+ (own_wxid.endswith('_') is False and sender_username.startswith(own_wxid))
1164
+ ))
1165
+ if not is_self and own_wxid:
1166
+ # Strip _xxxx suffix and retry
1167
+ parts = own_wxid.rsplit('_', 1)
1168
+ if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
1169
+ is_self = (sender_username == parts[0])
1170
+
1171
+ # Resolve sender display name from contact map
1172
+ if is_self:
1173
+ sender_display = "" # Let the CLI show "我"
1174
+ else:
1175
+ sender_display = name_map.get(sender_username, sender_username) if sender_username else sender_username
1176
+
1177
+ # Parse message_content - TEXT column
1178
+ raw_content = row.get('message_content')
1179
+ # **普通文本也可能是 zstd 压缩的 BLOB**(issue #12)。这里原来只认 `str`,
1180
+ # 于是 `local_type == 1` 的压缩正文被置空、再交给 `non_text_display()`,
1181
+ # 最终输出 `[未识别的消息类型 1]` —— 消息还在,正文没了,下游按
1182
+ # `parsedContent` 做的关键词 / @所有人 筛选就漏掉这些通知。
1183
+ # `_decode_content()` 已经处理三种形态(str / zstd frame / 普通 bytes),复用它即可。
1184
+ if local_type == 1:
1185
+ content = _decode_content(raw_content)
1186
+ else:
1187
+ content = raw_content if isinstance(raw_content, str) else ""
1188
+
1189
+ # `content`/`rawContent` stay exactly as stored; only `parsedContent` - the
1190
+ # field every consumer reads first - gets the display-ready form.
1191
+ #
1192
+ # 非文本消息(图片/表情/文件/引用/撤回/红包…)在过去得到的是**空串**:它们的内容是
1193
+ # JSON 里不是 str 的 BLOB,上面那行会把它置空。现在它们走 `non_text_display`,
1194
+ # 至少拿到 `[图片]`,多数还能带上文件名、被引用的原文或"谁撤回了一条消息"。
1195
+ if content:
1196
+ display = _strip_group_speaker(content, set(sender_id_map.values())) if is_group else content
1197
+ else:
1198
+ display = non_text_display(local_type, raw_content)
1199
+
1200
+ return {
1201
+ "localId": row.get('local_id') or 0,
1202
+ "serverId": str(row.get('server_id') or ''),
1203
+ "localType": local_type,
1204
+ "createTime": create_time,
1205
+ "isSend": 1 if is_self else 0, # 1 = I sent this
1206
+ "senderUsername": sender_username,
1207
+ "senderDisplay": sender_display,
1208
+ "content": content,
1209
+ "rawContent": content,
1210
+ # 文本不截断:末尾的 `@所有人` 一类关键词正是下游筛选要用的,截掉等于静默漏消息
1211
+ # (issue #12)。非文本仍保留 200 字的展示上限——它们是标签和标题,不是正文。
1212
+ "parsedContent": display if local_type == 1 else display[:200],
1213
+ }
1214
+
1215
+
1216
+ def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None,
1217
+ shard_names=None, shard_report=None, from_time=None, to_time=None):
1218
+ """Get messages for a specific talker, merged across every shard.
1219
+
1220
+ Args:
1221
+ name_map: optional {wxid: display_name} dict for resolving sender names
1222
+ own_wxid: account owner wxid for self-message detection
1223
+ shard_names: optional basename per connection, parallel to `conns`
1224
+ shard_report: optional list to append one outcome dict per shard to.
1225
+ Purely additive: the returned messages are identical with or
1226
+ without it, which is asserted by test/nt_decrypt_shards_test.py.
1227
+ from_time: optional inclusive lower bound on `create_time` (unix
1228
+ seconds), pushed into SQL so a bounded read does not have to fetch
1229
+ and discard the whole conversation.
1230
+ to_time: optional inclusive upper bound, same units.
1231
+
1232
+ A requested window is only ever honoured or refused, never approximated: a
1233
+ shard whose table has no `create_time` is skipped and reported as
1234
+ WINDOW_UNAVAILABLE rather than returning out-of-range rows that a caller
1235
+ tracking coverage would count as covered. Every in-tree caller that passes
1236
+ a window also passes `shard_report`, so that refusal is always visible.
1237
+ """
1238
+ if name_map is None:
1239
+ name_map = {}
1240
+
1241
+ window_requested = from_time is not None or to_time is not None
1242
+
1243
+ msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
1244
+ is_group = '@chatroom' in talker
1245
+
1246
+ def record(index, opened, has_table, rows_for_talker, reason, missing=None):
1247
+ if shard_report is None:
1248
+ return
1249
+ name = shard_names[index] if shard_names and index < len(shard_names) else ''
1250
+ shard_report.append({
1251
+ 'name': name,
1252
+ 'opened': opened,
1253
+ 'hasTalkerTable': has_table,
1254
+ 'rowsForTalker': rows_for_talker,
1255
+ 'reason': reason,
1256
+ # Columns this shard does not have. A non-empty list together with
1257
+ # a null `reason` means the read succeeded but lost fields, which is
1258
+ # a different thing from a failed read and has to stay visible.
1259
+ 'missingColumns': list(missing or ()),
1260
+ })
1261
+
1262
+ # Each shard only needs to yield its newest window: once every shard's rows
1263
+ # are merged and re-sorted, nothing older than that can reach this page.
1264
+ window = 0 if limit <= 0 else limit + offset
1265
+ collected = []
1266
+ found = False
1267
+
1268
+ for index, conn in enumerate(conns):
1269
+ c = conn.cursor()
1270
+ try:
1271
+ c.execute("SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
1272
+ if c.fetchone()[0] == 0:
1273
+ # The shard is readable and simply holds nothing for this
1274
+ # conversation - distinct from not being readable at all.
1275
+ record(index, True, False, None, None)
1276
+ continue
1277
+ found = True
1278
+ rows_for_talker = c.execute(
1279
+ 'SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
1280
+
1281
+ available = table_columns(c, msg_table)
1282
+ selected = [col for col in MESSAGE_COLUMNS if col in available]
1283
+ missing = [col for col in MESSAGE_COLUMNS if col not in available]
1284
+ if not [col for col in MESSAGE_ANCHOR_COLUMNS if col in available]:
1285
+ # The row shape no longer matches anything the reader can name.
1286
+ record(index, True, True, rows_for_talker, 'SCHEMA_MISMATCH',
1287
+ missing=missing)
1288
+ continue
1289
+ if window_requested and 'create_time' not in available:
1290
+ record(index, True, True, rows_for_talker, 'WINDOW_UNAVAILABLE',
1291
+ missing=missing)
1292
+ continue
1293
+
1294
+ order = [col for col in MESSAGE_ANCHOR_COLUMNS
1295
+ if col in available]
1296
+ sql = 'SELECT %s FROM "%s"' % (
1297
+ ', '.join('"%s"' % col for col in selected), msg_table)
1298
+ params = []
1299
+ clauses = []
1300
+ if from_time is not None:
1301
+ clauses.append('"create_time" >= ?')
1302
+ params.append(from_time)
1303
+ if to_time is not None:
1304
+ clauses.append('"create_time" <= ?')
1305
+ params.append(to_time)
1306
+ if clauses:
1307
+ sql += ' WHERE ' + ' AND '.join(clauses)
1308
+ sql += ' ORDER BY ' + ', '.join('"%s" DESC' % col for col in order)
1309
+ if window:
1310
+ sql += ' LIMIT ?'
1311
+ params.append(window)
1312
+ c.execute(sql, params)
1313
+ # Dicts rather than driver rows: the SELECT list varies per shard,
1314
+ # so position no longer identifies a column. Built here rather than
1315
+ # via row_factory, which every other reader sharing these
1316
+ # connections would inherit.
1317
+ rows = [dict(zip(selected, values)) for values in c.fetchall()]
1318
+
1319
+ # Sender ids are rowids, so the map has to come from the same
1320
+ # shard. Absent, it costs sender names and nothing else, so it is
1321
+ # probed rather than left to raise and lose the whole shard.
1322
+ has_name_table = c.execute(
1323
+ "SELECT COUNT(*) FROM sqlite_master WHERE name='Name2Id'").fetchone()[0] > 0
1324
+ sender_id_map = {}
1325
+ if has_name_table:
1326
+ c.execute("SELECT rowid, user_name FROM Name2Id")
1327
+ sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
1328
+ except Exception:
1329
+ # This shard had the conversation's table but could not be read.
1330
+ # Previously this was indistinguishable from "no rows here".
1331
+ record(index, True, None, None, 'READ_FAILED')
1332
+ continue
1333
+ record(index, True, True, rows_for_talker, None, missing=missing)
1334
+
1335
+ for row in rows:
1336
+ collected.append(_message_dict(row, sender_id_map, name_map, own_wxid, is_group))
1337
+
1338
+ if not found:
1339
+ return {"error": f"未找到会话: {talker}"}
1340
+
1341
+ collected.sort(key=lambda m: (m["createTime"], m["localId"]), reverse=True)
1342
+ if limit > 0:
1343
+ collected = collected[offset:offset + limit]
1344
+ return {"messages": collected}
1345
+
1346
+
1347
+ def cursor_probe(db_path, key_hex, salt_hex, passphrase='',
1348
+ tables_per_shard=5, rows_per_table=500):
1349
+ """Measure what a stable incremental cursor would need on this database.
1350
+
1351
+ D-027 keeps `stableCursor: false` until every message backend can honour
1352
+ the same semantics. This probe answers the NT backend's **data-side** half:
1353
+ whether every message table carries the anchor columns, and whether sampled
1354
+ rows actually hold a nonzero `server_id` (the reader's identity falls back
1355
+ to localId+createTime without one, and `local_id` is per-shard, so those
1356
+ rows cannot be resumed across shards). Duplicate server ids are collected
1357
+ across the sample too - an id that appears in two places cannot key a
1358
+ cursor, whatever the column definition says.
1359
+
1360
+ Sampling is bounded and says so (`exhaustive: false`): `tables_per_shard`
1361
+ tables per shard (sorted names, deterministic) read at both rowid ends,
1362
+ `rows_per_table` rows each. A full scan of every shard would cost minutes
1363
+ on a real account; the schema check IS exhaustive - `PRAGMA table_info` is
1364
+ cheap, so every Msg_* table's anchor columns are checked, not sampled.
1365
+
1366
+ Read-only: no write, no network, nothing leaves stdout.
1367
+ """
1368
+ pairs, failures = connect_message_shards_detailed(
1369
+ db_path, key_hex, salt_hex, passphrase)
1370
+ shard_items = []
1371
+ totals = {'tables': 0, 'tablesSampled': 0, 'rowsSampled': 0,
1372
+ 'serverIdMissing': 0}
1373
+ anchor_missing_total = 0
1374
+ # server_id(str) -> {(shard, table)} it was sampled in. A set per id keeps
1375
+ # the two rowid ends of one small table from double-counting the same row
1376
+ # as a "duplicate" - both ends see it, but it is one location.
1377
+ id_locations = {}
1378
+
1379
+ for failure in failures:
1380
+ shard_items.append({
1381
+ 'name': failure['name'], 'opened': False, 'reason': failure['reason'],
1382
+ 'tables': None,
1383
+ })
1384
+
1385
+ for path, conn in pairs:
1386
+ item = {'name': os.path.basename(path), 'opened': True, 'reason': None}
1387
+ try:
1388
+ tables = sorted(msg_tables(conn))
1389
+ item['tables'] = len(tables)
1390
+ totals['tables'] += len(tables)
1391
+
1392
+ # Schema half: exhaustive over every message table.
1393
+ anchor_missing = []
1394
+ for table in tables:
1395
+ cols = table_columns(conn.cursor(), table)
1396
+ missing = [c for c in MESSAGE_ANCHOR_COLUMNS if c not in cols]
1397
+ if missing and missing not in anchor_missing:
1398
+ anchor_missing.append(missing)
1399
+ anchor_missing_total += len(anchor_missing)
1400
+ item['anchorColumnsMissing'] = anchor_missing
1401
+
1402
+ # Data half: bounded sample, both rowid ends per table. A table
1403
+ # shorter than the bound hands the same rows to both ends - they
1404
+ # are one sample, not two, so rowids are deduplicated per table
1405
+ # (and the same id read at both ends must not look "duplicated").
1406
+ sample_targets = tables[:tables_per_shard]
1407
+ item['tablesSampled'] = len(sample_targets)
1408
+ totals['tablesSampled'] += len(sample_targets)
1409
+ sampled = 0
1410
+ missing_ids = 0
1411
+ for table in sample_targets:
1412
+ c = conn.cursor()
1413
+ seen_rowids = set()
1414
+ table_ids = set()
1415
+ for order in ('ASC', 'DESC'):
1416
+ c.execute(
1417
+ 'SELECT rowid, server_id FROM "%s" ORDER BY rowid %s LIMIT ?'
1418
+ % (table, order), (rows_per_table,))
1419
+ for rowid, server_id in c.fetchall():
1420
+ if rowid in seen_rowids:
1421
+ continue
1422
+ seen_rowids.add(rowid)
1423
+ sampled += 1
1424
+ if server_id is None or server_id == 0:
1425
+ missing_ids += 1
1426
+ else:
1427
+ table_ids.add(str(int(server_id)))
1428
+ for sid in table_ids:
1429
+ id_locations.setdefault(sid, set()).add((item['name'], table))
1430
+ item['rowsSampled'] = sampled
1431
+ item['serverIdMissing'] = missing_ids
1432
+ totals['rowsSampled'] += sampled
1433
+ totals['serverIdMissing'] += missing_ids
1434
+ except Exception as exc:
1435
+ item['reason'] = 'READ_FAILED: %s' % str(exc).split('\n')[0][:120]
1436
+ finally:
1437
+ shard_items.append(item)
1438
+ conn.close()
1439
+
1440
+ duplicates = {sid: locs for sid, locs in id_locations.items() if len(locs) > 1}
1441
+ return {
1442
+ 'shards': shard_items,
1443
+ 'schemaAnchorMissingTables': anchor_missing_total,
1444
+ 'sample': {
1445
+ 'tablesPerShard': tables_per_shard,
1446
+ 'rowsPerTable': rows_per_table,
1447
+ 'exhaustive': False,
1448
+ 'tablesTotal': totals['tables'],
1449
+ 'tablesSampled': totals['tablesSampled'],
1450
+ 'rowsSampled': totals['rowsSampled'],
1451
+ 'serverIdMissing': totals['serverIdMissing'],
1452
+ 'duplicateServerIds': len(duplicates),
1453
+ 'duplicateServerIdSamples': [
1454
+ {'serverId': sid, 'locations': sorted('%s/%s' % loc for loc in locs)}
1455
+ for sid, locs in sorted(duplicates.items())[:20]
1456
+ ],
1457
+ },
1458
+ }
1459
+
1460
+
1461
+ def get_contacts(conns, limit=200, keyword=None):
1462
+ """Get contacts from NT database, merged across every shard.
1463
+
1464
+ **给了 `keyword` 就不加 `LIMIT`**,由调用方在名字解析之后过滤、再按 `limit` 截断。
1465
+ 原来无条件 `LIMIT`,于是过滤发生在**截断之后**:关键字只能在前 `limit` 行里找,
1466
+ 后面的人搜不到(实测 500 人的通讯录、按备注名找 10 个只命中 3 个)。名字解析要用
1467
+ contact.db 的备注/昵称,那是调用方才有的东西,所以截断必须挪到过滤之后。
1468
+ """
1469
+ seen = set()
1470
+ contacts = []
1471
+ for conn in conns:
1472
+ try:
1473
+ sql = "SELECT user_name FROM Name2Id" if keyword else "SELECT user_name FROM Name2Id LIMIT ?"
1474
+ rows = conn.execute(sql, () if keyword else (limit,)).fetchall()
1475
+ except Exception:
1476
+ continue
1477
+ for (username,) in rows:
1478
+ # Name2Id carries a placeholder row with no user_name; rendering it
1479
+ # produced a blank line at the top of every contact list.
1480
+ if not username or username in seen:
1481
+ continue
1482
+ seen.add(username)
1483
+ contacts.append({"username": username, "displayName": username})
1484
+ return {"contacts": contacts}
1485
+
1486
+
1487
+ # ========== SNS (朋友圈) Queries ==========
1488
+
1489
+ def parse_sns_content(content_str):
1490
+ """Parse SNS content XML/Protobuf text to extract title, description, media etc."""
1491
+ result = {
1492
+ 'content': '',
1493
+ 'create_time': 0,
1494
+ 'username': '',
1495
+ 'object_id': '',
1496
+ 'media_count': 0,
1497
+ }
1498
+ if not content_str:
1499
+ return result
1500
+
1501
+ import re
1502
+
1503
+ # Extract createTime
1504
+ m = re.search(r'<createTime>(\d+)</createTime>', content_str)
1505
+ if m:
1506
+ result['create_time'] = int(m.group(1))
1507
+
1508
+ # Extract username
1509
+ m = re.search(r'<username>([^<]+)</username>', content_str)
1510
+ if m:
1511
+ result['username'] = m.group(1)
1512
+
1513
+ # Extract id
1514
+ m = re.search(r'<id>(\d+)</id>', content_str)
1515
+ if m:
1516
+ result['object_id'] = m.group(1)
1517
+
1518
+ # Extract contentDesc (main text)
1519
+ m = re.search(r'<contentDesc>([^<]*)</contentDesc>', content_str)
1520
+ if m:
1521
+ result['content'] = m.group(1)
1522
+
1523
+ # Extract contentDesc CDATA
1524
+ m = re.search(r'<contentDesc>\s*<!\[CDATA\[(.*?)\]\]>\s*</contentDesc>', content_str, re.DOTALL)
1525
+ if m:
1526
+ result['content'] = m.group(1).strip()
1527
+
1528
+ # Extract title if present
1529
+ m = re.search(r'<title>([^<]*)</title>', content_str)
1530
+ if m:
1531
+ title = m.group(1)
1532
+ if title and not result['content']:
1533
+ result['content'] = title
1534
+ elif title:
1535
+ result['content'] = title + '\n' + result['content']
1536
+
1537
+ # Count media (ContentObject tags)
1538
+ result['media_count'] = len(re.findall(r'<ContentObject[ >]', content_str))
1539
+
1540
+ return result
1541
+
1542
+
1543
+ def get_sns_timeline(cursor, limit=20, offset=0, usernames=None, keyword=None,
1544
+ start_time=None, end_time=None):
1545
+ """Query SNS timeline posts from SnsTimeLine table."""
1546
+ # Check if SnsTimeLine exists, fall back to SnsTopItem_1
1547
+ tables = [r[0] for r in cursor.execute(
1548
+ "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1549
+
1550
+ table = 'SnsTimeLine' if 'SnsTimeLine' in tables else None
1551
+ if not table:
1552
+ table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else None
1553
+ if not table:
1554
+ return {'success': False, 'error': 'No SNS table found'}
1555
+
1556
+ conditions = []
1557
+ params = []
1558
+
1559
+ if table == 'SnsTimeLine':
1560
+ if usernames:
1561
+ placeholders = ','.join(['?' for _ in usernames])
1562
+ conditions.append(f'user_name IN ({placeholders})')
1563
+ params.extend(usernames)
1564
+ if keyword:
1565
+ conditions.append('content LIKE ?')
1566
+ params.append(f'%{keyword}%')
1567
+ if start_time:
1568
+ conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) >= ?")
1569
+ params.append(start_time)
1570
+ if end_time:
1571
+ conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) <= ?")
1572
+ params.append(end_time)
1573
+ elif table == 'SnsTopItem_1':
1574
+ uname_col = 'username' if 'username' in [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()] else 'user_name'
1575
+ if usernames:
1576
+ placeholders = ','.join(['?' for _ in usernames])
1577
+ conditions.append(f'{uname_col} IN ({placeholders})')
1578
+ params.extend(usernames)
1579
+ if keyword:
1580
+ conditions.append('summary LIKE ?')
1581
+ params.append(f'%{keyword}%')
1582
+ if start_time:
1583
+ conditions.append('create_time >= ?')
1584
+ params.append(start_time)
1585
+ if end_time:
1586
+ conditions.append('create_time <= ?')
1587
+ params.append(end_time)
1588
+
1589
+ where = ' AND '.join(conditions) if conditions else '1=1'
1590
+ order = 'tid DESC' if table == 'SnsTimeLine' else 'create_time DESC'
1591
+
1592
+ try:
1593
+ cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1594
+ # Only select known text columns to avoid binary decode errors
1595
+ text_cols = [c for c in cols if c in ('tid', 'user_name', 'content', 'username', 'summary',
1596
+ 'create_time', 'last_read_time', 'is_read',
1597
+ 'from_username', 'from_nickname', 'to_username',
1598
+ 'to_nickname', 'comment_id', 'feed_id',
1599
+ 'createTime', 'userName')]
1600
+ if not text_cols:
1601
+ text_cols = ['*']
1602
+ select_str = ', '.join(text_cols)
1603
+ rows = cursor.execute(
1604
+ f'SELECT {select_str} FROM [{table}] WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?',
1605
+ params + [limit, offset]
1606
+ ).fetchall()
1607
+
1608
+ timeline = []
1609
+
1610
+ for row in rows:
1611
+ item = dict(zip(text_cols, row))
1612
+
1613
+ if table == 'SnsTimeLine':
1614
+ # Parse XML content
1615
+ content_str = item.get('content', '') or ''
1616
+ parsed = parse_sns_content(content_str)
1617
+ create_time = parsed['create_time'] or 0
1618
+ username = parsed['username'] or item.get('user_name', '')
1619
+ text = parsed['content'] or ''
1620
+ media_count = parsed['media_count']
1621
+ else:
1622
+ create_time = item.get('create_time', 0) or 0
1623
+ username = item.get('username', '') or item.get('user_name', '')
1624
+ text = item.get('summary', '') or ''
1625
+ media_count = 0
1626
+
1627
+ timeline.append({
1628
+ 'create_time': create_time,
1629
+ 'username': username,
1630
+ 'content': text,
1631
+ 'media_count': media_count,
1632
+ 'table': table,
1633
+ })
1634
+
1635
+ return {'success': True, 'timeline': timeline, 'table': table}
1636
+ except Exception as e:
1637
+ return {'success': False, 'error': str(e)}
1638
+
1639
+
1640
+ def get_sns_usernames(cursor):
1641
+ """Get unique usernames from SnsTopItem_1."""
1642
+ tables = [r[0] for r in cursor.execute(
1643
+ "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1644
+
1645
+ # Prefer SnsTopItem_1 for user listing (larger)
1646
+ table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
1647
+ if not table:
1648
+ return {'success': False, 'error': 'No SNS table found'}
1649
+
1650
+ # Discover username column
1651
+ cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1652
+ uname_col = 'username' if 'username' in cols else 'user_name'
1653
+
1654
+ try:
1655
+ rows = cursor.execute(
1656
+ f'SELECT [{uname_col}], COUNT(*) as cnt FROM [{table}] GROUP BY [{uname_col}] ORDER BY cnt DESC'
1657
+ ).fetchall()
1658
+ usernames = [r[0] for r in rows if r[0]]
1659
+ counts = {r[0]: r[1] for r in rows if r[0]}
1660
+ return {'success': True, 'usernames': usernames, 'counts': counts}
1661
+ except Exception as e:
1662
+ return {'success': False, 'error': str(e)}
1663
+
1664
+
1665
+ def get_sns_stats(cursor, my_wxid=None):
1666
+ """Get SNS statistics from SnsTopItem_1."""
1667
+ tables = [r[0] for r in cursor.execute(
1668
+ "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1669
+
1670
+ table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
1671
+ if not table:
1672
+ return {'success': False, 'error': 'No SNS table found'}
1673
+
1674
+ try:
1675
+ total = cursor.execute(f'SELECT COUNT(*) FROM [{table}]').fetchone()[0]
1676
+
1677
+ cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1678
+ uname_col = 'username' if 'username' in cols else 'user_name'
1679
+
1680
+ total_friends = 0
1681
+ if uname_col:
1682
+ total_friends = cursor.execute(
1683
+ f'SELECT COUNT(DISTINCT [{uname_col}]) FROM [{table}] WHERE [{uname_col}] IS NOT NULL'
1684
+ ).fetchone()[0]
1685
+
1686
+ my_posts = None
1687
+ if my_wxid and uname_col:
1688
+ my_posts = cursor.execute(
1689
+ f'SELECT COUNT(*) FROM [{table}] WHERE [{uname_col}] = ?',
1690
+ (my_wxid,)).fetchone()[0]
1691
+
1692
+ return {
1693
+ 'success': True,
1694
+ 'data': {
1695
+ 'totalPosts': total,
1696
+ 'totalFriends': total_friends,
1697
+ 'myPosts': my_posts,
1698
+ }
1699
+ }
1700
+ except Exception as e:
1701
+ return {'success': False, 'error': str(e)}
1702
+
1703
+
1704
+ # ========== Main CLI ==========
1705
+
1706
+ def main():
1707
+ import argparse
1708
+ parser = argparse.ArgumentParser(description='WeChat NT Database Tool')
1709
+ sub = parser.add_subparsers(dest='command')
1710
+
1711
+ # scan command
1712
+ scan_parser = sub.add_parser('scan', help='Scan memory for keys and match NT databases')
1713
+ scan_parser.add_argument('--json', action='store_true', help='Output as JSON')
1714
+ scan_parser.add_argument('--root', default=os.environ.get('WEFLOW_SCAN_ROOT'), help='xwechat_files root directory override')
1715
+
1716
+ # sessions command
1717
+ sessions_parser = sub.add_parser('sessions', help='List chat sessions')
1718
+ sessions_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1719
+ sessions_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1720
+ sessions_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1721
+ sessions_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1722
+ sessions_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1723
+ sessions_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1724
+ sessions_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1725
+ sessions_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1726
+
1727
+ # messages command
1728
+ msg_parser = sub.add_parser('messages', help='Get messages')
1729
+ msg_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1730
+ msg_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1731
+ msg_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1732
+ msg_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1733
+ msg_parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
1734
+ msg_parser.add_argument('--limit', type=int, default=100)
1735
+ msg_parser.add_argument('--offset', type=int, default=0)
1736
+ # Unix seconds, inclusive at both ends. Pushed into SQL, so a bounded read
1737
+ # no longer has to fetch the whole conversation to discard most of it.
1738
+ msg_parser.add_argument('--from', dest='from_time', type=int,
1739
+ default=os.environ.get('WEFLOW_FROM_TIME'),
1740
+ help='Only messages at or after this unix time')
1741
+ msg_parser.add_argument('--to', dest='to_time', type=int,
1742
+ default=os.environ.get('WEFLOW_TO_TIME'),
1743
+ help='Only messages at or before this unix time')
1744
+ msg_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for sender names')
1745
+ msg_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1746
+ msg_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1747
+ msg_parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid (for self-message detection)')
1748
+ # Opt-in: without it the JSON is byte-identical to before this flag existed.
1749
+ msg_parser.add_argument('--report-shards', action='store_true',
1750
+ help='Add a per-shard read report to the result (additive)')
1751
+
1752
+ # contacts command
1753
+ contacts_parser = sub.add_parser('contacts', help='List contacts')
1754
+ contacts_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1755
+ contacts_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1756
+ contacts_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1757
+ contacts_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1758
+ contacts_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1759
+ contacts_parser.add_argument('--limit', type=int, default=200)
1760
+ contacts_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1761
+ contacts_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1762
+ contacts_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1763
+
1764
+ # sns-timeline command
1765
+ sns_tl_parser = sub.add_parser('sns-timeline', help='Get SNS/Moments timeline')
1766
+ sns_tl_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
1767
+ sns_tl_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1768
+ sns_tl_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1769
+ sns_tl_parser.add_argument('--limit', type=int, default=20)
1770
+ sns_tl_parser.add_argument('--offset', type=int, default=0)
1771
+ sns_tl_parser.add_argument('--usernames', default=os.environ.get('WEFLOW_QUERY_USERNAMES'), help='JSON array of usernames to filter')
1772
+ sns_tl_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
1773
+ sns_tl_parser.add_argument('--start-time', type=int, help='Start timestamp')
1774
+ sns_tl_parser.add_argument('--end-time', type=int, help='End timestamp')
1775
+
1776
+ # sns-usernames command
1777
+ sns_un_parser = sub.add_parser('sns-usernames', help='List usernames with SNS posts')
1778
+ sns_un_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
1779
+ sns_un_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1780
+ sns_un_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1781
+
1782
+ # sns-stats command
1783
+ sns_stats_parser = sub.add_parser('sns-stats', help='SNS statistics')
1784
+ sns_stats_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
1785
+ sns_stats_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1786
+ sns_stats_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1787
+ sns_stats_parser.add_argument('--my-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid for my-posts count')
1788
+
1789
+ # fav-schema command
1790
+ fav_schema_parser = sub.add_parser('fav-schema', help='Dump favorite.db schema (key verification)')
1791
+ fav_schema_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to favorite.db')
1792
+ fav_schema_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1793
+
1794
+ verify_parser = sub.add_parser('verify', help='Verify a key+salt can open a database')
1795
+ verify_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1796
+ verify_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1797
+ verify_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1798
+
1799
+ # Same question as `verify`, answered without the native library - so a
1800
+ # missing or broken sqlcipher3 does not hide whether the key is correct.
1801
+ native_parser = sub.add_parser('verify-native',
1802
+ help='Verify a passphrase from the file header alone (no sqlcipher3)')
1803
+ native_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to any database in the set')
1804
+ native_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), required=not os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Passphrase hex (64 chars)')
1805
+ native_parser.add_argument('--internal-key', default=os.environ.get('WEFLOW_INTERNAL_DB_KEY', ''), help='Optional 64-char hex XOR key (some builds)')
1806
+
1807
+ # fav-list command
1808
+ fav_list_parser = sub.add_parser('fav-list', help='List favorite items')
1809
+ fav_list_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to favorite.db')
1810
+ fav_list_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1811
+ fav_list_parser.add_argument('--limit', type=int, default=100)
1812
+ fav_list_parser.add_argument('--offset', type=int, default=0)
1813
+ fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1814
+ fav_list_parser.add_argument('--type', type=int, dest='fav_type',
1815
+ help='Filter by type: 1=text 2=image 4=video 5=article 14=chatrecord')
1816
+
1817
+ # cursor-probe command: the data-side half of D-027's stable-cursor answer.
1818
+ cursor_parser = sub.add_parser(
1819
+ 'cursor-probe',
1820
+ help='Probe what a stable incremental cursor would need here (read-only, bounded sample)')
1821
+ cursor_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1822
+ cursor_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1823
+ cursor_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1824
+ cursor_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1825
+ cursor_parser.add_argument('--tables-per-shard', type=int, default=5,
1826
+ help='Message tables sampled per shard for the identity half (schema half is exhaustive)')
1827
+ cursor_parser.add_argument('--rows-per-table', type=int, default=500,
1828
+ help='Rows read at each rowid end per sampled table')
1829
+
1830
+ args = parser.parse_args()
1831
+
1832
+ if args.command == 'scan':
1833
+ # Discover the database files before touching the process. Walking the
1834
+ # filesystem does not need WeChat running, but key matching does - and
1835
+ # the caller derives keys from the passphrase when the memory scan
1836
+ # finds nothing, which only needs the file list. Returning early on
1837
+ # "process not running" without the databases threw that list away and
1838
+ # left the caller with nothing to derive from.
1839
+ databases = find_nt_databases(getattr(args, 'root', None))
1840
+
1841
+ pid = find_weixin_pid()
1842
+ if not pid:
1843
+ if IS_WINDOWS:
1844
+ print(json.dumps({"error": "Weixin.exe 未运行", "databases": databases}))
1845
+ else:
1846
+ print(json.dumps({"error": "未检测到 Linux 微信进程,请确认微信已启动并登录",
1847
+ "databases": databases}))
1848
+ return
1849
+
1850
+ if not args.json:
1851
+ print(f"扫描进程 PID {pid}...")
1852
+
1853
+ keys, scan_err = scan_memory_keys(pid)
1854
+ if scan_err == 'permission':
1855
+ print(json.dumps({"error": "PERMISSION_DENIED: 读取微信进程内存需要 root 或 CAP_SYS_PTRACE 权限。可使用 sudo 运行,或执行: sudo setcap cap_sys_ptrace=ep $(which python3)",
1856
+ "databases": databases}))
1857
+ return
1858
+ if scan_err == 'gone':
1859
+ print(json.dumps({"error": "微信进程已退出,请重试", "databases": databases}))
1860
+ return
1861
+ if not args.json:
1862
+ print(f"找到 {len(keys)} 个密钥")
1863
+
1864
+ if not args.json:
1865
+ print(f"找到 {len(databases)} 个 NT 数据库")
1866
+
1867
+ matched = match_keys_to_databases(keys, databases)
1868
+ if not args.json:
1869
+ print(f"匹配 {len(matched)} 个数据库")
1870
+ for db in matched:
1871
+ print(f" {db['name']} ({db['size']/1024/1024:.1f}MB) key={db['key'][:16]}... salt={db['salt'][:16]}...")
1872
+ else:
1873
+ print(json.dumps({"keys": keys, "databases": databases, "matched": matched}))
1874
+ return
1875
+
1876
+ if args.command == 'verify':
1877
+ # sqlcipher 打开+读 sqlite_master 才触发解密, 错误密钥在此报 "file is not a database"
1878
+ try:
1879
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
1880
+ n = conn.execute(
1881
+ "SELECT count(*) FROM sqlite_master WHERE type='table'"
1882
+ ).fetchone()[0]
1883
+ conn.close()
1884
+ print(json.dumps({"success": n > 0, "tables": n}, ensure_ascii=True))
1885
+ except Exception as e:
1886
+ print(json.dumps({"success": False, "error": str(e).split('\n')[0][:200]}, ensure_ascii=True))
1887
+ return
1888
+
1889
+ if args.command == 'verify-native':
1890
+ verdict = verify_passphrase_native(args.passphrase, args.db,
1891
+ getattr(args, 'internal_key', '') or '')
1892
+ if verdict is None:
1893
+ print(json.dumps({"success": False, "verified": None,
1894
+ "error": "无法判定(文件不可读、过短,或 passphrase 不是 64 位 hex)"},
1895
+ ensure_ascii=True))
1896
+ return
1897
+ print(json.dumps({"success": True, "verified": verdict}, ensure_ascii=True))
1898
+ return
1899
+
1900
+ # Build contact name map once if contact db provided
1901
+ contact_name_map = {}
1902
+ contact_db = getattr(args, 'contact_db', None)
1903
+ contact_key = getattr(args, 'contact_key', None)
1904
+ contact_salt = getattr(args, 'contact_salt', None)
1905
+ if contact_db and contact_key and contact_salt:
1906
+ contact_name_map = load_contact_names(contact_db, contact_key, contact_salt)
1907
+
1908
+ if args.command == 'sessions':
1909
+ conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1910
+ if not conns:
1911
+ print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1912
+ return
1913
+ result = get_sessions(conns)
1914
+ if 'sessions' in result:
1915
+ result['sessions'] = apply_contact_names(result['sessions'], contact_name_map)
1916
+ if args.keyword:
1917
+ kw = args.keyword.lower()
1918
+ result['sessions'] = [
1919
+ s for s in result['sessions']
1920
+ if kw in (s.get('username', '') + s.get('displayName', '') + s.get('summary', '')).lower()
1921
+ ]
1922
+ print(json.dumps(result, ensure_ascii=True))
1923
+ for conn in conns:
1924
+ conn.close()
1925
+
1926
+ elif args.command == 'messages':
1927
+ passphrase = getattr(args, 'passphrase', '') or ''
1928
+ own_wxid = getattr(args, 'own_wxid', None)
1929
+
1930
+ if getattr(args, 'report_shards', False):
1931
+ pairs, failures = connect_message_shards_detailed(
1932
+ args.db, args.key, args.salt, passphrase)
1933
+ if not pairs:
1934
+ print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1935
+ return
1936
+ conns = [conn for _path, conn in pairs]
1937
+ names = [os.path.basename(path) for path, _conn in pairs]
1938
+ shard_items = []
1939
+ result = get_messages(conns, args.talker, args.limit, args.offset,
1940
+ contact_name_map, own_wxid,
1941
+ shard_names=names, shard_report=shard_items,
1942
+ from_time=args.from_time, to_time=args.to_time)
1943
+ # Failure entries carry no opened connection, so they are not in
1944
+ # `conns` and could not be recorded by get_messages itself.
1945
+ result['shards'] = {
1946
+ 'scanned': len(pairs) + len(failures),
1947
+ 'opened': len(pairs),
1948
+ 'failed': len(failures),
1949
+ 'items': failures + shard_items,
1950
+ }
1951
+ else:
1952
+ conns = connect_message_shards(args.db, args.key, args.salt, passphrase)
1953
+ if not conns:
1954
+ print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1955
+ return
1956
+ result = get_messages(conns, args.talker, args.limit, args.offset,
1957
+ contact_name_map, own_wxid,
1958
+ from_time=args.from_time, to_time=args.to_time)
1959
+
1960
+ print(json.dumps(result, ensure_ascii=True))
1961
+ for conn in conns:
1962
+ conn.close()
1963
+
1964
+ elif args.command == 'contacts':
1965
+ conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1966
+ if not conns:
1967
+ print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1968
+ return
1969
+ result = get_contacts(conns, args.limit, keyword=args.keyword)
1970
+ if 'contacts' in result:
1971
+ result['contacts'] = apply_contact_names(result['contacts'], contact_name_map)
1972
+ result['contacts'] = filter_contacts(result['contacts'], args.keyword, args.limit)
1973
+ print(json.dumps(result, ensure_ascii=True))
1974
+ for conn in conns:
1975
+ conn.close()
1976
+
1977
+ elif args.command == 'sns-timeline':
1978
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
1979
+ usernames = None
1980
+ if args.usernames:
1981
+ try:
1982
+ usernames = json.loads(args.usernames)
1983
+ except: pass
1984
+ result = get_sns_timeline(conn.cursor(), args.limit, args.offset,
1985
+ usernames, args.keyword,
1986
+ args.start_time, args.end_time)
1987
+ print(json.dumps(result, ensure_ascii=True, default=str))
1988
+ conn.close()
1989
+
1990
+ elif args.command == 'sns-usernames':
1991
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
1992
+ result = get_sns_usernames(conn.cursor())
1993
+ print(json.dumps(result, ensure_ascii=True, default=str))
1994
+ conn.close()
1995
+
1996
+ elif args.command == 'sns-stats':
1997
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
1998
+ result = get_sns_stats(conn.cursor(), args.my_wxid)
1999
+ print(json.dumps(result, ensure_ascii=True, default=str))
2000
+ conn.close()
2001
+
2002
+ elif args.command == 'fav-schema':
2003
+ result = get_fav_schema(args.db, args.key)
2004
+ print(json.dumps(result, ensure_ascii=True))
2005
+
2006
+ elif args.command == 'fav-list':
2007
+ result = get_favorites(args.db, args.key, args.limit, args.offset,
2008
+ args.keyword, getattr(args, 'fav_type', None))
2009
+ print(json.dumps(result, ensure_ascii=True, default=str))
2010
+
2011
+ elif args.command == 'cursor-probe':
2012
+ result = cursor_probe(args.db, args.key, args.salt,
2013
+ getattr(args, 'passphrase', '') or '',
2014
+ tables_per_shard=args.tables_per_shard,
2015
+ rows_per_table=args.rows_per_table)
2016
+ print(json.dumps(result, ensure_ascii=True))
2017
+
2018
+ else:
2019
+ parser.print_help()
2020
+
2021
+
2022
+ if __name__ == '__main__':
2023
+ main()