diff --git a/client/src/cmbuyer_client/pdd/sku_selection.py b/client/src/cmbuyer_client/pdd/sku_selection.py
index 210c6d2..21e3ee5 100644
--- a/client/src/cmbuyer_client/pdd/sku_selection.py
+++ b/client/src/cmbuyer_client/pdd/sku_selection.py
@@ -16,12 +16,15 @@ EXPECTED_UNIT_PRICE = "12.88"
# 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。
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]"
+_ENTRY = "快要抢光 ¥ 12.88"
+_ENTRY_PROMOTION_LABEL = "快要抢光"
+_ENTRY_TEXT_BOUNDS = "[688,2184][1042,2253]"
+_ENTRY_ACTION_DESC = "快要抢光¥12.88"
+_ENTRY_ACTION_BOUNDS = "[446,2166][1080,2328]"
+_ENTRY_SIBLING = "免拼购买"
+_ENTRY_SIBLING_BOUNDS = "[688,2256][856,2305]"
_FORBIDDEN_ENTRY_ACTION_DESC = (
- "购买", "下单", "付款", "订单", "免拼购买", "单独购买", "直接拼成", "提交订单", "支付",
+ "购买", "下单", "付款", "订单", "单独购买", "直接拼成", "提交订单", "支付",
"先用后付", "0元下单", "0 元下单",
)
_SIZE = "尺码"
@@ -236,7 +239,8 @@ class SkuSelectionFlow:
raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
if len(entries) == 1 and raw != previous:
# 商品详情正文包含倒计时等动态节点,全文 XML 稳定不是已取证入口的安全属性。
- # 连续两帧只比较完整五层入口判据投影;任一层失配仍会在 eligible 阶段清空稳定态。
+ # 连续两帧只比较证据绑定的底部入口、直接父容器和不可点击兄弟节点;商品正文及
+ # 父容器内其他非危险动态节点不是入口身份,不能迫使实现退回坐标兜底。
projection = _entry_projection(entries[0], nodes)
if projection is None:
raise SkuSelectionError("商品页规格入口结构失效,已停止操作。")
@@ -423,19 +427,18 @@ def _choice(node: _Node) -> bool:
def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
- # 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续
- # 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部
- # 容器既不属于这条祖先链,也绝不能作为替代入口。
+ # 入口文本本身不可点击:必须证明它仍是已取证底部父容器的直接子节点,但动作坐标继续
+ # 使用第一行文本的窄 bounds,避免把父容器中心或第二行“免拼购买”变成坐标兜底。
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)
+ chain = _physical_entry_chain(entry, nodes)
if chain is None or _entry_projection(entry, nodes) is None:
return []
- ancestor = chain[-1]
+ 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)
@@ -451,49 +454,51 @@ def _physical_entries(nodes: list[_Node]) -> list[_Node]:
and node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == "android.widget.TextView"
]
- # 这里故意只证明物理五层链。pre-intent 必须识别旧商品,不能让禁词、浮层或其他
+ # 这里故意只证明物理结构。pre-intent 必须识别旧商品,不能让父容器描述或浮层等
# post-intent 安全条件把已经存在的旧入口伪装成“不存在”。
- return [node for node in entry_labels if _physical_entry_chain(node) is not None]
+ return [node for node in entry_labels if _physical_entry_chain(node, nodes) is not None]
-def _physical_entry_chain(node: _Node) -> tuple[_Node, ...] | None:
+def _physical_entry_chain(node: _Node, nodes: list[_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
+ ancestor = node.parent
+ if ancestor is None or not _exact_entry_node(
+ ancestor, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true"
):
return None
- return tuple(candidate for candidate, _, _, _ in chain if candidate is not None)
+ siblings = [
+ candidate for candidate in nodes
+ if candidate.parent is ancestor
+ and candidate.text == _ENTRY_SIBLING
+ and _exact_entry_node(
+ candidate, "android.widget.TextView", _ENTRY_SIBLING_BOUNDS, "false"
+ )
+ and not candidate.desc
+ and len(candidate.element) == 0
+ ]
+ if len(siblings) != 1:
+ return None
+ return node, ancestor, siblings[0]
def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | None:
- chain = _physical_entry_chain(node)
+ chain = _physical_entry_chain(node, nodes)
if chain is None:
return None
- _, inner, switcher, frame, ancestor = chain
+ _, ancestor, sibling = chain
if node.desc:
return None
- if any(candidate.text or candidate.desc for candidate in (inner, switcher, frame)):
+ # 金额只作为这个已取证入口的不可变身份。这里既不解析也不返回它,价格闸门仍只能读取规格面板。
+ if ancestor.text or ancestor.desc != _ENTRY_ACTION_DESC:
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)
- ):
+ sibling_mentions = [
+ candidate for candidate in nodes
+ if _ENTRY_SIBLING in candidate.text or _ENTRY_SIBLING in candidate.desc
+ ]
+ if len(sibling_mentions) != 1 or sibling_mentions[0].element is not sibling.element:
return None
subtree = [
candidate for candidate in nodes
@@ -503,29 +508,36 @@ def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | N
forbidden in value
for candidate in subtree
for value in (candidate.text, candidate.desc)
+ if candidate.element is not sibling.element
for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC
):
return None
+ if any(
+ value == _ENTRY_PROMOTION_LABEL
+ for candidate in subtree
+ for value in (candidate.text, candidate.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)
+ return tuple(
+ _entry_node_projection(candidate)
+ for candidate in (node, ancestor, sibling)
+ )
-def _subtree_projection(element: ElementTree.Element) -> tuple[object, ...]:
+def _entry_node_projection(node: _Node) -> tuple[str, ...]:
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),
+ node.element.tag,
+ node.element.get("package", ""),
+ node.element.get("class", ""),
+ node.bounds,
+ node.element.get("clickable", ""),
+ node.element.get("enabled", ""),
+ node.element.get("visible-to-user", ""),
+ node.text,
+ node.desc,
)
diff --git a/client/tests/pdd/fixtures/product_entry_8_17_0.xml b/client/tests/pdd/fixtures/product_entry_8_17_0.xml
index 759794e..a3e1be9 100644
--- a/client/tests/pdd/fixtures/product_entry_8_17_0.xml
+++ b/client/tests/pdd/fixtures/product_entry_8_17_0.xml
@@ -1,4 +1,5 @@
+
@@ -8,7 +9,9 @@
-
+
+
+
diff --git a/client/tests/pdd/test_sku_selection.py b/client/tests/pdd/test_sku_selection.py
index 68a5135..d741c07 100644
--- a/client/tests/pdd/test_sku_selection.py
+++ b/client/tests/pdd/test_sku_selection.py
@@ -77,7 +77,11 @@ class _RawDevice:
raise AssertionError(method)
def _apply_tap(self, x: int, y: int) -> None:
- if "快要抢光" in self.hierarchy and "[396,498][895,570]" not in self.hierarchy:
+ if (
+ (x, y) == (865, 2218)
+ and "快要抢光 ¥ 12.88" in self.hierarchy
+ and "[396,498][895,570]" not in self.hierarchy
+ ):
self.hierarchy = self.panel_hierarchy
return
root = ElementTree.fromstring(self.hierarchy)
@@ -117,11 +121,12 @@ def _center(bounds: str) -> tuple[int, int]:
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
+ child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
+ return [child, parents[child]]
+
+
+def _entry_sibling(root: ElementTree.Element) -> ElementTree.Element:
+ return next(node for node in root.iter("node") if node.get("text") == "免拼购买")
def _mutate_entry(depth: int, attribute: str, value: str) -> str:
@@ -132,20 +137,20 @@ def _mutate_entry(depth: int, attribute: str, value: str) -> str:
def _without_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
- root.remove(_entry_chain(root)[4])
+ root.remove(_entry_chain(root)[1])
return ElementTree.tostring(root, encoding="unicode")
def _duplicate_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
- entry_root = _entry_chain(root)[4]
+ entry_root = _entry_chain(root)[1]
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode")
def _extra_entry_action_ancestor() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
- action = _entry_chain(root)[4]
+ action = _entry_chain(root)[1]
root.append(ElementTree.Element("node", dict(action.attrib)))
return ElementTree.tostring(root, encoding="unicode")
@@ -159,7 +164,7 @@ def _entry_with_panel_price_marker() -> str:
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)
+ _entry_chain(root)[1].set("content-desc", action_desc or "快要抢光¥12.88")
return ElementTree.tostring(root, encoding="unicode")
@@ -184,10 +189,10 @@ def _with_overlapping_clickable(package: str, class_name: str, bounds: str) -> s
def _with_action_subtree_child(
text: str,
*,
- bounds: str = "[10,1260][100,1300]",
+ bounds: str = "[500,2170][600,2200]",
) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
- action = _entry_chain(root)[4]
+ action = _entry_chain(root)[1]
ElementTree.SubElement(
action,
"node",
@@ -204,6 +209,24 @@ def _with_action_subtree_child(
return ElementTree.tostring(root, encoding="unicode")
+def _mutate_entry_sibling(attribute: str, value: str) -> str:
+ root = ElementTree.fromstring(_PRODUCT_PAGE)
+ _entry_sibling(root).set(attribute, value)
+ return ElementTree.tostring(root, encoding="unicode")
+
+
+def _without_entry_sibling() -> str:
+ root = ElementTree.fromstring(_PRODUCT_PAGE)
+ _entry_chain(root)[1].remove(_entry_sibling(root))
+ return ElementTree.tostring(root, encoding="unicode")
+
+
+def _entry_sibling_elsewhere() -> str:
+ root = ElementTree.fromstring(_PRODUCT_PAGE)
+ root.append(ElementTree.fromstring(ElementTree.tostring(_entry_sibling(root), encoding="unicode")))
+ return ElementTree.tostring(root, encoding="unicode")
+
+
def _home_with_unverified_entry_labels() -> str:
root = ElementTree.Element("hierarchy")
for index in range(2):
@@ -273,14 +296,16 @@ class SkuSelectionFlowTests(unittest.TestCase):
self.assertEqual(flow.read_sku_unit_price(), "12.88")
flow.exit_sku_panel_safely()
- self.assertEqual(_tap_centers(device), [(978, 1333)])
+ self.assertEqual(_tap_centers(device), [(865, 2218)])
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)])
+ self.assertEqual(_tap_centers(device), [(865, 2218)])
+ self.assertNotIn((763, 2247), _tap_centers(device)) # 父容器中心不是获准目标。
+ self.assertNotIn((772, 2280), _tap_centers(device)) # 第二行“免拼购买”永不点击。
def test_entry_stability_uses_verified_chain_projection_not_whole_xml(self) -> None:
class DynamicProductDevice(_RawDevice):
@@ -291,7 +316,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
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), "活动剩余快要抢光")
+ self.hierarchy = _dynamic_product_page(str(self.frame))
return super().jsonrpc_call(method, params, timeout)
now = [0.0]
@@ -307,7 +332,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
flow.open_sku_panel(_TARGET_URL, "")
self.assertEqual(device.frame, 2)
- self.assertEqual(_tap_centers(device), [(978, 1333)])
+ self.assertEqual(_tap_centers(device), [(865, 2218)])
def test_entry_action_description_must_be_stable_across_frames(self) -> None:
class ChangingActionDescriptionDevice(_RawDevice):
@@ -347,11 +372,11 @@ class SkuSelectionFlowTests(unittest.TestCase):
_home_with_unverified_entry_labels(),
)
- self.assertEqual(_tap_centers(device), [(978, 1333)])
+ self.assertEqual(_tap_centers(device), [(865, 2218)])
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):
+ expected_clickable = ("false", "true")
+ for depth in range(2):
changes = {
"package": "other.package",
"class": "android.widget.Button",
@@ -366,13 +391,12 @@ class SkuSelectionFlowTests(unittest.TestCase):
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(1, "text", "祖先文字漂移")
] + [
- _mutate_entry(depth, "content-desc", "祖先描述漂移") for depth in range(0, 4)
+ _mutate_entry(0, "content-desc", "祖先描述漂移"),
+ _mutate_entry(1, "content-desc", "祖先描述漂移"),
] + [
- _mutate_entry(4, "content-desc", "祖先描述漂移")
- ] + [
- _mutate_entry(4, "content-desc", forbidden + "快要抢光")
+ _mutate_entry(1, "content-desc", forbidden + "快要抢光")
for forbidden in (
"免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单",
"立即购买", "确认下单", "立即付款", "订单详情",
@@ -387,12 +411,12 @@ class SkuSelectionFlowTests(unittest.TestCase):
_with_overlapping_clickable(
"com.xunmeng.pinduoduo",
"android.widget.Button",
- "[950,1300][1000,1340]",
+ "[850,2200][900,2230]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
- "[936,1208][1080,1352]",
+ "[800,2100][1000,2300]",
),
_with_overlapping_clickable(
"com.android.systemui",
@@ -408,7 +432,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
for old_page in (
_extra_entry_action_ancestor(),
_entry_with_panel_price_marker(),
- _mutate_entry(4, "content-desc", "立即购买快要抢光"),
+ _mutate_entry(1, "content-desc", "立即购买快要抢光"),
):
with self.subTest():
device = _RawDevice()
@@ -435,6 +459,30 @@ class SkuSelectionFlowTests(unittest.TestCase):
self._assert_entry_rejected_without_click(_without_entry())
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
+ def test_header_promotion_label_alone_never_becomes_entry(self) -> None:
+ self._assert_entry_rejected_without_click(_without_entry())
+
+ def test_entry_leaf_and_parent_amount_identity_drift_never_clicks(self) -> None:
+ for hierarchy in (
+ _PRODUCT_PAGE.replace('text="快要抢光 ¥ 12.88"', 'text="快要抢光 ¥ 13.88"'),
+ _mutate_entry(1, "content-desc", "快要抢光¥13.88"),
+ ):
+ with self.subTest():
+ self._assert_entry_rejected_without_click(hierarchy)
+
+ def test_entry_sibling_is_exact_inert_unique_and_in_same_parent(self) -> None:
+ cases = (
+ _without_entry_sibling(),
+ _mutate_entry_sibling("text", "免拼购买 "),
+ _mutate_entry_sibling("bounds", "[688,2255][856,2305]"),
+ _mutate_entry_sibling("clickable", "true"),
+ _with_action_subtree_child("免拼购买", bounds="[500,2260][650,2300]"),
+ _entry_sibling_elsewhere(),
+ )
+ for hierarchy in cases:
+ with self.subTest():
+ self._assert_entry_rejected_without_click(hierarchy)
+
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):
@@ -464,7 +512,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
_action_bounds(bounds)
- device = _RawDevice(_PRODUCT_PAGE.replace("[900,1312][1056,1355]", "[0,0][1081,1]"))
+ device = _RawDevice(_PRODUCT_PAGE.replace("[688,2184][1042,2253]", "[0,0][1081,1]"))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
@@ -477,7 +525,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), [(978, 1333), (282, 1086)])
+ self.assertEqual(_tap_centers(device), [(865, 2218), (282, 1086)])
def test_non_target_selection_restores_each_dimension_once(self) -> None:
device = _RawDevice()
@@ -487,7 +535,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(
_tap_centers(device),
- [(978, 1333), (282, 1086), (635, 1772)],
+ [(865, 2218), (282, 1086), (635, 1772)],
)
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
@@ -549,7 +597,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
device = NoPanelAfterEntry()
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
- self.assertEqual(_tap_centers(device), [(978, 1333)])
+ self.assertEqual(_tap_centers(device), [(865, 2218)])
duplicate = _duplicate_entry()
device = _RawDevice(duplicate)
@@ -802,9 +850,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
with self.assertRaises(SkuSelectionRunError):
- adapter.tap_sku_entry("[900,1312][1056,1355]")
+ adapter.tap_sku_entry("[688,2184][1042,2253]")
self.assertTrue(adapter.entry_was_tapped)
- self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [978, 1333], 10)])
+ self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [865, 2218], 10)])
def test_entry_stability_interruptions_never_click(self) -> None:
now = [0.0]