- 新增登录状态守卫后台任务 - 新增好友申请自动通过规则引擎 - 新增多分辨率UI配置与模板资源 - 新增消息拉取复合游标支持 - 优化发送队列与UI自动化逻辑 - 新增批量发送日志与错误处理 - 优化Docker镜像构建与ptrace初始化 - 新增联系人名称缓存预热
354 lines
13 KiB
Python
354 lines
13 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 re
|
||
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)
|
||
"""
|
||
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 按分辨率比例缩放(宽高比不变时坐标更接近目标)。
|
||
|
||
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
|
||
|
||
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),
|
||
)
|
||
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 (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)。
|
||
"""
|
||
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,
|
||
),
|
||
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,
|
||
),
|
||
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,
|
||
),
|
||
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,
|
||
),
|
||
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,
|
||
),
|
||
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,
|
||
),
|
||
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)
|
||
)
|