diff --git a/client/src/pdd_collect_service.py b/client/src/pdd_collect_service.py
index b07dd0f..e00a8f9 100644
--- a/client/src/pdd_collect_service.py
+++ b/client/src/pdd_collect_service.py
@@ -435,17 +435,80 @@ def _is_dimension_heading(label: str) -> bool:
# 新版页面会把可选数量写进标题,例如“颜色 (6)”或“颜色(6)”。
# 数量不是规格名称的一部分,只在判断标题类型时去掉,最终展示仍保留原文。
compact = re.sub(r"[((]\d+[))]$", "", compact)
+ # “确认款式”是新版规格弹层的标题,不是一个可选择的“款式”维度。
+ if compact in ("确认款式", "確認款式"):
+ return False
return compact in _DIMENSION_NAMES or compact.endswith(
("分类", "规格", "尺寸", "尺码", "颜色", "型号", "款式", "容量", "类型", "版本", "口味")
)
+def _find_non_scrollable_spec_panel(
+ root: ET.Element,
+ parents: Mapping[ET.Element, ET.Element],
+) -> Optional[ET.Element]:
+ """用多项强证据定位不暴露 scrollable 属性的自绘规格面板。"""
+
+ headings: list[ET.Element] = []
+ summaries: list[ET.Element] = []
+ panel_cues: list[ET.Element] = []
+ confirms: list[ET.Element] = []
+ for node in root.iter("node"):
+ label = _preferred_node_label(node).strip()
+ compact = label.replace(" ", "")
+ if not label or _parse_bounds(node.get("bounds", "")) is None:
+ continue
+ if _is_dimension_heading(label):
+ headings.append(node)
+ if compact.startswith(("已选", "已選", "请选择", "請選擇")):
+ summaries.append(node)
+ if compact in ("确认款式", "確認款式", "关闭", "關閉"):
+ panel_cues.append(node)
+ if compact in ("确定", "確定") and node.get("clickable") == "true":
+ confirms.append(node)
+
+ if not headings or not summaries or not panel_cues or not confirms:
+ return None
+
+ required = [*headings, summaries[0], panel_cues[0], confirms[0]]
+ common = set([required[0], *_ancestors(required[0], parents)])
+ for node in required[1:]:
+ common.intersection_update([node, *_ancestors(node, parents)])
+
+ all_bounds = [
+ bounds
+ for node in root.iter("node")
+ if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
+ ]
+ screen_area = 0
+ if all_bounds:
+ screen_area = max(item[2] for item in all_bounds) * max(
+ item[3] for item in all_bounds
+ )
+
+ candidates: list[tuple[int, ET.Element]] = []
+ for node in common:
+ bounds = _parse_bounds(node.get("bounds", ""))
+ if bounds is None or node.get("visible-to-user", "true") != "true":
+ continue
+ area = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1])
+ if screen_area and area >= screen_area * 0.95:
+ continue
+ candidates.append((area, node))
+ if not candidates:
+ return None
+ return min(candidates, key=lambda item: item[0])[1]
+
+
def _is_spec_panel_open(xml_data: str | bytes) -> bool:
"""判断规格面板是否已经出现,不要求当前视口已露出规格选项。"""
root = _parse_xml(xml_data)
labels = _all_labels(root)
_raise_special_page(labels)
+ parents = {child: parent for parent in root.iter() for child in parent}
+ if _find_non_scrollable_spec_panel(root, parents) is not None:
+ return True
has_scrollable_region = any(
node.get("scrollable") == "true"
and _parse_bounds(node.get("bounds", "")) is not None
@@ -536,11 +599,23 @@ def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot:
for node in root.iter("node")
if node.get("scrollable") == "true" and _parse_bounds(node.get("bounds", ""))
]
- if not scrollables:
+ non_scrollable_outer = _find_non_scrollable_spec_panel(root, parents)
+ panel_scrollables = []
+ if non_scrollable_outer is not None:
+ panel_scrollables = [
+ node
+ for node in scrollables
+ if node is non_scrollable_outer
+ or non_scrollable_outer in _ancestors(node, parents)
+ ]
+ outer_candidates = panel_scrollables or (
+ [non_scrollable_outer] if non_scrollable_outer is not None else scrollables
+ )
+ if not outer_candidates:
raise PddCollectError("PDD_DATA_SPEC_INCOMPLETE", "规格面板没有可识别的规格区域")
outer = max(
- scrollables,
+ outer_candidates,
key=lambda node: (
(_parse_bounds(node.get("bounds", "")) or (0, 0, 0, 0))[2]
- (_parse_bounds(node.get("bounds", "")) or (0, 0, 0, 0))[0]
@@ -616,8 +691,9 @@ def parse_spec_panel(xml_data: str | bytes) -> SpecSnapshot:
dimensions.append(SpecDimension(key, name, tuple(values)))
selected_text = next((label for label in labels if label.startswith("已选")), None)
+ price_boundary = heading_nodes[0][0] if heading_nodes else outer_bounds[1]
price_cent, raw_price, list_price_cent = _price_from_nodes(
- root.iter("node"), outer_bounds[1]
+ root.iter("node"), price_boundary
)
return SpecSnapshot(
tuple(dimensions), selected_text, price_cent, raw_price, list_price_cent
diff --git a/client/test/fixtures/pdd_spec_panel_non_scrollable.xml b/client/test/fixtures/pdd_spec_panel_non_scrollable.xml
new file mode 100644
index 0000000..488de74
--- /dev/null
+++ b/client/test/fixtures/pdd_spec_panel_non_scrollable.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/test/test_pdd_collect_service.py b/client/test/test_pdd_collect_service.py
index c1a49e2..6ec6886 100644
--- a/client/test/test_pdd_collect_service.py
+++ b/client/test/test_pdd_collect_service.py
@@ -10,6 +10,7 @@ import xml.etree.ElementTree as ET
from src.pdd_collect_service import (
PddCollectError,
PddCollectService,
+ _is_spec_panel_open,
parse_goods_page,
parse_quantity,
parse_spec_panel,
@@ -340,6 +341,9 @@ class PddCollectParserTest(unittest.TestCase):
cls.count_heading_spec_xml = (
FIXTURES / "pdd_spec_panel_count_heading.xml"
).read_text(encoding="utf-8")
+ cls.non_scrollable_spec_xml = (
+ FIXTURES / "pdd_spec_panel_non_scrollable.xml"
+ ).read_text(encoding="utf-8")
def test_quantity_keeps_raw_value_and_approximate_flag(self):
result = parse_quantity("已拼1.2万+件")
@@ -403,6 +407,63 @@ class PddCollectParserTest(unittest.TestCase):
["黑色", "红色", "蓝色", "紫色", "浅粉", "本色"],
)
+ def test_parse_non_scrollable_spec_panel(self):
+ result = parse_spec_panel(self.non_scrollable_spec_xml)
+
+ self.assertTrue(_is_spec_panel_open(self.non_scrollable_spec_xml))
+ self.assertEqual([item.key for item in result.dimensions], ["color", "size"])
+ self.assertEqual(
+ [value.text for value in result.dimensions[0].values],
+ [
+ "测试黑色",
+ "测试黑色+网袜",
+ "测试黑色+丝袜",
+ "测试黑色+短袜",
+ "测试黑色+长袜",
+ ],
+ )
+ self.assertEqual(
+ [value.text for value in result.dimensions[1].values],
+ ["均码(适合80-120斤)"],
+ )
+ self.assertEqual(result.price_cent, 817)
+ self.assertEqual(result.raw_price, "¥8.17")
+ self.assertNotIn("确认款式", [item.name for item in result.dimensions])
+
+ def test_wait_accepts_non_scrollable_spec_panel_without_sleep(self):
+ device = FakeCollectDevice(self.home_xml, self.non_scrollable_spec_xml)
+ device.opened_url = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
+ device.panel_open = True
+ clock = FakeClock()
+ service = PddCollectService(
+ PddDeviceService(lambda _serial: device),
+ "USB-001",
+ "client-001",
+ sleeper=clock.sleep,
+ monotonic=clock.monotonic,
+ spec_panel_timeout=1.0,
+ )
+
+ snapshot = service._wait_spec_panel(device)
+
+ self.assertEqual([item.key for item in snapshot.dimensions], ["color", "size"])
+ self.assertEqual(clock.sleeps, [])
+
+ def test_goods_page_is_not_non_scrollable_spec_panel(self):
+ self.assertFalse(_is_spec_panel_open(self.home_xml))
+
+ def test_full_window_with_scattered_labels_is_not_spec_panel(self):
+ xml_data = """
+
+
+
+
+
+
+ """
+
+ self.assertFalse(_is_spec_panel_open(xml_data))
+
def test_open_panel_without_visible_options_is_not_reported_as_timeout(self):
panel_xml = """