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:
2026-09-28 15:59:09 +08:00
parent 5c3dcba63e
commit b98e6deac1
26 changed files with 4011 additions and 51 deletions
+379
View File
@@ -0,0 +1,379 @@
"""发布计划的执行原语:**把视频推到手机**、抓分享链接、清理手机上的素材。
**分工**(用户定的):平台只负责"素材到手机 + 状态记账",
**抖音里怎么发(打开→点+→相册→下一步→填文案→发布)由用户在任务画布里自己写步骤** ——
所以这里**没有**写死的抖音发布流程,只有三个原语 + 一段编排:
```
push_one() 取计划 → 原子占位 → adb push 到指定目录 → 触发相册刷新
→ 把标题写进剪贴板(供后面的「粘贴」步骤直接用)
capture_share_link() 进「我 → 作品」第一条 → 分享 → 复制链接 → 读剪贴板 → 抠出 v.douyin.com 链接
clean_phone_video() 删手机上的素材(**精确路径**,绝不 rm 通配)
```
任务步骤(`task.py`):
· 「推送发布视频」`push_release` —— 调 `push_one()`
· 「标记发布结果」`mark_release` —— 用户在自己的发布流程末尾调用,回写状态(可顺带抓链接)
⚠ **失败阶段决定落 `failed` 还是 `unknown`**(见 `core/video_plan.STAGE_STATUS`):
推送阶段失败=还没碰抖音 → `failed`(可安全重试);推送完成后(stage=`post`)出的岔子
→ `unknown`(**可能已经发出去了,绝不自动重试**)。
"""
import os
import re
import time
from core.logger import get_logger
_log = get_logger("task.publish")
# 相册目录:**DCIM/Camera**(用户定的)。
# ⚠ 实测踩过的两件事:
# ① 相册(含抖音的选视频页)读的是 **MediaStore 索引**,`adb push` 只放文件、不进索引
# → 推完必须触发一次扫描(见 scan_media),否则"推送成功但相册里没有"。
# ② 推到自建子目录 `/sdcard/DCIM/rp` 时,MIUI 会把它归进「其他相册」,
# 抖音的选视频页看不顺眼 —— 干脆放官方相机目录,哪儿都列得出来。
# 放 Camera 的代价:用户自己拍的视频也在这个目录里 → **必须靠"刚推的排最前"来定位它**
# → 所以推完要 `touch` 把手机上那个文件的 mtime 改成"现在"(adb push 会保留**本地**修改时间,
# 推一个 3 天前上传的素材,在按时间排序的相册里就排不到最前面 —— 实测踩过)。
ALBUM_DIR = "/sdcard/DCIM/Camera"
SCAN_WAIT = 2.0 # 媒体扫描后等相册刷新(真机实测再调)
# 平台**曾经用过**的推送目录:推送前把同一计划在别处的旧副本清掉(历史遗留 + 换目录后的残留)。
# 不清的后果实测过:相册里同一个 plan id 出现 2~3 个同名视频,"点第一个"点到的可能是指向
# 已删文件的老记录 —— 这正是"相册里选错视频"的来源。
KNOWN_ALBUM_DIRS = ("/sdcard/DCIM/Camera", "/sdcard/DCIM/rp", "/sdcard/Movies/rp")
def _norm_path(p):
"""路径归一化,用于比对:去掉引号、`/storage/emulated/0` 与 `/sdcard` 等价、统一小写。
⚠ 大小写不是洁癖:MediaStore 的 `_data` 返回的是 **`/storage/emulated/0/dcim/rp/...`**
(目录部分被小写了),而我们写的是 `/sdcard/DCIM/...` —— 直接字符串比对永远不相等。
"""
s = (p or "").strip().strip("'\"")
s = s.replace("/storage/emulated/0", "/sdcard").replace("/storage/self/primary", "/sdcard")
return s.lower()
def _log_of(log):
return log or (lambda s: _log.info(s))
def _sh(d, cmd):
"""跑一条 shell 并**拿回文本**。
uiautomator2 3.x 的 `d.shell()` 返回 `ShellResponse`(**tuple 子类**,字段 .output/.exit_code),
不是 str —— 直接 `'abc' in resp`(元组成员判断,恒 False)或 `resp.strip()`(没有这方法)
都会得到错误结果。实测踩过:推送后的"文件大小校验"永远不通过、
"有没有进相册"永远报没进 —— 全是这一步读错了。统一走这里取文本。
"""
r = d.shell(cmd)
out = getattr(r, "output", None) # u2 3.x
if out is None:
out = r if isinstance(r, str) else str(r) # 老版本 u2 / 异常返回
return (out or "").strip()
# ================== 原语 ==================
def push_video(d, serial, local_path, plan_id, album_dir=ALBUM_DIR, ext=".mp4"):
"""把平台素材推到手机相册目录,并把手机上的时间改成"现在"。返回 (remote_path, error)。
改时间是为了**排序**:相册/抖音的选视频页按时间倒序,
`adb push` 保留的是**本地文件的修改时间**(推一个 3 天前上传的素材 → 排在很后面),
那"点第一个缩略图 = 刚推的那个"就不成立、可能点到别的视频。所以推完 `touch` 一下。
"""
remote = f"{album_dir}/{plan_id}{ext}"
try:
local_size = os.path.getsize(local_path)
_sh(d, f"mkdir -p {album_dir}")
if hasattr(d, "push"):
d.push(local_path, remote)
else: # 老版本 u2 兜底
from core.adb_helper import _adb
out = _adb("-s", serial, "push", local_path, remote)
if "error" in (out or "").lower():
return "", f"adb push 失败: {out[:120]}"
# 校验:**比对大小列**(不是"输出里有没有这个数字"—— 那种子串匹配会把日期里的
# 数字也当命中;u2 的 ShellResponse 更会让它恒不通过,见 _sh 的注释)
info = _sh(d, f"ls -l '{remote}'")
got = _remote_size(info)
if got is None:
return "", f"推上去的文件找不到: {info[:120] or '(ls 无输出)'}"
if got != local_size:
return "", f"推上去的文件大小不对: 本地 {local_size} / 手机 {got}"
# 排到最前:把手机上这个文件的 mtime 改成现在(改不动也不拦,只是排序可能不理想)
try:
_sh(d, f"touch '{remote}'")
except Exception as e:
_log.warning(f"touch 手机上的素材失败(排序可能不理想): {e}")
drop_old_copies(d, plan_id, keep=remote, ext=ext) # 老目录里的同名副本顺手清掉
return remote, ""
except Exception as e:
return "", f"推文件异常: {type(e).__name__}: {str(e)[:120]}"
def _remote_size(ls_out):
"""从 `ls -l` 输出里取**大小列**(字节)。取不到返回 None。
MIUI 的 toybox 输出形如 `-rw-rw---- 1 u0_a163 media_rw 15405006 2026-09-25 08:42 /sdcard/...`。
判据两道:① 行首是文件类型字符(`-`/`d`/`l`…)—— 挡住 "No such file" 这类错误行;
② 第 5 列是纯数字 —— 大小列就在那(`ls -l` 一定有 size 列,第 4 列是 owner)。
比"输出里有没有这个数字"这种子串匹配可靠得多(后者会被日期/权限里的数字骗到)。
"""
for line in (ls_out or "").splitlines():
parts = line.split()
if len(parts) >= 6 and parts[0][:1] in "-dlcbps" and parts[4].isdigit():
return int(parts[4])
return None
def _indexed_paths(d, name):
"""这个文件名在相册索引里对应哪些路径(MediaStore 的 `_data` 列表)。"""
query = ("content query --uri content://media/external/video/media --user 0 "
f"--projection _id:_display_name:_data --where \"_display_name='{name}'\"")
out = _sh(d, query)
return [m.group(1) for m in re.finditer(r"_data=(\S+)", out or "")]
def scan_media(d, remote):
"""触发相册刷新,并**按路径**验证它真的进了相册索引。
返回 `(verify, detail)`:`verify` ∈ `ok`(**这个路径**在索引里)/ `no_index`(扫完还是查不到)。
⚠ 为什么要验证:相册读的是 MediaStore 索引,`adb push` 只是放文件 ——
推成功但相册看不到是个**静默失败**("点了第一个缩略图结果点到别的东西"就是这么来的)。
⚠ **必须比路径而不是比文件名**:同一个 plan id 可能在老目录里还留着同名副本
(实测 A01 上 `Movies/rp` + `DCIM/rp` 各有一份),只比名字会把"老副本在索引里"
误判成"新文件已就绪" —— 相册里点第一个,点到的是那份指向旧文件的记录。
⚠ 命令只用**实测有效**的那两条:Android 12 上 `am broadcast MEDIA_SCANNER_SCAN_FILE`
已基本无效(返回 result=0 但什么也不做),真正干活的是 `content call … scan_file`;
老写法里那条 `MEDIA_MOUNTED` 广播在新系统上无效、还可能触发全盘重扫,已去掉。
"""
for cmd in (f"content call --uri content://media/external/file --method scan_file --arg '{remote}'",
f"am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file://{remote}"):
try:
_sh(d, cmd)
except Exception as e:
_log.warning(f"媒体扫描命令失败(继续): {e}")
time.sleep(SCAN_WAIT)
name = remote.split("/")[-1]
want = _norm_path(remote)
others = []
for _ in range(3): # 索引有时慢半拍,给它一秒再查两次
try:
paths = _indexed_paths(d, name)
if any(_norm_path(p) == want for p in paths):
return "ok", f"已进相册索引({remote})"
others = [p for p in paths if _norm_path(p) != want]
except Exception as e:
_log.warning(f"查相册索引失败(当作没进): {e}")
break
time.sleep(1)
if others:
_log.warning(f"⚠ 相册索引里只有**同名但不同路径**的记录:{others[:2]} ——"
f"这个文件本身({remote})还没进索引")
_log.warning(f"⚠ 文件已推到 {remote},但**没进相册索引**(相册里可能看不到它)——"
f"推送目录 {ALBUM_DIR} 或系统抓取策略可能要调整,见 doc/TASK_DEV.md §4.7")
return "no_index", "扫完仍没进相册索引(相册里可能看不到它)"
def drop_old_copies(d, plan_id, keep, ext=".mp4"):
"""把**同一个计划**在平台用过别的目录里的旧副本删掉(精确文件名,绝不通配)。
为什么要删:换了推送目录之后,老目录里的同名副本还在相册里(实测一个 plan id 躺了 3 份),
"点第一个缩略图 = 刚推的那个"就不成立了。删完顺手扫一下,让相册索引把那行去掉。
"""
gone = []
for adir in KNOWN_ALBUM_DIRS:
old = f"{adir}/{plan_id}{ext}"
if _norm_path(old) == _norm_path(keep):
continue
try:
if _remote_size(_sh(d, f"ls -l '{old}'")) is None:
continue
_sh(d, f"rm -f '{old}'")
_sh(d, f"content call --uri content://media/external/file --method scan_file --arg '{old}'")
gone.append(old)
except Exception as e:
_log.warning(f"清旧副本失败(不影响本次推送): {old}: {e}")
if gone:
_log.info(f"已清掉同一计划在旧目录里的副本: {gone}")
return gone
def verify_pushed(d, remote):
"""**推送后的一致性检查**:文件在不在 + 进没进相册索引。返回 (verify, detail)。
`verify` ∈ `ok` / `no_index` / `nofile`。**这是"推送成功"的判据**:
只看文件在不在是不够的 —— 后面的步骤要在相册里点它。
"""
size = _remote_size(_sh(d, f"ls -l '{remote}'"))
if size is None:
return "nofile", "手机上找不到这个文件"
return scan_media(d, remote)
def clean_phone_video(d, remote):
"""删手机上的素材(**精确路径**,绝不通配 —— 通配会删掉用户自己拍的东西)。"""
if not remote:
return False
try:
_sh(d, f"rm -f '{remote}'")
if _remote_size(_sh(d, f"ls -l '{remote}'")) is not None:
_log.warning(f"手机上的素材没删掉: {remote}")
return False
return True
except Exception as e:
_log.warning(f"删手机素材异常(不影响发布结论): {e}")
return False
def _first_album_item(d):
"""点相册/作品网格里的**第一个**缩略图(抖音按时间倒序,最新的在最前)。
不用固定坐标:dump 出网格里的图片单元,按 (y, x) 排序取第一个 ——
分辨率/刘海不同的机器都能用。仅用于**抓分享链接**(进「我」的作品第一条)。
"""
try:
xml = d.dump_hierarchy()
except Exception as e:
return False, f"dump 失败: {e}"
cells = []
for m in re.finditer(r"<node[^>]*>", xml):
tag = m.group(0)
def g(k):
mm = re.search(k + r'="([^"]*)"', tag)
return mm.group(1) if mm else ""
bounds = g("bounds")
mb = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
if not mb:
continue
x0, y0, x1, y1 = (int(v) for v in mb.groups())
if y0 < 200 or (y1 - y0) < 120 or (x1 - x0) < 120:
continue
cells.append((y0, x0, (x0 + x1) // 2, (y0 + y1) // 2))
if not cells:
return False, "没找到缩略图"
cells.sort()
d.click(cells[0][2], cells[0][3])
return True, f"点了第一个缩略图 ({cells[0][2]},{cells[0][3]})"
def _click_any(d, sels, timeout=3):
"""按顺序试几个选择器(形如 "text=分享" / "descriptionContains=分享"),点中即返回。"""
for sel in sels:
try:
key, val = sel.split("=", 1)
el = d(**{key: val})
if el.exists(timeout=timeout):
el.click()
return True, sel
except Exception:
continue
return False, ""
def capture_share_link(d, serial, timeout=30):
"""抓作品的分享链接:我 → 作品第一条 → 分享 → 复制链接 → 读剪贴板。
返回 (url, msg)。**抓不到不算失败**:链接空着,界面上标"缺链接"、素材文件不删。
⚠ 这一段仍是抖音 UI 流程,**按文字定位、需真机确认**;抖音改版就走不通。
"""
from core.video_plan import extract_share_url
from core.clipboard_helper import inject_clipboard
try:
_click_any(d, ["descriptionContains=我", "text=我"], timeout=4)
time.sleep(2)
ok, msg = _first_album_item(d)
if not ok:
return "", f"进作品页失败: {msg}"
time.sleep(2.5)
ok, sel = _click_any(d, ["descriptionContains=分享", "text=分享"], timeout=4)
if not ok:
return "", "没找到「分享」按钮"
time.sleep(1.5)
ok, sel = _click_any(d, ["text=复制链接", "text=复制"], timeout=4)
if not ok:
return "", "分享面板里没找到「复制链接」"
time.sleep(1.2)
try:
got = d.clipboard
except Exception as e:
return "", f"读剪贴板失败: {e}"
url = extract_share_url(got or "")
return (url, "ok") if url else ("", f"剪贴板里没有抖音链接: {str(got)[:60]!r}")
except Exception as e:
return "", f"抓链接异常: {type(e).__name__}: {str(e)[:100]}"
# ================== 编排 ==================
def push_one(serial, plan, opts=None, log=None):
"""把一条计划的素材推到手机(**不发布**)。返回 (ok, msg)。
做四件事:原子占位(防多设备/重跑重复推)→ 推文件 → 触发相册刷新 →
(可选)把标题写进手机剪贴板,供你后面画的「粘贴」步骤直接用。
占位成功后把 `stage` 置成 `post` —— 意思是"**后面的事不归平台管了**":
再出岔子(比如你标记失败)按 `unknown` 处理,而不是"可安全重试",
避免任务把一条你其实已经发出去的计划又推一遍、又发一遍。
"""
opts = opts or {}
log = _log_of(log)
import uiautomator2 as u2
from core import video_plan as vp
plan_id = plan["id"]
if not vp.claim(plan_id, status=vp.ST_PUSHING, stage=vp.STAGE_MANUAL):
return False, "这条已被别的任务/设备领走了(跳过)"
d = None
try:
local = vp._video_path(plan.get("video_file"))
if not local or not os.path.exists(local):
vp.mark_failed(plan_id, "push", "平台上的素材文件不在了(可能已被清理,要发请重传视频)")
return False, "平台素材缺失"
d = u2.connect(serial)
remote, err = push_video(d, serial, local, plan_id,
album_dir=opts.get("album_dir") or ALBUM_DIR)
if err:
vp.mark_failed(plan_id, "push", err)
return False, err
log(f"素材已推到手机: {remote}")
verify, vmsg = verify_pushed(d, remote)
if verify == "nofile":
# 文件都不在 → 真失败(可重试),别往后走
vp.mark_failed(plan_id, "push", vmsg)
return False, f"推送校验失败:{vmsg}"
log(f"推送校验:{'✅ ' + vmsg if verify == 'ok' else '⚠ ' + vmsg}")
title = (plan.get("title") or "").strip()
if opts.get("to_clipboard", True) and title:
try:
from core.clipboard_helper import inject_clipboard
okc, msgc = inject_clipboard(serial, title, d=d)
log(f"标题已写进剪贴板({'成功' if okc else '失败: ' + msgc})"
f"—— 后面用「粘贴」步骤就能填进抖音")
except Exception as e:
log(f"标题写剪贴板失败(不影响推送): {e}")
vp.mark_pushed(plan_id, remote=remote, verify=verify)
return True, (f"已推送到手机:{remote}" if verify == "ok"
else f"已推送到手机:{remote}(⚠ {vmsg})")
except Exception as e:
vp.mark_failed(plan_id, "push", f"异常: {type(e).__name__}: {str(e)[:150]}")
return False, f"推送异常: {type(e).__name__}"
finally:
try:
row = vp.get_plan(plan_id)
if row and row["status"] == vp.ST_PUSHING and not row.get("stage") == vp.STAGE_MANUAL:
vp.mark_failed(plan_id, "push", "执行中断(兜底降级)")
except Exception:
pass
def run_push(serial, plan_id, opts=None):
"""给计划页「推送到手机」按钮用的入口(在后台线程里跑)。"""
from core import video_plan as vp
plan = vp.get_plan(plan_id)
if not plan:
_log.warning(f"推送:计划 {plan_id} 不存在")
return False, "计划不存在"
return push_one(serial, plan, opts)
+292 -18
View File
@@ -102,7 +102,8 @@ STEP_TYPES = [
{"type": "wait_el", "label": "等待元素", "icon": "⏳",
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 10}},
{"type": "input_text", "label": "输入文字", "icon": "⌨",
"params": {"mode": "random", "texts": "你好\n有趣\n支持", "fixed_text": "", "clear_first": True}},
"params": {"mode": "random", "texts": "你好\n有趣\n支持", "fixed_text": "",
"clear_first": True, "text_source": "manual", "release_topics": ""}},
{"type": "clipboard", "label": "剪贴板注入", "icon": "📋",
"params": {"text": "", "paste": True}},
{"type": "wait", "label": "等待", "icon": "⏱",
@@ -118,6 +119,14 @@ STEP_TYPES = [
"then": [], "else": []}},
{"type": "mark_done", "label": "记为已做", "icon": "✅",
"params": {"selector_type": "text", "selector_value": ""}},
{"type": "push_release", "label": "推送发布视频", "icon": "📤",
"params": {"date_source": "today", "fixed_date": "",
"account": "device", "account_phone": "",
"album_dir": "/sdcard/DCIM/Camera", "to_clipboard": True,
"retry_failed": True, "max_same_run": 1}},
{"type": "mark_release", "label": "标记发布结果", "icon": "🏁",
"params": {"result": "published", "why": "", "capture_link": True,
"delete_phone": True, "album_dir": "/sdcard/DCIM/Camera"}},
{"type": "notify", "label": "发通知", "icon": "🔔",
"params": {"title": "", "message": "", "level": "info"}},
{"type": "stop_self", "label": "停止本设备", "icon": "⛔",
@@ -234,6 +243,7 @@ class GenericStepsWorker(BaseWorker):
# 「去重」条件最近一次解析出的身份:`mark_done` 留空时复用它,
# 保证"检查"与"记账"用的是**同一个身份字符串**(否则 key 对不上、去重失效)
self._last_ident = ""
self._last_release = None # 「推送发布视频」推的那条计划,供「标记发布结果」用
self._dedup_seen = False # 本轮是否跑过「去重」检查(区分"没配"与"配了但没读到")
# 公共巡检(任务级配置,独立于步骤画布;见 core/patrol.py):
# _wid 内部标识,用来记"下次到点 / 上次命中"两个节奏
@@ -824,24 +834,217 @@ class GenericStepsWorker(BaseWorker):
"""
source = str(params.get("cmp_source") or "").strip()
manual = split_cmp_values(manual_raw)
if source not in ("device", "all", "group"):
if source == "release":
# 「发布计划」:本机**下一条将要推送**的那条计划(推送前校验账号就靠它)。
# 候选值给两个:抖音号(纯号)+ 账号名称 —— 元素原文可能是
# `抖音号:35377983067`,也可能只显示昵称,两个都可命中(OR 语义)。
ids, note = self._release_candidates()
elif source in ("device", "all", "group"):
ids, note = ledger.douyin_ids(
source, serial=self.serial,
device_name=(self.ctx or {}).get("device_name", ""),
group=str(params.get("cmp_group") or "").strip())
else:
return manual_raw, "" # 手填(默认):行为与以前完全一致
ids, note = ledger.douyin_ids(
source, serial=self.serial,
device_name=(self.ctx or {}).get("device_name", ""),
group=str(params.get("cmp_group") or "").strip())
merged = list(ids) + [v for v in manual if v not in ids]
if not ids:
_log.warning(
f"[{self.serial}] 条件判断取号为空(来源 {source}):{note};手填候选 "
f"{len(manual)} 个 → "
+ ("仅用手填值" if manual else "候选为空,这一步将永远不命中(走未找到分支)"))
src_txt = (f"候选来自台账({source}) {len(ids)} 个号" if ids
else f"台账取号为空:{note}")
where = "发布计划" if source == "release" else f"台账({source})"
src_txt = (f"候选来自{where} {len(ids)} 个号" if ids
else f"{where}取号为空:{note}")
if manual:
src_txt += f",另加手填 {len(manual)} 个"
return "\n".join(merged), src_txt
def _release_targets(self, params):
"""推送/标记步骤共用的"取本机账号手机号"逻辑。返回 (phones, who)。"""
from core import ledger
phone = (params.get("account_phone") or "").strip()
if (params.get("account") or "device") == "phone" and phone:
return [phone], f"指定手机号 {phone}"
_, rows, note = ledger.resolve_device(
serial=self.serial, device_name=(self.ctx or {}).get("device_name", ""))
return [r.get("phone") for r in rows if r.get("phone")], f"本机账号({note})"
def _release_candidates(self):
"""条件判断的「候选值来源 = 发布计划」:本机要发的那条计划的 **抖音号 + 账号名称**。
优先用本次运行里「推送发布视频」已经拿到的那条(`_last_release`);
还没有就**看**下一条(peek,不占位)—— 这正是"推送前先校验账号对不对"的用法:
命中(当前登录的就是要发的那个号)→ 走 then 去推送/发布;未命中 → else 跳过并通知。
给两个候选值是刻意的:元素原文可能是 `抖音号:35377983067`,也可能只显示昵称,
两个都算命中(候选值之间是 OR)。配「包含」运算符即可。
"""
from core import video_plan as vp
plan = getattr(self, "_last_release", None)
src = "本次已推送的那条"
if not plan:
phones, _who = self._release_targets({})
plan = vp.peek_for_device(phones, vp._today()) if phones else None
src = "本机下一条待发"
if not plan:
return [], "本机没有待发的发布计划"
cands = [c for c in (plan.get("douyin_id"), plan.get("nickname")) if c]
note = (f"{src}计划的抖音号 {plan.get('douyin_id')}"
f"({plan.get('nickname') or plan.get('phone')} "
f"{plan.get('release_date')} #{plan.get('seq')})")
return cands, note
def _exec_push_release(self, d, params, depth=0):
"""推送发布视频:把本机某天待发的计划**推到手机相册**(**不发布**)。
**平台只负责到这里**:推文件 + 触发相册刷新 + 把标题写进剪贴板;
抖音里怎么发(打开→点+→相册→选视频→下一步→粘贴标题→发布)由你在这条步骤
**后面自己画步骤**,最后放一个「标记发布结果」回写状态。
参数:`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 —— 一次运行推几条;**多条时「标记」只认最后一条**,要发多条就重复"推送+标记"两步)
返回 None=本机没有待推的(跳过);True/False=至少推成一条/全失败。
"""
from tasks.generic import publish_flow as flow
from core import video_plan as vp
date = ((params.get("fixed_date") or "").strip()
if params.get("date_source") == "fixed" else vp._today()) or vp._today()
phones, who = self._release_targets(params)
if not phones:
_log.warning(f"[{self.serial}] 推送发布视频:本机没有台账账号 → 跳过")
self.set_action("本机没有台账账号 → 跳过")
return None
plans = vp.take_for_device(phones, date,
limit=int(params.get("max_same_run") or 1),
retry_failed=params.get("retry_failed", True) is not False)
if not plans:
_log.info(f"[{self.serial}] 推送发布视频:{who} 在 {date} 没有待推送的计划 → 跳过")
self.set_action(f"{date} 没有待推送计划")
return None
opts = {"album_dir": (params.get("album_dir") or "").strip() or flow.ALBUM_DIR,
"to_clipboard": params.get("to_clipboard", True) is not False}
okN = badN = 0
for plan in plans:
if self.stopped():
break
self._last_release = plan # 供后面的「标记发布结果」用
self.set_action(f"推送中:{plan.get('nickname') or plan['phone']} "
f"{plan['release_date']} #{plan['seq']}")
ok, msg = flow.push_one(self.serial, plan, opts,
log=lambda s: _log.info(f"[{self.serial}] {s}"))
if ok:
okN += 1
# 相册校验结果**单独说一句**:文件上去了 ≠ 相册里点得到它,
# 后面那步"点相册里第一个"靠的就是索引(不写这句,出了问题只能猜)
row = vp.get_plan(plan["id"]) or {}
if row.get("push_verify") == "no_index":
_log.warning(f"[{self.serial}] ⚠ 素材推上去了但**没进相册索引**"
f"(抖音选视频时可能看不到它)")
else:
badN += 1
self._notify_publish(plan, ok=False, msg=msg)
if okN and self._last_release:
lr = self._last_release
title = (lr.get("title") or "")[:20]
self.set_action(f"已推送到手机:{lr.get('nickname') or lr.get('phone')} "
f"{lr['release_date']} #{lr['seq']}"
+ (f"(标题『{title}』已进剪贴板)" if title else "")
+ ";发完记得放「标记发布结果」")
_log.info(f"[{self.serial}] 已推到手机,标题『{title}』已进剪贴板 —— "
f"后面用「粘贴」把它填进抖音,发完记得放一个「标记发布结果」")
else:
self.set_action(f"推送发布视频:成功 {okN} 条"
+ (f",失败 {badN} 条" if badN else ""))
return okN > 0 if (okN or badN) else None
def _exec_mark_release(self, d, params, depth=0):
"""标记发布结果:把"发出去了没有"回写到计划行(**你自己发布流程的最后一步**)。
参数:`result` = `published`(发出去了) / `failed`(没发出去,可重试) / `unknown`(不确定)
· `why` 失败原因(可选)· `capture_link`(True) 成功时顺带抓作品分享链接
· `delete_phone`(True) 成功/失败后删掉手机上的素材
放在你画布的**成功分支**里填 `published`、失败分支里填 `failed`;
拿不准有没有发出去就填 `unknown`(平台不会自动重发它)。
返回 True=标记成功 / False=发布失败 / None=找不到对应的计划(跳过)。
"""
from tasks.generic import publish_flow as flow
from core import video_plan as vp
import uiautomator2 as u2
plan = getattr(self, "_last_release", None)
if not plan:
phones, _who = self._release_targets(params)
plan = vp.last_pushed_for_device(phones) if phones else None
if not plan:
_log.warning(f"[{self.serial}] 标记发布结果:找不到对应的计划行 → 跳过"
f"(前面要先有一个「推送发布视频」步骤)")
self.set_action("没有可标记的计划")
return None
result = (params.get("result") or "published").strip().lower()
if result not in ("published", "failed", "unknown"):
_log.warning(f"[{self.serial}] 标记发布结果:result={result!r} 认不出 → 按 unknown 处理")
result = "unknown"
why = (params.get("why") or "").strip()
# 手机上的素材:先删再回写状态 —— 删失败不影响发布结论。
# 远程路径是**确定的**({相册目录}/{计划id}.mp4),所以这里能直接算出来,
# 不用把"上次推到哪"存进库(存了还得担心不一致)
if params.get("delete_phone", True) is not False:
try:
dd = d or u2.connect(self.serial)
album = (params.get("album_dir") or "").strip() or flow.ALBUM_DIR
flow.clean_phone_video(dd, f"{album}/{plan['id']}.mp4")
except Exception as e:
_log.warning(f"[{self.serial}] 删手机素材失败(不影响标记): {e}")
if result == "published":
url = ""
if params.get("capture_link", True) is not False:
try:
dd = d or u2.connect(self.serial)
url, lmsg = flow.capture_share_link(dd, self.serial)
_log.info(f"[{self.serial}] 抓分享链接:{url or ('失败:' + lmsg)}")
except Exception as e:
_log.warning(f"[{self.serial}] 抓分享链接异常: {e}")
vp.mark_done(plan["id"], share_url=url, msg=why)
self._notify_publish(dict(plan, share_url=url), ok=True, msg=why or "已标记发布成功")
self.set_action(f"已标记发布成功({'链接 ' + url if url else '没抓到链接'})")
return True
if result == "failed":
# **确认没发出去** → 直接落 `failed`(可重试),不能走 mark_failed('post')
# —— 那会落到"结果未知"、反而不敢重试。这里人是亲眼确认过的。
vp.set_status(plan["id"], vp.ST_FAILED, note=why or "人工标记:没发出去")
self._notify_publish(plan, ok=False, msg=why or "人工标记:没发出去")
self.set_action("已标记失败(下次任务会再推一次)")
return False
vp.mark_failed(plan["id"], "verify", why or "人工标记:结果不确定")
self._notify_publish(plan, ok=False, msg=why or "人工标记:结果不确定")
self.set_action("已标记「结果未知」(不会自动重发,需人工确认)")
return False
def _notify_publish(self, plan, ok, msg):
"""发布结果推通知(notify 零 DB:字段都在计划行里,不查库)。"""
try:
notifier.notify("task.video.published" if ok else "task.video.failed",
serial=self.serial,
device_name=(self.ctx or {}).get("device_name", ""),
model=(self.ctx or {}).get("model", ""),
phone=plan.get("phone", ""),
nickname=plan.get("nickname", ""),
release_date=plan.get("release_date", ""),
seq=plan.get("seq", 0),
title=(plan.get("title") or "")[:60],
share_url=plan.get("share_url", ""),
msg=msg)
except Exception as e:
_log.warning(f"发布通知发送失败(不影响发布):{e}")
def _dedup_policy(self):
"""任务级去重有效期(`dedup_reset` / `dedup_hours`)→ (kind, hours)。
@@ -1168,17 +1371,46 @@ class GenericStepsWorker(BaseWorker):
需先用"点击元素"步骤定位到输入框,本步骤只负责输入。
mode=random: 从 texts(换行分隔)随机选一条
mode=fixed: 输入 fixed_text
**`text_source`(可选):文字从哪来**
· `manual`(默认)= 用上面的 mode(行为与以前完全一致)
· `release_title` = **本机当前那条发布计划的标题**("推送发布视频"推的那条;
本机没有就取最近一条已推送的)· 配 `release_topics` 还能自动加话题
选发布计划来源时**会回读校验**(读输入框的 text):不校验的后果是
"抖音不吃 send_keys → 发出一篇空文案,而每一步都显示成功";
校验不过就自动改走剪贴板通道粘贴(设备端 Agent)再验一次。
"""
mode = params.get("mode", "random")
if mode == "fixed":
text = params.get("fixed_text", "")
else:
raw = params.get("texts", "")
candidates = [t.strip() for t in raw.splitlines() if t.strip()]
if not candidates:
_log.warning("input_text 无候选文字")
return
text = random.choice(candidates)
src = str(params.get("text_source") or "manual").strip()
text = ""
if src in ("release_title", "release_title_topics"):
from core import video_plan as vp
plan = getattr(self, "_last_release", None)
if not plan:
phones, _who = self._release_targets(params)
plan = vp.last_pushed_for_device(phones) if phones else None
if plan:
text, _err = vp.build_caption(
plan, params.get("release_topics") or ""
if src == "release_title_topics" else "")
self._last_release = self._last_release or plan
_log.info(f"[{self.serial}] 输入文字取计划标题:"
f"{plan.get('nickname') or plan.get('phone')} "
f"{plan.get('release_date')} #{plan.get('seq')}")
if not text:
_log.warning(f"[{self.serial}] 输入文字:本机没有可用的发布计划标题 → 跳过"
f"(前面要先有「推送发布视频」步骤)")
return None
if not text:
mode = params.get("mode", "random")
if mode == "fixed":
text = params.get("fixed_text", "")
else:
raw = params.get("texts", "")
candidates = [t.strip() for t in raw.splitlines() if t.strip()]
if not candidates:
_log.warning("input_text 无候选文字")
return
text = random.choice(candidates)
if not text:
return
clear_first = params.get("clear_first", True)
@@ -1199,6 +1431,48 @@ class GenericStepsWorker(BaseWorker):
_log.warning(f"[{self.serial}] input_text 未找到输入框: {e}")
except Exception as e2:
_log.warning(f"[{self.serial}] input_text 失败: {e2}")
# 取计划标题时**必须回读校验**:抖音的 EditText 有时不吃 send_keys,
# 不校验就可能发出一篇空文案而每一步都显示成功
if src in ("release_title", "release_title_topics"):
return self._verify_input(d, text, clear_first)
return None
def _verify_input(self, d, text, clear_first):
"""回读输入框,确认文字真的进去了;不行就改走剪贴板通道再验一次。"""
def _read():
try:
el = d(className="android.widget.EditText")
if el.exists:
return el.get_text(timeout=2) or ""
except Exception:
pass
return ""
for attempt in (1, 2):
got = _read()
if got and text[:12] in got:
return True
_log.warning(f"[{self.serial}] 文案回读不一致(第 {attempt} 次):"
f"期望『{text[:20]}…』读到『{got[:20]}』")
if attempt == 1:
# 第 2 次改走剪贴板通道(设备端 Agent 写剪贴板 + 粘贴),中文不会变形
try:
from core.clipboard_helper import inject_clipboard
okc, msgc = inject_clipboard(self.serial, text, d=d)
if okc:
d.jsonrpc.pasteClipboard()
time.sleep(1)
else:
_log.warning(f"[{self.serial}] 剪贴板兜底注入失败: {msgc}")
except Exception as e:
_log.warning(f"[{self.serial}] 剪贴板兜底异常: {e}")
time.sleep(1)
got = _read()
if got and text[:12] in got:
return True
_log.warning(f"[{self.serial}] ⚠ 文案没填进去(读回『{got[:30]}』)——"
f"接着发布就会发出空文案,这一步判失败")
return False
@register_task