#!/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()