Files
auto_control/doc/TASK_DEV.md
T
butubb 34a03db1b9 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=)` 带等待),
两套语义不能想当然统一——单测的假设备一开始没模拟对,是测试写错了不是代码写错了。)
2026-09-24 09:22:32 +08:00

646 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务与步骤开发(TASK_DEV)
> 适用读者:写任务、编排步骤、加步骤类型的开发者。
> 相关文档:[ARCHITECTURE.md](ARCHITECTURE.md) §5(调度链路)、[API.md](API.md) §5(任务接口)、[AI_CONSOLE.md](AI_CONSOLE.md)(AI 也会产步骤)。
> 现状:平台**只有 `generic_steps` 一种任务类型**,绝大多数 App 操作直接用步骤编辑器编排即可。
---
## 目录
- [1. 核心概念](#1-核心概念)
- [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps)
- [3. 21 种步骤全表](#3-21-种步骤全表)(含 [3.1 步骤默认值与动作录制](#31-步骤默认值与动作录制任务--动作配置))
- [4. 容器步骤与公共参数](#4-容器步骤与公共参数)(含 [4.5 公共巡检](#45-公共巡检任务级独立于步骤画布))
- [5. 选择器与元素定位](#5-选择器与元素定位)
- [6. 自定义动作](#6-自定义动作)
- [7. 步骤编辑器(前端)](#7-步骤编辑器前端)
- [8. 新增任务类型](#8-新增任务类型)
- [9. Action 框架(App 专属操作)](#9-action-框架app-专属操作)
- [10. 测试与排查](#10-测试与排查)
- [11. 开发检查清单](#11-开发检查清单)
---
## 1. 核心概念
### 1.1 TaskType — 任务类型
一个 `TaskType` = `Task` 子类 + `Worker` 子类 + `DEFAULT_PARAMS`,用 `@register_task` 注册进全局 `_TASK_TYPES`(key 为 `task_type` 字符串)。
```python
# tasks/base.py
_TASK_TYPES = {}
def register_task(task_cls):
"""任务类型注册装饰器(无需传 name,用 task_cls.task_type)"""
_TASK_TYPES[task_cls.task_type] = task_cls
return task_cls
def get_task_class(task_type):
return _TASK_TYPES.get(task_type) # 未注册返回 None
```
**注册位置**:`tasks/__init__.py` 只做两件事——`from .base import ...` 聚合导出,以及 import 各任务包子包触发注册:
```python
# tasks/__init__.py
from .base import BaseTask, register_task, list_task_types, get_task_class
from .generic import task # 触发 @register_task(当前唯一任务类型)
```
前端「新建任务」的任务类型下拉来自 `GET /api/task_types`。新增类型要重启 Web 才生效。
> **未知任务类型的行为**:调度时打 error 返回;`POST /api/jobs/<id>/run` 直接返回明确错误;启动加载时会把库里残留的已删类型任务**列进日志告警**(只告警,不自动删用户数据)。
### 1.2 TaskJob — 任务计划
| 字段 | 说明 |
|------|------|
| `task_type` | 任务类型(不传默认 `generic_steps`) |
| `target` | `{"mode":"all"\|"group"\|"serial", "group_name":…, "serial":…}`,默认 `{"mode":"all"}` |
| `params` | 任务参数,与 `DEFAULT_PARAMS` **深合并**;含两个隐藏开关:`skip_offline`(默认 **true**,serial/group 模式先跳过离线设备)、`preempt`(默认 **false**,是否抢占正在跑别的任务的设备) |
| `schedule` | `{"mode":"once"\|"cron"\|"cron_stop", "cron":…, "stop_cron":…, "window":{…}}` |
| `retry` | `{"max_attempts":1, "delay":60}` |
| `enabled` | 是否参与调度 |
> **后端不做 params 校验**:只校验 `name` 非空 + `task_type` 已注册,其余原样盲存——**步骤编辑器是参数正确性的唯一关卡**,保存前务必用编辑器的 validate / "测试此步骤" 自校验。
### 1.3 Worker — 单设备执行
每台设备一个 `BaseWorker` 线程(`core/device_worker.py`)。基类已封装:设备获取(IP:5555 直连 / USB 走远程 adb server,`try/finally` 保证释放)、u2 连接(30s 超时)、状态上报、异常分类(设备离线不重试)、`self.stopped()` 停止信号、心跳看门狗(120s)。子类只实现 `run_task(d)`。
可用的基类能力:
| 方法 | 用途 |
|------|------|
| `self.set_action(text)` | 上报"当前动作"(同时刷新心跳) |
| `self.set_progress(done=, total=, unit=, action_counts=, elapsed=)` | 上报进度(前端统一渲染进度条 + 徽章) |
| `self.heartbeat()` | 长循环内手动刷新心跳 |
| `self.stopped()` / `self.is_time_up()` | 检查停止信号 / 任务时长上限 |
| `self.serial` / `self.params` | 当前设备 / 合并后的参数 |
| `self._start_timer()` / `self.elapsed()` | 计时 |
---
## 2. 通用步骤任务 generic_steps
`task_type="generic_steps"`(`tasks/generic/task.py`):把任意 App 操作编排成"步骤链"。前端**步骤编辑器**拖拽节点 → 存为 `params.steps`(JSON 数组)→ worker 按顺序执行。
```json
{
"max_duration": 0,
"steps": [
{"id": "step_1", "type": "open_app", "label": "打开抖音",
"params": {"package": "com.ss.android.ugc.aweme", "wait_home": true, "home_feature": "拍摄"}}
]
}
```
- **任务级参数**:`DEFAULT_PARAMS = {"max_duration": 0}`(0 = 不限时)
- **没有默认步骤**:`steps` 只能由编辑器产出;为空时 worker 立即报错「通用步骤任务没有可执行步骤:请在「任务」页编辑该任务并添加步骤」(设备「最近错误」可见),**不会静默空跑**
- **顶层 steps 只顺序执行一次**——需要重复的动作必须显式放进 `loop` 步骤
- **每步 schema**:`{id, type, label, params}`;`id` 由前端生成保证唯一(导出为自定义动作时会剥掉 id)
**进度上报**:`done` = 累计执行的**非容器**步骤数(`loop`/`group`/`if_el` 不计入,避免噪音)、`total=0`、`unit="操作"`,前端显示"已执行 N 次操作"。
**选择器健康跟踪**:某选择器连续未命中达阈值(10 次)会记 `last_warning`("选择器连续 N 次未命中"),提示 App 改版导致选择器失效。
---
## 3. 21 种步骤全表
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
| # | type | 名称 | 必填 params | 可选 params(默认) | 子步骤 | 语义要点 |
|---|------|------|------------|--------------------|--------|---------|
| 1 | `open_app` | 打开 App | `package` | `wait_home`(False)、`home_feature`("") | — | `app_start(package, wait=True)`;`wait_home=true` 时以 `descriptionContains=home_feature` 作为"到首页"判定,超时只提示不失败。**缺 `package` 只告警跳过** |
| 2 | `stop_app` | 结束 App | `package` | — | — | `app_stop`(`am force-stop`,下次打开是冷启动) |
| 3 | `screen_on` | 亮屏 | — | — | — | `d.unlock()`(息屏时唤醒并滑动解锁) |
| 4 | `screen_off` | 息屏 | — | — | — | `d.screen_off()` |
| 5 | `keep_screen` | 保持亮屏 | `mode`("on") | — | — | `mode="off"` → 恢复自动息屏;否则**把系统息屏超时顶到最大**(`screen_off_timeout=2147483647`)+ `svc power stayon true` + 唤醒一次。⚠️ 只发 `svc power stayon true` 对**没插充电器**的设备(走 WiFi 的那些)**完全无效**——它管的是"充电时屏幕常亮",2026-09-14 就是因此被反馈"保持亮屏没用" |
| 6 | `key_event` | 按键 | `key`("back") | — | — | `d.press(key)`;**值直传,无白名单校验** |
| 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | **默认拟人**:弧线轨迹 + 起止点/幅度/时长抖动 + 本设备手速偏好(见 §8.4);`humanize:false` 还原老的「正中直线」;`jitter:0` 时位置/幅度不抖(时长仍随机);**非法 direction 静默不滑** |
| 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True)、`duration_min`(0.25)、`duration_max`(0.50)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | 每次滑动都重新生成轨迹(同 §8.4);每轮滑动后随机停 0.35~0.75s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 |
| 9 | `click` | 点击元素 | `selector_type`、`selector_value` | `wait_timeout`(2) | — | xpath 用 `wait(timeout)`,其它选择器用 `exists(timeout)`;`wait_timeout=0` 为立即判断。**空选择器只告警** |
| 10 | `click_xy` | 点击坐标 | — | `x`(50)、`y`(50) | — | **屏幕百分比**(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,**最脆的方式** |
| 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` |
| 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) |
| 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True) | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;**只负责输入,不负责定位输入框**(要先 click 输入框) |
| 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走 ClipInject 通道(绕开 Android 10+ 后台写剪贴板限制);`paste=true` 时再触发一次粘贴 |
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0)、`vary_pace`(False) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 `vary_pace` 再按**本设备节奏**缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开) |
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
| 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group |
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False)、`cmp_op`("")、`cmp_value`("") | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**;填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含) |
| 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 |
| 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) |
| 21 | `gesture` | 录制手势 | `points`(录出来的) | `speed`(1.0) | — | **纯录制回放**:把录下的轨迹点列 `[[x,y,t_ms],…]` 按原路径与时长交给设备插值,不做弧线/抖动/手速加工。与「滑动」是两套东西,见 §3.2 |
### 3.2 录制手势:**纯录制、纯回放**(`core/gesture.py`)
「滑动」和「录制手势」是**两套东西**,别混:
| | `swipe` 步骤 | `gesture` 步骤 |
|---|---|---|
| 存什么 | 方向 + 幅度 + 时长区间 | **完整轨迹点列** `[[x,y,t_ms], …]` |
| 回放时 | 每次重新生成(弧线/抖动/本设备手速) | **照录制的路径与时长**,不做任何修饰 |
| 适合 | 通用滑动,换设备也能用 | 你亲手划的、要求一模一样的手势 |
**为什么录制的不能走滑动那套**:滑动是"参数化"的——它按方向+幅度重新生成轨迹并叠
拟人抖动,录下来的路径就被丢掉了。要"完全按我划的重放",就只能存点列、回放点列。
**两个录制来源**(都在「录制手势」步骤的卡片上):
| 来源 | 做法 | 特点 |
|---|---|---|
| ● 手机上录 | 点开始后**用手指在真机上划**,后端读 `getevent` 抓真触屏 | 最真实:录的是人手的原始轨迹。合成注入不会出现在真触屏节点上(实测),所以录到的只有人手 |
| ● 网页上录 | 在弹窗画面里按住鼠标拖,前端按 8ms/3px 采样 | 不用碰手机,但有鼠标的机械感 |
要点与坑:
- **录制前必须唤醒设备**:息屏时 u2 抓 UI 树会从 3 秒退化到 **65 秒**(实测),截图还是黑的。
前端在选好设备后会自动调一次亮屏(`/api/device/screen_all`)。
- **回放是一次调用**(`d.swipe_points(points, duration)`),不是逐点注入:实测这台设备
**单次触摸注入 RPC ≈190ms**,逐点回放 20 个点要 3.8 秒,比录的手势慢一个数量级。
所以只能让设备自己插值——"时间"靠**点密度**还原(手指慢的地方采样点更密)。
- 回放点数**抽稀到 40 个**(`MAX_REPLAY_POINTS`):这台设备每个点开销大,40 是画质/耗时折中;
首尾点一定保留。
- 轨迹是**屏幕绝对像素**:换分辨率不同的设备可能偏,同型号/同分辨率最稳。
- 点列存在步骤 JSON 里(一条 0.5s 的手势约 25 点 / 400 字节),不新建表。
### 3.1 步骤默认值与动作录制(任务 → 动作配置)
**步骤默认值**:新建步骤时预填的参数(`core/step_defaults.py`,存 `app_meta.step_defaults`)。
在「任务 → 动作配置」页改,**只影响之后新建的步骤**,已有步骤里的参数不动。
覆盖范围:`swipe`(方向/时长区间/幅度/抖动/拟人)、`click`(等待超时)、
`long_click`(长按时长/超时)、`wait`(时长区间)、`key_event`(按键)、
`input_text`(模式/候选文案/是否清空)。
三条实现口径:
- **只存与出厂值不同的字段**——以后调出厂默认时,没配过的字段能跟着更新;
- 字段**白名单 + 范围夹取**(`SPEC`):越界值夹到边界、非法值直接丢弃,
不会把手改 JSON 塞进来的脏值带进任务;
- 前端 `_makeStep` 用 `STEP_LIB` 出厂值打底,再用这里的默认值覆盖,
`/api/step_defaults` 在打开「任务」Tab 时加载一次。
**动作录制**(`static/admin/recorder.js`):在设备画面上点/划/按键/输入,
自动翻译成步骤。两个入口:
| 入口 | 模式 | 产出 |
|---|---|---|
| 动作配置页「● 录制动作」 | `action` | 录完存成**自定义动作**(进动作库,拖进任何任务画布) |
| 步骤编辑器里滑动那一步的「● 录制手势」 | `gesture` | 只取最后一个手势,**回填当前滑动步骤**的方向/幅度/时长 |
翻译规则:
| 你做的 | 记成 | 依据 |
|---|---|---|
| 点一下 | `click`(选择器) | 从当前元素树里取**包含该点的最小可点击元素**,用它的 `suggested` 选择器 |
| 点一下(没命中元素) | `click_xy` | 退化成屏幕百分比坐标 |
| 按住划一下 | `swipe` | 方向(dx/dy 谁大取谁)、幅度(划距 ÷ 屏高/宽)、时长(你按住的毫秒数) |
| 停顿(间隔 ≥1s) | `wait` | 真实间隔 ±15%,可在录制器里关掉「记录停顿」 |
| 按键 / 输入 | `key_event` / `input_text` | 输入文字按**原样**记 `fixed_text`(不是随机候选) |
实现要点(改之前先看):
- 画面与元素树走 **`/api/uiauto/snapshot` 一次取齐**(截图+元素背靠背取,避免"框落在旧位置");
**每次操作后自动刷新**,所以下一次点击能用到最新的树。
- 录制器**只负责翻译**,真正执行仍走 `/api/screen/{tap,swipe,key,text}`——和设备大屏同一套接口。
- 录到的步骤在列表里可上移/下移/删除;存成动作走现有 `POST /api/custom_actions`(不新增表)。
---
## 4. 容器步骤与公共参数
### 4.1 `loop` 三种模式
| `loop_mode` | 行为 |
|-------------|------|
| `rounds`(默认) | 跑 `max_iterations` 轮 |
| `forever` | 一直循环,直到任务被外部停止或达到 `max_duration`(配合调度"定时启动+停止"或任务时长上限使用) |
| `time` | 跑满 `loop_duration` 秒(`<=0` 时**跳过**) |
> 未知 `loop_mode` 会**按 `rounds` 处理**(不报错)。
### 4.2 `if_el` 条件判断
- 先做判断:命中 → 执行 `then` 分支;未命中 → 执行 `else` 分支(分支都可继续嵌套)
- **`selector_type` 还支持两类非元素条件**(只有条件判断有,别的带选择器的步骤没有):
- `screen` —— 屏幕状态,`selector_value` 填 `off` / `on`(也认 `熄屏`/`灭屏`):读 `dumpsys power` 的 `mWakefulness`;
- `foreground` —— 前台是不是某个包名,`selector_value` 填包名(如 `com.ss.android.ugc.aweme`)。
- 这两类**不参与「选择器健康」统计**(它们不是选择器,不该攒出「连续未命中」告警),也不吃 `timeout`(只看当前状态,不等待)
- `selector_type="ocr"` 时改用 **OCR**:截屏 → `find_on_screen(img, selector_value)` 子串匹配;命中且 `ocr_click=true` 会点击命中位置。
- OCR 模式下 **`timeout` 不生效**(单次截图即判定)
- OCR 不可用(未装 `rapidocr_onnxruntime`)时该步返回 `None`(不判定)
- 命中长文本时点击位置按关键词在文本中的比例估算(避免点到整块中心偏离)
#### 文本比对(`cmp_op` / `cmp_value`,可选)——"这台登录的是不是那个号"
光判断"元素在不在"不够用。填了 `cmp_op` 就变成:**先按选择器取到元素的文本,再拿它和
`cmp_value` 比,比对结果才是命中与否**。
`cmp_op` 取值:`等于` / `不等于` / `包含` / `不包含`(`==`/`!=`/`contains` 这类别名也认)。
不填 = 老行为(只看元素在不在)。
例子(抓元素拿到的是 `android.widget.TextView text:抖音号:35377983067 id:com.ss.android.ugc.aweme:id/506`):
```json
{"type": "if_el", "params": {
"selector_type": "resourceId", "selector_value": "com.ss.android.ugc.aweme:id/506",
"cmp_op": "包含", "cmp_value": "35377983067", "timeout": 3,
"then": [{"type": "stop_self", "params": {"reason": "登录的不是目标抖音号"}}],
"else": []}}
```
要判断"**不是**这个号"就用 `不等于`(`cmp_op=不等于` 时"界面上是别的号"会命中,正好用来抓
"号被换/掉登录")。
**两条必须知道的规则**(都为了不误判):
| 情况 | 结果 | 为什么 |
|---|---|---|
| 元素**没找到** | 一律**未命中**(走 `else`),日志写"未找到目标,无法比对" | "没读到"绝不能被当成"和我设的不一样"——否则界面还没加载出来就误判成"账号被换了" |
| 元素在、但文本为空 | 按**空文本**参与比对(空 `不等于` 任何非空值 → 命中) | 用于抓"栏位是空的"(如未登录)。日志会打出实际读到的值,好排查 |
其它细节:
- 支持比对的类型:所有**元素类**选择器(xpath / text / resourceId / description / className…)、
**`foreground`**(比当前前台包名)、**`ocr`**(比 OCR 命中的那段文字,如 `抖音号:35377983067`)。
**`screen`(亮/熄)没有文本可比**,设了比对会被忽略并告警。
- 比对**不影响「选择器健康」统计**:统计用的仍是"元素在不在"——"找到了但值不对"是条件按预期
走了 `else`,不是选择器失效,不该攒出「连续未命中」告警。
- 读元素文本是**只读**操作(`get_text`,2 秒超时),不会点击或改动任何状态。
### 4.3 公共参数
| 参数 | 说明 |
|------|------|
| `probability` | 0-100,默认 100。小于 100 时**按概率决定本次是否执行该步**(如 30 ≈ 隔几次才触发一次);未触发会打日志"概率 X% 未触发,跳过" |
### 4.4 嵌套与容错
- **嵌套深度上限 5 层**:`loop.children` / `group.children` / `if_el.then/else` 递归超过 5 层会**整段跳过并告警**
- 每步执行前检查 `stopped()` / `is_time_up()`
- **静默跳过**:未知 `type`、缺必填参数(如 `click` 没选择器、`open_app` 没包名)只打 WARNING,**任务照常"成功"结束**。排查"任务成功了但什么都没做"时先看 `logs/task.log`
- 单个步骤内部异常被捕获并记 error,**不中断后续步骤**
---
### 4.5 公共巡检(任务级,独立于步骤画布)
任务运行期间**要全程盯着的条件**(熄屏就点亮、掉到桌面就通知、刷到广告就停),
配在任务编辑器的「公共巡检」块里,**不进步骤画布**。权威实现在 `core/patrol.py`。
| | 步骤(`params.steps`) | 公共巡检(`params.watchers`) |
|---|---|---|
| 配置位置 | 步骤画布,拖出来的 | 任务编辑器单独一块 |
| 执行方式 | 顺序执行 | **穿插**:每执行完一步、以及长等待的每个分片,看一眼"哪个巡检到点了" |
| 适合 | 一段固定流程 | 全程都要盯的守护条件 |
为什么穿插而不是另起线程:不需要并发模型,也**不会和主流程抢屏幕**(否则巡检点屏幕、
主流程同时也在点,动作互相打断)。代价是**精度受步长影响**——某一步卡 30 秒,
巡检最多晚 30 秒。
配置结构(`params.watchers` 数组,每项):
```json
{"name": "熄屏点亮", "enabled": true, "interval": 60,
"check": "screen_off",
"selector_type": "xpath", "selector_value": "",
"action": "screen_on",
"notify": true, "title": "{device} 熄屏了",
"message": "任务 {job} 在 {time} 发现 {screen},已点亮",
"cooldown": 300}
```
| 字段 | 说明 |
|------|------|
| `interval` | 检查间隔(秒,最小 5)。**首次检查在任务开始时立刻做一次**,之后按间隔 |
| `check` | `screen_off` 屏幕熄灭 / `screen_on` 屏幕亮着 / `element_exists` 元素存在 / `element_missing` 元素不存在 / `foreground_is` 前台是该App / `foreground_not` 前台不是该App |
| `selector_type`/`selector_value` | 元素类检查填选择器;`foreground_*` 填**包名**;屏幕类不用填 |
| `action` | `none` 只发通知 / `screen_on` 点亮屏幕 / `screen_off` 熄灭屏幕 / `stop_self` 停止本设备任务 |
| `notify` | 命中是否推通知(事件 `task.patrol.hit`,见 [NOTIFY.md](NOTIFY.md));`false` 就只做动作 |
| `title`/`message` | 通知标题正文,可用 `{device}` `{serial}` `{job}` `{time}` `{app}` `{screen}`。**文案在动作之前渲染**,所以写的是"发现熄屏,已点亮"而不是"发现亮屏" |
| `cooldown` | 命中后多少秒内不再重复动作/通知(默认 300)。**条件持续成立时(一直熄屏)靠它防刷屏** |
两个能直接抄的例子:
```jsonc
// 1) 熄屏就点亮(不吵人):每分钟看一眼,熄了就点亮
{"name":"熄屏点亮","interval":60,"check":"screen_off","action":"screen_on","notify":false}
// 2) 掉出抖音就通知 + 停本设备:前台不是抖音 → 通知 → 停下来等你处理
{"name":"掉出抖音","interval":60,"check":"foreground_not","selector_value":"com.ss.android.ugc.aweme",
"action":"stop_self","notify":true,"title":"{device} 掉出抖音了",
"message":"当前前台 {app},已停止本设备任务"}
```
要点与坑:
- **巡检不是步骤**:不要试图在画布上找它;它也不计入"已执行 N 次操作"的进度。
- 一次检查要查设备:屏幕 `0.3s`、前台 `0.7s`,**元素检查要 dump UI 树(慢设备上可能几秒)**。
所以 `interval` 别设太小(元素检查建议 ≥30s)。
- 巡检命中会往「日志 → 步骤明细」记一行(`step_type=patrol`,只记命中不记"没事发生")。
- 巡检内部异常只记日志、**绝不影响任务主流程**(同 `notifier.notify` 的口径)。
---
## 5. 选择器与元素定位
### 5.1 `selector_type` 支持的值
| 值 | 匹配语义 | 谁支持 |
|----|---------|--------|
| `xpath` | `d.xpath(value)`(存在性判断必须用 `wait()`) | 所有带选择器的步骤 |
| `description` | `content-desc` **精确**匹配 | 同上 |
| `descriptionContains` | `content-desc` **模糊**匹配(适配文案版本差异) | 同上 |
| `text` | 文本**精确**匹配 | 同上 |
| `resourceId` | `resource-id` 精确匹配(要带包名前缀 `com.xxx:id/…`) | 同上 |
| `className` | 类名匹配(如 `android.widget.EditText`) | 同上 |
| `ocr` | 截屏 OCR 文字子串匹配 | **仅 `if_el`** |
> 选择器值是**直接透传**给 uiautomator2 的 kwarg,**没有服务端校验**——写错了只会"找不到元素"。
### 5.2 定位优先级(重要)
```
① 目标有可见文字 → 用 text / descriptionContains(最稳)
② 有稳定 resource-id → 用 resourceId 或 xpath
③ 文案会变但有结构 → 用 descriptionContains 或结构 XPath
④ 纯图形、树里找不到 → 才用 click_xy(坐标,最脆)
```
**坐标是最脆的**:分辨率/布局一变就失配,且抓取时的坐标在其他设备上未必有效。抓取元素请在**任务实际运行的那台设备的同一界面**上做。
### 5.3 XPath 序号语义(踩过的坑)
| 写法 | 含义 |
|------|------|
| `//*[@resource-id="x"][2]` | 「在**其父节点**中排第 2 的匹配」——**不是**"第 2 个匹配" |
| `(//*[@resource-id="x"])[2]` | 「第 2 个匹配」← **抓取器生成的形式** |
历史实现写了前者,导致同 id 多实例(如底部导航 4 个 tab)时 `[2..n]` 全部失配。现在:
- 执行器 `tasks/generic/task.py` 的 `_norm_legacy_xpath` 会**自动纠正**旧任务里的 `//*[@x][k]` 写法(仅前缀,类名/位置步进不受影响)
- 抓取器生成 `(…)[k]` 前**先做语义消歧**(见 §5.4),序号型只是最后的退路
⚠️ **序号型选择器仍然脆弱**:它依赖"抓取那一刻该属性有 ≥k 个实例"。界面不同(如 App 还在闪屏页)就会失配——优先选带**文字**的元素,抓取器会自动产出语义选择器。
### 5.4 抓取器给的建议选择器
`GET /api/uiauto/elements`、`GET /api/uiauto/snapshot` 返回的元素都带 `suggested`,生成优先级:
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` 自动吸附到可点元素中心),返回吸附结果并刷新截图——确认位置是否可达
- **「✓ 测选择器」**:用**将填入的选择器**真跑一次 click(`POST /api/steps/test`)→ 返回 `命中 / 未找到 / 已执行`——确认回填的选择器在真实界面能命中
- **「测试此步骤」**:在编辑器里对任意步骤做真机试执行(不占用设备池、不影响运行中的任务)
### 5.6 u2 辅助函数(`core/u2_helper.py`)
| 函数 | 用途 |
|------|------|
| `ensure_app_running(d, package, max_restart=3, home_check=None)` | 确保 App 在前台,必要时重启 |
| `wait_for_app_home(d, package, home_check, timeout=40)` | 等 App 首页就绪。**注意:主页 Activity 名可能是 `SplashActivity`,不能靠 Activity 名判断,要用界面特征元素** |
| `random_sleep(min_s, max_s)` | 随机间隔(模拟人类) |
| `safe_click(el, timeout=1)` / `safe_set_text(el, text, timeout=1)` | 存在才点/才输,不抛异常 |
| `find_and_click(d, timeout=1, **selectors)` | 找元素并点击 |
| `random_swipe_up(d, ...)` | 按比例上滑,时长可随机 |
---
## 6. 自定义动作
**自定义动作**(`custom_action` 表)把一串常用步骤打包成可复用动作,供任何 `generic_steps` 任务拖入:
- 编辑器里 **☑ 多选步骤 → 「打包选中步骤」** 即可生成
- 拖入画布时**展开为 `group` 节点**(执行语义等同顺序执行一次)
- 「任务 → 自定义动作」子分栏可查看/编辑/删除
> 想做"引用型"节点(改一处、处处生效)目前不支持,见 [backlog/TODO.md](backlog/TODO.md)。
---
## 7. 步骤编辑器(前端)
`static/admin/editor.js` 的 `_stepEditor` 单例,能力:
| 能力 | 说明 |
|------|------|
| 操作库拖拽 | 左侧分类库(interact / screen / flow),拖到画布;支持跨层级嵌套(循环套循环)、拖拽排序 |
| 参数表单 | 按步骤类型渲染对应字段(选择器行、时长、概率、循环模式等) |
| 条件分支 | `if_el` 的 then/else 两个分支区;OCR 模式下多出"命中后点击"复选框;元素/OCR/前台App 条件还多一块**文本比对**(运算符下拉 + 比对的值,见 §4.2) |
| 多选与打包 | ☑ 多选 → 打包成自定义动作 |
| 元素抓取 | 「抓取元素」打开抓取弹窗(选设备 → 截图 + 元素树 → 点选回填),见 §5.5 |
| 测试 | 「测试此步骤」真机试执行 |
| 草稿自动保存 | 编辑中的步骤会存草稿,避免误关丢失 |
> 后端 `STEP_TYPES` 与前端 `STEP_LIB` 是**两份**定义,新增/修改步骤类型必须同时改,否则会出现"编辑器里有、后端不认识"(静默跳过)或反之。
---
## 8. 新增任务类型
> 平台当前只有 `generic_steps`。**先确认是否真的需要**:绝大多数 App 操作直接用步骤编辑器编排即可,新建任务类型的成本与维护负担都不低。
### 8.1 目录结构
```
tasks/<app>/
├── __init__.py # from . import task 触发注册
├── task.py # DEFAULT_PARAMS + Worker + Task + @register_task
└── actions/ # 该任务专属操作(可选)
├── __init__.py # 先 from .base import ACTIONS,再 from . import xxx
├── base.py # ACTIONS = create_action_registry()
└── like.py # @register_action(ACTIONS) LikeAction
```
参考实现:`tasks/generic/task.py`(步骤引擎)。
### 8.2 骨架
```python
# tasks/<app>/task.py
from tasks.base import BaseTask, register_task
from core.device_worker import BaseWorker, _update_status
from core.logger import get_logger
log = get_logger("task.<app>") # 写入 logs/task.log
DEFAULT_PARAMS = {
"count": 20, # 任务自身参数放这里,不放 config.py
"max_duration": 0, # 0 = 不限时(时长上限由基类实现)
}
class MyWorker(BaseWorker):
"""单设备执行体:只实现 run_task(d),d 是已连好的 u2.Device。"""
def __init__(self, serial, params=None):
super().__init__(serial, params)
p = {**DEFAULT_PARAMS, **(params or {})}
self.count = int(p.get("count", 20))
def run_task(self, d):
self._start_timer()
self.set_progress(done=0, total=self.count, unit="次")
for i in range(self.count):
if self.stopped() or self.is_time_up(): # 必须检查,否则无法停止/超时
break
self.set_action(f"第 {i+1}/{self.count} 次") # 上报当前动作(同时刷新心跳)
# ... 业务操作(用 d 做点击/滑动/输入)...
self.set_progress(done=i + 1, total=self.count, unit="次",
action_counts={}, elapsed=self.elapsed())
@register_task
class MyTask(BaseTask):
task_type = "my_task"
name = "我的任务"
description = "…"
default_params = dict(DEFAULT_PARAMS)
@classmethod
def list_action_types(cls):
return [] # 有专属操作时返回其清单
@classmethod
def get_action_class(cls, action_type):
return None
def create_worker(self, serial, params, ctx=None):
merged = {**DEFAULT_PARAMS, **(params or {})}
return MyWorker(serial, params=merged, ctx=ctx)
```
```python
# tasks/<app>/__init__.py
from . import task # noqa: F401 触发 @register_task 注册
```
```python
# tasks/__init__.py —— 加一行
from .base import BaseTask, register_task, list_task_types, get_task_class
from .generic import task
from .myapp import task # ← 新增
```
重启 Web 后,「新建任务」的任务类型下拉会出现新类型。
### 8.3 签名约定
- `BaseWorker.__init__(self, serial, params=None, daemon=True, ctx=None)`
- `Task.create_worker(self, serial, params, ctx=None)` —— **不要带 `stf_client` / `stf` 形参**(STF 已摘除,历史签名已清理)
- `ctx` 是本次运行的上下文(`run_id` / `job_id` / `job_name` / `device_name`),由
`TaskManager._run_with_retry` 传入;**只用于结构化记录**(如步骤明细
`core/step_log.py`),不参与业务逻辑。不关心就原样透传给 `BaseWorker` 即可。
### 8.4 拟人化:滑动与设备节奏(`core/humanize.py`)
自动化最容易被识别的地方不是"慢",是**每次都一模一样**:同一坐标、同一时长、
13 台设备整齐划一地做同一个动作。`core/humanize.py` 从两个层次解决:
| 层次 | 机制 | 谁决定 |
|---|---|---|
| **每次不同** | 起点/终点/幅度/时长抖动,弧线方向随机 | 每次调用现算(`jitter` 控制幅度,0~0.4) |
| **每台设备不同** | 手速(0.82~1.32)、幅度(0.86~1.16)、弧度、常用横坐标(±9%屏宽)、停顿(0.80~1.35) | `crc32(serial)` 播种,**同设备恒定** |
用法(同步步骤里用):
```python
from core import humanize
humanize.swipe(d, "up", serial=self.serial, duration_min=0.25, duration_max=0.4,
distance_ratio=0.6, jitter=0.15, humanize=True)
humanize.pace(self.serial, 5.0, enabled=True) # wait 步骤的"按设备节奏微调"
```
要点与坑:
- **性格稳定、抖动随机**:稳定才像"不同的人",随机才不像"机器"。性格按 **serial**
而不是设备名播种——改名不改风格,换地址会换风格(地址代表"这一台")。
- 内部用**独立的 `random.Random` 实例**,不碰全局 `random`:worker 是多线程的,
全局 RNG 的取值顺序会被别的线程打乱,性格就串味了。
- **屏幕尺寸别用 `d.info`**:部分设备上一次要 **14 秒**(实测),改用
`humanize.screen_size(d, serial)`——它走 `d.window_size()`(同设备 0.6s)并缓存
120 秒。这让"加了弧线反而更快"成为可能。
- **曲线点数固定为 4**(5 个点):`d.swipe_points` 在慢设备上**每多一个点约多 1 秒**
(实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),而设备自己会在点之间插值几十步,
4 个点的贝塞尔已经足够弯。**不要为了"更平滑"把点数调大。**
- `swipe_points` 抛异常时自动退回直线 `d.swipe`(老 agent 兜底),日志会留一行 warning。
- 想关掉:步骤参数 `humanize: false`(完全还原老行为);想调轻:`jitter: 0.05`。
---
## 9. Action 框架(App 专属操作)
`core/actions/` 提供**跨任务的通用操作抽象**:`BaseAction` 接口、概率触发、注册机制;**每个 App 有自己的注册表**(`create_action_registry()`),互不污染。
```python
# tasks/<app>/actions/like.py(示例)
from core.actions import BaseAction, register_action, should_trigger
from . import ACTIONS # 本 App 的注册表
@register_action(ACTIONS)
class LikeAction(BaseAction):
action_type = "like"
name = "点赞"
default_params = {"rate": 0.3}
def execute(self, ctx):
if not should_trigger(self.params): # 按概率决定是否本次触发
return False
... # ctx 提供 device/worker/进度等
return True
```
**循环导入坑**:`actions/__init__.py` 必须**先** `from .base import ACTIONS`,**再** `from . import like`。
> 当前 `generic_steps` **不使用** Action 框架(它直接分发到 `_exec_<type>`);Action 框架留给"专属任务类型"使用。
---
## 10. 测试与排查
| 场景 | 做法 |
|------|------|
| 试执行单个步骤 | 编辑器「测试此步骤」(`POST /api/steps/test`)——真跑一次,返回 `命中 / 未找到 / 已执行` |
| 试执行整个任务 | 建一条只含该步骤的临时任务,目标选**一台**设备,`enabled=false` +「执行任务」 |
| 看任务日志 | `logs/task.log`(业务)、`logs/core.log`(调度/adb) |
| 看设备错误 | 监控页「最近错误」列;**失败原因会带真实错误**(不再只有"重试N次失败") |
| 步骤没生效 | 检查是否有 WARNING:未知 type / 缺必填参数**只告警不报错** |
| 选择器失效 | 看设备 `last_warning`("选择器连续 N 次未命中")→ 重新抓取 |
**没有步骤的任务**:执行时会明确报错,不会静默空跑。
---
## 11. 开发检查清单
新增/修改任务与步骤时,逐条打勾:
- [ ] 后端 `STEP_TYPES` 与前端 `STEP_LIB` 一致(type 名、字段、默认值、分类)
- [ ] 新步骤在 `_exec_<type>` 里实现了,且缺参数时有明确日志
- [ ] 长循环里调用了 `self.stopped()` / `self.is_time_up()` / `self.heartbeat()`
- [ ] 用文字/id 定位,没有滥用坐标
- [ ] 抓元素是在**目标任务运行的那台设备**上、停在**同一界面**时做的
- [ ] 新增任务类型已注册进 `tasks/__init__.py`
- [ ] 文档同步:[TASK_DEV.md](TASK_DEV.md)(步骤表)、[API.md](API.md)(若接口有变)、必要时 [AI_CONSOLE.md](AI_CONSOLE.md)(AI 沉淀动作的白名单)
- [ ] 临时测试数据已清理(任务/分组/自定义动作)