"""朋友圈子 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) # 注入兄弟 driver:publish / 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_managed,TimeoutError 转 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] 。""" 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 写临时文件再读取。任何异常 返回 None(post_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__<随机> 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__<随机> 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 时返回 True(DB 不可用,跳过校验,不阻塞流程) """ 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