Skill: yuesf/wechat-publish-pro (version 0.3.0)

# wechat-publish-pro Skill 微信公众号文章发布工具，完全独立实现，不依赖任何外部 CLI 工具。 ## 功能特性 - **纯 Python 实现**: 不依赖 wenyan-cli 或其他外部工具 - **Markdown 转换**: 内置 Markdown → HTML 转换，参考 wenyan-cli 排版风格 - **多主题支持**: 多种精美主题（green、blue、purple、orange、default、simple） - **代码高亮**: Mac 风格代码块，语法高亮 - **封面生成**: 自动处理封面图，支持本地/网络图片 - **直接发布**: 一键发布到微信公众号草稿箱 ## 触发条件 当用户需要以下操作时自动触发： - 将 Markdown 或 HTML 文章发布到微信公众号草稿箱 - 转换 Markdown 为公众号格式 HTML - 使用特定主题发布公众号文章 - 测试微信连接 ## 安装 ```bash # 从 GitHub 安装 pip install git+https://github.com/yuesf/wechat-publish-pro.git ``` ## 配置 ### 第一步：获取微信公众号凭证 1. 登录 [https://developers.weixin.qq.com/](https://developers.weixin.qq.com/) 公众号平台 2. 获取 **AppID** 和 **AppSecret** ### 第二步：配置凭证 #### 环境变量 ```bash export WECHAT_APP_ID=your_wechat_app_id export WECHAT_APP_SECRET=your_wechat_app_secret ``` #### 配置文件 ```bash # 初始化配置 wechat-publish-pro config init # 设置配置 wechat-publish-pro config set wechat.app_id <AppID> wechat-publish-pro config set wechat.app_secret <AppSecret> ``` ### 第三步：设置 IP 白名单 把运行机器的 IP 添加到微信公众号后台白名单： 1. 登录 https://mp.weixin.qq.com/ 2. 设置与开发 → 基本配置 → IP白名单 ## 使用方式 ### 通过 OpenClaw 直接发送 在 OpenClaw 对话中直接说： - "把这篇文章发到公众号" - "用蓝色主题发布" - "帮我发布到微信，测试一下" ### 命令行方式 #### Markdown 转换 ```bash # 基本转换（默认绿色主题） wechat-publish-pro convert article.md # 指定蓝色主题 wechat-publish-pro convert article.md --theme blue # 指定输出文件 wechat-publish-pro convert article.md -o output.html ``` #### 发布到微信 ```bash # 发布 HTML 文件到草稿箱（需要指定账号，否则会卡在交互提示） wechat-publish-pro publish article.html --account <账号标识> # 指定标题和封面 wechat-publish-pro publish article.html --title "文章标题" --cover cover.jpg --account <账号标识> ``` **常见问题：** - **发布命令卡住不动**：确保使用 `--account` 参数指定账号，避免交互式选择 - **封面上传失败**：确认封面图片路径存在，不存在时省略 `--cover` 参数 #### 一站式：Markdown → 转换 → 发布 ```bash # 转换后直接发布 wechat-publish-pro convert article.md --theme blue -o /tmp/article.html wechat-publish-pro publish /tmp/article.html --title "文章标题" ``` #### 测试连接 ```bash wechat-publish-pro test ``` #### 上传图片 ```bash wechat-publish-pro upload-image image.jpg ``` ## Markdown 格式 文件顶部可以包含 frontmatter（建议只用 cover，不用 title，避免重复）： ```markdown <!-- title: 文章标题 --- cover: ./assets/cover.jpg --- 你的内容... ``` **重要注意事项**： - **不要在正文使用 `#` 大标题**——标题由命令行 `-t` 参数提供，正文里的 `#` 标题会导致标题重复输出 - **不要使用 `---` 作为章节分隔线**——wenyan-cli 会将其误解析为列表项 `• --`，用空行替代 `---` - **frontmatter 的 `title` 会被输出到正文**——如果同时在正文写了 `#` 标题，会导致两个标题重复；建议 frontmatter 的 title 用 `<!-- -->` 包裹，或完全不用 frontmatter 的 title，改由命令行 `-t` 提供标题 **推荐格式**： ```markdown <!-- title: 文章标题（可选，用命令行 -t 更稳定） cover: ./assets/cover.jpg --- 第一段内容（直接开始，不要用 `#` 标题） ## 章节标题（使用 ## ，不是 #） 内容... （章节之间用空行分隔，不要用 ---） ``` ## 可用主题 | 主题 | 风格 | |------|------| | green | 清新自然（绿色，默认） | | blue | 清新专业（蓝色） | | purple | 优雅神秘（紫色） | | orange | 温暖活力（橙色） | | default | 简洁清爽 | | simple | 极简风格 | ## 故障排查 ### 错误：45166 (IP地址不在白名单中) - 原因：运行机器的 IP 未添加到微信白名单 - 解决：登录公众号后台添加 IP 到白名单 ### 发布成功但看不到文章？ - 原因：文章在草稿箱，需要审核发布 - 解决：草稿箱 → 选中文章 → 发布 ## 架构说明 ``` wechat-publish-pro/ ├── src/wechat_publish_pro/ │ ├── cli.py # CLI 入口 │ ├── config.py # 配置管理 │ ├── converter/ # Markdown 转换模块 │ │ ├── wechat_style.py # 微信公众号样式转换器 │ │ ├── themes.py # 主题配置 │ │ └── theme_types.py # 主题数据类型 │ └── platforms/ # 平台适配器 │ └── wechat.py # 微信公众号 API └── pyproject.toml ``` ## 设计参考 本项目参考了以下优秀项目的设计思路： - **wenyan-cli**: Markdown 转微信 HTML 的排版思路 - **multi-writing-skills**: 转换器和主题系统的实现 但本项目是完全独立实现的，不依赖任何外部代码。 ## 作者 yuesf ## 许可证 MIT

path: CLAUDE.md
content: # CLAUDE.md

wechat-publish-pro 项目开发维护指南。

## 项目结构

```
wechat-publish-pro/
├── SKILL.md              # Skill 使用文档
├── scripts/
│   └── publish.sh        # 发布脚本（Markdown → HTML → 微信）
├── src/wechat_publish_pro/
│   ├── __init__.py
│   ├── cli.py            # CLI 入口 (typer)
│   ├── config.py         # 配置管理
│   └── platforms/        # 平台适配器
│       ├── base.py       # 抽象基类
│       ├── __init__.py
│       └── wechat.py     # 微信公众号实现
└── pyproject.toml
```

## 常用命令

```bash
# 安装/更新
pip install .

# 开发模式安装
pip install -e .

# 测试 CLI
wechat-publish-pro --help
wechat-publish-pro config show
wechat-publish-pro test

# 测试 wenyan-cli
wenyan render -f example.md -t lapis
```

## 依赖

- **wenyan-cli**: Node.js CLI，`npm install -g @wenyan-md/cli`
- **wechat-publish-pro**: Python 包，本项目

## 微信公众号 API

- 草稿箱 API: `POST /cgi-bin/draft/add`
- 素材上传: `POST /cgi-bin/material/add_material`
- Token 获取: `GET /cgi-bin/token`

## 故障排查

1. **Import Error**: 确保 pyproject.toml 中 packages 路径正确
2. **CLI 找不到**: `pip install .` 后重新加载 PATH 或检查 entry_points
3. **微信 45166**: IP 不在白名单，添加当前 IP 到公众号后台


path: README.md
content: # wechat-publish-pro

把 Markdown 文章转换为微信公众号格式并发布到草稿箱。

## 装上

```bash
pip install git+https://github.com/yuesf/wechat-publish-pro.git
```

## 配一下

### 第一步：获取微信公众号凭证

1. 登录 [https://developers.weixin.qq.com/](https://developers.weixin.qq.com/) 公众号平台
2. 获取 **AppID** 和 **AppSecret**

### 第二步：配置凭证

```bash
# 初始化
wechat-publish-pro config init

# 微信公众号的凭证
wechat-publish-pro config set wechat.app_id <你的AppID>
wechat-publish-pro config set wechat.app_secret <你的AppSecret>
```

### 第三步：设置 IP 白名单

把运行机器的 IP 添加到微信公众号后台白名单：
1. 登录 https://mp.weixin.qq.com/
2. 设置与开发 → 基本配置 → IP白名单

## 用一下

### 通过 OpenClaw 直接发送

在 OpenClaw 对话中直接说：
- "把这篇文章发到公众号"
- "用蓝色主题发布"
- "帮我发布到微信，测试一下"

### 命令行方式

```bash
# 把 HTML 文章发到公众号草稿箱
wechat-publish-pro publish article.html

# 指定标题和封面
wechat-publish-pro publish article.html --title "文章标题" --cover cover.jpg

# 看看配置对不对
wechat-publish-pro config show

# 测试微信连接
wechat-publish-pro test
```

### Markdown 转换

```bash
# 把 Markdown 转成 HTML（默认绿色主题）
wechat-publish-pro convert article.md

# 指定蓝色主题
wechat-publish-pro convert article.md --theme blue

# 转换后发布
wechat-publish-pro convert article.md -o /tmp/article.html
wechat-publish-pro publish /tmp/article.html
```

## 环境变量

不想用 config 命令？直接环境变量：

```bash
export WECHAT_APP_ID=xxx
export WECHAT_APP_SECRET=xxx
```

## 可用主题

| 主题 | 风格 |
|------|------|
| green | 清新自然（绿色，默认） |
| blue | 清新专业（蓝色） |
| purple | 优雅神秘（紫色） |
| orange | 温暖活力（橙色） |
| default | 简洁清爽 |
| simple | 极简风格 |

## 开发

```bash
git clone https://github.com/yuesf/wechat-publish-pro.git
cd wechat-publish-pro
pip install -e .
wechat-publish-pro --help
```

## 从 multi-writing-skills 剥离

这个项目从 [multi-writing-skills](https://github.com/yuesf/multi-writing-skills) 里剥离出来的，专门用来发公众号。

## License

MIT


path: example.md
content: ---
title: 测试文章 - wechat-publish-pro
cover: ./assets/cover.jpg
author: yuesf
---

# 欢迎使用 wechat-publish-pro

这是一篇测试文章，用于验证微信公众号发布功能。

## 功能特性

- Markdown 转 HTML
- 多主题支持
- 图片自动上传
- 一键发布到草稿箱

## 代码示例

```python
def hello():
    print("Hello, WeChat!")
```

## 总结

wechat-publish-pro 让微信公众号发布变得更简单！


path: pyproject.toml
content: [project]
name = "wechat-publish-pro"
version = "0.1.0"
description = "微信公众号文章发布工具，支持 Markdown 转换、多主题美化、一键发布到草稿箱"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
    "httpx>=0.25.0",
    "pydantic>=2.0.0",
    "pydantic-settings>=2.0.0",
    "pyyaml>=6.0",
    "pillow>=10.0.0",
    "typer>=0.9.0",
    "rich>=13.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0.0",
    "pytest-asyncio>=0.21.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project.scripts]
wechat-publish-pro = "wechat_publish_pro.cli:app"

[tool.hatch.build.targets.wheel]
packages = ["src/wechat_publish_pro"]

[tool.hatch.build.targets.wheel.sources]
"src/wechat_publish_pro" = "wechat_publish_pro"


path: scripts/publish.sh
content: #!/bin/bash
#===============================================================================
# wechat-publish-pro - 微信公众号发布脚本
# 
# 完全独立实现，不依赖任何外部 CLI 工具
# 
# 使用方式: ./publish.sh <markdown_or_html_file>
# 示例: 
#   ./publish.sh article.html
#   ./publish.sh article.md
#===============================================================================

set -e

# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# 帮助信息
show_help() {
    cat << EOF
${BLUE}wechat-publish-pro - 微信公众号发布工具${NC}

${YELLOW}使用方法:${NC}
    $0 <file> [options]

${YELLOW}参数:${NC}
    file             HTML 或 Markdown 文件路径（必填）

${YELLOW}选项:${NC}
    --title <title>     指定文章标题
    --cover <path>      指定封面图片

${YELLOW}环境变量:${NC}
    WECHAT_APP_ID              微信 AppID
    WECHAT_APP_SECRET          微信 AppSecret

${YELLOW}示例:${NC}
    $0 article.html
    $0 article.html --title "我的文章"

EOF
}

# 日志函数
log_info() {
    echo -e "${BLUE}[INFO]${NC} $1"
}

log_success() {
    echo -e "${GREEN}[SUCCESS]${NC} $1"
}

log_warning() {
    echo -e "${YELLOW}[WARNING]${NC} $1"
}

log_error() {
    echo -e "${RED}[ERROR]${NC} $1" >&2
}

# 检查依赖
check_dependencies() {
    log_info "检查依赖..."
    
    if ! command -v python3 &> /dev/null; then
        log_error "python3 未安装"
        exit 1
    fi
    
    if ! python3 -c "import wechat_publish_pro" 2>/dev/null; then
        log_error "wechat-publish-pro 未安装，请运行: pip install wechat-publish-pro"
        exit 1
    fi
    
    log_success "依赖检查通过"
}

# 加载环境变量
load_env() {
    local env_file="$HOME/.openclaw/.env"
    if [[ -f "$env_file" ]]; then
        log_info "从 $env_file 加载环境变量"
        set -a
        source "$env_file"
        set +a
    fi
}

# 主流程
main() {
    local file=""
    local title=""
    local cover=""
    
    # 解析参数
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help)
                show_help
                exit 0
                ;;
            --title)
                title="$2"
                shift 2
                ;;
            --cover)
                cover="$2"
                shift 2
                ;;
            -*)
                log_error "未知选项: $1"
                show_help
                exit 1
                ;;
            *)
                if [[ -z "$file" ]]; then
                    file="$1"
                fi
                shift
                ;;
        esac
    done
    
    # 检查必填参数
    if [[ -z "$file" ]]; then
        log_error "缺少必填参数：file"
        show_help
        exit 1
    fi
    
    # 检查文件是否存在
    if [[ ! -f "$file" ]]; then
        log_error "文件不存在: $file"
        exit 1
    fi
    
    # 转换为绝对路径
    file="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")"
    log_info "文件: $file"
    
    # 检查依赖
    check_dependencies
    
    # 加载环境变量
    load_env
    
    # 构建发布命令
    local publish_cmd="wechat-publish-pro publish \"$file\""
    
    [[ -n "$title" ]] && publish_cmd="$publish_cmd --title \"$title\""
    [[ -n "$cover" ]] && publish_cmd="$publish_cmd --cover \"$cover\""
    
    log_info "执行: $publish_cmd"
    
    # 执行发布命令
    eval $publish_cmd
    
    log_success "发布完成！"
    log_info "请前往微信公众号后台草稿箱查看: https://mp.weixin.qq.com/"
}

main "$@"


path: src/wechat_publish_pro/__init__.py
content: """
wechat-publish-pro - 微信公众号文章发布工具
"""

__version__ = "0.1.0"


path: src/wechat_publish_pro/cli.py
content: """
CLI 入口 - 支持多账号
"""

import asyncio
import re
from pathlib import Path
from typing import Optional

import httpx
from PIL import Image, ImageDraw
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt, Confirm
import typer

from .config import settings, WeChatAccount
from .converter import WeChatStyleConverter
from .platforms import WeChatPlatform, PublishRequest

app = typer.Typer(
    name="wechat-publish-pro",
    help="微信公众号文章发布工具",
)
console = Console()


def select_account() -> WeChatAccount:
    """让用户选择要使用的账号"""
    settings.load()
    account_names = settings.get_account_names()
    
    if not account_names:
        raise RuntimeError("未配置任何微信公众号账号，请先使用 config add-account 添加账号")
    
    # 只有一个账号，直接使用
    if len(account_names) == 1:
        account_key = account_names[0]
        console.print(f"[cyan]使用账号: {settings.get_account(account_key).name}[/cyan]")
        return settings.get_account(account_key)
    
    # 多个账号，让用户选择
    default_key = settings.get_default_account() or account_names[0]
    choices = [settings.get_account(k).name for k in account_names]
    choice_map = dict(zip(account_names, choices))
    
    # 显示账号列表
    console.print("[cyan]可用账号:[/cyan]")
    for i, key in enumerate(account_names, 1):
        acc = settings.get_account(key)
        marker = " (默认)" if key == default_key else ""
        console.print(f"  {i}. {acc.name}{marker}")
    
    # 提示选择
    choice = Prompt.ask(
        "\n选择要使用的账号",
        default=str(account_names.index(default_key) + 1),
        show_choices=False
    )
    
    # 解析选择
    try:
        idx = int(choice) - 1
        if 0 <= idx < len(account_names):
            account_key = account_names[idx]
        else:
            account_key = default_key
    except ValueError:
        account_key = default_key
    
    account = settings.get_account(account_key)
    console.print(f"[green]使用账号: {account.name}[/green]\n")
    return account


def create_wechat_platform(account: WeChatAccount) -> WeChatPlatform:
    """创建微信平台实例"""
    if not account.app_id or not account.app_secret:
        raise RuntimeError(f"账号 '{account.name}' 未配置 app_id 或 app_secret")
    return WeChatPlatform(
        app_id=account.app_id,
        app_secret=account.app_secret,
    )


# 配置命令组
config_app = typer.Typer(help="配置管理")
app.add_typer(config_app, name="config")


@config_app.command("init")
def config_init() -> None:
    """初始化配置文件"""
    settings.save()
    console.print(f"[green]配置文件已创建: {settings.get_config_file()}[/green]")


@config_app.command("show")
def config_show() -> None:
    """显示当前配置"""
    settings.load()

    table = Table(title="配置信息")
    table.add_column("配置项", style="cyan")
    table.add_column("值", style="green")

    table.add_row("配置文件", str(settings.get_config_file()))
    
    # 显示账号列表
    account_names = settings.get_account_names()
    if account_names:
        table.add_row("微信公众号账号", f"共 {len(account_names)} 个")
        default_key = settings.get_default_account()
        for key in account_names:
            acc = settings.get_account(key)
            marker = " (默认)" if key == default_key else ""
            table.add_row(f"  - {key}", f"{acc.name}{marker}")
    else:
        table.add_row("微信公众号账号", "[red]未配置[/red]")
    
    console.print(table)


@config_app.command("add-account")
def config_add_account(
    key: str = typer.Argument(..., help="账号标识（如: personal, work）"),
    name: str = typer.Option(..., "--name", "-n", help="账号显示名称"),
    app_id: str = typer.Option(..., "--app-id", help="微信公众号 AppID"),
    app_secret: str = typer.Option(..., "--app-secret", help="微信公众号 AppSecret"),
) -> None:
    """添加微信公众号账号
    
    示例:
        wechat-publish-pro config add-account personal --name "个人公众号" --app-id xxx --app-secret xxx
    """
    settings.load()
    settings.add_account(key, name, app_id, app_secret)
    settings.save()
    console.print(f"[green]已添加账号: {name} (key: {key})[/green]")


@config_app.command("remove-account")
def config_remove_account(
    key: str = typer.Argument(..., help="账号标识"),
) -> None:
    """删除微信公众号账号"""
    settings.load()
    if key not in settings.get_account_names():
        console.print(f"[red]账号 {key} 不存在[/red]")
        raise typer.Exit(1)
    
    name = settings.get_account(key).name
    settings.remove_account(key)
    settings.save()
    console.print(f"[green]已删除账号: {name}[/green]")


@config_app.command("set-default")
def config_set_default(
    key: str = typer.Argument(..., help="账号标识"),
) -> None:
    """设置默认账号"""
    settings.load()
    if key not in settings.get_account_names():
        console.print(f"[red]账号 {key} 不存在[/red]")
        raise typer.Exit(1)
    
    settings.set_default_account(key)
    settings.save()
    console.print(f"[green]已设置默认账号: {settings.get_account(key).name}[/green]")


@config_app.command("set")
def config_set(
    key: str = typer.Argument(..., help="配置键，格式: section.key"),
    value: str = typer.Argument(..., help="配置值"),
) -> None:
    """设置配置项

    示例:
        wechat-publish-pro config set account.name 默认账号
    """
    settings.load()

    parts = key.split(".")
    if len(parts) != 2:
        console.print("[red]错误: 配置键格式应为 section.key[/red]")
        raise typer.Exit(1)

    section, subkey = parts

    if section == "wechat":
        console.print("[yellow]提示: 微信账号请使用 add-account 命令添加[/yellow]")
        raise typer.Exit(1)
    else:
        console.print(f"[red]未知配置节: {section}[/red]")
        raise typer.Exit(1)

    settings.save()
    console.print(f"[green]已设置 {key}[/green]")


# 转换命令
@app.command()
def convert(
    file: Path = typer.Argument(..., help="Markdown 文件路径", exists=True),
    output: Optional[Path] = typer.Option(None, "--output", "-o", help="输出 HTML 文件路径"),
    theme: str = typer.Option(None, "--theme", help="主题名称 (green/blue/purple/orange/simple/default)"),
    title: Optional[str] = typer.Option(None, "--title", "-t", help="文章标题（默认从文件名提取）"),
) -> None:
    """将 Markdown 转换为微信公众号格式的 HTML

    支持多种精美主题，自动处理代码高亮、列表、表格等元素。
    """
    # 默认使用绿色主题
    if theme is None:
        theme = "green"
    
    if not file.exists():
        console.print(f"[red]文件不存在: {file}[/red]")
        raise typer.Exit(1)

    # 读取 Markdown 内容
    markdown_content = file.read_text(encoding="utf-8")

    # 解析 frontmatter 并移除
    article_title = title
    frontmatter_cover = None
    
    fm_match = re.match(r'^---\n(.*?)\n---\n', markdown_content, re.DOTALL)
    if fm_match:
        fm_content = fm_match.group(1)
        # 移除 frontmatter
        markdown_content = markdown_content[fm_match.end():]
        
        # 提取标题
        if not article_title:
            title_match = re.search(r'^title:\s*(.+?)\s*$', fm_content, re.MULTILINE)
            if title_match:
                article_title = title_match.group(1).strip().strip('"\'')
        
        # 提取封面
        cover_match = re.search(r'^cover:\s*(.+?)\s*$', fm_content, re.MULTILINE)
        if cover_match:
            frontmatter_cover = cover_match.group(1).strip().strip('"\'')
        
        # 如果 frontmatter 有 title，移除正文中的第一个 H1（避免重复）
        if article_title:
            markdown_content = re.sub(r'^#\s+.+$', '', markdown_content, count=1, flags=re.MULTILINE)
            markdown_content = markdown_content.lstrip('\n')
    else:
        # 没有 frontmatter，尝试从第一个 # 标题提取
        if not article_title:
            h1_match = re.search(r'^#\s+(.+)$', markdown_content, re.MULTILINE)
            if h1_match:
                article_title = h1_match.group(1).strip()
        # 无论标题是否提取成功，都移除第一个 H1 避免重复
        markdown_content = re.sub(r'^#\s+.+$', '', markdown_content, count=1, flags=re.MULTILINE)
        markdown_content = markdown_content.lstrip('\n')

    console.print(f"[cyan]转换 Markdown → HTML[/cyan]")
    console.print(f"[cyan]输入文件: {file}[/cyan]")
    if article_title:
        console.print(f"[cyan]标题: {article_title}[/cyan]")
    if frontmatter_cover:
        console.print(f"[cyan]封面: {frontmatter_cover}[/cyan]")
    console.print(f"[cyan]主题: {theme}[/cyan]")

    # 转换
    try:
        converter = WeChatStyleConverter(theme=theme)
        html_content = converter.convert(markdown_content, title=article_title)
    except Exception as e:
        console.print(f"[red]转换失败: {e}[/red]")
        raise typer.Exit(1)

    # 确定输出文件
    if output:
        output_path = output
    else:
        output_path = file.with_suffix('.html')

    output_path.write_text(html_content, encoding="utf-8")
    console.print(f"[green]转换完成: {output_path}[/green]")


# 发布命令
@app.command()
def publish(
    file: Path = typer.Argument(..., help="HTML 文件路径", exists=True),
    title: Optional[str] = typer.Option(None, "--title", "-t", help="文章标题（默认从文件名提取）"),
    cover: Optional[str] = typer.Option(None, "--cover", "-c", help="封面图片路径"),
    author: Optional[str] = typer.Option(None, "--author", "-a", help="作者"),
    account: Optional[str] = typer.Option(None, "--account", help="指定账号标识（不指定则交互选择）"),
) -> None:
    """发布 HTML 文件到微信公众号草稿箱
    """
    if not file.exists():
        console.print(f"[red]文件不存在: {file}[/red]")
        raise typer.Exit(1)

    # 读取 HTML 内容
    html_content = file.read_text(encoding="utf-8")

    # 确定标题
    article_title = title or file.stem
    if not title:
        # 尝试从 HTML 中提取标题
        title_match = re.search(r"<h1[^>]*>([^<]+)</h1>", html_content)
        if title_match:
            article_title = title_match.group(1)
        else:
            title_match = re.search(r"<title>([^<]+)</title>", html_content)
            if title_match:
                article_title = title_match.group(1)

    console.print(f"[cyan]发布 HTML 文件: {file}[/cyan]")
    console.print(f"[cyan]标题: {article_title}[/cyan]")

    # 选择账号
    if account:
        # 指定账号
        settings.load()
        acc = settings.get_account(account)
        if not acc:
            console.print(f"[red]账号 {account} 不存在[/red]")
            raise typer.Exit(1)
        console.print(f"[cyan]使用账号: {acc.name}[/cyan]\n")
    else:
        # 交互选择
        acc = select_account()

    # 发布到平台
    try:
        wechat = create_wechat_platform(acc)
    except RuntimeError as e:
        console.print(f"[red]{e}[/red]")
        raise typer.Exit(1)

    request = PublishRequest(
        title=article_title,
        content=html_content,
        cover=cover,
        author=author,
    )

    result = asyncio.run(wechat.publish(request))

    if result.success:
        console.print(f"[green]发布到 {acc.name} 成功！[/green]")
        if result.media_id:
            console.print(f"  Media ID: {result.media_id}")
        if result.article_url:
            console.print(f"  URL: {result.article_url}")
    else:
        console.print(f"[red]发布到 {acc.name} 失败: {result.message}[/red]")
        raise typer.Exit(1)


# 测试命令
@app.command()
def test(
    account: Optional[str] = typer.Option(None, "--account", help="指定账号标识"),
) -> None:
    """测试微信公众号连接"""
    if account:
        settings.load()
        acc = settings.get_account(account)
        if not acc:
            console.print(f"[red]账号 {account} 不存在[/red]")
            raise typer.Exit(1)
        console.print(f"[cyan]测试账号: {acc.name}[/cyan]\n")
    else:
        acc = select_account()

    try:
        wechat = create_wechat_platform(acc)
    except RuntimeError as e:
        console.print(f"[red]{e}[/red]")
        raise typer.Exit(1)

    console.print(f"[cyan]测试 {acc.name} 连接...[/cyan]")

    result = asyncio.run(wechat.validate_credentials())

    if result:
        console.print(f"[green]{acc.name} 连接成功！[/green]")
    else:
        console.print(f"[red]{acc.name} 连接失败[/red]")
        raise typer.Exit(1)


# 上传图片命令
@app.command()
def upload_image(
    file: Path = typer.Argument(..., help="图片文件路径", exists=True),
    account: Optional[str] = typer.Option(None, "--account", help="指定账号标识"),
) -> None:
    """上传图片到微信素材库"""
    if account:
        settings.load()
        acc = settings.get_account(account)
        if not acc:
            console.print(f"[red]账号 {account} 不存在[/red]")
            raise typer.Exit(1)
        console.print(f"[cyan]使用账号: {acc.name}[/cyan]\n")
    else:
        acc = select_account()

    try:
        wechat = create_wechat_platform(acc)
    except RuntimeError as e:
        console.print(f"[red]{e}[/red]")
        raise typer.Exit(1)

    console.print(f"[cyan]上传图片: {file}[/cyan]")

    result = asyncio.run(wechat.upload_image(str(file)))

    if result.success:
        console.print(f"[green]图片上传成功！[/green]")
        if result.media_id:
            console.print(f"  Media ID: {result.media_id}")
        if result.url:
            console.print(f"  URL: {result.url}")
    else:
        console.print(f"[red]图片上传失败: {result.message}[/red]")
        raise typer.Exit(1)


if __name__ == "__main__":
    app()


path: src/wechat_publish_pro/config.py
content: """
配置管理 - 支持多账号
"""

import os
from pathlib import Path
from typing import Optional

import yaml
from pydantic import BaseModel
from pydantic_settings import BaseSettings


def is_openclaw_env() -> bool:
    """检测是否在 openclaw 环境中运行"""
    openclaw_dir = Path.home() / ".openclaw"
    return openclaw_dir.exists() and openclaw_dir.is_dir()


def load_openclaw_env() -> dict[str, str]:
    """从 ~/.openclaw/.env 加载环境变量"""
    env_file = Path.home() / ".openclaw" / ".env"
    env_vars = {}
    if env_file.exists():
        with open(env_file, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#"):
                    if "=" in line:
                        key, value = line.split("=", 1)
                        env_vars[key.strip()] = value.strip()
    return env_vars


class WeChatAccount(BaseModel):
    """微信公众号账号配置"""
    name: str = "默认账号"
    app_id: str = ""
    app_secret: str = ""


class WeChatMultiConfig(BaseModel):
    """微信公众号多账号配置"""
    accounts: dict[str, WeChatAccount] = {}
    default_account: str = ""


class Settings(BaseSettings):
    """应用配置"""

    # 配置文件路径
    config_dir: Path = Path.home() / ".wechat-publish-pro"

    # 平台配置（多账号）
    wechat: WeChatMultiConfig = WeChatMultiConfig()

    class Config:
        env_prefix = "WECHAT_PUBLISHER_"
        env_nested_delimiter = "_"

    def get_config_file(self) -> Path:
        """获取配置文件路径"""
        return self.config_dir / "config.yaml"

    def load(self) -> None:
        """从文件和环境变量加载配置"""
        # 如果在 openclaw 环境中，先加载 ~/.openclaw/.env
        openclaw_env = {}
        if is_openclaw_env():
            openclaw_env = load_openclaw_env()

        # 先从配置文件加载
        config_file = self.get_config_file()
        if config_file.exists():
            with open(config_file, encoding="utf-8") as f:
                data = yaml.safe_load(f) or {}
                if "accounts" in data:
                    self.wechat.accounts = {
                        k: WeChatAccount(**v) for k, v in data["accounts"].items()
                    }
                if "default_account" in data:
                    self.wechat.default_account = data["default_account"]

        # 环境变量覆盖（单个账号模式，用于兼容旧配置）
        wechat_app_id = openclaw_env.get("WECHAT_APP_ID") or os.environ.get("WECHAT_APP_ID")
        wechat_app_secret = openclaw_env.get("WECHAT_APP_SECRET") or os.environ.get("WECHAT_APP_SECRET")
        
        if wechat_app_id and wechat_app_secret:
            # 如果环境变量有配置，但配置文件没有账号，则添加到 default 账号
            if not self.wechat.accounts:
                self.wechat.accounts["default"] = WeChatAccount(
                    name="默认账号",
                    app_id=wechat_app_id,
                    app_secret=wechat_app_secret
                )
                self.wechat.default_account = "default"


    def save(self) -> None:
        """保存配置到文件"""
        self.config_dir.mkdir(parents=True, exist_ok=True)
        config_file = self.get_config_file()

        data = {
            "accounts": {k: v.model_dump() for k, v in self.wechat.accounts.items()},
            "default_account": self.wechat.default_account,
        }

        with open(config_file, "w", encoding="utf-8") as f:
            yaml.dump(data, f, allow_unicode=True, default_flow_style=False)

    def get_account_names(self) -> list[str]:
        """获取所有账号名称"""
        return list(self.wechat.accounts.keys())

    def get_default_account(self) -> Optional[str]:
        """获取默认账号"""
        if self.wechat.default_account and self.wechat.default_account in self.wechat.accounts:
            return self.wechat.default_account
        if self.wechat.accounts:
            return list(self.wechat.accounts.keys())[0]
        return None

    def get_account(self, key: str) -> Optional[WeChatAccount]:
        """获取指定账号"""
        return self.wechat.accounts.get(key)

    def is_wechat_configured(self) -> bool:
        """检查是否已配置至少一个账号"""
        return any(
            acc.app_id and acc.app_secret 
            for acc in self.wechat.accounts.values()
        )

    def add_account(self, key: str, name: str, app_id: str, app_secret: str) -> None:
        """添加账号"""
        self.wechat.accounts[key] = WeChatAccount(
            name=name,
            app_id=app_id,
            app_secret=app_secret
        )
        if not self.wechat.default_account:
            self.wechat.default_account = key

    def remove_account(self, key: str) -> None:
        """删除账号"""
        if key in self.wechat.accounts:
            del self.wechat.accounts[key]
            if self.wechat.default_account == key:
                self.wechat.default_account = list(self.wechat.accounts.keys())[0] if self.wechat.accounts else ""

    def set_default_account(self, key: str) -> None:
        """设置默认账号"""
        if key in self.wechat.accounts:
            self.wechat.default_account = key


# 全局配置实例
settings = Settings()


path: src/wechat_publish_pro/converter/__init__.py
content: """
Markdown 转换器

支持 API 模式和 AI 模式转换 Markdown 为各平台兼容的 HTML。
支持 wenyan-cli 格式的 CSS 主题。
"""

from pathlib import Path
from typing import Optional

from markdown_it import MarkdownIt
from pydantic import BaseModel

from .wechat_style import WeChatStyleConverter
from .themes import get_theme, list_themes


class ConvertOptions(BaseModel):
    """转换选项"""

    theme: str = "default"
    api_endpoint: Optional[str] = None
    highlight_code: bool = True
    image_host: Optional[str] = None
    platform: str = "default"
    css_path: Optional[str] = None


class ConvertResult(BaseModel):
    """转换结果"""

    title: str
    html: str
    images: list[str] = []


class MarkdownConverter:
    """Markdown 转换器"""

    # mdnice API 端点
    MDNICE_API = "https://api.mdnice.com/api/v1/markdown"

    def __init__(self, options: Optional[ConvertOptions] = None) -> None:
        self.options = options or ConvertOptions()
        self._md = MarkdownIt("gfm-like", {"html": True, "linkify": True})

    def convert(
        self,
        content: str,
        title: Optional[str] = None,
        platform: str = "default",
        theme: str = "default",
        use_api: bool = False,
        api_endpoint: Optional[str] = None,
        css_path: Optional[str] = None,
    ) -> ConvertResult:
        """转换 Markdown 为 HTML

        Args:
            content: Markdown 内容
            title: 文章标题
            platform: 目标平台 (default/wechat)
            theme: 排版主题名称
            use_api: 是否使用 API 模式
            api_endpoint: API 端点
            css_path: CSS 文件路径（兼容 wenyan-cli 格式）
        """
        # 提取标题
        extracted_title = title
        lines = content.split("\n")
        for line in lines:
            if line.startswith("# "):
                extracted_title = line[2:].strip()
                break

        if not extracted_title:
            extracted_title = "无标题文章"

        # 选择转换方式
        if platform == "wechat":
            if use_api:
                # API 模式：调用 mdnice 或自定义 API
                html = self._convert_via_api(content, api_endpoint, theme)
            else:
                # 本地样式转换（支持 CSS 主题）
                converter = WeChatStyleConverter(theme=theme, css_path=css_path)
                html = converter.convert(content, extracted_title)
        else:
            # 基础转换
            html = self._md.render(content)

        # 提取图片
        images = self._extract_images(content)

        return ConvertResult(
            title=extracted_title,
            html=html,
            images=images,
        )

    def _convert_via_api(
        self,
        markdown: str,
        api_endpoint: Optional[str],
        theme: str,
    ) -> str:
        """通过 API 转换 Markdown"""
        import httpx

        endpoint = api_endpoint or self.MDNICE_API

        try:
            # mdnice API 格式
            payload = {
                "content": markdown,
                "theme": theme,
                "mobile": False,
            }

            with httpx.Client(timeout=60.0) as client:
                response = client.post(endpoint, json=payload)

                if response.status_code == 200:
                    data = response.json()
                    # mdnice 返回格式
                    if "data" in data and "content" in data["data"]:
                        return data["data"]["content"]
                    return data.get("content", data.get("html", markdown))

                # 如果 mdnice 失败，回退到本地转换
                converter = WeChatStyleConverter(theme=theme)
                return converter.convert(markdown)

        except Exception:
            # 出错时回退到本地转换
            converter = WeChatStyleConverter(theme=theme)
            return converter.convert(markdown)

    def convert_file(
        self,
        file_path: Path,
        title: Optional[str] = None,
        platform: str = "default",
        theme: str = "default",
        use_api: bool = False,
        api_endpoint: Optional[str] = None,
        css_path: Optional[str] = None,
    ) -> ConvertResult:
        """转换 Markdown 文件"""
        content = file_path.read_text(encoding="utf-8")
        if not title:
            title = file_path.stem
        return self.convert(content, title, platform, theme, use_api, api_endpoint, css_path)

    def _extract_images(self, content: str) -> list[str]:
        """提取 Markdown 中的图片 URL"""
        import re

        pattern = r"!\[.*?\]\((.*?)\)"
        return re.findall(pattern, content)

path: src/wechat_publish_pro/converter/css_theme.py
content: """
CSS 主题解析器

解析 wenyan-cli 格式的 CSS 文件，转换为微信公众号可用的内联样式。
"""

import re
from pathlib import Path
from typing import Dict, Optional
from dataclasses import dataclass

from .theme_types import ThemeStyles, ThemeColors


@dataclass
class CSSRule:
    """CSS 规则"""
    selector: str
    properties: Dict[str, str]


def parse_css(css_content: str) -> list[CSSRule]:
    """解析 CSS 内容，提取规则"""
    rules = []

    # 移除注释
    css_content = re.sub(r'/\*.*?\*/', '', css_content, flags=re.DOTALL)

    # 匹配选择器和属性块
    pattern = r'([^{]+)\{([^}]+)\}'

    for match in re.finditer(pattern, css_content, re.DOTALL):
        selector = match.group(1).strip()
        properties_str = match.group(2).strip()

        # 解析属性
        properties = {}
        for prop in properties_str.split(';'):
            prop = prop.strip()
            if ':' in prop:
                key, value = prop.split(':', 1)
                properties[key.strip()] = value.strip()

        if properties:
            rules.append(CSSRule(selector=selector, properties=properties))

    return rules


def css_to_inline_style(rules: list[CSSRule]) -> Dict[str, str]:
    """将 CSS 规则转换为元素样式映射

    wenyan-cli 格式：
    - #wenyan h1 -> h1
    - #wenyan p -> p
    - #wenyan pre code -> code_block
    - #wenyan p code -> code_inline
    - #wenyan pre -> code_block_container
    """
    styles = {}

    # 选择器映射：wenyan-cli 选择器 -> 元素类型
    selector_map = {
        '#wenyan h1': 'h1',
        '#wenyan h2': 'h2',
        '#wenyan h3': 'h3',
        '#wenyan h4': 'h4',
        '#wenyan h5': 'h5',
        '#wenyan h6': 'h6',
        '#wenyan p': 'p',
        '#wenyan blockquote': 'blockquote',
        '#wenyan pre': 'pre',
        '#wenyan pre code': 'code_block',
        '#wenyan code': 'code_inline',
        '#wenyan p code': 'code_inline',
        '#wenyan ul': 'ul',
        '#wenyan ol': 'ol',
        '#wenyan li': 'li',
        '#wenyan table': 'table',
        '#wenyan table th': 'th',
        '#wenyan table td': 'td',
        '#wenyan table tr': 'tr',
        '#wenyan img': 'img',
        '#wenyan a': 'a',
        '#wenyan hr': 'hr',
        '#wenyan strong': 'strong',
        '#wenyan p strong': 'strong',
        '#wenyan em': 'em',
        '#wenyan': 'container',
    }

    for rule in rules:
        # 处理 #wenyan 前缀的选择器
        for wenyan_selector, element in selector_map.items():
            if rule.selector == wenyan_selector:
                if element not in styles:
                    styles[element] = {}

                # 合并属性，后面的覆盖前面的
                styles[element].update(rule.properties)

                # 处理特殊选择器
                if element == 'pre':
                    # pre::before 用于代码块头部
                    styles['pre_before'] = rule.properties.copy()

    # 转换为内联样式字符串
    result = {}
    for element, props in styles.items():
        if props:
            result[element] = props_to_string(props)

    return result


def props_to_string(props: Dict[str, str]) -> str:
    """将属性字典转换为内联样式字符串"""
    # CSS 属性名转换为 camelCase（用于某些特殊情况）
    # 但微信公众号支持 kebab-case，所以直接使用
    parts = []
    for key, value in props.items():
        # 跳过伪元素和不支持的属性
        if key in ('content', 'display', 'position', 'z-index', 'transform'):
            # 这些属性在内联样式中可能不生效或不需要
            if key != 'display':  # display 通常是有用的
                continue
        parts.append(f'{key}: {value};')

    return ' '.join(parts)


def load_css_theme(css_path: str, theme_name: str = "custom") -> ThemeStyles:
    """从 CSS 文件加载主题

    Args:
        css_path: CSS 文件路径（本地路径或 URL）
        theme_name: 主题名称

    Returns:
        ThemeStyles 对象
    """
    import httpx

    # 读取 CSS 内容
    if css_path.startswith(('http://', 'https://')):
        # 网络 URL
        with httpx.Client(timeout=30.0) as client:
            response = client.get(css_path)
            css_content = response.text
    else:
        # 本地文件
        path = Path(css_path)
        if not path.exists():
            raise FileNotFoundError(f"CSS 文件不存在: {css_path}")
        css_content = path.read_text(encoding='utf-8')

    # 解析 CSS
    rules = parse_css(css_content)
    styles = css_to_inline_style(rules)

    # 创建 ThemeStyles
    return ThemeStyles(
        name=theme_name,
        display_name=theme_name.capitalize(),
        description=f"从 {css_path} 加载的主题",
        colors=ThemeColors(),
        h1_style=styles.get('h1', ''),
        h2_style=styles.get('h2', ''),
        h3_style=styles.get('h3', ''),
        h4_style=styles.get('h4', ''),
        p_style=styles.get('p', ''),
        blockquote_style=styles.get('blockquote', ''),
        code_inline_style=styles.get('code_inline', ''),
        code_block_style=styles.get('code_block', styles.get('pre', '')),
        code_header_style=styles.get('pre_before', ''),
        ul_style=styles.get('ul', ''),
        ol_style=styles.get('ol', ''),
        li_style=styles.get('li', ''),
        table_style=styles.get('table', ''),
        th_style=styles.get('th', ''),
        td_style=styles.get('td', ''),
        img_style=styles.get('img', ''),
        a_style=styles.get('a', ''),
        hr_style=styles.get('hr', ''),
        strong_style=styles.get('strong', ''),
        em_style=styles.get('em', ''),
        container_style=styles.get('container', ''),
    )


def register_css_theme(css_path: str, theme_name: str, themes_dict: Optional[dict] = None) -> ThemeStyles:
    """注册 CSS 主题

    Args:
        css_path: CSS 文件路径
        theme_name: 主题名称（唯一标识）
        themes_dict: 主题字典（可选，用于注册主题）

    Returns:
        ThemeStyles 对象
    """
    theme = load_css_theme(css_path, theme_name)
    if themes_dict is not None:
        themes_dict[theme_name] = theme
    return theme


def list_css_themes(theme_dir: Path) -> list[dict]:
    """列出目录中的所有 CSS 主题

    Args:
        theme_dir: 主题目录

    Returns:
        主题信息列表
    """
    themes = []

    if not theme_dir.exists():
        return themes

    for css_file in theme_dir.glob('*.css'):
        theme_name = css_file.stem
        themes.append({
            'name': theme_name,
            'path': str(css_file),
            'display_name': theme_name.capitalize(),
            'description': f"CSS 主题: {css_file.name}",
        })

    return themes

path: src/wechat_publish_pro/converter/theme_types.py
content: """
主题数据类型定义

定义主题的数据结构，避免循环导入。
"""

from dataclasses import dataclass, field


@dataclass
class ThemeColors:
    """主题颜色配置"""

    primary: str = "#333333"
    secondary: str = "#666666"
    accent: str = "#3f3f3f"
    background: str = "#ffffff"
    code_background: str = "#282c34"
    code_text: str = "#abb2bf"
    quote_background: str = "#f7f7f7"
    quote_border: str = "#ddd"
    link: str = "#576b95"
    table_header_bg: str = "#f5f5f5"


@dataclass
class ThemeStyles:
    """主题样式配置 - 兼容 wenyan-cli 的 #wenyan 容器格式"""

    name: str = "default"
    display_name: str = "默认主题"
    description: str = "简洁清爽的默认主题"
    colors: ThemeColors = field(default_factory=ThemeColors)

    # 标题样式
    h1_style: str = ""
    h2_style: str = ""
    h3_style: str = ""
    h4_style: str = ""

    # 段落样式
    p_style: str = ""

    # 引用样式
    blockquote_style: str = ""

    # 代码样式
    code_inline_style: str = ""
    code_block_style: str = ""
    code_header_style: str = ""  # 代码块头部样式

    # 列表样式
    ul_style: str = ""
    ol_style: str = ""
    li_style: str = ""

    # 表格样式
    table_style: str = ""
    th_style: str = ""
    td_style: str = ""
    tr_odd_style: str = ""  # 奇数行样式
    tr_even_style: str = ""  # 偶数行样式

    # 图片样式
    img_style: str = ""

    # 其他样式
    a_style: str = ""
    hr_style: str = ""
    strong_style: str = ""
    em_style: str = ""

    # 整体容器样式 - 使用 #wenyan 选择器格式
    container_style: str = ""

path: src/wechat_publish_pro/converter/themes.py
content: """
微信公众号主题样式配置

支持多种主题，每个主题包含完整的样式配置。
兼容 wenyan-cli 的 CSS 主题格式，使用 #wenyan 容器选择器。
"""

from pathlib import Path
from typing import Optional

from .theme_types import ThemeStyles, ThemeColors
from .css_theme import load_css_theme, register_css_theme


# 主题存储目录
THEME_DIR = Path.home() / ".multi-writing-skills" / "themes"


# ============ 预设主题 ============

# 默认主题 - 深色代码块 + 美化表格
DEFAULT_THEME = ThemeStyles(
    name="default",
    display_name="默认主题",
    description="简洁清爽的默认主题",
    colors=ThemeColors(),
    h1_style="font-size: 24px; font-weight: bold; color: #333; margin: 20px 0 10px; padding-bottom: 10px; border-bottom: 2px solid #333;",
    h2_style="font-size: 20px; font-weight: bold; color: #333; margin: 18px 0 8px; padding-left: 12px; border-left: 4px solid #333; background: linear-gradient(90deg, #f5f5f5 0%, transparent 100%);",
    h3_style="font-size: 18px; font-weight: bold; color: #333; margin: 16px 0 6px; padding-bottom: 5px; border-bottom: 1px dashed #ccc;",
    h4_style="font-size: 16px; font-weight: bold; color: #333; margin: 14px 0 4px;",
    p_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 10px 0; letter-spacing: 0.5px;",
    blockquote_style="background-color: #f8f8f8; border-left: 4px solid #555; padding: 12px 16px; margin: 16px 0; color: #555; font-size: 14px; border-radius: 0 6px 6px 0;",
    code_inline_style="background-color: #f0f0f0; color: #d14; padding: 3px 8px; border-radius: 4px; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 14px;",
    code_block_style="background-color: #282c34; color: #abb2bf; border-radius: 8px; padding: 16px; margin: 16px 0; overflow-x: auto; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 14px; line-height: 1.7; box-shadow: 0 2px 10px rgba(0,0,0,0.1);",
    code_header_style="background-color: #21252b; color: #abb2bf; padding: 8px 16px; font-size: 12px; border-radius: 8px 8px 0 0; border-bottom: 1px solid #333; font-family: 'SF Mono', Consolas, monospace;",
    ul_style="padding-left: 20px; margin: 10px 0; list-style-type: disc;",
    ol_style="padding-left: 0px; margin: 10px 0; list-style-position: inside;",
    li_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 6px 0;",
    table_style="width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);",
    th_style="background-color: #333; color: #fff; font-weight: bold; padding: 12px 15px; text-align: left; border: none;",
    td_style="padding: 12px 15px; border: none; border-bottom: 1px solid #eee;",
    tr_odd_style="background-color: #fff;",
    tr_even_style="background-color: #f9f9f9;",
    img_style="max-width: 100%; height: auto; display: block; margin: 15px auto; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);",
    a_style="color: #333; text-decoration: none; border-bottom: 1px solid #333;",
    hr_style="border: none; height: 2px; background: linear-gradient(90deg, transparent, #333, transparent); margin: 24px 0;",
    strong_style="font-weight: bold; color: #333;",
    em_style="font-style: italic; color: #555;",
    container_style='padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;',
)

# 橙心主题 - 温暖活力
ORANGE_THEME = ThemeStyles(
    name="orange",
    display_name="橙心主题",
    description="温暖活力的橙色主题",
    colors=ThemeColors(
        primary="#d9534f",
        secondary="#f0ad4e",
        accent="#ff6b6b",
        code_background="#2d2d2d",
        code_text="#f8f8f2",
        quote_border="#d9534f",
        link="#d9534f",
    ),
    h1_style="font-size: 24px; font-weight: bold; color: #d9534f; margin: 20px 0 10px; text-align: center;",
    h2_style="font-size: 20px; font-weight: bold; color: #fff; margin: 18px 0 8px; padding: 10px 16px; background: linear-gradient(135deg, #d9534f 0%, #f0ad4e 100%); border-radius: 6px;",
    h3_style="font-size: 18px; font-weight: bold; color: #d9534f; margin: 16px 0 6px; padding-left: 12px; border-left: 3px solid #f0ad4e;",
    h4_style="font-size: 16px; font-weight: bold; color: #333; margin: 14px 0 4px;",
    p_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 10px 0; letter-spacing: 0.5px;",
    blockquote_style="background: linear-gradient(135deg, #fff5f5 0%, #fff 100%); border-left: 4px solid #d9534f; padding: 12px 16px; margin: 16px 0; color: #666; font-size: 14px; border-radius: 0 8px 8px 0;",
    code_inline_style="background-color: #fff5f5; color: #d9534f; padding: 3px 8px; border-radius: 4px; font-family: 'SF Mono', Consolas, monospace; font-size: 14px;",
    code_block_style="background-color: #2d2d2d; color: #f8f8f2; border-radius: 8px; padding: 16px; margin: 16px 0; overflow-x: auto; font-family: 'SF Mono', Consolas, monospace; font-size: 14px; line-height: 1.7; box-shadow: 0 4px 12px rgba(217, 83, 79, 0.15);",
    code_header_style="background-color: #252525; color: #f8f8f2; padding: 8px 16px; font-size: 12px; border-radius: 8px 8px 0 0; border-bottom: 1px solid #3a3a3a;",
    ul_style="padding-left: 20px; margin: 10px 0; list-style-type: disc;",
    ol_style="padding-left: 0px; margin: 10px 0; list-style-position: inside;",
    li_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 6px 0;",
    table_style="width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(217, 83, 79, 0.1);",
    th_style="background: linear-gradient(135deg, #d9534f 0%, #f0ad4e 100%); color: #fff; font-weight: bold; padding: 12px 15px; text-align: left; border: none;",
    td_style="padding: 12px 15px; border: none; border-bottom: 1px solid #f0f0f0;",
    tr_odd_style="background-color: #fff;",
    tr_even_style="background-color: #fffbf7;",
    img_style="max-width: 100%; height: auto; display: block; margin: 15px auto; border-radius: 8px; box-shadow: 0 4px 12px rgba(217, 83, 79, 0.15);",
    a_style="color: #d9534f; text-decoration: none; border-bottom: 1px dashed #d9534f;",
    hr_style="border: none; height: 2px; background: linear-gradient(90deg, #d9534f, #f0ad4e, #d9534f); margin: 24px 0;",
    strong_style="font-weight: bold; color: #d9534f;",
    em_style="font-style: italic; color: #f0ad4e;",
    container_style='padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;',
)

# 蓝色主题 - 清新专业
BLUE_THEME = ThemeStyles(
    name="blue",
    display_name="蓝皓主题",
    description="清新专业的蓝色主题",
    colors=ThemeColors(
        primary="#3b8bba",
        secondary="#5bc0de",
        accent="#2a6496",
        code_background="#1e3a5f",
        code_text="#e6f3ff",
        quote_border="#3b8bba",
        link="#3b8bba",
        table_header_bg="#3b8bba",
    ),
    h1_style="font-size: 24px; font-weight: bold; color: #3b8bba; margin: 20px 0 10px; text-align: center; border-bottom: 2px solid #3b8bba; padding-bottom: 10px;",
    h2_style="font-size: 20px; font-weight: bold; color: #fff; margin: 18px 0 8px; padding: 10px 16px; background: #3b8bba; border-radius: 4px; box-shadow: 0 2px 8px rgba(59, 139, 186, 0.3);",
    h3_style="font-size: 18px; font-weight: bold; color: #3b8bba; margin: 16px 0 6px; border-bottom: 2px dotted #3b8bba; padding-bottom: 5px;",
    h4_style="font-size: 16px; font-weight: bold; color: #3b8bba; margin: 14px 0 4px;",
    p_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 10px 0; letter-spacing: 0.5px;",
    blockquote_style="background: linear-gradient(135deg, #f0f7ff 0%, #fff 100%); border-left: 4px solid #3b8bba; padding: 12px 16px; margin: 16px 0; color: #555; font-size: 14px; border-radius: 0 8px 8px 0;",
    code_inline_style="background-color: #e8f4fc; color: #2a6496; padding: 3px 8px; border-radius: 4px; font-family: 'SF Mono', Consolas, monospace; font-size: 14px;",
    code_block_style="background-color: #1e3a5f; color: #e6f3ff; border-radius: 8px; padding: 16px; margin: 16px 0; overflow-x: auto; font-family: 'SF Mono', Consolas, monospace; font-size: 14px; line-height: 1.7; box-shadow: 0 4px 12px rgba(30, 58, 95, 0.3);",
    code_header_style="background-color: #163252; color: #7eb8e2; padding: 8px 16px; font-size: 12px; border-radius: 8px 8px 0 0; border-bottom: 1px solid #2a4a6a;",
    ul_style="padding-left: 20px; margin: 10px 0; list-style-type: disc;",
    ol_style="padding-left: 0px; margin: 10px 0; list-style-position: inside;",
    li_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 6px 0;",
    table_style="width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(59, 139, 186, 0.15);",
    th_style="background: #3b8bba; color: #fff; font-weight: bold; padding: 12px 15px; text-align: left; border: none;",
    td_style="padding: 12px 15px; border: none; border-bottom: 1px solid #e8f4fc;",
    tr_odd_style="background-color: #fff;",
    tr_even_style="background-color: #f5fafd;",
    img_style="max-width: 100%; height: auto; display: block; margin: 15px auto; border-radius: 8px; box-shadow: 0 2px 8px rgba(59, 139, 186, 0.2);",
    a_style="color: #3b8bba; text-decoration: none; border-bottom: 1px solid #3b8bba;",
    hr_style="border: none; height: 1px; background: linear-gradient(90deg, transparent, #3b8bba, transparent); margin: 24px 0;",
    strong_style="font-weight: bold; color: #3b8bba;",
    em_style="font-style: italic; color: #5bc0de;",
    container_style='padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;',
)

# 绿色主题 - 清新自然
GREEN_THEME = ThemeStyles(
    name="green",
    display_name="绿意主题",
    description="清新自然的绿色主题",
    colors=ThemeColors(
        primary="#27ae60",
        secondary="#2ecc71",
        accent="#1e8449",
        code_background="#1a3d2e",
        code_text="#d4edda",
        quote_border="#27ae60",
        link="#27ae60",
    ),
    h1_style="font-size: 24px; font-weight: bold; color: #27ae60; margin: 20px 0 10px; text-align: center;",
    h2_style="font-size: 20px; font-weight: bold; color: #27ae60; margin: 18px 0 8px; padding-left: 12px; border-left: 4px solid #27ae60; background: linear-gradient(90deg, #e8f8ee 0%, #fff 100%);",
    h3_style="font-size: 18px; font-weight: bold; color: #27ae60; margin: 16px 0 6px;",
    h4_style="font-size: 16px; font-weight: bold; color: #333; margin: 14px 0 4px;",
    p_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 10px 0; letter-spacing: 0.5px;",
    blockquote_style="background-color: #e8f8ee; border-left: 4px solid #27ae60; padding: 12px 16px; margin: 16px 0; color: #555; font-size: 14px; border-radius: 0 8px 8px 0;",
    code_inline_style="background-color: #e8f8ee; color: #1e8449; padding: 3px 8px; border-radius: 4px; font-family: 'SF Mono', Consolas, monospace; font-size: 14px;",
    code_block_style="background-color: #1a3d2e; color: #d4edda; border-radius: 8px; padding: 16px; margin: 16px 0; overflow-x: auto; font-family: 'SF Mono', Consolas, monospace; font-size: 14px; line-height: 1.7; box-shadow: 0 4px 12px rgba(39, 174, 96, 0.15);",
    code_header_style="background-color: #143024; color: #7dcea0; padding: 8px 16px; font-size: 12px; border-radius: 8px 8px 0 0; border-bottom: 1px solid #2a5a3e;",
    ul_style="padding-left: 20px; margin: 10px 0; list-style-type: disc;",
    ol_style="padding-left: 0px; margin: 10px 0; list-style-position: inside;",
    li_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 6px 0;",
    table_style="width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(39, 174, 96, 0.1);",
    th_style="background: #27ae60; color: #fff; font-weight: bold; padding: 12px 15px; text-align: left; border: none;",
    td_style="padding: 12px 15px; border: none; border-bottom: 1px solid #e8f8ee;",
    tr_odd_style="background-color: #fff;",
    tr_even_style="background-color: #f5fdf8;",
    img_style="max-width: 100%; height: auto; display: block; margin: 15px auto; border-radius: 8px;",
    a_style="color: #27ae60; text-decoration: none;",
    hr_style="border: none; height: 2px; background: linear-gradient(90deg, transparent, #27ae60, transparent); margin: 24px 0;",
    strong_style="font-weight: bold; color: #27ae60;",
    em_style="font-style: italic;",
    container_style='padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;',
)

# 紫色主题 - 优雅神秘
PURPLE_THEME = ThemeStyles(
    name="purple",
    display_name="紫韵主题",
    description="优雅神秘的紫色主题",
    colors=ThemeColors(
        primary="#9b59b6",
        secondary="#8e44ad",
        accent="#6c3483",
        code_background="#2d1f3d",
        code_text="#e8daef",
        quote_border="#9b59b6",
        link="#9b59b6",
    ),
    h1_style="font-size: 24px; font-weight: bold; color: #9b59b6; margin: 20px 0 10px; text-align: center;",
    h2_style="font-size: 20px; font-weight: bold; color: #fff; margin: 18px 0 8px; padding: 10px 16px; background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); border-radius: 20px; display: inline-block;",
    h3_style="font-size: 18px; font-weight: bold; color: #9b59b6; margin: 16px 0 6px; padding-bottom: 5px; border-bottom: 2px dotted #9b59b6;",
    h4_style="font-size: 16px; font-weight: bold; color: #8e44ad; margin: 14px 0 4px;",
    p_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 10px 0; letter-spacing: 0.5px;",
    blockquote_style="background-color: #f9f5fc; border-left: 4px solid #9b59b6; padding: 12px 16px; margin: 16px 0; color: #555; font-size: 14px; border-radius: 0 8px 8px 0;",
    code_inline_style="background-color: #f9f5fc; color: #9b59b6; padding: 3px 8px; border-radius: 4px; font-family: 'SF Mono', Consolas, monospace; font-size: 14px;",
    code_block_style="background-color: #2d1f3d; color: #e8daef; border-radius: 8px; padding: 16px; margin: 16px 0; overflow-x: auto; font-family: 'SF Mono', Consolas, monospace; font-size: 14px; line-height: 1.7; box-shadow: 0 4px 12px rgba(155, 89, 182, 0.2);",
    code_header_style="background-color: #231730; color: #bb8fce; padding: 8px 16px; font-size: 12px; border-radius: 8px 8px 0 0; border-bottom: 1px solid #3d2a50;",
    ul_style="padding-left: 20px; margin: 10px 0; list-style-type: disc;",
    ol_style="padding-left: 0px; margin: 10px 0; list-style-position: inside;",
    li_style="font-size: 15px; line-height: 1.8; color: #3f3f3f; margin: 6px 0;",
    table_style="width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(155, 89, 182, 0.15);",
    th_style="background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); color: #fff; font-weight: bold; padding: 12px 15px; text-align: left; border: none;",
    td_style="padding: 12px 15px; border: none; border-bottom: 1px solid #f3e5f5;",
    tr_odd_style="background-color: #fff;",
    tr_even_style="background-color: #fcf5fe;",
    img_style="max-width: 100%; height: auto; display: block; margin: 15px auto; border-radius: 8px;",
    a_style="color: #9b59b6; text-decoration: none;",
    hr_style="border: none; height: 1px; background: linear-gradient(90deg, transparent, #9b59b6, transparent); margin: 24px 0;",
    strong_style="font-weight: bold; color: #9b59b6;",
    em_style="font-style: italic; color: #8e44ad;",
    container_style='padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;',
)

# 深蓝主题 - 深邃专业
DARK_BLUE_THEME = ThemeStyles(
    name="darkblue",
    display_name="深蓝主题",
    description="深邃专业的深蓝色主题",
    colors=ThemeColors(
        primary="#1a237e",
        secondary="#283593",
        accent="#3949ab",
        code_background="#0d1b2a",
        code_text="#e3f2fd",
        quote_border="#1a237e",
        link="#1a237e",
        table_header_bg="#1a237e",
    ),
    h1_style='font-size: 24px; font-weight: bold; color: #1a237e; margin: 20px 0 10px; text-align: center; border-bottom: 3px solid #1a237e; padding-bottom: 10px;',
    h2_style='font-size: 20px; font-weight: bold; color: #1a237e; margin: 18px 0 8px; padding: 10px 16px; padding-left: 14px; border-left: 5px solid #1a237e; background: linear-gradient(90deg, #e8eaf6 0%, transparent 100%); border-radius: 0 4px 4px 0;',
    h3_style='font-size: 18px; font-weight: bold; color: #1a237e; margin: 16px 0 6px; padding-left: 12px; border-left: 4px solid #3949ab; background: linear-gradient(90deg, #e8eaf6 0%, transparent 100%);',
    h4_style='font-size: 16px; font-weight: bold; color: #3949ab; margin: 14px 0 4px;',
    p_style='font-size: 15px; line-height: 1.8; color: #333; margin: 10px 0; letter-spacing: 0.5px;',
    blockquote_style='background: linear-gradient(135deg, #e8eaf6 0%, #fff 100%); border-left: 4px solid #1a237e; padding: 12px 16px; margin: 16px 0; color: #555; font-size: 14px; border-radius: 0 8px 8px 0;',
    code_inline_style='background-color: #e8eaf6; color: #1a237e; padding: 3px 8px; border-radius: 4px; font-family: \'SF Mono\', Consolas, monospace; font-size: 14px;',
    code_block_style='background-color: #0d1b2a; color: #e3f2fd; border-radius: 8px; padding: 16px; margin: 16px 0; overflow-x: auto; font-family: \'SF Mono\', Consolas, monospace; font-size: 14px; line-height: 1.7; box-shadow: 0 4px 12px rgba(13, 27, 42, 0.4);',
    code_header_style='background-color: #0a1520; color: #90caf9; padding: 8px 16px; font-size: 12px; border-radius: 8px 8px 0 0; border-bottom: 1px solid #1a3a5e;',
    ul_style='padding-left: 20px; margin: 10px 0; list-style-type: disc;',
    ol_style='padding-left: 0px; margin: 10px 0; list-style-position: inside;',
    li_style='font-size: 15px; line-height: 1.8; color: #333; margin: 6px 0;',
    table_style='width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(26, 35, 126, 0.2);',
    th_style='background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); color: #fff; font-weight: bold; padding: 12px 15px; text-align: left; border: none;',
    td_style='padding: 12px 15px; border: none; border-bottom: 1px solid #e8eaf6;',
    tr_odd_style='background-color: #fff;',
    tr_even_style='background-color: #f5f6fa;',
    img_style='max-width: 100%; height: auto; display: block; margin: 15px auto; border-radius: 8px; box-shadow: 0 2px 8px rgba(26, 35, 126, 0.3);',
    a_style='color: #1a237e; text-decoration: none; border-bottom: 1px solid #3949ab;',
    hr_style='border: none; height: 2px; background: linear-gradient(90deg, transparent, #1a237e, #3949ab, #1a237e, transparent); margin: 24px 0;',
    strong_style='font-weight: bold; color: #1a237e;',
    em_style='font-style: italic; color: #3949ab;',
    container_style='padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;',
)

# 简约主题 - 极简风格
SIMPLE_THEME = ThemeStyles(
    name="simple",
    display_name="简约主题",
    description="极简风格，专注内容",
    colors=ThemeColors(
        primary="#333333",
        secondary="#666666",
        code_background="#fafafa",
        code_text="#333",
        quote_border="#e0e0e0",
        link="#333333",
    ),
    h1_style="font-size: 22px; font-weight: bold; color: #333; margin: 25px 0 15px; text-align: center;",
    h2_style="font-size: 18px; font-weight: bold; color: #333; margin: 20px 0 10px;",
    h3_style="font-size: 16px; font-weight: bold; color: #333; margin: 15px 0 8px;",
    h4_style="font-size: 15px; font-weight: bold; color: #333; margin: 12px 0 6px;",
    p_style="font-size: 15px; line-height: 2; color: #333; margin: 12px 0;",
    blockquote_style="border-left: 3px solid #e0e0e0; padding-left: 15px; margin: 15px 0; color: #666; font-size: 14px;",
    code_inline_style="color: #c7254e; font-family: 'SF Mono', Consolas, monospace; font-size: 14px;",
    code_block_style="background-color: #fafafa; padding: 16px; margin: 16px 0; overflow-x: auto; font-family: 'SF Mono', Consolas, monospace; font-size: 14px; line-height: 1.7; border: 1px solid #eee; border-radius: 4px;",
    code_header_style="background-color: #f5f5f5; color: #666; padding: 6px 16px; font-size: 12px; border-bottom: 1px solid #eee; font-family: 'SF Mono', Consolas, monospace;",
    ul_style="padding-left: 20px; margin: 10px 0; list-style-type: disc;",
    ol_style="padding-left: 0px; margin: 10px 0; list-style-position: inside;",
    li_style="font-size: 15px; line-height: 2; color: #333; margin: 3px 0;",
    table_style="width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px;",
    th_style="font-weight: bold; border-bottom: 2px solid #333; padding: 12px; text-align: left; background: none;",
    td_style="border-bottom: 1px solid #eee; padding: 12px;",
    tr_odd_style="background-color: #fff;",
    tr_even_style="background-color: #fafafa;",
    img_style="max-width: 100%; height: auto; display: block; margin: 20px auto;",
    a_style="color: #333; text-decoration: underline;",
    hr_style="border: none; border-top: 1px solid #e0e0e0; margin: 25px 0;",
    strong_style="font-weight: bold;",
    em_style="font-style: italic;",
    container_style='padding: 20px; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;',
)

# 主题注册表
THEMES: dict[str, ThemeStyles] = {
    "default": DEFAULT_THEME,
    "orange": ORANGE_THEME,
    "blue": BLUE_THEME,
    "green": GREEN_THEME,
    "purple": PURPLE_THEME,
    "darkblue": DARK_BLUE_THEME,
    "simple": SIMPLE_THEME,
}


def get_theme(name: str, css_path: Optional[str] = None) -> ThemeStyles:
    """获取主题配置

    Args:
        name: 主题名称
        css_path: 可选的 CSS 文件路径（本地或 URL）

    Returns:
        ThemeStyles 对象
    """
    # 如果指定了 CSS 文件，加载 CSS 主题
    if css_path:
        return load_css_theme(css_path, name)

    # 如果是内置主题，直接返回
    if name in THEMES:
        return THEMES[name]

    # 检查是否是用户自定义 CSS 主题
    css_file = THEME_DIR / f"{name}.css"
    if css_file.exists():
        return load_css_theme(str(css_file), name)

    # 返回默认主题
    return DEFAULT_THEME


def register_theme(css_path: str, name: Optional[str] = None) -> ThemeStyles:
    """注册自定义 CSS 主题

    Args:
        css_path: CSS 文件路径（本地或 URL）
        name: 主题名称，默认从文件名提取

    Returns:
        ThemeStyles 对象
    """
    global THEMES
    if not name:
        name = Path(css_path).stem

    theme = register_css_theme(css_path, name, THEMES)
    return theme


def list_themes() -> list[dict]:
    """列出所有主题（内置 + 自定义 CSS）"""
    result = []

    # 内置主题
    for theme in THEMES.values():
        result.append({
            "name": theme.name,
            "display_name": theme.display_name,
            "description": theme.description,
            "type": "builtin",
        })

    # 自定义 CSS 主题
    if THEME_DIR.exists():
        for css_file in THEME_DIR.glob("*.css"):
            name = css_file.stem
            result.append({
                "name": name,
                "display_name": name.capitalize(),
                "description": f"CSS 主题: {css_file.name}",
                "type": "css",
                "path": str(css_file),
            })

    return result

path: src/wechat_publish_pro/converter/wechat_style.py
content: """
微信公众号样式转换器

完全重写列表和代码块转换逻辑，确保格式正确。
支持 wenyan-cli 格式的 CSS 主题。
"""

import re
from typing import Optional, List, Tuple
from dataclasses import dataclass

from .theme_types import ThemeStyles
from .themes import get_theme, THEMES


@dataclass
class CodeBlock:
    """代码块"""
    lang: str
    code: str
    start: int
    end: int


@dataclass
class ListItem:
    """列表项"""
    level: int
    ordered: bool
    content: str
    number: int = 0


class WeChatStyleConverter:
    """微信公众号样式转换器"""

    def __init__(self, theme: str = "default", css_path: Optional[str] = None):
        self.theme_name = theme
        self.css_path = css_path
        self.theme: ThemeStyles = get_theme(theme, css_path)

    def set_theme(self, theme: str, css_path: Optional[str] = None) -> None:
        self.theme_name = theme
        self.css_path = css_path
        self.theme = get_theme(theme, css_path)

    def convert(self, markdown: str, title: Optional[str] = None) -> str:
        """转换 Markdown 为带样式的 HTML"""
        # 1. 先处理代码块（避免被其他处理干扰）
        markdown, code_blocks = self._extract_code_blocks(markdown)

        # 2. 处理表格
        html = self._convert_tables(markdown)

        # 3. 处理标题
        html = self._convert_headers(html)

        # 4. 处理引用块
        html = self._convert_blockquotes(html)

        # 5. 处理分割线（必须在列表之前，否则 --- 会被解析成列表项）
        html = self._convert_hr(html)

        # 6. 处理列表（完全重写）
        html = self._convert_lists(html)

        # 7. 处理图片
        html = self._convert_images(html)

        # 8. 处理链接
        html = self._convert_links(html)

        # 9. 处理行内代码
        html = self._convert_inline_code(html)

        # 10. 处理粗体和斜体
        html = self._convert_strong_em(html)

        # 11. 处理段落
        html = self._convert_paragraphs(html)

        # 12. 恢复代码块
        html = self._restore_code_blocks(html, code_blocks)

        # 后处理
        html = self._postprocess(html)

        # 添加标题
        if title:
            html = f'<section style="text-align: center; margin-bottom: 20px;"><h1 style="{self.theme.h1_style}">{title}</h1></section>{html}'

        return html

    # ============ 代码块处理 ============

    def _extract_code_blocks(self, text: str) -> Tuple[str, List[CodeBlock]]:
        """提取代码块，用占位符替换"""
        code_blocks = []
        pattern = r'```(\w*)\n(.*?)```'

        def replace(match):
            lang = match.group(1) or "text"
            code = match.group(2)
            index = len(code_blocks)
            code_blocks.append(CodeBlock(lang=lang, code=code, start=match.start(), end=match.end()))
            return f'\x00CODE_BLOCK_{index}\x00'

        text = re.sub(pattern, replace, text, flags=re.DOTALL)
        return text, code_blocks

    def _restore_code_blocks(self, html: str, code_blocks: List[CodeBlock]) -> str:
        """恢复代码块并渲染"""
        for i, block in enumerate(code_blocks):
            placeholder = f'\x00CODE_BLOCK_{i}\x00'
            rendered = self._render_code_block(block)
            html = html.replace(placeholder, rendered)
        return html

    def _render_code_block(self, block: CodeBlock) -> str:
        """渲染单个代码块"""
        code = block.code

        # 1. 格式化代码：统一处理缩进，移除首尾空行，确保一致的行尾
        code = self._format_code(code)

        # 2. 先完全转义 HTML（此时代码是纯净的原始代码）
        code = code.replace('&', '&amp;')
        code = code.replace('<', '&lt;')
        code = code.replace('>', '&gt;')

        # 3. 然后在转义后的代码上进行高亮
        code = self._highlight_code_escaped(code, block.lang)

        # 4. 处理缩进：将前导空格转换为 &nbsp;，同时处理制表符
        # 微信公众号需要用 <br> 强制换行，仅靠 \n 在 pre 标签中不生效
        lines = code.split('\n')
        processed_lines = []
        for line in lines:
            # 将制表符转换为 4 个空格
            line = line.expandtabs(4)
            # 统计前导空格
            leading_spaces = len(line) - len(line.lstrip(' '))
            if leading_spaces > 0:
                line = '&nbsp;' * leading_spaces + line.lstrip(' ')
            processed_lines.append(line)

        code = '<br>'.join(processed_lines)

        # 语言名称和图标颜色
        lang_names = {
            'python': ('Python', '#3572A5'),
            'py': ('Python', '#3572A5'),
            'javascript': ('JavaScript', '#f7df1e'),
            'js': ('JavaScript', '#f7df1e'),
            'typescript': ('TypeScript', '#3178c6'),
            'ts': ('TypeScript', '#3178c6'),
            'java': ('Java', '#b07219'),
            'go': ('Go', '#00ADD8'),
            'rust': ('Rust', '#dea584'),
            'cpp': ('C++', '#f34b7d'),
            'c': ('C', '#555555'),
            'sql': ('SQL', '#e38c00'),
            'bash': ('Bash', '#4EAA25'),
            'shell': ('Shell', '#4EAA25'),
            'json': ('JSON', '#292929'),
            'yaml': ('YAML', '#cb171e'),
            'yml': ('YAML', '#cb171e'),
            'html': ('HTML', '#e34c26'),
            'css': ('CSS', '#563d7c'),
            'xml': ('XML', '#0060ac'),
            'markdown': ('Markdown', '#083fa1'),
            'md': ('Markdown', '#083fa1'),
            'text': ('Code', '#666666'),
            '': ('Code', '#666666'),
        }
        lang_info = lang_names.get(block.lang.lower(), (block.lang.upper() if block.lang else 'Code', '#666666'))
        lang_display = lang_info[0]
        lang_color = lang_info[1]

        # 样式 - 优化代码块视觉效果
        code_style = (
            "background-color: #282c34; "
            "color: #abb2bf; "
            "border-radius: 0 0 8px 8px; "
            "padding: 16px; "
            "margin: 0; "
            "overflow-x: auto; "
            "font-family: 'SF Mono', 'Fira Code', Consolas, 'Liberation Mono', Menlo, monospace; "
            "font-size: 13px; "
            "line-height: 1.6; "
            "box-shadow: 0 4px 12px rgba(0,0,0,0.15); "
        )

        # 代码块头部样式 - Mac 风格窗口
        header_style = (
            "background-color: #21252b; "
            "color: #abb2bf; "
            "padding: 12px 16px; "
            "font-size: 12px; "
            "border-radius: 8px 8px 0 0; "
            "border-bottom: 1px solid #181a1f; "
            "font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; "
            "display: flex; "
            "align-items: center; "
            "gap: 8px;"
        )

        return f'''<figure style="margin: 0; border-radius: 8px; overflow: hidden; box-shadow: 0 8px 24px rgba(0,0,0,0.15); background: #282c34;">
<section style="{header_style}">
<span style="width: 12px; height: 12px; border-radius: 50%; background: #ff5f56; box-shadow: inset 0 -1px 1px rgba(0,0,0,0.1);"></span>
<span style="width: 12px; height: 12px; border-radius: 50%; background: #ffbd2e; box-shadow: inset 0 -1px 1px rgba(0,0,0,0.1);"></span>
<span style="width: 12px; height: 12px; border-radius: 50%; background: #27c93f; box-shadow: inset 0 -1px 1px rgba(0,0,0,0.1);"></span>
<span style="margin-left: 8px; color: #5c6370; font-size: 11px;">{lang_display}</span>
</section>
<pre style="{code_style}"><code>{code}</code></pre>
</figure>'''

    def _format_code(self, code: str) -> str:
        """格式化代码块：
        - 移除首尾空行
        - 统一换行符
        - 移除尾部多余空格
        """
        # 统一换行符
        code = code.replace('\r\n', '\n').replace('\r', '\n')
        # 按行分割
        lines = code.split('\n')
        # 处理每一行：移除行尾空格
        lines = [line.rstrip() for line in lines]
        # 移除开头空行
        while lines and lines[0].strip() == '':
            lines.pop(0)
        # 移除结尾空行
        while lines and lines[-1].strip() == '':
            lines.pop()
        # 重新连接
        return '\n'.join(lines)

    def _highlight_code(self, code: str, lang: str) -> str:
        """语法高亮 - 在原始代码上应用高亮（已废弃，请使用 _highlight_code_escaped）"""
        return code

    def _highlight_code_escaped(self, code: str, lang: str) -> str:
        """语法高亮 - 在已转义的代码上应用高亮

        由于代码已经被 HTML 转义（< -> &lt;, > -> &gt;），
        可以安全地使用 span 标签进行高亮，不会与 HTML 标签冲突
        """
        # 颜色定义 (One Dark 主题)
        colors = {
            'keyword': '#c678dd',      # 紫色 - 关键字
            'string': '#98c379',       # 绿色 - 字符串
            'number': '#d19a66',       # 橙色 - 数字
            'comment': '#75715e',      # 灰色 - 注释
            'class': '#e5c07b',        # 黄色 - 类名
            'decorator': '#d19a66',    # 橙色 - 装饰器
            'builtin': '#e06c75',      # 红色 - 内置函数
        }

        # 关键字列表
        keywords = ['def', 'class', 'if', 'else', 'elif', 'for', 'while', 'return',
            'import', 'from', 'as', 'try', 'except', 'finally', 'with',
            'lambda', 'yield', 'raise', 'pass', 'break', 'continue',
            'True', 'False', 'None', 'and', 'or', 'not', 'in', 'is',
            'async', 'await', 'global', 'nonlocal', 'del', 'assert',
            'const', 'let', 'var', 'function', 'export', 'new', 'this',
            'super', 'extends', 'interface', 'type', 'struct', 'map',
            'func', 'package', 'go', 'defer', 'chan', 'range', 'select',
            'switch', 'case', 'default', 'catch', 'throw', 'typeof',
            'instanceof', 'void', 'null', 'undefined', 'true', 'false',
            'nil', 'make', 'goto', 'fallthrough']

        # 1. 字符串高亮 - 转义后双引号变成 &quot;
        code = re.sub(r'&quot;([^&]*)&quot;', rf'<span style="color: {colors["string"]};">&quot;\1&quot;</span>', code)
        code = re.sub(r'&#39;([^&]*)&#39;', rf'<span style="color: {colors["string"]};">&#39;\1&#39;</span>', code)

        # 2. 注释高亮
        code = re.sub(r'(?<=\n|\s)#(.*?)(?=\n|$)', rf'<span style="color: {colors["comment"]}; font-style: italic;">#\1</span>', code)

        # 3. 数字高亮
        code = re.sub(r'\b(\d+\.?\d*)\b', rf'<span style="color: {colors["number"]};">\1</span>', code)

        # 4. 装饰器
        code = re.sub(r'(@\w+)', rf'<span style="color: {colors["decorator"]};">\1</span>', code)

        # 5. 关键字
        for kw in keywords:
            code = re.sub(
                rf'(?<![a-zA-Z0-9_]){re.escape(kw)}(?![a-zA-Z0-9_])',
                rf'<span style="color: {colors["keyword"]};">{kw}</span>',
                code,
                flags=re.IGNORECASE
            )

        # 6. 类名 (PascalCase)
        code = re.sub(r'\b([A-Z][a-zA-Z0-9]+)\b', rf'<span style="color: {colors["class"]};">\1</span>', code)

        return code

    # ============ 列表处理（完全重写）============

    def _convert_lists(self, text: str) -> str:
        """转换列表 - 完全重写，支持嵌套"""
        lines = text.split('\n')
        result = []

        i = 0
        while i < len(lines):
            line = lines[i]

            # 检测无序列表项（允许内容为空）
            ul_match = re.match(r'^(\s*)[-*+]\s*(.*)$', line)
            # 检测有序列表项（允许内容为空）
            ol_match = re.match(r'^(\s*)(\d+)\.\s*(.*)$', line)

            if ul_match or ol_match:
                # 找到了列表开始，收集整个列表块
                list_html, consumed = self._parse_list_block(lines, i)
                result.append(list_html)
                i += consumed
            else:
                result.append(line)
                i += 1

        return '\n'.join(result)

    def _parse_list_block(self, lines: List[str], start: int) -> Tuple[str, int]:
        """解析整个列表块（空行不中断列表，允许列表项之间有空行）"""
        items = []
        i = start
        first_is_ordered = None  # 第一个列表项是否是有序列表
        first_number = 1  # 有序列表的起始编号

        while i < len(lines):
            line = lines[i]

            # 检测无序列表项（允许内容为空）
            ul_match = re.match(r'^(\s*)[-*+]\s*(.*)$', line)
            # 检测有序列表项（允许内容为空）
            ol_match = re.match(r'^(\s*)(\d+)\.\s*(.*)$', line)

            # 跳过纯空行（检查后面是否还有列表项）
            if line.strip() == '':
                # 查找下一个非空行
                next_non_empty = i + 1
                while next_non_empty < len(lines) and lines[next_non_empty].strip() == '':
                    next_non_empty += 1

                if next_non_empty >= len(lines):
                    # 后面没有内容了，结束列表
                    i += 1
                    break

                next_line = lines[next_non_empty]
                next_ul = re.match(r'^(\s*)[-*+]\s*(.*)$', next_line)
                next_ol = re.match(r'^(\s*)(\d+)\.\s*(.*)$', next_line)

                # 如果下一个非空行还是列表项，继续
                if next_ul or (next_ol and first_is_ordered):
                    i += 1
                    continue
                else:
                    # 下一个非空行不是列表项，结束
                    i += 1
                    break

            # 跳过不匹配的行
            if not ul_match and not ol_match:
                break

            if ul_match:
                indent = len(ul_match.group(1))
                content = ul_match.group(2).strip()
                if first_is_ordered is None:
                    first_is_ordered = False
                # 跳过空内容的无序列表项（单独的 - * +）
                if content:
                    items.append((indent, False, content, 0))
                i += 1
            elif ol_match:
                indent = len(ol_match.group(1))
                number = int(ol_match.group(2))
                content = ol_match.group(3).strip()
                if first_is_ordered is None:
                    first_is_ordered = True
                    first_number = number  # 记录第一个有序列表项的编号
                # 跳过空内容的有序列表项（单独的 1. 2. 3.）
                if content:
                    # 如果第一个有内容的项编号是 first_number+1 且第一个空项编号是 first_number
                    # 说明列表应该从 first_number 开始
                    items.append((indent, True, content, number))
                i += 1

        # 调整有序列表的编号：如果第一个列表项为空且编号为 1，后续项编号减 1
        if first_is_ordered and items:
            # 检查第一个有内容的项的编号是否比 first_number 大 1
            if items[0][3] == first_number + 1:
                # 列表应该从 first_number 开始，调整所有项的编号
                adjusted_items = [(indent, ordered, content, number - 1) for indent, ordered, content, number in items]
                items = adjusted_items

        # 渲染列表
        html = self._render_list_items(items)
        return html, i - start

    def _render_list_items(self, items: List[Tuple[int, bool, str, int]]) -> str:
        """渲染列表项为 HTML - 使用 div 模拟列表，避免微信重新解析"""
        if not items:
            return ''

        # 判断列表类型
        first_ordered = items[0][1]

        # 使用 div 模拟列表，避免微信编辑器重新解析
        # 无序列表：使用圆点字符 •
        # 有序列表：使用数字编号

        html_parts = []

        if first_ordered:
            # 有序列表
            list_style = self.theme.ol_style
            # 提取 padding-left 值用于缩进
            padding_match = re.search(r'padding-left:\s*(\d+)px', list_style)
            padding_left = int(padding_match.group(1)) if padding_match else 20

            for indent, ordered, content, number in items:
                # 处理内联格式
                content = self._process_inline(content)
                # 使用 section 模拟有序列表项
                # 注意：不重复加 padding-left（容器的 padding 已处理缩进），内容紧贴容器左边
                html_parts.append(
                    f'<section style="display: flex; align-items: flex-start; margin: 6px 0; padding-left: 0;">'
                    f'<span style="min-width: 20px; color: #3f3f3f; font-size: 15px;">{number}.</span>'
                    f'<span style="flex: 1; color: #3f3f3f; font-size: 15px; line-height: 1.8;">{content}</span>'
                    f'</section>'
                )
        else:
            # 无序列表
            list_style = self.theme.ul_style
            # 提取 padding-left 值用于缩进
            padding_match = re.search(r'padding-left:\s*(\d+)px', list_style)
            padding_left = int(padding_match.group(1)) if padding_match else 20

            for indent, ordered, content, number in items:
                # 处理内联格式
                content = self._process_inline(content)
                # 使用 section 模拟无序列表项，使用 • 作为项目符号
                # 注意：不重复加 padding-left（容器的 padding 已处理缩进），内容紧贴容器左边
                html_parts.append(
                    f'<section style="display: flex; align-items: flex-start; margin: 6px 0; padding-left: 0;">'
                    f'<span style="min-width: 20px; color: #3f3f3f; font-size: 15px;">•</span>'
                    f'<span style="flex: 1; color: #3f3f3f; font-size: 15px; line-height: 1.8;">{content}</span>'
                    f'</section>'
                )

        return '\n'.join(html_parts)

    def _process_inline(self, text: str) -> str:
        """处理行内格式（粗体、斜体、行内代码）"""
        # 行内代码
        text = re.sub(r'`([^`]+)`', f'<code style="{self.theme.code_inline_style}">\\1</code>', text)
        # 粗体
        text = re.sub(r'\*\*([^*]+)\*\*', f'<strong style="{self.theme.strong_style}">\\1</strong>', text)
        text = re.sub(r'__([^_]+)__', f'<strong style="{self.theme.strong_style}">\\1</strong>', text)
        # 斜体
        text = re.sub(r'\*([^*]+)\*', f'<em style="{self.theme.em_style}">\\1</em>', text)
        # 下划线斜体：不处理包含大写字母的（避免误处理代码块占位符）
        text = re.sub(r'_([a-z][^_]*[a-z])_', f'<em style="{self.theme.em_style}">\\1</em>', text)
        return text

    # ============ 其他元素处理 ============

    def _convert_headers(self, text: str) -> str:
        """转换标题"""
        lines = text.split('\n')
        result = []

        for line in lines:
            if line.startswith('###### '):
                result.append(f'<h6 style="{self.theme.h4_style}">{line[7:]}</h6>')
            elif line.startswith('##### '):
                result.append(f'<h5 style="{self.theme.h4_style}">{line[6:]}</h5>')
            elif line.startswith('#### '):
                result.append(f'<h4 style="{self.theme.h4_style}">{line[5:]}</h4>')
            elif line.startswith('### '):
                result.append(f'<h3 style="{self.theme.h3_style}">{line[4:]}</h3>')
            elif line.startswith('## '):
                result.append(f'<h2 style="{self.theme.h2_style}">{line[3:]}</h2>')
            elif line.startswith('# '):
                result.append(f'<h1 style="{self.theme.h1_style}">{line[2:]}</h1>')
            else:
                result.append(line)

        return '\n'.join(result)

    def _convert_blockquotes(self, text: str) -> str:
        """转换引用块"""
        lines = text.split('\n')
        result = []
        quote_lines = []

        for line in lines:
            if line.startswith('> '):
                quote_lines.append(line[2:])
            elif line.startswith('>'):
                quote_lines.append(line[1:])
            else:
                if quote_lines:
                    content = '<br>'.join(quote_lines)
                    result.append(f'<blockquote style="{self.theme.blockquote_style}">{content}</blockquote>')
                    quote_lines = []
                result.append(line)

        if quote_lines:
            content = '<br>'.join(quote_lines)
            result.append(f'<blockquote style="{self.theme.blockquote_style}">{content}</blockquote>')

        return '\n'.join(result)

    def _convert_tables(self, text: str) -> str:
        """转换表格"""
        lines = text.split('\n')
        result = []
        in_table = False
        is_header = True
        row_index = 0

        for line in lines:
            if '|' in line and line.strip().startswith('|'):
                cells = [c.strip() for c in line.split('|')[1:-1]]

                if not in_table:
                    result.append(f'<table style="{self.theme.table_style}">')
                    result.append('<thead>')
                    in_table = True
                    is_header = True

                # 跳过分隔行
                if all(set(c) <= {'-', ':', ' '} for c in cells):
                    result.append('</thead><tbody>')
                    is_header = False
                    continue

                if is_header:
                    cells_html = ''.join([f'<th style="{self.theme.th_style}">{c}</th>' for c in cells])
                    result.append(f'<tr>{cells_html}</tr>')
                else:
                    row_index += 1
                    row_style = self.theme.tr_odd_style if row_index % 2 == 1 else self.theme.tr_even_style
                    cells_html = ''.join([f'<td style="{self.theme.td_style}">{c}</td>' for c in cells])
                    result.append(f'<tr style="{row_style}">{cells_html}</tr>')
            else:
                if in_table:
                    result.append('</tbody></table>')
                    in_table = False
                result.append(line)

        if in_table:
            result.append('</tbody></table>')

        return '\n'.join(result)

    def _convert_hr(self, text: str) -> str:
        """转换分割线"""
        return re.sub(r'^---+$', f'<hr style="{self.theme.hr_style}">', text, flags=re.MULTILINE)

    def _convert_images(self, text: str) -> str:
        """转换图片"""
        return re.sub(r'!\[([^\]]*)\]\(([^)]+)\)',
                      f'<img src="\\2" alt="\\1" style="{self.theme.img_style}">', text)

    def _convert_links(self, text: str) -> str:
        """转换链接"""
        return re.sub(r'\[([^\]]+)\]\(([^)]+)\)',
                      f'<a href="\\2" style="{self.theme.a_style}">\\1</a>', text)

    def _convert_inline_code(self, text: str) -> str:
        """转换行内代码"""
        return re.sub(r'`([^`]+)`', f'<code style="{self.theme.code_inline_style}">\\1</code>', text)

    def _convert_strong_em(self, text: str) -> str:
        """转换粗体和斜体"""
        text = re.sub(r'\*\*([^*]+)\*\*', f'<strong style="{self.theme.strong_style}">\\1</strong>', text)
        text = re.sub(r'__([^_]+)__', f'<strong style="{self.theme.strong_style}">\\1</strong>', text)
        text = re.sub(r'\*([^*]+)\*', f'<em style="{self.theme.em_style}">\\1</em>', text)
        # 下划线斜体：不处理包含大写字母的（避免误处理代码块占位符如 CODE_BLOCK_0）
        text = re.sub(r'_([a-z][^_]*[a-z])_', f'<em style="{self.theme.em_style}">\\1</em>', text)
        return text

    def _convert_paragraphs(self, text: str) -> str:
        """转换段落"""
        lines = text.split('\n')
        result = []

        for line in lines:
            stripped = line.strip()
            # 跳过已转换的块元素
            if stripped.startswith(('<h1', '<h2', '<h3', '<h4', '<h5', '<h6',
                                    '<ul', '<ol', '<li', '</ul', '</ol',
                                    '<blockquote', '<figure', '<table', '<thead',
                                    '<tbody', '</thead', '</tbody', '<tr', '</tr',
                                    '<th', '<td', '</table', '</th', '</td',
                                    '<hr', '<img', '<section', '<p', '</section')):
                result.append(line)
            # 跳过代码块占位符
            elif '\x00CODE_BLOCK_' in stripped:
                result.append(line)
            # 跳过空行
            elif stripped == '':
                result.append('')
            # 跳过只包含标签的行
            elif re.match(r'^</?[a-z]+>$', stripped):
                result.append(line)
            elif stripped:
                result.append(f'<p style="{self.theme.p_style}">{stripped}</p>')

        return '\n'.join(result)

    def _postprocess(self, text: str) -> str:
        """后处理 - 使用 #wenyan 容器格式（兼容 wenyan-cli）"""
        text = re.sub(r'\n{3,}', '\n\n', text)
        # 使用 id="wenyan" 容器，兼容 wenyan-cli 的 CSS 主题格式
        text = f'<section id="wenyan" style="{self.theme.container_style}">{text}</section>'
        return text


def get_available_themes() -> List[str]:
    """获取所有可用主题名称"""
    return list(THEMES.keys())

path: src/wechat_publish_pro/platforms/__init__.py
content: """
平台适配器

支持微信公众号等平台发布。
"""

from .base import ImageUploadResult, Platform, PlatformType, PublishRequest, PublishResult
from .wechat import WeChatPlatform

__all__ = [
    "Platform",
    "PlatformType",
    "PublishRequest",
    "PublishResult",
    "ImageUploadResult",
    "WeChatPlatform",
]


path: src/wechat_publish_pro/platforms/base.py
content: """
平台抽象层

定义多平台发布的统一接口。
"""

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional


class PlatformType(str, Enum):
    """支持的平台类型"""

    WECHAT = "wechat"


@dataclass
class PublishRequest:
    """发布请求"""

    title: str
    content: str  # HTML 格式
    cover: Optional[str] = None
    author: Optional[str] = None
    digest: Optional[str] = None
    source_url: Optional[str] = None


@dataclass
class PublishResult:
    """发布结果"""

    success: bool
    platform: str
    media_id: Optional[str] = None
    article_url: Optional[str] = None
    draft_url: Optional[str] = None
    message: Optional[str] = None
    title: Optional[str] = None


@dataclass
class ImageUploadResult:
    """图片上传结果"""

    success: bool
    url: Optional[str] = None
    media_id: Optional[str] = None
    message: Optional[str] = None


class Platform(ABC):
    """平台抽象基类"""

    @property
    @abstractmethod
    def name(self) -> str:
        """平台标识"""
        pass

    @property
    @abstractmethod
    def display_name(self) -> str:
        """平台显示名称"""
        pass

    @abstractmethod
    async def publish(self, request: PublishRequest) -> PublishResult:
        """
        发布文章

        Args:
            request: 发布请求

        Returns:
            发布结果
        """
        pass

    @abstractmethod
    async def upload_image(self, image_path: str) -> ImageUploadResult:
        """
        上传图片

        Args:
            image_path: 图片路径（本地路径或 URL）

        Returns:
            上传结果
        """
        pass

    @abstractmethod
    async def validate_credentials(self) -> bool:
        """
        验证凭证是否有效

        Returns:
            凭证是否有效
        """
        pass

    @abstractmethod
    def is_configured(self) -> bool:
        """
        检查是否已配置凭证

        Returns:
            是否已配置
        """
        pass


path: src/wechat_publish_pro/platforms/wechat.py
content: """
微信公众号平台适配器
"""

import hashlib
import hmac
import time
from pathlib import Path
from typing import Optional

import httpx

from .base import ImageUploadResult, Platform, PublishRequest, PublishResult


class WeChatPlatform(Platform):
    """微信公众号平台"""

    BASE_URL = "https://api.weixin.qq.com/cgi-bin"

    def __init__(self, app_id: str, app_secret: str) -> None:
        self.app_id = app_id
        self.app_secret = app_secret
        self._access_token: Optional[str] = None
        self._token_expires_at: float = 0
        self._client = httpx.AsyncClient(timeout=30.0)

    @property
    def name(self) -> str:
        return "wechat"

    @property
    def display_name(self) -> str:
        return "微信公众号"

    def is_configured(self) -> bool:
        return bool(self.app_id and self.app_secret)

    async def _get_access_token(self) -> str:
        """获取 access_token"""
        # 检查缓存是否有效
        if self._access_token and time.time() < self._token_expires_at:
            return self._access_token

        url = f"{self.BASE_URL}/token"
        params = {
            "grant_type": "client_credential",
            "appid": self.app_id,
            "secret": self.app_secret,
        }

        resp = await self._client.get(url, params=params)
        data = resp.json()

        if "errcode" in data:
            raise RuntimeError(f"获取 access_token 失败: {data.get('errmsg')}")

        self._access_token = data["access_token"]
        # 提前 5 分钟过期
        self._token_expires_at = time.time() + data["expires_in"] - 300

        return self._access_token

    async def validate_credentials(self) -> bool:
        """验证凭证是否有效"""
        try:
            await self._get_access_token()
            return True
        except Exception:
            return False

    async def upload_image(self, image_path: str) -> ImageUploadResult:
        """上传图片到微信素材库"""
        token = await self._get_access_token()

        # 判断是 URL 还是本地文件
        if image_path.startswith(("http://", "https://")):
            # 下载远程图片
            resp = await self._client.get(image_path)
            image_data = resp.content
            filename = image_path.split("/")[-1].split("?")[0]
        else:
            # 读取本地文件
            path = Path(image_path)
            if not path.exists():
                return ImageUploadResult(
                    success=False, message=f"图片文件不存在: {image_path}"
                )
            image_data = path.read_bytes()
            filename = path.name

        # 上传到微信
        url = f"{self.BASE_URL}/material/add_material"
        params = {"access_token": token, "type": "image"}

        files = {"media": (filename, image_data, "image/jpeg")}
        resp = await self._client.post(url, params=params, files=files)
        data = resp.json()

        if "errcode" in data and data["errcode"] != 0:
            return ImageUploadResult(
                success=False, message=f"上传失败: {data.get('errmsg')}"
            )

        return ImageUploadResult(
            success=True, url=data.get("url"), media_id=data.get("media_id")
        )

    async def publish(self, request: PublishRequest) -> PublishResult:
        """发布文章到微信草稿箱"""
        try:
            token = await self._get_access_token()

            # 上传封面图片（必须有封面）
            thumb_media_id = None
            if request.cover:
                result = await self.upload_image(request.cover)
                if result.success and result.media_id:
                    thumb_media_id = result.media_id
                else:
                    return PublishResult(
                        success=False,
                        platform=self.name,
                        message=f"封面上传失败: {result.message}",
                    )
            else:
                # 没有封面时，生成默认封面
                thumb_media_id = await self._upload_default_cover(token)
                if not thumb_media_id:
                    return PublishResult(
                        success=False,
                        platform=self.name,
                        message="默认封面上传失败",
                    )

            # 创建草稿
            url = f"{self.BASE_URL}/draft/add"
            params = {"access_token": token}

            articles = [
                {
                    "title": request.title,
                    "author": request.author or "",
                    "digest": request.digest or "",
                    "content": request.content,
                    "content_source_url": request.source_url or "",
                    "thumb_media_id": thumb_media_id,
                    "need_open_comment": 0,
                    "only_fans_can_comment": 0,
                }
            ]

            payload = {"articles": articles}

            resp = await self._client.post(url, params=params, json=payload)
            data = resp.json()

            if "errcode" in data and data["errcode"] != 0:
                return PublishResult(
                    success=False,
                    platform=self.name,
                    message=f"创建草稿失败: {data.get('errmsg')}",
                )

            media_id = data["media_id"]

            return PublishResult(
                success=True,
                platform=self.name,
                media_id=media_id,
                draft_url=f"https://mp.weixin.qq.com",
                message="草稿创建成功",
            )

        except Exception as e:
            return PublishResult(
                success=False, platform=self.name, message=f"发布失败: {str(e)}"
            )

    async def _upload_default_cover(self, token: str) -> Optional[str]:
        """生成并上传默认封面图片"""
        try:
            import io

            from PIL import Image, ImageDraw, ImageFont

            # 创建一个简单的渐变封面（绿色主题）
            width, height = 900, 500
            img = Image.new("RGB", (width, height), color="#4CAF50")

            # 添加一些装饰
            draw = ImageDraw.Draw(img)

            # 绘制渐变效果（绿色渐变）
            for i in range(height):
                r = int(76 - (i / height) * 30)
                g = int(175 - (i / height) * 50)
                b = int(80 - (i / height) * 30)
                draw.line([(0, i), (width, i)], fill=(r, g, b))

            # 保存到内存
            buffer = io.BytesIO()
            img.save(buffer, format="JPEG", quality=95)
            image_data = buffer.getvalue()

            # 上传到微信
            url = f"{self.BASE_URL}/material/add_material"
            params = {"access_token": token, "type": "image"}

            files = {"media": ("cover.jpg", image_data, "image/jpeg")}
            resp = await self._client.post(url, params=params, files=files)
            data = resp.json()

            if "media_id" in data:
                return data["media_id"]
            return None

        except Exception:
            return None

    async def close(self) -> None:
        """关闭客户端连接"""
        await self._client.aclose()


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