# 任务与步骤开发(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. 24 种步骤全表](#3-24-种步骤全表)(含 [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//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, "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`): ```json {"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` 数组,每项): ```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` 的口径)。 ### 4.6 去重:同一个号不要做两次(跨设备幂等) **场景**(真实需求):一台手机登录多个账号、一共好几台手机,每个任务只让**其中一个目标号**做事 (如评论)。每天跑一次,但不知道什么时候跑完 → 会反复重跑 → **同一个号被做两次、有的号还没做**。 平台为此提供一张**「已做过」账本**(表 `done_mark`,见 [DATA_MODEL.md](DATA_MODEL.md) §2.9), **所有设备共享一份判断**:任何一台设备做过了,其余设备的检查都会命中。 | 组件 | 放哪 | 作用 | |---|---|---| | 条件判断,`选择器类型 = 去重` | 动作**之前** | 命中 = 这个身份在这个任务里做过了 → 走 `then` 分支 | | 步骤「记为已做」 | 动作**成功之后** | 把身份值记进账本 | ```json {"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](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//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 标签名一律是 ``**,`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// ├── __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//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.") # 写入 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//__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//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_`);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_` 里实现了,且缺参数时有明确日志 - [ ] 长循环里调用了 `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 沉淀动作的白名单) - [ ] 临时测试数据已清理(任务/分组/自定义动作)