Compare commits
28 Commits
9dec66d2bf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 38baec8f04 | |||
| 6093a0a09d | |||
| 3bd9cb7793 | |||
| 0ff280b0e8 | |||
| 84df9931c1 | |||
| 4ce74541bf | |||
| 2d7d444803 | |||
| 99164f7ce6 | |||
| c276df2d37 | |||
| 518f8e4805 | |||
| c8ddc1e83f | |||
| 94370f225e | |||
| 520e30d02e | |||
| 9e2220404f | |||
| bdc84ec75c | |||
| a03223ac80 | |||
| b02cd969b8 | |||
| 3c422994b9 | |||
| b7b40b1ece | |||
| ffed35db0c | |||
| faaf79428b | |||
| 1fa90ac21c | |||
| 0da1c0fb59 | |||
| c24889ffa5 | |||
| 29dd236bb9 | |||
| 9102a973cd | |||
| 2adc3c1e8d | |||
| 98c689eaa5 |
@@ -22,13 +22,62 @@
|
||||
1. **使用技能过程中,遇到任何技能没有明确说明的问题,不要自己想办法绕过去,立刻停止,记录到SkillBug.md,并汇报问题。**
|
||||
2. **安装第三方技能时,优先安装在workspace/skills中**
|
||||
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`
|
||||
|
||||
@@ -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)
|
||||
- [ ] 修复凭证目录权限: chmod 700 /home/admin/.openclaw/credentials
|
||||
- [ ] 禁用控制UI不安全认证: gateway.controlUi.allowInsecureAuth=false
|
||||
@@ -17,14 +13,23 @@
|
||||
- [ ] 配置阿里云 TTS 语音
|
||||
- 需要提供:AccessKeyId, AccessKeySecret
|
||||
- 配置命令: `openclaw config set channels.wecom.voice.type "aliyun"`
|
||||
- [ ] 测试阿里 SenseVoice 方案效果
|
||||
- 评估语音识别效果对比 Whisper
|
||||
- [ ] 跑通 Git 管理知识库仓库操作
|
||||
- 验证 git add/commit/push 流程
|
||||
- 确保归档后自动提交到远程仓库
|
||||
- [ ] 使用多个对话会话路由到不同代理
|
||||
- 设计会话路由规则
|
||||
- 配置不同代理处理不同类型请求
|
||||
|
||||
### 字幕增强方案(待实现)
|
||||
- [ ] **视觉大模型方案** - 用API识别视频帧中的字幕
|
||||
- 可选模型:GPT-4V / Claude Vision / GLM-4V / 通义千问VL
|
||||
- 流程:视频帧图片 → 视觉大模型 → 识别文字 → 输出字幕
|
||||
- [ ] **语音识别(ASR)方案** - 用ASR模型提取音频字幕
|
||||
- 可选模型:Whisper / 讯飞ASR / 阿里云ASR
|
||||
- 流程:视频 → 提取音频 → ASR模型 → 字幕文件(.srt/.lrc)
|
||||
- [x] ~~编译 Whisper.cpp~~ - 已完成基础功能
|
||||
- Whisper 编译完成,集成到 KB Workflow
|
||||
- [ ] **集成 pyannote.audio 说话者分离**
|
||||
- 创建 kb-whisper-diarization 技能
|
||||
- 安装依赖: `pip install pyannote.audio torch`
|
||||
- 设置 HF_TOKEN 环境变量
|
||||
- 申请模型权限: https://huggingface.co/pyannote/speaker-diarization
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 2026-02-12
|
||||
|
||||
## GPG 密钥与推送问题
|
||||
|
||||
### GPG 密钥
|
||||
- **Key ID**: `ED1614CCD82E2DE577EE5F16F8C1E40E1AFE6757`
|
||||
- **邮箱**: starmen1265@openclaw
|
||||
- **状态**: 添加到 Gitea 后验证失败("no armored data found")
|
||||
|
||||
### Git 推送问题
|
||||
- **Gitea 服务器**: 192.168.1.22:8418
|
||||
- **SSH 端口**: 2222(连接被拒)
|
||||
- **HTTP Token 认证**: 失败
|
||||
- **当前状态**: 提交成功 (98c689e),未推送
|
||||
|
||||
### 解决方向
|
||||
1. 手动在终端执行 `git push`(支持交互式输入)
|
||||
2. 或配置 Gitea SSH 访问
|
||||
|
||||
---
|
||||
|
||||
## 知识库添加任务
|
||||
|
||||
### B站视频 - 新加坡分区漫游指南
|
||||
|
||||
**状态:** OCR处理中(后台运行)
|
||||
|
||||
| 步骤 | 状态 |
|
||||
|------|------|
|
||||
| 环境变量配置 | ✅ KB_BASE_PATH, zhipu API, git配置完成 |
|
||||
| 链接解析 | ✅ BV1Z9nXzUEhv |
|
||||
| 元数据提取 | ✅ 标题: 新加坡分区漫游指南... |
|
||||
| 视频下载 | ✅ 使用 yt-dlp 成功下载720P |
|
||||
| OCR处理 | 🔄 进行中(10分钟视频) |
|
||||
| 深度分析 | ⏸️ 等待OCR完成 |
|
||||
| 归档 | ⏸️ 等待 |
|
||||
|
||||
**技术发现:**
|
||||
- Cookie API下载失败(403)→ 使用 yt-dlp 成功
|
||||
- yt-dlp Cookie格式:Netscape格式或 --add-header
|
||||
|
||||
### 环境变量记录
|
||||
|
||||
- KB_BASE_PATH: ~/work/knowledge-base
|
||||
- API_PROVIDER: zhipu
|
||||
- API_MODEL: glm-4.7
|
||||
- KB_GIT_USER: starmen1265
|
||||
- KB_GIT_EMAIL: starmen1265@163.com
|
||||
@@ -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(-)
|
||||
```
|
||||
|
||||
是否需要做其他修改?
|
||||
@@ -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 分支自动兼容
|
||||
@@ -4,21 +4,34 @@
|
||||
|
||||
## Features
|
||||
|
||||
- Video download (multiple quality options)
|
||||
- Video download using **yt-dlp** (recommended)
|
||||
- Subtitle 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
|
||||
|
||||
```bash
|
||||
# Download video (传入 --output 指定完整路径)
|
||||
python bilibili_content.py --id "BV1xx411c7mD" --type video --quality 480 --output "/path/to/downloads/BV1xx.mp4"
|
||||
# Download video (720P default)
|
||||
python bilibili_content.py --id "BV1xx411c7mD" --type video --output "/path/to/downloads/"
|
||||
|
||||
# Extract subtitle (传入 --output 指定完整路径)
|
||||
python bilibili_content.py --id "BV1xx411c7mD" --type subtitle --output "/path/to/subtitles/BV1xx.srt"
|
||||
# Download video (1080P)
|
||||
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"
|
||||
```
|
||||
|
||||
@@ -28,15 +41,51 @@ python bilibili_content.py --id "cv43780186" --type article --output "/path/to/a
|
||||
{
|
||||
"success": true,
|
||||
"type": "video",
|
||||
"filename": "BV1xx.mp4",
|
||||
"filepath": "/path/to/downloads/BV1xx.mp4",
|
||||
"message": "下载完成"
|
||||
"filename": "video_title.mp4",
|
||||
"filepath": "/path/to/downloads/video_title.mp4",
|
||||
"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
|
||||
|
||||
```bash
|
||||
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)**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Bilibili Content Downloader - B站内容获取工具
|
||||
|
||||
功能:
|
||||
- download_video(bvid, quality=480) - 下载视频
|
||||
- download_video(bvid, quality=480) - 下载视频(使用 yt-dlp)
|
||||
- extract_subtitle(bvid) - 提取字幕
|
||||
- extract_article(cvid) - 获取专栏全文(使用Playwright)
|
||||
- 命令行参数支持
|
||||
@@ -16,8 +16,9 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import aiohttp
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
@@ -25,153 +26,318 @@ from typing import Optional, Dict, Any, List
|
||||
|
||||
class BilibiliContent:
|
||||
"""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',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'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):
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cookie = cookie
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""获取请求头(包含Cookie)"""
|
||||
headers = self.BROWSER_HEADERS.copy()
|
||||
if self.cookie:
|
||||
headers['Cookie'] = self.cookie
|
||||
return headers
|
||||
|
||||
async def download_video(self, bvid: str, quality: int = 32) -> Dict[str, Any]:
|
||||
"""下载B站视频"""
|
||||
|
||||
def _create_cookie_file(self) -> str:
|
||||
"""创建 Netscape 格式的 Cookie 文件"""
|
||||
if not self.cookie:
|
||||
return None
|
||||
|
||||
# 解析 Cookie 字符串
|
||||
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": {}}
|
||||
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(headers=self._get_headers()) as session:
|
||||
# 获取视频信息
|
||||
print(f"获取视频信息: {bvid}")
|
||||
async with session.get("https://api.bilibili.com/x/web-interface/view", params={'bvid': bvid}) as resp:
|
||||
data = await resp.json()
|
||||
|
||||
if data.get('code') != 0:
|
||||
result["message"] = f"获取失败: {data.get('message')}"
|
||||
return result
|
||||
|
||||
video_data = data.get('data', {})
|
||||
title = video_data.get('title', '未知标题')
|
||||
cid = video_data.get('cid')
|
||||
|
||||
result["metadata"] = {"title": title, "cid": cid, "bvid": bvid}
|
||||
print(f"标题: {title}")
|
||||
|
||||
# 获取播放地址
|
||||
print(f"获取视频流...")
|
||||
async with session.get("https://api.bilibili.com/x/player/playurl",
|
||||
params={'bvid': bvid, 'cid': cid, 'qn': quality}) as resp:
|
||||
play_data = await resp.json()
|
||||
|
||||
if play_data.get('code') != 0:
|
||||
result["message"] = f"获取失败: {play_data.get('message')}"
|
||||
return result
|
||||
|
||||
durl = play_data.get('data', {}).get('durl', [])
|
||||
if not durl:
|
||||
result["message"] = "未找到视频流"
|
||||
return result
|
||||
|
||||
video_url = durl[0].get('url')
|
||||
print(f"下载视频...")
|
||||
|
||||
# 下载
|
||||
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"
|
||||
|
||||
async with session.get(video_url) as resp:
|
||||
with open(output_path, 'wb') as f:
|
||||
async for chunk in resp.content.iter_chunked(81920):
|
||||
f.write(chunk)
|
||||
print(f"\r下载中...", end="", flush=True)
|
||||
|
||||
print(f"\n✅ 已保存: {output_path}")
|
||||
# 先获取视频信息
|
||||
print(f"获取视频信息: {bvid}")
|
||||
|
||||
cookie_file = self._create_cookie_file()
|
||||
url = f"https://www.bilibili.com/video/{bvid}"
|
||||
|
||||
try:
|
||||
# 获取视频标题(用于文件名)
|
||||
if cookie_file:
|
||||
cmd = ["yt-dlp", "--cookies", cookie_file, "--get-title", url]
|
||||
else:
|
||||
cmd = ["yt-dlp", "--get-title", url]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
title = stdout.decode('utf-8').strip()
|
||||
if not title or 'error' in title.lower():
|
||||
# 尝试备用方法
|
||||
title = f"video_{bvid}"
|
||||
except Exception as e:
|
||||
title = f"video_{bvid}"
|
||||
|
||||
# 构建 yt-dlp 命令
|
||||
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '丨')).strip()[:50]
|
||||
|
||||
# 选择视频格式
|
||||
if quality == 80:
|
||||
# 1080P
|
||||
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"
|
||||
|
||||
print(f"下载视频: {title}")
|
||||
print(f"格式: {format_str}")
|
||||
|
||||
output_template = str(self.output_dir / f"{safe_title}.%(ext)s")
|
||||
|
||||
cmd = [
|
||||
"yt-dlp",
|
||||
"-f", format_str,
|
||||
"-o", output_template,
|
||||
"--no-download-archive",
|
||||
"-q",
|
||||
url
|
||||
]
|
||||
|
||||
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["filepath"] = str(output_path)
|
||||
result["message"] = f"下载完成"
|
||||
|
||||
result["filepath"] = str(video_file)
|
||||
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:
|
||||
result["message"] = f"错误: {str(e)}"
|
||||
|
||||
print(f"❌ {result['message']}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
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:
|
||||
async with aiohttp.ClientSession(headers=self._get_headers()) as session:
|
||||
async with session.get("https://api.bilibili.com/x/player/pagelist", params={'bvid': bvid}) as resp:
|
||||
import aiohttp
|
||||
|
||||
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"] = "未找到字幕"
|
||||
return result
|
||||
|
||||
cid = pages[0].get('cid', '')
|
||||
|
||||
|
||||
pages = data.get('data', [])
|
||||
if not pages:
|
||||
result["message"] = "未找到分P(这是正常的)"
|
||||
# 仍然返回 success=True,让工作流继续
|
||||
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"] = "未找到字幕"
|
||||
return result
|
||||
|
||||
|
||||
subtitles = sub_data.get('data', {}).get('subtitle', {}).get('list', [])
|
||||
|
||||
if not subtitles:
|
||||
result["message"] = "没有官方字幕(这是正常的),将使用 Whisper 进行语音识别"
|
||||
# 仍然返回 success=True,让工作流继续
|
||||
result["success"] = True
|
||||
result["subtitles"] = [{"lan": s.get('lan'), "title": s.get('lan_doc')} for s in subtitles]
|
||||
result["message"] = f"找到 {len(subtitles)} 个字幕"
|
||||
print(result["message"])
|
||||
|
||||
return result
|
||||
|
||||
result["success"] = True
|
||||
result["has_subtitle"] = True
|
||||
result["subtitles"] = [{"lan": s.get('lan'), "title": s.get('lan_doc')} for s in subtitles]
|
||||
result["message"] = f"✅ 找到 {len(subtitles)} 个字幕,将使用官方字幕进行深度分析"
|
||||
print(result["message"])
|
||||
|
||||
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
|
||||
|
||||
async def extract_article(self, cvid: str) -> Dict[str, Any]:
|
||||
"""获取专栏全文(使用Playwright异步API)"""
|
||||
result = {"success": False, "cvid": cvid, "title": "", "content": "", "message": ""}
|
||||
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
|
||||
cvid_clean = cvid.replace('cv', '')
|
||||
url = f"https://www.bilibili.com/read/cv{cvid_clean}"
|
||||
print(f"获取专栏: {url}")
|
||||
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
|
||||
|
||||
await page.goto(url, wait_until="networkidle")
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
# 提取标题
|
||||
title_elem = await page.query_selector('h1')
|
||||
if title_elem:
|
||||
result["title"] = (await title_elem.inner_text()).strip()
|
||||
print(f"标题: {result['title'][:50]}...")
|
||||
|
||||
|
||||
# 提取正文内容
|
||||
content_elem = await page.query_selector('.read-article-holder')
|
||||
if content_elem:
|
||||
@@ -183,45 +349,49 @@ class BilibiliContent:
|
||||
print(f"内容长度: {len(result['content'])} 字符")
|
||||
else:
|
||||
result["message"] = "未找到专栏内容"
|
||||
|
||||
|
||||
await browser.close()
|
||||
|
||||
|
||||
if result["success"]:
|
||||
result["message"] = f"✅ 获取成功: {result['title'][:30]}..."
|
||||
else:
|
||||
result["message"] = f"获取失败: {result.get('message', '未知错误')}"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
result["message"] = f"错误: {str(e)}"
|
||||
print(f"错误: {e}")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description='Bilibili Content Downloader')
|
||||
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('--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()
|
||||
|
||||
|
||||
cookie = os.environ.get('BILIBILI_COOKIE')
|
||||
downloader = BilibiliContent(output_dir=args.output, cookie=cookie)
|
||||
|
||||
|
||||
if args.type == 'video':
|
||||
result = await downloader.download_video(args.id, quality=args.quality)
|
||||
elif args.type == 'subtitle':
|
||||
result = await downloader.extract_subtitle(args.id)
|
||||
elif args.type == 'subtitle-download':
|
||||
# 下载并转换字幕为 SRT 格式
|
||||
result = await downloader.download_subtitle(args.id)
|
||||
else:
|
||||
result = await downloader.extract_article(args.id.replace('cv', ''))
|
||||
|
||||
|
||||
# 统一输出格式
|
||||
if result.get("success"):
|
||||
output = {
|
||||
"success": True,
|
||||
"type": args.type,
|
||||
"has_subtitle": result.get("has_subtitle", False),
|
||||
"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,
|
||||
"message": result.get('message', '处理成功')
|
||||
@@ -232,7 +402,7 @@ async def main():
|
||||
"error": result.get('message', '处理失败'),
|
||||
"detail": f"ID: {args.id}, 类型: {args.type}"
|
||||
}
|
||||
|
||||
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
return result
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# 分析内容
|
||||
python deep_analyzer.py --metadata meta.json --content content.json --output analysis.md
|
||||
# 分析内容(推荐:使用 -u 参数强制无缓冲输出)
|
||||
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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
**必须设置以下环境变量**:
|
||||
|
||||
```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
|
||||
|
||||
支持两种元数据格式:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
# FunASR 模型缓存目录
|
||||
FUNASR_CACHE_DIR=~/.cache/modelscope/hub
|
||||
@@ -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 许可证。
|
||||
@@ -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 等常见格式
|
||||
- **首次运行**: 首次运行时会自动下载模型文件,需要稳定的网络连接
|
||||
Executable
+313
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
# KB Whisper 环境变量配置示例
|
||||
# 复制此文件为 .env 并填写实际值
|
||||
|
||||
# Whisper.cpp 安装路径
|
||||
WHISPER_CPP_PATH=~/whisper.cpp
|
||||
@@ -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 许可证。
|
||||
@@ -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 等常见格式
|
||||
@@ -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"
|
||||
```
|
||||
Executable
+403
@@ -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
@@ -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` |
|
||||
| `KB_GIT_USER` | Git提交用户名 | `Your Name` |
|
||||
| `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 执行方式
|
||||
|
||||
@@ -50,9 +79,45 @@ description: 当收到"添加到知识库 + 链接" 或"保存到知识库 + 链
|
||||
当触发后,按照以下步骤执行:
|
||||
|
||||
### 步骤 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: 链接解析
|
||||
|
||||
@@ -94,42 +159,45 @@ python3 /path/to/zhihu_metadata.py --id "[ID]" --type question --output "${metad
|
||||
1. 获取目录路径:
|
||||
```bash
|
||||
python3 kb_workflow.py --content_downloads
|
||||
python3 kb_workflow.py --content_subtitles
|
||||
python3 kb_workflow.py --content
|
||||
```
|
||||
|
||||
2. 启动子代理调用对应平台的 content 技能:
|
||||
```bash
|
||||
# Bilibili 视频
|
||||
python3 /path/to/bilibili_content.py --id "[ID]" --type video --output [content_dir]
|
||||
# Bilibili 字幕下载(自动检测是否有官方字幕)
|
||||
python3 /path/to/bilibili_content.py --id "[ID]" --type subtitle-download --output [content_dir]
|
||||
|
||||
# Bilibili 字幕
|
||||
python3 /path/to/bilibili_content.py --id "[ID]" --type subtitle --output [subtitles_dir]
|
||||
# 返回 JSON 包含 has_subtitle 字段
|
||||
```
|
||||
|
||||
3. 解析输出 JSON,提取 video_filename 和 subtitle_filename(如有)
|
||||
3. 解析输出 JSON:
|
||||
- **has_subtitle = true**: 有官方字幕,直接进入步骤 6
|
||||
- **has_subtitle = false**: 没有官方字幕,继续步骤 5
|
||||
|
||||
### 步骤 5: OCR 处理(禁止跳过)
|
||||
> ⚠️ **重要提示**:没有找到官方字幕是**正常的**!很多视频没有自动生成的字幕。不要因为没有字幕就停止工作流,继续执行 Whisper 步骤即可。
|
||||
|
||||
> ⚠️ **重要**:此步骤为耗时步骤,视频按帧截取和 OCR 识别需要较长时间。
|
||||
> - **严禁跳过**:即使等待时间较长,也必须等待 OCR 完成
|
||||
> - **定时汇报**:每2-3分钟向用户汇报进度(处理帧数/预计剩余时间)
|
||||
> - **超时时间**:30分钟
|
||||
> - **耐心等待**:不要中途启动其他步骤,必须串行执行
|
||||
### 步骤 5: 语音识别转文字(仅当没有官方字幕时执行)
|
||||
|
||||
如果步骤4没有获取到字幕,需要从视频截帧 OCR:
|
||||
> ⚠️ **重要**:此步骤仅在**没有官方字幕**时执行。如果有官方字幕,**跳过此步骤**。
|
||||
|
||||
此步骤使用 Whisper 进行语音识别:
|
||||
- **严禁跳过**:必须等待识别完成
|
||||
- **定时汇报**:每2-3分钟向用户汇报进度
|
||||
- **超时时间**:30分钟
|
||||
- **耐心等待**:不要中途启动其他步骤,必须串行执行
|
||||
|
||||
1. 获取目录路径:
|
||||
```bash
|
||||
python3 kb_workflow.py --content
|
||||
```
|
||||
|
||||
2. 启动子代理调用 kb-ocr-processor(传入完整路径):
|
||||
2. 启动子代理调用 kb-whisper(传入视频路径):
|
||||
```bash
|
||||
# 必须传入 --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使用
|
||||
|
||||
@@ -142,12 +210,24 @@ python3 kb_workflow.py --analyzed
|
||||
|
||||
2. 准备 content JSON 文件(包含 subtitle/description)
|
||||
|
||||
3. 启动子代理调用 kb-deep-analyzer:
|
||||
3. 如果有官方字幕,使用官方字幕的 SRT 文件
|
||||
4. 如果没有官方字幕,使用 Whisper 识别的结果
|
||||
|
||||
5. 启动子代理调用 kb-deep-analyzer(使用 g5 模型):
|
||||
```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: 归档
|
||||
|
||||
@@ -167,14 +247,49 @@ python3 kb_workflow.py --archive-file "[分析报告文件路径]"
|
||||
|
||||
## 子技能清单
|
||||
|
||||
| 步骤 | 子技能 | 命令示例 | 输出 |
|
||||
|------|--------|----------|------|
|
||||
| 2 | kb-link-parser | `--url "BVxxx"` | `{p}_{id}.link.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` |
|
||||
| 4 | kb-bilibili-content | `--id "BVxxx" --type video` | `{p}_{id}.{ext}` |
|
||||
| 5 | kb-ocr-processor | `--video xxx.flv --output ocr.json` | `ocr.json`, `ocr.txt` |
|
||||
| 6 | kb-deep-analyzer | `--metadata x.meta.json --content x.json --output x.md` | `{p}_{id}.md` |
|
||||
| 步骤 | 子技能 | 命令示例 | 输出 | 备注 |
|
||||
|------|--------|----------|------|------|
|
||||
| 2 | kb-link-parser | `--url "BVxxx"` | `{p}_{id}.link.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` | |
|
||||
| 4 | kb-bilibili-content | `--id "BVxxx" --type subtitle-download` | `{p}_{id}.srt` | 如果没有字幕是正常的 |
|
||||
| 4 | kb-bilibili-content | `--id "BVxxx" --type video` | `{p}_{id}.mp4` | 如果需要下载视频 |
|
||||
| 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/
|
||||
│ ├── downloads/
|
||||
│ │ └── {platform}_{id}.mp4
|
||||
│ ├── subtitles/
|
||||
│ │ └── {platform}_{id}.srt
|
||||
│ └── {platform}_{id}.txt
|
||||
│ ├── {platform}_{id}.txt # Whisper 识别的文本
|
||||
│ └── {platform}_{id}.json # Whisper 完整结果
|
||||
└── analyzed/
|
||||
└── {platform}_{id}.md
|
||||
```
|
||||
|
||||
@@ -70,7 +70,32 @@ generate_summary(...) # 生成汇报
|
||||
|
||||
---
|
||||
|
||||
## 待办
|
||||
### 2026-02-12: B站视频下载失败 - 403 Forbidden
|
||||
|
||||
- [ ] 测试 Prompt-based 模式是否正常工作
|
||||
- [ ] 验证主代理能否正确执行 SKILL.md 中的步骤
|
||||
**问题:** 使用 kb-bilibili-content 下载视频时返回 403 Forbidden 错误。
|
||||
|
||||
**原因:**
|
||||
1. 视频可能需要B站会员权限
|
||||
2. 或者需要登录cookie才能下载
|
||||
3. 当前未配置 `BILIBILI_COOKIE` 环境变量
|
||||
|
||||
**复现场景:**
|
||||
- 链接:https://www.bilibili.com/video/BV1Z9nXzUEhv
|
||||
- 下载命令:`python3 bilibili_content.py --id "BV1Z9nXzUEhv" --type video`
|
||||
- 结果:返回403错误页面,文件只有548字节
|
||||
|
||||
**影响:** 无法进行后续的OCR处理和深度分析
|
||||
|
||||
**建议解决方案:**
|
||||
1. 配置 `BILIBILI_COOKIE` 环境变量
|
||||
2. 或提供cookies文件路径
|
||||
3. 或跳过OCR步骤,使用元数据+简介进行深度分析
|
||||
|
||||
**当前进度:**
|
||||
- ✅ 步骤1:环境变量检查完成
|
||||
- ✅ 步骤2:链接解析成功(bilibili_BV1Z9nXzUEhv)
|
||||
- ✅ 步骤3:元数据提取成功(标题:新加坡分区漫游指南...)
|
||||
- ❌ 步骤4:视频下载失败(403)
|
||||
- ⏸️ 步骤5:OCR处理(阻塞)
|
||||
- ⏸️ 步骤6:深度分析(阻塞)
|
||||
- ⏸️ 步骤7:归档(阻塞)
|
||||
|
||||
@@ -67,7 +67,6 @@ def ensure_directories(base_path: Path = None) -> Dict[str, Path]:
|
||||
"metadata": base_path / "metadata",
|
||||
"content": base_path / "content",
|
||||
"content_downloads": base_path / "content" / "downloads",
|
||||
"content_subtitles": base_path / "content" / "subtitles",
|
||||
"analyzed": base_path / "analyzed",
|
||||
"archive": base_path / "archive",
|
||||
"archive_subscriptions": base_path / "archive" / "subscriptions",
|
||||
@@ -143,46 +142,81 @@ def archive_and_git(
|
||||
}
|
||||
|
||||
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_dir = base_path / "archive" / date_str
|
||||
date_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 2. 复制分析报告
|
||||
# 3. 复制分析报告
|
||||
src_file = Path(analyzed_file)
|
||||
archive_file = date_dir / src_file.name
|
||||
shutil.copy(src_file, archive_file)
|
||||
result["archive_path"] = str(archive_file)
|
||||
|
||||
# 3. 复制元数据
|
||||
# 4. 复制元数据
|
||||
meta_file = base_path / "metadata" / f"{src_file.stem}.meta.json"
|
||||
if meta_file.exists():
|
||||
shutil.copy(meta_file, date_dir / meta_file.name)
|
||||
|
||||
# 4. 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))
|
||||
|
||||
# 5. Git 提交并推送
|
||||
if (git_root / ".git").exists():
|
||||
# 添加所有变更
|
||||
subprocess.run(['git', 'add', '.'], cwd=str(git_root), capture_output=True)
|
||||
commit_msg = f"Archive: {src_file.name} - {datetime.now().isoformat()}"
|
||||
commit_result = subprocess.run(
|
||||
['git', 'commit', '-m', commit_msg],
|
||||
cwd=str(git_root), capture_output=True, text=True
|
||||
)
|
||||
result["git_commit"] = commit_result.returncode == 0
|
||||
|
||||
if git_remote and commit_result.returncode == 0:
|
||||
push_result = subprocess.run(
|
||||
['git', 'push', 'origin', 'master'],
|
||||
cwd=str(git_root), capture_output=True, text=True, timeout=60
|
||||
# 检查是否有变更需要提交
|
||||
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_result = subprocess.run(
|
||||
['git', 'commit', '-m', commit_msg],
|
||||
cwd=str(git_root), capture_output=True, text=True
|
||||
)
|
||||
result["git_push"] = {"success": push_result.returncode == 0}
|
||||
result["git_commit"] = 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(
|
||||
['git', 'push', 'origin', 'master'],
|
||||
cwd=str(git_root), capture_output=True, text=True, timeout=60
|
||||
)
|
||||
result["git_push"] = {"success": push_result.returncode == 0, "output": push_result.stdout}
|
||||
|
||||
result["success"] = True
|
||||
|
||||
@@ -250,7 +284,6 @@ def main():
|
||||
parser.add_argument('--metadata', 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_subtitles', 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_subscriptions', action='store_true', help='获取订阅信息目录')
|
||||
@@ -280,9 +313,6 @@ def main():
|
||||
elif args.content_downloads:
|
||||
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:
|
||||
print(json.dumps({"path": str(dirs["analyzed"])}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user