diff --git a/client/scripts/capture_sku_panel_spike.py b/client/scripts/capture_sku_panel_spike.py
new file mode 100644
index 0000000..d7ae3df
--- /dev/null
+++ b/client/scripts/capture_sku_panel_spike.py
@@ -0,0 +1,103 @@
+"""采集人工已停在规格面板的三种状态证据;不执行任何页面操作。"""
+
+from __future__ import annotations
+
+import argparse
+from math import isfinite
+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_url import ProductUrl, ProductUrlError, parse_product_url
+from cmbuyer_client.pdd.sku_panel_spike import (
+ HUMAN_DECLARED_STATES,
+ SkuPanelEvidenceCapturer,
+ SkuPanelEvidenceError,
+)
+
+
+def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="采集人工已打开的拼多多规格面板状态证据。")
+ parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
+ product_source = parser.add_mutually_exclusive_group(required=True)
+ product_source.add_argument("--url", help="唯一 canonical goods.html?goods_id= 直链。")
+ product_source.add_argument("--goods-id", help="纯数字商品标识;仅用于记录证据归属。")
+ parser.add_argument("--state", required=True, choices=sorted(HUMAN_DECLARED_STATES), help="人工声明的面板状态。")
+ 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 resolve_product_url(arguments: argparse.Namespace) -> ProductUrl:
+ if isinstance(arguments.url, str):
+ return parse_product_url(arguments.url)
+ if isinstance(arguments.goods_id, str):
+ # 仅使用严格 parser 重新验证并构建,不把输入交给 ADB 或页面。
+ return parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={arguments.goods_id}")
+ raise ValueError("必须提供 --url 或 --goods-id。")
+
+
+def validate_arguments(arguments: argparse.Namespace) -> ProductUrl:
+ if not isinstance(arguments.serial, str) or not arguments.serial.strip():
+ raise ValueError("必须显式提供非空 --serial。")
+ if (
+ not isinstance(arguments.timeout, (int, float))
+ or isinstance(arguments.timeout, bool)
+ or arguments.timeout <= 0
+ or not isfinite(arguments.timeout)
+ ):
+ raise ValueError("--timeout 必须是大于 0 的有限数值。")
+ if arguments.state not in HUMAN_DECLARED_STATES:
+ raise ValueError("--state 必须是允许的人工声明状态。")
+ return resolve_product_url(arguments)
+
+
+def main(argv: list[str] | None = None) -> int:
+ arguments = parse_arguments(argv)
+ try:
+ link = validate_arguments(arguments)
+ 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 = SkuPanelEvidenceCapturer(client, connector, timeout_seconds=arguments.timeout)
+ try:
+ result = capturer.capture(
+ arguments.serial,
+ link.canonical_url,
+ arguments.state,
+ arguments.output_dir,
+ )
+ except (DeviceConnectionError, SkuPanelEvidenceError) 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/pdd/sku_panel_spike.py b/client/src/cmbuyer_client/pdd/sku_panel_spike.py
new file mode 100644
index 0000000..814313a
--- /dev/null
+++ b/client/src/cmbuyer_client/pdd/sku_panel_spike.py
@@ -0,0 +1,260 @@
+"""人工停留在规格面板后的只读取证。
+
+本模块不识别规格面板,不打开商品链接,也不读取价格;三种面板状态完全由现场人员声明。
+"""
+
+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
+
+
+HUMAN_DECLARED_STATES = frozenset(
+ {
+ "initial",
+ "one-dimension-selected",
+ "all-dimensions-selected",
+ }
+)
+
+
+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)},
+ ],
+ }
diff --git a/client/tests/pdd/test_sku_panel_spike.py b/client/tests/pdd/test_sku_panel_spike.py
new file mode 100644
index 0000000..91dbe6c
--- /dev/null
+++ b/client/tests/pdd/test_sku_panel_spike.py
@@ -0,0 +1,258 @@
+"""人工声明规格面板状态的离线只读取证测试。"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+from importlib.util import module_from_spec, spec_from_file_location
+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
+from cmbuyer_client.pdd.product_url import ProductUrlError
+from cmbuyer_client.pdd.sku_panel_spike import (
+ HUMAN_DECLARED_STATES,
+ SkuPanelDeclaredStateError,
+ SkuPanelEvidenceCapturer,
+ SkuPanelEvidenceError,
+ SkuPanelEvidenceTimeoutError,
+ SkuPanelHierarchyError,
+ SkuPanelPackageMismatchError,
+ SkuPanelScreenshotError,
+ SkuPanelUiDevice,
+ SkuPanelVersionMismatchError,
+)
+
+
+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[str] = []
+ 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(serial)
+ return self.inspection
+
+
+class FakeUiDevice:
+ def __init__(
+ self,
+ *,
+ version: str = "8.17.0",
+ package: str = "com.xunmeng.pinduoduo",
+ screenshot: str | None = None,
+ hierarchy: str = HIERARCHY,
+ ) -> None:
+ self.version = version
+ self.package = package
+ self.screenshot = screenshot if screenshot is not None else _png_base64()
+ self.hierarchy = hierarchy
+ 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.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":
+ return self.screenshot
+ if method == "dumpWindowHierarchy":
+ return self.hierarchy
+ raise AssertionError(f"unexpected RPC {method}")
+
+
+def _load_spike_script() -> object:
+ script_path = CLIENT_ROOT / "scripts" / "capture_sku_panel_spike.py"
+ spec = spec_from_file_location("capture_sku_panel_spike_for_test", script_path)
+ assert spec is not None and spec.loader is not None
+ module = module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+class SkuPanelEvidenceTests(unittest.TestCase):
+ def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> SkuPanelEvidenceCapturer:
+ return SkuPanelEvidenceCapturer(adb, lambda serial: device, timeout_seconds=2)
+
+ def test_all_human_declared_states_publish_redacted_manifest(self) -> None:
+ for state in sorted(HUMAN_DECLARED_STATES):
+ with self.subTest(state=state), TemporaryDirectory() as temporary:
+ adb = FakeAdbClient()
+ device = FakeUiDevice()
+ target = Path(temporary) / "evidence"
+ result = self._capturer(adb, device).capture(SERIAL, URL, state, target)
+ manifest = result.manifest_path.read_text(encoding="utf-8")
+
+ self.assertEqual(adb.calls, [SERIAL])
+ self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
+ self.assertIn(f'"human_declared_state": "{state}"', manifest)
+ self.assertIn('"goods_id": "123"', manifest)
+ self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
+ self.assertNotIn("detected_state", manifest)
+ self.assertNotIn(SERIAL, manifest)
+ self.assertNotIn("sensitive.activity.name", manifest)
+ self.assertNotIn(HIERARCHY, manifest)
+
+ def test_invalid_state_and_url_fail_before_device_access(self) -> None:
+ adb = FakeAdbClient()
+ with TemporaryDirectory() as temporary:
+ with self.assertRaises(SkuPanelDeclaredStateError):
+ self._capturer(adb, FakeUiDevice()).capture(SERIAL, URL, "guessed", Path(temporary) / "state")
+ with self.assertRaises(ProductUrlError):
+ self._capturer(adb, FakeUiDevice()).capture(
+ SERIAL,
+ "https://mobile.yangkeduo.com/goods.html?goods_id=12x",
+ "initial",
+ Path(temporary) / "url",
+ )
+
+ self.assertEqual(adb.calls, [])
+
+ def test_version_or_foreground_package_mismatch_stops_before_artifacts(self) -> None:
+ scenarios = (
+ (FakeUiDevice(version="8.17.1"), SkuPanelVersionMismatchError, ["app_info"]),
+ (FakeUiDevice(package="com.example.other"), SkuPanelPackageMismatchError, ["app_info", "app_current"]),
+ )
+ for device, error_type, expected_calls 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).capture(SERIAL, URL, "initial", target)
+
+ self.assertEqual(device.calls, expected_calls)
+ self.assertFalse(target.exists())
+
+ def test_screenshot_and_xml_failure_leave_no_partial_evidence(self) -> None:
+ scenarios = (
+ (FakeUiDevice(screenshot="not valid base64!"), SkuPanelScreenshotError),
+ (FakeUiDevice(hierarchy=""), SkuPanelHierarchyError),
+ )
+ 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).capture(SERIAL, URL, "initial", target)
+
+ self.assertFalse(target.exists())
+ self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
+
+ def test_screenshot_timeout_is_redacted_and_leaves_no_partial_evidence(self) -> None:
+ class TimeoutScreenshotDevice(FakeUiDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ if method == "takeScreenshot":
+ self.calls.append(method)
+ raise TimeoutError("adb 192.168.0.173:5555 raw detail")
+ return super().jsonrpc_call(method, params, timeout)
+
+ with TemporaryDirectory() as temporary:
+ target = Path(temporary) / "evidence"
+ with self.assertRaises(SkuPanelEvidenceTimeoutError) as raised:
+ self._capturer(FakeAdbClient(), TimeoutScreenshotDevice()).capture(SERIAL, URL, "initial", target)
+
+ self.assertNotIn(SERIAL, str(raised.exception))
+ self.assertNotIn("adb", str(raised.exception).lower())
+ self.assertFalse(target.exists())
+ self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
+
+ def test_existing_output_directory_is_not_overwritten_or_connected(self) -> None:
+ adb = FakeAdbClient()
+ device = FakeUiDevice()
+ with TemporaryDirectory() as temporary:
+ target = Path(temporary) / "evidence"
+ target.mkdir()
+ sentinel = target / "sentinel.txt"
+ sentinel.write_text("keep", encoding="utf-8")
+
+ with self.assertRaises(SkuPanelEvidenceError):
+ self._capturer(adb, device).capture(SERIAL, URL, "initial", target)
+
+ self.assertEqual(adb.calls, [])
+ self.assertEqual(device.calls, [])
+ self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
+
+ def test_protocol_has_no_ui_or_purchase_operation_methods(self) -> None:
+ forbidden = {
+ "click",
+ "swipe",
+ "send_keys",
+ "set_text",
+ "press",
+ "open_product",
+ "open_sku_panel",
+ "set_quantity",
+ "go_to_order_confirm",
+ "submit_order",
+ "pay",
+ }
+ self.assertTrue(forbidden.isdisjoint(SkuPanelUiDevice.__dict__))
+ self.assertEqual({name for name in SkuPanelEvidenceCapturer.__dict__ if not name.startswith("_")}, {"capture"})
+
+
+class SkuPanelSpikeCliTests(unittest.TestCase):
+ def test_validate_arguments_rejects_invalid_serial_timeout_state_and_url(self) -> None:
+ script = _load_spike_script()
+ valid = {
+ "serial": SERIAL,
+ "url": URL,
+ "goods_id": None,
+ "state": "initial",
+ "output_dir": Path("evidence"),
+ "timeout": 10.0,
+ "adb": "adb",
+ }
+ invalid_values = (
+ ("serial", ""),
+ ("timeout", 0),
+ ("timeout", float("inf")),
+ ("state", "not-declared"),
+ ("url", "https://mobile.yangkeduo.com/goods.html?goods_id=bad"),
+ )
+ for field, value in invalid_values:
+ with self.subTest(field=field, value=value):
+ arguments = argparse.Namespace(**(valid | {field: value}))
+ with self.assertRaises((ValueError, ProductUrlError)):
+ script.validate_arguments(arguments) # type: ignore[attr-defined]
+
+ def test_goods_id_is_rebuilt_as_canonical_url(self) -> None:
+ script = _load_spike_script()
+ arguments = argparse.Namespace(
+ serial=SERIAL,
+ url=None,
+ goods_id="00123",
+ state="all-dimensions-selected",
+ output_dir=Path("evidence"),
+ timeout=10.0,
+ adb="adb",
+ )
+
+ link = script.validate_arguments(arguments) # type: ignore[attr-defined]
+ self.assertEqual(link.canonical_url, "https://mobile.yangkeduo.com/goods.html?goods_id=00123")
diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md
index 603926f..7032ae6 100644
--- a/docs/03-tech-stack.md
+++ b/docs/03-tech-stack.md
@@ -125,6 +125,28 @@ D:\Portable\adb\adb.exe devices -l
证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查,
设备、版本、证据路径与 SHA-256 已记录到任务执行记录。
+### T-103 规格面板三状态只读取证(等待人工真机验收)
+
+`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。
+它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露
+`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`initial`、`one-dimension-selected`、
+`all-dimensions-selected` 三种值写入 manifest 的字段名是 `human_declared_state`,明确表示人工声明,
+不得将其当作自动识别结果。运行拼多多版本必须精确为 `8.17.0`,前台 package 必须是拼多多,否则
+fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容或发布半成品。
+
+人工只能通过商品页上独立且安全的「规格/已选」入口打开面板。不得点击或借用「单独购买」「免拼购买」
+「直接拼成」「现在买」「先用后付」「0 元下单」等购买、下单或支付入口;没有独立入口即记录 no-go。
+三种状态分别使用一个全新证据目录:
+
+```powershell
+.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --state initial --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-initial-" --timeout 10 --adb D:\Portable\adb\adb.exe
+.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --state one-dimension-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-one-dimension-" --timeout 10 --adb D:\Portable\adb\adb.exe
+.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --state all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-all-dimensions-" --timeout 10 --adb D:\Portable\adb\adb.exe
+```
+
+原始截图/XML 只能留在本机。人先核对三次截图的声明状态并完成隐私检查,再回报 manifest 路径、精确
+选择值和面板可见价格文本;agent 在此之前不得读取原始页面内容或编写任何规格面板判据。
+
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 d8af8fc..a0cc574 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -19,8 +19,9 @@
详见 [`03-tech-stack.md`](03-tech-stack.md)
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
- 连接边界、本地基线取证 CLI 和受限商品链接打开取证 CLI;尚无规格选择、价格读取或下单流程
-- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 46 项离线单元测试
+ 连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI 和人工声明规格面板状态的只读取证 CLI;
+ 尚无规格选择、价格读取或下单流程
+- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 55 项离线单元测试
(全部 mock,不连接真机)
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
@@ -28,19 +29,20 @@
并且不覆盖低版本环境;成功后打印真实启动命令。
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
-- 当前 blocker:无外部 blocker。T-102 已证明 canonical 链接可在拼多多 8.17.0 打开到目标商品并完成
- 人工隐私验收;下一步落成 T-103 的规格面板证据与试选验证。T-103 通过前 Phase 2 仍不能抢跑。
+- 当前设备门禁:T-102 已证明 canonical 链接可在拼多多 8.17.0 打开到目标商品并完成人工隐私验收;
+ T-103 的只读取证脚手架已落成,等待人工分别把安全的独立规格面板停在初始、已选一个维度、已选全部
+ 维度三种状态并采集证据。T-103 通过前 Phase 2 仍不能抢跑。
## 当前目录要点
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
-| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-102) | T-001~T-004、T-101~T-102 已完成;下一任务为 T-103 |
+| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-103) | T-001~T-004、T-101~T-102 已完成;T-103 正在执行 |
| `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 缺工具链明确失败 |
## 任务状态
@@ -133,6 +135,22 @@ D:\Portable\adb\adb.exe devices -l
检查无地址、手机号、支付信息或其他无关隐私;只回报 manifest 路径及截图/XML SHA-256,原始证据
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
+T-103 的规格面板只读取证命令要求人先在手机上打开**独立且安全的规格/已选入口**,并手工停在待采集
+状态。不得点击或借用「单独购买」「免拼购买」「直接拼成」「现在买」「先用后付」「0 元下单」等购买、
+下单或支付入口来打开规格面板;商品没有独立规格入口时必须报告 no-go,不得绕过。脚本本身不打开链接、
+不识别面板、不点击或选择规格,也不读取价格;`--state` 只是人工声明,不能作为自动判据:
+
+```powershell
+# 仓库根目录;三次采集使用三个全新的 output-dir
+.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --state initial --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-initial-" --timeout 10 --adb D:\Portable\adb\adb.exe
+.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --state one-dimension-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-one-dimension-" --timeout 10 --adb D:\Portable\adb\adb.exe
+.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --state all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-all-dimensions-" --timeout 10 --adb D:\Portable\adb\adb.exe
+```
+
+每次成功后,人必须本地核对截图确为所声明状态,并检查截图/XML 不含地址、手机号、支付信息或其他无关
+隐私,再只回报三个 manifest 路径、人工选择的精确规格值及面板可见价格文本;原始证据不得提交 Git。
+三组证据均通过人工检查后,agent 才能读取它们并提取经脱敏的测试 fixture、编写页面判据与精确选择逻辑。
+
## 关键背景
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`