2026-08-04 09:26:37 +08:00
|
|
|
|
"""安全打开 canonical 商品链接后的只读取证。"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from collections.abc import Callable
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
from hashlib import sha256
|
|
|
|
|
|
import json
|
2026-08-04 09:40:05 +08:00
|
|
|
|
from math import isfinite
|
2026-08-04 09:26:37 +08:00
|
|
|
|
import os
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
import shutil
|
2026-08-04 09:40:05 +08:00
|
|
|
|
from time import monotonic, sleep
|
2026-08-04 09:26:37 +08:00
|
|
|
|
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, IntentLaunchSummary
|
|
|
|
|
|
from ..device.baseline import (
|
|
|
|
|
|
HIERARCHY_PARAMS,
|
|
|
|
|
|
PDD_PACKAGE,
|
|
|
|
|
|
SCREENSHOT_PARAMS,
|
|
|
|
|
|
_save_base64_screenshot,
|
|
|
|
|
|
_sha256_file,
|
|
|
|
|
|
_validate_hierarchy,
|
|
|
|
|
|
)
|
|
|
|
|
|
from .product_url import ProductUrl, parse_product_url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
EXPECTED_PDD_VERSION = "8.17.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductOpenError(RuntimeError):
|
|
|
|
|
|
"""商品打开或证据发布未完整完成。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductVersionMismatchError(ProductOpenError):
|
|
|
|
|
|
"""运行时拼多多版本不是经取证允许的版本。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductPackageMismatchError(ProductOpenError):
|
2026-08-04 09:40:05 +08:00
|
|
|
|
"""Intent 后在有限时间内未观察到拼多多前台包。"""
|
2026-08-04 09:26:37 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductOpenTimeoutError(ProductOpenError):
|
|
|
|
|
|
"""商品打开后的只读取证超时。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductScreenshotCaptureError(ProductOpenError):
|
|
|
|
|
|
"""Intent 后截图不能作为完整 PNG 证据保存。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductHierarchyCaptureError(ProductOpenError):
|
|
|
|
|
|
"""Intent 后完整节点树不能作为有效 XML 证据保存。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductOpenUiDevice(Protocol):
|
|
|
|
|
|
"""本任务所需的只读 uiautomator2 接口;故意没有任何 UI 操作方法。"""
|
|
|
|
|
|
|
|
|
|
|
|
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 ProductOpenResult:
|
|
|
|
|
|
"""已原子发布的商品打开证据位置。"""
|
|
|
|
|
|
|
|
|
|
|
|
output_directory: Path
|
|
|
|
|
|
manifest_path: Path
|
|
|
|
|
|
screenshot_path: Path
|
|
|
|
|
|
hierarchy_path: Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProductOpenCapturer:
|
|
|
|
|
|
"""以 fail-closed 顺序打开已重建链接,并在打开后只读留证。
|
|
|
|
|
|
|
|
|
|
|
|
本类不判断商品页、Activity、文案或控件;打开后只确认当前 package,随后采集截图与
|
|
|
|
|
|
完整节点树。任何失败都不会发布半成品证据目录。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
adb_client: AdbClient,
|
|
|
|
|
|
connector: Callable[[str], ProductOpenUiDevice],
|
|
|
|
|
|
timeout_seconds: float,
|
2026-08-04 09:40:05 +08:00
|
|
|
|
foreground_poll_interval_seconds: float = 0.2,
|
|
|
|
|
|
monotonic_clock: Callable[[], float] = monotonic,
|
|
|
|
|
|
sleep_function: Callable[[float], None] = sleep,
|
2026-08-04 09:26:37 +08:00
|
|
|
|
) -> None:
|
2026-08-04 09:40:05 +08:00
|
|
|
|
if not _is_positive_finite(timeout_seconds):
|
|
|
|
|
|
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
|
|
|
|
|
if not _is_positive_finite(foreground_poll_interval_seconds):
|
|
|
|
|
|
raise ValueError("foreground_poll_interval_seconds 必须是大于 0 的有限数值")
|
2026-08-04 09:26:37 +08:00
|
|
|
|
self._adb_client = adb_client
|
|
|
|
|
|
self._connector = connector
|
|
|
|
|
|
self._timeout_seconds = timeout_seconds
|
2026-08-04 09:40:05 +08:00
|
|
|
|
self._foreground_poll_interval_seconds = foreground_poll_interval_seconds
|
|
|
|
|
|
self._monotonic_clock = monotonic_clock
|
|
|
|
|
|
self._sleep_function = sleep_function
|
2026-08-04 09:26:37 +08:00
|
|
|
|
|
|
|
|
|
|
def open_and_capture(self, serial: str, product_url: str, output_directory: Path) -> ProductOpenResult:
|
|
|
|
|
|
"""完成唯一允许的 Intent 打开及其后的只读取证。"""
|
|
|
|
|
|
|
|
|
|
|
|
# 公共入口只接收原始字符串并每次重新解析,不能由调用方构造不一致的值对象伪造 manifest。
|
|
|
|
|
|
link = parse_product_url(product_url)
|
|
|
|
|
|
target = Path(output_directory)
|
|
|
|
|
|
_validate_new_target(target)
|
|
|
|
|
|
|
|
|
|
|
|
staging: Path | None = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
# inspect 必须先于连接和 Intent,复用 T-101 的显式 serial、重复物理设备拒绝逻辑。
|
|
|
|
|
|
inspection = self._adb_client.inspect(serial)
|
|
|
|
|
|
device = self._connector(serial)
|
|
|
|
|
|
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
|
|
|
|
|
|
|
|
|
|
|
# 版本精确匹配是 Intent 的前置条件,失败时绝不调用 start_pdd_view_intent。
|
|
|
|
|
|
intent = self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
2026-08-04 09:40:05 +08:00
|
|
|
|
self._wait_for_pdd_foreground(device)
|
2026-08-04 09:26:37 +08:00
|
|
|
|
|
|
|
|
|
|
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 ProductScreenshotCaptureError("商品打开后截图取证失败,未发布任何证据产物。") 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 ProductHierarchyCaptureError("商品打开后节点树取证失败,未发布任何证据产物。") 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, pdd_version, intent, screenshot_path, hierarchy_path),
|
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
|
indent=2,
|
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
+ "\n",
|
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
|
)
|
|
|
|
|
|
os.replace(staging, target)
|
|
|
|
|
|
except (ProductOpenError, DeviceConnectionError):
|
|
|
|
|
|
_clean_staging(staging)
|
|
|
|
|
|
raise
|
|
|
|
|
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
|
|
|
|
|
_clean_staging(staging)
|
|
|
|
|
|
raise ProductOpenTimeoutError("商品打开后的只读取证超时,未发布任何证据产物。") from error
|
|
|
|
|
|
except Exception as error:
|
|
|
|
|
|
_clean_staging(staging)
|
|
|
|
|
|
# 底层异常可能含 serial、路径或远端页面内容,不能直接向 CLI 或日志传播。
|
|
|
|
|
|
raise ProductOpenError("商品打开或只读取证未完成,未发布任何证据产物。") from error
|
|
|
|
|
|
|
|
|
|
|
|
return ProductOpenResult(
|
|
|
|
|
|
output_directory=target,
|
|
|
|
|
|
manifest_path=target / "manifest.json",
|
|
|
|
|
|
screenshot_path=target / "screenshot.png",
|
|
|
|
|
|
hierarchy_path=target / "hierarchy.xml",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-04 09:40:05 +08:00
|
|
|
|
def _wait_for_pdd_foreground(self, device: ProductOpenUiDevice) -> None:
|
|
|
|
|
|
"""只轮询当前 package,直到 deadline;Activity 和节点树均不参与本判据。"""
|
|
|
|
|
|
|
|
|
|
|
|
deadline = self._monotonic_clock() + self._timeout_seconds
|
|
|
|
|
|
while True:
|
|
|
|
|
|
if _is_pdd_foreground(device.app_current()):
|
|
|
|
|
|
return
|
|
|
|
|
|
remaining = deadline - self._monotonic_clock()
|
|
|
|
|
|
if remaining <= 0:
|
|
|
|
|
|
raise ProductPackageMismatchError(
|
|
|
|
|
|
"商品链接打开后未在限定时间内进入拼多多,已停止后续取证。"
|
|
|
|
|
|
)
|
|
|
|
|
|
# 每个失败观察后都等待正的、受 deadline 约束的时长,避免 busy-loop。
|
|
|
|
|
|
self._sleep_function(min(self._foreground_poll_interval_seconds, remaining))
|
|
|
|
|
|
|
2026-08-04 09:26:37 +08:00
|
|
|
|
|
|
|
|
|
|
def _validate_new_target(target: Path) -> None:
|
|
|
|
|
|
if target.exists():
|
|
|
|
|
|
raise ProductOpenError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
|
|
|
|
|
if not target.name:
|
|
|
|
|
|
raise ProductOpenError("输出目录必须是明确的新目录。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: dict[str, Any]) -> str:
|
|
|
|
|
|
if not isinstance(app_info, dict):
|
|
|
|
|
|
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
|
|
|
|
|
version = app_info.get("versionName") or app_info.get("version_name")
|
|
|
|
|
|
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
|
|
|
|
|
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
|
|
|
|
|
return version
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 09:40:05 +08:00
|
|
|
|
def _is_positive_finite(value: object) -> bool:
|
|
|
|
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_pdd_foreground(current: object) -> bool:
|
|
|
|
|
|
return isinstance(current, dict) and current.get("package") == PDD_PACKAGE
|
2026-08-04 09:26:37 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _manifest(
|
|
|
|
|
|
inspection: DeviceInspection,
|
|
|
|
|
|
serial: str,
|
|
|
|
|
|
link: ProductUrl,
|
|
|
|
|
|
pdd_version: str,
|
|
|
|
|
|
intent: IntentLaunchSummary,
|
|
|
|
|
|
screenshot_path: Path,
|
|
|
|
|
|
hierarchy_path: Path,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""只写审计摘要;原始 serial、Activity、ADB 输出和页面正文均不进入 manifest。"""
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
|
"captured_at": datetime.now(UTC).isoformat(),
|
|
|
|
|
|
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
|
|
|
|
|
"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,
|
|
|
|
|
|
},
|
|
|
|
|
|
"intent": {"status": intent.status, "returncode": intent.returncode},
|
|
|
|
|
|
"current_package": PDD_PACKAGE,
|
|
|
|
|
|
"artifacts": [
|
|
|
|
|
|
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
|
|
|
|
|
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|