diff --git a/README.md b/README.md index 08aaa42..aeb583d 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ | **动作配置 / 录制** | 「任务 → 动作配置」:① 配**步骤默认值**(新建步骤预填:滑动时长/幅度/抖动/拟人、点击超时、等待区间…)② **录制动作**——在设备画面上点/划/按键,自动翻译成步骤(点元素记选择器、划一下记方向/幅度/时长),可存成可复用动作,也可只取一个手势回填滑动步骤 | | **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) | | **去重账本** | 解决"**同一个号被做两次、有的号还没做**":任务里用「条件判断→去重」+「记为已做」,把"这个身份做过了"记进一张**所有设备共享**的账本——多台手机、反复重跑都不会重复做同一个号。「任务 → 去重记录」页能看"谁做过了、还差谁",也能删记录让它重跑(见 [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 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 | | **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) | | **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 | diff --git a/core/video_plan.py b/core/video_plan.py index 803ab90..a8e57c9 100644 --- a/core/video_plan.py +++ b/core/video_plan.py @@ -58,7 +58,12 @@ STAGE_STATUS = {"push": ST_FAILED, "scan": ST_FAILED, "post": ST_UNKNOWN, "verify": ST_UNKNOWN, STAGE_MANUAL: ST_UNKNOWN} +# 素材扩展名:**视频 + 图片**(图片就是抖音的"图文",发布流程一样:相册里点第一个)。 +# 扩展名必须一路带到手机上 —— 抖音是按扩展名/MIME 判断这是视频还是图片的, +# 给图片起个 .mp4 的名字,相册里就成了一段坏视频。 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_BODY_LEN = 1000 # 正文上限(抖音文案框放得下 1000 字;同样不截断,超了拒收) MAX_ATTEMPTS = 3 # 尝试上限,超了不再自动取(人工确认后重试可清零) @@ -166,7 +171,7 @@ def name_error(seg): def parse_video_name(filename): - """视频文件名 `名字_日期_编号` → (meta, error)。 + """素材文件名 `名字_日期_编号` → (meta, error)。**视频和图片同一套规则**。 meta = {"name","date","seq"|None,"seq_given"};error 是给人看的拒收原因。 @@ -180,8 +185,9 @@ def parse_video_name(filename): if not name: return None, "文件名为空" stem, ext = os.path.splitext(name) - if ext.lower() not in VIDEO_EXTS: - return None, f"不是视频文件(支持 {'/'.join(e.lstrip('.') for e in VIDEO_EXTS)})" + if ext.lower() not in MEDIA_EXTS: + return None, (f"不是视频/图片文件(支持 " + f"{'/'.join(e.lstrip('.') for e in MEDIA_EXTS)})") parts = [p.strip() for p in stem.split("_")] if len(parts) < 2: return None, "文件名格式不对,应为 名字_日期_编号(编号可省略,如 小王_20260912.mp4)" @@ -554,6 +560,12 @@ def build_release_steps(): def sid(name): 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=""): return {"id": sid(name), "type": "click", "label": label, "params": {"selector_type": sel_type or "xpath", "selector_value": sel_val, @@ -565,35 +577,35 @@ def build_release_steps(): # **只用「包含」,绝不能用「不等于」** —— 读不到名字时候选为空, # "空 != 候选"会命中,把"读不到"误判成"可以发"。 publish_flow_steps = [ - dict(build_push_step("⑦ 推送发布视频")), - clk("open", "⑧ 点击抖音底部「+」(实测可用;改版了重新抓)", + dict(build_push_step("⑧ 推送发布视频")), + clk("open", "⑨ 点击抖音底部「+」(实测可用;改版了重新抓)", "descriptionContains", "拍摄"), - clk("album", "⑨ 点击「相册」(没命中的话用抓取元素选它)"), - clk("video", "⑩ 点击第一个视频(刚推的排最前;抓一次就行)"), - clk("next", "⑪ 点击「下一步」"), - clk("input", "⑫ 点击标题框(抖音发布页的文案框)"), + clk("album", "⑩ 点击「相册」(没命中的话用抓取元素选它)"), + clk("video", "⑪ 点击第一个视频(刚推的排最前;抓一次就行)"), + clk("next", "⑫ 点击「下一步」"), + clk("input", "⑬ 点击标题框(抖音发布页的标题框:hint「添加标题」)"), {"id": sid("title"), "type": "input_text", - "label": "⑬ 填标题(自动取这一条计划的标题,会回读校验)", + "label": "⑭ 填标题(自动取这一条计划的标题,会回读校验)", "params": {"text_source": "release_title", "release_topics": "", "clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}}, - clk("body", "⑭ 点击正文框(发布页的正文框)"), + clk("body", "⑮ 点击正文框(发布页的描述框:hint「添加作品描述」)"), {"id": sid("bodytxt"), "type": "input_text", - "label": "⑮ 填正文(自动取这一条计划的正文,会回读校验)", + "label": "⑯ 填正文(自动取这一条计划的正文,会回读校验)", "params": {"text_source": "release_body", "release_topics": "", "clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}}, - clk("publish", "⑯ 点击「发布」"), - dict(build_mark_step("⑰ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")), + clk("publish", "⑰ 点击「发布」"), + dict(build_mark_step("⑱ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")), ] # 循环体:切号 → 读名字 → 校验 → 推送 → 发布 → 标记。 # `switch_account` 自己会先看"当前是不是已经是目标"(第一个号不用切), # 也会处理"本机没登这个号"(直接跳过、不重试)。 loop_body = [ - dict(build_switch_step("③ 切换账号(切到本次要发的那个名字;已在该号则直接过)")), - dict(build_read_name_step("④ 读取本机当前账号名字")), + dict(build_switch_step("④ 切换账号(切到本次要发的那个名字;已在该号则直接过)")), + dict(build_read_name_step("⑤ 读取本机当前账号名字")), {"id": sid("check"), "type": "if_el", - "label": "⑤ 校验账号:当前登的是不是这次要发的那个名字", - "params": {"selector_type": "xpath", - "selector_value": "", + "label": "⑥ 校验账号:当前登的是不是这次要发的那个名字", + "params": {"selector_type": _name_sel()[0], + "selector_value": _name_sel()[1], "timeout": 8, "cmp_op": "包含", "cmp_source": "account_target", "cmp_value": "", "cmp_group": "", "ident_type": "text", "ident_value": "", @@ -608,12 +620,19 @@ def build_release_steps(): return [ {"id": sid("light"), "type": "screen_on", "label": "⓪ 亮屏", "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": "首页", "wait_home": True, "probability": 100}}, # 一轮把本机待发的号跑完:数据源是**计划表**(谁还有待发内容谁就在里面), # 不需要任何账号维护;停止/超时/上限三个出口见 `_exec_foreach_account` - dict(build_foreach_step(loop_body, "② 循环本机待发的号(一直切号发,直到发完)")), + dict(build_foreach_step(loop_body, "③ 循环本机待发的号(一直切号发,直到发完)")), ] diff --git a/doc/API.md b/doc/API.md index ac9493c..af0c49c 100644 --- a/doc/API.md +++ b/doc/API.md @@ -139,7 +139,7 @@ | 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/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 文本或文件)→ 配到已有计划行 | | GET | `/api/video_plan/timeline` | 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 字、该槽位已有标题、没有对应视频 | | `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`,补发也算今天) | diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 02aa835..6b85748 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -297,7 +297,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set | `base.js` | esc / CSRF / 权限 / API 封装 / Toast / Tab 与子分栏切换 / 模态框 / 常量表 | 所有模块的公共底座 | | `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** | | `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 轮询 + 脏检查(签名不变不重渲染) | | `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) | | `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | | diff --git a/doc/DATA_MODEL.md b/doc/DATA_MODEL.md index bfe1028..96f4689 100644 --- a/doc/DATA_MODEL.md +++ b/doc/DATA_MODEL.md @@ -244,7 +244,7 @@ 「发布计划」页维护;服务层 `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)。 +**素材文件(视频/图片)**落在 `data/videos/YYYY-MM/`(文件名 `{sha1[:12]}_{安全原名}`,内容寻址),**不进整库备份**(§6)。 | 列 | 类型 | 说明 | |----|------|------| @@ -255,7 +255,7 @@ | `seq` / `seq_auto` | Integer / Boolean | 编号从 **1** 起(不用 0 表示"无");`seq_auto`=号是自动分配的 | | `title` | 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 | 见下方状态机 | | `stage` | String(16) | 失败发生在哪一步:`push`/`scan`/`post`/`verify` | | `attempts` | Integer | 尝试次数(上限 3,超了不再自动取) | diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index 4de25b9..43e0b45 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -466,6 +466,10 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) **全部按「名字」走**(名字 = 上传文件名里的那个名字 = 抖音昵称):计划行认名字, 运行时读本机当前账号名字,**读不到名字就当"本机没有号"、什么都不发**。 +**素材可以是视频,也可以是图片**(扩展名各带各的,一路原样带到手机上 —— 抖音按它判断 +这是视频还是「图文」):**发布流程是同一条**(相册里点第一个素材 → 下一步 → 填标题/正文 → 发布), +所以任务画布不用为图片另写一套。 + **分工**(这是刻意的设计):**平台只管"素材到手机 + 状态记账",抖音里怎么发由你画** —— 因为发布流程跟账号、版本、界面都在变,写死在平台里改不动。 diff --git a/scripts/tests/test_parse_name.py b/scripts/tests/test_parse_name.py index e40207e..dd6d48e 100644 --- a/scripts/tests/test_parse_name.py +++ b/scripts/tests/test_parse_name.py @@ -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('编号 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:拒收 ===') @@ -54,8 +64,8 @@ check('非法日期', err_of(vp.parse_video_name('小王_20260230.mp4')), '日期 20260230 不是合法日期(应为 20260912 这种 8 位)') check('只有一段', err_of(vp.parse_video_name('小王.mp4')), '文件名格式不对,应为 名字_日期_编号(编号可省略,如 小王_20260912.mp4)') -check('不是视频', err_of(vp.parse_video_name('小王_20260912.txt')), - '不是视频文件(支持 mp4/mov/mkv/avi/3gp/flv/wmv/m4v/ts/webm)') +check('不是视频/图片', err_of(vp.parse_video_name('小王_20260912.txt')), + '不是视频/图片文件(支持 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('小_王_总_20260912.mp4')), '名字里不能有下划线:`小_王_总_20260912` 里日期前面有 3 段 ——应为 名字_日期_编号') diff --git a/scripts/tests/test_switch_account.py b/scripts/tests/test_switch_account.py index 91680e2..1b73098 100644 --- a/scripts/tests/test_switch_account.py +++ b/scripts/tests/test_switch_account.py @@ -83,6 +83,8 @@ class StubD: self.clicked.append(name) if self.switch_ok: self.cur = name + self.accounts = [] # 真机上点成功后**面板会关掉**(那几行消失)—— + # 步骤就是靠这个判断"点成功了"的:面板还在 = 这一下被吞了,要再点一次 LOGIN_HINTS = ('验证码', '其他方式登录', '手机号登录', '一键登录', '密码登录') @@ -154,7 +156,8 @@ try: d = StubD(cur='小李') d.switch_ok = 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']) print('=== 5. 卡在登录/验证码页 → 通知 + 停本设备 ===') diff --git a/static/admin/release.js b/static/admin/release.js index 981304c..bc01897 100644 --- a/static/admin/release.js +++ b/static/admin/release.js @@ -1,14 +1,14 @@ // 视频发布计划(「发布计划」页) // 数据在 video_plan 表,服务层 core/video_plan.py,接口 /api/video_plan/*(web/video_plan_api.py)。 // -// 这一页要解决的事:人员批量传视频与标题 → 自动按文件名配到账号上 → 时间线看每天谁要发、 +// 这一页要解决的事:人员批量传素材(视频/图片)与标题 → 自动按文件名配到账号上 → 时间线看每天谁要发、 // 发到哪一步了 → 到日子由任务把视频推到手机相册、走抖音发布流程 → 发布后抓分享链接存下来 // (平台不长期囤视频,链接才是长期资产;后续铺评论也是用这个链接打开视频)。 // // 三条与安全有关的约定(别改): // ① 上传**串行**:并发会打乱"无编号按顺序配"的顺序,还会撞 SQLite 回退模式的写锁 // ② 上传前把队列**按文件名排序**并给用户看一遍顺序(FileList 的顺序在 Windows 上不稳定) -// ③ 界面上同时显示"视频文件名"与"标题" —— 配错对这一眼就能看出来 +// ③ 界面上同时显示"素材文件名"与"标题" —— 配错对这一眼就能看出来 var _rpRows = [], _rpDays = [], _rpStats = {}, _rpDisk = {}, _rpToday = ''; var _rpTimer = null; @@ -81,7 +81,7 @@ function renderReleaseTimeline() { if (!box) return; if (!_rpDays.length) { box.innerHTML = '
' - + '这个范围里没有计划 —— 点「上传视频」把素材传进来
'; + + '这个范围里没有计划 —— 点「上传视频/图片」把素材传进来'; return; } box.innerHTML = _rpDays.map(function (day) { @@ -138,7 +138,7 @@ function rpCard(p) { var pv = p.push_verify || ''; var pushTag = !pv ? '' : (pv === 'ok' - ? ' 已推送·相册可见' + ? ' 已推送·相册可见' : (pv === 'no_index' ? ' 已推送·相册未见' : ' 已推送·文件不在')); @@ -207,7 +207,7 @@ function rpEnsurePolling() { } } -// ================== 上传:视频 ================== +// ================== 上传:素材(视频/图片) ================== // 文件名规则与服务端一致(这里只做**本地预检**,真正的判定永远以后端为准) // `名字_日期_编号`:**右锚定** —— 末尾可省编号 → 日期 → 紧挨日期的那一段就是名字 var _RP_DATE = /^\d{8}$/, _RP_SEQ = /^\d{1,3}$/; @@ -251,7 +251,7 @@ function rpPickVideos(input) { } 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'); var body = '
' + '确认下面每个文件解析出的账号与日期对不对(传上去就按这个配)。' @@ -385,7 +385,7 @@ function rpShowTitleConfirm(fname, text) { document.getElementById('modal-box').classList.add('wide-modal'); document.getElementById('modal-body').innerHTML = '
共 ' + lines.length + ' 行有效标题。' - + '每行 标题 || 正文_名字_日期_编号(|| 后面是正文,可省略;编号可省,标题里可以有 #话题);
没写编号的按顺序配到该名字当天还没标题的视频上。
' + + '每行 标题 || 正文_名字_日期_编号(|| 后面是正文,可省略;编号可省,标题里可以有 #话题);
没写编号的按顺序配到该名字当天还没标题的素材上。
' + '
' + esc(lines.slice(0, 200).join('\n')) + (lines.length > 200 ? '\n…' : '') + '
' + '
'; diff --git a/tasks/generic/publish_flow.py b/tasks/generic/publish_flow.py index cfc64cd..34f653d 100644 --- a/tasks/generic/publish_flow.py +++ b/tasks/generic/publish_flow.py @@ -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 份), diff --git a/tasks/generic/task.py b/tasks/generic/task.py index 906ac40..8d129a0 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -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}") diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index d3d65d8..0b0b64c 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -591,7 +591,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
+ data-perm="devices">上传视频/图片