diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py
index da8f509..2f5c8a9 100644
--- a/client/src/pdd_u2_purchase_adapter.py
+++ b/client/src/pdd_u2_purchase_adapter.py
@@ -202,6 +202,68 @@ def _clickable_text_targets(root: ET.Element, exact_text: str) -> list[Bounds]:
return sorted(targets)
+def _node_package(
+ node: ET.Element, parents: Mapping[ET.Element, ET.Element]
+) -> str:
+ """返回节点自身或最近祖先声明的包名。"""
+
+ current: Optional[ET.Element] = node
+ while current is not None:
+ if package_name := current.get("package", "").strip():
+ return package_name
+ current = parents.get(current)
+ return ""
+
+
+def _pdd_text_targets(root: ET.Element, exact_text: str) -> list[Bounds]:
+ """只返回 PDD 页面中指定文字的最近点击容器。"""
+
+ parents = _parent_nodes(root)
+ targets = {
+ bounds
+ for node in root.iter("node")
+ if exact_text
+ in {
+ node.get("text", "").strip(),
+ node.get("content-desc", "").strip(),
+ }
+ if _node_package(node, parents) == PDD_PACKAGE_NAME
+ if (bounds := _nearest_clickable_bounds(node, parents)) is not None
+ }
+ return sorted(targets)
+
+
+def _address_modify_targets(root: ET.Element) -> list[Bounds]:
+ """多地址时只返回唯一“已设默认”卡片内的修改按钮。"""
+
+ address_cards = [
+ card
+ for recycler in root.iter("node")
+ if recycler.get("class") == "androidx.recyclerview.widget.RecyclerView"
+ for card in list(recycler)
+ if _pdd_text_targets(card, "修改")
+ ]
+ if len(address_cards) <= 1:
+ return _pdd_text_targets(root, "修改")
+
+ default_cards = [
+ card for card in address_cards if "已设默认" in _labels(card)
+ ]
+ if len(default_cards) != 1:
+ return []
+ return _pdd_text_targets(default_cards[0], "修改")
+
+
+def _pdd_title_back_targets(root: ET.Element) -> list[Bounds]:
+ """只返回 PDD 地址页标题栏左侧的返回目标。"""
+
+ return [
+ bounds
+ for bounds in _pdd_text_targets(root, "返回")
+ if bounds[0] <= 240 and bounds[1] <= 320
+ ]
+
+
def _address_entry_targets(root: ET.Element) -> list[Bounds]:
"""通过脱敏手机号结构定位规格面板顶部的收货地址卡片。"""
@@ -266,9 +328,9 @@ def _is_reliable_address_panel(
labels = _labels(root)
if "收货地址" not in labels:
return False
- if len(_clickable_text_targets(root, "修改")) != 1:
+ if len(_address_modify_targets(root)) != 1:
return False
- if len(_clickable_text_targets(root, "返回")) != 1:
+ if len(_pdd_title_back_targets(root)) != 1:
return False
if expected_address and any(expected_address in label for label in labels):
return True
@@ -1344,7 +1406,7 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
saved_root, saved_page = self._wait_after_address_save(new_address)
if saved_page == "confirmation":
return
- back_targets = _clickable_text_targets(saved_root, "返回")
+ back_targets = _pdd_title_back_targets(saved_root)
self._click_unique_address_target(
back_targets,
"PURCHASE_ADDRESS_BACK_AMBIGUOUS",
@@ -1394,7 +1456,7 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
return root
if observed_page == "panel":
- targets = _clickable_text_targets(root, "修改")
+ targets = _address_modify_targets(root)
candidate_count = len(targets)
if candidate_count != 1:
raise PddPurchaseError(
diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py
index c0ecb13..7ba1bba 100644
--- a/client/test/test_pdd_u2_purchase_adapter.py
+++ b/client/test/test_pdd_u2_purchase_adapter.py
@@ -294,6 +294,56 @@ def address_panel_xml(address: str) -> str:
"""
+def multiple_address_panel_xml(
+ address: str, *, default_indexes=(0,)
+) -> str:
+ """按真机多地址结构构造不含个人数据的最小控件树。"""
+
+ cards = []
+ for index, (top, bottom) in enumerate(((226, 654), (654, 1083))):
+ modify_top = 544 if index == 0 else 973
+ marker = "已设默认" if index in default_indexes else "使用"
+ marker_top = 547 if index == 0 else 817
+ address_text = address if index in default_indexes else "备用测试地址"
+ cards.append(f"""
+
+
+
+
+
+
+
+
+ """)
+ return f"""
+
+
+
+
+
+
+ {''.join(cards)}
+
+
+
+ """
+
+
def address_edit_xml(address: str) -> str:
return f"""
@@ -377,6 +427,75 @@ class AddressFlowDevice(FakeDevice):
return super().__call__(**selector)
+class MultipleAddressFlowDevice(AddressFlowDevice):
+ """模拟多地址面板,并记录是否点击默认卡片和 PDD 标题栏返回。"""
+
+ def __init__(self, default_indexes=(0,)) -> None:
+ super().__init__()
+ self.default_indexes = default_indexes
+ self.address_modify_clicks = []
+ self.address_back_clicks = []
+
+ def dump_hierarchy(self):
+ if self.mode in {"address_panel", "address_saved"}:
+ return multiple_address_panel_xml(
+ self.address, default_indexes=self.default_indexes
+ )
+ return super().dump_hierarchy()
+
+ def click(self, x, y):
+ if self.mode == "address_panel":
+ self.clicks.append((x, y))
+ self.address_modify_clicks.append((x, y))
+ expected_y = 571 if self.default_indexes == (0,) else 1000
+ if (x, y) == (948, expected_y):
+ self.mode = "address_edit"
+ return
+ if self.mode == "address_saved":
+ self.clicks.append((x, y))
+ self.address_back_clicks.append((x, y))
+ if x <= 240 and y <= 320:
+ self.mode = "panel"
+ return
+ super().click(x, y)
+
+
+class AmbiguousDefaultAddressDevice(MultipleAddressFlowDevice):
+ """模拟默认地址卡片内部出现两个修改目标。"""
+
+ def dump_hierarchy(self):
+ xml_data = super().dump_hierarchy()
+ if self.mode != "address_panel":
+ return xml_data
+ root = ET.fromstring(xml_data)
+ recycler = next(
+ node
+ for node in root.iter("node")
+ if node.get("class")
+ == "androidx.recyclerview.widget.RecyclerView"
+ )
+ default_card = list(recycler)[0]
+ extra = ET.SubElement(
+ default_card,
+ "node",
+ {
+ "package": "com.xunmeng.pinduoduo",
+ "clickable": "true",
+ "bounds": "[800,544][880,598]",
+ },
+ )
+ ET.SubElement(
+ extra,
+ "node",
+ {
+ "package": "com.xunmeng.pinduoduo",
+ "text": "修改",
+ "bounds": "[800,544][880,598]",
+ },
+ )
+ return ET.tostring(root, encoding="unicode")
+
+
class AddressSaveOverlayDevice(AddressFlowDevice):
"""模拟保存后出现 ColorOS 密码本浮层。"""
@@ -730,6 +849,77 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
self.assertEqual(state.price_cent, 503)
adapter.close()
+ def test_multiple_addresses_modify_only_default_card(self):
+ device = MultipleAddressFlowDevice(default_indexes=(0,))
+ adapter = self._live_adapter(device, [])
+ adapter.open_goods(GOODS_URL)
+ adapter.select_options(
+ {"color": "黑色", "size": "3XL【140-165斤】"}
+ )
+
+ adapter.update_shipping_address("cg8")
+
+ self.assertEqual(device.address_modify_clicks, [(948, 571)])
+ self.assertEqual(device.address_back_clicks, [(108, 164)])
+ self.assertEqual(device.mode, "panel")
+ adapter.close()
+
+ def test_default_address_can_be_second_card(self):
+ device = MultipleAddressFlowDevice(default_indexes=(1,))
+ adapter = self._live_adapter(device, [])
+ adapter.open_goods(GOODS_URL)
+ adapter.select_options(
+ {"color": "黑色", "size": "3XL【140-165斤】"}
+ )
+
+ adapter.update_shipping_address("cg8")
+
+ self.assertEqual(device.address_modify_clicks, [(948, 1000)])
+ self.assertEqual(device.address_back_clicks, [(108, 164)])
+ self.assertEqual(device.mode, "panel")
+ adapter.close()
+
+ def test_multiple_addresses_without_unique_default_stop_safely(self):
+ for default_indexes in ((), (0, 1)):
+ with self.subTest(default_indexes=default_indexes):
+ device = MultipleAddressFlowDevice(
+ default_indexes=default_indexes
+ )
+ adapter = self._live_adapter(device, [])
+ adapter.open_goods(GOODS_URL)
+ adapter.select_options(
+ {"color": "黑色", "size": "3XL【140-165斤】"}
+ )
+
+ with self.assertRaises(PddPurchaseError) as raised:
+ adapter.update_shipping_address("cg8")
+
+ self.assertEqual(
+ raised.exception.code,
+ "PURCHASE_ADDRESS_EDIT_AMBIGUOUS",
+ )
+ self.assertEqual(device.address_modify_clicks, [])
+ self.assertEqual(device.address_back_clicks, [])
+ adapter.close()
+
+ def test_default_address_with_multiple_modify_targets_stops_safely(self):
+ device = AmbiguousDefaultAddressDevice()
+ adapter = self._live_adapter(device, [])
+ adapter.open_goods(GOODS_URL)
+ adapter.select_options(
+ {"color": "黑色", "size": "3XL【140-165斤】"}
+ )
+
+ with self.assertRaises(PddPurchaseError) as raised:
+ adapter.update_shipping_address("cg8")
+
+ self.assertEqual(
+ raised.exception.code, "PURCHASE_ADDRESS_EDIT_AMBIGUOUS"
+ )
+ self.assertEqual(raised.exception.diagnostics["candidate_count"], 2)
+ self.assertEqual(device.address_modify_clicks, [])
+ adapter.close()
+
def test_saved_address_panel_can_hide_full_address(self):
device = HiddenSavedAddressDevice()
adapter = self._live_adapter(device, [])