WechatOnCloud/bridge/woc_bridge/ui/locators/registry.py
Kris 15fc62d478 feat: 完成微信UI自动化新架构全量开发与集成
- 新增六层UI自动化架构:从Backend到Capabilities的完整分层实现
- 添加WeChat 4.0分辨率适配Profile与图像模板资源
- 实现幂等缓存、熔断器、重试策略、链路追踪与监控指标
- 新增头像下载安全校验、发布朋友圈路径白名单防护
- 优化密钥缓存、DB校验逻辑与初始化流程
- 补充完整错误码体系与启动清场机制
2026-07-16 01:19:47 +08:00

276 lines
10 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.

"""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 time
from typing import Optional
import yaml
from woc_bridge.ui.locators.base import GeomSpec, 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)
"""
candidates = [
f"wechat_{app_version}_{resolution[0]}x{resolution[1]}.yaml",
f"wechat_{app_version}.yaml",
"default.yaml",
]
for name in candidates:
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 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) -> None:
"""用 yaml.safe_load 加载 YAML 并填充 self.selectors。
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
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
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(by_geom_raw.get("x_offset", 0)),
y_offset=int(by_geom_raw.get("y_offset", 0)),
)
self.selectors[kind] = Selector(
kind=kind,
by_image=by_image,
by_geom=by_geom,
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", len(self.selectors), path
)
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
"""
defaults = {
"search_box": Selector(
kind="search_box",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.08,
y_ratio=0.05,
x_offset=0,
y_offset=0,
),
description="左侧顶部搜索框",
),
"send_button": Selector(
kind="send_button",
by_geom=GeomSpec(
relative_to="window_bottom_right",
x_ratio=1.0,
y_ratio=1.0,
x_offset=-60,
y_offset=-30,
),
description="右下角发送按钮",
),
"input_box": Selector(
kind="input_box",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.65,
y_ratio=0.93,
x_offset=0,
y_offset=0,
),
description="聊天输入框",
),
"input_box_empty": Selector(
kind="input_box_empty",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.65,
y_ratio=0.93,
x_offset=0,
y_offset=0,
),
description="空输入框灰色提示态(发送成功后校验)",
),
"search_result_first": Selector(
kind="search_result_first",
by_geom=GeomSpec(
relative_to="window",
x_ratio=0.08,
y_ratio=0.11,
x_offset=0,
y_offset=0,
),
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,
),
description="主聊天视图post_verify 校验用)",
),
}
self.selectors.clear()
self.selectors.update(defaults)
logger.info(
"[locator] loaded %d built-in default selectors", len(self.selectors)
)