#!/usr/bin/env python3 """ 飞书电子表格剧本写入工具 将儿童互动英语剧本以4列格式写入飞书电子表格。 自动去除台词中的 ** 知识点标记符,输出干净纯文本。 用法: python scripts/feishu_sheet_writer.py create --title "U22 剧本" --credential xiaobian python scripts/feishu_sheet_writer.py write --token --sheet \ --data --credential xiaobian python scripts/feishu_sheet_writer.py append --token --sheet \ --data --credential xiaobian python scripts/feishu_sheet_writer.py create-sheet --token \ --title "U22_L1_起" --credential xiaobian """ import json import sys import os import re import argparse import requests BASE_URL = "https://open.feishu.cn/open-apis" CRED_DIR = "/root/.openclaw/credentials" def get_token(credential_name): """获取 Bot tenant_access_token""" config_path = os.path.join(CRED_DIR, credential_name, "config.json") if not os.path.exists(config_path): print(f"❌ 凭证文件不存在: {config_path}", file=sys.stderr) sys.exit(1) with open(config_path) as f: config = json.load(f) app_id = config.get("apps", [{}])[0].get("appId", "") app_secret = config.get("apps", [{}])[0].get("appSecret", "") resp = requests.post( f"{BASE_URL}/auth/v3/tenant_access_token/internal", json={"app_id": app_id, "app_secret": app_secret}, timeout=10, ) data = resp.json() if data.get("code") != 0: print(f"❌ 获取token失败: {data}", file=sys.stderr) sys.exit(1) return data["tenant_access_token"] def set_tenant_editable(spreadsheet_token, token): """ 设置表格权限:组织内任何人可通过链接编辑。 非致命操作,失败不阻塞主流程。 """ resp = requests.patch( f"{BASE_URL}/drive/v1/permissions/{spreadsheet_token}/public?type=sheet", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, json={"link_share_entity": "tenant_editable"}, timeout=10, ) data = resp.json() if data.get("code") != 0: print(f"⚠️ 设置权限失败(非致命): {data}", file=sys.stderr) else: print(json.dumps({"permission": "tenant_editable"})) def strip_kp_markers(text): """ 去除文本中的 ** 知识点标记符,返回干净纯文本。 Feishu Sheets V2 不支持单元格内富文本,所有台词以纯文本存储。 """ if not isinstance(text, str): return text return re.sub(r'\*\*([^*]+)\*\*', r'\1', text) def create_spreadsheet(title, token): """创建新的电子表格""" resp = requests.post( f"{BASE_URL}/sheets/v3/spreadsheets", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, json={"title": title}, timeout=10, ) data = resp.json() if data.get("code") != 0: print(f"❌ 创建表格失败: {data}", file=sys.stderr) sys.exit(1) spreadsheet = data["data"]["spreadsheet"] spreadsheet_token = spreadsheet["spreadsheet_token"] # 自动设置组织内可编辑权限 set_tenant_editable(spreadsheet_token, token) print(json.dumps({ "token": spreadsheet_token, "url": spreadsheet["url"], "title": spreadsheet["title"], })) def create_sheet(spreadsheet_token, title, token): """在已有表格中创建新sheet""" resp = requests.post( f"{BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}/sheets_batch_update", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, json={ "requests": [{ "addSheet": { "properties": {"title": title}, } }] }, timeout=10, ) data = resp.json() if data.get("code") != 0: print(f"❌ 创建sheet失败: {data}", file=sys.stderr) sys.exit(1) reply = data["data"]["replies"][0] props = reply["addSheet"]["properties"] print(json.dumps({ "sheet_id": props["sheetId"], "title": props["title"], "index": props.get("index", -1), })) def write_data(spreadsheet_token, sheet_id, data_file, token, append=False, start_row=1): """写入或追加数据到表格。所有单元格均为纯文本,** 标记符自动去除。""" with open(data_file) as f: data = json.load(f) rows = data.get("rows", []) # Convert rows to plain-text API format, stripping ** markers values = [] for row in rows: formatted_row = [] for cell in row: if isinstance(cell, dict): # 兼容旧格式:dict → 提取 text 字段 formatted = strip_kp_markers(cell.get("text", "")) elif isinstance(cell, str): formatted = strip_kp_markers(cell) else: formatted = str(cell) if cell else "" formatted_row.append(formatted) values.append(formatted_row) if not values: print("⚠️ 没有数据可写入", file=sys.stderr) return num_rows = len(values) num_cols = max(len(row) for row in values) if values else 1 col_letter = chr(64 + num_cols) if num_cols <= 26 else "Z" if append: resp = requests.post( f"{BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}/values_append", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, json={ "valueRange": { "range": f"{sheet_id}!A{start_row}:{col_letter}{start_row + num_rows - 1}", "values": values, }, "insertDataOption": "INSERT_ROWS", }, timeout=30, ) else: resp = requests.put( f"{BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}/values", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, json={ "valueRange": { "range": f"{sheet_id}!A1:{col_letter}{num_rows}", "values": values, }, }, timeout=30, ) data_resp = resp.json() if data_resp.get("code") != 0: print(f"❌ 写入数据失败: {data_resp}", file=sys.stderr) sys.exit(1) print(json.dumps({ "status": "ok", "rows": num_rows, "columns": num_cols, "mode": "append" if append else "overwrite", })) def set_styles(spreadsheet_token, sheet_id, ranges_styles, token): """设置表头样式(粗体 + 灰底)""" resp = requests.put( f"{BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}/style", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, json={ "appendStyle": { "range": f"{sheet_id}!A1:D1", "style": { "font": {"bold": True}, "backColor": "#e8e8e8", }, }, }, timeout=10, ) data = resp.json() if data.get("code") != 0: print(f"⚠️ 设置样式失败(非致命): {data}", file=sys.stderr) else: print(json.dumps({"style_status": "ok"})) def main(): parser = argparse.ArgumentParser(description="飞书电子表格剧本写入工具") subparsers = parser.add_subparsers(dest="command", required=True) create_parser = subparsers.add_parser("create", help="创建新的电子表格") create_parser.add_argument("--title", required=True, help="表格标题") create_parser.add_argument("--credential", default="xiaobian", help="凭证名称") sheet_parser = subparsers.add_parser("create-sheet", help="在已有表格中创建新sheet") sheet_parser.add_argument("--token", required=True, help="spreadsheet token") sheet_parser.add_argument("--title", required=True, help="sheet标题") sheet_parser.add_argument("--credential", default="xiaobian", help="凭证名称") write_parser = subparsers.add_parser("write", help="写入数据(覆盖)") write_parser.add_argument("--token", required=True, help="spreadsheet token") write_parser.add_argument("--sheet", required=True, help="sheet ID") write_parser.add_argument("--data", required=True, help="数据JSON文件路径") write_parser.add_argument("--credential", default="xiaobian", help="凭证名称") append_parser = subparsers.add_parser("append", help="追加数据") append_parser.add_argument("--token", required=True, help="spreadsheet token") append_parser.add_argument("--sheet", required=True, help="sheet ID") append_parser.add_argument("--data", required=True, help="数据JSON文件路径") append_parser.add_argument("--start-row", type=int, default=1, help="起始行号") append_parser.add_argument("--credential", default="xiaobian", help="凭证名称") style_parser = subparsers.add_parser("style", help="设置表头样式") style_parser.add_argument("--token", required=True, help="spreadsheet token") style_parser.add_argument("--sheet", required=True, help="sheet ID") style_parser.add_argument("--credential", default="xiaobian", help="凭证名称") args = parser.parse_args() token = get_token(args.credential) if args.command == "create": create_spreadsheet(args.title, token) elif args.command == "create-sheet": create_sheet(args.token, args.title, token) elif args.command == "write": write_data(args.token, args.sheet, args.data, token, append=False) set_styles(args.token, args.sheet, None, token) elif args.command == "append": write_data(args.token, args.sheet, args.data, token, append=True, start_row=args.start_row) elif args.command == "style": set_styles(args.token, args.sheet, None, token) if __name__ == "__main__": main()