fix: 安全兼容采购规格繁简匹配 (#251)
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import ctypes
|
||||
from functools import lru_cache
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import xml.etree.ElementTree as ET
|
||||
from ctypes import wintypes
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
|
||||
@@ -10,6 +15,7 @@ Bounds = tuple[int, int, int, int]
|
||||
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||
_COLOR_HEADINGS = ("颜色分类", "颜色", "款式", "颜色款式", "花色")
|
||||
_PACKAGE_HEADINGS = ("套餐",)
|
||||
_LCMAP_SIMPLIFIED_CHINESE = 0x02000000
|
||||
|
||||
|
||||
def _parse_xml(xml_data: XmlData) -> ET.Element:
|
||||
@@ -41,21 +47,97 @@ def _node_labels(node: ET.Element) -> tuple[str, str]:
|
||||
return node.get("text", "").strip(), node.get("content-desc", "").strip()
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def normalize_spec_text(value: str) -> str:
|
||||
"""生成采购规格比较键;Windows 下把繁体转换为简体。
|
||||
|
||||
原始规格文字仍用于点击、显示和保存。本函数只生成比较键;Windows API
|
||||
不可用时安全退化为 Unicode 规范化,不做模糊匹配。
|
||||
"""
|
||||
|
||||
normalized = unicodedata.normalize("NFKC", str(value or "")).strip()
|
||||
if not normalized or sys.platform != "win32":
|
||||
return normalized.casefold()
|
||||
|
||||
try:
|
||||
function = ctypes.windll.kernel32.LCMapStringEx
|
||||
function.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.DWORD,
|
||||
wintypes.LPCWSTR,
|
||||
ctypes.c_int,
|
||||
wintypes.LPWSTR,
|
||||
ctypes.c_int,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
wintypes.LPARAM,
|
||||
]
|
||||
function.restype = ctypes.c_int
|
||||
required = function(
|
||||
"zh-CN",
|
||||
_LCMAP_SIMPLIFIED_CHINESE,
|
||||
normalized,
|
||||
-1,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
)
|
||||
if required <= 0:
|
||||
return normalized.casefold()
|
||||
buffer = ctypes.create_unicode_buffer(required)
|
||||
written = function(
|
||||
"zh-CN",
|
||||
_LCMAP_SIMPLIFIED_CHINESE,
|
||||
normalized,
|
||||
-1,
|
||||
buffer,
|
||||
required,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
)
|
||||
if written <= 0:
|
||||
return normalized.casefold()
|
||||
return buffer.value.casefold()
|
||||
except (AttributeError, OSError, TypeError, ValueError):
|
||||
return normalized.casefold()
|
||||
|
||||
|
||||
def _label_matches(label: str, target: str) -> bool:
|
||||
if label.casefold() == target.casefold():
|
||||
return True
|
||||
|
||||
pattern = rf"(?:^|[\s,,::;;]){re.escape(target)}(?:$|[\s,,::;;])"
|
||||
return re.search(pattern, label, flags=re.IGNORECASE) is not None
|
||||
|
||||
|
||||
def _node_match_kind(node: ET.Element, target: str) -> int:
|
||||
"""返回 2=原文精确匹配、1=繁简规范化匹配、0=不匹配。"""
|
||||
|
||||
checked_target = target.strip()
|
||||
if not checked_target:
|
||||
return 0
|
||||
|
||||
labels = _node_labels(node)
|
||||
if any(_label_matches(label, checked_target) for label in labels):
|
||||
return 2
|
||||
|
||||
normalized_target = normalize_spec_text(checked_target)
|
||||
if not normalized_target:
|
||||
return 0
|
||||
for label in labels:
|
||||
normalized_label = normalize_spec_text(label)
|
||||
if normalized_label and _label_matches(normalized_label, normalized_target):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _matches_target(node: ET.Element, target: str) -> bool:
|
||||
target = target.strip()
|
||||
if not target:
|
||||
if not target.strip():
|
||||
return False
|
||||
|
||||
for label in _node_labels(node):
|
||||
if label.casefold() == target.casefold():
|
||||
return True
|
||||
|
||||
# 兼容“颜色 黑色,已选中”或“尺码: XL”等无障碍描述,
|
||||
# 同时避免 XL 错误匹配到 2XL。
|
||||
pattern = rf"(?:^|[\s,,::;;]){re.escape(target)}(?:$|[\s,,::;;])"
|
||||
if re.search(pattern, label, flags=re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
return _node_match_kind(node, target) > 0
|
||||
|
||||
|
||||
def _parent_map(root: ET.Element) -> dict[ET.Element, ET.Element]:
|
||||
@@ -328,17 +410,45 @@ def _safe_horizontal_target(bounds: Bounds, region: Bounds) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _target_click_bounds(
|
||||
def _contains_bounds(outer: Bounds, inner: Bounds) -> bool:
|
||||
return (
|
||||
outer[0] <= inner[0]
|
||||
and outer[1] <= inner[1]
|
||||
and outer[2] >= inner[2]
|
||||
and outer[3] >= inner[3]
|
||||
)
|
||||
|
||||
|
||||
def _collapse_nested_bounds(bounds: list[Bounds]) -> list[Bounds]:
|
||||
"""把同一规格卡片的外层和文字点击区域合并为外层卡片。"""
|
||||
|
||||
unique = sorted(
|
||||
set(bounds),
|
||||
key=lambda item: (item[2] - item[0]) * (item[3] - item[1]),
|
||||
reverse=True,
|
||||
)
|
||||
result: list[Bounds] = []
|
||||
for candidate in unique:
|
||||
if any(_contains_bounds(existing, candidate) for existing in result):
|
||||
continue
|
||||
result.append(candidate)
|
||||
return result
|
||||
|
||||
|
||||
def _target_click_candidates(
|
||||
root: ET.Element,
|
||||
target: str,
|
||||
region: Bounds,
|
||||
require_horizontal_safe: bool = False,
|
||||
) -> Optional[Bounds]:
|
||||
) -> list[tuple[int, int, Bounds]]:
|
||||
parents = _parent_map(root)
|
||||
candidates: list[tuple[int, Bounds]] = []
|
||||
candidates: list[tuple[int, int, Bounds]] = []
|
||||
|
||||
for node in root.iter("node"):
|
||||
if not _is_available(node) or not _matches_target(node, target):
|
||||
if not _is_available(node):
|
||||
continue
|
||||
match_kind = _node_match_kind(node, target)
|
||||
if match_kind == 0:
|
||||
continue
|
||||
|
||||
bounds = _nearest_click_bounds(node, parents, region)
|
||||
@@ -348,11 +458,47 @@ def _target_click_bounds(
|
||||
continue
|
||||
|
||||
area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1])
|
||||
candidates.append((area, bounds))
|
||||
candidates.append((match_kind, area, bounds))
|
||||
return candidates
|
||||
|
||||
|
||||
def _normalized_target_is_ambiguous(
|
||||
root: ET.Element,
|
||||
target: str,
|
||||
region: Bounds,
|
||||
require_horizontal_safe: bool = False,
|
||||
) -> bool:
|
||||
candidates = _target_click_candidates(
|
||||
root,
|
||||
target,
|
||||
region,
|
||||
require_horizontal_safe=require_horizontal_safe,
|
||||
)
|
||||
if not candidates or any(kind == 2 for kind, _area, _bounds in candidates):
|
||||
return False
|
||||
return len(_collapse_nested_bounds([item[2] for item in candidates])) > 1
|
||||
|
||||
|
||||
def _target_click_bounds(
|
||||
root: ET.Element,
|
||||
target: str,
|
||||
region: Bounds,
|
||||
require_horizontal_safe: bool = False,
|
||||
) -> Optional[Bounds]:
|
||||
candidates = _target_click_candidates(
|
||||
root,
|
||||
target,
|
||||
region,
|
||||
require_horizontal_safe=require_horizontal_safe,
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda item: item[0])[1]
|
||||
best_kind = max(item[0] for item in candidates)
|
||||
best = [item for item in candidates if item[0] == best_kind]
|
||||
if best_kind == 1:
|
||||
bounds = _collapse_nested_bounds([item[2] for item in best])
|
||||
return bounds[0] if len(bounds) == 1 else None
|
||||
return max(best, key=lambda item: item[1])[2]
|
||||
|
||||
|
||||
def _target_is_selected(root: ET.Element, target: str) -> bool:
|
||||
@@ -391,6 +537,13 @@ def color_selection_failure_reason(xml_data: XmlData, target: str) -> str:
|
||||
if not matching_nodes:
|
||||
return "target_not_visible"
|
||||
region = _horizontal_color_region(root, target)
|
||||
if region is not None and _normalized_target_is_ambiguous(
|
||||
root,
|
||||
target,
|
||||
region,
|
||||
require_horizontal_safe=True,
|
||||
):
|
||||
return "target_ambiguous"
|
||||
if region is None or _target_click_bounds(
|
||||
root,
|
||||
target,
|
||||
@@ -401,6 +554,25 @@ def color_selection_failure_reason(xml_data: XmlData, target: str) -> str:
|
||||
return "selection_unconfirmed"
|
||||
|
||||
|
||||
def size_selection_failure_reason(xml_data: XmlData, target: str) -> str:
|
||||
"""说明尺码选择失败阶段,繁简等价候选不唯一时明确拒绝。"""
|
||||
|
||||
root = _parse_xml(xml_data)
|
||||
matching_nodes = [
|
||||
node
|
||||
for node in root.iter("node")
|
||||
if _is_available(node) and _matches_target(node, target)
|
||||
]
|
||||
if not matching_nodes:
|
||||
return "target_not_visible"
|
||||
region = _vertical_panel_region(root)
|
||||
if region is not None and _normalized_target_is_ambiguous(root, target, region):
|
||||
return "target_ambiguous"
|
||||
if region is None or _target_click_bounds(root, target, region) is None:
|
||||
return "safe_target_missing"
|
||||
return "selection_unconfirmed"
|
||||
|
||||
|
||||
def _visible_signature(
|
||||
root: ET.Element,
|
||||
region: Bounds,
|
||||
@@ -426,6 +598,13 @@ def _click_target_and_verify(
|
||||
action_delay: float,
|
||||
require_horizontal_safe: bool = False,
|
||||
) -> tuple[bool, ET.Element]:
|
||||
if _normalized_target_is_ambiguous(
|
||||
root,
|
||||
target,
|
||||
region,
|
||||
require_horizontal_safe=require_horizontal_safe,
|
||||
):
|
||||
return False, root
|
||||
if _target_is_selected(root, target):
|
||||
return True, root
|
||||
|
||||
@@ -444,6 +623,8 @@ def _click_target_and_verify(
|
||||
time.sleep(action_delay)
|
||||
|
||||
refreshed_root = _parse_xml(device.dump_hierarchy())
|
||||
# 点击前已经完成唯一候选校验。点击后页面通常会额外出现“已选:目标”
|
||||
# 摘要,不能把摘要和原卡片误判成两个可选候选。
|
||||
return _target_is_selected(refreshed_root, target), refreshed_root
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user