merge: T-103 safe price diagnostics

This commit is contained in:
QiuSW
2026-08-04 14:56:50 +08:00
4 changed files with 132 additions and 6 deletions
@@ -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:
+1 -1
View File
@@ -21,7 +21,7 @@
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB `client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI, 连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程 以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 76 项离线单元测试 - 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 78 项离线单元测试
(全部 mock,不连接真机) (全部 mock,不连接真机)
- 数据:SQLite 核心表与迁移已落成;无业务实例数据 - 数据:SQLite 核心表与迁移已落成;无业务实例数据
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口 - 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
+5 -1
View File
@@ -25,7 +25,7 @@ write_paths:
- docs/current-state.md - 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)”;因此本任务不再假设存在“未选择”或“只选择一个维度”的初始状态。 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-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-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 --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界