#!/usr/bin/env python3 """ 飞书电子表格剧本写入工具 将儿童互动英语剧本以8列格式写入飞书电子表格,支持知识点富文本标注。 用法: 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 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 format_cell_value(text, knowledge_points=None, is_user_line=False): """ 将文本格式化为飞书富文本单元格值。 Args: text: 单元格文本 knowledge_points: 知识点列表,如 ["afternoon", "adventure"] is_user_line: 是否为User台词(User台词知识点=红色+粗体,NPC=粗体) Returns: 飞书单元格值对象(带textFormatRuns) """ if not knowledge_points or not text: return text # 找出所有知识点的位置 format_runs = [] text_lower = text.lower() # 去重并按在文本中的位置排序 found_kps = [] for kp in knowledge_points: kp_lower = kp.lower() idx = 0 while True: idx = text_lower.find(kp_lower, idx) if idx == -1: break found_kps.append({ "start": idx, "end": idx + len(kp_lower), "original": text[idx:idx + len(kp_lower)], "kp": kp, }) idx += len(kp_lower) # 按位置排序,合并重叠区间 found_kps.sort(key=lambda x: x["start"]) if not found_kps: return text # 构建 textFormatRuns for kp_info in found_kps: fmt = {"bold": True} if is_user_line: fmt["foregroundColor"] = { "red": 245, "green": 74, "blue": 69, "alpha": 255 } # #f54a45 format_runs.append({ "startIndex": kp_info["start"], "format": fmt, }) return { "type": "text", "text": text, "textFormatRuns": format_runs, } 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"] print(json.dumps({ "token": spreadsheet["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) # data format: list of rows, each row is a list of column values # Column values can be strings or objects with {text, knowledge_points, is_user} rows = data.get("rows", []) knowledge_points = data.get("knowledge_points", []) # Convert rows to API format values = [] header_written = False for row_idx, row in enumerate(rows): formatted_row = [] for col_idx, cell in enumerate(row): if isinstance(cell, dict): is_user = cell.get("is_user", False) formatted = format_cell_value( cell.get("text", ""), knowledge_points, is_user_line=is_user, ) else: formatted = str(cell) if cell else "" formatted_row.append(formatted) values.append(formatted_row) if not values: print("⚠️ 没有数据可写入", file=sys.stderr) return # Calculate range 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: # Append mode - use append API 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: # Overwrite mode 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:H1", "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 command create_parser = subparsers.add_parser("create", help="创建新的电子表格") create_parser.add_argument("--title", required=True, help="表格标题") create_parser.add_argument("--credential", default="xiaobian", help="凭证名称") # create-sheet command 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 command 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 command 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 command 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()