Files
wechatauto-replica/wechatauto/demo_group_messages.py
T

356 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""wechatauto 演示:读取并识别固定群的最新消息(含红包 ZSTD 解析)。
从微信本地数据库直接读取指定群的最新消息,识别每条消息的类型、
发送者昵称与内容;红包消息自动解压 ZSTD 并提取祝福语/红包类型。
用法:
python demo_group_messages.py [群名关键词]
[--limit N] [--list-groups]
[--watch] [--red-only] [--sleep 秒]
参数:
群名关键词 匹配群名(模糊匹配),默认列出所有群后取第一个
--list-groups 仅列出所有群(名称 + wxid + 消息数),不读消息
--limit 读取最新 N 条消息(默认 20)
--watch 轮询模式:每 --sleep 秒读取一次新消息(增量,按 sort_seq)
--sleep 轮询间隔秒数(默认 3)
--red-only 只显示红包消息
例:
python demo_group_messages.py --list-groups
python demo_group_messages.py 家长群
python demo_group_messages.py 家长群 --limit 30
python demo_group_messages.py 家长群 --watch --red-only
python demo_group_messages.py 家长群 --watch --sleep 5
原理:
contact.db 存群(username 含 @chatroom)与成员(wxid → nick_name/remark);
message_*.db 按 Md5(群wxid) 建表存消息,群文本消息的 message_content 形如
"wxid: 内容",发送者 wxid 藏在内容前缀中(4.x 群消息 sender_id 不可靠)。
红包卡片 local_type=0x7D100000031message_content 为「容器头 + ZSTD 压缩
XML」,解压后可读到祝福语、红包类型(sceneid)、sendid 等。
"""
from __future__ import annotations
import argparse
import hashlib
import os
import re
import sys
import time
try:
os.system("chcp 65001 >nul 2>&1")
except Exception:
pass
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except AttributeError:
pass
from wechatauto import WeChatDB
# 红包卡片 local_type = 0x7D100000031
RED_PACKET_TYPE = 0x7D100000031
TYPE_LABEL = {
1: "文本",
3: "图片",
34: "语音",
43: "视频",
47: "动画表情",
48: "位置",
49: "文件/链接/卡片",
10000: "系统消息",
}
def load_nickname_map(db: WeChatDB) -> dict:
"""wxid -> 昵称/备注"""
mapping = {}
for rel, path, _ in db._db_files:
if os.path.basename(path) != "contact.db":
continue
conn = db._open(rel)
try:
rows = conn.execute(
"SELECT username, nick_name, remark FROM contact"
).fetchall()
finally:
conn.close()
for r in rows:
mapping[r[0]] = r[2] or r[1] or r[0]
break
return mapping
def list_groups(db: WeChatDB) -> list:
"""列出所有群(名称 + wxid + 消息数),按消息数倒序"""
groups = []
for rel, path, _ in db._db_files:
if os.path.basename(path) != "contact.db":
continue
conn = db._open(rel)
try:
rows = conn.execute(
"SELECT username, nick_name, remark FROM contact "
"WHERE username LIKE '%@chatroom'"
).fetchall()
finally:
conn.close()
for r in rows:
groups.append({"name": r[2] or r[1] or r[0], "wxid": r[0]})
break
for g in groups:
g["count"] = _chat_message_count(db, g["wxid"])
groups.sort(key=lambda x: -x["count"])
return groups
def _chat_message_count(db: WeChatDB, wxid: str) -> int:
md5hex = hashlib.md5(wxid.encode()).hexdigest()
total = 0
for rel in db._message_dbs():
conn = db._open(rel)
try:
tabs = [r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")]
for tab in tabs:
if tab == "Msg_%s" % md5hex:
total += conn.execute(
"SELECT COUNT(*) FROM %s" % tab).fetchone()[0]
finally:
conn.close()
return total
def find_group(db: WeChatDB, keyword: str):
kw = keyword.strip()
groups = list_groups(db)
if not kw:
return groups[0] if groups else None
exact = [g for g in groups if g["name"] == kw]
if exact:
return exact[0]
fuzzy = [g for g in groups if kw in g["name"]]
return fuzzy[0] if fuzzy else None
def _raw_messages(db: WeChatDB, wxid: str, limit: int):
"""批量读取该会话最新消息原始行(保留 message_content 二进制),升序返回。
4.x 会把同一会话的表拆分在多个 message_*.db 中(历史遗留),
因此需跨库聚合后按 sort_seq 排序取最新 limit 条。
"""
md5hex = hashlib.md5(wxid.encode()).hexdigest()
tab = "Msg_%s" % md5hex
all_rows = []
for rel in db._message_dbs():
conn = db._open(rel)
try:
exists = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(tab,)).fetchone()
if not exists:
continue
cur = conn.execute(
"SELECT local_id, local_type, real_sender_id, create_time, "
"message_content, sort_seq FROM %s" % tab)
cols = [c[0] for c in cur.description]
all_rows.extend(dict(zip(cols, r)) for r in cur.fetchall())
finally:
conn.close()
all_rows.sort(key=lambda r: r.get("sort_seq") or 0)
return all_rows[-limit:]
def zstd_decompress(content: bytes, max_size: int = 200000) -> str:
"""解 ZSTD:微信 4.x 卡片/红包 message_content 为 ZSTD 压缩 XML"""
if not content:
return ""
if isinstance(content, bytes):
try:
return content.decode("utf-8")
except UnicodeDecodeError:
pass
try:
import zstandard as zstd
dctx = zstd.ZstdDecompressor()
return dctx.decompress(content, max_output_size=max_size).decode(
"utf-8", "ignore")
except ImportError:
return ""
except Exception:
return ""
return str(content)
def parse_red_packet(xml: str) -> dict:
"""从红包 XML 提取关键字段"""
info = {}
if "wcpayinfo" not in xml:
return info
def cdata(tag):
m = re.search(r"<%s><!\[CDATA\[(.*?)\]\]></%s>" % (tag, tag), xml, re.S)
return m.group(1).strip() if m else ""
info["des"] = cdata("des")
info["receivertitle"] = cdata("receivertitle")
info["sendertitle"] = cdata("sendertitle")
info["sceneid"] = cdata("sceneid")
info["paymsgid"] = cdata("paymsgid")
info["fromusername"] = cdata("fromusername")
m = re.search(r"<type><!\[CDATA\[(\d+)\]\]>", xml)
info["type"] = m.group(1) if m else ""
m = re.search(r"total_num=(\d+)", xml)
info["total_num"] = m.group(1) if m else ""
m = re.search(r"<invalidtime><!\[CDATA\[(\d+)\]\]>", xml)
if m:
info["invalidtime"] = int(m.group(1))
return info
def parse_sender(content, sender_id, nickname_map) -> str:
"""解析群消息发送者昵称:
1) 文本 content 前缀 "wxid: 内容"4.x 群消息 sender 藏在这里)
2) 兜底 real_sender_id(已不推荐)
"""
if isinstance(content, bytes):
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
text = ""
else:
text = content
m = re.match(r"^(wxid_[0-9a-zA-Z_-]+|.*@chatroom):\s*", text)
if m:
wx = m.group(1)
return nickname_map.get(wx, wx), m.end()
return "", 0
def classify_message(row: dict, nickname_map: dict) -> dict:
"""识别单条消息:类型 + 发送者昵称 + 内容"""
local_type = row.get("local_type")
if local_type == RED_PACKET_TYPE:
label = "红包"
else:
label = TYPE_LABEL.get(local_type, "类型%d" % local_type)
content = row.get("message_content")
extra = {}
if local_type == RED_PACKET_TYPE:
xml = zstd_decompress(content)
extra = parse_red_packet(xml)
# 发送者:红包用 XML 里的 fromusernamecontent 是二进制无前缀)
if local_type == RED_PACKET_TYPE and extra.get("fromusername"):
fw = extra["fromusername"]
sender = nickname_map.get(fw, fw)
else:
# 文本等:优先 content 前缀
sender, cut = parse_sender(content, row.get("real_sender_id"), nickname_map)
if not sender:
sender = "成员#%s" % row.get("real_sender_id")
if isinstance(content, bytes):
display = content
else:
display = str(content)
# 文本:去掉 wxid 前缀显示正文
if local_type == 1 and cut:
display = display[cut:]
return {
"local_id": row.get("local_id"),
"type": label,
"sender": sender,
"create_time": row.get("create_time"),
"sort_seq": row.get("sort_seq"),
"content": display,
"extra": extra,
}
def print_message(m: dict) -> None:
line = "[%s] %s %s | %s" % (
m["type"], m["sender"], m["create_time"], str(m["content"])[:80])
print(line)
ex = m["extra"]
if ex:
parts = []
if ex.get("des"):
parts.append("描述: %s" % ex["des"])
if ex.get("receivertitle"):
parts.append("祝福语: %s" % ex["receivertitle"])
if ex.get("sceneid"):
parts.append("类型(sceneid): %s" % ex["sceneid"])
if ex.get("total_num"):
parts.append("个数: %s" % ex["total_num"])
if ex.get("paymsgid"):
parts.append("红包ID: %s" % ex["paymsgid"])
if ex.get("fromusername"):
parts.append("来源: %s" % ex["fromusername"])
if parts:
print(" " + " | ".join(parts))
def main() -> None:
parser = argparse.ArgumentParser(
description="读取并识别固定群的最新消息(含红包解析)")
parser.add_argument("group", nargs="?", default="",
help="群名关键词(模糊匹配)")
parser.add_argument("--list-groups", action="store_true",
help="仅列出所有群,不读消息")
parser.add_argument("--limit", type=int, default=20,
help="读取最新 N 条消息(默认 20")
parser.add_argument("--watch", action="store_true",
help="轮询模式:持续读取新消息")
parser.add_argument("--sleep", type=float, default=3.0,
help="轮询间隔秒数(默认 3")
parser.add_argument("--red-only", action="store_true",
help="只显示红包消息")
args = parser.parse_args()
db = WeChatDB()
nickname_map = load_nickname_map(db)
if args.list_groups:
groups = list_groups(db)
print("群列表(%d 个,按消息数倒序):" % len(groups))
for g in groups:
print(" %-28s %-32s %d 条" % (g["name"], g["wxid"], g["count"]))
return
group = find_group(db, args.group)
if not group:
print("未找到群:%r(可用 --list-groups 查看全部群)" % args.group)
sys.exit(1)
print("读取群:%s (%s)\n" % (group["name"], group["wxid"]))
since_seq = 0
while True:
rows = _raw_messages(db, group["wxid"], args.limit)
for row in rows:
if args.watch and row.get("sort_seq", 0) <= since_seq:
continue
info = classify_message(row, nickname_map)
if args.red_only and info["type"] != "红包":
continue
print_message(info)
since_seq = max(since_seq, row.get("sort_seq", 0))
if not args.watch:
break
print("\n--- 等待新消息 (%ds) ---" % args.sleep)
time.sleep(args.sleep)
if __name__ == "__main__":
main()