WechatOnCloud/bridge/woc_bridge/ui/locators/registry.py
Kris 054c4676aa refactor: 重构发送校验逻辑,移除DB轮询与熔断器,改用SSE校验
主要变更:
1. 新增SSEVerifyBus实现基于推送的发送结果校验,替代原DB轮询方案
2. 移除FlowOrchestrator中的DB熔断器,避免雪崩风险
3. 重构SendTextFlow,改用SSE校验流程,删除原DB校验相关代码
4. 新增RoiSpec与ROI搜索支持,优化UI元素匹配精度
5. 更新所有分辨率配置文件,添加ROI配置与调整搜索框坐标
6. 新增WOC_DISABLE_OPENCV环境变量开关,支持纯几何定位模式
7. 清理FlowContext中废弃的before_msg_id字段
2026-07-17 23:50:03 +08:00

392 lines
15 KiB
Python
Raw Permalink 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.

"""L2 LocatorRegistry按 (app_version, resolution) 加载 YAML profile + 熔断接口。
加载优先级:精确匹配 > 版本匹配 > default 兜底。
熔断接口record_image_failure / record_image_success / is_circuited
P1 阶段 no-opopencv=NoneP2 启用图像路径后生效。
"""
from __future__ import annotations
import logging
import os
import re
import time
from typing import Optional
import yaml
from woc_bridge.ui.locators.base import GeomSpec, RoiSpec, Selector
logger = logging.getLogger("woc-bridge")
class LocatorRegistry:
"""选择器注册表。
Attributes:
profile_dir: YAML profile 目录绝对路径
opencv: OpenCVBackend 实例P1 阶段为 NoneP2 启用图像路径)
selectors: kind -> Selector 映射
theme: 当前主题light / darkP1 阶段固定 light
"""
# 熔断参数:连续失败 10 次熔断 5 分钟
_FAIL_THRESHOLD = 10
_CIRCUIT_OPEN_SEC = 300.0
def __init__(self, profile_dir: str, opencv=None) -> None:
"""初始化注册表。
Args:
profile_dir: YAML profile 目录绝对路径
opencv: OpenCVBackend 实例P1 阶段为 None
"""
self.profile_dir = profile_dir
self.opencv = opencv
self.selectors: dict[str, Selector] = {}
self.theme: str = "light"
self._fail_counts: dict[str, int] = {}
self._circuit_open_until: dict[str, float] = {}
# ------------------------------------------------------------------
# Profile 加载
# ------------------------------------------------------------------
def load(self, app_version: str, resolution: tuple[int, int]) -> None:
"""按精确 > 最近分辨率 > 版本 > default 优先级加载 YAML profile。
Args:
app_version: 微信版本号,如 "4.0"
resolution: (width, height) 元组,如 (1920, 1080)
"""
exact_name = f"wechat_{app_version}_{resolution[0]}x{resolution[1]}.yaml"
exact_path = os.path.join(self.profile_dir, exact_name)
if os.path.exists(exact_path):
logger.info(
"[locator] load exact profile: %s (app_version=%s resolution=%s)",
exact_name,
app_version,
resolution,
)
self._load_yaml(exact_path)
return
# 无精确匹配时,找同版本最近分辨率并按比例缩放绝对偏移
nearest = self._find_nearest_profile(app_version, resolution)
if nearest:
name, profile_w, profile_h = nearest
logger.info(
"[locator] load nearest profile: %s for resolution=%s "
"(scale=%.3fx%.3f)",
name,
resolution,
resolution[0] / profile_w,
resolution[1] / profile_h,
)
self._load_yaml(
os.path.join(self.profile_dir, name),
target_resolution=resolution,
profile_resolution=(profile_w, profile_h),
)
return
# 版本通用/默认兜底
for name in [f"wechat_{app_version}.yaml", "default.yaml"]:
path = os.path.join(self.profile_dir, name)
if os.path.exists(path):
logger.info(
"[locator] load profile: %s (app_version=%s resolution=%s)",
name,
app_version,
resolution,
)
self._load_yaml(path)
return
logger.warning(
"[locator] no profile found in %s, fall back to built-in defaults "
"(app_version=%s resolution=%s)",
self.profile_dir,
app_version,
resolution,
)
self._load_defaults()
def _find_nearest_profile(
self, app_version: str, resolution: tuple[int, int]
) -> Optional[tuple[str, int, int]]:
"""找与当前分辨率最接近的 wechat_<version>_<w>x<h>.yaml。"""
pattern = re.compile(rf"wechat_{re.escape(app_version)}_(\d+)x(\d+)\.yaml$")
target_w, target_h = resolution
target_area = target_w * target_h
best: Optional[tuple[str, int, int]] = None
best_score: Optional[float] = None
for name in os.listdir(self.profile_dir):
m = pattern.match(name)
if not m:
continue
pw, ph = int(m.group(1)), int(m.group(2))
area = pw * ph
area_ratio = min(target_area, area) / max(target_area, area)
aspect_diff = abs(target_w / target_h - pw / ph)
# 面积越接近、宽高比越接近,分数越低
score = (1 - area_ratio) + aspect_diff
if best_score is None or score < best_score:
best_score = score
best = (name, pw, ph)
return best
def set_theme(self, theme: str) -> None:
"""切换主题light / dark"""
if theme != self.theme:
self.theme = theme
logger.info("[locator] theme switched to %s", theme)
# ------------------------------------------------------------------
# 选择器访问 + 熔断
# ------------------------------------------------------------------
def get(self, kind: str) -> Selector:
"""返回 kind 对应 Selector无则返回 _default_selector(kind)。"""
return self.selectors.get(kind) or self._default_selector(kind)
def is_circuited(self, kind: str) -> bool:
"""检查 kind 是否处于熔断期。
熔断到期时自动重置失败计数,使半开状态允许完整次数的试探,
而非首次失败即重新熔断。
"""
deadline = self._circuit_open_until.get(kind, 0)
if time.monotonic() < deadline:
return True
# 熔断已到期:清理熔断标记和失败计数,进入半开状态
if kind in self._circuit_open_until:
self._circuit_open_until.pop(kind, None)
self._fail_counts[kind] = 0
logger.info(
"[locator] kind=%s circuit recovered to HALF_OPEN (fail_count reset)",
kind,
)
return False
def record_image_failure(self, kind: str) -> None:
"""记录一次图像匹配失败。连续 _FAIL_THRESHOLD 次后熔断 _CIRCUIT_OPEN_SEC 秒。"""
self._fail_counts[kind] = self._fail_counts.get(kind, 0) + 1
if self._fail_counts[kind] >= self._FAIL_THRESHOLD:
self._circuit_open_until[kind] = time.monotonic() + self._CIRCUIT_OPEN_SEC
logger.warning(
"[locator] kind=%s image match circuit OPEN for %.0fs "
"(fail_count=%d)",
kind,
self._CIRCUIT_OPEN_SEC,
self._fail_counts[kind],
)
def record_image_success(self, kind: str) -> None:
"""记录一次图像匹配成功,重置失败计数与熔断。"""
self._fail_counts[kind] = 0
self._circuit_open_until.pop(kind, None)
# ------------------------------------------------------------------
# YAML 解析与默认值
# ------------------------------------------------------------------
def _load_yaml(
self,
path: str,
target_resolution: Optional[tuple[int, int]] = None,
profile_resolution: Optional[tuple[int, int]] = None,
) -> None:
"""用 yaml.safe_load 加载 YAML 并填充 self.selectors。
若提供 target_resolution 与 profile_resolution则对 by_geom 中的
绝对偏移 x_offset / y_offset 按分辨率比例缩放(宽高比不变时坐标更接近目标)。
roi 为相对窗口的比例矩形,与分辨率无关,不做缩放。
YAML 结构(每个 key 对应一个 Selector
search_box:
by_image: search_box.png # str可省略
by_geom: # dict可省略
relative_to: window
x_ratio: 0.08
y_ratio: 0.05
x_offset: 0
y_offset: 0
roi: # dict可省略缺省=全窗口)
x_ratio: 0.0
y_ratio: 0.0
w_ratio: 0.30
h_ratio: 0.20
threshold: 0.80
require_image: false
description: 搜索框
"""
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
except (OSError, yaml.YAMLError) as exc:
logger.error("[locator] load yaml failed: %s (%s)", path, exc)
self._load_defaults()
return
scale_x = scale_y = 1.0
if target_resolution and profile_resolution:
scale_x = target_resolution[0] / profile_resolution[0]
scale_y = target_resolution[1] / profile_resolution[1]
self.selectors.clear()
for kind, spec in data.items():
if not isinstance(spec, dict):
continue
by_image = spec.get("by_image")
by_geom_raw = spec.get("by_geom")
by_geom: Optional[GeomSpec] = None
if isinstance(by_geom_raw, dict):
by_geom = GeomSpec(
relative_to=by_geom_raw.get("relative_to", "window"),
x_ratio=float(by_geom_raw.get("x_ratio", 0.0)),
y_ratio=float(by_geom_raw.get("y_ratio", 0.0)),
x_offset=int(int(by_geom_raw.get("x_offset", 0)) * scale_x),
y_offset=int(int(by_geom_raw.get("y_offset", 0)) * scale_y),
)
roi_raw = spec.get("roi")
roi: Optional[RoiSpec] = None
if isinstance(roi_raw, dict):
roi = RoiSpec(
x_ratio=float(roi_raw.get("x_ratio", 0.0)),
y_ratio=float(roi_raw.get("y_ratio", 0.0)),
w_ratio=float(roi_raw.get("w_ratio", 1.0)),
h_ratio=float(roi_raw.get("h_ratio", 1.0)),
)
self.selectors[kind] = Selector(
kind=kind,
by_image=by_image,
by_geom=by_geom,
roi=roi,
threshold=float(spec.get("threshold", 0.80)),
require_image=bool(spec.get("require_image", False)),
description=str(spec.get("description", "")),
)
logger.info(
"[locator] loaded %d selectors from %s (scale=%.3fx%.3f)",
len(self.selectors), path, scale_x, scale_y,
)
def _default_selector(self, kind: str) -> Selector:
"""返回 kind 的兜底 Selector仅 description 标注 default"""
return Selector(kind=kind, description=f"default {kind}")
def _load_defaults(self) -> None:
"""从 xdotool_driver.py 现有硬编码常量迁移默认几何兜底。
参考 spec 4.2 节 YAML 示例与项目记忆中的硬编码值,
覆盖 6 个核心元素search_box / send_button / input_box /
input_box_empty / search_result_first / main_view。
relative_to 字段仅作语义占位,实际坐标计算统一用 ratio + offset
offset 已含正负号区分方向,详见 Actions._geom_find
roi 字段同步与 YAML profile 保持一致,防止无 YAML 时退化为全窗口
匹配(全窗口易误匹配到窗口其他相似 UI 元素,参见 input_box 误匹配事件)。
"""
defaults = {
"session_manager_icon": Selector(
kind="session_manager_icon",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.018,
y_ratio=0.109,
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.0, y_ratio=0.0, w_ratio=0.10, h_ratio=0.25,
),
description="左侧导航栏顶部微信消息/会话管理图标",
),
"search_box": Selector(
kind="search_box",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.092,
y_ratio=0.095,
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.0, y_ratio=0.0, w_ratio=0.30, h_ratio=0.20,
),
description="左侧顶部搜索框",
),
"send_button": Selector(
kind="send_button",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.972,
y_ratio=0.929,
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.90, y_ratio=0.85, w_ratio=0.10, h_ratio=0.15,
),
description="右下角发送按钮",
),
"input_box": Selector(
kind="input_box",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.743,
y_ratio=0.914,
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.55, y_ratio=0.78, w_ratio=0.45, h_ratio=0.22,
),
description="聊天输入框",
),
"input_box_empty": Selector(
kind="input_box_empty",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.743,
y_ratio=0.914,
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.55, y_ratio=0.78, w_ratio=0.45, h_ratio=0.22,
),
description="空输入框灰色提示态(发送成功后校验)",
),
"search_result_first": Selector(
kind="search_result_first",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.078,
y_ratio=0.174,
x_offset=0,
y_offset=0,
),
roi=RoiSpec(
x_ratio=0.0, y_ratio=0.10, w_ratio=0.30, h_ratio=0.40,
),
description="搜索结果第一项",
),
"main_view": Selector(
kind="main_view",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.65,
y_ratio=0.50,
x_offset=0,
y_offset=0,
),
# main_view 为大区域校验,无需 ROI 限制
description="主聊天视图post_verify 校验用)",
),
}
self.selectors.clear()
self.selectors.update(defaults)
logger.info(
"[locator] loaded %d built-in default selectors", len(self.selectors)
)