feat(client): capture one-shot sku reveal evidence
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"""采集 T-103 一次性尺码 reveal 的 before/after 本机证据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.sku_reveal_spike import SkuRevealSpikeCapturer, SkuRevealSpikeError
|
||||
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError
|
||||
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-103 一次性尺码 reveal 的本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--goods-id", required=True, help="固定 T-103 已取证 goods_id。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
|
||||
parser.add_argument("--timeout", type=float, default=30.0, help="整趟动作与只读调和时限(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if type(arguments.serial) is not str or not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--goods-id 不是 T-103 已取证商品。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
capturer = SkuRevealSpikeCapturer(
|
||||
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
result = capturer.capture(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError):
|
||||
# 不回显页面正文、节点、serial、坐标、路径或第三方异常。
|
||||
print("规格 reveal 取证失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格 reveal 取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"规格 reveal 取证完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
print("人工复核:请保持手机不动,确认颜色仍选中、S/M 均未选且未进入提交页。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="券前¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[693,580][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="请选择: 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="券前¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[693,580][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="请选择: 颜色分类 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[439,1582][831,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="已选: 黑色 CHA (纯棉) S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[36,1582][409,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from contextlib import redirect_stderr
|
||||
from io import BytesIO
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.pdd.sku_reveal_spike import (
|
||||
SkuRevealSpikeCapturer,
|
||||
SkuRevealSpikeError,
|
||||
_require_safe_reveal_path,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection import SkuSelectionError, _parse_nodes
|
||||
|
||||
|
||||
_FIXTURES = Path(__file__).with_name("fixtures")
|
||||
_PRODUCT = (_FIXTURES / "product_entry_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_EMPTY = (_FIXTURES / "sku_panel_empty_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_COLOR_ONLY = (_FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_M_SELECTED = (_FIXTURES / "sku_panel_size_m_restored_8_17_0.xml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _candidate_unselected() -> str:
|
||||
root = ElementTree.fromstring(_M_SELECTED)
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
for node in root.iter("node"):
|
||||
if node.get("text") == "已选: 黑色 CHA (纯棉) M(建议100-115)":
|
||||
node.set("text", "请选择: 尺码")
|
||||
if node.get("text") in {"S(建议80-100)", "M(建议100-115)"}:
|
||||
node.set("selected", "false")
|
||||
wrapper = parents[node]
|
||||
for child in list(wrapper):
|
||||
if child.get("class") == "android.view.View":
|
||||
wrapper.remove(child)
|
||||
action_root = next(
|
||||
node
|
||||
for node in root.iter("node")
|
||||
if node.get("class") == "android.view.ViewGroup"
|
||||
and node.get("clickable") == "true"
|
||||
and node.get("bounds") == "[0,366][1080,2328]"
|
||||
)
|
||||
button_frame = ElementTree.SubElement(
|
||||
action_root,
|
||||
"node",
|
||||
{
|
||||
"class": "android.widget.FrameLayout",
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"text": "",
|
||||
"content-desc": "",
|
||||
"clickable": "true",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
"bounds": "[0,2181][1080,2328]",
|
||||
},
|
||||
)
|
||||
button_content = ElementTree.SubElement(
|
||||
button_frame,
|
||||
"node",
|
||||
{
|
||||
"class": "android.widget.LinearLayout",
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"text": "",
|
||||
"content-desc": "",
|
||||
"clickable": "false",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
"bounds": "[273,2181][807,2328]",
|
||||
},
|
||||
)
|
||||
ElementTree.SubElement(
|
||||
button_content,
|
||||
"node",
|
||||
{
|
||||
"class": "android.widget.TextView",
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"text": "选择尺码后,提交订单",
|
||||
"content-desc": "",
|
||||
"clickable": "false",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
"bounds": "[285,2225][795,2284]",
|
||||
},
|
||||
)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _with_clickable_overlay(package: str, class_name: str, bounds: str) -> str:
|
||||
root = ElementTree.fromstring(_COLOR_ONLY)
|
||||
container = next(root.iter("node"))
|
||||
ElementTree.SubElement(
|
||||
container,
|
||||
"node",
|
||||
{
|
||||
"package": package,
|
||||
"class": class_name,
|
||||
"text": "",
|
||||
"content-desc": "",
|
||||
"clickable": "true",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
"bounds": bounds,
|
||||
},
|
||||
)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _png() -> str:
|
||||
image = Image.new("RGB", (1080, 2376), "white")
|
||||
raw = BytesIO()
|
||||
image.save(raw, format="PNG")
|
||||
return base64.b64encode(raw.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class _FakeDevice:
|
||||
def __init__(self) -> None:
|
||||
self.hierarchy = "<hierarchy />"
|
||||
self.after_hierarchy = _candidate_unselected()
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.swipe_error = False
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append(("app_info", package_name))
|
||||
return {"versionName": "8.17.0"}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append(("app_current",))
|
||||
return {"package": "com.xunmeng.pinduoduo"}
|
||||
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
return 1080, 2376
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
if method == "takeScreenshot":
|
||||
return _png()
|
||||
if method == "click":
|
||||
if params == [865, 2218]:
|
||||
self.hierarchy = _EMPTY
|
||||
return ""
|
||||
if params == [528, 1387]:
|
||||
self.hierarchy = _COLOR_ONLY
|
||||
return ""
|
||||
raise AssertionError(params)
|
||||
if method == "swipe":
|
||||
self.hierarchy = self.after_hierarchy
|
||||
if self.swipe_error:
|
||||
raise TimeoutError("unknown outcome")
|
||||
return ""
|
||||
raise AssertionError(method)
|
||||
|
||||
|
||||
class _FakeAdb:
|
||||
def __init__(self, device: _FakeDevice) -> None:
|
||||
self.device = device
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(("inspect", serial))
|
||||
return DeviceInspection(AdbDevice(serial=serial, state="device"), "PKG110", "16")
|
||||
|
||||
def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
|
||||
self.calls.append(("intent", serial, goods_id))
|
||||
self.device.hierarchy = _PRODUCT
|
||||
return object()
|
||||
|
||||
|
||||
def _swipes(device: _FakeDevice) -> list[tuple[object, ...]]:
|
||||
return [call for call in device.calls if call[:2] == ("jsonrpc", "swipe")]
|
||||
|
||||
|
||||
class SkuRevealSpikeTests(unittest.TestCase):
|
||||
def _capturer(self, device: _FakeDevice) -> SkuRevealSpikeCapturer:
|
||||
now = [0.0]
|
||||
return SkuRevealSpikeCapturer(
|
||||
_FakeAdb(device),
|
||||
lambda serial: device,
|
||||
30,
|
||||
monotonic_clock=lambda: now[0],
|
||||
sleep_function=lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||
)
|
||||
|
||||
def test_success_publishes_before_after_and_exactly_one_reveal(self) -> None:
|
||||
device = _FakeDevice()
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
result = self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
|
||||
self.assertEqual(result.output_directory, target)
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(any(call[1] == "pressKey" for call in device.calls if call[0] == "jsonrpc"))
|
||||
for relative in (
|
||||
"before/screenshot.png",
|
||||
"before/hierarchy.xml",
|
||||
"after/screenshot.png",
|
||||
"after/hierarchy.xml",
|
||||
"manifest.json",
|
||||
):
|
||||
self.assertTrue((target / relative).is_file(), relative)
|
||||
manifest_text = result.manifest_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(manifest_text)
|
||||
self.assertEqual(manifest["reveal_attempts"], 1)
|
||||
self.assertEqual(manifest["rpc_outcome"], "completed")
|
||||
self.assertNotIn("192.168.0.173:5555", manifest_text)
|
||||
self.assertNotIn("gesture", manifest)
|
||||
self.assertNotIn("coordinates", manifest)
|
||||
self.assertNotIn("start", manifest)
|
||||
self.assertNotIn("end", manifest)
|
||||
|
||||
def test_ambiguous_rpc_is_read_only_reconciled_without_retry(self) -> None:
|
||||
device = _FakeDevice()
|
||||
device.swipe_error = True
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
result = self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["rpc_outcome"], "ambiguous_reconciled")
|
||||
|
||||
def test_selected_m_or_precondition_drift_never_publishes_or_retries(self) -> None:
|
||||
for after in (
|
||||
_M_SELECTED,
|
||||
_candidate_unselected().replace("请选择: 尺码", "已选: 尺码"),
|
||||
):
|
||||
with self.subTest():
|
||||
device = _FakeDevice()
|
||||
device.after_hierarchy = after
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
with self.assertRaises(SkuRevealSpikeError):
|
||||
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
device = _FakeDevice()
|
||||
device.hierarchy = "<hierarchy />"
|
||||
invalid_color = _COLOR_ONLY.replace("请选择: 尺码", "请选择: 颜色分类 尺码")
|
||||
original_call = device.jsonrpc_call
|
||||
|
||||
def drift(method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = original_call(method, params, timeout)
|
||||
if method == "click" and params == [528, 1387]:
|
||||
device.hierarchy = invalid_color
|
||||
return value
|
||||
|
||||
device.jsonrpc_call = drift # type: ignore[method-assign]
|
||||
with TemporaryDirectory() as directory:
|
||||
with self.assertRaises((SkuSelectionError, SkuRevealSpikeError)):
|
||||
self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
Path(directory) / "evidence",
|
||||
)
|
||||
self.assertEqual(_swipes(device), [])
|
||||
|
||||
def test_invalid_goods_and_existing_target_are_zero_action(self) -> None:
|
||||
device = _FakeDevice()
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "existing"
|
||||
target.mkdir()
|
||||
with self.assertRaises(SkuRevealSpikeError):
|
||||
self._capturer(device).capture("wifi", "1", Path(directory) / "new")
|
||||
with self.assertRaises(SkuRevealSpikeError):
|
||||
self._capturer(device).capture("wifi", "937122477375", target)
|
||||
self.assertEqual(device.calls, [])
|
||||
|
||||
def test_before_screenshot_drift_is_rechecked_before_zero_swipe(self) -> None:
|
||||
device = _FakeDevice()
|
||||
original_call = device.jsonrpc_call
|
||||
|
||||
def drift(method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = original_call(method, params, timeout)
|
||||
if method == "takeScreenshot":
|
||||
device.hierarchy = _COLOR_ONLY.replace("请选择: 尺码", "请选择: 颜色分类 尺码")
|
||||
return value
|
||||
|
||||
device.jsonrpc_call = drift # type: ignore[method-assign]
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
target,
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(_swipes(device), [])
|
||||
|
||||
def test_complete_reveal_segment_rejects_narrow_impostor_and_invalid_bounds(self) -> None:
|
||||
for hierarchy in (
|
||||
_with_clickable_overlay(
|
||||
"com.xunmeng.pinduoduo",
|
||||
"android.view.ViewGroup",
|
||||
"[350,1450][370,1460]",
|
||||
),
|
||||
_with_clickable_overlay(
|
||||
"com.android.systemui",
|
||||
"android.view.ViewGroup",
|
||||
"[0,366][1080,2328]",
|
||||
),
|
||||
_with_clickable_overlay(
|
||||
"com.xunmeng.pinduoduo",
|
||||
"android.view.ViewGroup",
|
||||
"not-a-bound",
|
||||
),
|
||||
):
|
||||
with self.subTest(), self.assertRaises(SkuRevealSpikeError):
|
||||
_require_safe_reveal_path(_parse_nodes(hierarchy))
|
||||
|
||||
|
||||
class SkuRevealSpikeCliTests(unittest.TestCase):
|
||||
def test_cli_has_no_gesture_or_task_specification_parameters(self) -> None:
|
||||
script = _load_reveal_script()
|
||||
arguments = script.parse_arguments(
|
||||
[
|
||||
"--serial", "device-1",
|
||||
"--goods-id", "937122477375",
|
||||
"--output-dir", "evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
set(vars(arguments)),
|
||||
{"serial", "goods_id", "output_dir", "timeout", "adb"},
|
||||
)
|
||||
script.validate_arguments(arguments)
|
||||
for field, value in (
|
||||
("serial", ""),
|
||||
("goods_id", "1"),
|
||||
("timeout", 0),
|
||||
("timeout", float("inf")),
|
||||
):
|
||||
with self.subTest(field=field), self.assertRaises(ValueError):
|
||||
script.validate_arguments(
|
||||
type("Arguments", (), vars(arguments) | {field: value})()
|
||||
)
|
||||
|
||||
def test_cli_failure_is_redacted(self) -> None:
|
||||
script = _load_reveal_script()
|
||||
secret = "SERIAL=192.168.0.173:5555 <hierarchy>private</hierarchy>"
|
||||
|
||||
class FailingCapturer:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def capture(self, *args: object, **kwargs: object) -> object:
|
||||
raise SkuRevealSpikeError(secret)
|
||||
|
||||
stderr = io.StringIO()
|
||||
with patch.object(script, "SkuRevealSpikeCapturer", FailingCapturer), redirect_stderr(stderr):
|
||||
status = script.main(
|
||||
[
|
||||
"--serial", "192.168.0.173:5555",
|
||||
"--goods-id", "937122477375",
|
||||
"--output-dir", "evidence",
|
||||
]
|
||||
)
|
||||
output = stderr.getvalue()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertNotIn("Traceback", output)
|
||||
self.assertNotIn("192.168.0.173:5555", output)
|
||||
self.assertNotIn("private", output)
|
||||
|
||||
|
||||
def _load_reveal_script() -> object:
|
||||
path = Path(__file__).resolve().parents[2] / "scripts" / "capture_sku_reveal_spike.py"
|
||||
specification = importlib.util.spec_from_file_location("capture_sku_reveal_spike_test", path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError("无法加载 T-103 reveal 取证脚本。")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,7 +17,13 @@ import cmbuyer_client.pdd as pdd
|
||||
import cmbuyer_client.pdd.sku_selection_runner as runner_module
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceConnectionError, DeviceInspection
|
||||
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
|
||||
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
|
||||
from cmbuyer_client.pdd.sku_selection import (
|
||||
SkuPanelDevice,
|
||||
_action_bounds,
|
||||
_classify_panel,
|
||||
_parse_nodes,
|
||||
resolve_task_selection,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
SkuSelectionDeviceAdapterError,
|
||||
SkuSelectionRunError,
|
||||
@@ -27,7 +33,12 @@ from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
)
|
||||
|
||||
|
||||
_FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml"
|
||||
_FIXTURES = Path(__file__).with_name("fixtures")
|
||||
_EMPTY_FIXTURE = _FIXTURES / "sku_panel_empty_8_17_0.xml"
|
||||
_COLOR_FIXTURE = _FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml"
|
||||
_S_FIXTURE = _FIXTURES / "sku_panel_size_s_selected_8_17_0.xml"
|
||||
_M_FIXTURE = _FIXTURES / "sku_panel_size_m_restored_8_17_0.xml"
|
||||
_FIXTURE = _M_FIXTURE
|
||||
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
|
||||
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
|
||||
_TASK_COLOR = "黑色CHA(纯棉)"
|
||||
@@ -45,7 +56,7 @@ def _png_base64() -> str:
|
||||
class _RawDevice:
|
||||
def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
|
||||
self.hierarchy = hierarchy
|
||||
self.panel_hierarchy = _FIXTURE.read_text(encoding="utf-8")
|
||||
self.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8")
|
||||
self.version = "8.17.0"
|
||||
self.package = "com.xunmeng.pinduoduo"
|
||||
self.screenshot = _png_base64() if screenshot is None else screenshot
|
||||
@@ -84,30 +95,24 @@ class _RawDevice:
|
||||
):
|
||||
self.hierarchy = self.panel_hierarchy
|
||||
return
|
||||
root = ElementTree.fromstring(self.hierarchy)
|
||||
target = next(node for node in root.iter("node") if _center(node.get("bounds", "")) == (x, y))
|
||||
color = target.get("bounds", "").endswith("][438,1172]")
|
||||
for node in root.iter("node"):
|
||||
if node.get("selected") is not None and ((color and ",1000]" in node.get("bounds", "")) or (not color and ",1730]" in node.get("bounds", ""))):
|
||||
node.set("selected", "false")
|
||||
if color and self.fail_color_readback:
|
||||
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||
else:
|
||||
target.set("selected", "true")
|
||||
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||
if (x, y) == (528, 1387):
|
||||
self.hierarchy = (
|
||||
_EMPTY_FIXTURE.read_text(encoding="utf-8")
|
||||
if self.fail_color_readback
|
||||
else _COLOR_FIXTURE.read_text(encoding="utf-8")
|
||||
)
|
||||
return
|
||||
if (x, y) == (635, 1624):
|
||||
self.hierarchy = _M_FIXTURE.read_text(encoding="utf-8")
|
||||
return
|
||||
raise AssertionError((x, y))
|
||||
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
return 1080, 2376
|
||||
|
||||
def select_alternates(self) -> None:
|
||||
root = ElementTree.fromstring(self.panel_hierarchy)
|
||||
for node in root.iter("node"):
|
||||
if node.get("selected") is not None:
|
||||
node.set("selected", "false")
|
||||
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
|
||||
self.panel_hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||
self.panel_hierarchy = _S_FIXTURE.read_text(encoding="utf-8")
|
||||
if self.hierarchy != _PRODUCT_PAGE:
|
||||
self.hierarchy = self.panel_hierarchy
|
||||
|
||||
@@ -119,6 +124,34 @@ def _center(bounds: str) -> tuple[int, int]:
|
||||
return left + (right - left) // 2, top + (bottom - top) // 2
|
||||
|
||||
|
||||
def _mutate_unique_node(
|
||||
hierarchy: str,
|
||||
*,
|
||||
attribute: str,
|
||||
value: str | None,
|
||||
text: str | None = None,
|
||||
desc: str | None = None,
|
||||
class_name: str | None = None,
|
||||
bounds: str | None = None,
|
||||
) -> str:
|
||||
root = ElementTree.fromstring(hierarchy)
|
||||
matches = [
|
||||
node
|
||||
for node in root.iter("node")
|
||||
if (text is None or node.get("text") == text)
|
||||
and (desc is None or node.get("content-desc") == desc)
|
||||
and (class_name is None or node.get("class") == class_name)
|
||||
and (bounds is None or node.get("bounds") == bounds)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise AssertionError(f"fixture node match count: {len(matches)}")
|
||||
if value is None:
|
||||
matches[0].attrib.pop(attribute, None)
|
||||
else:
|
||||
matches[0].set(attribute, value)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
|
||||
@@ -286,18 +319,26 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_target_mapping_is_exact_and_success_path_restores_target(self) -> None:
|
||||
def test_target_mapping_stops_at_unproven_reveal_then_s_to_m_is_exact(self) -> None:
|
||||
device = _RawDevice()
|
||||
adapter = UiautomatorSkuPanelAdapter(device, 10)
|
||||
flow = SkuSelectionFlow(adapter)
|
||||
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(flow.read_sku_unit_price(), "12.88")
|
||||
flow.exit_sku_panel_safely()
|
||||
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8"))
|
||||
restored_flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(restored, 10))
|
||||
restored_flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(restored_flow.read_sku_unit_price(), "12.88")
|
||||
restored_flow.exit_sku_panel_safely()
|
||||
|
||||
self.assertEqual(_tap_centers(restored), [(635, 1624)])
|
||||
self.assertEqual(_actions(restored, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
|
||||
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
@@ -498,15 +539,45 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
def test_option_selected_and_container_drift_fail_closed_before_click(self) -> None:
|
||||
base = _FIXTURE.read_text(encoding="utf-8")
|
||||
cases = (
|
||||
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'selected="maybe" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||
base.replace('bounds="[126,1000][438,1172]"', 'bounds="[1,1][20,20]"'),
|
||||
base.replace('enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'enabled="false" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value=None),
|
||||
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value="maybe"),
|
||||
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="bounds", value="[1,1][20,20]"),
|
||||
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="enabled", value="false"),
|
||||
)
|
||||
for hierarchy in cases:
|
||||
with self.subTest(), self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(hierarchy), 10)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
|
||||
def test_unknown_clickable_action_ancestor_is_rejected_by_profile(self) -> None:
|
||||
root = ElementTree.fromstring(_EMPTY_FIXTURE.read_text(encoding="utf-8"))
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
target = next(
|
||||
node
|
||||
for node in root.iter("node")
|
||||
if node.get("content-desc") == "黑色 CHA (纯棉)"
|
||||
and node.get("class") == "android.view.ViewGroup"
|
||||
)
|
||||
region = parents[target]
|
||||
region.remove(target)
|
||||
unknown = ElementTree.SubElement(
|
||||
region,
|
||||
"node",
|
||||
{
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"class": "android.view.ViewGroup",
|
||||
"bounds": "[0,1188][1080,2046]",
|
||||
"clickable": "true",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
},
|
||||
)
|
||||
unknown.append(target)
|
||||
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
_classify_panel(_parse_nodes(ElementTree.tostring(root, encoding="unicode")))
|
||||
|
||||
def test_invalid_bounds_stop_before_action(self) -> None:
|
||||
for bounds in ("", "[1,2][1,3]", "[1,2][3,2]", "[0,0][1081,1]", "[0,0][1,2377]", "[a,0][1,1]"):
|
||||
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
|
||||
@@ -522,10 +593,9 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
device.fail_color_readback = True
|
||||
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
device.select_alternates()
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218), (282, 1086)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
|
||||
|
||||
def test_non_target_selection_restores_each_dimension_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
@@ -535,7 +605,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(
|
||||
_tap_centers(device),
|
||||
[(865, 2218), (282, 1086), (635, 1772)],
|
||||
[(865, 2218), (635, 1624)],
|
||||
)
|
||||
|
||||
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
|
||||
@@ -547,9 +617,12 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
|
||||
clickable_parent = _FIXTURE.read_text(encoding="utf-8").replace(
|
||||
'<node package="" class="android.view.ViewGroup" bounds="[396,498][895,570]">',
|
||||
'<node package="" class="android.view.ViewGroup" clickable="true" bounds="[396,498][895,570]">',
|
||||
clickable_parent = _mutate_unique_node(
|
||||
_FIXTURE.read_text(encoding="utf-8"),
|
||||
class_name="android.widget.LinearLayout",
|
||||
bounds="[396,498][895,570]",
|
||||
attribute="clickable",
|
||||
value="true",
|
||||
)
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(clickable_parent), 10)).read_sku_unit_price()
|
||||
@@ -566,6 +639,8 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection.py",
|
||||
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py",
|
||||
root / "scripts" / "run_t103_sku_selection.py",
|
||||
root / "src" / "cmbuyer_client" / "pdd" / "sku_reveal_spike.py",
|
||||
root / "scripts" / "capture_sku_reveal_spike.py",
|
||||
)
|
||||
forbidden = ("quantity", "confirm", "authorization", "fence", "submit_order", "payment")
|
||||
for path in files:
|
||||
@@ -606,31 +681,56 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
|
||||
for fixture in (_FIXTURE, _ENTRY_FIXTURE):
|
||||
for fixture in (
|
||||
_EMPTY_FIXTURE,
|
||||
_COLOR_FIXTURE,
|
||||
_S_FIXTURE,
|
||||
_M_FIXTURE,
|
||||
_ENTRY_FIXTURE,
|
||||
):
|
||||
content = fixture.read_text(encoding="utf-8")
|
||||
with self.subTest(fixture=fixture.name):
|
||||
self.assertNotRegex(content, r"1[3-9]\d{9}")
|
||||
for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
|
||||
self.assertNotIn(forbidden, content)
|
||||
content = _FIXTURE.read_text(encoding="utf-8")
|
||||
root = ElementTree.fromstring(content)
|
||||
leaf = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥12.88")
|
||||
self.assertEqual(leaf.get("clickable"), "false")
|
||||
self.assertEqual(leaf.get("bounds"), "[369,2225][710,2284]")
|
||||
self.assertNotIn("提交订单", content)
|
||||
|
||||
|
||||
class _CompletedFlow:
|
||||
"""仅隔离 runner 文件发布测试;生产 Flow 在 reveal 取证前仍必须停止。"""
|
||||
|
||||
def __init__(self, device: object, *args: object, **kwargs: object) -> None:
|
||||
self.device = device
|
||||
|
||||
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
|
||||
return None
|
||||
|
||||
def select_sku_options(self, selection: object) -> None:
|
||||
return None
|
||||
|
||||
def verify_target_selection_and_read_price(self, selection: object) -> str:
|
||||
return "12.88"
|
||||
|
||||
def exit_sku_panel_safely(self) -> None:
|
||||
return None
|
||||
|
||||
def reconcile_pending_action(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
|
||||
device.hierarchy = "<hierarchy />"
|
||||
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
||||
return SkuSelectionRunner(adb, lambda serial: device, 10)
|
||||
return SkuSelectionRunner(adb, lambda serial: device, 0.03)
|
||||
|
||||
def test_runner_atomically_publishes_screenshot_and_redacted_manifest(self) -> None:
|
||||
adb = _FakeAdb()
|
||||
device = _RawDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "result"
|
||||
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
with patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow):
|
||||
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
|
||||
self.assertEqual(result.unit_price, "12.88")
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
@@ -643,7 +743,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
self.assertIn('"panel_status": "verified"', manifest)
|
||||
self.assertIn('"safe_exit": "completed"', manifest)
|
||||
self.assertFalse((target / "hierarchy.xml").exists())
|
||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
def test_failure_stage_is_fixed_control_flow_metadata_without_error_text(self) -> None:
|
||||
class NoPanelAfterEntry(_RawDevice):
|
||||
@@ -729,7 +829,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
(Path(destination) / "sentinel").write_text("keep", encoding="utf-8")
|
||||
original_rename(source, destination)
|
||||
|
||||
with patch.object(runner_module.os, "rename", side_effect=create_target_then_rename), self.assertRaises(SkuSelectionRunError):
|
||||
with (
|
||||
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
|
||||
patch.object(runner_module.os, "rename", side_effect=create_target_then_rename),
|
||||
self.assertRaises(SkuSelectionRunError),
|
||||
):
|
||||
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertEqual((target / "sentinel").read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||
@@ -737,14 +841,20 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "result"
|
||||
with self.assertRaises(SkuSelectionScreenshotError) as screenshot_failure:
|
||||
with (
|
||||
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
|
||||
self.assertRaises(SkuSelectionScreenshotError) as screenshot_failure,
|
||||
):
|
||||
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertEqual(safe_failure_stage(screenshot_failure.exception), "screenshot_capture")
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||
|
||||
target = Path(temporary) / "write-failure"
|
||||
with patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")):
|
||||
with (
|
||||
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
|
||||
patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")),
|
||||
):
|
||||
with self.assertRaises(SkuSelectionScreenshotError):
|
||||
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertFalse(target.exists())
|
||||
@@ -792,7 +902,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
def test_small_but_valid_png_is_not_accepted(self) -> None:
|
||||
image = Image.new("RGB", (1, 1), "white")
|
||||
raw = BytesIO(); image.save(raw, format="PNG")
|
||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionScreenshotError):
|
||||
with (
|
||||
TemporaryDirectory() as temporary,
|
||||
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
|
||||
self.assertRaises(SkuSelectionScreenshotError),
|
||||
):
|
||||
self._runner(_FakeAdb(), _RawDevice(screenshot=base64.b64encode(raw.getvalue()).decode("ascii"))).run(
|
||||
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
|
||||
)
|
||||
@@ -907,14 +1021,8 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).exit_sku_panel_safely()
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
def test_screenshot_then_foreground_drift_publishes_nothing_and_never_back(self) -> None:
|
||||
class ForegroundDriftDevice(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot": self.package = "other"
|
||||
return value
|
||||
|
||||
device = ForegroundDriftDevice()
|
||||
def test_unproven_reveal_publishes_nothing_and_safely_exits_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "out"
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
@@ -922,7 +1030,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
self.assertFalse(target.exists())
|
||||
self.assertFalse((target / "manifest.json").exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
|
||||
def test_option_timeout_reconciliation_controls_back_once(self) -> None:
|
||||
class OptionTimeoutDevice(_RawDevice):
|
||||
@@ -939,7 +1047,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
|
||||
for delivered, expected_back in ((False, 0), (True, 1)):
|
||||
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
|
||||
device = OptionTimeoutDevice(delivered); device.select_alternates()
|
||||
device = OptionTimeoutDevice(delivered)
|
||||
adb = _FakeAdb(); device.hierarchy = "<hierarchy />"
|
||||
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
||||
runner = SkuSelectionRunner(adb, lambda serial: device, .03)
|
||||
@@ -956,9 +1064,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
raise TimeoutError("back uncertain")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
device = BackTimeoutDevice()
|
||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
|
||||
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
|
||||
device = BackTimeoutDevice(_M_FIXTURE.read_text(encoding="utf-8"))
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 0.03)).exit_sku_panel_safely()
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
|
||||
def test_entry_click_timeout_reconciles_only_through_verified_flow_exit(self) -> None:
|
||||
|
||||
+5
-1
@@ -25,7 +25,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T03:44:41Z sha256=cd788a2ff47d4c0c68c8119539f358d4ec03002ddce9f188dcab4de8320c0b4e -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T04:34:06Z sha256=6e089363c4d47d10e02c1af12cebfb57a40dec4d9d04459c6438c9b5aa0987ba -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:详情页底部购买区第一行“快要抢光 + 金额”是唯一获准的受控规格入口;商品内容区同名小字和第二行“免拼购买”都不得点击。
|
||||
@@ -270,6 +270,10 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
||||
### 2026-08-05T03:44:34Z · ila
|
||||
|
||||
2026-08-05 reveal 取证方案收口:不改造只读 capture_sku_panel_spike.py,也不把未经验证的滑动放进生产 SkuSelectionFlow。新增独立 capture_sku_reveal_spike.py:无颜色/尺码/坐标/手势参数,只在精确 COLOR_SELECTED_SIZE_HIDDEN 前置成立后沿已取证列间安全通道发出一次固定手势;发出前封存,RPC 正常/超时/异常后均只读调和且绝不重试。后置必须颜色仍选中、摘要仍请选择尺码、S/M 可见且全部 selected=false、未触发提交区;成功后不 Back,由人核对 before/after 后才允许固化生产 reveal 判据。T-103 继续 Doing。
|
||||
|
||||
### 2026-08-05T04:33:56Z · ila
|
||||
|
||||
2026-08-05 T-103 reveal 离线实现与终审通过:新增独立 `capture_sku_reveal_spike.py`,只在拼多多 8.17.0 / PKG110 / Android 16 / 1080×2376、goods_id `937122477375`、完整 `COLOR_SELECTED_SIZE_HIDDEN` 前置两次重证后,封存并发出唯一一次固定 reveal 手势;RPC 正常或结果不明均只读调和且绝不重试。后置要求目标颜色保持精确 selected、S/M 均可见且 selected=false、摘要仍为“请选择:尺码”、提交硬拒绝区仍在面板下方;成功不 Back,等待人工核对。四态 classifier 已绑定 A/B/S/M 真机证据的完整父链与 exact selected set;未知 clickable 祖先、窄浮层、SystemUI 同 bounds、坏 bounds 均失败关闭。独立终审 PASS;聚焦 55/55、client 全量 255/255、compileall、agent-context、diff-check 全部 PASS。未引用数量、确认页、围栏、提交或支付能力。`needs_device=true`,T-103 保持 Doing,等待项目所有者从拼多多首页、仅 Wi-Fi ADB、使用全新 output-dir 执行唯一一次真机 reveal 并人工确认 before/after。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
Reference in New Issue
Block a user