Skill: youhan2021/wechat-api-lite (version 1.0.0)

--- name: wechat-api-lite description: 微信公众号 API 轻量化工具 — 凭证获取、素材上传、草稿创建，主打最小化依赖和零冗余 triggers: - "公众号 API" - "微信发布" - "创建草稿" - "上传素材" - "wechat api" --- # WeChat API Lite 微信公众号 API 轻量化工具。只保留发布图文必需的功能：token 获取、封面图上传、正文图片上传、草稿创建。无多余命令，无依赖膨胀。 --- ## 凭证配置 复制示例文件并填入真实值： ```bash SKILL_DIR="$HOME/.hermes/skills/wechat-api-lite" cp "$SKILL_DIR/config.env.example" "$SKILL_DIR/config.env" # 编辑 config.env，填入 AppID 和 AppSecret ``` > AppID 和 AppSecret 获取：[微信公众平台](https://mp.weixin.qq.com) → 设置与开发 → 基本配置 --- ## 核心命令 ### token — 获取 access_token 有效期 7200 秒，脚本内自动缓存 + 提前刷新。 ```bash python3 $HOME/.hermes/skills/wechat-api-lite/scripts/wechat_api.py token ``` --- ### upload-thumb — 上传封面图 返回 `thumb_media_id`，用于创建草稿时指定封面。 ```bash python3 $HOME/.hermes/skills/wechat-api-lite/scripts/wechat_api.py upload-thumb <文件路径> # 示例 python3 $HOME/.hermes/skills/wechat-api-lite/scripts/wechat_api.py upload-thumb ~/hermes/research/Cover.png ``` > 封面图推荐尺寸：900 × 383 px（2.35:1 宽屏），PNG/JPG，不超过 2MB。 --- ### upload-image — 上传正文图片 返回 `media_id` + `url`，在正文 HTML 中用 `url` 嵌入图片。 ```bash python3 $HOME/.hermes/skills/wechat-api-lite/scripts/wechat_api.py upload-image <文件路径> ``` --- ### create-draft — 创建图文草稿 ```bash python3 $HOME/.hermes/skills/wechat-api-lite/scripts/wechat_api.py create-draft ~/hermes/research/draft.json ``` **draft.json 格式：** ```json [ { "title": "文章标题（不超过32字）", "author": "作者名", "digest": "摘要", "content": "<p>HTML 格式正文...</p>", "thumb_media_id": "upload-thumb 返回的 ID", "show_cover_pic": 1, "need_open_comment": 1, "only_fans_can_comment": 0 } ] ``` 成功输出：`✅ 草稿创建成功: media_id=XXXXXXXXXXXXXXXX` --- ### draft-list — 查看草稿数量 ```bash python3 $HOME/.hermes/skills/wechat-api-lite/scripts/wechat_api.py draft-list ``` --- ## 完整发布流程（wechat-post + wechat-api-lite 配合） ``` 1. wechat-post 生成 Markdown 正文 ↓ 2. 将 Markdown 转为 HTML，写入 draft.json ↓ 3. wechat-api-lite upload-thumb（上传封面 → thumb_media_id） ↓ 4. wechat-api-lite create-draft（创建草稿 → media_id） ↓ 5. 登录 mp.weixin.qq.com 草稿箱 → 预览 → 发布 ``` --- ## 草稿字段说明 | 字段 | 必填 | 说明 | |------|------|------| | `title` | ✅ | 标题，不超过 32 字符 | | `author` | 否 | 作者名，不超过 16 字 | | `digest` | 否 | 摘要，默认取正文前 54 字 | | `content` | ✅ | HTML 格式正文 | | `thumb_media_id` | ✅ | 封面图 ID（来自 upload-thumb） | | `show_cover_pic` | 否 | 是否在正文显示封面（0/1） | | `need_open_comment` | 否 | 是否打开评论（0/1） | | `only_fans_can_comment` | 否 | 是否仅粉丝可评论（0/1） | --- ## 已知问题 & API 行为 ### access_token 过期（40001） token 过期后所有 API 调用均失败。清除缓存后重试： ```bash rm ~/.hermes/skills/wechat-api-lite/scripts/.token_cache python3 $HOME/.hermes/skills/wechat-api-lite/scripts/wechat_api.py upload-thumb ... ``` ### 成功响应无 errcode 以下接口成功时不返回 `errcode`，判断成功仅看业务字段是否存在： | 接口 | 成功判断依据 | |------|-------------| | `draft/add`（创建草稿） | `"media_id" in result` | | `draft/count`（草稿数量） | `"total_count" in result` | ### url vs media_id - **正文 HTML 图片** → 用 `upload-image` 返回的 `url` - **草稿封面图** → 用 `upload-thumb` 返回的 `media_id` 两者不可混用。 ### ⚠️ 草稿正文图注位置注意 在 JSON 中写 HTML 正文时，如果要给配图加 caption 注释，**caption 必须紧跟在对应的 `<img>` 标签后面**，不要写在正文开头或其他位置。公众号渲染会把 caption 放在图片在正文中出现的相对位置，误放在文章顶部会导致 caption 跑到文章最下方显示。 - **正文 HTML 图片** → 用 `upload-image` 返回的 `url` - **草稿封面图** → 用 `upload-thumb` 返回的 `media_id` 两者不可混用。 --- ## 注意事项 - access_token 有效期 2 小时，脚本自动维护缓存，过期后手动清缓存重刷 - 永久素材有总量限制，缩略图不超过 2MB - 封面图推荐 900 × 383 px，正文图片宽度建议 ≤ 1080px - 草稿创建后在 [mp.weixin.qq.com](https://mp.weixin.qq.com) 草稿箱查看和编辑

path: config.env.example.txt
content: # 微信公众号 AppID 和 AppSecret
# 请复制此文件为 config.env 并填入真实值
# 注意：config.env 已在 .gitignore 中，不会被提交到 Git

WECHAT_APP_ID=your_app_id_here
WECHAT_APP_SECRET=your_app_secret_here


path: README.md
content: # wechat-api-lite

A lightweight WeChat Official Account API skill. Covers only the essentials: token management, image upload, and draft creation. No bloat, minimal dependencies.

[中文版](./README_zh.md)

> Installed via `npx skills add youhan2021/wechat-api-lite` or similar skills CLI.

---

## What it does

| Command | Description |
|---------|-------------|
| `token` | Get / refresh access_token |
| `upload-thumb` | Upload cover image → thumb_media_id |
| `upload-image` | Upload body image → media_id + url |
| `create-draft` | Create article draft |
| `draft-list` | Show draft count |

---

## Setup

**1. Copy and fill in credentials**

```bash
cp ~/.hermes/skills/wechat-api-lite/config.env.example \
   ~/.hermes/skills/wechat-api-lite/config.env

# Edit config.env with your AppID and AppSecret
# Get them at: mp.weixin.qq.com → Settings → Basic Config
```

**2. Upload a cover image**

```bash
python3 ~/.hermes/skills/wechat-api-lite/scripts/wechat_api.py \
  upload-thumb ~/hermes/research/Cover.png
# Returns thumb_media_id
```

**3. Create a draft**

```bash
python3 ~/.hermes/skills/wechat-api-lite/scripts/wechat_api.py \
  create-draft ~/hermes/research/draft.json
```

**4. Preview & publish**

Sign in to [mp.weixin.qq.com](https://mp.weixin.qq.com) → Drafts → Preview → Publish

---

## Draft JSON Format

```json
[
  {
    "title": "Article title (max 32 chars)",
    "author": "Author name",
    "digest": "Article summary",
    "content": "<p>HTML formatted body...</p>",
    "thumb_media_id": "ID returned by upload-thumb",
    "show_cover_pic": 1,
    "need_open_comment": 1,
    "only_fans_can_comment": 0
  }
]
```

---

## FAQ

**Getting 40001 errors?**
Token expired. Clear the cache and retry:

```bash
rm ~/.hermes/skills/wechat-api-lite/scripts/.token_cache
```

**url vs media_id — which to use?**
- Image links in HTML body → `url` from `upload-image`
- Cover image in draft → `media_id` from `upload-thumb`

---

## Workflow with wechat-post

```
wechat-post generates Markdown
        ↓
Convert Markdown to HTML, write to draft.json
        ↓
wechat-api-lite upload-thumb (cover)
        ↓
wechat-api-lite create-draft (publish)
```


path: README_zh.md
content: # wechat-api-lite

微信公众号 API 轻量化 Skill。只保留发布图文必需的功能：token 获取、封面图上传、正文图片上传、草稿创建。无多余命令，无依赖膨胀。

[English](./README.md)

> 通过 `npx skills add youhan2021/wechat-api-lite` 或同类 skills CLI 安装。

---

## 功能列表

| 命令 | 作用 |
|------|------|
| `token` | 获取 / 刷新 access_token |
| `upload-thumb` | 上传封面图 → thumb_media_id |
| `upload-image` | 上传正文图片 → media_id + url |
| `create-draft` | 创建图文草稿 |
| `draft-list` | 查看草稿箱数量 |

---

## 快速开始

**1. 配置凭证**

```bash
cp ~/.hermes/skills/wechat-api-lite/config.env.example \
   ~/.hermes/skills/wechat-api-lite/config.env

# 编辑 config.env，填入 AppID 和 AppSecret
# 获取地址：mp.weixin.qq.com → 设置与开发 → 基本配置
```

**2. 上传封面图**

```bash
python3 ~/.hermes/skills/wechat-api-lite/scripts/wechat_api.py \
  upload-thumb ~/hermes/research/Cover.png
# 返回 thumb_media_id
```

**3. 创建草稿**

```bash
python3 ~/.hermes/skills/wechat-api-lite/scripts/wechat_api.py \
  create-draft ~/hermes/research/draft.json
```

**4. 预览发布**

登录 [mp.weixin.qq.com](https://mp.weixin.qq.com) → 草稿箱 → 预览 → 发布

---

## 草稿 JSON 格式

```json
[
  {
    "title": "文章标题（不超过32字）",
    "author": "作者名",
    "digest": "摘要",
    "content": "<p>HTML 格式正文...</p>",
    "thumb_media_id": "upload-thumb 返回的 ID",
    "show_cover_pic": 1,
    "need_open_comment": 1,
    "only_fans_can_comment": 0
  }
]
```

---

## 常见问题

**API 返回 40001？**
Token 过期，清除缓存后重试：

```bash
rm ~/.hermes/skills/wechat-api-lite/scripts/.token_cache
```

**图片用 url 还是 media_id？**
- 正文 HTML 图片链接 → 用 `upload-image` 返回的 `url`
- 封面图 ID → 用 `upload-thumb` 返回的 `media_id`

---

## 与 wechat-post 配合

```
wechat-post 生成 Markdown
        ↓
将 Markdown 转为 HTML，写入 draft.json
        ↓
wechat-api-lite upload-thumb（上传封面）
        ↓
wechat-api-lite create-draft（发布草稿）
```


path: scripts/wechat_api.py
content: #!/usr/bin/env python3
"""
wechat-api-lite — 微信公众号 API 轻量化脚本
支持：获取 access_token、上传素材、创建草稿、发布等

环境变量（从 config.env 读取）：
  WECHAT_APP_ID
  WECHAT_APP_SECRET
"""

import os, sys, json, re, time
import urllib.request
import urllib.parse
import urllib.error

# ── Config ────────────────────────────────────────────────────────────────────

SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
ENV_FILE  = os.path.join(SKILL_DIR, "..", "config.env")

def load_env():
    if os.path.exists(ENV_FILE):
        with open(ENV_FILE) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    k, v = line.split("=", 1)
                    os.environ.setdefault(k.strip(), v.strip())

load_env()

APP_ID     = os.environ.get("WECHAT_APP_ID", "")
APP_SECRET = os.environ.get("WECHAT_APP_SECRET", "")

# ── HTTP helper ────────────────────────────────────────────────────────────────

def wechat_request(method, url, data=None, token=None):
    """Send HTTP request to WeChat API with JSON handling."""
    if data is not None:
        data = json.dumps(data, ensure_ascii=False).encode()

    params = {}
    if [REDACTED_SECRET]"access_token"] = token

    if params:
        url += ("?" if "?" not in url else "&") + urllib.parse.urlencode(params)

    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("Content-Type", "application/json; charset=utf-8")

    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            result = json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        body = e.read().decode()
        try:
            result = json.loads(body)
        except Exception:
            result = {"errcode": -1, "errmsg": body}
    except urllib.error.URLError as e:
        result = {"errcode": -1, "errmsg": str(e)}

    return result


def get_access_token():
    """获取 access_token（有效期 7200 秒）"""
    if not APP_ID or not APP_SECRET:
        print("错误：请先在 config.env 中填入 WECHAT_APP_ID 和 WECHAT_APP_SECRET")
        sys.exit(1)

    url = f"https://api.weixin.qq.com/cgi-bin/token"
    result = wechat_request("GET", url, token=False)

    # Build URL manually
    full_url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={APP_ID}&[REDACTED_SECRET]"
    req = urllib.request.Request(full_url, method="GET")
    with urllib.request.urlopen(req, timeout=30) as resp:
        result = json.loads(resp.read().decode())

    if "access_token" in result:
        return result["access_token"], result.get("expires_in", 7200)
    else:
        errcode = result.get("errcode", -1)
        errmsg  = result.get("errmsg", "unknown error")
        print(f"获取 access_token 失败: [{errcode}] {errmsg}")
        sys.exit(1)


# ── Token cache (simple file-based) ───────────────────────────────────────────

TOKEN_CACHE = os.path.join(SKILL_DIR, ".token_cache")

def read_cached_token():
    """读取缓存的 token"""
    if not os.path.exists(TOKEN_CACHE):
        return None
    try:
        with open(TOKEN_CACHE) as f:
            cache = json.loads(f.read())
        # 检查是否过期（提前5分钟）
        if time.time() < cache["expires_at"] - 300:
            return cache["token"]
    except Exception:
        pass
    return None

def write_cached_token(token, expires_in):
    with open(TOKEN_CACHE, "w") as f:
        json.dump({
            "token": token,
            "expires_at": time.time() + expires_in
        }, f)

def get_token():
    """获取 token（优先使用缓存）"""
    [REDACTED_SECRET])
    if [REDACTED_SECRET] token
    token, expires_in = get_access_token()
    write_cached_token(token, expires_in)
    return token


# ── API: 上传永久素材（图片） ────────────────────────────────────────────────

def upload_image(file_path):
    """
    上传图片永久素材。
    返回 {"media_id": "...", "url": "..."}
    """
    if not os.path.isfile(file_path):
        print(f"错误：文件不存在 {file_path}")
        sys.exit(1)

    [REDACTED_SECRET])
    url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image"

    boundary = "----WechatBoundary" + str(int(time.time() * 1000))
    body_bytes = []

    filename = os.path.basename(file_path)
    with open(file_path, "rb") as f:
        file_data = f.read()

    # Form field
    body_bytes.append(f"--{boundary}\r\n".encode())
    body_bytes.append(f'Content-Disposition: form-data; name="media"; filename="{filename}"\r\n'.encode())
    body_bytes.append("Content-Type: application/octet-stream\r\n\r\n".encode())
    body_bytes.append(file_data)
    body_bytes.append(f"\r\n--{boundary}--\r\n".encode())

    req = urllib.request.Request(url, data=b"".join(body_bytes), method="POST")
    req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")

    with urllib.request.urlopen(req, timeout=60) as resp:
        result = json.loads(resp.read().decode())

    if result.get("errcode") == 0 or "media_id" in result:
        print(f"✅ 图片上传成功: media_id={result.get('media_id', 'N/A')}")
        return result
    else:
        print(f"❌ 上传失败: {result}")
        sys.exit(1)


# ── API: 上传 thumb（封面） ───────────────────────────────────────────────────

def upload_thumb(file_path):
    """
    上传封面缩略图（永久）。
    返回 media_id。
    """
    if not os.path.isfile(file_path):
        print(f"错误：文件不存在 {file_path}")
        sys.exit(1)

    [REDACTED_SECRET])
    url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=thumb"

    boundary = "----WechatBoundary" + str(int(time.time() * 1000))
    body_bytes = []

    filename = os.path.basename(file_path)
    with open(file_path, "rb") as f:
        file_data = f.read()

    body_bytes.append(f"--{boundary}\r\n".encode())
    body_bytes.append(f'Content-Disposition: form-data; name="media"; filename="{filename}"\r\n'.encode())
    body_bytes.append("Content-Type: application/octet-stream\r\n\r\n".encode())
    body_bytes.append(file_data)
    body_bytes.append(f"\r\n--{boundary}--\r\n".encode())

    req = urllib.request.Request(url, data=b"".join(body_bytes), method="POST")
    req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")

    with urllib.request.urlopen(req, timeout=60) as resp:
        result = json.loads(resp.read().decode())

    if result.get("errcode") == 0 or "media_id" in result:
        print(f"✅ 封面上传成功: media_id={result.get('media_id', 'N/A')}")
        return result
    else:
        print(f"❌ 封面上传失败: {result}")
        sys.exit(1)


# ── API: 创建草稿 ─────────────────────────────────────────────────────────────

def create_draft(articles):
    """
    创建草稿箱图文消息。
    articles: list of dict, each dict has keys:
      title, author (opt), digest (opt), content,
      content_source_url (opt), thumb_media_id,
      show_cover_pic (0/1), need_open_comment (0/1), only_fans_can_comment (0/1)
    """
    [REDACTED_SECRET])
    url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"

    payload = {"articles": articles}
    result = wechat_request("POST", url, data=payload)

    # 微信新增草稿接口成功时不返回 errcode，仅检查 media_id
    if "media_id" in result:
        media_id = result.get("media_id", "N/A")
        print(f"✅ 草稿创建成功: media_id={media_id}")
        return {"media_id": media_id}
    else:
        errcode = result.get("errcode", -1)
        errmsg  = result.get("errmsg", "unknown error")
        print(f"❌ 创建草稿失败: [{errcode}] {errmsg}")
        print(f"   完整响应: {result}")
        sys.exit(1)


# ── API: 获取草稿列表 ─────────────────────────────────────────────────────────

def get_draft_list():
    """获取草稿列表"""
    [REDACTED_SECRET])
    url = f"https://api.weixin.qq.com/cgi-bin/draft/count?access_token={token}"
    result = wechat_request("GET", url)

    # 微信草稿计数接口成功时直接返回 total_count，无 errcode
    if "total_count" in result:
        count = result.get("total_count", 0)
        print(f"草稿箱共有 {count} 篇草稿")
        return result
    else:
        errcode = result.get("errcode", -1)
        errmsg  = result.get("errmsg", "unknown error")
        print(f"❌ 获取草稿列表失败: [{errcode}] {errmsg}")
        sys.exit(1)


# ── CLI ───────────────────────────────────────────────────────────────────────

def usage():
    print("""
wechat-api — 微信公众号 API 工具

用法:
  python3 wechat_api.py token                        # 获取 access_token
  python3 wechat_api.py upload-image <file>          # 上传图片素材
  python3 wechat_api.py upload-thumb <file>          # 上传封面缩略图
  python3 wechat_api.py create-draft <json_file>     # 创建草稿（从 JSON 文件）
  python3 wechat_api.py draft-list                   # 查看草稿数量

示例:
  # 1. 上传封面图获取 thumb_media_id
  python3 wechat_api.py upload-thumb ~/hermes/research/Cover.png

  # 2. 创建草稿（准备好 JSON 文件）
  python3 wechat_api.py create-draft ~/hermes/research/draft.json

JSON 草稿文件格式:
  [
    {
      "title": "文章标题",
      "author": "作者名",
      "digest": "摘要（可选）",
      "content": "<p>HTML 正文内容</p>",
      "thumb_media_id": "缩略图 media_id",
      "show_cover_pic": 1,
      "need_open_comment": 1,
      "only_fans_can_comment": 0
    }
  ]
""")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        usage()
        sys.exit(0)

    cmd = sys.argv[1]

    if cmd == "token":
        token, exp = get_access_token()
        print(f"access_token: {token}")
        print(f"expires_in:   {exp}s")

    elif cmd == "upload-image":
        if len(sys.argv) < 3:
            print("错误：缺少文件路径"); sys.exit(1)
        result = upload_image(sys.argv[2])
        print(json.dumps(result, ensure_ascii=False))

    elif cmd == "upload-thumb":
        if len(sys.argv) < 3:
            print("错误：缺少文件路径"); sys.exit(1)
        result = upload_thumb(sys.argv[2])
        print(json.dumps(result, ensure_ascii=False))

    elif cmd == "create-draft":
        if len(sys.argv) < 3:
            print("错误：缺少 JSON 文件路径"); sys.exit(1)
        with open(sys.argv[2], encoding="utf-8") as f:
            articles = json.load(f)
        result = create_draft(articles)
        print(json.dumps(result, ensure_ascii=False))

    elif cmd == "draft-list":
        result = get_draft_list()
        print(json.dumps(result, ensure_ascii=False))

    else:
        print(f"未知命令: {cmd}")
        usage()
        sys.exit(1)


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