Skill: wangfengxiang/wiznote (version 2.1.0)

--- name: wiznote description: 为知笔记（WizNote）API连接器，支持笔记的增删改查、搜索、附件管理、分类、标签、评论、分享等全部操作。当用户提到"为知笔记"、"WizNote"、"Wiz"，或需要在为知笔记中进行任何操作时使用此技能。 --- # 为知笔记 Skill（v2） 基于为知笔记私有化 API 的连接器，**默认使用协作笔记模式**，支持 Markdown 内容自动转换。 ## 配置 ```env WIZ_ENDPOINT=http://192.168.1.121:30802 WIZ_TOKEN=<your_token> WIZ_KB_GUID=c6522160-aca4-11ef-97d7-3bf3be62286f WIZ_USER_ID=[REDACTED_SECRET] WIZ_PASSWORD=i1ove1314W. ``` ## 核心原则 - 所有笔记操作默认使用**协作笔记**（通过 WebSocket 协议） - `create_note` / `update_note` 接受 Markdown 文本，自动转换为 blocks - 传统 HTML 笔记通过 `create_note(content_type='html')` 显式创建 ## API 快速索引 | 操作 | 方法 | |------|------| | 列出笔记 | `api.get_note_list()` | | 搜索笔记 | `api.search_notes(keyword)` | | 读取内容 | `api.get_note_content(doc_guid)` | | 创建笔记 | `api.create_note(title, markdown)` | | 更新笔记 | `api.update_note(doc_guid, markdown)` | | 删除笔记 | `api.delete_note(doc_guid)` | | 移动笔记 | `api.move_note(doc_guid, category)` | | 复制笔记 | `api.copy_note(doc_guid)` | | 分类管理 | `api.list_categories()` / `create_category()` / `delete_category()` | | 标签管理 | `api.list_tags()` / `add_tags()` / `remove_tags()` | | 评论 | `api.get_comments()` / `add_comment()` | | 历史版本 | `api.get_note_history()` / `get_note_version()` | | 分享 | `api.share_note()` / `list_shares()` | | 附件 | `api.get_note_attachments()` / `upload_attachment()` | | 模板创建 | `api.create_from_template(template_name, title)` | | Markdown转Blocks | `api.markdown_to_blocks(md_text)` | --- ## 1. 笔记基础操作 ### 列出笔记 ```python api.get_note_list(version=0, count=50) # 按分类列出 api.get_notes_by_category("/My Notes/", count=50) ``` ### 搜索笔记 ```python results = api.search_notes(keyword="周报", with_abstract=True) for note in results: print(note['title'], note['docGuid']) ``` ### 读取笔记 ```python # 自动识别协作笔记 / 普通笔记 content = api.get_note_content(doc_guid) detail = api.get_note_detail(doc_guid) # 元数据 note_type = detail['info'].get('type') # 'document' 或 'collaboration' ``` --- ## 2. 创建笔记（协作笔记 + Markdown） `create_note(title, content, category, tags)` — 传入 Markdown，自动转为 blocks 并创建协作笔记。 ```python result = api.create_note( title="2026年第17周周报", content="""# 本周完成 - 完成XXX模块开发 - 修复BUG #123 ## 下周计划 - [ ] 开始YYY模块 - [ ] 编写测试文档 ## 风险 > 暂无 """, category="/openclaw/周报/", tags="周报,openclaw" ) # result = {docGuid, editorToken, title, category} ``` **content_type 参数：** - `markdown`（默认）：Markdown 文本 → blocks - `blocks`：直接传入 blocks 列表 - `html`：创建传统 HTML 笔记 --- ## 3. 更新笔记 ```python api.update_note( doc_guid="xxx", content="""# 更新后的标题 新内容段落 - [x] 已完成任务 - [ ] 待办任务 """, title="更新后的标题" # 可选，同时更新标题 ) ``` --- ## 4. 删除 / 移动 / 复制 ```python # 删除（移到回收站） api.delete_note(doc_guid) # 移动到其他分类 api.move_note(doc_guid, "/openclaw/归档/") # 复制笔记 api.copy_note(doc_guid) # 自动加" - 副本" api.copy_note(doc_guid, title="新标题", category="/其他分类/") ``` --- ## 5. 分类（文件夹）管理 ```python # 列出所有分类 categories = api.list_categories() # 创建分类 api.create_category(parent="/My Notes/", name="项目A") # 删除分类 api.delete_category("/My Notes/项目A/") # 重命名分类 api.rename_category("/My Notes/旧名称/", "新名称") ``` --- ## 6. 标签管理 ```python # 列出所有标签 tags = api.list_tags() # 为笔记添加标签 api.add_tags(doc_guid, ["周报", "重要"]) # 批量添加（逗号分隔字符串） api.add_tags(doc_guid, "周报,2026,openclaw") # 移除标签 api.remove_tags(doc_guid, "临时标签") # 全局重命名标签 api.rename_tag("旧标签名", "新标签名") ``` --- ## 7. 评论功能 ```python # 获取评论 comments = api.get_comments(doc_guid) # 添加评论 api.add_comment(doc_guid, "这是一条评论内容") # 删除评论 api.delete_comment(doc_guid, comment_guid) ``` --- ## 8. 历史版本 ```python # 获取版本列表 versions = api.get_note_history(doc_guid) # 读取指定版本内容 version_data = api.get_note_version(doc_guid, version_id) ``` --- ## 9. 分享与权限 ```python # 分享笔记（生成链接） share_info = api.share_note( doc_guid, access='read', # 'read' 或 'edit' expire_days=30 # 0=永久 ) # share_info 包含分享URL # 列出所有分享 api.list_shares() # 取消分享 api.cancel_share(share_id) ``` --- ## 10. 附件管理 ```python # 列出附件 attachments = api.get_note_attachments(doc_guid) # 上传附件 result = api.upload_attachment(doc_guid, "/path/to/file.pdf") # 下载附件 data = api.download_attachment(doc_guid, att_guid) # 下载协作笔记图片 data = api.get_collaboration_image(doc_guid, image_name) ``` --- ## 11. 模板创建 ```python # 可用模板：weekly_report / meeting_minutes / todo / reading_notes / project_plan / blank result = api.create_from_template( template_name='weekly_report', title="2026年第17周周报", category="/openclaw/周报/", tags="周报", attendees="张三,李四", # 会议模板变量 recorder="王五", # 会议模板变量 ) ``` ### 模板内容预览 **weekly_report（周报）** ```markdown # {title} ## 本周完成 - ## 进行中 - ## 下周计划 - ## 风险与问题 > ## 备注 ``` **meeting_minutes（会议纪要）** ```markdown # {title} **日期**: {date} **参会人**: {attendees} **记录人**: {recorder} ## 会议议题 1. ## 讨论内容 ### 议题一 ### 议题二 ## 决议 - [ ] ## 后续行动（表格） ``` **todo（待办清单）** 按紧急/重要四象限组织 **project_plan（项目计划）** 含时间线表格、风险评估表格 **reading_notes（读书笔记）** 含核心观点、章节摘要、精彩摘录 --- ## 12. Markdown → Blocks 转换 `markdown_to_blocks(md_text)` — 将 Markdown 转换为协作笔记 blocks 列表，支持： | 语法 | 转换结果 | |------|---------| | `# 标题` | `text_block(heading=1)` | | `**粗体**` | `attributes: style-bold` | | `*斜体*` | `attributes: style-italic` | | `` `代码` `` | `attributes: style-code` | | `[链接](url)` | `attributes: link` | | `- 列表` | `list_block()` | | `1. 有序` | `list_block(ordered=True)` | | `- [x] 完成` | `list_block(checkbox='checked')` | | `> 引用` | `text_block(quoted=True)` | | ` ```code``` ` | `code_block()` | | `\| 表 \|` | `table_block()` | | `---` | `embed(embedType='hr')` | | `![alt](src)` | `embed(embedType='image')` | ### 块快捷构造方法 ```python # 基础块 api.text_block('文本', heading=2) # 标题/段落/引用 api.list_block('文本', ordered=True, level=2) # 列表 api.table_block(['列1','列2'], [['a','b']]) # 表格（返回block+extra_data） api.code_block('print("hello")', 'python') # 代码块（返回block+extra_data） api.divider_block() # 分隔线 api.image_block('https://...', '图片说明') # 图片（URL或协作笔记内部文件名） # v2.1 新增 api.link_block('百度', 'https://www.baidu.com') # 链接文本 api.webpage_block('http://baidu.com') # 嵌入网页 api.audio_block(src, file_name, file_size, file_type) # 音频 api.file_block(src, file_name, file_size, file_type) # 文件嵌入（office类型） api.drawio_block(src) # 流程图/UML（SVG文件名） api.encrypt_text_block(password, text, prompt) # 加密文本块 api.formula_block(tex) # 数学公式 # 行内样式构造（加粗/链接等混合文本） api.make_inline_block([ ('标签：', True), # True=加粗 ('内容', False), # False=普通 ]) ``` ### 功能兼容性矩阵 | 功能 | 写入支持 | 读取支持 | 备注 | |------|:--------:|:--------:|------| | 标题 (heading 1-3) | ✅ | ✅ | | | 段落文本 | ✅ | ✅ | | | **加粗** | ✅ | ✅ | `style-bold` 属性 | | *斜体* | ✅ | ✅ | `style-italic` 属性 | | ~~删除线~~ | ✅ | ✅ | `style-strikethrough` 属性 | | `行内代码` | ✅ | ✅ | `style-code` 属性 | | [链接](url) | ✅ | ✅ | `link` 属性 | | 引用块 | ✅ | ✅ | `quoted=True` | | 有序/无序列表 | ✅ | ✅ | | | 复选框列表 | ✅ | ✅ | `checked=True/False` | | 代码块 | ✅ | ✅ | 需 extra_data | | 表格 | ✅ | ⚠️ | 写入完整，读取时单元格内容为空（已知限制） | | 分割线 | ✅ | ✅ | | | 图片 | ✅ | ✅ | URL或内部文件名 | | 音频 | ✅ | ✅ | 需先上传附件获取src | | 文件嵌入 | ✅ | ✅ | 需先上传附件获取src | | 流程图/UML | ✅ | ✅ | 需先上传SVG获取src | | 加密文本 | ✅ | ✅ | AES加密 | | 数学公式 | ✅ | ✅ | LaTeX语法 | | 网页嵌入 | ✅ | ✅ | | --- ## 13. 协作笔记底层操作（高级） ```python # 创建协作笔记（直接用 blocks） result = api.create_collaboration_note( title="标题", blocks=[ api.text_block('一级标题', heading=1), api.text_block('正文'), ], category="/My Notes/", tags="标签" ) # 更新协作笔记（内部 del + create） api.update_collaboration_note(doc_guid, blocks, title="新标题") # 获取 editorToken [REDACTED_SECRET]) # 获取原始 JSON 内容 raw = api.get_collaboration_content(token, doc_guid) md = api.parse_collaboration_content(raw) ``` --- ## 14. Wrapper 命令行 ```bash # 列出笔记 python wiznote_wrapper.py list [count] # 搜索 python wiznote_wrapper.py search <keyword> # 读取 python wiznote_wrapper.py get <doc_guid> # 创建（Markdown） python wiznote_wrapper.py create "标题" "Markdown内容" --category /My Notes/ --tags tag1,tag2 # 更新 python wiznote_wrapper.py update <doc_guid> "新Markdown内容" --title "新标题" # 删除 / 移动 / 复制 python wiznote_wrapper.py delete <doc_guid> python wiznote_wrapper.py move <doc_guid> /新分类/ python wiznote_wrapper.py copy <doc_guid> [新标题] [分类] # 分类管理 python wiznote_wrapper.py categories python wiznote_wrapper.py create_category /父分类/ 新名称 python wiznote_wrapper.py delete_category /分类路径/ # 标签管理 python wiznote_wrapper.py tags python wiznote_wrapper.py add_tags <doc_guid> tag1,tag2 python wiznote_wrapper.py remove_tags <doc_guid> tag1 # 评论 python wiznote_wrapper.py comments <doc_guid> python wiznote_wrapper.py add_comment <doc_guid> <text> # 历史版本 python wiznote_wrapper.py history <doc_guid> # 分享 python wiznote_wrapper.py share <doc_guid> [read|edit] [expire_days] python wiznote_wrapper.py shares # 附件 python wiznote_wrapper.py attachments <doc_guid> python wiznote_wrapper.py upload <doc_guid> <file_path> # 模板 python wiznote_wrapper.py templates python wiznote_wrapper.py template weekly_report "标题" [分类] # Markdown转Blocks预览 python wiznote_wrapper.py md2blocks "# 标题\n- 列表项" ``` --- ## 15. 常见问题 ### Q: Token 过期怎么办？ A: Token 有效期约 15 分钟，API 会在初始化时自动登录获取。 ### Q: 如何创建普通 HTML 笔记？ A: `api.create_note(title, html, content_type='html')` ### Q: 协作笔记更新失败？ A: `update_collaboration_note` 使用 del + create 方式覆盖，op 修改（err:1002）为已知限制。 ### Q: 分类不存在？ A: 必须先通过 Web 界面创建分类，或使用 `create_category()` 创建。 ### Q: Markdown 表格导出？ A: 支持，Markdown 表格自动转为 `table_block`。 ## 技术架构 - **认证**：用户名密码 → token - **普通笔记**：REST API（create/save/download） - **协作笔记**：REST API 创建 + **WebSocket**（sharejs JSONv0 协议）读写 - **内容格式**：Markdown → blocks（协作笔记） / HTML（普通笔记） - **附件**：Base64 / 二进制流 ## 项目来源 GitHub: https://github.com/damoncui668/wiz-mcp ## 更新记录 - **2026-04-28 v2**：全面升级 - 笔记操作默认改为协作笔记模式 - 新增：`delete_note` / `move_note` / `copy_note` - 新增：分类管理（创建/删除/重命名） - 新增：标签管理（添加/移除/重命名标签） - 新增：评论功能（查看/添加/删除） - 新增：历史版本（查看版本列表/读取版本） - 新增：分享功能（创建分享/列分享/取消分享） - 新增：附件上传 - 新增：`markdown_to_blocks` 转换器（支持表格、代码块、复选框等） - 新增：5个笔记模板（周报/会议纪要/待办/读书笔记/项目计划） - 新增：`create_from_template` 一键从模板创建 - 新增：Block 快捷构造方法（code_block / divider_block / image_block） - 调研报告：https://github.com/altairwei/WizNotePlus、https://github.com/WizTeam/WizQTClient、https://github.com/famotime/Wiznotes_tools - **2026-05-14 v2.1**：功能扩展 - 新增：链接块 `link_block(text, url)` - 新增：网页嵌入块 `webpage_block(url)` - 新增：音频块 `audio_block(src, file_name, file_size, file_type)` - 新增：文件嵌入块 `file_block(src, file_name, file_size, file_type)` - 新增：流程图/UML 块 `drawio_block(src)` - 新增：加密文本块 `encrypt_text_block(password, text, prompt)` - 新增：公式块 `formula_block(tex)` - 新增：加粗/斜体/删除线行内样式 `make_inline_block(parts)` - 改进：`table_block` 动态列宽算法优化（中文按2倍宽度计算） - 改进：`update_collaboration_note` 支持删除线 `style-strikethrough` - 功能兼容性测试完成：标题/引用/链接/代码/公式/分割线/图片/音频/文件/流程图/表格/加密文本/网页嵌入

path: README.md
content: # wiz-mcp

为知笔记（WizNote）MCP STDIO Server - 用于在 Trae/OpenClaw 等 MCP 客户端中查询、读取和创建为知笔记内容。

## 功能特性

- **列出笔记**：获取笔记元数据列表
- **搜索笔记**：通过扫描元数据实现本地搜索
- **读取笔记**：支持普通 HTML/Lite/协作笔记的 Markdown 转换
- **创建笔记**：支持通过 URL 自动抓取或手动输入 Markdown 创建笔记
- **列出附件**：普通附件接口 + 协作笔记回退解析
- **下载附件**：支持普通附件和协作资源下载（二进制转 Base64）

## 安装

```bash
# 1. 克隆或下载项目
cd wiz-mcp

# 2. 创建虚拟环境（推荐）
python3.12 -m venv .venv
source .venv/bin/activate  # Linux/Mac
# 或 .venv\Scripts\activate  # Windows

# 3. 安装依赖
pip install -r requirements.txt --break-system-packages
```

## 配置

1. 复制 `.env.example` 为 `.env`：

```bash
cp .env.example .env
```

2. 编辑 `.env` 文件，配置以下环境变量：

| 变量 | 必填 | 说明 |
|------|------|------|
| WIZ_USER_ID | 是 | 为知笔记账号（邮箱） |
| WIZ_PASSWORD | 是 | 为知笔记密码 |
| WIZ_BASE_URL | 是 | 为知服务器地址（如 `http://127.0.0.1:5688` 或 `https://your-domain:5687`） |
| WIZ_GROUP_NAME | 否 | 群组名称（可选） |
| WIZ_KB_GUID | 否 | 知识库 GUID（可选，若填 URL 则忽略） |

## 启动

```bash
python wiz_mcp_server.py
# 或使用虚拟环境
.venv/bin/python wiz_mcp_server.py
```

## Trae mcpServers 配置示例

在 Trae 的 MCP 配置中添加：

```json
{
  "mcpServers": {
    "wiz-mcp": {
      "command": "/path/to/wiz-mcp/.venv/bin/python",
      "args": ["/path/to/wiz-mcp/wiz_mcp_server.py"],
      "env": {
        "PYTHONUNBUFFERED": "1"
      },
      "cwd": "/path/to/wiz-mcp"
    }
  }
}
```

## 冒烟测试

```bash
python mcp_smoke_test.py
# 或使用虚拟环境
.venv/bin/python mcp_smoke_test.py
```

## MCP Tools

### 1. wiz_list_notes

列出笔记元数据

```json
{
  "start_version": 0,
  "count": 50
}
```

### 2. wiz_search_notes

搜索笔记（扫描元数据本地过滤）

```json
{
  "query": "关键词",
  "start_version": 0,
  "page_size": 100,
  "max_pages": 10,
  "fields": ["title", "category"],
  "case_sensitive": false
}
```

### 3. wiz_get_note

读取笔记内容（自动下载图片/附件到本地）

```json
{
  "doc_guid": "笔记GUID",
  "format": "markdown",
  "include_info": true,
  "include_resources": true
}
```

**说明：** `include_resources` 默认为 `true`，会自动下载笔记中的图片和附件到 `output/notes/{doc_guid}/` 目录。

### 4. wiz_create_note

创建笔记（支持 URL 抓取）

```json
{
  "title": "笔记标题",
  "content": "Markdown 内容",
  "url": "https://example.com/article",
  "category": "/My Notes/",
  "tags": "tag1,tag2"
}
```

### 5. wiz_list_attachments

列出附件

```json
{
  "doc_guid": "笔记GUID",
  "note_type": "collaboration"
}
```

### 6. wiz_download_attachment

下载附件

```json
{
  "kind": "normal",
  "doc_guid": "笔记GUID",
  "att_guid": "附件GUID",
  "name": "文件名.pdf"
}
```

## 目录结构

```
wiz-mcp/
├── wiz_mcp_server.py          # 服务入口
├── mcp_smoke_test.py          # 冒烟测试
├── requirements.txt           # 依赖
├── .env                       # 环境变量（需手动创建）
├── .env.example               # 环境变量示例
├── .gitignore                # Git 忽略配置
├── README.md                  # 项目文档
├── prd.md                    # PRD 需求文档
├── output/                    # 输出目录
│   └── notes/                # 导出的笔记（含图片/附件）
├── .trae/
│   └── skills/wiznote-mcp/  # Trae Skill 指南
│       └── SKILL.md
└── scripts/                  # 核心代码
    ├── __init__.py
    ├── logging.py             # 日志模块
    ├── config.py             # 配置管理
    ├── wiz_open_api.py       # HTTP/WS API
    ├── server.py             # MCP 服务
    ├── note.py               # Note 模型
    ├── note_parser.py        # 解析器基类
    ├── note_fixer.py         # Markdown 修复
    ├── note_parser_factory.py # 解析器工厂
    ├── html_note_parser.py   # HTML 解析器
    ├── lite_note_parser.py   # Lite 解析器
    └── collaboration_note_parser.py # 协作笔记解析器
```

## 依赖说明

| 依赖 | 用途 |
|------|------|
| requests | HTTP 请求 |
| websocket-client | WebSocket 协作笔记 |
| python-dotenv | 环境变量加载 |
| html2text | HTML 转 Markdown |
| bs4 | BeautifulSoup 解析 |
| certifi | SSL 证书 |
| trafilatura | URL 网页抓取 |
| markdown | Markdown 转 HTML |
| pypdf | PDF 解析 |
| pytesseract | OCR 文字识别 |
| pillow | 图片处理 |

## License

MIT


path: requirements.txt
content: requests
websocket-client
python-dotenv
html2text
bs4
certifi
trafilatura
markdown
pypdf
pytesseract
pillow


path: scripts/__init__.py
content: """wiz-mcp: 为知笔记 MCP STDIO Server"""
__version__ = "1.0.0"


path: scripts/collaboration_note_parser.py
content: """协作笔记解析器"""
import json
import re

from note_parser import NoteParser
from note_fixer import NoteFixer
from wiz_logging import get_logger

logger = get_logger()


class BlockTextConverter:
    """协作笔记文本块转换器"""

    @staticmethod
    def to_text(text_dict: dict) -> str:
        """将文本对象转换为 Markdown 文本"""
        if not text_dict:
            return ""

        attributes = text_dict.get("attributes", {})

        # wiki-link
        if attributes.get("type") == "wiki-link":
            return BlockTextConverter.handle_wiki_link(text_dict)

        # math 公式
        if attributes.get("type") == "math":
            tex = attributes.get("tex", "").strip()
            return f'${tex}$'

        # link
        if attributes.get("link"):
            return BlockTextConverter.handle_link(text_dict)

        # style-code
        if attributes.get("style-code"):
            return BlockTextConverter.handle_code(text_dict)

        # style-bold
        if attributes.get("style-bold"):
            return BlockTextConverter.handle_bold(text_dict)

        # style-italic
        if attributes.get("style-italic"):
            return BlockTextConverter.handle_italic(text_dict)

        # style-strikethrough
        if attributes.get("style-strikethrough"):
            return BlockTextConverter.handle_strikethrough(text_dict)

        # 普通文本
        return text_dict.get("insert", "")

    @classmethod
    def handle_link(cls, text_dict: dict) -> str:
        """处理链接"""
        return f'[{text_dict["insert"]}]({text_dict["attributes"]["link"]})'

    @classmethod
    def handle_code(cls, text_dict: dict) -> str:
        """处理行内代码"""
        return f'`{text_dict["insert"]}`'

    @classmethod
    def handle_italic(cls, text_dict: dict) -> str:
        """处理斜体"""
        return f'*{text_dict["insert"]}*'

    @classmethod
    def handle_bold(cls, text_dict: dict) -> str:
        """处理粗体"""
        return f'**{text_dict["insert"]}**'

    @classmethod
    def handle_strikethrough(cls, text_dict: dict) -> str:
        """处理删除线"""
        return f'~~{text_dict["insert"]}~~'

    @classmethod
    def handle_wiki_link(cls, text_dict: dict) -> str:
        """处理 wiki 链接"""
        attributes = text_dict["attributes"]
        name = attributes.get("name", "")
        secondary_name = attributes.get("secondaryName", "")

        if name.endswith('.md'):
            name = name[:-3]

        if secondary_name:
            return f'[[{secondary_name}|{name}]]'
        else:
            return f'[[{name}]]'


class TextStrategy:
    """文本块策略"""

    def __init__(self, data: dict):
        self.data = data

    def to_text(self, block_row: dict) -> str:
        """转换文本块为 Markdown"""
        block_type = block_row.get("type", "")
        
        if block_type == "code":
            return self.handle_code(block_row)
        elif block_type == "math":
            return self.handle_math(block_row)
        elif block_row.get("quoted"):
            return self.handle_quote(block_row['text'])
        elif block_row.get("heading"):
            return self.handle_header(block_row, block_row['text'])
        else:
            return self.handle_text(block_row['text'])

    def handle_code(self, block_row: dict) -> str:
        """处理代码块"""
        code = block_row.get("code", "")
        language = block_row.get("language", "")
        if language and language.lower() != "plain text":
            return f"```{language}\n{code}\n```\n"
        else:
            return f"```\n{code}\n```\n"

    def handle_math(self, block_row: dict) -> str:
        """处理数学公式块"""
        math = block_row.get("math", "")
        display_mode = block_row.get("displayMode", False)
        if display_mode:
            return f"$$\n{math}\n$$\n"
        else:
            return f"${math}$\n"

    def handle_text(self, text_arr: list) -> str:
        """处理文本数组"""
        if not text_arr:
            return ''
        return ''.join(BlockTextConverter.to_text(t) for t in text_arr)

    def handle_header(self, block_row: dict, text_arr: list) -> str:
        """处理标题"""
        level = block_row.get("heading", 1)
        text = ' '.join(BlockTextConverter.to_text(t) for t in text_arr)
        return f'{"#" * level} {text}\n'

    def handle_quote(self, json_data: list) -> str:
        """处理引用"""
        text = ''.join(BlockTextConverter.to_text(t) for t in json_data)
        return f"> {text}\n"


class ListStrategy:
    """列表块策略"""

    def __init__(self, data: dict):
        self.data = data

    def to_text(self, block_row: dict) -> str:
        """转换列表块为 Markdown"""
        if block_row.get("ordered"):
            return self.handle_ordered_list(block_row)
        else:
            return self.handle_unordered_list(block_row)

    def handle_unordered_list(self, block_row: dict) -> str:
        """处理无序列表"""
        indent = (block_row['level'] - 1) * 2 * ' '
        text = f'{indent}- '

        # checkbox 支持
        checkbox = block_row.get("checkbox")
        if checkbox == "checked":
            text += "[x] "
        elif checkbox == "unchecked":
            text += "[ ] "

        text += ''.join(BlockTextConverter.to_text(t) for t in block_row["text"])
        return text + '\n'

    def handle_ordered_list(self, block_row: dict) -> str:
        """处理有序列表"""
        indent = (block_row['level'] - 1) * 2 * ' '
        start = block_row.get("start", 1)
        text = f'{indent}{start}. '
        text += ''.join(BlockTextConverter.to_text(t) for t in block_row["text"])
        return text + '\n'


class EmbedStrategy:
    """嵌入块策略"""

    def __init__(self, data: dict):
        self.data = data

    def to_text(self, block_row: dict) -> str:
        """转换嵌入块为 Markdown"""
        embed_type = block_row.get("embedType", "")
        embed_data = block_row.get("embedData", {})

        if embed_type == "image":
            return self.handle_image(embed_data)
        elif embed_type == "toc":
            return "\n[TOC]\n\n"
        elif embed_type == "hr":
            return "\n---\n\n"
        elif embed_type == "office":
            return self.handle_office(embed_data)
        elif embed_type == "drawio":
            return self.handle_drawio(embed_data)
        elif embed_type == "mermaid":
            return self.handle_mermaid(embed_data)
        elif embed_type == "math":
            return self.handle_math(embed_data)
        elif embed_type == "webpage":
            return self.handle_webpage(embed_data)
        else:
            logger.warning(f"不支持的 embed 类型: {embed_type}")
            return ""

    def handle_image(self, embed_data: dict) -> str:
        """处理图片"""
        src = embed_data.get("src", "")
        file_name = embed_data.get("fileName", "")
        return f"![{file_name}]({src})\n\n"

    def handle_office(self, embed_data: dict) -> str:
        """处理 Office 附件"""
        file_name = embed_data.get('fileName', '')
        src = embed_data.get('src', '')
        return f'\n[{file_name}](wiz-collab-attachment://{src})\n\n'

    def handle_drawio(self, embed_data: dict) -> str:
        """处理流程图"""
        src = embed_data.get("src", "")
        file_name = "流程图"
        return f'\n[{file_name}](wiz-collab-attachment://{src})\n\n'

    def handle_mermaid(self, embed_data: dict) -> str:
        """处理 Mermaid 流程图"""
        mermaid_text = embed_data.get("mermaidText", "")
        if mermaid_text:
            return f'\n```mermaid\n{mermaid_text}\n```\n\n'
        else:
            src = embed_data.get("src", "")
            if src:
                file_name = "Mermaid流程图"
                return f'\n[{file_name}](wiz-collab-attachment://{src})\n\n'
            return ""

    def handle_math(self, embed_data: dict) -> str:
        """处理数学公式"""
        math_text = embed_data.get("mathjaxText", "")
        align = embed_data.get("align", "center")
        if math_text:
            if align == "center":
                return f"\n$$\n{math_text}\n$$\n"
            else:
                return f"${math_text}$\n"
        return ""

    def handle_webpage(self, embed_data: dict) -> str:
        """处理网页嵌入"""
        src = embed_data.get("src", "")
        return f"\n[webpage]({src})\n\n"


class TableStrategy:
    """表格块策略"""

    def __init__(self, data: dict):
        self.data = data

    def to_text(self, block_row: dict) -> str:
        """转换表格块为 Markdown"""
        cols = block_row.get("cols", 0)
        children = block_row.get("children", [])

        if not children or cols == 0:
            return ""

        # 从 data 中获取 cell 内容
        children_text = []
        for child_id in children:
            if child_id in self.data:
                cell_text = self.data[child_id][0].get("text", [])
                text_content = ''.join(BlockTextConverter.to_text(t) for t in cell_text)
                children_text.append(text_content)
            else:
                children_text.append('')

        # 分离表头和表体
        headers = children_text[:cols]
        body = children_text[cols:]

        # 构建 Markdown 表格
        md_table = "| " + " | ".join(headers) + " |\n"
        md_table += "| " + " | ".join(["-----"] * cols) + " |\n"

        # 分割表体行
        body_rows = [body[i:i + cols] for i in range(0, len(body), cols)]
        for row in body_rows:
            md_table += "| " + " | ".join(row) + " |\n"

        return '\n' + md_table + '\n'


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

    STRATEGY_MAP = {
        "text": TextStrategy,
        "list": ListStrategy,
        "embed": EmbedStrategy,
        "table": TableStrategy,
        "code": TextStrategy,
        "math": TextStrategy
    }

    @staticmethod
    def to_text(data: dict, block: dict) -> str:
        """将协作笔记块转换为 Markdown"""
        block_type = block.get("type")
        strategy_class = MarkdownConverter.STRATEGY_MAP.get(block_type)

        if not strategy_class:
            logger.warning(f"不支持的块类型: {block_type}")
            return ""

        strategy = strategy_class(data)
        return strategy.to_text(block)


class CollaborationNoteParser(NoteParser):
    """协作笔记解析器"""

    def parse_content(self, origin_content: str) -> str:
        """解析协作笔记 JSON 内容为 Markdown"""
        markdown_content = self._parse(origin_content)
        file_content = NoteFixer.fix(markdown_content)
        return file_content

    def _parse(self, origin_content: str) -> str:
        """解析 JSON 内容"""
        json_content = json.loads(origin_content)
        blocks = json_content.get('data', {}).get('data', {}).get("blocks", [])

        # 构建数据映射（用于表格等引用）
        data_map = json_content.get('data', {}).get('data', {})

        text_parts = []
        for block in blocks:
            block_text = MarkdownConverter.to_text(data_map, block)
            text_parts.append(block_text)

        return ''.join(text_parts)


path: scripts/config.py
content: """配置管理模块"""
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from wiz_logging import get_logger

logger = get_logger()


class Config:
    """为知笔记 MCP 配置类"""

    def __init__(self, user_id: str, password: str, base_url: str,
                 group_name: str = None, kb_guid: str = None):
        self.user_id = user_id
        self.[REDACTED_SECRET]
        self.base_url = base_url.rstrip('/')
        self.group_name = group_name or ""
        self.kb_guid = kb_guid or ""

        # as_url 和 kb_server 都使用统一的 base_url
        self.as_url = self.base_url
        self.kb_server = self.base_url

    @classmethod
    def load(cls, env_path: str = None):
        """
        加载配置
        1. 若项目根目录存在 .env，则 load_dotenv
        2. 校验 WIZ_USER_ID/WIZ_PASSWORD/WIZ_BASE_URL 必填
        3. kb_guid 若包含 "://" 视为误填，置空
        """
        # 确定项目根目录
        if env_path:
            project_root = Path(env_path).parent
        else:
            project_root = Path(__file__).parent.parent

        dotenv_path = project_root / ".env"

        if dotenv_path.exists():
            load_dotenv(dotenv_path)
            logger.info(f"已加载 .env 文件: {dotenv_path}")

        # 读取必填配置
        user_id = os.getenv("WIZ_USER_ID")
        [REDACTED_SECRET]"WIZ_PASSWORD")
        base_url = os.getenv("WIZ_BASE_URL")

        # 校验必填字段
        if not user_id:
            raise ValueError("WIZ_USER_ID 是必填配置项")
        if not password:
            raise ValueError("WIZ_PASSWORD 是必填配置项")
        if not base_url:
            raise ValueError("WIZ_BASE_URL 是必填配置项")

        # 读取可选配置
        group_name = os.getenv("WIZ_GROUP_NAME")
        kb_guid = os.getenv("WIZ_KB_GUID")

        # kb_guid 若包含 "://" 视为误填，置空
        if kb_guid and "://" in kb_guid:
            logger.warning(f"WIZ_KB_GUID 包含 '://'，视为误填，将置空: {kb_guid}")
            kb_guid = ""

        return cls(user_id, password, base_url, group_name, kb_guid)


def get_config(env_path: str = None) -> Config:
    """获取配置单例"""
    global _config
    if _config is None:
        _config = Config.load(env_path)
    return _config


_config = None


path: scripts/html_note_parser.py
content: """HTML 笔记解析器"""
import html2text

from note_parser import NoteParser
from note_fixer import NoteFixer


class HtmlNoteParser(NoteParser):
    """HTML 笔记解析器"""

    def parse_content(self, origin_content: str) -> str:
        """解析 HTML 内容为 Markdown"""
        markdown_content = html2text.html2text(origin_content)
        file_content = NoteFixer.fix(markdown_content)
        return file_content


path: scripts/lite_note_parser.py
content: """Lite/Markdown 笔记解析器"""
from bs4 import BeautifulSoup

from note_parser import NoteParser
from note_fixer import NoteFixer


class LiteNoteParser(NoteParser):
    """Lite/Markdown 笔记解析器"""

    def parse_content(self, origin_content: str) -> str:
        """解析 Lite 内容为 Markdown"""
        markdown_content = self._parse(origin_content)
        file_content = NoteFixer.fix(markdown_content)
        return file_content

    @staticmethod
    def _parse(origin_content: str) -> str:
        """解析 Lite 格式内容"""
        soup = BeautifulSoup(origin_content, 'html.parser')
        body_tag = soup.find('body')
        if not body_tag:
            return origin_content
        pre_tag = body_tag.find('pre')
        if pre_tag:
            return pre_tag.get_text()
        return origin_content


path: scripts/note.py
content: """Note 类 - 笔记数据模型"""


class Note:
    """笔记数据模型"""

    @staticmethod
    def is_collaboration_note(note_type: str) -> bool:
        """判断是否是协作笔记"""
        return note_type == 'collaboration'


path: scripts/note_fixer.py
content: """NoteFixer - Markdown 后处理"""
import re


HEADING_PATTERN = re.compile(r'^(#{1,6})(?!\s+)(\w+)')


def fix_markdown_title(content: str) -> str:
    """修复标题#后面可能没有空格的问题"""
    lines = content.split('\n')
    output_lines = []
    for line in lines:
        if HEADING_PATTERN.match(line):
            result = re.sub(HEADING_PATTERN, r'\1 \2', line)
            output_lines.append(result)
        else:
            output_lines.append(line)
    return '\n'.join(output_lines)


def fix_markdown_code_block(content: str) -> str:
    """修复代码块多余的空行"""
    line_num = 0
    code_block = False
    fix_content = []
    lines = content.split('\n')
    for line in lines:
        if line.startswith('```'):
            code_block = not code_block
            line_num = 0
        if code_block:
            line_num += 1
            if line_num % 2 == 0:
                if line.isspace() or len(line) == 0:
                    continue
        fix_content.append(line)
    return '\n'.join(fix_content)


def fix_markdown_list(content: str) -> str:
    """将文本中的所有 \\- 开头的文字替换为 -"""
    return content.replace('\\- ', '- ')


class NoteFixer:
    """Markdown 内容修复类"""

    @staticmethod
    def fix(markdown_content: str) -> str:
        """修复 Markdown 内容"""
        file_content = fix_markdown_title(markdown_content)
        file_content = fix_markdown_code_block(file_content)
        file_content = fix_markdown_list(file_content)
        # 多个换行合并
        file_content = file_content.replace('\n\n\n\n', '\n\n')
        file_content = file_content.replace('\n\n \n\n', '\n\n')
        return file_content


path: scripts/note_parser.py
content: """笔记解析器基类"""
from abc import ABC, abstractmethod


class NoteParser(ABC):
    """笔记解析器抽象基类"""

    @abstractmethod
    def parse_content(self, origin_content: str) -> str:
        """
        解析笔记原始内容为 Markdown
        :param origin_content: 原始内容
        :return: Markdown 格式内容
        """
        pass


path: scripts/note_parser_factory.py
content: """笔记解析器工厂"""
from note_parser import NoteParser
from html_note_parser import HtmlNoteParser
from lite_note_parser import LiteNoteParser
from collaboration_note_parser import CollaborationNoteParser
from note import Note


class NoteParserFactory:
    """笔记解析器工厂"""

    @staticmethod
    def create_parser(note_type: str) -> NoteParser:
        """
        根据笔记类型创建对应的解析器
        :param note_type: 笔记类型
        :return: 笔记解析器实例
        """
        if Note.is_collaboration_note(note_type):
            return CollaborationNoteParser()
        elif note_type == 'lite/markdown':
            return LiteNoteParser()
        else:
            return HtmlNoteParser()


path: scripts/server.py
content: """MCP Server 主实现"""
import sys
import json
import base64
import re
import os
from pathlib import Path
from typing import Any, Dict, Optional

from config import Config, get_config
from wiz_open_api import WizOpenApi
from note_parser_factory import NoteParserFactory
from note import Note
from wiz_logging import get_logger

logger = get_logger()


class WizMCPServer:
    """为知笔记 MCP Server"""

    def __init__(self):
        self.config: Optional[Config] = None
        self.api: Optional[WizOpenApi] = None

    def initialize(self, config: Config):
        """初始化 Server"""
        self.config = config
        self.api = WizOpenApi(config)
        logger.info("WizMCP Server 初始化完成")

    def handle_request(self, request: dict) -> dict:
        """处理 JSON-RPC 请求"""
        method = request.get("method")
        request_id = request.get("id")

        if method == "initialize":
            return self._handle_initialize(request_id)
        elif method == "notifications/initialized":
            return self._handle_notifications_initialized(request_id)
        elif method == "tools/list":
            return self._handle_tools_list(request_id)
        elif method == "tools/call":
            return self._handle_tools_call(request_id, request.get("params", {}))
        else:
            return self._error_response(request_id, -32601, f"Method not found: {method}")

    def _handle_initialize(self, request_id: Any) -> dict:
        """处理 initialize 请求"""
        return {
            "jsonrpc": "2.0",
            "id": request_id,
            "result": {
                "protocolVersion": "2024-11-05",
                "capabilities": {
                    "tools": {}
                },
                "serverInfo": {
                    "name": "wiz-mcp",
                    "version": "1.0.0"
                }
            }
        }

    def _handle_notifications_initialized(self, request_id: Any) -> dict:
        """处理 notifications/initialized 请求"""
        return {
            "jsonrpc": "2.0",
            "id": request_id,
            "result": {}
        }

    def _handle_tools_list(self, request_id: Any) -> dict:
        """处理 tools/list 请求"""
        tools = [
            {
                "name": "wiz_list_notes",
                "description": "列出笔记元数据",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "start_version": {
                            "type": "integer",
                            "description": "起始版本号，默认0"
                        },
                        "count": {
                            "type": "integer",
                            "description": "返回数量，默认50，范围1-200",
                            "default": 50
                        }
                    }
                }
            },
            {
                "name": "wiz_search_notes",
                "description": "调用官方搜索接口搜索笔记",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "搜索关键词（必填）"
                        }
                    },
                    "required": ["query"]
                }
            },
            {
                "name": "wiz_get_note",
                "description": "读取笔记正文（支持下载图片/附件到本地）",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "doc_guid": {
                            "type": "string",
                            "description": "笔记GUID（必填）"
                        },
                        "format": {
                            "type": "string",
                            "enum": ["markdown", "html", "both"],
                            "description": "返回格式，默认markdown",
                            "default": "markdown"
                        },
                        "include_info": {
                            "type": "boolean",
                            "description": "是否包含笔记信息，默认true",
                            "default": True
                        },
                        "include_resources": {
                            "type": "boolean",
                            "description": "是否下载图片/附件到本地，默认true",
                            "default": True
                        },
                        "include_extracted_text": {
                            "type": "boolean",
                            "description": "是否抽取附件文本（OCR/解析），默认false",
                            "default": False
                        },
                        "max_extract_chars": {
                            "type": "integer",
                            "description": "抽取文本总长度上限，默认20000，0不限",
                            "default": 20000
                        },
                        "max_extract_chars_per_item": {
                            "type": "integer",
                            "description": "单文件抽取上限，默认5000，0不限",
                            "default": 5000
                        },
                        "ocr_lang": {
                            "type": "string",
                            "description": "OCR语言参数，默认chi_sim+eng",
                            "default": "chi_sim+eng"
                        }
                    },
                    "required": ["doc_guid"]
                }
            },
            {
                "name": "wiz_list_attachments",
                "description": "列出笔记附件",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "doc_guid": {
                            "type": "string",
                            "description": "笔记GUID（必填）"
                        },
                        "note_type": {
                            "type": "string",
                            "description": "笔记类型（可选，用于协作笔记回退）"
                        }
                    },
                    "required": ["doc_guid"]
                }
            },
            {
                "name": "wiz_download_attachment",
                "description": "下载附件",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "kind": {
                            "type": "string",
                            "enum": ["normal", "collaboration_resource"],
                            "description": "附件类型，默认normal",
                            "default": "normal"
                        },
                        "doc_guid": {
                            "type": "string",
                            "description": "笔记GUID（必填）"
                        },
                        "att_guid": {
                            "type": "string",
                            "description": "普通附件GUID"
                        },
                        "name": {
                            "type": "string",
                            "description": "附件名称（仅回显/清理换行）"
                        },
                        "src": {
                            "type": "string",
                            "description": "协作资源标识"
                        }
                    },
                    "required": ["doc_guid"]
                }
            },
            {
                "name": "wiz_create_note",
                "description": "创建笔记（支持从URL导入Markdown）",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "title": {
                            "type": "string",
                            "description": "笔记标题"
                        },
                        "content": {
                            "type": "string",
                            "description": "笔记内容（Markdown格式）"
                        },
                        "url": {
                            "type": "string",
                            "description": "URL地址，将自动抓取并转换为Markdown"
                        },
                        "category": {
                            "type": "string",
                            "description": "分类路径，默认 /My Notes/",
                            "default": "/My Notes/"
                        },
                        "tags": {
                            "type": "string",
                            "description": "标签，逗号分隔"
                        }
                    }
                }
            }
        ]

        return {
            "jsonrpc": "2.0",
            "id": request_id,
            "result": {"tools": tools}
        }

    def _handle_tools_call(self, request_id: Any, params: dict) -> dict:
        """处理 tools/call 请求"""
        tool_name = params.get("name")
        tool_args = params.get("arguments", {})

        try:
            if tool_name == "wiz_list_notes":
                result = self._tool_wiz_list_notes(tool_args)
            elif tool_name == "wiz_search_notes":
                result = self._tool_wiz_search_notes(tool_args)
            elif tool_name == "wiz_get_note":
                result = self._tool_wiz_get_note(tool_args)
            elif tool_name == "wiz_list_attachments":
                result = self._tool_wiz_list_attachments(tool_args)
            elif tool_name == "wiz_download_attachment":
                result = self._tool_wiz_download_attachment(tool_args)
            elif tool_name == "wiz_create_note":
                result = self._tool_wiz_create_note(tool_args)
            else:
                return self._error_response(request_id, -32601, f"Unknown tool: {tool_name}")

            return {
                "jsonrpc": "2.0",
                "id": request_id,
                "result": {
                    "content": [
                        {
                            "type": "text",
                            "text": json.dumps(result, ensure_ascii=False)
                        }
                    ]
                }
            }

        except Exception as e:
            logger.error(f"工具执行失败: {tool_name}, error: {e}")
            return {
                "jsonrpc": "2.0",
                "id": request_id,
                "result": {
                    "content": [
                        {
                            "type": "text",
                            "text": json.dumps({"error": str(e)}, ensure_ascii=False)
                        }
                    ],
                    "isError": True
                }
            }

    def _tool_wiz_list_notes(self, args: dict) -> dict:
        """wiz_list_notes 工具"""
        start_version = args.get("start_version", 0)
        count = min(max(args.get("count", 50), 1), 200)

        result = self.api.get_note_list(start_version, count)

        # API 直接返回数组或 {notes: [...], next_version: n}
        if isinstance(result, list):
            notes = result
            next_version = None
        else:
            notes = result.get("notes", []) if isinstance(result, dict) else []
            next_version = result.get("next_version") if isinstance(result, dict) else None

        return {
            "notes": notes,
            "next_version": next_version
        }

    def _tool_wiz_search_notes(self, args: dict) -> dict:
        """wiz_search_notes 工具 - 调用官方搜索接口"""
        query = args.get("query", "")

        if not query:
            raise ValueError("query 是必填参数")

        matches = self.api.search_notes(query, with_abstract=True, with_favor=False)

        return {
            "matches": matches,
            "next_version": None
        }

    def _tool_wiz_get_note(self, args: dict) -> dict:
        """wiz_get_note 工具"""
        doc_guid = args.get("doc_guid")
        format_type = args.get("format", "markdown")
        include_info = args.get("include_info", True)
        include_resources = args.get("include_resources", True)
        include_extracted_text = args.get("include_extracted_text", False)
        max_extract_chars = args.get("max_extract_chars", 20000)
        max_extract_chars_per_item = args.get("max_extract_chars_per_item", 5000)
        ocr_lang = args.get("ocr_lang", "chi_sim+eng")

        if not doc_guid:
            raise ValueError("doc_guid 是必填参数")

        # 如果开启文本抽取，强制下载资源
        if include_extracted_text:
            include_resources = True

        # 先获取笔记详情判断类型
        detail = self.api.get_note_detail(doc_guid)
        info = detail.get("info", {})
        note_type = info.get("type", "")
        title = info.get("title", "无标题")

        result = {
            "doc_guid": doc_guid,
            "type": note_type
        }

        if include_info:
            result["info"] = info

        # 获取原始内容
        is_collab = Note.is_collaboration_note(note_type)

        if is_collab:
            # 协作笔记
            editor_token = self.api.get_collaboration_token(doc_guid)
            collab_json = self.api.get_collaboration_content(editor_token, doc_guid)

            origin_content = collab_json

            if format_type in ("markdown", "both"):
                from collaboration_note_parser import CollaborationNoteParser
                parser = CollaborationNoteParser()
                result["markdown"] = parser.parse_content(collab_json)

            if format_type == "html":
                # 协作笔记返回 JSON 原文
                result["html"] = collab_json
            elif format_type == "both":
                result["html"] = collab_json

            # 下载协作笔记的资源
            if include_resources:
                resources_info = self._download_collaboration_resources(doc_guid, title)
                result["resources"] = resources_info["resources"]
                result["markdown"] = self._replace_resource_paths(
                    result.get("markdown", ""),
                    resources_info["replace_map"]
                )
        else:
            # 普通笔记
            origin_content = self.api.get_note_content(doc_guid)

            if format_type in ("markdown", "both"):
                parser = NoteParserFactory.create_parser(note_type)
                result["markdown"] = parser.parse_content(origin_content)

            if format_type in ("html", "both"):
                result["html"] = origin_content

            # 下载普通笔记的资源
            if include_resources:
                resources_info = self._download_note_resources(doc_guid, title)
                result["resources"] = resources_info["resources"]
                result["markdown"] = self._replace_resource_paths(
                    result.get("markdown", ""),
                    resources_info["replace_map"]
                )

        # 文本抽取
        if include_extracted_text and result.get("resources"):
            extract_result = self._extract_artifacts_text(
                result.get("resources", []),
                max_extract_chars,
                max_extract_chars_per_item,
                ocr_lang
            )
            result["extracted"] = extract_result["extracted"]
            result["bundle_text"] = self._build_bundle_text(
                result.get("markdown", ""),
                extract_result["extracted"],
                max_extract_chars
            )

        return result

    def _download_note_resources(self, doc_guid: str, title: str) -> dict:
        """下载普通笔记的资源（图片/附件）"""
        resources = self.api.get_note_resources(doc_guid)

        output_dir = Path(__file__).parent.parent / "output" / "notes" / doc_guid
        downloaded_resources = []
        replace_map = {}

        # 先过滤资源，收集有效资源
        valid_resources = []
        for res in resources:
            name = res.get("name", "")
            url = res.get("url", "")
            res_type = res.get("type", "attachment")

            if not name or not url:
                continue

            # 过滤非必要资源：name 是纯 GUID 格式的不是有效文件
            import re
            if re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', name, re.I):
                continue

            valid_resources.append(res)

        # 如果没有有效资源，直接返回
        if not valid_resources:
            return {
                "resources": [],
                "replace_map": {}
            }

        # 创建目录
        images_dir = output_dir / "images"
        attachments_dir = output_dir / "attachments"
        images_dir.mkdir(parents=True, exist_ok=True)
        attachments_dir.mkdir(parents=True, exist_ok=True)

        for res in valid_resources:
            name = res.get("name", "")
            url = res.get("url", "")

            try:
                # 判断是图片还是附件
                ext = name.lower().split('.')[-1] if '.' in name else ''
                image_exts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']

                if ext in image_exts:
                    # 图片
                    save_dir = images_dir
                    save_path = save_dir / name
                    rel_path = f"output/notes/{doc_guid}/images/{name}"
                else:
                    # 附件
                    save_dir = attachments_dir
                    save_path = save_dir / name
                    rel_path = f"output/notes/{doc_guid}/attachments/{name}"

                # 下载并保存
                content = self.api.download_resource(url)
                with open(save_path, "wb") as f:
                    f.write(content)

                downloaded_resources.append({
                    "name": name,
                    "path": str(save_path),
                    "relative_path": rel_path,
                    "type": "image" if ext in image_exts else "attachment"
                })

                # 记录替换映射（原路径 -> 新路径）
                replace_map[name] = rel_path
                logger.info(f"下载资源: {name} -> {rel_path}")

            except Exception as e:
                logger.warning(f"下载资源失败: {name}, error: {e}")

        return {
            "resources": downloaded_resources,
            "replace_map": replace_map
        }

    def _download_collaboration_resources(self, doc_guid: str, title: str) -> dict:
        """下载协作笔记的资源（图片/drawio等）"""
        # 获取协作笔记的 JSON 内容，提取 embed 信息
        editor_token = self.api.get_collaboration_token(doc_guid)
        collab_json = self.api.get_collaboration_content(editor_token, doc_guid)

        import json
        try:
            data = json.loads(collab_json)
            blocks = data.get("data", {}).get("data", {}).get("blocks", [])
        except:
            blocks = []

        # 先收集有效资源
        valid_resources = []
        for block in blocks:
            if block.get("type") != "embed":
                continue

            embed_type = block.get("embedType", "")
            embed_data = block.get("embedData", {})

            if embed_type in ("image", "office", "drawio"):
                src = embed_data.get("src", "")
                file_name = embed_data.get("fileName", src)
                if src:
                    valid_resources.append({
                        "type": embed_type,
                        "src": src,
                        "file_name": file_name
                    })

        # 如果没有有效资源，直接返回
        if not valid_resources:
            return {
                "resources": [],
                "replace_map": {}
            }

        # 创建目录
        output_dir = Path(__file__).parent.parent / "output" / "notes" / doc_guid
        images_dir = output_dir / "images"
        attachments_dir = output_dir / "attachments"
        images_dir.mkdir(parents=True, exist_ok=True)
        attachments_dir.mkdir(parents=True, exist_ok=True)

        downloaded_resources = []
        replace_map = {}

        for res in valid_resources:
            embed_type = res["type"]
            src = res["src"]
            file_name = res["file_name"]

            try:
                if embed_type == "image":
                    content = self.api.get_collaboration_image(doc_guid, src)
                    save_path = images_dir / file_name

                    rel_path = f"output/notes/{doc_guid}/images/{file_name}"
                    downloaded_resources.append({
                        "name": file_name,
                        "path": str(save_path),
                        "relative_path": rel_path,
                        "type": "image"
                    })

                    replace_map[src] = rel_path
                    logger.info(f"下载协作图片: {file_name}")

                elif embed_type in ("office", "drawio"):
                    content = self.api.download_collaboration_resource(editor_token, doc_guid, src)
                    save_path = attachments_dir / file_name

                    rel_path = f"output/notes/{doc_guid}/attachments/{file_name}"
                    downloaded_resources.append({
                        "name": file_name,
                        "path": str(save_path),
                        "relative_path": rel_path,
                        "type": embed_type
                    })

                    replace_map[src] = rel_path
                    logger.info(f"下载协作附件: {file_name}")

                with open(save_path, "wb") as f:
                    f.write(content)

            except Exception as e:
                logger.warning(f"下载协作资源失败: {src}, error: {e}")

        return {
            "resources": downloaded_resources,
            "replace_map": replace_map
        }

        return {
            "resources": downloaded_resources,
            "replace_map": replace_map
        }

    def _replace_resource_paths(self, markdown: str, replace_map: dict) -> str:
        """替换 Markdown 中的资源路径"""
        if not replace_map:
            return markdown

        for old_path, new_path in replace_map.items():
            # 替换各种格式的引用
            # ![name](old_path) -> ![name](new_path)
            # [name](wiz-collab-attachment://old_path) -> [name](new_path)
            # ![name](wiz-collab-attachment://old_path) -> ![name](new_path)

            # 处理 wiz-collab-attachment:// 协议
            markdown = markdown.replace(f"](wiz-collab-attachment://{old_path})", f"]({new_path})")
            markdown = markdown.replace(f"]({old_path})", f"]({new_path})")

        return markdown

    def _clamp_int(self, value: int, max_val: int) -> int:
        """限制整数在合理范围"""
        if max_val <= 0:
            return value
        return min(value, max_val)

    def _extract_image_ocr(self, file_path: str, ocr_lang: str) -> str:
        """对图片做 OCR"""
        try:
            from PIL import Image
            import pytesseract
            img = Image.open(file_path)
            text = pytesseract.image_to_string(img, lang=ocr_lang)
            return text.strip()
        except Exception as e:
            return f"[OCR error: {e}]"

    def _extract_pdf_text(self, file_path: str) -> str:
        """抽取 PDF 文本"""
        try:
            from pypdf import PdfReader
            reader = PdfReader(file_path)
            texts = []
            for page in reader.pages:
                text = page.extract_text()
                if text:
                    texts.append(text)
            return "\n".join(texts)
        except Exception as e:
            return f"[PDF error: {e}]"

    def _extract_docx_text(self, file_path: str) -> str:
        """抽取 DOCX 文本（不使用 python-docx）"""
        try:
            import zipfile
            import xml.etree.ElementTree as ET
            with zipfile.ZipFile(file_path, 'r') as z:
                with z.open('word/document.xml') as f:
                    tree = ET.parse(f)
                    root = tree.getroot()
                    ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
                    texts = []
                    for t in root.iter('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'):
                        if t.text:
                            texts.append(t.text)
                    return ''.join(texts)
        except Exception as e:
            return f"[DOCX error: {e}]"

    def _extract_text_file(self, file_path: str) -> str:
        """读取文本文件"""
        try:
            with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
                return f.read()
        except Exception as e:
            return f"[Text error: {e}]"

    def _extract_artifacts_text(self, resources: list, max_total: int, max_per_item: int, ocr_lang: str) -> dict:
        """抽取所有附件文本"""
        extracted_items = []
        total_chars = 0

        for res in resources:
            name = res.get("name", "")
            file_path = res.get("path", "")
            rel_path = res.get("relative_path", "")
            res_type = res.get("type", "attachment")

            method = ""
            text = ""
            error = None

            try:
                ext = name.lower().split('.')[-1] if '.' in name else ''

                # 图片 OCR
                if ext in ('png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tif', 'tiff'):
                    method = "ocr"
                    text = self._extract_image_ocr(file_path, ocr_lang)

                # PDF
                elif ext == 'pdf':
                    method = "pdf"
                    text = self._extract_pdf_text(file_path)

                # DOCX
                elif ext in ('docx', 'doc'):
                    method = "docx"
                    text = self._extract_docx_text(file_path)

                # 文本文件
                elif ext in ('txt', 'md', 'log', 'json', 'yaml', 'yml', 'csv', 'xml', 'html', 'py', 'js', 'java', 'c', 'cpp', 'h', 'sh', 'bat', 'ps1'):
                    method = "text"
                    text = self._extract_text_file(file_path)

                else:
                    method = ""

            except Exception as e:
                error = str(e)

            chars = len(text)
            # 限制单文件长度
            if max_per_item > 0 and chars > max_per_item:
                text = text[:max_per_item]
                chars = len(text)

            extracted_items.append({
                "name": name,
                "relative_path": rel_path,
                "type": res_type,
                "method": method,
                "extracted_chars": chars,
                "extracted_text": text,
                "error": error
            })

            total_chars += chars

        # 限制总长度
        if max_total > 0 and total_chars > max_total:
            # 从后往前截断
            remaining = max_total
            for item in reversed(extracted_items):
                if item["extracted_chars"] <= remaining:
                    remaining -= item["extracted_chars"]
                else:
                    item["extracted_text"] = item["extracted_text"][:remaining]
                    item["extracted_chars"] = remaining
                    remaining = 0
                    break
            total_chars = sum(item["extracted_chars"] for item in extracted_items)

        return {
            "extracted": {
                "total_chars": total_chars,
                "items": extracted_items
            }
        }

    def _build_bundle_text(self, markdown: str, extracted: dict, max_total: int) -> str:
        """构建 bundle_text"""
        parts = [markdown]

        for item in extracted.get("items", []):
            if item.get("extracted_text"):
                parts.append(f"\n\n--- {item['name']} ({item['method']}) ---\n")
                parts.append(item["extracted_text"])

        result = "".join(parts)

        if max_total > 0 and len(result) > max_total:
            result = result[:max_total]

        return result

    def _tool_wiz_list_attachments(self, args: dict) -> dict:
        """wiz_list_attachments 工具"""
        doc_guid = args.get("doc_guid")
        note_type = args.get("note_type", "")

        if not doc_guid:
            raise ValueError("doc_guid 是必填参数")

        result = {
            "doc_guid": doc_guid,
            "note_type": note_type
        }

        try:
            attachments = self.api.get_note_attachments(doc_guid)

            if attachments:
                result["attachments"] = attachments
            elif note_type == "collaboration" and not attachments:
                # 协作笔记回退解析
                collab_result = self._parse_collaboration_attachments(doc_guid)
                result["collaboration"] = collab_result
            elif not attachments:
                # 非协作笔记没有附件
                result["attachments"] = []

        except Exception as e:
            if note_type == "collaboration":
                # 协作笔记回退解析
                collab_result = self._parse_collaboration_attachments(doc_guid)
                result["collaboration"] = collab_result
            else:
                raise Exception(f"获取附件列表失败: {e}")

        return result

    def _parse_collaboration_attachments(self, doc_guid: str) -> dict:
        """解析协作笔记的附件"""
        editor_token = self.api.get_collaboration_token(doc_guid)
        collab_json = self.api.get_collaboration_content(editor_token, doc_guid)

        from collaboration_note_parser import CollaborationNoteParser
        parser = CollaborationNoteParser()
        markdown = parser.parse_content(collab_json)

        # 解析 embeds
        json_data = json.loads(collab_json)
        embeds = []
        blocks = json_data.get("data", {}).get("data", {}).get("blocks", [])
        for block in blocks:
            if block.get("type") == "embed":
                embed_type = block.get("embedType", "")
                embed_data = block.get("embedData", {})
                embeds.append({
                    "embedType": embed_type,
                    "src": embed_data.get("src", ""),
                    "fileName": embed_data.get("fileName", "")
                })

        # 从 markdown 中提取 wiz-collab-attachment:// 链接（排除图片）
        attachment_names = []
        pattern = r'\[([^\]]+)\]\(wiz-collab-attachment://([^)]+)\)'
        for match in re.finditer(pattern, markdown):
            src = match.group(2)
            if src not in attachment_names:
                attachment_names.append(src)

        return {
            "attachment_names": attachment_names,
            "embeds": embeds
        }

    def _tool_wiz_download_attachment(self, args: dict) -> dict:
        """wiz_download_attachment 工具"""
        kind = args.get("kind", "normal")
        doc_guid = args.get("doc_guid")
        att_guid = args.get("att_guid")
        name = args.get("name", "")
        src = args.get("src")

        if not doc_guid:
            raise ValueError("doc_guid 是必填参数")

        # 清理 name 中的换行
        if name:
            name = name.replace("\n", " ").replace("\r", "")

        result = {
            "doc_guid": doc_guid
        }

        if kind == "normal":
            if not att_guid:
                raise ValueError("kind=normal 时，att_guid 是必填参数")

            binary_content = self.api.download_attachment(doc_guid, att_guid)
            result["att_guid"] = att_guid
            result["name"] = name
            result["kind"] = "normal"

        else:
            # collaboration_resource
            if not src:
                raise ValueError("kind=collaboration_resource 时，src 是必填参数")

            editor_token = self.api.get_collaboration_token(doc_guid)
            binary_content = self.api.download_collaboration_resource(editor_token, doc_guid, src)
            result["src"] = src
            result["name"] = name
            result["kind"] = "collaboration_resource"

        # 保存到 output/attach 目录
        if name:
            save_dir = Path(__file__).parent.parent / "output" / "attach"
            save_dir.mkdir(parents=True, exist_ok=True)
            file_path = save_dir / name
            with open(file_path, "wb") as f:
                f.write(binary_content)
            result["saved_path"] = str(file_path)

        # 转换为 base64
        result["base64"] = base64.b64encode(binary_content).decode("utf-8")

        return result

    def _tool_wiz_create_note(self, args: dict) -> dict:
        """wiz_create_note 工具 - 创建笔记"""
        title = args.get("title", "")
        content = args.get("content", "")
        url = args.get("url", "")
        category = args.get("category", "/My Notes/")
        tags = args.get("tags", "")

        final_url = url

        # 如果提供了 URL，则抓取网页内容
        if url:
            import requests
            from bs4 import BeautifulSoup
            import html2text
            import re

            logger.info(f"正在抓取 URL: {url}")

            headers = {
                'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
                'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
                'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8'
            }

            try:
                response = requests.get(url, headers=headers, timeout=30, allow_redirects=True)
                response.raise_for_status()

                # 校验 content-type
                content_type = response.headers.get('content-type', '')
                if 'text/html' not in content_type.lower():
                    # 检查响应体是否以 < 开头
                    if not response.text.strip().startswith('<'):
                        raise Exception(f"非 HTML 响应: content-type={content_type}")

                # 获取最终跳转 URL
                final_url = response.url

                html_content = response.text
                soup = BeautifulSoup(html_content, 'html.parser')

                # 提取标题
                if soup.title:
                    title = soup.title.string.strip() if soup.title.string else ""

                # 主内容节点选择
                main_content = None

                # 优先 article, main 且文本长度 > 200
                for tag in ['article', 'main']:
                    elem = soup.find(tag)
                    if elem and len(elem.get_text(strip=True)) > 200:
                        main_content = elem
                        break

                # 根据 id/class 关键字选择
                if not main_content:
                    keywords = ['content', 'article', 'post', 'thread', 'main', 'body', 'entry', 'text']
                    for keyword in keywords:
                        elems = soup.find_all(id=re.compile(keyword, re.I))
                        for elem in elems:
                            text_len = len(elem.get_text(strip=True))
                            if text_len > 200:
                                main_content = elem
                                break
                        if main_content:
                            break

                        elems = soup.find_all(class_=re.compile(keyword, re.I))
                        for elem in elems:
                            text_len = len(elem.get_text(strip=True))
                            if text_len > 200:
                                main_content = elem
                                break
                        if main_content:
                            break

                # fallback 到 body
                if not main_content:
                    main_content = soup.body if soup.body else soup

                # 使用 html2text 转 Markdown
                h = html2text.HTML2Text()
                h.ignore_images = False
                h.ignore_links = False
                h.body_width = 0

                # 设置 baseurl 以处理相对链接
                parsed_url = requests.compat.urlparse(final_url)
                baseurl = f"{parsed_url.scheme}://{parsed_url.netloc}"
                h.baseurl = baseurl

                markdown_content = h.handle(str(main_content))

                # 清理标题非法字符，确保 .md 结尾
                if title:
                    title = re.sub(r'[\\/:*?"<>|]', '', title)
                    if not title.endswith('.md'):
                        title = title + '.md'
                else:
                    title = "无标题.md"

                # Markdown 顶部追加来源
                if final_url:
                    markdown_content = f"[来源]({final_url})\n\n{markdown_content}"

                content = markdown_content
                logger.info(f"成功抓取 URL，内容长度: {len(content)}")

            except Exception as e:
                raise Exception(f"抓取 URL 失败: {e}")

        if not content:
            raise Exception("content 或 url 是必填参数")

        # 清理标题非法字符，确保 .md 结尾
        if title:
            import re
            title = re.sub(r'[\\/:*?"<>|]', '', title)
            if not title.endswith('.md'):
                title = title + '.md'
        else:
            title = "无标题.md"

        # Markdown 存 <pre> 格式
        escaped_content = content.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
        html_body = f"<pre>{escaped_content}</pre>"

        # 构建为知笔记的 HTML 格式
        html = f"""<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style id="wiz_custom_css">html, body {{font-size: 12pt;}}body {{font-family: Helvetica, "Hiragino Sans GB", "微软雅黑", "Microsoft YaHei UI", SimSun, SimHei, arial, sans-serif;line-height: 1.6;margin: 0 auto;padding: 20px 16px;padding: 1.25rem 1rem;}}h1, h2, h3, h4, h5, h6 {{margin:20px 0 10px;margin:1.25rem 0 0.625rem;padding: 0;font-weight: bold;}}h1 {{font-size:20pt;font-size:1.67rem;}}h2 {{font-size:18pt;font-size:1.5rem;}}h3 {{font-size:15pt;font-size:1.25rem;}}h4 {{font-size:14pt;font-size:1.17rem;}}h5 {{font-size:12pt;font-size:1rem;}}h6 {{font-size:12pt;font-size:1rem;color: #777777;margin: 1rem 0;}}div, p, ul, ol, dl, li {{margin:0;}}blockquote, table, pre, code {{margin:8px 0;}}ul, ol {{padding-left:32px;padding-left:2rem;}}blockquote {{padding:0 12px;}}blockquote > :first-child {{margin-top:0;}}blockquote > :last-child {{margin-bottom:0;}}img {{border:0;max-width:100%;height:auto !important;margin:2px 0;}}table {{border-collapse:collapse;border:1px solid #bbbbbb;}}td, th {{padding:4px 8px;border-collapse:collapse;border:1px solid #bbbbbb;height:28px;word-break:break-all;box-sizing: border-box;}}</style>
</head>
<body>
{html_body}
</body>
</html>"""

        # 创建笔记
        result_data = self.api.create_note(title, html, category, tags)

        # 拿到 docGuid 后再调用一次 save_note 确保落库一致
        doc_guid = result_data.get("result", {}).get("docGuid", "")
        if doc_guid:
            try:
                self.api.save_note(doc_guid, title, html, category)
            except Exception as e:
                logger.warning(f"save_note 确认失败: {e}")

        # 返回结果
        return {
            "title": title,
            "category": category,
            "tags": tags,
            "url": final_url if final_url != url else (url if url else None),
            "doc_guid": doc_guid,
            "message": "笔记创建成功"
        }

    def _error_response(self, request_id: Any, code: int, message: str) -> dict:
        """返回错误响应"""
        return {
            "jsonrpc": "2.0",
            "id": request_id,
            "error": {
                "code": code,
                "message": message
            }
        }


def main():
    """主入口"""
    try:
        config = get_config()
        server = WizMCPServer()
        server.initialize(config)

        for line in sys.stdin:
            line = line.strip()
            if not line:
                continue

            try:
                request = json.loads(line)
                response = server.handle_request(request)
                print(json.dumps(response, ensure_ascii=False), file=sys.stdout)
                sys.stdout.flush()
            except json.JSONDecodeError:
                # 跳过非 JSON 行
                continue
            except Exception as e:
                logger.error(f"处理请求失败: {e}")
                error_response = {
                    "jsonrpc": "2.0",
                    "id": None,
                    "error": {
                        "code": -32603,
                        "message": f"Internal error: {e}"
                    }
                }
                print(json.dumps(error_response, ensure_ascii=False), file=sys.stdout)
                sys.stdout.flush()

    except Exception as e:
        logger.error(f"Server 启动失败: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/wiz_logging.py
content: """日志模块 - 所有日志必须输出到 stderr"""
import sys as _sys
from importlib import import_module
_logging = import_module('logging')

logger = _logging.getLogger("wiz_mcp")
logger.setLevel(_logging.INFO)

if not logger.handlers:
    handler = _logging.StreamHandler(_sys.stderr)
    handler.setLevel(_logging.INFO)
    formatter = _logging.Formatter("[%(asctime)s] %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
    handler.setFormatter(formatter)
    logger.addHandler(handler)

def get_logger():
    return logger


path: scripts/wiz_open_api.py
content: """为知笔记 Open API 封装 - HTTP/WS 调用（v2 全协作笔记模式）"""
import json
import re
import time
import ssl
import uuid
from urllib.parse import urlparse, quote
import requests
from websocket import create_connection

from config import Config
from wiz_logging import get_logger

logger = get_logger()


class WizOpenApi:
    """为知笔记 Open API 封装类 - 默认协作笔记模式"""

    ATTACHMENT_CONNECT_TIMEOUT_S = 10
    ATTACHMENT_READ_TIMEOUT_S = 60
    ATTACHMENT_CHUNK_SIZE = 64 * 1024

    def __init__(self, config: Config):
        self.config = config
        self.token = ''
        self.kb_server = ''
        self.kb_guid = ''
        self.user_guid = ''
        self.domain = ''

        self._auth()

    def _headers(self):
        """通用请求头"""
        return {'X-Wiz-Token': self.token, 'Content-Type': 'application/json'}

    def _get(self, path, params=None):
        """GET 请求封装"""
        url = f'{self.kb_server}{path}'
        resp = requests.get(url, params=params or {}, headers=self._headers())
        return self._parse_response(resp)

    def _post(self, path, data=None, params=None):
        """POST 请求封装"""
        url = f'{self.kb_server}{path}'
        resp = requests.post(url, params=params or {}, json=data or {}, headers=self._headers())
        return self._parse_response(resp)

    def _put(self, path, data=None, params=None):
        """PUT 请求封装"""
        url = f'{self.kb_server}{path}'
        resp = requests.put(url, params=params or {}, json=data or {}, headers=self._headers())
        return self._parse_response(resp)

    def _delete(self, path, params=None):
        """DELETE 请求封装"""
        url = f'{self.kb_server}{path}'
        resp = requests.delete(url, params=params or {}, headers=self._headers())
        return self._parse_response(resp)

    @staticmethod
    def _parse_response(resp):
        if resp.status_code != 200:
            raise Exception(f'HTTP错误 {resp.status_code}: {resp.text[:200]}')
        data = resp.json()
        if data.get('returnCode') != 200:
            raise Exception(f'API错误: {data}')
        return data.get('result', data)

    # ================================================================
    # 认证
    # ================================================================

    def _auth(self):
        """认证登录"""
        data = self._login()
        self.token = data['result']['token']
        self.kb_server = data['result']['kbServer']
        self.kb_guid = data['result']['kbGuid']
        self.user_guid = data['result']['userGuid']
        parsed = urlparse(self.kb_server)
        self.domain = parsed.netloc
        logger.info(f"登录成功: kb_guid={self.kb_guid}, user_guid={self.user_guid}")
        if self.config.group_name:
            self._switch_group()

    def _login(self):
        login_url = f'{self.config.as_url}/as/user/login'
        response = requests.post(
            login_url,
            data={'userId': self.config.user_id, 'password': self.config.password}
        )
        if response.status_code != 200:
            raise Exception(f'登录失败: HTTP {response.status_code}')
        data = response.json()
        if data.get('returnCode') != 200:
            raise Exception(f'登录失败: {data}')
        return data

    def _switch_group(self):
        if not self.config.group_name:
            return
        data = self._get('/as/user/groups', headers={'X-Wiz-Token': self.token})
        # 上面用的是 self._get 但 as_url 可能不同，走原始逻辑
        group_list_url = f'{self.config.as_url}/as/user/groups'
        resp = requests.get(group_list_url, headers={'X-Wiz-Token': self.token})
        data = resp.json()
        if data.get('returnCode') != 200:
            raise Exception(f'获取群组列表失败: {data}')
        for group in data.get('result', []):
            if group.get('name') == self.config.group_name:
                self.kb_guid = group['kbGuid']
                self.domain = urlparse(group.get('kbServer', self.kb_server)).netloc
                logger.info(f"已切换到群组: {self.config.group_name}, kb_guid={self.kb_guid}")
                return
        raise Exception(f'群组不存在: {self.config.group_name}')

    # ================================================================
    # 笔记 CRUD（默认协作笔记）
    # ================================================================

    def create_note(self, title, content='', category="/My Notes/", tags="",
                    content_type='markdown'):
        """
        创建笔记（默认协作笔记）
        :param title: 标题
        :param content: 内容（Markdown或纯文本）
        :param category: 分类路径
        :param tags: 标签（逗号分隔）
        :param content_type: 'markdown' | 'blocks' | 'html'
            - markdown: 自动转换为 blocks
            - blocks: content 已是 blocks 列表
            - html: 创建传统 HTML 笔记
        :return: {docGuid, editorToken, title, category}
        """
        if content_type == 'html':
            return self._create_html_note(title, content, category, tags)

        if content_type == 'blocks':
            blocks = content
        else:
            blocks = self.markdown_to_blocks(content)

        return self.create_collaboration_note(title, blocks, category, tags)

    def update_note(self, doc_guid, content='', title=None, content_type='markdown'):
        """
        更新笔记（默认协作笔记）
        :param doc_guid: 笔记GUID
        :param content: 新内容
        :param title: 可选新标题
        :param content_type: 同 create_note
        """
        if content_type == 'blocks':
            blocks = content
        elif content_type == 'html':
            return self.save_note(doc_guid, title or '', content)
        else:
            blocks = self.markdown_to_blocks(content)

        return self.update_collaboration_note(doc_guid, blocks, title)

    def delete_note(self, doc_guid):
        """
        删除笔记（移到回收站）
        :param doc_guid: 笔记GUID
        """
        result = self._delete(f'/ks/note/delete/{self.kb_guid}/{doc_guid}')
        logger.info(f"笔记已删除: {doc_guid}")
        return result

    def move_note(self, doc_guid, category):
        """
        移动笔记到指定分类
        :param doc_guid: 笔记GUID
        :param category: 目标分类路径，如 "/My Notes/项目/"
        """
        data = {
            'docGuid': doc_guid,
            'kbGuid': self.kb_guid,
            'category': category,
        }
        result = self._put(
            f'/ks/note/save/{self.kb_guid}/{doc_guid}',
            data=data,
            params={'infoOnly': '1'}
        )
        logger.info(f"笔记已移动: {doc_guid} -> {category}")
        return result

    def copy_note(self, doc_guid, title=None, category=None):
        """
        复制笔记
        :param doc_guid: 源笔记GUID
        :param title: 新标题（默认 "标题 - 副本"）
        :param category: 目标分类（默认同源）
        :return: 新笔记信息
        """
        # 获取源笔记详情
        detail = self.get_note_detail(doc_guid)
        info = detail.get('info', {})
        src_title = title or (info.get('title', '') + ' - 副本')
        src_category = category or info.get('category', '/My Notes/')
        src_tags = info.get('tags', '')

        note_type = info.get('type', 'document')
        if note_type == 'collaboration':
            # 读取协作笔记内容
            content = self.get_note_content(doc_guid)
            blocks = self.markdown_to_blocks(content)
            return self.create_collaboration_note(src_title, blocks, src_category, src_tags)
        else:
            html = detail.get('html', '')
            return self._create_html_note(src_title, html, src_category, src_tags)

    # ================================================================
    # 协作笔记操作
    # ================================================================

    def create_collaboration_note(self, title, blocks, category="/My Notes/", tags=""):
        """创建协作笔记并写入初始内容"""
        url = f'{self.kb_server}/ks/note/create/{self.kb_guid}'
        params = {'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'}
        data = {
            'kbGuid': self.kb_guid,
            'html': '',
            'category': category,
            'owner': self.config.user_id,
            'tags': tags,
            'title': title,
            'type': 'collaboration',
        }
        resp = requests.post(url, params=params, json=data, headers={
            'X-Wiz-Token': self.token, 'content-type': 'application/json'
        })
        result = resp.json()
        if result.get('returnCode') != 200:
            raise Exception(f'创建协作笔记失败: {result}')

        doc_guid = result['result']['docGuid']
        editor_token = result['editor']['editorToken']
        logger.info(f"协作笔记已创建: doc_guid={doc_guid}, title={title}")

        if blocks:
            self._ws_write_collaboration(doc_guid, editor_token, 0, blocks)

        return {
            'docGuid': doc_guid,
            'editorToken': editor_token,
            'title': title,
            'category': category,
        }

    def update_collaboration_note(self, doc_guid, blocks, title=None):
        """更新协作笔记内容（del + create 覆盖写）"""
        if title:
            self._put(
                f'/ks/note/save/{self.kb_guid}/{doc_guid}',
                data={
                    'category': '', 'docGuid': doc_guid,
                    'kbGuid': self.kb_guid, 'title': title,
                    'html': '', 'resources': []
                },
                params={'infoOnly': '1', 'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'}
            )

        token_info = self.get_collaboration_token(doc_guid)
        editor_token = token_info['editorToken'] if isinstance(token_info, dict) else token_info
        raw_content = self.get_collaboration_content(editor_token, doc_guid)
        content_data = json.loads(raw_content)
        current_v = content_data.get('data', {}).get('v', 0)
        doc_type = content_data.get('data', {}).get('type')

        if doc_type is None or current_v == 0:
            self._ws_write_collaboration(doc_guid, editor_token, 0, blocks)
        else:
            self._ws_write_collaboration(doc_guid, editor_token, current_v, blocks, delete_first=True)

        logger.info(f"协作笔记已更新: doc_guid={doc_guid}")
        return {'docGuid': doc_guid, 'status': 'updated'}

    # ================================================================
    # 笔记读取
    # ================================================================

    def get_note_list(self, version=0, count=50):
        return self._get(
            f'/ks/note/list/version/{self.kb_guid}',
            params={'version': version, 'count': count}
        )

    def get_notes_by_category(self, category, count=50, start=0):
        """
        按分类获取笔记列表
        :param category: 分类路径
        :param count: 返回数量
        :param start: 起始位置
        """
        return self._get(
            f'/ks/note/list/{self.kb_guid}',
            params={'category': category, 'count': count, 'start': start}
        )

    def search_notes(self, keyword, with_abstract=True, with_favor=False):
        return self._get(
            f'/ks/note/search/{self.kb_guid}',
            params={
                'ss': keyword,
                'withAbstract': 'true' if with_abstract else 'false',
                'withFavor': 'true' if with_favor else 'false',
                'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'
            }
        )

    def get_note_detail(self, doc_guid):
        return self._get(
            f'/ks/note/download/{self.kb_guid}/{doc_guid}',
            params={'downloadInfo': '0', 'downloadData': '1'}
        )

    def get_note_content(self, doc_guid):
        """获取笔记正文（自动检测协作笔记）"""
        detail = self.get_note_detail(doc_guid)
        info = detail.get('info', {})
        note_type = info.get('type', 'document')

        if note_type == 'collaboration':
            try:
                token_info = self.get_collaboration_token(doc_guid)
                editor_token = token_info['editorToken'] if isinstance(token_info, dict) else token_info
                raw_content = self.get_collaboration_content(editor_token, doc_guid)
                content_data = json.loads(raw_content)
                inner_data = content_data.get('data', {})
                if inner_data.get('type') is None and inner_data.get('v', 0) == 0:
                    return ''
                return self.parse_collaboration_content(raw_content)
            except Exception as e:
                logger.error(f"协作笔记获取失败，回退: {e}")
                return detail.get('html', '')

        return detail.get('html', '')

    # ================================================================
    # 分类/文件夹管理
    # ================================================================

    def list_categories(self):
        """获取所有分类（文件夹）列表"""
        return self._get(f'/ks/category/all/{self.kb_guid}')

    def create_category(self, parent, name):
        """
        创建分类
        :param parent: 父分类路径，如 "/My Notes/"
        :param name: 新分类名
        :return: 创建结果
        """
        category = parent.rstrip('/') + '/' + name + '/'
        return self._post(
            f'/ks/category/create/{self.kb_guid}',
            data={'category': category}
        )

    def delete_category(self, category):
        """
        删除分类（文件夹）
        :param category: 分类路径
        """
        return self._delete(
            f'/ks/category/delete/{self.kb_guid}',
            params={'category': category}
        )

    def rename_category(self, old_category, new_name):
        """
        重命名分类
        :param old_category: 原分类路径
        :param new_name: 新名称
        """
        # 解析父路径
        parts = old_category.rstrip('/').rsplit('/', 1)
        parent = parts[0] + '/' if len(parts) > 1 else '/'
        new_category = parent + new_name + '/'
        return self._post(
            f'/ks/category/rename/{self.kb_guid}',
            data={'oldCategory': old_category, 'newCategory': new_category}
        )

    # ================================================================
    # 标签管理
    # ================================================================

    def list_tags(self):
        """获取所有标签"""
        return self._get(f'/ks/tag/all/{self.kb_guid}')

    def add_tags(self, doc_guid, tags):
        """
        为笔记添加标签
        :param doc_guid: 笔记GUID
        :param tags: 标签列表或逗号分隔字符串
        """
        if isinstance(tags, str):
            tags = [t.strip() for t in tags.split(',') if t.strip()]
        return self._post(
            f'/ks/tag/add/{self.kb_guid}/{doc_guid}',
            data={'tags': tags}
        )

    def remove_tags(self, doc_guid, tags):
        """移除笔记标签"""
        if isinstance(tags, str):
            tags = [t.strip() for t in tags.split(',') if t.strip()]
        return self._post(
            f'/ks/tag/remove/{self.kb_guid}/{doc_guid}',
            data={'tags': tags}
        )

    def rename_tag(self, old_name, new_name):
        """重命名标签（全局）"""
        return self._post(
            f'/ks/tag/rename/{self.kb_guid}',
            data={'oldTag': old_name, 'newTag': new_name}
        )

    # ================================================================
    # 评论
    # ================================================================

    def get_comments(self, doc_guid):
        """获取笔记评论列表"""
        return self._get(
            f'/ks/comment/list/{self.kb_guid}/{doc_guid}',
            params={'clientType': 'web', 'clientVersion': '4.0'}
        )

    def add_comment(self, doc_guid, text):
        """
        添加评论
        :param doc_guid: 笔记GUID
        :param text: 评论内容
        """
        return self._post(
            f'/ks/comment/create/{self.kb_guid}/{doc_guid}',
            data={
                'text': text,
                'docGuid': doc_guid,
                'kbGuid': self.kb_guid,
            },
            params={'clientType': 'web', 'clientVersion': '4.0'}
        )

    def delete_comment(self, doc_guid, comment_guid):
        """删除评论"""
        return self._delete(
            f'/ks/comment/delete/{self.kb_guid}/{doc_guid}/{comment_guid}'
        )

    # ================================================================
    # 历史版本
    # ================================================================

    def get_note_history(self, doc_guid):
        """获取笔记历史版本列表"""
        return self._get(
            f'/ks/note/history/{self.kb_guid}/{doc_guid}',
            params={'clientType': 'web', 'clientVersion': '4.0'}
        )

    def get_note_version(self, doc_guid, version_id):
        """
        获取指定历史版本内容
        :param doc_guid: 笔记GUID
        :param version_id: 版本ID
        """
        return self._get(
            f'/ks/note/version/{self.kb_guid}/{doc_guid}/{version_id}',
            params={'clientType': 'web', 'clientVersion': '4.0'}
        )

    # ================================================================
    # 分享
    # ================================================================

    def share_note(self, doc_guid, access='read', expire_days=30):
        """
        分享笔记，返回分享链接
        :param doc_guid: 笔记GUID
        :param access: 'read' 或 'edit'
        :param expire_days: 过期天数，0 表示永久
        :return: 分享信息（含URL）
        """
        return self._post(
            f'/ks/share/create/{self.kb_guid}/{doc_guid}',
            data={
                'docGuid': doc_guid,
                'kbGuid': self.kb_guid,
                'access': access,
                'expire': expire_days * 86400 if expire_days > 0 else 0,
            }
        )

    def list_shares(self):
        """列出所有分享"""
        return self._get(f'/ks/share/list/{self.kb_guid}')

    def cancel_share(self, share_id):
        """取消分享"""
        return self._delete(f'/ks/share/delete/{self.kb_guid}/{share_id}')

    # ================================================================
    # 附件操作
    # ================================================================

    def get_note_attachments(self, doc_guid):
        return self._get(
            f'/ks/note/attachments/{self.kb_guid}/{doc_guid}',
            params={'extra': '1', 'clientType': 'web', 'clientVersion': '4.0'}
        )

    def download_attachment(self, doc_guid, att_guid):
        url = f'{self.kb_server}/ks/attachment/download/{self.kb_guid}/{doc_guid}/{att_guid}'
        timeout = (self.ATTACHMENT_CONNECT_TIMEOUT_S, self.ATTACHMENT_READ_TIMEOUT_S)
        resp = requests.get(url, params={'clientType': 'web', 'clientVersion': '4.0'},
                            headers={'X-Wiz-Token': self.token}, stream=True, timeout=timeout)
        if resp.status_code != 200:
            raise Exception(f'下载附件失败: HTTP {resp.status_code}')
        return resp.content

    def upload_attachment(self, doc_guid, file_path, name=None):
        """
        上传附件到笔记
        :param doc_guid: 笔记GUID
        :param file_path: 本地文件路径
        :param name: 附件名称（默认取文件名）
        :return: 附件信息
        """
        import os
        name = name or os.path.basename(file_path)
        url = f'{self.kb_server}/ks/attachment/upload/{self.kb_guid}/{doc_guid}'
        with open(file_path, 'rb') as f:
            resp = requests.post(
                url,
                files={'file': (name, f)},
                headers={'X-Wiz-Token': self.token},
                params={'clientType': 'web', 'clientVersion': '4.0'}
            )
        data = resp.json()
        if data.get('returnCode') != 200:
            raise Exception(f'上传附件失败: {data}')
        return data.get('result', data)

    def download_collaboration_resource(self, editor_token, doc_guid, src):
        url = f'{self.kb_server}/editor/{self.kb_guid}/{doc_guid}/resources/{src}'
        timeout = (self.ATTACHMENT_CONNECT_TIMEOUT_S, self.ATTACHMENT_READ_TIMEOUT_S)
        headers = {
            'cookie': f'x-live-editor-[REDACTED_SECRET]',
            'user-agent': 'Mozilla/5.0'
        }
        resp = requests.get(url, headers=headers, stream=True, timeout=timeout)
        if resp.status_code != 200:
            raise Exception(f'下载协作资源失败: HTTP {resp.status_code}')
        return resp.content

    def get_collaboration_image(self, doc_guid, image_name):
        token_info = self.get_collaboration_token(doc_guid)
        [REDACTED_SECRET]'editorToken'] if isinstance(token_info, dict) else token_info
        return self.download_collaboration_resource(token, doc_guid, image_name)

    # ================================================================
    # 传统 HTML 笔记（内部使用）
    # ================================================================

    def _create_html_note(self, title, html, category="/My Notes/", tags=""):
        """创建传统 HTML 笔记（内部方法）"""
        data = {
            'kbGuid': self.kb_guid, 'html': html,
            'category': category, 'owner': self.config.user_id,
            'tags': tags, 'title': title,
        }
        return self._post(
            f'/ks/note/create/{self.kb_guid}',
            data=data,
            params={'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'}
        )

    def save_note(self, doc_guid, title, html, category="/My Notes/"):
        """保存/更新传统 HTML 笔记"""
        data = {
            'category': category, 'docGuid': doc_guid,
            'kbGuid': self.kb_guid, 'title': title,
            'html': html, 'resources': []
        }
        return self._put(
            f'/ks/note/save/{self.kb_guid}/{doc_guid}',
            data=data,
            params={'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'}
        )

    # ================================================================
    # 协作笔记 WebSocket 协议
    # ================================================================

    def get_collaboration_token(self, doc_guid):
        """获取协作笔记 editorToken"""
        return self._post(f'/ks/note/{self.kb_guid}/{doc_guid}/tokens')

    def get_collaboration_content(self, editor_token, doc_guid):
        """通过 WebSocket 获取协作笔记内容"""
        scheme = 'wss' if self.config.base_url.startswith('https') else 'ws'
        ws_url = f"{scheme}://{self.domain}/editor/{self.kb_guid}/{doc_guid}"
        sslopt = None
        if scheme == 'wss':
            import certifi
            sslopt = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': certifi.where()}

        hs = {
            "a": "hs", "id": None,
            "auth": {
                "appId": self.kb_guid, "docId": doc_guid,
                "userId": self.user_guid, "permission": "w", "token": editor_token
            }
        }

        ws = create_connection(ws_url, sslopt=sslopt, timeout=10)
        ws.send(json.dumps(hs))
        init_resp = ws.recv()  # server returns {"a":"init",...}

        ws.send(json.dumps({"a": "f", "c": self.kb_guid, "d": doc_guid, "v": None}))
        ws.recv()  # ack
        content = ws.recv()  # actual content

        ws.send(json.dumps({"a": "s", "c": self.kb_guid, "d": doc_guid, "v": None}))
        ws.recv()
        ws.close()
        return content

    def _ws_write_collaboration(self, doc_guid, editor_token, version, blocks,
                                delete_first=False):
        """通过 WebSocket 写入协作笔记"""
        scheme = 'wss' if self.config.base_url.startswith('https') else 'ws'
        ws_url = f"{scheme}://{self.domain}/editor/{self.kb_guid}/{doc_guid}"
        sslopt = None
        if scheme == 'wss':
            import certifi
            sslopt = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': certifi.where()}

        hs = {
            'a': 'hs', 'id': None,
            'auth': {
                'appId': self.kb_guid, 'docId': doc_guid,
                'userId': self.user_guid, 'permission': 'w', 'token': editor_token
            }
        }

        ws = create_connection(ws_url, sslopt=sslopt, timeout=10)
        ws.send(json.dumps(hs))
        init_resp = ws.recv()  # server returns {"a":"init",...}

        # 同步
        ws.send(json.dumps({'a': 'f', 'c': self.kb_guid, 'd': doc_guid, 'v': None}))
        ws.recv()
        sync_content = ws.recv()
        try:
            server_v = json.loads(sync_content).get('data', {}).get('v', 0)
            if server_v > version:
                version = server_v
        except Exception:
            pass

        src = str(uuid.uuid4())[:20]
        seq = 1
        v = version

        if delete_first:
            ws.send(json.dumps({
                'a': 'op', 'c': self.kb_guid, 'd': doc_guid,
                'v': v, 'src': src, 'seq': seq, 'del': True,
            }))
            ws.recv()
            seq += 1
            v += 1

        # 将带 __id 的子单元格从 blocks 移到 doc_data 顶层
        clean_blocks = []
        extra_data_map = {}
        for block in blocks:
            if '__id' in block:
                cell_id = block['__id']
                # __val 是数组格式（表格子数据），直接放入顶层
                if '__val' in block:
                    extra_data_map[cell_id] = block['__val']
                else:
                    cell_data = {k: v for k, v in block.items() if not k.startswith('__')}
                    extra_data_map[cell_id] = cell_data
            else:
                clean_blocks.append(block)

        doc_data = {
            'blocks': clean_blocks,
            'comments': [], 'meta': {},
            'authors': [], 'commentators': [],
        }
        # 将子单元格数据合并到 doc_data 顶层
        doc_data.update(extra_data_map)

        ws.send(json.dumps({
            'a': 'op', 'c': self.kb_guid, 'd': doc_guid,
            'v': v, 'src': src, 'seq': seq,
            'create': {
                'type': 'http://sharejs.org/types/JSONv1',
                'data': doc_data,
            }
        }))
        ws.recv()

        ws.send(json.dumps({'a': 's', 'c': self.kb_guid, 'd': doc_guid, 'v': None}))
        try:
            ws.recv()
        except Exception:
            pass
        ws.close()

    # ================================================================
    # Markdown → Blocks 转换器
    # ================================================================

    def markdown_to_blocks(self, markdown_text):
        """
        将 Markdown 文本转换为协作笔记 blocks 列表
        支持：标题(#/##/###)、段落、粗体、斜体、行内代码、链接、
              有序/无序列表、复选框、引用、代码块、表格、分隔线、图片
        """
        if not markdown_text or not markdown_text.strip():
            return []

        blocks = []
        lines = markdown_text.split('\n')
        i = 0

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

            # 空行跳过
            if not line.strip():
                i += 1
                continue

            # 代码块
            if line.strip().startswith('```'):
                lang = line.strip()[3:].strip()
                code_lines = []
                i += 1
                while i < len(lines) and not lines[i].strip().startswith('```'):
                    code_lines.append(lines[i])
                    i += 1
                i += 1  # 跳过结束的 ```
                code_id = str(uuid.uuid4())[:8]
                child_id = f'_code_{code_id}_0'
                blocks.append({
                    'id': code_id,
                    'type': 'code',
                    'language': lang,
                    'children': [child_id],
                })
                blocks.append({
                    '__id': child_id,
                    '__type': 'code_cell',
                    'text': [{'insert': '\n'.join(code_lines)}],
                })
                continue

            # 标题
            heading_match = re.match(r'^(#{1,6})\s+(.+)$', line)
            if heading_match:
                level = len(heading_match.group(1))
                text = self._parse_inline_markdown(heading_match.group(2))
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'text',
                    'text': text,
                    'heading': min(level, 6),
                })
                i += 1
                continue

            # 分隔线
            if re.match(r'^(-{3,}|\*{3,}|_{3,})$', line.strip()):
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'embed',
                    'embedType': 'hr',
                    'embedData': {},
                })
                i += 1
                continue

            # 引用
            if line.strip().startswith('>'):
                text = self._parse_inline_markdown(line.strip().lstrip('> ').lstrip())
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'text',
                    'text': text,
                    'quoted': True,
                })
                i += 1
                continue

            # 复选框
            check_match = re.match(r'^(\s*)- \[([ xX])\] (.+)$', line)
            if check_match:
                text = self._parse_inline_markdown(check_match.group(3))
                level = len(check_match.group(1)) // 2 + 1
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'list',
                    'text': text,
                    'level': level,
                    'checkbox': 'checked' if check_match.group(2) != ' ' else 'unchecked',
                })
                i += 1
                continue

            # 无序列表
            ul_match = re.match(r'^(\s*)[-*+]\s+(.+)$', line)
            if ul_match:
                text = self._parse_inline_markdown(ul_match.group(2))
                level = len(ul_match.group(1)) // 2 + 1
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'list',
                    'text': text,
                    'level': level,
                })
                i += 1
                continue

            # 有序列表
            ol_match = re.match(r'^(\s*)(\d+)\.\s+(.+)$', line)
            if ol_match:
                text = self._parse_inline_markdown(ol_match.group(3))
                level = len(ol_match.group(1)) // 2 + 1
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'list',
                    'text': text,
                    'level': level,
                    'ordered': True,
                    'start': int(ol_match.group(2)),
                })
                i += 1
                continue

            # 表格
            if '|' in line and i + 1 < len(lines) and re.match(r'^\s*\|[\s\-:|]+\|\s*$', lines[i + 1]):
                headers = [c.strip() for c in line.strip('|').split('|')]
                i += 2  # 跳过分隔行
                rows = []
                while i < len(lines) and '|' in lines[i] and lines[i].strip().startswith('|'):
                    row = [c.strip() for c in lines[i].strip('|').split('|')]
                    rows.append(row)
                    i += 1
                block, extra = self.table_block(headers, rows)
                blocks.append(block)
                # 添加子单元格数据（数组格式直接标记）
                for key, val in extra.items():
                    blocks.append({'__id': key, '__val': val})
                continue

            # 图片
            img_match = re.match(r'^!\[([^\]]*)\]\(([^)]+)\)$', line.strip())
            if img_match:
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'embed',
                    'embedType': 'image',
                    'embedData': {'src': img_match.group(2), 'alt': img_match.group(1)},
                })
                i += 1
                continue

            # 普通段落
            text = self._parse_inline_markdown(line)
            blocks.append({
                'id': str(uuid.uuid4())[:8],
                'type': 'text',
                'text': text,
            })
            i += 1

        return blocks

    def _parse_inline_markdown(self, text):
        """解析行内 Markdown 为 text 数组（insert + attributes）"""
        if not text.strip():
            return [{'insert': text}]

        result = []
        # 简化处理：按优先级匹配粗体、斜体、代码、链接
        pattern = re.compile(
            r'(\*\*\*(.+?)\*\*\*)'   # 粗斜体
            r'|(\*\*(.+?)\*\*)'       # 粗体
            r'|(\*(.+?)\*)'           # 斜体
            r'|(`([^`]+)`)'           # 行内代码
            r'|(\[([^\]]+)\]\(([^)]+)\))'  # 链接
            r'|([^*\[`]+)'           # 普通文本
        )

        pos = 0
        while pos < len(text):
            match = pattern.match(text, pos)
            if not match:
                result.append({'insert': text[pos]})
                pos += 1
                continue

            if match.group(2):  # 粗斜体
                result.append({'insert': match.group(2), 'attributes': {'style-bold': True, 'style-italic': True}})
            elif match.group(4):  # 粗体
                result.append({'insert': match.group(4), 'attributes': {'style-bold': True}})
            elif match.group(6):  # 斜体
                result.append({'insert': match.group(6), 'attributes': {'style-italic': True}})
            elif match.group(8):  # 代码
                result.append({'insert': match.group(8), 'attributes': {'style-code': True}})
            elif match.group(10):  # 链接
                result.append({'insert': match.group(10), 'attributes': {'link': match.group(11)}})
            elif match.group(12):  # 普通文本
                result.append({'insert': match.group(12)})

            pos = match.end()

        return result if result else [{'insert': text}]

    # ================================================================
    # 协作笔记内容解析
    # ================================================================

    def parse_collaboration_content(self, raw_content):
        """解析协作笔记 JSON 为可读 Markdown 文本"""
        try:
            content_data = json.loads(raw_content) if isinstance(raw_content, str) else raw_content
        except json.JSONDecodeError:
            return str(raw_content)

        if not isinstance(content_data, dict) or 'data' not in content_data:
            return str(content_data)

        data_section = content_data['data']
        if not isinstance(data_section, dict) or 'data' not in data_section:
            return json.dumps(content_data, ensure_ascii=False, indent=2)

        inner_data = data_section['data']
        if 'blocks' not in inner_data:
            return json.dumps(content_data, ensure_ascii=False, indent=2)

        blocks = inner_data['blocks']
        lines = []
        for block in blocks:
            text = self._parse_block(inner_data, block)
            if text:
                lines.append(text)

        return '\n'.join(lines)

    def _parse_block(self, full_data, block):
        block_type = block.get('type', '')

        if block_type == 'text':
            text = self._parse_text_array(block.get('text', []))
            heading = block.get('heading')
            if heading:
                return f"{'#' * heading} {text}"
            elif block.get('quoted'):
                return f"> {text}"
            return text

        elif block_type == 'list':
            text = self._parse_text_array(block.get('text', []))
            level = block.get('level', 1)
            indent = '  ' * (level - 1)
            checkbox = block.get('checkbox')
            prefix = ''
            if checkbox == 'checked':
                prefix = '[x] '
            elif checkbox == 'unchecked':
                prefix = '[ ] '
            if block.get('ordered'):
                return f"{indent}{block.get('start', 1)}. {prefix}{text}"
            return f"{indent}- {prefix}{text}"

        elif block_type == 'code':
            language = block.get('language', '')
            children = block.get('children', [])
            code_lines = []
            for child_id in children:
                child_data = full_data.get(child_id)
                if child_data and isinstance(child_data, dict) and 'text' in child_data:
                    if child_data['text']:
                        code_lines.append(child_data['text'][0].get('insert', ''))
                    else:
                        code_lines.append('')
            return f"```{language}\n{'\n'.join(code_lines)}\n```"

        elif block_type == 'table':
            cols = block.get('cols', 0)
            children = block.get('children', [])
            cells = []
            for child_id in children:
                child_data = full_data.get(child_id)
                if child_data and isinstance(child_data, dict) and 'text' in child_data:
                    if child_data['text']:
                        cells.append(child_data['text'][0].get('insert', ''))
                    else:
                        cells.append('')
                else:
                    cells.append('')
            if not cells or cols == 0:
                return ''
            header = '| ' + ' | '.join(cells[:cols]) + ' |'
            sep = '| ' + ' | '.join(['---'] * cols) + ' |'
            rows = []
            body = cells[cols:]
            for i in range(0, len(body), cols):
                row = body[i:i + cols]
                while len(row) < cols:
                    row.append('')
                rows.append('| ' + ' | '.join(row) + ' |')
            return header + '\n' + sep + '\n' + '\n'.join(rows)

        elif block_type == 'embed':
            embed_type = block.get('embedType', '')
            embed_data = block.get('embedData', {})
            if embed_type == 'image':
                return f"![图片]({embed_data.get('src', '')})"
            elif embed_type == 'hr':
                return '---'
            return f"<!-- embed: {embed_type} -->"

        return ''

    def _parse_text_array(self, text_array):
        if not text_array:
            return ''
        parts = []
        for text_obj in text_array:
            insert = text_obj.get('insert', '')
            attrs = text_obj.get('attributes', {})
            if not attrs:
                parts.append(insert)
            elif attrs.get('style-bold'):
                parts.append(f"**{insert}**")
            elif attrs.get('style-italic'):
                parts.append(f"*{insert}*")
            elif attrs.get('style-code'):
                parts.append(f"`{insert}`")
            elif attrs.get('link'):
                parts.append(f"[{insert}]({attrs.get('link')})")
            else:
                parts.append(insert)
        return ''.join(parts)

    # ================================================================
    # 笔记模板
    # ================================================================

    @staticmethod
    def get_template(template_name, **kwargs):
        """
        获取预定义模板的 Markdown 内容
        :param template_name: 模板名称
            - weekly_report: 周报
            - meeting_minutes: 会议纪要
            - todo: 待办清单
            - reading_notes: 读书笔记
            - project_plan: 项目计划
            - blank: 空白
        :param kwargs: 模板变量
        :return: Markdown 文本
        """
        templates = {
            'weekly_report': """# {title}

## 本周完成

- 
- 
- 

## 进行中

- 
- 

## 下周计划

- 
- 
- 

## 风险与问题

> 

## 备注

""",
            'meeting_minutes': """# {title}

**日期**: {date}
**参会人**: {attendees}
**记录人**: {recorder}

## 会议议题

1. 

## 讨论内容

### 议题一


### 议题二


## 决议

- [ ] 
- [ ] 

## 后续行动

| 任务 | 负责人 | 截止日期 | 状态 |
|------|--------|----------|------|
|  |  |  | 待开始 |

""",
            'todo': """# {title}

## 🔴 紧急重要

- [ ] 

## 🟡 重要不紧急

- [ ] 
- [ ] 

## 🔵 紧急不重要

- [ ] 

## ⚪ 其他

- [ ] 

""",
            'reading_notes': """# {title}

**书名**: {book_name}
**作者**: {author}
**阅读日期**: {date}

## 核心观点

> 

## 章节摘要

### 第一章


### 第二章


## 精彩摘录

- 
- 
- 

## 个人思考


## 行动清单

- [ ] 
- [ ] 

""",
            'project_plan': """# {title}

## 项目背景


## 目标

1. 
2. 
3. 

## 时间线

| 阶段 | 时间 | 交付物 | 负责人 |
|------|------|--------|--------|
| 需求分析 |  |  |  |
| 设计 |  |  |  |
| 开发 |  |  |  |
| 测试 |  |  |  |
| 上线 |  |  |  |

## 资源需求

- 
- 

## 风险评估

| 风险 | 概率 | 影响 | 应对策略 |
|------|------|------|----------|
|  |  |  |  |

""",
            'blank': """# {title}

""",
        }

        template = templates.get(template_name, templates['blank'])
        return template.format(
            title=kwargs.get('title', '未命名'),
            date=kwargs.get('date', time.strftime('%Y-%m-%d')),
            attendees=kwargs.get('attendees', ''),
            recorder=kwargs.get('recorder', ''),
            book_name=kwargs.get('book_name', ''),
            author=kwargs.get('author', ''),
            **{k: v for k, v in kwargs.items()
               if k not in ('title', 'date', 'attendees', 'recorder', 'book_name', 'author')}
        )

    def create_from_template(self, template_name, title, category="/My Notes/", tags="",
                             **kwargs):
        """
        从模板创建协作笔记
        :param template_name: 模板名称
        :param title: 笔记标题
        :param category: 分类
        :param tags: 标签
        :param kwargs: 模板变量
        """
        kwargs['title'] = title
        md_content = self.get_template(template_name, **kwargs)
        blocks = self.markdown_to_blocks(md_content)
        return self.create_collaboration_note(title, blocks, category, tags)

    # ================================================================
    # Block 快捷构造方法
    # ================================================================

    @staticmethod
    def text_block(text, heading=None, quoted=False, block_id=None):
        block = {
            'id': block_id or str(uuid.uuid4())[:8],
            'type': 'text',
            'text': [{'insert': text}],
        }
        if heading:
            block['heading'] = heading
        if quoted:
            block['quoted'] = True
        return block

    @staticmethod
    def list_block(text, ordered=False, level=1, checkbox=None, block_id=None):
        block = {
            'id': block_id or str(uuid.uuid4())[:8],
            'type': 'list',
            'text': [{'insert': text}],
            'level': level,
        }
        if ordered:
            block['ordered'] = True
            block['start'] = 1
        if checkbox:
            block['checkbox'] = checkbox
        return block

    @staticmethod
    def table_block(headers, rows, block_id=None):
        if block_id is None:
            block_id = str(uuid.uuid4())[:8]
        cols = len(headers)
        num_rows = 1 + len(rows)  # header + data rows
        children = []
        extra_data = {}
        cell_idx = 0

        # 计算每列的最大内容宽度（中文字符算2倍宽），用于动态分配列宽
        def display_width(s):
            """计算字符串显示宽度：中文字符算2，英文/数字算1"""
            w = 0
            for ch in str(s):
                if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or '\uff00' <= ch <= '\uffef':
                    w += 2
                else:
                    w += 1
            return w

        col_max_len = [0] * cols
        for ci, h in enumerate(headers):
            col_max_len[ci] = max(col_max_len[ci], display_width(h))
        for row in rows:
            for ci, cell in enumerate(row):
                col_max_len[ci] = max(col_max_len[ci], display_width(cell))

        # 基于内容宽度按比例分配列宽，总宽度=600px
        total_len = sum(col_max_len) or 1
        total_width = 600
        min_width = 80
        cols_width = [max(min_width, int(total_width * (l / total_len))) for l in col_max_len]
        # 修正：确保总宽度不超过total_width
        while sum(cols_width) > total_width:
            widest = cols_width.index(max(cols_width))
            cols_width[widest] -= 10

        def make_cell(text):
            nonlocal cell_idx
            cell_id = f'_t_{block_id}_{cell_idx}'
            inner_id = f'_tc_{block_id}_{cell_idx}'
            children.append(cell_id)
            extra_data[cell_id] = [{'text': [{'insert': str(text)}], 'id': inner_id, 'type': 'text'}]
            cell_idx += 1

        for h in headers:
            make_cell(h)

        for row in rows:
            for cell in row:
                make_cell(cell)

        block = {
            'id': block_id,
            'type': 'table',
            'text': [],
            'cols': cols,
            'rows': num_rows,
            'children': children,
            'colsWidth': cols_width,
        }
        return block, extra_data

    @staticmethod
    def code_block(code, language='', block_id=None):
        """快捷方法：创建代码块"""
        code_id = block_id or str(uuid.uuid4())[:8]
        child_id = f'_code_{code_id}_0'
        block = {
            'id': code_id,
            'type': 'code',
            'language': language,
            'children': [child_id],
        }
        extra = {
            child_id: {'__id': child_id, '__type': 'code_cell', 'text': [{'insert': code}]}
        }
        return block, extra

    @staticmethod
    def divider_block():
        """快捷方法：创建分隔线"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'embed',
            'embedType': 'hr',
            'embedData': {},
        }

    @staticmethod
    def image_block(src, alt='图片'):
        """快捷方法：创建图片块"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'embed',
            'embedType': 'image',
            'align': 'center',
            'embedData': {'src': src, 'alt': alt},
        }

    @staticmethod
    def link_block(text, url):
        """快捷方法：创建带链接的文本块"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'text',
            'text': [{'insert': text, 'attributes': {'link': url}}],
        }

    @staticmethod
    def webpage_block(url):
        """快捷方法：创建网页嵌入块"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'embed',
            'embedType': 'webpage',
            'embedData': {'src': url},
        }

    @staticmethod
    def audio_block(src, file_name='', file_size=0, file_type='audio/mpeg'):
        """快捷方法：创建音频块（需先上传附件获取src）"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'embed',
            'embedType': 'audio',
            'align': 'center',
            'embedData': {
                'src': src,
                'fileName': file_name,
                'fileSize': file_size,
                'fileType': file_type,
                'previewType': 'card',
            },
        }

    @staticmethod
    def file_block(src, file_name='', file_size=0, file_type=''):
        """快捷方法：创建文件嵌入块（需先上传附件获取src）"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'embed',
            'embedType': 'office',
            'align': 'center',
            'embedData': {
                'src': src,
                'fileName': file_name,
                'fileSize': file_size,
                'fileType': file_type,
                'previewType': 'card',
            },
        }

    @staticmethod
    def drawio_block(src):
        """快捷方法：创建流程图/UML块（需先上传SVG获取src）"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'embed',
            'embedType': 'drawio',
            'align': 'center',
            'embedData': {'src': src},
        }

    @staticmethod
    def encrypt_text_block(password, encrypted_text, prompt=''):
        """快捷方法：创建加密文本块"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'embed',
            'embedType': 'encrypt-text',
            'align': 'center',
            'embedData': {
                'realText': encrypted_text,
                'hasPassword': True,
                'prompt': prompt or password,
                'showPromptForAll': True,
                'height': 160,
                'mobileHeight': 80,
            },
        }

    @staticmethod
    def formula_block(tex):
        """快捷方法：创建数学公式块（LaTeX语法）"""
        block_id = str(uuid.uuid4())[:8]
        return {
            'id': block_id,
            'type': 'text',
            'text': [{
                'insert': ' ',
                'attributes': {
                    'type': 'blockKatex',
                    'tex': tex,
                    'id': str(uuid.uuid4())[:20],
                    'src': '',
                    'box': True,
                    'created': True,
                },
            }],
        }

    @staticmethod
    def make_inline_block(parts):
        """快捷方法：构造带行内样式的文本块
        parts: list of (text, is_bold) tuples
        """
        text_list = []
        for text, is_bold in parts:
            item = {'insert': text}
            if is_bold:
                item['attributes'] = {'style-bold': True}
            text_list.append(item)
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'text',
            'text': text_list,
        }


path: wiz_mcp_server.py
content: """wiz-mcp 服务入口"""
from scripts.server import main

if __name__ == "__main__":
    main()


path: wiz_open_api.py
content: """为知笔记 Open API 封装 - HTTP/WS 调用（v2 全协作笔记模式）"""
import json
import re
import time
import ssl
import uuid
from urllib.parse import urlparse, quote
import requests
from websocket import create_connection

from config import Config
from wiz_logging import get_logger

logger = get_logger()


class WizOpenApi:
    """为知笔记 Open API 封装类 - 默认协作笔记模式"""

    ATTACHMENT_CONNECT_TIMEOUT_S = 10
    ATTACHMENT_READ_TIMEOUT_S = 60
    ATTACHMENT_CHUNK_SIZE = 64 * 1024

    def __init__(self, config: Config):
        self.config = config
        self.token = ''
        self.kb_server = ''
        self.kb_guid = ''
        self.user_guid = ''
        self.domain = ''

        self._auth()

    def _headers(self):
        """通用请求头"""
        return {'X-Wiz-Token': self.token, 'Content-Type': 'application/json'}

    def _get(self, path, params=None):
        """GET 请求封装"""
        url = f'{self.kb_server}{path}'
        resp = requests.get(url, params=params or {}, headers=self._headers())
        return self._parse_response(resp)

    def _post(self, path, data=None, params=None):
        """POST 请求封装"""
        url = f'{self.kb_server}{path}'
        resp = requests.post(url, params=params or {}, json=data or {}, headers=self._headers())
        return self._parse_response(resp)

    def _put(self, path, data=None, params=None):
        """PUT 请求封装"""
        url = f'{self.kb_server}{path}'
        resp = requests.put(url, params=params or {}, json=data or {}, headers=self._headers())
        return self._parse_response(resp)

    def _delete(self, path, params=None):
        """DELETE 请求封装"""
        url = f'{self.kb_server}{path}'
        resp = requests.delete(url, params=params or {}, headers=self._headers())
        return self._parse_response(resp)

    @staticmethod
    def _parse_response(resp):
        if resp.status_code != 200:
            raise Exception(f'HTTP错误 {resp.status_code}: {resp.text[:200]}')
        data = resp.json()
        if data.get('returnCode') != 200:
            raise Exception(f'API错误: {data}')
        return data.get('result', data)

    # ================================================================
    # 认证
    # ================================================================

    def _auth(self):
        """认证登录"""
        data = self._login()
        self.token = data['result']['token']
        self.kb_server = data['result']['kbServer']
        self.kb_guid = data['result']['kbGuid']
        self.user_guid = data['result']['userGuid']
        parsed = urlparse(self.kb_server)
        self.domain = parsed.netloc
        logger.info(f"登录成功: kb_guid={self.kb_guid}, user_guid={self.user_guid}")
        if self.config.group_name:
            self._switch_group()

    def _login(self):
        login_url = f'{self.config.as_url}/as/user/login'
        response = requests.post(
            login_url,
            data={'userId': self.config.user_id, 'password': self.config.password}
        )
        if response.status_code != 200:
            raise Exception(f'登录失败: HTTP {response.status_code}')
        data = response.json()
        if data.get('returnCode') != 200:
            raise Exception(f'登录失败: {data}')
        return data

    def _switch_group(self):
        if not self.config.group_name:
            return
        data = self._get('/as/user/groups', headers={'X-Wiz-Token': self.token})
        # 上面用的是 self._get 但 as_url 可能不同，走原始逻辑
        group_list_url = f'{self.config.as_url}/as/user/groups'
        resp = requests.get(group_list_url, headers={'X-Wiz-Token': self.token})
        data = resp.json()
        if data.get('returnCode') != 200:
            raise Exception(f'获取群组列表失败: {data}')
        for group in data.get('result', []):
            if group.get('name') == self.config.group_name:
                self.kb_guid = group['kbGuid']
                self.domain = urlparse(group.get('kbServer', self.kb_server)).netloc
                logger.info(f"已切换到群组: {self.config.group_name}, kb_guid={self.kb_guid}")
                return
        raise Exception(f'群组不存在: {self.config.group_name}')

    # ================================================================
    # 笔记 CRUD（默认协作笔记）
    # ================================================================

    def create_note(self, title, content='', category="/My Notes/", tags="",
                    content_type='markdown'):
        """
        创建笔记（默认协作笔记）
        :param title: 标题
        :param content: 内容（Markdown或纯文本）
        :param category: 分类路径
        :param tags: 标签（逗号分隔）
        :param content_type: 'markdown' | 'blocks' | 'html'
            - markdown: 自动转换为 blocks
            - blocks: content 已是 blocks 列表
            - html: 创建传统 HTML 笔记
        :return: {docGuid, editorToken, title, category}
        """
        if content_type == 'html':
            return self._create_html_note(title, content, category, tags)

        if content_type == 'blocks':
            blocks = content
        else:
            blocks = self.markdown_to_blocks(content)

        return self.create_collaboration_note(title, blocks, category, tags)

    def update_note(self, doc_guid, content='', title=None, content_type='markdown'):
        """
        更新笔记（默认协作笔记）
        :param doc_guid: 笔记GUID
        :param content: 新内容
        :param title: 可选新标题
        :param content_type: 同 create_note
        """
        if content_type == 'blocks':
            blocks = content
        elif content_type == 'html':
            return self.save_note(doc_guid, title or '', content)
        else:
            blocks = self.markdown_to_blocks(content)

        return self.update_collaboration_note(doc_guid, blocks, title)

    def delete_note(self, doc_guid):
        """
        删除笔记（移到回收站）
        :param doc_guid: 笔记GUID
        """
        result = self._delete(f'/ks/note/delete/{self.kb_guid}/{doc_guid}')
        logger.info(f"笔记已删除: {doc_guid}")
        return result

    def move_note(self, doc_guid, category):
        """
        移动笔记到指定分类
        :param doc_guid: 笔记GUID
        :param category: 目标分类路径，如 "/My Notes/项目/"
        """
        data = {
            'docGuid': doc_guid,
            'kbGuid': self.kb_guid,
            'category': category,
        }
        result = self._put(
            f'/ks/note/save/{self.kb_guid}/{doc_guid}',
            data=data,
            params={'infoOnly': '1'}
        )
        logger.info(f"笔记已移动: {doc_guid} -> {category}")
        return result

    def copy_note(self, doc_guid, title=None, category=None):
        """
        复制笔记
        :param doc_guid: 源笔记GUID
        :param title: 新标题（默认 "标题 - 副本"）
        :param category: 目标分类（默认同源）
        :return: 新笔记信息
        """
        # 获取源笔记详情
        detail = self.get_note_detail(doc_guid)
        info = detail.get('info', {})
        src_title = title or (info.get('title', '') + ' - 副本')
        src_category = category or info.get('category', '/My Notes/')
        src_tags = info.get('tags', '')

        note_type = info.get('type', 'document')
        if note_type == 'collaboration':
            # 读取协作笔记内容
            content = self.get_note_content(doc_guid)
            blocks = self.markdown_to_blocks(content)
            return self.create_collaboration_note(src_title, blocks, src_category, src_tags)
        else:
            html = detail.get('html', '')
            return self._create_html_note(src_title, html, src_category, src_tags)

    # ================================================================
    # 协作笔记操作
    # ================================================================

    def create_collaboration_note(self, title, blocks, category="/My Notes/", tags=""):
        """创建协作笔记并写入初始内容"""
        url = f'{self.kb_server}/ks/note/create/{self.kb_guid}'
        params = {'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'}
        data = {
            'kbGuid': self.kb_guid,
            'html': '',
            'category': category,
            'owner': self.config.user_id,
            'tags': tags,
            'title': title,
            'type': 'collaboration',
        }
        resp = requests.post(url, params=params, json=data, headers={
            'X-Wiz-Token': self.token, 'content-type': 'application/json'
        })
        result = resp.json()
        if result.get('returnCode') != 200:
            raise Exception(f'创建协作笔记失败: {result}')

        doc_guid = result['result']['docGuid']
        editor_token = result['editor']['editorToken']
        logger.info(f"协作笔记已创建: doc_guid={doc_guid}, title={title}")

        if blocks:
            self._ws_write_collaboration(doc_guid, editor_token, 0, blocks)

        return {
            'docGuid': doc_guid,
            'editorToken': editor_token,
            'title': title,
            'category': category,
        }

    def update_collaboration_note(self, doc_guid, blocks, title=None):
        """更新协作笔记内容（del + create 覆盖写）"""
        if title:
            self._put(
                f'/ks/note/save/{self.kb_guid}/{doc_guid}',
                data={
                    'category': '', 'docGuid': doc_guid,
                    'kbGuid': self.kb_guid, 'title': title,
                    'html': '', 'resources': []
                },
                params={'infoOnly': '1', 'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'}
            )

        token_info = self.get_collaboration_token(doc_guid)
        editor_token = token_info['editorToken'] if isinstance(token_info, dict) else token_info
        raw_content = self.get_collaboration_content(editor_token, doc_guid)
        content_data = json.loads(raw_content)
        current_v = content_data.get('data', {}).get('v', 0)
        doc_type = content_data.get('data', {}).get('type')

        if doc_type is None or current_v == 0:
            self._ws_write_collaboration(doc_guid, editor_token, 0, blocks)
        else:
            self._ws_write_collaboration(doc_guid, editor_token, current_v, blocks, delete_first=True)

        logger.info(f"协作笔记已更新: doc_guid={doc_guid}")
        return {'docGuid': doc_guid, 'status': 'updated'}

    # ================================================================
    # 笔记读取
    # ================================================================

    def get_note_list(self, version=0, count=50):
        return self._get(
            f'/ks/note/list/version/{self.kb_guid}',
            params={'version': version, 'count': count}
        )

    def get_notes_by_category(self, category, count=50, start=0):
        """
        按分类获取笔记列表
        :param category: 分类路径
        :param count: 返回数量
        :param start: 起始位置
        """
        return self._get(
            f'/ks/note/list/{self.kb_guid}',
            params={'category': category, 'count': count, 'start': start}
        )

    def search_notes(self, keyword, with_abstract=True, with_favor=False):
        return self._get(
            f'/ks/note/search/{self.kb_guid}',
            params={
                'ss': keyword,
                'withAbstract': 'true' if with_abstract else 'false',
                'withFavor': 'true' if with_favor else 'false',
                'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'
            }
        )

    def get_note_detail(self, doc_guid):
        return self._get(
            f'/ks/note/download/{self.kb_guid}/{doc_guid}',
            params={'downloadInfo': '0', 'downloadData': '1'}
        )

    def get_note_content(self, doc_guid):
        """获取笔记正文（自动检测协作笔记）"""
        detail = self.get_note_detail(doc_guid)
        info = detail.get('info', {})
        note_type = info.get('type', 'document')

        if note_type == 'collaboration':
            try:
                token_info = self.get_collaboration_token(doc_guid)
                editor_token = token_info['editorToken'] if isinstance(token_info, dict) else token_info
                raw_content = self.get_collaboration_content(editor_token, doc_guid)
                content_data = json.loads(raw_content)
                inner_data = content_data.get('data', {})
                if inner_data.get('type') is None and inner_data.get('v', 0) == 0:
                    return ''
                return self.parse_collaboration_content(raw_content)
            except Exception as e:
                logger.error(f"协作笔记获取失败，回退: {e}")
                return detail.get('html', '')

        return detail.get('html', '')

    # ================================================================
    # 分类/文件夹管理
    # ================================================================

    def list_categories(self):
        """获取所有分类（文件夹）列表"""
        return self._get(f'/ks/category/all/{self.kb_guid}')

    def create_category(self, parent, name):
        """
        创建分类
        :param parent: 父分类路径，如 "/My Notes/"
        :param name: 新分类名
        :return: 创建结果
        """
        category = parent.rstrip('/') + '/' + name + '/'
        return self._post(
            f'/ks/category/create/{self.kb_guid}',
            data={'category': category}
        )

    def delete_category(self, category):
        """
        删除分类（文件夹）
        :param category: 分类路径
        """
        return self._delete(
            f'/ks/category/delete/{self.kb_guid}',
            params={'category': category}
        )

    def rename_category(self, old_category, new_name):
        """
        重命名分类
        :param old_category: 原分类路径
        :param new_name: 新名称
        """
        # 解析父路径
        parts = old_category.rstrip('/').rsplit('/', 1)
        parent = parts[0] + '/' if len(parts) > 1 else '/'
        new_category = parent + new_name + '/'
        return self._post(
            f'/ks/category/rename/{self.kb_guid}',
            data={'oldCategory': old_category, 'newCategory': new_category}
        )

    # ================================================================
    # 标签管理
    # ================================================================

    def list_tags(self):
        """获取所有标签"""
        return self._get(f'/ks/tag/all/{self.kb_guid}')

    def add_tags(self, doc_guid, tags):
        """
        为笔记添加标签
        :param doc_guid: 笔记GUID
        :param tags: 标签列表或逗号分隔字符串
        """
        if isinstance(tags, str):
            tags = [t.strip() for t in tags.split(',') if t.strip()]
        return self._post(
            f'/ks/tag/add/{self.kb_guid}/{doc_guid}',
            data={'tags': tags}
        )

    def remove_tags(self, doc_guid, tags):
        """移除笔记标签"""
        if isinstance(tags, str):
            tags = [t.strip() for t in tags.split(',') if t.strip()]
        return self._post(
            f'/ks/tag/remove/{self.kb_guid}/{doc_guid}',
            data={'tags': tags}
        )

    def rename_tag(self, old_name, new_name):
        """重命名标签（全局）"""
        return self._post(
            f'/ks/tag/rename/{self.kb_guid}',
            data={'oldTag': old_name, 'newTag': new_name}
        )

    # ================================================================
    # 评论
    # ================================================================

    def get_comments(self, doc_guid):
        """获取笔记评论列表"""
        return self._get(
            f'/ks/comment/list/{self.kb_guid}/{doc_guid}',
            params={'clientType': 'web', 'clientVersion': '4.0'}
        )

    def add_comment(self, doc_guid, text):
        """
        添加评论
        :param doc_guid: 笔记GUID
        :param text: 评论内容
        """
        return self._post(
            f'/ks/comment/create/{self.kb_guid}/{doc_guid}',
            data={
                'text': text,
                'docGuid': doc_guid,
                'kbGuid': self.kb_guid,
            },
            params={'clientType': 'web', 'clientVersion': '4.0'}
        )

    def delete_comment(self, doc_guid, comment_guid):
        """删除评论"""
        return self._delete(
            f'/ks/comment/delete/{self.kb_guid}/{doc_guid}/{comment_guid}'
        )

    # ================================================================
    # 历史版本
    # ================================================================

    def get_note_history(self, doc_guid):
        """获取笔记历史版本列表"""
        return self._get(
            f'/ks/note/history/{self.kb_guid}/{doc_guid}',
            params={'clientType': 'web', 'clientVersion': '4.0'}
        )

    def get_note_version(self, doc_guid, version_id):
        """
        获取指定历史版本内容
        :param doc_guid: 笔记GUID
        :param version_id: 版本ID
        """
        return self._get(
            f'/ks/note/version/{self.kb_guid}/{doc_guid}/{version_id}',
            params={'clientType': 'web', 'clientVersion': '4.0'}
        )

    # ================================================================
    # 分享
    # ================================================================

    def share_note(self, doc_guid, access='read', expire_days=30):
        """
        分享笔记，返回分享链接
        :param doc_guid: 笔记GUID
        :param access: 'read' 或 'edit'
        :param expire_days: 过期天数，0 表示永久
        :return: 分享信息（含URL）
        """
        return self._post(
            f'/ks/share/create/{self.kb_guid}/{doc_guid}',
            data={
                'docGuid': doc_guid,
                'kbGuid': self.kb_guid,
                'access': access,
                'expire': expire_days * 86400 if expire_days > 0 else 0,
            }
        )

    def list_shares(self):
        """列出所有分享"""
        return self._get(f'/ks/share/list/{self.kb_guid}')

    def cancel_share(self, share_id):
        """取消分享"""
        return self._delete(f'/ks/share/delete/{self.kb_guid}/{share_id}')

    # ================================================================
    # 附件操作
    # ================================================================

    def get_note_attachments(self, doc_guid):
        return self._get(
            f'/ks/note/attachments/{self.kb_guid}/{doc_guid}',
            params={'extra': '1', 'clientType': 'web', 'clientVersion': '4.0'}
        )

    def download_attachment(self, doc_guid, att_guid):
        url = f'{self.kb_server}/ks/attachment/download/{self.kb_guid}/{doc_guid}/{att_guid}'
        timeout = (self.ATTACHMENT_CONNECT_TIMEOUT_S, self.ATTACHMENT_READ_TIMEOUT_S)
        resp = requests.get(url, params={'clientType': 'web', 'clientVersion': '4.0'},
                            headers={'X-Wiz-Token': self.token}, stream=True, timeout=timeout)
        if resp.status_code != 200:
            raise Exception(f'下载附件失败: HTTP {resp.status_code}')
        return resp.content

    def upload_attachment(self, doc_guid, file_path, name=None):
        """
        上传附件到笔记
        :param doc_guid: 笔记GUID
        :param file_path: 本地文件路径
        :param name: 附件名称（默认取文件名）
        :return: 附件信息
        """
        import os
        name = name or os.path.basename(file_path)
        url = f'{self.kb_server}/ks/attachment/upload/{self.kb_guid}/{doc_guid}'
        with open(file_path, 'rb') as f:
            resp = requests.post(
                url,
                files={'file': (name, f)},
                headers={'X-Wiz-Token': self.token},
                params={'clientType': 'web', 'clientVersion': '4.0'}
            )
        data = resp.json()
        if data.get('returnCode') != 200:
            raise Exception(f'上传附件失败: {data}')
        return data.get('result', data)

    def download_collaboration_resource(self, editor_token, doc_guid, src):
        url = f'{self.kb_server}/editor/{self.kb_guid}/{doc_guid}/resources/{src}'
        timeout = (self.ATTACHMENT_CONNECT_TIMEOUT_S, self.ATTACHMENT_READ_TIMEOUT_S)
        headers = {
            'cookie': f'x-live-editor-[REDACTED_SECRET]',
            'user-agent': 'Mozilla/5.0'
        }
        resp = requests.get(url, headers=headers, stream=True, timeout=timeout)
        if resp.status_code != 200:
            raise Exception(f'下载协作资源失败: HTTP {resp.status_code}')
        return resp.content

    def get_collaboration_image(self, doc_guid, image_name):
        token_info = self.get_collaboration_token(doc_guid)
        [REDACTED_SECRET]'editorToken'] if isinstance(token_info, dict) else token_info
        return self.download_collaboration_resource(token, doc_guid, image_name)

    # ================================================================
    # 传统 HTML 笔记（内部使用）
    # ================================================================

    def _create_html_note(self, title, html, category="/My Notes/", tags=""):
        """创建传统 HTML 笔记（内部方法）"""
        data = {
            'kbGuid': self.kb_guid, 'html': html,
            'category': category, 'owner': self.config.user_id,
            'tags': tags, 'title': title,
        }
        return self._post(
            f'/ks/note/create/{self.kb_guid}',
            data=data,
            params={'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'}
        )

    def save_note(self, doc_guid, title, html, category="/My Notes/"):
        """保存/更新传统 HTML 笔记"""
        data = {
            'category': category, 'docGuid': doc_guid,
            'kbGuid': self.kb_guid, 'title': title,
            'html': html, 'resources': []
        }
        return self._put(
            f'/ks/note/save/{self.kb_guid}/{doc_guid}',
            data=data,
            params={'clientType': 'web', 'clientVersion': '4.0', 'lang': 'zh-cn'}
        )

    # ================================================================
    # 协作笔记 WebSocket 协议
    # ================================================================

    def get_collaboration_token(self, doc_guid):
        """获取协作笔记 editorToken"""
        return self._post(f'/ks/note/{self.kb_guid}/{doc_guid}/tokens')

    def get_collaboration_content(self, editor_token, doc_guid):
        """通过 WebSocket 获取协作笔记内容"""
        scheme = 'wss' if self.config.base_url.startswith('https') else 'ws'
        ws_url = f"{scheme}://{self.domain}/editor/{self.kb_guid}/{doc_guid}"
        sslopt = None
        if scheme == 'wss':
            import certifi
            sslopt = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': certifi.where()}

        hs = {
            "a": "hs", "id": None,
            "auth": {
                "appId": self.kb_guid, "docId": doc_guid,
                "userId": self.user_guid, "permission": "w", "token": editor_token
            }
        }

        ws = create_connection(ws_url, sslopt=sslopt, timeout=10)
        ws.send(json.dumps(hs))
        init_resp = ws.recv()  # server returns {"a":"init",...}

        ws.send(json.dumps({"a": "f", "c": self.kb_guid, "d": doc_guid, "v": None}))
        ws.recv()  # ack
        content = ws.recv()  # actual content

        ws.send(json.dumps({"a": "s", "c": self.kb_guid, "d": doc_guid, "v": None}))
        ws.recv()
        ws.close()
        return content

    def _ws_write_collaboration(self, doc_guid, editor_token, version, blocks,
                                delete_first=False):
        """通过 WebSocket 写入协作笔记"""
        scheme = 'wss' if self.config.base_url.startswith('https') else 'ws'
        ws_url = f"{scheme}://{self.domain}/editor/{self.kb_guid}/{doc_guid}"
        sslopt = None
        if scheme == 'wss':
            import certifi
            sslopt = {'cert_reqs': ssl.CERT_REQUIRED, 'ca_certs': certifi.where()}

        hs = {
            'a': 'hs', 'id': None,
            'auth': {
                'appId': self.kb_guid, 'docId': doc_guid,
                'userId': self.user_guid, 'permission': 'w', 'token': editor_token
            }
        }

        ws = create_connection(ws_url, sslopt=sslopt, timeout=10)
        ws.send(json.dumps(hs))
        init_resp = ws.recv()  # server returns {"a":"init",...}

        # 同步
        ws.send(json.dumps({'a': 'f', 'c': self.kb_guid, 'd': doc_guid, 'v': None}))
        ws.recv()
        sync_content = ws.recv()
        try:
            server_v = json.loads(sync_content).get('data', {}).get('v', 0)
            if server_v > version:
                version = server_v
        except Exception:
            pass

        src = str(uuid.uuid4())[:20]
        seq = 1
        v = version

        if delete_first:
            ws.send(json.dumps({
                'a': 'op', 'c': self.kb_guid, 'd': doc_guid,
                'v': v, 'src': src, 'seq': seq, 'del': True,
            }))
            ws.recv()
            seq += 1
            v += 1

        doc_data = {
            'blocks': blocks,
            'comments': [], 'meta': {},
            'authors': [], 'commentators': [],
        }

        ws.send(json.dumps({
            'a': 'op', 'c': self.kb_guid, 'd': doc_guid,
            'v': v, 'src': src, 'seq': seq,
            'create': {
                'type': 'http://sharejs.org/types/JSONv1',
                'data': doc_data,
            }
        }))
        ws.recv()

        ws.send(json.dumps({'a': 's', 'c': self.kb_guid, 'd': doc_guid, 'v': None}))
        try:
            ws.recv()
        except Exception:
            pass
        ws.close()

    # ================================================================
    # Markdown → Blocks 转换器
    # ================================================================

    def markdown_to_blocks(self, markdown_text):
        """
        将 Markdown 文本转换为协作笔记 blocks 列表
        支持：标题(#/##/###)、段落、粗体、斜体、行内代码、链接、
              有序/无序列表、复选框、引用、代码块、表格、分隔线、图片
        """
        if not markdown_text or not markdown_text.strip():
            return []

        blocks = []
        lines = markdown_text.split('\n')
        i = 0

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

            # 空行跳过
            if not line.strip():
                i += 1
                continue

            # 代码块
            if line.strip().startswith('```'):
                lang = line.strip()[3:].strip()
                code_lines = []
                i += 1
                while i < len(lines) and not lines[i].strip().startswith('```'):
                    code_lines.append(lines[i])
                    i += 1
                i += 1  # 跳过结束的 ```
                code_id = str(uuid.uuid4())[:8]
                child_id = f'_code_{code_id}_0'
                blocks.append({
                    'id': code_id,
                    'type': 'code',
                    'language': lang,
                    'children': [child_id],
                })
                blocks.append({
                    '__id': child_id,
                    '__type': 'code_cell',
                    'text': [{'insert': '\n'.join(code_lines)}],
                })
                continue

            # 标题
            heading_match = re.match(r'^(#{1,6})\s+(.+)$', line)
            if heading_match:
                level = len(heading_match.group(1))
                text = self._parse_inline_markdown(heading_match.group(2))
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'text',
                    'text': text,
                    'heading': min(level, 6),
                })
                i += 1
                continue

            # 分隔线
            if re.match(r'^(-{3,}|\*{3,}|_{3,})$', line.strip()):
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'embed',
                    'embedType': 'hr',
                    'embedData': {},
                })
                i += 1
                continue

            # 引用
            if line.strip().startswith('>'):
                text = self._parse_inline_markdown(line.strip().lstrip('> ').lstrip())
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'text',
                    'text': text,
                    'quoted': True,
                })
                i += 1
                continue

            # 复选框
            check_match = re.match(r'^(\s*)- \[([ xX])\] (.+)$', line)
            if check_match:
                text = self._parse_inline_markdown(check_match.group(3))
                level = len(check_match.group(1)) // 2 + 1
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'list',
                    'text': text,
                    'level': level,
                    'checkbox': 'checked' if check_match.group(2) != ' ' else 'unchecked',
                })
                i += 1
                continue

            # 无序列表
            ul_match = re.match(r'^(\s*)[-*+]\s+(.+)$', line)
            if ul_match:
                text = self._parse_inline_markdown(ul_match.group(2))
                level = len(ul_match.group(1)) // 2 + 1
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'list',
                    'text': text,
                    'level': level,
                })
                i += 1
                continue

            # 有序列表
            ol_match = re.match(r'^(\s*)(\d+)\.\s+(.+)$', line)
            if ol_match:
                text = self._parse_inline_markdown(ol_match.group(3))
                level = len(ol_match.group(1)) // 2 + 1
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'list',
                    'text': text,
                    'level': level,
                    'ordered': True,
                    'start': int(ol_match.group(2)),
                })
                i += 1
                continue

            # 表格
            if '|' in line and i + 1 < len(lines) and re.match(r'^\s*\|[\s\-:|]+\|\s*$', lines[i + 1]):
                headers = [c.strip() for c in line.strip('|').split('|')]
                i += 2  # 跳过分隔行
                rows = []
                while i < len(lines) and '|' in lines[i] and lines[i].strip().startswith('|'):
                    row = [c.strip() for c in lines[i].strip('|').split('|')]
                    rows.append(row)
                    i += 1
                block, extra = self.table_block(headers, rows)
                blocks.append(block)
                # 添加额外数据
                for key, val in extra.items():
                    blocks.append({'__id': key, **val})
                continue

            # 图片
            img_match = re.match(r'^!\[([^\]]*)\]\(([^)]+)\)$', line.strip())
            if img_match:
                blocks.append({
                    'id': str(uuid.uuid4())[:8],
                    'type': 'embed',
                    'embedType': 'image',
                    'embedData': {'src': img_match.group(2), 'alt': img_match.group(1)},
                })
                i += 1
                continue

            # 普通段落
            text = self._parse_inline_markdown(line)
            blocks.append({
                'id': str(uuid.uuid4())[:8],
                'type': 'text',
                'text': text,
            })
            i += 1

        return blocks

    def _parse_inline_markdown(self, text):
        """解析行内 Markdown 为 text 数组（insert + attributes）"""
        if not text.strip():
            return [{'insert': text}]

        result = []
        # 简化处理：按优先级匹配粗体、斜体、代码、链接
        pattern = re.compile(
            r'(\*\*\*(.+?)\*\*\*)'   # 粗斜体
            r'|(\*\*(.+?)\*\*)'       # 粗体
            r'|(\*(.+?)\*)'           # 斜体
            r'|(`([^`]+)`)'           # 行内代码
            r'|(\[([^\]]+)\]\(([^)]+)\))'  # 链接
            r'|([^*\[`]+)'           # 普通文本
        )

        pos = 0
        while pos < len(text):
            match = pattern.match(text, pos)
            if not match:
                result.append({'insert': text[pos]})
                pos += 1
                continue

            if match.group(2):  # 粗斜体
                result.append({'insert': match.group(2), 'attributes': {'style-bold': True, 'style-italic': True}})
            elif match.group(4):  # 粗体
                result.append({'insert': match.group(4), 'attributes': {'style-bold': True}})
            elif match.group(6):  # 斜体
                result.append({'insert': match.group(6), 'attributes': {'style-italic': True}})
            elif match.group(8):  # 代码
                result.append({'insert': match.group(8), 'attributes': {'style-code': True}})
            elif match.group(10):  # 链接
                result.append({'insert': match.group(10), 'attributes': {'link': match.group(11)}})
            elif match.group(12):  # 普通文本
                result.append({'insert': match.group(12)})

            pos = match.end()

        return result if result else [{'insert': text}]

    # ================================================================
    # 协作笔记内容解析
    # ================================================================

    def parse_collaboration_content(self, raw_content):
        """解析协作笔记 JSON 为可读 Markdown 文本"""
        try:
            content_data = json.loads(raw_content) if isinstance(raw_content, str) else raw_content
        except json.JSONDecodeError:
            return str(raw_content)

        if not isinstance(content_data, dict) or 'data' not in content_data:
            return str(content_data)

        data_section = content_data['data']
        if not isinstance(data_section, dict) or 'data' not in data_section:
            return json.dumps(content_data, ensure_ascii=False, indent=2)

        inner_data = data_section['data']
        if 'blocks' not in inner_data:
            return json.dumps(content_data, ensure_ascii=False, indent=2)

        blocks = inner_data['blocks']
        lines = []
        for block in blocks:
            text = self._parse_block(inner_data, block)
            if text:
                lines.append(text)

        return '\n'.join(lines)

    def _parse_block(self, full_data, block):
        block_type = block.get('type', '')

        if block_type == 'text':
            text = self._parse_text_array(block.get('text', []))
            heading = block.get('heading')
            if heading:
                return f"{'#' * heading} {text}"
            elif block.get('quoted'):
                return f"> {text}"
            return text

        elif block_type == 'list':
            text = self._parse_text_array(block.get('text', []))
            level = block.get('level', 1)
            indent = '  ' * (level - 1)
            checkbox = block.get('checkbox')
            prefix = ''
            if checkbox == 'checked':
                prefix = '[x] '
            elif checkbox == 'unchecked':
                prefix = '[ ] '
            if block.get('ordered'):
                return f"{indent}{block.get('start', 1)}. {prefix}{text}"
            return f"{indent}- {prefix}{text}"

        elif block_type == 'code':
            language = block.get('language', '')
            children = block.get('children', [])
            code_lines = []
            for child_id in children:
                if child_id in full_data:
                    child_data = full_data[child_id]
                if child_data and isinstance(child_data, dict) and 'text' in child_data:
                        if child_data['text']:
                            code_lines.append(child_data['text'][0].get('insert', ''))
                        else:
                            code_lines.append('')
            return f"```{language}\n{'\n'.join(code_lines)}\n```"

        elif block_type == 'table':
            cols = block.get('cols', 0)
            children = block.get('children', [])
            cells = []
            for child_id in children:
                if child_id in full_data:
                    child_data = full_data[child_id]
                if child_data and isinstance(child_data, dict) and 'text' in child_data:
                        if child_data['text']:
                            cells.append(child_data['text'][0].get('insert', ''))
                        else:
                            cells.append('')
                else:
                    cells.append('')
            if not cells or cols == 0:
                return ''
            header = '| ' + ' | '.join(cells[:cols]) + ' |'
            sep = '| ' + ' | '.join(['---'] * cols) + ' |'
            rows = []
            body = cells[cols:]
            for i in range(0, len(body), cols):
                row = body[i:i + cols]
                while len(row) < cols:
                    row.append('')
                rows.append('| ' + ' | '.join(row) + ' |')
            return header + '\n' + sep + '\n' + '\n'.join(rows)

        elif block_type == 'embed':
            embed_type = block.get('embedType', '')
            embed_data = block.get('embedData', {})
            if embed_type == 'image':
                return f"![图片]({embed_data.get('src', '')})"
            elif embed_type == 'hr':
                return '---'
            return f"<!-- embed: {embed_type} -->"

        return ''

    def _parse_text_array(self, text_array):
        if not text_array:
            return ''
        parts = []
        for text_obj in text_array:
            insert = text_obj.get('insert', '')
            attrs = text_obj.get('attributes', {})
            if not attrs:
                parts.append(insert)
            elif attrs.get('style-bold'):
                parts.append(f"**{insert}**")
            elif attrs.get('style-italic'):
                parts.append(f"*{insert}*")
            elif attrs.get('style-code'):
                parts.append(f"`{insert}`")
            elif attrs.get('link'):
                parts.append(f"[{insert}]({attrs.get('link')})")
            else:
                parts.append(insert)
        return ''.join(parts)

    # ================================================================
    # 笔记模板
    # ================================================================

    @staticmethod
    def get_template(template_name, **kwargs):
        """
        获取预定义模板的 Markdown 内容
        :param template_name: 模板名称
            - weekly_report: 周报
            - meeting_minutes: 会议纪要
            - todo: 待办清单
            - reading_notes: 读书笔记
            - project_plan: 项目计划
            - blank: 空白
        :param kwargs: 模板变量
        :return: Markdown 文本
        """
        templates = {
            'weekly_report': """# {title}

## 本周完成

- 
- 
- 

## 进行中

- 
- 

## 下周计划

- 
- 
- 

## 风险与问题

> 

## 备注

""",
            'meeting_minutes': """# {title}

**日期**: {date}
**参会人**: {attendees}
**记录人**: {recorder}

## 会议议题

1. 

## 讨论内容

### 议题一


### 议题二


## 决议

- [ ] 
- [ ] 

## 后续行动

| 任务 | 负责人 | 截止日期 | 状态 |
|------|--------|----------|------|
|  |  |  | 待开始 |

""",
            'todo': """# {title}

## 🔴 紧急重要

- [ ] 

## 🟡 重要不紧急

- [ ] 
- [ ] 

## 🔵 紧急不重要

- [ ] 

## ⚪ 其他

- [ ] 

""",
            'reading_notes': """# {title}

**书名**: {book_name}
**作者**: {author}
**阅读日期**: {date}

## 核心观点

> 

## 章节摘要

### 第一章


### 第二章


## 精彩摘录

- 
- 
- 

## 个人思考


## 行动清单

- [ ] 
- [ ] 

""",
            'project_plan': """# {title}

## 项目背景


## 目标

1. 
2. 
3. 

## 时间线

| 阶段 | 时间 | 交付物 | 负责人 |
|------|------|--------|--------|
| 需求分析 |  |  |  |
| 设计 |  |  |  |
| 开发 |  |  |  |
| 测试 |  |  |  |
| 上线 |  |  |  |

## 资源需求

- 
- 

## 风险评估

| 风险 | 概率 | 影响 | 应对策略 |
|------|------|------|----------|
|  |  |  |  |

""",
            'blank': """# {title}

""",
        }

        template = templates.get(template_name, templates['blank'])
        return template.format(
            title=kwargs.get('title', '未命名'),
            date=kwargs.get('date', time.strftime('%Y-%m-%d')),
            attendees=kwargs.get('attendees', ''),
            recorder=kwargs.get('recorder', ''),
            book_name=kwargs.get('book_name', ''),
            author=kwargs.get('author', ''),
            **{k: v for k, v in kwargs.items()
               if k not in ('title', 'date', 'attendees', 'recorder', 'book_name', 'author')}
        )

    def create_from_template(self, template_name, title, category="/My Notes/", tags="",
                             **kwargs):
        """
        从模板创建协作笔记
        :param template_name: 模板名称
        :param title: 笔记标题
        :param category: 分类
        :param tags: 标签
        :param kwargs: 模板变量
        """
        kwargs['title'] = title
        md_content = self.get_template(template_name, **kwargs)
        blocks = self.markdown_to_blocks(md_content)
        return self.create_collaboration_note(title, blocks, category, tags)

    # ================================================================
    # Block 快捷构造方法
    # ================================================================

    @staticmethod
    def text_block(text, heading=None, quoted=False, block_id=None):
        block = {
            'id': block_id or str(uuid.uuid4())[:8],
            'type': 'text',
            'text': [{'insert': text}],
        }
        if heading:
            block['heading'] = heading
        if quoted:
            block['quoted'] = True
        return block

    @staticmethod
    def list_block(text, ordered=False, level=1, checkbox=None, block_id=None):
        block = {
            'id': block_id or str(uuid.uuid4())[:8],
            'type': 'list',
            'text': [{'insert': text}],
            'level': level,
        }
        if ordered:
            block['ordered'] = True
            block['start'] = 1
        if checkbox:
            block['checkbox'] = checkbox
        return block

    @staticmethod
    def table_block(headers, rows, block_id=None):
        cols = len(headers)
        children = []
        extra_data = {}
        cell_idx = 0

        for h in headers:
            cell_id = f'_t{block_id or ""}_{cell_idx}'
            children.append(cell_id)
            extra_data[cell_id] = {'text': [{'insert': str(h)}]}
            cell_idx += 1

        for row in rows:
            for cell in row:
                cell_id = f'_t{block_id or ""}_{cell_idx}'
                children.append(cell_id)
                extra_data[cell_id] = {'text': [{'insert': str(cell)}]}
                cell_idx += 1

        block = {
            'id': block_id or str(uuid.uuid4())[:8],
            'type': 'table',
            'cols': cols,
            'children': children,
        }
        return block, extra_data

    @staticmethod
    def code_block(code, language='', block_id=None):
        """快捷方法：创建代码块"""
        code_id = block_id or str(uuid.uuid4())[:8]
        child_id = f'_code_{code_id}_0'
        block = {
            'id': code_id,
            'type': 'code',
            'language': language,
            'children': [child_id],
        }
        extra = {
            child_id: {'__id': child_id, '__type': 'code_cell', 'text': [{'insert': code}]}
        }
        return block, extra

    @staticmethod
    def divider_block():
        """快捷方法：创建分隔线"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'embed',
            'embedType': 'hr',
            'embedData': {},
        }

    @staticmethod
    def image_block(src, alt='图片'):
        """快捷方法：创建图片块"""
        return {
            'id': str(uuid.uuid4())[:8],
            'type': 'embed',
            'embedType': 'image',
            'embedData': {'src': src, 'alt': alt},
        }


path: wiznote_wrapper.py
content: #!/usr/bin/env python3
"""为知笔记Wrapper - 简化API调用（v2 全协作笔记模式）"""

import sys
import os
import json
import time

sys.path.insert(0, '/root/.openclaw/workspace-zhongshu/skills/wiz-mcp')

from scripts.wiz_open_api import WizOpenApi
from scripts.config import get_config

COMMANDS = {
    'list': '列出笔记',
    'search': '搜索笔记',
    'get': '读取笔记内容',
    'create': '创建笔记（默认协作笔记）',
    'update': '更新笔记（默认协作笔记）',
    'delete': '删除笔记',
    'move': '移动笔记到指定分类',
    'copy': '复制笔记',
    'categories': '列出所有分类',
    'create_category': '创建分类',
    'delete_category': '删除分类',
    'tags': '列出所有标签',
    'add_tags': '添加标签',
    'remove_tags': '移除标签',
    'comments': '获取评论',
    'add_comment': '添加评论',
    'history': '获取历史版本',
    'share': '分享笔记',
    'shares': '列出分享',
    'attachments': '列出附件',
    'upload': '上传附件',
    'template': '从模板创建笔记',
    'templates': '列出可用模板',
    'md2blocks': 'Markdown转blocks预览',
}


def main():
    if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help', 'help'):
        print("用法: wiznote_wrapper.py <command> [args]\n")
        print("命令列表:")
        for cmd, desc in COMMANDS.items():
            print(f"  {cmd:20s} {desc}")
        return

    config = get_config()
    api = WizOpenApi(config)
    cmd = sys.argv[1]

    try:
        if cmd == 'list':
            count = int(sys.argv[2]) if len(sys.argv) > 2 else 50
            print(json.dumps(api.get_note_list(version=0, count=count), indent=2, ensure_ascii=False))

        elif cmd == 'search':
            assert len(sys.argv) >= 3, "需要搜索关键词"
            print(json.dumps(api.search_notes(keyword=sys.argv[2]), indent=2, ensure_ascii=False))

        elif cmd == 'get':
            assert len(sys.argv) >= 3, "需要笔记GUID"
            result = api.get_note_content(doc_guid=sys.argv[2])
            print(result if isinstance(result, str) else json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'create':
            # create <title> [content] [--category /path/] [--tags tag1,tag2]
            assert len(sys.argv) >= 3, "需要标题"
            title = sys.argv[2]
            content = sys.argv[3] if len(sys.argv) > 3 else ''
            category = '/My Notes/'
            tags = ''
            for i, arg in enumerate(sys.argv):
                if arg == '--category' and i + 1 < len(sys.argv):
                    category = sys.argv[i + 1]
                elif arg == '--tags' and i + 1 < len(sys.argv):
                    tags = sys.argv[i + 1]
            result = api.create_note(title, content, category, tags)
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'update':
            # update <doc_guid> [content] [--title new_title]
            assert len(sys.argv) >= 3, "需要笔记GUID"
            doc_guid = sys.argv[2]
            content = sys.argv[3] if len(sys.argv) > 3 else ''
            title = None
            for i, arg in enumerate(sys.argv):
                if arg == '--title' and i + 1 < len(sys.argv):
                    title = sys.argv[i + 1]
            result = api.update_note(doc_guid, content, title)
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'delete':
            assert len(sys.argv) >= 3, "需要笔记GUID"
            result = api.delete_note(sys.argv[2])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'move':
            assert len(sys.argv) >= 4, "需要: move <doc_guid> <category>"
            result = api.move_note(sys.argv[2], sys.argv[3])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'copy':
            assert len(sys.argv) >= 3, "需要笔记GUID"
            title = sys.argv[3] if len(sys.argv) > 3 else None
            category = sys.argv[4] if len(sys.argv) > 4 else None
            result = api.copy_note(sys.argv[2], title, category)
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'categories':
            print(json.dumps(api.list_categories(), indent=2, ensure_ascii=False))

        elif cmd == 'create_category':
            assert len(sys.argv) >= 4, "需要: create_category <parent> <name>"
            result = api.create_category(sys.argv[2], sys.argv[3])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'delete_category':
            assert len(sys.argv) >= 3, "需要分类路径"
            result = api.delete_category(sys.argv[2])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'tags':
            print(json.dumps(api.list_tags(), indent=2, ensure_ascii=False))

        elif cmd == 'add_tags':
            assert len(sys.argv) >= 4, "需要: add_tags <doc_guid> <tags>"
            result = api.add_tags(sys.argv[2], sys.argv[3])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'remove_tags':
            assert len(sys.argv) >= 4, "需要: remove_tags <doc_guid> <tags>"
            result = api.remove_tags(sys.argv[2], sys.argv[3])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'comments':
            assert len(sys.argv) >= 3, "需要笔记GUID"
            result = api.get_comments(sys.argv[2])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'add_comment':
            assert len(sys.argv) >= 4, "需要: add_comment <doc_guid> <text>"
            result = api.add_comment(sys.argv[2], ' '.join(sys.argv[3:]))
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'history':
            assert len(sys.argv) >= 3, "需要笔记GUID"
            result = api.get_note_history(sys.argv[2])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'share':
            assert len(sys.argv) >= 3, "需要笔记GUID"
            access = sys.argv[3] if len(sys.argv) > 3 else 'read'
            expire = int(sys.argv[4]) if len(sys.argv) > 4 else 30
            result = api.share_note(sys.argv[2], access, expire)
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'shares':
            print(json.dumps(api.list_shares(), indent=2, ensure_ascii=False))

        elif cmd == 'attachments':
            assert len(sys.argv) >= 3, "需要笔记GUID"
            result = api.get_note_attachments(sys.argv[2])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'upload':
            assert len(sys.argv) >= 4, "需要: upload <doc_guid> <file_path>"
            result = api.upload_attachment(sys.argv[2], sys.argv[3])
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'template':
            assert len(sys.argv) >= 3, "需要模板名称"
            template = sys.argv[2]
            title = sys.argv[3] if len(sys.argv) > 3 else f'新建{template}'
            category = sys.argv[4] if len(sys.argv) > 4 else '/My Notes/'
            result = api.create_from_template(template, title, category)
            print(json.dumps(result, indent=2, ensure_ascii=False))

        elif cmd == 'templates':
            print("可用模板:")
            for t in ['weekly_report', 'meeting_minutes', 'todo', 'reading_notes', 'project_plan', 'blank']:
                print(f"  - {t}")

        elif cmd == 'md2blocks':
            assert len(sys.argv) >= 3, "需要Markdown内容"
            md = ' '.join(sys.argv[2:])
            blocks = api.markdown_to_blocks(md)
            print(json.dumps(blocks, indent=2, ensure_ascii=False))

        else:
            print(f"未知命令: {cmd}\n可用命令: {', '.join(COMMANDS.keys())}")

    except AssertionError as e:
        print(f"参数错误: {e}")
    except Exception as e:
        print(f"错误: {str(e)}")
        import traceback
        traceback.print_exc()


if __name__ == "__main__":
    main()


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