feat(发布计划): 视频发布计划(批量上传配对 → 时间线 → 推送到手机 → 发布任务 → 分享链接)
一、平台侧(账号 → 发布计划页) - 新表 video_plan(schema v9→v10):账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接; 状态机 pending/ready/pushing/publishing/done/failed/unknown/skipped(**failed 与 unknown 必须分开**: 推送阶段的失败可安全重试;碰过抖音之后的岔子只能算"结果未知",绝不自动重发) - 素材上传:文件名 `手机号_日期_编号`(编号可省)解析配对;标题 txt `标题内容_手机号_日期_编号`; 内容寻址落盘 data/videos/YYYY-MM/(sha1 分块算,同名不存两份),**不进整库备份**但进 manifest 反查 - 新蓝图 web/video_plan_api.py:上传/时间线/统计/单条增删改/推送到手机/标记结果/裁决/链接导出 CSV/ 任务列表与一键新建、**就地编辑**(GET/PUT /tasks/<id>)、**一键推送**(POST /push_all,按设备分组、设备内串行) - 账号页拆子分栏(台账 / 发布计划)+ static/admin/release.js;清理 job(04:41 僵尸回收+过期行、04:47 素材文件) - 上传体积:MAX_CONTENT_LENGTH(默认 2GiB)+ 413 JSON + nginx client_max_body_size(修现有 APK 上传隐患) 二、任务侧(平台推素材,抖音流程你自己写) - 新步骤 push_release「推送发布视频」:原子占位 → adb push → **touch 改成"现在"** → 清旧目录同名副本 → 触发扫描并**按路径**校验相册索引 → 标题写进剪贴板;默认目录 /sdcard/DCIM/Camera - 新步骤 mark_release「标记发布结果」:回写 done/failed/unknown,成功时抓作品分享链接、删手机素材 - input_text 支持 text_source=release_title(自动取计划标题 + 回读校验); if_el 的候选值来源新增 release(**本机当前发布计划**的抖音号/昵称,发布前校验"登的是不是要发的号") - build_release_steps 骨架 15 步:⓪ 亮屏 → ① 打开抖音(等首页) → ② 点「我」→ ③ 等抖音号出现 → ④ 条件判断(账号) → then ⑤ 推送 ⑥⑦⑧⑨⑩⑪⑫ 抖音点击/填标题 → ⑬ 标记 / else 发通知跳过 三、修(推送这一路的检测机制) - **uiautomator2 3.x 的 d.shell() 返回 ShellResponse(tuple 子类)不是 str**:`'x' in resp` 恒 False、 `.strip()` 不存在 → "推上去的文件大小不对"每次都判失败(文件其实推上去了)、相册校验永远报没进、 删除确认永远判没删掉。新增 publish_flow._sh() 统一取 .output;大小改成解析 ls -l 的大小列 - **adb push 保留本地 mtime** → 推 3 天前上传的素材在按时间排序的相册里排不到最前, "点第一个 = 刚推的那个"不成立 → 推完 touch - 相册校验**按路径**比(MediaStore 的 _data 会把目录小写、/storage/emulated/0 ≡ /sdcard), 只比文件名会被老目录的同名残留骗过去 - 屏幕没亮就启动抖音会永远停在启动页(UI 树为空)→ 后面"点我/等抖音号"必然 miss, 最后报成误导人的"账号不符" → 骨架第一步固定加「亮屏」,open_app 等「首页」出现 四、其它 - core/ledger.serial_of():设备名 → 当前地址(设备换 IP 后快照是错的) - 通知事件 task.video.published / task.video.failed;备份清单加 video_plan 与素材统计 - 文档同步:DATA_MODEL §2.11 + schema v10、API(新接口与语义)、TASK_DEV §4.7 专章、 ARCHITECTURE(账号页子分栏/release.js/两个 job)、DEPLOY(表数/nginx)、NOTIFY、DEVELOPMENT、README
This commit is contained in:
+51
-1
@@ -62,7 +62,9 @@
|
||||
|
||||
## 2. 接口总索引
|
||||
|
||||
> 共 **107 条路由**。`鉴权` 列:`—` 无、`L` 登录、`T/D/A/G` = tasks/devices/apks/logs 权限位、`Admin` 仅管理员。
|
||||
> 本表列出全部业务路由(页面路由除外;同一路径的不同方法合并成一行)。
|
||||
> `鉴权` 列:`—` 无、`L` 登录、`T/D/A/G` = tasks/devices/apks/logs 权限位、`Admin` 仅管理员。
|
||||
> 想看实际注册了多少条:`python -c "from web_server import app; print(len([r for r in app.url_map.iter_rules() if r.rule.startswith('/api')]))"`。
|
||||
|
||||
### 2.1 auth(`web/auth.py`)
|
||||
|
||||
@@ -143,6 +145,21 @@
|
||||
| PUT | `/api/ledger/<acc_id>` | D | 改一条账号 |
|
||||
| DELETE | `/api/ledger/<acc_id>` | D | 删一条账号 |
|
||||
| POST | `/api/ledger/import` | D | 从表格粘贴文本批量导入(`dry_run=true` 只预览不写库) |
|
||||
| POST | `/api/video_plan/upload_video` | D | 上传单个视频素材(文件名 `手机号_日期_编号`)→ 解析 + 配对 + 入计划 |
|
||||
| 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 | `/api/video_plan/<id>/video` | D | 预览平台上的素材(文件清理后 → 410) |
|
||||
| POST | `/api/video_plan/<id>/push` | D | **把素材推到手机**(只推送、不发布;后台线程) |
|
||||
| POST | `/api/video_plan/push_all` | D | **一键推送**:某天(默认今天)所有待发布/失败的计划 → 各自手机(每台设备一个线程、设备内串行) |
|
||||
| POST | `/api/video_plan/<id>/mark` | D | 人工标记结果(`done`/`failed`/`unknown`,推完之后用) |
|
||||
| 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 步:亮屏 → 打开抖音 → 校验账号 → 推送 → 抖音点击 → 填标题 → 标记) |
|
||||
| GET/PUT | `/api/video_plan/tasks/<job_id>` | T | **发布任务的就地编辑**(就在「发布计划」页改名字/目标/时间/启停/步骤) |
|
||||
| POST | `/api/video_plan/tasks/<job_id>/adopt` | T | 给已有任务插上平台两步(推送放最前 / 标记放最后,自己的步骤不动) |
|
||||
|
||||
### 2.4 admin(`web/admin_api.py`)
|
||||
|
||||
@@ -518,6 +535,39 @@
|
||||
> ⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值";**不要**拿它填「去重」的身份元素
|
||||
> (身份是元素原文逐字算 key,格式不同会让去重**静默失效**)。
|
||||
|
||||
### 视频发布计划(「账号 → 发布计划」页)
|
||||
|
||||
表与状态机见 [DATA_MODEL.md](DATA_MODEL.md) §2.11,任务侧怎么自动发布见
|
||||
[TASK_DEV.md](TASK_DEV.md) §4.7;权限 `devices`。
|
||||
|
||||
| 接口 | 请求 | 响应要点 |
|
||||
|------|------|---------|
|
||||
| `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}` |
|
||||
| `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` **先删行再删素材文件** |
|
||||
| `GET /api/video_plan/<id>/video` | — | 素材预览(`send_file`,支持 Range);文件已被清理 → **410** |
|
||||
| `POST /api/video_plan/<id>/push` | — | **把素材推到手机**(只推送、不发布):推文件 → 触发相册刷新 → 标题写进剪贴板;已推送/结果未知 → 409。发布动作由你写在任务里(见 [TASK_DEV.md](TASK_DEV.md) §4.7) |
|
||||
| `POST /api/video_plan/push_all` | `{date?, retry_failed?}` | **一键推送**:某天(默认今天)`ready`/`failed` 且未超尝试上限的计划,**按设备分组**推(每台设备一个线程、设备内串行;设备间并行)。返回 `{ok,count,devices,no_device,msg}`;没绑上设备地址的条数单独在 `no_device` 里报出来。全部设备都解析不到 → 400 |
|
||||
| `POST /api/video_plan/<id>/mark` | `{status: done\|failed\|unknown, why}` | 人工标记结果(推完之后用):`done` 补 `published_at`;`failed`/`unknown` 把 `why` 记进 `last_error` |
|
||||
| `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/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` |
|
||||
| `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` |
|
||||
|
||||
**逐文件上传的幂等**:同一 `(手机号,日期,编号)` 已有行时 —— 文件内容相同 → `action=skip`(不重复落盘);
|
||||
内容不同 → **拒收**,前端给「覆盖」按钮(二次确认后带 `replace=1` 重传)。
|
||||
标题同理:同槽位已有别的标题 → 拒收(不静默覆盖,文案是发出去就改不了的东西)。
|
||||
|
||||
> ⚠ 上传体积:`MAX_CONTENT_LENGTH`(默认 2GiB,`.env` 的 `MAX_UPLOAD_MB` 可调)**必须大于
|
||||
> 「应用管理」最大的 APK**(实测 336MB),否则 APK 上传会被一起卡死;超限返回 **JSON 413**。
|
||||
> 上反代时 nginx 那侧还要 `client_max_body_size`(见 [DEPLOY.md](DEPLOY.md) §6)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 设备池与自动发现
|
||||
|
||||
+3
-2
@@ -86,7 +86,7 @@
|
||||
| 阶段 | 位置 | 做了什么 |
|
||||
|------|------|---------|
|
||||
| **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`)、`hour=4, minute=23` 挂去重记录清理(`_purge_done_mark`,只清 `day`/`hours` 桶)——两个清理任务都自建 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` 桶)、`hour=4, minute=41` 挂发布计划清理(`_purge_video_plan`:僵尸占位回收 + 过期行)、`hour=4, minute=47` 挂素材文件清理(`_purge_video_files`)——时间刻意错开(都是删数据的大操作),各任务自建 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`。
|
||||
@@ -280,7 +280,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
|
||||
| 顶级 Tab | `data-tab` | 权限 | 子分栏 |
|
||||
|---------|-----------|------|--------|
|
||||
| 监控 | `monitor` | 登录即可 | — |
|
||||
| 账号 | `account` | `devices` | — (账号台账:列表 / 增删改 / 从表格粘贴导入,见 [DATA_MODEL.md](DATA_MODEL.md) §2.10) |
|
||||
| 账号 | `account` | `devices` | `ledger` 账号台账(列表/增删改/粘贴导入)/ `plan` **发布计划**(上传视频与标题、时间线、推送到手机、导出链接 + **发布任务就地编辑**,见 [DATA_MODEL.md](DATA_MODEL.md) §2.10 §2.11) |
|
||||
| 任务 | `tasks` | 登录即可(写操作需 `tasks`) | `plan` 任务计划 / `actions` 自定义动作 / `actioncfg` 动作配置 / `dedup` 去重记录 |
|
||||
| 日志 | `logs` | `logs` | — |
|
||||
| 用户 | `users` | `admin` | — |
|
||||
@@ -298,6 +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`),走错这一层会把分支当数组下标用 |
|
||||
| `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
|
||||
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) |
|
||||
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
|
||||
|
||||
+58
-2
@@ -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 = 6` |
|
||||
| 当前 schema 版本 | `app_meta.schema_version = 10`(迁移清单见 `core/models.py` 的 `SCHEMA_MIGRATIONS`:建表/补列以模型为准,这里只作版本账本) |
|
||||
|
||||
**环境与库的绑定**(防混库,见 [DEPLOY.md](DEPLOY.md) §2.2)
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。
|
||||
|
||||
**表清单(16 张,全部是 `core/models.py` 里的 ORM 模型)**
|
||||
**表清单(17 张,全部是 `core/models.py` 里的 ORM 模型)**
|
||||
|
||||
| # | 表 | 用途 |
|
||||
|---|---|------|
|
||||
@@ -47,6 +47,7 @@
|
||||
| 14 | `task_step_log` | 任务步骤明细(每次步骤执行一条,见 §2.8;**唯一有无界增长风险的表**,靠保留期清理) |
|
||||
| 15 | `done_mark` | 去重账本:跨设备"已做过"标记(见 §2.9;本清单此前漏列,2026-09-24 补上) |
|
||||
| 16 | `device_account` | 账号台账:一台设备上登录着哪些账号(见 §2.10;任务「条件判断」的取号来源、设备端身份页显示用) |
|
||||
| 17 | `video_plan` | 视频发布计划:账号 × 发布日期 × 编号 → 素材 + 标题 + 发布状态 + 分享链接(见 §2.11) |
|
||||
|
||||
> 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE`
|
||||
> (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY`
|
||||
@@ -234,6 +235,55 @@
|
||||
|
||||
这张表**自动进整库备份**(§6 派生规则)。
|
||||
|
||||
### 2.11 `video_plan` — 视频发布计划(一个账号在某天要发的一个视频)
|
||||
|
||||
「账号 → 发布计划」页维护;服务层 `core/video_plan.py`,接口 `/api/video_plan/*`(见 [API.md](API.md) §2.14),
|
||||
任务步骤「发布视频」按它自动发布(见 [TASK_DEV.md](TASK_DEV.md) §4.7)。
|
||||
**素材文件**落在 `data/videos/YYYY-MM/`(文件名 `{sha1[:12]}_{安全原名}`,内容寻址),**不进整库备份**(§6)。
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| `id` | String(32) PK | uuid 前 8 位 |
|
||||
| `account_id` / `phone` | String(32) | 台账行 id(**不做外键**)/ 配对键(冗余存:账号删了也留痕) |
|
||||
| `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 配对写入;**空标题不会被发布**) |
|
||||
| `video_file` / `video_name` / `video_size` / `video_sha1` | 各自 | 落盘相对路径 / 原始名 / 大小 / 内容指纹(重复上传判据) |
|
||||
| `status` | String(16) index | 见下方状态机 |
|
||||
| `stage` | String(16) | 失败发生在哪一步:`push`/`scan`/`post`/`verify` |
|
||||
| `attempts` | Integer | 尝试次数(上限 3,超了不再自动取) |
|
||||
| `published_at` / `share_url` / `link_at` / `video_deleted_at` | String | 发布时刻 / **作品分享链接** / 抓到链接的时刻 / 素材何时清理 |
|
||||
| `push_verify` / `push_remote` | String(16)/String(200) | **推送后的相册校验**:`ok`=已进相册索引 / `no_index`=文件在但没进索引(相册里可能看不到)/ `nofile`=文件不在;`push_remote` = 推到手机上的绝对路径(删它、排查用)。**文件推上去了 ≠ 相册里点得到它**,所以单独存一列而不是混进 `last_error` |
|
||||
| `last_error` / `note` / `created_at` / `updated_at` | | 失败原因 / 人工备注 / 时间 |
|
||||
|
||||
**状态机**(本表的灵魂,别简化):
|
||||
|
||||
```
|
||||
pending 有视频、还没标题 ready 素材齐,等推送
|
||||
pushing 已推到手机(等人/用户的步骤去发) done 已发布(终态)
|
||||
skipped 人工跳过(终态) failed 推送阶段就失败 —— 还没到抖音,**可安全重试**
|
||||
unknown 推送之后出的岔子 —— **可能已经发出去了,绝不自动重试**,要人工裁决
|
||||
```
|
||||
|
||||
> **平台只负责把素材推到手机**(`push_release` 步骤 / 计划页「推送到手机」):
|
||||
> 推文件 → 触发相册刷新 → 把标题写进手机剪贴板。**抖音里怎么发由用户在任务画布上自己写**,
|
||||
> 最后放一个 `mark_release`「标记发布结果」回写这里的状态(`published`→`done`、`failed`、`unknown`)。
|
||||
> 这样抖音改版时用户改自己的步骤即可,不用等平台发版。
|
||||
|
||||
> ⚠ **`failed` 与 `unknown` 必须分开**:把"不知道自己发没发"混成"知道自己没发",
|
||||
> 就是重复发布的来源。`stage` 是两者互相转换的唯一依据(`core/video_plan.STAGE_STATUS`)。
|
||||
> 界面上的 `unknown` 卡片标橙 + 硬提示,人工到抖音确认后点「已发出 / 未发出」裁决。
|
||||
|
||||
**唯一性**:`(phone, release_date, seq)` 由**服务层**保证,**不加 DB 唯一索引** ——
|
||||
`seq` 从 1 起、没有"空值"可言,做部分唯一索引要写三处方言适配 + MySQL 生成列(§4.2),
|
||||
收益不匹配;违反的代价只是低频人工上传产生的重复行(可见、可删)。
|
||||
|
||||
**索引**:`(release_date,status)` 时间线主查询 · `(account_id,release_date)` 账号视角 ·
|
||||
`(phone,release_date,seq)` 配对/幂等 · `(video_file)` 清理时反查引用。
|
||||
|
||||
这张表**自动进整库备份**(§6 派生规则);**素材文件不进备份包**,见 §6 与 §7。
|
||||
|
||||
---
|
||||
|
||||
## 3. 非模型表
|
||||
@@ -368,6 +418,11 @@ SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
|
||||
- **导入侧**:备份里出现未登记的表(排除 `sqlite_` 前缀)→ 预览告警(字段 `extra_tables`)
|
||||
- `REQUIRED_TABLES = (app_meta, user, task_job, device_group)`:缺任一直接拒绝导入(这四项是"判定这是不是本平台备份"的最小集合,故意手工维护)
|
||||
|
||||
> **素材文件不进备份包**:`create_export()` 只打包 `data/apks/*.apk`,**不含 `data/videos/`**
|
||||
> (几十 GB 会把"数据库备份"这个核心能力搞坏)。备份的 `manifest.json` 里有
|
||||
> `videos.included=false` + 数量/字节数,备份预览页也会提示"素材需另外备份 `data/videos/`"
|
||||
> —— **不能让人以为备份了**。恢复后素材要重新上传(计划与发布状态、分享链接都在表里,已备份)。
|
||||
|
||||
> **红线**:新增持久化表时**同时补 `TABLE_LABELS` 的中文标签**并更新 [DEPLOY.md](DEPLOY.md) §数据备份。
|
||||
> 覆盖清单本身不再需要手工登记(已由 metadata 派生)。历史教训:`agent_action` 曾漏登记,
|
||||
> 导致"动作库看起来没备份"(数据其实在快照里,只是清单没列)。
|
||||
@@ -380,6 +435,7 @@ SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
|
||||
|------|------|--------|
|
||||
| `data/users.db`(+`-wal`/`-shm`) | SQLite 主库(**仅回退模式用**;连 MySQL 时这些文件不被读写,可留作历史归档) | 否 |
|
||||
| `data/apks/*.apk` | 上传的 APK | 否 |
|
||||
| `data/videos/YYYY-MM/*` | 视频发布计划的素材(几百 MB 一个;**不进整库备份**,见 §6) | 否 |
|
||||
| `data/backups/` | 导出临时 zip、`pre_restore_*.zip`(导入前安全网)、`restore_failed_*` | 否 |
|
||||
| `data/restore_staging/<token>/` | 导入暂存(TTL 1800s 自动清理) | 否 |
|
||||
| `data/restore_pending/` | 待生效恢复任务(重启时单事务消费) | 否 |
|
||||
|
||||
+13
-3
@@ -212,12 +212,16 @@ tail -20 logs/web.log # 无 ERROR/Traceback
|
||||
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
|
||||
```
|
||||
|
||||
当前 16 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` /
|
||||
当前 17 张表:`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` / `done_mark` /
|
||||
`device_account`(账号台账)。
|
||||
`device_account`(账号台账)/ `video_plan`(视频发布计划)。
|
||||
完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。
|
||||
|
||||
> ⚠ **视频素材(`data/videos/`)不进备份包**(几十 GB 会把备份搞坏):manifest 里有
|
||||
> `videos.included=false` 与数量/字节数,预览页也会提示 —— **素材要另外备份**。
|
||||
> 计划、发布状态与分享链接都在 `video_plan` 表里,随备份一起走。
|
||||
|
||||
> ⚠️ `task_step_log`(任务步骤明细)是会持续增长的表:它按 `KEEP_DAYS`
|
||||
> (默认 14 天,见 `core/step_log.py`)自动清理,但备份包里会带上保留期内的全部行。
|
||||
> 设备多、任务密时导出 zip 会明显变大——需要更小的包就调小那个常量。
|
||||
@@ -263,7 +267,13 @@ SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
|
||||
- **HTTPS/80 端口**:用 Nginx 反代
|
||||
|
||||
```nginx
|
||||
location / { proxy_pass http://127.0.0.1:18050; proxy_set_header Host $host; }
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18050;
|
||||
proxy_set_header Host $host;
|
||||
# ⚠ 必须加:nginx 默认 client_max_body_size 只有 1m,
|
||||
# 不加的话「应用管理」传 APK(有 336MB 的包)和视频素材上传都会 413。
|
||||
client_max_body_size 2048m;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+3
-1
@@ -140,6 +140,8 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
|
||||
| `WEB_HOST` / `WEB_PORT` | `0.0.0.0` / `18050` | 硬编码 | 18050 避开 Windows 动态端口段 |
|
||||
| `DATA_DIR` / `APK_DIR` | `data/` / `data/apks/` | 代码计算 | |
|
||||
| `BACKUP_DIR` / `RESTORE_STAGING_DIR` / `RESTORE_PENDING_DIR` | `data/backups` / `data/restore_staging` / `data/restore_pending` | 代码计算 | 备份相关 |
|
||||
| `VIDEO_DIR` | `data/videos` | `DATA_VIDEO_DIR` | 视频发布计划的素材目录(**不进整库备份**;测试要指到临时目录) |
|
||||
| `MAX_CONTENT_LENGTH` | 2 GiB | `MAX_UPLOAD_MB` | 单次上传体积上限;**必须大于最大的 APK(实测 336MB)**,否则 APK 上传会被卡死 |
|
||||
| `DEPLOY_ENV` | `dev` | `.env` 可覆盖 | 声明这套配置连哪个环境的库;与库名绑定(dev→`auto_control_dev`,prod→`auto_control`),不符拒绝启动 |
|
||||
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | 空 / `3306` / 空 / 空 / 空 | `.env` 可覆盖 | MySQL 目标;`DB_HOST` 为空则回退 SQLite(**生产禁止静默回退**,需 `DB_ALLOW_SQLITE_FALLBACK=1`) |
|
||||
| `DB_CHARSET` / `DB_COLLATION` | `utf8mb4` / `utf8mb4_bin` | `.env` 可覆盖 | 排序规则必须用 `_bin`(逐码点比较,等价 SQLite 的大小写敏感语义) |
|
||||
@@ -198,7 +200,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
|
||||
| 页面结构 / 样式 / 引入脚本 | `templates/admin/monitor.html` |
|
||||
| 公共工具(API/权限/Toast/Tab) | `static/admin/base.js` |
|
||||
| 列表分页排序 | `static/admin/list.js` |
|
||||
| 各功能域逻辑 | `static/admin/{monitor,editor,tasks,tools,apps,admin,agent,system,markdown,dedup,ledger}.js` |
|
||||
| 各功能域逻辑 | `static/admin/{monitor,editor,tasks,tools,apps,admin,agent,system,markdown,dedup,ledger,release}.js` |
|
||||
|
||||
新增 JS 模块:建文件 → 在 `monitor.html` 里按依赖顺序加 `<script src>` → 更新 [ARCHITECTURE.md](ARCHITECTURE.md) §6.3 与本文 §5.3。
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ daemon 线程。所以:
|
||||
| 任务批次 | `task.batch.started` · `.finished` · `.no_device` · `.unknown_type` · `task.cron.stopped` |
|
||||
| 任务·单设备 | `task.device.success` · `.failed` · `.offline` · `.error` · `.retry` · `.stopped` · `.preempted` · `.preempt_timeout` · `.released` |
|
||||
| 业务 | `task.selector.invalid`(选择器连续 10 次未命中——"任务成功但什么都没做"的隐蔽故障) |
|
||||
| 业务 | `task.video.published`(视频发布成功,带作品分享链接)/ `task.video.failed`(发布失败**或结果未知**——结果未知意味着可能已经发出去了,要到计划页人工确认,平台不会自动重发) |
|
||||
| 业务·任务自己发 | `task.notify.custom`(步骤「发通知」)/ `task.patrol.hit`(任务「公共巡检」命中)——**标题正文由任务自己写**,见下 |
|
||||
| Worker | `worker.connected` · `.attempt.done` · `.attempt.error`(单次尝试级,噪音大,默认没人勾) |
|
||||
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` · `device.battery.low` · `.battery.recovered` |
|
||||
|
||||
+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 概念、22 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
|
||||
| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、24 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
|
||||
| [MCP.md](MCP.md) | **MCP 手机控制手册**:20 个 `de_*` 工具用法、写操作门控、坐标换算、接入示例 | 接入方、数字员工 |
|
||||
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
|
||||
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
|
||||
|
||||
+152
-4
@@ -10,7 +10,7 @@
|
||||
|
||||
- [1. 核心概念](#1-核心概念)
|
||||
- [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps)
|
||||
- [3. 22 种步骤全表](#3-22-种步骤全表)(含 [3.1 步骤默认值与动作录制](#31-步骤默认值与动作录制任务--动作配置))
|
||||
- [3. 24 种步骤全表](#3-24-种步骤全表)(含 [3.1 步骤默认值与动作录制](#31-步骤默认值与动作录制任务--动作配置))
|
||||
- [4. 容器步骤与公共参数](#4-容器步骤与公共参数)(含 [4.5 公共巡检](#45-公共巡检任务级独立于步骤画布))
|
||||
- [5. 选择器与元素定位](#5-选择器与元素定位)
|
||||
- [6. 自定义动作](#6-自定义动作)
|
||||
@@ -109,7 +109,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
|
||||
---
|
||||
|
||||
## 3. 22 种步骤全表
|
||||
## 3. 24 种步骤全表
|
||||
|
||||
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
|
||||
|
||||
@@ -127,16 +127,18 @@ 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) | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;**只负责输入,不负责定位输入框**(要先 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`(或 `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) |
|
||||
| 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`)
|
||||
|
||||
@@ -294,6 +296,20 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
| `device` | **本机台账**:这台设备在台账里登记的抖音号(靠 `serial`→设备名 匹配) |
|
||||
| `all` | 全部台账 |
|
||||
| `group` | 某个**设备分组**内所有设备的号(分组名填在 `cmp_group`) |
|
||||
| `release` | **本机当前发布计划**要发的那个号:优先本次运行里「推送发布视频」刚取到的那条;还没有就**看**下一条待发的(只看不占位)。候选值是**抖音号 + 账号名称**两个(任一命中即可) |
|
||||
|
||||
#### `release`:发布前先确认"登的是要发的那个号"(一台手机登好几个号时必配)
|
||||
|
||||
一台手机可能登着好几个号,发布前必须确认当前抖音登的就是这条计划要发的号 ——
|
||||
就在发布流程**最前面**放一个条件判断(`cmp_source=release` + `包含`),命中了才走发布,
|
||||
没命中走 `else`(发通知/跳过)。这样"发错号"变成"这条跳过",而不是把 A 号的视频发到 B 号上。
|
||||
|
||||
- 比对的元素原文:抖音「我」页面上的那一行(如 `//*[contains(@text,'抖音号')]`)——
|
||||
`cmp_source=release` 给的候选值包含**抖音号**,所以 `包含` 就能命中。
|
||||
- 命中判据是**候选值里任一被元素原文包含**;两个候选值(抖音号、昵称)是刻意的:
|
||||
有的界面只显示昵称。
|
||||
- **本机没有待发计划 → 候选为空 → 这一步永远走 `else`**(日志会写明"本机没有待发的发布计划"),
|
||||
不会把不该发的号发出去。
|
||||
|
||||
- **与手填值是合并(OR)**,不是二选一:台账里的号 + 你补的一个号,任一命中即可。
|
||||
更重要的是 —— **台账取不到号时手填值仍然生效**,台账没维护好不会把任务直接打哑。
|
||||
@@ -446,6 +462,138 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
||||
|
||||
---
|
||||
|
||||
### 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) 且 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` 支持的值
|
||||
|
||||
Reference in New Issue
Block a user