@walkerch/wxecho 1.0.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 +209 -0
- package/bin/wxecho +21 -0
- package/dist/cli.js +460 -0
- package/package.json +53 -0
- package/py/config.py +220 -0
- package/py/export_chat.py +398 -0
- package/py/find_all_keys_macos +0 -0
- package/py/find_all_keys_macos.c +319 -0
- package/py/key_utils.py +38 -0
package/py/config.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
配置加载器 - 从 config.json 读取路径配置
|
|
3
|
+
首次运行时自动检测微信数据目录,检测失败则提示手动配置
|
|
4
|
+
"""
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
|
12
|
+
|
|
13
|
+
_SYSTEM = platform.system().lower()
|
|
14
|
+
|
|
15
|
+
if _SYSTEM == "linux":
|
|
16
|
+
_DEFAULT_TEMPLATE_DIR = os.path.expanduser("~/Documents/xwechat_files/your_wxid/db_storage")
|
|
17
|
+
_DEFAULT_PROCESS = "wechat"
|
|
18
|
+
elif _SYSTEM == "darwin":
|
|
19
|
+
# macOS 使用独立的 C 扫描器 (find_all_keys_macos.c),此处仅提供 config 默认值
|
|
20
|
+
_DEFAULT_TEMPLATE_DIR = os.path.expanduser("~/Documents/xwechat_files/your_wxid/db_storage")
|
|
21
|
+
_DEFAULT_PROCESS = "WeChat"
|
|
22
|
+
else:
|
|
23
|
+
_DEFAULT_TEMPLATE_DIR = r"D:\xwechat_files\your_wxid\db_storage"
|
|
24
|
+
_DEFAULT_PROCESS = "Weixin.exe"
|
|
25
|
+
|
|
26
|
+
_DEFAULT = {
|
|
27
|
+
"db_dir": _DEFAULT_TEMPLATE_DIR,
|
|
28
|
+
"keys_file": "all_keys.json",
|
|
29
|
+
"decrypted_dir": "decrypted",
|
|
30
|
+
"decoded_image_dir": "decoded_images",
|
|
31
|
+
"wechat_process": _DEFAULT_PROCESS,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _choose_candidate(candidates):
|
|
36
|
+
"""在多个候选目录中选择一个。"""
|
|
37
|
+
if len(candidates) == 1:
|
|
38
|
+
return candidates[0]
|
|
39
|
+
if len(candidates) > 1:
|
|
40
|
+
if not sys.stdin.isatty():
|
|
41
|
+
return candidates[0]
|
|
42
|
+
print("[!] 检测到多个微信数据目录(请选择当前正在运行的微信账号):")
|
|
43
|
+
for i, c in enumerate(candidates, 1):
|
|
44
|
+
print(f" {i}. {c}")
|
|
45
|
+
print(" 0. 跳过,稍后手动配置")
|
|
46
|
+
try:
|
|
47
|
+
while True:
|
|
48
|
+
choice = input("请选择 [0-{}]: ".format(len(candidates))).strip()
|
|
49
|
+
if choice == "0":
|
|
50
|
+
return None
|
|
51
|
+
if choice.isdigit() and 1 <= int(choice) <= len(candidates):
|
|
52
|
+
return candidates[int(choice) - 1]
|
|
53
|
+
print(" 无效输入,请重新选择")
|
|
54
|
+
except (EOFError, KeyboardInterrupt):
|
|
55
|
+
print()
|
|
56
|
+
return None
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _auto_detect_db_dir_windows():
|
|
61
|
+
"""从微信本地配置自动检测 Windows db_storage 路径。
|
|
62
|
+
|
|
63
|
+
读取 %APPDATA%\\Tencent\\xwechat\\config\\*.ini,
|
|
64
|
+
找到数据存储根目录,然后匹配 xwechat_files\\*\\db_storage。
|
|
65
|
+
"""
|
|
66
|
+
appdata = os.environ.get("APPDATA", "")
|
|
67
|
+
config_dir = os.path.join(appdata, "Tencent", "xwechat", "config")
|
|
68
|
+
if not os.path.isdir(config_dir):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# 从 ini 文件中找到有效的目录路径
|
|
72
|
+
data_roots = []
|
|
73
|
+
for ini_file in glob.glob(os.path.join(config_dir, "*.ini")):
|
|
74
|
+
try:
|
|
75
|
+
# 微信 ini 可能是 utf-8 或 gbk 编码(中文路径)
|
|
76
|
+
content = None
|
|
77
|
+
for enc in ("utf-8", "gbk"):
|
|
78
|
+
try:
|
|
79
|
+
with open(ini_file, "r", encoding=enc) as f:
|
|
80
|
+
content = f.read(1024).strip()
|
|
81
|
+
break
|
|
82
|
+
except UnicodeDecodeError:
|
|
83
|
+
continue
|
|
84
|
+
if not content or any(c in content for c in "\n\r\x00"):
|
|
85
|
+
continue
|
|
86
|
+
if os.path.isdir(content):
|
|
87
|
+
data_roots.append(content)
|
|
88
|
+
except OSError:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# 在每个根目录下搜索 xwechat_files\*\db_storage
|
|
92
|
+
seen = set()
|
|
93
|
+
candidates = []
|
|
94
|
+
for root in data_roots:
|
|
95
|
+
pattern = os.path.join(root, "xwechat_files", "*", "db_storage")
|
|
96
|
+
for match in glob.glob(pattern):
|
|
97
|
+
normalized = os.path.normcase(os.path.normpath(match))
|
|
98
|
+
if os.path.isdir(match) and normalized not in seen:
|
|
99
|
+
seen.add(normalized)
|
|
100
|
+
candidates.append(match)
|
|
101
|
+
|
|
102
|
+
return _choose_candidate(candidates)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _auto_detect_db_dir_linux():
|
|
106
|
+
"""自动检测 Linux 微信 db_storage 路径。
|
|
107
|
+
|
|
108
|
+
优先搜索当前用户的 home 目录。以 sudo 运行时通过 SUDO_USER 回退到
|
|
109
|
+
实际用户的 home,避免只搜索 /root 而遗漏真实数据目录。
|
|
110
|
+
"""
|
|
111
|
+
seen = set()
|
|
112
|
+
candidates = []
|
|
113
|
+
search_roots = [
|
|
114
|
+
os.path.expanduser("~/Documents/xwechat_files"),
|
|
115
|
+
]
|
|
116
|
+
# sudo 运行时,~ 展开为 /root;回退到实际用户的 home
|
|
117
|
+
sudo_user = os.environ.get("SUDO_USER")
|
|
118
|
+
if sudo_user:
|
|
119
|
+
# 验证 SUDO_USER 是合法系统用户,防止路径注入
|
|
120
|
+
import pwd
|
|
121
|
+
try:
|
|
122
|
+
sudo_home = pwd.getpwnam(sudo_user).pw_dir
|
|
123
|
+
except KeyError:
|
|
124
|
+
sudo_home = None
|
|
125
|
+
if sudo_home:
|
|
126
|
+
fallback = os.path.join(sudo_home, "Documents", "xwechat_files")
|
|
127
|
+
if fallback not in search_roots:
|
|
128
|
+
search_roots.append(fallback)
|
|
129
|
+
|
|
130
|
+
for root in search_roots:
|
|
131
|
+
if not os.path.isdir(root):
|
|
132
|
+
continue
|
|
133
|
+
pattern = os.path.join(root, "*", "db_storage")
|
|
134
|
+
for match in glob.glob(pattern):
|
|
135
|
+
normalized = os.path.normcase(os.path.normpath(match))
|
|
136
|
+
if os.path.isdir(match) and normalized not in seen:
|
|
137
|
+
seen.add(normalized)
|
|
138
|
+
candidates.append(match)
|
|
139
|
+
|
|
140
|
+
# 早期 Linux 微信版本(wine/容器方案)使用的数据路径
|
|
141
|
+
old_path = os.path.expanduser("~/.local/share/weixin/data/db_storage")
|
|
142
|
+
if os.path.isdir(old_path):
|
|
143
|
+
normalized = os.path.normcase(os.path.normpath(old_path))
|
|
144
|
+
if normalized not in seen:
|
|
145
|
+
candidates.append(old_path)
|
|
146
|
+
|
|
147
|
+
# 优先使用最近活跃账号:按 message 目录 mtime 降序(近似排序,best-effort)
|
|
148
|
+
def _mtime(path):
|
|
149
|
+
msg_dir = os.path.join(path, "message")
|
|
150
|
+
target = msg_dir if os.path.isdir(msg_dir) else path
|
|
151
|
+
try:
|
|
152
|
+
return os.path.getmtime(target)
|
|
153
|
+
except OSError:
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
candidates.sort(key=_mtime, reverse=True)
|
|
157
|
+
return _choose_candidate(candidates)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def auto_detect_db_dir():
|
|
161
|
+
if _SYSTEM == "windows":
|
|
162
|
+
return _auto_detect_db_dir_windows()
|
|
163
|
+
if _SYSTEM == "linux":
|
|
164
|
+
return _auto_detect_db_dir_linux()
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load_config():
|
|
169
|
+
cfg = {}
|
|
170
|
+
if os.path.exists(CONFIG_FILE):
|
|
171
|
+
try:
|
|
172
|
+
with open(CONFIG_FILE) as f:
|
|
173
|
+
cfg = json.load(f)
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
print(f"[!] {CONFIG_FILE} 格式损坏,将使用默认配置")
|
|
176
|
+
cfg = {}
|
|
177
|
+
# db_dir 缺失或仍为模板值时,尝试自动检测
|
|
178
|
+
db_dir = cfg.get("db_dir", "")
|
|
179
|
+
if not db_dir or db_dir == _DEFAULT_TEMPLATE_DIR or "your_wxid" in db_dir:
|
|
180
|
+
detected = auto_detect_db_dir()
|
|
181
|
+
if detected:
|
|
182
|
+
print(f"[+] 自动检测到微信数据目录: {detected}")
|
|
183
|
+
cfg = {**_DEFAULT, **cfg, "db_dir": detected}
|
|
184
|
+
with open(CONFIG_FILE, "w") as f:
|
|
185
|
+
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
|
186
|
+
print(f"[+] 已保存到: {CONFIG_FILE}")
|
|
187
|
+
else:
|
|
188
|
+
if not os.path.exists(CONFIG_FILE):
|
|
189
|
+
with open(CONFIG_FILE, "w") as f:
|
|
190
|
+
json.dump(_DEFAULT, f, indent=4, ensure_ascii=False)
|
|
191
|
+
print(f"[!] 未能自动检测微信数据目录")
|
|
192
|
+
print(f" 请手动编辑 {CONFIG_FILE} 中的 db_dir 字段")
|
|
193
|
+
if _SYSTEM == "linux":
|
|
194
|
+
print(" Linux 默认路径类似: ~/Documents/xwechat_files/<wxid>/db_storage")
|
|
195
|
+
else:
|
|
196
|
+
print(f" 路径可在 微信设置 → 文件管理 中找到")
|
|
197
|
+
sys.exit(1)
|
|
198
|
+
else:
|
|
199
|
+
cfg = {**_DEFAULT, **cfg}
|
|
200
|
+
|
|
201
|
+
# 将相对路径转为绝对路径
|
|
202
|
+
base = os.path.dirname(os.path.abspath(__file__))
|
|
203
|
+
for key in ("keys_file", "decrypted_dir", "decoded_image_dir"):
|
|
204
|
+
if key in cfg and not os.path.isabs(cfg[key]):
|
|
205
|
+
cfg[key] = os.path.join(base, cfg[key])
|
|
206
|
+
|
|
207
|
+
# 自动推导微信数据根目录(db_dir 的上级目录)
|
|
208
|
+
# db_dir 格式: D:\xwechat_files\<wxid>\db_storage
|
|
209
|
+
# base_dir 格式: D:\xwechat_files\<wxid>
|
|
210
|
+
db_dir = cfg.get("db_dir", "")
|
|
211
|
+
if db_dir and os.path.basename(db_dir) == "db_storage":
|
|
212
|
+
cfg["wechat_base_dir"] = os.path.dirname(db_dir)
|
|
213
|
+
else:
|
|
214
|
+
cfg["wechat_base_dir"] = db_dir
|
|
215
|
+
|
|
216
|
+
# decoded_image_dir 默认值
|
|
217
|
+
if "decoded_image_dir" not in cfg:
|
|
218
|
+
cfg["decoded_image_dir"] = os.path.join(base, "decoded_images")
|
|
219
|
+
|
|
220
|
+
return cfg
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
WeChat Chat History Exporter
|
|
4
|
+
|
|
5
|
+
Export chat history with a specific contact or group from decrypted WeChat databases.
|
|
6
|
+
Outputs TXT (human-readable), CSV (spreadsheet), and JSON (structured data).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 export_chat.py -n "联系人昵称或备注" -o ./output_dir
|
|
10
|
+
python3 export_chat.py -n "Chris" # 默认导出到 ~/Downloads/wxecho/Chris/
|
|
11
|
+
python3 export_chat.py -n "工作群" # 默认导出到 ~/Downloads/wxecho/工作群/
|
|
12
|
+
python3 export_chat.py -l # 列出所有会话
|
|
13
|
+
python3 export_chat.py -l --top 30 # 列出前30个会话
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import json
|
|
21
|
+
import csv
|
|
22
|
+
import hashlib
|
|
23
|
+
import argparse
|
|
24
|
+
from datetime import datetime, timezone, timedelta
|
|
25
|
+
|
|
26
|
+
from config import load_config
|
|
27
|
+
|
|
28
|
+
_cfg = load_config()
|
|
29
|
+
DECRYPTED_DIR = _cfg["decrypted_dir"]
|
|
30
|
+
CONTACT_DB = os.path.join(DECRYPTED_DIR, "contact", "contact.db")
|
|
31
|
+
|
|
32
|
+
MY_WXID = None # Auto-detected
|
|
33
|
+
MY_NAME = "我"
|
|
34
|
+
CST = timezone(timedelta(hours=8))
|
|
35
|
+
|
|
36
|
+
MSG_TYPES = {
|
|
37
|
+
1: "文本", 3: "图片", 34: "语音", 42: "名片", 43: "视频",
|
|
38
|
+
47: "表情", 48: "位置", 49: "链接/文件/小程序", 50: "语音/视频通话",
|
|
39
|
+
51: "系统消息", 10000: "系统提示", 10002: "撤回消息",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
MEDIA_TYPES = {3, 34, 43, 47}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_default_output_dir(name):
|
|
46
|
+
"""Get default output directory: ~/Downloads/wxecho/{name}/"""
|
|
47
|
+
return os.path.join(os.path.expanduser("~/Downloads/wxecho"), name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_message_dbs():
|
|
51
|
+
"""Find all message database files."""
|
|
52
|
+
msg_dir = os.path.join(DECRYPTED_DIR, "message")
|
|
53
|
+
dbs = []
|
|
54
|
+
if os.path.isdir(msg_dir):
|
|
55
|
+
for f in sorted(os.listdir(msg_dir)):
|
|
56
|
+
if f.startswith("message_") and f.endswith(".db") and "fts" not in f:
|
|
57
|
+
dbs.append(os.path.join(msg_dir, f))
|
|
58
|
+
return dbs
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_contact(query):
|
|
62
|
+
"""Search contacts by nickname, remark, or alias."""
|
|
63
|
+
conn = sqlite3.connect(CONTACT_DB)
|
|
64
|
+
results = conn.execute("""
|
|
65
|
+
SELECT username, nick_name, remark, alias
|
|
66
|
+
FROM contact
|
|
67
|
+
WHERE nick_name LIKE ? OR remark LIKE ? OR alias LIKE ?
|
|
68
|
+
""", (f"%{query}%", f"%{query}%", f"%{query}%")).fetchall()
|
|
69
|
+
conn.close()
|
|
70
|
+
return results
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_contact_display_name(username):
|
|
74
|
+
"""Look up a contact's display name (remark > nick > username) by username/wxid."""
|
|
75
|
+
conn = sqlite3.connect(CONTACT_DB)
|
|
76
|
+
row = conn.execute(
|
|
77
|
+
"SELECT nick_name, remark FROM contact WHERE username = ?",
|
|
78
|
+
(username,)
|
|
79
|
+
).fetchone()
|
|
80
|
+
conn.close()
|
|
81
|
+
if row:
|
|
82
|
+
nick, remark = row
|
|
83
|
+
return remark if remark else nick
|
|
84
|
+
return username # Fallback to username if not found
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def detect_my_wxid():
|
|
88
|
+
"""Auto-detect current user's wxid by finding the most frequent sender across all messages.
|
|
89
|
+
|
|
90
|
+
Note: This is a heuristic based on message frequency. If a contact has sent more messages
|
|
91
|
+
than yourself, you may be misidentified as that contact. Use --my-wxid to override if needed.
|
|
92
|
+
"""
|
|
93
|
+
sender_count = {}
|
|
94
|
+
name2id_cache = {} # db_path -> {rowid: wxid}
|
|
95
|
+
|
|
96
|
+
for db_path in get_message_dbs():
|
|
97
|
+
conn = sqlite3.connect(db_path)
|
|
98
|
+
try:
|
|
99
|
+
# Build name2id mapping for this DB (skip if table doesn't exist)
|
|
100
|
+
name2id = {}
|
|
101
|
+
try:
|
|
102
|
+
for rowid, uname in conn.execute("SELECT rowid, user_name FROM Name2Id"):
|
|
103
|
+
name2id[rowid] = uname
|
|
104
|
+
except Exception:
|
|
105
|
+
continue # Skip DBs without Name2Id table
|
|
106
|
+
name2id_cache[db_path] = name2id
|
|
107
|
+
|
|
108
|
+
# Get all tables
|
|
109
|
+
tables = conn.execute(
|
|
110
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
|
111
|
+
).fetchall()
|
|
112
|
+
|
|
113
|
+
for (table_name,) in tables:
|
|
114
|
+
try:
|
|
115
|
+
# Count sender frequency (exclude rowid 0 which seems to be empty/null)
|
|
116
|
+
rows = conn.execute(f"""
|
|
117
|
+
SELECT real_sender_id FROM {table_name}
|
|
118
|
+
WHERE real_sender_id IS NOT NULL AND real_sender_id > 0
|
|
119
|
+
""").fetchall()
|
|
120
|
+
|
|
121
|
+
for (sender_id,) in rows:
|
|
122
|
+
wxid = name2id.get(sender_id, "")
|
|
123
|
+
if wxid and wxid.startswith("wxid_"):
|
|
124
|
+
sender_count[wxid] = sender_count.get(wxid, 0) + 1
|
|
125
|
+
except Exception:
|
|
126
|
+
continue
|
|
127
|
+
finally:
|
|
128
|
+
conn.close()
|
|
129
|
+
|
|
130
|
+
if not sender_count:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# The wxid that appears most frequently as sender is likely the user
|
|
134
|
+
return max(sender_count.items(), key=lambda x: x[1])[0]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def list_conversations(top_n=20):
|
|
138
|
+
"""List all conversations with message counts."""
|
|
139
|
+
# Collect all Msg_ tables across databases
|
|
140
|
+
conversations = {}
|
|
141
|
+
for db_path in get_message_dbs():
|
|
142
|
+
conn = sqlite3.connect(db_path)
|
|
143
|
+
try:
|
|
144
|
+
tables = conn.execute(
|
|
145
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
|
146
|
+
).fetchall()
|
|
147
|
+
name2id = {}
|
|
148
|
+
try:
|
|
149
|
+
for rowid, uname in conn.execute("SELECT rowid, user_name FROM Name2Id"):
|
|
150
|
+
name2id[rowid] = uname
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
for (table_name,) in tables:
|
|
155
|
+
try:
|
|
156
|
+
row = conn.execute(f"""
|
|
157
|
+
SELECT COUNT(*),
|
|
158
|
+
datetime(MIN(create_time), 'unixepoch', 'localtime'),
|
|
159
|
+
datetime(MAX(create_time), 'unixepoch', 'localtime')
|
|
160
|
+
FROM {table_name} WHERE create_time > 0
|
|
161
|
+
""").fetchone()
|
|
162
|
+
count, earliest, latest = row
|
|
163
|
+
if count == 0:
|
|
164
|
+
continue
|
|
165
|
+
if table_name not in conversations:
|
|
166
|
+
conversations[table_name] = {
|
|
167
|
+
"count": 0, "earliest": earliest, "latest": latest
|
|
168
|
+
}
|
|
169
|
+
conversations[table_name]["count"] += count
|
|
170
|
+
# Update time range
|
|
171
|
+
if earliest and (not conversations[table_name]["earliest"]
|
|
172
|
+
or earliest < conversations[table_name]["earliest"]):
|
|
173
|
+
conversations[table_name]["earliest"] = earliest
|
|
174
|
+
if latest and (not conversations[table_name]["latest"]
|
|
175
|
+
or latest > conversations[table_name]["latest"]):
|
|
176
|
+
conversations[table_name]["latest"] = latest
|
|
177
|
+
except Exception:
|
|
178
|
+
continue
|
|
179
|
+
finally:
|
|
180
|
+
conn.close()
|
|
181
|
+
|
|
182
|
+
# Resolve table hashes to contact names
|
|
183
|
+
contact_map = {}
|
|
184
|
+
try:
|
|
185
|
+
conn = sqlite3.connect(CONTACT_DB)
|
|
186
|
+
for username, nick, remark, alias in conn.execute(
|
|
187
|
+
"SELECT username, nick_name, remark, alias FROM contact"
|
|
188
|
+
):
|
|
189
|
+
h = hashlib.md5(username.encode()).hexdigest()
|
|
190
|
+
table = f"Msg_{h}"
|
|
191
|
+
display = remark if remark else nick
|
|
192
|
+
contact_map[table] = (username, display, nick, remark)
|
|
193
|
+
conn.close()
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
# Sort by message count
|
|
198
|
+
sorted_convs = sorted(conversations.items(), key=lambda x: x[1]["count"], reverse=True)
|
|
199
|
+
|
|
200
|
+
print(f"\n{'排名':<4} {'消息数':<8} {'时间范围':<45} {'显示名':<20} {'用户名'}")
|
|
201
|
+
print("-" * 120)
|
|
202
|
+
for i, (table, info) in enumerate(sorted_convs[:top_n], 1):
|
|
203
|
+
if table in contact_map:
|
|
204
|
+
username, display, nick, remark = contact_map[table]
|
|
205
|
+
else:
|
|
206
|
+
username = table
|
|
207
|
+
display = "(?)"
|
|
208
|
+
time_range = f"{info['earliest']} ~ {info['latest']}"
|
|
209
|
+
print(f"{i:<4} {info['count']:<8} {time_range:<45} {display:<20} {username}")
|
|
210
|
+
|
|
211
|
+
print(f"\n共 {len(conversations)} 个会话")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def export_chat(contact_username, contact_display_name, output_dir):
|
|
215
|
+
"""Export all messages for a contact."""
|
|
216
|
+
table_hash = hashlib.md5(contact_username.encode()).hexdigest()
|
|
217
|
+
table_name = f"Msg_{table_hash}"
|
|
218
|
+
|
|
219
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
220
|
+
|
|
221
|
+
global MY_WXID
|
|
222
|
+
if not MY_WXID:
|
|
223
|
+
MY_WXID = detect_my_wxid()
|
|
224
|
+
|
|
225
|
+
all_messages = []
|
|
226
|
+
|
|
227
|
+
for db_path in get_message_dbs():
|
|
228
|
+
conn = sqlite3.connect(db_path)
|
|
229
|
+
db_name = os.path.basename(db_path)
|
|
230
|
+
try:
|
|
231
|
+
# Per-DB sender lookup
|
|
232
|
+
name2id = {}
|
|
233
|
+
for rowid, uname in conn.execute("SELECT rowid, user_name FROM Name2Id"):
|
|
234
|
+
name2id[rowid] = uname
|
|
235
|
+
|
|
236
|
+
rows = conn.execute(f"""
|
|
237
|
+
SELECT local_id, server_id, local_type, create_time,
|
|
238
|
+
real_sender_id, message_content, source,
|
|
239
|
+
WCDB_CT_message_content
|
|
240
|
+
FROM {table_name}
|
|
241
|
+
ORDER BY create_time ASC
|
|
242
|
+
""").fetchall()
|
|
243
|
+
|
|
244
|
+
for row in rows:
|
|
245
|
+
sender_wxid = name2id.get(row[4], "")
|
|
246
|
+
if sender_wxid == MY_WXID:
|
|
247
|
+
sender = MY_NAME
|
|
248
|
+
elif row[2] in (10000, 10002):
|
|
249
|
+
sender = "系统"
|
|
250
|
+
else:
|
|
251
|
+
sender = get_contact_display_name(sender_wxid)
|
|
252
|
+
|
|
253
|
+
content = row[5] or ""
|
|
254
|
+
if isinstance(content, bytes):
|
|
255
|
+
content = "[压缩内容]"
|
|
256
|
+
else:
|
|
257
|
+
# Strip leading wxid prefix: "wxid_xxx:\n..."
|
|
258
|
+
content = re.sub(r'^wxid_[a-zA-Z0-9]+:\n?', '', content)
|
|
259
|
+
|
|
260
|
+
all_messages.append({
|
|
261
|
+
"time": datetime.fromtimestamp(row[3], tz=CST).strftime(
|
|
262
|
+
"%Y-%m-%d %H:%M:%S") if row[3] else "",
|
|
263
|
+
"timestamp": row[3],
|
|
264
|
+
"sender": sender,
|
|
265
|
+
"type": row[2],
|
|
266
|
+
"type_name": MSG_TYPES.get(row[2], f"未知({row[2]})"),
|
|
267
|
+
"content": content,
|
|
268
|
+
"server_id": row[1],
|
|
269
|
+
"db": db_name,
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
if rows:
|
|
273
|
+
print(f" {db_name}: {len(rows)} 条消息")
|
|
274
|
+
except Exception as e:
|
|
275
|
+
pass # Table doesn't exist in this DB
|
|
276
|
+
finally:
|
|
277
|
+
conn.close()
|
|
278
|
+
|
|
279
|
+
all_messages.sort(key=lambda x: x["timestamp"] or 0)
|
|
280
|
+
|
|
281
|
+
if not all_messages:
|
|
282
|
+
print(f"未找到与 {contact_display_name} 的聊天记录")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# === TXT ===
|
|
286
|
+
txt_path = os.path.join(output_dir, "chat.txt")
|
|
287
|
+
with open(txt_path, "w", encoding="utf-8") as f:
|
|
288
|
+
f.write(f"微信聊天记录: {contact_display_name} ({contact_username})\n")
|
|
289
|
+
f.write(f"总消息数: {len(all_messages)}\n")
|
|
290
|
+
f.write(f"时间范围: {all_messages[0]['time']} ~ {all_messages[-1]['time']}\n")
|
|
291
|
+
f.write("=" * 60 + "\n\n")
|
|
292
|
+
|
|
293
|
+
for m in all_messages:
|
|
294
|
+
content = m["content"]
|
|
295
|
+
if m["type"] in MEDIA_TYPES:
|
|
296
|
+
content = f"[{m['type_name']}]"
|
|
297
|
+
elif m["type"] != 1 and not content:
|
|
298
|
+
content = f"[{m['type_name']}]"
|
|
299
|
+
f.write(f"[{m['time']}] {m['sender']}: {content}\n")
|
|
300
|
+
|
|
301
|
+
print(f" TXT: {txt_path}")
|
|
302
|
+
|
|
303
|
+
# === CSV ===
|
|
304
|
+
csv_path = os.path.join(output_dir, "chat.csv")
|
|
305
|
+
with open(csv_path, "w", encoding="utf-8-sig", newline="") as f:
|
|
306
|
+
writer = csv.writer(f)
|
|
307
|
+
writer.writerow(["时间", "发送者", "类型", "内容"])
|
|
308
|
+
for m in all_messages:
|
|
309
|
+
content = m["content"]
|
|
310
|
+
if m["type"] in MEDIA_TYPES:
|
|
311
|
+
content = f"[{m['type_name']}]"
|
|
312
|
+
elif m["type"] != 1 and not content:
|
|
313
|
+
content = f"[{m['type_name']}]"
|
|
314
|
+
writer.writerow([m["time"], m["sender"], m["type_name"], content])
|
|
315
|
+
|
|
316
|
+
print(f" CSV: {csv_path}")
|
|
317
|
+
|
|
318
|
+
# === JSON ===
|
|
319
|
+
json_path = os.path.join(output_dir, "chat.json")
|
|
320
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
321
|
+
json.dump(all_messages, f, ensure_ascii=False, indent=2)
|
|
322
|
+
|
|
323
|
+
print(f" JSON: {json_path}")
|
|
324
|
+
print(f"\n导出完成: {len(all_messages)} 条消息")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main():
|
|
328
|
+
parser = argparse.ArgumentParser(description="微信聊天记录导出工具")
|
|
329
|
+
parser.add_argument("-n", "--name", help="联系人昵称、备注或微信号(模糊搜索)")
|
|
330
|
+
parser.add_argument("-u", "--username", help="联系人用户名(精确匹配,跳过搜索)")
|
|
331
|
+
parser.add_argument("-o", "--output", help="导出目录(默认: ~/Downloads/wxecho/{name}/)")
|
|
332
|
+
parser.add_argument("--list", "-l", action="store_true", help="列出所有会话")
|
|
333
|
+
parser.add_argument("--top", type=int, default=20, help="列出前N个会话(默认20)")
|
|
334
|
+
parser.add_argument("--my-wxid", help="你自己的微信ID(可选,自动检测)")
|
|
335
|
+
args = parser.parse_args()
|
|
336
|
+
|
|
337
|
+
global MY_WXID
|
|
338
|
+
if args.my_wxid:
|
|
339
|
+
MY_WXID = args.my_wxid
|
|
340
|
+
|
|
341
|
+
if args.list:
|
|
342
|
+
list_conversations(args.top)
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
if not args.name and not args.username:
|
|
346
|
+
parser.print_help()
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
if args.username:
|
|
350
|
+
# Direct username, look up display name
|
|
351
|
+
results = find_contact(args.username)
|
|
352
|
+
if results:
|
|
353
|
+
username, nick, remark, alias = results[0]
|
|
354
|
+
display = remark if remark else nick
|
|
355
|
+
else:
|
|
356
|
+
username = args.username
|
|
357
|
+
display = args.username
|
|
358
|
+
output_dir = args.output or get_default_output_dir(display)
|
|
359
|
+
print(f"\n导出: {display} ({username})")
|
|
360
|
+
export_chat(username, display, output_dir)
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
# Search by name
|
|
364
|
+
results = find_contact(args.name)
|
|
365
|
+
|
|
366
|
+
if not results:
|
|
367
|
+
print(f"未找到匹配 \"{args.name}\" 的联系人")
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
if len(results) == 1:
|
|
371
|
+
username, nick, remark, alias = results[0]
|
|
372
|
+
display = remark if remark else nick
|
|
373
|
+
output_dir = args.output or get_default_output_dir(display)
|
|
374
|
+
print(f"\n找到联系人: {display} ({username})")
|
|
375
|
+
export_chat(username, display, output_dir)
|
|
376
|
+
else:
|
|
377
|
+
print(f"\n找到 {len(results)} 个匹配的联系人:")
|
|
378
|
+
for i, (username, nick, remark, alias) in enumerate(results, 1):
|
|
379
|
+
display = remark if remark else nick
|
|
380
|
+
print(f" {i}. {display} (昵称: {nick}, 备注: {remark}, 微信号: {alias}, ID: {username})")
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
choice = input("\n请选择 [1-{}]: ".format(len(results))).strip()
|
|
384
|
+
if choice.isdigit() and 1 <= int(choice) <= len(results):
|
|
385
|
+
idx = int(choice) - 1
|
|
386
|
+
username, nick, remark, alias = results[idx]
|
|
387
|
+
display = remark if remark else nick
|
|
388
|
+
output_dir = args.output or get_default_output_dir(display)
|
|
389
|
+
print(f"\n导出: {display} ({username})")
|
|
390
|
+
export_chat(username, display, output_dir)
|
|
391
|
+
else:
|
|
392
|
+
print("已取消")
|
|
393
|
+
except (EOFError, KeyboardInterrupt):
|
|
394
|
+
print("\n已取消")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
main()
|
|
Binary file
|