diff --git a/core/ledger.py b/core/ledger.py index b1d9a3c..a9be584 100644 --- a/core/ledger.py +++ b/core/ledger.py @@ -329,7 +329,9 @@ def agent_accounts(serial="", device_name="", limit=50, include_phone=False): name, rows, note = resolve_device(serial=serial, device_name=device_name) out = [] for r in rows[:max(1, int(limit or 50))]: - item = {"name": r.get("nickname") or "", "douyin_id": r.get("douyin_id") or "", + # name 恒空串:账号名称已停用(设备端 APK 仍会解析这个字段, + # 直接删 key 有解析失败的风险,所以留空串) + item = {"name": "", "douyin_id": r.get("douyin_id") or "", "device_name": r.get("device_name") or "", "device_no": name, "sim_in_device": bool(r.get("sim_in_device")), "can_post_video": bool(r.get("can_post_video"))} @@ -465,6 +467,10 @@ def parse_paste(text, delimiter="\t"): row[f] = "" for field, idx in mapped.items(): if field in FIELDS: + # 账号名称(nickname)**不再收录**:界面上已经去掉这一列, + # 老表格粘进来也不报错,只是这一列的值被忽略(库里的历史值不动) + if field == "nickname": + continue row[field] = cells[idx].strip() if idx < len(cells) else "" if not row["device_name"] and not row["douyin_id"]: errors.append({"line": lineno, "reason": "设备号与抖音号都是空的,已跳过"}) diff --git a/core/models.py b/core/models.py index da53953..7b5e9d8 100644 --- a/core/models.py +++ b/core/models.py @@ -525,7 +525,8 @@ class VideoPlan(db.Model): release_date = db.Column(db.String(10), default="", index=True) # "YYYY-MM-DD"(纯日期,等值比较) seq = db.Column(db.Integer, default=1) # 编号,从 1 起(不用 0 表示"无") seq_auto = db.Column(db.Boolean, default=True) # 编号是自动分配出来的(界面要提示) - title = db.Column(db.Text, default="") # 文案 + title = db.Column(db.Text, default="") # 标题(抖音发布页的「标题」框) + body = db.Column(db.Text, default="") # 正文/文案(发布页的文案框;标题 txt 里用 || 分隔) video_file = db.Column(db.String(120), default="") # 平台落盘文件名(不含绝对路径) video_name = db.Column(db.String(200), default="") # 原始上传名(排查用) video_size = db.Column(db.Integer, default=0) @@ -557,7 +558,8 @@ class VideoPlan(db.Model): "nickname": self.nickname or "", "douyin_id": self.douyin_id or "", "serial": self.serial or "", "release_date": self.release_date or "", "seq": int(self.seq or 1), "seq_auto": bool(self.seq_auto), - "title": self.title or "", "video_file": self.video_file or "", + "title": self.title or "", "body": self.body or "", + "video_file": self.video_file or "", "video_name": self.video_name or "", "video_size": int(self.video_size or 0), "video_sha1": self.video_sha1 or "", "status": self.status or "", "stage": self.stage or "", "attempts": int(self.attempts or 0), @@ -600,6 +602,7 @@ SCHEMA_MIGRATIONS = [ (9, "视频发布计划:video_plan 表(账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接)", None), (10, "视频发布计划:video_plan 新增 push_verify(推送后相册校验:ok/no_index/nofile)" "与 push_remote(手机上的绝对路径,删它/排查用)", None), + (11, "视频发布计划:video_plan 新增 body(正文)—— 标题 txt 一行里用 || 分隔「标题 || 正文」", None), ] # 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version) diff --git a/core/video_plan.py b/core/video_plan.py index 2bd7979..aa85ab0 100644 --- a/core/video_plan.py +++ b/core/video_plan.py @@ -60,6 +60,7 @@ STAGE_STATUS = {"push": ST_FAILED, "scan": ST_FAILED, VIDEO_EXTS = (".mp4", ".mov", ".mkv", ".avi", ".3gp", ".flv", ".wmv", ".m4v", ".ts", ".webm") MAX_TITLE_LEN = 100 # 超过就拒收(**不静默截断**:截断会发出错文案,用户看不见) +MAX_BODY_LEN = 1000 # 正文上限(抖音文案框放得下 1000 字;同样不截断,超了拒收) MAX_ATTEMPTS = 3 # 尝试上限,超了不再自动取(人工确认后重试可清零) ZOMBIE_MINUTES = 30 # 占位行超过这么久没动静 → 按 stage 降级(否则崩溃一次就永远发不出去) VIDEO_KEEP_DAYS = 7 # 平台素材:抓到分享链接后再留这么多天 @@ -164,6 +165,10 @@ def parse_video_name(filename): def parse_title_line(line): """标题行 → (meta, error)。**两种写法都认**,标题里都可以带下划线。 + **一行里可以有标题和正文**:`标题 || 正文 _手机号_日期_编号`(用 `||` 分隔, + 因为标题/正文里常带下划线;只切第一处,正文里再出现 `||` 原样保留)。 + 没写 `||` 就是只有标题(老文件照收);` || 正文_…` 这种只有正文也行。 + ① 标题在前(推荐,右锚定): A) 标题内容_手机号_日期_编号 B) 标题内容_手机号_日期 ② 手机号在前(左锚定,跟视频文件名同序 —— "文件名去掉扩展名 + 标题"): @@ -209,12 +214,19 @@ def parse_title_line(line): if not d: return None, f"日期 {date8} 不是合法日期(应为 20260912 这种 8 位)" date = d - title = "_".join(title_parts).strip() - if not title: - return None, "缺标题内容(只有手机号/日期/编号,没有标题文字)" + raw = "_".join(title_parts).strip() + # 「标题 || 正文」:用 `||` 分隔(标题/正文里常带下划线,所以不能再用下划线分列)。 + # 只切第一处:正文自己再出现 `||` 也原样保留。没写 `||` 就是只有标题(老文件照收)。 + title, body = raw, "" + if "||" in raw: + title, body = (x.strip() for x in raw.split("||", 1)) + if not title and not body: + return None, "缺标题/正文内容(只有手机号/日期/编号,没有文字)" if len(title) > MAX_TITLE_LEN: return None, f"标题太长({len(title)} 字 > {MAX_TITLE_LEN} 字上限),请改短或拆成两条" - return {"title": title, "phone": phone, "date": date, + if len(body) > MAX_BODY_LEN: + return None, f"正文太长({len(body)} 字 > {MAX_BODY_LEN} 字上限),请改短或拆成两条" + return {"title": title, "body": body, "phone": phone, "date": date, "seq": seq, "seq_given": seq is not None}, "" @@ -358,12 +370,21 @@ def pair_slots(videos, titles, accounts_by_phone, db_taken=None): return pairs, rejected -def build_caption(plan, topics=""): - """文案 = 标题 + 话题。**只在这一处定义拼接规则**(两处各配必然分叉)。 +def build_caption(plan, topics="", key="title"): + """文案 = 内容 + 话题。**只在这一处定义拼接规则**(两处各配必然分叉)。 + `key` 选内容来源:`title`(标题)/ `body`(正文)/ `title_body`(标题 + 换行 + 正文, + 有的抖音版本只有一个输入框时用这个)。 `topics` 支持逗号/换行分隔,不带 `#` 的自动补上。 """ - title = (plan.get("title") or "").strip() if isinstance(plan, dict) else "" + plan = plan if isinstance(plan, dict) else {} + if key == "title_body": + title = chr(10).join(x for x in ((plan.get("title") or "").strip(), + (plan.get("body") or "").strip()) if x) + elif key == "body": + title = (plan.get("body") or "").strip() + else: + title = (plan.get("title") or "").strip() tags = [] for chunk in re.split(r"[,,\n]", topics or ""): c = chunk.strip().lstrip("#").strip() @@ -483,8 +504,13 @@ def build_release_steps(): "label": "⑪ 填标题(自动取这一条计划的标题,会回读校验)", "params": {"text_source": "release_title", "release_topics": "", "clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}}, - clk("publish", "⑫ 点击「发布」"), - dict(build_mark_step("⑬ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")), + clk("body", "⑫ 点击正文输入框(发布页的文案框)"), + {"id": sid("bodytxt"), "type": "input_text", + "label": "⑬ 填正文(自动取这一条计划的正文,会回读校验)", + "params": {"text_source": "release_body", "release_topics": "", + "clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}}, + clk("publish", "⑭ 点击「发布」"), + dict(build_mark_step("⑮ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")), ] return [ {"id": sid("light"), "type": "screen_on", "label": "⓪ 亮屏", @@ -716,13 +742,16 @@ def import_titles(text, dry_run=False): counts["rejected"] += 1 continue item["seq"] = int(row.seq or 1) - if (row.title or "").strip() == m["title"]: + # 判"配过了"要**标题和正文都比**:同标题补一条正文是正常操作,不能当重复跳过 + body_in = m.get("body") or "" + if ((row.title or "").strip() == m["title"] + and (not body_in or (row.body or "").strip() == body_in)): item["action"] = "skip" - item["error"] = "这条标题已经配过了" + item["error"] = "这条标题/正文已经配过了" out.append(item) counts["skipped"] += 1 continue - if (row.title or "").strip(): + if (row.title or "").strip() and (row.title or "").strip() != m["title"]: # 已有别的标题 → 拒收(**不静默覆盖**:文案是发出去就改不了的东西; # 要换就到计划页改标题,或先删掉这条标题) item["action"] = "reject" @@ -734,9 +763,11 @@ def import_titles(text, dry_run=False): out.append(item) counts["attached"] += 1 if not dry_run: - row.title = m["title"] - if row.status == ST_PENDING: - row.status = ST_READY + if m["title"]: + row.title = m["title"] # 只有正文的行别把已有标题清掉 + if m.get("body"): + row.body = m["body"] # 只有标题的行别把已有正文清掉 + _sync_content_status(row) row.updated_at = _now() if not dry_run: db.session.commit() @@ -747,20 +778,39 @@ def import_titles(text, dry_run=False): return {"ok": True, "counts": counts, "rows": out, "dry_run": bool(dry_run), "msg": msg} -def set_title(plan_id, title): - """人工改标题(改完 `pending → ready`)。""" +def _sync_content_status(row): + """按"有没有内容(标题或正文)"在 `pending`(待内容)与 `ready`(待发布)之间切。 + + 抽出来是因为改标题、改正文、批量导入三处都要用**同一个判据** —— + 判据分叉就会出现"界面上有正文却显示待标题、推不出去"这种哑火。 + """ + has = bool((row.title or "").strip()) or bool((row.body or "").strip()) + if has and row.status == ST_PENDING: + row.status = ST_READY + elif not has and row.status == ST_READY: + row.status = ST_PENDING + + +def set_title(plan_id, title, body=None): + """人工改标题 /(可选)正文,改完按"有没有内容"切 pending/ready。 + + `body=None` = **不动正文**(老调用方语义不变);传空串 = 清掉正文。 + """ title = (title or "").strip() if len(title) > MAX_TITLE_LEN: return None, f"标题太长(上限 {MAX_TITLE_LEN} 字)" + if body is not None: + body = (body or "").strip() + if len(body) > MAX_BODY_LEN: + return None, f"正文太长(上限 {MAX_BODY_LEN} 字)" with _ctx(): row = VideoPlan.query.get(plan_id) if not row: return None, "计划不存在" row.title = title - if title and row.status == ST_PENDING: - row.status = ST_READY - elif not title and row.status == ST_READY: - row.status = ST_PENDING + if body is not None: + row.body = body + _sync_content_status(row) row.updated_at = _now() db.session.commit() return row.to_dict(), "" diff --git a/doc/API.md b/doc/API.md index c58c429..c1e2811 100644 --- a/doc/API.md +++ b/doc/API.md @@ -149,7 +149,7 @@ | POST | `/api/video_plan/upload_titles` | D | 上传标题(txt 文本或文件)→ 配到已有计划行 | | GET | `/api/video_plan/timeline` | D | 发布计划时间线(按日期分组的卡片 + 统计 + 磁盘) | | GET | `/api/video_plan/stats` | D | 今天要发/已发/待标题/过期未发 + 素材占用与磁盘余量 | -| GET/PUT/DELETE | `/api/video_plan/` | D | 单条计划:查看 / 改标题 / 删除(连素材文件) | +| GET/PUT/DELETE | `/api/video_plan/` | D | 单条计划:查看 / 改标题·正文(`PUT {title,body}`)/ 删除(连素材文件) | | GET | `/api/video_plan//video` | D | 预览平台上的素材(文件清理后 → 410) | | POST | `/api/video_plan//push` | D | **把素材推到手机**(只推送、不发布;后台线程) | | POST | `/api/video_plan/push_all` | D | **一键推送**:某天(默认今天)所有待发布/失败的计划 → 各自手机(每台设备一个线程、设备内串行) | @@ -157,7 +157,7 @@ | POST | `/api/video_plan//skip\|retry\|resolve` | D | 跳过 / 重试 / 裁决「结果未知」 | | GET | `/api/video_plan/links` | D | 已发布作品的分享链接(`format=csv` 导出,给铺评论用) | | GET | `/api/video_plan/tasks` | T | 可编辑的任务列表(全部通用步骤任务 + `is_release` 标记:状态/调度/下次运行 + 完整步骤) | -| POST | `/api/video_plan/tasks` | T | 一键新建标准发布任务(骨架 15 步:亮屏 → 打开抖音 → 校验账号 → 推送 → 抖音点击 → 填标题 → 标记) | +| POST | `/api/video_plan/tasks` | T | 一键新建标准发布任务(骨架 17 步:亮屏 → 打开抖音 → 校验账号 → 推送 → 抖音点击 → 填标题 → 填正文 → 标记) | | GET/PUT | `/api/video_plan/tasks/` | T | **发布任务的就地编辑**(就在「发布计划」页改名字/目标/时间/启停/步骤) | | POST | `/api/video_plan/tasks//adopt` | T | 给已有任务插上平台两步(推送放最前 / 标记放最后,自己的步骤不动) | @@ -543,7 +543,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_titles` | `{text, dry_run}` 或 multipart `file`(txt) | `{ok, counts:{total,attached,skipped,rejected}, rows:[{line,phone,date,seq,title,action,error}], msg}`;**每行两种写法都认**:`标题内容_手机号_日期_编号`(推荐,右锚定,标题里可带下划线)或 `手机号_日期_编号_标题`(左锚定,跟视频文件名同序),**编号都可省略**;标题里可以有 `#话题`;空行忽略,`#` 开头的行**只有认不出手机号/日期时**才当注释;拒收条件:手机号不在台账/命中多个、日期不合法、标题为空或 >100 字、该槽位已有标题、没有对应视频 | +| `POST /api/video_plan/upload_titles` | `{text, dry_run}` 或 multipart `file`(txt) | `{ok, counts:{total,attached,skipped,rejected}, rows:[{line,phone,date,seq,title,body,action,error}], msg}`;**一行里可带正文**:`标题 || 正文 _手机号_日期_编号`(`||` 分隔,正文可省,只切第一处);**两种写法都认**:标题在前(推荐,右锚定,标题里可带下划线)或 `手机号_日期_编号_标题 || 正文`(左锚定,跟视频文件名同序),**编号都可省略**;标题里可以有 `#话题`;空行忽略,`#` 开头的行**只有认不出手机号/日期时**才当注释;拒收条件:手机号不在台账/命中多个、日期不合法、标题为空或 >100 字、该槽位已有标题、没有对应视频 | | `GET /api/video_plan/timeline` | `?range=today\|week\|all&from=&to=&status=&account_id=&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/` · `PUT` · `DELETE` | `PUT {"title"}` | `PUT` 只改标题(改完 `pending→ready`);`DELETE` **先删行再删素材文件** | @@ -554,9 +554,9 @@ | `POST /api/video_plan//skip` | `{reason}` | 人工跳过 | | `POST /api/video_plan//retry` | — | `failed`→`ready`,`attempts` 清零 | | `POST /api/video_plan//resolve` | `{published, note}` | **`unknown` 的人工裁决**:确认真发出去了 → `done`;确认没发 → `failed`(可重试) | -| `GET /api/video_plan/links` | `?from=&to=&device=&phone=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码) | +| `GET /api/video_plan/links` | `?from=&to=&device=&phone=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码)。列:发布日期/设备号/手机号/抖音号/编号/标题/**正文**/分享链接/发布时间(**不再导出账号名称**) | | `GET /api/video_plan/tasks` | — | `{ok, tasks:[{id,name,enabled,mode,cron,next_run,target,steps,step_count,schedule,retry,is_release,has_push}], release_count, other_count}`:**全部通用步骤任务**(不止发布任务 —— 只列发布任务的话,手写的抖音发布流程会一条都不显示,人会以为"这页没有能改的地方"),界面按 `is_release`(有 `push_release` **或** `mark_release`,含嵌套)分两组。带完整 `steps` 是为**就地编辑**(抓选择器);`step_count` 数的是**含嵌套**的总步数。权限 `T` | -| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **15 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `if_el(cmp_source=release)` 校验账号 → then: `push_release` → 「+」(预填 `descriptionContains=拍摄`) → 相册/第一个视频/下一步/输入框/发布(**空选择器占位**)→ `input_text(text_source=release_title)` → `mark_release`;else: `notify` 跳过);**走 TaskManager 建**(内存+库一起更新,直接写库调度器不认)。⚠ `target` 分组键名是 **`group_name`**(写成 `group` 会静默解析出 0 台设备);分组/单设备没给具体目标 → 400。权限 `T` | +| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **17 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `if_el(cmp_source=release)` 校验账号 → then: `push_release` → 「+」(预填 `descriptionContains=拍摄`) → 相册/第一个视频/下一步/标题框/正文框/发布(**空选择器占位**)→ `input_text(text_source=release_title)` → `input_text(text_source=release_body)` → `mark_release`;else: `notify` 跳过);**走 TaskManager 建**(内存+库一起更新,直接写库调度器不认)。⚠ `target` 分组键名是 **`group_name`**(写成 `group` 会静默解析出 0 台设备);分组/单设备没给具体目标 → 400。权限 `T` | | `GET/PUT /api/video_plan/tasks/` | `PUT {name?, time?, target?, steps?, enabled?}` | **发布任务的就地编辑**(「账号 → 发布计划」页里改,不用跳任务页)。只放开这几个字段,**没传的字段原样保留**;`steps` 整块替换但 `params` 里的通用参数(去重有效期、抢占…)**深合并保留**。校验:空名字/坏时间/`steps` 不是数组/什么都不传 → 400,未知任务 → 404。权限 `T`。底层仍是 `TaskManager.update_job()`(内存+库一起改) | | `POST /api/video_plan/tasks//adopt` | — | 给**已有任务**插上平台那两步:`push_release` 放最前、`mark_release` 放最后(**中间你自己的步骤一步不动**;位置理由见 `core.video_plan.insert_release_steps`)。只对「通用步骤」任务;没有步骤 → 400,已经有这两步 → 409,未知任务 → 404。权限 `T` | diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 57d83b6..e04781e 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -298,7 +298,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set | `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** | | `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) | | `ledger.js` | 账号台账页(列表 / 单条增删改 / 从表格粘贴导入 + 逐行结果 / 设备维度弹窗)+ `ledgerCell()` 供设备池那一列 | 服务层 `core/ledger.py`;导入默认**先预览再写**(`dry_run`) | -| `release.js` | 发布计划页(时间线按日期分组 / 多选视频**串行**上传 + 逐文件进度与结果 / 标题 txt 上传 / 推送到手机 + **一键推送**(当天全部)、相册校验徽章与裁决 / 链接复制与导出 CSV / **发布任务的就地编辑**) | 服务层 `core/video_plan.py`;上传前把队列**按文件名排序**并让用户确认解析结果(顺序 = "无编号按顺序配"的依据)。就地编辑只放常用字段(名字/目标/时间/启停/逐步参数 + 抓取元素),更复杂的编排走「打开通用编辑器」→ `openTaskModal`;步骤路径里 `then/else/children` 在**步骤的 params 下**(`_rpWalk`),走错这一层会把分支当数组下标用 | +| `release.js` | 发布计划页(时间线按日期分组 / 多选视频**串行**上传 + 逐文件进度与结果 / 标题+正文 txt 上传(`标题 || 正文 _手机号_日期_编号`,**不显示账号名称、只显示抖音号**)/ 推送到手机 + **一键推送**(当天全部)、相册校验徽章与裁决 / 链接复制与导出 CSV / **发布任务的就地编辑**) | 服务层 `core/video_plan.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 f2618b1..1694b37 100644 --- a/doc/DATA_MODEL.md +++ b/doc/DATA_MODEL.md @@ -16,7 +16,7 @@ | SQLite 回退时的 PRAGMA | `journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL`(监听器按连接类型守卫,MySQL 连接不会执行) | | 建表方式 | **唯一真相是模型**:`db.create_all()`(建缺表)+ `_sync_columns()`(补缺列);`SCHEMA_MIGRATIONS` 只作版本账本与数据回填 | | 库位置 | MySQL:由 `DB_HOST/DB_NAME` 指定;SQLite:`data/users.db` | -| 当前 schema 版本 | `app_meta.schema_version = 10`(迁移清单见 `core/models.py` 的 `SCHEMA_MIGRATIONS`:建表/补列以模型为准,这里只作版本账本) | +| 当前 schema 版本 | `app_meta.schema_version = 11`(迁移清单见 `core/models.py` 的 `SCHEMA_MIGRATIONS`:建表/补列以模型为准,这里只作版本账本) | **环境与库的绑定**(防混库,见 [DEPLOY.md](DEPLOY.md) §2.2) @@ -248,7 +248,8 @@ | `device_name` / `nickname` / `douyin_id` / `serial` | String | 账号与设备快照(时间线卡片直接显示,免 join) | | `release_date` | String(10) index | **`2026-09-12`** 纯日期(等值比较走索引) | | `seq` / `seq_auto` | Integer / Boolean | 编号从 **1** 起(不用 0 表示"无");`seq_auto`=号是自动分配的 | -| `title` | Text | 文案(标题 txt 配对写入;**空标题不会被发布**) | +| `title` | Text | **标题**(发布页的「标题」框;标题 txt 配对写入) | +| `body` | Text | **正文/文案**(发布页的文案框,可带 `#话题`)—— 标题 txt 一行里用 `||` 分隔:`标题 || 正文 _手机号_日期_编号`;**标题与正文都为空的行不会被发布** | | `video_file` / `video_name` / `video_size` / `video_sha1` | 各自 | 落盘相对路径 / 原始名 / 大小 / 内容指纹(重复上传判据) | | `status` | String(16) index | 见下方状态机 | | `stage` | String(16) | 失败发生在哪一步:`push`/`scan`/`post`/`verify` | diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index 7838b51..40c929f 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -127,7 +127,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) | 10 | `click_xy` | 点击坐标 | — | `x`(50)、`y`(50) | — | **屏幕百分比**(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,**最脆的方式** | | 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` | | 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) | -| 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True)、`text_source`("manual")、`release_topics`("") | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;`text_source=release_title`(或 `release_title_topics`)时**取本机当前那条发布计划的标题**(配 `release_topics` 自动加话题),**并回读输入框校验**(不校验就可能发出一篇空文案);**只负责输入,不负责定位输入框**(要先 click 输入框) | +| 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True)、`text_source`("manual")、`release_topics`("") | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;`text_source` 还可取**本机当前那条发布计划**的内容:`release_title`(±`_topics`) = 标题、`release_body`(±`_topics`) = 正文、`release_title_body` = 标题+正文(配 `release_topics` 自动加话题),**并回读输入框校验**(不校验就可能发出一篇空文案);**只负责输入,不负责定位输入框**(要先 click 输入框) | | 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走**设备端 Agent** 通道(透明 Activity,绕开 Android 10+ 后台写剪贴板限制);老设备上只有独立 ClipInject 时自动兜底(见 `core/clipboard_helper.py`);`paste=true` 时再触发一次粘贴 | | 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0)、`vary_pace`(False) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 `vary_pace` 再按**本设备节奏**缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开) | | 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 | @@ -562,15 +562,16 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) 这类任务行上会标「**缺平台推送步骤**」并给一个「**插上平台步骤**」按钮 (`POST /api/video_plan/tasks//adopt`:推送放最前、标记放最后、**你原来的步骤一步不动**)。 还支持**一键新建标准发布任务** —— -骨架 **15 步**已经排好: +骨架 **17 步**已经排好(**标题和正文各一步** —— 对应抖音发布页上那两个框): ``` ⓪ 亮屏 ① 打开抖音(等「首页」出来) ② 点击「我」(desc 含"我") ③ 等待 3~5s ④ 条件判断 //*[contains(@text,'抖音号')] 包含 cmp_source=release ├─ then(登的就是要发的号)⑤ 推送发布视频 ⑥ 点击「+」(desc 含"拍摄") │ ⑦ 相册 ⑧ 第一个视频 ⑨ 下一步 ⑩ 输入框 ⑪ 输入文字(text_source=release_title) - │ ⑫ 点击「发布」 ⑬ 标记发布结果 - └─ else(不是这个号 / 今天没有待发计划)⑭ 发通知"本条跳过" + │ ⑫ 点击正文输入框 ⑬ 输入文字(text_source=release_body) + │ ⑭ 点击「发布」 ⑮ 标记发布结果 + └─ else(不是这个号 / 今天没有待发计划)⑯ 发通知"本条跳过" ``` > ⚠ **⓪ 亮屏 + ① 等「首页」是踩出来的,别删**:真机实测(2026-09-28,A01)—— @@ -579,17 +580,24 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) > 屏幕亮着时冷启动 5 秒就出底部栏。 建好之后**就在「发布计划」这一块点「编辑」就地改**(名字/目标/每天几点/启停/逐步参数, -选择器旁边就有「抓取元素」),把 5 个空选择器(相册/第一个视频/下一步/输入框/发布)抓一次就能跑 —— +选择器旁边就有「抓取元素」),把 6 个空选择器(相册/第一个视频/下一步/标题框/正文框/发布)抓一次就能跑 —— 要加步骤、循环、自定义动作这些更复杂的编排,才走「打开通用编辑器」(跳任务页)—— **抖音那几步故意不写死选择器**:混淆 id / 结构 xpath 改版就废, 抓一次的成本远低于每次发版都改平台代码。②⑥ 两条预填的是**按描述定位** (`descriptionContains`,跨版本稳),一般不用改。 -**「输入文字」自动用标题**:`text_source=release_title` 会取**本机当前那条计划**的标题 -(`push_release` 同一次运行里传递;断档了就取最近一条"已推送到手机"的), -`release_title_topics` 还能自动补话题;**取计划标题时会回读输入框校验**, -不通过就改走剪贴板通道再验一次 —— 抖音的 EditText 有时不吃键盘输入, -不校验的后果是"发出一篇空文案,而每一步都显示成功"。 +**「输入文字」自动用计划里的内容**(`text_source`,常量 `RELEASE_TEXT_SOURCES` 一处定义): + +| text_source | 取什么 | 用在哪个框 | +|---|---|---| +| `release_title` / `release_title_topics` | 计划的**标题**(`video_plan.title`) | 抖音发布页的「标题」框 | +| `release_body` / `release_body_topics` | 计划的**正文**(`video_plan.body`) | 抖音发布页的文案/正文框 | +| `release_title_body` | 标题 + 换行 + 正文 | 只有一个输入框的抖音版本 | + +带 `_topics` 的会再把 `release_topics`(逗号分隔)拼成 `#话题`。 +内容由 `push_release` 在同一次运行里传递(断档了就取最近一条"已推送到手机"的); +**取计划内容时会回读输入框校验**,不通过就改走剪贴板通道再验一次 —— +抖音的 EditText 有时不吃键盘输入,不校验的后果是"发出一篇空文案,而每一步都显示成功"。 **抓分享链接**(`mark_release` 的 `capture_link=True`,或计划页人工点「标记已发布」时): 进「我 → 作品」第一条 → 分享 → 复制链接 → 读剪贴板 → 正则抠出 `v.douyin.com` 链接。 diff --git a/static/admin/editor.js b/static/admin/editor.js index 1989539..86fc417 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -444,11 +444,13 @@ var _stepEditor={ const fromPlan=(tsrc!=='manual'); // 取值来源:默认手填(行为与以前一致);也可以直接吃「发布计划」里那一条的标题 h+='
选"发布计划"时,标题由前面的「推送发布视频」那一步取出来(同一次运行里传递;没取到会跳过并告警)—— 你就不用把同一句文案再抄一遍了
'; if(fromPlan){ - h+='
'; + h+='
'; h+='
取计划标题时会回读输入框校验:抖音的输入框有时不吃键盘输入,不校验就可能发出一篇空文案;校验不过会自动改走剪贴板粘贴再验一次
'; } // 手填那两块:**只隐藏不删**(_syncParams 按 DOM 存在与否收集,删了会把用户填的值抹掉) diff --git a/static/admin/ledger.js b/static/admin/ledger.js index bd92fc5..04847f6 100644 --- a/static/admin/ledger.js +++ b/static/admin/ledger.js @@ -20,7 +20,7 @@ function loadLedger() { apiGet('/api/ledger?limit=2000').then(function (r) { var tb = document.getElementById('tb-ledger'); if (!r || !r.ok) { - if (tb) tb.innerHTML = '读取失败:' + esc((r && r.error) || '') + ''; + if (tb) tb.innerHTML = '读取失败:' + esc((r && r.error) || '') + ''; return; } _ledgerAll = r.accounts || []; @@ -60,13 +60,13 @@ function renderLedger() { setListPager('ledger', { data: function () { return rows; }, filter: function (a, kw) { - return ((a.douyin_id || '') + (a.phone || '') + (a.nickname || '') + return ((a.douyin_id || '') + (a.phone || '') + (a.device_name || '') + (a.note || '')).toLowerCase().indexOf(kw) >= 0; }, sortValue: function (a, k) { return a[k]; }, render: renderLedgerRow, tbody: 'tb-ledger', pager: 'pager-ledger', pageSize: 20, - empty: '还没有账号 —— 点「粘贴导入」把你那张表贴进来' + empty: '还没有账号 —— 点「粘贴导入」把你那张表贴进来' }); applyListPager('ledger'); initListSearch('ledger', 'search-ledger'); @@ -86,7 +86,6 @@ function _clip(s, n) { function renderLedgerRow(a) { return '' + '' + esc(a.device_name || '-') + '' + - '' + esc(a.nickname || '-') + '' + '' + esc(a.douyin_id || '-') + '' + '' + esc(a.phone || '-') + '' + '' + esc(a.registered_at || '-') + '' + @@ -117,10 +116,9 @@ function _ledgerField(label, key, val, ph) { function openLedgerModal(id) { var a = id ? _ledgerById(id) : null; - document.getElementById('modal-title').textContent = a ? ('编辑账号:' + (a.nickname || a.douyin_id)) : '新增账号'; + document.getElementById('modal-title').textContent = a ? ('编辑账号:' + (a.douyin_id || a.phone)) : '新增账号'; var h = '
' + _ledgerField('设备号', 'device_name', a && a.device_name, '如 A01(要和设备池里的设备名一致)') + - _ledgerField('账号名称', 'nickname', a && a.nickname, '如 AA建材王总') + '
' + '
' + _ledgerField('抖音号', 'douyin_id', a && a.douyin_id, '纯号,如 35377983067(不带「抖音号:」前缀)') + _ledgerField('手机号', 'phone', a && a.phone, '') + '
' + @@ -167,7 +165,7 @@ function saveLedger(id) { function deleteLedger(id) { var a = _ledgerById(id); - var who = a ? ((a.device_name || '') + ' / ' + (a.nickname || '') + ' / ' + (a.douyin_id || '')) : id; + var who = a ? ((a.device_name || '') + ' / ' + (a.douyin_id || '')) : id; if (!confirm('删掉这条账号?\n\n' + who + '\n\n删掉之后:任务从台账取号就取不到它了。')) return; apiDelete('/api/ledger/' + id).then(function (r) { if (r && r.ok) { showToast(r.msg || '已删除', 'success'); loadLedger(); loadLedgerCounts(); } @@ -182,7 +180,7 @@ function openLedgerImport() { document.getElementById('modal-body').innerHTML = '
' + '把 Excel 里的表格连表头一起复制,粘到下面。首行是列名,顺序随意、缺列留空。
' + - '认得的列名:设备号 / 手机号 / 账号名称 / 抖音号 / 注册时间 / 卡在机内 / 可发视频 / 简介 / 备注' + + '认得的列名:设备号 / 手机号 / 抖音号 / 注册时间 / 卡在机内 / 可发视频 / 简介 / 备注(账号名称列会被忽略)' + '(认不出的列会被忽略)。没有表头时按上面这个顺序解析。
' + '「卡在机内 / 可发视频」认 是/有/1 与 否/空。' + '
' + @@ -197,7 +195,7 @@ function openLedgerImport() { '' + '' + '
' + + 'style="font-family:var(--mono);font-size:12px" placeholder="设备号 手机号 抖音号 注册时间 卡在机内 可发视频 简介 备注 A01 13800000000 35000000001 2026/9/24 否 是 ">' + '
'; document.getElementById('modal-footer').innerHTML = '' + @@ -243,7 +241,6 @@ function renderImportResult(r) { '' + a[1] + '' + '' + esc(x.device_name || '') + '' + '' + esc(x.douyin_id || '') + '' + - '' + esc(x.nickname || '') + '' + '' + esc(msg) + ''; }).join(''); var errs = (r.errors || []).map(function (e) { @@ -252,7 +249,7 @@ function renderImportResult(r) { box.innerHTML = head + _importErrorsNote(errs) + '
' + - '' + + '' + '' + (rows || '') + '
行动作设备号抖音号账号名称说明
行动作设备号抖音号说明
没有可导入的行
' + (r.dry_run ? '
确认没问题后点「直接导入」。
' : ''); } @@ -284,7 +281,7 @@ function showDeviceAccounts(name) { } document.getElementById('modal-title').textContent = name + ' 上的账号(' + (g.count || 0) + ' 个)'; var rows = (g.accounts || []).map(function (a) { - return '' + esc(a.nickname || '-') + '' + + return '' + '' + esc(a.douyin_id || '-') + '' + '' + esc(a.phone || '-') + '' + '' + _yn(a.sim_in_device) + '' + _yn(a.can_post_video) + '' + @@ -292,8 +289,8 @@ function showDeviceAccounts(name) { }).join(''); document.getElementById('modal-body').innerHTML = '
' + - '' + - '' + (rows || '') + '
账号名称抖音号手机号卡在机内可发视频注册时间备注
这台设备还没有登记账号
' + + '抖音号手机号卡在机内可发视频注册时间备注' + + '' + (rows || '这台设备还没有登记账号') + '' + '
在「账号」页可以增删改;设备端 Agent 的「身份大字页」显示的就是这些。
'; document.getElementById('modal-footer').innerHTML = ''; showModal(); diff --git a/static/admin/release.js b/static/admin/release.js index 0974d2b..05a552e 100644 --- a/static/admin/release.js +++ b/static/admin/release.js @@ -97,14 +97,16 @@ function renderReleaseTimeline() { } function rpCard(p) { - var acc = '' + esc(p.device_name || '?') + ' ' + esc(p.nickname || '') - + ' ' + var acc = '' + esc(p.device_name || '?') + ' ' + + '' + esc(p.douyin_id || p.phone) + ''; var video = p.video_name ? '' + esc(p.video_name.slice(0, 40)) + '' + (p.video_deleted_at ? ' 素材已清理' : '') : '素材已清理'; - var title = p.title ? esc(p.title.slice(0, 30)) : '⚠ 缺标题'; + var title = p.title ? esc(p.title.slice(0, 30)) + : (p.body ? '' : '⚠ 缺标题/正文'); + var body = p.body ? esc(p.body.slice(0, 60)) : '(无正文)'; var link = p.share_url ? '' + esc(p.share_url.replace('https://v.douyin.com/', '…/')) + '' @@ -126,7 +128,7 @@ function rpCard(p) { if (p.status === 'failed' || p.status === 'unknown') { ops.push(''); } - ops.push(''); + ops.push(''); if (p.status !== 'done' && p.status !== 'skipped') { ops.push(''); } @@ -153,8 +155,8 @@ function rpCard(p) { + (p.seq_auto ? '(自动)' : '') + ' ' + rpStatusLabel(p.status) + (p.attempts ? ' 已试 ' + p.attempts + ' 次' : '') + pushTag + '' + '
' + ops.join(' ') + '
' - + '
素材:' + video + ' | 标题:' + title - + (link ? ' | ' + link : '') + '
' + + '
素材:' + video + ' | 标题:' + title + '
' + + '
正文:' + body + (link ? ' | ' + link : '') + '
' + (p.published_at ? '
发布于 ' + esc(p.published_at) + '
' : '') + warn + ''; } @@ -163,24 +165,26 @@ function renderReleaseTable() { setListPager('release', { data: function () { return _rpRows; }, filter: function (p, kw) { - return ((p.device_name || '') + (p.nickname || '') + (p.phone || '') + (p.douyin_id || '') - + (p.title || '') + (p.video_name || '')).toLowerCase().indexOf(kw) >= 0; + return ((p.device_name || '') + (p.phone || '') + (p.douyin_id || '') + + (p.title || '') + (p.body || '') + (p.video_name || '')).toLowerCase().indexOf(kw) >= 0; }, sortValue: function (p, k) { return p[k]; }, render: function (p) { return '' + esc(p.release_date) + '' + esc(p.device_name || '-') + '' - + '' + esc(p.nickname || '-') + '/' + esc(p.douyin_id || p.phone) + '' + + '' + esc(p.douyin_id || p.phone || '-') + '' + '' + p.seq + (p.seq_auto ? '*' : '') + '' + '' + esc(p.video_name || '-') + '' + '' + esc(p.title || '-') + '' + + '' + esc(p.body || '-') + '' + '' + rpStatusLabel(p.status) + '' + '' + (p.share_url ? '' : '-') + '' - + ''; + + ''; }, tbody: 'tb-release', pager: 'pager-release', pageSize: 20, - empty: '这个范围里没有计划' + empty: '这个范围里没有计划' }); applyListPager('release'); initListSearch('release', 'search-release'); @@ -477,14 +481,30 @@ function rpDelete(id) { }); } +// 改标题 + 正文(发布页两个框分别填;都按"有没有内容"决定这条是不是「待发布」) function rpEditTitle(id) { var p = null; for (var i = 0; i < _rpRows.length; i++) if (_rpRows[i].id === id) p = _rpRows[i]; - var cur = p ? (p.title || '') : ''; - var t = prompt('标题(发布文案):', cur); - if (t === null) return; - apiPut('/api/video_plan/' + id, { title: t }).then(function (r) { - if (r && r.ok) { showToast(r.msg, 'success'); loadReleasePlan(); } else showToast((r && r.error) || '失败', 'error'); + document.getElementById('modal-title').textContent = '改标题 / 正文'; + document.getElementById('modal-body').innerHTML = + '
抖音发布页有两个框:标题(短,用于搜索展示)和正文(文案,可带 #话题)。' + + '两个框在任务里各由一个「输入文字」步骤填(取值来源分别选「计划的标题」/「计划的正文」)。
' + + '
' + + '
' + + '
' + + '
'; + document.getElementById('modal-footer').innerHTML = + '' + + ''; + showModal(); +} + +function rpSaveTitleBody(id) { + var t = (document.getElementById('rp-ed-t') || {}).value || ''; + var b = (document.getElementById('rp-ed-b') || {}).value || ''; + apiPut('/api/video_plan/' + id, { title: t, body: b }).then(function (r) { + if (r && r.ok) { closeModal(); showToast(r.msg || '已改', 'success'); loadReleasePlan(); } + else showToast((r && r.error) || '失败', 'error'); }); } @@ -672,7 +692,10 @@ var _RP_FIELDS = { wait: [['min', '最短(秒)', 'num'], ['max', '最长(秒)', 'num']], input_text: [['text_source', '文案从哪来', 'sel', [['manual', '手填(下面那栏)'], ['release_title', '这条发布计划的标题'], - ['release_title_topics', '计划的标题 + 话题']]], + ['release_title_topics', '计划的标题 + 话题'], + ['release_body', '这条发布计划的正文'], + ['release_body_topics', '计划的正文 + 话题'], + ['release_title_body', '标题 + 正文(一个框时用)']]], ['release_topics', '话题(逗号分隔)', 'text'], ['fixed_text', '手填的文案(上面的来源选手填时才用)', 'area']], push_release: [['date_source', '发哪天的计划', 'sel', [['today', '今天'], ['fixed', '指定日期']]], diff --git a/tasks/generic/task.py b/tasks/generic/task.py index b13f308..f146288 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -72,6 +72,16 @@ def _norm_legacy_xpath(sel_val): # ================== 步骤类型定义(前端操作库 + 后端执行共用)================== +# 「输入文字」的取值来源:plan 的字段名 → build_caption 的 key。 +# 集中一处定义,免得下面判据分叉(取了标题却当正文填)——加新来源只改这里。 +RELEASE_TEXT_SOURCES = { + "release_title": "title", + "release_title_topics": "title", + "release_body": "body", + "release_body_topics": "body", + "release_title_body": "title_body", +} + STEP_TYPES = [ {"type": "open_app", "label": "打开App", "icon": "▶", "params": {"package": "", "wait_home": False, "home_feature": ""}}, @@ -1376,13 +1386,15 @@ class GenericStepsWorker(BaseWorker): · `manual`(默认)= 用上面的 mode(行为与以前完全一致) · `release_title` = **本机当前那条发布计划的标题**("推送发布视频"推的那条; 本机没有就取最近一条已推送的)· 配 `release_topics` 还能自动加话题 + · `release_body` = 那条计划的**正文**(同一套来源与回读校验) + · `release_title_body` = 标题 + 换行 + 正文(抖音只有一个输入框时用这个) 选发布计划来源时**会回读校验**(读输入框的 text):不校验的后果是 "抖音不吃 send_keys → 发出一篇空文案,而每一步都显示成功"; 校验不过就自动改走剪贴板通道粘贴(设备端 Agent)再验一次。 """ src = str(params.get("text_source") or "manual").strip() text = "" - if src in ("release_title", "release_title_topics"): + if src in RELEASE_TEXT_SOURCES: from core import video_plan as vp plan = getattr(self, "_last_release", None) if not plan: @@ -1390,14 +1402,14 @@ class GenericStepsWorker(BaseWorker): 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 "") + plan, params.get("release_topics") or "", + key=RELEASE_TEXT_SOURCES[src]) self._last_release = self._last_release or plan - _log.info(f"[{self.serial}] 输入文字取计划标题:" - f"{plan.get('nickname') or plan.get('phone')} " + _log.info(f"[{self.serial}] 输入文字取计划{RELEASE_TEXT_SOURCES[src]}:" + f"{plan.get('douyin_id') or plan.get('phone')} " f"{plan.get('release_date')} #{plan.get('seq')}") if not text: - _log.warning(f"[{self.serial}] 输入文字:本机没有可用的发布计划标题 → 跳过" + _log.warning(f"[{self.serial}] 输入文字:本机没有可用的发布计划内容 → 跳过" f"(前面要先有「推送发布视频」步骤)") return None if not text: @@ -1433,7 +1445,7 @@ class GenericStepsWorker(BaseWorker): _log.warning(f"[{self.serial}] input_text 失败: {e2}") # 取计划标题时**必须回读校验**:抖音的 EditText 有时不吃 send_keys, # 不校验就可能发出一篇空文案而每一步都显示成功 - if src in ("release_title", "release_title_topics"): + if src in RELEASE_TEXT_SOURCES: return self._verify_input(d, text, clear_first) return None diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 8743e2d..47e11a5 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -608,7 +608,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 - @@ -638,7 +637,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 + data-perm="devices">上传标题/正文 @@ -682,8 +681,8 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 明细表(与时间线同一份数据)
设备号账号名称 抖音号 手机号 注册时间
- - + +
发布日期设备号账号编号视频文件标题状态分享链接操作发布日期设备号抖音号编号视频文件标题正文状态分享链接操作
@@ -691,7 +690,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
视频文件名规则 手机号_日期_编号(编号可省略,日期如 20260912 就是发布日期); - 标题 txt 每行 标题内容_手机号_日期_编号 或 手机号_日期_编号_标题(两种写法都认,编号都可省略,标题里可以有下划线、可以有 #话题;# 开头的行只要认得出手机号/日期就当标题,认不出才当注释忽略)。 + 标题 txt 每行 标题 || 正文 _手机号_日期_编号(正文可省;也能写成 手机号_日期_编号_标题 || 正文)(两种写法都认,编号都可省略,标题里可以有下划线、可以有 #话题;# 开头的行只要认得出手机号/日期就当标题,认不出才当注释忽略)。 手机号必须在「账号台账」里,否则拒收。
上传后自动按 (手机号, 日期, 编号) 配对:有编号按编号精确对,没编号按顺序配 —— 没传标题的视频不会被推送(先在计划页改标题或补传标题)。
diff --git a/web/video_plan_api.py b/web/video_plan_api.py index 9ff2275..cdc090a 100644 --- a/web/video_plan_api.py +++ b/web/video_plan_api.py @@ -105,11 +105,11 @@ def api_vp_one(pid): return jsonify({"ok": False, "error": "计划不存在"}), 404 return jsonify({"ok": True, "msg": "已删除(素材文件一并删除)"}) data = request.json or {} - if "title" in data: - row, err = vp.set_title(pid, data.get("title") or "") + if "title" in data or "body" in data: + row, err = vp.set_title(pid, data.get("title") or "", body=data.get("body")) if err: return jsonify({"ok": False, "error": err}), 400 if "太长" in err else 404 - return jsonify({"ok": True, "msg": "标题已改", "plan": row}) + return jsonify({"ok": True, "msg": "标题/正文已改", "plan": row}) return jsonify({"ok": False, "error": "没有要改的字段"}), 400 @@ -493,12 +493,12 @@ def api_vp_links(): if (request.args.get("format") or "") == "csv": buf = io.StringIO() w = csv.writer(buf) - w.writerow(["发布日期", "设备号", "手机号", "账号名称", "抖音号", - "编号", "标题", "分享链接", "发布时间"]) + w.writerow(["发布日期", "设备号", "手机号", "抖音号", + "编号", "标题", "正文", "分享链接", "发布时间"]) for r in rows: - w.writerow([r["release_date"], r["device_name"], r["phone"], r["nickname"], - r["douyin_id"], r["seq"], r["title"], r["share_url"], - r["published_at"]]) + w.writerow([r["release_date"], r["device_name"], r["phone"], + r["douyin_id"], r["seq"], r["title"], r.get("body", ""), + r["share_url"], r["published_at"]]) data = ("" + buf.getvalue()).encode("utf-8") # BOM:Excel 打开中文不乱码 return send_file(io.BytesIO(data), mimetype="text/csv", as_attachment=True, download_name=f"video_links_{vp._today()}.csv")