feat(client): add guarded product link capture

This commit is contained in:
QiuSW
2026-08-04 09:26:37 +08:00
parent e7a4be1b9b
commit 7040bb61d8
11 changed files with 808 additions and 13 deletions
+15
View File
@@ -0,0 +1,15 @@
"""拼多多链接的受限打开与只读取证。
此包不提供页面选择器、输入、滑动、下单或支付能力。
"""
from .product_open import ProductOpenCapturer, ProductOpenResult
from .product_url import ProductUrl, ProductUrlError, parse_product_url
__all__ = [
"ProductOpenCapturer",
"ProductOpenResult",
"ProductUrl",
"ProductUrlError",
"parse_product_url",
]
@@ -0,0 +1,234 @@
"""安全打开 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
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, 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):
"""Intent 后当前前台包不是拼多多。"""
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,
) -> None:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必须大于 0")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
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)
_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 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",
)
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
def _require_pdd_foreground(current: dict[str, Any]) -> None:
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise ProductPackageMismatchError("商品链接打开后前台应用不是拼多多,已停止后续取证。")
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)},
],
}
@@ -0,0 +1,61 @@
"""唯一允许交给 Android Intent 的商品链接。"""
from __future__ import annotations
from dataclasses import dataclass
from urllib.parse import parse_qsl, urlsplit
_SCHEME = "https"
_HOST = "mobile.yangkeduo.com"
_PATH = "/goods.html"
class ProductUrlError(ValueError):
"""输入不是可安全重建的 canonical 商品链接。"""
@dataclass(frozen=True)
class ProductUrl:
"""经验证的商品标识及由它重建的 canonical URL。"""
goods_id: str
canonical_url: str
def parse_product_url(value: str) -> ProductUrl:
"""只接受一个 ASCII 数字 ``goods_id`` 的拼多多商品直链。
解析结果绝不原样透传:Intent 使用的 URL 必须从 ``goods_id`` 重新构建,以排除
短链、额外参数、userinfo、fragment 和 URL 解析器的边缘表示。
"""
if not isinstance(value, str):
raise ProductUrlError("商品链接必须是字符串。")
try:
parsed = urlsplit(value)
port = parsed.port
query_pairs = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
except ValueError as error:
raise ProductUrlError("商品链接格式无效。") from error
if (
parsed.scheme != _SCHEME
or parsed.hostname != _HOST
or parsed.username is not None
or parsed.password is not None
or port is not None
or parsed.path != _PATH
or parsed.fragment
):
raise ProductUrlError("商品链接不是允许的拼多多商品直链。")
if len(query_pairs) != 1 or query_pairs[0][0] != "goods_id":
raise ProductUrlError("商品链接必须且只能包含一个 goods_id 参数。")
goods_id = query_pairs[0][1]
if not goods_id or any(character < "0" or character > "9" for character in goods_id):
raise ProductUrlError("goods_id 必须是纯数字。")
canonical_url = f"{_SCHEME}://{_HOST}{_PATH}?goods_id={goods_id}"
if value != canonical_url:
raise ProductUrlError("商品链接必须使用唯一 canonical 表示。")
return ProductUrl(goods_id=goods_id, canonical_url=canonical_url)