"""人工停留在规格面板后的只读取证。 本模块不识别规格面板,不打开商品链接,也不读取价格;三种面板状态完全由现场人员声明。 """ 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 typing import Any, Protocol from uuid import uuid4 from adbutils.errors import AdbTimeout from uiautomator2.exceptions import HTTPTimeoutError from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection from ..device.baseline import ( HIERARCHY_PARAMS, PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file, _validate_hierarchy, ) from .product_open import EXPECTED_PDD_VERSION from .product_url import ProductUrl, parse_product_url from .sku_panel_state import HUMAN_DECLARED_STATES class SkuPanelEvidenceError(RuntimeError): """人工规格面板证据无法完整发布。""" class SkuPanelDeclaredStateError(SkuPanelEvidenceError): """调用方没有提供允许的人工声明状态。""" class SkuPanelVersionMismatchError(SkuPanelEvidenceError): """运行时拼多多版本不是已取证版本。""" class SkuPanelPackageMismatchError(SkuPanelEvidenceError): """人工声明前台不是拼多多时仍试图留证。""" class SkuPanelEvidenceTimeoutError(SkuPanelEvidenceError): """只读截图或节点树取证超时。""" class SkuPanelScreenshotError(SkuPanelEvidenceError): """截图不能保存为严格有效的 PNG。""" class SkuPanelHierarchyError(SkuPanelEvidenceError): """节点树不能保存为严格有效的 XML。""" class SkuPanelUiDevice(Protocol): """人工面板证据所需的只读接口,故意没有任何页面操作方法。""" def app_info(self, package_name: str) -> dict[str, Any]: """读取应用元数据。""" def app_current(self) -> dict[str, Any]: """读取当前前台应用元数据。""" def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: """调用公开 JSON-RPC 的只读取证方法。""" @dataclass(frozen=True) class SkuPanelEvidenceResult: """已原子发布的本地证据目录。""" output_directory: Path manifest_path: Path screenshot_path: Path hierarchy_path: Path class SkuPanelEvidenceCapturer: """把人工已停留的面板状态留证,不对页面作任何自动结论。 状态字段命名为 ``human_declared_state``,防止消费者把本模块误解为自动面板/规格/价格识别。 """ def __init__( self, adb_client: AdbClient, connector: Callable[[str], SkuPanelUiDevice], 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 def capture( self, serial: str, product_url: str, human_declared_state: str, output_directory: Path, ) -> SkuPanelEvidenceResult: """采集人工已准备的状态;不会打开链接、面板或执行任何 UI 操作。""" link = parse_product_url(product_url) state = _validate_human_declared_state(human_declared_state) target = Path(output_directory) _validate_new_target(target) staging: Path | None = None try: # 沿用 T-101 的显式 serial、在线状态与重复物理设备 fail-closed 核验。 inspection = self._adb_client.inspect(serial) device = self._connector(serial) pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE)) _require_pdd_foreground(device.app_current()) target.parent.mkdir(parents=True, exist_ok=True) staging = target.parent / f".{target.name}.staging-{uuid4().hex}" staging.mkdir() screenshot_path = staging / "screenshot.png" try: _save_base64_screenshot( device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds), screenshot_path, ) except (AdbTimeout, HTTPTimeoutError, TimeoutError): raise except Exception as error: raise SkuPanelScreenshotError("规格面板截图取证失败,未发布任何证据产物。") from error try: hierarchy = device.jsonrpc_call( "dumpWindowHierarchy", HIERARCHY_PARAMS, timeout=self._timeout_seconds, ) _validate_hierarchy(hierarchy) except (AdbTimeout, HTTPTimeoutError, TimeoutError): raise except Exception as error: raise SkuPanelHierarchyError("规格面板节点树取证失败,未发布任何证据产物。") from error hierarchy_path = staging / "hierarchy.xml" hierarchy_path.write_text(hierarchy, encoding="utf-8") manifest_path = staging / "manifest.json" manifest_path.write_text( json.dumps( _manifest(inspection, serial, link, state, pdd_version, screenshot_path, hierarchy_path), ensure_ascii=False, indent=2, sort_keys=True, ) + "\n", encoding="utf-8", ) os.replace(staging, target) except (SkuPanelEvidenceError, DeviceConnectionError): _clean_staging(staging) raise except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error: _clean_staging(staging) raise SkuPanelEvidenceTimeoutError("规格面板只读取证超时,未发布任何证据产物。") from error except Exception as error: _clean_staging(staging) # 第三方异常可能含 serial、Activity 或页面正文,不能直接向 CLI/日志传播。 raise SkuPanelEvidenceError("规格面板只读取证未完成,未发布任何证据产物。") from error return SkuPanelEvidenceResult( output_directory=target, manifest_path=target / "manifest.json", screenshot_path=target / "screenshot.png", hierarchy_path=target / "hierarchy.xml", ) 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_human_declared_state(value: object) -> str: if not isinstance(value, str) or value not in HUMAN_DECLARED_STATES: raise SkuPanelDeclaredStateError("必须提供允许的人工声明规格面板状态。") return value def _validate_new_target(target: Path) -> None: if target.exists(): raise SkuPanelEvidenceError("输出目录已存在;为防止混入旧证据,拒绝覆盖。") if not target.name: raise SkuPanelEvidenceError("输出目录必须是明确的新目录。") def _clean_staging(staging: Path | None) -> None: if staging is not None and staging.exists(): # staging 仅在本次调用中创建,绝不删除调用方已存在的目录。 shutil.rmtree(staging) def _require_expected_version(app_info: object) -> str: if not isinstance(app_info, dict): raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。") version = app_info.get("versionName") or app_info.get("version_name") if not isinstance(version, str) or version != EXPECTED_PDD_VERSION: raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。") return version def _require_pdd_foreground(current: object) -> None: if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE: raise SkuPanelPackageMismatchError("当前前台应用不是拼多多,已停止取证。") def _manifest( inspection: DeviceInspection, serial: str, link: ProductUrl, human_declared_state: str, pdd_version: str, screenshot_path: Path, hierarchy_path: Path, ) -> dict[str, Any]: """仅记录人工声明与非敏感审计摘要,不写入 Activity 或页面内容。""" return { "schema_version": 1, "captured_at": datetime.now(UTC).isoformat(), "product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url}, "human_declared_state": human_declared_state, "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": pdd_version, }, "artifacts": [ {"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)}, {"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)}, ], }