From 58dc9022eb56f862aacb561c9be4a5c7f6bbff0f Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 17 Aug 2026 17:04:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=95=B4=E9=87=87=E9=9B=86?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E5=B0=BA=E7=A0=81=E5=80=99=E9=80=89=20(#256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pdd_collect_service.py | 31 +++- client/src/pdd_purchase_adapter.py | 139 +++++++++++++++++- client/src/pdd_u2_purchase_adapter.py | 122 ++++++++++++++++ client/test/test_pdd_collect_service.py | 4 +- client/test/test_pdd_u2_purchase_adapter.py | 151 +++++++++++++++++++- docs/client/01-requirements.md | 7 + docs/client/02-architecture.md | 11 +- 7 files changed, 458 insertions(+), 7 deletions(-) diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index 4c7d1a9..31a242f 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -1182,6 +1182,15 @@ class PddCollectService: artifacts=tuple(self._artifacts), ) + def collect_second_dimension_candidates( + self, device: Any + ) -> Optional[SpecDimension]: + """复用采集流程完整遍历当前颜色的第二规格,不点击任何选项。""" + + return self._collect_size_dimension( + device, require_confirmed_edges=True + ) + def _check_cancelled(self) -> None: if self._cancelled(): raise PddCollectError("PDD_CANCELLED", "采集任务已安全取消") @@ -1926,7 +1935,12 @@ class PddCollectService: break return latest_xml - def _collect_size_dimension(self, device: Any) -> Optional[SpecDimension]: + def _collect_size_dimension( + self, + device: Any, + *, + require_confirmed_edges: bool = False, + ) -> Optional[SpecDimension]: """完整遍历第二规格并收集文字,全程不点击尺码或套餐。""" sizes: dict[str, bool] = {} @@ -2008,7 +2022,10 @@ class PddCollectService: continuation = self._build_second_dimension_context( xml_data, initial_size_dimension ) - strict_edge_check = "套餐" in size_name and self._max_spec_swipes > 0 + strict_edge_check = ( + require_confirmed_edges + or ("套餐" in size_name and self._max_spec_swipes > 0) + ) if strict_edge_check and not start_confirmed: raise PddCollectError( "PDD_DATA_SPEC_INCOMPLETE", @@ -2051,6 +2068,16 @@ class PddCollectService: option_text = self._second_dimension_option_text(value.text) if option_text is None: continue + if ( + require_confirmed_edges + and option_text in sizes + and sizes[option_text] != value.available + ): + raise PddCollectError( + "PDD_DATA_SPEC_INCOMPLETE", + f"完整遍历时第二规格状态发生变化:{size_name}", + {"dimension_name": size_name}, + ) sizes[option_text] = sizes.get(option_text, False) or value.available signature = self._second_dimension_signature(xml_data, size_dimension) diff --git a/client/src/pdd_purchase_adapter.py b/client/src/pdd_purchase_adapter.py index 238f8e1..6d572d9 100644 --- a/client/src/pdd_purchase_adapter.py +++ b/client/src/pdd_purchase_adapter.py @@ -6,9 +6,128 @@ from __future__ import annotations +import hashlib from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Mapping +from typing import Mapping, Sequence + + +def _hash_frame(value: str) -> str: + """按接口契约生成带 UTF-8 字节长度的哈希片段。""" + + return f"{len(value.encode('utf-8'))}:{value}" + + +@dataclass(frozen=True) +class PurchaseSizeObservation: + """完整遍历时看到的一条第二规格,包含不可购买项。""" + + raw_text: str + available: bool + + +@dataclass(frozen=True) +class PurchaseSpecCandidate: + """可以交给 Admin 解析的一条可购买尺码候选。""" + + candidate_id: str + raw_text: str + options: Mapping[str, str] + + def to_dict(self) -> dict[str, object]: + return { + "candidate_id": self.candidate_id, + "raw_text": self.raw_text, + "options": dict(self.options), + } + + +@dataclass(frozen=True) +class PurchaseSpecCandidateSnapshot: + """当前商品、已选颜色下,一次完整且稳定的尺码候选观察。""" + + goods_id: str + selected_color: str + target_size: str + dimension_name: str + observations: tuple[PurchaseSizeObservation, ...] + candidates: tuple[PurchaseSpecCandidate, ...] + candidate_snapshot_hash: str + observed_at: str + + @classmethod + def build( + cls, + *, + goods_id: str, + selected_color: str, + target_size: str, + dimension_name: str, + observations: Sequence[PurchaseSizeObservation], + observed_at: str, + ) -> "PurchaseSpecCandidateSnapshot": + """按页面顺序生成候选短编号和 spec-resolution-v1 哈希。""" + + checked_observations = tuple(observations) + candidates = tuple( + PurchaseSpecCandidate( + candidate_id=f"c{index}", + raw_text=item.raw_text, + options={"color": selected_color, "size": item.raw_text}, + ) + for index, item in enumerate( + (item for item in checked_observations if item.available), + start=1, + ) + ) + material = "".join( + ( + _hash_frame("spec-resolution-v1"), + _hash_frame(goods_id), + _hash_frame(selected_color), + _hash_frame(str(len(candidates))), + *( + "".join( + ( + _hash_frame(item.candidate_id), + _hash_frame(item.raw_text), + _hash_frame(str(item.options["color"])), + _hash_frame(str(item.options["size"])), + ) + ) + for item in candidates + ), + ) + ) + return cls( + goods_id=goods_id, + selected_color=selected_color, + target_size=target_size, + dimension_name=dimension_name, + observations=checked_observations, + candidates=candidates, + candidate_snapshot_hash=hashlib.sha256( + material.encode("utf-8") + ).hexdigest(), + observed_at=observed_at, + ) + + def to_dict(self) -> dict[str, object]: + """返回确定顺序的内存结构;调用方不得把它直接写入日志。""" + + return { + "goods_id": self.goods_id, + "selected_color": self.selected_color, + "target_size": self.target_size, + "dimension_name": self.dimension_name, + "observations": [ + {"raw_text": item.raw_text, "available": item.available} + for item in self.observations + ], + "candidates": [item.to_dict() for item in self.candidates], + "candidate_snapshot_hash": self.candidate_snapshot_hash, + "observed_at": self.observed_at, + } @dataclass(frozen=True) @@ -46,6 +165,24 @@ class PddPurchaseError(RuntimeError): self.diagnostics = dict(diagnostics or {}) +class PddPurchaseSpecResolutionRequired(PddPurchaseError): + """本地无法精确匹配,但已得到完整可购买候选快照。""" + + def __init__(self, snapshot: PurchaseSpecCandidateSnapshot) -> None: + super().__init__( + "PURCHASE_SPEC_RESOLUTION_REQUIRED", + f"目标尺码无法精确匹配,需要解析候选:{snapshot.target_size}", + step="purchase_select_options", + diagnostics={ + "selection_failure": "target_not_visible", + "candidate_count": len(snapshot.candidates), + "candidate_snapshot_hash": snapshot.candidate_snapshot_hash, + "dimension_name": snapshot.dimension_name, + }, + ) + self.snapshot = snapshot + + class PddPurchaseAdapter(ABC): """一台设备的一次采购演练会话;只能由创建它的工作线程使用。""" diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index 07b0107..2242687 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -11,6 +11,7 @@ import re import time import xml.etree.ElementTree as ET from contextlib import nullcontext +from datetime import datetime, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from pathlib import Path from typing import Any, Callable, Mapping, Optional @@ -24,6 +25,11 @@ from .pdd_device_service import ( current_thread_device_service, ) from .performance_timing import current_performance_trace +from .pdd_collect_service import ( + PddCollectError, + PddCollectService, + SpecDimension, +) from .pdd_page_classifier import ( ACTION_NETWORK_ERROR, ACTION_READY, @@ -46,11 +52,15 @@ from .pdd_purchase_adapter import ( PddLivePurchaseAdapter, PddPurchaseAdapter, PddPurchaseError, + PddPurchaseSpecResolutionRequired, + PurchaseSizeObservation, + PurchaseSpecCandidateSnapshot, PurchasePageState, ) from .util.get_size_panle_coord import get_size_panel_coord from .util.select_color_size import ( color_selection_failure_reason, + normalize_spec_text, select_color, select_size, size_selection_failure_reason, @@ -890,6 +900,10 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): panel_timeout: float = 10.0, select_color_fn: Callable[..., bool] = select_color, select_size_fn: Callable[..., bool] = select_size, + size_candidate_collector: Optional[ + Callable[[Any], Optional[SpecDimension]] + ] = None, + now: Callable[[], datetime] = lambda: datetime.now(timezone.utc), artifact_directory: Optional[Path] = None, ) -> None: self._device_address = str(device_address or "").strip() @@ -901,6 +915,10 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): self._panel_timeout = panel_timeout self._select_color = select_color_fn self._select_size = select_size_fn + self._size_candidate_collector = ( + size_candidate_collector or self._collect_size_candidates + ) + self._now = now self._artifact_directory = artifact_directory self._last_xml: Optional[str] = None self._hierarchy_reads = 0 @@ -1023,6 +1041,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): panel_xml = self._wait_for_confirmation_panel() color = checked.get("color") + selected_color_for_resolution = "" if color: panel_xml = self._restore_purchase_panel_color_region(panel_xml) trace = current_performance_trace() @@ -1065,6 +1084,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): step="purchase_select_options", diagnostics=diagnostics, ) + selected_color_for_resolution = color size = checked.get("size") if size: latest_xml = self._dump_hierarchy() @@ -1082,6 +1102,15 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): failure_reason = size_selection_failure_reason( failed_xml, size ) + if ( + failure_reason == "target_not_visible" + and selected_color_for_resolution + ): + self._raise_spec_resolution_required( + failed_xml, + selected_color_for_resolution, + size, + ) messages = { "target_not_visible": f"没有找到目标尺码:{size}", "target_ambiguous": ( @@ -1105,6 +1134,99 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter): except Exception as exc: self._raise_device_or_page_error(exc, "purchase_select_options") + def _raise_spec_resolution_required( + self, + failed_xml: str, + selected_color: str, + target_size: str, + ) -> None: + """完整只读遍历后,仅为页面确实不存在的目标返回候选快照。""" + + try: + self._restore_purchase_panel_color_region(failed_xml) + dimension = self._size_candidate_collector(self._require_device()) + except PddPurchaseError: + raise + except PddCollectError as exc: + raise PddPurchaseError( + "PURCHASE_SIZE_CANDIDATE_SCAN_INCOMPLETE", + f"目标尺码未找到,且无法完整读取页面候选:{exc.message}", + step="purchase_select_options", + diagnostics={ + "selection_failure": "target_not_visible", + "candidate_scan_error": exc.code, + }, + ) from exc + except Exception as exc: + self._raise_device_or_page_error(exc, "purchase_select_options") + + if dimension is None or not dimension.values: + raise PddPurchaseError( + "PURCHASE_SIZE_CANDIDATE_SCAN_INCOMPLETE", + "目标尺码未找到,且页面没有可确认完整的第二规格候选", + step="purchase_select_options", + diagnostics={"selection_failure": "target_not_visible"}, + ) + + available_values = tuple( + item for item in dimension.values if item.available + ) + if not available_values: + raise PddPurchaseError( + "PURCHASE_OPTIONS_MISMATCH", + f"当前颜色没有可购买的尺码候选:{selected_color}", + step="purchase_select_options", + diagnostics={"selection_failure": "target_not_visible"}, + ) + + # 目标若在完整列表中精确或繁简等价出现,说明前面的点击/确认失败, + # 不能把它伪装成需要远端解析的问题。 + normalized_target = normalize_spec_text(target_size) + if any( + item.text == target_size + or normalize_spec_text(item.text) == normalized_target + for item in available_values + ): + raise PddPurchaseError( + "PURCHASE_OPTIONS_MISMATCH", + f"页面存在目标尺码,但没有可靠确认选中:{target_size}", + step="purchase_select_options", + diagnostics={"selection_failure": "selection_unconfirmed"}, + ) + + observed_at = ( + self._now() + .astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + snapshot = PurchaseSpecCandidateSnapshot.build( + goods_id=self._goods_id, + selected_color=selected_color, + target_size=target_size, + dimension_name=dimension.name, + observations=tuple( + PurchaseSizeObservation(item.text, item.available) + for item in dimension.values + ), + observed_at=observed_at, + ) + raise PddPurchaseSpecResolutionRequired(snapshot) + + def _collect_size_candidates(self, device: Any) -> Optional[SpecDimension]: + """使用采集模块同一套第二规格遍历,不建立新连接也不保存 XML。""" + + service = PddCollectService( + self._device_service, + self._device_address, + "purchase-runtime", + sleeper=self._sleep, + monotonic=self._monotonic, + cancelled=self._cancelled, + artifact_directory=None, + ) + return service.collect_second_dimension_candidates(device) + def set_quantity(self, quantity: int) -> None: device = self._require_device() if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0: diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index 376b3d4..d618862 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -853,7 +853,7 @@ class PddCollectParserTest(unittest.TestCase): sleeper=lambda _seconds: None, ) - dimension = service._collect_size_dimension(device) + dimension = service.collect_second_dimension_candidates(device) self.assertIsNotNone(dimension) self.assertEqual(dimension.name, "尺码") @@ -1499,7 +1499,7 @@ class PddCollectParserTest(unittest.TestCase): sleeper=lambda _seconds: None, ) - result = service._collect_size_dimension(device) + result = service.collect_second_dimension_candidates(device) self.assertIsNotNone(result) self.assertEqual(result.name, "套餐(7)") diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index 8fb2278..5df98a5 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -1,12 +1,17 @@ """uiautomator2 采购演练 Adapter 测试;不连接真实手机。""" +from datetime import datetime, timezone from pathlib import Path import tempfile import unittest import xml.etree.ElementTree as ET from src.pdd_device_service import PddDeviceService -from src.pdd_purchase_adapter import PddPurchaseError +from src.pdd_collect_service import DimensionValue, PddCollectError, SpecDimension +from src.pdd_purchase_adapter import ( + PddPurchaseError, + PddPurchaseSpecResolutionRequired, +) from src.performance_timing import TaskPerformanceTrace from src.pdd_u2_purchase_adapter import ( U2PddLivePurchaseAdapter, @@ -922,6 +927,150 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(device.editor_values, []) self.assertEqual(device.app_wait_calls, 0) + def test_missing_size_returns_complete_structured_candidate_snapshot(self): + device = FakeDevice() + collected = [] + + def collect_candidates(current_device): + collected.append(current_device) + return SpecDimension( + "size", + "尺码", + ( + DimensionValue("M", True), + DimensionValue("L(售罄)", False), + DimensionValue("XL", True), + ), + ) + + adapter = U2PddPurchaseAdapter( + "USB-001", + device_service=PddDeviceService(connector=lambda _serial: device), + sleeper=lambda _seconds: None, + select_color_fn=lambda *_args, **_kwargs: True, + select_size_fn=lambda *_args, **_kwargs: False, + size_candidate_collector=collect_candidates, + now=lambda: datetime(2026, 8, 17, 8, 0, tzinfo=timezone.utc), + ) + adapter.open_goods(GOODS_URL) + + with self.assertRaises(PddPurchaseSpecResolutionRequired) as raised: + adapter.select_options({"color": "黑色", "size": "XXL"}) + + snapshot = raised.exception.snapshot + self.assertEqual(collected, [device]) + self.assertEqual(snapshot.goods_id, "753136429979") + self.assertEqual(snapshot.dimension_name, "尺码") + self.assertEqual(snapshot.observed_at, "2026-08-17T08:00:00Z") + self.assertEqual( + [(item.raw_text, item.available) for item in snapshot.observations], + [("M", True), ("L(售罄)", False), ("XL", True)], + ) + self.assertEqual( + [item.to_dict() for item in snapshot.candidates], + [ + { + "candidate_id": "c1", + "raw_text": "M", + "options": {"color": "黑色", "size": "M"}, + }, + { + "candidate_id": "c2", + "raw_text": "XL", + "options": {"color": "黑色", "size": "XL"}, + }, + ], + ) + self.assertEqual( + snapshot.candidate_snapshot_hash, + "fcc35e4429625c830e6b2cbde9c618b63536339c18d9d452c34ec8347d1f1630", + ) + self.assertEqual( + raised.exception.diagnostics["candidate_snapshot_hash"], + snapshot.candidate_snapshot_hash, + ) + adapter.close() + + def test_candidate_snapshot_is_not_used_when_target_exists_but_click_fails(self): + device = FakeDevice() + adapter = U2PddPurchaseAdapter( + "USB-001", + device_service=PddDeviceService(connector=lambda _serial: device), + sleeper=lambda _seconds: None, + select_color_fn=lambda *_args, **_kwargs: True, + select_size_fn=lambda *_args, **_kwargs: False, + size_candidate_collector=lambda _device: SpecDimension( + "size", "尺码", (DimensionValue("XXL", True),) + ), + ) + adapter.open_goods(GOODS_URL) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.select_options({"color": "黑色", "size": "XXL"}) + + self.assertNotIsInstance( + raised.exception, PddPurchaseSpecResolutionRequired + ) + self.assertEqual(raised.exception.code, "PURCHASE_OPTIONS_MISMATCH") + self.assertEqual( + raised.exception.diagnostics["selection_failure"], + "selection_unconfirmed", + ) + adapter.close() + + def test_incomplete_candidate_scan_remains_normal_purchase_failure(self): + device = FakeDevice() + + def incomplete_scan(_device): + raise PddCollectError( + "PDD_DATA_SPEC_INCOMPLETE", "达到滑动上限" + ) + + adapter = U2PddPurchaseAdapter( + "USB-001", + device_service=PddDeviceService(connector=lambda _serial: device), + sleeper=lambda _seconds: None, + select_color_fn=lambda *_args, **_kwargs: True, + select_size_fn=lambda *_args, **_kwargs: False, + size_candidate_collector=incomplete_scan, + ) + adapter.open_goods(GOODS_URL) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.select_options({"color": "黑色", "size": "XXL"}) + + self.assertNotIsInstance( + raised.exception, PddPurchaseSpecResolutionRequired + ) + self.assertEqual( + raised.exception.code, + "PURCHASE_SIZE_CANDIDATE_SCAN_INCOMPLETE", + ) + self.assertEqual( + raised.exception.diagnostics["candidate_scan_error"], + "PDD_DATA_SPEC_INCOMPLETE", + ) + adapter.close() + + def test_size_without_selected_color_never_requests_remote_resolution(self): + device = FakeDevice() + scans = [] + adapter = U2PddPurchaseAdapter( + "USB-001", + device_service=PddDeviceService(connector=lambda _serial: device), + sleeper=lambda _seconds: None, + select_size_fn=lambda *_args, **_kwargs: False, + size_candidate_collector=lambda _device: scans.append(True), + ) + adapter.open_goods(GOODS_URL) + + with self.assertRaises(PddPurchaseError) as raised: + adapter.select_options({"size": "XXL"}) + + self.assertEqual(raised.exception.code, "PURCHASE_OPTIONS_MISMATCH") + self.assertEqual(scans, []) + adapter.close() + def test_contextual_confirm_panel_is_reliable_without_clicking_confirm(self): device = ContextualConfirmPanelDevice() calls = [] diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 3e56f62..e1cda9a 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -86,6 +86,13 @@ Client 应执行: 6. 获取并核对订单编号和下单时间。 7. 保存本地结果并提交 Admin。 +规格选择先按页面原文精确匹配,再允许繁体、简体等价文字唯一匹配;等价候选不唯一时 +必须停止。若商品正确、颜色已经可靠选中,但目标尺码在完整可购买列表中确实不存在, +Client 应只读遍历当前颜色的全部第二规格,保留规格名称、页面原文、可用状态和页面顺序, +生成稳定候选编号及快照哈希,交给后续受审计的规格解析流程。遍历未到边界、页面丢失、 +候选发生变化,或页面中其实存在目标尺码但点击确认失败时,仍按普通采购失败处理,不能 +请求远端猜测。本阶段候选只保存在当前执行内存,不写入 SQLite、日志或控件树产物。 + 地址更新只允许替换程序生成的末尾标记,不能截断真实地址主体。地址入口、修改按钮、 详细地址输入框、保存按钮或保存结果任一项不唯一、不完整或无法回读时,任务必须在 不可逆标记写入前失败并停止。姓名、手机号、完整地址和原始控件树只用于本次设备会话, diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index 925a0a7..c62db67 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -422,10 +422,19 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview `ui_main.py` 注入工厂。Adapter 通过设备工作线程绑定的 `PersistentPddDeviceService` 独占一次任务会话;同一设备可在 90 秒内复用底层 Device 连接,但每次判断都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size` -使用精确文字匹配;其他动态维度直接停止,不做相似匹配。原生 PDD +先使用原文精确匹配,再使用繁体、简体规范化结果做唯一匹配;其他动态维度直接停止, +不做模糊相似匹配。原生 PDD 控件树不暴露商品编号,因此商品编号来自 Adapter 本次已校验并打开的 PDD URL,包名和页面类型仍以最新控件树确认。 +颜色已可靠选中但尺码确实不在页面时,采购 Adapter 调用 +`PddCollectService.collect_second_dimension_candidates`,复用采集侧经过真机验证的 +第二规格只读遍历。遍历完成后,Adapter 在内存中保留全部观察项及可用状态,并仅为 +可购买项按页面顺序生成 `c1`、`c2` 等候选和 `spec-resolution-v1` 快照哈希。只有完整 +遍历才能抛出 `PddPurchaseSpecResolutionRequired`;遍历不完整、页面丢失、没有可购买项, +或完整列表中仍存在目标的精确/繁简等价文字,都返回普通采购错误。该 Adapter 不调用 +Admin,也不点击任何远端解析结果;网络请求和解析后复核属于后续工单。 + 部分 PDD 页面在点击商品页购买入口后直接进入包含规格和数量的 订单确认页。Adapter 只点击这一次可逆入口;`enter_confirmation` 和 `stop_before_submit` 只读确认“提交订单”等最终按钮存在,**不点击它**。