875 lines
34 KiB
Python
875 lines
34 KiB
Python
"""T-103 真机运行边界:窄适配器、原始截图和无页面正文的摘要。"""
|
|
|
|
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
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from PIL import Image, UnidentifiedImageError
|
|
|
|
from adbutils.errors import AdbTimeout
|
|
from uiautomator2.exceptions import HTTPTimeoutError
|
|
|
|
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
|
from ..device.baseline import (
|
|
PDD_PACKAGE,
|
|
SCREENSHOT_PARAMS,
|
|
_save_base64_screenshot,
|
|
_sha256_file,
|
|
_validate_hierarchy,
|
|
)
|
|
from .product_open import EXPECTED_PDD_VERSION
|
|
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
|
from .sku_selection import (
|
|
EXPECTED_GOODS_ID,
|
|
EXPECTED_UNIT_PRICE,
|
|
SkuPanelDevice,
|
|
SkuSelectionError,
|
|
SkuSelectionFlow,
|
|
_SKU_ENTRY_FAILURE_STAGES,
|
|
_REVEAL_GESTURE,
|
|
_ENTRY_TEXT_BOUNDS,
|
|
_TARGET_COLOR_UNROLLED_BOUNDS,
|
|
_TARGET_SIZE_BOUNDS,
|
|
_action_bounds,
|
|
_annotate_sku_entry_failure,
|
|
_parse_nodes,
|
|
_safe_sku_entry_failure_stage,
|
|
_unit_price,
|
|
resolve_task_selection,
|
|
)
|
|
|
|
|
|
EXPECTED_DEVICE_MODEL = "PKG110"
|
|
EXPECTED_ANDROID_VERSION = "16"
|
|
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
|
|
|
# CLI 只允许输出这些固定阶段码。阶段码描述运行器自己的控制流,不包含页面
|
|
# 文本、节点属性、serial、路径或第三方异常;未知/伪造值统一降级为 unknown。
|
|
_FAILURE_STAGES = frozenset(
|
|
(
|
|
"precheck",
|
|
"device_inspection",
|
|
"device_session",
|
|
"product_open",
|
|
"sku_entry",
|
|
*_SKU_ENTRY_FAILURE_STAGES,
|
|
"sku_selection",
|
|
"price_verification",
|
|
"screenshot_capture",
|
|
"screenshot_reverify",
|
|
"safe_exit",
|
|
"publish",
|
|
)
|
|
)
|
|
|
|
|
|
class SkuSelectionRunError(RuntimeError):
|
|
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
|
|
|
|
|
|
class SkuSelectionRunTimeoutError(SkuSelectionRunError):
|
|
"""设备 RPC 或操作超时。"""
|
|
|
|
|
|
class SkuSelectionScreenshotError(SkuSelectionRunError):
|
|
"""原始截图无法作为完整 PNG 原子发布。"""
|
|
|
|
|
|
class SkuSelectionUnexpectedPriceError(SkuSelectionRunError):
|
|
"""取证面板现价不是本任务已确认值。"""
|
|
|
|
|
|
class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
|
|
"""第三方设备接口失败的脱敏映射。"""
|
|
|
|
|
|
class SkuExitSpikeError(SkuSelectionRunError):
|
|
"""T-104 阶段 A 未形成完整的本机退出证据。"""
|
|
|
|
|
|
def safe_failure_stage(error: BaseException) -> str:
|
|
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
|
|
|
|
try:
|
|
stage = getattr(error, "_cmbuyer_failure_stage", None)
|
|
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
|
|
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。
|
|
if type(stage) is not str or stage not in _FAILURE_STAGES:
|
|
return "unknown"
|
|
if stage in _SKU_ENTRY_FAILURE_STAGES:
|
|
return stage if _safe_sku_entry_failure_stage(error) == stage else "unknown"
|
|
return stage
|
|
except BaseException:
|
|
return "unknown"
|
|
|
|
|
|
def _annotate_failure(error: BaseException, stage: str) -> None:
|
|
"""只给本次异常附加白名单控制流事实;原异常文本仍不对外输出。"""
|
|
|
|
safe_stage = stage if stage in _FAILURE_STAGES else "unknown"
|
|
try:
|
|
setattr(error, "_cmbuyer_failure_stage", safe_stage)
|
|
except BaseException:
|
|
# 极端第三方异常不允许写属性时仍保持原失败闭合语义。
|
|
pass
|
|
|
|
|
|
def _failure_stage_for(error: BaseException, runner_stage: str) -> str:
|
|
if runner_stage == "sku_entry":
|
|
flow_stage = _safe_sku_entry_failure_stage(error)
|
|
if flow_stage is not None:
|
|
return flow_stage
|
|
return runner_stage
|
|
|
|
|
|
def _annotate_mapped_failure(mapped: BaseException, source: BaseException, runner_stage: str) -> None:
|
|
stage = _failure_stage_for(source, runner_stage)
|
|
if stage in _SKU_ENTRY_FAILURE_STAGES:
|
|
_annotate_sku_entry_failure(mapped, stage)
|
|
_annotate_failure(mapped, stage)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SkuSelectionRunResult:
|
|
"""已发布的截图和无页面正文 manifest 摘要。"""
|
|
|
|
output_directory: Path
|
|
screenshot_path: Path
|
|
manifest_path: Path
|
|
unit_price: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SkuExitSpikeResult:
|
|
"""已原子发布、仍需人工判断页面身份的 T-104 证据。"""
|
|
|
|
output_directory: Path
|
|
manifest_path: Path
|
|
|
|
|
|
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
|
"""把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。
|
|
|
|
四个命名方法是仅有的状态改变入口;reveal 的手势参数固定且不向 Flow 暴露;
|
|
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
|
|
"""
|
|
|
|
def __init__(self, device: Any, timeout_seconds: float) -> None:
|
|
if not _is_positive_finite(timeout_seconds):
|
|
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
|
self._device = device
|
|
self._timeout_seconds = timeout_seconds
|
|
self._entry_was_tapped = False
|
|
self._entry_bounds_attempted: str | None = None
|
|
self._entry_rpc_outcome = "not_attempted"
|
|
self._option_bounds_attempted: set[str] = set()
|
|
self._option_rpc_outcomes: dict[str, str] = {}
|
|
self._reveal_attempted = False
|
|
self._reveal_rpc_outcome = "not_attempted"
|
|
self._left_panel = False
|
|
self._back_rpc_outcome = "not_attempted"
|
|
|
|
@property
|
|
def entry_was_tapped(self) -> bool:
|
|
"""仅供运行器决定故障后的单次尽力返回,不是页面操作。"""
|
|
|
|
return self._entry_was_tapped
|
|
|
|
@property
|
|
def left_panel(self) -> bool:
|
|
return self._left_panel
|
|
|
|
@property
|
|
def reveal_attempted(self) -> bool:
|
|
return self._reveal_attempted
|
|
|
|
@property
|
|
def entry_rpc_outcome(self) -> str:
|
|
return self._entry_rpc_outcome
|
|
|
|
@property
|
|
def entry_bounds_attempted(self) -> str | None:
|
|
return self._entry_bounds_attempted
|
|
|
|
@property
|
|
def option_rpc_outcomes(self) -> tuple[tuple[str, str], ...]:
|
|
return tuple(sorted(self._option_rpc_outcomes.items()))
|
|
|
|
@property
|
|
def reveal_rpc_outcome(self) -> str:
|
|
return self._reveal_rpc_outcome
|
|
|
|
@property
|
|
def option_attempts(self) -> int:
|
|
return len(self._option_bounds_attempted)
|
|
|
|
@property
|
|
def back_attempts(self) -> int:
|
|
return int(self._left_panel)
|
|
|
|
@property
|
|
def back_rpc_outcome(self) -> str:
|
|
return self._back_rpc_outcome
|
|
|
|
def app_info(self, package_name: str) -> dict[str, Any]:
|
|
value = self._call("app_info", package_name)
|
|
if not isinstance(value, dict):
|
|
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止操作。")
|
|
return value
|
|
|
|
def app_current(self) -> dict[str, Any]:
|
|
value = self._call("app_current")
|
|
if not isinstance(value, dict):
|
|
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止操作。")
|
|
return value
|
|
|
|
def dump_window_hierarchy(self) -> str:
|
|
value = self._call("jsonrpc_call", "dumpWindowHierarchy", [False, 50], timeout=self._timeout_seconds)
|
|
if not isinstance(value, str):
|
|
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止操作。")
|
|
return value
|
|
|
|
def tap_sku_entry(self, bounds: str) -> None:
|
|
if self._entry_was_tapped:
|
|
raise SkuSelectionDeviceAdapterError("规格入口已经尝试过,拒绝重试。")
|
|
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
|
|
self._entry_was_tapped = True
|
|
self._entry_bounds_attempted = bounds
|
|
self._entry_rpc_outcome = "ambiguous"
|
|
self._tap_bounds_once(bounds)
|
|
self._entry_rpc_outcome = "completed"
|
|
|
|
def tap_sku_option(self, bounds: str) -> None:
|
|
if bounds in self._option_bounds_attempted:
|
|
raise SkuSelectionDeviceAdapterError("同一规格选项已经尝试过,拒绝重试。")
|
|
# 规格 RPC 也可能送达后超时;按 exact bounds 封存本次唯一机会。
|
|
self._option_bounds_attempted.add(bounds)
|
|
self._option_rpc_outcomes[bounds] = "ambiguous"
|
|
self._tap_bounds_once(bounds)
|
|
self._option_rpc_outcomes[bounds] = "completed"
|
|
|
|
def reveal_size_options_once(self) -> None:
|
|
if self._reveal_attempted:
|
|
raise SkuSelectionDeviceAdapterError("规格显示动作已经尝试过,拒绝重试。")
|
|
# 手势唯一机会在 RPC 前封存;生产 API 不接受方向、坐标或步数参数。
|
|
self._reveal_attempted = True
|
|
self._reveal_rpc_outcome = "ambiguous"
|
|
self._call(
|
|
"jsonrpc_call",
|
|
"swipe",
|
|
list(_REVEAL_GESTURE),
|
|
timeout=self._timeout_seconds,
|
|
)
|
|
self._reveal_rpc_outcome = "completed"
|
|
|
|
def leave_sku_panel(self) -> None:
|
|
if self._left_panel:
|
|
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
|
|
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
|
|
self._left_panel = True
|
|
self._back_rpc_outcome = "ambiguous"
|
|
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
|
|
self._back_rpc_outcome = "completed"
|
|
|
|
def capture_screenshot(self) -> str:
|
|
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
|
|
if not isinstance(value, str):
|
|
raise SkuSelectionScreenshotError("规格面板原始截图读取失败,未发布任何证据产物。")
|
|
return value
|
|
|
|
def display_size(self) -> tuple[int, int]:
|
|
value = self._call("window_size")
|
|
if not isinstance(value, tuple) or len(value) != 2 or any(not isinstance(item, int) for item in value):
|
|
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止操作。")
|
|
return value
|
|
|
|
def _tap_bounds_once(self, bounds: str) -> None:
|
|
left, top, right, bottom = _action_bounds(bounds)
|
|
center_x = left + (right - left) // 2
|
|
center_y = top + (bottom - top) // 2
|
|
self._call("jsonrpc_call", "click", [center_x, center_y], timeout=self._timeout_seconds)
|
|
|
|
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
|
try:
|
|
operation = getattr(self._device, method)
|
|
return operation(*args, **kwargs)
|
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
|
raise SkuSelectionRunTimeoutError("规格面板设备操作超时,已停止操作。") from error
|
|
except SkuSelectionRunError:
|
|
raise
|
|
except Exception as error:
|
|
raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
|
|
|
|
|
|
class UiautomatorSkuExitAdapter:
|
|
"""T-104 阶段 A 的窄设备边界:只读能力加唯一一次命名 Back。
|
|
|
|
取证脚本不能取得 T-103 的入口、规格选项或 reveal 方法。Back 的唯一机会在
|
|
JSON-RPC 前封存,因为超时无法证明事件没有送达,任何结果不明都不得重发。
|
|
"""
|
|
|
|
def __init__(self, device: Any, timeout_seconds: float) -> None:
|
|
if not _is_positive_finite(timeout_seconds):
|
|
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
|
self._device = device
|
|
self._timeout_seconds = timeout_seconds
|
|
self._back_attempted = False
|
|
self._back_rpc_outcome = "not_attempted"
|
|
|
|
@property
|
|
def back_attempts(self) -> int:
|
|
return int(self._back_attempted)
|
|
|
|
@property
|
|
def back_rpc_outcome(self) -> str:
|
|
return self._back_rpc_outcome
|
|
|
|
def app_info(self, package_name: str) -> dict[str, Any]:
|
|
value = self._call("app_info", package_name)
|
|
if not isinstance(value, dict):
|
|
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止取证。")
|
|
return value
|
|
|
|
def app_current(self) -> dict[str, Any]:
|
|
value = self._call("app_current")
|
|
if not isinstance(value, dict):
|
|
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止取证。")
|
|
return value
|
|
|
|
def dump_window_hierarchy(self) -> str:
|
|
value = self._call(
|
|
"jsonrpc_call",
|
|
"dumpWindowHierarchy",
|
|
[False, 50],
|
|
timeout=self._timeout_seconds,
|
|
)
|
|
if not isinstance(value, str):
|
|
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止取证。")
|
|
return value
|
|
|
|
def capture_screenshot(self) -> str:
|
|
value = self._call(
|
|
"jsonrpc_call",
|
|
"takeScreenshot",
|
|
SCREENSHOT_PARAMS,
|
|
timeout=self._timeout_seconds,
|
|
)
|
|
if not isinstance(value, str):
|
|
raise SkuSelectionScreenshotError("退出后截图读取失败,未发布任何证据产物。")
|
|
return value
|
|
|
|
def display_size(self) -> tuple[int, int]:
|
|
value = self._call("window_size")
|
|
if (
|
|
not isinstance(value, tuple)
|
|
or len(value) != 2
|
|
or any(not isinstance(item, int) for item in value)
|
|
):
|
|
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止取证。")
|
|
return value
|
|
|
|
def leave_sku_panel(self) -> None:
|
|
if self._back_attempted:
|
|
raise SkuSelectionDeviceAdapterError("本次取证已经尝试过返回,拒绝重试。")
|
|
self._back_attempted = True
|
|
self._back_rpc_outcome = "ambiguous"
|
|
self._call(
|
|
"jsonrpc_call",
|
|
"pressKey",
|
|
["back"],
|
|
timeout=self._timeout_seconds,
|
|
)
|
|
self._back_rpc_outcome = "completed"
|
|
|
|
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
|
try:
|
|
operation = getattr(self._device, method)
|
|
return operation(*args, **kwargs)
|
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
|
raise SkuSelectionRunTimeoutError("T-104 设备操作超时,已停止操作。") from error
|
|
except SkuSelectionRunError:
|
|
raise
|
|
except Exception as error:
|
|
raise SkuSelectionDeviceAdapterError("T-104 设备操作失败,已停止操作。") from error
|
|
|
|
|
|
class SkuExitSpikeCapturer:
|
|
"""从人工停驻的已验证目标面板执行一次 Back,再只读采集阶段 A 证据。"""
|
|
|
|
def __init__(
|
|
self,
|
|
adb_client: AdbClient,
|
|
connector: Callable[[str], Any],
|
|
timeout_seconds: float,
|
|
) -> None:
|
|
if not _is_positive_finite(timeout_seconds):
|
|
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
|
self._adb_client = adb_client
|
|
self._connector = connector
|
|
self._timeout_seconds = timeout_seconds
|
|
self._started = False
|
|
|
|
def capture(self, serial: str, output_directory: Path) -> SkuExitSpikeResult:
|
|
if self._started:
|
|
raise SkuExitSpikeError("同一取证器不可重复调用。")
|
|
self._started = True
|
|
|
|
staging: Path | None = None
|
|
adapter: UiautomatorSkuExitAdapter | None = None
|
|
try:
|
|
if type(serial) is not str or not serial.strip():
|
|
raise SkuExitSpikeError("必须显式提供非空设备通道。")
|
|
target = Path(output_directory)
|
|
_validate_new_target(target)
|
|
staging = _prepare_staging(target)
|
|
|
|
inspection = self._adb_client.inspect(serial)
|
|
_require_expected_device(inspection)
|
|
adapter = UiautomatorSkuExitAdapter(
|
|
self._connector(serial),
|
|
self._timeout_seconds,
|
|
)
|
|
|
|
# 第一次只读核验拒绝把任意页面带入 Back 边界;第二次紧邻 Back,覆盖核验期间漂移。
|
|
_require_t104_exit_precondition(adapter)
|
|
_require_t104_exit_precondition(adapter)
|
|
|
|
rpc_outcome = "completed"
|
|
try:
|
|
adapter.leave_sku_panel()
|
|
except SkuSelectionRunError:
|
|
if adapter.back_attempts != 1 or adapter.back_rpc_outcome != "ambiguous":
|
|
raise
|
|
# RPC 失败不能证明 Back 未送达。只读采集可供人调和,但绝不再发第二次动作。
|
|
rpc_outcome = "ambiguous_reconciled"
|
|
|
|
if adapter.back_attempts != 1:
|
|
raise SkuExitSpikeError("返回动作审计不完整,未发布任何证据产物。")
|
|
if rpc_outcome == "completed" and adapter.back_rpc_outcome != "completed":
|
|
raise SkuExitSpikeError("返回动作结果不完整,未发布任何证据产物。")
|
|
|
|
# Back 后先确认仍是已取证版本的 PDD;不能先把其他前台应用写进本地证据。
|
|
initial_app = _require_t104_post_app(adapter)
|
|
|
|
screenshot_path = staging / "post_exit_screenshot.png"
|
|
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
|
_require_screenshot_size(screenshot_path)
|
|
|
|
hierarchy = adapter.dump_window_hierarchy()
|
|
_validate_hierarchy(hierarchy)
|
|
hierarchy_path = staging / "post_exit_hierarchy.xml"
|
|
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
|
|
|
current_app = _require_t104_post_app(adapter)
|
|
if current_app != initial_app:
|
|
raise SkuExitSpikeError("退出后应用摘要在采集期间漂移,未发布任何证据产物。")
|
|
app_path = staging / "post_exit_app.json"
|
|
app_path.write_text(
|
|
json.dumps(current_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manifest_path = staging / "manifest.json"
|
|
manifest_path.write_text(
|
|
json.dumps(
|
|
_t104_exit_manifest(
|
|
inspection,
|
|
serial,
|
|
rpc_outcome,
|
|
screenshot_path,
|
|
hierarchy_path,
|
|
app_path,
|
|
),
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
os.rename(staging, target)
|
|
staging = None
|
|
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError):
|
|
_clean_staging(staging)
|
|
raise
|
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
|
_clean_staging(staging)
|
|
raise SkuExitSpikeError("T-104 取证超时,未发布任何证据产物。") from error
|
|
except OSError as error:
|
|
_clean_staging(staging)
|
|
raise SkuExitSpikeError("T-104 证据目录无法发布,未发布任何证据产物。") from error
|
|
except Exception as error:
|
|
_clean_staging(staging)
|
|
raise SkuExitSpikeError("T-104 取证未完成,未发布任何证据产物。") from error
|
|
|
|
return SkuExitSpikeResult(target, target / "manifest.json")
|
|
|
|
|
|
class SkuSelectionRunner:
|
|
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
|
|
|
|
def __init__(
|
|
self,
|
|
adb_client: AdbClient,
|
|
connector: Callable[[str], Any],
|
|
timeout_seconds: float,
|
|
monotonic_clock: Callable[[], float] = monotonic,
|
|
) -> None:
|
|
if not _is_positive_finite(timeout_seconds):
|
|
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
|
self._adb_client = adb_client
|
|
self._connector = connector
|
|
self._timeout_seconds = timeout_seconds
|
|
self._monotonic_clock = monotonic_clock
|
|
|
|
def run(
|
|
self,
|
|
serial: str,
|
|
product_url: str,
|
|
task_color: str,
|
|
task_size: str,
|
|
output_directory: Path,
|
|
) -> SkuSelectionRunResult:
|
|
stage = "precheck"
|
|
staging: Path | None = None
|
|
adapter: UiautomatorSkuPanelAdapter | None = None
|
|
flow: SkuSelectionFlow | None = None
|
|
try:
|
|
link = parse_product_url(product_url)
|
|
if link.goods_id != EXPECTED_GOODS_ID:
|
|
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
|
selection = resolve_task_selection(task_color, task_size)
|
|
target = Path(output_directory)
|
|
_validate_new_target(target)
|
|
staging = _prepare_staging(target)
|
|
deadline = self._monotonic_clock() + self._timeout_seconds
|
|
|
|
stage = "device_inspection"
|
|
inspection = self._adb_client.inspect(serial)
|
|
_require_expected_device(inspection)
|
|
|
|
stage = "device_session"
|
|
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
|
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
|
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
|
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
|
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
|
|
|
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
|
stage = "product_open"
|
|
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
|
|
|
remaining = deadline - self._monotonic_clock()
|
|
if remaining <= 0:
|
|
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
|
|
|
stage = "sku_entry"
|
|
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
|
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
|
|
|
stage = "sku_selection"
|
|
flow.select_sku_options(selection)
|
|
|
|
stage = "price_verification"
|
|
unit_price = flow.verify_target_selection_and_read_price(selection)
|
|
if unit_price != EXPECTED_UNIT_PRICE:
|
|
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
|
|
|
stage = "screenshot_capture"
|
|
screenshot_path = staging / "screenshot.png"
|
|
try:
|
|
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
|
_require_screenshot_size(screenshot_path)
|
|
except SkuSelectionRunError:
|
|
raise
|
|
except Exception as error:
|
|
raise SkuSelectionScreenshotError("规格面板原始截图保存失败,未发布任何证据产物。") from error
|
|
|
|
manifest_path = staging / "manifest.json"
|
|
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
|
stage = "screenshot_reverify"
|
|
final_price = flow.verify_target_selection_and_read_price(selection)
|
|
if final_price != EXPECTED_UNIT_PRICE:
|
|
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
|
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
|
stage = "safe_exit"
|
|
flow.exit_sku_panel_safely()
|
|
|
|
_require_completed_action_audit(adapter)
|
|
|
|
stage = "publish"
|
|
manifest_path.write_text(
|
|
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size, adapter), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
|
os.rename(staging, target)
|
|
staging = None
|
|
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
|
|
_clean_staging(staging)
|
|
_annotate_failure(error, _failure_stage_for(error, stage))
|
|
raise
|
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
|
_clean_staging(staging)
|
|
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
|
|
_annotate_mapped_failure(mapped, error, stage)
|
|
raise mapped from error
|
|
except OSError as error:
|
|
_clean_staging(staging)
|
|
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
|
|
_annotate_mapped_failure(mapped, error, stage)
|
|
raise mapped from error
|
|
except Exception as error:
|
|
_clean_staging(staging)
|
|
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
|
|
_annotate_mapped_failure(mapped, error, stage)
|
|
raise mapped from error
|
|
finally:
|
|
# 结果不明只允许读回 pending;失败路径绝不继续后续动作或自动 Back。
|
|
if flow is not None:
|
|
try:
|
|
flow.reconcile_pending_action()
|
|
except (SkuSelectionRunError, SkuSelectionError):
|
|
pass
|
|
|
|
return SkuSelectionRunResult(
|
|
output_directory=target,
|
|
screenshot_path=target / "screenshot.png",
|
|
manifest_path=target / "manifest.json",
|
|
unit_price=EXPECTED_UNIT_PRICE,
|
|
)
|
|
|
|
|
|
def _is_positive_finite(value: object) -> bool:
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
|
|
|
|
|
def _validate_new_target(target: Path) -> None:
|
|
if target.exists():
|
|
raise SkuSelectionRunError("输出目录已存在;为防止覆盖旧证据,已停止操作。")
|
|
if not target.name:
|
|
raise SkuSelectionRunError("输出目录必须是明确的新目录。")
|
|
|
|
|
|
def _prepare_staging(target: Path) -> Path:
|
|
staging: Path | None = None
|
|
try:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
|
staging.mkdir()
|
|
probe = staging / ".write-probe"
|
|
probe.write_bytes(b"ok")
|
|
probe.unlink()
|
|
return staging
|
|
except OSError as error:
|
|
_clean_staging(staging)
|
|
raise SkuSelectionRunError("输出目录不可写,已停止操作。") from error
|
|
|
|
|
|
def _clean_staging(staging: Path | None) -> None:
|
|
if staging is not None and staging.exists():
|
|
shutil.rmtree(staging)
|
|
|
|
|
|
def _require_expected_version(app_info: object) -> str:
|
|
version = (app_info.get("versionName") or app_info.get("version_name")) if isinstance(app_info, dict) else None
|
|
if version != EXPECTED_PDD_VERSION:
|
|
raise SkuSelectionRunError("拼多多版本与已取证版本不一致,已停止操作。")
|
|
return version
|
|
|
|
|
|
def _require_expected_device(inspection: DeviceInspection) -> None:
|
|
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
|
raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
|
|
|
|
|
|
def _require_t104_exit_precondition(adapter: UiautomatorSkuExitAdapter) -> None:
|
|
"""fresh 核验 T-103 已人验的唯一 Back 前置,不产生任何页面动作。"""
|
|
|
|
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
|
current = adapter.app_current()
|
|
if current.get("package") != PDD_PACKAGE:
|
|
raise SkuExitSpikeError("Back 前拼多多不在前台,已停止取证。")
|
|
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
|
raise SkuExitSpikeError("屏幕坐标空间不是已取证尺寸,已停止取证。")
|
|
if _unit_price(_parse_nodes(adapter.dump_window_hierarchy())) != EXPECTED_UNIT_PRICE:
|
|
raise SkuExitSpikeError("Back 前目标规格或当前价不匹配,已停止取证。")
|
|
|
|
|
|
def _post_exit_app_evidence(value: object) -> dict[str, str]:
|
|
"""只保留页面身份复核需要的应用字段,不把第三方返回整体写盘。"""
|
|
|
|
if not isinstance(value, dict):
|
|
raise SkuExitSpikeError("退出后应用摘要无效,未发布任何证据产物。")
|
|
package = value.get("package")
|
|
activity = value.get("activity")
|
|
if not isinstance(package, str) or not package.strip():
|
|
raise SkuExitSpikeError("退出后应用包名缺失,未发布任何证据产物。")
|
|
if not isinstance(activity, str) or not activity.strip():
|
|
raise SkuExitSpikeError("退出后 Activity 缺失,未发布任何证据产物。")
|
|
return {"package": package, "activity": activity}
|
|
|
|
|
|
def _require_t104_post_app(adapter: UiautomatorSkuExitAdapter) -> dict[str, str]:
|
|
current = _post_exit_app_evidence(adapter.app_current())
|
|
if current["package"] != PDD_PACKAGE:
|
|
raise SkuExitSpikeError("Back 后拼多多不在前台,未采集页面证据。")
|
|
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
|
return current
|
|
|
|
|
|
def _t104_exit_manifest(
|
|
inspection: DeviceInspection,
|
|
serial: str,
|
|
rpc_outcome: str,
|
|
screenshot_path: Path,
|
|
hierarchy_path: Path,
|
|
app_path: Path,
|
|
) -> dict[str, Any]:
|
|
"""阶段 A 只写事实与哈希;不声明退出成功,也不授予价格证据角色。"""
|
|
|
|
return {
|
|
"schema_version": 1,
|
|
"captured_at": datetime.now(UTC).isoformat(),
|
|
"operation": "t104-sku-exit-evidence",
|
|
"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": EXPECTED_PDD_VERSION,
|
|
},
|
|
"back_attempts": 1,
|
|
"rpc_outcome": rpc_outcome,
|
|
"post_exit_status": "human_review_required",
|
|
"artifacts": [
|
|
{
|
|
"path": screenshot_path.name,
|
|
"role": "post_exit_human_review_only",
|
|
"sha256": _sha256_file(screenshot_path),
|
|
},
|
|
{
|
|
"path": hierarchy_path.name,
|
|
"role": "post_exit_raw_hierarchy_local_only",
|
|
"sha256": _sha256_file(hierarchy_path),
|
|
},
|
|
{
|
|
"path": app_path.name,
|
|
"role": "post_exit_app_identity_human_review_only",
|
|
"sha256": _sha256_file(app_path),
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def _require_screenshot_size(screenshot_path: Path) -> None:
|
|
try:
|
|
with Image.open(screenshot_path) as image:
|
|
image.load()
|
|
if image.size != EXPECTED_SCREEN_SIZE:
|
|
raise SkuSelectionScreenshotError("原始截图坐标空间不是已取证尺寸,未发布任何证据产物。")
|
|
except SkuSelectionRunError:
|
|
raise
|
|
except (UnidentifiedImageError, OSError) as error:
|
|
raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error
|
|
|
|
|
|
def _require_completed_action_audit(adapter: UiautomatorSkuPanelAdapter) -> None:
|
|
"""发布只接受完整正常链;测试替身或缺动作结果不能伪造真机闭环。"""
|
|
|
|
if (
|
|
not adapter.entry_was_tapped
|
|
or adapter.entry_bounds_attempted != _ENTRY_TEXT_BOUNDS
|
|
or adapter.entry_rpc_outcome != "completed"
|
|
or adapter.option_rpc_outcomes
|
|
!= tuple(
|
|
sorted(
|
|
(
|
|
(_TARGET_COLOR_UNROLLED_BOUNDS, "completed"),
|
|
(_TARGET_SIZE_BOUNDS, "completed"),
|
|
)
|
|
)
|
|
)
|
|
or not adapter.reveal_attempted
|
|
or adapter.reveal_rpc_outcome != "completed"
|
|
or adapter.back_attempts != 1
|
|
or adapter.back_rpc_outcome != "completed"
|
|
):
|
|
raise SkuSelectionRunError("规格动作审计链不完整,未发布任何证据产物。")
|
|
|
|
|
|
def _manifest(
|
|
inspection: DeviceInspection,
|
|
serial: str,
|
|
link: ProductUrl,
|
|
screenshot_path: Path,
|
|
task_color: str,
|
|
task_size: str,
|
|
adapter: UiautomatorSkuPanelAdapter,
|
|
) -> dict[str, Any]:
|
|
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
|
|
|
option_outcomes = dict(adapter.option_rpc_outcomes)
|
|
return {
|
|
"schema_version": 1,
|
|
"captured_at": datetime.now(UTC).isoformat(),
|
|
"operation": "t103-sku-selection",
|
|
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
|
"target_selection": {"color": task_color, "size": task_size},
|
|
"unit_price": EXPECTED_UNIT_PRICE,
|
|
"selection_status": "restored",
|
|
"panel_status": "verified_before_back",
|
|
"back_attempts": adapter.back_attempts,
|
|
"back_rpc_outcome": adapter.back_rpc_outcome,
|
|
"actions": {
|
|
"sku_entry": {
|
|
"attempts": int(adapter.entry_was_tapped),
|
|
"rpc_outcome": adapter.entry_rpc_outcome,
|
|
},
|
|
"target_color": {
|
|
"attempts": int(_TARGET_COLOR_UNROLLED_BOUNDS in option_outcomes),
|
|
"rpc_outcome": option_outcomes.get(
|
|
_TARGET_COLOR_UNROLLED_BOUNDS, "not_attempted"
|
|
),
|
|
},
|
|
"size_reveal": {
|
|
"attempts": int(adapter.reveal_attempted),
|
|
"rpc_outcome": adapter.reveal_rpc_outcome,
|
|
},
|
|
"target_size": {
|
|
"attempts": int(_TARGET_SIZE_BOUNDS in option_outcomes),
|
|
"rpc_outcome": option_outcomes.get(
|
|
_TARGET_SIZE_BOUNDS, "not_attempted"
|
|
),
|
|
},
|
|
"back": {
|
|
"attempts": adapter.back_attempts,
|
|
"rpc_outcome": adapter.back_rpc_outcome,
|
|
},
|
|
},
|
|
"post_exit_status": "human_review_required",
|
|
"page_identity": "human_review_required",
|
|
"channel": "wifi" if ":" in serial else "usb",
|
|
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
|
"device": {
|
|
"model": inspection.model,
|
|
"android_version": inspection.android_version,
|
|
"pdd_package": PDD_PACKAGE,
|
|
"pdd_version": EXPECTED_PDD_VERSION,
|
|
},
|
|
"artifacts": [{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)}],
|
|
}
|