Compare commits

...

26 Commits

Author SHA1 Message Date
starmen1265 38baec8f04 fix(kb-workflow): 从 ~/.kb_env 加载环境变量,解决非交互式 shell 问题 2026-02-13 20:13:17 +08:00
starmen1265 6093a0a09d Update: KB workflow git integration + todo items
- Add git remote check and pull before archive
- Support main/master branch auto-fallback
- Add todo: SenseVoice test, git ops, session routing
- Memory: BV1x3ctzkEdf knowledge base workflow
2026-02-13 19:09:57 +08:00
starmen1265 3bd9cb7793 feat: 深度分析使用 g5 模型并由子代理执行 2026-02-13 18:42:57 +08:00
starmen1265 0ff280b0e8 fix: 保留 Markdown 格式不过滤 2026-02-13 18:38:51 +08:00
starmen1265 84df9931c1 refactor: 重构 deep_analyzer 一次性调用 AI 生成完整报告 2026-02-13 18:34:03 +08:00
starmen1265 4ce74541bf docs: 添加执行方式和环境变量配置说明 2026-02-13 17:24:18 +08:00
starmen1265 2d7d444803 fix(kb-deep-analyzer): 子线程环境变量传递
- 在 _analyze_chunks_concurrent 中使用 _worker_env 保存环境变量
- 添加 init_worker() 初始化函数
- ThreadPoolExecutor 使用 initializer=init_worker 传递环境变量
2026-02-13 15:54:42 +08:00
starmen1265 99164f7ce6 fix(kb-deep-analyzer): 添加进度日志和超时追踪
- SimpleAIClient.generate(): 添加 AI 调用开始/结束日志
- _analyze_chunk(): 添加分片分析耗时日志
- _analyze_chunks_concurrent(): 添加批次进度日志和总耗时统计
- 便于排查卡住问题和监控进度
2026-02-13 15:49:17 +08:00
starmen1265 c276df2d37 feat(kb-workflow): 添加严格失败判断逻辑
- kb-deep-analyzer: 新增判断逻辑,只有AI真正完成分析才算成功
- 失败条件: summary以'共'开头、topics为空、full_analysis为None
- kb-workflow: 明确深度分析失败则整个流程失败
- 禁止降级处理或使用模板'假装成功'
2026-02-13 15:23:03 +08:00
starmen1265 518f8e4805 fix(kb-deep-analyzer): 优化文档格式
- date 改为视频发布日期(pubtime)
- 添加 url 到 frontmatter
- 去掉建议行动章节
- 去掉 {% if timeline %} 和最后分析时间
- 简化模板结构
2026-02-13 14:57:07 +08:00
starmen1265 c8ddc1e83f feat(kb-deep-analyzer): 添加流式写入器 StreamingWriter
- 支持多线程并发写入到文档不同位置
- 初始化时创建带占位符的文档结构
- 每批完成后写入对应位置
- 支持更新摘要、主题、见解等全局内容
2026-02-13 14:47:43 +08:00
starmen1265 94370f225e feat(kb-deep-analyzer): 改为批处理模式
- 10段 = 3+3+3+1 批次处理
- 每批最多3个并发
- 添加 on_batch_complete 回调支持
- timeout 保持 600s
2026-02-13 14:46:09 +08:00
starmen1265 520e30d02e feat(kb-deep-analyzer): 优化超时和并发
- timeout 从 120s 改为 600s
- 添加 max_concurrent=3 并发支持
- 新增 _analyze_chunks_concurrent 方法支持流式输出
- analyze() 方法支持 on_segment_complete 回调
2026-02-13 14:43:40 +08:00
starmen1265 9e2220404f fix(kb-workflow): 环境变量检查改为必须报错退出
- 必须的环境变量:AI_API_KEY, AI_PROVIDER, AI_MODEL, AI_BASE_URL
- 如果未设置,报错并退出,不执行后续步骤
- 删除 KB_BASE_PATH(已改为从 ~/work/knowledge-base 获取)
2026-02-13 14:37:09 +08:00
starmen1265 bdc84ec75c fix(kb-deep-analyzer): 修复环境变量传递问题
- generate() 每次调用时重新读取环境变量
- AI 调用方法接受 api_key, model, base_url 参数
- 解决 exec 工具中环境变量未传递的问题
2026-02-13 13:58:46 +08:00
starmen1265 a03223ac80 feat(kb-workflow): 优化无字幕视频处理逻辑
- kb-bilibili-content: extract_subtitle 返回 success=True 即使没有字幕
- 添加 download_subtitle 方法,支持下载官方字幕并转为 SRT 格式
- kb-workflow: 更新工作流逻辑,没有字幕是正常的,继续执行 Whisper
- 添加条件分支:有字幕→深度分析,无字幕→Whisper
2026-02-13 13:36:42 +08:00
starmen1265 b02cd969b8 feat(kb-deep-analyzer): 增加超时时间和重试机制
- timeout 从 30s 增加到 120s
- 添加重试逻辑,最多重试 3 次
- 避免因网络波动导致的 token 浪费
2026-02-13 13:17:19 +08:00
starmen1265 3c422994b9 feat(kb-deep-analyzer): 实现每段前加概述功能
- 添加元数据(标题、描述、来源)到每段分析提示词
- 移除 mock 相关代码,使用真实 AI(zhipu/glm-4.7)
- 配置环境变量支持 AI_API_KEY、AI_PROVIDER、AI_MODEL、AI_BASE_URL
2026-02-13 13:00:22 +08:00
starmen1265 b7b40b1ece feat(deep_analyzer): 使用SRT简化时间格式 + 分块分析 + 纯文本AI
- 添加 load_srt() 支持 SRT 字幕格式
- 时间格式简化为 HH:MM:SS
- 超过3000字自动分块分析
- _merge_summaries 改用纯文本而非JSON
- 添加提取函数解析AI响应
2026-02-13 12:28:21 +08:00
starmen1265 ffed35db0c refactor(deep-analyzer): 简化模板和元数据格式
- 移除重复的 frontmatter 区块
- 简化模板,去掉"## 基本信息"和重复元数据
- frontmatter 添加 analyze_time、bvid、platform 字段
- 修复 argparse 默认值(provider/model 从固定值改为 None)
- 添加 normalize_metadata 调用
- 适配 glm-4.7 的 reasoning_content 模式
- 添加 _parse_json_from_text 解析函数
2026-02-13 12:14:13 +08:00
starmen1265 faaf79428b fix(deep-analyzer): 修复元数据解析和AI调用问题
- 支持 {"metadata": {...}} 格式的元数据
- 从环境变量读取 AI_PROVIDER 和 AI_MODEL
- 修复 glm-4.7 的 reasoning_content 处理
- 修复 _format_time 支持 float 类型
- 支持 Whisper 的 segments 和 text 字段
2026-02-13 11:50:08 +08:00
starmen1265 1fa90ac21c feat(bilibili): 使用 yt-dlp 替换原有下载方案
- 重写 download_video 使用 yt-dlp + Netscape cookie 文件
- 支持 BILIBILI_COOKIE 环境变量
- 更新默认质量为 720P
- 更新 SKILL.md 文档

环境变量已在 ~/.bashrc 中配置
2026-02-13 11:30:59 +08:00
starmen1265 0da1c0fb59 feat(kb-whisper): 新增说话者分离功能
- 新增 kb-whisper-diarization 技能
- 使用 pyannote.audio 实现说话者分离
- 支持区分多个说话者(如访谈节目)
- 输出带 [SPEAKER_00] 标签的字幕
2026-02-13 09:15:44 +08:00
starmen1265 c24889ffa5 docs: 更新待办和内存记录
- 添加 Whisper 编译任务
- 记录 KB Workflow 优化内容
2026-02-13 09:04:37 +08:00
starmen1265 29dd236bb9 feat(kb-workflow): 切换为 Whisper 语音识别
- 将步骤5从 OCR 改为 Whisper 语音识别
- 更新 kb-whisper 支持自动音频转换
- 默认使用中文语言 (zh) 和 base 模型
- 更新输出格式为 {platform}_{id}.txt/.srt/.json
- 添加 FunASR 作为备用方案
2026-02-13 09:04:27 +08:00
starmen1265 9102a973cd feat(kb-whisper): 新增本地语音识别技能,基于 Whisper.cpp 2026-02-13 01:20:27 +08:00
24 changed files with 5089 additions and 1077 deletions
+59 -10
View File
@@ -22,13 +22,62 @@
1. **使用技能过程中,遇到任何技能没有明确说明的问题,不要自己想办法绕过去,立刻停止,记录到SkillBug.md,并汇报问题。** 1. **使用技能过程中,遇到任何技能没有明确说明的问题,不要自己想办法绕过去,立刻停止,记录到SkillBug.md,并汇报问题。**
2. **安装第三方技能时,优先安装在workspace/skills中** 2. **安装第三方技能时,优先安装在workspace/skills中**
3. **技能有说明的内容,不要擅自做优化决定,如果子代理擅自优化了,打回重做。** 3. **技能有说明的内容,不要擅自做优化决定,如果子代理擅自优化了,打回重做。**
4. **子代理监督规则(2026-02-09)** 4. **子代理监督规则(2026-02-09)** - 详见上方
- 主代理必须监督子代理的执行过程
- 子代理开始工作后,定期检查执行日志 ## 第三方库修改规则(2026-02-13)
- 发现以下违规行为立即打回重做: - **严禁直接修改第三方库代码**(如 pyannote.audio, speechbrain 等)
- 未按技能要求执行步骤 - 如遇到兼容性问题:
- 跳过或省略必做步骤 1. 先记录到 SkillBug.md
- 编造内容(代码、数据、描述等) 2. 向用户汇报情况
- 引用不存在的文件或帧号 3. 寻求替代方案(如使用其他服务、等待官方修复)
- 擅自优化技能规定的流程 - 如果必须修改第三方库代码:
- 打回时说明违规原因,要求严格按照技能重新执行 1. **必须先向用户汇报**,获得批准
2. 记录修改内容到 SkillBug.md
3. 考虑使用 monkey patch 或环境变量等非侵入式方案
## 知识库工作流(2026-02-13)
- **deep_analyzer.py**:一次性 AI 调用生成完整报告
- **内容字段**:支持 `text`、`subtitle`、`subtitles`、`description` 多种字段名
- **时间轴**:按 10 分钟分片
- **标签**:只保留 frontmatter 中的 tags,内容中的"标签" section 已删除
## pyannote.audio 兼容性问题(2026-02-13)
- **问题**: pyannote 4.0.4 与 torch 2.10+ 存在兼容性问题
- **症状**:
- `torchaudio.list_audio_backends()` 方法不存在
- `torchaudio.io.StreamReader` 不存在
- **解决方案**: 降级到 torch 2.8.0 + torchaudio 2.8.0
- **状态**: 暂停说话者分离工作,等待后续指令
## 系统配置(2026-02-13)
### Passwordless Sudo 配置
```bash
sudo visudo -f /etc/sudoers.d/nopasswd
# 内容:starmen1265 ALL=(ALL) NOPASSWD: ALL
sudo chmod 0440 /etc/sudoers.d/nopasswd
sudo visudo -c # 验证
```
### ffmpeg 依赖安装
```bash
sudo apt-get update
sudo apt-get install -y libavutil-dev ffmpeg
```
## 当前环境状态(2026-02-13)
- whisper.cpp: ~/whisper.cpp
- whisper 模型: ggml-base.bin
- kb-whisper 脚本: skills/kb-whisper/whisper.py
## ⚠️ 安全规则
### rm 命令使用规则
- **使用 rm 命令之前必须向用户汇报具体操作命令**
- **禁止直接执行 rm 命令**
- 等待用户确认后才能执行
### sudo 命令使用规则
- 需要配置 passwordless sudo 才能执行系统级操作
- 配置路径: `/etc/sudoers.d/nopasswd`
- 配置后需要验证: `sudo visudo -c`
+15 -10
View File
@@ -3,10 +3,6 @@
## 待处理 ## 待处理
### 重要 ### 重要
- [ ] 配置 OpenRouter 接入(使用免费模型如Pony Alpha)
- 获取 OPENROUTER_API_KEY
- 运行: `openclaw onboard --auth-choice apiKey --token-provider openrouter --token "$OPENROUTER_API_KEY"`
- 或在 config.yaml 中配置 env: { OPENROUTER_API_KEY: "sk-or-..." }
- [ ] 🔒 处理安全巡检报告问题(2026-02-10) - [ ] 🔒 处理安全巡检报告问题(2026-02-10)
- [ ] 修复凭证目录权限: chmod 700 /home/admin/.openclaw/credentials - [ ] 修复凭证目录权限: chmod 700 /home/admin/.openclaw/credentials
- [ ] 禁用控制UI不安全认证: gateway.controlUi.allowInsecureAuth=false - [ ] 禁用控制UI不安全认证: gateway.controlUi.allowInsecureAuth=false
@@ -17,14 +13,23 @@
- [ ] 配置阿里云 TTS 语音 - [ ] 配置阿里云 TTS 语音
- 需要提供:AccessKeyId, AccessKeySecret - 需要提供:AccessKeyId, AccessKeySecret
- 配置命令: `openclaw config set channels.wecom.voice.type "aliyun"` - 配置命令: `openclaw config set channels.wecom.voice.type "aliyun"`
- [ ] 测试阿里 SenseVoice 方案效果
- 评估语音识别效果对比 Whisper
- [ ] 跑通 Git 管理知识库仓库操作
- 验证 git add/commit/push 流程
- 确保归档后自动提交到远程仓库
- [ ] 使用多个对话会话路由到不同代理
- 设计会话路由规则
- 配置不同代理处理不同类型请求
### 字幕增强方案(待实现) ### 字幕增强方案(待实现)
- [ ] **视觉大模型方案** - 用API识别视频帧中的字幕 - [x] ~~编译 Whisper.cpp~~ - 已完成基础功能
- 可选模型:GPT-4V / Claude Vision / GLM-4V / 通义千问VL - Whisper 编译完成,集成到 KB Workflow
- 流程:视频帧图片 → 视觉大模型 → 识别文字 → 输出字幕 - [ ] **集成 pyannote.audio 说话者分离**
- [ ] **语音识别(ASR)方案** - 用ASR模型提取音频字幕 - 创建 kb-whisper-diarization 技能
- 可选模型:Whisper / 讯飞ASR / 阿里云ASR - 安装依赖: `pip install pyannote.audio torch`
- 流程:视频 → 提取音频 → ASR模型 → 字幕文件(.srt/.lrc) - 设置 HF_TOKEN 环境变量
- 申请模型权限: https://huggingface.co/pyannote/speaker-diarization
--- ---
+78
View File
@@ -0,0 +1,78 @@
# Session: 2026-02-13 10:43:39 UTC
- **Session Key**: agent:main:main
- **Session ID**: 8de40e85-24aa-48b6-9742-9aa6cf38fd78
- **Source**: feishu
## Conversation Summary
assistant: 已提交并推送!✅
```
refactor: 重构 deep_analyzer 一次性调用 AI 生成完整报告
4 files changed, 2091 insertions(+), 1594 deletions(-)
```
user: [Fri 2026-02-13 18:35 GMT+8] 我看了一下final版文档,标题 爱泼斯坦案背后的美式司法与认知战深度解析 摘要 沈逸教授针对2026年初爱泼斯坦案300万页文件解密事件进行了深度剖析。他指出,尽管大量文件公布,美国司法部已明确表示不会提起新的刑事诉讼,意味着涉案权贵客户将免于刑事追责,仅需通过民事诉讼支付巨额和解金。视频批判了美式司法体系服务于资本和政治的本质,将司法作为政治交易工具和宣泄公众情绪的缓冲阀。沈逸进一步分析了文件中夹杂的虚假信息(如“吃人”艺术照)所构成的“认知战”,通过释放真假难辨的信息制造混乱,引导舆论陷入无意义的阴谋论争吵。最后,他将爱泼斯坦案与“斩杀线”现象结合,揭示了美国社会顶层逍遥法外、底层绝望并存的残酷现实,认为这彻底粉碎了美式法治与人权的灯塔幻象,印证了中国道路的正确性。 主要主题 - 美式司法双标 - 政治交易工具 - 认知战与信息污染 - 资本凌驾法律 - 斩杀线与萝莉岛 - 阴谋论制造机制 - 制度性问题 - 资本主义法治 - 美国灯塔熄灭 - 中国道路自信
[message_id: cda17e67-4cd7-431a-aaf1-4799c2fbe297]
assistant: 蒋先生,文档内容已确认。有需要修改的地方吗?
user: [Fri 2026-02-13 18:35 GMT+8] 正文部分还是丢了Markdown标记。是AI原始输出就没带吗?再检查一下输入给AI的内容和AI输出的内容
[message_id: ff30c655-6076-4af5-b888-62b246c99290]
assistant: 让我检查代码,看看哪里清理了 Markdown:
assistant: 找到问题了!第 472-474 行在清理 Markdown。让我删除这些行:
assistant: 完成!✅ Markdown 格式已保留:
- `## 标题`
- `## 摘要`
- `**加粗文字**`
- `### 时间轴`
现在文档保留了完整的 Markdown 标记。需要提交推送吗?
user: System: [2026-02-13 18:38:07 GMT+8] Exec completed (tide-can, code 0) :: 内容长度: 8043 字符 [DeepAnalyzer] 将分 4 段分析 [AI Client] 开始调用 AI... [AI Client] AI 调用完成,耗时: 75.97秒 { "success": true, "filename": "bilibili_BV1W4f9B6EdC_final_v2.md", "filepath": "/home/starmen1265/work/knowledge-base/analyzed/bilibili_BV1W4f9B6EdC_final_v2.md", "message": "深度分析完成", "stats": { "summary_length": 273, "topics_count": 10, "insights_count": 5, "tags_count": 5 } }
System: [2026-02-13 18:38:41 GMT+8] Exec completed (rapid-sh, code 0)
[Fri 2026-02-13 18:38 GMT+8] 提交并推送
[message_id: d1ca58f2-4bce-4d1b-b1ce-88cb842e6082]
assistant: 已提交并推送!✅
```
fix: 保留 Markdown 格式不过滤
1 file changed, 1 insertion(+), 15 deletions(-)
```
user: System: [2026-02-13 18:38:55 GMT+8] Exec completed (kind-for, code 0)
System: [2026-02-13 18:39:06 GMT+8] Exec completed (swift-su, code 0)
System: [2026-02-13 18:39:26 GMT+8] Exec completed (tidy-tra, code 0)
[Fri 2026-02-13 18:42 GMT+8] 好,在使用AI进行深度分析的时候有指定使用哪个AI模型吗?希望能够指定用别名为g5的模型(原名zhipu/glm-5)。并且希望是由子代理进行分析。不影响主代理做其他事情。
[message_id: 0a9a6b20-1637-41b8-baee-9687f08f474f]
assistant: 浩南收到,蒋先生!让我查看 kb-workflow 技能并添加子代理调用:
assistant: deep_analyzer.py 已支持 `--model` 参数。现在修改 kb-workflow 技能,在深度分析步骤使用子代理并指定 g5:
assistant: 已完成!✅
修改内容:
- 深度分析步骤现在由**子代理**执行
- 使用 **g5 模型**(glm-5)
- 不影响主代理做其他事情
```bash
# 子代理调用示例
python3 /path/to/deep_analyzer.py \
--metadata [meta_path] \
--content [content_path] \
--output [analyzed_dir]/[filename].md \
--provider zhipu \
--model glm-5
```
user: [Fri 2026-02-13 18:43 GMT+8] 提交并推送
[message_id: f189f326-db19-419d-8334-79b02484cce6]
assistant: 蒋先生,刚刚已经提交并推送了!
```
feat: 深度分析使用 g5 模型并由子代理执行
1 file changed, 7 insertions(+), 3 deletions(-)
```
是否需要做其他修改?
+77
View File
@@ -0,0 +1,77 @@
# 2026-02-13 工作日志
## 完成事项
### deep_analyzer.py 重构
- 改为一次性 AI 调用生成完整报告
- 修复内容读取:支持 `text`、`subtitle`、`subtitles`、`description` 多种字段
- 时间轴分片策略:按 10 分钟分片
- 删除内容中的"标签" section,只保留 frontmatter 的 tags
### 报告格式
```
---
title: "xxx"
date: xxx
duration: xxx
author: xxx
bvid: xxx
platform: bilibili
url: xxx
tags: ["tag1", "tag2", ...]
---
## 标题
[AI 自总结标题]
## 摘要
[200-400字]
## 主要主题
- 主题1
- 主题2
...
## 关键见解
- 见解1
- 见解2
...
## 时间轴
### 第 1 部分 (00:00 - 10:00)
[详细分析]
### 第 2 部分 (10:00 - 20:00)
[详细分析]
...
```
## 待办
- 无
## 备注
- AI 模型:glm-4.7
- 内容长度:约 8000 字符输入,生成详细分析报告
## 2026-02-13 知识库工作流
### BV1x3ctzkEdf 添加到知识库
- 视频标题:冬奥会和超级碗,为何让MAGA帝君破防?
- 工作流完整执行:
1. 链接解析 ✅
2. 元数据提取 ✅
3. 字幕下载:无官方字幕,使用 Whisper ✅
4. Whisper 语音识别:981秒,464分段 ✅
5. 深度分析:9主题,5洞察,5标签 ✅
6. 归档:/archive/2026-02-13/bilibili_BV1x3ctzkEdf.md ✅
### Git 仓库配置
- 远端仓库:ssh://sc-gitea@192.168.1.22:2223/starmen1265/knowledge-base.git
- 本地 archive 目录已关联远端仓库
- 成功推送到 Gitea(master 分支)
- kb_workflow.py 已更新归档流程:
1. 检查并初始化 Git 仓库
2. 关联远端仓库(KB_GIT_REMOTE 环境变量)
3. 拉取远端最新代码
4. 归档文件后自动 commit + push
5. 支持 main/master 分支自动兼容
+60 -11
View File
@@ -4,21 +4,34 @@
## Features ## Features
- Video download (multiple quality options) - Video download using **yt-dlp** (recommended)
- Subtitle extraction - Subtitle extraction
- Article content extraction - Article content extraction
- Low quality for OCR (480p default) - Multiple quality options (360P/480P/720P/1080P)
- Supports `BILIBILI_COOKIE` environment variable
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `BILIBILI_COOKIE` | No | B站登录 Cookie (for member-only videos) |
## Commands ## Commands
```bash ```bash
# Download video (传入 --output 指定完整路径) # Download video (720P default)
python bilibili_content.py --id "BV1xx411c7mD" --type video --quality 480 --output "/path/to/downloads/BV1xx.mp4" python bilibili_content.py --id "BV1xx411c7mD" --type video --output "/path/to/downloads/"
# Extract subtitle (传入 --output 指定完整路径) # Download video (1080P)
python bilibili_content.py --id "BV1xx411c7mD" --type subtitle --output "/path/to/subtitles/BV1xx.srt" python bilibili_content.py --id "BV1xx411c7mD" --type video --quality 80 --output "/path/to/downloads/"
# Get article content (传入 --output 指定完整路径) # Download video (480P)
python bilibili_content.py --id "BV1xx411c7mD" --type video --quality 32 --output "/path/to/downloads/"
# Extract subtitle
python bilibili_content.py --id "BV1xx411c7mD" --type subtitle --output "/path/to/subtitles/"
# Get article content
python bilibili_content.py --id "cv43780186" --type article --output "/path/to/article.md" python bilibili_content.py --id "cv43780186" --type article --output "/path/to/article.md"
``` ```
@@ -28,15 +41,51 @@ python bilibili_content.py --id "cv43780186" --type article --output "/path/to/a
{ {
"success": true, "success": true,
"type": "video", "type": "video",
"filename": "BV1xx.mp4", "filename": "video_title.mp4",
"filepath": "/path/to/downloads/BV1xx.mp4", "filepath": "/path/to/downloads/video_title.mp4",
"message": "下载完成" "message": "下载完成: video_title.mp4"
} }
``` ```
## Cookie Setup
### Get Cookie from Browser
1. Login to bilibili.com
2. Press F12 → Network → Refresh page
3. Copy Cookie from request headers
### Set Environment Variable
```bash
# Add to ~/.bashrc for persistence
export BILIBILI_COOKIE="SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx; DedeUserID__ckMd5=xxx"
# Or set temporarily
export BILIBILI_COOKIE="your_cookie_here"
```
## Installation ## Installation
```bash ```bash
cp -r kb-bilibili-content/ ~/skills/ cp -r kb-bilibili-content/ ~/skills/
pip install aiohttp pip install aiohttp playwright
playwright install chromium
``` ```
## How It Works
Video download uses **yt-dlp** with Netscape-format cookie file:
1. Parses `BILIBILI_COOKIE` environment variable
2. Creates temporary Netscape-format cookie file
3. Runs `yt-dlp --cookies <file> -f <format> -o <output>`
4. Cleans up temporary file
## Quality Options
| Quality | ID | Resolution | Notes |
|---------|-----|------------|-------|
| 16 | 360P | 640x360 | Fastest |
| 32 | 480P | 852x480 | Default for low bandwidth |
| 64 | 720P | 1280x720 | **Recommended** |
| 80 | 1080P | 1920x1080 | Best quality |
Default quality: **64 (720P)**
+242 -74
View File
@@ -4,7 +4,7 @@
Bilibili Content Downloader - B站内容获取工具 Bilibili Content Downloader - B站内容获取工具
功能: 功能:
- download_video(bvid, quality=480) - 下载视频 - download_video(bvid, quality=480) - 下载视频(使用 yt-dlp)
- extract_subtitle(bvid) - 提取字幕 - extract_subtitle(bvid) - 提取字幕
- extract_article(cvid) - 获取专栏全文(使用Playwright) - extract_article(cvid) - 获取专栏全文(使用Playwright)
- 命令行参数支持 - 命令行参数支持
@@ -16,8 +16,9 @@ import asyncio
import json import json
import os import os
import re import re
import subprocess
import sys import sys
import aiohttp import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
@@ -26,127 +27,290 @@ from typing import Optional, Dict, Any, List
class BilibiliContent: class BilibiliContent:
"""B站内容获取器""" """B站内容获取器"""
# 完整的浏览器请求头
BROWSER_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Cache-Control': 'max-age=0',
}
def __init__(self, output_dir: str = "./output", cookie: str = None): def __init__(self, output_dir: str = "./output", cookie: str = None):
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True)
self.cookie = cookie self.cookie = cookie
def _get_headers(self) -> Dict[str, str]: def _create_cookie_file(self) -> str:
"""获取请求头(包含Cookie)""" """创建 Netscape 格式的 Cookie 文件"""
headers = self.BROWSER_HEADERS.copy() if not self.cookie:
if self.cookie: return None
headers['Cookie'] = self.cookie
return headers
async def download_video(self, bvid: str, quality: int = 32) -> Dict[str, Any]: # 解析 Cookie 字符串
"""下载B站视频""" cookies = {}
for item in self.cookie.split(';'):
item = item.strip()
if '=' in item:
key, value = item.split('=', 1)
cookies[key.strip()] = value.strip()
# 创建 Netscape 格式的 Cookie 文件
cookie_content = """# Netscape HTTP Cookie File
# Generated by bilibili_content.py
"""
for name, value in cookies.items():
cookie_content += f".bilibili.com\tTRUE\t/\tFALSE\t0\t{name}\t{value}\n"
# 写入临时文件
fd, path = tempfile.mkstemp(suffix='.txt')
with os.fdopen(fd, 'w') as f:
f.write(cookie_content)
return path
async def download_video(self, bvid: str, quality: int = 64) -> Dict[str, Any]:
"""下载B站视频(使用 yt-dlp)"""
result = {"success": False, "bvid": bvid, "message": "", "filepath": None, "metadata": {}} result = {"success": False, "bvid": bvid, "message": "", "filepath": None, "metadata": {}}
try: try:
# API 请求使用自动解压 # 先获取视频信息
async with aiohttp.ClientSession(headers=self._get_headers()) as api_session:
# 获取视频信息
print(f"获取视频信息: {bvid}") print(f"获取视频信息: {bvid}")
async with api_session.get("https://api.bilibili.com/x/web-interface/view", params={'bvid': bvid}) as resp:
data = await resp.json()
if data.get('code') != 0: cookie_file = self._create_cookie_file()
result["message"] = f"获取失败: {data.get('message')}" url = f"https://www.bilibili.com/video/{bvid}"
return result
video_data = data.get('data', {}) try:
title = video_data.get('title', '未知标题') # 获取视频标题(用于文件名)
cid = video_data.get('cid') if cookie_file:
cmd = ["yt-dlp", "--cookies", cookie_file, "--get-title", url]
else:
cmd = ["yt-dlp", "--get-title", url]
result["metadata"] = {"title": title, "cid": cid, "bvid": bvid} proc = await asyncio.create_subprocess_exec(
print(f"标题: {title}") *cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
# 获取播放地址 title = stdout.decode('utf-8').strip()
print(f"获取视频流...") if not title or 'error' in title.lower():
async with api_session.get("https://api.bilibili.com/x/player/playurl", # 尝试备用方法
params={'bvid': bvid, 'cid': cid, 'qn': quality}) as resp: title = f"video_{bvid}"
play_data = await resp.json() except Exception as e:
title = f"video_{bvid}"
if play_data.get('code') != 0: # 构建 yt-dlp 命令
result["message"] = f"获取失败: {play_data.get('message')}" safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '丨')).strip()[:50]
return result
durl = play_data.get('data', {}).get('durl', []) # 选择视频格式
if not durl: if quality == 80:
result["message"] = "未找到视频流" # 1080P
return result format_str = "30112+30280"
elif quality == 64:
# 720P
format_str = "30064+30280"
elif quality == 32:
# 480P
format_str = "30032+30280"
else:
# 360P
format_str = "30016+30280"
video_url = durl[0].get('url') print(f"下载视频: {title}")
print(f"下载视频...") print(f"格式: {format_str}")
# 下载 output_template = str(self.output_dir / f"{safe_title}.%(ext)s")
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:30]
output_path = self.output_dir / f"{safe_title}_{bvid}.flv"
# 视频下载使用禁用自动解压的 session cmd = [
async with aiohttp.ClientSession(headers=self._get_headers(), auto_decompress=False) as download_session: "yt-dlp",
async with download_session.get(video_url) as resp: "-f", format_str,
with open(output_path, 'wb') as f: "-o", output_template,
async for chunk in resp.content.iter_chunked(81920): "--no-download-archive",
f.write(chunk) "-q",
print(f"\r下载中...", end="", flush=True) url
]
print(f"\n✅ 已保存: {output_path}") if cookie_file:
cmd.extend(["--cookies", cookie_file])
print(f"执行命令: {' '.join(cmd[:4])}...")
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
# 查找下载的文件
downloaded_files = list(self.output_dir.glob(f"{safe_title}.*"))
if downloaded_files:
video_file = downloaded_files[0]
result["success"] = True result["success"] = True
result["filepath"] = str(output_path) result["filepath"] = str(video_file)
result["message"] = f"下载完成" result["message"] = f"下载完成: {video_file.name}"
result["metadata"] = {"title": title, "bvid": bvid}
print(f"✅ {result['message']}")
else:
result["message"] = "未找到下载的文件"
print(f"❌ {result['message']}")
# 清理临时 Cookie 文件
if cookie_file and os.path.exists(cookie_file):
os.remove(cookie_file)
except Exception as e: except Exception as e:
result["message"] = f"错误: {str(e)}" result["message"] = f"错误: {str(e)}"
print(f"❌ {result['message']}")
return result return result
async def extract_subtitle(self, bvid: str) -> Dict[str, Any]: async def extract_subtitle(self, bvid: str) -> Dict[str, Any]:
"""提取字幕""" """
result = {"success": False, "bvid": bvid, "subtitles": [], "message": ""} 提取字幕
注意:没有找到官方字幕是正常的!很多视频没有自动生成的字幕。
返回 success=True 即使没有字幕,让工作流继续执行后续步骤(如 Whisper)。
"""
result = {"success": False, "bvid": bvid, "subtitles": [], "has_subtitle": False, "message": ""}
try: try:
async with aiohttp.ClientSession(headers=self._get_headers()) as session: import aiohttp
async with session.get("https://api.bilibili.com/x/player/pagelist", params={'bvid': bvid}) as resp:
api_url = f"https://api.bilibili.com/x/player/pagelist?bvid={bvid}"
async with aiohttp.ClientSession() as session:
async with session.get(api_url) as resp:
data = await resp.json() data = await resp.json()
pages = data.get('data', []) pages = data.get('data', [])
if not pages: if not pages:
result["message"] = "未找到字幕" result["message"] = "未找到分P(这是正常的)"
# 仍然返回 success=True,让工作流继续
result["success"] = True
return result return result
cid = pages[0].get('cid', '') cid = pages[0].get('cid', '')
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}") as resp: async with session.get(f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}") as resp:
sub_data = await resp.json() sub_data = await resp.json()
subtitles = sub_data.get('data', {}).get('subtitle', {}).get('list', []) subtitles = sub_data.get('data', {}).get('subtitle', {}).get('list', [])
if not subtitles: if not subtitles:
result["message"] = "未找到字幕" result["message"] = "没有官方字幕(这是正常的),将使用 Whisper 进行语音识别"
# 仍然返回 success=True,让工作流继续
result["success"] = True
return result return result
result["success"] = True result["success"] = True
result["has_subtitle"] = True
result["subtitles"] = [{"lan": s.get('lan'), "title": s.get('lan_doc')} for s in subtitles] result["subtitles"] = [{"lan": s.get('lan'), "title": s.get('lan_doc')} for s in subtitles]
result["message"] = f"找到 {len(subtitles)} 个字幕" result["message"] = f"✅ 找到 {len(subtitles)} 个字幕,将使用官方字幕进行深度分析"
print(result["message"]) print(result["message"])
except Exception as e: except Exception as e:
result["message"] = f"错误: {str(e)}" result["message"] = f"获取字幕出错(这是正常的),将使用 Whisper: {str(e)}"
# 出错时也返回 success=True,让工作流继续
result["success"] = True
return result
async def download_subtitle(self, bvid: str) -> Dict[str, Any]:
"""
下载字幕并转换为 SRT 格式
如果没有官方字幕,返回 success=True 但 has_subtitle=False,
让工作流继续使用 Whisper 进行语音识别。
"""
result = {"success": False, "bvid": bvid, "filepath": None, "has_subtitle": False, "message": ""}
try:
import aiohttp
# 获取 CID
api_url = f"https://api.bilibili.com/x/player/pagelist?bvid={bvid}"
async with aiohttp.ClientSession() as session:
async with session.get(api_url) as resp:
data = await resp.json()
pages = data.get('data', [])
if not pages:
result["message"] = "未找到分P,将使用 Whisper 进行语音识别"
result["success"] = True
return result
cid = pages[0].get('cid', '')
# 获取字幕列表
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}") as resp:
sub_data = await resp.json()
subtitles = sub_data.get('data', {}).get('subtitle', {}).get('list', [])
if not subtitles:
result["message"] = "没有官方字幕,将使用 Whisper 进行语音识别"
result["success"] = True
return result
# 选择中文字幕,如果没有则选第一个
zh_sub = None
for s in subtitles:
if s.get('lan') in ('ai-zh', 'zh-CN', 'zh'):
zh_sub = s
break
if not zh_sub:
zh_sub = subtitles[0]
# 下载字幕文件
sub_url = zh_sub.get('subtitle_url', '')
if not sub_url:
result["message"] = "字幕URL为空,将使用 Whisper"
result["success"] = True
return result
if sub_url.startswith('//'):
sub_url = 'https:' + sub_url
async with aiohttp.ClientSession() as session:
async with session.get(sub_url) as resp:
sub_content = await resp.json()
# 转换为 SRT 格式
body = sub_content.get('body', [])
if not body:
result["message"] = "字幕内容为空,将使用 Whisper"
result["success"] = True
return result
def format_srt_time(t: float) -> str:
"""将秒数转换为 SRT 时间格式"""
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
ms = int((t - int(t)) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
srt_lines = []
for i, line in enumerate(body, 1):
start = line.get('from', 0)
end = line.get('to', 0)
content = line.get('content', '')
srt_lines.extend([str(i), f"{format_srt_time(start)} --> {format_srt_time(end)}", content, ""])
srt_content = "\n".join(srt_lines)
# 保存 SRT 文件
output_path = self.output_dir / f"{bvid}.srt"
with open(output_path, 'w', encoding='utf-8') as f:
f.write(srt_content)
result["success"] = True
result["has_subtitle"] = True
result["filepath"] = str(output_path)
result["message"] = f"✅ 官方字幕已下载: {output_path.name} ({len(body)}条)"
print(result["message"])
except Exception as e:
result["message"] = f"下载字幕出错: {str(e)},将使用 Whisper"
result["success"] = True # 仍然返回成功,让工作流继续
return result return result
@@ -203,9 +367,9 @@ class BilibiliContent:
async def main(): async def main():
parser = argparse.ArgumentParser(description='Bilibili Content Downloader') parser = argparse.ArgumentParser(description='Bilibili Content Downloader')
parser.add_argument('--id', '-i', required=True, help='B站内容ID') parser.add_argument('--id', '-i', required=True, help='B站内容ID')
parser.add_argument('--type', '-t', required=True, choices=['video', 'subtitle', 'article'], help='内容类型') parser.add_argument('--type', '-t', required=True, choices=['video', 'subtitle', 'subtitle-download', 'article'], help='内容类型')
parser.add_argument('--output', '-o', default='./output', help='输出目录') parser.add_argument('--output', '-o', default='./output', help='输出目录')
parser.add_argument('--quality', '-q', type=int, default=32, help='视频质量: 16=360P, 32=480P, 64=720P, 80=1080P') parser.add_argument('--quality', '-q', type=int, default=64, help='视频质量: 16=360P, 32=480P, 64=720P, 80=1080P')
args = parser.parse_args() args = parser.parse_args()
@@ -216,6 +380,9 @@ async def main():
result = await downloader.download_video(args.id, quality=args.quality) result = await downloader.download_video(args.id, quality=args.quality)
elif args.type == 'subtitle': elif args.type == 'subtitle':
result = await downloader.extract_subtitle(args.id) result = await downloader.extract_subtitle(args.id)
elif args.type == 'subtitle-download':
# 下载并转换字幕为 SRT 格式
result = await downloader.download_subtitle(args.id)
else: else:
result = await downloader.extract_article(args.id.replace('cv', '')) result = await downloader.extract_article(args.id.replace('cv', ''))
@@ -224,6 +391,7 @@ async def main():
output = { output = {
"success": True, "success": True,
"type": args.type, "type": args.type,
"has_subtitle": result.get("has_subtitle", False),
"filename": Path(result.get('filepath', '')).name if result.get('filepath') else None, "filename": Path(result.get('filepath', '')).name if result.get('filepath') else None,
"filepath": str(Path(result.get('filepath', '')).resolve()) if result.get('filepath') else None, "filepath": str(Path(result.get('filepath', '')).resolve()) if result.get('filepath') else None,
"message": result.get('message', '处理成功') "message": result.get('message', '处理成功')
+45 -2
View File
@@ -13,8 +13,9 @@
## Commands ## Commands
```bash ```bash
# 分析内容 # 分析内容(推荐:使用 -u 参数强制无缓冲输出)
python deep_analyzer.py --metadata meta.json --content content.json --output analysis.md AI_API_KEY="your-api-key" AI_PROVIDER="zhipu" AI_MODEL="glm-4.7" \
python3 -u deep_analyzer.py --metadata meta.json --content content.json --output analysis.md
# 指定类型 # 指定类型
python deep_analyzer.py --metadata meta.json --content content.json --type tutorial --output tutorial.md python deep_analyzer.py --metadata meta.json --content content.json --type tutorial --output tutorial.md
@@ -23,6 +24,48 @@ python deep_analyzer.py --metadata meta.json --content content.json --type tutor
python deep_analyzer.py -m meta.json -c content.json -o out.md --provider zhipu --model glm-4 python deep_analyzer.py -m meta.json -c content.json -o out.md --provider zhipu --model glm-4
``` ```
## 环境变量配置
**必须设置以下环境变量**:
```bash
export AI_API_KEY="your-api-key"
export AI_PROVIDER="zhipu" # 或 openai, minimax
export AI_MODEL="glm-4.7" # 模型名称
export AI_BASE_URL="https://..." # 可选,自定义 API 地址
```
### 重要提示
**必须使用 `-u` 参数运行 Python**,确保输出实时显示在终端:
```bash
python3 -u deep_analyzer.py ...
```
如果不使用 `-u` 参数,Python 的 stdout 缓冲会导致输出延迟或看不到进度。
## 执行方式选择
### 方式1: 并发执行(推荐,默认)
- 使用 `batch_size=3`(默认)
- 6个分片约 100 秒完成
- 适合快速分析
```bash
python3 -u deep_analyzer.py --metadata meta.json --content content.json -o out.md
```
### 方式2: 顺序执行
- 使用 `batch_size=1`
- 6个分片约 735 秒完成
- 适合调试和问题排查
```bash
# 在代码中设置 batch_size=1
# analyzer.analyze(metadata, content, "video", batch_size=1)
```
## Metadata Format ## Metadata Format
支持两种元数据格式: 支持两种元数据格式:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
# FunASR 模型缓存目录
FUNASR_CACHE_DIR=~/.cache/modelscope/hub
+212
View File
@@ -0,0 +1,212 @@
# KB FunASR - 本地语音识别
基于阿里 FunASR 的本地语音识别工具,支持将音视频文件转换为文本和 JSON 格式。
## 功能特性
- 🎯 **本地执行** - 完全离线运行,保护隐私
- 🚀 **多模型支持** - Paraformer、SenseVoice 等模型可选
- 📝 **多格式输出** - JSON、TXT 两种格式
- ⚡ **异步执行** - 支持后台运行,不阻塞主代理
- 🎬 **广泛兼容** - 支持常见音视频格式(MP3、MP4、WAV 等)
- 🇨🇳 **中文优化** - Paraformer 模型针对中文语音优化
## 安装前提
### 1. 安装依赖
```bash
pip install funasr modelscope
```
### 2. 配置环境变量(可选)
在 `~/.clawdbot/clawdbot.json` 中添加:
```json5
{
skills: {
entries: {
"kb-funasr": {
env: {
FUNASR_CACHE_DIR: "~/.cache/modelscope/hub"
}
}
}
}
}
```
或通过命令行配置:
```bash
clawdbot skills config kb-funasr FUNASR_CACHE_DIR "~/.cache/modelscope/hub"
```
## 使用方法
### 基本用法
```bash
# 使用默认 Paraformer 中文模型处理
python funasr.py --input video.mp4
# 后台异步执行
python funasr.py --input video.mp4 --background
```
### 指定模型
```bash
# Paraformer 中文模型(推荐)
python funasr.py --input video.mp4 --model paraformer-zh
# Paraformer 多语言模型
python funasr.py --input video.mp4 --model paraformer
# SenseVoice 多语言模型
python funasr.py --input video.mp4 --model sensevoice
```
### 指定输出目录
```bash
python funasr.py --input video.mp4 --output ./transcripts
```
## 输出文件
对于输入 `video.mp4`,将生成以下文件:
```
output/
├── video.json # 详细结果(含时间戳)
└── video.txt # 纯文本
```
### JSON 格式
```json
{
"success": true,
"input_file": "video.mp4",
"model": "paraformer-zh",
"output_files": {
"json": "video.json",
"txt": "video.txt"
},
"duration_seconds": 120.5,
"segments": [
{
"id": 0,
"start": 0.0,
"end": 3.2,
"text": "这是第一段识别结果"
},
{
"id": 1,
"start": 3.2,
"end": 6.8,
"text": "这是第二段识别结果"
}
]
}
```
## 模型对比
| 模型 | 速度 | 精度 | 适用场景 |
|------|------|------|----------|
| paraformer-zh | ⚡⚡⚡ | ⭐⭐⭐⭐ | 中文语音(推荐) |
| paraformer | ⚡⚡ | ⭐⭐⭐⭐ | 多语言识别 |
| sensevoice | ⚡ | ⭐⭐⭐⭐⭐ | 高精度多语言 |
## 完整示例
### 示例 1: 转录播客
```bash
python funasr.py --input podcast.mp3 --model paraformer-zh --background
```
### 示例 2: 生成视频识别结果
```bash
python funasr.py --input tutorial.mp4 --model paraformer
```
### 示例 3: 批量处理(配合脚本)
```bash
#!/bin/bash
for video in videos/*.mp4; do
python funasr.py --input "$video" --model paraformer-zh
done
```
## 在 Workflow 中使用
在知识库处理流程中,如果视频没有识别结果:
```bash
# 步骤4: 下载视频后
# 步骤5: 使用 FunASR 生成识别结果
python funasr.py --input ${video_path} --model paraformer-zh --background
# 等待 FunASR 完成,然后继续后续步骤
```
## 技术细节
- **引擎**: 阿里 FunASR
- **模型框架**: ModelScope
- **音频采样率**: 自动转换为 16kHz
- **语言**: 根据模型支持不同语言
## 故障排查
### 问题: 模型下载失败
```bash
# 首次运行会自动下载,确保网络稳定
python funasr.py --input test.mp4
# 手动检查缓存目录
ls ~/.cache/modelscope/hub/
```
### 问题: 音频格式不支持
```bash
# 转换音频格式
ffmpeg -i input.avi -acodec libmp3lame output.mp3
python funasr.py --input output.mp3
```
### 问题: 依赖安装失败
```bash
# 使用清华镜像源
pip install funasr modelscope -i https://pypi.tuna.tsinghua.edu.cn/simple
```
## 性能参考
在 CPU 环境下(Intel i7):
- **paraformer-zh 模型**: ~2x 实时速度(1分钟音频 ~30秒处理)
- **paraformer 模型**: ~1.5x 实时速度(1分钟音频 ~40秒处理)
- **sensevoice 模型**: ~1x 实时速度(1分钟音频 ~1分钟处理)
## 与 Whisper 对比
| 特性 | FunASR | Whisper |
|------|--------|---------|
| 中文识别 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 速度 | ⚡⚡⚡ | ⚡⚡ |
| 模型大小 | 较小 | 较大 |
| 多语言支持 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
## 许可证
本技能使用 MIT 许可证,FunASR 使用 Apache 2.0 许可证。
+113
View File
@@ -0,0 +1,113 @@
---
name: kb-funasr
description: 本地语音识别,支持音视频转文字(基于阿里 FunASR)
---
# KB FunASR Skill
**kb-funasr** - 本地语音识别,使用阿里 FunASR 将音视频文件转换为文本和 JSON 格式输出。
## 什么时候使用
当用户需要:
- 将视频/音频文件转换为文字
- 获取带时间戳的详细识别结果
- 需要中文优化的语音识别(Paraformer 模型)
## 环境变量
在使用之前,必须检查环境变量是否已经设置。
| 变量名 | 用途 | 示例 |
|--------|------|------|
| `FUNASR_CACHE_DIR` | FunASR 模型缓存目录 | `~/.cache/modelscope/hub` |
## 使用方法
```bash
# 基本用法(使用 Paraformer 模型)
python funasr.py --input video.mp4
# 指定模型
python funasr.py --input video.mp4 --model paraformer-zh
# 指定输出目录
python funasr.py --input video.mp4 --output ./output
# 异步执行(推荐)
python funasr.py --input video.mp4 --background
```
## 命令参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `--input` | 输入音视频文件路径 | 必需 |
| `--model` | FunASR 模型类型 | `paraformer-zh` |
| `--output` | 输出目录 | 文件所在目录 |
| `--background` | 后台异步执行 | False |
## 模型选项
- `paraformer-zh` - Paraformer 中文模型(轻量,中文优化,推荐)
- `paraformer` - Paraformer 多语言模型
- `sensevoice` - SenseVoice 多语言模型
## 输出文件
对于输入 `video.mp4`,将生成:
- `video.json` - 详细结果(包含时间戳、分段信息)
- `video.txt` - 纯文本内容
## 输出格式示例
### JSON 格式
```json
{
"success": true,
"input_file": "video.mp4",
"model": "paraformer-zh",
"output_files": {
"json": "video.json",
"txt": "video.txt"
},
"duration_seconds": 120.5,
"segments": [
{
"id": 0,
"start": 0.0,
"end": 3.2,
"text": "这是第一段识别结果"
}
]
}
```
## Workflow 中的使用
```bash
# 步骤4之后:如果已有音频但无字幕,可以使用 FunASR
python funasr.py --input ${video_path} --model paraformer-zh --background
```
## 安装
```bash
# 1. 安装依赖
pip install funasr modelscope
# 2. 首次运行时会自动下载模型到缓存目录
python funasr.py --input test.mp4
# 3. 复制技能到 skills 目录
cp -r kb-funasr/ ~/skills/
```
## 注意事项
- **异步执行**: 使用 `--background` 参数时,命令会在后台运行,不阻塞主代理
- **模型选择**: paraformer-zh 模型适合中文语音识别,速度较快,精度较高
- **文件格式**: 支持 MP3、MP4、WAV、FLAC、OGG、M4A 等常见格式
- **首次运行**: 首次运行时会自动下载模型文件,需要稳定的网络连接
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
KB FunASR - 本地语音识别工具
基于阿里 FunASR 实现音视频转文字
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional
class FunASRTranscriber:
"""FunASR 语音识别器"""
# 模型映射
MODEL_MAP = {
"paraformer-zh": "damo/speech_paraformer-large-vad_punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch",
"paraformer": "damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-pytorch",
"sensevoice": "FunASR/SenseVoiceSmall"
}
def __init__(self, model: str = "paraformer-zh", cache_dir: Optional[str] = None):
"""初始化 FunASR 识别器
Args:
model: 模型名称
cache_dir: 模型缓存目录
"""
self.model_name = model
self.model_id = self.MODEL_MAP.get(model, self.MODEL_MAP["paraformer-zh"])
self.cache_dir = cache_dir or os.path.expanduser("~/.cache/modelscope/hub")
self._model = None
def _load_model(self):
"""延迟加载模型"""
if self._model is None:
try:
from funasr import AutoModel
print(f"正在加载模型: {self.model_name} ({self.model_id})")
print(f"缓存目录: {self.cache_dir}")
self._model = AutoModel(
model=self.model_id,
disable_pbar=False,
disable_log=False,
)
print("模型加载完成")
except ImportError:
print("错误: 未安装 funasr 库")
print("请运行: pip install funasr modelscope")
sys.exit(1)
def _convert_audio(self, input_path: str) -> str:
"""转换音频格式为 16kHz WAV
Args:
input_path: 输入文件路径
Returns:
转换后的文件路径(如果不需要转换则返回原路径)
"""
# 检查是否已经是 WAV 格式
if input_path.lower().endswith('.wav'):
# 检查采样率
try:
result = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries',
'stream=sample_rate', '-of', 'default=noprint_wrappers=1:nokey=1',
input_path],
capture_output=True, text=True
)
if result.returncode == 0:
sample_rate = int(result.stdout.strip())
if sample_rate == 16000:
return input_path
except Exception:
pass
# 需要转换
output_path = input_path.rsplit('.', 1)[0] + '_converted.wav'
print(f"正在转换音频格式: {input_path} -> {output_path}")
try:
subprocess.run([
'ffmpeg', '-y', '-i', input_path,
'-acodec', 'pcm_s16le',
'-ar', '16000',
'-ac', '1',
output_path
], check=True, capture_output=True)
print("音频转换完成")
return output_path
except subprocess.CalledProcessError as e:
print(f"音频转换失败: {e}")
# 如果转换失败,尝试使用原文件
print("尝试使用原文件继续处理")
return input_path
except FileNotFoundError:
print("警告: 未找到 ffmpeg,跳过音频转换")
return input_path
def transcribe(self, input_path: str) -> Dict:
"""转录音频文件
Args:
input_path: 输入文件路径
Returns:
识别结果字典
"""
input_path = os.path.abspath(input_path)
if not os.path.exists(input_path):
return {
"success": False,
"error": f"文件不存在: {input_path}"
}
print(f"\n开始处理: {input_path}")
print(f"使用模型: {self.model_name}")
# 转换音频格式
audio_path = self._convert_audio(input_path)
# 加载模型
self._load_model()
# 执行识别
print("正在进行语音识别...")
try:
result = self._model.generate(
input=audio_path,
batch_size_s=300,
)
except Exception as e:
return {
"success": False,
"error": f"识别失败: {str(e)}"
}
# 解析结果
if not result or not isinstance(result, list):
return {
"success": False,
"error": "识别结果格式错误"
}
# FunASR 返回格式处理
segments = []
full_text = ""
for seg in result:
if isinstance(seg, dict):
text = seg.get("text", "").strip()
if "timestamp" in seg and seg["timestamp"]:
# 包含时间戳
timestamp = seg["timestamp"]
if len(timestamp) >= 2:
start_time = timestamp[0][0] if timestamp[0] else 0
end_time = timestamp[-1][1] if timestamp[-1] else start_time
else:
start_time = 0
end_time = 0
else:
start_time = 0
end_time = 0
if text:
segments.append({
"id": len(segments),
"start": round(start_time, 2),
"end": round(end_time, 2),
"text": text
})
full_text += text + "\n"
# 清理临时文件
if audio_path != input_path and "_converted.wav" in audio_path:
try:
os.remove(audio_path)
except Exception:
pass
# 计算时长
duration = segments[-1]["end"] if segments else 0
return {
"success": True,
"input_file": os.path.basename(input_path),
"model": self.model_name,
"output_files": {},
"duration_seconds": round(duration, 2),
"segments": segments,
"text": full_text.strip()
}
def save_outputs(self, result: Dict, output_dir: str):
"""保存输出文件
Args:
result: 识别结果
output_dir: 输出目录
"""
if not result.get("success"):
return
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
basename = Path(result["input_file"]).stem
# 保存 JSON
json_path = output_dir / f"{basename}.json"
with open(json_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"已保存: {json_path}")
result["output_files"]["json"] = str(json_path)
# 保存 TXT
txt_path = output_dir / f"{basename}.txt"
with open(txt_path, "w", encoding="utf-8") as f:
f.write(result.get("text", ""))
print(f"已保存: {txt_path}")
result["output_files"]["txt"] = str(txt_path)
def run_as_background(input_path: str, model: str, output_dir: str):
"""后台运行识别
Args:
input_path: 输入文件路径
model: 模型名称
output_dir: 输出目录
"""
# 创建日志文件
log_file = os.path.join(output_dir, f"funasr_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log")
# 重新执行命令(后台模式)
cmd = [
sys.executable, __file__,
"--input", input_path,
"--model", model,
"--output", output_dir
]
with open(log_file, "w") as f:
process = subprocess.Popen(
cmd,
stdout=f,
stderr=subprocess.STDOUT,
start_new_session=True
)
print(f"\n✓ 后台任务已启动 (PID: {process.pid})")
print(f"日志文件: {log_file}")
print(f"输出目录: {output_dir}")
print("\n使用以下命令查看进度:")
print(f" tail -f {log_file}")
def main():
parser = argparse.ArgumentParser(
description="KB FunASR - 本地语音识别工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python funasr.py --input video.mp4
python funasr.py --input podcast.mp3 --model paraformer-zh
python funasr.py --input video.mp4 --output ./transcripts --background
"""
)
parser.add_argument("--input", "-i", required=True, help="输入音视频文件路径")
parser.add_argument("--model", "-m", default="paraformer-zh",
choices=["paraformer-zh", "paraformer", "sensevoice"],
help="模型名称 (默认: paraformer-zh)")
parser.add_argument("--output", "-o", default=None, help="输出目录 (默认: 文件所在目录)")
parser.add_argument("--background", "-b", action="store_true", help="后台异步执行")
args = parser.parse_args()
# 确定输出目录
if args.output:
output_dir = args.output
else:
output_dir = os.path.dirname(args.input) or "."
# 后台模式
if args.background:
run_as_background(args.input, args.model, output_dir)
return
# 前台模式
transcriber = FunASRTranscriber(model=args.model)
result = transcriber.transcribe(args.input)
if result.get("success"):
transcriber.save_outputs(result, output_dir)
print(f"\n✓ 处理完成!")
print(f" 时长: {result['duration_seconds']} 秒")
print(f" 分段数: {len(result['segments'])}")
else:
print(f"\n✗ 处理失败: {result.get('error')}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,9 @@
# KB Whisper Diarization 环境变量
# Whisper.cpp 安装路径
WHISPER_CPP_PATH=~/whisper.cpp
# HuggingFace Token(必需,用于下载 pyannote 模型)
# 获取方式: https://huggingface.co/settings/tokens
# 注意: 需要先申请 pyannote/speaker-diarization 模型访问权限
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+127
View File
@@ -0,0 +1,127 @@
# KB Whisper Diarization Skill
**kb-whisper-diarization** - 使用 Whisper + pyannote.audio 实现语音识别和说话者分离。
## 什么时候使用
当用户需要:
- 区分多个说话者(如访谈、对话)
- 获取带说话者标签的字幕
- 分析对话中谁说了什么
## 环境变量
在使用之前,必须检查环境变量是否已经设置。
| 变量名 | 用途 | 示例 |
|--------|------|------|
| `WHISPER_CPP_PATH` | whisper.cpp 安装路径 | `~/whisper.cpp` |
| `HF_TOKEN` | HuggingFace Token | `hf_xxx...` |
### 获取 HuggingFace Token
pyannote.audio 需要从 HuggingFace 下载模型,需要先:
1. 注册 HuggingFace 账号:https://huggingface.co
2. 申请模型访问权限:https://huggingface.co/pyannote/speaker-diarization
3. 创建 Token:https://huggingface.co/settings/tokens
## 使用方法
```bash
# 基本用法(带说话者分离)
python whisper_diarization.py --input video.mp4
# 说话者分离
python whisper_diarization.py --input interview.mp4 --diarize
# 指定说话者数量
python whisper_diarization.py --input meeting.mp4 --diarize --num-speakers 2
# 指定语言
python whisper_diarization.py --input video.mp4 --language zh
```
## 命令参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `--input` | 输入音视频文件路径 | 必需 |
| `--output` | 输出目录 | 文件所在目录 |
| `--diarize` | 启用说话者分离 | False |
| `--num-speakers` | 估计说话者数量 | None(自动检测) |
| `--language` | 语言 | 自动检测 |
| `--model` | Whisper 模型 | base |
| `--background` | 后台异步执行 | False |
## 模型选项
- **Whisper 模型**: tiny, base, small, medium, large
- **说话者分离**: pyannote/speaker-diarization (需申请权限)
## 输出文件
对于输入 `interview.mp4`,将生成:
- `interview.mp4.json` - 完整结果(包含说话者标签)
- `interview.mp4.txt` - 纯文本
- `interview.mp4.srt` - 字幕格式(带说话者标签)
## 输出格式示例
### JSON 格式
```json
{
"success": true,
"input_file": "interview.mp4",
"diarization": true,
"speakers": ["SPEAKER_00", "SPEAKER_01"],
"segments": [
{
"id": 0,
"speaker": "SPEAKER_00",
"start": 0.0,
"end": 5.2,
"text": "大家好,欢迎来到访谈节目"
},
{
"id": 1,
"speaker": "SPEAKER_01",
"start": 5.2,
"end": 10.8,
"text": "谢谢邀请,我很荣幸"
}
]
}
```
### SRT 字幕格式
```
1
00:00:00,000 --> 00:00:05,200
[SPEAKER_00] 大家好,欢迎来到访谈节目
2
00:00:05,200 --> 00:00:10,800
[SPEAKER_01] 谢谢邀请,我很荣幸
```
## 安装
```bash
# 1. 安装依赖
pip install pyannote.audio torch
# 2. 设置 HuggingFace Token
export HF_TOKEN="hf_xxx..."
# 3. 首次运行会自动下载模型
python whisper_diarization.py --input test.mp4
```
## 注意事项
- **说话者分离需要申请权限**: https://huggingface.co/pyannote/speaker-diarization
- **处理时间较长**: 说话者分离会增加处理时间
- **对立体声效果更好**: 多声道音频的分离效果更佳
@@ -0,0 +1,8 @@
# KB Whisper Diarization 依赖
# 核心依赖
torch>=2.0.0
pyannote.audio>=1.0.0
# 可选: GPU 加速
# torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""
KB Whisper Diarization - 语音识别 + 说话者分离
使用 Whisper 进行语音识别,pyannote.audio 进行说话者分离
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
import re
def parse_arguments():
"""解析命令行参数"""
parser = argparse.ArgumentParser(
description='KB Whisper Diarization - 语音识别 + 说话者分离',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python whisper_diarization.py --input video.mp4
python whisper_diarization.py --input interview.mp4 --diarize
python whisper_diarization.py --input meeting.mp4 --diarize --num-speakers 2
"""
)
parser.add_argument('--input', required=True, help='输入音视频文件路径')
parser.add_argument('--output', help='输出目录 (默认: 文件所在目录)')
parser.add_argument('--diarize', action='store_true', help='启用说话者分离')
parser.add_argument('--num-speakers', type=int, help='估计说话者数量')
parser.add_argument('--language', help='指定语言 (默认: 自动检测)')
parser.add_argument('--model', default='base', choices=['tiny', 'base', 'small', 'medium', 'large'],
help='Whisper 模型 (默认: base)')
parser.add_argument('--background', action='store_true', help='后台异步执行')
return parser.parse_args()
def get_whisper_path():
"""获取 whisper.cpp 路径"""
path = os.environ.get('WHISPER_CPP_PATH')
if path:
path = os.path.expanduser(path)
if os.path.exists(path):
return path
return os.path.expanduser('~/whisper.cpp')
def check_whisper_model(whisper_path, model):
"""检查 Whisper 模型"""
model_path = os.path.join(whisper_path, 'models', f'ggml-{model}.bin')
if not os.path.exists(model_path):
raise FileNotFoundError(f"Whisper 模型不存在: {model_path}")
return model_path
def convert_to_wav(input_path, output_dir):
"""转换为 16kHz WAV"""
if input_path.lower().endswith('.wav'):
try:
result = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries', 'stream=sample_rate',
'-of', 'default=noprint_wrappers=1:nokey=1', input_path],
capture_output=True, text=True
)
if result.returncode == 0 and int(result.stdout.strip()) == 16000:
return input_path
except Exception:
pass
output_path = os.path.join(output_dir, f"{Path(input_path).stem}_audio.wav")
print(f"转换音频: {input_path} -> {output_path}")
subprocess.run([
'ffmpeg', '-y', '-i', input_path,
'-acodec', 'pcm_s16le', '-ar', '16000', '-ac', '1',
'-loglevel', 'error', output_path
], check=True, capture_output=True)
print("✓ 音频转换完成")
return output_path
def run_whisper(whisper_path, audio_path, model, output_dir, language=None):
"""运行 Whisper 识别"""
basename = Path(audio_path).stem.replace('_audio', '')
output_file = os.path.join(output_dir, basename)
cmd = [
os.path.join(whisper_path, 'whisper-cli'),
'-m', os.path.join(whisper_path, 'models', f'ggml-{model}.bin'),
'-f', audio_path,
'-of', output_file,
'-l', language or 'auto',
'--output-txt', '--output-srt', '--output-json',
]
print(f"执行 Whisper: {' '.join(cmd[:6])} ...")
result = subprocess.run(
cmd, capture_output=True, text=True, cwd=whisper_path, timeout=3600
)
if result.returncode != 0 and not os.path.exists(f"{output_file}.txt"):
raise RuntimeError(f"Whisper 失败: {result.stderr}")
return basename
def run_diarization(audio_path, num_speakers=None):
"""运行 pyannote.audio 说话者分离"""
print("\n🎙️ 步骤2: 说话者分离...")
try:
import torch
from pyannote.audio import Pipeline
except ImportError as e:
print(f"✗ 缺少依赖: {e}")
print("请安装: pip install pyannote.audio torch")
raise
# 检查 HuggingFace Token
hf_token = os.environ.get('HF_TOKEN')
if not hf_token:
raise RuntimeError("需要设置 HF_TOKEN 环境变量\n"
"获取方式: https://huggingface.co/settings/tokens")
# 新版 pyannote 需要先登录
from huggingface_hub import login
print("登录 HuggingFace...")
login(token=hf_token)
print("加载 pyannote 流水线...")
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization")
# 设置设备
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")
pipeline.to(device)
# 运行分离
print("执行说话者分离...")
diarization = pipeline(
audio_path,
num_speakers=num_speakers
)
# 解析结果
speakers = {}
for turn, _, speaker in diarization.itertracks(yield_label=True):
if speaker not in speakers:
speakers[speaker] = []
speakers[speaker].append({
"start": round(turn.start, 2),
"end": round(turn.end, 2)
})
print(f"✓ 检测到 {len(speakers)} 个说话者: {list(speakers.keys())}")
return speakers
def merge_results(whisper_txt, whisper_srt, diarization, output_dir, basename):
"""合并 Whisper 和说话者分离结果"""
print("\n📝 步骤3: 合并结果...")
# 读取 Whisper 文本
text_segments = []
if os.path.exists(whisper_txt):
with open(whisper_txt, 'r', encoding='utf-8') as f:
content = f.read()
# 简单按行分段
for i, line in enumerate(content.strip().split('\n')):
if line.strip():
text_segments.append({
"id": i,
"text": line.strip(),
"start": i * 3.0, # 估算
"end": (i + 1) * 3.0
})
# 读取 SRT 获取精确时间
srt_segments = []
if os.path.exists(whisper_srt):
with open(whisper_srt, 'r', encoding='utf-8') as f:
content = f.read()
# 解析 SRT
pattern = r'(\d+)\n(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})\n(.+?)(?=\n\n|\Z)'
matches = re.findall(pattern, content, re.DOTALL)
def parse_time(t):
h, m, s_ms = t.split(':')
s, ms = s_ms.split(',')
return float(h) * 3600 + float(m) * 60 + float(s) + float(ms) / 1000
for idx, num, start, end, text in matches:
srt_segments.append({
"id": int(idx),
"start": parse_time(start),
"end": parse_time(end),
"text": text.strip()
})
# 为每个 Whisper 段落分配说话者
merged_segments = []
for seg in srt_segments:
speaker = assign_speaker(seg['start'], seg['end'], diarization)
merged_segments.append({
"id": len(merged_segments),
"speaker": speaker,
"start": seg['start'],
"end": seg['end'],
"text": seg['text']
})
# 生成带说话者的 SRT
output_srt = os.path.join(output_dir, f"{basename}_diarized.srt")
with open(output_srt, 'w', encoding='utf-8') as f:
for seg in merged_segments:
start = format_time(seg['start'])
end = format_time(seg['end'])
f.write(f"{seg['id'] + 1}\n")
f.write(f"{start} --> {end}\n")
f.write(f"[{seg['speaker']}] {seg['text']}\n\n")
# 生成合并的 TXT
output_txt = os.path.join(output_dir, f"{basename}_diarized.txt")
with open(output_txt, 'w', encoding='utf-8') as f:
for seg in merged_segments:
f.write(f"[{seg['speaker']}] {seg['text']}\n")
# 生成 JSON
output_json = os.path.join(output_dir, f"{basename}_diarized.json")
json_data = {
"success": True,
"tool": "whisper-diarization",
"diarization": True,
"speakers": list(diarization.keys()),
"input_file": basename,
"output_files": {
"txt": f"{basename}_diarized.txt",
"srt": f"{basename}_diarized.srt",
"json": f"{basename}_diarized.json"
},
"segments": merged_segments
}
with open(output_json, 'w', encoding='utf-8') as f:
json.dump(json_data, f, ensure_ascii=False, indent=2)
print(f"✓ 生成文件:")
print(f" - {output_srt}")
print(f" - {output_txt}")
print(f" - {output_json}")
return json_data
def assign_speaker(start, end, diarization):
"""将时间段分配给说话者"""
best_speaker = "UNKNOWN"
max_overlap = 0
for speaker, segments in diarization.items():
for seg in segments:
overlap = min(end, seg['end']) - max(start, seg['start'])
if overlap > max_overlap:
max_overlap = overlap
best_speaker = speaker
return best_speaker
def format_time(seconds):
"""秒数转换为 SRT 时间格式"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{ms:03d}"
def main():
args = parse_arguments()
input_file = os.path.abspath(args.input)
if not os.path.exists(input_file):
print(f"✗ 文件不存在: {input_file}")
sys.exit(1)
output_dir = os.path.abspath(args.output) if args.output else os.path.dirname(input_file)
os.makedirs(output_dir, exist_ok=True)
basename = Path(input_file).stem
print(f"\n📁 输入: {input_file}")
print(f"📁 输出: {output_dir}")
print(f"🎙️ 说话者分离: {'是' if args.diarize else '否'}")
try:
# 步骤1: Whisper 识别
print(f"\n🎙️ 步骤1: 语音识别...")
whisper_path = get_whisper_path()
check_whisper_model(whisper_path, args.model)
audio_path = convert_to_wav(input_file, output_dir)
run_whisper(whisper_path, audio_path, args.model, output_dir, args.language)
# 步骤2: 说话者分离
if args.diarize:
diarization = run_diarization(audio_path, args.num_speakers)
else:
diarization = {}
# 步骤3: 合并结果
whisper_txt = os.path.join(output_dir, f"{basename}.txt")
whisper_srt = os.path.join(output_dir, f"{basename}.srt")
result = merge_results(whisper_txt, whisper_srt, diarization, output_dir, basename)
# 清理临时音频
if audio_path != input_path and audio_path.endswith('_audio.wav'):
try:
os.remove(audio_path)
except Exception:
pass
print(f"\n✅ 处理完成!")
print(f" 说话者数量: {len(diarization) if args.diarize else 1}")
print(f" 段落数量: {len(result['segments'])}")
except Exception as e:
print(f"\n✗ 错误: {e}")
sys.exit(1)
if __name__ == '__main__':
main()
+5
View File
@@ -0,0 +1,5 @@
# KB Whisper 环境变量配置示例
# 复制此文件为 .env 并填写实际值
# Whisper.cpp 安装路径
WHISPER_CPP_PATH=~/whisper.cpp
+211
View File
@@ -0,0 +1,211 @@
# KB Whisper - 本地语音识别
基于 Whisper.cpp 的本地语音识别工具,支持将音视频文件转换为文本、字幕和 JSON 格式。
## 功能特性
- 🎯 **本地执行** - 完全离线运行,保护隐私
- 🚀 **多模型支持** - 从 tiny 到 large,根据需求选择速度/精度
- 📝 **多格式输出** - JSON、SRT、TXT 三种格式
- ⚡ **异步执行** - 支持后台运行,不阻塞主代理
- 🎬 **广泛兼容** - 支持常见音视频格式(MP3、MP4、WAV 等)
## 安装前提
### 1. 安装 whisper.cpp
```bash
# 克隆仓库
git clone https://github.com/ggerganov/whisper.cpp.git ~/whisper.cpp
# 编译
cd ~/whisper.cpp
make
# 下载模型(至少需要一个模型)
./models/download-ggml-model.sh tiny
./models/download-ggml-model.sh base # 可选
```
### 2. 配置环境变量
在 `~/.clawdbot/clawdbot.json` 中添加:
```json5
{
skills: {
entries: {
"kb-whisper": {
env: {
WHISPER_CPP_PATH: "~/whisper.cpp"
}
}
}
}
}
```
或通过命令行配置:
```bash
clawdbot skills config kb-whisper WHISPER_CPP_PATH "~/whisper.cpp"
```
## 使用方法
### 基本用法
```bash
# 使用默认 tiny 模型处理
python whisper.py --input video.mp4
# 使用 base 模型(推荐,平衡速度和精度)
python whisper.py --input video.mp4 --model base
# 后台异步执行
python whisper.py --input video.mp4 --background
```
### 指定输出目录
```bash
python whisper.py --input video.mp4 --output ./transcripts
```
### 处理音频文件
```bash
python whisper.py --input podcast.mp3 --model base
```
## 输出文件
对于输入 `video.mp4`,将生成以下文件:
```
output/
├── video.json # 详细结果(含时间戳)
├── video.srt # 字幕格式
└── video.txt # 纯文本
```
### JSON 格式
```json
{
"success": true,
"input_file": "video.mp4",
"model": "base",
"output_files": {
"json": "video.json",
"srt": "video.srt",
"txt": "video.txt"
},
"duration_seconds": 120.5,
"segments": [
{
"id": 0,
"start": 0.0,
"end": 3.2,
"text": "这是第一段字幕"
},
{
"id": 1,
"start": 3.2,
"end": 6.8,
"text": "这是第二段字幕"
}
]
}
```
## 模型对比
| 模型 | 速度 | 精度 | 适用场景 |
|------|------|------|----------|
| tiny | ⚡⚡⚡ | ⭐ | 快速预览、实时转录 |
| base | ⚡⚡ | ⭐⭐⭐ | 日常使用(推荐) |
| small | ⚡ | ⭐⭐⭐⭐ | 高质量需求 |
| medium | ⭐ | ⭐⭐⭐⭐⭐ | 专业用途 |
| large | 🐢 | ⭐⭐⭐⭐⭐ | 最高精度要求 |
## 完整示例
### 示例 1: 转录播客
```bash
python whisper.py --input podcast.mp3 --model base --background
```
### 示例 2: 生成视频字幕
```bash
python whisper.py --input tutorial.mp4 --model small
```
### 示例 3: 批量处理(配合脚本)
```bash
#!/bin/bash
for video in videos/*.mp4; do
python whisper.py --input "$video" --model base
done
```
## 在 Workflow 中使用
在知识库处理流程中,如果视频没有字幕:
```bash
# 步骤4: 下载视频后,如果没有字幕
# 步骤5: 使用 Whisper 生成字幕
python whisper.py --input ${video_path} --model base --background
# 等待 Whisper 完成,然后继续后续步骤
```
## 技术细节
- **引擎**: Whisper.cpp(C++ 实现,性能优异)
- **音频采样率**: 自动转换为 16kHz
- **语言**: 自动检测(支持 99 种语言)
- **GPU 支持**: whisper.cpp 支持 CUDA 加速(需编译时启用)
## 故障排查
### 问题: 找不到 whisper.cpp
```bash
# 检查环境变量
echo $WHISPER_CPP_PATH
# 手动测试
~/whisper.cpp/main --help
```
### 问题: 模型未下载
```bash
cd ~/whisper.cpp
./models/download-ggml-model.sh tiny
```
### 问题: 音频格式不支持
```bash
# 转换音频格式
ffmpeg -i input.avi -acodec libmp3lame output.mp3
python whisper.py --input output.mp3
```
## 性能参考
在 CPU 环境下(Intel i7):
- **tiny 模型**: ~1x 实时速度(1分钟音频 ~1分钟处理)
- **base 模型**: ~0.5x 实时速度(1分钟音频 ~2分钟处理)
- **small 模型**: ~0.25x 实时速度(1分钟音频 ~4分钟处理)
## 许可证
本技能使用 MIT 许可证,Whisper.cpp 使用 MIT 许可证。
+124
View File
@@ -0,0 +1,124 @@
---
name: kb-whisper
description: 本地语音识别,支持音视频转文字(基于 Whisper.cpp)
---
# KB Whisper Skill
**kb-whisper** - 本地语音识别,使用 Whisper.cpp 将音视频文件转换为文本、字幕和 JSON 格式输出。
## 什么时候使用
当用户需要:
- 将视频/音频文件转换为文字
- 生成视频字幕(SRT 格式)
- 获取带时间戳的详细识别结果
## 环境变量
在使用之前,必须检查环境变量是否已经设置。
| 变量名 | 用途 | 示例 |
|--------|------|------|
| `WHISPER_CPP_PATH` | whisper.cpp 安装路径 | `~/whisper.cpp` |
## 使用方法
```bash
# 基本用法
python whisper.py --input video.mp4
# 指定模型(默认 tiny)
python whisper.py --input video.mp4 --model base
# 指定输出目录
python whisper.py --input video.mp4 --output ./output
# 异步执行(推荐)
python whisper.py --input video.mp4 --background
```
## 命令参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `--input` | 输入音视频文件路径 | 必需 |
| `--model` | Whisper 模型类型 | `tiny` |
| `--output` | 输出目录 | 文件所在目录 |
| `--background` | 后台异步执行 | False |
## 模型选项
- `tiny` - 最小模型,速度快,精度较低(推荐快速识别)
- `base` - 基础模型,平衡速度和精度(推荐)
- `small` - 小型模型,精度更高,速度较慢
- `medium` - 中型模型,高精度
- `large` - 大型模型,最高精度,速度慢
## 输出文件
对于输入 `video.mp4`,将生成:
- `video.json` - 详细结果(包含时间戳、分段信息)
- `video.srt` - 字幕格式文件
- `video.txt` - 纯文本内容
## 输出格式示例
### JSON 格式
```json
{
"success": true,
"input_file": "video.mp4",
"model": "tiny",
"output_files": {
"json": "video.json",
"srt": "video.srt",
"txt": "video.txt"
},
"duration_seconds": 120.5,
"segments": [
{
"id": 0,
"start": 0.0,
"end": 3.2,
"text": "这是第一段字幕"
}
]
}
```
## Workflow 中的使用
```bash
# 步骤4之后:如果已有音频但无字幕,可以使用 whisper
python whisper.py --input ${video_path} --model base --background
```
## 安装
```bash
# 1. 克隆 whisper.cpp
git clone https://github.com/ggerganov/whisper.cpp.git ~/whisper.cpp
# 2. 编译
cd ~/whisper.cpp
make
# 3. 下载模型(tiny)
./models/download-ggml-model.sh tiny
# 4. 下载更多模型(可选)
# ./models/download-ggml-model.sh base
# ./models/download-ggml-model.sh small
# 5. 复制技能到 skills 目录
cp -r kb-whisper/ ~/skills/
```
## 注意事项
- **异步执行**: 使用 `--background` 参数时,命令会在后台运行,不阻塞主代理
- **模型选择**: tiny 模型适合快速识别,base 模型适合一般用途,large 模型适合高精度需求
- **文件格式**: 支持 MP3、MP4、WAV、FLAC、OGG、M4A 等常见格式
+273
View File
@@ -0,0 +1,273 @@
# KB Whisper 使用示例
## 快速开始
### 1. 基础使用
```bash
# 使用默认 tiny 模型转录视频
python whisper.py --input video.mp4
# 使用 base 模型(推荐)
python whisper.py --input video.mp4 --model base
```
### 2. 异步执行
```bash
# 后台处理,不阻塞主代理
python whisper.py --input video.mp4 --model base --background
```
### 3. 指定输出目录
```bash
python whisper.py --input video.mp4 --output ./transcripts
```
### 4. 指定语言
```bash
# 指定中文
python whisper.py --input video.mp4 --language zh
# 指定英文
python whisper.py --input video.mp4 --language en
# 指定日语
python whisper.py --input video.mp4 --language ja
```
## 实际应用场景
### 场景 1: 转录播客
```bash
python whisper.py --input podcast.mp3 --model base --background
```
**输出**:
- `podcast.json` - 带时间戳的详细结果
- `podcast.srt` - 字幕格式
- `podcast.txt` - 纯文本
### 场景 2: 生成视频字幕
```bash
python whisper.py --input tutorial.mp4 --model small
```
**用途**:
- 为教程视频生成字幕
- 导出 SRT 文件到视频编辑软件
- 生成多语言字幕版本
### 场景 3: 会议录音转文字
```bash
python whisper.py --input meeting.mp3 --model base --output ./meetings
```
**输出示例**:
```
meetings/
├── meeting.json # 完整 JSON(含时间戳)
├── meeting.srt # 字幕格式
└── meeting.txt # 纯文本
```
### 场景 4: 批量处理
创建脚本 `batch_transcribe.sh`:
```bash
#!/bin/bash
OUTPUT_DIR="./transcripts"
mkdir -p "$OUTPUT_DIR"
for video in videos/*.mp4; do
echo "处理: $video"
python whisper.py --input "$video" --output "$OUTPUT_DIR" --model base
done
echo "批量处理完成!"
```
```bash
chmod +x batch_transcribe.sh
./batch_transcribe.sh
```
## 在 Workflow 中使用
### 知识库处理流程
在 `kb-workflow` 技能中,如果视频没有字幕:
```bash
# 步骤 4: 下载视频后
# 检查是否有字幕文件
if [ ! -f "${subtitle_file}" ]; then
echo "无字幕文件,使用 Whisper 生成..."
# 使用 Whisper 生成字幕(后台执行)
python /path/to/whisper.py \
--input "${video_path}" \
--model base \
--output "${content_dir}" \
--background
# 等待 Whisper 完成
sleep 5 # 等待后台任务启动
while ! [ -f "${content_dir}/$(basename ${video_path} .mp4).srt" ]; do
sleep 2
done
echo "Whisper 处理完成"
fi
```
## 输出文件使用
### JSON 格式
```python
import json
# 读取 JSON 结果
with open('video.json', 'r', encoding='utf-8') as f:
data = json.load(f)
# 访问分段
for segment in data['segments']:
print(f"[{segment['start']:.2f}-{segment['end']:.2f}] {segment['text']}")
```
### SRT 格式
```bash
# 直接在视频播放器中使用
mpv video.mp4 --sub-file=video.srt
# 或转换字幕格式
ffmpeg -i video.srt video.ass
```
### TXT 格式
```bash
# 搜索关键词
grep "重要信息" video.txt
# 统计字数
wc -m video.txt
```
## 性能优化建议
### 1. 根据内容选择模型
```bash
# 快速预览(短视频、测试)
python whisper.py --input video.mp4 --model tiny
# 日常使用(播客、会议)
python whisper.py --input video.mp4 --model base
# 高质量需求(正式内容、多语言)
python whisper.py --input video.mp4 --model small
```
### 2. 使用后台执行
```bash
# 处理长文件时使用后台模式
python whisper.py --input long_video.mp4 --model base --background
```
### 3. 指定语言提升准确度
```bash
# 如果知道内容语言,指定后准确度更高
python whisper.py --input video.mp4 --language zh
```
## 故障排查
### 问题 1: whisper.cpp 未找到
```bash
# 检查环境变量
echo $WHISPER_CPP_PATH
# 或配置
clawdbot skills config kb-whisper WHISPER_CPP_PATH "~/whisper.cpp"
```
### 问题 2: 模型未下载
```bash
cd ~/whisper.cpp
./models/download-ggml-model.sh base
```
### 问题 3: 音频格式不支持
```bash
# 转换格式
ffmpeg -i input.avi -acodec libmp3lame output.mp3
python whisper.py --input output.mp3
```
### 问题 4: 后台任务未完成
```bash
# 查看日志
cat video_whisper.log
# 检查进程
ps aux | grep whisper
```
## 集成到其他工具
### 集成到 Python 脚本
```python
import subprocess
import json
def transcribe_audio(audio_path, model='base'):
"""调用 whisper.py 转录音频"""
cmd = [
'python3', '/path/to/whisper.py',
'--input', audio_path,
'--model', model
]
result = subprocess.run(cmd, capture_output=True, text=True)
return json.loads(result.stdout)
# 使用
result = transcribe_audio('podcast.mp3', 'base')
print(f"识别到 {result['total_segments']} 个片段")
```
### 集成到 Shell 脚本
```bash
#!/bin/bash
transcribe() {
local file=$1
local model=${2:-base}
echo "转录: $file"
python3 whisper.py --input "$file" --model "$model" --background
}
# 批量处理
transcribe "video1.mp4"
transcribe "video2.mp4" "tiny"
```
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""
KB Whisper - 本地语音识别工具
基于 Whisper.cpp 的音视频转文字
功能:
- 自动将视频/音频转换为 16kHz WAV 格式
- 支持指定语言(默认中文)
- 输出标准 JSON 格式供 KB Workflow 使用
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import re
def parse_arguments():
"""解析命令行参数"""
parser = argparse.ArgumentParser(
description='KB Whisper - 本地语音识别工具',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python whisper.py --input video.mp4
python whisper.py --input video.mp4 --model base
python whisper.py --input video.mp4 --output ./output --background
"""
)
parser.add_argument(
'--input',
required=True,
help='输入音视频文件路径'
)
parser.add_argument(
'--model',
default='base',
choices=['tiny', 'base', 'small', 'medium', 'large'],
help='Whisper 模型类型 (默认: base)'
)
parser.add_argument(
'--output',
help='输出目录 (默认: 输入文件所在目录)'
)
parser.add_argument(
'--background',
action='store_true',
help='后台异步执行'
)
parser.add_argument(
'--language',
default='zh',
help='指定语言 (默认: zh),例如: en, zh, ja'
)
return parser.parse_args()
def get_whisper_cpp_path():
"""获取 whisper.cpp 路径"""
# 优先从环境变量获取
path = os.environ.get('WHISPER_CPP_PATH')
if path:
path = os.path.expanduser(path)
if os.path.exists(path):
return path
# 尝试常见位置
common_paths = [
os.path.expanduser('~/whisper.cpp'),
'/usr/local/bin/whisper',
'/opt/whisper.cpp',
]
for cp in common_paths:
if os.path.exists(cp):
return cp
raise FileNotFoundError(
"未找到 whisper.cpp。请设置环境变量 WHISPER_CPP_PATH "
"或安装到 ~/whisper.cpp\n"
"安装方法: git clone https://github.com/ggerganov/whisper.cpp.git ~/whisper.cpp && cd ~/whisper.cpp && make"
)
def check_model(whisper_path, model):
"""检查模型是否存在"""
model_path = os.path.join(whisper_path, 'models', f'ggml-{model}.bin')
if not os.path.exists(model_path):
raise FileNotFoundError(
f"模型文件不存在: {model_path}\n"
f"请运行以下命令下载模型:\n"
f"cd {whisper_path} && ./models/download-ggml-model.sh {model}"
)
return model_path
def convert_to_wav(input_path, output_dir):
"""将音视频转换为 16kHz WAV 格式
Whisper 需要 WAV 格式的输入文件。
"""
input_path = os.path.abspath(input_path)
# 如果已经是 WAV 且采样率正确,直接返回
if input_path.lower().endswith('.wav'):
try:
result = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries',
'stream=sample_rate', '-of', 'default=noprint_wrappers=1:nokey=1',
input_path],
capture_output=True, text=True
)
if result.returncode == 0 and int(result.stdout.strip()) == 16000:
print(f"输入文件已是 16kHz WAV 格式,直接使用")
return input_path
except Exception:
pass
# 转换
output_path = os.path.join(output_dir, f"{Path(input_path).stem}_audio.wav")
print(f"转换音频格式: {input_path} -> {output_path}")
try:
subprocess.run([
'ffmpeg', '-y', '-i', input_path,
'-acodec', 'pcm_s16le',
'-ar', '16000',
'-ac', '1',
'-loglevel', 'error',
output_path
], check=True, capture_output=True)
print("音频转换完成")
return output_path
except subprocess.CalledProcessError as e:
print(f"音频转换失败: {e}")
raise RuntimeError(f"无法转换音频格式: {e}")
except FileNotFoundError:
raise RuntimeError("未找到 ffmpeg,请安装: sudo apt-get install ffmpeg")
def run_whisper(whisper_path, audio_path, model, output_dir, language='zh'):
"""运行 Whisper 语音识别
Args:
whisper_path: whisper.cpp 目录
audio_path: WAV 音频文件路径
model: 模型名称
output_dir: 输出目录
language: 语言代码
"""
# 输出文件名(使用原始输入文件名,不是 audio.wav)
# 从 output_dir 和 audio_path 推断原始文件名
original_stem = Path(audio_path).stem.replace('_audio', '')
output_basename = original_stem
# 构建命令
cmd = [
os.path.join(whisper_path, 'whisper-cli'),
'-m', os.path.join(whisper_path, 'models', f'ggml-{model}.bin'),
'-f', audio_path,
'-of', os.path.join(output_dir, output_basename),
'-l', language,
'--output-txt',
'--output-srt',
'--output-json',
]
print(f"执行命令: {' '.join(cmd)}")
# 运行
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=whisper_path,
timeout=3600 # 1小时超时
)
if result.returncode != 0:
# 检查是否有警告但仍成功
if "error" in result.stderr.lower() and not os.path.exists(
os.path.join(output_dir, f"{output_basename}.txt")
):
raise RuntimeError(
f"Whisper 执行失败:\n"
f"标准输出: {result.stdout}\n"
f"错误输出: {result.stderr}"
)
return result
def parse_srt_for_duration(srt_file):
"""从 SRT 文件解析时长"""
if not os.path.exists(srt_file):
return 0
with open(srt_file, 'r', encoding='utf-8') as f:
content = f.read()
# 找到最后一个时间戳
pattern = r'(\d{2}:\d{2}:\d{2}),\d{3} --> \d{2}:\d{2}:\d{2},\d{3}'
matches = re.findall(pattern, content)
if not matches:
return 0
# 解析最后一个时间戳
last_time = matches[-1]
h, m, s = last_time.split(':')
return int(h) * 3600 + int(m) * 60 + int(s)
def create_workflow_json(input_file, output_dir, model, language, duration):
"""创建标准格式的 JSON 输出,供 KB Workflow 使用"""
input_path = Path(input_file)
basename = input_path.stem
txt_file = os.path.join(output_dir, f"{basename}.txt")
srt_file = os.path.join(output_dir, f"{basename}.srt")
json_file = os.path.join(output_dir, f"{basename}.json")
# 读取文本内容
text_content = ""
if os.path.exists(txt_file):
with open(txt_file, 'r', encoding='utf-8') as f:
text_content = f.read().strip()
# 解析 SRT 获取分段
segments = []
if os.path.exists(srt_file):
with open(srt_file, 'r', encoding='utf-8') as f:
content = f.read()
# SRT 格式: 序号 -> 时间 -> 文本
pattern = r'(\d+)\n(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})\n(.+?)(?=\n\n|\Z)'
matches = re.findall(pattern, content, re.DOTALL)
def parse_time(t):
h, m, s_ms = t.split(':')
s, ms = s_ms.split(',')
return float(h) * 3600 + float(m) * 60 + float(s) + float(ms) / 1000
for idx, (num, start, end, text) in enumerate(matches):
segments.append({
"id": idx,
"start": round(parse_time(start), 2),
"end": round(parse_time(end), 2),
"text": text.strip()
})
# 构建 JSON
workflow_json = {
"success": True,
"tool": "whisper",
"model": model,
"language": language,
"input_file": str(input_path),
"output_files": {
"txt": f"{basename}.txt",
"srt": f"{basename}.srt",
"json": f"{basename}.json"
},
"duration_seconds": duration,
"segments": segments,
"text": text_content
}
# 写入 JSON
with open(json_file, 'w', encoding='utf-8') as f:
json.dump(workflow_json, f, ensure_ascii=False, indent=2)
return workflow_json
def main():
"""主函数"""
args = parse_arguments()
# 检查输入文件
input_file = os.path.abspath(args.input)
if not os.path.exists(input_file):
print(f"错误: 输入文件不存在: {input_file}", file=sys.stderr)
sys.exit(1)
# 获取输出目录
if args.output:
output_dir = os.path.abspath(args.output)
else:
output_dir = os.path.dirname(input_file)
os.makedirs(output_dir, exist_ok=True)
# 获取 whisper.cpp 路径
try:
whisper_path = get_whisper_cpp_path()
print(f"✓ Whisper.cpp 路径: {whisper_path}")
except FileNotFoundError as e:
print(f"✗ 错误: {e}", file=sys.stderr)
sys.exit(1)
# 检查模型
try:
check_model(whisper_path, args.model)
print(f"✓ 使用模型: {args.model}")
except FileNotFoundError as e:
print(f"✗ 错误: {e}", file=sys.stderr)
sys.exit(1)
# 后台执行
if args.background:
print("启动后台处理...")
log_file = os.path.join(output_dir, f"{Path(input_file).stem}_whisper.log")
with open(log_file, 'w') as log:
subprocess.Popen(
[sys.executable, __file__,
'--input', input_file,
'--model', args.model,
'--output', output_dir,
'--language', args.language],
stdout=log,
stderr=log,
start_new_session=True
)
print(f"✓ 后台任务已启动")
print(f" 日志: {log_file}")
print(f" 输出: {output_dir}")
result = {
"success": True,
"background": True,
"status": "running",
"log_file": log_file,
"output_dir": output_dir
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return
# 前台执行
print(f"\n📁 处理文件: {input_file}")
print(f"📁 输出目录: {output_dir}")
try:
# 1. 转换为 WAV
print(f"\n🔄 步骤1: 转换音频格式...")
audio_path = convert_to_wav(input_file, output_dir)
# 2. 运行 Whisper
print(f"\n🎙️ 步骤2: 语音识别 (语言: {args.language})...")
run_whisper(whisper_path, audio_path, args.model, output_dir, args.language)
# 3. 清理临时 WAV 文件
if audio_path != input_file and audio_path.endswith('_audio.wav'):
try:
os.remove(audio_path)
print("✓ 清理临时文件")
except Exception:
pass
# 4. 获取时长并创建 JSON
print(f"\n📝 步骤3: 生成输出文件...")
basename = Path(input_file).stem
srt_file = os.path.join(output_dir, f"{basename}.srt")
duration = parse_srt_for_duration(srt_file)
workflow_json = create_workflow_json(
input_file, output_dir, args.model, args.language, duration
)
# 输出结果
print(f"\n✅ 处理完成!")
print(f" 时长: {duration} 秒")
print(f" 分段数: {len(workflow_json['segments'])}")
print(f"\n📄 输出文件:")
for fmt in ['txt', 'srt', 'json']:
fpath = os.path.join(output_dir, f"{basename}.{fmt}")
if os.path.exists(fpath):
size = os.path.getsize(fpath)
print(f" • {fmt.upper()}: {fpath} ({size:,} bytes)")
print(f"\n📋 Workflow JSON:")
print(json.dumps({
"success": workflow_json["success"],
"tool": "whisper",
"text_length": len(workflow_json["text"]),
"segments_count": len(workflow_json["segments"])
}, ensure_ascii=False, indent=2))
except Exception as e:
print(f"\n✗ 错误: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
+156 -42
View File
@@ -16,22 +16,51 @@ description: 当收到"添加到知识库 + 链接" 或"保存到知识库 + 链
## 环境变量 ## 环境变量
在执行 workflow 之前,必须检查环境变量是否已经设置。 ### 执行前必须检查
以下是必须的环境变量,如果缺失必须询问用户获得。没有全部设置完成之前禁止进行后续操作。
| 变量名 | 用途 | 示例 |
|--------|------|------|
| `KB_BASE_PATH` | 知识库根目录 | `~/work/kb`
| `AI_API_KEY` | AI深度分析 | `sk-xxx` |
| `AI_PROVIDER` | AI提供商 | `openai`, `zhipu`, `minimax` |
| `AI_MODEL` | AI模型 | `gpt-4` |
以下是可选的环境变量,询问用户是否要设置,允许暂时跳过 在执行 workflow 之前,必须检查环境变量是否已经设置。
**必须的环境变量**(缺失则报错退出):
配置方式:将变量写入 `~/.kb_env` 文件:
```bash
export AI_API_KEY="your-api-key"
export AI_PROVIDER="zhipu"
export AI_MODEL="glm-5"
export AI_BASE_URL="https://open.bigmodel.cn/api/coding/paas/v4"
```
**可选的环境变量**:
| 变量名 | 用途 | 示例 | | 变量名 | 用途 | 示例 |
|--------|------|------| |--------|------|------|
| `BILIBILI_COOKIE` | 下载会员视频 | `SESSDATA=xxx; bili_jct=xxx` | | `BILIBILI_COOKIE` | 下载会员视频 | `SESSDATA=xxx; bili_jct=xxx` |
| `KB_GIT_USER` | Git提交用户名 | `Your Name` | | `KB_GIT_USER` | Git提交用户名 | `Your Name` |
| `KB_GIT_EMAIL` | Git提交邮箱 | `your@email.com` | | `KB_GIT_EMAIL` | Git提交邮箱 | `your@email.com` |
### 检查命令
```bash
# 检查必须的环境变量
echo "AI_API_KEY: ${AI_API_KEY:0:10}..."
echo "AI_PROVIDER: $AI_PROVIDER"
echo "AI_MODEL: $AI_MODEL"
echo "AI_BASE_URL: $AI_BASE_URL"
# 验证方式1: 检查变量是否存在
if [ -z "$AI_API_KEY" ]; then
echo "❌ AI_API_KEY 未设置"
exit 1
fi
# 验证方式2: 执行测试调用
python3 -c "
import os
if not os.environ.get('AI_API_KEY'):
raise RuntimeError('AI_API_KEY 未设置')
print('✅ 环境变量检查通过')
"
```
## Workflow 执行方式 ## Workflow 执行方式
@@ -50,9 +79,45 @@ description: 当收到"添加到知识库 + 链接" 或"保存到知识库 + 链
当触发后,按照以下步骤执行: 当触发后,按照以下步骤执行:
### 步骤 1: 环境变量检查 ### 步骤 1: 环境变量检查
立刻告知用户,启用技能kb-workflow.
参考环境变量标题下内容执行。 **❌ 禁止跳过此步骤!必须先检查环境变量。**
如果必须的环境变量都没问题,继续执行下面的步骤。
```bash
# 0. 从 ~/.kb_env 加载环境变量(专门的知识库配置文件,不受 bashrc 交互式检查影响)
if [ -f ~/.kb_env ]; then
source ~/.kb_env
fi
# 1. 检查必须的环境变量是否存在
if [ -z "$AI_API_KEY" ] || [ -z "$AI_PROVIDER" ] || [ -z "$AI_MODEL" ] || [ -z "$AI_BASE_URL" ]; then
echo "❌ 错误:必须的环境变量未设置"
echo "请配置以下环境变量:"
echo " - AI_API_KEY"
echo " - AI_PROVIDER (zhipu/openai/minimax)"
echo " - AI_MODEL"
echo " - AI_BASE_URL"
exit 1
fi
echo "✅ 环境变量检查通过"
echo " AI_PROVIDER: $AI_PROVIDER"
echo " AI_MODEL: $AI_MODEL"
```
**如果检查不通过,必须**:
1. 报错提示用户
2. 退出,不执行后续步骤
3. 等待用户配置好环境变量后重新执行
## ⚠️ 严格失败处理原则
> **深度分析步骤必须成功,否则整个流程失败**
在执行工作流时,必须遵守以下原则:
1. **深度分析不可跳过**:kb-deep-analyzer 是必须成功执行的步骤
2. **失败即终止**:如果深度分析返回 `success: false`,必须立即停止并汇报失败
3. **禁止降级处理**:不能使用预设模板或简单摘要来"假装成功"
4. **每次执行都要使用 AI**:必须有真实的 AI API 调用记录
### 步骤 2: 链接解析 ### 步骤 2: 链接解析
@@ -94,42 +159,45 @@ python3 /path/to/zhihu_metadata.py --id "[ID]" --type question --output "${metad
1. 获取目录路径: 1. 获取目录路径:
```bash ```bash
python3 kb_workflow.py --content_downloads python3 kb_workflow.py --content_downloads
python3 kb_workflow.py --content_subtitles python3 kb_workflow.py --content
``` ```
2. 启动子代理调用对应平台的 content 技能: 2. 启动子代理调用对应平台的 content 技能:
```bash ```bash
# Bilibili 视频 # Bilibili 字幕下载(自动检测是否有官方字幕)
python3 /path/to/bilibili_content.py --id "[ID]" --type video --output [content_dir] python3 /path/to/bilibili_content.py --id "[ID]" --type subtitle-download --output [content_dir]
# Bilibili 字幕 # 返回 JSON 包含 has_subtitle 字段
python3 /path/to/bilibili_content.py --id "[ID]" --type subtitle --output [subtitles_dir]
``` ```
3. 解析输出 JSON,提取 video_filename 和 subtitle_filename(如有) 3. 解析输出 JSON:
- **has_subtitle = true**: 有官方字幕,直接进入步骤 6
- **has_subtitle = false**: 没有官方字幕,继续步骤 5
### 步骤 5: OCR 处理(禁止跳过) > ⚠️ **重要提示**:没有找到官方字幕是**正常的**!很多视频没有自动生成的字幕。不要因为没有字幕就停止工作流,继续执行 Whisper 步骤即可。
> ⚠️ **重要**:此步骤为耗时步骤,视频按帧截取和 OCR 识别需要较长时间。 ### 步骤 5: 语音识别转文字(仅当没有官方字幕时执行)
> - **严禁跳过**:即使等待时间较长,也必须等待 OCR 完成
> - **定时汇报**:每2-3分钟向用户汇报进度(处理帧数/预计剩余时间)
> - **超时时间**:30分钟
> - **耐心等待**:不要中途启动其他步骤,必须串行执行
如果步骤4没有获取到字幕,需要从视频截帧 OCR: > ⚠️ **重要**:此步骤仅在**没有官方字幕**时执行。如果有官方字幕,**跳过此步骤**。
此步骤使用 Whisper 进行语音识别:
- **严禁跳过**:必须等待识别完成
- **定时汇报**:每2-3分钟向用户汇报进度
- **超时时间**:30分钟
- **耐心等待**:不要中途启动其他步骤,必须串行执行
1. 获取目录路径: 1. 获取目录路径:
```bash ```bash
python3 kb_workflow.py --content python3 kb_workflow.py --content
``` ```
2. 启动子代理调用 kb-ocr-processor(传入完整路径): 2. 启动子代理调用 kb-whisper(传入视频路径):
```bash ```bash
# 必须传入 --output 完整路径 # 必须传入 --output 完整路径
python3 /path/to/ocr_processor.py --video [video_path] --output [content_dir]/ocr.json --merge python3 /path/to/whisper.py --input [video_path] --model base --output [content_dir]
``` ```
3. 等待 OCR 完成(定时汇报进度) 3. 等待 Whisper 完成(定时汇报进度)
4. 解析输出 JSON,生成 content 文件供步骤6使用 4. 解析输出 JSON,生成 content 文件供步骤6使用
@@ -142,12 +210,24 @@ python3 kb_workflow.py --analyzed
2. 准备 content JSON 文件(包含 subtitle/description) 2. 准备 content JSON 文件(包含 subtitle/description)
3. 启动子代理调用 kb-deep-analyzer: 3. 如果有官方字幕,使用官方字幕的 SRT 文件
4. 如果没有官方字幕,使用 Whisper 识别的结果
5. 启动子代理调用 kb-deep-analyzer(使用 g5 模型):
```bash ```bash
python3 /path/to/deep_analyzer.py --metadata [meta_path] --content [content_path] --output [analyzed_dir]/[filename].md python3 /path/to/deep_analyzer.py --metadata [meta_path] --content [content_path] --output [analyzed_dir]/[filename].md --provider zhipu --model glm-5
``` ```
4. 解析输出 JSON,获取分析结果 **环境变量设置**(在子代理中设置):
- `AI_PROVIDER=zhipu`
- `AI_MODEL=glm-5`
6. 解析输出 JSON,检查 `success` 字段:
> ⚠️ **深度分析失败 = 整个流程失败**
> - 如果 `success = true`:继续步骤 7(归档)
> - 如果 `success = false`:**立即终止流程**,向用户汇报失败原因
> - **严禁**继续执行后续步骤或使用降级内容"假装成功"
### 步骤 7: 归档 ### 步骤 7: 归档
@@ -167,14 +247,49 @@ python3 kb_workflow.py --archive-file "[分析报告文件路径]"
## 子技能清单 ## 子技能清单
| 步骤 | 子技能 | 命令示例 | 输出 | | 步骤 | 子技能 | 命令示例 | 输出 | 备注 |
|------|--------|----------|------| |------|--------|----------|------|------|
| 2 | kb-link-parser | `--url "BVxxx"` | `{p}_{id}.link.json` | | 2 | kb-link-parser | `--url "BVxxx"` | `{p}_{id}.link.json` | |
| 3 | kb-bilibili-metadata | `--id "BVxxx" --type video` | `{p}_{id}.meta.json` | | 3 | kb-bilibili-metadata | `--id "BVxxx" --type video` | `{p}_{id}.meta.json` | |
| 3 | kb-zhihu-metadata | `--id "xxx" --type question` | `{p}_{id}.meta.json` | | 3 | kb-zhihu-metadata | `--id "xxx" --type question` | `{p}_{id}.meta.json` | |
| 4 | kb-bilibili-content | `--id "BVxxx" --type video` | `{p}_{id}.{ext}` | | 4 | kb-bilibili-content | `--id "BVxxx" --type subtitle-download` | `{p}_{id}.srt` | 如果没有字幕是正常的 |
| 5 | kb-ocr-processor | `--video xxx.flv --output ocr.json` | `ocr.json`, `ocr.txt` | | 4 | kb-bilibili-content | `--id "BVxxx" --type video` | `{p}_{id}.mp4` | 如果需要下载视频 |
| 6 | kb-deep-analyzer | `--metadata x.meta.json --content x.json --output x.md` | `{p}_{id}.md` | | 5 | kb-whisper | `--input xxx.mp4 --model base` | `{p}_{id}.json`, `{p}_{id}.txt` | 仅当没有官方字幕时执行 |
| 6 | kb-deep-analyzer | `--metadata x.meta.json --content x.json --output x.md --provider zhipu --model glm-5` | `{p}_{id}.md` | **使用 g5 模型,必须成功** |
## 条件分支逻辑
```
步骤3: 获取元数据
↓
步骤4: 下载字幕 (--type subtitle-download)
↓
┌─────────────────────────────────────┐
│ has_subtitle = true? │
├─────────────────────────────────────┤
│ 是 → 直接进入步骤6(深度分析) │
│ 否 → 进入步骤5(Whisper语音识别) │
└─────────────────────────────────────┘
↓
步骤6: 深度分析
↓
┌─────────────────────────────────────┐
│ kb-deep-analyzer 执行结果 │
├─────────────────────────────────────┤
│ success = true → 步骤7(归档) │
│ success = false → ❌ 整个流程失败 │
└─────────────────────────────────────┘
```
> ⚠️ **重要:深度分析失败则整个流程失败**
> - 深度分析必须使用正确的 AI 模型(glm-4.7)完整分析字幕内容
> - 如果 `kb-deep-analyzer` 返回 `success: false`,**必须汇报失败**,不能继续执行后续步骤
> - 不能使用预设模板或降级内容来"假装成功"
> - 失败时必须向用户报告具体的错误原因
> ⚠️ **重要**:没有找到官方字幕是**正常的**!很多视频没有自动生成的字幕。
> - 如果 `has_subtitle = false`,继续执行 Whisper 步骤
> - 如果 `has_subtitle = true`,跳过 Whisper 步骤,直接使用官方字幕
## 目录结构 ## 目录结构
@@ -189,9 +304,8 @@ python3 kb_workflow.py --archive-file "[分析报告文件路径]"
├── content/ ├── content/
│ ├── downloads/ │ ├── downloads/
│ │ └── {platform}_{id}.mp4 │ │ └── {platform}_{id}.mp4
│ ├── subtitles/ │ ├── {platform}_{id}.txt # Whisper 识别的文本
│ │ └── {platform}_{id}.srt │ └── {platform}_{id}.json # Whisper 完整结果
│ └── {platform}_{id}.txt
└── analyzed/ └── analyzed/
└── {platform}_{id}.md └── {platform}_{id}.md
``` ```
+48 -18
View File
@@ -67,7 +67,6 @@ def ensure_directories(base_path: Path = None) -> Dict[str, Path]:
"metadata": base_path / "metadata", "metadata": base_path / "metadata",
"content": base_path / "content", "content": base_path / "content",
"content_downloads": base_path / "content" / "downloads", "content_downloads": base_path / "content" / "downloads",
"content_subtitles": base_path / "content" / "subtitles",
"analyzed": base_path / "analyzed", "analyzed": base_path / "analyzed",
"archive": base_path / "archive", "archive": base_path / "archive",
"archive_subscriptions": base_path / "archive" / "subscriptions", "archive_subscriptions": base_path / "archive" / "subscriptions",
@@ -143,33 +142,61 @@ def archive_and_git(
} }
try: try:
# 1. 创建归档目录 # 1. 检查并初始化 Git 仓库
git_dir = git_root / ".git"
if not git_dir.exists():
# 初始化仓库
subprocess.run(['git', 'init'], cwd=str(git_root), capture_output=True)
subprocess.run(['git', 'config', 'user.name', git_user], cwd=str(git_root))
subprocess.run(['git', 'config', 'user.email', git_email], cwd=str(git_root))
# 关联远端仓库
if git_remote:
subprocess.run(['git', 'remote', 'add', 'origin', git_remote], cwd=str(git_root), capture_output=True)
# 拉取远端分支
subprocess.run(['git', 'fetch', 'origin'], cwd=str(git_root), capture_output=True)
# 设置 main 分支并拉取
subprocess.run(['git', 'branch', '--set-upstream-to=origin/main', 'main'], cwd=str(git_root), capture_output=True)
subprocess.run(['git', 'pull', 'origin', 'main', '--rebase'], cwd=str(git_root), capture_output=True, timeout=120)
else:
# 已有仓库,检查远端是否配置
if git_remote:
remote_check = subprocess.run(['git', 'remote', '-v'], cwd=str(git_root), capture_output=True, text=True)
if 'origin' not in remote_check.stdout:
subprocess.run(['git', 'remote', 'add', 'origin', git_remote], cwd=str(git_root), capture_output=True)
# 拉取远端最新代码
subprocess.run(['git', 'fetch', 'origin'], cwd=str(git_root), capture_output=True)
# 尝试拉取 main 分支,如果失败则尝试 master
pull_result = subprocess.run(['git', 'pull', 'origin', 'main', '--rebase'], cwd=str(git_root), capture_output=True, timeout=120)
if pull_result.returncode != 0:
subprocess.run(['git', 'pull', 'origin', 'master', '--rebase'], cwd=str(git_root), capture_output=True, timeout=120)
# 2. 创建归档目录
date_str = datetime.now().strftime('%Y-%m-%d') date_str = datetime.now().strftime('%Y-%m-%d')
date_dir = base_path / "archive" / date_str date_dir = base_path / "archive" / date_str
date_dir.mkdir(parents=True, exist_ok=True) date_dir.mkdir(parents=True, exist_ok=True)
# 2. 复制分析报告 # 3. 复制分析报告
src_file = Path(analyzed_file) src_file = Path(analyzed_file)
archive_file = date_dir / src_file.name archive_file = date_dir / src_file.name
shutil.copy(src_file, archive_file) shutil.copy(src_file, archive_file)
result["archive_path"] = str(archive_file) result["archive_path"] = str(archive_file)
# 3. 复制元数据 # 4. 复制元数据
meta_file = base_path / "metadata" / f"{src_file.stem}.meta.json" meta_file = base_path / "metadata" / f"{src_file.stem}.meta.json"
if meta_file.exists(): if meta_file.exists():
shutil.copy(meta_file, date_dir / meta_file.name) shutil.copy(meta_file, date_dir / meta_file.name)
# 4. Git 操作 # 5. Git 提交并推送
git_dir = git_root / ".git"
if not git_dir.exists():
subprocess.run(['git', 'init'], cwd=str(git_root), capture_output=True)
if git_user:
subprocess.run(['git', 'config', 'user.name', git_user], cwd=str(git_root))
if git_email:
subprocess.run(['git', 'config', 'user.email', git_email], cwd=str(git_root))
if (git_root / ".git").exists(): if (git_root / ".git").exists():
# 添加所有变更
subprocess.run(['git', 'add', '.'], cwd=str(git_root), capture_output=True) subprocess.run(['git', 'add', '.'], cwd=str(git_root), capture_output=True)
# 检查是否有变更需要提交
status_result = subprocess.run(['git', 'status', '--porcelain'], cwd=str(git_root), capture_output=True, text=True)
if status_result.stdout.strip():
commit_msg = f"Archive: {src_file.name} - {datetime.now().isoformat()}" commit_msg = f"Archive: {src_file.name} - {datetime.now().isoformat()}"
commit_result = subprocess.run( commit_result = subprocess.run(
['git', 'commit', '-m', commit_msg], ['git', 'commit', '-m', commit_msg],
@@ -177,12 +204,19 @@ def archive_and_git(
) )
result["git_commit"] = commit_result.returncode == 0 result["git_commit"] = commit_result.returncode == 0
# 推送到远端
if git_remote and commit_result.returncode == 0: if git_remote and commit_result.returncode == 0:
push_result = subprocess.run(
['git', 'push', 'origin', 'main'],
cwd=str(git_root), capture_output=True, text=True, timeout=60
)
if push_result.returncode != 0:
# 如果 main 分支推送失败,尝试 master
push_result = subprocess.run( push_result = subprocess.run(
['git', 'push', 'origin', 'master'], ['git', 'push', 'origin', 'master'],
cwd=str(git_root), capture_output=True, text=True, timeout=60 cwd=str(git_root), capture_output=True, text=True, timeout=60
) )
result["git_push"] = {"success": push_result.returncode == 0} result["git_push"] = {"success": push_result.returncode == 0, "output": push_result.stdout}
result["success"] = True result["success"] = True
@@ -250,7 +284,6 @@ def main():
parser.add_argument('--metadata', action='store_true', help='获取元数据目录') parser.add_argument('--metadata', action='store_true', help='获取元数据目录')
parser.add_argument('--content', action='store_true', help='获取内容目录') parser.add_argument('--content', action='store_true', help='获取内容目录')
parser.add_argument('--content_downloads', action='store_true', help='获取主内容下载目录') parser.add_argument('--content_downloads', action='store_true', help='获取主内容下载目录')
parser.add_argument('--content_subtitles', action='store_true', help='获取字幕目录')
parser.add_argument('--analyzed', action='store_true', help='获取深度分析目录') parser.add_argument('--analyzed', action='store_true', help='获取深度分析目录')
parser.add_argument('--archive_dir', action='store_true', help='获取归档目录') parser.add_argument('--archive_dir', action='store_true', help='获取归档目录')
parser.add_argument('--archive_subscriptions', action='store_true', help='获取订阅信息目录') parser.add_argument('--archive_subscriptions', action='store_true', help='获取订阅信息目录')
@@ -280,9 +313,6 @@ def main():
elif args.content_downloads: elif args.content_downloads:
print(json.dumps({"path": str(dirs["content_downloads"])}, ensure_ascii=False, indent=2)) print(json.dumps({"path": str(dirs["content_downloads"])}, ensure_ascii=False, indent=2))
elif args.content_subtitles:
print(json.dumps({"path": str(dirs["content_subtitles"])}, ensure_ascii=False, indent=2))
elif args.analyzed: elif args.analyzed:
print(json.dumps({"path": str(dirs["analyzed"])}, ensure_ascii=False, indent=2)) print(json.dumps({"path": str(dirs["analyzed"])}, ensure_ascii=False, indent=2))