815 lines
31 KiB
Python
Executable File
815 lines
31 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
baoyu-post-to-wechat 图片生成增强版
|
||
集成 WeWrite 的豆包Seedream图片生成功能
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import re
|
||
import requests
|
||
import yaml
|
||
import base64
|
||
import tempfile
|
||
import mimetypes
|
||
import argparse
|
||
from pathlib import Path
|
||
from typing import List, Dict, Optional, Union, Tuple
|
||
from datetime import datetime
|
||
|
||
# 添加 WeWrite 路径
|
||
WEWRITE_DIR = Path("/home/ubuntu/wewrite")
|
||
sys.path.insert(0, str(WEWRITE_DIR))
|
||
|
||
try:
|
||
from toolkit.image_gen import generate_image
|
||
HAS_IMAGE_GEN = True
|
||
except ImportError as e:
|
||
print(f"⚠️ 无法导入 WeWrite 图片生成模块: {e}")
|
||
HAS_IMAGE_GEN = False
|
||
|
||
# 尝试导入Pillow(图片处理)
|
||
try:
|
||
from PIL import Image, ImageOps
|
||
HAS_PILLOW = True
|
||
except ImportError:
|
||
HAS_PILLOW = False
|
||
print("⚠️ Pillow未安装,图片优化功能受限")
|
||
|
||
# 配置文件路径
|
||
EXTEND_PATH = "/home/ubuntu/.nanobot/workspace/skills/baoyu-post-to-wechat/EXTEND.md"
|
||
WEWRITE_CONFIG = "/home/ubuntu/wewrite/config.yaml"
|
||
|
||
class ImageGenerator:
|
||
"""图片生成器(集成WeWrite的豆包Seedream)"""
|
||
|
||
@staticmethod
|
||
def generate_cover_image(prompt: str, output_path: str = None) -> str:
|
||
"""
|
||
生成封面图片
|
||
"""
|
||
if not HAS_IMAGE_GEN:
|
||
raise ImportError("WeWrite 图片生成模块未找到")
|
||
|
||
if not output_path:
|
||
output_path = f"/tmp/cover_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jpg"
|
||
|
||
try:
|
||
print(f"🎨 生成封面图片: {prompt}")
|
||
path = generate_image(prompt, output_path, size="cover")
|
||
print(f"✅ 封面图片生成成功: {path}")
|
||
return path
|
||
except Exception as e:
|
||
print(f"❌ 封面图片生成失败: {e}")
|
||
raise
|
||
|
||
@staticmethod
|
||
def generate_article_image(prompt: str, output_path: str = None) -> str:
|
||
"""
|
||
生成文章配图
|
||
"""
|
||
if not HAS_IMAGE_GEN:
|
||
raise ImportError("WeWrite 图片生成模块未找到")
|
||
|
||
if not output_path:
|
||
output_path = f"/tmp/article_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jpg"
|
||
|
||
try:
|
||
print(f"🎨 生成文章配图: {prompt}")
|
||
path = generate_image(prompt, output_path, size="article")
|
||
print(f"✅ 文章配图生成成功: {path}")
|
||
return path
|
||
except Exception as e:
|
||
print(f"❌ 文章配图生成失败: {e}")
|
||
raise
|
||
|
||
@staticmethod
|
||
def generate_image_for_section(section_title: str, content: str, output_path: str = None) -> str:
|
||
"""
|
||
根据章节内容生成配图
|
||
"""
|
||
# 构建图片生成提示词
|
||
prompt = f"{section_title} - {content[:50]}..."
|
||
|
||
if not output_path:
|
||
output_path = f"/tmp/section_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jpg"
|
||
|
||
return ImageGenerator.generate_article_image(prompt, output_path)
|
||
|
||
class ImageOptimizer:
|
||
"""图片优化器"""
|
||
|
||
@staticmethod
|
||
def optimize_image(image_path: str, max_size: int = 1024*1024) -> str:
|
||
"""
|
||
优化图片以适应微信要求
|
||
返回优化后的图片路径
|
||
"""
|
||
if not HAS_PILLOW:
|
||
print(f"⚠️ Pillow未安装,跳过图片优化: {os.path.basename(image_path)}")
|
||
return image_path
|
||
|
||
try:
|
||
with Image.open(image_path) as img:
|
||
original_format = img.format
|
||
original_size = os.path.getsize(image_path)
|
||
|
||
print(f"🖼️ 优化图片: {os.path.basename(image_path)} "
|
||
f"({original_format}, {original_size/1024:.1f}KB)")
|
||
|
||
# 转换模式
|
||
if img.mode not in ['RGB', 'L']:
|
||
img = img.convert('RGB')
|
||
|
||
# 检查是否需要调整尺寸(微信建议宽度≤2560)
|
||
max_width = 2560
|
||
if img.width > max_width:
|
||
ratio = max_width / img.width
|
||
new_height = int(img.height * ratio)
|
||
img = img.resize((max_width, new_height), Image.Resampling.LANCZOS)
|
||
print(f" ↳ 调整尺寸: {img.width}x{img.height}")
|
||
|
||
# 检查文件大小
|
||
if original_size > max_size:
|
||
# 创建临时文件
|
||
temp_file = tempfile.NamedTemporaryFile(
|
||
suffix='.jpg', delete=False, prefix='optimized_'
|
||
)
|
||
temp_path = temp_file.name
|
||
temp_file.close()
|
||
|
||
# 渐进式压缩
|
||
quality = 85
|
||
while quality >= 50:
|
||
img.save(temp_path, 'JPEG', quality=quality, optimize=True)
|
||
optimized_size = os.path.getsize(temp_path)
|
||
|
||
if optimized_size <= max_size or quality <= 50:
|
||
print(f" ↳ 压缩质量: {quality}% → {optimized_size/1024:.1f}KB")
|
||
return temp_path
|
||
|
||
quality -= 10
|
||
|
||
return temp_path
|
||
else:
|
||
# 如果图片已经符合要求,直接返回原路径
|
||
return image_path
|
||
|
||
except Exception as e:
|
||
print(f"❌ 图片优化失败 {os.path.basename(image_path)}: {e}")
|
||
return image_path
|
||
|
||
@staticmethod
|
||
def get_image_info(image_path: str) -> Dict:
|
||
"""获取图片信息"""
|
||
info = {
|
||
'path': image_path,
|
||
'filename': os.path.basename(image_path),
|
||
'exists': os.path.exists(image_path),
|
||
'size': os.path.getsize(image_path) if os.path.exists(image_path) else 0,
|
||
'optimized': False
|
||
}
|
||
|
||
if HAS_PILLOW and info['exists']:
|
||
try:
|
||
with Image.open(image_path) as img:
|
||
info.update({
|
||
'format': img.format,
|
||
'mode': img.mode,
|
||
'width': img.width,
|
||
'height': img.height,
|
||
'size_kb': info['size'] / 1024
|
||
})
|
||
except:
|
||
pass
|
||
|
||
return info
|
||
|
||
class MarkdownParser:
|
||
"""Markdown解析器(支持Frontmatter)"""
|
||
|
||
@staticmethod
|
||
def parse_frontmatter(content: str) -> Tuple[Dict, str]:
|
||
"""
|
||
解析Markdown中的YAML frontmatter
|
||
返回: (frontmatter_dict, markdown_body)
|
||
"""
|
||
# 匹配 frontmatter 模式: ---\n内容\n---\n
|
||
pattern = r'^---\s*\n(.*?)\n---\s*\n(.*)$'
|
||
match = re.match(pattern, content, re.DOTALL | re.MULTILINE)
|
||
|
||
if match:
|
||
frontmatter_str = match.group(1)
|
||
body = match.group(2)
|
||
|
||
try:
|
||
frontmatter = yaml.safe_load(frontmatter_str) or {}
|
||
except yaml.YAMLError as e:
|
||
print(f"⚠️ Frontmatter解析错误: {e}")
|
||
frontmatter = {}
|
||
|
||
return frontmatter, body
|
||
else:
|
||
return {}, content
|
||
|
||
@staticmethod
|
||
def extract_title_from_body(body: str) -> str:
|
||
"""从正文中提取标题(第一个一级标题)"""
|
||
# 匹配 # 标题
|
||
match = re.search(r'^#\s+(.+)$', body, re.MULTILINE)
|
||
if match:
|
||
return match.group(1).strip()
|
||
return ""
|
||
|
||
@staticmethod
|
||
def extract_summary(body: str, max_length: int = 120) -> str:
|
||
"""提取摘要(纯文本,不超过指定长度)"""
|
||
# 移除Markdown标记
|
||
plain_text = re.sub(r'[#*`\-\[\]>]', '', body)
|
||
# 移除HTML标签
|
||
plain_text = re.sub(r'<[^>]+>', '', plain_text)
|
||
# 合并空白字符
|
||
plain_text = re.sub(r'\s+', ' ', plain_text).strip()
|
||
|
||
if len(plain_text) <= max_length:
|
||
return plain_text
|
||
else:
|
||
# 在完整词语处截断
|
||
truncated = plain_text[:max_length]
|
||
last_space = truncated.rfind(' ')
|
||
if last_space > max_length * 0.7: # 确保不是太短的截断
|
||
return truncated[:last_space] + "..."
|
||
else:
|
||
return truncated[:max_length-3] + "..."
|
||
|
||
class WeChatPublisher:
|
||
"""微信公众号发布器(图片生成增强版)"""
|
||
|
||
def __init__(self):
|
||
self.config = self._load_config()
|
||
self.access_token = None
|
||
self.image_optimizer = ImageOptimizer()
|
||
self.markdown_parser = MarkdownParser()
|
||
self.image_generator = ImageGenerator() if HAS_IMAGE_GEN else None
|
||
|
||
def _load_config(self) -> Dict:
|
||
"""加载配置"""
|
||
config = {}
|
||
|
||
# 加载 EXTEND.md
|
||
if os.path.exists(EXTEND_PATH):
|
||
with open(EXTEND_PATH, 'r', encoding='utf-8') as f:
|
||
for line in f:
|
||
if ':' in line and not line.strip().startswith('#'):
|
||
key, value = line.split(':', 1)
|
||
config[key.strip()] = value.strip()
|
||
|
||
# 回退到 WeWrite 配置
|
||
if 'app_id' not in config and os.path.exists(WEWRITE_CONFIG):
|
||
with open(WEWRITE_CONFIG, 'r', encoding='utf-8') as f:
|
||
wewrite_config = yaml.safe_load(f)
|
||
config['app_id'] = wewrite_config['wechat']['appid']
|
||
config['app_secret'] = wewrite_config['wechat']['secret']
|
||
config['default_author'] = wewrite_config['wechat']['author']
|
||
|
||
# 加载图片生成配置
|
||
if 'image' in wewrite_config:
|
||
config['image_provider'] = wewrite_config['image']['provider']
|
||
config['image_api_key'] = wewrite_config['image']['api_key']
|
||
config['image_model'] = wewrite_config['image']['model']
|
||
|
||
# 设置默认值
|
||
config.setdefault('default_theme', 'default')
|
||
config.setdefault('default_color', 'blue')
|
||
config.setdefault('need_open_comment', '1')
|
||
config.setdefault('only_fans_can_comment', '0')
|
||
|
||
return config
|
||
|
||
def get_access_token(self) -> str:
|
||
"""获取微信 access_token"""
|
||
if self.access_token:
|
||
return self.access_token
|
||
|
||
url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={self.config['app_id']}&secret={self.config['app_secret']}"
|
||
response = requests.get(url)
|
||
data = response.json()
|
||
|
||
if 'access_token' in data:
|
||
self.access_token = data['access_token']
|
||
return self.access_token
|
||
else:
|
||
raise Exception(f"获取 access_token 失败: {data}")
|
||
|
||
def upload_image(self, image_path: str) -> str:
|
||
"""上传图片到微信(永久素材)"""
|
||
access_token = self.get_access_token()
|
||
|
||
# 检查文件是否存在
|
||
if not os.path.exists(image_path):
|
||
raise FileNotFoundError(f"图片文件不存在: {image_path}")
|
||
|
||
# 优化图片(如果需要)
|
||
optimized_path = self.image_optimizer.optimize_image(image_path)
|
||
use_optimized = optimized_path != image_path
|
||
|
||
if use_optimized:
|
||
print(f" ↳ 使用优化后的图片: {os.path.basename(optimized_path)}")
|
||
upload_path = optimized_path
|
||
else:
|
||
upload_path = image_path
|
||
|
||
# 获取MIME类型
|
||
mime_type, _ = mimetypes.guess_type(upload_path)
|
||
if not mime_type:
|
||
mime_type = 'image/jpeg'
|
||
|
||
# 上传图片
|
||
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={access_token}&type=image"
|
||
|
||
with open(upload_path, 'rb') as f:
|
||
files = {'media': (os.path.basename(upload_path), f, mime_type)}
|
||
response = requests.post(url, files=files)
|
||
|
||
data = response.json()
|
||
|
||
# 清理临时文件
|
||
if use_optimized and os.path.exists(optimized_path):
|
||
try:
|
||
os.unlink(optimized_path)
|
||
except:
|
||
pass
|
||
|
||
if 'media_id' in data:
|
||
return data['media_id']
|
||
else:
|
||
raise Exception(f"上传图片失败: {data}")
|
||
|
||
def upload_images(self, image_paths: List[str]) -> Dict[str, str]:
|
||
"""批量上传图片(带优化)"""
|
||
results = {}
|
||
|
||
print(f"📸 发现 {len(image_paths)} 张图片:")
|
||
for i, img_path in enumerate(image_paths, 1):
|
||
img_info = self.image_optimizer.get_image_info(img_path)
|
||
|
||
if not img_info['exists']:
|
||
print(f" {i}. ❌ 文件不存在: {img_info['filename']}")
|
||
results[img_path] = None
|
||
continue
|
||
|
||
# 显示图片信息
|
||
size_info = f"{img_info.get('size_kb', 0):.1f}KB"
|
||
if 'width' in img_info:
|
||
size_info = f"{img_info['width']}x{img_info['height']}, {size_info}"
|
||
|
||
format_info = img_info.get('format', '未知')
|
||
print(f" {i}. ✅ {img_info['filename']} ({format_info}, {size_info})")
|
||
|
||
print("⬆️ 上传图片到微信...")
|
||
for img_path in image_paths:
|
||
if not os.path.exists(img_path):
|
||
results[img_path] = None
|
||
continue
|
||
|
||
try:
|
||
media_id = self.upload_image(img_path)
|
||
results[img_path] = media_id
|
||
print(f" ✅ 上传成功: {os.path.basename(img_path)} -> {media_id[:20]}...")
|
||
except Exception as e:
|
||
print(f" ❌ 上传失败 {os.path.basename(img_path)}: {e}")
|
||
results[img_path] = None
|
||
|
||
return results
|
||
|
||
def extract_images_from_markdown(self, markdown_content: str) -> List[str]:
|
||
"""从Markdown中提取图片路径"""
|
||
# 匹配Markdown图片语法: 
|
||
pattern = r'!\[.*?\]\((.*?)\)'
|
||
matches = re.findall(pattern, markdown_content)
|
||
|
||
# 过滤出本地文件路径(排除URL)
|
||
local_images = []
|
||
for match in matches:
|
||
if not match.startswith(('http://', 'https://')):
|
||
# 检查文件是否存在
|
||
if os.path.exists(match):
|
||
local_images.append(match)
|
||
else:
|
||
# 尝试相对路径
|
||
abs_path = os.path.abspath(match)
|
||
if os.path.exists(abs_path):
|
||
local_images.append(abs_path)
|
||
|
||
return local_images
|
||
|
||
def replace_images_in_markdown(self, markdown_content: str, image_map: Dict[str, str]) -> str:
|
||
"""将Markdown中的本地图片替换为微信图片引用"""
|
||
content = markdown_content
|
||
|
||
for img_path, media_id in image_map.items():
|
||
if media_id:
|
||
# 构建微信图片引用格式
|
||
wechat_img = f'<img src="https://mmbiz.qpic.cn/mmbiz_jpg/{media_id}/0" alt="图片" style="width:100%;max-width:100%;">'
|
||
|
||
# 替换Markdown图片语法
|
||
pattern = rf'!\[.*?\]\({re.escape(img_path)}\)'
|
||
content = re.sub(pattern, wechat_img, content)
|
||
|
||
return content
|
||
|
||
def markdown_to_html(self, markdown_content: str) -> str:
|
||
"""将Markdown转换为HTML(优化版)"""
|
||
# 处理标题
|
||
html = markdown_content
|
||
|
||
# 标题
|
||
html = re.sub(r'^# (.*?)$', r'<h1>\1</h1>', html, flags=re.MULTILINE)
|
||
html = re.sub(r'^## (.*?)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
|
||
html = re.sub(r'^### (.*?)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
|
||
html = re.sub(r'^#### (.*?)$', r'<h4>\1</h4>', html, flags=re.MULTILINE)
|
||
|
||
# 粗体和斜体
|
||
html = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html)
|
||
html = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html)
|
||
|
||
# 删除线
|
||
html = re.sub(r'~~(.*?)~~', r'<del>\1</del>', html)
|
||
|
||
# 内联代码
|
||
html = re.sub(r'`([^`]+)`', r'<code>\1</code>', html)
|
||
|
||
# 列表
|
||
lines = html.split('\n')
|
||
in_list = False
|
||
result_lines = []
|
||
|
||
for line in lines:
|
||
if re.match(r'^[*-] ', line):
|
||
if not in_list:
|
||
result_lines.append('<ul>')
|
||
in_list = True
|
||
content = line[2:].strip()
|
||
result_lines.append(f' <li>{content}</li>')
|
||
elif re.match(r'^\d+\. ', line):
|
||
if not in_list:
|
||
result_lines.append('<ol>')
|
||
in_list = True
|
||
content = re.sub(r'^\d+\. ', '', line).strip()
|
||
result_lines.append(f' <li>{content}</li>')
|
||
else:
|
||
if in_list:
|
||
result_lines.append('</ul>' if '<ul>' in result_lines else '</ol>')
|
||
in_list = False
|
||
result_lines.append(line)
|
||
|
||
if in_list:
|
||
result_lines.append('</ul>' if '<ul>' in result_lines else '</ol>')
|
||
|
||
html = '\n'.join(result_lines)
|
||
|
||
# 引用
|
||
html = re.sub(r'^> (.*?)$', r'<blockquote>\1</blockquote>', html, flags=re.MULTILINE)
|
||
|
||
# 代码块(简化处理)
|
||
html = re.sub(r'```.*?\n(.*?)\n```', r'<pre><code>\1</code></pre>', html, flags=re.DOTALL)
|
||
|
||
# 链接
|
||
html = re.sub(r'\[(.*?)\]\((.*?)\)', r'<a href="\2">\1</a>', html)
|
||
|
||
# 段落处理
|
||
lines = html.split('\n')
|
||
in_paragraph = False
|
||
final_lines = []
|
||
|
||
for line in lines:
|
||
if line.strip() == '':
|
||
if in_paragraph:
|
||
final_lines.append('</p>')
|
||
in_paragraph = False
|
||
final_lines.append('')
|
||
else:
|
||
if not in_paragraph and not line.startswith(('<h', '<li', '<img', '<blockquote', '<pre', '<ul', '<ol', '<a')):
|
||
final_lines.append('<p>')
|
||
in_paragraph = True
|
||
final_lines.append(line)
|
||
|
||
if in_paragraph:
|
||
final_lines.append('</p>')
|
||
|
||
html = '\n'.join(final_lines)
|
||
|
||
return html
|
||
|
||
def create_draft(self, title: str, content: str, author: str = None,
|
||
digest: str = None, cover_image_path: str = None) -> str:
|
||
"""创建公众号草稿(支持HTML内容)"""
|
||
access_token = self.get_access_token()
|
||
url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={access_token}"
|
||
|
||
# 上传封面图
|
||
thumb_media_id = None
|
||
if cover_image_path and os.path.exists(cover_image_path):
|
||
print(f"🖼️ 上传封面图: {os.path.basename(cover_image_path)}")
|
||
thumb_media_id = self.upload_image(cover_image_path)
|
||
|
||
# 如果没有提供作者,使用默认作者
|
||
if not author:
|
||
author = self.config.get('default_author', '')
|
||
|
||
# 如果没有提供摘要,从内容中提取
|
||
if not digest:
|
||
plain_text = re.sub(r'<[^>]+>', '', content) # 移除HTML标签
|
||
plain_text = re.sub(r'\s+', ' ', plain_text).strip()
|
||
digest = self.markdown_parser.extract_summary(plain_text)
|
||
|
||
# 构建文章数据
|
||
article = {
|
||
"title": title,
|
||
"author": author,
|
||
"digest": digest,
|
||
"content": content,
|
||
"show_cover_pic": 0,
|
||
"need_open_comment": int(self.config.get('need_open_comment', 1)),
|
||
"only_fans_can_comment": int(self.config.get('only_fans_can_comment', 0))
|
||
}
|
||
|
||
if thumb_media_id:
|
||
article["thumb_media_id"] = thumb_media_id
|
||
|
||
body = {"articles": [article]}
|
||
|
||
# 发送请求
|
||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||
response = requests.post(url, data=json.dumps(body, ensure_ascii=False).encode('utf-8'), headers=headers)
|
||
data = response.json()
|
||
|
||
if 'media_id' in data:
|
||
return data['media_id']
|
||
else:
|
||
raise Exception(f"创建草稿失败: {data}")
|
||
|
||
def generate_images_for_markdown(self, markdown_content: str, output_dir: str = None) -> Tuple[str, Dict[str, str]]:
|
||
"""
|
||
为Markdown内容生成配图
|
||
返回: (处理后的markdown, 图片路径映射)
|
||
"""
|
||
if not self.image_generator:
|
||
raise ImportError("图片生成功能不可用")
|
||
|
||
if not output_dir:
|
||
output_dir = tempfile.mkdtemp(prefix="generated_images_")
|
||
|
||
# 提取章节标题
|
||
section_pattern = r'^#{2,4}\s+(.+)$'
|
||
sections = re.findall(section_pattern, markdown_content, re.MULTILINE)
|
||
|
||
image_map = {}
|
||
processed_content = markdown_content
|
||
|
||
print(f"🎨 为 {len(sections)} 个章节生成配图...")
|
||
|
||
for i, section_title in enumerate(sections, 1):
|
||
# 生成图片
|
||
output_path = os.path.join(output_dir, f"section_{i:02d}.jpg")
|
||
|
||
try:
|
||
image_path = self.image_generator.generate_image_for_section(
|
||
section_title,
|
||
f"文章章节配图: {section_title}",
|
||
output_path
|
||
)
|
||
|
||
# 在章节前插入图片
|
||
img_markdown = f'\n\n*{section_title}*\n'
|
||
|
||
# 找到章节位置并插入图片
|
||
section_pattern = rf'^(#{2,4}\s+{re.escape(section_title)})$'
|
||
processed_content = re.sub(
|
||
section_pattern,
|
||
f'{img_markdown}\\1',
|
||
processed_content,
|
||
flags=re.MULTILINE
|
||
)
|
||
|
||
image_map[image_path] = section_title
|
||
print(f" ✅ 为章节生成配图: {section_title}")
|
||
|
||
except Exception as e:
|
||
print(f" ⚠️ 章节配图生成失败 {section_title}: {e}")
|
||
|
||
return processed_content, image_map
|
||
|
||
def publish_markdown_with_images(self, markdown_path: str, title: str = None,
|
||
author: str = None, cover_image_path: str = None,
|
||
generate_images: bool = False) -> Dict:
|
||
"""发布Markdown文章(支持图文混排 + 图片生成)"""
|
||
print("=" * 60)
|
||
print("📝 baoyu-post-to-wechat 图片生成增强版 v3.0")
|
||
print("=" * 60)
|
||
|
||
# 检查文件
|
||
if not os.path.exists(markdown_path):
|
||
raise FileNotFoundError(f"Markdown文件不存在: {markdown_path}")
|
||
|
||
# 读取Markdown内容
|
||
with open(markdown_path, 'r', encoding='utf-8') as f:
|
||
markdown_content = f.read()
|
||
|
||
print(f"📄 读取文件: {os.path.basename(markdown_path)} ({len(markdown_content)} 字符)")
|
||
|
||
# 解析Frontmatter
|
||
print("🔍 解析Frontmatter...")
|
||
frontmatter, body = self.markdown_parser.parse_frontmatter(markdown_content)
|
||
|
||
if frontmatter:
|
||
print(" ✅ 发现Frontmatter:")
|
||
for key, value in frontmatter.items():
|
||
if key in ['title', 'author', 'date', 'tags']:
|
||
print(f" {key}: {value}")
|
||
|
||
# 确定标题(优先级:参数 > frontmatter > 正文提取 > 文件名)
|
||
if not title:
|
||
title = frontmatter.get('title', '')
|
||
|
||
if not title:
|
||
title = self.markdown_parser.extract_title_from_body(body)
|
||
|
||
if not title:
|
||
title = Path(markdown_path).stem
|
||
|
||
# 确定作者(优先级:参数 > frontmatter > 配置默认)
|
||
if not author:
|
||
author = frontmatter.get('author', '')
|
||
|
||
if not author:
|
||
author = self.config.get('default_author', '')
|
||
|
||
# 生成封面图(如果需要)
|
||
if not cover_image_path and generate_images and self.image_generator:
|
||
print("🎨 生成封面图片...")
|
||
try:
|
||
cover_prompt = f"文章封面: {title} - {author}"
|
||
cover_image_path = self.image_generator.generate_cover_image(cover_prompt)
|
||
print(f" ✅ 封面图片生成成功: {os.path.basename(cover_image_path)}")
|
||
except Exception as e:
|
||
print(f" ⚠️ 封面图片生成失败: {e}")
|
||
|
||
# 生成章节配图(如果需要)
|
||
if generate_images and self.image_generator:
|
||
print("🎨 生成章节配图...")
|
||
try:
|
||
body, generated_images = self.generate_images_for_markdown(body)
|
||
print(f" ✅ 生成 {len(generated_images)} 张章节配图")
|
||
except Exception as e:
|
||
print(f" ⚠️ 章节配图生成失败: {e}")
|
||
|
||
# 提取图片
|
||
print("🖼️ 提取图片...")
|
||
image_paths = self.extract_images_from_markdown(body)
|
||
|
||
if image_paths:
|
||
# 上传图片
|
||
image_map = self.upload_images(image_paths)
|
||
|
||
# 替换图片引用
|
||
print("🔄 替换图片引用...")
|
||
processed_body = self.replace_images_in_markdown(body, image_map)
|
||
else:
|
||
print("ℹ️ 未发现本地图片")
|
||
processed_body = body
|
||
|
||
# 转换为HTML
|
||
print("🔧 转换为HTML...")
|
||
html_content = self.markdown_to_html(processed_body)
|
||
|
||
# 提取摘要
|
||
print("📋 生成摘要...")
|
||
digest = self.markdown_parser.extract_summary(body)
|
||
print(f" 摘要: {digest[:50]}..." if len(digest) > 50 else f" 摘要: {digest}")
|
||
|
||
# 如果没有封面图,使用默认
|
||
if not cover_image_path:
|
||
default_cover = "/home/ubuntu/default_cover.png"
|
||
if os.path.exists(default_cover):
|
||
cover_image_path = default_cover
|
||
print(f"🖼️ 使用默认封面图: {os.path.basename(default_cover)}")
|
||
|
||
# 创建草稿
|
||
print("📤 创建公众号草稿...")
|
||
media_id = self.create_draft(title, html_content, author, digest, cover_image_path)
|
||
|
||
print("=" * 60)
|
||
print("✅ 发布成功!")
|
||
print("=" * 60)
|
||
|
||
return {
|
||
"success": True,
|
||
"media_id": media_id,
|
||
"title": title,
|
||
"author": author,
|
||
"digest": digest[:100] + "..." if len(digest) > 100 else digest,
|
||
"images_uploaded": len([v for v in image_map.values() if v]) if image_paths else 0,
|
||
"has_frontmatter": bool(frontmatter),
|
||
"images_generated": generate_images and self.image_generator is not None,
|
||
"message": "草稿创建成功"
|
||
}
|
||
|
||
def main():
|
||
"""命令行入口"""
|
||
parser = argparse.ArgumentParser(
|
||
description='微信公众号发布工具(图片生成增强版)',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
%(prog)s markdown article.md
|
||
%(prog)s markdown article.md --title "标题" --author "作者"
|
||
%(prog)s markdown article.md --generate-images # 自动生成配图
|
||
%(prog)s markdown article.md --cover /path/to/cover.jpg
|
||
%(prog)s test
|
||
"""
|
||
)
|
||
|
||
subparsers = parser.add_subparsers(dest='command', help='命令')
|
||
|
||
# markdown 命令
|
||
md_parser = subparsers.add_parser('markdown', help='发布Markdown文章')
|
||
md_parser.add_argument('file', help='Markdown文件路径')
|
||
md_parser.add_argument('--title', help='文章标题(覆盖frontmatter)')
|
||
md_parser.add_argument('--author', help='作者(覆盖frontmatter)')
|
||
md_parser.add_argument('--cover', help='封面图路径')
|
||
md_parser.add_argument('--generate-images', action='store_true',
|
||
help='自动为章节生成配图')
|
||
|
||
# test 命令
|
||
test_parser = subparsers.add_parser('test', help='测试环境和配置')
|
||
|
||
args = parser.parse_args()
|
||
|
||
if not args.command:
|
||
parser.print_help()
|
||
sys.exit(1)
|
||
|
||
publisher = WeChatPublisher()
|
||
|
||
try:
|
||
if args.command == 'markdown':
|
||
result = publisher.publish_markdown_with_images(
|
||
args.file, args.title, args.author, args.cover, args.generate_images
|
||
)
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
|
||
elif args.command == 'test':
|
||
print("🔧 测试环境和配置...")
|
||
print(f"Python版本: {sys.version.split()[0]}")
|
||
print(f"Pillow可用: {'✅' if HAS_PILLOW else '❌'}")
|
||
print(f"PyYAML可用: ✅")
|
||
print(f"图片生成可用: {'✅' if HAS_IMAGE_GEN else '❌'}")
|
||
|
||
# 测试配置
|
||
print(f"\n📋 配置信息:")
|
||
print(f" AppID: {publisher.config.get('app_id')}")
|
||
print(f" 作者: {publisher.config.get('default_author')}")
|
||
|
||
if HAS_IMAGE_GEN:
|
||
print(f" 图片生成API: {publisher.config.get('image_provider')}")
|
||
print(f" 图片模型: {publisher.config.get('image_model')}")
|
||
|
||
# 测试access_token
|
||
try:
|
||
token = publisher.get_access_token()
|
||
print(f" Access Token: {token[:20]}...")
|
||
print("✅ 配置正常")
|
||
except Exception as e:
|
||
print(f"❌ 配置错误: {e}")
|
||
|
||
# 测试图片生成
|
||
if HAS_IMAGE_GEN:
|
||
print(f"\n🎨 图片生成测试:")
|
||
try:
|
||
test_prompt = "一只可爱的橘猫在阳光下睡觉"
|
||
test_output = "/tmp/test_image_gen.jpg"
|
||
from toolkit.image_gen import generate_image
|
||
path = generate_image(test_prompt, test_output, size="cover")
|
||
print(f" ✅ 图片生成测试成功: {path}")
|
||
|
||
# 显示图片信息
|
||
if os.path.exists(path):
|
||
size = os.path.getsize(path) / 1024
|
||
print(f" 图片大小: {size:.1f}KB")
|
||
except Exception as e:
|
||
print(f" ❌ 图片生成测试失败: {e}")
|
||
|
||
else:
|
||
parser.print_help()
|
||
|
||
except Exception as e:
|
||
print(json.dumps({
|
||
"success": False,
|
||
"error": str(e)
|
||
}, ensure_ascii=False, indent=2))
|
||
sys.exit(1)
|
||
|
||
if __name__ == "__main__":
|
||
main() |