From ed72e62edad511fee4be911f4195d3ab7b8d69c8 Mon Sep 17 00:00:00 2001 From: chengma Date: Sun, 9 Aug 2026 10:10:15 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E9=87=87=E9=9B=86=E7=AD=89=E5=BE=85=E6=97=B6=E9=97=B4=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pdd_collect_service.py | 134 +++++++++++++++++++----- client/test/test_pdd_collect_service.py | 133 +++++++++++++++++++++++ 2 files changed, 238 insertions(+), 29 deletions(-) diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py index f3b822b..eb2e474 100644 --- a/client/src/pdd_collect_service.py +++ b/client/src/pdd_collect_service.py @@ -7,6 +7,7 @@ from __future__ import annotations import hashlib +import math import re import time import xml.etree.ElementTree as ET @@ -638,6 +639,10 @@ class PddCollectService: max_goods_page_swipes: int = 12, max_spec_swipes: int = 12, max_sku_count: int = 200, + color_poll_interval: float = 0.1, + color_selection_timeout: float = 0.6, + color_price_timeout: float = 1.2, + horizontal_swipe_settle_interval: float = 0.1, artifact_directory: Optional[Path] = None, ) -> None: self._device_service = device_service @@ -654,6 +659,21 @@ class PddCollectService: self._max_goods_page_swipes = max_goods_page_swipes self._max_spec_swipes = max_spec_swipes self._max_sku_count = max_sku_count + timing_values = { + "颜色轮询间隔": color_poll_interval, + "颜色选中超时": color_selection_timeout, + "颜色价格超时": color_price_timeout, + "水平滑动稳定间隔": horizontal_swipe_settle_interval, + } + for name, value in timing_values.items(): + if value <= 0: + raise ValueError(f"{name}必须大于 0 秒") + self._color_poll_interval = color_poll_interval + self._color_selection_timeout = color_selection_timeout + self._color_price_timeout = color_price_timeout + self._horizontal_swipe_settle_interval = ( + horizontal_swipe_settle_interval + ) self._artifact_directory = artifact_directory self._last_goods_xml: Optional[str] = None self._goods_screens_checked = 0 @@ -953,13 +973,15 @@ class PddCollectService: current_row_colors: list[DimensionValue] = [] stable_edge_reads = 0 previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None + pending_xml: Optional[str] = None for swipe_count in range(self._max_spec_swipes + 1): self._check_cancelled() # 每次只处理一个节点;点击可能让列表自动移动,下一项必须 # 从最新 XML 重新计算,不能继续使用点击前的旧坐标。 while True: - xml_data = self._dump_hierarchy(device) + xml_data = pending_xml or self._dump_hierarchy(device) + pending_xml = None rows = self._visible_color_rows(xml_data) if row_index >= len(rows): raise PddCollectError( @@ -998,7 +1020,7 @@ class PddCollectService: if stable_edge_reads >= 2 or swipe_count >= self._max_spec_swipes: break - root = _parse_xml(self._dump_hierarchy(device)) + root = _parse_xml(xml_data) region = self._horizontal_region(root) if region is None: break @@ -1008,7 +1030,9 @@ class PddCollectService: horizontal=True, reverse=not move_right, ) - self._sleep(0.35) + pending_xml = self._wait_for_horizontal_change( + device, signature + ) # 操作采用蛇形以减少无效滑动;输出仍恢复成页面自然的 # “每行从左到右”顺序,方便 Admin 下拉框稳定展示。 @@ -1030,13 +1054,10 @@ class PddCollectService: previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None stable_edge_reads = 0 + xml_data = self._dump_hierarchy(device) for _ in range(self._max_spec_swipes): self._check_cancelled() - xml_data = self._dump_hierarchy(device) - rows = self._visible_color_rows(xml_data) - signature = tuple( - (item.text, item.bounds) for row in rows for item in row - ) + signature = self._color_view_signature(xml_data) if signature == previous_signature: stable_edge_reads += 1 else: @@ -1052,7 +1073,7 @@ class PddCollectService: self._swipe_region( device, region, horizontal=True, reverse=True ) - self._sleep(0.35) + xml_data = self._wait_for_horizontal_change(device, signature) def _click_and_sample_color( self, device: Any, target: str @@ -1080,11 +1101,20 @@ class PddCollectService: (bounds[1] + bounds[3]) // 2, ) + selection_deadline = self._monotonic() + self._color_selection_timeout + selection_sleep_limit = math.ceil( + self._color_selection_timeout / self._color_poll_interval + ) + selection_sleeps = 0 + price_deadline: Optional[float] = None + price_sleep_limit = math.ceil( + self._color_price_timeout / self._color_poll_interval + ) + price_sleeps = 0 previous_price: Optional[tuple[int, Optional[str], Optional[int]]] = None stable_price_reads = 0 - for _ in range(8): + while True: self._check_cancelled() - self._sleep(0.2) latest_xml = self._dump_hierarchy(device) latest_root = _parse_xml(latest_xml) snapshot = parse_spec_panel(latest_xml) @@ -1097,28 +1127,74 @@ class PddCollectService: # 只能以完整可见可点击节点未报错的点击作为降级证据。 selection_confirmed = selected or not selection_exposed - if snapshot.price_cent is None or not selection_confirmed: + now = self._monotonic() + if not selection_confirmed: stable_price_reads = 0 previous_price = None - continue - current_price = ( - snapshot.price_cent, - snapshot.raw_price, - snapshot.list_price_cent, - ) - if current_price == previous_price: - stable_price_reads += 1 - else: - stable_price_reads = 1 - previous_price = current_price - if stable_price_reads >= 2: - return ColorPriceSample( - snapshot.price_cent, - snapshot.raw_price, - snapshot.list_price_cent, + selection_timed_out = ( + now >= selection_deadline + or selection_sleeps >= selection_sleep_limit ) + if selection_timed_out: + return ColorPriceSample(None, None, None) + else: + if price_deadline is None: + price_deadline = now + self._color_price_timeout + if snapshot.price_cent is None: + stable_price_reads = 0 + previous_price = None + else: + current_price = ( + snapshot.price_cent, + snapshot.raw_price, + snapshot.list_price_cent, + ) + if current_price == previous_price: + stable_price_reads += 1 + else: + stable_price_reads = 1 + previous_price = current_price + if stable_price_reads >= 2: + return ColorPriceSample( + snapshot.price_cent, + snapshot.raw_price, + snapshot.list_price_cent, + ) + if now >= price_deadline or price_sleeps >= price_sleep_limit: + return ColorPriceSample(None, None, None) - return ColorPriceSample(None, None, None) + self._sleep(self._color_poll_interval) + if selection_confirmed: + price_sleeps += 1 + else: + selection_sleeps += 1 + + def _color_view_signature( + self, xml_data: str | bytes + ) -> tuple[tuple[str, Bounds], ...]: + """返回颜色视口签名,用于判断水平滑动是否已经更新页面。""" + + return tuple( + (item.text, item.bounds) + for row in self._visible_color_rows(xml_data) + for item in row + ) + + def _wait_for_horizontal_change( + self, + device: Any, + previous_signature: tuple[tuple[str, Bounds], ...], + ) -> str: + """水平滑动后短暂等待;首次未变化时只补等一次。""" + + latest_xml = "" + for _ in range(2): + self._check_cancelled() + self._sleep(self._horizontal_swipe_settle_interval) + latest_xml = self._dump_hierarchy(device) + if self._color_view_signature(latest_xml) != previous_signature: + break + return latest_xml def _collect_size_dimension(self, device: Any) -> Optional[SpecDimension]: """颜色采价完成后只滚动并收集尺码文字,不点击尺码。""" diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py index a2b735f..dc4cbac 100644 --- a/client/test/test_pdd_collect_service.py +++ b/client/test/test_pdd_collect_service.py @@ -239,6 +239,58 @@ class AllMissingColorPriceDevice(IgnoredColorClickDevice): return super()._spec_xml().replace('selected="true"', 'selected="false"') +class NoColorPriceDevice(SnakeColorDevice): + """颜色可以选中,但价格节点一直没有出现。""" + + def _spec_xml(self): + price = self.prices[self.selected_color] / 100 + return super()._spec_xml().replace( + f'text="¥{price:.2f}"', 'text="价格加载中"' + ) + + +class DelayedHorizontalSwipeDevice(SnakeColorDevice): + """水平滑动后第一次读取仍返回旧视口,第二次才更新。""" + + def __init__(self, home_xml): + super().__init__(home_xml) + self.pending_page = None + self.old_page_reads = 0 + + def dump_hierarchy(self): + if self.panel_open and self.pending_page is not None: + if self.old_page_reads > 0: + self.old_page_reads -= 1 + else: + self.page = self.pending_page + self.pending_page = None + return super().dump_hierarchy() + + def swipe(self, x1, y1, x2, y2, duration=0.35): + self.swipes.append(((x1, y1, x2, y2), {"duration": duration})) + self.swipe_panel_states.append(self.panel_open) + if abs(x2 - x1) > abs(y2 - y1): + self.pending_page = 1 if x1 > x2 else 0 + self.old_page_reads = 1 + else: + self.sizes_visible = True + + +class FakeClock: + """测试用时钟:sleep 只推进虚拟时间,不真的等待。""" + + def __init__(self): + self.now = 0.0 + self.sleeps = [] + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.sleeps.append(seconds) + self.now += seconds + + def keep_only_one_sku(xml_data: str) -> str: """从脱敏固件中删除蓝色和 L,只保留一个组合。""" @@ -569,6 +621,87 @@ class PddCollectParserTest(unittest.TestCase): self.assertIsNone(sample.price_cent) + def test_color_price_is_checked_immediately_then_stabilized(self): + device = SnakeColorDevice(self.home_xml) + device.panel_open = True + device.page = 0 + clock = FakeClock() + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=clock.sleep, + monotonic=clock.monotonic, + ) + + sample = service._click_and_sample_color(device, "A色") + + self.assertEqual(sample.price_cent, 1000) + self.assertEqual(clock.sleeps, [0.1]) + self.assertEqual(device.clicked_colors, ["A色"]) + + def test_ignored_color_click_stops_at_selection_timeout(self): + device = IgnoredColorClickDevice(self.home_xml) + device.panel_open = True + device.page = 0 + clock = FakeClock() + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=clock.sleep, + monotonic=clock.monotonic, + color_poll_interval=0.1, + color_selection_timeout=0.3, + ) + + sample = service._click_and_sample_color(device, "B色") + + self.assertIsNone(sample.price_cent) + self.assertLessEqual(clock.now, 0.31) + self.assertEqual(len(device.clicks), 1) + + def test_selected_color_without_price_stops_at_price_timeout(self): + device = NoColorPriceDevice(self.home_xml) + device.panel_open = True + device.page = 0 + clock = FakeClock() + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=clock.sleep, + monotonic=clock.monotonic, + color_poll_interval=0.1, + color_price_timeout=0.3, + ) + + sample = service._click_and_sample_color(device, "A色") + + self.assertIsNone(sample.price_cent) + self.assertLessEqual(clock.now, 0.31) + self.assertEqual(device.clicked_colors, ["A色"]) + + def test_horizontal_swipe_waits_once_more_when_view_is_unchanged(self): + device = DelayedHorizontalSwipeDevice(self.home_xml) + device.panel_open = True + device.page = 0 + clock = FakeClock() + service = PddCollectService( + PddDeviceService(lambda _serial: device), + "USB-001", + "client-001", + sleeper=clock.sleep, + monotonic=clock.monotonic, + ) + previous = service._color_view_signature(device.dump_hierarchy()) + device.swipe(800, 800, 200, 800, duration=0.35) + + latest_xml = service._wait_for_horizontal_change(device, previous) + + self.assertNotEqual(service._color_view_signature(latest_xml), previous) + self.assertEqual(clock.sleeps, [0.1, 0.1]) + def test_all_missing_color_prices_still_collects_sizes(self): device = AllMissingColorPriceDevice(self.home_xml) service = PddCollectService(