Skill: paulforcoding/moyu-make-xhs-pics (version v1.0.2)

--- name: moyu-make-xhs-pics description: 将Markdown文章转换为小红书风格的封面图、插图和配图。支持阿里百炼和MiniMax API，4种风格（fresh/warm/notion/cute），4种布局（balanced/list/comparison/flow）。 version: 1.0.0 metadata: openclaw: homepage: https://github.com/paulforcoding/make-xhs-pics env: required: - DASHSCOPE_API_KEY # 阿里百炼 API Key（推荐） - MINIMAX_API_KEY # MiniMax API Key（不推荐） primary: DASHSCOPE_API_KEY note: 推荐使用阿里百炼，DASHSCOPE_API_KEY --- # moyu-make-xhs-pics - 小红书图片生成器 将 Markdown 文章自动转换为小红书风格的图片。 ## 功能 - 🖼️ **三种图片类型** - 封面图：文章内容的高度提炼 - 插图：完整表达文章内容 + 布局 - 配图：随机章节的内容概括 - 🤖 **双 Provider**：MiniMax + 阿里百炼 - 🎨 **4 种风格**：fresh / warm / notion / cute - 📐 **4 种布局**：balanced / list / comparison / flow - 💧 **水印支持**：自动添加"AI 生成" ## 安装 ```bash pip install -r requirements.txt ``` ## 环境变量 ```bash # MiniMax export MINIMAX_API_KEY="your-key" # 阿里百炼 export DASHSCOPE_API_KEY="your-key" ``` ## 使用方法 ```python from src import generate_article_images result = generate_article_images( article_path="path/to/article.md", cover_count=1, illustration_count=1, decoration_count=2, style="fresh", layout="auto", provider="dashscope" ) if result["success"]: print(f"生成成功: {result['total']} 张图片") print(f"封面图: {result['covers']}") print(f"插图: {result['illustrations']}") print(f"配图: {result['decorations']}") ``` ## 参数说明 | 参数 | 默认值 | 说明 | |------|--------|------| | cover_count | 1 | 封面图数量 | | illustration_count | 1 | 插图数量 | | decoration_count | 2 | 配图数量 | | style | fresh | 风格 (fresh/warm/notion/cute) | | layout | auto | 布局 (balanced/list/comparison/flow/auto) | | provider | minimax | API (minimax/dashscope) | ## License GPL v3 - See LICENSE file ## 参考 提示词逻辑参照 [baoyu-skills](https://github.com/JimLiu/baoyu-skills)

path: README.md
content: # moyu-make-xhs-pics

小红书图片生成器 - OpenClaw Skill

## 简介

输入一篇 Markdown 文章的本地路径
运行本 skill 讲自动该文件生成为小红书风格的1张封面图、1张插图和2张配图。

**提示词逻辑参照 [baoyu-skills](https://github.com/JimLiu/baoyu-skills)**

## 功能特性

- 🖼️ **三种图片类型**
  - 封面图：文章内容的高度提炼
  - 插图：完整表达文章内容 + 布局
  - 配图：随机章节的内容概括
- 🤖 **双 Provider**：MiniMax + 阿里百炼
- 🎨 **4 种风格**：fresh / warm / notion / cute
- 📐 **4 种布局**：balanced / list / comparison / flow
- 💧 **水印支持**：自动添加"AI 生成"

## 安装

```bash
pip install -r requirements.txt
```

## 环境变量

```bash
# MiniMax-不太推荐，效果不太好
export MINIMAX_API_KEY="your-key"

# 阿里百炼-推荐
export DASHSCOPE_API_KEY="your-key"
```

## 使用方法

### Python API

```python
from src import generate_article_images

result = generate_article_images(
    article_path="path/to/article.md",
    cover_count=1,
    illustration_count=1,
    decoration_count=2,
    style="fresh",
    layout="auto",
    provider="minimax"
)

if result["success"]:
    print(f"生成成功: {result['total']} 张图片")
    print(f"封面图: {result['covers']}")
    print(f"插图: {result['illustrations']}")
    print(f"配图: {result['decorations']}")
```

### 命令行

```bash
python scripts/generate.py article.md \
    --cover 1 \
    --illustration 1 \
    --decoration 2 \
    --style fresh \
    --layout auto \
    --provider minimax
```

## 参数说明

| 参数 | 默认值 | 说明 |
|------|--------|------|
| cover_count | 1 | 封面图数量 |
| illustration_count | 1 | 插图数量 |
| decoration_count | 2 | 配图数量 |
| style | fresh | 风格 (fresh/warm/notion/cute) |
| layout | auto | 布局 (balanced/list/comparison/flow/auto) |
| provider | minimax | API (minimax/dashscope) |

## 项目结构

```
moyu-make-xhs-pics/
├── src/
│   ├── __init__.py          # 主入口
│   ├── article_parser.py    # 文章解析
│   ├── prompt_engine.py     # 提示词引擎
│   ├── image_generator.py  # 图片生成
│   ├── watermark.py         # 水印
│   ├── layout_selector.py   # 布局选择
│   └── types.py             # 类型定义
├── scripts/
│   └── generate.py          # 命令行入口
├── tests/                   # 测试
├── data/
│   └── sample.md            # 示例文章
└── requirements.txt
```

## 测试

```bash
pytest tests/ -v
```

## License

GPL v3 - See [LICENSE](LICENSE) file


path: data/sample.md
content: # 测试文章

## 引言

这是一个测试文章，用于验证图片生成功能。

## 安装步骤

1. 下载软件
2. 安装软件
3. 配置环境
4. 运行测试

## 对比分析

A方案和B方案的差异：

- A方案：简单
- B方案：复杂


path: requirements.txt
content: requests
dashscope
Pillow


path: scripts/generate.py
content: #!/usr/bin/env python3
"""
命令行工具 - 生成小红书图片
"""

import argparse
import sys
import os

# 添加 src 目录到路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from src import generate_article_images, VALID_STYLES, VALID_LAYOUTS


def main():
    parser = argparse.ArgumentParser(
        description="小红书图片生成工具"
    )
    
    parser.add_argument(
        "article",
        help="文章文件路径"
    )
    
    parser.add_argument(
        "--cover", "-c",
        type=int,
        default=1,
        help="封面图数量 (默认: 1)"
    )
    
    parser.add_argument(
        "--illustration", "-i",
        type=int,
        default=1,
        help="插图数量 (默认: 1)"
    )
    
    parser.add_argument(
        "--decoration", "-d",
        type=int,
        default=2,
        help="配图数量 (默认: 2)"
    )
    
    parser.add_argument(
        "--style", "-s",
        choices=VALID_STYLES,
        default="fresh",
        help=f"风格 (默认: fresh)"
    )
    
    parser.add_argument(
        "--layout", "-l",
        choices=VALID_LAYOUTS,
        default="auto",
        help="布局 (默认: auto)"
    )
    
    parser.add_argument(
        "--provider", "-p",
        choices=["minimax", "dashscope"],
        default="minimax",
        help="API 提供商 (默认: minimax)"
    )
    
    args = parser.parse_args()
    
    print(f"📄 文章: {args.article}")
    print(f"🎨 风格: {args.style}")
    print(f"📐 布局: {args.layout}")
    print(f"🤖 Provider: {args.provider}")
    print(f"📊 数量: 封面{args.cover} + 插图{args.illustration} + 配图{args.decoration}")
    print()
    
    result = generate_article_images(
        article_path=args.article,
        cover_count=args.cover,
        illustration_count=args.illustration,
        decoration_count=args.decoration,
        style=args.style,
        layout=args.layout,
        provider=args.provider
    )
    
    if result["success"]:
        print("✅ 生成成功!")
        print()
        
        if result["covers"]:
            print("� cover:")
            for path in result["covers"]:
                print(f"  - {path}")
        
        if result["illustrations"]:
            print("📐 illustration:")
            for path in result["illustrations"]:
                print(f"  - {path}")
        
        if result["decorations"]:
            print("🖼️ decoration:")
            for path in result["decorations"]:
                print(f"  - {path}")
        
        print()
        print(f"总计: {result['total']} 张图片")
    else:
        print(f"❌ 生成失败: {result['error']}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/image_gen.py
content: """
baoyu-openclaw - 小红书图片生成器
支持两种模式：封面图 和 插图
"""

import os
import requests
import json
from typing import Dict, Optional, Any
from urllib.parse import urlparse, unquote
from pathlib import PurePosixPath

# 安装 Pillow: pip install Pillow
try:
    from PIL import Image, ImageDraw, ImageFont
    HAS_PIL = True
except ImportError:
    HAS_PIL = False
    print("Warning: Pillow not installed, watermark will not be added")

# ============ 风格定义 ============
STYLES = {
    "warm": {
        "keywords": ["温暖", "友好", "柔和色调", "温馨", "舒适", "暖色"],
        "description": "温暖友好的风格，适合情感类内容"
    },
    "notion": {
        "keywords": ["极简", "手绘线条", "知识感", "专业", "简洁", "扁平化"],
        "description": "极简手绘风格，适合知识干货类内容"
    },
    "fresh": {
        "keywords": ["清新", "自然", "干净", "明亮", "通透", "清爽"],
        "description": "清新自然的风格，适合生活分享类内容"
    }
}

# 布局定义
LAYOUT = "balanced"

# 尺寸定义
DEFAULT_WIDTH = 720
DEFAULT_HEIGHT = 1200

# 水印文字
WATERMARK = "AI 生成"


def generate_prompt(content: str, style: str = "notion", mode: str = "cover") -> str:
    """
    根据内容和风格生成提示词
    
    Args:
        content: 文章内容或标题
        style: 风格 (warm/notion/fresh)
        mode: 模式 (cover/illustration)
            - cover: 封面图，图片是内容的视觉提炼，不包含文字
            - illustration: 插图，图片是某个小节的概括，不包含文字
    """
    if style not in STYLES:
        style = "notion"
    
    style_info = STYLES[style]
    keywords = "，".join(style_info["keywords"][:3])
    
    if mode == "cover":
        # 封面图：内容的视觉提炼，不包含文字
        prompt = f"{content}的视觉概念图，{keywords}风格，现代扁平化设计，简约大气，主体突出，无文字符号，整体画面和谐完整"
    else:
        # 插图：具体场景描绘，避免抽象
        prompt = f"具体场景：{content}。{keywords}风格，扁平化插图，清晰的单个场景画面，有明确的主体事物和简单背景，画面干净易懂，无文字，无抽象图形"
    
    return prompt


def add_watermark(image_path: str, position: str = "右下角") -> str:
    """
    在图片上添加水印
    
    Args:
        image_path: 图片路径
        position: 水印位置 (左下角/右下角)
    
    Returns:
        添加水印后的图片路径
    """
    if not HAS_PIL:
        return image_path
    
    try:
        # 打开图片
        img = Image.open(image_path)
        
        # 创建绘图对象
        draw = ImageDraw.Draw(img)
        
        # 获取图片尺寸
        width, height = img.size
        
        # 尝试使用系统字体，优先使用支持中文的字体
        font_size = max(16, width // 30)
        font_paths = [
            "/System/Library/Fonts/STHeiti Medium.ttc",  # 黑体
            "/System/Library/Fonts/STHeiti Light.ttc",
            "/System/Library/Fonts/PingFang.ttc",
            "/System/Library/Fonts/Helvetica.ttc",
        ]
        
        font = None
        for font_path in font_paths:
            try:
                font = ImageFont.truetype(font_path, font_size)
                break
            except:
                continue
        
        if font is None:
            font = ImageFont.load_default()
        
        # 获取文字尺寸
        bbox = draw.textbbox((0, 0), WATERMARK, font=font)
        text_width = bbox[2] - bbox[0]
        text_height = bbox[3] - bbox[1]
        
        # 计算位置
        padding = 20
        if position == "左下角":
            x = padding
            y = height - text_height - padding
        else:  # 右下角
            x = width - text_width - padding
            y = height - text_height - padding
        
        # 绘制文字（带阴影效果，更清晰）
        shadow_color = (0, 0, 0)
        text_color = (255, 255, 255)
        
        # 阴影
        draw.text((x + 2, y + 2), WATERMARK, fill=shadow_color, font=font)
        # 文字
        draw.text((x, y), WATERMARK, fill=text_color, font=font)
        
        # 保存
        img.save(image_path)
        return image_path
        
    except Exception as e:
        print(f"添加水印失败: {e}")
        return image_path


class MiniMaxProvider:
    """MiniMax 图像生成服务提供商"""
    
    def __init__(self):
        self.[REDACTED_SECRET]'MINIMAX_API_KEY')
        if not self.api_key:
            raise ValueError("MINIMAX_API_KEY 环境变量未设置")
        self.base_url = "https://api.minimaxi.com/v1/image_generation"
    
    def generate(self, prompt: str, style: str, width: int, height: int, mode: str = "cover") -> Dict[str, Any]:
        """生成图像"""
        # MiniMax 使用标准长宽比格式
        if width == 720 and height == 1200:
            aspect_ratio = "2:3"
        elif width == height:
            aspect_ratio = "1:1"
        elif width / height > 1:
            aspect_ratio = "16:9"
        else:
            aspect_ratio = "9:16"
        
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json"
        }
        
        # 根据风格优化提示词
        styled_prompt = self._apply_style(prompt, style)
        
        payload = {
            "model": "image-01",
            "prompt": styled_prompt,
            "aspect_ratio": aspect_ratio,
            "response_format": "url",
            "n": 1,
            "prompt_optimizer": True
        }
        
        try:
            response = requests.post(self.base_url, json=payload, headers=headers, timeout=60)
            
            if response.status_code == 200:
                data = response.json()
                
                if data.get("data", {}).get("image_urls"):
                    image_urls = data["data"]["image_urls"]
                    image_url = image_urls[0]
                    
                    # 下载图片
                    local_path = self._download_image(image_url, prompt)
                    
                    if local_path:
                        # 添加水印
                        local_path = add_watermark(local_path, position="右下角")
                        
                        return {
                            "success": True,
                            "image_url": image_url,
                            "local_path": local_path
                        }
                    else:
                        return {
                            "success": False,
                            "image_url": None,
                            "local_path": None,
                            "error": "下载图片失败"
                        }
                else:
                    return {
                        "success": False,
                        "image_url": None,
                        "local_path": None,
                        "error": f"API 响应中未找到图片链接: {data}"
                    }
            else:
                return {
                    "success": False,
                    "image_url": None,
                    "local_path": None,
                    "error": f"API 请求失败: {response.status_code} - {response.text}"
                }
                
        except Exception as e:
            return {
                "success": False,
                "image_url": None,
                "local_path": None,
                "error": f"请求异常: {str(e)}"
            }
    
    def _apply_style(self, prompt: str, style: str) -> str:
        """根据风格调整提示词"""
        style_descriptions = {
            "warm": "温暖色调，柔和光影，温馨氛围",
            "notion": "清新简约，现代设计感，干净利落",
            "fresh": "明亮色彩，生动活泼，清爽风格"
        }
        
        style_desc = style_descriptions.get(style, "")
        if style_desc:
            return f"{prompt}, {style_desc}"
        return prompt
    
    def _download_image(self, url: str, prompt: str) -> Optional[str]:
        """下载图片到本地"""
        try:
            response = requests.get(url, timeout=60)
            if response.status_code == 200:
                # 生成文件名
                safe_name = "".join(c for c in prompt[:20] if c.isalnum())
                filename = f"minimax_{hash(prompt) % 100000}.png"
                filepath = f"/tmp/{filename}"
                
                with open(filepath, 'wb') as f:
                    f.write(response.content)
                return filepath
        except Exception as e:
            print(f"下载失败: {e}")
        return None


class DashScopeProvider:
    """阿里百炼图像生成服务提供商"""
    
    def __init__(self):
        self.[REDACTED_SECRET]'DASHSCOPE_API_KEY')
        if not self.api_key:
            raise ValueError("DASHSCOPE_API_KEY 环境变量未设置")
        
        # 动态导入 dashscope
        try:
            import dashscope
            from dashscope import ImageSynthesis
            from http import HTTPStatus
            self.dashscope = dashscope
            self.ImageSynthesis = ImageSynthesis
            self.HTTPStatus = HTTPStatus
        except ImportError:
            raise ImportError("请安装 dashscope: pip install dashscope")
        
        self.dashscope.[REDACTED_SECRET]
    
    def generate(self, prompt: str, style: str, width: int, height: int, mode: str = "cover") -> Dict[str, Any]:
        """生成图像"""
        # 风格优化
        styled_prompt = self._apply_style(prompt, style)
        
        size = f"{width}*{height}"
        
        try:
            rsp = self.ImageSynthesis.call(
                [REDACTED_SECRET],
                model="qwen-image-plus",
                prompt=styled_prompt,
                negative_prompt=" ",
                n=1,
                size=size,
                prompt_extend=True,
                watermark=False
            )
            
            if rsp.status_code == self.HTTPStatus.OK and rsp.output.task_status == 'SUCCEEDED':
                for result in rsp.output.results:
                    # 下载图片
                    local_path = self._download_image(result.url, prompt)
                    
                    if local_path:
                        # 添加水印
                        local_path = add_watermark(local_path, position="右下角")
                        
                        return {
                            "success": True,
                            "image_url": result.url,
                            "local_path": local_path
                        }
                    else:
                        return {
                            "success": False,
                            "image_url": None,
                            "local_path": None,
                            "error": "下载图片失败"
                        }
            else:
                msg = rsp.output.message if rsp.output else "未知错误"
                return {
                    "success": False,
                    "image_url": None,
                    "local_path": None,
                    "error": f"API 调用失败: {msg}"
                }
                
        except Exception as e:
            return {
                "success": False,
                "image_url": None,
                "local_path": None,
                "error": f"请求异常: {str(e)}"
            }
    
    def _apply_style(self, prompt: str, style: str) -> str:
        """根据风格调整提示词"""
        style_descriptions = {
            "warm": "温暖色调，柔和光影，温馨氛围",
            "notion": "清新简约，现代设计感，干净利落",
            "fresh": "明亮色彩，生动活泼，清爽风格"
        }
        
        style_desc = style_descriptions.get(style, "")
        if style_desc:
            return f"{prompt}, {style_desc}"
        return prompt
    
    def _download_image(self, url: str, prompt: str) -> Optional[str]:
        """下载图片到本地"""
        try:
            response = requests.get(url, timeout=60)
            if response.status_code == 200:
                safe_name = "".join(c for c in prompt[:20] if c.isalnum())
                filename = f"dashscope_{hash(prompt) % 100000}.png"
                filepath = f"/tmp/{filename}"
                
                with open(filepath, 'wb') as f:
                    f.write(response.content)
                return filepath
        except Exception as e:
            print(f"下载失败: {e}")
        return None


def generate_image(
    prompt: str,
    style: str = "notion",
    provider: str = "minimax",
    mode: str = "cover",
    width: int = 720,
    height: int = 1200
) -> Dict[str, Any]:
    """
    生成图像的主要函数
    
    Args:
        prompt: 图像生成的提示词
        style: 风格类型 (warm/notion/fresh)
        provider: 服务提供商 (minimax/dashscope)
        mode: 图像模式
            - cover: 封面图，图片是内容的视觉提炼
            - illustration: 插图，图片是某个小节的概括
        width: 图像宽度
        height: 图像高度
    
    Returns:
        dict: 包含 success, image_url, local_path 的字典
    """
    # 验证尺寸
    if width % 8 != 0 or height % 8 != 0:
        return {
            "success": False,
            "image_url": None,
            "local_path": None,
            "error": f"宽度({width})和高度({height})必须是8的倍数"
        }
    
    # 验证风格
    if style not in STYLES:
        return {
            "success": False,
            "image_url": None,
            "local_path": None,
            "error": f"无效的风格参数，支持: {list(STYLES.keys())}"
        }
    
    # 验证模式
    if mode not in ["cover", "illustration"]:
        return {
            "success": False,
            "image_url": None,
            "local_path": None,
            "error": "无效的模式，支持: cover (封面图), illustration (插图)"
        }
    
    # 生成优化的提示词
    optimized_prompt = generate_prompt(prompt, style, mode)
    
    # 选择提供商
    if provider.lower() == "minimax":
        provider_instance = MiniMaxProvider()
        actual_width, actual_height = width, height
    elif provider.lower() == "dashscope":
        provider_instance = DashScopeProvider()
        # DashScope 只支持特定尺寸
        actual_width, actual_height = 720, 1280
    else:
        return {
            "success": False,
            "image_url": None,
            "local_path": None,
            "error": f"无效的服务提供商，支持: ['minimax', 'dashscope']"
        }
    
    # 生成图像
    return provider_instance.generate(optimized_prompt, style, actual_width, actual_height, mode)


# 测试
if __name__ == "__main__":
    import sys
    
    # 测试
    result = generate_image(
        prompt="AI时代读代码的正确姿势",
        style="notion",
        mode="cover"
    )
    
    print("结果:", result)


path: src/__init__.py
content: """
moyu-make-xhs-pics - 小红书图片生成工具
"""

from .types import (
    Article,
    Section,
    ImageResult,
    ArticleImagesResult,
    STYLES,
    LAYOUTS,
    VALID_STYLES,
    VALID_LAYOUTS
)
from .article_parser import ArticleParser
from .prompt_engine import PromptEngine
from .layout_selector import LayoutSelector
from .image_generator import ImageGenerator
from .watermark import add_watermark

__version__ = "1.0.0"
__all__ = [
    "ArticleParser",
    "PromptEngine",
    "LayoutSelector",
    "ImageGenerator",
    "add_watermark",
    "generate_article_images",
    "STYLES",
    "LAYOUTS",
    "VALID_STYLES",
    "VALID_LAYOUTS",
]


def generate_article_images(
    article_path: str,
    cover_count: int = 1,
    illustration_count: int = 1,
    decoration_count: int = 2,
    style: str = "fresh",
    layout: str = "auto",
    provider: str = "minimax"
) -> ArticleImagesResult:
    """
    生成文章的图片（封面 + 插图 + 配图）
    
    Args:
        article_path: 文章文件路径
        cover_count: 封面图数量
        illustration_count: 插图数量
        decoration_count: 配图数量
        style: 风格 (fresh/warm/notion/cute)
        layout: 布局 (balanced/list/comparison/flow/auto)
        provider: API 提供商 (minimax/dashscope)
    
    Returns:
        ArticleImagesResult
    """
    try:
        # 1. 解析文章
        article = ArticleParser.parse(article_path)
        
        # 2. 解析布局
        resolved_layout = LayoutSelector.resolve_layout(layout, article)
        
        # 3. 初始化图片生成器
        generator = ImageGenerator(provider)
        
        covers = []
        illustrations = []
        decorations = []
        
        # 4. 生成封面图
        for i in range(cover_count):
            prompt = PromptEngine.generate_cover_prompt(article, style)
            result = generator.generate(prompt, style, "cover")
            
            if result["success"]:
                covers.append(result["local_path"])
            else:
                return ArticleImagesResult(
                    success=False,
                    covers=[],
                    illustrations=[],
                    decorations=[],
                    total=0,
                    error=f"封面图生成失败: {result['error']}"
                )
        
        # 5. 生成插图
        for i in range(illustration_count):
            prompt = PromptEngine.generate_illustration_prompt(article, style, resolved_layout)
            result = generator.generate(prompt, style, "illustration")
            
            if result["success"]:
                illustrations.append(result["local_path"])
            else:
                return ArticleImagesResult(
                    success=False,
                    covers=covers,
                    illustrations=[],
                    decorations=[],
                    total=len(covers),
                    error=f"插图生成失败: {result['error']}"
                )
        
        # 6. 生成配图
        selected_sections = PromptEngine.select_random_sections(
            article["sections"], 
            decoration_count
        )
        
        for section in selected_sections:
            prompt = PromptEngine.generate_decoration_prompt(section, style)
            result = generator.generate(prompt, style, "decoration")
            
            if result["success"]:
                decorations.append(result["local_path"])
            else:
                return ArticleImagesResult(
                    success=False,
                    covers=covers,
                    illustrations=illustrations,
                    decorations=[],
                    total=len(covers) + len(illustrations),
                    error=f"配图生成失败: {result['error']}"
                )
        
        # 7. 添加水印
        for path in covers + illustrations + decorations:
            add_watermark(path)
        
        return ArticleImagesResult(
            success=True,
            covers=covers,
            illustrations=illustrations,
            decorations=decorations,
            total=len(covers) + len(illustrations) + len(decorations),
            error=None
        )
        
    except FileNotFoundError as e:
        return ArticleImagesResult(
            success=False,
            covers=[],
            illustrations=[],
            decorations=[],
            total=0,
            error=str(e)
        )
    except ValueError as e:
        return ArticleImagesResult(
            success=False,
            covers=[],
            illustrations=[],
            decorations=[],
            total=0,
            error=str(e)
        )
    except Exception as e:
        return ArticleImagesResult(
            success=False,
            covers=[],
            illustrations=[],
            decorations=[],
            total=0,
            error=f"未知错误: {str(e)}"
        )


path: src/article_parser.py
content: """
文章解析模块
"""

import re
from pathlib import Path
from typing import Optional

from .types import Article, Section


class ArticleParser:
    """文章解析器"""
    
    @staticmethod
    def parse(file_path: str) -> Article:
        """
        解析 Markdown 文章
        
        Args:
            file_path: 文章文件路径
            
        Returns:
            Article 对象
            
        Raises:
            FileNotFoundError: 文件不存在
            ValueError: 文件格式错误
        """
        path = Path(file_path)
        if not path.exists():
            raise FileNotFoundError(f"文件不存在: {file_path}")
        
        content = path.read_text(encoding='utf-8')
        return ArticleParser.parse_markdown(content)
    
    @staticmethod
    def parse_markdown(content: str) -> Article:
        """
        解析 Markdown 内容
        
        Args:
            content: Markdown 内容
            
        Returns:
            Article 对象
        """
        lines = content.strip().split('\n')
        
        # 提取标题（第一个 # 开头的行）
        title = ""
        for line in lines:
            line = line.strip()
            if line.startswith('#'):
                title = line.lstrip('#').strip()
                break
        
        if not title:
            # 如果没有标题，使用第一行
            title = lines[0].strip() if lines else "无标题"
        
        # 解析章节
        sections = ArticleParser._parse_sections(lines)
        
        # 生成摘要
        summary = title
        
        return Article(
            title=title,
            sections=sections,
            summary=summary
        )
    
    @staticmethod
    def _parse_sections(lines: list[str]) -> list[Section]:
        """
        解析章节
        
        Args:
            lines: 行列表
            
        Returns:
            章节列表
        """
        sections = []
        current_section = None
        current_content = []
        
        for line in lines:
            line = line.strip()
            
            # 跳过空行
            if not line:
                continue
            
            # 检查是否是章节标题（## 开头）- 优先检查
            if line.startswith('##'):
                # 保存之前的章节
                if current_section and current_content:
                    sections.append(Section(
                        title=current_section,
                        content='\n'.join(current_content).strip()
                    ))
                    current_content = []
                
                current_section = line.lstrip('#').strip()
                continue
            
            # 跳过文章标题行（# 开头，但不是 ##）
            if line.startswith('#'):
                # 保存之前的章节
                if current_section and current_content:
                    sections.append(Section(
                        title=current_section,
                        content='\n'.join(current_content).strip()
                    ))
                    current_content = []
                continue
            
            # 添加到当前章节内容
            if current_section is None:
                # 如果没有章节标题，创建一个默认章节
                current_section = "内容"
            current_content.append(line)
        
        # 保存最后一个章节
        if current_section and current_content:
            sections.append(Section(
                title=current_section,
                content='\n'.join(current_content).strip()
            ))
        
        # 如果没有解析出章节，创建默认章节
        if not sections:
            sections.append(Section(
                title="内容",
                content=lines[0] if lines else ""
            ))
        
        return sections
    
    @staticmethod
    def extract_all_content(article: Article) -> str:
        """
        提取文章的全部内容
        
        用于插图（完整表达文章内容）
        
        Args:
            article: 文章对象
            
        Returns:
            全部内容文本
        """
        parts = [article["title"]]
        
        for section in article["sections"]:
            parts.append(f"{section['title']}: {section['content']}")
        
        return " ".join(parts)
    
    @staticmethod
    def get_plain_text(md_content: str) -> str:
        """
        获取纯文本（去除 Markdown 语法）
        
        Args:
            md_content: Markdown 内容
            
        Returns:
            纯文本
        """
        # 去除标题标记
        text = re.sub(r'#+ ', '', md_content)
        # 去除加粗
        text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
        # 去除链接
        text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
        # 去除图片
        text = re.sub(r'!\[.*?\]\(.+?\)', '', text)
        # 去除代码块
        text = re.sub(r'```.*?```', '', text, flags=re.DOTALL)
        text = re.sub(r'`(.+?)`', r'\1', text)
        
        return text.strip()


path: src/image_generator.py
content: """
图片生成模块
"""

import os
import requests
from typing import Dict, Any, Optional

from .types import ImageResult, STYLES


class ImageGenerator:
    """图片生成器"""
    
    def __init__(self, provider: str = "minimax"):
        """
        初始化
        
        Args:
            provider: API 提供商 ("minimax" 或 "dashscope")
            
        Raises:
            ValueError: provider 不支持或 API key 未设置
        """
        self.provider = provider.lower()
        
        if self.provider == "minimax":
            self.[REDACTED_SECRET]'MINIMAX_API_KEY')
            if not self.api_key:
                raise ValueError("MINIMAX_API_KEY 环境变量未设置")
            self.base_url = "https://api.minimaxi.com/v1/image_generation"
            
        elif self.provider == "dashscope":
            self.[REDACTED_SECRET]'DASHSCOPE_API_KEY')
            if not self.api_key:
                raise ValueError("DASHSCOPE_API_KEY 环境变量未设置")
            self.base_url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
            
        else:
            raise ValueError(f"不支持的 provider: {provider}")
    
    def generate(
        self,
        prompt: str,
        style: str,
        mode: str = "cover"
    ) -> ImageResult:
        """
        生成图片
        
        Args:
            prompt: 提示词
            style: 风格类型
            mode: 图片模式 ("cover", "illustration", "decoration")
            
        Returns:
            ImageResult
        """
        if self.provider == "minimax":
            return self._generate_minimax(prompt, style)
        else:
            return self._generate_dashscope(prompt, style)
    
    def _generate_minimax(self, prompt: str, style: str) -> ImageResult:
        """MiniMax API"""
        # 优化提示词
        styled_prompt = self._apply_style(prompt, style)
        
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json"
        }
        
        payload = {
            "model": "image-01",
            "prompt": styled_prompt,
            "aspect_ratio": "2:3",
            "response_format": "url",
            "n": 1,
            "prompt_optimizer": True
        }
        
        try:
            response = requests.post(
                self.base_url, 
                json=payload, 
                headers=headers, 
                timeout=120
            )
            
            if response.status_code == 200:
                try:
                    data = response.json()
                    if data is None:
                        return ImageResult(
                            success=False,
                            image_url=None,
                            local_path=None,
                            error=f"API 返回空数据: {response.text[:200]}"
                        )
                except Exception as e:
                    return ImageResult(
                        success=False,
                        image_url=None,
                        local_path=None,
                        error=f"JSON 解析失败: {str(e)}, 响应: {response.text[:200]}"
                    )
                
                if data and data.get("data", {}).get("image_urls"):
                    image_url = data["data"]["image_urls"][0]
                    local_path = self._download_image(image_url, prompt)
                    
                    if local_path:
                        return ImageResult(
                            success=True,
                            image_url=image_url,
                            local_path=local_path,
                            error=None
                        )
                
                return ImageResult(
                    success=False,
                    image_url=None,
                    local_path=None,
                    error=f"API 响应中未找到图片链接: {data}"
                )
            else:
                return ImageResult(
                    success=False,
                    image_url=None,
                    local_path=None,
                    error=f"API 请求失败: {response.status_code}"
                )
                
        except Exception as e:
            return ImageResult(
                success=False,
                image_url=None,
                local_path=None,
                error=f"请求异常: {str(e)}"
            )
    
    def _generate_dashscope(self, prompt: str, style: str) -> ImageResult:
        """阿里百炼 API"""
        try:
            from dashscope import ImageSynthesis
            from http import HTTPStatus
            import dashscope
        except ImportError:
            return ImageResult(
                success=False,
                image_url=None,
                local_path=None,
                error="请安装 dashscope: pip install dashscope"
            )
        
        # 设置 API URL（重要！）
        dashscope.base_http_api_url = 'https://dashscope.aliyuncs.com/api/v1'
        
        # 优化提示词
        styled_prompt = self._apply_style(prompt, style)
        
        try:
            rsp = ImageSynthesis.call(
                [REDACTED_SECRET],
                model="qwen-image",
                prompt=styled_prompt,
                negative_prompt=" ",
                n=1,
                size="1024*1707",
                prompt_extend=True,
                watermark=False
            )
            
            if rsp.status_code == HTTPStatus.OK and rsp.output.task_status == 'SUCCEEDED':
                result = rsp.output.results[0]
                local_path = self._download_image(result.url, prompt)
                
                if local_path:
                    return ImageResult(
                        success=True,
                        image_url=result.url,
                        local_path=local_path,
                        error=None
                    )
                
                return ImageResult(
                    success=False,
                    image_url=None,
                    local_path=None,
                    error="下载图片失败"
                )
            else:
                msg = rsp.output.message if rsp.output else "未知错误"
                return ImageResult(
                    success=False,
                    image_url=None,
                    local_path=None,
                    error=f"API 调用失败: {msg}"
                )
                
        except Exception as e:
            return ImageResult(
                success=False,
                image_url=None,
                local_path=None,
                error=f"请求异常: {str(e)}"
            )
    
    def _apply_style(self, prompt: str, style: str) -> str:
        """根据风格调整提示词"""
        style_descriptions = {
            "fresh": "清新、自然、干净、明亮、通透",
            "warm": "温暖、友好、柔和、温馨、暖色",
            "notion": "极简、手绘、专业、知识感、扁平化",
            "cute": "可爱、卡通、萌趣、活泼、卡通风格"
        }
        
        style_desc = style_descriptions.get(style, "")
        if style_desc:
            return f"{prompt}, {style_desc}"
        return prompt
    
    def _download_image(self, url: str, prompt: str) -> Optional[str]:
        """下载图片到本地"""
        try:
            response = requests.get(url, timeout=60)
            if response.status_code == 200:
                filename = f"moyu_{abs(hash(prompt)) % 100000}.png"
                filepath = f"/tmp/{filename}"
                
                with open(filepath, 'wb') as f:
                    f.write(response.content)
                return filepath
        except Exception as e:
            print(f"下载失败: {e}")
        return None


path: src/layout_selector.py
content: """
布局选择器
"""

from .types import Article, LAYOUTS


class LayoutSelector:
    """布局选择器"""
    
    VALID_LAYOUTS = list(LAYOUTS.keys())
    
    # 关键词映射
    COMPARISON_KEYWORDS = ['对比', '比较', '差异', '优缺点', '好坏', 'vs', 'versus', '不同', '区别']
    FLOW_KEYWORDS = ['首先', '然后', '接着', '最后', '步骤', '流程', '阶段', '原因', '结果', '导致', '为了']
    LIST_KEYWORDS = ['1.', '2.', '3.', '第一', '第二', '第三', '第四', '要点', '包括', '以下', '有以下几个方面']
    
    @staticmethod
    def auto_select(article: Article) -> str:
        """
        根据文章内容自动选择布局
        
        Args:
            article: 文章对象
            
        Returns:
            布局类型
        """
        # 合并所有内容进行判断
        all_content = ""
        for section in article["sections"]:
            all_content += section["title"] + section["content"]
        
        # 检查对比类
        if any(kw in all_content for kw in LayoutSelector.COMPARISON_KEYWORDS):
            return "comparison"
        
        # 检查流程/步骤类
        if any(kw in all_content for kw in LayoutSelector.FLOW_KEYWORDS):
            return "flow"
        
        # 检查列表类
        if any(kw in all_content for kw in LayoutSelector.LIST_KEYWORDS):
            return "list"
        
        # 默认
        return "balanced"
    
    @staticmethod
    def is_valid_layout(layout: str) -> bool:
        """
        验证布局是否有效
        
        Args:
            layout: 布局类型
            
        Returns:
            是否有效
        """
        return layout in LayoutSelector.VALID_LAYOUTS or layout == "auto"
    
    @staticmethod
    def resolve_layout(layout: str, article: Article) -> str:
        """
        解析布局（如果是 auto，则自动选择）
        
        Args:
            layout: 布局类型
            article: 文章对象
            
        Returns:
            解析后的布局类型
        """
        if layout == "auto":
            return LayoutSelector.auto_select(article)
        return layout


path: src/prompt_engine.py
content: """
提示词引擎 - 参照 baoyu-skills 详细模板
"""

import random
from .types import Article, Section


# 完整的风格预设（参照 baoyu-skills/references/presets/）
STYLE_PRESETS = {
    "fresh": {
        "name": "fresh",
        "category": "natural",
        "colors": {
            "primary": "薄荷绿、天空蓝、淡黄色",
            "background": "纯白色、柔和薄荷绿",
            "accents": "叶绿色、水蓝色"
        },
        "visual_elements": "植物叶片、云朵、水滴，简单几何形状，呼吸感构图，开放构图",
        "typography": "干净轻盈的手写字体，透气间距，清新配色",
        "best_layouts": ["sparse", "balanced", "flow"],
        "best_for": "健康生活、极简主义、自然主题"
    },
    "warm": {
        "name": "warm",
        "category": "emotional",
        "colors": {
            "primary": "暖橙色、淡黄色、柔和粉色",
            "background": "米白色、暖奶油色",
            "accents": "珊瑚色、杏色"
        },
        "visual_elements": "柔和曲线、温馨装饰、心形元素、温暖光晕",
        "typography": "圆润温暖的手写字体，友好间距，暖色调",
        "best_layouts": ["sparse", "balanced"],
        "best_for": "情感故事、个人分享、温暖话题"
    },
    "notion": {
        "name": "notion",
        "category": "minimal",
        "colors": {
            "primary": "黑色、深灰色",
            "background": "纯白色、浅灰色",
            "accents": "淡蓝色、淡黄色、淡粉色"
        },
        "visual_elements": "简洁线条、手绘抖动效果、几何形状、 stick figures，最大空白，单一墨水线条",
        "typography": "干净手写字体，简单无衬线标签，最少装饰",
        "best_layouts": ["sparse", "balanced", "dense", "list", "comparison", "flow"],
        "best_for": "知识分享、概念解释、SaaS内容、效率技巧、技术教程"
    },
    "cute": {
        "name": "cute",
        "category": "playful",
        "colors": {
            "primary": "粉色、薄荷绿、淡紫色",
            "background": "纯白色、柔和奶油色",
            "accents": "糖果色、彩虹色"
        },
        "visual_elements": "卡通角色、圆角方块、波点、星星、泡泡、圆润形状",
        "typography": "圆润可爱字体，活泼间距，糖果配色",
        "best_layouts": ["sparse", "balanced", "list"],
        "best_for": "轻松趣味、可爱宠物、少女心"
    }
}

# 完整的布局预设（参照 baoyu-skills/references/elements/canvas.md）
LAYOUT_PRESETS = {
    "sparse": {
        "density": "低信息密度",
        "whitespace": "60-70%",
        "structure": "单个焦点居中，呼吸感构图",
        "best_for": "封面、引用、震撼声明"
    },
    "balanced": {
        "density": "中等信息密度",
        "whitespace": "40-50%",
        "structure": "顶部标题权重，内容均匀分布，清晰的视觉层次",
        "best_for": "标准内容、教程"
    },
    "dense": {
        "density": "高信息密度",
        "whitespace": "20-30%",
        "structure": "组织化的网格结构，清晰的分隔",
        "best_for": "知识卡片、 cheat sheets"
    },
    "list": {
        "density": "高密度",
        "whitespace": "20-30%",
        "structure": "左对齐项目，清晰的数字/圆点层次，一致的项目格式，垂直列表布局，明确的1、2、3、4数字序号",
        "best_for": "排名、检查清单、步骤指南"
    },
    "comparison": {
        "density": "中等密度",
        "whitespace": "30-40%",
        "structure": "左边一个内容块，右边一个内容块，中间用箭头或VS标记分隔",
        "best_for": "前后对比、优缺点"
    },
    "flow": {
        "density": "中等密度",
        "whitespace": "30-40%",
        "structure": "从左到右的水平流程，用箭头或连接线将节点连接",
        "best_for": "流程、时间线、工作流"
    }
}


class PromptEngine:
    """提示词引擎（参照 baoyu-skills）"""
    
    @staticmethod
    def generate_cover_prompt(article: Article, style: str) -> str:
        """
        生成封面图提示词（简化版）
        封面只显示文章标题，不需要章节要点
        """
        style_preset = STYLE_PRESETS.get(style, STYLE_PRESETS["fresh"])
        
        # 封面只显示标题，不显示章节
        # 重要：可以有装饰性图片元素，但不能有额外文字
        prompt = f"""Xiaohongshu cover, {style_preset['name']} style.
Title: {article['title']}
Colors: {style_preset['colors']['primary']}.
Visual elements: {style_preset['visual_elements']}.
Clean design, hand-drawn, big title text.

IMPORTANT: 
- The title should be the ONLY text on the image
- NO subtitle text
- NO additional labels or annotations
- YES to decorative visual elements (plants, shapes, patterns)
- The visual elements should enhance, not contain text"""
        
        return prompt
    
    @staticmethod
    def generate_illustration_prompt(article: Article, style: str, layout: str) -> str:
        """
        生成插图提示词
        参照 baoyu-skills：分离文字内容和视觉概念，引导用图标/符号代替文字
        """
        style_preset = STYLE_PRESETS.get(style, STYLE_PRESETS["fresh"])
        
        # 构建章节内容（作为视觉参考）
        items = []
        for i, section in enumerate(article["sections"], 1):
            content = section["content"].replace("\n", " ").strip()[:50]
            items.append(f"{i}. {section['title']}: {content}")
        
        content_text = " | ".join(items)
        
        prompt = f"""Xiaohongshu infographic, {style_preset['name']} style.

Article: {article['title']}

## Key Sections (use as visual reference)
{content_text}

## Visual Approach
- Use icons, symbols, and visual metaphors to represent each section
- For section titles: use simple hand-drawn lettering style
- Keep text minimal - let visuals tell the story

## Text Rendering (IMPORTANT)
- If you include any text, it must be simple hand-drawn style letters
- Avoid complex text rendering - prefer icons over words
- Title text should be clean, minimal, hand-drawn aesthetic

## Style
Colors: {style_preset['colors']['primary']}.
Visual elements: {style_preset['visual_elements']}.
Clean hand-drawn minimalist design."""
        
        return prompt
    
    @staticmethod
    def generate_decoration_prompt(section: Section, style: str) -> str:
        """
        生成配图提示词
        参照 baoyu-skills：强调用视觉符号代替文字
        """
        style_preset = STYLE_PRESETS.get(style, STYLE_PRESETS["fresh"])
        
        prompt = f"""Create a Xiaohongshu (Little Red Book) style illustration:

## Article Section
Title: {section['title']}
Theme: {section['content'][:100] if section.get('content') else 'general topic'}

## Visual Concept (for AI understanding)
- Interpret the section title and theme into visual symbols
- Use icons, metaphors, or abstract shapes to represent the concept
- Let the image tell the story without text

## Style
- Style: {style_preset['name']} hand-drawn
- Colors: {style_preset['colors']['primary']}
- Background: {style_preset['colors']['background']}
- Visual Elements: {style_preset['visual_elements']}

## Text Rendering (IMPORTANT)
- If you include any text, use simple hand-drawn lettering
- Prefer icons and symbols over words
- Clean, minimalist composition
- Hand-drawn aesthetic

Please generate a decorative illustration."""
        
        return prompt
    
    @staticmethod
    def select_random_sections(sections: list[Section], count: int) -> list[Section]:
        """随机选择章节"""
        if not sections:
            return []
        if len(sections) >= count:
            return random.sample(sections, count)
        else:
            return [sections[i % len(sections)] for i in range(count)]


path: src/types.py
content: """
moyu-make-xhs-pics 类型定义
"""

from typing import TypedDict, Optional


class Section(TypedDict):
    """章节"""
    title: str
    content: str


class Article(TypedDict):
    """文章"""
    title: str
    sections: list[Section]
    summary: str


class ImageResult(TypedDict):
    """图片生成结果"""
    success: bool
    image_url: Optional[str]
    local_path: Optional[str]
    error: Optional[str]


class ArticleImagesResult(TypedDict):
    """文章图片生成结果"""
    success: bool
    covers: list[str]
    illustrations: list[str]
    decorations: list[str]
    total: int
    error: Optional[str]


# 风格定义
STYLES = {
    "fresh": {
        "keywords": ["清新", "自然", "干净", "明亮", "通透"],
        "description": "清新自然的风格，适合生活分享类内容"
    },
    "warm": {
        "keywords": ["温暖", "友好", "柔和", "温馨", "舒适"],
        "description": "温暖友好的风格，适合情感类内容"
    },
    "notion": {
        "keywords": ["极简", "手绘", "知识感", "专业", "简洁"],
        "description": "极简手绘风格，适合知识干货类内容"
    },
    "cute": {
        "keywords": ["可爱", "卡通", "萌趣", "活泼", "卡通风格"],
        "description": "可爱卡通风格，适合轻松趣味类内容"
    }
}

# 布局定义
LAYOUTS = {
    "balanced": "信息均匀分布在画面中，画面整洁平衡",
    "list": "垂直列表布局，明确的1、2、3、4数字序号或圆点标记，每个要点独立一行，清晰的条目分隔线，视觉上垂直排列",
    "comparison": "左右对比布局，左边一个内容右边一个内容，中间用箭头或VS标记分隔，显示所有对比项",
    "flow": "从左到右的水平流程布局，清晰的步骤1→步骤2→步骤3→步骤4箭头连接，显示完整流程"
}

VALID_STYLES = list(STYLES.keys())
VALID_LAYOUTS = list(LAYOUTS.keys()) + ["auto"]


path: src/watermark.py
content: """
水印处理模块
"""

import random
from pathlib import Path
from typing import Optional

# 安装 Pillow: pip install Pillow
try:
    from PIL import Image, ImageDraw, ImageFont
    HAS_PIL = True
except ImportError:
    HAS_PIL = False
    print("Warning: Pillow not installed, watermark will not be added")

# 水印文字
WATERMARK_TEXT = "AI 生成"

# 字体路径
FONT_PATHS = [
    "/System/Library/Fonts/STHeiti Medium.ttc",  # 黑体
    "/System/Library/Fonts/STHeiti Light.ttc",
    "/System/Library/Fonts/Helvetica.ttc",
]


def get_font(size: int):
    """获取字体"""
    for font_path in FONT_PATHS:
        try:
            return ImageFont.truetype(font_path, size)
        except:
            continue
    return ImageFont.load_default()


def add_watermark(
    image_path: str,
    text: str = WATERMARK_TEXT,
    position: Optional[str] = None
) -> str:
    """
    添加水印
    
    Args:
        image_path: 图片路径
        text: 水印文字
        position: 位置 ("left_bottom", "right_bottom", None=随机)
    
    Returns:
        处理后的图片路径
    """
    if not HAS_PIL:
        return image_path
    
    try:
        img = Image.open(image_path)
        draw = ImageDraw.Draw(img)
        
        width, height = img.size
        font_size = max(16, width // 30)
        font = get_font(font_size)
        
        # 获取文字尺寸
        bbox = draw.textbbox((0, 0), text, font=font)
        text_width = bbox[2] - bbox[0]
        text_height = bbox[3] - bbox[1]
        
        # 计算位置
        padding = 20
        
        if position is None:
            position = random.choice(["left_bottom", "right_bottom"])
        
        if position == "left_bottom":
            x = padding
            y = height - text_height - padding
        else:  # right_bottom
            x = width - text_width - padding
            y = height - text_height - padding
        
        # 绘制文字（带阴影效果）
        shadow_color = (0, 0, 0)
        text_color = (255, 255, 255)
        
        # 阴影
        draw.text((x + 2, y + 2), text, fill=shadow_color, font=font)
        # 文字
        draw.text((x, y), text, fill=text_color, font=font)
        
        # 保存
        img.save(image_path)
        return image_path
        
    except Exception as e:
        print(f"添加水印失败: {e}")
        return image_path


path: tests/test_api.py
content: import os
import pytest
import sys
import asyncio
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

# 尝试导入 API 调用相关的函数
try:
    from scripts.image_gen import generate_with_minimax, generate_with_dashscope
except ImportError:
    # 如果导入失败，创建模拟函数用于测试
    def generate_with_minimax(*args, **kwargs):
        raise NotImplementedError("generate_with_minimax not implemented")
    
    def generate_with_dashscope(*args, **kwargs):
        raise NotImplementedError("generate_with_dashscope not implemented")


# 检查环境变量，决定是否跳过测试
MINIMAX_API_KEY = os.environ.get('MINIMAX_API_KEY')
DASHSCOPE_API_KEY = os.environ.get('DASHSCOPE_API_KEY')

skip_minimax = pytest.mark.skipif(
    not MINIMAX_API_KEY, 
    reason="MINIMAX_API_KEY 环境变量未设置，跳过 MiniMax API 测试"
)

skip_dashscope = pytest.mark.skipif(
    not DASHSCOPE_API_KEY, 
    reason="DASHSCOPE_API_KEY 环境变量未设置，跳过阿里百炼 API 测试"
)


@skip_minimax
def test_minimax_api():
    """测试 MiniMax API 调用（需要环境变量 MINIMAX_API_KEY）"""
    # 测试基本参数验证
    if MINIMAX_API_KEY:
        # 测试函数是否存在
        assert callable(generate_with_minimax), "generate_with_minimax 函数不存在"
        
        # 如果函数实现完成，可以添加实际调用测试
        # 注意：这里只测试函数是否能正确处理参数，不实际发起网络请求
        try:
            # 尝试调用函数（可能会因为网络或其他原因失败，但在CI环境中我们主要检查接口是否可用）
            pass
        except NotImplementedError:
            # 如果函数未实现，则跳过进一步测试
            pytest.skip("generate_with_minimax 函数未实现")
        except Exception as e:
            # 对于API测试，由于网络等因素，可能会出现异常，这里主要是确保函数签名正确
            # 如果是连接错误或其他网络相关错误，也属于正常情况
            if "not implemented" in str(e).lower():
                pytest.skip(f"功能未实现: {e}")
            else:
                # 记录错误但不失败测试，因为API调用可能因网络等原因失败
                pass
    else:
        pytest.skip("MINIMAX_API_KEY 未设置")


@skip_dashscope
def test_dashscope_api():
    """测试阿里百炼 API 调用（需要环境变量 DASHSCOPE_API_KEY）"""
    # 测试基本参数验证
    if DASHSCOPE_API_KEY:
        # 测试函数是否存在
        assert callable(generate_with_dashscope), "generate_with_dashscope 函数不存在"
        
        # 如果函数实现完成，可以添加实际调用测试
        try:
            # 尝试调用函数（可能会因为网络或其他原因失败，但在CI环境中我们主要检查接口是否可用）
            pass
        except NotImplementedError:
            # 如果函数未实现，则跳过进一步测试
            pytest.skip("generate_with_dashscope 函数未实现")
        except Exception as e:
            # 对于API测试，由于网络等因素，可能会出现异常，这里主要是确保函数签名正确
            if "not implemented" in str(e).lower():
                pytest.skip(f"功能未实现: {e}")
            else:
                # 记录错误但不失败测试，因为API调用可能因网络等原因失败
                pass
    else:
        pytest.skip("DASHSCOPE_API_KEY 未设置")

path: tests/test_layout.py
content: """
测试：布局选择器
"""

import pytest
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from src.layout_selector import LayoutSelector


class TestLayoutSelector:
    """布局选择器测试"""
    
    def test_auto_select_comparison(self):
        """测试自动选择对比布局"""
        article = {
            "title": "对比测试",
            "sections": [
                {"title": "比较", "content": "A和B的差异是什么"}
            ],
            "summary": "测试"
        }
        
        layout = LayoutSelector.auto_select(article)
        assert layout == "comparison"
    
    def test_auto_select_flow(self):
        """测试自动选择流程布局"""
        article = {
            "title": "安装教程",
            "sections": [
                {"title": "步骤", "content": "首先下载，然后安装，接着配置，最后运行"}
            ],
            "summary": "测试"
        }
        
        layout = LayoutSelector.auto_select(article)
        assert layout == "flow"
    
    def test_auto_select_list(self):
        """测试自动选择列表布局"""
        article = {
            "title": "要点总结",
            "sections": [
                {"title": "要点", "content": "1. 第一点 2. 第二点 3. 第三点"}
            ],
            "summary": "测试"
        }
        
        layout = LayoutSelector.auto_select(article)
        assert layout == "list"
    
    def test_auto_select_default(self):
        """测试默认布局"""
        article = {
            "title": "普通文章",
            "sections": [
                {"title": "内容", "content": "这是一段普通的内容"}
            ],
            "summary": "测试"
        }
        
        layout = LayoutSelector.auto_select(article)
        assert layout == "balanced"
    
    def test_is_valid_layout(self):
        """测试布局验证"""
        assert LayoutSelector.is_valid_layout("balanced")
        assert LayoutSelector.is_valid_layout("list")
        assert LayoutSelector.is_valid_layout("comparison")
        assert LayoutSelector.is_valid_layout("flow")
        assert LayoutSelector.is_valid_layout("auto")
        assert not LayoutSelector.is_valid_layout("invalid")
    
    def test_resolve_layout(self):
        """测试布局解析"""
        article = {
            "title": "测试",
            "sections": [{"title": "A", "content": "内容"}],
            "summary": "测试"
        }
        
        # auto 应该解析为具体布局
        layout = LayoutSelector.resolve_layout("auto", article)
        assert layout in ["balanced", "list", "comparison", "flow"]
        
        # 其他直接返回
        layout = LayoutSelector.resolve_layout("list", article)
        assert layout == "list"


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


path: tests/test_parser.py
content: """
测试：文章解析
"""

import pytest
import tempfile
from pathlib import Path

import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from src.article_parser import ArticleParser
from src.types import Article


class TestArticleParser:
    """文章解析测试"""
    
    def test_parse_simple_article(self):
        """测试解析简单文章"""
        content = """# 测试标题

这是第一段内容。

## 第一章

这是第一章的内容。

## 第二章

这是第二章的内容。
"""
        
        with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
            f.write(content)
            f.flush()
            
            article = ArticleParser.parse(f.name)
            
            # 验证标题
            assert article["title"] == "测试标题"
            
            # 验证章节数
            assert len(article["sections"]) >= 1
            
            Path(f.name).unlink()
    
    def test_parse_no_title(self):
        """测试解析无标题文章"""
        content = """这是第一段内容。

## 第一章

这是第一章的内容。
"""
        
        with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
            f.write(content)
            f.flush()
            
            article = ArticleParser.parse(f.name)
            
            # 应该有默认标题
            assert article["title"] != ""
            
            Path(f.name).unlink()
    
    def test_extract_all_content(self):
        """测试提取全部内容"""
        article = {
            "title": "测试标题",
            "sections": [
                {"title": "第一章", "content": "内容一"},
                {"title": "第二章", "content": "内容二"}
            ],
            "summary": "测试"
        }
        
        content = ArticleParser.extract_all_content(article)
        
        assert "测试标题" in content
        assert "第一章" in content
        assert "内容一" in content
    
    def test_get_plain_text(self):
        """测试去除 Markdown 语法"""
        md = """# 标题

这是**加粗**文本，这是[链接](http://example.com)。

```python
print("hello")
```
"""
        
        text = ArticleParser.get_plain_text(md)
        
        assert "#" not in text
        assert "**" not in text
        assert "[" not in text
        assert "```" not in text


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


path: tests/test_prompt.py
content: """
测试：提示词引擎
"""

import pytest
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from src.prompt_engine import PromptEngine, STYLE_PRESETS, LAYOUT_PRESETS


class TestPromptEngine:
    """提示词引擎测试"""
    
    def test_generate_cover_prompt(self):
        """测试封面图提示词"""
        article = {
            "title": "AI时代读代码的正确姿势",
            "sections": [
                {"title": "第一章", "content": "内容一"},
                {"title": "第二章", "content": "内容二"}
            ],
            "summary": "测试"
        }
        
        prompt = PromptEngine.generate_cover_prompt(article, "notion")
        
        # 验证包含标题
        assert "AI时代读代码的正确姿势" in prompt
        # 验证包含风格关键词
        assert "notion" in prompt.lower() or "极简" in prompt or "手绘" in prompt
        # 验证包含章节要点
        assert "第一章" in prompt or "第二章" in prompt
    
    def test_generate_illustration_prompt(self):
        """测试插图提示词"""
        article = {
            "title": "安装XX软件",
            "sections": [
                {"title": "步骤1", "content": "下载"},
                {"title": "步骤2", "content": "安装"}
            ],
            "summary": "测试"
        }
        
        prompt = PromptEngine.generate_illustration_prompt(article, "notion", "flow")
        
        # 验证包含章节内容
        assert "步骤1" in prompt or "Point 1" in prompt
        # 验证包含布局关键词
        assert "flow" in prompt.lower() or "Flow" in prompt or "流程" in prompt
    
    def test_generate_decoration_prompt(self):
        """测试配图提示词"""
        section = {
            "title": "环境准备",
            "content": "需要安装Python环境"
        }
        
        prompt = PromptEngine.generate_decoration_prompt(section, "cute")
        
        # 验证包含章节内容
        assert "环境准备" in prompt or "Python" in prompt
        # 验证包含风格
        assert "cute" in prompt.lower() or "可爱" in prompt or "卡通" in prompt
    
    def test_all_styles(self):
        """测试所有风格"""
        article = {
            "title": "测试",
            "sections": [
                {"title": "第一章", "content": "内容"}
            ],
            "summary": "测试"
        }
        
        for style in STYLE_PRESETS.keys():
            prompt = PromptEngine.generate_cover_prompt(article, style)
            assert len(prompt) > 0
            assert style in STYLE_PRESETS
    
    def test_all_layouts(self):
        """测试所有布局"""
        article = {
            "title": "测试",
            "sections": [
                {"title": "第一步", "content": "下载"},
                {"title": "第二步", "content": "安装"}
            ],
            "summary": "测试"
        }
        
        for layout in LAYOUT_PRESETS.keys():
            prompt = PromptEngine.generate_illustration_prompt(article, "notion", layout)
            assert len(prompt) > 0
    
    def test_select_random_sections(self):
        """测试随机选择章节"""
        sections = [
            {"title": "第一章", "content": "内容1"},
            {"title": "第二章", "content": "内容2"},
            {"title": "第三章", "content": "内容3"}
        ]
        
        # 测试选择数量大于章节数
        selected = PromptEngine.select_random_sections(sections, 5)
        assert len(selected) == 5
        
        # 测试选择数量小于章节数
        selected = PromptEngine.select_random_sections(sections, 2)
        assert len(selected) == 2


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


path: tests/test_style.py
content: import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from scripts.image_gen import generate_prompt


def test_style_keywords():
    """验证 3 种风格（warm/notion/fresh）是否正确定义"""
    styles = {
        'warm': ['温暖', '柔和', '温馨', '舒适', '自然'],
        'notion': ['简洁', '现代', '极简', '清晰', '有序'],
        'fresh': ['清新', '明亮', '活力', '清爽', '青春']
    }
    
    assert 'warm' in styles
    assert 'notion' in styles
    assert 'fresh' in styles
    
    # 验证每种风格都有对应的关键词
    for style, keywords in styles.items():
        assert len(keywords) > 0
        assert isinstance(keywords, list)
        for keyword in keywords:
            assert isinstance(keyword, str)


def test_aspect_ratio():
    """验证 720x1200 符合 3:5 比例且是 8 的倍数"""
    width = 720
    height = 1200
    
    # 检查是否符合 3:5 比例
    ratio = width / height
    expected_ratio = 3 / 5
    assert abs(ratio - expected_ratio) < 0.0001, f"比例 {ratio} 不等于 3:5"
    
    # 检查宽高是否都是 8 的倍数
    assert width % 8 == 0, f"宽度 {width} 不是 8 的倍数"
    assert height % 8 == 0, f"高度 {height} 不是 8 的倍数"


def test_generate_prompt():
    """测试提示词生成函数"""
    # 测试基本功能
    prompt = generate_prompt("测试主题", "warm")
    assert isinstance(prompt, str)
    assert "测试主题" in prompt
    assert len(prompt) > 0
    
    # 测试不同风格
    for style in ['warm', 'notion', 'fresh']:
        prompt = generate_prompt("主题", style)
        assert isinstance(prompt, str)
        assert "主题" in prompt
        
    # 测试空输入
    try:
        empty_prompt = generate_prompt("", "warm")
        # 如果函数能处理空输入则继续
    except Exception:
        # 如果函数不能处理空输入则正常
        pass

path: tests/test_types.py
content: """
测试：类型和常量
"""

import pytest
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

from src.types import STYLES, LAYOUTS, VALID_STYLES, VALID_LAYOUTS


class TestTypes:
    """类型测试"""
    
    def test_styles(self):
        """测试风格定义"""
        assert "fresh" in STYLES
        assert "warm" in STYLES
        assert "notion" in STYLES
        assert "cute" in STYLES
        
        # 验证每个风格都有 keywords
        for style in STYLES.values():
            assert "keywords" in style
            assert len(style["keywords"]) > 0
    
    def test_layouts(self):
        """测试布局定义"""
        assert "balanced" in LAYOUTS
        assert "list" in LAYOUTS
        assert "comparison" in LAYOUTS
        assert "flow" in LAYOUTS
        
        # 验证每个布局都有描述
        for layout in LAYOUTS.values():
            assert len(layout) > 0
    
    def test_valid_styles(self):
        """测试有效风格列表"""
        assert "fresh" in VALID_STYLES
        assert "warm" in VALID_STYLES
        assert "notion" in VALID_STYLES
        assert "cute" in VALID_STYLES
        assert len(VALID_STYLES) == 4
    
    def test_valid_layouts(self):
        """测试有效布局列表"""
        assert "balanced" in VALID_LAYOUTS
        assert "list" in VALID_LAYOUTS
        assert "comparison" in VALID_LAYOUTS
        assert "flow" in VALID_LAYOUTS
        assert "auto" in VALID_LAYOUTS


class TestWatermark:
    """水印测试"""
    
    def test_add_watermark(self):
        """测试添加水印"""
        # 这个测试需要实际的图片文件
        # 这里只测试函数可以调用
        from src import watermark
        
        # 测试水印文字
        assert watermark.WATERMARK_TEXT == "AI 生成"


if __name__ == "__main__":
    pytest.main([__file__, "-v"])


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)