Files
Yuzhiran Dev c8bee712d7 Phase3: 三平台封面图生成
- 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展示封面图
2026-05-20 19:12:58 +08:00

200 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()