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]