merge: T-103 safe price diagnostics
This commit is contained in:
@@ -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:
|
||||
"""当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 入口
|
||||
|
||||
+5
-1
@@ -25,7 +25,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T06:46:31Z sha256=701cb30a4821867dc4970d7a310793212998f0cada7e36617712bcb6f4bd27ae -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T06:56:08Z sha256=1f5f8b30076531a859c0cada576c91206cbe20471694a53690231fbb031e816e -->
|
||||
## 问题 / 背景
|
||||
|
||||
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。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
Reference in New Issue
Block a user