#!/usr/bin/env python3 """ 将剧本表(Q8AyX5)中互动组件的文本配置转为结构化JSON并写回组件配置列。 """ import json, subprocess, sys, re # --- Bot Token --- def get_token(): APP_ID = "cli_a931175d41799cc7" import os with open(os.path.expanduser('/root/.openclaw/credentials/xiaoyan/config.json')) as f: cfg = json.load(f) APP_SECRET = cfg['apps'][0]['appSecret'] r = subprocess.run([ 'curl', '-s', '-X', 'POST', 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', '-H', 'Content-Type: application/json', '-d', json.dumps({"app_id": APP_ID, "app_secret": APP_SECRET}) ], capture_output=True, text=True) return json.loads(r.stdout)['tenant_access_token'] TOKEN = get_token() SPREADSHEET_TOKEN = "VBozs8u24h4KgdtSSiFc9vHEnBd" SHEET_ID = "Q8AyX5" # ============================================================ # Component configs (raw text from sheet column I) # ============================================================ CONFIGS = { "1217201": { "type": "图片单选", "text": """【任务标题】 为包裹找到正确的日期牌子 【情境引入】 Jay : Help me find the right place! 【互动内容】 Find the "Days Ago" sign.(音频) 选项: 00 01 02 答案: 00 辅助信息:days ago 指"几天前"。 【互动反馈】 正确 Lin : Bingo! 错误 Jay : No, look! This package is from 3 days ago! 【后置对话】 无""", }, "1217202": { "type": "对话朗读", "text": """【任务标题】 朗读90天前的包裹信息 【资源配置】 图片时机:互动内容 【情境引入】 无 【互动内容】 User: It's 90 days ago...(朗读) 【后置对话】 无""", }, "1217203": { "type": "对话朗读", "text": """【任务标题】 理解天数和月数的换算 【资源配置】 图片时机:互动内容 【情境引入】 User: No! 90 days! It is about... 【互动内容】 User: 3 months!(朗读) 【后置对话】 User: This is from months ago!""", }, "1217204": { "type": "对话组句", "text": """【任务标题】 用单词组句描述包裹信息 【资源配置】 图片时机:互动内容 【情境引入】 无 【互动内容】 要求:用所给单词或短语组句 It says 24 months ago.(音频) 选项1:it says 选项2:months ago 选项3:24 答案:It says 24 months ago. 【互动反馈】 正确 无 错误 Jay : Try again! Read what the package says. 【后置对话】 无""", }, "1217205": { "type": "对话朗读", "text": """【任务标题】 理解月数和年数的换算 【资源配置】 图片时机:互动内容 【情境引入】 无 【互动内容】 User: That's 2 years!(朗读) 【后置对话】 无""", }, "1217206": { "type": "对话选读", "text": """【任务标题】 选择你想表达的感受 【资源配置】 无 【情境引入】 无 【互动内容】 要求:选择一个你想表达的观点 选项:(音频) 选项1:That's a long time ago! - 反馈 Jay: You are right. 选项2:That is long, long ago! - 反馈 Jay: You are right. 【后置对话】 无""", }, "1217207": { "type": "图片多选", "text": """【任务标题】 找出写有months ago的包裹 【情境引入】 空 【互动内容】 Find the "months ago" packages in the picture.(音频) 选项: 00 01 02 答案: 01 02 辅助信息:months ago 指"几个月前"。 【互动反馈】 正确 User : Those two are months ago! 错误 Jay : Look again! Which ones say "months ago"? 【后置对话】 无""", }, "1217208": { "type": "图片单选", "text": """【任务标题】 找出写有一年前的包裹 【情境引入】 空 【互动内容】 Find the "year ago" package in the picture.(音频) 选项: 00 01 02 答案: 02 辅助信息:a year ago 指"一年前"。 【互动反馈】 正确 User : This one is a year ago! 错误 Jay : No, that's not right. Look again! 【后置对话】 无""", }, "1217209": { "type": "对话挖空", "text": """【任务标题】 补全对Sunny说的句子 【资源配置】 无 【情境引入】 空 【互动内容】 You must ___ it!(音频) 选项1:be happy with(正确) 选项2:happy with 【互动反馈】 正确 User : You must be happy with it! 错误 Sunny : That doesn't sound quite right... 【后置对话】 无""", }, "1217210": { "type": "对话选读", "text": """【任务标题】 选择帮Grace拿包裹的说法 【资源配置】 无 【情境引入】 空 【互动内容】 要求:选择一个你想表达的观点 选项:(音频) 选项1:Let me get it! - 反馈 Grace: Thank you, kid. 选项2:I will get it! - 反馈 Grace: Thank you, kid. 【后置对话】 无""", }, "1217211": { "type": "对话朗读", "text": """【任务标题】 对Anna的提醒 【资源配置】 图片时机:无 【情境引入】 无 【互动内容】 User: You will not be happy with it.(朗读) 【后置对话】 无""", }, "1217212": { "type": "对话组句", "text": """【任务标题】 用单词组句主动帮忙 【资源配置】 无 【情境引入】 空 【互动内容】 要求:用所给单词或短语组句 Can I get it for you?(音频) 选项1:for you 选项2:can I 选项3:get it 答案:Can I get it for you? 【互动反馈】 正确 Jack : You are very kind. But I just want to say... 错误 Jack: I beg your pardon? 【后置对话】 无""", }, "1217213": { "type": "对话挖空", "text": """【任务标题】 补全对Jack说的话 【资源配置】 图片时机:互动内容互动反馈 【情境引入】 无 【互动内容】 But this meat is from 2 ___ !(音频) 选项1:years ago(正确) 选项2:days ago 【互动反馈】 正确 Jack : Perfect! 错误 Jack : No, look at the sign on it! 【后置对话】 无""", }, "1217214": { "type": "听力拖拽", "text": """【任务标题】 告诉 Lin 你们分发包裹的事迹 【任务背景】 包裹大作战!你和 Jay 热火朝天地干了半天,把很多包裹带给了他们的主人。快来回顾一下你们的战果吧! 【通关知识】 get v. 收到 month n. 月 year n. 年 ago adv. 以前 ... month(s)/year(s) ago. 【开场语】 Lin: Come on, tell me what you did! 【听力文本】 # 文本 1 Jay: Well, well, well! Listen up! Jay: Tom gets a pen! User: It is from 5 days ago! Jay: And Sunny gets a dress. Jay: It is from 6 months ago. Jay: Jack gets some meat from 2 years ago! 【题目信息】 #单空选择 选项图片编号:00,01,02 答案图片编号: 01,00,02 【学习过程】 句子 1 It is from 5 days ago! 【ago】 句子 2 It is from 6 months ago. 【month】 句子 3 Jack gets some meat from 2 years ago! 【year】""", }, "1217215": { "type": "对话选读", "text": """【任务标题】 选择表达满意的方式 【资源配置】 无 【情境引入】 无 【互动内容】 要求:选择一个你想表达的观点 选项:(音频) 选项1:I am happy with it! - 反馈 Jay: That's good! 选项2:I am happy with the result! - 反馈 Jay: That's good! 【后置对话】 无""", }, "1217216": { "type": "对话挖空", "text": """【任务标题】 补全User想要包裹的句子 【资源配置】 无 【情境引入】 空 【互动内容】 I want to ___ one for myself!(音频) 选项1:get(正确) 选项2:get up 【互动反馈】 正确 无 错误 Jay : Hmm, that's not how we say it. Try again! 【后置对话】 无""", }, "1217217": { "type": "对话朗读", "text": """【任务标题】 朗读收到帽子的喜悦 【资源配置】 图片时机:无 【情境引入】 无 【互动内容】 User: Now I get my own hat!(朗读) 【后置对话】 无""", }, } # ============================================================ # Parsers for each component type # ============================================================ def extract_section(text, key): """Extract content between 【key】and next section header 【...】.""" # Match 【key】 then capture everything until next 【XXX】 header on its own line or end pattern = rf'【{re.escape(key)}】\s*\n?(.*?)(?=\n(?:【[^】]+】)\s*\n|\Z)' m = re.search(pattern, text, re.DOTALL) if m: return m.group(1).strip() return None def parse_context(text): """Parse context intro: '角色 : 台词' or '空' or '无'""" if not text or text in ('无', '空', ''): return None lines = [l.strip() for l in text.strip().split('\n') if l.strip()] result = [] for line in lines: if ':' in line: parts = line.split(':', 1) result.append({"character": parts[0].strip(), "line": parts[1].strip()}) elif ':' in line: parts = line.split(':', 1) result.append({"character": parts[0].strip(), "line": parts[1].strip()}) else: result.append(line) return result if result else None def parse_feedback(text): """Parse feedback: 正确/错误 角色 : 台词""" if not text or text == '无': return {"correct": None, "incorrect": None} result = {"correct": None, "incorrect": None} lines = text.strip().split('\n') current_type = None for line in lines: line = line.strip() if not line: continue if line.startswith('正确'): current_type = 'correct' content = line[2:].strip() if ':' in content: parts = content.split(':', 1) char = parts[0].strip() msg = parts[1].strip() if msg == '无': result['correct'] = None else: result['correct'] = {"character": char, "line": msg} elif content == '无': result['correct'] = None elif line.startswith('错误'): current_type = 'incorrect' content = line[2:].strip() if ':' in content: parts = content.split(':', 1) result['incorrect'] = {"character": parts[0].strip(), "line": parts[1].strip()} elif ':' in content: parts = content.split(':', 1) result['incorrect'] = {"character": parts[0].strip(), "line": parts[1].strip()} return result def parse_selective_options(text): """Parse 对话选读 options: 选项N:text - 反馈 X: line""" options = [] lines = text.strip().split('\n') i = 0 while i < len(lines): line = lines[i].strip() m = re.match(r'选项(\d+)[::]\s*(.+)', line) if m: idx = int(m.group(1)) opt_text = m.group(2).strip() feedback = None # Check next line for feedback if i + 1 < len(lines): next_line = lines[i + 1].strip() fm = re.match(r'[-–]\s*反馈\s*([^::]+)[::]\s*(.+)', next_line) if fm: feedback = {"character": fm.group(1).strip(), "line": fm.group(2).strip()} i += 1 options.append({"index": idx, "text": opt_text, "feedback": feedback}) i += 1 return options def parse_image_options(text): """Parse image choice options: 00, 01, 02 etc.""" options = [] lines = text.strip().split('\n') in_options = False for line in lines: line = line.strip() if line == '选项:': in_options = True continue if in_options: if re.match(r'^\d{2}$', line): options.append(line) else: break return options def parse_fill_options(text): """Parse fill-in-blanks options: 选项N:text(正确)""" options = [] lines = text.strip().split('\n') for line in lines: line = line.strip() m = re.match(r'选项(\d+)[::]\s*(.+)', line) if m: idx = int(m.group(1)) opt_text = m.group(2).strip() correct = False if '(正确)' in opt_text: correct = True opt_text = opt_text.replace('(正确)', '').strip() options.append({"index": idx, "text": opt_text, "correct": correct}) return options def parse_sentence_options(text): """Parse sentence building options: 选项N:text""" options = [] lines = text.strip().split('\n') for line in lines: line = line.strip() m = re.match(r'选项(\d+)[::]\s*(.+)', line) if m: idx = int(m.group(1)) opt_text = m.group(2).strip() options.append({"index": idx, "text": opt_text}) return options # ============================================================ # Converters # ============================================================ def convert_choice_image(cid, text): """图片单选 / 图片多选""" is_multi = CONFIGS[cid]['type'] == '图片多选' raw_interaction = extract_section(text, '互动内容') context = extract_section(text, '情境引入') feedback = extract_section(text, '互动反馈') post = extract_section(text, '后置对话') # Parse interaction interaction_lines = raw_interaction.strip().split('\n') instruction = interaction_lines[0].strip() audio = '(音频)' in instruction instruction = instruction.replace('(音频)', '').strip() options = [] answers = [] hint = None in_options = False in_answer = False in_hint = False for line in interaction_lines[1:]: line = line.strip() if line == '选项:': in_options = True continue if in_options and re.match(r'^\d{2}$', line): options.append(line) continue if '答案:' in line: in_options = False in_answer = True ans_text = line.replace('答案:', '').strip() if ans_text: answers.append(ans_text) continue if in_answer: if re.match(r'^\d{2}$', line): answers.append(line) elif '辅助信息' in line: in_answer = False in_hint = True hint = line.replace('辅助信息:', '').strip() else: in_answer = False continue if in_hint: continue if '辅助信息' in line: hint = line.replace('辅助信息:', '').strip() continue result = { "componentType": CONFIGS[cid]['type'], "taskTitle": extract_section(text, '任务标题'), "contextIntro": parse_context(context), "interaction": { "instruction": instruction, "audio": audio, "options": options, "answers": answers, "hint": hint }, "feedback": parse_feedback(feedback), "postDialogue": parse_context(post) } return result def convert_reading(cid, text): """对话朗读""" return { "componentType": "对话朗读", "taskTitle": extract_section(text, '任务标题'), "resourceConfig": extract_section(text, '资源配置') or None, "contextIntro": parse_context(extract_section(text, '情境引入')), "interaction": { "sentence": (extract_section(text, '互动内容') or '').replace('(朗读)', '').strip(), "audio": True }, "postDialogue": parse_context(extract_section(text, '后置对话')) } def convert_sentence_building(cid, text): """对话组句""" raw_interaction = extract_section(text, '互动内容') resource = extract_section(text, '资源配置') or None context = extract_section(text, '情境引入') feedback = extract_section(text, '互动反馈') post = extract_section(text, '后置对话') interaction_lines = raw_interaction.strip().split('\n') requirement = None sentence = None audio = True options = [] answer = None hint = None # Parse interaction i = 0 if interaction_lines[0].startswith('要求:'): requirement = interaction_lines[0].replace('要求:', '').strip() i = 1 # Find sentence line (ends with (音频)) for j in range(i, len(interaction_lines)): line = interaction_lines[j].strip() if '(音频)' in line: sentence = line.replace('(音频)', '').strip() i = j + 1 break elif re.match(r'选项\d+', line): # No sentence line found, use requirement as sentence i = j break # Parse options for j in range(i, len(interaction_lines)): line = interaction_lines[j].strip() m = re.match(r'选项(\d+)[::]\s*(.+)', line) if m: options.append({"index": int(m.group(1)), "text": m.group(2).strip()}) elif '答案:' in line: answer = line.replace('答案:', '').strip() elif '辅助信息:' in line: hint = line.replace('辅助信息:', '').strip() return { "componentType": "对话组句", "taskTitle": extract_section(text, '任务标题'), "resourceConfig": resource, "contextIntro": parse_context(context), "interaction": { "requirement": requirement, "sentence": sentence, "audio": audio, "options": options, "answer": answer, "hint": hint }, "feedback": parse_feedback(feedback), "postDialogue": parse_context(post) } def convert_selective_reading(cid, text): """对话选读""" raw_interaction = extract_section(text, '互动内容') resource = extract_section(text, '资源配置') or None context = extract_section(text, '情境引入') post = extract_section(text, '后置对话') interaction_lines = raw_interaction.strip().split('\n') requirement = interaction_lines[0].strip() audio = '(音频)' in raw_interaction if requirement.startswith('要求:'): requirement = requirement.replace('要求:', '').strip() options = parse_selective_options(raw_interaction) return { "componentType": "对话选读", "taskTitle": extract_section(text, '任务标题'), "resourceConfig": resource, "contextIntro": parse_context(context), "interaction": { "requirement": requirement, "audio": audio, "options": options }, "postDialogue": parse_context(post) } def convert_fill_blanks(cid, text): """对话挖空""" raw_interaction = extract_section(text, '互动内容') resource = extract_section(text, '资源配置') or None context = extract_section(text, '情境引入') feedback = extract_section(text, '互动反馈') post = extract_section(text, '后置对话') interaction_lines = raw_interaction.strip().split('\n') sentence = None options = [] for line in interaction_lines: line = line.strip() if '(音频)' in line and not line.startswith('选项'): sentence = line.replace('(音频)', '').strip() m = re.match(r'选项(\d+)[::]\s*(.+)', line) if m: opt_text = m.group(2).strip() correct = '(正确)' in opt_text opt_text = opt_text.replace('(正确)', '').strip() options.append({"index": int(m.group(1)), "text": opt_text, "correct": correct}) return { "componentType": "对话挖空", "taskTitle": extract_section(text, '任务标题'), "resourceConfig": resource, "contextIntro": parse_context(context), "interaction": { "sentence": sentence, "audio": True, "options": options }, "feedback": parse_feedback(feedback), "postDialogue": parse_context(post) } def convert_listening_drag(cid, text): """听力拖拽 → core_listening_drag JSON""" task_title = extract_section(text, '任务标题') task_bg = extract_section(text, '任务背景') or '' knowledge = extract_section(text, '通关知识') or '' opening = extract_section(text, '开场语') or '' audio_text = extract_section(text, '听力文本') or '' question_info = extract_section(text, '题目信息') or '' learning = extract_section(text, '学习过程') or '' # Parse audio text into dialogs dialog_list = [] for line in audio_text.strip().split('\n'): line = line.strip() if not line or line.startswith('#'): continue if ':' in line or ':' in line: sep = ':' if ':' in line else ':' parts = line.split(sep, 1) dialog_list.append({"character": parts[0].strip(), "line": parts[1].strip()}) # Parse question info option_images = [] answer_images = [] for line in question_info.strip().split('\n'): line = line.strip() if '选项图片编号' in line: option_images = [x.strip() for x in line.split(':', 1)[1].split(',') if x.strip()] if '答案图片编号' in line: # Answer on next line pass # Get answer from question info - look for answer after 答案图片编号 q_lines = question_info.strip().split('\n') for i, line in enumerate(q_lines): if '答案图片编号' in line and i + 1 < len(q_lines): answer_images = [x.strip() for x in q_lines[i+1].split(',') if x.strip() and x.strip() != '答案图片编号'] # For 听力拖拽, 【学习过程】 is the LAST section and contains inline 【kp】. # Extract it manually: everything after 【学习过程】 lp_match = re.search(r'【学习过程】\s*\n(.*)', text, re.DOTALL) learning = lp_match.group(1).strip() if lp_match else '' learning_steps = [] # Split by sentence markers blocks = re.split(r'\n(?=句子\s*\d*)', learning) for block in blocks: block = block.strip() if not block: continue # Remove the 句子 N header block = re.sub(r'^句子\s*\d*\s*\n?', '', block).strip() if not block: continue # Extract kp from 【...】 kp_match = re.search(r'【(.+?)】', block) kp = kp_match.group(1).strip() if kp_match else "" # Extract sentence (text before 【 or whole block if no 【) if kp_match: sentence = block[:kp_match.start()].strip() else: sentence = block.strip() if sentence or kp: learning_steps.append({"sentence": sentence, "knowledgePoint": kp}) # Scene description scene_desc = f"{task_bg}\n\n{opening}" return { "componentType": "听力拖拽", "cType": "core_listening_drag", "taskData": { "cType": "core_listening_drag", "cId": cid, "title": task_title, "sceneDesc": scene_desc, "key": "ago, month, year, get" }, "dialogList": dialog_list, "preDialog": [], "questionList": [ { "type": "drag_match", "optionImages": option_images, "answerImages": answer_images, "itemCount": len(option_images) } ], "learningData": { "learningPart": learning_steps, "closing": "" }, "audioText": audio_text, "knowledgeSummary": knowledge } # ============================================================ # Main conversion # ============================================================ CONVERTERS = { "图片单选": convert_choice_image, "图片多选": convert_choice_image, "对话朗读": convert_reading, "对话组句": convert_sentence_building, "对话选读": convert_selective_reading, "对话挖空": convert_fill_blanks, "听力拖拽": convert_listening_drag, } # Component row mapping (from sheet data: which row has which component ID) COMPONENT_ROWS = { "1217201": 33, "1217202": 40, "1217203": 43, "1217204": 50, "1217205": 51, "1217206": 53, "1217207": 68, "1217208": 70, "1217209": 89, "1217210": 95, "1217211": 107, "1217212": 120, "1217213": 123, "1217214": 157, "1217215": 164, "1217216": 168, "1217217": 173, } def write_cell(row, col_letter, value, token): """Write a single cell to the sheet.""" cell_range = f"{SHEET_ID}!{col_letter}{row}:{col_letter}{row}" payload = { "valueRange": { "range": cell_range, "values": [[value]] } } r = subprocess.run([ 'curl', '-s', '-X', 'PUT', f'https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values', '-H', f'Authorization: Bearer {token}', '-H', 'Content-Type: application/json', '-d', json.dumps(payload, ensure_ascii=False) ], capture_output=True, text=True) result = json.loads(r.stdout) if result.get('code') != 0: print(f" ❌ Write failed: {result}", file=sys.stderr) return False return True def main(): results = {} for cid, cfg in CONFIGS.items(): ctype = cfg['type'] converter = CONVERTERS.get(ctype) if not converter: print(f"⚠️ Unknown type {ctype} for {cid}", file=sys.stderr) continue try: json_data = converter(cid, cfg['text']) results[cid] = json_data print(f"✅ {cid} ({ctype}) → JSON OK") except Exception as e: print(f"❌ {cid} ({ctype}) → ERROR: {e}", file=sys.stderr) import traceback traceback.print_exc() # Print all JSONs for review print("\n" + "=" * 60) print("GENERATED JSONS") print("=" * 60) for cid, data in results.items(): print(f"\n--- {cid} ({data['componentType']}) ---") print(json.dumps(data, ensure_ascii=False, indent=2)) # Save to file for inspection with open('/root/.openclaw/workspace-xiaoyan/output/component_jsons.json', 'w', encoding='utf-8') as f: json.dump(results, f, ensure_ascii=False, indent=2) print(f"\n📁 Saved to output/component_jsons.json") # Write back to sheet print("\n" + "=" * 60) print("WRITING BACK TO SHEET") print("=" * 60) # Re-fetch token (may have expired) token = get_token() success = 0 fail = 0 for cid, data in results.items(): row = COMPONENT_ROWS.get(cid) if not row: print(f"⚠️ {cid}: no row mapping, skipped") continue json_str = json.dumps(data, ensure_ascii=False) if write_cell(row, 'I', json_str, token): print(f"✅ {cid} → row {row} written") success += 1 else: fail += 1 print(f"\nDone: {success} written, {fail} failed") if __name__ == '__main__': main()