793 lines
30 KiB
Python
Executable File
793 lines
30 KiB
Python
Executable File
#!/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'<img src="{img_url}" style="display: block; width: 100%; margin: 1.5em auto;" alt="图片">'
|
||
|
||
# 替换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}"
|
||
|
||
# 上传封面图(使用material接口)- 封面图是必须的
|
||
thumb_media_id = None
|
||
if cover_image_path and os.path.exists(cover_image_path):
|
||
print(f"🖼️ 上传封面图: {os.path.basename(cover_image_path)}")
|
||
cover_resp = self.upload_image(cover_image_path, upload_type="material")
|
||
thumb_media_id = cover_resp.get('media_id')
|
||
|
||
# 如果没有提供封面图或上传失败,使用默认封面图
|
||
if not thumb_media_id:
|
||
default_cover = "/home/ubuntu/oh-my-writing-skill/example.png"
|
||
if os.path.exists(default_cover):
|
||
print(f"🖼️ 使用默认封面图: {os.path.basename(default_cover)}")
|
||
cover_resp = self.upload_image(default_cover, upload_type="material")
|
||
thumb_media_id = cover_resp.get('media_id')
|
||
|
||
# 如果没有提供作者,使用默认作者
|
||
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)
|
||
|
||
# 确保摘要长度不超过100字符(微信限制)
|
||
if digest and len(digest) > 100:
|
||
digest = digest[:97] + "..."
|
||
|
||
# 构建文章数据
|
||
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 submit_draft_for_publish(self, media_id: str) -> bool:
|
||
"""将草稿提交发布"""
|
||
access_token = self.get_access_token()
|
||
url = f"https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token={access_token}"
|
||
body = {"media_id": media_id}
|
||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||
response = requests.post(url, data=json.dumps(body), headers=headers)
|
||
data = response.json()
|
||
if data.get('errcode') == 0:
|
||
print(f"✅ 发布提交成功,发布任务ID: {data.get('publish_id')}")
|
||
return True
|
||
else:
|
||
print(f"⚠️ 发布提交失败: {data}")
|
||
return False
|
||
|
||
def publish_markdown_with_images(self, markdown_path: str, title: str = None,
|
||
author: str = None, cover_image_path: str = None) -> Dict:
|
||
"""发布Markdown文章(支持图文混排 + Frontmatter)"""
|
||
print("=" * 60)
|
||
print("📝 baoyu-post-to-wechat 优化版 v2.0")
|
||
print("=" * 60)
|
||
|
||
# 检查文件
|
||
if not os.path.exists(markdown_path):
|
||
raise FileNotFoundError(f"Markdown文件不存在: {markdown_path}")
|
||
|
||
# ====== 博客同步(独立支线,不影响公众号发布)======
|
||
try:
|
||
from blog_sync import sync_markdown_to_blog
|
||
sync_markdown_to_blog(markdown_path, title, author)
|
||
# 博客同步成功/失败都不影响后续流程
|
||
except Exception as e:
|
||
print(f"⚠️ 博客同步异常(继续发布公众号): {e}")
|
||
# ==================================================
|
||
|
||
# 读取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', '')
|
||
|
||
# 提取图片
|
||
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)}")
|
||
else:
|
||
print("ℹ️ 未提供封面图,使用微信公众号默认封面")
|
||
|
||
# 创建草稿
|
||
print("📤 创建公众号草稿...")
|
||
media_id = self.create_draft(title, html_content, author, digest, cover_image_path)
|
||
|
||
# 提交发布(从草稿箱发布)
|
||
print("📤 提交公众号发布...")
|
||
try:
|
||
publish_result = self.submit_draft_for_publish(media_id)
|
||
publish_msg = "发布成功" if publish_result else "草稿创建成功(请手动发布)"
|
||
except Exception as e:
|
||
print(f"⚠️ 自动发布失败: {e}")
|
||
print("ℹ️ 请在微信公众号后台手动发布该草稿")
|
||
publish_msg = "草稿创建成功(请手动发布)"
|
||
|
||
print("=" * 60)
|
||
print(f"✅ {publish_msg}!")
|
||
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),
|
||
"message": publish_msg
|
||
}
|
||
|
||
def publish_html(self, html_path: str, title: str = None, author: str = None,
|
||
cover_image_path: str = None) -> Dict:
|
||
"""发布HTML文章"""
|
||
print("📝 开始发布HTML文章...")
|
||
|
||
if not os.path.exists(html_path):
|
||
raise FileNotFoundError(f"HTML文件不存在: {html_path}")
|
||
|
||
# 读取HTML内容
|
||
with open(html_path, 'r', encoding='utf-8') as f:
|
||
html_content = f.read()
|
||
|
||
print(f"📄 读取文件: {os.path.basename(html_path)} ({len(html_content)} 字符)")
|
||
|
||
# 如果没有标题,使用文件名
|
||
if not title:
|
||
title = Path(html_path).stem
|
||
|
||
# 创建草稿
|
||
print("📤 创建公众号草稿...")
|
||
media_id = self.create_draft(title, html_content, author, None, cover_image_path)
|
||
|
||
print("✅ 发布成功!")
|
||
|
||
return {
|
||
"success": True,
|
||
"media_id": media_id,
|
||
"title": title,
|
||
"author": author or self.config.get('default_author', ''),
|
||
"message": "草稿创建成功"
|
||
}
|
||
|
||
def main():
|
||
"""命令行入口"""
|
||
import sys
|
||
import argparse
|
||
|
||
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 --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='封面图路径')
|
||
|
||
# html 命令
|
||
html_parser = subparsers.add_parser('html', help='发布HTML文章')
|
||
html_parser.add_argument('file', help='HTML文件路径')
|
||
html_parser.add_argument('--title', help='文章标题')
|
||
html_parser.add_argument('--author', help='作者')
|
||
html_parser.add_argument('--cover', 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
|
||
)
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
|
||
elif args.command == 'html':
|
||
result = publisher.publish_html(
|
||
args.file, args.title, args.author, args.cover
|
||
)
|
||
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"\n📋 配置信息:")
|
||
print(f" AppID: {publisher.config.get('app_id')}")
|
||
print(f" 作者: {publisher.config.get('default_author')}")
|
||
|
||
# 测试access_token
|
||
try:
|
||
token = publisher.get_access_token()
|
||
print(f" Access Token: {token[:20]}...")
|
||
print("✅ 配置正常")
|
||
except Exception as e:
|
||
print(f"❌ 配置错误: {e}")
|
||
|
||
# 测试图片优化
|
||
if HAS_PILLOW:
|
||
print(f"\n🖼️ 图片优化测试:")
|
||
test_image = "/home/ubuntu/default_cover.png"
|
||
if os.path.exists(test_image):
|
||
info = publisher.image_optimizer.get_image_info(test_image)
|
||
print(f" 测试图片: {info.get('filename')}")
|
||
print(f" 格式: {info.get('format', '未知')}")
|
||
print(f" 尺寸: {info.get('width', 0)}x{info.get('height', 0)}")
|
||
print(f" 大小: {info.get('size_kb', 0):.1f}KB")
|
||
else:
|
||
print(" 测试图片不存在")
|
||
|
||
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() |