WechatOnCloud/bridge/woc_bridge/ui/locators/registry.py
Kris c45282f094 feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务
- 新增好友申请自动通过规则引擎
- 新增多分辨率UI配置与模板资源
- 新增消息拉取复合游标支持
- 优化发送队列与UI自动化逻辑
- 新增批量发送日志与错误处理
- 优化Docker镜像构建与ptrace初始化
- 新增联系人名称缓存预热
2026-07-17 18:10:31 +08:00

354 lines
13 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 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 阶段为 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 按分辨率比例缩放(宽高比不变时坐标更接近目标)。
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)
)