From 7040bb61d8a2eaa92f8ad6b70076cb51776c6522 Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Tue, 4 Aug 2026 09:26:37 +0800
Subject: [PATCH] feat(client): add guarded product link capture
---
client/scripts/capture_product_open.py | 75 ++++++
client/src/cmbuyer_client/device/adb.py | 48 ++++
client/src/cmbuyer_client/pdd/__init__.py | 15 ++
client/src/cmbuyer_client/pdd/product_open.py | 234 ++++++++++++++++++
client/src/cmbuyer_client/pdd/product_url.py | 61 +++++
client/tests/device/test_adb.py | 95 ++++++-
client/tests/pdd/__init__.py | 1 +
client/tests/pdd/test_product_open.py | 186 ++++++++++++++
client/tests/pdd/test_product_url.py | 49 ++++
docs/03-tech-stack.md | 20 ++
docs/current-state.md | 37 ++-
11 files changed, 808 insertions(+), 13 deletions(-)
create mode 100644 client/scripts/capture_product_open.py
create mode 100644 client/src/cmbuyer_client/pdd/__init__.py
create mode 100644 client/src/cmbuyer_client/pdd/product_open.py
create mode 100644 client/src/cmbuyer_client/pdd/product_url.py
create mode 100644 client/tests/pdd/__init__.py
create mode 100644 client/tests/pdd/test_product_open.py
create mode 100644 client/tests/pdd/test_product_url.py
diff --git a/client/scripts/capture_product_open.py b/client/scripts/capture_product_open.py
new file mode 100644
index 0000000..a20b5ac
--- /dev/null
+++ b/client/scripts/capture_product_open.py
@@ -0,0 +1,75 @@
+"""打开已验证的拼多多商品直链并采集只读本地证据。"""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+import sys
+
+
+CLIENT_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(CLIENT_ROOT / "src"))
+
+from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
+from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
+from cmbuyer_client.pdd.product_open import ProductOpenCapturer, ProductOpenError
+from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
+
+
+def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="打开 canonical 拼多多商品链接并采集只读证据。")
+ parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
+ parser.add_argument("--url", required=True, help="唯一允许的 goods.html?goods_id= 直链。")
+ parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
+ parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
+ parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
+ return parser.parse_args(argv)
+
+
+def validate_arguments(arguments: argparse.Namespace) -> None:
+ if not arguments.serial.strip():
+ raise ValueError("必须显式提供非空 --serial。")
+ if arguments.timeout <= 0:
+ raise ValueError("--timeout 必须大于 0。")
+ parse_product_url(arguments.url)
+
+
+def main(argv: list[str] | None = None) -> int:
+ arguments = parse_arguments(argv)
+ try:
+ validate_arguments(arguments)
+ link = parse_product_url(arguments.url)
+ except (ValueError, ProductUrlError) as error:
+ print(f"失败:{error}", file=sys.stderr)
+ return 2
+
+ try:
+ import adbutils
+ import uiautomator2 as u2
+ except ImportError:
+ print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
+ return 2
+
+ client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
+ connector = NoReconnectUiautomatorConnector(
+ adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
+ u2.connect,
+ )
+ capturer = ProductOpenCapturer(client, connector, timeout_seconds=arguments.timeout)
+ try:
+ result = capturer.open_and_capture(arguments.serial, link.canonical_url, arguments.output_dir)
+ except (DeviceConnectionError, ProductOpenError) as error:
+ # 不打印 ADB 输出、serial、Activity、XML 或页面正文。
+ print(f"商品打开取证失败:{error}", file=sys.stderr)
+ return 1
+ except OSError:
+ print("商品打开取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
+ return 1
+
+ print(f"商品打开取证完成:{result.output_directory}")
+ print(f"manifest:{result.manifest_path}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/client/src/cmbuyer_client/device/adb.py b/client/src/cmbuyer_client/device/adb.py
index a4d4f04..2a95886 100644
--- a/client/src/cmbuyer_client/device/adb.py
+++ b/client/src/cmbuyer_client/device/adb.py
@@ -48,6 +48,10 @@ class DuplicatePhysicalDeviceError(DeviceConnectionError):
"""同一物理手机通过多个 ADB 通道同时在线。"""
+class IntentLaunchUnconfirmedError(DeviceConnectionError):
+ """`am start -W` 没有给出可确认的启动成功结果。"""
+
+
@dataclass(frozen=True)
class CommandResult:
"""可注入命令执行器的最小、可离线构造结果。"""
@@ -57,6 +61,14 @@ class CommandResult:
returncode: int = 0
+@dataclass(frozen=True)
+class IntentLaunchSummary:
+ """不含 Activity、页面内容或 ADB 输出的受限启动摘要。"""
+
+ status: str
+ returncode: int
+
+
class CommandRunner(Protocol):
"""运行 ADB 子命令的可替换边界。"""
@@ -195,6 +207,42 @@ class AdbClient:
result = self._run_checked(("devices", "-l"))
return parse_adb_devices(result.stdout)
+ def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
+ """以参数数组启动唯一允许的拼多多 ACTION_VIEW Intent。
+
+ 这里刻意不提供任意 shell 或任意 package 的执行接口。调用方必须先完成
+ ``inspect`` 和应用版本核验;本方法在本层从纯数字 ``goods_id`` 重建 URL,调用方不能
+ 把另一个 URL 直接交给 ADB。本方法既不点击控件,也不解析 Activity 或页面文本。
+ """
+
+ selected_serial = _require_serial(serial)
+ if (
+ not isinstance(goods_id, str)
+ or not goods_id
+ or any(character < "0" or character > "9" for character in goods_id)
+ ):
+ raise ValueError("goods_id 必须是纯数字")
+ canonical_url = f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}"
+ result = self._run_checked(
+ (
+ "-s",
+ selected_serial,
+ "shell",
+ "am",
+ "start",
+ "-W",
+ "-a",
+ "android.intent.action.VIEW",
+ "-d",
+ canonical_url,
+ "-p",
+ "com.xunmeng.pinduoduo",
+ )
+ )
+ if not any(line.strip() == "Status: ok" for line in result.stdout.splitlines()):
+ raise IntentLaunchUnconfirmedError("商品链接启动结果无法确认,已停止后续取证。")
+ return IntentLaunchSummary(status="ok", returncode=result.returncode)
+
def _physical_identity(self, device: AdbDevice) -> frozenset[str]:
serialno = self._getprop(device.serial, "ro.serialno")
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
diff --git a/client/src/cmbuyer_client/pdd/__init__.py b/client/src/cmbuyer_client/pdd/__init__.py
new file mode 100644
index 0000000..389db9b
--- /dev/null
+++ b/client/src/cmbuyer_client/pdd/__init__.py
@@ -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",
+]
diff --git a/client/src/cmbuyer_client/pdd/product_open.py b/client/src/cmbuyer_client/pdd/product_open.py
new file mode 100644
index 0000000..db12e45
--- /dev/null
+++ b/client/src/cmbuyer_client/pdd/product_open.py
@@ -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)},
+ ],
+ }
diff --git a/client/src/cmbuyer_client/pdd/product_url.py b/client/src/cmbuyer_client/pdd/product_url.py
new file mode 100644
index 0000000..f413cba
--- /dev/null
+++ b/client/src/cmbuyer_client/pdd/product_url.py
@@ -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)
diff --git a/client/tests/device/test_adb.py b/client/tests/device/test_adb.py
index 814b784..63678c5 100644
--- a/client/tests/device/test_adb.py
+++ b/client/tests/device/test_adb.py
@@ -16,11 +16,14 @@ from cmbuyer_client.device.adb import (
AdbClient,
CommandResult,
DeviceIdentityUnconfirmedError,
+ DeviceCommandError,
+ DeviceCommandTimeoutError,
DeviceNotFoundError,
DeviceOfflineError,
DeviceStateError,
DeviceUnauthorizedError,
DuplicatePhysicalDeviceError,
+ IntentLaunchUnconfirmedError,
SerialRequiredError,
)
@@ -176,7 +179,95 @@ class AdbClientTests(unittest.TestCase):
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
- from cmbuyer_client.device.adb import DeviceCommandTimeoutError
-
with self.assertRaises(DeviceCommandTimeoutError):
AdbClient(TimeoutRunner()).inspect(USB_SERIAL)
+
+ def test_product_intent_is_fixed_to_action_view_and_pdd_package(self) -> None:
+ class IntentRunner:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, ...]] = []
+
+ def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
+ self.calls.append(tuple(arguments))
+ return CommandResult(stdout="Status: ok\n")
+
+ runner = IntentRunner()
+ summary = AdbClient(runner).start_pdd_view_intent(
+ USB_SERIAL,
+ "123",
+ )
+
+ self.assertEqual(summary.status, "ok")
+ self.assertEqual(
+ runner.calls,
+ [
+ (
+ "-s",
+ USB_SERIAL,
+ "shell",
+ "am",
+ "start",
+ "-W",
+ "-a",
+ "android.intent.action.VIEW",
+ "-d",
+ "https://mobile.yangkeduo.com/goods.html?goods_id=123",
+ "-p",
+ "com.xunmeng.pinduoduo",
+ )
+ ],
+ )
+
+ def test_product_intent_without_explicit_success_is_rejected(self) -> None:
+ class UnknownIntentRunner:
+ def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
+ return CommandResult(stdout="Starting: Intent { ... }\n")
+
+ with self.assertRaises(IntentLaunchUnconfirmedError):
+ AdbClient(UnknownIntentRunner()).start_pdd_view_intent(
+ USB_SERIAL,
+ "123",
+ )
+
+ def test_product_intent_rejects_invalid_goods_id_before_runner(self) -> None:
+ class RecordingRunner:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, ...]] = []
+
+ def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
+ self.calls.append(tuple(arguments))
+ return CommandResult(stdout="Status: ok\n")
+
+ invalid_values: tuple[object, ...] = (
+ "",
+ "12a",
+ "123",
+ " 123",
+ "123 ",
+ "https://mobile.yangkeduo.com/goods.html?goods_id=123",
+ "am start -W -d anything",
+ 123,
+ None,
+ )
+ for value in invalid_values:
+ with self.subTest(value=repr(value)):
+ runner = RecordingRunner()
+ with self.assertRaises(ValueError):
+ AdbClient(runner).start_pdd_view_intent(USB_SERIAL, value) # type: ignore[arg-type]
+ self.assertEqual(runner.calls, [])
+
+ def test_product_intent_nonzero_and_timeout_remain_distinct(self) -> None:
+ class FailedIntentRunner:
+ def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
+ return CommandResult(stdout="sensitive command output", returncode=1)
+
+ class TimeoutIntentRunner:
+ def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
+ raise subprocess.TimeoutExpired(arguments, timeout_seconds)
+
+ with self.assertRaises(DeviceCommandError) as command_error:
+ AdbClient(FailedIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
+ self.assertNotIn("sensitive command output", str(command_error.exception))
+
+ with self.assertRaises(DeviceCommandTimeoutError):
+ AdbClient(TimeoutIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
diff --git a/client/tests/pdd/__init__.py b/client/tests/pdd/__init__.py
new file mode 100644
index 0000000..544684c
--- /dev/null
+++ b/client/tests/pdd/__init__.py
@@ -0,0 +1 @@
+"""拼多多受限打开模块的离线测试。"""
diff --git a/client/tests/pdd/test_product_open.py b/client/tests/pdd/test_product_open.py
new file mode 100644
index 0000000..29c12f7
--- /dev/null
+++ b/client/tests/pdd/test_product_open.py
@@ -0,0 +1,186 @@
+"""商品打开围栏的离线测试;所有设备和命令均为 fake。"""
+
+from __future__ import annotations
+
+import base64
+from io import BytesIO
+from pathlib import Path
+import sys
+from tempfile import TemporaryDirectory
+import unittest
+
+from PIL import Image
+
+
+CLIENT_ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(CLIENT_ROOT / "src"))
+
+from cmbuyer_client.device.adb import AdbDevice, DeviceInspection, IntentLaunchSummary
+from cmbuyer_client.pdd.product_open import (
+ ProductOpenCapturer,
+ ProductOpenTimeoutError,
+ ProductOpenUiDevice,
+ ProductHierarchyCaptureError,
+ ProductPackageMismatchError,
+ ProductScreenshotCaptureError,
+ ProductVersionMismatchError,
+)
+from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError
+
+
+SERIAL = "192.168.0.173:5555"
+URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
+HIERARCHY = ""
+
+
+def _png_base64() -> str:
+ image_data = BytesIO()
+ Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
+ return base64.b64encode(image_data.getvalue()).decode("ascii")
+
+
+class FakeAdbClient:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, str | None]] = []
+ self.inspection = DeviceInspection(
+ device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
+ model="PKG110",
+ android_version="16",
+ )
+
+ def inspect(self, serial: str) -> DeviceInspection:
+ self.calls.append(("inspect", serial))
+ return self.inspection
+
+ def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
+ self.calls.append(("intent", goods_id))
+ return IntentLaunchSummary(status="ok", returncode=0)
+
+
+class FakeUiDevice:
+ def __init__(
+ self,
+ *,
+ version: str = "8.17.0",
+ current_package: str = "com.xunmeng.pinduoduo",
+ hierarchy: str = HIERARCHY,
+ timeout_on_screenshot: bool = False,
+ ) -> None:
+ self.version = version
+ self.current_package = current_package
+ self.hierarchy = hierarchy
+ self.timeout_on_screenshot = timeout_on_screenshot
+ self.calls: list[str] = []
+
+ def app_info(self, package_name: str) -> dict[str, str]:
+ self.calls.append("app_info")
+ return {"versionName": self.version}
+
+ def app_current(self) -> dict[str, str]:
+ self.calls.append("app_current")
+ return {"package": self.current_package, "activity": "sensitive.activity.name"}
+
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ self.calls.append(method)
+ if method == "takeScreenshot":
+ if self.timeout_on_screenshot:
+ raise TimeoutError("raw remote detail")
+ return _png_base64()
+ if method == "dumpWindowHierarchy":
+ return self.hierarchy
+ raise AssertionError(f"unexpected RPC {method}")
+
+
+class ProductOpenTests(unittest.TestCase):
+ def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> ProductOpenCapturer:
+ return ProductOpenCapturer(adb, lambda serial: device, timeout_seconds=2)
+
+ def test_success_uses_canonical_url_and_redacted_atomic_manifest(self) -> None:
+ adb = FakeAdbClient()
+ device = FakeUiDevice()
+ with TemporaryDirectory() as temporary:
+ target = Path(temporary) / "evidence"
+ result = self._capturer(adb, device).open_and_capture(SERIAL, URL, target)
+ manifest = result.manifest_path.read_text(encoding="utf-8")
+
+ self.assertTrue(result.screenshot_path.exists())
+ self.assertTrue(result.hierarchy_path.exists())
+ self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
+ self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
+ self.assertIn('"goods_id": "123"', manifest)
+ self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
+ self.assertNotIn(SERIAL, manifest)
+ self.assertNotIn("sensitive.activity.name", manifest)
+ self.assertNotIn(HIERARCHY, manifest)
+
+ def test_version_mismatch_halts_before_intent(self) -> None:
+ adb = FakeAdbClient()
+ for version in ("8.17.1", " 8.17.0 "):
+ with self.subTest(version=version), TemporaryDirectory() as temporary:
+ target = Path(temporary) / "evidence"
+ with self.assertRaises(ProductVersionMismatchError):
+ self._capturer(adb, FakeUiDevice(version=version)).open_and_capture(SERIAL, URL, target)
+
+ self.assertEqual(adb.calls[-1:], [("inspect", SERIAL)])
+ self.assertFalse(target.exists())
+
+ def test_public_entry_rejects_caller_constructed_url_value_object(self) -> None:
+ adb = FakeAdbClient()
+ with TemporaryDirectory() as temporary:
+ with self.assertRaises(ProductUrlError):
+ self._capturer(adb, FakeUiDevice()).open_and_capture(
+ SERIAL,
+ ProductUrl(goods_id="123", canonical_url="https://example.invalid/"), # type: ignore[arg-type]
+ Path(temporary) / "evidence",
+ )
+
+ self.assertEqual(adb.calls, [])
+
+ def test_foreground_package_mismatch_halts_before_capture(self) -> None:
+ adb = FakeAdbClient()
+ device = FakeUiDevice(current_package="com.example.other")
+ with TemporaryDirectory() as temporary:
+ target = Path(temporary) / "evidence"
+ with self.assertRaises(ProductPackageMismatchError):
+ self._capturer(adb, device).open_and_capture(SERIAL, URL, target)
+
+ self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
+ self.assertEqual(device.calls, ["app_info", "app_current"])
+ self.assertFalse(target.exists())
+
+ def test_timeout_and_invalid_hierarchy_leave_no_partial_evidence(self) -> None:
+ scenarios = (
+ (FakeUiDevice(timeout_on_screenshot=True), ProductOpenTimeoutError),
+ (FakeUiDevice(hierarchy=""), ProductHierarchyCaptureError),
+ )
+ for device, error_type in scenarios:
+ with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
+ target = Path(temporary) / "evidence"
+ with self.assertRaises(error_type):
+ self._capturer(FakeAdbClient(), device).open_and_capture(SERIAL, URL, target)
+
+ self.assertFalse(target.exists())
+ self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
+
+ def test_invalid_screenshot_is_a_distinct_redacted_failure(self) -> None:
+ class InvalidScreenshotDevice(FakeUiDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ if method == "takeScreenshot":
+ self.calls.append(method)
+ return "not valid base64!"
+ return super().jsonrpc_call(method, params, timeout)
+
+ with TemporaryDirectory() as temporary:
+ target = Path(temporary) / "evidence"
+ with self.assertRaises(ProductScreenshotCaptureError) as raised:
+ self._capturer(FakeAdbClient(), InvalidScreenshotDevice()).open_and_capture(SERIAL, URL, target)
+
+ self.assertNotIn("base64", str(raised.exception).lower())
+ self.assertFalse(target.exists())
+ self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
+
+ def test_read_only_protocol_has_no_ui_operation_methods(self) -> None:
+ forbidden = {"click", "swipe", "send_keys", "set_text", "press", "long_click"}
+
+ self.assertTrue(forbidden.isdisjoint(ProductOpenUiDevice.__dict__))
+ self.assertEqual(base64.b64decode(_png_base64())[:8], b"\x89PNG\r\n\x1a\n")
diff --git a/client/tests/pdd/test_product_url.py b/client/tests/pdd/test_product_url.py
new file mode 100644
index 0000000..b7dcc33
--- /dev/null
+++ b/client/tests/pdd/test_product_url.py
@@ -0,0 +1,49 @@
+"""canonical 商品 URL 的离线解析测试。"""
+
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+import unittest
+
+
+CLIENT_ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(CLIENT_ROOT / "src"))
+
+from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
+
+
+class ProductUrlTests(unittest.TestCase):
+ def test_rebuilds_url_from_goods_id(self) -> None:
+ link = parse_product_url("https://mobile.yangkeduo.com/goods.html?goods_id=00123")
+
+ self.assertEqual(link.goods_id, "00123")
+ self.assertEqual(
+ link.canonical_url,
+ "https://mobile.yangkeduo.com/goods.html?goods_id=00123",
+ )
+
+ def test_rejects_noncanonical_and_ambiguous_urls(self) -> None:
+ rejected = (
+ "http://mobile.yangkeduo.com/goods.html?goods_id=123",
+ "https://other.example/goods.html?goods_id=123",
+ "https://mobile.yangkeduo.com/other.html?goods_id=123",
+ "https://user@mobile.yangkeduo.com/goods.html?goods_id=123",
+ "https://mobile.yangkeduo.com:8443/goods.html?goods_id=123",
+ "https://mobile.yangkeduo.com:443/goods.html?goods_id=123",
+ "https://mobile.yangkeduo.com/goods.html?goods_id=123#fragment",
+ "https://mobile.yangkeduo.com/goods.html",
+ "https://mobile.yangkeduo.com/goods.html?goods_id=123&goods_id=456",
+ "https://mobile.yangkeduo.com/goods.html?goods_id=123&source=share",
+ "https://mobile.yangkeduo.com/goods.html?goods_id=12a",
+ "https://mobile.yangkeduo.com/goods.html?goods_id=%EF%BC%91%EF%BC%92%EF%BC%93",
+ "https://mobile.yangkeduo.com/goods.html?goods_id=",
+ " https://mobile.yangkeduo.com/goods.html?goods_id=123",
+ "https://MOBILE.YANGKEDUO.COM/goods.html?goods_id=123",
+ "https://mobile.yangkeduo.com/goods.html?goods_id=%31%32%33",
+ )
+
+ for value in rejected:
+ with self.subTest(value=value):
+ with self.assertRaises(ProductUrlError):
+ parse_product_url(value)
diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md
index bfce14a..7612884 100644
--- a/docs/03-tech-stack.md
+++ b/docs/03-tech-stack.md
@@ -103,6 +103,26 @@ D:\Portable\adb\adb.exe devices -l
和哈希,不得把原始证据提交 Git。USB 与 WiFi 已分别由人完成取证和隐私检查,设备型号、Android、
拼多多版本、产物路径及 SHA-256 已记录到 T-101;上述命令保留用于可审计的复现与排障。
+### T-102 按链接打开商品取证(离线实现已通过,等待人工真机验收)
+
+`client/scripts/capture_product_open.py` 只接受
+`https://mobile.yangkeduo.com/goods.html?goods_id=<纯数字>` 的唯一 canonical 表示。解析后由
+`goods_id` 再次重建 URL,并以参数数组执行显式限定 `com.xunmeng.pinduoduo` 的 Android `VIEW`
+intent;没有任意 URL、任意 shell 或其他 App 控件操作入口。运行拼多多版本必须精确等于 T-101 已
+取证的 `8.17.0`,版本失配会在 intent 前停止。
+
+```powershell
+# 仓库根目录;USB 或 WiFi 每次只保留一个通道在线,再手工复制该次 serial
+D:\Portable\adb\adb.exe devices -l
+.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-" --timeout 10 --adb D:\Portable\adb\adb.exe
+```
+
+脚本只在 intent 成功且前台 package 为拼多多后采集截图与完整 XML,不根据 Activity、节点文本或
+旧项目常量声称已到详情页。成功目录以原子方式发布,manifest 仅记录 `goods_id`、canonical URL、
+设备/App 非敏感元数据、受限命令摘要、文件路径和 SHA-256,不含原始 serial、Activity 或页面正文。
+必须由人本地确认截图对应目标商品并检查截图/XML 无地址、手机号、支付信息或其他无关隐私;原始
+证据不得提交 Git,人工确认前 T-102 保持 `DOING`。
+
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
枚举的版本中确定性选择最高的 Python 3.11+ 创建它;没有合规版本时明确失败,绝不回退默认 `python`。
diff --git a/docs/current-state.md b/docs/current-state.md
index 14bcbf5..a3baba5 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -11,16 +11,16 @@
## 当前快照
-- 日期:2026-08-03
-- 阶段:**Phase 0 · 地基(两端骨架、核心数据模型与统一入口已完成,尚无真机采购业务代码)**
+- 日期:2026-08-04
+- 阶段:**Phase 1 · 真机可行性(T-101 已完成,T-102 离线实现已通过,等待人工真机验收)**
- MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
使用 Python 3.11+ / uiautomator2 / PySide6。
详见 [`03-tech-stack.md`](03-tech-stack.md)
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
- 连接边界与本地基线取证 CLI;尚无真机采购流程
-- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 30 项离线单元测试
+ 连接边界、本地基线取证 CLI 和受限商品链接打开取证 CLI;尚无规格选择、价格读取或下单流程
+- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 43 项离线单元测试
(全部 mock,不连接真机)
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
@@ -28,19 +28,19 @@
并且不覆盖低版本环境;成功后打印真实启动命令。
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
-- 当前 blocker:无外部 blocker。T-101 已完成人工 USB/WiFi 双通道验收;下一步落成并领取 T-102,
- 验证按链接打开商品详情页。桌面 GUI 与后续真机采购流程尚未验收。
+- 当前 blocker:T-102 离线代码与构建门禁已通过,等待人使用明确的 canonical 商品链接执行真机取证,
+ 确认截图对应目标商品并完成隐私检查。T-102 在人工验收前保持 `DOING`;T-103 与 Phase 2 均不能抢跑。
## 当前目录要点
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
-| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101) | T-001~T-004、T-101 已完成;下一任务为 T-102 |
+| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-102) | T-001~T-004、T-101 已完成;T-102 正在人工验收 |
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
-| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的设备基线取证、离线测试与 wheel 元数据检查;无采购流程 |
+| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开取证、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
## 任务状态
@@ -51,9 +51,10 @@
源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次),
以及 T-001(采购服务 Go 骨架)。
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
- T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)。下一步推进
- T-102 → T-103。
-- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的 Phase 2 生产页面。
+ T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)。T-102 离线实现
+ 已通过,等待人工真机验收;通过后推进 T-103。
+- T-102 → T-103 是当前最高优先级和 MVP 生死线。T-103 通过前不开发依赖真机可读字段的
+ Phase 2 生产页面。
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
可读字段时必须先回修原型与交互清单。
@@ -117,6 +118,20 @@ D:\Portable\adb\adb.exe devices -l
公开 JSON-RPC 调用,uiautomator2 初始化仍有上游固定启动上限。截图/XML 只保留在本地,执行记录只写
路径和 SHA-256,原始证据不得提交 Git。
+T-102 的人工真机验收命令(只接受唯一 canonical 链接;USB 或 WiFi 每次只保留一个通道在线):
+
+```powershell
+# 仓库根目录;从输出中手工复制本次在线 serial
+D:\Portable\adb\adb.exe devices -l
+.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-" --timeout 10 --adb D:\Portable\adb\adb.exe
+```
+
+脚本只允许 Android `VIEW` intent,并把 package 固定为 `com.xunmeng.pinduoduo`;它不点击、滑动、
+输入或判断商品页节点,也不打开规格、读取价格、进入下单或支付。运行拼多多版本必须精确为
+`8.17.0`,否则在 intent 前停止。成功后由人本地查看截图/XML,确认页面确为该 `goods_id` 对应商品并
+检查无地址、手机号、支付信息或其他无关隐私;只回报 manifest 路径及截图/XML SHA-256,原始证据
+不得提交 Git。人工确认前 T-102 必须保持 `DOING`。
+
## 关键背景
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`