添加技能模块
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# KB Bilibili Content Skill
|
||||
|
||||
**kb-bilibili-content** - Download Bilibili videos and extract content.
|
||||
|
||||
## Features
|
||||
|
||||
- Video download using **yt-dlp** (recommended)
|
||||
- Subtitle extraction
|
||||
- Article content extraction
|
||||
- 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 (720P default)
|
||||
python bilibili_content.py --id "BV1xx411c7mD" --type video --output "/path/to/downloads/"
|
||||
|
||||
# Download video (1080P)
|
||||
python bilibili_content.py --id "BV1xx411c7mD" --type video --quality 80 --output "/path/to/downloads/"
|
||||
|
||||
# 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"
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"type": "video",
|
||||
"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 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)**
|
||||
Binary file not shown.
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Bilibili Content Downloader - B站内容获取工具
|
||||
|
||||
功能:
|
||||
- download_video(bvid, quality=480) - 下载视频(使用 yt-dlp)
|
||||
- extract_subtitle(bvid) - 提取字幕
|
||||
- extract_article(cvid) - 获取专栏全文(使用Playwright)
|
||||
- 命令行参数支持
|
||||
- JSON 输出
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class BilibiliContent:
|
||||
"""B站内容获取器"""
|
||||
|
||||
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 _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:
|
||||
# 先获取视频信息
|
||||
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(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]:
|
||||
"""
|
||||
提取字幕
|
||||
|
||||
注意:没有找到官方字幕是正常的!很多视频没有自动生成的字幕。
|
||||
返回 success=True 即使没有字幕,让工作流继续执行后续步骤(如 Whisper)。
|
||||
"""
|
||||
result = {"success": False, "bvid": bvid, "subtitles": [], "has_subtitle": False, "message": ""}
|
||||
|
||||
try:
|
||||
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"] = "未找到分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"] = "没有官方字幕(这是正常的),将使用 Whisper 进行语音识别"
|
||||
# 仍然返回 success=True,让工作流继续
|
||||
result["success"] = True
|
||||
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"获取字幕出错(这是正常的),将使用 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:
|
||||
html_content = await content_elem.inner_html()
|
||||
text_content = await content_elem.inner_text()
|
||||
result["content"] = text_content.strip()
|
||||
result["html_content"] = html_content.strip()
|
||||
result["success"] = bool(result["title"] and result["content"])
|
||||
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', 'subtitle-download', 'article'], help='内容类型')
|
||||
parser.add_argument('--output', '-o', default='./output', help='输出目录')
|
||||
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', '处理成功')
|
||||
}
|
||||
else:
|
||||
output = {
|
||||
"success": False,
|
||||
"error": result.get('message', '处理失败'),
|
||||
"detail": f"ID: {args.id}, 类型: {args.type}"
|
||||
}
|
||||
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
result = asyncio.run(main())
|
||||
sys.exit(0 if result.get("success") else 1)
|
||||
except KeyboardInterrupt:
|
||||
print(json.dumps({"success": False, "error": "用户取消"}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
B站视频提取脚本
|
||||
提取视频信息、统计数据、字幕并生成Markdown笔记
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
# 添加skills路径
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'skills', 'obsidian-kb'))
|
||||
|
||||
from src.extractors.bilibili_extractor import BilibiliExtractor
|
||||
from src.config import get_config
|
||||
|
||||
|
||||
def format_number(num):
|
||||
"""格式化数字"""
|
||||
if num >= 100000000:
|
||||
return f"{num / 100000000:.1f}亿"
|
||||
elif num >= 10000:
|
||||
return f"{num / 10000:.1f}万"
|
||||
else:
|
||||
return f"{num:,}"
|
||||
|
||||
|
||||
def generate_markdown(result, bvid):
|
||||
"""生成Markdown笔记"""
|
||||
metadata = result['metadata']
|
||||
stats = result['stats']
|
||||
description = result['description']
|
||||
subtitle = result['subtitle']
|
||||
|
||||
# 获取当前时间
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
md = f"""# {metadata['title']}
|
||||
|
||||
> **来源**: [B站](https://www.bilibili.com/video/{bvid})
|
||||
> **作者**: {metadata['author']}
|
||||
> **发布时间**: {metadata['pubtime']}
|
||||
> **提取时间**: {now}
|
||||
> **时长**: {metadata['duration']}
|
||||
|
||||
## 📊 统计数据
|
||||
|
||||
| 指标 | 数量 |
|
||||
|------|------|
|
||||
| 👀 播放 | {format_number(stats['view'])} |
|
||||
| 💬 弹幕 | {format_number(stats['danmaku'])} |
|
||||
| 📝 评论 | {format_number(stats['reply'])} |
|
||||
| ⭐ 收藏 | {format_number(stats['favorite'])} |
|
||||
| 🪙 投币 | {format_number(stats['coin'])} |
|
||||
| 🔗 分享 | {format_number(stats['share'])} |
|
||||
| 👍 点赞 | {format_number(stats['like'])} |
|
||||
|
||||
## 📝 视频简介
|
||||
|
||||
{description if description else '(暂无简介)'}
|
||||
|
||||
## 📌 标签
|
||||
|
||||
{metadata['tags'] if metadata['tags'] else '(无标签)'}
|
||||
|
||||
## 🎬 字幕
|
||||
|
||||
{subtitle if subtitle else '(无字幕)'}
|
||||
|
||||
---
|
||||
|
||||
**元数据**:
|
||||
- BV号: `{metadata['bvid']}`
|
||||
- AV号: `{metadata['aid']}`
|
||||
- UP主ID: `{metadata['mid']}`
|
||||
"""
|
||||
|
||||
return md
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# BV号
|
||||
bvid = "BV1N5foBjE5K" # 从 https://b23.tv/btMGoOi 解析得到
|
||||
|
||||
print(f"开始提取B站视频: {bvid}")
|
||||
print("=" * 60)
|
||||
|
||||
# 获取配置
|
||||
try:
|
||||
config = get_config()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 获取配置失败,使用默认配置: {e}")
|
||||
config = None
|
||||
|
||||
# 创建提取器
|
||||
extractor = BilibiliExtractor(config=config)
|
||||
|
||||
# 提取视频信息
|
||||
try:
|
||||
result = extractor.extract(bvid)
|
||||
|
||||
print("\n✅ 提取成功!\n")
|
||||
print("=" * 60)
|
||||
print("基本信息:")
|
||||
print("=" * 60)
|
||||
print(f"标题: {result['metadata']['title']}")
|
||||
print(f"作者: {result['metadata']['author']}")
|
||||
print(f"时长: {result['metadata']['duration']}")
|
||||
print(f"发布: {result['metadata']['pubtime']}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("统计数据:")
|
||||
print("=" * 60)
|
||||
print(f"播放: {format_number(result['stats']['view'])}")
|
||||
print(f"弹幕: {format_number(result['stats']['danmaku'])}")
|
||||
print(f"评论: {format_number(result['stats']['reply'])}")
|
||||
print(f"收藏: {format_number(result['stats']['favorite'])}")
|
||||
print(f"投币: {format_number(result['stats']['coin'])}")
|
||||
print(f"点赞: {format_number(result['stats']['like'])}")
|
||||
|
||||
# 生成 Markdown 笔记
|
||||
md_content = generate_markdown(result, bvid)
|
||||
|
||||
# 确保 notes 目录存在
|
||||
notes_dir = "/home/admin/.openclaw/workspace/notes"
|
||||
os.makedirs(notes_dir, exist_ok=True)
|
||||
|
||||
# 生成文件名(使用BV号作为文件名)
|
||||
filename = f"{bvid}.md"
|
||||
filepath = os.path.join(notes_dir, filename)
|
||||
|
||||
# 写入文件
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(md_content)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"✅ Markdown笔记已生成: {filepath}")
|
||||
print("=" * 60)
|
||||
|
||||
# 返回笔记内容
|
||||
return md_content
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 提取失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
使用旧版 API 获取并下载视频流
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async def download_video_stream(bvid, output_dir="/tmp/bilibili_downloads"):
|
||||
"""下载视频流"""
|
||||
url = f"https://www.bilibili.com/video/{bvid}"
|
||||
|
||||
# 创建输出目录
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
|
||||
print(f"正在下载视频: {url}")
|
||||
print("=" * 80)
|
||||
|
||||
# 首先获取 aid 和 cid
|
||||
print("\n1. 获取视频信息...")
|
||||
try:
|
||||
api_url = "https://api.bilibili.com/x/web-interface/view"
|
||||
response = await page.request.get(api_url, params={'bvid': bvid})
|
||||
data = await response.json()
|
||||
|
||||
if data.get('code') != 0:
|
||||
print(f" ❌ 失败: {data.get('message')}")
|
||||
await browser.close()
|
||||
return None
|
||||
|
||||
video_data = data.get('data', {})
|
||||
title = video_data.get('title')
|
||||
aid = video_data.get('aid')
|
||||
cid = video_data.get('cid')
|
||||
duration = video_data.get('duration')
|
||||
print(f" 标题: {title}")
|
||||
print(f" 时长: {duration}秒")
|
||||
except Exception as e:
|
||||
print(f" ❌ 异常: {e}")
|
||||
await browser.close()
|
||||
return None
|
||||
|
||||
# 获取视频流 URL
|
||||
print("\n2. 获取视频流 URL...")
|
||||
try:
|
||||
playurl_api = "https://api.bilibili.com/x/player/playurl"
|
||||
params = {
|
||||
'bvid': bvid,
|
||||
'cid': cid,
|
||||
'qn': 80, # 1080P
|
||||
'otype': 'json',
|
||||
'fnver': 0,
|
||||
'fnval': 0, # FLV 格式
|
||||
}
|
||||
|
||||
playurl_response = await page.request.get(playurl_api, params=params)
|
||||
|
||||
if not playurl_response.ok:
|
||||
print(f" ❌ HTTP 错误: {playurl_response.status}")
|
||||
await browser.close()
|
||||
return None
|
||||
|
||||
playurl_data = await playurl_response.json()
|
||||
|
||||
if playurl_data.get('code') != 0:
|
||||
print(f" ❌ API 错误: {playurl_data.get('message')}")
|
||||
await browser.close()
|
||||
return None
|
||||
|
||||
play_data = playurl_data.get('data', {})
|
||||
|
||||
# 检查支持的清晰度
|
||||
print(f"\n 支持的清晰度:")
|
||||
for i, (q, desc) in enumerate(zip(play_data.get('accept_quality', []), play_data.get('accept_description', []))):
|
||||
current = " (当前)" if q == play_data.get('quality') else ""
|
||||
print(f" {desc} ({q}){current}")
|
||||
|
||||
# 获取视频 URL
|
||||
durl = play_data.get('durl', [])
|
||||
if not durl:
|
||||
print(f" ❌ 没有找到视频流")
|
||||
await browser.close()
|
||||
return None
|
||||
|
||||
print(f"\n 找到 {len(durl)} 个视频分段")
|
||||
video_urls = []
|
||||
for i, segment in enumerate(durl):
|
||||
segment_url = segment.get('url')
|
||||
segment_size = segment.get('size', 0)
|
||||
video_urls.append(segment_url)
|
||||
print(f" 分段 {i+1}: {segment_size / 1024 / 1024:.2f}MB")
|
||||
print(f" {segment_url[:100]}...")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 异常: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await browser.close()
|
||||
return None
|
||||
|
||||
# 下载视频
|
||||
print(f"\n3. 开始下载视频...")
|
||||
all_segments = []
|
||||
total_size = 0
|
||||
|
||||
for i, video_url in enumerate(video_urls, 1):
|
||||
print(f"\n 下载分段 {i}/{len(video_urls)}...")
|
||||
|
||||
try:
|
||||
# 添加必要的请求头
|
||||
headers = {
|
||||
'Referer': 'https://www.bilibili.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
}
|
||||
|
||||
# 下载分段
|
||||
segment_response = await page.request.get(
|
||||
video_url,
|
||||
headers=headers,
|
||||
timeout=60000
|
||||
)
|
||||
|
||||
if not segment_response.ok:
|
||||
print(f" ❌ 下载失败: {segment_response.status}")
|
||||
continue
|
||||
|
||||
segment_data = await segment_response.body()
|
||||
segment_size = len(segment_data)
|
||||
total_size += segment_size
|
||||
all_segments.append(segment_data)
|
||||
|
||||
print(f" ✅ 下载成功: {segment_size / 1024 / 1024:.2f}MB")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 下载异常: {e}")
|
||||
continue
|
||||
|
||||
if not all_segments:
|
||||
print(f"\n ❌ 没有成功下载任何分段")
|
||||
await browser.close()
|
||||
return None
|
||||
|
||||
# 合并分段
|
||||
print(f"\n4. 合并视频分段...")
|
||||
video_data = b''.join(all_segments)
|
||||
|
||||
# 保存文件
|
||||
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:50]
|
||||
output_path = os.path.join(output_dir, f"{safe_title}.flv")
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(video_data)
|
||||
|
||||
print(f" ✅ 视频已保存: {output_path}")
|
||||
print(f" 文件大小: {total_size / 1024 / 1024:.2f}MB")
|
||||
|
||||
await browser.close()
|
||||
|
||||
return output_path
|
||||
|
||||
if __name__ == '__main__':
|
||||
bvid = "BV1KtFzzqEUG"
|
||||
result = asyncio.run(download_video_stream(bvid))
|
||||
if result:
|
||||
print(f"\n✅ 下载完成!")
|
||||
print(f"文件路径: {result}")
|
||||
else:
|
||||
print(f"\n❌ 下载失败")
|
||||
@@ -0,0 +1,46 @@
|
||||
# KB Bilibili Metadata Skill
|
||||
|
||||
**kb-bilibili-metadata** - Extract Bilibili video/article/up主 metadata.
|
||||
|
||||
## Features
|
||||
|
||||
- Video metadata extraction
|
||||
- Article (专栏) metadata extraction
|
||||
- UP主 information extraction
|
||||
- JSON output format
|
||||
- Playwright + HTTP API fallback
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Video metadata
|
||||
python bilibili_metadata.py --id "BV1xx411c7mD" --type video
|
||||
|
||||
# Article metadata
|
||||
python bilibili_metadata.py --id "cv43780186" --type article
|
||||
|
||||
# UP主 info
|
||||
python bilibili_metadata.py --id "7770724" --type up
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"platform": "bilibili",
|
||||
"type": "video",
|
||||
"id": "BV1xx411c7mD",
|
||||
"title": "视频标题",
|
||||
"author": "UP主名字",
|
||||
"stats": {"view": 100000, "like": 5000}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cp -r kb-bilibili-metadata/ ~/skills/
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
bilibili-metadata 技能包
|
||||
|
||||
B站元数据提取工具
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "OpenClaw"
|
||||
Binary file not shown.
@@ -0,0 +1,666 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
B站元数据提取器
|
||||
|
||||
独立版本,不依赖 obsidian-kb 结构
|
||||
支持视频、专栏、UP主信息提取
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 默认请求头
|
||||
DEFAULT_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',
|
||||
'Referer': 'https://www.bilibili.com/',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
}
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from undetected_playwright import stealth_async
|
||||
STEALTH_AVAILABLE = True
|
||||
except ImportError:
|
||||
STEALTH_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
HTTPX_AVAILABLE = True
|
||||
except ImportError:
|
||||
HTTPX_AVAILABLE = False
|
||||
|
||||
|
||||
class BilibiliExtractor:
|
||||
"""
|
||||
B站元数据提取器
|
||||
|
||||
支持:
|
||||
- 视频信息提取(标题、作者、统计数据、字幕等)
|
||||
- 专栏文章提取
|
||||
- UP主信息提取
|
||||
"""
|
||||
|
||||
# 正则模式
|
||||
BV_PATTERN = re.compile(r'BV[a-zA-Z0-9]{10}')
|
||||
CV_PATTERN = re.compile(r'cv(\d+)')
|
||||
URL_PATTERN = re.compile(r'https?://www\.bilibili\.com/video/(BV[a-zA-Z0-9]{10})')
|
||||
ARTICLE_URL_PATTERN = re.compile(r'https?://www\.bilibili\.com/read/(cv\d+)')
|
||||
UP_URL_PATTERN = re.compile(r'https?://space\.bilibili\.com/(\d+)')
|
||||
|
||||
def __init__(self, headless: bool = True, timeout: int = 30):
|
||||
"""
|
||||
初始化提取器
|
||||
|
||||
Args:
|
||||
headless: 是否使用无头模式(浏览器)
|
||||
timeout: 超时时间(秒)
|
||||
"""
|
||||
self.headless = headless
|
||||
self.timeout = timeout * 1000 # 转换为毫秒
|
||||
|
||||
def extract_video_metadata(self, bvid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
提取视频元数据
|
||||
|
||||
Args:
|
||||
bvid: B站视频BV号(如 BV1xx411c7mD)
|
||||
|
||||
Returns:
|
||||
Dict containing metadata, stats, description, subtitle
|
||||
"""
|
||||
# 验证并解析BV号
|
||||
bvid = self._resolve_bvid(bvid)
|
||||
if not bvid:
|
||||
raise ValueError(f"无效的BV号: {bvid}")
|
||||
|
||||
# 使用异步方法提取
|
||||
return asyncio.run(self._extract_video_async(bvid))
|
||||
|
||||
def extract_article_metadata(self, cvid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
提取专栏元数据
|
||||
|
||||
Args:
|
||||
cvid: B站专栏CV号(如 cv43780186)
|
||||
|
||||
Returns:
|
||||
Dict containing article metadata
|
||||
"""
|
||||
# 解析CV号
|
||||
cvid = self._resolve_cvid(cvid)
|
||||
if not cvid:
|
||||
raise ValueError(f"无效的CV号: {cvid}")
|
||||
|
||||
return asyncio.run(self._extract_article_async(cvid))
|
||||
|
||||
def extract_up_metadata(self, mid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
提取UP主信息
|
||||
|
||||
Args:
|
||||
mid: B站用户ID(如 7770724)
|
||||
|
||||
Returns:
|
||||
Dict containing UP主信息
|
||||
"""
|
||||
# 解析MID
|
||||
mid = self._resolve_mid(mid)
|
||||
if not mid:
|
||||
raise ValueError(f"无效的MID: {mid}")
|
||||
|
||||
return asyncio.run(self._extract_up_async(mid))
|
||||
|
||||
def _resolve_bvid(self, source_id: str) -> Optional[str]:
|
||||
"""解析并返回BV号"""
|
||||
source = source_id.strip()
|
||||
|
||||
# 直接BV号
|
||||
if self.BV_PATTERN.fullmatch(source):
|
||||
return source
|
||||
|
||||
# 完整URL
|
||||
match = self.URL_PATTERN.match(source)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_cvid(self, source_id: str) -> Optional[str]:
|
||||
"""解析并返回CV号"""
|
||||
source = source_id.strip()
|
||||
|
||||
# 直接CV号(带cv前缀)
|
||||
if source.startswith('cv'):
|
||||
match = self.CV_PATTERN.match(source)
|
||||
if match:
|
||||
return source
|
||||
|
||||
# 纯数字
|
||||
if source.isdigit():
|
||||
return f"cv{source}"
|
||||
|
||||
# 完整URL
|
||||
match = self.ARTICLE_URL_PATTERN.match(source)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_mid(self, source_id: str) -> Optional[str]:
|
||||
"""解析并返回MID"""
|
||||
source = source_id.strip()
|
||||
|
||||
# 纯数字
|
||||
if source.isdigit():
|
||||
return source
|
||||
|
||||
# 完整URL
|
||||
match = self.UP_URL_PATTERN.match(source)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
async def _extract_video_async(self, bvid: str) -> Dict[str, Any]:
|
||||
"""异步提取视频信息"""
|
||||
url = f"https://www.bilibili.com/video/{bvid}"
|
||||
result = {
|
||||
'metadata': {},
|
||||
'stats': {},
|
||||
'subtitle': '',
|
||||
'description': ''
|
||||
}
|
||||
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
# 使用 HTTP API
|
||||
return await self._extract_video_by_api(bvid)
|
||||
|
||||
async with async_playwright() as playwright:
|
||||
try:
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=self.headless,
|
||||
args=[
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-dev-shm-usage',
|
||||
]
|
||||
)
|
||||
|
||||
context = await browser.new_context(
|
||||
viewport={'width': 1920, 'height': 1080},
|
||||
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',
|
||||
locale='zh-CN',
|
||||
)
|
||||
|
||||
if STEALTH_AVAILABLE:
|
||||
await stealth_async(context)
|
||||
|
||||
page = await context.new_page()
|
||||
await page.goto(url, wait_until='domcontentloaded', timeout=self.timeout)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
content = await page.content()
|
||||
video_data = await self._try_extract_video_methods(page, content, bvid)
|
||||
|
||||
if video_data:
|
||||
result['metadata'] = {
|
||||
'title': video_data.get('title', ''),
|
||||
'author': video_data.get('author', ''),
|
||||
'bvid': video_data.get('bvid', ''),
|
||||
'aid': video_data.get('aid', ''),
|
||||
'mid': video_data.get('mid', ''),
|
||||
'pubtime': video_data.get('pubtime', ''),
|
||||
'duration': video_data.get('duration', ''),
|
||||
'url': url,
|
||||
'tags': video_data.get('tags', ''),
|
||||
}
|
||||
result['stats'] = {
|
||||
'view': video_data.get('view', 0),
|
||||
'danmaku': video_data.get('danmaku', 0),
|
||||
'reply': video_data.get('reply', 0),
|
||||
'favorite': video_data.get('favorite', 0),
|
||||
'coin': video_data.get('coin', 0),
|
||||
'share': video_data.get('share', 0),
|
||||
'like': video_data.get('like', 0),
|
||||
}
|
||||
result['description'] = video_data.get('desc', '')
|
||||
|
||||
await browser.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"视频提取错误: {e}")
|
||||
# 回退到 API
|
||||
return await self._extract_video_by_api(bvid)
|
||||
|
||||
return result
|
||||
|
||||
async def _extract_video_by_api(self, bvid: str) -> Dict[str, Any]:
|
||||
"""使用B站API提取视频信息"""
|
||||
result = {
|
||||
'metadata': {},
|
||||
'stats': {},
|
||||
'subtitle': '',
|
||||
'description': ''
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
|
||||
# 获取视频信息
|
||||
response = await client.get('https://api.bilibili.com/x/web-interface/view', params={'bvid': bvid})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('code') == 0:
|
||||
video_data = data.get('data', {})
|
||||
|
||||
result['metadata'] = {
|
||||
'title': video_data.get('title', ''),
|
||||
'author': video_data.get('owner', {}).get('name', ''),
|
||||
'bvid': video_data.get('bvid', ''),
|
||||
'aid': video_data.get('aid', ''),
|
||||
'mid': video_data.get('owner', {}).get('mid', ''),
|
||||
'pubtime': datetime.fromtimestamp(video_data.get('pubdate', 0)).strftime('%Y-%m-%d %H:%M:%S') if video_data.get('pubdate') else '',
|
||||
'duration': self._format_duration(video_data.get('duration', 0)),
|
||||
'url': f"https://www.bilibili.com/video/{bvid}",
|
||||
'tags': ', '.join([t.get('tag_name', '') for t in video_data.get('tag', [])]),
|
||||
}
|
||||
|
||||
stat = video_data.get('stat', {})
|
||||
result['stats'] = {
|
||||
'view': stat.get('view', 0),
|
||||
'danmaku': stat.get('danmaku', 0),
|
||||
'reply': stat.get('reply', 0),
|
||||
'favorite': stat.get('favorite', 0),
|
||||
'coin': stat.get('coin', 0),
|
||||
'share': stat.get('share', 0),
|
||||
'like': stat.get('like', 0),
|
||||
}
|
||||
result['description'] = video_data.get('desc', '')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"API提取错误: {e}")
|
||||
|
||||
return result
|
||||
|
||||
async def _try_extract_video_methods(self, page, content: str, bvid: str) -> Dict[str, Any]:
|
||||
"""尝试多种方法提取视频信息"""
|
||||
video_data = {}
|
||||
|
||||
# 方法1: 从 __INITIAL_STATE__
|
||||
try:
|
||||
initial_state = await page.evaluate('() => typeof window.__INITIAL_STATE__ !== "undefined" ? window.__INITIAL_STATE__ : null')
|
||||
if initial_state:
|
||||
video_data = self._parse_video_state(initial_state)
|
||||
if video_data and video_data.get('title'):
|
||||
return video_data
|
||||
except:
|
||||
pass
|
||||
|
||||
# 方法2: 使用 API(更可靠)
|
||||
return await self._extract_video_by_api(bvid)
|
||||
|
||||
def _parse_video_state(self, data: Dict) -> Dict[str, Any]:
|
||||
"""解析 __INITIAL_STATE__ 数据"""
|
||||
video_data = {}
|
||||
try:
|
||||
video = data.get('videoData', {})
|
||||
video_data['title'] = video.get('title', '')
|
||||
video_data['bvid'] = video.get('bvid', '')
|
||||
video_data['aid'] = video.get('aid', '')
|
||||
owner = video.get('owner', {})
|
||||
video_data['author'] = owner.get('name', '')
|
||||
video_data['mid'] = owner.get('mid', '')
|
||||
stat = video.get('stat', {})
|
||||
video_data['view'] = stat.get('view', 0)
|
||||
video_data['danmaku'] = stat.get('danmaku', 0)
|
||||
video_data['reply'] = stat.get('reply', 0)
|
||||
video_data['favorite'] = stat.get('favorite', 0)
|
||||
video_data['coin'] = stat.get('coin', 0)
|
||||
video_data['share'] = stat.get('share', 0)
|
||||
video_data['like'] = stat.get('like', 0)
|
||||
pubdate = video.get('pubdate', 0)
|
||||
video_data['pubtime'] = datetime.fromtimestamp(pubdate).strftime('%Y-%m-%d %H:%M:%S') if pubdate else ''
|
||||
video_data['duration'] = self._format_duration(video.get('duration', 0))
|
||||
video_data['desc'] = video.get('desc', '')
|
||||
video_data['tags'] = ', '.join([t.get('tag_name', '') for t in video.get('tag', [])])
|
||||
except:
|
||||
pass
|
||||
return video_data
|
||||
|
||||
async def _extract_article_async(self, cvid: str) -> Dict[str, Any]:
|
||||
"""异步提取专栏信息"""
|
||||
result = {}
|
||||
|
||||
if not HTTPX_AVAILABLE:
|
||||
return {'error': 'httpx 未安装'}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
|
||||
# 获取专栏信息
|
||||
response = await client.get('https://api.bilibili.com/x/article/viewinfo', params={'id': cvid[2:]})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('code') == 0:
|
||||
article = data.get('data', {})
|
||||
result = {
|
||||
'cvid': cvid,
|
||||
'title': article.get('title', ''),
|
||||
'author': article.get('author_name', ''),
|
||||
'mid': article.get('author_mid', ''),
|
||||
'publish_time': datetime.fromtimestamp(article.get('publish_time', 0)).strftime('%Y-%m-%d %H:%M:%S') if article.get('publish_time') else '',
|
||||
'view_count': article.get('stats', {}).get('view', 0),
|
||||
'like_count': article.get('stats', {}).get('like', 0),
|
||||
'coin_count': article.get('stats', {}).get('coin', 0),
|
||||
'share_count': article.get('stats', {}).get('share', 0),
|
||||
'reply_count': article.get('stats', {}).get('reply', 0),
|
||||
'url': f"https://www.bilibili.com/read/{cvid}",
|
||||
'summary': article.get('summary', ''),
|
||||
'word_count': article.get('words', 0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"专栏提取错误: {e}")
|
||||
result = {'error': str(e)}
|
||||
|
||||
return result
|
||||
|
||||
async def _extract_up_async(self, mid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
异步提取UP主信息(支持订阅跟踪)
|
||||
|
||||
Args:
|
||||
mid: UP主ID
|
||||
|
||||
Returns:
|
||||
Dict containing UP主完整信息
|
||||
"""
|
||||
result = {
|
||||
'type': 'up',
|
||||
'mid': mid,
|
||||
'subscribed': False,
|
||||
'basic_info': {},
|
||||
'subscription_info': {}
|
||||
}
|
||||
|
||||
if not HTTPX_AVAILABLE:
|
||||
return {'error': 'httpx 未安装'}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
|
||||
# 获取UP主基本信息
|
||||
response = await client.get('https://api.bilibili.com/x/space/acc/info', params={'mid': mid})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('code') == 0:
|
||||
up = data.get('data', {})
|
||||
result['basic_info'] = {
|
||||
'name': up.get('name', ''),
|
||||
'sex': up.get('sex', ''),
|
||||
'face': up.get('face', ''),
|
||||
'sign': up.get('sign', ''),
|
||||
'level': up.get('level', 0),
|
||||
'vip_type': up.get('vip', {}).get('type', 0),
|
||||
'vip_status': up.get('vip', {}).get('status', 0),
|
||||
'following_count': up.get('following', 0),
|
||||
'follower_count': up.get('follower', 0),
|
||||
'url': f"https://space.bilibili.com/{mid}",
|
||||
}
|
||||
|
||||
# 获取UP主视频统计
|
||||
stats_response = await client.get('https://api.bilibili.com/x/space/navnum', params={'mid': mid})
|
||||
if stats_response.status_code == 200:
|
||||
stats_data = stats_response.json()
|
||||
if stats_data.get('code') == 0:
|
||||
result['basic_info']['video_count'] = stats_data.get('data', {}).get('video', 0)
|
||||
|
||||
# 获取UP主专栏数量
|
||||
article_response = await client.get('https://api.bilibili.com/x/space/article', params={'mid': mid, 'pn': 1, 'ps': 1})
|
||||
if article_response.status_code == 200:
|
||||
article_data = article_response.json()
|
||||
if article_data.get('code') == 0:
|
||||
result['basic_info']['article_count'] = article_data.get('data', {}).get('count', 0)
|
||||
|
||||
# 获取UP主最近视频(用于跟踪更新)
|
||||
video_response = await client.get('https://api.bilibili.com/x/space/arc/search',
|
||||
params={'mid': mid, 'pn': 1, 'ps': 1, 'order': 'pubtime'})
|
||||
if video_response.status_code == 200:
|
||||
video_data = video_response.json()
|
||||
if video_data.get('code') == 0:
|
||||
videos = video_data.get('data', {}).get('list', {}).get('vlist', [])
|
||||
if videos:
|
||||
latest_video = videos[0]
|
||||
result['subscription_info'] = {
|
||||
'last_video_id': latest_video.get('bvid', ''),
|
||||
'last_video_title': latest_video.get('title', ''),
|
||||
'last_video_time': datetime.fromtimestamp(latest_video.get('created', 0)).strftime('%Y-%m-%d %H:%M:%S') if latest_video.get('created') else '',
|
||||
}
|
||||
|
||||
# 获取UP主最近专栏(用于跟踪更新)
|
||||
article_response = await client.get('https://api.bilibili.com/x/space/article',
|
||||
params={'mid': mid, 'pn': 1, 'ps': 1, 'sort': 'publish_time'})
|
||||
if article_response.status_code == 200:
|
||||
article_data = article_response.json()
|
||||
if article_data.get('code') == 0:
|
||||
articles = article_data.get('data', {}).get('articles', [])
|
||||
if articles:
|
||||
latest_article = articles[0]
|
||||
if 'last_article' not in result['subscription_info']:
|
||||
result['subscription_info']['last_article'] = {}
|
||||
result['subscription_info']['last_article'] = {
|
||||
'id': latest_article.get('id', 0),
|
||||
'title': latest_article.get('title', ''),
|
||||
'time': datetime.fromtimestamp(latest_article.get('publish_time', 0)).strftime('%Y-%m-%d %H:%M:%S') if latest_article.get('publish_time') else '',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"UP主提取错误: {e}")
|
||||
result = {'error': str(e)}
|
||||
|
||||
return result
|
||||
|
||||
def _format_duration(self, seconds: int) -> str:
|
||||
"""格式化时长(秒 -> HH:MM:SS)"""
|
||||
if not seconds:
|
||||
return ''
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
secs = seconds % 60
|
||||
if hours > 0:
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
else:
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
async def _extract_series_async(self, mid: str, series_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
异步提取系列合集信息(不包含列表内容)
|
||||
|
||||
Args:
|
||||
mid: UP主ID
|
||||
series_id: 系列ID
|
||||
|
||||
Returns:
|
||||
Dict containing series basic info
|
||||
"""
|
||||
result = {
|
||||
'type': 'series',
|
||||
'series_id': series_id,
|
||||
'mid': mid,
|
||||
'subscribed': False,
|
||||
'basic_info': {}
|
||||
}
|
||||
|
||||
if not HTTPX_AVAILABLE:
|
||||
return {'error': 'httpx 未安装'}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
|
||||
# 获取系列信息
|
||||
response = await client.get('https://api.bilibili.com/x/space/series',
|
||||
params={'mid': mid, 'series_id': series_id, 'pn': 1, 'ps': 1})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('code') == 0:
|
||||
series_info = data.get('data', {}).get('list', {})
|
||||
result['basic_info'] = {
|
||||
'title': series_info.get('title', ''),
|
||||
'description': series_info.get('desc', ''),
|
||||
'cover': series_info.get('cover', ''),
|
||||
'mid': mid,
|
||||
'creator_name': '',
|
||||
'total_count': series_info.get('cnt', 0),
|
||||
'last_updated': '',
|
||||
'update_count': 0
|
||||
}
|
||||
|
||||
# 获取创作者信息
|
||||
up_info = await client.get('https://api.bilibili.com/x/space/acc/info',
|
||||
params={'mid': mid})
|
||||
if up_info.status_code == 200:
|
||||
up_data = up_info.json()
|
||||
if up_data.get('code') == 0:
|
||||
result['basic_info']['creator_name'] = up_data.get('data', {}).get('name', '')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"系列合集提取错误: {e}")
|
||||
result = {'error': str(e)}
|
||||
|
||||
return result
|
||||
|
||||
async def _extract_readlist_async(self, rl_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
异步提取专栏合集信息(不包含列表内容)
|
||||
|
||||
Args:
|
||||
rl_id: 专栏合集ID (rl开头)
|
||||
|
||||
Returns:
|
||||
Dict containing readlist basic info
|
||||
"""
|
||||
result = {
|
||||
'type': 'readlist',
|
||||
'readlist_id': rl_id,
|
||||
'subscribed': False,
|
||||
'basic_info': {}
|
||||
}
|
||||
|
||||
if not HTTPX_AVAILABLE:
|
||||
return {'error': 'httpx 未安装'}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
|
||||
# 获取专栏合集信息
|
||||
response = await client.get('https://api.bilibili.com/x/article/readlist',
|
||||
params={'id': rl_id.replace('rl', '')})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('code') == 0:
|
||||
list_info = data.get('data', {})
|
||||
result['basic_info'] = {
|
||||
'title': list_info.get('name', ''),
|
||||
'description': list_info.get('description', ''),
|
||||
'cover': list_info.get('cover', ''),
|
||||
'mid': list_info.get('mid', ''),
|
||||
'creator_name': '',
|
||||
'total_count': list_info.get('articles_count', 0),
|
||||
'last_updated': '',
|
||||
'is_public': list_info.get('public', True)
|
||||
}
|
||||
|
||||
# 获取创作者信息
|
||||
mid = list_info.get('mid', '')
|
||||
if mid:
|
||||
up_info = await client.get('https://api.bilibili.com/x/space/acc/info',
|
||||
params={'mid': mid})
|
||||
if up_info.status_code == 200:
|
||||
up_data = up_info.json()
|
||||
if up_data.get('code') == 0:
|
||||
result['basic_info']['creator_name'] = up_data.get('data', {}).get('name', '')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"专栏合集提取错误: {e}")
|
||||
result = {'error': str(e)}
|
||||
|
||||
return result
|
||||
|
||||
async def _extract_favlist_async(self, fid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
异步提取收藏夹信息(不包含列表内容)
|
||||
|
||||
Args:
|
||||
fid: 收藏夹ID
|
||||
|
||||
Returns:
|
||||
Dict containing favlist basic info
|
||||
"""
|
||||
result = {
|
||||
'type': 'favlist',
|
||||
'favlist_id': fid,
|
||||
'subscribed': False,
|
||||
'basic_info': {}
|
||||
}
|
||||
|
||||
if not HTTPX_AVAILABLE:
|
||||
return {'error': 'httpx 未安装'}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
|
||||
# 获取收藏夹信息
|
||||
response = await client.get('https://api.bilibili.com/x/space/favlist',
|
||||
params={'fid': fid, 'pn': 1, 'ps': 1})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('code') == 0:
|
||||
fav_info = data.get('data', {})
|
||||
result['basic_info'] = {
|
||||
'title': fav_info.get('title', ''),
|
||||
'description': fav_info.get('intro', ''),
|
||||
'cover': fav_info.get('cover', ''),
|
||||
'mid': fav_info.get('mid', ''),
|
||||
'creator_name': '',
|
||||
'total_count': fav_info.get('media_count', 0),
|
||||
'is_public': fav_info.get('public', True),
|
||||
'like_count': fav_info.get('like', 0)
|
||||
}
|
||||
|
||||
# 获取创作者信息
|
||||
mid = fav_info.get('mid', '')
|
||||
if mid:
|
||||
up_info = await client.get('https://api.bilibili.com/x/space/acc/info',
|
||||
params={'mid': mid})
|
||||
if up_info.status_code == 200:
|
||||
up_data = up_info.json()
|
||||
if up_data.get('code') == 0:
|
||||
result['basic_info']['creator_name'] = up_data.get('data', {}).get('name', '')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"收藏夹提取错误: {e}")
|
||||
result = {'error': str(e)}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == '__main__':
|
||||
extractor = BilibiliExtractor()
|
||||
|
||||
print("=" * 60)
|
||||
print("B站元数据提取器测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试视频
|
||||
print("\n测试视频提取: BV1MnFNz5EDL")
|
||||
result = extractor.extract_video_metadata('BV1MnFNz5EDL')
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
B站元数据提取 CLI 工具
|
||||
|
||||
支持命令行调用,输出 JSON 格式数据
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from bilibili_extractor import BilibiliExtractor
|
||||
|
||||
|
||||
def format_number(num: int) -> str:
|
||||
"""格式化数字"""
|
||||
if num >= 100000000:
|
||||
return f"{num / 100000000:.1f}亿"
|
||||
elif num >= 10000:
|
||||
return f"{num / 10000:.1f}万"
|
||||
else:
|
||||
return f"{num:,}"
|
||||
|
||||
|
||||
def extract_video(extractor: BilibiliExtractor, bvid: str, output_file: str = None) -> Dict[str, Any]:
|
||||
"""提取视频元数据"""
|
||||
result = extractor.extract_video_metadata(bvid)
|
||||
|
||||
# 如果需要输出到文件
|
||||
if output_file:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_article(extractor: BilibiliExtractor, cvid: str, output_file: str = None) -> Dict[str, Any]:
|
||||
"""提取专栏元数据"""
|
||||
result = extractor.extract_article_metadata(cvid)
|
||||
|
||||
if output_file:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_up(extractor: BilibiliExtractor, mid: str, output_file: str = None) -> Dict[str, Any]:
|
||||
"""提取UP主信息(包含订阅跟踪所需信息)"""
|
||||
result = asyncio.run(extractor._extract_up_async(mid))
|
||||
|
||||
if output_file:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_series(extractor: BilibiliExtractor, mid: str, series_id: str, output_file: str = None) -> Dict[str, Any]:
|
||||
"""提取系列合集元数据"""
|
||||
result = asyncio.run(extractor._extract_series_async(mid, series_id))
|
||||
|
||||
if output_file:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_readlist(extractor: BilibiliExtractor, rl_id: str, output_file: str = None) -> Dict[str, Any]:
|
||||
"""提取专栏合集元数据"""
|
||||
result = asyncio.run(extractor._extract_readlist_async(rl_id))
|
||||
|
||||
if output_file:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_favlist(extractor: BilibiliExtractor, fid: str, output_file: str = None) -> Dict[str, Any]:
|
||||
"""提取收藏夹元数据"""
|
||||
result = asyncio.run(extractor._extract_favlist_async(fid))
|
||||
|
||||
if output_file:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='B站元数据提取工具',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
# 提取视频元数据
|
||||
python bilibili_metadata.py --id "BV1xx411c7mD" --type video
|
||||
|
||||
# 提取专栏元数据
|
||||
python bilibili_metadata.py --id "cv43780186" --type article
|
||||
|
||||
# 提取UP主信息
|
||||
python bilibili_metadata.py --id "7770724" --type up
|
||||
|
||||
# 提取系列合集信息
|
||||
python bilibili_metadata.py --id "123456" --type series --mid "7770724"
|
||||
|
||||
# 提取专栏合集信息
|
||||
python bilibili_metadata.py --id "rl123456" --type readlist
|
||||
|
||||
# 提取收藏夹信息
|
||||
python bilibili_metadata.py --id "123456" --type favlist
|
||||
|
||||
# 输出到文件
|
||||
python bilibili_metadata.py --id "BV1xx411c7mD" --type video --output result.json
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--id',
|
||||
required=True,
|
||||
help='B站资源ID(BV号、CV号或UP主ID)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--type',
|
||||
required=True,
|
||||
choices=['video', 'article', 'up', 'series', 'readlist', 'favlist'],
|
||||
help='资源类型'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--mid',
|
||||
help='UP主ID (用于series类型)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
'-o',
|
||||
help='输出文件路径(JSON格式)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--headless',
|
||||
action='store_true',
|
||||
default=True,
|
||||
help='使用无头模式(浏览器,默认开启,使用API)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--timeout',
|
||||
type=int,
|
||||
default=30,
|
||||
help='超时时间(秒,默认30)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--pretty',
|
||||
action='store_true',
|
||||
default=True,
|
||||
help='格式化输出(默认开启)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 创建提取器
|
||||
extractor = BilibiliExtractor(headless=args.headless, timeout=args.timeout)
|
||||
|
||||
try:
|
||||
# 根据类型提取数据
|
||||
if args.type == 'video':
|
||||
result = extract_video(extractor, args.id, args.output)
|
||||
elif args.type == 'article':
|
||||
result = extract_article(extractor, args.id, args.output)
|
||||
elif args.type == 'up':
|
||||
result = extract_up(extractor, args.id, args.output)
|
||||
elif args.type == 'series':
|
||||
if not args.mid:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "series类型需要提供 --mid 参数"
|
||||
}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
result = extract_series(extractor, args.mid, args.id, args.output)
|
||||
elif args.type == 'readlist':
|
||||
result = extract_readlist(extractor, args.id, args.output)
|
||||
elif args.type == 'favlist':
|
||||
result = extract_favlist(extractor, args.id, args.output)
|
||||
else:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": f"不支持的类型 {args.type}"
|
||||
}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
# 生成文件名
|
||||
platform = "bilibili"
|
||||
filename = f"{platform}_{args.id}.meta.json"
|
||||
filepath = args.output or filename
|
||||
|
||||
# 统一输出格式
|
||||
output = {
|
||||
"success": True,
|
||||
"filename": filename,
|
||||
"filepath": str(Path(filepath).resolve()) if filepath else None,
|
||||
"message": f"{args.type}元数据提取成功"
|
||||
}
|
||||
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
|
||||
# 如果指定了输出路径,则写入文件
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"detail": type(e).__name__
|
||||
}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
# KB Deep Analyzer Skill
|
||||
|
||||
**kb-deep-analyzer** - AI-powered deep content analysis.
|
||||
|
||||
## Features
|
||||
|
||||
- Multi-type analysis (tutorial, documentation, news)
|
||||
- Template-based output
|
||||
- Quality scoring
|
||||
- Automatic tagging
|
||||
- **多格式元数据支持**: 支持扁平结构 `{"title":"xxx"}` 和嵌套结构 `{"data":{"metadata":{...}}}`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# 分析内容(推荐:使用 -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
|
||||
|
||||
# 指定 AI 提供商
|
||||
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
|
||||
|
||||
支持两种元数据格式:
|
||||
|
||||
```json
|
||||
// 格式1: 扁平结构
|
||||
{
|
||||
"title": "视频标题",
|
||||
"author": "作者名",
|
||||
"duration": 619,
|
||||
"bvid": "BVxxx"
|
||||
}
|
||||
|
||||
// 格式2: 嵌套结构(kb-bilibili-metadata 输出)
|
||||
{
|
||||
"data": {
|
||||
"metadata": {
|
||||
"title": "视频标题",
|
||||
"author": "作者名",
|
||||
"duration": "10:19"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
会自动转换为扁平格式进行分析。
|
||||
Binary file not shown.
@@ -0,0 +1,514 @@
|
||||
"""Obsidian KB - AI客户端
|
||||
支持多个 AI 提供商和 fallback 机制
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from src.models import ContentType
|
||||
from src.config import get_config
|
||||
from src.content_chunker import ContentChunker, get_chunking_strategy, chunk_content
|
||||
|
||||
|
||||
class AIClient:
|
||||
"""
|
||||
AI客户端 - 支持分段分析长视频内容和 fallback 机制
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config()
|
||||
self.chunker = ContentChunker()
|
||||
# 获取 AI 配置
|
||||
self._load_ai_config()
|
||||
|
||||
def _load_ai_config(self) -> None:
|
||||
"""加载 AI 配置(主 provider + fallbacks)"""
|
||||
self.ai_configs = []
|
||||
|
||||
# 加载主配置
|
||||
main_provider = self.config.get('ai.provider', 'openai')
|
||||
main_config = {
|
||||
'provider': main_provider,
|
||||
'api_key': self.config.get('ai.api_key'),
|
||||
'model': self.config.get('ai.model', 'gpt-4'),
|
||||
'base_url': self.config.get('ai.base_url'),
|
||||
'is_primary': True
|
||||
}
|
||||
self.ai_configs.append(main_config)
|
||||
|
||||
# 加载 fallback 配置
|
||||
fallbacks = self.config.get('ai.fallbacks', [])
|
||||
for fb in fallbacks:
|
||||
self.ai_configs.append({
|
||||
'provider': fb.get('provider'),
|
||||
'api_key': fb.get('api_key'),
|
||||
'model': fb.get('model'),
|
||||
'base_url': fb.get('base_url'),
|
||||
'is_primary': False
|
||||
})
|
||||
|
||||
def _call_api_with_fallback(self, prompt: str, max_tokens: int = 1000, response_format: str = 'text') -> Tuple[bool, str]:
|
||||
"""
|
||||
使用 fallback 机制调用 API
|
||||
|
||||
Args:
|
||||
prompt: 提示词
|
||||
max_tokens: 最大 token 数
|
||||
response_format: 响应格式 ('text' 或 'json')
|
||||
|
||||
Returns:
|
||||
(success, response) 元组
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for i, ai_config in enumerate(self.ai_configs):
|
||||
try:
|
||||
if not ai_config.get('api_key'):
|
||||
print(f"[AIClient] 跳过 {ai_config.get('provider')} - API Key 为空")
|
||||
continue
|
||||
|
||||
provider_type = ai_config.get('provider')
|
||||
print(f"[AIClient] 尝试使用 {provider_type} (优先级 {i+1}/{len(self.ai_configs)})...")
|
||||
|
||||
response = self._call_single_provider(ai_config, prompt, max_tokens, response_format)
|
||||
return True, response
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
print(f"[AIClient] {provider_type} 调用失败: {e}")
|
||||
continue
|
||||
|
||||
# 所有 provider 都失败了
|
||||
error_msg = f"所有 AI 提供商均失败,最后错误: {last_error}" if last_error else "没有可用的 AI 配置"
|
||||
print(f"[AIClient] {error_msg}")
|
||||
return False, self._mock_generate(prompt, max_tokens) # 返回 mock 响应作为兜底
|
||||
|
||||
def _call_single_provider(self, ai_config: Dict, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""
|
||||
调用单个 AI 提供商
|
||||
|
||||
Args:
|
||||
ai_config: AI 配置
|
||||
prompt: 提示词
|
||||
max_tokens: 最大 token 数
|
||||
response_format: 响应格式
|
||||
|
||||
Returns:
|
||||
API 响应文本
|
||||
"""
|
||||
provider = ai_config.get('provider')
|
||||
api_key = ai_config.get('api_key')
|
||||
model = ai_config.get('model')
|
||||
base_url = ai_config.get('base_url')
|
||||
|
||||
if provider == 'minimax':
|
||||
return self._call_minimax(api_key, model, base_url, prompt, max_tokens, response_format)
|
||||
elif provider == 'zhipu':
|
||||
return self._call_zhipu(api_key, model, base_url, prompt, max_tokens, response_format)
|
||||
elif provider == 'alibaba':
|
||||
return self._call_alibaba(api_key, model, base_url, prompt, max_tokens, response_format)
|
||||
elif provider == 'openai':
|
||||
return self._call_openai(api_key, model, base_url, prompt, max_tokens, response_format)
|
||||
else:
|
||||
raise ValueError(f"不支持的 provider: {provider}")
|
||||
|
||||
def _call_minimax(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""调用 MiniMax API (Anthropic Messages 兼容接口)"""
|
||||
url = base_url or "https://api.minimaxi.com/anthropic/v1/messages"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
if response_format == 'json':
|
||||
payload['messages'][0]['content'] = prompt + "\n\n请直接返回 JSON 格式,不要有其他文字。"
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# MiniMax 返回的是 Anthropic 格式,content 是一个数组
|
||||
# 需要找到 type='text' 的元素
|
||||
for item in data.get('content', []):
|
||||
if item.get('type') == 'text':
|
||||
return item.get('text', '')
|
||||
|
||||
# 如果没有找到 text 类型,返回第一个元素的值(兼容旧格式)
|
||||
return data.get('content', [{}])[0].get('text', '')
|
||||
|
||||
def _call_zhipu(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""调用智谱 AI API (OpenAI 兼容接口)"""
|
||||
# 确保 base_url 包含 /chat/completions
|
||||
if base_url and not base_url.endswith('/chat/completions'):
|
||||
url = f"{base_url}/chat/completions"
|
||||
else:
|
||||
url = base_url or "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data['choices'][0]['message']['content']
|
||||
|
||||
def _call_alibaba(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""调用阿里云通义千问 API (OpenAI 兼容接口)"""
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data['choices'][0]['message']['content']
|
||||
|
||||
def _call_openai(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""调用 OpenAI API"""
|
||||
url = base_url or "https://api.openai.com/v1/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data['choices'][0]['message']['content']
|
||||
|
||||
def quick_generate(self, prompt: str, max_tokens: int = 100) -> str:
|
||||
"""快速生成(用于轻量扫描)"""
|
||||
success, response = self._call_api_with_fallback(prompt, max_tokens, 'text')
|
||||
return response
|
||||
|
||||
def generate_json(self, prompt: str) -> Dict[str, Any]:
|
||||
"""生成JSON格式响应"""
|
||||
success, response = self._call_api_with_fallback(prompt, 1000, 'json')
|
||||
try:
|
||||
return json.loads(response)
|
||||
except:
|
||||
return self._parse_json_from_text(response)
|
||||
|
||||
def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
C5.3: 深度分析入口,根据内容时长决定是否分段分析
|
||||
|
||||
Args:
|
||||
content_text: 内容文本
|
||||
content_type: 内容类型
|
||||
duration: 视频时长(秒)
|
||||
|
||||
Returns:
|
||||
分析结果字典
|
||||
"""
|
||||
duration = duration or 0
|
||||
|
||||
print(f"[AIClient] 深度分析: type={content_type}, duration={duration}s")
|
||||
|
||||
# 长视频(>=30分钟)且是视频类型,使用分段分析
|
||||
if duration >= 1800 and content_type == "video":
|
||||
print(f"[AIClient] 使用分段分析策略")
|
||||
return self._analyze_in_chunks(content_text, duration)
|
||||
|
||||
# 短视频直接分析
|
||||
print(f"[AIClient] 使用直接分析策略")
|
||||
return self._analyze_direct(content_text)
|
||||
|
||||
def _analyze_in_chunks(self, content_text: str, duration: int) -> Dict[str, Any]:
|
||||
"""
|
||||
C5.3: 分段分析长视频
|
||||
|
||||
Args:
|
||||
content_text: SRT字幕文本
|
||||
duration: 视频时长(秒)
|
||||
|
||||
Returns:
|
||||
合并后的分析结果
|
||||
"""
|
||||
# 1. 获取分段策略
|
||||
strategy = get_chunking_strategy(duration)
|
||||
print(f"[AIClient] 分段策略: {strategy['description']}")
|
||||
|
||||
# 2. 执行分片
|
||||
chunks = chunk_content(content_text, duration)
|
||||
print(f"[AIClient] 内容已分为 {len(chunks)} 段")
|
||||
|
||||
# 3. 逐段分析
|
||||
chunk_summaries = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
print(f"[AIClient] 分析第 {i+1}/{len(chunks)} 段...")
|
||||
summary = self._analyze_chunk(chunk.text, chunk.index)
|
||||
|
||||
# 处理时间格式(可能是float秒数或字符串)
|
||||
start_time = chunk.start_time
|
||||
end_time = chunk.end_time
|
||||
if isinstance(start_time, (int, float)):
|
||||
start_time = self._format_time(start_time)
|
||||
if isinstance(end_time, (int, float)):
|
||||
end_time = self._format_time(end_time)
|
||||
|
||||
chunk_summaries.append({
|
||||
'index': chunk.index,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'summary': summary
|
||||
})
|
||||
|
||||
# 4. 合并所有段摘要
|
||||
print(f"[AIClient] 合并 {len(chunk_summaries)} 段分析结果")
|
||||
final_result = self._merge_summaries(chunk_summaries, "video")
|
||||
|
||||
# 5. 构建完整时间轴
|
||||
final_result['timeline'] = self._build_timeline_from_chunks(chunk_summaries)
|
||||
|
||||
return final_result
|
||||
|
||||
def _analyze_chunk(self, chunk_text: str, chunk_index: int) -> str:
|
||||
"""
|
||||
C5.3: 分析单个内容分片
|
||||
|
||||
Args:
|
||||
chunk_text: 分片文本
|
||||
chunk_index: 分片索引
|
||||
|
||||
Returns:
|
||||
分片摘要(限制500字以内)
|
||||
"""
|
||||
# 截断文本避免过长
|
||||
text_sample = chunk_text[:3000] if len(chunk_text) > 3000 else chunk_text
|
||||
|
||||
prompt = f"""请对以下视频片段进行简要分析(限制在300字以内):
|
||||
|
||||
片段内容:
|
||||
{text_sample}
|
||||
|
||||
请提供:
|
||||
1. 这段的核心主题(50字以内)
|
||||
2. 关键要点(3-5条)
|
||||
3. 重要细节或引用
|
||||
|
||||
请用中文回答,控制在300字以内。"""
|
||||
|
||||
try:
|
||||
summary = self.quick_generate(prompt, max_tokens=500)
|
||||
# 确保不超过500字
|
||||
if len(summary) > 500:
|
||||
summary = summary[:497] + "..."
|
||||
return summary
|
||||
except Exception as e:
|
||||
print(f"[AIClient] 分片 {chunk_index} 分析失败: {e}")
|
||||
return f"第{chunk_index+1}段分析失败"
|
||||
|
||||
def _merge_summaries(self, chunk_summaries: List[Dict], content_type: str) -> Dict[str, Any]:
|
||||
"""
|
||||
C5.3: 合并所有段摘要,生成完整分析报告
|
||||
|
||||
Args:
|
||||
chunk_summaries: 各段摘要列表
|
||||
content_type: 内容类型
|
||||
|
||||
Returns:
|
||||
完整分析结果
|
||||
"""
|
||||
# 构建合并提示
|
||||
summaries_text = "\n\n".join([
|
||||
f"=== 第{s['index']+1}段 ({s['start_time']}-{s['end_time']}) ===\n{s['summary']}"
|
||||
for s in chunk_summaries
|
||||
])
|
||||
|
||||
prompt = f"""基于以下各段分析,生成完整的视频分析报告:
|
||||
|
||||
{summaries_text}
|
||||
|
||||
请提供以下格式的JSON:
|
||||
{{
|
||||
"summary": "整体摘要(200字以内)",
|
||||
"topics": ["主题1", "主题2", "主题3", "主题4"],
|
||||
"insights": ["深入见解1", "深入见解2", "深入见解3"],
|
||||
"actions": ["建议行动1", "建议行动2"],
|
||||
"full_analysis": "详细分析(500字以内)"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
result = self.generate_json(prompt)
|
||||
|
||||
# 确保必要字段存在
|
||||
if 'topics' not in result:
|
||||
result['topics'] = ["主题A", "主题B", "主题C"]
|
||||
if 'insights' not in result:
|
||||
result['insights'] = ["重要见解1", "重要见解2"]
|
||||
if 'actions' not in result:
|
||||
result['actions'] = []
|
||||
if 'summary' not in result:
|
||||
result['summary'] = "长视频分析完成"
|
||||
if 'full_analysis' not in result:
|
||||
result['full_analysis'] = summaries_text[:500]
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"[AIClient] 合并摘要失败: {e}")
|
||||
# 返回基础合并结果
|
||||
return {
|
||||
'summary': f'视频共分为{len(chunk_summaries)}个部分进行分析',
|
||||
'topics': [f"主题{i+1}" for i in range(min(4, len(chunk_summaries)))],
|
||||
'insights': ['从分段分析中综合得出'],
|
||||
'actions': [],
|
||||
'full_analysis': summaries_text[:800]
|
||||
}
|
||||
|
||||
def _analyze_direct(self, content_text: str) -> Dict[str, Any]:
|
||||
"""直接分析(不分段)"""
|
||||
# 截断文本
|
||||
text_sample = content_text[:5000] if len(content_text) > 5000 else content_text
|
||||
|
||||
prompt = f"""请分析以下内容,提供JSON格式结果:
|
||||
|
||||
{text_sample}
|
||||
|
||||
返回格式:
|
||||
{{
|
||||
"summary": "整体摘要(150字以内)",
|
||||
"topics": ["主题1", "主题2", "主题3"],
|
||||
"insights": ["见解1", "见解2"],
|
||||
"actions": ["行动建议1"],
|
||||
"timeline": [
|
||||
{{"start_time": "00:00", "end_time": "05:00", "title": "第一部分", "summary": "..."}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
return self.generate_json(prompt)
|
||||
except Exception as e:
|
||||
print(f"[AIClient] 直接分析失败: {e}")
|
||||
return {
|
||||
'summary': '分析失败',
|
||||
'topics': ['无法解析'],
|
||||
'insights': ['请检查内容格式'],
|
||||
'actions': [],
|
||||
'timeline': []
|
||||
}
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
"""格式化秒数为时间字符串"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
if hours > 0:
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
def _build_timeline_from_chunks(self, chunk_summaries: List[Dict]) -> List[Dict[str, Any]]:
|
||||
"""从分片摘要构建时间轴"""
|
||||
timeline = []
|
||||
for summary in chunk_summaries:
|
||||
timeline.append({
|
||||
'index': summary['index'],
|
||||
'start_time': summary['start_time'],
|
||||
'end_time': summary['end_time'],
|
||||
'title': f"第{summary['index']+1}部分",
|
||||
'summary': summary['summary'][:200] + "..." if len(summary['summary']) > 200 else summary['summary']
|
||||
})
|
||||
return timeline
|
||||
|
||||
def _mock_generate(self, prompt: str, max_tokens: int) -> str:
|
||||
"""模拟AI生成(实际实现应调用真实API)"""
|
||||
# 根据prompt内容返回模拟响应
|
||||
prompt_lower = prompt.lower()
|
||||
|
||||
if 'json' in prompt_lower or 'json格式' in prompt:
|
||||
if '主题' in prompt or 'topic' in prompt_lower:
|
||||
return json.dumps({
|
||||
'summary': '这是一个关于人工智能和技术发展的内容,涵盖了多个重要主题。',
|
||||
'topics': ['人工智能', '机器学习', '未来发展', '技术趋势'],
|
||||
'insights': [
|
||||
'AI正在改变我们的工作方式',
|
||||
'机器学习应用越来越广泛',
|
||||
'未来技术发展趋势值得关注'
|
||||
],
|
||||
'actions': ['深入研究相关技术', '关注行业动态'],
|
||||
'full_analysis': '详细分析内容...'
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps({'result': '模拟JSON响应'})
|
||||
|
||||
if '摘要' in prompt or 'summary' in prompt_lower:
|
||||
return '这是一段关于人工智能发展的重要讨论,涵盖了技术趋势和未来应用。'
|
||||
|
||||
if '关键词' in prompt or 'keyword' in prompt_lower:
|
||||
return '人工智能, 机器学习, 深度学习, 神经网络, 自然语言处理'
|
||||
|
||||
return '模拟AI响应内容'
|
||||
|
||||
def _parse_json_from_text(self, text: str) -> Dict[str, Any]:
|
||||
"""从文本中解析JSON"""
|
||||
# 尝试提取JSON块
|
||||
import re
|
||||
json_match = re.search(r'\{[\s\S]*\}', text)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group())
|
||||
except:
|
||||
pass
|
||||
return {'raw_response': text}
|
||||
|
||||
|
||||
class MultiAIClient:
|
||||
"""
|
||||
多AI客户端 - 支持多种AI提供商的客户端
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config()
|
||||
self.primary_client = AIClient()
|
||||
self.clients = {
|
||||
'openai': self.primary_client,
|
||||
'local': AIClient(), # 可以配置为本地模型
|
||||
}
|
||||
|
||||
def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""使用主客户端进行深度分析"""
|
||||
return self.primary_client._deep_analyze(content_text, content_type, duration)
|
||||
|
||||
def quick_generate(self, prompt: str, max_tokens: int = 100, provider: str = None) -> str:
|
||||
"""快速生成"""
|
||||
client = self.clients.get(provider, self.primary_client)
|
||||
return client.quick_generate(prompt, max_tokens)
|
||||
@@ -0,0 +1,511 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deep Analyzer - AI 深度内容分析工具
|
||||
|
||||
一次性调用 AI 生成完整深度分析报告。
|
||||
|
||||
Usage:
|
||||
python deep_analyzer.py --metadata meta.json --content content.json --output analysis.md [--type video]
|
||||
python deep_analyzer.py --help
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# ============== 数据模型 ==============
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
"""分析结果"""
|
||||
summary: str = ""
|
||||
main_topics: List[str] = field(default_factory=list)
|
||||
key_insights: List[str] = field(default_factory=list)
|
||||
recommended_tags: List[str] = field(default_factory=list)
|
||||
full_analysis: str = ""
|
||||
|
||||
|
||||
# ============== AI 客户端 ==============
|
||||
|
||||
class SimpleAIClient:
|
||||
"""AI 客户端"""
|
||||
|
||||
def __init__(self, api_key: str = None, provider: str = None, model: str = None, base_url: str = None):
|
||||
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
|
||||
self.provider = provider or os.environ.get("AI_PROVIDER", "")
|
||||
self.model = model or os.environ.get("AI_MODEL", "gpt-4")
|
||||
self.base_url = base_url or os.environ.get("AI_BASE_URL", "")
|
||||
self.max_retries = 3
|
||||
|
||||
def generate(self, prompt: str, max_tokens: int = 16000) -> str:
|
||||
"""生成文本"""
|
||||
api_key = os.environ.get("AI_API_KEY", self.api_key)
|
||||
provider = os.environ.get("AI_PROVIDER", self.provider)
|
||||
model = os.environ.get("AI_MODEL", self.model)
|
||||
base_url = os.environ.get("AI_BASE_URL", self.base_url)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError("AI_API_KEY 未设置")
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
import time
|
||||
start_time = time.time()
|
||||
print(f"[AI Client] 开始调用 AI...")
|
||||
|
||||
if provider == "openai":
|
||||
result = self._call_openai(prompt, max_tokens, api_key, model, base_url)
|
||||
elif provider == "zhipu":
|
||||
result = self._call_zhipu(prompt, max_tokens, api_key, model, base_url)
|
||||
elif provider == "minimax":
|
||||
result = self._call_minimax(prompt, max_tokens, api_key, model, base_url)
|
||||
else:
|
||||
raise ValueError(f"不支持的 AI 提供商: {provider}")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[AI Client] AI 调用完成,耗时: {elapsed:.2f}秒")
|
||||
return result
|
||||
except Exception as e:
|
||||
if attempt < self.max_retries - 1:
|
||||
print(f"[AI Client] 第 {attempt + 1} 次调用失败,{self.max_retries - attempt - 1} 次重试剩余: {e}")
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
|
||||
def _call_openai(self, prompt: str, max_tokens: int, api_key: str, model: str, base_url: str) -> str:
|
||||
import requests
|
||||
url = f"{base_url}/chat/completions" if base_url else "https://api.openai.com/v1/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]}
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=600)
|
||||
response.raise_for_status()
|
||||
return response.json()['choices'][0]['message']['content']
|
||||
|
||||
def _call_zhipu(self, prompt: str, max_tokens: int, api_key: str, model: str, base_url: str) -> str:
|
||||
import requests
|
||||
base_url = base_url or "https://open.bigmodel.cn/api/coding/paas/v4"
|
||||
url = f"{base_url}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
payload = {"model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens}
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=600)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
message = data.get('choices', [{}])[0].get('message', {})
|
||||
content = message.get('content', '')
|
||||
reasoning = message.get('reasoning_content', '')
|
||||
if not content and reasoning:
|
||||
sentences = reasoning.strip().split('。')
|
||||
useful = [s.strip() for s in sentences if s.strip() and len(s.strip()) > 10]
|
||||
if useful:
|
||||
return '。'.join(useful[-3:]) + '。'
|
||||
return reasoning.strip()[:500]
|
||||
return content
|
||||
|
||||
def _call_minimax(self, prompt: str, max_tokens: int, api_key: str, model: str, base_url: str) -> str:
|
||||
import requests
|
||||
url = base_url or "https://api.minimaxi.com/anthropic/v1/messages"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]}
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=600)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
for item in data.get('content', []):
|
||||
if item.get('type') == 'text':
|
||||
return item.get('text', '')
|
||||
return str(data)
|
||||
|
||||
|
||||
# ============== 分析器 ==============
|
||||
|
||||
class DeepAnalyzer:
|
||||
"""深度内容分析器"""
|
||||
|
||||
def __init__(self, ai_client: SimpleAIClient = None):
|
||||
self.ai_client = ai_client or SimpleAIClient()
|
||||
|
||||
def analyze(self, metadata: Dict[str, Any], content: Dict[str, Any]) -> AnalysisResult:
|
||||
"""一次性调用 AI 生成完整分析报告"""
|
||||
|
||||
# 标准化元数据
|
||||
metadata = self._normalize_metadata(metadata)
|
||||
|
||||
title = metadata.get('title', '未知')
|
||||
duration = metadata.get('duration', 0)
|
||||
author = metadata.get('author', '')
|
||||
pubtime = metadata.get('pubtime', '')
|
||||
url = metadata.get('url', '')
|
||||
bvid = metadata.get('bvid', '')
|
||||
|
||||
# 获取内容 - 支持多种字段名
|
||||
text = content.get('text', '') or content.get('subtitle', '') or content.get('subtitles', '') or content.get('description', '')
|
||||
|
||||
print(f"[DeepAnalyzer] 开始分析: {title}")
|
||||
print(f"[DeepAnalyzer] 时长: {duration}秒, 内容长度: {len(text)} 字符")
|
||||
|
||||
# 计算分片数(按10分钟分片)
|
||||
num_chunks = max(1, (duration + 599) // 600) # 向上取整到10分钟
|
||||
print(f"[DeepAnalyzer] 将分 {num_chunks} 段分析")
|
||||
|
||||
# 构建 prompt
|
||||
prompt = self._build_prompt(
|
||||
title=title,
|
||||
duration=duration,
|
||||
duration_str=self._format_duration(duration),
|
||||
author=author,
|
||||
pubtime=pubtime,
|
||||
url=url,
|
||||
bvid=bvid,
|
||||
text=text,
|
||||
num_chunks=num_chunks
|
||||
)
|
||||
|
||||
# 调用 AI
|
||||
try:
|
||||
full_analysis = self.ai_client.generate(prompt, max_tokens=16000)
|
||||
except Exception as e:
|
||||
print(f"[DeepAnalyzer] AI 调用失败: {e}")
|
||||
raise
|
||||
|
||||
# 解析结果
|
||||
result = self._parse_analysis(full_analysis, text, duration)
|
||||
|
||||
return result
|
||||
|
||||
def _build_prompt(self, title: str, duration: int, duration_str: str, author: str,
|
||||
pubtime: str, url: str, bvid: str, text: str, num_chunks: int) -> str:
|
||||
"""构建分析 prompt"""
|
||||
|
||||
# 截取适当长度的文本(20-30K 字符)
|
||||
max_chars = 30000
|
||||
if len(text) > max_chars:
|
||||
text_sample = text[:max_chars] + "\n\n[...]"
|
||||
else:
|
||||
text_sample = text
|
||||
|
||||
prompt = f"""你是一个专业的视频内容分析师。请根据以下信息生成深度分析报告:
|
||||
|
||||
【视频信息】
|
||||
标题:{title}
|
||||
作者:{author}
|
||||
时长:{duration_str}({duration}秒)
|
||||
发布日期:{pubtime}
|
||||
平台:bilibili
|
||||
URL:{url}
|
||||
|
||||
【内容】(完整字幕)
|
||||
{text_sample}
|
||||
|
||||
【输出要求】必须使用以下格式:
|
||||
|
||||
## 标题
|
||||
[自己总结的简洁标题,禁止使用原标题]
|
||||
|
||||
## 摘要
|
||||
[200-400字]
|
||||
|
||||
## 主要主题
|
||||
[从内容中提取不超过10个主题,每个不超过10字]
|
||||
- 主题1
|
||||
- 主题2
|
||||
...
|
||||
|
||||
## 关键见解
|
||||
[若干条深刻见解]
|
||||
- 见解1
|
||||
- 见解2
|
||||
...
|
||||
|
||||
## 标签
|
||||
[从内容中提取3-5个精炼标签,每个不超过10字]
|
||||
- 标签1
|
||||
- 标签2
|
||||
...
|
||||
|
||||
## 时间轴
|
||||
### 第 1 部分 (00:00 - 10:00)
|
||||
[详细分析,至少500字]
|
||||
|
||||
### 第 2 部分 (10:00 - 20:00)
|
||||
[详细分析,至少500字]
|
||||
|
||||
...(以此类推)
|
||||
|
||||
【重要】分析应足够详尽,读者阅读后可了解视频核心内容,无需再看原视频。"""
|
||||
|
||||
return prompt
|
||||
|
||||
def _parse_analysis(self, analysis_text: str, original_text: str, duration: int) -> AnalysisResult:
|
||||
"""解析 AI 返回的分析结果"""
|
||||
result = AnalysisResult()
|
||||
result.full_analysis = analysis_text
|
||||
|
||||
# 提取各部分(使用正则)
|
||||
lines = analysis_text.split('\n')
|
||||
|
||||
# 简单解析:按 ## 标题分割
|
||||
current_section = ""
|
||||
in_section = None
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('##'):
|
||||
# 保存上一个 section
|
||||
if in_section:
|
||||
self._store_section(result, in_section, current_section)
|
||||
# 开始新 section
|
||||
section_name = line.lstrip('#').strip()
|
||||
in_section = section_name
|
||||
current_section = ""
|
||||
elif in_section:
|
||||
current_section += line + "\n"
|
||||
|
||||
# 存储最后一个 section
|
||||
if in_section:
|
||||
self._store_section(result, in_section, current_section)
|
||||
|
||||
# 如果解析失败,使用原始文本
|
||||
if not result.summary:
|
||||
result.summary = analysis_text[:500]
|
||||
|
||||
return result
|
||||
|
||||
def _store_section(self, result: AnalysisResult, section_name: str, content: str):
|
||||
"""存储解析到的 section"""
|
||||
content = content.strip()
|
||||
if not content:
|
||||
return
|
||||
|
||||
section_lower = section_name.lower()
|
||||
|
||||
if '摘要' in section_lower:
|
||||
result.summary = content[:500]
|
||||
elif '主题' in section_lower:
|
||||
# 提取主题
|
||||
topics = []
|
||||
for line in content.split('\n'):
|
||||
line = line.strip().lstrip('-').strip()
|
||||
if line and len(line) <= 15:
|
||||
topics.append(line)
|
||||
result.main_topics = topics[:10]
|
||||
elif '见解' in section_lower or '观点' in section_lower:
|
||||
# 提取见解
|
||||
insights = []
|
||||
for line in content.split('\n'):
|
||||
line = line.strip().lstrip('-').strip()
|
||||
if line and len(line) > 5:
|
||||
insights.append(line)
|
||||
result.key_insights = insights
|
||||
elif '标签' in section_lower:
|
||||
# 提取标签
|
||||
tags = []
|
||||
for line in content.split('\n'):
|
||||
line = line.strip().lstrip('-').strip()
|
||||
if line and len(line) <= 10:
|
||||
tags.append(line)
|
||||
result.recommended_tags = tags[:5]
|
||||
|
||||
def _normalize_metadata(self, metadata: Dict) -> Dict:
|
||||
"""标准化元数据"""
|
||||
if not metadata:
|
||||
return {}
|
||||
|
||||
# 如果已经扁平
|
||||
if 'title' in metadata:
|
||||
duration = metadata.get('duration', 0)
|
||||
if isinstance(duration, str) and ':' in duration:
|
||||
parts = duration.split(':')
|
||||
try:
|
||||
metadata['duration'] = int(parts[0]) * 60 + int(parts[1])
|
||||
except:
|
||||
pass
|
||||
return metadata
|
||||
|
||||
# 嵌套结构
|
||||
if 'metadata' in metadata:
|
||||
meta = metadata['metadata']
|
||||
normalized = {}
|
||||
for k, v in meta.items():
|
||||
normalized[k] = v
|
||||
# 处理 duration
|
||||
duration = normalized.get('duration', 0)
|
||||
if isinstance(duration, str) and ':' in duration:
|
||||
parts = duration.split(':')
|
||||
try:
|
||||
normalized['duration'] = int(parts[0]) * 60 + int(parts[1])
|
||||
except:
|
||||
pass
|
||||
return normalized
|
||||
|
||||
return metadata
|
||||
|
||||
def _format_duration(self, seconds: int) -> str:
|
||||
"""格式化时长"""
|
||||
h = seconds // 3600
|
||||
m = (seconds % 3600) // 60
|
||||
s = seconds % 60
|
||||
if h > 0:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
# ============== 工具函数 ==============
|
||||
|
||||
def load_json(file_path: str) -> Dict:
|
||||
"""加载 JSON 文件"""
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return {}
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def generate_frontmatter(metadata: Dict, result: AnalysisResult) -> str:
|
||||
"""生成 Markdown frontmatter"""
|
||||
# 提取标签
|
||||
tags = result.recommended_tags or []
|
||||
|
||||
# 格式化日期
|
||||
pubtime = metadata.get('pubtime', '')
|
||||
if pubtime:
|
||||
date_str = str(pubtime)[:10]
|
||||
else:
|
||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
fm = ["---",
|
||||
f"title: \"{metadata.get('title', '未命名')}\"",
|
||||
f"date: {date_str}",
|
||||
f"duration: {metadata.get('duration_str', metadata.get('duration', 0))}",
|
||||
f"author: {metadata.get('author', '未知')}",
|
||||
f"bvid: {metadata.get('bvid', '')}",
|
||||
f"platform: {metadata.get('platform', 'bilibili')}"]
|
||||
|
||||
# 添加 URL
|
||||
url = metadata.get('url', '')
|
||||
if url:
|
||||
fm.append(f"url: {url}")
|
||||
|
||||
if tags:
|
||||
fm.append("tags: [" + ", ".join(f'"{t}"' for t in tags) + "]")
|
||||
|
||||
fm.append("---")
|
||||
return '\n'.join(fm)
|
||||
|
||||
|
||||
# ============== 主程序 ==============
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Deep Analyzer - AI 深度内容分析工具")
|
||||
parser.add_argument("--metadata", "-m", required=True, help="元数据 JSON 文件路径")
|
||||
parser.add_argument("--content", "-c", required=True, help="内容 JSON 文件路径")
|
||||
parser.add_argument("--output", "-o", required=True, help="输出 Markdown 文件路径")
|
||||
parser.add_argument("--type", "-t", default="video", choices=["video", "article"], help="内容类型")
|
||||
parser.add_argument("--api-key", "-k", default=None, help="AI API Key")
|
||||
parser.add_argument("--provider", "-p", default=None, choices=["openai", "zhipu", "minimax"], help="AI 提供商")
|
||||
parser.add_argument("--model", default=None, help="AI 模型")
|
||||
parser.add_argument("--base-url", "-u", default=None, help="API Base URL")
|
||||
parser.add_argument("--no-frontmatter", action="store_true", help="不生成 frontmatter")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 加载数据
|
||||
print(f"[Main] 加载元数据: {args.metadata}")
|
||||
raw_metadata = load_json(args.metadata)
|
||||
|
||||
print(f"[Main] 加载内容: {args.content}")
|
||||
content = load_json(args.content)
|
||||
|
||||
# 标准化元数据
|
||||
metadata = {}
|
||||
if 'title' in raw_metadata:
|
||||
metadata = raw_metadata.copy()
|
||||
# 处理 duration
|
||||
duration = metadata.get('duration', 0)
|
||||
if isinstance(duration, str) and ':' in duration:
|
||||
parts = duration.split(':')
|
||||
try:
|
||||
metadata['duration'] = int(parts[0]) * 60 + int(parts[1])
|
||||
except:
|
||||
pass
|
||||
metadata['duration_str'] = format_duration(metadata.get('duration', 0))
|
||||
elif 'metadata' in raw_metadata:
|
||||
meta = raw_metadata['metadata']
|
||||
metadata = meta.copy()
|
||||
duration = metadata.get('duration', 0)
|
||||
if isinstance(duration, str) and ':' in duration:
|
||||
parts = duration.split(':')
|
||||
try:
|
||||
metadata['duration'] = int(parts[0]) * 60 + int(parts[1])
|
||||
except:
|
||||
pass
|
||||
metadata['duration_str'] = format_duration(metadata.get('duration', 0))
|
||||
|
||||
# 初始化
|
||||
ai_client = SimpleAIClient(
|
||||
api_key=args.api_key or os.environ.get("AI_API_KEY", ""),
|
||||
provider=args.provider or os.environ.get("AI_PROVIDER", ""),
|
||||
model=args.model or os.environ.get("AI_MODEL", "gpt-4"),
|
||||
base_url=args.base_url or os.environ.get("AI_BASE_URL", "")
|
||||
)
|
||||
analyzer = DeepAnalyzer(ai_client)
|
||||
|
||||
# 执行分析
|
||||
print(f"[Main] 开始分析...")
|
||||
result = analyzer.analyze(metadata, content)
|
||||
|
||||
# 使用 AI 生成的原始内容(保留 Markdown 格式)
|
||||
full_analysis = result.full_analysis
|
||||
|
||||
# 删除内容中的"标签" section(只保留 frontmatter 中的 tags)
|
||||
# 匹配 ## 标签 或 标签(无##)到下一个 ## 或文件结尾之间的内容
|
||||
full_analysis = re.sub(r'(## ?)?标签\n[\s\S]*?(?=\n## ?|\n时间轴|---|\Z)', '', full_analysis)
|
||||
# 清理多余的空行
|
||||
full_analysis = re.sub(r'\n{3,}', '\n\n', full_analysis)
|
||||
|
||||
# 写入输出
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
|
||||
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
# 添加 frontmatter
|
||||
if not args.no_frontmatter:
|
||||
fm = generate_frontmatter(metadata, result)
|
||||
f.write(fm + "\n\n")
|
||||
|
||||
# 写入分析内容
|
||||
f.write(full_analysis)
|
||||
|
||||
# 输出结果
|
||||
output = {
|
||||
"success": True,
|
||||
"filename": Path(args.output).name,
|
||||
"filepath": str(Path(args.output).resolve()),
|
||||
"message": "深度分析完成",
|
||||
"stats": {
|
||||
"summary_length": len(result.summary) if result.summary else 0,
|
||||
"topics_count": len(result.main_topics),
|
||||
"insights_count": len(result.key_insights),
|
||||
"tags_count": len(result.recommended_tags)
|
||||
}
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def format_duration(seconds: int) -> str:
|
||||
"""格式化时长"""
|
||||
h = seconds // 3600
|
||||
m = (seconds % 3600) // 60
|
||||
s = seconds % 60
|
||||
if h > 0:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
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 等常见格式
|
||||
- **首次运行**: 首次运行时会自动下载模型文件,需要稳定的网络连接
|
||||
Binary file not shown.
@@ -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,43 @@
|
||||
# KB Link Parser Skill
|
||||
|
||||
**kb-link-parser** - Extract and resolve IDs from various platform URLs.
|
||||
|
||||
## Features
|
||||
|
||||
- Multi-platform support: Bilibili, YouTube, Zhihu
|
||||
- Short link detection: b23.tv and other short links
|
||||
- Multiple content types: video, article, up主, playlist, collection
|
||||
- Batch processing from file
|
||||
- JSON output format
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Parse single URL
|
||||
python link-parser.py --url "https://www.bilibili.com/video/BV1xx411c7mD"
|
||||
|
||||
# Batch processing
|
||||
python link-parser.py --batch urls.txt --output results.json
|
||||
|
||||
# Output format
|
||||
python link-parser.py --url "..." --format json
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"platform": "bilibili",
|
||||
"type": "video",
|
||||
"id": "BV1xx411c7mD"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cp -r kb-link-parser/ ~/skills/
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,637 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Link Parser - Extract and resolve IDs from various platform URLs.
|
||||
|
||||
Supports:
|
||||
- Bilibili (video, article, up主, collection, series)
|
||||
- YouTube (video, channel, playlist)
|
||||
- Zhihu (question, answer, user, collection)
|
||||
- Xiaohongshu (note, user)
|
||||
- Short links (b23.tv, xhslink.com, etc.)
|
||||
|
||||
Usage:
|
||||
python link-parser.py --url "https://www.bilibili.com/video/BV1xx411c7mD"
|
||||
python link-parser.py --batch urls.txt --output results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from link_parser_shortlink import ShortLinkResolver
|
||||
|
||||
|
||||
class LinkParser:
|
||||
"""Main link parser class"""
|
||||
|
||||
# Bilibili patterns
|
||||
BILIBILI_VIDEO_PATTERN = re.compile(r'https?://(?:www\.)?bilibili\.com/video/(BV[a-zA-Z0-9]+)')
|
||||
BILIBILI_VIDEO_SHORT_PATTERN = re.compile(r'https?://b23\.tv/([a-zA-Z0-9]+)')
|
||||
BILIBILI_ARTICLE_PATTERN = re.compile(r'https?://(?:www\.)?bilibili\.com/(?:article|read)/([a-zA-Z0-9]+)')
|
||||
BILIBILI_SPACE_PATTERN = re.compile(r'https?://(?:space\.)?bilibili\.com/(\d+)')
|
||||
BILIBILI_MOBILE_SPACE_PATTERN = re.compile(r'https?://m\.bilibili\.com/space/(\d+)')
|
||||
BILIBILI_MOBILE_OPUS_PATTERN = re.compile(r'https?://m\.bilibili\.com/opus/(\d+)')
|
||||
BILIBILI_FAVLIST_PATTERN = re.compile(r'https?://(?:space\.)?bilibili\.com/\d+/favlist\?fid=(\d+)')
|
||||
BILIBILI_OPUS_PATTERN = re.compile(r'https?://(?:www\.)?bilibili\.com/opus/(\d+)')
|
||||
BILIBILI_SERIES_PATTERN = re.compile(r'https?://(?:space\.)?bilibili\.com/\d+/lists/(\d+)\?type=series')
|
||||
BILIBILI_READLIST_PATTERN = re.compile(r'https?://(?:www\.)?bilibili\.com/read/readlist/(rl[a-zA-Z0-9]+)')
|
||||
|
||||
# YouTube patterns
|
||||
YOUTUBE_VIDEO_PATTERN = re.compile(r'(?:https?://)?(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})')
|
||||
YOUTUBE_CHANNEL_PATTERN = re.compile(r'(?:https?://)?(?:www\.)?youtube\.com/channel/([a-zA-Z0-9_-]+)')
|
||||
YOUTUBE_USER_PATTERN = re.compile(r'(?:https?://)?(?:www\.)?youtube\.com/user/([a-zA-Z0-9_-]+)')
|
||||
YOUTUBE_PLAYLIST_PATTERN = re.compile(r'(?:https?://)?(?:www\.)?youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)')
|
||||
|
||||
# Xiaohongshu (小红书) patterns
|
||||
XIAOHONGSHU_SHORT_PATTERN = re.compile(r'https?://xhslink\.com/o/([a-zA-Z0-9]+)')
|
||||
XIAOHONGSHU_NOTE_PATTERN = re.compile(r'https?://(?:www\.)?xiaohongshu\.com/explore/([a-zA-Z0-9]+)')
|
||||
XIAOHONGSHU_NOTE_PATTERN2 = re.compile(r'https?://(?:www\.)?xiaohongshu\.com/notes/([a-zA-Z0-9]+)')
|
||||
XIAOHONGSHU_DISCOVERY_PATTERN = re.compile(r'https?://(?:www\.)?xiaohongshu\.com/discovery/item/([a-zA-Z0-9]+)')
|
||||
XIAOHONGSHU_USER_PATTERN = re.compile(r'https?://(?:www\.)?xiaohongshu\.com/user/profile/(\d+)')
|
||||
|
||||
# Zhihu patterns
|
||||
ZHIHAU_QUESTION_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/question/(\d+)')
|
||||
ZHIHAU_ANSWER_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/question/\d+/answer/(\d+)')
|
||||
ZHIHAU_USER_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/people/([a-zA-Z0-9_-]+)')
|
||||
ZHIHAU_COLUMN_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/column/([a-zA-Z0-9_-]+)')
|
||||
ZHIHAU_COLLECTION_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/collection/(\d+)')
|
||||
# Additional Zhihu patterns
|
||||
ZHIHAU_ZHUANLAN_PATTERN = re.compile(r'https?://zhuanlan\.zhihu\.com/p/(\d+)')
|
||||
ZHIHAU_ORG_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/org/([a-zA-Z0-9_-]+)')
|
||||
ZHIHAU_ORG_ANSWERS_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/org/[a-zA-Z0-9_-]+/answers')
|
||||
ZHIHAU_ORG_VIDEOS_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/org/[a-zA-Z0-9_-]+/zvideos')
|
||||
ZHIHAU_ORG_ASKS_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/org/[a-zA-Z0-9_-]+/asks')
|
||||
ZHIHAU_ORG_POSTS_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/org/[a-zA-Z0-9_-]+/posts')
|
||||
ZHIHAU_ORG_PINS_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/org/[a-zA-Z0-9_-]+/pins')
|
||||
ZHIHAU_ZVIDEO_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/zvideo/(\d+)')
|
||||
|
||||
def __init__(self):
|
||||
self.shortlink_resolver = ShortLinkResolver()
|
||||
|
||||
def resolve_shortlink(self, url: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve short link to get final URL.
|
||||
|
||||
Args:
|
||||
url: Short URL to resolve
|
||||
|
||||
Returns:
|
||||
Final URL after resolution, or None if not resolvable
|
||||
"""
|
||||
# Check if it's a known short link pattern
|
||||
if self.shortlink_resolver.is_bilibili_short_link(url):
|
||||
# Try to resolve using requests (follows redirects)
|
||||
try:
|
||||
import requests
|
||||
response = requests.head(url, allow_redirects=True, timeout=10)
|
||||
return response.url
|
||||
except Exception:
|
||||
# Fallback: return original URL
|
||||
return url
|
||||
|
||||
# Check if it's Xiaohongshu short link
|
||||
if self.XIAOHONGSHU_SHORT_PATTERN.match(url):
|
||||
try:
|
||||
import requests
|
||||
response = requests.get(url, allow_redirects=True, timeout=10)
|
||||
return response.url
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
return url
|
||||
|
||||
def extract_bilibili_id(self, url: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Extract Bilibili ID from URL.
|
||||
|
||||
Args:
|
||||
url: Bilibili URL
|
||||
|
||||
Returns:
|
||||
Dict with platform, type, and id fields, or None if not matched
|
||||
"""
|
||||
# Try series/playlist (must check before space pattern)
|
||||
match = self.BILIBILI_SERIES_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "bilibili",
|
||||
"type": "playlist",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try readlist (专栏合集)
|
||||
match = self.BILIBILI_READLIST_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "bilibili",
|
||||
"type": "readlist",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try favlist (收藏夹)
|
||||
match = self.BILIBILI_FAVLIST_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "bilibili",
|
||||
"type": "favlist",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try opus (动态)
|
||||
match = self.BILIBILI_OPUS_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "bilibili",
|
||||
"type": "opus",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try mobile opus (移动端动态)
|
||||
match = self.BILIBILI_MOBILE_OPUS_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "bilibili",
|
||||
"type": "opus",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try video
|
||||
match = self.BILIBILI_VIDEO_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "bilibili",
|
||||
"type": "video",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try article/read
|
||||
match = self.BILIBILI_ARTICLE_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "bilibili",
|
||||
"type": "article",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try space (UP主) - must check after specific patterns
|
||||
match = self.BILIBILI_SPACE_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "bilibili",
|
||||
"type": "up主",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try mobile space (移动端UP主空间)
|
||||
match = self.BILIBILI_MOBILE_SPACE_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "bilibili",
|
||||
"type": "up主",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def extract_youtube_id(self, url: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Extract YouTube ID from URL.
|
||||
|
||||
Args:
|
||||
url: YouTube URL
|
||||
|
||||
Returns:
|
||||
Dict with platform, type, and id fields, or None if not matched
|
||||
"""
|
||||
# Try video
|
||||
match = self.YOUTUBE_VIDEO_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "youtube",
|
||||
"type": "video",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try channel
|
||||
match = self.YOUTUBE_CHANNEL_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "youtube",
|
||||
"type": "channel",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try user
|
||||
match = self.YOUTUBE_USER_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "youtube",
|
||||
"type": "user",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try playlist
|
||||
match = self.YOUTUBE_PLAYLIST_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "youtube",
|
||||
"type": "playlist",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def extract_zhihu_id(self, url: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Extract Zhihu ID from URL.
|
||||
|
||||
Args:
|
||||
url: Zhihu URL
|
||||
|
||||
Returns:
|
||||
Dict with platform, type, and id fields, or None if not matched
|
||||
"""
|
||||
# Try question
|
||||
match = self.ZHIHAU_QUESTION_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "question",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try answer
|
||||
match = self.ZHIHAU_ANSWER_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "answer",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try user
|
||||
match = self.ZHIHAU_USER_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "user",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try column
|
||||
match = self.ZHIHAU_COLUMN_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "column",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try collection
|
||||
match = self.ZHIHAU_COLLECTION_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "collection",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try zhuanlan article (专栏文章)
|
||||
match = self.ZHIHAU_ZHUANLAN_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "zhuanlan",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try org page (机构页面)
|
||||
match = self.ZHIHAU_ORG_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "org",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try org answers (机构回答)
|
||||
if self.ZHIHAU_ORG_ANSWERS_PATTERN.match(url):
|
||||
match = self.ZHIHAU_ORG_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "org_answers",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try org zvideos (机构视频)
|
||||
if self.ZHIHAU_ORG_VIDEOS_PATTERN.match(url):
|
||||
match = self.ZHIHAU_ORG_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "org_zvideos",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try org asks (机构提问)
|
||||
if self.ZHIHAU_ORG_ASKS_PATTERN.match(url):
|
||||
match = self.ZHIHAU_ORG_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "org_asks",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try org posts (机构文章)
|
||||
if self.ZHIHAU_ORG_POSTS_PATTERN.match(url):
|
||||
match = self.ZHIHAU_ORG_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "org_posts",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try org pins (机构想法)
|
||||
if self.ZHIHAU_ORG_PINS_PATTERN.match(url):
|
||||
match = self.ZHIHAU_ORG_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "org_pins",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try zvideo (知乎视频)
|
||||
match = self.ZHIHAU_ZVIDEO_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "zhihu",
|
||||
"type": "zvideo",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def extract_xiaohongshu_id(self, url: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Extract Xiaohongshu ID from URL.
|
||||
|
||||
Args:
|
||||
url: Xiaohongshu URL
|
||||
|
||||
Returns:
|
||||
Dict with platform, type, and id fields, or None if not matched
|
||||
"""
|
||||
# Try short link
|
||||
match = self.XIAOHONGSHU_SHORT_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "xiaohongshu",
|
||||
"type": "note",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try explore link
|
||||
match = self.XIAOHONGSHU_NOTE_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "xiaohongshu",
|
||||
"type": "note",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try notes link
|
||||
match = self.XIAOHONGSHU_NOTE_PATTERN2.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "xiaohongshu",
|
||||
"type": "note",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try discovery item link (resolved from short link)
|
||||
match = self.XIAOHONGSHU_DISCOVERY_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "xiaohongshu",
|
||||
"type": "note",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
# Try user profile
|
||||
match = self.XIAOHONGSHU_USER_PATTERN.match(url)
|
||||
if match:
|
||||
return {
|
||||
"platform": "xiaohongshu",
|
||||
"type": "user",
|
||||
"id": match.group(1)
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def parse(self, url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse URL and extract platform-specific ID.
|
||||
|
||||
Args:
|
||||
url: URL to parse
|
||||
|
||||
Returns:
|
||||
Dict with platform, type, id, and original_url fields
|
||||
"""
|
||||
result = {
|
||||
"platform": None,
|
||||
"type": None,
|
||||
"id": None,
|
||||
"original_url": url,
|
||||
"resolved_url": None,
|
||||
"success": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# First, resolve short links
|
||||
resolved = self.resolve_shortlink(url)
|
||||
if resolved:
|
||||
result["resolved_url"] = resolved
|
||||
url = resolved
|
||||
|
||||
# Try each platform
|
||||
bilibili_result = self.extract_bilibili_id(url)
|
||||
if bilibili_result:
|
||||
result.update(bilibili_result)
|
||||
result["success"] = True
|
||||
return result
|
||||
|
||||
youtube_result = self.extract_youtube_id(url)
|
||||
if youtube_result:
|
||||
result.update(youtube_result)
|
||||
result["success"] = True
|
||||
return result
|
||||
|
||||
zhihu_result = self.extract_zhihu_id(url)
|
||||
if zhihu_result:
|
||||
result.update(zhihu_result)
|
||||
result["success"] = True
|
||||
return result
|
||||
|
||||
# Try Xiaohongshu
|
||||
xiaohongshu_result = self.extract_xiaohongshu_id(url)
|
||||
if xiaohongshu_result:
|
||||
result.update(xiaohongshu_result)
|
||||
result["success"] = True
|
||||
return result
|
||||
|
||||
# Check for short links that need resolution
|
||||
short_links = self.shortlink_resolver.detect_short_links(url)
|
||||
if short_links:
|
||||
result["error"] = "Short link detected. Please provide the full URL."
|
||||
return result
|
||||
|
||||
result["error"] = "Unsupported URL format"
|
||||
return result
|
||||
|
||||
|
||||
def format_output(data: Dict[str, Any], output_format: str = "json") -> str:
|
||||
"""Format output based on specified format."""
|
||||
if output_format == "json":
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
elif output_format == "compact":
|
||||
return json.dumps(data, ensure_ascii=False, separators=(',', ':'))
|
||||
elif output_format == "text":
|
||||
lines = []
|
||||
for key, value in data.items():
|
||||
if value is not None:
|
||||
lines.append(f"{key}: {value}")
|
||||
return "\n".join(lines)
|
||||
else:
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Parse and extract IDs from various platform URLs",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --url "https://www.bilibili.com/video/BV1xx411c7mD"
|
||||
%(prog)s --url "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
%(prog)s --url "https://www.zhihu.com/question/266309957"
|
||||
%(prog)s --batch urls.txt --output results.json
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--url", "-u",
|
||||
help="Single URL to parse"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--batch", "-b",
|
||||
help="File containing URLs to parse (one per line)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
help="Output file path (for batch mode)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--format", "-f",
|
||||
choices=["json", "compact", "text"],
|
||||
default="json",
|
||||
help="Output format (default: json)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check if we're in shortlink help mode
|
||||
if len(sys.argv) == 2 and sys.argv[1] in ['--help', '-h']:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
if not args.url and not args.batch:
|
||||
parser.print_help()
|
||||
print("\nError: Please provide --url or --batch")
|
||||
return 1
|
||||
|
||||
link_parser = LinkParser()
|
||||
|
||||
if args.url:
|
||||
# Single URL mode
|
||||
parsed = link_parser.parse(args.url)
|
||||
|
||||
# 统一输出格式
|
||||
if parsed["success"]:
|
||||
output = {
|
||||
"success": True,
|
||||
"platform": parsed.get("platform"),
|
||||
"type": parsed.get("type"),
|
||||
"id": parsed.get("id"),
|
||||
"filename": f"{parsed.get('platform')}_{parsed.get('id')}.link.json" if parsed.get("platform") and parsed.get("id") else None,
|
||||
"message": "URL解析成功"
|
||||
}
|
||||
else:
|
||||
output = {
|
||||
"success": False,
|
||||
"error": parsed.get("error", "解析失败"),
|
||||
"detail": f"URL: {args.url}"
|
||||
}
|
||||
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
return 0 if output["success"] else 1
|
||||
|
||||
elif args.batch:
|
||||
try:
|
||||
with open(args.batch, 'r', encoding='utf-8') as f:
|
||||
urls = [line.strip() for line in f if line.strip()]
|
||||
except Exception as e:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "读取文件失败",
|
||||
"detail": str(e)
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
results = []
|
||||
for url in urls:
|
||||
parsed = link_parser.parse(url)
|
||||
if parsed["success"]:
|
||||
results.append({
|
||||
"success": True,
|
||||
"platform": parsed.get("platform"),
|
||||
"type": parsed.get("type"),
|
||||
"id": parsed.get("id"),
|
||||
"filename": f"{parsed.get('platform')}_{parsed.get('id')}.link.json" if parsed.get("platform") and parsed.get("id") else None
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"success": False,
|
||||
"error": parsed.get("error", "解析失败"),
|
||||
"detail": f"URL: {url}"
|
||||
})
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
print(json.dumps({
|
||||
"success": True,
|
||||
"message": f"结果已保存到 {args.output}",
|
||||
"total": len(results)
|
||||
}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
for result in results:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print("-" * 40)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Short link resolver for link-parser skill.
|
||||
|
||||
Provides utilities for detecting and resolving short links, particularly
|
||||
Bilibili short links (b23.tv) which may be blocked by anti-scraping measures.
|
||||
|
||||
Supported short link services:
|
||||
- Bilibili: b23.tv
|
||||
- Generic URL shorteners (detection only)
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class ShortLinkResolver:
|
||||
"""Short link detection and resolution handler"""
|
||||
|
||||
# Pattern for Bilibili short links
|
||||
BILIBILI_SHORT_PATTERN = re.compile(r'https?://b23\.tv/([a-zA-Z0-9]+)')
|
||||
|
||||
# Pattern for other short link services
|
||||
OTHER_SHORT_PATTERNS = {
|
||||
'bit.ly': re.compile(r'https?://bit\.ly/\w+'),
|
||||
't.cn': re.compile(r'https?://t\.cn/\w+'),
|
||||
'dwz.cn': re.compile(r'https?://dwz\.cn/\w+'),
|
||||
'goo.gl': re.compile(r'https?://goo\.gl/\w+'),
|
||||
'tinyurl.com': re.compile(r'https?://tinyurl\.com/\w+'),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def detect_short_links(text: str) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Detect short links in text.
|
||||
|
||||
Args:
|
||||
text: Input text to search
|
||||
|
||||
Returns:
|
||||
List of detected short links with metadata:
|
||||
[
|
||||
{
|
||||
'url': 'https://b23.tv/xxxxx',
|
||||
'platform': 'bilibili',
|
||||
'is_short': True
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
detected = []
|
||||
|
||||
# Check for Bilibili short links
|
||||
for match in ShortLinkResolver.BILIBILI_SHORT_PATTERN.finditer(text):
|
||||
detected.append({
|
||||
'url': match.group(0),
|
||||
'code': match.group(1),
|
||||
'platform': 'bilibili',
|
||||
'is_short': True
|
||||
})
|
||||
|
||||
# Check for other short link services
|
||||
for platform, pattern in ShortLinkResolver.OTHER_SHORT_PATTERNS.items():
|
||||
for match in pattern.finditer(text):
|
||||
detected.append({
|
||||
'url': match.group(0),
|
||||
'platform': platform,
|
||||
'is_short': True
|
||||
})
|
||||
|
||||
return detected
|
||||
|
||||
@staticmethod
|
||||
def is_bilibili_short_link(url: str) -> bool:
|
||||
"""
|
||||
Check if URL is a Bilibili short link.
|
||||
|
||||
Args:
|
||||
url: URL to check
|
||||
|
||||
Returns:
|
||||
True if URL is a b23.tv short link
|
||||
"""
|
||||
return bool(ShortLinkResolver.BILIBILI_SHORT_PATTERN.match(url))
|
||||
|
||||
@staticmethod
|
||||
def format_short_link_help(short_link: Optional[str] = None) -> str:
|
||||
"""
|
||||
Format help message for short links.
|
||||
|
||||
Args:
|
||||
short_link: Optional short link URL to mention
|
||||
|
||||
Returns:
|
||||
Help message
|
||||
"""
|
||||
lines = [
|
||||
"Detection: Short link",
|
||||
""
|
||||
]
|
||||
|
||||
if short_link:
|
||||
lines.append(f"Short link: {short_link}")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"Due to anti-scraping protection, short links cannot be automatically resolved.",
|
||||
"Please provide the full URL or the content ID (e.g., BV号 for Bilibili).",
|
||||
"",
|
||||
"Full URL format: https://www.bilibili.com/video/BVxxxxx"
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def format_short_link_warning(short_links: List[Dict[str, str]]) -> str:
|
||||
"""
|
||||
Format warning message for multiple short links.
|
||||
|
||||
Args:
|
||||
short_links: List of short link metadata
|
||||
|
||||
Returns:
|
||||
Warning message
|
||||
"""
|
||||
if not short_links:
|
||||
return ""
|
||||
|
||||
lines = [
|
||||
"Short links detected:",
|
||||
""
|
||||
]
|
||||
|
||||
for i, link in enumerate(short_links, 1):
|
||||
platform = link.get('platform', 'unknown')
|
||||
url = link['url']
|
||||
|
||||
if platform == 'bilibili':
|
||||
lines.append(f" [{i}] Bilibili short link: {url}")
|
||||
lines.append(" Full URL or BV ID required")
|
||||
else:
|
||||
lines.append(f" [{i}] {platform}: {url}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class PlaywrightResolver:
|
||||
"""
|
||||
Browser-based short link resolver using Playwright.
|
||||
|
||||
This is an optional implementation for environments where Playwright
|
||||
is available. It can bypass anti-scraping measures by simulating
|
||||
a real browser.
|
||||
|
||||
Requirements:
|
||||
pip install playwright
|
||||
playwright install chromium
|
||||
"""
|
||||
|
||||
def __init__(self, headless: bool = True, timeout: int = 10000):
|
||||
"""
|
||||
Initialize Playwright resolver.
|
||||
|
||||
Args:
|
||||
headless: Whether to run browser in headless mode
|
||||
timeout: Page navigation timeout in milliseconds
|
||||
"""
|
||||
self.headless = headless
|
||||
self.timeout = timeout
|
||||
self._playwright = None
|
||||
|
||||
def _check_playwright_available(self) -> bool:
|
||||
"""Check if Playwright is installed and available"""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def resolve(self, short_url: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve short link using Playwright browser automation.
|
||||
|
||||
Args:
|
||||
short_url: Short URL to resolve
|
||||
|
||||
Returns:
|
||||
Final URL after redirects, or None if resolution fails
|
||||
"""
|
||||
if not self._check_playwright_available():
|
||||
raise ImportError(
|
||||
"Playwright not installed. Install with: pip install playwright && playwright install chromium"
|
||||
)
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
# Launch browser
|
||||
browser = p.chromium.launch(headless=self.headless)
|
||||
context = browser.new_context(
|
||||
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',
|
||||
viewport={'width': 1920, 'height': 1080}
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
# Navigate to short URL
|
||||
page.goto(short_url, timeout=self.timeout, wait_until='networkidle')
|
||||
|
||||
# Get final URL
|
||||
final_url = page.url
|
||||
|
||||
# Close browser
|
||||
browser.close()
|
||||
|
||||
return final_url
|
||||
|
||||
except Exception as e:
|
||||
print(f"Playwright resolution failed: {e}")
|
||||
return None
|
||||
|
||||
def resolve_bilibili_short(self, short_url: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve Bilibili short link and extract BV ID.
|
||||
|
||||
Args:
|
||||
short_url: Bilibili short URL (e.g., https://b23.tv/xxxxx)
|
||||
|
||||
Returns:
|
||||
Full Bilibili URL with BV ID, or None if resolution fails
|
||||
"""
|
||||
final_url = self.resolve(short_url)
|
||||
if not final_url:
|
||||
return None
|
||||
|
||||
# Check if final URL contains BV ID
|
||||
if 'bilibili.com/video/' in final_url:
|
||||
return final_url
|
||||
|
||||
# Extract BV ID if present in URL
|
||||
bv_pattern = re.compile(r'(BV[a-zA-Z0-9]{10})')
|
||||
match = bv_pattern.search(final_url)
|
||||
if match:
|
||||
return f"https://www.bilibili.com/video/{match.group(1)}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_resolver() -> ShortLinkResolver:
|
||||
"""
|
||||
Get the default resolver instance.
|
||||
|
||||
Returns:
|
||||
ShortLinkResolver instance
|
||||
"""
|
||||
return ShortLinkResolver()
|
||||
|
||||
|
||||
def detect_short_links(text: str) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Detect short links in text.
|
||||
|
||||
Args:
|
||||
text: Input text to search
|
||||
|
||||
Returns:
|
||||
List of detected short links with metadata
|
||||
"""
|
||||
resolver = ShortLinkResolver()
|
||||
return resolver.detect_short_links(text)
|
||||
|
||||
|
||||
# CLI interface for testing
|
||||
def main():
|
||||
"""CLI for testing the short link resolver."""
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 link_parser_shortlink.py 'text to test'")
|
||||
print("\nExamples:")
|
||||
print(' python3 link_parser_shortlink.py "Check this: https://b23.tv/oLBZZfK"')
|
||||
print(' python3 link_parser_shortlink.py "https://bit.ly/xxxxx"')
|
||||
sys.exit(1)
|
||||
|
||||
text = ' '.join(sys.argv[1:])
|
||||
short_links = detect_short_links(text)
|
||||
|
||||
import json
|
||||
print(json.dumps(short_links, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,66 @@
|
||||
# KB OCR Processor Skill
|
||||
|
||||
**kb-ocr-processor** - Extract text from video frames using OCR.
|
||||
|
||||
## Features
|
||||
|
||||
- **视频截帧**: 直接从视频文件截取帧图片
|
||||
- **批量处理**: 支持处理帧目录
|
||||
- **双引擎**: EasyOCR / Tesseract
|
||||
- **置信度评分**: 每帧识别结果带置信度
|
||||
- **文本合并**: 自动合并所有帧的识别结果
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# 方式1: 直接从视频截帧并OCR(推荐)
|
||||
python ocr_processor.py --video ./video.flv --output ocr.json
|
||||
|
||||
# 方式2: 处理已截取的帧目录
|
||||
python ocr_processor.py --input ./frames/ --output ocr.json
|
||||
|
||||
# 指定截帧帧率(默认1帧/秒)
|
||||
python ocr_processor.py --video ./video.flv --fps 2 --output ocr.json
|
||||
|
||||
# 同时生成合并的文本文件
|
||||
python ocr_processor.py --video ./video.flv --output ocr.json --merge
|
||||
|
||||
# 使用 Tesseract 引擎
|
||||
python ocr_processor.py --input ./frames/ --engine tesseract --output ocr.json
|
||||
```
|
||||
|
||||
## Workflow 中的使用
|
||||
|
||||
```bash
|
||||
# 步骤5: OCR处理(视频无字幕时)
|
||||
python ocr_processor.py --video ${CONTENT_VIDEO_PATH} --output ${CONTENT_DIR}/ocr.json --merge
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"engine": "easyocr",
|
||||
"total_frames": 619,
|
||||
"results": [
|
||||
{
|
||||
"source": "0001.jpg",
|
||||
"text": "字幕文字",
|
||||
"confidence": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cp -r kb-ocr-processor/ ~/skills/
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 依赖说明
|
||||
# - easyocr: 主要OCR引擎(自动下载模型)
|
||||
# - pytesseract: Tesseract引擎
|
||||
# - ffmpeg: 视频截帧(apt install ffmpeg)
|
||||
```
|
||||
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OCR Processor - 视频帧文字识别工具
|
||||
支持 EasyOCR 和 Tesseract 引擎
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import glob
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
import easyocr
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class OCRProcessor:
|
||||
"""OCR 文字识别处理器"""
|
||||
|
||||
def __init__(self, engine: str = "easyocr", languages: List[str] = ["ch_sim", "en"]):
|
||||
"""
|
||||
初始化 OCR 处理器
|
||||
|
||||
Args:
|
||||
engine: OCR 引擎 ("easyocr" 或 "tesseract")
|
||||
languages: 语言列表
|
||||
"""
|
||||
self.engine = engine
|
||||
self.languages = languages
|
||||
self.reader = None
|
||||
|
||||
if engine == "easyocr":
|
||||
self.reader = easyocr.Reader(languages, gpu=False)
|
||||
|
||||
def process_image(self, image_path: str) -> Dict:
|
||||
"""
|
||||
处理单张图片进行 OCR 识别
|
||||
|
||||
Args:
|
||||
image_path: 图片路径
|
||||
|
||||
Returns:
|
||||
包含 text 和 confidence 的字典
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
return {"text": "", "confidence": 0.0, "error": "File not found"}
|
||||
|
||||
try:
|
||||
if self.engine == "easyocr":
|
||||
return self._process_with_easyocr(image_path)
|
||||
elif self.engine == "tesseract":
|
||||
return self._process_with_tesseract(image_path)
|
||||
else:
|
||||
return {"text": "", "confidence": 0.0, "error": f"Unknown engine: {self.engine}"}
|
||||
except Exception as e:
|
||||
return {"text": "", "confidence": 0.0, "error": str(e)}
|
||||
|
||||
def _process_with_easyocr(self, image_path: str) -> Dict:
|
||||
"""使用 EasyOCR 处理图片"""
|
||||
results = self.reader.readtext(image_path)
|
||||
|
||||
if not results:
|
||||
return {"text": "", "confidence": 0.0}
|
||||
|
||||
texts = []
|
||||
confidences = []
|
||||
|
||||
for bbox, text, conf in results:
|
||||
texts.append(text)
|
||||
confidences.append(conf)
|
||||
|
||||
merged_text = "\n".join(texts)
|
||||
avg_confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
||||
|
||||
return {
|
||||
"text": merged_text,
|
||||
"confidence": round(avg_confidence, 4),
|
||||
"details": [
|
||||
{"text": text, "confidence": round(conf, 4)}
|
||||
for text, conf in zip(texts, confidences)
|
||||
]
|
||||
}
|
||||
|
||||
def _process_with_tesseract(self, image_path: str) -> Dict:
|
||||
"""使用 Tesseract 处理图片"""
|
||||
image = Image.open(image_path)
|
||||
text = pytesseract.image_to_string(image, lang="+".join(self.languages))
|
||||
|
||||
# Tesseract 不直接返回置信度,返回 0.0
|
||||
return {
|
||||
"text": text.strip(),
|
||||
"confidence": 0.0
|
||||
}
|
||||
|
||||
def process_frames(self, input_dir: str, fps: float = 0.1, progress_file: Optional[str] = None) -> List[Dict]:
|
||||
"""
|
||||
处理目录下所有帧图片
|
||||
|
||||
Args:
|
||||
input_dir: 帧图片目录
|
||||
fps: 采样帧率
|
||||
progress_file: 可选,进度文件路径(会实时更新)
|
||||
|
||||
Returns:
|
||||
OCR 结果列表
|
||||
"""
|
||||
if not os.path.isdir(input_dir):
|
||||
print(f"Error: Directory not found: {input_dir}")
|
||||
return []
|
||||
|
||||
# 支持的图片格式
|
||||
image_extensions = ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp"]
|
||||
image_files = []
|
||||
|
||||
for ext in image_extensions:
|
||||
image_files.extend(glob.glob(os.path.join(input_dir, ext)))
|
||||
image_files.extend(glob.glob(os.path.join(input_dir, "**", ext), recursive=True))
|
||||
|
||||
# 按文件名排序
|
||||
image_files = sorted(image_files)
|
||||
|
||||
if not image_files:
|
||||
print(f"No images found in {input_dir}")
|
||||
return []
|
||||
|
||||
total = len(image_files)
|
||||
print(f"Found {total} images in {input_dir}")
|
||||
print(f"Processing with {self.engine} engine...")
|
||||
|
||||
def update_progress(current, status="running", last_result=None):
|
||||
"""更新进度文件"""
|
||||
if progress_file:
|
||||
import json
|
||||
import time
|
||||
prog = {
|
||||
"current": current,
|
||||
"total": total,
|
||||
"percent": round(current / total * 100, 1) if total > 0 else 0,
|
||||
"status": status,
|
||||
"last_result": last_result,
|
||||
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
with open(progress_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(prog, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 初始化进度
|
||||
update_progress(0, "started")
|
||||
|
||||
results = []
|
||||
for i, image_path in enumerate(image_files, 1):
|
||||
basename = os.path.basename(image_path)
|
||||
print(f"Processing [{i}/{total}]: {basename}")
|
||||
|
||||
ocr_result = self.process_image(image_path)
|
||||
ocr_result["source"] = basename
|
||||
|
||||
# 记录结果摘要
|
||||
text_count = len(ocr_result.get("text", "").split()) if ocr_result.get("text") else 0
|
||||
last_result = f"✓ {text_count} 处文字" if ocr_result.get("text") else "✗ 无文字"
|
||||
|
||||
results.append(ocr_result)
|
||||
|
||||
# 实时更新进度
|
||||
update_progress(i, "running", last_result)
|
||||
|
||||
# 完成
|
||||
update_progress(total, "completed", f"完成 {total} 张")
|
||||
|
||||
print(f"\n✅ Processed {total} frames")
|
||||
return results
|
||||
|
||||
def merge_texts(self, results: List[Dict]) -> str:
|
||||
"""
|
||||
合并多个 OCR 结果的文本
|
||||
|
||||
Args:
|
||||
results: OCR 结果列表
|
||||
|
||||
Returns:
|
||||
合并后的文本
|
||||
"""
|
||||
texts = []
|
||||
for result in results:
|
||||
if result.get("text"):
|
||||
texts.append(result["text"])
|
||||
return "\n\n---\n\n".join(texts)
|
||||
|
||||
def save_results(self, results: List[Dict], output_path: str, merge: bool = False):
|
||||
"""
|
||||
保存 OCR 结果到 JSON 文件
|
||||
|
||||
Args:
|
||||
results: OCR 结果列表
|
||||
output_path: 输出文件路径
|
||||
merge: 是否同时保存合并后的文本文件
|
||||
"""
|
||||
output_data = {
|
||||
"engine": self.engine,
|
||||
"total_frames": len(results),
|
||||
"results": results
|
||||
}
|
||||
|
||||
# 保存 JSON
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(output_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"Results saved to {output_path}")
|
||||
|
||||
# 如果需要,同时保存合并的文本
|
||||
if merge:
|
||||
text_path = output_path.replace(".json", ".txt")
|
||||
merged_text = self.merge_texts(results)
|
||||
with open(text_path, "w", encoding="utf-8") as f:
|
||||
f.write(merged_text)
|
||||
print(f"Merged text saved to {text_path}")
|
||||
|
||||
|
||||
def extract_frames(video_path: str, output_dir: str, fps: float = 1.0) -> str:
|
||||
"""
|
||||
使用 ffmpeg 从视频中截取帧图片
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_dir: 输出目录
|
||||
fps: 每秒截取帧数 (默认: 1)
|
||||
|
||||
Returns:
|
||||
帧图片目录路径
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ffmpeg 命令: -i 输入 -vf fps=fps 输出格式
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", video_path,
|
||||
"-vf", f"fps={fps}",
|
||||
"-q:v", "2", # 质量等级 (2=高质量)
|
||||
str(output_dir / "%04d.jpg")
|
||||
]
|
||||
|
||||
print(f"🔄 截取视频帧: {video_path}")
|
||||
print(f" 输出目录: {output_dir}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
# 统计截取的帧数
|
||||
frame_count = len(list(output_dir.glob("*.jpg")))
|
||||
print(f"✅ 截取完成: {frame_count} 帧")
|
||||
return str(output_dir)
|
||||
else:
|
||||
print(f"❌ ffmpeg 错误: {result.stderr}")
|
||||
return ""
|
||||
except FileNotFoundError:
|
||||
print("❌ 错误: 未找到 ffmpeg,请先安装: apt install ffmpeg")
|
||||
return ""
|
||||
|
||||
|
||||
def main():
|
||||
"""命令行入口"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="OCR 视频帧文字识别工具",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
# 使用 EasyOCR 处理帧目录
|
||||
python ocr_processor.py --input ./frames/ --output ./ocr.json
|
||||
|
||||
# 直接从视频截帧并 OCR
|
||||
python ocr_processor.py --video ./video.flv --output ./ocr.json
|
||||
|
||||
# 使用 Tesseract 处理
|
||||
python ocr_processor.py --input ./frames/ --output ./ocr.json --engine tesseract
|
||||
|
||||
# 指定截帧帧率
|
||||
python ocr_processor.py --video ./video.flv --output ./ocr.json --fps 2
|
||||
|
||||
# 同时生成合并的文本文件
|
||||
python ocr_processor.py --input ./frames/ --output ./ocr.json --merge
|
||||
"""
|
||||
)
|
||||
|
||||
# 输入源:帧目录 或 视频文件(二选一)
|
||||
input_group = parser.add_mutually_exclusive_group(required=True)
|
||||
input_group.add_argument("--input", "-i", help="输入帧图片目录")
|
||||
input_group.add_argument("--video", "-v", help="输入视频文件(会自动截帧)")
|
||||
|
||||
parser.add_argument("--output", "-o", required=True, help="输出 JSON 文件路径")
|
||||
parser.add_argument("--fps", "-f", type=float, default=1.0, help="截帧帧率 (默认: 1帧/秒)")
|
||||
parser.add_argument("--engine", "-e", choices=["easyocr", "tesseract"],
|
||||
default="easyocr", help="OCR 引擎 (默认: easyocr)")
|
||||
parser.add_argument("--merge", action="store_true", help="同时保存合并的文本文件")
|
||||
parser.add_argument("--languages", "-l", nargs="+",
|
||||
default=["ch_sim", "en"], help="语言列表")
|
||||
parser.add_argument("--progress", "-p", help="可选:进度文件路径(会实时更新)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 如果指定了视频文件,先截帧
|
||||
input_dir = args.input
|
||||
if args.video:
|
||||
if not os.path.exists(args.video):
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": f"视频文件不存在: {args.video}"
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
# 创建临时帧目录
|
||||
frames_dir = Path(args.video).stem + "_frames"
|
||||
input_dir = extract_frames(args.video, frames_dir, fps=args.fps)
|
||||
|
||||
if not input_dir:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "截帧失败"
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
# 初始化处理器
|
||||
processor = OCRProcessor(engine=args.engine, languages=args.languages)
|
||||
|
||||
# 处理帧
|
||||
results = processor.process_frames(input_dir, fps=args.fps, progress_file=args.progress)
|
||||
|
||||
if results:
|
||||
# 保存结果
|
||||
processor.save_results(results, args.output, merge=args.merge)
|
||||
|
||||
# 统一输出格式
|
||||
total_texts = sum(1 for r in results if r.get("text"))
|
||||
avg_conf = sum(r.get("confidence", 0) for r in results) / len(results) if results else 0
|
||||
|
||||
output = {
|
||||
"success": True,
|
||||
"filename": Path(args.output).name,
|
||||
"filepath": str(Path(args.output).resolve()),
|
||||
"message": f"OCR处理完成",
|
||||
"stats": {
|
||||
"total_frames": len(results),
|
||||
"frames_with_text": total_texts,
|
||||
"avg_confidence": round(avg_conf, 4)
|
||||
}
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
else:
|
||||
print(json.dumps({
|
||||
"success": False,
|
||||
"error": "未处理到任何结果"
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
easyocr>=1.7.0
|
||||
pytesseract>=0.3.10
|
||||
Pillow>=9.0.0
|
||||
@@ -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 等常见格式
|
||||
Binary file not shown.
@@ -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"
|
||||
```
|
||||
@@ -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()
|
||||
@@ -0,0 +1,14 @@
|
||||
# KB Workflow 环境变量配置
|
||||
export KB_BASE_PATH="~/work/knowledge-base"
|
||||
|
||||
# 可选: B站 Cookie(用于下载会员视频)
|
||||
# export BILIBILI_COOKIE="SESSDATA=xxx; bili_jct=xxx"
|
||||
|
||||
# 可选: AI 分析配置
|
||||
# export AI_API_KEY="your-api-key"
|
||||
# export AI_PROVIDER="openai" # 或 zhipu, minimax
|
||||
# export AI_MODEL="gpt-4"
|
||||
|
||||
# 可选: Git 配置
|
||||
# export KB_GIT_USER="Your Name"
|
||||
# export KB_GIT_EMAIL="your@email.com"
|
||||
@@ -0,0 +1,14 @@
|
||||
# KB Workflow 环境变量配置
|
||||
export KB_BASE_PATH="~/work/knowledge-base"
|
||||
|
||||
# 可选: B站 Cookie(用于下载会员视频)
|
||||
# export BILIBILI_COOKIE="SESSDATA=xxx; bili_jct=xxx"
|
||||
|
||||
# 可选: AI 分析配置
|
||||
# export AI_API_KEY="your-api-key"
|
||||
# export AI_PROVIDER="openai" # 或 zhipu, minimax
|
||||
# export AI_MODEL="gpt-4"
|
||||
|
||||
# 可选: Git 配置
|
||||
# export KB_GIT_USER="Your Name"
|
||||
# export KB_GIT_EMAIL="your@email.com"
|
||||
@@ -0,0 +1,247 @@
|
||||
# KB Workflow 环境安装脚本
|
||||
|
||||
一键检查并安装 kb-workflow 知识库工作流所需的全部环境依赖。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 自动检测系统类型 (Ubuntu/Debian/Fedora/Arch)
|
||||
- ✅ 检查系统依赖 (Python3, pip3, ffmpeg, tesseract, git)
|
||||
- ✅ 检查 Python 依赖包 (httpx, aiohttp, playwright, easyocr 等)
|
||||
- ✅ 自动创建知识库目录结构
|
||||
- ✅ 生成环境变量配置示例
|
||||
- ✅ 汇总显示所有需要执行的安装命令
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 运行检查脚本
|
||||
|
||||
```bash
|
||||
cd /home/starmen1265/.openclaw/workspace/skills
|
||||
./install-kb-workflow.sh
|
||||
```
|
||||
|
||||
脚本会检查所有依赖,并显示需要执行的安装命令。
|
||||
|
||||
### 示例输出
|
||||
|
||||
```
|
||||
========================================
|
||||
第一步: 检查系统依赖
|
||||
========================================
|
||||
[SUCCESS] Python3 已安装: Python 3.12.3
|
||||
[WARN] pip3 未安装
|
||||
[WARN] ffmpeg 未安装
|
||||
...
|
||||
|
||||
========================================
|
||||
需要安装的系统依赖
|
||||
========================================
|
||||
|
||||
检测到以下依赖未安装:
|
||||
- python3-pip
|
||||
- ffmpeg
|
||||
- tesseract-ocr
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
请运行以下命令安装:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-pip ffmpeg tesseract-ocr tesseract-ocr-chi-sim tesseract-ocr-eng
|
||||
```
|
||||
|
||||
## 依赖清单
|
||||
|
||||
### 系统依赖
|
||||
|
||||
| 依赖 | 用途 | 安装命令 |
|
||||
|------|------|----------|
|
||||
| **python3** | Python 运行时 | `sudo apt-get install python3` |
|
||||
| **python3-pip** | Python 包管理器 | `sudo apt-get install python3-pip` |
|
||||
| **ffmpeg** | 视频处理(OCR 截帧) | `sudo apt-get install ffmpeg` |
|
||||
| **tesseract-ocr** | OCR 引擎(备用方案) | `sudo apt-get install tesseract-ocr` |
|
||||
| **git** | 版本控制(归档提交) | `sudo apt-get install git` |
|
||||
|
||||
### Tesseract 语言包
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install tesseract-ocr-chi-sim tesseract-ocr-chi-tra tesseract-ocr-eng
|
||||
|
||||
# Fedora/RHEL
|
||||
sudo dnf install tesseract-langpack-chi_sim tesseract-langpack-chi_tra tesseract-langpack-eng
|
||||
```
|
||||
|
||||
### Python 依赖
|
||||
|
||||
```bash
|
||||
# 基础 HTTP 库
|
||||
pip3 install --user httpx aiohttp requests
|
||||
|
||||
# 图像和 OCR
|
||||
pip3 install --user Pillow easyocr pytesseract
|
||||
|
||||
# 浏览器自动化
|
||||
pip3 install --user playwright
|
||||
python3 -m playwright install chromium
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
安装完成后会创建以下目录结构:
|
||||
|
||||
```
|
||||
~/work/knowledge-base/ # 知识库根目录(可配置)
|
||||
├── archive/ # 归档目录
|
||||
│ ├── pending/ # 待处理
|
||||
│ └── subscriptions/ # 订阅信息
|
||||
├── metadata/ # 元数据文件
|
||||
├── content/ # 内容文件
|
||||
│ ├── downloads/ # 视频下载
|
||||
│ └── subtitles/ # 字幕文件
|
||||
└── analyzed/ # 深度分析报告
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 必需
|
||||
- `KB_BASE_PATH` - 知识库根目录(默认: `~/work/knowledge-base`)
|
||||
|
||||
### 可选
|
||||
- `BILIBILI_COOKIE` - B站 Cookie,用于下载会员视频
|
||||
- `AI_API_KEY` - AI API 密钥,用于深度内容分析
|
||||
- `AI_PROVIDER` - AI 提供商(openai/zhipu/minimax)
|
||||
- `AI_MODEL` - AI 模型名称(如 gpt-4)
|
||||
- `AI_BASE_URL` - 自定义 API 端点
|
||||
- `KB_GIT_USER` - Git 用户名(用于归档提交)
|
||||
- `KB_GIT_EMAIL` - Git 邮箱(用于归档提交)
|
||||
- `KB_GIT_REMOTE` - Git 远程仓库地址
|
||||
|
||||
### 配置方法
|
||||
|
||||
将以下内容添加到 `~/.bashrc` 或 `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
# KB Workflow 配置
|
||||
export KB_BASE_PATH="~/work/knowledge-base"
|
||||
|
||||
# 可选:B站 Cookie
|
||||
export BILIBILI_COOKIE="SESSDATA=xxx; bili_jct=xxx"
|
||||
|
||||
# 可选:AI 配置
|
||||
export AI_API_KEY="your-api-key"
|
||||
export AI_PROVIDER="openai"
|
||||
export AI_MODEL="gpt-4"
|
||||
```
|
||||
|
||||
然后重新加载配置:
|
||||
```bash
|
||||
source ~/.bashrc # 或 source ~/.zshrc
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. pip3 命令不存在
|
||||
|
||||
```bash
|
||||
# 方法 1: 安装 pip3
|
||||
sudo apt-get install python3-pip
|
||||
|
||||
# 方法 2: 使用 python3 -m pip
|
||||
python3 -m pip install --user <package>
|
||||
```
|
||||
|
||||
### 2. Playwright 浏览器安装失败
|
||||
|
||||
```bash
|
||||
# 手动安装 Chromium
|
||||
python3 -m playwright install chromium
|
||||
|
||||
# 如果仍然失败,尝试安装依赖
|
||||
sudo apt-get install libnss3 libatk-bridge2.0-0 libxss1 libgtk-3-0
|
||||
```
|
||||
|
||||
### 3. EasyOCR 安装失败
|
||||
|
||||
EasyOCR 依赖 PyTorch,可能需要较长时间安装:
|
||||
|
||||
```bash
|
||||
# 先安装 PyTorch
|
||||
pip3 install --user torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# 再安装 easyocr
|
||||
pip3 install --user easyocr
|
||||
```
|
||||
|
||||
### 4. 权限问题
|
||||
|
||||
如果使用 `--user` 安装的包无法找到,检查 PATH:
|
||||
|
||||
```bash
|
||||
# 查看用户安装路径
|
||||
python3 -m site --user-base
|
||||
|
||||
# 添加到 PATH(添加到 ~/.bashrc)
|
||||
export PATH="$PATH:$(python3 -m site --user-base)/bin"
|
||||
```
|
||||
|
||||
### 5. OCR 中文识别问题
|
||||
|
||||
确保已安装中文语言包:
|
||||
|
||||
```bash
|
||||
# 检查已安装的语言包
|
||||
tesseract --list-langs
|
||||
|
||||
# 应该能看到 chi_sim 和 chi_tra
|
||||
```
|
||||
|
||||
## 更新
|
||||
|
||||
如果技能更新了依赖,可以重新运行检查脚本:
|
||||
|
||||
```bash
|
||||
./install-kb-workflow.sh
|
||||
```
|
||||
|
||||
## 子技能清单
|
||||
|
||||
| 技能 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| kb-workflow | `kb-workflow/kb_workflow.py` | 主流程协调 |
|
||||
| kb-link-parser | `kb-link-parser/link-parser.py` | 链接解析 |
|
||||
| kb-bilibili-metadata | `kb-bilibili-metadata/bilibili_metadata.py` | B站元数据提取 |
|
||||
| kb-bilibili-content | `kb-bilibili-content/bilibili_content.py` | B站内容下载 |
|
||||
| kb-ocr-processor | `kb-ocr-processor/ocr_processor.py` | 视频 OCR 识别 |
|
||||
| kb-deep-analyzer | `kb-deep-analyzer/deep_analyzer.py` | AI 深度分析 |
|
||||
|
||||
## 使用 kb-workflow
|
||||
|
||||
安装完成后,可以通过以下方式使用:
|
||||
|
||||
### 方式 1:通过主代理
|
||||
|
||||
直接向主代理发送消息:
|
||||
```
|
||||
添加到知识库 https://www.bilibili.com/video/BVxxxxx
|
||||
```
|
||||
|
||||
### 方式 2:手动运行子技能
|
||||
|
||||
```bash
|
||||
# 1. 链接解析
|
||||
python3 kb-link-parser/link-parser.py --url "https://www.bilibili.com/video/BVxxxxx"
|
||||
|
||||
# 2. 元数据提取
|
||||
python3 kb-bilibili-metadata/bilibili_metadata.py --id "BVxxxxx" --type video
|
||||
|
||||
# 3. 内容下载
|
||||
python3 kb-bilibili-content/bilibili_content.py --id "BVxxxxx" --type subtitle
|
||||
|
||||
# 4. 深度分析
|
||||
python3 kb-deep-analyzer/deep_analyzer.py --metadata meta.json --content content.json --output analysis.md
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -0,0 +1,360 @@
|
||||
---
|
||||
name: kb-workflow
|
||||
description: 当收到"添加到知识库 + 链接" 或"保存到知识库 + 链接"时使用
|
||||
---
|
||||
|
||||
# KB Workflow
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
当用户发送包含以下模式的消息时,此技能自动触发:
|
||||
|
||||
| 触发词 | 行为 | 示例 |
|
||||
|--------|------|------|
|
||||
| `添加到知识库` + 链接 | 添加到知识库并处理 | "添加到知识库 https://www.bilibili.com/video/BVxxx" |
|
||||
| `保存到知识库` + 链接 | 同上 | "保存到知识库 https://www.bilibili.com/video/BVxxx" |
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 执行前必须检查
|
||||
|
||||
在执行 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 执行方式
|
||||
|
||||
主代理直接执行以下步骤,无需启动 kb-workflow 子代理:
|
||||
|
||||
1. 主代理启动子代理调用 kb-link-parser
|
||||
2. 主代理启动子代理调用 kb-平台-metadata
|
||||
3. 主代理启动子代理调用 kb-平台-content
|
||||
4. 主代理启动子代理调用 kb-ocr-processor(如需要)
|
||||
5. 主代理启动子代理调用 kb-deep-analyzer
|
||||
6. 主代理执行 `kb_workflow.py --archive-file` 归档
|
||||
|
||||
|
||||
## Workflow 执行步骤
|
||||
|
||||
当触发后,按照以下步骤执行:
|
||||
|
||||
### 步骤 1: 环境变量检查
|
||||
|
||||
**❌ 禁止跳过此步骤!必须先检查环境变量。**
|
||||
|
||||
```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: 链接解析
|
||||
|
||||
1. 获取目录路径:
|
||||
```bash
|
||||
python3 kb_workflow.py --pending
|
||||
```
|
||||
|
||||
2. 启动子代理调用 kb-link-parser:
|
||||
```bash
|
||||
python3 /path/to/link-parser.py --url "[URL]"
|
||||
```
|
||||
|
||||
3. 解析输出 JSON,提取 filename 和 platform
|
||||
|
||||
### 步骤 3: 元数据提取
|
||||
|
||||
1. 获取元数据目录路径:
|
||||
```bash
|
||||
python3 kb_workflow.py --metadata
|
||||
# 返回: {"path": "/home/admin/work/knowledge-base/metadata"}
|
||||
```
|
||||
|
||||
2. 构建输出文件路径:`${metadata_dir}/${platform}_${id}.meta.json`
|
||||
|
||||
3. 启动子代理调用对应平台的 metadata 技能(传入 `--output`):
|
||||
```bash
|
||||
# Bilibili
|
||||
python3 /path/to/bilibili_metadata.py --id "[ID]" --type video --output "${metadata_dir}/bilibili_[ID].meta.json"
|
||||
|
||||
# Zhihu
|
||||
python3 /path/to/zhihu_metadata.py --id "[ID]" --type question --output "${metadata_dir}/zhihu_[ID].meta.json"
|
||||
```
|
||||
|
||||
4. 解析输出 JSON,提取 filename
|
||||
|
||||
### 步骤 4: 内容下载
|
||||
|
||||
1. 获取目录路径:
|
||||
```bash
|
||||
python3 kb_workflow.py --content_downloads
|
||||
python3 kb_workflow.py --content
|
||||
```
|
||||
|
||||
2. 启动子代理调用对应平台的 content 技能:
|
||||
```bash
|
||||
# Bilibili 字幕下载(自动检测是否有官方字幕)
|
||||
python3 /path/to/bilibili_content.py --id "[ID]" --type subtitle-download --output [content_dir]
|
||||
|
||||
# 返回 JSON 包含 has_subtitle 字段
|
||||
```
|
||||
|
||||
3. 解析输出 JSON:
|
||||
- **has_subtitle = true**: 有官方字幕,直接进入步骤 6
|
||||
- **has_subtitle = false**: 没有官方字幕,继续步骤 5
|
||||
|
||||
> ⚠️ **重要提示**:没有找到官方字幕是**正常的**!很多视频没有自动生成的字幕。不要因为没有字幕就停止工作流,继续执行 Whisper 步骤即可。
|
||||
|
||||
### 步骤 5: 语音识别转文字(仅当没有官方字幕时执行)
|
||||
|
||||
> ⚠️ **重要**:此步骤仅在**没有官方字幕**时执行。如果有官方字幕,**跳过此步骤**。
|
||||
|
||||
此步骤使用 Whisper 进行语音识别:
|
||||
- **严禁跳过**:必须等待识别完成
|
||||
- **定时汇报**:每2-3分钟向用户汇报进度
|
||||
- **超时时间**:30分钟
|
||||
- **耐心等待**:不要中途启动其他步骤,必须串行执行
|
||||
|
||||
1. 获取目录路径:
|
||||
```bash
|
||||
python3 kb_workflow.py --content
|
||||
```
|
||||
|
||||
2. 启动子代理调用 kb-whisper(传入视频路径):
|
||||
```bash
|
||||
# 必须传入 --output 完整路径
|
||||
python3 /path/to/whisper.py --input [video_path] --model base --output [content_dir]
|
||||
```
|
||||
|
||||
3. 等待 Whisper 完成(定时汇报进度)
|
||||
|
||||
4. 解析输出 JSON,生成 content 文件供步骤6使用
|
||||
|
||||
### 步骤 6: 深度分析
|
||||
|
||||
1. 获取目录路径:
|
||||
```bash
|
||||
python3 kb_workflow.py --analyzed
|
||||
```
|
||||
|
||||
2. 准备 content JSON 文件(包含 subtitle/description)
|
||||
|
||||
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 --provider zhipu --model glm-5
|
||||
```
|
||||
|
||||
**环境变量设置**(在子代理中设置):
|
||||
- `AI_PROVIDER=zhipu`
|
||||
- `AI_MODEL=glm-5`
|
||||
|
||||
6. 解析输出 JSON,检查 `success` 字段:
|
||||
|
||||
> ⚠️ **深度分析失败 = 整个流程失败**
|
||||
> - 如果 `success = true`:继续步骤 7(归档)
|
||||
> - 如果 `success = false`:**立即终止流程**,向用户汇报失败原因
|
||||
> - **严禁**继续执行后续步骤或使用降级内容"假装成功"
|
||||
|
||||
### 步骤 7: 归档
|
||||
|
||||
运行归档命令:
|
||||
```bash
|
||||
python3 kb_workflow.py --archive-file "[分析报告文件路径]"
|
||||
```
|
||||
|
||||
### 步骤 8: 汇报完成
|
||||
|
||||
向用户汇报:
|
||||
- 视频标题
|
||||
- 处理阶段完成情况
|
||||
- 最终文件路径
|
||||
- Git commit 信息
|
||||
|
||||
|
||||
## 子技能清单
|
||||
|
||||
| 步骤 | 子技能 | 命令示例 | 输出 | 备注 |
|
||||
|------|--------|----------|------|------|
|
||||
| 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 步骤,直接使用官方字幕
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
~/work/knowledge-base/
|
||||
├── archive/
|
||||
│ └── YYYY-MM-DD/
|
||||
├── pending/
|
||||
│ └── {platform}_{id}.link.json
|
||||
├── metadata/
|
||||
│ └── {platform}_{id}.meta.json
|
||||
├── content/
|
||||
│ ├── downloads/
|
||||
│ │ └── {platform}_{id}.mp4
|
||||
│ ├── {platform}_{id}.txt # Whisper 识别的文本
|
||||
│ └── {platform}_{id}.json # Whisper 完整结果
|
||||
└── analyzed/
|
||||
└── {platform}_{id}.md
|
||||
```
|
||||
|
||||
## 辅助命令
|
||||
|
||||
```bash
|
||||
# ------------------- 查重与归档 -------------------
|
||||
|
||||
# 查重
|
||||
python3 kb_workflow.py --check --url "[URL]"
|
||||
|
||||
# 归档分析报告
|
||||
python3 kb_workflow.py --archive-file "[分析报告路径]"
|
||||
|
||||
|
||||
# ------------------- 目录路径查询 -------------------
|
||||
|
||||
# 获取知识库根目录(默认 ~/work/knowledge-base)
|
||||
python3 kb_workflow.py --base-path "~/custom/path"
|
||||
|
||||
# 获取待处理目录(pending)
|
||||
python3 kb_workflow.py --pending
|
||||
|
||||
# 获取元数据目录
|
||||
python3 kb_workflow.py --metadata
|
||||
|
||||
# 获取内容目录
|
||||
python3 kb_workflow.py --content
|
||||
|
||||
# 获取内容下载目录
|
||||
python3 kb_workflow.py --content_downloads
|
||||
|
||||
# 获取字幕目录
|
||||
python3 kb_workflow.py --content_subtitles
|
||||
|
||||
# 获取深度分析目录
|
||||
python3 kb_workflow.py --analyzed
|
||||
|
||||
# 获取归档目录
|
||||
python3 kb_workflow.py --archive
|
||||
|
||||
# 获取订阅信息目录
|
||||
python3 kb_workflow.py --archive_subscriptions
|
||||
|
||||
|
||||
# ------------------- 工具函数 -------------------
|
||||
|
||||
# 从文件名提取平台名称
|
||||
python3 kb_workflow.py --extract_platform "bilibili_BVxxx.mp4"
|
||||
```
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# SkillBug.md
|
||||
|
||||
## 问题记录
|
||||
|
||||
### 2026-02-10: 子代理权限问题
|
||||
|
||||
**问题:** `sessions_spawn` 调用子代理时返回 `forbidden` 错误。
|
||||
|
||||
**原因:** OpenClaw 配置中 allowlist 为空,Python 脚本没有权限调用子代理。
|
||||
|
||||
**解决方案:** 改用 Prompt-based 模式(方案B)。
|
||||
|
||||
---
|
||||
|
||||
## 架构重构
|
||||
|
||||
### 之前(Script-based)
|
||||
|
||||
```
|
||||
kb_workflow.py (Python脚本)
|
||||
↓
|
||||
sessions_spawn() # ❌ 失败(脚本没有权限)
|
||||
```
|
||||
|
||||
### 之后(Prompt-based)
|
||||
|
||||
```
|
||||
主代理读取 SKILL.md
|
||||
↓
|
||||
按照指导调用 sessions_spawn # ✅ 成功(主代理有权限)
|
||||
```
|
||||
|
||||
### 文件变更
|
||||
|
||||
| 文件 | 变更 | 说明 |
|
||||
|------|------|------|
|
||||
| `SKILL.md` | 重写 | 详细定义 workflow 步骤,主代理直接读取执行 |
|
||||
| `kb_workflow.py` | 简化 | 保留为辅助工具函数库(查重、归档、Git) |
|
||||
| `skill_main.py` | 简化 | 只做触发检测,返回指导信息 |
|
||||
| `SkillBug.md` | 更新 | 记录架构变更 |
|
||||
|
||||
### SKILL.md 结构
|
||||
|
||||
```
|
||||
1. 触发条件
|
||||
2. 步骤 1: 查重
|
||||
3. 步骤 2: kb-link-parser
|
||||
4. 步骤 3: 元数据技能
|
||||
5. 步骤 4: 内容下载
|
||||
6. 步骤 5: OCR(30分钟超时)
|
||||
7. 步骤 6: 深度分析
|
||||
8. 步骤 7: 归档和 Git
|
||||
9. 步骤 8: 汇报完成
|
||||
10. 目录结构
|
||||
11. 支持平台
|
||||
12. 注意事项
|
||||
```
|
||||
|
||||
### kb_workflow.py 保留的函数
|
||||
|
||||
```python
|
||||
extract_platform(filename) # 从文件名提取平台
|
||||
extract_base_name(filename) # 从文件名提取基础名称
|
||||
get_knowledge_base_path() # 获取知识库根目录
|
||||
ensure_directories(base_path) # 确保目录存在
|
||||
check_duplicate(url) # 查重
|
||||
archive_and_git(analyzed_file) # 归档和 Git
|
||||
generate_summary(...) # 生成汇报
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2026-02-12: B站视频下载失败 - 403 Forbidden
|
||||
|
||||
**问题:** 使用 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:归档(阻塞)
|
||||
@@ -0,0 +1,321 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# KB Workflow 环境一键安装脚本
|
||||
# =============================================================================
|
||||
# 功能: 检查并安装 kb-workflow 技能所需的全部环境依赖
|
||||
# 支持: Ubuntu/Debian 系统
|
||||
#
|
||||
# 使用方法:
|
||||
# ./install-env.sh # 检查环境并提示安装命令(检查模式)
|
||||
# ./install-env.sh --auto # 自动安装所有缺失依赖(自动模式)
|
||||
# =============================================================================
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 日志函数
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_section() {
|
||||
echo ""
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}$1${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
}
|
||||
|
||||
# 获取脚本目录
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
DEFAULT_KB_PATH="${HOME}/work/knowledge-base"
|
||||
|
||||
# 解析参数
|
||||
AUTO_MODE=false
|
||||
if [ "$1" = "--auto" ]; then
|
||||
AUTO_MODE=true
|
||||
log_info "自动安装模式"
|
||||
fi
|
||||
|
||||
# 检测 Linux 发行版
|
||||
detect_distro() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
echo "$ID"
|
||||
elif [ -f /etc/debian_version ]; then
|
||||
echo "debian"
|
||||
elif [ -f /etc/redhat-release ]; then
|
||||
echo "rhel"
|
||||
fi
|
||||
}
|
||||
DISTRO=$(detect_distro)
|
||||
|
||||
# 检查命令是否存在
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# 检查 Python 包
|
||||
check_python_package() {
|
||||
python3 -c "import $1" 2>/dev/null
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 1. 检查系统依赖
|
||||
# =============================================================================
|
||||
log_section "第一步: 检查系统依赖"
|
||||
|
||||
MISSING_DEPS=()
|
||||
|
||||
# 检查 Python3
|
||||
if command_exists python3; then
|
||||
PYTHON_VERSION=$(python3 --version | cut -d' ' -f2)
|
||||
log_success "Python3 已安装: $PYTHON_VERSION"
|
||||
else
|
||||
log_warn "Python3 未安装"
|
||||
MISSING_DEPS+=("python3")
|
||||
fi
|
||||
|
||||
# 检查 pip3
|
||||
if python3 -m pip --version > /dev/null 2>&1 || command_exists pip3; then
|
||||
log_success "pip3 已安装"
|
||||
else
|
||||
log_warn "pip3 未安装"
|
||||
MISSING_DEPS+=("python3-pip")
|
||||
fi
|
||||
|
||||
# 检查 ffmpeg
|
||||
if command_exists ffmpeg; then
|
||||
log_success "ffmpeg 已安装"
|
||||
else
|
||||
log_warn "ffmpeg 未安装"
|
||||
MISSING_DEPS+=("ffmpeg")
|
||||
fi
|
||||
|
||||
# 检查 tesseract
|
||||
if command_exists tesseract; then
|
||||
log_success "Tesseract OCR 已安装"
|
||||
else
|
||||
log_warn "Tesseract OCR 未安装"
|
||||
MISSING_DEPS+=("tesseract-ocr")
|
||||
fi
|
||||
|
||||
# 检查 git
|
||||
if command_exists git; then
|
||||
log_success "Git 已安装"
|
||||
else
|
||||
log_warn "Git 未安装"
|
||||
MISSING_DEPS+=("git")
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 2. 自动安装系统依赖(自动模式)
|
||||
# =============================================================================
|
||||
if [ ${#MISSING_DEPS[@]} -gt 0 ]; then
|
||||
if [ "$AUTO_MODE" = true ]; then
|
||||
log_section "自动安装系统依赖"
|
||||
log_info "检测到系统: $DISTRO"
|
||||
|
||||
case "$DISTRO" in
|
||||
ubuntu|debian)
|
||||
log_info "更新包列表..."
|
||||
sudo apt-get update -qq
|
||||
|
||||
log_info "安装依赖: ${MISSING_DEPS[*]}"
|
||||
sudo apt-get install -y -qq "${MISSING_DEPS[@]}" tesseract-ocr-chi-sim tesseract-ocr-eng
|
||||
;;
|
||||
fedora|rhel|centos)
|
||||
log_info "更新包列表..."
|
||||
sudo dnf check-update -y || true
|
||||
|
||||
log_info "安装依赖: ${MISSING_DEPS[*]}"
|
||||
sudo dnf install -y "${MISSING_DEPS[@]}" tesseract-langpack-chi_sim tesseract-langpack-eng
|
||||
;;
|
||||
arch)
|
||||
log_info "安装依赖: ${MISSING_DEPS[*]}"
|
||||
sudo pacman -S --noconfirm "${MISSING_DEPS[@]}" tesseract-data-chi_sim tesseract-data-eng
|
||||
;;
|
||||
*)
|
||||
log_error "不支持的系统: $DISTRO"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
log_success "系统依赖安装完成"
|
||||
MISSING_DEPS=()
|
||||
else
|
||||
log_section "需要安装的系统依赖"
|
||||
echo ""
|
||||
echo "检测到以下依赖未安装:"
|
||||
for dep in "${MISSING_DEPS[@]}"; do
|
||||
echo " - $dep"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "请运行以下命令安装:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo -e "${YELLOW}sudo apt-get update${NC}"
|
||||
echo -e "${YELLOW}sudo apt-get install -y ${MISSING_DEPS[*]} tesseract-ocr-chi-sim tesseract-ocr-eng${NC}"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 3. 检查 Python 依赖
|
||||
# =============================================================================
|
||||
log_section "第二步: 检查 Python 依赖"
|
||||
|
||||
PYTHON_PACKAGES=(
|
||||
"httpx:httpx"
|
||||
"aiohttp:aiohttp"
|
||||
"requests:requests"
|
||||
"Pillow:PIL"
|
||||
)
|
||||
|
||||
MISSING_PY_PKGS=()
|
||||
|
||||
for pkg_info in "${PYTHON_PACKAGES[@]}"; do
|
||||
pkg_name=$(echo "$pkg_info" | cut -d':' -f1)
|
||||
import_name=$(echo "$pkg_info" | cut -d':' -f2)
|
||||
if check_python_package "$import_name" 2>/dev/null; then
|
||||
log_success "$pkg_name 已安装"
|
||||
else
|
||||
log_warn "$pkg_name 未安装"
|
||||
MISSING_PY_PKGS+=("$pkg_name")
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查特殊包
|
||||
check_python_package "playwright" 2>/dev/null && log_success "playwright 已安装" || MISSING_PY_PKGS+=("playwright")
|
||||
check_python_package "easyocr" 2>/dev/null && log_success "easyocr 已安装" || MISSING_PY_PKGS+=("easyocr")
|
||||
check_python_package "pytesseract" 2>/dev/null && log_success "pytesseract 已安装" || MISSING_PY_PKGS+=("pytesseract")
|
||||
|
||||
# =============================================================================
|
||||
# 4. 自动安装 Python 依赖(自动模式)
|
||||
# =============================================================================
|
||||
if [ ${#MISSING_PY_PKGS[@]} -gt 0 ]; then
|
||||
if [ "$AUTO_MODE" = true ]; then
|
||||
log_section "自动安装 Python 包"
|
||||
|
||||
log_info "安装: ${MISSING_PY_PKGS[*]}"
|
||||
pip3 install --user "${MISSING_PY_PKGS[@]}" -q
|
||||
|
||||
log_success "Python 包安装完成"
|
||||
|
||||
# 安装 Playwright 浏览器
|
||||
if [[ " ${MISSING_PY_PKGS[*]} " =~ " playwright " ]]; then
|
||||
log_info "安装 Playwright Chromium 浏览器..."
|
||||
python3 -m playwright install chromium
|
||||
log_success "Chromium 浏览器安装完成"
|
||||
fi
|
||||
|
||||
MISSING_PY_PKGS=()
|
||||
else
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "需要安装以下 Python 包:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
for pkg in "${MISSING_PY_PKGS[@]}"; do
|
||||
echo " - $pkg"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "请运行以下命令安装:"
|
||||
echo ""
|
||||
echo -e "${YELLOW}pip3 install --user ${MISSING_PY_PKGS[*]}${NC}"
|
||||
|
||||
if [[ " ${MISSING_PY_PKGS[*]} " =~ " playwright " ]]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}安装完 playwright 后,还需要运行:${NC}"
|
||||
echo -e "${YELLOW} python3 -m playwright install chromium${NC}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 5. 创建知识库目录结构
|
||||
# =============================================================================
|
||||
log_section "第三步: 创建知识库目录结构"
|
||||
|
||||
KB_BASE_PATH="${KB_BASE_PATH:-$DEFAULT_KB_PATH}"
|
||||
log_info "知识库路径: $KB_BASE_PATH"
|
||||
|
||||
mkdir -p "$KB_BASE_PATH"/{archive/{subscriptions,pending},metadata,content/{downloads,subtitles},analyzed}
|
||||
log_success "目录结构创建完成"
|
||||
|
||||
# =============================================================================
|
||||
# 6. 环境变量配置提示
|
||||
# =============================================================================
|
||||
log_section "第四步: 环境变量配置"
|
||||
|
||||
if [ -z "$KB_BASE_PATH_SET" ]; then
|
||||
log_warn "KB_BASE_PATH 环境变量未设置"
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "建议添加到 ~/.bashrc 或 ~/.zshrc:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo -e "${YELLOW}export KB_BASE_PATH=\"$KB_BASE_PATH\"${NC}"
|
||||
echo ""
|
||||
else
|
||||
log_success "KB_BASE_PATH 已设置: $KB_BASE_PATH"
|
||||
fi
|
||||
|
||||
# 创建环境变量示例文件
|
||||
ENV_FILE="${SCRIPT_DIR}/.env.example"
|
||||
cat > "$ENV_FILE" << 'EOF'
|
||||
# KB Workflow 环境变量配置
|
||||
export KB_BASE_PATH="~/work/knowledge-base"
|
||||
|
||||
# 可选: B站 Cookie(用于下载会员视频)
|
||||
# export BILIBILI_COOKIE="SESSDATA=xxx; bili_jct=xxx"
|
||||
|
||||
# 可选: AI 分析配置
|
||||
# export AI_API_KEY="your-api-key"
|
||||
# export AI_PROVIDER="openai" # 或 zhipu, minimax
|
||||
# export AI_MODEL="gpt-4"
|
||||
|
||||
# 可选: Git 配置
|
||||
# export KB_GIT_USER="Your Name"
|
||||
# export KB_GIT_EMAIL="your@email.com"
|
||||
EOF
|
||||
|
||||
log_info "环境变量示例已保存到: $ENV_FILE"
|
||||
|
||||
# =============================================================================
|
||||
# 7. 完成总结
|
||||
# =============================================================================
|
||||
log_section "安装完成"
|
||||
|
||||
echo ""
|
||||
if [ ${#MISSING_DEPS[@]} -eq 0 ] && [ ${#MISSING_PY_PKGS[@]} -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ 所有依赖已安装完成!${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ 部分依赖需要手动安装${NC}"
|
||||
echo ""
|
||||
|
||||
if [ ${#MISSING_DEPS[@]} -gt 0 ]; then
|
||||
echo "系统依赖:"
|
||||
echo -e "${YELLOW}sudo apt-get install -y ${MISSING_DEPS[*]} tesseract-ocr-chi-sim tesseract-ocr-eng${NC}"
|
||||
fi
|
||||
|
||||
if [ ${#MISSING_PY_PKGS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Python 包:"
|
||||
echo -e "${YELLOW}pip3 install --user ${MISSING_PY_PKGS[*]}${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "使用方法:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " export KB_BASE_PATH=\"~/work/knowledge-base\"
|
||||
echo " python3 kb_workflow.py --check --url \"[URL]\""
|
||||
echo ""
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
KB Workflow - Knowledge Base Workflow Orchestrator
|
||||
|
||||
知识库工作流辅助工具函数。
|
||||
|
||||
此模块提供查重、文件操作、Git 管理等工具函数,
|
||||
供主代理在执行 SKILL.md 中定义的 workflow 时调用。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
import shutil
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
|
||||
# ==================== 子技能映射 ====================
|
||||
|
||||
METADATA_AGENTS = {
|
||||
"bilibili": "kb-bilibili-metadata",
|
||||
"zhihu": "kb-zhihu-metadata",
|
||||
}
|
||||
|
||||
CONTENT_AGENTS = {
|
||||
"bilibili": "kb-bilibili-content",
|
||||
}
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def extract_platform(filename: str) -> str:
|
||||
"""从文件名提取平台(第一个下划线之前)"""
|
||||
match = re.match(r'^([a-zA-Z0-9]+)_(.+)$', filename)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def extract_base_name(filename: str) -> str:
|
||||
"""从文件名提取基础名称(去掉扩展名)"""
|
||||
return Path(filename).stem
|
||||
|
||||
|
||||
def get_knowledge_base_path() -> Path:
|
||||
"""获取知识库根目录"""
|
||||
base_path_str = os.environ.get('KB_BASE_PATH', '')
|
||||
if base_path_str:
|
||||
return Path(base_path_str).expanduser()
|
||||
return Path.home() / "work" / "knowledge-base"
|
||||
|
||||
|
||||
def ensure_directories(base_path: Path = None) -> Dict[str, Path]:
|
||||
"""确保目录存在"""
|
||||
if base_path is None:
|
||||
base_path = get_knowledge_base_path()
|
||||
|
||||
dirs = {
|
||||
"base": base_path,
|
||||
"pending": base_path / "pending",
|
||||
"metadata": base_path / "metadata",
|
||||
"content": base_path / "content",
|
||||
"content_downloads": base_path / "content" / "downloads",
|
||||
"analyzed": base_path / "analyzed",
|
||||
"archive": base_path / "archive",
|
||||
"archive_subscriptions": base_path / "archive" / "subscriptions",
|
||||
}
|
||||
|
||||
for name, dir_path in dirs.items():
|
||||
if isinstance(dir_path, Path):
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return dirs
|
||||
|
||||
|
||||
def check_duplicate(base_path: Path = None, url: str = None, platform: str = None, content_id: str = None) -> Dict[str, Any]:
|
||||
"""查重检查"""
|
||||
if base_path is None:
|
||||
base_path = get_knowledge_base_path()
|
||||
|
||||
metadata_dir = base_path / "metadata"
|
||||
archive_dir = base_path / "archive"
|
||||
|
||||
# 从URL提取ID
|
||||
if url and not content_id:
|
||||
bv_match = re.search(r'BV[a-zA-Z0-9]+', url)
|
||||
content_id = bv_match.group() if bv_match else None
|
||||
|
||||
result = {"duplicate": False, "status": None, "files": []}
|
||||
|
||||
if metadata_dir.exists():
|
||||
for f in metadata_dir.iterdir():
|
||||
if f.is_file() and content_id and content_id in f.name:
|
||||
result["duplicate"] = True
|
||||
result["status"] = "metadata"
|
||||
result["files"].append(str(f))
|
||||
|
||||
if archive_dir.exists():
|
||||
for date_dir in archive_dir.iterdir():
|
||||
if date_dir.is_dir():
|
||||
for f in date_dir.iterdir():
|
||||
if f.is_file() and content_id and content_id in f.name:
|
||||
result["duplicate"] = True
|
||||
result["status"] = "archived"
|
||||
result["files"].append(str(f))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def archive_and_git(
|
||||
analyzed_file: str,
|
||||
base_path: Path = None,
|
||||
git_root: Path = None,
|
||||
git_user: str = None,
|
||||
git_email: str = None,
|
||||
git_remote: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""归档并提交Git"""
|
||||
if base_path is None:
|
||||
base_path = get_knowledge_base_path()
|
||||
if git_root is None:
|
||||
git_root = base_path / "archive"
|
||||
if git_user is None:
|
||||
git_user = os.environ.get('KB_GIT_USER', '')
|
||||
if git_email is None:
|
||||
git_email = os.environ.get('KB_GIT_EMAIL', '')
|
||||
if git_remote is None:
|
||||
git_remote = os.environ.get('KB_GIT_REMOTE', '')
|
||||
|
||||
result = {
|
||||
"success": False,
|
||||
"archive_path": "",
|
||||
"git_commit": False,
|
||||
"git_push": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 5. Git 提交并推送
|
||||
if (git_root / ".git").exists():
|
||||
# 添加所有变更
|
||||
subprocess.run(['git', 'add', '.'], cwd=str(git_root), capture_output=True)
|
||||
|
||||
# 检查是否有变更需要提交
|
||||
status_result = subprocess.run(['git', 'status', '--porcelain'], cwd=str(git_root), capture_output=True, text=True)
|
||||
|
||||
if status_result.stdout.strip():
|
||||
commit_msg = f"Archive: {src_file.name} - {datetime.now().isoformat()}"
|
||||
commit_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', '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
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate_summary(url: str, stages: List[Dict], final_file: str, archive_result: Dict = None) -> str:
|
||||
"""生成处理完成汇报"""
|
||||
# 提取视频标题(如果有)
|
||||
title_match = re.search(r'com/video/([^?]+)', url)
|
||||
title = title_match.group(1) if title_match else "未知"
|
||||
|
||||
summary = f"""
|
||||
✅ **处理完成!**
|
||||
|
||||
**视频:** {title}
|
||||
|
||||
**处理阶段:**
|
||||
"""
|
||||
for stage in stages:
|
||||
stage_name = stage.get('stage', 'unknown')
|
||||
emoji = "✓" if stage.get('success') else "○"
|
||||
summary += f"{emoji} {stage_name}\n"
|
||||
|
||||
if final_file:
|
||||
summary += f"\n**最终文件:** `{final_file}`"
|
||||
|
||||
if archive_result:
|
||||
if archive_result.get('git_commit'):
|
||||
summary += f"\n**Git:** 已提交"
|
||||
if archive_result.get('git_push', {}).get('success'):
|
||||
summary += " ✓"
|
||||
elif archive_result.get('git_push'):
|
||||
summary += " ✗"
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
# ==================== CLI ====================
|
||||
|
||||
def main():
|
||||
"""CLI入口 - 用于命令行测试"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='KB Workflow - Knowledge Base Workflow Orchestrator',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
使用示例:
|
||||
# 查重
|
||||
python kb_workflow.py --check --url "https://www.bilibili.com/video/BVxxx"
|
||||
|
||||
# 归档
|
||||
python kb_workflow.py --archive "/path/to/analyzed.md"
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('--check', action='store_true', help='查重')
|
||||
parser.add_argument('--url', type=str, help='要检查的URL')
|
||||
parser.add_argument('--archive-file', type=str, help='归档的文件路径')
|
||||
parser.add_argument('--base-path', type=str, help='获取知识库根目录')
|
||||
|
||||
parser.add_argument('--pending', action='store_true', help='获取url解析目录')
|
||||
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('--analyzed', action='store_true', help='获取深度分析目录')
|
||||
parser.add_argument('--archive_dir', action='store_true', help='获取归档目录')
|
||||
parser.add_argument('--archive_subscriptions', action='store_true', help='获取订阅信息目录')
|
||||
parser.add_argument('--extract_platform', type=str, help='要提取平台名称的文件名')
|
||||
args = parser.parse_args()
|
||||
|
||||
base_path = Path(args.base_path) if args.base_path else None
|
||||
dirs = ensure_directories(base_path)
|
||||
|
||||
if args.check and args.url:
|
||||
result = check_duplicate(base_path, args.url)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.archive_file:
|
||||
result = archive_and_git(args.archive_file, base_path)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.pending:
|
||||
print(json.dumps({"path": str(dirs["pending"])}, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.metadata:
|
||||
print(json.dumps({"path": str(dirs["metadata"])}, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.content:
|
||||
print(json.dumps({"path": str(dirs["content"])}, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.content_downloads:
|
||||
print(json.dumps({"path": str(dirs["content_downloads"])}, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.analyzed:
|
||||
print(json.dumps({"path": str(dirs["analyzed"])}, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.archive_dir:
|
||||
print(json.dumps({"path": str(dirs["archive"])}, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.archive_subscriptions:
|
||||
print(json.dumps({"path": str(dirs["archive_subscriptions"])}, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.extract_platform:
|
||||
platform = extract_platform(args.extract_platform)
|
||||
print(json.dumps({"platform": platform}, ensure_ascii=False, indent=2))
|
||||
|
||||
elif args.base_path:
|
||||
print(json.dumps({"path": str(dirs["base"])}, ensure_ascii=False, indent=2))
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
# KB Zhihu Metadata Skill
|
||||
|
||||
**kb-zhihu-metadata** - Extract Zhihu question/answer/user metadata.
|
||||
|
||||
## Features
|
||||
|
||||
- Question metadata extraction
|
||||
- Answer metadata extraction
|
||||
- User information extraction
|
||||
- JSON output format
|
||||
- Markdown conversion support
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Question metadata
|
||||
python zhihu_metadata.py --id "266309957" --type question
|
||||
|
||||
# Answer metadata
|
||||
python zhihu_metadata.py --id "2003427891925509507" --type answer
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"platform": "zhihu",
|
||||
"type": "question",
|
||||
"id": "266309957",
|
||||
"title": "问题标题",
|
||||
"author": "作者名字",
|
||||
"answer_count": 50,
|
||||
"followers": 1000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cp -r kb-zhihu-metadata/ ~/skills/
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
from markdownify import MarkdownConverter as _MarkdownConverter
|
||||
|
||||
class MarkdownConverter:
|
||||
"""HTML 转 Markdown 转换器,保留完整格式"""
|
||||
|
||||
def __init__(self):
|
||||
self.converter = _MarkdownConverter(
|
||||
heading_style="ATX",
|
||||
bullets="*,-",
|
||||
code_blocks=True,
|
||||
tables=True,
|
||||
strip=["script", "style", "nav", "footer", "aside"]
|
||||
)
|
||||
|
||||
def convert(self, html: str) -> str:
|
||||
"""转换 HTML 为 Markdown"""
|
||||
return self.converter.convert(html)
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
知乎 Playwright 内容提取器
|
||||
使用 Playwright 浏览器自动化工具抓取知乎内容
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import os
|
||||
from typing import Dict, Optional, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# 添加当前目录到 Python 路径以支持绝对导入
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
try:
|
||||
from .markdown_converter import MarkdownConverter
|
||||
except ImportError:
|
||||
from markdown_converter import MarkdownConverter
|
||||
|
||||
|
||||
class PlaywrightBrowser:
|
||||
"""Playwright 浏览器管理器"""
|
||||
|
||||
def __init__(self, headless: bool = True, timeout: int = 30):
|
||||
self.headless = headless
|
||||
self.timeout = timeout * 1000
|
||||
self.browser = None
|
||||
self.context = None
|
||||
self.page = None
|
||||
|
||||
def start(self):
|
||||
"""启动浏览器"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
self.playwright = sync_playwright().start()
|
||||
self.browser = self.playwright.chromium.launch(headless=self.headless)
|
||||
self.context = self.browser.new_context(
|
||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
|
||||
)
|
||||
self.page = self.context.new_page()
|
||||
self.page.set_default_timeout(self.timeout)
|
||||
|
||||
def close(self):
|
||||
"""关闭浏览器"""
|
||||
if self.page:
|
||||
self.page.close()
|
||||
if self.context:
|
||||
self.context.close()
|
||||
if self.browser:
|
||||
self.browser.close()
|
||||
if hasattr(self, 'playwright'):
|
||||
self.playwright.stop()
|
||||
|
||||
def goto(self, url: str, wait_until: str = "networkidle"):
|
||||
"""导航到指定URL"""
|
||||
return self.page.goto(url, wait_until=wait_until)
|
||||
|
||||
def wait_for_selector(self, selector: str, timeout: int = None):
|
||||
"""等待选择器出现"""
|
||||
timeout_ms = timeout * 1000 if timeout else self.timeout
|
||||
return self.page.wait_for_selector(selector, timeout=timeout_ms)
|
||||
|
||||
def query_selector(self, selector: str):
|
||||
"""查询选择器"""
|
||||
return self.page.query_selector(selector)
|
||||
|
||||
def query_selector_all(self, selector: str):
|
||||
"""查询所有匹配的选择器"""
|
||||
return self.page.query_selector_all(selector)
|
||||
|
||||
def evaluate(self, script: str):
|
||||
"""执行 JavaScript 代码"""
|
||||
return self.page.evaluate(script)
|
||||
|
||||
|
||||
class ZhihuPlaywrightExtractor:
|
||||
"""知乎内容提取器 - 使用 Playwright"""
|
||||
|
||||
URL_PATTERNS = {
|
||||
'zvideo': r'zhihu\.com/zvideo/(\d+)',
|
||||
'zhuanlan': r'zhuanlan\.zhihu\.com/p/(\d+)',
|
||||
}
|
||||
|
||||
def __init__(self, headless: bool = True, timeout: int = 30):
|
||||
"""
|
||||
初始化提取器
|
||||
|
||||
Args:
|
||||
headless: 是否使用无头浏览器
|
||||
timeout: 超时时间(秒)
|
||||
"""
|
||||
self.browser = PlaywrightBrowser(headless=headless, timeout=timeout)
|
||||
self.markdown_converter = MarkdownConverter()
|
||||
|
||||
def identify_url_type(self, url: str) -> Optional[str]:
|
||||
"""
|
||||
识别 URL 类型
|
||||
|
||||
Returns:
|
||||
'answer', 'article', 'question' 或 None
|
||||
"""
|
||||
for url_type, pattern in self.URL_PATTERNS.items():
|
||||
if re.search(pattern, url):
|
||||
return url_type
|
||||
return None
|
||||
|
||||
def extract_id(self, url: str) -> Optional[str]:
|
||||
"""
|
||||
从 URL 中提取内容 ID
|
||||
|
||||
Returns:
|
||||
内容 ID 或 None
|
||||
"""
|
||||
url_type = self.identify_url_type(url)
|
||||
if url_type:
|
||||
pattern = self.URL_PATTERNS[url_type]
|
||||
match = re.search(pattern, url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def _extract_text_content(self, element) -> str:
|
||||
"""提取元素的文本内容"""
|
||||
if element is None:
|
||||
return ''
|
||||
return element.inner_text() if hasattr(element, 'inner_text') else ''
|
||||
|
||||
def _extract_html_content(self, element) -> str:
|
||||
"""提取元素的 HTML 内容"""
|
||||
if element is None:
|
||||
return ''
|
||||
return element.inner_html() if hasattr(element, 'inner_html') else ''
|
||||
|
||||
def _extract_zvideo_content(self) -> Dict[str, Any]:
|
||||
"""提取知乎视频内容"""
|
||||
result = {
|
||||
'type': 'zvideo',
|
||||
'title': '',
|
||||
'author': '',
|
||||
'content': '',
|
||||
'content_markdown': '',
|
||||
'publish_time': '',
|
||||
'vote_up': 0,
|
||||
}
|
||||
|
||||
# 打印页面标题用于调试
|
||||
page_title = self.browser.page.title()
|
||||
print(f"页面标题: {page_title}")
|
||||
|
||||
# 提取视频标题
|
||||
title_elem = self.browser.query_selector('.VideoPlaylistInfo-title')
|
||||
if title_elem:
|
||||
result['title'] = self._extract_text_content(title_elem)
|
||||
|
||||
# 备用:尝试其他选择器
|
||||
if not result['title']:
|
||||
title_elem = self.browser.query_selector('h1')
|
||||
if title_elem:
|
||||
result['title'] = self._extract_text_content(title_elem)
|
||||
|
||||
# 提取作者信息
|
||||
author_elem = self.browser.query_selector('.AuthorInfo-name')
|
||||
if author_elem:
|
||||
result['author'] = self._extract_text_content(author_elem)
|
||||
|
||||
# 提取视频描述
|
||||
desc_elem = self.browser.query_selector('.Video-desc')
|
||||
if desc_elem:
|
||||
html_content = self._extract_html_content(desc_elem)
|
||||
result['content'] = html_content
|
||||
result['content_markdown'] = self.markdown_converter.convert(html_content)
|
||||
|
||||
# 提取发布时间
|
||||
time_elem = self.browser.query_selector('.Video-meta time')
|
||||
if time_elem:
|
||||
result['publish_time'] = time_elem.get_attribute('datetime') or self._extract_text_content(time_elem)
|
||||
|
||||
# 提取点赞数
|
||||
vote_elem = self.browser.query_selector('.VideoLikeButton-count')
|
||||
if vote_elem:
|
||||
vote_text = self._extract_text_content(vote_elem)
|
||||
try:
|
||||
result['vote_up'] = int(vote_text.replace(',', ''))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def _extract_zhuanlan_content(self) -> Dict[str, Any]:
|
||||
"""提取知乎专栏文章内容"""
|
||||
result = {
|
||||
'type': 'zhuanlan',
|
||||
'title': '',
|
||||
'author': '',
|
||||
'content': '',
|
||||
'content_markdown': '',
|
||||
'publish_time': '',
|
||||
'vote_up': 0,
|
||||
}
|
||||
|
||||
# 打印页面标题用于调试
|
||||
page_title = self.browser.page.title()
|
||||
print(f"页面标题: {page_title}")
|
||||
|
||||
# 提取文章标题
|
||||
title_elem = self.browser.query_selector('.Post-Title')
|
||||
if title_elem:
|
||||
result['title'] = self._extract_text_content(title_elem)
|
||||
|
||||
# 备用
|
||||
if not result['title']:
|
||||
title_elem = self.browser.query_selector('h1')
|
||||
if title_elem:
|
||||
result['title'] = self._extract_text_content(title_elem)
|
||||
|
||||
# 提取作者信息
|
||||
author_elem = self.browser.query_selector('.AuthorInfo-name')
|
||||
if author_elem:
|
||||
result['author'] = self._extract_text_content(author_elem)
|
||||
|
||||
# 提取文章内容
|
||||
content_elem = self.browser.query_selector('.Post-RichText')
|
||||
if content_elem:
|
||||
html_content = self._extract_html_content(content_elem)
|
||||
result['content'] = html_content
|
||||
result['content_markdown'] = self.markdown_converter.convert(html_content)
|
||||
|
||||
# 提取发布时间
|
||||
time_elem = self.browser.query_selector('.ContentItem-time')
|
||||
if time_elem:
|
||||
result['publish_time'] = time_elem.get_attribute('data-tooltip') or self._extract_text_content(time_elem)
|
||||
|
||||
# 提取点赞数
|
||||
vote_elem = self.browser.query_selector('.VoteButton--up .VoteButton-label')
|
||||
if vote_elem:
|
||||
vote_text = self._extract_text_content(vote_elem)
|
||||
try:
|
||||
result['vote_up'] = int(vote_text.replace(',', ''))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def extract(self, url: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
从 URL 提取知乎内容
|
||||
|
||||
Args:
|
||||
url: 知乎 URL
|
||||
|
||||
Returns:
|
||||
包含提取内容的字典,结构如下:
|
||||
{
|
||||
'type': 'zvideo' | 'zhuanlan',
|
||||
'title': str,
|
||||
'author': str,
|
||||
'content': str (HTML),
|
||||
'content_markdown': str (Markdown),
|
||||
'publish_time': str,
|
||||
'vote_up': int,
|
||||
}
|
||||
如果提取失败返回 None
|
||||
"""
|
||||
try:
|
||||
# 识别 URL 类型
|
||||
url_type = self.identify_url_type(url)
|
||||
if not url_type:
|
||||
print(f"无法识别 URL 类型: {url}")
|
||||
return None
|
||||
|
||||
print(f"URL 类型: {url_type}")
|
||||
print(f"启动浏览器...")
|
||||
|
||||
# 启动浏览器
|
||||
self.browser.start()
|
||||
|
||||
print(f"访问页面: {url}")
|
||||
# 访问页面
|
||||
self.browser.goto(url, wait_until="domcontentloaded")
|
||||
|
||||
print("等待页面加载...")
|
||||
import time
|
||||
time.sleep(3) # 等待 JavaScript 执行
|
||||
|
||||
# 根据类型提取内容
|
||||
if url_type == 'zvideo':
|
||||
result = self._extract_zvideo_content()
|
||||
elif url_type == 'zhuanlan':
|
||||
result = self._extract_zhuanlan_content()
|
||||
else:
|
||||
print(f"不支持的类型: {url_type}")
|
||||
return None
|
||||
|
||||
# 添加额外信息
|
||||
result['url'] = url
|
||||
result['content_id'] = self.extract_id(url)
|
||||
|
||||
print(f"提取成功! 标题: {result.get('title', '')[:50]}...")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"提取知乎内容时出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
finally:
|
||||
# 关闭浏览器
|
||||
self.browser.close()
|
||||
|
||||
def __enter__(self):
|
||||
"""上下文管理器入口"""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""上下文管理器出口"""
|
||||
self.browser.close()
|
||||
return False
|
||||
|
||||
|
||||
# 便捷函数
|
||||
def extract_zhihu_content(url: str, headless: bool = True, timeout: int = 30) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
便捷函数:提取知乎内容
|
||||
|
||||
Args:
|
||||
url: 知乎 URL
|
||||
headless: 是否使用无头浏览器
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
提取的内容字典
|
||||
"""
|
||||
with ZhihuPlaywrightExtractor(headless=headless, timeout=timeout) as extractor:
|
||||
return extractor.extract(url)
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
知乎元数据获取工具
|
||||
|
||||
仅支持两种内容类型:
|
||||
- zvideo: 知乎视频 (https://www.zhihu.com/zvideo/xxx)
|
||||
- zhuanlan: 知乎专栏文章 (https://zhuanlan.zhihu.com/p/xxx)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
# 添加当前目录到 Python 路径
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
from zhihu_extractor import ZhihuPlaywrightExtractor
|
||||
|
||||
|
||||
def extract_zvideo(video_id: str, headless: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
获取知乎视频元数据
|
||||
|
||||
Args:
|
||||
video_id: 视频 ID (数字字符串)
|
||||
headless: 是否使用无头浏览器
|
||||
|
||||
Returns:
|
||||
视频元数据字典
|
||||
"""
|
||||
url = f"https://www.zhihu.com/zvideo/{video_id}"
|
||||
return _extract_metadata(url, "zvideo", headless)
|
||||
|
||||
|
||||
def extract_zhuanlan(article_id: str, headless: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
获取知乎专栏文章元数据
|
||||
|
||||
Args:
|
||||
article_id: 文章 ID (数字字符串)
|
||||
headless: 是否使用无头浏览器
|
||||
|
||||
Returns:
|
||||
文章元数据字典
|
||||
"""
|
||||
url = f"https://zhuanlan.zhihu.com/p/{article_id}"
|
||||
return _extract_metadata(url, "zhuanlan", headless)
|
||||
|
||||
|
||||
def _extract_metadata(url: str, content_type: str, headless: bool) -> Dict[str, Any]:
|
||||
"""
|
||||
内部函数:提取元数据
|
||||
|
||||
Args:
|
||||
url: 知乎 URL
|
||||
content_type: 内容类型 (zvideo/zhuanlan)
|
||||
headless: 是否使用无头浏览器
|
||||
|
||||
Returns:
|
||||
元数据字典
|
||||
"""
|
||||
extractor = ZhihuPlaywrightExtractor(headless=headless)
|
||||
result = extractor.extract(url)
|
||||
|
||||
if result:
|
||||
# 标准化输出格式
|
||||
metadata = {
|
||||
"type": content_type,
|
||||
"id": result.get("content_id", ""),
|
||||
"url": result.get("url", url),
|
||||
"title": result.get("title", ""),
|
||||
"author": result.get("author", ""),
|
||||
"publish_time": result.get("publish_time", ""),
|
||||
"vote_up": result.get("vote_up", 0),
|
||||
"content_markdown": result.get("content_markdown", ""),
|
||||
"raw_html": result.get("content", ""),
|
||||
}
|
||||
return metadata
|
||||
else:
|
||||
return {
|
||||
"type": content_type,
|
||||
"error": "Failed to extract content",
|
||||
"url": url
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""命令行入口"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="知乎元数据获取工具",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
支持的类型:
|
||||
zvideo - 知乎视频 (https://www.zhihu.com/zvideo/123456)
|
||||
zhuanlan - 知乎专栏文章 (https://zhuanlan.zhihu.com/p/123456)
|
||||
|
||||
示例:
|
||||
python zhihu_metadata.py --id "123456" --type zvideo
|
||||
python zhihu_metadata.py --id "123456" --type zhuanlan
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--id", "-i",
|
||||
required=True,
|
||||
help="知乎内容 ID (视频ID或文章ID)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--type", "-t",
|
||||
required=True,
|
||||
choices=["zvideo", "zhuanlan"],
|
||||
help="内容类型: zvideo=视频, zhuanlan=专栏文章"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
default=None,
|
||||
help="输出文件路径 (默认: stdout)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="使用无头浏览器模式 (默认: True)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 提取元数据
|
||||
if args.type == "zvideo":
|
||||
result = extract_zvideo(args.id, headless=args.headless)
|
||||
else: # zhuanlan
|
||||
result = extract_zhuanlan(args.id, headless=args.headless)
|
||||
|
||||
# 输出结果
|
||||
json_output = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
f.write(json_output)
|
||||
print(f"结果已保存到: {args.output}")
|
||||
else:
|
||||
print(json_output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user