diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py
index 119b215..90f700b 100644
--- a/client/src/pdd_collect_service.py
+++ b/client/src/pdd_collect_service.py
@@ -174,6 +174,18 @@ class SpecSnapshot:
list_price_cent: Optional[int]
+@dataclass(frozen=True)
+class _SecondDimensionContext:
+ """标题滑出屏幕后,继续识别第二规格所需的稳定特征。"""
+
+ name: str
+ horizontal: bool
+ container_class: str
+ container_resource_id: str
+ container_bounds: Bounds
+ option_structures: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
+
+
@dataclass(frozen=True)
class VisibleSpecOption:
"""当前控件树中完整可点击的规格节点。"""
@@ -1728,6 +1740,8 @@ class PddCollectService:
size_name = dimension.name
size_found = True
for value in dimension.values:
+ if self._is_second_dimension_action(value.text):
+ continue
sizes[value.text] = sizes.get(value.text, False) or value.available
if size_found:
@@ -1760,6 +1774,19 @@ class PddCollectService:
xml_data, start_confirmed = self._move_second_dimension_to_start(
device, xml_data
)
+ initial_snapshot = parse_spec_panel(xml_data)
+ initial_size_dimension = next(
+ (item for item in initial_snapshot.dimensions if item.key == "size"),
+ None,
+ )
+ if initial_size_dimension is None:
+ raise PddCollectError(
+ "PDD_DATA_SPEC_INCOMPLETE",
+ f"无法建立第二规格续页上下文:{size_name}",
+ )
+ continuation = self._build_second_dimension_context(
+ xml_data, initial_size_dimension
+ )
strict_edge_check = "套餐" in size_name and self._max_spec_swipes > 0
if strict_edge_check and not start_confirmed:
raise PddCollectError(
@@ -1780,16 +1807,31 @@ class PddCollectService:
(item for item in snapshot.dimensions if item.key == "size"),
None,
)
+ if size_dimension is None:
+ size_dimension = self._extract_second_dimension_continuation(
+ xml_data, continuation
+ )
+ if size_dimension is None:
+ # 控件树偶尔会在滚动动画中短暂缺节点,再读取一次后才判定失败。
+ xml_data, retry_snapshot = self._read_valid_spec_panel(device)
+ size_dimension = next(
+ (item for item in retry_snapshot.dimensions if item.key == "size"),
+ None,
+ ) or self._extract_second_dimension_continuation(
+ xml_data, continuation
+ )
if size_dimension is None:
raise PddCollectError(
"PDD_DATA_SPEC_INCOMPLETE",
- f"滚动过程中丢失第二规格:{size_name}",
+ f"滚动过程中持续丢失第二规格:{size_name}",
)
size_name = size_dimension.name
for value in size_dimension.values:
+ if self._is_second_dimension_action(value.text):
+ continue
sizes[value.text] = sizes.get(value.text, False) or value.available
- signature = self._second_dimension_signature(xml_data)
+ signature = self._second_dimension_signature(xml_data, size_dimension)
stable_edge_reads = (
stable_edge_reads + 1 if signature == previous_signature else 0
)
@@ -1862,7 +1904,7 @@ class PddCollectService:
)
if region is None or not horizontal:
return xml_data, True
- signature = self._second_dimension_signature(xml_data)
+ signature = self._second_dimension_signature(xml_data, size_dimension)
stable_edge_reads = (
stable_edge_reads + 1 if signature == previous_signature else 0
)
@@ -1879,20 +1921,15 @@ class PddCollectService:
return xml_data, False
def _second_dimension_signature(
- self, xml_data: str | bytes
+ self,
+ xml_data: str | bytes,
+ dimension: SpecDimension,
) -> 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:
+ for value in dimension.values:
node = self._find_option_node(root, value.text)
if node is None:
continue
@@ -1901,6 +1938,167 @@ class PddCollectService:
result.append((value.text, bounds))
return tuple(result)
+ def _build_second_dimension_context(
+ self,
+ xml_data: str | bytes,
+ dimension: SpecDimension,
+ ) -> _SecondDimensionContext:
+ """记录第二规格容器和选项结构,供标题滑出后的续页使用。"""
+
+ root = _parse_xml(xml_data)
+ parents = {child: parent for parent in root.iter() for child in parent}
+ region, horizontal = self._second_dimension_region(root, dimension)
+ if region is None:
+ raise PddCollectError(
+ "PDD_DATA_SPEC_INCOMPLETE",
+ f"无法定位第二规格滚动区域:{dimension.name}",
+ )
+ option_nodes = [
+ node
+ for value in dimension.values
+ if (node := self._find_option_node(root, value.text)) is not None
+ ]
+ containers = [
+ ancestor
+ for node in option_nodes
+ for ancestor in _ancestors(node, parents)
+ if ancestor.get("scrollable") == "true"
+ and _parse_bounds(ancestor.get("bounds", "")) == region
+ ]
+ if not containers:
+ raise PddCollectError(
+ "PDD_DATA_SPEC_INCOMPLETE",
+ f"无法锁定第二规格滚动容器:{dimension.name}",
+ )
+ container = containers[0]
+ structures = tuple(
+ dict.fromkeys(
+ self._option_structure(node, container, parents)
+ for node in option_nodes
+ )
+ )
+ return _SecondDimensionContext(
+ name=dimension.name,
+ horizontal=horizontal,
+ container_class=container.get("class", ""),
+ container_resource_id=container.get("resource-id", ""),
+ container_bounds=region,
+ option_structures=structures,
+ )
+
+ def _extract_second_dimension_continuation(
+ self,
+ xml_data: str | bytes,
+ context: _SecondDimensionContext,
+ ) -> Optional[SpecDimension]:
+ """规格标题不可见时,从已锁定容器提取同结构的后续选项。"""
+
+ root = _parse_xml(xml_data)
+ parents = {child: parent for parent in root.iter() for child in parent}
+ container = self._find_second_dimension_container(root, context)
+ if container is None:
+ return None
+ container_bounds = _parse_bounds(container.get("bounds", ""))
+ if container_bounds is None:
+ return None
+
+ values: list[DimensionValue] = []
+ seen: set[str] = set()
+ for node in _top_level_clickable_options(container, parents):
+ if self._option_structure(node, container, parents) not in context.option_structures:
+ continue
+ text = _preferred_or_descendant_label(node).strip()
+ bounds = _parse_bounds(node.get("bounds", ""))
+ if (
+ not text
+ or text in seen
+ or bounds is None
+ or self._is_second_dimension_action(text)
+ or bounds[2] - bounds[0]
+ >= (container_bounds[2] - container_bounds[0]) * 0.85
+ ):
+ continue
+ if text.endswith(("…", "...")):
+ raise PddCollectError(
+ "PDD_DATA_SKU_NAME_TRUNCATED",
+ f"规格名称被截断,无法安全采集:{text}",
+ )
+ seen.add(text)
+ values.append(DimensionValue(text, _is_available(node)))
+ if not values:
+ return None
+ return SpecDimension("size", context.name, tuple(values))
+
+ @staticmethod
+ def _option_structure(
+ node: ET.Element,
+ container: ET.Element,
+ parents: Mapping[ET.Element, ET.Element],
+ ) -> tuple[str, str, tuple[tuple[str, str], ...]]:
+ """生成不依赖坐标和 XML 对象身份的选项结构特征。"""
+
+ ancestors: list[tuple[str, str]] = []
+ current = parents.get(node)
+ while current is not None and current is not container:
+ ancestors.append(
+ (current.get("class", ""), current.get("resource-id", ""))
+ )
+ current = parents.get(current)
+ return (
+ node.get("class", ""),
+ node.get("resource-id", ""),
+ tuple(ancestors),
+ )
+
+ @staticmethod
+ def _find_second_dimension_container(
+ root: ET.Element,
+ context: _SecondDimensionContext,
+ ) -> Optional[ET.Element]:
+ """在新控件树中重新找到首次锁定的滚动容器。"""
+
+ candidates = [
+ node
+ for node in root.iter("node")
+ if node.get("scrollable") == "true"
+ and _parse_bounds(node.get("bounds", "")) is not None
+ and (
+ not context.container_resource_id
+ or node.get("resource-id", "") == context.container_resource_id
+ )
+ ]
+ if not candidates:
+ return None
+ return max(
+ candidates,
+ key=lambda node: (
+ _parse_bounds(node.get("bounds", "")) == context.container_bounds,
+ node.get("class", "") == context.container_class,
+ ),
+ )
+
+ @staticmethod
+ def _is_second_dimension_action(text: str) -> bool:
+ """排除规格面板中容易和选项结构相同的操作按钮。"""
+
+ compact = text.replace(" ", "")
+ if "¥" in compact or "¥" in compact:
+ return True
+ return compact.startswith(("已选", "请选择")) or any(
+ marker in compact
+ for marker in (
+ "增加数量",
+ "减少数量",
+ "提交订单",
+ "立即购买",
+ "单独购买",
+ "免拼购买",
+ "发起拼单",
+ "确认购买",
+ "关闭",
+ )
+ )
+
def _second_dimension_region(
self,
root: ET.Element,
diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py
index 2a409d2..2960c29 100644
--- a/client/test/test_pdd_collect_service.py
+++ b/client/test/test_pdd_collect_service.py
@@ -509,6 +509,59 @@ class HorizontalPackageDevice:
self.page = max(self.page - 1, 0)
+class VerticalLongPackageDevice:
+ """模拟纵向长套餐:滑到第二屏后“套餐”标题不再出现在控件树。"""
+
+ packages = tuple(f"套餐{i}" for i in range(1, 11))
+
+ def __init__(self):
+ self.page = 0
+ self.swipes = []
+ self.clicks = []
+
+ def dump_hierarchy(self):
+ start = min(self.page * 3, len(self.packages) - 4)
+ visible = self.packages[start : start + 4]
+ headings = ""
+ if self.page == 0:
+ headings = """
+
+
+
+ """
+ options = []
+ for index, text in enumerate(visible):
+ top = 1080 + index * 210
+ options.append(
+ f''
+ )
+ return f"""
+
+
+
+ {headings}
+ {''.join(options)}
+
+
+
+
+ """
+
+ def swipe(self, x1, y1, x2, y2, duration=0.35):
+ self.swipes.append((x1, y1, x2, y2, duration))
+ if abs(y2 - y1) <= abs(x2 - x1):
+ return
+ if y1 > y2:
+ self.page = min(self.page + 1, 2)
+ else:
+ self.page = max(self.page - 1, 0)
+
+
class FakeClock:
"""测试用时钟:sleep 只推进虚拟时间,不真的等待。"""
@@ -1258,6 +1311,30 @@ class PddCollectParserTest(unittest.TestCase):
self.assertEqual(raised.exception.diagnostics["expected_count"], 8)
self.assertEqual(raised.exception.diagnostics["collected_count"], 7)
+ def test_vertical_packages_continue_after_heading_scrolls_out(self):
+ device = VerticalLongPackageDevice()
+ service = PddCollectService(
+ PddDeviceService(lambda _serial: device),
+ "USB-001",
+ "client-001",
+ sleeper=lambda _seconds: None,
+ )
+
+ result = service._collect_size_dimension(device)
+
+ self.assertIsNotNone(result)
+ self.assertEqual(result.name, "套餐(10)")
+ self.assertEqual(
+ [value.text for value in result.values],
+ list(device.packages),
+ )
+ self.assertNotIn("增加数量", [value.text for value in result.values])
+ self.assertNotIn("提交订单", [value.text for value in result.values])
+ self.assertEqual(device.clicks, [])
+ self.assertTrue(
+ all(abs(y2 - y1) > abs(x2 - x1) for x1, y1, x2, y2, _ in device.swipes)
+ )
+
def test_visible_click_is_fallback_when_page_exposes_no_selection_state(self):
xml_data = keep_only_one_sku(self.spec_xml)
root = ET.fromstring(xml_data)