diff --git a/platform/backend/app/api/articles.py b/platform/backend/app/api/articles.py
index 3a04e22..43f6e76 100644
--- a/platform/backend/app/api/articles.py
+++ b/platform/backend/app/api/articles.py
@@ -138,7 +138,7 @@ def preview_article(topic_id: str, platform: str = "zhihu", current_user: User =
article = db.query(Article).filter(Article.id == article_id).first()
if not article or not article.html_content:
raise HTTPException(status_code=404, detail=f"Article not found for {topic_id} on {platform}")
- return {"topic_id": topic_id, "platform": platform, "html": article.html_content}
+ return {"topic_id": topic_id, "platform": platform, "html": article.html_content, "images": article.images or {}}
@router.put("/{topic_id}/content")
def update_article_content(
diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py
index 1e31599..1f2d039 100644
--- a/platform/backend/app/main.py
+++ b/platform/backend/app/main.py
@@ -95,6 +95,13 @@ app.include_router(platform_config.router)
app.include_router(collector_mgmt.router)
app.include_router(assistant.router)
+# 挂载自动生成的图片(必须先于前端根挂载)
+PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
+AUTOMATION_IMAGES_DIR = PROJECT_ROOT_DIR / "automation" / "images"
+if AUTOMATION_IMAGES_DIR.exists():
+ app.mount("/automation/images", StaticFiles(directory=str(AUTOMATION_IMAGES_DIR)), name="images")
+ logger.info(f"Images mounted at /automation/images from {AUTOMATION_IMAGES_DIR}")
+
# 挂载前端
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
if FRONTEND_DIR.exists() and (FRONTEND_DIR / "index.html").exists():
diff --git a/platform/frontend/topics.html b/platform/frontend/topics.html
index 7f60edf..5f04971 100644
--- a/platform/frontend/topics.html
+++ b/platform/frontend/topics.html
@@ -163,6 +163,9 @@
+
+
![封面图]()
+
@@ -196,7 +199,7 @@ const TopicsApp = {
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
topics: [], allTopics: [], todayTopics: [], todayCount: 0,
previewVisible: false, previewTopic: null, previewFullscreen: false,
- previewPlatform: 'zhihu', platformContents: {}, editing: false, editContent: '',
+ previewPlatform: 'zhihu', platformContents: {}, previewImages: {}, editing: false, editContent: '',
publishDialogVisible: false, publishTopic: null,
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
publishing: false,
@@ -317,14 +320,14 @@ const TopicsApp = {
} catch (error) { this.$message.error(`批量审查失败: ${error.message}`); }
},
async openPreview(topic) {
- this.previewTopic = topic; this.previewPlatform = 'zhihu'; this.previewVisible = true; this.platformContents = {};
+ this.previewTopic = topic; this.previewPlatform = 'zhihu'; this.previewVisible = true; this.platformContents = {}; this.previewImages = {};
const token = this.getToken();
if (!token) return;
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
const names = { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' };
await Promise.all(platforms.map(p =>
fetch(`/api/articles/${topic.id}/preview?platform=${p}`, { headers: { 'Authorization': 'Bearer ' + token } })
- .then(r => r.ok ? r.json() : null).then(d => { if (d && d.html) this.platformContents[p] = d.html; }).catch(e => { console.error(`加载${p}预览失败:`, e); this.$message.error(`加载${names[p]}预览失败`); })
+ .then(r => r.ok ? r.json() : null).then(d => { if (d && d.html) { this.platformContents[p] = d.html; if (d.images) this.previewImages[p] = d.images; } }).catch(e => { console.error(`加载${p}预览失败:`, e); this.$message.error(`加载${names[p]}预览失败`); })
));
},
togglePreviewFullscreen() {
diff --git a/scripts/cover_generator.py b/scripts/cover_generator.py
new file mode 100644
index 0000000..4c7f84a
--- /dev/null
+++ b/scripts/cover_generator.py
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+"""
+封面图生成器:为三平台文章生成文字封面图
+
+依赖: Pillow + Noto Sans CJK 字体
+用法: python3 scripts/cover_generator.py --topic-id A07
+"""
+
+import argparse, datetime, json, logging, sys, os
+from pathlib import Path
+from typing import Dict, Optional
+from PIL import Image, ImageDraw, ImageFont
+
+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
+
+LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
+OUTPUT_DIR = PROJECT_ROOT / "automation" / "images" / "generated"
+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"cover_{TODAY}.log"), logging.StreamHandler()])
+logger = logging.getLogger(__name__)
+
+# Find a suitable CJK font
+_FONT_CANDIDATES = [
+ "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Bold.ttc",
+ "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Medium.ttc",
+ "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
+]
+_FONT_PATH = None
+for fp in _FONT_CANDIDATES:
+ if Path(fp).exists():
+ _FONT_PATH = fp
+ break
+
+if not _FONT_PATH:
+ import subprocess
+ r = subprocess.run(["fc-match", "-f", "%{file}", "sans:lang=zh"], capture_output=True, text=True)
+ if r.returncode == 0 and Path(r.stdout.strip()).exists():
+ _FONT_PATH = r.stdout.strip()
+
+COVER_SIZES = {
+ "zhihu": (1200, 630),
+ "wechat": (900, 500),
+ "xiaohongshu": (1080, 1440),
+}
+
+PLATFORM_COLORS = {
+ "zhihu": { "bg_start": "#0066cc", "bg_end": "#004d99", "title": "#ffffff", "accent": "#66b3ff", "watermark": "知乎专栏" },
+ "wechat": { "bg_start": "#07c160", "bg_end": "#06ad56", "title": "#ffffff", "accent": "#b3ffd9", "watermark": "微信公众号" },
+ "xiaohongshu": { "bg_start": "#ff2442", "bg_end": "#cc1a35", "title": "#ffffff", "accent": "#ffb3c1", "watermark": "小红书" },
+}
+
+
+def _get_font(size: int):
+ if _FONT_PATH:
+ return ImageFont.truetype(_FONT_PATH, size)
+ return ImageFont.load_default()
+
+
+def _wrap_text(text: str, max_chars: int = 6) -> str:
+ """Split long title into lines"""
+ lines = []
+ while text:
+ if len(text) <= max_chars:
+ lines.append(text)
+ break
+ idx = text.rfind(",", 0, max_chars + 1)
+ if idx <= 0:
+ idx = text.rfind(" ", 0, max_chars + 1)
+ if idx <= 0:
+ idx = max_chars
+ lines.append(text[:idx])
+ text = text[idx:]
+ return "\n".join(lines)
+
+
+def generate_cover(topic_id: str, platform: str) -> Optional[str]:
+ """Generate cover image for a topic on a specific platform"""
+ topic = get_topic_by_id(topic_id)
+ if not topic:
+ logger.error(f"Topic {topic_id} not found")
+ return None
+
+ title = topic.get("title", "")
+ if not title:
+ logger.warning(f"Topic {topic_id} has no title")
+ return None
+
+ size = COVER_SIZES.get(platform)
+ if not size:
+ logger.error(f"Unknown platform: {platform}")
+ return None
+
+ colors = PLATFORM_COLORS[platform]
+ w, h = size
+ img = Image.new("RGB", (w, h))
+ draw = ImageDraw.Draw(img)
+
+ for y in range(h):
+ r = y / h
+ for c in range(3):
+ start = int(colors["bg_start"][1:3], 16) if c == 0 else (int(colors["bg_start"][3:5], 16) if c == 1 else int(colors["bg_start"][5:7], 16))
+ end = int(colors["bg_end"][1:3], 16) if c == 0 else (int(colors["bg_end"][3:5], 16) if c == 1 else int(colors["bg_end"][5:7], 16))
+ val = int(start + (end - start) * r)
+ color = list(img.getpixel((0, y)) if y > 0 else (0, 0, 0))
+ color[c] = val
+ img.putpixel((0, y), tuple(color))
+ for x in range(1, w):
+ color = img.getpixel((0, y))
+ draw.line([(x, y), (x, y)], fill=color)
+
+ if platform == "xiaohongshu":
+ title_font_size = 56
+ wrapped = _wrap_text(title, max_chars=7)
+ font = _get_font(title_font_size)
+ lines = wrapped.split("\n")
+ total_h = len(lines) * (title_font_size + 20)
+ start_y = (h - total_h) // 2 - 40
+ for i, line in enumerate(lines):
+ bbox = draw.textbbox((0, 0), line, font=font)
+ tw = bbox[2] - bbox[0]
+ x = (w - tw) // 2
+ y_pos = start_y + i * (title_font_size + 20)
+ draw.text((x, y_pos), line, fill=colors["title"], font=font)
+
+ font_small = _get_font(24)
+ wm = colors["watermark"]
+ bbox = draw.textbbox((0, 0), wm, font=font_small)
+ ww = bbox[2] - bbox[0]
+ draw.text(((w - ww) // 2, h - 80), wm, fill=colors["accent"], font=font_small)
+ else:
+ title_font_size = 52 if platform == "wechat" else 64
+ font = _get_font(title_font_size)
+ bbox = draw.textbbox((0, 0), title, font=font)
+ tw = bbox[2] - bbox[0]
+ if tw > w - 120:
+ wrapped = _wrap_text(title, max_chars=8)
+ lines = wrapped.split("\n")
+ total_h = len(lines) * (title_font_size + 16)
+ start_y = (h - total_h) // 2
+ for i, line in enumerate(lines):
+ bbox2 = draw.textbbox((0, 0), line, font=font)
+ tw2 = bbox2[2] - bbox2[0]
+ x = (w - tw2) // 2
+ draw.text((x, start_y + i * (title_font_size + 16)), line, fill=colors["title"], font=font)
+ else:
+ x = (w - tw) // 2
+ draw.text((x, h // 2 - title_font_size), title, fill=colors["title"], font=font)
+
+ font_small = _get_font(20)
+ wm = colors["watermark"]
+ bbox = draw.textbbox((0, 0), wm, font=font_small)
+ ww = bbox[2] - bbox[0]
+ draw.text((w - ww - 20, h - 40), wm, fill=colors["accent"], font=font_small)
+
+ out_dir = OUTPUT_DIR / TODAY
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_path = out_dir / f"cover_{platform}_{topic_id}.png"
+ img.save(out_path, "PNG")
+ relative_path = f"automation/images/generated/{TODAY}/cover_{platform}_{topic_id}.png"
+ logger.info(f"Cover saved: {out_path}")
+ return relative_path
+
+
+def update_article_image(topic_id: str, platform: str, image_path: str):
+ """Save cover path to Article.images JSON field"""
+ try:
+ from db_helper import save_cover_to_article
+ save_cover_to_article(topic_id, platform, image_path)
+ except Exception as e:
+ logger.warning(f"Failed to update article image in DB: {e}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="生成文章封面图")
+ parser.add_argument("--topic-id", required=True)
+ parser.add_argument("--platform", choices=["zhihu", "wechat", "xiaohongshu", "all"], default="all")
+ args = parser.parse_args()
+
+ platforms = ["zhihu", "wechat", "xiaohongshu"] if args.platform == "all" else [args.platform]
+ images = {}
+ for plat in platforms:
+ path = generate_cover(args.topic_id, plat)
+ if path:
+ images[plat] = path
+ update_article_image(args.topic_id, plat, path)
+ print(json.dumps(images, ensure_ascii=False))
+ logger.info(f"Generated covers for {args.topic_id}: {list(images.keys())}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/creator.py b/scripts/creator.py
index 76e40f9..5886fb8 100755
--- a/scripts/creator.py
+++ b/scripts/creator.py
@@ -103,10 +103,12 @@ def run_pipeline(topic_id: str = None) -> Dict:
update_topic_status(tid, 'pending')
return {"ok": False, "error": "writer step failed"}
- # 4. 配图生成(AI版,失败时回退PIL版)
+ # 4. 配图生成(AI版 → PIL版 → 封面文字版)
image_ok = run_step("ai_image_generator.py", tid)
if not image_ok:
image_ok = run_step("image_generator.py", tid)
+ if not image_ok:
+ image_ok = run_step("cover_generator.py", tid)
# 5. 合规优化(自动审核并标记为「待发布」)
if not run_optimizer_step(tid):
diff --git a/scripts/db_helper.py b/scripts/db_helper.py
index 13dd5b0..199202e 100644
--- a/scripts/db_helper.py
+++ b/scripts/db_helper.py
@@ -259,6 +259,27 @@ def save_article(topic_id: str, platform: str, html_content: str, db: Optional[S
if close_db:
db.close()
+def save_cover_to_article(topic_id: str, platform: str, cover_path: str, db: Optional[Session] = None):
+ """保存封面图路径到 article.images 字段"""
+ close_db = False
+ if db is None:
+ db = SessionLocal()
+ close_db = True
+ try:
+ from app.models import Article
+ article_id = f"{platform}_{topic_id}"
+ article = db.query(Article).filter(Article.id == article_id).first()
+ if article:
+ images = article.images or {}
+ images["cover"] = cover_path
+ article.images = images
+ db.commit()
+ except Exception:
+ db.rollback()
+ finally:
+ if close_db:
+ db.close()
+
def get_article(topic_id: str, platform: str, db: Optional[Session] = None) -> Optional[Dict]:
"""从 articles 表获取文章 HTML"""
close_db = False