fix: 完整遍历尺码与套餐第二规格 (#167)

This commit is contained in:
chengma
2026-08-11 17:20:37 +08:00
parent 22bf3fe82c
commit 18678e1b82
2 changed files with 307 additions and 2 deletions
+220 -2
View File
@@ -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, ...],