Skill: jacobluo/mowenskill-publish (version 1.0.2)

--- name: mowen-note-publisher description: Publish, edit, or configure notes on Mowen (墨问) via Open API. Use when user mentions 墨问, mowen, 发布笔记, publish note, or wants to create social-media-style notes with rich text and images. dependencies: "python>=3" --- # 墨问笔记发布 (Mowen Note Publisher) ## Overview Publish rich-text notes (text, images, formatting) to 墨问 via its Open API. This skill covers three operations: **creating** new notes, **editing** existing notes (API-created only), and **modifying note settings** (privacy, sharing, expiration). A Python script (`scripts/publish_note.py`) handles all API calls, image uploads, and NoteAtom construction with zero third-party dependencies. ## Prerequisites - **API Key**: Obtain from 墨问小程序 → 我的 → 设置 → 开放平台 → 创建 API Key - **Environment**: Set `MOWEN_API_KEY` environment variable, or pass via `--api-key` argument - **Python 3**: Required for running the publish script (macOS has it pre-installed) ## Workflow Decision Tree ``` User Request │ ├── "发布/创建笔记" or "publish note" │ └── ACTION: create │ ├── Has images? → Upload images first (see Image Handling) │ ├── Build input JSON with paragraphs, tags, autoPublish │ └── Run: publish_note.py --action create │ ├── "编辑/修改笔记内容" or "edit note" │ └── ACTION: edit (requires noteId from previous create) │ ├── ⚠️ Only API-created notes can be edited │ ├── Build input JSON with new paragraphs │ └── Run: publish_note.py --action edit --note-id <ID> │ └── "修改笔记设置/隐私" or "change note settings" └── ACTION: settings (requires noteId) └── Run: publish_note.py --action settings --note-id <ID> --privacy <N> ``` ## Creating a Note (Step by Step) ### 1. Gather Content from User Collect the note content. Identify: - Text paragraphs (plain text or rich text with bold/highlight/links) - Images (local file paths or URLs) - Tags (optional, max 10, each ≤ 30 chars) - Whether to auto-publish (default: draft) - Privacy setting (default: public) ### 2. Prepare Input JSON Build a JSON object following this schema: ```json { "paragraphs": [ "Plain text paragraph", [{"text": "Bold text", "bold": true}, " and normal text"], {"type": "heading", "level": 1, "text": "Title"}, {"type": "image", "src": "https://example.com/photo.jpg"}, {"type": "image", "src": "/path/to/local/photo.jpg", "width": 1080, "height": 720}, {"type": "blockquote", "text": "A quote"}, {"type": "bulletList", "items": ["Item 1", "Item 2"]}, {"type": "orderedList", "items": ["First", "Second"]} ], "tags": ["tag1", "tag2"], "autoPublish": true, "privacyType": 1 } ``` **Paragraph types:** | Type | Format | Description | |------|--------|-------------| | Plain text | `"string"` | Simple text paragraph | | Rich text | `[{...}, ...]` | Array of text items with optional `bold`, `highlight`, `link` | | Heading | `{"type":"heading", "level":1, "text":"..."}` | h1-h6 heading | | Image | `{"type":"image", "src":"..."}` | Image (URL, local path, or fileId) | | Quote | `{"type":"blockquote", "text":"..."}` or `{"type":"quote", "text":"..."}` | Block quote | | Bullet list | `{"type":"bulletList", "items":["..."]}` | Unordered list | | Ordered list | `{"type":"orderedList", "items":["..."]}` | Numbered list | | Raw node | `{"type":"raw", "node":{...}}` | Direct NoteAtom node passthrough | **Privacy types:** `public` = public, `private` = self-only, `rule` = custom rules (with noShare/expireAt) ### 3. Save Input and Run Script Save the JSON to a temporary file and execute: ```bash python {SKILL_DIR}/scripts/publish_note.py --action create --input /tmp/note_input.json ``` Or pipe directly via stdin: ```bash echo '<json>' | python {SKILL_DIR}/scripts/publish_note.py --action create ``` The script outputs a JSON result with `noteId` on stdout. **Save this noteId** — it is needed for subsequent edit or settings operations. ### 4. Report Result Inform the user of the created note's ID and status (draft vs published). ## Editing a Note > ⚠️ **Limitation**: Only notes created via the API can be edited. Notes created in the 墨问 mini-program cannot be edited through this API. To edit, provide the `noteId` (from a previous create) and new content: ```bash python {SKILL_DIR}/scripts/publish_note.py --action edit --note-id <NOTE_ID> --input /tmp/note_edit.json ``` The input JSON format is identical to creation (the `paragraphs` field). The entire note content will be **replaced**. ## Modifying Note Settings To change privacy, sharing, or expiration settings: ```bash # Set to private python {SKILL_DIR}/scripts/publish_note.py --action settings --note-id <NOTE_ID> --privacy private # Set to public python {SKILL_DIR}/scripts/publish_note.py --action settings --note-id <NOTE_ID> --privacy public # Set custom rule: forbid sharing with expiration python {SKILL_DIR}/scripts/publish_note.py --action settings --note-id <NOTE_ID> --privacy rule --no-share --expire-at 1735689600 ``` ## Image Handling The script automatically handles images found in `paragraphs`: - **URL images** (`http://` or `https://`): Uploaded via remote upload API (< 30MB) - **Local file paths**: Uploaded via local upload API (< 50MB, formats: gif/jpeg/jpg/png/webp) - **Existing fileId**: Passed through directly (no upload needed) Image upload is automatic — simply include image entries in the `paragraphs` array and the script handles the rest. Optionally provide `width` and `height` for better rendering. Rate limiting (1 req/sec) is automatically enforced between API calls. ## API Reference For complete API documentation including endpoint details, NoteAtom structure specification, request/response examples, and error codes, refer to: 📄 `references/mowen_api.md` Key search patterns for the reference file: - `## 1. 笔记创建` — Create endpoint details - `## 2. 笔记编辑` — Edit endpoint details - `## 3. 笔记设置` — Settings endpoint details - `## 4. NoteAtom 结构定义` — Full NoteAtom schema with all node types - `## 5. 图片上传` — Image upload (local + remote) procedures - `## 7. 限频与配额汇总` — Rate limits and daily quotas ## Common Use Cases ### Publish a simple text note ```json { "paragraphs": [ "今天天气真好，适合出门散步 ☀️" ], "tags": ["日常", "心情"], "autoPublish": true } ``` ### Publish a note with images and formatting ```json { "paragraphs": [ {"type": "heading", "level": 1, "text": "周末探店记录"}, "今天去了一家超棒的咖啡店！", {"type": "image", "src": "https://example.com/coffee.jpg"}, [{"text": "拿铁", "bold": true}, "味道超赞，推荐指数 ⭐⭐⭐⭐⭐"], {"type": "image", "src": "/Users/me/photos/cake.jpg", "width": 1080, "height": 1080}, {"type": "blockquote", "text": "生活需要一点甜"}, {"type": "bulletList", "items": ["环境优雅", "咖啡好喝", "甜品精致"]} ], "tags": ["探店", "咖啡", "美食"], "autoPublish": true } ``` ### Edit a previously created note ```bash # Original creation returned noteId = "note_abc123" python publish_note.py --action edit --note-id note_abc123 --input updated_content.json ``` ### Make a note private ```bash python publish_note.py --action settings --note-id note_abc123 --privacy private ```

path: references/mowen_api.md
content: # 墨问 Open API 参考文档

## 概述

- **Base URL**: `https://open.mowen.cn`
- **认证方式**: Bearer Token（API-KEY）
- **请求头**: `Authorization: Bearer {API-KEY}`, `Content-Type: application/json`
- **全局限频**: 所有 API 均限频 1次/秒
- **响应格式**: 成功时直接返回 JSON 数据（无 code/data 包装），错误时返回 `{"code": N, "reason": "...", "message": "..."}`

---

## 1. 笔记创建

### POST `/api/open/api/v1/note/create`

创建一篇新笔记。

| 属性 | 说明 |
|------|------|
| 限频 | 1次/秒 |
| 配额 | 100次/天 |

### 请求体

```json
{
  "body": {
    "type": "doc",
    "content": [ ... ]
  },
  "settings": {
    "tags": ["标签1", "标签2"],
    "autoPublish": true
  }
}
```

#### 字段说明

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `body` | object | 是 | NoteAtom 树结构，详见 [NoteAtom 结构](#4-noteatom-结构定义) |
| `settings.tags` | string[] | 否 | 标签列表，最多 10 个，每个不超过 30 字符 |
| `settings.autoPublish` | boolean | 否 | 是否自动发布，默认 `false`（草稿） |

### 成功响应

```json
{
  "noteId": "note_abc123"
}
```

---

## 2. 笔记编辑

### POST `/api/open/api/v1/note/edit`

编辑已有笔记的内容。

> **重要限制**: 仅支持编辑通过 API 创建的笔记，不支持小程序端创建的笔记。

| 属性 | 说明 |
|------|------|
| 限频 | 1次/秒 |
| 配额 | 1000次/天 |

### 请求体

```json
{
  "noteId": "note_abc123",
  "body": {
    "type": "doc",
    "content": [ ... ]
  }
}
```

#### 字段说明

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `noteId` | string | 是 | 笔记 ID（创建时返回） |
| `body` | object | 是 | 新的 NoteAtom 树结构，会完全替换原有内容 |

### 成功响应

```json
{
  "noteId": "note_abc123"
}
```

---

## 3. 笔记设置

### POST `/api/open/api/v1/note/set`

修改笔记的隐私设置。

| 属性 | 说明 |
|------|------|
| 限频 | 1次/秒 |
| 配额 | 100次/天 |

### 请求体

```json
{
  "noteId": "note_abc123",
  "section": 1,
  "settings": {
    "privacy": {
      "type": "public"
    }
  }
}
```

带规则的隐私设置：

```json
{
  "noteId": "note_abc123",
  "section": 1,
  "settings": {
    "privacy": {
      "type": "rule",
      "rule": {
        "noShare": true,
        "expireAt": "1700000000"
      }
    }
  }
}
```

#### 字段说明

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `noteId` | string | 是 | 笔记 ID |
| `section` | integer | 是 | 设置类型，`1` = 隐私设置 |
| `settings.privacy.type` | string | 是 | `"public"` = 公开, `"private"` = 仅自己可见, `"rule"` = 自定义规则 |
| `settings.privacy.rule.noShare` | boolean | 否 | 是否禁止分享（仅 rule 类型） |
| `settings.privacy.rule.expireAt` | string | 否 | 过期时间戳字符串，`"0"` = 永不过期（仅 rule 类型） |

### 成功响应

```json
{}
```

---

## 4. NoteAtom 结构定义

NoteAtom 是墨问笔记的富文本内容结构，采用树状嵌套形式。

### 根节点

```json
{
  "type": "doc",
  "content": [ ... ]
}
```

根节点 `type` **必须**为 `"doc"`，`content` 为子节点数组。

### 节点类型

根据官方 API 文档，支持以下节点类型：

| 节点类型 | 分类 | 说明 |
|----------|------|------|
| `doc` | 根节点 | 顶层必须是 doc |
| `paragraph` | block | 段落 |
| `text` | inline | 文本内容 |
| `heading` | block | 标题（attrs.level: "1"-"6"） |
| `quote` | block | 引用块 |
| `image` | block | 图片（attrs.uuid 为文件ID） |
| `audio` | block | 音频（attrs.uuid 为文件ID） |
| `pdf` | block | PDF（attrs.uuid 为文件ID） |
| `note` | block | 内链笔记（attrs.uuid 为笔记ID） |
| `bold` | marks | 加粗标记 |
| `highlight` | marks | 高亮标记 |
| `link` | marks | 链接标记（attrs.href） |
| `bulletList` | block | 无序列表 |
| `orderedList` | block | 有序列表 |
| `listItem` | block | 列表项 |

> **注意**: `attrs` 中所有属性值均为 `string` 类型。

#### 段落 (paragraph)

```json
{
  "type": "paragraph",
  "content": [
    {
      "type": "text",
      "text": "这是一段文字"
    }
  ]
}
```

#### 文本 (text)

文本节点是叶子节点，可以带有格式标记（marks）。

```json
{
  "type": "text",
  "text": "加粗高亮文字",
  "marks": [
    { "type": "bold" },
    { "type": "highlight" }
  ]
}
```

**支持的 marks 类型：**

| Mark 类型 | 说明 | 附加属性 |
|-----------|------|----------|
| `bold` | 加粗 | 无 |
| `highlight` | 高亮 | 无 |
| `link` | 超链接 | `attrs: { href: "https://..." }` |

#### 链接文本示例

```json
{
  "type": "text",
  "text": "点击这里",
  "marks": [
    {
      "type": "link",
      "attrs": {
        "href": "https://example.com"
      }
    }
  ]
}
```

#### 图片 (image)

```json
{
  "type": "image",
  "attrs": {
    "uuid": "file_id_from_upload",
    "width": "1080",
    "height": "720",
    "ratio": "1.5",
    "align": "center",
    "alt": "图片描述"
  }
}
```

| 属性 | 类型 | 说明 |
|------|------|------|
| `uuid` | string | 图片文件 ID，来自上传 API 返回的 `fileId` |
| `width` | string | 图片宽度（px），字符串类型 |
| `height` | string | 图片高度（px），字符串类型 |
| `ratio` | string | 宽高比 `width / height`，字符串类型 |
| `align` | string | 对齐方式，可选 `left`/`center`/`right` |
| `alt` | string | 图片描述 |

> 图片尺寸信息可选但推荐提供，用于前端渲染占位。
> **注意**: `attrs` 中所有值均为字符串类型。

#### 引用块 (quote)

```json
{
  "type": "quote",
  "content": [
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "这是引用内容" }
      ]
    }
  ]
}
```

#### 标题 (heading)

```json
{
  "type": "heading",
  "attrs": { "level": "1" },
  "content": [
    { "type": "text", "text": "一级标题" }
  ]
}
```

`level` 取值 `"1"` 到 `"6"`（字符串类型），对应 h1-h6。

#### 有序列表 (orderedList) / 无序列表 (bulletList)

```json
{
  "type": "bulletList",
  "content": [
    {
      "type": "listItem",
      "content": [
        {
          "type": "paragraph",
          "content": [
            { "type": "text", "text": "列表项 1" }
          ]
        }
      ]
    }
  ]
}
```

有序列表使用 `"type": "orderedList"`，结构相同。

### 完整 NoteAtom 示例

一篇包含标题、文字、图片和引用的笔记：

```json
{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": { "level": "1" },
      "content": [
        { "type": "text", "text": "我的旅行日记" }
      ]
    },
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "今天去了" },
        {
          "type": "text",
          "text": "西湖",
          "marks": [{ "type": "bold" }]
        },
        { "type": "text", "text": "，风景很美！" }
      ]
    },
    {
      "type": "image",
      "attrs": {
        "uuid": "abc123-file-id",
        "width": "1920",
        "height": "1080",
        "ratio": "1.78"
      }
    },
    {
      "type": "quote",
      "content": [
        {
          "type": "paragraph",
          "content": [
            { "type": "text", "text": "欲把西湖比西子，淡妆浓抹总相宜。" }
          ]
        }
      ]
    }
  ]
}
```

---

## 5. 图片上传

### 5.1 本地上传（两步流程）

适用于上传本地文件。支持格式：gif, jpeg, jpg, png, webp。大小限制：< 50MB。

#### 步骤 1：获取上传授权信息

**POST** `/api/open/api/v1/upload/prepare`

| 属性 | 说明 |
|------|------|
| 限频 | 1次/秒 |
| 配额 | 200次/天 |

请求体：

```json
{
  "fileType": 1,
  "fileName": "photo.jpg"
}
```

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `fileType` | integer | 是 | `1`=图片, `2`=音频, `3`=PDF |
| `fileName` | string | 否 | 文件名（含扩展名） |

成功响应：

```json
{
  "form": {
    "endpoint": "https://xxx.oss-cn-xxx.aliyuncs.com",
    "key": "uploads/xxx/photo.jpg",
    "policy": "eyJ...",
    "callback": "eyJ...",
    "success_action_status": "200",
    "x-oss-signature-version": "OSS4-HMAC-SHA256",
    "x-oss-credential": "xxx",
    "x-oss-date": "20250101T000000Z",
    "x-oss-signature": "xxx",
    "x-oss-meta-mo-uid": "xxx",
    "x:file_name": "photo.jpg",
    "x:file_id": "file_abc123",
    "x:file_uid": "xxx"
  }
}
```

#### 步骤 2：文件投递到 OSS

**POST** `{endpoint}/`（endpoint 来自步骤 1 的返回）

- **Content-Type**: `multipart/form-data`
- 将步骤 1 返回的 `form` 中所有字段作为表单字段发送
- 最后追加 `file` 字段，值为实际文件二进制内容

表单字段顺序：

1. `key`
2. `policy`
3. `callback`
4. `success_action_status`
5. `x-oss-signature-version`
6. `x-oss-credential`
7. `x-oss-date`
8. `x-oss-signature`
9. `x-oss-meta-mo-uid`
10. `x:file_name`
11. `x:file_id`
12. `x:file_uid`
13. `file`（文件二进制数据）

成功后，图片的 `fileId` 即为步骤 1 返回的 `form["x:file_id"]`，用于 NoteAtom 中 `image.attrs.uuid`。

### 5.2 远程上传（一步完成）

适用于上传网络图片 URL。大小限制：< 30MB。

**POST** `/api/open/api/v1/upload/url`

| 属性 | 说明 |
|------|------|
| 限频 | 1次/秒 |
| 配额 | 200次/天 |

请求体：

```json
{
  "fileType": 1,
  "url": "https://example.com/photo.jpg",
  "fileName": "photo.jpg"
}
```

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `fileType` | integer | 是 | `1`=图片, `2`=音频, `3`=PDF |
| `url` | string | 是 | 远程文件 URL |
| `fileName` | string | 否 | 文件名 |

成功响应：

```json
{
  "file": {
    "uid": "xxx",
    "fileId": "file_abc123",
    "name": "photo.jpg",
    "path": "https://cdn.mowen.cn/xxx/photo.jpg",
    "type": 1,
    "format": "jpg"
  }
}
```

返回的 `file.fileId` 即为 NoteAtom 中 `image.attrs.uuid` 的值。

> 远程上传受目标服务器速度、防盗链策略等影响，不保证一定成功。建议优先使用远程上传，失败时提示用户下载图片后使用本地上传。

---

## 6. 错误响应格式

错误响应统一格式：

```json
{
  "code": 400,
  "reason": "PARAMS",
  "message": "具体错误描述",
  "metadata": {}
}
```

| reason | 说明 | 建议 |
|--------|------|------|
| `LOGIN` | 认证失败 | 检查 API-KEY 是否正确或已过期 |
| `PARAMS` | 参数错误 | 检查请求参数格式和内容 |
| `VALIDATOR` | 请求体校验失败 | 检查请求体结构是否符合要求 |
| `PERM` | 无权限 | 检查 API-KEY 权限范围 |
| `RATELIMIT` | 频率限制 | 等待后重试，确保间隔 >= 1秒 |

---

## 7. 限频与配额汇总

| API | 路径 | 限频 | 日配额 |
|-----|------|------|--------|
| 笔记创建 | `/api/open/api/v1/note/create` | 1次/秒 | 100次/天 |
| 笔记编辑 | `/api/open/api/v1/note/edit` | 1次/秒 | 1000次/天 |
| 笔记设置 | `/api/open/api/v1/note/set` | 1次/秒 | 100次/天 |
| 获取上传授权 | `/api/open/api/v1/upload/prepare` | 1次/秒 | 200次/天 |
| 远程上传 | `/api/open/api/v1/upload/url` | 1次/秒 | 200次/天 |


path: run_tests.py
content: #!/usr/bin/env python3
"""独立运行测试的脚本"""
import sys
import os
import unittest

# 确保路径正确
os.chdir(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, "scripts")
sys.path.insert(0, "tests")

# 发现并运行测试
loader = unittest.TestLoader()
suite = loader.discover("tests", pattern="test_*.py")
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
sys.exit(0 if result.wasSuccessful() else 1)


path: run_tests.sh
content: #!/bin/bash
cd /Users/robiluo/aicoding/mowenskill
python3 -m pytest tests/test_publish_note.py -v 2>&1 || python3 -m unittest tests.test_publish_note -v 2>&1


path: scripts/publish_note.py
content: #!/usr/bin/env python3
"""
墨问笔记发布脚本 — publish_note.py

通过墨问 Open API 创建、编辑笔记或修改笔记设置。
支持本地图片上传和远程图片 URL 上传。

用法:
  # 创建笔记（从 JSON 文件读取内容）
  python publish_note.py --action create --input note.json

  # 创建笔记（从 stdin 读取 JSON）
  echo '{"paragraphs": [...]}' | python publish_note.py --action create

  # 编辑笔记
  python publish_note.py --action edit --note-id NOTE_ID --input note.json

  # 修改笔记设置
  python publish_note.py --action settings --note-id NOTE_ID --privacy rule --no-share

环境变量:
  MOWEN_API_KEY  墨问 API Key（也可通过 --api-key 参数传入）

零第三方依赖，仅使用 Python 标准库。
"""

import argparse
import json
import mimetypes
import os
import sys
import time
import uuid as uuid_mod
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

# ── 常量 ─────────────────────────────────────────────────────────────────────

BASE_URL = "https://open.mowen.cn"
API_NOTE_CREATE = "/api/open/api/v1/note/create"
API_NOTE_EDIT = "/api/open/api/v1/note/edit"
API_NOTE_SET = "/api/open/api/v1/note/set"
API_UPLOAD_PREPARE = "/api/open/api/v1/upload/prepare"
API_UPLOAD_URL = "/api/open/api/v1/upload/url"

RATE_LIMIT_DELAY = 1.1  # 秒，略大于 1 以确保不触发限频

# ── HTTP 工具 ─────────────────────────────────────────────────────────────────


def _api_request(api_key: str, path: str, payload: dict) -> dict:
    """向墨问 API 发送 JSON POST 请求并返回响应字典。"""
    url = BASE_URL + path
    data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    req = Request(
        url,
        data=data,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json; charset=utf-8",
        },
        method="POST",
    )
    try:
        with urlopen(req, timeout=30) as resp:
            body = json.loads(resp.read().decode("utf-8"))
    except HTTPError as e:
        body_text = e.read().decode("utf-8", errors="replace") if e.fp else ""
        try:
            err = json.loads(body_text)
            msg = err.get("message", err.get("msg", body_text))
            code = err.get("code", e.code)
            _die(f"API 错误 (HTTP {e.code}): {msg} (code={code})")
        except (json.JSONDecodeError, ValueError):
            _die(f"HTTP {e.code} — {e.reason}\n{body_text}")
    except URLError as e:
        _die(f"网络错误: {e.reason}")

    # API 成功时直接返回响应体（无 code/data 包装）
    # 错误响应含 code 字段（非 0）
    if "code" in body and body["code"] != 0:
        msg = body.get("message", body.get("msg", "未知错误"))
        _die(f"API 业务错误: {msg} (code={body['code']})")
    return body


def _multipart_upload(endpoint: str, form_fields: dict, file_path: str) -> bytes:
    """使用 multipart/form-data 上传文件到 OSS。"""
    boundary = uuid_mod.uuid4().hex
    body_parts: list[bytes] = []

    # 添加表单字段（按 form_fields 中的顺序）
    field_order = [
        "key", "policy", "callback", "success_action_status",
        "x-oss-signature-version", "x-oss-credential", "x-oss-date",
        "x-oss-signature", "x-oss-meta-mo-uid",
        "x:file_name", "x:file_id", "x:file_uid",
    ]
    for field in field_order:
        if field in form_fields:
            body_parts.append(
                f"--{boundary}\r\n"
                f'Content-Disposition: form-data; name="{field}"\r\n\r\n'
                f"{form_fields[field]}\r\n".encode("utf-8")
            )

    # 添加文件字段
    filename = os.path.basename(file_path)
    mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
    with open(file_path, "rb") as f:
        file_data = f.read()

    body_parts.append(
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
        f"Content-Type: {mime_type}\r\n\r\n".encode("utf-8")
    )
    body_parts.append(file_data)
    body_parts.append(f"\r\n--{boundary}--\r\n".encode("utf-8"))

    body = b"".join(body_parts)

    upload_url = endpoint.rstrip("/") + "/"
    req = Request(
        upload_url,
        data=body,
        headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
        method="POST",
    )
    try:
        with urlopen(req, timeout=120) as resp:
            return resp.read()
    except HTTPError as e:
        body_text = e.read().decode("utf-8", errors="replace") if e.fp else ""
        _die(f"文件投递失败: HTTP {e.code}\n{body_text}")
    except URLError as e:
        _die(f"文件投递网络错误: {e.reason}")


# ── 图片上传 ──────────────────────────────────────────────────────────────────


def upload_local_image(api_key: str, file_path: str) -> str:
    """本地图片上传（两步流程），返回 fileId。"""
    if not os.path.isfile(file_path):
        _die(f"文件不存在: {file_path}")

    file_size = os.path.getsize(file_path)
    if file_size > 50 * 1024 * 1024:
        _die(f"文件过大 ({file_size / 1024 / 1024:.1f}MB)，本地上传限制 50MB")

    filename = os.path.basename(file_path)
    ext = os.path.splitext(filename)[1].lower().lstrip(".")
    if ext not in ("gif", "jpeg", "jpg", "png", "webp"):
        _die(f"不支持的图片格式: .{ext}（仅支持 gif/jpeg/jpg/png/webp）")

    _info(f"[上传] 获取授权: {filename}")
    prepare_data = _api_request(api_key, API_UPLOAD_PREPARE, {
        "fileType": 1,
        "fileName": filename,
    })

    form = prepare_data.get("form", {})
    if not form:
        _die("获取上传授权失败: 未返回 form 数据")

    endpoint = form.pop("endpoint", "")
    if not endpoint:
        _die("获取上传授权失败: 未返回 endpoint")

    file_id = form.get("x:file_id", "")

    time.sleep(RATE_LIMIT_DELAY)

    _info(f"[上传] 投递文件: {filename} -> {endpoint}")
    _multipart_upload(endpoint, form, file_path)

    _info(f"[上传] 完成: fileId={file_id}")
    return file_id


def upload_remote_image(api_key: str, url: str, filename: str = "") -> str:
    """远程图片上传（一步完成），返回 fileId。"""
    _info(f"[上传] 远程上传: {url}")
    payload = {"fileType": 1, "url": url}
    if filename:
        payload["fileName"] = filename

    data = _api_request(api_key, API_UPLOAD_URL, payload)
    file_info = data.get("file", {})
    file_id = file_info.get("fileId", "")
    if not file_id:
        _die(f"远程上传失败: 未返回 fileId\n响应: {json.dumps(data, ensure_ascii=False)}")

    _info(f"[上传] 完成: fileId={file_id}")
    return file_id


def upload_image(api_key: str, image_ref: str) -> str:
    """
    统一图片上传入口。
    - 如果 image_ref 是 URL（http/https 开头），使用远程上传
    - 如果 image_ref 是本地路径，使用本地上传
    - 如果 image_ref 已经是 fileId（不含 / 且不以 http 开头），直接返回
    """
    if image_ref.startswith("http://") or image_ref.startswith("https://"):
        return upload_remote_image(api_key, image_ref)
    elif os.path.sep in image_ref or os.path.exists(image_ref):
        return upload_local_image(api_key, image_ref)
    else:
        # 假定已经是 fileId
        return image_ref


# ── NoteAtom 构建 ─────────────────────────────────────────────────────────────


def _build_text_node(text_item: dict) -> dict:
    """
    构建文本节点。

    text_item 格式:
      { "text": "内容", "bold": true, "highlight": true, "link": "https://..." }
    """
    node = {"type": "text", "text": text_item.get("text", "")}
    marks = []
    if text_item.get("bold"):
        marks.append({"type": "bold"})
    if text_item.get("highlight"):
        marks.append({"type": "highlight"})
    if text_item.get("link"):
        marks.append({"type": "link", "attrs": {"href": text_item["link"]}})
    if marks:
        node["marks"] = marks
    return node


def _build_paragraph(content_items: list) -> dict:
    """构建段落节点。content_items 为文本项列表。"""
    children = []
    for item in content_items:
        if isinstance(item, str):
            children.append({"type": "text", "text": item})
        elif isinstance(item, dict):
            children.append(_build_text_node(item))
    return {"type": "paragraph", "content": children}


def build_note_atom(api_key: str, input_data: dict) -> dict:
    """
    根据输入数据构建 NoteAtom。

    input_data 格式:
    {
      "paragraphs": [
        "纯文本段落",
        [{"text": "加粗", "bold": true}, "普通文字"],
        {"type": "image", "src": "https://... 或 /path/to/file 或 fileId"},
        {"type": "image", "src": "...", "width": 1080, "height": 720},
        {"type": "heading", "level": 1, "text": "标题"},
        {"type": "blockquote", "text": "引用内容"},
        {"type": "bulletList", "items": ["项目1", "项目2"]},
        {"type": "orderedList", "items": ["项目1", "项目2"]}
      ]
    }
    """
    content_nodes = []
    paragraphs = input_data.get("paragraphs", [])

    for para in paragraphs:
        if isinstance(para, str):
            # 纯文本段落
            content_nodes.append(_build_paragraph([para]))

        elif isinstance(para, list):
            # 富文本段落（文本项列表）
            content_nodes.append(_build_paragraph(para))

        elif isinstance(para, dict):
            node_type = para.get("type", "paragraph")

            if node_type == "image":
                src = para.get("src", "")
                if not src:
                    _warn("跳过无 src 的图片节点")
                    continue
                file_id = upload_image(api_key, src)
                time.sleep(RATE_LIMIT_DELAY)
                img_node = {
                    "type": "image",
                    "attrs": {"uuid": file_id},
                }
                if para.get("width") and para.get("height"):
                    img_node["attrs"]["width"] = str(para["width"])
                    img_node["attrs"]["height"] = str(para["height"])
                    img_node["attrs"]["ratio"] = str(round(
                        para["width"] / para["height"], 2
                    ))
                content_nodes.append(img_node)

            elif node_type == "heading":
                level = str(para.get("level", 1))
                text = para.get("text", "")
                content_nodes.append({
                    "type": "heading",
                    "attrs": {"level": level},
                    "content": [{"type": "text", "text": text}],
                })

            elif node_type in ("blockquote", "quote"):
                text = para.get("text", "")
                content_nodes.append({
                    "type": "quote",
                    "content": [_build_paragraph([text])],
                })

            elif node_type in ("bulletList", "orderedList"):
                items = para.get("items", [])
                list_items = []
                for item_text in items:
                    list_items.append({
                        "type": "listItem",
                        "content": [_build_paragraph([item_text])],
                    })
                content_nodes.append({
                    "type": node_type,
                    "content": list_items,
                })

            elif node_type == "paragraph":
                # 明确的段落类型
                text = para.get("text", "")
                content = para.get("content", [text] if text else [])
                content_nodes.append(_build_paragraph(content))

            elif node_type == "raw":
                # 直接传入原始 NoteAtom 节点
                raw_node = para.get("node")
                if raw_node:
                    content_nodes.append(raw_node)

            else:
                _warn(f"未知节点类型: {node_type}，作为段落处理")
                text = para.get("text", str(para))
                content_nodes.append(_build_paragraph([text]))

    if not content_nodes:
        _die("内容为空，至少需要一个段落")

    return {"type": "doc", "content": content_nodes}


# ── 操作实现 ──────────────────────────────────────────────────────────────────


def action_create(api_key: str, input_data: dict) -> dict:
    """创建笔记。"""
    note_atom = build_note_atom(api_key, input_data)

    # 构建 settings
    settings = {}
    tags = input_data.get("tags", [])
    if tags:
        if len(tags) > 10:
            _warn(f"标签数量 {len(tags)} 超过上限 10，将截断")
            tags = tags[:10]
        for i, tag in enumerate(tags):
            if len(tag) > 30:
                _warn(f"标签 '{tag}' 超过 30 字符，将截断")
                tags[i] = tag[:30]
        settings["tags"] = tags

    if input_data.get("autoPublish") is not None:
        settings["autoPublish"] = bool(input_data["autoPublish"])

    payload = {"body": note_atom}
    if settings:
        payload["settings"] = settings

    _info("[创建] 调用创建 API...")
    data = _api_request(api_key, API_NOTE_CREATE, payload)
    note_id = data.get("noteId", "")
    _info(f"[创建] 成功! noteId={note_id}")
    _info(f"  配额提醒: 创建 API 每日限额 100 次")
    return {"noteId": note_id, "action": "create"}


def action_edit(api_key: str, note_id: str, input_data: dict) -> dict:
    """编辑笔记内容。"""
    if not note_id:
        _die("编辑操作必须提供 --note-id 参数")

    note_atom = build_note_atom(api_key, input_data)

    payload = {
        "noteId": note_id,
        "body": note_atom,
    }

    _info(f"[编辑] 编辑笔记 {note_id}...")
    data = _api_request(api_key, API_NOTE_EDIT, payload)
    _info(f"[编辑] 成功!")
    _info(f"  配额提醒: 编辑 API 每日限额 1000 次")
    _info(f"  ⚠️ 注意: 仅支持编辑通过 API 创建的笔记")
    return {"noteId": note_id, "action": "edit"}


def action_settings(api_key: str, note_id: str, settings: dict) -> dict:
    """修改笔记隐私设置。"""
    if not note_id:
        _die("设置操作必须提供 --note-id 参数")

    privacy_type = settings.get("privacyType")
    if not privacy_type:
        _die("至少需要 --privacy 参数 (public/private/rule)")

    privacy_settings = {"type": privacy_type}

    # rule 类型支持 noShare 和 expireAt
    if privacy_type == "rule":
        rule = {}
        if settings.get("noShare") is not None:
            rule["noShare"] = bool(settings["noShare"])
        if settings.get("expireAt") is not None:
            rule["expireAt"] = str(settings["expireAt"])
        if rule:
            privacy_settings["rule"] = rule

    payload = {
        "noteId": note_id,
        "section": 1,
        "settings": {
            "privacy": privacy_settings,
        },
    }

    _info(f"[设置] 修改笔记 {note_id} 的设置...")
    data = _api_request(api_key, API_NOTE_SET, payload)
    _info(f"[设置] 成功!")
    _info(f"  配额提醒: 设置 API 每日限额 100 次")
    return {"noteId": note_id, "action": "settings"}


# ── 工具函数 ──────────────────────────────────────────────────────────────────


def _info(msg: str):
    print(f"[mowen] {msg}", file=sys.stderr)


def _warn(msg: str):
    print(f"[mowen] ⚠️  {msg}", file=sys.stderr)


def _die(msg: str):
    print(f"[mowen] ❌ {msg}", file=sys.stderr)
    sys.exit(1)


# ── CLI 入口 ──────────────────────────────────────────────────────────────────


def parse_args():
    parser = argparse.ArgumentParser(
        description="墨问笔记发布工具 — 创建、编辑笔记或修改笔记设置",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  # 创建笔记
  python publish_note.py --action create --input note.json

  # 创建笔记（stdin）
  echo '{"paragraphs":["Hello World"],"tags":["test"],"autoPublish":true}' | python publish_note.py --action create

  # 编辑笔记
  python publish_note.py --action edit --note-id NOTE_ID --input note.json

  # 修改设置
  python publish_note.py --action settings --note-id NOTE_ID --privacy public

输入 JSON 格式（create/edit）:
  {
    "paragraphs": [
      "纯文本段落",
      [{"text": "加粗", "bold": true}, "普通"],
      {"type": "image", "src": "https://example.com/img.jpg"},
      {"type": "image", "src": "/path/to/local.jpg", "width": 1080, "height": 720},
      {"type": "heading", "level": 1, "text": "标题"},
      {"type": "blockquote", "text": "引用"},
      {"type": "bulletList", "items": ["项1", "项2"]}
    ],
    "tags": ["标签1", "标签2"],
    "autoPublish": true
  }
""",
    )

    parser.add_argument(
        "--action",
        required=True,
        choices=["create", "edit", "settings"],
        help="操作类型: create=创建笔记, edit=编辑笔记, settings=修改设置",
    )
    parser.add_argument(
        "--api-key",
        default=os.environ.get("MOWEN_API_KEY", ""),
        help="墨问 API Key（默认读取 MOWEN_API_KEY 环境变量）",
    )
    parser.add_argument(
        "--note-id",
        default="",
        help="笔记 ID（edit 和 settings 操作必填）",
    )
    parser.add_argument(
        "--input",
        default="",
        help="输入 JSON 文件路径（不指定时从 stdin 读取）",
    )

    # settings 操作的参数
    settings_group = parser.add_argument_group("settings 操作参数")
    settings_group.add_argument(
        "--privacy",
        choices=["public", "private", "rule"],
        help="隐私类型: public=公开, private=仅自己可见, rule=自定义规则",
    )
    settings_group.add_argument(
        "--no-share",
        action="store_true",
        default=None,
        help="禁止分享（仅 rule 模式）",
    )
    settings_group.add_argument(
        "--expire-at",
        type=int,
        help="过期时间（Unix 时间戳，秒；0=永不过期；仅 rule 模式）",
    )

    return parser.parse_args()


def main():
    args = parse_args()

    # 校验 API Key
    [REDACTED_SECRET]
    if not api_key:
        _die(
            "未提供 API Key。请通过 --api-key 参数或 MOWEN_API_KEY 环境变量设置。\n"
            "获取方式: 墨问小程序 → 我的 → 设置 → 开放平台 → 创建 API Key"
        )

    action = args.action

    if action in ("create", "edit"):
        # 读取输入 JSON
        if args.input:
            if not os.path.isfile(args.input):
                _die(f"输入文件不存在: {args.input}")
            with open(args.input, "r", encoding="utf-8") as f:
                input_data = json.load(f)
        else:
            _info("从 stdin 读取 JSON 输入...")
            raw = sys.stdin.read()
            if not raw.strip():
                _die("stdin 无输入数据")
            input_data = json.loads(raw)

        if action == "create":
            result = action_create(api_key, input_data)
        else:
            result = action_edit(api_key, args.note_id, input_data)

    elif action == "settings":
        settings = {}
        if args.privacy is not None:
            settings["privacyType"] = args.privacy
        if args.no_share:
            settings["noShare"] = True
        if args.expire_at is not None:
            settings["expireAt"] = args.expire_at

        result = action_settings(api_key, args.note_id, settings)

    # 输出结果 JSON 到 stdout
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: tests/test_publish_note.py
content: #!/usr/bin/env python3
"""
publish_note.py 单元测试

通过 mock 替换所有网络调用，测试本地逻辑：
- NoteAtom 构建（各种段落类型）
- 图片上传路由（URL/本地/fileId）
- action_create / action_edit / action_settings 的 payload 构造
- CLI 参数解析
- 标签截断、空内容错误等边界场景
"""

import json
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch

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

import publish_note as pn


# ── 辅助工具 ────────────────────────────────────────────────────────────────


def mock_api_request(api_key, path, payload):
    """模拟 _api_request，根据 path 返回不同的假数据。"""
    if path == pn.API_NOTE_CREATE:
        return {"noteId": "mock_note_001"}
    elif path == pn.API_NOTE_EDIT:
        return {"noteId": payload.get("noteId", "")}
    elif path == pn.API_NOTE_SET:
        return {}
    elif path == pn.API_UPLOAD_PREPARE:
        return {
            "form": {
                "endpoint": "https://mock-oss.example.com",
                "key": "uploads/test.jpg",
                "policy": "mock_policy",
                "callback": "mock_callback",
                "success_action_status": "200",
                "x:file_id": "mock_file_id_local",
                "x:file_name": payload.get("fileName", "test.jpg"),
                "x:file_uid": "mock_uid",
            }
        }
    elif path == pn.API_UPLOAD_URL:
        return {
            "file": {
                "fileId": "mock_file_id_remote",
                "name": "remote.jpg",
                "path": "https://cdn.mock.com/remote.jpg",
            }
        }
    return {}


# ── 测试类 ──────────────────────────────────────────────────────────────────


class TestBuildTextNode(unittest.TestCase):
    """测试 _build_text_node"""

    def test_plain_text(self):
        node = pn._build_text_node({"text": "hello"})
        self.assertEqual(node, {"type": "text", "text": "hello"})
        self.assertNotIn("marks", node)

    def test_bold(self):
        node = pn._build_text_node({"text": "bold", "bold": True})
        self.assertEqual(node["marks"], [{"type": "bold"}])

    def test_highlight(self):
        node = pn._build_text_node({"text": "hl", "highlight": True})
        self.assertEqual(node["marks"], [{"type": "highlight"}])

    def test_link(self):
        node = pn._build_text_node({"text": "click", "link": "https://example.com"})
        self.assertEqual(len(node["marks"]), 1)
        self.assertEqual(node["marks"][0]["type"], "link")
        self.assertEqual(node["marks"][0]["attrs"]["href"], "https://example.com")

    def test_multiple_marks(self):
        node = pn._build_text_node({
            "text": "all",
            "bold": True,
            "highlight": True,
            "link": "https://x.com",
        })
        mark_types = [m["type"] for m in node["marks"]]
        self.assertIn("bold", mark_types)
        self.assertIn("highlight", mark_types)
        self.assertIn("link", mark_types)

    def test_empty_text(self):
        node = pn._build_text_node({})
        self.assertEqual(node["text"], "")


class TestBuildParagraph(unittest.TestCase):
    """测试 _build_paragraph"""

    def test_string_items(self):
        p = pn._build_paragraph(["hello", "world"])
        self.assertEqual(p["type"], "paragraph")
        self.assertEqual(len(p["content"]), 2)
        self.assertEqual(p["content"][0], {"type": "text", "text": "hello"})
        self.assertEqual(p["content"][1], {"type": "text", "text": "world"})

    def test_dict_items(self):
        p = pn._build_paragraph([{"text": "bold", "bold": True}])
        self.assertEqual(p["content"][0]["marks"], [{"type": "bold"}])

    def test_mixed_items(self):
        p = pn._build_paragraph(["plain", {"text": "bold", "bold": True}])
        self.assertEqual(len(p["content"]), 2)
        self.assertNotIn("marks", p["content"][0])
        self.assertIn("marks", p["content"][1])


class TestBuildNoteAtom(unittest.TestCase):
    """测试 build_note_atom — 各种段落类型"""

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_plain_text_paragraph(self, mock_time, mock_req):
        result = pn.build_note_atom("key", {"paragraphs": ["Hello World"]})
        self.assertEqual(result["type"], "doc")
        self.assertEqual(len(result["content"]), 1)
        para = result["content"][0]
        self.assertEqual(para["type"], "paragraph")
        self.assertEqual(para["content"][0]["text"], "Hello World")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_rich_text_paragraph(self, mock_time, mock_req):
        input_data = {
            "paragraphs": [
                [{"text": "加粗", "bold": True}, "普通"]
            ]
        }
        result = pn.build_note_atom("key", input_data)
        para = result["content"][0]
        self.assertEqual(para["type"], "paragraph")
        self.assertEqual(len(para["content"]), 2)
        self.assertIn("marks", para["content"][0])

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_heading(self, mock_time, mock_req):
        input_data = {
            "paragraphs": [
                {"type": "heading", "level": 2, "text": "标题"}
            ]
        }
        result = pn.build_note_atom("key", input_data)
        h = result["content"][0]
        self.assertEqual(h["type"], "heading")
        self.assertEqual(h["attrs"]["level"], "2")
        self.assertEqual(h["content"][0]["text"], "标题")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_blockquote(self, mock_time, mock_req):
        input_data = {
            "paragraphs": [
                {"type": "blockquote", "text": "引用内容"}
            ]
        }
        result = pn.build_note_atom("key", input_data)
        bq = result["content"][0]
        self.assertEqual(bq["type"], "quote")
        inner_para = bq["content"][0]
        self.assertEqual(inner_para["type"], "paragraph")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_quote_alias(self, mock_time, mock_req):
        """type='quote' 也应生成 quote 节点"""
        input_data = {
            "paragraphs": [
                {"type": "quote", "text": "引用"}
            ]
        }
        result = pn.build_note_atom("key", input_data)
        bq = result["content"][0]
        self.assertEqual(bq["type"], "quote")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_bullet_list(self, mock_time, mock_req):
        input_data = {
            "paragraphs": [
                {"type": "bulletList", "items": ["A", "B", "C"]}
            ]
        }
        result = pn.build_note_atom("key", input_data)
        bl = result["content"][0]
        self.assertEqual(bl["type"], "bulletList")
        self.assertEqual(len(bl["content"]), 3)
        self.assertEqual(bl["content"][0]["type"], "listItem")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_ordered_list(self, mock_time, mock_req):
        input_data = {
            "paragraphs": [
                {"type": "orderedList", "items": ["1st", "2nd"]}
            ]
        }
        result = pn.build_note_atom("key", input_data)
        ol = result["content"][0]
        self.assertEqual(ol["type"], "orderedList")
        self.assertEqual(len(ol["content"]), 2)

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_image_url(self, mock_time, mock_req):
        """URL 图片应调用远程上传"""
        input_data = {
            "paragraphs": [
                {"type": "image", "src": "https://example.com/photo.jpg"}
            ]
        }
        result = pn.build_note_atom("key", input_data)
        img = result["content"][0]
        self.assertEqual(img["type"], "image")
        self.assertEqual(img["attrs"]["uuid"], "mock_file_id_remote")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_image_with_dimensions(self, mock_time, mock_req):
        """图片带尺寸信息"""
        input_data = {
            "paragraphs": [
                {"type": "image", "src": "https://example.com/photo.jpg",
                 "width": 1920, "height": 1080}
            ]
        }
        result = pn.build_note_atom("key", input_data)
        img = result["content"][0]
        self.assertEqual(img["attrs"]["width"], "1920")
        self.assertEqual(img["attrs"]["height"], "1080")
        self.assertEqual(img["attrs"]["ratio"], "1.78")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_image_fileid_passthrough(self, mock_time, mock_req):
        """已有的 fileId 应直接透传"""
        input_data = {
            "paragraphs": [
                {"type": "image", "src": "existing_file_id_123"}
            ]
        }
        result = pn.build_note_atom("key", input_data)
        img = result["content"][0]
        self.assertEqual(img["type"], "image")
        self.assertEqual(img["attrs"]["uuid"], "existing_file_id_123")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_raw_node(self, mock_time, mock_req):
        """raw 类型应直接透传节点"""
        custom_node = {"type": "customWidget", "attrs": {"id": "w1"}}
        input_data = {
            "paragraphs": [
                {"type": "raw", "node": custom_node}
            ]
        }
        result = pn.build_note_atom("key", input_data)
        self.assertEqual(result["content"][0], custom_node)

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_empty_paragraphs_exits(self, mock_time, mock_req):
        """空内容应触发 sys.exit"""
        with self.assertRaises(SystemExit):
            pn.build_note_atom("key", {"paragraphs": []})

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_complex_note(self, mock_time, mock_req):
        """组合多种类型的复杂笔记"""
        input_data = {
            "paragraphs": [
                {"type": "heading", "level": 1, "text": "我的日记"},
                "今天天气很好",
                [{"text": "重要", "bold": True}, "的事情"],
                {"type": "image", "src": "https://example.com/img.jpg"},
                {"type": "blockquote", "text": "名言警句"},
                {"type": "bulletList", "items": ["A", "B"]},
            ]
        }
        result = pn.build_note_atom("key", input_data)
        self.assertEqual(len(result["content"]), 6)
        types = [n["type"] for n in result["content"]]
        self.assertEqual(types, [
            "heading", "paragraph", "paragraph",
            "image", "quote", "bulletList",
        ])


class TestUploadImageRouting(unittest.TestCase):
    """测试 upload_image 路由逻辑"""

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    def test_url_routes_to_remote(self, mock_req):
        fid = pn.upload_image("key", "https://example.com/photo.jpg")
        self.assertEqual(fid, "mock_file_id_remote")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    def test_http_url_routes_to_remote(self, mock_req):
        fid = pn.upload_image("key", "http://example.com/photo.jpg")
        self.assertEqual(fid, "mock_file_id_remote")

    def test_fileid_passthrough(self):
        """不含路径分隔符且不以 http 开头的字符串视为 fileId"""
        fid = pn.upload_image("key", "abc123_file_id")
        self.assertEqual(fid, "abc123_file_id")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "_multipart_upload", return_value=b"ok")
    @patch("time.sleep")
    def test_local_path_routes_to_local(self, mock_sleep, mock_upload, mock_req):
        """真实本地文件路径走本地上传"""
        # 创建一个临时图片文件
        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
            f.write(b"\xff\xd8\xff\xe0" + b"\x00" * 100)  # 最小 JPEG 头
            tmp_path = f.name
        try:
            fid = pn.upload_image("key", tmp_path)
            self.assertEqual(fid, "mock_file_id_local")
        finally:
            os.unlink(tmp_path)


class TestActionCreate(unittest.TestCase):
    """测试 action_create"""

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_basic_create(self, mock_time, mock_req):
        input_data = {
            "paragraphs": ["Hello"],
            "tags": ["test"],
            "autoPublish": True,
        }
        result = pn.action_create("key", input_data)
        self.assertEqual(result["noteId"], "mock_note_001")
        self.assertEqual(result["action"], "create")

        # 验证最后一次 _api_request 的调用参数（创建调用）
        last_call = mock_req.call_args_list[-1]
        path = last_call[0][1]
        payload = last_call[0][2]
        self.assertEqual(path, pn.API_NOTE_CREATE)
        self.assertIn("body", payload)
        self.assertIn("settings", payload)
        self.assertEqual(payload["settings"]["tags"], ["test"])
        self.assertTrue(payload["settings"]["autoPublish"])

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_create_no_settings(self, mock_time, mock_req):
        """不传 tags/autoPublish，settings 应不存在"""
        input_data = {"paragraphs": ["Simple note"]}
        result = pn.action_create("key", input_data)
        self.assertEqual(result["action"], "create")

        # payload 中不应有 settings
        create_call = [c for c in mock_req.call_args_list if c[0][1] == pn.API_NOTE_CREATE]
        payload = create_call[0][0][2]
        self.assertNotIn("settings", payload)

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_tags_truncation(self, mock_time, mock_req):
        """超过 10 个标签应被截断"""
        input_data = {
            "paragraphs": ["text"],
            "tags": [f"tag{i}" for i in range(15)],
        }
        result = pn.action_create("key", input_data)
        # 验证 payload 中 tags 被截断
        create_call = [c for c in mock_req.call_args_list if c[0][1] == pn.API_NOTE_CREATE]
        payload = create_call[0][0][2]
        self.assertEqual(len(payload["settings"]["tags"]), 10)

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_long_tag_truncation(self, mock_time, mock_req):
        """超过 30 字符的标签应被截断"""
        long_tag = "a" * 50
        input_data = {
            "paragraphs": ["text"],
            "tags": [long_tag],
        }
        pn.action_create("key", input_data)
        create_call = [c for c in mock_req.call_args_list if c[0][1] == pn.API_NOTE_CREATE]
        payload = create_call[0][0][2]
        self.assertEqual(len(payload["settings"]["tags"][0]), 30)


class TestActionEdit(unittest.TestCase):
    """测试 action_edit"""

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_basic_edit(self, mock_time, mock_req):
        input_data = {"paragraphs": ["Updated content"]}
        result = pn.action_edit("key", "note_123", input_data)
        self.assertEqual(result["noteId"], "note_123")
        self.assertEqual(result["action"], "edit")

        edit_call = [c for c in mock_req.call_args_list if c[0][1] == pn.API_NOTE_EDIT]
        payload = edit_call[0][0][2]
        self.assertEqual(payload["noteId"], "note_123")
        self.assertIn("body", payload)
        self.assertEqual(payload["body"]["type"], "doc")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_edit_without_note_id_exits(self, mock_time, mock_req):
        """编辑操作无 noteId 应退出"""
        with self.assertRaises(SystemExit):
            pn.action_edit("key", "", {"paragraphs": ["text"]})


class TestActionSettings(unittest.TestCase):
    """测试 action_settings — 使用 section + privacy 格式"""

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    def test_privacy_public(self, mock_req):
        result = pn.action_settings("key", "note_123", {"privacyType": "public"})
        self.assertEqual(result["noteId"], "note_123")
        self.assertEqual(result["action"], "settings")

        payload = mock_req.call_args[0][2]
        self.assertEqual(payload["noteId"], "note_123")
        self.assertEqual(payload["section"], 1)
        self.assertEqual(payload["settings"]["privacy"]["type"], "public")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    def test_privacy_private(self, mock_req):
        pn.action_settings("key", "note_123", {"privacyType": "private"})
        payload = mock_req.call_args[0][2]
        self.assertEqual(payload["settings"]["privacy"]["type"], "private")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    def test_rule_with_no_share(self, mock_req):
        pn.action_settings("key", "note_123", {
            "privacyType": "rule",
            "noShare": True,
        })
        payload = mock_req.call_args[0][2]
        self.assertEqual(payload["settings"]["privacy"]["type"], "rule")
        self.assertTrue(payload["settings"]["privacy"]["rule"]["noShare"])

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    def test_rule_with_expire(self, mock_req):
        pn.action_settings("key", "note_123", {
            "privacyType": "rule",
            "expireAt": 1700000000,
        })
        payload = mock_req.call_args[0][2]
        self.assertEqual(payload["settings"]["privacy"]["type"], "rule")
        self.assertEqual(payload["settings"]["privacy"]["rule"]["expireAt"], "1700000000")

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    def test_rule_with_all_options(self, mock_req):
        pn.action_settings("key", "note_123", {
            "privacyType": "rule",
            "noShare": True,
            "expireAt": 1800000000,
        })
        payload = mock_req.call_args[0][2]
        privacy = payload["settings"]["privacy"]
        self.assertEqual(privacy["type"], "rule")
        self.assertTrue(privacy["rule"]["noShare"])
        self.assertEqual(privacy["rule"]["expireAt"], "1800000000")

    def test_no_privacy_exits(self):
        """无 privacy 参数应退出"""
        with self.assertRaises(SystemExit):
            pn.action_settings("key", "note_123", {})

    def test_no_note_id_exits(self):
        """无 noteId 应退出"""
        with self.assertRaises(SystemExit):
            pn.action_settings("key", "", {"privacyType": "public"})

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    def test_api_path_is_note_set(self, mock_req):
        """应调用 /api/open/api/v1/note/set 端点"""
        pn.action_settings("key", "note_123", {"privacyType": "public"})
        path = mock_req.call_args[0][1]
        self.assertEqual(path, pn.API_NOTE_SET)


class TestCLIParsing(unittest.TestCase):
    """测试 CLI 参数解析"""

    def test_create_action(self):
        with patch("sys.argv", ["prog", "--action", "create", "--api-key", "test_key", "--input", "note.json"]):
            args = pn.parse_args()
            self.assertEqual(args.action, "create")
            self.assertEqual(args.api_key, "test_key")
            self.assertEqual(args.input, "note.json")

    def test_edit_action(self):
        with patch("sys.argv", ["prog", "--action", "edit", "--api-key", "k", "--note-id", "n1", "--input", "e.json"]):
            args = pn.parse_args()
            self.assertEqual(args.action, "edit")
            self.assertEqual(args.note_id, "n1")

    def test_settings_action(self):
        with patch("sys.argv", ["prog", "--action", "settings", "--api-key", "k",
                                 "--note-id", "n1", "--privacy", "public"]):
            args = pn.parse_args()
            self.assertEqual(args.action, "settings")
            self.assertEqual(args.privacy, "public")

    def test_settings_rule_no_share(self):
        with patch("sys.argv", ["prog", "--action", "settings", "--api-key", "k",
                                 "--note-id", "n1", "--privacy", "rule", "--no-share"]):
            args = pn.parse_args()
            self.assertEqual(args.privacy, "rule")
            self.assertTrue(args.no_share)

    def test_settings_expire_at(self):
        with patch("sys.argv", ["prog", "--action", "settings", "--api-key", "k",
                                 "--note-id", "n1", "--privacy", "rule", "--expire-at", "1700000000"]):
            args = pn.parse_args()
            self.assertEqual(args.expire_at, 1700000000)

    def test_invalid_action_exits(self):
        with patch("sys.argv", ["prog", "--action", "invalid"]):
            with self.assertRaises(SystemExit):
                pn.parse_args()

    def test_invalid_privacy_exits(self):
        with patch("sys.argv", ["prog", "--action", "settings", "--privacy", "unknown"]):
            with self.assertRaises(SystemExit):
                pn.parse_args()

    def test_api_key_from_env(self):
        with patch.dict(os.environ, {"MOWEN_API_KEY": "env_key"}):
            with patch("sys.argv", ["prog", "--action", "create", "--input", "n.json"]):
                args = pn.parse_args()
                self.assertEqual(args.api_key, "env_key")


class TestEndToEndCreate(unittest.TestCase):
    """端到端测试：从 JSON 文件到 action_create"""

    @patch.object(pn, "_api_request", side_effect=mock_api_request)
    @patch.object(pn, "time")
    def test_create_from_json_file(self, mock_time, mock_req):
        input_data = {
            "paragraphs": [
                {"type": "heading", "level": 1, "text": "测试标题"},
                "这是一段测试文字",
                {"type": "image", "src": "https://example.com/test.jpg", "width": 800, "height": 600},
                {"type": "blockquote", "text": "引用"},
                {"type": "bulletList", "items": ["项1", "项2"]},
            ],
            "tags": ["测试", "单元测试"],
            "autoPublish": True,
        }

        # 写入临时文件
        with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
            json.dump(input_data, f, ensure_ascii=False)
            tmp_path = f.name

        try:
            # 读取并执行
            with open(tmp_path, "r", encoding="utf-8") as f:
                loaded = json.load(f)

            result = pn.action_create("test_key", loaded)
            self.assertEqual(result["noteId"], "mock_note_001")
            self.assertEqual(result["action"], "create")

            # 验证 API 被调用了（至少有远程图片上传 + 创建）
            paths_called = [c[0][1] for c in mock_req.call_args_list]
            self.assertIn(pn.API_UPLOAD_URL, paths_called)
            self.assertIn(pn.API_NOTE_CREATE, paths_called)
        finally:
            os.unlink(tmp_path)


if __name__ == "__main__":
    unittest.main()


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