refactor: 通用技能按类别拆分为独立目录

skills/ → skills-dev(9), skills-req(10), skills-ops(4),
skills-integration(8), skills-biz(4), skills-workflow(7)

generate-marketplace.py 改为自动扫描所有 skills-* 目录。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 11:31:58 +10:30
parent ea266e9cce
commit 712063071c
170 changed files with 341 additions and 346 deletions

View File

@@ -0,0 +1,8 @@
{
"name": "data-excel-plugin",
"description": "Plugin for data-excel",
"version": "1.0.0",
"author": {
"name": "qiudl"
}
}

View File

@@ -0,0 +1,443 @@
---
name: data-excel
description: Excel 数据处理与 BI 集成。通过自然语言操作 Excel 文件的读取、编辑、转换,并支持导入到 BI 系统Metabase进行可视化分析。当用户提到 Excel、表格处理、数据导入、BI 分析相关任务时自动激活。
---
# Excel 数据处理与 BI 集成 Skill
## 功能概述
- **Excel 读取**: 读取 .xlsx/.xls 文件内容,支持多 Sheet
- **Excel 编辑**: 修改单元格、添加/删除行列、格式化
- **数据转换**: Excel ↔ CSV ↔ JSON ↔ SQL
- **BI 集成**: 导入数据到 Metabase 进行可视化
---
## 环境依赖
```bash
# Python 包(推荐使用 uv
uv pip install pandas openpyxl xlrd xlsxwriter sqlalchemy pymysql
# 或使用 pip
pip install pandas openpyxl xlrd xlsxwriter sqlalchemy pymysql
```
---
## 自然语言操作示例
### 读取操作
| 用户说 | 执行操作 |
|--------|----------|
| "读取这个 Excel 文件" | 读取并显示内容摘要 |
| "看一下第二个 Sheet" | 切换到指定 Sheet |
| "显示前 20 行" | 限制显示行数 |
| "这个表有哪些列" | 列出列名和数据类型 |
### 编辑操作
| 用户说 | 执行操作 |
|--------|----------|
| "把 A 列的空值填充为 0" | 填充缺失值 |
| "删除重复行" | 去重 |
| "把日期列转成 YYYY-MM-DD 格式" | 格式化日期 |
| "添加一列计算总价=单价×数量" | 新增计算列 |
| "筛选销售额大于 1000 的记录" | 过滤数据 |
| "按部门汇总销售额" | 分组聚合 |
### 导出操作
| 用户说 | 执行操作 |
|--------|----------|
| "导出为 CSV" | 转换格式 |
| "生成 SQL 插入语句" | 生成 INSERT 语句 |
| "导入到数据库" | 写入 MySQL/PostgreSQL |
| "上传到 Metabase" | BI 系统集成 |
---
## Python 代码模板
### 读取 Excel
```python
import pandas as pd
# 读取 Excel 文件
df = pd.read_excel('data.xlsx')
# 读取指定 Sheet
df = pd.read_excel('data.xlsx', sheet_name='Sheet2')
# 读取所有 Sheet
all_sheets = pd.read_excel('data.xlsx', sheet_name=None)
for name, sheet_df in all_sheets.items():
print(f"Sheet: {name}, 行数: {len(sheet_df)}")
# 显示基本信息
print(df.head(10)) # 前 10 行
print(df.columns) # 列名
print(df.dtypes) # 数据类型
print(df.describe()) # 统计摘要
```
### 编辑 Excel
```python
import pandas as pd
df = pd.read_excel('data.xlsx')
# 填充空值
df['列名'].fillna(0, inplace=True)
# 删除重复行
df.drop_duplicates(inplace=True)
# 添加计算列
df['总价'] = df['单价'] * df['数量']
# 筛选数据
df_filtered = df[df['销售额'] > 1000]
# 分组汇总
df_summary = df.groupby('部门')['销售额'].sum().reset_index()
# 日期格式化
df['日期'] = pd.to_datetime(df['日期']).dt.strftime('%Y-%m-%d')
# 重命名列
df.rename(columns={'旧列名': '新列名'}, inplace=True)
# 排序
df.sort_values(by='销售额', ascending=False, inplace=True)
# 保存修改
df.to_excel('output.xlsx', index=False)
```
### 格式化 Excel带样式
```python
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
# 创建 Excel 并设置样式
df = pd.read_excel('data.xlsx')
with pd.ExcelWriter('styled_output.xlsx', engine='openpyxl') as writer:
df.to_excel(writer, index=False, sheet_name='数据')
workbook = writer.book
worksheet = writer.sheets['数据']
# 设置标题行样式
header_font = Font(bold=True, color='FFFFFF')
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
for col in range(1, len(df.columns) + 1):
cell = worksheet.cell(row=1, column=col)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
# 自动调整列宽
for column in worksheet.columns:
max_length = max(len(str(cell.value or '')) for cell in column)
worksheet.column_dimensions[column[0].column_letter].width = max_length + 2
```
---
## 数据转换
### Excel → CSV
```python
import pandas as pd
df = pd.read_excel('data.xlsx')
df.to_csv('output.csv', index=False, encoding='utf-8-sig') # utf-8-sig 解决中文乱码
```
### Excel → JSON
```python
import pandas as pd
df = pd.read_excel('data.xlsx')
# 转为 JSON 数组
json_str = df.to_json(orient='records', force_ascii=False, indent=2)
print(json_str)
# 保存为文件
with open('output.json', 'w', encoding='utf-8') as f:
f.write(json_str)
```
### Excel → SQL INSERT
```python
import pandas as pd
df = pd.read_excel('data.xlsx')
table_name = 'my_table'
# 生成 INSERT 语句
def generate_insert_sql(df, table_name):
columns = ', '.join(df.columns)
values_list = []
for _, row in df.iterrows():
values = ', '.join([f"'{v}'" if isinstance(v, str) else str(v) for v in row])
values_list.append(f"({values})")
sql = f"INSERT INTO {table_name} ({columns}) VALUES\n" + ',\n'.join(values_list) + ';'
return sql
sql = generate_insert_sql(df, table_name)
print(sql)
```
### Excel → MySQL 直接导入
```python
import pandas as pd
from sqlalchemy import create_engine
# 数据库连接
engine = create_engine('mysql+pymysql://user:password@host:3306/database')
# 读取 Excel
df = pd.read_excel('data.xlsx')
# 导入数据库(如果表存在则替换)
df.to_sql('table_name', engine, if_exists='replace', index=False)
# 或追加数据
df.to_sql('table_name', engine, if_exists='append', index=False)
```
---
## BI 系统集成 (Metabase)
### Metabase 服务信息
| 项目 | 值 |
|------|-----|
| 服务器 | prod-metaBI (192.144.174.87) |
| SSH 用户 | ubuntu |
| SSH 密钥 | ~/.ssh/prod_meta.pem |
| 系统 | Ubuntu 24.04 LTS |
| 数据源 | MySQL |
| 所属公司 | 北京欢乐宿 |
### SSH 连接
```bash
# 连接 Metabase 服务器
ssh prod-metaBI
# 查看 MySQL 状态
ssh prod-metaBI "docker ps | grep mysql"
# 查看 Metabase 容器状态
ssh prod-metaBI "docker ps | grep metabase"
```
### MySQL 连接配置 (Metabase 数据源)
| 配置项 | 值 |
|--------|-----|
| Host | `127.0.0.1` |
| Port | `3306` |
| Database | `finance_db` |
| Username | `root` |
| Password | `root123456` |
> **注意**: MySQL 8.0 需使用 `mysql_native_password` 认证,否则会报 RSA 公钥错误。
### 数据导入流程
```
Excel 文件 → Python 处理 → MySQL 数据库 → Metabase 可视化
```
### 完整导入脚本
```python
import pandas as pd
from sqlalchemy import create_engine
import os
def excel_to_metabase(excel_path, table_name, db_config):
"""
将 Excel 数据导入到 Metabase 可查询的数据库
Args:
excel_path: Excel 文件路径
table_name: 目标表名
db_config: 数据库配置 dict
"""
# 读取 Excel
df = pd.read_excel(excel_path)
# 数据清洗
df.columns = df.columns.str.strip() # 去除列名空格
df = df.dropna(how='all') # 删除全空行
# 连接数据库
engine = create_engine(
f"mysql+pymysql://{db_config['user']}:{db_config['password']}"
f"@{db_config['host']}:{db_config['port']}/{db_config['database']}"
)
# 导入数据
df.to_sql(table_name, engine, if_exists='replace', index=False)
print(f"✓ 已导入 {len(df)} 行数据到表 {table_name}")
print(f"→ 现在可以在 Metabase 中查询此表")
return df
# 使用示例
db_config = {
'host': 'localhost',
'port': 3306,
'user': 'metabase_user',
'password': 'your_password',
'database': 'analytics'
}
excel_to_metabase('sales_data.xlsx', 'sales_report', db_config)
```
### Metabase 常用查询模板
导入数据后,在 Metabase 中可使用以下查询:
```sql
-- 数据概览
SELECT * FROM imported_table LIMIT 100;
-- 按日期汇总
SELECT DATE(created_at) as date, COUNT(*) as count, SUM(amount) as total
FROM imported_table
GROUP BY DATE(created_at)
ORDER BY date;
-- 分类统计
SELECT category, COUNT(*) as count, AVG(value) as avg_value
FROM imported_table
GROUP BY category
ORDER BY count DESC;
```
---
## 常见场景
### 场景 1: 合并多个 Excel 文件
```python
import pandas as pd
import glob
# 合并目录下所有 Excel 文件
files = glob.glob('data/*.xlsx')
df_list = [pd.read_excel(f) for f in files]
df_merged = pd.concat(df_list, ignore_index=True)
df_merged.to_excel('merged.xlsx', index=False)
print(f"已合并 {len(files)} 个文件,共 {len(df_merged)}")
```
### 场景 2: 数据透视表
```python
import pandas as pd
df = pd.read_excel('sales.xlsx')
# 创建透视表
pivot = pd.pivot_table(
df,
values='销售额',
index='产品类别',
columns='月份',
aggfunc='sum',
fill_value=0
)
pivot.to_excel('pivot_report.xlsx')
```
### 场景 3: 数据校验
```python
import pandas as pd
df = pd.read_excel('data.xlsx')
# 检查空值
null_counts = df.isnull().sum()
print("空值统计:\n", null_counts[null_counts > 0])
# 检查重复
duplicates = df[df.duplicated()]
print(f"重复行数: {len(duplicates)}")
# 数据类型检查
print("数据类型:\n", df.dtypes)
```
### 场景 4: 生成报表
```python
import pandas as pd
df = pd.read_excel('data.xlsx')
# 生成多 Sheet 报表
with pd.ExcelWriter('report.xlsx') as writer:
# 原始数据
df.to_excel(writer, sheet_name='原始数据', index=False)
# 汇总数据
summary = df.groupby('部门').agg({
'销售额': 'sum',
'订单数': 'count'
}).reset_index()
summary.to_excel(writer, sheet_name='部门汇总', index=False)
# 统计信息
stats = df.describe()
stats.to_excel(writer, sheet_name='统计信息')
```
---
## 工作流程
```
1. 用户上传 Excel 文件或提供路径
2. 用自然语言描述需求(如"按月份汇总销售额"
3. Claude 生成并执行 Python 代码
4. 返回处理结果或导出文件
5. 可选:导入到 BI 系统进行可视化
```
---
## 注意事项
- Excel 文件路径使用绝对路径更可靠
- 中文 CSV 导出使用 `encoding='utf-8-sig'` 避免乱码
- 大文件(>10万行考虑分批处理
- 导入数据库前先备份现有数据
- 敏感数据注意脱敏处理

View File

@@ -0,0 +1,14 @@
{
"name": "doubao-voice-plugin",
"description": "Doubao (豆包) Voice API integration for TTS and ASR",
"version": "1.0.0",
"author": {
"name": "qiudl"
},
"skills": [
{
"name": "doubao-voice",
"path": "./skills/SKILL.md"
}
]
}

View File

@@ -0,0 +1,54 @@
# 音频文件(生成的测试输出)
*.mp3
*.wav
*.pcm
# 测试脚本(仅本地使用)
scripts/test_*.py
scripts/check_credentials.py
scripts/README_TEST.md
# 系统文件
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# IDE
.vscode/
.idea/
*.swp
*.swo
# 环境配置(包含凭证的本地文件)
setup_env.local.sh
.env
.env.local
# 测试生成的文件
*.log
test_output/

View File

@@ -0,0 +1,201 @@
# 部署指南
## 在另一台电脑上使用这个 Skill
### ✅ 可以直接使用吗?
**大部分功能可以直接使用!** 但需要做一些简单的配置。
---
## 📋 部署步骤
### 1⃣ 将插件复制到新电脑
```bash
# 方式1: 从Git克隆
git clone <repo-url> doubao-voice-plugin
# 方式2: 复制文件夹
cp -r doubao-voice-plugin /path/to/new/location
```
### 2⃣ 安装依赖
**核心依赖** (必需):
```bash
pip3 install requests
```
**可选依赖** (仅用voice_converter_sdk.py时需要):
```bash
pip3 install volcengine
```
**检查是否安装成功**:
```bash
python3 -c "import requests; print('✅ requests 已安装')"
```
### 3⃣ 配置凭证
创建本地配置文件:
```bash
cd scripts
cp setup_env.local.sh.example setup_env.local.sh
```
编辑 `setup_env.local.sh`,填入您的火山引擎凭证:
```bash
export DOUBAO_APP_ID="your_app_id"
export DOUBAO_ACCESS_TOKEN="your_access_token"
```
### 4⃣ 使用
```bash
# 加载环境变量
source scripts/setup_env.local.sh
# 文字转语音
python3 scripts/voice_converter.py tts "你好世界" -o hello.mp3
# 语音转文字需先启用ASR服务
python3 scripts/voice_converter.py asr audio.mp3
```
---
## 🔧 系统要求
| 需求 | 版本 | 状态 |
|------|------|------|
| **Python** | 3.6+ | ✅ 必需 |
| **requests** | 任意版本 | ✅ 必需 |
| **volcengine** | 任意版本 | ⚠️ 可选 |
| **操作系统** | Linux/Mac/Windows | ✅ 都支持 |
---
## 🚨 常见问题
### Q: 错误 "ModuleNotFoundError: No module named 'requests'"
**解决**:
```bash
pip3 install requests
```
### Q: 错误 "DOUBAO_APP_ID not found"
**解决**:
```bash
# 检查环境变量
echo $DOUBAO_APP_ID
# 如果为空,重新加载配置
source setup_env.local.sh
```
### Q: 为什么 ASR 不工作?
**原因**: 需要在火山引擎控制台启用 ASR 服务
**解决**: 访问 https://console.volcengine.com/speech/service启用语音识别服务
### Q: 可以在 Windows 上使用吗?
**可以!** 但环境变量设置方式不同:
```batch
REM Windows CMD
set DOUBAO_APP_ID=your_app_id
set DOUBAO_ACCESS_TOKEN=your_access_token
python scripts\voice_converter.py tts "你好" -o hello.mp3
```
或在 PowerShell
```powershell
$env:DOUBAO_APP_ID="your_app_id"
$env:DOUBAO_ACCESS_TOKEN="your_access_token"
python scripts/voice_converter.py tts "你好" -o hello.mp3
```
### Q: 如何在 Docker 中使用?
**Dockerfile 示例**:
```dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY . .
RUN pip install requests
ENV DOUBAO_APP_ID=${DOUBAO_APP_ID}
ENV DOUBAO_ACCESS_TOKEN=${DOUBAO_ACCESS_TOKEN}
ENTRYPOINT ["python", "scripts/voice_converter.py"]
```
运行:
```bash
docker build -t doubao-voice .
docker run -e DOUBAO_APP_ID=xxx -e DOUBAO_ACCESS_TOKEN=xxx doubao-voice tts "你好"
```
---
## 📦 三种使用方式
### 方式 1: 命令行 (推荐简单使用)
```bash
python3 scripts/voice_converter.py tts "文本" -o output.mp3
```
### 方式 2: Python 模块导入
```python
import sys
sys.path.insert(0, 'scripts')
from voice_converter import DoubaoVoiceConverter
converter = DoubaoVoiceConverter()
converter.text_to_speech("你好世界", output_file="hello.mp3")
```
### 方式 3: Claude Code Skill (自动)
如果安装在 Claude Code 的 plugins 目录,会自动识别为 Skill
```bash
# 用户说: "把这段话转成语音:你好世界"
# → 自动调用 TTS API
```
---
## 🔐 安全提示
**推荐做法**:
- 凭证存储在 `.local` 文件中(不在 Git 中)
- 使用环境变量而不是硬编码
- 定期更新 Access Token
**不要做**:
- 不要把凭证提交到 Git
- 不要在脚本中硬编码凭证
- 不要分享包含凭证的配置文件
---
## 📝 最小化部署清单
```bash
✅ 复制文件夹
✅ pip install requests
✅ 复制并编辑 setup_env.local.sh
source setup_env.local.sh
✅ python3 scripts/voice_converter.py tts "测试"
✅ 成功!
```
---
## 🆘 如需帮助
1. 检查 README.md (用户文档)
2. 查看 skills/SKILL.md (API 文档)
3. 查看 STATUS.md (开发状态)

View File

@@ -0,0 +1,196 @@
# Git 提交指南
## 📋 提交清单
### ✅ 应该提交的文件
```bash
git add .
git status # 确认以下文件已staged
应包含:
- .claude-plugin/plugin.json # 插件配置
- skills/SKILL.md # 技能文档
- scripts/voice_converter.py # 核心工具
- scripts/voice_converter_v2.py # 备选方案
- scripts/voice_converter_sdk.py # 备选方案
- scripts/check_credentials.py # 诊断工具
- scripts/test_services.py # 服务测试
- scripts/test_v3_debug.py # V3调试工具
- scripts/setup_env.sh # 示例脚本(占位符版本)
- scripts/setup_env.local.sh.example # 本地配置模板
- README.md # 用户文档
- STATUS.md # 开发状态
- .gitignore # Git忽略规则
- GIT_GUIDE.md # 本文件
```
### ❌ 被自动忽略的文件(勿手动提交)
```bash
# .gitignore 已配置,以下文件不会被提交:
- *.mp3, *.wav, *.pcm # 音频文件
- .DS_Store # 系统文件
- setup_env.local.sh # 本地凭证文件
- .env, .env.local # 环境变量文件
- __pycache__/ # Python缓存
- .vscode/, .idea/ # IDE配置
```
---
## 🔐 凭证管理 (重要!)
### 本地使用流程
```bash
# 1. 基于模板创建本地配置文件
cd scripts
cp setup_env.local.sh.example setup_env.local.sh
# 2. 编辑本地文件,填入您的真实凭证
nano setup_env.local.sh # 或用您喜欢的编辑器
# 3. 本地使用时source 本地文件
source setup_env.local.sh
# 4. 验证注意setup_env.local.sh 在 .gitignore 中)
git status # 应该看不到 setup_env.local.sh
```
### 关键安全要点
**做这些**:
- 凭证存储在本地的 `.local` 文件中
- 凭证存储在环境变量中(不硬编码)
- 公开文件只包含占位符 `your_app_id`, `your_access_token`
- 定期检查 git status 确保没有凭证被暴露
**不要做这些**:
- 不要把真实凭证提交到 Git
- 不要硬编码凭证在 Python 文件中
- 不要修改 .gitignore让敏感文件被跟踪
- 不要分享包含凭证的 shell 脚本
---
## 📝 提交步骤
```bash
# 1. 确保您创建了本地配置文件
cd /Users/junhuang/coolbuy/claude-marketplace/plugins/doubao-voice-plugin/scripts
cp setup_env.local.sh.example setup_env.local.sh
# 编辑 setup_env.local.sh填入您的凭证
# 2. 检查状态
cd ..
git status
# 3. 提交所有应提交的文件
git add .
# 4. 验证没有凭证泄露
git diff --cached | grep -i "DOUBAO_APP_ID\|DOUBAO_ACCESS_TOKEN\|AKLT\|VOLCENGINE"
# 如果有输出,说明有凭证要被提交,请取消并修改
# 5. 提交
git commit -m "feat: Add Doubao Voice plugin with TTS/ASR support"
# 6. 再次检查
git show HEAD # 确认提交内容
# 7. 推送
git push origin main
```
---
## 🔍 验证清单
提交前,运行以下命令确认安全:
```bash
# 检查是否有真实凭证在staged文件中
git diff --cached | grep -E "2288996168|LlDjcX-_UEnn4OW87iMorpXccQUilaHX|AKLTMGQ3"
# 正常情况下应该没有输出
# 检查 setup_env.local.sh 是否被忽略
git status | grep setup_env.local.sh
# 应该看不到这个文件
# 检查 .gitignore 配置是否正确
cat .gitignore | grep "setup_env.local"
# 应该看到这一行
# 查看即将提交的文件列表
git ls-files
# 确认关键文件都在其中,但不包含 setup_env.local.sh
```
---
## 使用说明(给其他用户)
在您发布插件后,其他用户应该:
```bash
# 1. 克隆插件
git clone <repo-url> doubao-voice-plugin
cd doubao-voice-plugin/scripts
# 2. 创建本地配置
cp setup_env.local.sh.example setup_env.local.sh
# 3. 编辑配置,填入他们自己的凭证
vim setup_env.local.sh
# 4. 配置环境变量
source setup_env.local.sh
# 5. 测试功能
python3 voice_converter.py tts "测试"
# 6. setup_env.local.sh 不会被版本控制跟踪
git status # 看不到 setup_env.local.sh ✅
```
---
## FAQ
**Q: 我不小心提交了凭证怎么办?**
A: 立即执行:
```bash
# 从 Git 历史中移除敏感文件
git rm --cached scripts/setup_env.local.sh
git commit --amend -m "Remove sensitive file"
# 更改您的火山引擎 Access Token出于安全考虑
# 在控制台重新生成新的 token
```
**Q: 为什么需要 setup_env.local.sh.example**
A: 这样其他用户可以看到配置文件应该包含哪些环境变量,而不会暴露任何真实凭证。
**Q: 可以把凭证放在 ~/.bashrc 里吗?**
A: 可以,但 setup_env.local.sh 更加灵活,易于项目专用配置。
**Q: 如何在 CI/CD 中使用敏感凭证?**
A: 在 CI/CD 平台GitHub Actions, GitLab CI等中使用 Secrets/Variables 功能,不要在代码中硬编码。
---
## 总结
**已完成的安全措施**
1. ✓ .gitignore 配置了敏感文件忽略规则
2. ✓ setup_env.sh 改为占位符版本
3. ✓ 创建了 setup_env.local.sh.example 模板
4. ✓ 所有代码文件使用环境变量读取凭证
5. ✓ 提供了清晰的本地配置说明
现在可以安全地提交到 Git🎉

View File

@@ -0,0 +1,182 @@
# 豆包语音插件 (Doubao Voice Plugin)
火山引擎豆包语音API集成插件支持文字转语音(TTS)和唱歌功能。
## 功能特性
- **✅ 语音合成 (TTS)**: 文字转语音,支持多种音色 - **已测试可用**
- **🎵 唱歌**: 让豆包唱歌,支持实时语音交互 - **已开通端到端大模型**
- **简单易用**: 命令行工具,一行命令即可使用
- **多种音色**: 支持女声/男声等多种基础音色
- **实时交互**: 支持与豆包进行实时对话和唱歌
## 快速开始
### 1. 获取API凭证
访问 [火山引擎控制台](https://console.volcengine.com/speech/app) 创建应用并获取:
- **App ID** (数字)
- **Access Token** (长字符串)
开通所需服务:
1. 在控制台勾选 **"语音合成"** 服务 (TTS)
### 2. 配置环境变量
**方式1: 使用配置脚本 (推荐)**
```bash
cd scripts
source setup_env.sh # 自动设置环境变量
```
**方式2: 手动设置**
```bash
export DOUBAO_APP_ID="your_app_id"
export DOUBAO_ACCESS_TOKEN="your_access_token"
```
### 3. 安装依赖
```bash
pip3 install requests --break-system-packages
```
### 4. 检查凭证
```bash
# 检查凭证配置
python3 scripts/check_credentials.py
```
### 5. 使用示例
#### TTS 文字转语音(命令行)
```bash
cd scripts
# 基础用法 - ✅ 已测试可用
python3 voice_converter.py tts "你好,我是豆包语音助手" -o output.mp3
# 使用不同音色
python3 voice_converter.py tts "测试男声" -o male.mp3 -v BV701_V2_streaming
```
#### 唱歌(命令行)🎵
```bash
cd scripts
# 让豆包唱歌
python3 singing.py sing "请唱一首关于春天的歌" -o spring.mp3
# 交互式唱歌模式(实时对话)
python3 singing.py interactive
```
#### Python 代码方式
```python
# TTS - 文字转语音
from scripts.voice_converter import DoubaoVoiceConverter
converter = DoubaoVoiceConverter()
audio_file = converter.text_to_speech("你好,欢迎使用豆包", output_file="hello.mp3")
# 唱歌
import asyncio
from scripts.singing import DoubaoSinging
async def sing():
singing = DoubaoSinging()
audio_file = await singing.sing("请唱一首情歌", output_file="love_song.mp3")
asyncio.run(sing())
```
## 自然语言调用
在 Claude Code 中可以使用自然语言调用:
**TTS 文字转语音**:
- "把这段话转成语音:你好世界"
- "用温柔女声合成语音"
- "用男声朗读这段文字"
**唱歌**:
- "请唱一首关于春天的歌"
- "唱一个温柔的摇篮曲"
- "开启与豆包的实时语音对话模式"
示例:
```
用户: "帮我把'欢迎使用豆包语音'转成语音"
Claude: 调用TTS服务生成output.mp3
```
## 价格说明
### TTS (语音合成)
- 大模型并发版: 2000元/并发/月
- 按量付费: 按字符数计费
### 免费试用
新用户开通服务后可获得免费额度。
## 支持的音色
| 音色代码 | 描述 | 场景 | 状态 |
|---------|------|------|------|
| BV700_V2_streaming | 通用女声 | 通用场景 | ✅ V1 可用 |
| BV701_V2_streaming | 通用男声 | 通用场景 | ✅ V1 可用 |
| BV406_streaming | 温柔女声 | 客服、助手 | ✅ V1 可用 |
| BV158_streaming | 活泼女声 | 教育、娱乐 | ✅ V1 可用 |
| BV115_streaming | 磁性男声 | 新闻、播音 | ✅ V1 可用 |
**注意**: 豆包2.0高级音色需要使用V3 API目前正在调试中。
## 常见问题
### TTS 返回 "requested resource not granted"
**解决方法**: 在控制台勾选"语音合成"服务选项
### Authorization 头格式错误
确保使用 `Bearer;{token}` 格式(注意分号),而不是 `Bearer {token}`
### 环境变量未生效
```bash
# 检查环境变量
echo $DOUBAO_APP_ID
echo $DOUBAO_ACCESS_TOKEN
# 如果为空,重新设置
source setup_env.sh
```
## API 版本说明
### V1 API (当前使用) ✅
- **状态**: 已测试,稳定可用
- **认证**: Bearer Token
- **音色**: 支持基础音色
- **推荐**: 日常使用推荐
### V3 API (豆包2.0) ⚠️
- **状态**: 调试中,存在 "get resource id empty" 问题
- **认证**: Bearer Token + Resource-Id
- **音色**: 支持豆包2.0高级音色
- **说明**: 需要联系火山引擎技术支持获取正确配置
## 技术支持
- [官方文档](https://www.volcengine.com/docs/6561/1359369)
- [控制台](https://console.volcengine.com/speech/app)
- [计费说明](https://www.volcengine.com/docs/6561/1359370)
## 许可证
本插件遵循 MIT 许可证。
## 作者
qiudl @ zhiyuncai.com

View File

@@ -0,0 +1,200 @@
# 豆包语音插件 - 开发状态
**更新时间**: 2026-02-07
**版本**: 1.0.0
---
## ✅ 已完成功能
### 1. TTS (文字转语音) - 完全可用 ✅
**测试状态**: 通过
**API版本**: V1
**可用音色**:
- BV700_V2_streaming (通用女声)
- BV701_V2_streaming (通用男声)
- BV406_streaming (温柔女声)
- BV158_streaming (活泼女声)
- BV115_streaming (磁性男声)
**测试命令**:
```bash
source scripts/setup_env.sh
python3 scripts/voice_converter.py tts "你好世界" -o hello.mp3
```
**测试结果**:
- ✅ HTTP 200 OK
- ✅ Code 3000 Success
- ✅ 成功生成 MP3 文件
- ✅ 音质正常
---
## ⚠️ 待完成功能
### 2. ASR (语音转文字) - 待启用服务
**问题**: Code 1001 - "requested resource not granted"
**原因**: ASR 服务未在火山引擎控制台正确启用
**解决步骤**:
1. 访问: https://console.volcengine.com/speech/service
2. 找到 "语音识别 (ASR)" 服务
3. 确保服务已启用并勾选必要选项
4. 等待服务生效(可能需要几分钟)
5. 重新测试
**测试命令** (服务启用后):
```bash
python3 scripts/voice_converter.py asr audio.mp3
```
---
### 3. V3 API / 豆包2.0音色 - 调试中
**问题**: Code 45000000 - "get resource id empty"
**已尝试的方法**:
- [x] Resource-Id header
- [x] X-Resource-Id header
- [x] resource_id query parameter
- [x] resource_id in app config
- [x] 多种 resource_id 值: volc.bigmodel.tts, volc.seed-tts.default, volc.tts.default
**当前状态**: 所有方法均返回相同错误
**可能原因**:
1. V3 API 可能需要不同的认证方式 (IAM签名)
2. 需要特殊的服务实例配置
3. Resource-Id 的获取或配置方法不正确
**建议**:
- 联系火山引擎技术支持获取 V3 API 正确配置方法
- 或继续使用 V1 API (已满足基本需求)
---
## 📁 项目文件结构
```
plugins/doubao-voice-plugin/
├── .claude-plugin/
│ └── plugin.json # 插件元数据
├── skills/
│ └── SKILL.md # 技能定义和文档
├── scripts/
│ ├── voice_converter.py # 主转换工具 (V1 API, 可用)
│ ├── voice_converter_v2.py # 手动签名版本 (待测试)
│ ├── voice_converter_sdk.py # SDK版本 (待测试)
│ ├── check_credentials.py # 凭证检查工具
│ ├── test_services.py # 服务状态测试
│ ├── test_v3_debug.py # V3 API 调试脚本
│ ├── setup_env.sh # 环境变量配置脚本
│ └── README_TEST.md # 测试报告
├── README.md # 用户文档
└── STATUS.md # 本文件 (开发状态)
```
---
## 🔧 诊断工具
### 检查凭证配置
```bash
python3 scripts/check_credentials.py
```
显示当前环境变量配置状态
### 测试服务状态
```bash
python3 scripts/test_services.py
```
测试 TTS 和 ASR 服务是否可用
### V3 API 调试
```bash
python3 scripts/test_v3_debug.py
```
测试多种 V3 API 配置方式
---
## 📊 当前凭证配置
```bash
DOUBAO_APP_ID="your_app_id"
DOUBAO_ACCESS_TOKEN="your_access_token"
# V3 可选配置 (暂不可用)
# DOUBAO_USE_V3="true"
# DOUBAO_RESOURCE_ID="volc.bigmodel.tts"
```
**Access Key 信息** (用于签名认证,暂未使用):
- Access Key ID: your_access_key_id
- Secret Access Key: your_secret_access_key
---
## 🎯 下一步计划
### 立即可用
1.**使用 TTS 功能**
- 集成到应用中
- 测试不同音色
- 生产环境部署
### 短期目标 (1-3天)
2. ⚠️ **启用 ASR 服务**
- 在控制台启用服务
- 测试语音识别功能
- 完善错误处理
### 长期目标 (可选)
3. 🔄 **V3 API 支持**
- 联系火山引擎技术支持
- 获取正确的 Resource-Id 配置方法
- 支持豆包2.0高级音色
---
## 📞 技术支持
### 火山引擎
- 文档: https://www.volcengine.com/docs/6561/1329505
- 控制台: https://console.volcengine.com/speech/app
- 服务管理: https://console.volcengine.com/speech/service
### 常见问题解决
1. **TTS 可用但 ASR 不可用**
- 检查控制台 ASR 服务是否启用
- 确认勾选了"语音识别"选项
2. **V3 API 持续报错**
- 暂时使用 V1 API
- 联系火山引擎技术支持
3. **认证失败**
- 检查环境变量是否正确设置
- 确认 Access Token 格式正确
- 注意 Authorization header 使用 `Bearer;{token}` (有分号)
---
## ✨ 总结
**当前可用**: TTS (文字转语音) 功能完全可用,可以投入使用
**待解决**:
1. 在控制台启用 ASR 服务
2. (可选) 解决 V3 API 配置问题
**建议**: 先使用 V1 API 的 TTS 功能满足基本语音合成需求。ASR 功能在控制台启用服务后即可使用。V3 API 的豆包2.0音色为可选功能,可以后续再解决。
---
*Generated by Claude Code on 2026-02-07*

View File

@@ -0,0 +1,186 @@
# 豆包语音工具使用指南
简单易用的豆包语音命令行工具,支持**文字转语音(TTS)**和**唱歌**。
## 快速开始
### 1. 配置环境变量
```bash
# 在 ~/.zshrc 或 ~/.bashrc 中添加
export DOUBAO_APP_ID="your_app_id"
export DOUBAO_ACCESS_TOKEN="your_access_token"
# 使配置生效
source ~/.zshrc
```
### 2. 安装依赖
```bash
pip install requests
```
## 使用方法
### 📝 文字转语音 (TTS)
**基础用法:**
```bash
python voice_converter.py tts "你好,我是豆包语音助手"
```
**指定输出文件和音色:**
```bash
python voice_converter.py tts "欢迎使用豆包语音" -o welcome.mp3 -v BV701_V2_streaming
```
**可用音色:**
- `BV700_V2_streaming` - 通用女声(默认,推荐)
- `BV701_V2_streaming` - 通用男声
- `BV406_streaming` - 温柔女声
- `BV158_streaming` - 活泼女声
- `BV115_streaming` - 磁性男声
### 🎵 唱歌 (Singing)
**基础用法:**
```bash
python singing.py sing "请唱一首关于春天的歌"
```
**指定输出文件:**
```bash
python singing.py sing "唱一个温柔的摇篮曲" -o lullaby.mp3
```
**交互式模式(实时对话):**
```bash
python singing.py interactive
```
在交互模式下可以自然地与豆包对话,要求她唱歌、讲故事等。输入 `quit` 退出。
## Python 代码调用
```python
# TTS - 文字转语音
from voice_converter import DoubaoVoiceConverter
converter = DoubaoVoiceConverter()
audio_file = converter.text_to_speech(
"你好,欢迎使用豆包语音",
output_file="hello.mp3",
voice_type="BV700_V2_streaming"
)
print(f"生成语音: {audio_file}")
# 唱歌
import asyncio
from singing import DoubaoSinging
async def main():
singing = DoubaoSinging()
# 让豆包唱歌
audio_file = await singing.sing(
"请唱一首情歌",
output_file="love_song.mp3",
language="zh-CN"
)
print(f"唱歌完成: {audio_file}")
# 或启动交互模式
# await singing.interactive_singing()
asyncio.run(main())
```
## 完整示例
### 示例1生成通知语音
```bash
# 生成女声通知
python voice_converter.py tts "您有一条新消息,请注意查收" -o notification.mp3
# 生成男声通知
python voice_converter.py tts "系统将在5分钟后进行维护" -o maintenance.mp3 -v BV701_V2_streaming
```
### 示例2唱歌
```bash
# 让豆包唱一首情歌
python singing.py sing "请唱一首温柔的情歌" -o love_song.mp3
# 让豆包唱一首儿歌
python singing.py sing "唱一首欢快的儿歌" -o kids_song.mp3
# 启动交互式模式与豆包对话
python singing.py interactive
```
## 错误处理
### 常见错误
**1. 环境变量未设置**
```
❌ 错误: 请先设置环境变量:
export DOUBAO_APP_ID='your_app_id'
export DOUBAO_ACCESS_TOKEN='your_access_token'
```
**解决:** 确保已正确设置环境变量并 `source ~/.zshrc`
**2. API 调用失败**
```
❌ 错误: TTS 失败 (code: 4001): Invalid token
```
**解决:** 检查 Access Token 是否正确或已过期
## 技术参数
### 音频格式要求
**TTS 输出:**
- 格式MP3
- 采样率16000 Hz
- 声道:单声道
### API 限制
- **TTS**: 单次最长 5000 字符
- **并发限制**: 根据购买的并发数
## 在 Claude Code 中使用
在 Claude Code 中可以直接用自然语言调用:
**TTS - 文字转语音**:
```
"把这段话转成语音:你好世界"
"用温柔女声合成:欢迎光临"
```
**唱歌**:
```
"请唱一首关于春天的歌"
"唱一个温柔的摇篮曲"
"开启与豆包的实时语音对话模式"
```
## 获取 API 凭证
1. 访问 [火山引擎控制台](https://console.volcengine.com/speech/app)
2. 创建应用
3. 获取 App ID 和 Access Token
4. 开通所需服务:
- 豆包语音合成模型2.0
## 参考链接
- [火山引擎豆包语音文档](https://www.volcengine.com/docs/6561)
- [API 接口文档](https://www.volcengine.com/docs/6561/1096680)
- [计费说明](https://www.volcengine.com/docs/6561/1359370)

View File

@@ -0,0 +1,21 @@
#!/bin/bash
# 豆包语音 API 环境变量配置(本地版本)
#
# 使用说明:
# 1. 复制本文件: cp setup_env.local.sh.example setup_env.local.sh
# 2. 编辑 setup_env.local.sh填入您的真实凭证
# 3. 运行: source setup_env.local.sh
# 4. .gitignore 已配置忽略 setup_env.local.sh所以您的凭证不会被提交到 Git
# ⚠️ 重要:请在下面填入您的真实凭证(仅本地使用)
export DOUBAO_APP_ID="your_app_id_here"
export DOUBAO_ACCESS_TOKEN="your_access_token_here"
# V3 API 配置 (可选如需豆包2.0音色)
# export DOUBAO_USE_V3="true"
# export DOUBAO_RESOURCE_ID="volc.bigmodel.tts"
echo "✅ 豆包语音 API 环境变量已设置(本地配置)"
echo ""
echo "App ID: ${DOUBAO_APP_ID:0:10}..."
echo "Access Token: ${DOUBAO_ACCESS_TOKEN:0:20}..."

View File

@@ -0,0 +1,22 @@
#!/bin/bash
# 豆包语音 API 环境变量配置 (示例)
#
# ⚠️ 重要:这是示例脚本,包含占位符。
# 本地使用时,请参考 setup_env.local.sh.example 创建 setup_env.local.sh
# 然后在其中填入您的真实凭证。.gitignore 已配置忽略 .local 文件。
export DOUBAO_APP_ID="your_app_id"
export DOUBAO_ACCESS_TOKEN="your_access_token"
# V3 API 配置 (可选如需豆包2.0音色)
# export DOUBAO_USE_V3="true"
# export DOUBAO_RESOURCE_ID="volc.bigmodel.tts"
echo "✅ 豆包语音 API 环境变量已设置"
echo ""
echo "App ID: $DOUBAO_APP_ID"
echo "Access Token: ${DOUBAO_ACCESS_TOKEN:0:20}..."
echo ""
echo "现在可以运行:"
echo " python3 voice_converter.py tts \"你好世界\" -o hello.mp3"
echo " python3 voice_converter.py asr audio.mp3 # 需先启用ASR服务"

View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
豆包唱歌工具
基于豆包端到端实时语音大模型,支持让豆包唱歌
使用WebSocket实时对话和生成音频
"""
import os
import sys
import json
import asyncio
import websockets
import struct
import uuid
from typing import Optional
# 连接级事件不需要session_id
CONNECTION_EVENTS = {1, 2, 50, 51, 52}
class DoubaoSinging:
"""豆包唱歌工具类"""
def __init__(self):
# 从环境变量读取配置
self.app_id = os.environ.get("DOUBAO_APP_ID")
self.access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
if not self.app_id or not self.access_token:
raise ValueError(
"请先设置环境变量:\n"
"export DOUBAO_APP_ID='your_app_id'\n"
"export DOUBAO_ACCESS_TOKEN='your_access_token'"
)
# 端到端实时语音WebSocket地址
self.ws_url = "wss://openspeech.bytedance.com/api/v3/realtime/dialogue"
self.app_key = "PlgvMymc7f3tQnJ6" # 固定值
self.resource_id = "volc.speech.dialog" # 固定值
def _build_message(self, event_id: int, payload: dict = None, session_id: str = None) -> bytes:
"""
构建二进制消息
协议格式:
- header (4 bytes)
- event_id (4 bytes, big-endian)
- [session_id_len (4 bytes) + session_id (variable)] -- 仅非连接级事件
- payload_len (4 bytes, big-endian)
- payload (variable, JSON)
"""
buf = bytearray()
# Header (4 bytes)
buf.append(0x11) # version=1, header_size=1
buf.append(0x14) # FULL_CLIENT_REQUEST(0x1) + WITH_EVENT(0x4)
buf.append(0x10) # JSON serialization, no compression
buf.append(0x00) # reserved
# Event ID
buf.extend(struct.pack('>I', event_id))
# Session ID (required for non-connection events)
if event_id not in CONNECTION_EVENTS:
sid_bytes = (session_id or "").encode('utf-8')
buf.extend(struct.pack('>I', len(sid_bytes)))
buf.extend(sid_bytes)
# Payload
if payload:
payload_bytes = json.dumps(payload, ensure_ascii=False).encode('utf-8')
else:
payload_bytes = b'{}'
buf.extend(struct.pack('>I', len(payload_bytes)))
buf.extend(payload_bytes)
return bytes(buf)
def _parse_response(self, data: bytes) -> dict:
"""
解析服务端二进制消息
Returns:
dict with keys: msg_type, event_id, session_id, payload, payload_bytes
"""
result = {"raw": data}
if len(data) < 4:
return result
# Header
msg_type = (data[1] >> 4) & 0x0F
flags = data[1] & 0x0F
result["msg_type"] = msg_type
offset = 4
# Event ID (if WITH_EVENT flag)
if flags & 0x04 and len(data) >= offset + 4:
event_id = struct.unpack('>I', data[offset:offset + 4])[0]
result["event_id"] = event_id
offset += 4
# Connect ID for connection events (50, 51, 52)
if event_id in {50, 51, 52} and len(data) >= offset + 4:
cid_len = struct.unpack('>I', data[offset:offset + 4])[0]
offset += 4
if len(data) >= offset + cid_len:
result["connect_id"] = data[offset:offset + cid_len].decode('utf-8', errors='ignore')
offset += cid_len
# Session ID for session-level events
elif event_id not in CONNECTION_EVENTS and len(data) >= offset + 4:
sid_len = struct.unpack('>I', data[offset:offset + 4])[0]
offset += 4
if len(data) >= offset + sid_len:
result["session_id"] = data[offset:offset + sid_len].decode('utf-8', errors='ignore')
offset += sid_len
# Payload
if len(data) >= offset + 4:
payload_len = struct.unpack('>I', data[offset:offset + 4])[0]
offset += 4
if len(data) >= offset + payload_len:
payload_raw = data[offset:offset + payload_len]
result["payload_bytes"] = payload_raw
# Audio-only responses (msg_type 0xB) have raw audio
if msg_type == 0x0B:
result["is_audio"] = True
else:
try:
result["payload"] = json.loads(payload_raw.decode('utf-8'))
except:
result["payload_text"] = payload_raw.decode('utf-8', errors='ignore')
return result
async def sing(
self,
song_request: str,
output_file: str = "singing_output.mp3",
language: str = "zh-CN",
model: str = "1.2.1.0"
) -> str:
"""
让豆包唱歌
Args:
song_request: 唱歌请求,如 "请唱一首关于春天的歌"
output_file: 输出音频文件路径
language: 语言代码 (zh-CN/en-US)
model: 模型版本
Returns:
str: 输出文件路径
"""
print(f"🎵 豆包唱歌中...")
print(f" 请求: {song_request}")
print(f" 模型: {model}")
try:
audio_data = bytearray()
session_id = str(uuid.uuid4())
# WebSocket连接头
headers = {
"X-Api-App-ID": self.app_id,
"X-Api-Access-Key": self.access_token,
"X-Api-Resource-Id": self.resource_id,
"X-Api-App-Key": self.app_key,
"X-Api-Connect-Id": str(uuid.uuid4()),
}
async with websockets.connect(self.ws_url, additional_headers=headers) as websocket:
print("✅ WebSocket连接成功")
# 1. StartConnection (event_id=1, 无需session_id)
await websocket.send(self._build_message(1))
response = await asyncio.wait_for(websocket.recv(), timeout=5)
resp = self._parse_response(response)
if resp.get("event_id") == 50:
print(f"✅ 连接已建立")
else:
print(f"⚠️ 连接响应: {resp}")
# 2. StartSession (event_id=100, 需要session_id)
start_session_payload = {
"tts": {
"audio_config": {
"channel": 1,
"format": "pcm",
"sample_rate": 24000
}
},
"dialog": {
"extra": {
"enable_music": True,
"input_mod": "text",
"model": model
}
}
}
await websocket.send(self._build_message(100, start_session_payload, session_id))
response = await asyncio.wait_for(websocket.recv(), timeout=5)
resp = self._parse_response(response)
if resp.get("event_id") == 150:
print(f"✅ 会话已建立")
elif resp.get("payload", {}).get("error"):
print(f"❌ 会话错误: {resp['payload']['error']}")
return None
else:
print(f"📋 会话响应: {resp}")
# 3. SayHello/ChatTextQuery (event_id=300, 需要session_id)
chat_payload = {"content": song_request}
await websocket.send(self._build_message(300, chat_payload, session_id))
print(f"📤 已发送唱歌请求")
# 4. 接收音频流(使用超时检测结束)
print("\n📋 接收音频流...")
tts_started = False
recv_timeout = 5 # 5秒无数据则认为结束
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=recv_timeout)
except asyncio.TimeoutError:
break
except websockets.exceptions.ConnectionClosed:
break
if isinstance(message, bytes) and len(message) >= 4:
resp = self._parse_response(message)
msg_type = resp.get("msg_type", 0)
flags = message[1] & 0x0F
# Audio-only response (0xB = 11)
if resp.get("is_audio") and resp.get("payload_bytes"):
audio_data.extend(resp["payload_bytes"])
if not tts_started:
print(f" 接收音频中...", end="", flush=True)
tts_started = True
else:
print(".", end="", flush=True)
# NEG_SEQUENCE flag = last packet
if flags & 0x02:
break
# Server error (0xF = 15)
elif msg_type == 0x0F:
error = resp.get("payload", {}).get("error", "unknown")
print(f"\n❌ 服务器错误: {error}")
break
# Full server response (0x9) - session finished
elif msg_type == 0x09:
event_id = resp.get("event_id", 0)
if event_id in {152, 52}:
break
# 5. 保存音频文件
if audio_data:
# Save as PCM, convert extension if needed
actual_output = output_file
if output_file.endswith('.mp3'):
actual_output = output_file.replace('.mp3', '.pcm')
with open(actual_output, "wb") as f:
f.write(audio_data)
file_size = len(audio_data) / 1024
print(f"\n\n✅ 唱歌完成!")
print(f" 输出: {actual_output} ({file_size:.1f} KB)")
print(f" 格式: PCM (24000Hz, 单声道)")
return actual_output
else:
print("\n⚠️ 未收到音频数据,请检查:")
print(" 1. 凭证是否正确")
print(" 2. 端到端实时语音大模型是否已开通")
print(" 3. 网络连接是否正常")
return None
except websockets.exceptions.WebSocketException as e:
raise Exception(f"WebSocket连接错误: {str(e)}")
except Exception as e:
raise Exception(f"唱歌调用失败: {str(e)}")
def main():
"""命令行工具"""
import argparse
parser = argparse.ArgumentParser(description="豆包唱歌工具")
subparsers = parser.add_subparsers(dest="command", help="选择功能")
# 唱歌命令
sing_parser = subparsers.add_parser("sing", help="让豆包唱歌")
sing_parser.add_argument("request", help="唱歌请求,如 '请唱一首关于春天的歌'")
sing_parser.add_argument(
"-o", "--output", default="singing_output.mp3", help="输出音频文件(默认: singing_output.mp3"
)
sing_parser.add_argument(
"-l", "--language", default="zh-CN", help="语言代码(默认: zh-CN"
)
sing_parser.add_argument(
"-m", "--model", default="1.2.1.0", help="模型版本(默认: 1.2.1.0=O2.0版本)"
)
args = parser.parse_args()
if not args.command:
parser.print_help()
return
try:
singing = DoubaoSinging()
if args.command == "sing":
asyncio.run(singing.sing(args.request, args.output, args.language, args.model))
except Exception as e:
print(f"❌ 错误: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
豆包语音转换工具
支持:文字转语音 (TTS)
"""
import os
import sys
import json
import base64
import requests
from pathlib import Path
class DoubaoVoiceConverter:
"""豆包语音转换工具类"""
def __init__(self):
# 从环境变量读取配置
self.app_id = os.environ.get("DOUBAO_APP_ID")
self.access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
if not self.app_id or not self.access_token:
raise ValueError(
"请先设置环境变量:\n"
"export DOUBAO_APP_ID='your_app_id'\n"
"export DOUBAO_ACCESS_TOKEN='your_access_token'"
)
# API版本选择: V1 (默认, 支持基础音色) 或 V3 (豆包2.0, 需额外配置)
self.use_v3 = os.environ.get("DOUBAO_USE_V3", "false").lower() == "true"
if self.use_v3:
self.tts_url = "https://openspeech.bytedance.com/api/v3/tts/unidirectional"
self.resource_id = os.environ.get("DOUBAO_RESOURCE_ID", "volc.bigmodel.tts")
else:
# V1 API - 稳定可用,支持基础音色
self.tts_url = "https://openspeech.bytedance.com/api/v1/tts"
def text_to_speech(
self,
text: str,
output_file: str = "output.mp3",
voice_type: str = "BV700_V2_streaming"
) -> str:
"""
文字转语音 (TTS)
Args:
text: 要转换的文字
output_file: 输出音频文件路径
voice_type: 音色类型
- BV700_V2_streaming: 通用女声(推荐)
- BV701_V2_streaming: 通用男声
- BV406_streaming: 温柔女声
- BV158_streaming: 活泼女声
- BV115_streaming: 磁性男声
Returns:
str: 输出文件路径
"""
print(f"📝 文字转语音中...")
print(f" 文字: {text[:50]}{'...' if len(text) > 50 else ''}")
print(f" 音色: {voice_type}")
headers = {
"Authorization": f"Bearer;{self.access_token}",
"Content-Type": "application/json"
}
# V3 API需要Resource-Id (如果启用)
if self.use_v3:
headers["Resource-Id"] = self.resource_id
payload = {
"app": {
"appid": self.app_id,
"token": self.access_token,
"cluster": "volcano_tts"
},
"user": {
"uid": "user_001"
},
"audio": {
"voice_type": voice_type,
"encoding": "mp3",
"speed_ratio": 1.0,
"volume_ratio": 1.0,
"pitch_ratio": 1.0
},
"request": {
"reqid": f"tts_{os.urandom(8).hex()}",
"text": text,
"text_type": "plain",
"operation": "query"
}
}
try:
response = requests.post(self.tts_url, headers=headers, json=payload, timeout=30)
# 打印响应头信息
print(f"\n📋 响应信息:")
print(f" HTTP状态码: {response.status_code}")
if 'X-Tt-Logid' in response.headers:
print(f" RequestId: {response.headers['X-Tt-Logid']}")
if 'X-Request-Id' in response.headers:
print(f" X-Request-Id: {response.headers['X-Request-Id']}")
data = response.json()
# 打印完整响应
print(f"\n📄 完整响应:")
print(json.dumps(data, indent=2, ensure_ascii=False))
print()
if data.get("code") == 3000:
# 成功:解码并保存音频
audio_data = base64.b64decode(data["data"])
with open(output_file, "wb") as f:
f.write(audio_data)
file_size = len(audio_data) / 1024 # KB
print(f"✅ 语音合成成功!")
print(f" 输出: {output_file} ({file_size:.1f} KB)")
return output_file
else:
error_msg = data.get("message", "未知错误")
reqid = data.get("reqid", "未知")
raise Exception(f"TTS 失败\n 错误码: {data.get('code')}\n 错误信息: {error_msg}\n RequestId: {reqid}")
except requests.exceptions.Timeout:
raise Exception("请求超时,请检查网络连接")
except Exception as e:
raise Exception(f"TTS 调用失败: {str(e)}")
def main():
"""命令行工具"""
import argparse
parser = argparse.ArgumentParser(description="豆包语音转换工具")
subparsers = parser.add_subparsers(dest="command", help="选择功能")
# TTS 命令
tts_parser = subparsers.add_parser("tts", help="文字转语音")
tts_parser.add_argument("text", help="要转换的文字")
tts_parser.add_argument("-o", "--output", default="output.mp3", help="输出音频文件(默认: output.mp3")
tts_parser.add_argument("-v", "--voice", default="BV700_V2_streaming",
help="音色类型(默认: BV700_V2_streaming 通用女声)")
args = parser.parse_args()
if not args.command:
parser.print_help()
return
try:
converter = DoubaoVoiceConverter()
if args.command == "tts":
converter.text_to_speech(args.text, args.output, args.voice)
except Exception as e:
print(f"❌ 错误: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,508 @@
---
name: doubao-voice
description: 豆包语音API调用。支持语音合成(TTS)和唱歌。当用户提到语音合成、文字转语音、唱歌、豆包语音相关任务时自动激活。
---
# 豆包语音API技能
调用火山引擎豆包语音API实现文字转语音(TTS)和唱歌功能。
## 核心功能 ⭐
### 1. 文字转语音 (TTS)
```bash
# 1. 配置环境变量
export DOUBAO_APP_ID="your_app_id"
export DOUBAO_ACCESS_TOKEN="your_access_token"
# 2. 文字转语音
python scripts/voice_converter.py tts "你好世界"
```
### 2. 唱歌 🎵
```bash
# 让豆包唱歌
python scripts/singing.py sing "请唱一首关于春天的歌"
# 交互式唱歌模式
python scripts/singing.py interactive
```
## 功能概述
| 模块 | 功能 | 推荐模型 |
|------|------|---------|
| **语音合成 (TTS)** | 文字转语音、多种音色 | 豆包语音合成模型2.0 |
| **唱歌** | 实时语音交互、唱歌、角色扮演 | 豆包端到端实时语音大模型 |
---
## 环境配置
### 1. 获取火山引擎豆包语音凭证
1. 访问 [火山引擎控制台](https://console.volcengine.com/)
2. 开通「豆包语音」服务
3. 创建应用获取 `App ID``Access Token`
4. 开通所需服务:
- 「语音合成」权限:大模型语音合成
### 2. 环境变量配置
```bash
# ~/.zshrc 或 ~/.bashrc
export DOUBAO_APP_ID="your_app_id"
export DOUBAO_ACCESS_TOKEN="your_access_token"
export DOUBAO_CLUSTER="volcano_tts" # TTS服务集群
```
### 3. Python 依赖
```bash
# 推荐使用 uv
uv pip install requests websocket-client
# 或使用 pip
pip install requests websocket-client
```
---
## API 基础
### Base URL
```
TTS API: https://openspeech.bytedance.com/api/v1/tts
```
### 认证方式
使用 Access Token 进行认证,在请求头中添加:
```
Authorization: Bearer {access_token}
```
---
## 一、语音合成 (TTS)
### 1.1 基础语音合成
将文本转换为语音文件。
**自然语言示例**:
- "把这段文字转成语音"
- "用豆包合成语音"
- "生成语音:你好,欢迎使用豆包语音"
**Python 实现**:
```python
import os
import requests
import json
import base64
def text_to_speech(text: str, voice_type: str = "BV700_V2_streaming", output_file: str = "output.mp3"):
"""
文字转语音
Args:
text: 要合成的文本
voice_type: 音色类型 (默认: BV700_V2_streaming)
output_file: 输出音频文件路径
Returns:
音频文件路径
"""
app_id = os.environ.get("DOUBAO_APP_ID")
access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
cluster = os.environ.get("DOUBAO_CLUSTER", "volcano_tts")
url = "https://openspeech.bytedance.com/api/v1/tts"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
payload = {
"app": {
"appid": app_id,
"token": access_token,
"cluster": cluster
},
"user": {
"uid": "user123"
},
"audio": {
"voice_type": voice_type,
"encoding": "mp3",
"speed_ratio": 1.0,
"volume_ratio": 1.0,
"pitch_ratio": 1.0
},
"request": {
"reqid": "req_" + os.urandom(8).hex(),
"text": text,
"text_type": "plain",
"operation": "query"
}
}
response = requests.post(url, headers=headers, json=payload)
data = response.json()
if data.get("code") == 3000:
# 解码音频数据
audio_data = base64.b64decode(data["data"])
with open(output_file, "wb") as f:
f.write(audio_data)
return output_file
else:
raise Exception(f"TTS 失败: {data}")
# 使用示例
audio_file = text_to_speech("你好,我是豆包语音助手")
print(f"语音已生成: {audio_file}")
```
### 1.2 流式语音合成
适用于长文本,边生成边播放。
```python
import websocket
import json
import os
def stream_tts(text: str, voice_type: str = "BV700_V2_streaming"):
"""
流式语音合成
Args:
text: 要合成的文本
voice_type: 音色类型
"""
app_id = os.environ.get("DOUBAO_APP_ID")
access_token = os.environ.get("DOUBAO_ACCESS_TOKEN")
ws_url = f"wss://openspeech.bytedance.com/api/v1/tts/ws?appid={app_id}&token={access_token}"
def on_message(ws, message):
data = json.loads(message)
if "audio" in data:
# 处理音频数据
audio_chunk = base64.b64decode(data["audio"])
# 播放或保存音频片段
print(f"收到音频片段: {len(audio_chunk)} 字节")
def on_open(ws):
payload = {
"app": {
"appid": app_id,
"token": access_token,
"cluster": "volcano_tts"
},
"user": {
"uid": "user123"
},
"audio": {
"voice_type": voice_type,
"encoding": "mp3"
},
"request": {
"reqid": "stream_" + os.urandom(8).hex(),
"text": text,
"text_type": "plain",
"operation": "submit"
}
}
ws.send(json.dumps(payload))
ws = websocket.WebSocketApp(
ws_url,
on_message=on_message,
on_open=on_open
)
ws.run_forever()
# 使用示例
stream_tts("这是一段很长的文本,使用流式合成可以边生成边播放...")
```
### 1.3 音色选择
豆包语音提供多种音色:
| 音色代码 | 描述 | 场景 |
|---------|------|------|
| BV700_V2_streaming | 通用女声 | 通用场景 |
| BV701_V2_streaming | 通用男声 | 通用场景 |
| BV406_streaming | 温柔女声 | 客服、助手 |
| BV158_streaming | 活泼女声 | 教育、娱乐 |
| BV115_streaming | 磁性男声 | 新闻、播音 |
**查询可用音色**:
```bash
TOKEN="${DOUBAO_ACCESS_TOKEN}"
APP_ID="${DOUBAO_APP_ID}"
curl -s "https://openspeech.bytedance.com/api/v1/tts/voices?appid=$APP_ID" \
-H "Authorization: Bearer $TOKEN"
```
---
## 完整工具类
```python
import os
import requests
import base64
import json
from typing import Optional
class DoubaoVoice:
"""豆包语音API工具类"""
BASE_URL = "https://openspeech.bytedance.com/api/v1"
def __init__(self, app_id: str = None, access_token: str = None):
self.app_id = app_id or os.environ.get("DOUBAO_APP_ID")
self.access_token = access_token or os.environ.get("DOUBAO_ACCESS_TOKEN")
self.cluster_tts = os.environ.get("DOUBAO_CLUSTER", "volcano_tts")
@property
def headers(self):
return {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json"
}
def text_to_speech(
self,
text: str,
voice_type: str = "BV700_V2_streaming",
output_file: str = "output.mp3"
) -> str:
"""文字转语音"""
url = f"{self.BASE_URL}/tts"
payload = {
"app": {
"appid": self.app_id,
"token": self.access_token,
"cluster": self.cluster_tts
},
"user": {"uid": "user123"},
"audio": {
"voice_type": voice_type,
"encoding": "mp3",
"speed_ratio": 1.0,
"volume_ratio": 1.0,
"pitch_ratio": 1.0
},
"request": {
"reqid": "req_" + os.urandom(8).hex(),
"text": text,
"text_type": "plain",
"operation": "query"
}
}
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") == 3000:
audio_data = base64.b64decode(data["data"])
with open(output_file, "wb") as f:
f.write(audio_data)
return output_file
else:
raise Exception(f"TTS 失败: {data}")
def list_voices(self) -> list:
"""获取可用音色列表"""
url = f"{self.BASE_URL}/tts/voices"
params = {"appid": self.app_id}
response = requests.get(url, headers=self.headers, params=params)
data = response.json()
if data.get("code") == 0:
return data["voices"]
else:
raise Exception(f"获取音色列表失败: {data}")
# ==================== 使用示例 ====================
if __name__ == "__main__":
voice = DoubaoVoice()
# 示例1: 文字转语音
audio_file = voice.text_to_speech("你好,我是豆包语音助手")
print(f"语音已生成: {audio_file}")
# 示例2: 查看可用音色
voices = voice.list_voices()
for v in voices[:5]:
print(f"{v['voice_type']}: {v['description']}")
```
---
## 二、唱歌 (豆包端到端实时语音大模型)
### 2.1 基础唱歌
让豆包唱歌,支持任何歌曲主题。
**自然语言示例**:
- "请唱一首关于春天的歌"
- "唱一个温柔的摇篮曲"
- "来一首欢快的儿歌"
**Python 实现**:
```python
import asyncio
from scripts.singing import DoubaoSinging
async def main():
singing = DoubaoSinging()
# 让豆包唱歌
audio_file = await singing.sing(
"请唱一首关于春天的歌",
output_file="spring_song.mp3",
language="zh-CN"
)
print(f"唱歌完成: {audio_file}")
asyncio.run(main())
```
### 2.2 交互式唱歌
与豆包进行实时对话,可以要求她唱歌、讲故事等。
**Python 实现**:
```python
import asyncio
from scripts.singing import DoubaoSinging
async def main():
singing = DoubaoSinging()
# 启动交互式模式
await singing.interactive_singing(language="zh-CN")
asyncio.run(main())
```
**交互示例**:
```
你: 请唱一首情歌
豆包: [生成音频] 我会为你唱一首温柔的情歌...
你: 能加点方言吗?
豆包: [用方言重新唱歌]
你: quit
再见!
```
---
## 自然语言操作示例
### TTS 操作
| 用户说 | 执行操作 |
|--------|----------|
| "把这段话转成语音:你好世界" | 调用 TTS API 生成语音 |
| "用温柔女声合成语音" | 使用 BV406_streaming 音色 |
| "生成一段播音腔的新闻语音" | 使用磁性男声音色 |
### 唱歌操作
| 用户说 | 执行操作 |
|--------|----------|
| "请唱一首关于春天的歌" | 调用端到端实时语音大模型生成唱歌音频 |
| "唱一首摇篮曲" | 生成温柔的摇篮曲 |
| "唱歌的同时讲个故事" | 交互式对话中唱歌并讲故事 |
| "开启交互式唱歌模式" | 启动实时语音交互 |
---
## 计费说明
### TTS 计费
- **并发版**: 2000元/并发/月(纯并发计费,不收取字符调用费用)
- **按量付费**: 按合成字符数计费
### 免费试用
新用户开通服务后可获得一定免费额度,具体额度以控制台显示为准。
---
## 注意事项
1. **音频格式**: TTS 支持 mp3/wav/pcm
2. **文本长度**: TTS 单次请求最长支持 5000 字符
3. **并发限制**: 注意 API 调用频率和并发数限制
4. **Token 安全**: Access Token 存储在环境变量中,不要硬编码
---
## 错误处理
```python
def safe_tts(text: str):
"""带错误处理的 TTS"""
try:
voice = DoubaoVoice()
return voice.text_to_speech(text)
except Exception as e:
if "401" in str(e):
print("认证失败,请检查 Access Token")
elif "429" in str(e):
print("请求过于频繁,请稍后重试")
else:
print(f"合成失败: {e}")
return None
```
---
## 常见场景
### 场景 1: 生成多语言语音
```python
voice = DoubaoVoice()
# 中文
voice.text_to_speech("你好", voice_type="BV700_V2_streaming", output_file="zh.mp3")
# 英文
voice.text_to_speech("Hello", voice_type="EN_001", output_file="en.mp3")
```
---
## 参考资源
- [火山引擎豆包语音文档](https://www.volcengine.com/docs/6561/1359369)
- [豆包语音控制台](https://console.volcengine.com/speech/app)
- [API 接口文档](https://www.volcengine.com/docs/6561/1359370)
- [计费说明](https://www.volcengine.com/docs/6561/1359370)

View File

@@ -0,0 +1,8 @@
{
"name": "feishu-bitable-plugin",
"description": "飞书多维表格操作。用于记录增删改查、批量操作、筛选排序、数据同步。当需要操作飞书多维表格时使用。",
"version": "1.0.0",
"author": {
"name": "qiudl"
}
}

View File

@@ -0,0 +1,130 @@
---
name: feishu-bitable
description: 飞书多维表格操作。用于记录增删改查、批量操作、筛选排序、数据同步。当需要操作飞书多维表格时使用。
---
# 飞书多维表格 (Bitable)
## URL 结构
```
https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX
└── app_token └── table_id
```
## 核心操作
### 列出记录
```python
def list_records(app_token, table_id, filter_str=None, page_size=100):
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
params = {"page_size": page_size}
if filter_str:
params["filter"] = filter_str
response = requests.get(url, headers=headers, params=params)
return response.json()["data"]["items"]
```
### 创建记录
```python
def create_record(app_token, table_id, fields):
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
response = requests.post(url, headers=headers, json={"fields": fields})
return response.json()["data"]["record"]
# 示例
create_record("BascXXX", "tblXXX", {"任务名称": "完成开发", "状态": "进行中"})
```
### 批量创建 (最多 500 条)
```python
url = f".../records/batch_create"
requests.post(url, headers=headers, json={"records": [{"fields": {...}}, ...]})
```
### 更新记录
```python
def update_record(app_token, table_id, record_id, fields):
url = f".../records/{record_id}"
response = requests.put(url, headers=headers, json={"fields": fields})
return response.json()["data"]["record"]
```
### 删除记录
```python
def delete_record(app_token, table_id, record_id):
url = f".../records/{record_id}"
return requests.delete(url, headers=headers).json()
```
## 筛选条件
```python
# 等于
'CurrentValue.[状态]="进行中"'
# 包含
'CurrentValue.[标题].contains("任务")'
# 大于(数字/日期)
'CurrentValue.[优先级]>2'
# 组合
'AND(CurrentValue.[状态]="进行中", CurrentValue.[优先级]>2)'
'OR(CurrentValue.[状态]="完成", CurrentValue.[状态]="归档")'
# 空值
'CurrentValue.[截止日期]=BLANK()'
```
## 排序
```python
params = {
"sort": '[{"field_name":"优先级","desc":true}]'
}
```
## 字段类型
| 类型 | 值格式 |
|------|--------|
| 文本 | `"字符串"` |
| 数字 | `123` |
| 单选 | `"选项值"` |
| 多选 | `["选项1", "选项2"]` |
| 日期 | `1706400000000` (毫秒时间戳) |
| 人员 | `[{"id": "ou_xxx"}]` |
| 复选框 | `true/false` |
| 链接 | `{"link": "https://...", "text": "显示文本"}` |
## 完整工具类
`~/.claude/skills/feishu/feishu_bitable.py`
```python
from feishu_bitable import FeishuBitable
bitable = FeishuBitable()
records = bitable.list_records("BascXXX", "tblXXX")
bitable.create_record("BascXXX", "tblXXX", {"名称": "测试"})
bitable.batch_create("BascXXX", "tblXXX", [{"名称": "1"}, {"名称": "2"}])
```
## 常见场景
```python
# 获取待处理任务
tasks = bitable.list_records("BascXXX", "tblXXX",
filter_str='CurrentValue.[状态]="待处理"')
# 批量更新状态
for task in tasks:
bitable.update_record("BascXXX", "tblXXX",
task["record_id"], {"状态": "已完成"})
```

View File

@@ -0,0 +1,8 @@
{
"name": "feishu-docx-plugin",
"description": "飞书云文档操作。用于创建、编辑云文档,插入内容块,会议纪要生成。当需要操作飞书云文档时使用。",
"version": "1.0.0",
"author": {
"name": "qiudl"
}
}

View File

@@ -0,0 +1,126 @@
---
name: feishu-docx
description: 飞书云文档操作。用于创建、编辑云文档,插入内容块,会议纪要生成。当需要操作飞书云文档时使用。
---
# 飞书云文档 (Docx)
## URL 结构
```
https://xxx.feishu.cn/docx/DoxcXXXXXX
└── document_id
```
## 创建文档
```python
def create_document(title, folder_token=None):
url = "https://open.feishu.cn/open-apis/docx/v1/documents"
payload = {"title": title}
if folder_token:
payload["folder_token"] = folder_token
response = requests.post(url, headers=headers, json=payload)
doc = response.json()["data"]["document"]
return {"document_id": doc["document_id"], "url": f"https://feishu.cn/docx/{doc['document_id']}"}
```
## 设置权限
```python
def set_permission(document_id, editable=True):
"""设置文档为组织内可编辑/只读"""
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{document_id}/public"
payload = {
"external_access_entity": "open",
"link_share_entity": "tenant_editable" if editable else "tenant_readable"
}
requests.patch(url, headers=headers, params={"type": "docx"}, json=payload)
```
## 内容块类型
| 类型 | block_type | 示例 |
|------|------------|------|
| 段落 | 2 | 普通文本 |
| 一级标题 | 3 | # 标题 |
| 二级标题 | 4 | ## 标题 |
| 三级标题 | 5 | ### 标题 |
| 无序列表 | 13 | - 列表项 |
| 有序列表 | 14 | 1. 列表项 |
| 代码块 | 16 | ```code``` |
| 引用 | 18 | > 引用 |
| 分割线 | 22 | --- |
| 图片 | 27 | 需先上传 |
## 创建内容块
```python
def create_block(document_id, block_id, block_type, content):
url = f"https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/children"
if block_type in [3, 4, 5]: # 标题
block = {"block_type": block_type, "heading": {"elements": [{"text_run": {"content": content}}]}}
elif block_type == 2: # 段落
block = {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
elif block_type in [13, 14]: # 列表
block = {"block_type": block_type, "bullet/ordered": {"elements": [{"text_run": {"content": content}}]}}
requests.post(url, headers=headers, json={"children": [block], "index": -1})
```
## 图片上传
```python
# 1. 上传图片到素材库
def upload_image(file_path, parent_node):
url = "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all"
with open(file_path, 'rb') as f:
files = {'file': f}
data = {'file_name': os.path.basename(file_path), 'parent_type': 'docx_image', 'parent_node': parent_node}
response = requests.post(url, headers={"Authorization": f"Bearer {token}"}, files=files, data=data)
return response.json()["data"]["file_token"]
# 2. 插入图片块
def insert_image(document_id, block_id, file_token):
block = {"block_type": 27, "image": {"token": file_token}}
# ... 同 create_block
```
## 会议纪要模板
```python
def create_meeting_notes(title, date, attendees, agenda, decisions, action_items):
doc = create_document(f"{title} - {date}")
doc_id = doc["document_id"]
# 获取根块
root = requests.get(f".../documents/{doc_id}/blocks/{doc_id}").json()
root_id = root["data"]["block"]["block_id"]
# 添加内容
create_block(doc_id, root_id, 3, f"会议纪要:{title}")
create_block(doc_id, root_id, 2, f"日期:{date}")
create_block(doc_id, root_id, 2, f"参会人:{', '.join(attendees)}")
create_block(doc_id, root_id, 4, "议程")
for item in agenda:
create_block(doc_id, root_id, 13, item)
create_block(doc_id, root_id, 4, "决议")
for item in decisions:
create_block(doc_id, root_id, 13, item)
create_block(doc_id, root_id, 4, "待办事项")
for item in action_items:
create_block(doc_id, root_id, 14, item)
return doc
```
## 完整工具类
见 `~/.claude/skills/feishu/feishu_docx.py`
## 注意事项
- 创建文档必须指定 `folder_token`,否则会出现在「与我共享」
- 默认存储到 `C80gfkRnzlonQ5d4AhOcOACDnNg`01运营文件夹
- 图片必须先上传到素材库,再插入文档

View File

@@ -0,0 +1,8 @@
{
"name": "feishu-plugin",
"description": "飞书多维表格快捷操作。通过自然语言实现多维表格的增删改查、数据同步、批量操作等功能。当用户提到飞书、多维表格、Bitable、飞书表格相关任务时自动激活。",
"version": "1.1.0",
"author": {
"name": "qiudl"
}
}

View File

@@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""
添加需求图片字段并上传图片到飞书多维表格
"""
import requests
import os
from datetime import datetime, timedelta
from PIL import Image, ImageDraw, ImageFont
# ========== 配置 ==========
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# Demo 创建的多维表格信息
APP_TOKEN = "D6PQbxf4aald77sPjDTciYbenjc"
TABLE_ID = "tblX3YbGrXm8pmLR"
class FeishuBitable:
"""飞书多维表格操作工具类"""
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
self.app_id = app_id
self.app_secret = app_secret
self._token = None
self._token_expires = None
@property
def token(self) -> str:
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
url = f"{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):
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
def create_field(self, app_token: str, table_id: str, field_name: str, field_type: int, property: dict = None):
"""创建新字段"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
payload = {
"field_name": field_name,
"type": field_type
}
if property:
payload["property"] = property
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建字段失败: {data}")
print(f"[OK] 字段 '{field_name}' 创建成功")
return data["data"]["field"]
def upload_media(self, app_token: str, file_path: str, file_name: str = None):
"""上传附件到多维表格"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
if file_name is None:
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
headers = {
"Authorization": f"Bearer {self.token}"
}
with open(file_path, 'rb') as f:
files = {
'file': (file_name, f, 'image/png')
}
data = {
'file_name': file_name,
'parent_type': 'bitable_image',
'parent_node': app_token,
'size': str(file_size)
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
if result.get("code") != 0:
raise Exception(f"上传失败: {result}")
file_token = result["data"]["file_token"]
print(f"[OK] 文件 '{file_name}' 上传成功, file_token: {file_token}")
return file_token
def list_records(self, app_token: str, table_id: str, filter_str: str = None):
"""列出记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
params = {"page_size": 100}
if filter_str:
params["filter"] = filter_str
response = requests.get(url, headers=self.headers, params=params)
data = response.json()
if data.get("code") != 0:
raise Exception(f"查询失败: {data}")
return data["data"].get("items", [])
def update_record(self, app_token: str, table_id: str, record_id: str, fields: dict):
"""更新记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
response = requests.put(url, headers=self.headers, json={"fields": fields})
data = response.json()
if data.get("code") != 0:
raise Exception(f"更新失败: {data}")
print(f"[OK] 记录 {record_id} 更新成功")
return data["data"]["record"]
def generate_sample_images(output_dir: str):
"""生成2张示例需求图片"""
os.makedirs(output_dir, exist_ok=True)
images = []
# 图片1: 用户流程图
img1 = Image.new('RGB', (800, 600), color='#f0f4f8')
draw1 = ImageDraw.Draw(img1)
# 绘制标题
draw1.rectangle([50, 30, 750, 80], fill='#4a90d9', outline='#2563eb')
draw1.text((400, 55), "用户登录流程图", fill='white', anchor='mm')
# 绘制流程框
boxes = [
(150, 150, "打开登录页"),
(400, 150, "输入账号密码"),
(650, 150, "点击登录"),
(150, 300, "验证账号"),
(400, 300, "生成Token"),
(650, 300, "跳转首页"),
]
for x, y, text in boxes:
draw1.rectangle([x-60, y-25, x+60, y+25], fill='#e8f4fd', outline='#4a90d9', width=2)
draw1.text((x, y), text, fill='#1e3a5f', anchor='mm')
# 绘制箭头线
arrows = [
(210, 150, 340, 150),
(460, 150, 590, 150),
(650, 175, 650, 275),
(590, 300, 460, 300),
(340, 300, 210, 300),
]
for x1, y1, x2, y2 in arrows:
draw1.line([(x1, y1), (x2, y2)], fill='#4a90d9', width=2)
# 添加水印
draw1.text((400, 550), "Claude Code Demo - 需求图片1", fill='#94a3b8', anchor='mm')
img1_path = os.path.join(output_dir, "requirement_flow.png")
img1.save(img1_path)
images.append(img1_path)
print(f"[OK] 生成图片: {img1_path}")
# 图片2: 界面原型图
img2 = Image.new('RGB', (800, 600), color='#ffffff')
draw2 = ImageDraw.Draw(img2)
# 绘制浏览器框架
draw2.rectangle([50, 30, 750, 570], outline='#d1d5db', width=2)
draw2.rectangle([50, 30, 750, 70], fill='#f3f4f6', outline='#d1d5db')
# 浏览器按钮
draw2.ellipse([70, 42, 86, 58], fill='#ef4444')
draw2.ellipse([95, 42, 111, 58], fill='#eab308')
draw2.ellipse([120, 42, 136, 58], fill='#22c55e')
# 地址栏
draw2.rectangle([160, 42, 600, 58], fill='white', outline='#d1d5db')
draw2.text((170, 50), "https://example.com/login", fill='#6b7280', anchor='lm')
# 登录表单区域
draw2.rectangle([200, 120, 600, 500], fill='#f8fafc', outline='#e2e8f0', width=1)
# Logo 占位
draw2.ellipse([350, 140, 450, 200], fill='#4a90d9')
draw2.text((400, 170), "LOGO", fill='white', anchor='mm')
# 标题
draw2.text((400, 230), "欢迎登录", fill='#1e293b', anchor='mm')
# 输入框
draw2.rectangle([250, 270, 550, 310], fill='white', outline='#cbd5e1')
draw2.text((260, 290), "请输入用户名", fill='#94a3b8', anchor='lm')
draw2.rectangle([250, 330, 550, 370], fill='white', outline='#cbd5e1')
draw2.text((260, 350), "请输入密码", fill='#94a3b8', anchor='lm')
# 登录按钮
draw2.rectangle([250, 400, 550, 450], fill='#4a90d9', outline='#2563eb')
draw2.text((400, 425), "登 录", fill='white', anchor='mm')
# 底部链接
draw2.text((320, 480), "忘记密码", fill='#4a90d9', anchor='mm')
draw2.text((480, 480), "注册账号", fill='#4a90d9', anchor='mm')
# 水印
draw2.text((400, 550), "Claude Code Demo - 需求图片2", fill='#94a3b8', anchor='mm')
img2_path = os.path.join(output_dir, "requirement_ui.png")
img2.save(img2_path)
images.append(img2_path)
print(f"[OK] 生成图片: {img2_path}")
return images
def main():
print("\n" + "#" * 60)
print("# 添加需求图片到多维表格")
print("#" * 60)
bitable = FeishuBitable()
# Step 1: 生成示例图片
print("\n" + "=" * 50)
print("Step 1: 生成示例图片")
print("=" * 50)
output_dir = "/tmp/feishu_demo_images"
image_paths = generate_sample_images(output_dir)
# Step 2: 添加"需求图片"字段
print("\n" + "=" * 50)
print("Step 2: 添加「需求图片」字段")
print("=" * 50)
try:
bitable.create_field(
APP_TOKEN,
TABLE_ID,
"需求图片",
17 # 17 = 附件类型
)
except Exception as e:
if "FieldNameExist" in str(e) or "FieldNameDuplicated" in str(e) or "1254043" in str(e) or "1254014" in str(e):
print("[INFO] 字段「需求图片」已存在,跳过创建")
else:
raise
# Step 3: 上传图片
print("\n" + "=" * 50)
print("Step 3: 上传图片到飞书")
print("=" * 50)
file_tokens = []
for img_path in image_paths:
file_token = bitable.upload_media(APP_TOKEN, img_path)
file_tokens.append(file_token)
# Step 4: 查找"完成产品需求文档"记录
print("\n" + "=" * 50)
print("Step 4: 查找目标记录")
print("=" * 50)
records = bitable.list_records(APP_TOKEN, TABLE_ID)
target_record = None
for record in records:
if record["fields"].get("任务名称") == "完成产品需求文档":
target_record = record
break
if not target_record:
raise Exception("未找到「完成产品需求文档」记录")
print(f"[OK] 找到记录: {target_record['record_id']}")
print(f" 任务名称: {target_record['fields'].get('任务名称')}")
# Step 5: 更新记录,添加图片
print("\n" + "=" * 50)
print("Step 5: 更新记录,添加图片附件")
print("=" * 50)
# 构造附件字段值
attachments = [{"file_token": ft} for ft in file_tokens]
bitable.update_record(
APP_TOKEN,
TABLE_ID,
target_record["record_id"],
{"需求图片": attachments}
)
# 完成
print("\n" + "=" * 60)
print("完成!")
print("=" * 60)
print(f"\n已添加 {len(file_tokens)} 张图片到「完成产品需求文档」记录")
print(f"\n访问地址查看效果:")
print(f" https://zhiyuncai.feishu.cn/base/{APP_TOKEN}?table={TABLE_ID}")
print()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,530 @@
#!/usr/bin/env python3
"""
ai-proj 与飞书项目同步脚本
功能:
- 初始化:创建飞书根目录、多维表格
- 全量同步:同步所有项目到飞书
- 增量同步:只同步新增项目
- 更新同步:更新现有项目的任务统计等信息
使用方法:
python aiproj_sync.py init # 首次初始化
python aiproj_sync.py sync # 增量同步(推荐日常使用)
python aiproj_sync.py sync-all # 全量同步
python aiproj_sync.py update # 更新统计信息
"""
import os
import sys
import json
import requests
from datetime import datetime
from typing import Optional, Dict, List, Any
# ============================================
# 配置
# ============================================
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
# 默认协作者 open_id邱栋梁@智云采购)
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
# ============================================
# 飞书 API
# ============================================
class FeishuAPI:
def __init__(self):
self.token = None
def get_token(self) -> str:
"""获取 tenant_access_token"""
if self.token:
return self.token
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": FEISHU_APP_ID,
"app_secret": FEISHU_APP_SECRET
})
data = response.json()
if data.get("code") == 0:
self.token = data["tenant_access_token"]
return self.token
else:
raise Exception(f"获取飞书 token 失败: {data}")
def _headers(self, content_type: bool = False) -> Dict:
headers = {"Authorization": f"Bearer {self.get_token()}"}
if content_type:
headers["Content-Type"] = "application/json"
return headers
def get_root_folder(self) -> str:
"""获取云空间根文件夹"""
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["token"]
raise Exception(f"获取根文件夹失败: {data}")
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
"""创建文件夹"""
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
response = requests.post(url, headers=self._headers(True), json={
"name": name,
"folder_token": parent_token
})
data = response.json()
if data.get("code") == 0:
folder_token = data["data"]["token"]
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
return folder_token
raise Exception(f"创建文件夹失败: {data}")
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
"""添加协作者
应用通过 API 创建的文件/文件夹,所有者是应用本身。
组织内用户默认无法访问,需要显式添加为协作者。
Args:
file_token: 文件/文件夹 token
file_type: 类型 (folder, doc, sheet, bitable 等)
user_open_id: 用户 open_id
perm: 权限级别 (full_access, edit, view)
"""
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
params = {"type": file_type, "need_notification": "false"}
payload = {
"member_type": "openid",
"member_id": user_open_id,
"perm": perm
}
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
return response.json().get("code") == 0
def create_bitable(self, name: str, folder_token: str) -> Dict:
"""创建多维表格"""
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
response = requests.post(url, headers=self._headers(True), json={
"name": name,
"folder_token": folder_token
})
data = response.json()
if data.get("code") == 0:
return data["data"]["app"]
raise Exception(f"创建多维表格失败: {data}")
def get_bitable_tables(self, app_token: str) -> List[Dict]:
"""获取数据表列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["items"]
raise Exception(f"获取数据表失败: {data}")
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
"""创建字段"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.post(url, headers=self._headers(True), json=field)
data = response.json()
if data.get("code") == 0:
return data["data"]["field"]
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
return None
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
"""更新字段"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
response = requests.put(url, headers=self._headers(True), json=updates)
return response.json().get("code") == 0
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
"""获取字段列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["items"]
return []
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
"""添加记录"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
data = response.json()
if data.get("code") == 0:
return data["data"]["record"]
print(f" 记录添加失败: {data.get('msg')}")
return None
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
"""获取记录列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
params = {"page_size": 500}
if filter_str:
params["filter"] = filter_str
response = requests.get(url, headers=self._headers(), params=params)
data = response.json()
if data.get("code") == 0:
return data["data"].get("items", [])
return []
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
"""更新记录"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
return response.json().get("code") == 0
# ============================================
# ai-proj API
# ============================================
class AIProjAPI:
@staticmethod
def get_projects() -> List[Dict]:
"""获取项目列表"""
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
response = requests.get(url, headers=headers)
data = response.json()
if data.get("success"):
return data["data"]["data"]
raise Exception(f"获取项目列表失败: {data}")
# ============================================
# 配置管理
# ============================================
def load_config() -> Optional[Dict]:
"""加载配置"""
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH) as f:
return json.load(f)
return None
def save_config(config: Dict):
"""保存配置"""
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
# ============================================
# 同步逻辑
# ============================================
def init():
"""初始化:创建飞书目录结构"""
print("=" * 50)
print("飞书 ai-proj 项目同步 - 初始化")
print("=" * 50)
if load_config():
print("\n已存在配置文件,是否重新初始化?(y/N)")
if input().lower() != 'y':
print("取消初始化")
return
feishu = FeishuAPI()
# 1. 获取根目录
print("\n[1/5] 获取云空间根目录...")
space_root = feishu.get_root_folder()
print(f" 根目录: {space_root}")
# 2. 创建 ai-proj 文件夹
print("\n[2/5] 创建 ai-proj 根文件夹...")
root_folder = feishu.create_folder("ai-proj", space_root)
print(f" 创建成功: {root_folder}")
# 3. 创建多维表格
print("\n[3/5] 创建项目目录多维表格...")
bitable = feishu.create_bitable("项目目录", root_folder)
app_token = bitable["app_token"]
print(f" 创建成功: {app_token}")
tables = feishu.get_bitable_tables(app_token)
table_id = tables[0]["table_id"]
# 4. 创建字段
print("\n[4/5] 创建数据表字段...")
# 先修改第一列名称
fields = feishu.get_fields(app_token, table_id)
if fields:
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
"field_name": "项目名称",
"type": fields[0]["type"]
})
print(" 修改字段: 项目名称")
# 创建其他字段
field_definitions = [
{"field_name": "项目ID", "type": 2},
{"field_name": "项目编号", "type": 1},
{"field_name": "飞书文件夹", "type": 15},
{"field_name": "状态", "type": 3, "property": {"options": [
{"name": "active", "color": 0},
{"name": "on_hold", "color": 1},
{"name": "planning", "color": 2},
{"name": "completed", "color": 3},
{"name": "archived", "color": 4}
]}},
{"field_name": "优先级", "type": 3, "property": {"options": [
{"name": "high", "color": 0},
{"name": "medium", "color": 1},
{"name": "low", "color": 2}
]}},
{"field_name": "所属企业", "type": 1},
{"field_name": "任务总数", "type": 2},
{"field_name": "描述", "type": 1},
{"field_name": "创建时间", "type": 5},
{"field_name": "最后同步", "type": 5},
]
for field in field_definitions:
if feishu.create_field(app_token, table_id, field):
print(f" 创建字段: {field['field_name']}")
# 5. 保存配置
print("\n[5/5] 保存配置...")
config = {
"root_folder_token": root_folder,
"bitable_app_token": app_token,
"bitable_table_id": table_id,
"created_at": datetime.now().isoformat(),
"synced_projects": {}
}
save_config(config)
print("\n" + "=" * 50)
print("初始化完成!")
print("=" * 50)
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
print(f"多维表格: https://feishu.cn/base/{app_token}")
print(f"\n配置文件: {CONFIG_PATH}")
print("\n运行 'python aiproj_sync.py sync' 同步项目")
def sync(full: bool = False):
"""同步项目"""
config = load_config()
if not config:
print("未找到配置,请先运行 init")
return
print("=" * 50)
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
print("=" * 50)
feishu = FeishuAPI()
app_token = config["bitable_app_token"]
table_id = config["bitable_table_id"]
root_folder = config["root_folder_token"]
synced = config.get("synced_projects", {})
# 获取 ai-proj 项目
print("\n获取 ai-proj 项目列表...")
projects = AIProjAPI.get_projects()
print(f"{len(projects)} 个项目")
# 筛选需要同步的项目
if full:
to_sync = projects
else:
to_sync = [p for p in projects if str(p["id"]) not in synced]
if not to_sync:
print("\n没有新项目需要同步")
return
print(f"\n需要同步 {len(to_sync)} 个项目...")
success = 0
for project in to_sync:
project_id = str(project["id"])
project_name = project["name"]
folder_name = f"{project_name}_{project_id}"
print(f"\n 处理: {project_name} (ID: {project_id})")
# 创建文件夹(如果不存在)
folder_token = synced.get(project_id, {}).get("folder_token")
if not folder_token:
try:
folder_token = feishu.create_folder(folder_name, root_folder)
print(f" 创建文件夹: {folder_token}")
except Exception as e:
print(f" 文件夹创建失败: {e}")
folder_token = ""
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
# 添加/更新记录
record_fields = {
"项目名称": project_name,
"项目ID": int(project_id),
"项目编号": project.get("project_number", ""),
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
"状态": project.get("status", "active"),
"优先级": project.get("priority", "medium"),
"所属企业": project.get("company_name", ""),
"任务总数": project.get("task_count", 0),
"描述": (project.get("description", "") or "")[:500],
"创建时间": int(datetime.fromisoformat(
project["created_at"].replace("Z", "+00:00")
).timestamp() * 1000),
"最后同步": int(datetime.now().timestamp() * 1000)
}
record_id = synced.get(project_id, {}).get("record_id")
if record_id and not full:
# 更新现有记录
if feishu.update_record(app_token, table_id, record_id, record_fields):
print(f" 更新记录成功")
success += 1
else:
# 添加新记录
record = feishu.add_record(app_token, table_id, record_fields)
if record:
record_id = record["record_id"]
print(f" 添加记录成功")
success += 1
# 更新同步记录
synced[project_id] = {
"folder_token": folder_token,
"folder_url": folder_url,
"record_id": record_id,
"synced_at": datetime.now().isoformat()
}
# 保存配置
config["synced_projects"] = synced
config["last_sync"] = datetime.now().isoformat()
save_config(config)
print("\n" + "=" * 50)
print(f"同步完成: {success}/{len(to_sync)}")
print("=" * 50)
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
def update_stats():
"""更新任务统计信息"""
config = load_config()
if not config:
print("未找到配置,请先运行 init")
return
print("=" * 50)
print("飞书 ai-proj 项目同步 - 更新统计")
print("=" * 50)
feishu = FeishuAPI()
app_token = config["bitable_app_token"]
table_id = config["bitable_table_id"]
synced = config.get("synced_projects", {})
# 获取最新项目数据
print("\n获取 ai-proj 项目列表...")
projects = AIProjAPI.get_projects()
project_map = {str(p["id"]): p for p in projects}
updated = 0
for project_id, sync_info in synced.items():
record_id = sync_info.get("record_id")
if not record_id:
continue
project = project_map.get(project_id)
if not project:
continue
# 只更新统计字段
update_fields = {
"任务总数": project.get("task_count", 0),
"状态": project.get("status", "active"),
"最后同步": int(datetime.now().timestamp() * 1000)
}
if feishu.update_record(app_token, table_id, record_id, update_fields):
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
updated += 1
config["last_sync"] = datetime.now().isoformat()
save_config(config)
print(f"\n更新完成: {updated} 个项目")
def show_status():
"""显示同步状态"""
config = load_config()
if not config:
print("未初始化,请先运行: python aiproj_sync.py init")
return
print("=" * 50)
print("ai-proj 飞书同步状态")
print("=" * 50)
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
print(f"上次同步: {config.get('last_sync', '从未')}")
print(f"配置文件: {CONFIG_PATH}")
# ============================================
# 主入口
# ============================================
def main():
if len(sys.argv) < 2:
print("用法: python aiproj_sync.py <command>")
print("\n命令:")
print(" init 首次初始化(创建目录和多维表格)")
print(" sync 增量同步(只同步新项目)")
print(" sync-all 全量同步(同步所有项目)")
print(" update 更新统计信息")
print(" status 查看同步状态")
return
command = sys.argv[1]
if command == "init":
init()
elif command == "sync":
sync(full=False)
elif command == "sync-all":
sync(full=True)
elif command == "update":
update_stats()
elif command == "status":
show_status()
else:
print(f"未知命令: {command}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
检查飞书云文档中的图片块状态
"""
import requests
from datetime import datetime, timedelta
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# 最近创建的测试文档
DOCUMENT_ID = "Z53YdDpezob1NPx63sQcsrt8nzd"
def get_token():
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": ZHIYUN_APP_ID,
"app_secret": ZHIYUN_APP_SECRET
})
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取 token 失败: {data}")
return data["tenant_access_token"]
def get_document_blocks(document_id: str):
"""获取文档所有块"""
token = get_token()
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取块失败: {data}")
return data["data"].get("items", [])
def main():
print(f"\n检查文档: {DOCUMENT_ID}")
print("=" * 60)
blocks = get_document_blocks(DOCUMENT_ID)
print(f"\n文档共有 {len(blocks)} 个块:\n")
for i, block in enumerate(blocks):
block_type = block.get("block_type")
block_id = block.get("block_id")
# 块类型映射
type_names = {
1: "page",
2: "text",
3: "heading1",
4: "heading2",
5: "heading3",
12: "bullet",
13: "ordered",
14: "code",
17: "todo",
22: "divider",
27: "image",
}
type_name = type_names.get(block_type, f"type_{block_type}")
print(f" [{i}] block_type={block_type} ({type_name}), block_id={block_id[:20]}...")
# 如果是图片块,显示详细信息
if block_type == 27:
image_data = block.get("image", {})
print(f" image data: {image_data}")
# 检查图片是否有效
file_token = image_data.get("token") or image_data.get("file_token")
if file_token:
print(f" file_token: {file_token}")
# 尝试获取图片信息
check_image_status(file_token)
else:
print(f" [WARN] 图片块没有 token!")
def check_image_status(file_token: str):
"""检查图片状态"""
token = get_token()
# 尝试获取文件元信息
url = f"{BASE_URL}/drive/v1/medias/{file_token}"
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
data = response.json()
print(f" 图片状态: code={data.get('code')}, msg={data.get('msg')}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""检查文档中的图片块状态"""
import requests
from datetime import datetime, timedelta
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
def get_token():
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": ZHIYUN_APP_ID,
"app_secret": ZHIYUN_APP_SECRET
})
return response.json()["tenant_access_token"]
def main():
token = get_token()
headers = {"Authorization": f"Bearer {token}"}
url = f"{BASE_URL}/docx/v1/documents/{DOCUMENT_ID}/blocks"
response = requests.get(url, headers=headers)
data = response.json()
if data.get("code") != 0:
print(f"获取失败: {data}")
return
blocks = data["data"].get("items", [])
print(f"文档共有 {len(blocks)} 个块\n")
image_count = 0
for block in blocks:
if block.get("block_type") == 27:
image_count += 1
image_data = block.get("image", {})
token_value = image_data.get("token", "")
print(f"图片块 #{image_count}:")
print(f" block_id: {block.get('block_id')}")
print(f" token: {token_value if token_value else '(空)'}")
print(f" width: {image_data.get('width')}, height: {image_data.get('height')}")
print()
if image_count == 0:
print("文档中没有图片块")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""
创建新的飞书文档ai-proj 项目可见性手册
"""
import requests
import os
import time
from datetime import datetime, timedelta
from PIL import Image, ImageDraw, ImageFont
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
_token = None
_token_expires = None
def get_token():
global _token, _token_expires
if _token and _token_expires and datetime.now() < _token_expires:
return _token
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": ZHIYUN_APP_ID,
"app_secret": ZHIYUN_APP_SECRET
})
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取 token 失败: {data}")
_token = data["tenant_access_token"]
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
return _token
def headers():
return {
"Authorization": f"Bearer {get_token()}",
"Content-Type": "application/json"
}
def create_document(title: str):
"""创建文档"""
url = f"{BASE_URL}/docx/v1/documents"
response = requests.post(url, headers=headers(), json={"title": title})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建文档失败: {data}")
doc = data["data"]["document"]
document_id = doc["document_id"]
print(f"[OK] 文档创建成功: {document_id}")
return document_id
def set_document_permission(document_id: str):
"""设置文档权限为组织内可编辑"""
url = f"{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",
"invite_external": False
}
response = requests.patch(url, headers=headers(), params={"type": "docx"}, json=payload)
result = response.json()
if result.get("code") == 0:
print("[OK] 权限设置成功: 组织内可编辑")
return True
else:
print(f"[WARN] 权限设置: {result.get('msg')}")
return False
def get_document_blocks(document_id: str):
"""获取文档所有块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
response = requests.get(url, headers=headers())
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取块失败: {data}")
return data["data"].get("items", [])
def create_blocks(document_id: str, parent_id: str, blocks: list):
"""创建内容块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
payload = {"children": blocks}
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建块失败: {data}")
return data["data"].get("children", [])
def create_image_block(document_id: str, parent_id: str):
"""创建空图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
payload = {
"children": [{
"block_type": 27,
"image": {}
}]
}
response = requests.post(url, headers=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(file_path: str, block_id: str):
"""上传图片"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
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 {get_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(document_id: str, block_id: str, file_token: str):
"""绑定图片到图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
payload = {"replace_image": {"token": file_token}}
response = requests.patch(url, headers=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(document_id: str, file_path: str, description: str = ""):
"""插入图片的完整流程:创建块 -> 上传 -> 绑定"""
print(f" 插入图片: {description}")
# Step 1: 创建空图片块
block_id = create_image_block(document_id, document_id)
print(f" block_id: {block_id}")
# Step 2: 上传图片
file_token = upload_image(file_path, block_id)
print(f" file_token: {file_token}")
# Step 3: 绑定图片到块
bind_image(document_id, block_id, file_token)
print(f" 绑定成功!")
# 等待处理
time.sleep(0.5)
return block_id, file_token
def generate_visibility_diagram():
"""生成可见性示意图"""
output_path = "/tmp/visibility_diagram.png"
img = Image.new('RGB', (800, 400), color='#ffffff')
draw = ImageDraw.Draw(img)
# 尝试加载字体
try:
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18)
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14)
except:
font_large = ImageFont.load_default()
font_medium = ImageFont.load_default()
font_small = ImageFont.load_default()
# 标题背景
draw.rectangle([0, 0, 800, 60], fill='#1890ff')
draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large)
# 私有项目区域
draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3)
draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium)
draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small)
draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small)
# 私有项目图标
draw.ellipse([175, 210, 245, 280], fill='#fa8c16')
draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large)
draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small)
draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small)
# 企业项目区域
draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3)
draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium)
draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small)
draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small)
# 企业项目图标组
positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)]
for x, y in positions:
draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff')
draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium)
draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small)
# 中间箭头
draw.text((400, 225), "", fill='#333333', anchor='mm', font=font_large)
img.save(output_path)
print(f"[OK] 生成可见性示意图: {output_path}")
return output_path
def generate_ui_screenshot():
"""生成 UI 示意图"""
output_path = "/tmp/visibility_ui.png"
img = Image.new('RGB', (700, 250), color='#fafafa')
draw = ImageDraw.Draw(img)
try:
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16)
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12)
except:
font_medium = ImageFont.load_default()
font_small = ImageFont.load_default()
# 标题
draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium)
# 私有项目选项(选中状态)
draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2)
draw.ellipse([45, 95, 65, 115], fill='#fa8c16')
draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium)
draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small)
# 默认标签
draw.rectangle([250, 85, 310, 110], fill='#52c41a')
draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small)
# 企业项目选项(未选中状态)
draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1)
draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2)
draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium)
draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small)
# 底部说明
draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1)
draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small)
draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small)
img.save(output_path)
print(f"[OK] 生成 UI 示意图: {output_path}")
return output_path
# 文档内容块定义
def heading1(text):
return {"block_type": 3, "heading1": {"elements": [{"text_run": {"content": text}}]}}
def heading2(text):
return {"block_type": 4, "heading2": {"elements": [{"text_run": {"content": text}}]}}
def heading3(text):
return {"block_type": 5, "heading3": {"elements": [{"text_run": {"content": text}}]}}
def text_block(content):
return {"block_type": 2, "text": {"elements": [{"text_run": {"content": content}}]}}
def bullet(content):
return {"block_type": 12, "bullet": {"elements": [{"text_run": {"content": content}}]}}
def ordered(content):
return {"block_type": 13, "ordered": {"elements": [{"text_run": {"content": content}}]}}
def code_block(content, language=1):
return {"block_type": 14, "code": {"elements": [{"text_run": {"content": content}}], "language": language}}
def divider():
return {"block_type": 22, "divider": {}}
def main():
print("\n" + "#" * 60)
print("# 创建新文档ai-proj 项目可见性手册")
print("#" * 60)
# Step 1: 生成示意图
print("\n--- Step 1: 生成示意图 ---")
diagram_path = generate_visibility_diagram()
ui_path = generate_ui_screenshot()
# Step 2: 创建新文档
print("\n--- Step 2: 创建新文档 ---")
doc_id = create_document("ai-proj 项目可见性手册")
set_document_permission(doc_id)
# Step 3: 创建手册内容
print("\n--- Step 3: 创建手册内容 ---")
# 第一部分:标题和概述
blocks_part1 = [
heading1("ai-proj 项目可见性手册"),
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
divider(),
heading2("1. 功能概述"),
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
bullet("私有项目 (private):仅项目创建者和管理员可见"),
bullet("企业项目 (enterprise):企业内所有成员可见"),
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
divider(),
heading2("2. 可见性对比"),
text_block("下图展示了两种可见性的区别:"),
]
create_blocks(doc_id, doc_id, blocks_part1)
print(" 第一部分内容创建完成")
# 插入可见性示意图
print("\n--- Step 4: 插入可见性示意图 ---")
insert_image(doc_id, diagram_path, "可见性示意图")
# 第二部分:设置方法
print("\n--- Step 5: 添加设置说明 ---")
blocks_part2 = [
divider(),
heading2("3. 设置项目可见性"),
heading3("3.1 创建项目时设置"),
text_block("在创建项目时,您可以选择项目的可见性:"),
ordered("点击「创建项目」按钮"),
ordered("在弹出的表单中找到「项目可见性」选项"),
ordered("选择「私有项目」或「企业项目」"),
ordered("填写其他信息后点击「确定」"),
text_block("UI 界面示意:"),
]
create_blocks(doc_id, doc_id, blocks_part2)
print(" 设置说明创建完成")
# 插入 UI 示意图
print("\n--- Step 6: 插入 UI 示意图 ---")
insert_image(doc_id, ui_path, "UI 示意图")
# 第三部分:修改方法和规则
print("\n--- Step 7: 添加剩余内容 ---")
blocks_part3 = [
heading3("3.2 修改已有项目"),
ordered("在项目列表中找到目标项目"),
ordered("点击项目卡片上的「编辑」按钮"),
ordered("在编辑页面修改「项目可见性」"),
ordered("点击「保存」"),
divider(),
heading2("4. 可见性规则"),
bullet("私有项目:仅所有者和超级管理员可以查看"),
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
bullet("只有项目所有者或管理员可以修改可见性"),
bullet("设置为「企业项目」需要项目已关联企业"),
divider(),
heading2("5. API 参考"),
text_block("修改项目可见性的 API"),
code_block('''PATCH /api/v1/projects/:id/visibility
请求体:
{
"visibility": "enterprise"
}
可选值: "private" | "enterprise"'''),
divider(),
heading2("6. 注意事项"),
bullet("新项目默认为私有,请根据需要调整可见性"),
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
bullet("将企业项目改为私有后,其他成员将无法访问"),
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
divider(),
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
]
create_blocks(doc_id, doc_id, blocks_part3)
print("\n" + "=" * 60)
print("文档创建完成!")
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{doc_id}")
print("=" * 60)
# 验证图片
print("\n--- 验证图片状态 ---")
time.sleep(2) # 等待服务器处理
blocks = get_document_blocks(doc_id)
image_count = 0
for block in blocks:
if block.get("block_type") == 27:
image_count += 1
image_data = block.get("image", {})
token = image_data.get("token", "")
width = image_data.get("width", 0)
height = image_data.get("height", 0)
status = "✅ 有效" if token else "❌ 空"
print(f"图片 #{image_count}: {status} (token: {token[:15]}..., 尺寸: {width}x{height})")
if image_count == 0:
print("⚠️ 警告:文档中没有图片块")
elif image_count == 2:
print(f"\n✅ 成功上传 {image_count} 张图片")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
深入调试飞书云文档图片上传
"""
import requests
import os
from datetime import datetime, timedelta
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
_token = None
_token_expires = None
def get_token():
global _token, _token_expires
if _token and _token_expires and datetime.now() < _token_expires:
return _token
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": ZHIYUN_APP_ID,
"app_secret": ZHIYUN_APP_SECRET
})
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取 token 失败: {data}")
_token = data["tenant_access_token"]
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
return _token
def headers():
return {
"Authorization": f"Bearer {get_token()}",
"Content-Type": "application/json"
}
def set_document_permission(document_id: str, editable: bool = True):
"""设置文档权限"""
url = f"{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=headers(), params={"type": "docx"}, json=payload)
return response.json().get("code") == 0
def create_document(title: str, editable: bool = True):
"""创建文档(自动设置为组织内可编辑)"""
url = f"{BASE_URL}/docx/v1/documents"
response = requests.post(url, headers=headers(), json={"title": title})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建文档失败: {data}")
doc = data["data"]["document"]
document_id = doc["document_id"]
print(f"[OK] 文档创建成功: {document_id}")
# 自动设置权限
if editable and set_document_permission(document_id, True):
print(f"[OK] 权限设置成功: 组织内可编辑")
return document_id
def get_raw_content(document_id: str):
"""获取文档原始内容"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/raw_content"
response = requests.get(url, headers=headers())
data = response.json()
print(f"[DEBUG] raw_content 响应: {data}")
return data
def get_blocks(document_id: str):
"""获取文档块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
# 尝试添加 document_revision_id 参数
params = {"document_revision_id": -1} # -1 表示最新版本
response = requests.get(url, headers=headers(), params=params)
data = response.json()
return data
def create_image_block(document_id: str):
"""创建图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
# 添加 document_revision_id 参数
params = {"document_revision_id": -1}
payload = {
"children": [{
"block_type": 27,
"image": {}
}]
}
response = requests.post(url, headers=headers(), params=params, json=payload)
data = response.json()
print(f"[DEBUG] 创建图片块响应: {data}")
if data.get("code") != 0:
raise Exception(f"创建图片块失败: {data}")
block_id = data["data"]["children"][0]["block_id"]
print(f"[OK] 图片块创建成功: {block_id}")
return block_id
def upload_image(file_path: str, block_id: str):
"""上传图片"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
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 {get_token()}"},
files=files,
data=data
)
result = response.json()
print(f"[DEBUG] 上传响应: {result}")
if result.get("code") != 0:
raise Exception(f"上传失败: {result}")
return result["data"]["file_token"]
def bind_image_to_block(document_id: str, block_id: str, file_token: str):
"""
绑定图片到图片块 (关键步骤!)
使用 replace_image 字段通过 PATCH 请求绑定
"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
params = {"document_revision_id": -1}
# 正确的 payload: 使用 replace_image
payload = {
"replace_image": {
"token": file_token
}
}
print(f"[INFO] 绑定图片: PATCH {url}")
print(f"[INFO] payload: {payload}")
response = requests.patch(url, headers=headers(), params=params, json=payload)
result = response.json()
print(f"[DEBUG] 绑定响应: {result}")
if result.get("code") == 0:
print(f"[OK] 图片绑定成功!")
return True
else:
print(f"[ERROR] 图片绑定失败: code={result.get('code')}, msg={result.get('msg')}")
return False
def main():
print("\n" + "#" * 60)
print("# 深入调试飞书云文档图片上传")
print("#" * 60)
# 生成测试图片
from PIL import Image, ImageDraw
test_image = "/tmp/debug_test_image.png"
img = Image.new('RGB', (200, 150), color='#4a90d9')
draw = ImageDraw.Draw(img)
draw.text((100, 75), "Test", fill='white', anchor='mm')
img.save(test_image)
print(f"[OK] 测试图片: {test_image}")
# Step 1: 创建文档
print("\n--- Step 1: 创建文档 ---")
doc_id = create_document(f"调试图片上传 - {datetime.now().strftime('%H:%M:%S')}")
# Step 2: 创建图片块
print("\n--- Step 2: 创建图片块 ---")
block_id = create_image_block(doc_id)
# Step 3: 上传图片
print("\n--- Step 3: 上传图片 ---")
file_token = upload_image(test_image, block_id)
print(f"[OK] file_token: {file_token}")
# Step 4: 检查块状态
print("\n--- Step 4: 检查块状态 ---")
blocks = get_blocks(doc_id)
for item in blocks.get("data", {}).get("items", []):
if item.get("block_type") == 27:
print(f"[INFO] 图片块: {item}")
# Step 5: 绑定图片到图片块 (关键步骤!)
print("\n--- Step 5: 绑定图片到图片块 ---")
bind_image_to_block(doc_id, block_id, file_token)
# Step 6: 再次检查
print("\n--- Step 6: 再次检查块状态 ---")
import time
time.sleep(2) # 等待2秒
blocks = get_blocks(doc_id)
for item in blocks.get("data", {}).get("items", []):
if item.get("block_type") == 27:
print(f"[INFO] 图片块: {item}")
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""
飞书多维表格 Demo
使用 zhiyun.ai 凭证演示多维表格的完整操作流程
"""
import requests
from datetime import datetime, timedelta
from typing import Optional, List, Dict
# ========== 配置 ==========
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# ========== 工具类 ==========
class FeishuBitable:
"""飞书多维表格操作工具类"""
def __init__(self, app_id: str = ZHIYUN_APP_ID, app_secret: str = ZHIYUN_APP_SECRET):
self.app_id = app_id
self.app_secret = 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"{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)
print(f"[OK] Token 获取成功,有效期至 {self._token_expires}")
return self._token
@property
def headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
# ========== 多维表格管理 ==========
def create_bitable(self, name: str, folder_token: str = None) -> Dict:
"""
创建新的多维表格
Args:
name: 多维表格名称
folder_token: 文件夹 token可选不指定则创建在根目录
"""
url = f"{BASE_URL}/bitable/v1/apps"
payload = {"name": name}
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}")
app_info = data["data"]["app"]
print(f"[OK] 多维表格创建成功")
print(f" 名称: {app_info['name']}")
print(f" app_token: {app_info['app_token']}")
print(f" URL: {app_info.get('url', 'N/A')}")
return app_info
def list_tables(self, app_token: str) -> List[Dict]:
"""列出多维表格中的所有数据表"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
response = requests.get(url, headers=self.headers)
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取数据表列表失败: {data}")
tables = data["data"].get("items", [])
print(f"[OK] 找到 {len(tables)} 个数据表")
for t in tables:
print(f" - {t['name']} (table_id: {t['table_id']})")
return tables
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
"""
在多维表格中创建数据表
Args:
app_token: 多维表格 app_token
name: 数据表名称
fields: 字段定义列表
"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
payload = {
"table": {
"name": name,
"default_view_name": "默认视图",
"fields": fields
}
}
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建数据表失败: {data}")
table_info = data["data"]
print(f"[OK] 数据表创建成功")
print(f" 名称: {name}")
print(f" table_id: {table_info['table_id']}")
return table_info
# ========== 记录操作 ==========
def list_records(self, app_token: str, table_id: str,
filter_str: str = None, page_size: int = 100) -> List[Dict]:
"""列出所有记录(自动分页)"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
all_records = []
page_token = None
while True:
params = {"page_size": min(page_size, 500)}
if page_token:
params["page_token"] = page_token
if filter_str:
params["filter"] = filter_str
response = requests.get(url, headers=self.headers, params=params)
data = response.json()
if data.get("code") != 0:
raise Exception(f"查询失败: {data}")
items = data["data"].get("items", [])
all_records.extend(items)
if not data["data"].get("has_more"):
break
page_token = data["data"]["page_token"]
return all_records
def create_record(self, app_token: str, table_id: str, fields: Dict) -> Dict:
"""创建单条记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
response = requests.post(url, headers=self.headers, json={"fields": fields})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建失败: {data}")
return data["data"]["record"]
def batch_create(self, app_token: str, table_id: str,
records: List[Dict], batch_size: int = 500) -> List[Dict]:
"""批量创建记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
created = []
for i in range(0, len(records), batch_size):
batch = [{"fields": r} if "fields" not in r else r for r in records[i:i+batch_size]]
response = requests.post(url, headers=self.headers, json={"records": batch})
data = response.json()
if data.get("code") != 0:
raise Exception(f"批量创建失败: {data}")
created.extend(data["data"]["records"])
return created
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
"""获取字段定义"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.get(url, headers=self.headers)
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取字段失败: {data}")
return data["data"]["items"]
# ========== Demo 函数 ==========
def demo_verify_credentials():
"""验证凭证是否有效"""
print("\n" + "="*50)
print("Step 1: 验证飞书应用凭证")
print("="*50)
bitable = FeishuBitable()
token = bitable.token # 触发 token 获取
print(f" Token 前缀: {token[:20]}...")
return bitable
def demo_create_bitable(bitable: FeishuBitable) -> str:
"""创建新的多维表格"""
print("\n" + "="*50)
print("Step 2: 创建多维表格")
print("="*50)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
name = f"Claude Code Demo - {timestamp}"
app_info = bitable.create_bitable(name)
return app_info["app_token"]
def demo_create_task_table(bitable: FeishuBitable, app_token: str) -> str:
"""创建任务管理数据表"""
print("\n" + "="*50)
print("Step 3: 创建任务管理数据表")
print("="*50)
# 定义字段
fields = [
{
"field_name": "任务名称",
"type": 1 # 文本
},
{
"field_name": "状态",
"type": 3, # 单选
"property": {
"options": [
{"name": "待处理", "color": 0},
{"name": "进行中", "color": 1},
{"name": "已完成", "color": 2},
{"name": "已取消", "color": 3}
]
}
},
{
"field_name": "优先级",
"type": 3, # 单选
"property": {
"options": [
{"name": "", "color": 4},
{"name": "", "color": 5},
{"name": "", "color": 6}
]
}
},
{
"field_name": "负责人",
"type": 1 # 文本
},
{
"field_name": "截止日期",
"type": 5, # 日期
"property": {
"date_formatter": "yyyy/MM/dd"
}
},
{
"field_name": "工时(小时)",
"type": 2 # 数字
},
{
"field_name": "备注",
"type": 1 # 文本
}
]
table_info = bitable.create_table(app_token, "任务看板", fields)
return table_info["table_id"]
def demo_add_sample_data(bitable: FeishuBitable, app_token: str, table_id: str):
"""添加示例数据"""
print("\n" + "="*50)
print("Step 4: 添加示例数据")
print("="*50)
# 准备示例数据
now = datetime.now()
sample_tasks = [
{
"任务名称": "完成产品需求文档",
"状态": "已完成",
"优先级": "",
"负责人": "张三",
"截止日期": int((now - timedelta(days=2)).timestamp() * 1000),
"工时(小时)": 8,
"备注": "PRD 已评审通过"
},
{
"任务名称": "设计系统架构方案",
"状态": "进行中",
"优先级": "",
"负责人": "李四",
"截止日期": int((now + timedelta(days=3)).timestamp() * 1000),
"工时(小时)": 16,
"备注": "正在编写技术方案"
},
{
"任务名称": "开发用户登录模块",
"状态": "待处理",
"优先级": "",
"负责人": "王五",
"截止日期": int((now + timedelta(days=7)).timestamp() * 1000),
"工时(小时)": 24,
"备注": "等待架构方案确定"
},
{
"任务名称": "编写单元测试",
"状态": "待处理",
"优先级": "",
"负责人": "赵六",
"截止日期": int((now + timedelta(days=10)).timestamp() * 1000),
"工时(小时)": 12,
"备注": ""
},
{
"任务名称": "部署测试环境",
"状态": "待处理",
"优先级": "",
"负责人": "钱七",
"截止日期": int((now + timedelta(days=14)).timestamp() * 1000),
"工时(小时)": 4,
"备注": "需要申请服务器资源"
}
]
# 批量创建记录
created = bitable.batch_create(app_token, table_id, sample_tasks)
print(f"[OK] 成功创建 {len(created)} 条示例记录")
# 显示创建的记录
for i, record in enumerate(created, 1):
fields = record["fields"]
print(f" {i}. {fields.get('任务名称')} - {fields.get('状态')} ({fields.get('优先级')}优先级)")
return created
def demo_query_data(bitable: FeishuBitable, app_token: str, table_id: str):
"""查询数据演示"""
print("\n" + "="*50)
print("Step 5: 查询数据演示")
print("="*50)
# 查询所有记录
all_records = bitable.list_records(app_token, table_id)
print(f"[OK] 共 {len(all_records)} 条记录")
# 获取字段定义
fields = bitable.get_fields(app_token, table_id)
print(f"[OK] 共 {len(fields)} 个字段:")
for f in fields:
print(f" - {f['field_name']} (类型: {f['type']})")
return all_records
def main():
"""运行完整 Demo"""
print("\n" + "#"*60)
print("# 飞书多维表格 Demo - zhiyun.ai")
print("#"*60)
try:
# Step 1: 验证凭证
bitable = demo_verify_credentials()
# Step 2: 创建多维表格
app_token = demo_create_bitable(bitable)
# Step 3: 创建数据表
table_id = demo_create_task_table(bitable, app_token)
# Step 4: 添加示例数据
demo_add_sample_data(bitable, app_token, table_id)
# Step 5: 查询数据
demo_query_data(bitable, app_token, table_id)
# 完成
print("\n" + "="*60)
print("Demo 完成!")
print("="*60)
print(f"\n多维表格信息:")
print(f" app_token: {app_token}")
print(f" table_id: {table_id}")
print(f"\n访问地址:")
print(f" https://zhiyun-ai.feishu.cn/base/{app_token}?table={table_id}")
print()
return app_token, table_id
except Exception as e:
print(f"\n[ERROR] {e}")
raise
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,451 @@
#!/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("文档中没有图片")

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""
WPS .dbt 文件迁移到飞书多维表格
"""
import pandas as pd
import requests
import json
import re
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
# ========== 配置 ==========
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# 源文件
SOURCE_FILE = "/Users/donglinlai/Downloads/酷采团购系统优化进度表.dbt.xlsx"
class FeishuBitable:
"""飞书多维表格操作工具类"""
def __init__(self):
self._token = None
self._token_expires = None
@property
def token(self) -> str:
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": ZHIYUN_APP_ID,
"app_secret": ZHIYUN_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):
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
def create_bitable(self, name: str) -> Dict:
"""创建多维表格"""
url = f"{BASE_URL}/bitable/v1/apps"
response = requests.post(url, headers=self.headers, json={"name": name})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建多维表格失败: {data}")
return data["data"]["app"]
def create_table(self, app_token: str, name: str, fields: List[Dict]) -> Dict:
"""创建数据表"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables"
payload = {
"table": {
"name": name,
"default_view_name": "默认视图",
"fields": fields
}
}
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建数据表失败: {data}")
return data["data"]
def batch_create_records(self, app_token: str, table_id: str,
records: List[Dict], batch_size: int = 100) -> int:
"""批量创建记录"""
url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
total_created = 0
for i in range(0, len(records), batch_size):
batch = records[i:i+batch_size]
payload = {"records": [{"fields": r} for r in batch]}
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
print(f" [WARN] 批次 {i//batch_size + 1} 部分失败: {data.get('msg', '')}")
# 尝试逐条插入
for record in batch:
try:
single_url = f"{BASE_URL}/bitable/v1/apps/{app_token}/tables/{table_id}/records"
single_resp = requests.post(single_url, headers=self.headers, json={"fields": record})
if single_resp.json().get("code") == 0:
total_created += 1
except:
pass
else:
total_created += len(data["data"]["records"])
return total_created
def analyze_column_type(series: pd.Series, col_name: str) -> Dict:
"""分析列的数据类型,返回飞书字段定义"""
col_lower = col_name.lower()
# 根据列名判断类型
if any(kw in col_lower for kw in ['日期', '时间', 'date', 'time', '提出时间', '发版日期', '更新时间']):
return {"field_name": col_name, "type": 5, "property": {"date_formatter": "yyyy/MM/dd"}}
if any(kw in col_lower for kw in ['图片', '附件', '截图', 'image', 'file', 'attachment']):
return {"field_name": col_name, "type": 1} # 作为文本处理
if any(kw in col_lower for kw in ['优先级', '状态', '类型', '分类', '终端', '严重程度']):
# 提取唯一值作为选项
unique_vals = series.dropna().astype(str).unique()
unique_vals = [v for v in unique_vals if v and v != 'nan' and len(v) < 50][:20]
if len(unique_vals) > 0 and len(unique_vals) <= 20:
return {
"field_name": col_name,
"type": 3, # 单选
"property": {
"options": [{"name": str(v)} for v in unique_vals]
}
}
if any(kw in col_lower for kw in ['进度', '百分比', '%']):
return {"field_name": col_name, "type": 2} # 数字
# 检查是否为数字列
try:
numeric_vals = pd.to_numeric(series.dropna(), errors='coerce')
if numeric_vals.notna().sum() / max(len(series.dropna()), 1) > 0.8:
return {"field_name": col_name, "type": 2} # 数字
except:
pass
# 默认为文本
return {"field_name": col_name, "type": 1}
def clean_value(val: Any, field_type: int) -> Any:
"""清理和转换值"""
if pd.isna(val) or val is None:
return None
if field_type == 5: # 日期
try:
if isinstance(val, (datetime, pd.Timestamp)):
return int(val.timestamp() * 1000)
elif isinstance(val, str):
dt = pd.to_datetime(val)
return int(dt.timestamp() * 1000)
except:
return None
if field_type == 2: # 数字
try:
return float(val)
except:
return None
if field_type == 3: # 单选
val_str = str(val).strip()
if val_str and val_str != 'nan':
return val_str
return None
# 文本类型
val_str = str(val).strip()
if val_str == 'nan' or not val_str:
return None
# 限制文本长度
if len(val_str) > 10000:
val_str = val_str[:10000] + "..."
return val_str
def migrate_sheet(bitable: FeishuBitable, app_token: str,
df: pd.DataFrame, sheet_name: str) -> str:
"""迁移单个 Sheet 到数据表"""
print(f"\n{'='*50}")
print(f"迁移 Sheet: 【{sheet_name}")
print(f"{'='*50}")
# 清理列名
df.columns = [str(c).strip() for c in df.columns]
# 去除完全空的行
df = df.dropna(how='all')
print(f" 数据: {len(df)} 行, {len(df.columns)}")
# 分析字段类型
fields = []
field_types = {}
for col in df.columns:
if not col or col.startswith('Unnamed'):
continue
field_def = analyze_column_type(df[col], col)
fields.append(field_def)
field_types[col] = field_def["type"]
print(f" 字段: {len(fields)}")
# 创建数据表
table_info = bitable.create_table(app_token, sheet_name, fields)
table_id = table_info["table_id"]
print(f" [OK] 数据表创建成功: {table_id}")
# 准备记录数据
records = []
for _, row in df.iterrows():
record = {}
for col in df.columns:
if not col or col.startswith('Unnamed'):
continue
val = clean_value(row[col], field_types.get(col, 1))
if val is not None:
record[col] = val
if record: # 只添加非空记录
records.append(record)
print(f" 准备导入 {len(records)} 条记录...")
# 批量创建记录
if records:
created = bitable.batch_create_records(app_token, table_id, records)
print(f" [OK] 成功导入 {created}/{len(records)} 条记录")
else:
print(f" [INFO] 无有效数据")
return table_id
def main():
print("\n" + "#" * 60)
print("# WPS 文件迁移到飞书多维表格")
print("#" * 60)
bitable = FeishuBitable()
# Step 1: 读取源文件
print("\n" + "=" * 50)
print("Step 1: 读取源文件")
print("=" * 50)
xlsx = pd.ExcelFile(SOURCE_FILE)
print(f" 文件: {SOURCE_FILE}")
print(f" Sheet 数量: {len(xlsx.sheet_names)}")
# Step 2: 创建多维表格
print("\n" + "=" * 50)
print("Step 2: 创建飞书多维表格")
print("=" * 50)
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
bitable_name = f"酷采团购系统优化进度表 (迁移 {timestamp})"
app_info = bitable.create_bitable(bitable_name)
app_token = app_info["app_token"]
print(f" [OK] 多维表格创建成功")
print(f" 名称: {bitable_name}")
print(f" app_token: {app_token}")
# Step 3: 迁移每个 Sheet
print("\n" + "=" * 50)
print("Step 3: 迁移数据表")
print("=" * 50)
table_ids = {}
for sheet_name in xlsx.sheet_names:
df = pd.read_excel(xlsx, sheet_name=sheet_name)
table_id = migrate_sheet(bitable, app_token, df, sheet_name)
table_ids[sheet_name] = table_id
# 完成
print("\n" + "=" * 60)
print("迁移完成!")
print("=" * 60)
print(f"\n多维表格信息:")
print(f" 名称: {bitable_name}")
print(f" app_token: {app_token}")
print(f"\n数据表:")
for name, tid in table_ids.items():
print(f" - {name}: {tid}")
print(f"\n访问地址:")
print(f" https://zhiyuncai.feishu.cn/base/{app_token}")
print()
return app_token, table_ids
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,449 @@
#!/usr/bin/env python3
"""
重建飞书文档ai-proj 项目可见性手册
清理现有内容后重新生成
"""
import requests
import os
import time
from datetime import datetime, timedelta
from PIL import Image, ImageDraw, ImageFont
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# 目标文档
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
_token = None
_token_expires = None
def get_token():
global _token, _token_expires
if _token and _token_expires and datetime.now() < _token_expires:
return _token
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": ZHIYUN_APP_ID,
"app_secret": ZHIYUN_APP_SECRET
})
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取 token 失败: {data}")
_token = data["tenant_access_token"]
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
return _token
def headers():
return {
"Authorization": f"Bearer {get_token()}",
"Content-Type": "application/json"
}
def get_document_blocks(document_id: str):
"""获取文档所有块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
response = requests.get(url, headers=headers())
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取块失败: {data}")
return data["data"].get("items", [])
def delete_block(document_id: str, block_id: str):
"""删除块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
response = requests.delete(url, headers=headers(), params={"document_revision_id": -1})
result = response.json()
return result.get("code") == 0
def clear_document(document_id: str):
"""清空文档内容(保留根块)"""
print("正在清空文档...")
blocks = get_document_blocks(document_id)
# 找出所有子块(排除根块本身)
child_blocks = [b for b in blocks if b.get("block_id") != document_id and b.get("parent_id") == document_id]
print(f" 发现 {len(child_blocks)} 个顶级子块需要删除")
# 从后向前删除,避免索引问题
for block in reversed(child_blocks):
block_id = block.get("block_id")
if delete_block(document_id, block_id):
print(f" 删除: {block_id[:20]}...")
else:
print(f" 删除失败: {block_id[:20]}...")
time.sleep(0.1) # 避免请求过快
print(" 文档清空完成")
def create_blocks(document_id: str, parent_id: str, blocks: list):
"""创建内容块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
payload = {"children": blocks}
response = requests.post(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建块失败: {data}")
return data["data"].get("children", [])
def create_image_block(document_id: str, parent_id: str):
"""创建空图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
payload = {
"children": [{
"block_type": 27,
"image": {}
}]
}
response = requests.post(url, headers=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(file_path: str, block_id: str):
"""上传图片"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
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 {get_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(document_id: str, block_id: str, file_token: str):
"""绑定图片到图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
payload = {"replace_image": {"token": file_token}}
response = requests.patch(url, headers=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(document_id: str, file_path: str, description: str = ""):
"""插入图片的完整流程"""
print(f" 插入图片: {description}")
# Step 1: 创建空图片块
block_id = create_image_block(document_id, document_id)
print(f" block_id: {block_id[:20]}...")
# Step 2: 上传图片
file_token = upload_image(file_path, block_id)
print(f" file_token: {file_token[:20]}...")
# Step 3: 绑定图片
bind_image(document_id, block_id, file_token)
print(f" 绑定成功!")
return block_id
def generate_visibility_diagram():
"""生成可见性示意图"""
output_path = "/tmp/visibility_diagram.png"
img = Image.new('RGB', (800, 400), color='#ffffff')
draw = ImageDraw.Draw(img)
# 尝试加载字体,如果失败则使用默认
try:
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 18)
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 14)
except:
font_large = ImageFont.load_default()
font_medium = ImageFont.load_default()
font_small = ImageFont.load_default()
# 标题背景
draw.rectangle([0, 0, 800, 60], fill='#1890ff')
draw.text((400, 30), "ai-proj 项目可见性示意图", fill='white', anchor='mm', font=font_large)
# 私有项目区域
draw.rectangle([40, 80, 380, 370], fill='#fff7e6', outline='#fa8c16', width=3)
draw.text((210, 110), "🔒 私有项目", fill='#fa8c16', anchor='mm', font=font_medium)
draw.text((210, 140), "(private)", fill='#d48806', anchor='mm', font=font_small)
# 私有项目说明
draw.text((210, 180), "仅项目创建者可见", fill='#666666', anchor='mm', font=font_small)
# 私有项目图标
draw.ellipse([175, 210, 245, 280], fill='#fa8c16')
draw.text((210, 245), "👤", fill='white', anchor='mm', font=font_large)
draw.text((210, 310), "项目所有者", fill='#333333', anchor='mm', font=font_small)
draw.text((210, 335), "管理员", fill='#999999', anchor='mm', font=font_small)
# 企业项目区域
draw.rectangle([420, 80, 760, 370], fill='#e6f7ff', outline='#1890ff', width=3)
draw.text((590, 110), "👥 企业项目", fill='#1890ff', anchor='mm', font=font_medium)
draw.text((590, 140), "(enterprise)", fill='#096dd9', anchor='mm', font=font_small)
# 企业项目说明
draw.text((590, 180), "企业内所有成员可见", fill='#666666', anchor='mm', font=font_small)
# 企业项目图标组
positions = [(520, 230), (590, 230), (660, 230), (555, 290), (625, 290)]
for x, y in positions:
draw.ellipse([x-25, y-25, x+25, y+25], fill='#1890ff')
draw.text((x, y), "👤", fill='white', anchor='mm', font=font_medium)
draw.text((590, 340), "企业所有成员", fill='#333333', anchor='mm', font=font_small)
# 中间箭头
draw.text((400, 225), "", fill='#333333', anchor='mm', font=font_large)
img.save(output_path)
print(f"[OK] 生成可见性示意图: {output_path}")
return output_path
def generate_ui_screenshot():
"""生成 UI 示意图"""
output_path = "/tmp/visibility_ui.png"
img = Image.new('RGB', (700, 250), color='#fafafa')
draw = ImageDraw.Draw(img)
try:
font_medium = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 16)
font_small = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 12)
except:
font_medium = ImageFont.load_default()
font_small = ImageFont.load_default()
# 标题
draw.text((30, 25), "创建项目 - 可见性选择", fill='#333333', font=font_medium)
# 私有项目选项(选中状态)
draw.rectangle([30, 70, 320, 140], fill='#fff7e6', outline='#fa8c16', width=2)
draw.ellipse([45, 95, 65, 115], fill='#fa8c16')
draw.text((80, 90), "🔒 私有项目", fill='#fa8c16', font=font_medium)
draw.text((80, 115), "仅创建者可见", fill='#999999', font=font_small)
# 默认标签
draw.rectangle([250, 85, 310, 110], fill='#52c41a')
draw.text((280, 97), "默认", fill='white', anchor='mm', font=font_small)
# 企业项目选项(未选中状态)
draw.rectangle([350, 70, 640, 140], fill='#ffffff', outline='#d9d9d9', width=1)
draw.ellipse([365, 95, 385, 115], outline='#d9d9d9', width=2)
draw.text((400, 90), "👥 企业项目", fill='#666666', font=font_medium)
draw.text((400, 115), "企业内所有成员可见", fill='#999999', font=font_small)
# 底部说明
draw.rectangle([30, 170, 670, 220], fill='#f0f5ff', outline='#adc6ff', width=1)
draw.text((50, 185), "💡 提示:新创建的项目默认为私有项目。", fill='#1890ff', font=font_small)
draw.text((50, 205), "设置为企业项目后,项目需要先关联企业才能被企业成员访问。", fill='#666666', font=font_small)
img.save(output_path)
print(f"[OK] 生成 UI 示意图: {output_path}")
return output_path
# 文档内容块定义
def heading1(text):
return {
"block_type": 3,
"heading1": {"elements": [{"text_run": {"content": text}}]}
}
def heading2(text):
return {
"block_type": 4,
"heading2": {"elements": [{"text_run": {"content": text}}]}
}
def heading3(text):
return {
"block_type": 5,
"heading3": {"elements": [{"text_run": {"content": text}}]}
}
def text_block(content):
return {
"block_type": 2,
"text": {"elements": [{"text_run": {"content": content}}]}
}
def bullet(content):
return {
"block_type": 12,
"bullet": {"elements": [{"text_run": {"content": content}}]}
}
def ordered(content):
return {
"block_type": 13,
"ordered": {"elements": [{"text_run": {"content": content}}]}
}
def code_block(content, language=1):
return {
"block_type": 14,
"code": {
"elements": [{"text_run": {"content": content}}],
"language": language
}
}
def divider():
return {"block_type": 22, "divider": {}}
def main():
print("\n" + "#" * 60)
print("# 重建飞书文档ai-proj 项目可见性手册")
print("#" * 60)
# Step 1: 生成示意图
print("\n--- Step 1: 生成示意图 ---")
diagram_path = generate_visibility_diagram()
ui_path = generate_ui_screenshot()
# Step 2: 清空文档
print("\n--- Step 2: 清空文档 ---")
clear_document(DOCUMENT_ID)
time.sleep(1) # 等待清空完成
# Step 3: 创建手册内容
print("\n--- Step 3: 创建手册内容 ---")
# 第一部分:标题和概述
blocks_part1 = [
heading1("ai-proj 项目可见性手册"),
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
divider(),
heading2("1. 功能概述"),
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
bullet("私有项目 (private):仅项目创建者和管理员可见"),
bullet("企业项目 (enterprise):企业内所有成员可见"),
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
divider(),
heading2("2. 可见性对比"),
text_block("下图展示了两种可见性的区别:"),
]
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part1)
print(" 第一部分内容创建完成")
# 插入可见性示意图
print("\n--- Step 4: 插入可见性示意图 ---")
insert_image(DOCUMENT_ID, diagram_path, "可见性示意图")
# 第二部分:设置方法
print("\n--- Step 5: 添加设置说明 ---")
blocks_part2 = [
divider(),
heading2("3. 设置项目可见性"),
heading3("3.1 创建项目时设置"),
text_block("在创建项目时,您可以选择项目的可见性:"),
ordered("点击「创建项目」按钮"),
ordered("在弹出的表单中找到「项目可见性」选项"),
ordered("选择「私有项目」或「企业项目」"),
ordered("填写其他信息后点击「确定」"),
text_block("UI 界面示意:"),
]
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part2)
print(" 设置说明创建完成")
# 插入 UI 示意图
print("\n--- Step 6: 插入 UI 示意图 ---")
insert_image(DOCUMENT_ID, ui_path, "UI 示意图")
# 第三部分:修改方法和规则
print("\n--- Step 7: 添加剩余内容 ---")
blocks_part3 = [
heading3("3.2 修改已有项目"),
ordered("在项目列表中找到目标项目"),
ordered("点击项目卡片上的「编辑」按钮"),
ordered("在编辑页面修改「项目可见性」"),
ordered("点击「保存」"),
divider(),
heading2("4. 可见性规则"),
bullet("私有项目:仅所有者和超级管理员可以查看"),
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
bullet("只有项目所有者或管理员可以修改可见性"),
bullet("设置为「企业项目」需要项目已关联企业"),
divider(),
heading2("5. API 参考"),
text_block("修改项目可见性的 API"),
code_block('''PATCH /api/v1/projects/:id/visibility
请求体:
{
"visibility": "enterprise"
}
可选值: "private" | "enterprise"'''),
divider(),
heading2("6. 注意事项"),
bullet("新项目默认为私有,请根据需要调整可见性"),
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
bullet("将企业项目改为私有后,其他成员将无法访问"),
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
divider(),
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
]
create_blocks(DOCUMENT_ID, DOCUMENT_ID, blocks_part3)
print("\n" + "=" * 60)
print("文档重建完成!")
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
print("=" * 60)
# 验证图片
print("\n--- 验证图片状态 ---")
blocks = get_document_blocks(DOCUMENT_ID)
image_count = 0
for block in blocks:
if block.get("block_type") == 27:
image_count += 1
image_data = block.get("image", {})
token = image_data.get("token", "")
print(f"图片 #{image_count}: token={'有效' if token else ''} ({token[:20] if token else 'N/A'}...)")
if image_count == 0:
print("警告:文档中没有图片块")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,530 @@
#!/usr/bin/env python3
"""
ai-proj 与飞书项目同步脚本
功能:
- 初始化:创建飞书根目录、多维表格
- 全量同步:同步所有项目到飞书
- 增量同步:只同步新增项目
- 更新同步:更新现有项目的任务统计等信息
使用方法:
python aiproj_sync.py init # 首次初始化
python aiproj_sync.py sync # 增量同步(推荐日常使用)
python aiproj_sync.py sync-all # 全量同步
python aiproj_sync.py update # 更新统计信息
"""
import os
import sys
import json
import requests
from datetime import datetime
from typing import Optional, Dict, List, Any
# ============================================
# 配置
# ============================================
FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "cli_a9f29dca82b9dbef")
FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho")
# 默认协作者 open_id邱栋梁@智云采购)
# 应用创建的文件夹所有者是应用本身,需要显式添加用户为协作者
DEFAULT_COLLABORATOR_OPENID = "ou_43784ff7c819ac000095fb52a4c3d1c7"
AIPROJ_API_BASE = "https://ai.pipexerp.com/api/v1"
AIPROJ_TOKEN = os.getenv("AIPROJ_TOKEN", "aiproj_pk_b455c91607414c22a0f3d8f09785969f1aa2144f33f1336fbb12450ecebfdb64")
CONFIG_PATH = os.path.expanduser("~/.config/aiproj-feishu-sync.json")
# ============================================
# 飞书 API
# ============================================
class FeishuAPI:
def __init__(self):
self.token = None
def get_token(self) -> str:
"""获取 tenant_access_token"""
if self.token:
return self.token
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": FEISHU_APP_ID,
"app_secret": FEISHU_APP_SECRET
})
data = response.json()
if data.get("code") == 0:
self.token = data["tenant_access_token"]
return self.token
else:
raise Exception(f"获取飞书 token 失败: {data}")
def _headers(self, content_type: bool = False) -> Dict:
headers = {"Authorization": f"Bearer {self.get_token()}"}
if content_type:
headers["Content-Type"] = "application/json"
return headers
def get_root_folder(self) -> str:
"""获取云空间根文件夹"""
url = "https://open.feishu.cn/open-apis/drive/explorer/v2/root_folder/meta"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["token"]
raise Exception(f"获取根文件夹失败: {data}")
def create_folder(self, name: str, parent_token: str, add_collaborator: bool = True) -> str:
"""创建文件夹"""
url = "https://open.feishu.cn/open-apis/drive/v1/files/create_folder"
response = requests.post(url, headers=self._headers(True), json={
"name": name,
"folder_token": parent_token
})
data = response.json()
if data.get("code") == 0:
folder_token = data["data"]["token"]
# 自动添加协作者(应用创建的文件夹默认只有应用能访问)
if add_collaborator and DEFAULT_COLLABORATOR_OPENID:
self.add_collaborator(folder_token, "folder", DEFAULT_COLLABORATOR_OPENID)
return folder_token
raise Exception(f"创建文件夹失败: {data}")
def add_collaborator(self, file_token: str, file_type: str, user_open_id: str, perm: str = "full_access") -> bool:
"""添加协作者
应用通过 API 创建的文件/文件夹,所有者是应用本身。
组织内用户默认无法访问,需要显式添加为协作者。
Args:
file_token: 文件/文件夹 token
file_type: 类型 (folder, doc, sheet, bitable 等)
user_open_id: 用户 open_id
perm: 权限级别 (full_access, edit, view)
"""
url = f"https://open.feishu.cn/open-apis/drive/v1/permissions/{file_token}/members"
params = {"type": file_type, "need_notification": "false"}
payload = {
"member_type": "openid",
"member_id": user_open_id,
"perm": perm
}
response = requests.post(url, headers=self._headers(True), params=params, json=payload)
return response.json().get("code") == 0
def create_bitable(self, name: str, folder_token: str) -> Dict:
"""创建多维表格"""
url = "https://open.feishu.cn/open-apis/bitable/v1/apps"
response = requests.post(url, headers=self._headers(True), json={
"name": name,
"folder_token": folder_token
})
data = response.json()
if data.get("code") == 0:
return data["data"]["app"]
raise Exception(f"创建多维表格失败: {data}")
def get_bitable_tables(self, app_token: str) -> List[Dict]:
"""获取数据表列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["items"]
raise Exception(f"获取数据表失败: {data}")
def create_field(self, app_token: str, table_id: str, field: Dict) -> Optional[Dict]:
"""创建字段"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.post(url, headers=self._headers(True), json=field)
data = response.json()
if data.get("code") == 0:
return data["data"]["field"]
print(f" 字段创建失败: {field.get('field_name')} - {data.get('msg')}")
return None
def update_field(self, app_token: str, table_id: str, field_id: str, updates: Dict) -> bool:
"""更新字段"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields/{field_id}"
response = requests.put(url, headers=self._headers(True), json=updates)
return response.json().get("code") == 0
def get_fields(self, app_token: str, table_id: str) -> List[Dict]:
"""获取字段列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
response = requests.get(url, headers=self._headers())
data = response.json()
if data.get("code") == 0:
return data["data"]["items"]
return []
def add_record(self, app_token: str, table_id: str, fields: Dict) -> Optional[Dict]:
"""添加记录"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
response = requests.post(url, headers=self._headers(True), json={"fields": fields})
data = response.json()
if data.get("code") == 0:
return data["data"]["record"]
print(f" 记录添加失败: {data.get('msg')}")
return None
def get_records(self, app_token: str, table_id: str, filter_str: str = None) -> List[Dict]:
"""获取记录列表"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
params = {"page_size": 500}
if filter_str:
params["filter"] = filter_str
response = requests.get(url, headers=self._headers(), params=params)
data = response.json()
if data.get("code") == 0:
return data["data"].get("items", [])
return []
def update_record(self, app_token: str, table_id: str, record_id: str, fields: Dict) -> bool:
"""更新记录"""
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}"
response = requests.put(url, headers=self._headers(True), json={"fields": fields})
return response.json().get("code") == 0
# ============================================
# ai-proj API
# ============================================
class AIProjAPI:
@staticmethod
def get_projects() -> List[Dict]:
"""获取项目列表"""
url = f"{AIPROJ_API_BASE}/projects?page_size=100"
headers = {"Authorization": f"Bearer {AIPROJ_TOKEN}"}
response = requests.get(url, headers=headers)
data = response.json()
if data.get("success"):
return data["data"]["data"]
raise Exception(f"获取项目列表失败: {data}")
# ============================================
# 配置管理
# ============================================
def load_config() -> Optional[Dict]:
"""加载配置"""
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH) as f:
return json.load(f)
return None
def save_config(config: Dict):
"""保存配置"""
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
# ============================================
# 同步逻辑
# ============================================
def init():
"""初始化:创建飞书目录结构"""
print("=" * 50)
print("飞书 ai-proj 项目同步 - 初始化")
print("=" * 50)
if load_config():
print("\n已存在配置文件,是否重新初始化?(y/N)")
if input().lower() != 'y':
print("取消初始化")
return
feishu = FeishuAPI()
# 1. 获取根目录
print("\n[1/5] 获取云空间根目录...")
space_root = feishu.get_root_folder()
print(f" 根目录: {space_root}")
# 2. 创建 ai-proj 文件夹
print("\n[2/5] 创建 ai-proj 根文件夹...")
root_folder = feishu.create_folder("ai-proj", space_root)
print(f" 创建成功: {root_folder}")
# 3. 创建多维表格
print("\n[3/5] 创建项目目录多维表格...")
bitable = feishu.create_bitable("项目目录", root_folder)
app_token = bitable["app_token"]
print(f" 创建成功: {app_token}")
tables = feishu.get_bitable_tables(app_token)
table_id = tables[0]["table_id"]
# 4. 创建字段
print("\n[4/5] 创建数据表字段...")
# 先修改第一列名称
fields = feishu.get_fields(app_token, table_id)
if fields:
feishu.update_field(app_token, table_id, fields[0]["field_id"], {
"field_name": "项目名称",
"type": fields[0]["type"]
})
print(" 修改字段: 项目名称")
# 创建其他字段
field_definitions = [
{"field_name": "项目ID", "type": 2},
{"field_name": "项目编号", "type": 1},
{"field_name": "飞书文件夹", "type": 15},
{"field_name": "状态", "type": 3, "property": {"options": [
{"name": "active", "color": 0},
{"name": "on_hold", "color": 1},
{"name": "planning", "color": 2},
{"name": "completed", "color": 3},
{"name": "archived", "color": 4}
]}},
{"field_name": "优先级", "type": 3, "property": {"options": [
{"name": "high", "color": 0},
{"name": "medium", "color": 1},
{"name": "low", "color": 2}
]}},
{"field_name": "所属企业", "type": 1},
{"field_name": "任务总数", "type": 2},
{"field_name": "描述", "type": 1},
{"field_name": "创建时间", "type": 5},
{"field_name": "最后同步", "type": 5},
]
for field in field_definitions:
if feishu.create_field(app_token, table_id, field):
print(f" 创建字段: {field['field_name']}")
# 5. 保存配置
print("\n[5/5] 保存配置...")
config = {
"root_folder_token": root_folder,
"bitable_app_token": app_token,
"bitable_table_id": table_id,
"created_at": datetime.now().isoformat(),
"synced_projects": {}
}
save_config(config)
print("\n" + "=" * 50)
print("初始化完成!")
print("=" * 50)
print(f"\n根文件夹: https://feishu.cn/drive/folder/{root_folder}")
print(f"多维表格: https://feishu.cn/base/{app_token}")
print(f"\n配置文件: {CONFIG_PATH}")
print("\n运行 'python aiproj_sync.py sync' 同步项目")
def sync(full: bool = False):
"""同步项目"""
config = load_config()
if not config:
print("未找到配置,请先运行 init")
return
print("=" * 50)
print(f"飞书 ai-proj 项目同步 - {'全量' if full else '增量'}同步")
print("=" * 50)
feishu = FeishuAPI()
app_token = config["bitable_app_token"]
table_id = config["bitable_table_id"]
root_folder = config["root_folder_token"]
synced = config.get("synced_projects", {})
# 获取 ai-proj 项目
print("\n获取 ai-proj 项目列表...")
projects = AIProjAPI.get_projects()
print(f"{len(projects)} 个项目")
# 筛选需要同步的项目
if full:
to_sync = projects
else:
to_sync = [p for p in projects if str(p["id"]) not in synced]
if not to_sync:
print("\n没有新项目需要同步")
return
print(f"\n需要同步 {len(to_sync)} 个项目...")
success = 0
for project in to_sync:
project_id = str(project["id"])
project_name = project["name"]
folder_name = f"{project_name}_{project_id}"
print(f"\n 处理: {project_name} (ID: {project_id})")
# 创建文件夹(如果不存在)
folder_token = synced.get(project_id, {}).get("folder_token")
if not folder_token:
try:
folder_token = feishu.create_folder(folder_name, root_folder)
print(f" 创建文件夹: {folder_token}")
except Exception as e:
print(f" 文件夹创建失败: {e}")
folder_token = ""
folder_url = f"https://feishu.cn/drive/folder/{folder_token}" if folder_token else ""
# 添加/更新记录
record_fields = {
"项目名称": project_name,
"项目ID": int(project_id),
"项目编号": project.get("project_number", ""),
"飞书文件夹": {"link": folder_url, "text": folder_name} if folder_url else None,
"状态": project.get("status", "active"),
"优先级": project.get("priority", "medium"),
"所属企业": project.get("company_name", ""),
"任务总数": project.get("task_count", 0),
"描述": (project.get("description", "") or "")[:500],
"创建时间": int(datetime.fromisoformat(
project["created_at"].replace("Z", "+00:00")
).timestamp() * 1000),
"最后同步": int(datetime.now().timestamp() * 1000)
}
record_id = synced.get(project_id, {}).get("record_id")
if record_id and not full:
# 更新现有记录
if feishu.update_record(app_token, table_id, record_id, record_fields):
print(f" 更新记录成功")
success += 1
else:
# 添加新记录
record = feishu.add_record(app_token, table_id, record_fields)
if record:
record_id = record["record_id"]
print(f" 添加记录成功")
success += 1
# 更新同步记录
synced[project_id] = {
"folder_token": folder_token,
"folder_url": folder_url,
"record_id": record_id,
"synced_at": datetime.now().isoformat()
}
# 保存配置
config["synced_projects"] = synced
config["last_sync"] = datetime.now().isoformat()
save_config(config)
print("\n" + "=" * 50)
print(f"同步完成: {success}/{len(to_sync)}")
print("=" * 50)
print(f"\n多维表格: https://feishu.cn/base/{app_token}")
def update_stats():
"""更新任务统计信息"""
config = load_config()
if not config:
print("未找到配置,请先运行 init")
return
print("=" * 50)
print("飞书 ai-proj 项目同步 - 更新统计")
print("=" * 50)
feishu = FeishuAPI()
app_token = config["bitable_app_token"]
table_id = config["bitable_table_id"]
synced = config.get("synced_projects", {})
# 获取最新项目数据
print("\n获取 ai-proj 项目列表...")
projects = AIProjAPI.get_projects()
project_map = {str(p["id"]): p for p in projects}
updated = 0
for project_id, sync_info in synced.items():
record_id = sync_info.get("record_id")
if not record_id:
continue
project = project_map.get(project_id)
if not project:
continue
# 只更新统计字段
update_fields = {
"任务总数": project.get("task_count", 0),
"状态": project.get("status", "active"),
"最后同步": int(datetime.now().timestamp() * 1000)
}
if feishu.update_record(app_token, table_id, record_id, update_fields):
print(f" 更新: {project['name']} - 任务数: {project.get('task_count', 0)}")
updated += 1
config["last_sync"] = datetime.now().isoformat()
save_config(config)
print(f"\n更新完成: {updated} 个项目")
def show_status():
"""显示同步状态"""
config = load_config()
if not config:
print("未初始化,请先运行: python aiproj_sync.py init")
return
print("=" * 50)
print("ai-proj 飞书同步状态")
print("=" * 50)
print(f"\n根文件夹: https://feishu.cn/drive/folder/{config['root_folder_token']}")
print(f"多维表格: https://feishu.cn/base/{config['bitable_app_token']}")
print(f"已同步项目: {len(config.get('synced_projects', {}))}")
print(f"上次同步: {config.get('last_sync', '从未')}")
print(f"配置文件: {CONFIG_PATH}")
# ============================================
# 主入口
# ============================================
def main():
if len(sys.argv) < 2:
print("用法: python aiproj_sync.py <command>")
print("\n命令:")
print(" init 首次初始化(创建目录和多维表格)")
print(" sync 增量同步(只同步新项目)")
print(" sync-all 全量同步(同步所有项目)")
print(" update 更新统计信息")
print(" status 查看同步状态")
return
command = sys.argv[1]
if command == "init":
init()
elif command == "sync":
sync(full=False)
elif command == "sync-all":
sync(full=True)
elif command == "update":
update_stats()
elif command == "status":
show_status()
else:
print(f"未知命令: {command}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,139 @@
---
name: feishu
description: 飞书文档与多维表格操作入口。当用户提到飞书、云文档、多维表格、Bitable 相关任务时自动激活。
---
# 飞书集成
## 功能模块
| 模块 | 技能 | 说明 |
|------|------|------|
| 云文档 | `feishu-docx` | 创建、编辑云文档,会议纪要 |
| 多维表格 | `feishu-bitable` | 记录增删改查,数据同步 |
| 任务 | 本技能 | 创建待办任务 |
## 环境配置
```bash
# ~/.zshrc凭证唯一配置位置
export FEISHU_APP_ID="cli_a9f29dca82b9dbef"
export FEISHU_APP_SECRET="<从飞书开放平台获取>"
```
**权限要求**
- 云文档:`docx:document`, `drive:drive`
- 多维表格:`bitable:app`
- 任务:`task:task:write`
## Access Token
```python
import os, requests
def get_tenant_access_token():
"""获取飞书 tenant_access_token"""
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": os.environ["FEISHU_APP_ID"],
"app_secret": os.environ["FEISHU_APP_SECRET"]
})
data = response.json()
if data.get("code") == 0:
return data["tenant_access_token"]
raise Exception(f"获取 token 失败: {data}")
```
## 默认存储位置
| 文件夹 | folder_token |
|--------|-------------|
| ai-proj 根目录 | `RTLKf247ClQQDyd5IjxcTOVQnxd` |
| 01运营 (默认) | `C80gfkRnzlonQ5d4AhOcOACDnNg` |
## URL 结构
```
云文档: https://xxx.feishu.cn/docx/DoxcXXXXXX
└── document_id
多维表格: https://xxx.feishu.cn/base/BascXXX?table=tblXXX&view=vewXXX
└── app_token └── table_id
```
## 飞书任务
```python
def create_task(summary: str, due_time: int = None):
"""创建飞书任务"""
url = "https://open.feishu.cn/open-apis/task/v2/tasks"
token = get_tenant_access_token()
payload = {"summary": summary}
if due_time:
payload["due"] = {"timestamp": str(due_time), "is_all_day": False}
response = requests.post(url,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=payload)
data = response.json()
if data.get("code") == 0:
return data["data"]["task"]
raise Exception(f"创建任务失败: {data}")
```
## 工具类
完整工具类见:
- `~/.claude/skills/feishu/feishu_bitable.py` - 多维表格
- `~/.claude/skills/feishu/feishu_docx.py` - 云文档
## Incoming Webhook群机器人通知卡片
**Webhook 地址**:存储在 `~/.config/devops/credentials.env``FEISHU_DEPLOY_WEBHOOK`
**⚠️ 关键注意事项**
-**禁用 schema 2.0**`"schema": "2.0"` 会返回 ErrCode 11246必须用 legacy 格式
-**legacy 卡片格式**(无 schema 字段)才能正常发送
-**按钮 URL 禁止指向列表页**:必须带具体资源 ID`/requirements/864`,不能是 `/requirements`
-**保存到文件再 curl**:包含中文的 JSON 直接用 `'...'` 传参会报 "blank argument" 错误
**正确的卡片发送示例**
```bash
cat > /tmp/feishu_card.json << 'EOF'
{
"msg_type": "interactive",
"card": {
"header": {
"title": {"tag": "plain_text", "content": "通知标题"},
"template": "blue"
},
"elements": [
{
"tag": "div",
"text": {"tag": "lark_md", "content": "**内容**:描述文字"}
},
{
"tag": "action",
"actions": [{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看详情"},
"type": "primary",
"url": "https://ai.pipexerp.com/requirements/864"
}]
}
]
}
}
EOF
curl -s -X POST \
"https://open.feishu.cn/open-apis/bot/v2/hook/xxx" \
-H "Content-Type: application/json" \
-d @/tmp/feishu_card.json
```
**header template 颜色**`blue`(待审批)/ `green`(通过)/ `red`(驳回)/ `wathet`(信息)
## 相关技能
- `feishu-docx` - 云文档详细操作
- `feishu-bitable` - 多维表格详细操作

View File

@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""
测试飞书云文档图片上传
"""
import requests
import os
from datetime import datetime, timedelta
from PIL import Image, ImageDraw
# ========== 配置 ==========
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
class FeishuDocx:
"""飞书云文档操作工具类"""
def __init__(self):
self._token = None
self._token_expires = None
@property
def token(self) -> str:
if self._token and self._token_expires and datetime.now() < self._token_expires:
return self._token
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": ZHIYUN_APP_ID,
"app_secret": ZHIYUN_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)
print(f"[OK] Token 获取成功")
return self._token
@property
def headers(self):
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
def set_document_permission(self, document_id: str, editable: bool = True) -> bool:
"""设置文档权限为组织内可编辑"""
url = f"{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)
result = response.json()
if result.get("code") == 0:
print(f"[OK] 权限设置成功: 组织内可编辑")
return True
else:
print(f"[WARN] 权限设置失败: {result.get('msg')}")
return False
def create_document(self, title: str, folder_token: str = None, editable: bool = True) -> dict:
"""创建云文档(自动设置为组织内可编辑)"""
url = f"{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"]
print(f"[OK] 文档创建成功: {doc['document_id']}")
# 自动设置权限
if editable:
self.set_document_permission(doc['document_id'], editable=True)
return doc
def create_empty_image_block(self, document_id: str, index: int = -1) -> str:
"""
创建空的图片块,返回 block_id
正确流程第一步:先创建空图片块
"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
# 创建空的图片块
image_block = {
"block_type": 27,
"image": {} # 空的图片块
}
payload = {"children": [image_block]}
if index >= 0:
payload["index"] = index
response = requests.post(url, headers=self.headers, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建图片块失败: {data}")
# 获取创建的图片块 ID
children = data["data"].get("children", [])
if not children:
raise Exception("创建图片块失败: 没有返回 children")
block_id = children[0].get("block_id")
print(f"[OK] 空图片块创建成功, block_id: {block_id}")
return block_id
def upload_image_to_block(self, file_path: str, block_id: str) -> str:
"""
上传图片到指定的图片块
正确流程第二步:将图片上传并绑定到已创建的图片块
"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
headers = {"Authorization": f"Bearer {self.token}"}
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
data = {
'file_name': file_name,
'parent_type': 'docx_image', # 云文档图片
'parent_node': block_id, # 关键: 使用图片块的 block_id
'size': str(file_size)
}
print(f"[INFO] 上传图片到 block_id={block_id}")
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] 上传响应: code={result.get('code')}, msg={result.get('msg')}")
if result.get("code") != 0:
raise Exception(f"上传图片失败: {result}")
file_token = result["data"]["file_token"]
print(f"[OK] 图片上传成功, file_token: {file_token}")
return file_token
def _upload_media(self, file_path: str, parent_type: str, parent_node: str = '') -> str:
"""使用 media API 上传"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
headers = {"Authorization": f"Bearer {self.token}"}
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
data = {
'file_name': file_name,
'parent_type': parent_type,
'parent_node': parent_node,
'size': str(file_size)
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] media 响应 ({parent_type}): code={result.get('code')}, msg={result.get('msg')}")
if result.get("code") == 0:
return result["data"]["file_token"]
return None
def _upload_to_drive(self, file_path: str) -> str:
"""上传到云空间根目录,然后获取 file_token"""
# 先获取根文件夹 token
url = f"{BASE_URL}/drive/explorer/v2/root_folder/meta"
headers = {"Authorization": f"Bearer {self.token}"}
response = requests.get(url, headers=headers)
result = response.json()
print(f"[DEBUG] 根目录响应: {result}")
if result.get("code") != 0:
return None
root_token = result["data"]["token"]
# 上传文件
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
upload_url = f"{BASE_URL}/drive/v1/medias/upload_all"
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
data = {
'file_name': file_name,
'parent_type': 'explorer',
'parent_node': root_token,
'size': str(file_size)
}
response = requests.post(upload_url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] drive 上传响应: code={result.get('code')}, msg={result.get('msg')}")
if result.get("code") == 0:
return result["data"]["file_token"]
return None
def _upload_im_image(self, file_path: str) -> str:
"""使用消息图片 API 上传"""
url = f"{BASE_URL}/im/v1/images"
headers = {"Authorization": f"Bearer {self.token}"}
with open(file_path, 'rb') as f:
files = {'image': f}
data = {'image_type': 'message'}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] im 图片响应: code={result.get('code')}, msg={result.get('msg')}")
if result.get("code") == 0:
return result["data"]["image_key"]
return None
def _try_upload_with_type(self, file_path: str, parent_type: str) -> str:
"""尝试不同的 parent_type 上传"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
headers = {"Authorization": f"Bearer {self.token}"}
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
data = {
'file_name': file_name,
'parent_type': parent_type,
'parent_node': '',
'size': str(file_size)
}
print(f"[INFO] 尝试 parent_type={parent_type}")
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(f"[DEBUG] 响应: {result}")
if result.get("code") != 0:
raise Exception(f"上传失败 ({parent_type}): {result}")
return result["data"]["file_token"]
def bind_image_to_block(self, document_id: str, block_id: str, file_token: str) -> dict:
"""
绑定图片到图片块 (关键的第三步!)
使用 PATCH 请求和 replace_image 字段将图片绑定到已创建的图片块
"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
params = {"document_revision_id": -1}
payload = {
"replace_image": {
"token": file_token
}
}
print(f"[INFO] 绑定图片到块: block_id={block_id}")
response = requests.patch(url, headers=self.headers, params=params, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"绑定图片失败: {data}")
print(f"[OK] 图片绑定成功")
return data["data"]
def generate_test_image(output_path: str):
"""生成测试图片"""
img = Image.new('RGB', (400, 300), color='#4a90d9')
draw = ImageDraw.Draw(img)
draw.rectangle([20, 20, 380, 280], fill='#f0f4f8', outline='#2563eb', width=2)
draw.text((200, 150), "Test Image", fill='#1e3a5f', anchor='mm')
draw.text((200, 200), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fill='#6b7280', anchor='mm')
img.save(output_path)
print(f"[OK] 测试图片生成: {output_path}")
return output_path
def main():
print("\n" + "#" * 60)
print("# 飞书云文档图片上传测试")
print("#" * 60)
docx = FeishuDocx()
# Step 1: 生成测试图片
print("\n" + "=" * 50)
print("Step 1: 生成测试图片")
print("=" * 50)
test_image = "/tmp/feishu_docx_test_image.png"
generate_test_image(test_image)
# Step 2: 创建测试文档
print("\n" + "=" * 50)
print("Step 2: 创建测试文档")
print("=" * 50)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
doc = docx.create_document(f"图片上传测试 - {timestamp}")
document_id = doc["document_id"]
# Step 3: 创建空的图片块
print("\n" + "=" * 50)
print("Step 3: 创建空的图片块")
print("=" * 50)
try:
block_id = docx.create_empty_image_block(document_id)
except Exception as e:
print(f"[ERROR] 创建图片块失败: {e}")
return
# Step 4: 上传图片
print("\n" + "=" * 50)
print("Step 4: 上传图片")
print("=" * 50)
try:
file_token = docx.upload_image_to_block(test_image, block_id)
except Exception as e:
print(f"[ERROR] 上传图片失败: {e}")
return
# Step 5: 绑定图片到图片块 (关键步骤!)
print("\n" + "=" * 50)
print("Step 5: 绑定图片到图片块")
print("=" * 50)
try:
docx.bind_image_to_block(document_id, block_id, file_token)
except Exception as e:
print(f"[ERROR] 绑定图片失败: {e}")
return
# 完成
print("\n" + "=" * 60)
print("测试完成!")
print("=" * 60)
print(f"\n文档地址:")
print(f" https://feishu.cn/docx/{document_id}")
print()
def check_permissions(docx):
"""检查应用权限"""
# 获取应用信息
url = f"{BASE_URL}/application/v6/applications/underauditlist"
headers = {"Authorization": f"Bearer {docx.token}"}
response = requests.get(url, headers=headers)
print(f"[DEBUG] 权限检查响应: {response.json()}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
更新飞书文档ai-proj 项目可见性手册
"""
import requests
import os
from datetime import datetime, timedelta
from PIL import Image, ImageDraw, ImageFont
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# 目标文档
DOCUMENT_ID = "Eqt2dpcpToVDxExFmhicqFa9nJf"
_token = None
_token_expires = None
def get_token():
global _token, _token_expires
if _token and _token_expires and datetime.now() < _token_expires:
return _token
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": ZHIYUN_APP_ID,
"app_secret": ZHIYUN_APP_SECRET
})
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取 token 失败: {data}")
_token = data["tenant_access_token"]
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
return _token
def headers():
return {
"Authorization": f"Bearer {get_token()}",
"Content-Type": "application/json"
}
def get_document_blocks(document_id: str):
"""获取文档所有块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks"
response = requests.get(url, headers=headers())
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取块失败: {data}")
return data["data"].get("items", [])
def delete_block(document_id: str, block_id: str):
"""删除块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
response = requests.delete(url, headers=headers(), params={"document_revision_id": -1})
return response.json().get("code") == 0
def create_blocks(document_id: str, parent_id: str, blocks: list):
"""创建内容块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
payload = {"children": blocks}
response = requests.post(url, headers=headers(), json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建块失败: {data}")
return data["data"].get("children", [])
def create_image_block(document_id: str, parent_id: str):
"""创建空图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{parent_id}/children"
payload = {
"children": [{
"block_type": 27,
"image": {}
}]
}
response = requests.post(url, headers=headers(), json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建图片块失败: {data}")
return data["data"]["children"][0]["block_id"]
def upload_image(file_path: str, block_id: str):
"""上传图片"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
with open(file_path, 'rb') as f:
files = {'file': (file_name, f, 'image/png')}
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 {get_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(document_id: str, block_id: str, file_token: str):
"""绑定图片到图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
payload = {"replace_image": {"token": file_token}}
response = requests.patch(url, headers=headers(), params={"document_revision_id": -1}, json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"绑定失败: {data}")
return True
def generate_visibility_diagram():
"""生成可见性示意图"""
output_path = "/tmp/visibility_diagram.png"
img = Image.new('RGB', (800, 400), color='#ffffff')
draw = ImageDraw.Draw(img)
# 标题
draw.rectangle([0, 0, 800, 50], fill='#1890ff')
draw.text((400, 25), "项目可见性示意图", fill='white', anchor='mm')
# 私有项目
draw.rectangle([50, 80, 370, 350], fill='#fff7e6', outline='#fa8c16', width=2)
draw.text((210, 100), "🔒 私有项目 (private)", fill='#fa8c16', anchor='mm')
draw.text((210, 140), "仅创建者可见", fill='#666666', anchor='mm')
# 私有项目内的人员图标
draw.ellipse([180, 180, 240, 240], fill='#fa8c16')
draw.text((210, 210), "👤", fill='white', anchor='mm')
draw.text((210, 270), "项目所有者", fill='#333333', anchor='mm')
draw.text((210, 300), "管理员", fill='#999999', anchor='mm')
# 企业项目
draw.rectangle([430, 80, 750, 350], fill='#e6f7ff', outline='#1890ff', width=2)
draw.text((590, 100), "👥 企业项目 (enterprise)", fill='#1890ff', anchor='mm')
draw.text((590, 140), "企业内所有成员可见", fill='#666666', anchor='mm')
# 企业项目内的人员图标组
positions = [(520, 190), (590, 190), (660, 190), (555, 250), (625, 250)]
for x, y in positions:
draw.ellipse([x-20, y-20, x+20, y+20], fill='#1890ff')
draw.text((x, y), "👤", fill='white', anchor='mm')
draw.text((590, 300), "企业所有成员", fill='#333333', anchor='mm')
draw.text((590, 330), "管理员", fill='#999999', anchor='mm')
# 箭头
draw.text((400, 200), "", fill='#333333', anchor='mm')
img.save(output_path)
print(f"[OK] 生成可见性示意图: {output_path}")
return output_path
def generate_ui_screenshot():
"""生成 UI 示意图"""
output_path = "/tmp/visibility_ui.png"
img = Image.new('RGB', (600, 200), color='#fafafa')
draw = ImageDraw.Draw(img)
# 标题
draw.text((20, 20), "项目可见性选择", fill='#333333')
# Radio Group 模拟
# 私有项目选项
draw.rectangle([20, 60, 280, 100], fill='#fff7e6', outline='#fa8c16', width=2)
draw.ellipse([30, 70, 50, 90], fill='#fa8c16')
draw.text((60, 80), "🔒 私有项目", fill='#fa8c16', anchor='lm')
# 企业项目选项
draw.rectangle([300, 60, 560, 100], fill='#ffffff', outline='#d9d9d9', width=1)
draw.ellipse([310, 70, 330, 90], outline='#d9d9d9', width=1)
draw.text((340, 80), "👥 企业项目", fill='#666666', anchor='lm')
# 说明文字
draw.text((20, 130), "私有项目仅创建者可见,企业项目对企业内所有成员可见", fill='#999999')
# 默认标签
draw.rectangle([200, 65, 270, 85], fill='#52c41a')
draw.text((235, 75), "默认", fill='white', anchor='mm')
img.save(output_path)
print(f"[OK] 生成 UI 示意图: {output_path}")
return output_path
# 文档内容块定义
def heading1(text):
return {
"block_type": 3,
"heading1": {"elements": [{"text_run": {"content": text}}]}
}
def heading2(text):
return {
"block_type": 4,
"heading2": {"elements": [{"text_run": {"content": text}}]}
}
def heading3(text):
return {
"block_type": 5,
"heading3": {"elements": [{"text_run": {"content": text}}]}
}
def text_block(content):
return {
"block_type": 2,
"text": {"elements": [{"text_run": {"content": content}}]}
}
def bullet(content):
return {
"block_type": 12,
"bullet": {"elements": [{"text_run": {"content": content}}]}
}
def ordered(content):
return {
"block_type": 13,
"ordered": {"elements": [{"text_run": {"content": content}}]}
}
def code_block(content, language="json"):
return {
"block_type": 14,
"code": {
"elements": [{"text_run": {"content": content}}],
"language": 1 if language == "json" else 0
}
}
def divider():
return {"block_type": 22, "divider": {}}
def main():
print("\n" + "#" * 60)
print("# 更新飞书文档ai-proj 项目可见性手册")
print("#" * 60)
# Step 1: 生成示意图
print("\n--- Step 1: 生成示意图 ---")
diagram_path = generate_visibility_diagram()
ui_path = generate_ui_screenshot()
# Step 2: 直接追加内容(不删除现有内容)
print("\n--- Step 2: 追加手册内容 ---")
print("\n--- Step 3: 创建手册内容 ---")
# 定义手册内容
manual_blocks = [
heading1("ai-proj 项目可见性手册"),
text_block("本手册介绍 ai-proj 系统中项目可见性功能的使用方法。"),
divider(),
heading2("1. 功能概述"),
text_block("项目可见性用于控制谁可以查看和访问您的项目。ai-proj 支持两种可见性级别:"),
bullet("私有项目 (private):仅项目创建者和管理员可见"),
bullet("企业项目 (enterprise):企业内所有成员可见"),
text_block("新创建的项目默认为「私有项目」,保护您的隐私。"),
divider(),
heading2("2. 可见性对比"),
]
# 创建第一批内容块
created = create_blocks(DOCUMENT_ID, DOCUMENT_ID, manual_blocks)
print(f" 创建了 {len(created)} 个内容块")
# Step 4: 插入可见性示意图
print("\n--- Step 4: 插入可见性示意图 ---")
img_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
print(f" 图片块: {img_block_id}")
file_token = upload_image(diagram_path, img_block_id)
print(f" file_token: {file_token}")
bind_image(DOCUMENT_ID, img_block_id, file_token)
print(" 图片绑定成功!")
# Step 5: 继续添加内容
print("\n--- Step 5: 添加更多内容 ---")
more_blocks = [
divider(),
heading2("3. 设置项目可见性"),
heading3("3.1 创建项目时设置"),
ordered("点击「创建项目」按钮"),
ordered("在弹出的表单中找到「项目可见性」选项"),
ordered("选择「私有项目」或「企业项目」"),
ordered("填写其他信息后点击「确定」"),
]
create_blocks(DOCUMENT_ID, DOCUMENT_ID, more_blocks)
# Step 6: 插入 UI 示意图
print("\n--- Step 6: 插入 UI 示意图 ---")
ui_block_id = create_image_block(DOCUMENT_ID, DOCUMENT_ID)
ui_file_token = upload_image(ui_path, ui_block_id)
bind_image(DOCUMENT_ID, ui_block_id, ui_file_token)
print(" UI 示意图上传成功!")
# Step 7: 添加剩余内容
print("\n--- Step 7: 添加剩余内容 ---")
final_blocks = [
heading3("3.2 修改已有项目"),
ordered("在项目列表中找到目标项目"),
ordered("点击项目卡片上的「编辑」按钮"),
ordered("在编辑页面修改「项目可见性」"),
ordered("点击「保存」"),
divider(),
heading2("4. 可见性规则"),
bullet("私有项目:仅所有者和超级管理员可以查看"),
bullet("企业项目:需要项目已关联企业,企业内所有成员可以查看"),
bullet("只有项目所有者或管理员可以修改可见性"),
bullet("设置为「企业项目」需要项目已关联企业"),
divider(),
heading2("5. API 参考"),
text_block("修改项目可见性的 API"),
code_block('''PATCH /api/v1/projects/:id/visibility
请求体:
{
"visibility": "enterprise"
}
可选值: "private" | "enterprise"'''),
divider(),
heading2("6. 注意事项"),
bullet("新项目默认为私有,请根据需要调整可见性"),
bullet("将私有项目改为企业项目后,企业所有成员都能看到"),
bullet("将企业项目改为私有后,其他成员将无法访问"),
bullet("项目成员权限不受可见性影响,被添加为成员的用户始终可以访问"),
divider(),
text_block(f"最后更新:{datetime.now().strftime('%Y-%m-%d %H:%M')}"),
]
create_blocks(DOCUMENT_ID, DOCUMENT_ID, final_blocks)
print("\n" + "=" * 60)
print("文档更新完成!")
print(f"\n文档地址: https://zhiyuncai.feishu.cn/docx/{DOCUMENT_ID}")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
上传用户指定的图片到飞书云文档
"""
import requests
import os
from datetime import datetime, timedelta
ZHIYUN_APP_ID = "cli_a9f29dca82b9dbef"
ZHIYUN_APP_SECRET = "sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho"
BASE_URL = "https://open.feishu.cn/open-apis"
# 用户指定的图片
IMAGE_PATH = "/Users/donglinlai/Downloads/u274.png"
_token = None
_token_expires = None
def get_token():
global _token, _token_expires
if _token and _token_expires and datetime.now() < _token_expires:
return _token
url = f"{BASE_URL}/auth/v3/tenant_access_token/internal"
response = requests.post(url, json={
"app_id": ZHIYUN_APP_ID,
"app_secret": ZHIYUN_APP_SECRET
})
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取 token 失败: {data}")
_token = data["tenant_access_token"]
_token_expires = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 60)
return _token
def headers():
return {
"Authorization": f"Bearer {get_token()}",
"Content-Type": "application/json"
}
def set_document_permission(document_id: str, editable: bool = True):
"""
设置文档权限
Args:
document_id: 文档ID
editable: True=组织内可编辑, False=组织内只读
"""
url = f"{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=headers(), params={"type": "docx"}, json=payload)
data = response.json()
if data.get("code") == 0:
print(f" 权限设置成功: {'组织内可编辑' if editable else '组织内只读'}")
return True
else:
print(f" [WARN] 权限设置失败: {data.get('msg')}")
return False
def create_document(title: str, editable: bool = True):
"""创建文档并设置权限"""
url = f"{BASE_URL}/docx/v1/documents"
response = requests.post(url, headers=headers(), json={"title": title})
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建文档失败: {data}")
doc = data["data"]["document"]
document_id = doc["document_id"]
# 自动设置权限
if editable:
set_document_permission(document_id, editable=True)
return document_id
def create_image_block(document_id: str):
"""创建空图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{document_id}/children"
payload = {
"children": [{
"block_type": 27,
"image": {}
}]
}
response = requests.post(url, headers=headers(), json=payload)
data = response.json()
if data.get("code") != 0:
raise Exception(f"创建图片块失败: {data}")
return data["data"]["children"][0]["block_id"]
def upload_image(file_path: str, block_id: str):
"""上传图片"""
url = f"{BASE_URL}/drive/v1/medias/upload_all"
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
# 根据扩展名设置 MIME 类型
ext = file_name.lower().split('.')[-1]
mime_types = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp'
}
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 {get_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(document_id: str, block_id: str, file_token: str):
"""绑定图片到图片块"""
url = f"{BASE_URL}/docx/v1/documents/{document_id}/blocks/{block_id}"
payload = {
"replace_image": {
"token": file_token
}
}
response = requests.patch(
url,
headers=headers(),
params={"document_revision_id": -1},
json=payload
)
data = response.json()
if data.get("code") != 0:
raise Exception(f"绑定失败: {data}")
return True
def main():
print(f"\n上传图片: {IMAGE_PATH}")
print("=" * 60)
# 检查文件
if not os.path.exists(IMAGE_PATH):
print(f"[ERROR] 文件不存在: {IMAGE_PATH}")
return
file_size = os.path.getsize(IMAGE_PATH)
print(f"文件大小: {file_size / 1024:.1f} KB")
# Step 1: 创建文档
print("\n[1/4] 创建飞书文档...")
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
doc_id = create_document(f"火把图片 - {timestamp}")
print(f" 文档ID: {doc_id}")
# Step 2: 创建图片块
print("[2/4] 创建图片块...")
block_id = create_image_block(doc_id)
print(f" 块ID: {block_id}")
# Step 3: 上传图片
print("[3/4] 上传图片...")
file_token = upload_image(IMAGE_PATH, block_id)
print(f" file_token: {file_token}")
# Step 4: 绑定图片
print("[4/4] 绑定图片...")
bind_image(doc_id, block_id, file_token)
print(" 绑定成功!")
print("\n" + "=" * 60)
print("上传完成!")
print(f"\n文档地址: https://feishu.cn/docx/{doc_id}")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,10 @@
{
"吴薇儿": {
"email": "wuweier@zhiyuncai.com",
"open_id": "ou_1d5cdfee78cbe6f8acc0751fff00ed09"
},
"宋佳香": {
"email": "songjiaxiang@zhiyuncai.com",
"open_id": "e6e72eb8"
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "siyuan-plugin",
"description": "Plugin for siyuan",
"version": "1.0.0",
"author": {
"name": "qiudl"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
{
"name": "siyuan-to-feishu-plugin",
"description": "将思源笔记导出为 PDF 并发送到飞书群。当用户提到发送笔记、导出PDF发飞书、/siyuan send、分享到飞书相关任务时自动激活。",
"version": "1.0.0",
"author": {
"name": "qiudl"
}
}

View File

@@ -0,0 +1,81 @@
---
name: siyuan-to-feishu
description: 将思源笔记导出为 PDF 并发送到飞书群。当用户提到"发送笔记"、"导出PDF发飞书"、"/siyuan send"、"分享到飞书"相关任务时自动激活。
---
# siyuan-to-feishu Skill
将思源笔记导出为 PDF 文件,并通过飞书文件消息发送到指定群组。
## 触发条件
- 用户说"把思源笔记发到飞书"
- 用户说"/siyuan send <doc-id>"
- 用户说"导出笔记 PDF 并分享"
- 用户提到"发送笔记到龙虾群"
## 使用方式
```bash
# 基础用法(发送到默认群:龙虾大神群)
~/.claude/skills/siyuan-to-feishu/send.sh <doc-id>
# 指定目标群
~/.claude/skills/siyuan-to-feishu/send.sh <doc-id> --group <chat-id>
# 指定 PDF 文件名
~/.claude/skills/siyuan-to-feishu/send.sh <doc-id> --name "技术文档.pdf"
# 示例
~/.claude/skills/siyuan-to-feishu/send.sh 20260312053926-w0m0fnc
~/.claude/skills/siyuan-to-feishu/send.sh 20260312053926-w0m0fnc --group oc_06889f55d62add6c484a8caea38d8e6c
~/.claude/skills/siyuan-to-feishu/send.sh 20260312053926-w0m0fnc --name "会议纪要.pdf"
```
## 执行流程
```
/siyuan send <doc-id>
├─── 1. 思源 Export API ──► 获取 HTML 内容
│ (siyuan.pipexerp.com)
├─── 2. wkhtmltopdf/Python ► 渲染为 PDF 文件
│ (/tmp/siyuan-export-<doc-id>.pdf)
├─── 3. 飞书 Files API ─────► 上传获取 file_key
│ (open.feishu.cn)
└─── 4. 飞书 IM API ─────────► 群消息 ✅
(open.feishu.cn)
```
## 配置参数
| 参数 | 值 |
|------|-----|
| 思源服务地址 | `https://siyuan.pipexerp.com` |
| 思源 API Token | `mkea1080c0x0jxqy` |
| 飞书 App ID | `cli_a9f29dca82b9dbef` |
| 飞书 App Secret | `sDfhjG7QT1S4gfHiMVYSygmPQPN1R2Ho` |
| 默认飞书群 | `oc_06889f55d62add6c484a8caea38d8e6c` (龙虾大神群) |
## 执行时机
当用户要执行发送操作时,直接调用:
```bash
bash ~/.claude/skills/siyuan-to-feishu/send.sh <doc-id> [--group <chat-id>] [--name <filename>]
```
脚本会自动打印进度并返回结果。
## 错误处理
| 错误场景 | 提示信息 |
|----------|----------|
| doc-id 不存在 | "❌ 笔记不存在,请检查 ID: <doc-id>" |
| PDF 超过 20MB | "❌ 文件过大 (>20MB),请拆分笔记后重试" |
| 飞书 API 失败 | "❌ 飞书发送失败 (code: <错误码>),已重试 3 次" |
| 思源服务不可达 | "❌ 思源服务连接失败,请检查 siyuan.pipexerp.com" |
| wkhtmltopdf 未安装 | 自动降级为 Python weasyprint 或提示安装 |

View File

@@ -0,0 +1,8 @@
{
"name": "wecom-plugin",
"description": "企业微信集成。通过自然语言发送消息、管理群机器人、操作审批流程、管理通讯录。当用户提到企业微信、微信工作、群机器人、企业号、wecom相关任务时自动激活。",
"version": "1.0.0",
"author": {
"name": "qiudl"
}
}

View File

@@ -0,0 +1,808 @@
---
name: wecom
description: 企业微信集成。通过自然语言发送消息、管理群机器人、操作审批流程、管理通讯录。当用户提到企业微信、微信工作、群机器人、企业号、wecom相关任务时自动激活。
---
# 企业微信集成技能
## 功能概述
### 消息推送
- **应用消息**: 向指定用户/部门发送文本、图片、文件等消息
- **群机器人**: 通过 Webhook 向群聊发送消息
- **模板消息**: 发送结构化的卡片消息
### 通讯录管理
- **部门管理**: 查询、创建、更新部门
- **成员管理**: 查询、创建、更新成员信息
### 审批流程
- **发起审批**: 通过 API 发起审批申请
- **审批状态**: 查询审批单状态
---
## 环境配置
### 已配置的企业微信应用
| 配置项 | 值 |
|--------|-----|
| 企业ID (CorpID) | `ww8ab927306fa235d2` |
| 应用ID (AgentId) | `1000003` |
| 可信域名 | `wecom.pipexerp.com` |
### 环境变量
```bash
# ~/.zshrc 已配置
export WECOM_CORP_ID="ww8ab927306fa235d2"
export WECOM_AGENT_ID="1000003"
export WECOM_SECRET="Dts8BmENzjxCRK1Qn4qmkO6mU81FLVEkhI2LitcBcjI"
```
---
## API 基础
### 获取 Access Token
```python
import os
import requests
CORP_ID = os.environ.get("WECOM_CORP_ID")
CORP_SECRET = os.environ.get("WECOM_SECRET")
AGENT_ID = os.environ.get("WECOM_AGENT_ID")
def get_access_token():
"""获取企业微信 access_token有效期 7200 秒)"""
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": CORP_ID,
"corpsecret": CORP_SECRET
}
resp = requests.get(url, params=params)
result = resp.json()
if result.get("errcode") == 0:
return result["access_token"]
else:
raise Exception(f"获取 token 失败: {result}")
```
---
## 消息推送
### 发送文本消息
```python
def send_text(user_id: str, content: str):
"""发送文本消息给指定用户"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
data = {
"touser": user_id, # 用 "@all" 发送给所有人
"msgtype": "text",
"agentid": int(AGENT_ID),
"text": {"content": content}
}
resp = requests.post(url, json=data)
return resp.json()
# 使用示例
send_text("@all", "这是一条测试消息")
```
### 发送 Markdown 消息
```python
def send_markdown(user_id: str, content: str):
"""发送 Markdown 格式消息"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
data = {
"touser": user_id,
"msgtype": "markdown",
"agentid": int(AGENT_ID),
"markdown": {"content": content}
}
resp = requests.post(url, json=data)
return resp.json()
```
### 发送卡片消息
```python
def send_card(user_id: str, title: str, description: str, url: str):
"""发送文本卡片消息"""
token = get_access_token()
api_url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
data = {
"touser": user_id,
"msgtype": "textcard",
"agentid": int(AGENT_ID),
"textcard": {
"title": title,
"description": description,
"url": url,
"btntxt": "详情"
}
}
resp = requests.post(api_url, json=data)
return resp.json()
```
---
## 群机器人 Webhook
```python
def send_to_group(webhook_url: str, content: str, mentioned_list: list = None):
"""通过 Webhook 发送群消息"""
data = {
"msgtype": "text",
"text": {
"content": content,
"mentioned_list": mentioned_list or []
}
}
resp = requests.post(webhook_url, json=data)
return resp.json()
# 使用示例
WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx"
send_to_group(WEBHOOK_URL, "自动化任务完成通知", ["@all"])
```
---
## 通讯录管理
### 获取部门列表
```python
def get_departments():
"""获取部门列表"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token={token}"
resp = requests.get(url)
return resp.json()
```
### 获取部门成员
```python
def get_department_users(department_id: int):
"""获取部门成员列表"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/simplelist"
params = {"access_token": token, "department_id": department_id}
resp = requests.get(url, params=params)
return resp.json()
```
---
## 云文档操作
### 样式规范
企业微信云文档支持两种样式方案,根据文档类型选择:
| 文档类型 | 推荐方案 | 说明 |
|----------|----------|------|
| 合同、协议 | 带标题样式 | 使用 `create_contract_doc()` |
| 报告、记录 | 简单文本 | 使用 `create_simple_doc()` |
| 会议纪要 | 简单文本 | 使用 `create_simple_doc()` |
#### 简单文本样式符号规范
```
文档标题 ← 由文档名称体现
━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ← 分隔线
【章节标题】 ← 用【】标记一级章节
内容正文...
【子章节】 ← 同样用【】
1.1 条款内容
1.2 条款内容
```
#### 带标题样式(合同类)
- heading_level=1: 文档主标题(如"物流服务合同"
- heading_level=2: 章节标题(如"第一条"、"甲方"
- heading_level=0: 正文内容
---
### 创建文档
```python
def create_doc(doc_name: str, doc_type: int = 3):
"""
创建企业微信文档
Args:
doc_name: 文档名称
doc_type: 文档类型 (3=文档, 4=表格)
Returns:
dict: {"docid": "xxx", "url": "https://doc.weixin.qq.com/..."}
"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/create_doc?access_token={token}"
resp = requests.post(url, json={
"doc_type": doc_type,
"doc_name": doc_name
})
return resp.json()
# 使用示例
result = create_doc("项目文档")
print(f"文档链接: {result['url']}")
```
### 获取文档内容
```python
def get_doc_content(docid: str):
"""获取文档内容"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/get?access_token={token}"
resp = requests.post(url, json={"docid": docid})
return resp.json()
```
### 编辑文档内容
```python
def update_doc(docid: str, text: str, index: int = 0):
"""
更新文档内容
Args:
docid: 文档ID
text: 要插入的文本内容
index: 插入位置 (0=开头)
"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}"
resp = requests.post(url, json={
"docid": docid,
"requests": [
{
"insert_text": {
"text": text,
"location": {"index": index}
}
}
]
})
return resp.json()
# 使用示例
update_doc("DOCID", "# 标题\n\n这是正文内容")
```
### 获取文档列表
```python
def list_docs(limit: int = 20):
"""获取文档列表"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/doc_list?access_token={token}"
resp = requests.post(url, json={"limit": limit})
return resp.json()
```
### 上传图片到文档
```python
import base64
def upload_doc_image(docid: str, image_path: str):
"""
上传图片到文档(获取图片 URL
Args:
docid: 文档ID
image_path: 本地图片路径
Returns:
dict: {"url": "https://wdcdn.qpic.cn/...", "width": 1280, "height": 800}
"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/image_upload?access_token={token}"
with open(image_path, "rb") as f:
img_base64 = base64.b64encode(f.read()).decode()
resp = requests.post(url, json={
"docid": docid,
"base64_content": img_base64
})
return resp.json()
# 使用示例
result = upload_doc_image("DOCID", "/tmp/screenshot.png")
print(f"图片 URL: {result['url']}")
```
### 插入图片到文档
```python
def insert_image_to_doc(docid: str, image_url: str, index: int = 1):
"""
插入图片到文档
Args:
docid: 文档ID
image_url: 图片 URL从 upload_doc_image 获取)
index: 插入位置
注意:使用 insert_paragraph + elements + image 格式
"""
token = get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}"
resp = requests.post(url, json={
"docid": docid,
"requests": [
{
"insert_paragraph": {
"elements": [
{"image": {"url": image_url}}
],
"location": {"index": index}
}
}
]
})
return resp.json()
# 使用示例
insert_image_to_doc("DOCID", "https://wdcdn.qpic.cn/xxx", index=1)
```
### 获取文档末尾位置
```python
def get_doc_end_index(docid: str) -> int:
"""获取文档末尾索引(用于追加内容)"""
doc = get_doc_content(docid)
if doc.get("errcode") != 0:
return 1
body = doc.get("document", {}).get("body", {})
blocks = body.get("blocks", [])
end_index = 1
for block in blocks:
if "paragraph" in block:
for elem in block["paragraph"].get("elements", []):
if "text_run" in elem:
end_index = max(end_index, elem["text_run"].get("end_index", 1))
return end_index
```
### 完整示例:截图并插入文档
```python
import base64
from playwright.sync_api import sync_playwright
def screenshot_and_insert(url: str, docid: str, title: str = "网页截图"):
"""
截取网页并插入到文档
Args:
url: 要截取的网页 URL
docid: 目标文档 ID
title: 截图标题
"""
token = get_access_token()
# 1. 截取网页
print(f"截取网页: {url}")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(url, wait_until="networkidle")
page.screenshot(path="/tmp/screenshot.png")
browser.close()
# 2. 上传图片
print("上传图片...")
with open("/tmp/screenshot.png", "rb") as f:
img_base64 = base64.b64encode(f.read()).decode()
upload_resp = requests.post(
f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/image_upload?access_token={token}",
json={"docid": docid, "base64_content": img_base64}
).json()
img_url = upload_resp.get("url")
# 3. 获取文档末尾位置
end_index = get_doc_end_index(docid)
# 4. 插入标题
requests.post(
f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}",
json={
"docid": docid,
"requests": [
{"insert_text": {"text": f"\n\n{title}\n\n", "location": {"index": end_index}}}
]
}
)
# 5. 插入图片
new_end = get_doc_end_index(docid)
insert_resp = requests.post(
f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}",
json={
"docid": docid,
"requests": [
{
"insert_paragraph": {
"elements": [{"image": {"url": img_url}}],
"location": {"index": new_end}
}
}
]
}
).json()
if insert_resp.get("errcode") == 0:
print("✅ 截图已插入文档!")
else:
print(f"❌ 插入失败: {insert_resp}")
# 使用示例
screenshot_and_insert("https://www.baidu.com", "YOUR_DOCID", "百度首页截图")
```
---
## 通用函数封装
### 简单文本文档
适用于报告、记录、会议纪要等。使用符号标记章节。
```python
def create_simple_doc(doc_name: str, content: str) -> dict:
"""
创建简单文本文档
Args:
doc_name: 文档名称
content: 文档内容(使用【】标记章节)
Returns:
dict: {"docid": "xxx", "url": "https://..."}
Example:
content = '''项目周报
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【本周完成】
1. 完成用户模块开发
2. 修复登录bug
【下周计划】
1. 开始订单模块
2. 编写测试用例
'''
result = create_simple_doc("2026年第5周周报", content)
"""
token = get_access_token()
# 1. 创建文档
create_url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/create_doc?access_token={token}"
create_resp = requests.post(create_url, json={
"doc_type": 3,
"doc_name": doc_name
}).json()
if create_resp.get("errcode") != 0:
return create_resp
docid = create_resp["docid"]
doc_url = create_resp["url"]
# 2. 写入内容
update_url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}"
update_resp = requests.post(update_url, json={
"docid": docid,
"requests": [{
"insert_text": {
"text": content,
"location": {"index": 0}
}
}]
}).json()
return {
"errcode": update_resp.get("errcode", 0),
"docid": docid,
"url": doc_url
}
```
### 合同类文档(带标题样式)
适用于合同、协议等需要标题层级的正式文档。
```python
import time
def create_contract_doc(doc_name: str, sections: list) -> dict:
"""
创建带标题样式的合同文档
Args:
doc_name: 文档名称
sections: 内容列表,格式 [(文本, 标题级别), ...]
标题级别: 1=主标题, 2=章节标题, 0=正文
Returns:
dict: {"docid": "xxx", "url": "https://..."}
Example:
sections = [
("物流服务合同", 1),
("合同编号: WL-2026-0201", 0),
("", 0),
("甲方(托运方)", 2),
("公司名称: xxx公司", 0),
("", 0),
("第一条 服务内容", 2),
("1.1 服务类型:货物运输", 0),
("1.2 货物类型xxx", 0),
]
result = create_contract_doc("物流服务合同-甲方与乙方", sections)
"""
token = get_access_token()
# 1. 创建文档
create_url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/create_doc?access_token={token}"
create_resp = requests.post(create_url, json={
"doc_type": 3,
"doc_name": doc_name
}).json()
if create_resp.get("errcode") != 0:
return create_resp
docid = create_resp["docid"]
doc_url = create_resp["url"]
# 2. 倒序内容因为每次在位置0插入
reversed_sections = list(reversed(sections))
# 3. 分批插入每批最多25个留余量
update_url = f"https://qyapi.weixin.qq.com/cgi-bin/wedoc/document/batch_update?access_token={token}"
batch_size = 25
for i in range(0, len(reversed_sections), batch_size):
batch = reversed_sections[i:i+batch_size]
requests_list = []
for text, level in batch:
para = {"elements": [{"text_run": {"content": text}}]}
if level > 0:
para["paragraph_style"] = {"heading_level": level}
requests_list.append({
"insert_paragraph": {
"location": {"index": 0},
"paragraph": para
}
})
resp = requests.post(update_url, json={
"docid": docid,
"requests": requests_list
}).json()
if resp.get("errcode") != 0:
return {
"errcode": resp.get("errcode"),
"errmsg": resp.get("errmsg"),
"docid": docid,
"url": doc_url
}
time.sleep(0.3) # 避免频率限制
return {
"errcode": 0,
"docid": docid,
"url": doc_url
}
def build_contract_sections(
title: str,
party_a: dict,
party_b: dict,
clauses: list,
signature: bool = True
) -> list:
"""
构建合同内容结构
Args:
title: 合同标题
party_a: 甲方信息 {"name": "", "code": "", "address": "", "phone": "", "legal_rep": ""}
party_b: 乙方信息,格式同上
clauses: 条款列表 [{"title": "第一条 xxx", "items": ["1.1 xxx", "1.2 xxx"]}, ...]
signature: 是否包含签章栏
Returns:
list: 可直接传给 create_contract_doc 的 sections
"""
sections = []
# 标题
sections.append((title, 1))
sections.append(("", 0))
# 甲方
sections.append(("甲方", 2))
if party_a.get("name"):
sections.append((f"公司名称: {party_a['name']}", 0))
if party_a.get("code"):
sections.append((f"统一社会信用代码: {party_a['code']}", 0))
if party_a.get("address"):
sections.append((f"地址: {party_a['address']}", 0))
if party_a.get("phone"):
sections.append((f"联系电话: {party_a['phone']}", 0))
if party_a.get("legal_rep"):
sections.append((f"法定代表人: {party_a['legal_rep']}", 0))
sections.append(("", 0))
# 乙方
sections.append(("乙方", 2))
if party_b.get("name"):
sections.append((f"公司名称: {party_b['name']}", 0))
if party_b.get("code"):
sections.append((f"统一社会信用代码: {party_b['code']}", 0))
if party_b.get("address"):
sections.append((f"地址: {party_b['address']}", 0))
if party_b.get("phone"):
sections.append((f"联系电话: {party_b['phone']}", 0))
if party_b.get("legal_rep"):
sections.append((f"法定代表人: {party_b['legal_rep']}", 0))
sections.append(("", 0))
# 条款
for clause in clauses:
sections.append((clause["title"], 2))
for item in clause.get("items", []):
sections.append((item, 0))
sections.append(("", 0))
# 签章
if signature:
sections.append(("签章", 2))
sections.append(("", 0))
sections.append((f"甲方(盖章): {party_a.get('name', '')}", 0))
sections.append(("法定代表人/授权代表________________", 0))
sections.append(("日期________________", 0))
sections.append(("", 0))
sections.append((f"乙方(盖章): {party_b.get('name', '')}", 0))
sections.append(("法定代表人/授权代表________________", 0))
sections.append(("日期________________", 0))
return sections
```
### 使用示例
#### 简化示例
```python
# 简化示例:使用占位符
party_a = {"name": "甲方公司名称", "code": "甲方税号"}
party_b = {"name": "乙方公司名称", "code": "乙方税号"}
clauses = [
{"title": "第一条 服务内容", "items": ["1.1 xxx", "1.2 xxx"]},
{"title": "第二条 服务期限", "items": ["2.1 xxx"]},
]
sections = build_contract_sections("合同标题", party_a, party_b, clauses)
result = create_contract_doc("合同文档名称", sections)
```
#### 完整示例
```python
# 完整示例:创建物流合同
party_a = {
"name": "重庆妗晨工贸有限公司",
"code": "91500104MA7EJTPA6D",
"address": "重庆市大渡口区跳磴镇海康路106号1-1",
"phone": "15213397998"
}
party_b = {
"name": "北京名风新能源科技有限公司",
"code": "91110106092440790K",
"legal_rep": "魏小健"
}
clauses = [
{
"title": "第一条 服务内容",
"items": [
"1.1 服务类型:货物运输配送服务",
"1.2 货物类型:今麦郎系列产品",
]
},
{
"title": "第二条 服务期限",
"items": [
"2.1 合同有效期:自 2026年2月1日 至 2027年1月31日",
]
},
{
"title": "第三条 运费标准",
"items": [
"3.1 运费标准:今麦郎产品 2.50 元/件",
"3.2 结算周期:月结",
]
}
]
# 构建并创建文档
sections = build_contract_sections("物流服务合同", party_a, party_b, clauses)
result = create_contract_doc("物流服务合同-妗晨与名风", sections)
print(f"文档链接: {result['url']}")
```
---
## 常见错误码
| 错误码 | 说明 | 解决方案 |
|--------|------|----------|
| 40001 | access_token 无效 | 重新获取 token |
| 40058 | 请求参数错误 | 检查 API 参数格式 |
| 48002 | API 未授权 | 在应用设置中开启对应 API 权限 |
| 60011 | 用户不存在 | 检查 userid 是否正确 |
| 60020 | IP 不在白名单 | 在应用设置中添加可信 IP |
| 81013 | 缺少通讯录权限 | 在应用权限中开启通讯录读取权限 |
| 44001 | 文档不存在 | 检查 docid 是否正确 |
| 2050065 | 插入位置无效 | 使用 get_doc_end_index 获取正确位置 |
| 2400001 | 请求参数错误 | 检查 insert_paragraph 格式 |
| 93017 | JSON 格式错误 | 图片上传需要使用 base64_content |
---
## 相关资源
| 资源 | 链接 |
|------|------|
| API 文档 | https://developer.work.weixin.qq.com/document/ |
| 调试工具 | https://developer.work.weixin.qq.com/devtool/interface |
| 管理后台 | https://work.weixin.qq.com/wework_admin/ |