fix: content quality, image format, task monitor, calendar data source, search UI & sort
This commit is contained in:
+171
-534
@@ -1,547 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
文章配图自动生成器
|
||||
基于PIL,根据文章标题、内容自动生成适合各平台的配图
|
||||
多平台文章配图生成器
|
||||
为知乎/公众号/小红书生成平台风格的 SVG 配图(base64 内联,无需外部资源)
|
||||
"""
|
||||
import base64, math, textwrap, re
|
||||
from typing import List, Optional
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
PLATFORM_STYLES = {
|
||||
"zhihu": {
|
||||
"primary": "#0084ff",
|
||||
"primary_rgb": "0, 132, 255",
|
||||
"accent": "#e8f4fd",
|
||||
"gradient_start": "#e8f4fd",
|
||||
"gradient_end": "#f5f9ff",
|
||||
"card_bg": "#ffffff",
|
||||
"title_color": "#1a1a1a",
|
||||
"dim_color": "#c0c4cc",
|
||||
},
|
||||
"wechat": {
|
||||
"primary": "#07c160",
|
||||
"primary_rgb": "7, 193, 96",
|
||||
"accent": "#f0faf4",
|
||||
"gradient_start": "#f0faf4",
|
||||
"gradient_end": "#e8f5ee",
|
||||
"card_bg": "#ffffff",
|
||||
"title_color": "#1a1a1a",
|
||||
"dim_color": "#c0c4cc",
|
||||
},
|
||||
"xiaohongshu": {
|
||||
"primary": "#ff2442",
|
||||
"primary_rgb": "255, 36, 66",
|
||||
"accent": "#fff5f5",
|
||||
"gradient_start": "#fff5f5",
|
||||
"gradient_end": "#fff0f0",
|
||||
"card_bg": "#ffffff",
|
||||
"title_color": "#262626",
|
||||
"dim_color": "#bfbfbf",
|
||||
},
|
||||
}
|
||||
|
||||
import yaml
|
||||
|
||||
# from PIL import Image, ImageDraw, ImageFont
|
||||
# 使用系统PIL,确保虚拟环境正确安装
|
||||
import sys
|
||||
sys.path.insert(0, '/usr/local/lib64/python3.11/site-packages')
|
||||
sys.path.insert(0, '/usr/lib64/python3.11/site-packages')
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import random
|
||||
|
||||
# 确保项目根目录在路径中
|
||||
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 db_helper import get_topic_by_id
|
||||
from app.models import Article
|
||||
from app.database import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 加载配置
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
with open(CONFIG_DIR / "wecom_config.yaml", 'r', encoding='utf-8') as f:
|
||||
wecom_config = yaml.safe_load(f)
|
||||
|
||||
@dataclass
|
||||
class ImageSpec:
|
||||
"""图片规格"""
|
||||
platform: str
|
||||
width: int
|
||||
height: int
|
||||
format: str = "PNG"
|
||||
quality: int = 85
|
||||
bg_color: Tuple[int, int, int] = (255, 255, 255) # 白色背景
|
||||
accent_color: Tuple[int, int, int] = (76, 175, 80) # 品牌绿色 #4CAF50
|
||||
text_color: Tuple[int, int, int] = (51, 51, 51) # 深灰色
|
||||
|
||||
class ImageGenerator:
|
||||
"""图片生成器"""
|
||||
|
||||
def __init__(self, output_base: Path = None):
|
||||
self.output_base = output_base or (PROJECT_ROOT / "automation" / "images" / "generated")
|
||||
self.today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
self.output_dir = self.output_base / self.today
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 加载平台规格
|
||||
self.platform_specs = {}
|
||||
for platform, specs in wecom_config["image_specs"].items():
|
||||
self.platform_specs[platform] = ImageSpec(
|
||||
platform=platform,
|
||||
width=specs["width"],
|
||||
height=specs["height"],
|
||||
format=specs["format"],
|
||||
quality=specs["quality"]
|
||||
)
|
||||
|
||||
# 字体路径
|
||||
self.font_paths = self._find_chinese_fonts()
|
||||
|
||||
def _find_chinese_fonts(self) -> List[str]:
|
||||
"""查找系统中可用的中文字体"""
|
||||
font_paths = [
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", # 文泉驿微米黑
|
||||
"/usr/share/fonts/truetype/arphic/uming.ttc", # 文鼎PL中等
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"/System/Library/Fonts/PingFang.ttc", # macOS
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc", # macOS
|
||||
"C:\\Windows\\Fonts\\msyh.ttc", # Windows
|
||||
"C:\\Windows\\Fonts\\simsun.ttc"
|
||||
]
|
||||
available = [p for p in font_paths if os.path.exists(p)]
|
||||
return available if available else [None] # 回退到默认字体
|
||||
|
||||
def _get_font(self, size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
||||
"""获取合适的中文字体"""
|
||||
# 优先使用系统中文字体
|
||||
chinese_fonts = ["/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/zh_CN/SimHei.ttf"]
|
||||
for font_path in chinese_fonts + self.font_paths:
|
||||
if font_path and os.path.exists(font_path):
|
||||
try:
|
||||
return ImageFont.truetype(font_path, size)
|
||||
except:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
def generate_cover_image(self, title: str, subtitle: str = "", platform: str = "zhihu") -> Path:
|
||||
"""生成封面图"""
|
||||
spec = self.platform_specs.get(platform, self.platform_specs["zhihu"])
|
||||
|
||||
# 创建图片
|
||||
img = Image.new('RGB', (spec.width, spec.height), color=spec.bg_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 添加渐变背景
|
||||
for y in range(spec.height):
|
||||
# 从顶部到中间的渐变
|
||||
ratio = y / (spec.height * 0.6)
|
||||
r = int(255 * (1 - ratio) + 230 * ratio)
|
||||
g = int(255 * (1 - ratio) + 240 * ratio)
|
||||
b = int(255 * (1 - ratio) + 250 * ratio)
|
||||
draw.line([(0, y), (spec.width, y)], fill=(r, g, b))
|
||||
|
||||
# 绘制品牌标识区域(底部条纹)
|
||||
stripe_height = 20
|
||||
stripe_y = spec.height - stripe_height - 30
|
||||
draw.rectangle([0, stripe_y, spec.width, stripe_y + stripe_height], fill=spec.accent_color)
|
||||
draw.text((20, stripe_y + 5), "宇之然", fill=(255, 255, 255), font=self._get_font(14))
|
||||
|
||||
# 标题排版
|
||||
title_font = self._get_font(int(spec.height * 0.12), bold=True)
|
||||
subtitle_font = self._get_font(int(spec.height * 0.06))
|
||||
|
||||
# 自动换行处理
|
||||
max_width = spec.width * 0.9
|
||||
title_lines = self._wrap_text(title, title_font, max_width)
|
||||
subtitle_lines = self._wrap_text(subtitle, subtitle_font, max_width) if subtitle else []
|
||||
|
||||
# 计算总高度
|
||||
line_spacing = 1.2
|
||||
title_height = len(title_lines) * title_font.size * line_spacing
|
||||
subtitle_height = len(subtitle_lines) * subtitle_font.size * line_spacing
|
||||
total_text_height = title_height + subtitle_height + 20 # 间距
|
||||
|
||||
# 居中绘制
|
||||
start_y = (spec.height - total_text_height) // 2
|
||||
|
||||
# 绘制标题
|
||||
for i, line in enumerate(title_lines):
|
||||
y = start_y + i * (title_font.size * line_spacing)
|
||||
self._draw_centered_text(draw, line, y, spec.width, title_font, spec.text_color)
|
||||
|
||||
# 绘制副标题
|
||||
if subtitle_lines:
|
||||
subtitle_start_y = start_y + title_height + 10
|
||||
for i, line in enumerate(subtitle_lines):
|
||||
y = subtitle_start_y + i * (subtitle_font.size * line_spacing)
|
||||
self._draw_centered_text(draw, line, y, spec.width, subtitle_font, (102, 102, 102))
|
||||
|
||||
# 保存图片
|
||||
filename = f"cover_{platform}.{spec.format.lower()}"
|
||||
output_path = self.output_dir / filename
|
||||
img.save(output_path, quality=spec.quality)
|
||||
|
||||
return output_path
|
||||
|
||||
def generate_chart_image(self, chart_type: str, data: Dict, title: str, platform: str = "zhihu") -> Path:
|
||||
"""生成数据图表"""
|
||||
spec = self.platform_specs.get(platform, self.platform_specs["zhihu"])
|
||||
|
||||
img = Image.new('RGB', (spec.width, spec.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 绘制标题
|
||||
title_font = self._get_font(36, bold=True)
|
||||
draw.text((50, 30), title, fill=spec.text_color, font=title_font)
|
||||
|
||||
# 根据图表类型绘制
|
||||
if chart_type == "bar":
|
||||
self._draw_bar_chart(draw, data, spec)
|
||||
elif chart_type == "pie":
|
||||
self._draw_pie_chart(draw, data, spec)
|
||||
elif chart_type == "line":
|
||||
self._draw_line_chart(draw, data, spec)
|
||||
else:
|
||||
# 默认显示文本
|
||||
text_font = self._get_font(24)
|
||||
draw.text((50, 150), f"图表类型: {chart_type}", fill=spec.text_color, font=text_font)
|
||||
draw.text((50, 200), f"数据: {json.dumps(data, ensure_ascii=False)}", fill=spec.text_color, font=text_font)
|
||||
|
||||
# 水印
|
||||
watermark_font = self._get_font(14)
|
||||
draw.text((spec.width - 150, spec.height - 30), "数据来源: 宇之然", fill=(150, 150, 150), font=watermark_font)
|
||||
|
||||
filename = f"data_chart_{platform}.png"
|
||||
output_path = self.output_dir / filename
|
||||
img.save(output_path, quality=spec.quality)
|
||||
|
||||
return output_path
|
||||
|
||||
def _draw_bar_chart(self, draw: ImageDraw.Draw, data: Dict, spec: ImageSpec):
|
||||
"""绘制柱状图"""
|
||||
# 数据格式: {"label1": value1, "label2": value2, ...}
|
||||
labels = list(data.keys())
|
||||
values = list(data.values())
|
||||
max_value = max(values) if values else 1
|
||||
|
||||
chart_area = {
|
||||
"left": 100,
|
||||
"top": 120,
|
||||
"right": spec.width - 50,
|
||||
"bottom": spec.height - 100
|
||||
}
|
||||
|
||||
chart_width = chart_area["right"] - chart_area["left"]
|
||||
chart_height = chart_area["bottom"] - chart_area["top"]
|
||||
|
||||
bar_width = chart_width // (len(values) * 2)
|
||||
gap = bar_width
|
||||
|
||||
# 绘制坐标轴
|
||||
draw.line([
|
||||
(chart_area["left"], chart_area["top"]),
|
||||
(chart_area["left"], chart_area["bottom"])
|
||||
], fill=(0, 0, 0), width=2)
|
||||
draw.line([
|
||||
(chart_area["left"], chart_area["bottom"]),
|
||||
(chart_area["right"], chart_area["bottom"])
|
||||
], fill=(0, 0, 0), width=2)
|
||||
|
||||
# 绘制柱子
|
||||
for i, (label, value) in enumerate(zip(labels, values)):
|
||||
x = chart_area["left"] + i * (bar_width + gap) + gap // 2
|
||||
bar_height = (value / max_value) * chart_height
|
||||
y_bottom = chart_area["bottom"]
|
||||
y_top = chart_area["bottom"] - bar_height
|
||||
|
||||
# 柱子(渐变色)
|
||||
for y in range(int(y_top), int(y_bottom)):
|
||||
ratio = (y - y_top) / bar_height if bar_height > 0 else 0
|
||||
r = int(76 + (100-76) * ratio)
|
||||
g = int(175 + (150-175) * ratio)
|
||||
b = int(80 + (120-80) * ratio)
|
||||
draw.line([(x, y), (x + bar_width, y)], fill=(r, g, b))
|
||||
|
||||
# 标签
|
||||
label_font = self._get_font(18)
|
||||
self._draw_centered_text(draw, label, y_bottom + 10, x + bar_width // 2, label_font, (80, 80, 80))
|
||||
|
||||
# 数值
|
||||
value_font = self._get_font(20, bold=True)
|
||||
self._draw_centered_text(draw, f"{value}", y_top - 10, x + bar_width // 2, value_font, spec.accent_color)
|
||||
|
||||
def _draw_pie_chart(self, draw: ImageDraw.Draw, data: Dict, spec: ImageSpec):
|
||||
"""绘制饼图"""
|
||||
# 简单实现:绘制圆形扇形
|
||||
center_x, center_y = spec.width // 2, spec.height // 2
|
||||
radius = min(spec.width, spec.height) // 3
|
||||
|
||||
total = sum(data.values()) if data else 1
|
||||
angle_start = 0
|
||||
|
||||
# 颜色调色板
|
||||
colors = [
|
||||
(76, 175, 80), (33, 150, 83), (139, 195, 74),
|
||||
(255, 193, 7), (255, 152, 0), (244, 67, 54)
|
||||
]
|
||||
|
||||
for i, (label, value) in enumerate(data.items()):
|
||||
angle_extent = (value / total) * 360
|
||||
color = colors[i % len(colors)]
|
||||
|
||||
# 绘制扇形
|
||||
draw.arc(
|
||||
[center_x - radius, center_y - radius, center_x + radius, center_y + radius],
|
||||
angle_start, angle_start + angle_extent,
|
||||
fill=color, width=radius * 2
|
||||
)
|
||||
angle_start += angle_extent
|
||||
|
||||
# 画中心白圆形成饼图效果
|
||||
inner_radius = radius * 0.5
|
||||
draw.ellipse(
|
||||
[center_x - inner_radius, center_y - inner_radius, center_x + inner_radius, center_y + inner_radius],
|
||||
fill=(255, 255, 255)
|
||||
)
|
||||
|
||||
# 绘制图例
|
||||
legend_y = spec.height - 80
|
||||
legend_x = 100
|
||||
for i, (label, value) in enumerate(data.items()):
|
||||
color = colors[i % len(colors)]
|
||||
# 色块
|
||||
draw.rectangle([legend_x, legend_y + i*25, legend_x+20, legend_y+20+i*25], fill=color)
|
||||
# 标签
|
||||
label_font = self._get_font(16)
|
||||
draw.text((legend_x+30, legend_y+i*25), f"{label}: {value}", fill=(60, 60, 60), font=label_font)
|
||||
|
||||
def _draw_line_chart(self, draw: ImageDraw.Draw, data: Dict, spec: ImageSpec):
|
||||
"""绘制折线图"""
|
||||
# 简化版:显示文本描述
|
||||
title_font = self._get_font(24)
|
||||
draw.text((50, 100), "折线图 (数据趋势)", fill=spec.text_color, font=title_font)
|
||||
|
||||
items = list(data.items())
|
||||
if not items:
|
||||
draw.text((50, 150), "无可用数据", fill=(100, 100, 100), font=self._get_font(18))
|
||||
return
|
||||
|
||||
# 列出数据
|
||||
data_font = self._get_font(16)
|
||||
y = 200
|
||||
for label, value in items[:10]: # 限制显示数量
|
||||
draw.text((50, y), f"{label}: {value}", fill=(80, 80, 80), font=data_font)
|
||||
y += 25
|
||||
|
||||
def generate_concept_image(self, title: str, items: List[str], platform: str = "zhihu") -> Path:
|
||||
"""生成概念示意图(用于行动清单等)"""
|
||||
spec = self.platform_specs.get(platform, self.platform_specs["zhihu"])
|
||||
|
||||
img = Image.new('RGB', (spec.width, spec.height), color=(245, 245, 245))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 标题
|
||||
title_font = self._get_font(42, bold=True)
|
||||
self._draw_centered_text(draw, title, 60, spec.width, title_font, spec.text_color)
|
||||
|
||||
# 绘制项目列表(带复选框样式)
|
||||
item_font = self._get_font(28)
|
||||
start_y = 150
|
||||
for i, item in enumerate(items[:8]): # 限制8个
|
||||
y = start_y + i * 50
|
||||
# 复选框
|
||||
box_size = 30
|
||||
box_x = (spec.width - 400) // 2
|
||||
draw.rectangle([box_x, y, box_x + box_size, y + box_size], outline=spec.accent_color, width=3)
|
||||
# 勾
|
||||
check_font = self._get_font(24)
|
||||
draw.text((box_x + 7, y + 2), "✓", fill=spec.accent_color, font=check_font)
|
||||
# 文字
|
||||
draw.text((box_x + box_size + 20, y + 5), item[:30], fill=(60, 60, 60), font=item_font)
|
||||
|
||||
filename = f"action_checklist_{platform}.png"
|
||||
output_path = self.output_dir / filename
|
||||
img.save(output_path, quality=spec.quality)
|
||||
|
||||
return output_path
|
||||
|
||||
def generate_equipment_list_image(self, items: List[Dict[str, str]], platform: str = "zhihu") -> Path:
|
||||
"""生成装备清单图"""
|
||||
spec = self.platform_specs.get(platform, self.platform_specs["zhihu"])
|
||||
|
||||
img = Image.new('RGB', (spec.width, spec.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 标题
|
||||
title = "装备清单"
|
||||
title_font = self._get_font(38, bold=True)
|
||||
draw.text((50, 40), title, fill=spec.text_color, font=title_font)
|
||||
|
||||
# 列头
|
||||
headers = ["名称", "用途", "预算"]
|
||||
header_font = self._get_font(24, bold=True)
|
||||
col_width = spec.width // len(headers)
|
||||
for i, header in enumerate(headers):
|
||||
x = i * col_width + 20
|
||||
draw.text((x, 100), header, fill=(100, 100, 100), font=header_font)
|
||||
|
||||
# 分隔线
|
||||
draw.line([(50, 130), (spec.width-50, 130)], fill=(200, 200, 200), width=2)
|
||||
|
||||
# 绘制条目
|
||||
item_font = self._get_font(20)
|
||||
row_height = 40
|
||||
y = 150
|
||||
for item in items[:10]: # 最多10行
|
||||
name = item.get("name", "")[:12]
|
||||
purpose = item.get("purpose", "")[:10]
|
||||
budget = item.get("budget", "")
|
||||
|
||||
draw.text((70, y), name, fill=(50, 50, 50), font=item_font)
|
||||
draw.text((col_width + 70, y), purpose, fill=(50, 50, 50), font=item_font)
|
||||
draw.text((2*col_width + 70, y), budget, fill=(50, 50, 50), font=item_font)
|
||||
|
||||
y += row_height
|
||||
|
||||
# 底部总预算
|
||||
total_budget = sum([int(item.get("budget", "0").replace("元", "")) for item in items if item.get("budget", "").replace("元", "").isdigit()])
|
||||
total_font = self._get_font(22, bold=True)
|
||||
draw.text((50, spec.height - 50), f"总预算: {total_budget}元", fill=spec.accent_color, font=total_font)
|
||||
|
||||
filename = f"equipment_{platform}.png"
|
||||
output_path = self.output_dir / filename
|
||||
img.save(output_path, quality=spec.quality)
|
||||
|
||||
return output_path
|
||||
|
||||
def _wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]:
|
||||
"""文本自动换行"""
|
||||
words = list(text)
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for char in words:
|
||||
test_line = current_line + char
|
||||
bbox = font.getbbox(test_line)
|
||||
width = bbox[2] - bbox[0]
|
||||
|
||||
if width <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = char
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
return lines if lines else [text]
|
||||
|
||||
def _draw_centered_text(self, draw: ImageDraw.Draw, text: str, y: int, center_x: int, font: ImageFont.FreeTypeFont, color: Tuple[int, int, int]):
|
||||
"""绘制居中文本"""
|
||||
bbox = font.getbbox(text)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
x = center_x - text_width // 2
|
||||
draw.text((x, y), text, fill=color, font=font)
|
||||
|
||||
def generate_all_placeholders(self, article_title: str, platform: str = "zhihu") -> Dict[str, Path]:
|
||||
"""生成所有占位图片"""
|
||||
files = {}
|
||||
|
||||
# 1. 封面图
|
||||
files["cover"] = self.generate_cover_image(article_title, "宇之然 · 可持续生活指南", platform)
|
||||
|
||||
# 2. 数据图表示例
|
||||
files["data_chart"] = self.generate_chart_image("bar", {"选项A": 45, "选项B": 32, "选项C": 23}, "数据对比", platform)
|
||||
|
||||
# 3. 概念图(行动清单)
|
||||
files["action_checklist"] = self.generate_concept_image("立即行动清单", [
|
||||
"第一步:记录现状,识别改进空间",
|
||||
"第二步:尝试最小可行改变",
|
||||
"第三步:评估效果,决定是否继续",
|
||||
"第四步:建立习惯,持续改进"
|
||||
], platform)
|
||||
|
||||
# 4. 装备清单图
|
||||
files["equipment"] = self.generate_equipment_list_image([
|
||||
{"name": "智能插座", "purpose": "定时控制", "budget": "50元"},
|
||||
{"name": "土壤传感器", "purpose": "湿度监测", "budget": "80元"},
|
||||
{"name": "自动灌溉", "purpose": "浇水", "budget": "120元"},
|
||||
{"name": "LED补光灯", "purpose": "光照", "budget": "200元"}
|
||||
], platform)
|
||||
|
||||
return files
|
||||
|
||||
def generate_for_topic(topic_id: str, platforms: List[str] = None) -> Dict[str, Dict[str, str]]:
|
||||
"""为指定选题生成三平台配图,路径存入 articles 表"""
|
||||
if platforms is None:
|
||||
platforms = ["zhihu", "wechat", "xiaohongshu"]
|
||||
|
||||
topic = get_topic_by_id(topic_id)
|
||||
if not topic:
|
||||
raise ValueError(f"Topic {topic_id} not found")
|
||||
|
||||
title = topic.get("title", "无标题")
|
||||
generator = ImageGenerator()
|
||||
results = {}
|
||||
|
||||
for platform in platforms:
|
||||
try:
|
||||
files = generator.generate_all_placeholders(title, platform)
|
||||
cover_path = str(files.get("cover", ""))
|
||||
chart_path = str(files.get("data_chart", ""))
|
||||
checklist_path = str(files.get("action_checklist", ""))
|
||||
|
||||
images = {
|
||||
"cover": cover_path,
|
||||
"chart": chart_path,
|
||||
"checklist": checklist_path,
|
||||
}
|
||||
|
||||
# 存入 DB
|
||||
save_article_images(topic_id, platform, images)
|
||||
|
||||
results[platform] = images
|
||||
logger.info(f" [{platform}] cover={Path(cover_path).name}" if cover_path else "")
|
||||
except Exception as e:
|
||||
logger.error(f" [{platform}] 生成失败: {e}")
|
||||
results[platform] = {}
|
||||
|
||||
return results
|
||||
FONT = "-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif"
|
||||
|
||||
|
||||
def save_article_images(topic_id: str, platform: str, images: Dict[str, str]):
|
||||
"""将图片路径写入 articles 表的 images 字段"""
|
||||
db = SessionLocal()
|
||||
def _wrap_chinese(text: str, chars_per_line: int = 14) -> List[str]:
|
||||
"""将中文文本按字数折行,尽量在标点处断开"""
|
||||
if not text:
|
||||
return [""]
|
||||
lines = []
|
||||
remainder = text
|
||||
while len(remainder) > chars_per_line:
|
||||
chunk = remainder[:chars_per_line]
|
||||
# 尝试在最后一个标点处断开
|
||||
cut = max(chunk.rfind(c) + 1 for c in (",", "、", "。", "!", "?", ":", ";", ")", " ", "—") if c in chunk[:-1])
|
||||
if cut <= 0:
|
||||
cut = chars_per_line
|
||||
lines.append(remainder[:cut].strip())
|
||||
remainder = remainder[cut:].strip()
|
||||
if remainder:
|
||||
lines.append(remainder)
|
||||
return lines
|
||||
|
||||
|
||||
def _to_base64(svg: str) -> str:
|
||||
try:
|
||||
from app.models import Article
|
||||
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
|
||||
else:
|
||||
article = Article(
|
||||
id=article_id,
|
||||
topic_id=topic_id,
|
||||
platform=platform,
|
||||
file_path=f"db:{article_id}",
|
||||
status="draft",
|
||||
images=images,
|
||||
)
|
||||
db.add(article)
|
||||
db.commit()
|
||||
import cairosvg
|
||||
png = cairosvg.svg2png(bytestring=svg.encode('utf-8'))
|
||||
return 'data:image/png;base64,' + base64.b64encode(png).decode('ascii')
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
return 'data:image/svg+xml;base64,' + base64.b64encode(svg.encode('utf-8')).decode('ascii')
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='文章配图生成器')
|
||||
parser.add_argument('--topic-id', help='选题ID,指定则为选题生成配图')
|
||||
args = parser.parse_args()
|
||||
def _alt_attr(text: str) -> str:
|
||||
return text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
|
||||
|
||||
if args.topic_id:
|
||||
print(f"为选题 {args.topic_id} 生成配图...")
|
||||
results = generate_for_topic(args.topic_id)
|
||||
print(json.dumps({"topic_id": args.topic_id, "images": results}, ensure_ascii=False))
|
||||
sys.exit(0)
|
||||
|
||||
"""测试主函数"""
|
||||
generator = ImageGenerator()
|
||||
|
||||
# 测试生成图片
|
||||
print(f"开始生成图片到: {generator.output_dir}")
|
||||
|
||||
# 生成所有类型的占位图
|
||||
files = generator.generate_all_placeholders("上海阳台种菜一年:我收获的不仅是蔬菜", "zhihu")
|
||||
|
||||
print("\n生成的文件:")
|
||||
for name, path in files.items():
|
||||
print(f" - {name}: {path.name} ({path.stat().st_size // 1024}KB)")
|
||||
|
||||
print(f"\n✅ 图片生成完成,共 {len(files)} 张")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
def _img_tag(src: str, alt: str, width: int = 1080) -> str:
|
||||
return f'<p><img src="{src}" alt="{_alt_attr(alt)}" style="width:100%;max-width:{width}px;border-radius:8px;"></p>\n'
|
||||
|
||||
|
||||
def generate_lead(platform: str, title: str, field: str = "", brand: str = "宇之然") -> str:
|
||||
"""生成文章头图(1080×600)"""
|
||||
s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
|
||||
lines = _wrap_chinese(title, 16)
|
||||
|
||||
title_lines = ""
|
||||
y_start = 160
|
||||
for i, line in enumerate(lines[:3]):
|
||||
title_lines += f'<text x="80" y="{y_start + i*60}" font-size="44" font-weight="bold" fill="{s["title_color"]}">{_alt_attr(line)}</text>\n'
|
||||
|
||||
field_badge = ""
|
||||
if field:
|
||||
field_badge = f'''
|
||||
<rect x="80" y="{y_start + len(lines[:3]) * 60 + 20}" width="{len(field)*14 + 32}" height="34" rx="17" fill="{s["primary"]}" opacity="0.12"/>
|
||||
<text x="96" y="{y_start + len(lines[:3]) * 60 + 43}" font-size="14" fill="{s["primary"]}" font-weight="500">{_alt_attr(field)}</text>'''
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="600" viewBox="0 0 1080 600" style="width:100%;max-width:1080px;border-radius:8px;">
|
||||
<defs>
|
||||
<linearGradient id="lead_bg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="{s["gradient_start"]}"/><stop offset="100%" stop-color="{s["gradient_end"]}"/></linearGradient>
|
||||
</defs>
|
||||
<rect width="1080" height="600" fill="url(#lead_bg)"/>
|
||||
<circle cx="120" cy="120" r="220" fill="{s["primary"]}" opacity="0.04"/>
|
||||
<circle cx="960" cy="480" r="180" fill="{s["primary"]}" opacity="0.06"/>
|
||||
<circle cx="540" cy="300" r="320" fill="{s["primary"]}" opacity="0.02"/>
|
||||
<rect x="80" y="80" width="80" height="4" rx="2" fill="{s["primary"]}"/>
|
||||
<g font-family="{FONT}">
|
||||
{title_lines}
|
||||
{field_badge}
|
||||
<text x="80" y="540" font-size="14" fill="{s["dim_color"]}">{_alt_attr(brand)}</text>
|
||||
</g>
|
||||
</svg>'''
|
||||
return _img_tag(_to_base64(svg), title)
|
||||
|
||||
|
||||
def generate_section_card(platform: str, section_title: str, section_num: int) -> str:
|
||||
"""生成章节分隔图(800×160)"""
|
||||
s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
|
||||
num_text = f"{section_num:02d}"
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="800" height="160" viewBox="0 0 800 160" style="width:100%;max-width:800px;border-radius:8px;">
|
||||
<rect width="800" height="160" fill="{s["card_bg"]}" rx="10"/>
|
||||
<rect x="0" y="0" width="5" height="160" fill="{s["primary"]}" rx="2.5"/>
|
||||
<text x="36" y="72" font-size="56" font-weight="bold" fill="{s["primary"]}" opacity="0.12" font-family="{FONT}">{num_text}</text>
|
||||
<text x="36" y="120" font-size="20" font-weight="bold" fill="{s["title_color"]}" font-family="{FONT}">{_alt_attr(section_title)}</text>
|
||||
</svg>'''
|
||||
return _img_tag(_to_base64(svg), section_title, 800)
|
||||
|
||||
|
||||
def generate_quote_card(platform: str, quote: str, source: str = "") -> str:
|
||||
"""生成金句卡片(800×280)"""
|
||||
s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
|
||||
lines = _wrap_chinese(quote, 20)
|
||||
|
||||
quote_lines = ""
|
||||
y_start = 110
|
||||
for i, line in enumerate(lines[:4]):
|
||||
quote_lines += f'<text x="80" y="{y_start + i*36}" font-size="18" fill="{s["title_color"]}" font-weight="500">{_alt_attr(line)}</text>\n'
|
||||
|
||||
source_line = ""
|
||||
if source:
|
||||
source_line = f'<text x="80" y="{y_start + min(len(lines), 4)*36 + 6}" font-size="13" fill="{s["dim_color"]}">{_alt_attr(f"— {source}")}</text>'
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="800" height="280" viewBox="0 0 800 280" style="width:100%;max-width:800px;border-radius:8px;">
|
||||
<rect width="800" height="280" fill="{s["accent"]}" rx="10"/>
|
||||
<text x="40" y="80" font-size="56" fill="{s["primary"]}" opacity="0.2" font-family="Georgia, serif">"</text>
|
||||
<g font-family="{FONT}">
|
||||
{quote_lines}
|
||||
{source_line}
|
||||
</g>
|
||||
</svg>'''
|
||||
return _img_tag(_to_base64(svg), f"金句:{quote[:30]}", 800)
|
||||
|
||||
|
||||
def generate_data_highlight(platform: str, number: str, label: str) -> str:
|
||||
"""生成数据高亮卡片(800×220)"""
|
||||
s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="800" height="220" viewBox="0 0 800 220" style="width:100%;max-width:800px;border-radius:8px;">
|
||||
<rect width="800" height="220" fill="{s["primary"]}" rx="10"/>
|
||||
<g font-family="{FONT}">
|
||||
<text x="80" y="120" font-size="60" font-weight="bold" fill="#ffffff">{_alt_attr(number)}</text>
|
||||
<text x="80" y="170" font-size="16" fill="#ffffff" opacity="0.85">{_alt_attr(label)}</text>
|
||||
</g>
|
||||
</svg>'''
|
||||
return _img_tag(_to_base64(svg), label, 800)
|
||||
|
||||
|
||||
def insert_lead_image(html: str, platform: str, title: str, field: str) -> str:
|
||||
"""在 HTML 正文开头插入头图(仅必要环节)"""
|
||||
lead = generate_lead(platform, title, field)
|
||||
h1_end = html.find('</h1>')
|
||||
if h1_end != -1:
|
||||
html = html[:h1_end + 5] + '\n' + lead + html[h1_end + 5:]
|
||||
else:
|
||||
html = lead + html
|
||||
return html
|
||||
|
||||
|
||||
# 平台特定的配图密度
|
||||
PLATFORM_IMAGE_COUNTS = {
|
||||
"zhihu": {"lead": True, "sections": True, "quotes": 0, "data": 0, "density": "medium"},
|
||||
"wechat": {"lead": True, "sections": True, "quotes": 0, "data": 0, "density": "medium"},
|
||||
"xiaohongshu": {"lead": True, "sections": True, "quotes": 0, "data": 0, "density": "high"},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user