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: diff --git a/docs/current-state.md b/docs/current-state.md index 2c43298..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;尚无规格选择、价格读取或下单流程 -- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 76 项离线单元测试 +- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 78 项离线单元测试 (全部 mock,不连接真机) - 数据:SQLite 核心表与迁移已落成;无业务实例数据 - 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口 diff --git a/docs/tasks/T-103.md b/docs/tasks/T-103.md index b0cfbbe..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)”;因此本任务不再假设存在“未选择”或“只选择一个维度”的初始状态。 @@ -143,6 +143,10 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space ### 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。 ## 边界