feat: 完整采集采购尺码候选 (#256)

This commit is contained in:
chengma
2026-08-17 17:04:43 +08:00
parent f187bfe0fe
commit 58dc9022eb
7 changed files with 458 additions and 7 deletions
+29 -2
View File
@@ -1182,6 +1182,15 @@ class PddCollectService:
artifacts=tuple(self._artifacts), 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: def _check_cancelled(self) -> None:
if self._cancelled(): if self._cancelled():
raise PddCollectError("PDD_CANCELLED", "采集任务已安全取消") raise PddCollectError("PDD_CANCELLED", "采集任务已安全取消")
@@ -1926,7 +1935,12 @@ class PddCollectService:
break break
return latest_xml 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] = {} sizes: dict[str, bool] = {}
@@ -2008,7 +2022,10 @@ class PddCollectService:
continuation = self._build_second_dimension_context( continuation = self._build_second_dimension_context(
xml_data, initial_size_dimension 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: if strict_edge_check and not start_confirmed:
raise PddCollectError( raise PddCollectError(
"PDD_DATA_SPEC_INCOMPLETE", "PDD_DATA_SPEC_INCOMPLETE",
@@ -2051,6 +2068,16 @@ class PddCollectService:
option_text = self._second_dimension_option_text(value.text) option_text = self._second_dimension_option_text(value.text)
if option_text is None: if option_text is None:
continue 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 sizes[option_text] = sizes.get(option_text, False) or value.available
signature = self._second_dimension_signature(xml_data, size_dimension) signature = self._second_dimension_signature(xml_data, size_dimension)
+138 -1
View File
@@ -6,9 +6,128 @@
from __future__ import annotations from __future__ import annotations
import hashlib
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field 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) @dataclass(frozen=True)
@@ -46,6 +165,24 @@ class PddPurchaseError(RuntimeError):
self.diagnostics = dict(diagnostics or {}) 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): class PddPurchaseAdapter(ABC):
"""一台设备的一次采购演练会话;只能由创建它的工作线程使用。""" """一台设备的一次采购演练会话;只能由创建它的工作线程使用。"""
+122
View File
@@ -11,6 +11,7 @@ import re
import time import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from contextlib import nullcontext from contextlib import nullcontext
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Mapping, Optional from typing import Any, Callable, Mapping, Optional
@@ -24,6 +25,11 @@ from .pdd_device_service import (
current_thread_device_service, current_thread_device_service,
) )
from .performance_timing import current_performance_trace from .performance_timing import current_performance_trace
from .pdd_collect_service import (
PddCollectError,
PddCollectService,
SpecDimension,
)
from .pdd_page_classifier import ( from .pdd_page_classifier import (
ACTION_NETWORK_ERROR, ACTION_NETWORK_ERROR,
ACTION_READY, ACTION_READY,
@@ -46,11 +52,15 @@ from .pdd_purchase_adapter import (
PddLivePurchaseAdapter, PddLivePurchaseAdapter,
PddPurchaseAdapter, PddPurchaseAdapter,
PddPurchaseError, PddPurchaseError,
PddPurchaseSpecResolutionRequired,
PurchaseSizeObservation,
PurchaseSpecCandidateSnapshot,
PurchasePageState, PurchasePageState,
) )
from .util.get_size_panle_coord import get_size_panel_coord from .util.get_size_panle_coord import get_size_panel_coord
from .util.select_color_size import ( from .util.select_color_size import (
color_selection_failure_reason, color_selection_failure_reason,
normalize_spec_text,
select_color, select_color,
select_size, select_size,
size_selection_failure_reason, size_selection_failure_reason,
@@ -890,6 +900,10 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
panel_timeout: float = 10.0, panel_timeout: float = 10.0,
select_color_fn: Callable[..., bool] = select_color, select_color_fn: Callable[..., bool] = select_color,
select_size_fn: Callable[..., bool] = select_size, 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, artifact_directory: Optional[Path] = None,
) -> None: ) -> None:
self._device_address = str(device_address or "").strip() self._device_address = str(device_address or "").strip()
@@ -901,6 +915,10 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
self._panel_timeout = panel_timeout self._panel_timeout = panel_timeout
self._select_color = select_color_fn self._select_color = select_color_fn
self._select_size = select_size_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._artifact_directory = artifact_directory
self._last_xml: Optional[str] = None self._last_xml: Optional[str] = None
self._hierarchy_reads = 0 self._hierarchy_reads = 0
@@ -1023,6 +1041,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
panel_xml = self._wait_for_confirmation_panel() panel_xml = self._wait_for_confirmation_panel()
color = checked.get("color") color = checked.get("color")
selected_color_for_resolution = ""
if color: if color:
panel_xml = self._restore_purchase_panel_color_region(panel_xml) panel_xml = self._restore_purchase_panel_color_region(panel_xml)
trace = current_performance_trace() trace = current_performance_trace()
@@ -1065,6 +1084,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
step="purchase_select_options", step="purchase_select_options",
diagnostics=diagnostics, diagnostics=diagnostics,
) )
selected_color_for_resolution = color
size = checked.get("size") size = checked.get("size")
if size: if size:
latest_xml = self._dump_hierarchy() latest_xml = self._dump_hierarchy()
@@ -1082,6 +1102,15 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
failure_reason = size_selection_failure_reason( failure_reason = size_selection_failure_reason(
failed_xml, size 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 = { messages = {
"target_not_visible": f"没有找到目标尺码:{size}", "target_not_visible": f"没有找到目标尺码:{size}",
"target_ambiguous": ( "target_ambiguous": (
@@ -1105,6 +1134,99 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
except Exception as exc: except Exception as exc:
self._raise_device_or_page_error(exc, "purchase_select_options") 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: def set_quantity(self, quantity: int) -> None:
device = self._require_device() device = self._require_device()
if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0: if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:
+2 -2
View File
@@ -853,7 +853,7 @@ class PddCollectParserTest(unittest.TestCase):
sleeper=lambda _seconds: None, sleeper=lambda _seconds: None,
) )
dimension = service._collect_size_dimension(device) dimension = service.collect_second_dimension_candidates(device)
self.assertIsNotNone(dimension) self.assertIsNotNone(dimension)
self.assertEqual(dimension.name, "尺码") self.assertEqual(dimension.name, "尺码")
@@ -1499,7 +1499,7 @@ class PddCollectParserTest(unittest.TestCase):
sleeper=lambda _seconds: None, sleeper=lambda _seconds: None,
) )
result = service._collect_size_dimension(device) result = service.collect_second_dimension_candidates(device)
self.assertIsNotNone(result) self.assertIsNotNone(result)
self.assertEqual(result.name, "套餐(7)") self.assertEqual(result.name, "套餐(7)")
+150 -1
View File
@@ -1,12 +1,17 @@
"""uiautomator2 采购演练 Adapter 测试;不连接真实手机。""" """uiautomator2 采购演练 Adapter 测试;不连接真实手机。"""
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import unittest import unittest
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from src.pdd_device_service import PddDeviceService 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.performance_timing import TaskPerformanceTrace
from src.pdd_u2_purchase_adapter import ( from src.pdd_u2_purchase_adapter import (
U2PddLivePurchaseAdapter, U2PddLivePurchaseAdapter,
@@ -922,6 +927,150 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
self.assertEqual(device.editor_values, []) self.assertEqual(device.editor_values, [])
self.assertEqual(device.app_wait_calls, 0) 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): def test_contextual_confirm_panel_is_reliable_without_clicking_confirm(self):
device = ContextualConfirmPanelDevice() device = ContextualConfirmPanelDevice()
calls = [] calls = []
+7
View File
@@ -86,6 +86,13 @@ Client 应执行:
6. 获取并核对订单编号和下单时间。 6. 获取并核对订单编号和下单时间。
7. 保存本地结果并提交 Admin。 7. 保存本地结果并提交 Admin。
规格选择先按页面原文精确匹配,再允许繁体、简体等价文字唯一匹配;等价候选不唯一时
必须停止。若商品正确、颜色已经可靠选中,但目标尺码在完整可购买列表中确实不存在,
Client 应只读遍历当前颜色的全部第二规格,保留规格名称、页面原文、可用状态和页面顺序,
生成稳定候选编号及快照哈希,交给后续受审计的规格解析流程。遍历未到边界、页面丢失、
候选发生变化,或页面中其实存在目标尺码但点击确认失败时,仍按普通采购失败处理,不能
请求远端猜测。本阶段候选只保存在当前执行内存,不写入 SQLite、日志或控件树产物。
地址更新只允许替换程序生成的末尾标记,不能截断真实地址主体。地址入口、修改按钮、 地址更新只允许替换程序生成的末尾标记,不能截断真实地址主体。地址入口、修改按钮、
详细地址输入框、保存按钮或保存结果任一项不唯一、不完整或无法回读时,任务必须在 详细地址输入框、保存按钮或保存结果任一项不唯一、不完整或无法回读时,任务必须在
不可逆标记写入前失败并停止。姓名、手机号、完整地址和原始控件树只用于本次设备会话, 不可逆标记写入前失败并停止。姓名、手机号、完整地址和原始控件树只用于本次设备会话,
+10 -1
View File
@@ -422,10 +422,19 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview
`ui_main.py` 注入工厂。Adapter 通过设备工作线程绑定的 `ui_main.py` 注入工厂。Adapter 通过设备工作线程绑定的
`PersistentPddDeviceService` 独占一次任务会话;同一设备可在 90 秒内复用底层 `PersistentPddDeviceService` 独占一次任务会话;同一设备可在 90 秒内复用底层
Device 连接,但每次判断都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size` Device 连接,但每次判断都重新读取当前包名和控件树。当前 Admin 下发的 `color` 和 `size`
使用精确文字匹配;其他动态维度直接停止,不做相似匹配。原生 PDD 先使用原文精确匹配,再使用繁体、简体规范化结果做唯一匹配;其他动态维度直接停止,
不做模糊相似匹配。原生 PDD
控件树不暴露商品编号,因此商品编号来自 Adapter 本次已校验并打开的 控件树不暴露商品编号,因此商品编号来自 Adapter 本次已校验并打开的
PDD URL,包名和页面类型仍以最新控件树确认。 PDD URL,包名和页面类型仍以最新控件树确认。
颜色已可靠选中但尺码确实不在页面时,采购 Adapter 调用
`PddCollectService.collect_second_dimension_candidates`,复用采集侧经过真机验证的
第二规格只读遍历。遍历完成后,Adapter 在内存中保留全部观察项及可用状态,并仅为
可购买项按页面顺序生成 `c1`、`c2` 等候选和 `spec-resolution-v1` 快照哈希。只有完整
遍历才能抛出 `PddPurchaseSpecResolutionRequired`;遍历不完整、页面丢失、没有可购买项,
或完整列表中仍存在目标的精确/繁简等价文字,都返回普通采购错误。该 Adapter 不调用
Admin,也不点击任何远端解析结果;网络请求和解析后复核属于后续工单。
部分 PDD 页面在点击商品页购买入口后直接进入包含规格和数量的 部分 PDD 页面在点击商品页购买入口后直接进入包含规格和数量的
订单确认页。Adapter 只点击这一次可逆入口;`enter_confirmation` 订单确认页。Adapter 只点击这一次可逆入口;`enter_confirmation`
和 `stop_before_submit` 只读确认“提交订单”等最终按钮存在,**不点击它**。 和 `stop_before_submit` 只读确认“提交订单”等最终按钮存在,**不点击它**。