feat(发布计划): 标题 + 正文(标题 || 正文 _手机号_日期_编号)+ 发布流程多一步填正文;账号名称全面下线

一、正文(video_plan.body,schema v10→v11)
- 标题 txt 一行里可带正文,用 `||` 分隔(标题/正文都常带下划线,不能再用下划线分列;
  只切第一处,正文里再出现 `||` 原样保留):`标题 || 正文 _手机号_日期_编号`
  正文可省(老文件照收);两种写法(标题在前 / 手机号在前)都能带正文
- 长度:标题 ≤100 字、正文 ≤1000 字,超了**拒收不截断**
- 「待发布」判据从"有没有标题"改成"**标题或正文有没有内容**"(`_sync_content_status` 一处定义,
  改标题/改正文/批量导入三处共用),只给正文的行也照样待发
- 导入配对:同标题补一条新正文**要收下**(不能当重复跳过);已有别的标题仍然拒收(不静默覆盖)

二、发布流程多一步
- 骨架 15 → **17 步**:⑪ 输入文字(计划的标题) → ⑫ 点击正文框 → ⑬ 输入文字(计划的正文) → ⑭ 发布 → ⑮ 标记
- 「输入文字」取值来源集中到 `RELEASE_TEXT_SOURCES`:release_title(_topics) / release_body(_topics) /
  release_title_body(标题+正文,只有一个输入框的抖音版本用);`build_caption(key=…)` 支持三种来源
- 任务编辑器与发布计划就地编辑器的下拉都补齐这 6 档;待抓的空选择器 5 → 6 个(多了正文框)

三、账号名称下线(用户要求:只要抖音号)
- 发布计划:卡片 / 明细表 / 搜索 / 导出 CSV 都不再有账号名称(CSV 改为
  发布日期/设备号/手机号/抖音号/编号/标题/正文/分享链接/发布时间)
- 账号台账:列表列 / 编辑弹窗 / 粘贴导入预览 / 设备维度弹窗 / 导入说明与示例都去掉;
  **粘贴导入忽略这一列**(不报错、不落库),库里的历史值保留(不删列)
- 设备端 Agent 身份页:平台不再下发名称(传空串,**不改 APK 也能生效**,避免老版本解析失败)

四、文档 / 测试
- DATA_MODEL:body 列 + schema 11;API:upload_titles(`||` 语义)、PUT {title,body}、CSV 列、骨架 17 步
- TASK_DEV:骨架 17 步、`input_text` 六个取值来源表、正文长度上限
- ARCHITECTURE:release.js 说明(正文、不显示账号名称)
- 测试(%TEMP% 被系统清理后重建):test_plan_body.py 33 项(解析/取内容/骨架/入库/导入配对)、
  smoke_app_body.py 10 项(整机装配:页面、PUT 改正文、时间线、CSV 表头)—— 全过
This commit is contained in:
2026-10-11 16:36:56 +08:00
parent a800d051ae
commit 6ee089e023
13 changed files with 196 additions and 95 deletions
+5 -5
View File
@@ -149,7 +149,7 @@
| POST | `/api/video_plan/upload_titles` | D | 上传标题(txt 文本或文件)→ 配到已有计划行 |
| GET | `/api/video_plan/timeline` | D | 发布计划时间线(按日期分组的卡片 + 统计 + 磁盘) |
| GET | `/api/video_plan/stats` | D | 今天要发/已发/待标题/过期未发 + 素材占用与磁盘余量 |
| GET/PUT/DELETE | `/api/video_plan/<id>` | D | 单条计划:查看 / 改标题 / 删除(连素材文件) |
| GET/PUT/DELETE | `/api/video_plan/<id>` | D | 单条计划:查看 / 改标题·正文(`PUT {title,body}`)/ 删除(连素材文件) |
| GET | `/api/video_plan/<id>/video` | D | 预览平台上的素材(文件清理后 → 410) |
| POST | `/api/video_plan/<id>/push` | D | **把素材推到手机**(只推送、不发布;后台线程) |
| POST | `/api/video_plan/push_all` | D | **一键推送**:某天(默认今天)所有待发布/失败的计划 → 各自手机(每台设备一个线程、设备内串行) |
@@ -157,7 +157,7 @@
| POST | `/api/video_plan/<id>/skip\|retry\|resolve` | D | 跳过 / 重试 / 裁决「结果未知」 |
| GET | `/api/video_plan/links` | D | 已发布作品的分享链接(`format=csv` 导出,给铺评论用) |
| GET | `/api/video_plan/tasks` | T | 可编辑的任务列表(全部通用步骤任务 + `is_release` 标记:状态/调度/下次运行 + 完整步骤) |
| POST | `/api/video_plan/tasks` | T | 一键新建标准发布任务(骨架 15 步:亮屏 → 打开抖音 → 校验账号 → 推送 → 抖音点击 → 填标题 → 标记) |
| POST | `/api/video_plan/tasks` | T | 一键新建标准发布任务(骨架 17 步:亮屏 → 打开抖音 → 校验账号 → 推送 → 抖音点击 → 填标题 → 填正文 → 标记) |
| GET/PUT | `/api/video_plan/tasks/<job_id>` | T | **发布任务的就地编辑**(就在「发布计划」页改名字/目标/时间/启停/步骤) |
| POST | `/api/video_plan/tasks/<job_id>/adopt` | T | 给已有任务插上平台两步(推送放最前 / 标记放最后,自己的步骤不动) |
@@ -543,7 +543,7 @@
| 接口 | 请求 | 响应要点 |
|------|------|---------|
| `POST /api/video_plan/upload_video` | multipart `file`(单文件)+ `replace` | `{ok,name,action:add\|skip\|replace,plan,msg}`;解析/配对不通过 → **400 + `error`(人话原因)**。前端**逐文件串行**发(才能给每个文件一条进度与结果) |
| `POST /api/video_plan/upload_titles` | `{text, dry_run}` 或 multipart `file`(txt) | `{ok, counts:{total,attached,skipped,rejected}, rows:[{line,phone,date,seq,title,action,error}], msg}`;**每行两种写法都认**:`标题内容_手机号_日期_编号`(推荐,右锚定,标题里可带下划线)或 `手机号_日期_编号_标题`(左锚定,跟视频文件名同序),**编号都可省略**;标题里可以有 `#话题`;空行忽略,`#` 开头的行**只有认不出手机号/日期时**才当注释;拒收条件:手机号不在台账/命中多个、日期不合法、标题为空或 >100 字、该槽位已有标题、没有对应视频 |
| `POST /api/video_plan/upload_titles` | `{text, dry_run}` 或 multipart `file`(txt) | `{ok, counts:{total,attached,skipped,rejected}, rows:[{line,phone,date,seq,title,body,action,error}], msg}`;**一行里可带正文**:`标题 || 正文 _手机号_日期_编号`(`||` 分隔,正文可省,只切第一处);**两种写法都认**:标题在前(推荐,右锚定,标题里可带下划线)或 `手机号_日期_编号_标题 || 正文`(左锚定,跟视频文件名同序),**编号都可省略**;标题里可以有 `#话题`;空行忽略,`#` 开头的行**只有认不出手机号/日期时**才当注释;拒收条件:手机号不在台账/命中多个、日期不合法、标题为空或 >100 字、该槽位已有标题、没有对应视频 |
| `GET /api/video_plan/timeline` | `?range=today\|week\|all&from=&to=&status=&account_id=&q=&limit=` | `{ok, days:[{date,weekday,total,done,plans:[…]}], stats, disk, today}` |
| `GET /api/video_plan/stats` | — | `{ok, stats, disk, today}`;`stats` 里**三个口径都给**:`today_ready`(今天要发几个号,按发布日期)/ `today_done`(今天完成)/ `published_today`(今天实际发的,按 `published_at`,补发也算今天) |
| `GET /api/video_plan/<id>` · `PUT` · `DELETE` | `PUT {"title"}` | `PUT` 只改标题(改完 `pending→ready`);`DELETE` **先删行再删素材文件** |
@@ -554,9 +554,9 @@
| `POST /api/video_plan/<id>/skip` | `{reason}` | 人工跳过 |
| `POST /api/video_plan/<id>/retry` | — | `failed`→`ready`,`attempts` 清零 |
| `POST /api/video_plan/<id>/resolve` | `{published, note}` | **`unknown` 的人工裁决**:确认真发出去了 → `done`;确认没发 → `failed`(可重试) |
| `GET /api/video_plan/links` | `?from=&to=&device=&phone=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码) |
| `GET /api/video_plan/links` | `?from=&to=&device=&phone=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码)。列:发布日期/设备号/手机号/抖音号/编号/标题/**正文**/分享链接/发布时间(**不再导出账号名称**) |
| `GET /api/video_plan/tasks` | — | `{ok, tasks:[{id,name,enabled,mode,cron,next_run,target,steps,step_count,schedule,retry,is_release,has_push}], release_count, other_count}`:**全部通用步骤任务**(不止发布任务 —— 只列发布任务的话,手写的抖音发布流程会一条都不显示,人会以为"这页没有能改的地方"),界面按 `is_release`(有 `push_release` **或** `mark_release`,含嵌套)分两组。带完整 `steps` 是为**就地编辑**(抓选择器);`step_count` 数的是**含嵌套**的总步数。权限 `T` |
| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **15 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `if_el(cmp_source=release)` 校验账号 → then: `push_release` → 「+」(预填 `descriptionContains=拍摄`) → 相册/第一个视频/下一步/输入框/发布(**空选择器占位**)→ `input_text(text_source=release_title)` → `mark_release`;else: `notify` 跳过);**走 TaskManager 建**(内存+库一起更新,直接写库调度器不认)。⚠ `target` 分组键名是 **`group_name`**(写成 `group` 会静默解析出 0 台设备);分组/单设备没给具体目标 → 400。权限 `T` |
| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **17 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `if_el(cmp_source=release)` 校验账号 → then: `push_release` → 「+」(预填 `descriptionContains=拍摄`) → 相册/第一个视频/下一步/标题框/正文框/发布(**空选择器占位**)→ `input_text(text_source=release_title)` → `input_text(text_source=release_body)` → `mark_release`;else: `notify` 跳过);**走 TaskManager 建**(内存+库一起更新,直接写库调度器不认)。⚠ `target` 分组键名是 **`group_name`**(写成 `group` 会静默解析出 0 台设备);分组/单设备没给具体目标 → 400。权限 `T` |
| `GET/PUT /api/video_plan/tasks/<job_id>` | `PUT {name?, time?, target?, steps?, enabled?}` | **发布任务的就地编辑**(「账号 → 发布计划」页里改,不用跳任务页)。只放开这几个字段,**没传的字段原样保留**;`steps` 整块替换但 `params` 里的通用参数(去重有效期、抢占…)**深合并保留**。校验:空名字/坏时间/`steps` 不是数组/什么都不传 → 400,未知任务 → 404。权限 `T`。底层仍是 `TaskManager.update_job()`(内存+库一起改) |
| `POST /api/video_plan/tasks/<job_id>/adopt` | — | 给**已有任务**插上平台那两步:`push_release` 放最前、`mark_release` 放最后(**中间你自己的步骤一步不动**;位置理由见 `core.video_plan.insert_release_steps`)。只对「通用步骤」任务;没有步骤 → 400,已经有这两步 → 409,未知任务 → 404。权限 `T` |
+1 -1
View File
@@ -298,7 +298,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** |
| `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) |
| `ledger.js` | 账号台账页(列表 / 单条增删改 / 从表格粘贴导入 + 逐行结果 / 设备维度弹窗)+ `ledgerCell()` 供设备池那一列 | 服务层 `core/ledger.py`;导入默认**先预览再写**(`dry_run`) |
| `release.js` | 发布计划页(时间线按日期分组 / 多选视频**串行**上传 + 逐文件进度与结果 / 标题 txt 上传 / 推送到手机 + **一键推送**(当天全部)、相册校验徽章与裁决 / 链接复制与导出 CSV / **发布任务的就地编辑**) | 服务层 `core/video_plan.py`;上传前把队列**按文件名排序**并让用户确认解析结果(顺序 = "无编号按顺序配"的依据)。就地编辑只放常用字段(名字/目标/时间/启停/逐步参数 + 抓取元素),更复杂的编排走「打开通用编辑器」→ `openTaskModal`;步骤路径里 `then/else/children` 在**步骤的 params 下**(`_rpWalk`),走错这一层会把分支当数组下标用 |
| `release.js` | 发布计划页(时间线按日期分组 / 多选视频**串行**上传 + 逐文件进度与结果 / 标题+正文 txt 上传(`标题 || 正文 _手机号_日期_编号`,**不显示账号名称、只显示抖音号**)/ 推送到手机 + **一键推送**(当天全部)、相册校验徽章与裁决 / 链接复制与导出 CSV / **发布任务的就地编辑**) | 服务层 `core/video_plan.py`;上传前把队列**按文件名排序**并让用户确认解析结果(顺序 = "无编号按顺序配"的依据)。就地编辑只放常用字段(名字/目标/时间/启停/逐步参数 + 抓取元素),更复杂的编排走「打开通用编辑器」→ `openTaskModal`;步骤路径里 `then/else/children` 在**步骤的 params 下**(`_rpWalk`),走错这一层会把分支当数组下标用 |
| `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) |
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
+3 -2
View File
@@ -16,7 +16,7 @@
| SQLite 回退时的 PRAGMA | `journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL`(监听器按连接类型守卫,MySQL 连接不会执行) |
| 建表方式 | **唯一真相是模型**:`db.create_all()`(建缺表)+ `_sync_columns()`(补缺列);`SCHEMA_MIGRATIONS` 只作版本账本与数据回填 |
| 库位置 | MySQL:由 `DB_HOST/DB_NAME` 指定;SQLite:`data/users.db` |
| 当前 schema 版本 | `app_meta.schema_version = 10`(迁移清单见 `core/models.py` 的 `SCHEMA_MIGRATIONS`:建表/补列以模型为准,这里只作版本账本) |
| 当前 schema 版本 | `app_meta.schema_version = 11`(迁移清单见 `core/models.py` 的 `SCHEMA_MIGRATIONS`:建表/补列以模型为准,这里只作版本账本) |
**环境与库的绑定**(防混库,见 [DEPLOY.md](DEPLOY.md) §2.2)
@@ -248,7 +248,8 @@
| `device_name` / `nickname` / `douyin_id` / `serial` | String | 账号与设备快照(时间线卡片直接显示,免 join) |
| `release_date` | String(10) index | **`2026-09-12`** 纯日期(等值比较走索引) |
| `seq` / `seq_auto` | Integer / Boolean | 编号从 **1** 起(不用 0 表示"无");`seq_auto`=号是自动分配的 |
| `title` | Text | 文案(标题 txt 配对写入;**空标题不会被发布**) |
| `title` | Text | **标题**(发布页的「标题」框;标题 txt 配对写入) |
| `body` | Text | **正文/文案**(发布页的文案框,可带 `#话题`)—— 标题 txt 一行里用 `||` 分隔:`标题 || 正文 _手机号_日期_编号`;**标题与正文都为空的行不会被发布** |
| `video_file` / `video_name` / `video_size` / `video_sha1` | 各自 | 落盘相对路径 / 原始名 / 大小 / 内容指纹(重复上传判据) |
| `status` | String(16) index | 见下方状态机 |
| `stage` | String(16) | 失败发生在哪一步:`push`/`scan`/`post`/`verify` |
+18 -10
View File
@@ -127,7 +127,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
| 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 输入框) |
| 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`(±`_topics`) = 标题、`release_body`(±`_topics`) = 正文、`release_title_body` = 标题+正文(配 `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 |
@@ -562,15 +562,16 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
这类任务行上会标「**缺平台推送步骤**」并给一个「**插上平台步骤**」按钮
(`POST /api/video_plan/tasks/<id>/adopt`:推送放最前、标记放最后、**你原来的步骤一步不动**)。
还支持**一键新建标准发布任务** ——
骨架 **15 步**已经排好:
骨架 **17 步**已经排好(**标题和正文各一步** —— 对应抖音发布页上那两个框):
```
⓪ 亮屏 ① 打开抖音(等「首页」出来) ② 点击「我」(desc 含"我") ③ 等待 3~5s
④ 条件判断 //*[contains(@text,'抖音号')] 包含 cmp_source=release
├─ then(登的就是要发的号)⑤ 推送发布视频 ⑥ 点击「+」(desc 含"拍摄")
│ ⑦ 相册 ⑧ 第一个视频 ⑨ 下一步 ⑩ 输入框 ⑪ 输入文字(text_source=release_title)
│ ⑫ 点击「发布」 ⑬ 标记发布结果
└─ else(不是这个号 / 今天没有待发计划)⑭ 发通知"本条跳过"
│ ⑫ 点击正文输入框 ⑬ 输入文字(text_source=release_body)
│ ⑭ 点击「发布」 ⑮ 标记发布结果
└─ else(不是这个号 / 今天没有待发计划)⑯ 发通知"本条跳过"
```
> ⚠ **⓪ 亮屏 + ① 等「首页」是踩出来的,别删**:真机实测(2026-09-28,A01)——
@@ -579,17 +580,24 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
> 屏幕亮着时冷启动 5 秒就出底部栏。
建好之后**就在「发布计划」这一块点「编辑」就地改**(名字/目标/每天几点/启停/逐步参数,
选择器旁边就有「抓取元素」),把 5 个空选择器(相册/第一个视频/下一步/输入框/发布)抓一次就能跑 ——
选择器旁边就有「抓取元素」),把 6 个空选择器(相册/第一个视频/下一步/标题框/正文框/发布)抓一次就能跑 ——
要加步骤、循环、自定义动作这些更复杂的编排,才走「打开通用编辑器」(跳任务页)——
**抖音那几步故意不写死选择器**:混淆 id / 结构 xpath 改版就废,
抓一次的成本远低于每次发版都改平台代码。②⑥ 两条预填的是**按描述定位**
(`descriptionContains`,跨版本稳),一般不用改。
**「输入文字」自动用标题**:`text_source=release_title` 会取**本机当前那条计划**的标题
(`push_release` 同一次运行里传递;断档了就取最近一条"已推送到手机"的),
`release_title_topics` 还能自动补话题;**取计划标题时会回读输入框校验**,
不通过就改走剪贴板通道再验一次 —— 抖音的 EditText 有时不吃键盘输入,
不校验的后果是"发出一篇空文案,而每一步都显示成功"。
**「输入文字」自动用计划里的内容**(`text_source`,常量 `RELEASE_TEXT_SOURCES` 一处定义):
| text_source | 取什么 | 用在哪个框 |
|---|---|---|
| `release_title` / `release_title_topics` | 计划的**标题**(`video_plan.title`) | 抖音发布页的「标题」框 |
| `release_body` / `release_body_topics` | 计划的**正文**(`video_plan.body`) | 抖音发布页的文案/正文框 |
| `release_title_body` | 标题 + 换行 + 正文 | 只有一个输入框的抖音版本 |
带 `_topics` 的会再把 `release_topics`(逗号分隔)拼成 `#话题`。
内容由 `push_release` 在同一次运行里传递(断档了就取最近一条"已推送到手机"的);
**取计划内容时会回读输入框校验**,不通过就改走剪贴板通道再验一次 ——
抖音的 EditText 有时不吃键盘输入,不校验的后果是"发出一篇空文案,而每一步都显示成功"。
**抓分享链接**(`mark_release` 的 `capture_link=True`,或计划页人工点「标记已发布」时):
进「我 → 作品」第一条 → 分享 → 复制链接 → 读剪贴板 → 正则抠出 `v.douyin.com` 链接。