fix: 兼容非滚动PDD规格面板 (#154)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy rotation="0">
|
||||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2340]" visible-to-user="true" enabled="true">
|
||||
<node class="android.widget.RelativeLayout" clickable="true" scrollable="false" bounds="[0,720][1080,2214]" visible-to-user="true" enabled="true">
|
||||
<node class="android.widget.TextView" text="确认款式" bounds="[438,746][642,814]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.widget.ImageView" content-desc="关闭" clickable="true" bounds="[969,724][1080,835]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.widget.ImageView" content-desc="查看大图" clickable="true" bounds="[39,855][291,1107]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.widget.TextView" text="券后 ¥8.17" bounds="[324,852][573,919]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.widget.TextView" text="券前¥9.17" bounds="[591,866][781,915]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.widget.TextView" text="已选择: 测试黑色 均码(适合80-120斤)" bounds="[324,1009][1044,1062]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.widget.ImageView" content-desc="减少数量" clickable="true" bounds="[324,1080][402,1155]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.widget.EditText" text="1" clickable="true" bounds="[408,1080][489,1155]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.widget.ImageView" content-desc="增加数量" clickable="true" bounds="[495,1080][573,1155]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.widget.TextView" text="颜色分类" bounds="[36,1214][216,1267]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.view.ViewGroup" clickable="true" bounds="[36,1295][276,1382]" visible-to-user="true" enabled="true">
|
||||
<node class="android.widget.TextView" text="测试黑色" clickable="true" selected="true" bounds="[36,1295][276,1382]" visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
<node class="android.view.ViewGroup" clickable="true" bounds="[306,1295][696,1382]" visible-to-user="true" enabled="true">
|
||||
<node class="android.widget.TextView" text="测试黑色+网袜" clickable="true" bounds="[306,1295][696,1382]" visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
<node class="android.view.ViewGroup" clickable="true" bounds="[36,1412][426,1499]" visible-to-user="true" enabled="true">
|
||||
<node class="android.widget.TextView" text="测试黑色+丝袜" clickable="true" bounds="[36,1412][426,1499]" visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
<node class="android.view.ViewGroup" clickable="true" bounds="[456,1412][888,1499]" visible-to-user="true" enabled="true">
|
||||
<node class="android.widget.TextView" text="测试黑色+短袜" clickable="true" bounds="[456,1412][888,1499]" visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
<node class="android.view.ViewGroup" clickable="true" bounds="[36,1529][468,1616]" visible-to-user="true" enabled="true">
|
||||
<node class="android.widget.TextView" text="测试黑色+长袜" clickable="true" bounds="[36,1529][468,1616]" visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
<node class="android.widget.TextView" text="尺码" bounds="[36,1684][126,1737]" visible-to-user="true" enabled="true" />
|
||||
<node class="android.view.ViewGroup" clickable="true" bounds="[36,1765][711,1852]" visible-to-user="true" enabled="true">
|
||||
<node class="android.widget.TextView" text="均码(适合80-120斤)" clickable="true" selected="true" bounds="[36,1765][711,1852]" visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
<node class="android.widget.TextView" text="确定" clickable="true" bounds="[0,2067][1080,2214]" visible-to-user="true" enabled="true" />
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -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 = """<hierarchy>
|
||||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||||
<node text="确认款式" bounds="[20,100][220,160]" />
|
||||
<node text="已选择:测试值" bounds="[20,300][500,360]" />
|
||||
<node text="颜色分类" bounds="[20,800][220,860]" />
|
||||
<node text="确定" clickable="true" bounds="[0,2280][1080,2400]" />
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
|
||||
self.assertFalse(_is_spec_panel_open(xml_data))
|
||||
|
||||
def test_open_panel_without_visible_options_is_not_reported_as_timeout(self):
|
||||
panel_xml = """<hierarchy>
|
||||
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||||
|
||||
Reference in New Issue
Block a user