diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index 68e0acc..4c7d1a9 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -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", diff --git a/client/test/fixtures/pdd_quick_confirmation.xml b/client/test/fixtures/pdd_quick_confirmation.xml new file mode 100644 index 0000000..7a16964 --- /dev/null +++ b/client/test/fixtures/pdd_quick_confirmation.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index 15dc060..376b3d4 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -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 = """ @@ -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))