@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/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