diff --git a/core/uiauto_helper.py b/core/uiauto_helper.py
index 69fe8ba..db6f448 100644
--- a/core/uiauto_helper.py
+++ b/core/uiauto_helper.py
@@ -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):
"""递归扁平化。
diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md
index c9bacbc..0749dff 100644
--- a/doc/TASK_DEV.md
+++ b/doc/TASK_DEV.md
@@ -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**。
diff --git a/static/admin/editor.js b/static/admin/editor.js
index 82b3b1b..7896f39 100644
--- a/static/admin/editor.js
+++ b/static/admin/editor.js
@@ -845,7 +845,10 @@ var _stepEditor={
'';
document.getElementById('el-picker-footer').innerHTML=
''+
- '';
+ ''+
+ ''
+ +'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;'
+ +'「✓ 测选择器」会用将填入的选择器真跑一次点击';
// 加载截图和元素树(_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('⚠ '+esc(sg.reason||'无可用属性')+'');
+ var acts='
'
+ +''
+ +(!sg.invalid&&sg.value
+ ? ''
+ : '')
+ +'
';
return ''+
'
'+(el.depth||0)+''+
'
'+indent+esc(el.name||el.class||'')+'
'+
@@ -1006,10 +1017,41 @@ var _stepEditor={
'
'+esc(sg.type)+''+
(sg.indexed?'
#'+sg.occ+'/'+sg.total+'':'')+
(sg.broad?'
⚠宽泛':'')+
+ acts+
'
';
}).join('');
document.getElementById('el-list').innerHTML=html||'
无元素
';
},
+ // 抓取时"直接点一下":按元素 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();
diff --git a/tasks/generic/task.py b/tasks/generic/task.py
index 02697fd..6eb0b8f 100644
--- a/tasks/generic/task.py
+++ b/tasks/generic/task.py
@@ -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: