From 44586fef38e40ef1de4ee220cbfffa28f377aa8f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 18:21:30 +0800 Subject: [PATCH] fix(client): bind SKU entry to verified parent chain --- .../src/cmbuyer_client/pdd/sku_selection.py | 53 +++++++++- .../pdd/fixtures/product_entry_8_17_0.xml | 14 +++ client/tests/pdd/test_sku_selection.py | 98 ++++++++++++++++--- 3 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 client/tests/pdd/fixtures/product_entry_8_17_0.xml diff --git a/client/src/cmbuyer_client/pdd/sku_selection.py b/client/src/cmbuyer_client/pdd/sku_selection.py index 6c99e98..5efac1c 100644 --- a/client/src/cmbuyer_client/pdd/sku_selection.py +++ b/client/src/cmbuyer_client/pdd/sku_selection.py @@ -17,6 +17,9 @@ EXPECTED_UNIT_PRICE = "12.88" TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")} _TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values())) _ENTRY = "快要抢光" +_ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]" +_ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]" +_ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]" _SIZE = "尺码" _W, _H = 1080, 2376 _PRICE_PARENT = "[396,498][895,570]" @@ -352,9 +355,55 @@ def _choice(node: _Node) -> bool: def _eligible_entries(nodes: list[_Node]) -> list[_Node]: - # 面板与底部硬拒绝区绝不能被误作商品页入口;入口仍只按经取证精确文案、包、状态判定。 + # 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续 + # 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部 + # 容器既不属于这条祖先链,也绝不能作为替代入口。 if any(node.bounds == _PRICE_PARENT for node in nodes): return [] - return [node for node in nodes if _live(node) and node.element.get("class") == "android.widget.TextView" and node.text == _ENTRY] + 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") + ] + 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 + + +def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool: + return ( + node.element.get("package") == PDD_PACKAGE + and node.element.get("class") == class_name + and node.bounds == bounds + and node.element.get("clickable") == clickable + and node.element.get("enabled") == "true" + and node.element.get("visible-to-user") == "true" + ) def _action_bounds(bounds: str) -> tuple[int, int, int, int]: diff --git a/client/tests/pdd/fixtures/product_entry_8_17_0.xml b/client/tests/pdd/fixtures/product_entry_8_17_0.xml new file mode 100644 index 0000000..759794e --- /dev/null +++ b/client/tests/pdd/fixtures/product_entry_8_17_0.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/client/tests/pdd/test_sku_selection.py b/client/tests/pdd/test_sku_selection.py index 002e901..a0f7f0c 100644 --- a/client/tests/pdd/test_sku_selection.py +++ b/client/tests/pdd/test_sku_selection.py @@ -27,12 +27,11 @@ from cmbuyer_client.pdd.sku_selection_runner import ( _FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml" +_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml" _TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375" _TASK_COLOR = "黑色CHA(纯棉)" _TASK_SIZE = "M(建议100-115)" -_PRODUCT_PAGE = """""" +_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8") def _png_base64() -> str: @@ -115,6 +114,34 @@ def _center(bounds: str) -> tuple[int, int]: return left + (right - left) // 2, top + (bottom - top) // 2 +def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]: + parents = {child: parent for parent in root.iter() for child in parent} + child = next(node for node in root.iter("node") if node.get("text") == "快要抢光") + chain = [child] + for _ in range(4): + chain.append(parents[chain[-1]]) + return chain + + +def _mutate_entry(depth: int, attribute: str, value: str) -> str: + root = ElementTree.fromstring(_PRODUCT_PAGE) + _entry_chain(root)[depth].set(attribute, value) + return ElementTree.tostring(root, encoding="unicode") + + +def _without_entry() -> str: + root = ElementTree.fromstring(_PRODUCT_PAGE) + root.remove(_entry_chain(root)[4]) + return ElementTree.tostring(root, encoding="unicode") + + +def _duplicate_entry() -> str: + root = ElementTree.fromstring(_PRODUCT_PAGE) + entry_root = _entry_chain(root)[4] + root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode"))) + 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] @@ -141,6 +168,20 @@ class _FakeAdb: class SkuSelectionFlowTests(unittest.TestCase): + def _assert_entry_rejected_without_click(self, hierarchy: str) -> None: + now = [0.0] + device = _RawDevice(hierarchy) + flow = SkuSelectionFlow( + UiautomatorSkuPanelAdapter(device, 10), + 0.01, + 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_target_mapping_is_exact_and_success_path_restores_target(self) -> None: device = _RawDevice() adapter = UiautomatorSkuPanelAdapter(device, 10) @@ -151,9 +192,35 @@ class SkuSelectionFlowTests(unittest.TestCase): self.assertEqual(flow.read_sku_unit_price(), "12.88") flow.exit_sku_panel_safely() - self.assertEqual(_tap_centers(device), [(170, 850)]) + self.assertEqual(_tap_centers(device), [(978, 1333)]) self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)]) + def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None: + device = _RawDevice() + SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL) + + 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): + changes = { + "package": "other.package", + "class": "android.widget.Button", + "bounds": "[1,1][2,2]", + "clickable": "true" if expected_clickable[depth] == "false" else "false", + "enabled": "false", + "visible-to-user": "false", + } + for attribute, value in changes.items(): + with self.subTest(depth=depth, attribute=attribute): + self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value)) + + 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(_without_entry()) + self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true")) + def test_unknown_task_or_ui_variants_are_rejected_without_action(self) -> None: for color, size in (("黑色 CHA (纯棉)", _TASK_SIZE), (_TASK_COLOR, "M(建议100-115)"), ("黑色CHA(纯棉)", _TASK_SIZE)): with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError): @@ -183,7 +250,7 @@ class SkuSelectionFlowTests(unittest.TestCase): with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError): _action_bounds(bounds) - device = _RawDevice(_PRODUCT_PAGE.replace("[20,800][320,900]", "[0,0][1081,1]")) + device = _RawDevice(_PRODUCT_PAGE.replace("[900,1312][1056,1355]", "[0,0][1081,1]")) with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL) self.assertEqual(_actions(device, "click"), []) @@ -196,7 +263,7 @@ class SkuSelectionFlowTests(unittest.TestCase): device.select_alternates() with self.assertRaises(SkuSelectionError): flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE)) - self.assertEqual(_tap_centers(device), [(170, 850), (282, 1086)]) + self.assertEqual(_tap_centers(device), [(978, 1333), (282, 1086)]) def test_non_target_selection_restores_each_dimension_once(self) -> None: device = _RawDevice() @@ -206,7 +273,7 @@ class SkuSelectionFlowTests(unittest.TestCase): flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE)) self.assertEqual( _tap_centers(device), - [(170, 850), (282, 1086), (635, 1772)], + [(978, 1333), (282, 1086), (635, 1772)], ) def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None: @@ -268,19 +335,22 @@ class SkuSelectionFlowTests(unittest.TestCase): device = NoPanelAfterEntry() with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL) - self.assertEqual(_tap_centers(device), [(170, 850)]) + self.assertEqual(_tap_centers(device), [(978, 1333)]) - duplicate = _PRODUCT_PAGE.replace("", _PRODUCT_PAGE.removeprefix("")) + duplicate = _duplicate_entry() device = _RawDevice(duplicate) with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL) self.assertEqual(_actions(device, "click"), []) def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None: + for fixture in (_FIXTURE, _ENTRY_FIXTURE): + content = fixture.read_text(encoding="utf-8") + with self.subTest(fixture=fixture.name): + self.assertNotRegex(content, r"1[3-9]\d{9}") + for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"): + self.assertNotIn(forbidden, content) content = _FIXTURE.read_text(encoding="utf-8") - self.assertNotRegex(content, r"1[3-9]\d{9}") - for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"): - self.assertNotIn(forbidden, content) root = ElementTree.fromstring(content) leaf = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥12.88") self.assertEqual(leaf.get("clickable"), "false") @@ -442,9 +512,9 @@ class SkuSelectionRunnerTests(unittest.TestCase): adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10) with self.assertRaises(SkuSelectionRunError): - adapter.tap_sku_entry("[20,800][320,900]") + adapter.tap_sku_entry("[900,1312][1056,1355]") self.assertTrue(adapter.entry_was_tapped) - self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [170, 850], 10)]) + self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [978, 1333], 10)]) def test_entry_stability_interruptions_never_click(self) -> None: now = [0.0]