用户场景(他原话):一台手机登录 5 个抖音号、一共 5 台手机,每个任务只让其中一个
目标号评论;每天跑一次但不知道什么时候跑完,于是"一直重复跑" → 结果
"一个手机还没评论到,一个手机都评论两次了"。
**根因不是"单设备重复",是跨设备没有共享的判断 + 进度不可见。** 所以做两件事:
① 幂等;② 把"谁做过了、还差谁"摆到台面上(不然只能靠重跑确认,而重跑又在制造重复)。
- `core/models.py`:新表 `done_mark`(迁移账本补 v7)。**判据只有 `scope_key` 的
唯一索引**——多台设备会同时判断"没做过","先查后插"有竞态(两台都插),
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才原子。
- `core/dedup.py`(新):`build_key`(`任务|身份|时间桶`)/ `check` / `mark` /
`list_marks`(带"今天做了几台/几个号"统计)/ `delete_mark` / `clear_job` / `purge_old`。
自建 app context(照 device_pool 的 `_ctx()`),任务线程/Web/清理都不用关心。
- 任务侧两个部件(**检查在前、记账在后**):
· `if_el` 新增条件类型 `selector_type="dedup"`:命中=这个身份做过了 → 走 then 分支。
身份元素在 `ident_type`/`ident_value`(留空 = 用设备 serial,一号一机场景)。
· 新步骤 `mark_done`「记为已做」(22 种步骤):放动作**成功之后**。
拆两步的用意:动作失败就不记账,下次重跑还会重试该设备 —— 失败不丢。
- 有效期(`dedup_reset` = day/all/hours)放**任务级**:检查与记账两处各填一份的话,
填不一致就算出两个 key、去重会**静默失效**,所以强制只配一处(编辑器顶部下拉)。
- 三条防误伤规则(都有测试兜着):
· 身份读不到 / 身份值过长 → **不去重、当没做过照常执行**。绝不能把"读不到"
当成空身份——那会让所有设备共用一个 key、第一台记账后其余全被误判成"做过"。
· `kind='all'`(只做一次)的记录**永不清理**(清了等于语义失效);清理只删 day/hours。
· 去重的两个易错点在保存时直接告警:身份元素两边不一致、有检查没记账/有记账没检查。
- 「任务 → 去重记录」新子分栏(`static/admin/dedup.js`):统计行 + 明细表 +
删单条(那个号重跑)/ 清空任务(整批重跑)。接口 3 个(GET/delete/clear,PERM_TASKS)。
- 每日 04:23 清理(挂现有 APScheduler),`TABLE_LABELS` 补中文名(备份覆盖自动派生)。
- AI 建任务草稿校验同步:`dedup` 走自己的规则(要 ident_value、xpath 前缀校验),
没填身份元素只警告不拦(用设备当身份是合法用法);普通条件空选择器仍然拦。
- 文档:TASK_DEV §4.6(去重专章 + App 内检测的兜底配方与它的三个局限)、
DATA_MODEL §2.9、API 三个接口、ARCHITECTURE(分层/装配/子分栏/JS 分工/清理)、
DEPLOY §5.2(15 张表)、步骤数 21→22 全库同步。
自测:单元 + 集成 33 项(**含 8 线程抢同一个身份、恰好一个成功**的原子性断言,
以及"all 记录不被清理""身份读不到不去重""清了能重跑")、
**真机端到端**(cs1 上"检查→动作→记账"跑两遍:第二遍被拦、换 serial 的"另一台设备"
同样被拦、删记录后能重跑)、草稿校验 5 项、GET 冒烟 56 路由 0 个 500。
(注:本分支基于 feat/if-el-multi-value,因为它俩都要改 task.py 的 STEP_TYPES 与
editor.js 的 STEP_LIB 同一区域,分开从 dev 拉必然冲突——这份是超集,合一次两份都进。)
43 KiB
任务与步骤开发(TASK_DEV)
适用读者:写任务、编排步骤、加步骤类型的开发者。 相关文档:ARCHITECTURE.md §5(调度链路)、API.md §5(任务接口)、AI_CONSOLE.md(AI 也会产步骤)。 现状:平台只有
generic_steps一种任务类型,绝大多数 App 操作直接用步骤编辑器编排即可。
目录
- 1. 核心概念
- 2. 通用步骤任务 generic_steps
- 3. 22 种步骤全表(含 3.1 步骤默认值与动作录制)
- 4. 容器步骤与公共参数(含 4.5 公共巡检)
- 5. 选择器与元素定位
- 6. 自定义动作
- 7. 步骤编辑器(前端)
- 8. 新增任务类型
- 9. Action 框架(App 专属操作)
- 10. 测试与排查
- 11. 开发检查清单
1. 核心概念
1.1 TaskType — 任务类型
一个 TaskType = Task 子类 + Worker 子类 + DEFAULT_PARAMS,用 @register_task 注册进全局 _TASK_TYPES(key 为 task_type 字符串)。
# 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 各任务包子包触发注册:
# 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 按顺序执行。
{
"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, "dedup_reset": "day", "dedup_hours": 6}(max_duration0 = 不限时;dedup_reset是去重有效期,见 §4.6) - 没有默认步骤:
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. 22 种步骤全表
参数与默认值以
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("")、ident_type/ident_value(去重身份) |
then / else |
见 §4.2;条件类型除元素/OCR 外还支持 屏幕状态、前台App、去重(见 §4.6);填 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 |
| 22 | mark_done |
记为已做 | —(身份元素可选填 selector_type/selector_value) |
— | — | 把身份值记进去重账本(跨设备共享)。放动作成功之后——失败不记账,下次重跑会重试。留空 = 用设备 serial 当身份。配套「条件判断 → 去重」用,见 §4.6 |
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(不判定) - 命中长文本时点击位置按关键词在文本中的比例估算(避免点到整块中心偏离)
- OCR 模式下
文本比对(cmp_op / cmp_value,可选)——"这台登录的是不是那个号"
光判断"元素在不在"不够用。填了 cmp_op 就变成:先按选择器取到元素的文本,再拿它和
cmp_value 比,比对结果才是命中与否。
cmp_op 取值:等于 / 不等于 / 包含 / 不包含(==/!=/contains 这类别名也认)。
不填 = 老行为(只看元素在不在)。
cmp_value 可以填多个值(换行或 | 分隔,一行一个):多个之间是 OR——
"这几个号都算我的",任一命中即命中;否定式(不等于/不包含)则是"一个都不许出现"。
⚠ 逗号不是分隔符("抖音号:123,456" 这种文本本身带逗号,拆了就永远匹配不上)。
例子(抓元素拿到的是 android.widget.TextView text:抖音号:35377983067 id:com.ss.android.ugc.aweme:id/506):
{"type": "if_el", "params": {
"selector_type": "resourceId", "selector_value": "com.ss.android.ugc.aweme:id/506",
"cmp_op": "包含",
"cmp_value": "35377983067\n35377983068", // 多个号,任一命中即可
"timeout": 3,
"then": [{"type": "stop_self", "params": {"reason": "登录的不是我的号"}}],
"else": []}}
要判断"不是我的号"就用 不等于("界面上是别的号"会命中,正好用来抓"号被换/掉登录")。
日志会写清"采集到什么、几个候选、命中的是哪个值",例如:
采集到 '抖音号:35377983068' 包含 2 个候选值 → 符合,命中『35377983068』。
两条必须知道的规则(都为了不误判):
| 情况 | 结果 | 为什么 |
|---|---|---|
| 元素没找到 | 一律未命中(走 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 数组,每项):
{"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);false 就只做动作 |
title/message |
通知标题正文,可用 {device} {serial} {job} {time} {app} {screen}。文案在动作之前渲染,所以写的是"发现熄屏,已点亮"而不是"发现亮屏" |
cooldown |
命中后多少秒内不再重复动作/通知(默认 300)。条件持续成立时(一直熄屏)靠它防刷屏 |
两个能直接抄的例子:
// 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的口径)。
4.6 去重:同一个号不要做两次(跨设备幂等)
场景(真实需求):一台手机登录多个账号、一共好几台手机,每个任务只让其中一个目标号做事 (如评论)。每天跑一次,但不知道什么时候跑完 → 会反复重跑 → 同一个号被做两次、有的号还没做。
平台为此提供一张**「已做过」账本**(表 done_mark,见 DATA_MODEL.md §2.9),
所有设备共享一份判断:任何一台设备做过了,其余设备的检查都会命中。
| 组件 | 放哪 | 作用 |
|---|---|---|
条件判断,选择器类型 = 去重 |
动作之前 | 命中 = 这个身份在这个任务里做过了 → 走 then 分支 |
| 步骤「记为已做」 | 动作成功之后 | 把身份值记进账本 |
{"type": "if_el", "params": {
"selector_type": "dedup",
"ident_type": "resourceId", "ident_value": "com.ss.android.ugc.aweme:id/506",
"then": [{"type": "stop_self", "params": {"reason": "这个号今天已经评过了"}}],
"else": [
{"type": "click", "params": {"selector_type": "text", "selector_value": "发送"}},
{"type": "mark_done", "params": {"selector_type": "resourceId",
"selector_value": "com.ss.android.ugc.aweme:id/506"}}
]}}
身份值(去重的"谁")= 身份元素读出来的文本(上例=抖音号)。
留空则该步骤用设备 serial 当身份(适合"一号一机")。
有效期(去重多久算"新的一轮")在任务级配一次:dedup_reset = day(默认,每天一次)
/ all(只做一次)/ hours(每 dedup_hours 小时)。任务编辑器顶部有「去重有效期」下拉。
⚠ 为什么有效期和身份元素都强制"只配一处":检查与记账两边算的是同一个 key, 一边填得不一样就会算出两个 key → 去重静默失效(最难查的那种 bug)。 编辑器保存时会直接告警:「身份元素不一致」「有去重检查但没有记为已做」等。
四条必须知道的语义:
| 情况 | 行为 | 为什么 |
|---|---|---|
| 身份元素读不到值 | 不去重,当"没做过"照常执行(日志告警) | 读不到若当成空身份,所有设备会共用一个 key,第一台记账后其余全被误跳过——宁可重复一次,也不能漏做 |
| 身份值过长 | 同上,不去重 | 截断会让两个身份撞成同一个 key(同样是误跳过) |
| 「记为已做」放在动作失败路径上 | 不会执行 | 拆成"检查在前、记账在后"就是为了失败不记账、下次重试 |
dedup_reset='all' |
记录永不被清理 | 清了就等于"只做一次"失效(清理任务只删 day/hours 桶) |
看进度 / 让它重跑:「任务 → 去重记录」页能看到"谁做过了、今天做了几个号", 可以删单条(那个号/那台设备重跑)或清空整个任务。这一页的价值就是让你不必靠"一直重跑"来确认。
第二种办法(兜底,不用账本):进 App 的评论区后用 条件判断 + 文本比对(包含),候选值填本账号的评论标识(昵称/「我」标签),命中就跳过。 它的局限要说清楚,别指望它万能:
- 依赖 App 的评论区结构,改版就失效
- 自己的评论常常要滑动加载才出现在元素树里(可能要滑几次、慢)
- 多台设备并发时可能同时判"没评过"(没有原子性)——所以账本才是主拦,这个只做兜底
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,生成优先级:
- 有
resource-id→//*[@resource-id="v"];无 id 但有text/content-desc同理 - 该属性值全树有多个时,先用第二个属性把目标单独圈出来(语义选择器,标
semantic:true+via)://*[@resource-id="v" and @text="我"]。抖音底部导航同 id 的几个 tab 靠这个解决—— 灰度版 tab 数量会变(4 个 ↔ 3 个),序号必然错位,文字不会 - 两个属性组合仍分不开(列表里同 id 同文字)→ 才退回序号
(…)[k](标indexed:true+occ/total,前端打黄标 ⚠ 序号) - 都没有属性、但有"最近的有属性祖先" →
{祖先}/*[@class="android.widget.ImageView"][n] - 连祖先都没有 →
//hierarchy/*[i]/*[j](按子节点位置逐层,标broad:true,脆弱、前端会提示) - 类名也是空 → 标
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。
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 骨架
# 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)
# tasks/<app>/__init__.py
from . import task # noqa: F401 触发 @register_task 注册
# 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 |
crc32(serial) 播种,同设备恒定 |
用法(同步步骤里用):
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()),互不污染。
# 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(步骤表)、API.md(若接口有变)、必要时 AI_CONSOLE.md(AI 沉淀动作的白名单)
- 临时测试数据已清理(任务/分组/自定义动作)