feat(抓取): 选择器优先语义化(同 id 多实例用 @text 限定,而非序号)+ 修掉两类"死选择器"

语义消歧(B 项):主属性在整棵树里重复时,先找第二个属性把目标单独圈出来 ——
  //*[@resource-id="x" and @text="我"]   (次属性 text > content-desc > class,
                                        单个不够就两两组合)
标 semantic:true + via;只有组合也分不开(列表里同 id 同文字)才退回
  (//*[@resource-id="x"])[k]            (标 indexed,前端黄标提醒脆弱)
抖音底部导航正是这个场景:tab 个数随灰度版本变(4 个 ↔ 3 个),序号必然错位。

顺带修掉两个结构性缺陷(给上面做验证时逐条 lxml 求值发现的,均非本次引入):
1. 结构步进把 class 当标签名 —— dump 的 XML 标签**一律是 <node>**,class 在
   @class 上,所以 //FrameLayout[1]/… 这类路径**永远零命中**;改 *[@class="…"][n]
2. 兜底结构路径用 @index 定位兄弟 —— 实测同级 index 会重复(状态栏/内容区/
   导航栏三个兄弟全是 index="0");改按子节点位置 //hierarchy/*[1]/*[2]

前端:抓取列表把 text/content-desc 排到最前并加粗上色(最稳的定位依据);
序号型从蓝标改**黄标 ⚠ 序号 k/n**,语义型给**绿标 ✓ 语义**;属性页新增
「选择器稳定性」一行说明这个选择器靠什么定位、会不会因界面变化失效。

真机实测(192.168.20.100,248 个元素):
  精确命中目标 221 → 247 | 死选择器 26 → 0 | 语义型 0 → 26(序号型 141 → 115)
  「我」的语义选择器经 /api/steps/test 真机点击 → 命中 ✓

文档:TASK_DEV §5.3/5.4(含两个 XPath 坑)、API §8(suggested 字段表 +
snapshot 行)、research/U2_ELEMENT_SELECTORS §五/§六、backlog ②标记完成。
This commit is contained in:
2026-09-13 22:05:47 +08:00
parent 4ea777a55a
commit 0bc713137d
7 changed files with 203 additions and 47 deletions
+110 -21
View File
@@ -12,7 +12,7 @@ API 参考(uiautodev 0.14):
- GET /api/android/{serial}/dump_hierarchy — 元素树 JSON
"""
import requests
from collections import Counter
from collections import Counter, defaultdict
from core.logger import get_logger
@@ -79,24 +79,32 @@ def get_elements(serial):
"""获取指定设备的 UI 元素树。
返回 (ok, data_or_error):
ok=True — data 是元素列表 [{name, attrs..., suggested:{type,value,indexed?,occ?,total?,broad?}}, ...]
ok=True — data 是元素列表 [{name, attrs..., suggested:{type,value,semantic?,via?,indexed?,occ?,total?,broad?}}, ...]
ok=False — data 是错误消息字符串
元素树由 uiautodev 的 dump_hierarchy 返回(JSON)。我们递归提取每个节点的
关键属性(resource-id/text/description/class/bounds...),供前端列表展示和选择。
选择器精度策略(精确到具体按钮的关键):
选择器精度策略(精确到具体按钮的关键,优先级见 doc/research/U2_ELEMENT_SELECTORS.md §三):
1. 有 resource-id/text/content-desc 的元素:
先预统计该属性在整棵树中的出现次数——
- 唯一出现:直接用属性选择器 //*[@resource-id="x"]
- 重复出现(如抖音底部导航 4 个 tab 同 id):用**整体加括号**的
`(//*[@resource-id="x"])[k]` 精确到第 k 个匹配。
- 重复出现(如抖音底部导航的 tab 同 id,个数还随灰度版本变):
**优先用第二个属性把目标单独圈出来**(语义选择器):
//*[@resource-id="x" and @text="我"]
它只认"这个元素的文字/id 是什么",不认"同 id 一共几个",
换设备/换版本依然成立 → 标记 semantic + via(用于限定的属性)。
- 两个属性组合仍圈不出来时,才退回**整体加括号**的
`(//*[@resource-id="x"])[k]`(标记 indexed,前端醒目提示脆弱)。
注意 XPath 语义:`//*[@id="x"][k]` 是"在其父节点中排第 k",不是
第 k 个匹配——历史实现踩过这个坑(多实例时 [2..n] 全部失配)。
2. 无任何属性的元素:
用最近一个有属性祖先的选择器限定范围 + 同 class 兄弟序号定位
(如 //*[@resource-id="x"]/FrameLayout/ImageView[2]);
整棵树都没有属性时退化为从根开始的结构路径(标记 broad,前端提示脆弱)。
(如 //*[@resource-id="x"]/*[@class="android.widget.ImageView"][2]);
整棵树都没有属性时退化为从根开始的结构路径
(//hierarchy/*[1]/*[3]…,标记 broad,前端提示脆弱)。
注意:dump 的 XML 标签一律是 <node>,class 在 @class 上 —— 任何把
class 名当标签名的写法(//FrameLayout[1])都永远匹配不到,别再用。
"""
if not serial:
return False, "缺少 serial"
@@ -125,6 +133,25 @@ def get_elements(serial):
return False, f"抓取失败: {e}"
def _child_path(path):
"""内部路径 `/0/2/1` → XPath 段 `/*[1]/*[3]/*[2]`(按**子节点位置**逐层定位)。
用于"整棵树都没有可用属性"时的兜底结构路径。为什么按位置而不是 `@index`:
实测 Android dump 里同级节点的 index 属性**会重复**(状态栏/内容区/导航栏
三个兄弟的 index 全是 "0"),拿它定位会一次命中好几个。
"""
return "".join(f"/*[{int(seg) + 1}]"
for seg in path.strip("/").split("/") if seg)
def _xpath(conds):
"""由条件列表拼 XPath:[(attr, value), ...] → `//*[@a="1" and @b="2"]`。
单个条件即 `//*[@a="1"]`。多个条件是**并列且**,任一条不成立就不匹配。
"""
return "//*[" + " and ".join(f"@{a}={_xpath_q(v)}" for a, v in conds) + "]"
def _xpath_q(v):
"""XPath 字符串字面量:优先双引号,值含双引号时改用单引号包裹(XPath 1.0 无转义)。"""
if '"' in v:
@@ -134,8 +161,10 @@ def _xpath_q(v):
def _extract(root, out):
"""把 uiautodev 元素树扁平化为可选列表,并为每个节点生成精确选择器建议。"""
# 全树属性出现次数(预统计,供重复元素加 [k] 序号消歧)
# 全树属性出现次数(预统计:判断"这个值重不重复",决定要不要消歧)
id_cnt, text_cnt, desc_cnt = Counter(), Counter(), Counter()
# 同属性值 → 该值对应的全部节点 properties(语义消歧要在"同值节点"里比第二个属性)
id_nodes, text_nodes, desc_nodes = defaultdict(list), defaultdict(list), defaultdict(list)
# 文档顺序已出现次数(决定当前元素是第几个)
seen_id, seen_text, seen_desc = Counter(), Counter(), Counter()
@@ -146,15 +175,57 @@ def _extract(root, out):
rid, text, desc = (props.get(k, "") for k in ("resource-id", "text", "content-desc"))
if rid:
id_cnt[rid] += 1
id_nodes[rid].append(props)
if text:
text_cnt[text] += 1
text_nodes[text].append(props)
if desc:
desc_cnt[desc] += 1
desc_nodes[desc].append(props)
for c in node.get("children") or []:
count_attrs(c)
def attr_selector(attr, value, cnt, seen):
"""属性选择器:唯一直接出;重复时用 `(…)[k]` 精确到**第 k 个匹配**。
# 消歧时可用的次要属性(按"越稳越靠前"排:文字 > 描述 > class)
_SECONDARY_ATTRS = ("text", "content-desc", "class")
def secondary_conds(peers, props):
"""属性值重复时,找一组能**把本节点单独圈出来**的次要属性。
例:抖音底部 4 个 tab 共享 resource-id `…:0qf`,但 text 分别是
首页/朋友/消息/我 —— 于是
//*[@resource-id="…:0qf" and @text="我"]
这种**语义选择器**只依赖"目标自己长什么样",不依赖"同 id 一共几个":
灰度版把 tab 从 4 个变 3 个、换一台设备,它照样命中。
对比 `(…)[k]`:那是"第 k 个匹配",界面一变就指到别的元素上 → 点不中。
返回 ([(attr, value), ...], via) 或 (None, None)。peers 是"同主属性值"的
全部节点 properties —— 唯一性只需在这一集合内成立(任何匹配都必然在此集合中)。
"""
def matched(conds):
return sum(1 for p in peers
if all((p.get(a) or "") == v for a, v in conds))
usable = [(a, props.get(a) or "") for a in _SECONDARY_ATTRS]
usable = [(a, v) for a, v in usable if v.strip()] # 空值不能做限定条件
# 单个次要属性够了就用它(选择器最短)
for a, v in usable:
if matched([(a, v)]) == 1:
return [(a, v)], a
# 单个不够 → 两个次要属性组合(如 @text + @class)
for i in range(len(usable)):
for j in range(i + 1, len(usable)):
conds = [usable[i], usable[j]]
if matched(conds) == 1:
return conds, f"{usable[i][0]}+{usable[j][0]}"
return None, None
def attr_selector(attr, value, cnt, seen, peers, props):
"""属性选择器:唯一直接出;重复时**先语义消歧,再退化到序号**。
语义消歧(2026-09-13 新增,见 doc/research/U2_ELEMENT_SELECTORS.md §五):
//*[@resource-id="x" and @text="我"] ← 不依赖元素个数,换设备/换版本仍成立
退而求其次(同 id 同文字都分不开,如列表里的重复项):
(//*[@resource-id="x"])[2] ← 精确到第 2 个匹配,但界面一变就失配
重要(XPath 位置谓词语义):
//*[@resource-id="x"][2] → 「在**其父节点**中排第 2 的属性匹配」,**不是**第 2 个匹配
@@ -163,12 +234,20 @@ def _extract(root, out):
[2..n] 全部匹配不到 → 运行时"未找到元素"(2026-09-10 实测修复)。
"""
seen[value] += 1
base = f'//*[@{attr}={_xpath_q(value)}]'
conds = [(attr, value)]
if cnt[value] > 1:
val = f"({base})[{seen[value]}]"
return val, {"type": "xpath", "value": val,
"indexed": True, "occ": seen[value], "total": cnt[value]}
return base, {"type": "xpath", "value": base}
extra, via = secondary_conds(peers, props)
if extra:
conds += extra
else:
val = f"({_xpath(conds)})[{seen[value]}]"
return val, {"type": "xpath", "value": val, "indexed": True,
"occ": seen[value], "total": cnt[value]}
val = _xpath(conds)
info = {"type": "xpath", "value": val}
if len(conds) > 1:
info.update(semantic=True, via=via)
return val, info
def flatten(node, depth=0, path="", ctx=None, tag_path="", tag_index=1):
"""递归扁平化。
@@ -201,7 +280,10 @@ def _extract(root, out):
# 始终带同 class 兄弟序号(含 [1]),结构路径才精确无歧义;
# class 为空的层(tag=*)不参与结构路径,避免 //*[N] 前缀污染导致选择器定位到任意节点
if tag != "*":
seg = f"{tag}[{tag_index}]"
# 步进必须写成 `*[@class="…"]`,**不能**写 `FrameLayout[1]`:
# Android dump 的 XML 里每个元素的**标签名都是 `<node>`**,class 在
# @class 属性上 —— 写成标签名会永远匹配不到任何东西(见下方 broad 注释)。
seg = f"*[@class={_xpath_q(cls)}][{tag_index}]"
own_tag_path = f"{tag_path}/{seg}" if tag_path else seg
else:
seg = ""
@@ -209,11 +291,14 @@ def _extract(root, out):
# ---- 推荐选择器:唯一属性 > 锚点祖先限定 > 全结构路径 ----
if rid:
child_ctx, suggested = attr_selector("resource-id", rid, id_cnt, seen_id)
child_ctx, suggested = attr_selector("resource-id", rid, id_cnt, seen_id,
id_nodes[rid], props)
elif text:
child_ctx, suggested = attr_selector("text", text, text_cnt, seen_text)
child_ctx, suggested = attr_selector("text", text, text_cnt, seen_text,
text_nodes[text], props)
elif desc:
child_ctx, suggested = attr_selector("content-desc", desc, desc_cnt, seen_desc)
child_ctx, suggested = attr_selector("content-desc", desc, desc_cnt, seen_desc,
desc_nodes[desc], props)
elif ctx:
val = f"{ctx}/{seg}"
suggested = {"type": "xpath", "value": val}
@@ -226,8 +311,12 @@ def _extract(root, out):
"invalid": True,
"reason": "该元素无可用属性(id/文本/class),无法生成可靠选择器"}
else:
# 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示)
suggested = {"type": "xpath", "value": f"//{own_tag_path}", "broad": True}
# 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示)。
# 按子节点位置逐层定位 —— 历史实现写成 //FrameLayout[1]/LinearLayout[2]
# 这种"class 当标签名"的路径,**永远零命中**(dump 的标签全是 <node>);
# 实测 248 个元素里 26 条死选择器,2026-09-13 修复。
suggested = {"type": "xpath", "value": f"//hierarchy{_child_path(path)}",
"broad": True}
child_ctx = None
out.append({