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: