Add platform config website_url, admin tab, fix writer DB config fallback, add trigger endpoints
This commit is contained in:
@@ -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()
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user