feat: 微信自动化客服(wechatauto-replica) 干净历史导入 - AI 自动回复/语音收发/朋友圈发布

This commit is contained in:
2026-09-16 10:12:49 +08:00
commit c3b98f4c4e
69 changed files with 19828 additions and 0 deletions
+811
View File
@@ -0,0 +1,811 @@
"""朋友圈(Moments)相关接口实现。
本模块提供两类接口:
- :class:`Moment`UIA 路线,老架构):微信 4.x 自绘界面已不暴露
UIA 节点,此路线在 4.x 上不可用,仅保留兼容;
- :class:`MomentDB`(数据库路线,4.x 推荐):直接读取微信本地
``sns.db`` 的 ``SnsTimeLine`` 表,内容为 ``SnsDataItem`` XML
可稳定获取全部朋友圈(含正文、图片/视频 md5、点赞、评论、定位)。
注:**发朋友圈(PublishMoments)功能已舍弃**——4.x 的发表为自绘
界面操作,无法可靠自动化;本模块仅保留朋友圈读取/点赞/评论能力。
"""
from __future__ import annotations
import html
import os
import re
import time
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Union
from wechatauto import uia
from wechatauto.languages import MOMENTS, get_lang
from wechatauto.logger import wxlog
from wechatauto.param import WxParam, WxResponse
from wechatauto.ui.base import BaseUISubWnd
from wechatauto.utils.tools import find_all_windows_from_root
from wechatauto.utils.win32 import SetClipboardText
def _lang(table, key: str) -> str:
"""根据当前语言环境返回对应文案。"""
return get_lang(table, key)
def _is_time_line(text: str) -> bool:
"""粗略判断一行文本是否为时间信息。"""
if not text:
return False
patterns = [
r"\d{4}\d{1,2}月\d{1,2}日",
r"\d{2}-\d{2}",
r"\d{1,2}:\d{2}",
r"昨[天日]",
r"星期[一二三四五六日天]",
]
return any(re.search(pattern, text) for pattern in patterns)
def _split_like_names(text: str) -> List[str]:
"""解析点赞字符串。"""
if not text:
return []
like_prefix = _lang(MOMENTS, '')
text = text.strip()
if text.startswith(like_prefix):
text = text[len(like_prefix):].lstrip(': ')
sep = _lang(MOMENTS, '分隔符_点赞')
if sep:
parts = [part.strip() for part in text.split(sep) if part.strip()]
else:
parts = [name.strip() for name in re.split(r'[,:]', text) if name.strip()]
return parts
@dataclass
class MomentComment:
"""朋友圈评论数据结构。"""
author: str
content: str
reply_to: Optional[str] = None
raw: str = ''
@classmethod
def from_text(cls, text: str) -> 'MomentComment':
text = text.strip()
reply_to = None
author = ''
content = text
# 格式示例:"张三 回复 李四:你好" 或 "张三: 哈喽"
match = re.match(r'^(?P<author>[^:]+?)\s*(?:回复\s*(?P<reply>[^:]+?)\s*)?[:](?P<content>.*)$', text)
if match:
author = match.group('author').strip()
reply_to = match.group('reply')
if reply_to:
reply_to = reply_to.strip()
content = match.group('content').strip()
else:
author = ''
content = text.strip()
return cls(author=author, content=content, reply_to=reply_to, raw=text)
class MomentItem(BaseUISubWnd):
"""朋友圈单条动态。"""
def __init__(self, control: uia.Control, parent: 'MomentList'):
self.control = control
self.parent = parent
self.root = parent.root
self._parsed = False
self.nickname: str = ''
self.content: str = ''
self.location: Optional[str] = None
self.time: str = ''
self.likes: List[str] = []
self.comments: List[MomentComment] = []
self.image_count: int = 0
self.is_advertisement: bool = False
self._comment_controls: Dict[str, uia.Control] = {}
# ----------------------------------------------------------------------------------------------
# 数据解析
# ----------------------------------------------------------------------------------------------
def _ensure_parsed(self) -> None:
if self._parsed:
return
raw_text = self.control.Name or ''
lines = [line.strip() for line in raw_text.splitlines() if line.strip()]
if lines:
self.nickname = lines[0]
body_lines = lines[1:]
content_lines: List[str] = []
comment_lines: List[str] = []
likes_line: Optional[str] = None
for line in body_lines:
if not line:
continue
if re.search(_lang(MOMENTS, 're_图片数'), line):
count = re.findall(r'\d+', line)
if count:
self.image_count = int(count[0])
continue
if line.startswith(_lang(MOMENTS, '')):
likes_line = line
continue
if line == _lang(MOMENTS, '评论'):
# 后续均为评论
comment_lines.extend(body_lines[body_lines.index(line) + 1:])
break
if _lang(MOMENTS, '广告') in line:
self.is_advertisement = True
continue
if not self.time and _is_time_line(line):
self.time = line
continue
content_lines.append(line)
# 若未在循环中捕获评论,则继续检查剩余行
if not comment_lines:
collecting = False
for line in body_lines:
if line == _lang(MOMENTS, '评论'):
collecting = True
continue
if collecting:
comment_lines.append(line)
if likes_line:
self.likes = _split_like_names(likes_line)
self.content = '\n'.join(content_lines).strip()
self.comments = [MomentComment.from_text(line) for line in comment_lines if line.strip()]
# 记录可用于回复的控件
for child in self.control.GetChildren():
if child.ControlTypeName == 'TextControl':
text = (child.Name or '').strip()
if text:
self._comment_controls.setdefault(text, child)
self._parsed = True
# ----------------------------------------------------------------------------------------------
# 对外属性访问
# ----------------------------------------------------------------------------------------------
@property
def publisher(self) -> str:
self._ensure_parsed()
return self.nickname
@property
def text(self) -> str:
self._ensure_parsed()
return self.content
@property
def timestamp(self) -> str:
self._ensure_parsed()
return self.time
@property
def like_users(self) -> List[str]:
self._ensure_parsed()
return list(self.likes)
@property
def comment_list(self) -> List[MomentComment]:
self._ensure_parsed()
return list(self.comments)
# ----------------------------------------------------------------------------------------------
# 工具方法
# ----------------------------------------------------------------------------------------------
def find_comment(self, author: str) -> Optional[MomentComment]:
self._ensure_parsed()
for comment in self.comments:
if comment.author == author:
return comment
return None
def get_comment_control(self, comment: MomentComment) -> Optional[uia.Control]:
self._ensure_parsed()
key_candidates = [comment.raw, f"{comment.author}: {comment.content}", f"{comment.author}{comment.content}"]
for key in key_candidates:
if key and key in self._comment_controls:
return self._comment_controls[key]
# fallback: 遍历匹配
for text, ctrl in self._comment_controls.items():
if comment.author and text.startswith(comment.author):
if comment.content in text:
return ctrl
return None
class MomentList(BaseUISubWnd):
"""朋友圈时间线列表。"""
def __init__(self, parent: 'Moment'):
self.parent = parent
self.root = parent.root
self.control = self._locate_list(parent)
self._items: Optional[List[MomentItem]] = None
def _locate_list(self, parent: 'Moment') -> Optional[uia.Control]:
wxlog.debug('尝试定位朋友圈列表控件')
# 首先尝试通过常用 className 定位
candidates: Iterable[uia.Control] = []
try:
candidates = parent._api.control.GetChildren()
except Exception:
candidates = []
queue = list(candidates)
visited = set()
while queue:
ctrl = queue.pop(0)
if ctrl in visited:
continue
visited.add(ctrl)
class_name = getattr(ctrl, 'ClassName', '') or ''
automation_id = getattr(ctrl, 'AutomationId', '') or ''
if ctrl.ControlTypeName == 'ListControl' and ('Moment' in class_name or 'moment' in automation_id.lower()):
wxlog.debug(f'找到疑似朋友圈列表控件:{class_name}')
return ctrl
# 朋友圈列表一般会包含"评论"按钮
children = []
try:
children = ctrl.GetChildren()
except Exception:
children = []
if ctrl.ControlTypeName == 'ListControl':
for child in children:
try:
if getattr(child, 'Name', '') == _lang(MOMENTS, '评论'):
wxlog.debug('通过子元素匹配到朋友圈列表控件')
return ctrl
except Exception:
continue
queue.extend(children)
wxlog.debug('未能定位到朋友圈列表控件')
return None
def exists(self, wait: float = 0) -> bool: # type: ignore[override]
if not self.control:
return False
try:
return self.control.Exists(wait)
except Exception:
return False
def refresh(self) -> None:
self._items = None
def get_items(self, refresh: bool = False) -> List[MomentItem]:
if refresh or self._items is None:
self._items = []
if not self.control:
return self._items
try:
children = self.control.GetChildren()
except Exception:
children = []
for child in children:
try:
if child.ControlTypeName in {'ListItemControl', 'CustomControl'}:
text = getattr(child, 'Name', '') or ''
if text.strip():
self._items.append(MomentItem(child, self))
except Exception:
continue
return list(self._items)
class Moment:
"""朋友圈接口封装。"""
def __init__(self, wx_obj):
self._wx = wx_obj
self._api = wx_obj._api
self.root = wx_obj._api
self._list: Optional[MomentList] = None
# ------------------------------------------------------------------------------------------
# 内部工具
# ------------------------------------------------------------------------------------------
def _ensure_list(self) -> Optional[MomentList]:
if self._list and self._list.exists(0):
return self._list
try:
self._wx.SwitchToMoments()
time.sleep(0.2)
except Exception:
wxlog.debug('切换到朋友圈页面失败')
return None
self._list = MomentList(self)
if not self._list.control:
return None
return self._list
# ------------------------------------------------------------------------------------------
# 对外接口
# ------------------------------------------------------------------------------------------
def GetMoments(self, refresh: bool = False) -> List[MomentItem]:
"""获取朋友圈动态列表。
Args:
refresh: 是否强制刷新控件缓存。
Returns:
List[MomentItem]: 朋友圈动态对象列表。
"""
moment_list = self._ensure_list()
if not moment_list:
return []
return moment_list.get_items(refresh)
def FindMomentByPublisher(self, nickname: str, refresh: bool = False) -> Optional[MomentItem]:
"""根据发布者昵称查找朋友圈动态。"""
nickname = nickname.strip()
for item in self.GetMoments(refresh=refresh):
if item.publisher == nickname:
return item
return None
# ------------------------------------------------------------------------------------------
# 点赞与评论(部分功能依赖 UI 结构,尽量保证稳健)
# ------------------------------------------------------------------------------------------
def _invoke_action_menu(self, item: MomentItem) -> Optional['MomentActionMenu']:
action_button = None
try:
for child in item.control.GetChildren():
if child.ControlTypeName == 'ButtonControl':
action_button = child
break
except Exception:
action_button = None
if action_button:
action_button.Click()
else:
try:
item.control.RightClick()
except Exception:
return None
menu = MomentActionMenu(item)
if not menu.exists(0.5):
return None
return menu
def Like(self, item: MomentItem, cancel: bool = False) -> WxResponse:
menu = self._invoke_action_menu(item)
if not menu:
return WxResponse.failure('未能打开朋友圈操作菜单')
try:
return menu.like(cancel)
finally:
menu.close()
def Comment(self, item: MomentItem, content: str, reply_to: Optional[str] = None) -> WxResponse:
if reply_to:
comment = item.find_comment(reply_to)
if not comment:
return WxResponse.failure('未找到需要回复的评论')
ctrl = item.get_comment_control(comment)
if not ctrl:
return WxResponse.failure('未定位到评论控件')
ctrl.Click()
else:
menu = self._invoke_action_menu(item)
if not menu:
return WxResponse.failure('未能打开朋友圈操作菜单')
try:
result = menu.comment()
finally:
menu.close()
if not result:
return result
dialog = MomentCommentDialog(self)
if not dialog.exists(0.5):
return WxResponse.failure('未弹出评论窗口')
return dialog.send(content)
class MomentActionMenu(BaseUISubWnd):
"""朋友圈点赞/评论菜单。"""
_win_cls_name: str = 'Qt51514QWindowToolSaveBits'
def __init__(self, parent: MomentItem, timeout: float = 1.0):
self.parent = parent
self.root = parent.root
self.control = self._locate(timeout)
def _locate(self, timeout: float) -> Optional[uia.Control]:
t0 = time.time()
while time.time() - t0 <= timeout:
wins = find_all_windows_from_root(classname=self._win_cls_name, pid=self.root.pid)
for win in wins:
try:
children = win.GetChildren()
except Exception:
children = []
for child in children:
name = getattr(child, 'Name', '')
if name in {_lang(MOMENTS, ''), _lang(MOMENTS, '取消'), _lang(MOMENTS, '评论')}:
return win
time.sleep(0.05)
return None
def exists(self, wait: float = 0) -> bool: # type: ignore[override]
if not self.control:
return False
try:
return self.control.Exists(wait)
except Exception:
return False
def _find_button(self, names: Iterable[str]) -> Optional[uia.Control]:
if not self.control:
return None
target_names = list(names)
try:
children = self.control.GetChildren()
except Exception:
children = []
for child in children:
if child.ControlTypeName != 'ButtonControl':
continue
name = getattr(child, 'Name', '')
if name in target_names:
return child
return None
def like(self, cancel: bool = False) -> WxResponse:
target_names = [_lang(MOMENTS, '')]
if cancel:
target_names.insert(0, _lang(MOMENTS, '取消'))
button = self._find_button(target_names)
if not button:
return WxResponse.failure('未找到点赞按钮')
button.Click()
return WxResponse.success('操作成功')
def comment(self) -> WxResponse:
button = self._find_button([_lang(MOMENTS, '评论')])
if not button:
return WxResponse.failure('未找到评论按钮')
button.Click()
return WxResponse.success('已触发评论')
def close(self) -> None:
if not self.control:
return
try:
self.control.SendKeys('{Esc}')
except Exception:
pass
class MomentCommentDialog(BaseUISubWnd):
"""朋友圈评论输入窗口。"""
_win_cls_name: str = 'Qt51514QWindowToolSaveBits'
def __init__(self, parent: Moment):
self.parent = parent
self.root = parent.root
self.control = self._locate()
if self.control:
self._init_controls()
def _locate(self) -> Optional[uia.Control]:
wins = find_all_windows_from_root(classname=self._win_cls_name, pid=self.root.pid)
for win in wins:
try:
children = win.GetChildren()
except Exception:
children = []
for child in children:
if child.ControlTypeName == 'ButtonControl' and getattr(child, 'Name', '') == _lang(MOMENTS, '发送'):
return win
return None
def _init_controls(self) -> None:
self.edit: Optional[uia.Control] = None
self.send_button: Optional[uia.Control] = None
try:
children = self.control.GetChildren()
except Exception:
children = []
for child in children:
if child.ControlTypeName == 'EditControl' and self.edit is None:
self.edit = child
elif child.ControlTypeName == 'ButtonControl' and getattr(child, 'Name', '') == _lang(MOMENTS, '发送'):
self.send_button = child
def exists(self, wait: float = 0) -> bool: # type: ignore[override]
if not self.control:
return False
try:
return self.control.Exists(wait)
except Exception:
return False
def send(self, content: str) -> WxResponse:
if not self.exists(0):
return WxResponse.failure('评论窗口不存在')
if not content:
return WxResponse.failure('评论内容不能为空')
if not self.edit or not self.edit.Exists(0):
return WxResponse.failure('未找到评论输入框')
try:
self.edit.Click()
self.edit.SendKeys('{Ctrl}a')
SetClipboardText(content)
self.edit.SendKeys('{Ctrl}v')
if self.send_button and self.send_button.Exists(0):
self.send_button.Click()
else:
self.edit.SendKeys('{Enter}')
except Exception as exc: # pragma: no cover - UI 交互异常仅记录日志
wxlog.debug(f'发送朋友圈评论失败:{exc}')
return WxResponse.failure('发送评论失败')
return WxResponse.success('评论成功')
# ===========================================================================
# 数据库路线(微信 4.x 推荐):读取 sns.db SnsTimeLine
# ===========================================================================
def _parse_user_comment(block: str) -> dict:
"""解析单个 ``<user_comment>...</user_comment>`` 块。"""
def tag(name: str) -> Optional[str]:
m = re.search(r"<%s>([^<]*)</%s>" % (name, name), block)
return m.group(1).strip() if m else None
return {
"username": tag("username") or "",
"nickname": tag("nickname") or "",
"content": tag("content") or "",
"create_time": int(tag("create_time") or 0),
"type": int(tag("type") or 0),
"comment_id": tag("comment_id") or "",
"ref_comment_id": tag("ref_comment_id") or "",
"b_deleted": int(tag("b_deleted") or 0),
}
class MomentDB:
"""微信 4.x 朋友圈数据库读取器(无需 UI/OCR)。
数据来源:``sns.db`` 的 ``SnsTimeLine`` 表,``content`` 为
``SnsDataItem`` XML。支持解析正文、图片/视频 md5 与本地缓存路径、
点赞、评论、定位。
用法::
from wechatauto import WeChatDB, MomentDB
md = MomentDB(WeChatDB())
for feed in md.get_moments(limit=10):
print(feed["nickname"], feed["text"])
"""
def __init__(self, db):
self.db = db
# ------------------------------------------------------------------
# 数据访问
# ------------------------------------------------------------------
def _open_sns(self):
for rel, path, _ in self.db._db_files:
if os.path.basename(path) == "sns.db":
return self.db._open(rel)
raise RuntimeError("未找到 sns.db(朋友圈库)")
def get_moments(self, limit: int = 20, offset: int = 0,
username: Optional[str] = None) -> List[dict]:
"""读取朋友圈时间线(按 tid 倒序)。username 可过滤指定发布者。"""
conn = self._open_sns()
try:
if username:
rows = conn.execute(
"SELECT tid, user_name, content FROM SnsTimeLine "
"WHERE user_name=? ORDER BY tid DESC LIMIT ? OFFSET ?",
(username, limit, offset),
).fetchall()
else:
rows = conn.execute(
"SELECT tid, user_name, content FROM SnsTimeLine "
"ORDER BY tid DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
finally:
conn.close()
return [self.parse_feed(r["content"], r["user_name"]) for r in rows]
def get_moment(self, tid: int) -> Optional[dict]:
conn = self._open_sns()
try:
r = conn.execute(
"SELECT tid, user_name, content FROM SnsTimeLine WHERE tid=?",
(tid,),
).fetchone()
finally:
conn.close()
return self.parse_feed(r["content"], r["user_name"]) if r else None
def get_my_moments(self, limit: int = 20) -> List[dict]:
return self.get_moments(limit=limit, username=self.db.wxid)
def count(self) -> int:
conn = self._open_sns()
try:
return conn.execute("SELECT count(*) FROM SnsTimeLine").fetchone()[0]
finally:
conn.close()
# ------------------------------------------------------------------
# XML 解析
# ------------------------------------------------------------------
def parse_feed(self, xml: str, fallback_user: str = "") -> dict:
if isinstance(xml, bytes):
xml = xml.decode("utf-8", "replace")
m = re.search(r"<id>(\d+)</id>", xml)
feed_id = m.group(1) if m else ""
m = re.search(r"<username>([^<]+)</username>", xml)
author = m.group(1) if m else (fallback_user or "")
m = re.search(r"<createTime>(\d+)</createTime>", xml)
create_time = int(m.group(1)) if m else 0
m = re.search(r"<contentDesc>([^<]*)</contentDesc>", xml)
text = html.unescape(m.group(1)) if m else ""
m = re.search(r'<location latitude="([^"]*)" longitude="([^"]*)"', xml)
location = {"latitude": m.group(1), "longitude": m.group(2)} if m else None
# 图片 / 视频(mediaList 中的 url 带 md5
images, videos = [], []
for mm in re.finditer(
r'<media>.*?<url[^>]*md5="([0-9a-fA-F]{32})"[^>]*>(.*?)</url>', xml, re.S
):
md5 = mm.group(1).lower()
url = mm.group(2).strip()
if any(ext in url.lower() for ext in (".mp4", ".mov", ".avi")):
videos.append({"md5": md5, "url": url})
else:
images.append({"md5": md5, "url": url})
# 点赞 / 评论
likes, comments = [], []
lm = re.search(r"<like_user_list>(.*?)</like_user_list>", xml, re.S)
if lm:
for blk in re.findall(r"<user_comment>(.*?)</user_comment>", lm.group(1), re.S):
c = _parse_user_comment(blk)
if c["username"]:
likes.append(c)
cm = re.search(r"<comment_user_list>(.*?)</comment_user_list>", xml, re.S)
if cm:
for blk in re.findall(r"<user_comment>(.*?)</user_comment>", cm.group(1), re.S):
c = _parse_user_comment(blk)
if c["username"] and not c["b_deleted"]:
comments.append(c)
return {
"id": feed_id,
"tid": fallback_user,
"username": author,
"nickname": self._nick(author),
"text": text,
"location": location,
"create_time": create_time,
"images": images,
"videos": videos,
"likes": likes,
"comments": comments,
}
def _nick(self, username: str) -> str:
if not username:
return ""
try:
return self.db.get_nickname(username)
except Exception:
return username
# ------------------------------------------------------------------
# 媒体落地
# ------------------------------------------------------------------
@staticmethod
def _cache_root(db) -> str:
return os.path.join(db.account_dir, "cache")
def find_local_media(self, md5: str, kind: str = "image") -> Optional[str]:
"""在 cache/<月>/Sns/<Img|Video> 下按 md5 查找本地缓存文件。"""
if len(md5) < 2:
return None
sub = "Img" if kind == "image" else "Video"
for root, _, files in os.walk(self._cache_root(self.db)):
if os.sep + "Sns" + os.sep + sub not in os.sep + root + os.sep:
continue
for f in files:
if f.startswith(md5):
return os.path.join(root, f)
return None
def download_media(self, media: dict, save_dir: Optional[str] = None,
kind: str = "image") -> Optional[str]:
"""下载朋友圈图片/视频:优先本地缓存,否则从 URL 拉取。"""
save_dir = save_dir or os.path.join(
os.path.expanduser("~"), "Documents", "wechatauto_moments"
)
os.makedirs(save_dir, exist_ok=True)
md5 = (media or {}).get("md5")
local = self.find_local_media(md5, kind) if md5 else None
ext = ".jpg"
if local:
src = local
data = open(src, "rb").read()
else:
url = (media or {}).get("url")
if not url:
return None
try:
import urllib.request
req = urllib.request.Request(
url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0)"}
)
data = urllib.request.urlopen(req, timeout=20).read()
except Exception as e:
wxlog.debug(f'下载朋友圈媒体失败:{e}')
return None
if url.lower().endswith((".mp4", ".mov", ".avi")):
ext = ".mp4"
out = os.path.join(save_dir, "%s%s" % (md5 or os.path.basename(url or "media"), ext))
with open(out, "wb") as f:
f.write(data)
return out