Files
auto_control/doc/TASK_DEV.md
T
butubb a800d051ae feat(发布计划): 标题行两种写法都认 + 行首 # 不再无条件当注释
一、标题行解析(core/video_plan.parse_title_line)
- 原来只认「标题内容_手机号_日期_编号」(右锚定)。现在**两种都认**:
   ① 标题内容_手机号_日期_编号        (推荐,右锚定 —— 标题里带下划线也不会错配)
   ② 手机号_日期_编号_标题            (左锚定,跟视频文件名同序 = "文件名去掉扩展名 + 标题")
  两种里都**先试带编号的**,保证同一行永远只有一种解释;编号都可省略。
- 新拒收一条:`手机号_日期_1`(只有编号、没标题)**直接拒** ——
  不能把它当标题"1"(静默生成一条标题是"1"的文案,比拒收危险得多);
  标题真是纯数字的用写法①。

二、`#` 开头的行(parse_titles_text)
- 原来是"以 `#` 开头就整行忽略" → **`#中秋快乐_...` 这种正常标题会被静默丢掉**。
- 改成**先按标题行解析,解析得出就当标题;解析不出且以 `#` 开头才算注释**。
  `# 这是注释` 照样忽略,`#话题` 开头的标题照收(`#` 保留在标题里)。

三、其它
- 文案同步:上传标题面板/帮助文案、doc/API.md 的 upload_titles 语义(两种写法、`#` 规则、拒收条件)
- 测试:新增 8 条(两种写法 × 带/不带编号 × 标题含下划线与 #话题、只有编号要拒收、整段注释与报错)
2026-09-29 09:00:43 +08:00

58 KiB
Raw Blame History

任务与步骤开发(TASK_DEV)

适用读者:写任务、编排步骤、加步骤类型的开发者。 相关文档:ARCHITECTURE.md §5(调度链路)、API.md §5(任务接口)、AI_CONSOLE.md(AI 也会产步骤)。 现状:平台只有 generic_steps 一种任务类型,绝大多数 App 操作直接用步骤编辑器编排即可。


目录


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_duration 0 = 不限时;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. 24 种步骤全表

参数与默认值以 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)、text_source("manual")、release_topics("") — mode="fixed" 用 fixed_text,否则从 texts 按行随机选一条;text_source=release_title(或 release_title_topics)时取本机当前那条发布计划的标题(配 release_topics 自动加话题),并回读输入框校验(不校验就可能发出一篇空文案);只负责输入,不负责定位输入框(要先 click 输入框)
14 clipboard 剪贴板注入 text paste(True) — 走设备端 Agent 通道(透明 Activity,绕开 Android 10+ 后台写剪贴板限制);老设备上只有独立 ClipInject 时自动兜底(见 core/clipboard_helper.py);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("")、cmp_source("")、cmp_group("")、ident_type/ident_value(去重身份) then / else 见 §4.2;条件类型除元素/OCR 外还支持 屏幕状态、前台App、去重(见 §4.6);填 cmp_op 则改成比元素的文本(等于/不等于/包含/不包含,多值任一命中),候选值还可以从**「账号」台账取(cmp_source = device/all/group),或取本机要发的那条发布计划**的抖音号/昵称(cmp_source = release)
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
23 push_release 推送发布视频 date_source(today/fixed)、fixed_date account(device/phone)、account_phone、album_dir(/sdcard/DCIM/Camera)、to_clipboard(True)、retry_failed(True)、max_same_run(1) — 取本机某天的「发布计划」→ 只把素材推到手机(+ 触发相册刷新 + 标题写进剪贴板)。不发布 —— 抖音流程你自己在后面画。幂等靠原子占位(多设备/重跑不会重复推)。见 §4.7
24 mark_release 标记发布结果 result(published/failed/unknown)、why capture_link(True)、delete_phone(True)、album_dir — 放在你自己发布流程的末尾:把"发出去了没有"回写到计划行(成功时顺手抓作品分享链接、删手机上的素材)。拿不准就填 unknown —— 平台不会自动重发它。见 §4.7

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 这类别名也认)。 不填 = 老行为(只看元素在不在)。

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 秒超时),不会点击或改动任何状态。

候选值从「账号」台账取号(cmp_source / cmp_group,可选)

几十个号时手写 cmp_value 很累、而且换号要回来改。cmp_source 让候选值直接来自 「账号」页的台账:

cmp_source 取哪些号
"" / manual(默认) 只用手填的 cmp_value —— 行为与以前完全一致
device 本机台账:这台设备在台账里登记的抖音号(靠 serial→设备名 匹配)
all 全部台账
group 某个设备分组内所有设备的号(分组名填在 cmp_group)
release 本机当前发布计划要发的那个号:优先本次运行里「推送发布视频」刚取到的那条;还没有就看下一条待发的(只看不占位)。候选值是抖音号 + 账号名称两个(任一命中即可)

release:发布前先确认"登的是要发的那个号"(一台手机登好几个号时必配)

一台手机可能登着好几个号,发布前必须确认当前抖音登的就是这条计划要发的号 —— 就在发布流程最前面放一个条件判断(cmp_source=release + 包含),命中了才走发布, 没命中走 else(发通知/跳过)。这样"发错号"变成"这条跳过",而不是把 A 号的视频发到 B 号上。

  • 比对的元素原文:抖音「我」页面上的那一行(如 //*[contains(@text,'抖音号')])—— cmp_source=release 给的候选值包含抖音号,所以 包含 就能命中。

  • 命中判据是候选值里任一被元素原文包含;两个候选值(抖音号、昵称)是刻意的: 有的界面只显示昵称。

  • 本机没有待发计划 → 候选为空 → 这一步永远走 else(日志会写明"本机没有待发的发布计划"), 不会把不该发的号发出去。

  • 与手填值是合并(OR),不是二选一:台账里的号 + 你补的一个号,任一命中即可。 更重要的是 —— 台账取不到号时手填值仍然生效,台账没维护好不会把任务直接打哑。

  • 取到 0 个号时日志会写明原因(如"设备名对不上"): 候选为空会让这一步永远走 else 分支,而任务本身照样"跑完了"。 编辑器保存时也会为此报警告,请到「账号」页核对设备号。

  • 比对运算符仍要选:台账里是纯号(35377983067),元素原文是 抖音号:35377983067 → 用 包含。(要整段一模一样才用 等于。)

⚠ 台账的纯号只能当"比对用的候选值",绝不能拿去填「去重」的身份元素(ident_value): 去重身份存的是元素原文、逐字算 key(core/dedup.py 的 build_key), 格式不一致(35377983067 vs 抖音号:35377983067)会让检查与记账算出两个不同的 key → 去重静默失效(重复评论,而且日志看不出问题)。 去重身份继续用「抓取元素」抓到的那个账号元素即可。

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": {}}      // 身份留空 = 自动跟随上面检查用的身份
  ]}}

身份值(去重的"谁")= 身份元素读出来的文本(上例=抖音号)。

身份只需在检查侧配一次:

位置 身份元素 结果
条件判断(去重) 填账号元素 检查用这个身份
记为已做 留空(推荐) 自动跟随上面检查刚解析出的身份 —— 检查与记账必然用同一个 key
记为已做 也填了 用自己填的;与检查侧不一致时编辑器会告警
记为已做 留空 + 任务里没有去重检查 退回设备 serial 当身份("一号一机"场景)

⚠ 为什么把「留空 = 自动跟随」做成默认:检查与记账算的是同一个 key, 两处各配一遍、一边填了一边忘了 → 两个 key → 去重静默失效(最难查的那种 bug, 现象是"去重没生效、还是重复做")。编辑器保存时会告警「两处身份不一致」 「有去重检查但没有记为已做」「有记为已做但没有去重检查」。

有效期(去重多久算"新的一轮")在任务级配一次:dedup_reset = day(默认,每天一次) / all(只做一次)/ hours(每 dedup_hours 小时)。任务编辑器顶部有「去重有效期」下拉。

四条必须知道的语义:

情况 行为 为什么
身份元素读不到值 不去重,当"没做过"照常执行(日志告警) 读不到若当成空身份,所有设备会共用一个 key,第一台记账后其余全被误跳过——宁可重复一次,也不能漏做
身份值过长 同上,不去重 截断会让两个身份撞成同一个 key(同样是误跳过)
「记为已做」放在动作失败路径上 不会执行 拆成"检查在前、记账在后"就是为了失败不记账、下次重试
dedup_reset='all' 记录永不被清理 清了就等于"只做一次"失效(清理任务只删 day/hours 桶)

看进度 / 让它重跑:「任务 → 去重记录」页能看到"谁做过了、今天做了几个号", 可以删单条(那个号/那台设备重跑)或清空整个任务。这一页的价值就是让你不必靠"一直重跑"来确认。

第二种办法(兜底,不用账本):进 App 的评论区后用 条件判断 + 文本比对(包含),候选值填本账号的评论标识(昵称/「我」标签),命中就跳过。 它的局限要说清楚,别指望它万能:

  • 依赖 App 的评论区结构,改版就失效
  • 自己的评论常常要滑动加载才出现在元素树里(可能要滑几次、慢)
  • 多台设备并发时可能同时判"没评过"(没有原子性)——所以账本才是主拦,这个只做兜底

4.7 发布计划:平台推素材,发布流程你自己写(push_release + mark_release)

数据与界面见 DATA_MODEL.md §2.11 与「账号 → 发布计划」页。

分工(这是刻意的设计):平台只管"素材到手机 + 状态记账",抖音里怎么发由你画 —— 因为发布流程跟账号、版本、界面都在变,写死在平台里改不动。

[条件判断 cmp_source=release]  →  [推送发布视频]  →  [你自己写的发布步骤…]  →  [标记发布结果]
 当前登的是要发的号吗?             push_release        打开抖音→点+→相册→选视频       mark_release
 不是 → else:发通知(本条跳过)                        →下一步→粘贴标题→发布          result=published 放成功分支
                                                                                 result=failed 放失败分支

先校验账号再发(一台手机登好几个号时必配):放在最前面的条件判断用 cmp_source=release(候选值 = 本机要发的那条计划的抖音号/账号名称,见 §4.2), 元素原文取抖音「我」页面上的 //*[contains(@text,'抖音号')],运算符 包含; 没命中就走 else —— 宁可不发,也不发错号。

「推送发布视频」做六件事(tasks/generic/publish_flow.py 的 push_one()):

① 取本机账号(core.ledger.resolve_device —— "本机是哪台"只在那处定义,别写第二套)
② 取计划(**队列口径**):本机账号名下 `发布日期 <= 目标日期`(过期会补发、未来的不发)、
   `status ∈ (ready, failed)`(**done/skipped 永不入队**)、`attempts < 3` 的行,
   按 `(发布日期, 编号)` 升序取最早的 —— 即"按顺序发没发过的"
③ **原子占位**:UPDATE … SET status='pushing' WHERE id=? AND status IN ('ready','failed')
   ← **幂等的唯一判据**(多设备/任务重跑不会把同一条推两遍)
④ adb push → {album_dir}/{计划id}.mp4(默认 /sdcard/DCIM/Camera)→ 校验**大小**(按 ls -l 的大小列比)
⑤ **touch 一下**把手机上这个文件的 mtime 改成"现在" + 清掉同一计划在旧目录里的副本
⑥ 触发媒体扫描 → **按路径**校验它真的进了相册索引 → 把标题写进手机剪贴板(供「粘贴」用)

为什么是 DCIM/Camera、为什么要 touch(都是实测踩出来的):

  • 相册(含抖音的选视频页)读的是 MediaStore 索引:adb push 只放文件、不进索引, 推完必须触发扫描,否则"推送成功但相册里没有"。
  • MIUI 会把自建子目录(DCIM/rp)归到「其他相册」,抖音的选视频页列不顺 —— 所以放官方相机目录。
  • 放 Camera 的代价:用户自己拍的视频也在这个目录里 → "相册里第一个 = 刚推的那个"靠排序成立。 而 adb push 保留的是本地文件的修改时间(推一个 3 天前上传的素材 → 排在很后面, "点第一个"就点到别的视频了)→ 推完必须 touch,把 mtime 改成现在。
  • 同一个 plan id 在老目录里可能还留着副本(换目录后的残留)→ 推送时顺手按精确路径清掉, 并重扫让相册索引把那行去掉;不清的话相册里同一个视频出现两三份,点第一个可能点到死文件。
  • 校验必须比路径,不能只比文件名:MediaStore 的 _data 返回的是 /storage/emulated/0/dcim/rp/...(目录部分被小写),而 _indexed_paths() 归一化后再比; 只比文件名会被"老副本在索引里"骗过去,判成"新文件已就绪"。
  • 校验结果落 video_plan.push_verify(ok / no_index / nofile)+ push_remote(手机上的绝对路径), 界面上标「已推送·相册可见 / 已推送·相册未见」——文件推上去了 ≠ 相册里点得到它, 这两件事必须分开显示。

⚠ uiautomator2 3.x 的 d.shell() 返回 ShellResponse(tuple 子类),不是 str: 直接 'abc' in resp(元组成员判断,恒 False)或 resp.strip()(没这方法)都会得到错误结果。 实测踩过:推送的"文件大小校验"永远不通过(每次推送都被记成失败,而文件其实推上去了)、 "有没有进相册"永远报没进。取输出统一走 publish_flow._sh(d, cmd)。

远程名用计划 id(唯一可识别)。老默认目录 Movies/rp、DCIM/rp 里的历史副本会被 drop_old_copies() 清掉。

**「标记发布结果」**放在你的流程末尾:

result 什么时候用 落库
published 确认发出去了 done(顺手抓作品分享链接存进计划行、删手机上的素材)
failed 确认没发出去 failed(可重试:下次任务会再推一次)
unknown 拿不准 unknown(绝不自动重发,计划页标橙,人工裁决)

阶段与状态的对应(core/video_plan.STAGE_STATUS,本设计最要紧的一张表):

阶段 含义 落库 能不能自动重试
push 推文件到手机失败(还没碰抖音) failed ✅ 安全
scan 媒体扫描 failed ✅ 同上
manual 已推到手机,后面归你/你的人管 — —
post / verify 推送之后出的岔子(你标记失败/未知) unknown ❌ 可能已经发出去了

⚠ failed 与 unknown 必须分开:把"不知道自己发没发"当成"知道自己没发", 就是重复发布的来源。每日 04:41 的清理 job 会把卡住的占位按阶段降级 (manual 阶段 → unknown,不会降成"可重试")—— 没有它,一条计划崩一次就永远卡住。

几条硬约束(都是踩过的坑):

  • 只删平台自己推的文件({计划id}.mp4 精确路径)。绝不 rm 通配 —— 会删用户自己拍的东西。
  • 一次只推一个视频(max_same_run=1):这样"相册里第一个 = 我要发的那个"才成立; 要一次发多条,就重复"推送 + 你的发布步骤 + 标记"这一组。
  • 你的发布步骤里,文案要用「粘贴」(推送时标题已进剪贴板);如果非要用「输入文字」, 记得后面加一步回读校验(读输入框的 text)—— 抖音的 EditText 有时不吃 send_keys, 不校验就可能发出一篇空文案而每一步都显示成功。
  • 不靠 done_mark 判重:去重账本的 identity 是元素原文,与 (手机号,日期,编号) 两套 key 语义不同,混用会静默失效。
  • 任务状态 ≠ 发布结果:_exec_steps 不检查步骤结果,标记失败不会让任务失败 —— 「这次发布的真相」以计划行的 status 为准(计划页看得到),并会推 task.video.published / task.video.failed 通知。

「发布计划」页顶部还有一块「发布任务」(GET/POST /api/video_plan/tasks): 上面一组是发布任务(步骤里含「推送发布视频」或「标记发布结果」,含嵌套在条件/循环里的), 下面「其它任务」折叠着全部通用步骤任务 —— 都能就地编辑。 列全部是刻意的:手写的抖音发布流程(自己点相册→输入框→发布,没有平台的 push_release 步骤) 如果被筛掉,人打开这页只会看到"一键新建",会以为"这页没有能改的地方"; 这类任务行上会标「缺平台推送步骤」并给一个「插上平台步骤」按钮 (POST /api/video_plan/tasks/<id>/adopt:推送放最前、标记放最后、你原来的步骤一步不动)。 还支持一键新建标准发布任务 —— 骨架 15 步已经排好:

⓪ 亮屏  ① 打开抖音(等「首页」出来)  ② 点击「我」(desc 含"我")  ③ 等待 3~5s
④ 条件判断  //*[contains(@text,'抖音号')] 包含 cmp_source=release
   ├─ then(登的就是要发的号)⑤ 推送发布视频 ⑥ 点击「+」(desc 含"拍摄")
   │        ⑦ 相册 ⑧ 第一个视频 ⑨ 下一步 ⑩ 输入框 ⑪ 输入文字(text_source=release_title)
   │        ⑫ 点击「发布」 ⑬ 标记发布结果
   └─ else(不是这个号 / 今天没有待发计划)⑭ 发通知"本条跳过"

⚠ ⓪ 亮屏 + ① 等「首页」是踩出来的,别删:真机实测(2026-09-28,A01)—— 屏幕没亮就启动抖音,它会永远停在启动页(mCurrentFocus=splash.SplashActivity、UI 树是空的), 于是"点击我"必然 miss、"等抖音号出现"必然超时,最后报成一句误导人的"账号不符"。 屏幕亮着时冷启动 5 秒就出底部栏。

建好之后就在「发布计划」这一块点「编辑」就地改(名字/目标/每天几点/启停/逐步参数, 选择器旁边就有「抓取元素」),把 5 个空选择器(相册/第一个视频/下一步/输入框/发布)抓一次就能跑 —— 要加步骤、循环、自定义动作这些更复杂的编排,才走「打开通用编辑器」(跳任务页)—— 抖音那几步故意不写死选择器:混淆 id / 结构 xpath 改版就废, 抓一次的成本远低于每次发版都改平台代码。②⑥ 两条预填的是按描述定位 (descriptionContains,跨版本稳),一般不用改。

「输入文字」自动用标题:text_source=release_title 会取本机当前那条计划的标题 (push_release 同一次运行里传递;断档了就取最近一条"已推送到手机"的), release_title_topics 还能自动补话题;取计划标题时会回读输入框校验, 不通过就改走剪贴板通道再验一次 —— 抖音的 EditText 有时不吃键盘输入, 不校验的后果是"发出一篇空文案,而每一步都显示成功"。

抓分享链接(mark_release 的 capture_link=True,或计划页人工点「标记已发布」时): 进「我 → 作品」第一条 → 分享 → 复制链接 → 读剪贴板 → 正则抠出 v.douyin.com 链接。 这一段仍是抖音 UI 流程(按文字定位),抖音改版会失效 —— 失效时链接空着, 计划页标"缺链接"、素材文件不删(链接与素材至少留一个),不影响"已发布"这个结论。

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。


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.821.32)、幅度(0.861.16)、弧度、常用横坐标(±9%屏宽)、停顿(0.80~1.35) 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 沉淀动作的白名单)
  • 临时测试数据已清理(任务/分组/自定义动作)