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