#!/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图片语法: ![alt](path) 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, flags=re.MULTILINE) html = re.sub(r'^## (.*?)$', r'

\1

', html, flags=re.MULTILINE) html = re.sub(r'^### (.*?)$', r'

\1

', html, flags=re.MULTILINE) html = re.sub(r'^#### (.*?)$', r'

\1

', html, flags=re.MULTILINE) # 粗体和斜体 html = re.sub(r'\*\*(.*?)\*\*', r'\1', html) html = re.sub(r'\*(.*?)\*', r'\1', html) # 删除线 html = re.sub(r'~~(.*?)~~', r'\1', html) # 内联代码 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('' if '' if '