feat(client): project sanitized SKU price candidates

This commit is contained in:
QiuSW
2026-08-04 14:42:49 +08:00
parent a45a1b5afc
commit 1e69d274b8
2 changed files with 270 additions and 8 deletions
@@ -1,6 +1,7 @@
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
此模块只处理人工采集的本地文件:不连接设备、不理解拼多多页面,也不识别规格或价格。
此模块只处理人工采集的本地文件:不连接设备、不识别规格;仅可按已取证的固定
几何和严格格式,将跨隐私边界的价格叶节点投影到派生 XML。
"""
from __future__ import annotations
@@ -21,7 +22,7 @@ from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
SANITIZER_VERSION = "t103-privacy-v3"
SANITIZER_VERSION = "t103-privacy-v4"
EXPECTED_GOODS_ID = "937122477375"
EXPECTED_PDD_VERSION = "8.17.0"
EXPECTED_DEVICE_MODEL = "PKG110"
@@ -37,6 +38,21 @@ _FULL_PHONE_RE = re.compile(r"(?:\+?86)?1[3-9]\d{9}")
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
# 这两个槽位来自 T-103 当前第一态、1080×2376 XML 坐标的人工审查。它们不是通用
# 页面判据;坐标、文本或结构任何变化都停止发布,交由人重新取证。
_CROSSING_PRICE_BOUNDS = frozenset({(396, 503, 712, 570), (730, 503, 895, 570)})
_PRICE_PROJECTION_ATTRIBUTES = (
"bounds",
"text",
"package",
"class",
"clickable",
"enabled",
"visible-to-user",
)
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
_CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖光) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z")
class SkuEvidenceSanitizationError(RuntimeError):
@@ -49,9 +65,12 @@ class _CleanupStats:
removed_nodes: int = 0
cleared_crossing_nodes: int = 0
preserved_crossing_price_nodes: int = 0
retained_below_nodes: int = 0
max_right: int = 0
max_bottom: int = 0
current_price_candidates: int = 0
original_price_candidates: int = 0
@dataclass(frozen=True)
@@ -271,6 +290,7 @@ def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
_require_expected_xml_coordinate_space(stats)
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
_require_safe_crossing_price_projection(stats)
if _contains_phone(root):
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
@@ -294,9 +314,12 @@ def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats
parent.remove(node)
return
if position == "crossing":
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
_clear_node_text(node)
stats.cleared_crossing_nodes += 1
if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"):
_project_crossing_price_node(node, stats)
else:
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
_clear_node_text(node)
stats.cleared_crossing_nodes += 1
else:
stats.retained_below_nodes += 1
for child in list(node):
@@ -340,6 +363,47 @@ def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
return "crossing"
def _project_crossing_price_node(node: ElementTree.Element, stats: _CleanupStats) -> None:
"""投影唯一允许的跨界价格叶节点;任何结构漂移一律拒绝发布。"""
if (
len(node) != 0
or node.get("package") != "com.xunmeng.pinduoduo"
or node.get("class") != "android.widget.TextView"
or node.get("clickable") != "false"
or node.get("enabled") != "true"
or node.get("visible-to-user") != "true"
):
raise SkuEvidenceSanitizationError("跨界价格节点结构不匹配,拒绝发布。")
text = node.get("text")
if text is None:
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
match = _CROSSING_PRICE_TEXT_RE.fullmatch(text)
if match is None:
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
# 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。
node.attrib = {attribute: node.attrib[attribute] for attribute in _PRICE_PROJECTION_ATTRIBUTES}
node.text = None
node.tail = None
stats.preserved_crossing_price_nodes += 1
if match.group(1) is not None:
stats.current_price_candidates += 1
else:
stats.original_price_candidates += 1
def _require_safe_crossing_price_projection(stats: _CleanupStats) -> None:
"""当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
if (
stats.current_price_candidates != 1
or stats.original_price_candidates > 1
or stats.preserved_crossing_price_nodes != stats.current_price_candidates + stats.original_price_candidates
):
raise SkuEvidenceSanitizationError("跨界价格候选不唯一或缺失,拒绝发布。")
def _clear_node_text(node: ElementTree.Element) -> None:
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
node.text = None
@@ -415,6 +479,7 @@ def _derived_manifest(
"privacy_cleanup": {
"removed_nodes": cleanup_stats.removed_nodes,
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes,
"preserved_crossing_price_nodes": cleanup_stats.preserved_crossing_price_nodes,
"retained_below_nodes": cleanup_stats.retained_below_nodes,
"max_right": cleanup_stats.max_right,
"max_bottom": cleanup_stats.max_bottom,