fix(client): accept exact current-only sku state
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user