#!/usr/bin/env python3 """ WeChat Android EnMicroMsg.db 搜索服务器 适配单库结构:message / rcontact / conversation 表 Usage: python3 search_server_android.py /path/to/EnMicroMsg.db.decrypted.db python3 search_server_android.py # 默认 ~/wxmoment/dbs/EnMicroMsg.db.decrypted.db """ import sqlite3, json, os, hashlib, re, time, sys from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs PORT = 5002 # ── DB path ─────────────────────────────────────────────────────────────────── if len(sys.argv) > 1: DB_PATH = sys.argv[1] else: DB_PATH = os.path.expanduser("~/wxmoment/dbs/EnMicroMsg.db.decrypted.db") if not os.path.exists(DB_PATH): print(f"[!] DB not found: {DB_PATH}") print("Usage: python3 search_server_android.py ") sys.exit(1) # ── Type labels ─────────────────────────────────────────────────────────────── TYPE_LABELS = { 1: '文字', 3: '图片', 34: '语音', 43: '视频', 47: '表情', 49: '链接/文件', 50: '视频通话', 10000: '系统消息', 436207665: '红包', 469762048: '转账', 822083633: '引用回复', 754974769: '拍一拍', 570425393: '语音通话', 1090519089: '文件', 268445456: '位置', 285212721: '合并转发', } # ── Data loading ────────────────────────────────────────────────────────────── contacts = {} # username -> {nickname, remark, avatar} chat_stats = {} # username -> {count, last_time} def load_contacts(): try: con = sqlite3.connect(DB_PATH) con.row_factory = sqlite3.Row for row in con.execute( "SELECT username, nickname, conRemark, alias FROM rcontact WHERE deleteFlag=0" ): u = row['username'] contacts[u] = { 'nickname': row['nickname'] or '', 'remark': row['conRemark'] or '', 'alias': row['alias'] or '', } con.close() print(f'[+] {len(contacts)} contacts loaded') except Exception as e: print(f'[!] contacts: {e}') def build_chat_stats(): try: con = sqlite3.connect(DB_PATH) for row in con.execute( "SELECT talker, COUNT(*) as cnt, MAX(createTime) as last_time FROM message GROUP BY talker" ): chat_stats[row[0]] = {'count': row[1], 'last_time': row[2] or 0} con.close() print(f'[+] {len(chat_stats)} chat stats loaded') except Exception as e: print(f'[!] chat_stats: {e}') 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(msg_type, raw_content, is_send): """Return (sender_username_or_empty, clean_text, is_sent_by_me)""" is_me = int(is_send or 0) == 1 if msg_type != 1: label = TYPE_LABELS.get(msg_type, f'[类型{msg_type}]') if msg_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:\nbody" or "username:\n\nbody" if not is_me and ':' in raw_content[:100]: idx = raw_content.index(':') sender_name = raw_content[:idx] text = raw_content[idx + 1:].lstrip('\n') return (sender_name, text, False) return ('', raw_content, is_me) # ── API ─────────────────────────────────────────────────────────────────────── def api_sessions(_p): try: con = sqlite3.connect(DB_PATH) con.row_factory = sqlite3.Row # conversation table may be empty; fallback to message talkers rows = con.execute( """SELECT talker as username, MAX(createTime) as createTime, COUNT(*) as cnt FROM message GROUP BY talker ORDER BY createTime DESC LIMIT 500""" ).fetchall() result = [] for row in rows: d = dict(row) u = d.get('username', '') d['display_name'] = dname(u) s = chat_stats.get(u, {}) d['msg_count'] = s.get('count', d.get('cnt', 0)) d['last_time_fmt'] = fmt_ts(d.get('createTime', 0)) result.append(d) con.close() return {'total': len(result), 'sessions': result} except Exception as e: return {'total': 0, 'sessions': [], 'error': str(e)} def api_stats(_p): try: con = sqlite3.connect(DB_PATH) # type: 1=好友, 2=群聊成员, 3=公众号, etc. r = con.execute(""" SELECT SUM(CASE WHEN type=1 THEN 1 ELSE 0 END), SUM(CASE WHEN type=3 THEN 1 ELSE 0 END), SUM(CASE WHEN type=2 THEN 1 ELSE 0 END), SUM(CASE WHEN type NOT IN (0,1,2,3,4,33) THEN 1 ELSE 0 END) FROM rcontact WHERE deleteFlag=0 """).fetchone() con.close() return {'friends': r[0] or 0, 'official': r[1] or 0, 'group_members': r[2] or 0, 'other': r[3] or 0} 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] page = int(params.get('page', ['1'])[0]) per_page = int(params.get('per_page', ['200'])[0]) type_map = { 'friends': 'type=1', 'official': 'type=3', 'groups': "username LIKE '%@chatroom%'", 'other': 'type NOT IN (0,1,2,3,4,33)', 'all': '1=1', } where = type_map.get(ctype, type_map['friends']) try: con = sqlite3.connect(DB_PATH) con.row_factory = sqlite3.Row base_where = f"deleteFlag=0 AND ({where})" if q: like = f'%{q}%' rows = con.execute( f"""SELECT username, nickname, conRemark, alias, pyInitial, quanPin FROM rcontact WHERE {base_where} AND (nickname LIKE ? OR conRemark LIKE ? OR username LIKE ? OR alias LIKE ? OR pyInitial LIKE ? OR quanPin LIKE ?)""", (like,) * 6 ).fetchall() else: rows = con.execute( f"""SELECT username, nickname, conRemark, alias, pyInitial, quanPin FROM rcontact WHERE {base_where}""" ).fetchall() con.close() items = [] for row in rows: name = row['conRemark'] or row['nickname'] or row['username'] if not name: continue py = row['pyInitial'] 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, 'first_letter': fl, 'msg_count': s.get('count', 0), 'last_time': s.get('last_time', 0), 'last_time_fmt': fmt_ts(s.get('last_time', 0)), }) 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'} try: con = sqlite3.connect(DB_PATH) con.row_factory = sqlite3.Row total = con.execute('SELECT COUNT(*) FROM message WHERE talker = ?', (username,)).fetchone()[0] rows = con.execute( 'SELECT * FROM message WHERE talker = ? ORDER BY createTime DESC LIMIT ? OFFSET ?', (username, limit, offset) ).fetchall() messages = [] for row in rows: d = dict(row) lt = d.get('type', 1) raw = d.get('content', '') or '' sid = d.get('isSend', 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({ 'msgId': d.get('msgId'), 'type': lt, 'createTime': d.get('createTime', 0), 'time_fmt': fmt_ts(d.get('createTime', 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 = [] try: con = sqlite3.connect(DB_PATH) con.row_factory = sqlite3.Row rows = con.execute( 'SELECT * FROM message WHERE type = 1 AND content LIKE ? ORDER BY createTime DESC LIMIT ?', (f'%{q}%', limit * 2) ).fetchall() for row in rows: d = dict(row) lt = d.get('type', 1) raw = d.get('content', '') or '' sid = d.get('isSend', 0) sender_raw, text, is_me = parse_msg(lt, raw, sid) chat_user = d.get('talker', '') results.append({ 'msgId': d.get('msgId'), 'createTime': d.get('createTime', 0), 'time_fmt': fmt_ts(d.get('createTime', 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), 'text': text, }) con.close() except Exception as e: return {'error': str(e), 'results': [], 'count': 0} results.sort(key=lambda x: x.get('createTime', 0), reverse=True) return {'query': q, 'count': len(results[:limit]), 'results': results[:limit]} # ── HTML ────────────────────────────────────────────────────────────────────── HTML = r""" 微信聊天记录 (Android)

📱 微信聊天记录

选择一个会话

← 从左侧选择会话,或在顶部搜索消息
""" # ── 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 Android 聊天记录搜索工具') print(f'DB: {DB_PATH}') print('=' * 50) load_contacts() build_chat_stats() print(f'\n[*] 启动服务: http://localhost:{PORT}') print(' Ctrl+C 停止\n') HTTPServer(('localhost', PORT), Handler).serve_forever()