weflow-cli 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/weflow-cli-electron.cjs +59 -0
- package/bin/weflow-cli.ts +2311 -0
- package/cli.cjs +3 -0
- package/dist/bin/weflow-cli.d.ts +3 -0
- package/dist/bin/weflow-cli.d.ts.map +1 -0
- package/dist/bin/weflow-cli.js +2270 -0
- package/dist/bin/weflow-cli.js.map +1 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.d.ts.map +1 -0
- package/dist/mcp-server/index.js +690 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/core/dbPathService.d.ts +40 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -0
- package/dist/src/core/dbPathService.js +324 -0
- package/dist/src/core/dbPathService.js.map +1 -0
- package/dist/src/core/keyService.d.ts +59 -0
- package/dist/src/core/keyService.d.ts.map +1 -0
- package/dist/src/core/keyService.js +368 -0
- package/dist/src/core/keyService.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +62 -0
- package/dist/src/core/ntCore.d.ts.map +1 -0
- package/dist/src/core/ntCore.js +191 -0
- package/dist/src/core/ntCore.js.map +1 -0
- package/dist/src/core/sqlcipherCore.d.ts +119 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
- package/dist/src/core/sqlcipherCore.js +693 -0
- package/dist/src/core/sqlcipherCore.js.map +1 -0
- package/dist/src/core/wcdbCore.d.ts +738 -0
- package/dist/src/core/wcdbCore.d.ts.map +1 -0
- package/dist/src/core/wcdbCore.js +4545 -0
- package/dist/src/core/wcdbCore.js.map +1 -0
- package/dist/src/core/wechatClient.d.ts +23 -0
- package/dist/src/core/wechatClient.d.ts.map +1 -0
- package/dist/src/core/wechatClient.js +166 -0
- package/dist/src/core/wechatClient.js.map +1 -0
- package/dist/src/services/chatService.d.ts +25 -0
- package/dist/src/services/chatService.d.ts.map +1 -0
- package/dist/src/services/chatService.js +291 -0
- package/dist/src/services/chatService.js.map +1 -0
- package/dist/src/services/configService.d.ts +43 -0
- package/dist/src/services/configService.d.ts.map +1 -0
- package/dist/src/services/configService.js +156 -0
- package/dist/src/services/configService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +30 -0
- package/dist/src/services/exportService.d.ts.map +1 -0
- package/dist/src/services/exportService.js +273 -0
- package/dist/src/services/exportService.js.map +1 -0
- package/dist/src/services/wechat-formatter.d.ts +69 -0
- package/dist/src/services/wechat-formatter.d.ts.map +1 -0
- package/dist/src/services/wechat-formatter.js +248 -0
- package/dist/src/services/wechat-formatter.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +28 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -0
- package/dist/src/services/wechatMessageService.js +341 -0
- package/dist/src/services/wechatMessageService.js.map +1 -0
- package/dist/src/services/wereadService.d.ts +206 -0
- package/dist/src/services/wereadService.d.ts.map +1 -0
- package/dist/src/services/wereadService.js +145 -0
- package/dist/src/services/wereadService.js.map +1 -0
- package/dist/src/services/whitelistService.d.ts +20 -0
- package/dist/src/services/whitelistService.d.ts.map +1 -0
- package/dist/src/services/whitelistService.js +60 -0
- package/dist/src/services/whitelistService.js.map +1 -0
- package/dist/src/types.d.ts +140 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/errors.d.ts +25 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +43 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/pathUtils.d.ts +5 -0
- package/dist/src/utils/pathUtils.d.ts.map +1 -0
- package/dist/src/utils/pathUtils.js +16 -0
- package/dist/src/utils/pathUtils.js.map +1 -0
- package/dist/src/utils/pythonRunner.d.ts +27 -0
- package/dist/src/utils/pythonRunner.d.ts.map +1 -0
- package/dist/src/utils/pythonRunner.js +62 -0
- package/dist/src/utils/pythonRunner.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +14 -0
- package/dist/src/utils/talkerUtils.d.ts.map +1 -0
- package/dist/src/utils/talkerUtils.js +73 -0
- package/dist/src/utils/talkerUtils.js.map +1 -0
- package/mcp-server/index.ts +757 -0
- package/package.json +72 -0
- package/resources/key/win32/x64/wx_key.dll +0 -0
- package/resources/wcdb/win32/x64/SDL2.dll +0 -0
- package/resources/wcdb/win32/x64/WCDB.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
- package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
- package/scripts/_batch_link_sources.py +73 -0
- package/scripts/_utils.py +531 -0
- package/scripts/annual_report.py +796 -0
- package/scripts/auto_tag.py +128 -0
- package/scripts/biz_daily.py +896 -0
- package/scripts/chat_report.py +397 -0
- package/scripts/chat_stats.py +702 -0
- package/scripts/classify_daily.py +411 -0
- package/scripts/compile_wiki.py +258 -0
- package/scripts/create_reading_notes.py +370 -0
- package/scripts/enrich_backlinks.py +163 -0
- package/scripts/export_chat_html.py +766 -0
- package/scripts/extract_3x_key.py +86 -0
- package/scripts/extract_todos.py +404 -0
- package/scripts/fav_server.py +392 -0
- package/scripts/fix_topics.py +111 -0
- package/scripts/generate_ai_report.py +367 -0
- package/scripts/generate_html.py +1372 -0
- package/scripts/generate_review.py +170 -0
- package/scripts/mcp_bridge.py +398 -0
- package/scripts/nt_decrypt.py +562 -0
- package/scripts/pipeline.py +145 -0
- package/scripts/promote_all.py +310 -0
- package/scripts/promote_ideas.py +297 -0
- package/scripts/rag_chat.py +191 -0
- package/scripts/scan_decrypt_4x.py +329 -0
- package/scripts/semantic_search.py +431 -0
- package/scripts/sync_fav.py +146 -0
- package/scripts/sync_weread.py +192 -0
- package/scripts/vault_rag.py +139 -0
- package/scripts/vault_search.py +141 -0
- package/src/core/dbPathService.ts +348 -0
- package/src/core/keyService.ts +409 -0
- package/src/core/ntCore.ts +224 -0
- package/src/core/sqlcipherCore.ts +764 -0
- package/src/core/wcdbCore.ts +4568 -0
- package/src/core/wechatClient.ts +204 -0
- package/src/lz4.d.ts +10 -0
- package/src/services/chatService.ts +296 -0
- package/src/services/configService.ts +197 -0
- package/src/services/exportService.ts +305 -0
- package/src/services/wechat-formatter.ts +348 -0
- package/src/services/wechatMessageService.ts +383 -0
- package/src/services/wereadService.ts +308 -0
- package/src/services/whitelistService.ts +72 -0
- package/src/types.ts +141 -0
- package/src/utils/errors.ts +42 -0
- package/src/utils/pathUtils.ts +16 -0
- package/src/utils/pythonRunner.ts +81 -0
- package/src/utils/talkerUtils.ts +84 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
WeChat NT (4.x) Database Access Tool
|
|
4
|
+
Uses sqlcipher3 to decrypt and query NT-format databases.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import ctypes
|
|
11
|
+
from ctypes import wintypes, c_void_p, c_size_t, create_string_buffer, byref, sizeof
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from sqlcipher3 import dbapi2 as sqlcipher
|
|
16
|
+
except ImportError:
|
|
17
|
+
print(json.dumps({"error": "需要 sqlcipher3: pip install sqlcipher3"}))
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
|
|
20
|
+
# ========== Memory Scanner ==========
|
|
21
|
+
PROCESS_VM_READ = 0x0010
|
|
22
|
+
PROCESS_QUERY_INFORMATION = 0x0400
|
|
23
|
+
MEM_COMMIT = 0x1000
|
|
24
|
+
PAGE_NOACCESS = 0x01
|
|
25
|
+
PAGE_GUARD = 0x100
|
|
26
|
+
|
|
27
|
+
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
|
28
|
+
_fields_ = [
|
|
29
|
+
('BaseAddress', ctypes.c_void_p),
|
|
30
|
+
('AllocationBase', ctypes.c_void_p),
|
|
31
|
+
('AllocationProtect', wintypes.DWORD),
|
|
32
|
+
('PartitionId', wintypes.WORD),
|
|
33
|
+
('RegionSize', ctypes.c_size_t),
|
|
34
|
+
('State', wintypes.DWORD),
|
|
35
|
+
('Protect', wintypes.DWORD),
|
|
36
|
+
('Type', wintypes.DWORD),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
kernel32 = ctypes.windll.kernel32
|
|
40
|
+
ReadProcessMemory = kernel32.ReadProcessMemory
|
|
41
|
+
ReadProcessMemory.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
|
|
42
|
+
ReadProcessMemory.restype = wintypes.BOOL
|
|
43
|
+
VirtualQueryEx = kernel32.VirtualQueryEx
|
|
44
|
+
VirtualQueryEx.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
|
45
|
+
VirtualQueryEx.restype = ctypes.c_size_t
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def find_weixin_pid():
|
|
49
|
+
"""Find Weixin.exe process ID."""
|
|
50
|
+
try:
|
|
51
|
+
import pymem, pymem.process
|
|
52
|
+
for proc in pymem.process.list_processes():
|
|
53
|
+
try:
|
|
54
|
+
name = proc.szExeFile
|
|
55
|
+
if isinstance(name, bytes):
|
|
56
|
+
name = name.decode('utf-8', errors='ignore')
|
|
57
|
+
if name.lower() == 'weixin.exe':
|
|
58
|
+
return proc.th32ProcessID
|
|
59
|
+
except:
|
|
60
|
+
pass
|
|
61
|
+
except ImportError:
|
|
62
|
+
pass
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def scan_memory_keys(pid):
|
|
67
|
+
"""Scan process memory for x'<64hex_key><32hex_salt>' patterns."""
|
|
68
|
+
hProcess = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
|
69
|
+
if not hProcess:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
|
|
73
|
+
keys_found = []
|
|
74
|
+
address = 0x10000
|
|
75
|
+
|
|
76
|
+
while address < 0x7FFFFFFFFFFF:
|
|
77
|
+
mbi = MEMORY_BASIC_INFORMATION()
|
|
78
|
+
result = VirtualQueryEx(hProcess, ctypes.c_void_p(address), ctypes.byref(mbi), sizeof(mbi))
|
|
79
|
+
if result == 0:
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
region_addr = mbi.BaseAddress or 0
|
|
83
|
+
region_size = mbi.RegionSize or 0
|
|
84
|
+
|
|
85
|
+
if (mbi.State == MEM_COMMIT and
|
|
86
|
+
region_size > 256 and region_size < 200 * 1024 * 1024 and
|
|
87
|
+
mbi.Protect not in (0, PAGE_NOACCESS, PAGE_GUARD)):
|
|
88
|
+
|
|
89
|
+
pos = region_addr
|
|
90
|
+
end = region_addr + region_size
|
|
91
|
+
while pos < end:
|
|
92
|
+
chunk_size = min(65536, end - pos)
|
|
93
|
+
buf = create_string_buffer(chunk_size)
|
|
94
|
+
bytesRead = c_size_t(0)
|
|
95
|
+
ok = ReadProcessMemory(hProcess, ctypes.c_void_p(pos), buf, chunk_size, byref(bytesRead))
|
|
96
|
+
if ok and bytesRead.value > 0:
|
|
97
|
+
data = buf.raw[:bytesRead.value]
|
|
98
|
+
for m in pattern.finditer(data):
|
|
99
|
+
key_hex = m.group(1).decode()
|
|
100
|
+
salt_hex = m.group(2).decode()
|
|
101
|
+
keys_found.append((key_hex, salt_hex))
|
|
102
|
+
pos += chunk_size
|
|
103
|
+
|
|
104
|
+
address = region_addr + region_size
|
|
105
|
+
|
|
106
|
+
kernel32.CloseHandle(hProcess)
|
|
107
|
+
|
|
108
|
+
# Deduplicate
|
|
109
|
+
seen = set()
|
|
110
|
+
unique_keys = []
|
|
111
|
+
for k, s in keys_found:
|
|
112
|
+
pair = (k, s)
|
|
113
|
+
if pair not in seen:
|
|
114
|
+
seen.add(pair)
|
|
115
|
+
unique_keys.append({"key": k, "salt": s})
|
|
116
|
+
|
|
117
|
+
return unique_keys
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ========== NT Database Discovery ==========
|
|
121
|
+
|
|
122
|
+
def find_nt_databases():
|
|
123
|
+
"""Find all NT-format databases under xwechat_files (message + contact)."""
|
|
124
|
+
candidates = [
|
|
125
|
+
os.path.expandvars(r'%USERPROFILE%\xwechat_files'),
|
|
126
|
+
os.path.expandvars(r'%USERPROFILE%\Documents\xwechat_files'),
|
|
127
|
+
]
|
|
128
|
+
xwechat = None
|
|
129
|
+
for c in candidates:
|
|
130
|
+
if os.path.isdir(c):
|
|
131
|
+
xwechat = c
|
|
132
|
+
break
|
|
133
|
+
if not xwechat:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
databases = []
|
|
137
|
+
for wxid_dir in os.listdir(xwechat):
|
|
138
|
+
# Scan message databases
|
|
139
|
+
msg_storage = os.path.join(xwechat, wxid_dir, 'db_storage', 'message')
|
|
140
|
+
if os.path.isdir(msg_storage):
|
|
141
|
+
for f in os.listdir(msg_storage):
|
|
142
|
+
if f.endswith('.db') and not any(x in f for x in ['-shm', '-wal']):
|
|
143
|
+
full_path = os.path.join(msg_storage, f)
|
|
144
|
+
try:
|
|
145
|
+
with open(full_path, 'rb') as fh:
|
|
146
|
+
salt = fh.read(16)
|
|
147
|
+
databases.append({
|
|
148
|
+
"path": full_path,
|
|
149
|
+
"name": f"message/{f}",
|
|
150
|
+
"salt": salt.hex(),
|
|
151
|
+
"size": os.path.getsize(full_path),
|
|
152
|
+
"wxid": wxid_dir,
|
|
153
|
+
})
|
|
154
|
+
except:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
# Scan contact database
|
|
158
|
+
contact_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'contact', 'contact.db')
|
|
159
|
+
if os.path.isfile(contact_db):
|
|
160
|
+
try:
|
|
161
|
+
with open(contact_db, 'rb') as fh:
|
|
162
|
+
salt = fh.read(16)
|
|
163
|
+
databases.append({
|
|
164
|
+
"path": contact_db,
|
|
165
|
+
"name": "contact/contact.db",
|
|
166
|
+
"salt": salt.hex(),
|
|
167
|
+
"size": os.path.getsize(contact_db),
|
|
168
|
+
"wxid": wxid_dir,
|
|
169
|
+
})
|
|
170
|
+
except:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
# Scan SNS (朋友圈) database
|
|
174
|
+
sns_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'sns', 'sns.db')
|
|
175
|
+
if os.path.isfile(sns_db):
|
|
176
|
+
try:
|
|
177
|
+
with open(sns_db, 'rb') as fh:
|
|
178
|
+
salt = fh.read(16)
|
|
179
|
+
databases.append({
|
|
180
|
+
"path": sns_db,
|
|
181
|
+
"name": "sns/sns.db",
|
|
182
|
+
"salt": salt.hex(),
|
|
183
|
+
"size": os.path.getsize(sns_db),
|
|
184
|
+
"wxid": wxid_dir,
|
|
185
|
+
})
|
|
186
|
+
except:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
return databases
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def find_contact_db_path(message_db_path):
|
|
193
|
+
"""Derive contact.db path from message_0.db path.
|
|
194
|
+
|
|
195
|
+
message_0.db: <xwechat_files>/<wxid>/db_storage/message/message_0.db
|
|
196
|
+
contact.db: <xwechat_files>/<wxid>/db_storage/contact/contact.db
|
|
197
|
+
"""
|
|
198
|
+
msg_dir = os.path.dirname(message_db_path)
|
|
199
|
+
wxid_dir = os.path.dirname(msg_dir) # .../db_storage
|
|
200
|
+
xwechat_dir = os.path.dirname(wxid_dir) # .../<wxid>
|
|
201
|
+
contact_db = os.path.join(xwechat_dir, 'db_storage', 'contact', 'contact.db')
|
|
202
|
+
if os.path.isfile(contact_db):
|
|
203
|
+
return contact_db
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_contact_names(contact_db_path, contact_key_hex, contact_salt_hex):
|
|
208
|
+
"""Load wxid -> {remark, nick_name} map from contact.db.
|
|
209
|
+
|
|
210
|
+
Returns dict: {wxid: display_name}
|
|
211
|
+
display_name priority: remark > nick_name > alias > wxid
|
|
212
|
+
"""
|
|
213
|
+
if not contact_db_path or not contact_key_hex or not contact_salt_hex:
|
|
214
|
+
return {}
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
raw_key = f"x'{contact_key_hex}{contact_salt_hex}'"
|
|
218
|
+
conn = sqlcipher.connect(contact_db_path)
|
|
219
|
+
c = conn.cursor()
|
|
220
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
221
|
+
|
|
222
|
+
# contact.db schema: username, alias, remark, nick_name, ...
|
|
223
|
+
c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), NULLIF(alias,''), username) FROM contact")
|
|
224
|
+
name_map = {}
|
|
225
|
+
for username, display in c.fetchall():
|
|
226
|
+
if username:
|
|
227
|
+
name_map[username] = display
|
|
228
|
+
|
|
229
|
+
conn.close()
|
|
230
|
+
return name_map
|
|
231
|
+
except Exception as e:
|
|
232
|
+
return {}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def apply_contact_names(sessions, name_map):
|
|
236
|
+
"""Apply contact names to session list, replacing bare wxid displayNames."""
|
|
237
|
+
if not name_map:
|
|
238
|
+
return sessions
|
|
239
|
+
for s in sessions:
|
|
240
|
+
username = s.get('username', '')
|
|
241
|
+
if username in name_map:
|
|
242
|
+
s['displayName'] = name_map[username]
|
|
243
|
+
return sessions
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def match_keys_to_databases(keys, databases):
|
|
247
|
+
"""Match memory keys to databases by comparing salts."""
|
|
248
|
+
salt_to_key = {}
|
|
249
|
+
for k in keys:
|
|
250
|
+
salt_to_key[k["salt"]] = k["key"]
|
|
251
|
+
|
|
252
|
+
matched = []
|
|
253
|
+
for db in databases:
|
|
254
|
+
if db["salt"] in salt_to_key:
|
|
255
|
+
db["key"] = salt_to_key[db["salt"]]
|
|
256
|
+
matched.append(db)
|
|
257
|
+
|
|
258
|
+
return matched
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ========== Database Operations ==========
|
|
262
|
+
|
|
263
|
+
def connect_nt_db(db_path, key_hex, salt_hex):
|
|
264
|
+
"""Connect to an NT database using sqlcipher3."""
|
|
265
|
+
raw_key = f"x'{key_hex}{salt_hex}'"
|
|
266
|
+
conn = sqlcipher.connect(db_path)
|
|
267
|
+
c = conn.cursor()
|
|
268
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
269
|
+
return conn, c
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_sessions(conn):
|
|
273
|
+
"""Get chat sessions from NT database (Name2Id table)."""
|
|
274
|
+
c = conn.cursor()
|
|
275
|
+
sessions = []
|
|
276
|
+
|
|
277
|
+
# NT format: each chat has its own Msg_<MD5> table
|
|
278
|
+
# The Name2Id table maps usernames to IDs (user_name, is_session)
|
|
279
|
+
try:
|
|
280
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1 LIMIT 500")
|
|
281
|
+
rows = c.fetchall()
|
|
282
|
+
|
|
283
|
+
import hashlib as hl
|
|
284
|
+
|
|
285
|
+
for (username,) in rows:
|
|
286
|
+
summary = ""
|
|
287
|
+
last_time = 0
|
|
288
|
+
|
|
289
|
+
# Try to get last message summary
|
|
290
|
+
try:
|
|
291
|
+
tbl_hash = hl.md5(username.encode()).hexdigest()
|
|
292
|
+
msg_table = f"Msg_{tbl_hash}"
|
|
293
|
+
|
|
294
|
+
c.execute(f'SELECT create_time, source, message_content, local_type FROM "{msg_table}" ORDER BY create_time DESC LIMIT 1')
|
|
295
|
+
row = c.fetchone()
|
|
296
|
+
if row:
|
|
297
|
+
last_time = row[0] or 0
|
|
298
|
+
msg_type = row[3] or 0
|
|
299
|
+
source_text = row[1]
|
|
300
|
+
content_text = row[2]
|
|
301
|
+
|
|
302
|
+
if msg_type == 1:
|
|
303
|
+
# Text message: use message_content
|
|
304
|
+
if isinstance(content_text, str) and content_text:
|
|
305
|
+
summary = content_text[:50]
|
|
306
|
+
elif isinstance(content_text, bytes):
|
|
307
|
+
summary = content_text.decode('utf-8', errors='ignore')[:50]
|
|
308
|
+
elif isinstance(source_text, str) and source_text:
|
|
309
|
+
# Non-text: try to extract from source
|
|
310
|
+
# Strip XML tags for summary
|
|
311
|
+
import re as _re
|
|
312
|
+
clean = _re.sub(r'<[^>]+>', '', source_text)
|
|
313
|
+
lines = clean.split('\n')
|
|
314
|
+
if len(lines) > 1 and lines[1].strip():
|
|
315
|
+
summary = lines[1].strip()[:50]
|
|
316
|
+
elif clean.strip():
|
|
317
|
+
summary = clean.strip()[:50]
|
|
318
|
+
except:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
sessions.append({
|
|
322
|
+
"username": username,
|
|
323
|
+
"type": 1 if "@chatroom" in username else 0,
|
|
324
|
+
"unreadCount": 0,
|
|
325
|
+
"summary": summary,
|
|
326
|
+
"sortTimestamp": last_time,
|
|
327
|
+
"lastTimestamp": last_time,
|
|
328
|
+
"displayName": username,
|
|
329
|
+
})
|
|
330
|
+
except Exception as e:
|
|
331
|
+
return {"error": str(e)}
|
|
332
|
+
|
|
333
|
+
# Sort by timestamp descending
|
|
334
|
+
sessions.sort(key=lambda s: s.get("sortTimestamp", 0), reverse=True)
|
|
335
|
+
return {"sessions": sessions}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_messages(conn, talker, limit=100, offset=0, name_map=None, own_wxid=None):
|
|
339
|
+
"""Get messages for a specific talker from NT database.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
name_map: optional {wxid: display_name} dict for resolving sender names
|
|
343
|
+
own_wxid: account owner wxid for self-message detection
|
|
344
|
+
"""
|
|
345
|
+
import hashlib
|
|
346
|
+
if name_map is None:
|
|
347
|
+
name_map = {}
|
|
348
|
+
c = conn.cursor()
|
|
349
|
+
|
|
350
|
+
msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
# Check if table exists
|
|
354
|
+
c.execute(f"SELECT COUNT(*) FROM sqlite_master WHERE name='{msg_table}'")
|
|
355
|
+
if c.fetchone()[0] == 0:
|
|
356
|
+
return {"error": f"未找到会话: {talker}"}
|
|
357
|
+
|
|
358
|
+
if limit > 0:
|
|
359
|
+
c.execute(f'''
|
|
360
|
+
SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
|
|
361
|
+
create_time, status, upload_status, download_status,
|
|
362
|
+
server_seq, origin_source, source, message_content, compress_content
|
|
363
|
+
FROM "{msg_table}"
|
|
364
|
+
ORDER BY create_time DESC
|
|
365
|
+
LIMIT ? OFFSET ?
|
|
366
|
+
''', (limit, offset))
|
|
367
|
+
else:
|
|
368
|
+
c.execute(f'''
|
|
369
|
+
SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
|
|
370
|
+
create_time, status, upload_status, download_status,
|
|
371
|
+
server_seq, origin_source, source, message_content, compress_content
|
|
372
|
+
FROM "{msg_table}"
|
|
373
|
+
ORDER BY create_time DESC
|
|
374
|
+
''')
|
|
375
|
+
|
|
376
|
+
rows = c.fetchall()
|
|
377
|
+
messages = []
|
|
378
|
+
|
|
379
|
+
# Build sender_id -> username map from Name2Id (one query for all messages)
|
|
380
|
+
c.execute("SELECT rowid, user_name FROM Name2Id")
|
|
381
|
+
sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
|
|
382
|
+
|
|
383
|
+
for row in rows:
|
|
384
|
+
local_type = row[2] or 0
|
|
385
|
+
create_time = row[5] or 0
|
|
386
|
+
real_sender_id = row[4] or 0
|
|
387
|
+
|
|
388
|
+
# Resolve sender: real_sender_id -> Name2Id -> user_name
|
|
389
|
+
sender_username = sender_id_map.get(real_sender_id, "")
|
|
390
|
+
|
|
391
|
+
# Determine if message is from self
|
|
392
|
+
# own_wxid may have _xxxx suffix (from xwechat_files dir), try both
|
|
393
|
+
is_self = bool(own_wxid and (
|
|
394
|
+
sender_username == own_wxid or
|
|
395
|
+
(own_wxid.endswith('_') is False and sender_username.startswith(own_wxid))
|
|
396
|
+
))
|
|
397
|
+
if not is_self and own_wxid:
|
|
398
|
+
# Strip _xxxx suffix and retry
|
|
399
|
+
parts = own_wxid.rsplit('_', 1)
|
|
400
|
+
if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
|
|
401
|
+
is_self = (sender_username == parts[0])
|
|
402
|
+
|
|
403
|
+
# Resolve sender display name from contact map
|
|
404
|
+
if is_self:
|
|
405
|
+
sender_display = "" # Let the CLI show "我"
|
|
406
|
+
else:
|
|
407
|
+
sender_display = name_map.get(sender_username, sender_username) if sender_username else sender_username
|
|
408
|
+
|
|
409
|
+
# Parse message_content - TEXT column
|
|
410
|
+
content = row[12] if isinstance(row[12], str) else ""
|
|
411
|
+
|
|
412
|
+
messages.append({
|
|
413
|
+
"localId": row[0] or 0,
|
|
414
|
+
"serverId": str(row[1] or ''),
|
|
415
|
+
"localType": local_type,
|
|
416
|
+
"createTime": create_time,
|
|
417
|
+
"isSend": 1 if is_self else 0, # 1 = I sent this
|
|
418
|
+
"senderUsername": sender_username,
|
|
419
|
+
"senderDisplay": sender_display,
|
|
420
|
+
"content": content,
|
|
421
|
+
"rawContent": content,
|
|
422
|
+
"parsedContent": content[:200] if local_type == 1 else "",
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
return {"messages": messages}
|
|
426
|
+
except Exception as e:
|
|
427
|
+
return {"error": str(e)}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def get_contacts(conn, limit=200):
|
|
431
|
+
"""Get contacts from NT database."""
|
|
432
|
+
c = conn.cursor()
|
|
433
|
+
try:
|
|
434
|
+
c.execute("SELECT user_name FROM Name2Id LIMIT ?", (limit,))
|
|
435
|
+
rows = c.fetchall()
|
|
436
|
+
contacts = [{"username": r[0], "displayName": r[0]} for r in rows]
|
|
437
|
+
return {"contacts": contacts}
|
|
438
|
+
except Exception as e:
|
|
439
|
+
return {"error": str(e)}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ========== Main CLI ==========
|
|
443
|
+
|
|
444
|
+
def main():
|
|
445
|
+
import argparse
|
|
446
|
+
parser = argparse.ArgumentParser(description='WeChat NT Database Tool')
|
|
447
|
+
sub = parser.add_subparsers(dest='command')
|
|
448
|
+
|
|
449
|
+
# scan command
|
|
450
|
+
scan_parser = sub.add_parser('scan', help='Scan memory for keys and match NT databases')
|
|
451
|
+
scan_parser.add_argument('--json', action='store_true', help='Output as JSON')
|
|
452
|
+
|
|
453
|
+
# sessions command
|
|
454
|
+
sessions_parser = sub.add_parser('sessions', help='List chat sessions')
|
|
455
|
+
sessions_parser.add_argument('--db', required=True, help='Path to NT database')
|
|
456
|
+
sessions_parser.add_argument('--key', required=True, help='Key hex (64 chars)')
|
|
457
|
+
sessions_parser.add_argument('--salt', required=True, help='Salt hex (32 chars)')
|
|
458
|
+
sessions_parser.add_argument('--keyword', help='Filter by keyword')
|
|
459
|
+
sessions_parser.add_argument('--contact-db', help='Path to contact.db for display names')
|
|
460
|
+
sessions_parser.add_argument('--contact-key', help='Contact DB key hex (64 chars)')
|
|
461
|
+
sessions_parser.add_argument('--contact-salt', help='Contact DB salt hex (32 chars)')
|
|
462
|
+
|
|
463
|
+
# messages command
|
|
464
|
+
msg_parser = sub.add_parser('messages', help='Get messages')
|
|
465
|
+
msg_parser.add_argument('--db', required=True, help='Path to NT database')
|
|
466
|
+
msg_parser.add_argument('--key', required=True, help='Key hex (64 chars)')
|
|
467
|
+
msg_parser.add_argument('--salt', required=True, help='Salt hex (32 chars)')
|
|
468
|
+
msg_parser.add_argument('--talker', required=True, help='Talker username')
|
|
469
|
+
msg_parser.add_argument('--limit', type=int, default=100)
|
|
470
|
+
msg_parser.add_argument('--offset', type=int, default=0)
|
|
471
|
+
msg_parser.add_argument('--contact-db', help='Path to contact.db for sender names')
|
|
472
|
+
msg_parser.add_argument('--contact-key', help='Contact DB key hex (64 chars)')
|
|
473
|
+
msg_parser.add_argument('--contact-salt', help='Contact DB salt hex (32 chars)')
|
|
474
|
+
msg_parser.add_argument('--own-wxid', help='Account owner wxid (for self-message detection)')
|
|
475
|
+
|
|
476
|
+
# contacts command
|
|
477
|
+
contacts_parser = sub.add_parser('contacts', help='List contacts')
|
|
478
|
+
contacts_parser.add_argument('--db', required=True, help='Path to NT database')
|
|
479
|
+
contacts_parser.add_argument('--key', required=True, help='Key hex (64 chars)')
|
|
480
|
+
contacts_parser.add_argument('--salt', required=True, help='Salt hex (32 chars)')
|
|
481
|
+
contacts_parser.add_argument('--keyword', help='Filter by keyword')
|
|
482
|
+
contacts_parser.add_argument('--limit', type=int, default=200)
|
|
483
|
+
contacts_parser.add_argument('--contact-db', help='Path to contact.db for display names')
|
|
484
|
+
contacts_parser.add_argument('--contact-key', help='Contact DB key hex (64 chars)')
|
|
485
|
+
contacts_parser.add_argument('--contact-salt', help='Contact DB salt hex (32 chars)')
|
|
486
|
+
|
|
487
|
+
args = parser.parse_args()
|
|
488
|
+
|
|
489
|
+
if args.command == 'scan':
|
|
490
|
+
pid = find_weixin_pid()
|
|
491
|
+
if not pid:
|
|
492
|
+
print(json.dumps({"error": "Weixin.exe 未运行"}))
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
if not args.json:
|
|
496
|
+
print(f"扫描进程 PID {pid}...")
|
|
497
|
+
|
|
498
|
+
keys = scan_memory_keys(pid)
|
|
499
|
+
if not args.json:
|
|
500
|
+
print(f"找到 {len(keys)} 个密钥")
|
|
501
|
+
|
|
502
|
+
databases = find_nt_databases()
|
|
503
|
+
if not args.json:
|
|
504
|
+
print(f"找到 {len(databases)} 个 NT 数据库")
|
|
505
|
+
|
|
506
|
+
matched = match_keys_to_databases(keys, databases)
|
|
507
|
+
if not args.json:
|
|
508
|
+
print(f"匹配 {len(matched)} 个数据库")
|
|
509
|
+
for db in matched:
|
|
510
|
+
print(f" {db['name']} ({db['size']/1024/1024:.1f}MB) key={db['key'][:16]}... salt={db['salt'][:16]}...")
|
|
511
|
+
else:
|
|
512
|
+
print(json.dumps({"keys": keys, "databases": databases, "matched": matched}))
|
|
513
|
+
|
|
514
|
+
# Build contact name map once if contact db provided
|
|
515
|
+
contact_name_map = {}
|
|
516
|
+
contact_db = getattr(args, 'contact_db', None)
|
|
517
|
+
contact_key = getattr(args, 'contact_key', None)
|
|
518
|
+
contact_salt = getattr(args, 'contact_salt', None)
|
|
519
|
+
if contact_db and contact_key and contact_salt:
|
|
520
|
+
contact_name_map = load_contact_names(contact_db, contact_key, contact_salt)
|
|
521
|
+
|
|
522
|
+
if args.command == 'sessions':
|
|
523
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
524
|
+
result = get_sessions(conn)
|
|
525
|
+
if 'sessions' in result:
|
|
526
|
+
result['sessions'] = apply_contact_names(result['sessions'], contact_name_map)
|
|
527
|
+
if args.keyword:
|
|
528
|
+
kw = args.keyword.lower()
|
|
529
|
+
result['sessions'] = [
|
|
530
|
+
s for s in result['sessions']
|
|
531
|
+
if kw in (s.get('username', '') + s.get('displayName', '') + s.get('summary', '')).lower()
|
|
532
|
+
]
|
|
533
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
534
|
+
conn.close()
|
|
535
|
+
|
|
536
|
+
elif args.command == 'messages':
|
|
537
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
538
|
+
own_wxid = getattr(args, 'own_wxid', None)
|
|
539
|
+
result = get_messages(conn, args.talker, args.limit, args.offset, contact_name_map, own_wxid)
|
|
540
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
541
|
+
conn.close()
|
|
542
|
+
|
|
543
|
+
elif args.command == 'contacts':
|
|
544
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
545
|
+
result = get_contacts(conn, args.limit)
|
|
546
|
+
if 'contacts' in result:
|
|
547
|
+
result['contacts'] = apply_contact_names(result['contacts'], contact_name_map)
|
|
548
|
+
if args.keyword:
|
|
549
|
+
kw = args.keyword.lower()
|
|
550
|
+
result['contacts'] = [
|
|
551
|
+
c for c in result['contacts']
|
|
552
|
+
if kw in (c.get('username', '') + c.get('displayName', '') + c.get('remark', '') + c.get('nickname', '')).lower()
|
|
553
|
+
]
|
|
554
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
555
|
+
conn.close()
|
|
556
|
+
|
|
557
|
+
else:
|
|
558
|
+
parser.print_help()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
if __name__ == '__main__':
|
|
562
|
+
main()
|