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
+11 -1
View File
@@ -527,10 +527,20 @@
|------|------|---------|
| `GET /api/uiauto/status` | — | uiautodev(:20242)是否在跑,前端据此禁/启用"抓取元素" |
| `GET /api/uiauto/devices` | — | 可选设备列表(uiautodev 设备 + 池内在线补全) |
| `GET /api/uiauto/elements?serial=` | — | 扁平元素列表,每项带 `suggested`(推荐选择器)、`bounds`、`depth` |
| `GET /api/uiauto/elements?serial=` | — | 扁平元素列表(走 uiautodev),每项带 `suggested`、`bounds`、`depth` |
| `GET /api/uiauto/snapshot?serial=` | — | **一次取齐**:截图 + 元素树(原生 u2 同一连接背靠背)→ `{image,width,height,elements,unstable,cost_ms}`;抓取弹窗用这个,失败返回 **502** |
| `GET /api/uiauto/screenshot?serial=` | — | JPEG |
| `POST /api/steps/test` | `{"serial","step":{…}}` | 真机试执行单个步骤 → `命中 / 未找到 / 已执行` |
**`suggested`(推荐选择器)字段**:`{type, value, semantic?, via?, indexed?, occ?, total?, broad?, invalid?, reason?}`
| 字段 | 含义 |
|------|------|
| `semantic` + `via` | **语义选择器**:`//*[@id="x" and @text="y"]`,`via` 是用于限定的第二属性(`text`/`content-desc`/`class` 或 `a+b`)——不依赖同类元素个数,换设备/换版本仍命中 |
| `indexed` + `occ`/`total` | 退化形式 `(//*[@id="x"])[k]`:同 id 且同文字分不开时才用,**界面一变即失配**,前端黄标提醒 |
| `broad` | 结构路径兜底(`//hierarchy/*[i]/*[j]`),最脆 |
| `invalid` + `reason` | 无任何可用属性,前端禁止回填 |
`GET /api/uiauto/elements` 在 uiautodev 不可用时返回 **503**。设备 dump 慢时可能超时(见 [backlog](backlog/TODO.md))。
---
+20 -9
View File
@@ -206,22 +206,33 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
历史实现写了前者,导致同 id 多实例(如底部导航 4 个 tab)时 `[2..n]` 全部失配。现在:
- `core/uiauto_helper.py` 生成的都是 `(//*[@…])[k]` 形式(唯一匹配时才省略序号)
- 执行器 `tasks/generic/task.py` 的 `_norm_legacy_xpath` 会**自动纠正**旧任务里的 `//*[@x][k]` 写法(仅前缀,`.../FrameLayout[2]` 这类兄弟序号不受影响)
- 执行器 `tasks/generic/task.py` 的 `_norm_legacy_xpath` 会**自动纠正**旧任务里的 `//*[@x][k]` 写法(仅前缀,类名/位置步进不受影响)
- 抓取器生成 `(…)[k]` 前**先做语义消歧**(见 §5.4),序号型只是最后的退路
⚠️ **序号型选择器仍然脆弱**:它依赖"抓取那一刻该属性有 ≥k 个实例"。界面不同(如 App 还在闪屏页)就会失配——优先选唯一 id 或文字。
⚠️ **序号型选择器仍然脆弱**:它依赖"抓取那一刻该属性有 ≥k 个实例"。界面不同(如 App 还在闪屏页)就会失配——优先选带**文字**的元素,抓取器会自动产出语义选择器。
### 5.4 抓取器给的建议选择器
`GET /api/uiauto/elements` 返回的元素带 `suggested`,生成优先级:
`GET /api/uiauto/elements`、`GET /api/uiauto/snapshot` 返回的元素都带 `suggested`,生成优先级:
1. 有 `resource-id` → `//*[@resource-id="v"]`(重复时带序号 `(…)[k]`,并标 `indexed:true` + 出现次数)
2. 无 id 有 `text` → `//*[@text="v"]`
3. 无 id/text 有 `content-desc` → `//*[@content-desc="v"]`
4. 都没有但有"最近的有属性祖先" → `{祖先}/{Class}[n]` 结构路径
5. 连祖先都没有 → `//{class_path}`(标 `broad:true`,脆弱,前端会提示)
1. 有 `resource-id` → `//*[@resource-id="v"]`;无 id 但有 `text`/`content-desc` 同理
2. 该属性值**全树有多个**时,**先用第二个属性把目标单独圈出来**(语义选择器,标 `semantic:true` + `via`):
`//*[@resource-id="v" and @text="我"]`。抖音底部导航同 id 的几个 tab 靠这个解决——
灰度版 tab 数量会变(4 个 ↔ 3 个),序号必然错位,文字不会
3. 两个属性组合仍分不开(列表里同 id 同文字)→ 才退回序号 `(…)[k]`(标 `indexed:true` + `occ`/`total`,前端打**黄标 ⚠ 序号**)
4. 都没有属性、但有"最近的有属性祖先" → `{祖先}/*[@class="android.widget.ImageView"][n]`
5. 连祖先都没有 → `//hierarchy/*[i]/*[j]`(按**子节点位置**逐层,标 `broad:true`,脆弱、前端会提示)
6. 类名也是空 → 标 `invalid:true`(提示无法生成可靠选择器)
**两个必须记住的 XPath 坑**(都实测踩过):
- **dump 的 XML 标签名一律是 `<node>`**,`class` 在 `@class` 属性上。所以结构步进只能写
`*[@class="…"]` 或位置 `*[i]`;写成 `//FrameLayout[1]` 这种"类名当标签名"的路径**永远零命中**
(2026-09-13 修:248 个元素里曾有 26 条死选择器)
- **同级节点的 `index` 属性会重复**(状态栏/内容区/导航栏三个兄弟的 `index` 全是 `0`),
兜底结构路径要按**位置** `*[i+1]` 定位,不能按 `@index`
### 5.5 抓取与验证(编辑器里的两个按钮)
- **「▶ 点一下」**:按元素 `bounds` 中心在设备上真点一次(`POST /api/screen/tap`,`snap=1` 自动吸附到可点元素中心),返回吸附结果并刷新截图——确认位置是否可达
+1 -1
View File
@@ -86,7 +86,7 @@
- [ ] **序号型 XPath / 界面就绪 的防呆**(2026-09-10 db03f16e 案例)
- 背景:`(//*[@resource-id="…"])[6]` 依赖"抓取那一刻该属性有 ≥6 个实例";运行时界面不同(App 仍在闪屏页)→ 序号必然失配。
- 期望:① `open_app` 提示勾选「等待首页」;② 抓取器对**带序号**的选择器加醒目提示;③ 抓取弹窗提醒"请先把设备停在任务运行到该步时的同一界面再抓"。
- 期望:① `open_app` 提示勾选「等待首页」;② ~~抓取器对**带序号**的选择器加醒目提示~~(✅ 2026-09-13 已完成,且更进一步:抓取器**优先产出语义选择器**再去掉序号,见 [research/U2_ELEMENT_SELECTORS.md](../research/U2_ELEMENT_SELECTORS.md) §五);③ 抓取弹窗提醒"请先把设备停在任务运行到该步时的同一界面再抓"。
- 涉及:`core/uiauto_helper.py`、`static/admin/editor.js`、`tasks/generic/task.py`、`doc/TASK_DEV.md`。
- [ ] 自定义动作支持 `action_ref` 引用型节点(现状:拖入画布会**展开成 group**,改动需同步执行器 + 编辑器)。
+44 -10
View File
@@ -125,16 +125,50 @@
---
## 五、建议的改动(待排期)
## 五、建议的改动
1. **抓取器(`core/uiauto_helper.py`)**:同一 id 出现多个实例时,**优先产出
`//*[@resource-id="x" and @text="…"]`** 而不是 `(…)[k]`;抓取列表里把
`text`/`content-desc` 放在最显眼位置,让用户能看着选。
2. **抓取弹窗**:对**带序号**的选择器加醒目提示("这个选择器依赖界面元素个数,
界面一变就失效")。
3. **已存任务**:把 `(…)[k]` 型选择器扫一遍,能改成文字限定的自动改(脚本),
其余在编辑器里逐个复核。
4. **运行期**:`click` 未命中时,日志里顺带 dump 一下"同 id 现在有几个实例",
1. ✅ **抓取器优先产出语义选择器**(2026-09-13 已实现,`core/uiauto_helper._extract`):
同一属性值出现多个实例时,**先找第二个属性把目标单独圈出来** ——
`//*[@resource-id="x" and @text="…"]`(次属性按 `text` > `content-desc` > `class`
排,单个不够就两个组合);只有怎么都分不开(列表里同 id 同文字)才退回
`(…)[k]`。返回值标 `semantic:true` + `via`(限定用的属性)或 `indexed:true`。
抓取列表里 **text/content-desc 排在最前并加粗上色**,让用户能看着选。
2. ✅ **序号型加醒目提示**(同日):前端把 `indexed` 的徽标从蓝色 `#k/n` 换成
黄色 **⚠ 序号 k/n**,悬浮说明"依赖同类元素个数,界面一变就指到别的元素上";
语义型给绿色 **✓ 语义**;右侧「属性」页新增一行**选择器稳定性**。
3. ⏳ **已存任务**:把 `(…)[k]` 型选择器扫一遍,能改成文字限定的自动改(脚本),
其余在编辑器里逐个复核。(用户自行处理)
4. ⏳ **运行期**:`click` 未命中时,日志里顺带 dump 一下"同 id 现在有几个实例",
让"序号错位"当场可诊断(现在是干巴巴一句"未找到元素")。
5. **(针对第四节的"抓取错位")** 抓取接口合并成一个快照接口 + 双截图校验,
5. ✅ **(针对第四节的"抓取错位")** 抓取接口合并成一个快照接口 + 双截图校验,
具体见第四节「解法」。
---
## 六、顺带修掉的两个"结构性"坑(2026-09-13)
给 §五.1 做验证时(对真机 dump 逐条用 lxml 求值,比对"命中数==1 且 bounds 一致"),
发现旧实现 248 个元素里有 **26 条选择器永远匹配不到任何东西** —— 都是"结构路径"类:
| 坑 | 现象 | 原因 |
|---|---|---|
| `//FrameLayout[1]/LinearLayout[2]` | 零命中 | **dump 的 XML 标签名一律是 `<node>`**,class 在 `@class` 属性上 —— 把类名当标签名写的路径永远匹配不到 |
| `//hierarchy/node[@index="1"]` | 零命中或一次命中多个 | 同级节点的 **`index` 属性会重复**(实测状态栏/内容区/导航栏三个兄弟的 `index` 全是 `0`) |
**修法**:
- 子路径步进改成 `*[@class="android.widget.ImageView"][n]`(谓词 `[n]` 在步进选出的
节点集上按文档序定位,语义与原来的"同 class 兄弟序号"一致)
- 兜底结构路径改成按**子节点位置**:`//hierarchy/*[1]/*[3]`
**效果**(同一份真机 XML,248 个元素):
| | 旧 | 新 |
|---|---|---|
| 先按 lxml 求值,精确命中目标 | 221 | **247** |
| 零命中(死选择器) | 26 | **0** |
| 语义型 / 序号型 | 0 / 141 | **26 / 115** |
`core/uiauto_helper.py` 里 `_child_path()` 的注释与 `doc/TASK_DEV.md` §5.4 都记了这两个坑,
避免以后又写回来。