feat(client): verify same-product safe exit
This commit is contained in:
@@ -28,6 +28,12 @@ _ENTRY_ACTION_DESC = "快要抢光¥12.88"
|
||||
_ENTRY_ACTION_BOUNDS = "[446,2166][1080,2328]"
|
||||
_ENTRY_SIBLING = "免拼购买"
|
||||
_ENTRY_SIBLING_BOUNDS = "[688,2256][856,2305]"
|
||||
_PRODUCT_TITLE_DESC = "2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭"
|
||||
_PRODUCT_TITLE_FIRST_LINE = "2026年新款高档重工潮流烫钻中长款T恤显瘦宽松"
|
||||
_PRODUCT_TITLE_SECOND_LINE = "上衣淡人穿搭"
|
||||
_PRODUCT_TITLE_RESOURCE_ID = "com.xunmeng.pinduoduo:id/tv_title"
|
||||
_PRODUCT_TITLE_BOUNDS = "[36,1571][1044,1689]"
|
||||
_DANGEROUS_EXIT_ACTION_TERMS = ("提交订单", "确认订单", "立即支付", "去支付", "付款")
|
||||
_FORBIDDEN_ENTRY_ACTION_DESC = (
|
||||
"购买", "下单", "付款", "订单", "单独购买", "直接拼成", "提交订单", "支付",
|
||||
"先用后付", "0元下单", "0 元下单",
|
||||
@@ -423,16 +429,22 @@ class SkuSelectionFlow:
|
||||
self._terminal = True
|
||||
raise
|
||||
deadline = self._clock() + self._entry_timeout
|
||||
stable: tuple[object, ...] | None = None
|
||||
try:
|
||||
while True:
|
||||
self._require_foreground()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != before:
|
||||
try:
|
||||
_classify_panel(_parse_nodes(raw))
|
||||
except SkuSelectionError:
|
||||
try:
|
||||
projection = _product_exit_projection(_parse_nodes(raw))
|
||||
except SkuSelectionError:
|
||||
stable = None
|
||||
else:
|
||||
# 用前台/版本检查夹住第二棵候选树;页面在 dump 后漂移时不能成功。
|
||||
self._require_foreground()
|
||||
if stable == projection:
|
||||
self._terminal = True
|
||||
return
|
||||
stable = projection
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
|
||||
@@ -1394,6 +1406,62 @@ def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | N
|
||||
)
|
||||
|
||||
|
||||
def _product_exit_projection(nodes: list[_Node]) -> tuple[object, ...]:
|
||||
"""返回 T-104 人验同商品详情页的最小稳定投影;不读取页面价格。"""
|
||||
|
||||
if any(
|
||||
_is_live_clickable(node)
|
||||
and any(term in value for value in (node.text, node.desc) for term in _DANGEROUS_EXIT_ACTION_TERMS)
|
||||
for node in nodes
|
||||
):
|
||||
# T-106/T-107 前只把这些结构当保守拒绝,不宣称它们是完整确认/支付页分类器。
|
||||
raise SkuSelectionError("退出后出现危险动作语义,不能确认安全退出。")
|
||||
|
||||
entries = _eligible_entries(nodes)
|
||||
if len(entries) != 1:
|
||||
raise SkuSelectionError("退出后商品规格入口不唯一。")
|
||||
entry = _entry_projection(entries[0], nodes)
|
||||
if entry is None:
|
||||
raise SkuSelectionError("退出后商品规格入口结构失效。")
|
||||
|
||||
titles = [
|
||||
node
|
||||
for node in nodes
|
||||
if _exact_common(
|
||||
node,
|
||||
"android.view.ViewGroup",
|
||||
_PRODUCT_TITLE_BOUNDS,
|
||||
clickable="true",
|
||||
selected="false",
|
||||
scrollable="false",
|
||||
)
|
||||
and node.element.get("resource-id") == _PRODUCT_TITLE_RESOURCE_ID
|
||||
and node.element.get("long-clickable") == "true"
|
||||
and not node.text
|
||||
and node.desc == _PRODUCT_TITLE_DESC
|
||||
]
|
||||
title = _one(titles, "退出后同商品标题正锚不唯一。")
|
||||
children = _walk_direct_children(title)
|
||||
expected_children = (
|
||||
(_PRODUCT_TITLE_FIRST_LINE, "[36,1571][1023,1624]"),
|
||||
(_PRODUCT_TITLE_SECOND_LINE, "[36,1636][306,1689]"),
|
||||
)
|
||||
if len(children) != len(expected_children) or any(
|
||||
not _exact_readonly_text(child, text, bounds)
|
||||
for child, (text, bounds) in zip(children, expected_children, strict=True)
|
||||
):
|
||||
raise SkuSelectionError("退出后同商品标题子结构漂移。")
|
||||
|
||||
return (
|
||||
"product_exit_8_17_0",
|
||||
entry,
|
||||
_entry_node_projection(title),
|
||||
tuple(_entry_node_projection(child) for child in children),
|
||||
title.element.get("resource-id", ""),
|
||||
title.element.get("long-clickable", ""),
|
||||
)
|
||||
|
||||
|
||||
def _entry_node_projection(node: _Node) -> tuple[str, ...]:
|
||||
return (
|
||||
node.element.tag,
|
||||
|
||||
@@ -148,6 +148,7 @@ class SkuSelectionRunResult:
|
||||
screenshot_path: Path
|
||||
manifest_path: Path
|
||||
unit_price: str
|
||||
captured_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -589,7 +590,9 @@ class SkuSelectionRunner:
|
||||
stage = "screenshot_capture"
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
screenshot_payload = adapter.capture_screenshot()
|
||||
captured_at = datetime.now(UTC)
|
||||
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
@@ -610,7 +613,22 @@ class SkuSelectionRunner:
|
||||
|
||||
stage = "publish"
|
||||
manifest_path.write_text(
|
||||
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size, adapter), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
json.dumps(
|
||||
_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
link,
|
||||
screenshot_path,
|
||||
task_color,
|
||||
task_size,
|
||||
adapter,
|
||||
captured_at,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||
@@ -648,6 +666,7 @@ class SkuSelectionRunner:
|
||||
screenshot_path=target / "screenshot.png",
|
||||
manifest_path=target / "manifest.json",
|
||||
unit_price=EXPECTED_UNIT_PRICE,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -819,13 +838,14 @@ def _manifest(
|
||||
task_color: str,
|
||||
task_size: str,
|
||||
adapter: UiautomatorSkuPanelAdapter,
|
||||
captured_at: datetime,
|
||||
) -> dict[str, Any]:
|
||||
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
||||
|
||||
option_outcomes = dict(adapter.option_rpc_outcomes)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"captured_at": captured_at.isoformat(),
|
||||
"operation": "t103-sku-selection",
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"target_selection": {"color": task_color, "size": task_size},
|
||||
@@ -860,8 +880,9 @@ def _manifest(
|
||||
"rpc_outcome": adapter.back_rpc_outcome,
|
||||
},
|
||||
},
|
||||
"post_exit_status": "human_review_required",
|
||||
"page_identity": "human_review_required",
|
||||
"post_exit_status": "same_product_verified",
|
||||
"safe_exit": "completed",
|
||||
"page_identity": "same_goods_evidence_bound",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
|
||||
Reference in New Issue
Block a user