perf: 优化颜色采集等待时间 (#44)
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
@@ -638,6 +639,10 @@ class PddCollectService:
|
|||||||
max_goods_page_swipes: int = 12,
|
max_goods_page_swipes: int = 12,
|
||||||
max_spec_swipes: int = 12,
|
max_spec_swipes: int = 12,
|
||||||
max_sku_count: int = 200,
|
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,
|
artifact_directory: Optional[Path] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._device_service = device_service
|
self._device_service = device_service
|
||||||
@@ -654,6 +659,21 @@ class PddCollectService:
|
|||||||
self._max_goods_page_swipes = max_goods_page_swipes
|
self._max_goods_page_swipes = max_goods_page_swipes
|
||||||
self._max_spec_swipes = max_spec_swipes
|
self._max_spec_swipes = max_spec_swipes
|
||||||
self._max_sku_count = max_sku_count
|
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._artifact_directory = artifact_directory
|
||||||
self._last_goods_xml: Optional[str] = None
|
self._last_goods_xml: Optional[str] = None
|
||||||
self._goods_screens_checked = 0
|
self._goods_screens_checked = 0
|
||||||
@@ -953,13 +973,15 @@ class PddCollectService:
|
|||||||
current_row_colors: list[DimensionValue] = []
|
current_row_colors: list[DimensionValue] = []
|
||||||
stable_edge_reads = 0
|
stable_edge_reads = 0
|
||||||
previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None
|
previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None
|
||||||
|
pending_xml: Optional[str] = None
|
||||||
|
|
||||||
for swipe_count in range(self._max_spec_swipes + 1):
|
for swipe_count in range(self._max_spec_swipes + 1):
|
||||||
self._check_cancelled()
|
self._check_cancelled()
|
||||||
# 每次只处理一个节点;点击可能让列表自动移动,下一项必须
|
# 每次只处理一个节点;点击可能让列表自动移动,下一项必须
|
||||||
# 从最新 XML 重新计算,不能继续使用点击前的旧坐标。
|
# 从最新 XML 重新计算,不能继续使用点击前的旧坐标。
|
||||||
while True:
|
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)
|
rows = self._visible_color_rows(xml_data)
|
||||||
if row_index >= len(rows):
|
if row_index >= len(rows):
|
||||||
raise PddCollectError(
|
raise PddCollectError(
|
||||||
@@ -998,7 +1020,7 @@ class PddCollectService:
|
|||||||
if stable_edge_reads >= 2 or swipe_count >= self._max_spec_swipes:
|
if stable_edge_reads >= 2 or swipe_count >= self._max_spec_swipes:
|
||||||
break
|
break
|
||||||
|
|
||||||
root = _parse_xml(self._dump_hierarchy(device))
|
root = _parse_xml(xml_data)
|
||||||
region = self._horizontal_region(root)
|
region = self._horizontal_region(root)
|
||||||
if region is None:
|
if region is None:
|
||||||
break
|
break
|
||||||
@@ -1008,7 +1030,9 @@ class PddCollectService:
|
|||||||
horizontal=True,
|
horizontal=True,
|
||||||
reverse=not move_right,
|
reverse=not move_right,
|
||||||
)
|
)
|
||||||
self._sleep(0.35)
|
pending_xml = self._wait_for_horizontal_change(
|
||||||
|
device, signature
|
||||||
|
)
|
||||||
|
|
||||||
# 操作采用蛇形以减少无效滑动;输出仍恢复成页面自然的
|
# 操作采用蛇形以减少无效滑动;输出仍恢复成页面自然的
|
||||||
# “每行从左到右”顺序,方便 Admin 下拉框稳定展示。
|
# “每行从左到右”顺序,方便 Admin 下拉框稳定展示。
|
||||||
@@ -1030,13 +1054,10 @@ class PddCollectService:
|
|||||||
|
|
||||||
previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None
|
previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None
|
||||||
stable_edge_reads = 0
|
stable_edge_reads = 0
|
||||||
|
xml_data = self._dump_hierarchy(device)
|
||||||
for _ in range(self._max_spec_swipes):
|
for _ in range(self._max_spec_swipes):
|
||||||
self._check_cancelled()
|
self._check_cancelled()
|
||||||
xml_data = self._dump_hierarchy(device)
|
signature = self._color_view_signature(xml_data)
|
||||||
rows = self._visible_color_rows(xml_data)
|
|
||||||
signature = tuple(
|
|
||||||
(item.text, item.bounds) for row in rows for item in row
|
|
||||||
)
|
|
||||||
if signature == previous_signature:
|
if signature == previous_signature:
|
||||||
stable_edge_reads += 1
|
stable_edge_reads += 1
|
||||||
else:
|
else:
|
||||||
@@ -1052,7 +1073,7 @@ class PddCollectService:
|
|||||||
self._swipe_region(
|
self._swipe_region(
|
||||||
device, region, horizontal=True, reverse=True
|
device, region, horizontal=True, reverse=True
|
||||||
)
|
)
|
||||||
self._sleep(0.35)
|
xml_data = self._wait_for_horizontal_change(device, signature)
|
||||||
|
|
||||||
def _click_and_sample_color(
|
def _click_and_sample_color(
|
||||||
self, device: Any, target: str
|
self, device: Any, target: str
|
||||||
@@ -1080,11 +1101,20 @@ class PddCollectService:
|
|||||||
(bounds[1] + bounds[3]) // 2,
|
(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
|
previous_price: Optional[tuple[int, Optional[str], Optional[int]]] = None
|
||||||
stable_price_reads = 0
|
stable_price_reads = 0
|
||||||
for _ in range(8):
|
while True:
|
||||||
self._check_cancelled()
|
self._check_cancelled()
|
||||||
self._sleep(0.2)
|
|
||||||
latest_xml = self._dump_hierarchy(device)
|
latest_xml = self._dump_hierarchy(device)
|
||||||
latest_root = _parse_xml(latest_xml)
|
latest_root = _parse_xml(latest_xml)
|
||||||
snapshot = parse_spec_panel(latest_xml)
|
snapshot = parse_spec_panel(latest_xml)
|
||||||
@@ -1097,10 +1127,23 @@ class PddCollectService:
|
|||||||
# 只能以完整可见可点击节点未报错的点击作为降级证据。
|
# 只能以完整可见可点击节点未报错的点击作为降级证据。
|
||||||
selection_confirmed = selected or not selection_exposed
|
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
|
stable_price_reads = 0
|
||||||
previous_price = None
|
previous_price = None
|
||||||
continue
|
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 = (
|
current_price = (
|
||||||
snapshot.price_cent,
|
snapshot.price_cent,
|
||||||
snapshot.raw_price,
|
snapshot.raw_price,
|
||||||
@@ -1117,9 +1160,42 @@ class PddCollectService:
|
|||||||
snapshot.raw_price,
|
snapshot.raw_price,
|
||||||
snapshot.list_price_cent,
|
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]:
|
def _collect_size_dimension(self, device: Any) -> Optional[SpecDimension]:
|
||||||
"""颜色采价完成后只滚动并收集尺码文字,不点击尺码。"""
|
"""颜色采价完成后只滚动并收集尺码文字,不点击尺码。"""
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,58 @@ class AllMissingColorPriceDevice(IgnoredColorClickDevice):
|
|||||||
return super()._spec_xml().replace('selected="true"', 'selected="false"')
|
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:
|
def keep_only_one_sku(xml_data: str) -> str:
|
||||||
"""从脱敏固件中删除蓝色和 L,只保留一个组合。"""
|
"""从脱敏固件中删除蓝色和 L,只保留一个组合。"""
|
||||||
|
|
||||||
@@ -569,6 +621,87 @@ class PddCollectParserTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIsNone(sample.price_cent)
|
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):
|
def test_all_missing_color_prices_still_collects_sizes(self):
|
||||||
device = AllMissingColorPriceDevice(self.home_xml)
|
device = AllMissingColorPriceDevice(self.home_xml)
|
||||||
service = PddCollectService(
|
service = PddCollectService(
|
||||||
|
|||||||
Reference in New Issue
Block a user