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 市场调研简报
This commit is contained in:
yuzhiran
2026-07-11 12:36:01 +08:00
parent 3d596275d5
commit 7e953afbd2
39 changed files with 2418 additions and 311 deletions
+189
View File
@@ -0,0 +1,189 @@
#!/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()
+72 -3
View File
@@ -69,6 +69,33 @@ def _load_platform_config() -> dict:
PLATFORM_CONFIG = _load_platform_config()
_AI_DISCLOSURE_TEXT = {
"zhihu": "本文由 AI 辅助创作,核心观点与数据经宇之然人工审核校对。",
"wechat": "本文由 AI 辅助创作,核心观点与数据经宇之然人工审核校对。",
"xiaohongshu": "✨ 本文由 AI 辅助生成,内容已人工核对~",
}
def _load_ai_disclosure_enabled() -> bool:
"""根据《人工智能生成合成内容标识办法》(2025-09-01 施行),AI 生成内容须主动声明。
默认开启;可通过 SystemConfig(disclose_ai=false) 或环境变量 YZR_DISCLOSE_AI=0 关闭。"""
try:
if os.environ.get("YZR_DISCLOSE_AI", "").strip() == "0":
return False
from app.database import SessionLocal
from app.models import SystemConfig
db = SessionLocal()
try:
sc = db.query(SystemConfig).filter(SystemConfig.key == "disclose_ai").first()
if sc and str(sc.value).strip().lower() in ("false", "0", "no"):
return False
finally:
db.close()
except Exception:
pass
return True
AI_DISCLOSURE_ENABLED = _load_ai_disclosure_enabled()
PLATFORM_NAMES = {
"zhihu": "知乎专栏",
"wechat": "微信公众号",
@@ -235,10 +262,40 @@ def inject_geo_metadata(html: str, title: str, content: str, platform: str, tags
return html
def inject_ai_disclosure(html: str, platform: str) -> str:
"""按《人工智能生成合成内容标识办法》注入 AI 生成声明。
显式:文首/文末标注「AI 生成」;隐式:head 注入 ai-generated meta 与 AIGC 隐式标识字段。"""
if not AI_DISCLOSURE_ENABLED:
return html
text = _AI_DISCLOSURE_TEXT.get(platform, _AI_DISCLOSURE_TEXT["zhihu"])
disclosure_block = f'\n<blockquote class="ai-disclosure">🤖 {text}</blockquote>\n'
# 文末声明(置于正文末尾,封面/互动钩子之前不影响阅读)
if "<!-- AI_DISCLOSURE -->" in html:
html = html.replace("<!-- AI_DISCLOSURE -->", disclosure_block.strip())
else:
# 在 </body> 前插入文末声明
html = html.replace("</body>", f"{disclosure_block}</body>")
# 隐式标识:head 注入声明 meta + AIGC 元数据字段(符合标识办法附录 E)
head_meta = (
'<meta name="ai-generated" content="true">\n'
'<meta name="ai-disclosure" content="本文含人工智能生成合成内容">\n'
'<script type="application/ld+json">\n'
'{"AIGC":{"Label":"1","ContentProducer":"宇之然","ProduceID":"yzr-' + platform + '",'
'"ContentPropagator":"宇之然","PropagateID":"yzr-' + platform + '"}}\n'
'</script>\n'
)
html = html.replace("</head>", head_meta + "</head>")
return html
class Writer:
def __init__(self, topic_id: str):
self.topic_id = topic_id
self.topic = self._load_topic()
self._degraded = False
outline_file = OUTLINE_DIR / TODAY / f"{topic_id}_outline.md"
if not outline_file.exists():
raise FileNotFoundError(f"Outline not found: {outline_file}")
@@ -334,6 +391,9 @@ class Writer:
except Exception as e:
logger.warning(f"LLM 扩写失败 [{platform}]: {e}")
# LLM 不可用或返回为空 → 降级为大纲要点拼接(非真正成稿)
self._degraded = True
logger.warning(f"⚠️ [{platform}] 章节「{section['title']}」LLM 扩写缺失,降级为大纲要点拼接(内容未达发布质量)")
lines = [l.strip() for l in content.split('\n') if not self._is_outline_noise(l)]
if lines:
sentences = []
@@ -608,8 +668,11 @@ class Writer:
html = html.replace("<!-- TAGS -->", tags_html)
else:
html = html.replace("<!-- TAGS -->", "")
html = inject_geo_metadata(html, title, adapted, platform, tags_html)
# 强制 AI 生成声明(合规要求,可配置关闭)
html = inject_ai_disclosure(html, platform)
return html
def save_html(self, html: str, platform: str, *, title: str = "", content: str = "") -> str:
@@ -634,8 +697,14 @@ class Writer:
html = self.generate_platform_html(markdown, platform)
results[platform] = str(self.save_html(html, platform, title=title, content=markdown))
self.mark_draft()
logger.info(f"撰写完成,状态已更新为待审查")
return {"ok": True, "files": results}
if self._degraded:
logger.error(
"❌ 撰写完成但存在 LLM 降级章节(大纲要点拼接,未达发布质量)。"
"选题保留为待审查,请检查 LLM 提供商连通性后重跑,勿直接发布降级稿。"
)
else:
logger.info(f"撰写完成,状态已更新为待审查")
return {"ok": True, "files": results, "degraded": self._degraded}
def main():
import argparse