WechatOnCloud/bridge/woc_bridge/ui/drivers/moment.py

1049 lines
43 KiB
Python
Raw Normal View History

"""朋友圈子 driver发表图文 / 点赞 / 评论 / 删除。
xdotool_driver.py 单类中拆出的朋友圈域方法继承 BaseDriver 复用公共能力
find_wechat_window 归属 WindowDriver driver 通过构造函数注入
window_driver 引用调用
方法清单与原 XdotoolDriver 签名一致
- publish_moment(content, timeout_sec=20.0) -> str
任务描述称 publish_moment_text XdotoolDriver 实际命名为 publish_moment此处保持原名
- publish_moment_with_image(image_path, content="", timeout_sec=25.0) -> str
- moment_like(moment_index=1, timeout_sec=20.0) -> bool
- moment_comment(comment, moment_index=1, timeout_sec=20.0) -> bool
- moment_delete(moment_index=1, timeout_sec=20.0) -> bool
"""
from __future__ import annotations
import asyncio
import logging
import os
import random
import time
from typing import Any, Awaitable, Optional, TYPE_CHECKING
from woc_bridge.models import BridgeError
from woc_bridge.ui.drivers.base import BaseDriver
if TYPE_CHECKING:
from woc_bridge.ui.drivers.window import WindowDriver
logger = logging.getLogger("woc-bridge")
class MomentDriver(BaseDriver):
"""朋友圈子 driver。"""
def __init__(
self,
display: str,
backend=None,
window_driver: Optional["WindowDriver"] = None,
) -> None:
super().__init__(display, backend)
# 注入兄弟 driverpublish / moment_* 依赖 WindowDriver.find_wechat_window
self._window_driver = window_driver
# Task 11: moment_like post_verify 用存储点赞前的截图PNG bytes
# 由 moment_like 在点击"赞"之前捕获post_verify_moment_like 读取比对
self._moment_like_before_shot: Optional[bytes] = None
# P0 修复:评论前截图,供 post_verify_moment_comment 比对
self._moment_comment_before_shot: Optional[bytes] = None
# ------------------------------------------------------------------
# 命令执行包装(保持与原 XdotoolDriver._run 语义一致)
# ------------------------------------------------------------------
async def _run(
self,
args: list[str],
*,
input_bytes: Optional[bytes] = None,
) -> tuple[int, bytes, bytes]:
"""执行一条命令并返回 (returncode, stdout, stderr)。
委托 BaseDriver._run_managedTimeoutError BridgeError(code=SEND_FAILED)
"""
try:
return await self._run_managed(args)
except asyncio.TimeoutError as exc:
logger.error("[ui] command timeout: %s", args[0] if args else "?")
raise BridgeError(
code="SEND_FAILED",
message=f"命令超时: {args[0] if args else '?'}",
) from exc
async def _key(self, key: str, repeat: int = 1) -> None:
"""执行 xdotool key [--repeat N] <key>。"""
logger.info("[ui] key: %s repeat=%d", key, repeat)
args = ["xdotool", "key"]
if repeat > 1:
args.extend(["--repeat", str(repeat)])
args.append(key)
rc, _, _ = await self._run(args)
logger.info("[ui] key %s -> rc=%s", key, rc)
async def _sleep(self, seconds: float) -> None:
"""asyncio.sleep 封装,便于测试与统一调速。"""
logger.info("[ui] sleep %.2fs", seconds)
await asyncio.sleep(seconds)
async def _paste_via_xclip(self, text: str) -> None:
"""通过 xdotool type 直接向聚焦控件输入文本。
微信 4.x Linux 自绘 UI 不响应 Ctrl+V改用 xdotool type 逐字符输入
方法名保留 _paste_via_xclip 以维持调用点稳定
注意
- \n / \r 替换为空格避免 xdotool 把换行解析为 Return
- 使用 --delay 30ms 降低输入速度减少中文 IME 字符丢失
Raises:
BridgeError(SEND_FAILED): xdotool type 执行失败
"""
normalized = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
normalized = " ".join(normalized.split())
if normalized != text:
logger.info("[ui] type text normalized: original_len=%d len=%d", len(text), len(normalized))
text = normalized
logger.info("[ui] type text: len=%d", len(text))
rc, _, stderr = await self._run(["xdotool", "type", "--delay", "30", "--", text])
if rc != 0:
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
raise BridgeError(
code="SEND_FAILED",
message=f"xdotool type 失败 (code={rc}): {err}",
)
logger.info("[ui] type done rc=%s", rc)
async def _click(self, x: int, y: int) -> None:
"""移动鼠标到 (x, y) 并左键单击(分两条命令版本)。"""
rc, _, _ = await self._run(
["xdotool", "mousemove", "--sync", str(x), str(y)]
)
if rc != 0:
raise BridgeError(
code="SEND_FAILED",
message=f"移动鼠标到 ({x},{y}) 失败",
)
rc, _, _ = await self._run(["xdotool", "click", "1"])
if rc != 0:
raise BridgeError(
code="SEND_FAILED",
message=f"点击 ({x},{y}) 失败",
)
async def _long_press(self, x: int, y: int, hold_sec: float = 1.5) -> None:
"""移动鼠标到 (x, y) 并长按左键 hold_sec 秒后释放。
try/finally 保证 mouseup 一定执行避免超时/取消时
mousedown 状态泄漏导致整个 UI 自动化瘫痪拖拽锁死
"""
rc, _, _ = await self._run(
["xdotool", "mousemove", "--sync", str(x), str(y)]
)
if rc != 0:
raise BridgeError(
code="SEND_FAILED",
message=f"长按:移动鼠标到 ({x},{y}) 失败",
)
rc, _, _ = await self._run(["xdotool", "mousedown", "1"])
if rc != 0:
raise BridgeError(
code="SEND_FAILED",
message=f"长按 ({x},{y}) mousedown 失败",
)
try:
await self._sleep(hold_sec)
finally:
rc, _, _ = await asyncio.shield(
self._run(["xdotool", "mouseup", "1"])
)
if rc != 0:
logger.warning(
"_long_press mouseup 失败 (rc=%d),鼠标可能仍处于按下状态", rc
)
async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
"""激活微信窗口并校验焦点是否真正落到该窗口上。
先用非阻塞 windowactivate 避免 --sync VNC 无人操作时死等
然后轮询 getactivewindow 最多 2 秒确认目标窗口已获得焦点
"""
if window_id is None:
window_id = await self._window_driver.find_wechat_window()
if window_id is None:
raise BridgeError(
code="WINDOW_NOT_FOUND",
message="未找到微信窗口,无法激活",
)
await self._run(["xdotool", "windowactivate", str(window_id)])
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
rc, stdout, _ = await self._run(["xdotool", "getactivewindow"])
if rc == 0:
active_text = stdout.decode(errors="ignore").strip()
try:
if int(active_text) == window_id:
return
except ValueError:
pass
await self._sleep(0.1)
logger.warning(
"[ui] 窗口激活校验失败: window_id=%s 未在 2s 内成为活动窗口", window_id
)
async def _get_window_geometry(self) -> tuple[int, int, int, int]:
"""获取微信窗口几何信息,返回 (win_x, win_y, win_w, win_h)。
复用 BaseDriver._parse_window_geometry 消除内联解析重复
"""
window_id = await self._window_driver.find_wechat_window()
if window_id is None:
raise BridgeError(
code="WINDOW_NOT_FOUND",
message="未找到微信窗口",
)
returncode, stdout, _ = await self._run(
["xdotool", "getwindowgeometry", "--shell", str(window_id)]
)
if returncode != 0:
raise BridgeError(
code="SEND_FAILED",
message="无法获取微信窗口几何信息",
)
geom = self._parse_window_geometry(stdout.decode(errors="ignore"))
if geom is None:
raise BridgeError(
code="SEND_FAILED",
message="解析窗口几何信息失败",
)
return (geom.x, geom.y, geom.width, geom.height)
async def _enter_moments_page(self) -> tuple[int, int, int, int]:
"""进入朋友圈页面并返回窗口几何 (win_x, win_y, win_w, win_h)。
统一朋友圈互动接口like/comment/delete的前置步骤
激活窗口 获取几何 关闭弹窗 点击朋友圈入口 等待打开
避免在聊天主界面执行朋友圈操作导致误删聊天消息C3 风险
"""
window_id = await self._window_driver.find_wechat_window()
if window_id is None:
raise BridgeError(
code="WINDOW_NOT_FOUND",
message="未找到微信窗口,无法进入朋友圈",
)
await self._activate_window_fast(window_id)
await self._sleep(0.3)
win_x, win_y, win_w, win_h = await self._get_window_geometry()
# 关闭可能存在的弹窗/搜索框
await self._key("Escape")
await self._sleep(0.2)
# 点击左侧栏「朋友圈」图标(估算:左栏底部偏上)
moment_x = win_x + 30
moment_y = win_y + win_h - 90
await self._click(moment_x, moment_y)
await self._sleep(1.0)
return (win_x, win_y, win_w, win_h)
async def _capture_screenshot_png(self) -> Optional[bytes]:
"""截取当前屏幕返回 PNG 字节流Task 11 post_verify 用)。
优先委托 backend.screenshot()XdotoolBackend 已实现 scrot -o -
失败或无 backend 时回退 legacy scrot 写临时文件再读取任何异常
返回 Nonepost_verify 调用方负责 None 兜底
"""
if self._backend is not None and hasattr(self._backend, "screenshot"):
try:
return await self._backend.screenshot()
except Exception as exc:
logger.warning("[ui] backend.screenshot 异常: %s", exc)
return None
# legacy: scrot 写临时文件
import tempfile
tmp_path = tempfile.mktemp(suffix=".png")
try:
rc, _, _ = await self._run(["scrot", tmp_path])
if rc != 0:
return None
with open(tmp_path, "rb") as f:
return f.read()
except Exception as exc:
logger.warning("[ui] legacy scrot 截图异常: %s", exc)
return None
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
# ------------------------------------------------------------------
# 发表朋友圈(纯文字)
# ------------------------------------------------------------------
async def publish_moment(
self,
content: str,
timeout_sec: float = 20.0,
) -> str:
"""发表纯文字朋友圈。
流程
1. 激活微信窗口
2. 获取窗口几何点击左侧栏朋友圈图标进入朋友圈页
3. 点击右上角相机图标 选择发表文字
4. 粘贴文字内容
5. 点击发表按钮
6. Esc 关闭朋友圈页回到主界面
注意下方点击坐标均为估算值基于微信 4.0 Linux 默认布局
需在目标分辨率实测后调优
Args:
content: 朋友圈文字内容
timeout_sec: 整体超时默认 20
Returns:
本地生成的 local_moment_id格式 moment_<unix秒>_<随机>
Raises:
BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
BridgeError(SEND_FAILED): 超时或 UI 操作失败
"""
if not content:
raise BridgeError(
code="SEND_FAILED",
message="content 不能为空,无法发表朋友圈",
)
deadline = time.monotonic() + timeout_sec
# 1. 激活窗口(非阻塞,避免 --sync 死等)
window_id = await self._window_driver.find_wechat_window()
if window_id is None:
raise BridgeError(
code="WINDOW_NOT_FOUND",
message="未找到微信窗口,无法发表朋友圈",
)
await self._step(self._activate_window_fast(window_id), "激活窗口", deadline)
await self._step(self._sleep(0.3), "等待窗口激活", deadline)
# 2. 获取窗口几何
returncode, stdout, _ = await self._run(
["xdotool", "getwindowgeometry", "--shell", str(window_id)]
)
if returncode != 0:
raise BridgeError(
code="SEND_FAILED",
message="无法获取微信窗口几何信息",
)
geom = self._parse_window_geometry(stdout.decode(errors="ignore"))
if geom is None:
raise BridgeError(
code="SEND_FAILED",
message="解析窗口几何信息失败",
)
win_x, win_y, win_w, win_h = geom.x, geom.y, geom.width, geom.height
# 3. 关闭可能存在的弹窗/搜索框
await self._step(self._key("Escape"), "关闭弹窗", deadline)
await self._step(self._sleep(0.2), "等待 Esc 生效", deadline)
# 4. 点击左侧栏「朋友圈」图标(估算:左栏底部偏上)
moment_x = win_x + 30
moment_y = win_y + win_h - 90
await self._step(self._click(moment_x, moment_y), "点击朋友圈入口", deadline)
await self._step(self._sleep(1.0), "等待朋友圈页打开", deadline)
# 5. 点击右上角相机图标(估算:窗口右上角)
camera_x = win_x + win_w - 30
camera_y = win_y + 30
await self._step(self._click(camera_x, camera_y), "点击相机图标", deadline)
await self._step(self._sleep(0.6), "等待发表菜单弹出", deadline)
# 6. 选择「发表文字」(菜单第 1 项,按 Down 选中 + 回车)
await self._step(self._key("Down"), "选中发表文字", deadline)
await self._step(self._sleep(0.2), "等待选中", deadline)
await self._step(self._key("Return"), "进入发表文字", deadline)
await self._step(self._sleep(0.8), "等待发表文字弹窗", deadline)
# 7. 粘贴文字内容
await self._step(self._paste_via_xclip(content), "粘贴朋友圈内容", deadline)
await self._step(self._sleep(0.3), "等待粘贴完成", deadline)
# 8. 点击「发表」按钮(估算:弹窗右上角)
publish_x = win_x + win_w - 60
publish_y = win_y + 60
await self._step(self._click(publish_x, publish_y), "点击发表按钮", deadline)
await self._step(self._sleep(1.0), "等待发表完成", deadline)
# 9. 点击左上角返回按钮回到聊天主界面(估算:窗口左上角)
back_x = win_x + 30
back_y = win_y + 30
await self._step(self._click(back_x, back_y), "点击返回按钮", deadline)
await self._step(self._sleep(0.5), "等待返回主界面", deadline)
# 兜底 Esc关闭可能残留的弹窗如发表成功提示
await self._step(self._key("Escape"), "兜底关闭弹窗", deadline)
await self._step(self._sleep(0.2), "等待焦点稳定", deadline)
# 10. 生成 local_moment_id
local_moment_id = f"moment_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
return local_moment_id
# ------------------------------------------------------------------
# 发表带图片的朋友圈
# ------------------------------------------------------------------
async def publish_moment_with_image(
self,
image_path: str,
content: str = "",
timeout_sec: float = 25.0,
) -> str:
"""发表带图片的朋友圈experimental
Args:
image_path: 图片文件容器内绝对路径
content: 可选文字内容
Returns:
本地生成的 local_moment_id格式 moment_img_<unix秒>_<随机>
Raises:
BridgeError(INVALID_PARAMS): 图片不存在或不可读
BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
BridgeError(SEND_FAILED): 超时或 UI 操作失败
"""
if not image_path or not os.path.isfile(image_path):
raise BridgeError(
code="INVALID_PARAMS",
message=f"图片不存在: {image_path}",
)
if not os.access(image_path, os.R_OK):
raise BridgeError(
code="INVALID_PARAMS",
message=f"图片不可读: {image_path}",
)
deadline = time.monotonic() + timeout_sec
# 复用纯文字朋友圈的前置步骤:激活窗口 → 进入朋友圈 → 点相机
window_id = await self._window_driver.find_wechat_window()
if window_id is None:
raise BridgeError(
code="WINDOW_NOT_FOUND",
message="未找到微信窗口,无法发表朋友圈",
)
await self._step(self._activate_window_fast(window_id), "激活窗口", deadline)
await self._step(self._sleep(0.3), "等待窗口激活", deadline)
returncode, stdout, _ = await self._run(
["xdotool", "getwindowgeometry", "--shell", str(window_id)]
)
if returncode != 0:
raise BridgeError(
code="SEND_FAILED",
message="无法获取微信窗口几何信息",
)
geom = self._parse_window_geometry(stdout.decode(errors="ignore"))
if geom is None:
raise BridgeError(
code="SEND_FAILED",
message="解析窗口几何信息失败",
)
win_x, win_y, win_w, win_h = geom.x, geom.y, geom.width, geom.height
# 进入朋友圈
await self._step(self._key("Escape"), "关闭弹窗", deadline)
await self._step(self._sleep(0.2), "等待 Esc 生效", deadline)
moment_x = win_x + 30
moment_y = win_y + win_h - 90
await self._step(self._click(moment_x, moment_y), "点击朋友圈入口", deadline)
await self._step(self._sleep(1.0), "等待朋友圈页打开", deadline)
# 点相机 → 选"发表图片"(第 2 项Down 2 次)
camera_x = win_x + win_w - 30
camera_y = win_y + 30
await self._step(self._click(camera_x, camera_y), "点击相机图标", deadline)
await self._step(self._sleep(0.6), "等待发表菜单弹出", deadline)
await self._step(self._key("Down"), "选中发表图片", deadline)
await self._step(self._sleep(0.2), "等待选中", deadline)
await self._step(self._key("Down"), "选中发表图片", deadline)
await self._step(self._sleep(0.2), "等待选中", deadline)
await self._step(self._key("Return"), "进入发表图片", deadline)
await self._step(self._sleep(0.8), "等待文件选择器", deadline)
# 文件选择器:粘贴图片路径并回车
await self._step(self._paste_via_xclip(image_path), "粘贴图片路径", deadline)
await self._step(self._sleep(0.3), "等待粘贴完成", deadline)
await self._step(self._key("Return"), "确认选择图片", deadline)
await self._step(self._sleep(1.0), "等待图片加载", deadline)
# 可选:输入文字
if content and content.strip():
await self._step(self._paste_via_xclip(content), "粘贴文字内容", deadline)
await self._step(self._sleep(0.3), "等待粘贴完成", deadline)
# 点击"发表"按钮
publish_x = win_x + win_w - 60
publish_y = win_y + 60
await self._step(self._click(publish_x, publish_y), "点击发表按钮", deadline)
await self._step(self._sleep(1.0), "等待发表完成", deadline)
# 返回主界面
back_x = win_x + 30
back_y = win_y + 30
await self._step(self._click(back_x, back_y), "点击返回按钮", deadline)
await self._step(self._sleep(0.5), "等待返回主界面", deadline)
await self._step(self._key("Escape"), "兜底关闭弹窗", deadline)
await self._step(self._sleep(0.2), "等待焦点稳定", deadline)
local_moment_id = f"moment_img_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
logger.info(
"publish_moment_with_image: image=%s content_len=%d → 成功 local_moment_id=%s",
image_path, len(content) if content else 0, local_moment_id,
)
return local_moment_id
# ------------------------------------------------------------------
# 朋友圈点赞experimental
# ------------------------------------------------------------------
async def moment_like(
self,
moment_index: int = 1,
timeout_sec: float = 20.0,
) -> bool:
"""给朋友圈点赞experimental
自动进入朋友圈页面后再执行点赞操作避免在聊天主界面误触
Args:
moment_index: 朋友圈在时间线中的序号1=最新2=次新...
Returns:
True 表示 UI 操作流程执行完成
"""
if moment_index < 1:
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {moment_index}",
)
deadline = time.monotonic() + timeout_sec
# 进入朋友圈页面(自动激活窗口 + 导航)
win_x, win_y, win_w, win_h = await self._step(
self._enter_moments_page(), "进入朋友圈页面", deadline
)
# Task 11: 点击"赞"之前捕获 before 截图,供 post_verify_moment_like 比对
self._moment_like_before_shot = await self._capture_screenshot_png()
# 估算第 N 条朋友圈的"赞/评论"按钮位置
like_x = win_x + win_w - 30
like_y = win_y + 90 + (moment_index - 1) * 200
await self._step(self._click(like_x, like_y), "点击赞/评论按钮", deadline)
await self._step(self._sleep(0.5), "等待弹窗", deadline)
# 弹窗中"赞"通常是第一项
await self._step(self._key("Down"), "选中赞", deadline)
await self._step(self._sleep(0.2), "等待选中", deadline)
await self._step(self._key("Return"), "点击赞", deadline)
await self._step(self._sleep(0.5), "等待点赞完成", deadline)
logger.info("moment_like: index=%d → UI 操作完成", moment_index)
return True
# ------------------------------------------------------------------
# 朋友圈评论experimental
# ------------------------------------------------------------------
async def moment_comment(
self,
comment: str,
moment_index: int = 1,
timeout_sec: float = 20.0,
) -> bool:
"""评论朋友圈experimental
自动进入朋友圈页面后再执行评论操作
Args:
comment: 评论文字内容
moment_index: 朋友圈序号1=最新
"""
if not comment:
raise BridgeError(
code="INVALID_PARAMS",
message="comment 不能为空",
)
if moment_index < 1:
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {moment_index}",
)
deadline = time.monotonic() + timeout_sec
# 进入朋友圈页面
win_x, win_y, win_w, win_h = await self._step(
self._enter_moments_page(), "进入朋友圈页面", deadline
)
# P0 修复:评论前捕获 before 截图,供 post_verify_moment_comment 比对。
# 原实现 post_verify 在评论后才截 before导致 before/after 都是评论后状态,
# 截图比对结果与评论是否生效完全无关。
self._moment_comment_before_shot = await self._capture_screenshot_png()
# 点击"赞/评论"按钮
like_x = win_x + win_w - 30
like_y = win_y + 90 + (moment_index - 1) * 200
await self._step(self._click(like_x, like_y), "点击赞/评论按钮", deadline)
await self._step(self._sleep(0.5), "等待弹窗", deadline)
# "评论"通常是第二项
await self._step(self._key("Down"), "选中评论", deadline)
await self._step(self._sleep(0.2), "等待选中", deadline)
await self._step(self._key("Down"), "选中评论", deadline)
await self._step(self._sleep(0.2), "等待选中", deadline)
await self._step(self._key("Return"), "点击评论", deadline)
await self._step(self._sleep(0.5), "等待输入框", deadline)
# 粘贴评论内容
await self._step(self._paste_via_xclip(comment), "粘贴评论内容", deadline)
await self._step(self._sleep(0.3), "等待粘贴完成", deadline)
# 发送
await self._step(self._key("Return"), "发送评论", deadline)
await self._step(self._sleep(0.5), "等待发送完成", deadline)
logger.info(
"moment_comment: index=%d comment_len=%d → UI 操作完成",
moment_index, len(comment),
)
return True
# ------------------------------------------------------------------
# 删除朋友圈experimental
# ------------------------------------------------------------------
async def moment_delete(
self,
moment_index: int = 1,
timeout_sec: float = 20.0,
) -> bool:
"""删除自己的朋友圈experimental
自动进入朋友圈页面后再执行删除操作避免在聊天主界面
长按消息导致误删聊天消息不可逆数据丢失
Args:
moment_index: 朋友圈序号1=最新
"""
if moment_index < 1:
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {moment_index}",
)
deadline = time.monotonic() + timeout_sec
# 进入朋友圈页面(关键安全步骤:避免在聊天界面误删消息)
win_x, win_y, win_w, win_h = await self._step(
self._enter_moments_page(), "进入朋友圈页面", deadline
)
# P0 修复:记录删除前的朋友圈数量基线,供 post_verify 比对
self._moment_delete_baseline_count: Optional[int] = None
# 长按自己的朋友圈触发删除菜单
moment_x = win_x + win_w // 2
moment_y = win_y + 90 + (moment_index - 1) * 200
await self._step(self._long_press(moment_x, moment_y, 1.5), "长按朋友圈", deadline)
await self._step(self._sleep(0.6), "等待菜单弹出", deadline)
# 选择"删除"
await self._step(self._key("Down"), "导航到删除", deadline)
await self._step(self._sleep(0.2), "等待选中", deadline)
await self._step(self._key("Return"), "选择删除", deadline)
await self._step(self._sleep(0.5), "等待确认对话框", deadline)
# 确认删除
await self._step(self._key("Return"), "确认删除", deadline)
await self._step(self._sleep(0.5), "等待删除完成", deadline)
logger.info("moment_delete: index=%d → UI 操作完成", moment_index)
return True
# ------------------------------------------------------------------
# Task 11: post_verify 校验方法UI 操作成功后校验 DB/截图,返回 verified bool
# ------------------------------------------------------------------
async def post_verify_moment_like(
self,
db_reader: Any = None,
moment_index: int = 1,
sleep_sec: float = 1.0,
timeout_sec: float = 3.0,
) -> bool:
"""校验点赞是否生效Task 11
截图比对点赞前后同位置像素差异OpenCV absdiff
- moment_like 在点击""之前已捕获 before 截图存于
self._moment_like_before_shot
- 本方法 sleep 1s 后捕获 after 截图转灰度后 absdiff
差异像素数 > 阈值 verified=True点赞按钮状态变化
- OpenCV 不可用或 before 截图为 None 返回 False 并告警
Args:
db_reader: 兼容签名点赞校验不读 DB保留参数统一接口
moment_index: 朋友圈序号仅用于日志
sleep_sec: after 截图前等待秒数
timeout_sec: 兼容签名截图校验无需轮询仅用 sleep_sec
Returns:
True 表示检测到点赞位置像素变化False 表示未检测到或无法校验
"""
before = self._moment_like_before_shot
# 用完即清,避免下次复用旧截图
self._moment_like_before_shot = None
if before is None:
logger.warning(
"post_verify_moment_like: before 截图为 None跳过校验 (index=%d)",
moment_index,
)
return False
try:
import cv2
import numpy as np
except ImportError:
logger.warning(
"post_verify_moment_like: OpenCV 不可用,跳过截图校验 (index=%d)",
moment_index,
)
return False
await asyncio.sleep(sleep_sec)
after = await self._capture_screenshot_png()
if after is None:
logger.warning(
"post_verify_moment_like: after 截图失败 (index=%d)",
moment_index,
)
return False
try:
before_arr = np.frombuffer(before, dtype=np.uint8)
after_arr = np.frombuffer(after, dtype=np.uint8)
before_img = cv2.imdecode(before_arr, cv2.IMREAD_GRAYSCALE)
after_img = cv2.imdecode(after_arr, cv2.IMREAD_GRAYSCALE)
if before_img is None or after_img is None:
logger.warning(
"post_verify_moment_like: 截图 decode 失败 (index=%d)",
moment_index,
)
return False
# 尺寸不一致时 resize 对齐
if before_img.shape != after_img.shape:
after_img = cv2.resize(
after_img, (before_img.shape[1], before_img.shape[0])
)
diff = cv2.absdiff(before_img, after_img)
# 像素差异 > 30 的点数占比 > 0.5% 即认为有变化
changed_pixels = int(np.count_nonzero(diff > 30))
total_pixels = before_img.shape[0] * before_img.shape[1]
ratio = changed_pixels / total_pixels if total_pixels > 0 else 0.0
logger.info(
"post_verify_moment_like: 像素差异 changed=%d ratio=%.4f (index=%d)",
changed_pixels, ratio, moment_index,
)
return ratio > 0.005
except Exception as exc:
logger.warning(
"post_verify_moment_like: absdiff 计算异常 (index=%d): %s",
moment_index, exc,
)
return False
async def post_verify_moment_comment(
self,
db_reader: Any,
comment: str,
moment_index: int = 1,
sleep_sec: float = 2.0,
timeout_sec: float = 3.0,
) -> bool:
"""校验评论是否生效Task 11
探测 MomentsComment 表存在性存在则走 DB 校验 comment 列含
comment 子串的记录不存在走截图兜底absdiff 比对发送前后
DB 不可用db_reader None时静默返回 True
Args:
db_reader: DbReader 实例可为 None
comment: 期望的评论文字内容
moment_index: 朋友圈序号仅用于日志
sleep_sec: 首次查询前等待秒数
timeout_sec: 轮询超时秒数
Returns:
True 表示检测到评论写入或截图变化False 表示未检测到
"""
if db_reader is None:
logger.warning(
"post_verify_moment_comment: db_reader 为 None跳过校验 (index=%d)",
moment_index,
)
return True
await asyncio.sleep(sleep_sec)
# 探测 MomentsComment 表是否存在
comment_table = await asyncio.to_thread(
self._probe_moment_table, db_reader, ["comment", "Comments", "MomentComment", "MomentsComment"]
)
if comment_table is not None:
# DB 校验:查 comment 列含目标子串
deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline:
try:
found = await asyncio.to_thread(
self._query_comment_in_table, db_reader, comment_table, comment
)
if found:
return True
except Exception as exc:
logger.warning(
"post_verify_moment_comment: DB 查询异常 (index=%d): %s",
moment_index, exc,
)
await asyncio.sleep(1.0)
logger.warning(
"post_verify_moment_comment: DB 校验超时 %.1fs (index=%d)",
timeout_sec, moment_index,
)
return False
# 截图兜底(无 MomentsComment 表)
logger.info(
"post_verify_moment_comment: MomentsComment 表不存在,走截图兜底 (index=%d)",
moment_index,
)
# P0 修复:使用 moment_comment UI 操作前捕获的 before 截图,
# 而非在此处重新截 before此时评论已完成before/after 都是评论后状态)
before = self._moment_comment_before_shot
self._moment_comment_before_shot = None # 用完即清
if before is None:
logger.warning(
"post_verify_moment_comment: before 截图为 None跳过校验 (index=%d)",
moment_index,
)
return False
await asyncio.sleep(1.0)
after = await self._capture_screenshot_png()
return self._screenshot_absdiff_changed(before, after, "comment", moment_index)
async def post_verify_moment_delete(
self,
db_reader: Any,
moment_index: int = 1,
sleep_sec: float = 2.0,
timeout_sec: float = 3.0,
baseline_count: Optional[int] = None,
) -> bool:
"""校验删除是否生效Task 11
P0 修复原实现只要 DB 可读status=ok就返回 True完全未校验删除生效
现改为比对删除前后的朋友圈数量baseline_count 由路由层在删除前捕获
删除后查询当前数量 < baseline_count 则认为删除成功
Args:
db_reader: DbReader 实例可为 None
moment_index: 被删除的朋友圈序号仅用于日志
sleep_sec: 首次查询前等待秒数
timeout_sec: 轮询超时秒数
baseline_count: 删除前的朋友圈数量基线None 表示无法建立基线
Returns:
True 表示检测到朋友圈数量减少删除生效
False 表示未检测到变化或无法校验
baseline_count=None 时返回 TrueDB 不可用跳过校验不阻塞流程
"""
if db_reader is None:
logger.warning(
"post_verify_moment_delete: db_reader 为 None跳过校验 (index=%d)",
moment_index,
)
return True
# 无基线时无法校验数量变化,返回 True不阻塞但记录告警
if baseline_count is None:
logger.warning(
"post_verify_moment_delete: 无基线 count跳过校验 (index=%d)",
moment_index,
)
return True
await asyncio.sleep(sleep_sec)
deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline:
try:
result = await asyncio.to_thread(
db_reader.get_moments_timeline, 0, 50
)
status = result.get("status", "")
if status == "ok":
current_count = len(result.get("moments", []))
# P0 修复:比对数量变化,而非仅检查 DB 可读
if current_count < baseline_count:
logger.info(
"post_verify_moment_delete: 朋友圈数量 %d%d,删除生效 (index=%d)",
baseline_count, current_count, moment_index,
)
return True
logger.warning(
"post_verify_moment_delete: 朋友圈数量未变 %d == %d (index=%d)",
current_count, baseline_count, moment_index,
)
except Exception as exc:
logger.warning(
"post_verify_moment_delete: DB 查询异常 (index=%d): %s",
moment_index, exc,
)
await asyncio.sleep(1.0)
logger.warning(
"post_verify_moment_delete: 超时 %.1fs 未确认删除 (index=%d)",
timeout_sec, moment_index,
)
return False
# ------------------------------------------------------------------
# post_verify 辅助方法
# ------------------------------------------------------------------
def _probe_moment_table(
self, db_reader: Any, candidates: list[str]
) -> Optional[str]:
"""探测朋友圈 DB 中是否存在候选表名,返回命中的表名或 None。
直接复用 db_reader 内部的 _find_moment_db_path + _ensure_decrypted
链路有困难涉及私有方法这里用 sqlite3 直接打开 moment DB 探测
DB 不可读时返回 None
"""
import sqlite3
try:
moment_db = db_reader._find_moment_db_path()
except Exception:
return None
if moment_db is None:
return None
storage = db_reader._find_db_storage_dir()
if storage is None:
return None
try:
import os as _os
rel_path = _os.path.relpath(moment_db, storage).replace(_os.sep, "/")
db_path = db_reader._ensure_decrypted(rel_path)
except Exception:
return None
try:
conn = sqlite3.connect(db_path, isolation_level=None)
try:
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
tables = {row[0] for row in cur.fetchall()}
for cand in candidates:
if cand in tables:
return cand
finally:
conn.close()
except Exception:
return None
return None
def _query_comment_in_table(
self, db_reader: Any, table: str, comment_substring: str
) -> bool:
"""在指定朋友圈评论表中查 comment 列是否含目标子串。"""
import sqlite3
try:
moment_db = db_reader._find_moment_db_path()
except Exception:
return False
if moment_db is None:
return False
storage = db_reader._find_db_storage_dir()
if storage is None:
return False
try:
import os as _os
rel_path = _os.path.relpath(moment_db, storage).replace(_os.sep, "/")
db_path = db_reader._ensure_decrypted(rel_path)
except Exception:
return False
try:
conn = sqlite3.connect(db_path, isolation_level=None)
conn.row_factory = sqlite3.Row
try:
cols = db_reader._table_columns(conn, table)
# 找文本列
text_col = db_reader._pick_column(
cols, ["content", "text", "comment", "body", "message"]
)
if text_col is None:
return False
sql = f"SELECT {text_col} FROM [{table}] LIMIT 50"
cur = conn.execute(sql)
for row in cur.fetchall():
val = row[text_col] or ""
if comment_substring in str(val):
return True
finally:
conn.close()
except Exception:
return False
return False
def _screenshot_absdiff_changed(
self,
before: Optional[bytes],
after: Optional[bytes],
tag: str,
moment_index: int,
) -> bool:
"""截图 absdiff 比对通用辅助:差异像素占比 > 0.5% 返回 True。"""
if before is None or after is None:
logger.warning(
"post_verify_moment_%s: 截图为 None (before=%s after=%s index=%d)",
tag, before is None, after is None, moment_index,
)
return False
try:
import cv2
import numpy as np
except ImportError:
logger.warning(
"post_verify_moment_%s: OpenCV 不可用 (index=%d)", tag, moment_index
)
return False
try:
before_img = cv2.imdecode(
np.frombuffer(before, dtype=np.uint8), cv2.IMREAD_GRAYSCALE
)
after_img = cv2.imdecode(
np.frombuffer(after, dtype=np.uint8), cv2.IMREAD_GRAYSCALE
)
if before_img is None or after_img is None:
return False
if before_img.shape != after_img.shape:
after_img = cv2.resize(
after_img, (before_img.shape[1], before_img.shape[0])
)
diff = cv2.absdiff(before_img, after_img)
changed = int(np.count_nonzero(diff > 30))
total = before_img.shape[0] * before_img.shape[1]
ratio = changed / total if total > 0 else 0.0
logger.info(
"post_verify_moment_%s: absdiff changed=%d ratio=%.4f (index=%d)",
tag, changed, ratio, moment_index,
)
return ratio > 0.005
except Exception as exc:
logger.warning(
"post_verify_moment_%s: absdiff 异常 (index=%d): %s",
tag, moment_index, exc,
)
return False