Files
yuzhiran 7e953afbd2 feat: 创作工作台统一 + AI味检测/白标 + 移动端补全 + 合规发布闭环
- 新增创作工作台 studio.html:合并选题/内容工厂/文章管理为单一 tab 入口(iframe embed 模式)
- 新增 AI味检测模块(ai_slop API + 页面,合规软硬问题分级)
- 新增白标品牌配置(branding API + 页面 + deploy 私有化交付包)
- 发布闭环:publishing 放宽至 editor + records/mark-published 接口
- 移动端响应式补全(admin/calendar/ai-slop 表格卡片兜底)
- 修复菜单幂等播种缺陷(按 path 对齐,避免功能页孤立)
- 新增短视频脚本 shortvideo.py 与 2026 市场调研简报
2026-07-11 12:36:01 +08:00

190 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
长文 → 短视频脚本改写器
读取某选题的长文(知乎/公众号 markdown),生成 N 条差异化短视频口播/分镜脚本。
"""
import argparse
import datetime
import json
import logging
import sys
from pathlib import Path
from typing import Dict, List, Optional
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
from db_helper import get_topic_by_id, get_articles_by_topic, save_article
from prompt_loader import get_prompt, get_prompt_params
try:
from app.core.nvidia_client import call_llm
HAVE_LLM = True
except ImportError:
HAVE_LLM = False
DATA_DIR = PROJECT_ROOT / "automation" / "data"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOGS_DIR / f"shortvideo_{TODAY}.log"),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = "你是短视频脚本编剧,擅长把长文改成高完播率口播脚本"
_VARIANT_ANGLES = [
("观点冲突型", "从一个反直觉/有争议的观点切入,制造认知冲突,让观众想反驳或认同"),
("干货清单型", "提炼 3-5 条可立刻上手的干货要点,节奏快、信息密度高"),
("故事共鸣型", "用一个真实人物/生活场景的故事开头,引发情绪共鸣,再带出观点"),
]
def _build_prompt(source_text: str, angle_name: str, angle_desc: str) -> str:
return f"""你是一个短视频脚本编剧。下面是一篇长文的内容,请把它改写成一条短视频脚本。
改写角度:{angle_name}——{angle_desc}
长文素材:
{source_text}
请严格按以下结构输出(用 markdown,各级标题不要省略):
## 标题(爆款钩子)
写 1 个 15-25 字的短视频标题,带强钩子(悬念/冲突/数字/结果),不要标题党到离谱。
## 口播文案
写 60-90 秒的口语化口播稿,约 200-350 字。像真人对着镜头说话,有停顿标记(用「[停顿]」标注换气/节奏点)。不要书面语,不要「首先其次最后」套路。
## 分镜建议
给出 3-5 个镜头,每个镜头包含:
- 画面:具体拍什么(景别/动作/场景)
- 字幕:屏幕上打出的关键词
- 时长:几秒
用列表或表格呈现,每个镜头一行。
## 适配平台
说明这条脚本分别适配 抖音 / 视频号 / 小红书视频 时的微调建议(各 1 句话)。
只输出上面的 markdown,不要其他说明。"""
def _template_markdown(topic_title: str, angle_name: str) -> str:
return f"""## 标题(爆款钩子)
(待生成){topic_title} - {angle_name}角度
## 口播文案
[LLM 不可用,使用模板占位。请稍后手动补充 60-90 秒口播稿,并用[停顿]标记节奏。]
## 分镜建议
- 画面:(占位) 字幕:(占位) 时长:(占位)
## 适配平台
- 抖音:(占位)
- 视频号:(占位)
- 小红书视频:(占位)
"""
def _load_source(topic: Dict, articles: List[Dict]) -> str:
for platform in ("zhihu", "wechat"):
for a in articles:
if a.get("platform") == platform and a.get("html_content"):
return a["html_content"]
if topic.get("core_concept"):
return f"# {topic.get('title', '')}\n\n{topic.get('core_concept')}"
return topic.get("title", "")
def _generate_one(source: str, idx: int, topic_title: str) -> str:
angle_name, angle_desc = _VARIANT_ANGLES[(idx - 1) % len(_VARIANT_ANGLES)]
if HAVE_LLM:
try:
params = get_prompt_params("shortvideo_script") or {}
temperature = params.get("temperature", 0.8)
max_tokens = params.get("max_tokens", 2000)
prompt = _build_prompt(source, angle_name, angle_desc)
result = call_llm(
prompt,
temperature=temperature,
max_tokens=max_tokens,
system_prompt=SYSTEM_PROMPT,
)
if result and result.strip():
return result.strip()
except Exception as e:
logger.warning("LLM 调用失败(variant %s):%s", idx, e)
logger.warning("LLM 不可用或失败,写入模板占位(variant %s", idx)
return _template_markdown(topic_title, angle_name)
def _extract_title(markdown: str, fallback: str) -> str:
for line in markdown.splitlines():
line = line.strip()
if line.startswith("## 标题") or line.startswith("# 标题"):
rest = line.split("标题", 1)[-1].strip("(): ")
if rest:
return rest[:60]
first = next((l.strip("# ").strip() for l in markdown.splitlines() if l.strip()), "")
return first[:60] if first else fallback
def main():
parser = argparse.ArgumentParser(description="长文转短视频脚本")
parser.add_argument("--topic-id", required=True, help="选题 ID")
parser.add_argument("--count", type=int, default=3, help="生成脚本数量(默认 3")
args = parser.parse_args()
topic_id = args.topic_id
count = max(1, args.count)
files: List[str] = []
out_dir = DATA_DIR / "shortvideos" / TODAY
out_dir.mkdir(parents=True, exist_ok=True)
topic = get_topic_by_id(topic_id)
if not topic:
logger.error("选题不存在:%s", topic_id)
print(json.dumps({"ok": False, "topic_id": topic_id, "count": 0, "files": []}, ensure_ascii=False))
sys.exit(1)
articles = get_articles_by_topic(topic_id)
source = _load_source(topic, articles)
topic_title = topic.get("title", topic_id)
ok = True
for i in range(1, count + 1):
markdown = _generate_one(source, i, topic_title)
title = _extract_title(markdown, f"{topic_title}_短视频{i}")
file_path = out_dir / f"{topic_id}_shortvideo_{i}.md"
try:
file_path.write_text(markdown, encoding="utf-8")
files.append(str(file_path))
logger.info("已写出脚本文件:%s", file_path)
except Exception as e:
logger.error("写文件失败:%s", e)
ok = False
continue
try:
save_article(topic_id, f"shortvideo_{i}", html_content=None, title=title, content=markdown)
logger.info("已写入 DBshortvideo_%s / %s", i, topic_id)
except Exception as e:
logger.warning("DB 保存失败(文件已落盘):%s", e)
summary = {"ok": ok, "topic_id": topic_id, "count": count, "files": files}
print(json.dumps(summary, ensure_ascii=False))
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()