#!/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'{t}' 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'{t}' 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 = "{{TITLE}}

{{TITLE}}

" html = template.replace("{{TITLE}}", title).replace("{{DATE}}", TODAY).replace("{{GEN_TIME}}", GEN_TIME) html_content = _md_parser(adapted) html = html.replace("", html_content) tags_html = self._get_platform_tags(platform) if tags_html: html = html.replace("", tags_html) else: html = html.replace("", "") 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()