"""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)}], }