From c8bee712d7d9c8b71df023bdc5e9a127ae2a630c Mon Sep 17 00:00:00 2001 From: Yuzhiran Dev Date: Wed, 20 May 2026 19:12:58 +0800 Subject: [PATCH] =?UTF-8?q?Phase3:=20=E4=B8=89=E5=B9=B3=E5=8F=B0=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=E5=9B=BE=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cover_generator.py: Pillow生成知乎/微信/小红书封面图(渐变背景+标题) - 知乎 1200×630 蓝调, 微信 900×500 绿调, 小红书 1080×1440 红调 - creator.py 流水线增加 cover_generator 作为最终回退 - db_helper.py 新增 save_cover_to_article - main.py 挂载 /automation/images 静态目录 - articles.py preview 接口返回 images 字段 - topics.html 预览dialog展示封面图 --- platform/backend/app/api/articles.py | 2 +- platform/backend/app/main.py | 7 + platform/frontend/topics.html | 9 +- scripts/cover_generator.py | 199 +++++++++++++++++++++++++++ scripts/creator.py | 4 +- scripts/db_helper.py | 21 +++ 6 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 scripts/cover_generator.py 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