diff --git a/client/src/cmbuyer_client/pdd/sku_selection.py b/client/src/cmbuyer_client/pdd/sku_selection.py index 5efac1c..e69253b 100644 --- a/client/src/cmbuyer_client/pdd/sku_selection.py +++ b/client/src/cmbuyer_client/pdd/sku_selection.py @@ -20,6 +20,10 @@ _ENTRY = "快要抢光" _ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]" _ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]" _ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]" +_FORBIDDEN_ENTRY_ACTION_DESC = ( + "购买", "下单", "付款", "订单", "免拼购买", "单独购买", "直接拼成", "提交订单", "支付", + "先用后付", "0元下单", "0 元下单", +) _SIZE = "尺码" _W, _H = 1080, 2376 _PRICE_PARENT = "[396,498][895,570]" @@ -89,7 +93,9 @@ class SkuSelectionFlow: raise SkuSelectionError("商品不是已取证目标,已停止操作。") if pre_intent_hierarchy is not None: previous_nodes = _parse_nodes(pre_intent_hierarchy) - if _eligible_entries(previous_nodes): + # intent 前只判断旧页是否已经存在完整入口链;浮层或额外动作节点不能把旧商品 + # 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。 + if _physical_entries(previous_nodes): raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。") entry, before = self._wait_for_entry(pre_intent_hierarchy) _action_bounds(entry.bounds) @@ -168,13 +174,19 @@ class SkuSelectionFlow: current = self._device.app_current() if isinstance(current, dict) and current.get("package") == PDD_PACKAGE: raw = self._read_hierarchy() - entries = _eligible_entries(_parse_nodes(raw)) + nodes = _parse_nodes(raw) + entries = _eligible_entries(nodes) if len(entries) > 1: raise SkuSelectionError("商品页规格入口不唯一,已停止操作。") if len(entries) == 1 and raw != previous: - if stable == raw: + # 商品详情正文包含倒计时等动态节点,全文 XML 稳定不是已取证入口的安全属性。 + # 连续两帧只比较完整五层入口判据投影;任一层失配仍会在 eligible 阶段清空稳定态。 + projection = _entry_projection(entries[0], nodes) + if projection is None: + raise SkuSelectionError("商品页规格入口结构失效,已停止操作。") + if stable == projection: return entries[0], raw - stable = raw + stable = projection else: stable = None else: @@ -358,41 +370,128 @@ def _eligible_entries(nodes: list[_Node]) -> list[_Node]: # 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续 # 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部 # 容器既不属于这条祖先链,也绝不能作为替代入口。 - if any(node.bounds == _PRICE_PARENT for node in nodes): return [] + if any(node.bounds == _PRICE_PARENT for node in nodes): + return [] + entries = _physical_entries(nodes) + if len(entries) != 1: + return entries + entry = entries[0] + chain = _physical_entry_chain(entry) + if chain is None or _entry_projection(entry, nodes) is None: + return [] + ancestor = chain[-1] + left, top, right, bottom = _action_bounds(entry.bounds) + center = (left + (right - left) // 2, top + (bottom - top) // 2) + occupants = _live_clickables_covering(nodes, center) + # RPC 点的是文本中心而不是祖先对象。只有完整入口链自己的动作祖先占用该坐标时才可点击; + # SystemUI 浮层、额外按钮或任意部分覆盖矩形都可能截获触摸,必须零点击失败关闭。 + return entries if len(occupants) == 1 and occupants[0].element is ancestor.element else [] + + +def _physical_entries(nodes: list[_Node]) -> list[_Node]: entry_labels = [ node for node in nodes if node.text == _ENTRY and node.element.get("package") == PDD_PACKAGE and node.element.get("class") == "android.widget.TextView" ] - if len(entry_labels) != 1: - return entry_labels - action_ancestors = [ - node for node in nodes - if _exact_entry_node(node, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true") + # 这里故意只证明物理五层链。pre-intent 必须识别旧商品,不能让禁词、浮层或其他 + # post-intent 安全条件把已经存在的旧入口伪装成“不存在”。 + return [node for node in entry_labels if _physical_entry_chain(node) is not None] + + +def _physical_entry_chain(node: _Node) -> tuple[_Node, ...] | None: + if node.text != _ENTRY or not _exact_entry_node( + node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false" + ) or len(node.element) != 0: + return None + inner = node.parent + switcher = inner.parent if inner is not None else None + frame = switcher.parent if switcher is not None else None + ancestor = frame.parent if frame is not None else None + chain = ( + (node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"), + (inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false"), + (switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false"), + (frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false"), + (ancestor, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true"), + ) + if any( + candidate is None or not _exact_entry_node(candidate, class_name, bounds, clickable) + for candidate, class_name, bounds, clickable in chain + ): + return None + return tuple(candidate for candidate, _, _, _ in chain if candidate is not None) + + +def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | None: + chain = _physical_entry_chain(node) + if chain is None: + return None + _, inner, switcher, frame, ancestor = chain + if node.desc: + return None + if any(candidate.text or candidate.desc for candidate in (inner, switcher, frame)): + return None + if ( + ancestor.text + or (ancestor.desc and not ancestor.desc.endswith(_ENTRY)) + or any(forbidden in ancestor.desc for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC) + ): + return None + subtree = [ + candidate for candidate in nodes + if candidate.element is ancestor.element or _descendant(candidate, ancestor) ] - if len(action_ancestors) != 1: - return [] - action_ancestor = action_ancestors[0] - entries: list[_Node] = [] - for node in entry_labels: - if not _exact_entry_node(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"): - return [] - inner = node.parent - switcher = inner.parent if inner is not None else None - frame = switcher.parent if switcher is not None else None - ancestor = frame.parent if frame is not None else None - if ( - inner is not None - and _exact_entry_node(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false") - and switcher is not None - and _exact_entry_node(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false") - and frame is not None - and _exact_entry_node(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false") - and ancestor is action_ancestor - ): - entries.append(node) - return entries + if any( + forbidden in value + for candidate in subtree + for value in (candidate.text, candidate.desc) + for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC + ): + return None + entry_text_nodes = [candidate for candidate in subtree if candidate.text == _ENTRY] + if len(entry_text_nodes) != 1 or entry_text_nodes[0].element is not node.element: + return None + # 动作祖先整棵子树按真实层级和顺序投影。允许非危险动态正文存在,但任一结构或语义 + # 在两帧间变化都不会点击;正文只作为不透明稳定键,不从中解析详情价。 + return _subtree_projection(ancestor.element) + + +def _subtree_projection(element: ElementTree.Element) -> tuple[object, ...]: + return ( + element.tag, + element.get("package", ""), + element.get("class", ""), + element.get("bounds", ""), + element.get("clickable", ""), + element.get("enabled", ""), + element.get("visible-to-user", ""), + element.get("text", ""), + element.get("content-desc", ""), + tuple(_subtree_projection(child) for child in element), + ) + + +def _is_live_clickable(node: _Node) -> bool: + return ( + node.element.get("clickable") == "true" + and node.element.get("enabled") == "true" + and node.element.get("visible-to-user") == "true" + ) + + +def _live_clickables_covering(nodes: list[_Node], point: tuple[int, int]) -> list[_Node]: + occupants: list[_Node] = [] + x, y = point + for node in nodes: + if not _is_live_clickable(node): + continue + # 活跃可点击节点的 bounds 无法验证时,无法证明它不会截获入口坐标,因此整体失败关闭。 + left, top, right, bottom = _action_bounds(node.bounds) + if left <= x < right and top <= y < bottom: + occupants.append(node) + return occupants def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool: diff --git a/client/tests/pdd/test_sku_selection.py b/client/tests/pdd/test_sku_selection.py index 6b28bbb..b780077 100644 --- a/client/tests/pdd/test_sku_selection.py +++ b/client/tests/pdd/test_sku_selection.py @@ -143,6 +143,86 @@ def _duplicate_entry() -> str: return ElementTree.tostring(root, encoding="unicode") +def _extra_entry_action_ancestor() -> str: + root = ElementTree.fromstring(_PRODUCT_PAGE) + action = _entry_chain(root)[4] + root.append(ElementTree.Element("node", dict(action.attrib))) + return ElementTree.tostring(root, encoding="unicode") + + +def _entry_with_panel_price_marker() -> str: + root = ElementTree.fromstring(_PRODUCT_PAGE) + root.append(ElementTree.Element("node", {"bounds": "[396,498][895,570]"})) + return ElementTree.tostring(root, encoding="unicode") + + +def _dynamic_product_page(value: str, action_desc: str = "") -> str: + root = ElementTree.fromstring(_PRODUCT_PAGE) + root.set("dynamic-page-value", value) + _entry_chain(root)[4].set("content-desc", action_desc) + return ElementTree.tostring(root, encoding="unicode") + + +def _with_overlapping_clickable(package: str, class_name: str, bounds: str) -> str: + root = ElementTree.fromstring(_PRODUCT_PAGE) + root.append( + ElementTree.Element( + "node", + { + "package": package, + "class": class_name, + "bounds": bounds, + "clickable": "true", + "enabled": "true", + "visible-to-user": "true", + }, + ) + ) + return ElementTree.tostring(root, encoding="unicode") + + +def _with_action_subtree_child( + text: str, + *, + bounds: str = "[10,1260][100,1300]", +) -> str: + root = ElementTree.fromstring(_PRODUCT_PAGE) + action = _entry_chain(root)[4] + ElementTree.SubElement( + action, + "node", + { + "text": text, + "package": "com.xunmeng.pinduoduo", + "class": "android.widget.TextView", + "bounds": bounds, + "clickable": "false", + "enabled": "true", + "visible-to-user": "true", + }, + ) + return ElementTree.tostring(root, encoding="unicode") + + +def _home_with_unverified_entry_labels() -> str: + root = ElementTree.Element("hierarchy") + for index in range(2): + ElementTree.SubElement( + root, + "node", + { + "text": "快要抢光", + "package": "com.xunmeng.pinduoduo", + "class": "android.widget.TextView", + "clickable": "false", + "enabled": "true", + "visible-to-user": "true", + "bounds": f"[{index},{index}][{index + 1},{index + 1}]", + }, + ) + return ElementTree.tostring(root, encoding="unicode") + + def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]: return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method] @@ -202,6 +282,73 @@ class SkuSelectionFlowTests(unittest.TestCase): self.assertEqual(_tap_centers(device), [(978, 1333)]) + def test_entry_stability_uses_verified_chain_projection_not_whole_xml(self) -> None: + class DynamicProductDevice(_RawDevice): + def __init__(self) -> None: + super().__init__() + self.frame = 0 + + def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: + if method == "dumpWindowHierarchy" and "快要抢光" in self.hierarchy: + self.frame += 1 + self.hierarchy = _dynamic_product_page(str(self.frame), "活动剩余快要抢光") + return super().jsonrpc_call(method, params, timeout) + + now = [0.0] + device = DynamicProductDevice() + flow = SkuSelectionFlow( + UiautomatorSkuPanelAdapter(device, 10), + 0.03, + 0.01, + lambda: now[0], + lambda seconds: now.__setitem__(0, now[0] + seconds), + ) + + flow.open_sku_panel(_TARGET_URL, "") + + self.assertEqual(device.frame, 2) + self.assertEqual(_tap_centers(device), [(978, 1333)]) + + def test_entry_action_description_must_be_stable_across_frames(self) -> None: + class ChangingActionDescriptionDevice(_RawDevice): + def __init__(self) -> None: + super().__init__() + self.frame = 0 + + def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: + if method == "dumpWindowHierarchy": + self.frame += 1 + self.hierarchy = _dynamic_product_page( + str(self.frame), + f"活动{self.frame}快要抢光", + ) + return super().jsonrpc_call(method, params, timeout) + + now = [0.0] + device = ChangingActionDescriptionDevice() + flow = SkuSelectionFlow( + UiautomatorSkuPanelAdapter(device, 10), + 0.02, + 0.01, + lambda: now[0], + lambda seconds: now.__setitem__(0, now[0] + seconds), + ) + + with self.assertRaises(SkuSelectionError): + flow.open_sku_panel(_TARGET_URL, "") + + self.assertEqual(_actions(device, "click"), []) + + def test_unverified_home_entry_labels_do_not_trigger_old_product_rejection(self) -> None: + device = _RawDevice() + + SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel( + _TARGET_URL, + _home_with_unverified_entry_labels(), + ) + + self.assertEqual(_tap_centers(device), [(978, 1333)]) + def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None: expected_clickable = ("false", "false", "false", "false", "true") for depth in range(5): @@ -217,8 +364,74 @@ class SkuSelectionFlowTests(unittest.TestCase): with self.subTest(depth=depth, attribute=attribute): self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value)) + def test_entry_chain_text_and_description_drift_never_clicks(self) -> None: + cases = [ + _mutate_entry(depth, "text", "祖先文字漂移") for depth in range(1, 5) + ] + [ + _mutate_entry(depth, "content-desc", "祖先描述漂移") for depth in range(0, 4) + ] + [ + _mutate_entry(4, "content-desc", "祖先描述漂移") + ] + [ + _mutate_entry(4, "content-desc", forbidden + "快要抢光") + for forbidden in ( + "免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单", + "立即购买", "确认下单", "立即付款", "订单详情", + ) + ] + for hierarchy in cases: + with self.subTest(): + self._assert_entry_rejected_without_click(hierarchy) + + def test_any_live_clickable_covering_entry_center_blocks_click(self) -> None: + cases = ( + _with_overlapping_clickable( + "com.xunmeng.pinduoduo", + "android.widget.Button", + "[950,1300][1000,1340]", + ), + _with_overlapping_clickable( + "com.android.systemui", + "android.view.ViewGroup", + "[936,1208][1080,1352]", + ), + _with_overlapping_clickable( + "com.android.systemui", + "android.view.ViewGroup", + "not-a-bound", + ), + ) + for hierarchy in cases: + with self.subTest(): + self._assert_entry_rejected_without_click(hierarchy) + + def test_pre_intent_old_entry_is_rejected_even_with_extra_action_node(self) -> None: + for old_page in ( + _extra_entry_action_ancestor(), + _entry_with_panel_price_marker(), + _mutate_entry(4, "content-desc", "立即购买快要抢光"), + ): + with self.subTest(): + device = _RawDevice() + flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)) + + with self.assertRaises(SkuSelectionError): + flow.open_sku_panel(_TARGET_URL, old_page) + + self.assertEqual(_actions(device, "click"), []) + + def test_action_subtree_dangerous_or_ambiguous_children_never_click(self) -> None: + cases = ( + _with_action_subtree_child("提交订单"), + _with_action_subtree_child("免拼购买"), + _with_action_subtree_child("快要抢光"), + ) + for hierarchy in cases: + with self.subTest(): + self._assert_entry_rejected_without_click(hierarchy) + def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None: self._assert_entry_rejected_without_click(_duplicate_entry()) + self._assert_entry_rejected_without_click(_extra_entry_action_ancestor()) self._assert_entry_rejected_without_click(_without_entry()) self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true")) @@ -555,7 +768,11 @@ class SkuSelectionRunnerTests(unittest.TestCase): now = [0.0] class SequenceDevice(_RawDevice): def __init__(self) -> None: - super().__init__(); self.frames = [_PRODUCT_PAGE, "", _PRODUCT_PAGE] + super().__init__(); self.frames = [ + _dynamic_product_page("first"), + "", + _dynamic_product_page("second"), + ] def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: if method == "dumpWindowHierarchy" and self.frames: self.hierarchy = self.frames.pop(0)