Files
2026-07-22 16:00:02 +08:00

1032 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
CMS 改写 → 公众号发布 一键流水线
两种模式:
1. 自动改写模式 (默认): python pipeline.py <article_id>
自己调 DeepSeek LLM 改写,然后发布
2. 传入改写内容模式: python pipeline.py <article_id> --content-file <markdown文件>
用预改写好的内容直接发布(志玲模式)
环境:
依赖 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, delete_article
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"
HUNYUAN_LITE_MODEL = "hy-image-lite"
HUNYUAN_LITE_ENDPOINT = "https://tokenhub.tencentmaas.com/v1/api/image/lite"
# 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, article_id: int) -> 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:
delete_article(article_id)
print(f" 🗑️ 已删除短文章 ID={article_id}({text_len} 字符)", file=sys.stderr)
raise ValueError(
f"原文文字内容不足({text_len} 字符 < {_MIN_TEXT_LENGTH}),"
f"可能全部为图片,已删除"
)
return text_len
def _check_llm_response(content: str, article_id: int) -> None:
"""
检查 LLM 返回是否是无意义的占位回复。
如果是占位回复则抛出 ValueError。
"""
for pattern in _LLM_PLACEHOLDER_PATTERNS:
if re.search(pattern, content):
delete_article(article_id)
print(f" 🗑️ 已删除占位回复文章 ID={article_id}", file=sys.stderr)
raise ValueError(
f"LLM 返回疑似占位回复(匹配模式: {pattern}),已删除"
)
# 额外检查:改写后内容过短(< 200 字)也视为无效
if len(content) < _MIN_TEXT_LENGTH:
delete_article(article_id)
print(f" 🗑️ 已删除改写后过短文章 ID={article_id}({len(content)} 字符)", file=sys.stderr)
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 _hunyuan_lite_image(prompt: str, output_path: str,
resolution: str = "1024:1024") -> bool:
"""腾讯混元生图 hy-image-lite (轻量同步版)
POST 一次直接返回图片 URL,无需轮询。价格更低。
注意:Lite 固定输出 1024×1024,不支持自定义分辨率。
"""
import requests
if not HUNYUAN_API_KEY:
return False
try:
payload = {
"model": HUNYUAN_LITE_MODEL,
"prompt": prompt,
"rsp_img_type": "url",
}
headers = {
"Authorization": f"Bearer {HUNYUAN_API_KEY}",
"Content-Type": "application/json",
}
resp = requests.post(HUNYUAN_LITE_ENDPOINT, json=payload,
headers=headers, timeout=60)
resp.raise_for_status()
result = resp.json()
data = result.get("data", [])
if not data:
print(f" ⚠️ 混元 Lite 无数据: {result}", file=sys.stderr)
return False
img_url = data[0].get("url", "")
if not img_url:
print(f" ⚠️ 混元 Lite 无 URL: {data[0]}", file=sys.stderr)
return False
r = requests.get(img_url, timeout=60)
r.raise_for_status()
img_data = r.content
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "wb") as f:
f.write(img_data)
print(f" ✅ 混元 Lite: {os.path.basename(output_path)} ({len(img_data)} bytes)", file=sys.stderr)
return True
except Exception as e:
print(f" ⚠️ 混元 Lite 异常: {e}", file=sys.stderr)
return False
def _generate_image(prompt: str, output_path: str,
resolution: str = "1024:1024") -> bool:
"""生成封面配图:优先混元 Lite → 混元 V3.0 → Seedream"""
# 1) 混元 Lite (轻量版,更便宜,同步返回)
success = _hunyuan_lite_image(prompt, output_path, resolution)
if success:
return True
# 2) 混元 V3.0 (标准版)
print(" ↪ 降级到混元 V3.0...", file=sys.stderr)
success = _hunyuan_image(prompt, output_path, resolution)
if success:
return True
# 3) Seedream
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
# 匹配 <img src="..." ...> 标签,提取 src 和整个标签
img_pattern = re.compile(r'<img[^>]+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, article_id)
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, article_id)
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()