fix: 兼容快速确认页进入规格面板 (#190)

This commit is contained in:
chengma
2026-08-12 17:30:03 +08:00
parent 275b9888b7
commit 9109b8891d
3 changed files with 300 additions and 2 deletions
+144 -2
View File
@@ -645,6 +645,132 @@ def _is_spec_panel_open(xml_data: str | bytes) -> bool:
return has_selection_summary or has_submit_hint or has_dimension_heading
def _quick_confirmation_spec_coord(root: ET.Element) -> Optional[tuple[int, int]]:
"""定位快速确认页中的当前主规格值,不接触底部购买按钮。"""
if classify_pdd_page(root, "").kind != PAGE_ORDER_CONFIRMATION:
return None
nodes = list(root.iter("node"))
if sum(node.get("package") == PDD_PACKAGE_NAME for node in nodes) < 3:
return None
bounded_nodes = [
(node, bounds)
for node in nodes
if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
and node.get("visible-to-user", "true") != "false"
]
screen_bottom = max((bounds[3] for _, bounds in bounded_nodes), default=0)
if not screen_bottom:
return None
labels = [_preferred_or_descendant_label(node).replace(" ", "") for node in nodes]
has_close = any(label in ("关闭", "關閉") for label in labels)
has_summary = any(
label.startswith(("已选", "已選")) for label in labels
)
has_payment_area = any(
word in label for label in labels for word in ("微信支付", "先用后付", "支付方式")
)
has_quick_buy_action = any(
"现在买" in _preferred_or_descendant_label(node).replace(" ", "")
and (bounds[1] + bounds[3]) // 2 >= screen_bottom * 0.75
for node, bounds in bounded_nodes
)
quantity_editors = [
node
for node in nodes
if node.get("class") == "android.widget.EditText"
and node.get("text", "").strip().isdigit()
and int(node.get("text", "0")) > 0
]
decreases = [
node
for node in nodes
if _preferred_or_descendant_label(node).replace(" ", "") == "减少数量"
and node.get("clickable") == "true"
]
increases = [
node
for node in nodes
if _preferred_or_descendant_label(node).replace(" ", "") == "增加数量"
and node.get("clickable") == "true"
]
if not (
has_close
and has_summary
and has_payment_area
and has_quick_buy_action
and len(quantity_editors) == 1
and len(decreases) == 1
and len(increases) == 1
):
return None
headings = []
for node, bounds in bounded_nodes:
label = _preferred_node_label(node).strip()
if (
label
and node.get("clickable") != "true"
and _is_dimension_heading(label)
):
headings.append((bounds[1], label, bounds))
headings.sort(key=lambda item: item[0])
used_keys: set[str] = set()
primary_index: Optional[int] = None
for index, (_, label, _) in enumerate(headings):
key = _dimension_key(label, used_keys)
used_keys.add(key)
if key == "color":
primary_index = index
break
if primary_index is None:
return None
_, _, heading_bounds = headings[primary_index]
next_heading_top = (
headings[primary_index + 1][0]
if primary_index + 1 < len(headings)
else int(screen_bottom * 0.75)
)
candidates: list[tuple[int, int, Bounds]] = []
for node, bounds in bounded_nodes:
if node.get("clickable") != "true" or not _is_available(node):
continue
label = _preferred_or_descendant_label(node).strip()
compact = label.replace(" ", "")
if not label:
continue
if compact.startswith(("已选", "已選", "请选择", "請選擇")):
continue
if any(
word in compact
for word in (
"现在买",
"提交订单",
"确认购买",
"支付",
"增加数量",
"减少数量",
"关闭",
)
):
continue
if bounds[1] < heading_bounds[3] or bounds[3] > next_heading_top:
continue
area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1])
candidates.append((bounds[1], area, bounds))
if not candidates:
return None
_, _, target = min(candidates, key=lambda item: (item[0], item[1]))
left, top, right, bottom = target
return (left + right) // 2, (top + bottom) // 2
def _top_level_clickable_options(
container: ET.Element,
parents: Mapping[ET.Element, ET.Element],
@@ -1282,6 +1408,7 @@ class PddCollectService:
deadline = self._monotonic() + self._spec_panel_timeout
saw_confirmation_page = False
quick_confirmation_recovery_attempted = False
incomplete_reads = 0
while self._monotonic() < deadline:
self._check_cancelled()
@@ -1302,12 +1429,27 @@ class PddCollectService:
return snapshot
self._last_invalid_spec_xml = xml_data
incomplete_reads += 1
if not quick_confirmation_recovery_attempted:
coordinate = _quick_confirmation_spec_coord(root)
if coordinate is not None:
# 这里只打开当前主规格,禁止触碰“现在买”等不可逆动作。
device.click(*coordinate)
quick_confirmation_recovery_attempted = True
self._sleep(0.25)
if saw_confirmation_page:
message = "规格面板已经打开,但页面结构无法识别"
if quick_confirmation_recovery_attempted:
message = "快速确认页无法进入可识别的规格选择区域"
raise PddCollectError(
"PDD_DATA_SPEC_INCOMPLETE",
"规格面板已经打开,但页面结构无法识别",
{"incomplete_spec_reads": incomplete_reads},
message,
{
"incomplete_spec_reads": incomplete_reads,
"quick_confirmation_recovery_attempted": (
quick_confirmation_recovery_attempted
),
},
)
raise PddCollectError(
"PDD_PAGE_SPEC_PANEL_TIMEOUT",
+48
View File
@@ -0,0 +1,48 @@
<hierarchy rotation="0">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout"
bounds="[0,0][1080,2340]" visible-to-user="true" enabled="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout"
bounds="[0,220][1080,2210]" visible-to-user="true" enabled="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.ImageView"
content-desc="关闭" clickable="true" bounds="[940,240][1010,310]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="测试收货信息" bounds="[40,360][900,430]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="已选: 测试款式 测试尺码" bounds="[360,820][980,890]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.ImageView"
content-desc="减少数量" clickable="true" bounds="[360,920][430,990]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.EditText"
text="1" clickable="true" bounds="[435,920][510,990]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.ImageView"
content-desc="增加数量" clickable="true" bounds="[515,920][585,990]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="款式" bounds="[32,1170][150,1220]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="测试款式" clickable="true" bounds="[32,1240][470,1320]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="尺码" bounds="[32,1360][150,1410]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="测试尺码" clickable="true" bounds="[32,1430][360,1510]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="使用微信支付,可更换支付方式" bounds="[120,1900][900,1970]"
visible-to-user="true" enabled="true" />
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout"
clickable="true" bounds="[240,2010][800,2140]"
visible-to-user="true" enabled="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.TextView"
text="现在买,仅 ¥15.60" bounds="[280,2040][760,2110]"
visible-to-user="true" enabled="true" />
</node>
</node>
</node>
</hierarchy>
+108
View File
@@ -158,6 +158,30 @@ class PanelDoesNotOpenDevice(FakeCollectDevice):
self.clicks.append((x, y))
class QuickConfirmationDevice(FakeCollectDevice):
"""点击当前款式后,从快速确认页进入正常规格面板。"""
def __init__(self, quick_confirmation_xml, spec_xml):
super().__init__(quick_confirmation_xml, spec_xml)
self.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
self.panel_open = False
def dump_hierarchy(self):
return self.spec_xml if self.panel_open else self.home_xml
def click(self, x, y):
self.clicks.append((x, y))
if 32 <= x <= 470 and 1240 <= y <= 1320:
self.panel_open = True
class StuckQuickConfirmationDevice(QuickConfirmationDevice):
"""快速确认页接受点击后仍不打开规格选择区域。"""
def click(self, x, y):
self.clicks.append((x, y))
TRANSIENT_EMPTY_SPEC_XML = """<hierarchy>
<node class="android.widget.FrameLayout" package="com.xunmeng.pinduoduo"
bounds="[0,0][1080,2340]">
@@ -612,6 +636,9 @@ class PddCollectParserTest(unittest.TestCase):
cls.submit_hint_spec_xml = (
FIXTURES / "pdd_spec_panel_submit_hint.xml"
).read_text(encoding="utf-8")
cls.quick_confirmation_xml = (
FIXTURES / "pdd_quick_confirmation.xml"
).read_text(encoding="utf-8")
cls.sold_out_xml = (
FIXTURES / "pdd_sold_out_recommendations.xml"
).read_text(encoding="utf-8")
@@ -944,6 +971,87 @@ class PddCollectParserTest(unittest.TestCase):
self.assertEqual([item.key for item in snapshot.dimensions], ["color", "size"])
self.assertEqual(clock.sleeps, [])
self.assertEqual(device.clicks, [])
def test_quick_confirmation_is_not_a_complete_spec_panel(self):
self.assertFalse(_is_spec_panel_open(self.quick_confirmation_xml))
with self.assertRaises(PddCollectError) as raised:
parse_spec_panel(self.quick_confirmation_xml)
self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_INCOMPLETE")
def test_wait_opens_spec_selector_from_quick_confirmation_once(self):
device = QuickConfirmationDevice(
self.quick_confirmation_xml,
self.non_scrollable_spec_xml,
)
clock = FakeClock()
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=clock.sleep,
monotonic=clock.monotonic,
spec_panel_timeout=1.0,
)
snapshot = service._wait_spec_panel(device)
self.assertEqual([item.key for item in snapshot.dimensions], ["color", "size"])
self.assertEqual(device.clicks, [(251, 1280)])
self.assertTrue(all(y < 2000 for _, y in device.clicks))
def test_quick_confirmation_recovery_is_attempted_only_once(self):
device = StuckQuickConfirmationDevice(
self.quick_confirmation_xml,
self.non_scrollable_spec_xml,
)
clock = FakeClock()
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=clock.sleep,
monotonic=clock.monotonic,
spec_panel_timeout=1.0,
)
with self.assertRaises(PddCollectError) as raised:
service._wait_spec_panel(device)
self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_INCOMPLETE")
self.assertIn("快速确认页", raised.exception.message)
self.assertEqual(device.clicks, [(251, 1280)])
self.assertTrue(
raised.exception.diagnostics["quick_confirmation_recovery_attempted"]
)
def test_quick_confirmation_without_payment_evidence_is_not_clicked(self):
incomplete_xml = self.quick_confirmation_xml.replace(
"使用微信支付,可更换支付方式",
"普通页面说明",
)
device = StuckQuickConfirmationDevice(
incomplete_xml,
self.non_scrollable_spec_xml,
)
clock = FakeClock()
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=clock.sleep,
monotonic=clock.monotonic,
spec_panel_timeout=1.0,
)
with self.assertRaises(PddCollectError) as raised:
service._wait_spec_panel(device)
self.assertEqual(raised.exception.code, "PDD_DATA_SPEC_INCOMPLETE")
self.assertEqual(device.clicks, [])
self.assertFalse(
raised.exception.diagnostics["quick_confirmation_recovery_attempted"]
)
def test_goods_page_is_not_non_scrollable_spec_panel(self):
self.assertFalse(_is_spec_panel_open(self.home_xml))