#!/usr/bin/env python3 """ CMS 改写 → 公众号发布 一键流水线 两种模式: 1. 自动改写模式 (默认): python pipeline.py 自己调 DeepSeek LLM 改写,然后发布 2. 传入改写内容模式: python pipeline.py --content-file 用预改写好的内容直接发布(志玲模式) 环境: 依赖 openai 库: pip install openai API Key 从 ~/.nanobot/config.json 读取 DeepSeek 配置 """ import sys import os import re import json import time import random import argparse import yaml from typing import Optional, Dict, Any # 确保脚本在 rewrite/ 目录执行也能正常工作 SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.dirname(SCRIPT_DIR) AUTO_CONFIG_PATH = os.path.join(SCRIPT_DIR, "auto_config.yaml") BAOYU_DIR = "/home/ubuntu/.nanobot/workspace/skills/baoyu-post-to-wechat" sys.path.insert(0, BAOYU_DIR) sys.path.insert(0, SCRIPT_DIR) from cms_ops import get_article_full, update_article_content from baoyu_optimized import WeChatPublisher # ---------- 配置 ---------- MODEL = "deepseek-v4-flash" NANOBOT_CONFIG = os.path.expanduser("~/.nanobot/config.json") SYSTEM_PROMPT = """你是一个专业的医疗健康内容编辑。请将下面的文章改写为适合微信公众号发布的版本。 改写要求: 1. **第一行必须是标题**: 以 `# ` 开头。⚠️ **必须重新创作标题,严禁照搬原标题原句**。可以换表述、调语序、换措辞,但必须保持原文核心含义不变(不曲解、不偏离主旨)。标题中禁止使用任何emoji表情符号 2. **保持医疗准确性**: 不得改变任何医学事实、数据、诊断标准、用药建议 3. **风格口语化**: 用亲近自然的语气,像医生朋友在聊天。正文适当使用emoji(每段不超过1个),但标题禁止emoji 4. **保留结构**: 保留原文的章节标题和信息层次,但标题可以重新提炼得更吸引人 5. **精简优化**: 去除重复表述,合并相近内容,让文章更紧凑 6. **清理无关内容**: 去掉原文作者署名、转载来源、第三方推广链接(如"相关阅读"、"更多精彩内容"、"推荐阅读"、"参考文献"等板块) 7. **适合手机阅读**: 段落短小(2-3句一段),善用小标题和列表 8. **只输出改写后的 markdown**,不要加任何解释说明 输出格式: - 第一行: `# 改写后的标题` - 正文: 纯 markdown,可以包含 **粗体**、无序列表 -、## 二级标题、段间空行分隔""" # ---------- 图片生成配置 ---------- IMAGE_GEN_ENABLED = True # 是否启用配图生成 IMAGE_GEN_COUNT = 1 # 目标生成图片数量 IMAGE_STORAGE_DIR = os.path.join(PROJECT_ROOT, "storage", "admin", "images") BANNERS_DIR = os.path.join(PROJECT_ROOT, "storage", "admin", "images", "banners") ADS_IMAGE = os.path.join(PROJECT_ROOT, "storage", "admin", "images", "ads", "0.jpg") IMAGE_PROMPT_SYSTEM = """你是一个图片提示词专家。根据下面给出的文章片段,生成一个适合配图的简短中文描述(用于AI生图),要求: 1. 描述要简洁具体,20-40个字 2. 风格要求:温暖、专业、治愈的视觉风格 3. 内容要与上下文紧密相关 4. 只输出描述文字,不要加任何解释或前缀""" # 混元生图 (TokenHub MAS API) HUNYUAN_API_KEY = "sk-qgPNJwuOGji7I7ixqNbbkWaTIvF1MrYTRWQnEZ3ZRVU4u7y3" HUNYUAN_API_BASE = "https://tokenhub.tencentmaas.com/v1/api/image" HUNYUAN_MODEL = "hy-image-v3.0" # Seedream(降级) SEEDREAM_API_KEY = "83a29955-c633-449f-97f1-f49b62295cf6" SEEDREAM_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" SEEDREAM_MODEL = "doubao-seedream-4-5-251128" # ---------- 工具函数 ---------- def load_deepseek_key() -> str: """从 nanobot 配置读取 DeepSeek API Key""" try: with open(NANOBOT_CONFIG) as f: config = json.load(f) key = config["providers"]["deepseek"]["apiKey"] if not key: raise ValueError("DeepSeek API key 为空") return key except Exception as e: print(f"❌ 读取 DeepSeek API Key 失败: {e}", file=sys.stderr) print(f" 配置文件: {NANOBOT_CONFIG}", file=sys.stderr) sys.exit(1) def call_deepseek(markdown: str, api_key: str) -> str: """调用 DeepSeek API 改写文章""" try: from openai import OpenAI except ImportError: print("❌ 需要安装 openai: pip install openai", file=sys.stderr) sys.exit(1) client = OpenAI(api_key=api_key, base_url="https://api.deepseek.com") print("⏳ 调 DeepSeek 改写中...", file=sys.stderr) response = client.chat.completions.create( model=MODEL, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": markdown}, ], temperature=0.7, max_tokens=4096, ) result = response.choices[0].message.content.strip() tokens = response.usage.total_tokens if response.usage else "?" print(f" 消耗 {tokens} tokens", file=sys.stderr) return result def resolve_cover_path(cover_url: Optional[str]) -> Optional[str]: """将 CMS 封面 URL 转为本地绝对路径""" if not cover_url: return None # cover_url 格式: /storage/admin/images/202605/xxx.jpg path = os.path.join(PROJECT_ROOT, "..", cover_url.lstrip("/")) # 某些情况下 cover_url 可能不带前导 / alt_path = os.path.join(PROJECT_ROOT, "storage", "admin", "images", *cover_url.split("/")[-2:]) for p in [path, alt_path]: if os.path.exists(p): return os.path.normpath(p) print(f"⚠️ 封面图未找到: {cover_url}", file=sys.stderr) return None # ---------- 标题提取 ---------- def _extract_title(markdown: str) -> tuple[str, str]: """从 markdown 首行提取 `# 标题`,返回 (标题, 去除标题后的正文)""" lines = markdown.split("\n") for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith("# ") and not stripped.startswith("## "): title = stripped[2:].strip() # 去掉标题行,保留后续内容 remaining = "\n".join(lines[i + 1:]).strip() return title, remaining # 没有找到标题,返回空标题 + 原文 return "", markdown # ---------- emoji 清理 ---------- def _strip_emoji(text: str) -> str: """移除文本中的 emoji 表情符号,用于清理标题。 策略:先移除非 BMP 字符(覆盖绝大多数现代 emoji), 再清理少量 BMP 区 emoji 符号。 保留 ⚠️、①②、℃ 等实用符号不被误删。 """ # 1. 移除非 BMP 字符(Supplementary Plane,覆盖 😀🚀🎯 等几乎全部多彩 emoji) text = re.sub(r'[^\u0000-\uFFFF]+', '', text) # 2. 移除 BMP 区的 emoji/装饰符号 emoji_pattern = re.compile( "[" "\U00002702-\U000027B0" # Dingbats (✂-➰) "\U00002194-\U00002199" # Arrows (↔↕↖↗↘↙) "\U00002B05-\U00002B07" # More arrows (⬅⬆⬇) "\U00002934-\U00002935" # Curved arrows (⤴⤵) "\U000025AA-\U000025AB" # Small squares (▪▫) "\U000025B6" # Play ▶ "\U000025C0" # Reverse ◀ "\U000025FB-\U000025FE" # Medium/white squares (◻◼◽◾) "\U00002614-\U00002615" # Umbrella/Rain (☔☕) "\U00002648-\U00002653" # Zodiac etc (♈-♓) "\U000026AA-\U000026AB" # Circles (⚪⚫) "\U000026BD-\U000026BE" # Sports (⚽⚾) "\U000026C4-\U000026C5" # Snowman/No clouds (⛄⛅) "\U000026CE" # Ophiuchus (⛎) "\U000026D4" # No entry (⛔) "\U000026EA" # Church (⛪) "\U000026F2-\U000026F3" # Fountain/Flag (⛲⛳) "\U000026F5" # Sailboat (⛵) "\U000026FA" # Tent (⛺) "\U000026FD" # Fuel pump (⛽) "\U00002714" # Checkmark (✔) "\U00002716" # Cross (✖) "\U00002728" # Sparkles (✨) "\U00002733-\U00002734" # Star-tip (✳✴) "\U00002744-\U00002745" # Snowflake (❄❅) "\U00002747" # Sparkle (❇) "\U00002757" # Cross mark (❌) "\U00002763-\U00002764" # Heart (❣❤) "\U00002795-\U00002797" # Plus/Minus/Divide (➕➖➗) "\U00002B1B-\U00002B1C" # Large squares (⬛⬜) "\U00002B50" # Star ⭐ "\U00002B55" # Circle ⭕ "\U00003030" # Wavy dash 〰 "\U0000303D" # Part alternation mark 〽 "\U00003297" # Congrats ㊗ "\U00003299" # Secret ㊙ "\U0000FE00-\U0000FE0F" # Variation Selectors "\U0000200D" # ZWJ "]+", flags=re.UNICODE) return emoji_pattern.sub('', text).strip() # ---------- 封面图 ---------- def _random_banner() -> Optional[str]: """从 banners 目录随机选一张图片,返回绝对路径""" if not os.path.isdir(BANNERS_DIR): return None images = [f for f in os.listdir(BANNERS_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] if not images: return None chosen = random.choice(images) path = os.path.join(BANNERS_DIR, chosen) print(f" 🎲 随机封面: {chosen}", file=sys.stderr) return path # ---------- 内容清理 ---------- def _clean_content(markdown: str) -> str: """清理第三方链接、作者署名等无关内容""" lines = markdown.split("\n") cleaned = [] skip_until_section_end = False for line in lines: stripped = line.strip().lower() # 检测需要跳过的板块标题 if re.match(r'^#{1,3}\s*(相关阅读|更多精彩|推荐阅读|延伸阅读|参考文献|参考资料|热门推荐|往期回顾|猜你喜欢|广告)', stripped): skip_until_section_end = True continue # 板块结束(遇到新标题 或 空行不再累加) if skip_until_section_end: if stripped.startswith("#") and not re.match(r'^#{1,3}\s*(相关阅读|更多精彩|推荐阅读|延伸阅读|参考文献|参考资料|热门推荐|往期回顾|猜你喜欢|广告)', stripped): skip_until_section_end = False cleaned.append(line) continue # 跳过带来源/作者标记的行(行首) if re.match(r'^(原文作者|作者[::]|来源[::]|转载自|文[//]\s*|编辑[::]|排版[::]|审核[::])', stripped): continue # 跳过含作者+格式的元数据行(如 "尿酸痛风 作者: 刘松 格式: Markdown") if re.search(r'作者[::]\s*\S+', stripped) and re.search(r'格式[::]', stripped): continue # 跳过独立的格式声明行 if re.match(r'^格式[::]\s*(Markdown|HTML|TXT|TEXT)', stripped): continue # 跳过明显的第三方链接(纯 URL 行) if re.match(r'^https?://', stripped) and 'mp.weixin' not in stripped and 'niaodaifu' not in stripped: continue cleaned.append(line) return "\n".join(cleaned) # ---------- 内容质量检查 ---------- # LLM 占位回复检测模式(当原文无文字时 DeepSeek 会返回这类消息) _LLM_PLACEHOLDER_PATTERNS = [ r'原文内容(全部|仅|只).*(图片|链接|无法显示)', r'没有可改写的文本', r'请提供.*文字', r'请.*提供.*文章.*内容', r'一旦获得文字内容', r'您好,您提供的.*(图片|链接)', r'为了生成适合', r'无法生成适合', r'请确保文章包含文字', ] _MIN_TEXT_LENGTH = 200 # 原文纯文本低于此值视为无实质内容 def _strip_images(markdown: str) -> str: """移除 markdown 图片语法,返回纯文本""" return re.sub(r'!\[.*?\]\([^)]+\)', '', markdown) def _check_original_content(content: str) -> int: """ 检查原文是否有足够的文字内容。 返回纯文本字符数;低于 _MIN_TEXT_LENGTH 抛出 ValueError。 """ text = _strip_images(content) # 也移除 markdown 链接、HTML 标签等 text = re.sub(r'\[([^\]]*)\]\([^)]+\)', r'\1', text) # 链接文字保留 text = re.sub(r'<[^>]+>', '', text) # HTML 标签 text = re.sub(r'\s+', ' ', text).strip() text_len = len(text) if text_len < _MIN_TEXT_LENGTH: raise ValueError( f"原文文字内容不足({text_len} 字符 < {_MIN_TEXT_LENGTH})," f"可能全部为图片,跳过改写" ) return text_len def _check_llm_response(content: str) -> None: """ 检查 LLM 返回是否是无意义的占位回复。 如果是占位回复则抛出 ValueError。 """ for pattern in _LLM_PLACEHOLDER_PATTERNS: if re.search(pattern, content): raise ValueError( f"LLM 返回疑似占位回复(匹配模式: {pattern}),不发布" ) # 额外检查:改写后内容过短(< 200 字)也视为无效 if len(content) < _MIN_TEXT_LENGTH: raise ValueError( f"改写后内容过短({len(content)} 字符 < {_MIN_TEXT_LENGTH}),不发布" ) # ---------- 正文末尾广告图 ---------- def _append_ads_image(markdown: str) -> str: """在文章末尾添加固定广告图""" if not os.path.exists(ADS_IMAGE): print(f" ⚠️ 广告图不存在: {ADS_IMAGE}", file=sys.stderr) return markdown web_path = "/storage/admin/images/ads/0.jpg" print(f" 📢 追加广告图: {web_path}", file=sys.stderr) return markdown.rstrip() + f"\n\n![广告]({web_path})\n" # ---------- 图片生成 ---------- def _make_image_prompt(context_text: str, api_key: str) -> str: """用 LLM 从文章片段生成图片 prompt""" try: from openai import OpenAI except ImportError: print("⚠️ openai 未安装,使用默认图片 prompt", file=sys.stderr) return "温暖的医疗健康主题插图,简洁专业的扁平设计风格" client = OpenAI(api_key=api_key, base_url="https://api.deepseek.com") try: response = client.chat.completions.create( model=MODEL, messages=[ {"role": "system", "content": IMAGE_PROMPT_SYSTEM}, {"role": "user", "content": context_text}, ], temperature=0.3, max_tokens=200, ) prompt = response.choices[0].message.content.strip() print(f" 🎨 图片 prompt: {prompt}", file=sys.stderr) return prompt except Exception as e: print(f" ⚠️ 图片 prompt 生成失败: {e}", file=sys.stderr) return "温暖的医疗健康主题插图,简洁专业的扁平设计风格" def _generate_image(prompt: str, output_path: str, resolution: str = "1024:1024") -> bool: """调用混元生图生成图片,失败则降级到 Seedream""" success = _hunyuan_image(prompt, output_path, resolution) if success: return True print(" ↪ 降级到 Seedream...", file=sys.stderr) return _seedream_image(prompt, output_path, resolution) def _hunyuan_image(prompt: str, output_path: str, resolution: str = "1024:1024") -> bool: """腾讯混元生图 hy-image-v3.0 (TokenHub MAS API) 异步提交流程: submit → poll query → download """ import requests if not HUNYUAN_API_KEY: return False try: # Step 1: 提交生图任务 submit_url = f"{HUNYUAN_API_BASE}/submit" submit_payload = { "model": HUNYUAN_MODEL, "prompt": prompt, "logo_add": 0, # 不添加"AI生成"水印 } # 可选参数:尺寸 parts = resolution.replace(":", "x").split("x") if len(parts) == 2: try: w, h = int(parts[0]), int(parts[1]) submit_payload["width"] = w submit_payload["height"] = h except ValueError: pass headers = { "Authorization": f"Bearer {HUNYUAN_API_KEY}", "Content-Type": "application/json", } resp = requests.post(submit_url, json=submit_payload, headers=headers, timeout=30) resp.raise_for_status() result = resp.json() task_id = result.get("id") if not task_id: print(f" ⚠️ 混元提交失败: {result}", file=sys.stderr) return False print(f" 📤 混元任务已提交: {task_id}", file=sys.stderr) # Step 2: 轮询查询结果 query_url = f"{HUNYUAN_API_BASE}/query" query_payload = {"model": HUNYUAN_MODEL, "id": task_id} for _ in range(30): # 最多等 150 秒 time.sleep(5) qr = requests.post(query_url, json=query_payload, headers=headers, timeout=30) qr.raise_for_status() qresult = qr.json() status = qresult.get("status", "") if status == "completed": # 提取图片 URL (Tencent MAS 返回 data 数组) data = qresult.get("data", []) if not data: print(f" ⚠️ 混元无图片: {qresult}", file=sys.stderr) return False img_obj = data[0] img_url = img_obj.get("url", "") img_b64 = img_obj.get("b64_json", "") if img_url: r = requests.get(img_url, timeout=60) r.raise_for_status() img_data = r.content elif img_b64: import base64 img_data = base64.b64decode(img_b64) else: print(f" ⚠️ 混元无可用图片格式: {img_obj}", file=sys.stderr) return False os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) with open(output_path, "wb") as f: f.write(img_data) print(f" ✅ 混元生图: {os.path.basename(output_path)} ({len(img_data)} bytes)", file=sys.stderr) return True elif status in ("failed", "cancelled"): print(f" ⚠️ 混元任务失败: status={status}, {qresult}", file=sys.stderr) return False # 其他状态 (processing, queued, etc.) — 继续等待 print(f" ⏳ 混元: {status}", file=sys.stderr) print(f" ⚠️ 混元超时 (150s)", file=sys.stderr) return False except Exception as e: print(f" ⚠️ 混元异常: {e}", file=sys.stderr) return False def _seedream_image(prompt: str, output_path: str, resolution: str = "1024:1024") -> bool: """Seedream (火山引擎 ARK)""" import requests # Seedream 要求 ≥ 3,686,400 像素,自动放大 parts = resolution.replace(":", "x").split("x") if len(parts) == 2: try: w, h = int(parts[0]), int(parts[1]) if w * h < 3686400: scale = (3686400 / (w * h)) ** 0.5 w2, h2 = (int(w * scale) // 16) * 16, (int(h * scale) // 16) * 16 resolution = f"{w2}x{h2}" except ValueError: pass try: url = f"{SEEDREAM_BASE_URL}/images/generations" resp = requests.post( url, headers={ "Authorization": f"Bearer {SEEDREAM_API_KEY}", "Content-Type": "application/json", }, json={ "model": SEEDREAM_MODEL, "prompt": prompt, "size": resolution, "n": 1, }, timeout=120, ) resp.raise_for_status() data = resp.json() image_url = data["data"][0].get("url") if not image_url: return False r = requests.get(image_url, timeout=60) r.raise_for_status() os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) with open(output_path, "wb") as f: f.write(r.content) print(f" ✅ Seedream: {os.path.basename(output_path)} ({len(r.content)} bytes)", file=sys.stderr) return True except Exception as e: print(f" ⚠️ Seedream: {e}", file=sys.stderr) return False def _remove_watermark(image_path: str) -> None: """去除混元生图右下角'AI生成'水印:OpenCV修复 + 背景覆盖 + 官网logo叠加""" try: import cv2 import numpy as np from PIL import Image, ImageDraw LOGO_PATH = os.path.join( PROJECT_ROOT, "storage/admin/images/setting/20260506_100405_89971b62.png", ) if not os.path.exists(LOGO_PATH): return img_cv = cv2.imread(image_path) if img_cv is None: return h, w = img_cv.shape[:2] # ---- 第1步:检测水印区域并修复 ---- # 右下角扫描: 30%宽 × 25%高(扩大扫描范围以捕获完整水印) scan_w = int(w * 0.30) scan_h = int(h * 0.25) corner = img_cv[h - scan_h : h, w - scan_w : w] # 转灰度,用大核高斯模糊做背景估计,diff 找出局部异常亮区 gray = cv2.cvtColor(corner, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (51, 51), 0) diff = cv2.subtract(gray, blurred) # 水印文字比周围亮,自适应阈值 diff_max = diff.max() threshold = max(8, int(diff_max * 0.35)) _, wm_mask = cv2.threshold(diff, threshold, 255, cv2.THRESH_BINARY) # 膨胀连接文字笔画,扩大修复区域 kernel = np.ones((5, 5), np.uint8) wm_mask = cv2.dilate(wm_mask, kernel, iterations=2) # 补充右下角最小覆盖区(兜底:混元水印始终在右下角) fixed_mask = np.zeros((scan_h, scan_w), dtype=np.uint8) fixed_mask[int(scan_h * 0.4):, int(scan_w * 0.6):] = 255 wm_mask = cv2.bitwise_or(wm_mask, fixed_mask) wm_pixels = wm_mask.sum() // 255 # 记录水印在扫描区中的边界框(全图坐标) ys, xs = np.where(wm_mask > 0) wm_left = w - scan_w + xs.min() wm_top = h - scan_h + ys.min() wm_right = w - scan_w + xs.max() wm_bottom = h - scan_h + ys.max() if wm_pixels > 200: # 扩展到全图做修复(加大 inpaint 半径) full_mask = np.zeros((h, w), dtype=np.uint8) full_mask[h - scan_h : h, w - scan_w : w] = wm_mask img_cv = cv2.inpaint(img_cv, full_mask, 5, cv2.INPAINT_TELEA) print(f" 🎨 水印修复完成 ({wm_pixels} 像素)", file=sys.stderr) else: # 未检测到水印时,使用默认右下角区域 wm_left = int(w * 0.70) wm_top = int(h * 0.75) wm_right = w wm_bottom = h print(f" ℹ️ 未检测到明确水印,使用默认右下角覆盖", file=sys.stderr) cv2.imwrite(image_path, img_cv, [cv2.IMWRITE_JPEG_QUALITY, 95]) # ---- 第2步:背景覆盖 + 叠加官网logo ---- img_pil = Image.open(image_path).convert("RGBA") logo = Image.open(LOGO_PATH).convert("RGBA") # 计算覆盖区域:从水印左上角延伸到图片右下角,加 padding padding = int(min(w, h) * 0.015) patch_left = max(0, wm_left - padding) patch_top = max(0, wm_top - padding) patch_right = w # 延伸到右边缘 patch_bottom = h # 延伸到底边缘 patch_w = patch_right - patch_left patch_h = patch_bottom - patch_top # 创建白色圆角背景(高不透明度确保完全覆盖水印) patch = Image.new("RGBA", (patch_w, patch_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(patch) radius = max(10, int(min(patch_w, patch_h) * 0.04)) draw.rounded_rectangle( [(0, 0), (patch_w - 1, patch_h - 1)], radius=radius, fill=(255, 255, 255, 245), ) img_pil.paste(patch, (patch_left, patch_top), patch) # 官网logo大小:按 patch 短边的 75% 等比缩放 logo_h = max(int(min(patch_w, patch_h) * 0.75), 100) logo_w = int(logo_h * logo.size[0] / logo.size[1]) logo_r = logo.resize((logo_w, logo_h), Image.LANCZOS) # Logo 居中放置在覆盖区域 logo_x = patch_left + (patch_w - logo_w) // 2 logo_y = patch_top + (patch_h - logo_h) // 2 img_pil.paste(logo_r, (logo_x, logo_y), logo_r) img_pil = img_pil.convert("RGB") img_pil.save(image_path, quality=95) print( f" 🏷️ 官网logo叠加完成 (覆盖{scan_w}x{scan_h}→{patch_w}x{patch_h}, " f"logo:{logo_w}x{logo_h})", file=sys.stderr, ) except Exception as e: logger.error(f"去水印失败 ({image_path}): {e}") print(f" ⚠️ 去水印失败: {e}", file=sys.stderr) def _find_image_spots(markdown: str, count: int = 2) -> list: """在 markdown 中找适合插入图片的位置,返回 [(插入点索引, 上下文), ...]""" lines = markdown.split("\n") candidates = [] # 找段落间的空行位置作为候选 for i in range(1, len(lines) - 1): # 空行 + 下一行有内容 → 段落边界 if lines[i].strip() == "" and i + 1 < len(lines) and lines[i + 1].strip(): # 确保不在标题前插入(标题通常以 ## 开头) if not lines[i + 1].strip().startswith("#"): # 取前后各 3 行作为上下文 ctx_start = max(0, i - 3) ctx_end = min(len(lines), i + 4) context = "\n".join(lines[ctx_start:ctx_end]) candidates.append((i + 1, context)) # 在空行后插入 # 均匀选取 count 个点 if not candidates: return [] if len(candidates) <= count: return candidates # 均匀分布:从中间选取 step = len(candidates) // count selected = [candidates[step * j] for j in range(count)] return selected def _insert_images(markdown: str, api_key: str) -> tuple[str, list[str]]: """分析 markdown、生成配图并插入,返回 (新 markdown, 生成的图片 web_path 列表)""" if not IMAGE_GEN_ENABLED: return markdown, [] spots = _find_image_spots(markdown, IMAGE_GEN_COUNT) if not spots: print(" ℹ️ 未找到合适的图片插入位置", file=sys.stderr) return markdown, [] print(f" 🖼️ 计划插入 {len(spots)} 张配图", file=sys.stderr) lines = markdown.split("\n") generated: list[tuple[int, str]] = [] # (line_index, image_path) for idx, (line_idx, context) in enumerate(spots): # 生成图片 prompt img_prompt = _make_image_prompt(context, api_key) # 输出路径:storage/admin/images/YYYYMM/ month_dir = time.strftime("%Y%m") filename = f"ai_gen_{int(time.time())}_{idx}.jpg" output_dir = os.path.join(IMAGE_STORAGE_DIR, month_dir) output_path = os.path.join(output_dir, filename) ok = _generate_image(img_prompt, output_path) if ok: # logo_add=0 已消除水印,无需后处理覆盖 # _remove_watermark(output_path) # 相对路径用于 markdown web_path = f"/storage/admin/images/{month_dir}/{filename}" generated.append((line_idx, web_path)) else: print(f" ❌ 图片 {idx+1} 生成失败,跳过", file=sys.stderr) if not generated: return markdown, [] # 从后往前插入(避免索引偏移) for line_idx, web_path in sorted(generated, key=lambda x: x[0], reverse=True): img_md = f"\n![配图]({web_path})\n" lines.insert(line_idx, img_md) # 提取生成的图片路径列表(按插入顺序) generated_paths = [web_path for _, web_path in sorted(generated, key=lambda x: x[0])] return "\n".join(lines), generated_paths # ---------- 微信图片上传 ---------- def _upload_html_images(html: str, publisher) -> str: """找出 HTML 中以 /storage/ 开头的本地图片,上传到微信并替换为 CDN 地址""" import re from html import unescape # 匹配 标签,提取 src 和整个标签 img_pattern = re.compile(r']+src=["\']([^"\']+)["\'][^>]*>', re.IGNORECASE) storage_base = PROJECT_ROOT def upload_and_replace(match): full_tag = match.group(0) src = match.group(1) # 只处理以 /storage/ 开头的本地路径 if not src.startswith("/storage/"): return full_tag local_path = os.path.normpath(os.path.join(storage_base, src.lstrip("/"))) if not os.path.exists(local_path): print(f" ⚠️ 图片不存在: {local_path}", file=sys.stderr) return full_tag try: resp = publisher.upload_image(local_path, upload_type="body") wechat_url = resp.get("url", "") if wechat_url: print(f" ✅ 已上传到微信: {os.path.basename(src)}", file=sys.stderr) # 替换整个 img 标签,只改 src return full_tag.replace(src, wechat_url) else: print(f" ❌ 微信未返回URL: {os.path.basename(src)}", file=sys.stderr) return full_tag except Exception as e: print(f" ❌ 上传失败 {os.path.basename(src)}: {e}", file=sys.stderr) return full_tag result = img_pattern.sub(upload_and_replace, html) return result # ---------- 核心流程 ---------- def run_pipeline( article_id: int, rewritten_content: Optional[str] = None, ) -> Dict[str, Any]: """ 一键改写→发布流程。 Args: article_id: CMS 文章 ID rewritten_content: 预改写好的 markdown。None 则自动调 LLM Returns: {"article_id": ..., "title": ..., "media_id": ..., "db_updated": bool} """ # 1. 查文章 article = get_article_full(article_id) if not article: raise ValueError(f"文章 ID={article_id} 不存在") title = article["title"] author = "尿大夫" # 改写后统一使用尿大夫署名 digest = article.get("summary") or "" original = article["content"] print(f"📄 文章: ID={article_id} | {title}", file=sys.stderr) print(f" 原文字数: {len(original)} chars", file=sys.stderr) # 2. 改写(两种模式) api_key = None # will be set in auto mode if rewritten_content: print(" 模式: 手动改写(跳过 LLM)", file=sys.stderr) new_content = rewritten_content else: # 自动改写前先检查原文内容质量 text_len = _check_original_content(original) print(f" 原文有效文字: {text_len} chars", file=sys.stderr) print(" 模式: 自动改写(DeepSeek)", file=sys.stderr) api_key = load_deepseek_key() new_content = call_deepseek(original, api_key) # 检查 LLM 返回是否有效 _check_llm_response(new_content) print(f" 改写后字数: {len(new_content)} chars", file=sys.stderr) # 2.1 提取改写后的标题(# 标题)+ 去emoji rewritten_title, new_content = _extract_title(new_content) if rewritten_title: print(f" ✏️ 改写标题: {rewritten_title}", file=sys.stderr) title = _strip_emoji(rewritten_title) if title != rewritten_title: print(f" 🧹 去emoji后: {title}", file=sys.stderr) else: print(f" ℹ️ 未检测到改写标题,保留原标题", file=sys.stderr) # 兜底:原标题也去emoji title = _strip_emoji(title) # 2.2 清理第三方链接/作者署名 new_content = _clean_content(new_content) print(f" 清理后字数: {len(new_content)} chars", file=sys.stderr) # 2.3 生成配图并插入 print("🎨 生成配图...", file=sys.stderr) if rewritten_content: new_content, gen_image_paths = _insert_images(new_content, load_deepseek_key()) else: new_content, gen_image_paths = _insert_images(new_content, api_key) # 2.4 末尾添加广告图 new_content = _append_ads_image(new_content) # 3. 转 HTML publisher = WeChatPublisher() html_content = publisher.markdown_to_html(new_content) # 3.5 上传 HTML 中的本地图片到微信(将 /storage/... 转为微信 CDN 地址) html_content = _upload_html_images(html_content, publisher) # 4. 封面图:优先用 AI 生成的配图(已去水印+叠加logo),再降级到 banners/原封面 cover_path = None cover_web_path = None if gen_image_paths: # 解析第一张AI生成图的本地路径 first_web = gen_image_paths[0] cover_path = os.path.normpath(os.path.join(PROJECT_ROOT, first_web.lstrip("/"))) cover_web_path = first_web print(f" 🎨 AI配图作为封面: {first_web}", file=sys.stderr) if not cover_path or not os.path.exists(cover_path): cover_web_path = None # 本地不存在,不更新DB cover_path = _random_banner() if not cover_path: print(" ℹ️ banners 目录无图,尝试用文章封面", file=sys.stderr) cover_url = article.get("cover_image") or "" cover_path = resolve_cover_path(cover_url) # 5. 创建公众号草稿 print("📝 创建公众号草稿...", file=sys.stderr) media_id = publisher.create_draft( title=title, content=html_content, author=author, digest=digest, cover_image_path=cover_path, ) print(f" media_id: {media_id}", file=sys.stderr) # 5.5 根据配置决定是否自动发布 publish_mode = "publish" # 默认值 if os.path.exists(AUTO_CONFIG_PATH): try: with open(AUTO_CONFIG_PATH, 'r', encoding='utf-8') as f: auto_cfg = yaml.safe_load(f) publish_mode = auto_cfg.get("publish_mode", "publish") except Exception: pass if publish_mode == "publish": print("📢 自动发布草稿...", file=sys.stderr) try: publisher.publish_draft(media_id) print(" ✅ 已提交发布", file=sys.stderr) except Exception as e: print(f" ⚠️ 发布失败: {e}", file=sys.stderr) else: print(" 📋 草稿模式: 跳过自动发布,请手动确认", file=sys.stderr) # 6. 更新数据库(含标题、作者署名、封面图) ok = update_article_content( article_id, new_content, status="published", new_title=title, new_author=author, new_cover_image=cover_web_path, ) print(f"{'✅' if ok else '❌'} 数据库更新: {'成功' if ok else '失败'}", file=sys.stderr) return { "article_id": article_id, "title": title, "media_id": media_id, "db_updated": ok, } # ---------- CLI ---------- def main(): parser = argparse.ArgumentParser( description="CMS 草稿改写 → 公众号发布 一键流水线" ) parser.add_argument("article_id", type=int, help="CMS 文章 ID") parser.add_argument( "--content-file", "-f", help="改写好的 markdown 文件路径。不传则自动调 DeepSeek 改写" ) parser.add_argument( "--dry-run", action="store_true", help="只改写不发布,输出改写结果到 stdout" ) args = parser.parse_args() # 读取改写内容 rewritten = None if args.content_file: with open(args.content_file) as f: rewritten = f.read() # 干跑模式:只改写输出 if args.dry_run: article = get_article_full(args.article_id) if not article: print(f"❌ 文章 ID={args.article_id} 不存在", file=sys.stderr) sys.exit(1) api_key = load_deepseek_key() result = call_deepseek(article["content"], api_key) print(result) return # 正式执行 try: result = run_pipeline(args.article_id, rewritten) print(json.dumps(result, ensure_ascii=False, indent=2), flush=True) except Exception as e: print(f"❌ 流水线失败: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()