Add platform config website_url, admin tab, fix writer DB config fallback, add trigger endpoints

This commit is contained in:
Yuzhiran Dev
2026-05-20 09:38:56 +08:00
parent 55e5f3d166
commit 498165440f
84 changed files with 1988 additions and 242 deletions
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
AI 配图生成器 - 使用 sensenova API 生成文章配图
支持 OpenAI 兼容格式的图片生成 API
"""
import os, sys, json, datetime, logging, requests, base64
from pathlib import Path
PROJECT_ROOT = Path('/root/openclaw-workspace/projects/yu-zhi-ran')
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
from dotenv import load_dotenv
load_dotenv(str(PROJECT_ROOT / 'platform' / 'backend' / '.env'))
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"ai_image_{TODAY}.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
SENSENOVA_API_KEY = os.getenv("SENSENOVA_API_KEY", "sk-2Bbcf8pSTSl1x2BV5fKtDsUIGdfjKX7M")
SENSENOVA_BASE_URL = os.getenv("SENSENOVA_BASE_URL", "https://token.sensenova.cn/v1")
SENSENOVA_MODEL = os.getenv("SENSENOVA_IMAGE_MODEL", "sensenova-u1-fast")
# Platform image size mapping
PLATFORM_SIZES = {
"zhihu": "1760x2368",
"wechat": "1760x2368",
"xiaohongshu": "1664x2496",
}
def generate_image(prompt: str, platform: str = "zhihu", size: str = None) -> str:
if not size:
size = PLATFORM_SIZES.get(platform, "2048x2048")
headers = {
"Authorization": f"Bearer {SENSENOVA_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": SENSENOVA_MODEL,
"prompt": prompt,
"n": 1,
"size": size,
"response_format": "b64_json",
}
try:
resp = requests.post(
f"{SENSENOVA_BASE_URL}/images/generations",
headers=headers,
json=payload,
timeout=120,
)
if resp.status_code == 200:
data = resp.json()
if "data" in data and len(data["data"]) > 0:
item = data["data"][0]
if "b64_json" in item:
return item["b64_json"]
if "url" in item:
img_resp = requests.get(item["url"], timeout=60)
if img_resp.status_code == 200:
return base64.b64encode(img_resp.content).decode("ascii")
logger.error(f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)[:500]}")
else:
logger.error(f"API 请求失败: {resp.status_code} {resp.text[:500]}")
except requests.Timeout:
logger.error("API 请求超时")
except Exception as e:
logger.error(f"生成图片失败: {e}")
return None
def generate_article_images(topic_id: str, title: str, field: str = "", platforms: list = None) -> dict:
if platforms is None:
platforms = ["zhihu", "wechat", "xiaohongshu"]
output_dir = PROJECT_ROOT / "automation" / "images" / "generated" / TODAY
output_dir.mkdir(parents=True, exist_ok=True)
results = {}
for platform in platforms:
platform_results = {}
for img_type, prompt_template in [
("cover", f"为文章《{title}》生成一张高质量的封面配图,领域:{field}。风格:简约现代,色彩柔和专业,适合{platform}平台。不要文字。"),
("illustration", f"为文章《{title}》(领域:{field})生成一张内容插图,表现核心概念。风格:清新自然,适合{platform}平台。"),
]:
img_b64 = generate_image(prompt_template, platform)
if img_b64:
filename = f"{img_type}_{platform}_{topic_id}.png"
filepath = output_dir / filename
with open(filepath, "wb") as f:
f.write(base64.b64decode(img_b64))
platform_results[img_type] = str(filepath)
logger.info(f"[{platform}] {img_type} 生成成功: {filename}")
else:
logger.warning(f"[{platform}] {img_type} 生成失败,跳过")
results[platform] = platform_results
return results
def save_images_to_db(topic_id: str, platform: str, images: dict):
try:
from app.database import SessionLocal
from app.models import Article
db = SessionLocal()
article_id = f"{platform}_{topic_id}"
article = db.query(Article).filter(Article.id == article_id).first()
if article:
existing = article.images or {}
existing.update(images)
article.images = existing
db.commit()
logger.info(f"[{platform}] 图片路径已保存到 DB")
db.close()
except Exception as e:
logger.error(f"保存图片路径到 DB 失败: {e}")
def main():
import argparse
parser = argparse.ArgumentParser(description="AI 配图生成器")
parser.add_argument("--topic-id", required=True, help="选题ID")
parser.add_argument("--title", help="文章标题")
parser.add_argument("--field", default="", help="领域")
args = parser.parse_args()
from db_helper import get_topic_by_id
topic = get_topic_by_id(args.topic_id)
if not topic:
print(json.dumps({"ok": False, "error": f"Topic {args.topic_id} not found"}))
sys.exit(1)
title = args.title or topic.get("title", "")
field = args.field or topic.get("field", "")
logger.info(f"为选题 {args.topic_id}{title}》生成配图...")
results = generate_article_images(args.topic_id, title, field)
for platform, imgs in results.items():
if imgs:
save_images_to_db(args.topic_id, platform, imgs)
output = {"ok": True, "topic_id": args.topic_id, "images": results}
print(json.dumps(output, ensure_ascii=False))
if __name__ == "__main__":
main()
+1 -1
View File
@@ -431,7 +431,7 @@ class SustainabilityCollector:
只输出JSON,不要其他文字。"""
try:
resp = call_llm(prompt, temperature=0.7, max_tokens=800)
resp = call_llm(prompt, temperature=0.7)
resp = resp.strip()
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
+5 -2
View File
@@ -163,8 +163,11 @@ def polish_with_llm(html: str, platform: str, remaining_issues: Optional[List[Di
- 短句化,读起来更流畅"""
polished = call_llm(polish_prompt, model=model, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
if '<h2' in polished or '<p>' in polished:
tag = "针对性修复" if remaining_issues else "常规润色"
return polished, f"LLM {tag}"
if len(polished) > len(html) * 0.3 and len(polished) > 100:
if not any(kw in polished[:100] for kw in ['保留', '建议', '可以', '应该', '推荐']):
tag = "针对性修复" if remaining_issues else "常规润色"
return polished, f"LLM {tag}"
logger.warning(f"LLM 优化输出异常(过短或含建议性文字),保留原文 (len={len(polished)})")
except Exception as e:
logger.warning(f"LLM 优化失败: {e}")
return html, None
+4 -2
View File
@@ -102,8 +102,10 @@ def run_pipeline(topic_id: str = None) -> Dict:
update_topic_status(tid, 'pending')
return {"ok": False, "error": "writer step failed"}
# 4. 配图生成
image_ok = run_step("image_generator.py", tid)
# 4. 配图生成AI版,失败时回退PIL版)
image_ok = run_step("ai_image_generator.py", tid)
if not image_ok:
image_ok = run_step("image_generator.py", tid)
# 5. 合规优化(自动审核并标记为「待发布」)
if not run_optimizer_step(tid):
+1 -1
View File
@@ -102,7 +102,7 @@ class Outliner:
直接输出大纲,不要输出思考过程。"""
try:
outline = call_llm(prompt, temperature=0.6, max_tokens=2000, system_prompt="你是一个有经验的内容编辑,擅长为不同选题设计差异化的文章结构。")
outline = call_llm(prompt, temperature=0.6, system_prompt="你是一个有经验的内容编辑,擅长为不同选题设计差异化的文章结构。")
logger.info(f"LLM 大纲生成成功,长度:{len(outline)}")
return f"# 文章大纲:{title}\n\n{outline}\n\n---\n*大纲生成时间:{TODAY}*"
except Exception as e:
+1 -1
View File
@@ -102,7 +102,7 @@ class Researcher:
风格:说人话,直击要点,像资深编辑在给作者做 briefing。避免「首先其次最后」「综上所述」。直接输出内容,不要输出思考过程。"""
try:
return call_llm(prompt, temperature=0.5, max_tokens=1200, system_prompt="你是一个行业研究员,擅长从案例中发现真洞察。")
return call_llm(prompt, temperature=0.5, system_prompt="你是一个行业研究员,擅长从案例中发现真洞察。")
except Exception as e:
logger.warning(f"LLM 研究发现摘要生成失败: {e}")
return ""
+1 -1
View File
@@ -120,7 +120,7 @@ def generate_new_topics(gaps: List[Dict]) -> List[Dict]:
只输出 JSON 数组,不要其他文字。"""
try:
resp = call_llm(prompt, temperature=0.4, max_tokens=2000)
resp = call_llm(prompt, temperature=0.4)
resp = resp.strip()
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
+1 -1
View File
@@ -50,7 +50,7 @@ def fetch_llm_trends() -> List[Dict]:
只输出 JSON,不要其他文字。"""
try:
resp = call_llm(prompt, temperature=0.4, max_tokens=2000)
resp = call_llm(prompt, temperature=0.4)
resp = resp.strip()
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
+85 -62
View File
@@ -35,21 +35,33 @@ 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+标签",
},
_FALLBACK_PLATFORM_CONFIG = {
"zhihu": {"max_chars": 3000, "style": "深度长文分析", "min_chars": 1500},
"wechat": {"max_chars": 1500, "style": "亲切口语化", "min_chars": 800},
"xiaohongshu": {"max_chars": 800, "style": "图文笔记,emoji+标签", "min_chars": 300},
}
def _load_platform_config() -> dict:
try:
from app.database import SessionLocal
from app.models import PlatformConfig
db = SessionLocal()
configs = db.query(PlatformConfig).all()
db.close()
result = {}
for c in configs:
result[c.platform] = {
"max_chars": c.max_words or _FALLBACK_PLATFORM_CONFIG.get(c.platform, {}).get("max_chars", 3000),
"style": c.default_format or _FALLBACK_PLATFORM_CONFIG.get(c.platform, {}).get("style", "深度内容"),
"min_chars": c.min_words or _FALLBACK_PLATFORM_CONFIG.get(c.platform, {}).get("min_chars", 300),
}
return result
except Exception:
pass
return dict(_FALLBACK_PLATFORM_CONFIG)
PLATFORM_CONFIG = _load_platform_config()
class Writer:
def __init__(self, topic_id: str):
self.topic_id = topic_id
@@ -96,86 +108,97 @@ class Writer:
sections.append(current)
return sections
@staticmethod
def _clean_markdown(text: str) -> str:
lines = text.split('\n')
cleaned = []
for line in lines:
line = re.sub(r'^#{1,6}\s+', '', line)
line = re.sub(r'^[\-\*\+]\s+', '', line)
line = re.sub(r'^\d+[\.\)]\s+', '', line)
line = re.sub(r'\*{1,3}([^*]+)\*{1,3}', r'\1', line)
cleaned.append(line)
return '\n'.join(cleaned).strip()
@staticmethod
def _is_outline_noise(line: str) -> bool:
stripped = line.strip()
if not stripped:
return True
if stripped.startswith('---'):
return True
if '大纲生成时间' in stripped:
return True
if stripped.startswith('*大纲'):
return True
return False
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']}」这一节。
prompt = f"""你是一个资深作者,正在写一篇关于「{self.topic['title']}」的文章。写「{section['title']}」这一节。
⚠️ 今天日期:{datetime.datetime.now().strftime('%Y年%m月%d')}当前年份:{datetime.datetime.now().year}年。
今天日期:{datetime.datetime.now().strftime('%Y年%m月%d')}
笔记要点:
{content}
要求(逐条对照,每一条都不能跳过):
### 热点与时效
- **必须引用{datetime.datetime.now().year-1}-{datetime.datetime.now().year}年最新数据/事件/政策/行业报告**,禁用一切过时数据
- 体现当前国内外正在讨论什么、最新的趋势变化
- 每个论点必须配一个真实发生的最新案例(附数据来源),严禁使用虚构数据
- 写作前先确认:这个数据和案例是否是最近{datetime.datetime.now().year-1}-{datetime.datetime.now().year}年的?
【输出要求】
输出3-5段纯粹、流畅的段落文字,共400-800字。
### 独特观点
- 有自己的判断和立场,拒绝"车轱辘话""正确废话"
- 至少提供一个"大多数人没想到"的角度
- 宁可尖锐,也不要平庸
格式:
- 禁止任何标题/列表/格式标记(#、-、*、1.、**等)
- 每段3-5句,段间空行分隔
- 用「你」或「我们」视角,自然口语化
### 价值
- 回答读者一个具体问题或解决一个困惑
- 每段回答一个"所以呢?"——读者看完能带走什么
- 结束时读者要有「学到了+想转发」的感觉
内容要求(让文章在各平台能被推荐):
- 开头直接切入痛点或反常识观点,抓住注意力
- 每个观点配具体案例或数据(用「据统计」「调研显示」等),不要空泛说理
- 有独特判断和立场,避免正确废话
- 回答「所以呢」——读者看完能带走什么
- 结尾有情绪感召力,让人想点赞/收藏/转发
### 真人感
- 用「你」或「我们」视角,不要用「我」
- 像人在自然说话,不是AI组装文字
- 避免「首先」「其次」「总的来说」「综上所述」「值得注意的是」
- 段落短,2-4句一段,节奏有变化
- 适当用反问或口语化表达
### SEO
- 自然融入1-2个目标搜索词,不生硬堆砌
- 第一句包含核心关键词
### 专业与简洁
- 语言精准,不注水,不为了字数凑内容
- 200-400字,写到点子上就停
### 平台推荐友好
- 节奏不能平,要有起承转合
- 结尾要让人有点赞/收藏/转发的冲动
直接输出段落正文。"""
直接输出段落正文,不要任何附加说明。"""
try:
expanded = call_llm(prompt, temperature=0.6, max_tokens=1500)
if expanded and len(expanded.strip()) > len(content):
return expanded.strip()
expanded = call_llm(prompt, temperature=0.6)
if expanded:
cleaned = self._clean_markdown(expanded.strip())
if cleaned:
return cleaned
except Exception as e:
logger.warning(f"LLM 扩写失败: {e}")
# Fallback: 将 bullet points 展开为段落
lines = [l.strip() for l in content.split('\n') if l.strip()]
# Fallback: 将 bullet points 展开为段落(过滤噪音行)
lines = [l.strip() for l in content.split('\n') if not self._is_outline_noise(l)]
if lines:
sentences = []
for line in lines:
text = line.lstrip('- *').strip()
text = line
for prefix in ['- ', '* ', '1. ', '2. ', '3. ', '4. ', '5. ']:
if line.startswith(prefix):
text = line[len(prefix):]
break
text = text.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
return ''
def generate_full_markdown(self) -> str:
sections = self._parse_outline_sections()
parts = []
for sec in sections:
if sec['level'] == 1:
if sec.get('content'):
expanded = self._expand_section(sec)
if expanded:
parts.append(expanded + "\n")
continue
heading = f"{'#' * sec['level']} {sec['title']}"
parts.append(heading)
@@ -246,7 +269,7 @@ class Writer:
if HAVE_LLM:
prompt = tag_prompts.get(platform, f"根据文章信息生成适合{platform}的标签。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。")
try:
tags_text = call_llm(prompt, temperature=0.2, max_tokens=1000)
tags_text = call_llm(prompt, temperature=0.2)
if tags_text:
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
if tags:
@@ -342,7 +365,7 @@ class Writer:
prompt = title_templates.get(platform, f"给以下文章改个吸引人的{platform}标题:{original}")
try:
resp = call_llm(prompt, temperature=0.7, max_tokens=1000)
resp = call_llm(prompt, temperature=0.7)
titles = []
for line in resp.strip().split('\n'):
line = line.strip()