feat(去重): 跨设备「已做过」账本 —— 同一个号不会做两次 + 「谁做过了」看得见
用户场景(他原话):一台手机登录 5 个抖音号、一共 5 台手机,每个任务只让其中一个
目标号评论;每天跑一次但不知道什么时候跑完,于是"一直重复跑" → 结果
"一个手机还没评论到,一个手机都评论两次了"。
**根因不是"单设备重复",是跨设备没有共享的判断 + 进度不可见。** 所以做两件事:
① 幂等;② 把"谁做过了、还差谁"摆到台面上(不然只能靠重跑确认,而重跑又在制造重复)。
- `core/models.py`:新表 `done_mark`(迁移账本补 v7)。**判据只有 `scope_key` 的
唯一索引**——多台设备会同时判断"没做过","先查后插"有竞态(两台都插),
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才原子。
- `core/dedup.py`(新):`build_key`(`任务|身份|时间桶`)/ `check` / `mark` /
`list_marks`(带"今天做了几台/几个号"统计)/ `delete_mark` / `clear_job` / `purge_old`。
自建 app context(照 device_pool 的 `_ctx()`),任务线程/Web/清理都不用关心。
- 任务侧两个部件(**检查在前、记账在后**):
· `if_el` 新增条件类型 `selector_type="dedup"`:命中=这个身份做过了 → 走 then 分支。
身份元素在 `ident_type`/`ident_value`(留空 = 用设备 serial,一号一机场景)。
· 新步骤 `mark_done`「记为已做」(22 种步骤):放动作**成功之后**。
拆两步的用意:动作失败就不记账,下次重跑还会重试该设备 —— 失败不丢。
- 有效期(`dedup_reset` = day/all/hours)放**任务级**:检查与记账两处各填一份的话,
填不一致就算出两个 key、去重会**静默失效**,所以强制只配一处(编辑器顶部下拉)。
- 三条防误伤规则(都有测试兜着):
· 身份读不到 / 身份值过长 → **不去重、当没做过照常执行**。绝不能把"读不到"
当成空身份——那会让所有设备共用一个 key、第一台记账后其余全被误判成"做过"。
· `kind='all'`(只做一次)的记录**永不清理**(清了等于语义失效);清理只删 day/hours。
· 去重的两个易错点在保存时直接告警:身份元素两边不一致、有检查没记账/有记账没检查。
- 「任务 → 去重记录」新子分栏(`static/admin/dedup.js`):统计行 + 明细表 +
删单条(那个号重跑)/ 清空任务(整批重跑)。接口 3 个(GET/delete/clear,PERM_TASKS)。
- 每日 04:23 清理(挂现有 APScheduler),`TABLE_LABELS` 补中文名(备份覆盖自动派生)。
- AI 建任务草稿校验同步:`dedup` 走自己的规则(要 ident_value、xpath 前缀校验),
没填身份元素只警告不拦(用设备当身份是合法用法);普通条件空选择器仍然拦。
- 文档:TASK_DEV §4.6(去重专章 + App 内检测的兜底配方与它的三个局限)、
DATA_MODEL §2.9、API 三个接口、ARCHITECTURE(分层/装配/子分栏/JS 分工/清理)、
DEPLOY §5.2(15 张表)、步骤数 21→22 全库同步。
自测:单元 + 集成 33 项(**含 8 线程抢同一个身份、恰好一个成功**的原子性断言,
以及"all 记录不被清理""身份读不到不去重""清了能重跑")、
**真机端到端**(cs1 上"检查→动作→记账"跑两遍:第二遍被拦、换 serial 的"另一台设备"
同样被拦、删记录后能重跑)、草稿校验 5 项、GET 冒烟 56 路由 0 个 500。
(注:本分支基于 feat/if-el-multi-value,因为它俩都要改 task.py 的 STEP_TYPES 与
editor.js 的 STEP_LIB 同一区域,分开从 dev 拉必然冲突——这份是超集,合一次两份都进。)
This commit is contained in:
+5
-1
@@ -184,7 +184,11 @@ mcp_agent.Agent.run_stream(prompt, serial, history, on_delta, on_tool, on_usage,
|
||||
从本轮**成功**的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束:
|
||||
|
||||
- **禁坐标**:带 `click_xy` 的步骤不会被沉淀(坐标换个设备/分辨率就失效)
|
||||
- 白名单步骤类型(21 种去掉 `click_xy`、`keep_screen`、`gesture`)
|
||||
- 白名单步骤类型(**显式列举 16 种**,见 `web/agent_api.py` 的 `_ACTION_STEP_TYPES`):
|
||||
`open_app` / `stop_app` / `screen_on` / `screen_off` / `key_event` / `swipe` /
|
||||
`swipe_until` / `click` / `long_click` / `wait_el` / `input_text` / `clipboard` /
|
||||
`wait` / `loop` / `group` / `if_el`——坐标类(`click_xy`)与流程标记类
|
||||
(`keep_screen` / `gesture` / `notify` / `stop_self` / `mark_done`)都不沉淀
|
||||
- 每类型有必填参数校验(如 `click` 必须有选择器)
|
||||
- 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步
|
||||
- 保存时服务端**再校验一次**,含坐标的提交直接 400
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
|
||||
平台已有两套能力,但互不相通:
|
||||
- **AI 控制台**:一句话 + 选设备 → 多模态 Agent(DeepSeek)通过 20 个 `de_*` 工具在手机上「边看边做」(截图看屏、`de_ui_tree` 拿元素树、`de_tap_element/de_tap_text` 语义点按),流式回放步骤。
|
||||
- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…21 种节点),在编辑器里拖拽编排、单步试跑、定时调度。
|
||||
- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…22 种节点),在编辑器里拖拽编排、单步试跑、定时调度。
|
||||
|
||||
目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。
|
||||
|
||||
|
||||
+14
@@ -134,6 +134,9 @@
|
||||
| GET | `/api/uiauto/snapshot` | D | **一次取齐**截图 + 元素树(原生 u2,抓取弹窗用) |
|
||||
| GET | `/api/uiauto/screenshot` | D | uiautodev 截图(JPEG) |
|
||||
| POST | `/api/steps/test` | D | 真机试执行单个步骤 |
|
||||
| GET | `/api/done_marks?job=&limit=` | L | 去重记录列表 + 统计(`{marks, stats{total,devices,identities,today_devices,today_identities}}`) |
|
||||
| POST | `/api/done_marks/delete` | T | 删一条去重记录(`{id}`)→ 该设备/身份下次会重新执行 |
|
||||
| POST | `/api/done_marks/clear` | T | 清空某任务的全部去重记录(`{job}`)→ 整批重跑 |
|
||||
|
||||
### 2.4 admin(`web/admin_api.py`)
|
||||
|
||||
@@ -468,6 +471,17 @@
|
||||
|
||||
`steps` 的 schema 与 `generic_steps` 的 `params.steps` 完全一致,见 [TASK_DEV.md](TASK_DEV.md)。
|
||||
|
||||
### 去重记录(「任务 → 去重记录」页)
|
||||
|
||||
| 接口 | 请求 | 响应要点 |
|
||||
|------|------|---------|
|
||||
| `GET /api/done_marks` | `?job=<任务ID>&limit=200` | `{ok, marks:[{id,scope_key,kind,job_id,job_name,serial,device_name,identity,created_at}], stats:{total,devices,identities,today_devices,today_identities}}`。`job` 省略 = 全部任务 |
|
||||
| `POST /api/done_marks/delete` | `{"id"}` | 删一条 → 该设备/身份下次会重新执行;不存在 → 404;缺 id → 400 |
|
||||
| `POST /api/done_marks/clear` | `{"job"}` | 清空该任务的全部记录(整批重跑)→ `{ok,msg,deleted}`;缺 job → 400 |
|
||||
|
||||
账本表与语义见 [DATA_MODEL.md](DATA_MODEL.md) §2.9;任务侧怎么用见
|
||||
[TASK_DEV.md](TASK_DEV.md) §4.6。
|
||||
|
||||
---
|
||||
|
||||
## 6. 设备池与自动发现
|
||||
|
||||
+6
-5
@@ -26,7 +26,7 @@
|
||||
┌───────────────────────────▼──────────────────────────────────────────┐
|
||||
│ 领域层 task_manager(调度) device_worker(执行) device_pool(池) │
|
||||
│ system_backup(备份) apk_manager(应用) device_discovery │
|
||||
│ device_battery(电量采集 + 低电量告警) │
|
||||
│ device_battery(电量采集 + 低电量告警) dedup(去重账本) │
|
||||
└───────────────────────────┬──────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼──────────────────────────────────────────┐
|
||||
@@ -80,13 +80,13 @@
|
||||
|------|------|---------|
|
||||
| **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` |
|
||||
| **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 |
|
||||
| **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) |
|
||||
| **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`dedup.init_app`(**去重账本绑 app**,供任务线程/Web/清理自推 context);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) |
|
||||
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
|
||||
|
||||
| 阶段 | 位置 | 做了什么 |
|
||||
|------|------|---------|
|
||||
| **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) |
|
||||
| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`,自建 app context);失败仅 warning |
|
||||
| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`)、`hour=4, minute=23` 挂去重记录清理(`_purge_done_mark`,只清 `day`/`hours` 桶)——两个清理任务都自建 app context;失败仅 warning |
|
||||
| **G** | `__main__` | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_purge_step_log_async()`(后台清理超期步骤明细)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `notifier.shutdown()` + `step_log.shutdown()` + `mgr.shutdown()` + `device_discovery.shutdown()` + `device_battery.shutdown()` + 停 uiautodev |
|
||||
|
||||
> ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。
|
||||
@@ -270,7 +270,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
|
||||
|
||||
### 6.1 单页应用
|
||||
|
||||
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 7 个 Tab 面板 + 10 个模态框容器 + 15 个 `<script src>`
|
||||
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 7 个 Tab 面板 + 10 个模态框容器 + 16 个 `<script src>`
|
||||
- 独立页面:`login.html`(登录)、`wall.html`(监控大屏,**完全自包含**,自带 CSS/JS,不加载 `static/admin/*.js`)
|
||||
- 服务端内联页:`GET /locate`(设备端定位大字页,免登录)
|
||||
- 响应头强制 `no-store`,避免后台改版后浏览器拿旧页面
|
||||
@@ -280,7 +280,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
|
||||
| 顶级 Tab | `data-tab` | 权限 | 子分栏 |
|
||||
|---------|-----------|------|--------|
|
||||
| 监控 | `monitor` | 登录即可 | — |
|
||||
| 任务 | `tasks` | 登录即可(写操作需 `tasks`) | `plan` 任务计划 / `actions` 自定义动作 |
|
||||
| 任务 | `tasks` | 登录即可(写操作需 `tasks`) | `plan` 任务计划 / `actions` 自定义动作 / `actioncfg` 动作配置 / `dedup` 去重记录 |
|
||||
| 日志 | `logs` | `logs` | — |
|
||||
| 用户 | `users` | `admin` | — |
|
||||
| 工具 | `tools` | `admin` | `clipboard` / `adb` / `ts` / `apks` / `appver` / `devapps` / `devpool` / `groups` |
|
||||
@@ -300,6 +300,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
|
||||
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) |
|
||||
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
|
||||
| `tools.js` | 工具 Tab:剪贴板、adb 终端、Tailscale、设备池、自动发现、远程看屏、**电量监控配置**(阈值/间隔/立即采集) | 电量表单只回填一次,10s 轮询不冲掉用户正在输的值 |
|
||||
| `dedup.js` | 任务 Tab·**去重记录**:账本列表 + 统计("今天做了几个号")+ 删单条/清空任务 | 数据来自 `done_mark` 表,见 [TASK_DEV.md](TASK_DEV.md) §4.6 |
|
||||
| `apps.js` | 应用管理:APK 上传/安装/删除、设备已装应用 | |
|
||||
| `admin.js` | 分组、日志、用户 + **全局初始化入口**(末尾 `initCsrf(); loadMe(); showTab('monitor')`) | |
|
||||
| `agent.js` | AI 控制台·聊天:会话、SSE 流、Markdown / 推理链 / token 渲染、实时画面、经验库 / 动作库 | |
|
||||
|
||||
@@ -172,6 +172,31 @@
|
||||
写入走 `core/step_log.py` 的**专用写线程 + 有界队列**(任务线程只 `put_nowait`,
|
||||
微秒级;队列满丢弃并计数)——步骤执行是热路径,绝不能在任务线程里同步写库。
|
||||
|
||||
### 2.9 `done_mark` — 去重账本(跨设备"已做过")
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| `id` | Integer PK | |
|
||||
| `scope_key` | String(300) **UNIQUE** | **幂等的全部依据**:`任务ID \| 身份值 \| 时间桶` |
|
||||
| `kind` | String(12) | `day` / `hours` / `all`(有效期策略,任务级 `dedup_reset`) |
|
||||
| `job_id` | String(32) idx | 哪个任务 |
|
||||
| `job_name` | String(120) | |
|
||||
| `serial` / `device_name` | String | 哪台设备(界面上显示"谁做过了") |
|
||||
| `identity` | String(200) | 身份值(如抖音号 `35377983067`) |
|
||||
| `created_at` | String(20) idx | |
|
||||
|
||||
索引:`job_id`、`created_at`、`(job_id, created_at)`。
|
||||
|
||||
**为什么靠唯一索引**:多台设备会同时判断"没做过","先查后插"有竞态(两台都插);
|
||||
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才是原子的。
|
||||
实现在 `core/dedup.py`(`check` / `mark` / `list_marks` / `delete_mark` / `clear_job` / `purge_old`)。
|
||||
|
||||
**保留期**:每天 04:23 清理过保留期(`core/dedup.KEEP_DAYS`,默认 180 天)的记录,
|
||||
但**只清 `day`/`hours` 桶**——`kind='all'`("只做一次")清了就等于去重失效,永不清理。
|
||||
量级很小(设备数 × 天数),单条 DELETE 足够,不需要像步骤明细那样分批。
|
||||
|
||||
⚠️ 这张表**自动进整库备份**(§6 派生规则);接任务步骤见 [TASK_DEV.md](TASK_DEV.md) §4.6。
|
||||
|
||||
---
|
||||
|
||||
## 3. 非模型表
|
||||
|
||||
+5
-3
@@ -212,14 +212,16 @@ tail -20 logs/web.log # 无 ERROR/Traceback
|
||||
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
|
||||
```
|
||||
|
||||
当前 14 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` /
|
||||
当前 15 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` /
|
||||
`apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` /
|
||||
`experience_audit` / `agent_action` / `device_install_log` / `task_step_log`。
|
||||
`experience_audit` / `agent_action` / `device_install_log` / `task_step_log` / `done_mark`。
|
||||
完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。
|
||||
|
||||
> ⚠️ `task_step_log`(任务步骤明细)是**唯一会持续增长**的表:它按 `KEEP_DAYS`
|
||||
> ⚠️ `task_step_log`(任务步骤明细)是会持续增长的表:它按 `KEEP_DAYS`
|
||||
> (默认 14 天,见 `core/step_log.py`)自动清理,但备份包里会带上保留期内的全部行。
|
||||
> 设备多、任务密时导出 zip 会明显变大——需要更小的包就调小那个常量。
|
||||
> `done_mark`(去重账本)也会增长,但量级是"设备数 × 天数",可以忽略;它同样有保留期
|
||||
> (默认 180 天,且 `kind='all'` 的记录**永不清理**)。
|
||||
|
||||
> **新增一张 ORM 表,就自动进了覆盖清单**,不可能再漏(2026-09-10 动作库 `agent_action`
|
||||
> 曾因手工维护漏登记,数据其实在快照里,只是清单没列 → 被误判为"没有备份")。
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | **架构详解**:分层、启动装配顺序、线程与并发模型、设备生命周期、任务调度链路、状态机、关键设计决策与扩展点 | 所有开发者(先读这篇) |
|
||||
| [DATA_MODEL.md](DATA_MODEL.md) | **数据模型**:SQLite 表与字段、schema 迁移、非模型表、`app_meta` 配置键、数据目录、备份覆盖清单 | 后端开发、运维 |
|
||||
| [API.md](API.md) | **HTTP 接口全量**:按蓝图分组的路由表、鉴权、请求/响应示例、非 JSON 响应、错误分支 | 前端开发、外部接入 |
|
||||
| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、21 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
|
||||
| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、22 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
|
||||
| [MCP.md](MCP.md) | **MCP 手机控制手册**:20 个 `de_*` 工具用法、写操作门控、坐标换算、接入示例 | 接入方、数字员工 |
|
||||
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
|
||||
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
|
||||
|
||||
+61
-4
@@ -10,7 +10,7 @@
|
||||
|
||||
- [1. 核心概念](#1-核心概念)
|
||||
- [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps)
|
||||
- [3. 21 种步骤全表](#3-21-种步骤全表)(含 [3.1 步骤默认值与动作录制](#31-步骤默认值与动作录制任务--动作配置))
|
||||
- [3. 22 种步骤全表](#3-22-种步骤全表)(含 [3.1 步骤默认值与动作录制](#31-步骤默认值与动作录制任务--动作配置))
|
||||
- [4. 容器步骤与公共参数](#4-容器步骤与公共参数)(含 [4.5 公共巡检](#45-公共巡检任务级独立于步骤画布))
|
||||
- [5. 选择器与元素定位](#5-选择器与元素定位)
|
||||
- [6. 自定义动作](#6-自定义动作)
|
||||
@@ -97,7 +97,8 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
}
|
||||
```
|
||||
|
||||
- **任务级参数**:`DEFAULT_PARAMS = {"max_duration": 0}`(0 = 不限时)
|
||||
- **任务级参数**:`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)
|
||||
@@ -108,7 +109,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
|
||||
---
|
||||
|
||||
## 3. 21 种步骤全表
|
||||
## 3. 22 种步骤全表
|
||||
|
||||
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
|
||||
|
||||
@@ -131,10 +132,11 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0)、`vary_pace`(False) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 `vary_pace` 再按**本设备节奏**缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开) |
|
||||
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
|
||||
| 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group |
|
||||
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False)、`cmp_op`("")、`cmp_value`("") | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**;填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含) |
|
||||
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False)、`cmp_op`("")、`cmp_value`("")、`ident_type`/`ident_value`(去重身份) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**、**去重**(见 §4.6);填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含,多值任一命中) |
|
||||
| 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 |
|
||||
| 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) |
|
||||
| 21 | `gesture` | 录制手势 | `points`(录出来的) | `speed`(1.0) | — | **纯录制回放**:把录下的轨迹点列 `[[x,y,t_ms],…]` 按原路径与时长交给设备插值,不做弧线/抖动/手速加工。与「滑动」是两套东西,见 §3.2 |
|
||||
| 22 | `mark_done` | 记为已做 | —(身份元素可选填 `selector_type`/`selector_value`) | — | — | 把身份值记进**去重账本**(跨设备共享)。放动作**成功之后**——失败不记账,下次重跑会重试。留空 = 用设备 `serial` 当身份。**配套「条件判断 → 去重」用**,见 §4.6 |
|
||||
|
||||
### 3.2 录制手势:**纯录制、纯回放**(`core/gesture.py`)
|
||||
|
||||
@@ -353,6 +355,61 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
- 巡检命中会往「日志 → 步骤明细」记一行(`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": {"selector_type": "resourceId",
|
||||
"selector_value": "com.ss.android.ugc.aweme:id/506"}}
|
||||
]}}
|
||||
```
|
||||
|
||||
**身份值**(去重的"谁")= 身份元素读出来的文本(上例=抖音号)。
|
||||
留空则该步骤用**设备 `serial`** 当身份(适合"一号一机")。
|
||||
|
||||
**有效期**(去重多久算"新的一轮")在**任务级**配一次:`dedup_reset` = `day`(默认,每天一次)
|
||||
/ `all`(只做一次)/ `hours`(每 `dedup_hours` 小时)。任务编辑器顶部有「去重有效期」下拉。
|
||||
|
||||
> ⚠ **为什么有效期和身份元素都强制"只配一处"**:检查与记账两边算的是同一个 key,
|
||||
> 一边填得不一样就会算出两个 key → **去重静默失效**(最难查的那种 bug)。
|
||||
> 编辑器保存时会直接告警:「身份元素不一致」「有去重检查但没有记为已做」等。
|
||||
|
||||
**四条必须知道的语义**:
|
||||
|
||||
| 情况 | 行为 | 为什么 |
|
||||
|---|---|---|
|
||||
| 身份元素**读不到值** | **不去重**,当"没做过"照常执行(日志告警) | 读不到若当成空身份,所有设备会共用一个 key,第一台记账后其余全被误跳过——宁可重复一次,也不能漏做 |
|
||||
| 身份值**过长** | 同上,不去重 | 截断会让两个身份撞成同一个 key(同样是误跳过) |
|
||||
| 「记为已做」放在动作失败路径上 | **不会执行** | 拆成"检查在前、记账在后"就是为了**失败不记账、下次重试** |
|
||||
| `dedup_reset='all'` | 记录**永不被清理** | 清了就等于"只做一次"失效(清理任务只删 `day`/`hours` 桶) |
|
||||
|
||||
**看进度 / 让它重跑**:「任务 → 去重记录」页能看到"谁做过了、今天做了几个号",
|
||||
可以删单条(那个号/那台设备重跑)或清空整个任务。**这一页的价值就是让你不必靠"一直重跑"来确认。**
|
||||
|
||||
**第二种办法(兜底,不用账本)**:进 App 的评论区后用
|
||||
**条件判断 + 文本比对(包含)**,候选值填本账号的评论标识(昵称/「我」标签),命中就跳过。
|
||||
它的局限要说清楚,别指望它万能:
|
||||
|
||||
- 依赖 App 的评论区结构,**改版就失效**
|
||||
- 自己的评论常常要**滑动加载**才出现在元素树里(可能要滑几次、慢)
|
||||
- 多台设备并发时可能**同时**判"没评过"(没有原子性)——所以**账本才是主拦**,这个只做兜底
|
||||
|
||||
---
|
||||
|
||||
## 5. 选择器与元素定位
|
||||
|
||||
Reference in New Issue
Block a user