diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py
index c26fde7..f793c0a 100644
--- a/client/src/pdd_u2_purchase_adapter.py
+++ b/client/src/pdd_u2_purchase_adapter.py
@@ -277,10 +277,10 @@ def _has_visible_color_region(root: ET.Element) -> bool:
def _needs_color_region_restore(root: ET.Element) -> bool:
- """只有第二规格已出现且颜色摘要仍存在时,才判定面板停在中部。"""
+ """用维度名或强结构证据判断颜色区域是否已经滑出视口。"""
labels = [_label(node).strip() for node in root.iter("node")]
- has_color_summary = any(
+ has_named_color_summary = any(
label.replace(" ", "").startswith(
("请选择", "請選擇", "已选", "已選", "已选择", "已選擇")
)
@@ -291,7 +291,87 @@ def _needs_color_region_restore(root: ET.Element) -> bool:
_dimension_heading(label) in _SECOND_DIMENSION_HEADINGS
for label in labels
)
- return has_color_summary and has_second_heading
+ if not has_second_heading:
+ return False
+ if has_named_color_summary:
+ return True
+
+ # 部分 PDD 面板的摘要只写“已选:具体颜色 具体尺码”,不再带
+ # “颜色分类”维度名。只有唯一纵向容器内、第二规格标题上方同时
+ # 存在至少两个完整可点击卡片时,才把它认作已滚动的第一规格。
+ has_plain_selected_summary = any(
+ label.replace(" ", "").startswith(
+ ("已选", "已選", "已选择", "已選擇")
+ )
+ for label in labels
+ )
+ return has_plain_selected_summary and _has_first_dimension_structure(root)
+
+
+def _has_first_dimension_structure(root: ET.Element) -> bool:
+ """确认唯一纵向容器的第二规格标题上方仍有多个第一规格卡片。"""
+
+ region = _purchase_panel_scroll_region(root)
+ if region is None:
+ return False
+ width = region[2] - region[0]
+ height = region[3] - region[1]
+ if height < width * 0.70:
+ return False
+
+ second_heading_tops = [
+ bounds[1]
+ for node in root.iter("node")
+ if _dimension_heading(_label(node)) in _SECOND_DIMENSION_HEADINGS
+ if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
+ if region[0] <= bounds[0]
+ and region[1] <= bounds[1]
+ and bounds[2] <= region[2]
+ and bounds[3] <= region[3]
+ ]
+ if not second_heading_tops:
+ return False
+ second_heading_top = min(second_heading_tops)
+
+ excluded = {
+ "增加数量",
+ "减少数量",
+ "提交订单",
+ "确定",
+ "打开大图",
+ }
+ candidate_bounds: set[Bounds] = set()
+ for node in root.iter("node"):
+ if (
+ node.get("clickable") != "true"
+ or node.get("enabled", "true") == "false"
+ or node.get("visible-to-user", "true") == "false"
+ ):
+ continue
+ bounds = _parse_bounds(node.get("bounds", ""))
+ label = _label(node).strip()
+ if not label or bounds is None:
+ continue
+ if not (
+ region[0] <= bounds[0]
+ and region[1] <= bounds[1]
+ and bounds[2] <= region[2]
+ and bounds[3] <= second_heading_top
+ ):
+ continue
+ compact = label.replace(" ", "")
+ if (
+ compact.startswith(
+ ("请选择", "請選擇", "已选", "已選", "选择", "選擇")
+ )
+ or any(marker in compact for marker in excluded)
+ or _dimension_heading(label) in (
+ _COLOR_DIMENSION_HEADINGS | _SECOND_DIMENSION_HEADINGS
+ )
+ ):
+ continue
+ candidate_bounds.add(bounds)
+ return len(candidate_bounds) >= 2
def _purchase_panel_scroll_region(root: ET.Element) -> Optional[Bounds]:
diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py
index a5dccd2..7abb56e 100644
--- a/client/test/test_pdd_u2_purchase_adapter.py
+++ b/client/test/test_pdd_u2_purchase_adapter.py
@@ -19,6 +19,7 @@ from src.pdd_u2_purchase_adapter import (
U2PddLivePurchaseAdapter,
U2PddPurchaseAdapter,
_final_submit_targets,
+ _needs_color_region_restore,
_page_kind,
_tagged_shipping_address,
)
@@ -152,6 +153,98 @@ def vertically_scrolled_purchase_panel_xml(*, at_color_top: bool) -> str:
"""
+def numbered_vertical_color_panel_xml(
+ *,
+ at_color_top: bool,
+ target_selected: bool = False,
+ middle_option_count: int = 8,
+ extra_scrollable: bool = False,
+) -> str:
+ """模拟摘要不含维度名、当前停在 E09~E16 的纵向颜色列表。"""
+
+ if at_color_top:
+ selected = ' selected="true"' if target_selected else ""
+ content = f"""
+
+
+
+
+
+
+ """
+ else:
+ cards = []
+ for index in range(middle_option_count):
+ number = index + 9
+ top = 997 + index * 115
+ bottom = top + 85
+ cards.append(
+ f''
+ )
+ content = "\n".join(cards) + """
+
+
+ """
+ extra = """
+
+ """ if extra_scrollable else ""
+ summary = (
+ "已选: 宠粉诱惑-E01【一件】 均码【70-140斤】"
+ if target_selected
+ else "已选: 宠粉诱惑-E10【一件】 均码【70-140斤】"
+ )
+ return f"""
+
+
+
+
+
+
+ {content}
+
+ {extra}
+
+
+ """
+
+
+def size_only_panel_xml() -> str:
+ """单规格尺码面板不能被结构兜底误认为颜色列表中部。"""
+
+ return """
+
+
+
+
+
+
+
+
+
+
+ """
+
+
class FakeEditor:
def __init__(self, device) -> None:
self.device = device
@@ -316,6 +409,40 @@ class VerticallyScrolledColorPanelDevice(FakeDevice):
self.at_color_top = True
+class NumberedVerticalColorPanelDevice(FakeDevice):
+ """模拟目标 E01 在纵向列表顶部、当前视口停在 E09~E16。"""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.at_color_top = False
+ self.target_selected = False
+ self.swipes = []
+
+ def dump_hierarchy(self):
+ if not self.has_opened:
+ return super().dump_hierarchy()
+ if self.mode == "home":
+ return home_xml()
+ if self.mode == "panel":
+ return numbered_vertical_color_panel_xml(
+ at_color_top=self.at_color_top,
+ target_selected=self.target_selected,
+ )
+ return super().dump_hierarchy()
+
+ def click(self, x, y):
+ self.clicks.append((x, y))
+ if self.mode == "panel" and self.at_color_top and 1090 <= y <= 1175:
+ self.target_selected = True
+ return
+ self.mode = "panel"
+
+ def swipe(self, x1, y1, x2, y2, duration=0.0):
+ self.swipes.append((x1, y1, x2, y2, duration))
+ if y2 > y1:
+ self.at_color_top = True
+
+
class ColdStartDevice(FakeDevice):
def __init__(self) -> None:
super().__init__()
@@ -1622,6 +1749,58 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
self.assertIn('text="颜色分类"', selected_xml[0])
adapter.close()
+ def test_purchase_restores_plain_summary_and_matches_traditional_target(self):
+ clock = FakeClock()
+ device = NumberedVerticalColorPanelDevice()
+ adapter = U2PddPurchaseAdapter(
+ "USB-001",
+ device_service=PddDeviceService(connector=lambda _serial: device),
+ sleeper=clock.sleep,
+ monotonic=clock.monotonic,
+ )
+ adapter.open_goods(GOODS_URL)
+
+ adapter.select_options({"color": "寵粉誘惑-E01【一件】"})
+
+ self.assertTrue(device.at_color_top)
+ self.assertTrue(device.target_selected)
+ self.assertEqual(len(device.swipes), 1)
+ # 一次打开规格面板、一次点击唯一的简体 E01 卡片。
+ self.assertEqual(len(device.clicks), 2)
+ adapter.close()
+
+ def test_plain_summary_restore_requires_strong_first_dimension_structure(self):
+ cases = (
+ (
+ "valid_vertical_first_dimension",
+ numbered_vertical_color_panel_xml(at_color_top=False),
+ True,
+ ),
+ (
+ "only_one_candidate_before_size",
+ numbered_vertical_color_panel_xml(
+ at_color_top=False,
+ middle_option_count=1,
+ ),
+ False,
+ ),
+ (
+ "multiple_scrollables",
+ numbered_vertical_color_panel_xml(
+ at_color_top=False,
+ extra_scrollable=True,
+ ),
+ False,
+ ),
+ ("size_only", size_only_panel_xml(), False),
+ )
+ for name, xml_data, expected in cases:
+ with self.subTest(name=name):
+ self.assertEqual(
+ _needs_color_region_restore(ET.fromstring(xml_data)),
+ expected,
+ )
+
def test_purchase_does_not_scroll_when_color_region_is_already_visible(self):
device = VerticallyScrolledColorPanelDevice(starts_at_top=True)
adapter = self._live_adapter(
diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md
index ed49f51..f136365 100644
--- a/docs/client/06-quality-security.md
+++ b/docs/client/06-quality-security.md
@@ -119,6 +119,9 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode`
同一滚动容器内候选坐标分布和容器形状判断主要方向,再按方向有界遍历;
方向证据不足时不滑动、不点击。找到目标后仍须通过精确或唯一繁简等价匹配,
不能用近似颜色代替任务规格。
+- 纵向颜色列表停在中部时,“已选”摘要可能不包含“颜色分类”维度名。只有唯一
+ 纵向规格容器、第二规格标题及其上方多个完整可点击卡片同时存在,才允许有界
+ 归位到正式颜色标题;单规格、候选不足或容器不唯一时不得猜测滑动。
- 运行时规格解析请求必须在网络发送前写入独立 SQLite 记录;只调用一次专用命令,
不轮询 Admin。只有 `matched` 响应的哈希、候选编号、原文和 options 都是本次候选的
逐字副本时才继续。响应返回后重新完整遍历真机候选并复算哈希,页面、颜色或候选