#!/usr/bin/env python3
"""
baoyu-post-to-wechat 优化版
阶段1优化:图片优化 + Frontmatter支持
"""
import os
import json
import re
import requests
import yaml
import base64
import tempfile
import mimetypes
from pathlib import Path
from typing import List, Dict, Optional, Union, Tuple
from datetime import datetime
# 尝试导入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 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
@staticmethod
def format_needs_conversion(image_path: str) -> bool:
"""检查图片格式是否需要转换"""
if not HAS_PILLOW:
return False
unsupported_formats = {'WEBP', 'GIF', 'BMP', 'TIFF', 'TIF', 'SVG', 'ICO'}
try:
with Image.open(image_path) as img:
return img.format in unsupported_formats
except:
return False
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 = 100) -> 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()
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']
# 设置默认值
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, upload_type: str = "material") -> dict:
"""上传图片到微信
Args:
image_path: 图片路径
upload_type: "material" 用于封面(返回media_id), "body" 用于正文(返回url)
"""
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'
# 根据类型选择接口
if upload_type == "body":
# 正文图片使用 uploadimg 接口,返回URL
url = f"https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token={access_token}"
else:
# 封面图片使用 add_material 接口,返回media_id
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 upload_type == "body":
# 正文图片返回url (转换为https)
if 'url' in data:
url = data['url']
# 确保使用https
if url.startswith('http://'):
url = 'https://' + url[7:]
return {'url': url}
else:
raise Exception(f"上传正文图片失败: {data}")
else:
# 封面图片返回media_id
if 'media_id' in data:
return {'media_id': data['media_id']}
else:
raise Exception(f"上传封面图片失败: {data}")
def upload_images(self, image_paths: List[str]) -> Dict[str, str]:
"""批量上传正文图片(返回URL)"""
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', '未知')
needs_convert = self.image_optimizer.format_needs_conversion(img_path)
status = "🔄 需要转换" if needs_convert else "✅ 格式正常"
print(f" {i}. {status} {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:
# 正文图片使用body类型,返回URL
resp = self.upload_image(img_path, upload_type="body")
results[img_path] = resp['url']
print(f" ✅ 上传成功: {os.path.basename(img_path)}")
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中的本地图片替换为微信图片URL"""
content = markdown_content
for img_path, img_url in image_map.items():
if img_url:
# 使用 uploadimg 接口返回的 URL
# 添加样式使其在微信中显示良好
wechat_img = f''
# 替换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'
\1', 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('\1', html, flags=re.MULTILINE) # 代码块(简化处理) html = re.sub(r'```.*?\n(.*?)\n```', r'
\1', html, flags=re.DOTALL)
# 链接
html = re.sub(r'\[(.*?)\]\((.*?)\)', r'\1', html)
# 段落处理
lines = html.split('\n')
in_paragraph = False
final_lines = []
for line in lines:
if line.strip() == '':
if in_paragraph:
final_lines.append('')
in_paragraph = False
final_lines.append('')
else:
if not in_paragraph and not line.startswith(('