feat(client): add guarded product link capture
This commit is contained in:
@@ -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())
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""拼多多受限打开模块的离线测试。"""
|
||||
@@ -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 = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node /></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="<not-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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user