fix: 完整遍历尺码与套餐第二规格 (#167)
This commit is contained in:
@@ -718,6 +718,11 @@ def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot:
|
||||
if options:
|
||||
groups.append((name, options))
|
||||
|
||||
# 嵌套 RecyclerView 会先加入 groups,可能把页面下方的“套餐”排在
|
||||
# 上方“款式”之前。规范 key 依赖页面语义顺序,必须按标题 y 坐标恢复。
|
||||
heading_tops = {name: top for top, name in heading_nodes}
|
||||
groups.sort(key=lambda item: heading_tops.get(item[0], outer_bounds[3]))
|
||||
|
||||
used_keys: set[str] = set()
|
||||
dimensions: list[SpecDimension] = []
|
||||
for name, option_nodes in groups:
|
||||
@@ -1697,15 +1702,19 @@ class PddCollectService:
|
||||
return latest_xml
|
||||
|
||||
def _collect_size_dimension(self, device: Any) -> Optional[SpecDimension]:
|
||||
"""颜色采价完成后只滚动并收集尺码文字,不点击尺码。"""
|
||||
"""完整遍历第二规格并收集文字,全程不点击尺码或套餐。"""
|
||||
|
||||
sizes: dict[str, bool] = {}
|
||||
size_name = "尺码"
|
||||
xml_data: Optional[str] = None
|
||||
size_found = False
|
||||
previous_signature: Optional[
|
||||
tuple[tuple[str, tuple[str, ...]], ...]
|
||||
] = None
|
||||
stable_edge_reads = 0
|
||||
|
||||
# 颜色采价结束时页面通常仍在顶部,先纵向找到第二规格。这个阶段
|
||||
# 保留原有行为,避免改变普通衣服“颜色 + 尺码”的稳定流程。
|
||||
for swipe_count in range(self._max_spec_swipes + 1):
|
||||
self._check_cancelled()
|
||||
xml_data, snapshot = self._read_valid_spec_panel(device)
|
||||
@@ -1717,9 +1726,13 @@ class PddCollectService:
|
||||
)
|
||||
if dimension.key == "size":
|
||||
size_name = dimension.name
|
||||
size_found = True
|
||||
for value in dimension.values:
|
||||
sizes[value.text] = sizes.get(value.text, False) or value.available
|
||||
|
||||
if size_found:
|
||||
break
|
||||
|
||||
signature = tuple(
|
||||
(item.key, tuple(value.text for value in item.values))
|
||||
for item in snapshot.dimensions
|
||||
@@ -1741,14 +1754,219 @@ class PddCollectService:
|
||||
)
|
||||
self._sleep(0.35)
|
||||
|
||||
if not sizes:
|
||||
if not size_found or xml_data is None:
|
||||
return None
|
||||
|
||||
xml_data, start_confirmed = self._move_second_dimension_to_start(
|
||||
device, xml_data
|
||||
)
|
||||
strict_edge_check = "套餐" in size_name and self._max_spec_swipes > 0
|
||||
if strict_edge_check and not start_confirmed:
|
||||
raise PddCollectError(
|
||||
"PDD_DATA_SPEC_INCOMPLETE",
|
||||
f"{size_name}无法确认已经到达列表起点",
|
||||
)
|
||||
# 第一阶段只负责找到分组;正式输出从确认后的起点重新按页面顺序收集。
|
||||
sizes = {}
|
||||
previous_signature = None
|
||||
stable_edge_reads = 0
|
||||
end_confirmed = False
|
||||
for swipe_count in range(self._max_spec_swipes + 1):
|
||||
self._check_cancelled()
|
||||
xml_data, snapshot = self._read_valid_spec_panel(
|
||||
device, initial_xml=xml_data
|
||||
)
|
||||
size_dimension = next(
|
||||
(item for item in snapshot.dimensions if item.key == "size"),
|
||||
None,
|
||||
)
|
||||
if size_dimension is None:
|
||||
raise PddCollectError(
|
||||
"PDD_DATA_SPEC_INCOMPLETE",
|
||||
f"滚动过程中丢失第二规格:{size_name}",
|
||||
)
|
||||
size_name = size_dimension.name
|
||||
for value in size_dimension.values:
|
||||
sizes[value.text] = sizes.get(value.text, False) or value.available
|
||||
|
||||
signature = self._second_dimension_signature(xml_data)
|
||||
stable_edge_reads = (
|
||||
stable_edge_reads + 1 if signature == previous_signature else 0
|
||||
)
|
||||
previous_signature = signature
|
||||
if stable_edge_reads >= 2:
|
||||
end_confirmed = True
|
||||
break
|
||||
if swipe_count >= self._max_spec_swipes:
|
||||
break
|
||||
|
||||
root = _parse_xml(xml_data)
|
||||
region, horizontal = self._second_dimension_region(
|
||||
root, size_dimension
|
||||
)
|
||||
if region is None:
|
||||
end_confirmed = True
|
||||
break
|
||||
self._swipe_region(
|
||||
device,
|
||||
region,
|
||||
horizontal=horizontal,
|
||||
reverse=False,
|
||||
)
|
||||
self._sleep(
|
||||
self._horizontal_swipe_settle_interval if horizontal else 0.35
|
||||
)
|
||||
xml_data = None
|
||||
|
||||
expected_count = self._dimension_expected_count(size_name)
|
||||
if expected_count is not None and len(sizes) < expected_count:
|
||||
raise PddCollectError(
|
||||
"PDD_DATA_SPEC_INCOMPLETE",
|
||||
f"{size_name}应有 {expected_count} 个选项,实际只采到 {len(sizes)} 个",
|
||||
{
|
||||
"dimension_name": size_name,
|
||||
"expected_count": expected_count,
|
||||
"collected_count": len(sizes),
|
||||
},
|
||||
)
|
||||
if expected_count is None and strict_edge_check and not end_confirmed:
|
||||
raise PddCollectError(
|
||||
"PDD_DATA_SPEC_INCOMPLETE",
|
||||
f"{size_name}达到滑动上限,无法确认是否采集完整",
|
||||
{"dimension_name": size_name, "collected_count": len(sizes)},
|
||||
)
|
||||
return SpecDimension(
|
||||
"size",
|
||||
size_name,
|
||||
tuple(DimensionValue(text, available) for text, available in sizes.items()),
|
||||
)
|
||||
|
||||
def _move_second_dimension_to_start(
|
||||
self, device: Any, xml_data: str
|
||||
) -> tuple[str, bool]:
|
||||
"""横向第二规格先归位到左端;纵向列表保持当前安全起点。"""
|
||||
|
||||
previous_signature: Optional[tuple[tuple[str, Bounds], ...]] = None
|
||||
stable_edge_reads = 0
|
||||
for attempt in range(self._max_spec_swipes + 1):
|
||||
snapshot = parse_spec_panel(xml_data)
|
||||
size_dimension = next(
|
||||
(item for item in snapshot.dimensions if item.key == "size"),
|
||||
None,
|
||||
)
|
||||
if size_dimension is None:
|
||||
return xml_data, False
|
||||
root = _parse_xml(xml_data)
|
||||
region, horizontal = self._second_dimension_region(
|
||||
root, size_dimension
|
||||
)
|
||||
if region is None or not horizontal:
|
||||
return xml_data, True
|
||||
signature = self._second_dimension_signature(xml_data)
|
||||
stable_edge_reads = (
|
||||
stable_edge_reads + 1 if signature == previous_signature else 0
|
||||
)
|
||||
previous_signature = signature
|
||||
if stable_edge_reads >= 2:
|
||||
return xml_data, True
|
||||
if attempt >= self._max_spec_swipes:
|
||||
return xml_data, False
|
||||
self._swipe_region(
|
||||
device, region, horizontal=True, reverse=True
|
||||
)
|
||||
self._sleep(self._horizontal_swipe_settle_interval)
|
||||
xml_data, _ = self._read_valid_spec_panel(device)
|
||||
return xml_data, False
|
||||
|
||||
def _second_dimension_signature(
|
||||
self, xml_data: str | bytes
|
||||
) -> tuple[tuple[str, Bounds], ...]:
|
||||
"""只返回当前可见第二规格,避免其他区域变化干扰到边判断。"""
|
||||
|
||||
root = _parse_xml(xml_data)
|
||||
snapshot = parse_spec_panel(xml_data)
|
||||
size_dimension = next(
|
||||
(item for item in snapshot.dimensions if item.key == "size"),
|
||||
None,
|
||||
)
|
||||
if size_dimension is None:
|
||||
return ()
|
||||
result = []
|
||||
for value in size_dimension.values:
|
||||
node = self._find_option_node(root, value.text)
|
||||
if node is None:
|
||||
continue
|
||||
bounds = _parse_bounds(node.get("bounds", ""))
|
||||
if bounds is not None:
|
||||
result.append((value.text, bounds))
|
||||
return tuple(result)
|
||||
|
||||
def _second_dimension_region(
|
||||
self,
|
||||
root: ET.Element,
|
||||
dimension: SpecDimension,
|
||||
) -> tuple[Optional[Bounds], bool]:
|
||||
"""返回第二规格最近的滚动容器及其主要滚动方向。"""
|
||||
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
containers: dict[ET.Element, int] = {}
|
||||
option_bounds: list[Bounds] = []
|
||||
for value in dimension.values:
|
||||
node = self._find_option_node(root, value.text)
|
||||
if node is None:
|
||||
continue
|
||||
bounds = _parse_bounds(node.get("bounds", ""))
|
||||
if bounds is not None:
|
||||
option_bounds.append(bounds)
|
||||
current = parents.get(node)
|
||||
while current is not None:
|
||||
if (
|
||||
current.get("scrollable") == "true"
|
||||
and _parse_bounds(current.get("bounds", "")) is not None
|
||||
):
|
||||
containers[current] = containers.get(current, 0) + 1
|
||||
break
|
||||
current = parents.get(current)
|
||||
|
||||
if containers:
|
||||
container = max(
|
||||
containers,
|
||||
key=lambda item: (
|
||||
containers[item],
|
||||
len(_ancestors(item, parents)),
|
||||
),
|
||||
)
|
||||
region = _parse_bounds(container.get("bounds", ""))
|
||||
else:
|
||||
region = self._vertical_region(root)
|
||||
if region is None:
|
||||
return None, False
|
||||
|
||||
centers = [
|
||||
((left + right) // 2, (top + bottom) // 2)
|
||||
for left, top, right, bottom in option_bounds
|
||||
]
|
||||
region_width = region[2] - region[0]
|
||||
region_height = region[3] - region[1]
|
||||
if region_height >= region_width * 0.75:
|
||||
# 外层规格面板通常很高;其中一行尺码横向排布,不代表面板
|
||||
# 应横向滚动。只有较矮的独立列表才根据选项分布判断横向。
|
||||
horizontal = False
|
||||
elif len(centers) >= 2:
|
||||
x_span = max(item[0] for item in centers) - min(item[0] for item in centers)
|
||||
y_span = max(item[1] for item in centers) - min(item[1] for item in centers)
|
||||
horizontal = x_span > y_span
|
||||
else:
|
||||
horizontal = region_width > region_height * 1.8
|
||||
return region, horizontal
|
||||
|
||||
@staticmethod
|
||||
def _dimension_expected_count(name: str) -> Optional[int]:
|
||||
"""读取“套餐(15)”这类标题中的选项数量。"""
|
||||
|
||||
match = re.search(r"[((]\s*(\d+)\s*[))]\s*$", name)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
def _build_color_price_skus(
|
||||
self,
|
||||
dimensions: tuple[SpecDimension, ...],
|
||||
|
||||
Reference in New Issue
Block a user