fix(client): accept exact current-only sku state

This commit is contained in:
QiuSW
2026-08-05 15:20:09 +08:00
parent 5a4633fb64
commit e1e534c5d3
8 changed files with 821 additions and 58 deletions
@@ -66,12 +66,48 @@ _TARGET_URL = f"https://mobile.yangkeduo.com/goods.html?goods_id={EXPECTED_GOODS
_REVEAL_START = (360, 1900)
_REVEAL_END = (360, 1300)
_REVEAL_STEPS = 30
_REVEAL_FAILURE_STAGES = frozenset(
(
"reveal_precondition",
"reveal_attempted",
"reveal_candidate",
"reveal_after",
"reveal_publish",
)
)
_REVEAL_FAILURE_MARKER = object()
class SkuRevealSpikeError(RuntimeError):
"""一次性 reveal 取证未形成可发布证据。"""
def _annotate_reveal_failure(error: BaseException, stage: str) -> None:
"""只记录本模块实际走到的固定阶段;入口阶段使用独立 marker,不会被覆盖。"""
if type(stage) is not str or stage not in _REVEAL_FAILURE_STAGES:
return
try:
setattr(error, "_cmbuyer_reveal_failure_stage", stage)
# marker 最后写入,任一 setter 失败都不能形成可信阶段。
setattr(error, "_cmbuyer_reveal_failure_marker", _REVEAL_FAILURE_MARKER)
except BaseException:
pass
def safe_reveal_failure_stage(error: BaseException) -> str | None:
"""读取可公开的 reveal 控制流阶段;伪造属性或 hostile getter 均失败闭合。"""
try:
marker = getattr(error, "_cmbuyer_reveal_failure_marker", None)
stage = getattr(error, "_cmbuyer_reveal_failure_stage", None)
if marker is not _REVEAL_FAILURE_MARKER or type(stage) is not str:
return None
return stage if stage in _REVEAL_FAILURE_STAGES else None
except BaseException:
return None
@dataclass(frozen=True)
class SkuRevealSpikeResult:
output_directory: Path
@@ -127,13 +163,15 @@ class SkuRevealSpikeCapturer:
goods_id: str,
output_directory: Path,
) -> SkuRevealSpikeResult:
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。")
link = parse_product_url(_TARGET_URL)
target = Path(output_directory)
_validate_new_target(target)
stage = "reveal_precondition"
staging: Path | None = None
adapter: _RevealEvidenceAdapter | None = None
try:
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。")
link = parse_product_url(_TARGET_URL)
target = Path(output_directory)
_validate_new_target(target)
staging = _prepare_staging(target)
deadline = self._clock() + self._timeout_seconds
inspection = self._adb_client.inspect(serial)
@@ -180,12 +218,16 @@ class SkuRevealSpikeCapturer:
(staging / "before" / "hierarchy.xml").write_text(before_hierarchy, encoding="utf-8")
rpc_outcome = "completed"
# 此后即属于 attempted:adapter 会在 RPC 前封存唯一机会,结果不明也只能调和。
stage = "reveal_attempted"
try:
adapter.reveal_size_options_once()
except SkuSelectionRunError:
rpc_outcome = "ambiguous_reconciled"
stage = "reveal_candidate"
projection, after_hierarchy = self._wait_for_candidate(adapter, deadline)
stage = "reveal_after"
after_directory = staging / "after"
_capture_frame(adapter, after_directory, after_hierarchy)
reverified = adapter.dump_window_hierarchy()
@@ -193,6 +235,7 @@ class SkuRevealSpikeCapturer:
raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。")
(after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8")
stage = "reveal_publish"
manifest = _manifest(inspection, serial, rpc_outcome, staging)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
@@ -201,12 +244,22 @@ class SkuRevealSpikeCapturer:
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError):
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
_clean_staging(staging)
failure_stage = stage
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
# attempted 只能在 adapter 已于 RPC 前封存唯一机会后成立。
failure_stage = "reveal_precondition"
_annotate_reveal_failure(error, failure_stage)
raise
except Exception as error:
_clean_staging(staging)
raise SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。") from error
mapped = SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。")
failure_stage = stage
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
failure_stage = "reveal_precondition"
_annotate_reveal_failure(mapped, failure_stage)
raise mapped from error
return SkuRevealSpikeResult(target, target / "manifest.json")
+207 -42
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from functools import partial
import re
from time import monotonic, sleep
from typing import Any, Callable, Protocol
@@ -126,15 +127,32 @@ class _PanelProfile(Enum):
@dataclass(frozen=True)
class _PanelSpec:
profile: _PanelProfile
header_bounds: str
outer_bounds: str
price_row_bounds: str
class _DualPriceLayout:
row_bounds: str
current_text: str
current_bounds: str
original_text: str
original_bounds: str
@dataclass(frozen=True)
class _CurrentOnlyPriceLayout:
# 同一 App 版本会把原价整段移除;这是独立证据布局,不是“原价可选”。
frame_bounds: str
row_bounds: str
current_text: str
current_bounds: str
_PriceLayout = _DualPriceLayout | _CurrentOnlyPriceLayout
@dataclass(frozen=True)
class _PanelSpec:
profile: _PanelProfile
header_bounds: str
outer_bounds: str
price_layout: _PriceLayout
summary_text: str
summary_bounds: str
color_label_bounds: str | None
@@ -149,8 +167,10 @@ _PANEL_SPECS = (
_PanelSpec(
_PanelProfile.PANEL_OPEN_EMPTY,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
"券前¥29.88", "[693,580][912,647]",
_DualPriceLayout(
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
"券前¥29.88", "[693,580][912,647]",
),
_EMPTY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", False,
"[36,2069][114,2079]", None,
@@ -158,8 +178,32 @@ _PANEL_SPECS = (
_PanelSpec(
_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
"券前¥29.88", "[693,580][912,647]",
_DualPriceLayout(
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
"券前¥29.88", "[693,580][912,647]",
),
_COLOR_ONLY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", True,
"[36,2069][114,2079]", None,
),
_PanelSpec(
_PanelProfile.PANEL_OPEN_EMPTY,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
_CurrentOnlyPriceLayout(
"[396,575][1053,647]", "[396,575][693,647]",
"限1件 ¥12.88 ", "[396,580][675,647]",
),
_EMPTY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", False,
"[36,2069][114,2079]", None,
),
_PanelSpec(
_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
_CurrentOnlyPriceLayout(
"[396,575][1053,647]", "[396,575][693,647]",
"限1件 ¥12.88 ", "[396,580][675,647]",
),
_COLOR_ONLY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", True,
"[36,2069][114,2079]", None,
@@ -167,8 +211,10 @@ _PANEL_SPECS = (
_PanelSpec(
_PanelProfile.SIZE_VISIBLE_NON_TARGET,
"[0,366][1080,1000]", "[0,1000][1080,2079]",
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
_DualPriceLayout(
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
),
_S_SUMMARY, "[396,654][1053,716]", None,
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
"[36,1506][114,1552]", _S_SIZE_UI,
@@ -176,8 +222,10 @@ _PANEL_SPECS = (
_PanelSpec(
_PanelProfile.TARGETS_SELECTED,
"[0,366][1080,1000]", "[0,1000][1080,2079]",
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
_DualPriceLayout(
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
),
_TARGET_SUMMARY, "[396,654][1053,716]", None,
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
"[36,1506][114,1552]", _TARGET_SIZE_UI,
@@ -235,15 +283,21 @@ class SkuSelectionFlow:
self._require_foreground()
before = self._read_hierarchy()
nodes = _parse_nodes(before)
profile = _classify_panel(nodes)
spec = _classify_panel(nodes)
profile = spec.profile
if profile is _PanelProfile.PANEL_OPEN_EMPTY:
target = _target_color_action(nodes, selected=False)
_action_bounds(target.bounds)
_require_action_occupants(nodes, target)
self._pending = (before, _require_color_only_panel)
color_postcondition = partial(
_require_profile_with_price_layout,
expected=_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
expected_price_layout=spec.price_layout,
)
self._pending = (before, color_postcondition)
self._device.tap_sku_option(target.bounds)
self._wait_after_action(before, _require_color_only_panel)
self._wait_after_action(before, color_postcondition)
# 当前证据只证明颜色选择;尺码仍在视口外。没有动作证据时必须在此停住,
# 不能把一次通用 swipe 或下一次规格点击伪装成已验证流程。
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
@@ -383,14 +437,14 @@ def _parse_nodes(raw: str) -> list[_Node]:
return result
def _classify_panel(nodes: list[_Node]) -> _PanelProfile:
matches: list[_PanelProfile] = []
def _classify_panel(nodes: list[_Node]) -> _PanelSpec:
matches: list[_PanelSpec] = []
for spec in _PANEL_SPECS:
try:
_match_panel_profile(nodes, spec)
except SkuSelectionError:
continue
matches.append(spec.profile)
matches.append(spec)
if len(matches) != 1:
raise SkuSelectionError("规格面板不符合唯一完整取证 profile,已停止操作。")
return matches[0]
@@ -408,9 +462,23 @@ def _require_target_panel(nodes: list[_Node]) -> None:
_require_profile(nodes, _PanelProfile.TARGETS_SELECTED)
def _require_profile(nodes: list[_Node], expected: _PanelProfile) -> None:
if _classify_panel(nodes) is not expected:
def _require_profile(nodes: list[_Node], expected: _PanelProfile) -> _PanelSpec:
spec = _classify_panel(nodes)
if spec.profile is not expected:
raise SkuSelectionError("规格面板动作后状态与已取证 profile 不一致,已停止操作。")
return spec
def _require_profile_with_price_layout(
nodes: list[_Node],
*,
expected: _PanelProfile,
expected_price_layout: _PriceLayout,
) -> None:
spec = _require_profile(nodes, expected)
# 同一次颜色点击不得跨价格证据 variant;完整 dataclass identity 同时绑定类型与全部 exact 字段。
if spec.price_layout != expected_price_layout:
raise SkuSelectionError("规格面板动作后价格布局发生跨变体漂移,已停止操作。")
def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
@@ -427,24 +495,24 @@ def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
[node for node in nodes if node.parent is surface and _exact_recycler(node, spec.outer_bounds)],
"规格面板维度容器不唯一。",
)
price_row = _one(
[node for node in nodes if _descendant(node, header) and _exact_inert(node, "android.widget.LinearLayout", spec.price_row_bounds)],
"规格面板价格行不唯一。",
)
if len([child for child in price_row.element if child.tag == "node"]) != 2:
raise SkuSelectionError("规格面板价格行子节点数量漂移。")
current = _one(
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, spec.current_text, spec.current_bounds)],
"规格面板当前价角色不唯一。",
)
_one(
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, spec.original_text, spec.original_bounds)],
"规格面板原价角色不唯一。",
current, summary_anchor, summary_is_direct = _match_price_layout(
nodes,
header,
spec.price_layout,
)
if _clickable_before(current, surface):
raise SkuSelectionError("规格面板价格角色位于可点击内容祖先下。")
_one(
[node for node in nodes if _descendant(node, header) and _exact_readonly_text(node, spec.summary_text, spec.summary_bounds)],
[
node
for node in nodes
if (
node.parent is summary_anchor
if summary_is_direct
else _descendant(node, summary_anchor)
)
and _exact_readonly_text(node, spec.summary_text, spec.summary_bounds)
],
"规格面板摘要不唯一。",
)
@@ -507,11 +575,13 @@ def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
def _unit_price(nodes: list[_Node]) -> str:
_require_profile(nodes, _PanelProfile.TARGETS_SELECTED)
spec = _spec_for(_PanelProfile.TARGETS_SELECTED)
spec = _classify_panel(nodes)
if spec.profile is not _PanelProfile.TARGETS_SELECTED:
raise SkuSelectionError("规格面板动作后状态与已取证 profile 不一致,已停止操作。")
current_bounds = spec.price_layout.current_bounds
candidates = [
node for node in nodes
if _exact_readonly_text(node, _ROLLED_CURRENT_PRICE, spec.current_bounds)
if _exact_readonly_text(node, _ROLLED_CURRENT_PRICE, current_bounds)
]
current = _one(candidates, "规格面板现价不唯一,已停止读取。")
surface = _one([node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)], "规格面板内容面不唯一。")
@@ -521,16 +591,16 @@ def _unit_price(nodes: list[_Node]) -> str:
def _target_color_action(nodes: list[_Node], *, selected: bool) -> _Node:
profile = _classify_panel(nodes)
spec = _classify_panel(nodes)
profile = spec.profile
expected_profile = _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN if selected else _PanelProfile.PANEL_OPEN_EMPTY
if profile is not expected_profile:
raise SkuSelectionError("目标颜色 action 不属于预期 profile。")
spec = _spec_for(profile)
return _one([node for node in nodes if _exact_color_action(node, spec.color_bounds, selected)], "目标颜色 action 不唯一。")
def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node:
profile = _classify_panel(nodes)
profile = _classify_panel(nodes).profile
expected_profile = _PanelProfile.TARGETS_SELECTED if selected else _PanelProfile.SIZE_VISIBLE_NON_TARGET
if profile is not expected_profile:
raise SkuSelectionError("目标尺码 action 不属于预期 profile。")
@@ -541,7 +611,102 @@ def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node:
def _spec_for(profile: _PanelProfile) -> _PanelSpec:
return next(spec for spec in _PANEL_SPECS if spec.profile is profile)
matches = [spec for spec in _PANEL_SPECS if spec.profile is profile]
if len(matches) != 1:
raise SkuSelectionError("该 profile 没有唯一静态 spec。")
return matches[0]
def _match_price_layout(
nodes: list[_Node],
header: _Node,
layout: _PriceLayout,
) -> tuple[_Node, _Node, bool]:
if isinstance(layout, _DualPriceLayout):
price_row = _one(
[
node
for node in nodes
if _descendant(node, header)
and _exact_inert(node, "android.widget.LinearLayout", layout.row_bounds)
],
"规格面板双价格行不唯一。",
)
if len([child for child in price_row.element if child.tag == "node"]) != 2:
raise SkuSelectionError("规格面板双价格行子节点数量漂移。")
current = _one(
[
node
for node in nodes
if node.parent is price_row
and _exact_readonly_text(node, layout.current_text, layout.current_bounds)
],
"规格面板双价格当前价角色不唯一。",
)
_one(
[
node
for node in nodes
if node.parent is price_row
and _exact_readonly_text(node, layout.original_text, layout.original_bounds)
],
"规格面板双价格原价角色不唯一。",
)
# 历史已批准 raw 的双价格行和摘要位于 header 内的嵌套内容容器,
# 旧证据只允许后代关系,不能套用 current-only 的直接父链。
return current, header, False
frame = _one(
[
node
for node in nodes
if _descendant(node, header)
and _exact_inert(node, "android.widget.FrameLayout", layout.frame_bounds)
],
"规格面板单价格外层不唯一。",
)
if len([child for child in frame.element if child.tag == "node"]) != 1:
raise SkuSelectionError("规格面板单价格外层子节点数量漂移。")
content = frame.parent
relative = content.parent if content is not None else None
content_frame = relative.parent if relative is not None else None
if (
content is None
or relative is None
or content_frame is None
or content_frame.parent is not header
or not _exact_inert(content, "android.view.ViewGroup", "[0,551][1080,940]")
or not _exact_inert(relative, "android.widget.RelativeLayout", "[0,551][1080,940]")
or not _exact_inert(content_frame, "android.widget.FrameLayout", "[0,551][1080,940]")
):
raise SkuSelectionError("规格面板单价格外层父链漂移。")
price_row = _one(
[
node
for node in nodes
if node.parent is frame
and _exact_inert(node, "android.widget.LinearLayout", layout.row_bounds)
],
"规格面板单价格行父子关系漂移。",
)
if len([child for child in price_row.element if child.tag == "node"]) != 1:
raise SkuSelectionError("规格面板单价格行子节点数量漂移。")
current = _one(
[
node
for node in nodes
if node.parent is price_row
and _exact_readonly_text(node, layout.current_text, layout.current_bounds)
],
"规格面板单价格当前价角色不唯一。",
)
if any(
_descendant(node, header)
and node.text == "券前¥29.88"
for node in nodes
):
raise SkuSelectionError("规格面板单价格变体出现原价角色。")
return current, content, True
def _exact_inert(node: _Node, class_name: str, bounds: str) -> bool: