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
+1 -1
View File
@@ -53,7 +53,7 @@
| **动作配置 / 录制** | 「任务 → 动作配置」:① 配**步骤默认值**(新建步骤预填:滑动时长/幅度/抖动/拟人、点击超时、等待区间…)② **录制动作**——在设备画面上点/划/按键,自动翻译成步骤(点元素记选择器、划一下记方向/幅度/时长),可存成可复用动作,也可只取一个手势回填滑动步骤 | | **动作配置 / 录制** | 「任务 → 动作配置」:① 配**步骤默认值**(新建步骤预填:滑动时长/幅度/抖动/拟人、点击超时、等待区间…)② **录制动作**——在设备画面上点/划/按键,自动翻译成步骤(点元素记选择器、划一下记方向/幅度/时长),可存成可复用动作,也可只取一个手势回填滑动步骤 |
| **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) | | **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) |
| **去重账本** | 解决"**同一个号被做两次、有的号还没做**":任务里用「条件判断→去重」+「记为已做」,把"这个身份做过了"记进一张**所有设备共享**的账本——多台手机、反复重跑都不会重复做同一个号。「任务 → 去重记录」页能看"谁做过了、还差谁",也能删记录让它重跑(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.6) | | **去重账本** | 解决"**同一个号被做两次、有的号还没做**":任务里用「条件判断→去重」+「记为已做」,把"这个身份做过了"记进一张**所有设备共享**的账本——多台手机、反复重跑都不会重复做同一个号。「任务 → 去重记录」页能看"谁做过了、还差谁",也能删记录让它重跑(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.6) |
| **视频发布计划** | 「发布计划」页:批量上传视频(文件名 `名字_日期_编号`,**名字 = 抖音昵称**)与标题 txt(每行 `标题 || 正文_名字_日期_编号`)→ 自动按 (名字,日期,编号) 配对 → **按日期的时间线**看每天谁要发、发到哪一步。**平台只负责把素材推到手机**(推到 `/sdcard/DCIM/Camera/`、触发相册刷新、把标题写进剪贴板),**抖音里怎么发由你自己在任务里写步骤**(步骤顺序:`读取本机账号名字` → `条件判断`校验 → `推送发布视频` → 你的发布步骤 → `标记发布结果`;页顶部有**「发布任务」**块可一键建好骨架 + 看下次运行/启停,`输入文字` 那一步可以选**取值来源=计划标题/正文**自动填文案并回读校验);发完可抓作品的**分享链接**存下来(平台不长期囤视频,链接才是长期资产,也方便后续铺评论:可一键复制、按日期/名字导出 CSV)。状态区分"**可重试**"(推送阶段失败,还没到抖音)与"**结果未知、需人工确认**"(推送之后出的岔子,绝不自动重发)(见 [DATA_MODEL.md](doc/DATA_MODEL.md) §2.11、[TASK_DEV.md](doc/TASK_DEV.md) §4.7) | | **视频发布计划** | 「发布计划」页:批量上传**视频/图片**(文件名 `名字_日期_编号`,**名字 = 抖音昵称**;视频和图片同一套规则)与标题 txt(每行 `标题 || 正文_名字_日期_编号`)→ 自动按 (名字,日期,编号) 配对 → **按日期的时间线**看每天谁要发、发到哪一步。**平台只负责把素材推到手机**(推到 `/sdcard/DCIM/Camera/`、触发相册刷新、把标题写进剪贴板),**抖音里怎么发由你自己在任务里写步骤**(步骤顺序:`读取本机账号名字` → `条件判断`校验 → `推送发布视频` → 你的发布步骤 → `标记发布结果`;页顶部有**「发布任务」**块可一键建好骨架 + 看下次运行/启停,`输入文字` 那一步可以选**取值来源=计划标题/正文**自动填文案并回读校验);发完可抓作品的**分享链接**存下来(平台不长期囤视频,链接才是长期资产,也方便后续铺评论:可一键复制、按日期/名字导出 CSV)。状态区分"**可重试**"(推送阶段失败,还没到抖音)与"**结果未知、需人工确认**"(推送之后出的岔子,绝不自动重发)(见 [DATA_MODEL.md](doc/DATA_MODEL.md) §2.11、[TASK_DEV.md](doc/TASK_DEV.md) §4.7) |
| **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 | | **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 |
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) | | **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) |
| **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 | | **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 |
+40 -21
View File
@@ -58,7 +58,12 @@ STAGE_STATUS = {"push": ST_FAILED, "scan": ST_FAILED,
"post": ST_UNKNOWN, "verify": ST_UNKNOWN, "post": ST_UNKNOWN, "verify": ST_UNKNOWN,
STAGE_MANUAL: ST_UNKNOWN} STAGE_MANUAL: ST_UNKNOWN}
# 素材扩展名:**视频 + 图片**(图片就是抖音的"图文",发布流程一样:相册里点第一个)。
# 扩展名必须一路带到手机上 —— 抖音是按扩展名/MIME 判断这是视频还是图片的,
# 给图片起个 .mp4 的名字,相册里就成了一段坏视频。
VIDEO_EXTS = (".mp4", ".mov", ".mkv", ".avi", ".3gp", ".flv", ".wmv", ".m4v", ".ts", ".webm") VIDEO_EXTS = (".mp4", ".mov", ".mkv", ".avi", ".3gp", ".flv", ".wmv", ".m4v", ".ts", ".webm")
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".heic")
MEDIA_EXTS = VIDEO_EXTS + IMAGE_EXTS
MAX_TITLE_LEN = 100 # 超过就拒收(**不静默截断**:截断会发出错文案,用户看不见) MAX_TITLE_LEN = 100 # 超过就拒收(**不静默截断**:截断会发出错文案,用户看不见)
MAX_BODY_LEN = 1000 # 正文上限(抖音文案框放得下 1000 字;同样不截断,超了拒收) MAX_BODY_LEN = 1000 # 正文上限(抖音文案框放得下 1000 字;同样不截断,超了拒收)
MAX_ATTEMPTS = 3 # 尝试上限,超了不再自动取(人工确认后重试可清零) MAX_ATTEMPTS = 3 # 尝试上限,超了不再自动取(人工确认后重试可清零)
@@ -166,7 +171,7 @@ def name_error(seg):
def parse_video_name(filename): def parse_video_name(filename):
"""视频文件名 `名字_日期_编号` → (meta, error)。 """素材文件名 `名字_日期_编号` → (meta, error)。**视频和图片同一套规则**。
meta = {"name","date","seq"|None,"seq_given"};error 是给人看的拒收原因。 meta = {"name","date","seq"|None,"seq_given"};error 是给人看的拒收原因。
@@ -180,8 +185,9 @@ def parse_video_name(filename):
if not name: if not name:
return None, "文件名为空" return None, "文件名为空"
stem, ext = os.path.splitext(name) stem, ext = os.path.splitext(name)
if ext.lower() not in VIDEO_EXTS: if ext.lower() not in MEDIA_EXTS:
return None, f"不是视频文件(支持 {'/'.join(e.lstrip('.') for e in VIDEO_EXTS)})" return None, (f"不是视频/图片文件(支持 "
f"{'/'.join(e.lstrip('.') for e in MEDIA_EXTS)})")
parts = [p.strip() for p in stem.split("_")] parts = [p.strip() for p in stem.split("_")]
if len(parts) < 2: if len(parts) < 2:
return None, "文件名格式不对,应为 名字_日期_编号(编号可省略,如 小王_20260912.mp4)" return None, "文件名格式不对,应为 名字_日期_编号(编号可省略,如 小王_20260912.mp4)"
@@ -554,6 +560,12 @@ def build_release_steps():
def sid(name): def sid(name):
return f"step_rel_{name}_{uuid.uuid4().hex[:6]}" return f"step_rel_{name}_{uuid.uuid4().hex[:6]}"
def _name_sel():
"""昵称控件的选择器(`config` 里抓一次填一次)—— 「读名字」和「校验账号」
**必须是同一个控件**,否则校验那步会永远不命中(等于跳过整个发布流程)。"""
from config import DOUYIN_NAME_SELECTOR_TYPE, DOUYIN_NAME_SELECTOR_VALUE
return (DOUYIN_NAME_SELECTOR_TYPE or "xpath", DOUYIN_NAME_SELECTOR_VALUE)
def clk(name, label, sel_type="", sel_val=""): def clk(name, label, sel_type="", sel_val=""):
return {"id": sid(name), "type": "click", "label": label, return {"id": sid(name), "type": "click", "label": label,
"params": {"selector_type": sel_type or "xpath", "selector_value": sel_val, "params": {"selector_type": sel_type or "xpath", "selector_value": sel_val,
@@ -565,35 +577,35 @@ def build_release_steps():
# **只用「包含」,绝不能用「不等于」** —— 读不到名字时候选为空, # **只用「包含」,绝不能用「不等于」** —— 读不到名字时候选为空,
# "空 != 候选"会命中,把"读不到"误判成"可以发"。 # "空 != 候选"会命中,把"读不到"误判成"可以发"。
publish_flow_steps = [ publish_flow_steps = [
dict(build_push_step("⑦ 推送发布视频")), dict(build_push_step("⑧ 推送发布视频")),
clk("open", "⑧ 点击抖音底部「+」(实测可用;改版了重新抓)", clk("open", "⑨ 点击抖音底部「+」(实测可用;改版了重新抓)",
"descriptionContains", "拍摄"), "descriptionContains", "拍摄"),
clk("album", "⑨ 点击「相册」(没命中的话用抓取元素选它)"), clk("album", "⑩ 点击「相册」(没命中的话用抓取元素选它)"),
clk("video", "⑩ 点击第一个视频(刚推的排最前;抓一次就行)"), clk("video", "⑪ 点击第一个视频(刚推的排最前;抓一次就行)"),
clk("next", "⑪ 点击「下一步」"), clk("next", "⑫ 点击「下一步」"),
clk("input", "⑫ 点击标题框(抖音发布页的文案框)"), clk("input", "⑬ 点击标题框(抖音发布页的标题框:hint「添加标题」)"),
{"id": sid("title"), "type": "input_text", {"id": sid("title"), "type": "input_text",
"label": "⑬ 填标题(自动取这一条计划的标题,会回读校验)", "label": "⑭ 填标题(自动取这一条计划的标题,会回读校验)",
"params": {"text_source": "release_title", "release_topics": "", "params": {"text_source": "release_title", "release_topics": "",
"clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}}, "clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}},
clk("body", "⑭ 点击正文框(发布页的正文框)"), clk("body", "⑮ 点击正文框(发布页的描述框:hint「添加作品描述」)"),
{"id": sid("bodytxt"), "type": "input_text", {"id": sid("bodytxt"), "type": "input_text",
"label": "⑮ 填正文(自动取这一条计划的正文,会回读校验)", "label": "⑯ 填正文(自动取这一条计划的正文,会回读校验)",
"params": {"text_source": "release_body", "release_topics": "", "params": {"text_source": "release_body", "release_topics": "",
"clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}}, "clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}},
clk("publish", "⑯ 点击「发布」"), clk("publish", "⑰ 点击「发布」"),
dict(build_mark_step("⑰ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")), dict(build_mark_step("⑱ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")),
] ]
# 循环体:切号 → 读名字 → 校验 → 推送 → 发布 → 标记。 # 循环体:切号 → 读名字 → 校验 → 推送 → 发布 → 标记。
# `switch_account` 自己会先看"当前是不是已经是目标"(第一个号不用切), # `switch_account` 自己会先看"当前是不是已经是目标"(第一个号不用切),
# 也会处理"本机没登这个号"(直接跳过、不重试)。 # 也会处理"本机没登这个号"(直接跳过、不重试)。
loop_body = [ loop_body = [
dict(build_switch_step("③ 切换账号(切到本次要发的那个名字;已在该号则直接过)")), dict(build_switch_step("④ 切换账号(切到本次要发的那个名字;已在该号则直接过)")),
dict(build_read_name_step("④ 读取本机当前账号名字")), dict(build_read_name_step("⑤ 读取本机当前账号名字")),
{"id": sid("check"), "type": "if_el", {"id": sid("check"), "type": "if_el",
"label": "⑤ 校验账号:当前登的是不是这次要发的那个名字", "label": "⑥ 校验账号:当前登的是不是这次要发的那个名字",
"params": {"selector_type": "xpath", "params": {"selector_type": _name_sel()[0],
"selector_value": "", "selector_value": _name_sel()[1],
"timeout": 8, "cmp_op": "包含", "cmp_source": "account_target", "timeout": 8, "cmp_op": "包含", "cmp_source": "account_target",
"cmp_value": "", "cmp_group": "", "cmp_value": "", "cmp_group": "",
"ident_type": "text", "ident_value": "", "ident_type": "text", "ident_value": "",
@@ -608,12 +620,19 @@ def build_release_steps():
return [ return [
{"id": sid("light"), "type": "screen_on", "label": "⓪ 亮屏", {"id": sid("light"), "type": "screen_on", "label": "⓪ 亮屏",
"params": {"probability": 100}}, "params": {"probability": 100}},
{"id": sid("openapp"), "type": "open_app", "label": "① 打开抖音(等「首页」出来)", # **先强杀再冷启动**(2026-10-12 实测踩出来的):抖音可能停在**发布页/编辑页/别人的
# 视频页**(上一轮没发出去、或人手动停在那儿),`open_app` 只会把它拉到前台、
# 还是那一页 —— 那一页**没有底部「我」页签**,切号第一步就失败,整轮什么都不发。
# 冷启动保证起来是「首页」。
{"id": sid("stopapp"), "type": "stop_app",
"label": "① 结束抖音(冷启动,保证起来停在「首页」)",
"params": {"package": "com.ss.android.ugc.aweme", "probability": 100}},
{"id": sid("openapp"), "type": "open_app", "label": "② 打开抖音(等「首页」出来)",
"params": {"package": "com.ss.android.ugc.aweme", "home_feature": "首页", "params": {"package": "com.ss.android.ugc.aweme", "home_feature": "首页",
"wait_home": True, "probability": 100}}, "wait_home": True, "probability": 100}},
# 一轮把本机待发的号跑完:数据源是**计划表**(谁还有待发内容谁就在里面), # 一轮把本机待发的号跑完:数据源是**计划表**(谁还有待发内容谁就在里面),
# 不需要任何账号维护;停止/超时/上限三个出口见 `_exec_foreach_account` # 不需要任何账号维护;停止/超时/上限三个出口见 `_exec_foreach_account`
dict(build_foreach_step(loop_body, "② 循环本机待发的号(一直切号发,直到发完)")), dict(build_foreach_step(loop_body, "③ 循环本机待发的号(一直切号发,直到发完)")),
] ]
+2 -2
View File
@@ -139,7 +139,7 @@
| GET | `/api/done_marks?job=&limit=` | L | 去重记录列表 + 统计(`{marks, stats{total,devices,identities,today_devices,today_identities}}`) | | GET | `/api/done_marks?job=&limit=` | L | 去重记录列表 + 统计(`{marks, stats{total,devices,identities,today_devices,today_identities}}`) |
| POST | `/api/done_marks/delete` | T | 删一条去重记录(`{id}`)→ 该设备/身份下次会重新执行 | | POST | `/api/done_marks/delete` | T | 删一条去重记录(`{id}`)→ 该设备/身份下次会重新执行 |
| POST | `/api/done_marks/clear` | T | 清空某任务的全部去重记录(`{job}`)→ 整批重跑 | | POST | `/api/done_marks/clear` | T | 清空某任务的全部去重记录(`{job}`)→ 整批重跑 |
| POST | `/api/video_plan/upload_video` | D | 上传单个视频素材(文件名 `名字_日期_编号`)→ 解析名字 + 入计划 | | POST | `/api/video_plan/upload_video` | D | 上传单个素材(**视频或图片**,文件名 `名字_日期_编号`)→ 解析名字 + 入计划 |
| POST | `/api/video_plan/upload_titles` | D | 上传标题(txt 文本或文件)→ 配到已有计划行 | | POST | `/api/video_plan/upload_titles` | D | 上传标题(txt 文本或文件)→ 配到已有计划行 |
| GET | `/api/video_plan/timeline` | D | 发布计划时间线(按日期分组的卡片 + 统计 + 磁盘) | | GET | `/api/video_plan/timeline` | D | 发布计划时间线(按日期分组的卡片 + 统计 + 磁盘) |
| GET | `/api/video_plan/stats` | D | 今天要发/已发/待标题/过期未发 + 素材占用与磁盘余量 | | GET | `/api/video_plan/stats` | D | 今天要发/已发/待标题/过期未发 + 素材占用与磁盘余量 |
@@ -506,7 +506,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_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,name,date,seq,title,body,action,error}], msg}`;**一行里可带正文**:`标题 || 正文_名字_日期_编号`(`||` 分隔,正文可省,只切第一处);**只有这一种写法**(名字在日期前一段,右锚定);**编号可省略**;标题/正文里可以有下划线和 `#话题`;空行忽略,`#` 开头的行**只有认不出名字/日期时**才当注释;拒收条件:名字里带下划线/是纯数字/是手机号、日期不合法、标题为空或 >100 字、该槽位已有标题、没有对应视频 | | `POST /api/video_plan/upload_titles` | `{text, dry_run}` 或 multipart `file`(txt) | `{ok, counts:{total,attached,skipped,rejected}, rows:[{line,name,date,seq,title,body,action,error}], msg}`;**一行里可带正文**:`标题 || 正文_名字_日期_编号`(`||` 分隔,正文可省,只切第一处);**只有这一种写法**(名字在日期前一段,右锚定);**编号可省略**;标题/正文里可以有下划线和 `#话题`;空行忽略,`#` 开头的行**只有认不出名字/日期时**才当注释;拒收条件:名字里带下划线/是纯数字/是手机号、日期不合法、标题为空或 >100 字、该槽位已有标题、没有对应视频 |
| `GET /api/video_plan/timeline` | `?range=today\|week\|all&from=&to=&status=&q=&limit=` | `{ok, days:[{date,weekday,total,done,plans:[…]}], stats, disk, today}` | | `GET /api/video_plan/timeline` | `?range=today\|week\|all&from=&to=&status=&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/stats` | — | `{ok, stats, disk, today}`;`stats` 里**三个口径都给**:`today_ready`(今天要发几个号,按发布日期)/ `today_done`(今天完成)/ `published_today`(今天实际发的,按 `published_at`,补发也算今天) |
+1 -1
View File
@@ -297,7 +297,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| `base.js` | esc / CSRF / 权限 / API 封装 / Toast / Tab 与子分栏切换 / 模态框 / 常量表 | 所有模块的公共底座 | | `base.js` | esc / CSRF / 权限 / API 封装 / Toast / Tab 与子分栏切换 / 模态框 / 常量表 | 所有模块的公共底座 |
| `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** | | `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** |
| `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) | | `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) |
| `release.js` | 发布计划页(时间线按日期分组 / 多选视频**串行**上传 + 逐文件进度与结果 / 标题+正文 txt 上传(`标题 || 正文_名字_日期_编号`,卡片与明细**以名字为主**)/ 推送到手机 + **一键推送**(当天全部,**按名字找设备**:实时探测哪台在线设备登着这个号,逐条报出"没有设备登着"的号)、相册校验徽章与裁决 / 链接复制与导出 CSV / **发布任务的就地编辑**) | 服务层 `core/video_plan.py`(实时探测在 `core/account_probe.py`);上传前把队列**按文件名排序**并让用户确认解析结果(顺序 = "无编号按顺序配"的依据)。就地编辑只放常用字段(名字/目标/时间/启停/逐步参数 + 抓取元素),更复杂的编排走「打开通用编辑器」→ `openTaskModal`;步骤路径里 `then/else/children` 在**步骤的 params 下**(`_rpWalk`),走错这一层会把分支当数组下标用 | | `release.js` | 发布计划页(时间线按日期分组 / 多选**素材(视频/图片)**串行上传 + 逐文件进度与结果 / 标题+正文 txt 上传(`标题 || 正文_名字_日期_编号`,卡片与明细**以名字为主**)/ 推送到手机 + **一键推送**(当天全部,**按名字找设备**:实时探测哪台在线设备登着这个号,逐条报出"没有设备登着"的号)、相册校验徽章与裁决 / 链接复制与导出 CSV / **发布任务的就地编辑**) | 服务层 `core/video_plan.py`(实时探测在 `core/account_probe.py`);上传前把队列**按文件名排序**并让用户确认解析结果(顺序 = "无编号按顺序配"的依据)。就地编辑只放常用字段(名字/目标/时间/启停/逐步参数 + 抓取元素),更复杂的编排走「打开通用编辑器」→ `openTaskModal`;步骤路径里 `then/else/children` 在**步骤的 params 下**(`_rpWalk`),走错这一层会把分支当数组下标用 |
| `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) | | `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) | | `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) |
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | | | `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
+2 -2
View File
@@ -244,7 +244,7 @@
「发布计划」页维护;服务层 `core/video_plan.py`,接口 `/api/video_plan/*`(见 [API.md](API.md) §2.14), 「发布计划」页维护;服务层 `core/video_plan.py`,接口 `/api/video_plan/*`(见 [API.md](API.md) §2.14),
任务步骤「发布视频」按它自动发布(见 [TASK_DEV.md](TASK_DEV.md) §4.7)。 任务步骤「发布视频」按它自动发布(见 [TASK_DEV.md](TASK_DEV.md) §4.7)。
**素材文件**落在 `data/videos/YYYY-MM/`(文件名 `{sha1[:12]}_{安全原名}`,内容寻址),**不进整库备份**(§6)。 **素材文件(视频/图片)**落在 `data/videos/YYYY-MM/`(文件名 `{sha1[:12]}_{安全原名}`,内容寻址),**不进整库备份**(§6)。
| 列 | 类型 | 说明 | | 列 | 类型 | 说明 |
|----|------|------| |----|------|------|
@@ -255,7 +255,7 @@
| `seq` / `seq_auto` | Integer / Boolean | 编号从 **1** 起(不用 0 表示"无");`seq_auto`=号是自动分配的 | | `seq` / `seq_auto` | Integer / Boolean | 编号从 **1** 起(不用 0 表示"无");`seq_auto`=号是自动分配的 |
| `title` | Text | **标题**(发布页的「标题」框;标题 txt 配对写入) | | `title` | Text | **标题**(发布页的「标题」框;标题 txt 配对写入) |
| `body` | Text | **正文/文案**(发布页的文案框,可带 `#话题`)—— 标题 txt 一行里用 `||` 分隔:`标题 || 正文_名字_日期_编号`;**标题与正文都为空的行不会被发布** | | `body` | Text | **正文/文案**(发布页的文案框,可带 `#话题`)—— 标题 txt 一行里用 `||` 分隔:`标题 || 正文_名字_日期_编号`;**标题与正文都为空的行不会被发布** |
| `video_file` / `video_name` / `video_size` / `video_sha1` | 各自 | 落盘相对路径 / 原始名 / 大小 / 内容指纹(重复上传判据) | | `video_file` / `video_name` / `video_size` / `video_sha1` | 各自 | **素材**(视频或图片,看扩展名):落盘相对路径 / 原始名 / 大小 / 内容指纹(重复上传判据)。推送时**扩展名原样带到手机**——抖音按它判断视频还是图文 |
| `status` | String(16) index | 见下方状态机 | | `status` | String(16) index | 见下方状态机 |
| `stage` | String(16) | 失败发生在哪一步:`push`/`scan`/`post`/`verify` | | `stage` | String(16) | 失败发生在哪一步:`push`/`scan`/`post`/`verify` |
| `attempts` | Integer | 尝试次数(上限 3,超了不再自动取) | | `attempts` | Integer | 尝试次数(上限 3,超了不再自动取) |
+4
View File
@@ -466,6 +466,10 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
**全部按「名字」走**(名字 = 上传文件名里的那个名字 = 抖音昵称):计划行认名字, **全部按「名字」走**(名字 = 上传文件名里的那个名字 = 抖音昵称):计划行认名字,
运行时读本机当前账号名字,**读不到名字就当"本机没有号"、什么都不发**。 运行时读本机当前账号名字,**读不到名字就当"本机没有号"、什么都不发**。
**素材可以是视频,也可以是图片**(扩展名各带各的,一路原样带到手机上 —— 抖音按它判断
这是视频还是「图文」):**发布流程是同一条**(相册里点第一个素材 → 下一步 → 填标题/正文 → 发布),
所以任务画布不用为图片另写一套。
**分工**(这是刻意的设计):**平台只管"素材到手机 + 状态记账",抖音里怎么发由你画** —— **分工**(这是刻意的设计):**平台只管"素材到手机 + 状态记账",抖音里怎么发由你画** ——
因为发布流程跟账号、版本、界面都在变,写死在平台里改不动。 因为发布流程跟账号、版本、界面都在变,写死在平台里改不动。
+12 -2
View File
@@ -33,6 +33,16 @@ check('名字里带表情', vp.parse_video_name('🐟小鱼_20260912.mp4')[0]['n
check('大写扩展名', vp.parse_video_name('小王_20260912.MP4')[0]['date'], '2026-09-12') check('大写扩展名', vp.parse_video_name('小王_20260912.MP4')[0]['date'], '2026-09-12')
check('编号 3 位', vp.parse_video_name('小王_20260912_999.mp4')[0]['seq'], 999) check('编号 3 位', vp.parse_video_name('小王_20260912_999.mp4')[0]['seq'], 999)
print('=== parse_video_name:图片(图文)也认 ===')
check('jpg', vp.parse_video_name('小王_20260912_1.jpg')[0],
{"name": "小王", "date": "2026-09-12", "seq": 1, "seq_given": True})
check('png(无编号)', vp.parse_video_name('建材王总_20260913.png')[0],
{"name": "建材王总", "date": "2026-09-13", "seq": None, "seq_given": False})
check('大写 JPG', vp.parse_video_name('小王_20260912.JPG')[0]['name'], '小王')
check('webp', vp.parse_video_name('小王_20260912.webp')[0]['date'], '2026-09-12')
check('gif 不收(没进白名单)',
'不是视频/图片' in (vp.parse_video_name('小王_20260912.gif')[1] or ''), True)
print('=== parse_video_name:拒收 ===') print('=== parse_video_name:拒收 ===')
@@ -54,8 +64,8 @@ check('非法日期', err_of(vp.parse_video_name('小王_20260230.mp4')),
'日期 20260230 不是合法日期(应为 20260912 这种 8 位)') '日期 20260230 不是合法日期(应为 20260912 这种 8 位)')
check('只有一段', err_of(vp.parse_video_name('小王.mp4')), check('只有一段', err_of(vp.parse_video_name('小王.mp4')),
'文件名格式不对,应为 名字_日期_编号(编号可省略,如 小王_20260912.mp4)') '文件名格式不对,应为 名字_日期_编号(编号可省略,如 小王_20260912.mp4)')
check('不是视频', err_of(vp.parse_video_name('小王_20260912.txt')), check('不是视频/图片', err_of(vp.parse_video_name('小王_20260912.txt')),
'不是视频文件(支持 mp4/mov/mkv/avi/3gp/flv/wmv/m4v/ts/webm)') '不是视频/图片文件(支持 mp4/mov/mkv/avi/3gp/flv/wmv/m4v/ts/webm/jpg/jpeg/png/webp/heic)')
check('空文件名', err_of(vp.parse_video_name('')), '文件名为空') check('空文件名', err_of(vp.parse_video_name('')), '文件名为空')
check('多段(名字含下划线还带日期)', err_of(vp.parse_video_name('小_王_总_20260912.mp4')), check('多段(名字含下划线还带日期)', err_of(vp.parse_video_name('小_王_总_20260912.mp4')),
'名字里不能有下划线:`小_王_总_20260912` 里日期前面有 3 段 ——应为 名字_日期_编号') '名字里不能有下划线:`小_王_总_20260912` 里日期前面有 3 段 ——应为 名字_日期_编号')
+4 -1
View File
@@ -83,6 +83,8 @@ class StubD:
self.clicked.append(name) self.clicked.append(name)
if self.switch_ok: if self.switch_ok:
self.cur = name self.cur = name
self.accounts = [] # 真机上点成功后**面板会关掉**(那几行消失)——
# 步骤就是靠这个判断"点成功了"的:面板还在 = 这一下被吞了,要再点一次
LOGIN_HINTS = ('验证码', '其他方式登录', '手机号登录', '一键登录', '密码登录') LOGIN_HINTS = ('验证码', '其他方式登录', '手机号登录', '一键登录', '密码登录')
@@ -154,7 +156,8 @@ try:
d = StubD(cur='小李') d = StubD(cur='小李')
d.switch_ok = False # 抖音没反应 d.switch_ok = False # 抖音没反应
check('返回 False', w._exec_switch_account(d, PARAMS), False) check('返回 False', w._exec_switch_account(d, PARAMS), False)
check('试了 2 次(max_attempts=2)', d.clicked, ['小王', '小王']) # 面板一直没关(点一次吞一次)→ 每次尝试点两下、共 2 次尝试
check('试了 2 次尝试、每次点两下', d.clicked, ['小王', '小王', '小王', '小王'])
check('发了切号失败通知', [n[0] for n in notes], ['task.account.switch_failed']) check('发了切号失败通知', [n[0] for n in notes], ['task.account.switch_failed'])
print('=== 5. 卡在登录/验证码页 → 通知 + 停本设备 ===') print('=== 5. 卡在登录/验证码页 → 通知 + 停本设备 ===')
+7 -7
View File
@@ -1,14 +1,14 @@
// 视频发布计划(「发布计划」页) // 视频发布计划(「发布计划」页)
// 数据在 video_plan 表,服务层 core/video_plan.py,接口 /api/video_plan/*(web/video_plan_api.py)。 // 数据在 video_plan 表,服务层 core/video_plan.py,接口 /api/video_plan/*(web/video_plan_api.py)。
// //
// 这一页要解决的事:人员批量传视频与标题 → 自动按文件名配到账号上 → 时间线看每天谁要发、 // 这一页要解决的事:人员批量传素材(视频/图片)与标题 → 自动按文件名配到账号上 → 时间线看每天谁要发、
// 发到哪一步了 → 到日子由任务把视频推到手机相册、走抖音发布流程 → 发布后抓分享链接存下来 // 发到哪一步了 → 到日子由任务把视频推到手机相册、走抖音发布流程 → 发布后抓分享链接存下来
// (平台不长期囤视频,链接才是长期资产;后续铺评论也是用这个链接打开视频)。 // (平台不长期囤视频,链接才是长期资产;后续铺评论也是用这个链接打开视频)。
// //
// 三条与安全有关的约定(别改): // 三条与安全有关的约定(别改):
// ① 上传**串行**:并发会打乱"无编号按顺序配"的顺序,还会撞 SQLite 回退模式的写锁 // ① 上传**串行**:并发会打乱"无编号按顺序配"的顺序,还会撞 SQLite 回退模式的写锁
// ② 上传前把队列**按文件名排序**并给用户看一遍顺序(FileList 的顺序在 Windows 上不稳定) // ② 上传前把队列**按文件名排序**并给用户看一遍顺序(FileList 的顺序在 Windows 上不稳定)
// ③ 界面上同时显示"视频文件名"与"标题" —— 配错对这一眼就能看出来 // ③ 界面上同时显示"素材文件名"与"标题" —— 配错对这一眼就能看出来
var _rpRows = [], _rpDays = [], _rpStats = {}, _rpDisk = {}, _rpToday = ''; var _rpRows = [], _rpDays = [], _rpStats = {}, _rpDisk = {}, _rpToday = '';
var _rpTimer = null; var _rpTimer = null;
@@ -81,7 +81,7 @@ function renderReleaseTimeline() {
if (!box) return; if (!box) return;
if (!_rpDays.length) { if (!_rpDays.length) {
box.innerHTML = '<div class="empty" style="padding:20px;text-align:center">' box.innerHTML = '<div class="empty" style="padding:20px;text-align:center">'
+ '这个范围里没有计划 —— 点「上传视频」把素材传进来</div>'; + '这个范围里没有计划 —— 点「上传视频/图片」把素材传进来</div>';
return; return;
} }
box.innerHTML = _rpDays.map(function (day) { box.innerHTML = _rpDays.map(function (day) {
@@ -138,7 +138,7 @@ function rpCard(p) {
var pv = p.push_verify || ''; var pv = p.push_verify || '';
var pushTag = !pv ? '' var pushTag = !pv ? ''
: (pv === 'ok' : (pv === 'ok'
? ' <span class="label label-success" title="已进相册索引,抖音选视频能看到它">已推送·相册可见</span>' ? ' <span class="label label-success" title="已进相册索引,抖音选素材能看到它">已推送·相册可见</span>'
: (pv === 'no_index' : (pv === 'no_index'
? ' <span class="label label-warning" title="文件在手机上,但没进相册索引 —— 相册/抖音里可能看不到它">已推送·相册未见</span>' ? ' <span class="label label-warning" title="文件在手机上,但没进相册索引 —— 相册/抖音里可能看不到它">已推送·相册未见</span>'
: ' <span class="label label-warning">已推送·文件不在</span>')); : ' <span class="label label-warning">已推送·文件不在</span>'));
@@ -207,7 +207,7 @@ function rpEnsurePolling() {
} }
} }
// ================== 上传:视频 ================== // ================== 上传:素材(视频/图片) ==================
// 文件名规则与服务端一致(这里只做**本地预检**,真正的判定永远以后端为准) // 文件名规则与服务端一致(这里只做**本地预检**,真正的判定永远以后端为准)
// `名字_日期_编号`:**右锚定** —— 末尾可省编号 → 日期 → 紧挨日期的那一段就是名字 // `名字_日期_编号`:**右锚定** —— 末尾可省编号 → 日期 → 紧挨日期的那一段就是名字
var _RP_DATE = /^\d{8}$/, _RP_SEQ = /^\d{1,3}$/; var _RP_DATE = /^\d{8}$/, _RP_SEQ = /^\d{1,3}$/;
@@ -251,7 +251,7 @@ function rpPickVideos(input) {
} }
function rpShowUploadConfirm(rows) { function rpShowUploadConfirm(rows) {
document.getElementById('modal-title').textContent = '上传视频(' + rows.length + ' 个)'; document.getElementById('modal-title').textContent = '上传视频/图片(' + rows.length + ' 个)';
document.getElementById('modal-box').classList.add('wide-modal'); document.getElementById('modal-box').classList.add('wide-modal');
var body = '<div class="help" style="margin-bottom:8px">' var body = '<div class="help" style="margin-bottom:8px">'
+ '确认下面每个文件解析出的账号与日期对不对(<b>传上去就按这个配</b>)。' + '确认下面每个文件解析出的账号与日期对不对(<b>传上去就按这个配</b>)。'
@@ -385,7 +385,7 @@ function rpShowTitleConfirm(fname, text) {
document.getElementById('modal-box').classList.add('wide-modal'); document.getElementById('modal-box').classList.add('wide-modal');
document.getElementById('modal-body').innerHTML = document.getElementById('modal-body').innerHTML =
'<div class="help" style="margin-bottom:6px">共 ' + lines.length + ' 行有效标题。' '<div class="help" style="margin-bottom:6px">共 ' + lines.length + ' 行有效标题。'
+ '每行 <code>标题 || 正文_名字_日期_编号</code>(<code>||</code> 后面是正文,可省略;编号可省,标题里可以有 <code>#话题</code>);<br>没写编号的按顺序配到该名字当天还没标题的视频上。</div>' + '每行 <code>标题 || 正文_名字_日期_编号</code>(<code>||</code> 后面是正文,可省略;编号可省,标题里可以有 <code>#话题</code>);<br>没写编号的按顺序配到该名字当天还没标题的素材上。</div>'
+ '<div style="max-height:50vh;overflow:auto;font-family:var(--mono);font-size:12px;white-space:pre-wrap">' + '<div style="max-height:50vh;overflow:auto;font-family:var(--mono);font-size:12px;white-space:pre-wrap">'
+ esc(lines.slice(0, 200).join('\n')) + (lines.length > 200 ? '\n…' : '') + '</div>' + esc(lines.slice(0, 200).join('\n')) + (lines.length > 200 ? '\n…' : '') + '</div>'
+ '<div id="rp-title-result"></div>'; + '<div id="rp-title-result"></div>';
+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)。 """把平台素材推到手机相册目录,并把手机上的时间改成"现在"。返回 (remote_path, error)。
改时间是为了**排序**:相册/抖音的选视频页按时间倒序, 改时间是为了**排序**:相册/抖音的选素材页按时间倒序,
`adb push` 保留的是**本地文件的修改时间**(推一个 3 天前上传的素材 → 排在很后面), `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}" remote = f"{album_dir}/{plan_id}{ext}"
try: try:
local_size = os.path.getsize(local_path) 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"): def drop_old_copies(d, plan_id, keep, ext=".mp4"):
"""(`ext` 由 `push_video` 按素材真实扩展名传进来:视频/图片各删各的旧副本)"""
"""把**同一个计划**在平台用过别的目录里的旧副本删掉(精确文件名,绝不通配)。 """把**同一个计划**在平台用过别的目录里的旧副本删掉(精确文件名,绝不通配)。
为什么要删:换了推送目录之后,老目录里的同名副本还在相册里(实测一个 plan id 躺了 3 份), 为什么要删:换了推送目录之后,老目录里的同名副本还在相册里(实测一个 plan id 躺了 3 份),
+79 -20
View File
@@ -32,6 +32,7 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
loop - 循环块(含 children 步骤列表 + max_iterations) loop - 循环块(含 children 步骤列表 + max_iterations)
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用) group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
""" """
import os
import random import random
import re import re
import time import time
@@ -1077,21 +1078,56 @@ class GenericStepsWorker(BaseWorker):
_log.warning(f"[{self.serial}] 点击失败({sel_type}={sel_val}): {e}") _log.warning(f"[{self.serial}] 点击失败({sel_type}={sel_val}): {e}")
return False 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): def _click_in_list(self, d, name, timeout=4):
"""在账号列表里**按名字**找控件并点击(文本优先,再试描述/精确文本)。 """在账号列表里**按名字**找控件并点击(文本优先,再试描述/精确文本)。
⚠ **绝不用 `[N]` 序号**:`doc/research/U2_ELEMENT_SELECTORS.md` 实测序号型会随界面 ⚠ **绝不用 `[N]` 序号**:`doc/research/U2_ELEMENT_SELECTORS.md` 实测序号型会随界面
变形错位 —— 多点一个就是把内容发到别人号上。 变形错位 —— 多点一个就是把内容发到别人号上。
""" """
for kw in ({"textContains": name}, {"descriptionContains": name}, {"text": name}): obj = self._find_in_list(d, name, timeout)
try: if obj is None:
obj = d(**kw) return False
if obj.exists(timeout=timeout): try:
obj.click() obj.click()
return True return True
except Exception as e: except Exception as e:
_log.debug(f"[{self.serial}] 列表里按 {kw} 找『{name}』失败: {e}") _log.warning(f"[{self.serial}] 点列表里的『{name}』失败: {e}")
return False 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): def _switch_target(self, params):
"""这一步切到哪个名字:`var:target_name`(默认,循环块设的目标)/ """这一步切到哪个名字:`var:target_name`(默认,循环块设的目标)/
@@ -1175,20 +1211,36 @@ class GenericStepsWorker(BaseWorker):
hint = self._page_has_text(d, self._LOGIN_HINTS) hint = self._page_has_text(d, self._LOGIN_HINTS)
if hint: if hint:
return self._handle_login_page(target, hint, params) 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 continue
# 等面板真的打开:**轮询找目标那一行,出现就点**。 # 先**只等**面板开稳(等目标那一行出现,不点):实测"刚出现就点"会被
# 固定 sleep 不够用(实测:冷启动/慢设备上点了切换入口后列表还没渲染出来, # 面板的入场动画**吞掉** —— 号没切成、面板还开着,第二轮又变成"进不去列表"。
# 一次找不到就误判成"本机没登这个号" —— 那是会漏发的假故障)。
hit = False hit = False
deadline = time.time() + max(6.0, timeout) deadline = time.time() + max(6.0, timeout)
while time.time() < deadline: while time.time() < deadline:
if self.stopped(): if self.stopped():
return False 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 hit = True
break 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: if not hit:
hint = self._page_has_text(d, self._LOGIN_HINTS) hint = self._page_has_text(d, self._LOGIN_HINTS)
if hint: if hint:
@@ -1202,9 +1254,12 @@ class GenericStepsWorker(BaseWorker):
if not want_verify: if not want_verify:
self.set_action(f"已点击『{target}』(未校验)") self.set_action(f"已点击『{target}』(未校验)")
return True return True
# 等切换生效:抖音会重载「我」页面,几秒内名字才会变 —— 同样轮询,别只读一次 # 等切换生效:切换会触发抖音**整体重启**(等于换了个号重新登录),十几秒才回到
# 「我」页是正常的(实测:整步跑了 77s,其中大部分就是在等它重启完)。而且重启后
# 落的地方**可能是信息流**(不是「我」页)—— 所以窗口放到 30s,并且**读不到就重新点
# 一次「我」**再读(踩过两次:窗口太短 → 明明切成功却判失败 → 整轮什么都不发)。
last_got = "" last_got = ""
vdeadline = time.time() + max(6.0, timeout) vdeadline = time.time() + max(30.0, timeout)
while time.time() < vdeadline: while time.time() < vdeadline:
if self.stopped(): if self.stopped():
return False return False
@@ -1214,6 +1269,8 @@ class GenericStepsWorker(BaseWorker):
self.set_action(f"已切到『{target}』") self.set_action(f"已切到『{target}』")
self._vars["account_name"] = target self._vars["account_name"] = target
return True return True
if not last_got:
self._click_sel(d, me_type, me_val, timeout=1)
time.sleep(0.8) time.sleep(0.8)
hint = self._page_has_text(d, self._LOGIN_HINTS) if not last_got else "" hint = self._page_has_text(d, self._LOGIN_HINTS) if not last_got else ""
if hint: if hint:
@@ -1337,13 +1394,15 @@ class GenericStepsWorker(BaseWorker):
why = (params.get("why") or "").strip() why = (params.get("why") or "").strip()
# 手机上的素材:先删再回写状态 —— 删失败不影响发布结论。 # 手机上的素材:先删再回写状态 —— 删失败不影响发布结论。
# 远程路径是**确定的**({相册目录}/{计划id}.mp4),所以这里能直接算出来, # 路径优先用**推送时记下的** `push_remote`(视频/图片扩展名不同,不能靠猜),
# 不用把"上次推到哪"存进库(存了还得担心不一致) # 没有就按 `{相册目录}/{计划id}{素材自己的扩展名}` 现算。
if params.get("delete_phone", True) is not False: if params.get("delete_phone", True) is not False:
try: try:
dd = d or u2.connect(self.serial) dd = d or u2.connect(self.serial)
album = (params.get("album_dir") or "").strip() or flow.ALBUM_DIR 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: except Exception as e:
_log.warning(f"[{self.serial}] 删手机素材失败(不影响标记): {e}") _log.warning(f"[{self.serial}] 删手机素材失败(不影响标记): {e}")
+4 -4
View File
@@ -591,7 +591,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<div> <div>
<div class="toolbar" style="flex-wrap:wrap"> <div class="toolbar" style="flex-wrap:wrap">
<button class="btn btn-primary" onclick="document.getElementById('rp-video-input').click()" <button class="btn btn-primary" onclick="document.getElementById('rp-video-input').click()"
data-perm="devices">上传视频</button> data-perm="devices">上传视频/图片</button>
<button class="btn" onclick="document.getElementById('rp-title-input').click()" <button class="btn" onclick="document.getElementById('rp-title-input').click()"
data-perm="devices">上传标题/正文</button> data-perm="devices">上传标题/正文</button>
<button class="btn btn-primary" onclick="rpPushAll()" data-perm="devices" <button class="btn btn-primary" onclick="rpPushAll()" data-perm="devices"
@@ -637,7 +637,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<summary style="cursor:pointer;color:var(--text-light)">明细表(与时间线同一份数据)</summary> <summary style="cursor:pointer;color:var(--text-light)">明细表(与时间线同一份数据)</summary>
<table class="table table-sm" style="margin-top:8px"> <table class="table table-sm" style="margin-top:8px">
<thead><tr> <thead><tr>
<th>发布日期</th><th>名字</th><th>由哪台发出</th><th>编号</th><th>视频文件</th> <th>发布日期</th><th>名字</th><th>由哪台发出</th><th>编号</th><th>素材文件</th>
<th>标题</th><th>正文</th><th>状态</th><th>分享链接</th><th>操作</th> <th>标题</th><th>正文</th><th>状态</th><th>分享链接</th><th>操作</th>
</tr></thead> </tr></thead>
<tbody id="tb-release"></tbody> <tbody id="tb-release"></tbody>
@@ -645,7 +645,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</details> </details>
<div class="help"> <div class="help">
视频文件名规则 <code>名字_日期_编号</code>(<b>名字 = 抖音上的昵称</b>;编号可省略,日期如 <code>20260912</code> 就是发布日期); 素材文件名规则 <code>名字_日期_编号</code>(<b>名字 = 抖音上的昵称</b>;<b>视频、图片都行</b>,扩展名各带各的;编号可省略,日期如 <code>20260912</code> 就是发布日期);
标题 txt 每行 <code>标题 || 正文_名字_日期_编号</code>(<code>||</code> 后面是正文、可省略;编号可省,标题/正文里可以有下划线、可以有 <code>#话题</code>;<code>#</code> 开头的行<b>只要认得出名字/日期就当标题</b>,认不出才当注释忽略)。 标题 txt 每行 <code>标题 || 正文_名字_日期_编号</code>(<code>||</code> 后面是正文、可省略;编号可省,标题/正文里可以有下划线、可以有 <code>#话题</code>;<code>#</code> 开头的行<b>只要认得出名字/日期就当标题</b>,认不出才当注释忽略)。
<b>名字里不能有下划线、不能是纯数字</b>(那样切不出名字来),不满足会直接拒收并说明原因。<br> <b>名字里不能有下划线、不能是纯数字</b>(那样切不出名字来),不满足会直接拒收并说明原因。<br>
上传后自动按 (名字, 日期, 编号) 配对:有编号按编号精确对,没编号按顺序配 —— 上传后自动按 (名字, 日期, 编号) 配对:有编号按编号精确对,没编号按顺序配 ——
@@ -659,7 +659,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
要先到抖音里看一眼,再用「已发出 / 未发出」裁决(平台不会自动重发)。 要先到抖音里看一眼,再用「已发出 / 未发出」裁决(平台不会自动重发)。
</div> </div>
<input type="file" id="rp-video-input" multiple accept="video/*" style="display:none" onchange="rpPickVideos(this)"> <input type="file" id="rp-video-input" multiple accept="video/*,image/*" style="display:none" onchange="rpPickVideos(this)">
<input type="file" id="rp-title-input" accept=".txt" style="display:none" onchange="rpPickTitles(this)"> <input type="file" id="rp-title-input" accept=".txt" style="display:none" onchange="rpPickTitles(this)">
</div> </div>
</div> </div>
+3 -1
View File
@@ -22,9 +22,11 @@ bp = Blueprint("video_plan", __name__)
@bp.route("/api/video_plan/upload_video", methods=["POST"]) @bp.route("/api/video_plan/upload_video", methods=["POST"])
@perm_required(PERM_DEVICES) @perm_required(PERM_DEVICES)
def api_vp_upload_video(): def api_vp_upload_video():
"""上传单个视频(前端**逐文件**发,才能给每个文件一条结果与进度)。 """上传单个素材(**视频或图片**;前端**逐文件**发,才能给每个文件一条结果与进度)。
文件名规则 `名字_日期_编号`(编号可省略,名字 = 抖音昵称,不查台账)。 文件名规则 `名字_日期_编号`(编号可省略,名字 = 抖音昵称,不查台账)。
视频与图片同一套规则、同一条发布流程(图片走抖音「图文」);扩展名一路带到手机上,
抖音按它判断这是视频还是图片。
""" """
f = request.files.get("file") f = request.files.get("file")
if not f or not f.filename: if not f or not f.filename: