feat(发布计划): 素材支持图片(图文)—— 上传视频/图片,发布流程共用一条 + 切号健壮性再修

用户实测反馈:"图片流程一样,现在也是点第一个" → 图片不做单独一套流程,只放开上传侧;
扩展名一路原样带到手机上(抖音按扩展名/MIME 判断视频还是图文,图片推成 .mp4 会变坏视频)。

· core/video_plan.py:VIDEO_EXTS + IMAGE_EXTS(jpg/jpeg/png/webp/heic) → MEDIA_EXTS;
  报错文案改"不是视频/图片文件"
· tasks/generic/publish_flow.push_video:ext 默认**从本地文件名取**(不再硬编码 .mp4);
  drop_old_copies 收到真实扩展名
· tasks/generic/task.py:删手机素材优先用推送时记下的 push_remote,其次按素材扩展名现算;
  切号:等面板开稳再点(刚出现就点会被入场动画吞掉)、点完面板还开着(点的就是当前号)
  就先关面板、一切以**回读**为准、回读窗口 12s→30s(换号会触发抖音整体重启,实测 77s 才回来);
  失败时日志附带"当时屏幕上是什么"(亮屏/前台包/屏上文字)—— "点了没反应"一眼可判
· 骨架:先 stop_app 冷启动(抖音可能停在发布页/别的视频页,那种页面上没有「我」页签,
  切号第一步就废);「校验账号」的选择器与「读名字」统一从 .env 取(漏了这一步会静默跳过
  整个发布流程 —— 用户实际遇到的"直接完成、没发布"就是这个)
· 界面:上传视频 → 上传视频/图片,accept 加 image/*,明细表"视频文件"→"素材文件",help 文案
· 文档:TASK_DEV §4.7 / API / DATA_MODEL / ARCHITECTURE / README 同步
· 测试:解析用例补图片(jpg/png/webp/大写 JPG 认,gif 不收)+ 改报错文案期望
This commit is contained in:
2026-10-12 10:13:49 +08:00
parent 1e8b97e386
commit 1b65ffe089
13 changed files with 167 additions and 65 deletions
+8 -3
View File
@@ -75,13 +75,17 @@ def _sh(d, cmd):
# ================== 原语 ==================
def push_video(d, serial, local_path, plan_id, album_dir=ALBUM_DIR, ext=".mp4"):
def push_video(d, serial, local_path, plan_id, album_dir=ALBUM_DIR, ext=""):
"""把平台素材推到手机相册目录,并把手机上的时间改成"现在"。返回 (remote_path, error)。
改时间是为了**排序**:相册/抖音的选视频页按时间倒序,
改时间是为了**排序**:相册/抖音的选素材页按时间倒序,
`adb push` 保留的是**本地文件的修改时间**(推一个 3 天前上传的素材 → 排在很后面),
那"点第一个缩略图 = 刚推的那个"就不成立、可能点到别的视频。所以推完 `touch` 一下。
那"点第一个缩略图 = 刚推的那个"就不成立、可能点到别的素材。所以推完 `touch` 一下。
`ext` 默认**从本地文件名上取**(视频/图片各带各的扩展名)—— 抖音是按扩展名/MIME
判断这是视频还是图片的,把图片推成 `xxx.mp4`,相册里就成了一段坏视频。
"""
ext = (ext or os.path.splitext(local_path)[1] or ".mp4").lower()
remote = f"{album_dir}/{plan_id}{ext}"
try:
local_size = os.path.getsize(local_path)
@@ -178,6 +182,7 @@ def scan_media(d, remote):
def drop_old_copies(d, plan_id, keep, ext=".mp4"):
"""(`ext` 由 `push_video` 按素材真实扩展名传进来:视频/图片各删各的旧副本)"""
"""把**同一个计划**在平台用过别的目录里的旧副本删掉(精确文件名,绝不通配)。
为什么要删:换了推送目录之后,老目录里的同名副本还在相册里(实测一个 plan id 躺了 3 份),
+79 -20
View File
@@ -32,6 +32,7 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
loop - 循环块(含 children 步骤列表 + max_iterations)
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
"""
import os
import random
import re
import time
@@ -1077,21 +1078,56 @@ class GenericStepsWorker(BaseWorker):
_log.warning(f"[{self.serial}] 点击失败({sel_type}={sel_val}): {e}")
return False
def _find_in_list(self, d, name, timeout=1):
"""账号列表里**有没有**这个名字的控件(只判断,不点)。返回那个 UiObject 或 None。"""
for kw in ({"textContains": name}, {"descriptionContains": name}, {"text": name}):
try:
obj = d(**kw)
if obj.exists(timeout=timeout):
return obj
except Exception as e:
_log.debug(f"[{self.serial}] 列表里按 {kw} 找『{name}』失败: {e}")
return None
def _click_in_list(self, d, name, timeout=4):
"""在账号列表里**按名字**找控件并点击(文本优先,再试描述/精确文本)。
⚠ **绝不用 `[N]` 序号**:`doc/research/U2_ELEMENT_SELECTORS.md` 实测序号型会随界面
变形错位 —— 多点一个就是把内容发到别人号上。
"""
for kw in ({"textContains": name}, {"descriptionContains": name}, {"text": name}):
try:
obj = d(**kw)
if obj.exists(timeout=timeout):
obj.click()
return True
except Exception as e:
_log.debug(f"[{self.serial}] 列表里按 {kw} 找『{name}』失败: {e}")
return False
obj = self._find_in_list(d, name, timeout)
if obj is None:
return False
try:
obj.click()
return True
except Exception as e:
_log.warning(f"[{self.serial}] 点列表里的『{name}』失败: {e}")
return False
def _screen_digest(self, d, limit=6):
"""给日志用的一小段"现在屏幕上有什么"。
「点了没反应」这类故障,光看"找不到元素"没法判:是息屏了、跳到别的页面了、
还是界面还没加载出来 —— 把当时屏上的头几个字打出来,一眼能分辨。
"""
try:
xml = d.dump_hierarchy()
except Exception as e:
return f"(读元素树失败:{type(e).__name__})"
vals = []
for m in re.finditer(r'(?:text|content-desc)="([^"]{1,24})"', xml):
v = m.group(1).strip()
if v and v not in vals:
vals.append(v)
if len(vals) >= limit:
break
on = ""
try:
on = "亮屏" if screen_is_on(d) else "**息屏/锁屏**"
except Exception:
pass
return f"{on} 前台={current_package(d) or '未知'} 屏上:{'、'.join(vals) or '(空)'}"
def _switch_target(self, params):
"""这一步切到哪个名字:`var:target_name`(默认,循环块设的目标)/
@@ -1175,20 +1211,36 @@ class GenericStepsWorker(BaseWorker):
hint = self._page_has_text(d, self._LOGIN_HINTS)
if hint:
return self._handle_login_page(target, hint, params)
_log.warning(f"[{self.serial}] 切换账号:进不去账号列表(第 {i}/{attempts} 次)")
_log.warning(f"[{self.serial}] 切换账号:进不去账号列表(第 {i}/{attempts} 次)"
f"—— {self._screen_digest(d)}")
continue
# 等面板真的打开:**轮询找目标那一行,出现就点**。
# 固定 sleep 不够用(实测:冷启动/慢设备上点了切换入口后列表还没渲染出来,
# 一次找不到就误判成"本机没登这个号" —— 那是会漏发的假故障)。
# 先**只等**面板开稳(等目标那一行出现,不点):实测"刚出现就点"会被
# 面板的入场动画**吞掉** —— 号没切成、面板还开着,第二轮又变成"进不去列表"。
hit = False
deadline = time.time() + max(6.0, timeout)
while time.time() < deadline:
if self.stopped():
return False
if self._click_in_list(d, target, timeout=1):
if self._find_in_list(d, target, timeout=1) is not None:
hit = True
break
time.sleep(0.8)
time.sleep(0.5)
if hit:
time.sleep(0.8) # 等面板动画结束(上面那条注释说的时间点)
# 点一下;点完**面板还开着**就再点一次,仍开着就按回退把面板关掉。
# 为什么要关:**点的如果正好是当前号,抖音什么都不做、面板也不关** ——
# 那种情况下"面板还开着"不代表失败,得回到「我」页用**回读**来判。
self._click_in_list(d, target, timeout=2)
time.sleep(1.5)
if self._find_in_list(d, target, timeout=1) is not None:
self._click_in_list(d, target, timeout=2)
time.sleep(1.5)
if self._find_in_list(d, target, timeout=1) is not None:
try:
d.press("back") # 关掉面板,露出「我」页
except Exception:
pass
time.sleep(1.5)
if not hit:
hint = self._page_has_text(d, self._LOGIN_HINTS)
if hint:
@@ -1202,9 +1254,12 @@ class GenericStepsWorker(BaseWorker):
if not want_verify:
self.set_action(f"已点击『{target}』(未校验)")
return True
# 等切换生效:抖音会重载「我」页面,几秒内名字才会变 —— 同样轮询,别只读一次
# 等切换生效:切换会触发抖音**整体重启**(等于换了个号重新登录),十几秒才回到
# 「我」页是正常的(实测:整步跑了 77s,其中大部分就是在等它重启完)。而且重启后
# 落的地方**可能是信息流**(不是「我」页)—— 所以窗口放到 30s,并且**读不到就重新点
# 一次「我」**再读(踩过两次:窗口太短 → 明明切成功却判失败 → 整轮什么都不发)。
last_got = ""
vdeadline = time.time() + max(6.0, timeout)
vdeadline = time.time() + max(30.0, timeout)
while time.time() < vdeadline:
if self.stopped():
return False
@@ -1214,6 +1269,8 @@ class GenericStepsWorker(BaseWorker):
self.set_action(f"已切到『{target}』")
self._vars["account_name"] = target
return True
if not last_got:
self._click_sel(d, me_type, me_val, timeout=1)
time.sleep(0.8)
hint = self._page_has_text(d, self._LOGIN_HINTS) if not last_got else ""
if hint:
@@ -1337,13 +1394,15 @@ class GenericStepsWorker(BaseWorker):
why = (params.get("why") or "").strip()
# 手机上的素材:先删再回写状态 —— 删失败不影响发布结论。
# 远程路径是**确定的**({相册目录}/{计划id}.mp4),所以这里能直接算出来,
# 不用把"上次推到哪"存进库(存了还得担心不一致)
# 路径优先用**推送时记下的** `push_remote`(视频/图片扩展名不同,不能靠猜),
# 没有就按 `{相册目录}/{计划id}{素材自己的扩展名}` 现算。
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")
ext = os.path.splitext(plan.get("video_file") or "")[1] or ".mp4"
remote = (plan.get("push_remote") or "").strip() or f"{album}/{plan['id']}{ext}"
flow.clean_phone_video(dd, remote)
except Exception as e:
_log.warning(f"[{self.serial}] 删手机素材失败(不影响标记): {e}")