#!/usr/bin/env python3 """ WeChat 聊天记录搜索工具 用法: python3 search_server.py 访问: http://localhost:5001 """ import sqlite3, json, os, hashlib, re, time from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs PORT = 5001 DB_DIR = '/Users/yibo/Documents/wechat_plain' CONTACT_DB = f'{DB_DIR}/contact_contact.db' SESSION_DB = f'{DB_DIR}/session_session.db' MSG_DBS = [f'{DB_DIR}/message_message_{i}.db' for i in range(11) if os.path.exists(f'{DB_DIR}/message_message_{i}.db')] MY_IDS = {15, 543} # real_sender_id values that mean "sent by me" TYPE_LABELS = { 1: '文字', 3: '图片', 34: '语音', 43: '视频', 47: '表情', 49: '链接/文件', 50: '视频通话', 10000: '系统消息', 436207665: '红包', 469762048: '转账', } # ── Data loading ─────────────────────────────────────────────────────────────── contacts = {} # username → {nickname, remark, avatar} md5_to_user = {} # md5(username) → username table_index = {} # md5(username) → db_path chat_stats = {} # username → {count, last_time, first_time} def md5(s): return hashlib.md5(s.encode()).hexdigest() def load_contacts(): try: con = sqlite3.connect(CONTACT_DB) con.row_factory = sqlite3.Row for row in con.execute( "SELECT username, nick_name, remark, small_head_url FROM contact WHERE delete_flag=0" ): u = row['username'] contacts[u] = { 'nickname': row['nick_name'] or '', 'remark': row['remark'] or '', 'avatar': row['small_head_url'] or '', } md5_to_user[md5(u)] = u con.close() print(f'[+] {len(contacts)} contacts') except Exception as e: print(f'[!] contacts: {e}') def build_index(): for db_path in MSG_DBS: try: con = sqlite3.connect(db_path) for (t,) in con.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" ): h = t[4:] if h not in table_index: table_index[h] = db_path con.close() except: pass print(f'[+] {len(table_index)} message tables indexed') def load_chat_stats(): path = f'{DB_DIR}/chat_stats.json' if not os.path.exists(path): print('[!] chat_stats.json not found, run build_chat_stats.py first') return with open(path) as f: chat_stats.update(json.load(f)) print(f'[+] {len(chat_stats)} chat stat entries loaded') def dname(username): if not username: return '未知' c = contacts.get(username, {}) return c.get('remark') or c.get('nickname') or username def fmt_ts(ts): try: ts = int(ts) if ts > 1e12: ts //= 1000 return time.strftime('%Y-%m-%d %H:%M', time.localtime(ts)) except: return '' def parse_msg(local_type, raw_content, real_sender_id): """Return (sender_username_or_empty, clean_text, is_sent_by_me)""" is_me = int(real_sender_id or 0) in MY_IDS if local_type != 1: label = TYPE_LABELS.get(local_type, f'[类型{local_type}]') if local_type == 49 and isinstance(raw_content, str): m = re.search(r'(.*?)', raw_content, re.S) if m: label = f'[链接] {m.group(1)[:60]}' return ('', label, is_me) if not raw_content: return ('', '', is_me) if isinstance(raw_content, bytes): try: raw_content = raw_content.decode('utf-8', errors='replace') except: return ('', '[二进制内容]', is_me) # Group/received messages: "username:\ntext" if not is_me and ':\n' in raw_content[:100]: idx = raw_content.index(':\n') sender_name = raw_content[:idx] text = raw_content[idx+2:] return (sender_name, text, False) return ('', raw_content, is_me) # ── API ──────────────────────────────────────────────────────────────────────── def api_sessions(_p): try: con = sqlite3.connect(SESSION_DB) con.row_factory = sqlite3.Row total = con.execute("SELECT count(*) FROM SessionTable WHERE is_hidden=0").fetchone()[0] rows = con.execute( "SELECT * FROM SessionTable WHERE is_hidden=0 ORDER BY sort_timestamp DESC LIMIT 500" ).fetchall() result = [] for row in rows: d = dict(row) u = d.get('username', '') d['display_name'] = dname(u) d['avatar'] = contacts.get(u, {}).get('avatar', '') d['last_time_fmt'] = fmt_ts(d.get('last_timestamp') or d.get('sort_timestamp', 0)) result.append(d) con.close() return {'total': total, 'sessions': result} except Exception as e: return {'total': 0, 'sessions': []} TYPE_FILTERS = { 'friends': "local_type=0", 'contacts': "local_type=1", 'groups': "local_type IN (2,3) AND username LIKE '%@chatroom%'", 'members': "local_type=3 AND username NOT LIKE '%@chatroom%'", 'other': "local_type IN (5,6)", 'all': "1=1", } def api_stats(_p): try: con = sqlite3.connect(CONTACT_DB) r = con.execute(""" SELECT sum(CASE WHEN local_type=0 THEN 1 ELSE 0 END), sum(CASE WHEN local_type=1 THEN 1 ELSE 0 END), sum(CASE WHEN local_type IN (2,3) AND username LIKE '%@chatroom%' THEN 1 ELSE 0 END), sum(CASE WHEN local_type=3 AND username NOT LIKE '%@chatroom%' THEN 1 ELSE 0 END), sum(CASE WHEN local_type IN (5,6) THEN 1 ELSE 0 END) FROM contact WHERE delete_flag=0 """).fetchone() con.close() return {'friends': r[0], 'contacts': r[1], 'groups': r[2], 'members': r[3], 'other': r[4]} except Exception as e: return {'error': str(e)} def api_contacts(params): q = params.get('q', [''])[0] ctype = params.get('type', ['friends'])[0] sort_by = params.get('sort', ['name'])[0] # name | count | time page = int(params.get('page', ['1'])[0]) per_page = int(params.get('per_page', ['200'])[0]) where = TYPE_FILTERS.get(ctype, TYPE_FILTERS['friends']) try: con = sqlite3.connect(CONTACT_DB) con.row_factory = sqlite3.Row base_where = f"delete_flag=0 AND ({where})" if q: like = f'%{q}%' search_cond = ("AND (nick_name LIKE ? OR remark LIKE ? OR username LIKE ? " "OR remark_pin_yin_initial LIKE ? OR pin_yin_initial LIKE ?)") rows = con.execute( f"""SELECT username, nick_name, remark, small_head_url, remark_pin_yin_initial, pin_yin_initial FROM contact WHERE {base_where} {search_cond}""", (like,)*5).fetchall() else: rows = con.execute( f"""SELECT username, nick_name, remark, small_head_url, remark_pin_yin_initial, pin_yin_initial FROM contact WHERE {base_where}""").fetchall() con.close() items = [] for row in rows: name = row['remark'] or row['nick_name'] or row['username'] if not name: continue py = (row['remark_pin_yin_initial'] if row['remark'] else row['pin_yin_initial']) or '' fl = py[0].upper() if py and py[0].isalpha() else '#' s = chat_stats.get(row['username'], {}) items.append({ 'username': row['username'], 'display_name': name, 'avatar': row['small_head_url'] or '', 'first_letter': fl, 'msg_count': s.get('count', 0), 'last_time': s.get('last_time', 0), 'last_time_fmt': fmt_ts(s['last_time']) if s.get('last_time') else '', }) # Sort if sort_by == 'count': items.sort(key=lambda x: x['msg_count'], reverse=True) elif sort_by == 'time': items.sort(key=lambda x: x['last_time'], reverse=True) else: items.sort(key=lambda x: (x['first_letter'], x['display_name'].lower())) total = len(items) offset = (page - 1) * per_page page_items = items[offset:offset + per_page] return {'total': total, 'page': page, 'per_page': per_page, 'has_more': offset + len(page_items) < total, 'sort': sort_by, 'contacts': page_items} except Exception as e: return {'total': 0, 'contacts': [], 'error': str(e)} def api_chat(params): username = params.get('username', [''])[0] page = int(params.get('page', ['1'])[0]) limit = int(params.get('limit', ['50'])[0]) offset = (page - 1) * limit if not username: return {'error': 'username required'} h = md5(username) tbl = f'Msg_{h}' db_path = table_index.get(h) if not db_path: return {'messages': [], 'total': 0, 'display_name': dname(username)} try: con = sqlite3.connect(db_path) con.row_factory = sqlite3.Row total = con.execute(f'SELECT count(*) FROM "{tbl}"').fetchone()[0] rows = con.execute( f'SELECT * FROM "{tbl}" ORDER BY create_time DESC LIMIT ? OFFSET ?', (limit, offset) ).fetchall() messages = [] for row in rows: d = dict(row) lt = d.get('local_type', 1) raw = d.get('message_content', '') or '' sid = d.get('real_sender_id', 0) sender_raw, text, is_me = parse_msg(lt, raw, sid) sender_name = '我' if is_me else (dname(sender_raw) if sender_raw else dname(username)) messages.append({ 'local_id': d.get('local_id'), 'local_type': lt, 'create_time': d.get('create_time', 0), 'time_fmt': fmt_ts(d.get('create_time', 0)), 'is_me': is_me, 'sender_name': sender_name, 'text': text, 'type_label': TYPE_LABELS.get(lt, f'[{lt}]'), }) con.close() return { 'username': username, 'display_name': dname(username), 'total': total, 'page': page, 'messages': messages, } except Exception as e: return {'error': str(e), 'messages': [], 'total': 0} def api_search(params): q = params.get('q', [''])[0] limit = int(params.get('limit', ['60'])[0]) if len(q) < 1: return {'results': [], 'count': 0} results = [] for db_path in MSG_DBS: try: con = sqlite3.connect(db_path) con.row_factory = sqlite3.Row tables = [r[0] for r in con.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'" )] for tbl in tables: try: rows = con.execute( f'SELECT * FROM "{tbl}" WHERE local_type=1 ' f'AND message_content LIKE ? ORDER BY create_time DESC LIMIT 5', (f'%{q}%',) ).fetchall() h = tbl[4:] chat_user = md5_to_user.get(h, '') for row in rows: d = dict(row) lt = d.get('local_type', 1) raw = d.get('message_content', '') or '' sid = d.get('real_sender_id', 0) sender_raw, text, is_me = parse_msg(lt, raw, sid) results.append({ 'local_id': d.get('local_id'), 'create_time': d.get('create_time', 0), 'time_fmt': fmt_ts(d.get('create_time', 0)), 'is_me': is_me, 'sender_name': '我' if is_me else (dname(sender_raw) if sender_raw else dname(chat_user)), 'chat_username': chat_user, 'chat_name': dname(chat_user) if chat_user else tbl, 'text': text, }) except: pass con.close() except: pass if len(results) >= limit * 2: break results.sort(key=lambda x: x.get('create_time', 0), reverse=True) return {'query': q, 'count': len(results[:limit]), 'results': results[:limit]} # ── HTML ─────────────────────────────────────────────────────────────────────── HTML = r""" 微信聊天记录

📱 微信聊天记录

选择一个会话

← 从左侧选择会话,或在顶部搜索消息
""" # ── HTTP handler ─────────────────────────────────────────────────────────────── ROUTES = { '/api/sessions': lambda p: api_sessions(p), '/api/contacts': api_contacts, '/api/stats': api_stats, '/api/chat': api_chat, '/api/search': api_search, } class Handler(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) params = parse_qs(parsed.query) if parsed.path in ('/', '/index.html'): body = HTML.encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.end_headers() self.wfile.write(body) elif parsed.path in ROUTES: try: data = ROUTES[parsed.path](params) except Exception as e: data = {'error': str(e)} body = json.dumps(data, ensure_ascii=False, default=str).encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() self.wfile.write(body) else: self.send_response(404); self.end_headers() def log_message(self, fmt, *args): print(f' {args[1]} {args[0]}') if __name__ == '__main__': print('=' * 50) print('WeChat 聊天记录搜索工具') print('=' * 50) load_contacts() build_index() load_chat_stats() print(f'\n[*] 启动服务: http://localhost:{PORT}') print(' Ctrl+C 停止\n') HTTPServer(('localhost', PORT), Handler).serve_forever()