Skill: hanjing5024064/smart-content-engine (version 1.1.0)

--- name: content-engine description: 内容引擎 — 跨平台内容创作与分发工具，支持自学习优化、Obsidian 集成、AI 配图提示词 version: 1.1.0 metadata: openclaw: optional_env: - CE_TWITTER_BEARER_TOKEN - CE_LINKEDIN_ACCESS_TOKEN - CE_WECHAT_APPID - CE_WECHAT_SECRET - CE_MEDIUM_TOKEN - CE_BLOG_TYPE - CE_BLOG_PATH - CE_SUBSCRIPTION_TIER - CE_OBSIDIAN_VAULT_PATH --- # 内容引擎（content-engine） 你是一个专业的跨平台内容运营 Agent。你的职责是帮助用户创作、管理、适配和分发内容到 Twitter、LinkedIn、微信公众号、博客（Hugo/Jekyll/Hexo）和 Medium 等平台。你始终使用中文与用户沟通。 本 Skill 是 ClawHub 上首个支持微信公众号集成的内容分发工具。 ## 环境变量说明 | 变量 | 必需 | 说明 | |------|------|------| | `CE_TWITTER_BEARER_TOKEN` | 否 | Twitter API v2 Bearer Token | | `CE_LINKEDIN_ACCESS_TOKEN` | 否 | LinkedIn API Access Token | | `CE_WECHAT_APPID` | 否 | 微信公众号 AppID | | `CE_WECHAT_SECRET` | 否 | 微信公众号 AppSecret | | `CE_MEDIUM_TOKEN` | 否 | Medium Integration Token | | `CE_BLOG_TYPE` | 否 | 博客引擎类型（hugo / jekyll / hexo），默认 hugo | | `CE_BLOG_PATH` | 否 | 博客项目根目录路径 | | `CE_SUBSCRIPTION_TIER` | 否 | 订阅等级，默认 `free`，可选 `paid` | | `CE_OBSIDIAN_VAULT_PATH` | 否 | Obsidian 笔记库路径，用于草稿导入导出 | 启动时，检查已配置的平台 Token。若用户尝试发布到未配置 Token 的平台，提示其配置对应的环境变量。 --- ## 流程一：内容管理 当用户说"创建内容"、"管理文章"、"编辑内容"或类似意图时，执行以下操作： ### 创建内容 引导用户提供以下信息： - **标题**（必填） - **正文**（必填，支持 Markdown 格式） - **摘要**（可选，用于微信公众号描述和社交媒体预览） - **标签**（可选，用于生成 hashtag 和分类） - **目标平台**（可选，支持: twitter, linkedin, wechat, blog, medium） - **作者**（可选） ```bash python3 scripts/content_store.py --action create --data '{"title":"...", "body":"...", "tags":["..."], "platforms":["twitter","wechat"]}' ``` ### 管理内容 ```bash # 查看内容列表 python3 scripts/content_store.py --action list # 按状态过滤 python3 scripts/content_store.py --action list --data '{"status":"草稿"}' # 获取内容详情 python3 scripts/content_store.py --action get --data '{"id":"CT..."}' # 更新内容 python3 scripts/content_store.py --action update --data '{"id":"CT...", "title":"新标题"}' # 删除内容 python3 scripts/content_store.py --action delete --data '{"id":"CT..."}' ``` ### 导入导出 ```bash # 从 Markdown 文件导入（支持 YAML frontmatter） python3 scripts/content_store.py --action import --data '{"file_path":"./article.md"}' # 导出为 Markdown python3 scripts/content_store.py --action export --data '{"id":"CT...", "file_path":"./output.md"}' ``` ### 内容状态流转 ``` 草稿 → 待审核 → 已排期 → 已发布 → 已归档 ``` 每个状态只能按规则流转，不可跳跃。 --- ## 流程二：适配与发布 当用户说"发布到 Twitter"、"适配微信"、"分发内容"或类似意图时，执行以下步骤： ### 步骤 1：内容适配 将通用内容转换为目标平台格式： ```bash # 适配到单个平台 python3 scripts/platform_adapter.py --action adapt --data '{"id":"CT...", "platform":"twitter"}' # 预览适配效果 python3 scripts/platform_adapter.py --action preview --data '{"id":"CT...", "platform":"wechat"}' # 校验内容是否符合平台要求 python3 scripts/platform_adapter.py --action validate --data '{"id":"CT...", "platform":"linkedin"}' # 批量适配到多个平台（付费版） python3 scripts/platform_adapter.py --action batch-adapt --data '{"id":"CT..."}' ``` 各平台适配规则参考 `references/platform-specs.md`。 ### 步骤 2：发布（付费版） ```bash # 发布到指定平台 python3 scripts/publisher.py --action publish --data '{"id":"CT...", "platform":"twitter"}' # 定时发布 python3 scripts/publisher.py --action schedule --data '{"id":"CT...", "platform":"wechat", "scheduled_at":"2026-03-20T18:00:00"}' # 查看发布历史 python3 scripts/publisher.py --action list-published # 撤回内容（标记归档） python3 scripts/publisher.py --action unpublish --data '{"id":"CT..."}' ``` 微信公众号发布流程参考 `references/wechat-guide.md`。 --- ## 流程三：数据指标（付费版） 当用户说"查看数据"、"内容表现"、"指标报告"或类似意图时： ```bash # 采集指标 python3 scripts/metrics_collector.py --action collect --data '{"content_id":"CT..."}' # 生成报告 python3 scripts/metrics_collector.py --action report --data '{"content_id":"CT..."}' # 对比多条内容 python3 scripts/metrics_collector.py --action compare --data '{"content_ids":["CT1","CT2"]}' # 查看热门内容趋势（含 Mermaid 图表） python3 scripts/metrics_collector.py --action trending ``` 各平台采集的指标： - **Twitter**: 点赞、转发、回复、曝光 - **LinkedIn**: 点赞、评论、分享、浏览 - **微信公众号**: 阅读、分享、收藏 - **Medium**: 阅读、鼓掌、回应 --- ## 流程四：内容日历（付费版） 当用户说"规划日历"、"排期管理"、"发布计划"或类似意图时： ```bash # 添加发布计划 python3 scripts/calendar_manager.py --action plan --data '{"content_id":"CT...", "platform":"twitter", "date":"2026-03-20"}' # 查看周日历 python3 scripts/calendar_manager.py --action view --data '{"view":"week"}' # 查看月日历 python3 scripts/calendar_manager.py --action view --data '{"view":"month"}' # 获取最佳发布时间建议 python3 scripts/calendar_manager.py --action suggest --data '{"platform":"wechat", "date":"2026-03-20"}' # 导出日历（Markdown 或 CSV） python3 scripts/calendar_manager.py --action export --data '{"format":"markdown", "file_path":"./calendar.md"}' ``` 付费版日历导出包含 Mermaid Gantt 时间线图。 --- ## 流程五：自学习内容智能 当用户说"分析内容表现"、"推荐话题"、"什么时候发布最好"或类似意图时： ```bash # 记录内容表现数据 python3 scripts/learning_engine.py --action record-performance --data '{"content_id":"CT...", "platform":"twitter", "metrics":{"likes":128,"retweets":45}, "topic":"AI编程", "posting_time":"2026-03-19T10:00:00"}' # 记录用户偏好 python3 scripts/learning_engine.py --action record-preference --data '{"add_topic":"AI Agent", "add_style":"技术深度"}' # 分析历史表现（哪些话题/时间/格式效果最好） python3 scripts/learning_engine.py --action analyze # 推荐下一个内容话题 python3 scripts/learning_engine.py --action suggest-topic --data '{"platform":"linkedin", "count":5}' # 推荐最佳发布时间 python3 scripts/learning_engine.py --action suggest-timing --data '{"platform":"twitter"}' # 查看内容表现统计面板 python3 scripts/learning_engine.py --action stats ``` 学习引擎会自动： - 在指标采集后记录到学习数据库 - 根据历史表现计算互动得分和互动率 - 识别高表现话题、时段和格式 --- ## 流程六：Obsidian 草稿工作流 当用户说"从 Obsidian 导入"、"笔记转内容"、"同步笔记"或类似意图时： ```bash # 连接到 Obsidian 笔记库 python3 scripts/obsidian_sync.py --action connect --data '{"vault_path":"~/MyVault"}' # 列出标记为草稿的笔记（带 #content 或 #draft 标签） python3 scripts/obsidian_sync.py --action list-drafts # 导入一篇笔记为内容草稿 python3 scripts/obsidian_sync.py --action import-draft --data '{"file":"drafts/my-article.md"}' # 导出内容回 Obsidian 笔记库 python3 scripts/obsidian_sync.py --action export-draft --data '{"title":"...", "body":"...", "ce_id":"CT...", "ce_status":"已发布"}' # 双向同步（检测新草稿和已变更文件） python3 scripts/obsidian_sync.py --action sync # 也可通过 content_store 直接导入 Obsidian 笔记 python3 scripts/content_store.py --action import-obsidian --data '{"file":"drafts/my-article.md"}' ``` Obsidian 格式支持： - `[[wikilinks]]` 自动转换为纯文本或 Markdown 链接 - `#tags` 提取为内容标签 - YAML frontmatter 解析为内容元数据 --- ## 流程七：AI 配图助手 当用户说"生成配图"、"图片建议"、"配图提示词"或类似意图时： ```bash # 根据内容生成 AI 图片提示词（Midjourney/DALL-E/SD 风格） python3 scripts/image_prompter.py --action generate-prompt --data '{"text":"文章内容...", "title":"文章标题", "platform":"twitter"}' # 分析内容，建议配图位置和内容 python3 scripts/image_prompter.py --action suggest-images --data '{"text":"完整文章...", "title":"标题"}' # 生成 SEO 友好的 alt text python3 scripts/image_prompter.py --action format-alt-text --data '{"description":"图片描述", "keywords":["AI","编程"]}' # 创建完整的视觉内容规划（hero、章节、缩略图） python3 scripts/image_prompter.py --action image-plan --data '{"text":"文章正文...", "title":"标题", "platforms":["blog","twitter","wechat"]}' ``` 各平台推荐图片尺寸： - **Twitter Card**: 1200x675 (16:9) - **LinkedIn Post**: 1200x627 (1.91:1) - **微信封面**: 900x383 (2.35:1) - **博客 Hero**: 1200x630 (1.91:1) - **Medium Feature**: 1400x788 (16:9) 提示词同时生成中英文版本，适配全球化内容需求。 --- ## 订阅校验逻辑 ### 读取订阅等级 ``` tier = env CE_SUBSCRIPTION_TIER，默认 "free" ``` ### 功能权限矩阵 | 功能 | 免费版（free） | 付费版（paid，¥99/月） | |------|---------------|----------------------| | 内容数量上限 | 20 条 | 500 条 | | 平台数量上限 | 2 个 | 5 个（全部） | | 内容创建/编辑/删除 | 支持 | 支持 | | 基础适配（预览） | 支持 | 支持 | | 手动发布 | 支持 | 支持 | | Markdown 导入导出 | 支持 | 支持 | | AI 配图提示词生成 | 支持 | 支持 | | Obsidian 笔记导入 | 支持 | 支持 | | 自动发布到平台 | 不支持 | 支持 | | 微信公众号 | 不支持 | 支持 | | 批量适配 | 不支持 | 支持 | | 定时发布 | 不支持 | 支持 | | 数据指标采集 | 不支持 | 支持 | | 自学习内容智能 | 不支持 | 支持 | | 学习洞察分析 | 不支持 | 支持 | | Obsidian 双向同步 | 不支持 | 支持 | | 内容日历 | 不支持 | 支持 | | Mermaid 图表 | 不支持 | 支持 | ### 校验失败时的行为 当用户请求的功能超出当前订阅等级时： 1. 明确告知用户当前功能仅限付费版。 2. 简要说明付费版的优势。 3. 提供升级引导："如需升级至付费版（¥99/月），请联系管理员或访问订阅管理页面。" 4. 不要直接拒绝，而是提供免费版可用的替代方案（如果有的话）。 --- ## 安全规范 1. **Token 保护**：所有平台 API Token 仅通过环境变量传递，绝不在对话中显示、记录或输出。 2. **HTML 安全**：微信公众号文章 HTML 经过清理，移除 script、iframe 等危险标签和 on* 事件属性。 3. **内容安全**：发布前校验内容格式，防止意外发布不完整或格式错误的内容。 4. **错误处理**：API 调用失败时，向用户展示友好的错误提示，不暴露内部路径或 Token 信息。 5. **数据安全**：所有内容数据存储在本地，不上传到云端。 --- ## 参考文档 在进行平台适配和发布时，请参考以下文档： - **平台规格**：`references/platform-specs.md` — 各平台的字符限制、图片要求和格式规则。 - **微信指南**：`references/wechat-guide.md` — 微信公众号 API 配置和使用指南。 --- ## 行为准则 1. 始终使用中文与用户沟通。 2. 在发布内容前，先向用户展示适配后的预览并获得确认。 3. 对用户的内容给出改进建议，帮助提升各平台的传播效果。 4. 主动提醒不同平台的最佳实践（如 Twitter 的 hashtag 策略、LinkedIn 的专业语气）。 5. 遇到模糊的用户意图时，主动追问以明确需求。 6. 发布出错时，耐心排查并给出可行的解决方案。 7. 尊重订阅等级限制，在提示升级时保持友好，不反复推销。 8. 涉及微信公众号操作时，提醒用户参考 `references/wechat-guide.md` 完成前置配置。

path: assets/README.md
content: # Content Engine / 内容引擎

> Cross-platform content creation and distribution tool — the FIRST WeChat Official Account integration on ClawHub!
>
> 跨平台内容创作与分发工具 — ClawHub 首个支持微信公众号集成的内容引擎！

---

## Features / 功能亮点

- **Multi-platform Distribution / 多平台分发** — Write once, publish everywhere: Twitter, LinkedIn, WeChat Official Account, Blog (Hugo/Jekyll/Hexo), Medium
- **WeChat OA Integration / 微信公众号集成** — The FIRST skill on ClawHub to support WeChat Official Account publishing, including article formatting, author cards, and rich text
- **Smart Adaptation / 智能适配** — Automatically adapts content to each platform's format, character limits, and best practices
- **Self-Learning Engine / 自学习引擎** — Learn from content performance to continuously improve: topic suggestions, optimal posting times, engagement analysis
- **Obsidian Integration / Obsidian 集成** — Import drafts directly from your Obsidian vault, bidirectional sync, wikilinks and tags conversion
- **AI Image Prompter / AI 配图助手** — Generate AI image prompts (Midjourney/DALL-E/SD style) for your content, with platform-specific sizing and SEO alt text
- **Content Calendar / 内容日历** — Plan, schedule, and visualize your content pipeline with Mermaid Gantt charts
- **Performance Metrics / 数据指标** — Collect and compare engagement metrics across all platforms with learning-based insights
- **Markdown Native / Markdown 原生** — Create content in Markdown, import/export with YAML frontmatter support
- **Local Data / 本地数据** — All data stored locally, nothing leaves your environment

---

## Version Comparison / 版本对比

| Feature / 功能 | Free / 免费版 | Paid / 付费版 ¥99/月 |
|------|:------:|:------------:|
| Content limit / 内容上限 | 20 | 500 |
| Platforms / 平台数 | 2 | 5 (all / 全部) |
| Create & Edit / 创建编辑 | ✅ | ✅ |
| Basic Adaptation / 基础适配 | ✅ | ✅ |
| Markdown Import/Export / 导入导出 | ✅ | ✅ |
| AI Image Prompts / AI 配图提示词 | ✅ | ✅ |
| Obsidian Import / Obsidian 导入 | ✅ | ✅ |
| Auto Publish / 自动发布 | ❌ | ✅ |
| WeChat OA / 微信公众号 | ❌ | ✅ |
| Batch Adapt / 批量适配 | ❌ | ✅ |
| Scheduled Publish / 定时发布 | ❌ | ✅ |
| Metrics Collection / 指标采集 | ❌ | ✅ |
| Self-Learning Engine / 自学习引擎 | ❌ | ✅ |
| Learning Insights / 学习洞察 | ❌ | ✅ |
| Obsidian Bidirectional Sync / 双向同步 | ❌ | ✅ |
| Content Calendar / 内容日历 | ❌ | ✅ |
| Mermaid Charts / 可视化图表 | ❌ | ✅ |

---

## Quick Start / 快速开始

### 1. Install / 安装

Search `content-engine` on ClawHub and install, or use CLI:

在 ClawHub 搜索 `content-engine` 并安装，或使用命令行：

```bash
openclaw skill install content-engine
```

### 2. Configure Platforms / 配置平台

Set environment variables for the platforms you want to use:

配置你要使用的平台环境变量：

```bash
# Twitter
export CE_TWITTER_BEARER_TOKEN="your-bearer-token"

# LinkedIn
export CE_LINKEDIN_ACCESS_TOKEN="your-access-token"

# WeChat Official Account / 微信公众号
export CE_WECHAT_APPID="your-appid"
export CE_WECHAT_SECRET="your-appsecret"

# Medium
export CE_MEDIUM_TOKEN="your-integration-token"

# Blog (Hugo/Jekyll/Hexo)
export CE_BLOG_TYPE="hugo"
export CE_BLOG_PATH="/path/to/your/blog"

# Obsidian Vault / Obsidian 笔记库
export CE_OBSIDIAN_VAULT_PATH="~/MyVault"

# Subscription / 订阅等级
export CE_SUBSCRIPTION_TIER="paid"
```

### 3. Create & Publish / 创建并发布

```bash
# Create content / 创建内容
/content-engine create "My Article Title" --platforms twitter,linkedin,wechat

# Adapt to platforms / 适配到各平台
/content-engine adapt CT... --platform twitter

# Preview before publishing / 发布前预览
/content-engine preview CT... --platform wechat

# Publish / 发布
/content-engine publish CT... --platform twitter
```

### 4. Track Performance / 追踪表现

```bash
# Collect metrics / 采集指标
/content-engine metrics CT...

# View content calendar / 查看内容日历
/content-engine calendar week
```

---

## Example / 使用示例

### Content Creation Workflow / 内容创作工作流

```
User: 帮我创建一篇关于 AI 编程助手的文章，发布到 Twitter 和微信公众号

Agent: 好的，我来帮你创建内容...
       [创建内容，生成 Twitter thread 和微信公众号 HTML 文章]
       [展示各平台预览]
       [获得确认后发布]

User: 查看这篇文章在各平台的表现

Agent: 正在采集指标数据...
       Twitter: 128 点赞, 45 转发, 12 回复, 5,230 曝光
       微信公众号: 2,340 阅读, 89 分享, 156 收藏
       [生成对比图表]
```

---

## Supported Platforms / 支持的平台

| Platform / 平台 | Format / 格式 | Key Feature / 核心特性 |
|------|------|------|
| Twitter / X | Thread (280 chars/tweet) | Auto-split, hashtags, CJK char counting |
| LinkedIn | Professional post (3000 chars) | Professional tone, structured format |
| WeChat OA / 微信公众号 | HTML article | Rich text, author card, image refs |
| Blog | Markdown + frontmatter | Hugo, Jekyll, Hexo support |
| Medium | Markdown | Medium-compatible format, 5 tags |

---

## FAQ / 常见问题

### Q1: Is this really the first WeChat integration on ClawHub? / 这真的是 ClawHub 首个微信集成吗？

Yes! content-engine is the first OpenClaw skill to support WeChat Official Account API integration, enabling direct article publishing from the CLI.

是的！content-engine 是首个支持微信公众号 API 集成的 OpenClaw Skill，可以直接从命令行发布文章到公众号。

### Q2: Do I need all platform tokens configured? / 需要配置所有平台的 Token 吗？

No. Only configure the platforms you plan to use. Content creation and adaptation work without any tokens — tokens are only needed for publishing.

不需要。只配置你计划使用的平台即可。内容创建和适配不需要任何 Token — Token 仅在发布时需要。

### Q3: Is my data uploaded to the cloud? / 数据会上传到云端吗？

No. All content data is stored locally in `~/.openclaw-bdi/content-engine/`. API calls are made directly from your machine to each platform.

不会。所有内容数据存储在本地 `~/.openclaw-bdi/content-engine/`。API 调用从你的机器直接发送到各平台。

### Q4: How does the Chinese character counting work for Twitter? / Twitter 的中文字符计数怎么算？

Twitter counts CJK characters as 2 character positions. content-engine automatically handles this when splitting content into threads.

Twitter 将中日韩字符计为 2 个字符位。content-engine 在拆分 thread 时会自动处理这个规则。

### Q5: Can I use the free version for WeChat? / 免费版能用微信公众号吗？

WeChat Official Account integration is a paid feature. Free users can create and adapt content, but publishing to WeChat requires the paid tier (¥99/month).

微信公众号集成是付费功能。免费用户可以创建和适配内容，但发布到微信需要付费版（¥99/月）。

### Q6: What blog engines are supported? / 支持哪些博客引擎？

Hugo, Jekyll, and Hexo. Set `CE_BLOG_TYPE` to your engine type, and `CE_BLOG_PATH` to your blog project root. content-engine generates properly formatted Markdown with the correct frontmatter.

支持 Hugo、Jekyll 和 Hexo。设置 `CE_BLOG_TYPE` 为你的引擎类型，`CE_BLOG_PATH` 为博客项目根目录。content-engine 会生成正确格式的 Markdown 和 frontmatter。

---

## Technical Support / 技术支持

- **Docs / 文档**: See `references/` directory for platform specs and WeChat guide
- **Issues / 问题反馈**: Submit on ClawHub skill page
- **Community / 社区讨论**: Join `#content-engine` channel on ClawHub
- **Email / 邮件**: [REDACTED_SECRET]

---

*content-engine v1.1.0 | Compatible with OpenClaw 0.5+ | First WeChat OA integration on ClawHub*


path: scripts/obsidian_sync.py
content: #!/usr/bin/env python3
"""
content-engine Obsidian 笔记库集成模块

连接 Obsidian 笔记库，实现草稿导入导出和双向同步，
打通笔记到内容发布的完整工作流。
"""

import json
import os
import re
import sys
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

from utils import (
    generate_id,
    get_data_file,
    load_input_data,
    now_iso,
    output_error,
    output_success,
    parse_common_args,
    read_json_file,
    write_json_file,
)


# ============================================================
# 常量与配置
# ============================================================

SYNC_STATE_FILE = "obsidian_sync_state.json"

# Obsidian 笔记中标识为内容草稿的标签
DRAFT_TAGS = {"#content", "#draft", "#内容", "#草稿"}

# Obsidian frontmatter 中支持的字段映射
FRONTMATTER_FIELD_MAP = {
    "title": "title",
    "标题": "title",
    "tags": "tags",
    "标签": "tags",
    "platforms": "platforms",
    "平台": "platforms",
    "author": "author",
    "作者": "author",
    "summary": "summary",
    "description": "summary",
    "摘要": "summary",
    "status": "status",
    "状态": "status",
    "ce_id": "ce_id",
    "ce_status": "ce_status",
    "ce_published_at": "ce_published_at",
}


# ============================================================
# 笔记库路径管理
# ============================================================

def _get_vault_path() -> Optional[str]:
    """获取 Obsidian 笔记库路径。

    优先读取环境变量 CE_OBSIDIAN_VAULT_PATH。

    Returns:
        笔记库路径，未配置时返回 None。
    """
    vault = os.environ.get("CE_OBSIDIAN_VAULT_PATH", "")
    if not vault:
        return None
    vault = os.path.expanduser(vault)
    if not os.path.isdir(vault):
        return None
    return vault


def _get_sync_state() -> Dict[str, Any]:
    """读取同步状态数据。"""
    filepath = get_data_file(SYNC_STATE_FILE)
    data = read_json_file(filepath)
    if isinstance(data, list):
        data = {}
    if not isinstance(data, dict):
        data = {}
    data.setdefault("vault_path", "")
    data.setdefault("synced_files", {})  # {相对路径: {ce_id, last_sync, hash}}
    data.setdefault("last_sync_at", "")
    return data


def _save_sync_state(state: Dict[str, Any]) -> None:
    """保存同步状态数据。"""
    write_json_file(get_data_file(SYNC_STATE_FILE), state)


# ============================================================
# Obsidian 格式解析
# ============================================================

def _parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
    """解析 Obsidian 笔记的 YAML frontmatter。

    Args:
        content: 笔记原始内容。

    Returns:
        (元数据字典, 正文内容) 的元组。
    """
    metadata: Dict[str, Any] = {}
    body = content

    match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", content, re.DOTALL)
    if match:
        fm_str = match.group(1)
        body = match.group(2).strip()

        # 简单 YAML 解析（不依赖 pyyaml）
        current_key = ""
        current_list: Optional[List[str]] = None

        for line in fm_str.split("\n"):
            stripped = line.strip()
            if not stripped or stripped.startswith("#"):
                continue

            # 检测列表项（以 - 开头，属于当前 key）
            if stripped.startswith("- ") and current_key and current_list is not None:
                val = stripped[2:].strip().strip("\"'")
                current_list.append(val)
                continue

            # 键值对
            if ":" in stripped:
                idx = stripped.index(":")
                raw_key = stripped[:idx].strip().lower()
                raw_val = stripped[idx + 1:].strip()

                # 映射字段名
                mapped_key = FRONTMATTER_FIELD_MAP.get(raw_key, raw_key)

                if raw_val.startswith("[") and raw_val.endswith("]"):
                    # 行内数组
                    items = [v.strip().strip("\"'") for v in raw_val[1:-1].split(",") if v.strip()]
                    metadata[mapped_key] = items
                    current_key = ""
                    current_list = None
                elif not raw_val:
                    # 可能是多行列表的开始
                    current_key = mapped_key
                    current_list = []
                    metadata[mapped_key] = current_list
                else:
                    metadata[mapped_key] = raw_val.strip("\"'")
                    current_key = ""
                    current_list = None

    return metadata, body


def _convert_wikilinks(text: str, mode: str = "plain") -> str:
    """转换 Obsidian [[wikilinks]] 为目标格式。

    Args:
        text: 包含 wikilinks 的文本。
        mode: 转换模式:
            - "plain": 转为纯文本
            - "markdown": 转为 Markdown 链接（无实际 URL）

    Returns:
        转换后的文本。
    """
    # [[显示文本|链接目标]] 格式
    text = re.sub(r"\[\[([^|\]]+)\|([^\]]+)\]\]", _wikilink_replace(mode), text)
    # [[链接目标]] 格式
    text = re.sub(r"\[\[([^\]]+)\]\]", _simple_wikilink_replace(mode), text)
    return text


def _wikilink_replace(mode: str):
    """返回 [[display|target]] 的替换函数。"""
    def replacer(m: re.Match) -> str:
        target = m.group(1)
        display = m.group(2)
        if mode == "markdown":
            return f"[{display}]({target})"
        return display
    return replacer


def _simple_wikilink_replace(mode: str):
    """返回 [[target]] 的替换函数。"""
    def replacer(m: re.Match) -> str:
        target = m.group(1)
        if mode == "markdown":
            return f"[{target}]({target})"
        return target
    return replacer


def _extract_tags_from_body(text: str) -> List[str]:
    """从正文中提取 Obsidian #tags。

    Args:
        text: 笔记正文。

    Returns:
        标签列表（不含 # 前缀）。
    """
    # 匹配 #tag 格式，排除标题中的 #
    tags = re.findall(r"(?:^|\s)#([a-zA-Z\u4e00-\u9fff][\w\u4e00-\u9fff/-]*)", text)
    return list(dict.fromkeys(tags))  # 去重保序


def _simple_hash(content: str) -> str:
    """计算内容的简单哈希值（用于变更检测）。

    Args:
        content: 文本内容。

    Returns:
        哈希字符串。
    """
    # 使用简单的字符串哈希（避免依赖 hashlib 以外的库）
    h = 0
    for ch in content:
        h = (h * 31 + ord(ch)) & 0xFFFFFFFF
    return format(h, "08x")


def _build_frontmatter(metadata: Dict[str, Any]) -> str:
    """构建 YAML frontmatter 字符串。

    Args:
        metadata: 元数据字典。

    Returns:
        包含 --- 分隔符的 frontmatter 字符串。
    """
    lines = ["---"]
    for key, value in metadata.items():
        if isinstance(value, list):
            if value:
                items_str = ", ".join(f'"{v}"' for v in value)
                lines.append(f"{key}: [{items_str}]")
        elif value:
            lines.append(f'{key}: "{value}"')
    lines.append("---")
    return "\n".join(lines)


# ============================================================
# 操作：连接笔记库
# ============================================================

def connect(data: Dict[str, Any]) -> None:
    """连接到 Obsidian 笔记库。

    可选字段: vault_path（笔记库路径，优先使用 CE_OBSIDIAN_VAULT_PATH 环境变量）

    Args:
        data: 包含可选笔记库路径的字典。
    """
    vault_path = data.get("vault_path") or _get_vault_path()
    if not vault_path:
        output_error(
            "未指定 Obsidian 笔记库路径。请设置环境变量 CE_OBSIDIAN_VAULT_PATH "
            "或通过 vault_path 参数指定。",
            code="CONFIG_ERROR",
        )
        return

    vault_path = os.path.expanduser(vault_path)

    if not os.path.isdir(vault_path):
        output_error(f"笔记库路径不存在: {vault_path}", code="PATH_NOT_FOUND")
        return

    # 扫描笔记库概况
    md_count = 0
    draft_count = 0
    for root, _dirs, files in os.walk(vault_path):
        for f in files:
            if f.endswith(".md"):
                md_count += 1
                fpath = os.path.join(root, f)
                try:
                    with open(fpath, "r", encoding="utf-8") as fp:
                        content = fp.read(2000)  # 只读前 2000 字符检查标签
                    if _is_draft_note(content):
                        draft_count += 1
                except (IOError, UnicodeDecodeError):
                    continue

    # 保存连接状态
    state = _get_sync_state()
    state["vault_path"] = vault_path
    _save_sync_state(state)

    output_success({
        "message": f"已连接到 Obsidian 笔记库: {vault_path}",
        "vault_path": vault_path,
        "total_notes": md_count,
        "draft_notes": draft_count,
    })


def _is_draft_note(content: str) -> bool:
    """检查笔记内容是否标记为草稿。

    Args:
        content: 笔记内容（可以是部分内容）。

    Returns:
        True 表示是草稿笔记。
    """
    content_lower = content.lower()
    for tag in DRAFT_TAGS:
        if tag.lower() in content_lower:
            return True

    # 检查 frontmatter 中的标签
    match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
    if match:
        fm = match.group(1).lower()
        if "content" in fm or "draft" in fm or "草稿" in fm or "内容" in fm:
            return True

    return False


# ============================================================
# 操作：列出草稿
# ============================================================

def list_drafts(data: Optional[Dict[str, Any]] = None) -> None:
    """列出 Obsidian 笔记库中的草稿笔记。

    可选字段: vault_path

    Args:
        data: 可选的配置字典。
    """
    vault_path = (data.get("vault_path") if data else None) or _get_vault_path()
    if not vault_path:
        # 尝试从同步状态读取
        state = _get_sync_state()
        vault_path = state.get("vault_path")

    if not vault_path or not os.path.isdir(vault_path):
        output_error("未连接到 Obsidian 笔记库，请先执行 connect 操作", code="NOT_CONNECTED")
        return

    drafts = []
    for root, _dirs, files in os.walk(vault_path):
        for f in files:
            if not f.endswith(".md"):
                continue
            fpath = os.path.join(root, f)
            try:
                with open(fpath, "r", encoding="utf-8") as fp:
                    content = fp.read()
            except (IOError, UnicodeDecodeError):
                continue

            if not _is_draft_note(content):
                continue

            metadata, body = _parse_frontmatter(content)
            rel_path = os.path.relpath(fpath, vault_path)
            title = metadata.get("title", "")
            if not title:
                # 从文件名或 H1 标题获取
                h1_match = re.match(r"^#\s+(.+)$", body, re.MULTILINE)
                if h1_match:
                    title = h1_match.group(1).strip()
                else:
                    title = os.path.splitext(f)[0]

            tags = metadata.get("tags", [])
            body_tags = _extract_tags_from_body(body)
            all_tags = list(dict.fromkeys(tags + body_tags))

            drafts.append({
                "file": rel_path,
                "title": title,
                "tags": all_tags,
                "platforms": metadata.get("platforms", []),
                "status": metadata.get("ce_status", "未导入"),
                "ce_id": metadata.get("ce_id", ""),
                "char_count": len(body),
                "modified": _file_mtime(fpath),
            })

    # 按修改时间倒序
    drafts.sort(key=lambda d: d.get("modified", ""), reverse=True)

    output_success({
        "message": f"找到 {len(drafts)} 篇草稿笔记",
        "vault_path": vault_path,
        "drafts": drafts,
    })


def _file_mtime(filepath: str) -> str:
    """获取文件修改时间的 ISO 格式字符串。

    Args:
        filepath: 文件路径。

    Returns:
        ISO 格式时间字符串。
    """
    try:
        mtime = os.path.getmtime(filepath)
        return datetime.fromtimestamp(mtime).strftime("%Y-%m-%dT%H:%M:%S")
    except OSError:
        return ""


# ============================================================
# 操作：导入草稿
# ============================================================

def import_draft(data: Dict[str, Any]) -> None:
    """从 Obsidian 笔记库导入一篇笔记作为内容草稿。

    必填字段: file（笔记在库中的相对路径）
    可选字段: vault_path

    解析 frontmatter 中的 title, tags, platforms 等字段。
    转换 [[wikilinks]] 为纯文本，提取 #tags 为内容标签。

    Args:
        data: 包含文件路径的字典。
    """
    file_rel = data.get("file", "")
    if not file_rel:
        output_error("笔记文件路径（file）为必填字段", code="VALIDATION_ERROR")
        return

    vault_path = data.get("vault_path") or _get_vault_path()
    if not vault_path:
        state = _get_sync_state()
        vault_path = state.get("vault_path")

    if not vault_path or not os.path.isdir(vault_path):
        output_error("未连接到 Obsidian 笔记库，请先执行 connect 操作", code="NOT_CONNECTED")
        return

    fpath = os.path.join(vault_path, file_rel)
    if not os.path.exists(fpath):
        output_error(f"笔记文件不存在: {file_rel}", code="FILE_NOT_FOUND")
        return

    try:
        with open(fpath, "r", encoding="utf-8") as f:
            raw_content = f.read()
    except (IOError, UnicodeDecodeError) as e:
        output_error(f"读取笔记失败: {e}", code="FILE_ERROR")
        return

    # 解析元数据和正文
    metadata, body = _parse_frontmatter(raw_content)

    # 转换 wikilinks
    body = _convert_wikilinks(body, "plain")

    # 提取标签
    fm_tags = metadata.get("tags", [])
    if isinstance(fm_tags, str):
        fm_tags = [t.strip() for t in fm_tags.split(",") if t.strip()]
    body_tags = _extract_tags_from_body(body)
    all_tags = list(dict.fromkeys(fm_tags + body_tags))
    # 移除草稿标记标签
    all_tags = [t for t in all_tags if t.lower() not in {"content", "draft", "内容", "草稿"}]

    # 从正文中移除 #tag（已提取到 tags 字段）
    body = re.sub(r"(?:^|\s)#([a-zA-Z\u4e00-\u9fff][\w\u4e00-\u9fff/-]*)", " ", body).strip()

    # 获取标题
    title = metadata.get("title", "")
    if not title:
        h1_match = re.match(r"^#\s+(.+)$", body, re.MULTILINE)
        if h1_match:
            title = h1_match.group(1).strip()
            # 从正文中移除 H1 标题行
            body = re.sub(r"^#\s+.+\n*", "", body, count=1).strip()
        else:
            title = os.path.splitext(os.path.basename(file_rel))[0]

    # 平台列表
    platforms = metadata.get("platforms", [])
    if isinstance(platforms, str):
        platforms = [p.strip() for p in platforms.split(",") if p.strip()]

    # 构建导入结果
    content_data = {
        "title": title,
        "body": body,
        "summary": metadata.get("summary", ""),
        "tags": all_tags,
        "platforms": platforms,
        "author": metadata.get("author", ""),
        "source": "obsidian",
        "source_file": file_rel,
    }

    # 更新同步状态
    state = _get_sync_state()
    state["synced_files"][file_rel] = {
        "last_sync": now_iso(),
        "hash": _simple_hash(raw_content),
        "direction": "import",
    }
    state["last_sync_at"] = now_iso()
    _save_sync_state(state)

    output_success({
        "message": f"已从 Obsidian 导入笔记「{title}」",
        "content": content_data,
        "source_file": file_rel,
        "note": "请使用 content_store.py --action create 创建内容，并将上述数据作为参数传入",
    })


# ============================================================
# 操作：导出草稿
# ============================================================

def export_draft(data: Dict[str, Any]) -> None:
    """将内容导出回 Obsidian 笔记库。

    必填字段: title, body
    可选字段: file（目标文件相对路径）, vault_path, tags, platforms,
              author, summary, ce_id, ce_status, ce_published_at

    生成带 frontmatter 的 .md 文件。

    Args:
        data: 包含内容数据的字典。
    """
    title = data.get("title", "")
    body = data.get("body", "")

    if not title:
        output_error("标题（title）为必填字段", code="VALIDATION_ERROR")
        return
    if not body:
        output_error("正文（body）为必填字段", code="VALIDATION_ERROR")
        return

    vault_path = data.get("vault_path") or _get_vault_path()
    if not vault_path:
        state = _get_sync_state()
        vault_path = state.get("vault_path")

    if not vault_path or not os.path.isdir(vault_path):
        output_error("未连接到 Obsidian 笔记库，请先执行 connect 操作", code="NOT_CONNECTED")
        return

    # 确定目标文件路径
    file_rel = data.get("file", "")
    if not file_rel:
        # 自动生成文件名
        safe_title = re.sub(r"[^\w\u4e00-\u9fff-]", "-", title)
        safe_title = re.sub(r"-+", "-", safe_title).strip("-")
        file_rel = f"{safe_title}.md"

    fpath = os.path.join(vault_path, file_rel)

    # 构建 frontmatter
    fm_data = {"title": title}
    if data.get("tags"):
        tags = data["tags"]
        if isinstance(tags, str):
            tags = [t.strip() for t in tags.split(",") if t.strip()]
        fm_data["tags"] = tags
    if data.get("platforms"):
        platforms = data["platforms"]
        if isinstance(platforms, str):
            platforms = [p.strip() for p in platforms.split(",") if p.strip()]
        fm_data["platforms"] = platforms
    if data.get("author"):
        fm_data["author"] = data["author"]
    if data.get("summary"):
        fm_data["summary"] = data["summary"]
    if data.get("ce_id"):
        fm_data["ce_id"] = data["ce_id"]
    if data.get("ce_status"):
        fm_data["ce_status"] = data["ce_status"]
    if data.get("ce_published_at"):
        fm_data["ce_published_at"] = data["ce_published_at"]

    frontmatter = _build_frontmatter(fm_data)
    full_content = frontmatter + "\n\n" + body

    # 写入文件
    try:
        os.makedirs(os.path.dirname(fpath) if os.path.dirname(fpath) != "" else fpath, exist_ok=True)
        with open(fpath, "w", encoding="utf-8") as f:
            f.write(full_content)
    except IOError as e:
        output_error(f"写入笔记失败: {e}", code="FILE_ERROR")
        return

    # 更新同步状态
    state = _get_sync_state()
    state["synced_files"][file_rel] = {
        "last_sync": now_iso(),
        "hash": _simple_hash(full_content),
        "direction": "export",
        "ce_id": data.get("ce_id", ""),
    }
    state["last_sync_at"] = now_iso()
    _save_sync_state(state)

    output_success({
        "message": f"已导出内容到 Obsidian: {file_rel}",
        "file": file_rel,
        "vault_path": vault_path,
        "full_path": fpath,
    })


# ============================================================
# 操作：双向同步
# ============================================================

def sync(data: Optional[Dict[str, Any]] = None) -> None:
    """Obsidian 笔记库双向同步。

    检测笔记库中的变更，导入新草稿，更新已发布内容的状态。

    可选字段: vault_path, dry_run（仅检测不执行）

    Args:
        data: 可选的配置字典。
    """
    vault_path = (data.get("vault_path") if data else None) or _get_vault_path()
    if not vault_path:
        state = _get_sync_state()
        vault_path = state.get("vault_path")

    if not vault_path or not os.path.isdir(vault_path):
        output_error("未连接到 Obsidian 笔记库，请先执行 connect 操作", code="NOT_CONNECTED")
        return

    dry_run = data.get("dry_run", False) if data else False
    state = _get_sync_state()

    new_drafts = []      # 新发现的草稿
    modified = []        # 已同步但有变更的文件
    unchanged = []       # 未变更的文件

    # 扫描笔记库
    for root, _dirs, files in os.walk(vault_path):
        for f in files:
            if not f.endswith(".md"):
                continue
            fpath = os.path.join(root, f)
            rel_path = os.path.relpath(fpath, vault_path)

            try:
                with open(fpath, "r", encoding="utf-8") as fp:
                    content = fp.read()
            except (IOError, UnicodeDecodeError):
                continue

            if not _is_draft_note(content):
                continue

            current_hash = _simple_hash(content)
            synced_info = state["synced_files"].get(rel_path)

            if synced_info is None:
                # 新草稿
                metadata, body = _parse_frontmatter(content)
                title = metadata.get("title", "")
                if not title:
                    h1_match = re.match(r"^#\s+(.+)$", body, re.MULTILINE)
                    title = h1_match.group(1).strip() if h1_match else os.path.splitext(f)[0]
                new_drafts.append({
                    "file": rel_path,
                    "title": title,
                    "hash": current_hash,
                })
            elif synced_info.get("hash") != current_hash:
                # 已同步但有变更
                modified.append({
                    "file": rel_path,
                    "ce_id": synced_info.get("ce_id", ""),
                    "old_hash": synced_info.get("hash", ""),
                    "new_hash": current_hash,
                    "last_sync": synced_info.get("last_sync", ""),
                })
            else:
                unchanged.append(rel_path)

    if not dry_run:
        state["last_sync_at"] = now_iso()
        _save_sync_state(state)

    output_success({
        "message": f"同步检测完成: {len(new_drafts)} 新草稿, {len(modified)} 已变更, {len(unchanged)} 未变更",
        "dry_run": dry_run,
        "vault_path": vault_path,
        "new_drafts": new_drafts,
        "modified": modified,
        "unchanged_count": len(unchanged),
        "note": "新草稿请使用 import-draft 导入，变更文件请手动确认后更新" if new_drafts or modified else "所有文件已同步",
    })


# ============================================================
# 主入口
# ============================================================

def main() -> None:
    """主函数：解析命令行参数并分发操作。"""
    parser = parse_common_args("content-engine Obsidian 笔记库集成")
    args = parser.parse_args()

    action = args.action.lower()

    try:
        data = load_input_data(args)
    except ValueError as e:
        output_error(str(e), code="INPUT_ERROR")
        return

    actions = {
        "connect": lambda: connect(data or {}),
        "import-draft": lambda: import_draft(data or {}),
        "export-draft": lambda: export_draft(data or {}),
        "list-drafts": lambda: list_drafts(data),
        "sync": lambda: sync(data),
    }

    handler = actions.get(action)
    if handler:
        handler()
    else:
        valid_actions = "、".join(actions.keys())
        output_error(f"未知操作: {action}，支持的操作: {valid_actions}", code="INVALID_ACTION")


if __name__ == "__main__":
    main()


path: references/wechat-guide.md
content: # 微信公众号 API 配置与使用指南

本指南帮助你完成微信公众号的 API 接入配置，实现通过 content-engine 自动发布文章到公众号。

---

## 前置条件

1. 已注册微信公众号（服务号或订阅号，推荐服务号）
2. 已完成微信认证（未认证账号 API 权限受限）
3. 具有管理员权限

---

## 第一步：获取 AppID 和 AppSecret

1. 登录 [微信公众平台](https://mp.weixin.qq.com/)
2. 进入「设置与开发」→「基本配置」
3. 在「公众号开发信息」区域找到：
   - **AppID（应用ID）**：固定不变，直接复制
   - **AppSecret（应用密钥）**：点击「重置」生成新密钥，**务必立即保存**，页面关闭后无法再次查看

4. 配置环境变量：
   ```bash
   export CE_WECHAT_APPID="你的AppID"
   export CE_WECHAT_SECRET="你的AppSecret"
   ```

> **安全提醒**：AppSecret 等同于账号密码，绝不能泄露。不要将其写入代码或提交到版本控制系统。

---

## 第二步：配置 IP 白名单

1. 在「基本配置」页面找到「IP白名单」
2. 点击「修改」，添加你的服务器公网 IP 地址
3. 若在本地开发，添加本机公网 IP（可通过 `curl ifconfig.me` 查询）

> 未配置白名单的 IP 地址调用 API 将返回 `40164` 错误。

---

## 第三步：access_token 机制说明

### 获取 access_token

content-engine 会自动调用以下接口获取 access_token：

```
GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&[REDACTED_SECRET]
```

返回示例：
```json
{
  "access_token": "ACCESS_TOKEN",
  "expires_in": 7200
}
```

### 有效期与刷新

- access_token 有效期为 **7200 秒（2 小时）**
- 每日调用上限为 **2000 次**
- content-engine 会在每次发布操作时自动获取新的 access_token
- 建议不要频繁调用，避免触发速率限制

### 常见错误码

| 错误码 | 说明 | 解决方案 |
|--------|------|----------|
| 40001 | AppSecret 错误 | 检查 CE_WECHAT_SECRET 是否正确 |
| 40002 | grant_type 无效 | 检查请求参数 |
| 40164 | IP 未在白名单 | 在公众号后台添加 IP 白名单 |
| 42001 | access_token 过期 | 重新获取（自动处理） |
| 45009 | API 调用频次超限 | 降低调用频率，等待后重试 |

---

## 第四步：文章发布流程

content-engine 的微信发布采用以下流程：

### 1. 创建草稿

```
POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN
```

请求体：
```json
{
  "articles": [
    {
      "title": "文章标题",
      "author": "作者",
      "digest": "摘要",
      "content": "<p>HTML格式正文</p>",
      "content_source_url": "",
      "thumb_media_id": "封面图素材ID",
      "need_open_comment": 0,
      "only_fans_can_comment": 0
    }
  ]
}
```

### 2. 发布文章

```
POST https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=ACCESS_TOKEN
```

请求体：
```json
{
  "media_id": "草稿的media_id"
}
```

### 3. 查询发布状态

```
POST https://api.weixin.qq.com/cgi-bin/freepublish/get?access_token=ACCESS_TOKEN
```

请求体：
```json
{
  "publish_id": "发布任务ID"
}
```

---

## 第五步：素材管理

### 上传永久素材（封面图）

如需设置文章封面图，需先上传图片为永久素材：

```
POST https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=ACCESS_TOKEN&type=image
```

返回的 `media_id` 可用于文章的 `thumb_media_id` 字段。

### 上传正文图片

正文中的图片需通过以下接口上传：

```
POST https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=ACCESS_TOKEN
```

返回的 URL 可直接在 HTML 正文的 `<img>` 标签中使用。

---

## 常见问题

### Q1：订阅号和服务号有什么区别？

服务号拥有更多 API 权限，包括模板消息、自定义菜单、网页授权等。建议使用服务号进行内容分发。

### Q2：发布后可以修改文章吗？

微信公众号已发布的文章不支持直接修改内容。如需更正，可以删除后重新发布，或发布勘误声明。

### Q3：每天可以发布几篇文章？

- 订阅号：每天 1 次推送，每次最多 8 篇
- 服务号：每月 4 次推送，每次最多 8 篇

### Q4：如何处理图片？

目前 content-engine 的微信适配器会将 Markdown 图片转换为 HTML `<img>` 标签。若图片为外部 URL，需确保微信服务器可访问该地址。建议使用微信素材接口上传图片获取永久链接。

---

## 调试建议

1. 使用 [微信公众平台接口调试工具](https://mp.weixin.qq.com/debug/) 测试 API 调用
2. 检查返回的 JSON 中是否包含 `errcode` 字段
3. 确认 IP 白名单配置正确
4. 确认 AppSecret 未被重置（重置后旧密钥立即失效）


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