fix(client): stabilize verified sku entry

This commit is contained in:
QiuSW
2026-08-05 10:10:56 +08:00
parent 42eac57824
commit a09c58beef
2 changed files with 349 additions and 33 deletions
+131 -32
View File
@@ -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: