Files
yu-zhi-ran/scripts/writer.py
T
Yuzhiran Dev 233e23016c feat: 内容数据迁移至数据库,合规审查全链路打通
- 文章 HTML 存储从文件系统迁移至 articles 表,删除 releases 目录
- 合规审查从 DB 读取 HTML,审查结果写回 DB,通过后自动推进至待发布
- 新增 todayCount 筛选按钮,与系统概览统计数据一致
- 全屏预览修复:提升 z-index 超过侧边栏,添加退出全屏/关闭按钮
- 统一 '优化' → '审查' 命名,消除前后端术语不一致
- 调度器创作完成后自动触发审查(生成 → 审查 → 待发布)
- 清理旧备份/调试文件、过期大纲和研究笔记
2026-05-13 17:33:56 +08:00

445 lines
17 KiB
Python
Raw 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
import json, datetime, logging, sys, re
from pathlib import Path
from typing import Dict, List
PROJECT_ROOT = Path(__file__).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, update_topic_status
try:
from app.core.nvidia_client import call_llm
HAVE_LLM = True
except ImportError:
HAVE_LLM = False
import mistune
DATA_DIR = PROJECT_ROOT / "automation" / "data"
OUTLINE_DIR = DATA_DIR / "outlines"
RELEASE_DIR = DATA_DIR / "releases"
TEMPLATES_DIR = PROJECT_ROOT / "automation" / "templates"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
GEN_TIME = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOGS_DIR / f"writer_{TODAY}.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
_md_parser = mistune.create_markdown()
PLATFORM_CONFIG = {
"zhihu": {
"max_chars": 3000,
"style": "深度长文分析",
},
"wechat": {
"max_chars": 1500,
"style": "亲切口语化",
},
"xiaohongshu": {
"max_chars": 800,
"style": "图文笔记,emoji+标签",
},
}
class Writer:
def __init__(self, topic_id: str):
self.topic_id = topic_id
self.topic = self._load_topic()
outline_file = OUTLINE_DIR / TODAY / f"{topic_id}_outline.md"
if not outline_file.exists():
raise FileNotFoundError(f"Outline not found: {outline_file}")
self.outline_content = outline_file.read_text(encoding='utf-8')
self.release_dir = RELEASE_DIR / TODAY
self.release_dir.mkdir(parents=True, exist_ok=True)
research_file = DATA_DIR / "research" / TODAY / f"{topic_id}_research.md"
self.research_notes = research_file.read_text(encoding='utf-8') if research_file.exists() else ""
def _load_topic(self) -> Dict:
topic = get_topic_by_id(self.topic_id)
if not topic:
raise ValueError(f"Topic {self.topic_id} not found")
return topic
def _clean_title(self, title: str) -> str:
title = re.sub(r'[(]约\s*\d+字[)]', '', title)
title = re.sub(r'[(]MVP[)]', '', title)
title = re.sub(r'[(][^)]*?[)]', '', title)
return title.strip()
def _parse_outline_sections(self) -> List[Dict]:
sections = []
current = None
for line in self.outline_content.splitlines():
if line.startswith("# "):
if current:
sections.append(current)
current = {"level": 1, "title": line[2:].strip(), "content": ""}
elif line.startswith("## "):
if current:
sections.append(current)
current = {"level": 2, "title": line[3:].strip(), "content": ""}
elif line.startswith("### "):
if current:
sections.append(current)
current = {"level": 3, "title": line[4:].strip(), "content": ""}
else:
if current and line.strip():
current['content'] = current.get('content', '') + line + "\n"
if current:
sections.append(current)
return sections
def _expand_section(self, section: Dict) -> str:
content = section.get('content', '').strip()
if len(content) > 200:
return content
if HAVE_LLM and len(content) < 150:
logger.info(f"使用 LLM 扩写章节: {section['title']}")
prompt = f"""你是一个真人写作者,在写一篇关于「{self.topic['title']}」的文章。现在要写「{section['title']}」这一节,你的笔记要点如下:
{content}
要求:
- 200-300字,用自己的话展开
- 用「你」或「我们」视角,不要用「我」
- 读起来像人在自然说话,不是AI在组装文字
- 避免「首先」「其次」「总的来说」「综上所述」这类套路表达
- 如果合适,可以加一句反问
直接输出段落正文。"""
try:
expanded = call_llm(prompt, temperature=0.6, max_tokens=1500)
if expanded and len(expanded.strip()) > len(content):
return expanded.strip()
except Exception as e:
logger.warning(f"LLM 扩写失败: {e}")
# Fallback: 将 bullet points 展开为段落
lines = [l.strip() for l in content.split('\n') if l.strip()]
if lines:
sentences = []
for line in lines:
text = line.lstrip('- *').strip()
if text:
for prefix in ['- ', '* ', '1. ', '2. ', '3. ', '4. ', '5. ']:
if line.startswith(prefix):
text = line[len(prefix):]
break
if text[-1] not in '。!?;':
text += ''
sentences.append(text)
if sentences:
return ' '.join(sentences)
return content
def generate_full_markdown(self) -> str:
sections = self._parse_outline_sections()
parts = []
for sec in sections:
if sec['level'] == 1:
continue
heading = f"{'#' * sec['level']} {sec['title']}"
parts.append(heading)
if sec.get('content'):
expanded = self._expand_section(sec)
parts.append(expanded + "\n")
full_md = "\n".join(parts).strip()
return full_md
def _adapt_for_platform(self, markdown: str, platform: str) -> str:
cfg = PLATFORM_CONFIG[platform]
platform_prompts = {
"zhihu": f"""你是一个知乎答主。把以下文章改写成知乎回答。
## 知乎回答的特点
- 知乎用户习惯理性分析、数据支撑、逻辑递进
- 开头直击问题本质,不绕弯子
- 每一段讲一个观点,段与段之间有逻辑推进
## 改写要求
- 用第三人称或「我们」视角,不要用「我」
- 不要编个人经历——知乎读者在意的是分析质量,不是故事
- 开头可以抛数据、抛现象、抛一个矛盾点
- 不用"首先其次最后",用自然过渡
- 避免AI感表达:「总的来说」「综上所述」「值得注意的是」
- 字数:{cfg['max_chars']}字以内
## 原文
{markdown[:3000]}
## 输出
直接输出改写后的完整内容(仅正文),每段后空一行。""",
"wechat": f"""你是一个公众号作者。把以下文章改写成公众号推文。
## 公众号推文的特点
- 开头必须制造共鸣或好奇心,让读者愿意往下读
- 短段落,有节奏感,每段2-3行
- 核心观点加粗突出
## 改写要求
- 用「你」视角,**通篇不允许出现「我」字**
- 把原文中所有的「我」改成「你」或「很多人」或「有人」
- 结构可以和原文完全不同——公众号不需要全面的分析,抓住1-2个痛点打透就行
- 可以删减原文内容,保留最有力的观点
- 避免「综上所述」「值得注意的是」「换言之」
- 字数:{cfg['max_chars']}字以内
## 原文
{markdown[:3000]}
## 输出
直接输出改写后的完整内容(仅正文),每段后空一行。""",
"xiaohongshu": f"""你是一个小红书用户。把以下文章改写成小红书笔记。
## 小红书笔记的特点
- 极短!极短!极短!小红书用户没耐心看长文
- 直接给结论、给清单、给步骤
- 原文通常很深很全,但笔记只取最关键的2-3个点
## 改写要求
- **全文控制在 {cfg['max_chars']} 字以内**,多一个字都不要
- 用「你」视角,不要用「我」
- 开头一句抓住注意力,可以是结论、可以是一个反常识的观点
- 正文每段1-2句,可以完全打乱原文结构
- emoji每人段最多1个点缀,不要堆砌(✨💡✅🔸)
- 结尾加 #话题标签 3-5个
- 原文的深度分析全部砍掉,只留最 actionable 的内容
## 原文
{markdown[:3000]}
## 输出
直接输出改写后的完整内容(仅正文)。""",
}
if HAVE_LLM:
prompt = platform_prompts.get(platform, f"""将以下文章改写为适合{platform}平台版本,{cfg['max_chars']}字以内:
{markdown[:3000]}""")
try:
adapted = call_llm(prompt, temperature=0.7, max_tokens=2000)
if adapted and len(adapted.strip()) > 50:
adapted = adapted.strip()
# 字数强校验
max_c = cfg['max_chars']
if platform == "xiaohongshu" and len(adapted) > max_c * 1.2:
adapted = adapted[:max_c]
last_break = max(adapted.rfind(''), adapted.rfind('\n'), adapted.rfind(''), adapted.rfind(''))
if last_break > max_c // 2:
adapted = adapted[:last_break + 1]
# 微信/知乎清除残留的"我"
if platform in ("wechat", "zhihu"):
adapted = adapted.replace('', '')
logger.info(f"LLM 平台适配完成: {platform} ({len(adapted)}字, \"\"x{adapted.count('')})")
return adapted
except Exception as e:
logger.warning(f"LLM {platform} 适配失败: {e}")
except Exception as e:
logger.warning(f"LLM {platform} 适配失败: {e}")
if platform == "xiaohongshu":
lines = markdown.split('\n')
result = []
char_count = 0
for line in lines:
if char_count >= cfg['max_chars']:
break
if line.startswith('## '):
line = f"## ✨ {line[3:]}"
elif line.startswith('### '):
line = f"### 💡 {line[4:]}"
result.append(line)
char_count += len(line)
adapted = '\n'.join(result)
if adapted.count('#') == 0:
adapted = f"# {self.topic['title']}\n\n{adapted}"
return adapted
return markdown
def _get_platform_tags(self, platform: str) -> str:
field = self.topic.get('field', '')
title = self.topic.get('title', '')
core = self.topic.get('core_concept', '')
tag_prompts = {
"zhihu": f"根据文章信息,生成知乎文章分类标签(3-5个,每个2-4字)。知乎标签偏学术/行业分类,如「经济学」「消费心理学」「科技趋势」。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。",
"wechat": f"根据文章信息,生成微信公众号文章标签(3-5个,每个2-4字)。公众号标签偏话题/兴趣分类,如「省钱攻略」「生活方式」「成长干货」。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。",
"xiaohongshu": f"根据文章信息,生成小红书笔记标签(3-5个,每个2-4字)。小红书标签偏场景/人群分类,如「实用干货」「学生党」「打工人必看」「好物分享」。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。",
}
if HAVE_LLM:
prompt = tag_prompts.get(platform, f"根据文章信息生成适合{platform}的标签。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。")
try:
tags_text = call_llm(prompt, temperature=0.2, max_tokens=100)
if tags_text:
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
if tags:
return " ".join(f'<span class="tag">{t}</span>' for t in tags[:5])
except Exception:
pass
tags = []
if field:
import re
parts = re.split(r'[/、与和及]', field)
for p in parts:
p = p.strip()
if len(p) >= 2:
tags.append(p)
if len(parts) == 1 and len(parts[0]) > 4:
for i in range(0, len(parts[0]), 2):
chunk = parts[0][i:i+2]
if len(chunk) == 2:
tags.append(chunk)
tags.pop(0)
platform_extra = {"zhihu": ["职场"], "xiaohongshu": ["生活"]}
for t in platform_extra.get(platform, []):
if t not in tags:
tags.append(t)
if not tags:
tags = ["科技"]
seen = set()
return " ".join(f'<span class="tag">{t}</span>' for t in tags if t not in seen and not seen.add(t))
def _optimize_title(self, platform: str) -> str:
original = self.topic['title']
if not HAVE_LLM:
return original
title_templates = {
"zhihu": f"""你是一个知乎用户,在写一个回答的标题。
原文标题:{original}
领域:{self.topic.get('field', '')}
要求:
- 有信息量、带数字或对比最好
- 不要太长,20字以内
- 风格参考知乎真实高赞标题,不要套路句式
- 避免:「如何……」废句式、「XXX指南/手册/全攻略」
生成 3 个选项,每行一个。""",
"wechat": f"""你是一个公众号作者,在给文章起标题。
原文标题:{original}
领域:{self.topic.get('field', '')}
要求:
- 制造点好奇心,让人想点开看
- 口语化,不要书面腔
- 不要感叹号堆砌,不要「重磅/震惊/紧急」
- 参考真实公众号标题的感觉
生成 3 个选项,每行一个。""",
"xiaohongshu": f"""你是一个小红书用户,在给笔记起标题。
原文标题:{original}
领域:{self.topic.get('field', '')}
要求:
- 短,20字以内
- 带1个emoji点缀就行,不用多
- 有场景感或结果感
- 不要「必看/收藏/码住」
- 像真实用户写的,不是运营写的
生成 3 个选项,每行一个。""",
}
prompt = title_templates.get(platform, f"给以下文章改个吸引人的{platform}标题:{original}")
try:
resp = call_llm(prompt, temperature=0.7, max_tokens=200)
titles = []
for line in resp.strip().split('\n'):
line = line.strip()
if not line:
continue
line = re.sub(r'^\d+[.、)\s]+', '', line)
line = line.strip('*#- \t')
if line:
titles.append(line)
if titles:
logger.info(f"标题优化 [{platform}]: {titles[0][:50]}...")
return titles[0]
except Exception as e:
logger.warning(f"标题优化失败: {e}")
return original
def generate_platform_html(self, markdown: str, platform: str) -> str:
title = self._optimize_title(platform)
adapted = self._adapt_for_platform(markdown, platform)
tpl_path = TEMPLATES_DIR / f"{platform}.html"
if tpl_path.exists():
template = tpl_path.read_text(encoding='utf-8')
else:
template = "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>{{TITLE}}</title><meta name='viewport' content='width=device-width'><style>body{max-width:800px;margin:0 auto;padding:20px;font-family:-apple-system,sans-serif;line-height:1.8}</style></head><body><h1>{{TITLE}}</h1><!-- CONTENT --></body></html>"
html = template.replace("{{TITLE}}", title).replace("{{DATE}}", TODAY).replace("{{GEN_TIME}}", GEN_TIME)
html_content = _md_parser(adapted)
html = html.replace("<!-- CONTENT -->", html_content)
tags_html = self._get_platform_tags(platform)
if tags_html:
html = html.replace("<!-- TAGS -->", tags_html)
else:
html = html.replace("<!-- TAGS -->", "")
return html
def save_html(self, html: str, platform: str) -> Path:
out_dir = self.release_dir / platform
out_dir.mkdir(parents=True, exist_ok=True)
filename = f"{platform}_{self.topic_id}.html"
out_path = out_dir / filename
out_path.write_text(html, encoding='utf-8')
logger.info(f"HTML 生成: {out_path}")
return out_path
def mark_draft(self):
update_topic_status(self.topic_id, 'review')
logger.info(f"选题 {self.topic_id} 状态已更新为待审查(数据库)")
def run(self):
logger.info("开始撰写阶段")
markdown = self.generate_full_markdown()
results = {}
for platform in ["zhihu", "wechat", "xiaohongshu"]:
html = self.generate_platform_html(markdown, platform)
results[platform] = str(self.save_html(html, platform))
self.mark_draft()
logger.info(f"撰写完成,状态已更新为待审查")
return {"ok": True, "files": results}
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--topic-id', required=True, help='选题ID')
args = parser.parse_args()
w = Writer(args.topic_id)
result = w.run()
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if result['ok'] else 1)
if __name__ == "__main__":
main()