fix: 兼容套餐标题滑出后的续页采集 (#168)
This commit is contained in:
@@ -174,6 +174,18 @@ class SpecSnapshot:
|
|||||||
list_price_cent: Optional[int]
|
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)
|
@dataclass(frozen=True)
|
||||||
class VisibleSpecOption:
|
class VisibleSpecOption:
|
||||||
"""当前控件树中完整可点击的规格节点。"""
|
"""当前控件树中完整可点击的规格节点。"""
|
||||||
@@ -1728,6 +1740,8 @@ class PddCollectService:
|
|||||||
size_name = dimension.name
|
size_name = dimension.name
|
||||||
size_found = True
|
size_found = True
|
||||||
for value in dimension.values:
|
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
|
sizes[value.text] = sizes.get(value.text, False) or value.available
|
||||||
|
|
||||||
if size_found:
|
if size_found:
|
||||||
@@ -1760,6 +1774,19 @@ class PddCollectService:
|
|||||||
xml_data, start_confirmed = self._move_second_dimension_to_start(
|
xml_data, start_confirmed = self._move_second_dimension_to_start(
|
||||||
device, xml_data
|
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
|
strict_edge_check = "套餐" in size_name and self._max_spec_swipes > 0
|
||||||
if strict_edge_check and not start_confirmed:
|
if strict_edge_check and not start_confirmed:
|
||||||
raise PddCollectError(
|
raise PddCollectError(
|
||||||
@@ -1780,16 +1807,31 @@ class PddCollectService:
|
|||||||
(item for item in snapshot.dimensions if item.key == "size"),
|
(item for item in snapshot.dimensions if item.key == "size"),
|
||||||
None,
|
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:
|
if size_dimension is None:
|
||||||
raise PddCollectError(
|
raise PddCollectError(
|
||||||
"PDD_DATA_SPEC_INCOMPLETE",
|
"PDD_DATA_SPEC_INCOMPLETE",
|
||||||
f"滚动过程中丢失第二规格:{size_name}",
|
f"滚动过程中持续丢失第二规格:{size_name}",
|
||||||
)
|
)
|
||||||
size_name = size_dimension.name
|
size_name = size_dimension.name
|
||||||
for value in size_dimension.values:
|
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
|
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 = (
|
||||||
stable_edge_reads + 1 if signature == previous_signature else 0
|
stable_edge_reads + 1 if signature == previous_signature else 0
|
||||||
)
|
)
|
||||||
@@ -1862,7 +1904,7 @@ class PddCollectService:
|
|||||||
)
|
)
|
||||||
if region is None or not horizontal:
|
if region is None or not horizontal:
|
||||||
return xml_data, True
|
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 = (
|
||||||
stable_edge_reads + 1 if signature == previous_signature else 0
|
stable_edge_reads + 1 if signature == previous_signature else 0
|
||||||
)
|
)
|
||||||
@@ -1879,20 +1921,15 @@ class PddCollectService:
|
|||||||
return xml_data, False
|
return xml_data, False
|
||||||
|
|
||||||
def _second_dimension_signature(
|
def _second_dimension_signature(
|
||||||
self, xml_data: str | bytes
|
self,
|
||||||
|
xml_data: str | bytes,
|
||||||
|
dimension: SpecDimension,
|
||||||
) -> tuple[tuple[str, Bounds], ...]:
|
) -> tuple[tuple[str, Bounds], ...]:
|
||||||
"""只返回当前可见第二规格,避免其他区域变化干扰到边判断。"""
|
"""返回已确认的第二规格签名,不要求当前屏仍显示规格标题。"""
|
||||||
|
|
||||||
root = _parse_xml(xml_data)
|
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 = []
|
result = []
|
||||||
for value in size_dimension.values:
|
for value in dimension.values:
|
||||||
node = self._find_option_node(root, value.text)
|
node = self._find_option_node(root, value.text)
|
||||||
if node is None:
|
if node is None:
|
||||||
continue
|
continue
|
||||||
@@ -1901,6 +1938,167 @@ class PddCollectService:
|
|||||||
result.append((value.text, bounds))
|
result.append((value.text, bounds))
|
||||||
return tuple(result)
|
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(
|
def _second_dimension_region(
|
||||||
self,
|
self,
|
||||||
root: ET.Element,
|
root: ET.Element,
|
||||||
|
|||||||
@@ -509,6 +509,59 @@ class HorizontalPackageDevice:
|
|||||||
self.page = max(self.page - 1, 0)
|
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 = """
|
||||||
|
<node text="款式" bounds="[36,720][180,780]" />
|
||||||
|
<node text="口罩款" clickable="true" selected="true"
|
||||||
|
bounds="[36,800][300,900]" />
|
||||||
|
<node text="套餐(10)" bounds="[36,980][240,1040]" />
|
||||||
|
"""
|
||||||
|
options = []
|
||||||
|
for index, text in enumerate(visible):
|
||||||
|
top = 1080 + index * 210
|
||||||
|
options.append(
|
||||||
|
f'<node text="{text}" clickable="true" '
|
||||||
|
f'bounds="[36,{top}][500,{top + 150}]" />'
|
||||||
|
)
|
||||||
|
return f"""<hierarchy>
|
||||||
|
<node bounds="[0,0][1080,2340]">
|
||||||
|
<node class="android.widget.ScrollView" scrollable="true"
|
||||||
|
bounds="[0,650][1080,2200]">
|
||||||
|
<node text="已选:套餐1" bounds="[36,660][500,710]" />
|
||||||
|
{headings}
|
||||||
|
{''.join(options)}
|
||||||
|
<node text="增加数量" clickable="true"
|
||||||
|
bounds="[36,1950][220,2050]" />
|
||||||
|
<node text="提交订单" clickable="true"
|
||||||
|
bounds="[300,2080][1040,2190]" />
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>"""
|
||||||
|
|
||||||
|
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:
|
class FakeClock:
|
||||||
"""测试用时钟:sleep 只推进虚拟时间,不真的等待。"""
|
"""测试用时钟:sleep 只推进虚拟时间,不真的等待。"""
|
||||||
|
|
||||||
@@ -1258,6 +1311,30 @@ class PddCollectParserTest(unittest.TestCase):
|
|||||||
self.assertEqual(raised.exception.diagnostics["expected_count"], 8)
|
self.assertEqual(raised.exception.diagnostics["expected_count"], 8)
|
||||||
self.assertEqual(raised.exception.diagnostics["collected_count"], 7)
|
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):
|
def test_visible_click_is_fallback_when_page_exposes_no_selection_state(self):
|
||||||
xml_data = keep_only_one_sku(self.spec_xml)
|
xml_data = keep_only_one_sku(self.spec_xml)
|
||||||
root = ET.fromstring(xml_data)
|
root = ET.fromstring(xml_data)
|
||||||
|
|||||||
Reference in New Issue
Block a user