- 重命名 plugins/ → skills/,个人插件迁移到 skills-personal/(gitignore) - 更新 generate-marketplace.py 支持 config 读取和 skills-personal 扫描 - 新增 claude-config.yaml(技能启用/禁用 + MCP 配置) - 新增 init.sh(交互式 MCP 初始化,支持 stdio/SSE 模式) - 新增 CLAUDE.md 项目说明 - 重写 README.md 反映新结构 - 删除过时脚本:PUSH.sh、generate-marketplace.sh、convert-skills.sh Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
450 lines
16 KiB
Python
450 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
重建飞书文档:ai-proj 项目可见性手册
|
||
清理现有内容后重新生成
|
||
"""
|
||
|
||
import requests
|
||
import os
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
|
||
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
|
||
BASE_URL = "https://open.feishu.cn/open-apis"
|
||
|
||
# 目标文档
|
||
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
|
||
|
||
_token = None
|
||
_token_expires = None
|
||
|
||
|
||
def get_token():
|
||
global _token, _token_expires
|
||
if _token and _token_expires and datetime.now() < _token_expires:
|
||
return _token
|
||
|
||
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
|
||
response = requests.post(url, json={
|
||
"app_id": ZHIYUN_APP_ID,
|
||
"app_secret": ZHIYUN_APP_SECRET
|
||
})
|
||
data = response.json()
|
||
if data.get("code") != 0:
|
||
raise Exception(f"获取 token 失败: {data}")
|
||
|
||
_token = data["tenant_access_token"]
|
||
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
|
||
return _token
|
||
|
||
|
||
def headers():
|
||
return {
|
||
"Authorization": f"Bearer {get_token()}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
|
||
def get_document_blocks(document_id: str):
|
||
"""获取文档所有块"""
|
||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
|
||
response = requests.get(url, headers=headers())
|
||
data = response.json()
|
||
if data.get("code") != 0:
|
||
raise Exception(f"获取块失败: {data}")
|
||
return data["data"].get("items", [])
|
||
|
||
|
||
def delete_block(document_id: str, block_id: str):
|
||
"""删除块"""
|
||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||
response = requests.delete(url, headers=headers(), params={"document_revision_id": -1})
|
||
result = response.json()
|
||
return result.get("code") == 0
|
||
|
||
|
||
def clear_document(document_id: str):
|
||
"""清空文档内容(保留根块)"""
|
||
print("正在清空文档...")
|
||
blocks = get_document_blocks(document_id)
|
||
|
||
# 找出所有子块(排除根块本身)
|
||
child_blocks = [b for b in blocks if b.get("block_id") != document_id and b.get("parent_id") == document_id]
|
||
|
||
print(f" 发现 {len(child_blocks)} 个顶级子块需要删除")
|
||
|
||
# 从后向前删除,避免索引问题
|
||
for block in reversed(child_blocks):
|
||
block_id = block.get("block_id")
|
||
if delete_block(document_id, block_id):
|
||
print(f" 删除: {block_id[:20]}...")
|
||
else:
|
||
print(f" 删除失败: {block_id[:20]}...")
|
||
time.sleep(0.1) # 避免请求过快
|
||
|
||
print(" 文档清空完成")
|
||
|
||
|
||
def create_blocks(document_id: str, parent_id: str, blocks: list):
|
||
"""创建内容块"""
|
||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||
payload = {"children": blocks}
|
||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||
data = response.json()
|
||
if data.get("code") != 0:
|
||
raise Exception(f"创建块失败: {data}")
|
||
return data["data"].get("children", [])
|
||
|
||
|
||
def create_image_block(document_id: str, parent_id: str):
|
||
"""创建空图片块"""
|
||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
|
||
payload = {
|
||
"children": [{
|
||
"block_type": 27,
|
||
"image": {}
|
||
}]
|
||
}
|
||
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||
data = response.json()
|
||
if data.get("code") != 0:
|
||
raise Exception(f"创建图片块失败: {data}")
|
||
return data["data"]["children"][0]["block_id"]
|
||
|
||
|
||
def upload_image(file_path: str, block_id: str):
|
||
"""上传图片"""
|
||
url = f"{BASE_URL}/drive/v1/medias/upload_all"
|
||
file_name = os.path.basename(file_path)
|
||
file_size = os.path.getsize(file_path)
|
||
|
||
with open(file_path, 'rb') as f:
|
||
files = {'file': (file_name, f, 'image/png')}
|
||
data = {
|
||
'file_name': file_name,
|
||
'parent_type': 'docx_image',
|
||
'parent_node': block_id,
|
||
'size': str(file_size)
|
||
}
|
||
response = requests.post(
|
||
url,
|
||
headers={"Authorization": f"Bearer {get_token()}"},
|
||
files=files,
|
||
data=data
|
||
)
|
||
|
||
result = response.json()
|
||
if result.get("code") != 0:
|
||
raise Exception(f"上传失败: {result}")
|
||
return result["data"]["file_token"]
|
||
|
||
|
||
def bind_image(document_id: str, block_id: str, file_token: str):
|
||
"""绑定图片到图片块"""
|
||
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
|
||
payload = {"replace_image": {"token": file_token}}
|
||
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
|
||
data = response.json()
|
||
if data.get("code") != 0:
|
||
raise Exception(f"绑定失败: {data}")
|
||
return True
|
||
|
||
|
||
def insert_image(document_id: str, file_path: str, description: str = ""):
|
||
"""插入图片的完整流程"""
|
||
print(f" 插入图片: {description}")
|
||
|
||
# Step 1: 创建空图片块
|
||
block_id = create_image_block(document_id, document_id)
|
||
print(f" block_id: {block_id[:20]}...")
|
||
|
||
# Step 2: 上传图片
|
||
file_token = upload_image(file_path, block_id)
|
||
print(f" file_token: {file_token[:20]}...")
|
||
|
||
# Step 3: 绑定图片
|
||
bind_image(document_id, block_id, file_token)
|
||
print(f" 绑定成功!")
|
||
|
||
return block_id
|
||
|
||
|
||
def generate_visibility_diagram():
|
||
"""生成可见性示意图"""
|
||
output_path = "/tmp/visibility_diagram.png"
|
||
|
||
img = Image.new('RGB', (800, 400), color='#ffffff')
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# 尝试加载字体,如果失败则使用默认
|
||
try:
|
||
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18)
|
||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14)
|
||
except:
|
||
font_large = ImageFont.load_default()
|
||
font_medium = ImageFont.load_default()
|
||
font_small = ImageFont.load_default()
|
||
|
||
# 标题背景
|
||
draw.rectangle([0, 0, 800, 60], fill='#1890ff')
|
||
draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large)
|
||
|
||
# 私有项目区域
|
||
draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3)
|
||
draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium)
|
||
draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small)
|
||
|
||
# 私有项目说明
|
||
draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small)
|
||
|
||
# 私有项目图标
|
||
draw.ellipse([175, 210, 245, 280], fill='#fa8c16')
|
||
draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large)
|
||
draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small)
|
||
draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small)
|
||
|
||
# 企业项目区域
|
||
draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3)
|
||
draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium)
|
||
draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small)
|
||
|
||
# 企业项目说明
|
||
draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small)
|
||
|
||
# 企业项目图标组
|
||
positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)]
|
||
for x, y in positions:
|
||
draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff')
|
||
draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium)
|
||
|
||
draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small)
|
||
|
||
# 中间箭头
|
||
draw.text((400, 225), "→", fill='#333333', anchor='mm', font=font_large)
|
||
|
||
img.save(output_path)
|
||
print(f"[OK] 生成可见性示意图: {output_path}")
|
||
return output_path
|
||
|
||
|
||
def generate_ui_screenshot():
|
||
"""生成 UI 示意图"""
|
||
output_path = "/tmp/visibility_ui.png"
|
||
|
||
img = Image.new('RGB', (700, 250), color='#fafafa')
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
try:
|
||
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16)
|
||
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12)
|
||
except:
|
||
font_medium = ImageFont.load_default()
|
||
font_small = ImageFont.load_default()
|
||
|
||
# 标题
|
||
draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium)
|
||
|
||
# 私有项目选项(选中状态)
|
||
draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2)
|
||
draw.ellipse([45, 95, 65, 115], fill='#fa8c16')
|
||
draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium)
|
||
draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small)
|
||
|
||
# 默认标签
|
||
draw.rectangle([250, 85, 310, 110], fill='#52c41a')
|
||
draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small)
|
||
|
||
# 企业项目选项(未选中状态)
|
||
draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1)
|
||
draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2)
|
||
draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium)
|
||
draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small)
|
||
|
||
# 底部说明
|
||
draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1)
|
||
draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small)
|
||
draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small)
|
||
|
||
img.save(output_path)
|
||
print(f"[OK] 生成 UI 示意图: {output_path}")
|
||
return output_path
|
||
|
||
|
||
# 文档内容块定义
|
||
def heading1(text):
|
||
return {
|
||
"block_type": 3,
|
||
"heading1": {"elements": [{"text_run": {"content": text}}]}
|
||
}
|
||
|
||
|
||
def heading2(text):
|
||
return {
|
||
"block_type": 4,
|
||
"heading2": {"elements": [{"text_run": {"content": text}}]}
|
||
}
|
||
|
||
|
||
def heading3(text):
|
||
return {
|
||
"block_type": 5,
|
||
"heading3": {"elements": [{"text_run": {"content": text}}]}
|
||
}
|
||
|
||
|
||
def text_block(content):
|
||
return {
|
||
"block_type": 2,
|
||
"text": {"elements": [{"text_run": {"content": content}}]}
|
||
}
|
||
|
||
|
||
def bullet(content):
|
||
return {
|
||
"block_type": 12,
|
||
"bullet": {"elements": [{"text_run": {"content": content}}]}
|
||
}
|
||
|
||
|
||
def ordered(content):
|
||
return {
|
||
"block_type": 13,
|
||
"ordered": {"elements": [{"text_run": {"content": content}}]}
|
||
}
|
||
|
||
|
||
def code_block(content, language=1):
|
||
return {
|
||
"block_type": 14,
|
||
"code": {
|
||
"elements": [{"text_run": {"content": content}}],
|
||
"language": language
|
||
}
|
||
}
|
||
|
||
|
||
def divider():
|
||
return {"block_type": 22, "divider": {}}
|
||
|
||
|
||
def main():
|
||
print("\n" + "#" * 60)
|
||
print("# 重建飞书文档:ai-proj 项目可见性手册")
|
||
print("#" * 60)
|
||
|
||
# Step 1: 生成示意图
|
||
print("\n--- Step 1: 生成示意图 ---")
|
||
diagram_path = generate_visibility_diagram()
|
||
ui_path = generate_ui_screenshot()
|
||
|
||
# Step 2: 清空文档
|
||
print("\n--- Step 2: 清空文档 ---")
|
||
clear_document(DOCUMENT_ID)
|
||
time.sleep(1) # 等待清空完成
|
||
|
||
# Step 3: 创建手册内容
|
||
print("\n--- Step 3: 创建手册内容 ---")
|
||
|
||
# 第一部分:标题和概述
|
||
blocks_part1 = [
|
||
heading1("ai-proj 项目可见性手册"),
|
||
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
|
||
divider(),
|
||
heading2("1. 功能概述"),
|
||
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
|
||
bullet("私有项目 (private):仅项目创建者和管理员可见"),
|
||
bullet("企业项目 (enterprise):企业内所有成员可见"),
|
||
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
|
||
divider(),
|
||
heading2("2. 可见性对比"),
|
||
text_block("下图展示了两种可见性的区别:"),
|
||
]
|
||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part1)
|
||
print(" 第一部分内容创建完成")
|
||
|
||
# 插入可见性示意图
|
||
print("\n--- Step 4: 插入可见性示意图 ---")
|
||
insert_image(DOCUMENT_ID, diagram_path, "可见性示意图")
|
||
|
||
# 第二部分:设置方法
|
||
print("\n--- Step 5: 添加设置说明 ---")
|
||
blocks_part2 = [
|
||
divider(),
|
||
heading2("3. 设置项目可见性"),
|
||
heading3("3.1 创建项目时设置"),
|
||
text_block("在创建项目时,您可以选择项目的可见性:"),
|
||
ordered("点击「创建项目」按钮"),
|
||
ordered("在弹出的表单中找到「项目可见性」选项"),
|
||
ordered("选择「私有项目」或「企业项目」"),
|
||
ordered("填写其他信息后点击「确定」"),
|
||
text_block("UI 界面示意:"),
|
||
]
|
||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part2)
|
||
print(" 设置说明创建完成")
|
||
|
||
# 插入 UI 示意图
|
||
print("\n--- Step 6: 插入 UI 示意图 ---")
|
||
insert_image(DOCUMENT_ID, ui_path, "UI 示意图")
|
||
|
||
# 第三部分:修改方法和规则
|
||
print("\n--- Step 7: 添加剩余内容 ---")
|
||
blocks_part3 = [
|
||
heading3("3.2 修改已有项目"),
|
||
ordered("在项目列表中找到目标项目"),
|
||
ordered("点击项目卡片上的「编辑」按钮"),
|
||
ordered("在编辑页面修改「项目可见性」"),
|
||
ordered("点击「保存」"),
|
||
divider(),
|
||
heading2("4. 可见性规则"),
|
||
bullet("私有项目:仅所有者和超级管理员可以查看"),
|
||
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
|
||
bullet("只有项目所有者或管理员可以修改可见性"),
|
||
bullet("设置为「企业项目」需要项目已关联企业"),
|
||
divider(),
|
||
heading2("5. API 参考"),
|
||
text_block("修改项目可见性的 API:"),
|
||
code_block('''PATCH /api/v1/projects/:id/visibility
|
||
|
||
请求体:
|
||
{
|
||
"visibility": "enterprise"
|
||
}
|
||
|
||
可选值: "private" | "enterprise"'''),
|
||
divider(),
|
||
heading2("6. 注意事项"),
|
||
bullet("新项目默认为私有,请根据需要调整可见性"),
|
||
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
|
||
bullet("将企业项目改为私有后,其他成员将无法访问"),
|
||
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
|
||
divider(),
|
||
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
|
||
]
|
||
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part3)
|
||
|
||
print("\n" + "=" * 60)
|
||
print("文档重建完成!")
|
||
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
|
||
print("=" * 60)
|
||
|
||
# 验证图片
|
||
print("\n--- 验证图片状态 ---")
|
||
blocks = get_document_blocks(DOCUMENT_ID)
|
||
image_count = 0
|
||
for block in blocks:
|
||
if block.get("block_type") == 27:
|
||
image_count += 1
|
||
image_data = block.get("image", {})
|
||
token = image_data.get("token", "")
|
||
print(f"图片 #{image_count}: token={'有效' if token else '空'} ({token[:20] if token else 'N/A'}...)")
|
||
|
||
if image_count == 0:
|
||
print("警告:文档中没有图片块")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|