fix(client): target verified bottom sku entry
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user