diff --git a/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py b/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py
index 0eef776..b507524 100644
--- a/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py
+++ b/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py
@@ -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,27 @@ _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_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",
+ "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")
+_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):
@@ -49,9 +71,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 +296,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 +320,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, bounds, stats)
+ else:
+ # 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
+ _clear_node_text(node)
+ stats.cleared_crossing_nodes += 1
else:
stats.retained_below_nodes += 1
for child in list(node):
@@ -340,6 +369,83 @@ def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
return "crossing"
+def _project_crossing_price_node(
+ node: ElementTree.Element,
+ bounds: tuple[int, int, int, int],
+ 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 _crossing_price_text_mismatch_error(bounds, text)
+
+ # 只有这七项经上述检查后可进入派生 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 _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:
+ """当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
+
+ 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 +521,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,
diff --git a/client/tests/device/test_sku_evidence_sanitizer.py b/client/tests/device/test_sku_evidence_sanitizer.py
index d729928..fbf24fc 100644
--- a/client/tests/device/test_sku_evidence_sanitizer.py
+++ b/client/tests/device/test_sku_evidence_sanitizer.py
@@ -34,6 +34,10 @@ TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
FULL_PHONE = "13800138000"
MASKED_PHONE = "138****0000"
SAFE_TEXT = "synthetic-safe-lower-content"
+CURRENT_PRICE = "快卖光 ¥12.88"
+ORIGINAL_PRICE = "¥29.00"
+PRICE_CURRENT_BOUNDS = "[396,503][712,570]"
+PRICE_ORIGINAL_BOUNDS = "[730,503][895,570]"
def _hash(path: Path) -> str:
@@ -48,11 +52,56 @@ def _default_xml() -> str:
return (
""
f""
+ f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
+ f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
f""
""
)
+def _price_node(
+ text: str,
+ bounds: str,
+ *,
+ package: str = "com.xunmeng.pinduoduo",
+ node_class: str = "android.widget.TextView",
+ clickable: str = "false",
+ enabled: str = "true",
+ visible: str = "true",
+ extra_attributes: str = "",
+ children: str = "",
+) -> str:
+ attributes = (
+ f"bounds='{bounds}' text='{text}' package='{package}' class='{node_class}' "
+ f"clickable='{clickable}' enabled='{enabled}' visible-to-user='{visible}'{extra_attributes}"
+ )
+ return f"{children}"
+
+
+def _xml_with_prices(
+ current: str = CURRENT_PRICE,
+ original: str = ORIGINAL_PRICE,
+ *,
+ current_node: str | None = None,
+ original_node: str | None = None,
+ include_original: bool = True,
+ extra_nodes: str = "",
+) -> str:
+ current_markup = current_node if current_node is not None else _price_node(current, PRICE_CURRENT_BOUNDS)
+ original_markup = (
+ original_node if original_node is not None else _price_node(original, PRICE_ORIGINAL_BOUNDS)
+ ) if include_original else ""
+ return (
+ ""
+ f""
+ f"{current_markup}"
+ f"{original_markup}"
+ f"{extra_nodes}"
+ f""
+ ""
+ )
+
+
def _write_raw(
root: Path,
*,
@@ -117,7 +166,7 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
self.assertNotIn(MASKED_PHONE, derived_xml)
self.assertIn(f'"human_declared_state": "{state}"', manifest)
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
- self.assertIn('"sanitizer_version": "t103-privacy-v3"', manifest)
+ self.assertIn('"sanitizer_version": "t103-privacy-v4"', manifest)
self.assertIn('"screenshot_space": {', manifest)
self.assertIn('"xml_coordinate_space": {', manifest)
self.assertIn('"height": 2376', manifest)
@@ -125,6 +174,7 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
self.assertIn('"privacy_mask_rectangle": [', manifest)
self.assertIn('"removed_nodes": 1', manifest)
self.assertIn('"cleared_crossing_nodes": 0', manifest)
+ self.assertIn('"preserved_crossing_price_nodes": 2', manifest)
self.assertIn('"retained_below_nodes": 1', manifest)
self.assertIn('"max_right": 1080', manifest)
self.assertIn('"max_bottom": 2376', manifest)
@@ -140,6 +190,8 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
""
f""
f""
+ f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
+ f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
f""
""
)
@@ -152,20 +204,245 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
self.assertIsNotNone(crossing)
assert crossing is not None
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
- self.assertEqual(len(list(crossing)), 1)
- self.assertEqual(list(crossing)[0].get("text"), SAFE_TEXT)
+ self.assertEqual(len(list(crossing)), 3)
+ self.assertEqual(list(crossing)[-1].get("text"), SAFE_TEXT)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertEqual(
manifest["privacy_cleanup"],
{
"removed_nodes": 1,
"cleared_crossing_nodes": 1,
+ "preserved_crossing_price_nodes": 2,
"retained_below_nodes": 1,
"max_right": 1080,
"max_bottom": 2376,
},
)
+ def test_only_strict_crossing_price_leaves_are_projected_with_whitelisted_attributes(self) -> None:
+ xml = _xml_with_prices(
+ current_node=_price_node(
+ CURRENT_PRICE,
+ PRICE_CURRENT_BOUNDS,
+ extra_attributes=" content-desc='discard' resource-id='discard' focused='true'",
+ ),
+ original_node=_price_node(
+ ORIGINAL_PRICE,
+ PRICE_ORIGINAL_BOUNDS,
+ extra_attributes=" content-desc='discard-too' resource-id='discard-too'",
+ ),
+ )
+ with TemporaryDirectory() as temporary:
+ raw = _write_raw(Path(temporary), xml=xml)
+ result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
+ root = ElementTree.parse(result.hierarchy_path).getroot()
+ prices = [node for node in root.findall("node") if node.get("text") in {CURRENT_PRICE, ORIGINAL_PRICE}]
+
+ self.assertEqual(len(prices), 2)
+ for node in prices:
+ self.assertEqual(
+ set(node.attrib),
+ {"bounds", "text", "package", "class", "clickable", "enabled", "visible-to-user"},
+ )
+ self.assertEqual(node.get("package"), "com.xunmeng.pinduoduo")
+ self.assertEqual(node.get("class"), "android.widget.TextView")
+ self.assertEqual(node.get("clickable"), "false")
+ self.assertEqual(node.get("enabled"), "true")
+ self.assertEqual(node.get("visible-to-user"), "true")
+
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+ self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
+ self.assertNotIn("discard", result.hierarchy_path.read_text(encoding="utf-8"))
+
+ def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
+ bad_texts = (
+ f"快卖光 ¥12.88 {TEST_ADDRESS}",
+ f"快卖光 ¥12.88 {FULL_PHONE}",
+ "快卖光 ¥12.88 使用微信支付",
+ "快卖光 ¥12.88 提交订单",
+ "快卖光 ¥12.88 优惠-11元",
+ "快要抢光 ¥12.88",
+ "快卖光 ¥0.00",
+ "快卖光 ¥12.8",
+ "快卖光 ¥12.880",
+ "快卖光 ¥12.88",
+ )
+ for text in bad_texts:
+ with self.subTest(text=text), TemporaryDirectory() as temporary:
+ raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
+ with self.assertRaises(SkuEvidenceSanitizationError):
+ 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:
+ raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
+ result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
+ hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
+ self.assertIn(current, hierarchy)
+
+ def test_unique_current_price_without_original_price_is_published(self) -> None:
+ with TemporaryDirectory() as temporary:
+ raw = _write_raw(Path(temporary), xml=_xml_with_prices(include_original=False))
+ result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
+ hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+
+ self.assertIn(CURRENT_PRICE, hierarchy)
+ self.assertNotIn(ORIGINAL_PRICE, hierarchy)
+ self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 1)
+
+ def test_crossing_price_projection_rejects_newline_and_structure_drift(self) -> None:
+ encoded_newline = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
+ "快卖光 ¥12.88", "快卖光
¥12.88"
+ )
+ with TemporaryDirectory() as temporary:
+ raw = _write_raw(Path(temporary), xml=_xml_with_prices(current_node=encoded_newline))
+ with self.assertRaises(SkuEvidenceSanitizationError):
+ sanitize_sku_panel_evidence(raw, raw.parent / "derived")
+ self.assertFalse((raw.parent / "derived").exists())
+
+ bad_structure = (
+ ("package", {"package": "com.android.systemui"}),
+ ("class", {"node_class": "android.view.View"}),
+ ("clickable", {"clickable": "true"}),
+ ("disabled", {"enabled": "false"}),
+ ("hidden", {"visible": "false"}),
+ ("children", {"children": ""}),
+ )
+ for name, kwargs in bad_structure:
+ with self.subTest(structure=name), TemporaryDirectory() as temporary:
+ raw = _write_raw(
+ Path(temporary),
+ xml=_xml_with_prices(current_node=_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS, **kwargs)),
+ )
+ with self.assertRaises(SkuEvidenceSanitizationError):
+ sanitize_sku_panel_evidence(raw, raw.parent / "derived")
+ self.assertFalse((raw.parent / "derived").exists())
+
+ def test_crossing_price_candidates_require_unique_current_and_at_most_one_original(self) -> None:
+ scenarios = (
+ (
+ "missing-current",
+ _xml_with_prices(current="¥12.88", original=ORIGINAL_PRICE),
+ ),
+ (
+ "duplicate-current",
+ _xml_with_prices(current=CURRENT_PRICE, original=f"快卖光 {ORIGINAL_PRICE}"),
+ ),
+ (
+ "multiple-original",
+ _xml_with_prices(
+ extra_nodes=_price_node("¥39.88", PRICE_ORIGINAL_BOUNDS),
+ ),
+ ),
+ )
+ for name, xml in scenarios:
+ with self.subTest(name=name), TemporaryDirectory() as temporary:
+ raw = _write_raw(Path(temporary), xml=xml)
+ with self.assertRaises(SkuEvidenceSanitizationError):
+ sanitize_sku_panel_evidence(raw, raw.parent / "derived")
+ self.assertFalse((raw.parent / "derived").exists())
+
+ def test_crossing_price_outside_fixed_windows_is_cleared_and_submit_price_is_not_candidate(self) -> None:
+ outside_crossing = _price_node("快卖光 ¥99.99", "[396,498][712,570]")
+ submit = (
+ ""
+ )
+ with TemporaryDirectory() as temporary:
+ raw = _write_raw(Path(temporary), xml=_xml_with_prices(extra_nodes=outside_crossing + submit))
+ result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
+ hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
+ manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
+
+ self.assertNotIn("快卖光 ¥99.99", hierarchy)
+ self.assertIn("提交订单 ¥12.88", hierarchy)
+ self.assertIn("submit-button", hierarchy)
+ self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
+
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
with TemporaryDirectory() as temporary:
root = Path(temporary)
diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md
index 67bd66f..34e49e4 100644
--- a/docs/03-tech-stack.md
+++ b/docs/03-tech-stack.md
@@ -155,12 +155,20 @@ T-103 已在 `client/src/cmbuyer_client/device/sku_evidence_sanitizer.py` 实现
`client/scripts/sanitize_sku_panel_evidence.py` 提供离线 CLI。第一组新 raw 的 PNG 头部由人确认实际为
1080×2376,推翻了 v1 将截图和 XML 坐标空间都写成 1080×2400 的假设;v1 正确拒绝且原始证据未重采。
随后 v2 在同一份 raw 上安全报告 XML `observed 1080x2376` 并拒绝发布,证明其独立配置的
-1080×2400 假设同样不成立。当前 `t103-privacy-v3` 配置精确绑定 PKG110 / Android 16 / 拼多多
-8.17.0 / goods_id `937122477375`;screenshot space 与 XML coordinate space 仍分别建模、分别校验,
-当前都精确为 1080×2376,且都把
+1080×2400 假设同样不成立。v3 用同一 raw 成功发布后,人已确认截图隐私带完整遮挡、派生 XML
+不含地址或手机号且目标颜色和尺码仍为预选;但顶部当前价节点横跨隐私带边界,v3 只留下断片,
+而下方完整的“提交订单 ¥12.88”属于第一趟硬拒绝区,不能作为 SKU 单价证据。
+
+当前 `t103-privacy-v4` 继续精确绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id
+`937122477375`;screenshot space 与 XML coordinate space 仍分别建模、分别校验,当前都精确为
+1080×2376,且都把
`[0,0,1080,540)` 作为整宽隐私带。截图覆盖该区域;XML 递归移除区域内节点,跨界容器只清空自身
-敏感属性并保留下方子节点。地址依靠已确认的整块几何隔离而不是易漏的关键词表;完整、掩码、带分隔符
-或跨节点手机号残留会被自动复检拒绝。
+敏感属性并保留下方子节点。v4 **不改变或缩小截图遮罩**,只允许交界带两个已取证精确 bounds 中,
+属于拼多多包、不可点击、可见且启用的 `TextView` 叶节点投影最小价格属性;当前价必须唯一匹配
+可选“快卖光”前缀加人民币符号和两位小数,原价同格式且最多一个。包外、可点击、结构漂移、重复、
+零值、混杂文本或坐标变化一律拒绝。地址依靠已确认的整块几何隔离而不是易漏的关键词表;完整、
+掩码、带分隔符或跨节点手机号残留会被自动复检拒绝。v4 离线实现已通过合成测试,仍须由人用同一
+第一态 raw 重跑并确认派生结果,才构成真实价格证据。
```powershell
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "<证据目录>\raw" --output-dir "<证据目录>\derived"
@@ -170,8 +178,8 @@ T-103 已在 `client/src/cmbuyer_client/device/sku_evidence_sanitizer.py` 实现
节点观察到的最大 right/bottom 必须精确为 1080×2376,否则只报告非敏感 observed 尺寸并拒绝。它只允许发布到
同级且尚不存在的 `derived`;失败或发布竞态不覆盖已有目录、不留下 staging。派生 manifest 记录
`privacy_tier=SANITIZED`、sanitizer 版本、两个坐标空间、清理计数及本机 source/derived 哈希,不记录
-原始路径、serial 或页面正文。只有自动复检通过并经人确认状态对应性的派生物,agent 才能读取并提取
-最小 fixture、编写判据。
+原始路径、serial 或页面正文;v4 另记录 `preserved_crossing_price_nodes`,用于核对严格价格投影
+数量。只有自动复检通过并经人确认状态对应性的派生物,agent 才能读取并提取最小 fixture、编写判据。
2026-08-04 的首轮旧证据只用于确认上述入口事实;其中旧 `initial` 不是规格面板,另两张原始截图含隐私
区域,因此 XML 未读取、fixture 与选择器未生成。T-110 完成受控入口与脱敏契约后,T-103 重新取证;
diff --git a/docs/current-state.md b/docs/current-state.md
index 3d39d5d..78b1f44 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -21,7 +21,7 @@
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
-- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 69 项离线单元测试
+- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 78 项离线单元测试
(全部 mock,不连接真机)
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
@@ -32,10 +32,13 @@
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为第一趟可逆导航,
数量、确认页、提交订单、付款与通用点击能力仍不可达。T-103 的原始证据本机隔离与确定性脱敏器
- 已完成离线实现;人工确认面板刚打开时目标颜色和尺码已经自动选中,取证三态已据此修正。下一步
- 用 v3 对已采第一态 raw 重新脱敏;截图与 XML 已分别安全观测为 1080×2376,仍作为两个独立坐标
- 空间严格校验。第一态 derived 经人验收后,再采集“两个维度改为非目标 / 两个维度恢复目标”的 raw,
- 并由人只核对 derived。在派生证据验收前不写页面判据,Phase 2 仍不能抢跑。
+ 已完成离线实现;第一态 v3 derived 已由人确认隐私安全,并证明目标颜色和尺码自动预选,派生哈希
+ 复算一致。顶部规格价格横跨隐私遮罩边界,当前只保留了不可读断片;唯一完整金额位于禁触的
+ “提交订单 ¥12.88”区域,不能作为第一趟价格证据。v4 已在保持截图遮罩不变的前提下,只允许顶部
+ 交界带中满足精确坐标、拼多多包、非点击叶节点和严格金额格式的文本投影到派生 XML,离线门禁已通过;
+ 下一步由人保留已验收 v3 derived,并用同一 raw 重跑 v4。价格证据通过后才采集“两个维度改为非目标 /
+ 两个维度恢复目标”两态。T-010 已允许不依赖真机字段的 T-201 和只创建 `DRAFT` 的 T-202 并行;
+ T-203 及后续会启动试选或依赖真机字段的 Phase 2 功能继续等待 T-103。
## 当前目录要点
@@ -59,9 +62,11 @@
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
链接打开与目标商品/隐私人工验收)。
+- 已完成 T-010(安全并行门禁);T-201(管理员登录与会话)已在独立写路径并行开发。T-202 可在
+ T-201 完成后继续,但只能创建和展示 `DRAFT`,不得启动试选或引入未经 T-103 证实的真机字段。
- 已完成 T-110(第一趟受控规格入口与隐私脱敏边界)。T-103 是当前最高优先级和 MVP 生死线,
- 已补充 T-110 依赖并恢复 `DOING`;脱敏器已完成,三态派生证据和新真机验收完成前不开发依赖真机可读字段的
- Phase 2 生产页面。
+ 已补充 T-110 依赖并恢复 `DOING`;v4 脱敏器离线实现已完成,三态派生证据和新真机验收完成前不开发
+ T-203 及后续依赖真机可读字段或会启动试选的 Phase 2 生产页面。
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
可读字段时必须先回修原型与交互清单。
@@ -161,11 +166,13 @@ T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored--v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored--v2\derived"
```
-原始 screenshot/XML 可能包含 PDD 固定展示的地址和掩码手机号,只能留在本机 `raw` 目录,不能由
-agent、fixture、业务或上传端消费。`t103-privacy-v3` 把截图与 XML 保持为两个独立坐标空间,
-并根据同一第一态 raw 的两次 fail-closed 安全观测将二者分别精确固定为 1080×2376;它校验源哈希、
-设备/App/商品/人工状态与隐私结构,在 sibling
-`derived` 目录原子发布 screenshot/XML 与 manifest;任何不匹配、手机号残留或已有目标均拒绝发布。
+PDD 现场显示地址和掩码手机号不阻塞真机流程;但原始 screenshot/XML 只能留在本机 `raw` 隔离目录,
+不能由 agent、fixture、业务或上传端消费。`t103-privacy-v4` 把截图与 XML 保持为两个独立坐标空间,
+并根据同一第一态 raw 的两次 fail-closed 安全观测将二者分别精确固定为 1080×2376;截图隐私带沿用
+v3,不扩大、不缩小。v4 只从两个精确交界 bounds 投影属于拼多多包、不可点击且格式严格唯一的价格
+叶节点,任何点击节点、包外节点、坐标/结构/文本漂移、重复或零值都 fail closed。它校验源哈希、
+设备/App/商品/人工状态与隐私结构,在 sibling `derived` 目录原子发布 screenshot/XML 与 manifest;
+任何不匹配、手机号残留或已有目标均拒绝发布。
自动复检通过且人确认三态对应性后,agent 才能读取派生物并提取最小 fixture、编写判据。
人工确认中还必须记录中间态实际选择的非目标颜色和尺码,不能只写“已切换”。
diff --git a/docs/tasks/T-103.md b/docs/tasks/T-103.md
index 373d969..ca6bcde 100644
--- a/docs/tasks/T-103.md
+++ b/docs/tasks/T-103.md
@@ -25,7 +25,7 @@ write_paths:
- docs/current-state.md
---
-
+
## 问题 / 背景
T-102 已证明 canonical 链接可进入目标商品。T-103 随后在 PKG110 / Android 16 / 拼多多 8.17.0、衣服商品 goods_id `937122477375` 上确认:规格面板只能从详情页右下角精确文案“快要抢光”进入,面板固定显示收货区域和掩码手机号。T-110 经项目所有者批准,将该已取证点击定义为可逆、能力受限的规格面板导航;这不是通用购买入口豁免,不授权其他文案、数量、确认页、提交订单或支付。 项目所有者随后确认,面板刚打开时已自动选中目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”;因此本任务不再假设存在“未选择”或“只选择一个维度”的初始状态。
@@ -131,6 +131,22 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
### 2026-08-04T06:05:53Z · ila
2026-08-04 T-103 sanitizer v3 坐标修正与主审:人用同一第一态 raw 运行 v2,脱敏器安全拒绝并仅报告 observed 1080x2376,证明 XML 实际坐标与截图相同;原始证据仍有效、未重采、未读取正文。提交 acf7e11 将 sanitizer_version 升为 t103-privacy-v3;截图与 XML 仍分别建模并分别严格校验,但当前均精确绑定 1080x2376,隐私带保持 [0,0,1080,540)。合成测试明确拒绝旧截图 1080x2400、旧 v2 XML 1080x2400,以及较小/较大坐标;手机号残留、哈希/状态/版本、确定性、原子发布和不覆盖门禁保持不变。主 agent 独立复跑 69 项 client 单测、compileall、CLI help、静态禁用能力检索与 diff-check,全部通过;未连接设备、未读取真实 raw。T-103 保持 DOING,等待人用同一第一态 raw 重跑 v3 并只核对 derived。
+
+### 2026-08-04T06:17:05Z · ila
+
+2026-08-04 第一态 derived 人工隐私验收与主审:项目所有者确认截图顶部隐私区完全遮黑、派生 XML 不含地址/手机号、目标颜色和尺码仍为预选;顶部价格因隐私带只显示一部分,最底部价格完整。人工确认后主 agent 才读取 derived,未访问 raw。路径 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-opened-target- 937122477375-v2\derived;manifest 为 privacy_tier=SANITIZED、sanitizer_version=t103-privacy-v3、state=panel-opened-target-preselected,派生 screenshot/XML SHA-256 分别为 f9e370747cbef4facf6ec4a20d5af72b36c7615e7be7038144b1834632cdbf57 / f2024b7bcc69a03b05f5e95610708c0dbd33001698f40a011ddc895cf0410fb1,复算一致。XML 中“黑色 CHA (纯棉)”与“M(建议100-115)”各有明确 selected=true,汇总文本一致。完整金额候选仅见“提交订单 ¥12.88”;该文本只能在不可点击的派生快照中用于离线价格语义验证,第一趟绝不能获得该控件或其可点击父节点。另观察到 com.android.systemui 的“肉包采购辅助”浮层节点,后续最小 fixture/判据必须限定 PDD package,并建议后两态采集前关闭该浮层。第一态隐私与状态证据通过,但价格语义仍需另两态交叉验证,T-103 保持 DOING。
+
+### 2026-08-04T06:20:03Z · ila
+
+主审补充澄清:上一条所述“提交订单 ¥12.88”只能证明面板中存在该禁触文本,不得作为第一趟 SKU 单价证据,也不得驱动任何控件解析或交互。原因是它属于可点击“提交订单”父区域,违反 T-110 硬拒绝区边界。v3 第一态因此只通过隐私与预选状态验收,价格证据仍不通过。下一步不缩小 [0,0,1080,540) 截图遮罩,而由 v4 脱敏器在顶部交界带仅保留严格白名单、PDD package、非点击叶节点的价格文本到派生 XML;任何混杂文本、点击节点、包外节点或非唯一候选均 fail closed。
+
+### 2026-08-04T06:43:53Z · ila
+
+2026-08-04 客户确认加速方案:手机规格面板显示地址/手机号不再作为真机流程阻塞条件;不再扩大或调整截图黑色遮罩。原始证据仍只留本机,agent/Git/服务端仍只消费派生物。提交 1e69d27 实现 t103-privacy-v4:截图遮罩与 1080x2376 双坐标校验完全不变,只把已取证的两个顶部交界价格槽中,PDD package、TextView、非点击、可见启用、叶节点且严格匹配“唯一快卖光当前价 + 至多一个原价”的七属性安全文本投影到派生 XML;结构/文本/候选数漂移均原子拒绝,底部“提交订单 ¥12.88”仍不是价格候选。主 agent 两轮退回测试职责问题后独立复跑 76 项 client 单测、compileall、CLI help、禁用能力检索与 diff-check,全部通过。下一步保留已验收 v3 derived,用同一 raw 生成 v4 derived;T-103 保持 DOING。
+
+### 2026-08-04T06:55:52Z · ila
+
+2026-08-04 项目所有者用同一第一态 raw 运行 v4,脱敏器按设计 fail closed:跨界价格节点文本不匹配,未发布 derived;raw 与已保留的 derived-v3-reviewed 未受损。提交 ef1ac60 增加最小安全诊断:失败只输出两个固定价格槽位和受控 reason 枚举,不回显原文、金额、数字串、字符码点、长度或原始属性,v4 成功白名单与原子不发布语义不变。主 agent 独立复跑 78 项 client 单测、compileall、CLI help 与 diff-check,全部通过。等待项目所有者用同一 raw 重跑并反馈 slot/reason;T-103 保持 DOING。
## 边界