Files
nanocode/new-media-platform/website-new/rewrite/pipeline.py.bak.20260529
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

516 lines
18 KiB
Python
Raw 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 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![配图]({web_path})\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()