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:
+79
-20
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user