feat(client): capture one-shot sku reveal evidence
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
"""T-103 尺码显示动作的一次性真机取证;不属于生产采购 Flow。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from time import monotonic, sleep
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
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,
|
||||
resolve_task_selection,
|
||||
SkuSelectionError,
|
||||
SkuSelectionFlow,
|
||||
)
|
||||
from .sku_selection_runner import (
|
||||
EXPECTED_ANDROID_VERSION,
|
||||
EXPECTED_DEVICE_MODEL,
|
||||
EXPECTED_SCREEN_SIZE,
|
||||
SkuSelectionRunError,
|
||||
UiautomatorSkuPanelAdapter,
|
||||
_require_expected_device,
|
||||
_require_expected_version,
|
||||
_require_screenshot_size,
|
||||
)
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
class SkuRevealSpikeError(RuntimeError):
|
||||
"""一次性 reveal 取证未形成可发布证据。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuRevealSpikeResult:
|
||||
output_directory: Path
|
||||
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 的前后证据。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], Any],
|
||||
timeout_seconds: float,
|
||||
monotonic_clock: Callable[[], float] = monotonic,
|
||||
sleep_function: Callable[[float], None] = sleep,
|
||||
) -> None:
|
||||
if not _positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._clock = monotonic_clock
|
||||
self._sleep = sleep_function
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
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)
|
||||
staging: Path | None = None
|
||||
try:
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._clock() + self._timeout_seconds
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = _RevealEvidenceAdapter(self._connector(serial), self._timeout_seconds)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuRevealSpikeError("设备不是已取证的竖屏坐标空间,已停止取证。")
|
||||
pre_intent = adapter.dump_window_hierarchy()
|
||||
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuRevealSpikeError("规格入口取证超时,未执行 reveal。")
|
||||
flow = SkuSelectionFlow(
|
||||
adapter,
|
||||
entry_wait_timeout_seconds=remaining,
|
||||
monotonic_clock=self._clock,
|
||||
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("规格流程未停在已取证的仅颜色状态。")
|
||||
|
||||
before_hierarchy = adapter.dump_window_hierarchy()
|
||||
before_nodes = _parse_nodes(before_hierarchy)
|
||||
_require_color_only_panel(before_nodes)
|
||||
_require_safe_reveal_path(before_nodes)
|
||||
_capture_frame(adapter, staging / "before", before_hierarchy)
|
||||
# 截图 RPC 期间页面也可能变化;真正发送手势前必须用新树再次证明同一前置与安全通道。
|
||||
before_hierarchy = adapter.dump_window_hierarchy()
|
||||
before_nodes = _parse_nodes(before_hierarchy)
|
||||
_require_color_only_panel(before_nodes)
|
||||
_require_safe_reveal_path(before_nodes)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
current = adapter.app_current()
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise SkuRevealSpikeError("reveal 前拼多多不在前台,未执行手势。")
|
||||
(staging / "before" / "hierarchy.xml").write_text(before_hierarchy, encoding="utf-8")
|
||||
|
||||
rpc_outcome = "completed"
|
||||
try:
|
||||
adapter.reveal_size_options_once()
|
||||
except SkuSelectionRunError:
|
||||
rpc_outcome = "ambiguous_reconciled"
|
||||
|
||||
projection, after_hierarchy = self._wait_for_candidate(adapter, deadline)
|
||||
after_directory = staging / "after"
|
||||
_capture_frame(adapter, after_directory, after_hierarchy)
|
||||
reverified = adapter.dump_window_hierarchy()
|
||||
if _candidate_projection(_parse_nodes(reverified)) != projection:
|
||||
raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。")
|
||||
(after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8")
|
||||
|
||||
manifest = _manifest(inspection, serial, rpc_outcome, staging)
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。") from error
|
||||
|
||||
return SkuRevealSpikeResult(target, target / "manifest.json")
|
||||
|
||||
def _wait_for_candidate(
|
||||
self,
|
||||
adapter: _RevealEvidenceAdapter,
|
||||
deadline: float,
|
||||
) -> tuple[tuple[tuple[str, ...], ...], str]:
|
||||
stable: tuple[tuple[str, ...], ...] | None = None
|
||||
while True:
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
current = adapter.app_current()
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise SkuRevealSpikeError("reveal 后拼多多不在前台,未发布证据。")
|
||||
hierarchy = adapter.dump_window_hierarchy()
|
||||
try:
|
||||
projection = _candidate_projection(_parse_nodes(hierarchy))
|
||||
except SkuSelectionError:
|
||||
projection = None
|
||||
if projection is not None and projection == stable:
|
||||
return projection, hierarchy
|
||||
stable = projection
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuRevealSpikeError("reveal 后未形成稳定候选状态,未发布证据。")
|
||||
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]")],
|
||||
"候选面板头部不唯一。",
|
||||
)
|
||||
outer = _one(
|
||||
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
|
||||
"候选维度容器不唯一。",
|
||||
)
|
||||
price_row = _one(
|
||||
[node for node in nodes if _descendant(node, header) and _exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")],
|
||||
"候选价格行不唯一。",
|
||||
)
|
||||
current = _one(
|
||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, "快卖完 ¥12.88", "[396,503][712,570]")],
|
||||
"候选当前价不唯一。",
|
||||
)
|
||||
original = _one(
|
||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")],
|
||||
"候选原价不唯一。",
|
||||
)
|
||||
if _clickable_before(current, surface):
|
||||
raise SkuSelectionError("候选当前价位于可点击内容祖先下。")
|
||||
summary = _one(
|
||||
[node for node in nodes if _descendant(node, header) and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]")],
|
||||
"候选摘要不唯一。",
|
||||
)
|
||||
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_row, current, original, summary, color_region, color, size_label, size_options, s_action, m_action, dangerous[0])
|
||||
)
|
||||
|
||||
|
||||
def _capture_frame(adapter: _RevealEvidenceAdapter, directory: Path, hierarchy: str) -> None:
|
||||
directory.mkdir()
|
||||
hierarchy_path = directory / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
screenshot_path = directory / "screenshot.png"
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
rpc_outcome: str,
|
||||
staging: Path,
|
||||
) -> dict[str, Any]:
|
||||
artifacts = []
|
||||
for relative in (
|
||||
"before/screenshot.png",
|
||||
"before/hierarchy.xml",
|
||||
"after/screenshot.png",
|
||||
"after/hierarchy.xml",
|
||||
):
|
||||
path = staging / relative
|
||||
artifacts.append({"path": relative, "sha256": _sha256_file(path)})
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"operation": "t103-sku-reveal-evidence",
|
||||
"profile_id": "pdd-8.17.0-size-reveal-gap-v1",
|
||||
"product": {"goods_id": EXPECTED_GOODS_ID},
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": "8.17.0",
|
||||
},
|
||||
"reveal_attempts": 1,
|
||||
"rpc_outcome": rpc_outcome,
|
||||
"candidate_status": "human_review_required",
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists() or not target.name:
|
||||
raise SkuRevealSpikeError("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
def _prepare_staging(target: Path) -> Path:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
return staging
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _positive_finite(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value > 0
|
||||
and isfinite(value)
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import re
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Callable, Protocol
|
||||
@@ -29,17 +30,16 @@ _FORBIDDEN_ENTRY_ACTION_DESC = (
|
||||
)
|
||||
_SIZE = "尺码"
|
||||
_W, _H = 1080, 2376
|
||||
_PRICE_PARENT = "[396,498][895,570]"
|
||||
_CURRENT = "[396,503][712,570]"
|
||||
_ORIGINAL = "[730,503][895,570]"
|
||||
_SUMMARY = "[396,654][1053,716]"
|
||||
_COLOR_REGION = "[36,1000][1080,1631]"
|
||||
_SIZE_LABEL = "[36,1654][114,1700]"
|
||||
_SIZE_HEADER = "[36,1637][1044,1718]"
|
||||
_SIZE_OPTIONS = "[36,1730][1044,2045]"
|
||||
_PANEL_SURFACE = "[0,366][1080,2079]"
|
||||
_EMPTY_SUMMARY = "请选择: 颜色分类 尺码"
|
||||
_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+)\]$")
|
||||
_PRICE = re.compile(r"^[^0-9¥¥]*[¥¥]([1-9][0-9]*\.[0-9]{2})$")
|
||||
_ORIGINAL_PRICE = re.compile(r"^[¥¥][1-9][0-9]*\.[0-9]{2}$")
|
||||
_ROLLED_CURRENT_PRICE = "快卖完 ¥12.88"
|
||||
_ROLLED_ORIGINAL_PRICE = "¥29.88"
|
||||
_BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估")
|
||||
|
||||
|
||||
@@ -118,6 +118,73 @@ class _Node:
|
||||
def bounds(self) -> str: return self.element.get("bounds", "")
|
||||
|
||||
|
||||
class _PanelProfile(Enum):
|
||||
PANEL_OPEN_EMPTY = "panel_open_empty"
|
||||
COLOR_SELECTED_SIZE_HIDDEN = "color_selected_size_hidden"
|
||||
SIZE_VISIBLE_NON_TARGET = "size_visible_non_target"
|
||||
TARGETS_SELECTED = "targets_selected"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PanelSpec:
|
||||
profile: _PanelProfile
|
||||
header_bounds: str
|
||||
outer_bounds: str
|
||||
price_row_bounds: str
|
||||
current_text: str
|
||||
current_bounds: str
|
||||
original_text: str
|
||||
original_bounds: str
|
||||
summary_text: str
|
||||
summary_bounds: str
|
||||
color_label_bounds: str | None
|
||||
color_region_bounds: str
|
||||
color_bounds: str
|
||||
color_selected: bool
|
||||
size_label_bounds: str
|
||||
selected_size: str | None
|
||||
|
||||
|
||||
_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]",
|
||||
_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]",
|
||||
"[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.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]",
|
||||
_S_SUMMARY, "[396,654][1053,716]", None,
|
||||
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
|
||||
"[36,1506][114,1552]", _S_SIZE_UI,
|
||||
),
|
||||
_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]",
|
||||
_TARGET_SUMMARY, "[396,654][1053,716]", None,
|
||||
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
|
||||
"[36,1506][114,1552]", _TARGET_SIZE_UI,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -150,40 +217,63 @@ class SkuSelectionFlow:
|
||||
raise
|
||||
|
||||
try:
|
||||
self._pending = (before, _panel)
|
||||
self._pending = (before, _require_empty_panel)
|
||||
self._device.tap_sku_entry(entry.bounds)
|
||||
except BaseException as error:
|
||||
_annotate_sku_entry_failure(error, "sku_entry_click")
|
||||
raise
|
||||
|
||||
try:
|
||||
self._wait_after_action(before, _panel)
|
||||
self._wait_after_action(before, _require_empty_panel)
|
||||
except BaseException as error:
|
||||
_annotate_sku_entry_failure(error, "sku_entry_panel_verify")
|
||||
raise
|
||||
|
||||
def select_sku_options(self, selection: SkuSelection) -> None:
|
||||
if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
|
||||
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
|
||||
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
|
||||
initial = self._verified_nodes()
|
||||
_option(initial, "color", selection.color); _option(initial, "size", selection.size)
|
||||
_selected_label(initial, "color"); _selected_label(initial, "size")
|
||||
self._restore("color", selection.color)
|
||||
self._restore("size", selection.size)
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _parse_nodes(before)
|
||||
profile = _classify_panel(nodes)
|
||||
|
||||
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)
|
||||
self._device.tap_sku_option(target.bounds)
|
||||
self._wait_after_action(before, _require_color_only_panel)
|
||||
# 当前证据只证明颜色选择;尺码仍在视口外。没有动作证据时必须在此停住,
|
||||
# 不能把一次通用 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)
|
||||
return
|
||||
if profile is _PanelProfile.TARGETS_SELECTED:
|
||||
return
|
||||
raise SkuSelectionError("规格面板状态不属于已取证 profile,已停止操作。")
|
||||
|
||||
def read_sku_unit_price(self) -> str:
|
||||
return _unit_price(self._verified_nodes())
|
||||
|
||||
def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str:
|
||||
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
|
||||
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止读取。")
|
||||
nodes = self._verified_nodes()
|
||||
_selected(nodes, "color", selection.color)
|
||||
_selected(nodes, "size", selection.size)
|
||||
return _unit_price(nodes)
|
||||
|
||||
def exit_sku_panel_safely(self) -> None:
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
_panel(_parse_nodes(before))
|
||||
_classify_panel(_parse_nodes(before))
|
||||
self._device.leave_sku_panel()
|
||||
deadline = self._clock() + self._entry_timeout
|
||||
while True:
|
||||
@@ -191,7 +281,7 @@ class SkuSelectionFlow:
|
||||
raw = self._read_hierarchy()
|
||||
if raw != before:
|
||||
try:
|
||||
_panel(_parse_nodes(raw))
|
||||
_classify_panel(_parse_nodes(raw))
|
||||
except SkuSelectionError:
|
||||
return
|
||||
remaining = deadline - self._clock()
|
||||
@@ -206,26 +296,6 @@ class SkuSelectionFlow:
|
||||
before, condition = self._pending
|
||||
self._wait_after_action(before, condition)
|
||||
|
||||
def _restore(self, dimension: str, expected: str) -> None:
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _panel(_parse_nodes(before))
|
||||
target = _option(nodes, dimension, expected)
|
||||
if _selected_label(nodes, dimension) == expected:
|
||||
return
|
||||
_action_bounds(target.bounds)
|
||||
condition: Callable[[list[_Node]], Any]
|
||||
if dimension == "color":
|
||||
condition = lambda refreshed: _post_color(refreshed, expected)
|
||||
else:
|
||||
condition = lambda refreshed: _post_all_targets(refreshed, expected)
|
||||
self._pending = (before, condition)
|
||||
self._device.tap_sku_option(target.bounds)
|
||||
if dimension == "color":
|
||||
self._wait_after_action(before, condition)
|
||||
else:
|
||||
self._wait_after_action(before, condition)
|
||||
|
||||
def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]:
|
||||
deadline, stable = self._clock() + self._entry_timeout, None
|
||||
while True:
|
||||
@@ -276,7 +346,9 @@ class SkuSelectionFlow:
|
||||
|
||||
def _verified_nodes(self) -> list[_Node]:
|
||||
self._require_foreground()
|
||||
return _panel(self._read_nodes())
|
||||
nodes = self._read_nodes()
|
||||
_classify_panel(nodes)
|
||||
return nodes
|
||||
|
||||
def _require_version(self) -> None:
|
||||
info = self._device.app_info(PDD_PACKAGE)
|
||||
@@ -311,85 +383,415 @@ def _parse_nodes(raw: str) -> list[_Node]:
|
||||
return result
|
||||
|
||||
|
||||
def _panel(nodes: list[_Node]) -> list[_Node]:
|
||||
parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止操作。")
|
||||
_one([n for n in nodes if n.parent is parent and n.bounds == _ORIGINAL and _readonly(n) and _ORIGINAL_PRICE.fullmatch(n.text)], "规格面板原价槽位不唯一,已停止操作。")
|
||||
_one([n for n in nodes if n.bounds == _SUMMARY and _readonly(n) and n.text.startswith("已选:")], "规格面板已选摘要不唯一,已停止操作。")
|
||||
_color_container(nodes); _size_container(nodes)
|
||||
return nodes
|
||||
def _classify_panel(nodes: list[_Node]) -> _PanelProfile:
|
||||
matches: list[_PanelProfile] = []
|
||||
for spec in _PANEL_SPECS:
|
||||
try:
|
||||
_match_panel_profile(nodes, spec)
|
||||
except SkuSelectionError:
|
||||
continue
|
||||
matches.append(spec.profile)
|
||||
if len(matches) != 1:
|
||||
raise SkuSelectionError("规格面板不符合唯一完整取证 profile,已停止操作。")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _require_empty_panel(nodes: list[_Node]) -> None:
|
||||
_require_profile(nodes, _PanelProfile.PANEL_OPEN_EMPTY)
|
||||
|
||||
|
||||
def _require_color_only_panel(nodes: list[_Node]) -> None:
|
||||
_require_profile(nodes, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN)
|
||||
|
||||
|
||||
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:
|
||||
raise SkuSelectionError("规格面板动作后状态与已取证 profile 不一致,已停止操作。")
|
||||
|
||||
|
||||
def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
|
||||
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", spec.header_bounds)],
|
||||
"规格面板头部不唯一。",
|
||||
)
|
||||
outer = _one(
|
||||
[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)],
|
||||
"规格面板原价角色不唯一。",
|
||||
)
|
||||
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)],
|
||||
"规格面板摘要不唯一。",
|
||||
)
|
||||
|
||||
color_region = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_recycler(node, spec.color_region_bounds)],
|
||||
"规格面板颜色容器不唯一。",
|
||||
)
|
||||
if spec.color_label_bounds is None:
|
||||
if any(node.text == "颜色分类" and _readonly(node) for node in nodes):
|
||||
raise SkuSelectionError("滚动态出现未取证颜色标题。")
|
||||
else:
|
||||
_one(
|
||||
[node for node in nodes if _descendant(node, outer) and not _descendant(node, color_region) and _exact_readonly_text(node, "颜色分类", spec.color_label_bounds)],
|
||||
"规格面板颜色标题不唯一。",
|
||||
)
|
||||
color = _one(
|
||||
[node for node in nodes if node.parent is color_region and _exact_color_action(node, spec.color_bounds, spec.color_selected)],
|
||||
"目标颜色 action 不唯一。",
|
||||
)
|
||||
selected_nodes = _require_color_subtree(color, spec)
|
||||
_require_color_action_chain(color, color_region, outer, surface, spec)
|
||||
|
||||
size_label = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and not _descendant(node, color_region) and _exact_readonly_text(node, _SIZE, spec.size_label_bounds)],
|
||||
"规格面板尺码标题不唯一。",
|
||||
)
|
||||
if spec.selected_size is None:
|
||||
if any(
|
||||
node.text in {_S_SIZE_UI, _TARGET_SIZE_UI}
|
||||
and _is_size_action_text(node)
|
||||
for node in nodes
|
||||
):
|
||||
raise SkuSelectionError("尺码隐藏 profile 出现可点击尺码。")
|
||||
_require_exact_selected_set(nodes, surface, selected_nodes)
|
||||
return
|
||||
|
||||
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 根不唯一。",
|
||||
)
|
||||
size_actions = [
|
||||
node for node in nodes
|
||||
if _descendant(node, size_options)
|
||||
and _is_size_action_text(node)
|
||||
]
|
||||
s_action = _one([node for node in size_actions if node.text == _S_SIZE_UI and node.bounds == "[36,1582][409,1667]"], "S 尺码 action 不唯一。")
|
||||
m_action = _one([node for node in size_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)
|
||||
selected_sizes = [node for node in size_actions if node.element.get("selected") == "true"]
|
||||
expected_action = s_action if spec.selected_size == _S_SIZE_UI else m_action
|
||||
if selected_sizes != [expected_action]:
|
||||
raise SkuSelectionError("尺码维度 selected 状态不唯一。")
|
||||
_require_exact_selected_set(nodes, surface, [*selected_nodes, expected_action])
|
||||
|
||||
|
||||
def _unit_price(nodes: list[_Node]) -> str:
|
||||
parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止读取。")
|
||||
money = [n for n in nodes if n.parent is parent and _readonly(n) and any(mark in n.text for mark in "¥¥")]
|
||||
if len(money) != 2: raise SkuSelectionError("规格面板金额槽位不唯一,已停止读取。")
|
||||
current = _one([n for n in money if n.bounds == _CURRENT and not _clickable_ancestor(n) and not any(word in n.text for word in _BAD_PRICE_ROLE) and _PRICE.fullmatch(n.text)], "规格面板现价不唯一或不符合已取证槽位,已停止读取。")
|
||||
if not any(n.bounds == _ORIGINAL and _ORIGINAL_PRICE.fullmatch(n.text) for n in money):
|
||||
raise SkuSelectionError("规格面板原价槽位无效,已停止读取。")
|
||||
match = _PRICE.fullmatch(current.text)
|
||||
if match is None: raise SkuSelectionError("规格面板现价格式失效,已停止读取。")
|
||||
return match.group(1)
|
||||
_require_profile(nodes, _PanelProfile.TARGETS_SELECTED)
|
||||
spec = _spec_for(_PanelProfile.TARGETS_SELECTED)
|
||||
candidates = [
|
||||
node for node in nodes
|
||||
if _exact_readonly_text(node, _ROLLED_CURRENT_PRICE, spec.current_bounds)
|
||||
]
|
||||
current = _one(candidates, "规格面板现价不唯一,已停止读取。")
|
||||
surface = _one([node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)], "规格面板内容面不唯一。")
|
||||
if _clickable_before(current, surface) or any(word in current.text for word in _BAD_PRICE_ROLE):
|
||||
raise SkuSelectionError("规格面板现价角色不可安全读取。")
|
||||
return EXPECTED_UNIT_PRICE
|
||||
|
||||
|
||||
def _option(nodes: list[_Node], dimension: str, expected: str) -> _Node:
|
||||
_panel(nodes)
|
||||
return _one([n for n in _options(nodes, dimension) if _label(n) == expected], "规格选项不唯一或不是精确匹配,已停止操作。")
|
||||
def _target_color_action(nodes: list[_Node], *, selected: bool) -> _Node:
|
||||
profile = _classify_panel(nodes)
|
||||
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 _selected(nodes: list[_Node], dimension: str, expected: str) -> None:
|
||||
if _selected_label(nodes, dimension) != expected:
|
||||
raise SkuSelectionError("规格选择后读回的 selected 文案不一致,已停止操作。")
|
||||
def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node:
|
||||
profile = _classify_panel(nodes)
|
||||
expected_profile = _PanelProfile.TARGETS_SELECTED if selected else _PanelProfile.SIZE_VISIBLE_NON_TARGET
|
||||
if profile is not expected_profile:
|
||||
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()],
|
||||
"目标尺码 action 不唯一。",
|
||||
)
|
||||
|
||||
|
||||
def _selected_label(nodes: list[_Node], dimension: str) -> str:
|
||||
selected = [n for n in _options(nodes, dimension) if n.element.get("selected") == "true"]
|
||||
label = _label(_one(selected, "规格维度没有唯一 selected 状态,已停止操作。"))
|
||||
if label is None: raise SkuSelectionError("规格维度 selected 文案无效,已停止操作。")
|
||||
return label
|
||||
def _spec_for(profile: _PanelProfile) -> _PanelSpec:
|
||||
return next(spec for spec in _PANEL_SPECS if spec.profile is profile)
|
||||
|
||||
|
||||
def _post_color(nodes: list[_Node], expected: str) -> None:
|
||||
_panel(nodes)
|
||||
_selected(nodes, "color", expected)
|
||||
_selected_label(nodes, "size")
|
||||
def _exact_inert(node: _Node, class_name: str, bounds: str) -> bool:
|
||||
return _exact_common(node, class_name, bounds, clickable="false", selected="false", scrollable="false") and not node.text and not node.desc
|
||||
|
||||
|
||||
def _post_all_targets(nodes: list[_Node], expected_size: str) -> None:
|
||||
_panel(nodes)
|
||||
_selected(nodes, "color", _TARGET_COLOR_UI)
|
||||
_selected(nodes, "size", expected_size)
|
||||
def _exact_recycler(node: _Node, bounds: str) -> bool:
|
||||
return _exact_common(node, "androidx.recyclerview.widget.RecyclerView", bounds, clickable="false", selected="false", scrollable="true") and not node.text and not node.desc
|
||||
|
||||
|
||||
def _options(nodes: list[_Node], dimension: str) -> list[_Node]:
|
||||
container = _color_container(nodes) if dimension == "color" else _size_container(nodes) if dimension == "size" else None
|
||||
if container is None: raise SkuSelectionError("未知规格维度,已停止操作。")
|
||||
candidates = [n for n in nodes if _descendant(n, container) and _contained(n, container) and _choice(n) and _label(n) is not None]
|
||||
return [n for n in candidates if not _labeled_ancestor(n, candidates)]
|
||||
def _exact_readonly_text(node: _Node, text: str, bounds: str) -> bool:
|
||||
return _exact_common(node, "android.widget.TextView", bounds, clickable="false", selected="false", scrollable="false") and node.text == text and not node.desc
|
||||
|
||||
|
||||
def _color_container(nodes: list[_Node]) -> _Node:
|
||||
return _one([n for n in nodes if n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "androidx.recyclerview.widget.RecyclerView" and n.bounds == _COLOR_REGION], "规格面板颜色容器不唯一,已停止操作。")
|
||||
def _exact_color_action(node: _Node, bounds: str, selected: bool) -> bool:
|
||||
return (
|
||||
_exact_common(node, "android.view.ViewGroup", bounds, clickable="true", selected=str(selected).lower(), scrollable="false")
|
||||
and not node.text
|
||||
and node.desc == _TARGET_COLOR_UI
|
||||
)
|
||||
|
||||
|
||||
def _size_container(nodes: list[_Node]) -> _Node:
|
||||
label = _one([n for n in nodes if n.text == _SIZE and n.bounds == _SIZE_LABEL and _readonly(n)], "规格面板尺码标签不唯一,已停止操作。")
|
||||
header = label.parent
|
||||
if header is None or header.element.get("package") != PDD_PACKAGE or header.element.get("class") != "android.widget.LinearLayout" or header.bounds != _SIZE_HEADER or header.parent is None:
|
||||
raise SkuSelectionError("规格面板尺码标题容器不符合已取证结构,已停止操作。")
|
||||
return _one([n for n in nodes if n.parent is header.parent and n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "android.widget.LinearLayout" and n.bounds == _SIZE_OPTIONS], "规格面板尺码选项容器不唯一,已停止操作。")
|
||||
def _exact_live_layout(node: _Node, class_name: str, bounds: str) -> bool:
|
||||
return _exact_common(node, class_name, bounds, clickable="true", selected="false", scrollable="false") and not node.text and not node.desc
|
||||
|
||||
|
||||
def _label(node: _Node) -> str | None:
|
||||
values = {value for value in (node.text, node.desc) if value}
|
||||
return values.pop() if len(values) == 1 else None
|
||||
def _exact_common(node: _Node, class_name: str, bounds: str, *, clickable: str, selected: str, scrollable: str) -> bool:
|
||||
return (
|
||||
node.element.get("package") == PDD_PACKAGE
|
||||
and node.element.get("class") == class_name
|
||||
and node.bounds == bounds
|
||||
and node.element.get("clickable") == clickable
|
||||
and node.element.get("enabled") == "true"
|
||||
and node.element.get("visible-to-user") == "true"
|
||||
and node.element.get("selected") == selected
|
||||
and node.element.get("scrollable") == scrollable
|
||||
)
|
||||
|
||||
|
||||
def _labeled_ancestor(node: _Node, candidates: list[_Node]) -> bool:
|
||||
ids, parent = {id(n.element) for n in candidates}, node.parent
|
||||
def _is_size_action_text(node: _Node) -> bool:
|
||||
return (
|
||||
node.element.get("package") == PDD_PACKAGE
|
||||
and node.element.get("class") == "android.widget.TextView"
|
||||
and node.element.get("clickable") == "true"
|
||||
and node.element.get("enabled") == "true"
|
||||
and node.element.get("visible-to-user") == "true"
|
||||
and node.element.get("selected") in {"true", "false"}
|
||||
and node.element.get("scrollable") == "false"
|
||||
and not node.desc
|
||||
)
|
||||
|
||||
|
||||
def _require_size_wrapper(action: _Node, size_options: _Node) -> None:
|
||||
wrapper = action.parent
|
||||
if (
|
||||
wrapper is None
|
||||
or wrapper.parent is not size_options
|
||||
or not _exact_live_layout(wrapper, "android.view.ViewGroup", action.bounds)
|
||||
or wrapper.element[0] is not action.element
|
||||
):
|
||||
raise SkuSelectionError("尺码 action 父结构漂移。")
|
||||
children = [child for child in wrapper.element if child.tag == "node"]
|
||||
if action.element.get("selected") == "true":
|
||||
if len(children) != 2:
|
||||
raise SkuSelectionError("已选尺码指示子树数量漂移。")
|
||||
marker = _Node(children[1], wrapper)
|
||||
if (
|
||||
not _exact_common(
|
||||
marker,
|
||||
"android.view.View",
|
||||
action.bounds,
|
||||
clickable="false",
|
||||
selected="false",
|
||||
scrollable="false",
|
||||
)
|
||||
or marker.text
|
||||
or marker.desc
|
||||
):
|
||||
raise SkuSelectionError("已选尺码指示节点结构漂移。")
|
||||
elif len(children) != 1:
|
||||
raise SkuSelectionError("未选尺码 action 子树数量漂移。")
|
||||
|
||||
|
||||
def _require_panel_chain(surface: _Node) -> None:
|
||||
expected = (
|
||||
("android.widget.LinearLayout", "[0,366][1080,2079]", "false"),
|
||||
("android.view.ViewGroup", "[0,366][1080,2328]", "true"),
|
||||
("android.widget.LinearLayout", "[0,120][1080,2328]", "true"),
|
||||
("android.widget.FrameLayout", "[0,120][1080,2328]", "false"),
|
||||
("android.widget.FrameLayout", "[0,120][1080,2328]", "false"),
|
||||
("android.widget.LinearLayout", "[0,0][1080,2328]", "false"),
|
||||
("android.widget.FrameLayout", "[0,0][1080,2376]", "false"),
|
||||
)
|
||||
node = surface.parent
|
||||
for class_name, bounds, clickable in expected:
|
||||
if (
|
||||
node is None
|
||||
or not _exact_common(
|
||||
node,
|
||||
class_name,
|
||||
bounds,
|
||||
clickable=clickable,
|
||||
selected="false",
|
||||
scrollable="false",
|
||||
)
|
||||
or node.text
|
||||
or node.desc
|
||||
):
|
||||
raise SkuSelectionError("规格面板祖先链不符合已取证结构。")
|
||||
node = node.parent
|
||||
if node is None or node.element.tag != "hierarchy" or node.parent is not None:
|
||||
raise SkuSelectionError("规格面板根节点结构漂移。")
|
||||
|
||||
|
||||
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 len(children) != 4:
|
||||
raise SkuSelectionError("目标颜色完整卡片子树数量漂移。")
|
||||
child_nodes = [node for node in _walk_direct_children(color)]
|
||||
view, image, zoom, label_layout = child_nodes
|
||||
if not _exact_common(view, "android.view.View", spec.color_bounds, clickable="false", selected=selected, scrollable="false") or view.text or view.desc:
|
||||
raise SkuSelectionError("目标颜色选中遮罩结构漂移。")
|
||||
if not _exact_common(image, "android.widget.ImageView", "[372,1188][684,1500]", clickable="true", selected=selected, scrollable="false") or image.text or image.desc != _TARGET_COLOR_UI:
|
||||
raise SkuSelectionError("目标颜色图片 action 结构漂移。")
|
||||
if not _exact_common(zoom, "android.widget.ImageView", "[372,1188][483,1299]", clickable="true", selected=selected, scrollable="false") or zoom.text or zoom.desc != "打开大图":
|
||||
raise SkuSelectionError("目标颜色大图 action 结构漂移。")
|
||||
if not _exact_common(label_layout, "android.widget.LinearLayout", "[372,1479][684,1587]", clickable="false", selected=selected, scrollable="false") or label_layout.text or label_layout.desc or len(label_layout.element) != 1:
|
||||
raise SkuSelectionError("目标颜色文字容器结构漂移。")
|
||||
label = _Node(label_layout.element[0], label_layout)
|
||||
if not _exact_common(label, "android.widget.TextView", "[372,1479][684,1587]", clickable="true", selected=selected, scrollable="false") or label.text != _TARGET_COLOR_UI or label.desc:
|
||||
raise SkuSelectionError("目标颜色文字 action 结构漂移。")
|
||||
if spec.color_selected:
|
||||
expected_selected.extend((view, image, zoom, label_layout, label))
|
||||
return expected_selected
|
||||
|
||||
if len(children) != 2:
|
||||
raise SkuSelectionError("目标颜色滚动态子树数量漂移。")
|
||||
view, label_layout = [node for node in _walk_direct_children(color)]
|
||||
if not _exact_common(view, "android.view.View", spec.color_bounds, clickable="false", selected=selected, scrollable="false") or view.text or view.desc:
|
||||
raise SkuSelectionError("目标颜色滚动态遮罩结构漂移。")
|
||||
if not _exact_common(label_layout, "android.widget.LinearLayout", spec.color_bounds, clickable="false", selected=selected, scrollable="false") or label_layout.text or label_layout.desc or len(label_layout.element) != 1:
|
||||
raise SkuSelectionError("目标颜色滚动态文字容器漂移。")
|
||||
label = _Node(label_layout.element[0], label_layout)
|
||||
if not _exact_common(label, "android.widget.TextView", spec.color_bounds, clickable="true", selected=selected, scrollable="false") or label.text != _TARGET_COLOR_UI or label.desc:
|
||||
raise SkuSelectionError("目标颜色滚动态文字 action 漂移。")
|
||||
if spec.color_selected:
|
||||
expected_selected.extend((view, label_layout, label))
|
||||
return expected_selected
|
||||
|
||||
|
||||
def _require_color_action_chain(
|
||||
color: _Node,
|
||||
color_region: _Node,
|
||||
outer: _Node,
|
||||
surface: _Node,
|
||||
spec: _PanelSpec,
|
||||
) -> None:
|
||||
if color.parent is not color_region:
|
||||
raise SkuSelectionError("目标颜色 action 不属于已取证颜色容器。")
|
||||
if spec.color_bounds == "[372,1188][684,1587]":
|
||||
expected = (
|
||||
("android.widget.FrameLayout", "[0,1188][1080,2046]"),
|
||||
("android.widget.LinearLayout", "[0,1188][1080,2052]"),
|
||||
("android.widget.LinearLayout", "[0,1077][1080,2052]"),
|
||||
)
|
||||
else:
|
||||
expected = (
|
||||
("android.widget.FrameLayout", "[0,1000][1080,1483]"),
|
||||
("android.widget.LinearLayout", "[0,1000][1080,1489]"),
|
||||
("android.widget.LinearLayout", "[0,1000][1080,1489]"),
|
||||
)
|
||||
node = color_region.parent
|
||||
for class_name, bounds in expected:
|
||||
if node is None or not _exact_inert(node, class_name, bounds):
|
||||
raise SkuSelectionError("目标颜色 action 父链不符合已取证结构。")
|
||||
node = node.parent
|
||||
if node is not outer or outer.parent is not surface:
|
||||
raise SkuSelectionError("目标颜色 action 未沿已取证维度父链回到面板。")
|
||||
|
||||
|
||||
def _require_size_action_chain(
|
||||
action: _Node,
|
||||
size_options: _Node,
|
||||
outer: _Node,
|
||||
surface: _Node,
|
||||
) -> None:
|
||||
wrapper = action.parent
|
||||
if wrapper is None or wrapper.parent is not size_options:
|
||||
raise SkuSelectionError("尺码 action 不属于已取证 options 容器。")
|
||||
node = size_options.parent
|
||||
for class_name, bounds in (
|
||||
("android.widget.LinearLayout", "[36,1582][1044,1897]"),
|
||||
("android.widget.LinearLayout", "[0,1489][1080,1930]"),
|
||||
):
|
||||
if node is None or not _exact_inert(node, class_name, bounds):
|
||||
raise SkuSelectionError("尺码 action 父链不符合已取证结构。")
|
||||
node = node.parent
|
||||
if node is not outer or outer.parent is not surface:
|
||||
raise SkuSelectionError("尺码 action 未沿已取证维度父链回到面板。")
|
||||
|
||||
|
||||
def _walk_direct_children(parent: _Node) -> list[_Node]:
|
||||
return [_Node(child, parent) for child in parent.element if child.tag == "node"]
|
||||
|
||||
|
||||
def _require_exact_selected_set(nodes: list[_Node], surface: _Node, expected: list[_Node]) -> None:
|
||||
actual = [
|
||||
node for node in nodes
|
||||
if _descendant(node, surface) and node.element.get("selected") == "true"
|
||||
]
|
||||
if {id(node.element) for node in actual} != {id(node.element) for node in expected}:
|
||||
raise SkuSelectionError("规格面板 selected 节点集合与完整 profile 不一致。")
|
||||
|
||||
|
||||
def _require_action_occupants(nodes: list[_Node], target: _Node) -> None:
|
||||
left, top, right, bottom = _action_bounds(target.bounds)
|
||||
point = (left + (right - left) // 2, top + (bottom - top) // 2)
|
||||
allowed: set[int] = {id(target.element)}
|
||||
parent = target.parent
|
||||
while parent is not None:
|
||||
if id(parent.element) in ids and _label(parent) is not None: return True
|
||||
if _is_live_clickable(parent):
|
||||
if not (
|
||||
_exact_live_layout(parent, "android.view.ViewGroup", target.bounds)
|
||||
or _exact_live_layout(parent, "android.view.ViewGroup", "[0,366][1080,2328]")
|
||||
or _exact_live_layout(parent, "android.widget.LinearLayout", "[0,120][1080,2328]")
|
||||
):
|
||||
raise SkuSelectionError("规格 action 存在未取证可点击祖先,已停止操作。")
|
||||
allowed.add(id(parent.element))
|
||||
parent = parent.parent
|
||||
return False
|
||||
for node in nodes:
|
||||
if _descendant(node, target) and _is_live_clickable(node):
|
||||
node_left, node_top, node_right, node_bottom = _action_bounds(node.bounds)
|
||||
if node_left <= point[0] < node_right and node_top <= point[1] < node_bottom:
|
||||
allowed.add(id(node.element))
|
||||
occupants = _live_clickables_covering(nodes, point)
|
||||
if {id(node.element) for node in occupants} != allowed:
|
||||
raise SkuSelectionError("规格 action 中心存在未取证可点击占用,已停止操作。")
|
||||
|
||||
|
||||
def _clickable_before(node: _Node, stop: _Node) -> bool:
|
||||
parent = node.parent
|
||||
while parent is not None and parent.element is not stop.element:
|
||||
if parent.element.get("clickable") == "true":
|
||||
return True
|
||||
parent = parent.parent
|
||||
# 当前价必须能沿已取证内容祖先回到面板内容面;不接受另一个树枝上的同坐标文本。
|
||||
return parent is None
|
||||
|
||||
|
||||
def _descendant(node: _Node, ancestor: _Node) -> bool:
|
||||
@@ -400,20 +802,6 @@ def _descendant(node: _Node, ancestor: _Node) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _contained(node: _Node, container: _Node) -> bool:
|
||||
left, top, right, bottom = _action_bounds(node.bounds)
|
||||
outer_left, outer_top, outer_right, outer_bottom = _action_bounds(container.bounds)
|
||||
return outer_left <= left < right <= outer_right and outer_top <= top < bottom <= outer_bottom
|
||||
|
||||
|
||||
def _clickable_ancestor(node: _Node) -> bool:
|
||||
parent = node.parent
|
||||
while parent is not None:
|
||||
if parent.element.get("clickable") == "true": return True
|
||||
parent = parent.parent
|
||||
return False
|
||||
|
||||
|
||||
def _readonly(node: _Node) -> bool:
|
||||
return node.element.get("package") == PDD_PACKAGE and node.element.get("class") == "android.widget.TextView" and node.element.get("clickable") == "false" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true"
|
||||
|
||||
@@ -422,14 +810,15 @@ def _live(node: _Node) -> bool:
|
||||
return node.element.get("package") == PDD_PACKAGE and node.element.get("clickable") == "true" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true" and bool(node.bounds)
|
||||
|
||||
|
||||
def _choice(node: _Node) -> bool:
|
||||
return _live(node) and node.element.get("class") == "android.view.ViewGroup" and node.element.get("selected") in {"true", "false"}
|
||||
|
||||
|
||||
def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
|
||||
# 入口文本本身不可点击:必须证明它仍是已取证底部父容器的直接子节点,但动作坐标继续
|
||||
# 使用第一行文本的窄 bounds,避免把父容器中心或第二行“免拼购买”变成坐标兜底。
|
||||
if any(node.bounds == _PRICE_PARENT for node in nodes):
|
||||
if any(
|
||||
node.bounds == _PANEL_SURFACE
|
||||
and node.element.get("package") == PDD_PACKAGE
|
||||
and node.element.get("class") == "android.view.ViewGroup"
|
||||
for node in nodes
|
||||
):
|
||||
return []
|
||||
entries = _physical_entries(nodes)
|
||||
if len(entries) != 1:
|
||||
|
||||
Reference in New Issue
Block a user