头图

摘要

微信4.1版本的UI采用新的框架开发,能够获取到的信息有限,目前只能获取到消息列表的控件内容。

代码

import time
import threading
import uiautomation as auto
from win10toast import ToastNotifier
import tkinter as tk
from tkinter import ttk
import re

shown_msgs = set()
FILE_NAME = "messages.txt"

import re

def parse_chat_name_flexible(raw_name: str):
    """
    灵活解析微信未读消息,并去掉【消息免打扰】字样和方括号中的未读数
    """
    if not raw_name:
        return None

    # 去掉消息免打扰
    raw_name = re.sub(r'[\[\(【(]*消息免打扰[\]\)】)]*', '', raw_name)

    parts = raw_name.split()
    if len(parts) < 2:
        return None

    user = parts[0]
    unread = "0条未读"
    msg = ""
    sender = ""
    time_str = ""

    start_idx = 1

    # 已置顶
    if "已置顶" in parts:
        start_idx += 1

    # 查找未读条数
    for i in range(start_idx, min(start_idx + 2, len(parts))):
        if "条未读" in parts[i]:
            unread = parts[i]
            start_idx = i + 1
            break

    # 查找时间
    for i in range(len(parts) - 1, start_idx - 1, -1):
        if ':' in parts[i] or '/' in parts[i]:
            time_str = parts[i]
            end_idx = i
            break
    else:
        end_idx = len(parts)

    msg_parts = parts[start_idx:end_idx]
    msg = " ".join(msg_parts)

    # 拆分发送人和消息内容
    if ":" in msg:
        sender, msg_content = msg.split(":", 1)
    elif ":" in msg:  # 中文冒号
        sender, msg_content = msg.split(":", 1)
    else:
        sender, msg_content = "", msg

    # 清理发送人里的 [n条]
    sender = re.sub(r'\[\d+条\]', '', sender).strip()

    return {
        "user": user.strip(),
        "unread": unread.strip(),
        "sender": sender.strip(),
        "msg": msg_content.strip(),
        "time": time_str.strip()
    }


def find_unread_sessions():
    desktop = auto.GetRootControl()
    chat_cells = []

    for control, depth in auto.WalkControl(desktop):
        if control.ClassName == "mmui::ChatSessionCell":
            if "未读" in (control.Name or ""):
                parsed = parse_chat_name_flexible(control.Name)
                if parsed:
                    chat_cells.append(parsed)

    return chat_cells


def monitor_messages(tree):
    # 确保子线程正确初始化 COM
    with auto.UIAutomationInitializerInThread():
        toaster = ToastNotifier()
        while True:
            try:
                sessions = find_unread_sessions()

                for s in sessions:
                    key = f"{s['user']}_{s['msg']}_{s['time']}"
                    if key not in shown_msgs:
                        title = f"{s['user']} ({s['unread']})"
                        msg = f"{s['msg']}  [{s['time']}]"
                        print(f"[通知] {title} -> {msg}")

                        # 系统通知
                        toaster.show_toast(title, msg, duration=3, threaded=True)

                        # 写入txt
                        with open(FILE_NAME, "a", encoding="utf-8") as f:
                            f.write(f"{s['time']}\t{s['user']}\t{s['unread']}\t{s['sender']}\t{s['msg']}\n")

                        # 更新表格
                        tree.after(0, lambda s=s: tree.insert(
                            "", "end",
                            values=(s['time'], s['user'], s['unread'], s['sender'], s['msg'])
                        ))

                        shown_msgs.add(key)

            except Exception as e:
                print(f"[错误] {e}")

            time.sleep(2)


def start_gui():
    root = tk.Tk()
    root.title("微信未读消息监控")
    root.geometry("1200x550")

    style = ttk.Style(root)
    style.theme_use("default")
    style.configure("Treeview", rowheight=30, font=("Microsoft YaHei", 11))
    style.configure("Treeview.Heading", font=("Microsoft YaHei", 12, "bold"), anchor="center")

    # 新增 sender 列
    columns = ("time", "user", "unread", "sender", "msg")
    tree = ttk.Treeview(root, columns=columns, show="headings")
    tree.heading("time", text="时间")
    tree.heading("user", text="用户")
    tree.heading("unread", text="未读")
    tree.heading("sender", text="发送人")
    tree.heading("msg", text="消息内容")

    # 设置列宽和对齐
    tree.column("time", width=100, anchor="center")
    tree.column("user", width=120, anchor="center")
    tree.column("unread", width=80, anchor="center")
    tree.column("sender", width=120, anchor="center")
    tree.column("msg", width=500, anchor="center")

    # 滚动条
    scrollbar = ttk.Scrollbar(root, orient="vertical", command=tree.yview)
    tree.configure(yscroll=scrollbar.set)
    scrollbar.pack(side="right", fill="y")

    tree.pack(fill="both", expand=True)

    # 启动后台线程
    t = threading.Thread(target=monitor_messages, args=(tree,), daemon=True)
    t.start()

    root.mainloop()

if __name__ == "__main__":
    start_gui()

使用

需要将微信打开,不能关闭,可最小化,但绝对不能叉掉。

image.png

然后允许这个Python脚本即可。

Python wxmsg.py

目前支持的功能:

  1. 读取私信未读的最新的那条消息
  2. 读取群最新的未读的那条消息
  3. 读取最新的收款记录
  4. 会在右下角弹出
  5. 会有ui界面,记录消息
  6. 读取公众号最新发布的消息

界面

image.png

2025-10-25

部分用户反馈获取不到消息,我这次基于4.1.1.19版本写了一个脚本,大家可以试试。

image.png

import uiautomation as auto

def find_target(control, class_name, name):
    """递归查找指定 ClassName + Name 的控件"""
    if control.ClassName == class_name and control.Name == name:
        return control
    try:
        for c in control.GetChildren():
            result = find_target(c, class_name, name)
            if result:
                return result
    except Exception:
        pass
    return None

def find_all_names_by_class(control, class_name, results=None):
    """递归查找所有指定 ClassName 的控件并提取 Name"""
    if results is None:
        results = []
    try:
        for c in control.GetChildren():
            if c.ClassName == class_name:
                results.append(c.Name)
            find_all_names_by_class(c, class_name, results)
    except Exception:
        pass
    return results

# 查找微信主 XView
root = auto.Control(searchDepth=10, ClassName='mmui::XView')
if not root:
    raise Exception('未找到 XView')

# 查找会话列表 XTableView
x_tableview = find_target(root, 'mmui::XTableView', '会话')
if not x_tableview:
    raise Exception('未找到 mmui::XTableView | 会话')

# 提取所有 ChatSessionCell 的 Name
names = find_all_names_by_class(x_tableview, 'mmui::ChatSessionCell')

# 分割昵称和消息输出
for i, full_name in enumerate(names, 1):
    if not full_name:
        continue
    parts = full_name.split(' ', 1)  # 第一个空格分割
    nickname = parts[0]
    message = parts[1] if len(parts) > 1 else ''
    print(f"[{i}] 昵称: {nickname} | 消息: {message}")

image.png

2025-11-22仍可用

image.png

image.png

2026-02-25更新

基于 4.1.7.30 新版适配重新写的界面。

88a5bc6b720b467e29b9a0206cd64926.png

代码

# -*- coding: utf-8 -*-
# pip install pyqt5 uiautomation
import sys
import re
import datetime
import uiautomation as auto
from PyQt5 import QtCore, QtWidgets

TARGET_DEPTH = 14


def is_time_line(text: str):
    text = text.strip()
    return bool(re.match(r'^\d{1,2}:\d{2}$', text) or re.match(r'^\d{2}/\d{2}$', text))


def parse_session(name_block: str):
    lines = [l.strip() for l in name_block.splitlines()]
    lines = [l for l in lines if l]
    if not lines:
        return None

    session_name = lines[0]
    time_text = None
    for l in reversed(lines):
        if is_time_line(l):
            time_text = l
            break

    ignore_keywords = ['已置顶', '消息免打扰', '撤销']
    message_line = None
    for l in lines[1:]:
        if l == time_text:
            continue
        if any(k in l for k in ignore_keywords):
            continue
        message_line = l

    if not message_line:
        return None

    msg_type = "文本"
    if ':' in message_line:
        sender, content = message_line.split(':', 1)
        return {
            "group_name": session_name,
            "sender": sender.strip().strip('"'),
            "content": content.strip(),
            "time": time_text,
            "msg_type": msg_type
        }

    return {
        "group_name": session_name,
        "sender": "我",
        "content": message_line,
        "time": time_text,
        "msg_type": msg_type
    }


def time_to_sort_key(time_str):
    """把 HH:MM 或 MM/DD 转成 datetime,用于排序"""
    if not time_str:
        return datetime.datetime.min
    try:
        if re.match(r'^\d{1,2}:\d{2}$', time_str):
            h, m = map(int, time_str.split(":"))
            now = datetime.datetime.now()
            return datetime.datetime(now.year, now.month, now.day, h, m)
        elif re.match(r'^\d{2}/\d{2}$', time_str):
            month, day = map(int, time_str.split("/"))
            now = datetime.datetime.now()
            return datetime.datetime(now.year, month, day)
    except:
        return datetime.datetime.min
    return datetime.datetime.min


class FetchThread(QtCore.QThread):
    data_signal = QtCore.pyqtSignal(list)

    def __init__(self, interval=1.5):
        super().__init__()
        self.interval = interval
        self._running = False
        auto.SetGlobalSearchTimeout(10)

    def run(self):
        self._running = True
        while self._running:
            try:
                results = self.fetch_data()
                if results:
                    # 按时间倒序排序
                    results.sort(key=lambda x: time_to_sort_key(x.get("time")), reverse=True)
                    self.data_signal.emit(results)
            except:
                pass
            self.msleep(int(self.interval * 1000))

    def stop(self):
        self._running = False

    def set_interval(self, interval):
        self.interval = interval

    def fetch_data(self):
        result_list = []
        root = auto.GetRootControl()
        target = root.Control(searchDepth=5, ClassName='mmui::MainWindow')
        if not target.Exists(2):
            return result_list

        def dump(control, depth=0):
            if depth == TARGET_DEPTH:
                try:
                    if control.ClassName == 'mmui::ChatSessionCell' and control.Name:
                        result = parse_session(control.Name)
                        if result:
                            result_list.append(result)
                except:
                    pass
                return
            for child in control.GetChildren():
                dump(child, depth + 1)

        dump(target)
        return result_list


class MainWindow(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.resize(1300, 690)
        self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
        self.setAttribute(QtCore.Qt.WA_TranslucentBackground)

        self.main_layout = QtWidgets.QVBoxLayout(self)
        self.main_layout.setContentsMargins(8, 8, 8, 8)

        self.container = QtWidgets.QFrame()
        self.container.setObjectName("container")
        self.container_layout = QtWidgets.QVBoxLayout(self.container)
        self.container_layout.setContentsMargins(20, 20, 20, 20)
        self.main_layout.addWidget(self.container)

        # 顶部栏
        title_bar = QtWidgets.QHBoxLayout()
        self.container_layout.addLayout(title_bar)
        self.title = QtWidgets.QLabel("微信会话实时监听")
        self.title.setStyleSheet("font-size:18px;font-weight:bold;")
        self.close_btn = QtWidgets.QPushButton("✕")
        self.close_btn.setFixedSize(36, 32)
        self.close_btn.clicked.connect(self.close)
        title_bar.addWidget(self.title)
        title_bar.addStretch()
        title_bar.addWidget(self.close_btn)

        # 切换按钮
        self.toggle_btn = QtWidgets.QPushButton()
        self.toggle_btn.setFixedSize(120, 60)
        self.toggle_btn.setStyleSheet("font-size:18px;font-weight:bold;")
        self.container_layout.addWidget(self.toggle_btn, alignment=QtCore.Qt.AlignLeft)

        # 表格
        self.table = QtWidgets.QTableWidget()
        self.table.setColumnCount(5)
        self.table.setHorizontalHeaderLabels(
            ["昵称", "发送者", "消息类型", "内容", "时间"]
        )
        self.table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
        self.table.verticalHeader().setVisible(False)
        self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
        self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
        self.table.setWordWrap(True)
        self.container_layout.addWidget(self.table)

        # 线程
        self.thread = FetchThread(interval=1.5)
        self.thread.data_signal.connect(self.update_table)

        self.toggle_btn.clicked.connect(self.toggle_listen)

        self.setStyleSheet("""
        QWidget {
            font-family: "Microsoft YaHei";
            color: #e6e6e6;
        }
        #container {
            background-color: #1e1f26;
            border-radius: 14px;
        }
        QPushButton {
            border-radius: 10px;
            font-size:18px;
            font-weight:bold;
        }
        QTableWidget {
            background-color: #232530;
            gridline-color: #2f3240;
        }
        QHeaderView::section {
            background-color: #2d2f3a;
            border: none;
            padding: 6px;
        }
        """)

        self._drag_pos = None
        self.listening = False
        self.start_listen()
        self.shown_msgs = set()

    # 拖动
    def mousePressEvent(self, event):
        if event.button() == QtCore.Qt.LeftButton:
            self._drag_pos = event.globalPos() - self.frameGeometry().topLeft()
            event.accept()

    def mouseMoveEvent(self, event):
        if self._drag_pos and event.buttons() == QtCore.Qt.LeftButton:
            self.move(event.globalPos() - self._drag_pos)
            event.accept()

    def mouseReleaseEvent(self, event):
        self._drag_pos = None

    # 监听控制
    def toggle_listen(self):
        if self.listening:
            self.stop_listen()
        else:
            self.start_listen()

    def start_listen(self):
        if not self.thread.isRunning():
            self.thread.start()
        self.listening = True
        self.toggle_btn.setText("停止监听")
        self.toggle_btn.setStyleSheet("""
            QPushButton {
                background-color: #c0392b;
                border-radius: 10px;
                font-size:18px;
                font-weight:bold;
            }
            QPushButton:hover {
                background-color: #e74c3c;
            }
        """)

    def stop_listen(self):
        self.thread.stop()
        self.listening = False
        self.toggle_btn.setText("开始监听")
        self.toggle_btn.setStyleSheet("""
            QPushButton {
                background-color: #27ae60;
                border-radius: 10px;
                font-size:18px;
                font-weight:bold;
            }
            QPushButton:hover {
                background-color: #2ecc71;
            }
        """)

    # 表格追加,最新在上
    def add_center_item(self, row, col, text):
        item = QtWidgets.QTableWidgetItem(text)
        item.setTextAlignment(QtCore.Qt.AlignCenter)
        self.table.setItem(row, col, item)

    # 修改 update_table 方法
    def update_table(self, data):
        for item in data:
            msg_key = (item["group_name"], item["sender"], item["content"], item["time"])
            if msg_key in self.shown_msgs:
                continue  # 已渲染过就跳过
            self.shown_msgs.add(msg_key)

            self.table.insertRow(0)  # 最新消息插在最上面
            self.add_center_item(0, 0, item["group_name"])
            self.add_center_item(0, 1, item["sender"])
            self.add_center_item(0, 2, item["msg_type"])
            content_item = QtWidgets.QTableWidgetItem(item["content"])
            content_item.setTextAlignment(QtCore.Qt.AlignCenter)
            content_item.setToolTip(item["content"])
            self.table.setItem(0, 3, content_item)
            self.add_center_item(0, 4, item["time"] or "")
            self.table.setRowHeight(0, 60)
            self.table.setColumnWidth(3, 300)


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

作者

TANKING


TANKING
4.9k 声望614 粉丝

热爱分享,热爱创作,热爱研究。