feat(发布计划): 标题 + 正文(标题 || 正文 _手机号_日期_编号)+ 发布流程多一步填正文;账号名称全面下线
一、正文(video_plan.body,schema v10→v11)
- 标题 txt 一行里可带正文,用 `||` 分隔(标题/正文都常带下划线,不能再用下划线分列;
只切第一处,正文里再出现 `||` 原样保留):`标题 || 正文 _手机号_日期_编号`
正文可省(老文件照收);两种写法(标题在前 / 手机号在前)都能带正文
- 长度:标题 ≤100 字、正文 ≤1000 字,超了**拒收不截断**
- 「待发布」判据从"有没有标题"改成"**标题或正文有没有内容**"(`_sync_content_status` 一处定义,
改标题/改正文/批量导入三处共用),只给正文的行也照样待发
- 导入配对:同标题补一条新正文**要收下**(不能当重复跳过);已有别的标题仍然拒收(不静默覆盖)
二、发布流程多一步
- 骨架 15 → **17 步**:⑪ 输入文字(计划的标题) → ⑫ 点击正文框 → ⑬ 输入文字(计划的正文) → ⑭ 发布 → ⑮ 标记
- 「输入文字」取值来源集中到 `RELEASE_TEXT_SOURCES`:release_title(_topics) / release_body(_topics) /
release_title_body(标题+正文,只有一个输入框的抖音版本用);`build_caption(key=…)` 支持三种来源
- 任务编辑器与发布计划就地编辑器的下拉都补齐这 6 档;待抓的空选择器 5 → 6 个(多了正文框)
三、账号名称下线(用户要求:只要抖音号)
- 发布计划:卡片 / 明细表 / 搜索 / 导出 CSV 都不再有账号名称(CSV 改为
发布日期/设备号/手机号/抖音号/编号/标题/正文/分享链接/发布时间)
- 账号台账:列表列 / 编辑弹窗 / 粘贴导入预览 / 设备维度弹窗 / 导入说明与示例都去掉;
**粘贴导入忽略这一列**(不报错、不落库),库里的历史值保留(不删列)
- 设备端 Agent 身份页:平台不再下发名称(传空串,**不改 APK 也能生效**,避免老版本解析失败)
四、文档 / 测试
- DATA_MODEL:body 列 + schema 11;API:upload_titles(`||` 语义)、PUT {title,body}、CSV 列、骨架 17 步
- TASK_DEV:骨架 17 步、`input_text` 六个取值来源表、正文长度上限
- ARCHITECTURE:release.js 说明(正文、不显示账号名称)
- 测试(%TEMP% 被系统清理后重建):test_plan_body.py 33 项(解析/取内容/骨架/入库/导入配对)、
smoke_app_body.py 10 项(整机装配:页面、PUT 改正文、时间线、CSV 表头)—— 全过
This commit is contained in:
+71
-21
@@ -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(), ""
|
||||
|
||||
Reference in New Issue
Block a user