fix: 采购选色前归位规格面板 (#230)

This commit is contained in:
chengma
2026-08-14 18:10:56 +08:00
parent cbe8d0347b
commit fee40284b3
2 changed files with 401 additions and 0 deletions
+260
View File
@@ -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 = (
+141
View File
@@ -98,6 +98,53 @@ def contextual_confirm_panel_xml(quantity: int = 1) -> str:
</hierarchy>"""
def vertically_scrolled_purchase_panel_xml(*, at_color_top: bool) -> str:
"""构造规格面板位于颜色顶部或停在中部的脱敏控件树。"""
if at_color_top:
scroll_content = """
<node text="颜色分类" bounds="[32,1120][190,1170]"/>
<node text="黑色" clickable="true" enabled="true"
visible-to-user="true" bounds="[32,1190][332,1320]"/>
<node text="白色" clickable="true" enabled="true"
visible-to-user="true" bounds="[359,1190][659,1320]"/>
<node text="尺码" bounds="[32,1500][114,1550]"/>
<node text="均码" clickable="true" enabled="true"
visible-to-user="true" bounds="[32,1580][391,1660]"/>
"""
else:
scroll_content = """
<node text="黑色 ¥6.93" clickable="true" enabled="true"
visible-to-user="true" bounds="[32,1095][332,1460]"/>
<node text="白色 ¥6.93" clickable="true" enabled="true"
visible-to-user="true" bounds="[359,1095][659,1460]"/>
<node text="尺码" bounds="[32,1605][114,1653]"/>
<node text="均码" clickable="true" selected="true"
enabled="true" visible-to-user="true"
bounds="[32,1677][391,1753]"/>
"""
return f"""<hierarchy>
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
<node text="确认款式" bounds="[430,650][650,710]"/>
<node text="请选择: 颜色分类" bounds="[360,770][990,830]"/>
<node class="android.widget.EditText" text="1"
bounds="[440,870][510,940]"/>
<node content-desc="减少数量" clickable="true"
bounds="[360,870][430,940]"/>
<node content-desc="增加数量" clickable="true"
bounds="[515,870][585,940]"/>
<node package="com.xunmeng.pinduoduo"
class="androidx.recyclerview.widget.RecyclerView"
scrollable="true" enabled="true" visible-to-user="true"
bounds="[0,1095][1020,1918]">
{scroll_content}
</node>
<node text="选择颜色分类后,提交订单"
bounds="[0,2181][1080,2328]"/>
</node>
</hierarchy>"""
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(