WechatOnCloud/bridge/woc_bridge/ui/drivers/moment.py
Kris 3effe1338c feat: 新增纯坐标模式开关与文本输入优化
1. 新增WOC_DISABLE_OPENCV环境变量支持纯坐标定位模式
2. 优化xdotool文本输入:替换换行符为空格、添加30ms输入延迟
3. 新增输入框清空逻辑,避免内容残留
2026-07-18 05:23:06 +08:00

1049 lines
43 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.

"""朋友圈子 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