2026-07-16 16:53:42 +08:00
|
|
|
|
"""朋友圈子 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] <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 以维持调用点稳定。
|
|
|
|
|
|
|
2026-07-18 05:23:06 +08:00
|
|
|
|
注意:
|
|
|
|
|
|
- 把 \n / \r 替换为空格,避免 xdotool 把换行解析为 Return 键。
|
|
|
|
|
|
- 使用 --delay 30ms 降低输入速度,减少中文 IME 字符丢失。
|
|
|
|
|
|
|
2026-07-16 16:53:42 +08:00
|
|
|
|
Raises:
|
|
|
|
|
|
BridgeError(SEND_FAILED): xdotool type 执行失败
|
|
|
|
|
|
"""
|
2026-07-18 05:23:06 +08:00
|
|
|
|
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
|
2026-07-16 16:53:42 +08:00
|
|
|
|
logger.info("[ui] type text: len=%d", len(text))
|
2026-07-18 05:23:06 +08:00
|
|
|
|
rc, _, stderr = await self._run(["xdotool", "type", "--delay", "30", "--", text])
|
2026-07-16 16:53:42 +08:00
|
|
|
|
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_<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 时返回 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
|