#!/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("文档中没有图片")