weflow-cli 1.6.1 → 1.6.2

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,1216 +1,1352 @@
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 re
10
- import ctypes
11
- from ctypes import wintypes, c_void_p, c_size_t, create_string_buffer, byref, sizeof
12
- from pathlib import Path
13
-
14
- sqlcipher = None
15
-
16
-
17
- def require_sqlcipher():
18
- """Load SQLCipher only for database operations, not path discovery."""
19
- global sqlcipher
20
- if sqlcipher is not None:
21
- return sqlcipher
22
- try:
23
- from sqlcipher3 import dbapi2 as sqlcipher_module
24
- except ImportError as error:
25
- raise RuntimeError("需要 sqlcipher3: pip install sqlcipher3") from error
26
- sqlcipher = sqlcipher_module
27
- return sqlcipher
28
-
29
- # ========== Memory Scanner ==========
30
- PROCESS_VM_READ = 0x0010
31
- PROCESS_QUERY_INFORMATION = 0x0400
32
- MEM_COMMIT = 0x1000
33
- PAGE_NOACCESS = 0x01
34
- PAGE_GUARD = 0x100
35
-
36
- class MEMORY_BASIC_INFORMATION(ctypes.Structure):
37
- _fields_ = [
38
- ('BaseAddress', ctypes.c_void_p),
39
- ('AllocationBase', ctypes.c_void_p),
40
- ('AllocationProtect', wintypes.DWORD),
41
- ('PartitionId', wintypes.WORD),
42
- ('RegionSize', ctypes.c_size_t),
43
- ('State', wintypes.DWORD),
44
- ('Protect', wintypes.DWORD),
45
- ('Type', wintypes.DWORD),
46
- ]
47
-
48
- IS_WINDOWS = os.name == 'nt'
49
-
50
- if IS_WINDOWS:
51
- kernel32 = ctypes.windll.kernel32
52
- ReadProcessMemory = kernel32.ReadProcessMemory
53
- ReadProcessMemory.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID, ctypes.c_size_t, ctypes.POINTER(c_size_t)]
54
- ReadProcessMemory.restype = wintypes.BOOL
55
- VirtualQueryEx = kernel32.VirtualQueryEx
56
- VirtualQueryEx.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
57
- VirtualQueryEx.restype = ctypes.c_size_t
58
- else:
59
- kernel32 = None
60
-
61
-
62
- def find_weixin_pid():
63
- """Find WeChat process ID (Windows: pymem; Linux: /proc scan)."""
64
- if not IS_WINDOWS:
65
- return find_weixin_pid_linux()
66
- try:
67
- import pymem, pymem.process
68
- for proc in pymem.process.list_processes():
69
- try:
70
- name = proc.szExeFile
71
- if isinstance(name, bytes):
72
- name = name.decode('utf-8', errors='ignore')
73
- if name.lower() == 'weixin.exe':
74
- return proc.th32ProcessID
75
- except:
76
- pass
77
- except ImportError:
78
- pass
79
- return None
80
-
81
-
82
- _LINUX_WECHAT_COMMS = {'wechat', 'wechatappex', 'weixin'}
83
- _LINUX_EXE_PREFIX_DENY = ('python', 'bash', 'sh', 'zsh', 'node', 'perl', 'ruby', 'electron')
84
-
85
-
86
- def _is_linux_wechat_process(pid):
87
- if pid == os.getpid():
88
- return False
89
- try:
90
- with open(f'/proc/{pid}/comm') as f:
91
- comm = f.read().strip().lower()
92
- if comm in _LINUX_WECHAT_COMMS:
93
- return True
94
- try:
95
- exe = os.path.realpath(os.readlink(f'/proc/{pid}/exe'))
96
- except OSError:
97
- return False
98
- name = os.path.basename(exe).lower()
99
- if any(name.startswith(p) for p in _LINUX_EXE_PREFIX_DENY):
100
- return False
101
- return 'wechat' in name or 'weixin' in name
102
- except (PermissionError, FileNotFoundError, ProcessLookupError):
103
- return False
104
-
105
-
106
- def find_weixin_pid_linux():
107
- """Find Linux WeChat main process (largest RSS among candidates)."""
108
- best = None
109
- best_rss = -1
110
- try:
111
- pids = os.listdir('/proc')
112
- except OSError:
113
- return None
114
- for pid_str in pids:
115
- if not pid_str.isdigit():
116
- continue
117
- pid = int(pid_str)
118
- if not _is_linux_wechat_process(pid):
119
- continue
120
- try:
121
- with open(f'/proc/{pid}/statm') as f:
122
- rss_kb = int(f.read().split()[1]) * 4
123
- except (OSError, IndexError, ValueError):
124
- rss_kb = 0
125
- if rss_kb > best_rss:
126
- best_rss = rss_kb
127
- best = pid
128
- return best
129
-
130
-
131
- def scan_memory_keys(pid):
132
- """Scan process memory for x'<64hex_key><32hex_salt>' patterns.
133
-
134
- Returns (keys, error): keys is a list of {"key","salt"} dicts,
135
- error is None on success or 'permission' / 'gone' / 'not_windows'.
136
- """
137
- if not IS_WINDOWS:
138
- return scan_memory_keys_linux(pid)
139
-
140
- hProcess = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
141
- if not hProcess:
142
- return [], None
143
-
144
- pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
145
- keys_found = []
146
- address = 0x10000
147
-
148
- while address < 0x7FFFFFFFFFFF:
149
- mbi = MEMORY_BASIC_INFORMATION()
150
- result = VirtualQueryEx(hProcess, ctypes.c_void_p(address), ctypes.byref(mbi), sizeof(mbi))
151
- if result == 0:
152
- break
153
-
154
- region_addr = mbi.BaseAddress or 0
155
- region_size = mbi.RegionSize or 0
156
-
157
- if (mbi.State == MEM_COMMIT and
158
- region_size > 256 and region_size < 200 * 1024 * 1024 and
159
- mbi.Protect not in (0, PAGE_NOACCESS, PAGE_GUARD)):
160
-
161
- pos = region_addr
162
- end = region_addr + region_size
163
- while pos < end:
164
- chunk_size = min(65536, end - pos)
165
- buf = create_string_buffer(chunk_size)
166
- bytesRead = c_size_t(0)
167
- ok = ReadProcessMemory(hProcess, ctypes.c_void_p(pos), buf, chunk_size, byref(bytesRead))
168
- if ok and bytesRead.value > 0:
169
- data = buf.raw[:bytesRead.value]
170
- for m in pattern.finditer(data):
171
- key_hex = m.group(1).decode()
172
- salt_hex = m.group(2).decode()
173
- keys_found.append((key_hex, salt_hex))
174
- pos += chunk_size
175
-
176
- address = region_addr + region_size
177
-
178
- kernel32.CloseHandle(hProcess)
179
-
180
- # Deduplicate
181
- seen = set()
182
- unique_keys = []
183
- for k, s in keys_found:
184
- pair = (k, s)
185
- if pair not in seen:
186
- seen.add(pair)
187
- unique_keys.append({"key": k, "salt": s})
188
-
189
- return unique_keys, None
190
-
191
-
192
- _LINUX_SKIP_MAPPINGS = {'[vdso]', '[vsyscall]', '[vvar]'}
193
- _LINUX_SKIP_PREFIXES = ('/usr/lib/', '/lib/', '/usr/share/')
194
-
195
-
196
- def scan_memory_keys_linux(pid):
197
- """Scan /proc/<pid>/maps + /proc/<pid>/mem for the key pattern.
198
-
199
- Requires root or CAP_SYS_PTRACE (or the target being a descendant
200
- of this process when yama ptrace_scope=1).
201
- """
202
- regions = []
203
- try:
204
- with open(f'/proc/{pid}/maps') as f:
205
- for line in f:
206
- parts = line.split()
207
- if len(parts) < 2 or 'r' not in parts[1]:
208
- continue
209
- if len(parts) >= 6:
210
- name = parts[5]
211
- if name in _LINUX_SKIP_MAPPINGS:
212
- continue
213
- name_lower = name.lower()
214
- if name.startswith(_LINUX_SKIP_PREFIXES) and \
215
- 'wcdb' not in name_lower and 'wechat' not in name_lower and 'weixin' not in name_lower:
216
- continue
217
- try:
218
- start_s, end_s = parts[0].split('-')
219
- start = int(start_s, 16)
220
- size = int(end_s, 16) - start
221
- except ValueError:
222
- continue
223
- if 0 < size < 500 * 1024 * 1024:
224
- regions.append((start, size))
225
- except PermissionError:
226
- return [], 'permission'
227
- except (FileNotFoundError, ProcessLookupError):
228
- return [], 'gone'
229
-
230
- pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
231
- keys_found = []
232
- try:
233
- with open(f'/proc/{pid}/mem', 'rb') as mem:
234
- for base, size in regions:
235
- try:
236
- mem.seek(base)
237
- data = mem.read(size)
238
- except (OSError, ValueError):
239
- continue
240
- for m in pattern.finditer(data):
241
- keys_found.append((m.group(1).decode(), m.group(2).decode()))
242
- except PermissionError:
243
- return [], 'permission'
244
- except (FileNotFoundError, ProcessLookupError):
245
- return [], 'gone'
246
-
247
- seen = set()
248
- unique_keys = []
249
- for k, s in keys_found:
250
- pair = (k, s)
251
- if pair not in seen:
252
- seen.add(pair)
253
- unique_keys.append({"key": k, "salt": s})
254
-
255
- return unique_keys, None
256
-
257
-
258
- # ========== NT Database Discovery ==========
259
-
260
- def _is_nt_account_dir(path):
261
- return os.path.isdir(os.path.join(path, 'db_storage')) or \
262
- os.path.isdir(os.path.join(path, 'Msg'))
263
-
264
-
265
- def _normalize_nt_root(root):
266
- if not root:
267
- return None
268
- path = os.path.abspath(os.path.expandvars(os.path.expanduser(root)))
269
- if os.path.isfile(path):
270
- path = os.path.dirname(path)
271
- if not os.path.isdir(path):
272
- return None
273
-
274
- try:
275
- for entry in os.listdir(path):
276
- candidate = os.path.join(path, entry)
277
- if os.path.isdir(candidate) and _is_nt_account_dir(candidate):
278
- return path
279
- except OSError:
280
- return None
281
-
282
- for _ in range(6):
283
- if _is_nt_account_dir(path):
284
- return os.path.dirname(path)
285
- parent = os.path.dirname(path)
286
- if parent == path:
287
- break
288
- path = parent
289
-
290
- return None
291
-
292
-
293
- def find_nt_databases(root=None):
294
- """Find all NT-format databases under xwechat_files (message + contact)."""
295
- if root:
296
- normalized_root = _normalize_nt_root(root)
297
- candidates = [normalized_root] if normalized_root else []
298
- elif IS_WINDOWS:
299
- candidates = [
300
- os.path.expandvars(r'%USERPROFILE%\xwechat_files'),
301
- os.path.expandvars(r'%USERPROFILE%\Documents\xwechat_files'),
302
- ]
303
- else:
304
- home = os.path.expanduser('~')
305
- candidates = [
306
- os.path.join(home, '.local', 'share', 'xwechat_files'),
307
- os.path.join(home, 'xwechat_files'),
308
- os.path.join(home, 'Documents', 'xwechat_files'),
309
- os.path.join(home, '文档', 'xwechat_files'),
310
- ]
311
- xwechat = None
312
- for c in candidates:
313
- if os.path.isdir(c):
314
- xwechat = c
315
- break
316
- if not xwechat:
317
- return []
318
-
319
- databases = []
320
- for wxid_dir in os.listdir(xwechat):
321
- # Scan message databases
322
- msg_storage = os.path.join(xwechat, wxid_dir, 'db_storage', 'message')
323
- if os.path.isdir(msg_storage):
324
- for f in os.listdir(msg_storage):
325
- if f.endswith('.db') and not any(x in f for x in ['-shm', '-wal']):
326
- full_path = os.path.join(msg_storage, f)
327
- try:
328
- with open(full_path, 'rb') as fh:
329
- salt = fh.read(16)
330
- databases.append({
331
- "path": full_path,
332
- "name": f"message/{f}",
333
- "salt": salt.hex(),
334
- "size": os.path.getsize(full_path),
335
- "wxid": wxid_dir,
336
- })
337
- except:
338
- pass
339
-
340
- # Scan contact database
341
- contact_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'contact', 'contact.db')
342
- if os.path.isfile(contact_db):
343
- try:
344
- with open(contact_db, 'rb') as fh:
345
- salt = fh.read(16)
346
- databases.append({
347
- "path": contact_db,
348
- "name": "contact/contact.db",
349
- "salt": salt.hex(),
350
- "size": os.path.getsize(contact_db),
351
- "wxid": wxid_dir,
352
- })
353
- except:
354
- pass
355
-
356
- # Scan SNS (朋友圈) database
357
- sns_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'sns', 'sns.db')
358
- if os.path.isfile(sns_db):
359
- try:
360
- with open(sns_db, 'rb') as fh:
361
- salt = fh.read(16)
362
- databases.append({
363
- "path": sns_db,
364
- "name": "sns/sns.db",
365
- "salt": salt.hex(),
366
- "size": os.path.getsize(sns_db),
367
- "wxid": wxid_dir,
368
- })
369
- except:
370
- pass
371
-
372
- # Scan favorites (收藏) database
373
- fav_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'favorite', 'favorite.db')
374
- if os.path.isfile(fav_db):
375
- try:
376
- with open(fav_db, 'rb') as fh:
377
- salt = fh.read(16)
378
- databases.append({
379
- "path": fav_db,
380
- "name": "favorite/favorite.db",
381
- "salt": salt.hex(),
382
- "size": os.path.getsize(fav_db),
383
- "wxid": wxid_dir,
384
- })
385
- except:
386
- pass
387
-
388
- return databases
389
-
390
-
391
- def find_contact_db_path(message_db_path):
392
- """Derive contact.db path from message_0.db path.
393
-
394
- message_0.db: <xwechat_files>/<wxid>/db_storage/message/message_0.db
395
- contact.db: <xwechat_files>/<wxid>/db_storage/contact/contact.db
396
- """
397
- msg_dir = os.path.dirname(message_db_path)
398
- wxid_dir = os.path.dirname(msg_dir) # .../db_storage
399
- xwechat_dir = os.path.dirname(wxid_dir) # .../<wxid>
400
- contact_db = os.path.join(xwechat_dir, 'db_storage', 'contact', 'contact.db')
401
- if os.path.isfile(contact_db):
402
- return contact_db
403
- return None
404
-
405
-
406
- def load_contact_names(contact_db_path, contact_key_hex, contact_salt_hex):
407
- """Load wxid -> {remark, nick_name} map from contact.db.
408
-
409
- Returns dict: {wxid: display_name}
410
- display_name priority: remark > nick_name > alias > wxid
411
- """
412
- if not contact_db_path or not contact_key_hex or not contact_salt_hex:
413
- return {}
414
-
415
- try:
416
- raw_key = f"x'{contact_key_hex}{contact_salt_hex}'"
417
- conn = require_sqlcipher().connect(contact_db_path)
418
- c = conn.cursor()
419
- c.execute(f'PRAGMA key = "{raw_key}";')
420
-
421
- # contact.db schema: username, alias, remark, nick_name, ...
422
- c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), NULLIF(alias,''), username) FROM contact")
423
- name_map = {}
424
- for username, display in c.fetchall():
425
- if username:
426
- name_map[username] = display
427
-
428
- conn.close()
429
- return name_map
430
- except Exception as e:
431
- return {}
432
-
433
-
434
- def apply_contact_names(sessions, name_map):
435
- """Apply contact names to session list, replacing bare wxid displayNames."""
436
- if not name_map:
437
- return sessions
438
- for s in sessions:
439
- username = s.get('username', '')
440
- if username in name_map:
441
- s['displayName'] = name_map[username]
442
- return sessions
443
-
444
-
445
- def match_keys_to_databases(keys, databases):
446
- """Match memory keys to databases by comparing salts."""
447
- salt_to_key = {}
448
- for k in keys:
449
- salt_to_key[k["salt"]] = k["key"]
450
-
451
- matched = []
452
- for db in databases:
453
- if db["salt"] in salt_to_key:
454
- db["key"] = salt_to_key[db["salt"]]
455
- matched.append(db)
456
-
457
- return matched
458
-
459
-
460
- # ========== Database Operations ==========
461
-
462
- def connect_nt_db(db_path, key_hex, salt_hex):
463
- """Connect to an NT database using sqlcipher3."""
464
- raw_key = f"x'{key_hex}{salt_hex}'"
465
- conn = require_sqlcipher().connect(db_path)
466
- c = conn.cursor()
467
- c.execute(f'PRAGMA key = "{raw_key}";')
468
- return conn, c
469
-
470
-
471
- def get_fav_schema(db_path, key_hex):
472
- """Dump favorite.db schema + sample rows (key verification / exploration)."""
473
- try:
474
- with open(db_path, 'rb') as fh:
475
- salt = fh.read(16).hex()
476
- conn = require_sqlcipher().connect(db_path)
477
- conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
478
- tables = conn.execute(
479
- "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
480
- ).fetchall()
481
- result = {"success": True, "tables": [t[0] for t in tables]}
482
- if not tables:
483
- result["error"] = "数据库为空或密钥错误"
484
- result["success"] = False
485
- conn.close()
486
- return result
487
- except Exception as e:
488
- return {"success": False, "error": str(e).split('\n')[0][:200]}
489
-
490
-
491
- FAV_TYPE_NAMES = {
492
- 1: 'text', # 文字
493
- 2: 'image', # 图片
494
- 4: 'video', # 视频
495
- 5: 'article', # 公众号文章/网页链接
496
- 14: 'chatrecord', # 聊天记录
497
- }
498
-
499
-
500
- def parse_fav_content(content):
501
- """Extract display fields (title/link/desc/source/cover) from favitem XML."""
502
- out = {}
503
- if not content:
504
- return out
505
- import xml.etree.ElementTree as ET
506
- try:
507
- root = ET.fromstring(content)
508
- except ET.ParseError:
509
- return out
510
-
511
- def text(path):
512
- el = root.find(path)
513
- if el is not None and el.text and el.text.strip():
514
- return el.text.strip()
515
- return None
516
-
517
- title = (text('weburlitem/pagetitle')
518
- or text('datalist/dataitem/datatitle')
519
- or text('title')
520
- or text('desc'))
521
- if title:
522
- out['title'] = title
523
- link = (text('weburlitem/clean_url')
524
- or text('source/link')
525
- or text('datalist/dataitem/stream_weburl'))
526
- if link:
527
- out['link'] = link
528
- desc = text('weburlitem/pagedesc') or text('datalist/dataitem/datadesc')
529
- if desc and desc != out.get('title'):
530
- out['desc'] = desc
531
- src = text('weburlitem/appmsgshareitem/srcdisplayname')
532
- if src:
533
- out['source_name'] = src
534
- cover = (text('weburlitem/pagethumb_url')
535
- or text('datalist/dataitem/dataext')
536
- or text('datalist/dataitem/cdn_thumburl'))
537
- if cover:
538
- out['cover'] = cover
539
- fmt = text('datalist/dataitem/datafmt')
540
- if fmt:
541
- out['format'] = fmt
542
- return out
543
-
544
-
545
- def get_favorites(db_path, key_hex, limit=100, offset=0, keyword=None, fav_type=None):
546
- """List favorite items from favorite.db with parsed content."""
547
- try:
548
- with open(db_path, 'rb') as fh:
549
- salt = fh.read(16).hex()
550
- conn = require_sqlcipher().connect(db_path)
551
- conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
552
- c = conn.cursor()
553
-
554
- tables = [t[0] for t in c.execute(
555
- "SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
556
- if 'fav_db_item' not in tables:
557
- conn.close()
558
- return {"error": f"未找到 fav_db_item 表,现有表: {tables[:20]}"}
559
-
560
- total = c.execute('SELECT count(*) FROM fav_db_item').fetchone()[0]
561
-
562
- sql = ('SELECT local_id, server_id, type, update_time, fromusr, realchatname, content '
563
- 'FROM fav_db_item WHERE 1=1')
564
- params = []
565
- if fav_type is not None:
566
- sql += ' AND type = ?'
567
- params.append(fav_type)
568
- if keyword:
569
- sql += ' AND content LIKE ?'
570
- params.append(f'%{keyword}%')
571
- sql += ' ORDER BY update_time DESC LIMIT ? OFFSET ?'
572
- params.extend([limit, offset])
573
- rows = c.execute(sql, params).fetchall()
574
- conn.close()
575
-
576
- items = []
577
- for local_id, server_id, ftype, update_time, fromusr, realchatname, content in rows:
578
- ct = content if isinstance(content, str) else (
579
- content.decode('utf-8', errors='replace') if content else '')
580
- item = {
581
- 'local_id': local_id,
582
- 'server_id': server_id,
583
- 'type': ftype,
584
- 'type_name': FAV_TYPE_NAMES.get(ftype, 'type_%s' % ftype),
585
- 'update_time': update_time,
586
- 'from_user': fromusr,
587
- 'chat_name': realchatname or None,
588
- }
589
- item.update(parse_fav_content(ct))
590
- items.append(item)
591
- return {"favorites": items, "total": total, "count": len(items),
592
- "limit": limit, "offset": offset}
593
- except Exception as e:
594
- return {"error": str(e).split('\n')[0][:200]}
595
-
596
-
597
- def get_sessions(conn):
598
- """Get chat sessions from NT database (Name2Id table)."""
599
- c = conn.cursor()
600
- sessions = []
601
-
602
- # NT format: each chat has its own Msg_<MD5> table
603
- # The Name2Id table maps usernames to IDs (user_name, is_session)
604
- try:
605
- c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1 LIMIT 500")
606
- rows = c.fetchall()
607
-
608
- import hashlib as hl
609
-
610
- for (username,) in rows:
611
- summary = ""
612
- last_time = 0
613
-
614
- # Try to get last message summary
615
- try:
616
- tbl_hash = hl.md5(username.encode()).hexdigest()
617
- msg_table = f"Msg_{tbl_hash}"
618
-
619
- c.execute(f'SELECT create_time, source, message_content, local_type FROM "{msg_table}" ORDER BY create_time DESC LIMIT 1')
620
- row = c.fetchone()
621
- if row:
622
- last_time = row[0] or 0
623
- msg_type = row[3] or 0
624
- source_text = row[1]
625
- content_text = row[2]
626
-
627
- if msg_type == 1:
628
- # Text message: use message_content
629
- if isinstance(content_text, str) and content_text:
630
- summary = content_text[:50]
631
- elif isinstance(content_text, bytes):
632
- summary = content_text.decode('utf-8', errors='ignore')[:50]
633
- elif isinstance(source_text, str) and source_text:
634
- # Non-text: try to extract from source
635
- # Strip XML tags for summary
636
- import re as _re
637
- clean = _re.sub(r'<[^>]+>', '', source_text)
638
- lines = clean.split('\n')
639
- if len(lines) > 1 and lines[1].strip():
640
- summary = lines[1].strip()[:50]
641
- elif clean.strip():
642
- summary = clean.strip()[:50]
643
- except:
644
- pass
645
-
646
- sessions.append({
647
- "username": username,
648
- "type": 1 if "@chatroom" in username else 0,
649
- "unreadCount": 0,
650
- "summary": summary,
651
- "sortTimestamp": last_time,
652
- "lastTimestamp": last_time,
653
- "displayName": username,
654
- })
655
- except Exception as e:
656
- return {"error": str(e)}
657
-
658
- # Sort by timestamp descending
659
- sessions.sort(key=lambda s: s.get("sortTimestamp", 0), reverse=True)
660
- return {"sessions": sessions}
661
-
662
-
663
- def get_messages(conn, talker, limit=100, offset=0, name_map=None, own_wxid=None):
664
- """Get messages for a specific talker from NT database.
665
-
666
- Args:
667
- name_map: optional {wxid: display_name} dict for resolving sender names
668
- own_wxid: account owner wxid for self-message detection
669
- """
670
- import hashlib
671
- if name_map is None:
672
- name_map = {}
673
- c = conn.cursor()
674
-
675
- msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
676
-
677
- try:
678
- # Check if table exists
679
- c.execute(f"SELECT COUNT(*) FROM sqlite_master WHERE name='{msg_table}'")
680
- if c.fetchone()[0] == 0:
681
- return {"error": f"未找到会话: {talker}"}
682
-
683
- if limit > 0:
684
- c.execute(f'''
685
- SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
686
- create_time, status, upload_status, download_status,
687
- server_seq, origin_source, source, message_content, compress_content
688
- FROM "{msg_table}"
689
- ORDER BY create_time DESC
690
- LIMIT ? OFFSET ?
691
- ''', (limit, offset))
692
- else:
693
- c.execute(f'''
694
- SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
695
- create_time, status, upload_status, download_status,
696
- server_seq, origin_source, source, message_content, compress_content
697
- FROM "{msg_table}"
698
- ORDER BY create_time DESC
699
- ''')
700
-
701
- rows = c.fetchall()
702
- messages = []
703
-
704
- # Build sender_id -> username map from Name2Id (one query for all messages)
705
- c.execute("SELECT rowid, user_name FROM Name2Id")
706
- sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
707
-
708
- for row in rows:
709
- local_type = row[2] or 0
710
- create_time = row[5] or 0
711
- real_sender_id = row[4] or 0
712
-
713
- # Resolve sender: real_sender_id -> Name2Id -> user_name
714
- sender_username = sender_id_map.get(real_sender_id, "")
715
-
716
- # Determine if message is from self
717
- # own_wxid may have _xxxx suffix (from xwechat_files dir), try both
718
- is_self = bool(own_wxid and (
719
- sender_username == own_wxid or
720
- (own_wxid.endswith('_') is False and sender_username.startswith(own_wxid))
721
- ))
722
- if not is_self and own_wxid:
723
- # Strip _xxxx suffix and retry
724
- parts = own_wxid.rsplit('_', 1)
725
- if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
726
- is_self = (sender_username == parts[0])
727
-
728
- # Resolve sender display name from contact map
729
- if is_self:
730
- sender_display = "" # Let the CLI show ""
731
- else:
732
- sender_display = name_map.get(sender_username, sender_username) if sender_username else sender_username
733
-
734
- # Parse message_content - TEXT column
735
- content = row[12] if isinstance(row[12], str) else ""
736
-
737
- messages.append({
738
- "localId": row[0] or 0,
739
- "serverId": str(row[1] or ''),
740
- "localType": local_type,
741
- "createTime": create_time,
742
- "isSend": 1 if is_self else 0, # 1 = I sent this
743
- "senderUsername": sender_username,
744
- "senderDisplay": sender_display,
745
- "content": content,
746
- "rawContent": content,
747
- "parsedContent": content[:200] if local_type == 1 else "",
748
- })
749
-
750
- return {"messages": messages}
751
- except Exception as e:
752
- return {"error": str(e)}
753
-
754
-
755
- def get_contacts(conn, limit=200):
756
- """Get contacts from NT database."""
757
- c = conn.cursor()
758
- try:
759
- c.execute("SELECT user_name FROM Name2Id LIMIT ?", (limit,))
760
- rows = c.fetchall()
761
- contacts = [{"username": r[0], "displayName": r[0]} for r in rows]
762
- return {"contacts": contacts}
763
- except Exception as e:
764
- return {"error": str(e)}
765
-
766
-
767
- # ========== SNS (朋友圈) Queries ==========
768
-
769
- def parse_sns_content(content_str):
770
- """Parse SNS content XML/Protobuf text to extract title, description, media etc."""
771
- result = {
772
- 'content': '',
773
- 'create_time': 0,
774
- 'username': '',
775
- 'object_id': '',
776
- 'media_count': 0,
777
- }
778
- if not content_str:
779
- return result
780
-
781
- import re
782
-
783
- # Extract createTime
784
- m = re.search(r'<createTime>(\d+)</createTime>', content_str)
785
- if m:
786
- result['create_time'] = int(m.group(1))
787
-
788
- # Extract username
789
- m = re.search(r'<username>([^<]+)</username>', content_str)
790
- if m:
791
- result['username'] = m.group(1)
792
-
793
- # Extract id
794
- m = re.search(r'<id>(\d+)</id>', content_str)
795
- if m:
796
- result['object_id'] = m.group(1)
797
-
798
- # Extract contentDesc (main text)
799
- m = re.search(r'<contentDesc>([^<]*)</contentDesc>', content_str)
800
- if m:
801
- result['content'] = m.group(1)
802
-
803
- # Extract contentDesc CDATA
804
- m = re.search(r'<contentDesc>\s*<!\[CDATA\[(.*?)\]\]>\s*</contentDesc>', content_str, re.DOTALL)
805
- if m:
806
- result['content'] = m.group(1).strip()
807
-
808
- # Extract title if present
809
- m = re.search(r'<title>([^<]*)</title>', content_str)
810
- if m:
811
- title = m.group(1)
812
- if title and not result['content']:
813
- result['content'] = title
814
- elif title:
815
- result['content'] = title + '\n' + result['content']
816
-
817
- # Count media (ContentObject tags)
818
- result['media_count'] = len(re.findall(r'<ContentObject[ >]', content_str))
819
-
820
- return result
821
-
822
-
823
- def get_sns_timeline(cursor, limit=20, offset=0, usernames=None, keyword=None,
824
- start_time=None, end_time=None):
825
- """Query SNS timeline posts from SnsTimeLine table."""
826
- # Check if SnsTimeLine exists, fall back to SnsTopItem_1
827
- tables = [r[0] for r in cursor.execute(
828
- "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
829
-
830
- table = 'SnsTimeLine' if 'SnsTimeLine' in tables else None
831
- if not table:
832
- table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else None
833
- if not table:
834
- return {'success': False, 'error': 'No SNS table found'}
835
-
836
- conditions = []
837
- params = []
838
-
839
- if table == 'SnsTimeLine':
840
- if usernames:
841
- placeholders = ','.join(['?' for _ in usernames])
842
- conditions.append(f'user_name IN ({placeholders})')
843
- params.extend(usernames)
844
- if keyword:
845
- conditions.append('content LIKE ?')
846
- params.append(f'%{keyword}%')
847
- if start_time:
848
- conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) >= ?")
849
- params.append(start_time)
850
- if end_time:
851
- conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) <= ?")
852
- params.append(end_time)
853
- elif table == 'SnsTopItem_1':
854
- uname_col = 'username' if 'username' in [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()] else 'user_name'
855
- if usernames:
856
- placeholders = ','.join(['?' for _ in usernames])
857
- conditions.append(f'{uname_col} IN ({placeholders})')
858
- params.extend(usernames)
859
- if keyword:
860
- conditions.append('summary LIKE ?')
861
- params.append(f'%{keyword}%')
862
- if start_time:
863
- conditions.append('create_time >= ?')
864
- params.append(start_time)
865
- if end_time:
866
- conditions.append('create_time <= ?')
867
- params.append(end_time)
868
-
869
- where = ' AND '.join(conditions) if conditions else '1=1'
870
- order = 'tid DESC' if table == 'SnsTimeLine' else 'create_time DESC'
871
-
872
- try:
873
- cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
874
- # Only select known text columns to avoid binary decode errors
875
- text_cols = [c for c in cols if c in ('tid', 'user_name', 'content', 'username', 'summary',
876
- 'create_time', 'last_read_time', 'is_read',
877
- 'from_username', 'from_nickname', 'to_username',
878
- 'to_nickname', 'comment_id', 'feed_id',
879
- 'createTime', 'userName')]
880
- if not text_cols:
881
- text_cols = ['*']
882
- select_str = ', '.join(text_cols)
883
- rows = cursor.execute(
884
- f'SELECT {select_str} FROM [{table}] WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?',
885
- params + [limit, offset]
886
- ).fetchall()
887
-
888
- timeline = []
889
-
890
- for row in rows:
891
- item = dict(zip(text_cols, row))
892
-
893
- if table == 'SnsTimeLine':
894
- # Parse XML content
895
- content_str = item.get('content', '') or ''
896
- parsed = parse_sns_content(content_str)
897
- create_time = parsed['create_time'] or 0
898
- username = parsed['username'] or item.get('user_name', '')
899
- text = parsed['content'] or ''
900
- media_count = parsed['media_count']
901
- else:
902
- create_time = item.get('create_time', 0) or 0
903
- username = item.get('username', '') or item.get('user_name', '')
904
- text = item.get('summary', '') or ''
905
- media_count = 0
906
-
907
- timeline.append({
908
- 'create_time': create_time,
909
- 'username': username,
910
- 'content': text,
911
- 'media_count': media_count,
912
- 'table': table,
913
- })
914
-
915
- return {'success': True, 'timeline': timeline, 'table': table}
916
- except Exception as e:
917
- return {'success': False, 'error': str(e)}
918
-
919
-
920
- def get_sns_usernames(cursor):
921
- """Get unique usernames from SnsTopItem_1."""
922
- tables = [r[0] for r in cursor.execute(
923
- "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
924
-
925
- # Prefer SnsTopItem_1 for user listing (larger)
926
- table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
927
- if not table:
928
- return {'success': False, 'error': 'No SNS table found'}
929
-
930
- # Discover username column
931
- cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
932
- uname_col = 'username' if 'username' in cols else 'user_name'
933
-
934
- try:
935
- rows = cursor.execute(
936
- f'SELECT [{uname_col}], COUNT(*) as cnt FROM [{table}] GROUP BY [{uname_col}] ORDER BY cnt DESC'
937
- ).fetchall()
938
- usernames = [r[0] for r in rows if r[0]]
939
- counts = {r[0]: r[1] for r in rows if r[0]}
940
- return {'success': True, 'usernames': usernames, 'counts': counts}
941
- except Exception as e:
942
- return {'success': False, 'error': str(e)}
943
-
944
-
945
- def get_sns_stats(cursor, my_wxid=None):
946
- """Get SNS statistics from SnsTopItem_1."""
947
- tables = [r[0] for r in cursor.execute(
948
- "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
949
-
950
- table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
951
- if not table:
952
- return {'success': False, 'error': 'No SNS table found'}
953
-
954
- try:
955
- total = cursor.execute(f'SELECT COUNT(*) FROM [{table}]').fetchone()[0]
956
-
957
- cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
958
- uname_col = 'username' if 'username' in cols else 'user_name'
959
-
960
- total_friends = 0
961
- if uname_col:
962
- total_friends = cursor.execute(
963
- f'SELECT COUNT(DISTINCT [{uname_col}]) FROM [{table}] WHERE [{uname_col}] IS NOT NULL'
964
- ).fetchone()[0]
965
-
966
- my_posts = None
967
- if my_wxid and uname_col:
968
- my_posts = cursor.execute(
969
- f'SELECT COUNT(*) FROM [{table}] WHERE [{uname_col}] = ?',
970
- (my_wxid,)).fetchone()[0]
971
-
972
- return {
973
- 'success': True,
974
- 'data': {
975
- 'totalPosts': total,
976
- 'totalFriends': total_friends,
977
- 'myPosts': my_posts,
978
- }
979
- }
980
- except Exception as e:
981
- return {'success': False, 'error': str(e)}
982
-
983
-
984
- # ========== Main CLI ==========
985
-
986
- def main():
987
- import argparse
988
- parser = argparse.ArgumentParser(description='WeChat NT Database Tool')
989
- sub = parser.add_subparsers(dest='command')
990
-
991
- # scan command
992
- scan_parser = sub.add_parser('scan', help='Scan memory for keys and match NT databases')
993
- scan_parser.add_argument('--json', action='store_true', help='Output as JSON')
994
- scan_parser.add_argument('--root', default=os.environ.get('WEFLOW_SCAN_ROOT'), help='xwechat_files root directory override')
995
-
996
- # sessions command
997
- sessions_parser = sub.add_parser('sessions', help='List chat sessions')
998
- 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')
999
- 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)')
1000
- 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)')
1001
- sessions_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1002
- sessions_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1003
- sessions_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1004
- sessions_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1005
-
1006
- # messages command
1007
- msg_parser = sub.add_parser('messages', help='Get messages')
1008
- 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')
1009
- 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)')
1010
- 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)')
1011
- msg_parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
1012
- msg_parser.add_argument('--limit', type=int, default=100)
1013
- msg_parser.add_argument('--offset', type=int, default=0)
1014
- msg_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for sender names')
1015
- msg_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1016
- msg_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1017
- msg_parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid (for self-message detection)')
1018
-
1019
- # contacts command
1020
- contacts_parser = sub.add_parser('contacts', help='List contacts')
1021
- 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')
1022
- 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)')
1023
- 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)')
1024
- contacts_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1025
- contacts_parser.add_argument('--limit', type=int, default=200)
1026
- contacts_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1027
- contacts_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1028
- contacts_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1029
-
1030
- # sns-timeline command
1031
- sns_tl_parser = sub.add_parser('sns-timeline', help='Get SNS/Moments timeline')
1032
- 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')
1033
- 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)')
1034
- 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)')
1035
- sns_tl_parser.add_argument('--limit', type=int, default=20)
1036
- sns_tl_parser.add_argument('--offset', type=int, default=0)
1037
- sns_tl_parser.add_argument('--usernames', default=os.environ.get('WEFLOW_QUERY_USERNAMES'), help='JSON array of usernames to filter')
1038
- sns_tl_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
1039
- sns_tl_parser.add_argument('--start-time', type=int, help='Start timestamp')
1040
- sns_tl_parser.add_argument('--end-time', type=int, help='End timestamp')
1041
-
1042
- # sns-usernames command
1043
- sns_un_parser = sub.add_parser('sns-usernames', help='List usernames with SNS posts')
1044
- 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')
1045
- 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)')
1046
- 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)')
1047
-
1048
- # sns-stats command
1049
- sns_stats_parser = sub.add_parser('sns-stats', help='SNS statistics')
1050
- 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')
1051
- 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)')
1052
- 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)')
1053
- sns_stats_parser.add_argument('--my-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid for my-posts count')
1054
-
1055
- # fav-schema command
1056
- fav_schema_parser = sub.add_parser('fav-schema', help='Dump favorite.db schema (key verification)')
1057
- 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')
1058
- 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)')
1059
-
1060
- verify_parser = sub.add_parser('verify', help='Verify a key+salt can open a database')
1061
- 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')
1062
- 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)')
1063
- 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)')
1064
-
1065
- # fav-list command
1066
- fav_list_parser = sub.add_parser('fav-list', help='List favorite items')
1067
- 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')
1068
- 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)')
1069
- fav_list_parser.add_argument('--limit', type=int, default=100)
1070
- fav_list_parser.add_argument('--offset', type=int, default=0)
1071
- fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
1072
- fav_list_parser.add_argument('--type', type=int, dest='fav_type',
1073
- help='Filter by type: 1=text 2=image 4=video 5=article 14=chatrecord')
1074
-
1075
- args = parser.parse_args()
1076
-
1077
- if args.command == 'scan':
1078
- # Discover the database files before touching the process. Walking the
1079
- # filesystem does not need WeChat running, but key matching does - and
1080
- # the caller derives keys from the passphrase when the memory scan
1081
- # finds nothing, which only needs the file list. Returning early on
1082
- # "process not running" without the databases threw that list away and
1083
- # left the caller with nothing to derive from.
1084
- databases = find_nt_databases(getattr(args, 'root', None))
1085
-
1086
- pid = find_weixin_pid()
1087
- if not pid:
1088
- if IS_WINDOWS:
1089
- print(json.dumps({"error": "Weixin.exe 未运行", "databases": databases}))
1090
- else:
1091
- print(json.dumps({"error": "未检测到 Linux 微信进程,请确认微信已启动并登录",
1092
- "databases": databases}))
1093
- return
1094
-
1095
- if not args.json:
1096
- print(f"扫描进程 PID {pid}...")
1097
-
1098
- keys, scan_err = scan_memory_keys(pid)
1099
- if scan_err == 'permission':
1100
- print(json.dumps({"error": "PERMISSION_DENIED: 读取微信进程内存需要 root 或 CAP_SYS_PTRACE 权限。可使用 sudo 运行,或执行: sudo setcap cap_sys_ptrace=ep $(which python3)",
1101
- "databases": databases}))
1102
- return
1103
- if scan_err == 'gone':
1104
- print(json.dumps({"error": "微信进程已退出,请重试", "databases": databases}))
1105
- return
1106
- if not args.json:
1107
- print(f"找到 {len(keys)} 个密钥")
1108
-
1109
- if not args.json:
1110
- print(f"找到 {len(databases)} 个 NT 数据库")
1111
-
1112
- matched = match_keys_to_databases(keys, databases)
1113
- if not args.json:
1114
- print(f"匹配 {len(matched)} 个数据库")
1115
- for db in matched:
1116
- print(f" {db['name']} ({db['size']/1024/1024:.1f}MB) key={db['key'][:16]}... salt={db['salt'][:16]}...")
1117
- else:
1118
- print(json.dumps({"keys": keys, "databases": databases, "matched": matched}))
1119
- return
1120
-
1121
- if args.command == 'verify':
1122
- # sqlcipher 打开+读 sqlite_master 才触发解密, 错误密钥在此报 "file is not a database"
1123
- try:
1124
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1125
- n = conn.execute(
1126
- "SELECT count(*) FROM sqlite_master WHERE type='table'"
1127
- ).fetchone()[0]
1128
- conn.close()
1129
- print(json.dumps({"success": n > 0, "tables": n}, ensure_ascii=True))
1130
- except Exception as e:
1131
- print(json.dumps({"success": False, "error": str(e).split('\n')[0][:200]}, ensure_ascii=True))
1132
- return
1133
-
1134
- # Build contact name map once if contact db provided
1135
- contact_name_map = {}
1136
- contact_db = getattr(args, 'contact_db', None)
1137
- contact_key = getattr(args, 'contact_key', None)
1138
- contact_salt = getattr(args, 'contact_salt', None)
1139
- if contact_db and contact_key and contact_salt:
1140
- contact_name_map = load_contact_names(contact_db, contact_key, contact_salt)
1141
-
1142
- if args.command == 'sessions':
1143
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1144
- result = get_sessions(conn)
1145
- if 'sessions' in result:
1146
- result['sessions'] = apply_contact_names(result['sessions'], contact_name_map)
1147
- if args.keyword:
1148
- kw = args.keyword.lower()
1149
- result['sessions'] = [
1150
- s for s in result['sessions']
1151
- if kw in (s.get('username', '') + s.get('displayName', '') + s.get('summary', '')).lower()
1152
- ]
1153
- print(json.dumps(result, ensure_ascii=True))
1154
- conn.close()
1155
-
1156
- elif args.command == 'messages':
1157
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1158
- own_wxid = getattr(args, 'own_wxid', None)
1159
- result = get_messages(conn, args.talker, args.limit, args.offset, contact_name_map, own_wxid)
1160
- print(json.dumps(result, ensure_ascii=True))
1161
- conn.close()
1162
-
1163
- elif args.command == 'contacts':
1164
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1165
- result = get_contacts(conn, args.limit)
1166
- if 'contacts' in result:
1167
- result['contacts'] = apply_contact_names(result['contacts'], contact_name_map)
1168
- if args.keyword:
1169
- kw = args.keyword.lower()
1170
- result['contacts'] = [
1171
- c for c in result['contacts']
1172
- if kw in (c.get('username', '') + c.get('displayName', '') + c.get('remark', '') + c.get('nickname', '')).lower()
1173
- ]
1174
- print(json.dumps(result, ensure_ascii=True))
1175
- conn.close()
1176
-
1177
- elif args.command == 'sns-timeline':
1178
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1179
- usernames = None
1180
- if args.usernames:
1181
- try:
1182
- usernames = json.loads(args.usernames)
1183
- except: pass
1184
- result = get_sns_timeline(conn.cursor(), args.limit, args.offset,
1185
- usernames, args.keyword,
1186
- args.start_time, args.end_time)
1187
- print(json.dumps(result, ensure_ascii=True, default=str))
1188
- conn.close()
1189
-
1190
- elif args.command == 'sns-usernames':
1191
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1192
- result = get_sns_usernames(conn.cursor())
1193
- print(json.dumps(result, ensure_ascii=True, default=str))
1194
- conn.close()
1195
-
1196
- elif args.command == 'sns-stats':
1197
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1198
- result = get_sns_stats(conn.cursor(), args.my_wxid)
1199
- print(json.dumps(result, ensure_ascii=True, default=str))
1200
- conn.close()
1201
-
1202
- elif args.command == 'fav-schema':
1203
- result = get_fav_schema(args.db, args.key)
1204
- print(json.dumps(result, ensure_ascii=True))
1205
-
1206
- elif args.command == 'fav-list':
1207
- result = get_favorites(args.db, args.key, args.limit, args.offset,
1208
- args.keyword, getattr(args, 'fav_type', None))
1209
- print(json.dumps(result, ensure_ascii=True, default=str))
1210
-
1211
- else:
1212
- parser.print_help()
1213
-
1214
-
1215
- if __name__ == '__main__':
1216
- 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 re
10
+ import hashlib
11
+ import ctypes
12
+ from ctypes import wintypes, c_void_p, c_size_t, create_string_buffer, byref, sizeof
13
+ from pathlib import Path
14
+
15
+ sqlcipher = None
16
+
17
+
18
+ def require_sqlcipher():
19
+ """Load SQLCipher only for database operations, not path discovery."""
20
+ global sqlcipher
21
+ if sqlcipher is not None:
22
+ return sqlcipher
23
+ try:
24
+ from sqlcipher3 import dbapi2 as sqlcipher_module
25
+ except ImportError as error:
26
+ raise RuntimeError("需要 sqlcipher3: pip install sqlcipher3") from error
27
+ sqlcipher = sqlcipher_module
28
+ return sqlcipher
29
+
30
+ # ========== Memory Scanner ==========
31
+ PROCESS_VM_READ = 0x0010
32
+ PROCESS_QUERY_INFORMATION = 0x0400
33
+ MEM_COMMIT = 0x1000
34
+ PAGE_NOACCESS = 0x01
35
+ PAGE_GUARD = 0x100
36
+
37
+ class MEMORY_BASIC_INFORMATION(ctypes.Structure):
38
+ _fields_ = [
39
+ ('BaseAddress', ctypes.c_void_p),
40
+ ('AllocationBase', ctypes.c_void_p),
41
+ ('AllocationProtect', wintypes.DWORD),
42
+ ('PartitionId', wintypes.WORD),
43
+ ('RegionSize', ctypes.c_size_t),
44
+ ('State', wintypes.DWORD),
45
+ ('Protect', wintypes.DWORD),
46
+ ('Type', wintypes.DWORD),
47
+ ]
48
+
49
+ IS_WINDOWS = os.name == 'nt'
50
+
51
+ if IS_WINDOWS:
52
+ kernel32 = ctypes.windll.kernel32
53
+ ReadProcessMemory = kernel32.ReadProcessMemory
54
+ ReadProcessMemory.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID, ctypes.c_size_t, ctypes.POINTER(c_size_t)]
55
+ ReadProcessMemory.restype = wintypes.BOOL
56
+ VirtualQueryEx = kernel32.VirtualQueryEx
57
+ VirtualQueryEx.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
58
+ VirtualQueryEx.restype = ctypes.c_size_t
59
+ else:
60
+ kernel32 = None
61
+
62
+
63
+ def find_weixin_pid():
64
+ """Find WeChat process ID (Windows: pymem; Linux: /proc scan)."""
65
+ if not IS_WINDOWS:
66
+ return find_weixin_pid_linux()
67
+ try:
68
+ import pymem, pymem.process
69
+ for proc in pymem.process.list_processes():
70
+ try:
71
+ name = proc.szExeFile
72
+ if isinstance(name, bytes):
73
+ name = name.decode('utf-8', errors='ignore')
74
+ if name.lower() == 'weixin.exe':
75
+ return proc.th32ProcessID
76
+ except:
77
+ pass
78
+ except ImportError:
79
+ pass
80
+ return None
81
+
82
+
83
+ _LINUX_WECHAT_COMMS = {'wechat', 'wechatappex', 'weixin'}
84
+ _LINUX_EXE_PREFIX_DENY = ('python', 'bash', 'sh', 'zsh', 'node', 'perl', 'ruby', 'electron')
85
+
86
+
87
+ def _is_linux_wechat_process(pid):
88
+ if pid == os.getpid():
89
+ return False
90
+ try:
91
+ with open(f'/proc/{pid}/comm') as f:
92
+ comm = f.read().strip().lower()
93
+ if comm in _LINUX_WECHAT_COMMS:
94
+ return True
95
+ try:
96
+ exe = os.path.realpath(os.readlink(f'/proc/{pid}/exe'))
97
+ except OSError:
98
+ return False
99
+ name = os.path.basename(exe).lower()
100
+ if any(name.startswith(p) for p in _LINUX_EXE_PREFIX_DENY):
101
+ return False
102
+ return 'wechat' in name or 'weixin' in name
103
+ except (PermissionError, FileNotFoundError, ProcessLookupError):
104
+ return False
105
+
106
+
107
+ def find_weixin_pid_linux():
108
+ """Find Linux WeChat main process (largest RSS among candidates)."""
109
+ best = None
110
+ best_rss = -1
111
+ try:
112
+ pids = os.listdir('/proc')
113
+ except OSError:
114
+ return None
115
+ for pid_str in pids:
116
+ if not pid_str.isdigit():
117
+ continue
118
+ pid = int(pid_str)
119
+ if not _is_linux_wechat_process(pid):
120
+ continue
121
+ try:
122
+ with open(f'/proc/{pid}/statm') as f:
123
+ rss_kb = int(f.read().split()[1]) * 4
124
+ except (OSError, IndexError, ValueError):
125
+ rss_kb = 0
126
+ if rss_kb > best_rss:
127
+ best_rss = rss_kb
128
+ best = pid
129
+ return best
130
+
131
+
132
+ def scan_memory_keys(pid):
133
+ """Scan process memory for x'<64hex_key><32hex_salt>' patterns.
134
+
135
+ Returns (keys, error): keys is a list of {"key","salt"} dicts,
136
+ error is None on success or 'permission' / 'gone' / 'not_windows'.
137
+ """
138
+ if not IS_WINDOWS:
139
+ return scan_memory_keys_linux(pid)
140
+
141
+ hProcess = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
142
+ if not hProcess:
143
+ return [], None
144
+
145
+ pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
146
+ keys_found = []
147
+ address = 0x10000
148
+
149
+ while address < 0x7FFFFFFFFFFF:
150
+ mbi = MEMORY_BASIC_INFORMATION()
151
+ result = VirtualQueryEx(hProcess, ctypes.c_void_p(address), ctypes.byref(mbi), sizeof(mbi))
152
+ if result == 0:
153
+ break
154
+
155
+ region_addr = mbi.BaseAddress or 0
156
+ region_size = mbi.RegionSize or 0
157
+
158
+ if (mbi.State == MEM_COMMIT and
159
+ region_size > 256 and region_size < 200 * 1024 * 1024 and
160
+ mbi.Protect not in (0, PAGE_NOACCESS, PAGE_GUARD)):
161
+
162
+ pos = region_addr
163
+ end = region_addr + region_size
164
+ while pos < end:
165
+ chunk_size = min(65536, end - pos)
166
+ buf = create_string_buffer(chunk_size)
167
+ bytesRead = c_size_t(0)
168
+ ok = ReadProcessMemory(hProcess, ctypes.c_void_p(pos), buf, chunk_size, byref(bytesRead))
169
+ if ok and bytesRead.value > 0:
170
+ data = buf.raw[:bytesRead.value]
171
+ for m in pattern.finditer(data):
172
+ key_hex = m.group(1).decode()
173
+ salt_hex = m.group(2).decode()
174
+ keys_found.append((key_hex, salt_hex))
175
+ pos += chunk_size
176
+
177
+ address = region_addr + region_size
178
+
179
+ kernel32.CloseHandle(hProcess)
180
+
181
+ # Deduplicate
182
+ seen = set()
183
+ unique_keys = []
184
+ for k, s in keys_found:
185
+ pair = (k, s)
186
+ if pair not in seen:
187
+ seen.add(pair)
188
+ unique_keys.append({"key": k, "salt": s})
189
+
190
+ return unique_keys, None
191
+
192
+
193
+ _LINUX_SKIP_MAPPINGS = {'[vdso]', '[vsyscall]', '[vvar]'}
194
+ _LINUX_SKIP_PREFIXES = ('/usr/lib/', '/lib/', '/usr/share/')
195
+
196
+
197
+ def scan_memory_keys_linux(pid):
198
+ """Scan /proc/<pid>/maps + /proc/<pid>/mem for the key pattern.
199
+
200
+ Requires root or CAP_SYS_PTRACE (or the target being a descendant
201
+ of this process when yama ptrace_scope=1).
202
+ """
203
+ regions = []
204
+ try:
205
+ with open(f'/proc/{pid}/maps') as f:
206
+ for line in f:
207
+ parts = line.split()
208
+ if len(parts) < 2 or 'r' not in parts[1]:
209
+ continue
210
+ if len(parts) >= 6:
211
+ name = parts[5]
212
+ if name in _LINUX_SKIP_MAPPINGS:
213
+ continue
214
+ name_lower = name.lower()
215
+ if name.startswith(_LINUX_SKIP_PREFIXES) and \
216
+ 'wcdb' not in name_lower and 'wechat' not in name_lower and 'weixin' not in name_lower:
217
+ continue
218
+ try:
219
+ start_s, end_s = parts[0].split('-')
220
+ start = int(start_s, 16)
221
+ size = int(end_s, 16) - start
222
+ except ValueError:
223
+ continue
224
+ if 0 < size < 500 * 1024 * 1024:
225
+ regions.append((start, size))
226
+ except PermissionError:
227
+ return [], 'permission'
228
+ except (FileNotFoundError, ProcessLookupError):
229
+ return [], 'gone'
230
+
231
+ pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
232
+ keys_found = []
233
+ try:
234
+ with open(f'/proc/{pid}/mem', 'rb') as mem:
235
+ for base, size in regions:
236
+ try:
237
+ mem.seek(base)
238
+ data = mem.read(size)
239
+ except (OSError, ValueError):
240
+ continue
241
+ for m in pattern.finditer(data):
242
+ keys_found.append((m.group(1).decode(), m.group(2).decode()))
243
+ except PermissionError:
244
+ return [], 'permission'
245
+ except (FileNotFoundError, ProcessLookupError):
246
+ return [], 'gone'
247
+
248
+ seen = set()
249
+ unique_keys = []
250
+ for k, s in keys_found:
251
+ pair = (k, s)
252
+ if pair not in seen:
253
+ seen.add(pair)
254
+ unique_keys.append({"key": k, "salt": s})
255
+
256
+ return unique_keys, None
257
+
258
+
259
+ # ========== NT Database Discovery ==========
260
+
261
+ def _is_nt_account_dir(path):
262
+ return os.path.isdir(os.path.join(path, 'db_storage')) or \
263
+ os.path.isdir(os.path.join(path, 'Msg'))
264
+
265
+
266
+ def _normalize_nt_root(root):
267
+ if not root:
268
+ return None
269
+ path = os.path.abspath(os.path.expandvars(os.path.expanduser(root)))
270
+ if os.path.isfile(path):
271
+ path = os.path.dirname(path)
272
+ if not os.path.isdir(path):
273
+ return None
274
+
275
+ try:
276
+ for entry in os.listdir(path):
277
+ candidate = os.path.join(path, entry)
278
+ if os.path.isdir(candidate) and _is_nt_account_dir(candidate):
279
+ return path
280
+ except OSError:
281
+ return None
282
+
283
+ for _ in range(6):
284
+ if _is_nt_account_dir(path):
285
+ return os.path.dirname(path)
286
+ parent = os.path.dirname(path)
287
+ if parent == path:
288
+ break
289
+ path = parent
290
+
291
+ return None
292
+
293
+
294
+ def find_nt_databases(root=None):
295
+ """Find all NT-format databases under xwechat_files (message + contact)."""
296
+ if root:
297
+ normalized_root = _normalize_nt_root(root)
298
+ candidates = [normalized_root] if normalized_root else []
299
+ elif IS_WINDOWS:
300
+ candidates = [
301
+ os.path.expandvars(r'%USERPROFILE%\xwechat_files'),
302
+ os.path.expandvars(r'%USERPROFILE%\Documents\xwechat_files'),
303
+ ]
304
+ else:
305
+ home = os.path.expanduser('~')
306
+ candidates = [
307
+ os.path.join(home, '.local', 'share', 'xwechat_files'),
308
+ os.path.join(home, 'xwechat_files'),
309
+ os.path.join(home, 'Documents', 'xwechat_files'),
310
+ os.path.join(home, '文档', 'xwechat_files'),
311
+ ]
312
+ xwechat = None
313
+ for c in candidates:
314
+ if os.path.isdir(c):
315
+ xwechat = c
316
+ break
317
+ if not xwechat:
318
+ return []
319
+
320
+ databases = []
321
+ for wxid_dir in os.listdir(xwechat):
322
+ # Scan message databases
323
+ msg_storage = os.path.join(xwechat, wxid_dir, 'db_storage', 'message')
324
+ if os.path.isdir(msg_storage):
325
+ for f in os.listdir(msg_storage):
326
+ if f.endswith('.db') and not any(x in f for x in ['-shm', '-wal']):
327
+ full_path = os.path.join(msg_storage, f)
328
+ try:
329
+ with open(full_path, 'rb') as fh:
330
+ salt = fh.read(16)
331
+ databases.append({
332
+ "path": full_path,
333
+ "name": f"message/{f}",
334
+ "salt": salt.hex(),
335
+ "size": os.path.getsize(full_path),
336
+ "wxid": wxid_dir,
337
+ })
338
+ except:
339
+ pass
340
+
341
+ # Scan contact database
342
+ contact_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'contact', 'contact.db')
343
+ if os.path.isfile(contact_db):
344
+ try:
345
+ with open(contact_db, 'rb') as fh:
346
+ salt = fh.read(16)
347
+ databases.append({
348
+ "path": contact_db,
349
+ "name": "contact/contact.db",
350
+ "salt": salt.hex(),
351
+ "size": os.path.getsize(contact_db),
352
+ "wxid": wxid_dir,
353
+ })
354
+ except:
355
+ pass
356
+
357
+ # Scan SNS (朋友圈) database
358
+ sns_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'sns', 'sns.db')
359
+ if os.path.isfile(sns_db):
360
+ try:
361
+ with open(sns_db, 'rb') as fh:
362
+ salt = fh.read(16)
363
+ databases.append({
364
+ "path": sns_db,
365
+ "name": "sns/sns.db",
366
+ "salt": salt.hex(),
367
+ "size": os.path.getsize(sns_db),
368
+ "wxid": wxid_dir,
369
+ })
370
+ except:
371
+ pass
372
+
373
+ # Scan favorites (收藏) database
374
+ fav_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'favorite', 'favorite.db')
375
+ if os.path.isfile(fav_db):
376
+ try:
377
+ with open(fav_db, 'rb') as fh:
378
+ salt = fh.read(16)
379
+ databases.append({
380
+ "path": fav_db,
381
+ "name": "favorite/favorite.db",
382
+ "salt": salt.hex(),
383
+ "size": os.path.getsize(fav_db),
384
+ "wxid": wxid_dir,
385
+ })
386
+ except:
387
+ pass
388
+
389
+ return databases
390
+
391
+
392
+ def find_contact_db_path(message_db_path):
393
+ """Derive contact.db path from message_0.db path.
394
+
395
+ message_0.db: <xwechat_files>/<wxid>/db_storage/message/message_0.db
396
+ contact.db: <xwechat_files>/<wxid>/db_storage/contact/contact.db
397
+ """
398
+ msg_dir = os.path.dirname(message_db_path)
399
+ wxid_dir = os.path.dirname(msg_dir) # .../db_storage
400
+ xwechat_dir = os.path.dirname(wxid_dir) # .../<wxid>
401
+ contact_db = os.path.join(xwechat_dir, 'db_storage', 'contact', 'contact.db')
402
+ if os.path.isfile(contact_db):
403
+ return contact_db
404
+ return None
405
+
406
+
407
+ def load_contact_names(contact_db_path, contact_key_hex, contact_salt_hex):
408
+ """Load wxid -> {remark, nick_name} map from contact.db.
409
+
410
+ Returns dict: {wxid: display_name}
411
+ display_name priority: remark > nick_name > alias > wxid
412
+ """
413
+ if not contact_db_path or not contact_key_hex or not contact_salt_hex:
414
+ return {}
415
+
416
+ try:
417
+ raw_key = f"x'{contact_key_hex}{contact_salt_hex}'"
418
+ conn = require_sqlcipher().connect(contact_db_path)
419
+ c = conn.cursor()
420
+ c.execute(f'PRAGMA key = "{raw_key}";')
421
+
422
+ # contact.db schema: username, alias, remark, nick_name, ...
423
+ c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), NULLIF(alias,''), username) FROM contact")
424
+ name_map = {}
425
+ for username, display in c.fetchall():
426
+ if username:
427
+ name_map[username] = display
428
+
429
+ conn.close()
430
+ return name_map
431
+ except Exception as e:
432
+ return {}
433
+
434
+
435
+ def apply_contact_names(sessions, name_map):
436
+ """Apply contact names to session list, replacing bare wxid displayNames."""
437
+ if not name_map:
438
+ return sessions
439
+ for s in sessions:
440
+ username = s.get('username', '')
441
+ if username in name_map:
442
+ s['displayName'] = name_map[username]
443
+ return sessions
444
+
445
+
446
+ def match_keys_to_databases(keys, databases):
447
+ """Match memory keys to databases by comparing salts."""
448
+ salt_to_key = {}
449
+ for k in keys:
450
+ salt_to_key[k["salt"]] = k["key"]
451
+
452
+ matched = []
453
+ for db in databases:
454
+ if db["salt"] in salt_to_key:
455
+ db["key"] = salt_to_key[db["salt"]]
456
+ matched.append(db)
457
+
458
+ return matched
459
+
460
+
461
+ # ========== Database Operations ==========
462
+
463
+ def connect_nt_db(db_path, key_hex, salt_hex):
464
+ """Connect to an NT database using sqlcipher3."""
465
+ raw_key = f"x'{key_hex}{salt_hex}'"
466
+ conn = require_sqlcipher().connect(db_path)
467
+ c = conn.cursor()
468
+ c.execute(f'PRAGMA key = "{raw_key}";')
469
+ return conn, c
470
+
471
+
472
+ # WeChat rolls a conversation into a new shard over time, and every shard is
473
+ # encrypted with its own key. Reading only the configured database therefore
474
+ # shows a transcript that stops wherever the first shard's last write left off
475
+ # - which is silent, because the query still succeeds. Only the HTML exporter
476
+ # used to merge shards, so the same chat exported two different histories
477
+ # depending on the format asked for.
478
+
479
+ SHARD_EXCLUDED = {'message_fts.db', 'message_resource.db'}
480
+
481
+
482
+ def discover_message_shards(db_path):
483
+ """Every NT message shard sitting beside the configured database."""
484
+ path = Path(db_path)
485
+ if not path.parent.is_dir():
486
+ return [str(path)]
487
+ shards = sorted(str(p) for p in path.parent.glob('message_*.db')
488
+ if p.name.lower() not in SHARD_EXCLUDED)
489
+ return shards or [str(path)]
490
+
491
+
492
+ def derive_database_key(path, fallback_key, fallback_salt, passphrase=''):
493
+ """Per-shard SQLCipher key (WeChat 4.1.12.26+).
494
+
495
+ The shared passphrase is PBKDF2-HMAC-SHA512'd against each shard's own
496
+ 16-byte header salt. Without a passphrase the configured pair is used
497
+ unchanged, which is what shard 0 opens with on older installs.
498
+ """
499
+ if not passphrase:
500
+ return fallback_key, fallback_salt
501
+ try:
502
+ with open(path, 'rb') as fh:
503
+ salt = fh.read(16)
504
+ if len(salt) != 16:
505
+ return fallback_key, fallback_salt
506
+ raw_passphrase = bytes.fromhex(passphrase)
507
+ key = hashlib.pbkdf2_hmac('sha512', raw_passphrase, salt, 256000, 32).hex()
508
+ return key, salt.hex()
509
+ except (OSError, ValueError):
510
+ return fallback_key, fallback_salt
511
+
512
+
513
+ def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
514
+ """Open the configured database plus every sibling shard.
515
+
516
+ Returns the connections that actually opened. A shard that fails is
517
+ skipped rather than fatal: a partially readable transcript beats a
518
+ command that refuses to run at all.
519
+ """
520
+ opened = []
521
+ for shard in discover_message_shards(db_path):
522
+ derived_key, derived_salt = derive_database_key(
523
+ shard, key_hex, salt_hex, passphrase)
524
+ # Try the derived key first; fall back to the configured pair so
525
+ # installs without a passphrase keep working exactly as before.
526
+ candidates = [(derived_key, derived_salt)]
527
+ if (derived_key, derived_salt) != (key_hex, salt_hex):
528
+ candidates.append((key_hex, salt_hex))
529
+ for candidate_key, candidate_salt in candidates:
530
+ try:
531
+ conn, _ = connect_nt_db(shard, candidate_key, candidate_salt)
532
+ # PRAGMA key alone never fails; only a read surfaces a bad key.
533
+ conn.execute('SELECT count(*) FROM sqlite_master').fetchone()
534
+ except Exception:
535
+ continue
536
+ opened.append(conn)
537
+ break
538
+ return opened
539
+
540
+
541
+ def msg_tables(conn):
542
+ """Names of the per-conversation Msg_ tables in one shard."""
543
+ try:
544
+ rows = conn.execute(
545
+ "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg\\_%' ESCAPE '\\'"
546
+ ).fetchall()
547
+ except Exception:
548
+ return set()
549
+ return {row[0] for row in rows}
550
+
551
+
552
+ def get_fav_schema(db_path, key_hex):
553
+ """Dump favorite.db schema + sample rows (key verification / exploration)."""
554
+ try:
555
+ with open(db_path, 'rb') as fh:
556
+ salt = fh.read(16).hex()
557
+ conn = require_sqlcipher().connect(db_path)
558
+ conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
559
+ tables = conn.execute(
560
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
561
+ ).fetchall()
562
+ result = {"success": True, "tables": [t[0] for t in tables]}
563
+ if not tables:
564
+ result["error"] = "数据库为空或密钥错误"
565
+ result["success"] = False
566
+ conn.close()
567
+ return result
568
+ except Exception as e:
569
+ return {"success": False, "error": str(e).split('\n')[0][:200]}
570
+
571
+
572
+ FAV_TYPE_NAMES = {
573
+ 1: 'text', # 文字
574
+ 2: 'image', # 图片
575
+ 4: 'video', # 视频
576
+ 5: 'article', # 公众号文章/网页链接
577
+ 14: 'chatrecord', # 聊天记录
578
+ }
579
+
580
+
581
+ def parse_fav_content(content):
582
+ """Extract display fields (title/link/desc/source/cover) from favitem XML."""
583
+ out = {}
584
+ if not content:
585
+ return out
586
+ import xml.etree.ElementTree as ET
587
+ try:
588
+ root = ET.fromstring(content)
589
+ except ET.ParseError:
590
+ return out
591
+
592
+ def text(path):
593
+ el = root.find(path)
594
+ if el is not None and el.text and el.text.strip():
595
+ return el.text.strip()
596
+ return None
597
+
598
+ title = (text('weburlitem/pagetitle')
599
+ or text('datalist/dataitem/datatitle')
600
+ or text('title')
601
+ or text('desc'))
602
+ if title:
603
+ out['title'] = title
604
+ link = (text('weburlitem/clean_url')
605
+ or text('source/link')
606
+ or text('datalist/dataitem/stream_weburl'))
607
+ if link:
608
+ out['link'] = link
609
+ desc = text('weburlitem/pagedesc') or text('datalist/dataitem/datadesc')
610
+ if desc and desc != out.get('title'):
611
+ out['desc'] = desc
612
+ src = text('weburlitem/appmsgshareitem/srcdisplayname')
613
+ if src:
614
+ out['source_name'] = src
615
+ cover = (text('weburlitem/pagethumb_url')
616
+ or text('datalist/dataitem/dataext')
617
+ or text('datalist/dataitem/cdn_thumburl'))
618
+ if cover:
619
+ out['cover'] = cover
620
+ fmt = text('datalist/dataitem/datafmt')
621
+ if fmt:
622
+ out['format'] = fmt
623
+ return out
624
+
625
+
626
+ def get_favorites(db_path, key_hex, limit=100, offset=0, keyword=None, fav_type=None):
627
+ """List favorite items from favorite.db with parsed content."""
628
+ try:
629
+ with open(db_path, 'rb') as fh:
630
+ salt = fh.read(16).hex()
631
+ conn = require_sqlcipher().connect(db_path)
632
+ conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
633
+ c = conn.cursor()
634
+
635
+ tables = [t[0] for t in c.execute(
636
+ "SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
637
+ if 'fav_db_item' not in tables:
638
+ conn.close()
639
+ return {"error": f"未找到 fav_db_item 表,现有表: {tables[:20]}"}
640
+
641
+ total = c.execute('SELECT count(*) FROM fav_db_item').fetchone()[0]
642
+
643
+ sql = ('SELECT local_id, server_id, type, update_time, fromusr, realchatname, content '
644
+ 'FROM fav_db_item WHERE 1=1')
645
+ params = []
646
+ if fav_type is not None:
647
+ sql += ' AND type = ?'
648
+ params.append(fav_type)
649
+ if keyword:
650
+ sql += ' AND content LIKE ?'
651
+ params.append(f'%{keyword}%')
652
+ sql += ' ORDER BY update_time DESC LIMIT ? OFFSET ?'
653
+ params.extend([limit, offset])
654
+ rows = c.execute(sql, params).fetchall()
655
+ conn.close()
656
+
657
+ items = []
658
+ for local_id, server_id, ftype, update_time, fromusr, realchatname, content in rows:
659
+ ct = content if isinstance(content, str) else (
660
+ content.decode('utf-8', errors='replace') if content else '')
661
+ item = {
662
+ 'local_id': local_id,
663
+ 'server_id': server_id,
664
+ 'type': ftype,
665
+ 'type_name': FAV_TYPE_NAMES.get(ftype, 'type_%s' % ftype),
666
+ 'update_time': update_time,
667
+ 'from_user': fromusr,
668
+ 'chat_name': realchatname or None,
669
+ }
670
+ item.update(parse_fav_content(ct))
671
+ items.append(item)
672
+ return {"favorites": items, "total": total, "count": len(items),
673
+ "limit": limit, "offset": offset}
674
+ except Exception as e:
675
+ return {"error": str(e).split('\n')[0][:200]}
676
+
677
+
678
+ def _summarise(row):
679
+ """(last_time, summary) from the newest message row of a conversation."""
680
+ last_time = row[0] or 0
681
+ msg_type = row[3] or 0
682
+ source_text = row[1]
683
+ content_text = row[2]
684
+
685
+ if msg_type == 1:
686
+ # Text message: use message_content
687
+ if isinstance(content_text, str) and content_text:
688
+ return last_time, content_text[:50]
689
+ if isinstance(content_text, bytes):
690
+ return last_time, content_text.decode('utf-8', errors='ignore')[:50]
691
+ return last_time, ""
692
+
693
+ if isinstance(source_text, str) and source_text:
694
+ # Non-text: try to extract from source, stripping XML tags
695
+ clean = re.sub(r'<[^>]+>', '', source_text)
696
+ lines = clean.split('\n')
697
+ if len(lines) > 1 and lines[1].strip():
698
+ return last_time, lines[1].strip()[:50]
699
+ if clean.strip():
700
+ return last_time, clean.strip()[:50]
701
+ return last_time, ""
702
+
703
+
704
+ def get_sessions(conns):
705
+ """Get chat sessions, newest message per conversation across every shard."""
706
+ sessions = {}
707
+
708
+ # NT format: each chat has its own Msg_<MD5> table
709
+ # The Name2Id table maps usernames to IDs (user_name, is_session)
710
+ for conn in conns:
711
+ c = conn.cursor()
712
+ try:
713
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1 LIMIT 500")
714
+ usernames = [row[0] for row in c.fetchall() if row[0]]
715
+ except Exception:
716
+ continue
717
+
718
+ tables = msg_tables(conn)
719
+ for username in usernames:
720
+ entry = sessions.setdefault(username, {"summary": "", "last_time": 0})
721
+ msg_table = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
722
+ if msg_table not in tables:
723
+ continue
724
+ try:
725
+ c.execute(f'SELECT create_time, source, message_content, local_type FROM "{msg_table}" ORDER BY create_time DESC LIMIT 1')
726
+ row = c.fetchone()
727
+ if row:
728
+ last_time, summary = _summarise(row)
729
+ # A conversation spans shards; only the newest speaks for it.
730
+ if last_time >= entry["last_time"]:
731
+ entry["last_time"] = last_time
732
+ entry["summary"] = summary
733
+ except Exception:
734
+ continue
735
+
736
+ result = [{
737
+ "username": username,
738
+ "type": 1 if "@chatroom" in username else 0,
739
+ "unreadCount": 0,
740
+ "summary": entry["summary"],
741
+ "sortTimestamp": entry["last_time"],
742
+ "lastTimestamp": entry["last_time"],
743
+ "displayName": username,
744
+ } for username, entry in sessions.items()]
745
+
746
+ # Sort by timestamp descending
747
+ result.sort(key=lambda s: s.get("sortTimestamp", 0), reverse=True)
748
+ return {"sessions": result}
749
+
750
+
751
+ def _strip_group_speaker(content, known_ids):
752
+ """Drop the `wxid_...:` prefix group rows carry in their content.
753
+
754
+ Only an id the shard's Name2Id actually knows is accepted, so a message
755
+ that merely starts with `note: ...` is left alone.
756
+ """
757
+ match = re.match(r'^([A-Za-z0-9_@.-]{5,64})\s*[::]\s', str(content or ''))
758
+ if not match or match.group(1) not in known_ids:
759
+ return content
760
+ return content[match.end():]
761
+
762
+
763
+ def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
764
+ """One message row -> the CLI's message shape."""
765
+ local_type = row[2] or 0
766
+ create_time = row[5] or 0
767
+ real_sender_id = row[4] or 0
768
+
769
+ # Resolve sender: real_sender_id -> Name2Id -> user_name
770
+ sender_username = sender_id_map.get(real_sender_id, "")
771
+
772
+ # Determine if message is from self
773
+ # own_wxid may have _xxxx suffix (from xwechat_files dir), try both
774
+ is_self = bool(own_wxid and (
775
+ sender_username == own_wxid or
776
+ (own_wxid.endswith('_') is False and sender_username.startswith(own_wxid))
777
+ ))
778
+ if not is_self and own_wxid:
779
+ # Strip _xxxx suffix and retry
780
+ parts = own_wxid.rsplit('_', 1)
781
+ if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
782
+ is_self = (sender_username == parts[0])
783
+
784
+ # Resolve sender display name from contact map
785
+ if is_self:
786
+ sender_display = "" # Let the CLI show "我"
787
+ else:
788
+ sender_display = name_map.get(sender_username, sender_username) if sender_username else sender_username
789
+
790
+ # Parse message_content - TEXT column
791
+ content = row[12] if isinstance(row[12], str) else ""
792
+
793
+ # `content`/`rawContent` stay exactly as stored; only `parsedContent` - the
794
+ # field every consumer reads first - gets the display-ready form.
795
+ display = _strip_group_speaker(content, set(sender_id_map.values())) if is_group else content
796
+
797
+ return {
798
+ "localId": row[0] or 0,
799
+ "serverId": str(row[1] or ''),
800
+ "localType": local_type,
801
+ "createTime": create_time,
802
+ "isSend": 1 if is_self else 0, # 1 = I sent this
803
+ "senderUsername": sender_username,
804
+ "senderDisplay": sender_display,
805
+ "content": content,
806
+ "rawContent": content,
807
+ "parsedContent": display[:200] if local_type == 1 else "",
808
+ }
809
+
810
+
811
+ def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None):
812
+ """Get messages for a specific talker, merged across every shard.
813
+
814
+ Args:
815
+ name_map: optional {wxid: display_name} dict for resolving sender names
816
+ own_wxid: account owner wxid for self-message detection
817
+ """
818
+ if name_map is None:
819
+ name_map = {}
820
+
821
+ msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
822
+ is_group = '@chatroom' in talker
823
+
824
+ # Each shard only needs to yield its newest window: once every shard's rows
825
+ # are merged and re-sorted, nothing older than that can reach this page.
826
+ window = 0 if limit <= 0 else limit + offset
827
+ collected = []
828
+ found = False
829
+
830
+ for conn in conns:
831
+ c = conn.cursor()
832
+ try:
833
+ c.execute("SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
834
+ if c.fetchone()[0] == 0:
835
+ continue
836
+ found = True
837
+
838
+ sql = f'''
839
+ SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
840
+ create_time, status, upload_status, download_status,
841
+ server_seq, origin_source, source, message_content, compress_content
842
+ FROM "{msg_table}"
843
+ ORDER BY create_time DESC, local_id DESC
844
+ '''
845
+ if window:
846
+ c.execute(sql + ' LIMIT ?', (window,))
847
+ else:
848
+ c.execute(sql)
849
+ rows = c.fetchall()
850
+
851
+ # Sender ids are rowids, so the map has to come from the same shard.
852
+ c.execute("SELECT rowid, user_name FROM Name2Id")
853
+ sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
854
+ except Exception:
855
+ continue
856
+
857
+ for row in rows:
858
+ collected.append(_message_dict(row, sender_id_map, name_map, own_wxid, is_group))
859
+
860
+ if not found:
861
+ return {"error": f"未找到会话: {talker}"}
862
+
863
+ collected.sort(key=lambda m: (m["createTime"], m["localId"]), reverse=True)
864
+ if limit > 0:
865
+ collected = collected[offset:offset + limit]
866
+ return {"messages": collected}
867
+
868
+
869
+ def get_contacts(conns, limit=200):
870
+ """Get contacts from NT database, merged across every shard."""
871
+ seen = set()
872
+ contacts = []
873
+ for conn in conns:
874
+ try:
875
+ rows = conn.execute("SELECT user_name FROM Name2Id LIMIT ?", (limit,)).fetchall()
876
+ except Exception:
877
+ continue
878
+ for (username,) in rows:
879
+ # Name2Id carries a placeholder row with no user_name; rendering it
880
+ # produced a blank line at the top of every contact list.
881
+ if not username or username in seen:
882
+ continue
883
+ seen.add(username)
884
+ contacts.append({"username": username, "displayName": username})
885
+ return {"contacts": contacts}
886
+
887
+
888
+ # ========== SNS (朋友圈) Queries ==========
889
+
890
+ def parse_sns_content(content_str):
891
+ """Parse SNS content XML/Protobuf text to extract title, description, media etc."""
892
+ result = {
893
+ 'content': '',
894
+ 'create_time': 0,
895
+ 'username': '',
896
+ 'object_id': '',
897
+ 'media_count': 0,
898
+ }
899
+ if not content_str:
900
+ return result
901
+
902
+ import re
903
+
904
+ # Extract createTime
905
+ m = re.search(r'<createTime>(\d+)</createTime>', content_str)
906
+ if m:
907
+ result['create_time'] = int(m.group(1))
908
+
909
+ # Extract username
910
+ m = re.search(r'<username>([^<]+)</username>', content_str)
911
+ if m:
912
+ result['username'] = m.group(1)
913
+
914
+ # Extract id
915
+ m = re.search(r'<id>(\d+)</id>', content_str)
916
+ if m:
917
+ result['object_id'] = m.group(1)
918
+
919
+ # Extract contentDesc (main text)
920
+ m = re.search(r'<contentDesc>([^<]*)</contentDesc>', content_str)
921
+ if m:
922
+ result['content'] = m.group(1)
923
+
924
+ # Extract contentDesc CDATA
925
+ m = re.search(r'<contentDesc>\s*<!\[CDATA\[(.*?)\]\]>\s*</contentDesc>', content_str, re.DOTALL)
926
+ if m:
927
+ result['content'] = m.group(1).strip()
928
+
929
+ # Extract title if present
930
+ m = re.search(r'<title>([^<]*)</title>', content_str)
931
+ if m:
932
+ title = m.group(1)
933
+ if title and not result['content']:
934
+ result['content'] = title
935
+ elif title:
936
+ result['content'] = title + '\n' + result['content']
937
+
938
+ # Count media (ContentObject tags)
939
+ result['media_count'] = len(re.findall(r'<ContentObject[ >]', content_str))
940
+
941
+ return result
942
+
943
+
944
+ def get_sns_timeline(cursor, limit=20, offset=0, usernames=None, keyword=None,
945
+ start_time=None, end_time=None):
946
+ """Query SNS timeline posts from SnsTimeLine table."""
947
+ # Check if SnsTimeLine exists, fall back to SnsTopItem_1
948
+ tables = [r[0] for r in cursor.execute(
949
+ "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
950
+
951
+ table = 'SnsTimeLine' if 'SnsTimeLine' in tables else None
952
+ if not table:
953
+ table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else None
954
+ if not table:
955
+ return {'success': False, 'error': 'No SNS table found'}
956
+
957
+ conditions = []
958
+ params = []
959
+
960
+ if table == 'SnsTimeLine':
961
+ if usernames:
962
+ placeholders = ','.join(['?' for _ in usernames])
963
+ conditions.append(f'user_name IN ({placeholders})')
964
+ params.extend(usernames)
965
+ if keyword:
966
+ conditions.append('content LIKE ?')
967
+ params.append(f'%{keyword}%')
968
+ if start_time:
969
+ conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) >= ?")
970
+ params.append(start_time)
971
+ if end_time:
972
+ conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) <= ?")
973
+ params.append(end_time)
974
+ elif table == 'SnsTopItem_1':
975
+ uname_col = 'username' if 'username' in [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()] else 'user_name'
976
+ if usernames:
977
+ placeholders = ','.join(['?' for _ in usernames])
978
+ conditions.append(f'{uname_col} IN ({placeholders})')
979
+ params.extend(usernames)
980
+ if keyword:
981
+ conditions.append('summary LIKE ?')
982
+ params.append(f'%{keyword}%')
983
+ if start_time:
984
+ conditions.append('create_time >= ?')
985
+ params.append(start_time)
986
+ if end_time:
987
+ conditions.append('create_time <= ?')
988
+ params.append(end_time)
989
+
990
+ where = ' AND '.join(conditions) if conditions else '1=1'
991
+ order = 'tid DESC' if table == 'SnsTimeLine' else 'create_time DESC'
992
+
993
+ try:
994
+ cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
995
+ # Only select known text columns to avoid binary decode errors
996
+ text_cols = [c for c in cols if c in ('tid', 'user_name', 'content', 'username', 'summary',
997
+ 'create_time', 'last_read_time', 'is_read',
998
+ 'from_username', 'from_nickname', 'to_username',
999
+ 'to_nickname', 'comment_id', 'feed_id',
1000
+ 'createTime', 'userName')]
1001
+ if not text_cols:
1002
+ text_cols = ['*']
1003
+ select_str = ', '.join(text_cols)
1004
+ rows = cursor.execute(
1005
+ f'SELECT {select_str} FROM [{table}] WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?',
1006
+ params + [limit, offset]
1007
+ ).fetchall()
1008
+
1009
+ timeline = []
1010
+
1011
+ for row in rows:
1012
+ item = dict(zip(text_cols, row))
1013
+
1014
+ if table == 'SnsTimeLine':
1015
+ # Parse XML content
1016
+ content_str = item.get('content', '') or ''
1017
+ parsed = parse_sns_content(content_str)
1018
+ create_time = parsed['create_time'] or 0
1019
+ username = parsed['username'] or item.get('user_name', '')
1020
+ text = parsed['content'] or ''
1021
+ media_count = parsed['media_count']
1022
+ else:
1023
+ create_time = item.get('create_time', 0) or 0
1024
+ username = item.get('username', '') or item.get('user_name', '')
1025
+ text = item.get('summary', '') or ''
1026
+ media_count = 0
1027
+
1028
+ timeline.append({
1029
+ 'create_time': create_time,
1030
+ 'username': username,
1031
+ 'content': text,
1032
+ 'media_count': media_count,
1033
+ 'table': table,
1034
+ })
1035
+
1036
+ return {'success': True, 'timeline': timeline, 'table': table}
1037
+ except Exception as e:
1038
+ return {'success': False, 'error': str(e)}
1039
+
1040
+
1041
+ def get_sns_usernames(cursor):
1042
+ """Get unique usernames from SnsTopItem_1."""
1043
+ tables = [r[0] for r in cursor.execute(
1044
+ "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1045
+
1046
+ # Prefer SnsTopItem_1 for user listing (larger)
1047
+ table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
1048
+ if not table:
1049
+ return {'success': False, 'error': 'No SNS table found'}
1050
+
1051
+ # Discover username column
1052
+ cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1053
+ uname_col = 'username' if 'username' in cols else 'user_name'
1054
+
1055
+ try:
1056
+ rows = cursor.execute(
1057
+ f'SELECT [{uname_col}], COUNT(*) as cnt FROM [{table}] GROUP BY [{uname_col}] ORDER BY cnt DESC'
1058
+ ).fetchall()
1059
+ usernames = [r[0] for r in rows if r[0]]
1060
+ counts = {r[0]: r[1] for r in rows if r[0]}
1061
+ return {'success': True, 'usernames': usernames, 'counts': counts}
1062
+ except Exception as e:
1063
+ return {'success': False, 'error': str(e)}
1064
+
1065
+
1066
+ def get_sns_stats(cursor, my_wxid=None):
1067
+ """Get SNS statistics from SnsTopItem_1."""
1068
+ tables = [r[0] for r in cursor.execute(
1069
+ "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1070
+
1071
+ table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
1072
+ if not table:
1073
+ return {'success': False, 'error': 'No SNS table found'}
1074
+
1075
+ try:
1076
+ total = cursor.execute(f'SELECT COUNT(*) FROM [{table}]').fetchone()[0]
1077
+
1078
+ cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1079
+ uname_col = 'username' if 'username' in cols else 'user_name'
1080
+
1081
+ total_friends = 0
1082
+ if uname_col:
1083
+ total_friends = cursor.execute(
1084
+ f'SELECT COUNT(DISTINCT [{uname_col}]) FROM [{table}] WHERE [{uname_col}] IS NOT NULL'
1085
+ ).fetchone()[0]
1086
+
1087
+ my_posts = None
1088
+ if my_wxid and uname_col:
1089
+ my_posts = cursor.execute(
1090
+ f'SELECT COUNT(*) FROM [{table}] WHERE [{uname_col}] = ?',
1091
+ (my_wxid,)).fetchone()[0]
1092
+
1093
+ return {
1094
+ 'success': True,
1095
+ 'data': {
1096
+ 'totalPosts': total,
1097
+ 'totalFriends': total_friends,
1098
+ 'myPosts': my_posts,
1099
+ }
1100
+ }
1101
+ except Exception as e:
1102
+ return {'success': False, 'error': str(e)}
1103
+
1104
+
1105
+ # ========== Main CLI ==========
1106
+
1107
+ def main():
1108
+ import argparse
1109
+ parser = argparse.ArgumentParser(description='WeChat NT Database Tool')
1110
+ sub = parser.add_subparsers(dest='command')
1111
+
1112
+ # scan command
1113
+ scan_parser = sub.add_parser('scan', help='Scan memory for keys and match NT databases')
1114
+ scan_parser.add_argument('--json', action='store_true', help='Output as JSON')
1115
+ scan_parser.add_argument('--root', default=os.environ.get('WEFLOW_SCAN_ROOT'), help='xwechat_files root directory override')
1116
+
1117
+ # sessions command
1118
+ sessions_parser = sub.add_parser('sessions', help='List chat sessions')
1119
+ 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')
1120
+ 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)')
1121
+ 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)')
1122
+ sessions_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1123
+ sessions_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1124
+ sessions_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1125
+ sessions_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1126
+ sessions_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1127
+
1128
+ # messages command
1129
+ msg_parser = sub.add_parser('messages', help='Get messages')
1130
+ 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')
1131
+ 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)')
1132
+ 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)')
1133
+ msg_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1134
+ msg_parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
1135
+ msg_parser.add_argument('--limit', type=int, default=100)
1136
+ msg_parser.add_argument('--offset', type=int, default=0)
1137
+ msg_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for sender names')
1138
+ msg_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1139
+ msg_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1140
+ msg_parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid (for self-message detection)')
1141
+
1142
+ # contacts command
1143
+ contacts_parser = sub.add_parser('contacts', help='List contacts')
1144
+ 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')
1145
+ 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)')
1146
+ 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)')
1147
+ contacts_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1148
+ contacts_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1149
+ contacts_parser.add_argument('--limit', type=int, default=200)
1150
+ contacts_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1151
+ contacts_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1152
+ contacts_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1153
+
1154
+ # sns-timeline command
1155
+ sns_tl_parser = sub.add_parser('sns-timeline', help='Get SNS/Moments timeline')
1156
+ 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')
1157
+ 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)')
1158
+ 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)')
1159
+ sns_tl_parser.add_argument('--limit', type=int, default=20)
1160
+ sns_tl_parser.add_argument('--offset', type=int, default=0)
1161
+ sns_tl_parser.add_argument('--usernames', default=os.environ.get('WEFLOW_QUERY_USERNAMES'), help='JSON array of usernames to filter')
1162
+ sns_tl_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
1163
+ sns_tl_parser.add_argument('--start-time', type=int, help='Start timestamp')
1164
+ sns_tl_parser.add_argument('--end-time', type=int, help='End timestamp')
1165
+
1166
+ # sns-usernames command
1167
+ sns_un_parser = sub.add_parser('sns-usernames', help='List usernames with SNS posts')
1168
+ 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')
1169
+ 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)')
1170
+ 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)')
1171
+
1172
+ # sns-stats command
1173
+ sns_stats_parser = sub.add_parser('sns-stats', help='SNS statistics')
1174
+ 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')
1175
+ 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)')
1176
+ 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)')
1177
+ sns_stats_parser.add_argument('--my-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid for my-posts count')
1178
+
1179
+ # fav-schema command
1180
+ fav_schema_parser = sub.add_parser('fav-schema', help='Dump favorite.db schema (key verification)')
1181
+ 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')
1182
+ 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)')
1183
+
1184
+ verify_parser = sub.add_parser('verify', help='Verify a key+salt can open a database')
1185
+ 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')
1186
+ 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)')
1187
+ 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)')
1188
+
1189
+ # fav-list command
1190
+ fav_list_parser = sub.add_parser('fav-list', help='List favorite items')
1191
+ 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')
1192
+ 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)')
1193
+ fav_list_parser.add_argument('--limit', type=int, default=100)
1194
+ fav_list_parser.add_argument('--offset', type=int, default=0)
1195
+ fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
1196
+ fav_list_parser.add_argument('--type', type=int, dest='fav_type',
1197
+ help='Filter by type: 1=text 2=image 4=video 5=article 14=chatrecord')
1198
+
1199
+ args = parser.parse_args()
1200
+
1201
+ if args.command == 'scan':
1202
+ # Discover the database files before touching the process. Walking the
1203
+ # filesystem does not need WeChat running, but key matching does - and
1204
+ # the caller derives keys from the passphrase when the memory scan
1205
+ # finds nothing, which only needs the file list. Returning early on
1206
+ # "process not running" without the databases threw that list away and
1207
+ # left the caller with nothing to derive from.
1208
+ databases = find_nt_databases(getattr(args, 'root', None))
1209
+
1210
+ pid = find_weixin_pid()
1211
+ if not pid:
1212
+ if IS_WINDOWS:
1213
+ print(json.dumps({"error": "Weixin.exe 未运行", "databases": databases}))
1214
+ else:
1215
+ print(json.dumps({"error": "未检测到 Linux 微信进程,请确认微信已启动并登录",
1216
+ "databases": databases}))
1217
+ return
1218
+
1219
+ if not args.json:
1220
+ print(f"扫描进程 PID {pid}...")
1221
+
1222
+ keys, scan_err = scan_memory_keys(pid)
1223
+ if scan_err == 'permission':
1224
+ print(json.dumps({"error": "PERMISSION_DENIED: 读取微信进程内存需要 root 或 CAP_SYS_PTRACE 权限。可使用 sudo 运行,或执行: sudo setcap cap_sys_ptrace=ep $(which python3)",
1225
+ "databases": databases}))
1226
+ return
1227
+ if scan_err == 'gone':
1228
+ print(json.dumps({"error": "微信进程已退出,请重试", "databases": databases}))
1229
+ return
1230
+ if not args.json:
1231
+ print(f"找到 {len(keys)} 个密钥")
1232
+
1233
+ if not args.json:
1234
+ print(f"找到 {len(databases)} 个 NT 数据库")
1235
+
1236
+ matched = match_keys_to_databases(keys, databases)
1237
+ if not args.json:
1238
+ print(f"匹配 {len(matched)} 个数据库")
1239
+ for db in matched:
1240
+ print(f" {db['name']} ({db['size']/1024/1024:.1f}MB) key={db['key'][:16]}... salt={db['salt'][:16]}...")
1241
+ else:
1242
+ print(json.dumps({"keys": keys, "databases": databases, "matched": matched}))
1243
+ return
1244
+
1245
+ if args.command == 'verify':
1246
+ # sqlcipher 打开+读 sqlite_master 才触发解密, 错误密钥在此报 "file is not a database"
1247
+ try:
1248
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
1249
+ n = conn.execute(
1250
+ "SELECT count(*) FROM sqlite_master WHERE type='table'"
1251
+ ).fetchone()[0]
1252
+ conn.close()
1253
+ print(json.dumps({"success": n > 0, "tables": n}, ensure_ascii=True))
1254
+ except Exception as e:
1255
+ print(json.dumps({"success": False, "error": str(e).split('\n')[0][:200]}, ensure_ascii=True))
1256
+ return
1257
+
1258
+ # Build contact name map once if contact db provided
1259
+ contact_name_map = {}
1260
+ contact_db = getattr(args, 'contact_db', None)
1261
+ contact_key = getattr(args, 'contact_key', None)
1262
+ contact_salt = getattr(args, 'contact_salt', None)
1263
+ if contact_db and contact_key and contact_salt:
1264
+ contact_name_map = load_contact_names(contact_db, contact_key, contact_salt)
1265
+
1266
+ if args.command == 'sessions':
1267
+ conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1268
+ if not conns:
1269
+ print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1270
+ return
1271
+ result = get_sessions(conns)
1272
+ if 'sessions' in result:
1273
+ result['sessions'] = apply_contact_names(result['sessions'], contact_name_map)
1274
+ if args.keyword:
1275
+ kw = args.keyword.lower()
1276
+ result['sessions'] = [
1277
+ s for s in result['sessions']
1278
+ if kw in (s.get('username', '') + s.get('displayName', '') + s.get('summary', '')).lower()
1279
+ ]
1280
+ print(json.dumps(result, ensure_ascii=True))
1281
+ for conn in conns:
1282
+ conn.close()
1283
+
1284
+ elif args.command == 'messages':
1285
+ conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1286
+ if not conns:
1287
+ print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1288
+ return
1289
+ own_wxid = getattr(args, 'own_wxid', None)
1290
+ result = get_messages(conns, args.talker, args.limit, args.offset, contact_name_map, own_wxid)
1291
+ print(json.dumps(result, ensure_ascii=True))
1292
+ for conn in conns:
1293
+ conn.close()
1294
+
1295
+ elif args.command == 'contacts':
1296
+ conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1297
+ if not conns:
1298
+ print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1299
+ return
1300
+ result = get_contacts(conns, args.limit)
1301
+ if 'contacts' in result:
1302
+ result['contacts'] = apply_contact_names(result['contacts'], contact_name_map)
1303
+ if args.keyword:
1304
+ kw = args.keyword.lower()
1305
+ result['contacts'] = [
1306
+ c for c in result['contacts']
1307
+ if kw in (c.get('username', '') + c.get('displayName', '') + c.get('remark', '') + c.get('nickname', '')).lower()
1308
+ ]
1309
+ print(json.dumps(result, ensure_ascii=True))
1310
+ for conn in conns:
1311
+ conn.close()
1312
+
1313
+ elif args.command == 'sns-timeline':
1314
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
1315
+ usernames = None
1316
+ if args.usernames:
1317
+ try:
1318
+ usernames = json.loads(args.usernames)
1319
+ except: pass
1320
+ result = get_sns_timeline(conn.cursor(), args.limit, args.offset,
1321
+ usernames, args.keyword,
1322
+ args.start_time, args.end_time)
1323
+ print(json.dumps(result, ensure_ascii=True, default=str))
1324
+ conn.close()
1325
+
1326
+ elif args.command == 'sns-usernames':
1327
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
1328
+ result = get_sns_usernames(conn.cursor())
1329
+ print(json.dumps(result, ensure_ascii=True, default=str))
1330
+ conn.close()
1331
+
1332
+ elif args.command == 'sns-stats':
1333
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
1334
+ result = get_sns_stats(conn.cursor(), args.my_wxid)
1335
+ print(json.dumps(result, ensure_ascii=True, default=str))
1336
+ conn.close()
1337
+
1338
+ elif args.command == 'fav-schema':
1339
+ result = get_fav_schema(args.db, args.key)
1340
+ print(json.dumps(result, ensure_ascii=True))
1341
+
1342
+ elif args.command == 'fav-list':
1343
+ result = get_favorites(args.db, args.key, args.limit, args.offset,
1344
+ args.keyword, getattr(args, 'fav_type', None))
1345
+ print(json.dumps(result, ensure_ascii=True, default=str))
1346
+
1347
+ else:
1348
+ parser.print_help()
1349
+
1350
+
1351
+ if __name__ == '__main__':
1352
+ main()