fix(client): diagnose T-103 price text safely
This commit is contained in:
@@ -40,7 +40,11 @@ _MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*",
|
|||||||
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
|
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
|
||||||
# 这两个槽位来自 T-103 当前第一态、1080×2376 XML 坐标的人工审查。它们不是通用
|
# 这两个槽位来自 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 = (
|
_PRICE_PROJECTION_ATTRIBUTES = (
|
||||||
"bounds",
|
"bounds",
|
||||||
"text",
|
"text",
|
||||||
@@ -53,6 +57,8 @@ _PRICE_PROJECTION_ATTRIBUTES = (
|
|||||||
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
|
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
|
||||||
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
|
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
|
||||||
_CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖光) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z")
|
_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):
|
class SkuEvidenceSanitizationError(RuntimeError):
|
||||||
@@ -315,7 +321,7 @@ def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats
|
|||||||
return
|
return
|
||||||
if position == "crossing":
|
if position == "crossing":
|
||||||
if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"):
|
if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"):
|
||||||
_project_crossing_price_node(node, stats)
|
_project_crossing_price_node(node, bounds, stats)
|
||||||
else:
|
else:
|
||||||
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
|
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
|
||||||
_clear_node_text(node)
|
_clear_node_text(node)
|
||||||
@@ -363,7 +369,11 @@ def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
|
|||||||
return "crossing"
|
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 (
|
if (
|
||||||
@@ -380,7 +390,7 @@ def _project_crossing_price_node(node: ElementTree.Element, stats: _CleanupStats
|
|||||||
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
|
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
|
||||||
match = _CROSSING_PRICE_TEXT_RE.fullmatch(text)
|
match = _CROSSING_PRICE_TEXT_RE.fullmatch(text)
|
||||||
if match is None:
|
if match is None:
|
||||||
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
|
raise _crossing_price_text_mismatch_error(bounds, text)
|
||||||
|
|
||||||
# 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。
|
# 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。
|
||||||
node.attrib = {attribute: node.attrib[attribute] for attribute in _PRICE_PROJECTION_ATTRIBUTES}
|
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
|
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:
|
def _require_safe_crossing_price_projection(stats: _CleanupStats) -> None:
|
||||||
"""当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
|
"""当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,86 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
self.assertFalse((raw.parent / "derived").exists())
|
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:
|
def test_crossing_price_projection_allows_only_limited_ascii_spaces_and_yen_variants(self) -> None:
|
||||||
for current in ("快卖光 ¥12.88", " 快卖光 ¥ 12.88 ", "快卖光 ¥12.88"):
|
for current in ("快卖光 ¥12.88", " 快卖光 ¥ 12.88 ", "快卖光 ¥12.88"):
|
||||||
with self.subTest(current=current), TemporaryDirectory() as temporary:
|
with self.subTest(current=current), TemporaryDirectory() as temporary:
|
||||||
|
|||||||
Reference in New Issue
Block a user