diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py
index 7d19296..faa23d9 100644
--- a/client/src/pdd_u2_purchase_adapter.py
+++ b/client/src/pdd_u2_purchase_adapter.py
@@ -70,6 +70,14 @@ _PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
_OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄")
_ADDRESS_ACTION_SETTLE_SECONDS = 1.0
+_COLOR_DIMENSION_HEADINGS = frozenset(
+ {"颜色分类", "顏色分類", "颜色", "顏色", "款式", "花色", "颜色款式"}
+)
+_SECOND_DIMENSION_HEADINGS = frozenset(
+ {"尺码", "尺碼", "尺寸", "套餐", "规格", "規格", "型号", "型號"}
+)
+_PANEL_TOP_MAX_SWIPES = 8
+_PANEL_TOP_STABLE_READS = 2
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
_MAX_QUANTITY_BUTTON_CLICKS = 5
_KEYBOARD_STATUS_MARKERS = (
@@ -167,6 +175,159 @@ def _parent_nodes(root: ET.Element) -> dict[ET.Element, ET.Element]:
}
+def _dimension_heading(label: str) -> str:
+ """只接受独立规格标题,明确排除选择摘要和提交提示。"""
+
+ compact = re.sub(r"\s+", "", str(label or ""))
+ if not compact or compact.startswith(
+ ("请选择", "請選擇", "已选", "已選", "已选择", "已選擇")
+ ):
+ return ""
+ if compact.startswith("选择") and "后" in compact:
+ return ""
+ compact = re.sub(r"[((]\d+[))]$", "", compact)
+ return compact
+
+
+def _screen_bounds(root: ET.Element) -> Optional[Bounds]:
+ parsed = [
+ bounds
+ for node in root.iter("node")
+ if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
+ ]
+ if not parsed:
+ return None
+ return 0, 0, max(item[2] for item in parsed), max(item[3] for item in parsed)
+
+
+def _has_visible_color_region(root: ET.Element) -> bool:
+ """正式颜色标题下必须同时存在至少一个可靠的可点击选项。"""
+
+ parents = _parent_nodes(root)
+ headings: list[Bounds] = []
+ second_heading_tops: list[int] = []
+ for node in root.iter("node"):
+ heading = _dimension_heading(_label(node))
+ bounds = _parse_bounds(node.get("bounds", ""))
+ if bounds is None or node.get("visible-to-user", "true") == "false":
+ continue
+ if heading in _COLOR_DIMENSION_HEADINGS:
+ headings.append(bounds)
+ elif heading in _SECOND_DIMENSION_HEADINGS:
+ second_heading_tops.append(bounds[1])
+
+ screen = _screen_bounds(root)
+ if not headings or screen is None:
+ return False
+
+ excluded = {
+ "增加数量",
+ "减少数量",
+ "提交订单",
+ "确定",
+ "打开大图",
+ }
+ for heading_bounds in headings:
+ boundary = min(
+ (
+ top
+ for top in second_heading_tops
+ if top > heading_bounds[3]
+ ),
+ default=screen[3],
+ )
+ for node in root.iter("node"):
+ label = _label(node).strip()
+ bounds = _parse_bounds(node.get("bounds", ""))
+ if not label or bounds is None:
+ continue
+ if bounds[1] < heading_bounds[3] or bounds[1] >= boundary:
+ continue
+ if _dimension_heading(label) in (
+ _COLOR_DIMENSION_HEADINGS | _SECOND_DIMENSION_HEADINGS
+ ):
+ continue
+ compact = label.replace(" ", "")
+ if (
+ compact.startswith(
+ ("请选择", "請選擇", "已选", "已選", "选择", "選擇")
+ )
+ or any(marker in compact for marker in excluded)
+ or re.fullmatch(r"[¥¥]?\d+(?:\.\d{1,2})?", compact)
+ ):
+ continue
+ if _nearest_clickable_bounds(node, parents) is not None:
+ return True
+ return False
+
+
+def _needs_color_region_restore(root: ET.Element) -> bool:
+ """只有第二规格已出现且颜色摘要仍存在时,才判定面板停在中部。"""
+
+ labels = [_label(node).strip() for node in root.iter("node")]
+ has_color_summary = any(
+ label.replace(" ", "").startswith(
+ ("请选择", "請選擇", "已选", "已選", "已选择", "已選擇")
+ )
+ and any(heading in label.replace(" ", "") for heading in _COLOR_DIMENSION_HEADINGS)
+ for label in labels
+ )
+ has_second_heading = any(
+ _dimension_heading(label) in _SECOND_DIMENSION_HEADINGS
+ for label in labels
+ )
+ return has_color_summary and has_second_heading
+
+
+def _purchase_panel_scroll_region(root: ET.Element) -> Optional[Bounds]:
+ """返回规格面板中唯一可靠的纵向滚动容器。"""
+
+ screen = _screen_bounds(root)
+ if screen is None:
+ return None
+ screen_width = screen[2] - screen[0]
+ screen_height = screen[3] - screen[1]
+ candidates: list[Bounds] = []
+ for node in root.iter("node"):
+ if (
+ node.get("scrollable") != "true"
+ or node.get("package") != PDD_PACKAGE_NAME
+ or node.get("visible-to-user", "true") == "false"
+ or node.get("enabled", "true") == "false"
+ ):
+ continue
+ bounds = _parse_bounds(node.get("bounds", ""))
+ if bounds is None:
+ continue
+ width = bounds[2] - bounds[0]
+ height = bounds[3] - bounds[1]
+ if width >= screen_width * 0.60 and height >= screen_height * 0.20:
+ candidates.append(bounds)
+ unique = sorted(set(candidates))
+ return unique[0] if len(unique) == 1 else None
+
+
+def _region_signature(root: ET.Element, region: Bounds) -> str:
+ """生成当前滚动视口的短签名,只用于判断是否已经无法继续归位。"""
+
+ parts = []
+ for node in root.iter("node"):
+ bounds = _parse_bounds(node.get("bounds", ""))
+ if bounds is None:
+ continue
+ center_x = (bounds[0] + bounds[2]) // 2
+ center_y = (bounds[1] + bounds[3]) // 2
+ if not (
+ region[0] <= center_x <= region[2]
+ and region[1] <= center_y <= region[3]
+ ):
+ continue
+ label = _label(node)
+ if label:
+ parts.append(f"{label}|{bounds}")
+ return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
+
+
def _nearest_clickable_bounds(
node: ET.Element, parents: Mapping[ET.Element, ET.Element]
) -> Optional[Bounds]:
@@ -862,6 +1023,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
color = checked.get("color")
if color:
+ panel_xml = self._restore_purchase_panel_color_region(panel_xml)
trace = current_performance_trace()
stage = (
trace.stage("purchase_select_color")
@@ -1258,6 +1420,104 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
diagnostics=diagnostics,
)
+ def _restore_purchase_panel_color_region(self, xml_data: str) -> str:
+ """选择颜色前把规格内容有界归位到正式颜色区域。"""
+
+ root = _parse_xml(xml_data)
+ if _has_visible_color_region(root):
+ return xml_data
+ if not _needs_color_region_restore(root):
+ # 兼容不暴露正式标题或滚动属性、但原选择器可以处理的旧面板。
+ return xml_data
+
+ region = _purchase_panel_scroll_region(root)
+ if region is None:
+ self._raise_color_region_not_found(
+ swipe_count=0,
+ stable_reads=0,
+ scroll_region_found=False,
+ )
+
+ device = self._require_device()
+ previous_signature = _region_signature(root, region)
+ unchanged_reads = 0
+ swipe_count = 0
+ for _ in range(_PANEL_TOP_MAX_SWIPES):
+ self._check_cancelled("purchase_select_options")
+ left, top, right, bottom = region
+ height = bottom - top
+ x = (left + right) // 2
+ # 手指向下滑,让规格内容返回顶部;坐标始终限制在规格容器内。
+ device.swipe(
+ x,
+ top + int(height * 0.25),
+ x,
+ top + int(height * 0.82),
+ duration=0.35,
+ )
+ swipe_count += 1
+ self._sleep(0.2)
+
+ current_xml = self._dump_hierarchy()
+ root = _parse_xml(current_xml)
+ page_kind = _page_kind(root, "")
+ if page_kind in {
+ "captcha",
+ "login_required",
+ "risk_control",
+ "payment",
+ }:
+ self._raise_special_page(page_kind)
+ if page_kind != "order_confirmation":
+ self._raise_color_region_not_found(
+ swipe_count=swipe_count,
+ stable_reads=unchanged_reads,
+ scroll_region_found=True,
+ )
+ if _has_visible_color_region(root):
+ return current_xml
+
+ current_region = _purchase_panel_scroll_region(root)
+ if current_region is None:
+ break
+ region = current_region
+ signature = _region_signature(root, region)
+ if signature == previous_signature:
+ unchanged_reads += 1
+ else:
+ unchanged_reads = 0
+ previous_signature = signature
+ if unchanged_reads >= _PANEL_TOP_STABLE_READS:
+ break
+
+ self._raise_color_region_not_found(
+ swipe_count=swipe_count,
+ stable_reads=unchanged_reads,
+ scroll_region_found=True,
+ )
+
+ def _raise_color_region_not_found(
+ self,
+ *,
+ swipe_count: int,
+ stable_reads: int,
+ scroll_region_found: bool,
+ ) -> None:
+ diagnostics: dict[str, Any] = {
+ "swipe_count": swipe_count,
+ "stable_reads": stable_reads,
+ "scroll_region_found": scroll_region_found,
+ }
+ artifact = self._save_last_xml("purchase-color-region-not-found")
+ if artifact is not None:
+ diagnostics["artifacts"] = [artifact]
+ raise PddPurchaseError(
+ "PURCHASE_PANEL_COLOR_REGION_NOT_FOUND",
+ "规格面板已打开,但无法回到颜色分类区域",
+ step="purchase_select_options",
+ diagnostics=diagnostics,
+ )
+
def _dump_hierarchy(self) -> str:
raw = self._require_device().dump_hierarchy()
xml_data = (
diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py
index 53f8e40..6f67e6d 100644
--- a/client/test/test_pdd_u2_purchase_adapter.py
+++ b/client/test/test_pdd_u2_purchase_adapter.py
@@ -98,6 +98,53 @@ def contextual_confirm_panel_xml(quantity: int = 1) -> str:
"""
+def vertically_scrolled_purchase_panel_xml(*, at_color_top: bool) -> str:
+ """构造规格面板位于颜色顶部或停在中部的脱敏控件树。"""
+
+ if at_color_top:
+ scroll_content = """
+
+
+
+
+
+ """
+ else:
+ scroll_content = """
+
+
+
+
+ """
+ return f"""
+
+
+
+
+
+
+
+ {scroll_content}
+
+
+
+ """
+
+
class FakeEditor:
def __init__(self, device) -> None:
self.device = device
@@ -180,6 +227,32 @@ class FakeDevice:
raise AssertionError(f"未预期的 selector: {selector}")
+class VerticallyScrolledColorPanelDevice(FakeDevice):
+ """模拟 PDD 记住规格面板纵向位置。"""
+
+ def __init__(self, *, starts_at_top: bool, restores_on_swipe: bool = True):
+ super().__init__()
+ self.at_color_top = starts_at_top
+ self.restores_on_swipe = restores_on_swipe
+ 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 vertically_scrolled_purchase_panel_xml(
+ at_color_top=self.at_color_top
+ )
+ return super().dump_hierarchy()
+
+ def swipe(self, x1, y1, x2, y2, duration=0.0):
+ self.swipes.append((x1, y1, x2, y2, duration))
+ if self.restores_on_swipe:
+ self.at_color_top = True
+
+
class ColdStartDevice(FakeDevice):
def __init__(self) -> None:
super().__init__()
@@ -927,6 +1000,74 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
self.assertEqual(device.clicks[-1], (540, 2140))
adapter.close()
+ def test_purchase_restores_color_region_before_selecting_color(self):
+ clock = FakeClock()
+ device = VerticallyScrolledColorPanelDevice(starts_at_top=False)
+ selected_xml = []
+
+ def select_color_fn(_device, xml_data, _target, **_kwargs):
+ selected_xml.append(xml_data)
+ return True
+
+ adapter = self._live_adapter(
+ device,
+ [],
+ sleeper=clock.sleep,
+ monotonic=clock.monotonic,
+ select_color_fn=select_color_fn,
+ )
+ adapter.open_goods(GOODS_URL)
+
+ adapter.select_options({"color": "黑色"})
+
+ self.assertEqual(len(device.swipes), 1)
+ self.assertLess(device.swipes[0][1], device.swipes[0][3])
+ self.assertEqual(len(selected_xml), 1)
+ self.assertIn('text="颜色分类"', selected_xml[0])
+ adapter.close()
+
+ def test_purchase_does_not_scroll_when_color_region_is_already_visible(self):
+ device = VerticallyScrolledColorPanelDevice(starts_at_top=True)
+ adapter = self._live_adapter(
+ device,
+ [],
+ select_color_fn=lambda *_args, **_kwargs: True,
+ )
+ adapter.open_goods(GOODS_URL)
+
+ adapter.select_options({"color": "黑色"})
+
+ self.assertEqual(device.swipes, [])
+ adapter.close()
+
+ def test_purchase_stops_when_color_region_cannot_be_restored(self):
+ clock = FakeClock()
+ device = VerticallyScrolledColorPanelDevice(
+ starts_at_top=False,
+ restores_on_swipe=False,
+ )
+ select_calls = []
+ adapter = self._live_adapter(
+ device,
+ [],
+ sleeper=clock.sleep,
+ monotonic=clock.monotonic,
+ select_color_fn=lambda *_args, **_kwargs: select_calls.append(True),
+ )
+ adapter.open_goods(GOODS_URL)
+
+ with self.assertRaises(PddPurchaseError) as raised:
+ adapter.select_options({"color": "黑色"})
+
+ self.assertEqual(
+ raised.exception.code,
+ "PURCHASE_PANEL_COLOR_REGION_NOT_FOUND",
+ )
+ self.assertEqual(len(device.swipes), 2)
+ self.assertEqual(select_calls, [])
+ self.assertEqual(len(device.clicks), 1)
+ adapter.close()
+
def test_address_is_split_at_first_separator_before_tagging(self):
self.assertEqual(
_tagged_shipping_address(