fix: 元素选择器序号语义 + 抓取弹窗直接点击测试
- uiauto_helper: 同属性多实例的选择器由 `//*[@id="x"][k]` 改为 `(//*[@id="x"])[k]`—— 前者在 XPath 里是"父节点内排第 k",多实例时 [2..n] 全部失配(实测抖音底部 4 个同 id tab: 仅 [1] 可用),任务里表现为"未找到元素"但界面上元素明明存在。 - tasks/generic/task.py: 新增 _norm_legacy_xpath,执行前把**历史遗留**的 `//*[@attr=…][k]` 窄范围纠正为带括号形式(只改前缀,结构路径 …/FrameLayout[2] 的 兄弟序号保持不动)——已存任务无需重抓即可恢复。 - editor.js: 抓取弹窗每条元素新增「▶ 点一下」(按 bounds 中心真点一次,/api/screen/tap snap=1 吸附)与「✓ 测选择器」(用将填入的选择器跑 /api/steps/test 验证命中), 点击后自动刷新截图;底部加用法提示。 - doc/TASK_DEV.md:写明 xpath 序号必须整体加括号 + 旧形态自动纠正 + 优先文字/唯一 id。
This commit is contained in:
+15
-7
@@ -89,9 +89,10 @@ def get_elements(serial):
|
||||
1. 有 resource-id/text/content-desc 的元素:
|
||||
先预统计该属性在整棵树中的出现次数——
|
||||
- 唯一出现:直接用属性选择器 //*[@resource-id="x"]
|
||||
- 重复出现(如抖音信息流点赞按钮同 id 几十个):附加 [k] 位置谓词
|
||||
精确到具体实例 //*[@resource-id="x"][k]。uiautomator2 的 d.xpath()
|
||||
底层是 lxml 标准 XPath,[k] 与抓取时同一语义,不会误中屏幕外第一个。
|
||||
- 重复出现(如抖音底部导航 4 个 tab 同 id):用**整体加括号**的
|
||||
`(//*[@resource-id="x"])[k]` 精确到第 k 个匹配。
|
||||
注意 XPath 语义:`//*[@id="x"][k]` 是"在其父节点中排第 k",不是
|
||||
第 k 个匹配——历史实现踩过这个坑(多实例时 [2..n] 全部失配)。
|
||||
2. 无任何属性的元素:
|
||||
用最近一个有属性祖先的选择器限定范围 + 同 class 兄弟序号定位
|
||||
(如 //*[@resource-id="x"]/FrameLayout/ImageView[2]);
|
||||
@@ -153,14 +154,21 @@ def _extract(root, out):
|
||||
count_attrs(c)
|
||||
|
||||
def attr_selector(attr, value, cnt, seen):
|
||||
"""属性选择器:唯一直接出,重复加 [k] 位置谓词。返回 (选择器, suggested)。"""
|
||||
"""属性选择器:唯一直接出;重复时用 `(…)[k]` 精确到**第 k 个匹配**。
|
||||
|
||||
重要(XPath 位置谓词语义):
|
||||
//*[@resource-id="x"][2] → 「在**其父节点**中排第 2 的属性匹配」,**不是**第 2 个匹配
|
||||
(//*[@resource-id="x"])[2] → 「第 2 个匹配」← 我们要的
|
||||
历史实现写成前者,导致同 id 多实例(如抖音底部导航 4 个 tab 同 id)时
|
||||
[2..n] 全部匹配不到 → 运行时"未找到元素"(2026-09-10 实测修复)。
|
||||
"""
|
||||
seen[value] += 1
|
||||
val = f'//*[@{attr}={_xpath_q(value)}]'
|
||||
base = f'//*[@{attr}={_xpath_q(value)}]'
|
||||
if cnt[value] > 1:
|
||||
val += f"[{seen[value]}]"
|
||||
val = f"({base})[{seen[value]}]"
|
||||
return val, {"type": "xpath", "value": val,
|
||||
"indexed": True, "occ": seen[value], "total": cnt[value]}
|
||||
return val, {"type": "xpath", "value": val}
|
||||
return base, {"type": "xpath", "value": base}
|
||||
|
||||
def flatten(node, depth=0, path="", ctx=None, tag_path="", tag_index=1):
|
||||
"""递归扁平化。
|
||||
|
||||
@@ -317,6 +317,12 @@ while not self.stopped():
|
||||
`className`;**仅 `if_el` 额外支持 `ocr`**(截屏 OCR 按文字匹配,UI 树里没有的文字也能找到,可选 `ocr_click` 命中后自动点击)。
|
||||
带选择器的步骤(click/long_click/swipe_until/wait_el/if_el)都必须填 `selector_value`。
|
||||
|
||||
> **xpath 序号必须整体加括号**(2026-09-10 修复):同一属性多个实例时,"第 k 个匹配"要写
|
||||
> **`(//*[@resource-id="x"])[k]`**;写成 `//*[@resource-id="x"][k]` 是"在其**父节点**中排第 k",
|
||||
> 多实例时 `[2..n]` 全部匹配不到(表现为运行时"未找到元素",但界面上明明有这个元素)。
|
||||
> 元素抓取器现已生成带括号形式;执行器 `_norm_legacy_xpath` 会自动纠正**旧任务**里的前者(只改前缀,
|
||||
> 结构路径 `.../FrameLayout[2]` 的兄弟序号不动)。等价工具定位优先用 `text`/`resourceId`,比序号 xpath 稳。
|
||||
|
||||
> **静默跳过语义**:worker 对**未知 type / 缺必填**(如 `package`、`selector_value` 为空)**只打 warning 跳过,不会报错失败**——任务会"看起来成功但啥也没干"。因此写任务必须**自行校验**:用编辑器内置 validate + "测试此步骤"逐个验证选择器(见 §2.11)。
|
||||
> **权威 schema**:`tasks/generic/task.py` 的 `STEP_TYPES`(后端执行器)与 `static/admin/editor.js` 的 `STEP_LIB`(前端操作库)**必须保持同步**——改节点结构两边要一起改。
|
||||
> **AI 辅助生成**:用一句话需求 → AI 生成 generic_steps 任务(步骤 JSON → 编辑器预填 → 人工确认)的规划见 **doc/AI_TASK_GEN.md**。
|
||||
|
||||
+43
-1
@@ -845,7 +845,10 @@ var _stepEditor={
|
||||
'</div>';
|
||||
document.getElementById('el-picker-footer').innerHTML=
|
||||
'<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+
|
||||
'<button class="btn" onclick="closeElPicker()">取消</button>';
|
||||
'<button class="btn" onclick="closeElPicker()">取消</button>'+
|
||||
'<span class="text-muted" style="margin-left:10px;font-size:11.5px">'
|
||||
+'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;'
|
||||
+'「✓ 测选择器」会用将填入的选择器真跑一次点击</span>';
|
||||
// 加载截图和元素树(_refreshShot 内部同时刷新两者)
|
||||
self._refreshShot();
|
||||
},
|
||||
@@ -999,6 +1002,14 @@ var _stepEditor={
|
||||
var indent=' '.repeat(Math.min(el.depth||0,6));
|
||||
var sg=el.suggested||{};
|
||||
if(sg.invalid)attrs.push('<span style="color:#dc2626">⚠ '+esc(sg.reason||'无可用属性')+'</span>');
|
||||
var acts='<div class="ep-acts" style="margin-left:auto;display:flex;gap:4px;flex:none">'
|
||||
+'<button class="btn btn-xs" title="直接在设备上点这个元素(按元素中心坐标点击,服务端自动吸附到可点元素)" '
|
||||
+'onclick="event.stopPropagation();_stepEditor._testTap('+idx+')">▶ 点一下</button>'
|
||||
+(!sg.invalid&&sg.value
|
||||
? '<button class="btn btn-xs" title="用将填入的选择器在设备上真跑一次 click,验证是否命中" '
|
||||
+'onclick="event.stopPropagation();_stepEditor._testSelector('+idx+')">✓ 测选择器</button>'
|
||||
: '')
|
||||
+'</div>';
|
||||
return '<div class="el-picker-item" data-idx="'+idx+'" title="点击填入: '+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+
|
||||
'<span class="ep-depth">'+(el.depth||0)+'</span>'+
|
||||
'<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+
|
||||
@@ -1006,10 +1017,41 @@ var _stepEditor={
|
||||
'<span class="ep-sel">'+esc(sg.type)+'</span>'+
|
||||
(sg.indexed?' <span class="label label-info" title="界面中同属性元素共 '+sg.total+' 个,按从上到下这是第 '+sg.occ+' 个,选择器已带序号精确定位">#'+sg.occ+'/'+sg.total+'</span>':'')+
|
||||
(sg.broad?' <span class="label label-warning" title="无唯一属性,选择器可能误匹配其他元素,建议选带文字/id 的元素">⚠宽泛</span>':'')+
|
||||
acts+
|
||||
'</div>';
|
||||
}).join('');
|
||||
document.getElementById('el-list').innerHTML=html||'<div class="se-empty">无元素</div>';
|
||||
},
|
||||
// 抓取时"直接点一下":按元素 bounds 中心在设备上点击(服务端 snap 吸附)
|
||||
_testTap:function(idx){
|
||||
var el=this._elData&&this._elData[idx]; var serial=this._elSerial;
|
||||
if(!el||!serial)return;
|
||||
var m=(el.bounds||'').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
||||
if(!m){ showToast('该元素没有 bounds,无法坐标点击','error'); return; }
|
||||
var x=Math.round((+m[1]+ +m[3])/2), y=Math.round((+m[2]+ +m[4])/2);
|
||||
var self=this;
|
||||
apiPost('/api/screen/tap', {serial:serial, x:x, y:y, snap:1}).then(function(r){
|
||||
if(!r||!r.ok){ showToast((r&&r.error)||'点击失败','error'); return; }
|
||||
showToast('已点击'+(r.label?('「'+r.label+'」'):'')+' ('+r.x+','+r.y+')'+(r.snapped?' [已吸附]':''),'success');
|
||||
setTimeout(function(){ if(self._elSerial===serial)self._refreshShot(); }, 800);
|
||||
});
|
||||
},
|
||||
// 抓取时验证"将填入的选择器":走 /api/steps/test 真跑一次 click
|
||||
_testSelector:function(idx){
|
||||
var el=this._elData&&this._elData[idx]; var serial=this._elSerial;
|
||||
if(!el||!serial)return;
|
||||
var sg=el.suggested||{};
|
||||
if(!sg.value){ showToast('该元素没有可用选择器','error'); return; }
|
||||
var self=this;
|
||||
var step={type:'click', label:'测试点击',
|
||||
params:{selector_type:sg.type||'xpath', selector_value:sg.value, wait_timeout:2}};
|
||||
apiPost('/api/steps/test', {serial:serial, step:step}).then(function(r){
|
||||
if(!r||!r.ok){ showToast((r&&r.error)||'测试失败','error'); return; }
|
||||
var ok=(r.result==='命中');
|
||||
showToast('选择器测试: '+(r.result||'')+(r.msg?(' — '+r.msg):''), ok?'success':'error');
|
||||
setTimeout(function(){ if(self._elSerial===serial)self._refreshShot(); }, 800);
|
||||
});
|
||||
},
|
||||
_filterEl:function(kw){
|
||||
if(!kw){this._renderEl(this._elData);this._renderOverlays();return;}
|
||||
kw=kw.toLowerCase();
|
||||
|
||||
@@ -30,6 +30,7 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
|
||||
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
|
||||
"""
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
|
||||
from tasks.base import BaseTask, register_task
|
||||
@@ -39,6 +40,24 @@ from core.logger import get_logger
|
||||
|
||||
_log = get_logger("task.generic")
|
||||
|
||||
# 历史抓取器生成的"伪序号"选择器://*[@resource-id="x"][4]
|
||||
# XPath 里这是"父节点内排第 4",不是"第 4 个匹配"——同属性多实例时 [2..n] 全部失配。
|
||||
_LEGACY_IDX_XPATH = re.compile(r'^(//\*\[@[^\]]+\])\[(\d+)\](.*)$')
|
||||
|
||||
|
||||
def _norm_legacy_xpath(sel_val):
|
||||
"""把历史 `//*[@attr=…][k]` 纠正为 `(//*[@attr=…])[k]`(只改整体前缀,保留后续子路径)。
|
||||
|
||||
窄范围:只匹配"属性XPath + 数字谓词"开头的形态;结构路径里
|
||||
`.../FrameLayout[2]` 的兄弟序号是有意为之,不受影响。
|
||||
"""
|
||||
if not isinstance(sel_val, str):
|
||||
return sel_val
|
||||
m = _LEGACY_IDX_XPATH.match(sel_val.strip())
|
||||
if not m:
|
||||
return sel_val
|
||||
return f"({m.group(1)})[{m.group(2)}]{m.group(3)}"
|
||||
|
||||
|
||||
# ================== 步骤类型定义(前端操作库 + 后端执行共用)==================
|
||||
STEP_TYPES = [
|
||||
@@ -165,6 +184,12 @@ class GenericStepsWorker(BaseWorker):
|
||||
stype = step.get("type", "")
|
||||
label = step.get("label", stype)
|
||||
params = step.get("params", {})
|
||||
# 兼容历史选择器://*[@attr=…][k] → (//*[@attr=…])[k](XPath 位置谓词语义)
|
||||
if params.get("selector_type") == "xpath" and params.get("selector_value"):
|
||||
fixed = _norm_legacy_xpath(params["selector_value"])
|
||||
if fixed != params["selector_value"]:
|
||||
params = dict(params, selector_value=fixed)
|
||||
_log.info(f"[{self.serial}] 选择器已纠正为: {fixed}")
|
||||
# 概率触发:probability=100 必执行,<100 按百分比概率决定本次是否执行
|
||||
prob = float(params.get("probability", 100))
|
||||
if prob < 100 and random.random() * 100 > prob:
|
||||
|
||||
Reference in New Issue
Block a user