feat(client): productionize proven sku reveal flow
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""T-103 尺码显示动作的一次性真机取证;不属于生产采购 Flow。"""
|
||||
"""T-103 尺码显示动作的一次性真机证据采集。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,32 +20,10 @@ from ..device.baseline import PDD_PACKAGE, _save_base64_screenshot, _sha256_file
|
||||
from .product_url import parse_product_url
|
||||
from .sku_selection import (
|
||||
EXPECTED_GOODS_ID,
|
||||
_COLOR_ONLY_SUMMARY,
|
||||
_PANEL_SURFACE,
|
||||
_PanelProfile,
|
||||
_REVEAL_NOT_PROVEN,
|
||||
_S_SIZE_UI,
|
||||
_TARGET_COLOR_UI,
|
||||
_TARGET_SIZE_UI,
|
||||
_action_bounds,
|
||||
_clickable_before,
|
||||
_descendant,
|
||||
_exact_color_action,
|
||||
_exact_inert,
|
||||
_exact_live_layout,
|
||||
_exact_readonly_text,
|
||||
_exact_recycler,
|
||||
_is_size_action_text,
|
||||
_one,
|
||||
_parse_nodes,
|
||||
_require_color_only_panel,
|
||||
_require_color_action_chain,
|
||||
_require_color_subtree,
|
||||
_require_exact_selected_set,
|
||||
_require_panel_chain,
|
||||
_require_size_wrapper,
|
||||
_require_size_action_chain,
|
||||
_spec_for,
|
||||
_require_safe_reveal_path,
|
||||
_revealed_unselected_projection,
|
||||
resolve_task_selection,
|
||||
SkuSelectionError,
|
||||
SkuSelectionFlow,
|
||||
@@ -63,9 +41,6 @@ from .sku_selection_runner import (
|
||||
|
||||
|
||||
_TARGET_URL = f"https://mobile.yangkeduo.com/goods.html?goods_id={EXPECTED_GOODS_ID}"
|
||||
_REVEAL_START = (360, 1900)
|
||||
_REVEAL_END = (360, 1300)
|
||||
_REVEAL_STEPS = 30
|
||||
_REVEAL_FAILURE_STAGES = frozenset(
|
||||
(
|
||||
"reveal_precondition",
|
||||
@@ -114,30 +89,6 @@ class SkuRevealSpikeResult:
|
||||
manifest_path: Path
|
||||
|
||||
|
||||
class _RevealEvidenceAdapter(UiautomatorSkuPanelAdapter):
|
||||
"""只为 spike 增加一个无参数、固定 profile 的一次性手势。"""
|
||||
|
||||
def __init__(self, device: Any, timeout_seconds: float) -> None:
|
||||
super().__init__(device, timeout_seconds)
|
||||
self._reveal_attempted = False
|
||||
|
||||
@property
|
||||
def reveal_attempted(self) -> bool:
|
||||
return self._reveal_attempted
|
||||
|
||||
def reveal_size_options_once(self) -> None:
|
||||
if self._reveal_attempted:
|
||||
raise SkuRevealSpikeError("规格显示动作已经尝试过,拒绝重试。")
|
||||
# RPC 超时也可能表示手势已经送达,必须在调用前封存唯一机会。
|
||||
self._reveal_attempted = True
|
||||
self._call(
|
||||
"jsonrpc_call",
|
||||
"swipe",
|
||||
[*_REVEAL_START, *_REVEAL_END, _REVEAL_STEPS],
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
class SkuRevealSpikeCapturer:
|
||||
"""打开已取证面板、选一次目标颜色,再采集唯一 reveal 的前后证据。"""
|
||||
|
||||
@@ -165,7 +116,7 @@ class SkuRevealSpikeCapturer:
|
||||
) -> SkuRevealSpikeResult:
|
||||
stage = "reveal_precondition"
|
||||
staging: Path | None = None
|
||||
adapter: _RevealEvidenceAdapter | None = None
|
||||
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||
try:
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。")
|
||||
@@ -176,7 +127,7 @@ class SkuRevealSpikeCapturer:
|
||||
deadline = self._clock() + self._timeout_seconds
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = _RevealEvidenceAdapter(self._connector(serial), self._timeout_seconds)
|
||||
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuRevealSpikeError("设备不是已取证的竖屏坐标空间,已停止取证。")
|
||||
@@ -193,13 +144,10 @@ class SkuRevealSpikeCapturer:
|
||||
sleep_function=self._sleep,
|
||||
)
|
||||
flow.open_sku_panel(link.canonical_url, pre_intent)
|
||||
try:
|
||||
flow.select_sku_options(resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)"))
|
||||
except SkuSelectionError as error:
|
||||
if error.args != (_REVEAL_NOT_PROVEN,):
|
||||
raise
|
||||
else:
|
||||
raise SkuRevealSpikeError("规格流程未停在已取证的仅颜色状态。")
|
||||
# spike 只复用生产 Flow 的私有前置准备,不调用完整选择流程,避免重复 reveal/M。
|
||||
flow._prepare_reveal_precondition(
|
||||
resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)")
|
||||
)
|
||||
|
||||
before_hierarchy = adapter.dump_window_hierarchy()
|
||||
before_nodes = _parse_nodes(before_hierarchy)
|
||||
@@ -231,7 +179,7 @@ class SkuRevealSpikeCapturer:
|
||||
after_directory = staging / "after"
|
||||
_capture_frame(adapter, after_directory, after_hierarchy)
|
||||
reverified = adapter.dump_window_hierarchy()
|
||||
if _candidate_projection(_parse_nodes(reverified)) != projection:
|
||||
if _revealed_unselected_projection(_parse_nodes(reverified)) != projection:
|
||||
raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。")
|
||||
(after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8")
|
||||
|
||||
@@ -265,7 +213,7 @@ class SkuRevealSpikeCapturer:
|
||||
|
||||
def _wait_for_candidate(
|
||||
self,
|
||||
adapter: _RevealEvidenceAdapter,
|
||||
adapter: UiautomatorSkuPanelAdapter,
|
||||
deadline: float,
|
||||
) -> tuple[tuple[tuple[str, ...], ...], str]:
|
||||
stable: tuple[tuple[str, ...], ...] | None = None
|
||||
@@ -276,7 +224,7 @@ class SkuRevealSpikeCapturer:
|
||||
raise SkuRevealSpikeError("reveal 后拼多多不在前台,未发布证据。")
|
||||
hierarchy = adapter.dump_window_hierarchy()
|
||||
try:
|
||||
projection = _candidate_projection(_parse_nodes(hierarchy))
|
||||
projection = _revealed_unselected_projection(_parse_nodes(hierarchy))
|
||||
except SkuSelectionError:
|
||||
projection = None
|
||||
if projection is not None and projection == stable:
|
||||
@@ -288,315 +236,7 @@ class SkuRevealSpikeCapturer:
|
||||
self._sleep(min(0.2, remaining))
|
||||
|
||||
|
||||
def _require_safe_reveal_path(nodes: list[Any]) -> None:
|
||||
# 当前真机证据证明 x=360 是两列规格卡之间的空隙;必须验证完整线段,离散采样会漏掉窄浮层。
|
||||
surface = _one(
|
||||
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
|
||||
"固定 reveal 通道无法绑定面板内容面。",
|
||||
)
|
||||
_require_panel_chain(surface)
|
||||
content = surface.parent
|
||||
action_root = content.parent if content is not None else None
|
||||
action_parent = action_root.parent if action_root is not None else None
|
||||
if (
|
||||
content is None
|
||||
or action_root is None
|
||||
or action_parent is None
|
||||
or not _exact_live_layout(action_root, "android.view.ViewGroup", "[0,366][1080,2328]")
|
||||
or not _exact_live_layout(action_parent, "android.widget.LinearLayout", "[0,120][1080,2328]")
|
||||
):
|
||||
raise SkuRevealSpikeError("固定 reveal 通道祖先身份漂移,未执行手势。")
|
||||
allowed = {id(action_root.element), id(action_parent.element)}
|
||||
occupants: set[int] = set()
|
||||
for node in nodes:
|
||||
if node.element.get("clickable") != "true":
|
||||
continue
|
||||
try:
|
||||
left, top, right, bottom = _action_bounds(node.bounds)
|
||||
except SkuSelectionError as error:
|
||||
raise SkuRevealSpikeError("可点击节点坐标不可验证,未执行手势。") from error
|
||||
if (
|
||||
left <= _REVEAL_START[0] < right
|
||||
and top <= _REVEAL_START[1]
|
||||
and bottom > _REVEAL_END[1]
|
||||
):
|
||||
occupants.add(id(node.element))
|
||||
if occupants != allowed:
|
||||
raise SkuRevealSpikeError("固定 reveal 通道被未取证可点击节点占用,未执行手势。")
|
||||
if _REVEAL_START[1] >= 2079 or _REVEAL_END[1] >= 2079:
|
||||
raise SkuRevealSpikeError("固定 reveal 通道越过规格内容区,未执行手势。")
|
||||
|
||||
|
||||
def _candidate_projection(nodes: list[Any]) -> tuple[tuple[str, ...], ...]:
|
||||
surface = _one(
|
||||
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
|
||||
"候选面板内容面不唯一。",
|
||||
)
|
||||
_require_panel_chain(surface)
|
||||
header = _one(
|
||||
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", "[0,366][1080,1000]")],
|
||||
"候选面板头部不唯一。",
|
||||
)
|
||||
if len([child for child in header.element if child.tag == "node"]) != 4:
|
||||
raise SkuSelectionError("候选面板头部子节点数量漂移。")
|
||||
outer = _one(
|
||||
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
|
||||
"候选维度容器不唯一。",
|
||||
)
|
||||
price_content, price_frame = _candidate_price_container(nodes, header)
|
||||
price_nodes = _candidate_price_nodes(nodes, header, surface, price_frame)
|
||||
summary = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is price_content
|
||||
and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]")
|
||||
and len(node.element) == 0
|
||||
],
|
||||
"候选摘要不唯一。",
|
||||
)
|
||||
color_region = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_recycler(node, "[36,1000][1080,1483]")],
|
||||
"候选颜色容器不唯一。",
|
||||
)
|
||||
color = _one(
|
||||
[node for node in nodes if node.parent is color_region and _exact_color_action(node, "[372,1000][684,1024]", True)],
|
||||
"候选目标颜色不唯一。",
|
||||
)
|
||||
rolled_spec = _spec_for(_PanelProfile.TARGETS_SELECTED)
|
||||
selected_color_nodes = _require_color_subtree(color, rolled_spec)
|
||||
_require_color_action_chain(color, color_region, outer, surface, rolled_spec)
|
||||
size_label = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_readonly_text(node, "尺码", "[36,1506][114,1552]")],
|
||||
"候选尺码标题不唯一。",
|
||||
)
|
||||
size_header = size_label.parent
|
||||
if size_header is None or not _exact_live_layout(size_header, "android.widget.LinearLayout", "[36,1489][1044,1570]"):
|
||||
raise SkuSelectionError("候选尺码标题父结构漂移。")
|
||||
size_options = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_inert(node, "android.view.ViewGroup", "[36,1582][1044,1897]")],
|
||||
"候选尺码 options 根不唯一。",
|
||||
)
|
||||
actions = [node for node in nodes if _descendant(node, size_options) and _is_size_action_text(node)]
|
||||
s_action = _one(
|
||||
[node for node in actions if node.text == _S_SIZE_UI and node.bounds == "[36,1582][409,1667]"],
|
||||
"候选 S action 不唯一。",
|
||||
)
|
||||
m_action = _one(
|
||||
[node for node in actions if node.text == _TARGET_SIZE_UI and node.bounds == "[439,1582][831,1667]"],
|
||||
"候选 M action 不唯一。",
|
||||
)
|
||||
_require_size_wrapper(s_action, size_options)
|
||||
_require_size_wrapper(m_action, size_options)
|
||||
_require_size_action_chain(s_action, size_options, outer, surface)
|
||||
_require_size_action_chain(m_action, size_options, outer, surface)
|
||||
if any(node.element.get("selected") == "true" for node in actions):
|
||||
raise SkuSelectionError("reveal 候选态已有尺码被选中。")
|
||||
_require_exact_selected_set(nodes, surface, selected_color_nodes)
|
||||
dangerous = [
|
||||
node for node in nodes
|
||||
if "提交订单" in node.text
|
||||
and node.element.get("package") == PDD_PACKAGE
|
||||
and node.element.get("class") == "android.widget.TextView"
|
||||
]
|
||||
if len(dangerous) != 1 or _action_bounds(dangerous[0].bounds)[1] < 2079:
|
||||
raise SkuSelectionError("提交硬拒绝区位置不唯一。")
|
||||
return tuple(
|
||||
(
|
||||
node.element.get("class", ""),
|
||||
node.bounds,
|
||||
node.text,
|
||||
node.desc,
|
||||
node.element.get("selected", ""),
|
||||
node.element.get("clickable", ""),
|
||||
)
|
||||
for node in (
|
||||
surface,
|
||||
header,
|
||||
outer,
|
||||
*price_nodes,
|
||||
summary,
|
||||
color_region,
|
||||
color,
|
||||
size_label,
|
||||
size_options,
|
||||
s_action,
|
||||
m_action,
|
||||
dangerous[0],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _candidate_price_nodes(
|
||||
nodes: list[Any],
|
||||
header: Any,
|
||||
surface: Any,
|
||||
price_frame: Any,
|
||||
) -> tuple[Any, Any, Any]:
|
||||
matches: list[tuple[Any, Any, Any]] = []
|
||||
for matcher in (_candidate_dual_price_nodes, _candidate_current_only_price_nodes):
|
||||
try:
|
||||
match = matcher(nodes, header, surface, price_frame)
|
||||
except SkuSelectionError:
|
||||
continue
|
||||
matches.append(match)
|
||||
if len(matches) != 1:
|
||||
raise SkuSelectionError("候选价格布局不符合唯一完整取证变体。")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _candidate_dual_price_nodes(
|
||||
nodes: list[Any],
|
||||
header: Any,
|
||||
surface: Any,
|
||||
price_frame: Any,
|
||||
) -> tuple[Any, Any, Any]:
|
||||
price_row = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is price_frame
|
||||
and _exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")
|
||||
],
|
||||
"候选双价格行不唯一。",
|
||||
)
|
||||
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, "快卖完 ¥12.88", "[396,503][712,570]")
|
||||
and len(node.element) == 0
|
||||
],
|
||||
"候选双价格当前价不唯一。",
|
||||
)
|
||||
original = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is price_row
|
||||
and _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")
|
||||
and len(node.element) == 0
|
||||
],
|
||||
"候选双价格原价不唯一。",
|
||||
)
|
||||
if _clickable_before(current, surface):
|
||||
raise SkuSelectionError("候选双价格当前价位于可点击内容祖先下。")
|
||||
if any(
|
||||
_candidate_current_only_fragment(node)
|
||||
for node in nodes
|
||||
if _descendant(node, header)
|
||||
):
|
||||
raise SkuSelectionError("候选双价格布局混入单价格残片。")
|
||||
return price_row, current, original
|
||||
|
||||
|
||||
def _candidate_current_only_price_nodes(
|
||||
nodes: list[Any],
|
||||
header: Any,
|
||||
surface: Any,
|
||||
price_frame: Any,
|
||||
) -> tuple[Any, Any, Any]:
|
||||
price_row = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is price_frame
|
||||
and _exact_inert(node, "android.widget.LinearLayout", "[396,498][693,570]")
|
||||
],
|
||||
"候选单价格行父子关系漂移。",
|
||||
)
|
||||
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, "限1件 ¥12.88 ", "[396,503][675,570]")
|
||||
and len(node.element) == 0
|
||||
],
|
||||
"候选单价格当前价不唯一。",
|
||||
)
|
||||
if any(node.text in {"¥29.88", "券前¥29.88"} for node in nodes):
|
||||
raise SkuSelectionError("候选单价格变体出现原价角色。")
|
||||
if any(
|
||||
_candidate_dual_fragment(node)
|
||||
for node in nodes
|
||||
if _descendant(node, header)
|
||||
):
|
||||
raise SkuSelectionError("候选单价格布局混入双价格残片。")
|
||||
if _clickable_before(current, surface):
|
||||
raise SkuSelectionError("候选单价格当前价位于可点击内容祖先下。")
|
||||
return price_frame, price_row, current
|
||||
|
||||
|
||||
def _candidate_price_container(nodes: list[Any], header: Any) -> tuple[Any, Any]:
|
||||
content_frame = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is header
|
||||
and _exact_inert(node, "android.widget.FrameLayout", "[0,474][1080,863]")
|
||||
],
|
||||
"候选价格内容外框不唯一。",
|
||||
)
|
||||
if len([child for child in content_frame.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("候选价格内容外框子节点数量漂移。")
|
||||
relative = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is content_frame
|
||||
and _exact_inert(node, "android.widget.RelativeLayout", "[0,474][1080,863]")
|
||||
],
|
||||
"候选价格 RelativeLayout 父子关系漂移。",
|
||||
)
|
||||
if len([child for child in relative.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("候选价格 RelativeLayout 子节点数量漂移。")
|
||||
content = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is relative
|
||||
and _exact_inert(node, "android.view.ViewGroup", "[0,474][1080,863]")
|
||||
],
|
||||
"候选价格内容 ViewGroup 父子关系漂移。",
|
||||
)
|
||||
if len([child for child in content.element if child.tag == "node"]) != 8:
|
||||
raise SkuSelectionError("候选价格内容 ViewGroup 子节点数量漂移。")
|
||||
price_frame = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is content
|
||||
and _exact_inert(node, "android.widget.FrameLayout", "[396,498][1053,570]")
|
||||
],
|
||||
"候选价格 FrameLayout 父子关系漂移。",
|
||||
)
|
||||
if len([child for child in price_frame.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("候选价格 FrameLayout 子节点数量漂移。")
|
||||
return content, price_frame
|
||||
|
||||
|
||||
def _candidate_current_only_fragment(node: Any) -> bool:
|
||||
return (
|
||||
_exact_inert(node, "android.widget.LinearLayout", "[396,498][693,570]")
|
||||
or _exact_readonly_text(node, "限1件 ¥12.88 ", "[396,503][675,570]")
|
||||
)
|
||||
|
||||
|
||||
def _candidate_dual_fragment(node: Any) -> bool:
|
||||
return (
|
||||
_exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")
|
||||
or _exact_readonly_text(node, "快卖完 ¥12.88", "[396,503][712,570]")
|
||||
or _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")
|
||||
)
|
||||
|
||||
|
||||
def _capture_frame(adapter: _RevealEvidenceAdapter, directory: Path, hierarchy: str) -> None:
|
||||
def _capture_frame(adapter: UiautomatorSkuPanelAdapter, directory: Path, hierarchy: str) -> None:
|
||||
directory.mkdir()
|
||||
hierarchy_path = directory / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
@@ -18,6 +18,8 @@ EXPECTED_UNIT_PRICE = "12.88"
|
||||
# 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。
|
||||
TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")}
|
||||
_TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values()))
|
||||
_TARGET_COLOR_UNROLLED_BOUNDS = "[372,1188][684,1587]"
|
||||
_TARGET_SIZE_BOUNDS = "[439,1582][831,1667]"
|
||||
_ENTRY = "快要抢光 ¥ 12.88"
|
||||
_ENTRY_PROMOTION_LABEL = "快要抢光"
|
||||
_ENTRY_TEXT_BOUNDS = "[688,2184][1042,2253]"
|
||||
@@ -37,10 +39,11 @@ _COLOR_ONLY_SUMMARY = "请选择: 尺码"
|
||||
_S_SIZE_UI = "S(建议80-100)"
|
||||
_S_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_S_SIZE_UI}"
|
||||
_TARGET_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_TARGET_SIZE_UI}"
|
||||
_REVEAL_NOT_PROVEN = "尺码仍在已取证视口外;受控显示动作尚未取证,已停止后续点击。"
|
||||
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||
_ROLLED_CURRENT_PRICE = "快卖完 ¥12.88"
|
||||
_ROLLED_ORIGINAL_PRICE = "¥29.88"
|
||||
_REVEALED_CURRENT_PRICE = "限1件 ¥12.88 "
|
||||
_REVEAL_GESTURE = (360, 1900, 360, 1300, 30)
|
||||
_BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估")
|
||||
|
||||
|
||||
@@ -91,6 +94,7 @@ class SkuPanelDevice(Protocol):
|
||||
def dump_window_hierarchy(self) -> str: ...
|
||||
def tap_sku_entry(self, bounds: str) -> None: ...
|
||||
def tap_sku_option(self, bounds: str) -> None: ...
|
||||
def reveal_size_options_once(self) -> None: ...
|
||||
def leave_sku_panel(self) -> None: ...
|
||||
|
||||
|
||||
@@ -122,6 +126,7 @@ class _Node:
|
||||
class _PanelProfile(Enum):
|
||||
PANEL_OPEN_EMPTY = "panel_open_empty"
|
||||
COLOR_SELECTED_SIZE_HIDDEN = "color_selected_size_hidden"
|
||||
SIZE_VISIBLE_UNSELECTED = "size_visible_unselected"
|
||||
SIZE_VISIBLE_NON_TARGET = "size_visible_non_target"
|
||||
TARGETS_SELECTED = "targets_selected"
|
||||
|
||||
@@ -172,7 +177,7 @@ _PANEL_SPECS = (
|
||||
"券前¥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,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, False,
|
||||
"[36,2069][114,2079]", None,
|
||||
),
|
||||
_PanelSpec(
|
||||
@@ -183,7 +188,7 @@ _PANEL_SPECS = (
|
||||
"券前¥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,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, True,
|
||||
"[36,2069][114,2079]", None,
|
||||
),
|
||||
_PanelSpec(
|
||||
@@ -194,7 +199,7 @@ _PANEL_SPECS = (
|
||||
"限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,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, False,
|
||||
"[36,2069][114,2079]", None,
|
||||
),
|
||||
_PanelSpec(
|
||||
@@ -205,7 +210,7 @@ _PANEL_SPECS = (
|
||||
"限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,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, True,
|
||||
"[36,2069][114,2079]", None,
|
||||
),
|
||||
_PanelSpec(
|
||||
@@ -233,6 +238,19 @@ _PANEL_SPECS = (
|
||||
)
|
||||
|
||||
|
||||
_REVEALED_UNSELECTED_SPEC = _PanelSpec(
|
||||
_PanelProfile.SIZE_VISIBLE_UNSELECTED,
|
||||
"[0,366][1080,1000]", "[0,1000][1080,2079]",
|
||||
_CurrentOnlyPriceLayout(
|
||||
"[396,498][1053,570]", "[396,498][693,570]",
|
||||
_REVEALED_CURRENT_PRICE, "[396,503][675,570]",
|
||||
),
|
||||
_COLOR_ONLY_SUMMARY, "[396,654][1053,716]", None,
|
||||
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
|
||||
"[36,1506][114,1552]", None,
|
||||
)
|
||||
|
||||
|
||||
class SkuSelectionFlow:
|
||||
def __init__(self, device: SkuPanelDevice, entry_wait_timeout_seconds: float = 0.2,
|
||||
entry_poll_interval_seconds: float = 0.2, monotonic_clock: Callable[[], float] = monotonic,
|
||||
@@ -241,9 +259,12 @@ class SkuSelectionFlow:
|
||||
raise ValueError("入口等待参数无效。")
|
||||
self._device, self._entry_timeout, self._poll = device, entry_wait_timeout_seconds, entry_poll_interval_seconds
|
||||
self._clock, self._sleep = monotonic_clock, sleep_function
|
||||
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None
|
||||
self._pending: tuple[str, Callable[[list[_Node]], Any], bool] | None = None
|
||||
self._terminal = False
|
||||
self._color_selected_by_flow = False
|
||||
|
||||
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
|
||||
self._require_active()
|
||||
try:
|
||||
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
|
||||
@@ -265,90 +286,175 @@ class SkuSelectionFlow:
|
||||
raise
|
||||
|
||||
try:
|
||||
self._pending = (before, _require_empty_panel)
|
||||
self._pending = (before, _require_empty_panel, False)
|
||||
self._device.tap_sku_entry(entry.bounds)
|
||||
except BaseException as error:
|
||||
self._terminal = True
|
||||
_annotate_sku_entry_failure(error, "sku_entry_click")
|
||||
raise
|
||||
|
||||
try:
|
||||
self._wait_after_action(before, _require_empty_panel)
|
||||
except BaseException as error:
|
||||
self._terminal = True
|
||||
_annotate_sku_entry_failure(error, "sku_entry_panel_verify")
|
||||
raise
|
||||
|
||||
def select_sku_options(self, selection: SkuSelection) -> None:
|
||||
self._require_active()
|
||||
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
|
||||
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
|
||||
nodes = self._verified_nodes()
|
||||
initial_profile = _classify_panel(nodes).profile
|
||||
if initial_profile is _PanelProfile.TARGETS_SELECTED:
|
||||
return
|
||||
if initial_profile is _PanelProfile.SIZE_VISIBLE_NON_TARGET:
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _parse_nodes(before)
|
||||
target = _target_size_action(nodes, selected=False)
|
||||
_action_bounds(target.bounds)
|
||||
_require_action_occupants(nodes, target)
|
||||
self._perform_mutation(
|
||||
before,
|
||||
_require_target_panel,
|
||||
partial(self._device.tap_sku_option, target.bounds),
|
||||
)
|
||||
return
|
||||
|
||||
self._prepare_reveal_precondition(selection)
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _parse_nodes(before)
|
||||
spec = _require_profile(nodes, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN)
|
||||
if not isinstance(spec.price_layout, _CurrentOnlyPriceLayout):
|
||||
# 真机动作证据只覆盖 current-only;dual 仅颜色态必须在 reveal 前零动作停止。
|
||||
raise SkuSelectionError("规格显示前置不是已取证单价格布局,已停止操作。")
|
||||
_require_safe_reveal_path(nodes)
|
||||
self._perform_mutation(
|
||||
before,
|
||||
_revealed_unselected_projection,
|
||||
self._device.reveal_size_options_once,
|
||||
require_stable=True,
|
||||
)
|
||||
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _parse_nodes(before)
|
||||
_revealed_unselected_projection(nodes)
|
||||
target = _target_size_action(nodes, selected=False)
|
||||
_action_bounds(target.bounds)
|
||||
_require_action_occupants(nodes, target)
|
||||
self._perform_mutation(
|
||||
before,
|
||||
_require_target_panel,
|
||||
partial(self._device.tap_sku_option, target.bounds),
|
||||
)
|
||||
|
||||
def _prepare_reveal_precondition(self, selection: SkuSelection) -> None:
|
||||
"""只把 current-only 初态推进到仅颜色态,供生产 Flow 与证据 spike 共用。"""
|
||||
|
||||
self._require_active()
|
||||
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
|
||||
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _parse_nodes(before)
|
||||
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)
|
||||
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, color_postcondition)
|
||||
# 当前证据只证明颜色选择;尺码仍在视口外。没有动作证据时必须在此停住,
|
||||
# 不能把一次通用 swipe 或下一次规格点击伪装成已验证流程。
|
||||
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
|
||||
if profile is _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN:
|
||||
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
|
||||
if profile is _PanelProfile.SIZE_VISIBLE_NON_TARGET:
|
||||
target = _target_size_action(nodes, selected=False)
|
||||
_action_bounds(target.bounds)
|
||||
_require_action_occupants(nodes, target)
|
||||
self._pending = (before, _require_target_panel)
|
||||
self._device.tap_sku_option(target.bounds)
|
||||
self._wait_after_action(before, _require_target_panel)
|
||||
if spec.profile is _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN:
|
||||
if not isinstance(spec.price_layout, _CurrentOnlyPriceLayout):
|
||||
raise SkuSelectionError("规格显示前置不是已取证单价格布局,已停止操作。")
|
||||
if not self._color_selected_by_flow:
|
||||
raise SkuSelectionError("目标颜色不是本次 Flow 精确选中,未执行 reveal。")
|
||||
return
|
||||
if profile is _PanelProfile.TARGETS_SELECTED:
|
||||
return
|
||||
raise SkuSelectionError("规格面板状态不属于已取证 profile,已停止操作。")
|
||||
if spec.profile is not _PanelProfile.PANEL_OPEN_EMPTY or not isinstance(
|
||||
spec.price_layout, _CurrentOnlyPriceLayout
|
||||
):
|
||||
raise SkuSelectionError("规格面板不属于获准的单价格初态,已停止操作。")
|
||||
target = _target_color_action(nodes, selected=False)
|
||||
_action_bounds(target.bounds)
|
||||
_require_action_occupants(nodes, target)
|
||||
color_postcondition = partial(
|
||||
_require_profile_with_price_layout,
|
||||
expected=_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
|
||||
expected_price_layout=spec.price_layout,
|
||||
)
|
||||
self._perform_mutation(
|
||||
before,
|
||||
color_postcondition,
|
||||
partial(self._device.tap_sku_option, target.bounds),
|
||||
)
|
||||
self._color_selected_by_flow = True
|
||||
|
||||
def read_sku_unit_price(self) -> str:
|
||||
self._require_active()
|
||||
return _unit_price(self._verified_nodes())
|
||||
|
||||
def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str:
|
||||
self._require_active()
|
||||
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
|
||||
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止读取。")
|
||||
nodes = self._verified_nodes()
|
||||
return _unit_price(nodes)
|
||||
|
||||
def exit_sku_panel_safely(self) -> None:
|
||||
self._require_active()
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
_classify_panel(_parse_nodes(before))
|
||||
self._device.leave_sku_panel()
|
||||
# 截图重证与 Back 之间仍可能漂移;只有目标双规格和已验单价同时保持时才允许返回。
|
||||
_unit_price(_parse_nodes(before))
|
||||
try:
|
||||
self._device.leave_sku_panel()
|
||||
except BaseException:
|
||||
self._terminal = True
|
||||
raise
|
||||
deadline = self._clock() + self._entry_timeout
|
||||
while True:
|
||||
self._require_foreground()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != before:
|
||||
try:
|
||||
_classify_panel(_parse_nodes(raw))
|
||||
except SkuSelectionError:
|
||||
return
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
try:
|
||||
while True:
|
||||
self._require_foreground()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != before:
|
||||
try:
|
||||
_classify_panel(_parse_nodes(raw))
|
||||
except SkuSelectionError:
|
||||
self._terminal = True
|
||||
return
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
except BaseException:
|
||||
self._terminal = True
|
||||
raise
|
||||
|
||||
def reconcile_pending_action(self) -> None:
|
||||
"""仅只读调和一次已发出但尚未得到后置条件确认的动作。"""
|
||||
if self._pending is None:
|
||||
return
|
||||
before, condition = self._pending
|
||||
self._wait_after_action(before, condition)
|
||||
before, condition, require_stable = self._pending
|
||||
self._wait_after_action(before, condition, require_stable=require_stable)
|
||||
|
||||
def _perform_mutation(
|
||||
self,
|
||||
before: str,
|
||||
condition: Callable[[list[_Node]], Any],
|
||||
mutation: Callable[[], None],
|
||||
*,
|
||||
require_stable: bool = False,
|
||||
) -> list[_Node]:
|
||||
# 后置条件连同唯一动作机会必须在 RPC 前封存;超时可能表示已送达,不能重发。
|
||||
self._pending = (before, condition, require_stable)
|
||||
try:
|
||||
mutation()
|
||||
return self._wait_after_action(before, condition, require_stable=require_stable)
|
||||
except BaseException:
|
||||
# 结果不明时只允许 reconcile_pending_action 读回;即使调和成功,本 Flow 也不再继续。
|
||||
self._terminal = True
|
||||
raise
|
||||
|
||||
def _require_active(self) -> None:
|
||||
if self._terminal:
|
||||
raise SkuSelectionError("规格流程已进入不可重入终止态,已停止操作。")
|
||||
|
||||
def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]:
|
||||
deadline, stable = self._clock() + self._entry_timeout, None
|
||||
@@ -380,19 +486,31 @@ class SkuSelectionFlow:
|
||||
raise SkuSelectionError("等待已取证规格入口超时,未执行点击。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
|
||||
def _wait_after_action(self, previous: str, condition: Callable[[list[_Node]], Any]) -> list[_Node]:
|
||||
def _wait_after_action(
|
||||
self,
|
||||
previous: str,
|
||||
condition: Callable[[list[_Node]], Any],
|
||||
*,
|
||||
require_stable: bool = False,
|
||||
) -> list[_Node]:
|
||||
deadline = self._clock() + self._entry_timeout
|
||||
stable: Any = None
|
||||
has_stable = False
|
||||
while True:
|
||||
self._require_foreground()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != previous:
|
||||
nodes = _parse_nodes(raw)
|
||||
try:
|
||||
condition(nodes)
|
||||
self._pending = None
|
||||
return nodes
|
||||
projection = condition(nodes)
|
||||
if not require_stable or (has_stable and projection == stable):
|
||||
self._pending = None
|
||||
return nodes
|
||||
stable = projection
|
||||
has_stable = True
|
||||
except SkuSelectionError:
|
||||
pass
|
||||
stable = None
|
||||
has_stable = False
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。")
|
||||
@@ -445,6 +563,12 @@ def _classify_panel(nodes: list[_Node]) -> _PanelSpec:
|
||||
except SkuSelectionError:
|
||||
continue
|
||||
matches.append(spec)
|
||||
try:
|
||||
_revealed_unselected_projection(nodes)
|
||||
except SkuSelectionError:
|
||||
pass
|
||||
else:
|
||||
matches.append(_REVEALED_UNSELECTED_SPEC)
|
||||
if len(matches) != 1:
|
||||
raise SkuSelectionError("规格面板不符合唯一完整取证 profile,已停止操作。")
|
||||
return matches[0]
|
||||
@@ -574,6 +698,177 @@ def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
|
||||
_require_exact_selected_set(nodes, surface, [*selected_nodes, expected_action])
|
||||
|
||||
|
||||
def _require_safe_reveal_path(nodes: list[_Node]) -> None:
|
||||
"""证明固定手势整条线段仍是已取证的两列卡片间安全通道。"""
|
||||
|
||||
surface = _one(
|
||||
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
|
||||
"固定 reveal 通道无法绑定面板内容面。",
|
||||
)
|
||||
_require_panel_chain(surface)
|
||||
content = surface.parent
|
||||
action_root = content.parent if content is not None else None
|
||||
action_parent = action_root.parent if action_root is not None else None
|
||||
if (
|
||||
content is None
|
||||
or action_root is None
|
||||
or action_parent is None
|
||||
or not _exact_live_layout(action_root, "android.view.ViewGroup", "[0,366][1080,2328]")
|
||||
or not _exact_live_layout(action_parent, "android.widget.LinearLayout", "[0,120][1080,2328]")
|
||||
):
|
||||
raise SkuSelectionError("固定 reveal 通道祖先身份漂移,未执行手势。")
|
||||
start_x, start_y, end_x, end_y, _steps = _REVEAL_GESTURE
|
||||
allowed = {id(action_root.element), id(action_parent.element)}
|
||||
occupants: set[int] = set()
|
||||
for node in nodes:
|
||||
if node.element.get("clickable") != "true":
|
||||
continue
|
||||
try:
|
||||
left, top, right, bottom = _action_bounds(node.bounds)
|
||||
except SkuSelectionError as error:
|
||||
raise SkuSelectionError("可点击节点坐标不可验证,未执行手势。") from error
|
||||
if (
|
||||
left <= start_x < right
|
||||
and top <= start_y
|
||||
and bottom > end_y
|
||||
):
|
||||
occupants.add(id(node.element))
|
||||
if occupants != allowed:
|
||||
raise SkuSelectionError("固定 reveal 通道被未取证可点击节点占用,未执行手势。")
|
||||
if start_y >= 2079 or end_y >= 2079:
|
||||
raise SkuSelectionError("固定 reveal 通道越过规格内容区,未执行手势。")
|
||||
|
||||
|
||||
def _revealed_unselected_projection(nodes: list[_Node]) -> tuple[tuple[str, ...], ...]:
|
||||
"""唯一生产判据:current-only reveal 后,目标颜色已选且 S/M 均未选。"""
|
||||
|
||||
surface = _one(
|
||||
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
|
||||
"reveal 后面板内容面不唯一。",
|
||||
)
|
||||
_require_panel_chain(surface)
|
||||
header = _one(
|
||||
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", "[0,366][1080,1000]")],
|
||||
"reveal 后面板头部不唯一。",
|
||||
)
|
||||
if len([child for child in header.element if child.tag == "node"]) != 4:
|
||||
raise SkuSelectionError("reveal 后面板头部子节点数量漂移。")
|
||||
outer = _one(
|
||||
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
|
||||
"reveal 后维度容器不唯一。",
|
||||
)
|
||||
content_frame = _one(
|
||||
[node for node in nodes if node.parent is header and _exact_inert(node, "android.widget.FrameLayout", "[0,474][1080,863]")],
|
||||
"reveal 后价格内容外框不唯一。",
|
||||
)
|
||||
if len([child for child in content_frame.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("reveal 后价格内容外框子节点数量漂移。")
|
||||
relative = _one(
|
||||
[node for node in nodes if node.parent is content_frame and _exact_inert(node, "android.widget.RelativeLayout", "[0,474][1080,863]")],
|
||||
"reveal 后价格 RelativeLayout 父子关系漂移。",
|
||||
)
|
||||
if len([child for child in relative.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("reveal 后价格 RelativeLayout 子节点数量漂移。")
|
||||
content = _one(
|
||||
[node for node in nodes if node.parent is relative and _exact_inert(node, "android.view.ViewGroup", "[0,474][1080,863]")],
|
||||
"reveal 后价格内容 ViewGroup 父子关系漂移。",
|
||||
)
|
||||
if len([child for child in content.element if child.tag == "node"]) != 8:
|
||||
raise SkuSelectionError("reveal 后价格内容 ViewGroup 子节点数量漂移。")
|
||||
price_frame = _one(
|
||||
[node for node in nodes if node.parent is content and _exact_inert(node, "android.widget.FrameLayout", "[396,498][1053,570]")],
|
||||
"reveal 后价格 FrameLayout 父子关系漂移。",
|
||||
)
|
||||
if len([child for child in price_frame.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("reveal 后价格 FrameLayout 子节点数量漂移。")
|
||||
price_row = _one(
|
||||
[node for node in nodes if node.parent is price_frame and _exact_inert(node, "android.widget.LinearLayout", "[396,498][693,570]")],
|
||||
"reveal 后单价格行父子关系漂移。",
|
||||
)
|
||||
if len([child for child in price_row.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("reveal 后单价格行子节点数量漂移。")
|
||||
current = _one(
|
||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, _REVEALED_CURRENT_PRICE, "[396,503][675,570]") and len(node.element) == 0],
|
||||
"reveal 后单价格当前价不唯一。",
|
||||
)
|
||||
if _clickable_before(current, surface):
|
||||
raise SkuSelectionError("reveal 后单价格当前价位于可点击内容祖先下。")
|
||||
if any(
|
||||
node.text in {_ROLLED_CURRENT_PRICE, _ROLLED_ORIGINAL_PRICE, "券前¥29.88"}
|
||||
or (node.bounds == "[396,498][895,570]" and node.element.get("class") == "android.widget.LinearLayout")
|
||||
for node in nodes
|
||||
):
|
||||
raise SkuSelectionError("reveal 后单价格布局混入双价格角色。")
|
||||
summary = _one(
|
||||
[node for node in nodes if node.parent is content and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]") and len(node.element) == 0],
|
||||
"reveal 后摘要不唯一。",
|
||||
)
|
||||
color_region = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_recycler(node, "[36,1000][1080,1483]")],
|
||||
"reveal 后颜色容器不唯一。",
|
||||
)
|
||||
color = _one(
|
||||
[node for node in nodes if node.parent is color_region and _exact_color_action(node, "[372,1000][684,1024]", True)],
|
||||
"reveal 后目标颜色不唯一。",
|
||||
)
|
||||
selected_color_nodes = _require_color_subtree(color, _REVEALED_UNSELECTED_SPEC)
|
||||
_require_color_action_chain(color, color_region, outer, surface, _REVEALED_UNSELECTED_SPEC)
|
||||
size_label = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_readonly_text(node, _SIZE, "[36,1506][114,1552]")],
|
||||
"reveal 后尺码标题不唯一。",
|
||||
)
|
||||
size_header = size_label.parent
|
||||
if size_header is None or not _exact_live_layout(size_header, "android.widget.LinearLayout", "[36,1489][1044,1570]"):
|
||||
raise SkuSelectionError("reveal 后尺码标题父结构漂移。")
|
||||
size_options = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_inert(node, "android.view.ViewGroup", "[36,1582][1044,1897]")],
|
||||
"reveal 后尺码 options 根不唯一。",
|
||||
)
|
||||
actions = [node for node in nodes if _descendant(node, size_options) and _is_size_action_text(node)]
|
||||
s_action = _one(
|
||||
[node for node in actions if node.text == _S_SIZE_UI and node.bounds == "[36,1582][409,1667]" and node.element.get("selected") == "false"],
|
||||
"reveal 后 S action 不唯一。",
|
||||
)
|
||||
m_action = _one(
|
||||
[node for node in actions if node.text == _TARGET_SIZE_UI and node.bounds == _TARGET_SIZE_BOUNDS and node.element.get("selected") == "false"],
|
||||
"reveal 后 M action 不唯一。",
|
||||
)
|
||||
_require_size_wrapper(s_action, size_options)
|
||||
_require_size_wrapper(m_action, size_options)
|
||||
_require_size_action_chain(s_action, size_options, outer, surface)
|
||||
_require_size_action_chain(m_action, size_options, outer, surface)
|
||||
if any(node.element.get("selected") == "true" for node in actions):
|
||||
raise SkuSelectionError("reveal 后已有尺码被选中。")
|
||||
_require_exact_selected_set(nodes, surface, selected_color_nodes)
|
||||
dangerous = [
|
||||
node
|
||||
for node in nodes
|
||||
if node.element.get("package") == PDD_PACKAGE
|
||||
and node.element.get("class") == "android.widget.TextView"
|
||||
and "提交订单" in node.text
|
||||
]
|
||||
danger = _one(dangerous, "提交硬拒绝区位置不唯一。")
|
||||
if (
|
||||
not _exact_readonly_text(danger, "选择尺码后,提交订单", "[285,2225][795,2284]")
|
||||
or len(danger.element) != 0
|
||||
):
|
||||
raise SkuSelectionError("提交硬拒绝区结构漂移。")
|
||||
return tuple(
|
||||
(
|
||||
node.element.get("class", ""),
|
||||
node.bounds,
|
||||
node.text,
|
||||
node.desc,
|
||||
node.element.get("selected", ""),
|
||||
node.element.get("clickable", ""),
|
||||
)
|
||||
for node in (
|
||||
surface, header, outer, price_frame, price_row, current, summary,
|
||||
color_region, color, size_label, size_options, s_action, m_action, danger,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _unit_price(nodes: list[_Node]) -> str:
|
||||
spec = _classify_panel(nodes)
|
||||
if spec.profile is not _PanelProfile.TARGETS_SELECTED:
|
||||
@@ -601,11 +896,15 @@ def _target_color_action(nodes: list[_Node], *, selected: bool) -> _Node:
|
||||
|
||||
def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node:
|
||||
profile = _classify_panel(nodes).profile
|
||||
expected_profile = _PanelProfile.TARGETS_SELECTED if selected else _PanelProfile.SIZE_VISIBLE_NON_TARGET
|
||||
if profile is not expected_profile:
|
||||
expected_profiles = (
|
||||
{_PanelProfile.TARGETS_SELECTED}
|
||||
if selected
|
||||
else {_PanelProfile.SIZE_VISIBLE_UNSELECTED, _PanelProfile.SIZE_VISIBLE_NON_TARGET}
|
||||
)
|
||||
if profile not in expected_profiles:
|
||||
raise SkuSelectionError("目标尺码 action 不属于预期 profile。")
|
||||
return _one(
|
||||
[node for node in nodes if _is_size_action_text(node) and node.text == _TARGET_SIZE_UI and node.bounds == "[439,1582][831,1667]" and node.element.get("selected") == str(selected).lower()],
|
||||
[node for node in nodes if _is_size_action_text(node) and node.text == _TARGET_SIZE_UI and node.bounds == _TARGET_SIZE_BOUNDS and node.element.get("selected") == str(selected).lower()],
|
||||
"目标尺码 action 不唯一。",
|
||||
)
|
||||
|
||||
@@ -825,7 +1124,7 @@ def _require_color_subtree(color: _Node, spec: _PanelSpec) -> list[_Node]:
|
||||
selected = str(spec.color_selected).lower()
|
||||
children = [child for child in color.element if child.tag == "node"]
|
||||
expected_selected: list[_Node] = [color] if spec.color_selected else []
|
||||
if spec.color_bounds == "[372,1188][684,1587]":
|
||||
if spec.color_bounds == _TARGET_COLOR_UNROLLED_BOUNDS:
|
||||
if len(children) != 4:
|
||||
raise SkuSelectionError("目标颜色完整卡片子树数量漂移。")
|
||||
child_nodes = [node for node in _walk_direct_children(color)]
|
||||
@@ -869,7 +1168,7 @@ def _require_color_action_chain(
|
||||
) -> None:
|
||||
if color.parent is not color_region:
|
||||
raise SkuSelectionError("目标颜色 action 不属于已取证颜色容器。")
|
||||
if spec.color_bounds == "[372,1188][684,1587]":
|
||||
if spec.color_bounds == _TARGET_COLOR_UNROLLED_BOUNDS:
|
||||
expected = (
|
||||
("android.widget.FrameLayout", "[0,1188][1080,2046]"),
|
||||
("android.widget.LinearLayout", "[0,1188][1080,2052]"),
|
||||
|
||||
@@ -31,6 +31,10 @@ from .sku_selection import (
|
||||
SkuSelectionError,
|
||||
SkuSelectionFlow,
|
||||
_SKU_ENTRY_FAILURE_STAGES,
|
||||
_REVEAL_GESTURE,
|
||||
_ENTRY_TEXT_BOUNDS,
|
||||
_TARGET_COLOR_UNROLLED_BOUNDS,
|
||||
_TARGET_SIZE_BOUNDS,
|
||||
_action_bounds,
|
||||
_annotate_sku_entry_failure,
|
||||
_safe_sku_entry_failure_stage,
|
||||
@@ -135,9 +139,9 @@ class SkuSelectionRunResult:
|
||||
|
||||
|
||||
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。
|
||||
|
||||
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法;
|
||||
四个命名方法是仅有的状态改变入口;reveal 的手势参数固定且不向 Flow 暴露;
|
||||
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
|
||||
"""
|
||||
|
||||
@@ -147,7 +151,14 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
self._device = device
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._entry_was_tapped = False
|
||||
self._entry_bounds_attempted: str | None = None
|
||||
self._entry_rpc_outcome = "not_attempted"
|
||||
self._option_bounds_attempted: set[str] = set()
|
||||
self._option_rpc_outcomes: dict[str, str] = {}
|
||||
self._reveal_attempted = False
|
||||
self._reveal_rpc_outcome = "not_attempted"
|
||||
self._left_panel = False
|
||||
self._back_rpc_outcome = "not_attempted"
|
||||
|
||||
@property
|
||||
def entry_was_tapped(self) -> bool:
|
||||
@@ -159,6 +170,38 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
def left_panel(self) -> bool:
|
||||
return self._left_panel
|
||||
|
||||
@property
|
||||
def reveal_attempted(self) -> bool:
|
||||
return self._reveal_attempted
|
||||
|
||||
@property
|
||||
def entry_rpc_outcome(self) -> str:
|
||||
return self._entry_rpc_outcome
|
||||
|
||||
@property
|
||||
def entry_bounds_attempted(self) -> str | None:
|
||||
return self._entry_bounds_attempted
|
||||
|
||||
@property
|
||||
def option_rpc_outcomes(self) -> tuple[tuple[str, str], ...]:
|
||||
return tuple(sorted(self._option_rpc_outcomes.items()))
|
||||
|
||||
@property
|
||||
def reveal_rpc_outcome(self) -> str:
|
||||
return self._reveal_rpc_outcome
|
||||
|
||||
@property
|
||||
def option_attempts(self) -> int:
|
||||
return len(self._option_bounds_attempted)
|
||||
|
||||
@property
|
||||
def back_attempts(self) -> int:
|
||||
return int(self._left_panel)
|
||||
|
||||
@property
|
||||
def back_rpc_outcome(self) -> str:
|
||||
return self._back_rpc_outcome
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
value = self._call("app_info", package_name)
|
||||
if not isinstance(value, dict):
|
||||
@@ -178,19 +221,46 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
return value
|
||||
|
||||
def tap_sku_entry(self, bounds: str) -> None:
|
||||
if self._entry_was_tapped:
|
||||
raise SkuSelectionDeviceAdapterError("规格入口已经尝试过,拒绝重试。")
|
||||
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
|
||||
self._entry_was_tapped = True
|
||||
self._entry_bounds_attempted = bounds
|
||||
self._entry_rpc_outcome = "ambiguous"
|
||||
self._tap_bounds_once(bounds)
|
||||
self._entry_rpc_outcome = "completed"
|
||||
|
||||
def tap_sku_option(self, bounds: str) -> None:
|
||||
if bounds in self._option_bounds_attempted:
|
||||
raise SkuSelectionDeviceAdapterError("同一规格选项已经尝试过,拒绝重试。")
|
||||
# 规格 RPC 也可能送达后超时;按 exact bounds 封存本次唯一机会。
|
||||
self._option_bounds_attempted.add(bounds)
|
||||
self._option_rpc_outcomes[bounds] = "ambiguous"
|
||||
self._tap_bounds_once(bounds)
|
||||
self._option_rpc_outcomes[bounds] = "completed"
|
||||
|
||||
def reveal_size_options_once(self) -> None:
|
||||
if self._reveal_attempted:
|
||||
raise SkuSelectionDeviceAdapterError("规格显示动作已经尝试过,拒绝重试。")
|
||||
# 手势唯一机会在 RPC 前封存;生产 API 不接受方向、坐标或步数参数。
|
||||
self._reveal_attempted = True
|
||||
self._reveal_rpc_outcome = "ambiguous"
|
||||
self._call(
|
||||
"jsonrpc_call",
|
||||
"swipe",
|
||||
list(_REVEAL_GESTURE),
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
self._reveal_rpc_outcome = "completed"
|
||||
|
||||
def leave_sku_panel(self) -> None:
|
||||
if self._left_panel:
|
||||
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
|
||||
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
|
||||
self._left_panel = True
|
||||
self._back_rpc_outcome = "ambiguous"
|
||||
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
|
||||
self._back_rpc_outcome = "completed"
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
|
||||
@@ -312,9 +382,11 @@ class SkuSelectionRunner:
|
||||
stage = "safe_exit"
|
||||
flow.exit_sku_panel_safely()
|
||||
|
||||
_require_completed_action_audit(adapter)
|
||||
|
||||
stage = "publish"
|
||||
manifest_path.write_text(
|
||||
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size, adapter), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||
@@ -340,11 +412,10 @@ class SkuSelectionRunner:
|
||||
_annotate_mapped_failure(mapped, error, stage)
|
||||
raise mapped from error
|
||||
finally:
|
||||
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
||||
# 结果不明只允许读回 pending;失败路径绝不继续后续动作或自动 Back。
|
||||
if flow is not None:
|
||||
try:
|
||||
flow.reconcile_pending_action()
|
||||
flow.exit_sku_panel_safely()
|
||||
except (SkuSelectionRunError, SkuSelectionError):
|
||||
pass
|
||||
|
||||
@@ -411,9 +482,42 @@ def _require_screenshot_size(screenshot_path: Path) -> None:
|
||||
raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error
|
||||
|
||||
|
||||
def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, screenshot_path: Path, task_color: str, task_size: str) -> dict[str, Any]:
|
||||
def _require_completed_action_audit(adapter: UiautomatorSkuPanelAdapter) -> None:
|
||||
"""发布只接受完整正常链;测试替身或缺动作结果不能伪造真机闭环。"""
|
||||
|
||||
if (
|
||||
not adapter.entry_was_tapped
|
||||
or adapter.entry_bounds_attempted != _ENTRY_TEXT_BOUNDS
|
||||
or adapter.entry_rpc_outcome != "completed"
|
||||
or adapter.option_rpc_outcomes
|
||||
!= tuple(
|
||||
sorted(
|
||||
(
|
||||
(_TARGET_COLOR_UNROLLED_BOUNDS, "completed"),
|
||||
(_TARGET_SIZE_BOUNDS, "completed"),
|
||||
)
|
||||
)
|
||||
)
|
||||
or not adapter.reveal_attempted
|
||||
or adapter.reveal_rpc_outcome != "completed"
|
||||
or adapter.back_attempts != 1
|
||||
or adapter.back_rpc_outcome != "completed"
|
||||
):
|
||||
raise SkuSelectionRunError("规格动作审计链不完整,未发布任何证据产物。")
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
link: ProductUrl,
|
||||
screenshot_path: Path,
|
||||
task_color: str,
|
||||
task_size: str,
|
||||
adapter: UiautomatorSkuPanelAdapter,
|
||||
) -> dict[str, Any]:
|
||||
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
||||
|
||||
option_outcomes = dict(adapter.option_rpc_outcomes)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
@@ -422,8 +526,36 @@ def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, scree
|
||||
"target_selection": {"color": task_color, "size": task_size},
|
||||
"unit_price": EXPECTED_UNIT_PRICE,
|
||||
"selection_status": "restored",
|
||||
"panel_status": "verified",
|
||||
"safe_exit": "completed",
|
||||
"panel_status": "verified_before_back",
|
||||
"back_attempts": adapter.back_attempts,
|
||||
"back_rpc_outcome": adapter.back_rpc_outcome,
|
||||
"actions": {
|
||||
"sku_entry": {
|
||||
"attempts": int(adapter.entry_was_tapped),
|
||||
"rpc_outcome": adapter.entry_rpc_outcome,
|
||||
},
|
||||
"target_color": {
|
||||
"attempts": int(_TARGET_COLOR_UNROLLED_BOUNDS in option_outcomes),
|
||||
"rpc_outcome": option_outcomes.get(
|
||||
_TARGET_COLOR_UNROLLED_BOUNDS, "not_attempted"
|
||||
),
|
||||
},
|
||||
"size_reveal": {
|
||||
"attempts": int(adapter.reveal_attempted),
|
||||
"rpc_outcome": adapter.reveal_rpc_outcome,
|
||||
},
|
||||
"target_size": {
|
||||
"attempts": int(_TARGET_SIZE_BOUNDS in option_outcomes),
|
||||
"rpc_outcome": option_outcomes.get(
|
||||
_TARGET_SIZE_BOUNDS, "not_attempted"
|
||||
),
|
||||
},
|
||||
"back": {
|
||||
"attempts": adapter.back_attempts,
|
||||
"rpc_outcome": adapter.back_rpc_outcome,
|
||||
},
|
||||
},
|
||||
"post_exit_status": "human_review_required",
|
||||
"page_identity": "human_review_required",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
|
||||
Reference in New Issue
Block a user