---
name: feishu-embedded-sheet
version: 1.0.0
description: |
  飞书云文档中内嵌电子表格（Sheet）的读取与写入技能。
  当飞书 docx 文档中包含 `<sheet token="xxx"/>` 内嵌表格时，使用本技能进行精准的行列级读写操作。
  
  **触发场景：**
  - 需要读取飞书文档中内嵌的 Excel/Sheet 表格数据
  - 需要向飞书文档中内嵌的 Sheet 写入/更新数据
  - 用户提到"文档里的表格"、"内嵌表格"、"剧本表格"等
  - 从文档 markdown 中看到 `<sheet token="xxx_yyy"/>` 标签
metadata:
  requires:
    permissions: ["sheets:spreadsheet:readonly", "sheets:spreadsheet"]
  identity: bot
---

# 飞书文档内嵌 Sheet 读写技能

## 核心概念

飞书 docx 文档可以内嵌电子表格（Sheet）。通过 `lark-cli docs +fetch` 获取文档 markdown 时，内嵌表格会渲染为：

```
<sheet token="WreDs73X1hrgdFtxdpJcItj2n5c_RuQopk"/>
```

**Token 结构解析：**
- 完整 token：`WreDs73X1hrgdFtxdpJcItj2n5c_RuQopk`
- `_` 前面部分 = `spreadsheet_token`：`WreDs73X1hrgdFtxdpJcItj2n5c`
- `_` 后面部分 = `sheet_id`：`RuQopk`

同一个文档内嵌的多个 Sheet 共享同一个 `spreadsheet_token`，通过不同的 `sheet_id` 区分。

## 前置步骤：获取 Bot Token

所有操作均使用 **Bot 身份**执行，禁止触发用户授权。

```bash
APP_ID="cli_a931175d41799cc7"
APP_SECRET=$(jq -r '.apps[0].appSecret' /root/.openclaw/credentials/xiaoyan/config.json)

TOKEN=$(curl -s -X POST "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" \
  -H "Content-Type: application/json" \
  -d "{\"app_id\":\"$APP_ID\",\"app_secret\":\"$APP_SECRET\"}" \
  | jq -r '.tenant_access_token')
```

Token 有效期 2 小时，过期需重新获取。

## 完整执行链路

### 第一步：从文档中提取 Sheet Token

1. 先通过 `lark_wiki_operate_as_bot` 技能获取文档内容
2. 在返回的 markdown 中查找 `<sheet token="xxx"/>` 标签
3. 解析出 `spreadsheet_token` 和 `sheet_id`

```bash
# 获取文档内容（以 wiki 文档为例）
LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoyan \
  lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --as bot

LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaoyan \
  lark-cli docs +fetch --doc <obj_token> --as bot
```

### 第二步：获取 Sheet 元数据（了解表结构）

```bash
curl -s -X GET \
  "https://open.feishu.cn/open-apis/sheets/v3/spreadsheets/${SPREADSHEET_TOKEN}/sheets/query" \
  -H "Authorization: Bearer $TOKEN" | jq '.'
```

返回信息包括：
- `sheet_id`：每个子表的 ID
- `title`：子表名称
- `grid_properties.row_count`：总行数
- `grid_properties.column_count`：总列数
- `grid_properties.frozen_row_count`：冻结行数（通常表头行）
- `merges`：合并单元格信息

### 第三步：读取数据

#### 读取指定范围

```bash
# 格式：{sheet_id}!{起始单元格}:{结束单元格}
curl -s -X GET \
  "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/${SPREADSHEET_TOKEN}/values/${SHEET_ID}!A1:J196?valueRenderOption=ToString" \
  -H "Authorization: Bearer $TOKEN" | jq '.data.valueRange.values'
```

**参数说明：**
- `valueRenderOption=ToString`：所有值转为字符串返回（推荐，避免类型混乱）
- Range 格式：`{sheet_id}!{列字母}{行号}:{列字母}{行号}`
- 行号从 1 开始，列用字母表示（A=1, B=2, ..., Z=26, AA=27...）

#### 读取全表

先从元数据获取 `row_count` 和 `column_count`，然后构造完整 range：

```bash
# 例：10列196行
curl -s -X GET \
  "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/${SPREADSHEET_TOKEN}/values/${SHEET_ID}!A1:J196?valueRenderOption=ToString" \
  -H "Authorization: Bearer $TOKEN"
```

#### 读取结果处理

返回的 `values` 是二维数组，每个元素是一行：
```json
{
  "data": {
    "valueRange": {
      "values": [
        ["类型", "组件配置", "ID", ...],   // 第1行（表头）
        ["TL\nxxx", "S1前置xxx", null, ...], // 第2行
        ...
      ]
    }
  }
}
```

- `null` 表示空单元格
- 换行符在值中以 `\n` 表示
- 合并单元格：只有左上角单元格有值，其余为 `null`

### 第四步：写入数据

#### ⚠️ 步骤 4.0：行号连续性强制校验（写入前必须执行，不可跳过）

**问题：** 飞书 Sheets API 的 range 写入按连续区域覆盖。如果 range 指定 `F4:F67`，API 会把数据依次写入 Row 4, 5, 6, ..., 67，**不管这些行是否对应正确的数据行**。

**典型事故：** 剧本表格中，E 列有角色名的行号不连续（Row 4 有台词、Row 5 是剧情描述空行、Row 6 又有台词），如果按连续 range 批量写入，Row 6 的英文台词会写到 Row 5 上，造成全列偏移。

**强制规则（不可跳过）：**

```python
def validate_and_write(spreadsheet_token, sheet_id, column_letter, data: dict, token):
    """
    写入前强制校验行号连续性，不连续时自动切换逐行写入。
    
    Args:
        data: {行号: "内容"} 字典，如 {118: "They are like kids.", 120: "Enough!", ...}
    """
    rows = sorted(data.keys())
    
    # 检查连续性
    is_continuous = all(rows[i+1] - rows[i] == 1 for i in range(len(rows)-1))
    
    if is_continuous:
        # ✅ 行号连续 → 批量 range 写入（高效）
        values = [[data[r]] for r in rows]
        range_str = f"{sheet_id}!{column_letter}{rows[0]}:{column_letter}{rows[-1]}"
        write_range(spreadsheet_token, range_str, values, token)
        print(f"✅ 批量写入: {range_str} ({len(rows)} 行，行号连续)")
    else:
        # ⚠️ 行号不连续 → 必须逐行写入（安全）
        print(f"⚠️ 行号不连续，切换逐行写入（共 {len(rows)} 行）")
        gaps = [rows[i+1] - rows[i] for i in range(len(rows)-1) if rows[i+1] - rows[i] > 1]
        print(f"   跳空位置: {[(rows[i], rows[i+1]) for i in range(len(rows)-1) if rows[i+1] - rows[i] > 1]}")
        
        for row in rows:
            range_str = f"{sheet_id}!{column_letter}{row}:{column_letter}{row}"
            write_range(spreadsheet_token, range_str, [[data[row]]], token)
            time.sleep(0.15)  # 逐行写入间隔，避免频率限制
    
    # 写入后立即执行 E-F 对齐校验（步骤 4.3）
    verify_ef_alignment(spreadsheet_token, sheet_id, rows, token)
```

**禁止行为：**
- ❌ 直接构造连续 range（如 `F118:F142`）而不检查行号是否真的连续
- ❌ 假设行号连续、跳过校验
- ❌ 写入后不回读验证

#### 步骤 4.1：写入/更新指定行

```bash
curl -s -X PUT \
  "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/${SPREADSHEET_TOKEN}/values" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "valueRange": {
      "range": "'${SHEET_ID}'!A197:J197",
      "values": [
        ["对话测试", "S99主线\n自动\n对话测试", "9999901", "剧情描述", "User", "台词", "User", "润色台词", null, "知识点"]
      ]
    }
  }'
```

#### 步骤 4.2：批量写入多行（仅行号连续时使用）

```bash
# ⚠️ 仅当行号连续时可用！写入前必须先执行步骤 4.0 校验
curl -s -X PUT \
  "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/${SPREADSHEET_TOKEN}/values" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "valueRange": {
      "range": "'${SHEET_ID}'!A197:J199",
      "values": [
        ["行1列A", "行1列B", ...],
        ["行2列A", "行2列B", ...],
        ["行3列A", "行3列B", ...]
      ]
    }
  }'
```

#### 步骤 4.3：写入后 E-F 对齐验证（强制执行）

写入完成后，必须回读 E 列和 F 列，逐行验证对齐：

```python
def verify_ef_alignment(spreadsheet_token, sheet_id, written_rows, token):
    """
    写入后验证：E 列有角色名的行，F 列必须有对应台词；
    E 列为空的行（剧情描述行），F 列必须为空。
    """
    min_row = min(written_rows)
    max_row = max(written_rows)
    
    # 回读 E 列和 F 列
    range_str = f"{sheet_id}!E{min_row}:F{max_row}"
    values = read_range(spreadsheet_token, range_str, token)
    
    errors = []
    for i, row_data in enumerate(values):
        actual_row = min_row + i
        e_val = row_data[0] if len(row_data) > 0 else None  # E列：角色名
        f_val = row_data[1] if len(row_data) > 1 else None  # F列：英文台词
        
        if e_val and not f_val:
            # E 列有角色名但 F 列为空 → 台词缺失
            errors.append(f"行{actual_row}: E列有角色[{e_val}]但F列为空，台词缺失")
        elif not e_val and f_val:
            # E 列为空但 F 列有内容 → 位移！台词写到了剧情描述行
            errors.append(f"行{actual_row}: E列为空但F列有内容[{f_val[:30]}...]，台词位移到非台词行！")
    
    if errors:
        print("❌ E-F 对齐校验失败：")
        for e in errors:
            print(f"   {e}")
        raise RuntimeError("写入后对齐校验失败，数据可能错位，请检查并修复")
    else:
        print(f"✅ E-F 对齐校验通过（{len(written_rows)} 行）")
```

#### 更新指定单元格

```bash
# 只更新单个单元格
curl -s -X PUT \
  "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/${SPREADSHEET_TOKEN}/values" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "valueRange": {
      "range": "'${SHEET_ID}'!H50:H50",
      "values": [["更新后的台词润色内容"]]
    }
  }'
```

#### 清空单元格

写入空字符串 `""` 即可清空单元格内容。

### 第五步：写入后回读验证

**必须回读验证**，确保写入内容与预期一致：

```bash
curl -s -X GET \
  "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/${SPREADSHEET_TOKEN}/values/${SHEET_ID}!A197:J197?valueRenderOption=ToString" \
  -H "Authorization: Bearer $TOKEN" | jq '.data.valueRange.values'
```

## 注意事项

1. **身份限制**：所有操作使用 Bot 身份，需要 Bot 应用对目标文档有编辑权限
2. **权限要求**：Bot 需在飞书开发者后台开通 `sheets:spreadsheet` 权限；目标文档/知识空间需将 Bot 添加为成员并授予编辑权限
3. **合并单元格**：写入合并单元格区域时，只写入左上角单元格即可，其余位置保持 null
4. **行数限制**：单次读取/写入建议不超过 500 行，超大表格请分批操作
5. **并发写入**：避免同时多次写入同一个 Sheet，可能导致数据覆盖
6. **数据类型**：写入时，数字直接用 number 类型，文本用 string，空值用 null
7. **换行符**：需要在单元格内换行时，在字符串中使用 `\n`



## 读取后错行/乱行校验（强制执行）

### 校验目标

读取 Sheet 数据后，必须自动检测关键列之间的行号对齐关系，防止以下问题：
- 组件配置（G列）写入了错误行，导致内容与组件名称（A列）错位
- 英文台词（F列）写入了错误行，导致台词与角色（E列）错位
- 批量写入时行号偏移导致整列数据整体下移/上移

### 校验规则（剧本表格 A-I 列）

#### 规则1：A列-G列 对齐校验（组件类型 vs 组件配置）

```
如果 A列[row] 是组件类型（匹配：对话朗读|对话挖空|对话选择|对话组句|对话表达|对话选读|图片单选|图片多选|信息拼词|看图选词|听力\S+|阅读\S+|写作\S+|口语\S+）
  → G列[row] 必须非空（包含组件配置内容）

如果 G列[row] 非空（包含组件配置内容）
  → A列[row] 必须是组件类型（不能是 TL / 空 / 其他非组件标记）
```

#### 规则2：B列-G列 对齐校验（组件ID vs 组件配置）

```
如果 B列[row] 是数字（组件ID）
  → G列[row] 必须非空

如果 G列[row] 非空且包含 "【组件ID】" 标记
  → 提取的组件ID 必须与 B列[row] 一致
```

#### 规则3：E列-F列 对齐校验（角色名 vs 英文台词）

```
如果 E列[row] 非空（有角色名）且 A列[row] 不是 TL / 空（非纯剧情行）
  → F列[row] 建议非空（角色说话应有台词）
  → 若 F列[row] 为空，标记为"疑似缺失"（可能是剧情描述行，需人工确认）
```

#### 规则4：G列 内容归属校验（防止配置写入非组件行）

```
扫描 G列 所有非空单元格：
  如果 G列[row] 包含组件配置特征（"【任务标题】" / "【资源配置】" / "【互动内容】"）
    且 A列[row] 为 TL / 空 / 非组件类型
    → 标记为"错行"：组件配置写入了非组件行
```

### 校验执行流程

```python
# 读取 Sheet 数据后立即执行
def validate_sheet_alignment(rows):
    errors = []
    warnings = []
    
    component_pattern = re.compile(
        r'^(对话朗读|对话挖空|对话选择|对话组句|对话表达|对话选读|'
        r'图片单选|图片多选|信息拼词|看图选词|'
        r'听力\S+|阅读\S+|写作\S+|口语\S+)'
    )
    
    for i, row in enumerate(rows):
        row_num = i + 1  # 1-indexed
        a_val = row[0] if len(row) > 0 else None  # A列：类型
        b_val = row[1] if len(row) > 1 else None  # B列：ID
        e_val = row[4] if len(row) > 4 else None  # E列：角色名
        f_val = row[5] if len(row) > 5 else None  # F列：英文台词
        g_val = row[6] if len(row) > 6 else None  # G列：组件配置
        
        # 规则1：A-G 对齐
        if a_val and component_pattern.match(str(a_val)):
            if not g_val:
                errors.append(f"行{row_num}: A列=[{a_val}] 但 G列为空，组件配置缺失")
        
        if g_val and '【任务标题】' in str(g_val):
            if not a_val or not component_pattern.match(str(a_val)):
                errors.append(f"行{row_num}: G列有组件配置但 A列=[{a_val}] 非组件类型，疑似错行")
        
        # 规则2：B-G 对齐
        if b_val and str(b_val).isdigit():
            if not g_val:
                errors.append(f"行{row_num}: B列=[{b_val}] 但 G列为空，组件配置缺失")
            elif '【组件ID】' in str(g_val):
                g_id = extract_component_id(str(g_val))
                if g_id and str(g_id) != str(b_val):
                    errors.append(f"行{row_num}: B列ID=[{b_val}] 与 G列组件ID=[{g_id}] 不一致")
        
        # 规则3：E-F 对齐
        if e_val and a_val and a_val not in ('TL', ''):
            if not f_val:
                warnings.append(f"行{row_num}: E列=[{e_val}] 但 F列为空，疑似台词缺失")
        
        # 规则4：G列归属
        if g_val and ('【任务标题】' in str(g_val) or '【互动内容】' in str(g_val)):
            if not a_val or a_val in ('TL', ''):
                errors.append(f"行{row_num}: G列有组件配置但 A列=[{a_val}]，组件配置错行到非组件行")
    
    return errors, warnings
```

### 校验结果处理

| 级别 | 条件 | 处理方式 |
|------|------|---------|
| **错误 (Error)** | A-G 不对齐 / B-G ID不一致 / G列错行到非组件行 | 立即报告用户，附具体行号和错位内容，暂停后续写入操作 |
| **警告 (Warning)** | E-F 疑似缺失 / 其他非关键不对齐 | 汇总报告，继续执行但标注"需人工确认" |

### 校验触发时机

1. **读取后立即校验**：任何读取 Sheet 数据的操作完成后，自动执行上述校验
2. **写入前校验**：写入操作前，先读取目标区域，校验当前数据对齐状态
3. **写入后回读校验**：写入完成后回读，再次执行对齐校验，确保写入未引入新错位

## 异常处理

| 错误码 | 含义 | 处理方式 |
|--------|------|---------|
| `1310251` | spreadsheet_token 格式错误 | 检查是否正确从 sheet token 中拆分出 spreadsheet_token |
| `1310201` | 无权限 | Bot 未被添加为文档/知识空间成员，需用户授权 |
| `1310212` | Range 格式错误 | 检查 sheet_id 和单元格范围格式 |
| `1310214` | 超出表格范围 | 检查行列号是否超出实际表格大小 |

## 与其他技能的配合

- **读取文档内容**：先用 `lark_wiki_operate_as_bot` 技能获取文档 markdown，从中提取 `<sheet>` 标签
- **多维表格操作**：如果目标是独立的多维表格（Bitable），应使用 `lark_bitable_operate_as_bot` 技能，而非本技能
- **文档写入**：如果需要修改文档中 Sheet 以外的内容（文本、表格等），使用 `feishu-update-doc` 技能
