feat(条件判断): 支持「选中元素 → 拿它的文本和设定的值比」(等于/不等于/包含/不包含)

需求原话:条件判断需要能选择元素,比如我设置了一个抖音号,让它采集这个元素的
文本和我设的值做条件判断。

原来的条件判断只会判「元素在不在」(找到走 then、没找到走 else),没法判"内容对不对"。
现在加两个可选参数:

- `cmp_op`:等于 / 不等于 / 包含 / 不包含(`==`/`!=`/`contains` 这类别名也认);
  留空 = 老行为,**完全向后兼容**(现有任务不用改)。
- `cmp_value`:要比对的值。

填了就变成:先按选择器取到元素文本 → 和 cmp_value 比 → 比对结果才是命中与否。
用户那个例子就是:元素 `com.ss.android.ugc.aweme:id/506` 的文本是
`抖音号:35377983067`,选「包含」、值填 `35377983067` 即可;
要抓"登录的不是这个号"就用「不等于」。

- 读取只走 `get_text()`(只读,2 秒超时),不点击、不改状态。
- **两条防误判规则**(都写进文档了):
  · 元素**没找到**时一律算未命中(走 else)——"没读到"绝不能被当成"和我设的不一样",
    否则界面还没加载出来就会误判成"账号被换了";
  · 元素在但文本为空时按空文本参与比对(用于抓"栏位是空的"),日志会打出实际读到的值。
- 比对**不污染「选择器健康」统计**:统计用的仍是"元素在不在"——"找到了但值不对"
  是条件按预期走了 else,不是选择器失效,不该攒出「连续未命中」告警。
- 支持比对的类型:所有元素类选择器 + `foreground`(比前台包名)+ `ocr`(比 OCR 命中的那段字)。
  `screen`(亮/熄)没有文本可比,设了会忽略并告警。
- 前端:条件判断面板多一块「文本比对」(运算符下拉 + 比对的值),
  只在元素/OCR/前台App 条件下出现;保存前校验"选了运算符却没填值"和"screen 上设了比对"。
- AI 建任务草稿校验(`core/task_draft.py`)同步拦"运算符拼错""有运算符没值"——
  执行器对认不出的运算符是"一律不命中",不拦就会变成悄悄走 else。

自测:比对函数 16 项(含别名、空值、None、拼错运算符)、`_exec_if_el` 全分支 20 项
(含"没找到+不等于"这个关键边界、健康统计用存在性、screen 忽略比对、u2 抛异常不炸)、
**真机验证**(cs1 上真读一个 TextView 的文本再比对:包含/等于/不等于 全对,
元素不存在时不误判)、草稿校验 5 项、GET 路由冒烟 55 个 0 个 500。

(测试里踩到一个坑记一笔:u2 的 `XPathSelector.exists` 是纯 bool 属性,而
`UiObject.exists` 是"可调用的 Exists 对象"(`bool()` 即时判断、`exists(timeout=)` 带等待),
两套语义不能想当然统一——单测的假设备一开始没模拟对,是测试写错了不是代码写错了。)
This commit is contained in:
2026-09-24 09:22:32 +08:00
parent bf432b4f56
commit 34a03db1b9
4 changed files with 181 additions and 8 deletions
+103 -3
View File
@@ -113,7 +113,7 @@ STEP_TYPES = [
"params": {"children": []}},
{"type": "if_el", "label": "条件判断", "icon": "❓",
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
"then": [], "else": []}},
"cmp_op": "", "cmp_value": "", "then": [], "else": []}},
{"type": "notify", "label": "发通知", "icon": "🔔",
"params": {"title": "", "message": "", "level": "info"}},
{"type": "stop_self", "label": "停止本设备", "icon": "⛔",
@@ -129,6 +129,43 @@ DEFAULT_PARAMS = {
# 缺 steps 的任务执行时会明确报错(见 run_task),不会静默空跑。
}
# 「条件判断」文本比对时读元素文本的超时(秒):只读一次属性,正常几十毫秒
_TEXT_READ_TIMEOUT = 2
# 「条件判断」的文本比对运算符:中文为主(界面上就是这个),英文/符号也认,
# 方便 AI 建任务或手写 JSON 时不用猜用哪个词。
_CMP_ALIASES = {
"等于": ("等于", "==", "=", "eq", "equals", "equal"),
"不等于": ("不等于", "!=", "<>", "ne", "not_equal", "notequal"),
"包含": ("包含", "含", "contains", "in", "has"),
"不包含": ("不包含", "不含", "not_contains", "notcontains", "excludes"),
}
# 界面上给的运算符(顺序即下拉框顺序);上面那张表另收了英文/符号别名。
# AI 草稿校验(core/task_draft.py)用它拦"运算符拼错 -> 永远不命中"。
CMP_OPS = tuple(_CMP_ALIASES)
def _text_matches(got, op, want):
"""元素文本比对:got(采集到的)对 want(用户设的)做 op 判断。
op 见 `_CMP_ALIASES`(等于/不等于/包含/不包含)。认不出的 op 一律返回 False
——条件判断"看不懂就算不命中",比猜一个更容易排查。
"""
got = (got or "").strip()
want = (want or "").strip()
key = (op or "").strip().lower()
for canon, aliases in _CMP_ALIASES.items():
if key in [a.lower() for a in aliases]:
if canon == "等于":
return got == want
if canon == "不等于":
return got != want
if canon == "包含":
# 想匹配空串等于"永远命中",那是误配,当不命中处理
return bool(want) and want in got
return bool(want) and want not in got
return False
class GenericStepsWorker(BaseWorker):
"""通用步骤 worker:按 params.steps 顺序执行步骤链。
@@ -576,15 +613,24 @@ class GenericStepsWorker(BaseWorker):
selector_type=ocr 时用屏幕 OCR 匹配文字(图片/画布/WebView 里的文字也能找到),
命中且 ocr_click=True 时自动点击该文字中心。
分支里可放任意步骤(含循环/嵌套条件判断),实现"有弹窗就关掉、没弹窗就继续"这类逻辑。
**文本比对(cmp_op / cmp_value,可选)**:光"元素在不在"不够用——常见需求是
"这台机器上登录的抖音号是不是我配的那个"。填了 cmp_op 就变成:
先按选择器**取到元素的文本**,再拿它和 cmp_value 比(等于/不等于/包含/不包含),
比对结果才是命中与否。例:`resourceId=com.ss.android.ugc.aweme:id/506` 的文本是
`抖音号:35377983067`,cmp_op=包含、cmp_value=35377983067 就是"这台上登录的是它"。
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
"""
sel_type = params.get("selector_type", "xpath")
sel_val = params.get("selector_value", "")
timeout = float(params.get("timeout", 3))
cmp_op = str(params.get("cmp_op") or "").strip()
cmp_val = str(params.get("cmp_value") or "")
if not sel_val:
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
return None
found = False
got = None # 比对用的"采集到的值":元素文本 / OCR 命中文本 / 前台包名
# 非元素类条件(屏幕状态/前台 App):不参与"选择器健康"统计——它们不是选择器,
# 连续未命中不该被记成"选择器失效"
dynamic = sel_type in ("screen", "foreground")
@@ -599,6 +645,7 @@ class GenericStepsWorker(BaseWorker):
f"(判断{'熄屏' if want_off else '亮屏'})")
elif sel_type == "foreground":
cur = current_package(d)
got = cur or ""
found = cur == str(sel_val).strip()
detail = f"前台='{cur or '未知'}'(期望 {sel_val})"
elif sel_type == "ocr":
@@ -614,6 +661,7 @@ class GenericStepsWorker(BaseWorker):
_log.warning(f"[{self.serial}] OCR 截图失败")
else:
found, center, matched = find_on_screen(img, sel_val)
got = matched or "" if found else ""
if found:
if params.get("ocr_click") and center:
d.click(*center)
@@ -634,16 +682,68 @@ class GenericStepsWorker(BaseWorker):
except Exception as e:
_log.warning(f"[{self.serial}] 条件判断异常: {e}")
found = False
# 元素"在不在"的原始结果:选择器健康统计要用它、**不能**用比对结果——
# "找到了但文本不对"是条件按预期走了 else 分支,不是选择器失效。
existed = found
# ---- 文本比对(可选)----
if cmp_op and sel_type != "screen":
if not found:
# 元素都没找到就没什么可比的:算未命中(走 else 分支)。
# 这条规则很重要——"没读到"永远不能被当成"和我设的不一样",
# 否则界面还没加载出来时会误判成"账号被别人换了"。
nofind = f"未找到目标,无法比对({cmp_op} {cmp_val})"
detail = f"{detail};{nofind}" if detail else nofind
else:
if got is None:
got = self._read_element_text(d, sel_type, sel_val)
hit = _text_matches(got, cmp_op, cmp_val)
detail = f"采集到 '{got}' {cmp_op} '{cmp_val}' → {'符合' if hit else '不符合'}"
found = hit
elif cmp_op and sel_type == "screen":
_log.warning(f"[{self.serial}] 屏幕状态没有文本可比,已忽略文本比对设置")
if dynamic:
_log.info(f"[{self.serial}] 条件判断 {detail} → {'命中' if found else '未命中'}")
else:
self._track_selector_health(sel_val, found)
self._track_selector_health(sel_val, existed)
branch = params.get("then" if found else "else") or []
self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)")
if cmp_op:
cmp_txt = "比对命中" if found else "比对未命中"
else:
cmp_txt = "命中" if found else "未命中"
self.set_action(f"条件{cmp_txt} → 执行{'then' if found else 'else'}分支({len(branch)}步)")
_log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {detail or sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步")
self._exec_steps(d, branch, depth + 1)
return found
def _read_element_text(self, d, sel_type, sel_val):
"""取选中元素的文本(文本比对用)。读不到返回 ""。
只读操作,不改任何状态;失败静默返回空串,由调用方按"空文本"去比对
(日志里会打出实际读到的值,排查时能看出是空还是不对)。
注意 u2 两种元素的 exists 语义不同,别"统一"掉:
· `d.xpath(...)` → XPathSelector.exists 是**纯 bool 属性**
· `d(text=...)` → UiObject.exists 是**可调用的 Exists 对象**
(`bool()` 即时判断、`exists(timeout=t)` 带等待)——两种写法都对
"""
try:
if sel_type == "xpath":
el = d.xpath(sel_val)
if not el.exists:
return ""
return (el.get_text() or "").strip()
el = d(**{sel_type: sel_val})
if not el.exists:
return ""
# 限时读:元素在"检查"与"读"之间消失时,别挂满默认等待(默认可能是 10s)
return (el.get_text(timeout=_TEXT_READ_TIMEOUT) or "").strip()
except Exception as e:
_log.warning(f"[{self.serial}] 读取元素文本失败(按空文本比对): {e}")
return ""
def _exec_swipe(self, d, params, depth=0):
"""滑动一次(默认拟人:弧线 + 抖动 + 本设备手速,见 core/humanize.py)。