From ef1ac60ac5a551d2b1d9ae98c235b6aeb92128f1 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 14:55:14 +0800 Subject: [PATCH] fix(client): diagnose T-103 price text safely --- .../device/sku_evidence_sanitizer.py | 50 +++++++++++- .../device/test_sku_evidence_sanitizer.py | 80 +++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py b/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py index f7bc41c..b507524 100644 --- a/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py +++ b/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py @@ -40,7 +40,11 @@ _MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", _SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+") # 这两个槽位来自 T-103 当前第一态、1080×2376 XML 坐标的人工审查。它们不是通用 # 页面判据;坐标、文本或结构任何变化都停止发布,交由人重新取证。 -_CROSSING_PRICE_BOUNDS = frozenset({(396, 503, 712, 570), (730, 503, 895, 570)}) +_CROSSING_PRICE_SLOTS = { + (396, 503, 712, 570): "[396,503][712,570]", + (730, 503, 895, 570): "[730,503][895,570]", +} +_CROSSING_PRICE_BOUNDS = frozenset(_CROSSING_PRICE_SLOTS) _PRICE_PROJECTION_ATTRIBUTES = ( "bounds", "text", @@ -53,6 +57,8 @@ _PRICE_PROJECTION_ATTRIBUTES = ( # 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和 # 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。 _CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖光) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z") +_CROSSING_PRICE_PREFIX_RE = re.compile(r" {0,1}(?:快卖光 {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}") +_CROSSING_PRICE_ALLOWED_CHARACTERS = frozenset(" 快卖光¥¥0123456789.") class SkuEvidenceSanitizationError(RuntimeError): @@ -315,7 +321,7 @@ def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats return if position == "crossing": if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"): - _project_crossing_price_node(node, stats) + _project_crossing_price_node(node, bounds, stats) else: # 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。 _clear_node_text(node) @@ -363,7 +369,11 @@ def _vertical_position(bounds: tuple[int, int, int, int]) -> str: return "crossing" -def _project_crossing_price_node(node: ElementTree.Element, stats: _CleanupStats) -> None: +def _project_crossing_price_node( + node: ElementTree.Element, + bounds: tuple[int, int, int, int], + stats: _CleanupStats, +) -> None: """投影唯一允许的跨界价格叶节点;任何结构漂移一律拒绝发布。""" if ( @@ -380,7 +390,7 @@ def _project_crossing_price_node(node: ElementTree.Element, stats: _CleanupStats raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。") match = _CROSSING_PRICE_TEXT_RE.fullmatch(text) if match is None: - raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。") + raise _crossing_price_text_mismatch_error(bounds, text) # 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。 node.attrib = {attribute: node.attrib[attribute] for attribute in _PRICE_PROJECTION_ATTRIBUTES} @@ -393,6 +403,38 @@ def _project_crossing_price_node(node: ElementTree.Element, stats: _CleanupStats stats.original_price_candidates += 1 +def _crossing_price_text_mismatch_error( + bounds: tuple[int, int, int, int], + text: str, +) -> SkuEvidenceSanitizationError: + """仅输出固定槽位与 reason,避免将任意 raw 正文带入 CLI 或日志。""" + + reason = _crossing_price_text_mismatch_reason(text) + slot = _CROSSING_PRICE_SLOTS[bounds] + return SkuEvidenceSanitizationError(f"跨界价格节点文本不匹配:slot={slot};reason={reason}。") + + +def _crossing_price_text_mismatch_reason(text: str) -> str: + """将未匹配文本归类为受控枚举;返回值绝不包含原始片段。""" + + if "\r" in text or "\n" in text: + return "newline" + if any(character.isspace() and character != " " for character in text): + return "non_ascii_whitespace" + if any(marker in text for marker in ("提交订单", "支付", "下单", "优惠")): + return "extra_or_order" + without_leading_space = text.lstrip(" ") + if without_leading_space.startswith("快") and not without_leading_space.startswith("快卖光"): + return "known_prefix_missing" + if "¥" not in text and "¥" not in text: + return "currency_missing" + if _CROSSING_PRICE_PREFIX_RE.match(text) is not None: + return "extra_or_order" + if any(character not in _CROSSING_PRICE_ALLOWED_CHARACTERS for character in text): + return "forbidden_characters" + return "amount_shape" + + def _require_safe_crossing_price_projection(stats: _CleanupStats) -> None: """当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。""" diff --git a/client/tests/device/test_sku_evidence_sanitizer.py b/client/tests/device/test_sku_evidence_sanitizer.py index fa9f830..fbf24fc 100644 --- a/client/tests/device/test_sku_evidence_sanitizer.py +++ b/client/tests/device/test_sku_evidence_sanitizer.py @@ -274,6 +274,86 @@ class SkuEvidenceSanitizerTests(unittest.TestCase): sanitize_sku_panel_evidence(raw, raw.parent / "derived") self.assertFalse((raw.parent / "derived").exists()) + def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None: + newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace( + "快卖光 ¥12.88", "快卖光 ¥12.88" + ) + cases = ( + ("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS), + ( + "non-ascii-whitespace", + _xml_with_prices(current="快卖光 ¥12.88"), + "non_ascii_whitespace", + PRICE_CURRENT_BOUNDS, + ), + ( + "known-prefix-missing", + _xml_with_prices(current="快要抢光 ¥12.88"), + "known_prefix_missing", + PRICE_CURRENT_BOUNDS, + ), + ( + "currency-missing", + _xml_with_prices(current="快卖光 12.88"), + "currency_missing", + PRICE_CURRENT_BOUNDS, + ), + ( + "amount-shape", + _xml_with_prices(current="快卖光 ¥12.8"), + "amount_shape", + PRICE_CURRENT_BOUNDS, + ), + ( + "extra-or-order", + _xml_with_prices(current="提交订单 ¥12.88"), + "extra_or_order", + PRICE_CURRENT_BOUNDS, + ), + ( + "forbidden-characters", + _xml_with_prices(current="商品 ¥12.88"), + "forbidden_characters", + PRICE_CURRENT_BOUNDS, + ), + ( + "right-slot-amount-shape", + _xml_with_prices(original="¥29.0"), + "amount_shape", + PRICE_ORIGINAL_BOUNDS, + ), + ) + for name, xml, reason, bounds in cases: + with self.subTest(name=name), TemporaryDirectory() as temporary: + raw = _write_raw(Path(temporary), xml=xml) + with self.assertRaises(SkuEvidenceSanitizationError) as raised: + sanitize_sku_panel_evidence(raw, raw.parent / "derived") + + self.assertEqual( + str(raised.exception), + f"跨界价格节点文本不匹配:slot={bounds};reason={reason}。", + ) + self.assertFalse((raw.parent / "derived").exists()) + + def test_crossing_price_text_mismatch_never_echoes_sensitive_or_order_text(self) -> None: + cases = ( + f"快卖光 ¥12.88 {TEST_ADDRESS}", + f"快卖光 ¥12.88 {FULL_PHONE}", + "快卖光 ¥12.88 使用微信支付", + "快卖光 ¥12.88 提交订单", + ) + for text in cases: + with self.subTest(text=text), TemporaryDirectory() as temporary: + raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text)) + with self.assertRaises(SkuEvidenceSanitizationError) as raised: + sanitize_sku_panel_evidence(raw, raw.parent / "derived") + + message = str(raised.exception) + self.assertIn("slot=[396,503][712,570]", message) + self.assertIn("reason=extra_or_order", message) + for raw_fragment in (TEST_ADDRESS, FULL_PHONE, "使用微信支付", "提交订单", "¥12.88"): + self.assertNotIn(raw_fragment, message) + def test_crossing_price_projection_allows_only_limited_ascii_spaces_and_yen_variants(self) -> None: for current in ("快卖光 ¥12.88", " 快卖光 ¥ 12.88 ", "快卖光 ¥12.88"): with self.subTest(current=current), TemporaryDirectory() as temporary: