- 新增六层UI自动化架构:从Backend到Capabilities的完整分层实现 - 添加WeChat 4.0分辨率适配Profile与图像模板资源 - 实现幂等缓存、熔断器、重试策略、链路追踪与监控指标 - 新增头像下载安全校验、发布朋友圈路径白名单防护 - 优化密钥缓存、DB校验逻辑与初始化流程 - 补充完整错误码体系与启动清场机制
276 lines
10 KiB
Python
276 lines
10 KiB
Python
"""L2 LocatorRegistry:按 (app_version, resolution) 加载 YAML profile + 熔断接口。
|
||
|
||
加载优先级:精确匹配 > 版本匹配 > default 兜底。
|
||
熔断接口:record_image_failure / record_image_success / is_circuited,
|
||
P1 阶段 no-op(opencv=None),P2 启用图像路径后生效。
|
||
"""
|
||
|
||
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 阶段为 None,P2 启用图像路径)
|
||
selectors: kind -> Selector 映射
|
||
theme: 当前主题(light / dark),P1 阶段固定 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)
|
||
)
|