438 lines
18 KiB
Python
438 lines
18 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
|
|
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,
|
|
_action_bounds,
|
|
_annotate_sku_entry_failure,
|
|
_safe_sku_entry_failure_stage,
|
|
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):
|
|
"""第三方设备接口失败的脱敏映射。"""
|
|
|
|
|
|
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
|
|
|
|
|
|
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
|
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。
|
|
|
|
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法;
|
|
坐标由 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._left_panel = False
|
|
|
|
@property
|
|
def entry_was_tapped(self) -> bool:
|
|
"""仅供运行器决定故障后的单次尽力返回,不是页面操作。"""
|
|
|
|
return self._entry_was_tapped
|
|
|
|
@property
|
|
def left_panel(self) -> bool:
|
|
return self._left_panel
|
|
|
|
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:
|
|
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
|
|
self._entry_was_tapped = True
|
|
self._tap_bounds_once(bounds)
|
|
|
|
def tap_sku_option(self, bounds: str) -> None:
|
|
self._tap_bounds_once(bounds)
|
|
|
|
def leave_sku_panel(self) -> None:
|
|
if self._left_panel:
|
|
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
|
|
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
|
|
self._left_panel = True
|
|
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
|
|
|
|
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 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()
|
|
|
|
stage = "publish"
|
|
manifest_path.write_text(
|
|
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), 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:
|
|
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
|
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
|
try:
|
|
flow.reconcile_pending_action()
|
|
flow.exit_sku_panel_safely()
|
|
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_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 _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, screenshot_path: Path, task_color: str, task_size: str) -> dict[str, Any]:
|
|
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
|
|
|
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",
|
|
"safe_exit": "completed",
|
|
"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)}],
|
|
}
|