feat(发布计划): 改按名字发(文件名 名字_日期_编号)+ 账号管理整页下线 + 自动切号一轮发完

发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行
`标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块
「一轮把本机待发的号跑完」自动切号,一直发到发完。

· 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因),
  删掉三处"手机号必须命中台账"的校验,加索引 ix_video_plan_name_date(启动时补建)
· 新步骤三个:读本机账号名字 / 切换账号(**按名字点控件**、切完回读校验、遇登录页停本设备)/
  循环本机待发的号(试过不回头、跑一轮没进展就换号、停止·超时·上限三个干净出口)
· 发布骨架改 18 步:读名字 → 校验账号(cmp_source=account_target)→ 推送(**推送前重读昵称核对**)
  → 抖音流程 → 标记结果;push_release 的 account 默认 name
· 账号管理整页下线:删 core/ledger.py(serial_of 迁到 core/device_pool.py)、6 个 /api/ledger*、
  「账号」页与 ledger.js、设备池「账号」列、设备端账号块;发布计划升为一级页签;
  device_account 表与 42 行历史数据保留、仍在备份清单内(**停用不等于删数据**)
· 人工入口「按名字找设备」:新增 core/account_probe.py(只读探测、跳过在跑任务的设备)
  + POST /api/video_plan/push_by_account,没设备登着的号逐条报出来;
  推送时把"实际是谁发的"写进 posted_serial / posted_device_name
· 通知:新增 task.account.switch_failed / task.account.need_login
· schema v12(posted_*)/ v13(device.cur_account_name / cur_name_at)
· 文档同步:TASK_DEV / API / DATA_MODEL / ARCHITECTURE / NOTIFY / DEVICE_AGENT / DEPLOY /
  DEVELOPMENT / README / .env.example(两个抖音控件选择器抓一次填 .env)
· 新增 scripts/tests/:6 套用例 + run_all.py(临时库 + 假设备,不碰真实数据、不连真机)
This commit is contained in:
2026-10-12 09:02:40 +08:00
parent 75bee21ffb
commit beda0f3779
37 changed files with 2615 additions and 1747 deletions
+81 -49
View File
@@ -109,7 +109,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
---
## 3. 24 种步骤全表
## 3. 27 种步骤全表
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
@@ -131,14 +131,18 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
| 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 |
| 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) |
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False)、`cmp_op`("")、`cmp_value`("")、`cmp_source`("")、`ident_type`/`ident_value`(去重身份) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**、**去重**(见 §4.6);填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含,多值任一命中),候选值还可以由平台给:**本次要发的名字**(`cmp_source=account_target`)或**本机要发的那条发布计划**的名字(`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 |
| 23 | `push_release` | 推送发布视频 | `date_source`(today/fixed)、`fixed_date` | `account`(name/phone)、`account_phone`、`album_dir`(/sdcard/DCIM/Camera)、`to_clipboard`(True)、`retry_failed`(True)、`max_same_run`(1) | — | 取**本次要发的那个名字**名下的「发布计划」→ **只把素材推到手机**(+ 触发相册刷新 + 标题写进剪贴板)。**不发布** —— 抖音流程你自己在后面画。`account=name`(默认)时**推送前会重读昵称核对**,对不上就不推。幂等靠原子占位(多设备/重跑不会重复推)。见 §4.7 |
| 24 | `mark_release` | 标记发布结果 | `result`(published/failed/unknown)、`why` | `capture_link`(True)、`delete_phone`(True)、`album_dir` | — | 放在**你自己发布流程的末尾**:把"发出去了没有"回写到计划行(成功时顺手抓作品分享链接、删手机上的素材)。拿不准就填 `unknown` —— 平台不会自动重发它。见 §4.7 |
| 25 | `read_account_name` | 读取本机账号名字 | `selector_value`(昵称控件,**待抓**) | `selector_type`(xpath)、`timeout`(8)、`on_missing`(fail) | — | 读抖音「我」页面上的**昵称** → 清洗后存进运行期变量(`account_name`),并记下用的选择器供 `push_release`/`switch_account` 核对。**读不到 = 本机没有号**:条件判断走 `else`、推送跳过(都不发)。`on_missing=fail`(默认) 让这一步直接失败、`skip` 只记一句继续。见 §4.7 |
| 26 | `switch_account` | 切换账号 | `switch_selector_value`(进「账号切换」列表那一下,**待抓**) | `target`(`var:target_name`)、`target_value`、`name_selector_*`、`me_selector_*`(descriptionContains=我)、`timeout`(8)、`verify`(True)、`max_attempts`(2)、`on_login_page`(stop) | — | **只在本机已登录过的号之间切**:当前已是目标→直接成功;否则点「我」→ 点切换入口 → **在列表里按名字找控件点击**(**禁 `[N]` 序号**:序号型会随界面变形错位)→ 等重载 → **回读校验**。列表里没这个名字 = 本机没登这个号 → 立刻 False(不重试、不算故障)。停在登录/验证码页 → 通知 + 默认停本设备(平台不做登录/滑块)。见 §4.7 |
| 27 | `foreach_account` | 循环本机待发的号 | — | `date_source`(today/fixed)、`fixed_date`、`retry_failed`(True)、`max_accounts`(0=不限) | `children` | **一轮把本机待发的号跑完**:数据源 = 计划表里还有待发内容的名字(`pending_names`,**不需要账号台账**),逐个写进运行期变量 `target_name` 后跑 children;试过的名字不回头(防死循环);内层"这个名字还有待发就一直发",跑一轮没进展(切号失败/守卫没过)就换下一个。三个干净出口:停止 / 任务时长上限 / `max_accounts`。见 §4.7 |
### 3.2 录制手势:**纯录制、纯回放**(`core/gesture.py`)
@@ -285,45 +289,38 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
走了 `else`,不是选择器失效,不该攒出「连续未命中」告警。
- 读元素文本是**只读**操作(`get_text`,2 秒超时),不会点击或改动任何状态。
#### 候选值从「账号」台账取号(`cmp_source` / `cmp_group`,可选)
#### 候选值由平台给(`cmp_source`,可选)
几十个号时手写 `cmp_value` 很累、而且换号要回来改。`cmp_source` 让候选值直接来自
「账号」页的台账:
几十个号时手写 `cmp_value` 很累、而且换号要回来改。`cmp_source` 让候选值由平台给:
| `cmp_source` | 取哪些号 |
| `cmp_source` | 候选值 |
|---|---|
| `""` / `manual`(默认) | 只用手填的 `cmp_value` —— **行为与以前完全一致** |
| `device` | **本机台账**:这台设备在台账里登记的抖音号(靠 `serial`→设备名 匹配) |
| `all` | 全部台账 |
| `group` | 某个**设备分组**内所有设备的号(分组名填在 `cmp_group`) |
| `release` | **本机当前发布计划**要发的那个号:优先本次运行里「推送发布视频」刚取到的那条;还没有就**看**下一条待发的(只看不占位)。候选值是**抖音号 + 账号名称**两个(任一命中即可) |
| `account_target` | **本次要发的那个名字**:循环块设的目标名字优先,没有就用「读取本机账号名字」刚读到的|
| `release` | **本机当前发布计划**要发的那个名字:优先本次运行里「推送发布视频」刚取到的那条;还没有就**看**下一条待发的(只看不占位) |
#### `release`:发布前先确认"登的是要发的那个号"(一台手机登好几个号时必配)
#### `account_target`:发布前先确认"登的是要发的那个号"(一台手机登好几个号时必配)
一台手机可能登着好几个号,发布前必须确认当前抖音登的就是这条计划要发的号 ——
就在发布流程**最前面**放一个条件判断(`cmp_source=release` + `包含`),命中了才走发布,
没命中走 `else`(发通知/跳过)。这样"发错号"变成"这条跳过",而不是把 A 号的视频发到 B 号上。
一台手机可能登着好几个号,发布前必须确认当前抖音登的就是**这次要发的**那个号 ——
就在发布流程**最前面**放一个条件判断(`cmp_source=account_target` + `包含`),
命中了才走发布,没命中走 `else`(发通知/跳过)。这样"发错号"变成"这条跳过",
而不是把 A 号的视频发到 B 号上。
- 比对的元素原文:抖音「我」页面上的那一行(如 `//*[contains(@text,'抖音号')]`)——
`cmp_source=release` 给的候选值包含**抖音号**,所以 `包含` 就能命中。
- 命中判据是**候选值里任一被元素原文包含**;两个候选值(抖音号、昵称)是刻意的:
有的界面只显示昵称。
- **本机没有待发计划 → 候选为空 → 这一步永远走 `else`**(日志会写明"本机没有待发的发布计划"),
不会把不该发的号发出去。
- 比对的元素原文 = **昵称控件**(和「读取本机账号名字」用**同一个选择器**,抓一次两处都填);
候选值是名字,所以用 **`包含`**。
- **读不到名字 → 候选为空 → 这一步永远走 `else`**(日志会写明"没读到本次要发的名字")。
这是故意的:**读不到名字 = 不知道现在登的是谁 = 不发**。
- ⚠ **绝不能用「不等于」**:读不到名字时候选为空,`不等于` 对空候选算**命中**,
会把"读不到"误判成"可以发"。
- **与手填值是合并(OR)**,不是二选一:平台给的候选 + 你补的值,任一命中即可
(平台的候选取不到时手填值仍然生效,不会把任务直接打哑)。
- 「推送发布视频」自己**还会再核对一次**(推送前重读昵称,见 §4.7),所以就算这个条件判断
没画,也不会发错号 —— 条件是给"要先看一眼再决定"的场景用的。
- **与手填值是合并(OR)**,不是二选一:台账里的号 + 你补的一个号,任一命中即可。
更重要的是 —— **台账取不到号时手填值仍然生效**,台账没维护好不会把任务直接打哑。
- 取到 0 个号时**日志会写明原因**(如"设备名对不上"):
候选为空会让这一步**永远走 `else` 分支**,而任务本身照样"跑完了"。
编辑器保存时也会为此报警告,请到「账号」页核对设备号。
- 比对运算符仍要选:台账里是**纯号**(`35377983067`),元素原文是
`抖音号:35377983067` → 用 **`包含`**。(要整段一模一样才用 `等于`。)
> ⚠ **台账的纯号只能当"比对用的候选值",绝不能拿去填「去重」的身份元素**(`ident_value`):
> ⚠ **比对用的候选值绝不能拿去填「去重」的身份元素**(`ident_value`):
> 去重身份存的是**元素原文**、逐字算 key(`core/dedup.py` 的 `build_key`),
> 格式不一致(`35377983067` vs `抖音号:35377983067`)会让**检查与记账算出两个不同的
> key → 去重静默失效**(重复评论,而且日志看不出问题)。
> 去重身份继续用「抓取元素」抓到的那个账号元素即可。
> 格式不一致(`小王` vs `昵称:小王`)会让**检查与记账算出两个不同的 key → 去重静默失效**
> (重复评论,而且日志看不出问题)。去重身份继续用「抓取元素」抓到的那个账号元素即可。
### 4.3 公共参数
@@ -464,35 +461,70 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
### 4.7 发布计划:平台推素材,发布流程你自己写(`push_release` + `mark_release`)
数据与界面见 [DATA_MODEL.md](DATA_MODEL.md) §2.11 与「账号 → 发布计划」页。
数据与界面见 [DATA_MODEL.md](DATA_MODEL.md) §2.11 与「发布计划」页。
**全部按「名字」走**(名字 = 上传文件名里的那个名字 = 抖音昵称):计划行认名字,
运行时读本机当前账号名字,**读不到名字就当"本机没有号"、什么都不发**。
**分工**(这是刻意的设计):**平台只管"素材到手机 + 状态记账",抖音里怎么发由你画** ——
因为发布流程跟账号、版本、界面都在变,写死在平台里改不动。
```
[条件判断 cmp_source=release] → [推送发布视频] → [你自己写的发布步骤…] → [标记发布结果]
当前登的是要发的号吗? push_release 打开抖音→点+→相册→选视频 mark_release
不是 → else:发通知(本条跳过) →下一步→粘贴标题→发布 result=published 放成功分支
result=failed 放失败分支
[读取本机账号名字] → [条件判断 account_target] → [推送发布视频] → [你的发布步骤…] → [标记发布结果]
read_account_name 登的是要发的号吗? push_release 打开抖音→点+→相册→选视频 mark_release
不是/读不到 → else:通知跳过 →下一步→填标题→填正文→发布 published 放成功分支
failed 放失败分支
```
**先校验账号再发**(一台手机登好几个号时必配):放在最前面的条件判断用
`cmp_source=release`(候选值 = 本机要发的那条计划的**抖音号/账号名称**,见 §4.2),
元素原文取抖音「我」页面上的 `//*[contains(@text,'抖音号')]`,运算符 `包含`;
没命中就走 `else` —— **宁可不发,也不发错号**。
**先读名字、再校验、最后才发**(一台手机登好几个号时必配):
- 「读取本机账号名字」:抓抖音「我」页面上的**昵称控件**(选择器待抓,抓一次即可),
清洗后的名字进运行期变量(见 §3 第 25 行)。**读不到 = 本机没有号**。
- 条件判断用 `cmp_source=account_target`(候选值 = 本次要发的名字,见 §4.2)+ `包含`;
没命中就走 `else` —— **宁可不发,也不发错号**。
- 「推送发布视频」自己**再核对一次**:推送前重读昵称,与要发的名字对不上(或读不到)→ 不推。
**自动切号(`switch_account`,一台手机多个号时用)**:
一轮任务里"切号 → 读名 → 推 → 发 → 标记"循环跑多个号(由 `foreach_account` 循环块驱动):
```
[foreach_account] ← 一轮把本机待发的号跑完(数据源 = 计划表里还有待发内容的名字)
└ [switch_account] → [read_account_name] → [if_el account_target] → [push_release] → 你的发布步骤 → [mark_release]
切到这个名字 确认切成了 登的是它吗? 取这个号的计划
```
- **只在本机已登录过的号之间切**(抖音账号列表里有的号):不做登录新号、不做滑块。
- 列表里**没有**这个名字 → 立刻返回 False(= 本机没登这个号,**不重试、不算故障**):
多台设备各自"试自己有的号",不会互相误报。
- 切完**回读校验**(`verify=True`):读回来的不是目标 → 该号这一轮**不推送**(守卫会挡下)。
- 停在登录/验证码页 → 通知 `task.account.need_login` + **默认停本设备**(见 [NOTIFY.md](NOTIFY.md))。
- ⚠ **选择器只能按名字**(`textContains`),**绝不能用 `[N]` 序号**:序号会随界面变形错位,
点错就是把内容发到别人号上([research/U2_ELEMENT_SELECTORS.md](research/U2_ELEMENT_SELECTORS.md) 有实测)。
**人工入口也一样按名字找设备**(「发布计划」页):
- 「推送到手机」:实时探测**哪台在线设备现在登着这条计划的名字**,推给它;没有设备登着 →
400 + 人话原因("先去那台设备上切号")。
- 「一键推送(今天)」:当天所有待推计划按名字分组 → 逐条推;**没设备登着的号在 `unmatched`
里逐条报出来**(见 [API.md](API.md) §`push_by_account`)。
- 探测在 `core/account_probe.py`:只读/幂等(连设备 → 抖音前台 → 点「我」→ 读昵称),
**跳过正在跑任务的设备**(不抢屏),单台失败只报那台;结果顺手缓存进 `device.cur_account_name`
(**纯缓存**,自动任务永远现场读屏幕)。
**「推送发布视频」做六件事**(`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')
(名字从哪来:「读取本机账号名字」读到的,或循环块设的目标名字,见 `_release_scope`)
② **原子占位**:UPDATE … SET status='pushing' WHERE id=? AND status IN ('ready','failed')
← **幂等的唯一判据**(多设备/任务重跑不会把同一条推两遍)
④ adb push → {album_dir}/{计划id}.mp4(默认 /sdcard/DCIM/Camera)→ 校验**大小**(按 ls -l 的大小列比)
⑤ **touch 一下**把手机上这个文件的 mtime 改成"现在" + 清掉同一计划在旧目录里的副本
⑥ 触发媒体扫描 → **按路径**校验它真的进了相册索引 → 把标题写进手机剪贴板(供「粘贴」用)
③ adb push → {album_dir}/{计划id}.mp4(默认 /sdcard/DCIM/Camera)→ 校验**大小**(按 ls -l 的大小列比)
④ **touch 一下**把手机上这个文件的 mtime 改成"现在" + 清掉同一计划在旧目录里的副本
⑤ 触发媒体扫描 → **按路径**校验它真的进了相册索引 → 把标题写进手机剪贴板(供「粘贴」用)
```
**为什么是 `DCIM/Camera`、为什么要 touch**(都是实测踩出来的):
@@ -548,7 +580,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
- **你的发布步骤里,文案要用「粘贴」**(推送时标题已进剪贴板);如果非要用「输入文字」,
记得后面加一步**回读校验**(读输入框的 text)—— 抖音的 EditText 有时不吃 `send_keys`,
不校验就可能发出一篇空文案而每一步都显示成功。
- **不靠 `done_mark` 判重**:去重账本的 identity 是元素原文,与 `(手机号,日期,编号)` 两套 key
- **不靠 `done_mark` 判重**:去重账本的 identity 是元素原文,与 `(名字,日期,编号)` 两套 key
语义不同,混用会静默失效。
- **任务状态 ≠ 发布结果**:`_exec_steps` 不检查步骤结果,**标记失败不会让任务失败** ——
「这次发布的真相」以计划行的 `status` 为准(计划页看得到),并会推