refactor: 合并 claude-marketplace,重构目录结构为单一仓库

- 重命名 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>
This commit is contained in:
2026-03-14 11:11:59 +10:30
parent f7f5428812
commit 99881e268a
191 changed files with 1131 additions and 492 deletions

View File

@@ -0,0 +1,8 @@
{
"name": "feishu-plugin",
"description": "飞书多维表格快捷操作。通过自然语言实现多维表格的增删改查、数据同步、批量操作等功能。当用户提到飞书、多维表格、Bitable、飞书表格相关任务时自动激活。",
"version": "1.1.0",
"author": {
"name": "qiudl"
}
}

View File

@@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""
添加需求图片字段并上传图片到飞书多维表格
"""
import requests
import os
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"
# Demo 创建的多维表格信息
APP_TOKEN = "D6PQbxf4aald77sPjDTciYbenjc"
TABLE_ID = "tblX3YbGrXm8pmLR"
class FeishuBitable:
"""飞书多维表格操作工具类"""
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
self.app_id = app_id
self.app_secret = app_secret
self._token = None
self._token_expires = None
@property
def token(self) -> str:
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": self.app_id,
"app_secret": self.app_secret
})
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取 token 失败: {data}")
self._token = data["tenant_access_token"]
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
return self._token
@property
def headers(self):
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
def create_field(self, app_token: str, table_id: str, field_name: str, field_type: int, property: dict = None):
"""创建新字段"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
payload = {
"field_name": field_name,
"type": field_type
}
if property:
payload["property"] = property
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建字段失败: {data}")
print(f"[OK] 字段 '{field_name}' 创建成功")
return data["data"]["field"]
def upload_media(self, app_token: str, file_path: str, file_name: str = None):
"""上传附件到多维表格"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
if file_name is None:
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
headers = {
"Authorization": f"Bearer {self.token}"
}
with open(file_path, 'rb') as f:
files = {
'file': (file_name, f, 'image/png')
}
data = {
'file_name': file_name,
'parent_type': 'bitable_image',
'parent_node': app_token,
'size': str(file_size)
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
if result.get("code") != 0:
raise Exception(f"上传失败: {result}")
file_token = result["data"]["file_token"]
print(f"[OK] 文件 '{file_name}' 上传成功, file_token: {file_token}")
return file_token
def list_records(self, app_token: str, table_id: str, filter_str: str = None):
"""列出记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
params = {"page_size": 100}
if filter_str:
params["filter"] = filter_str
response = requests.get(url, headers=self.headers, params=params)
data = response.json()
if data.get("code") != 0:
raise Exception(f"查询失败: {data}")
return data["data"].get("items", [])
def update_record(self, app_token: str, table_id: str, record_id: str, fields: dict):
"""更新记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
response = requests.put(url, headers=self.headers, json={"fields": fields})
data = response.json()
if data.get("code") != 0:
raise Exception(f"更新失败: {data}")
print(f"[OK] 记录 {record_id} 更新成功")
return data["data"]["record"]
def generate_sample_images(output_dir: str):
"""生成2张示例需求图片"""
os.makedirs(output_dir, exist_ok=True)
images = []
# 图片1: 用户流程图
img1 = Image.new('RGB', (800, 600), color='#f0f4f8')
draw1 = ImageDraw.Draw(img1)
# 绘制标题
draw1.rectangle([50, 30, 750, 80], fill='#4a90d9', outline='#2563eb')
draw1.text((400, 55), "用户登录流程图", fill='white', anchor='mm')
# 绘制流程框
boxes = [
(150, 150, "打开登录页"),
(400, 150, "输入账号密码"),
(650, 150, "点击登录"),
(150, 300, "验证账号"),
(400, 300, "生成Token"),
(650, 300, "跳转首页"),
]
for x, y, text in boxes:
draw1.rectangle([x-60, y-25, x+60, y+25], fill='#e8f4fd', outline='#4a90d9', width=2)
draw1.text((x, y), text, fill='#1e3a5f', anchor='mm')
# 绘制箭头线
arrows = [
(210, 150, 340, 150),
(460, 150, 590, 150),
(650, 175, 650, 275),
(590, 300, 460, 300),
(340, 300, 210, 300),
]
for x1, y1, x2, y2 in arrows:
draw1.line([(x1, y1), (x2, y2)], fill='#4a90d9', width=2)
# 添加水印
draw1.text((400, 550), "Claude Code Demo - 需求图片1", fill='#94a3b8', anchor='mm')
img1_path = os.path.join(output_dir, "requirement_flow.png")
img1.save(img1_path)
images.append(img1_path)
print(f"[OK] 生成图片: {img1_path}")
# 图片2: 界面原型图
img2 = Image.new('RGB', (800, 600), color='#ffffff')
draw2 = ImageDraw.Draw(img2)
# 绘制浏览器框架
draw2.rectangle([50, 30, 750, 570], outline='#d1d5db', width=2)
draw2.rectangle([50, 30, 750, 70], fill='#f3f4f6', outline='#d1d5db')
# 浏览器按钮
draw2.ellipse([70, 42, 86, 58], fill='#ef4444')
draw2.ellipse([95, 42, 111, 58], fill='#eab308')
draw2.ellipse([120, 42, 136, 58], fill='#22c55e')
# 地址栏
draw2.rectangle([160, 42, 600, 58], fill='white', outline='#d1d5db')
draw2.text((170, 50), "https://example.com/login", fill='#6b7280', anchor='lm')
# 登录表单区域
draw2.rectangle([200, 120, 600, 500], fill='#f8fafc', outline='#e2e8f0', width=1)
# Logo 占位
draw2.ellipse([350, 140, 450, 200], fill='#4a90d9')
draw2.text((400, 170), "LOGO", fill='white', anchor='mm')
# 标题
draw2.text((400, 230), "欢迎登录", fill='#1e293b', anchor='mm')
# 输入框
draw2.rectangle([250, 270, 550, 310], fill='white', outline='#cbd5e1')
draw2.text((260, 290), "请输入用户名", fill='#94a3b8', anchor='lm')
draw2.rectangle([250, 330, 550, 370], fill='white', outline='#cbd5e1')
draw2.text((260, 350), "请输入密码", fill='#94a3b8', anchor='lm')
# 登录按钮
draw2.rectangle([250, 400, 550, 450], fill='#4a90d9', outline='#2563eb')
draw2.text((400, 425), "登 录", fill='white', anchor='mm')
# 底部链接
draw2.text((320, 480), "忘记密码", fill='#4a90d9', anchor='mm')
draw2.text((480, 480), "注册账号", fill='#4a90d9', anchor='mm')
# 水印
draw2.text((400, 550), "Claude Code Demo - 需求图片2", fill='#94a3b8', anchor='mm')
img2_path = os.path.join(output_dir, "requirement_ui.png")
img2.save(img2_path)
images.append(img2_path)
print(f"[OK] 生成图片: {img2_path}")
return images
def main():
print("\n" + "#" * 60)
print("# 添加需求图片到多维表格")
print("#" * 60)
bitable = FeishuBitable()
# Step 1: 生成示例图片
print("\n" + "=" * 50)
print("Step 1: 生成示例图片")
print("=" * 50)
output_dir = "/tmp/feishu_demo_images"
image_paths = generate_sample_images(output_dir)
# Step 2: 添加"需求图片"字段
print("\n" + "=" * 50)
print("Step 2: 添加「需求图片」字段")
print("=" * 50)
try:
bitable.create_field(
APP_TOKEN,
TABLE_ID,
"需求图片",
17 # 17 = 附件类型
)
except Exception as e:
if "FieldNameExist" in str(e) or "FieldNameDuplicated" in str(e) or "1254043" in str(e) or "1254014" in str(e):
print("[INFO] 字段「需求图片」已存在,跳过创建")
else:
raise
# Step 3: 上传图片
print("\n" + "=" * 50)
print("Step 3: 上传图片到飞书")
print("=" * 50)
file_tokens = []
for img_path in image_paths:
file_token = bitable.upload_media(APP_TOKEN, img_path)
file_tokens.append(file_token)
# Step 4: 查找"完成产品需求文档"记录
print("\n" + "=" * 50)
print("Step 4: 查找目标记录")
print("=" * 50)
records = bitable.list_records(APP_TOKEN, TABLE_ID)
target_record = None
for record in records:
if record["fields"].get("任务名称") == "完成产品需求文档":
target_record = record
break
if not target_record:
raise Exception("未找到「完成产品需求文档」记录")
print(f"[OK] 找到记录: {target_record['record_id']}")
print(f" 任务名称: {target_record['fields'].get('任务名称')}")
# Step 5: 更新记录,添加图片
print("\n" + "=" * 50)
print("Step 5: 更新记录,添加图片附件")
print("=" * 50)
# 构造附件字段值
attachments = [{"file_token": ft} for ft in file_tokens]
bitable.update_record(
APP_TOKEN,
TABLE_ID,
target_record["record_id"],
{"需求图片": attachments}
)
# 完成
print("\n" + "=" * 60)
print("完成!")
print("=" * 60)
print(f"\n已添加 {len(file_tokens)} 张图片到「完成产品需求文档」记录")
print(f"\n访问地址查看效果:")
print(f" https://zhiyuncai.feishu.cn/base/{APP_TOKEN}?table={TABLE_ID}")
print()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,530 @@
#!/usr/bin/env python3
"""
ai-proj 与飞书项目同步脚本
功能:
- 初始化:创建飞书根目录、多维表格
- 全量同步:同步所有项目到飞书
- 增量同步:只同步新增项目
- 更新同步:更新现有项目的任务统计等信息
使用方法:
python aiproj_sync.py init # 首次初始化
python aiproj_sync.py sync # 增量同步(推荐日常使用)
python aiproj_sync.py sync-all # 全量同步
python aiproj_sync.py update # 更新统计信息
"""
import os
import sys
import json
import requests
from datetime import datetime
from typing import Optional, Dict, List, Any
# ============================================
# 配置
# ============================================
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
# 默认协作者 open_id邱栋梁@智云采购)
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
# ============================================
# 飞书 API
# ============================================
class FeishuAPI:
def __init__(self):
self.token = None
def get_token(self) -> str:
"""获取 tenant_access_token"""
if self.token:
return self.token
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": FEISHU_APP_ID,
"app_secret": FEISHU_APP_SECRET
})
data = response.json()
if data.get("code") == 0:
self.token = data["tenant_access_token"]
return self.token
else:
raise Exception(f"获取飞书 token 失败: {data}")
def _headers(self, content_type: bool = False) -> Dict:
headers = {"Authorization": f"Bearer {self.get_token()}"}
if content_type:
headers["Content-Type"] = "application/json"
return headers
def get_root_folder(self) -> str:
"""获取云空间根文件夹"""
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["token"]
raise Exception(f"获取根文件夹失败: {data}")
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
"""创建文件夹"""
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
response = requests.post(url, headers=self._headers(True), json={
"name": name,
"folder_token": parent_token
})
data = response.json()
if data.get("code") == 0:
folder_token = data["data"]["token"]
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
return folder_token
raise Exception(f"创建文件夹失败: {data}")
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
"""添加协作者
应用通过 API 创建的文件/文件夹,所有者是应用本身。
组织内用户默认无法访问,需要显式添加为协作者。
Args:
file_token: 文件/文件夹 token
file_type: 类型 (folder, doc, sheet, bitable 等)
user_open_id: 用户 open_id
perm: 权限级别 (full_access, edit, view)
"""
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
params = {"type": file_type, "need_notification": "false"}
payload = {
"member_type": "openid",
"member_id": user_open_id,
"perm": perm
}
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
return response.json().get("code") == 0
def create_bitable(self, name: str, folder_token: str) -> Dict:
"""创建多维表格"""
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
response = requests.post(url, headers=self._headers(True), json={
"name": name,
"folder_token": folder_token
})
data = response.json()
if data.get("code") == 0:
return data["data"]["app"]
raise Exception(f"创建多维表格失败: {data}")
def get_bitable_tables(self, app_token: str) -> List[Dict]:
"""获取数据表列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["items"]
raise Exception(f"获取数据表失败: {data}")
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
"""创建字段"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.post(url, headers=self._headers(True), json=field)
data = response.json()
if data.get("code") == 0:
return data["data"]["field"]
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
return None
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
"""更新字段"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
response = requests.put(url, headers=self._headers(True), json=updates)
return response.json().get("code") == 0
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
"""获取字段列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["items"]
return []
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
"""添加记录"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
data = response.json()
if data.get("code") == 0:
return data["data"]["record"]
print(f" 记录添加失败: {data.get('msg')}")
return None
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
"""获取记录列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
params = {"page_size": 500}
if filter_str:
params["filter"] = filter_str
response = requests.get(url, headers=self._headers(), params=params)
data = response.json()
if data.get("code") == 0:
return data["data"].get("items", [])
return []
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
"""更新记录"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
return response.json().get("code") == 0
# ============================================
# ai-proj API
# ============================================
class AIProjAPI:
@staticmethod
def get_projects() -> List[Dict]:
"""获取项目列表"""
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
response = requests.get(url, headers=headers)
data = response.json()
if data.get("success"):
return data["data"]["data"]
raise Exception(f"获取项目列表失败: {data}")
# ============================================
# 配置管理
# ============================================
def load_config() -> Optional[Dict]:
"""加载配置"""
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH) as f:
return json.load(f)
return None
def save_config(config: Dict):
"""保存配置"""
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
# ============================================
# 同步逻辑
# ============================================
def init():
"""初始化:创建飞书目录结构"""
print("=" * 50)
print("飞书 ai-proj 项目同步 - 初始化")
print("=" * 50)
if load_config():
print("\n已存在配置文件,是否重新初始化?(y/N)")
if input().lower() != 'y':
print("取消初始化")
return
feishu = FeishuAPI()
# 1. 获取根目录
print("\n[1/5] 获取云空间根目录...")
space_root = feishu.get_root_folder()
print(f" 根目录: {space_root}")
# 2. 创建 ai-proj 文件夹
print("\n[2/5] 创建 ai-proj 根文件夹...")
root_folder = feishu.create_folder("ai-proj", space_root)
print(f" 创建成功: {root_folder}")
# 3. 创建多维表格
print("\n[3/5] 创建项目目录多维表格...")
bitable = feishu.create_bitable("项目目录", root_folder)
app_token = bitable["app_token"]
print(f" 创建成功: {app_token}")
tables = feishu.get_bitable_tables(app_token)
table_id = tables[0]["table_id"]
# 4. 创建字段
print("\n[4/5] 创建数据表字段...")
# 先修改第一列名称
fields = feishu.get_fields(app_token, table_id)
if fields:
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
"field_name": "项目名称",
"type": fields[0]["type"]
})
print(" 修改字段: 项目名称")
# 创建其他字段
field_definitions = [
{"field_name": "项目ID", "type": 2},
{"field_name": "项目编号", "type": 1},
{"field_name": "飞书文件夹", "type": 15},
{"field_name": "状态", "type": 3, "property": {"options": [
{"name": "active", "color": 0},
{"name": "on_hold", "color": 1},
{"name": "planning", "color": 2},
{"name": "completed", "color": 3},
{"name": "archived", "color": 4}
]}},
{"field_name": "优先级", "type": 3, "property": {"options": [
{"name": "high", "color": 0},
{"name": "medium", "color": 1},
{"name": "low", "color": 2}
]}},
{"field_name": "所属企业", "type": 1},
{"field_name": "任务总数", "type": 2},
{"field_name": "描述", "type": 1},
{"field_name": "创建时间", "type": 5},
{"field_name": "最后同步", "type": 5},
]
for field in field_definitions:
if feishu.create_field(app_token, table_id, field):
print(f" 创建字段: {field['field_name']}")
# 5. 保存配置
print("\n[5/5] 保存配置...")
config = {
"root_folder_token": root_folder,
"bitable_app_token": app_token,
"bitable_table_id": table_id,
"created_at": datetime.now().isoformat(),
"synced_projects": {}
}
save_config(config)
print("\n" + "=" * 50)
print("初始化完成!")
print("=" * 50)
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
print(f"多维表格: https://feishu.cn/base/{app_token}")
print(f"\n配置文件: {CONFIG_PATH}")
print("\n运行 'python aiproj_sync.py sync' 同步项目")
def sync(full: bool = False):
"""同步项目"""
config = load_config()
if not config:
print("未找到配置,请先运行 init")
return
print("=" * 50)
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
print("=" * 50)
feishu = FeishuAPI()
app_token = config["bitable_app_token"]
table_id = config["bitable_table_id"]
root_folder = config["root_folder_token"]
synced = config.get("synced_projects", {})
# 获取 ai-proj 项目
print("\n获取 ai-proj 项目列表...")
projects = AIProjAPI.get_projects()
print(f"{len(projects)} 个项目")
# 筛选需要同步的项目
if full:
to_sync = projects
else:
to_sync = [p for p in projects if str(p["id"]) not in synced]
if not to_sync:
print("\n没有新项目需要同步")
return
print(f"\n需要同步 {len(to_sync)} 个项目...")
success = 0
for project in to_sync:
project_id = str(project["id"])
project_name = project["name"]
folder_name = f"{project_name}_{project_id}"
print(f"\n 处理: {project_name} (ID: {project_id})")
# 创建文件夹(如果不存在)
folder_token = synced.get(project_id, {}).get("folder_token")
if not folder_token:
try:
folder_token = feishu.create_folder(folder_name, root_folder)
print(f" 创建文件夹: {folder_token}")
except Exception as e:
print(f" 文件夹创建失败: {e}")
folder_token = ""
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
# 添加/更新记录
record_fields = {
"项目名称": project_name,
"项目ID": int(project_id),
"项目编号": project.get("project_number", ""),
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
"状态": project.get("status", "active"),
"优先级": project.get("priority", "medium"),
"所属企业": project.get("company_name", ""),
"任务总数": project.get("task_count", 0),
"描述": (project.get("description", "") or "")[:500],
"创建时间": int(datetime.fromisoformat(
project["created_at"].replace("Z", "+00:00")
).timestamp() * 1000),
"最后同步": int(datetime.now().timestamp() * 1000)
}
record_id = synced.get(project_id, {}).get("record_id")
if record_id and not full:
# 更新现有记录
if feishu.update_record(app_token, table_id, record_id, record_fields):
print(f" 更新记录成功")
success += 1
else:
# 添加新记录
record = feishu.add_record(app_token, table_id, record_fields)
if record:
record_id = record["record_id"]
print(f" 添加记录成功")
success += 1
# 更新同步记录
synced[project_id] = {
"folder_token": folder_token,
"folder_url": folder_url,
"record_id": record_id,
"synced_at": datetime.now().isoformat()
}
# 保存配置
config["synced_projects"] = synced
config["last_sync"] = datetime.now().isoformat()
save_config(config)
print("\n" + "=" * 50)
print(f"同步完成: {success}/{len(to_sync)}")
print("=" * 50)
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
def update_stats():
"""更新任务统计信息"""
config = load_config()
if not config:
print("未找到配置,请先运行 init")
return
print("=" * 50)
print("飞书 ai-proj 项目同步 - 更新统计")
print("=" * 50)
feishu = FeishuAPI()
app_token = config["bitable_app_token"]
table_id = config["bitable_table_id"]
synced = config.get("synced_projects", {})
# 获取最新项目数据
print("\n获取 ai-proj 项目列表...")
projects = AIProjAPI.get_projects()
project_map = {str(p["id"]): p for p in projects}
updated = 0
for project_id, sync_info in synced.items():
record_id = sync_info.get("record_id")
if not record_id:
continue
project = project_map.get(project_id)
if not project:
continue
# 只更新统计字段
update_fields = {
"任务总数": project.get("task_count", 0),
"状态": project.get("status", "active"),
"最后同步": int(datetime.now().timestamp() * 1000)
}
if feishu.update_record(app_token, table_id, record_id, update_fields):
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
updated += 1
config["last_sync"] = datetime.now().isoformat()
save_config(config)
print(f"\n更新完成: {updated} 个项目")
def show_status():
"""显示同步状态"""
config = load_config()
if not config:
print("未初始化,请先运行: python aiproj_sync.py init")
return
print("=" * 50)
print("ai-proj 飞书同步状态")
print("=" * 50)
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
print(f"上次同步: {config.get('last_sync', '从未')}")
print(f"配置文件: {CONFIG_PATH}")
# ============================================
# 主入口
# ============================================
def main():
if len(sys.argv) < 2:
print("用法: python aiproj_sync.py <command>")
print("\n命令:")
print(" init 首次初始化(创建目录和多维表格)")
print(" sync 增量同步(只同步新项目)")
print(" sync-all 全量同步(同步所有项目)")
print(" update 更新统计信息")
print(" status 查看同步状态")
return
command = sys.argv[1]
if command == "init":
init()
elif command == "sync":
sync(full=False)
elif command == "sync-all":
sync(full=True)
elif command == "update":
update_stats()
elif command == "status":
show_status()
else:
print(f"未知命令: {command}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
检查飞书云文档中的图片块状态
"""
import requests
from datetime import datetime, timedelta
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# 最近创建的测试文档
DOCUMENT_ID = "Z53YdDpezob1NPx63sQcsrt8nzd"
def get_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}")
return data["tenant_access_token"]
def get_document_blocks(document_id: str):
"""获取文档所有块"""
token = get_token()
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取块失败: {data}")
return data["data"].get("items", [])
def main():
print(f"\n检查文档: {DOCUMENT_ID}")
print("=" * 60)
blocks = get_document_blocks(DOCUMENT_ID)
print(f"\n文档共有 {len(blocks)} 个块:\n")
for i, block in enumerate(blocks):
block_type = block.get("block_type")
block_id = block.get("block_id")
# 块类型映射
type_names = {
1: "page",
2: "text",
3: "heading1",
4: "heading2",
5: "heading3",
12: "bullet",
13: "ordered",
14: "code",
17: "todo",
22: "divider",
27: "image",
}
type_name = type_names.get(block_type, f"type_{block_type}")
print(f" [{i}] block_type={block_type} ({type_name}), block_id={block_id[:20]}...")
# 如果是图片块,显示详细信息
if block_type == 27:
image_data = block.get("image", {})
print(f" image data: {image_data}")
# 检查图片是否有效
file_token = image_data.get("token") or image_data.get("file_token")
if file_token:
print(f" file_token: {file_token}")
# 尝试获取图片信息
check_image_status(file_token)
else:
print(f" [WARN] 图片块没有 token!")
def check_image_status(file_token: str):
"""检查图片状态"""
token = get_token()
# 尝试获取文件元信息
url = f"{BASE_URL}/drive/v1/medias/{file_token}"
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
data = response.json()
print(f" 图片状态: code={data.get('code')}, msg={data.get('msg')}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""检查文档中的图片块状态"""
import requests
from datetime import datetime, timedelta
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
def get_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
})
return response.json()["tenant_access_token"]
def main():
token = get_token()
headers = {"Authorization": f"Bearer {token}"}
url = f"{BASE_URL}/docx/v1/documents/{DOCUMENT_ID}/blocks"
response = requests.get(url, headers=headers)
data = response.json()
if data.get("code") != 0:
print(f"获取失败: {data}")
return
blocks = data["data"].get("items", [])
print(f"文档共有 {len(blocks)} 个块\n")
image_count = 0
for block in blocks:
if block.get("block_type") == 27:
image_count += 1
image_data = block.get("image", {})
token_value = image_data.get("token", "")
print(f"图片块 #{image_count}:")
print(f" block_id: {block.get('block_id')}")
print(f" token: {token_value if token_value else '(空)'}")
print(f" width: {image_data.get('width')}, height: {image_data.get('height')}")
print()
if image_count == 0:
print("文档中没有图片块")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,423 @@
#!/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"
_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 create_document(title: str):
"""创建文档"""
url = f"{BASE_URL}/docx/v1/documents"
response = requests.post(url, headers=headers(), json={"title": title})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建文档失败: {data}")
doc = data["data"]["document"]
document_id = doc["document_id"]
print(f"[OK] 文档创建成功: {document_id}")
return document_id
def set_document_permission(document_id: str):
"""设置文档权限为组织内可编辑"""
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
payload = {
"external_access_entity": "open",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"link_share_entity": "tenant_editable",
"invite_external": False
}
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
result = response.json()
if result.get("code") == 0:
print("[OK] 权限设置成功: 组织内可编辑")
return True
else:
print(f"[WARN] 权限设置: {result.get('msg')}")
return False
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 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}")
# Step 2: 上传图片
file_token = upload_image(file_path, block_id)
print(f" file_token: {file_token}")
# Step 3: 绑定图片到块
bind_image(document_id, block_id, file_token)
print(f" 绑定成功!")
# 等待处理
time.sleep(0.5)
return block_id, file_token
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: 创建新文档 ---")
doc_id = create_document("ai-proj 项目可见性手册")
set_document_permission(doc_id)
# 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(doc_id, doc_id, blocks_part1)
print(" 第一部分内容创建完成")
# 插入可见性示意图
print("\n--- Step 4: 插入可见性示意图 ---")
insert_image(doc_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(doc_id, doc_id, blocks_part2)
print(" 设置说明创建完成")
# 插入 UI 示意图
print("\n--- Step 6: 插入 UI 示意图 ---")
insert_image(doc_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(doc_id, doc_id, blocks_part3)
print("\n" + "=" * 60)
print("文档创建完成!")
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{doc_id}")
print("=" * 60)
# 验证图片
print("\n--- 验证图片状态 ---")
time.sleep(2) # 等待服务器处理
blocks = get_document_blocks(doc_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", "")
width = image_data.get("width", 0)
height = image_data.get("height", 0)
status = "✅ 有效" if token else "❌ 空"
print(f"图片 #{image_count}: {status} (token: {token[:15]}..., 尺寸: {width}x{height})")
if image_count == 0:
print("⚠️ 警告:文档中没有图片块")
elif image_count == 2:
print(f"\n✅ 成功上传 {image_count} 张图片")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
深入调试飞书云文档图片上传
"""
import requests
import os
from datetime import datetime, timedelta
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
_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 set_document_permission(document_id: str, editable: bool = True):
"""设置文档权限"""
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
payload = {
"external_access_entity": "open",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
"invite_external": False
}
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
return response.json().get("code") == 0
def create_document(title: str, editable: bool = True):
"""创建文档(自动设置为组织内可编辑)"""
url = f"{BASE_URL}/docx/v1/documents"
response = requests.post(url, headers=headers(), json={"title": title})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建文档失败: {data}")
doc = data["data"]["document"]
document_id = doc["document_id"]
print(f"[OK] 文档创建成功: {document_id}")
# 自动设置权限
if editable and set_document_permission(document_id, True):
print(f"[OK] 权限设置成功: 组织内可编辑")
return document_id
def get_raw_content(document_id: str):
"""获取文档原始内容"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/raw_content"
response = requests.get(url, headers=headers())
data = response.json()
print(f"[DEBUG] raw_content 响应: {data}")
return data
def get_blocks(document_id: str):
"""获取文档块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
# 尝试添加 document_revision_id 参数
params = {"document_revision_id": -1} # -1 表示最新版本
response = requests.get(url, headers=headers(), params=params)
data = response.json()
return data
def create_image_block(document_id: str):
"""创建图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
# 添加 document_revision_id 参数
params = {"document_revision_id": -1}
payload = {
"children": [{
"block_type": 27,
"image": {}
}]
}
response = requests.post(url, headers=headers(), params=params, json=payload)
data = response.json()
print(f"[DEBUG] 创建图片块响应: {data}")
if data.get("code") != 0:
raise Exception(f"创建图片块失败: {data}")
block_id = data["data"]["children"][0]["block_id"]
print(f"[OK] 图片块创建成功: {block_id}")
return 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()
print(f"[DEBUG] 上传响应: {result}")
if result.get("code") != 0:
raise Exception(f"上传失败: {result}")
return result["data"]["file_token"]
def bind_image_to_block(document_id: str, block_id: str, file_token: str):
"""
绑定图片到图片块 (关键步骤!)
使用 replace_image 字段通过 PATCH 请求绑定
"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
params = {"document_revision_id": -1}
# 正确的 payload: 使用 replace_image
payload = {
"replace_image": {
"token": file_token
}
}
print(f"[INFO] 绑定图片: PATCH {url}")
print(f"[INFO] payload: {payload}")
response = requests.patch(url, headers=headers(), params=params, json=payload)
result = response.json()
print(f"[DEBUG] 绑定响应: {result}")
if result.get("code") == 0:
print(f"[OK] 图片绑定成功!")
return True
else:
print(f"[ERROR] 图片绑定失败: code={result.get('code')}, msg={result.get('msg')}")
return False
def main():
print("\n" + "#" * 60)
print("# 深入调试飞书云文档图片上传")
print("#" * 60)
# 生成测试图片
from PIL import Image, ImageDraw
test_image = "/tmp/debug_test_image.png"
img = Image.new('RGB', (200, 150), color='#4a90d9')
draw = ImageDraw.Draw(img)
draw.text((100, 75), "Test", fill='white', anchor='mm')
img.save(test_image)
print(f"[OK] 测试图片: {test_image}")
# Step 1: 创建文档
print("\n--- Step 1: 创建文档 ---")
doc_id = create_document(f"调试图片上传 - {datetime.now().strftime('%H:%M:%S')}")
# Step 2: 创建图片块
print("\n--- Step 2: 创建图片块 ---")
block_id = create_image_block(doc_id)
# Step 3: 上传图片
print("\n--- Step 3: 上传图片 ---")
file_token = upload_image(test_image, block_id)
print(f"[OK] file_token: {file_token}")
# Step 4: 检查块状态
print("\n--- Step 4: 检查块状态 ---")
blocks = get_blocks(doc_id)
for item in blocks.get("data", {}).get("items", []):
if item.get("block_type") == 27:
print(f"[INFO] 图片块: {item}")
# Step 5: 绑定图片到图片块 (关键步骤!)
print("\n--- Step 5: 绑定图片到图片块 ---")
bind_image_to_block(doc_id, block_id, file_token)
# Step 6: 再次检查
print("\n--- Step 6: 再次检查块状态 ---")
import time
time.sleep(2) # 等待2秒
blocks = get_blocks(doc_id)
for item in blocks.get("data", {}).get("items", []):
if item.get("block_type") == 27:
print(f"[INFO] 图片块: {item}")
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""
飞书多维表格 Demo
使用 zhiyun.ai 凭证演示多维表格的完整操作流程
"""
import requests
from datetime import datetime, timedelta
from typing import Optional, List, Dict
# ========== 配置 ==========
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# ========== 工具类 ==========
class FeishuBitable:
"""飞书多维表格操作工具类"""
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
self.app_id = app_id
self.app_secret = app_secret
self._token = None
self._token_expires = None
@property
def token(self) -> str:
"""获取或刷新 access token"""
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": self.app_id,
"app_secret": self.app_secret
})
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取 token 失败: {data}")
self._token = data["tenant_access_token"]
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
print(f"[OK] Token 获取成功,有效期至 {self._token_expires}")
return self._token
@property
def headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
# ========== 多维表格管理 ==========
def create_bitable(self, name: str, folder_token: str = None) -> Dict:
"""
创建新的多维表格
Args:
name: 多维表格名称
folder_token: 文件夹 token可选不指定则创建在根目录
"""
url = f"{BASE_URL}/bitable/v1/apps"
payload = {"name": name}
if folder_token:
payload["folder_token"] = folder_token
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建多维表格失败: {data}")
app_info = data["data"]["app"]
print(f"[OK] 多维表格创建成功")
print(f" 名称: {app_info['name']}")
print(f" app_token: {app_info['app_token']}")
print(f" URL: {app_info.get('url', 'N/A')}")
return app_info
def list_tables(self, app_token: str) -> List[Dict]:
"""列出多维表格中的所有数据表"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
response = requests.get(url, headers=self.headers)
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取数据表列表失败: {data}")
tables = data["data"].get("items", [])
print(f"[OK] 找到 {len(tables)} 个数据表")
for t in tables:
print(f" - {t['name']} (table_id: {t['table_id']})")
return tables
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
"""
在多维表格中创建数据表
Args:
app_token: 多维表格 app_token
name: 数据表名称
fields: 字段定义列表
"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
payload = {
"table": {
"name": name,
"default_view_name": "默认视图",
"fields": fields
}
}
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建数据表失败: {data}")
table_info = data["data"]
print(f"[OK] 数据表创建成功")
print(f" 名称: {name}")
print(f" table_id: {table_info['table_id']}")
return table_info
# ========== 记录操作 ==========
def list_records(self, app_token: str, table_id: str,
filter_str: str = None, page_size: int = 100) -> List[Dict]:
"""列出所有记录(自动分页)"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
all_records = []
page_token = None
while True:
params = {"page_size": min(page_size, 500)}
if page_token:
params["page_token"] = page_token
if filter_str:
params["filter"] = filter_str
response = requests.get(url, headers=self.headers, params=params)
data = response.json()
if data.get("code") != 0:
raise Exception(f"查询失败: {data}")
items = data["data"].get("items", [])
all_records.extend(items)
if not data["data"].get("has_more"):
break
page_token = data["data"]["page_token"]
return all_records
def create_record(self, app_token: str, table_id: str, fields: Dict) -> Dict:
"""创建单条记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
response = requests.post(url, headers=self.headers, json={"fields": fields})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建失败: {data}")
return data["data"]["record"]
def batch_create(self, app_token: str, table_id: str,
records: List[Dict], batch_size: int = 500) -> List[Dict]:
"""批量创建记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
created = []
for i in range(0, len(records), batch_size):
batch = [{"fields": r} if "fields" not in r else r for r in records[i:i+batch_size]]
response = requests.post(url, headers=self.headers, json={"records": batch})
data = response.json()
if data.get("code") != 0:
raise Exception(f"批量创建失败: {data}")
created.extend(data["data"]["records"])
return created
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
"""获取字段定义"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.get(url, headers=self.headers)
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取字段失败: {data}")
return data["data"]["items"]
# ========== Demo 函数 ==========
def demo_verify_credentials():
"""验证凭证是否有效"""
print("\n" + "="*50)
print("Step 1: 验证飞书应用凭证")
print("="*50)
bitable = FeishuBitable()
token = bitable.token # 触发 token 获取
print(f" Token 前缀: {token[:20]}...")
return bitable
def demo_create_bitable(bitable: FeishuBitable) -> str:
"""创建新的多维表格"""
print("\n" + "="*50)
print("Step 2: 创建多维表格")
print("="*50)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
name = f"Claude Code Demo - {timestamp}"
app_info = bitable.create_bitable(name)
return app_info["app_token"]
def demo_create_task_table(bitable: FeishuBitable, app_token: str) -> str:
"""创建任务管理数据表"""
print("\n" + "="*50)
print("Step 3: 创建任务管理数据表")
print("="*50)
# 定义字段
fields = [
{
"field_name": "任务名称",
"type": 1 # 文本
},
{
"field_name": "状态",
"type": 3, # 单选
"property": {
"options": [
{"name": "待处理", "color": 0},
{"name": "进行中", "color": 1},
{"name": "已完成", "color": 2},
{"name": "已取消", "color": 3}
]
}
},
{
"field_name": "优先级",
"type": 3, # 单选
"property": {
"options": [
{"name": "", "color": 4},
{"name": "", "color": 5},
{"name": "", "color": 6}
]
}
},
{
"field_name": "负责人",
"type": 1 # 文本
},
{
"field_name": "截止日期",
"type": 5, # 日期
"property": {
"date_formatter": "yyyy/MM/dd"
}
},
{
"field_name": "工时(小时)",
"type": 2 # 数字
},
{
"field_name": "备注",
"type": 1 # 文本
}
]
table_info = bitable.create_table(app_token, "任务看板", fields)
return table_info["table_id"]
def demo_add_sample_data(bitable: FeishuBitable, app_token: str, table_id: str):
"""添加示例数据"""
print("\n" + "="*50)
print("Step 4: 添加示例数据")
print("="*50)
# 准备示例数据
now = datetime.now()
sample_tasks = [
{
"任务名称": "完成产品需求文档",
"状态": "已完成",
"优先级": "",
"负责人": "张三",
"截止日期": int((now - timedelta(days=2)).timestamp() * 1000),
"工时(小时)": 8,
"备注": "PRD 已评审通过"
},
{
"任务名称": "设计系统架构方案",
"状态": "进行中",
"优先级": "",
"负责人": "李四",
"截止日期": int((now + timedelta(days=3)).timestamp() * 1000),
"工时(小时)": 16,
"备注": "正在编写技术方案"
},
{
"任务名称": "开发用户登录模块",
"状态": "待处理",
"优先级": "",
"负责人": "王五",
"截止日期": int((now + timedelta(days=7)).timestamp() * 1000),
"工时(小时)": 24,
"备注": "等待架构方案确定"
},
{
"任务名称": "编写单元测试",
"状态": "待处理",
"优先级": "",
"负责人": "赵六",
"截止日期": int((now + timedelta(days=10)).timestamp() * 1000),
"工时(小时)": 12,
"备注": ""
},
{
"任务名称": "部署测试环境",
"状态": "待处理",
"优先级": "",
"负责人": "钱七",
"截止日期": int((now + timedelta(days=14)).timestamp() * 1000),
"工时(小时)": 4,
"备注": "需要申请服务器资源"
}
]
# 批量创建记录
created = bitable.batch_create(app_token, table_id, sample_tasks)
print(f"[OK] 成功创建 {len(created)} 条示例记录")
# 显示创建的记录
for i, record in enumerate(created, 1):
fields = record["fields"]
print(f" {i}. {fields.get('任务名称')} - {fields.get('状态')} ({fields.get('优先级')}优先级)")
return created
def demo_query_data(bitable: FeishuBitable, app_token: str, table_id: str):
"""查询数据演示"""
print("\n" + "="*50)
print("Step 5: 查询数据演示")
print("="*50)
# 查询所有记录
all_records = bitable.list_records(app_token, table_id)
print(f"[OK] 共 {len(all_records)} 条记录")
# 获取字段定义
fields = bitable.get_fields(app_token, table_id)
print(f"[OK] 共 {len(fields)} 个字段:")
for f in fields:
print(f" - {f['field_name']} (类型: {f['type']})")
return all_records
def main():
"""运行完整 Demo"""
print("\n" + "#"*60)
print("# 飞书多维表格 Demo - zhiyun.ai")
print("#"*60)
try:
# Step 1: 验证凭证
bitable = demo_verify_credentials()
# Step 2: 创建多维表格
app_token = demo_create_bitable(bitable)
# Step 3: 创建数据表
table_id = demo_create_task_table(bitable, app_token)
# Step 4: 添加示例数据
demo_add_sample_data(bitable, app_token, table_id)
# Step 5: 查询数据
demo_query_data(bitable, app_token, table_id)
# 完成
print("\n" + "="*60)
print("Demo 完成!")
print("="*60)
print(f"\n多维表格信息:")
print(f" app_token: {app_token}")
print(f" table_id: {table_id}")
print(f"\n访问地址:")
print(f" https://zhiyun-ai.feishu.cn/base/{app_token}?table={table_id}")
print()
return app_token, table_id
except Exception as e:
print(f"\n[ERROR] {e}")
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,451 @@
#!/usr/bin/env python3
"""
飞书云文档操作工具类
包含文档创建、内容块管理、图片上传等功能
"""
import os
import requests
from datetime import datetime, timedelta
from typing import Dict, List, Optional
class FeishuDocx:
"""飞书云文档操作工具类(含图片上传)"""
BASE_URL = "https://open.feishu.cn/open-apis"
DEFAULT_APP_ID = "cli_a9f29dca82b9dbef"
DEFAULT_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
def __init__(self, app_id: str = None, app_secret: str = None):
self.app_id = app_id or os.environ.get("FEISHU_APP_ID") or self.DEFAULT_APP_ID
self.app_secret = app_secret or os.environ.get("FEISHU_APP_SECRET") or self.DEFAULT_APP_SECRET
self._token = None
self._token_expires = None
@property
def token(self) -> str:
"""获取或刷新 access token"""
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
url = f"{self.BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": self.app_id,
"app_secret": self.app_secret
})
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取 token 失败: {data}")
self._token = data["tenant_access_token"]
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
return self._token
@property
def headers(self) -> dict:
"""获取请求头"""
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
# ==================== 文档管理 ====================
def set_document_permission(self, document_id: str, editable: bool = True) -> bool:
"""
设置文档权限
Args:
document_id: 文档ID
editable: True=组织内可编辑, False=组织内只读
"""
url = f"{self.BASE_URL}/drive/v1/permissions/{document_id}/public"
payload = {
"external_access_entity": "open",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
"invite_external": False
}
response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload)
return response.json().get("code") == 0
def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict:
"""
创建文档(自动设置为组织内可编辑)
Args:
title: 文档标题
folder_token: 文件夹token (可选)
editable: 是否设置为组织内可编辑 (默认True)
Returns:
dict: {"document_id": "xxx", "title": "xxx", "url": "xxx"}
"""
url = f"{self.BASE_URL}/docx/v1/documents"
payload = {"title": title}
if folder_token:
payload["folder_token"] = folder_token
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建文档失败: {data}")
doc = data["data"]["document"]
document_id = doc["document_id"]
if editable:
self.set_document_permission(document_id, editable=True)
return {
"document_id": document_id,
"title": doc["title"],
"url": f"https://feishu.cn/docx/{document_id}"
}
def get_document_blocks(self, document_id: str, page_size: int = 500) -> List[Dict]:
"""获取文档所有内容块"""
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks"
all_blocks = []
page_token = None
while True:
params = {"page_size": page_size}
if page_token:
params["page_token"] = page_token
response = requests.get(url, headers=self.headers, params=params)
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取内容块失败: {data}")
all_blocks.extend(data["data"].get("items", []))
if not data["data"].get("has_more"):
break
page_token = data["data"]["page_token"]
return all_blocks
# ==================== 内容块操作 ====================
def create_blocks(self, document_id: str, blocks: list, parent_id: str = None) -> List[Dict]:
"""
创建内容块
Args:
document_id: 文档ID
blocks: 内容块列表
parent_id: 父块ID (默认为文档根块)
"""
parent_id = parent_id or document_id
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
response = requests.post(
url,
headers=self.headers,
params={"document_revision_id": -1},
json={"children": blocks}
)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建内容块失败: {data}")
return data["data"]["children"]
# ==================== 图片上传 (三步流程) ====================
def create_empty_image_block(self, document_id: str, parent_id: str = None) -> str:
"""
Step 1: 创建空图片块
Args:
document_id: 文档ID
parent_id: 父块ID (默认为文档根块)
Returns:
block_id: 图片块ID
"""
parent_id = parent_id or document_id
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
payload = {
"children": [{
"block_type": 27,
"image": {}
}]
}
response = requests.post(
url,
headers=self.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_to_block(self, file_path: str, block_id: str) -> str:
"""
Step 2: 上传图片文件
Args:
file_path: 本地图片路径
block_id: 图片块ID (从 Step 1 获取)
Returns:
file_token: 图片token
"""
url = f"{self.BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
# 根据文件扩展名确定 MIME 类型
ext = os.path.splitext(file_path)[1].lower()
mime_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
}
mime_type = mime_types.get(ext, 'image/png')
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, mime_type)}
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 {self.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_to_block(self, document_id: str, block_id: str, file_token: str) -> bool:
"""
Step 3: 绑定图片到图片块 (关键步骤!)
Args:
document_id: 文档ID
block_id: 图片块ID
file_token: 图片token (从 Step 2 获取)
Returns:
bool: 是否成功
"""
url = f"{self.BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
payload = {
"replace_image": {
"token": file_token
}
}
response = requests.patch(
url,
headers=self.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(self, document_id: str, file_path: str, parent_id: str = None) -> dict:
"""
插入图片到文档 (封装三步流程)
Args:
document_id: 文档ID
file_path: 本地图片路径
parent_id: 父块ID (默认为文档根块)
Returns:
dict: {"block_id": "xxx", "file_token": "xxx"}
"""
# Step 1: 创建空图片块
block_id = self.create_empty_image_block(document_id, parent_id)
# Step 2: 上传图片
file_token = self.upload_image_to_block(file_path, block_id)
# Step 3: 绑定图片
self.bind_image_to_block(document_id, block_id, file_token)
return {"block_id": block_id, "file_token": file_token}
def check_image_status(self, document_id: str) -> List[Dict]:
"""
检查文档中图片块的状态
Returns:
List[Dict]: 图片块状态列表
"""
blocks = self.get_document_blocks(document_id)
images = []
for block in blocks:
if block.get("block_type") == 27:
image_data = block.get("image", {})
images.append({
"block_id": block.get("block_id"),
"token": image_data.get("token", ""),
"width": image_data.get("width", 0),
"height": image_data.get("height", 0),
"is_valid": bool(image_data.get("token"))
})
return images
# ==================== 内容块构建器 ====================
@staticmethod
def heading1(text: str) -> dict:
"""一级标题"""
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}}
@staticmethod
def heading2(text: str) -> dict:
"""二级标题"""
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}}
@staticmethod
def heading3(text: str) -> dict:
"""三级标题"""
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}}
@staticmethod
def text(content: str) -> dict:
"""文本段落"""
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
@staticmethod
def bullet(content: str) -> dict:
"""无序列表项"""
return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}}
@staticmethod
def ordered(content: str) -> dict:
"""有序列表项"""
return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}}
@staticmethod
def code(content: str, language: int = 1) -> dict:
"""代码块 (language: 1=JSON, 4=Python, 等)"""
return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}}
@staticmethod
def divider() -> dict:
"""分割线"""
return {"block_type": 22, "divider": {}}
@staticmethod
def todo(content: str, done: bool = False) -> dict:
"""待办事项"""
return {"block_type": 17, "todo": {"elements": [{"text_run": {"content": content}}], "style": {"done": done}}}
# ==================== 高级功能 ====================
def create_meeting_minutes(self, title: str, content: dict) -> dict:
"""
创建会议纪要
Args:
title: 文档标题
content: {
"summary": "会议摘要",
"points": [{"title": "...", "items": ["..."]}],
"todos": [{"assignee": "...", "task": "..."}]
}
Returns:
dict: 文档信息
"""
doc = self.create_document(title)
document_id = doc["document_id"]
blocks = []
if content.get("summary"):
blocks.append(self.heading2("会议摘要"))
blocks.append(self.text(content["summary"]))
blocks.append(self.divider())
if content.get("points"):
blocks.append(self.heading2("小结"))
for point in content["points"]:
blocks.append(self.heading3(point["title"]))
for item in point.get("items", []):
blocks.append(self.bullet(item))
blocks.append(self.divider())
if content.get("todos"):
blocks.append(self.heading2("待办事项"))
for todo_item in content["todos"]:
text = f"{todo_item['assignee']}: {todo_item['task']}"
blocks.append(self.todo(text))
self.create_blocks(document_id, blocks)
return doc
# ==================== 使用示例 ====================
if __name__ == "__main__":
docx = FeishuDocx()
# 示例1: 创建文档并插入图片
print("=== 示例1: 创建带图片的文档 ===")
doc = docx.create_document("图片上传测试文档")
print(f"文档创建成功: {doc['url']}")
# 如果有测试图片,取消注释以下代码
# result = docx.insert_image(doc["document_id"], "/path/to/image.png")
# print(f"图片插入成功: {result}")
# 示例2: 创建会议纪要
print("\n=== 示例2: 创建会议纪要 ===")
doc = docx.create_meeting_minutes(
"测试会议纪要",
{
"summary": "本次会议讨论了项目进度...",
"points": [{"title": "进度汇报", "items": ["任务A已完成", "任务B进行中"]}],
"todos": [{"assignee": "张三", "task": "完成文档编写"}]
}
)
print(f"会议纪要已创建: {doc['url']}")
# 示例3: 检查图片状态
print("\n=== 示例3: 检查图片状态 ===")
images = docx.check_image_status(doc["document_id"])
if images:
for img in images:
status = "✅ 有效" if img["is_valid"] else "❌ 空"
print(f"图片: {status}, 尺寸: {img['width']}x{img['height']}")
else:
print("文档中没有图片")

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""
WPS .dbt 文件迁移到飞书多维表格
"""
import pandas as pd
import requests
import json
import re
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
# ========== 配置 ==========
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# 源文件
SOURCE_FILE = "/Users/donglinlai/Downloads/酷采团购系统优化进度表.dbt.xlsx"
class FeishuBitable:
"""飞书多维表格操作工具类"""
def __init__(self):
self._token = None
self._token_expires = None
@property
def token(self) -> str:
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._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}")
self._token = data["tenant_access_token"]
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
return self._token
@property
def headers(self):
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
def create_bitable(self, name: str) -> Dict:
"""创建多维表格"""
url = f"{BASE_URL}/bitable/v1/apps"
response = requests.post(url, headers=self.headers, json={"name": name})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建多维表格失败: {data}")
return data["data"]["app"]
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
"""创建数据表"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
payload = {
"table": {
"name": name,
"default_view_name": "默认视图",
"fields": fields
}
}
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建数据表失败: {data}")
return data["data"]
def batch_create_records(self, app_token: str, table_id: str,
records: List[Dict], batch_size: int = 100) -> int:
"""批量创建记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
total_created = 0
for i in range(0, len(records), batch_size):
batch = records[i:i+batch_size]
payload = {"records": [{"fields": r} for r in batch]}
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
print(f" [WARN] 批次 {i//batch_size + 1} 部分失败: {data.get('msg', '')}")
# 尝试逐条插入
for record in batch:
try:
single_url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
single_resp = requests.post(single_url, headers=self.headers, json={"fields": record})
if single_resp.json().get("code") == 0:
total_created += 1
except:
pass
else:
total_created += len(data["data"]["records"])
return total_created
def analyze_column_type(series: pd.Series, col_name: str) -> Dict:
"""分析列的数据类型,返回飞书字段定义"""
col_lower = col_name.lower()
# 根据列名判断类型
if any(kw in col_lower for kw in ['日期', '时间', 'date', 'time', '提出时间', '发版日期', '更新时间']):
return {"field_name": col_name, "type": 5, "property": {"date_formatter": "yyyy/MM/dd"}}
if any(kw in col_lower for kw in ['图片', '附件', '截图', 'image', 'file', 'attachment']):
return {"field_name": col_name, "type": 1} # 作为文本处理
if any(kw in col_lower for kw in ['优先级', '状态', '类型', '分类', '终端', '严重程度']):
# 提取唯一值作为选项
unique_vals = series.dropna().astype(str).unique()
unique_vals = [v for v in unique_vals if v and v != 'nan' and len(v) < 50][:20]
if len(unique_vals) > 0 and len(unique_vals) <= 20:
return {
"field_name": col_name,
"type": 3, # 单选
"property": {
"options": [{"name": str(v)} for v in unique_vals]
}
}
if any(kw in col_lower for kw in ['进度', '百分比', '%']):
return {"field_name": col_name, "type": 2} # 数字
# 检查是否为数字列
try:
numeric_vals = pd.to_numeric(series.dropna(), errors='coerce')
if numeric_vals.notna().sum() / max(len(series.dropna()), 1) > 0.8:
return {"field_name": col_name, "type": 2} # 数字
except:
pass
# 默认为文本
return {"field_name": col_name, "type": 1}
def clean_value(val: Any, field_type: int) -> Any:
"""清理和转换值"""
if pd.isna(val) or val is None:
return None
if field_type == 5: # 日期
try:
if isinstance(val, (datetime, pd.Timestamp)):
return int(val.timestamp() * 1000)
elif isinstance(val, str):
dt = pd.to_datetime(val)
return int(dt.timestamp() * 1000)
except:
return None
if field_type == 2: # 数字
try:
return float(val)
except:
return None
if field_type == 3: # 单选
val_str = str(val).strip()
if val_str and val_str != 'nan':
return val_str
return None
# 文本类型
val_str = str(val).strip()
if val_str == 'nan' or not val_str:
return None
# 限制文本长度
if len(val_str) > 10000:
val_str = val_str[:10000] + "..."
return val_str
def migrate_sheet(bitable: FeishuBitable, app_token: str,
df: pd.DataFrame, sheet_name: str) -> str:
"""迁移单个 Sheet 到数据表"""
print(f"\n{'='*50}")
print(f"迁移 Sheet: 【{sheet_name}")
print(f"{'='*50}")
# 清理列名
df.columns = [str(c).strip() for c in df.columns]
# 去除完全空的行
df = df.dropna(how='all')
print(f" 数据: {len(df)} 行, {len(df.columns)}")
# 分析字段类型
fields = []
field_types = {}
for col in df.columns:
if not col or col.startswith('Unnamed'):
continue
field_def = analyze_column_type(df[col], col)
fields.append(field_def)
field_types[col] = field_def["type"]
print(f" 字段: {len(fields)}")
# 创建数据表
table_info = bitable.create_table(app_token, sheet_name, fields)
table_id = table_info["table_id"]
print(f" [OK] 数据表创建成功: {table_id}")
# 准备记录数据
records = []
for _, row in df.iterrows():
record = {}
for col in df.columns:
if not col or col.startswith('Unnamed'):
continue
val = clean_value(row[col], field_types.get(col, 1))
if val is not None:
record[col] = val
if record: # 只添加非空记录
records.append(record)
print(f" 准备导入 {len(records)} 条记录...")
# 批量创建记录
if records:
created = bitable.batch_create_records(app_token, table_id, records)
print(f" [OK] 成功导入 {created}/{len(records)} 条记录")
else:
print(f" [INFO] 无有效数据")
return table_id
def main():
print("\n" + "#" * 60)
print("# WPS 文件迁移到飞书多维表格")
print("#" * 60)
bitable = FeishuBitable()
# Step 1: 读取源文件
print("\n" + "=" * 50)
print("Step 1: 读取源文件")
print("=" * 50)
xlsx = pd.ExcelFile(SOURCE_FILE)
print(f" 文件: {SOURCE_FILE}")
print(f" Sheet 数量: {len(xlsx.sheet_names)}")
# Step 2: 创建多维表格
print("\n" + "=" * 50)
print("Step 2: 创建飞书多维表格")
print("=" * 50)
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
bitable_name = f"酷采团购系统优化进度表 (迁移 {timestamp})"
app_info = bitable.create_bitable(bitable_name)
app_token = app_info["app_token"]
print(f" [OK] 多维表格创建成功")
print(f" 名称: {bitable_name}")
print(f" app_token: {app_token}")
# Step 3: 迁移每个 Sheet
print("\n" + "=" * 50)
print("Step 3: 迁移数据表")
print("=" * 50)
table_ids = {}
for sheet_name in xlsx.sheet_names:
df = pd.read_excel(xlsx, sheet_name=sheet_name)
table_id = migrate_sheet(bitable, app_token, df, sheet_name)
table_ids[sheet_name] = table_id
# 完成
print("\n" + "=" * 60)
print("迁移完成!")
print("=" * 60)
print(f"\n多维表格信息:")
print(f" 名称: {bitable_name}")
print(f" app_token: {app_token}")
print(f"\n数据表:")
for name, tid in table_ids.items():
print(f" - {name}: {tid}")
print(f"\n访问地址:")
print(f" https://zhiyuncai.feishu.cn/base/{app_token}")
print()
return app_token, table_ids
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,449 @@
#!/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()

View File

@@ -0,0 +1,530 @@
#!/usr/bin/env python3
"""
ai-proj 与飞书项目同步脚本
功能:
- 初始化:创建飞书根目录、多维表格
- 全量同步:同步所有项目到飞书
- 增量同步:只同步新增项目
- 更新同步:更新现有项目的任务统计等信息
使用方法:
python aiproj_sync.py init # 首次初始化
python aiproj_sync.py sync # 增量同步(推荐日常使用)
python aiproj_sync.py sync-all # 全量同步
python aiproj_sync.py update # 更新统计信息
"""
import os
import sys
import json
import requests
from datetime import datetime
from typing import Optional, Dict, List, Any
# ============================================
# 配置
# ============================================
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
# 默认协作者 open_id邱栋梁@智云采购)
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
# ============================================
# 飞书 API
# ============================================
class FeishuAPI:
def __init__(self):
self.token = None
def get_token(self) -> str:
"""获取 tenant_access_token"""
if self.token:
return self.token
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": FEISHU_APP_ID,
"app_secret": FEISHU_APP_SECRET
})
data = response.json()
if data.get("code") == 0:
self.token = data["tenant_access_token"]
return self.token
else:
raise Exception(f"获取飞书 token 失败: {data}")
def _headers(self, content_type: bool = False) -> Dict:
headers = {"Authorization": f"Bearer {self.get_token()}"}
if content_type:
headers["Content-Type"] = "application/json"
return headers
def get_root_folder(self) -> str:
"""获取云空间根文件夹"""
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["token"]
raise Exception(f"获取根文件夹失败: {data}")
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
"""创建文件夹"""
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
response = requests.post(url, headers=self._headers(True), json={
"name": name,
"folder_token": parent_token
})
data = response.json()
if data.get("code") == 0:
folder_token = data["data"]["token"]
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
return folder_token
raise Exception(f"创建文件夹失败: {data}")
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
"""添加协作者
应用通过 API 创建的文件/文件夹,所有者是应用本身。
组织内用户默认无法访问,需要显式添加为协作者。
Args:
file_token: 文件/文件夹 token
file_type: 类型 (folder, doc, sheet, bitable 等)
user_open_id: 用户 open_id
perm: 权限级别 (full_access, edit, view)
"""
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
params = {"type": file_type, "need_notification": "false"}
payload = {
"member_type": "openid",
"member_id": user_open_id,
"perm": perm
}
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
return response.json().get("code") == 0
def create_bitable(self, name: str, folder_token: str) -> Dict:
"""创建多维表格"""
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
response = requests.post(url, headers=self._headers(True), json={
"name": name,
"folder_token": folder_token
})
data = response.json()
if data.get("code") == 0:
return data["data"]["app"]
raise Exception(f"创建多维表格失败: {data}")
def get_bitable_tables(self, app_token: str) -> List[Dict]:
"""获取数据表列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["items"]
raise Exception(f"获取数据表失败: {data}")
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
"""创建字段"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.post(url, headers=self._headers(True), json=field)
data = response.json()
if data.get("code") == 0:
return data["data"]["field"]
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
return None
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
"""更新字段"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
response = requests.put(url, headers=self._headers(True), json=updates)
return response.json().get("code") == 0
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
"""获取字段列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["items"]
return []
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
"""添加记录"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
data = response.json()
if data.get("code") == 0:
return data["data"]["record"]
print(f" 记录添加失败: {data.get('msg')}")
return None
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
"""获取记录列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
params = {"page_size": 500}
if filter_str:
params["filter"] = filter_str
response = requests.get(url, headers=self._headers(), params=params)
data = response.json()
if data.get("code") == 0:
return data["data"].get("items", [])
return []
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
"""更新记录"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
return response.json().get("code") == 0
# ============================================
# ai-proj API
# ============================================
class AIProjAPI:
@staticmethod
def get_projects() -> List[Dict]:
"""获取项目列表"""
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
response = requests.get(url, headers=headers)
data = response.json()
if data.get("success"):
return data["data"]["data"]
raise Exception(f"获取项目列表失败: {data}")
# ============================================
# 配置管理
# ============================================
def load_config() -> Optional[Dict]:
"""加载配置"""
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH) as f:
return json.load(f)
return None
def save_config(config: Dict):
"""保存配置"""
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
# ============================================
# 同步逻辑
# ============================================
def init():
"""初始化:创建飞书目录结构"""
print("=" * 50)
print("飞书 ai-proj 项目同步 - 初始化")
print("=" * 50)
if load_config():
print("\n已存在配置文件,是否重新初始化?(y/N)")
if input().lower() != 'y':
print("取消初始化")
return
feishu = FeishuAPI()
# 1. 获取根目录
print("\n[1/5] 获取云空间根目录...")
space_root = feishu.get_root_folder()
print(f" 根目录: {space_root}")
# 2. 创建 ai-proj 文件夹
print("\n[2/5] 创建 ai-proj 根文件夹...")
root_folder = feishu.create_folder("ai-proj", space_root)
print(f" 创建成功: {root_folder}")
# 3. 创建多维表格
print("\n[3/5] 创建项目目录多维表格...")
bitable = feishu.create_bitable("项目目录", root_folder)
app_token = bitable["app_token"]
print(f" 创建成功: {app_token}")
tables = feishu.get_bitable_tables(app_token)
table_id = tables[0]["table_id"]
# 4. 创建字段
print("\n[4/5] 创建数据表字段...")
# 先修改第一列名称
fields = feishu.get_fields(app_token, table_id)
if fields:
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
"field_name": "项目名称",
"type": fields[0]["type"]
})
print(" 修改字段: 项目名称")
# 创建其他字段
field_definitions = [
{"field_name": "项目ID", "type": 2},
{"field_name": "项目编号", "type": 1},
{"field_name": "飞书文件夹", "type": 15},
{"field_name": "状态", "type": 3, "property": {"options": [
{"name": "active", "color": 0},
{"name": "on_hold", "color": 1},
{"name": "planning", "color": 2},
{"name": "completed", "color": 3},
{"name": "archived", "color": 4}
]}},
{"field_name": "优先级", "type": 3, "property": {"options": [
{"name": "high", "color": 0},
{"name": "medium", "color": 1},
{"name": "low", "color": 2}
]}},
{"field_name": "所属企业", "type": 1},
{"field_name": "任务总数", "type": 2},
{"field_name": "描述", "type": 1},
{"field_name": "创建时间", "type": 5},
{"field_name": "最后同步", "type": 5},
]
for field in field_definitions:
if feishu.create_field(app_token, table_id, field):
print(f" 创建字段: {field['field_name']}")
# 5. 保存配置
print("\n[5/5] 保存配置...")
config = {
"root_folder_token": root_folder,
"bitable_app_token": app_token,
"bitable_table_id": table_id,
"created_at": datetime.now().isoformat(),
"synced_projects": {}
}
save_config(config)
print("\n" + "=" * 50)
print("初始化完成!")
print("=" * 50)
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
print(f"多维表格: https://feishu.cn/base/{app_token}")
print(f"\n配置文件: {CONFIG_PATH}")
print("\n运行 'python aiproj_sync.py sync' 同步项目")
def sync(full: bool = False):
"""同步项目"""
config = load_config()
if not config:
print("未找到配置,请先运行 init")
return
print("=" * 50)
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
print("=" * 50)
feishu = FeishuAPI()
app_token = config["bitable_app_token"]
table_id = config["bitable_table_id"]
root_folder = config["root_folder_token"]
synced = config.get("synced_projects", {})
# 获取 ai-proj 项目
print("\n获取 ai-proj 项目列表...")
projects = AIProjAPI.get_projects()
print(f"{len(projects)} 个项目")
# 筛选需要同步的项目
if full:
to_sync = projects
else:
to_sync = [p for p in projects if str(p["id"]) not in synced]
if not to_sync:
print("\n没有新项目需要同步")
return
print(f"\n需要同步 {len(to_sync)} 个项目...")
success = 0
for project in to_sync:
project_id = str(project["id"])
project_name = project["name"]
folder_name = f"{project_name}_{project_id}"
print(f"\n 处理: {project_name} (ID: {project_id})")
# 创建文件夹(如果不存在)
folder_token = synced.get(project_id, {}).get("folder_token")
if not folder_token:
try:
folder_token = feishu.create_folder(folder_name, root_folder)
print(f" 创建文件夹: {folder_token}")
except Exception as e:
print(f" 文件夹创建失败: {e}")
folder_token = ""
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
# 添加/更新记录
record_fields = {
"项目名称": project_name,
"项目ID": int(project_id),
"项目编号": project.get("project_number", ""),
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
"状态": project.get("status", "active"),
"优先级": project.get("priority", "medium"),
"所属企业": project.get("company_name", ""),
"任务总数": project.get("task_count", 0),
"描述": (project.get("description", "") or "")[:500],
"创建时间": int(datetime.fromisoformat(
project["created_at"].replace("Z", "+00:00")
).timestamp() * 1000),
"最后同步": int(datetime.now().timestamp() * 1000)
}
record_id = synced.get(project_id, {}).get("record_id")
if record_id and not full:
# 更新现有记录
if feishu.update_record(app_token, table_id, record_id, record_fields):
print(f" 更新记录成功")
success += 1
else:
# 添加新记录
record = feishu.add_record(app_token, table_id, record_fields)
if record:
record_id = record["record_id"]
print(f" 添加记录成功")
success += 1
# 更新同步记录
synced[project_id] = {
"folder_token": folder_token,
"folder_url": folder_url,
"record_id": record_id,
"synced_at": datetime.now().isoformat()
}
# 保存配置
config["synced_projects"] = synced
config["last_sync"] = datetime.now().isoformat()
save_config(config)
print("\n" + "=" * 50)
print(f"同步完成: {success}/{len(to_sync)}")
print("=" * 50)
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
def update_stats():
"""更新任务统计信息"""
config = load_config()
if not config:
print("未找到配置,请先运行 init")
return
print("=" * 50)
print("飞书 ai-proj 项目同步 - 更新统计")
print("=" * 50)
feishu = FeishuAPI()
app_token = config["bitable_app_token"]
table_id = config["bitable_table_id"]
synced = config.get("synced_projects", {})
# 获取最新项目数据
print("\n获取 ai-proj 项目列表...")
projects = AIProjAPI.get_projects()
project_map = {str(p["id"]): p for p in projects}
updated = 0
for project_id, sync_info in synced.items():
record_id = sync_info.get("record_id")
if not record_id:
continue
project = project_map.get(project_id)
if not project:
continue
# 只更新统计字段
update_fields = {
"任务总数": project.get("task_count", 0),
"状态": project.get("status", "active"),
"最后同步": int(datetime.now().timestamp() * 1000)
}
if feishu.update_record(app_token, table_id, record_id, update_fields):
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
updated += 1
config["last_sync"] = datetime.now().isoformat()
save_config(config)
print(f"\n更新完成: {updated} 个项目")
def show_status():
"""显示同步状态"""
config = load_config()
if not config:
print("未初始化,请先运行: python aiproj_sync.py init")
return
print("=" * 50)
print("ai-proj 飞书同步状态")
print("=" * 50)
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
print(f"上次同步: {config.get('last_sync', '从未')}")
print(f"配置文件: {CONFIG_PATH}")
# ============================================
# 主入口
# ============================================
def main():
if len(sys.argv) < 2:
print("用法: python aiproj_sync.py <command>")
print("\n命令:")
print(" init 首次初始化(创建目录和多维表格)")
print(" sync 增量同步(只同步新项目)")
print(" sync-all 全量同步(同步所有项目)")
print(" update 更新统计信息")
print(" status 查看同步状态")
return
command = sys.argv[1]
if command == "init":
init()
elif command == "sync":
sync(full=False)
elif command == "sync-all":
sync(full=True)
elif command == "update":
update_stats()
elif command == "status":
show_status()
else:
print(f"未知命令: {command}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,139 @@
---
name: feishu
description: 飞书文档与多维表格操作入口。当用户提到飞书、云文档、多维表格、Bitable 相关任务时自动激活。
---
# 飞书集成
## 功能模块
| 模块 | 技能 | 说明 |
|------|------|------|
| 云文档 | `feishu-docx` | 创建、编辑云文档,会议纪要 |
| 多维表格 | `feishu-bitable` | 记录增删改查,数据同步 |
| 任务 | 本技能 | 创建待办任务 |
## 环境配置
```bash
# ~/.zshrc凭证唯一配置位置
export FEISHU_APP_ID="cli_a9f29dca82b9dbef"
export FEISHU_APP_SECRET="<从飞书开放平台获取>"
```
**权限要求**
- 云文档:`docx:document`, `drive:drive`
- 多维表格:`bitable:app`
- 任务:`task:task:write`
## Access Token
```python
import os, requests
def get_tenant_access_token():
"""获取飞书 tenant_access_token"""
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": os.environ["FEISHU_APP_ID"],
"app_secret": os.environ["FEISHU_APP_SECRET"]
})
data = response.json()
if data.get("code") == 0:
return data["tenant_access_token"]
raise Exception(f"获取 token 失败: {data}")
```
## 默认存储位置
| 文件夹 | folder_token |
|--------|-------------|
| ai-proj 根目录 | `RTLKf247ClQQDyd5IjxcTOVQnxd` |
| 01运营 (默认) | `C80gfkRnzlonQ5d4AhOcOACDnNg` |
## URL 结构
```
云文档: https://xxx.feishu.cn/docx/DoxcXXXXXX
└── document_id
多维表格: https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX
└── app_token └── table_id
```
## 飞书任务
```python
def create_task(summary: str, due_time: int = None):
"""创建飞书任务"""
url = "https://open.feishu.cn/open-apis/task/v2/tasks"
token = get_tenant_access_token()
payload = {"summary": summary}
if due_time:
payload["due"] = {"timestamp": str(due_time), "is_all_day": False}
response = requests.post(url,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=payload)
data = response.json()
if data.get("code") == 0:
return data["data"]["task"]
raise Exception(f"创建任务失败: {data}")
```
## 工具类
完整工具类见:
- `~/.claude/skills/feishu/feishu_bitable.py` - 多维表格
- `~/.claude/skills/feishu/feishu_docx.py` - 云文档
## Incoming Webhook群机器人通知卡片
**Webhook 地址**:存储在 `~/.config/devops/credentials.env``FEISHU_DEPLOY_WEBHOOK`
**⚠️ 关键注意事项**
-**禁用 schema 2.0**`"schema": "2.0"` 会返回 ErrCode 11246必须用 legacy 格式
-**legacy 卡片格式**(无 schema 字段)才能正常发送
-**按钮 URL 禁止指向列表页**:必须带具体资源 ID`/requirements/864`,不能是 `/requirements`
-**保存到文件再 curl**:包含中文的 JSON 直接用 `'...'` 传参会报 "blank argument" 错误
**正确的卡片发送示例**
```bash
cat > /tmp/feishu_card.json << 'EOF'
{
"msg_type": "interactive",
"card": {
"header": {
"title": {"tag": "plain_text", "content": "通知标题"},
"template": "blue"
},
"elements": [
{
"tag": "div",
"text": {"tag": "lark_md", "content": "**内容**:描述文字"}
},
{
"tag": "action",
"actions": [{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看详情"},
"type": "primary",
"url": "https://ai.pipexerp.com/requirements/864"
}]
}
]
}
}
EOF
curl -s -X POST \
"https://open.feishu.cn/open-apis/bot/v2/hook/xxx" \
-H "Content-Type: application/json" \
-d @/tmp/feishu_card.json
```
**header template 颜色**`blue`(待审批)/ `green`(通过)/ `red`(驳回)/ `wathet`(信息)
## 相关技能
- `feishu-docx` - 云文档详细操作
- `feishu-bitable` - 多维表格详细操作

View File

@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""
测试飞书云文档图片上传
"""
import requests
import os
from datetime import datetime, timedelta
from PIL import Image, ImageDraw
# ========== 配置 ==========
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
class FeishuDocx:
"""飞书云文档操作工具类"""
def __init__(self):
self._token = None
self._token_expires = None
@property
def token(self) -> str:
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._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}")
self._token = data["tenant_access_token"]
self._token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
print(f"[OK] Token 获取成功")
return self._token
@property
def headers(self):
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
def set_document_permission(self, document_id: str, editable: bool = True) -> bool:
"""设置文档权限为组织内可编辑"""
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
payload = {
"external_access_entity": "open",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
"invite_external": False
}
response = requests.patch(url, headers=self.headers, params={"type": "docx"}, json=payload)
result = response.json()
if result.get("code") == 0:
print(f"[OK] 权限设置成功: 组织内可编辑")
return True
else:
print(f"[WARN] 权限设置失败: {result.get('msg')}")
return False
def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict:
"""创建云文档(自动设置为组织内可编辑)"""
url = f"{BASE_URL}/docx/v1/documents"
payload = {"title": title}
if folder_token:
payload["folder_token"] = folder_token
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建文档失败: {data}")
doc = data["data"]["document"]
print(f"[OK] 文档创建成功: {doc['document_id']}")
# 自动设置权限
if editable:
self.set_document_permission(doc['document_id'], editable=True)
return doc
def create_empty_image_block(self, document_id: str, index: int = -1) -> str:
"""
创建空的图片块,返回 block_id
正确流程第一步:先创建空图片块
"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
# 创建空的图片块
image_block = {
"block_type": 27,
"image": {} # 空的图片块
}
payload = {"children": [image_block]}
if index >= 0:
payload["index"] = index
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建图片块失败: {data}")
# 获取创建的图片块 ID
children = data["data"].get("children", [])
if not children:
raise Exception("创建图片块失败: 没有返回 children")
block_id = children[0].get("block_id")
print(f"[OK] 空图片块创建成功, block_id: {block_id}")
return block_id
def upload_image_to_block(self, file_path: str, block_id: str) -> str:
"""
上传图片到指定的图片块
正确流程第二步:将图片上传并绑定到已创建的图片块
"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
headers = {"Authorization": f"Bearer {self.token}"}
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, # 关键: 使用图片块的 block_id
'size': str(file_size)
}
print(f"[INFO] 上传图片到 block_id={block_id}")
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] 上传响应: code={result.get('code')}, msg={result.get('msg')}")
if result.get("code") != 0:
raise Exception(f"上传图片失败: {result}")
file_token = result["data"]["file_token"]
print(f"[OK] 图片上传成功, file_token: {file_token}")
return file_token
def _upload_media(self, file_path: str, parent_type: str, parent_node: str = '') -> str:
"""使用 media API 上传"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
headers = {"Authorization": f"Bearer {self.token}"}
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
data = {
'file_name': file_name,
'parent_type': parent_type,
'parent_node': parent_node,
'size': str(file_size)
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] media 响应 ({parent_type}): code={result.get('code')}, msg={result.get('msg')}")
if result.get("code") == 0:
return result["data"]["file_token"]
return None
def _upload_to_drive(self, file_path: str) -> str:
"""上传到云空间根目录,然后获取 file_token"""
# 先获取根文件夹 token
url = f"{BASE_URL}/drive/explorer/v2/root_folder/meta"
headers = {"Authorization": f"Bearer {self.token}"}
response = requests.get(url, headers=headers)
result = response.json()
print(f"[DEBUG] 根目录响应: {result}")
if result.get("code") != 0:
return None
root_token = result["data"]["token"]
# 上传文件
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
upload_url = f"{BASE_URL}/drive/v1/medias/upload_all"
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
data = {
'file_name': file_name,
'parent_type': 'explorer',
'parent_node': root_token,
'size': str(file_size)
}
response = requests.post(upload_url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] drive 上传响应: code={result.get('code')}, msg={result.get('msg')}")
if result.get("code") == 0:
return result["data"]["file_token"]
return None
def _upload_im_image(self, file_path: str) -> str:
"""使用消息图片 API 上传"""
url = f"{BASE_URL}/im/v1/images"
headers = {"Authorization": f"Bearer {self.token}"}
with open(file_path, 'rb') as f:
files = {'image': f}
data = {'image_type': 'message'}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] im 图片响应: code={result.get('code')}, msg={result.get('msg')}")
if result.get("code") == 0:
return result["data"]["image_key"]
return None
def _try_upload_with_type(self, file_path: str, parent_type: str) -> str:
"""尝试不同的 parent_type 上传"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
headers = {"Authorization": f"Bearer {self.token}"}
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
data = {
'file_name': file_name,
'parent_type': parent_type,
'parent_node': '',
'size': str(file_size)
}
print(f"[INFO] 尝试 parent_type={parent_type}")
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] 响应: {result}")
if result.get("code") != 0:
raise Exception(f"上传失败 ({parent_type}): {result}")
return result["data"]["file_token"]
def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> dict:
"""
绑定图片到图片块 (关键的第三步!)
使用 PATCH 请求和 replace_image 字段将图片绑定到已创建的图片块
"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
params = {"document_revision_id": -1}
payload = {
"replace_image": {
"token": file_token
}
}
print(f"[INFO] 绑定图片到块: block_id={block_id}")
response = requests.patch(url, headers=self.headers, params=params, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"绑定图片失败: {data}")
print(f"[OK] 图片绑定成功")
return data["data"]
def generate_test_image(output_path: str):
"""生成测试图片"""
img = Image.new('RGB', (400, 300), color='#4a90d9')
draw = ImageDraw.Draw(img)
draw.rectangle([20, 20, 380, 280], fill='#f0f4f8', outline='#2563eb', width=2)
draw.text((200, 150), "Test Image", fill='#1e3a5f', anchor='mm')
draw.text((200, 200), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fill='#6b7280', anchor='mm')
img.save(output_path)
print(f"[OK] 测试图片生成: {output_path}")
return output_path
def main():
print("\n" + "#" * 60)
print("# 飞书云文档图片上传测试")
print("#" * 60)
docx = FeishuDocx()
# Step 1: 生成测试图片
print("\n" + "=" * 50)
print("Step 1: 生成测试图片")
print("=" * 50)
test_image = "/tmp/feishu_docx_test_image.png"
generate_test_image(test_image)
# Step 2: 创建测试文档
print("\n" + "=" * 50)
print("Step 2: 创建测试文档")
print("=" * 50)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
doc = docx.create_document(f"图片上传测试 - {timestamp}")
document_id = doc["document_id"]
# Step 3: 创建空的图片块
print("\n" + "=" * 50)
print("Step 3: 创建空的图片块")
print("=" * 50)
try:
block_id = docx.create_empty_image_block(document_id)
except Exception as e:
print(f"[ERROR] 创建图片块失败: {e}")
return
# Step 4: 上传图片
print("\n" + "=" * 50)
print("Step 4: 上传图片")
print("=" * 50)
try:
file_token = docx.upload_image_to_block(test_image, block_id)
except Exception as e:
print(f"[ERROR] 上传图片失败: {e}")
return
# Step 5: 绑定图片到图片块 (关键步骤!)
print("\n" + "=" * 50)
print("Step 5: 绑定图片到图片块")
print("=" * 50)
try:
docx.bind_image_to_block(document_id, block_id, file_token)
except Exception as e:
print(f"[ERROR] 绑定图片失败: {e}")
return
# 完成
print("\n" + "=" * 60)
print("测试完成!")
print("=" * 60)
print(f"\n文档地址:")
print(f" https://feishu.cn/docx/{document_id}")
print()
def check_permissions(docx):
"""检查应用权限"""
# 获取应用信息
url = f"{BASE_URL}/application/v6/applications/underauditlist"
headers = {"Authorization": f"Bearer {docx.token}"}
response = requests.get(url, headers=headers)
print(f"[DEBUG] 权限检查响应: {response.json()}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
更新飞书文档ai-proj 项目可见性手册
"""
import requests
import os
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})
return response.json().get("code") == 0
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(), 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(), 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 generate_visibility_diagram():
"""生成可见性示意图"""
output_path = "/tmp/visibility_diagram.png"
img = Image.new('RGB', (800, 400), color='#ffffff')
draw = ImageDraw.Draw(img)
# 标题
draw.rectangle([0, 0, 800, 50], fill='#1890ff')
draw.text((400, 25), "项目可见性示意图", fill='white', anchor='mm')
# 私有项目
draw.rectangle([50, 80, 370, 350], fill='#fff7e6', outline='#fa8c16', width=2)
draw.text((210, 100), "🔒 私有项目 (private)", fill='#fa8c16', anchor='mm')
draw.text((210, 140), "仅创建者可见", fill='#666666', anchor='mm')
# 私有项目内的人员图标
draw.ellipse([180, 180, 240, 240], fill='#fa8c16')
draw.text((210, 210), "👤", fill='white', anchor='mm')
draw.text((210, 270), "项目所有者", fill='#333333', anchor='mm')
draw.text((210, 300), "管理员", fill='#999999', anchor='mm')
# 企业项目
draw.rectangle([430, 80, 750, 350], fill='#e6f7ff', outline='#1890ff', width=2)
draw.text((590, 100), "👥 企业项目 (enterprise)", fill='#1890ff', anchor='mm')
draw.text((590, 140), "企业内所有成员可见", fill='#666666', anchor='mm')
# 企业项目内的人员图标组
positions = [(520, 190), (590, 190), (660, 190), (555, 250), (625, 250)]
for x, y in positions:
draw.ellipse([x-20, y-20, x+20, y+20], fill='#1890ff')
draw.text((x, y), "👤", fill='white', anchor='mm')
draw.text((590, 300), "企业所有成员", fill='#333333', anchor='mm')
draw.text((590, 330), "管理员", fill='#999999', anchor='mm')
# 箭头
draw.text((400, 200), "", fill='#333333', anchor='mm')
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', (600, 200), color='#fafafa')
draw = ImageDraw.Draw(img)
# 标题
draw.text((20, 20), "项目可见性选择", fill='#333333')
# Radio Group 模拟
# 私有项目选项
draw.rectangle([20, 60, 280, 100], fill='#fff7e6', outline='#fa8c16', width=2)
draw.ellipse([30, 70, 50, 90], fill='#fa8c16')
draw.text((60, 80), "🔒 私有项目", fill='#fa8c16', anchor='lm')
# 企业项目选项
draw.rectangle([300, 60, 560, 100], fill='#ffffff', outline='#d9d9d9', width=1)
draw.ellipse([310, 70, 330, 90], outline='#d9d9d9', width=1)
draw.text((340, 80), "👥 企业项目", fill='#666666', anchor='lm')
# 说明文字
draw.text((20, 130), "私有项目仅创建者可见,企业项目对企业内所有成员可见", fill='#999999')
# 默认标签
draw.rectangle([200, 65, 270, 85], fill='#52c41a')
draw.text((235, 75), "默认", fill='white', anchor='mm')
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="json"):
return {
"block_type": 14,
"code": {
"elements": [{"text_run": {"content": content}}],
"language": 1 if language == "json" else 0
}
}
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: 追加手册内容 ---")
print("\n--- Step 3: 创建手册内容 ---")
# 定义手册内容
manual_blocks = [
heading1("ai-proj 项目可见性手册"),
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
divider(),
heading2("1. 功能概述"),
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
bullet("私有项目 (private):仅项目创建者和管理员可见"),
bullet("企业项目 (enterprise):企业内所有成员可见"),
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
divider(),
heading2("2. 可见性对比"),
]
# 创建第一批内容块
created = create_blocks(DOCUMENT_ID, DOCUMENT_ID, manual_blocks)
print(f" 创建了 {len(created)} 个内容块")
# Step 4: 插入可见性示意图
print("\n--- Step 4: 插入可见性示意图 ---")
img_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
print(f" 图片块: {img_block_id}")
file_token = upload_image(diagram_path, img_block_id)
print(f" file_token: {file_token}")
bind_image(DOCUMENT_ID, img_block_id, file_token)
print(" 图片绑定成功!")
# Step 5: 继续添加内容
print("\n--- Step 5: 添加更多内容 ---")
more_blocks = [
divider(),
heading2("3. 设置项目可见性"),
heading3("3.1 创建项目时设置"),
ordered("点击「创建项目」按钮"),
ordered("在弹出的表单中找到「项目可见性」选项"),
ordered("选择「私有项目」或「企业项目」"),
ordered("填写其他信息后点击「确定」"),
]
create_blocks(DOCUMENT_ID, DOCUMENT_ID, more_blocks)
# Step 6: 插入 UI 示意图
print("\n--- Step 6: 插入 UI 示意图 ---")
ui_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
ui_file_token = upload_image(ui_path, ui_block_id)
bind_image(DOCUMENT_ID, ui_block_id, ui_file_token)
print(" UI 示意图上传成功!")
# Step 7: 添加剩余内容
print("\n--- Step 7: 添加剩余内容 ---")
final_blocks = [
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, final_blocks)
print("\n" + "=" * 60)
print("文档更新完成!")
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
上传用户指定的图片到飞书云文档
"""
import requests
import os
from datetime import datetime, timedelta
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# 用户指定的图片
IMAGE_PATH = "/Users/donglinlai/Downloads/u274.png"
_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 set_document_permission(document_id: str, editable: bool = True):
"""
设置文档权限
Args:
document_id: 文档ID
editable: True=组织内可编辑, False=组织内只读
"""
url = f"{BASE_URL}/drive/v1/permissions/{document_id}/public"
payload = {
"external_access_entity": "open",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"link_share_entity": "tenant_editable" if editable else "tenant_readable",
"invite_external": False
}
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
data = response.json()
if data.get("code") == 0:
print(f" 权限设置成功: {'组织内可编辑' if editable else '组织内只读'}")
return True
else:
print(f" [WARN] 权限设置失败: {data.get('msg')}")
return False
def create_document(title: str, editable: bool = True):
"""创建文档并设置权限"""
url = f"{BASE_URL}/docx/v1/documents"
response = requests.post(url, headers=headers(), json={"title": title})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建文档失败: {data}")
doc = data["data"]["document"]
document_id = doc["document_id"]
# 自动设置权限
if editable:
set_document_permission(document_id, editable=True)
return document_id
def create_image_block(document_id: str):
"""创建空图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
payload = {
"children": [{
"block_type": 27,
"image": {}
}]
}
response = requests.post(url, headers=headers(), 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)
# 根据扩展名设置 MIME 类型
ext = file_name.lower().split('.')[-1]
mime_types = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp'
}
mime_type = mime_types.get(ext, 'image/png')
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, mime_type)}
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 main():
print(f"\n上传图片: {IMAGE_PATH}")
print("=" * 60)
# 检查文件
if not os.path.exists(IMAGE_PATH):
print(f"[ERROR] 文件不存在: {IMAGE_PATH}")
return
file_size = os.path.getsize(IMAGE_PATH)
print(f"文件大小: {file_size / 1024:.1f} KB")
# Step 1: 创建文档
print("\n[1/4] 创建飞书文档...")
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
doc_id = create_document(f"火把图片 - {timestamp}")
print(f" 文档ID: {doc_id}")
# Step 2: 创建图片块
print("[2/4] 创建图片块...")
block_id = create_image_block(doc_id)
print(f" 块ID: {block_id}")
# Step 3: 上传图片
print("[3/4] 上传图片...")
file_token = upload_image(IMAGE_PATH, block_id)
print(f" file_token: {file_token}")
# Step 4: 绑定图片
print("[4/4] 绑定图片...")
bind_image(doc_id, block_id, file_token)
print(" 绑定成功!")
print("\n" + "=" * 60)
print("上传完成!")
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,10 @@
{
"吴薇儿": {
"email": "wuweier@zhiyuncai.com",
"open_id": "ou_1d5cdfee78cbe6f8acc0751fff00ed09"
},
"宋佳香": {
"email": "songjiaxiang@zhiyuncai.com",
"open_id": "e6e72eb8"
}
}