516 lines
18 KiB
Python
516 lines
18 KiB
Python
#!/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 json
|
||
import time
|
||
import argparse
|
||
from typing import Optional, Dict, Any
|
||
|
||
# 确保脚本在 rewrite/ 目录执行也能正常工作
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
|
||
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. **保持医疗准确性**: 不得改变任何医学事实、数据、诊断标准、用药建议
|
||
2. **风格口语化**: 用亲近自然的语气,像医生朋友在聊天。适当使用emoji(每段不超过1个)
|
||
3. **保留结构**: 保留原文的章节标题和信息层次,但标题可以重新提炼得更吸引人
|
||
4. **精简优化**: 去除重复表述,合并相近内容,让文章更紧凑
|
||
5. **适合手机阅读**: 段落短小(2-3句一段),善用小标题和列表
|
||
6. **只输出改写后的 markdown**,不要加任何解释说明
|
||
|
||
输出格式: 纯 markdown 文本,可以包含:
|
||
- **粗体** 强调关键信息
|
||
- 无序列表 -
|
||
- ## 二级标题(如果需要)
|
||
- 段间空行分隔"""
|
||
|
||
|
||
# ---------- 图片生成配置 ----------
|
||
|
||
IMAGE_GEN_ENABLED = True # 是否启用配图生成
|
||
IMAGE_GEN_COUNT = 2 # 目标生成图片数量(1-3)
|
||
IMAGE_STORAGE_DIR = os.path.join(PROJECT_ROOT, "storage", "admin", "images")
|
||
IMAGE_PROMPT_SYSTEM = """你是一个图片提示词专家。根据下面给出的文章片段,生成一个适合配图的简短中文描述(用于AI生图),要求:
|
||
|
||
1. 描述要简洁具体,20-40个字
|
||
2. 风格要求:温暖、专业、治愈的视觉风格
|
||
3. 内容要与上下文紧密相关
|
||
4. 只输出描述文字,不要加任何解释或前缀"""
|
||
|
||
# 混元生图
|
||
HUNYUAN_SECRET_ID = "AKIDlmaJo4Qeq23xmdR7jIn59yd4RZUYUHtP"
|
||
HUNYUAN_SECRET_KEY = "QnCSUP6d3Untw6B8tUt4xqkTCoEh1A9u"
|
||
HUNYUAN_REGION = "ap-guangzhou"
|
||
|
||
# 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 _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:
|
||
"""腾讯混元生图 TextToImageLite"""
|
||
try:
|
||
from tencentcloud.common import credential
|
||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
|
||
from tencentcloud.aiart.v20221229 import aiart_client, models
|
||
except ImportError:
|
||
return False
|
||
|
||
try:
|
||
cred = credential.Credential(HUNYUAN_SECRET_ID, HUNYUAN_SECRET_KEY)
|
||
client = aiart_client.AiartClient(cred, HUNYUAN_REGION)
|
||
|
||
req = models.TextToImageLiteRequest()
|
||
req.Prompt = prompt
|
||
req.Resolution = resolution
|
||
req.LogoAdd = 0
|
||
req.RspImgType = "url"
|
||
|
||
resp = client.TextToImageLite(req)
|
||
result_url = resp.ResultImage
|
||
if not result_url:
|
||
return False
|
||
|
||
import requests
|
||
r = requests.get(result_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" ✅ 混元生图: {os.path.basename(output_path)} ({len(r.content)} bytes)", file=sys.stderr)
|
||
return True
|
||
|
||
except TencentCloudSDKException as e:
|
||
print(f" ⚠️ 混元: {e}", 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 _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) -> str:
|
||
"""分析 markdown、生成配图并插入,返回新 markdown"""
|
||
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:
|
||
# 相对路径用于 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\n"
|
||
lines.insert(line_idx, img_md)
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ---------- 微信图片上传 ----------
|
||
|
||
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 = article.get("author") or "尿大夫"
|
||
digest = article.get("summary") or ""
|
||
cover_url = article.get("cover_image") or ""
|
||
original = article["content"]
|
||
|
||
print(f"📄 文章: ID={article_id} | {title}", file=sys.stderr)
|
||
print(f" 原文字数: {len(original)} chars", file=sys.stderr)
|
||
|
||
# 2. 改写(两种模式)
|
||
if rewritten_content:
|
||
print(" 模式: 手动改写(跳过 LLM)", file=sys.stderr)
|
||
new_content = rewritten_content
|
||
else:
|
||
print(" 模式: 自动改写(DeepSeek)", file=sys.stderr)
|
||
api_key = load_deepseek_key()
|
||
new_content = call_deepseek(original, api_key)
|
||
|
||
print(f" 改写后字数: {len(new_content)} chars", file=sys.stderr)
|
||
|
||
# 2.5 生成配图并插入
|
||
print("🎨 生成配图...", file=sys.stderr)
|
||
if rewritten_content:
|
||
new_content = _insert_images(new_content, load_deepseek_key())
|
||
else:
|
||
new_content = _insert_images(new_content, api_key)
|
||
|
||
# 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. 解析封面图路径
|
||
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)
|
||
|
||
# 6. 更新数据库
|
||
ok = update_article_content(article_id, new_content, status="published")
|
||
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()
|