From bee3cb0e4b5eaa0d4fb0b4163e78dc11e2546522 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 18 Aug 2026 16:52:04 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A7=84=E6=A0=BC=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E5=85=A5=E5=8F=A3=E6=9C=89=E9=99=90=E4=B8=8B?= =?UTF-8?q?=E6=BB=91=E8=AF=86=E5=88=AB=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pdd_u2_purchase_adapter.py | 117 +++++++++++++++++++- client/test/test_pdd_u2_purchase_adapter.py | 50 +++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/client/src/pdd_u2_purchase_adapter.py b/client/src/pdd_u2_purchase_adapter.py index f793c0a..02b884f 100644 --- a/client/src/pdd_u2_purchase_adapter.py +++ b/client/src/pdd_u2_purchase_adapter.py @@ -14,7 +14,7 @@ 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 +from typing import Any, Callable, Mapping, NoReturn, Optional from urllib.parse import parse_qs, urlparse from .db import data_dir @@ -85,6 +85,8 @@ _PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支 _FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买") _OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄") _ADDRESS_ACTION_SETTLE_SECONDS = 1.0 +_ADDRESS_ENTRY_MAX_SWIPES = 3 +_ADDRESS_ENTRY_STABLE_READ_LIMIT = 2 _COLOR_DIMENSION_HEADINGS = frozenset( {"颜色分类", "顏色分類", "颜色", "顏色", "款式", "花色", "颜色款式"} ) @@ -2040,7 +2042,7 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): step="purchase_update_shipping_address", ) - root = _parse_xml(self._dump_hierarchy()) + root = self._prepare_address_entry() entry_targets = _address_entry_targets(root) self._click_unique_address_target( entry_targets, @@ -2107,6 +2109,117 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter): exc, "purchase_update_shipping_address" ) + def _prepare_address_entry(self) -> ET.Element: + """确保规格面板顶部的收货地址入口可见且唯一。 + + 地址入口可能因规格面板停留在下方而被裁剪。只有首次识别失败时, + 才在唯一的规格滚动容器内有限下滑,并在每次操作后重新读取控件树。 + """ + + root = _parse_xml(self._dump_hierarchy()) + if len(_address_entry_targets(root)) == 1: + return root + + region = _purchase_panel_scroll_region(root) + if region is None: + self._raise_address_entry_ambiguous( + swipe_count=0, + stable_reads=0, + scroll_region_found=False, + candidate_count=len(_address_entry_targets(root)), + ) + + device = self._require_device() + previous_signature = _region_signature(root, region) + unchanged_reads = 0 + swipe_count = 0 + candidate_count = len(_address_entry_targets(root)) + for _ in range(_ADDRESS_ENTRY_MAX_SWIPES): + self._check_cancelled("purchase_update_shipping_address") + left, top, right, bottom = region + height = bottom - top + x = (left + right) // 2 + # 手指向下滑,使规格面板内容向上移动,显示顶部联系人和地址。 + device.swipe( + x, + top + int(height * 0.24), + x, + top + int(height * 0.78), + duration=0.35, + ) + swipe_count += 1 + self._sleep(0.5) + + 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_address_entry_ambiguous( + swipe_count=swipe_count, + stable_reads=unchanged_reads, + scroll_region_found=True, + candidate_count=len(_address_entry_targets(root)), + ) + + entry_targets = _address_entry_targets(root) + candidate_count = len(entry_targets) + if candidate_count == 1: + return root + + current_region = _purchase_panel_scroll_region(root) + if current_region is None: + break + signature = _region_signature(root, current_region) + if signature == previous_signature: + unchanged_reads += 1 + if unchanged_reads >= _ADDRESS_ENTRY_STABLE_READ_LIMIT: + break + else: + unchanged_reads = 0 + previous_signature = signature + region = current_region + + self._raise_address_entry_ambiguous( + swipe_count=swipe_count, + stable_reads=unchanged_reads, + scroll_region_found=True, + candidate_count=candidate_count, + ) + raise AssertionError("unreachable") + + def _raise_address_entry_ambiguous( + self, + *, + swipe_count: int, + stable_reads: int, + scroll_region_found: bool, + candidate_count: int, + ) -> NoReturn: + """以不含个人信息的诊断结束地址入口识别。""" + + diagnostics: dict[str, Any] = { + "candidate_count": candidate_count, + "swipe_count": swipe_count, + "stable_reads": stable_reads, + "scroll_region_found": scroll_region_found, + } + artifact = self._save_last_xml("purchase-address-entry-ambiguous") + if artifact is not None: + diagnostics["artifacts"] = [artifact] + raise PddPurchaseError( + "PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", + "规格面板没有唯一可靠的收货地址入口", + step="purchase_update_shipping_address", + diagnostics=diagnostics, + ) + def _click_unique_address_target( self, targets: list[Bounds], code: str, message: str ) -> None: diff --git a/client/test/test_pdd_u2_purchase_adapter.py b/client/test/test_pdd_u2_purchase_adapter.py index 7abb56e..aa275ef 100644 --- a/client/test/test_pdd_u2_purchase_adapter.py +++ b/client/test/test_pdd_u2_purchase_adapter.py @@ -544,6 +544,19 @@ def address_confirmation_xml(address: str) -> str: """ +def clipped_address_confirmation_xml(address: str) -> str: + """首次地址卡片被顶部裁剪时的脱敏控件树。""" + + return address_confirmation_xml(address).replace( + '', + "", + ).replace( + '', + '', + ) + + def address_panel_xml(address: str) -> str: return f""" @@ -976,6 +989,25 @@ class LostFirstModifyClickAddressDevice(AddressFlowDevice): self.mode = "address_edit" +class ClippedAddressFlowDevice(AddressFlowDevice): + """模拟规格面板下滑后才显示联系人和手机号。""" + + def __init__(self) -> None: + super().__init__() + self.swipes = [] + self.address_visible = False + + def dump_hierarchy(self): + if self.mode == "panel" and not self.address_visible: + return clipped_address_confirmation_xml(self.address) + return super().dump_hierarchy() + + def swipe(self, x1, y1, x2, y2, duration=0.0): + self.swipes.append((x1, y1, x2, y2, duration)) + if y2 > y1: + self.address_visible = True + + class NeverOpenAddressEditDevice(AddressFlowDevice): """模拟两次“修改”点击都未打开编辑页。""" @@ -1897,6 +1929,24 @@ class U2PddPurchaseAdapterTest(unittest.TestCase): self.assertEqual(clock.sleeps.count(1.0), 5) adapter.close() + def test_address_entry_scrolls_down_once_when_contact_is_clipped(self): + device = ClippedAddressFlowDevice() + adapter = self._live_adapter(device, []) + adapter.open_goods(GOODS_URL) + adapter.select_options( + {"color": "黑色", "size": "3XL【140-165斤】"} + ) + + adapter.update_shipping_address("cg12") + + self.assertEqual(len(device.swipes), 1) + self.assertLess(device.swipes[0][1], device.swipes[0][3]) + self.assertEqual( + device.address, "测试省测试市测试区测试路1号_cg12" + ) + self.assertEqual(device.mode, "panel") + adapter.close() + def test_multiple_addresses_modify_only_default_card(self): device = MultipleAddressFlowDevice(default_indexes=(0,)) adapter = self._live_adapter(device, [])