skills/ → skills-dev(9), skills-req(10), skills-ops(4), skills-integration(8), skills-biz(4), skills-workflow(7) generate-marketplace.py 改为自动扫描所有 skills-* 目录。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
531 lines
19 KiB
Python
531 lines
19 KiB
Python
#!/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()
|