Files
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

895 lines
58 KiB
Markdown
Raw Permalink 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. 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/<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, "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/<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](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 沉淀动作的白名单)
- [ ] 临时测试数据已清理(任务/分组/自定义动作)