commit
ae016a6af7
7 changed files with 477 additions and 0 deletions
@ -0,0 +1,33 @@ |
|||||
|
"""全局配置""" |
||||
|
|
||||
|
from __future__ import annotations |
||||
|
|
||||
|
from dataclasses import dataclass, field |
||||
|
|
||||
|
|
||||
|
@dataclass |
||||
|
class Config: |
||||
|
"""监听运行配置""" |
||||
|
|
||||
|
# ---- 微信窗口 ---- |
||||
|
wechat_exe: str = "WeChat.exe" # 微信进程名 |
||||
|
wechat_title: str = "微信" # 微信主窗口标题关键字 |
||||
|
|
||||
|
# ---- 轮询 ---- |
||||
|
poll_interval: float = 1.0 # 消息轮询间隔(秒) |
||||
|
idle_poll_interval: float = 3.0 # 空闲时的轮询间隔 |
||||
|
|
||||
|
# ---- 消息 ---- |
||||
|
max_message_length: int = 10_000 # 单条消息最大字符数 |
||||
|
dedup_window: int = 300 # 消息去重窗口(秒) |
||||
|
|
||||
|
# ---- 存储 ---- |
||||
|
sqlite_path: str = "wechat_messages.db" |
||||
|
redis_url: str = "" # 为空则不启用 Redis |
||||
|
|
||||
|
# ---- 日志 ---- |
||||
|
log_level: str = "INFO" |
||||
|
log_file: str = "wechat_monitor.log" |
||||
|
|
||||
|
# ---- 监控目标 ---- |
||||
|
watch_conversations: list[str] = field(default_factory=list) # 为空则监控所有 |
||||
@ -0,0 +1,50 @@ |
|||||
|
"""消息数据模型""" |
||||
|
|
||||
|
from __future__ import annotations |
||||
|
|
||||
|
import time |
||||
|
from dataclasses import dataclass, field |
||||
|
from enum import Enum |
||||
|
from typing import Any |
||||
|
|
||||
|
|
||||
|
class MessageType(Enum): |
||||
|
TEXT = "text" |
||||
|
IMAGE = "image" |
||||
|
FILE = "file" |
||||
|
VOICE = "voice" |
||||
|
VIDEO = "video" |
||||
|
LINK = "link" |
||||
|
SYSTEM = "system" |
||||
|
UNKNOWN = "unknown" |
||||
|
|
||||
|
|
||||
|
@dataclass |
||||
|
class Message: |
||||
|
"""单条聊天消息""" |
||||
|
|
||||
|
msg_id: str |
||||
|
conversation: str # 会话名称(联系人/群名) |
||||
|
sender: str # 发送者 |
||||
|
content: str # 消息内容(文本 / 文件路径 / 图片路径等) |
||||
|
msg_type: MessageType = MessageType.TEXT |
||||
|
timestamp: float = field(default_factory=time.time) |
||||
|
is_self: bool = False # 是否自己发送 |
||||
|
raw_data: dict[str, Any] = field(default_factory=dict) |
||||
|
|
||||
|
def to_dict(self) -> dict[str, Any]: |
||||
|
return { |
||||
|
"msg_id": self.msg_id, |
||||
|
"conversation": self.conversation, |
||||
|
"sender": self.sender, |
||||
|
"content": self.content, |
||||
|
"msg_type": self.msg_type.value, |
||||
|
"timestamp": self.timestamp, |
||||
|
"is_self": self.is_self, |
||||
|
} |
||||
|
|
||||
|
@classmethod |
||||
|
def from_dict(cls, data: dict[str, Any]) -> Message: |
||||
|
data = data.copy() |
||||
|
data["msg_type"] = MessageType(data["msg_type"]) |
||||
|
return cls(**data) |
||||
@ -0,0 +1,5 @@ |
|||||
|
from .wechat import WeChatWindow |
||||
|
from .conversation import ConversationList |
||||
|
from .message import MessageList |
||||
|
|
||||
|
__all__ = ["WeChatWindow", "ConversationList", "MessageList"] |
||||
@ -0,0 +1,103 @@ |
|||||
|
"""会话列表操作""" |
||||
|
|
||||
|
from __future__ import annotations |
||||
|
|
||||
|
import logging |
||||
|
import time |
||||
|
|
||||
|
import uiautomation as uia |
||||
|
|
||||
|
from ..config import Config |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
class ConversationList: |
||||
|
"""操作微信左侧会话列表""" |
||||
|
|
||||
|
def __init__(self, window: uia.WindowControl, config: Config) -> None: |
||||
|
self._window = window |
||||
|
self.config = config |
||||
|
self._list: uia.ListControl | None = None |
||||
|
|
||||
|
# ------------------------------------------------------------------ |
||||
|
# 初始化面板 |
||||
|
# ------------------------------------------------------------------ |
||||
|
|
||||
|
def _ensure_list(self) -> uia.ListControl | None: |
||||
|
if self._list is not None and self._list.Exists(maxSearchSeconds=0.5): |
||||
|
return self._list |
||||
|
try: |
||||
|
# 微信会话列表通常是一个 ListControl |
||||
|
# 通过搜索树定位第一个 ListControl |
||||
|
lst = self._window.ListControl(searchDepth=8) |
||||
|
if lst.Exists(maxSearchSeconds=2): |
||||
|
self._list = lst |
||||
|
return lst |
||||
|
except Exception: |
||||
|
logger.debug("定位会话列表失败") |
||||
|
return None |
||||
|
|
||||
|
# ------------------------------------------------------------------ |
||||
|
# 公开方法 |
||||
|
# ------------------------------------------------------------------ |
||||
|
|
||||
|
def get_items(self) -> list[uia.Control]: |
||||
|
"""返回会话列表中的所有会话项""" |
||||
|
lst = self._ensure_list() |
||||
|
if lst is None: |
||||
|
return [] |
||||
|
try: |
||||
|
return lst.GetChildren() |
||||
|
except Exception: |
||||
|
logger.exception("获取会话列表项失败") |
||||
|
return [] |
||||
|
|
||||
|
def get_current_conversation_name(self) -> str: |
||||
|
"""获取当前激活的会话名称(聊天窗口顶部标题)""" |
||||
|
try: |
||||
|
# 聊聊窗口标题区域 |
||||
|
edit = self._window.EditControl(searchDepth=8) |
||||
|
if edit.Exists(maxSearchSeconds=1): |
||||
|
return edit.Name or "" |
||||
|
except Exception: |
||||
|
pass |
||||
|
return "" |
||||
|
|
||||
|
def click_conversation(self, name: str) -> bool: |
||||
|
"""点击指定名称的会话""" |
||||
|
lst = self._ensure_list() |
||||
|
if lst is None: |
||||
|
return False |
||||
|
try: |
||||
|
for item in lst.GetChildren(): |
||||
|
if name in (item.Name or ""): |
||||
|
item.Click() |
||||
|
time.sleep(0.5) |
||||
|
logger.info("已切换到会话: %s", name) |
||||
|
return True |
||||
|
except Exception: |
||||
|
logger.exception("点击会话失败: %s", name) |
||||
|
logger.warning("未找到会话: %s", name) |
||||
|
return False |
||||
|
|
||||
|
def search_conversation(self, keyword: str) -> bool: |
||||
|
"""通过搜索框定位会话""" |
||||
|
try: |
||||
|
search_edit = self._window.EditControl(Name="搜索", searchDepth=8) |
||||
|
if not search_edit.Exists(maxSearchSeconds=2): |
||||
|
search_edit = self._window.EditControl(searchDepth=3) |
||||
|
if search_edit.Exists(maxSearchSeconds=1): |
||||
|
search_edit.Click() |
||||
|
time.sleep(0.3) |
||||
|
search_edit.SendKeys(keyword, interval=0.05) |
||||
|
time.sleep(1.0) |
||||
|
# 点击第一个搜索结果 |
||||
|
result = self._window.ListItemControl(searchDepth=8) |
||||
|
if result.Exists(maxSearchSeconds=2): |
||||
|
result.Click() |
||||
|
time.sleep(0.5) |
||||
|
return True |
||||
|
except Exception: |
||||
|
logger.exception("搜索会话失败: %s", keyword) |
||||
|
return False |
||||
@ -0,0 +1,169 @@ |
|||||
|
"""从微信聊天面板提取消息""" |
||||
|
|
||||
|
from __future__ import annotations |
||||
|
|
||||
|
import hashlib |
||||
|
import logging |
||||
|
import time |
||||
|
from typing import Any |
||||
|
|
||||
|
import uiautomation as uia |
||||
|
|
||||
|
from ..models.message import Message, MessageType |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
class MessageList: |
||||
|
"""从聊天面板中读取消息列表""" |
||||
|
|
||||
|
def __init__(self, window: uia.WindowControl) -> None: |
||||
|
self._window = window |
||||
|
self._chat_list: uia.ListControl | None = None |
||||
|
|
||||
|
# ------------------------------------------------------------------ |
||||
|
# 内部 |
||||
|
# ------------------------------------------------------------------ |
||||
|
|
||||
|
def _ensure_chat_list(self) -> uia.ListControl | None: |
||||
|
if self._chat_list is not None and self._chat_list.Exists(maxSearchSeconds=0.5): |
||||
|
return self._chat_list |
||||
|
try: |
||||
|
# 聊天消息列表 — 搜索较深层级 |
||||
|
lst = self._window.ListControl(searchDepth=10) |
||||
|
if lst.Exists(maxSearchSeconds=2): |
||||
|
self._chat_list = lst |
||||
|
return lst |
||||
|
except Exception: |
||||
|
logger.debug("定位聊天消息列表失败") |
||||
|
return None |
||||
|
|
||||
|
@staticmethod |
||||
|
def _make_msg_id(conversation: str, sender: str, content: str, ts: float) -> str: |
||||
|
raw = f"{conversation}|{sender}|{content}|{ts}" |
||||
|
return hashlib.md5(raw.encode()).hexdigest() |
||||
|
|
||||
|
# ------------------------------------------------------------------ |
||||
|
# 公开方法 |
||||
|
# ------------------------------------------------------------------ |
||||
|
|
||||
|
def extract_messages(self, conversation: str = "") -> list[Message]: |
||||
|
"""提取当前聊天面板中的所有可见消息""" |
||||
|
lst = self._ensure_chat_list() |
||||
|
if lst is None: |
||||
|
return [] |
||||
|
|
||||
|
messages: list[Message] = [] |
||||
|
try: |
||||
|
items = lst.GetChildren() |
||||
|
except Exception: |
||||
|
logger.exception("获取消息列表子项失败") |
||||
|
return [] |
||||
|
|
||||
|
for item in items: |
||||
|
msg = self._parse_item(item, conversation) |
||||
|
if msg is not None: |
||||
|
messages.append(msg) |
||||
|
|
||||
|
return messages |
||||
|
|
||||
|
def get_latest_messages(self, conversation: str = "", since: float = 0) -> list[Message]: |
||||
|
"""获取指定时间戳之后的新消息""" |
||||
|
all_msgs = self.extract_messages(conversation) |
||||
|
return [m for m in all_msgs if m.timestamp > since] |
||||
|
|
||||
|
# ------------------------------------------------------------------ |
||||
|
# 解析单条消息控件 |
||||
|
# ------------------------------------------------------------------ |
||||
|
|
||||
|
def _parse_item(self, item: uia.Control, conversation: str) -> Message | None: |
||||
|
"""尝试将一个 ListItem 解析为 Message""" |
||||
|
try: |
||||
|
name = item.Name or "" |
||||
|
if not name: |
||||
|
return None |
||||
|
|
||||
|
# 判断是否自己发送 — 通过控件的某些属性 |
||||
|
is_self = self._guess_is_self(item) |
||||
|
sender = self._extract_sender(item, is_self) |
||||
|
content = name # Name 通常就是消息内容 |
||||
|
msg_type = self._guess_type(item, content) |
||||
|
|
||||
|
ts = time.time() |
||||
|
msg_id = self._make_msg_id(conversation, sender, content, ts) |
||||
|
|
||||
|
return Message( |
||||
|
msg_id=msg_id, |
||||
|
conversation=conversation, |
||||
|
sender=sender, |
||||
|
content=content, |
||||
|
msg_type=msg_type, |
||||
|
timestamp=ts, |
||||
|
is_self=is_self, |
||||
|
raw_data={"automation_id": item.AutomationId, "class_name": item.ClassName}, |
||||
|
) |
||||
|
except Exception: |
||||
|
logger.debug("解析消息控件失败", exc_info=True) |
||||
|
return None |
||||
|
|
||||
|
def _guess_is_self(self, item: uia.Control) -> bool: |
||||
|
"""启发式判断消息是否为自己发送""" |
||||
|
try: |
||||
|
# 微信中,自己的消息通常在右侧,ClassName 可能包含 "Self" 或类似标识 |
||||
|
cls_name = (item.ClassName or "").lower() |
||||
|
if "self" in cls_name or "right" in cls_name: |
||||
|
return True |
||||
|
# 也可以通过位置判断(控件在父容器右侧) |
||||
|
rect = item.BoundingRectangle |
||||
|
parent_rect = item.GetParentControl().BoundingRectangle if item.GetParentControl() else None |
||||
|
if parent_rect and rect: |
||||
|
mid = (parent_rect.left + parent_rect.right) / 2 |
||||
|
return rect.left > mid |
||||
|
except Exception: |
||||
|
pass |
||||
|
return False |
||||
|
|
||||
|
def _extract_sender(self, item: uia.Control, is_self: bool) -> str: |
||||
|
"""提取消息发送者名称""" |
||||
|
try: |
||||
|
# 尝试从子控件中获取发送者名称 |
||||
|
for child in item.GetChildren(): |
||||
|
name = child.Name or "" |
||||
|
# 发送者名称通常较短且不是消息内容本身 |
||||
|
if name and len(name) < 30 and child.ControlTypeName in ( |
||||
|
"TextControl", "ButtonControl", |
||||
|
): |
||||
|
return name |
||||
|
except Exception: |
||||
|
pass |
||||
|
return "self" if is_self else "unknown" |
||||
|
|
||||
|
@staticmethod |
||||
|
def _guess_type(item: uia.Control, content: str) -> MessageType: |
||||
|
"""启发式判断消息类型""" |
||||
|
try: |
||||
|
cls_name = (item.ClassName or "").lower() |
||||
|
if "image" in cls_name or "pic" in cls_name: |
||||
|
return MessageType.IMAGE |
||||
|
if "file" in cls_name: |
||||
|
return MessageType.FILE |
||||
|
if "voice" in cls_name or "audio" in cls_name: |
||||
|
return MessageType.VOICE |
||||
|
if "video" in cls_name: |
||||
|
return MessageType.VIDEO |
||||
|
except Exception: |
||||
|
pass |
||||
|
|
||||
|
# 内容判断 |
||||
|
if content.startswith("[") and content.endswith("]"): |
||||
|
tag = content.strip("[]") |
||||
|
if "图片" in tag or "image" in tag.lower(): |
||||
|
return MessageType.IMAGE |
||||
|
if "文件" in tag or "file" in tag.lower(): |
||||
|
return MessageType.FILE |
||||
|
if "语音" in tag or "voice" in tag.lower(): |
||||
|
return MessageType.VOICE |
||||
|
if "视频" in tag or "video" in tag.lower(): |
||||
|
return MessageType.VIDEO |
||||
|
|
||||
|
return MessageType.TEXT |
||||
@ -0,0 +1,117 @@ |
|||||
|
"""微信窗口定位与基本交互 — 基于 uiautomation""" |
||||
|
|
||||
|
from __future__ import annotations |
||||
|
|
||||
|
import logging |
||||
|
import time |
||||
|
|
||||
|
import uiautomation as uia |
||||
|
|
||||
|
from ..config import Config |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
class WeChatWindow: |
||||
|
"""封装微信主窗口的查找、激活、以及子面板获取""" |
||||
|
|
||||
|
def __init__(self, config: Config) -> None: |
||||
|
self.config = config |
||||
|
self._window: uia.WindowControl | None = None |
||||
|
|
||||
|
# ------------------------------------------------------------------ |
||||
|
# 公开方法 |
||||
|
# ------------------------------------------------------------------ |
||||
|
|
||||
|
def find_window(self) -> uia.WindowControl | None: |
||||
|
"""查找微信主窗口(不激活)""" |
||||
|
try: |
||||
|
win = uia.WindowControl( |
||||
|
searchDepth=1, |
||||
|
Name=self.config.wechat_title, |
||||
|
ClassName="WeChatMainWndForPC", |
||||
|
) |
||||
|
if win.Exists(maxSearchSeconds=2): |
||||
|
self._window = win |
||||
|
logger.info("找到微信窗口: %s", win.Name) |
||||
|
return win |
||||
|
except Exception: |
||||
|
logger.debug("通过 ClassName 查找失败,尝试按标题查找") |
||||
|
win = uia.WindowControl( |
||||
|
searchDepth=1, |
||||
|
Name=self.config.wechat_title, |
||||
|
) |
||||
|
if win.Exists(maxSearchSeconds=2): |
||||
|
self._window = win |
||||
|
logger.info("找到微信窗口: %s", win.Name) |
||||
|
return win |
||||
|
|
||||
|
logger.warning("未找到微信窗口") |
||||
|
self._window = None |
||||
|
return None |
||||
|
|
||||
|
def activate(self) -> bool: |
||||
|
"""激活(前置)微信窗口""" |
||||
|
win = self._window or self.find_window() |
||||
|
if win is None: |
||||
|
return False |
||||
|
try: |
||||
|
win.SetFocus() |
||||
|
time.sleep(0.3) |
||||
|
return True |
||||
|
except Exception: |
||||
|
logger.exception("激活微信窗口失败") |
||||
|
return False |
||||
|
|
||||
|
def get_window(self) -> uia.WindowControl | None: |
||||
|
return self._window |
||||
|
|
||||
|
def is_alive(self) -> bool: |
||||
|
"""窗口是否仍然存在""" |
||||
|
win = self._window |
||||
|
if win is None: |
||||
|
return False |
||||
|
try: |
||||
|
return win.Exists(maxSearchSeconds=1) |
||||
|
except Exception: |
||||
|
return False |
||||
|
|
||||
|
def get_chat_panel(self) -> uia.Control | None: |
||||
|
"""获取当前聊天消息列表面板""" |
||||
|
win = self._window or self.find_window() |
||||
|
if win is None: |
||||
|
return None |
||||
|
# 聊天消息区域通常在 "ChatPanel" 或包含消息列表的 ListControl |
||||
|
try: |
||||
|
# 微信 PC 版的消息列表一般是一个 ListControl,ClassName 包含 "ChatMsgList" |
||||
|
panel = win.ListControl(searchDepth=8) |
||||
|
if panel.Exists(maxSearchSeconds=2): |
||||
|
return panel |
||||
|
except Exception: |
||||
|
logger.debug("通过 ListControl 查找聊天面板失败") |
||||
|
|
||||
|
# 备选方案:搜索名称包含 "消息" 的控件 |
||||
|
try: |
||||
|
panel = win.ListControl(Name="消息", searchDepth=8) |
||||
|
if panel.Exists(maxSearchSeconds=1): |
||||
|
return panel |
||||
|
except Exception: |
||||
|
pass |
||||
|
|
||||
|
logger.warning("未找到聊天消息面板") |
||||
|
return None |
||||
|
|
||||
|
def get_conversation_list_panel(self) -> uia.Control | None: |
||||
|
"""获取左侧会话列表面板""" |
||||
|
win = self._window or self.find_window() |
||||
|
if win is None: |
||||
|
return None |
||||
|
try: |
||||
|
# 会话列表通常在左侧,ClassName 包含 "SessionList" |
||||
|
panel = win.ListControl(searchDepth=8) |
||||
|
if panel.Exists(maxSearchSeconds=2): |
||||
|
return panel |
||||
|
except Exception: |
||||
|
pass |
||||
|
logger.warning("未找到会话列表面板") |
||||
|
return None |
||||
Loading…
Reference in new issue