fix: 安全兼容采购规格繁简匹配 (#251)

This commit is contained in:
chengma
2026-08-17 15:20:35 +08:00
parent 1927e3edb6
commit 134514e382
4 changed files with 373 additions and 56 deletions
+22 -1
View File
@@ -53,6 +53,7 @@ from .util.select_color_size import (
color_selection_failure_reason, color_selection_failure_reason,
select_color, select_color,
select_size, select_size,
size_selection_failure_reason,
) )
@@ -1048,6 +1049,9 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
diagnostics["artifacts"] = [artifact] diagnostics["artifacts"] = [artifact]
messages = { messages = {
"target_not_visible": f"没有找到目标颜色:{color}", "target_not_visible": f"没有找到目标颜色:{color}",
"target_ambiguous": (
f"目标颜色存在多个繁简等价候选,已停止采购:{color}"
),
"safe_target_missing": ( "safe_target_missing": (
f"目标颜色没有完整可见的安全点击位置:{color}" f"目标颜色没有完整可见的安全点击位置:{color}"
), ),
@@ -1074,10 +1078,27 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
device, latest_xml, size, action_delay=0.2 device, latest_xml, size, action_delay=0.2
) )
if not size_selected: if not size_selected:
failed_xml = self._dump_hierarchy()
failure_reason = size_selection_failure_reason(
failed_xml, size
)
messages = {
"target_not_visible": f"没有找到目标尺码:{size}",
"target_ambiguous": (
f"目标尺码存在多个繁简等价候选,已停止采购:{size}"
),
"safe_target_missing": (
f"目标尺码没有可靠的安全点击位置:{size}"
),
"selection_unconfirmed": (
f"点击尺码后页面没有确认已选中:{size}"
),
}
raise PddPurchaseError( raise PddPurchaseError(
"PURCHASE_OPTIONS_MISMATCH", "PURCHASE_OPTIONS_MISMATCH",
f"没有精确选中尺码:{size}", messages[failure_reason],
step="purchase_select_options", step="purchase_select_options",
diagnostics={"selection_failure": failure_reason},
) )
except PddPurchaseError: except PddPurchaseError:
raise raise
+196 -15
View File
@@ -1,6 +1,11 @@
import ctypes
from functools import lru_cache
import re import re
import sys
import time import time
import unicodedata
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from ctypes import wintypes
from typing import Any, Optional, Union 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+)\]$") _BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
_COLOR_HEADINGS = ("颜色分类", "颜色", "款式", "颜色款式", "花色") _COLOR_HEADINGS = ("颜色分类", "颜色", "款式", "颜色款式", "花色")
_PACKAGE_HEADINGS = ("套餐",) _PACKAGE_HEADINGS = ("套餐",)
_LCMAP_SIMPLIFIED_CHINESE = 0x02000000
def _parse_xml(xml_data: XmlData) -> ET.Element: 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() return node.get("text", "").strip(), node.get("content-desc", "").strip()
def _matches_target(node: ET.Element, target: str) -> bool: @lru_cache(maxsize=512)
target = target.strip() def normalize_spec_text(value: str) -> str:
if not target: """生成采购规格比较键;Windows 下把繁体转换为简体。
return False
for label in _node_labels(node): 原始规格文字仍用于点击、显示和保存。本函数只生成比较键;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(): if label.casefold() == target.casefold():
return True return True
# 兼容“颜色 黑色,已选中”或“尺码: XL”等无障碍描述,
# 同时避免 XL 错误匹配到 2XL。
pattern = rf"(?:^|[\s,,::;;]){re.escape(target)}(?:$|[\s,,::;;])" pattern = rf"(?:^|[\s,,::;;]){re.escape(target)}(?:$|[\s,,::;;])"
if re.search(pattern, label, flags=re.IGNORECASE): return re.search(pattern, label, flags=re.IGNORECASE) is not None
return True
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:
if not target.strip():
return False return False
return _node_match_kind(node, target) > 0
def _parent_map(root: ET.Element) -> dict[ET.Element, ET.Element]: 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, root: ET.Element,
target: str, target: str,
region: Bounds, region: Bounds,
require_horizontal_safe: bool = False, require_horizontal_safe: bool = False,
) -> Optional[Bounds]: ) -> list[tuple[int, int, Bounds]]:
parents = _parent_map(root) parents = _parent_map(root)
candidates: list[tuple[int, Bounds]] = [] candidates: list[tuple[int, int, Bounds]] = []
for node in root.iter("node"): 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 continue
bounds = _nearest_click_bounds(node, parents, region) bounds = _nearest_click_bounds(node, parents, region)
@@ -348,11 +458,47 @@ def _target_click_bounds(
continue continue
area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]) 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: if not candidates:
return None 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: 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: if not matching_nodes:
return "target_not_visible" return "target_not_visible"
region = _horizontal_color_region(root, target) 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( if region is None or _target_click_bounds(
root, root,
target, target,
@@ -401,6 +554,25 @@ def color_selection_failure_reason(xml_data: XmlData, target: str) -> str:
return "selection_unconfirmed" 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( def _visible_signature(
root: ET.Element, root: ET.Element,
region: Bounds, region: Bounds,
@@ -426,6 +598,13 @@ def _click_target_and_verify(
action_delay: float, action_delay: float,
require_horizontal_safe: bool = False, require_horizontal_safe: bool = False,
) -> tuple[bool, ET.Element]: ) -> 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): if _target_is_selected(root, target):
return True, root return True, root
@@ -444,6 +623,8 @@ def _click_target_and_verify(
time.sleep(action_delay) time.sleep(action_delay)
refreshed_root = _parse_xml(device.dump_hierarchy()) refreshed_root = _parse_xml(device.dump_hierarchy())
# 点击前已经完成唯一候选校验。点击后页面通常会额外出现“已选:目标”
# 摘要,不能把摘要和原卡片误判成两个可选候选。
return _target_is_selected(refreshed_root, target), refreshed_root return _target_is_selected(refreshed_root, target), refreshed_root
@@ -1618,6 +1618,35 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
self.assertIn("purchase_select_size", operations) self.assertIn("purchase_select_size", operations)
adapter.close() adapter.close()
def test_failed_option_stage_is_recorded_as_failed(self):
cases = (
({"color": "不存在的颜色"}, "purchase_select_color"),
(
{"color": "黑色", "size": "不存在的尺码"},
"purchase_select_size",
),
)
for options, expected_operation in cases:
with self.subTest(operation=expected_operation):
device = FakeDevice()
adapter = self._adapter(device, [])
records = []
trace = TaskPerformanceTrace(sink=records.append)
trace.bind_task(f"PUR-{expected_operation}")
adapter.open_goods(GOODS_URL)
with trace.activate(), self.assertRaises(PddPurchaseError):
adapter.select_options(options)
matching = [
record
for record in records
if record["operation"] == expected_operation
]
self.assertEqual(len(matching), 1)
self.assertEqual(matching[0]["result"], "failed")
adapter.close()
def test_same_quantity_does_not_focus_editor(self): def test_same_quantity_does_not_focus_editor(self):
device = FakeDevice() device = FakeDevice()
adapter = self._adapter(device, []) adapter = self._adapter(device, [])
+89 -3
View File
@@ -6,7 +6,10 @@ import xml.etree.ElementTree as ET
from src.util.select_color_size import ( from src.util.select_color_size import (
color_selection_failure_reason, color_selection_failure_reason,
normalize_spec_text,
select_color, select_color,
select_size,
size_selection_failure_reason,
) )
@@ -16,9 +19,15 @@ FIXTURES = Path(__file__).parent / "fixtures"
class StaticColorDevice: class StaticColorDevice:
"""模拟没有滚动属性的图片颜色规格面板。""" """模拟没有滚动属性的图片颜色规格面板。"""
def __init__(self, xml_data: str, confirm_selection: bool = True) -> None: def __init__(
self,
xml_data: str,
confirm_selection: bool = True,
selected_text: str = "测试黑色",
) -> None:
self.xml_data = xml_data self.xml_data = xml_data
self.confirm_selection = confirm_selection self.confirm_selection = confirm_selection
self.selected_text = selected_text
self.clicks = [] self.clicks = []
self.swipes = [] self.swipes = []
@@ -31,10 +40,10 @@ class StaticColorDevice:
return return
root = ET.fromstring(self.xml_data) root = ET.fromstring(self.xml_data)
for node in root.iter("node"): for node in root.iter("node"):
if node.get("text", "").strip() == "测试黑色": if node.get("text", "").strip() == self.selected_text:
node.set("selected", "true") node.set("selected", "true")
if node.get("text", "").strip().startswith("请选择"): if node.get("text", "").strip().startswith("请选择"):
node.set("text", "已选: 测试黑色") node.set("text", f"已选: {self.selected_text}")
self.xml_data = ET.tostring(root, encoding="unicode") self.xml_data = ET.tostring(root, encoding="unicode")
def swipe(self, *args, **kwargs) -> None: def swipe(self, *args, **kwargs) -> None:
@@ -122,6 +131,83 @@ class StaticColorSelectionTest(unittest.TestCase):
self.assertEqual(device.clicks, []) self.assertEqual(device.clicks, [])
self.assertEqual(device.swipes, []) self.assertEqual(device.swipes, [])
def test_traditional_color_matches_unique_simplified_card(self):
simplified = "缥缈仙姬绾【黑色】"
traditional = "縹緲仙姬綰【黑色】"
panel_xml = self.panel_xml.replace("测试黑色", simplified)
device = StaticColorDevice(panel_xml, selected_text=simplified)
selected = select_color(
device,
panel_xml,
traditional,
action_delay=0.001,
)
self.assertEqual(normalize_spec_text(traditional), simplified.casefold())
self.assertTrue(selected)
self.assertEqual(device.clicks, [(194, 1389)])
def test_traditional_size_matches_unique_simplified_option(self):
simplified = "均码【40.0-60.0公斤】"
traditional = "均碼【40.0-60.0公斤】"
panel_xml = self.panel_xml.replace('text="M"', f'text="{simplified}"')
device = StaticColorDevice(panel_xml, selected_text=simplified)
selected = select_size(
device,
panel_xml,
traditional,
action_delay=0.001,
)
self.assertTrue(selected)
self.assertEqual(device.clicks, [(77, 1762)])
def test_normalized_duplicate_color_candidates_are_not_clicked(self):
simplified = "缥缈仙姬绾【黑色】"
traditional = "縹緲仙姬綰【黑色】"
panel_xml = self.panel_xml.replace("测试黑色", simplified).replace(
"测试红色", simplified
)
device = StaticColorDevice(panel_xml, selected_text=simplified)
selected = select_color(
device,
panel_xml,
traditional,
action_delay=0.001,
)
self.assertFalse(selected)
self.assertEqual(device.clicks, [])
self.assertEqual(
color_selection_failure_reason(panel_xml, traditional),
"target_ambiguous",
)
def test_normalized_duplicate_size_candidates_are_not_clicked(self):
simplified = "均码【40.0-60.0公斤】"
traditional = "均碼【40.0-60.0公斤】"
panel_xml = self.panel_xml.replace('text="M"', f'text="{simplified}"').replace(
'text="L"', f'text="{simplified}"'
)
device = StaticColorDevice(panel_xml, selected_text=simplified)
selected = select_size(
device,
panel_xml,
traditional,
action_delay=0.001,
)
self.assertFalse(selected)
self.assertEqual(device.clicks, [])
self.assertEqual(
size_selection_failure_reason(panel_xml, traditional),
"target_ambiguous",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()